Skip to content

Research: support @/ (or #) path-alias imports in a buildless runtime #549

@vivek7405

Description

@vivek7405

Problem

Next.js apps use @/ path aliases (import { x } from '@/lib/utils') instead of deep relative paths (../../../lib/utils). In Next this is just a TypeScript path alias (compilerOptions.paths: { "@/*": ["./*"] }, often with a configurable base like src or src/app) that its bundler honors at build time. webjs scaffolds currently use deep relative imports, and an alias like @/ would meaningfully improve DX (especially for the modules architecture, where ../../../lib/... is common).

The wrinkle is that webjs is buildless: there is no bundler rewriting specifiers. Source .ts/.js is served as native ES modules after type-stripping, so a bare @/lib/foo specifier must resolve at RUNTIME (Node 24+ and Bun on the server, the browser importmap on the client), not just in the editor. So the Next approach (tsconfig paths + bundler) only solves half of it; the runtime half needs explicit webjs wiring.

The case to cover: directory-prefix wildcard with a configurable base (the literal Next @/lib/db)

The alias must cover the DIRECTORY-PREFIX WILDCARD, not just single-file aliases. In Next, @ maps to a configurable base (root, src, or src/app), so @/lib/db resolves to <base>/lib/db for ANY path under that base, one mapping, every file reachable. The research must answer whether webjs can do this general case (configurable base + @/* wildcard), not only specific single-file aliases like #db -> ./db/connection.server.ts.

Critical constraint that makes the @ sigil harder than #:

  • Node's native package.json "imports" field REQUIRES every key to start with #. So "@/*" is NOT a legal imports key. A native "#/*": "./*" (or "#app/*": "./src/app/*") wildcard works on Node AND Bun with zero webjs code, but it spells #, not @.
  • To get the literal @/* server-side, webjs must install a CUSTOM module-resolution hook: Node's module.registerHooks / customization-hooks loader and Bun's resolver plugin, mapping @/ -> the base dir at resolve time. Research whether such a hook is viable and acceptable in webjs's buildless model on BOTH runtimes: it must register before any app module loads, compose with the existing TS-strip loader (Support both Bun and Node runtimes (first-class create + run) #508), and resolve to the REAL path so the .server.ts boundary + no-server-import-in-browser-module / elision checks still fire.
  • The browser side is easier: an importmap trailing-slash scope ("@/": "/" or "@/": "/src/app/") DOES express the wildcard prefix natively, so the client half of @/* is feasible via importmap injection. The asymmetry is purely server-side resolution.

So the real question for the @ sigil: is a cross-runtime custom resolver hook (Node + Bun) worth it for muscle-memory parity with Next, versus shipping #-prefixed native wildcards that read #lib/db / #app/lib/db instead of @/lib/db? Decide with base-dir configurability in mind (root vs src vs src/app).

Design / approach

Research whether and how to support path aliases without a build step. Two layers to satisfy:

  • Editor + typecheck (easy half): compilerOptions.paths ("@/*": ["./*"] or a base) in the scaffold tsconfig.json gives @/ autocomplete and webjs typecheck resolution, same as Next. Just a tsconfig setting.
  • Runtime (the real question): since nothing rewrites the specifier, the runtime must resolve it. Options to evaluate:
    • Node/Bun native package.json "imports" field (the # subpath-imports convention with a wildcard, e.g. "#lib/*": "./lib/*" or "#/*": "./*"). The buildless-native answer: works on BOTH Node and Bun with zero build step and zero resolver hook, the standards-track mechanism for internal aliases. Tradeoff: the sigil is #, not @.
    • @/ via a custom resolver hook + importmap. Server: a Node/Bun module-resolution hook mapping @/ -> base. Client: an importmap trailing-slash scope ("@/": "/..."). Evaluate the hook's viability/cost.
    • A hybrid: tsconfig paths for the editor + the runtime mechanism, generated together so they cannot drift.
  • Decide the sigil (@ for Next muscle-memory at the cost of a custom server resolver, vs # native and zero-hook), the configurable base, and scaffold-default vs opt-in.

Implementation notes (for the implementing agent)

  • Runtime resolver surfaces: node_modules/@webjsdev/server/src/importmap.js (importmap generator / client resolver) and vendor.js (vendor pinning into .webjs/vendor/importmap.json). A @/ alias flows through here for the browser side; an importmap trailing-slash scope expresses the wildcard.
  • Server side: native "imports" (# only) is resolved by Node/Bun with no webjs code, which is why # is the low-effort path. A @ sigil needs a custom hook (Node module.registerHooks, Bun resolver plugin) registered at boot before app modules load; verify it composes with the Support both Bun and Node runtimes (first-class create + run) #508 TS stripper and that Bun honors "imports" identically (cross-runtime assert).
  • Scaffold tsconfig is generated in packages/cli/lib/create.js (no paths/baseUrl today); the editor half is a change here plus the templates. Pick the base (root vs src).
  • Landmines: client importmap and server resolver must agree, or an alias resolves in dev/SSR but 404s in the browser (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). Any alias must be reflected in the served <script type="importmap">. The type stripper does not rewrite specifiers, so do NOT assume a build step.
  • Invariants: stays buildless (no bundler, no webjs build); server-only code stays in .server.{js,ts}, the alias must resolve to the REAL path so it cannot become a way to import server-only modules into browser files and dodge the elision / no-server-import-in-browser-module checks.
  • Coordinate with the Drizzle import-ergonomics outcome (Research: Drizzle vs Prisma as the default ORM, leaning Drizzle (#532) #546/Switch default ORM from Prisma to Drizzle across scaffolds, webjs db, docs, blog #551): #db / @/db style imports for the db module are the same mechanism; land a consistent answer.

This is a RESEARCH task: the output is a recommendation (sigil, base, mechanism, default-vs-opt-in). Per project convention the research record is a PR (title + description + comments) then closed, NOT a doc under agent-docs/. Implementation, if any, follows as its own issue.

Acceptance criteria

  • A written recommendation: sigil (@ vs #), configurable base, runtime mechanism (native "imports" wildcard vs custom resolver hook + importmap vs hybrid), and scaffold-default vs opt-in.
  • The recommendation covers the directory-prefix WILDCARD with a configurable base (@/lib/db -> <base>/lib/db for ANY path), not just single-file aliases.
  • If @ is chosen: proof a custom Node + Bun resolver hook maps @/* at runtime, registers before app modules load, composes with the TS stripper, and resolves to the real path so the .server.ts boundary + elision still fire. If # is chosen: proof the native "imports" wildcard covers the same ergonomics on Node AND Bun.
  • Proof the chosen mechanism resolves at runtime on BOTH Node 24+ and Bun, AND in the browser importmap (an alias that works in SSR but 404s client-side is a fail).
  • Confirmation the editor/webjs typecheck half (tsconfig paths) aligns with the runtime half so they cannot drift.
  • The server-only-import boundary + elision checks still resolve the real path through the alias (no bypass).

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