@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,542 @@
1
+ import { join, resolve, dirname, sep } from 'node:path';
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { stat } from 'node:fs/promises';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createRequire } from 'node:module';
6
+ import { withBasePath } from '../base-path.js';
7
+ import { isCompressible, negotiateEncoding, createCompressor, varyWithAcceptEncoding } from '../listener-core.js';
8
+
9
+ /** @param {import('node:http').IncomingMessage} req @param {URL} url */
10
+ export function toWebRequest(req, url) {
11
+ const method = (req.method || 'GET').toUpperCase();
12
+ /** @type {Record<string,string>} */
13
+ const headers = {};
14
+ for (const [k, v] of Object.entries(req.headers)) {
15
+ // Drop HTTP/2 pseudo-headers (`:method`, `:path`, `:scheme`, `:authority`).
16
+ // They're parsed separately into req.method / req.url and are rejected
17
+ // by the standard Headers class if we pass them through verbatim.
18
+ if (k.startsWith(':')) continue;
19
+ // Strip any inbound `x-webjs-remote-ip` header so clients cannot
20
+ // spoof the framework-stamped client IP that rate-limit's
21
+ // `clientIp(req, { trustProxy: false })` reads. We rewrite it
22
+ // below from the actual TCP socket. Node's IncomingMessage
23
+ // always lowercases header keys, so a literal compare is enough.
24
+ if (k === 'x-webjs-remote-ip') continue;
25
+ headers[k] = Array.isArray(v) ? v.join(',') : String(v ?? '');
26
+ }
27
+ // Stamp the framework-trusted remote IP from the socket. Read by
28
+ // `clientIp(req)` (rate-limit.js) as the bucket key when
29
+ // `trustProxy: false` (the safe default).
30
+ const remoteIp = req.socket?.remoteAddress;
31
+ if (remoteIp) headers['x-webjs-remote-ip'] = remoteIp;
32
+ let body;
33
+ if (method !== 'GET' && method !== 'HEAD') {
34
+ body = new ReadableStream({
35
+ start(controller) {
36
+ req.on('data', (chunk) => controller.enqueue(chunk));
37
+ req.on('end', () => controller.close());
38
+ req.on('error', (e) => controller.error(e));
39
+ },
40
+ });
41
+ }
42
+ return new Request(url, /** @type any */ ({ method, headers, body, duplex: 'half' }));
43
+ }
44
+
45
+ /**
46
+ * @param {import('node:http').ServerResponse} res
47
+ * @param {Response} webRes
48
+ * @param {import('node:http').IncomingMessage} [req]
49
+ * @param {{ compress?: boolean }} [opts]
50
+ */
51
+ export async function sendWebResponse(res, webRes, req, opts) {
52
+ /** @type {Record<string,string | string[]>} */
53
+ const headers = {};
54
+ // Preserve multi-value headers (Set-Cookie) via getSetCookie when available.
55
+ if (typeof /** @type any */ (webRes.headers).getSetCookie === 'function') {
56
+ const cookies = /** @type any */ (webRes.headers).getSetCookie();
57
+ if (cookies.length) headers['set-cookie'] = cookies;
58
+ }
59
+ webRes.headers.forEach((v, k) => {
60
+ if (k === 'set-cookie') return;
61
+ headers[k] = v;
62
+ });
63
+
64
+ // Negotiate compression via the SHARED seam (listener-core.js), so the node and
65
+ // Bun shells negotiate + compress identically (brotli > gzip > deflate, node:zlib
66
+ // both sides). Skip a body that is already content-encoded (a route.ts returning
67
+ // pre-compressed bytes), and merge into any pre-existing `Vary` rather than
68
+ // clobbering it (`isCompressible` already excludes `text/event-stream`).
69
+ let compressor = null;
70
+ if (opts?.compress && req && webRes.body && !headers['content-encoding'] && isCompressible(headers['content-type'])) {
71
+ const encoding = negotiateEncoding(req.headers['accept-encoding']);
72
+ compressor = createCompressor(encoding);
73
+ if (compressor) {
74
+ headers['content-encoding'] = encoding;
75
+ headers['vary'] = varyWithAcceptEncoding(typeof headers['vary'] === 'string' ? headers['vary'] : '');
76
+ delete headers['content-length'];
77
+ }
78
+ }
79
+
80
+ res.writeHead(webRes.status, headers);
81
+ if (!webRes.body) { res.end(); return; }
82
+
83
+ if (compressor) {
84
+ compressor.pipe(res);
85
+ const reader = webRes.body.getReader();
86
+ try {
87
+ while (true) {
88
+ const { done, value } = await reader.read();
89
+ if (done) break;
90
+ compressor.write(value);
91
+ }
92
+ } finally {
93
+ compressor.end();
94
+ }
95
+ return;
96
+ }
97
+
98
+ const reader = webRes.body.getReader();
99
+ while (true) {
100
+ const { done, value } = await reader.read();
101
+ if (done) break;
102
+ res.write(value);
103
+ }
104
+ res.end();
105
+ }
106
+
107
+ export const MIME = {
108
+ '.html': 'text/html; charset=utf-8',
109
+ '.js': 'application/javascript; charset=utf-8',
110
+ '.mjs': 'application/javascript; charset=utf-8',
111
+ '.ts': 'application/javascript; charset=utf-8',
112
+ '.mts': 'application/javascript; charset=utf-8',
113
+ '.css': 'text/css; charset=utf-8',
114
+ '.json': 'application/json; charset=utf-8',
115
+ '.svg': 'image/svg+xml',
116
+ '.png': 'image/png',
117
+ '.jpg': 'image/jpeg',
118
+ '.jpeg': 'image/jpeg',
119
+ '.gif': 'image/gif',
120
+ '.webp': 'image/webp',
121
+ '.ico': 'image/x-icon',
122
+ '.txt': 'text/plain; charset=utf-8',
123
+ };
124
+
125
+ /**
126
+ * Cache of stripped `.ts` / `.mts` source.
127
+ * Keyed by absolute file path. Entries expire when mtime changes.
128
+ * Capped at 500 entries to prevent unbounded memory growth in
129
+ * long-running production servers.
130
+ *
131
+ * Stripper: `module.stripTypeScriptTypes` (Node 24+ built-in).
132
+ * Position-preserving whitespace replacement. No sourcemap is
133
+ * emitted because every (line, column) maps to itself in the source.
134
+ *
135
+ * Only erasable TypeScript is supported. Non-erasable syntax (`enum`,
136
+ * `namespace` with values, parameter properties, legacy decorators
137
+ * with `emitDecoratorMetadata`, `import = require`) throws at strip
138
+ * time. The `erasable-typescript-only` and `no-non-erasable-typescript`
139
+ * lint rules catch these at edit time. WebJs is buildless end-to-end:
140
+ * there is no bundler fallback.
141
+ *
142
+ * The transformed bytes are cached per request handler in `state.tsCache`
143
+ * (a `Map<string, { mtimeMs, code, map }>`), bounded to `TS_CACHE_MAX`
144
+ * entries. The cache is per-handler rather than module-global because the
145
+ * cached code bakes in that handler's elision verdict, so two handlers for
146
+ * the same app with different elision settings must not share it.
147
+ */
148
+ export const TS_CACHE_MAX = 500;
149
+
150
+ /** PascalCase → kebab-case for a helpful diagnostic example tag name. */
151
+ export function kebab(name) {
152
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
153
+ }
154
+
155
+ /**
156
+ * Per-request correlation id (issue #239). Honor an inbound `X-Request-Id`
157
+ * from a trusted upstream proxy so a trace id propagates across services; mint
158
+ * a fresh `crypto.randomUUID()` otherwise. The inbound value is length-capped
159
+ * and validated against a conservative token charset so a hostile client
160
+ * cannot inject control chars / a header-splitting payload (the value is
161
+ * echoed back in the `X-Request-Id` response header). On any mismatch we fall
162
+ * back to a minted id rather than trust the junk.
163
+ *
164
+ * @param {Request} req
165
+ * @returns {string}
166
+ */
167
+ export function resolveRequestId(req) {
168
+ const inbound = req.headers.get('x-request-id');
169
+ if (inbound && inbound.length <= 200 && /^[A-Za-z0-9._-]+$/.test(inbound)) return inbound;
170
+ return crypto.randomUUID();
171
+ }
172
+
173
+ /**
174
+ * Whether a path should be access-logged (issue #239). The framework's own
175
+ * `/__webjs/*` probes, static runtime assets, and the dev SSE reload stream
176
+ * are high-frequency infrastructure traffic, not app requests, so logging them
177
+ * would just spam the access log. App routes (including app-authored
178
+ * `/api/*`) are logged.
179
+ *
180
+ * @param {string} pathname
181
+ * @returns {boolean}
182
+ */
183
+ export function shouldAccessLog(pathname) {
184
+ return !pathname.startsWith('/__webjs/');
185
+ }
186
+
187
+ /**
188
+ * Auto-load `<appDir>/.env` into `process.env` once at boot. Mirrors
189
+ * what Rails / Next / Astro do out of the box: a scaffolded app with
190
+ * a committed `.env.example` and a developer-copied `.env` should
191
+ * "just work" without the user having to add a dotenv import or set
192
+ * the file path on the CLI.
193
+ *
194
+ * Uses Node 24+'s built-in `process.loadEnvFile`, which is dotenv-
195
+ * compatible and DOES NOT override pre-existing `process.env` values.
196
+ * Calls that hit a missing file or parse error are silenced; the
197
+ * server should still come up cleanly when there's no `.env`.
198
+ *
199
+ * Idempotent: re-running is a no-op for any env var the user already
200
+ * exported (e.g. via the host shell or a process manager). That
201
+ * keeps the "shell-set wins over file" precedence Rails users
202
+ * expect.
203
+ *
204
+ * Must run before any server-only module is loaded by
205
+ * buildActionIndex, since module-init code in `lib/*.server.ts`
206
+ * (e.g. `createAuth({ secret: process.env.AUTH_SECRET })`) reads
207
+ * process.env at import time. createRequestHandler is the
208
+ * single entry point where this is guaranteed.
209
+ *
210
+ * @param {string} appDir
211
+ */
212
+ export function loadAppEnv(appDir) {
213
+ try {
214
+ if (typeof process.loadEnvFile === 'function') {
215
+ process.loadEnvFile(join(appDir, '.env'));
216
+ }
217
+ } catch {
218
+ // No .env file, malformed file, or Node version without
219
+ // loadEnvFile. Either way, fall through silently: the user
220
+ // may not need any env vars, or they may set them via shell.
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Walk the route table + component scanner to collect every file the
226
+ * browser may legitimately fetch as an ES module, then expand via the
227
+ * module graph into the full transitive closure.
228
+ *
229
+ * This is webjs's equivalent of Next.js's bundler-produced page
230
+ * manifest, derived lazily on the first request (and re-derived on every
231
+ * rebuild) instead of at compile time. The dev server's source-file branch uses the returned
232
+ * Set as an authorization gate: in-set → served (subject to the
233
+ * .server.{js,ts} stub guardrail); out-of-set → 404.
234
+ *
235
+ * Browser-bound entries:
236
+ * - page.{js,ts,mjs,mts} (re-runs on client for hydration)
237
+ * - layout.{js,ts,mjs,mts} (same)
238
+ * - error.{js,ts,mjs,mts} (same)
239
+ * - loading.{js,ts,mjs,mts} (same)
240
+ * - not-found.{js,ts,mjs,mts} (same)
241
+ * - component files discovered by the scanner (eager + lazy)
242
+ *
243
+ * Server-only entries (NOT in the set):
244
+ * - route.{js,ts} (API handlers, never fetched as JS module)
245
+ * - middleware.{js,ts}
246
+ * - metadata routes (sitemap.js, robots.js, manifest.js, …)
247
+ * - .server.{js,ts} files (browser gets a stub, not the source)
248
+ *
249
+ * Components are passed in (rather than rescanned) so the caller can
250
+ * share one scan with `primeComponentRegistry`. Saves a full
251
+ * appDir walk on each analysis (the first request and every rebuild).
252
+ *
253
+ * @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
254
+ * @param {Awaited<ReturnType<typeof buildModuleGraph>>} moduleGraph
255
+ * @param {Awaited<ReturnType<typeof scanComponents>>} components
256
+ * @param {string} appDir
257
+ * @returns {Set<string>}
258
+ */
259
+ /**
260
+ * Collect every page + layout file across the route table. These are the
261
+ * modules the client boot script imports, and thus the candidates for
262
+ * inert-route elision (dropping a module that does no client work).
263
+ * `route.{js,ts}` / middleware / metadata are excluded: they never ship.
264
+ *
265
+ * @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
266
+ * @returns {string[]}
267
+ */
268
+ export function collectRouteModules(routeTable) {
269
+ /** @type {Set<string>} */
270
+ const mods = new Set();
271
+ for (const page of routeTable.pages || []) {
272
+ if (page.file) mods.add(page.file);
273
+ for (const f of page.layouts || []) mods.add(f);
274
+ }
275
+ return [...mods];
276
+ }
277
+
278
+ /**
279
+ * List the app's top-level source directory names, for expanding a `#*`
280
+ * catch-all import alias into one browser importmap prefix scope per dir (#555).
281
+ * Excludes infra dirs that are never imported via `#` (node_modules, dotfiles,
282
+ * the build/vendor caches). A new top-level folder is picked up on the next boot
283
+ * (dev restarts on changes), so the alias stays zero-maintenance.
284
+ * @param {string} appDir
285
+ * @returns {string[]}
286
+ */
287
+ export function appTopLevelDirs(appDir) {
288
+ const SKIP = new Set(['node_modules', 'dist', 'public']);
289
+ try {
290
+ return readdirSync(appDir, { withFileTypes: true })
291
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !SKIP.has(e.name))
292
+ .map((e) => e.name);
293
+ } catch {
294
+ return [];
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Find the absolute directory of the `@webjsdev/core` package, regardless of
300
+ * whether we're running from the monorepo or an installed copy.
301
+ * @param {string} appDir
302
+ */
303
+ export function locateCoreDir(appDir) {
304
+ try {
305
+ const require = createRequire(join(appDir, 'package.json'));
306
+ const pkgPath = require.resolve('@webjsdev/core/package.json');
307
+ return dirname(pkgPath);
308
+ } catch {}
309
+ // Workspace fallback when the app dir cannot resolve `@webjsdev/core`.
310
+ // This file lives at `packages/server/src/dev/helpers.js`, one directory
311
+ // deeper than the pre-split `packages/server/src/dev.js`, so the walk up to
312
+ // `packages/` needs FOUR steps, not three. Getting this wrong resolves to
313
+ // `packages/server/core`, which does not exist, and every `/__webjs/core/*`
314
+ // request 404s while the importmap still points at it.
315
+ const here = fileURLToPath(import.meta.url);
316
+ return resolve(here, '..', '..', '..', '..', 'core');
317
+ }
318
+
319
+ /**
320
+ * Find an npm package's installed root folder in the app's node_modules graph.
321
+ * @param {string} appDir
322
+ * @param {string} pkgName
323
+ * @returns {string | null}
324
+ */
325
+ export function locatePackageDir(appDir, pkgName) {
326
+ // Many packages lock down `./package.json` in their exports field, so we
327
+ // resolve the bare specifier (always exported) and trim back to the
328
+ // folder named pkgName.
329
+ const match = '/node_modules/' + pkgName + '/';
330
+ const tryFrom = (from) => {
331
+ const require = createRequire(from);
332
+ const entry = require.resolve(pkgName).split(sep).join('/');
333
+ const at = entry.lastIndexOf(match);
334
+ if (at < 0) return null;
335
+ return entry.slice(0, at + match.length - 1).split('/').join(sep);
336
+ };
337
+ try { const d = tryFrom(join(appDir, 'package.json')); if (d) return d; } catch {}
338
+ try { const d = tryFrom(fileURLToPath(import.meta.url)); if (d) return d; } catch {}
339
+ return null;
340
+ }
341
+
342
+ // The overlay renderer source, read once + `export`-stripped so it inlines as
343
+ // plain functions into the classic reload-client script. Sharing the one source
344
+ // (`dev-overlay.js`, which the browser test imports directly) means the test
345
+ // drives the EXACT code that ships, with no drift (#264).
346
+ const DEV_OVERLAY_SRC = readFileSync(new URL('../dev-overlay.js', import.meta.url), 'utf8')
347
+ .replace(/^export /gm, '');
348
+
349
+ // The reload SharedWorker relay source (#887), read once + `export`-stripped so
350
+ // it inlines into the served worker script. Sharing the one source
351
+ // (`dev-reload-worker.js`, which the browser test imports directly) means the
352
+ // test drives the EXACT relay code that ships, with no drift (same as #264).
353
+ const RELOAD_WORKER_SRC = readFileSync(new URL('../dev-reload-worker.js', import.meta.url), 'utf8')
354
+ .replace(/^export /gm, '');
355
+
356
+ // The stylesheet re-request source (#1398), read once + `export`-stripped so it
357
+ // inlines into the served reload client. Same shared-source rule as the two
358
+ // above: the browser test imports this module directly, so it drives the EXACT
359
+ // code that ships.
360
+ const DEV_STYLES_SRC = readFileSync(new URL('../dev-styles.js', import.meta.url), 'utf8')
361
+ .replace(/^export /gm, '');
362
+
363
+ /**
364
+ * The dev live-reload client. The `EventSource` URL is a framework-emitted
365
+ * same-origin path, so it must carry the base path under a sub-path deploy
366
+ * (#256), like the importmap targets and the RPC stub. No-op when basePath
367
+ * is empty.
368
+ * @param {string} bp the normalized base path (`''` = no-op)
369
+ * @returns {string}
370
+ */
371
+ export function reloadClientJs(bp) {
372
+ // The overlay renderer uses textContent throughout (never innerHTML), so the
373
+ // error message / code frame can never inject markup (#264). Served only in
374
+ // dev (the /__webjs/reload.js branch 404s in prod), so it never reaches a
375
+ // production page.
376
+ //
377
+ // Every tab shares ONE live-reload connection through a SharedWorker (#887).
378
+ // The old client opened an `EventSource` per tab, and because the dev server
379
+ // is HTTP/1.1 and browsers cap concurrent connections per host at ~6, a
380
+ // handful of open tabs would hold every connection slot with idle SSE streams
381
+ // and later tabs could not even fetch their HTML. The SharedWorker holds the
382
+ // single `EventSource` (see reloadWorkerJs) and relays each `reload` /
383
+ // `webjs-error` to every tab. The overlay still renders on the main thread
384
+ // (a worker has no DOM), so the worker forwards only the raw frame data.
385
+ //
386
+ // Fallback: where `SharedWorker` is missing (some mobile browsers) or its
387
+ // construction throws (a strict dev CSP without `worker-src`), each tab opens
388
+ // its own `EventSource`, the original behaviour. Correct, just not shared.
389
+ const eventsUrl = JSON.stringify(withBasePath('/__webjs/events', bp));
390
+ const workerUrl = JSON.stringify(withBasePath('/__webjs/reload-worker.js', bp));
391
+ const versionUrl = JSON.stringify(withBasePath('/__webjs/version', bp));
392
+ return `// webjs dev reload client
393
+ ${DEV_OVERLAY_SRC}
394
+ ${RELOAD_WORKER_SRC}
395
+ ${DEV_STYLES_SRC}
396
+ function __webjsApplyError(data) {
397
+ let f; try { f = JSON.parse(data); } catch (_) { return; }
398
+ renderDevOverlay(f);
399
+ }
400
+ // Keep the overlay tracking the page actually on screen (#1047): a render frame
401
+ // is scoped to the URL that produced it, so navigating away takes it down, and a
402
+ // frame that arrived before the URL advanced goes up once it does. The gate
403
+ // itself lives inside renderDevOverlay, so __webjsApplyError needs no change.
404
+ installDevOverlayNavSync();
405
+ // Never reload INTO a server that is still restarting (Node's node --watch
406
+ // briefly kills the process on an edit), which would paint a style-less,
407
+ // half-rendered page (#893). Probe the lightweight /__webjs/version endpoint
408
+ // until it answers, THEN reload. Under an in-process reload the server is up,
409
+ // so the first probe passes and the reload is instant; under a restart the
410
+ // probes fail until the fresh server is listening, so the old page stays put
411
+ // until the new one is ready. Bounded, so a genuinely-dead server still
412
+ // reloads (and shows the browser's own error) rather than hanging forever.
413
+ function __webjsWhenReady(then) {
414
+ var tries = 0;
415
+ function attempt() {
416
+ fetch(${versionUrl}, { cache: 'no-store' }).then(function (r) {
417
+ if (r && r.ok) then(); else again();
418
+ }).catch(again);
419
+ }
420
+ function again() { if (++tries > 100) location.reload(); else setTimeout(attempt, 100); }
421
+ attempt();
422
+ }
423
+ // Apply a reload signal at the lightest correct weight (#1398). A page or
424
+ // layout edit never changes browser-bound JS, so the server's re-render is the
425
+ // complete truth and the client router can swap it in place: scroll and (for a
426
+ // page edit) the hydrated state of components outside the changed region
427
+ // survive. Anything else, including every signal the server could not classify,
428
+ // is the full reload this always was.
429
+ function __webjsApplyReload(verdict) {
430
+ __webjsWhenReady(function () {
431
+ // Runtime feature detection, never an assumption. The refresh entry is
432
+ // published by enableClientRouter and removed by disableClientRouter, so its
433
+ // ABSENCE covers both no-router cases at once: an app that opted out with
434
+ // \`webjs.clientRouter: false\`, and a page that ships no component at all so
435
+ // @webjsdev/core never loads in the browser and the module never runs.
436
+ var refresh = globalThis.__webjsRefreshPage;
437
+ if ((verdict === 'page' || verdict === 'shell') && typeof refresh === 'function') {
438
+ // Take any error overlay down BEFORE re-rendering. DEFENSIVE, and the
439
+ // reasoning is worth recording because the obvious scenario for it does
440
+ // NOT hold. The overlay's own teardown is keyed on the URL CHANGING,
441
+ // which a same-url refresh never does, so a refresh has no exit for a
442
+ // live overlay the way a full reload did (it replaced the document).
443
+ //
444
+ // The scenario that looks like it needs this, a page-render error, is not
445
+ // reachable: the dev 500 page carries no children boundaries and no
446
+ // layout, so applying it shares no boundary with the live page and
447
+ // degrades to a hard navigation, in BOTH directions. Breaking a page
448
+ // reloads, and so does fixing it, and the reload clears the overlay. What
449
+ // is left is the narrow case of an UNSCOPED frame (a \`rebuild\` or
450
+ // \`ts-strip\` failure), which the nav sync deliberately never clears and
451
+ // which can coexist with a page that still renders and so still refreshes.
452
+ //
453
+ // Before rather than after, so the ordering is self-correcting: if this
454
+ // render also fails, the server pushes a fresh frame DURING it and the
455
+ // overlay comes back describing the CURRENT error. Dismissing afterwards
456
+ // would race that push and wipe a legitimately new overlay.
457
+ dismissDevOverlay();
458
+ refresh(verdict).then(function (ok) {
459
+ if (!ok) { location.reload(); return; }
460
+ refreshStyles();
461
+ }, function () { location.reload(); });
462
+ return;
463
+ }
464
+ location.reload();
465
+ });
466
+ }
467
+ function __webjsDirectEvents() {
468
+ // No SharedWorker (Chrome for Android has none) or its construction threw (a
469
+ // strict dev CSP with no worker-src). Run the SAME relay here in the tab over
470
+ // a shim port instead of a second copy of the boot-id rule (#887, #893) and
471
+ // the reload debounce (#1397). The shim scope has no setTimeout, so the relay
472
+ // picks up the tab's own timers.
473
+ const scope = {};
474
+ startReloadWorker(scope, EventSource, ${eventsUrl});
475
+ scope.onconnect({ ports: [{ start() {}, postMessage(m) {
476
+ // Nothing may throw out of here, and nothing may be silently dropped.
477
+ //
478
+ // The relay's fanout deletes a port whose postMessage throws, which is the
479
+ // right read for a REAL MessagePort (a throw there means the tab is gone)
480
+ // and the wrong one for this shim, whose postMessage runs application code
481
+ // synchronously: an overlay render that threw would permanently
482
+ // unsubscribe this tab and silently kill live reload for the rest of the
483
+ // page's life. So the throw is contained here.
484
+ //
485
+ // Contained, NOT swallowed, and the difference matters. The old fallback
486
+ // attached to the EventSource directly, where a handler throw detached
487
+ // nothing but still reached the console, and so does a throw out of the
488
+ // SharedWorker path's onmessage below. Only the DETACHMENT is being
489
+ // prevented; discarding the error would make this the one path where a
490
+ // dev-overlay bug leaves no trace, which would be a new behaviour rather
491
+ // than a restored one.
492
+ try {
493
+ if (m.type === 'reload') __webjsApplyReload(m.verdict);
494
+ else if (m.type === 'webjs-error') __webjsApplyError(m.data);
495
+ } catch (_) {
496
+ console.error('[webjs] dev reload handler threw', _);
497
+ }
498
+ } }] });
499
+ }
500
+ try {
501
+ if (typeof SharedWorker !== 'undefined') {
502
+ const w = new SharedWorker(${workerUrl});
503
+ w.port.onmessage = (e) => {
504
+ const m = e.data || {};
505
+ if (m.type === 'reload') __webjsApplyReload(m.verdict);
506
+ else if (m.type === 'webjs-error') __webjsApplyError(m.data);
507
+ };
508
+ w.port.start();
509
+ } else {
510
+ __webjsDirectEvents();
511
+ }
512
+ } catch (_) {
513
+ __webjsDirectEvents();
514
+ }
515
+ `;
516
+ }
517
+
518
+ /**
519
+ * The dev live-reload SharedWorker. One instance is shared across every tab of
520
+ * the same origin (a SharedWorker is keyed by its script URL), so it holds the
521
+ * ONE `EventSource` to `/__webjs/events` and fans each event out to all tabs
522
+ * over their `MessagePort`s (#887). The `EventSource` URL carries the base path
523
+ * the same way the client's does (#256). Served only in dev.
524
+ * @param {string} bp the normalized base path (`''` = no-op)
525
+ * @returns {string}
526
+ */
527
+ export function reloadWorkerJs(bp) {
528
+ return `// webjs dev reload worker (one shared connection for all tabs)
529
+ ${RELOAD_WORKER_SRC}
530
+ startReloadWorker(self, EventSource, ${JSON.stringify(withBasePath('/__webjs/events', bp))});
531
+ `;
532
+ }
533
+
534
+ /**
535
+ * Whether a path exists, as a boolean rather than a throw.
536
+ *
537
+ * @param {string} p
538
+ * @returns {Promise<boolean>}
539
+ */
540
+ export async function exists(p) {
541
+ try { await stat(p); return true; } catch { return false; }
542
+ }