@webjsdev/server 0.8.63 → 0.8.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +7 -2
- package/package.json +2 -2
- package/src/browser-entries.js +64 -0
- package/src/check/helpers.js +365 -0
- package/src/check/rules-actions.js +174 -0
- package/src/check/rules-components.js +275 -0
- package/src/check/rules-imports.js +166 -0
- package/src/check/rules-registry.js +128 -0
- package/src/check/rules-routing.js +224 -0
- package/src/check/rules-typescript.js +177 -0
- package/src/check/rules.js +127 -0
- package/src/check/runner-support.js +405 -0
- package/src/check/runner.js +113 -0
- package/src/check.js +4 -1774
- package/src/component-elision.js +173 -26
- package/src/dev/config.js +338 -0
- package/src/dev/handler.js +1372 -0
- package/src/dev/helpers.js +542 -0
- package/src/dev/serve.js +840 -0
- package/src/dev/server.js +322 -0
- package/src/dev-classify.js +174 -0
- package/src/dev-reload-worker.js +133 -7
- package/src/dev-styles.js +97 -0
- package/src/dev.js +22 -3077
- package/src/form-dispatch.js +12 -3
- package/src/importmap.js +6 -3
- package/src/listener-core.js +16 -2
- package/src/module-graph.js +112 -7
- package/src/router.js +11 -1
- package/src/ssr/client-router-flag.js +42 -0
- package/src/ssr/document.js +361 -0
- package/src/ssr/env-shim.js +51 -0
- package/src/ssr/escape.js +29 -0
- package/src/ssr/head.js +857 -0
- package/src/ssr/preloads.js +195 -0
- package/src/ssr/render.js +1481 -0
- package/src/ssr/responses.js +201 -0
- package/src/ssr.js +28 -2445
- package/src/vendor/audit.js +400 -0
- package/src/vendor/integrity.js +245 -0
- package/src/vendor/jspm.js +348 -0
- package/src/vendor/manifest.js +96 -0
- package/src/vendor/pins.js +892 -0
- package/src/vendor/providers.js +35 -0
- package/src/vendor/resolver.js +225 -0
- package/src/vendor/scanner.js +132 -0
- package/src/vendor.js +29 -2371
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { join, resolve, sep } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
// Server-side `.ts` imports are handled natively by Node 24+'s default
|
|
8
|
+
// type-stripping (`process.features.typescript === 'strip'`) or by Bun. The
|
|
9
|
+
// BROWSER-bound `.ts` request handler erases types via the pluggable stripper in
|
|
10
|
+
// `./ts-strip.js` (Node's built-in `module.stripTypeScriptTypes`, or `amaro` on
|
|
11
|
+
// Bun), so SSR and hydration produce identical JS on either runtime (#508).
|
|
12
|
+
import { buildRouteTable, matchPage } from '../router.js';
|
|
13
|
+
import { generateRouteTypes } from '../route-types.js';
|
|
14
|
+
import { setClientRouterEnabled, setMetadataIconRoutes } from '../ssr.js';
|
|
15
|
+
import {
|
|
16
|
+
buildActionIndex,
|
|
17
|
+
} from '../actions.js';
|
|
18
|
+
import { registerActionHooks } from '../action-seed.js';
|
|
19
|
+
import { readRegenerateRules, isRegenerateOutputPath } from '../dev-regenerate.js';
|
|
20
|
+
import { classifyChangedPath } from '../dev-classify.js';
|
|
21
|
+
import { ensureStripper } from '../ts-strip.js';
|
|
22
|
+
import { defaultLogger } from '../logger.js';
|
|
23
|
+
import { assertNodeVersion } from '../node-version.js';
|
|
24
|
+
import { applyEnvValidation } from '../env-schema.js';
|
|
25
|
+
import { runInstrumentation } from '../instrumentation.js';
|
|
26
|
+
import { withRequest, setCspNonce, setBodyLimits, setRequestId, requestId as getRequestId } from '../context.js';
|
|
27
|
+
import { buildInfoResponse } from '../build-info.js';
|
|
28
|
+
import { mintNonce, buildCspHeader, cspHeaderName } from '../csp.js';
|
|
29
|
+
import { propagateTrustedRemoteIp } from '../rate-limit.js';
|
|
30
|
+
import { reachedBareImports, resolveVendorImports, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from '../vendor.js';
|
|
31
|
+
import { browserEntryFiles } from '../browser-entries.js';
|
|
32
|
+
import { buildModuleGraph, seenFilesFor, appImportsMap, reachableFromEntries } from '../module-graph.js';
|
|
33
|
+
import { primeComponentRegistry, findOrphanComponents, scanComponents } from '../component-scanner.js';
|
|
34
|
+
import { analyzeElision } from '../component-elision.js';
|
|
35
|
+
|
|
36
|
+
import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from '../importmap.js';
|
|
37
|
+
import { stripBasePath, withBasePath } from '../base-path.js';
|
|
38
|
+
import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
|
|
39
|
+
import { resolveActionIdentity } from '../form-action-identity.js';
|
|
40
|
+
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, resolveAssetUrl } from '../asset-hash.js';
|
|
41
|
+
import { applySecurityHeaders, webRequestIsHttps } from '../headers.js';
|
|
42
|
+
import {
|
|
43
|
+
applyRedirects,
|
|
44
|
+
applyTrailingSlash,
|
|
45
|
+
} from '../redirects.js';
|
|
46
|
+
import { applyConditionalGet } from '../conditional-get.js';
|
|
47
|
+
import { commitHtmlCache, setAppSourceFingerprint } from '../html-cache.js';
|
|
48
|
+
import { buildDevErrorFrame } from '../dev-error.js';
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
readElideEnabled,
|
|
52
|
+
readSeedEnabled,
|
|
53
|
+
readClientRouterEnabled,
|
|
54
|
+
readHeaderRules,
|
|
55
|
+
readRedirectRules,
|
|
56
|
+
readTrailingSlashFromApp,
|
|
57
|
+
readBasePathFromApp,
|
|
58
|
+
warnOnInvalidWebjsConfig,
|
|
59
|
+
readAllowedOriginsFromApp,
|
|
60
|
+
readCspConfigFromApp,
|
|
61
|
+
readBodyLimitsFromApp,
|
|
62
|
+
} from './config.js';
|
|
63
|
+
import {
|
|
64
|
+
exists, kebab, resolveRequestId, shouldAccessLog, loadAppEnv, collectRouteModules,
|
|
65
|
+
appTopLevelDirs, locateCoreDir,
|
|
66
|
+
} from './helpers.js';
|
|
67
|
+
import {
|
|
68
|
+
handleCore, loadMiddleware, tryServeFrameworkStatic, tryServePublicAsset,
|
|
69
|
+
} from './serve.js';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A short content digest of a single file's bytes for the app-source deploy
|
|
73
|
+
* signal (#899). Raw `sha256(bytes)`, deliberately independent of `assetHashFor`
|
|
74
|
+
* (which folds the elision fingerprint and memoizes for `?v` emission): the
|
|
75
|
+
* signal only needs "did the source bytes change", and server-only files never
|
|
76
|
+
* enter the asset-hash memo anyway. Returns `''` on a read failure, so a
|
|
77
|
+
* transient error degrades the id rather than throwing during analysis.
|
|
78
|
+
* @param {string} abs
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function fileByteHash(abs) {
|
|
82
|
+
try { return createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 16); }
|
|
83
|
+
catch { return ''; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @type {string | undefined} memoized `@webjsdev/server` version, folded into the app-source signal (#899). */
|
|
87
|
+
let cachedServerVersion;
|
|
88
|
+
/**
|
|
89
|
+
* The installed `@webjsdev/server` version (this package). Folded into the
|
|
90
|
+
* app-source deploy signal so a server-framework release (which alters SSR
|
|
91
|
+
* output but ships no new browser module) turns over the client's stale caches.
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
function frameworkServerVersion() {
|
|
95
|
+
if (cachedServerVersion !== undefined) return cachedServerVersion;
|
|
96
|
+
try {
|
|
97
|
+
const v = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
98
|
+
cachedServerVersion = typeof v === 'string' ? v.replace(/[^\w.-]/g, '').slice(0, 32) : '';
|
|
99
|
+
} catch {
|
|
100
|
+
cachedServerVersion = '';
|
|
101
|
+
}
|
|
102
|
+
return cachedServerVersion;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Create a reusable, framework-agnostic request handler for a WebJs app.
|
|
107
|
+
* The returned `handle(req)` takes a standard `Request` and resolves to a
|
|
108
|
+
* standard `Response`: suitable for Node http, Deno, Bun, Cloudflare Workers,
|
|
109
|
+
* or embedding inside an Express/Fastify app.
|
|
110
|
+
*
|
|
111
|
+
* @param {{
|
|
112
|
+
* appDir: string,
|
|
113
|
+
* dev?: boolean,
|
|
114
|
+
* logger?: import('../logger.js').Logger,
|
|
115
|
+
* onError?: (error: unknown, ctx: { request: Request, requestId: string|null, phase: string }) => void,
|
|
116
|
+
* onReload?: (verdict?: import('../dev-classify.js').ReloadVerdict) => void,
|
|
117
|
+
* onDevError?: (frame: object) => void,
|
|
118
|
+
* }} opts
|
|
119
|
+
*/
|
|
120
|
+
export async function createRequestHandler(opts) {
|
|
121
|
+
// Preflight: WebJs needs Node 24+ (built-in TS strip + recursive fs.watch).
|
|
122
|
+
// Throw a clear Error here so an embedded host (Express/Fastify/Bun/Deno)
|
|
123
|
+
// gets the actionable message at boot, not a cryptic API failure mid-request.
|
|
124
|
+
assertNodeVersion({ onFail: 'throw' });
|
|
125
|
+
// Resolve the TS stripper backend at boot (#508): the Node built-in, or amaro
|
|
126
|
+
// on Bun. Doing it here pays the (one-time) amaro import up front and surfaces
|
|
127
|
+
// a missing-amaro error at boot rather than on the first `.ts` request.
|
|
128
|
+
await ensureStripper();
|
|
129
|
+
const appDir = resolve(opts.appDir);
|
|
130
|
+
// Load <appDir>/.env into process.env BEFORE anything else.
|
|
131
|
+
// buildActionIndex below imports server-only files (lib/*.server.ts,
|
|
132
|
+
// modules/**/*.server.ts), some of which read process.env at module
|
|
133
|
+
// init (e.g. createAuth reads AUTH_SECRET). Without this call,
|
|
134
|
+
// scaffolded apps with a committed .env.example + .env would fail
|
|
135
|
+
// to boot until the user discovered the missing env-load. See
|
|
136
|
+
// tracker #37.
|
|
137
|
+
loadAppEnv(appDir);
|
|
138
|
+
// Optional boot-time env validation (#236). If <appDir>/env.{js,ts} exists it
|
|
139
|
+
// default-exports a typed schema or a custom validator function; we run it
|
|
140
|
+
// against process.env (now populated by loadAppEnv) BEFORE buildActionIndex
|
|
141
|
+
// imports any server-only module. A failure throws a clear aggregated Error
|
|
142
|
+
// here, so an embedded host rejects at boot and the CLI exits non-zero,
|
|
143
|
+
// failing fast instead of crashing cryptically mid-request. Absent file is a
|
|
144
|
+
// no-op (opt-in). Coerced + defaulted values are written back to process.env.
|
|
145
|
+
await applyEnvValidation(appDir, { dev: !!opts.dev });
|
|
146
|
+
const dev = !!opts.dev;
|
|
147
|
+
const logger = opts.logger || defaultLogger({ dev });
|
|
148
|
+
// Boot-time `webjs` config validation (#1300). The published JSON Schema used
|
|
149
|
+
// to reach users only through the scaffold's .vscode `$ref`, so a typo'd key
|
|
150
|
+
// was caught in an editor and nowhere else: the key was dropped, the feature
|
|
151
|
+
// stayed at its default, and nothing said so. This runs it once per boot, from
|
|
152
|
+
// the one entry point dev, prod, and an embedded host all share.
|
|
153
|
+
//
|
|
154
|
+
// It WARNS and continues, never throws. A typo costs one feature sitting at
|
|
155
|
+
// its default; a hard boot failure over a schema quibble costs the whole app.
|
|
156
|
+
// A missing / unreadable / unparseable package.json is a silent no-op, like
|
|
157
|
+
// every other `webjs.*` reader in this file.
|
|
158
|
+
await warnOnInvalidWebjsConfig(appDir, logger);
|
|
159
|
+
// Boot-time instrumentation hook (#848): run the optional app-root
|
|
160
|
+
// instrumentation.{js,ts} register() ONCE, after env validation and before
|
|
161
|
+
// the route table / action index are built, so observability plumbing starts
|
|
162
|
+
// before traffic. Any error sink it registers via setOnError composes with
|
|
163
|
+
// the programmatic opts.onError (both fire). Fail-open (a throwing hook is
|
|
164
|
+
// logged, not fatal).
|
|
165
|
+
const { onError: instrumentationOnError } = await runInstrumentation(appDir, { dev, logger });
|
|
166
|
+
// APM / Sentry integration point (issue #239). Called whenever the request
|
|
167
|
+
// pipeline catches an unhandled error: the top-level handle() catch (the
|
|
168
|
+
// last-resort 500), an unexpected throw inside the produce() funnel, or a
|
|
169
|
+
// middleware that threw. BEST-EFFORT by contract: a throwing onError is
|
|
170
|
+
// caught here so it can never crash the response, and the framework's own
|
|
171
|
+
// sanitized 500 / existing error behavior is unchanged (the hook is purely
|
|
172
|
+
// additive). The sink receives the error plus the correlation id so it can
|
|
173
|
+
// tie the report to the same id the access log and X-Request-Id carry.
|
|
174
|
+
// Both the programmatic opts.onError AND any sink an instrumentation.{js,ts}
|
|
175
|
+
// register() installed via setOnError fire (they compose, #848).
|
|
176
|
+
const onErrorSinks = [opts.onError, instrumentationOnError].filter((f) => typeof f === 'function');
|
|
177
|
+
const hasOnError = onErrorSinks.length > 0;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Invoke every registered onError sink defensively. The phase is a coarse
|
|
181
|
+
* label of where the pipeline caught the error, for the sink's own grouping.
|
|
182
|
+
* @param {unknown} error
|
|
183
|
+
* @param {Request} request
|
|
184
|
+
* @param {string} phase
|
|
185
|
+
*/
|
|
186
|
+
function reportError(error, request, phase) {
|
|
187
|
+
if (!hasOnError) return;
|
|
188
|
+
const ctx = { request, requestId: getRequestId(), phase };
|
|
189
|
+
for (const sink of onErrorSinks) {
|
|
190
|
+
try {
|
|
191
|
+
sink(error, ctx);
|
|
192
|
+
} catch (e) {
|
|
193
|
+
logger.error?.('[webjs] onError hook threw (ignored)', { err: String(e) });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Sub-path deployment base path (issue #256), read once from the app's
|
|
199
|
+
// package.json `webjs.basePath` and bound into the importmap builder BEFORE
|
|
200
|
+
// setCoreInstall / setVendorEntries so every importmap target (and the
|
|
201
|
+
// recomputed hash) reflects the prefix. Empty (the default) makes both the
|
|
202
|
+
// ingress strip and the emit-side prefix pure no-ops, so an unconfigured app
|
|
203
|
+
// is byte-identical to before this feature.
|
|
204
|
+
const basePathValue = await readBasePathFromApp(appDir);
|
|
205
|
+
await setBasePath(basePathValue);
|
|
206
|
+
|
|
207
|
+
// Cross-origin allowlist for the action CSRF check (#659), read once.
|
|
208
|
+
const allowedOriginsValue = await readAllowedOriginsFromApp(appDir);
|
|
209
|
+
|
|
210
|
+
// Client-router opt-out (#629): bind it eagerly at handler construction (the
|
|
211
|
+
// same timing as setBasePath above), so a handler's module-global is set
|
|
212
|
+
// before any render even if a request arrives before the first warm. The
|
|
213
|
+
// analysis pass re-reads it each rebuild so a dev toggle takes effect live.
|
|
214
|
+
setClientRouterEnabled(await readClientRouterEnabled(appDir));
|
|
215
|
+
|
|
216
|
+
const coreDir = locateCoreDir(appDir);
|
|
217
|
+
// Switch the importmap between dist/ bundles and src/ per-file
|
|
218
|
+
// URLs depending on whether the resolved @webjsdev/core install
|
|
219
|
+
// has built bundles on disk. npm-installed copies always do;
|
|
220
|
+
// workspace dev does only after `npm run build:dist`. Without
|
|
221
|
+
// a built dist the server falls back to the historical per-file
|
|
222
|
+
// src/ URLs so dev iteration does not require a build step.
|
|
223
|
+
//
|
|
224
|
+
// Both required bundles must exist. An older @webjsdev/core
|
|
225
|
+
// install built BEFORE the browser-entry split (#119/#128) has
|
|
226
|
+
// `webjs-core.js` but no `webjs-core-browser.js`. Enabling dist
|
|
227
|
+
// mode in that case would route the bare `@webjsdev/core`
|
|
228
|
+
// specifier at a 404 on every page. Require both so a partial
|
|
229
|
+
// dist transparently degrades to src/ mode instead.
|
|
230
|
+
const distDir = join(coreDir, 'dist');
|
|
231
|
+
const distComplete =
|
|
232
|
+
existsSync(join(distDir, 'webjs-core.js')) &&
|
|
233
|
+
existsSync(join(distDir, 'webjs-core-browser.js'));
|
|
234
|
+
await setCoreInstall(coreDir, distComplete);
|
|
235
|
+
|
|
236
|
+
// Path-alias imports (#555). Emit the browser importmap scopes for the app's
|
|
237
|
+
// `package.json "imports"` aliases, derived from the SAME map the server
|
|
238
|
+
// resolver (`expandImportAlias`) reads, so SSR and the browser agree. The
|
|
239
|
+
// scaffold ships the single catch-all `"#*": "./*"` (one key, zero
|
|
240
|
+
// maintenance, Bun-safe), which expands to one browser prefix scope per
|
|
241
|
+
// top-level dir (`#lib/` -> `/lib/`, ...); the dir scan is here so a new
|
|
242
|
+
// folder is picked up on boot. An app with no `"imports"` block yields {}
|
|
243
|
+
// (a no-op, byte-identical to before this feature).
|
|
244
|
+
await setImportAliasEntries(importAliasBrowserEntries(appImportsMap(appDir), appTopLevelDirs(appDir)));
|
|
245
|
+
|
|
246
|
+
// Content-hash asset URLs for immutable caching (issue #243). Bind the
|
|
247
|
+
// asset-hash module to the app + core roots and enable fingerprinting in
|
|
248
|
+
// PROD only. In dev `withAssetHash` stays a pure no-op (never enabled), so
|
|
249
|
+
// the dev importmap / boot / preload output is byte-identical to before.
|
|
250
|
+
// This runs AFTER setBasePath / setCoreInstall, but those recompute the
|
|
251
|
+
// importmap hash with `{ fingerprint: false }`, so the boot-published build
|
|
252
|
+
// id is a stable deploy fingerprint independent of per-file content hashes.
|
|
253
|
+
setAssetRoots({ appDir, coreDir, enabled: !dev });
|
|
254
|
+
|
|
255
|
+
// Install the resolver behind the isomorphic `asset()` helper (#1194), the
|
|
256
|
+
// same provider seam `cspNonce()` uses. An author writes
|
|
257
|
+
// `href=${asset('/public/app.css')}` and gets the fingerprinted url on the
|
|
258
|
+
// server; the browser has no provider and returns the path unchanged.
|
|
259
|
+
//
|
|
260
|
+
// The `public/` gate is defensive rather than decorative. `resolveUrlToFile`
|
|
261
|
+
// maps any same-origin path under the PROJECT ROOT, so an app that passed
|
|
262
|
+
// request- or user-derived data here (`asset(user.avatarPath)`) would
|
|
263
|
+
// otherwise read and publish a content hash for `/.env`, `/db/app.db`, or a
|
|
264
|
+
// `.server.ts`, leaking both existence and a fingerprint of the bytes for
|
|
265
|
+
// files the serve path deliberately 404s. Mirror the static-asset route:
|
|
266
|
+
// under `public/`, no traversal. Everything else returns unchanged, having
|
|
267
|
+
// never touched the disk.
|
|
268
|
+
setAssetUrlProvider((p) => resolveAssetUrl(p, basePath()));
|
|
269
|
+
|
|
270
|
+
// The `'use server'` load hook. Install it NOW, at boot, before any action
|
|
271
|
+
// module is imported (ESM caches by URL, so a module loaded before the hook
|
|
272
|
+
// would never be wrapped). Read once (not per-rebuild): the hook is global
|
|
273
|
+
// and cannot be cleanly un-installed, so toggling needs a restart.
|
|
274
|
+
//
|
|
275
|
+
// It installs UNCONDITIONALLY, and only seed collection (#472) follows the
|
|
276
|
+
// `webjs.seed` switch. Action identity (#1155) rides the same hook, and it is
|
|
277
|
+
// what a bound `<form action=${action}>` resolves through, so gating the hook
|
|
278
|
+
// on seeding would mean `webjs.seed: false` silently broke every no-JS form.
|
|
279
|
+
await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
|
|
280
|
+
|
|
281
|
+
// When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
|
|
282
|
+
// deterministic vendor map that is cheap to read (one file, no analysis, no
|
|
283
|
+
// network). Resolve it AT BOOT and publish the build id immediately so the
|
|
284
|
+
// process advertises a stable, non-empty id from its very first response: a
|
|
285
|
+
// freshly-deployed pinned process is detected as a new deploy by old-deploy
|
|
286
|
+
// clients with zero warmup window. Mirrors Rails importmap (committed pins
|
|
287
|
+
// rendered deterministically at runtime). Pinning stays optional; an unpinned
|
|
288
|
+
// app does no vendor work at boot and publishes its id after the first
|
|
289
|
+
// successful resolve instead. Either way the EXPENSIVE analysis (graph, scan,
|
|
290
|
+
// gate, elision) and the UNPINNED jspm resolve stay deferred to the first
|
|
291
|
+
// request, so #143's win is intact; only the cheap committed-file read moves
|
|
292
|
+
// back to boot, and only when a VALID pin exists. A committed pin file is
|
|
293
|
+
// served as-is (elision never prunes it), so the boot-resolved map equals the
|
|
294
|
+
// final served map and the published id is authoritative.
|
|
295
|
+
//
|
|
296
|
+
// Validate the pin with readPinFile BEFORE treating the app as pinned-at-boot.
|
|
297
|
+
// hasVendorPin is a cheap existence check; a malformed pin (exists but
|
|
298
|
+
// unparseable) must NOT short-circuit here, because resolveVendorImports would
|
|
299
|
+
// then fall through to its bare-import scan thunk, and the boot-time thunk is
|
|
300
|
+
// empty (the real scan is part of the deferred analysis). A broken pin instead
|
|
301
|
+
// falls through to the normal deferred resolve, which carries the real scan
|
|
302
|
+
// thunk and degrades gracefully, exactly as an unpinned app does.
|
|
303
|
+
let bootVendorPinned = false;
|
|
304
|
+
if (hasVendorPin(appDir) && (await readPinFile(appDir))) {
|
|
305
|
+
try {
|
|
306
|
+
const v = await resolveVendorImports(appDir, () => new Set());
|
|
307
|
+
await setVendorEntries(v.imports, v.integrity);
|
|
308
|
+
publishBuildId();
|
|
309
|
+
bootVendorPinned = true;
|
|
310
|
+
} catch (e) {
|
|
311
|
+
// An unexpected failure applying a VALID pin (e.g. setVendorEntries
|
|
312
|
+
// throwing) is non-fatal: leave bootVendorPinned false so the deferred
|
|
313
|
+
// resolve re-attempts on the first request. Boot stays resilient.
|
|
314
|
+
logger.error?.(`[webjs] applying the committed vendor pin at boot failed (will retry on the first request):`, e);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Whole-app analysis (module graph, component scan, browser-bound gate,
|
|
319
|
+
// action index, middleware, elision, vendor) is NOT run at boot. It is
|
|
320
|
+
// computed on the first request via ensureReady() below and memoized, so the
|
|
321
|
+
// server starts without walking or reading the app's source, executing any
|
|
322
|
+
// server module, or hitting the network. Only the route table is built
|
|
323
|
+
// eagerly: it is a cheap directory scan (no code reads), and routing, Early
|
|
324
|
+
// Hints, and WebSocket lookups need it available before the first request.
|
|
325
|
+
const routeTable = await buildRouteTable(appDir);
|
|
326
|
+
// Auto-linked favicons: tell the head builder which icon metadata routes
|
|
327
|
+
// exist, so `app/icon.*` is linked when the app declares no metadata.icons.
|
|
328
|
+
// Bound here rather than threaded through ssrOpts, matching
|
|
329
|
+
// setClientRouterEnabled; re-bound in doRebuild so adding or deleting the
|
|
330
|
+
// file takes effect without a restart.
|
|
331
|
+
setMetadataIconRoutes(routeTable.metadataRoutes);
|
|
332
|
+
|
|
333
|
+
// Emit `.webjs/routes.d.ts` (typed Route union + per-route params, #258) in
|
|
334
|
+
// dev so an editor's tsserver always has up-to-date route types without the
|
|
335
|
+
// developer remembering to run `webjs types`. Best-effort and fire-and-
|
|
336
|
+
// forget: a failure logs and never blocks boot. Re-emitted after each route
|
|
337
|
+
// rebuild (see doRebuild) so adding/removing a route refreshes the types.
|
|
338
|
+
/** @returns {Promise<void>} */
|
|
339
|
+
async function emitRouteTypes() {
|
|
340
|
+
try {
|
|
341
|
+
const { mkdir, writeFile, rename } = await import('node:fs/promises');
|
|
342
|
+
const text = await generateRouteTypes(appDir);
|
|
343
|
+
const outDir = join(appDir, '.webjs');
|
|
344
|
+
await mkdir(outDir, { recursive: true });
|
|
345
|
+
// Write to a temp sibling then rename, so tsserver (which reads this
|
|
346
|
+
// file) never observes a half-written body if two rebuilds race. rename
|
|
347
|
+
// is atomic within the same dir. Both paths sit under the watcher-ignored
|
|
348
|
+
// .webjs/, so neither the temp write nor the rename re-triggers a rebuild.
|
|
349
|
+
const dest = join(outDir, 'routes.d.ts');
|
|
350
|
+
const tmp = join(outDir, `routes.d.ts.${process.pid}.tmp`);
|
|
351
|
+
await writeFile(tmp, text);
|
|
352
|
+
await rename(tmp, dest);
|
|
353
|
+
} catch (e) {
|
|
354
|
+
logger.warn?.(`[webjs] could not write .webjs/routes.d.ts (route types): ${e?.message || e}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (dev) void emitRouteTypes();
|
|
358
|
+
|
|
359
|
+
// Per-path response-header rules (issue #232), read once from the
|
|
360
|
+
// app's package.json `webjs.headers`. Static config, so no rebuild
|
|
361
|
+
// re-read; the secure defaults need no config and apply regardless.
|
|
362
|
+
const headerRules = await readHeaderRules(appDir);
|
|
363
|
+
// Declarative redirect rules (issue #254), read once from the app's
|
|
364
|
+
// package.json `webjs.redirects` and compiled to URLPattern rules at
|
|
365
|
+
// boot (never per request). Empty when unconfigured, so an app with no
|
|
366
|
+
// redirects is unchanged. Applied at the very start of request handling,
|
|
367
|
+
// before routing / SSR / asset serving, so a moved URL returns a 308/307
|
|
368
|
+
// immediately.
|
|
369
|
+
const redirectRules = await readRedirectRules(appDir);
|
|
370
|
+
// Trailing-slash policy (issue #255), read once from the app's package.json
|
|
371
|
+
// `webjs.trailingSlash`. Default `'ignore'` (no canonicalization), so an
|
|
372
|
+
// unconfigured app is unchanged; `'never'` (recommended) strips a trailing
|
|
373
|
+
// slash and `'always'` adds one, each via a 308 to the canonical form.
|
|
374
|
+
// Applied in produce() AFTER the declarative redirects, so an explicit
|
|
375
|
+
// redirect rule wins first and the two never loop.
|
|
376
|
+
const trailingSlashPolicy = await readTrailingSlashFromApp(appDir);
|
|
377
|
+
// CSP config (issue #233), read once from the app's package.json
|
|
378
|
+
// `webjs.csp`. OFF by default: when disabled no nonce is minted and no
|
|
379
|
+
// Content-Security-Policy header is set, so an unconfigured app is
|
|
380
|
+
// unchanged. When enabled, `handle()` mints a fresh per-request nonce,
|
|
381
|
+
// makes it the value `cspNonce()` returns (so the SSR'd inline scripts
|
|
382
|
+
// carry it), and sets the matching header carrying the same nonce.
|
|
383
|
+
const cspConfig = await readCspConfigFromApp(appDir);
|
|
384
|
+
// Request body-size limits (issue #237), read once from the app's
|
|
385
|
+
// package.json `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env
|
|
386
|
+
// overrides. The secure defaults (1 MiB JSON/RPC, 10 MiB form) apply when
|
|
387
|
+
// unconfigured. Stamped on every request via `setBodyLimits` so `readBody`
|
|
388
|
+
// (used inside route handlers) enforces the same cap the RPC and form-dispatch
|
|
389
|
+
// paths do.
|
|
390
|
+
const bodyLimits = await readBodyLimitsFromApp(appDir);
|
|
391
|
+
|
|
392
|
+
const state = {
|
|
393
|
+
routeTable,
|
|
394
|
+
actionIndex: null,
|
|
395
|
+
middleware: null,
|
|
396
|
+
bodyLimits,
|
|
397
|
+
logger,
|
|
398
|
+
// Test-mode serve gate (#806). Set ONLY by the browser-test harness
|
|
399
|
+
// (`@webjsdev/server/testing`'s `createBrowserTestHandler`), NEVER by
|
|
400
|
+
// `webjs dev` / `webjs start`. When true the source-serve gate is relaxed
|
|
401
|
+
// so ANY app file under appDir is servable (a component a browser test
|
|
402
|
+
// imports is not route-reachable, so it is absent from `browserBoundFiles`
|
|
403
|
+
// and would 404). The `.server.*` guardrail (source -> RPC/throw stub) and
|
|
404
|
+
// the core / vendor serving are unchanged, so no server source is exposed.
|
|
405
|
+
testMode: opts.testMode === true,
|
|
406
|
+
moduleGraph: null,
|
|
407
|
+
elidableComponents: new Set(),
|
|
408
|
+
inertRouteModules: new Set(),
|
|
409
|
+
importOnlyRouteModules: new Map(),
|
|
410
|
+
// The browser-bound ENTRY files (pages / layouts / boundaries / components),
|
|
411
|
+
// kept alongside the expanded gate set because the vendor scan roots at the
|
|
412
|
+
// entries rather than at the closure.
|
|
413
|
+
browserEntryFiles: new Set(),
|
|
414
|
+
browserBoundFiles: null,
|
|
415
|
+
// Dev live-reload classification (#1398). `shippedFiles` is the transitive
|
|
416
|
+
// closure of what the browser can ACTUALLY load (the gate set minus every
|
|
417
|
+
// module elision drops), `graphFiles` is every app source the graph walked,
|
|
418
|
+
// and `pageFiles` is the route table's pages. Together they are the whole
|
|
419
|
+
// input to `classifyChangedPath`, so a watch event can be answered without
|
|
420
|
+
// reaching into the rest of this state. Dev-only; left empty in prod.
|
|
421
|
+
/** @type {Set<string>} */
|
|
422
|
+
shippedFiles: new Set(),
|
|
423
|
+
/** @type {Set<string>} */
|
|
424
|
+
graphFiles: new Set(),
|
|
425
|
+
/** @type {Set<string>} */
|
|
426
|
+
pageFiles: new Set(),
|
|
427
|
+
// Transformed-source cache (stripped TS + applied elision). Per-handler,
|
|
428
|
+
// NOT module-global: the cached bytes bake in THIS handler's elision
|
|
429
|
+
// verdict, so two handlers for the same app with different elision
|
|
430
|
+
// settings (a multi-tenant embedder, or the differential elision test)
|
|
431
|
+
// must not share it, or the second would serve the first's elided source.
|
|
432
|
+
tsCache: new Map(),
|
|
433
|
+
// The most recent unresolved dev error frame (#264), or null. Pushed to the
|
|
434
|
+
// SSE channel for the live overlay and replayed to a freshly-connected tab.
|
|
435
|
+
// Dev-only (never populated when !dev); cleared on a successful rebuild.
|
|
436
|
+
lastDevError: null,
|
|
437
|
+
// On-request regeneration rules (#967): `webjs.dev.regenerate` from the app's
|
|
438
|
+
// package.json. Dev-only (prod builds its outputs via `start.before`). Loaded
|
|
439
|
+
// now and refreshed on each rebuild so a config edit takes effect without a
|
|
440
|
+
// restart; empty for a plain app, so the serving path is unaffected.
|
|
441
|
+
regenerateRules: dev ? await readRegenerateRules(appDir) : [],
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// Teach the renderers how to turn a real server-action function into the
|
|
445
|
+
// `<hash>/<fn>` identity a form submits (#1155). The hook path is
|
|
446
|
+
// process-global and needs no index; `state.actionIndex` is consulted only as
|
|
447
|
+
// a hash memo, and by the scan fallback for a runtime with no hook. In a
|
|
448
|
+
// process running several handlers the fallback therefore resolves against
|
|
449
|
+
// the most recently booted one, which is the same scope the asset-url
|
|
450
|
+
// provider already has.
|
|
451
|
+
setFormActionResolver((fn) => (state.actionIndex ? resolveActionIdentity(state.actionIndex, fn) : null));
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Report a dev error (#264): build a frame and push it to the open tab via
|
|
455
|
+
* the SSE overlay channel. DEV-ONLY and best-effort, so it can never affect a
|
|
456
|
+
* response or crash the server (a frame-build failure is swallowed). No file
|
|
457
|
+
* path or source is ever built in prod (the early return), so nothing leaks.
|
|
458
|
+
*
|
|
459
|
+
* `info.url` stamps a `render` frame with the URL that produced it (#1047), so
|
|
460
|
+
* the browser overlay can refuse a frame for a page the tab is not viewing.
|
|
461
|
+
* A `ts-strip` / `rebuild` frame passes none and stays unscoped.
|
|
462
|
+
*
|
|
463
|
+
* @param {unknown} error
|
|
464
|
+
* @param {{ kind?: 'render'|'ts-strip'|'rebuild', file?: string, line?: number, column?: number, hint?: string, url?: string }} [info]
|
|
465
|
+
*/
|
|
466
|
+
function reportDevError(error, info = {}) {
|
|
467
|
+
if (!dev) return;
|
|
468
|
+
try {
|
|
469
|
+
const frame = buildDevErrorFrame(error, { ...info, appDir });
|
|
470
|
+
state.lastDevError = frame;
|
|
471
|
+
opts.onDevError?.(frame);
|
|
472
|
+
} catch { /* ignore */ }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// All whole-app analysis is built lazily on the first request, memoized so
|
|
476
|
+
// boot does none of it. It runs in two stages. The deterministic analysis
|
|
477
|
+
// (module graph, component scan + prime, browser-bound gate, action index,
|
|
478
|
+
// middleware, elision) is network-free and, once built, never re-runs unless
|
|
479
|
+
// a rebuild invalidates it; readiness gates on it. Vendor resolution is a
|
|
480
|
+
// SEPARATE, best-effort stage: a pinned app reads a committed importmap file,
|
|
481
|
+
// an unpinned app auto-fetches from jspm. It does NOT gate readiness, so an
|
|
482
|
+
// offline or partially-unresolvable app still boots. A transient vendor
|
|
483
|
+
// failure is re-attempted on the NEXT ensureReady call (driven by an incoming
|
|
484
|
+
// request, a readiness probe, or the warm-up), with no background timer: the
|
|
485
|
+
// platform's traffic and probes are the retry loop. `readyError` holds a
|
|
486
|
+
// propagating analysis failure so /__webjs/ready can report it.
|
|
487
|
+
let analysisDone = false;
|
|
488
|
+
// Whether the analysis has EVER completed (#1398). Distinct from
|
|
489
|
+
// `analysisDone`, which `doRebuild` flips false on every edit: the live-reload
|
|
490
|
+
// classifier needs to know the derived sets are POPULATED, not that they are
|
|
491
|
+
// current, because it deliberately classifies against the previous build's
|
|
492
|
+
// graph (see the note in `doRebuild`). Gating it on `analysisDone` turned the
|
|
493
|
+
// feature off for every edit after the first in a burst, since nothing
|
|
494
|
+
// re-warms the analysis until an HTTP request arrives and the relay defers
|
|
495
|
+
// that request by its 2000ms quiet window (#1397), which is longer than the
|
|
496
|
+
// measured inter-save gap. Never reset.
|
|
497
|
+
let analysisEverDone = false;
|
|
498
|
+
// A pinned app applied its FULL vendor map and published the build id at boot
|
|
499
|
+
// (above). The deferred vendor stage still runs once (and after every rebuild)
|
|
500
|
+
// to PRUNE that map to the elision-reachable specifiers, so a pinned app serves
|
|
501
|
+
// the same map an unpinned one does (#197); it does not re-publish the build id
|
|
502
|
+
// (the boot hash stays the deploy fingerprint). An unpinned app starts false and
|
|
503
|
+
// resolves live on the first request.
|
|
504
|
+
let vendorResolved = false;
|
|
505
|
+
let vendorAttemptedOnce = false;
|
|
506
|
+
let vendorGen = 0;
|
|
507
|
+
let readyDone = false;
|
|
508
|
+
/** @type {unknown} */
|
|
509
|
+
let readyError = null;
|
|
510
|
+
/** @type {Promise<void> | null} */
|
|
511
|
+
let readyInFlight = null;
|
|
512
|
+
|
|
513
|
+
async function ensureReady() {
|
|
514
|
+
// Fully warm: analysis done and vendor resolved. Nothing to do.
|
|
515
|
+
if (analysisDone && vendorResolved) return;
|
|
516
|
+
// A warm pass is in flight (the analysis and/or the FIRST vendor attempt).
|
|
517
|
+
// Await it rather than serving past it: a concurrent early request must get
|
|
518
|
+
// the FINAL importmap, never a half-resolved one. This is what makes the
|
|
519
|
+
// unpinned warmup flawless. The first attempt's jspm resolve is
|
|
520
|
+
// timeout-bounded (vendor.js), so an offline app cannot hang here: on
|
|
521
|
+
// timeout the resolve returns and the response is served with an empty,
|
|
522
|
+
// reload-safe build id, then the retry below completes it. Without this
|
|
523
|
+
// wait, a request arriving mid-resolve would serve a partial map and an
|
|
524
|
+
// empty-then-changing build id, the exact warmup drift that hard-reloads
|
|
525
|
+
// and wipes a half-filled form.
|
|
526
|
+
if (readyInFlight) { await readyInFlight; return; }
|
|
527
|
+
// Analysis warm but the first vendor attempt already completed and failed:
|
|
528
|
+
// re-attempt WITHOUT blocking this request. The single-flight dedupes
|
|
529
|
+
// concurrent attempts; success flips the flag AND publishes the build id.
|
|
530
|
+
// This is the request/probe-driven retry (no timer). Until it succeeds the
|
|
531
|
+
// served build id stays empty (reload-safe), so no navigation hard-reloads.
|
|
532
|
+
if (analysisDone && vendorAttemptedOnce) {
|
|
533
|
+
const gen = vendorGen;
|
|
534
|
+
resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); } }).catch(() => {});
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
// Otherwise run the (single-flighted) full warm: the analysis, then the
|
|
538
|
+
// first vendor attempt, awaited so the first response carries the import map.
|
|
539
|
+
if (!readyInFlight) {
|
|
540
|
+
readyInFlight = (async () => {
|
|
541
|
+
/** @type {Record<string, number>} */
|
|
542
|
+
const t = {};
|
|
543
|
+
let ranAnalysis = false, ranVendor = false;
|
|
544
|
+
const now = () => performance.now();
|
|
545
|
+
try {
|
|
546
|
+
if (!analysisDone) {
|
|
547
|
+
let m = now();
|
|
548
|
+
state.moduleGraph = await buildModuleGraph(appDir);
|
|
549
|
+
t.graph = now() - m; m = now();
|
|
550
|
+
const components = await scanComponents(appDir);
|
|
551
|
+
await primeComponentRegistry(appDir, components);
|
|
552
|
+
t.scan = now() - m; m = now();
|
|
553
|
+
state.browserEntryFiles = browserEntryFiles(state.routeTable, components);
|
|
554
|
+
state.browserBoundFiles = reachableFromEntries(state.moduleGraph, [...state.browserEntryFiles], appDir);
|
|
555
|
+
t.gate = now() - m; m = now();
|
|
556
|
+
state.actionIndex = await buildActionIndex(appDir, dev);
|
|
557
|
+
t.actions = now() - m; m = now();
|
|
558
|
+
state.middleware = await loadMiddleware(appDir, dev, logger);
|
|
559
|
+
t.middleware = now() - m; m = now();
|
|
560
|
+
// Client-router opt-out (#629): re-read each pass so toggling
|
|
561
|
+
// `webjs.clientRouter` takes effect on rebuild without a restart.
|
|
562
|
+
setClientRouterEnabled(await readClientRouterEnabled(appDir));
|
|
563
|
+
// Read the switch ONCE: the dev summary below reports it too, and
|
|
564
|
+
// calling readElideEnabled twice per warm re-reads package.json.
|
|
565
|
+
const elideOn = await readElideEnabled(appDir);
|
|
566
|
+
const r = elideOn
|
|
567
|
+
? await analyzeElision(components, collectRouteModules(state.routeTable),
|
|
568
|
+
state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
|
|
569
|
+
: { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map(),
|
|
570
|
+
shippedRouteModules: new Map(), componentVerdicts: new Map() };
|
|
571
|
+
state.elidableComponents = r.elidableComponents;
|
|
572
|
+
state.inertRouteModules = r.inertRouteModules;
|
|
573
|
+
state.importOnlyRouteModules = r.importOnlyRouteModules;
|
|
574
|
+
// Dev live-reload classification (#1398): derive the three sets the
|
|
575
|
+
// watch-event classifier reads. `browserBoundFiles` above is the
|
|
576
|
+
// AUTHORIZATION gate (it walks from every entry, elided ones
|
|
577
|
+
// included, because an elided module's URL must still 404 rather
|
|
578
|
+
// than leak), so it is the wrong input here: the question is what
|
|
579
|
+
// the browser actually HOLDS. Re-walk from the entries elision
|
|
580
|
+
// keeps, and an edit to anything reachable from those is a reload.
|
|
581
|
+
// An import-only page's substituted components are themselves
|
|
582
|
+
// entries, so dropping the page loses none of its reach.
|
|
583
|
+
if (dev) {
|
|
584
|
+
const shippedEntries = [...state.browserEntryFiles].filter((f) =>
|
|
585
|
+
!state.elidableComponents.has(f)
|
|
586
|
+
&& !state.inertRouteModules.has(f)
|
|
587
|
+
&& !state.importOnlyRouteModules.has(f));
|
|
588
|
+
state.shippedFiles = reachableFromEntries(state.moduleGraph, shippedEntries, appDir);
|
|
589
|
+
state.graphFiles = seenFilesFor(state.moduleGraph);
|
|
590
|
+
state.pageFiles = new Set((state.routeTable.pages || []).map((p) => p.file).filter(Boolean));
|
|
591
|
+
}
|
|
592
|
+
// Fold the elision verdict into app-module content hashes (#243): an
|
|
593
|
+
// app module's served body is elision-transformed, so a verdict flip
|
|
594
|
+
// must bust its `?v` even when its source is byte-identical. A stable
|
|
595
|
+
// string of the sorted elidable + inert paths, RELATIVIZED to appDir
|
|
596
|
+
// so the fingerprint is a property of the app's STRUCTURE, not its
|
|
597
|
+
// filesystem location (two deploys at different absolute paths, or
|
|
598
|
+
// two identical apps, produce the same fingerprint). asset-hash
|
|
599
|
+
// digests it into each app-module hash; '' when nothing is elidable,
|
|
600
|
+
// so a no-elision app's hash stays exactly `sha256(bytes)`.
|
|
601
|
+
{
|
|
602
|
+
// `appDir + sep` boundary (matches asset-hash's containment guard)
|
|
603
|
+
// so a sibling-prefix dir cannot be mis-relativized.
|
|
604
|
+
const rel = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
|
|
605
|
+
const elidedPaths = [
|
|
606
|
+
...state.elidableComponents,
|
|
607
|
+
...state.inertRouteModules,
|
|
608
|
+
// An import-only module is dropped from the boot (replaced by its
|
|
609
|
+
// component imports), so a flip in / out of that class must bust
|
|
610
|
+
// the importer's `?v` too.
|
|
611
|
+
...state.importOnlyRouteModules.keys(),
|
|
612
|
+
].map(rel).sort();
|
|
613
|
+
setElisionFingerprint(elidedPaths.length ? elidedPaths.join('\n') : '');
|
|
614
|
+
}
|
|
615
|
+
// App-source fingerprint for the HTML cache key (#318): a
|
|
616
|
+
// deterministic, location-independent digest of the browser-bound
|
|
617
|
+
// file set's content hashes, so a deploy that changes ONLY an app
|
|
618
|
+
// module's bytes (which the importmap-only build id misses) re-keys
|
|
619
|
+
// the cache instead of serving a body with stale `?v` boot URLs.
|
|
620
|
+
// PROD only (asset-hash is enabled there); '' in dev, where fs.watch
|
|
621
|
+
// handles staleness. Computed AFTER the elision fingerprint, so each
|
|
622
|
+
// per-file hash already reflects the elision verdict (an elision
|
|
623
|
+
// flip changes the served output, so it moves this fingerprint too).
|
|
624
|
+
// This eagerly hashes the WHOLE browser-bound set once (not just the
|
|
625
|
+
// first route's files), but `assetHashFor` is memoized and already
|
|
626
|
+
// runs for `?v` emission in prod, so it stays a one-time bounded cost
|
|
627
|
+
// inside the lazy analysis (never at boot).
|
|
628
|
+
if (!dev && state.browserBoundFiles) {
|
|
629
|
+
const relApp = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
|
|
630
|
+
const lines = [...state.browserBoundFiles]
|
|
631
|
+
.map((abs) => `${relApp(abs)}:${assetHashFor(abs)}`)
|
|
632
|
+
.sort();
|
|
633
|
+
setAppSourceFingerprint(lines.join('\n'));
|
|
634
|
+
} else {
|
|
635
|
+
setAppSourceFingerprint('');
|
|
636
|
+
}
|
|
637
|
+
// App-source deploy SIGNAL (#899), distinct from the #318 html-cache
|
|
638
|
+
// fingerprint above: hashes ALL app source (the module-graph `seen`
|
|
639
|
+
// set, INCLUDING server-only `.server.ts` that `browserBoundFiles`
|
|
640
|
+
// omits) with a raw per-file byte digest (decoupled from the
|
|
641
|
+
// asset-hash memo/elision machinery), plus the installed
|
|
642
|
+
// `@webjsdev/server` version, so an app-source OR a server-framework
|
|
643
|
+
// deploy moves it. Sorted + appDir-relative for determinism across
|
|
644
|
+
// instances. Drives the client's soft cache-evict (not a reload).
|
|
645
|
+
// PROD only; empty in dev (fs.watch + SSE handle staleness there).
|
|
646
|
+
if (!dev && state.moduleGraph) {
|
|
647
|
+
const relApp = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
|
|
648
|
+
const srcLines = [...seenFilesFor(state.moduleGraph)]
|
|
649
|
+
.map((abs) => `${relApp(abs)}:${fileByteHash(abs)}`)
|
|
650
|
+
.sort();
|
|
651
|
+
srcLines.push(`@webjsdev/server:${frameworkServerVersion()}`);
|
|
652
|
+
setAppSourceId(srcLines.join('\n'));
|
|
653
|
+
} else {
|
|
654
|
+
setAppSourceId('');
|
|
655
|
+
}
|
|
656
|
+
t.elision = now() - m;
|
|
657
|
+
if (dev) {
|
|
658
|
+
// An orphan is EITHER shape `findOrphanComponents` reports: no
|
|
659
|
+
// registration call at all, or one whose tag is computed. The
|
|
660
|
+
// scanner matches only a literal tag, so it sees neither, and
|
|
661
|
+
// what is ALWAYS lost is the verdict, the registry entry, and the
|
|
662
|
+
// preload hint. The upgrade is the part that differs, and a
|
|
663
|
+
// computed tag only survives when its importer ships WHOLE (see
|
|
664
|
+
// findOrphanComponents for why that is the exception). The doctor
|
|
665
|
+
// check and `webjs elision` say the same thing.
|
|
666
|
+
for (const { className, file } of await findOrphanComponents(appDir)) {
|
|
667
|
+
logger.warn?.(
|
|
668
|
+
`[webjs] ${className} extends WebComponent but is never registered with a literal tag in ${file} ` +
|
|
669
|
+
`(either there is no registration call, or its tag is computed), so the scanner cannot see it: ` +
|
|
670
|
+
`no elision verdict, no registry entry, no preload hint. ` +
|
|
671
|
+
`<${kebab(className)}> tags never upgrade with no registration call, and a computed tag ` +
|
|
672
|
+
`upgrades only while its importing module ships whole. ` +
|
|
673
|
+
`Add \`${className}.register('<tag-name>');\` with a literal tag.`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
// The elision summary (#1308), one server-console line. NOT a
|
|
677
|
+
// browser push: an inert route ships zero application JS, and the
|
|
678
|
+
// most useful manual check an author has is opening the network
|
|
679
|
+
// tab on that route and seeing nothing, which a dev-only boot
|
|
680
|
+
// script would corrupt on exactly the pages this proves. The fact
|
|
681
|
+
// is also app-wide while a browser channel is per-tab, and the
|
|
682
|
+
// per-page detail already has a home in `webjs elision`.
|
|
683
|
+
// Re-emitted after each fs.watch rebuild, because doRebuild sets
|
|
684
|
+
// analysisDone = false and this sits inside that stage.
|
|
685
|
+
logger.info?.(
|
|
686
|
+
elideOn
|
|
687
|
+
? `[webjs] elision: ${r.elidableComponents.size}/${new Set(components.map((c) => c.file)).size} components elided, ` +
|
|
688
|
+
`${r.inertRouteModules.size} route modules inert, ${r.importOnlyRouteModules.size} import-only, ` +
|
|
689
|
+
`${r.shippedRouteModules.size} ship whole. Run \`webjs elision\` for the per-module verdict.`
|
|
690
|
+
: `[webjs] elision: disabled (WEBJS_ELIDE / webjs.elide), every module ships.`,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
analysisDone = true;
|
|
694
|
+
analysisEverDone = true;
|
|
695
|
+
ranAnalysis = true;
|
|
696
|
+
}
|
|
697
|
+
readyError = null;
|
|
698
|
+
if (!vendorResolved) {
|
|
699
|
+
const m = now();
|
|
700
|
+
const gen = vendorGen;
|
|
701
|
+
vendorAttemptedOnce = true;
|
|
702
|
+
const ok = await resolveAndApplyVendor();
|
|
703
|
+
t.vendor = now() - m;
|
|
704
|
+
ranVendor = true;
|
|
705
|
+
// Only memoize success (and only if a rebuild didn't intervene). A
|
|
706
|
+
// transient failure leaves vendorResolved false; the next ensureReady
|
|
707
|
+
// call re-attempts it non-blocking. A permanent unresolvable (jspm
|
|
708
|
+
// 401) reports ok and is tolerated, so it does not loop. On success
|
|
709
|
+
// the importmap is now authoritatively final, so publish the build
|
|
710
|
+
// id: from here every response advertises the same stable value and
|
|
711
|
+
// the client router's deploy detection works without warmup drift.
|
|
712
|
+
// A pinned app published the build id at boot (hash of the committed
|
|
713
|
+
// pin) and the prune only shrinks the served map, so do NOT re-publish
|
|
714
|
+
// (that would drift the id mid-process). An unpinned app publishes its
|
|
715
|
+
// now-final live map here.
|
|
716
|
+
if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); }
|
|
717
|
+
}
|
|
718
|
+
// Readiness reflects a FULLY warm instance: the deterministic analysis
|
|
719
|
+
// AND the first vendor attempt have both completed (note: completed,
|
|
720
|
+
// not necessarily succeeded). A readiness-gated platform (Railway
|
|
721
|
+
// healthcheckPath, k8s readinessProbe) therefore admits traffic only
|
|
722
|
+
// AFTER the build id is published (vendor resolved) or definitively
|
|
723
|
+
// empty (a bounded vendor failure), never DURING the vendor-resolution
|
|
724
|
+
// window. This is what makes warm-up actually protect users: the prior
|
|
725
|
+
// instance keeps serving until the new one is fully warm, so a real
|
|
726
|
+
// request lands on a warm instance with a stable build id instead of
|
|
727
|
+
// racing the resolve. The first vendor attempt is bounded (the jspm
|
|
728
|
+
// fetch timeout in vendor.js), so an offline / CDN-degraded app still
|
|
729
|
+
// becomes ready shortly after that timeout, degraded but reload-safe,
|
|
730
|
+
// which preserves the boot resilience #143 introduced. The gate is the
|
|
731
|
+
// FIRST attempt only: a transient failure still flips readyDone here,
|
|
732
|
+
// so a later non-blocking retry never has to re-open the readiness gate.
|
|
733
|
+
readyDone = true;
|
|
734
|
+
if (ranAnalysis) {
|
|
735
|
+
const ms = (x) => Math.round(x || 0);
|
|
736
|
+
const total = ms(t.graph) + ms(t.scan) + ms(t.gate) + ms(t.actions) + ms(t.middleware) + ms(t.elision) + ms(t.vendor);
|
|
737
|
+
logger.info?.(
|
|
738
|
+
`[webjs] analysis warm in ${total}ms (graph ${ms(t.graph)}, scan ${ms(t.scan)}, ` +
|
|
739
|
+
`gate ${ms(t.gate)}, actions ${ms(t.actions)}, middleware ${ms(t.middleware)}, ` +
|
|
740
|
+
`elision ${ms(t.elision)}, vendor ${ms(t.vendor)})`,
|
|
741
|
+
);
|
|
742
|
+
} else if (ranVendor && vendorResolved) {
|
|
743
|
+
logger.info?.(`[webjs] vendor resolved in ${Math.round(t.vendor || 0)}ms`);
|
|
744
|
+
}
|
|
745
|
+
} catch (e) {
|
|
746
|
+
readyError = e;
|
|
747
|
+
throw e;
|
|
748
|
+
} finally {
|
|
749
|
+
readyInFlight = null;
|
|
750
|
+
}
|
|
751
|
+
})();
|
|
752
|
+
}
|
|
753
|
+
await readyInFlight;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// All vendor resolves funnel through one single-flight so two never overlap
|
|
757
|
+
// (resolveVendorImports reports a transient failure via a module-global flag
|
|
758
|
+
// that only one in-flight resolve may safely touch). Never rejects; returns
|
|
759
|
+
// the resolve's ok flag (false on a transient failure, applying whatever
|
|
760
|
+
// partial map resolved so the app is no worse off).
|
|
761
|
+
/** @type {Promise<boolean> | null} */
|
|
762
|
+
let vendorResolveInFlight = null;
|
|
763
|
+
function resolveAndApplyVendor() {
|
|
764
|
+
if (vendorResolveInFlight) return vendorResolveInFlight;
|
|
765
|
+
vendorResolveInFlight = (async () => {
|
|
766
|
+
try {
|
|
767
|
+
const scan = async () => {
|
|
768
|
+
const skip = new Set([...state.elidableComponents, ...state.inertRouteModules, ...state.importOnlyRouteModules.keys()]);
|
|
769
|
+
return reachedBareImports(state.moduleGraph, [...state.browserEntryFiles], appDir, skip);
|
|
770
|
+
};
|
|
771
|
+
const v = await resolveVendorImports(appDir, scan);
|
|
772
|
+
let { imports, integrity } = v;
|
|
773
|
+
if (bootVendorPinned) {
|
|
774
|
+
// resolveVendorImports returns a committed pin VERBATIM (it never runs
|
|
775
|
+
// the scan for a pinned app). Prune it to the elision-reachable
|
|
776
|
+
// specifiers so a pinned app serves the same map an unpinned one does
|
|
777
|
+
// (#197): an elided-only dep like dayjs is dropped. One scan; the pin
|
|
778
|
+
// path skipped it. This runs on the first warm AND after every rebuild,
|
|
779
|
+
// so the pruned map is the single source of truth.
|
|
780
|
+
const reachable = await scan();
|
|
781
|
+
({ imports, integrity } = prunePinToReachable(imports, integrity, reachable));
|
|
782
|
+
}
|
|
783
|
+
await setVendorEntries(imports, integrity);
|
|
784
|
+
return v.ok;
|
|
785
|
+
} catch (e) {
|
|
786
|
+
logger.error?.(`[webjs] vendor resolve failed (will retry on the next request):`, e);
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
})().finally(() => { vendorResolveInFlight = null; });
|
|
790
|
+
return vendorResolveInFlight;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Optional app-level readiness check. A `readiness.{js,ts}` file at the app
|
|
794
|
+
// root may default-export an async function; /__webjs/ready runs it once the
|
|
795
|
+
// analysis is warm, so readiness can reflect LIVE dependency health (a DB
|
|
796
|
+
// ping, a queue connection) that the static analysis cannot see. Returning
|
|
797
|
+
// false or throwing reports the instance not ready (503), so a readinessProbe
|
|
798
|
+
// holds traffic off an instance whose deps are down. Absent file => analysis-
|
|
799
|
+
// warm is the only gate. The module is cached per build (cleared on rebuild);
|
|
800
|
+
// the function itself runs on every probe so it reflects current state.
|
|
801
|
+
let readinessFn;
|
|
802
|
+
async function getReadinessCheck() {
|
|
803
|
+
if (readinessFn !== undefined) return readinessFn;
|
|
804
|
+
let file = null;
|
|
805
|
+
for (const name of ['readiness.ts', 'readiness.js', 'readiness.mts', 'readiness.mjs']) {
|
|
806
|
+
const p = join(appDir, name);
|
|
807
|
+
if (await exists(p)) { file = p; break; }
|
|
808
|
+
}
|
|
809
|
+
if (!file) { readinessFn = null; return null; }
|
|
810
|
+
try {
|
|
811
|
+
const url = pathToFileURL(file).toString();
|
|
812
|
+
const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
|
|
813
|
+
const mod = await import(url + bust);
|
|
814
|
+
readinessFn = typeof mod.default === 'function' ? mod.default : null;
|
|
815
|
+
} catch (e) {
|
|
816
|
+
logger.error?.(`[webjs] failed to load readiness.{js,ts}`, { err: String(e) });
|
|
817
|
+
readinessFn = null;
|
|
818
|
+
}
|
|
819
|
+
return readinessFn;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Rebuilds are serialized so a slow rebuild #1 cannot overwrite a fresher
|
|
823
|
+
// rebuild #2's route table when it finally finishes. Without this, two file
|
|
824
|
+
// edits inside one fs.watch debounce window could produce a permanently
|
|
825
|
+
// stale state until the next rebuild.
|
|
826
|
+
let rebuildInFlight = Promise.resolve();
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* @param {import('../dev-classify.js').ReloadVerdict} [verdict] the live-reload
|
|
830
|
+
* classification of the change that triggered this rebuild (#1398). Absent
|
|
831
|
+
* (an embedded host calling `rebuild()`, or any caller with no filename to
|
|
832
|
+
* classify) means a full reload, which is the fail-safe default.
|
|
833
|
+
*/
|
|
834
|
+
async function rebuild(verdict) {
|
|
835
|
+
rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict)).catch((e) => {
|
|
836
|
+
logger.error?.(`[webjs] rebuild failed:`, e);
|
|
837
|
+
// Push the failure to the open tab so the overlay appears live after a
|
|
838
|
+
// breaking edit, not only on the next manual navigation (#264).
|
|
839
|
+
reportDevError(e, { kind: 'rebuild' });
|
|
840
|
+
});
|
|
841
|
+
return rebuildInFlight;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** @param {import('../dev-classify.js').ReloadVerdict} [verdict] */
|
|
845
|
+
async function doRebuild(verdict) {
|
|
846
|
+
// The route table is the only eager artifact (cheap directory scan); rebuild
|
|
847
|
+
// it so routing reflects added/removed route files immediately.
|
|
848
|
+
state.routeTable = await buildRouteTable(appDir);
|
|
849
|
+
// Adding or deleting app/icon.* changes whether the head auto-links it.
|
|
850
|
+
setMetadataIconRoutes(state.routeTable.metadataRoutes);
|
|
851
|
+
// Refresh the generated route types (#258) so adding/removing a route file
|
|
852
|
+
// updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
|
|
853
|
+
// best-effort (see emitRouteTypes).
|
|
854
|
+
if (dev) void emitRouteTypes();
|
|
855
|
+
clearVendorCache();
|
|
856
|
+
// Content-hash asset cache (#243): clear so a changed file re-hashes and
|
|
857
|
+
// its emitted `?v` busts the stale immutable copy. A no-op in dev (the
|
|
858
|
+
// module is never enabled there), kept for correctness + the deploy-busts
|
|
859
|
+
// regression test, which clears + re-hashes to observe a changed `?v`.
|
|
860
|
+
clearAssetHashCache();
|
|
861
|
+
state.tsCache.clear();
|
|
862
|
+
// Invalidate the lazy analysis; the next request rebuilds the graph,
|
|
863
|
+
// component scan, gate, action index, middleware, elision, and vendor map.
|
|
864
|
+
// Wait out any in-flight build first so it cannot commit stale results
|
|
865
|
+
// after the reset. A dependency edit can flip an elision verdict without
|
|
866
|
+
// changing an importer's mtime, hence the state.tsCache.clear above.
|
|
867
|
+
if (readyInFlight) { try { await readyInFlight; } catch {} }
|
|
868
|
+
// Bump the vendor generation so a vendor resolve still in flight from the
|
|
869
|
+
// previous build cannot flip vendorResolved against the fresh state.
|
|
870
|
+
vendorGen++;
|
|
871
|
+
analysisDone = false;
|
|
872
|
+
vendorResolved = false;
|
|
873
|
+
vendorAttemptedOnce = false;
|
|
874
|
+
readyDone = false;
|
|
875
|
+
readyError = null;
|
|
876
|
+
readinessFn = undefined;
|
|
877
|
+
// Refresh the on-request regenerate rules (#967) so a `webjs.dev.regenerate`
|
|
878
|
+
// config edit takes effect without a restart, mirroring the elide/router
|
|
879
|
+
// re-reads above. Dev-only; a no-op array in prod.
|
|
880
|
+
if (dev) state.regenerateRules = await readRegenerateRules(appDir);
|
|
881
|
+
// Optimistically clear the dev error (#264): the rebuild itself only
|
|
882
|
+
// re-scans the route table and INVALIDATES the lazy analysis (the real
|
|
883
|
+
// re-parse / re-strip / re-render happens on the next request), so we do
|
|
884
|
+
// not yet know the edit fixed the error. Clearing it here means a tab that
|
|
885
|
+
// connects now is not replayed a possibly-stale overlay; `onReload` then
|
|
886
|
+
// reloads every open tab, and if the underlying error is still present the
|
|
887
|
+
// reloaded request re-pushes a fresh frame (a brief dismiss-then-reappear
|
|
888
|
+
// flicker on an unrelated edit, self-correcting to the right end state).
|
|
889
|
+
state.lastDevError = null;
|
|
890
|
+
// The verdict was computed against the PREVIOUS build's graph, because this
|
|
891
|
+
// rebuild only INVALIDATES the lazy analysis (`analysisDone = false` above)
|
|
892
|
+
// and the fresh graph is not built until the next request. Awaiting
|
|
893
|
+
// `ensureReady()` here to classify against the new one would push the SSE
|
|
894
|
+
// frame roughly 1900ms later on a large app and stack that in front of the
|
|
895
|
+
// relay's quiet window, so it is not worth it. The one case that could
|
|
896
|
+
// exploit the staleness, an edit that adds a brand-new component import to
|
|
897
|
+
// an otherwise morphable page, is closed on the CLIENT: `addNewHeadElements`
|
|
898
|
+
// runs on both boundary tiers and swaps in the changed boot script, which
|
|
899
|
+
// executes and registers the new component.
|
|
900
|
+
opts.onReload?.(verdict || { v: 'reload', by: '', why: 'no-verdict' });
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** @param {Request} req */
|
|
904
|
+
function handle(req) {
|
|
905
|
+
return withRequest(req, async () => {
|
|
906
|
+
// Correlation id (issue #239): honor an inbound X-Request-Id from a
|
|
907
|
+
// trusted upstream proxy, else mint a fresh UUID. Stored on the request
|
|
908
|
+
// scope FIRST so everything downstream (the SSR, server actions, the
|
|
909
|
+
// access / error log, the onError sink, the response header) reads the
|
|
910
|
+
// same id, threading one trace id across services.
|
|
911
|
+
const reqId = resolveRequestId(req);
|
|
912
|
+
setRequestId(reqId);
|
|
913
|
+
|
|
914
|
+
// CSP (issue #233): when enabled, mint a fresh CSPRNG nonce and store
|
|
915
|
+
// it on the request scope BEFORE producing the response, so the SSR
|
|
916
|
+
// pipeline's `cspNonce()` reads this exact value and stamps it on the
|
|
917
|
+
// inline boot script, the importmap, and the modulepreload hints.
|
|
918
|
+
// Disabled by default, so no nonce is minted and the response is
|
|
919
|
+
// unchanged. One minted value flows mint -> store -> SSR -> header.
|
|
920
|
+
const nonce = cspConfig.enabled ? mintNonce() : '';
|
|
921
|
+
if (nonce) setCspNonce(nonce);
|
|
922
|
+
|
|
923
|
+
// Make the resolved body-size limits (issue #237) readable from the
|
|
924
|
+
// request scope, so `readBody` inside a route handler enforces the same
|
|
925
|
+
// cap the framework's own RPC / form-dispatch body reads do.
|
|
926
|
+
setBodyLimits(state.bodyLimits);
|
|
927
|
+
|
|
928
|
+
let pathname = '/';
|
|
929
|
+
try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
|
|
930
|
+
// Sub-path deployment (issue #256): the per-path response-header rules
|
|
931
|
+
// (`webjs.headers`) author their `source` patterns app-root-relative,
|
|
932
|
+
// exactly like `webjs.redirects` / `webjs.trailingSlash` (which the
|
|
933
|
+
// ingress strip in produce() already sees root-relative). So match the
|
|
934
|
+
// header rules against the STRIPPED path too, keeping the whole config
|
|
935
|
+
// surface consistent under a base path. `pathname` itself (used for the
|
|
936
|
+
// access log) stays the RAW path the client hit. No-op when basePath is
|
|
937
|
+
// empty or the request is not under it.
|
|
938
|
+
let headerPathname = pathname;
|
|
939
|
+
if (basePathValue) {
|
|
940
|
+
const s = stripBasePath(pathname, basePathValue);
|
|
941
|
+
if (s !== null) headerPathname = s;
|
|
942
|
+
}
|
|
943
|
+
const startedAt = performance.now();
|
|
944
|
+
|
|
945
|
+
let res;
|
|
946
|
+
try {
|
|
947
|
+
res = await produce(req);
|
|
948
|
+
} catch (e) {
|
|
949
|
+
// A throw escaping produce() is the last-resort 500 (every interior
|
|
950
|
+
// path catches its own errors, but a surprise still must not crash the
|
|
951
|
+
// host). Fire the onError sink (best-effort) and emit a sanitized 500,
|
|
952
|
+
// preserving the prior behavior plus the new APM hook.
|
|
953
|
+
reportError(e, req, 'handle');
|
|
954
|
+
logger.error?.('[webjs] request pipeline threw', {
|
|
955
|
+
requestId: reqId,
|
|
956
|
+
method: req.method,
|
|
957
|
+
path: pathname,
|
|
958
|
+
err: e instanceof Error ? e.stack : String(e),
|
|
959
|
+
});
|
|
960
|
+
res = new Response('Server error', { status: 500 });
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Merge in the secure-by-default headers plus the per-path config
|
|
964
|
+
// (issue #232) as the final step, so app middleware, route
|
|
965
|
+
// handlers, and `expose` headers (already on `res`) always win.
|
|
966
|
+
// Applied to every served response (documents, assets, the core
|
|
967
|
+
// runtime, probes), since the defaults are universally safe.
|
|
968
|
+
let merged = applySecurityHeaders(res, {
|
|
969
|
+
pathname: headerPathname,
|
|
970
|
+
https: webRequestIsHttps(req),
|
|
971
|
+
prod: !dev,
|
|
972
|
+
rules: headerRules,
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
// Expose the correlation id on the response (issue #239) so a client /
|
|
976
|
+
// proxy can read it from the X-Request-Id header. Never clobber an id an
|
|
977
|
+
// upstream / the app already set on the response.
|
|
978
|
+
if (!merged.headers.has('x-request-id')) merged.headers.set('x-request-id', reqId);
|
|
979
|
+
|
|
980
|
+
// Emit the Content-Security-Policy header carrying the SAME minted
|
|
981
|
+
// nonce the SSR'd scripts got (no drift). Set only when CSP is
|
|
982
|
+
// enabled; never clobber a CSP header the app already set (in
|
|
983
|
+
// middleware, a route handler, or via the webjs.headers config), so
|
|
984
|
+
// an explicit app policy still wins.
|
|
985
|
+
if (nonce && !merged.headers.has('content-security-policy') &&
|
|
986
|
+
!merged.headers.has('content-security-policy-report-only')) {
|
|
987
|
+
try {
|
|
988
|
+
// readCspConfig already drops a directive whose name/value carries a
|
|
989
|
+
// control char, so buildCspHeader produces a Headers-safe value. The
|
|
990
|
+
// try/catch is a belt-and-suspenders backstop: a surprise value must
|
|
991
|
+
// never throw the response pipeline (fail closed to no CSP header
|
|
992
|
+
// rather than a self-inflicted 500 on every request).
|
|
993
|
+
merged.headers.set(cspHeaderName(cspConfig), buildCspHeader(cspConfig, nonce));
|
|
994
|
+
} catch {
|
|
995
|
+
/* a malformed policy must not take the request down: serve without CSP */
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Server HTML cache write (#241): if the SSR marked this response as a
|
|
1000
|
+
// cache candidate (an opted-in `revalidate` page), store the FINAL body
|
|
1001
|
+
// now, after segment middleware has added any per-user Set-Cookie (which
|
|
1002
|
+
// the funnel's guard re-checks, so a per-user response is not cached) and
|
|
1003
|
+
// before conditional-GET can swap it for a bodiless 304. The marker is
|
|
1004
|
+
// stripped here regardless. Best-effort: a store failure is swallowed.
|
|
1005
|
+
try {
|
|
1006
|
+
const reqUrl = new URL(req.url);
|
|
1007
|
+
// Sub-path deployment (issue #256): the HTML cache READ (in ssrPage)
|
|
1008
|
+
// and `revalidatePath` both key on the app-root-relative path, so key
|
|
1009
|
+
// the WRITE on the stripped path too, or a cached page would never
|
|
1010
|
+
// hit. No-op when basePath is empty / the path is not under it.
|
|
1011
|
+
if (basePathValue) {
|
|
1012
|
+
const s = stripBasePath(reqUrl.pathname, basePathValue);
|
|
1013
|
+
if (s !== null) reqUrl.pathname = s;
|
|
1014
|
+
}
|
|
1015
|
+
merged = await commitHtmlCache(req, merged, reqUrl);
|
|
1016
|
+
} catch { /* ignore */ }
|
|
1017
|
+
|
|
1018
|
+
// Conditional GET (RFC 7232, issue #240): attach a content-hash ETag to
|
|
1019
|
+
// a cacheable response missing one, and turn a matching If-None-Match
|
|
1020
|
+
// into a 304 Not Modified with no body. Applied LAST, after every header
|
|
1021
|
+
// (X-Webjs-Build, X-Request-Id, Set-Cookie, CSP) is on the response, so a
|
|
1022
|
+
// 304 carries the validators a shared cache and the client router need.
|
|
1023
|
+
// A no-store response, a non-GET/HEAD, and a streaming Suspense body are
|
|
1024
|
+
// all skipped (see conditional-get.js). A `private` response is NOT
|
|
1025
|
+
// skipped: private forbids shared storage, not validation (#1140). Logged with the final
|
|
1026
|
+
// (possibly 304) status. Best-effort: a failure leaves the 200 untouched.
|
|
1027
|
+
let conditioned = merged;
|
|
1028
|
+
try {
|
|
1029
|
+
conditioned = await applyConditionalGet(req, merged);
|
|
1030
|
+
} catch { /* ignore */ }
|
|
1031
|
+
|
|
1032
|
+
// Structured access log (issue #239): ONE info line per handled request
|
|
1033
|
+
// at the single response funnel, carrying only method / path / status /
|
|
1034
|
+
// duration / requestId (no bodies, no secrets). Suppressed for the
|
|
1035
|
+
// framework's own /__webjs/* probe + static traffic so it does not spam.
|
|
1036
|
+
// Best-effort: a logger that throws must not take the response down.
|
|
1037
|
+
// Use the STRIPPED path for the suppression check (issue #256) so a
|
|
1038
|
+
// framework probe at `<basePath>/__webjs/*` is suppressed just like the
|
|
1039
|
+
// root-mounted `/__webjs/*`. The logged `path` stays the RAW client URL.
|
|
1040
|
+
if (shouldAccessLog(headerPathname)) {
|
|
1041
|
+
try {
|
|
1042
|
+
// #1309: fold the dev-only seeding counters into the ONE access-log
|
|
1043
|
+
// line rather than adding a second one. Present only on a response
|
|
1044
|
+
// that carried the header, so only page renders gain the field and
|
|
1045
|
+
// production is unchanged.
|
|
1046
|
+
const seed = dev ? conditioned.headers.get('x-webjs-seed') : null;
|
|
1047
|
+
logger.info?.('request', {
|
|
1048
|
+
requestId: reqId,
|
|
1049
|
+
method: req.method,
|
|
1050
|
+
path: pathname,
|
|
1051
|
+
status: conditioned.status,
|
|
1052
|
+
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
1053
|
+
...(seed ? { seed } : {}),
|
|
1054
|
+
});
|
|
1055
|
+
} catch { /* never let logging crash the response */ }
|
|
1056
|
+
}
|
|
1057
|
+
return conditioned;
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** @param {Request} req */
|
|
1062
|
+
async function produce(req) {
|
|
1063
|
+
const rawUrl = new URL(req.url);
|
|
1064
|
+
|
|
1065
|
+
// Sub-path deployment ingress strip (issue #256). When `webjs.basePath`
|
|
1066
|
+
// is set and the request path is under it, STRIP the prefix and rewrite
|
|
1067
|
+
// the Request so EVERYTHING downstream (redirects, trailing-slash, the
|
|
1068
|
+
// probes, the `/__webjs/*` checks, the source-file gate, route matching,
|
|
1069
|
+
// SSR) sees a ROOT-relative path and works UNCHANGED. This single strip
|
|
1070
|
+
// is why the rest of the framework needs no per-site changes. A request
|
|
1071
|
+
// whose path is NOT under the base path is not for this mounted app, so
|
|
1072
|
+
// return a 404 (the safe default for a mounted app). Empty basePath (the
|
|
1073
|
+
// default) is a pure pass-through, so an unconfigured app is unchanged.
|
|
1074
|
+
if (basePathValue) {
|
|
1075
|
+
const stripped = stripBasePath(rawUrl.pathname, basePathValue);
|
|
1076
|
+
if (stripped === null) {
|
|
1077
|
+
// Not under the base path: this request is not for this app.
|
|
1078
|
+
return new Response('Not found', {
|
|
1079
|
+
status: 404,
|
|
1080
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
const newUrl = new URL(req.url);
|
|
1084
|
+
newUrl.pathname = stripped;
|
|
1085
|
+
// Rewrite the Request with the stripped URL, preserving method,
|
|
1086
|
+
// headers, and body. `duplex: 'half'` is required by the spec when
|
|
1087
|
+
// a body stream is present on a non-GET/HEAD request.
|
|
1088
|
+
//
|
|
1089
|
+
// SECURITY (#756): this is a fresh Request object, so it is NOT in the
|
|
1090
|
+
// listener's out-of-band trusted-IP WeakMap (the Bun shell stamps the IP
|
|
1091
|
+
// there instead of cloning the Request to set the header). Without
|
|
1092
|
+
// carrying it forward, `clientIp` would fall back to the inbound
|
|
1093
|
+
// `x-webjs-remote-ip` header that the copied `req.headers` still carries,
|
|
1094
|
+
// which a client can spoof. So strip that header on the rebuild and
|
|
1095
|
+
// propagate the framework-trusted IP across the new object, the same
|
|
1096
|
+
// pattern form-dispatch.js uses.
|
|
1097
|
+
const headers = new Headers(req.headers);
|
|
1098
|
+
headers.delete('x-webjs-remote-ip');
|
|
1099
|
+
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
|
1100
|
+
const next = new Request(newUrl, /** @type {any} */ ({
|
|
1101
|
+
method: req.method,
|
|
1102
|
+
headers,
|
|
1103
|
+
body: hasBody ? req.body : undefined,
|
|
1104
|
+
duplex: hasBody ? 'half' : undefined,
|
|
1105
|
+
redirect: req.redirect,
|
|
1106
|
+
signal: req.signal,
|
|
1107
|
+
}));
|
|
1108
|
+
propagateTrustedRemoteIp(req, next);
|
|
1109
|
+
req = next;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (redirectRules.length) {
|
|
1113
|
+
// Declarative redirects (issue #254): apply the configured old-path ->
|
|
1114
|
+
// new-path rules at the VERY START of request handling, before the
|
|
1115
|
+
// probes, routing, SSR, or asset serving. A matched source returns a
|
|
1116
|
+
// 308 (permanent, the SEO default) / 307 (temporary) / configured
|
|
1117
|
+
// status immediately, so a moved URL never reaches the router.
|
|
1118
|
+
// `applyRedirects` skips /__webjs/* itself, so the framework probes /
|
|
1119
|
+
// runtime below are never redirected. The secure-header + conditional-GET
|
|
1120
|
+
// funnel in handle() still wraps this Response, like any other.
|
|
1121
|
+
const redir = applyRedirects(req, redirectRules);
|
|
1122
|
+
if (redir) return redir;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (trailingSlashPolicy !== 'ignore') {
|
|
1126
|
+
// Trailing-slash canonicalization (issue #255): after the explicit
|
|
1127
|
+
// redirects above (so an explicit rule wins first and the two never
|
|
1128
|
+
// form a loop), 308-redirect a non-canonical path to the policy's
|
|
1129
|
+
// canonical form (`never` strips a trailing slash, `always` adds one).
|
|
1130
|
+
// Default `'ignore'` is a no-op. The root `/` and file paths are
|
|
1131
|
+
// exempt; `/__webjs/*` is exempt too (defense in depth, the redirects
|
|
1132
|
+
// above already skip it). The funnel in handle() still wraps this.
|
|
1133
|
+
const canonical = applyTrailingSlash(req, trailingSlashPolicy);
|
|
1134
|
+
if (canonical) return canonical;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const url = new URL(req.url);
|
|
1138
|
+
let path;
|
|
1139
|
+
try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
|
|
1140
|
+
// The probes match the RAW pathname, never the decoded one. Decoding first
|
|
1141
|
+
// would make `/__webjs%2Fhealth` answer the liveness probe, which is a
|
|
1142
|
+
// different url that happens to decode to the same string. Guarded, because
|
|
1143
|
+
// an unparseable url must fall through rather than throw out of `produce`.
|
|
1144
|
+
let probePath;
|
|
1145
|
+
try { probePath = new URL(req.url).pathname; } catch { probePath = ''; }
|
|
1146
|
+
|
|
1147
|
+
// Health and readiness probes are answered BEFORE ensureReady so a probe
|
|
1148
|
+
// never blocks on the analysis. `/__webjs/health` is liveness (the
|
|
1149
|
+
// process is up and accepting connections). `/__webjs/ready` is 503 until
|
|
1150
|
+
// the instance is FULLY warm (the deterministic analysis AND the first
|
|
1151
|
+
// vendor attempt have both completed, so the importmap build id is
|
|
1152
|
+
// settled), then 200 unless an optional app readiness check
|
|
1153
|
+
// (readiness.{js,ts}) reports a dependency down. So a readinessProbe holds
|
|
1154
|
+
// traffic off a not-yet-warm or dependency-unhealthy instance, and admits
|
|
1155
|
+
// it only once the build id is stable, never mid vendor-resolution.
|
|
1156
|
+
// Probing `/__webjs/ready` also kicks off the warm in the background, so
|
|
1157
|
+
// an embedder that never called warmup() still warms. The first vendor
|
|
1158
|
+
// attempt is bounded (the jspm fetch timeout), so a vendor CDN failure
|
|
1159
|
+
// delays readiness only briefly and then admits the instance (degraded but
|
|
1160
|
+
// reload-safe); a transient failure is re-attempted on the next request.
|
|
1161
|
+
if (probePath === '/__webjs/health') {
|
|
1162
|
+
return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Build-info probe (issue #239): which build is live? Answered before
|
|
1166
|
+
// ensureReady like the other probes (it depends only on the package
|
|
1167
|
+
// version + already-published build id + process info, never the app
|
|
1168
|
+
// analysis). No secrets.
|
|
1169
|
+
if (probePath === '/__webjs/version') {
|
|
1170
|
+
return buildInfoResponse();
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
if (probePath === '/__webjs/ready') {
|
|
1174
|
+
const noStore = { 'cache-control': 'no-store' };
|
|
1175
|
+
if (!readyDone) {
|
|
1176
|
+
ensureReady().catch(() => {}); // drive the warm; never block the probe
|
|
1177
|
+
const body = readyError
|
|
1178
|
+
? { status: 'error', error: String((readyError && readyError.message) || readyError) }
|
|
1179
|
+
: { status: 'pending' };
|
|
1180
|
+
return Response.json(body, { status: 503, headers: noStore });
|
|
1181
|
+
}
|
|
1182
|
+
// Analysis is warm. Consult the optional app readiness check (live
|
|
1183
|
+
// dependency health, e.g. a DB ping) if the app provides one.
|
|
1184
|
+
const check = await getReadinessCheck();
|
|
1185
|
+
if (check) {
|
|
1186
|
+
try {
|
|
1187
|
+
if ((await check()) === false) {
|
|
1188
|
+
return Response.json({ status: 'unready' }, { status: 503, headers: noStore });
|
|
1189
|
+
}
|
|
1190
|
+
} catch (e) {
|
|
1191
|
+
return Response.json(
|
|
1192
|
+
{ status: 'unready', error: String((e && e.message) || e) },
|
|
1193
|
+
{ status: 503, headers: noStore },
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return Response.json({ status: 'ok' }, { headers: noStore });
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Framework-internal static assets (the @webjsdev/core runtime, the dev
|
|
1201
|
+
// reload client, downloaded vendor bundles) depend on neither the analysis
|
|
1202
|
+
// nor the vendor importmap, so serve them BEFORE ensureReady(). Otherwise a
|
|
1203
|
+
// cold instance blocks them behind the first vendor resolve (issue #190),
|
|
1204
|
+
// and the core bundle is on every page's boot path, so that stalled first
|
|
1205
|
+
// interactivity site-wide. Matched on the decoded path, like handleCore.
|
|
1206
|
+
//
|
|
1207
|
+
// Content-hash fingerprint (#243): a `?v=` query means an immutable,
|
|
1208
|
+
// content-addressed asset url, so it is served `immutable` rather than
|
|
1209
|
+
// with the 1h fallback.
|
|
1210
|
+
const earlyStatic = await tryServeFrameworkStatic(path, req.method.toUpperCase(), { coreDir, appDir, dev, versioned: url.searchParams.has('v') });
|
|
1211
|
+
if (earlyStatic) return earlyStatic;
|
|
1212
|
+
|
|
1213
|
+
// `/public/*` needs neither the module graph nor the vendor importmap, so
|
|
1214
|
+
// in DEV serve it here rather than behind the analysis (#1397). A cold
|
|
1215
|
+
// `/public/tailwind.css` measured 1907ms at 4a335549, of which 1900ms was
|
|
1216
|
+
// `ensureReady()`, which is why the stylesheet is the request most exposed
|
|
1217
|
+
// to the next `node --watch` restart. DEV ONLY on purpose: in prod
|
|
1218
|
+
// `/__webjs/ready` already holds traffic off a cold instance until the
|
|
1219
|
+
// analysis and the first vendor attempt have both completed, and hoisting
|
|
1220
|
+
// there would silently un-gate a `/public/*` file that an app middleware
|
|
1221
|
+
// protects. The consequence in dev is that root middleware does not run
|
|
1222
|
+
// for these paths, the same trade the framework statics above already make
|
|
1223
|
+
// in both modes. `state.regenerateRules` is loaded at boot inside
|
|
1224
|
+
// createRequestHandler, so the #967 on-request rebuild is available here,
|
|
1225
|
+
// ahead of ensureReady.
|
|
1226
|
+
if (dev) {
|
|
1227
|
+
const publicResp = await tryServePublicAsset(path, {
|
|
1228
|
+
appDir, dev, versioned: url.searchParams.has('v'), regenerateRules: state.regenerateRules,
|
|
1229
|
+
});
|
|
1230
|
+
if (publicResp) return publicResp;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Build all whole-app analysis on the first request (memoized), before
|
|
1234
|
+
// any SSR, module serve, gate check, action dispatch, or middleware runs.
|
|
1235
|
+
await ensureReady();
|
|
1236
|
+
|
|
1237
|
+
const next = () => handleCore(req, {
|
|
1238
|
+
state,
|
|
1239
|
+
appDir,
|
|
1240
|
+
coreDir,
|
|
1241
|
+
dev,
|
|
1242
|
+
reportError,
|
|
1243
|
+
reportDevError,
|
|
1244
|
+
hasOnError,
|
|
1245
|
+
logger,
|
|
1246
|
+
cspEnabled: cspConfig.enabled,
|
|
1247
|
+
allowedOrigins: allowedOriginsValue,
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
if (state.middleware) {
|
|
1251
|
+
try {
|
|
1252
|
+
return await state.middleware(req, next);
|
|
1253
|
+
} catch (e) {
|
|
1254
|
+
reportError(e, req, 'middleware');
|
|
1255
|
+
logger.error('middleware threw', { err: String(e), requestId: getRequestId() });
|
|
1256
|
+
return new Response('Server error', { status: 500 });
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return next();
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Lightweight lookup used by the HTTP layer to emit 103 Early Hints
|
|
1264
|
+
* BEFORE running SSR: resolves a pathname to its page-route module URLs
|
|
1265
|
+
* without loading them. Returns null for non-page paths.
|
|
1266
|
+
*
|
|
1267
|
+
* Sub-path deployment (issue #256): the HTTP layer passes the RAW request
|
|
1268
|
+
* pathname (still carrying the base path, since the ingress strip happens
|
|
1269
|
+
* inside `produce`, not here), so strip it for route matching and prefix
|
|
1270
|
+
* the emitted module URLs so the early-hint preloads resolve under the
|
|
1271
|
+
* prefix. A path not under the base path yields null (no hints).
|
|
1272
|
+
*
|
|
1273
|
+
* @param {string} pathname
|
|
1274
|
+
*/
|
|
1275
|
+
function routeFor(pathname) {
|
|
1276
|
+
const matchPathname = basePathValue
|
|
1277
|
+
? stripBasePath(pathname, basePathValue)
|
|
1278
|
+
: pathname;
|
|
1279
|
+
if (matchPathname === null) return null;
|
|
1280
|
+
const page = matchPage(state.routeTable, matchPathname);
|
|
1281
|
+
if (!page) return null;
|
|
1282
|
+
// Mirror ssr.js's boot assembly EXACTLY: drop inert route modules, and
|
|
1283
|
+
// splice an import-only module's component imports in place of the module
|
|
1284
|
+
// (#605). Otherwise the 103 Early Hints would preload the page / layout
|
|
1285
|
+
// modules the body no longer imports (a wasted hint) and miss the component
|
|
1286
|
+
// modules it actually fetches (a double-fetch on the real module).
|
|
1287
|
+
const inert = state.inertRouteModules;
|
|
1288
|
+
const importOnly = state.importOnlyRouteModules;
|
|
1289
|
+
const files = [];
|
|
1290
|
+
const seenFiles = new Set();
|
|
1291
|
+
for (const f of [page.route.file, ...page.route.layouts]) {
|
|
1292
|
+
if (inert && inert.has(f)) continue;
|
|
1293
|
+
const emit = importOnly && importOnly.get(f);
|
|
1294
|
+
for (const t of emit || [f]) if (!seenFiles.has(t)) { seenFiles.add(t); files.push(t); }
|
|
1295
|
+
}
|
|
1296
|
+
const moduleUrls = files.map((f) => {
|
|
1297
|
+
let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
|
|
1298
|
+
const url = rel.split('\\').join('/').replace(/^\/?/, '/');
|
|
1299
|
+
// Mirror ssr.js's emit (basePath THEN `?v`) so the 103 Early Hints preload
|
|
1300
|
+
// the SAME url the body's modulepreload + boot specifiers request (#243).
|
|
1301
|
+
// A bare url would warm a different url and waste the hint. No-op in dev.
|
|
1302
|
+
// Best-effort: a request landing in the narrow pre-warm window (before the
|
|
1303
|
+
// elision verdict is set) hashes against an empty fingerprint, so the hint
|
|
1304
|
+
// can mismatch the post-warm body for that one request, only a wasted
|
|
1305
|
+
// speculative preload, never a stale body (the body url is authoritative).
|
|
1306
|
+
return withAssetHash(withBasePath(url, basePathValue), basePathValue);
|
|
1307
|
+
});
|
|
1308
|
+
return { moduleUrls };
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
return {
|
|
1312
|
+
handle,
|
|
1313
|
+
rebuild,
|
|
1314
|
+
routeFor,
|
|
1315
|
+
/**
|
|
1316
|
+
* Proactively run the first-request analysis (module graph, component
|
|
1317
|
+
* scan, gate, action index, middleware, elision, vendor map) in the
|
|
1318
|
+
* background, so a real first request finds it already memoized. Safe to
|
|
1319
|
+
* call any number of times and concurrently: the work is single-flighted,
|
|
1320
|
+
* so this never duplicates it or races a real request. It is a single
|
|
1321
|
+
* best-effort kick: errors are caught and logged rather than thrown (a
|
|
1322
|
+
* background warm-up must not crash the process), and whatever failed simply
|
|
1323
|
+
* re-runs on the next request or readiness probe (the platform's traffic and
|
|
1324
|
+
* probes are the retry loop, so there is no internal backoff). `startServer`
|
|
1325
|
+
* calls this once the HTTP server is listening; embedders can call it after
|
|
1326
|
+
* their own listen.
|
|
1327
|
+
* @returns {Promise<void>}
|
|
1328
|
+
*/
|
|
1329
|
+
warmup: () => ensureReady().catch((e) => logger.error?.(`[webjs] background warm-up failed (will retry on the next request):`, e)),
|
|
1330
|
+
/** current route table getter: used by the WebSocket subsystem */
|
|
1331
|
+
getRouteTable: () => state.routeTable,
|
|
1332
|
+
/** Current unresolved dev error frame (#264), or null. Replayed by
|
|
1333
|
+
* startServer to a freshly-connected SSE client so the overlay shows even
|
|
1334
|
+
* after a navigation, not only on the breaking edit. Always null in prod. */
|
|
1335
|
+
getLastDevError: () => state.lastDevError,
|
|
1336
|
+
/** Whether a dev-watcher filename is a `webjs.dev.regenerate` OUTPUT (#967),
|
|
1337
|
+
* so `startServer`'s file watcher (a different scope, with no access to
|
|
1338
|
+
* `state`) can skip a build product the server itself writes and avoid a
|
|
1339
|
+
* spurious reload. Reads the live, rebuild-refreshed rules. */
|
|
1340
|
+
isRegenerateOutput: (filename) => isRegenerateOutputPath(filename, state.regenerateRules),
|
|
1341
|
+
/**
|
|
1342
|
+
* Classify a dev-watcher event into a live-reload verdict (#1398).
|
|
1343
|
+
* `startServer`'s watcher is a different scope with no access to `state`,
|
|
1344
|
+
* exactly like `isRegenerateOutput` above, so the lookup is exposed rather
|
|
1345
|
+
* than the sets.
|
|
1346
|
+
*
|
|
1347
|
+
* `root` is the WATCHED root the event came from, not `appDir`: `fs.watch`
|
|
1348
|
+
* reports a path relative to whatever root it was given, and an extra
|
|
1349
|
+
* `webjs.dev.watch` root (#894) is usually OUTSIDE the app. Resolving those
|
|
1350
|
+
* against `appDir` would manufacture a nonexistent in-app path and lose the
|
|
1351
|
+
* one fact the classifier needs about them.
|
|
1352
|
+
*
|
|
1353
|
+
* Returns the fail-safe `reload` verdict until the first analysis completes.
|
|
1354
|
+
*
|
|
1355
|
+
* @param {string} filename `event.filename`, relative to `root`
|
|
1356
|
+
* @param {string} [root] the watched root, defaulting to the app dir
|
|
1357
|
+
* @returns {import('../dev-classify.js').ReloadVerdict}
|
|
1358
|
+
*/
|
|
1359
|
+
classifyWatchPath: (filename, root) => classifyChangedPath(resolve(root || appDir, filename), {
|
|
1360
|
+
appDir,
|
|
1361
|
+
shippedFiles: state.shippedFiles,
|
|
1362
|
+
graphFiles: state.graphFiles,
|
|
1363
|
+
pageFiles: state.pageFiles,
|
|
1364
|
+
analysisReady: analysisEverDone,
|
|
1365
|
+
sep,
|
|
1366
|
+
}),
|
|
1367
|
+
appDir,
|
|
1368
|
+
dev,
|
|
1369
|
+
logger,
|
|
1370
|
+
state,
|
|
1371
|
+
};
|
|
1372
|
+
}
|