@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/index.js +19 -0
- package/package.json +5 -2
- package/src/actions.js +112 -11
- package/src/base-path.js +149 -0
- package/src/dev.js +262 -23
- package/src/importmap.js +54 -3
- package/src/redirects.js +389 -0
- package/src/route-types.js +176 -0
- package/src/sitemap.js +161 -0
- package/src/ssr.js +108 -7
- package/src/testing.js +400 -0
- package/src/vendor.js +143 -6
- package/webjs-config.schema.json +147 -0
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2016
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|