@webjsdev/server 0.8.15 → 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 +91 -1
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/actions.js +12 -1
- 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 +114 -7
- package/src/file-storage.js +466 -0
- package/src/frame-render.js +172 -0
- package/src/html-cache.js +46 -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. */
|
|
@@ -247,7 +258,7 @@ export declare function handleApi(
|
|
|
247
258
|
// ---------------------------------------------------------------------------
|
|
248
259
|
|
|
249
260
|
/** Scan the app for `.server.{js,ts}` files and build the RPC + expose index. */
|
|
250
|
-
export declare function buildActionIndex(appDir: string, dev: boolean): Promise<unknown>;
|
|
261
|
+
export declare function buildActionIndex(appDir: string, dev: boolean, opts?: { skipExposeLoad?: boolean }): Promise<unknown>;
|
|
251
262
|
/** Whether a file path is a `.server.{js,ts,mjs,mts}` server file. */
|
|
252
263
|
export declare function isServerFile(file: string): boolean;
|
|
253
264
|
/** SHA-256 hash of an action file's absolute path (the RPC endpoint addressing scheme). */
|
|
@@ -553,6 +564,63 @@ export declare function broadcast(
|
|
|
553
564
|
opts?: { except?: unknown },
|
|
554
565
|
): void;
|
|
555
566
|
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// file-storage.js (#247)
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
/** A streaming handle returned by `FileStore.get` (body is a stream, never buffered). */
|
|
572
|
+
export interface StoredObjectHandle {
|
|
573
|
+
body: ReadableStream | import('node:stream').Readable;
|
|
574
|
+
size: number;
|
|
575
|
+
contentType: string;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** The result of `FileStore.put`. */
|
|
579
|
+
export interface PutResult {
|
|
580
|
+
key: string;
|
|
581
|
+
size: number;
|
|
582
|
+
contentType: string;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* The pluggable file-storage interface. `diskStore` is the local-disk default;
|
|
587
|
+
* an S3-compatible adapter is a drop-in (same web-standard object shapes).
|
|
588
|
+
*/
|
|
589
|
+
export interface FileStore {
|
|
590
|
+
put(
|
|
591
|
+
key: string,
|
|
592
|
+
file: Blob | File | ReadableStream | Uint8Array,
|
|
593
|
+
opts?: { contentType?: string },
|
|
594
|
+
): Promise<PutResult>;
|
|
595
|
+
get(key: string): Promise<StoredObjectHandle | null>;
|
|
596
|
+
delete(key: string): Promise<void>;
|
|
597
|
+
url(key: string): string;
|
|
598
|
+
has?(key: string): Promise<boolean>;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** The default uploads directory (relative to cwd), gitignore-friendly. */
|
|
602
|
+
export declare const DEFAULT_UPLOAD_DIR: string;
|
|
603
|
+
/** Local-disk file store (the default adapter). Streams writes. */
|
|
604
|
+
export declare function diskStore(opts?: { dir?: string; baseUrl?: string }): FileStore;
|
|
605
|
+
/** Set the default file store (call at startup to use a custom dir or S3). */
|
|
606
|
+
export declare function setFileStore(store: FileStore): void;
|
|
607
|
+
/** Get the default file store (`diskStore` under `<cwd>/.webjs/uploads` unless set). */
|
|
608
|
+
export declare function getFileStore(): FileStore;
|
|
609
|
+
/** Generate a random, opaque, traversal-safe key, preserving a whitelisted extension. */
|
|
610
|
+
export declare function generateKey(filename?: string): string;
|
|
611
|
+
/** Validate a key and return the absolute path it resolves to (throws on traversal). */
|
|
612
|
+
export declare function assertSafeKey(dir: string, key: string): string;
|
|
613
|
+
/** Mint a signed, expiring URL for a stored object (HMAC-SHA256). */
|
|
614
|
+
export declare function signedUrl(
|
|
615
|
+
key: string,
|
|
616
|
+
opts: { secret: string; expiresIn?: number; base?: string },
|
|
617
|
+
): string;
|
|
618
|
+
/** Verify a signed URL (or parsed params); constant-time, checks expiry + tamper. */
|
|
619
|
+
export declare function verifySignedUrl(
|
|
620
|
+
input: string | URL | URLSearchParams | { key?: string; exp?: string | number; sig?: string },
|
|
621
|
+
secret: string,
|
|
622
|
+
): { valid: boolean; key: string | null; reason?: string };
|
|
623
|
+
|
|
556
624
|
// ---------------------------------------------------------------------------
|
|
557
625
|
// json.js
|
|
558
626
|
// ---------------------------------------------------------------------------
|
|
@@ -583,6 +651,28 @@ export declare function sitemap(entries: SitemapEntry[]): string;
|
|
|
583
651
|
/** Serialize child sitemaps into a `<sitemapindex>` XML document. */
|
|
584
652
|
export declare function sitemapIndex(sitemaps: SitemapIndexEntry[]): string;
|
|
585
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
|
+
|
|
586
676
|
// ---------------------------------------------------------------------------
|
|
587
677
|
// websocket.js
|
|
588
678
|
// ---------------------------------------------------------------------------
|
package/index.js
CHANGED
|
@@ -46,8 +46,10 @@ export { revalidateTag, revalidateTags } from './src/cache-tags.js';
|
|
|
46
46
|
export { revalidatePath, revalidateAll } from './src/html-cache.js';
|
|
47
47
|
export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
|
|
48
48
|
export { broadcast } from './src/broadcast.js';
|
|
49
|
+
export { diskStore, setFileStore, getFileStore, generateKey, signedUrl, verifySignedUrl, assertSafeKey, DEFAULT_UPLOAD_DIR } from './src/file-storage.js';
|
|
49
50
|
export { json, readBody } from './src/json.js';
|
|
50
51
|
export { sitemap, sitemapIndex } from './src/sitemap.js';
|
|
52
|
+
export { stream, streamResponse, acceptsStream, STREAM_MIME } from './src/stream.js';
|
|
51
53
|
export { attachWebSocket } from './src/websocket.js';
|
|
52
54
|
export { getSerializer, setSerializer, defaultSerializer } from './src/serializer.js';
|
|
53
55
|
|
package/package.json
CHANGED
package/src/actions.js
CHANGED
|
@@ -151,9 +151,17 @@ async function rpcResponse(payload, init = {}) {
|
|
|
151
151
|
*
|
|
152
152
|
* @param {string} appDir
|
|
153
153
|
* @param {boolean} dev
|
|
154
|
+
* @param {{ skipExposeLoad?: boolean }} [opts]
|
|
155
|
+
* `skipExposeLoad: true` builds the (load-free) `fileToHash` / `hashToFile`
|
|
156
|
+
* maps WITHOUT importing any `expose()`-referencing module, so `httpRoutes`
|
|
157
|
+
* stays empty. A read-only introspection caller (the MCP `list_actions` tool,
|
|
158
|
+
* #262) uses this to derive RPC endpoint hashes without running a server
|
|
159
|
+
* module's top-level side effects (Prisma init, DB connect) or risking a
|
|
160
|
+
* stray stdout write. The request pipeline keeps the default (loads expose
|
|
161
|
+
* routes, which the router must know before a request can hit them).
|
|
154
162
|
* @returns {Promise<ActionIndex>}
|
|
155
163
|
*/
|
|
156
|
-
export async function buildActionIndex(appDir, dev) {
|
|
164
|
+
export async function buildActionIndex(appDir, dev, opts = {}) {
|
|
157
165
|
/** @type {Map<string,string>} */
|
|
158
166
|
const hashToFile = new Map();
|
|
159
167
|
/** @type {Map<string,string>} */
|
|
@@ -190,6 +198,9 @@ export async function buildActionIndex(appDir, dev) {
|
|
|
190
198
|
// mention in a comment or string only over-matches, costing one harmless
|
|
191
199
|
// extra module load; the common pure-RPC file never names `expose` and so
|
|
192
200
|
// still defers entirely.
|
|
201
|
+
// A read-only caller (MCP introspection) only needs the file -> hash maps
|
|
202
|
+
// above, so skip the expose-load entirely (no module side effects).
|
|
203
|
+
if (opts.skipExposeLoad) continue;
|
|
193
204
|
let src = '';
|
|
194
205
|
try { src = await readFile(file, 'utf8'); } catch {}
|
|
195
206
|
if (!/\bexpose\b/.test(src)) continue;
|
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 } 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
|
}
|