@webjsdev/server 0.8.10 → 0.8.12
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 +4 -1
- package/package.json +4 -2
- package/src/actions.js +21 -3
- package/src/auth.js +8 -1
- package/src/base-path.js +149 -0
- package/src/build-info.js +59 -0
- package/src/cache-fn.js +40 -0
- package/src/cache-tags.js +147 -0
- package/src/conditional-get.js +183 -0
- package/src/context.js +74 -1
- package/src/dev.js +449 -49
- package/src/html-cache.js +305 -0
- package/src/importmap.js +54 -3
- package/src/redirects.js +389 -0
- package/src/route-types.js +176 -0
- package/src/session.js +4 -0
- package/src/ssr.js +210 -9
- package/src/vendor.js +9 -6
- package/webjs-config.schema.json +147 -0
package/src/dev.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createServer as createHttp1Server } from 'node:http';
|
|
2
2
|
import { stat, readFile, watch as fsWatch } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { digestHex } from './crypto-utils.js';
|
|
5
4
|
import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
|
|
6
5
|
import { join, extname, resolve, dirname, relative, sep } from 'node:path';
|
|
7
6
|
import { createRequire } from 'node:module';
|
|
@@ -48,6 +47,7 @@ process.emitWarning = function (warning, type, code, ctor) {
|
|
|
48
47
|
};
|
|
49
48
|
|
|
50
49
|
import { buildRouteTable, matchPage, matchApi } from './router.js';
|
|
50
|
+
import { generateRouteTypes } from './route-types.js';
|
|
51
51
|
import { ssrPage, ssrNotFound } from './ssr.js';
|
|
52
52
|
import { loadPageAction, runPageAction } from './page-action.js';
|
|
53
53
|
import { handleApi } from './api.js';
|
|
@@ -68,7 +68,8 @@ import {
|
|
|
68
68
|
import { defaultLogger } from './logger.js';
|
|
69
69
|
import { assertNodeVersion } from './node-version.js';
|
|
70
70
|
import { applyEnvValidation } from './env-schema.js';
|
|
71
|
-
import { withRequest, setCspNonce, setBodyLimits } from './context.js';
|
|
71
|
+
import { withRequest, setCspNonce, setBodyLimits, setRequestId, requestId as getRequestId } from './context.js';
|
|
72
|
+
import { buildInfoResponse } from './build-info.js';
|
|
72
73
|
import { readCspConfig, mintNonce, buildCspHeader, cspHeaderName } from './csp.js';
|
|
73
74
|
import { attachWebSocket } from './websocket.js';
|
|
74
75
|
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
|
|
@@ -80,10 +81,51 @@ import { analyzeElision, elideImportsFromSource } from './component-elision.js';
|
|
|
80
81
|
function kebab(name) {
|
|
81
82
|
return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
82
83
|
}
|
|
83
|
-
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Per-request correlation id (issue #239). Honor an inbound `X-Request-Id`
|
|
87
|
+
* from a trusted upstream proxy so a trace id propagates across services; mint
|
|
88
|
+
* a fresh `crypto.randomUUID()` otherwise. The inbound value is length-capped
|
|
89
|
+
* and validated against a conservative token charset so a hostile client
|
|
90
|
+
* cannot inject control chars / a header-splitting payload (the value is
|
|
91
|
+
* echoed back in the `X-Request-Id` response header). On any mismatch we fall
|
|
92
|
+
* back to a minted id rather than trust the junk.
|
|
93
|
+
*
|
|
94
|
+
* @param {Request} req
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function resolveRequestId(req) {
|
|
98
|
+
const inbound = req.headers.get('x-request-id');
|
|
99
|
+
if (inbound && inbound.length <= 200 && /^[A-Za-z0-9._-]+$/.test(inbound)) return inbound;
|
|
100
|
+
return crypto.randomUUID();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether a path should be access-logged (issue #239). The framework's own
|
|
105
|
+
* `/__webjs/*` probes, static runtime assets, and the dev SSE reload stream
|
|
106
|
+
* are high-frequency infrastructure traffic, not app requests, so logging them
|
|
107
|
+
* would just spam the access log. App routes (including app-authored
|
|
108
|
+
* `/api/*`) are logged.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} pathname
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
function shouldAccessLog(pathname) {
|
|
114
|
+
return !pathname.startsWith('/__webjs/');
|
|
115
|
+
}
|
|
116
|
+
import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath } from './importmap.js';
|
|
117
|
+
import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
|
|
84
118
|
import { urlFromRequest } from './forwarded.js';
|
|
85
119
|
import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
|
|
120
|
+
import {
|
|
121
|
+
compileRedirectRules,
|
|
122
|
+
applyRedirects,
|
|
123
|
+
readTrailingSlashPolicy,
|
|
124
|
+
applyTrailingSlash,
|
|
125
|
+
} from './redirects.js';
|
|
86
126
|
import { readBodyLimits, computeServerTimeouts } from './body-limit.js';
|
|
127
|
+
import { applyConditionalGet, BUFFERED_MARKER } from './conditional-get.js';
|
|
128
|
+
import { commitHtmlCache } from './html-cache.js';
|
|
87
129
|
|
|
88
130
|
const MIME = {
|
|
89
131
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -227,6 +269,61 @@ export async function readHeaderRules(appDir) {
|
|
|
227
269
|
}
|
|
228
270
|
}
|
|
229
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
|
+
|
|
230
327
|
/**
|
|
231
328
|
* Read the CSP config (`webjs.csp`) from the app's package.json and
|
|
232
329
|
* normalize it (issue #233). A missing, malformed, or unreadable config
|
|
@@ -294,6 +391,7 @@ export async function readServerTimeoutsFromApp(appDir) {
|
|
|
294
391
|
* appDir: string,
|
|
295
392
|
* dev?: boolean,
|
|
296
393
|
* logger?: import('./logger.js').Logger,
|
|
394
|
+
* onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
|
|
297
395
|
* onReload?: () => void,
|
|
298
396
|
* }} opts
|
|
299
397
|
*/
|
|
@@ -321,6 +419,39 @@ export async function createRequestHandler(opts) {
|
|
|
321
419
|
await applyEnvValidation(appDir, { dev: !!opts.dev });
|
|
322
420
|
const dev = !!opts.dev;
|
|
323
421
|
const logger = opts.logger || defaultLogger({ dev });
|
|
422
|
+
// APM / Sentry integration point (issue #239). Called whenever the request
|
|
423
|
+
// pipeline catches an unhandled error: the top-level handle() catch (the
|
|
424
|
+
// last-resort 500), an unexpected throw inside the produce() funnel, or a
|
|
425
|
+
// middleware that threw. BEST-EFFORT by contract: a throwing onError is
|
|
426
|
+
// caught here so it can never crash the response, and the framework's own
|
|
427
|
+
// sanitized 500 / existing error behavior is unchanged (the hook is purely
|
|
428
|
+
// additive). The sink receives the error plus the correlation id so it can
|
|
429
|
+
// tie the report to the same id the access log and X-Request-Id carry.
|
|
430
|
+
const onError = typeof opts.onError === 'function' ? opts.onError : null;
|
|
431
|
+
/**
|
|
432
|
+
* Invoke the app's onError sink defensively. The phase is a coarse label of
|
|
433
|
+
* where the pipeline caught the error, for the sink's own grouping.
|
|
434
|
+
* @param {unknown} error
|
|
435
|
+
* @param {Request} request
|
|
436
|
+
* @param {string} phase
|
|
437
|
+
*/
|
|
438
|
+
function reportError(error, request, phase) {
|
|
439
|
+
if (!onError) return;
|
|
440
|
+
try {
|
|
441
|
+
onError(error, { request, requestId: getRequestId(), phase });
|
|
442
|
+
} catch (e) {
|
|
443
|
+
logger.error?.('[webjs] onError hook threw (ignored)', { err: String(e) });
|
|
444
|
+
}
|
|
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
|
+
|
|
324
455
|
const coreDir = locateCoreDir(appDir);
|
|
325
456
|
// Switch the importmap between dist/ bundles and src/ per-file
|
|
326
457
|
// URLs depending on whether the resolved @webjsdev/core install
|
|
@@ -387,11 +518,53 @@ export async function createRequestHandler(opts) {
|
|
|
387
518
|
// Hints, and WebSocket lookups need it available before the first request.
|
|
388
519
|
const routeTable = await buildRouteTable(appDir);
|
|
389
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
|
+
|
|
390
547
|
// Per-path response-header rules (issue #232), read once from the
|
|
391
548
|
// app's package.json `webjs.headers`. Static config, so no rebuild
|
|
392
549
|
// re-read; the secure defaults need no config and apply regardless.
|
|
393
550
|
const headerRules = await readHeaderRules(appDir);
|
|
394
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
|
+
|
|
395
568
|
// CSP config (issue #233), read once from the app's package.json
|
|
396
569
|
// `webjs.csp`. OFF by default: when disabled no nonce is minted and no
|
|
397
570
|
// Content-Security-Policy header is set, so an unconfigured app is
|
|
@@ -656,6 +829,10 @@ export async function createRequestHandler(opts) {
|
|
|
656
829
|
// The route table is the only eager artifact (cheap directory scan); rebuild
|
|
657
830
|
// it so routing reflects added/removed route files immediately.
|
|
658
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();
|
|
659
836
|
clearVendorCache();
|
|
660
837
|
state.tsCache.clear();
|
|
661
838
|
// Invalidate the lazy analysis; the next request rebuilds the graph,
|
|
@@ -679,6 +856,14 @@ export async function createRequestHandler(opts) {
|
|
|
679
856
|
/** @param {Request} req */
|
|
680
857
|
function handle(req) {
|
|
681
858
|
return withRequest(req, async () => {
|
|
859
|
+
// Correlation id (issue #239): honor an inbound X-Request-Id from a
|
|
860
|
+
// trusted upstream proxy, else mint a fresh UUID. Stored on the request
|
|
861
|
+
// scope FIRST so everything downstream (the SSR, server actions, the
|
|
862
|
+
// access / error log, the onError sink, the response header) reads the
|
|
863
|
+
// same id, threading one trace id across services.
|
|
864
|
+
const reqId = resolveRequestId(req);
|
|
865
|
+
setRequestId(reqId);
|
|
866
|
+
|
|
682
867
|
// CSP (issue #233): when enabled, mint a fresh CSPRNG nonce and store
|
|
683
868
|
// it on the request scope BEFORE producing the response, so the SSR
|
|
684
869
|
// pipeline's `cspNonce()` reads this exact value and stamps it on the
|
|
@@ -693,20 +878,58 @@ export async function createRequestHandler(opts) {
|
|
|
693
878
|
// cap the framework's own RPC / page-action body reads do.
|
|
694
879
|
setBodyLimits(state.bodyLimits);
|
|
695
880
|
|
|
696
|
-
|
|
881
|
+
let pathname = '/';
|
|
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
|
+
}
|
|
896
|
+
const startedAt = performance.now();
|
|
897
|
+
|
|
898
|
+
let res;
|
|
899
|
+
try {
|
|
900
|
+
res = await produce(req);
|
|
901
|
+
} catch (e) {
|
|
902
|
+
// A throw escaping produce() is the last-resort 500 (every interior
|
|
903
|
+
// path catches its own errors, but a surprise still must not crash the
|
|
904
|
+
// host). Fire the onError sink (best-effort) and emit a sanitized 500,
|
|
905
|
+
// preserving the prior behavior plus the new APM hook.
|
|
906
|
+
reportError(e, req, 'handle');
|
|
907
|
+
logger.error?.('[webjs] request pipeline threw', {
|
|
908
|
+
requestId: reqId,
|
|
909
|
+
method: req.method,
|
|
910
|
+
path: pathname,
|
|
911
|
+
err: e instanceof Error ? e.stack : String(e),
|
|
912
|
+
});
|
|
913
|
+
res = new Response('Server error', { status: 500 });
|
|
914
|
+
}
|
|
915
|
+
|
|
697
916
|
// Merge in the secure-by-default headers plus the per-path config
|
|
698
917
|
// (issue #232) as the final step, so app middleware, route
|
|
699
918
|
// handlers, and `expose` headers (already on `res`) always win.
|
|
700
919
|
// Applied to every served response (documents, assets, the core
|
|
701
920
|
// runtime, probes), since the defaults are universally safe.
|
|
702
|
-
let
|
|
703
|
-
|
|
704
|
-
const merged = applySecurityHeaders(res, {
|
|
705
|
-
pathname,
|
|
921
|
+
let merged = applySecurityHeaders(res, {
|
|
922
|
+
pathname: headerPathname,
|
|
706
923
|
https: webRequestIsHttps(req),
|
|
707
924
|
prod: !dev,
|
|
708
925
|
rules: headerRules,
|
|
709
926
|
});
|
|
927
|
+
|
|
928
|
+
// Expose the correlation id on the response (issue #239) so a client /
|
|
929
|
+
// proxy can read it from the X-Request-Id header. Never clobber an id an
|
|
930
|
+
// upstream / the app already set on the response.
|
|
931
|
+
if (!merged.headers.has('x-request-id')) merged.headers.set('x-request-id', reqId);
|
|
932
|
+
|
|
710
933
|
// Emit the Content-Security-Policy header carrying the SAME minted
|
|
711
934
|
// nonce the SSR'd scripts got (no drift). Set only when CSP is
|
|
712
935
|
// enabled; never clobber a CSP header the app already set (in
|
|
@@ -725,13 +948,129 @@ export async function createRequestHandler(opts) {
|
|
|
725
948
|
/* a malformed policy must not take the request down: serve without CSP */
|
|
726
949
|
}
|
|
727
950
|
}
|
|
728
|
-
|
|
951
|
+
|
|
952
|
+
// Server HTML cache write (#241): if the SSR marked this response as a
|
|
953
|
+
// cache candidate (an opted-in `revalidate` page), store the FINAL body
|
|
954
|
+
// now, after segment middleware has added any per-user Set-Cookie (which
|
|
955
|
+
// the funnel's guard re-checks, so a per-user response is not cached) and
|
|
956
|
+
// before conditional-GET can swap it for a bodiless 304. The marker is
|
|
957
|
+
// stripped here regardless. Best-effort: a store failure is swallowed.
|
|
958
|
+
try {
|
|
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
|
+
}
|
|
968
|
+
merged = await commitHtmlCache(req, merged, reqUrl);
|
|
969
|
+
} catch { /* never let the cache write crash the response */ }
|
|
970
|
+
|
|
971
|
+
// Conditional GET (RFC 7232, issue #240): attach a content-hash ETag to
|
|
972
|
+
// a cacheable response missing one, and turn a matching If-None-Match
|
|
973
|
+
// into a 304 Not Modified with no body. Applied LAST, after every header
|
|
974
|
+
// (X-Webjs-Build, X-Request-Id, Set-Cookie, CSP) is on the response, so a
|
|
975
|
+
// 304 carries the validators a shared cache and the client router need.
|
|
976
|
+
// A no-store / per-user response, a non-GET/HEAD, and a streaming Suspense
|
|
977
|
+
// body are all skipped (see conditional-get.js). Logged with the final
|
|
978
|
+
// (possibly 304) status. Best-effort: a failure leaves the 200 untouched.
|
|
979
|
+
let conditioned = merged;
|
|
980
|
+
try {
|
|
981
|
+
conditioned = await applyConditionalGet(req, merged);
|
|
982
|
+
} catch { /* never let validator computation crash the response */ }
|
|
983
|
+
|
|
984
|
+
// Structured access log (issue #239): ONE info line per handled request
|
|
985
|
+
// at the single response funnel, carrying only method / path / status /
|
|
986
|
+
// duration / requestId (no bodies, no secrets). Suppressed for the
|
|
987
|
+
// framework's own /__webjs/* probe + static traffic so it does not spam.
|
|
988
|
+
// Best-effort: a logger that throws must not take the response down.
|
|
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)) {
|
|
993
|
+
try {
|
|
994
|
+
logger.info?.('request', {
|
|
995
|
+
requestId: reqId,
|
|
996
|
+
method: req.method,
|
|
997
|
+
path: pathname,
|
|
998
|
+
status: conditioned.status,
|
|
999
|
+
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
1000
|
+
});
|
|
1001
|
+
} catch { /* never let logging crash the response */ }
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return conditioned;
|
|
729
1005
|
});
|
|
730
1006
|
}
|
|
731
1007
|
|
|
732
1008
|
/** @param {Request} req */
|
|
733
1009
|
function produce(req) {
|
|
734
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
|
+
|
|
735
1074
|
// Health and readiness probes are answered BEFORE ensureReady so a probe
|
|
736
1075
|
// never blocks on the analysis. `/__webjs/health` is liveness (the
|
|
737
1076
|
// process is up and accepting connections). `/__webjs/ready` is 503 until
|
|
@@ -751,6 +1090,13 @@ export async function createRequestHandler(opts) {
|
|
|
751
1090
|
if (probePath === '/__webjs/health') {
|
|
752
1091
|
return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
|
|
753
1092
|
}
|
|
1093
|
+
// Build-info probe (issue #239): which build is live? Answered before
|
|
1094
|
+
// ensureReady like the other probes (it depends only on the package
|
|
1095
|
+
// version + already-published build id + process info, never the app
|
|
1096
|
+
// analysis). No secrets.
|
|
1097
|
+
if (probePath === '/__webjs/version') {
|
|
1098
|
+
return buildInfoResponse();
|
|
1099
|
+
}
|
|
754
1100
|
if (probePath === '/__webjs/ready') {
|
|
755
1101
|
const noStore = { 'cache-control': 'no-store' };
|
|
756
1102
|
if (!readyDone) {
|
|
@@ -790,12 +1136,13 @@ export async function createRequestHandler(opts) {
|
|
|
790
1136
|
// Build all whole-app analysis on the first request (memoized), before
|
|
791
1137
|
// any SSR, module serve, gate check, action dispatch, or middleware runs.
|
|
792
1138
|
await ensureReady();
|
|
793
|
-
const next = () => handleCore(req, { state, appDir, coreDir, dev });
|
|
1139
|
+
const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, cspEnabled: cspConfig.enabled });
|
|
794
1140
|
if (state.middleware) {
|
|
795
1141
|
try {
|
|
796
1142
|
return await state.middleware(req, next);
|
|
797
1143
|
} catch (e) {
|
|
798
|
-
|
|
1144
|
+
reportError(e, req, 'middleware');
|
|
1145
|
+
logger.error('middleware threw', { err: String(e), requestId: getRequestId() });
|
|
799
1146
|
return new Response('Server error', { status: 500 });
|
|
800
1147
|
}
|
|
801
1148
|
}
|
|
@@ -808,14 +1155,25 @@ export async function createRequestHandler(opts) {
|
|
|
808
1155
|
* BEFORE running SSR: resolves a pathname to its page-route module URLs
|
|
809
1156
|
* without loading them. Returns null for non-page paths.
|
|
810
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
|
+
*
|
|
811
1164
|
* @param {string} pathname
|
|
812
1165
|
*/
|
|
813
1166
|
function routeFor(pathname) {
|
|
814
|
-
const
|
|
1167
|
+
const matchPathname = basePathValue
|
|
1168
|
+
? stripBasePath(pathname, basePathValue)
|
|
1169
|
+
: pathname;
|
|
1170
|
+
if (matchPathname === null) return null;
|
|
1171
|
+
const page = matchPage(state.routeTable, matchPathname);
|
|
815
1172
|
if (!page) return null;
|
|
816
1173
|
const moduleUrls = [page.route.file, ...page.route.layouts].map((f) => {
|
|
817
1174
|
let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
|
|
818
|
-
|
|
1175
|
+
const url = rel.split('\\').join('/').replace(/^\/?/, '/');
|
|
1176
|
+
return withBasePath(url, basePathValue);
|
|
819
1177
|
});
|
|
820
1178
|
return { moduleUrls };
|
|
821
1179
|
}
|
|
@@ -855,12 +1213,32 @@ export async function createRequestHandler(opts) {
|
|
|
855
1213
|
* etc.) sitting in front of this process. See the deployment docs for
|
|
856
1214
|
* the recommended topology.
|
|
857
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
|
+
/**
|
|
858
1235
|
* @param {{
|
|
859
1236
|
* appDir: string,
|
|
860
1237
|
* port?: number,
|
|
861
1238
|
* dev?: boolean,
|
|
862
1239
|
* compress?: boolean,
|
|
863
1240
|
* logger?: import('./logger.js').Logger,
|
|
1241
|
+
* onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
|
|
864
1242
|
* }} opts
|
|
865
1243
|
*/
|
|
866
1244
|
export async function startServer(opts) {
|
|
@@ -889,18 +1267,9 @@ export async function startServer(opts) {
|
|
|
889
1267
|
// `fs.promises.watch`. Stable on macOS, Windows, and Linux as of
|
|
890
1268
|
// Node 24. No external dep needed.
|
|
891
1269
|
//
|
|
892
|
-
// fs.watch returns relative paths in event.filename.
|
|
893
|
-
//
|
|
894
|
-
//
|
|
895
|
-
// dev.db-journal, migrations/) which the dev server writes
|
|
896
|
-
// during db:migrate and would otherwise loop.
|
|
897
|
-
//
|
|
898
|
-
// The prisma branch uses prefix-only matching (no required
|
|
899
|
-
// trailing separator) so the SQLite sidecar files like
|
|
900
|
-
// `prisma/dev.db` and `prisma/dev.db-journal` are ignored too.
|
|
901
|
-
// node_modules / .git stay separator-anchored so unrelated
|
|
902
|
-
// names like `node_modules.bak/foo` don't get caught.
|
|
903
|
-
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.
|
|
904
1273
|
const rebuild = debounce(() => app.rebuild(), 80);
|
|
905
1274
|
watcherAbort = new AbortController();
|
|
906
1275
|
(async () => {
|
|
@@ -908,7 +1277,7 @@ export async function startServer(opts) {
|
|
|
908
1277
|
const events = fsWatch(app.appDir, { recursive: true, signal: watcherAbort.signal });
|
|
909
1278
|
for await (const event of events) {
|
|
910
1279
|
const filename = event.filename || '';
|
|
911
|
-
if (
|
|
1280
|
+
if (shouldIgnoreWatchPath(filename)) continue;
|
|
912
1281
|
rebuild();
|
|
913
1282
|
}
|
|
914
1283
|
} catch (err) {
|
|
@@ -932,8 +1301,11 @@ export async function startServer(opts) {
|
|
|
932
1301
|
try {
|
|
933
1302
|
const url = urlFromRequest(req);
|
|
934
1303
|
|
|
935
|
-
// SSE: handled specially; doesn't fit the req→Response model.
|
|
936
|
-
|
|
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') {
|
|
937
1309
|
if (!dev) { res.writeHead(404); res.end(); return; }
|
|
938
1310
|
res.writeHead(200, {
|
|
939
1311
|
'content-type': 'text/event-stream',
|
|
@@ -1052,7 +1424,7 @@ async function tryServeFrameworkStatic(path, method, ctx) {
|
|
|
1052
1424
|
// Dev live-reload client.
|
|
1053
1425
|
if (path === '/__webjs/reload.js') {
|
|
1054
1426
|
if (!dev) return new Response('Not found', { status: 404 });
|
|
1055
|
-
return new Response(
|
|
1427
|
+
return new Response(reloadClientJs(basePath()), {
|
|
1056
1428
|
headers: { 'content-type': 'application/javascript; charset=utf-8' },
|
|
1057
1429
|
});
|
|
1058
1430
|
}
|
|
@@ -1111,7 +1483,7 @@ async function tryServeFrameworkStatic(path, method, ctx) {
|
|
|
1111
1483
|
}
|
|
1112
1484
|
|
|
1113
1485
|
async function handleCore(req, ctx) {
|
|
1114
|
-
const { state, appDir, coreDir, dev } = ctx;
|
|
1486
|
+
const { state, appDir, coreDir, dev, reportError, cspEnabled } = ctx;
|
|
1115
1487
|
const url = new URL(req.url);
|
|
1116
1488
|
// Decode percent-encoded characters so filesystem lookups match real
|
|
1117
1489
|
// filenames. Dynamic route segments like `[slug]` and route groups like
|
|
@@ -1136,7 +1508,10 @@ async function handleCore(req, ctx) {
|
|
|
1136
1508
|
const actMatch = /^\/__webjs\/action\/([a-f0-9]+)\/([A-Za-z0-9_$]+)$/.exec(path);
|
|
1137
1509
|
if (actMatch) {
|
|
1138
1510
|
if (method !== 'POST') return new Response('POST only', { status: 405 });
|
|
1139
|
-
|
|
1511
|
+
// Pass the onError sink (issue #239): a server action that throws
|
|
1512
|
+
// unexpectedly is reported to the APM hook before the sanitized 500.
|
|
1513
|
+
const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
|
|
1514
|
+
return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError);
|
|
1140
1515
|
}
|
|
1141
1516
|
|
|
1142
1517
|
// expose()d server actions (first-class REST), with optional CORS support.
|
|
@@ -1157,7 +1532,12 @@ async function handleCore(req, ctx) {
|
|
|
1157
1532
|
} else {
|
|
1158
1533
|
const exposed = matchExposedAction(state.actionIndex, method, path);
|
|
1159
1534
|
if (exposed) {
|
|
1160
|
-
|
|
1535
|
+
// Pass the onError sink (issue #239): an exposed REST handler that throws
|
|
1536
|
+
// unexpectedly is reported to the APM hook before the sanitized 500, the
|
|
1537
|
+
// same as the RPC action path (phase 'action' covers both server-action
|
|
1538
|
+
// invocation shapes).
|
|
1539
|
+
const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
|
|
1540
|
+
const resp = await invokeExposedAction(state.actionIndex, exposed.route, exposed.params, req, onActionError);
|
|
1161
1541
|
return withCors(resp, exposed.route, req);
|
|
1162
1542
|
}
|
|
1163
1543
|
}
|
|
@@ -1281,6 +1661,7 @@ async function handleCore(req, ctx) {
|
|
|
1281
1661
|
});
|
|
1282
1662
|
}
|
|
1283
1663
|
} catch (e) {
|
|
1664
|
+
if (reportError) reportError(e, req, 'metadata');
|
|
1284
1665
|
if (dev) console.error(`[webjs] metadata route error (${meta.stem}):`, e);
|
|
1285
1666
|
return new Response('Internal error', { status: 500 });
|
|
1286
1667
|
}
|
|
@@ -1309,6 +1690,14 @@ async function handleCore(req, ctx) {
|
|
|
1309
1690
|
elidableComponents: state.elidableComponents,
|
|
1310
1691
|
inertRouteModules: state.inertRouteModules,
|
|
1311
1692
|
notFoundFile: state.routeTable.notFound,
|
|
1693
|
+
// Server HTML cache (#241): a CSP-enabled page emits a fresh
|
|
1694
|
+
// per-request nonce into its body, so its bytes vary per request and
|
|
1695
|
+
// it must never be HTML-cached. Pass the flag so the cache guard skips
|
|
1696
|
+
// it. CSP is off by default, so the common case stays cacheable.
|
|
1697
|
+
cspEnabled,
|
|
1698
|
+
// onError sink (issue #239): a page render error that becomes a 500 is
|
|
1699
|
+
// reported to the APM hook with the active request's correlation id.
|
|
1700
|
+
onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
|
|
1312
1701
|
};
|
|
1313
1702
|
if (method === 'GET' || method === 'HEAD') {
|
|
1314
1703
|
const handler = () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
@@ -1577,16 +1966,15 @@ async function fileResponse(abs, opts) {
|
|
|
1577
1966
|
try {
|
|
1578
1967
|
const data = await readFile(abs);
|
|
1579
1968
|
const type = MIME[extname(abs).toLowerCase()] || 'application/octet-stream';
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1969
|
+
// The body is fully buffered (read into `data`), so opt it into the
|
|
1970
|
+
// conditional-GET funnel, which is the single place that hashes the bytes
|
|
1971
|
+
// into a weak ETag and honors If-None-Match -> 304 (dev + prod alike).
|
|
1972
|
+
const headers = { 'content-type': type, [BUFFERED_MARKER]: '1' };
|
|
1973
|
+
headers['cache-control'] = opts.dev
|
|
1974
|
+
? 'no-cache'
|
|
1975
|
+
: opts.immutable
|
|
1587
1976
|
? 'public, max-age=31536000, immutable'
|
|
1588
1977
|
: 'public, max-age=3600';
|
|
1589
|
-
}
|
|
1590
1978
|
return new Response(data, { status: 200, headers });
|
|
1591
1979
|
} catch {
|
|
1592
1980
|
return new Response('Not found', { status: 404 });
|
|
@@ -1611,13 +1999,13 @@ async function jsModuleResponse(abs, dev, elideOpts) {
|
|
|
1611
1999
|
const code = elideImportsFromSource(
|
|
1612
2000
|
source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
1613
2001
|
);
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
}
|
|
2002
|
+
// Buffered (string) body, so opt into the conditional-GET funnel for the
|
|
2003
|
+
// weak ETag + 304 (see fileResponse).
|
|
2004
|
+
const headers = {
|
|
2005
|
+
'content-type': 'application/javascript; charset=utf-8',
|
|
2006
|
+
'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
|
|
2007
|
+
[BUFFERED_MARKER]: '1',
|
|
2008
|
+
};
|
|
1621
2009
|
return new Response(code, { status: 200, headers });
|
|
1622
2010
|
}
|
|
1623
2011
|
|
|
@@ -1670,6 +2058,7 @@ async function tsResponse(abs, dev, elideOpts, cache) {
|
|
|
1670
2058
|
headers: {
|
|
1671
2059
|
'content-type': 'application/javascript; charset=utf-8',
|
|
1672
2060
|
'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
|
|
2061
|
+
[BUFFERED_MARKER]: '1',
|
|
1673
2062
|
},
|
|
1674
2063
|
});
|
|
1675
2064
|
}
|
|
@@ -1726,6 +2115,7 @@ async function tsResponse(abs, dev, elideOpts, cache) {
|
|
|
1726
2115
|
headers: {
|
|
1727
2116
|
'content-type': 'application/javascript; charset=utf-8',
|
|
1728
2117
|
'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
|
|
2118
|
+
[BUFFERED_MARKER]: '1',
|
|
1729
2119
|
},
|
|
1730
2120
|
});
|
|
1731
2121
|
}
|
|
@@ -1851,7 +2241,17 @@ function locatePackageDir(appDir, pkgName) {
|
|
|
1851
2241
|
return null;
|
|
1852
2242
|
}
|
|
1853
2243
|
|
|
1854
|
-
|
|
1855
|
-
|
|
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))});
|
|
1856
2255
|
es.addEventListener('reload', () => location.reload());
|
|
1857
2256
|
`;
|
|
2257
|
+
}
|