@webjsdev/server 0.8.16 → 0.8.18
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.d.ts +33 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/asset-hash.js +155 -1
- package/src/check.js +37 -7
- package/src/component-elision.js +8 -0
- package/src/dev-error.js +129 -0
- package/src/dev-overlay.js +75 -0
- package/src/dev.js +124 -8
- package/src/frame-render.js +172 -0
- package/src/html-cache.js +46 -3
- package/src/js-scan.js +13 -3
- package/src/page-action.js +7 -0
- package/src/ssr.js +31 -0
- package/src/stream.js +95 -0
package/index.d.ts
CHANGED
|
@@ -91,6 +91,15 @@ export interface RequestHandlerOptions {
|
|
|
91
91
|
error: unknown,
|
|
92
92
|
ctx: { request: Request; requestId: string | null; phase: string },
|
|
93
93
|
) => void;
|
|
94
|
+
/** Called when a dev source change has been applied (the live-reload trigger). */
|
|
95
|
+
onReload?: () => void;
|
|
96
|
+
/**
|
|
97
|
+
* Dev error overlay sink (#264): called with a structured error frame when a
|
|
98
|
+
* dev render crash, a non-erasable-TS strip failure, or a failed rebuild
|
|
99
|
+
* occurs. `startServer` wires this to the SSE overlay channel. Dev-only and
|
|
100
|
+
* best-effort; never fires in prod.
|
|
101
|
+
*/
|
|
102
|
+
onDevError?: (frame: object) => void;
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
/** A matched page route for a path, as returned by `routeFor`. */
|
|
@@ -110,6 +119,8 @@ export interface RequestHandler {
|
|
|
110
119
|
warmup: () => Promise<void>;
|
|
111
120
|
/** Current route table getter (used by the WebSocket subsystem). */
|
|
112
121
|
getRouteTable: () => unknown;
|
|
122
|
+
/** Current unresolved dev error frame (#264), or null (always null in prod). */
|
|
123
|
+
getLastDevError: () => object | null;
|
|
113
124
|
/** The resolved app root. */
|
|
114
125
|
appDir: string;
|
|
115
126
|
/** Whether the handler is in dev mode. */
|
|
@@ -640,6 +651,28 @@ export declare function sitemap(entries: SitemapEntry[]): string;
|
|
|
640
651
|
/** Serialize child sitemaps into a `<sitemapindex>` XML document. */
|
|
641
652
|
export declare function sitemapIndex(sitemaps: SitemapIndexEntry[]): string;
|
|
642
653
|
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
// stream.js (#248): stream-action helpers (the `<webjs-stream>` HTML the
|
|
656
|
+
// client `renderStream` / element applies surgically)
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
/** The content type that negotiates and carries a stream-action response. */
|
|
660
|
+
export declare const STREAM_MIME: string;
|
|
661
|
+
/** Report whether a request opted into a stream-action response (its `Accept` carries the stream MIME). */
|
|
662
|
+
export declare function acceptsStream(req: Request | { headers?: Headers }): boolean;
|
|
663
|
+
/** The stream-action builder; each method returns one `<webjs-stream>` HTML string. */
|
|
664
|
+
export declare const stream: {
|
|
665
|
+
append(target: string, content: string): string;
|
|
666
|
+
prepend(target: string, content: string): string;
|
|
667
|
+
before(target: string, content: string): string;
|
|
668
|
+
after(target: string, content: string): string;
|
|
669
|
+
replace(target: string, content: string): string;
|
|
670
|
+
update(target: string, content: string): string;
|
|
671
|
+
remove(target: string): string;
|
|
672
|
+
};
|
|
673
|
+
/** Wrap one or more stream-action strings in a `Response` carrying the stream content type. */
|
|
674
|
+
export declare function streamResponse(...parts: string[]): Response;
|
|
675
|
+
|
|
643
676
|
// ---------------------------------------------------------------------------
|
|
644
677
|
// websocket.js
|
|
645
678
|
// ---------------------------------------------------------------------------
|
package/index.js
CHANGED
|
@@ -49,6 +49,7 @@ export { broadcast } from './src/broadcast.js';
|
|
|
49
49
|
export { diskStore, setFileStore, getFileStore, generateKey, signedUrl, verifySignedUrl, assertSafeKey, DEFAULT_UPLOAD_DIR } from './src/file-storage.js';
|
|
50
50
|
export { json, readBody } from './src/json.js';
|
|
51
51
|
export { sitemap, sitemapIndex } from './src/sitemap.js';
|
|
52
|
+
export { stream, streamResponse, acceptsStream, STREAM_MIME } from './src/stream.js';
|
|
52
53
|
export { attachWebSocket } from './src/websocket.js';
|
|
53
54
|
export { getSerializer, setSerializer, defaultSerializer } from './src/serializer.js';
|
|
54
55
|
|
package/package.json
CHANGED
package/src/asset-hash.js
CHANGED
|
@@ -39,7 +39,9 @@
|
|
|
39
39
|
|
|
40
40
|
import { createHash } from 'node:crypto';
|
|
41
41
|
import { readFileSync } from 'node:fs';
|
|
42
|
-
import { join, resolve, sep } from 'node:path';
|
|
42
|
+
import { extname, join, resolve, sep } from 'node:path';
|
|
43
|
+
import { redactStringsAndTemplates } from './js-scan.js';
|
|
44
|
+
import { resolveImport } from './module-graph.js';
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* Length of the emitted hex hash. 12 hex chars (48 bits) is plenty to
|
|
@@ -264,3 +266,155 @@ export function withAssetHash(url, basePath = '') {
|
|
|
264
266
|
const sepChar = url.includes('?') ? '&' : '?';
|
|
265
267
|
return `${url}${sepChar}v=${hash}`;
|
|
266
268
|
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Static `import` specifier with positional indices. Mirrors module-graph.js's
|
|
272
|
+
* `IMPORT_RE` (side-effect / default / namespace / named imports), but captures
|
|
273
|
+
* the quote (group 1) and specifier (group 2) so the `/d` flag yields the
|
|
274
|
+
* specifier's start/end for an in-place splice. Excludes dynamic `import(...)`
|
|
275
|
+
* (no whitespace after `import`) and `import.meta` (no quote), exactly like the
|
|
276
|
+
* graph scanner, so the rewrite set is the static-import set the modulepreload
|
|
277
|
+
* hints cover.
|
|
278
|
+
*
|
|
279
|
+
* @type {RegExp}
|
|
280
|
+
*/
|
|
281
|
+
const IMPORT_SPEC_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?(['"])([^'"]+)\1/dg;
|
|
282
|
+
|
|
283
|
+
/** Match `export … from '…'` re-exports (mirrors module-graph's EXPORT_FROM_RE). @type {RegExp} */
|
|
284
|
+
const EXPORT_SPEC_RE = /\bexport\b[^'";]+?\sfrom\s+(['"])([^'"]+)\1/dg;
|
|
285
|
+
|
|
286
|
+
/** A server-file specifier: served as a stub at a bare URL, never preloaded. @type {RegExp} */
|
|
287
|
+
const SERVER_FILE_RE = /\.server\.(?:js|ts|mjs|mts)$/;
|
|
288
|
+
|
|
289
|
+
/** JS-ish specifier extensions that map to a `.ts`/`.mts` sibling on disk. @type {Set<string>} */
|
|
290
|
+
const JS_SPEC_EXTS = new Set(['.js', '.mjs', '.jsx', '.cjs']);
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Normalize a relative import specifier's extension to the RESOLVED file's, so
|
|
294
|
+
* the URL the browser fetches equals the modulepreload href (which `ssr.js`
|
|
295
|
+
* derives from the resolved absolute path via `toUrlPath`, after `resolveImport`
|
|
296
|
+
* rewrites a `.js` specifier to its `.ts` sibling and resolves an extensionless
|
|
297
|
+
* one). Without this, an `import './x.js'` whose file is `x.ts` would be served
|
|
298
|
+
* `./x.js?v=H` (fetched as `/x.js?v=H`) while the preload is `/x.ts?v=H`: same
|
|
299
|
+
* hash, different path, so the preload is wasted and the file double-fetched.
|
|
300
|
+
*
|
|
301
|
+
* Conservative: only swaps a known JS-ish extension or appends to a genuinely
|
|
302
|
+
* extensionless basename; a directory specifier (`.` / `..` / trailing-slash)
|
|
303
|
+
* or an unrecognized extension is left untouched.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} spec the author's relative specifier (no query)
|
|
306
|
+
* @param {string} abs the resolved absolute file path
|
|
307
|
+
* @returns {string}
|
|
308
|
+
*/
|
|
309
|
+
function normalizeSpecToResolved(spec, abs) {
|
|
310
|
+
const absExt = extname(abs);
|
|
311
|
+
if (!absExt) return spec;
|
|
312
|
+
const specExt = extname(spec);
|
|
313
|
+
if (specExt === absExt) return spec;
|
|
314
|
+
const base = spec.slice(spec.lastIndexOf('/') + 1);
|
|
315
|
+
if (base === '' || base === '.' || base === '..') return spec; // directory import
|
|
316
|
+
if (specExt) return JS_SPEC_EXTS.has(specExt) ? spec.slice(0, -specExt.length) + absExt : spec;
|
|
317
|
+
return spec + absExt; // extensionless
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Append `?v=<content-hash>` to every SAME-ORIGIN relative / root-absolute
|
|
322
|
+
* static-import specifier in a served module's source, so the URL the browser
|
|
323
|
+
* actually fetches matches the `?v=`-versioned `modulepreload` hint and boot
|
|
324
|
+
* specifier the framework emits for that file.
|
|
325
|
+
*
|
|
326
|
+
* The bug this fixes (#369): the boot script imports the entry modules with
|
|
327
|
+
* `?v=<hash>` and the head emits `<link rel=modulepreload href=".../x.ts?v=hash">`
|
|
328
|
+
* for every transitive module, but a layout/page imports its components with a
|
|
329
|
+
* BARE relative specifier (`import '../components/x.ts'`). The browser resolves
|
|
330
|
+
* a relative specifier against the importer's URL and a `?v` query is NOT
|
|
331
|
+
* inherited across that resolution, so it fetches the UN-versioned URL: a
|
|
332
|
+
* different cache key from the preload. Result: the preload is wasted, the
|
|
333
|
+
* module is downloaded a second time, and the served copy gets the 1h fallback
|
|
334
|
+
* cache header instead of `immutable`. Rewriting the specifier in the served
|
|
335
|
+
* source to carry the same `?v=<hash>` collapses both to one cache key (one
|
|
336
|
+
* fetch, preload used, immutable cached).
|
|
337
|
+
*
|
|
338
|
+
* The appended hash is `assetHashFor(resolvedTarget)`, the exact value
|
|
339
|
+
* `withAssetHash` computes for the modulepreload href of the same file (same
|
|
340
|
+
* absolute path -> same hash, elision-fingerprint fold included), so the two
|
|
341
|
+
* URLs are byte-identical by construction.
|
|
342
|
+
*
|
|
343
|
+
* The fetched URL is made to equal the preload href even when the specifier's
|
|
344
|
+
* extension differs from the file on disk: `normalizeSpecToResolved` rewrites a
|
|
345
|
+
* `.js`/extensionless specifier to the resolved `.ts` sibling (the form the
|
|
346
|
+
* preload, derived from the resolved path, uses), then `?v` is appended.
|
|
347
|
+
*
|
|
348
|
+
* Scope, mirroring the modulepreload set so the rewrite never diverges from it:
|
|
349
|
+
* - Only `.`-relative specifiers (`./`, `../`). The browser resolves these
|
|
350
|
+
* against the importer's own URL, which is already base-path-prefixed, so
|
|
351
|
+
* the rewrite is base-path-correct by construction. A `/`-root-absolute
|
|
352
|
+
* specifier is deliberately NOT versioned: it would miss the `webjs.basePath`
|
|
353
|
+
* prefix under a sub-path deploy (a pre-existing author-import limitation),
|
|
354
|
+
* and apps use relative imports. A BARE specifier (`@webjsdev/core`) is
|
|
355
|
+
* importmap-resolved and versioned at its importmap TARGET; rewriting it here
|
|
356
|
+
* would break the importmap key.
|
|
357
|
+
* - Only targets that resolve to a readable file UNDER `_appDir` (the servable
|
|
358
|
+
* same-origin root). A `.server.*` target is excluded: it serves as a stub
|
|
359
|
+
* at a bare URL and is never in the preload set, and its served bytes are
|
|
360
|
+
* generated (not the file bytes the hash covers).
|
|
361
|
+
* - A specifier already carrying a query is left as-is.
|
|
362
|
+
*
|
|
363
|
+
* Matching runs over a redaction mask with `blankStrings` on (NO literal body
|
|
364
|
+
* survives), so an `import`/`export … from` printed inside an `html\`\`` template
|
|
365
|
+
* OR a plain string literal is never rewritten (a plain-string body is NOT
|
|
366
|
+
* blanked by the default mask, so the stricter mask is required here to avoid
|
|
367
|
+
* splicing `?v` into the served string's value).
|
|
368
|
+
*
|
|
369
|
+
* A pure pass-through when fingerprinting is disabled (dev, or roots unset), so
|
|
370
|
+
* dev output stays byte-identical. Runs AFTER `elideImportsFromSource` in the
|
|
371
|
+
* serve path, so an elided side-effect import (already replaced by a comment)
|
|
372
|
+
* is not matched.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} source the served module source (post-elision, type-stripped if TS)
|
|
375
|
+
* @param {string} importerAbs absolute path of the module being served
|
|
376
|
+
* @returns {string}
|
|
377
|
+
*/
|
|
378
|
+
export function versionModuleImports(source, importerAbs) {
|
|
379
|
+
if (!_enabled || !_appDir) return source;
|
|
380
|
+
if (typeof source !== 'string') return source;
|
|
381
|
+
// Cheap bail before any regex/redaction work for a module with no imports.
|
|
382
|
+
if (!source.includes('import') && !source.includes('export')) return source;
|
|
383
|
+
|
|
384
|
+
const masked = redactStringsAndTemplates(source, true);
|
|
385
|
+
/** @type {Array<{ start: number, end: number, text: string }>} */
|
|
386
|
+
const edits = [];
|
|
387
|
+
for (const re of [IMPORT_SPEC_RE, EXPORT_SPEC_RE]) {
|
|
388
|
+
re.lastIndex = 0;
|
|
389
|
+
for (const m of source.matchAll(re)) {
|
|
390
|
+
// The keyword must sit in code position; if blanked in the mask it lives
|
|
391
|
+
// inside a string / template / comment and is not a real import edge.
|
|
392
|
+
if (masked[/** @type {number} */ (m.index)] === ' ') continue;
|
|
393
|
+
const spec = m[2];
|
|
394
|
+
// Only `./` `../` relative specifiers (base-path-correct, the app pattern).
|
|
395
|
+
// Bare -> importmap target; `/`-absolute -> pre-existing basePath gap.
|
|
396
|
+
if (spec[0] !== '.') continue;
|
|
397
|
+
if (spec.includes('?')) continue;
|
|
398
|
+
const abs = resolveImport(spec, importerAbs, _appDir);
|
|
399
|
+
if (!abs || SERVER_FILE_RE.test(abs)) continue;
|
|
400
|
+
if (!abs.startsWith(_appDir + sep)) continue;
|
|
401
|
+
const hash = assetHashFor(abs);
|
|
402
|
+
if (!hash) continue;
|
|
403
|
+
// `/d` flag: indices[2] is [start, end] of the specifier CONTENT (no
|
|
404
|
+
// quotes). Replace it with the resolved-extension form + `?v` so the
|
|
405
|
+
// fetched URL is byte-identical to the preload href.
|
|
406
|
+
const [start, end] = /** @type {[number, number]} */ (m.indices[2]);
|
|
407
|
+
edits.push({ start, end, text: `${normalizeSpecToResolved(spec, abs)}?v=${hash}` });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (edits.length === 0) return source;
|
|
411
|
+
|
|
412
|
+
edits.sort((a, b) => a.start - b.start);
|
|
413
|
+
let out = '';
|
|
414
|
+
let last = 0;
|
|
415
|
+
for (const e of edits) {
|
|
416
|
+
out += source.slice(last, e.start) + e.text;
|
|
417
|
+
last = e.end;
|
|
418
|
+
}
|
|
419
|
+
return out + source.slice(last);
|
|
420
|
+
}
|
package/src/check.js
CHANGED
|
@@ -91,13 +91,18 @@ export const RULES = [
|
|
|
91
91
|
{
|
|
92
92
|
name: 'gitignore-vendor-not-ignored',
|
|
93
93
|
description:
|
|
94
|
-
'Verifies the `.gitignore` exception for `.webjs/vendor/` is structurally correct via `git check-ignore`. The intended pattern is
|
|
94
|
+
'Verifies the `.gitignore` exception for `.webjs/vendor/` is structurally correct via `git check-ignore`. The intended pattern is `**/.webjs/*` (NOT `.webjs/`) plus `!**/.webjs/vendor/` plus `!**/.webjs/vendor/**`. The `**/` prefix matches `.webjs/` at any depth so a nested / monorepo app does not leak its generated `.webjs/routes.d.ts`; the older root-anchored `.webjs/*` also passes this rule (the probe is run from the app root). The common-looking pattern `.webjs/` excludes the directory itself, after which git cannot re-include children (gitignore semantics: a parent exclusion blocks child negations). Without this rule, an AI agent or human editor would silently break `webjs vendor pin` by simplifying the pattern; the failure is invisible until production. Rule fires when the working directory is a git repo and a `.gitignore` exists; skipped when neither is true.',
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
name: 'no-browser-globals-in-render',
|
|
98
98
|
description:
|
|
99
99
|
'Flags genuinely browser-only APIs used in a WebComponent constructor, willUpdate, or render() method. The SSR pipeline instantiates the component, runs willUpdate plus controllers\' hostUpdate, reflects properties, and calls render() to produce HTML, on a server element shim that backs the attribute methods but has no real DOM. So a browser global (document, window, localStorage, sessionStorage, navigator, location, matchMedia, screen, history) or an unshimmed HTMLElement member on `this` (attachShadow, shadowRoot, classList, querySelector, querySelectorAll, getBoundingClientRect, focus, blur, scrollIntoView) touched there throws at SSR time (the isomorphic footgun). The attribute methods (getAttribute/setAttribute/hasAttribute/removeAttribute/toggleAttribute), the event methods (addEventListener/removeEventListener/dispatchEvent), and attachInternals are shim-backed and run server-side, so they are NOT flagged. The flagged APIs belong in connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor (or derive them in willUpdate) only from server-known inputs (attributes, props). Conservative: only the constructor, willUpdate, and render bodies are scanned, and only direct references, so helper indirection is not flagged (the runtime SSR error covers that case).',
|
|
100
100
|
},
|
|
101
|
+
{
|
|
102
|
+
name: 'no-scaffold-placeholder',
|
|
103
|
+
description:
|
|
104
|
+
'The one sentinel-based check, and the deliberate exception to the "objectively broken code" framing of the other rules: it flags scaffold example content that has not been replaced, so a delivered app contains only what the user intended and never leftover scaffold code. `webjs create` marks its example/demo files (the example homepage in app/page.ts, the example app chrome in app/layout.ts) with a one-line marker comment carrying the literal token `webjs-scaffold-placeholder`. The rule scans raw source (the marker lives in a comment, which the redacted scan view would hide) and fails for every file that still carries the token. A freshly scaffolded app fails this rule BY DESIGN until its placeholders are addressed: replace the example content, or deliberately keep it, and in either case delete the marker line. No finished app legitimately ships a literal remove-me marker, so this still qualifies as a check rather than a convention. The marker is acknowledge-and-remove, so keeping a piece is a one-line deletion rather than a forced rewrite.',
|
|
105
|
+
},
|
|
101
106
|
];
|
|
102
107
|
|
|
103
108
|
/** Set of all known rule names for fast lookup. */
|
|
@@ -465,6 +470,27 @@ export async function checkConventions(appDir) {
|
|
|
465
470
|
}
|
|
466
471
|
}
|
|
467
472
|
|
|
473
|
+
// --- Rule: no-scaffold-placeholder ---
|
|
474
|
+
// `webjs create` marks its example/demo files (app/page.ts, app/layout.ts)
|
|
475
|
+
// with a one-line marker comment so a delivered app cannot silently ship
|
|
476
|
+
// leftover scaffold content. Scan RAW `content`: the marker lives in a
|
|
477
|
+
// comment, which the `scan` view redacts to whitespace. One violation per
|
|
478
|
+
// still-marked file. The token is assembled here so this source does not
|
|
479
|
+
// itself carry the contiguous literal.
|
|
480
|
+
{
|
|
481
|
+
const MARKER = 'webjs-scaffold-' + 'placeholder';
|
|
482
|
+
for (const { rel, content } of files) {
|
|
483
|
+
if (!content.includes(MARKER)) continue;
|
|
484
|
+
violations.push({
|
|
485
|
+
rule: 'no-scaffold-placeholder',
|
|
486
|
+
file: rel,
|
|
487
|
+
message:
|
|
488
|
+
'Scaffold placeholder marker still present. This file is unmodified example content from `webjs create`, and the delivered app should contain only what the user intended, not leftover scaffold code.',
|
|
489
|
+
fix: `Replace the example content (or deliberately keep it), then delete the marker comment line carrying the ${MARKER} token.`,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
468
494
|
// --- Rule: no-server-env-in-components ---
|
|
469
495
|
// Catches `process.env.X` reads in component files where X is not a
|
|
470
496
|
// WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
|
|
@@ -728,9 +754,12 @@ export async function checkConventions(appDir) {
|
|
|
728
754
|
// --- Rule: gitignore-vendor-not-ignored ---
|
|
729
755
|
// The .gitignore pattern for .webjs/vendor/ is subtle: `.webjs/`
|
|
730
756
|
// alone excludes the directory entirely and git can't re-include
|
|
731
|
-
// children of an excluded parent. The correct pattern is
|
|
732
|
-
// plus
|
|
733
|
-
//
|
|
757
|
+
// children of an excluded parent. The correct pattern is `**/.webjs/*`
|
|
758
|
+
// plus `!**/.webjs/vendor/` plus `!**/.webjs/vendor/**` (the `**/`
|
|
759
|
+
// prefix ignores `.webjs/` at any depth so a nested app does not leak
|
|
760
|
+
// its generated routes.d.ts; the older root-anchored `.webjs/*` also
|
|
761
|
+
// passes, since this probe runs from the app root). AI agents and
|
|
762
|
+
// human reviewers frequently "simplify" this back to `.webjs/`,
|
|
734
763
|
// silently breaking `webjs vendor pin`.
|
|
735
764
|
//
|
|
736
765
|
// This rule verifies the actual gitignore behavior by spawning
|
|
@@ -778,9 +807,10 @@ export async function checkConventions(appDir) {
|
|
|
778
807
|
`${probe} is gitignored, but \`webjs vendor pin\` writes files under .webjs/vendor/ and they MUST be committed for production deploys to use the pin (instead of calling api.jspm.io on every cold start). The most common cause: a \`.webjs/\` line in .gitignore that excludes the parent directory before the \`!.webjs/vendor/\` exception can take effect (git semantics: a parent exclusion blocks child negations). A second possible cause is a broader rule (e.g. \`*.js\` at root) that hides bundle files added by \`webjs vendor pin --download\`.`,
|
|
779
808
|
fix:
|
|
780
809
|
'Replace `.webjs/` in your .gitignore with this three-line pattern:\n' +
|
|
781
|
-
'
|
|
782
|
-
'
|
|
783
|
-
'
|
|
810
|
+
' **/.webjs/*\n' +
|
|
811
|
+
' !**/.webjs/vendor/\n' +
|
|
812
|
+
' !**/.webjs/vendor/**\n' +
|
|
813
|
+
'The `**/` prefix ignores `.webjs/` at any depth (so a nested / monorepo app does not leak its generated `.webjs/routes.d.ts`) while still re-including the committed vendor pin. ' +
|
|
784
814
|
'Verify with `git check-ignore -q .webjs/vendor/importmap.json` (exit 1 means correctly un-ignored).',
|
|
785
815
|
});
|
|
786
816
|
}
|
package/src/component-elision.js
CHANGED
|
@@ -59,6 +59,10 @@ export const REACTIVE_IMPORTS = new Set([
|
|
|
59
59
|
'computed',
|
|
60
60
|
'effect',
|
|
61
61
|
'watch',
|
|
62
|
+
// `optimistic(signal, value, action)` mutates a signal (client work) and
|
|
63
|
+
// settles after a server round-trip, so a component importing it is not
|
|
64
|
+
// display-only and must ship.
|
|
65
|
+
'optimistic',
|
|
62
66
|
'Task',
|
|
63
67
|
'until',
|
|
64
68
|
'asyncAppend',
|
|
@@ -66,6 +70,10 @@ export const REACTIVE_IMPORTS = new Set([
|
|
|
66
70
|
'ContextProvider',
|
|
67
71
|
'ContextConsumer',
|
|
68
72
|
'connectWS',
|
|
73
|
+
// `renderStream(payload)` applies a server stream-action payload to the live
|
|
74
|
+
// DOM (append/replace/remove), so a component importing it does client work
|
|
75
|
+
// and must ship (#248).
|
|
76
|
+
'renderStream',
|
|
69
77
|
// Client-only directives. `ref` / `createRef` fire a callback against the
|
|
70
78
|
// live element (focus, measure, third-party mount); `live` syncs an input
|
|
71
79
|
// value against the DOM. All produce identical SSR HTML but do real work
|
package/src/dev-error.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev error overlay frames (#264).
|
|
3
|
+
*
|
|
4
|
+
* The dev server pushes a structured error frame over the existing SSE reload
|
|
5
|
+
* channel so the open tab renders a rich overlay (message, parsed location, a
|
|
6
|
+
* source code frame) without a manual reload, the way Vite's HMR error overlay
|
|
7
|
+
* and Next's dev overlay work. This module is the PURE frame builder: it parses
|
|
8
|
+
* an error's stack for the offending file location and reads a source excerpt.
|
|
9
|
+
* It is DEV-ONLY by construction (the caller gates on `dev`), so no file path or
|
|
10
|
+
* source ever reaches a production response.
|
|
11
|
+
*
|
|
12
|
+
* `buildDevErrorFrame` returns a plain JSON-serializable object the reload
|
|
13
|
+
* client renders. The only side effect is a guarded `readFileSync` of the
|
|
14
|
+
* offending source file for the code frame (a read failure degrades to no
|
|
15
|
+
* frame, never throws).
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pull the first useful `file:line:column` out of an error stack. Prefers a
|
|
21
|
+
* frame under `appDir` (the user's own code) over a framework / node_modules
|
|
22
|
+
* frame, so the overlay points at the edit that broke, not webjs internals.
|
|
23
|
+
*
|
|
24
|
+
* Handles the V8 stack forms `at fn (/abs/file.ts:12:5)`, `at /abs/file.ts:12:5`,
|
|
25
|
+
* and `at file:///abs/file.ts:12:5`.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} stack
|
|
28
|
+
* @param {string} [appDir]
|
|
29
|
+
* @returns {{ file: string, line: number, column: number } | null}
|
|
30
|
+
*/
|
|
31
|
+
export function parseStackLocation(stack, appDir) {
|
|
32
|
+
if (typeof stack !== 'string') return null;
|
|
33
|
+
// Capture `/abs/path` (group 1) then an OPTIONAL `?query` (the dev module
|
|
34
|
+
// loader appends a `?t=<mtime>` cache-bust, which would otherwise be glued to
|
|
35
|
+
// the path and break the readFileSync that builds the code frame), then
|
|
36
|
+
// `:line:col`. The path class excludes `?` so the query is split off.
|
|
37
|
+
//
|
|
38
|
+
// POSIX-first: this anchors on a leading `/`, so a Windows `C:\...` bare-path
|
|
39
|
+
// frame yields no location and the overlay degrades to a message-only card
|
|
40
|
+
// (the `file:///` URL form, whose path uses forward slashes, still resolves).
|
|
41
|
+
// That matches the framework's POSIX-first dev posture; it degrades, never
|
|
42
|
+
// crashes.
|
|
43
|
+
const re = /(?:file:\/\/)?(\/[^\s:()?]+)(?:\?[^\s:()]*)?:(\d+):(\d+)/g;
|
|
44
|
+
/** @type {{ file: string, line: number, column: number }[]} */
|
|
45
|
+
const frames = [];
|
|
46
|
+
let m;
|
|
47
|
+
while ((m = re.exec(stack)) !== null) {
|
|
48
|
+
frames.push({ file: m[1], line: Number(m[2]), column: Number(m[3]) });
|
|
49
|
+
}
|
|
50
|
+
if (!frames.length) return null;
|
|
51
|
+
// Prefer a frame in the app (not node_modules), then the first app frame,
|
|
52
|
+
// else the first frame overall.
|
|
53
|
+
const inApp = frames.filter(
|
|
54
|
+
(f) => (!appDir || f.file.startsWith(appDir)) && !f.file.includes('/node_modules/')
|
|
55
|
+
);
|
|
56
|
+
return (inApp[0] || frames.find((f) => !f.file.includes('/node_modules/')) || frames[0]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read a source excerpt around `line` (1-based), `context` lines on each side,
|
|
61
|
+
* formatted with line numbers and a `>` marker on the offending line plus a
|
|
62
|
+
* caret under `column`. Returns null when the file cannot be read.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} file
|
|
65
|
+
* @param {number} line
|
|
66
|
+
* @param {number} [column]
|
|
67
|
+
* @param {number} [context]
|
|
68
|
+
* @returns {string | null}
|
|
69
|
+
*/
|
|
70
|
+
export function readCodeFrame(file, line, column, context = 3) {
|
|
71
|
+
let src;
|
|
72
|
+
try { src = readFileSync(file, 'utf8'); } catch { return null; }
|
|
73
|
+
const lines = src.split('\n');
|
|
74
|
+
if (line < 1 || line > lines.length) return null;
|
|
75
|
+
const start = Math.max(1, line - context);
|
|
76
|
+
const end = Math.min(lines.length, line + context);
|
|
77
|
+
const gutter = String(end).length;
|
|
78
|
+
/** @type {string[]} */
|
|
79
|
+
const out = [];
|
|
80
|
+
for (let n = start; n <= end; n++) {
|
|
81
|
+
const marker = n === line ? '>' : ' ';
|
|
82
|
+
out.push(`${marker} ${String(n).padStart(gutter)} | ${lines[n - 1]}`);
|
|
83
|
+
if (n === line && column && column > 0) {
|
|
84
|
+
// A caret line under the offending column (account for the gutter prefix).
|
|
85
|
+
out.push(` ${' '.repeat(gutter)} | ${' '.repeat(column - 1)}^`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out.join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a dev error frame from an error. DEV-ONLY (the caller gates on `dev`).
|
|
93
|
+
*
|
|
94
|
+
* @param {unknown} error
|
|
95
|
+
* @param {{ kind?: 'render' | 'ts-strip' | 'rebuild', appDir?: string, file?: string, line?: number, column?: number, hint?: string }} [opts]
|
|
96
|
+
* @returns {{ kind: string, message: string, stack: string|null, file: string|null, line: number|null, column: number|null, codeFrame: string|null, hint: string|null }}
|
|
97
|
+
*/
|
|
98
|
+
export function buildDevErrorFrame(error, opts = {}) {
|
|
99
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
100
|
+
const kind = opts.kind || 'render';
|
|
101
|
+
const stack = typeof err.stack === 'string' ? err.stack : null;
|
|
102
|
+
// An explicit file (the ts-strip case, where the strip error names the .ts
|
|
103
|
+
// file + position) wins over stack parsing; otherwise parse the stack.
|
|
104
|
+
let file = opts.file || null;
|
|
105
|
+
let line = opts.line != null ? opts.line : null;
|
|
106
|
+
let column = opts.column != null ? opts.column : null;
|
|
107
|
+
if (!file && stack) {
|
|
108
|
+
const loc = parseStackLocation(stack, opts.appDir);
|
|
109
|
+
if (loc) { file = loc.file; line = loc.line; column = loc.column; }
|
|
110
|
+
}
|
|
111
|
+
// An explicit file (the ts-strip case) but no line: Node's strip error embeds
|
|
112
|
+
// the offending position as `file:line:col` in its MESSAGE, so mine the
|
|
113
|
+
// message (and stack) for a location pointing at that same file.
|
|
114
|
+
if (file && line == null) {
|
|
115
|
+
const loc = parseStackLocation(`${err.message}\n${stack || ''}`, opts.appDir);
|
|
116
|
+
if (loc && loc.file === file) { line = loc.line; column = loc.column; }
|
|
117
|
+
}
|
|
118
|
+
const codeFrame = file && line ? readCodeFrame(file, line, column || 0) : null;
|
|
119
|
+
return {
|
|
120
|
+
kind,
|
|
121
|
+
message: err.message || String(err),
|
|
122
|
+
stack,
|
|
123
|
+
file,
|
|
124
|
+
line,
|
|
125
|
+
column,
|
|
126
|
+
codeFrame,
|
|
127
|
+
hint: opts.hint || null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev error overlay renderer (#264), the BROWSER half of the dev error
|
|
3
|
+
* overlay. It is a browser-safe ES module (no node imports) so it can be both
|
|
4
|
+
* unit-tested in a real browser AND inlined verbatim into the served dev
|
|
5
|
+
* reload client (`reloadClientJs` reads this file's source, strips the `export`
|
|
6
|
+
* keywords, and embeds it). Sharing the one source means the test drives the
|
|
7
|
+
* exact code that ships, with no drift.
|
|
8
|
+
*
|
|
9
|
+
* Security: the overlay is built with `createElement` + `textContent` only,
|
|
10
|
+
* NEVER `innerHTML`, so a hostile error message / file path / code frame is
|
|
11
|
+
* rendered as inert text and can never inject markup or script.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The single live overlay element, or null. */
|
|
15
|
+
let __wjOverlay = null;
|
|
16
|
+
|
|
17
|
+
/** Remove the overlay if one is showing. */
|
|
18
|
+
export function dismissDevOverlay() {
|
|
19
|
+
if (__wjOverlay) { __wjOverlay.remove(); __wjOverlay = null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Append a styled text row to `parent`. */
|
|
23
|
+
function __wjRow(parent, css, text) {
|
|
24
|
+
const d = document.createElement('div');
|
|
25
|
+
d.style.cssText = css;
|
|
26
|
+
d.textContent = text;
|
|
27
|
+
parent.appendChild(d);
|
|
28
|
+
return d;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render the dev error overlay for a frame, replacing any prior one.
|
|
33
|
+
* @param {{ kind?: string, message?: string, file?: string|null, line?: number|null, column?: number|null, codeFrame?: string|null, hint?: string|null }} f
|
|
34
|
+
*/
|
|
35
|
+
export function renderDevOverlay(f) {
|
|
36
|
+
dismissDevOverlay();
|
|
37
|
+
if (!f) return;
|
|
38
|
+
const o = document.createElement('div');
|
|
39
|
+
o.setAttribute('data-webjs-error-overlay', '');
|
|
40
|
+
o.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:rgba(10,10,12,.92);color:#e6e6e6;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;padding:32px;overflow:auto';
|
|
41
|
+
const card = document.createElement('div');
|
|
42
|
+
card.style.cssText = 'max-width:920px;margin:0 auto;background:#1a1a1f;border:1px solid #5b2330;border-radius:8px;padding:24px';
|
|
43
|
+
const kind = f.kind === 'ts-strip' ? 'TypeScript error (hydration is dead until fixed)' : f.kind === 'rebuild' ? 'Rebuild failed' : 'Server render error';
|
|
44
|
+
__wjRow(card, 'color:#ff6b6b;font-weight:700;font-size:15px;margin-bottom:8px', kind);
|
|
45
|
+
__wjRow(card, 'white-space:pre-wrap;margin-bottom:12px', f.message || '');
|
|
46
|
+
if (f.file) __wjRow(card, 'color:#9aa3ad;margin-bottom:12px', f.file + (f.line ? ':' + f.line + (f.column ? ':' + f.column : '') : ''));
|
|
47
|
+
if (f.codeFrame) {
|
|
48
|
+
const pre = document.createElement('pre');
|
|
49
|
+
pre.style.cssText = 'background:#0d0d10;border-radius:6px;padding:12px;overflow:auto;margin:0 0 12px;white-space:pre';
|
|
50
|
+
pre.textContent = f.codeFrame;
|
|
51
|
+
card.appendChild(pre);
|
|
52
|
+
}
|
|
53
|
+
if (f.hint) __wjRow(card, 'color:#ffd479;border-top:1px solid #333;padding-top:12px;white-space:pre-wrap', f.hint);
|
|
54
|
+
if (f.stack) {
|
|
55
|
+
const det = document.createElement('details');
|
|
56
|
+
det.style.cssText = 'margin-top:12px;color:#9aa3ad';
|
|
57
|
+
const sum = document.createElement('summary');
|
|
58
|
+
sum.textContent = 'Stack trace';
|
|
59
|
+
sum.style.cssText = 'cursor:pointer';
|
|
60
|
+
det.appendChild(sum);
|
|
61
|
+
const pre = document.createElement('pre');
|
|
62
|
+
pre.style.cssText = 'background:#0d0d10;border-radius:6px;padding:12px;overflow:auto;margin:8px 0 0;white-space:pre;font-size:12px';
|
|
63
|
+
pre.textContent = f.stack;
|
|
64
|
+
det.appendChild(pre);
|
|
65
|
+
card.appendChild(det);
|
|
66
|
+
}
|
|
67
|
+
const btn = document.createElement('button');
|
|
68
|
+
btn.textContent = 'Dismiss';
|
|
69
|
+
btn.style.cssText = 'margin-top:16px;background:#333;color:#eee;border:0;border-radius:4px;padding:6px 12px;cursor:pointer';
|
|
70
|
+
btn.addEventListener('click', dismissDevOverlay);
|
|
71
|
+
card.appendChild(btn);
|
|
72
|
+
o.appendChild(card);
|
|
73
|
+
(document.body || document.documentElement).appendChild(o);
|
|
74
|
+
__wjOverlay = o;
|
|
75
|
+
}
|
package/src/dev.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer as createHttp1Server } from 'node:http';
|
|
2
2
|
import { stat, readFile, watch as fsWatch } from 'node:fs/promises';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
|
|
5
5
|
import { join, extname, resolve, dirname, relative, sep } from 'node:path';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
@@ -115,7 +115,7 @@ function shouldAccessLog(pathname) {
|
|
|
115
115
|
}
|
|
116
116
|
import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath } from './importmap.js';
|
|
117
117
|
import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
|
|
118
|
-
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash } from './asset-hash.js';
|
|
118
|
+
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports } from './asset-hash.js';
|
|
119
119
|
import { urlFromRequest } from './forwarded.js';
|
|
120
120
|
import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
|
|
121
121
|
import {
|
|
@@ -126,7 +126,8 @@ import {
|
|
|
126
126
|
} from './redirects.js';
|
|
127
127
|
import { readBodyLimits, computeServerTimeouts } from './body-limit.js';
|
|
128
128
|
import { applyConditionalGet, BUFFERED_MARKER } from './conditional-get.js';
|
|
129
|
-
import { commitHtmlCache } from './html-cache.js';
|
|
129
|
+
import { commitHtmlCache, setAppSourceFingerprint } from './html-cache.js';
|
|
130
|
+
import { buildDevErrorFrame } from './dev-error.js';
|
|
130
131
|
|
|
131
132
|
const MIME = {
|
|
132
133
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -394,6 +395,7 @@ export async function readServerTimeoutsFromApp(appDir) {
|
|
|
394
395
|
* logger?: import('./logger.js').Logger,
|
|
395
396
|
* onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
|
|
396
397
|
* onReload?: () => void,
|
|
398
|
+
* onDevError?: (frame: object) => void,
|
|
397
399
|
* }} opts
|
|
398
400
|
*/
|
|
399
401
|
export async function createRequestHandler(opts) {
|
|
@@ -607,8 +609,30 @@ export async function createRequestHandler(opts) {
|
|
|
607
609
|
// settings (a multi-tenant embedder, or the differential elision test)
|
|
608
610
|
// must not share it, or the second would serve the first's elided source.
|
|
609
611
|
tsCache: new Map(),
|
|
612
|
+
// The most recent unresolved dev error frame (#264), or null. Pushed to the
|
|
613
|
+
// SSE channel for the live overlay and replayed to a freshly-connected tab.
|
|
614
|
+
// Dev-only (never populated when !dev); cleared on a successful rebuild.
|
|
615
|
+
lastDevError: null,
|
|
610
616
|
};
|
|
611
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Report a dev error (#264): build a frame and push it to the open tab via
|
|
620
|
+
* the SSE overlay channel. DEV-ONLY and best-effort, so it can never affect a
|
|
621
|
+
* response or crash the server (a frame-build failure is swallowed). No file
|
|
622
|
+
* path or source is ever built in prod (the early return), so nothing leaks.
|
|
623
|
+
*
|
|
624
|
+
* @param {unknown} error
|
|
625
|
+
* @param {{ kind?: 'render'|'ts-strip'|'rebuild', file?: string, line?: number, column?: number, hint?: string }} [info]
|
|
626
|
+
*/
|
|
627
|
+
function reportDevError(error, info = {}) {
|
|
628
|
+
if (!dev) return;
|
|
629
|
+
try {
|
|
630
|
+
const frame = buildDevErrorFrame(error, { ...info, appDir });
|
|
631
|
+
state.lastDevError = frame;
|
|
632
|
+
opts.onDevError?.(frame);
|
|
633
|
+
} catch { /* a frame-build failure must never affect the request path */ }
|
|
634
|
+
}
|
|
635
|
+
|
|
612
636
|
// All whole-app analysis is built lazily on the first request, memoized so
|
|
613
637
|
// boot does none of it. It runs in two stages. The deterministic analysis
|
|
614
638
|
// (module graph, component scan + prime, browser-bound gate, action index,
|
|
@@ -707,6 +731,28 @@ export async function createRequestHandler(opts) {
|
|
|
707
731
|
].map(rel).sort();
|
|
708
732
|
setElisionFingerprint(elidedPaths.length ? elidedPaths.join('\n') : '');
|
|
709
733
|
}
|
|
734
|
+
// App-source fingerprint for the HTML cache key (#318): a
|
|
735
|
+
// deterministic, location-independent digest of the browser-bound
|
|
736
|
+
// file set's content hashes, so a deploy that changes ONLY an app
|
|
737
|
+
// module's bytes (which the importmap-only build id misses) re-keys
|
|
738
|
+
// the cache instead of serving a body with stale `?v` boot URLs.
|
|
739
|
+
// PROD only (asset-hash is enabled there); '' in dev, where fs.watch
|
|
740
|
+
// handles staleness. Computed AFTER the elision fingerprint, so each
|
|
741
|
+
// per-file hash already reflects the elision verdict (an elision
|
|
742
|
+
// flip changes the served output, so it moves this fingerprint too).
|
|
743
|
+
// This eagerly hashes the WHOLE browser-bound set once (not just the
|
|
744
|
+
// first route's files), but `assetHashFor` is memoized and already
|
|
745
|
+
// runs for `?v` emission in prod, so it stays a one-time bounded cost
|
|
746
|
+
// inside the lazy analysis (never at boot).
|
|
747
|
+
if (!dev && state.browserBoundFiles) {
|
|
748
|
+
const relApp = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
|
|
749
|
+
const lines = [...state.browserBoundFiles]
|
|
750
|
+
.map((abs) => `${relApp(abs)}:${assetHashFor(abs)}`)
|
|
751
|
+
.sort();
|
|
752
|
+
setAppSourceFingerprint(lines.join('\n'));
|
|
753
|
+
} else {
|
|
754
|
+
setAppSourceFingerprint('');
|
|
755
|
+
}
|
|
710
756
|
t.elision = now() - m;
|
|
711
757
|
if (dev) {
|
|
712
758
|
for (const { className, file } of await findOrphanComponents(appDir)) {
|
|
@@ -850,6 +896,9 @@ export async function createRequestHandler(opts) {
|
|
|
850
896
|
async function rebuild() {
|
|
851
897
|
rebuildInFlight = rebuildInFlight.then(() => doRebuild()).catch((e) => {
|
|
852
898
|
logger.error?.(`[webjs] rebuild failed:`, e);
|
|
899
|
+
// Push the failure to the open tab so the overlay appears live after a
|
|
900
|
+
// breaking edit, not only on the next manual navigation (#264).
|
|
901
|
+
reportDevError(e, { kind: 'rebuild' });
|
|
853
902
|
});
|
|
854
903
|
return rebuildInFlight;
|
|
855
904
|
}
|
|
@@ -884,6 +933,15 @@ export async function createRequestHandler(opts) {
|
|
|
884
933
|
readyDone = false;
|
|
885
934
|
readyError = null;
|
|
886
935
|
readinessFn = undefined; // reload readiness.{js,ts} after a rebuild
|
|
936
|
+
// Optimistically clear the dev error (#264): the rebuild itself only
|
|
937
|
+
// re-scans the route table and INVALIDATES the lazy analysis (the real
|
|
938
|
+
// re-parse / re-strip / re-render happens on the next request), so we do
|
|
939
|
+
// not yet know the edit fixed the error. Clearing it here means a tab that
|
|
940
|
+
// connects now is not replayed a possibly-stale overlay; `onReload` then
|
|
941
|
+
// reloads every open tab, and if the underlying error is still present the
|
|
942
|
+
// reloaded request re-pushes a fresh frame (a brief dismiss-then-reappear
|
|
943
|
+
// flicker on an unrelated edit, self-correcting to the right end state).
|
|
944
|
+
state.lastDevError = null;
|
|
887
945
|
opts.onReload?.();
|
|
888
946
|
}
|
|
889
947
|
|
|
@@ -1175,7 +1233,7 @@ export async function createRequestHandler(opts) {
|
|
|
1175
1233
|
// Build all whole-app analysis on the first request (memoized), before
|
|
1176
1234
|
// any SSR, module serve, gate check, action dispatch, or middleware runs.
|
|
1177
1235
|
await ensureReady();
|
|
1178
|
-
const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, cspEnabled: cspConfig.enabled });
|
|
1236
|
+
const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled: cspConfig.enabled });
|
|
1179
1237
|
if (state.middleware) {
|
|
1180
1238
|
try {
|
|
1181
1239
|
return await state.middleware(req, next);
|
|
@@ -1245,6 +1303,10 @@ export async function createRequestHandler(opts) {
|
|
|
1245
1303
|
warmup: () => ensureReady().catch((e) => logger.error?.(`[webjs] background warm-up failed (will retry on the next request):`, e)),
|
|
1246
1304
|
/** current route table getter: used by the WebSocket subsystem */
|
|
1247
1305
|
getRouteTable: () => state.routeTable,
|
|
1306
|
+
/** Current unresolved dev error frame (#264), or null. Replayed by
|
|
1307
|
+
* startServer to a freshly-connected SSE client so the overlay shows even
|
|
1308
|
+
* after a navigation, not only on the breaking edit. Always null in prod. */
|
|
1309
|
+
getLastDevError: () => state.lastDevError,
|
|
1248
1310
|
appDir,
|
|
1249
1311
|
dev,
|
|
1250
1312
|
logger,
|
|
@@ -1304,6 +1366,15 @@ export async function startServer(opts) {
|
|
|
1304
1366
|
try { res.write(`event: reload\ndata: now\n\n`); } catch {}
|
|
1305
1367
|
}
|
|
1306
1368
|
},
|
|
1369
|
+
// Dev error overlay (#264): push a frame to every open tab over the SAME
|
|
1370
|
+
// SSE channel. A distinct `webjs-error` event name (NOT `error`, which is
|
|
1371
|
+
// EventSource's native connection-error event) carries the JSON frame.
|
|
1372
|
+
onDevError: (frame) => {
|
|
1373
|
+
const data = JSON.stringify(frame);
|
|
1374
|
+
for (const res of sseClients) {
|
|
1375
|
+
try { res.write(`event: webjs-error\ndata: ${data}\n\n`); } catch {}
|
|
1376
|
+
}
|
|
1377
|
+
},
|
|
1307
1378
|
});
|
|
1308
1379
|
|
|
1309
1380
|
/** @type {AbortController | null} */
|
|
@@ -1360,6 +1431,13 @@ export async function startServer(opts) {
|
|
|
1360
1431
|
});
|
|
1361
1432
|
res.write(`event: hello\ndata: webjs\n\n`);
|
|
1362
1433
|
sseClients.add(res);
|
|
1434
|
+
// Replay an unresolved dev error (#264) so a tab that connects AFTER the
|
|
1435
|
+
// breaking edit (e.g. opened via a fresh navigation) still shows the
|
|
1436
|
+
// overlay, not only the tab that was open when the error fired.
|
|
1437
|
+
const pending = app.getLastDevError?.();
|
|
1438
|
+
if (pending) {
|
|
1439
|
+
try { res.write(`event: webjs-error\ndata: ${JSON.stringify(pending)}\n\n`); } catch {}
|
|
1440
|
+
}
|
|
1363
1441
|
res.socket?.on('close', () => sseClients.delete(res));
|
|
1364
1442
|
return;
|
|
1365
1443
|
}
|
|
@@ -1537,7 +1615,7 @@ async function tryServeFrameworkStatic(path, method, ctx) {
|
|
|
1537
1615
|
}
|
|
1538
1616
|
|
|
1539
1617
|
async function handleCore(req, ctx) {
|
|
1540
|
-
const { state, appDir, coreDir, dev, reportError, cspEnabled } = ctx;
|
|
1618
|
+
const { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled } = ctx;
|
|
1541
1619
|
const url = new URL(req.url);
|
|
1542
1620
|
// Decode percent-encoded characters so filesystem lookups match real
|
|
1543
1621
|
// filenames. Dynamic route segments like `[slug]` and route groups like
|
|
@@ -1694,7 +1772,7 @@ async function handleCore(req, ctx) {
|
|
|
1694
1772
|
// A `?v=<hash>` app-module request is content-addressed -> immutable
|
|
1695
1773
|
// (#243); an un-fingerprinted request keeps the 1h fallback.
|
|
1696
1774
|
if (/\.m?ts$/.test(abs)) {
|
|
1697
|
-
return tsResponse(abs, dev, elideOpts, state.tsCache, versioned);
|
|
1775
|
+
return tsResponse(abs, dev, elideOpts, state.tsCache, versioned, reportDevError);
|
|
1698
1776
|
}
|
|
1699
1777
|
if (/\.m?js$/.test(abs)) {
|
|
1700
1778
|
return jsModuleResponse(abs, dev, elideOpts, versioned);
|
|
@@ -1760,6 +1838,10 @@ async function handleCore(req, ctx) {
|
|
|
1760
1838
|
// onError sink (issue #239): a page render error that becomes a 500 is
|
|
1761
1839
|
// reported to the APM hook with the active request's correlation id.
|
|
1762
1840
|
onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
|
|
1841
|
+
// Dev error overlay (#264): a render crash pushes a frame to the open
|
|
1842
|
+
// tab. Dev-only (reportDevError early-returns in prod), so no source
|
|
1843
|
+
// leaks. Distinct from onError (the APM sink), which always fires.
|
|
1844
|
+
onDevError: dev ? (e) => reportDevError(e, { kind: 'render' }) : undefined,
|
|
1763
1845
|
};
|
|
1764
1846
|
if (method === 'GET' || method === 'HEAD') {
|
|
1765
1847
|
const handler = () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
@@ -2060,9 +2142,13 @@ async function jsModuleResponse(abs, dev, elideOpts, immutable) {
|
|
|
2060
2142
|
let source;
|
|
2061
2143
|
try { source = await readFile(abs, 'utf8'); }
|
|
2062
2144
|
catch { return new Response('Not found', { status: 404 }); }
|
|
2063
|
-
|
|
2145
|
+
let code = elideImportsFromSource(
|
|
2064
2146
|
source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
2065
2147
|
);
|
|
2148
|
+
// Version same-origin relative import specifiers so the URL the browser
|
|
2149
|
+
// fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
|
|
2150
|
+
// A no-op in dev (fingerprinting disabled).
|
|
2151
|
+
code = versionModuleImports(code, abs);
|
|
2066
2152
|
// Buffered (string) body, so opt into the conditional-GET funnel for the
|
|
2067
2153
|
// weak ETag + 304 (see fileResponse).
|
|
2068
2154
|
const headers = {
|
|
@@ -2117,7 +2203,7 @@ async function stripTs(source, _abs) {
|
|
|
2117
2203
|
* serve `immutable` (1 year) instead of the 1h fallback. The cached BODY is
|
|
2118
2204
|
* the same bytes regardless; only the cache header varies. Dev stays `no-cache`.
|
|
2119
2205
|
*/
|
|
2120
|
-
async function tsResponse(abs, dev, elideOpts, cache, immutable) {
|
|
2206
|
+
async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDevError) {
|
|
2121
2207
|
// The body bytes are identical with or without `?v`; only the cache header
|
|
2122
2208
|
// changes, so the per-mtime cache stays a single entry.
|
|
2123
2209
|
const cacheControl = dev ? 'no-cache' : immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
|
|
@@ -2148,6 +2234,15 @@ async function tsResponse(abs, dev, elideOpts, cache, immutable) {
|
|
|
2148
2234
|
// see what went wrong in their logs.
|
|
2149
2235
|
// eslint-disable-next-line no-console
|
|
2150
2236
|
console.error(`[webjs] non-erasable TypeScript in ${abs}: ${err.message}`);
|
|
2237
|
+
// Dev error overlay (#264): a TS strip failure breaks only the CLIENT
|
|
2238
|
+
// module fetch (the page still SSRs, so hydration is silently dead and
|
|
2239
|
+
// the hint below is buried in a JS comment). Push a frame so the open tab
|
|
2240
|
+
// shows the overlay with the offending file + the no-non-erasable hint.
|
|
2241
|
+
reportDevError?.(err, {
|
|
2242
|
+
kind: 'ts-strip',
|
|
2243
|
+
file: abs,
|
|
2244
|
+
hint: 'webjs is buildless: only erasable TypeScript is supported. Replace enum / namespace-with-values / parameter-property / legacy-decorator / import = require with their erasable equivalents. Run `webjs check` (no-non-erasable-typescript rule).',
|
|
2245
|
+
});
|
|
2151
2246
|
const msg = dev
|
|
2152
2247
|
// Dev: include the file path and Node's error message so the
|
|
2153
2248
|
// developer's browser tooling can point them at the offending
|
|
@@ -2176,6 +2271,11 @@ async function tsResponse(abs, dev, elideOpts, cache, immutable) {
|
|
|
2176
2271
|
code, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
2177
2272
|
);
|
|
2178
2273
|
}
|
|
2274
|
+
// Version same-origin relative import specifiers so the URL the browser
|
|
2275
|
+
// fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
|
|
2276
|
+
// A no-op in dev (fingerprinting disabled). Cached with the elision result:
|
|
2277
|
+
// PROD files are static within a deploy, so the baked `?v` stays correct.
|
|
2278
|
+
code = versionModuleImports(code, abs);
|
|
2179
2279
|
// Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
|
|
2180
2280
|
if (cache.size >= TS_CACHE_MAX) {
|
|
2181
2281
|
const oldest = cache.keys().next().value;
|
|
@@ -2320,9 +2420,25 @@ function locatePackageDir(appDir, pkgName) {
|
|
|
2320
2420
|
* @param {string} bp the normalized base path (`''` = no-op)
|
|
2321
2421
|
* @returns {string}
|
|
2322
2422
|
*/
|
|
2423
|
+
// The overlay renderer source, read once + `export`-stripped so it inlines as
|
|
2424
|
+
// plain functions into the classic reload-client script. Sharing the one source
|
|
2425
|
+
// (`dev-overlay.js`, which the browser test imports directly) means the test
|
|
2426
|
+
// drives the EXACT code that ships, with no drift (#264).
|
|
2427
|
+
const DEV_OVERLAY_SRC = readFileSync(new URL('./dev-overlay.js', import.meta.url), 'utf8')
|
|
2428
|
+
.replace(/^export /gm, '');
|
|
2429
|
+
|
|
2323
2430
|
function reloadClientJs(bp) {
|
|
2431
|
+
// The overlay renderer uses textContent throughout (never innerHTML), so the
|
|
2432
|
+
// error message / code frame can never inject markup (#264). Served only in
|
|
2433
|
+
// dev (the /__webjs/reload.js branch 404s in prod), so it never reaches a
|
|
2434
|
+
// production page.
|
|
2324
2435
|
return `// webjs dev reload client
|
|
2436
|
+
${DEV_OVERLAY_SRC}
|
|
2325
2437
|
const es = new EventSource(${JSON.stringify(withBasePath('/__webjs/events', bp))});
|
|
2326
2438
|
es.addEventListener('reload', () => location.reload());
|
|
2439
|
+
es.addEventListener('webjs-error', (e) => {
|
|
2440
|
+
let f; try { f = JSON.parse(e.data); } catch (_) { return; }
|
|
2441
|
+
renderDevOverlay(f);
|
|
2442
|
+
});
|
|
2327
2443
|
`;
|
|
2328
2444
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side `<webjs-frame>` subtree extraction (#253).
|
|
3
|
+
*
|
|
4
|
+
* When a `<webjs-frame src loading>` self-loads (or a click drives a frame
|
|
5
|
+
* nav), the client sends an `x-webjs-frame: <id>` header and then, from the
|
|
6
|
+
* response, applies ONLY the matching `<webjs-frame id>` subtree (the rest of
|
|
7
|
+
* the page is discarded). Rendering the FULL page just to throw away all but
|
|
8
|
+
* one region wastes the full-page render + transfer cost.
|
|
9
|
+
*
|
|
10
|
+
* This module extracts the requested frame subtree from the already-rendered
|
|
11
|
+
* full-page HTML and returns ONLY that, so the response is BYTE-EQUIVALENT by
|
|
12
|
+
* construction to what the client would have extracted from the full page (a
|
|
13
|
+
* `src` self-load and a click-driven frame nav therefore produce identical DOM)
|
|
14
|
+
* while sending far fewer bytes. "Isolable" means the requested frame id is
|
|
15
|
+
* present in the rendered output; when it is not, the caller falls back to the
|
|
16
|
+
* full-page render (so an auth redirect / a route that dropped the frame is
|
|
17
|
+
* handled exactly as before).
|
|
18
|
+
*
|
|
19
|
+
* The extraction is a structural scan over the rendered HTML, balancing nested
|
|
20
|
+
* `<webjs-frame>` tags so a frame nested inside another frame is matched
|
|
21
|
+
* correctly. It only triggers when the `x-webjs-frame` header is present, so a
|
|
22
|
+
* normal full-page request is completely unaffected (byte-identical).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const FRAME_TAG = 'webjs-frame';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read the requested frame id off a request's `x-webjs-frame` header.
|
|
29
|
+
* Returns the trimmed id, or `null` when the header is absent / empty (the
|
|
30
|
+
* normal full-page request path).
|
|
31
|
+
*
|
|
32
|
+
* @param {Request | undefined} req
|
|
33
|
+
* @returns {string | null}
|
|
34
|
+
*/
|
|
35
|
+
export function requestedFrameId(req) {
|
|
36
|
+
if (!req || !req.headers) return null;
|
|
37
|
+
const raw = req.headers.get('x-webjs-frame');
|
|
38
|
+
if (!raw) return null;
|
|
39
|
+
const id = raw.trim();
|
|
40
|
+
return id ? id : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Find the `<webjs-frame ... id="<frameId>" ...>...</webjs-frame>` subtree in
|
|
45
|
+
* `html` and return it verbatim (opening tag through matching close tag), so
|
|
46
|
+
* the slice is byte-equivalent to the same bytes inside the full page. Nested
|
|
47
|
+
* `<webjs-frame>` tags are balanced, so an outer frame's close is not mistaken
|
|
48
|
+
* for an inner frame's. Returns `null` when no frame with that id is present
|
|
49
|
+
* (the caller then renders / returns the full page).
|
|
50
|
+
*
|
|
51
|
+
* The id match is attribute-aware (it reads the `id` attribute of each opening
|
|
52
|
+
* `<webjs-frame>` tag rather than substring-matching), so a frame whose id is a
|
|
53
|
+
* prefix of another id is not mismatched. A self-closing `<webjs-frame/>` (no
|
|
54
|
+
* children) is matched as an empty subtree.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} html The rendered full-page HTML.
|
|
57
|
+
* @param {string} frameId The requested frame id.
|
|
58
|
+
* @returns {string | null} The frame subtree, or null when absent.
|
|
59
|
+
*/
|
|
60
|
+
export function extractFrameSubtree(html, frameId) {
|
|
61
|
+
if (typeof html !== 'string' || !frameId) return null;
|
|
62
|
+
const lower = html.toLowerCase();
|
|
63
|
+
const open = '<' + FRAME_TAG;
|
|
64
|
+
let cursor = 0;
|
|
65
|
+
while (cursor < lower.length) {
|
|
66
|
+
const start = lower.indexOf(open, cursor);
|
|
67
|
+
if (start === -1) return null;
|
|
68
|
+
// Confirm this is a real tag open (`<webjs-frame` followed by whitespace,
|
|
69
|
+
// `>`, or `/`), not a longer tag name that happens to share the prefix.
|
|
70
|
+
const after = lower[start + open.length];
|
|
71
|
+
if (after !== undefined && after !== ' ' && after !== '\t' && after !== '\n'
|
|
72
|
+
&& after !== '\r' && after !== '>' && after !== '/') {
|
|
73
|
+
cursor = start + open.length;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// Locate the end of this opening tag.
|
|
77
|
+
const tagEnd = html.indexOf('>', start);
|
|
78
|
+
if (tagEnd === -1) return null;
|
|
79
|
+
const openTag = html.slice(start, tagEnd + 1);
|
|
80
|
+
const selfClosing = /\/\s*>$/.test(openTag);
|
|
81
|
+
const id = readIdAttr(openTag);
|
|
82
|
+
if (id === frameId) {
|
|
83
|
+
if (selfClosing) return openTag;
|
|
84
|
+
const end = findMatchingClose(html, lower, tagEnd + 1);
|
|
85
|
+
if (end === -1) return null;
|
|
86
|
+
return html.slice(start, end);
|
|
87
|
+
}
|
|
88
|
+
cursor = tagEnd + 1;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read the `id` attribute value out of a single opening `<webjs-frame ...>`
|
|
95
|
+
* tag string. Handles double-quoted, single-quoted, and unquoted forms.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} openTag The opening tag including `<` and `>`.
|
|
98
|
+
* @returns {string | null}
|
|
99
|
+
*/
|
|
100
|
+
function readIdAttr(openTag) {
|
|
101
|
+
const m = /\sid\s*=\s*("([^"]*)"|'([^']*)'|([^\s>/]+))/i.exec(openTag);
|
|
102
|
+
if (!m) return null;
|
|
103
|
+
return m[2] ?? m[3] ?? m[4] ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Given the index just past a `<webjs-frame ...>` opening tag, return the index
|
|
108
|
+
* just past its matching `</webjs-frame>`, balancing nested `<webjs-frame>`
|
|
109
|
+
* opens. Returns -1 when unbalanced (no matching close).
|
|
110
|
+
*
|
|
111
|
+
* @param {string} html Original-case HTML (returned-slice source).
|
|
112
|
+
* @param {string} lower Lower-cased copy for case-insensitive scanning.
|
|
113
|
+
* @param {number} from Index just past the opening tag's `>`.
|
|
114
|
+
* @returns {number} Index just past the matching `</webjs-frame>`, or -1.
|
|
115
|
+
*/
|
|
116
|
+
/**
|
|
117
|
+
* Find the next `needle` (`<webjs-frame` or `</webjs-frame`) in `lower` from
|
|
118
|
+
* `start` that is a REAL tag, i.e. the char right after the tag name is a tag
|
|
119
|
+
* boundary (whitespace, `>`, or `/`). This skips a prefix-collision tag such as
|
|
120
|
+
* `<webjs-frame-nav>` / `</webjs-frame-nav>`, which `indexOf` alone would match
|
|
121
|
+
* and miscount (an unbalanced one would corrupt the depth scan). Returns -1
|
|
122
|
+
* when no real tag remains.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} lower
|
|
125
|
+
* @param {string} needle
|
|
126
|
+
* @param {number} start
|
|
127
|
+
* @returns {number}
|
|
128
|
+
*/
|
|
129
|
+
function findRealTag(lower, needle, start) {
|
|
130
|
+
let at = start;
|
|
131
|
+
while (at < lower.length) {
|
|
132
|
+
const idx = lower.indexOf(needle, at);
|
|
133
|
+
if (idx === -1) return -1;
|
|
134
|
+
const after = lower[idx + needle.length];
|
|
135
|
+
if (after === undefined || after === ' ' || after === '\t' || after === '\n'
|
|
136
|
+
|| after === '\r' || after === '>' || after === '/') {
|
|
137
|
+
return idx;
|
|
138
|
+
}
|
|
139
|
+
at = idx + needle.length; // a prefix-collision tag, keep scanning
|
|
140
|
+
}
|
|
141
|
+
return -1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findMatchingClose(html, lower, from) {
|
|
145
|
+
const open = '<' + FRAME_TAG;
|
|
146
|
+
const close = '</' + FRAME_TAG;
|
|
147
|
+
let depth = 1;
|
|
148
|
+
let i = from;
|
|
149
|
+
while (i < lower.length) {
|
|
150
|
+
const nextOpen = findRealTag(lower, open, i);
|
|
151
|
+
const nextClose = findRealTag(lower, close, i);
|
|
152
|
+
if (nextClose === -1) return -1;
|
|
153
|
+
if (nextOpen !== -1 && nextOpen < nextClose) {
|
|
154
|
+
// A nested frame open. Skip past its opening tag, and only increase
|
|
155
|
+
// depth when it is NOT self-closing (a self-closing nested frame opens
|
|
156
|
+
// and closes in one tag, so it does not need a matching close).
|
|
157
|
+
const tagEnd = html.indexOf('>', nextOpen);
|
|
158
|
+
if (tagEnd === -1) return -1;
|
|
159
|
+
const nestedOpenTag = html.slice(nextOpen, tagEnd + 1);
|
|
160
|
+
if (!/\/\s*>$/.test(nestedOpenTag)) depth++;
|
|
161
|
+
i = tagEnd + 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// A close tag. Consume through its `>`.
|
|
165
|
+
const closeEnd = html.indexOf('>', nextClose);
|
|
166
|
+
if (closeEnd === -1) return -1;
|
|
167
|
+
depth--;
|
|
168
|
+
i = closeEnd + 1;
|
|
169
|
+
if (depth === 0) return i;
|
|
170
|
+
}
|
|
171
|
+
return -1;
|
|
172
|
+
}
|
package/src/html-cache.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { getStore } from './cache.js';
|
|
28
28
|
import { STREAM_MARKER } from './conditional-get.js';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
29
30
|
import { publishedBuildId } from './importmap.js';
|
|
30
31
|
import { dynamicAccessed } from './context.js';
|
|
31
32
|
|
|
@@ -53,6 +54,39 @@ export const HTML_CACHE_MARKER = 'x-webjs-html-cache';
|
|
|
53
54
|
*/
|
|
54
55
|
let _generation = 0;
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* A fingerprint of the served app source, folded into every cache key (#318).
|
|
59
|
+
* The published build id keys on the IMPORTMAP (core + vendor) only, so a deploy
|
|
60
|
+
* that changes ONLY an app module's bytes does not move it, and a Redis-cached
|
|
61
|
+
* `revalidate` page survives that deploy still serving its baked-in OLD `?v`
|
|
62
|
+
* boot URLs (under #243 immutable caching those then content-address to stale
|
|
63
|
+
* bytes). dev.js sets this to a deterministic, location-independent digest of
|
|
64
|
+
* the browser-bound file set's content hashes (PROD only; '' in dev, where
|
|
65
|
+
* fs.watch handles staleness), so an app-source-changing deploy re-keys (a fresh
|
|
66
|
+
* render) while a no-change redeploy keeps the key (a warm cache survives). The
|
|
67
|
+
* client-router build id deliberately stays importmap-only: a partial-swap
|
|
68
|
+
* fragment already carries the new `?v` boot URLs, so app updates propagate on
|
|
69
|
+
* the next navigation without a destructive hard reload.
|
|
70
|
+
*
|
|
71
|
+
* @type {string}
|
|
72
|
+
*/
|
|
73
|
+
let _appSourceFp = '';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Set the app-source fingerprint folded into the HTML cache key (see
|
|
77
|
+
* `_appSourceFp`). Called by dev.js from `ensureReady` after the browser-bound
|
|
78
|
+
* file set + asset hashes are known, and again on each rebuild. The input is a
|
|
79
|
+
* deterministic, location-independent STRING (sorted `relpath:contentHash`
|
|
80
|
+
* lines); it is digested to a short hex so the key stays compact. An empty
|
|
81
|
+
* input clears the fingerprint (the key collapses to its prior shape).
|
|
82
|
+
*
|
|
83
|
+
* @param {string} raw
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*/
|
|
86
|
+
export function setAppSourceFingerprint(raw) {
|
|
87
|
+
_appSourceFp = raw ? createHash('sha256').update(raw).digest('hex').slice(0, 16) : '';
|
|
88
|
+
}
|
|
89
|
+
|
|
56
90
|
/**
|
|
57
91
|
* Read the revalidation window (seconds) a page module opted into via
|
|
58
92
|
* `export const revalidate = N` (the Next idiom). Returns a positive finite
|
|
@@ -86,8 +120,13 @@ export function readRevalidate(pageModule) {
|
|
|
86
120
|
* naturally writes and reads under fresh keys. The cached HTML bakes the
|
|
87
121
|
* deploy's `data-webjs-build` importmap into its boot script, so a Redis
|
|
88
122
|
* store that survives a deploy must NOT let a v2 process serve a v1-body
|
|
89
|
-
* (resolving modules against stale vendor URLs)
|
|
90
|
-
*
|
|
123
|
+
* (resolving modules against stale vendor URLs);
|
|
124
|
+
* - the app-source fingerprint (#318), so a deploy that changes ONLY an app
|
|
125
|
+
* module's bytes (which does not move the importmap build id) also re-keys,
|
|
126
|
+
* instead of serving a cached body with stale `?v` boot URLs. Together the
|
|
127
|
+
* build id (vendor) and the app-source fingerprint (app modules) mean ANY
|
|
128
|
+
* deploy that changes the served output re-keys, while a no-change redeploy
|
|
129
|
+
* keeps every key so a warm cache survives.
|
|
91
130
|
*
|
|
92
131
|
* @param {URL} url
|
|
93
132
|
* @returns {string}
|
|
@@ -100,7 +139,11 @@ export function htmlCacheKey(url) {
|
|
|
100
139
|
? '?' + params.map(([k, v]) => `${k}=${v}`).join('&')
|
|
101
140
|
: '';
|
|
102
141
|
const build = publishedBuildId() || 'nobuild';
|
|
103
|
-
|
|
142
|
+
// The app-source fingerprint (#318) re-keys on an app-module-only deploy that
|
|
143
|
+
// the importmap-only build id misses. Empty (dev / no fingerprint) collapses
|
|
144
|
+
// to the prior key shape, so an unconfigured app is byte-identical.
|
|
145
|
+
const appfp = _appSourceFp ? `${_appSourceFp}:` : '';
|
|
146
|
+
return `${KEY_PREFIX}${build}:${appfp}${_generation}:${url.pathname}${search}`;
|
|
104
147
|
}
|
|
105
148
|
|
|
106
149
|
/**
|
package/src/js-scan.js
CHANGED
|
@@ -67,10 +67,20 @@ const REGEX_PRECEDING_KEYWORDS = new Set([
|
|
|
67
67
|
* nesting, so a nested `` html`...${html`...`}...` `` is delimited correctly
|
|
68
68
|
* (the inner backtick is not mistaken for the outer close).
|
|
69
69
|
*
|
|
70
|
+
* `blankStrings` (default false, so existing callers are byte-identical)
|
|
71
|
+
* additionally blanks PLAIN string bodies and disables the verbatim-template
|
|
72
|
+
* fast path, yielding a mask in which NO literal body survives. Callers that
|
|
73
|
+
* only check whether a keyword sits in code position (not inside any literal),
|
|
74
|
+
* e.g. the import-versioning pass, pass true so an `import '…'` written inside
|
|
75
|
+
* a plain string is correctly masked out (the default mask keeps plain-string
|
|
76
|
+
* bodies verbatim so `register('tag')` stays readable, which would otherwise
|
|
77
|
+
* leave such a string looking like a real import).
|
|
78
|
+
*
|
|
70
79
|
* @param {string} src
|
|
80
|
+
* @param {boolean} [blankStrings=false] also blank plain-string + verbatim-template bodies
|
|
71
81
|
* @returns {string}
|
|
72
82
|
*/
|
|
73
|
-
export function redactStringsAndTemplates(src) {
|
|
83
|
+
export function redactStringsAndTemplates(src, blankStrings = false) {
|
|
74
84
|
const n = src.length;
|
|
75
85
|
let out = '';
|
|
76
86
|
let i = 0;
|
|
@@ -157,7 +167,7 @@ export function redactStringsAndTemplates(src) {
|
|
|
157
167
|
if (ch === '\n') hasNewline = true;
|
|
158
168
|
k++;
|
|
159
169
|
}
|
|
160
|
-
const verbatim = !forceBlank && !tagged && closed && !hasNewline && !hasInterp;
|
|
170
|
+
const verbatim = !forceBlank && !blankStrings && !tagged && closed && !hasNewline && !hasInterp;
|
|
161
171
|
out += '`'; i++;
|
|
162
172
|
if (verbatim) {
|
|
163
173
|
while (i < n) {
|
|
@@ -197,7 +207,7 @@ export function redactStringsAndTemplates(src) {
|
|
|
197
207
|
if (c === '/' && next === '/') { scanLineComment(); continue; }
|
|
198
208
|
if (c === '/' && next === '*') { scanBlockComment(); continue; }
|
|
199
209
|
if (c === '/' && isRegex()) { scanRegex(); continue; }
|
|
200
|
-
if (c === "'" || c === '"') { scanString(c, blank); continue; }
|
|
210
|
+
if (c === "'" || c === '"') { scanString(c, blank || blankStrings); continue; }
|
|
201
211
|
if (c === '`') { scanTemplate(blank); continue; }
|
|
202
212
|
if (c === '{') { brace++; lastSig = '{'; lastWord = ''; lastWasIncDec = false; out += blank ? ' ' : c; i++; continue; }
|
|
203
213
|
if (c === '}') { brace--; lastSig = '}'; lastWord = ''; lastWasIncDec = false; out += blank ? ' ' : c; i++; continue; }
|
package/src/page-action.js
CHANGED
|
@@ -207,6 +207,13 @@ export async function runPageAction(route, params, url, loaded, req, ssrOpts) {
|
|
|
207
207
|
throw err;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
// A page action MAY return a `Response` directly (e.g. a content-negotiated
|
|
211
|
+
// `streamResponse`, #248). Honor it verbatim, so the action owns the status +
|
|
212
|
+
// content type and the router applies it (a stream body surgically). With JS
|
|
213
|
+
// off the same action returns a normal ActionResult instead, so the PRG /
|
|
214
|
+
// re-render paths below still drive the no-JS form.
|
|
215
|
+
if (result instanceof Response) return result;
|
|
216
|
+
|
|
210
217
|
if (!isFailureResult(result)) {
|
|
211
218
|
// SUCCESS: Post/Redirect/Get. A user-controlled `result.redirect` is only
|
|
212
219
|
// honored when it is a same-site local path; otherwise fall back to the
|
package/src/ssr.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
readHtmlCache,
|
|
14
14
|
HTML_CACHE_MARKER,
|
|
15
15
|
} from './html-cache.js';
|
|
16
|
+
import { requestedFrameId, extractFrameSubtree } from './frame-render.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* SSR a matched page route to a Response.
|
|
@@ -94,6 +95,31 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
94
95
|
? new Set(haveHeader.split(',').map((s) => s.trim()).filter(Boolean))
|
|
95
96
|
: null;
|
|
96
97
|
const body = await renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule);
|
|
98
|
+
|
|
99
|
+
// Frame subtree render (#253). A `<webjs-frame src>` self-load (or a
|
|
100
|
+
// click-driven frame nav) sends `x-webjs-frame: <id>` and applies ONLY the
|
|
101
|
+
// matching `<webjs-frame id>` subtree from the response, discarding the rest
|
|
102
|
+
// of the page. So when that header is present AND the requested frame is in
|
|
103
|
+
// the rendered output (the "isolable" case), return JUST that subtree: the
|
|
104
|
+
// bytes are extracted verbatim from the same full render, so the result is
|
|
105
|
+
// BYTE-EQUIVALENT to what the client would slice from a full-page response,
|
|
106
|
+
// but the full document shell + all the other regions never go on the wire.
|
|
107
|
+
// The frame swap path (applySwap in router-client.js) parses this body and
|
|
108
|
+
// does `doc.querySelector('webjs-frame#<id>')`, which finds the lone
|
|
109
|
+
// subtree exactly as it would in the full page. A streamed (Suspense)
|
|
110
|
+
// render is skipped (its bytes are not yet final). When the frame id is NOT
|
|
111
|
+
// found (an auth redirect to a login page, a route that dropped the frame),
|
|
112
|
+
// we fall through to the normal full-page render, where the client's
|
|
113
|
+
// existing `webjs:frame-missing` fallback handles the absence. A request
|
|
114
|
+
// with NO `x-webjs-frame` header never reaches this branch, so a normal
|
|
115
|
+
// page request is byte-identical to before this feature.
|
|
116
|
+
const frameId = requestedFrameId(opts.req);
|
|
117
|
+
if (frameId && suspenseCtx.pending.length === 0) {
|
|
118
|
+
const subtree = extractFrameSubtree(body, frameId);
|
|
119
|
+
if (subtree !== null) {
|
|
120
|
+
return htmlResponse(subtree, opts.status || 200, opts.req, url);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
97
123
|
// Module URLs for the page + every layout in its chain. These ride
|
|
98
124
|
// the importmap; the browser fetches each file as it walks the
|
|
99
125
|
// import graph. Combined with the modulepreload hints below, this
|
|
@@ -187,6 +213,11 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
187
213
|
if (typeof opts.onError === 'function') {
|
|
188
214
|
try { opts.onError(err); } catch { /* a throwing sink must not affect the response */ }
|
|
189
215
|
}
|
|
216
|
+
// Dev error overlay (#264): push a rich frame to the open tab so the
|
|
217
|
+
// overlay appears live. Dev-only + best-effort; never affects the response.
|
|
218
|
+
if (typeof opts.onDevError === 'function') {
|
|
219
|
+
try { opts.onDevError(err); } catch { /* a throwing sink must not affect the response */ }
|
|
220
|
+
}
|
|
190
221
|
// Error paths still need to honor the request's CSP nonce so the
|
|
191
222
|
// error page's boot scripts (when moduleUrls is non-empty) and
|
|
192
223
|
// the meta csp-nonce tag both pass strict-CSP enforcement.
|
package/src/stream.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side stream-action helpers (#248) that build the `<webjs-stream>` HTML
|
|
3
|
+
* the client applier (`renderStream` / the `webjs-stream` element) applies
|
|
4
|
+
* surgically.
|
|
5
|
+
*
|
|
6
|
+
* The payload is plain HTML, a `<webjs-stream action target>` wrapping one
|
|
7
|
+
* `<template>`. These helpers compose that string for the two delivery paths,
|
|
8
|
+
* a content-negotiated HTTP form response and a `broadcast()` / WS message, so
|
|
9
|
+
* an app never hand-writes the markup. A page `action` or a `route` handler
|
|
10
|
+
* branches on `acceptsStream(request)`, returning `streamResponse(...)` when the
|
|
11
|
+
* router asked for a stream and a normal render otherwise (the JS-off degrade).
|
|
12
|
+
*
|
|
13
|
+
* The helpers do NOT escape the CONTENT (it is server-authored HTML, like a
|
|
14
|
+
* template result). Escape any user-supplied substring yourself, the same way
|
|
15
|
+
* you would inside an `html` template hole.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The content type that negotiates and carries a stream-action response. */
|
|
19
|
+
export const STREAM_MIME = 'text/vnd.webjs-stream.html';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Report whether a request opted into a stream-action response. The client
|
|
23
|
+
* router adds the stream MIME to `Accept` on a form submission. With JS off the
|
|
24
|
+
* browser sends no such Accept, so this returns false and the app returns a
|
|
25
|
+
* normal render (the progressive-enhancement degrade).
|
|
26
|
+
*
|
|
27
|
+
* @param {Request | { headers?: Headers }} req
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function acceptsStream(req) {
|
|
31
|
+
const accept = req && req.headers && req.headers.get ? (req.headers.get('accept') || '') : '';
|
|
32
|
+
return accept.toLowerCase().includes(STREAM_MIME);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Escape the target id for safe placement in a double-quoted attribute. */
|
|
36
|
+
function escapeAttr(s) {
|
|
37
|
+
return String(s == null ? '' : s)
|
|
38
|
+
.replace(/&/g, '&')
|
|
39
|
+
.replace(/"/g, '"')
|
|
40
|
+
.replace(/</g, '<')
|
|
41
|
+
.replace(/>/g, '>');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build one `<webjs-stream>` action element.
|
|
46
|
+
* @param {string} action One of append, prepend, before, after, replace, update, remove.
|
|
47
|
+
* @param {string} target The target element id.
|
|
48
|
+
* @param {string} [content] Inner HTML for the insert actions (ignored by remove).
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function build(action, target, content) {
|
|
52
|
+
const t = escapeAttr(target);
|
|
53
|
+
if (action === 'remove') {
|
|
54
|
+
return `<webjs-stream action="remove" target="${t}"></webjs-stream>`;
|
|
55
|
+
}
|
|
56
|
+
const inner = content == null ? '' : String(content);
|
|
57
|
+
return `<webjs-stream action="${action}" target="${t}"><template>${inner}</template></webjs-stream>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The stream-action builder. Each method returns one `<webjs-stream>` HTML
|
|
62
|
+
* string. Concatenate several and pass them to `streamResponse` or a
|
|
63
|
+
* `broadcast()`.
|
|
64
|
+
*/
|
|
65
|
+
export const stream = {
|
|
66
|
+
/** @param {string} target @param {string} content @returns {string} */
|
|
67
|
+
append: (target, content) => build('append', target, content),
|
|
68
|
+
/** @param {string} target @param {string} content @returns {string} */
|
|
69
|
+
prepend: (target, content) => build('prepend', target, content),
|
|
70
|
+
/** @param {string} target @param {string} content @returns {string} */
|
|
71
|
+
before: (target, content) => build('before', target, content),
|
|
72
|
+
/** @param {string} target @param {string} content @returns {string} */
|
|
73
|
+
after: (target, content) => build('after', target, content),
|
|
74
|
+
/** @param {string} target @param {string} content @returns {string} */
|
|
75
|
+
replace: (target, content) => build('replace', target, content),
|
|
76
|
+
/** @param {string} target @param {string} content @returns {string} */
|
|
77
|
+
update: (target, content) => build('update', target, content),
|
|
78
|
+
/** @param {string} target @returns {string} */
|
|
79
|
+
remove: (target) => build('remove', target),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Wrap one or more stream-action strings in a `Response` carrying the stream
|
|
84
|
+
* content type, so the client router applies it surgically. A non-200 status is
|
|
85
|
+
* fine (the router applies a stream body of any status).
|
|
86
|
+
*
|
|
87
|
+
* @param {...string} parts `<webjs-stream>` HTML strings from `stream.*`.
|
|
88
|
+
* @returns {Response}
|
|
89
|
+
*/
|
|
90
|
+
export function streamResponse(...parts) {
|
|
91
|
+
return new Response(parts.join('\n'), {
|
|
92
|
+
status: 200,
|
|
93
|
+
headers: { 'content-type': STREAM_MIME + '; charset=utf-8' },
|
|
94
|
+
});
|
|
95
|
+
}
|