Skip to content

Every bundled @fontsource* font is unreachable from the npm distribution: the machine-npm fallback reads only dependencies, not optionalDependencies #206

Description

@daodaobing

Environment

Hypit @hypit/hypit 0.1.8 (npm install); also present on current main = v0.1.9 (2f2bb329)
Install method npm install @hypit/hypit (not a source checkout)
OS Windows 11 (10.0.26200.9445)
Node v22.22.2
npm 10.9.7
Machine package root HYPIT_STATE_HOME is set; machine packages live under <HYPIT_STATE_HOME>/packages

Reproduction

Author a minimal local Source that uses one of the built-in fonts (no Provider, no network), then:

# 1. check
$ hypit check productions/hello-world/chat.svml

x Source is invalid
  @fontsource-variable/inter is needed by this authored font. Install it once with:
  hypit packages install @fontsource-variable/inter@5.3.0

# 2. do exactly what it says
$ hypit packages install @fontsource-variable/inter@5.3.0
$ hypit packages status @fontsource-variable/inter@5.3.0
  ... Ready true          # the CLI agrees the package is on disk

# 3. check again -- same error
$ hypit check productions/hello-world/chat.svml
  @fontsource-variable/inter is needed by this authored font. Install it once with:
  hypit packages install @fontsource-variable/inter@5.3.0

The package really is in the right place:

EXISTS  <HYPIT_STATE_HOME>/packages/@fontsource-variable/inter/5.3.0/node_modules/@fontsource-variable/inter/package.json

So the CLI's own repair instruction can never fix it: install → reported Ready → same error.

Expected

After hypit packages install @fontsource-variable/inter@5.3.0, hypit check passes.

Actual

hypit check keeps reporting the same error, forever. All 109 bundled fonts
(69 × @fontsource-variable/* + 39 × @fontsource/* + @infolektuell/noto-color-emoji)
are unreachable this way.

Root cause

packages/package-loader-node/src/location.ts:

 79  export function declaredExternalPackageRoot(root: string, from: string | URL, name: string): string | undefined {
 80    let cursor = dirname(fromPath(from));
 81    while (true) {
 82      const manifest = join(cursor, "package.json");
 83      if (existsSync(manifest)) {
 84        const value = JSON.parse(readFileSync(manifest, "utf8")) as { dependencies?: Record<string, string> };
 85        const version = value.dependencies?.[name];          // <== dependencies only
 86        return version === undefined ? undefined : externalPackageInstallRoot(root, name, version);
 87      }
 88      const parent = dirname(cursor);
 89      if (parent === cursor) return undefined;
 90      cursor = parent;
 91    }
 92  }

packages/fonts-open/package.json declares all 109 fonts under optionalDependencies
dependencies is empty. So declaredExternalPackageRoot always returns undefined.

Call chain:

hypit check <source.svml>
  └─ fonts-open/src/surface.ts:26
       resolveNodePackageResource(packageName, path, { from: import.meta.url })
     └─ location.ts:243 resolveNodePackageResource
        └─ location.ts:188 locateNodePackage
           ├─ L194 ancestorPackage(...)           # 1. plain node_modules walk upward
           ├─ L206 nearby !== undefined → return   # source checkout returns here
           └─ L211-219 allowExternal branch
                └─ L215 declaredExternalPackageRoot(...)  # 2. machine-npm version pick
                   └─ L85 reads dependencies only → undefined
        └─ L220 throw new Error(`cannot locate installed package ${name}`)
  └─ fonts-open/src/surface.ts:30 rewraps as "… is needed by this authored font …"

Measured on both layouts (calling the exported functions directly):

Resolution start point declaredExternalPackageRoot(root, from, "@fontsource-variable/inter") locateNodePackage(...)
npm distribution .../node_modules/@hypit/hypit/packages/fonts-open/src/surface.ts undefinedbug FAILED: cannot locate installed package @fontsource-variable/inter
source checkout <repo>/packages/fonts-open/src/surface.ts undefined (same bug, masked) LOCATED <repo>/node_modules/.pnpm/@fontsource-variable+inter@5.3.0/…

Why the source checkout is unaffected: pnpm install symlinks optionalDependencies into
packages/fonts-open/node_modules/ (69 @fontsource-variable/* symlinks measured), so
ancestorPackage hits at L206 and never reaches the broken L215 branch. In the npm
distribution there is no node_modules under the embedded package directory at all
(53 packages installed there, zero fonts), so L215 is the only path left.

Suggested minimal fix

Touch only L84-85:

-      const value = JSON.parse(readFileSync(manifest, "utf8")) as { dependencies?: Record<string, string> };
-      const version = value.dependencies?.[name];
+      const value = JSON.parse(readFileSync(manifest, "utf8")) as {
+        dependencies?: Record<string, string>;
+        optionalDependencies?: Record<string, string>;
+      };
+      const version = value.dependencies?.[name] ?? value.optionalDependencies?.[name];

Measured before/after on this machine:

before: declaredExternalPackageRoot(...) -> undefined
        locateNodePackage(...)           -> FAILED: cannot locate installed package
after:  declaredExternalPackageRoot(...) -> <HYPIT_STATE_HOME>/packages/@fontsource-variable/inter/5.3.0
        locateNodePackage(...)           -> LOCATED <HYPIT_STATE_HOME>/packages/@fontsource-variable/inter/5.3.0/node_modules/@fontsource-variable/inter

Regression test

packages/package-loader-node/test/location.test.ts currently has zero coverage for
optionalDependencies. A case like this one should be added:

test("a machine npm fallback resolves an upstream asset the importer declares optionally", async () => {
  // importer manifest declares optionalDependencies: { "optional-only": "3.2.1" }
  // assert both locateNodePackage(...) and resolveNodePackageResource(...) hit the machine npm root
});

Measured: not ok before the fix, ok after.

Impact

Any optional upstream asset resolved through the machine npm root is affected. Today
fonts-open is the only package declaring upstream assets via optionalDependencies, so the
blast radius is "every fully-local render that uses a built-in font" — which is exactly the
$0 path the docs recommend for getting started.

Notes

  • All evidence comes from read-only investigation plus direct calls to exported functions; no
    installed files were modified.
  • This is independent of the separate issue about npm-distribution local rendering failing
    with Cannot find package '@hypit/hyperframes'. Fixing either one does not fix the other.

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