Skip to content

fix: give the HyperFrames capture process the Distribution resolver its parent has - #209

Merged
rponeawa merged 1 commit into
hypit-ai:mainfrom
daodaobing:fix/capture-distribution-resolver
Sep 16, 2026
Merged

rponeawa merged 1 commit into
hypit-ai:mainfrom
daodaobing:fix/capture-distribution-resolver

Conversation

@daodaobing

Copy link
Copy Markdown
Contributor

Closes #207

What this fixes

bin/hypit.mjs installs two resolution hooks with module.registerHooks. Those hooks are process-local, and capture-process.ts starts its render child with Node directly:

spawn(process.execPath, ["--import", import.meta.resolve("tsx"), fileURLToPath(entry)], )

That child therefore saw tsx but neither @hypit/* nor the machine packages the Distribution's own modules declare. The child's runtime graph needs both — render.ts imports @hypit/hyperframes/project, @hypit/media, @hypit/media-execution, @hypit/render-hyperframes and @hypit/runtime, and opaque-capture.ts imports @hyperframes/engine.

A contributor checkout hides this completely: pnpm links every workspace package into each package's own node_modules, so plain Node resolution succeeds and the bug is invisible. A packed Distribution ships packages/*/src/**/* with zero node_modules entries and zero symlinks (verified against npm run pack:distribution's tarball), so local rendering failed there with

x Render failed
  Cannot find package '@hypit/hyperframes'

The change

Preload a 5-line bootstrap that installs the same two resolvers the launcher installs, resolving the Distribution root from HYPIT_DISTRIBUTION_ROOT (published by bin/hypit.mjs:23 and inherited) with the same import.meta.dirname-relative fallback that packages/video-cli/src/distribution.ts:17 already uses. No new environment variable, no NODE_PATH, no junction, no node_modules patch, no hard-coded path.

spawn(process.execPath, [
  "--import", import.meta.resolve("tsx"),
  "--import", new URL("./capture-bootstrap.ts", import.meta.url).href,
  fileURLToPath(entry),
], {})
// packages/provider-hyperframes-local/src/capture-bootstrap.ts
const distributionRoot = resolve(
  process.env.HYPIT_DISTRIBUTION_ROOT ?? resolve(import.meta.dirname, "../../.."),
);
const { installDistributionPackageResolution, installExternalPackageResolution } =
  await import("../../package-loader-node/src/distribution-resolution.js");
installDistributionPackageResolution([distributionRoot]);
const { hypitHostPackageRoot } = await import("../../runtime-host-node/src/index.js");
installExternalPackageResolution([hypitHostPackageRoot()]);

Why the bootstrap lives in the provider

packages/package-loader-node owns installDistributionPackageResolution, but it cannot import hypitHostPackageRoot from @hypit/runtime-host-node — that package already depends on package-loader-node, so it would be a dependency cycle. provider-hyperframes-local already declares both packages, so the bootstrap sits next to the spawn it explains. Only relative specifiers can be used, since the module is evaluated before any hook exists.

Happy to move it if maintainers prefer another home — the constraint is just "somewhere that can see both packages without a cycle".

Relationship to the existing pattern

packages/video-cli/src/distribution.ts:58-68 already solves this class of problem for the Runtime Worker by re-entering the launcher (workerLaunch.args = [installedLauncher]). This is the same idea applied to the one child that does not go through the launcher. I did not extract a shared helper because the two call sites do not share a fixed relative depth, and the duplication is 5 lines.

Test evidence

New case in packages/provider-hyperframes-local/test/capture-process.test.ts. It starts outside the checkout, where nothing above the entry declares @hypit/hyperframes, and has three arms:

  1. Control — same entry, no preload → ERR_MODULE_NOT_FOUND.
  2. Same entry with the preload → resolves, exports include stageHyperframesProject.
  3. runCaptureProcess itself, proving the entry point passes the preload.
$ node --import tsx --test packages/provider-hyperframes-local/test/capture-process.test.ts
ok 1 - a stuck renderer and its detached child both stop before the call rejects
ok 2 - a capture process resolves Distribution packages without workspace links
ok 3 - renderer stdout and stderr diagnostics are drained before reporting success
# tests 3 · pass 3 · fail 0

It fails on the unmodified spawn, with the production error verbatim:

$ git stash push -- packages/provider-hyperframes-local/src/capture-process.ts
$ node --import tsx --test …capture-process.test.ts
not ok 2 - a capture process resolves Distribution packages without workspace links
    HyperFrames process exited before completion:
    Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@hypit/hyperframes' imported from
      <tmp>/hypit-capture-resolution-…/worker.mjs
$ git stash pop

End-to-end acceptance on a real packed Distribution

$ npm run pack:distribution                     →  dist/release/hypit-hypit-0.1.9.tgz (1041 entries)
$ npm install ./hypit-hypit-0.1.9.tgz           →  isolated project dir, no link to the checkout
$ hypit check productions/hello-world/chat.svml →  valid
$ hypit plan  productions/hello-world/chat.svrun --runtime …/hello.runtime.json
    Local requests 3 · media.local / hyperframes.local · local, no Provider charge
$ hypit build productions/hello-world/chat.svrun --runtime …/hello.runtime.json --follow
+ Build complete   (240/240 frames)

Exported MP4: mov,mp4, 2 streams, h264 High 540x960 30/1 240 frames 8.000 s + aac LC 48000 Hz stereo 8.000 s, full ffmpeg -f null - decode with zero errors.

Preloaded resolution inside that install (probe placed where opaque-capture.ts lives, cwd = the project, NODE_PATH unset, HYPIT_DISTRIBUTION_ROOT unset):

@hypit/hyperframes/project  -> file:///<project>/node_modules/@hypit/hypit/packages/hyperframes/src/project.ts
@hyperframes/engine         -> file:///<HYPIT_STATE_HOME>/packages/@hyperframes/engine/0.7.101/node_modules/@hyperframes/engine/dist/index.js

Zero occurrences of the checkout path anywhere in the installed Distribution, and the only symlink under the install is npm's own project-local example package. So this is genuinely running the packaged artifact, not falling back to a source tree.

Verification

  • pnpm check (repo-wide tsc --noEmit) — passes on this branch.
  • Full provider suite: node --import tsx --test packages/provider-hyperframes-local/test/*.test.ts → 19 tests, 15 pass, 0 fail, 4 skipped (skips are behind the browser-test opt-in flag).
  • pnpm test — unchanged; the single failure is the pre-existing Windows EPERM symlink case in compiler-node, same as on pristine main.

This PR is independent of the optionalDependencies font fix (#206); the two touch disjoint files and I verified each branch against pristine main separately.

resolve hooks registered by bin/hypit.mjs are process-local, so the HyperFrames
capture child, which Node starts directly, saw neither @hypit/* nor the machine
packages those modules declare. Only a contributor checkout hid this: pnpm links
each workspace package into its own node_modules, while a packed Distribution
ships none, so local rendering failed there with ERR_MODULE_NOT_FOUND on
@hypit/hyperframes before it reached @hyperframes/engine.

Preload the same resolution environment in that child and cover it with a test
that starts outside the workspace.
@rponeawa

Copy link
Copy Markdown
Member

Thank you for the report and the fix.

Reproduced on the published 0.1.10 distribution. It ships packages/hyperframes with a ./project export and no node_modules/@hypit anywhere, so a child started without the resolver has nothing to fall back on:

node --import tsx probe.mjs        # probe imports "@hypit/hyperframes/project"
  Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@hypit/hyperframes'

Adding capture-bootstrap.ts to that same distribution and preloading it resolves the import:

without the preload -> Cannot find package '@hypit/hyperframes'
with the preload    -> resolved: stageHyperframesProject

On the relative cross-package imports: provider-hyperframes-local already declares both @hypit/package-loader-node and @hypit/runtime-host-node, so this reaches packages the manifest names, and reaching them by path is what installing the name resolver requires. bin/hypit.mjs meets the same constraint the same way, and the file comment records why. Taking it as written.

Passing the preload as a file: URL keeps the Windows path handling consistent with how tsx is already passed.

Linux and Windows checks pass. Merging.

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

Labels

None yet

Projects

None yet

2 participants