@webjsdev/server 0.8.63 → 0.8.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +7 -2
- package/package.json +2 -2
- package/src/browser-entries.js +64 -0
- package/src/check/helpers.js +365 -0
- package/src/check/rules-actions.js +174 -0
- package/src/check/rules-components.js +275 -0
- package/src/check/rules-imports.js +166 -0
- package/src/check/rules-registry.js +128 -0
- package/src/check/rules-routing.js +224 -0
- package/src/check/rules-typescript.js +177 -0
- package/src/check/rules.js +127 -0
- package/src/check/runner-support.js +405 -0
- package/src/check/runner.js +113 -0
- package/src/check.js +4 -1774
- package/src/component-elision.js +173 -26
- package/src/dev/config.js +338 -0
- package/src/dev/handler.js +1372 -0
- package/src/dev/helpers.js +542 -0
- package/src/dev/serve.js +840 -0
- package/src/dev/server.js +322 -0
- package/src/dev-classify.js +174 -0
- package/src/dev-reload-worker.js +133 -7
- package/src/dev-styles.js +97 -0
- package/src/dev.js +22 -3077
- package/src/form-dispatch.js +12 -3
- package/src/importmap.js +6 -3
- package/src/listener-core.js +16 -2
- package/src/module-graph.js +112 -7
- package/src/router.js +11 -1
- package/src/ssr/client-router-flag.js +42 -0
- package/src/ssr/document.js +361 -0
- package/src/ssr/env-shim.js +51 -0
- package/src/ssr/escape.js +29 -0
- package/src/ssr/head.js +857 -0
- package/src/ssr/preloads.js +195 -0
- package/src/ssr/render.js +1481 -0
- package/src/ssr/responses.js +201 -0
- package/src/ssr.js +28 -2445
- package/src/vendor/audit.js +400 -0
- package/src/vendor/integrity.js +245 -0
- package/src/vendor/jspm.js +348 -0
- package/src/vendor/manifest.js +96 -0
- package/src/vendor/pins.js +892 -0
- package/src/vendor/providers.js +35 -0
- package/src/vendor/resolver.js +225 -0
- package/src/vendor/scanner.js +132 -0
- package/src/vendor.js +29 -2371
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
|
-
*
|
|
1677
|
-
*
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
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';
|