@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,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
|
+
}
|