Skip to content

npm distribution: local HyperFrames rendering fails with Cannot find package '@hypit/hyperframes' - the capture child process is spawned without the Distribution resolver hooks #207

Description

@daodaobing

Environment

Hypit @hypit/hypit 0.1.8 (npm install); also present on current main = v0.1.9 (2f2bb329)
OS Windows 11 (10.0.26200.9445)
Node v22.22.2
npm 10.9.7
ffmpeg / ffprobe 8.1.1
Chrome C:\Program Files\Google\Chrome\Application\chrome.exe
Runtime Profile only media.local + hyperframes.local, no managed endpoints
Machine package root HYPIT_STATE_HOME set

Reproduction

Same Source that renders fine from a source checkout:

$ hypit check productions/hello-world/chat.svml                      # OK
$ hypit plan  productions/hello-world/chat.svrun                     # OK · 3 requests · all local · no Provider charge
$ hypit build productions/hello-world/chat.svrun \
      --runtime productions/hello-world/hello.runtime.json --follow

Progress then fails:

· Working · 1/3 steps complete · 1 staging / starting browsers / rendering frames   # visual render starts
x Render failed
  Cannot find package '@hypit/hyperframes'

Worth noting the order: Cannot find package '@hyperframes/engine' is reported first
(it goes away once the package is installed into the machine npm root), then
Cannot find package '@hypit/hyperframes' appears — and that one cannot be installed,
because @hypit/hyperframes is not published on npm.

Expected

The same Source completes local visual render → audio → mux → MP4 under the npm distribution,
with plan still reporting no Provider charge.

Actual

The local render child process never starts. build fails at the visual-render stage.
(Audio and muxing stages do work.)

Root cause

1. The resolver hooks live in the parent process; rendering happens in a child process

bin/hypit.mjs:

 24  register();                                               // tsx: allow importing TS sources
 29  installDistributionPackageResolution([distributionRoot]);   // resolver for @hypit/*
 31  installExternalPackageResolution([hypitHostPackageRoot()]);  // machine-npm fallback

packages/package-loader-node/src/distribution-resolution.ts:87 registers these via
module.registerHooks(...). module.registerHooks is process-local and synchronous
a brand-new Node process started with child_process.spawn does not inherit it (Node has no
mechanism for passing hooks through spawn).

2. The render child process happens to not go through the launcher

packages/provider-hyperframes-local/src/capture-process.ts:

const child = spawn(process.execPath, ["--import", import.meta.resolve("tsx"), fileURLToPath(entry)], {
  detached: process.platform !== "win32", windowsHide: true,
  stdio: ["ignore", "pipe", "pipe", "ipc"],
});

--import tsx installs TypeScript support only — no Hypit resolver hooks. That child is
left with nothing but Node's native "walk up looking for node_modules".

3. The child's dependency graph has runtime @hypit/* imports

capture-worker.ts
  └─ import { captureStagedVisual } from "./capture.js"
       └─ capture.ts:10  import { renderWorkerLimit } from "./render.js"     <== runtime import, not type-only
            └─ render.ts:6   import { stageHyperframesProject } from "@hypit/hyperframes/project"
               render.ts:7   import { sealRenderedVisual } from "@hypit/media"
               render.ts:9   import { verifyCompositableSurfaceBytes } from "@hypit/media-execution"
               render.ts:10  import { verifyHyperframesVisualRequest } from "@hypit/render-hyperframes"
               render.ts:12  import { isStreamingResourceStore } from "@hypit/runtime"

(capture.ts:3,4 and output.ts:1 import @hypit/hyperframes as import type, which is erased.
The fatal one is capture.ts:10, a runtime import that drags render.ts into the child graph.)

4. So "works from source, fails from npm" reduces to node_modules symlinks

Resolution start point import('@hypit/hyperframes/project')
source <repo>/packages/provider-hyperframes-local/ RESOLVED ok · exports = stageHyperframesProject
npm distribution .../node_modules/@hypit/hypit/packages/provider-hyperframes-local/ FAILED ERR_MODULE_NOT_FOUND: Cannot find package '@hypit/hyperframes'

Why:

# source: pnpm symlinks workspace deps into each package's own node_modules
packages/provider-hyperframes-local/node_modules/@hypit/
  hyperframes -> <repo>/packages/hyperframes              (13 @hypit/* symlinks)
packages/provider-hyperframes-local/node_modules/@hyperframes/
  engine   -> .../@hyperframes+engine@0.7.101/...
  producer -> .../@hyperframes+producer@0.7.101/...

# npm distribution: no node_modules inside the embedded package dirs at all
MISSING  <project>/node_modules/@hypit/hypit/packages/provider-hyperframes-local/node_modules
         <project>/node_modules/@hypit/  ->  contains only `hypit`

Extra measurement (import.meta.resolve in the same process, before vs after registering hooks):

=== before hooks (== child process situation) ===
  before @hyperframes/engine  -> FAILED ERR_MODULE_NOT_FOUND
  before @hypit/hyperframes   -> FAILED ERR_MODULE_NOT_FOUND
=== after hooks (== parent process situation) ===
  after  @hyperframes/engine  -> file:///<HYPIT_STATE_HOME>/packages/@hyperframes/engine/0.7.101/...
  after  @hypit/hyperframes   -> file:///<project>/node_modules/@hypit/hypit/packages/hyperframes/...

In short: the parent is fine; the child is simply missing its copy of the hooks.

Classification

A packaging bug with a spawn-environment bug on top.

  • packaging: package.json files packs packages/*/src/**/* and packages/*/package.json
    into the tarball, but nothing gives those embedded packages a node_modules resolution
    context. @hypit/* belongs to the Distribution and is owned by the hooks — and the hooks
    only exist in the parent.
  • spawn: capture-process.ts starts the child with tsx but without a bootstrap module that
    installs the resolver hooks. That implicitly requires the runtime to already have symlinks,
    which is only true in a pnpm/workspace checkout.
  • docs: the local-rendering docs target the source checkout (node bin/hypit.mjs +
    pnpm build:public-types), while the README Quickstart also offers npx @hypit/hypit.
    Under that documented consumption path, local rendering cannot work by construction —
    the published shape and the docs disagree.

Suggested fix (option A, recommended)

Give the child process the hooks too. Add a tiny internal bootstrap module, e.g.
packages/package-loader-node/src/child-bootstrap.ts:

import { installDistributionPackageResolution, installExternalPackageResolution } from "./distribution-resolution.js";
import { hypitHostPackageRoot } from "@hypit/runtime-host-node";

and change the spawn args to:

spawn(process.execPath, [
  "--import", import.meta.resolve("tsx"),
  "--import", import.meta.resolve("@hypit/package-loader-node/child-bootstrap"),  // <== new
  fileURLToPath(entry),
], { /* unchanged */ });

The hooks locate the Distribution root via HYPIT_DISTRIBUTION_ROOT
(bin/hypit.mjs:23 already exports it into process.env, so the child inherits it) —
no new protocol needed.

Note: in a real fix the bootstrap module has to live somewhere that can depend on both
package-loader-node and runtime-host-node; package-loader-node itself cannot, because
runtime-host-node already depends on it (cycle). Placing it in
provider-hyperframes-local/src/ is the smallest change that compiles today.

Alternatives considered:

  • Option B (weaker): move pure helpers like renderWorkerLimit out of render.ts so
    capture.ts stops pulling it in. This only postpones the problem — the child must
    eventually execute Distribution code.
  • Option C (release-side): make embedded packages genuinely resolvable (e.g. generate a
    runtime resolution map for packages/* in bin/hypit.mjs). Larger blast radius than A.

Option A turns "Distribution resolution" from a per-process fact into part of the child-process
launch protocol — the same idea already used by
workerLaunch.args = [installedLauncher] (packages/video-cli/src/distribution.ts:60-68),
which solves the identical problem by re-entering the launcher. This spawn site was missed.

Status on my side

I have implemented option A locally and verified it end to end against a real packed
tarball
(npm run pack:distributionhypit-hypit-0.1.9.tgz, 1041 entries, 0 node_modules,
0 symlinks) installed into an isolated directory:

Stage Result
check ✅ valid
plan --runtime ✅ 3 requests / 3 local / no Provider charge ×3
build --follow 240/240 frames
MP4 h264 High 540×960 30fps 240 frames + aac stereo, zero decode errors
Depends on source tree no — zero references to the checkout inside the install, NODE_PATH unset
Regression test new case not ok before the fix (byte-identical error) / ok after

A PR follows. Happy to move the bootstrap module wherever maintainers prefer.

Notes

  • Evidence comes from read-only investigation plus an isolated A/B child process; no installed
    files were modified.
  • Independent of the optionalDependencies font-resolution issue. Fixing either does not fix
    the other.
  • The source-checkout path is not blocked by this bug: fully-local $0 rendering works there
    (measured: 240 frames → MP4). Only the npm consumption path is blocked.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions