@webjsdev/server 0.8.63 → 0.8.65
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 +7 -2
- package/package.json +2 -2
- package/src/browser-entries.js +64 -0
- package/src/check/helpers.js +365 -0
- package/src/check/rules-actions.js +174 -0
- package/src/check/rules-components.js +275 -0
- package/src/check/rules-imports.js +166 -0
- package/src/check/rules-registry.js +128 -0
- package/src/check/rules-routing.js +224 -0
- package/src/check/rules-typescript.js +177 -0
- package/src/check/rules.js +127 -0
- package/src/check/runner-support.js +405 -0
- package/src/check/runner.js +113 -0
- package/src/check.js +4 -1774
- package/src/component-elision.js +173 -26
- package/src/dev/config.js +338 -0
- package/src/dev/handler.js +1372 -0
- package/src/dev/helpers.js +542 -0
- package/src/dev/serve.js +840 -0
- package/src/dev/server.js +322 -0
- package/src/dev-classify.js +174 -0
- package/src/dev-reload-worker.js +133 -7
- package/src/dev-styles.js +97 -0
- package/src/dev.js +22 -3077
- package/src/form-dispatch.js +12 -3
- package/src/importmap.js +6 -3
- package/src/listener-core.js +16 -2
- package/src/module-graph.js +112 -7
- package/src/router.js +11 -1
- package/src/ssr/client-router-flag.js +42 -0
- package/src/ssr/document.js +361 -0
- package/src/ssr/env-shim.js +51 -0
- package/src/ssr/escape.js +29 -0
- package/src/ssr/head.js +857 -0
- package/src/ssr/preloads.js +195 -0
- package/src/ssr/render.js +1481 -0
- package/src/ssr/responses.js +201 -0
- package/src/ssr.js +28 -2445
- package/src/vendor/audit.js +400 -0
- package/src/vendor/integrity.js +245 -0
- package/src/vendor/jspm.js +348 -0
- package/src/vendor/manifest.js +96 -0
- package/src/vendor/pins.js +892 -0
- package/src/vendor/providers.js +35 -0
- package/src/vendor/resolver.js +225 -0
- package/src/vendor/scanner.js +132 -0
- 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
|
+
}
|
package/src/dev-reload-worker.js
CHANGED
|
@@ -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).
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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)
|
|
172
|
+
if (lastBoot !== null && e.data !== lastBoot) requestReload('reload');
|
|
51
173
|
lastBoot = e.data;
|
|
52
174
|
});
|
|
53
175
|
|
|
54
|
-
|
|
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) => {
|