Skip to content

feat: # path-alias imports + convert in-repo apps & scaffold#565

Merged
vivek7405 merged 13 commits into
mainfrom
feat/path-alias-hash-imports
Jun 18, 2026
Merged

feat: # path-alias imports + convert in-repo apps & scaffold#565
vivek7405 merged 13 commits into
mainfrom
feat/path-alias-hash-imports

Conversation

@vivek7405

@vivek7405 vivek7405 commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Closes #555
Closes #556

Implements the # path-alias import mechanism (#555) and converts the in-repo apps, examples, and scaffold templates to use it (#556). #556 hard-depends on #555 (the alias must resolve before any import can be converted), so both ship together here.

The alias shape: #* catch-all (Bun-safe, zero-maintenance)

The scaffold ships a single root catch-all in package.json:

"imports": { "#*": "./*" }

So import { db } from '#db/connection.server.ts', #components/..., #lib/..., #modules/..., and any NEW top-level folder all resolve with no config change. Two findings drove the shape (verified on Node 26 + Bun 1.3.14):

  • A #/-prefixed key ("#/*": "./*", spelled #/lib/...) does NOT resolve on Bun (Cannot find module '#/lib/x'), only on Node. Since the blog runs on Bun on Railway, that would break the deploy. The slash-free #* form (spelled #lib/...) resolves natively on both.
  • A single key keeps it zero-maintenance (a new folder needs no config), which per-directory keys would lose.

#555: the mechanism (framework)

  • resolveImport (module-graph) expands a matching # specifier to its real path BEFORE the relative branch (driven off the app's "imports" map), so the auth gate, modulepreload, elision, and no-server-import-in-browser-module all see through the alias. An alias cannot launder a .server.ts past the boundary (counterfactual test included).
  • importAliasBrowserEntries (importmap) derives the browser scope from the SAME map. A bare # cannot be an importmap prefix key, so the #* catch-all is expanded into one trailing-slash scope per top-level dir (#lib/ -> /lib/, ...) from a directory scan (dev.js appTopLevelDirs); a new folder is covered on the next boot. Server + browser stay in lockstep.
  • versionModuleImports (asset-hash) rewrites a # import in prod served source to a base-path-safe versioned relative specifier, so it gets the same ?v immutable cache + matched preload as a relative import (no dogfood: modulepreload ?v= mismatch double-downloads every nested component module #369 wasted preload); a # alias to a .server.ts is left unversioned.

The alias addresses a top-level SUBDIRECTORY (#lib/...); a root-level file is imported relatively (the browser scope is per-directory).

#556: the conversion (apps + templates)

  • examples/blog, website, docs, packages/ui/packages/website: app-internal deep relatives rewritten to # (including the Drizzle db/ imports from Switch default ORM from Prisma to Drizzle across scaffolds, webjs db, docs, blog #551). Imports that escape the app root (to packages/server, the ui registry sibling) stay relative.
  • Scaffold template files + create.js / saas-template.js emit # (including the registry cn rewrite to #lib/utils/cn.ts), so a fresh app demonstrates the feature.

Verification

  • Unit + mechanism + scaffold tests green; # resolves natively on Node 26 AND Bun 1.3.14 (test/bun/path-alias.mjs, wired into CI).
  • Dogfood: blog smoke 8/8, blog e2e 87/87, website/docs/ui-website boot 200 with the auto-derived # importmap scopes and no broken preloads; a real blog module imports clean on Bun.
  • Self-review ran two rounds (round 1 found a real saas cn-rewrite regression + two missed real imports, fixed; round 2 found only #/-vs-# wording, fixed).

@vivek7405 vivek7405 self-assigned this Jun 17, 2026
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Design rationale: why #/ is safe and keeps the framework buildless

Worth recording why this does not compromise no-build or the security boundary, since both were a concern:

  • No-build preserved. #/ is Node's native package.json "imports" field, resolved at runtime by Node 24+ and Bun with no build step, no bundler, no transform. The on-disk source is what runs. This is the whole reason Research: support @/ (or #) path-alias imports in a buildless runtime #549 picked # over @: the @ sigil needs two bespoke per-runtime resolver hooks, # needs none.
  • Framework packages untouched. #/ maps to the APP root, so it is an app convention only. packages/* (the published libraries) keep their own resolution and get no #/ (out of scope per Convert deep relative imports to #/ aliases across the in-repo apps + examples #556).
  • No-op for apps without an imports block. appImportsMap returns null, so every new path is byte-identical to before. The existing module-graph / check / elision / scanner suites (288 tests) stay green.
  • The .server.ts boundary is preserved, not weakened. The load-bearing change is expanding the alias to the REAL path inside resolveImport, so the auth gate, elision, modulepreload, and no-server-import-in-browser-module all see through it. The counterfactual test proves a .server.ts imported via #/ into a shipping module is still flagged (and correctly skipped when the page elides), i.e. the alias does not launder the boundary.
  • Server + browser lockstep. Both the server resolver and the browser importmap scope derive from the one imports map, so an alias that resolves in SSR never 404s in the browser.
  • Prod immutable caching kept. versionModuleImports rewrites a #/ alias in served source to a base-path-safe versioned relative specifier, so an aliased import gets the same ?v immutable cache + matched preload as a relative import (no dogfood: modulepreload ?v= mismatch double-downloads every nested component module #369 wasted-preload regression).

@vivek7405 vivek7405 changed the title feat: # path-alias imports + convert in-repo apps & scaffold to #/ feat: # path-alias imports + convert in-repo apps & scaffold Jun 17, 2026
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Review: # alias holds up, a few real catches fixed

Went over this in several passes. The mechanism is sound: the alias is expanded inside resolveImport before the graph / auth gate / elision / no-server-import-in-browser-module see it, so a #-aliased .server.ts still trips the boundary (counterfactual test proves it), and the browser importmap scope is derived from the same map, so SSR and the browser stay in lockstep.

What I caught and fixed along the way:

  • The big one: the decided single "#/*": "./*" key does not resolve on Bun (verified on 1.3.14), only on Node. Since the blog serves on Bun on Railway that would have broken the deploy. Switched to the slash-free "#*": "./*" catch-all, which resolves natively on both and keeps the one-key zero-maintenance property (a new top-level folder needs no config). Browser scopes are auto-derived per top-level dir from a scan.
  • saas-template.js's readUiComponent had a self-replacing no-op rewrite, so a scaffolded saas app shipped components/ui/* with a broken ../lib/utils.ts cn import. Fixed to #lib/utils/cn.ts with a scaffold-integration assertion over every copied component.
  • Two real app imports the codemod missed (website/app/page.ts highlight, ui-demo dialog) and a batch of #/-vs-# wording in comments, test names, the PR title, and body.
  • Two scaffold tests asserted the old relative cn / modules paths; re-pointed at the aliases.

Last pass was clean. Verified: # resolves on Node 26 and Bun 1.3.14, blog smoke 8/8, blog e2e 87/87, the four apps boot with the auto-derived # scopes and no broken preloads, full suite green.

@vivek7405

Copy link
Copy Markdown
Collaborator Author

CI note: the auth-session-default type-fixture failure is not from this PR

The unit job's only red is type fixture compiles clean: auth-session-default.test-d.ts, with tsc reporting @webjsdev/server has no AuthInstance (the pre-#451 shape). I dug into this and it is not caused by anything in this PR:

  • This PR changes NO auth types, packages/server/index.d.ts, package.json, tsconfig, or the lock. git diff origin/main...HEAD over those is empty; the branch base is origin/main.
  • packages/server/index.d.ts on this branch exports AuthInstance (lines 831, 849); the published @webjsdev/server@0.8.30 tarball also exports it; the lock has only the workspace symlink (link: true -> packages/server).
  • It passes the full local suite (2589 pass) and the exact solo tsc --moduleResolution bundler --allowJs command exits 0 locally.
  • main's unit job is green on the last six runs, so this is not a main-side regression.
  • The smoking gun: in the SAME failing CI run, the sibling auth-session-augment.test-d.ts (which imports the SAME AuthInstance / createAuth from @webjsdev/server) PASSES. Two separate solo tsc processes resolving the identical node_modules cannot legitimately disagree on whether @webjsdev/server exports AuthInstance. That points to a CI-environment / type-fixtures-runner resolution anomaly (likely concurrency-timing under node --test parallelism), not a code defect here. The prior Drizzle PR hit the same auth-fixture class.

Everything the feature actually does is verified green: # resolves natively on Node 26 + Bun 1.3.14, the security boundary holds (counterfactual), blog smoke 8/8, blog e2e 87/87, the four apps boot with the auto-derived # importmap scopes and no broken preloads, and all the other CI jobs (conventions, build, browser, e2e, docker, postgres, in-repo apps, bun matrix) are green. Recommend tracking the flaky type-fixtures resolution as its own issue rather than blocking on it here.

vivek7405 pushed a commit that referenced this pull request Jun 18, 2026
…erministic (#566)

The type-fixtures tsc used --moduleResolution bundler, which intermittently
resolved @webjsdev/server to its default export condition (index.js, untyped)
instead of the types condition (index.d.ts) under node --test cross-file
concurrency. A fixture importing a type-only export (AuthInstance, the generic
createAuth<TUser>) then failed with 'no exported member' / 'resolves to an
untyped module', non-deterministically and shifting between sibling fixtures
(auth-session-default vs auth-session-augment) across runs; green on main,
deterministic on branches that perturbed test timing (#563, #565). Switch the
fixtures to module/moduleResolution nodenext, which honors the package exports
types condition deterministically (how a typed Node consumer resolves), so the
bare @webjsdev/* specifiers always land on the .d.ts. Drop --allowJs too (no
fixture imports a .js). All 10 fixtures pass with their @ts-expect-error
counterfactuals intact.
vivek7405 added a commit that referenced this pull request Jun 18, 2026
…567)

* fix: resolve type fixtures with nodenext so @webjsdev/* types are deterministic (#566)

The type-fixtures tsc used --moduleResolution bundler, which intermittently
resolved @webjsdev/server to its default export condition (index.js, untyped)
instead of the types condition (index.d.ts) under node --test cross-file
concurrency. A fixture importing a type-only export (AuthInstance, the generic
createAuth<TUser>) then failed with 'no exported member' / 'resolves to an
untyped module', non-deterministically and shifting between sibling fixtures
(auth-session-default vs auth-session-augment) across runs; green on main,
deterministic on branches that perturbed test timing (#563, #565). Switch the
fixtures to module/moduleResolution nodenext, which honors the package exports
types condition deterministically (how a typed Node consumer resolves), so the
bare @webjsdev/* specifiers always land on the .d.ts. Drop --allowJs too (no
fixture imports a .js). All 10 fixtures pass with their @ts-expect-error
counterfactuals intact.

* test(bun): denylist test/docs/ from the matrix (docs-boot 5s-timeout flake)

Every test/docs/*.test.mjs boots the docs app via createRequestHandler and
asserts rendered HTML; the cold boot resolves docs code-sample bare imports
via jspm and intermittently exceeds bun test's 5s default timeout (which page
tips over varies: security-page, troubleshooting-page, llms have all flaked,
red on main too). Docs-content checks, fully covered on the Node path. Same
class as the differential-elision + deployment-secrets denylist entries.

* fix: retry transient tsc resolution + honor directory denylist entries

The type-fixtures runner intermittently failed in CI when a spawned tsc
momentarily resolved a workspace package to its untyped .js under
node --test cross-file concurrency (TS7016/TS2305 on @webjsdev/server),
non-deterministically shifting between sibling fixtures. The package
types are correct, so a fresh tsc a moment later succeeds; retry up to
3x only on a failure that names @webjsdev/core|server, so a genuine
fixture type error (which persists across attempts and points at the
fixture) is still reported.

Also fix the bun test matrix denylist matcher: it compared exact
repo-relative paths only, so a directory-prefix entry like 'test/docs/'
never matched any file under it. Honor a trailing-slash entry as a
prefix so the flaky docs app-boot tests are actually skipped on Bun.

* fix: isolate the d.ts counterfactual so it cannot race fixture compiles

The retry was fragile: the counterfactual moved the live
packages/server/index.d.ts aside for a full ~2.5s tsc run, far longer
than a few retries can outlast, and the transient failure surfaces as
varied codes (TS7016/TS2305/TS2322/TS2578) that no longer name the
package on an augmentation fixture, so the retry detector missed it.

Real fix: the counterfactual now copies the package into a temp
node_modules and removes the overlay from the COPY, never the live file.
node --test runs files concurrently, so a concurrent fixture compile in
type-fixtures.test.mjs always sees the real, present overlay. Drops the
retry loop (no longer needed) and keeps the bun denylist directory-prefix
matcher fix from the prior commit.

---------

Co-authored-by: t <t@t>
t added 13 commits June 18, 2026 09:08
Expand a package.json 'imports' subpath alias (#/*: ./*) to its real path
inside resolveImport, BEFORE the relative branch, so the module graph, auth
gate, modulepreload, elision, and no-server-import-in-browser-module all see
through the alias (an alias must not launder a .server.ts past the boundary).
importmap.js emits the matching browser scope (#/: /) from the same imports
map, kept in lockstep with the server resolver. versionModuleImports rewrites
a #/ alias in served source to a base-path-safe versioned relative specifier
so it gets the same immutable ?v caching + matched preload as a relative import.
Driven off the actual imports map so a non-default base (./src/*) is honored.
…#555)

Scaffold package.json gets imports: { '#/*': './*' }. Tests cover alias
expansion, the non-default base, the graph edge, the browser-scope derivation,
and the security counterfactual: a .server.ts imported via #/ into a shipping
module still trips no-server-import-in-browser-module (the alias does not
launder the boundary), while an elided page is correctly not flagged.
A #/ alias import is rewritten to a base-path-safe versioned relative
specifier matching the modulepreload href (no #369 wasted-preload), and a
#/ alias to a .server.ts is left untouched (bare-URL stub, never preloaded).
Add the package.json imports block + rewrite app-internal deep-relative
imports to #/ across examples/blog, website, docs, and the ui-website
(including the Drizzle db/ imports from #551). Imports that escape the app
root (to packages/server, the ui registry sibling) stay relative; code
samples inside docs page strings are untouched (masked). Boots verified on
all four apps with the #/ importmap scope and no broken preloads.
Convert the deep-relative imports create.js/saas-template.js emit and the
template files (test/hello, AGENTS.md, CONVENTIONS.md samples) to #/, so a
freshly scaffolded app ships with #/ imports. readUiComponent rewrites a
registry component's relative ../lib/utils.ts to the app's #/lib/utils/cn.ts.
Only root-targeting climbs are converted (a partway ../types.ts is left).
scaffold-integration asserts the imports block, #/ usage, and no surviving
within-app deep relatives.
The decided single "#/*": "./*" key does NOT resolve on Bun (latest, 1.3.14):
Bun's native package.json imports resolver rejects a #/-prefixed key (Cannot
find module '#/lib/x'), though Node resolves it. Since the blog runs on Bun on
Railway, that would break the deploy. Switch to per-directory keys (#lib/*:
./lib/*, spelled #lib/...), which resolve natively on Node AND Bun and produce
a valid trailing-slash browser importmap scope (Bun's own docs use this form).
The resolver/importmap code is key-shape-agnostic; only the scaffold + apps +
tests changed shape. Verified on node 26 + bun 1.3.14; the 4 apps boot and a
real blog module imports clean on Bun.
…#556)

Use one root catch-all import key `"#*": "./*"` instead of per-directory keys:
a new top-level folder is aliased with no config change. `#*` resolves natively
on Node AND Bun (verified on bun 1.3.14), unlike a `#/`-prefixed key which Bun
rejects. A bare `#` can't be a browser importmap prefix, so the browser scopes
are auto-derived per top-level dir from a directory scan (dev.js `appTopLevelDirs`
-> importmap), keeping server + browser in lockstep off the one map. Apps + tests
use `#lib/...` spelling; the resolver stays key-shape-agnostic.
Root AGENTS.md gets an Imports convention (prefer #db/, #components/, #lib/...
over deep relatives; native package.json imports, #* catch-all, no slash, Bun-
safe). packages/server/AGENTS.md documents the resolver seam (appImportsMap /
expandImportAlias in module-graph, importAliasBrowserEntries in importmap).
…555/#556)

Self-review findings:
- saas-template readUiComponent had a self-replacing no-op (the bulk rename
  mangled the search string), so a scaffolded saas app shipped components/ui/*
  with a broken ../lib/utils.ts cn import (ERR_MODULE_NOT_FOUND). Fixed to
  rewrite the registry's relative cn import to #lib/utils/cn.ts, with a
  scaffold-integration regression assertion over every copied ui component.
- Two real app imports the codemod missed (website/app/page.ts highlight,
  ui-demo dialog side-effect import) now use #.
- Corrected #/* vs #* comment drift in the CI step, path-alias proof header,
  and the scaffold test comment; documented that the # alias addresses a
  subdirectory (a root-level file has no browser scope, import it relatively).
- Updated the landing-page code sample to #db/ for consistency.
The shipped alias is # (no slash); some comments/test names loosely called it
#/ (the very form the feature rejects on Bun). Corrected in module-graph.js,
asset-hash.js, the no-server-import + version-module-imports tests, and the
bun path-alias proof header. Legit '#/-prefixed key is avoided' contrasts kept.
The full-stack ui-kit test expected the registry cn import at a relative
../../lib/utils/cn.ts and the api route test expected ../../../modules/...;
the scaffold now emits #lib/utils/cn.ts and #modules/... The api route test's
relative ../-depth off-by-one guard is moot (the alias is depth-independent),
re-pointed at the alias.
@vivek7405 vivek7405 force-pushed the feat/path-alias-hash-imports branch from dcb2d6f to 0f21a19 Compare June 18, 2026 03:38
@vivek7405 vivek7405 marked this pull request as ready for review June 18, 2026 03:48
@vivek7405 vivek7405 merged commit 08ad8b8 into main Jun 18, 2026
9 checks passed
@vivek7405 vivek7405 deleted the feat/path-alias-hash-imports branch June 18, 2026 03:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant