@webjsdev/server 0.8.63 → 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 (47) hide show
  1. package/index.d.ts +7 -2
  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/router.js +11 -1
  30. package/src/ssr/client-router-flag.js +42 -0
  31. package/src/ssr/document.js +361 -0
  32. package/src/ssr/env-shim.js +51 -0
  33. package/src/ssr/escape.js +29 -0
  34. package/src/ssr/head.js +857 -0
  35. package/src/ssr/preloads.js +195 -0
  36. package/src/ssr/render.js +1481 -0
  37. package/src/ssr/responses.js +201 -0
  38. package/src/ssr.js +28 -2445
  39. package/src/vendor/audit.js +400 -0
  40. package/src/vendor/integrity.js +245 -0
  41. package/src/vendor/jspm.js +348 -0
  42. package/src/vendor/manifest.js +96 -0
  43. package/src/vendor/pins.js +892 -0
  44. package/src/vendor/providers.js +35 -0
  45. package/src/vendor/resolver.js +225 -0
  46. package/src/vendor/scanner.js +132 -0
  47. package/src/vendor.js +29 -2371
package/src/dev.js CHANGED
@@ -1,3078 +1,23 @@
1
- import { createServer as createHttp1Server } from 'node:http';
2
- import { stat, readFile, watch as fsWatch } from 'node:fs/promises';
3
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
4
- import { createHash } from 'node:crypto';
5
- import { join, extname, resolve, dirname, relative, sep } from 'node:path';
6
- import { createRequire } from 'node:module';
7
- import { fileURLToPath, pathToFileURL } from 'node:url';
8
-
9
- // Server-side `.ts` imports are handled natively by Node 24+'s default
10
- // type-stripping (`process.features.typescript === 'strip'`) or by Bun. The
11
- // BROWSER-bound `.ts` request handler erases types via the pluggable stripper in
12
- // `./ts-strip.js` (Node's built-in `module.stripTypeScriptTypes`, or `amaro` on
13
- // Bun), so SSR and hydration produce identical JS on either runtime (#508).
14
-
15
- import { buildRouteTable, matchPage, matchApi } from './router.js';
16
- import { generateRouteTypes } from './route-types.js';
17
- import { ssrPage, ssrNotFound, setClientRouterEnabled, setMetadataIconRoutes } from './ssr.js';
18
- import { runFormAction, reportFormSubmittedAsGet } from './form-dispatch.js';
19
- import { handleApi } from './api.js';
20
- import {
21
- buildActionIndex,
22
- serveActionStub,
23
- serveServerOnlyStub,
24
- invokeAction,
25
- isServerFile,
26
- hasUseServerDirective,
27
- hashFile,
28
- } from './actions.js';
29
- import { registerActionHooks } from './action-seed.js';
30
- import { readRegenerateRules, maybeRegenerate, isRegenerateOutputPath } from './dev-regenerate.js';
31
- import { stripTypeScript, ensureStripper } from './ts-strip.js';
32
- import { defaultLogger } from './logger.js';
33
- import { assertNodeVersion } from './node-version.js';
34
- import { applyEnvValidation } from './env-schema.js';
35
- import { runInstrumentation, findInstrumentationClient } from './instrumentation.js';
36
- import { withRequest, setCspNonce, setBodyLimits, setRequestId, requestId as getRequestId } from './context.js';
37
- import { buildInfoResponse } from './build-info.js';
38
- import { readCspConfig, mintNonce, buildCspHeader, cspHeaderName } from './csp.js';
39
- import { attachWebSocket } from './websocket.js';
40
- import {
41
- SseHub,
42
- serverRuntime,
43
- isCompressible,
44
- installProcessHandlers,
45
- makeShutdown,
46
- negotiateEncoding,
47
- createCompressor,
48
- varyWithAcceptEncoding,
49
- DEV_BOOT_ID,
50
- } from './listener-core.js';
51
- import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
52
- import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport, appImportsMap, seenFilesFor } from './module-graph.js';
53
- import { primeComponentRegistry, findOrphanComponents, scanComponents } from './component-scanner.js';
54
- import { analyzeElision, elideImportsFromSource } from './component-elision.js';
55
-
56
- /** PascalCase → kebab-case for a helpful diagnostic example tag name. */
57
- function kebab(name) {
58
- return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
59
- }
60
-
61
- /**
62
- * Per-request correlation id (issue #239). Honor an inbound `X-Request-Id`
63
- * from a trusted upstream proxy so a trace id propagates across services; mint
64
- * a fresh `crypto.randomUUID()` otherwise. The inbound value is length-capped
65
- * and validated against a conservative token charset so a hostile client
66
- * cannot inject control chars / a header-splitting payload (the value is
67
- * echoed back in the `X-Request-Id` response header). On any mismatch we fall
68
- * back to a minted id rather than trust the junk.
69
- *
70
- * @param {Request} req
71
- * @returns {string}
72
- */
73
- function resolveRequestId(req) {
74
- const inbound = req.headers.get('x-request-id');
75
- if (inbound && inbound.length <= 200 && /^[A-Za-z0-9._-]+$/.test(inbound)) return inbound;
76
- return crypto.randomUUID();
77
- }
78
-
79
- /**
80
- * Whether a path should be access-logged (issue #239). The framework's own
81
- * `/__webjs/*` probes, static runtime assets, and the dev SSE reload stream
82
- * are high-frequency infrastructure traffic, not app requests, so logging them
83
- * would just spam the access log. App routes (including app-authored
84
- * `/api/*`) are logged.
85
- *
86
- * @param {string} pathname
87
- * @returns {boolean}
88
- */
89
- function shouldAccessLog(pathname) {
90
- return !pathname.startsWith('/__webjs/');
91
- }
92
- import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from './importmap.js';
93
- import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
94
- import { validateAppWebjsConfig } from './webjs-config-validate.js';
95
- import { propagateTrustedRemoteIp } from './rate-limit.js';
96
- import { readAllowedOrigins } from './csrf.js';
97
- import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
98
- import { resolveActionIdentity } from './form-action-identity.js';
99
- import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports, resolveAssetUrl } from './asset-hash.js';
100
- import { urlFromRequest } from './forwarded.js';
101
- import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
102
- import {
103
- compileRedirectRules,
104
- applyRedirects,
105
- readTrailingSlashPolicy,
106
- applyTrailingSlash,
107
- } from './redirects.js';
108
- import { readBodyLimits, computeServerTimeouts } from './body-limit.js';
109
- import { applyConditionalGet, BUFFERED_MARKER } from './conditional-get.js';
110
- import { commitHtmlCache, setAppSourceFingerprint } from './html-cache.js';
111
- import { buildDevErrorFrame } from './dev-error.js';
112
-
113
- const MIME = {
114
- '.html': 'text/html; charset=utf-8',
115
- '.js': 'application/javascript; charset=utf-8',
116
- '.mjs': 'application/javascript; charset=utf-8',
117
- '.ts': 'application/javascript; charset=utf-8',
118
- '.mts': 'application/javascript; charset=utf-8',
119
- '.css': 'text/css; charset=utf-8',
120
- '.json': 'application/json; charset=utf-8',
121
- '.svg': 'image/svg+xml',
122
- '.png': 'image/png',
123
- '.jpg': 'image/jpeg',
124
- '.jpeg': 'image/jpeg',
125
- '.gif': 'image/gif',
126
- '.webp': 'image/webp',
127
- '.ico': 'image/x-icon',
128
- '.txt': 'text/plain; charset=utf-8',
129
- };
130
-
131
- /**
132
- * Cache of stripped `.ts` / `.mts` source.
133
- * Keyed by absolute file path. Entries expire when mtime changes.
134
- * Capped at 500 entries to prevent unbounded memory growth in
135
- * long-running production servers.
136
- *
137
- * Stripper: `module.stripTypeScriptTypes` (Node 24+ built-in).
138
- * Position-preserving whitespace replacement. No sourcemap is
139
- * emitted because every (line, column) maps to itself in the source.
140
- *
141
- * Only erasable TypeScript is supported. Non-erasable syntax (`enum`,
142
- * `namespace` with values, parameter properties, legacy decorators
143
- * with `emitDecoratorMetadata`, `import = require`) throws at strip
144
- * time. The `erasable-typescript-only` and `no-non-erasable-typescript`
145
- * lint rules catch these at edit time. WebJs is buildless end-to-end:
146
- * there is no bundler fallback.
147
- *
148
- * The transformed bytes are cached per request handler in `state.tsCache`
149
- * (a `Map<string, { mtimeMs, code, map }>`), bounded to `TS_CACHE_MAX`
150
- * entries. The cache is per-handler rather than module-global because the
151
- * cached code bakes in that handler's elision verdict, so two handlers for
152
- * the same app with different elision settings must not share it.
153
- */
154
- const TS_CACHE_MAX = 500;
155
-
156
- /**
157
- * Auto-load `<appDir>/.env` into `process.env` once at boot. Mirrors
158
- * what Rails / Next / Astro do out of the box: a scaffolded app with
159
- * a committed `.env.example` and a developer-copied `.env` should
160
- * "just work" without the user having to add a dotenv import or set
161
- * the file path on the CLI.
162
- *
163
- * Uses Node 24+'s built-in `process.loadEnvFile`, which is dotenv-
164
- * compatible and DOES NOT override pre-existing `process.env` values.
165
- * Calls that hit a missing file or parse error are silenced; the
166
- * server should still come up cleanly when there's no `.env`.
167
- *
168
- * Idempotent: re-running is a no-op for any env var the user already
169
- * exported (e.g. via the host shell or a process manager). That
170
- * keeps the "shell-set wins over file" precedence Rails users
171
- * expect.
172
- *
173
- * Must run before any server-only module is loaded by
174
- * buildActionIndex, since module-init code in `lib/*.server.ts`
175
- * (e.g. `createAuth({ secret: process.env.AUTH_SECRET })`) reads
176
- * process.env at import time. createRequestHandler is the
177
- * single entry point where this is guaranteed.
178
- *
179
- * @param {string} appDir
180
- */
181
- function loadAppEnv(appDir) {
182
- try {
183
- if (typeof process.loadEnvFile === 'function') {
184
- process.loadEnvFile(join(appDir, '.env'));
185
- }
186
- } catch {
187
- // No .env file, malformed file, or Node version without
188
- // loadEnvFile. Either way, fall through silently: the user
189
- // may not need any env vars, or they may set them via shell.
190
- }
191
- }
192
-
193
- /**
194
- * Read the `WEBJS_ELIDE` environment override, if set.
195
- * `0` / `false` / `off` / `no` (case-insensitive) force elision OFF;
196
- * `1` / `true` / `on` / `yes` force it ON. Any other value, or an unset
197
- * variable, returns `undefined` so the caller falls through to the
198
- * `package.json` switch. The env override is the deploy-time / ops escape
199
- * hatch: force-disable elision to rule it out while debugging a wrong-strip
200
- * without editing committed code, or force-enable it regardless of an
201
- * app's `package.json`. It is also the seam the differential elision test
202
- * uses to render the same app on and off in one process.
203
- * @returns {boolean | undefined}
204
- */
205
- function elideEnvOverride() {
206
- const raw = process.env.WEBJS_ELIDE;
207
- if (raw == null || raw === '') return undefined;
208
- const v = String(raw).trim().toLowerCase();
209
- if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
210
- if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
211
- return undefined;
212
- }
213
-
214
- /**
215
- * Read the project-level elision switch.
216
- * Precedence: the `WEBJS_ELIDE` env override wins when set, otherwise the
217
- * `package.json` `{ "webjs": { "elide": false } }` switch disables
218
- * display-only and inert-route elision app-wide (everything ships, like
219
- * before the feature existed). Any other value, or an absent key, leaves
220
- * elision enabled (the default). Re-read on every rebuild so toggling
221
- * either control takes effect without a server restart.
222
- * @param {string} appDir
223
- * @returns {Promise<boolean>}
224
- */
225
- export async function readElideEnabled(appDir) {
226
- const override = elideEnvOverride();
227
- if (override !== undefined) return override;
228
- try {
229
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
230
- if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
231
- } catch {
232
- // No package.json, malformed JSON, or unreadable. Keep the default.
233
- }
234
- return true;
235
- }
236
-
237
- /**
238
- * Read the `WEBJS_SEED` environment override, if set. Same grammar as
239
- * `WEBJS_ELIDE`: `0`/`false`/`off`/`no` force seeding OFF, `1`/`true`/`on`/`yes`
240
- * force it ON, anything else (or unset) falls through to the package.json
241
- * switch. The deploy-time / ops escape hatch and the seam tests use to render
242
- * an app with seeding on and off in one process.
243
- * @returns {boolean | undefined}
244
- */
245
- function seedEnvOverride() {
246
- const raw = process.env.WEBJS_SEED;
247
- if (raw == null || raw === '') return undefined;
248
- const v = String(raw).trim().toLowerCase();
249
- if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
250
- if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
251
- return undefined;
252
- }
253
-
254
- /**
255
- * Read the project-level SSR action-seeding switch (#472). Precedence: the
256
- * `WEBJS_SEED` env override wins when set, otherwise the package.json
257
- * `{ "webjs": { "seed": false } }` switch disables it (the client re-fetches on
258
- * hydration as it did before the feature). Default ON (opt-out), mirroring
259
- * elision: any value other than the literal `false` keeps seeding enabled.
260
- *
261
- * Seeding installs a process-global `module.registerHooks` load hook, so unlike
262
- * elision it is read ONCE at boot (not re-read per rebuild): toggling it needs a
263
- * restart, like a deploy.
264
- * @param {string} appDir
265
- * @returns {Promise<boolean>}
266
- */
267
- export async function readSeedEnabled(appDir) {
268
- const override = seedEnvOverride();
269
- if (override !== undefined) return override;
270
- try {
271
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
272
- if (pkg && pkg.webjs && pkg.webjs.seed === false) return false;
273
- } catch {
274
- // No package.json, malformed JSON, or unreadable. Keep the default.
275
- }
276
- return true;
277
- }
278
-
279
- /**
280
- * Read the client-router switch (`webjs.clientRouter`) from the app's
281
- * package.json (#629). Default `true`: the client router auto-enables in the
282
- * browser whenever `@webjsdev/core` loads (the automatic-navigation thesis).
283
- * `{ "webjs": { "clientRouter": false } }` opts the WHOLE app out (pure MPA,
284
- * full-page navigation), which the render path signals to the browser so the
285
- * core bundle's module-end auto-enable is skipped. Any other value, or an
286
- * absent key, keeps the router on. Re-read on every rebuild (alongside elide)
287
- * so toggling it takes effect without a server restart.
288
- * @param {string} appDir
289
- * @returns {Promise<boolean>}
290
- */
291
- export async function readClientRouterEnabled(appDir) {
292
- try {
293
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
294
- if (pkg && pkg.webjs && pkg.webjs.clientRouter === false) return false;
295
- } catch {
296
- // No package.json, malformed JSON, or unreadable. Keep the default.
297
- }
298
- return true;
299
- }
300
-
301
- /**
302
- * Read the per-path response-header config (`webjs.headers`) from the
303
- * app's package.json and compile it to URLPattern rules. A missing,
304
- * malformed, or unreadable config yields an empty rule set (the secure
305
- * defaults still apply), never a throw.
306
- *
307
- * @param {string} appDir
308
- * @returns {Promise<ReturnType<typeof compileHeaderRules>>}
309
- */
310
- export async function readHeaderRules(appDir) {
311
- try {
312
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
313
- return compileHeaderRules(pkg);
314
- } catch {
315
- return [];
316
- }
317
- }
318
-
319
- /**
320
- * Read the declarative redirect config (`webjs.redirects`) from the app's
321
- * package.json and compile it to URLPattern rules (issue #254). A missing,
322
- * malformed, or unreadable config yields an empty rule set (no redirects),
323
- * never a throw. Patterns are compiled ONCE here at boot, not per request.
324
- *
325
- * @param {string} appDir
326
- * @returns {Promise<ReturnType<typeof compileRedirectRules>>}
327
- */
328
- export async function readRedirectRules(appDir) {
329
- try {
330
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
331
- return compileRedirectRules(pkg);
332
- } catch {
333
- return [];
334
- }
335
- }
336
-
337
- /**
338
- * Read the trailing-slash policy (`webjs.trailingSlash`) from the app's
339
- * package.json (issue #255). A missing, malformed, or unreadable config
340
- * yields `'ignore'` (no canonicalization), never a throw, so an
341
- * unconfigured app is unchanged.
342
- *
343
- * @param {string} appDir
344
- * @returns {Promise<'never' | 'always' | 'ignore'>}
345
- */
346
- export async function readTrailingSlashFromApp(appDir) {
347
- try {
348
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
349
- return readTrailingSlashPolicy(pkg);
350
- } catch {
351
- return 'ignore';
352
- }
353
- }
354
-
355
- /**
356
- * Read the sub-path base path (`webjs.basePath`) from the app's
357
- * package.json (issue #256). A missing, malformed, or unreadable config
358
- * yields `''` (root mount), never a throw, so an unconfigured app is
359
- * byte-identical to before this feature. Normalized to `''` or
360
- * `/segment[/segment...]` by `readBasePath`.
361
- *
362
- * @param {string} appDir
363
- * @returns {Promise<string>}
364
- */
365
- export async function readBasePathFromApp(appDir) {
366
- try {
367
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
368
- return readBasePath(pkg);
369
- } catch {
370
- return '';
371
- }
372
- }
373
-
374
- /**
375
- * Read the app package.json and warn once about anything wrong with its `webjs`
376
- * block (#1300). One aggregated warning, listing every problem, so a config with
377
- * three typos does not produce three log lines.
378
- *
379
- * Never throws and never exits. See `webjs-config-validate.js` for why warning
380
- * is the ruling and why this does not replace the CLI's `doctor.gate` check.
381
- *
382
- * @param {string} appDir
383
- * @param {{ warn?: (...args: any[]) => void }} logger
384
- * @returns {Promise<void>}
385
- */
386
- export async function warnOnInvalidWebjsConfig(appDir, logger) {
387
- let problems;
388
- try {
389
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
390
- problems = validateAppWebjsConfig(pkg);
391
- } catch {
392
- return;
393
- }
394
- if (!problems.length) return;
395
- logger?.warn?.(
396
- `[webjs] the "webjs" block in package.json has ${problems.length} problem(s), ` +
397
- `each ignored at its default: ${problems.join('; ')}. ` +
398
- `See https://webjs.dev/docs/configuration`,
399
- );
400
- }
401
-
402
- /**
403
- * Read the cross-origin allowlist (`webjs.allowedOrigins`) from the app's
404
- * package.json. These hosts / origins are accepted by the action CSRF check
405
- * even when cross-site (reverse-proxy / multi-domain setups). A missing or
406
- * unreadable config yields `[]`.
407
- *
408
- * @param {string} appDir
409
- * @returns {Promise<string[]>}
410
- */
411
- export async function readAllowedOriginsFromApp(appDir) {
412
- try {
413
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
414
- return readAllowedOrigins(pkg);
415
- } catch {
416
- return [];
417
- }
418
- }
419
-
420
- /**
421
- * Read the CSP config (`webjs.csp`) from the app's package.json and
422
- * normalize it (issue #233). A missing, malformed, or unreadable config
423
- * yields a disabled config (no nonce minted, no CSP header), never a
424
- * throw: a broken security knob must fail closed, not take the app down.
425
- *
426
- * @param {string} appDir
427
- * @returns {Promise<ReturnType<typeof readCspConfig>>}
428
- */
429
- export async function readCspConfigFromApp(appDir) {
430
- try {
431
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
432
- return readCspConfig(pkg);
433
- } catch {
434
- return readCspConfig(undefined);
435
- }
436
- }
437
-
438
- /**
439
- * Resolve the request body-size limits (issue #237) from the app's package.json
440
- * `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env overrides
441
- * (`WEBJS_MAX_BODY_BYTES` / `WEBJS_MAX_MULTIPART_BYTES`). A missing or
442
- * unreadable package.json falls through to the secure defaults (env still wins),
443
- * never a throw.
444
- *
445
- * @param {string} appDir
446
- * @returns {Promise<{ json: number, multipart: number }>}
447
- */
448
- export async function readBodyLimitsFromApp(appDir) {
449
- let pkg;
450
- try {
451
- pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
452
- } catch {
453
- pkg = undefined;
454
- }
455
- return readBodyLimits(pkg);
456
- }
457
-
458
- /**
459
- * Extra dev watch roots from the app's package.json `webjs.dev.watch` (#894).
460
- * These are directories the app READS from but that live OUTSIDE its appDir, so
461
- * the recursive `fs.watch(appDir)` never sees them (e.g. the website renders
462
- * posts from a repo-root `blog/` dir, a sibling of the app, so editing a post
463
- * would not live-reload). Each entry is resolved relative to the appDir and MAY
464
- * escape it (`"../blog"`); a change under one runs the same rebuild + reload as
465
- * an in-tree edit. Opt-in: a missing/empty config yields `[]`, so a plain app
466
- * watches only its appDir, unchanged.
467
- *
468
- * Returns absolute, de-duped paths, skipping any that overlap the appDir (the
469
- * appDir is already watched recursively, so an ancestor or descendant root
470
- * would just double-fire the rebuild). Existence is NOT checked here (the caller
471
- * filters missing paths so it can log them); the reader stays a pure
472
- * package.json read, matching the other `readXFromApp` helpers.
473
- *
474
- * @param {string} appDir
475
- * @returns {Promise<string[]>}
476
- */
477
- export async function readDevWatchPathsFromApp(appDir) {
478
- let pkg;
479
- try {
480
- pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
481
- } catch {
482
- return [];
483
- }
484
- const raw = pkg && pkg.webjs && pkg.webjs.dev && pkg.webjs.dev.watch;
485
- if (!Array.isArray(raw)) return [];
486
- /** @type {string[]} */
487
- const out = [];
488
- const seen = new Set();
489
- for (const entry of raw) {
490
- if (typeof entry !== 'string' || !entry.trim()) continue;
491
- const abs = resolve(appDir, entry);
492
- // Skip the appDir itself and any ancestor/descendant overlap: the appDir is
493
- // already watched recursively, so an overlapping extra root double-fires.
494
- if (abs === appDir || appDir.startsWith(abs + sep) || abs.startsWith(appDir + sep)) continue;
495
- if (seen.has(abs)) continue;
496
- seen.add(abs);
497
- out.push(abs);
498
- }
499
- return out;
500
- }
501
-
502
- /**
503
- * Resolve the node:http server timeouts (issue #237) from the app's
504
- * package.json `webjs.requestTimeoutMs` / `webjs.headersTimeoutMs` /
505
- * `webjs.keepAliveTimeoutMs` plus the env overrides. A missing or unreadable
506
- * package.json falls through to the secure defaults (env still wins).
507
- *
508
- * @param {string} appDir
509
- * @returns {Promise<{ requestTimeout: number, headersTimeout: number, keepAliveTimeout: number }>}
510
- */
511
- export async function readServerTimeoutsFromApp(appDir) {
512
- let pkg;
513
- try {
514
- pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
515
- } catch {
516
- pkg = undefined;
517
- }
518
- return computeServerTimeouts(pkg);
519
- }
520
-
521
- /**
522
- * Create a reusable, framework-agnostic request handler for a WebJs app.
523
- * The returned `handle(req)` takes a standard `Request` and resolves to a
524
- * standard `Response`: suitable for Node http, Deno, Bun, Cloudflare Workers,
525
- * or embedding inside an Express/Fastify app.
526
- *
527
- * @param {{
528
- * appDir: string,
529
- * dev?: boolean,
530
- * logger?: import('./logger.js').Logger,
531
- * onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
532
- * onReload?: () => void,
533
- * onDevError?: (frame: object) => void,
534
- * }} opts
535
- */
536
- export async function createRequestHandler(opts) {
537
- // Preflight: WebJs needs Node 24+ (built-in TS strip + recursive fs.watch).
538
- // Throw a clear Error here so an embedded host (Express/Fastify/Bun/Deno)
539
- // gets the actionable message at boot, not a cryptic API failure mid-request.
540
- assertNodeVersion({ onFail: 'throw' });
541
- // Resolve the TS stripper backend at boot (#508): the Node built-in, or amaro
542
- // on Bun. Doing it here pays the (one-time) amaro import up front and surfaces
543
- // a missing-amaro error at boot rather than on the first `.ts` request.
544
- await ensureStripper();
545
- const appDir = resolve(opts.appDir);
546
- // Load <appDir>/.env into process.env BEFORE anything else.
547
- // buildActionIndex below imports server-only files (lib/*.server.ts,
548
- // modules/**/*.server.ts), some of which read process.env at module
549
- // init (e.g. createAuth reads AUTH_SECRET). Without this call,
550
- // scaffolded apps with a committed .env.example + .env would fail
551
- // to boot until the user discovered the missing env-load. See
552
- // tracker #37.
553
- loadAppEnv(appDir);
554
- // Optional boot-time env validation (#236). If <appDir>/env.{js,ts} exists it
555
- // default-exports a typed schema or a custom validator function; we run it
556
- // against process.env (now populated by loadAppEnv) BEFORE buildActionIndex
557
- // imports any server-only module. A failure throws a clear aggregated Error
558
- // here, so an embedded host rejects at boot and the CLI exits non-zero,
559
- // failing fast instead of crashing cryptically mid-request. Absent file is a
560
- // no-op (opt-in). Coerced + defaulted values are written back to process.env.
561
- await applyEnvValidation(appDir, { dev: !!opts.dev });
562
- const dev = !!opts.dev;
563
- const logger = opts.logger || defaultLogger({ dev });
564
- // Boot-time `webjs` config validation (#1300). The published JSON Schema used
565
- // to reach users only through the scaffold's .vscode `$ref`, so a typo'd key
566
- // was caught in an editor and nowhere else: the key was dropped, the feature
567
- // stayed at its default, and nothing said so. This runs it once per boot, from
568
- // the one entry point dev, prod, and an embedded host all share.
569
- //
570
- // It WARNS and continues, never throws. A typo costs one feature sitting at
571
- // its default; a hard boot failure over a schema quibble costs the whole app.
572
- // A missing / unreadable / unparseable package.json is a silent no-op, like
573
- // every other `webjs.*` reader in this file.
574
- await warnOnInvalidWebjsConfig(appDir, logger);
575
- // Boot-time instrumentation hook (#848): run the optional app-root
576
- // instrumentation.{js,ts} register() ONCE, after env validation and before
577
- // the route table / action index are built, so observability plumbing starts
578
- // before traffic. Any error sink it registers via setOnError composes with
579
- // the programmatic opts.onError (both fire). Fail-open (a throwing hook is
580
- // logged, not fatal).
581
- const { onError: instrumentationOnError } = await runInstrumentation(appDir, { dev, logger });
582
- // APM / Sentry integration point (issue #239). Called whenever the request
583
- // pipeline catches an unhandled error: the top-level handle() catch (the
584
- // last-resort 500), an unexpected throw inside the produce() funnel, or a
585
- // middleware that threw. BEST-EFFORT by contract: a throwing onError is
586
- // caught here so it can never crash the response, and the framework's own
587
- // sanitized 500 / existing error behavior is unchanged (the hook is purely
588
- // additive). The sink receives the error plus the correlation id so it can
589
- // tie the report to the same id the access log and X-Request-Id carry.
590
- // Both the programmatic opts.onError AND any sink an instrumentation.{js,ts}
591
- // register() installed via setOnError fire (they compose, #848).
592
- const onErrorSinks = [opts.onError, instrumentationOnError].filter((f) => typeof f === 'function');
593
- const hasOnError = onErrorSinks.length > 0;
594
- /**
595
- * Invoke every registered onError sink defensively. The phase is a coarse
596
- * label of where the pipeline caught the error, for the sink's own grouping.
597
- * @param {unknown} error
598
- * @param {Request} request
599
- * @param {string} phase
600
- */
601
- function reportError(error, request, phase) {
602
- if (!hasOnError) return;
603
- const ctx = { request, requestId: getRequestId(), phase };
604
- for (const sink of onErrorSinks) {
605
- try {
606
- sink(error, ctx);
607
- } catch (e) {
608
- logger.error?.('[webjs] onError hook threw (ignored)', { err: String(e) });
609
- }
610
- }
611
- }
612
- // Sub-path deployment base path (issue #256), read once from the app's
613
- // package.json `webjs.basePath` and bound into the importmap builder BEFORE
614
- // setCoreInstall / setVendorEntries so every importmap target (and the
615
- // recomputed hash) reflects the prefix. Empty (the default) makes both the
616
- // ingress strip and the emit-side prefix pure no-ops, so an unconfigured app
617
- // is byte-identical to before this feature.
618
- const basePathValue = await readBasePathFromApp(appDir);
619
- await setBasePath(basePathValue);
620
-
621
- // Cross-origin allowlist for the action CSRF check (#659), read once.
622
- const allowedOriginsValue = await readAllowedOriginsFromApp(appDir);
623
-
624
- // Client-router opt-out (#629): bind it eagerly at handler construction (the
625
- // same timing as setBasePath above), so a handler's module-global is set
626
- // before any render even if a request arrives before the first warm. The
627
- // analysis pass re-reads it each rebuild so a dev toggle takes effect live.
628
- setClientRouterEnabled(await readClientRouterEnabled(appDir));
629
-
630
- const coreDir = locateCoreDir(appDir);
631
- // Switch the importmap between dist/ bundles and src/ per-file
632
- // URLs depending on whether the resolved @webjsdev/core install
633
- // has built bundles on disk. npm-installed copies always do;
634
- // workspace dev does only after `npm run build:dist`. Without
635
- // a built dist the server falls back to the historical per-file
636
- // src/ URLs so dev iteration does not require a build step.
637
- //
638
- // Both required bundles must exist. An older @webjsdev/core
639
- // install built BEFORE the browser-entry split (#119/#128) has
640
- // `webjs-core.js` but no `webjs-core-browser.js`. Enabling dist
641
- // mode in that case would route the bare `@webjsdev/core`
642
- // specifier at a 404 on every page. Require both so a partial
643
- // dist transparently degrades to src/ mode instead.
644
- const distDir = join(coreDir, 'dist');
645
- const distComplete =
646
- existsSync(join(distDir, 'webjs-core.js')) &&
647
- existsSync(join(distDir, 'webjs-core-browser.js'));
648
- await setCoreInstall(coreDir, distComplete);
649
-
650
- // Path-alias imports (#555). Emit the browser importmap scopes for the app's
651
- // `package.json "imports"` aliases, derived from the SAME map the server
652
- // resolver (`expandImportAlias`) reads, so SSR and the browser agree. The
653
- // scaffold ships the single catch-all `"#*": "./*"` (one key, zero
654
- // maintenance, Bun-safe), which expands to one browser prefix scope per
655
- // top-level dir (`#lib/` -> `/lib/`, ...); the dir scan is here so a new
656
- // folder is picked up on boot. An app with no `"imports"` block yields {}
657
- // (a no-op, byte-identical to before this feature).
658
- await setImportAliasEntries(importAliasBrowserEntries(appImportsMap(appDir), appTopLevelDirs(appDir)));
659
-
660
- // Content-hash asset URLs for immutable caching (issue #243). Bind the
661
- // asset-hash module to the app + core roots and enable fingerprinting in
662
- // PROD only. In dev `withAssetHash` stays a pure no-op (never enabled), so
663
- // the dev importmap / boot / preload output is byte-identical to before.
664
- // This runs AFTER setBasePath / setCoreInstall, but those recompute the
665
- // importmap hash with `{ fingerprint: false }`, so the boot-published build
666
- // id is a stable deploy fingerprint independent of per-file content hashes.
667
- setAssetRoots({ appDir, coreDir, enabled: !dev });
668
-
669
- // Install the resolver behind the isomorphic `asset()` helper (#1194), the
670
- // same provider seam `cspNonce()` uses. An author writes
671
- // `href=${asset('/public/app.css')}` and gets the fingerprinted url on the
672
- // server; the browser has no provider and returns the path unchanged.
673
- //
674
- // The `public/` gate is defensive rather than decorative. `resolveUrlToFile`
675
- // maps any same-origin path under the PROJECT ROOT, so an app that passed
676
- // request- or user-derived data here (`asset(user.avatarPath)`) would
677
- // otherwise read and publish a content hash for `/.env`, `/db/app.db`, or a
678
- // `.server.ts`, leaking both existence and a fingerprint of the bytes for
679
- // files the serve path deliberately 404s. Mirror the static-asset route:
680
- // under `public/`, no traversal. Everything else returns unchanged, having
681
- // never touched the disk.
682
- setAssetUrlProvider((p) => resolveAssetUrl(p, basePath()));
683
-
684
- // The `'use server'` load hook. Install it NOW, at boot, before any action
685
- // module is imported (ESM caches by URL, so a module loaded before the hook
686
- // would never be wrapped). Read once (not per-rebuild): the hook is global
687
- // and cannot be cleanly un-installed, so toggling needs a restart.
688
- //
689
- // It installs UNCONDITIONALLY, and only seed collection (#472) follows the
690
- // `webjs.seed` switch. Action identity (#1155) rides the same hook, and it is
691
- // what a bound `<form action=${action}>` resolves through, so gating the hook
692
- // on seeding would mean `webjs.seed: false` silently broke every no-JS form.
693
- await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
694
-
695
-
696
- // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
697
- // deterministic vendor map that is cheap to read (one file, no analysis, no
698
- // network). Resolve it AT BOOT and publish the build id immediately so the
699
- // process advertises a stable, non-empty id from its very first response: a
700
- // freshly-deployed pinned process is detected as a new deploy by old-deploy
701
- // clients with zero warmup window. Mirrors Rails importmap (committed pins
702
- // rendered deterministically at runtime). Pinning stays optional; an unpinned
703
- // app does no vendor work at boot and publishes its id after the first
704
- // successful resolve instead. Either way the EXPENSIVE analysis (graph, scan,
705
- // gate, elision) and the UNPINNED jspm resolve stay deferred to the first
706
- // request, so #143's win is intact; only the cheap committed-file read moves
707
- // back to boot, and only when a VALID pin exists. A committed pin file is
708
- // served as-is (elision never prunes it), so the boot-resolved map equals the
709
- // final served map and the published id is authoritative.
710
- //
711
- // Validate the pin with readPinFile BEFORE treating the app as pinned-at-boot.
712
- // hasVendorPin is a cheap existence check; a malformed pin (exists but
713
- // unparseable) must NOT short-circuit here, because resolveVendorImports would
714
- // then fall through to its bare-import scan thunk, and the boot-time thunk is
715
- // empty (the real scan is part of the deferred analysis). A broken pin instead
716
- // falls through to the normal deferred resolve, which carries the real scan
717
- // thunk and degrades gracefully, exactly as an unpinned app does.
718
- let bootVendorPinned = false;
719
- if (hasVendorPin(appDir) && (await readPinFile(appDir))) {
720
- try {
721
- const v = await resolveVendorImports(appDir, () => new Set());
722
- await setVendorEntries(v.imports, v.integrity);
723
- publishBuildId();
724
- bootVendorPinned = true;
725
- } catch (e) {
726
- // An unexpected failure applying a VALID pin (e.g. setVendorEntries
727
- // throwing) is non-fatal: leave bootVendorPinned false so the deferred
728
- // resolve re-attempts on the first request. Boot stays resilient.
729
- logger.error?.(`[webjs] applying the committed vendor pin at boot failed (will retry on the first request):`, e);
730
- }
731
- }
732
-
733
- // Whole-app analysis (module graph, component scan, browser-bound gate,
734
- // action index, middleware, elision, vendor) is NOT run at boot. It is
735
- // computed on the first request via ensureReady() below and memoized, so the
736
- // server starts without walking or reading the app's source, executing any
737
- // server module, or hitting the network. Only the route table is built
738
- // eagerly: it is a cheap directory scan (no code reads), and routing, Early
739
- // Hints, and WebSocket lookups need it available before the first request.
740
- const routeTable = await buildRouteTable(appDir);
741
- // instrumentation-client.{js,ts} (#848) is an app-ROOT file (sibling of app/,
742
- // like env.js / readiness.js), not a router stem. Resolve it once and stash it
743
- // on the route table so ssrOpts + the browser-servable gate reach it uniformly.
744
- routeTable.instrumentationClient = await findInstrumentationClient(appDir);
745
- // Auto-linked favicons: tell the head builder which icon metadata routes
746
- // exist, so `app/icon.*` is linked when the app declares no metadata.icons.
747
- // Bound here rather than threaded through ssrOpts, matching
748
- // setClientRouterEnabled; re-bound in doRebuild so adding or deleting the
749
- // file takes effect without a restart.
750
- setMetadataIconRoutes(routeTable.metadataRoutes);
751
-
752
- // Emit `.webjs/routes.d.ts` (typed Route union + per-route params, #258) in
753
- // dev so an editor's tsserver always has up-to-date route types without the
754
- // developer remembering to run `webjs types`. Best-effort and fire-and-
755
- // forget: a failure logs and never blocks boot. Re-emitted after each route
756
- // rebuild (see doRebuild) so adding/removing a route refreshes the types.
757
- /** @returns {Promise<void>} */
758
- async function emitRouteTypes() {
759
- try {
760
- const { mkdir, writeFile, rename } = await import('node:fs/promises');
761
- const text = await generateRouteTypes(appDir);
762
- const outDir = join(appDir, '.webjs');
763
- await mkdir(outDir, { recursive: true });
764
- // Write to a temp sibling then rename, so tsserver (which reads this
765
- // file) never observes a half-written body if two rebuilds race. rename
766
- // is atomic within the same dir. Both paths sit under the watcher-ignored
767
- // .webjs/, so neither the temp write nor the rename re-triggers a rebuild.
768
- const dest = join(outDir, 'routes.d.ts');
769
- const tmp = join(outDir, `routes.d.ts.${process.pid}.tmp`);
770
- await writeFile(tmp, text);
771
- await rename(tmp, dest);
772
- } catch (e) {
773
- logger.warn?.(`[webjs] could not write .webjs/routes.d.ts (route types): ${e?.message || e}`);
774
- }
775
- }
776
- if (dev) void emitRouteTypes();
777
-
778
- // Per-path response-header rules (issue #232), read once from the
779
- // app's package.json `webjs.headers`. Static config, so no rebuild
780
- // re-read; the secure defaults need no config and apply regardless.
781
- const headerRules = await readHeaderRules(appDir);
782
-
783
- // Declarative redirect rules (issue #254), read once from the app's
784
- // package.json `webjs.redirects` and compiled to URLPattern rules at
785
- // boot (never per request). Empty when unconfigured, so an app with no
786
- // redirects is unchanged. Applied at the very start of request handling,
787
- // before routing / SSR / asset serving, so a moved URL returns a 308/307
788
- // immediately.
789
- const redirectRules = await readRedirectRules(appDir);
790
-
791
- // Trailing-slash policy (issue #255), read once from the app's package.json
792
- // `webjs.trailingSlash`. Default `'ignore'` (no canonicalization), so an
793
- // unconfigured app is unchanged; `'never'` (recommended) strips a trailing
794
- // slash and `'always'` adds one, each via a 308 to the canonical form.
795
- // Applied in produce() AFTER the declarative redirects, so an explicit
796
- // redirect rule wins first and the two never loop.
797
- const trailingSlashPolicy = await readTrailingSlashFromApp(appDir);
798
-
799
- // CSP config (issue #233), read once from the app's package.json
800
- // `webjs.csp`. OFF by default: when disabled no nonce is minted and no
801
- // Content-Security-Policy header is set, so an unconfigured app is
802
- // unchanged. When enabled, `handle()` mints a fresh per-request nonce,
803
- // makes it the value `cspNonce()` returns (so the SSR'd inline scripts
804
- // carry it), and sets the matching header carrying the same nonce.
805
- const cspConfig = await readCspConfigFromApp(appDir);
806
-
807
- // Request body-size limits (issue #237), read once from the app's
808
- // package.json `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env
809
- // overrides. The secure defaults (1 MiB JSON/RPC, 10 MiB form) apply when
810
- // unconfigured. Stamped on every request via `setBodyLimits` so `readBody`
811
- // (used inside route handlers) enforces the same cap the RPC and form-dispatch
812
- // paths do.
813
- const bodyLimits = await readBodyLimitsFromApp(appDir);
814
-
815
- const state = {
816
- routeTable,
817
- actionIndex: null,
818
- middleware: null,
819
- bodyLimits,
820
- logger,
821
- // Test-mode serve gate (#806). Set ONLY by the browser-test harness
822
- // (`@webjsdev/server/testing`'s `createBrowserTestHandler`), NEVER by
823
- // `webjs dev` / `webjs start`. When true the source-serve gate is relaxed
824
- // so ANY app file under appDir is servable (a component a browser test
825
- // imports is not route-reachable, so it is absent from `browserBoundFiles`
826
- // and would 404). The `.server.*` guardrail (source -> RPC/throw stub) and
827
- // the core / vendor serving are unchanged, so no server source is exposed.
828
- testMode: opts.testMode === true,
829
- moduleGraph: null,
830
- elidableComponents: new Set(),
831
- inertRouteModules: new Set(),
832
- importOnlyRouteModules: new Map(),
833
- browserBoundFiles: null,
834
- // Transformed-source cache (stripped TS + applied elision). Per-handler,
835
- // NOT module-global: the cached bytes bake in THIS handler's elision
836
- // verdict, so two handlers for the same app with different elision
837
- // settings (a multi-tenant embedder, or the differential elision test)
838
- // must not share it, or the second would serve the first's elided source.
839
- tsCache: new Map(),
840
- // The most recent unresolved dev error frame (#264), or null. Pushed to the
841
- // SSE channel for the live overlay and replayed to a freshly-connected tab.
842
- // Dev-only (never populated when !dev); cleared on a successful rebuild.
843
- lastDevError: null,
844
- // On-request regeneration rules (#967): `webjs.dev.regenerate` from the app's
845
- // package.json. Dev-only (prod builds its outputs via `start.before`). Loaded
846
- // now and refreshed on each rebuild so a config edit takes effect without a
847
- // restart; empty for a plain app, so the serving path is unaffected.
848
- regenerateRules: dev ? await readRegenerateRules(appDir) : [],
849
- };
850
-
851
- // Teach the renderers how to turn a real server-action function into the
852
- // `<hash>/<fn>` identity a form submits (#1155). The hook path is
853
- // process-global and needs no index; `state.actionIndex` is consulted only as
854
- // a hash memo, and by the scan fallback for a runtime with no hook. In a
855
- // process running several handlers the fallback therefore resolves against
856
- // the most recently booted one, which is the same scope the asset-url
857
- // provider already has.
858
- setFormActionResolver((fn) => (state.actionIndex ? resolveActionIdentity(state.actionIndex, fn) : null));
859
-
860
- /**
861
- * Report a dev error (#264): build a frame and push it to the open tab via
862
- * the SSE overlay channel. DEV-ONLY and best-effort, so it can never affect a
863
- * response or crash the server (a frame-build failure is swallowed). No file
864
- * path or source is ever built in prod (the early return), so nothing leaks.
865
- *
866
- * `info.url` stamps a `render` frame with the URL that produced it (#1047), so
867
- * the browser overlay can refuse a frame for a page the tab is not viewing.
868
- * A `ts-strip` / `rebuild` frame passes none and stays unscoped.
869
- *
870
- * @param {unknown} error
871
- * @param {{ kind?: 'render'|'ts-strip'|'rebuild', file?: string, line?: number, column?: number, hint?: string, url?: string }} [info]
872
- */
873
- function reportDevError(error, info = {}) {
874
- if (!dev) return;
875
- try {
876
- const frame = buildDevErrorFrame(error, { ...info, appDir });
877
- state.lastDevError = frame;
878
- opts.onDevError?.(frame);
879
- } catch { /* a frame-build failure must never affect the request path */ }
880
- }
881
-
882
- // All whole-app analysis is built lazily on the first request, memoized so
883
- // boot does none of it. It runs in two stages. The deterministic analysis
884
- // (module graph, component scan + prime, browser-bound gate, action index,
885
- // middleware, elision) is network-free and, once built, never re-runs unless
886
- // a rebuild invalidates it; readiness gates on it. Vendor resolution is a
887
- // SEPARATE, best-effort stage: a pinned app reads a committed importmap file,
888
- // an unpinned app auto-fetches from jspm. It does NOT gate readiness, so an
889
- // offline or partially-unresolvable app still boots. A transient vendor
890
- // failure is re-attempted on the NEXT ensureReady call (driven by an incoming
891
- // request, a readiness probe, or the warm-up), with no background timer: the
892
- // platform's traffic and probes are the retry loop. `readyError` holds a
893
- // propagating analysis failure so /__webjs/ready can report it.
894
- let analysisDone = false; // deterministic analysis complete (readiness gate)
895
- // A pinned app applied its FULL vendor map and published the build id at boot
896
- // (above). The deferred vendor stage still runs once (and after every rebuild)
897
- // to PRUNE that map to the elision-reachable specifiers, so a pinned app serves
898
- // the same map an unpinned one does (#197); it does not re-publish the build id
899
- // (the boot hash stays the deploy fingerprint). An unpinned app starts false and
900
- // resolves live on the first request.
901
- let vendorResolved = false; // vendor map fully resolved/pruned (or permanently tolerated)
902
- let vendorAttemptedOnce = false; // the first (blocking) vendor attempt has run
903
- let vendorGen = 0; // bumped on rebuild; a stale resolve cannot flip vendorResolved
904
- let readyDone = false; // mirrors analysisDone; the /__webjs/ready gate
905
- /** @type {unknown} */
906
- let readyError = null;
907
- /** @type {Promise<void> | null} */
908
- let readyInFlight = null;
909
- async function ensureReady() {
910
- // Fully warm: analysis done and vendor resolved. Nothing to do.
911
- if (analysisDone && vendorResolved) return;
912
- // A warm pass is in flight (the analysis and/or the FIRST vendor attempt).
913
- // Await it rather than serving past it: a concurrent early request must get
914
- // the FINAL importmap, never a half-resolved one. This is what makes the
915
- // unpinned warmup flawless. The first attempt's jspm resolve is
916
- // timeout-bounded (vendor.js), so an offline app cannot hang here: on
917
- // timeout the resolve returns and the response is served with an empty,
918
- // reload-safe build id, then the retry below completes it. Without this
919
- // wait, a request arriving mid-resolve would serve a partial map and an
920
- // empty-then-changing build id, the exact warmup drift that hard-reloads
921
- // and wipes a half-filled form.
922
- if (readyInFlight) { await readyInFlight; return; }
923
- // Analysis warm but the first vendor attempt already completed and failed:
924
- // re-attempt WITHOUT blocking this request. The single-flight dedupes
925
- // concurrent attempts; success flips the flag AND publishes the build id.
926
- // This is the request/probe-driven retry (no timer). Until it succeeds the
927
- // served build id stays empty (reload-safe), so no navigation hard-reloads.
928
- if (analysisDone && vendorAttemptedOnce) {
929
- const gen = vendorGen;
930
- resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); } }).catch(() => {});
931
- return;
932
- }
933
- // Otherwise run the (single-flighted) full warm: the analysis, then the
934
- // first vendor attempt, awaited so the first response carries the import map.
935
- if (!readyInFlight) {
936
- readyInFlight = (async () => {
937
- /** @type {Record<string, number>} */
938
- const t = {};
939
- let ranAnalysis = false, ranVendor = false;
940
- const now = () => performance.now();
941
- try {
942
- if (!analysisDone) {
943
- let m = now();
944
- state.moduleGraph = await buildModuleGraph(appDir);
945
- t.graph = now() - m; m = now();
946
- const components = await scanComponents(appDir);
947
- await primeComponentRegistry(appDir, components);
948
- t.scan = now() - m; m = now();
949
- state.browserBoundFiles = computeBrowserBoundFiles(state.routeTable, state.moduleGraph, components, appDir);
950
- t.gate = now() - m; m = now();
951
- state.actionIndex = await buildActionIndex(appDir, dev);
952
- t.actions = now() - m; m = now();
953
- state.middleware = await loadMiddleware(appDir, dev, logger);
954
- t.middleware = now() - m; m = now();
955
- // Client-router opt-out (#629): re-read each pass so toggling
956
- // `webjs.clientRouter` takes effect on rebuild without a restart.
957
- setClientRouterEnabled(await readClientRouterEnabled(appDir));
958
- // Read the switch ONCE: the dev summary below reports it too, and
959
- // calling readElideEnabled twice per warm re-reads package.json.
960
- const elideOn = await readElideEnabled(appDir);
961
- const r = elideOn
962
- ? await analyzeElision(components, collectRouteModules(state.routeTable),
963
- state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
964
- : { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map(),
965
- shippedRouteModules: new Map(), componentVerdicts: new Map() };
966
- state.elidableComponents = r.elidableComponents;
967
- state.inertRouteModules = r.inertRouteModules;
968
- state.importOnlyRouteModules = r.importOnlyRouteModules;
969
- // Fold the elision verdict into app-module content hashes (#243): an
970
- // app module's served body is elision-transformed, so a verdict flip
971
- // must bust its `?v` even when its source is byte-identical. A stable
972
- // string of the sorted elidable + inert paths, RELATIVIZED to appDir
973
- // so the fingerprint is a property of the app's STRUCTURE, not its
974
- // filesystem location (two deploys at different absolute paths, or
975
- // two identical apps, produce the same fingerprint). asset-hash
976
- // digests it into each app-module hash; '' when nothing is elidable,
977
- // so a no-elision app's hash stays exactly `sha256(bytes)`.
978
- {
979
- // `appDir + sep` boundary (matches asset-hash's containment guard)
980
- // so a sibling-prefix dir cannot be mis-relativized.
981
- const rel = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
982
- const elidedPaths = [
983
- ...state.elidableComponents,
984
- ...state.inertRouteModules,
985
- // An import-only module is dropped from the boot (replaced by its
986
- // component imports), so a flip in / out of that class must bust
987
- // the importer's `?v` too.
988
- ...state.importOnlyRouteModules.keys(),
989
- ].map(rel).sort();
990
- setElisionFingerprint(elidedPaths.length ? elidedPaths.join('\n') : '');
991
- }
992
- // App-source fingerprint for the HTML cache key (#318): a
993
- // deterministic, location-independent digest of the browser-bound
994
- // file set's content hashes, so a deploy that changes ONLY an app
995
- // module's bytes (which the importmap-only build id misses) re-keys
996
- // the cache instead of serving a body with stale `?v` boot URLs.
997
- // PROD only (asset-hash is enabled there); '' in dev, where fs.watch
998
- // handles staleness. Computed AFTER the elision fingerprint, so each
999
- // per-file hash already reflects the elision verdict (an elision
1000
- // flip changes the served output, so it moves this fingerprint too).
1001
- // This eagerly hashes the WHOLE browser-bound set once (not just the
1002
- // first route's files), but `assetHashFor` is memoized and already
1003
- // runs for `?v` emission in prod, so it stays a one-time bounded cost
1004
- // inside the lazy analysis (never at boot).
1005
- if (!dev && state.browserBoundFiles) {
1006
- const relApp = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
1007
- const lines = [...state.browserBoundFiles]
1008
- .map((abs) => `${relApp(abs)}:${assetHashFor(abs)}`)
1009
- .sort();
1010
- setAppSourceFingerprint(lines.join('\n'));
1011
- } else {
1012
- setAppSourceFingerprint('');
1013
- }
1014
- // App-source deploy SIGNAL (#899), distinct from the #318 html-cache
1015
- // fingerprint above: hashes ALL app source (the module-graph `seen`
1016
- // set, INCLUDING server-only `.server.ts` that `browserBoundFiles`
1017
- // omits) with a raw per-file byte digest (decoupled from the
1018
- // asset-hash memo/elision machinery), plus the installed
1019
- // `@webjsdev/server` version, so an app-source OR a server-framework
1020
- // deploy moves it. Sorted + appDir-relative for determinism across
1021
- // instances. Drives the client's soft cache-evict (not a reload).
1022
- // PROD only; empty in dev (fs.watch + SSE handle staleness there).
1023
- if (!dev && state.moduleGraph) {
1024
- const relApp = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
1025
- const srcLines = [...seenFilesFor(state.moduleGraph)]
1026
- .map((abs) => `${relApp(abs)}:${fileByteHash(abs)}`)
1027
- .sort();
1028
- srcLines.push(`@webjsdev/server:${frameworkServerVersion()}`);
1029
- setAppSourceId(srcLines.join('\n'));
1030
- } else {
1031
- setAppSourceId('');
1032
- }
1033
- t.elision = now() - m;
1034
- if (dev) {
1035
- // An orphan is EITHER shape `findOrphanComponents` reports: no
1036
- // registration call at all, or one whose tag is computed. The
1037
- // scanner matches only a literal tag, so it sees neither, and
1038
- // what is ALWAYS lost is the verdict, the registry entry, and the
1039
- // preload hint. The upgrade is the part that differs, and a
1040
- // computed tag only survives when its importer ships WHOLE (see
1041
- // findOrphanComponents for why that is the exception). The doctor
1042
- // check and `webjs elision` say the same thing.
1043
- for (const { className, file } of await findOrphanComponents(appDir)) {
1044
- logger.warn?.(
1045
- `[webjs] ${className} extends WebComponent but is never registered with a literal tag in ${file} ` +
1046
- `(either there is no registration call, or its tag is computed), so the scanner cannot see it: ` +
1047
- `no elision verdict, no registry entry, no preload hint. ` +
1048
- `<${kebab(className)}> tags never upgrade with no registration call, and a computed tag ` +
1049
- `upgrades only while its importing module ships whole. ` +
1050
- `Add \`${className}.register('<tag-name>');\` with a literal tag.`,
1051
- );
1052
- }
1053
- // The elision summary (#1308), one server-console line. NOT a
1054
- // browser push: an inert route ships zero application JS, and the
1055
- // most useful manual check an author has is opening the network
1056
- // tab on that route and seeing nothing, which a dev-only boot
1057
- // script would corrupt on exactly the pages this proves. The fact
1058
- // is also app-wide while a browser channel is per-tab, and the
1059
- // per-page detail already has a home in `webjs elision`.
1060
- // Re-emitted after each fs.watch rebuild, because doRebuild sets
1061
- // analysisDone = false and this sits inside that stage.
1062
- logger.info?.(
1063
- elideOn
1064
- ? `[webjs] elision: ${r.elidableComponents.size}/${new Set(components.map((c) => c.file)).size} components elided, ` +
1065
- `${r.inertRouteModules.size} route modules inert, ${r.importOnlyRouteModules.size} import-only, ` +
1066
- `${r.shippedRouteModules.size} ship whole. Run \`webjs elision\` for the per-module verdict.`
1067
- : `[webjs] elision: disabled (WEBJS_ELIDE / webjs.elide), every module ships.`,
1068
- );
1069
- }
1070
- analysisDone = true;
1071
- ranAnalysis = true;
1072
- }
1073
- readyError = null;
1074
- if (!vendorResolved) {
1075
- const m = now();
1076
- const gen = vendorGen;
1077
- vendorAttemptedOnce = true;
1078
- const ok = await resolveAndApplyVendor();
1079
- t.vendor = now() - m;
1080
- ranVendor = true;
1081
- // Only memoize success (and only if a rebuild didn't intervene). A
1082
- // transient failure leaves vendorResolved false; the next ensureReady
1083
- // call re-attempts it non-blocking. A permanent unresolvable (jspm
1084
- // 401) reports ok and is tolerated, so it does not loop. On success
1085
- // the importmap is now authoritatively final, so publish the build
1086
- // id: from here every response advertises the same stable value and
1087
- // the client router's deploy detection works without warmup drift.
1088
- // A pinned app published the build id at boot (hash of the committed
1089
- // pin) and the prune only shrinks the served map, so do NOT re-publish
1090
- // (that would drift the id mid-process). An unpinned app publishes its
1091
- // now-final live map here.
1092
- if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); }
1093
- }
1094
- // Readiness reflects a FULLY warm instance: the deterministic analysis
1095
- // AND the first vendor attempt have both completed (note: completed,
1096
- // not necessarily succeeded). A readiness-gated platform (Railway
1097
- // healthcheckPath, k8s readinessProbe) therefore admits traffic only
1098
- // AFTER the build id is published (vendor resolved) or definitively
1099
- // empty (a bounded vendor failure), never DURING the vendor-resolution
1100
- // window. This is what makes warm-up actually protect users: the prior
1101
- // instance keeps serving until the new one is fully warm, so a real
1102
- // request lands on a warm instance with a stable build id instead of
1103
- // racing the resolve. The first vendor attempt is bounded (the jspm
1104
- // fetch timeout in vendor.js), so an offline / CDN-degraded app still
1105
- // becomes ready shortly after that timeout, degraded but reload-safe,
1106
- // which preserves the boot resilience #143 introduced. The gate is the
1107
- // FIRST attempt only: a transient failure still flips readyDone here,
1108
- // so a later non-blocking retry never has to re-open the readiness gate.
1109
- readyDone = true;
1110
- if (ranAnalysis) {
1111
- const ms = (x) => Math.round(x || 0);
1112
- const total = ms(t.graph) + ms(t.scan) + ms(t.gate) + ms(t.actions) + ms(t.middleware) + ms(t.elision) + ms(t.vendor);
1113
- logger.info?.(
1114
- `[webjs] analysis warm in ${total}ms (graph ${ms(t.graph)}, scan ${ms(t.scan)}, ` +
1115
- `gate ${ms(t.gate)}, actions ${ms(t.actions)}, middleware ${ms(t.middleware)}, ` +
1116
- `elision ${ms(t.elision)}, vendor ${ms(t.vendor)})`,
1117
- );
1118
- } else if (ranVendor && vendorResolved) {
1119
- logger.info?.(`[webjs] vendor resolved in ${Math.round(t.vendor || 0)}ms`);
1120
- }
1121
- } catch (e) {
1122
- readyError = e;
1123
- throw e;
1124
- } finally {
1125
- readyInFlight = null;
1126
- }
1127
- })();
1128
- }
1129
- await readyInFlight;
1130
- }
1131
-
1132
- // All vendor resolves funnel through one single-flight so two never overlap
1133
- // (resolveVendorImports reports a transient failure via a module-global flag
1134
- // that only one in-flight resolve may safely touch). Never rejects; returns
1135
- // the resolve's ok flag (false on a transient failure, applying whatever
1136
- // partial map resolved so the app is no worse off).
1137
- /** @type {Promise<boolean> | null} */
1138
- let vendorResolveInFlight = null;
1139
- function resolveAndApplyVendor() {
1140
- if (vendorResolveInFlight) return vendorResolveInFlight;
1141
- vendorResolveInFlight = (async () => {
1142
- try {
1143
- const scan = () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules, ...state.importOnlyRouteModules.keys()]));
1144
- const v = await resolveVendorImports(appDir, scan);
1145
- let { imports, integrity } = v;
1146
- if (bootVendorPinned) {
1147
- // resolveVendorImports returns a committed pin VERBATIM (it never runs
1148
- // the scan for a pinned app). Prune it to the elision-reachable
1149
- // specifiers so a pinned app serves the same map an unpinned one does
1150
- // (#197): an elided-only dep like dayjs is dropped. One scan; the pin
1151
- // path skipped it. This runs on the first warm AND after every rebuild,
1152
- // so the pruned map is the single source of truth.
1153
- const reachable = await scan();
1154
- ({ imports, integrity } = prunePinToReachable(imports, integrity, reachable));
1155
- }
1156
- await setVendorEntries(imports, integrity);
1157
- return v.ok;
1158
- } catch (e) {
1159
- logger.error?.(`[webjs] vendor resolve failed (will retry on the next request):`, e);
1160
- return false;
1161
- }
1162
- })().finally(() => { vendorResolveInFlight = null; });
1163
- return vendorResolveInFlight;
1164
- }
1165
-
1166
- // Optional app-level readiness check. A `readiness.{js,ts}` file at the app
1167
- // root may default-export an async function; /__webjs/ready runs it once the
1168
- // analysis is warm, so readiness can reflect LIVE dependency health (a DB
1169
- // ping, a queue connection) that the static analysis cannot see. Returning
1170
- // false or throwing reports the instance not ready (503), so a readinessProbe
1171
- // holds traffic off an instance whose deps are down. Absent file => analysis-
1172
- // warm is the only gate. The module is cached per build (cleared on rebuild);
1173
- // the function itself runs on every probe so it reflects current state.
1174
- let readinessFn; // undefined = unloaded, null = no file, function = loaded
1175
- async function getReadinessCheck() {
1176
- if (readinessFn !== undefined) return readinessFn;
1177
- let file = null;
1178
- for (const name of ['readiness.ts', 'readiness.js', 'readiness.mts', 'readiness.mjs']) {
1179
- const p = join(appDir, name);
1180
- if (await exists(p)) { file = p; break; }
1181
- }
1182
- if (!file) { readinessFn = null; return null; }
1183
- try {
1184
- const url = pathToFileURL(file).toString();
1185
- const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
1186
- const mod = await import(url + bust);
1187
- readinessFn = typeof mod.default === 'function' ? mod.default : null;
1188
- } catch (e) {
1189
- logger.error?.(`[webjs] failed to load readiness.{js,ts}`, { err: String(e) });
1190
- readinessFn = null;
1191
- }
1192
- return readinessFn;
1193
- }
1194
-
1195
- // Rebuilds are serialized so a slow rebuild #1 cannot overwrite a fresher
1196
- // rebuild #2's route table when it finally finishes. Without this, two file
1197
- // edits inside one fs.watch debounce window could produce a permanently
1198
- // stale state until the next rebuild.
1199
- let rebuildInFlight = Promise.resolve();
1200
-
1201
- async function rebuild() {
1202
- rebuildInFlight = rebuildInFlight.then(() => doRebuild()).catch((e) => {
1203
- logger.error?.(`[webjs] rebuild failed:`, e);
1204
- // Push the failure to the open tab so the overlay appears live after a
1205
- // breaking edit, not only on the next manual navigation (#264).
1206
- reportDevError(e, { kind: 'rebuild' });
1207
- });
1208
- return rebuildInFlight;
1209
- }
1210
-
1211
- async function doRebuild() {
1212
- // The route table is the only eager artifact (cheap directory scan); rebuild
1213
- // it so routing reflects added/removed route files immediately.
1214
- state.routeTable = await buildRouteTable(appDir);
1215
- state.routeTable.instrumentationClient = await findInstrumentationClient(appDir);
1216
- // Adding or deleting app/icon.* changes whether the head auto-links it.
1217
- setMetadataIconRoutes(state.routeTable.metadataRoutes);
1218
- // Refresh the generated route types (#258) so adding/removing a route file
1219
- // updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
1220
- // best-effort (see emitRouteTypes).
1221
- if (dev) void emitRouteTypes();
1222
- clearVendorCache();
1223
- // Content-hash asset cache (#243): clear so a changed file re-hashes and
1224
- // its emitted `?v` busts the stale immutable copy. A no-op in dev (the
1225
- // module is never enabled there), kept for correctness + the deploy-busts
1226
- // regression test, which clears + re-hashes to observe a changed `?v`.
1227
- clearAssetHashCache();
1228
- state.tsCache.clear();
1229
- // Invalidate the lazy analysis; the next request rebuilds the graph,
1230
- // component scan, gate, action index, middleware, elision, and vendor map.
1231
- // Wait out any in-flight build first so it cannot commit stale results
1232
- // after the reset. A dependency edit can flip an elision verdict without
1233
- // changing an importer's mtime, hence the state.tsCache.clear above.
1234
- if (readyInFlight) { try { await readyInFlight; } catch {} }
1235
- // Bump the vendor generation so a vendor resolve still in flight from the
1236
- // previous build cannot flip vendorResolved against the fresh state.
1237
- vendorGen++;
1238
- analysisDone = false;
1239
- vendorResolved = false;
1240
- vendorAttemptedOnce = false;
1241
- readyDone = false;
1242
- readyError = null;
1243
- readinessFn = undefined; // reload readiness.{js,ts} after a rebuild
1244
- // Refresh the on-request regenerate rules (#967) so a `webjs.dev.regenerate`
1245
- // config edit takes effect without a restart, mirroring the elide/router
1246
- // re-reads above. Dev-only; a no-op array in prod.
1247
- if (dev) state.regenerateRules = await readRegenerateRules(appDir);
1248
- // Optimistically clear the dev error (#264): the rebuild itself only
1249
- // re-scans the route table and INVALIDATES the lazy analysis (the real
1250
- // re-parse / re-strip / re-render happens on the next request), so we do
1251
- // not yet know the edit fixed the error. Clearing it here means a tab that
1252
- // connects now is not replayed a possibly-stale overlay; `onReload` then
1253
- // reloads every open tab, and if the underlying error is still present the
1254
- // reloaded request re-pushes a fresh frame (a brief dismiss-then-reappear
1255
- // flicker on an unrelated edit, self-correcting to the right end state).
1256
- state.lastDevError = null;
1257
- opts.onReload?.();
1258
- }
1259
-
1260
- /** @param {Request} req */
1261
- function handle(req) {
1262
- return withRequest(req, async () => {
1263
- // Correlation id (issue #239): honor an inbound X-Request-Id from a
1264
- // trusted upstream proxy, else mint a fresh UUID. Stored on the request
1265
- // scope FIRST so everything downstream (the SSR, server actions, the
1266
- // access / error log, the onError sink, the response header) reads the
1267
- // same id, threading one trace id across services.
1268
- const reqId = resolveRequestId(req);
1269
- setRequestId(reqId);
1270
-
1271
- // CSP (issue #233): when enabled, mint a fresh CSPRNG nonce and store
1272
- // it on the request scope BEFORE producing the response, so the SSR
1273
- // pipeline's `cspNonce()` reads this exact value and stamps it on the
1274
- // inline boot script, the importmap, and the modulepreload hints.
1275
- // Disabled by default, so no nonce is minted and the response is
1276
- // unchanged. One minted value flows mint -> store -> SSR -> header.
1277
- const nonce = cspConfig.enabled ? mintNonce() : '';
1278
- if (nonce) setCspNonce(nonce);
1279
-
1280
- // Make the resolved body-size limits (issue #237) readable from the
1281
- // request scope, so `readBody` inside a route handler enforces the same
1282
- // cap the framework's own RPC / form-dispatch body reads do.
1283
- setBodyLimits(state.bodyLimits);
1284
-
1285
- let pathname = '/';
1286
- try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
1287
- // Sub-path deployment (issue #256): the per-path response-header rules
1288
- // (`webjs.headers`) author their `source` patterns app-root-relative,
1289
- // exactly like `webjs.redirects` / `webjs.trailingSlash` (which the
1290
- // ingress strip in produce() already sees root-relative). So match the
1291
- // header rules against the STRIPPED path too, keeping the whole config
1292
- // surface consistent under a base path. `pathname` itself (used for the
1293
- // access log) stays the RAW path the client hit. No-op when basePath is
1294
- // empty or the request is not under it.
1295
- let headerPathname = pathname;
1296
- if (basePathValue) {
1297
- const s = stripBasePath(pathname, basePathValue);
1298
- if (s !== null) headerPathname = s;
1299
- }
1300
- const startedAt = performance.now();
1301
-
1302
- let res;
1303
- try {
1304
- res = await produce(req);
1305
- } catch (e) {
1306
- // A throw escaping produce() is the last-resort 500 (every interior
1307
- // path catches its own errors, but a surprise still must not crash the
1308
- // host). Fire the onError sink (best-effort) and emit a sanitized 500,
1309
- // preserving the prior behavior plus the new APM hook.
1310
- reportError(e, req, 'handle');
1311
- logger.error?.('[webjs] request pipeline threw', {
1312
- requestId: reqId,
1313
- method: req.method,
1314
- path: pathname,
1315
- err: e instanceof Error ? e.stack : String(e),
1316
- });
1317
- res = new Response('Server error', { status: 500 });
1318
- }
1319
-
1320
- // Merge in the secure-by-default headers plus the per-path config
1321
- // (issue #232) as the final step, so app middleware, route
1322
- // handlers, and `expose` headers (already on `res`) always win.
1323
- // Applied to every served response (documents, assets, the core
1324
- // runtime, probes), since the defaults are universally safe.
1325
- let merged = applySecurityHeaders(res, {
1326
- pathname: headerPathname,
1327
- https: webRequestIsHttps(req),
1328
- prod: !dev,
1329
- rules: headerRules,
1330
- });
1331
-
1332
- // Expose the correlation id on the response (issue #239) so a client /
1333
- // proxy can read it from the X-Request-Id header. Never clobber an id an
1334
- // upstream / the app already set on the response.
1335
- if (!merged.headers.has('x-request-id')) merged.headers.set('x-request-id', reqId);
1336
-
1337
- // Emit the Content-Security-Policy header carrying the SAME minted
1338
- // nonce the SSR'd scripts got (no drift). Set only when CSP is
1339
- // enabled; never clobber a CSP header the app already set (in
1340
- // middleware, a route handler, or via the webjs.headers config), so
1341
- // an explicit app policy still wins.
1342
- if (nonce && !merged.headers.has('content-security-policy') &&
1343
- !merged.headers.has('content-security-policy-report-only')) {
1344
- // readCspConfig already drops a directive whose name/value carries a
1345
- // control char, so buildCspHeader produces a Headers-safe value. The
1346
- // try/catch is a belt-and-suspenders backstop: a surprise value must
1347
- // never throw the response pipeline (fail closed to no CSP header
1348
- // rather than a self-inflicted 500 on every request).
1349
- try {
1350
- merged.headers.set(cspHeaderName(cspConfig), buildCspHeader(cspConfig, nonce));
1351
- } catch {
1352
- /* a malformed policy must not take the request down: serve without CSP */
1353
- }
1354
- }
1355
-
1356
- // Server HTML cache write (#241): if the SSR marked this response as a
1357
- // cache candidate (an opted-in `revalidate` page), store the FINAL body
1358
- // now, after segment middleware has added any per-user Set-Cookie (which
1359
- // the funnel's guard re-checks, so a per-user response is not cached) and
1360
- // before conditional-GET can swap it for a bodiless 304. The marker is
1361
- // stripped here regardless. Best-effort: a store failure is swallowed.
1362
- try {
1363
- const reqUrl = new URL(req.url);
1364
- // Sub-path deployment (issue #256): the HTML cache READ (in ssrPage)
1365
- // and `revalidatePath` both key on the app-root-relative path, so key
1366
- // the WRITE on the stripped path too, or a cached page would never
1367
- // hit. No-op when basePath is empty / the path is not under it.
1368
- if (basePathValue) {
1369
- const s = stripBasePath(reqUrl.pathname, basePathValue);
1370
- if (s !== null) reqUrl.pathname = s;
1371
- }
1372
- merged = await commitHtmlCache(req, merged, reqUrl);
1373
- } catch { /* never let the cache write crash the response */ }
1374
-
1375
- // Conditional GET (RFC 7232, issue #240): attach a content-hash ETag to
1376
- // a cacheable response missing one, and turn a matching If-None-Match
1377
- // into a 304 Not Modified with no body. Applied LAST, after every header
1378
- // (X-Webjs-Build, X-Request-Id, Set-Cookie, CSP) is on the response, so a
1379
- // 304 carries the validators a shared cache and the client router need.
1380
- // A no-store response, a non-GET/HEAD, and a streaming Suspense body are
1381
- // all skipped (see conditional-get.js). A `private` response is NOT
1382
- // skipped: private forbids shared storage, not validation (#1140). Logged with the final
1383
- // (possibly 304) status. Best-effort: a failure leaves the 200 untouched.
1384
- let conditioned = merged;
1385
- try {
1386
- conditioned = await applyConditionalGet(req, merged);
1387
- } catch { /* never let validator computation crash the response */ }
1388
-
1389
- // Structured access log (issue #239): ONE info line per handled request
1390
- // at the single response funnel, carrying only method / path / status /
1391
- // duration / requestId (no bodies, no secrets). Suppressed for the
1392
- // framework's own /__webjs/* probe + static traffic so it does not spam.
1393
- // Best-effort: a logger that throws must not take the response down.
1394
- // Use the STRIPPED path for the suppression check (issue #256) so a
1395
- // framework probe at `<basePath>/__webjs/*` is suppressed just like the
1396
- // root-mounted `/__webjs/*`. The logged `path` stays the RAW client URL.
1397
- if (shouldAccessLog(headerPathname)) {
1398
- try {
1399
- // #1309: fold the dev-only seeding counters into the ONE access-log
1400
- // line rather than adding a second one. Present only on a response
1401
- // that carried the header, so only page renders gain the field and
1402
- // production is unchanged.
1403
- const seed = dev ? conditioned.headers.get('x-webjs-seed') : null;
1404
- logger.info?.('request', {
1405
- requestId: reqId,
1406
- method: req.method,
1407
- path: pathname,
1408
- status: conditioned.status,
1409
- durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
1410
- ...(seed ? { seed } : {}),
1411
- });
1412
- } catch { /* never let logging crash the response */ }
1413
- }
1414
-
1415
- return conditioned;
1416
- });
1417
- }
1418
-
1419
- /** @param {Request} req */
1420
- function produce(req) {
1421
- return (async () => {
1422
- // Sub-path deployment ingress strip (issue #256). When `webjs.basePath`
1423
- // is set and the request path is under it, STRIP the prefix and rewrite
1424
- // the Request so EVERYTHING downstream (redirects, trailing-slash, the
1425
- // probes, the `/__webjs/*` checks, the source-file gate, route matching,
1426
- // SSR) sees a ROOT-relative path and works UNCHANGED. This single strip
1427
- // is why the rest of the framework needs no per-site changes. A request
1428
- // whose path is NOT under the base path is not for this mounted app, so
1429
- // return a 404 (the safe default for a mounted app). Empty basePath (the
1430
- // default) is a pure pass-through, so an unconfigured app is unchanged.
1431
- if (basePathValue) {
1432
- let reqUrl;
1433
- try { reqUrl = new URL(req.url); } catch { reqUrl = null; }
1434
- if (reqUrl) {
1435
- const stripped = stripBasePath(reqUrl.pathname, basePathValue);
1436
- if (stripped === null) {
1437
- // Not under the base path: this request is not for this app.
1438
- return new Response('Not found', {
1439
- status: 404,
1440
- headers: { 'content-type': 'text/plain; charset=utf-8' },
1441
- });
1442
- }
1443
- if (stripped !== reqUrl.pathname) {
1444
- reqUrl.pathname = stripped;
1445
- // Rewrite the Request with the stripped URL, preserving method,
1446
- // headers, and body. `duplex: 'half'` is required by the spec when
1447
- // a body stream is present on a non-GET/HEAD request.
1448
- //
1449
- // SECURITY (#756): this is a fresh Request object, so it is NOT in
1450
- // the listener's out-of-band trusted-IP WeakMap (the Bun shell
1451
- // stamps the IP there instead of cloning the Request to set the
1452
- // header). Without carrying it forward, `clientIp` would fall back
1453
- // to the inbound `x-webjs-remote-ip` header that the copied
1454
- // `req.headers` still carries, which a client can spoof. So strip
1455
- // that header on the rebuild and propagate the framework-trusted IP
1456
- // across the new object, the same pattern form-dispatch.js uses.
1457
- const headers = new Headers(req.headers);
1458
- headers.delete('x-webjs-remote-ip');
1459
- const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
1460
- const next = new Request(
1461
- reqUrl.toString(),
1462
- /** @type {any} */ ({
1463
- method: req.method,
1464
- headers,
1465
- body: hasBody ? req.body : undefined,
1466
- duplex: hasBody ? 'half' : undefined,
1467
- redirect: req.redirect,
1468
- signal: req.signal,
1469
- }),
1470
- );
1471
- propagateTrustedRemoteIp(req, next);
1472
- req = next;
1473
- }
1474
- }
1475
- }
1476
-
1477
- // Declarative redirects (issue #254): apply the configured old-path ->
1478
- // new-path rules at the VERY START of request handling, before the
1479
- // probes, routing, SSR, or asset serving. A matched source returns a
1480
- // 308 (permanent, the SEO default) / 307 (temporary) / configured
1481
- // status immediately, so a moved URL never reaches the router.
1482
- // `applyRedirects` skips /__webjs/* itself, so the framework probes /
1483
- // runtime below are never redirected. The secure-header + conditional-GET
1484
- // funnel in handle() still wraps this Response, like any other.
1485
- const redirectResp = applyRedirects(req, redirectRules);
1486
- if (redirectResp) return redirectResp;
1487
-
1488
- // Trailing-slash canonicalization (issue #255): after the explicit
1489
- // redirects above (so an explicit rule wins first and the two never
1490
- // form a loop), 308-redirect a non-canonical path to the policy's
1491
- // canonical form (`never` strips a trailing slash, `always` adds one).
1492
- // Default `'ignore'` is a no-op. The root `/` and file paths are
1493
- // exempt; `/__webjs/*` is exempt too (defense in depth, the redirects
1494
- // above already skip it). The funnel in handle() still wraps this.
1495
- const slashResp = applyTrailingSlash(req, trailingSlashPolicy);
1496
- if (slashResp) return slashResp;
1497
-
1498
- // Health and readiness probes are answered BEFORE ensureReady so a probe
1499
- // never blocks on the analysis. `/__webjs/health` is liveness (the
1500
- // process is up and accepting connections). `/__webjs/ready` is 503 until
1501
- // the instance is FULLY warm (the deterministic analysis AND the first
1502
- // vendor attempt have both completed, so the importmap build id is
1503
- // settled), then 200 unless an optional app readiness check
1504
- // (readiness.{js,ts}) reports a dependency down. So a readinessProbe holds
1505
- // traffic off a not-yet-warm or dependency-unhealthy instance, and admits
1506
- // it only once the build id is stable, never mid vendor-resolution.
1507
- // Probing `/__webjs/ready` also kicks off the warm in the background, so
1508
- // an embedder that never called warmup() still warms. The first vendor
1509
- // attempt is bounded (the jspm fetch timeout), so a vendor CDN failure
1510
- // delays readiness only briefly and then admits the instance (degraded but
1511
- // reload-safe); a transient failure is re-attempted on the next request.
1512
- let probePath;
1513
- try { probePath = new URL(req.url).pathname; } catch { probePath = ''; }
1514
- if (probePath === '/__webjs/health') {
1515
- return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
1516
- }
1517
- // Build-info probe (issue #239): which build is live? Answered before
1518
- // ensureReady like the other probes (it depends only on the package
1519
- // version + already-published build id + process info, never the app
1520
- // analysis). No secrets.
1521
- if (probePath === '/__webjs/version') {
1522
- return buildInfoResponse();
1523
- }
1524
- if (probePath === '/__webjs/ready') {
1525
- const noStore = { 'cache-control': 'no-store' };
1526
- if (!readyDone) {
1527
- ensureReady().catch(() => {}); // drive the warm; never block the probe
1528
- const body = readyError
1529
- ? { status: 'error', error: String((readyError && readyError.message) || readyError) }
1530
- : { status: 'pending' };
1531
- return Response.json(body, { status: 503, headers: noStore });
1532
- }
1533
- // Analysis is warm. Consult the optional app readiness check (live
1534
- // dependency health, e.g. a DB ping) if the app provides one.
1535
- const check = await getReadinessCheck();
1536
- if (check) {
1537
- try {
1538
- if ((await check()) === false) {
1539
- return Response.json({ status: 'unready' }, { status: 503, headers: noStore });
1540
- }
1541
- } catch (e) {
1542
- return Response.json(
1543
- { status: 'unready', error: String((e && e.message) || e) },
1544
- { status: 503, headers: noStore },
1545
- );
1546
- }
1547
- }
1548
- return Response.json({ status: 'ok' }, { headers: noStore });
1549
- }
1550
- // Framework-internal static assets (the @webjsdev/core runtime, the dev
1551
- // reload client, downloaded vendor bundles) depend on neither the analysis
1552
- // nor the vendor importmap, so serve them BEFORE ensureReady(). Otherwise a
1553
- // cold instance blocks them behind the first vendor resolve (issue #190),
1554
- // and the core bundle is on every page's boot path, so that stalled first
1555
- // interactivity site-wide. Matched on the decoded path, like handleCore.
1556
- let assetPath = probePath;
1557
- try { assetPath = decodeURIComponent(probePath); } catch { /* keep raw on malformed escape */ }
1558
- // Content-hash fingerprint (#243): a `?v=` query means an immutable,
1559
- // content-addressed asset url. Detected off the raw request url here
1560
- // (probePath was parsed from it above).
1561
- let assetVersioned = false;
1562
- try { assetVersioned = new URL(req.url).searchParams.has('v'); } catch { /* none */ }
1563
- const staticResp = await tryServeFrameworkStatic(assetPath, req.method.toUpperCase(), { coreDir, appDir, dev, versioned: assetVersioned });
1564
- if (staticResp) return staticResp;
1565
- // Build all whole-app analysis on the first request (memoized), before
1566
- // any SSR, module serve, gate check, action dispatch, or middleware runs.
1567
- await ensureReady();
1568
- const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, reportDevError, hasOnError, logger, cspEnabled: cspConfig.enabled, allowedOrigins: allowedOriginsValue });
1569
- if (state.middleware) {
1570
- try {
1571
- return await state.middleware(req, next);
1572
- } catch (e) {
1573
- reportError(e, req, 'middleware');
1574
- logger.error('middleware threw', { err: String(e), requestId: getRequestId() });
1575
- return new Response('Server error', { status: 500 });
1576
- }
1577
- }
1578
- return next();
1579
- })();
1580
- }
1581
-
1582
- /**
1583
- * Lightweight lookup used by the HTTP layer to emit 103 Early Hints
1584
- * BEFORE running SSR: resolves a pathname to its page-route module URLs
1585
- * without loading them. Returns null for non-page paths.
1586
- *
1587
- * Sub-path deployment (issue #256): the HTTP layer passes the RAW request
1588
- * pathname (still carrying the base path, since the ingress strip happens
1589
- * inside `produce`, not here), so strip it for route matching and prefix
1590
- * the emitted module URLs so the early-hint preloads resolve under the
1591
- * prefix. A path not under the base path yields null (no hints).
1592
- *
1593
- * @param {string} pathname
1594
- */
1595
- function routeFor(pathname) {
1596
- const matchPathname = basePathValue
1597
- ? stripBasePath(pathname, basePathValue)
1598
- : pathname;
1599
- if (matchPathname === null) return null;
1600
- const page = matchPage(state.routeTable, matchPathname);
1601
- if (!page) return null;
1602
- // Mirror ssr.js's boot assembly EXACTLY: drop inert route modules, and
1603
- // splice an import-only module's component imports in place of the module
1604
- // (#605). Otherwise the 103 Early Hints would preload the page / layout
1605
- // modules the body no longer imports (a wasted hint) and miss the component
1606
- // modules it actually fetches (a double-fetch on the real module).
1607
- const inert = state.inertRouteModules;
1608
- const importOnly = state.importOnlyRouteModules;
1609
- const files = [];
1610
- const seenFiles = new Set();
1611
- for (const f of [page.route.file, ...page.route.layouts]) {
1612
- if (inert && inert.has(f)) continue;
1613
- const emit = importOnly && importOnly.get(f);
1614
- for (const t of emit || [f]) if (!seenFiles.has(t)) { seenFiles.add(t); files.push(t); }
1615
- }
1616
- const moduleUrls = files.map((f) => {
1617
- let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
1618
- const url = rel.split('\\').join('/').replace(/^\/?/, '/');
1619
- // Mirror ssr.js's emit (basePath THEN `?v`) so the 103 Early Hints preload
1620
- // the SAME url the body's modulepreload + boot specifiers request (#243).
1621
- // A bare url would warm a different url and waste the hint. No-op in dev.
1622
- // Best-effort: a request landing in the narrow pre-warm window (before the
1623
- // elision verdict is set) hashes against an empty fingerprint, so the hint
1624
- // can mismatch the post-warm body for that one request, only a wasted
1625
- // speculative preload, never a stale body (the body url is authoritative).
1626
- return withAssetHash(withBasePath(url, basePathValue), basePathValue);
1627
- });
1628
- return { moduleUrls };
1629
- }
1630
-
1631
- return {
1632
- handle,
1633
- rebuild,
1634
- routeFor,
1635
- /**
1636
- * Proactively run the first-request analysis (module graph, component
1637
- * scan, gate, action index, middleware, elision, vendor map) in the
1638
- * background, so a real first request finds it already memoized. Safe to
1639
- * call any number of times and concurrently: the work is single-flighted,
1640
- * so this never duplicates it or races a real request. It is a single
1641
- * best-effort kick: errors are caught and logged rather than thrown (a
1642
- * background warm-up must not crash the process), and whatever failed simply
1643
- * re-runs on the next request or readiness probe (the platform's traffic and
1644
- * probes are the retry loop, so there is no internal backoff). `startServer`
1645
- * calls this once the HTTP server is listening; embedders can call it after
1646
- * their own listen.
1647
- * @returns {Promise<void>}
1648
- */
1649
- warmup: () => ensureReady().catch((e) => logger.error?.(`[webjs] background warm-up failed (will retry on the next request):`, e)),
1650
- /** current route table getter: used by the WebSocket subsystem */
1651
- getRouteTable: () => state.routeTable,
1652
- /** Current unresolved dev error frame (#264), or null. Replayed by
1653
- * startServer to a freshly-connected SSE client so the overlay shows even
1654
- * after a navigation, not only on the breaking edit. Always null in prod. */
1655
- getLastDevError: () => state.lastDevError,
1656
- /** Whether a dev-watcher filename is a `webjs.dev.regenerate` OUTPUT (#967),
1657
- * so `startServer`'s file watcher (a different scope, with no access to
1658
- * `state`) can skip a build product the server itself writes and avoid a
1659
- * spurious reload. Reads the live, rebuild-refreshed rules. */
1660
- isRegenerateOutput: (filename) => isRegenerateOutputPath(filename, state.regenerateRules),
1661
- appDir,
1662
- dev,
1663
- logger,
1664
- };
1665
- }
1666
-
1667
- /**
1668
- * Start a WebJs HTTP server. Thin wrapper around `createRequestHandler`.
1669
- *
1670
- * Speaks plain HTTP/1.1. TLS termination + HTTP/2 to the browser is
1671
- * expected to be handled by a reverse proxy (PaaS edge, nginx, Caddy,
1672
- * etc.) sitting in front of this process. See the deployment docs for
1673
- * the recommended topology.
1674
- *
1675
1
  /**
1676
- * Paths under the app root whose changes must NOT trigger a dev rebuild.
1677
- * `node_modules` / `.git` are noise. `.webjs/` is the framework's generated
1678
- * artefact dir (the #258 routes.d.ts and the vendor pin) that the dev server
1679
- * itself writes on startup and on every rebuild, so without this skip the
1680
- * write fires a watch event, triggers a rebuild, re-writes the file, and loops
1681
- * forever. `db/dev.db*` (the SQLite file + sidecars) and `db/migrations`
1682
- * (drizzle-kit output) churn during db:migrate. The `db/dev.db` branch is
1683
- * prefix-only (no trailing separator) so the `-journal` / `-wal` sidecars match
1684
- * too, while staying anchored to `db/dev.db` so a SOURCE file like
1685
- * `db/schema.server.ts` still triggers a reload. The others stay
1686
- * separator-anchored so an unrelated name like `node_modules.bak/foo` does not.
1687
- *
1688
- * @param {string} filename relative path from an fs.watch `event.filename`
1689
- * @returns {boolean} true when the change should be ignored
1690
- */
1691
- export function shouldIgnoreWatchPath(filename) {
1692
- return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)/.test(filename || '');
1693
- }
1694
-
1695
- /**
1696
- * A short content digest of a single file's bytes for the app-source deploy
1697
- * signal (#899). Raw `sha256(bytes)`, deliberately independent of `assetHashFor`
1698
- * (which folds the elision fingerprint and memoizes for `?v` emission): the
1699
- * signal only needs "did the source bytes change", and server-only files never
1700
- * enter the asset-hash memo anyway. Returns `''` on a read failure, so a
1701
- * transient error degrades the id rather than throwing during analysis.
1702
- * @param {string} abs
1703
- * @returns {string}
1704
- */
1705
- function fileByteHash(abs) {
1706
- try { return createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 16); }
1707
- catch { return ''; }
1708
- }
1709
-
1710
- /** @type {string} memoized `@webjsdev/server` version, folded into the app-source signal (#899). */
1711
- let _frameworkServerVersion;
1712
- /**
1713
- * The installed `@webjsdev/server` version (this package). Folded into the
1714
- * app-source deploy signal so a server-framework release (which alters SSR
1715
- * output but ships no new browser module) turns over the client's stale caches.
1716
- * @returns {string}
1717
- */
1718
- function frameworkServerVersion() {
1719
- if (_frameworkServerVersion !== undefined) return _frameworkServerVersion;
1720
- try {
1721
- const v = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
1722
- _frameworkServerVersion = typeof v === 'string' ? v.replace(/[^\w.-]/g, '').slice(0, 32) : '';
1723
- } catch { _frameworkServerVersion = ''; }
1724
- return _frameworkServerVersion;
1725
- }
1726
-
1727
- /**
1728
- * @param {{
1729
- * appDir: string,
1730
- * port?: number,
1731
- * dev?: boolean,
1732
- * compress?: boolean,
1733
- * logger?: import('./logger.js').Logger,
1734
- * onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
1735
- * }} opts
1736
- */
1737
- export async function startServer(opts) {
1738
- const dev = !!opts.dev;
1739
- const port = opts.port ?? 8080;
1740
- // Compression default: on in prod, off in dev (cheaper to debug raw bytes).
1741
- const compress = opts.compress ?? !dev;
1742
- const logger = opts.logger || defaultLogger({ dev });
1743
-
1744
- // Runtime-neutral SSE registry + fanout (shared by the node:http and Bun.serve
1745
- // shells via listener-core.js, so live-reload + the dev error overlay behave
1746
- // identically on both). Built before the handler so its onReload / onDevError
1747
- // callbacks can fan out through it.
1748
- const hub = new SseHub();
1749
- let app;
1750
- try {
1751
- app = await createRequestHandler({
1752
- ...opts,
1753
- logger,
1754
- onReload: () => hub.reload(),
1755
- // Dev error overlay (#264): push a frame to every open tab over the SAME
1756
- // SSE channel. A distinct `webjs-error` event name (NOT `error`, which is
1757
- // EventSource's native connection-error event) carries the JSON frame.
1758
- onDevError: (frame) => hub.devError(frame),
1759
- });
1760
- } catch (e) {
1761
- // The hub starts its keepalive interval in its constructor (before this
1762
- // await), so a boot failure must clear it rather than leak a live timer.
1763
- hub.closeAll();
1764
- throw e;
1765
- }
1766
-
1767
- /** @type {AbortController | null} */
1768
- let watcherAbort = null;
1769
- if (dev) {
1770
- // Watch the app root recursively via Node's built-in
1771
- // `fs.promises.watch`. Stable on macOS, Windows, and Linux as of
1772
- // Node 24. No external dep needed.
1773
- //
1774
- // fs.watch returns relative paths in event.filename. `shouldIgnoreWatchPath`
1775
- // (module-level, exported for tests) skips node_modules, .git, .webjs/, and
1776
- // the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
1777
- const rebuild = debounce(() => app.rebuild(), 80);
1778
- watcherAbort = new AbortController();
1779
- const watchRoot = async (root) => {
1780
- try {
1781
- const events = fsWatch(root, { recursive: true, signal: watcherAbort.signal });
1782
- for await (const event of events) {
1783
- const filename = event.filename || '';
1784
- if (shouldIgnoreWatchPath(filename)) continue;
1785
- // A regenerate output (#967) is a build product the server itself
1786
- // writes on request; ignoring it stops a spurious rebuild + reload
1787
- // (and a reload -> refetch -> recompile -> reload cycle), same as the
1788
- // db/dev.db carve-out above. `app` exposes the check because `state`
1789
- // lives in createRequestHandler's scope, not here.
1790
- if (app.isRegenerateOutput(filename)) continue;
1791
- rebuild();
1792
- }
1793
- } catch (err) {
1794
- if (err && /** @type any */(err).name !== 'AbortError') {
1795
- logger.warn({ err }, `file watcher exited (${root})`);
1796
- }
1797
- }
1798
- };
1799
- watchRoot(app.appDir);
1800
- // Extra roots the app reads from OUTSIDE its appDir (#894): repo-root content
1801
- // dirs (blog markdown, data fixtures) the recursive appDir watch can't see.
1802
- // Opt-in via `webjs.dev.watch`; each funnels into the same debounced rebuild.
1803
- const extraWatch = (await readDevWatchPathsFromApp(app.appDir)).filter((p) => existsSync(p));
1804
- for (const root of extraWatch) watchRoot(root);
1805
- if (extraWatch.length) logger.info?.(`[webjs] also watching ${extraWatch.length} extra dev path(s): ${extraWatch.join(', ')}`);
1806
- }
1807
-
1808
- // Inbound server timeouts (issue #237). On node these are the node:http
1809
- // built-ins; on Bun the node `requestTimeout` maps to Bun's single
1810
- // `idleTimeout`. Defends against slowloris and hung connections. Overridable
1811
- // via `webjs.requestTimeoutMs` / `headersTimeoutMs` / `keepAliveTimeoutMs` in
1812
- // package.json or the matching WEBJS_*_MS env vars; `0` disables that timeout.
1813
- const timeouts = await readServerTimeoutsFromApp(app.appDir);
1814
-
1815
- // The shared context both listener shells consume. The transport-specific glue
1816
- // (node:http `res.write`, Bun.serve streaming Response) lives in each shell;
1817
- // the SSE registry, the live-reload path predicate, the WS module loader, and
1818
- // the lifecycle wiring are shared via listener-core.js so the shells can't drift.
1819
- /** @type {import('./listener-types.js').ListenerContext} */
1820
- const ctx = { app, dev, compress, logger, hub, port, basePathStr: basePath(), timeouts, watcherAbort };
1821
-
1822
- // Pick the adapter by runtime. Bun is Request/Response-native, so its shell
1823
- // skips the node:http bridge (toWebRequest/sendWebResponse) for ~1.9x more
1824
- // req/s on the listening path (#511); the Bun shell is dynamically imported so
1825
- // the `Bun.*` global is never referenced on Node.
1826
- if (serverRuntime() === 'bun') {
1827
- const { startBunListener } = await import('./listener-bun.js');
1828
- return startBunListener(ctx);
1829
- }
1830
- return startNodeListener(ctx);
1831
- }
1832
-
1833
- /**
1834
- * The node:http listener shell: the original `startServer` socket path, now
1835
- * reading the shared `ListenerContext`. Bridges node `IncomingMessage` ->
1836
- * `Request` (`toWebRequest`) and `Response` -> `ServerResponse`
1837
- * (`sendWebResponse`), emits 103 Early Hints, and drives SSE + WS over node
1838
- * primitives, sharing the SSE registry + lifecycle wiring with the Bun shell.
1839
- * @param {import('./listener-types.js').ListenerContext} ctx
1840
- * @returns {{ server: import('node:http').Server, close: () => Promise<void> }}
1841
- */
1842
- function startNodeListener(ctx) {
1843
- const { app, dev, compress, logger, hub, port, basePathStr, timeouts, watcherAbort } = ctx;
1844
-
1845
- const server = makeHttpServer(async (req, res) => {
1846
- try {
1847
- const url = urlFromRequest(req);
1848
-
1849
- // SSE: handled specially; doesn't fit the req→Response model. Match the
1850
- // base-path-stripped pathname so the reload stream answers at
1851
- // `<basePath>/__webjs/events` under a sub-path deploy (#256). With no
1852
- // basePath this is a pure pass-through (the bare path still matches).
1853
- if (stripBasePath(url.pathname, basePathStr) === '/__webjs/events') {
1854
- if (!dev) { res.writeHead(404); res.end(); return; }
1855
- res.writeHead(200, {
1856
- 'content-type': 'text/event-stream',
1857
- 'cache-control': 'no-cache',
1858
- connection: 'keep-alive',
1859
- });
1860
- // `retry: 300` shrinks the browser's EventSource reconnect backoff from
1861
- // its ~3s default so a reconnect after a `node --watch` restart happens
1862
- // promptly. The `hello` data is a per-process boot id (#893): the client
1863
- // reloads on a reconnect ONLY when it changes (a real restart), so a
1864
- // transient reconnect never triggers a spurious reload.
1865
- res.write(`retry: 300\nevent: hello\ndata: ${DEV_BOOT_ID}\n\n`);
1866
- // Register a node client wrapper in the shared hub: the fanout + keepalive
1867
- // live in SseHub; only the transport write (res.write / res.end) is local.
1868
- const client = {
1869
- send: (s) => { try { res.write(s); } catch {} },
1870
- close: () => { try { res.end(); } catch {} },
1871
- };
1872
- hub.add(client);
1873
- // Replay an unresolved dev error (#264) so a tab that connects AFTER the
1874
- // breaking edit (e.g. opened via a fresh navigation) still shows the
1875
- // overlay, not only the tab that was open when the error fired.
1876
- const pending = app.getLastDevError?.();
1877
- if (pending) {
1878
- try { res.write(`event: webjs-error\ndata: ${JSON.stringify(pending)}\n\n`); } catch {}
1879
- }
1880
- res.socket?.on('close', () => hub.remove(client));
1881
- return;
1882
- }
1883
-
1884
- // 103 Early Hints: before running SSR, send preload hints for the
1885
- // page's module URLs so the browser can begin fetching them while
1886
- // the server is still computing the body. Skipped in dev (file churn
1887
- // would send stale URLs after rebuilds) and for non-GET/HEAD.
1888
- if (
1889
- !dev &&
1890
- (req.method === 'GET' || req.method === 'HEAD') &&
1891
- typeof res.writeEarlyHints === 'function'
1892
- ) {
1893
- const match = app.routeFor(url.pathname);
1894
- if (match && match.moduleUrls.length) {
1895
- try {
1896
- res.writeEarlyHints({
1897
- link: match.moduleUrls.map((u) => `<${u}>; rel=modulepreload`),
1898
- });
1899
- } catch (e) {
1900
- logger.warn('writeEarlyHints failed', { err: String(e) });
1901
- }
1902
- }
1903
- }
1904
-
1905
- const webReq = toWebRequest(req, url);
1906
- const resp = await app.handle(webReq);
1907
- await sendWebResponse(res, resp, req, { compress });
1908
- } catch (e) {
1909
- logger.error('request pipeline threw', { err: e instanceof Error ? e.stack : String(e) });
1910
- if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' });
1911
- res.end(dev && e instanceof Error ? `webjs error: ${e.stack}` : 'Internal server error');
1912
- }
1913
- });
1914
-
1915
- // node:http built-in timeouts: `requestTimeout` bounds the time to receive the
1916
- // WHOLE request, `headersTimeout` just the headers (kept strictly under
1917
- // requestTimeout so it actually fires), `keepAliveTimeout` the idle window
1918
- // before a kept-alive socket is closed.
1919
- server.requestTimeout = timeouts.requestTimeout;
1920
- server.headersTimeout = timeouts.headersTimeout;
1921
- server.keepAliveTimeout = timeouts.keepAliveTimeout;
1922
-
1923
- // WebSocket upgrade handling: any route.js that exports `WS` becomes a
1924
- // WebSocket endpoint at its URL.
1925
- attachWebSocket(server, () => app.getRouteTable(), { dev, logger });
1926
-
1927
- server.listen(port, () => {
1928
- logger.info(`webjs ${dev ? 'dev' : 'prod'} server ready on http://localhost:${port}`);
1929
- // The server is now accepting connections; warm the first-request analysis
1930
- // in the background so a real first request finds it memoized. Fire-and-
1931
- // forget: listening (and thus readiness probes / load-balancer health) does
1932
- // not wait on it, and a failure here does not bring the process down.
1933
- app.warmup();
1934
- });
1935
-
1936
- const closeServer = () => new Promise((resolve, reject) => {
1937
- server.close((err) => (err ? reject(err) : resolve(undefined)));
1938
- });
1939
- const shutdown = makeShutdown({ closeServer, hub, logger });
1940
- process.once('SIGINT', () => shutdown('SIGINT'));
1941
- process.once('SIGTERM', () => shutdown('SIGTERM'));
1942
-
1943
- // Catch-all process handlers: log, but don't tear the process down on a
1944
- // single mishandled promise. Uncaught exceptions are different: state may be
1945
- // corrupted, so log + start an orderly shutdown rather than continuing.
1946
- installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
1947
-
1948
- return {
1949
- server,
1950
- close: () => new Promise((r) => {
1951
- if (watcherAbort) watcherAbort.abort();
1952
- // Clear the shared SSE keepalive timer so repeated startServer/close cycles
1953
- // (e.g. across a test run) don't accumulate live intervals.
1954
- hub.closeAll();
1955
- server.close(() => r());
1956
- }),
1957
- };
1958
- }
1959
-
1960
- /**
1961
- * The core request → response pipeline, minus middleware.
1962
- * @param {Request} req
1963
- * @param {{state: any, appDir: string, coreDir: string, dev: boolean}} ctx
1964
- */
1965
- /**
1966
- * Serve framework-internal static assets that depend on NEITHER the whole-app
1967
- * analysis NOR the vendor importmap: the `@webjsdev/core` runtime files, the
1968
- * dev reload client, and (in `--download` pin mode) the committed vendor
1969
- * bundles. `handle()` calls this BEFORE `ensureReady()`, so a cold instance
1970
- * returns them immediately instead of blocking on the first vendor resolve
1971
- * (issue #190). The core bundle is on every page's boot path, so coupling it
1972
- * to the jspm resolve stalled first interactivity site-wide on a cold instance.
1973
- *
1974
- * Like the health / readiness probes (also answered pre-`ensureReady`), these
1975
- * bypass app middleware. That is correct: they are framework infrastructure the
1976
- * app needs to function, not app routes, and `state.middleware` is not even
1977
- * loaded until `ensureReady()` completes.
1978
- *
1979
- * @param {string} path decoded pathname
1980
- * @param {string} method upper-cased HTTP method
1981
- * @param {{ coreDir: string, appDir: string, dev: boolean, versioned?: boolean }} ctx
1982
- * `versioned` is true when the request carried a `?v=` query (a
1983
- * content-hash-fingerprinted url, issue #243); the core module is then served
1984
- * `immutable` (1 year) instead of the 1h fallback, since the hash in the url
1985
- * busts the cache on the next deploy.
1986
- * @returns {Promise<Response|null>} a Response, or null when path is not one of these assets
1987
- */
1988
- async function tryServeFrameworkStatic(path, method, ctx) {
1989
- const { coreDir, appDir, dev, versioned } = ctx;
1990
-
1991
- // Dev live-reload client.
1992
- if (path === '/__webjs/reload.js') {
1993
- if (!dev) return new Response('Not found', { status: 404 });
1994
- return new Response(reloadClientJs(basePath()), {
1995
- headers: { 'content-type': 'application/javascript; charset=utf-8' },
1996
- });
1997
- }
1998
-
1999
- // Dev live-reload SharedWorker: one shared connection for all tabs (#887).
2000
- if (path === '/__webjs/reload-worker.js') {
2001
- if (!dev) return new Response('Not found', { status: 404 });
2002
- return new Response(reloadWorkerJs(basePath()), {
2003
- headers: { 'content-type': 'application/javascript; charset=utf-8' },
2004
- });
2005
- }
2006
-
2007
- // Core module: /__webjs/core/*
2008
- //
2009
- // ETag + ~1h max-age, NOT immutable. The URL path is un-versioned
2010
- // (`/__webjs/core/src/render-client.js` etc.), so bumping `@webjsdev/core`
2011
- // ships different bytes at the same URL. An `immutable` cache-control
2012
- // directive at an edge CDN (Cloudflare, Vercel, Fly) keeps the prior bytes
2013
- // pinned for up to a year, which silently bricks the next deploy: browsers
2014
- // load the old client renderer against a server emitting the new SSR shape,
2015
- // and any exports added in the bump (e.g., the slot.js entry points landed
2016
- // for 0.6.0) resolve to undefined in the cached file.
2017
- // Regression: 2026-05-20, ui.webjs.dev tier-2 components after
2018
- // @webjsdev/core 0.5.0 -> 0.6.0 republish.
2019
- if (path.startsWith('/__webjs/core/')) {
2020
- const rel = path.slice('/__webjs/core/'.length);
2021
- const abs = resolve(coreDir, rel);
2022
- // Trailing-separator boundary check, not a raw string prefix: a raw
2023
- // `startsWith(coreDir)` would admit a sibling like `@webjsdev/core-evil`,
2024
- // reachable via an encoded slash (`..%2f`, which survives URL normalization
2025
- // and then decodes to `../`). Match the public-root branch's guard.
2026
- if (abs !== coreDir && !abs.startsWith(coreDir + sep)) {
2027
- return new Response('forbidden', { status: 403 });
2028
- }
2029
- // A `?v=<hash>` request is content-addressed, so serve it `immutable` (#243):
2030
- // the hash busts the cache on the next core bump, so the regression the
2031
- // un-versioned 1h cap guards against (a stale immutable core after a bump)
2032
- // cannot recur. An un-fingerprinted request keeps the 1h fallback.
2033
- return fileResponse(abs, { dev, immutable: !!versioned });
2034
- }
2035
-
2036
- // Vendor URL handler for `webjs vendor pin --download` mode only. In default
2037
- // pin mode (or no-pin mode) the importmap routes bare imports straight to
2038
- // ga.jspm.io URLs and the browser bypasses this server entirely. When the
2039
- // user ran `webjs vendor pin --download`, the importmap has local
2040
- // `/__webjs/vendor/<file>.js` URLs and this serves the committed bundle files
2041
- // from `.webjs/vendor/`. These are read-only static content: allow GET/HEAD
2042
- // for the normal fetch, OPTIONS for any cross-origin preflight (204 with the
2043
- // same Allow header rather than 405, which some intermediaries treat as a
2044
- // hard failure even for a CORS probe), and 405 everything else.
2045
- if (path.startsWith('/__webjs/vendor/') && path.endsWith('.js')) {
2046
- if (method === 'OPTIONS') {
2047
- return new Response(null, { status: 204, headers: { allow: 'GET, HEAD, OPTIONS' } });
2048
- }
2049
- if (method !== 'GET' && method !== 'HEAD') {
2050
- return new Response(null, { status: 405, headers: { allow: 'GET, HEAD, OPTIONS' } });
2051
- }
2052
- const filename = path.slice('/__webjs/vendor/'.length);
2053
- const resp = await serveDownloadedBundle(filename, appDir, dev);
2054
- if (method === 'HEAD') {
2055
- // HEAD must return same headers as GET with no body.
2056
- return new Response(null, { status: resp.status, headers: resp.headers });
2057
- }
2058
- return resp;
2059
- }
2060
-
2061
- return null;
2062
- }
2063
-
2064
- async function handleCore(req, ctx) {
2065
- const { state, appDir, coreDir, dev, reportError, reportDevError, hasOnError, logger, cspEnabled, allowedOrigins } = ctx;
2066
- const url = new URL(req.url);
2067
- // Decode percent-encoded characters so filesystem lookups match real
2068
- // filenames. Dynamic route segments like `[slug]` and route groups like
2069
- // `(marketing)` contain chars that browsers percent-encode in URLs
2070
- // (`%5B`, `%5D`, `%28`, `%29`). Without decoding, the server joins the
2071
- // encoded path with the app directory → file not found → 404 → no JS
2072
- // loads → no interactivity.
2073
- let path;
2074
- try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
2075
- const method = req.method.toUpperCase();
2076
- // Content-hash fingerprint (#243): a `?v=` query marks a content-addressed
2077
- // asset url that may be served `immutable` (the hash busts the cache on a
2078
- // byte change). The pathname (query stripped by `url.pathname`) resolves the
2079
- // file as today; only the cache header changes when `?v` is present.
2080
- const versioned = url.searchParams.has('v');
2081
-
2082
- // Health / readiness probes (`/__webjs/health`, `/__webjs/ready`) and the
2083
- // framework-internal static assets (`/__webjs/core/*`, `/__webjs/reload.js`,
2084
- // `/__webjs/reload-worker.js`, downloaded `/__webjs/vendor/*`) are served in
2085
- // `handle()` BEFORE ensureReady,
2086
- // so they are not repeated here. This fallback covers the (currently
2087
- // unreachable) case of handleCore being entered for one of those assets, so
2088
- // the routing stays correct if a future caller bypasses the early path.
2089
- const frameworkStatic = await tryServeFrameworkStatic(path, method, { coreDir, appDir, dev, versioned });
2090
- if (frameworkStatic) return frameworkStatic;
2091
-
2092
- // Internal server-action RPC endpoint
2093
- const actMatch = /^\/__webjs\/action\/([a-f0-9]+)\/([A-Za-z0-9_$]+)$/.exec(path);
2094
- if (actMatch) {
2095
- // HTTP-verb actions (#488): any RPC verb may hit the endpoint; invokeAction
2096
- // enforces the action's DECLARED method (405 + Allow on a mismatch) and
2097
- // reads args from the URL (GET/DELETE) or the body (POST/PUT/PATCH).
2098
- if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
2099
- return new Response('method not allowed', { status: 405 });
2100
- }
2101
- // Pass the onError sink (issue #239): a server action that throws
2102
- // unexpectedly is reported to the APM hook before the sanitized 500.
2103
- const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
2104
- return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError, allowedOrigins);
2105
- }
2106
-
2107
- // Static: /public/*, plus a small set of ROOT assets that must serve at the
2108
- // site root even though they live under public/. A service worker registered
2109
- // at /sw.js scopes to the origin root, so it MUST serve at / (not
2110
- // /public/sw.js), and so must its offline fallback. Same remap shape as the
2111
- // /favicon.ico special-case below. (#830)
2112
- const ROOT_ASSETS = { '/sw.js': '/public/sw.js', '/offline.html': '/public/offline.html' };
2113
- if (path.startsWith('/public/') || path === '/favicon.ico' || path in ROOT_ASSETS) {
2114
- const p = path === '/favicon.ico' ? '/public/favicon.ico' : (ROOT_ASSETS[path] || path);
2115
- const abs = join(appDir, p);
2116
- // Containment check. `join` normalises `..` segments, so a path
2117
- // like `/public/%2E%2E/secret/x.svg` decodes (after URL parsing,
2118
- // which doesn't touch `%2E`) to `/public/../secret/x.svg` and
2119
- // `join(appDir, ...)` resolves it to `appDir/secret/x.svg`. The
2120
- // resulting `abs` could be inside `appDir` but OUTSIDE `appDir/
2121
- // public/`, exposing files the user reasonably thought were
2122
- // private under their non-public directories. Reject anything
2123
- // that doesn't stay under `appDir/public/` (and the favicon
2124
- // exception, which is already validated above).
2125
- const publicRoot = join(appDir, 'public') + sep;
2126
- if (!abs.startsWith(publicRoot)) {
2127
- return new Response(null, { status: 404 });
2128
- }
2129
- // On-request regeneration (#967): in dev, if a `webjs.dev.regenerate` rule
2130
- // matches this output and it is stale (a source is newer, or it is missing),
2131
- // rebuild it to completion BEFORE serving, so a newly added utility class is
2132
- // never served stale. No-op when no rule matches or the output is fresh, and
2133
- // never runs in prod (rules are empty there). This replaces the fragile
2134
- // `tailwindcss --watch` that could die mid-session and serve stale CSS.
2135
- if (dev && state.regenerateRules.length) {
2136
- await maybeRegenerate(appDir, p.replace(/^\/+/, ''), state.regenerateRules);
2137
- }
2138
- // A `?v=<hash>` public asset is content-addressed -> immutable (#243).
2139
- if (await exists(abs)) {
2140
- const res = await fileResponse(abs, { dev, immutable: versioned });
2141
- // A worker served below its registration path only controls that subtree
2142
- // unless the response opts it up to the root scope. (#830)
2143
- if (path === '/sw.js') res.headers.set('Service-Worker-Allowed', '/');
2144
- return res;
2145
- }
2146
- }
2147
-
2148
- // User source modules (served as ES modules, with action-file rewriting).
2149
- //
2150
- // Authorization gate: only files reachable from a browser-bound entry
2151
- // (page, layout, error, loading, not-found, component) via the module
2152
- // graph are servable. Same posture as Next.js, where the bundler's
2153
- // manifest is the source of truth for what the browser may fetch.
2154
- // Anything not in the set (node_modules/, top-level package.json,
2155
- // scripts/, etc.) 404s here regardless of whether the file exists on
2156
- // disk. The `.server.{js,ts}` stub guardrail runs below as a
2157
- // defense-in-depth layer.
2158
- if (method === 'GET' && /\.(js|mjs|ts|mts|css|svg|png|jpg|jpeg|gif|webp|json|ico|txt)$/.test(path)) {
2159
- let abs = join(appDir, path);
2160
- // When the browser asks for `.js`, allow falling through to a sibling
2161
- // `.ts` (the TypeScript-with-"allowImportingTsExtensions: false" pattern).
2162
- if (!(await exists(abs)) && /\.js$/.test(abs)) {
2163
- const tsAbs = abs.replace(/\.js$/, '.ts');
2164
- if (await exists(tsAbs)) abs = tsAbs;
2165
- else {
2166
- const mtsAbs = abs.replace(/\.js$/, '.mts');
2167
- if (await exists(mtsAbs)) abs = mtsAbs;
2168
- }
2169
- }
2170
- // Gate: must be in the browser-bound module graph. Server-action
2171
- // files (.server.{js,ts}) get a stub via the guardrail below; they
2172
- // ARE included in browserBoundFiles because client code imports
2173
- // them by path (the import rewrites to an RPC stub at request time).
2174
- // In test mode any app file is servable (see the `state.testMode` note
2175
- // above); otherwise the file must be in the browser-bound module graph.
2176
- const inGraph = state.testMode || (state.browserBoundFiles && state.browserBoundFiles.has(abs));
2177
- // Containment: `abs` must be appDir itself or genuinely UNDER it. The
2178
- // trailing `sep` (matching the public-asset branch) stops a `..` path that
2179
- // resolves to a sibling sharing the appDir name-prefix (`/x/app` ->
2180
- // `/x/app-secrets/...`) from passing in test mode, where graph membership
2181
- // is not the gate.
2182
- const underAppDir = abs === appDir || abs.startsWith(appDir + sep);
2183
- if (underAppDir && inGraph && (await exists(abs))) {
2184
- // Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
2185
- // MUST NEVER be served as source to the browser. The extension is
2186
- // the path-level boundary; we re-verify it on every request (not
2187
- // just rely on the action-index snapshot, which is built on the first
2188
- // request and refreshed on rebuild) so files created later, FS races,
2189
- // or developer error never punch through.
2190
- //
2191
- // What the browser gets depends on the file's `'use server'` status:
2192
- // - With `'use server'` => server action: a generated RPC stub
2193
- // whose exports POST to /__webjs/action/:hash/:fn.
2194
- // - Without `'use server'` => server-only utility: a stub that
2195
- // throws at module load with a clear error. The file's source
2196
- // never reaches the browser either way.
2197
- if (isServerFile(abs)) {
2198
- if (await hasUseServerDirective(abs)) {
2199
- // Lazily ensure the index knows about this file so serveActionStub
2200
- // can mint a stable hash and function list.
2201
- if (!state.actionIndex.fileToHash.has(abs)) {
2202
- const h = await hashFile(abs);
2203
- state.actionIndex.fileToHash.set(abs, h);
2204
- state.actionIndex.hashToFile.set(h, abs);
2205
- }
2206
- const stub = await serveActionStub(state.actionIndex, abs);
2207
- return new Response(stub, {
2208
- headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
2209
- });
2210
- }
2211
- const relPath = relative(appDir, abs);
2212
- const stub = serveServerOnlyStub(relPath);
2213
- return new Response(stub, {
2214
- headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
2215
- });
2216
- }
2217
- // TypeScript source: strip types via Node 24+'s built-in, cache by mtime.
2218
- // Both module paths also strip side-effect imports of display-only
2219
- // components so the browser never downloads their JS.
2220
- const elideOpts = {
2221
- moduleGraph: state.moduleGraph,
2222
- elidableComponents: state.elidableComponents,
2223
- appDir,
2224
- };
2225
- // A `?v=<hash>` app-module request is content-addressed -> immutable
2226
- // (#243); an un-fingerprinted request keeps the 1h fallback.
2227
- if (/\.m?ts$/.test(abs)) {
2228
- return tsResponse(abs, dev, elideOpts, state.tsCache, versioned, reportDevError);
2229
- }
2230
- if (/\.m?js$/.test(abs)) {
2231
- return jsModuleResponse(abs, dev, elideOpts, versioned);
2232
- }
2233
- return fileResponse(abs, { dev, immutable: versioned });
2234
- }
2235
- // Dev hint (#751): the request is for a real app source module that EXISTS
2236
- // on disk but is NOT in the browser-bound graph, so the gate 404s it. The
2237
- // most common cause is a dynamic `import()` the static scanner cannot
2238
- // track: a string-literal `import('./x.ts')` IS tracked and servable, but a
2239
- // computed `import(expr)` / `import('./' + name)` cannot be resolved
2240
- // statically and falls through here. Surface the likely cause instead of a
2241
- // bare 404 so the author is not left guessing. Dev-only and diagnostic; it
2242
- // does not change the 404 status, only the body + a server log line.
2243
- if (dev && abs.startsWith(appDir) && /\.m?[jt]s$/.test(abs) && (await exists(abs))) {
2244
- const rel = relative(appDir, abs);
2245
- const hint = `[webjs] 404: ${rel} exists but is not reachable from any browser-bound entry, so it is not servable. If you load it via a dynamic import(), use a STRING-LITERAL specifier (e.g. import('./x.ts')) so the scanner can track it; a computed import(expr) cannot be resolved statically and will 404. Otherwise this module is simply unreferenced by client code.`;
2246
- console.warn(hint);
2247
- return new Response(hint, { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' } });
2248
- }
2249
- }
2250
-
2251
- // Metadata routes: /sitemap.xml, /robots.txt, /icon, /opengraph-image, etc.
2252
- if (method === 'GET' && state.routeTable.metadataRoutes) {
2253
- const meta = state.routeTable.metadataRoutes.find((r) => r.urlPath === path);
2254
- if (meta) {
2255
- try {
2256
- const mod = await import(pathToFileURL(meta.file).toString() + (dev ? `?t=${Date.now()}` : ''));
2257
- if (mod.default) {
2258
- const result = await mod.default();
2259
- // If the function returns a Response, use it directly.
2260
- if (result instanceof Response) return result;
2261
- // If it returns a string, determine content type from the URL path.
2262
- const ct = path.endsWith('.xml') ? 'application/xml; charset=utf-8'
2263
- : path.endsWith('.txt') ? 'text/plain; charset=utf-8'
2264
- : path.endsWith('.json') ? 'application/json; charset=utf-8'
2265
- : 'application/octet-stream';
2266
- return new Response(typeof result === 'string' ? result : JSON.stringify(result), {
2267
- headers: { 'content-type': ct, 'cache-control': dev ? 'no-cache' : 'public, max-age=3600' },
2268
- });
2269
- }
2270
- } catch (e) {
2271
- if (reportError) reportError(e, req, 'metadata');
2272
- if (dev) console.error(`[webjs] metadata route error (${meta.stem}):`, e);
2273
- return new Response('Internal error', { status: 500 });
2274
- }
2275
- }
2276
- }
2277
-
2278
- // API route (route.js handler)
2279
- const api = matchApi(state.routeTable, path);
2280
- if (api) {
2281
- const handler = () => handleApi(api.route, api.params, req, dev);
2282
- return runWithSegmentMiddleware(req, api.route.middlewares, handler, dev);
2283
- }
2284
-
2285
- // Page route. GET/HEAD render the page. A NON-GET/HEAD method (POST/PUT/…)
2286
- // is a form submission (#1155): the `__webjs_action` hidden field names the
2287
- // server action to run, it runs inside the page's segment middleware, and the
2288
- // result either PRG-redirects (303), re-renders the same page (422) with
2289
- // field errors, or honors a thrown redirect()/notFound(). A non-GET carrying
2290
- // no action identity is a 405 on a path that exists but only renders.
2291
- {
2292
- const page = matchPage(state.routeTable, path);
2293
- if (page) {
2294
- // The URL this render is FOR, in the form the browser sees it (#1047), so
2295
- // the overlay's scope gate can compare it against `location.pathname +
2296
- // location.search`. Two details are load-bearing: the RAW `url.pathname`
2297
- // (not the decoded `path`) is used, because `location.pathname` is
2298
- // percent-encoded too; and the base path is put back, because the ingress
2299
- // strip already removed it from `url` while the browser's location still
2300
- // carries it, so without this the gate would suppress every overlay on a
2301
- // sub-path deploy.
2302
- const devErrorUrl = withBasePath(url.pathname, basePath()) + url.search;
2303
- // A speculative link prefetch must never raise an overlay (#1047): the
2304
- // user is only hovering a link to a page that throws, and the page they
2305
- // are actually looking at is fine. Dropping the hook also keeps the throw
2306
- // out of `state.lastDevError`, so the SSE replay cannot hand it to a
2307
- // freshly-connected tab either. The APM `onError` sink below still fires,
2308
- // because the render really did throw.
2309
- const isPrefetch = req.headers.get('x-webjs-prefetch') === '1';
2310
- const ssrOpts = {
2311
- dev, appDir, moduleGraph: state.moduleGraph,
2312
- serverFiles: state.actionIndex.fileToHash,
2313
- elidableComponents: state.elidableComponents,
2314
- inertRouteModules: state.inertRouteModules,
2315
- importOnlyRouteModules: state.importOnlyRouteModules,
2316
- notFoundFile: state.routeTable.notFound,
2317
- // Root-only boundaries (#848): the app-wide catch-all error page and the
2318
- // unmatched-anywhere 404, rendered by ssr.js when a nested boundary is
2319
- // absent.
2320
- globalError: state.routeTable.globalError,
2321
- globalNotFound: state.routeTable.globalNotFound,
2322
- // instrumentation-client.{js,ts} (#848): imported first in the client
2323
- // boot so it runs before app modules.
2324
- instrumentationClient: state.routeTable.instrumentationClient,
2325
- // Server HTML cache (#241): a CSP-enabled page emits a fresh
2326
- // per-request nonce into its body, so its bytes vary per request and
2327
- // it must never be HTML-cached. Pass the flag so the cache guard skips
2328
- // it. CSP is off by default, so the common case stays cacheable.
2329
- cspEnabled,
2330
- // onError sink (issue #239): a page render error that becomes a 500 is
2331
- // reported to the APM hook with the active request's correlation id.
2332
- onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
2333
- // Dev error overlay (#264): a render crash pushes a frame to the open
2334
- // tab. Dev-only (reportDevError early-returns in prod), so no source
2335
- // leaks. Distinct from onError (the APM sink), which always fires.
2336
- onDevError: dev && !isPrefetch
2337
- ? (e) => reportDevError(e, { kind: 'render', url: devErrorUrl })
2338
- : undefined,
2339
- };
2340
- if (method === 'GET' || method === 'HEAD') {
2341
- // #1307: `__webjs_action` in the QUERY STRING means a submission
2342
- // carrying a bound action's identity went out as a GET, so the action
2343
- // never ran. Nothing else in the framework puts that reserved field in
2344
- // a url. Detect only, so the render below is unchanged: answering a GET
2345
- // differently because of a query parameter would hand any visitor a way
2346
- // to turn any page into an error.
2347
- reportFormSubmittedAsGet(
2348
- url, req,
2349
- // Gated on hasOnError, not on `reportError`: that is always a
2350
- // function here and no-ops internally, so passing it would spend a
2351
- // dedupe slot on a report nobody receives.
2352
- hasOnError ? (e) => reportError(e, req, 'action') : undefined,
2353
- logger, dev, page.route,
2354
- );
2355
- // A successful render of URL U supersedes a RETAINED render error for
2356
- // that same URL (#1047), so a reconnecting tab is not handed a frame the
2357
- // page has since recovered from. Keyed on BOTH frame identity and url:
2358
- // identity so a frame this very render just reported is never wiped, url
2359
- // so a good render of an unrelated page cannot erase an error that is
2360
- // still current for the page the user is looking at (the #893 gap the
2361
- // retention exists to close). Dev-only, and skipped for a prefetch,
2362
- // which is not the user's view of anything.
2363
- const handler = dev && !isPrefetch
2364
- ? async () => {
2365
- const before = state.lastDevError;
2366
- const resp = await ssrPage(page.route, page.params, url, { ...ssrOpts, req });
2367
- if (
2368
- before && state.lastDevError === before
2369
- && before.kind === 'render' && before.url === devErrorUrl
2370
- ) state.lastDevError = null;
2371
- return resp;
2372
- }
2373
- : () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
2374
- return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
2375
- }
2376
- // Every non-GET/HEAD to a page runs the page's segment middleware, and
2377
- // the dispatcher always answers with a Response (a 405 when there is no
2378
- // action to run), so a middleware that post-processes `await next()`
2379
- // never has to handle an absent one.
2380
- const deps = {
2381
- actionIndex: state.actionIndex,
2382
- allowedOrigins,
2383
- onError: hasOnError ? (e) => reportError(e, req, 'action') : undefined,
2384
- logger,
2385
- };
2386
- const handler = () => runFormAction(page.route, page.params, url, req, ssrOpts, deps);
2387
- return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
2388
- }
2389
- }
2390
-
2391
- // Fallback: content-negotiated 404
2392
- if (wantsJson(req, path)) {
2393
- return Response.json({ error: 'Not found', path }, { status: 404 });
2394
- }
2395
- // Unmatched anywhere: prefer the root not-found.{js,ts}, then a
2396
- // global-not-found.{js,ts} (#848), else the default 404 page.
2397
- return ssrNotFound(state.routeTable.notFound || state.routeTable.globalNotFound, { dev, appDir, req, url });
2398
- }
2399
-
2400
- /** @param {Request} req @param {string} path */
2401
- function wantsJson(req, path) {
2402
- const accept = req.headers.get('accept') || '';
2403
- if (accept.includes('application/json') && !accept.includes('text/html')) return true;
2404
- if (path.startsWith('/api/') || path.startsWith('/__webjs/')) return true;
2405
- return false;
2406
- }
2407
-
2408
- /**
2409
- * Chain segment-level middleware.js (outermost first) around a handler.
2410
- * Each middleware is `(req, next) => Response`. If any throws, log and 500.
2411
- *
2412
- * @param {Request} req
2413
- * @param {string[]} files absolute paths of middleware.js files, outermost → innermost
2414
- * @param {() => Promise<Response>} terminal
2415
- * @param {boolean} dev
2416
- */
2417
- async function runWithSegmentMiddleware(req, files, terminal, dev) {
2418
- if (!files || !files.length) return terminal();
2419
- const handlers = [];
2420
- for (const f of files) {
2421
- try {
2422
- const url = pathToFileURL(f).toString();
2423
- const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
2424
- const mod = await import(url + bust);
2425
- if (typeof mod.default === 'function') handlers.push(mod.default);
2426
- } catch {
2427
- // Bad middleware file: skip; top-level error handler will catch real problems.
2428
- }
2429
- }
2430
- let i = 0;
2431
- const next = () => {
2432
- if (i >= handlers.length) return terminal();
2433
- const fn = handlers[i++];
2434
- return fn(req, next);
2435
- };
2436
- return next();
2437
- }
2438
-
2439
- /**
2440
- * Root-middleware filename candidates, in resolution order.
2441
- *
2442
- * Every other routing convention accepts all four extensions (the router
2443
- * matches on the STEM, so `app/<segment>/middleware.ts` has always worked),
2444
- * and both the scaffold and the dev supervisor write / watch `middleware.ts`.
2445
- * This lookup used to be the single literal `middleware.js`, so a root
2446
- * `middleware.ts` was silently never loaded: no error, no warning, the app
2447
- * just ran with no global middleware. TypeScript is the documented default
2448
- * for an app, so that was the common case, and it went unnoticed because the
2449
- * failure is invisible (a missing middleware looks exactly like an app that
2450
- * has none). `.ts` is tried first to match the dev supervisor's order.
2451
- */
2452
- const ROOT_MIDDLEWARE_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
2453
-
2454
- /**
2455
- * Load the optional top-level `middleware.{ts,js,mts,mjs}`.
2456
- * @param {string} appDir
2457
- * @param {boolean} dev
2458
- * @param {import('./logger.js').Logger} logger
2459
- */
2460
- async function loadMiddleware(appDir, dev, logger) {
2461
- let file = null;
2462
- for (const name of ROOT_MIDDLEWARE_FILES) {
2463
- const candidate = join(appDir, name);
2464
- if (await exists(candidate)) { file = candidate; break; }
2465
- }
2466
- if (!file) return null;
2467
- const url = pathToFileURL(file).toString();
2468
- const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
2469
- try {
2470
- const mod = await import(url + bust);
2471
- return typeof mod.default === 'function' ? mod.default : null;
2472
- } catch (e) {
2473
- logger.error('failed to load root middleware', { file, err: String(e) });
2474
- return null;
2475
- }
2476
- }
2477
-
2478
- /**
2479
- * Install signal handlers that stop accepting new connections, close SSE
2480
- * clients, and exit once in-flight requests drain.
2481
- * @param {import('node:http').Server} server
2482
- * @param {Set<import('node:http').ServerResponse>} sseClients
2483
- * @param {import('./logger.js').Logger} logger
2484
- */
2485
- /**
2486
- * Create a plain HTTP/1.1 server. WebJs deploys are expected to sit
2487
- * behind a reverse proxy (PaaS edge, nginx, Caddy, etc.) that handles
2488
- * TLS termination and speaks HTTP/2 to clients: Node's http2 module
2489
- * doesn't need to be involved on the framework side.
2490
- *
2491
- * @param {(req: any, res: any) => void} handler
2492
- */
2493
- function makeHttpServer(handler) {
2494
- return createHttp1Server(handler);
2495
- }
2496
-
2497
- /* ------------ helpers ------------ */
2498
-
2499
- /** @param {import('node:http').IncomingMessage} req @param {URL} url */
2500
- function toWebRequest(req, url) {
2501
- const method = (req.method || 'GET').toUpperCase();
2502
- /** @type {Record<string,string>} */
2503
- const headers = {};
2504
- for (const [k, v] of Object.entries(req.headers)) {
2505
- // Drop HTTP/2 pseudo-headers (`:method`, `:path`, `:scheme`, `:authority`).
2506
- // They're parsed separately into req.method / req.url and are rejected
2507
- // by the standard Headers class if we pass them through verbatim.
2508
- if (k.startsWith(':')) continue;
2509
- // Strip any inbound `x-webjs-remote-ip` header so clients cannot
2510
- // spoof the framework-stamped client IP that rate-limit's
2511
- // `clientIp(req, { trustProxy: false })` reads. We rewrite it
2512
- // below from the actual TCP socket. Node's IncomingMessage
2513
- // always lowercases header keys, so a literal compare is enough.
2514
- if (k === 'x-webjs-remote-ip') continue;
2515
- headers[k] = Array.isArray(v) ? v.join(',') : String(v ?? '');
2516
- }
2517
- // Stamp the framework-trusted remote IP from the socket. Read by
2518
- // `clientIp(req)` (rate-limit.js) as the bucket key when
2519
- // `trustProxy: false` (the safe default).
2520
- const remoteIp = req.socket?.remoteAddress;
2521
- if (remoteIp) headers['x-webjs-remote-ip'] = remoteIp;
2522
- let body;
2523
- if (method !== 'GET' && method !== 'HEAD') {
2524
- body = new ReadableStream({
2525
- start(controller) {
2526
- req.on('data', (chunk) => controller.enqueue(chunk));
2527
- req.on('end', () => controller.close());
2528
- req.on('error', (e) => controller.error(e));
2529
- },
2530
- });
2531
- }
2532
- return new Request(url, /** @type any */ ({ method, headers, body, duplex: 'half' }));
2533
- }
2534
-
2535
- /**
2536
- * @param {import('node:http').ServerResponse} res
2537
- * @param {Response} webRes
2538
- * @param {import('node:http').IncomingMessage} [req]
2539
- * @param {{ compress?: boolean }} [opts]
2540
- */
2541
- async function sendWebResponse(res, webRes, req, opts) {
2542
- /** @type {Record<string,string | string[]>} */
2543
- const headers = {};
2544
- // Preserve multi-value headers (Set-Cookie) via getSetCookie when available.
2545
- if (typeof /** @type any */ (webRes.headers).getSetCookie === 'function') {
2546
- const cookies = /** @type any */ (webRes.headers).getSetCookie();
2547
- if (cookies.length) headers['set-cookie'] = cookies;
2548
- }
2549
- webRes.headers.forEach((v, k) => {
2550
- if (k === 'set-cookie') return;
2551
- headers[k] = v;
2552
- });
2553
-
2554
- // Negotiate compression via the SHARED seam (listener-core.js), so the node and
2555
- // Bun shells negotiate + compress identically (brotli > gzip > deflate, node:zlib
2556
- // both sides). Skip a body that is already content-encoded (a route.ts returning
2557
- // pre-compressed bytes), and merge into any pre-existing `Vary` rather than
2558
- // clobbering it (`isCompressible` already excludes `text/event-stream`).
2559
- let compressor = null;
2560
- if (opts?.compress && req && webRes.body && !headers['content-encoding'] && isCompressible(headers['content-type'])) {
2561
- const encoding = negotiateEncoding(req.headers['accept-encoding']);
2562
- compressor = createCompressor(encoding);
2563
- if (compressor) {
2564
- headers['content-encoding'] = encoding;
2565
- headers['vary'] = varyWithAcceptEncoding(typeof headers['vary'] === 'string' ? headers['vary'] : '');
2566
- delete headers['content-length'];
2567
- }
2568
- }
2569
-
2570
- res.writeHead(webRes.status, headers);
2571
- if (!webRes.body) { res.end(); return; }
2572
-
2573
- if (compressor) {
2574
- compressor.pipe(res);
2575
- const reader = webRes.body.getReader();
2576
- try {
2577
- while (true) {
2578
- const { done, value } = await reader.read();
2579
- if (done) break;
2580
- compressor.write(value);
2581
- }
2582
- } finally {
2583
- compressor.end();
2584
- }
2585
- return;
2586
- }
2587
-
2588
- const reader = webRes.body.getReader();
2589
- while (true) {
2590
- const { done, value } = await reader.read();
2591
- if (done) break;
2592
- res.write(value);
2593
- }
2594
- res.end();
2595
- }
2596
-
2597
- /**
2598
- * Read a file and return a Response with appropriate caching.
2599
- * Dev: no-cache (always revalidate).
2600
- * Prod: ETag + ~1h max-age for user files; `immutable` bumps to 1 year.
2601
- *
2602
- * @param {string} abs
2603
- * @param {{ dev: boolean, immutable: boolean }} opts
2604
- */
2605
- async function fileResponse(abs, opts) {
2606
- try {
2607
- let data = await readFile(abs);
2608
- // In dev an external watcher (tailwindcss --watch, esbuild, ...) rewrites a
2609
- // public asset with truncate-then-write, so the file is 0 bytes for a short
2610
- // window during a rebuild. A hot reload that lands in that window would
2611
- // serve empty CSS / JS and the page paints unstyled (#891). Close that
2612
- // 0-byte window: when an empty read comes from a file that was JUST modified
2613
- // (the mid-rewrite signal), re-read a few times so the truncated read does
2614
- // not reach the browser. A genuinely empty, untouched asset is served
2615
- // immediately (no delay), and a non-zero-but-partial write is a smaller
2616
- // residual gap this does not cover. Prod has no such watcher and is
2617
- // left untouched.
2618
- if (opts.dev && data.length === 0) {
2619
- let midRewrite = false;
2620
- try { midRewrite = Date.now() - (await stat(abs)).mtimeMs < 500; } catch { /* gone */ }
2621
- for (let i = 0; midRewrite && i < 12 && data.length === 0; i++) {
2622
- await new Promise((r) => setTimeout(r, 20));
2623
- try { data = await readFile(abs); } catch { break; }
2624
- }
2625
- }
2626
- const type = MIME[extname(abs).toLowerCase()] || 'application/octet-stream';
2627
- // The body is fully buffered (read into `data`), so opt it into the
2628
- // conditional-GET funnel, which is the single place that hashes the bytes
2629
- // into a weak ETag and honors If-None-Match -> 304 (dev + prod alike).
2630
- const headers = { 'content-type': type, [BUFFERED_MARKER]: '1' };
2631
- headers['cache-control'] = opts.dev
2632
- ? 'no-cache'
2633
- : opts.immutable
2634
- ? 'public, max-age=31536000, immutable'
2635
- : 'public, max-age=3600';
2636
- return new Response(data, { status: 200, headers });
2637
- } catch {
2638
- return new Response('Not found', { status: 404 });
2639
- }
2640
- }
2641
-
2642
- /**
2643
- * Serve a plain `.js` / `.mjs` browser module, stripping side-effect
2644
- * imports of display-only components. Mirrors {@link fileResponse}'s
2645
- * headers but reads as text so the source can be transformed. Used only
2646
- * for files that exist as `.js` on disk (TS apps usually hit
2647
- * {@link tsResponse} via the .js to .ts sibling rewrite instead).
2648
- *
2649
- * @param {string} abs
2650
- * @param {boolean} dev
2651
- * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} elideOpts
2652
- * @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
2653
- * serve `immutable` (1 year) instead of the 1h fallback. Dev stays `no-cache`.
2654
- */
2655
- async function jsModuleResponse(abs, dev, elideOpts, immutable) {
2656
- let source;
2657
- try { source = await readFile(abs, 'utf8'); }
2658
- catch { return new Response('Not found', { status: 404 }); }
2659
- let code = elideImportsFromSource(
2660
- source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
2661
- );
2662
- // Version same-origin relative import specifiers so the URL the browser
2663
- // fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
2664
- // A no-op in dev (fingerprinting disabled).
2665
- code = versionModuleImports(code, abs);
2666
- // Buffered (string) body, so opt into the conditional-GET funnel for the
2667
- // weak ETag + 304 (see fileResponse).
2668
- const headers = {
2669
- 'content-type': 'application/javascript; charset=utf-8',
2670
- 'cache-control': dev ? 'no-cache' : immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600',
2671
- [BUFFERED_MARKER]: '1',
2672
- };
2673
- return new Response(code, { status: 200, headers });
2674
- }
2675
-
2676
- async function exists(p) {
2677
- try { await stat(p); return true; } catch { return false; }
2678
- }
2679
-
2680
- /**
2681
- * Strip TypeScript types from `source` via Node's built-in
2682
- * `module.stripTypeScriptTypes`. Position-preserving whitespace
2683
- * replacement: no sourcemap is needed because every (line, column)
2684
- * maps to itself in the source.
2685
- *
2686
- * Only erasable TypeScript is supported. Non-erasable syntax
2687
- * (`enum`, `namespace` with values, parameter properties, legacy
2688
- * decorators with `emitDecoratorMetadata`, `import = require`)
2689
- * throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` from Node and the
2690
- * dev server returns the error to the caller. The
2691
- * `erasable-typescript-only` and `no-non-erasable-typescript` lint
2692
- * rules catch these at edit time. There is no bundler fallback;
2693
- * WebJs is buildless end-to-end.
2694
- *
2695
- * The backend is the runtime-appropriate stripper (#508): Node 24+'s built-in
2696
- * `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, equally
2697
- * position-preserving), resolved once via `./ts-strip.js`.
2698
- *
2699
- * @param {string} source
2700
- * @param {string} _abs (unused; preserved for symmetry with prior signature)
2701
- * @returns {Promise<string>}
2702
- */
2703
- async function stripTs(source, _abs) {
2704
- return stripTypeScript(source);
2705
- }
2706
-
2707
- /**
2708
- * Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
2709
- * Result is cached by mtime in the handler's own `cache` so subsequent
2710
- * requests are instant; a file edit invalidates naturally. `elideOpts`
2711
- * additionally strips side-effect imports of display-only components from
2712
- * the served code, which is exactly why `cache` is the per-handler
2713
- * `state.tsCache` and not a module-global: the cached bytes bake in this
2714
- * handler's elision verdict.
2715
- *
2716
- * @param {string} abs
2717
- * @param {boolean} dev
2718
- * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} [elideOpts]
2719
- * @param {Map<string, { mtimeMs: number, code: string, map: string | null }>} cache the handler's `state.tsCache`
2720
- * @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
2721
- * serve `immutable` (1 year) instead of the 1h fallback. The cached BODY is
2722
- * the same bytes regardless; only the cache header varies. Dev stays `no-cache`.
2723
- */
2724
- async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDevError) {
2725
- // The body bytes are identical with or without `?v`; only the cache header
2726
- // changes, so the per-mtime cache stays a single entry.
2727
- const cacheControl = dev ? 'no-cache' : immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
2728
- const st = await stat(abs);
2729
- const cached = cache.get(abs);
2730
- if (cached && cached.mtimeMs === st.mtimeMs) {
2731
- return new Response(cached.code, {
2732
- headers: {
2733
- 'content-type': 'application/javascript; charset=utf-8',
2734
- 'cache-control': cacheControl,
2735
- [BUFFERED_MARKER]: '1',
2736
- },
2737
- });
2738
- }
2739
- const source = await readFile(abs, 'utf8');
2740
- let code;
2741
- try {
2742
- code = await stripTs(source, abs);
2743
- } catch (err) {
2744
- // Node's stripTypeScriptTypes throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
2745
- // for enum, namespace with values, parameter properties, legacy
2746
- // decorators with emitDecoratorMetadata, and import = require.
2747
- // Return a clean 500 with the file path and a pointer at the
2748
- // erasable-typescript-only lint rule rather than letting the
2749
- // error bubble up unstyled.
2750
- if (err && err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
2751
- // Log full detail server-side regardless of mode so operators
2752
- // see what went wrong in their logs.
2753
- // eslint-disable-next-line no-console
2754
- console.error(`[webjs] non-erasable TypeScript in ${abs}: ${err.message}`);
2755
- // Dev error overlay (#264): a TS strip failure breaks only the CLIENT
2756
- // module fetch (the page still SSRs, so hydration is silently dead and
2757
- // the hint below is buried in a JS comment). Push a frame so the open tab
2758
- // shows the overlay with the offending file + the no-non-erasable hint.
2759
- reportDevError?.(err, {
2760
- kind: 'ts-strip',
2761
- file: abs,
2762
- hint: 'webjs is buildless: only erasable TypeScript is supported. Replace enum / namespace-with-values / parameter-property / legacy-decorator / import = require with their erasable equivalents. Run `webjs check` (no-non-erasable-typescript rule).',
2763
- });
2764
- const msg = dev
2765
- // Dev: include the file path and Node's error message so the
2766
- // developer's browser tooling can point them at the offending
2767
- // construct. Replace `*` + `/` with `*\\/` so a path or
2768
- // message containing the comment-close sequence cannot
2769
- // terminate the wrapper comment early.
2770
- ? `[webjs] non-erasable TypeScript in ${abs}: ${err.message}\n\n` +
2771
- `webjs is buildless: only erasable TS syntax is supported. ` +
2772
- `Replace enum / namespace / parameter-property / legacy-decorator / ` +
2773
- `import = require constructs with their erasable equivalents. ` +
2774
- `Run \`webjs check\` for guidance (no-non-erasable-typescript rule). ` +
2775
- `Docs: https://webjs.dev/docs/typescript`
2776
- // Prod: terse, no path leak, no Node-message leak (Node's
2777
- // message can include source snippets). Operators get the
2778
- // detail in server logs above.
2779
- : `[webjs] server error transforming a .ts response. Check server logs.`;
2780
- return new Response(`/* ${msg.replace(/\*\//g, '*\\/')} */`, {
2781
- status: 500,
2782
- headers: { 'content-type': 'application/javascript; charset=utf-8' },
2783
- });
2784
- }
2785
- throw err;
2786
- }
2787
- if (elideOpts) {
2788
- code = elideImportsFromSource(
2789
- code, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
2790
- );
2791
- }
2792
- // Version same-origin relative import specifiers so the URL the browser
2793
- // fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
2794
- // A no-op in dev (fingerprinting disabled). Cached with the elision result:
2795
- // PROD files are static within a deploy, so the baked `?v` stays correct.
2796
- code = versionModuleImports(code, abs);
2797
- // Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
2798
- if (cache.size >= TS_CACHE_MAX) {
2799
- const oldest = cache.keys().next().value;
2800
- cache.delete(oldest);
2801
- }
2802
- cache.set(abs, { mtimeMs: st.mtimeMs, code, map: null });
2803
- return new Response(code, {
2804
- headers: {
2805
- 'content-type': 'application/javascript; charset=utf-8',
2806
- 'cache-control': cacheControl,
2807
- [BUFFERED_MARKER]: '1',
2808
- },
2809
- });
2810
- }
2811
-
2812
- function debounce(fn, ms) {
2813
- let t;
2814
- return (...args) => {
2815
- clearTimeout(t);
2816
- t = setTimeout(() => fn(...args), ms);
2817
- };
2818
- }
2819
-
2820
- /**
2821
- * Walk the route table + component scanner to collect every file the
2822
- * browser may legitimately fetch as an ES module, then expand via the
2823
- * module graph into the full transitive closure.
2824
- *
2825
- * This is webjs's equivalent of Next.js's bundler-produced page
2826
- * manifest, derived lazily on the first request (and re-derived on every
2827
- * rebuild) instead of at compile time. The dev server's source-file branch uses the returned
2828
- * Set as an authorization gate: in-set → served (subject to the
2829
- * .server.{js,ts} stub guardrail); out-of-set → 404.
2830
- *
2831
- * Browser-bound entries:
2832
- * - page.{js,ts,mjs,mts} (re-runs on client for hydration)
2833
- * - layout.{js,ts,mjs,mts} (same)
2834
- * - error.{js,ts,mjs,mts} (same)
2835
- * - loading.{js,ts,mjs,mts} (same)
2836
- * - not-found.{js,ts,mjs,mts} (same)
2837
- * - component files discovered by the scanner (eager + lazy)
2838
- *
2839
- * Server-only entries (NOT in the set):
2840
- * - route.{js,ts} (API handlers, never fetched as JS module)
2841
- * - middleware.{js,ts}
2842
- * - metadata routes (sitemap.js, robots.js, manifest.js, …)
2843
- * - .server.{js,ts} files (browser gets a stub, not the source)
2844
- *
2845
- * Components are passed in (rather than rescanned) so the caller can
2846
- * share one scan with `primeComponentRegistry`. Saves a full
2847
- * appDir walk on each analysis (the first request and every rebuild).
2848
- *
2849
- * @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
2850
- * @param {Awaited<ReturnType<typeof buildModuleGraph>>} moduleGraph
2851
- * @param {Awaited<ReturnType<typeof scanComponents>>} components
2852
- * @param {string} appDir
2853
- * @returns {Set<string>}
2854
- */
2855
- /**
2856
- * Collect every page + layout file across the route table. These are the
2857
- * modules the client boot script imports, and thus the candidates for
2858
- * inert-route elision (dropping a module that does no client work).
2859
- * `route.{js,ts}` / middleware / metadata are excluded: they never ship.
2860
- *
2861
- * @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
2862
- * @returns {string[]}
2863
- */
2864
- function collectRouteModules(routeTable) {
2865
- /** @type {Set<string>} */
2866
- const mods = new Set();
2867
- for (const page of routeTable.pages || []) {
2868
- if (page.file) mods.add(page.file);
2869
- for (const f of page.layouts || []) mods.add(f);
2870
- }
2871
- return [...mods];
2872
- }
2873
-
2874
- function computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir) {
2875
- /** @type {Set<string>} */
2876
- const entries = new Set();
2877
- for (const page of routeTable.pages) {
2878
- if (page.file) entries.add(page.file);
2879
- for (const f of page.layouts || []) entries.add(f);
2880
- for (const f of page.errors || []) entries.add(f);
2881
- for (const f of page.loadings || []) entries.add(f);
2882
- for (const f of page.forbiddens || []) entries.add(f);
2883
- for (const f of page.unauthorizeds || []) entries.add(f);
2884
- }
2885
- if (routeTable.notFound) entries.add(routeTable.notFound);
2886
- if (routeTable.notFounds) {
2887
- for (const f of routeTable.notFounds.values()) entries.add(f);
2888
- }
2889
- if (routeTable.globalError) entries.add(routeTable.globalError);
2890
- if (routeTable.globalNotFound) entries.add(routeTable.globalNotFound);
2891
- // instrumentation-client is browser-bound (imported first in the boot), so it
2892
- // must be servable through the gate.
2893
- if (routeTable.instrumentationClient) entries.add(routeTable.instrumentationClient);
2894
- // Lazy components live in the registry but no page imports their
2895
- // class directly; the lazy-loader fetches their module URLs on
2896
- // viewport entry. Add every discovered component file as an entry so
2897
- // the graph walk covers both eager and lazy paths.
2898
- for (const c of components) entries.add(c.file);
2899
- return reachableFromEntries(moduleGraph, [...entries], appDir);
2900
- }
2901
-
2902
- /**
2903
- * List the app's top-level source directory names, for expanding a `#*`
2904
- * catch-all import alias into one browser importmap prefix scope per dir (#555).
2905
- * Excludes infra dirs that are never imported via `#` (node_modules, dotfiles,
2906
- * the build/vendor caches). A new top-level folder is picked up on the next boot
2907
- * (dev restarts on changes), so the alias stays zero-maintenance.
2908
- * @param {string} appDir
2909
- * @returns {string[]}
2910
- */
2911
- function appTopLevelDirs(appDir) {
2912
- const SKIP = new Set(['node_modules', 'dist', 'public']);
2913
- try {
2914
- return readdirSync(appDir, { withFileTypes: true })
2915
- .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !SKIP.has(e.name))
2916
- .map((e) => e.name);
2917
- } catch {
2918
- return [];
2919
- }
2920
- }
2921
-
2922
- /**
2923
- * Find the absolute directory of the `@webjsdev/core` package, regardless of
2924
- * whether we're running from the monorepo or an installed copy.
2925
- * @param {string} appDir
2926
- */
2927
- function locateCoreDir(appDir) {
2928
- try {
2929
- const require = createRequire(join(appDir, 'package.json'));
2930
- const pkgPath = require.resolve('@webjsdev/core/package.json');
2931
- return dirname(pkgPath);
2932
- } catch {}
2933
- const here = fileURLToPath(import.meta.url);
2934
- return resolve(here, '..', '..', '..', 'core');
2935
- }
2936
-
2937
- /**
2938
- * Find an npm package's installed root folder in the app's node_modules graph.
2939
- * @param {string} appDir
2940
- * @param {string} pkgName
2941
- * @returns {string | null}
2942
- */
2943
- function locatePackageDir(appDir, pkgName) {
2944
- // Many packages lock down `./package.json` in their exports field, so we
2945
- // resolve the bare specifier (always exported) and trim back to the
2946
- // folder named pkgName.
2947
- const match = '/node_modules/' + pkgName + '/';
2948
- const tryFrom = (from) => {
2949
- const require = createRequire(from);
2950
- const entry = require.resolve(pkgName).split(sep).join('/');
2951
- const at = entry.lastIndexOf(match);
2952
- if (at < 0) return null;
2953
- return entry.slice(0, at + match.length - 1).split('/').join(sep);
2954
- };
2955
- try { const d = tryFrom(join(appDir, 'package.json')); if (d) return d; } catch {}
2956
- try { const d = tryFrom(fileURLToPath(import.meta.url)); if (d) return d; } catch {}
2957
- return null;
2958
- }
2959
-
2960
- /**
2961
- * The dev live-reload client. The `EventSource` URL is a framework-emitted
2962
- * same-origin path, so it must carry the base path under a sub-path deploy
2963
- * (#256), like the importmap targets and the RPC stub. No-op when basePath
2964
- * is empty.
2965
- * @param {string} bp the normalized base path (`''` = no-op)
2966
- * @returns {string}
2967
- */
2968
- // The overlay renderer source, read once + `export`-stripped so it inlines as
2969
- // plain functions into the classic reload-client script. Sharing the one source
2970
- // (`dev-overlay.js`, which the browser test imports directly) means the test
2971
- // drives the EXACT code that ships, with no drift (#264).
2972
- const DEV_OVERLAY_SRC = readFileSync(new URL('./dev-overlay.js', import.meta.url), 'utf8')
2973
- .replace(/^export /gm, '');
2974
-
2975
- // The reload SharedWorker relay source (#887), read once + `export`-stripped so
2976
- // it inlines into the served worker script. Sharing the one source
2977
- // (`dev-reload-worker.js`, which the browser test imports directly) means the
2978
- // test drives the EXACT relay code that ships, with no drift (same as #264).
2979
- const RELOAD_WORKER_SRC = readFileSync(new URL('./dev-reload-worker.js', import.meta.url), 'utf8')
2980
- .replace(/^export /gm, '');
2981
-
2982
- function reloadClientJs(bp) {
2983
- // The overlay renderer uses textContent throughout (never innerHTML), so the
2984
- // error message / code frame can never inject markup (#264). Served only in
2985
- // dev (the /__webjs/reload.js branch 404s in prod), so it never reaches a
2986
- // production page.
2987
- //
2988
- // Every tab shares ONE live-reload connection through a SharedWorker (#887).
2989
- // The old client opened an `EventSource` per tab, and because the dev server
2990
- // is HTTP/1.1 and browsers cap concurrent connections per host at ~6, a
2991
- // handful of open tabs would hold every connection slot with idle SSE streams
2992
- // and later tabs could not even fetch their HTML. The SharedWorker holds the
2993
- // single `EventSource` (see reloadWorkerJs) and relays each `reload` /
2994
- // `webjs-error` to every tab. The overlay still renders on the main thread
2995
- // (a worker has no DOM), so the worker forwards only the raw frame data.
2996
- //
2997
- // Fallback: where `SharedWorker` is missing (some mobile browsers) or its
2998
- // construction throws (a strict dev CSP without `worker-src`), each tab opens
2999
- // its own `EventSource`, the original behaviour. Correct, just not shared.
3000
- const eventsUrl = JSON.stringify(withBasePath('/__webjs/events', bp));
3001
- const workerUrl = JSON.stringify(withBasePath('/__webjs/reload-worker.js', bp));
3002
- const versionUrl = JSON.stringify(withBasePath('/__webjs/version', bp));
3003
- return `// webjs dev reload client
3004
- ${DEV_OVERLAY_SRC}
3005
- function __webjsApplyError(data) {
3006
- let f; try { f = JSON.parse(data); } catch (_) { return; }
3007
- renderDevOverlay(f);
3008
- }
3009
- // Keep the overlay tracking the page actually on screen (#1047): a render frame
3010
- // is scoped to the URL that produced it, so navigating away takes it down, and a
3011
- // frame that arrived before the URL advanced goes up once it does. The gate
3012
- // itself lives inside renderDevOverlay, so __webjsApplyError needs no change.
3013
- installDevOverlayNavSync();
3014
- // Never reload INTO a server that is still restarting (Node's node --watch
3015
- // briefly kills the process on an edit), which would paint a style-less,
3016
- // half-rendered page (#893). Probe the lightweight /__webjs/version endpoint
3017
- // until it answers, THEN reload. Under an in-process reload the server is up,
3018
- // so the first probe passes and the reload is instant; under a restart the
3019
- // probes fail until the fresh server is listening, so the old page stays put
3020
- // until the new one is ready. Bounded, so a genuinely-dead server still
3021
- // reloads (and shows the browser's own error) rather than hanging forever.
3022
- function __webjsReloadWhenReady() {
3023
- var tries = 0;
3024
- function attempt() {
3025
- fetch(${versionUrl}, { cache: 'no-store' }).then(function (r) {
3026
- if (r && r.ok) location.reload(); else again();
3027
- }).catch(again);
3028
- }
3029
- function again() { if (++tries > 100) location.reload(); else setTimeout(attempt, 100); }
3030
- attempt();
3031
- }
3032
- function __webjsDirectEvents() {
3033
- // Same restart-reload as the SharedWorker relay (#893), for the per-tab
3034
- // fallback: the hello frame carries the server's per-process boot id, so a
3035
- // CHANGED id on reconnect means a real restart (reload), while a transient
3036
- // reconnect to the same process keeps the id (no spurious reload).
3037
- var lastBoot = null;
3038
- const es = new EventSource(${eventsUrl});
3039
- es.addEventListener('hello', (e) => {
3040
- if (lastBoot !== null && e.data !== lastBoot) __webjsReloadWhenReady();
3041
- lastBoot = e.data;
3042
- });
3043
- es.addEventListener('reload', () => __webjsReloadWhenReady());
3044
- es.addEventListener('webjs-error', (e) => __webjsApplyError(e.data));
3045
- }
3046
- try {
3047
- if (typeof SharedWorker !== 'undefined') {
3048
- const w = new SharedWorker(${workerUrl});
3049
- w.port.onmessage = (e) => {
3050
- const m = e.data || {};
3051
- if (m.type === 'reload') __webjsReloadWhenReady();
3052
- else if (m.type === 'webjs-error') __webjsApplyError(m.data);
3053
- };
3054
- w.port.start();
3055
- } else {
3056
- __webjsDirectEvents();
3057
- }
3058
- } catch (_) {
3059
- __webjsDirectEvents();
3060
- }
3061
- `;
3062
- }
3063
-
3064
- /**
3065
- * The dev live-reload SharedWorker. One instance is shared across every tab of
3066
- * the same origin (a SharedWorker is keyed by its script URL), so it holds the
3067
- * ONE `EventSource` to `/__webjs/events` and fans each event out to all tabs
3068
- * over their `MessagePort`s (#887). The `EventSource` URL carries the base path
3069
- * the same way the client's does (#256). Served only in dev.
3070
- * @param {string} bp the normalized base path (`''` = no-op)
3071
- * @returns {string}
3072
- */
3073
- function reloadWorkerJs(bp) {
3074
- return `// webjs dev reload worker (one shared connection for all tabs)
3075
- ${RELOAD_WORKER_SRC}
3076
- startReloadWorker(self, EventSource, ${JSON.stringify(withBasePath('/__webjs/events', bp))});
3077
- `;
3078
- }
2
+ * @module @webjsdev/server/dev
3
+ * Thin barrel re-exporting dev configuration, request handler, and server launching modules.
4
+ */
5
+
6
+ export {
7
+ readElideEnabled,
8
+ readSeedEnabled,
9
+ readClientRouterEnabled,
10
+ readHeaderRules,
11
+ readRedirectRules,
12
+ readTrailingSlashFromApp,
13
+ readBasePathFromApp,
14
+ warnOnInvalidWebjsConfig,
15
+ readAllowedOriginsFromApp,
16
+ readCspConfigFromApp,
17
+ readBodyLimitsFromApp,
18
+ readDevWatchPathsFromApp,
19
+ readServerTimeoutsFromApp,
20
+ } from './dev/config.js';
21
+
22
+ export { createRequestHandler } from './dev/handler.js';
23
+ export { startServer, shouldIgnoreWatchPath } from './dev/server.js';