@webjsdev/server 0.8.29 → 0.8.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.29",
3
+ "version": "0.8.31",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/actions.js CHANGED
@@ -150,7 +150,7 @@ async function rpcResponse(payload, init = {}) {
150
150
  * module (the hash index is all the analysis needs; a module is imported on
151
151
  * demand by `invokeAction` / `serveActionStub`), so it is safe for a read-only
152
152
  * introspection caller (the MCP `list_actions` tool, #262) with no top-level
153
- * side effects (Prisma init, DB connect).
153
+ * side effects (DB driver init, DB connect).
154
154
  *
155
155
  * @param {string} appDir
156
156
  * @param {boolean} dev
@@ -181,7 +181,7 @@ export async function buildActionIndex(appDir, dev) {
181
181
  // Pure-RPC actions are NOT executed at boot: invokeAction and
182
182
  // serveActionStub import the module on demand (first RPC call / first stub
183
183
  // fetch), so the hash index above is all the analysis needs. Eagerly
184
- // running every server module (and its transitive Prisma init, DB
184
+ // running every server module (and its transitive DB driver init, DB
185
185
  // connects, etc.) would be wasted work.
186
186
  }
187
187
 
package/src/asset-hash.js CHANGED
@@ -39,9 +39,9 @@
39
39
 
40
40
  import { createHash } from 'node:crypto';
41
41
  import { readFileSync } from 'node:fs';
42
- import { extname, join, resolve, sep } from 'node:path';
42
+ import { dirname, extname, join, relative, resolve, sep } from 'node:path';
43
43
  import { redactStringsAndTemplates } from './js-scan.js';
44
- import { resolveImport } from './module-graph.js';
44
+ import { resolveImport, expandImportAlias } from './module-graph.js';
45
45
 
46
46
  /**
47
47
  * Length of the emitted hex hash. 12 hex chars (48 bits) is plenty to
@@ -306,6 +306,24 @@ const JS_SPEC_EXTS = new Set(['.js', '.mjs', '.jsx', '.cjs']);
306
306
  * @param {string} abs the resolved absolute file path
307
307
  * @returns {string}
308
308
  */
309
+ /**
310
+ * A base-path-safe relative specifier from the importing module to a resolved
311
+ * target file, used to rewrite a `#`-alias import in served source (#555). The
312
+ * browser resolves a relative specifier against the importer's own URL (already
313
+ * base-path-prefixed), so the result needs no importmap and no basePath fix-up,
314
+ * and it carries the `?v` the caller appends. Uses the resolved file's actual
315
+ * extension (so no `normalizeSpecToResolved` pass is needed).
316
+ *
317
+ * @param {string} importerAbs absolute path of the module being served
318
+ * @param {string} abs resolved absolute path of the import target
319
+ * @returns {string}
320
+ */
321
+ function relativeSpecifier(importerAbs, abs) {
322
+ let rel = relative(dirname(importerAbs), abs).split(sep).join('/');
323
+ if (!rel.startsWith('.')) rel = './' + rel;
324
+ return rel;
325
+ }
326
+
309
327
  function normalizeSpecToResolved(spec, abs) {
310
328
  const absExt = extname(abs);
311
329
  if (!absExt) return spec;
@@ -391,9 +409,15 @@ export function versionModuleImports(source, importerAbs) {
391
409
  // inside a string / template / comment and is not a real import edge.
392
410
  if (masked[/** @type {number} */ (m.index)] === ' ') continue;
393
411
  const spec = m[2];
394
- // Only `./` `../` relative specifiers (base-path-correct, the app pattern).
395
- // Bare -> importmap target; `/`-absolute -> pre-existing basePath gap.
396
- if (spec[0] !== '.') continue;
412
+ const isRelative = spec[0] === '.';
413
+ // A `#`-style subpath alias (#555) is rewritten to a base-path-safe
414
+ // RELATIVE specifier below (so it carries `?v` and matches the preload,
415
+ // exactly like a relative import). Only `./`/`../` relatives and
416
+ // app-imports aliases qualify: a bare specifier is importmap-resolved and
417
+ // versioned at its target; a `/`-root-absolute specifier would miss the
418
+ // basePath prefix.
419
+ const isAlias = !isRelative && expandImportAlias(spec, _appDir) !== null;
420
+ if (!isRelative && !isAlias) continue;
397
421
  if (spec.includes('?')) continue;
398
422
  const abs = resolveImport(spec, importerAbs, _appDir);
399
423
  if (!abs || SERVER_FILE_RE.test(abs)) continue;
@@ -402,9 +426,14 @@ export function versionModuleImports(source, importerAbs) {
402
426
  if (!hash) continue;
403
427
  // `/d` flag: indices[2] is [start, end] of the specifier CONTENT (no
404
428
  // quotes). Replace it with the resolved-extension form + `?v` so the
405
- // fetched URL is byte-identical to the preload href.
429
+ // fetched URL is byte-identical to the preload href. An alias is emitted
430
+ // as a relative specifier to the resolved file (the browser resolves it
431
+ // against the importer's already-prefixed URL, so it stays base-path-safe
432
+ // without the importmap, and the `?v` collapses fetch + preload to one
433
+ // immutable cache key).
406
434
  const [start, end] = /** @type {[number, number]} */ (m.indices[2]);
407
- edits.push({ start, end, text: `${normalizeSpecToResolved(spec, abs)}?v=${hash}` });
435
+ const versioned = isAlias ? relativeSpecifier(importerAbs, abs) : normalizeSpecToResolved(spec, abs);
436
+ edits.push({ start, end, text: `${versioned}?v=${hash}` });
408
437
  }
409
438
  }
410
439
  if (edits.length === 0) return source;
package/src/cache-fn.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * import { cache } from '@webjsdev/server';
9
9
  *
10
10
  * export const listPosts = cache(
11
- * async () => prisma.post.findMany({ orderBy: { createdAt: 'desc' } }),
11
+ * async () => db.query.posts.findMany({ orderBy: { createdAt: 'desc' } }),
12
12
  * { key: 'posts', ttl: 60 }
13
13
  * );
14
14
  *
@@ -107,7 +107,7 @@ export function cache(fn, opts) {
107
107
  *
108
108
  * ```js
109
109
  * export async function createPost(input) {
110
- * await prisma.post.create({ data: input });
110
+ * await db.insert(posts).values(input);
111
111
  * await listPosts.invalidate();
112
112
  * }
113
113
  * ```
package/src/cache-tags.js CHANGED
@@ -104,7 +104,7 @@ export async function addKeyToTags(tags, cacheKey, ttlMs) {
104
104
  * 'use server';
105
105
  * import { revalidateTag } from '@webjsdev/server';
106
106
  * export async function createComment(input) {
107
- * await prisma.comment.create({ data: input });
107
+ * await db.insert(comments).values(input);
108
108
  * await revalidateTag('post:' + input.postId); // postById(postId) recomputes
109
109
  * return { success: true };
110
110
  * }
package/src/dev.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createServer as createHttp1Server } from 'node:http';
2
2
  import { stat, readFile, watch as fsWatch } from 'node:fs/promises';
3
- import { existsSync, readFileSync } from 'node:fs';
3
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
4
4
  import { join, extname, resolve, dirname, relative, sep } from 'node:path';
5
5
  import { createRequire } from 'node:module';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -45,7 +45,7 @@ import {
45
45
  varyWithAcceptEncoding,
46
46
  } from './listener-core.js';
47
47
  import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
48
- import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport } from './module-graph.js';
48
+ import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport, appImportsMap } from './module-graph.js';
49
49
  import { primeComponentRegistry, findOrphanComponents, scanComponents } from './component-scanner.js';
50
50
  import { analyzeElision, elideImportsFromSource } from './component-elision.js';
51
51
 
@@ -85,7 +85,7 @@ function resolveRequestId(req) {
85
85
  function shouldAccessLog(pathname) {
86
86
  return !pathname.startsWith('/__webjs/');
87
87
  }
88
- import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath } from './importmap.js';
88
+ import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from './importmap.js';
89
89
  import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
90
90
  import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports } from './asset-hash.js';
91
91
  import { urlFromRequest } from './forwarded.js';
@@ -493,6 +493,16 @@ export async function createRequestHandler(opts) {
493
493
  existsSync(join(distDir, 'webjs-core-browser.js'));
494
494
  await setCoreInstall(coreDir, distComplete);
495
495
 
496
+ // Path-alias imports (#555). Emit the browser importmap scopes for the app's
497
+ // `package.json "imports"` aliases, derived from the SAME map the server
498
+ // resolver (`expandImportAlias`) reads, so SSR and the browser agree. The
499
+ // scaffold ships the single catch-all `"#*": "./*"` (one key, zero
500
+ // maintenance, Bun-safe), which expands to one browser prefix scope per
501
+ // top-level dir (`#lib/` -> `/lib/`, ...); the dir scan is here so a new
502
+ // folder is picked up on boot. An app with no `"imports"` block yields {}
503
+ // (a no-op, byte-identical to before this feature).
504
+ await setImportAliasEntries(importAliasBrowserEntries(appImportsMap(appDir), appTopLevelDirs(appDir)));
505
+
496
506
  // Content-hash asset URLs for immutable caching (issue #243). Bind the
497
507
  // asset-hash module to the app + core roots and enable fingerprinting in
498
508
  // PROD only. In dev `withAssetHash` stays a pure no-op (never enabled), so
@@ -1353,16 +1363,18 @@ export async function createRequestHandler(opts) {
1353
1363
  * artefact dir (the #258 routes.d.ts and the vendor pin) that the dev server
1354
1364
  * itself writes on startup and on every rebuild, so without this skip the
1355
1365
  * write fires a watch event, triggers a rebuild, re-writes the file, and loops
1356
- * forever. `prisma/dev*` / `prisma/migrations` churn during db:migrate. The
1357
- * prisma branch is prefix-only (no trailing separator) so the SQLite sidecars
1358
- * `prisma/dev.db` / `prisma/dev.db-journal` match too; the others stay
1366
+ * forever. `db/dev.db*` (the SQLite file + sidecars) and `db/migrations`
1367
+ * (drizzle-kit output) churn during db:migrate. The `db/dev.db` branch is
1368
+ * prefix-only (no trailing separator) so the `-journal` / `-wal` sidecars match
1369
+ * too, while staying anchored to `db/dev.db` so a SOURCE file like
1370
+ * `db/schema.server.ts` still triggers a reload. The others stay
1359
1371
  * separator-anchored so an unrelated name like `node_modules.bak/foo` does not.
1360
1372
  *
1361
1373
  * @param {string} filename relative path from an fs.watch `event.filename`
1362
1374
  * @returns {boolean} true when the change should be ignored
1363
1375
  */
1364
1376
  export function shouldIgnoreWatchPath(filename) {
1365
- return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])prisma[\\/](?:dev|migrations)/.test(filename || '');
1377
+ return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)/.test(filename || '');
1366
1378
  }
1367
1379
 
1368
1380
  /**
@@ -1414,7 +1426,7 @@ export async function startServer(opts) {
1414
1426
  //
1415
1427
  // fs.watch returns relative paths in event.filename. `shouldIgnoreWatchPath`
1416
1428
  // (module-level, exported for tests) skips node_modules, .git, .webjs/, and
1417
- // prisma's dev artefacts so a file the dev server itself writes never loops.
1429
+ // the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
1418
1430
  const rebuild = debounce(() => app.rebuild(), 80);
1419
1431
  watcherAbort = new AbortController();
1420
1432
  (async () => {
@@ -2359,6 +2371,26 @@ function computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir) {
2359
2371
  return reachableFromEntries(moduleGraph, [...entries], appDir);
2360
2372
  }
2361
2373
 
2374
+ /**
2375
+ * List the app's top-level source directory names, for expanding a `#*`
2376
+ * catch-all import alias into one browser importmap prefix scope per dir (#555).
2377
+ * Excludes infra dirs that are never imported via `#` (node_modules, dotfiles,
2378
+ * the build/vendor caches). A new top-level folder is picked up on the next boot
2379
+ * (dev restarts on changes), so the alias stays zero-maintenance.
2380
+ * @param {string} appDir
2381
+ * @returns {string[]}
2382
+ */
2383
+ function appTopLevelDirs(appDir) {
2384
+ const SKIP = new Set(['node_modules', 'dist', 'public']);
2385
+ try {
2386
+ return readdirSync(appDir, { withFileTypes: true })
2387
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !SKIP.has(e.name))
2388
+ .map((e) => e.name);
2389
+ } catch {
2390
+ return [];
2391
+ }
2392
+ }
2393
+
2362
2394
  /**
2363
2395
  * Find the absolute directory of the `@webjsdev/core` package, regardless of
2364
2396
  * whether we're running from the monorepo or an installed copy.
package/src/env-schema.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * webjs auto-loads `<appDir>/.env` into `process.env` at boot, but does no
5
5
  * validation, so a missing or misconfigured required var (DATABASE_URL,
6
- * AUTH_SECRET, ...) fails late and cryptically (a Prisma connect error
6
+ * AUTH_SECRET, ...) fails late and cryptically (a database connect error
7
7
  * mid-request, an undefined secret signing a token). This module adds an
8
8
  * optional boot-time validation hook that fails fast with one clear message
9
9
  * listing EVERY missing or invalid var at once, before the app serves a
package/src/importmap.js CHANGED
@@ -28,6 +28,17 @@ function escapeAttr(s) {
28
28
  /** @type {Record<string, string>} */
29
29
  let _extraEntries = {};
30
30
 
31
+ /**
32
+ * Browser importmap entries for the app's `package.json "imports"` subpath
33
+ * aliases (#555), e.g. `{ "#lib/": "/lib/" }` for `"#lib/*": "./lib/*"`. Kept in lockstep
34
+ * with the server-side resolver (`module-graph.js`'s `expandImportAlias`):
35
+ * BOTH are derived from the one `"imports"` map, so an alias that resolves in
36
+ * SSR never 404s in the browser (the #158/#159 preload-mismatch class). Set at
37
+ * boot by `dev.js` via `setImportAliasEntries`.
38
+ * @type {Record<string, string>}
39
+ */
40
+ let _aliasEntries = {};
41
+
31
42
  /**
32
43
  * The normalized `webjs.basePath` for a sub-path deployment (issue #256),
33
44
  * `''` (the default) for a root mount. When non-empty, every same-origin
@@ -67,6 +78,65 @@ export function basePath() {
67
78
  return _basePath;
68
79
  }
69
80
 
81
+ /**
82
+ * Derive the BROWSER importmap entries for an app's `package.json "imports"`
83
+ * subpath-alias map (#555). The scaffold ships a single root catch-all key
84
+ * `"#*": "./*"` (one key, zero maintenance: a new top-level folder is aliased
85
+ * with no config change, and `#*` resolves natively on Node AND Bun, unlike a
86
+ * `#/`-prefixed key which Bun rejects). A browser importmap needs a
87
+ * trailing-slash PREFIX key to match, and a bare `#` is not one, so a catch-all
88
+ * is expanded into one prefix scope PER top-level directory (`topLevelDirs`):
89
+ * `#lib/` -> `/lib/`, `#components/` -> `/components/`, etc. The dirs are scanned
90
+ * by the caller (dev.js) so this stays pure; a new folder produces a new scope
91
+ * on the next boot. A non-catch-all key is mapped directly: a per-dir wildcard
92
+ * `"#lib/*": "./lib/*"` becomes `"#lib/": "/lib/"`, an exact `"#db": "./x.ts"`
93
+ * becomes `"#db": "/x.ts"`. The leading `./` becomes a root-absolute `/` and the
94
+ * `*` is dropped (the trailing slash carries the prefix match). A non-default
95
+ * base (`"#*": "./src/*"`) folds into the emitted URL. Only string targets are
96
+ * mappable (a conditional-export object is skipped). Derived from the SAME map
97
+ * the server resolver reads, so SSR and the browser agree.
98
+ *
99
+ * @param {Record<string, unknown> | null | undefined} importsMap
100
+ * @param {string[]} [topLevelDirs] app top-level dir names, for expanding a `#*` catch-all
101
+ * @returns {Record<string, string>}
102
+ */
103
+ export function importAliasBrowserEntries(importsMap, topLevelDirs = []) {
104
+ /** @type {Record<string, string>} */
105
+ const out = {};
106
+ if (!importsMap || typeof importsMap !== 'object') return out;
107
+ for (const [key, value] of Object.entries(importsMap)) {
108
+ if (typeof value !== 'string') continue;
109
+ if (!value.startsWith('./')) continue; // only app-root-relative targets map to a URL
110
+ const star = key.indexOf('*');
111
+ if (star !== -1 && key.slice(0, star) === '#') {
112
+ // Root catch-all `#*` -> `./<base>*`: expand to a prefix scope per dir.
113
+ // base is the part of the value between `.` and `*` (`'' ` for `./*`,
114
+ // `/src/` for `./src/*`), so `#<dir>/` -> `/<base><dir>/`.
115
+ const base = value.slice(1, value.lastIndexOf('*')); // './*' -> '/', './src/*' -> '/src/'
116
+ for (const dir of topLevelDirs) out[`#${dir}/`] = `${base}${dir}/`;
117
+ continue;
118
+ }
119
+ // Per-dir wildcard or exact key: map directly.
120
+ out[key.replace('*', '')] = value.replace(/^\./, '').replace('*', '');
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Bind the browser importmap to the app's `package.json "imports"` aliases
127
+ * (#555). Called once at boot by `dev.js`, derived from the SAME `"imports"`
128
+ * map the server resolver reads, so SSR and the browser agree. The hash is
129
+ * recomputed eagerly (like `setBasePath` / `setCoreInstall`) so
130
+ * `importMapHash()` stays synchronous on the per-request path.
131
+ *
132
+ * @param {Record<string, string>} entries browser entries from `importAliasBrowserEntries`
133
+ * @returns {Promise<void>}
134
+ */
135
+ export async function setImportAliasEntries(entries) {
136
+ _aliasEntries = entries && typeof entries === 'object' ? entries : {};
137
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })));
138
+ }
139
+
70
140
  /**
71
141
  * SRI integrity hashes keyed by FINAL URL (post-importmap-rewrite).
72
142
  * Populated only when a pin file with `integrity` is present;
@@ -338,6 +408,7 @@ export function buildImportMap(opts = {}) {
338
408
  const merged = {
339
409
  ..._coreEntries,
340
410
  ..._extraEntries,
411
+ ..._aliasEntries,
341
412
  };
342
413
  // Sort keys so logically-identical importmaps serialize byte-for-byte
343
414
  // identically. The client router compares textContent to detect
package/src/json.js CHANGED
@@ -9,7 +9,7 @@ import { readTextBounded, BodyLimitError, DEFAULT_MAX_BODY_BYTES } from './body-
9
9
  * // GET /api/posts
10
10
  * import { json } from '@webjsdev/server';
11
11
  * export async function GET() {
12
- * return json(await listPosts()); // plain Prisma rows with Date columns
12
+ * return json(await listPosts()); // plain Drizzle rows with Date columns
13
13
  * }
14
14
  *
15
15
  * The helper reads the in-flight Request from the AsyncLocalStorage
@@ -12,10 +12,71 @@
12
12
  */
13
13
 
14
14
  import { readFile, readdir, stat } from 'node:fs/promises';
15
- import { existsSync } from 'node:fs';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
16
  import { join, resolve, dirname, extname, sep } from 'node:path';
17
17
  import { redactStringsAndTemplates } from './js-scan.js';
18
18
 
19
+ /**
20
+ * Per-appDir cache of the parsed `package.json "imports"` subpath-alias map
21
+ * (#555). `null` means "no imports block" (the common case for an app that
22
+ * does not use the `#` alias). Read once per appDir; cleared with the parse
23
+ * cache on rebuild.
24
+ * @type {Map<string, Record<string,string> | null>}
25
+ */
26
+ const IMPORTS_CACHE = new Map();
27
+
28
+ /**
29
+ * Read an app's `package.json "imports"` subpath-import map (Node's native
30
+ * import-alias mechanism). Cached per appDir.
31
+ * @param {string} appDir
32
+ * @returns {Record<string,string> | null}
33
+ */
34
+ export function appImportsMap(appDir) {
35
+ if (IMPORTS_CACHE.has(appDir)) return IMPORTS_CACHE.get(appDir) ?? null;
36
+ let map = null;
37
+ try {
38
+ const pkg = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8'));
39
+ if (pkg && typeof pkg.imports === 'object' && pkg.imports) map = pkg.imports;
40
+ } catch { /* no package.json / unparseable: no aliases */ }
41
+ IMPORTS_CACHE.set(appDir, map);
42
+ return map;
43
+ }
44
+
45
+ /**
46
+ * Expand a `package.json "imports"` subpath alias (e.g. `#lib/db.server.ts`
47
+ * with the scaffold's catch-all `"#*": "./*"`) to its real APP-RELATIVE target
48
+ * string (`./lib/db.server.ts`).
49
+ * The security-critical seam (#555): the graph walker, auth gate, elision, and
50
+ * `no-server-import-in-browser-module` all route through `resolveImport`, so
51
+ * expanding the alias here (to the real path) is what stops an alias from
52
+ * laundering a `.server.ts` past those checks. Driven off the actual `"imports"`
53
+ * map so a non-default base (`"#lib/*": "./src/lib/*"`) is honored; never
54
+ * hardcodes `./`. Supports one trailing `*` wildcard per key (Node's rule) plus
55
+ * exact keys. (A `#/`-prefixed key is avoided in the scaffold because Bun's
56
+ * native resolver rejects it; the expansion here is key-shape-agnostic.)
57
+ * @param {string} spec the import specifier, e.g. `'#lib/db.server.ts'`
58
+ * @param {string} appDir
59
+ * @returns {string | null} the app-relative target (e.g. `'./lib/db.server.ts'`), or null if no alias matches
60
+ */
61
+ export function expandImportAlias(spec, appDir) {
62
+ const map = appImportsMap(appDir);
63
+ if (!map) return null;
64
+ for (const [key, value] of Object.entries(map)) {
65
+ if (typeof value !== 'string') continue; // conditional-export objects are not graph-resolvable
66
+ const star = key.indexOf('*');
67
+ if (star === -1) {
68
+ if (spec === key) return value;
69
+ continue;
70
+ }
71
+ const prefix = key.slice(0, star);
72
+ const suffix = key.slice(star + 1);
73
+ if (!spec.startsWith(prefix) || !spec.endsWith(suffix)) continue;
74
+ const middle = spec.slice(prefix.length, spec.length - suffix.length);
75
+ return value.replace('*', middle);
76
+ }
77
+ return null;
78
+ }
79
+
19
80
  /** @type {RegExp} match static `import … from '…'` and `import '…'` */
20
81
  const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
21
82
 
@@ -52,6 +113,9 @@ const EXPORT_FROM_RE = /\bexport\b[^'";]+?\sfrom\s+['"]([^'"]+)['"]/g;
52
113
  * @returns {Promise<ModuleGraph>}
53
114
  */
54
115
  export async function buildModuleGraph(appDir) {
116
+ // Re-read the app's `package.json "imports"` each build so an edit to the
117
+ // alias map (#555) is picked up on rebuild.
118
+ IMPORTS_CACHE.delete(appDir);
55
119
  /** @type {ModuleGraph} */
56
120
  const graph = new Map();
57
121
  /** @type {Set<string>} every file walked this build (graph holds only files
@@ -286,8 +350,11 @@ async function parseFile(file, appDir, graph, seen) {
286
350
  // and is not a real import edge.
287
351
  if (masked[m.index] === ' ') continue;
288
352
  const spec = m[1];
289
- // Only resolve relative imports within the project.
290
- if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
353
+ // Only resolve relative imports + `#`-style subpath aliases (#555)
354
+ // within the project. A bare npm specifier (dayjs) has no alias match
355
+ // and is skipped; an aliased `#lib/x.server.ts` IS followed so the
356
+ // graph / auth gate / elision see the real path through the alias.
357
+ if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) continue;
291
358
  const resolved = resolveImport(spec, file, appDir);
292
359
  if (resolved) deps.add(resolved);
293
360
  }
@@ -308,7 +375,13 @@ async function parseFile(file, appDir, graph, seen) {
308
375
  export function resolveImport(spec, fromFile, appDir) {
309
376
  const base = dirname(fromFile);
310
377
  let target;
311
- if (spec.startsWith('/')) {
378
+ const aliased = expandImportAlias(spec, appDir);
379
+ if (aliased) {
380
+ // `#`-style subpath alias (#555): the app-relative target is resolved
381
+ // against the app root, then run through the same extension fallback below
382
+ // so the boundary / elision / preload all see the real on-disk path.
383
+ target = resolve(appDir, aliased);
384
+ } else if (spec.startsWith('/')) {
312
385
  // Absolute from app root (how browser sees it)
313
386
  target = join(appDir, spec);
314
387
  } else {
package/src/vendor.js CHANGED
@@ -409,7 +409,7 @@ async function jspmCall(installs, provider) {
409
409
  // A 5xx/429 is a transient jspm problem worth retrying. A 401/4xx
410
410
  // means at least one install is genuinely unresolvable (jspm uses
411
411
  // 401 for that): a private / workspace / server-only package (e.g.
412
- // @webjsdev/server, @prisma/client) the browser never fetches
412
+ // @webjsdev/server, better-sqlite3) the browser never fetches
413
413
  // anyway. Permanent failures must NOT block readiness.
414
414
  const transient = response.status >= 500 || response.status === 429;
415
415
  return { ok: false, imports: {}, transient };
@@ -2,8 +2,8 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "https://webjs.dev/schemas/webjs-config.schema.json",
4
4
  "title": "webjs config block",
5
- "description": "Schema for the `webjs` object in a webjs app's package.json. Each key maps to a documented server reader. An unknown key is flagged (additionalProperties is false) so a typo no longer silently falls back to the default.",
6
- "$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the server reader functions (readElideEnabled in dev.js, readSeedEnabled in dev.js, compileHeaderRules in headers.js, compileRedirectRules / readTrailingSlashPolicy in redirects.js, readBasePath in base-path.js, readCspConfig in csp.js, readBodyLimits / computeServerTimeouts in body-limit.js). Adding a webjs.* key means updating all three. See packages/server/AGENTS.md for the one documented procedure.",
5
+ "description": "Schema for the `webjs` object in a webjs app's package.json. Each key maps to a documented reader (a server reader, or for `dev`/`start` the CLI's readAppTasks). An unknown key is flagged (additionalProperties is false) so a typo no longer silently falls back to the default.",
6
+ "$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the reader functions (readElideEnabled in dev.js, readSeedEnabled in dev.js, compileHeaderRules in headers.js, compileRedirectRules / readTrailingSlashPolicy in redirects.js, readBasePath in base-path.js, readCspConfig in csp.js, readBodyLimits / computeServerTimeouts in body-limit.js, and the CLI's readAppTasks in packages/cli/lib/app-tasks.js for dev/start, #550). Adding a webjs.* key means updating all three. See packages/server/AGENTS.md for the one documented procedure.",
7
7
  "type": "object",
8
8
  "additionalProperties": false,
9
9
  "properties": {
@@ -147,6 +147,35 @@
147
147
  "type": "integer",
148
148
  "minimum": 0,
149
149
  "default": 5000
150
+ },
151
+ "dev": {
152
+ "description": "Dev task orchestration (#550). Read by the CLI (readAppTasks in packages/cli/lib/app-tasks.js), NOT the server. webjs dev runs `before` (one-shot, e.g. webjs db migrate) to completion, then `parallel` (long-lived watchers, e.g. tailwindcss --watch) alongside the server. Replaces the old predev hook + concurrently watchers.",
153
+ "type": "object",
154
+ "additionalProperties": false,
155
+ "properties": {
156
+ "before": {
157
+ "description": "One-shot commands run sequentially to completion before the dev server boots. A non-zero exit aborts the boot.",
158
+ "type": "array",
159
+ "items": { "type": "string" }
160
+ },
161
+ "parallel": {
162
+ "description": "Long-lived commands run as child processes alongside the dev server, torn down on exit so a watcher cannot leak past it.",
163
+ "type": "array",
164
+ "items": { "type": "string" }
165
+ }
166
+ }
167
+ },
168
+ "start": {
169
+ "description": "Start task orchestration (#550). Read by the CLI (readAppTasks in packages/cli/lib/app-tasks.js), NOT the server. webjs start runs `before` (one-shot, e.g. webjs db migrate) to completion before serving. Replaces the old prestart hook.",
170
+ "type": "object",
171
+ "additionalProperties": false,
172
+ "properties": {
173
+ "before": {
174
+ "description": "One-shot commands run sequentially to completion before the prod server boots. A non-zero exit aborts the boot.",
175
+ "type": "array",
176
+ "items": { "type": "string" }
177
+ }
178
+ }
150
179
  }
151
180
  }
152
181
  }