Skip to content

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

@vivek7405

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.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions