@webjsdev/server 0.8.62 → 0.8.64

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.
Files changed (48) hide show
  1. package/index.d.ts +32 -3
  2. package/package.json +2 -2
  3. package/src/browser-entries.js +64 -0
  4. package/src/check/helpers.js +365 -0
  5. package/src/check/rules-actions.js +174 -0
  6. package/src/check/rules-components.js +275 -0
  7. package/src/check/rules-imports.js +166 -0
  8. package/src/check/rules-registry.js +128 -0
  9. package/src/check/rules-routing.js +224 -0
  10. package/src/check/rules-typescript.js +177 -0
  11. package/src/check/rules.js +127 -0
  12. package/src/check/runner-support.js +405 -0
  13. package/src/check/runner.js +113 -0
  14. package/src/check.js +4 -1774
  15. package/src/component-elision.js +1 -1
  16. package/src/dev/config.js +338 -0
  17. package/src/dev/handler.js +1372 -0
  18. package/src/dev/helpers.js +542 -0
  19. package/src/dev/serve.js +840 -0
  20. package/src/dev/server.js +322 -0
  21. package/src/dev-classify.js +174 -0
  22. package/src/dev-reload-worker.js +133 -7
  23. package/src/dev-styles.js +97 -0
  24. package/src/dev.js +22 -3077
  25. package/src/form-dispatch.js +12 -3
  26. package/src/importmap.js +6 -3
  27. package/src/listener-core.js +16 -2
  28. package/src/module-graph.js +112 -7
  29. package/src/rate-limit.js +50 -5
  30. package/src/router.js +11 -1
  31. package/src/ssr/client-router-flag.js +42 -0
  32. package/src/ssr/document.js +361 -0
  33. package/src/ssr/env-shim.js +51 -0
  34. package/src/ssr/escape.js +29 -0
  35. package/src/ssr/head.js +857 -0
  36. package/src/ssr/preloads.js +195 -0
  37. package/src/ssr/render.js +1481 -0
  38. package/src/ssr/responses.js +201 -0
  39. package/src/ssr.js +28 -2445
  40. package/src/vendor/audit.js +400 -0
  41. package/src/vendor/integrity.js +245 -0
  42. package/src/vendor/jspm.js +348 -0
  43. package/src/vendor/manifest.js +96 -0
  44. package/src/vendor/pins.js +892 -0
  45. package/src/vendor/providers.js +35 -0
  46. package/src/vendor/resolver.js +225 -0
  47. package/src/vendor/scanner.js +132 -0
  48. package/src/vendor.js +29 -2371
@@ -0,0 +1,322 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { strongerVerdict } from '../dev-classify.js';
3
+ import { watch as fsWatch } from 'node:fs/promises';
4
+ import { relative } from 'node:path';
5
+ import { createServer as createHttp1Server } from 'node:http';
6
+ import { createRequestHandler } from './handler.js';
7
+ import { readServerTimeoutsFromApp, readDevWatchPathsFromApp } from './config.js';
8
+ import { defaultLogger } from '../logger.js';
9
+ import { SseHub, makeShutdown, installProcessHandlers, DEV_BOOT_ID, serverRuntime } from '../listener-core.js';
10
+ import { urlFromRequest } from '../forwarded.js';
11
+ import { stripBasePath } from '../base-path.js';
12
+ import { basePath } from '../importmap.js';
13
+ import { attachWebSocket } from '../websocket.js';
14
+ import { toWebRequest, sendWebResponse } from './helpers.js';
15
+
16
+ function debounce(fn, ms) {
17
+ let t;
18
+ return (...args) => {
19
+ clearTimeout(t);
20
+ t = setTimeout(() => fn(...args), ms);
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Start a WebJs HTTP server. Thin wrapper around `createRequestHandler`.
26
+ *
27
+ * Speaks plain HTTP/1.1. TLS termination + HTTP/2 to the browser is
28
+ * expected to be handled by a reverse proxy (PaaS edge, nginx, Caddy,
29
+ * etc.) sitting in front of this process. See the deployment docs for
30
+ * the recommended topology.
31
+ *
32
+ /**
33
+ * Paths under the app root whose changes must NOT trigger a dev rebuild.
34
+ * `node_modules` / `.git` are noise. `.webjs/` is the framework's generated
35
+ * artefact dir (the #258 routes.d.ts and the vendor pin) that the dev server
36
+ * itself writes on startup and on every rebuild, so without this skip the
37
+ * write fires a watch event, triggers a rebuild, re-writes the file, and loops
38
+ * forever. `db/dev.db*` (the SQLite file + sidecars) and `db/migrations`
39
+ * (drizzle-kit output) churn during db:migrate. The `db/dev.db` branch is
40
+ * prefix-only (no trailing separator) so the `-journal` / `-wal` sidecars match
41
+ * too, while staying anchored to `db/dev.db` so a SOURCE file like
42
+ * `db/schema.server.ts` still triggers a reload. The others stay
43
+ * separator-anchored so an unrelated name like `node_modules.bak/foo` does not.
44
+ *
45
+ * @param {string} filename relative path from an fs.watch `event.filename`
46
+ * @returns {boolean} true when the change should be ignored
47
+ */
48
+ export function shouldIgnoreWatchPath(filename) {
49
+ return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)/.test(filename || '');
50
+ }
51
+
52
+ /**
53
+ * Install signal handlers that stop accepting new connections, close SSE
54
+ * clients, and exit once in-flight requests drain.
55
+ * @param {import('node:http').Server} server
56
+ * @param {Set<import('node:http').ServerResponse>} sseClients
57
+ * @param {import('../logger.js').Logger} logger
58
+ */
59
+ /**
60
+ * Create a plain HTTP/1.1 server. WebJs deploys are expected to sit
61
+ * behind a reverse proxy (PaaS edge, nginx, Caddy, etc.) that handles
62
+ * TLS termination and speaks HTTP/2 to clients: Node's http2 module
63
+ * doesn't need to be involved on the framework side.
64
+ *
65
+ * @param {(req: any, res: any) => void} handler
66
+ */
67
+ function makeHttpServer(handler) {
68
+ return createHttp1Server(handler);
69
+ }
70
+
71
+ /* ------------ helpers ------------ */
72
+
73
+ /**
74
+ * The node:http listener shell: the original `startServer` socket path, now
75
+ * reading the shared `ListenerContext`. Bridges node `IncomingMessage` ->
76
+ * `Request` (`toWebRequest`) and `Response` -> `ServerResponse`
77
+ * (`sendWebResponse`), emits 103 Early Hints, and drives SSE + WS over node
78
+ * primitives, sharing the SSE registry + lifecycle wiring with the Bun shell.
79
+ * @param {import('../listener-types.js').ListenerContext} ctx
80
+ * @returns {{ server: import('node:http').Server, close: () => Promise<void> }}
81
+ */
82
+ function startNodeListener(ctx) {
83
+ const { app, dev, compress, logger, hub, port, basePathStr, timeouts, watcherAbort } = ctx;
84
+
85
+ const server = makeHttpServer(async (req, res) => {
86
+ try {
87
+ const url = urlFromRequest(req);
88
+
89
+ // SSE: handled specially; doesn't fit the req→Response model. Match the
90
+ // base-path-stripped pathname so the reload stream answers at
91
+ // `<basePath>/__webjs/events` under a sub-path deploy (#256). With no
92
+ // basePath this is a pure pass-through (the bare path still matches).
93
+ if (stripBasePath(url.pathname, basePathStr) === '/__webjs/events') {
94
+ if (!dev) { res.writeHead(404); res.end(); return; }
95
+ res.writeHead(200, {
96
+ 'content-type': 'text/event-stream',
97
+ 'cache-control': 'no-cache',
98
+ connection: 'keep-alive',
99
+ });
100
+ // `retry: 300` shrinks the browser's EventSource reconnect backoff from
101
+ // its ~3s default so a reconnect after a `node --watch` restart happens
102
+ // promptly. The `hello` data is a per-process boot id (#893): the client
103
+ // reloads on a reconnect ONLY when it changes (a real restart), so a
104
+ // transient reconnect never triggers a spurious reload.
105
+ res.write(`retry: 300\nevent: hello\ndata: ${DEV_BOOT_ID}\n\n`);
106
+ // Register a node client wrapper in the shared hub: the fanout + keepalive
107
+ // live in SseHub; only the transport write (res.write / res.end) is local.
108
+ const client = {
109
+ send: (s) => { try { res.write(s); } catch {} },
110
+ close: () => { try { res.end(); } catch {} },
111
+ };
112
+ hub.add(client);
113
+ // Replay an unresolved dev error (#264) so a tab that connects AFTER the
114
+ // breaking edit (e.g. opened via a fresh navigation) still shows the
115
+ // overlay, not only the tab that was open when the error fired.
116
+ const pending = app.getLastDevError?.();
117
+ if (pending) {
118
+ try { res.write(`event: webjs-error\ndata: ${JSON.stringify(pending)}\n\n`); } catch {}
119
+ }
120
+ res.socket?.on('close', () => hub.remove(client));
121
+ return;
122
+ }
123
+
124
+ // 103 Early Hints: before running SSR, send preload hints for the
125
+ // page's module URLs so the browser can begin fetching them while
126
+ // the server is still computing the body. Skipped in dev (file churn
127
+ // would send stale URLs after rebuilds) and for non-GET/HEAD.
128
+ if (
129
+ !dev &&
130
+ (req.method === 'GET' || req.method === 'HEAD') &&
131
+ typeof res.writeEarlyHints === 'function'
132
+ ) {
133
+ const match = app.routeFor(url.pathname);
134
+ if (match && match.moduleUrls.length) {
135
+ try {
136
+ res.writeEarlyHints({
137
+ link: match.moduleUrls.map((u) => `<${u}>; rel=modulepreload`),
138
+ });
139
+ } catch (e) {
140
+ logger.warn('writeEarlyHints failed', { err: String(e) });
141
+ }
142
+ }
143
+ }
144
+
145
+ const webReq = toWebRequest(req, url);
146
+ const resp = await app.handle(webReq);
147
+ await sendWebResponse(res, resp, req, { compress });
148
+ } catch (e) {
149
+ logger.error('request pipeline threw', { err: e instanceof Error ? e.stack : String(e) });
150
+ if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' });
151
+ res.end(dev && e instanceof Error ? `webjs error: ${e.stack}` : 'Internal server error');
152
+ }
153
+ });
154
+
155
+ // node:http built-in timeouts: `requestTimeout` bounds the time to receive the
156
+ // WHOLE request, `headersTimeout` just the headers (kept strictly under
157
+ // requestTimeout so it actually fires), `keepAliveTimeout` the idle window
158
+ // before a kept-alive socket is closed.
159
+ server.requestTimeout = timeouts.requestTimeout;
160
+ server.headersTimeout = timeouts.headersTimeout;
161
+ server.keepAliveTimeout = timeouts.keepAliveTimeout;
162
+
163
+ // WebSocket upgrade handling: any route.js that exports `WS` becomes a
164
+ // WebSocket endpoint at its URL.
165
+ attachWebSocket(server, () => app.getRouteTable(), { dev, logger });
166
+
167
+ server.listen(port, () => {
168
+ logger.info(`webjs ${dev ? 'dev' : 'prod'} server ready on http://localhost:${port}`);
169
+ // The server is now accepting connections; warm the first-request analysis
170
+ // in the background so a real first request finds it memoized. Fire-and-
171
+ // forget: listening (and thus readiness probes / load-balancer health) does
172
+ // not wait on it, and a failure here does not bring the process down.
173
+ app.warmup();
174
+ });
175
+
176
+ const closeServer = () => new Promise((resolve, reject) => {
177
+ server.close((err) => (err ? reject(err) : resolve(undefined)));
178
+ });
179
+ const shutdown = makeShutdown({ closeServer, hub, logger });
180
+ process.once('SIGINT', () => shutdown('SIGINT'));
181
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
182
+
183
+ // Catch-all process handlers: log, but don't tear the process down on a
184
+ // single mishandled promise. Uncaught exceptions are different: state may be
185
+ // corrupted, so log + start an orderly shutdown rather than continuing.
186
+ installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
187
+
188
+ return {
189
+ server,
190
+ close: () => new Promise((r) => {
191
+ if (watcherAbort) watcherAbort.abort();
192
+ // Clear the shared SSE keepalive timer so repeated startServer/close cycles
193
+ // (e.g. across a test run) don't accumulate live intervals.
194
+ hub.closeAll();
195
+ server.close(() => r());
196
+ }),
197
+ };
198
+ }
199
+
200
+ /**
201
+ * The core request → response pipeline, minus middleware.
202
+ * @param {Request} req
203
+ * @param {{state: any, appDir: string, coreDir: string, dev: boolean}} ctx
204
+ */
205
+ /**
206
+ * @param {{
207
+ * appDir: string,
208
+ * port?: number,
209
+ * dev?: boolean,
210
+ * compress?: boolean,
211
+ * logger?: import('../logger.js').Logger,
212
+ * onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
213
+ * }} opts
214
+ */
215
+ export async function startServer(opts) {
216
+ const dev = !!opts.dev;
217
+ const port = opts.port ?? 8080;
218
+ // Compression default: on in prod, off in dev (cheaper to debug raw bytes).
219
+ const compress = opts.compress ?? !dev;
220
+ const logger = opts.logger || defaultLogger({ dev });
221
+
222
+ // Runtime-neutral SSE registry + fanout (shared by the node:http and Bun.serve
223
+ // shells via listener-core.js, so live-reload + the dev error overlay behave
224
+ // identically on both). Built before the handler so its onReload / onDevError
225
+ // callbacks can fan out through it.
226
+ const hub = new SseHub();
227
+ let app;
228
+ try {
229
+ app = await createRequestHandler({
230
+ ...opts,
231
+ logger,
232
+ onReload: (verdict) => hub.reload(verdict),
233
+ // Dev error overlay (#264): push a frame to every open tab over the SAME
234
+ // SSE channel. A distinct `webjs-error` event name (NOT `error`, which is
235
+ // EventSource's native connection-error event) carries the JSON frame.
236
+ onDevError: (frame) => hub.devError(frame),
237
+ });
238
+ } catch (e) {
239
+ // The hub starts its keepalive interval in its constructor (before this
240
+ // await), so a boot failure must clear it rather than leak a live timer.
241
+ hub.closeAll();
242
+ throw e;
243
+ }
244
+
245
+ /** @type {AbortController | null} */
246
+ let watcherAbort = null;
247
+ if (dev) {
248
+ // Watch the app root recursively via Node's built-in
249
+ // `fs.promises.watch`. Stable on macOS, Windows, and Linux as of
250
+ // Node 24. No external dep needed.
251
+ //
252
+ // fs.watch returns relative paths in event.filename. `shouldIgnoreWatchPath`
253
+ // (module-level, exported for tests) skips node_modules, .git, .webjs/, and
254
+ // the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
255
+ // Live-reload classification (#1398). Several files can change inside one
256
+ // 80ms debounce window, so hold the STRONGEST verdict of the window and
257
+ // hand it to the rebuild. Same rule as the browser relay's cross-batch
258
+ // accumulation, for the same reason: a window mixing a page edit and a
259
+ // component edit is a component edit, and taking the last one would morph
260
+ // fresh markup onto the old component class.
261
+ /** @type {import('../dev-classify.js').ReloadVerdict | null} */
262
+ let pendingVerdict = null;
263
+ const rebuild = debounce(() => {
264
+ const v = pendingVerdict;
265
+ pendingVerdict = null;
266
+ app.rebuild(v || undefined);
267
+ }, 80);
268
+ watcherAbort = new AbortController();
269
+ const watchRoot = async (root) => {
270
+ try {
271
+ const events = fsWatch(root, { recursive: true, signal: watcherAbort.signal });
272
+ for await (const event of events) {
273
+ const filename = event.filename || '';
274
+ if (shouldIgnoreWatchPath(filename)) continue;
275
+ // A regenerate output (#967) is a build product the server itself
276
+ // writes on request; ignoring it stops a spurious rebuild + reload
277
+ // (and a reload -> refetch -> recompile -> reload cycle), same as the
278
+ // db/dev.db carve-out above. `app` exposes the check because `state`
279
+ // lives in createRequestHandler's scope, not here.
280
+ if (app.isRegenerateOutput(filename)) continue;
281
+ pendingVerdict = strongerVerdict(pendingVerdict, app.classifyWatchPath(filename, root));
282
+ rebuild();
283
+ }
284
+ } catch (err) {
285
+ if (err && /** @type any */(err).name !== 'AbortError') {
286
+ logger.warn({ err }, `file watcher exited (${root})`);
287
+ }
288
+ }
289
+ };
290
+ watchRoot(app.appDir);
291
+ // Extra roots the app reads from OUTSIDE its appDir (#894): repo-root content
292
+ // dirs (blog markdown, data fixtures) the recursive appDir watch can't see.
293
+ // Opt-in via `webjs.dev.watch`; each funnels into the same debounced rebuild.
294
+ const extraWatch = (await readDevWatchPathsFromApp(app.appDir)).filter((p) => existsSync(p));
295
+ for (const root of extraWatch) watchRoot(root);
296
+ if (extraWatch.length) logger.info?.(`[webjs] also watching ${extraWatch.length} extra dev path(s): ${extraWatch.join(', ')}`);
297
+ }
298
+
299
+ // Inbound server timeouts (issue #237). On node these are the node:http
300
+ // built-ins; on Bun the node `requestTimeout` maps to Bun's single
301
+ // `idleTimeout`. Defends against slowloris and hung connections. Overridable
302
+ // via `webjs.requestTimeoutMs` / `headersTimeoutMs` / `keepAliveTimeoutMs` in
303
+ // package.json or the matching WEBJS_*_MS env vars; `0` disables that timeout.
304
+ const timeouts = await readServerTimeoutsFromApp(app.appDir);
305
+
306
+ // The shared context both listener shells consume. The transport-specific glue
307
+ // (node:http `res.write`, Bun.serve streaming Response) lives in each shell;
308
+ // the SSE registry, the live-reload path predicate, the WS module loader, and
309
+ // the lifecycle wiring are shared via listener-core.js so the shells can't drift.
310
+ /** @type {import('../listener-types.js').ListenerContext} */
311
+ const ctx = { app, dev, compress, logger, hub, port, basePathStr: basePath(), timeouts, watcherAbort };
312
+
313
+ // Pick the adapter by runtime. Bun is Request/Response-native, so its shell
314
+ // skips the node:http bridge (toWebRequest/sendWebResponse) for ~1.9x more
315
+ // req/s on the listening path (#511); the Bun shell is dynamically imported so
316
+ // the `Bun.*` global is never referenced on Node.
317
+ if (serverRuntime() === 'bun') {
318
+ const { startBunListener } = await import('../listener-bun.js');
319
+ return startBunListener(ctx);
320
+ }
321
+ return startNodeListener(ctx);
322
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Classify a dev-watcher file change into a live-reload verdict (#1398).
3
+ *
4
+ * Every dev edit used to produce a full page reload: the page blinks, hydrated
5
+ * component state resets, scroll position is lost. For an edit to a page, a
6
+ * layout, or a server-only module that is heavier than necessary, because pages
7
+ * and layouts never hydrate. Their function runs only on the server, so freshly
8
+ * rendered server HTML is the complete truth for them and nothing in the
9
+ * browser can be stale after a re-render. Such a change can be applied in place
10
+ * through the client router instead of reloading.
11
+ *
12
+ * The decision is made HERE, on the server, and the browser is a dumb executor
13
+ * of the verdict it is handed (the shape Vite and Next.js both settle on). It
14
+ * is kept in its own module, with no server state and no imports, so the ladder
15
+ * is unit-testable without booting anything.
16
+ *
17
+ * The classification walks the MODULE GRAPH, never the path shape. A path-shape
18
+ * heuristic ("anything under `app/**` named `page.ts` can morph") is wrong in
19
+ * both directions and neither failure is exotic:
20
+ *
21
+ * - A page that imports a client-effecting non-component util SHIPS WHOLE (the
22
+ * import-only rule in `component-elision.js`, #605 / #963). Editing it changes
23
+ * browser-bound JS, so it must reload despite being a page.
24
+ * - A helper under `modules/<feature>/utils/` imported by both a page and a
25
+ * component is a component edit by reachability, and its path says nothing.
26
+ *
27
+ * Everything the server cannot classify is a full reload. A wrong morph is a
28
+ * broken page; a wrong reload is a flash.
29
+ */
30
+
31
+ /**
32
+ * The three verdicts, STRONGEST FIRST. The index in this array IS the strength
33
+ * order, which is what `strongerVerdict` compares and what lets a batch of
34
+ * changes collapse to the strongest verdict it contains rather than the last.
35
+ *
36
+ * - `reload`: a component module, anything transitively reaching one, or any
37
+ * path the server cannot place. The browser does `location.reload()`.
38
+ * - `shell`: a layout module, or a server-only module. The browser re-renders
39
+ * the current URL and replaces the whole body, preserving scroll.
40
+ * - `page`: a page module and nothing else. The browser re-renders the current
41
+ * URL and morphs the deepest shared boundary, preserving scroll AND the
42
+ * hydrated state of components outside the changed region.
43
+ *
44
+ * @type {readonly ['reload', 'shell', 'page']}
45
+ */
46
+ export const RELOAD_VERDICTS = /** @type {const} */ (['reload', 'shell', 'page']);
47
+
48
+ /**
49
+ * @typedef {{ v: 'page' | 'shell' | 'reload', by: string, why: string }} ReloadVerdict
50
+ */
51
+
52
+ /**
53
+ * The stronger of two verdicts, treating `null` / `undefined` as weakest and an
54
+ * unrecognised one as `reload` (fail safe, never fail open).
55
+ *
56
+ * Used twice for the same reason. The watcher can see several files change
57
+ * inside one 80ms debounce window, and the browser relay coalesces a burst of
58
+ * signals into ONE emitted reload (#1397). In both places a batch mixing a page
59
+ * edit and a component edit is a COMPONENT edit, so taking the last verdict
60
+ * would leave the old component class running against fresh markup.
61
+ *
62
+ * @param {ReloadVerdict | null | undefined} a
63
+ * @param {ReloadVerdict | null | undefined} b
64
+ * @returns {ReloadVerdict | null}
65
+ */
66
+ export function strongerVerdict(a, b) {
67
+ if (!a) return b || null;
68
+ if (!b) return a;
69
+ return verdictRank(a.v) <= verdictRank(b.v) ? a : b;
70
+ }
71
+
72
+ /**
73
+ * Strength rank of a verdict name. An unknown name ranks as `reload` (0), the
74
+ * strongest, so a corrupt or future value can only ever over-reload.
75
+ * @param {string} v
76
+ * @returns {number}
77
+ */
78
+ function verdictRank(v) {
79
+ const i = RELOAD_VERDICTS.indexOf(/** @type any */ (v));
80
+ return i === -1 ? 0 : i;
81
+ }
82
+
83
+ /**
84
+ * Classify one changed absolute path into a reload verdict.
85
+ *
86
+ * The ladder, first match wins:
87
+ *
88
+ * 1. The analysis has NEVER completed, so nothing is known yet. Fail safe. This
89
+ * mirrors Vite's pessimistic seed (`needFullReload = modules.length === 0`).
90
+ * In practice this is the window between boot and the first request.
91
+ *
92
+ * `analysisReady` deliberately means "the sets are POPULATED", not "they are
93
+ * current". A rebuild invalidates the lazy analysis and nothing re-warms it
94
+ * until an HTTP request arrives, while the relay defers that request by its
95
+ * 2000ms quiet window (#1397), which is longer than the measured inter-save
96
+ * gap. Reading currency here would therefore turn the feature off for every
97
+ * edit after the first in a burst. Classifying against the previous build's
98
+ * graph is the intended behaviour, and it is conservative in the right
99
+ * direction: a file the stale graph has never seen falls to rung 6 and
100
+ * reloads.
101
+ * 2. The path is outside `appDir`. The watcher only fires for `appDir` plus the
102
+ * opt-in `webjs.dev.watch` roots (#894), and a file outside `appDir` is
103
+ * content the server reads at render time, never a browser module. It can
104
+ * change what any layout renders, so it takes the whole-body `shell` swap.
105
+ * 3. The path is in the SHIPPED closure. This is the transitive answer and the
106
+ * one that matters: the browser holds this module, so the page must reload.
107
+ * 4. The path is a page module. Morphable.
108
+ * 5. The path is somewhere else in the module graph (a layout, a server-only
109
+ * util, a `.server.*` file reachable only from a page). Server-only by
110
+ * elimination, so `shell`.
111
+ * 6. Anything else. A brand-new file, a deleted file, and every `public/` asset
112
+ * land here. `public/` is not in the module graph at all, so the server
113
+ * cannot tell what the file IS or what depends on it, and most of what lives
114
+ * there (an image, a font, a service worker, a hand-written script) is
115
+ * something no amount of re-rendering picks up. The refresh does re-request
116
+ * the page's stylesheets, so a `public/` CSS edit alone would now survive a
117
+ * morph, but that is one knowable case inside a rung whose whole point is
118
+ * that the file is unknowable.
119
+ *
120
+ * Note what step 3 sweeps in deliberately. A `'use server'` action file
121
+ * imported by a SHIPPING component is inside the closure (the graph keeps a
122
+ * `.server.*` node and stops at it), so editing it reloads. That is correct
123
+ * rather than merely conservative, because adding an export to that file
124
+ * changes the generated RPC stub the browser holds. The same action imported
125
+ * only by a page falls to step 5 and gets `shell`.
126
+ *
127
+ * @param {string} abs absolute path of the changed file
128
+ * @param {object} ctx
129
+ * @param {string} ctx.appDir
130
+ * @param {Set<string>} ctx.shippedFiles transitive closure of every module the browser can load
131
+ * @param {Set<string>} ctx.graphFiles every app source file the module graph walked
132
+ * @param {Set<string>} ctx.pageFiles every `page.{js,ts}` on the route table
133
+ * @param {boolean} ctx.analysisReady false before the first analysis completes
134
+ * @param {string} [ctx.sep] path separator, injectable so the ladder is testable off-platform
135
+ * @returns {ReloadVerdict}
136
+ */
137
+ export function classifyChangedPath(abs, ctx) {
138
+ const sep = ctx.sep || '/';
139
+ const by = relativize(abs, ctx.appDir, sep);
140
+ if (!abs) return { v: 'reload', by: '', why: 'no-path' };
141
+ if (!ctx.analysisReady) return { v: 'reload', by, why: 'analysis-cold' };
142
+ if (!isUnder(abs, ctx.appDir, sep)) return { v: 'shell', by, why: 'extra-watch-root' };
143
+ if (ctx.shippedFiles && ctx.shippedFiles.has(abs)) return { v: 'reload', by, why: 'ships-to-browser' };
144
+ if (ctx.pageFiles && ctx.pageFiles.has(abs)) return { v: 'page', by, why: 'page-module' };
145
+ if (ctx.graphFiles && ctx.graphFiles.has(abs)) return { v: 'shell', by, why: 'server-only-module' };
146
+ return { v: 'reload', by, why: 'unknown-path' };
147
+ }
148
+
149
+ /**
150
+ * True when `abs` sits inside `dir`. The `dir + sep` boundary is what stops a
151
+ * sibling directory sharing a prefix (`/app-old` against `/app`) from being
152
+ * read as containment, the same guard `asset-hash` uses.
153
+ * @param {string} abs
154
+ * @param {string} dir
155
+ * @param {string} sep
156
+ */
157
+ function isUnder(abs, dir, sep) {
158
+ if (!dir) return false;
159
+ return abs === dir || abs.startsWith(dir.endsWith(sep) ? dir : dir + sep);
160
+ }
161
+
162
+ /**
163
+ * The app-relative path, or the absolute path when it is not under `appDir`.
164
+ * Diagnostic only: it rides the SSE frame as `by` so a dev can see which file
165
+ * produced a verdict, and nothing branches on it.
166
+ * @param {string} abs
167
+ * @param {string} dir
168
+ * @param {string} sep
169
+ */
170
+ function relativize(abs, dir, sep) {
171
+ if (!abs) return '';
172
+ if (!isUnder(abs, dir, sep)) return abs;
173
+ return abs.slice(dir.endsWith(sep) ? dir.length : dir.length + 1);
174
+ }
@@ -2,17 +2,86 @@
2
2
  * The dev live-reload SharedWorker relay (#887), the BROWSER half. Kept as a
3
3
  * standalone browser-safe module (no node imports) so the served worker inlines
4
4
  * the EXACT source a browser test drives, with no drift, the same pattern as
5
- * `dev-overlay.js` (#264). `reloadWorkerJs` in dev.js reads this file, strips
6
- * the `export` keyword, and appends a
7
- * `startReloadWorker(self, EventSource, '<eventsUrl>')` call.
5
+ * `dev-overlay.js` (#264).
6
+ *
7
+ * BOTH served dev scripts inline this file, `export`-stripped. `reloadWorkerJs`
8
+ * appends a `startReloadWorker(self, EventSource, '<eventsUrl>')` call for the
9
+ * SharedWorker, and since #1397 `reloadClientJs` inlines it too, so the per-tab
10
+ * fallback runs this same relay over a shim port instead of a second copy of
11
+ * the boot-id rule and the reload debounce.
8
12
  *
9
13
  * One SharedWorker is shared across every tab of the origin (a SharedWorker is
10
14
  * keyed by its script URL), so it holds the ONE `EventSource` to
11
15
  * `/__webjs/events` and fans each `reload` / `webjs-error` out to every tab over
12
16
  * its `MessagePort`. Tab count never touches the browser's per-host HTTP/1.1
13
17
  * connection cap, which the per-tab `EventSource` it replaces used to exhaust.
18
+ */
19
+
20
+ /**
21
+ * Reload coalescing (#1397). An agent saves several files a second or two
22
+ * apart, and EACH save produces TWO reload signals: the in-process `reload`
23
+ * frame from the fs.watch rebuild, then a changed boot id when the browser
24
+ * reconnects to the process `node --watch` restarted (measured 429ms apart, and
25
+ * 1071ms between one save's reconnect and the next save's in-process frame).
26
+ * Acting on every one reloads into a server that is about to be killed again,
27
+ * which is how the page ends up unstyled.
28
+ *
29
+ * 2000ms is above the measured 1071ms inter-save gap so a realistic burst
30
+ * collapses to one reload, and deliberately not NEAR it, since a window close
31
+ * to that gap fires just as the next restart begins. It also lands just past
32
+ * the 1900ms analysis warm measured on the website app, which is the useful
33
+ * place for it: the restarted process kicks off `warmup()` as soon as it
34
+ * listens, so the wait overlaps work the reload request would have blocked on
35
+ * anyway and the reload arrives at a server that has finished warming.
36
+ */
37
+ export const RELOAD_QUIET_MS = 2000;
38
+
39
+ /**
40
+ * The longest a reload is ever held, measured from the FIRST signal of a batch
41
+ * (#1397). A quiet window alone would freeze the page on stale content for a
42
+ * whole agent burst, so a sustained burst still repaints at least this often.
43
+ * 2.5x the quiet window, so it never fires for an ordinary pair of edits.
44
+ */
45
+ export const RELOAD_MAX_HOLD_MS = 5000;
46
+
47
+ /**
48
+ * Verdict strength, STRONGEST FIRST (#1398). Mirrors `RELOAD_VERDICTS` in the
49
+ * server's `dev-classify.js`; duplicated rather than imported because this file
50
+ * is inlined verbatim into two served browser scripts and must stay
51
+ * import-free.
52
+ *
53
+ * A batch collapses to ONE emitted reload, so it must take the STRONGEST
54
+ * verdict in the batch and never the last one: a burst mixing a page edit and a
55
+ * component edit is a component edit, and morphing it would leave the old
56
+ * component class running against fresh markup.
57
+ */
58
+ export const VERDICT_STRENGTH = ['reload', 'shell', 'page'];
59
+
60
+ /**
61
+ * Resolve an SSE `reload` frame's `data` to a verdict name.
14
62
  *
15
- * @param {{ onconnect: any }} scope the worker global (`self`)
63
+ * ANY failure resolves to `reload`: an absent payload, malformed JSON, a
64
+ * non-object, a `v` outside the three literals, or the legacy bare `data: now`.
65
+ * That is what makes the wire-format change safe in both directions between a
66
+ * long-running browser tab and a restarted server, since the worst a skew can
67
+ * produce is the full reload that was the behaviour before this existed.
68
+ *
69
+ * @param {string} data
70
+ * @returns {string} one of VERDICT_STRENGTH
71
+ */
72
+ export function parseVerdict(data) {
73
+ try {
74
+ const o = JSON.parse(data);
75
+ if (o && typeof o === 'object' && VERDICT_STRENGTH.indexOf(o.v) !== -1) return o.v;
76
+ } catch (_) { /* fall through to the fail-safe */ }
77
+ return 'reload';
78
+ }
79
+
80
+ /**
81
+ * @param {{ onconnect: any, setTimeout?: any, clearTimeout?: any }} scope the
82
+ * worker global (`self`), or a plain shim object for the per-tab fallback.
83
+ * Timers are read off it when it has them, which is what lets a browser test
84
+ * drive the debounce on a fake clock.
16
85
  * @param {new (url: string) => any} EventSourceCtor the `EventSource` constructor
17
86
  * @param {string} eventsUrl the base-path-aware `/__webjs/events` URL
18
87
  */
@@ -24,6 +93,13 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
24
93
  /** @type {string | null} the last-seen per-process boot id (#893) */
25
94
  let lastBoot = null;
26
95
 
96
+ // Timers come from the worker global when it has them (the real SharedWorker
97
+ // scope does, and so does a tab running the per-tab fallback), else the
98
+ // ambient globals. Reading them off `scope` is what lets the browser test
99
+ // drive a fake clock and keep its assertions synchronous, and calling them as
100
+ // `timers.setTimeout(...)` keeps `this` the global in a real browser.
101
+ const timers = scope && typeof scope.setTimeout === 'function' ? scope : globalThis;
102
+
27
103
  // A MessagePort has no reliable close event, so prune a port when a post to it
28
104
  // throws (a closed tab). Some browsers silently no-op instead of throwing,
29
105
  // leaving a dead port in the set, but that is a harmless dev-only no-op and
@@ -34,6 +110,43 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
34
110
  }
35
111
  }
36
112
 
113
+ // The ONE debounced reload emitter (#1397). Two timers rather than a clock:
114
+ // the quiet timer is re-armed by every signal, the cap timer is armed once
115
+ // per batch and NEVER re-armed, so the cap measures from the first signal of
116
+ // the batch instead of sliding with the burst. Whichever fires first emits
117
+ // and cancels the other, which also starts a fresh batch.
118
+ /** @type {any} */ let quietTimer = null;
119
+ /** @type {any} */ let capTimer = null;
120
+
121
+ /** @type {string} the strongest verdict seen in the CURRENT batch (#1398) */
122
+ let batchVerdict = 'page';
123
+
124
+ function emitReload() {
125
+ if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
126
+ if (capTimer !== null) { timers.clearTimeout(capTimer); capTimer = null; }
127
+ const v = batchVerdict;
128
+ // Resetting HERE rather than in requestReload is load-bearing: emitReload is
129
+ // the single place a batch ends, and it is reached from both timers.
130
+ batchVerdict = 'page'; // a fresh batch starts at the weakest verdict
131
+ fanout({ type: 'reload', verdict: v });
132
+ }
133
+
134
+ /** Strength rank; an unrecognised name ranks strongest, so it can only ever
135
+ * over-reload. @param {string} v */
136
+ function rank(v) {
137
+ const i = VERDICT_STRENGTH.indexOf(v);
138
+ return i === -1 ? 0 : i;
139
+ }
140
+
141
+ /** @param {string} verdict */
142
+ function requestReload(verdict) {
143
+ // Lower rank is stronger, so keep the minimum across the batch.
144
+ if (rank(verdict) < rank(batchVerdict)) batchVerdict = VERDICT_STRENGTH[rank(verdict)];
145
+ if (quietTimer !== null) timers.clearTimeout(quietTimer);
146
+ quietTimer = timers.setTimeout(emitReload, RELOAD_QUIET_MS);
147
+ if (capTimer === null) capTimer = timers.setTimeout(emitReload, RELOAD_MAX_HOLD_MS);
148
+ }
149
+
37
150
  const es = new EventSourceCtor(eventsUrl);
38
151
 
39
152
  // The `hello` frame fires on every (re)connect and carries the server's
@@ -45,13 +158,26 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl) {
45
158
  // tab still gates it on a readiness probe). A transient reconnect (sleep/wake,
46
159
  // a network blip, a tab evicted at the HTTP/1.1 cap) reconnects to the SAME
47
160
  // process with the SAME id, so it never reloads. The first `hello` only
48
- // records the baseline.
161
+ // records the baseline. Both reload paths route through the ONE debounced
162
+ // emitter above (#1397), since a burst of edits produces one signal of each
163
+ // kind per save.
164
+ //
165
+ // A changed boot id is unconditionally a FULL RELOAD (#1398), and it is NOT a
166
+ // "no verdict" signal to be overridden by a lighter one already in the batch.
167
+ // A restart carries no filename, so nothing survives it to classify, and a
168
+ // burst can hold a page edit whose in-process frame was delivered next to a
169
+ // component edit whose frame died with the old process. In that burst the
170
+ // changed boot id is the component edit's ONLY trace.
49
171
  es.addEventListener('hello', (e) => {
50
- if (lastBoot !== null && e.data !== lastBoot) fanout({ type: 'reload' });
172
+ if (lastBoot !== null && e.data !== lastBoot) requestReload('reload');
51
173
  lastBoot = e.data;
52
174
  });
53
175
 
54
- es.addEventListener('reload', () => { lastError = null; fanout({ type: 'reload' }); });
176
+ // The cached error is cleared IMMEDIATELY, not on the debounced emit: a tab
177
+ // connecting during the pending window must not be replayed an overlay for an
178
+ // error the rebuild already fixed. `webjs-error` itself stays undebounced,
179
+ // since an overlay has to appear at once and it is not a reload.
180
+ es.addEventListener('reload', (e) => { lastError = null; requestReload(parseVerdict(e.data)); });
55
181
  es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
56
182
 
57
183
  scope.onconnect = (e) => {