Skip to content

Implement # path-alias imports (package.json imports + resolveImport alias expansion) #555

Description

@vivek7405

Problem

Implement path-alias imports so scaffolded apps can write import { db } from '#/lib/db.server.ts' / import { Button } from '#/components/button.ts' instead of deep relative paths (../../../lib/...). Decided sigil: # (native package.json "imports"), per the research in #549 (record: closed PR #553). The @ sigil was rejected (it adds two bespoke per-runtime resolver hooks + a tsconfig-paths drift surface purely for Next parity; # is native, standards-track, zero editor config).

Decided shape: a SINGLE root-mapped key "#/*": "./*" (not per-directory keys). So every import reads #/lib/..., #/components/..., #/modules/..., #/app/..., which is the exact Next analog (@/lib/... with # swapped for @), the closest preserved muscle memory. One key, zero maintenance when dirs are added, configurable base (a src-rooted app writes "#/*": "./src/*").

Security-critical finding (the load-bearing change)

webjs's resolveImport (node_modules/@webjsdev/server/src/module-graph.js, ~L290/L308) currently SKIPS any specifier not starting with . or /. So a #/lib/db.server.ts specifier is treated as a bare npm specifier and is INVISIBLE to the four things that walk the import graph: the browser-bound auth gate (reachableFromEntries), modulepreload (transitiveDeps), elision (analyzeElision + the serve-time stripper in component-elision.js), and the no-server-import-in-browser-module check (check.js). Without a fix, an alias would LAUNDER a .server.ts past all of them. The mandatory change: expand a matching #/-prefixed specifier to its real path INSIDE resolveImport, before the relative-resolution branch, so all four see through the alias.

Design / approach (scope, from the #553 research)

  1. module-graph.js resolveImport: read the app's package.json "imports", expand a matching #/-prefixed specifier to the real path before the relative branch (the security-critical change above). Drive it off the actual "imports" map so a non-default base (./src/*) is honored, do NOT hardcode ./.
  2. importmap.js: emit the matching browser entry derived from the same "imports" block, a single trailing-slash scope "#/": "/" (or "#/": "/src/" for a src base), so browser and server agree. The Modulepreload hints emitted for server-only files the gate 404s #158/Import scanner counts imports shown as code inside template literals #159 preload-mismatch class is the landmine: the importmap and the server resolver MUST stay in lockstep, both derived from the one "imports" map.
  3. cli/lib/create.js: add the single-key "imports" block to the scaffold package.json:
    "imports": { "#/*": "./*" }
    base = project root. NOTE: this ships the "imports" BLOCK (the capability). Converting the template FILE contents to the #/... form is handled by Convert deep relative imports to #/ aliases across the in-repo apps + examples #556 (the codemod sweep over all existing deep-relative imports), so Implement # path-alias imports (package.json imports + resolveImport alias expansion) #555 stays purely the mechanism.
  4. Editor: native "imports" resolves under NodeNext with ZERO compilerOptions.paths (verified on TS 5.x in research: path-alias imports (@/ vs #) in a buildless runtime #553), so no tsconfig drift surface.

Ship as a scaffold default (the value is in the modules architecture where deep relatives proliferate); opt-out is simply using a plain relative import. An unused "imports" block costs nothing.

Implementation notes (for the implementing agent)

Acceptance criteria

  • import x from '#/lib/foo.server.ts' resolves at runtime on Node 24+ AND Bun (native, no build step) and serves correctly in the browser via the emitted importmap entry.
  • resolveImport expands #/-aliases so the auth gate, modulepreload, elision, and no-server-import-in-browser-module all see the real path.
  • COUNTERFACTUAL: a .server.ts imported via a #/-alias into a shipping browser module still trips no-server-import-in-browser-module (proves the alias does not launder the boundary).
  • The importmap and the server resolver agree (no alias that resolves in SSR but 404s in the browser), both derived from the one "imports" map.
  • Scaffold ships the single-key "imports": { "#/*": "./*" }; a freshly scaffolded app uses #/... imports and passes webjs check + boots.
  • A non-default base ("#/*": "./src/*") resolves correctly on both runtimes and in the browser (proves the mapping is not hardcoded to ./).
  • Tests at every layer: unit (alias expansion + the counterfactual), browser (a scaffolded app importing #/components/... hydrates), e2e (the aliased URL is in the auth gate and served, not 404), cross-runtime (Bun).
  • Docs + AGENTS.md + scaffold templates updated.

Decided in #549 (research). Record: #553.

Activity

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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions