@webjsdev/server 0.8.11 → 0.8.13

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/src/dev.js CHANGED
@@ -47,6 +47,7 @@ process.emitWarning = function (warning, type, code, ctor) {
47
47
  };
48
48
 
49
49
  import { buildRouteTable, matchPage, matchApi } from './router.js';
50
+ import { generateRouteTypes } from './route-types.js';
50
51
  import { ssrPage, ssrNotFound } from './ssr.js';
51
52
  import { loadPageAction, runPageAction } from './page-action.js';
52
53
  import { handleApi } from './api.js';
@@ -112,9 +113,16 @@ function resolveRequestId(req) {
112
113
  function shouldAccessLog(pathname) {
113
114
  return !pathname.startsWith('/__webjs/');
114
115
  }
115
- import { setVendorEntries, setCoreInstall, publishBuildId } from './importmap.js';
116
+ import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath } from './importmap.js';
117
+ import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
116
118
  import { urlFromRequest } from './forwarded.js';
117
119
  import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
120
+ import {
121
+ compileRedirectRules,
122
+ applyRedirects,
123
+ readTrailingSlashPolicy,
124
+ applyTrailingSlash,
125
+ } from './redirects.js';
118
126
  import { readBodyLimits, computeServerTimeouts } from './body-limit.js';
119
127
  import { applyConditionalGet, BUFFERED_MARKER } from './conditional-get.js';
120
128
  import { commitHtmlCache } from './html-cache.js';
@@ -261,6 +269,61 @@ export async function readHeaderRules(appDir) {
261
269
  }
262
270
  }
263
271
 
272
+ /**
273
+ * Read the declarative redirect config (`webjs.redirects`) from the app's
274
+ * package.json and compile it to URLPattern rules (issue #254). A missing,
275
+ * malformed, or unreadable config yields an empty rule set (no redirects),
276
+ * never a throw. Patterns are compiled ONCE here at boot, not per request.
277
+ *
278
+ * @param {string} appDir
279
+ * @returns {Promise<ReturnType<typeof compileRedirectRules>>}
280
+ */
281
+ export async function readRedirectRules(appDir) {
282
+ try {
283
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
284
+ return compileRedirectRules(pkg);
285
+ } catch {
286
+ return [];
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Read the trailing-slash policy (`webjs.trailingSlash`) from the app's
292
+ * package.json (issue #255). A missing, malformed, or unreadable config
293
+ * yields `'ignore'` (no canonicalization), never a throw, so an
294
+ * unconfigured app is unchanged.
295
+ *
296
+ * @param {string} appDir
297
+ * @returns {Promise<'never' | 'always' | 'ignore'>}
298
+ */
299
+ export async function readTrailingSlashFromApp(appDir) {
300
+ try {
301
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
302
+ return readTrailingSlashPolicy(pkg);
303
+ } catch {
304
+ return 'ignore';
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Read the sub-path base path (`webjs.basePath`) from the app's
310
+ * package.json (issue #256). A missing, malformed, or unreadable config
311
+ * yields `''` (root mount), never a throw, so an unconfigured app is
312
+ * byte-identical to before this feature. Normalized to `''` or
313
+ * `/segment[/segment...]` by `readBasePath`.
314
+ *
315
+ * @param {string} appDir
316
+ * @returns {Promise<string>}
317
+ */
318
+ export async function readBasePathFromApp(appDir) {
319
+ try {
320
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
321
+ return readBasePath(pkg);
322
+ } catch {
323
+ return '';
324
+ }
325
+ }
326
+
264
327
  /**
265
328
  * Read the CSP config (`webjs.csp`) from the app's package.json and
266
329
  * normalize it (issue #233). A missing, malformed, or unreadable config
@@ -380,6 +443,15 @@ export async function createRequestHandler(opts) {
380
443
  logger.error?.('[webjs] onError hook threw (ignored)', { err: String(e) });
381
444
  }
382
445
  }
446
+ // Sub-path deployment base path (issue #256), read once from the app's
447
+ // package.json `webjs.basePath` and bound into the importmap builder BEFORE
448
+ // setCoreInstall / setVendorEntries so every importmap target (and the
449
+ // recomputed hash) reflects the prefix. Empty (the default) makes both the
450
+ // ingress strip and the emit-side prefix pure no-ops, so an unconfigured app
451
+ // is byte-identical to before this feature.
452
+ const basePathValue = await readBasePathFromApp(appDir);
453
+ await setBasePath(basePathValue);
454
+
383
455
  const coreDir = locateCoreDir(appDir);
384
456
  // Switch the importmap between dist/ bundles and src/ per-file
385
457
  // URLs depending on whether the resolved @webjsdev/core install
@@ -446,11 +518,53 @@ export async function createRequestHandler(opts) {
446
518
  // Hints, and WebSocket lookups need it available before the first request.
447
519
  const routeTable = await buildRouteTable(appDir);
448
520
 
521
+ // Emit `.webjs/routes.d.ts` (typed Route union + per-route params, #258) in
522
+ // dev so an editor's tsserver always has up-to-date route types without the
523
+ // developer remembering to run `webjs types`. Best-effort and fire-and-
524
+ // forget: a failure logs and never blocks boot. Re-emitted after each route
525
+ // rebuild (see doRebuild) so adding/removing a route refreshes the types.
526
+ /** @returns {Promise<void>} */
527
+ async function emitRouteTypes() {
528
+ try {
529
+ const { mkdir, writeFile, rename } = await import('node:fs/promises');
530
+ const text = await generateRouteTypes(appDir);
531
+ const outDir = join(appDir, '.webjs');
532
+ await mkdir(outDir, { recursive: true });
533
+ // Write to a temp sibling then rename, so tsserver (which reads this
534
+ // file) never observes a half-written body if two rebuilds race. rename
535
+ // is atomic within the same dir. Both paths sit under the watcher-ignored
536
+ // .webjs/, so neither the temp write nor the rename re-triggers a rebuild.
537
+ const dest = join(outDir, 'routes.d.ts');
538
+ const tmp = join(outDir, `routes.d.ts.${process.pid}.tmp`);
539
+ await writeFile(tmp, text);
540
+ await rename(tmp, dest);
541
+ } catch (e) {
542
+ logger.warn?.(`[webjs] could not write .webjs/routes.d.ts (route types): ${e?.message || e}`);
543
+ }
544
+ }
545
+ if (dev) void emitRouteTypes();
546
+
449
547
  // Per-path response-header rules (issue #232), read once from the
450
548
  // app's package.json `webjs.headers`. Static config, so no rebuild
451
549
  // re-read; the secure defaults need no config and apply regardless.
452
550
  const headerRules = await readHeaderRules(appDir);
453
551
 
552
+ // Declarative redirect rules (issue #254), read once from the app's
553
+ // package.json `webjs.redirects` and compiled to URLPattern rules at
554
+ // boot (never per request). Empty when unconfigured, so an app with no
555
+ // redirects is unchanged. Applied at the very start of request handling,
556
+ // before routing / SSR / asset serving, so a moved URL returns a 308/307
557
+ // immediately.
558
+ const redirectRules = await readRedirectRules(appDir);
559
+
560
+ // Trailing-slash policy (issue #255), read once from the app's package.json
561
+ // `webjs.trailingSlash`. Default `'ignore'` (no canonicalization), so an
562
+ // unconfigured app is unchanged; `'never'` (recommended) strips a trailing
563
+ // slash and `'always'` adds one, each via a 308 to the canonical form.
564
+ // Applied in produce() AFTER the declarative redirects, so an explicit
565
+ // redirect rule wins first and the two never loop.
566
+ const trailingSlashPolicy = await readTrailingSlashFromApp(appDir);
567
+
454
568
  // CSP config (issue #233), read once from the app's package.json
455
569
  // `webjs.csp`. OFF by default: when disabled no nonce is minted and no
456
570
  // Content-Security-Policy header is set, so an unconfigured app is
@@ -715,6 +829,10 @@ export async function createRequestHandler(opts) {
715
829
  // The route table is the only eager artifact (cheap directory scan); rebuild
716
830
  // it so routing reflects added/removed route files immediately.
717
831
  state.routeTable = await buildRouteTable(appDir);
832
+ // Refresh the generated route types (#258) so adding/removing a route file
833
+ // updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
834
+ // best-effort (see emitRouteTypes).
835
+ if (dev) void emitRouteTypes();
718
836
  clearVendorCache();
719
837
  state.tsCache.clear();
720
838
  // Invalidate the lazy analysis; the next request rebuilds the graph,
@@ -762,6 +880,19 @@ export async function createRequestHandler(opts) {
762
880
 
763
881
  let pathname = '/';
764
882
  try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
883
+ // Sub-path deployment (issue #256): the per-path response-header rules
884
+ // (`webjs.headers`) author their `source` patterns app-root-relative,
885
+ // exactly like `webjs.redirects` / `webjs.trailingSlash` (which the
886
+ // ingress strip in produce() already sees root-relative). So match the
887
+ // header rules against the STRIPPED path too, keeping the whole config
888
+ // surface consistent under a base path. `pathname` itself (used for the
889
+ // access log) stays the RAW path the client hit. No-op when basePath is
890
+ // empty or the request is not under it.
891
+ let headerPathname = pathname;
892
+ if (basePathValue) {
893
+ const s = stripBasePath(pathname, basePathValue);
894
+ if (s !== null) headerPathname = s;
895
+ }
765
896
  const startedAt = performance.now();
766
897
 
767
898
  let res;
@@ -788,7 +919,7 @@ export async function createRequestHandler(opts) {
788
919
  // Applied to every served response (documents, assets, the core
789
920
  // runtime, probes), since the defaults are universally safe.
790
921
  let merged = applySecurityHeaders(res, {
791
- pathname,
922
+ pathname: headerPathname,
792
923
  https: webRequestIsHttps(req),
793
924
  prod: !dev,
794
925
  rules: headerRules,
@@ -826,6 +957,14 @@ export async function createRequestHandler(opts) {
826
957
  // stripped here regardless. Best-effort: a store failure is swallowed.
827
958
  try {
828
959
  const reqUrl = new URL(req.url);
960
+ // Sub-path deployment (issue #256): the HTML cache READ (in ssrPage)
961
+ // and `revalidatePath` both key on the app-root-relative path, so key
962
+ // the WRITE on the stripped path too, or a cached page would never
963
+ // hit. No-op when basePath is empty / the path is not under it.
964
+ if (basePathValue) {
965
+ const s = stripBasePath(reqUrl.pathname, basePathValue);
966
+ if (s !== null) reqUrl.pathname = s;
967
+ }
829
968
  merged = await commitHtmlCache(req, merged, reqUrl);
830
969
  } catch { /* never let the cache write crash the response */ }
831
970
 
@@ -847,7 +986,10 @@ export async function createRequestHandler(opts) {
847
986
  // duration / requestId (no bodies, no secrets). Suppressed for the
848
987
  // framework's own /__webjs/* probe + static traffic so it does not spam.
849
988
  // Best-effort: a logger that throws must not take the response down.
850
- if (shouldAccessLog(pathname)) {
989
+ // Use the STRIPPED path for the suppression check (issue #256) so a
990
+ // framework probe at `<basePath>/__webjs/*` is suppressed just like the
991
+ // root-mounted `/__webjs/*`. The logged `path` stays the RAW client URL.
992
+ if (shouldAccessLog(headerPathname)) {
851
993
  try {
852
994
  logger.info?.('request', {
853
995
  requestId: reqId,
@@ -866,6 +1008,69 @@ export async function createRequestHandler(opts) {
866
1008
  /** @param {Request} req */
867
1009
  function produce(req) {
868
1010
  return (async () => {
1011
+ // Sub-path deployment ingress strip (issue #256). When `webjs.basePath`
1012
+ // is set and the request path is under it, STRIP the prefix and rewrite
1013
+ // the Request so EVERYTHING downstream (redirects, trailing-slash, the
1014
+ // probes, the `/__webjs/*` checks, the source-file gate, route matching,
1015
+ // SSR) sees a ROOT-relative path and works UNCHANGED. This single strip
1016
+ // is why the rest of the framework needs no per-site changes. A request
1017
+ // whose path is NOT under the base path is not for this mounted app, so
1018
+ // return a 404 (the safe default for a mounted app). Empty basePath (the
1019
+ // default) is a pure pass-through, so an unconfigured app is unchanged.
1020
+ if (basePathValue) {
1021
+ let reqUrl;
1022
+ try { reqUrl = new URL(req.url); } catch { reqUrl = null; }
1023
+ if (reqUrl) {
1024
+ const stripped = stripBasePath(reqUrl.pathname, basePathValue);
1025
+ if (stripped === null) {
1026
+ // Not under the base path: this request is not for this app.
1027
+ return new Response('Not found', {
1028
+ status: 404,
1029
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
1030
+ });
1031
+ }
1032
+ if (stripped !== reqUrl.pathname) {
1033
+ reqUrl.pathname = stripped;
1034
+ // Rewrite the Request with the stripped URL, preserving method,
1035
+ // headers, and body. `duplex: 'half'` is required by the spec when
1036
+ // a body stream is present on a non-GET/HEAD request.
1037
+ const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
1038
+ req = new Request(
1039
+ reqUrl.toString(),
1040
+ /** @type {any} */ ({
1041
+ method: req.method,
1042
+ headers: req.headers,
1043
+ body: hasBody ? req.body : undefined,
1044
+ duplex: hasBody ? 'half' : undefined,
1045
+ redirect: req.redirect,
1046
+ signal: req.signal,
1047
+ }),
1048
+ );
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ // Declarative redirects (issue #254): apply the configured old-path ->
1054
+ // new-path rules at the VERY START of request handling, before the
1055
+ // probes, routing, SSR, or asset serving. A matched source returns a
1056
+ // 308 (permanent, the SEO default) / 307 (temporary) / configured
1057
+ // status immediately, so a moved URL never reaches the router.
1058
+ // `applyRedirects` skips /__webjs/* itself, so the framework probes /
1059
+ // runtime below are never redirected. The secure-header + conditional-GET
1060
+ // funnel in handle() still wraps this Response, like any other.
1061
+ const redirectResp = applyRedirects(req, redirectRules);
1062
+ if (redirectResp) return redirectResp;
1063
+
1064
+ // Trailing-slash canonicalization (issue #255): after the explicit
1065
+ // redirects above (so an explicit rule wins first and the two never
1066
+ // form a loop), 308-redirect a non-canonical path to the policy's
1067
+ // canonical form (`never` strips a trailing slash, `always` adds one).
1068
+ // Default `'ignore'` is a no-op. The root `/` and file paths are
1069
+ // exempt; `/__webjs/*` is exempt too (defense in depth, the redirects
1070
+ // above already skip it). The funnel in handle() still wraps this.
1071
+ const slashResp = applyTrailingSlash(req, trailingSlashPolicy);
1072
+ if (slashResp) return slashResp;
1073
+
869
1074
  // Health and readiness probes are answered BEFORE ensureReady so a probe
870
1075
  // never blocks on the analysis. `/__webjs/health` is liveness (the
871
1076
  // process is up and accepting connections). `/__webjs/ready` is 503 until
@@ -950,14 +1155,25 @@ export async function createRequestHandler(opts) {
950
1155
  * BEFORE running SSR: resolves a pathname to its page-route module URLs
951
1156
  * without loading them. Returns null for non-page paths.
952
1157
  *
1158
+ * Sub-path deployment (issue #256): the HTTP layer passes the RAW request
1159
+ * pathname (still carrying the base path, since the ingress strip happens
1160
+ * inside `produce`, not here), so strip it for route matching and prefix
1161
+ * the emitted module URLs so the early-hint preloads resolve under the
1162
+ * prefix. A path not under the base path yields null (no hints).
1163
+ *
953
1164
  * @param {string} pathname
954
1165
  */
955
1166
  function routeFor(pathname) {
956
- const page = matchPage(state.routeTable, pathname);
1167
+ const matchPathname = basePathValue
1168
+ ? stripBasePath(pathname, basePathValue)
1169
+ : pathname;
1170
+ if (matchPathname === null) return null;
1171
+ const page = matchPage(state.routeTable, matchPathname);
957
1172
  if (!page) return null;
958
1173
  const moduleUrls = [page.route.file, ...page.route.layouts].map((f) => {
959
1174
  let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
960
- return rel.split('\\').join('/').replace(/^\/?/, '/');
1175
+ const url = rel.split('\\').join('/').replace(/^\/?/, '/');
1176
+ return withBasePath(url, basePathValue);
961
1177
  });
962
1178
  return { moduleUrls };
963
1179
  }
@@ -997,6 +1213,25 @@ export async function createRequestHandler(opts) {
997
1213
  * etc.) sitting in front of this process. See the deployment docs for
998
1214
  * the recommended topology.
999
1215
  *
1216
+ /**
1217
+ * Paths under the app root whose changes must NOT trigger a dev rebuild.
1218
+ * `node_modules` / `.git` are noise. `.webjs/` is the framework's generated
1219
+ * artefact dir (the #258 routes.d.ts and the vendor pin) that the dev server
1220
+ * itself writes on startup and on every rebuild, so without this skip the
1221
+ * write fires a watch event, triggers a rebuild, re-writes the file, and loops
1222
+ * forever. `prisma/dev*` / `prisma/migrations` churn during db:migrate. The
1223
+ * prisma branch is prefix-only (no trailing separator) so the SQLite sidecars
1224
+ * `prisma/dev.db` / `prisma/dev.db-journal` match too; the others stay
1225
+ * separator-anchored so an unrelated name like `node_modules.bak/foo` does not.
1226
+ *
1227
+ * @param {string} filename relative path from an fs.watch `event.filename`
1228
+ * @returns {boolean} true when the change should be ignored
1229
+ */
1230
+ export function shouldIgnoreWatchPath(filename) {
1231
+ return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])prisma[\\/](?:dev|migrations)/.test(filename || '');
1232
+ }
1233
+
1234
+ /**
1000
1235
  * @param {{
1001
1236
  * appDir: string,
1002
1237
  * port?: number,
@@ -1032,18 +1267,9 @@ export async function startServer(opts) {
1032
1267
  // `fs.promises.watch`. Stable on macOS, Windows, and Linux as of
1033
1268
  // Node 24. No external dep needed.
1034
1269
  //
1035
- // fs.watch returns relative paths in event.filename. We apply
1036
- // the same ignore filter chokidar used before: skip
1037
- // node_modules, .git, and prisma's dev artefacts (dev.db,
1038
- // dev.db-journal, migrations/) which the dev server writes
1039
- // during db:migrate and would otherwise loop.
1040
- //
1041
- // The prisma branch uses prefix-only matching (no required
1042
- // trailing separator) so the SQLite sidecar files like
1043
- // `prisma/dev.db` and `prisma/dev.db-journal` are ignored too.
1044
- // node_modules / .git stay separator-anchored so unrelated
1045
- // names like `node_modules.bak/foo` don't get caught.
1046
- const IGNORE = /(?:^|[\\/])(?:node_modules|\.git)(?:[\\/]|$)|(?:^|[\\/])prisma[\\/](?:dev|migrations)/;
1270
+ // fs.watch returns relative paths in event.filename. `shouldIgnoreWatchPath`
1271
+ // (module-level, exported for tests) skips node_modules, .git, .webjs/, and
1272
+ // prisma's dev artefacts so a file the dev server itself writes never loops.
1047
1273
  const rebuild = debounce(() => app.rebuild(), 80);
1048
1274
  watcherAbort = new AbortController();
1049
1275
  (async () => {
@@ -1051,7 +1277,7 @@ export async function startServer(opts) {
1051
1277
  const events = fsWatch(app.appDir, { recursive: true, signal: watcherAbort.signal });
1052
1278
  for await (const event of events) {
1053
1279
  const filename = event.filename || '';
1054
- if (IGNORE.test(filename)) continue;
1280
+ if (shouldIgnoreWatchPath(filename)) continue;
1055
1281
  rebuild();
1056
1282
  }
1057
1283
  } catch (err) {
@@ -1075,8 +1301,11 @@ export async function startServer(opts) {
1075
1301
  try {
1076
1302
  const url = urlFromRequest(req);
1077
1303
 
1078
- // SSE: handled specially; doesn't fit the req→Response model.
1079
- if (url.pathname === '/__webjs/events') {
1304
+ // SSE: handled specially; doesn't fit the req→Response model. Match the
1305
+ // base-path-stripped pathname so the reload stream answers at
1306
+ // `<basePath>/__webjs/events` under a sub-path deploy (#256). With no
1307
+ // basePath this is a pure pass-through (the bare path still matches).
1308
+ if (stripBasePath(url.pathname, basePath()) === '/__webjs/events') {
1080
1309
  if (!dev) { res.writeHead(404); res.end(); return; }
1081
1310
  res.writeHead(200, {
1082
1311
  'content-type': 'text/event-stream',
@@ -1195,7 +1424,7 @@ async function tryServeFrameworkStatic(path, method, ctx) {
1195
1424
  // Dev live-reload client.
1196
1425
  if (path === '/__webjs/reload.js') {
1197
1426
  if (!dev) return new Response('Not found', { status: 404 });
1198
- return new Response(RELOAD_CLIENT_JS, {
1427
+ return new Response(reloadClientJs(basePath()), {
1199
1428
  headers: { 'content-type': 'application/javascript; charset=utf-8' },
1200
1429
  });
1201
1430
  }
@@ -2012,7 +2241,17 @@ function locatePackageDir(appDir, pkgName) {
2012
2241
  return null;
2013
2242
  }
2014
2243
 
2015
- const RELOAD_CLIENT_JS = `// webjs dev reload client
2016
- const es = new EventSource('/__webjs/events');
2244
+ /**
2245
+ * The dev live-reload client. The `EventSource` URL is a framework-emitted
2246
+ * same-origin path, so it must carry the base path under a sub-path deploy
2247
+ * (#256), like the importmap targets and the RPC stub. No-op when basePath
2248
+ * is empty.
2249
+ * @param {string} bp the normalized base path (`''` = no-op)
2250
+ * @returns {string}
2251
+ */
2252
+ function reloadClientJs(bp) {
2253
+ return `// webjs dev reload client
2254
+ const es = new EventSource(${JSON.stringify(withBasePath('/__webjs/events', bp))});
2017
2255
  es.addEventListener('reload', () => location.reload());
2018
2256
  `;
2257
+ }
package/src/importmap.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { digestHex } from './crypto-utils.js';
4
4
  import { jsonForScriptTag } from './script-tag-json.js';
5
+ import { withBasePath } from './base-path.js';
5
6
 
6
7
  // Local attribute escaper. Matches ssr.js's escapeAttr (the source
7
8
  // of truth for HTML attribute escaping in this package). Kept inline
@@ -26,6 +27,45 @@ function escapeAttr(s) {
26
27
  /** @type {Record<string, string>} */
27
28
  let _extraEntries = {};
28
29
 
30
+ /**
31
+ * The normalized `webjs.basePath` for a sub-path deployment (issue #256),
32
+ * `''` (the default) for a root mount. When non-empty, every same-origin
33
+ * absolute importmap TARGET (the `/__webjs/core/*` core entries and any
34
+ * same-origin `/__webjs/vendor/*` local vendor target) is prefixed with it
35
+ * so module resolution works under the prefix. A cross-origin `https://`
36
+ * CDN vendor target is absolute and is left untouched. Set once at boot by
37
+ * `setBasePath`, which recomputes the importmap hash so `importMapHash()`
38
+ * stays synchronous on the hot path.
39
+ *
40
+ * @type {string}
41
+ */
42
+ let _basePath = '';
43
+
44
+ /**
45
+ * Bind the importmap to a sub-path deployment's base path (issue #256).
46
+ * Called once at boot by `dev.js`. With the empty default the map is
47
+ * byte-identical to a root mount. The importmap hash is recomputed eagerly
48
+ * (like `setCoreInstall` / `setVendorEntries`) so `importMapHash()` stays
49
+ * synchronous on the per-request SSR path.
50
+ *
51
+ * @param {string} basePath the normalized base path (`''` = root mount)
52
+ * @returns {Promise<void>}
53
+ */
54
+ export async function setBasePath(basePath) {
55
+ _basePath = basePath || '';
56
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
57
+ }
58
+
59
+ /**
60
+ * The active base path, for callers that prefix their own emitted URLs
61
+ * against the same value (ssr.js's boot specifiers, preloads, reload src).
62
+ *
63
+ * @returns {string}
64
+ */
65
+ export function basePath() {
66
+ return _basePath;
67
+ }
68
+
29
69
  /**
30
70
  * SRI integrity hashes keyed by FINAL URL (post-importmap-rewrite).
31
71
  * Populated only when a pin file with `integrity` is present;
@@ -292,7 +332,12 @@ export function buildImportMap() {
292
332
  // even though the content didn't actually change.
293
333
  /** @type {Record<string, string>} */
294
334
  const imports = {};
295
- for (const k of Object.keys(merged).sort()) imports[k] = merged[k];
335
+ // Prefix every same-origin absolute target with the sub-path base
336
+ // (issue #256). `withBasePath` is a no-op when the base path is empty
337
+ // (so a root-mounted app's map is byte-identical) and leaves a
338
+ // cross-origin `https://` CDN vendor target untouched (only the
339
+ // framework's own `/__webjs/*` and same-origin vendor targets move).
340
+ for (const k of Object.keys(merged).sort()) imports[k] = withBasePath(merged[k], _basePath);
296
341
 
297
342
  // Emit `integrity` per the importmap-integrity spec (Chrome 132+,
298
343
  // Safari 18.4+, Firefox flagged). Browsers without support ignore
@@ -305,11 +350,17 @@ export function buildImportMap() {
305
350
  // unrelated pin file edits, and leak removed URLs to the wire.
306
351
  const out = { imports };
307
352
  const usedUrls = new Set(Object.values(imports));
308
- const intKeys = Object.keys(_vendorIntegrity).filter(k => usedUrls.has(k)).sort();
353
+ // Integrity keys are the FINAL post-rewrite URLs, so prefix a same-origin
354
+ // local vendor key with the base path to match its (now prefixed) imports
355
+ // value. A cross-origin CDN key is untouched by `withBasePath` and lines
356
+ // up with its unprefixed imports value.
357
+ const intKeys = Object.keys(_vendorIntegrity)
358
+ .filter(k => usedUrls.has(withBasePath(k, _basePath)))
359
+ .sort();
309
360
  if (intKeys.length) {
310
361
  /** @type {Record<string, string>} */
311
362
  const integrity = {};
312
- for (const k of intKeys) integrity[k] = _vendorIntegrity[k];
363
+ for (const k of intKeys) integrity[withBasePath(k, _basePath)] = _vendorIntegrity[k];
313
364
  out.integrity = integrity;
314
365
  }
315
366
  return out;