Skip to content

feat(components): stream package_component instead of base64 JSON - #2152

Open
dawsontoth wants to merge 2 commits into
mainfrom
claude/package-component-stream
Open

feat(components): stream package_component instead of base64 JSON#2152
dawsontoth wants to merge 2 commits into
mainfrom
claude/package-component-stream

Conversation

@dawsontoth

Copy link
Copy Markdown
Contributor

Closes #2150.

package_component returned 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:

const payload = (await packageDirectory(pathToProject, req)).toString('base64');
return { project, payload };

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.concat the 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') throws ERR_STRING_TOO_LONG once the result exceeds V8's string cap:

$ node -e "const b=require('buffer'); console.log(b.constants.MAX_STRING_LENGTH)"
536870888        # 512 MiB → ~384 MiB of gzipped tar is the ceiling

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: true returns the packer's Readable with download headers attached. Nothing new had to be built:

  • streamPackagedDirectory() (components/packageComponent.ts:115) already returns a tar-fs → gzip Readable, 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:172 already pipes any returned Readable carrying a headers Map, and honours preCompressed so an already-gzipped body isn't compressed twice.
  • handleGetDeploymentPayload() and get_backup are the existing precedents on that path.

Memory is constant on both ends, nothing is staged to disk, and there is no size ceiling.

estimate: true returns { project, total_size, dangling_symlinks } from a single scanPackageDirectory() 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 own get_components tree 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 package alias packages locally for deploy (bin/cliOperations.ts:14) and never reads a remote payload.

Notes for review

  • estimate is checked before stream and wins if both are set. They are deliberately not declared as Joi exclusive peers: oxor conflicts on presence, so it would reject a caller that always sends both fields with one set to false.
  • content-disposition interpolates project, which is already narrowed by path.parse().name and validated against PROJECT_FILE_NAME_REGEX before it gets there, so it can't inject header content.
  • The project-resolution guard is a drive-by fix with a real reason to be in this PR: the node_modules fallback's catch only rethrows ENOENT, so any other realpath failure left pathToProject undefined 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 whose node_modules is a file) and fails without the guard.
  • A mid-walk failure after headers are committed still truncates the response — inherent to streaming, and the same behaviour get_backup/get_deployment_payload already have.
  • Fabric Connect can't carry this. Central Manager's proxy is message-passing, not an HTTP reverse proxy, and caps bodies at 2 MB ("Fabric Connect body size must be less than 2MB. Utilize Direct Connect for larger payloads."). A streamed download needs a direct Bearer connection; that's a Studio-side concern, noted on studio#1591.

Testing

New unitTests/components/packageComponentOperation.test.js — 11 tests against a real temporary components root, covering all three response shapes, skip_node_modules on 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 by unitTests/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

]

`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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, good call.

🤖 Reviewed with Codex

Comment thread components/operations.js Outdated
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>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great
🤖 Reviewed with Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

package_component: stream the tarball instead of returning it as base64 JSON

3 participants