@webjsdev/server 0.8.16 → 0.8.17

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.16",
3
+ "version": "0.8.17",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -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
@@ -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 } 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 });
@@ -2117,7 +2199,7 @@ async function stripTs(source, _abs) {
2117
2199
  * serve `immutable` (1 year) instead of the 1h fallback. The cached BODY is
2118
2200
  * the same bytes regardless; only the cache header varies. Dev stays `no-cache`.
2119
2201
  */
2120
- async function tsResponse(abs, dev, elideOpts, cache, immutable) {
2202
+ async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDevError) {
2121
2203
  // The body bytes are identical with or without `?v`; only the cache header
2122
2204
  // changes, so the per-mtime cache stays a single entry.
2123
2205
  const cacheControl = dev ? 'no-cache' : immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
@@ -2148,6 +2230,15 @@ async function tsResponse(abs, dev, elideOpts, cache, immutable) {
2148
2230
  // see what went wrong in their logs.
2149
2231
  // eslint-disable-next-line no-console
2150
2232
  console.error(`[webjs] non-erasable TypeScript in ${abs}: ${err.message}`);
2233
+ // Dev error overlay (#264): a TS strip failure breaks only the CLIENT
2234
+ // module fetch (the page still SSRs, so hydration is silently dead and
2235
+ // the hint below is buried in a JS comment). Push a frame so the open tab
2236
+ // shows the overlay with the offending file + the no-non-erasable hint.
2237
+ reportDevError?.(err, {
2238
+ kind: 'ts-strip',
2239
+ file: abs,
2240
+ 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).',
2241
+ });
2151
2242
  const msg = dev
2152
2243
  // Dev: include the file path and Node's error message so the
2153
2244
  // developer's browser tooling can point them at the offending
@@ -2320,9 +2411,25 @@ function locatePackageDir(appDir, pkgName) {
2320
2411
  * @param {string} bp the normalized base path (`''` = no-op)
2321
2412
  * @returns {string}
2322
2413
  */
2414
+ // The overlay renderer source, read once + `export`-stripped so it inlines as
2415
+ // plain functions into the classic reload-client script. Sharing the one source
2416
+ // (`dev-overlay.js`, which the browser test imports directly) means the test
2417
+ // drives the EXACT code that ships, with no drift (#264).
2418
+ const DEV_OVERLAY_SRC = readFileSync(new URL('./dev-overlay.js', import.meta.url), 'utf8')
2419
+ .replace(/^export /gm, '');
2420
+
2323
2421
  function reloadClientJs(bp) {
2422
+ // The overlay renderer uses textContent throughout (never innerHTML), so the
2423
+ // error message / code frame can never inject markup (#264). Served only in
2424
+ // dev (the /__webjs/reload.js branch 404s in prod), so it never reaches a
2425
+ // production page.
2324
2426
  return `// webjs dev reload client
2427
+ ${DEV_OVERLAY_SRC}
2325
2428
  const es = new EventSource(${JSON.stringify(withBasePath('/__webjs/events', bp))});
2326
2429
  es.addEventListener('reload', () => location.reload());
2430
+ es.addEventListener('webjs-error', (e) => {
2431
+ let f; try { f = JSON.parse(e.data); } catch (_) { return; }
2432
+ renderDevOverlay(f);
2433
+ });
2327
2434
  `;
2328
2435
  }
@@ -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). Folding the build id in
90
- * means a deploy effectively invalidates all cached HTML for free.
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
- return `${KEY_PREFIX}${build}:${_generation}:${url.pathname}${search}`;
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
  /**
@@ -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, '&amp;')
39
+ .replace(/"/g, '&quot;')
40
+ .replace(/</g, '&lt;')
41
+ .replace(/>/g, '&gt;');
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
+ }