feat(components): stream package_component instead of base64 JSON - #2152
feat(components): stream package_component instead of base64 JSON#2152dawsontoth wants to merge 2 commits into
Conversation
] `package_component` returned the whole component tarball as base64 inside the JSON envelope. For an archive of N bytes that peaks at ~4.7N resident in the shared Harper process — 2N to concat the gzip chunks, +1.33N for the base64 string, +1.33N again when Fastify serializes the envelope — and it hard-fails above ~384 MiB of compressed output, where base64 exceeds V8's 512 MiB string cap and `.toString('base64')` throws ERR_STRING_TOO_LONG. Add `stream: true`, which returns the packer's Readable with download headers attached. Both halves already existed: `streamPackagedDirectory()` was written for exactly this reason ("avoiding the Node.js 2GB Buffer cap that the buffered variant runs into"), and serverHandlers.js already pipes any returned Readable carrying a `headers` Map — the same path `get_deployment_payload` and `get_backup` take. Memory is constant on both ends and there is no size ceiling. The base64 shape stays the default so existing callers are untouched. Also add `estimate: true`, which returns `{ total_size, dangling_symlinks }` from a single `scanPackageDirectory()` walk without packaging anything, so a caller can decide whether the download is worth starting. `estimate` is checked first and wins if both are set; the two are deliberately not Joi exclusive peers, since `oxor` conflicts on presence and would reject a caller that always sends both fields with one false. Guard the project resolution while here: the node_modules fallback's catch only rethrows ENOENT, so any other realpath failure left `pathToProject` undefined and walked it anyway. On the stream path the response headers are committed before the walk runs, so that surfaced as a 200 with a truncated body instead of an error the caller could act on. Studio's side of this is HarperFast/studio#1591. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces streaming and estimation capabilities to the packageComponent operation, allowing clients to stream packaged component directories as raw gzip bytes or request a size and symlink estimate instead of receiving a base64-encoded payload. It also adds corresponding Joi validation schema updates and comprehensive unit tests covering these new response shapes and error handling behaviors. The review comment was removed because it incorrectly suggests asserting a specific system error code (ENOTDIR) that is swallowed by the implementation, which instead throws a generic 'Unable to locate project' error; therefore, we have no additional feedback to provide.
|
Reviewed; no blockers found. |
kriszyp
left a comment
There was a problem hiding this comment.
Nice, good call.
🤖 Reviewed with Codex
Review feedback (kriszyp): the post-hoc `if (!pathToProject)` guard fixed the symptom by flattening every swallowed error into "Unable to locate project", which loses the original code, path, and cause — a broken installation (ENOTDIR) or a permissions problem (EACCES) would send an operator looking for a missing project instead of the real fault. Fix the invariant at its source instead: the node_modules fallback's catch now throws the friendly message only for ENOENT and rethrows everything else. That makes pathToProject guaranteed on the success path, so the fallback guard and its comment are gone. The regression test now asserts the ENOTDIR code survives and that the message is *not* the missing-project one; it fails without the rethrow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ab1ce92 to
8e693c1
Compare
Closes #2150.
package_componentreturned the whole component tarball as base64 inside the JSON envelope. This adds a streamed shape, which is what the operation should have been doing all along — the machinery was already in the repo, the handler just wasn't using it.The problem
components/operations.js:354:For an archive of N bytes that peaks at roughly 4.7N resident in the shared (on Fabric, multi-tenant) Harper process: 2N to accumulate and
Buffer.concatthe gzip chunks, +1.33N for the base64 string, +1.33N again when Fastify serializes the envelope.And it has a hard ceiling, not just pressure —
.toString('base64')throwsERR_STRING_TOO_LONGonce the result exceeds V8's string cap:The browser pays the same tax again, which is the downstream symptom in HarperFast/studio#1591: bytes transfer fine, then Chrome kills the renderer while decoding.
The change
stream: truereturns the packer'sReadablewith download headers attached. Nothing new had to be built:streamPackagedDirectory()(components/packageComponent.ts:115) already returns a tar-fs → gzipReadable, and its docstring says it exists to avoid "the Node.js 2GB Buffer cap that the buffered variant runs into for large components". The handler simply called the buffered variant instead.serverHandlers.js:172already pipes any returnedReadablecarrying aheadersMap, and honourspreCompressedso an already-gzipped body isn't compressed twice.handleGetDeploymentPayload()andget_backupare the existing precedents on that path.Memory is constant on both ends, nothing is staged to disk, and there is no size ceiling.
estimate: truereturns{ project, total_size, dangling_symlinks }from a singlescanPackageDirectory()walk without packaging anything, so a caller can decide whether the download is worth starting. Studio wants this for the include-node_modules case, where its ownget_componentstree gives it no size to work from.The base64 shape stays the default, so every existing caller is untouched. The remote operation's only real consumer is Studio — the CLI's
packagealias packages locally for deploy (bin/cliOperations.ts:14) and never reads a remotepayload.Notes for review
estimateis checked beforestreamand wins if both are set. They are deliberately not declared as Joi exclusive peers:oxorconflicts on presence, so it would reject a caller that always sends both fields with one set tofalse.content-dispositioninterpolatesproject, which is already narrowed bypath.parse().nameand validated againstPROJECT_FILE_NAME_REGEXbefore it gets there, so it can't inject header content.catchonly rethrows ENOENT, so any other realpath failure leftpathToProjectundefined and walked it anyway. That was survivable when the response was buffered; on the stream path headers are committed before the walk runs, so it would surface as a 200 with a truncated body. Its test drives a real ENOTDIR (a rootPath whosenode_modulesis a file) and fails without the guard.get_backup/get_deployment_payloadalready have.Testing
New
unitTests/components/packageComponentOperation.test.js— 11 tests against a real temporary components root, covering all three response shapes,skip_node_moduleson the stream path, a full stream → gunzip → tar-extract round trip back to the source tree, the estimate totals with and without node_modules, and both resolution failures. The transport half is already covered byunitTests/server/serverHelpers/serverHandlers.test.js, so this stops at the handler's return value.Ran green locally alongside every other suite that imports the changed modules —
packageComponent,envOperations,deploymentOperations,serverHandlers,fastifyRoutes/operations— 94 passing.🤖 Generated with Claude Code