@webjsdev/server 0.8.91 → 0.10.0
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/README.md +4 -1
- package/index.d.ts +461 -102
- package/index.js +9 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-input.js +208 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +112 -15
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/helpers.js +85 -0
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +133 -28
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +109 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +24 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +262 -0
- package/src/core-exports.js +190 -0
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/dev-status.js +122 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +632 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +191 -133
- package/src/dev/server.js +109 -37
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +322 -78
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev-styles.js +64 -6
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +150 -69
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +49 -17
- package/src/ssr/head.js +228 -25
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +243 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +52 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +85 -17
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +3 -8
- package/src/component-elision.js +0 -1852
- package/src/elision-differential.js +0 -101
- package/src/elision-report.js +0 -193
package/src/dev/handler.js
CHANGED
|
@@ -4,7 +4,7 @@ import { registerBunAppSource } from './bun-app-source.js';
|
|
|
4
4
|
import { evictPoisonedBunTranspilerCache } from '../bun-transpiler-cache.js';
|
|
5
5
|
import { serverRuntime } from '../listener-core.js';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
|
-
import { join, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
8
8
|
|
|
9
9
|
// Server-side `.ts` imports are handled natively by Node 24+'s default
|
|
10
10
|
// type-stripping (`process.features.typescript === 'strip'`) or by Bun. The
|
|
@@ -16,32 +16,47 @@ import { generateRouteTypes } from '../route-types.js';
|
|
|
16
16
|
import { setClientRouterEnabled, setMetadataIconRoutes } from '../ssr.js';
|
|
17
17
|
import {
|
|
18
18
|
buildActionIndex,
|
|
19
|
+
isServerFile,
|
|
20
|
+
hasUseServerDirective,
|
|
19
21
|
} from '../actions.js';
|
|
20
22
|
import { registerActionHooks } from '../action-seed.js';
|
|
23
|
+
import { installEsmHotHooks, installBunModuleVersions, isServedModule, bumpModuleVersions, movedServedModules, diskStamp, isCommonJsModule } from './esm-hot-hooks.js';
|
|
24
|
+
import { createBrowserVersions, shippedImporters } from './specifier-versions.js';
|
|
21
25
|
import { sourceLocationsRequested } from '../dev-source-locations.js';
|
|
22
26
|
import { registerSourceLocationHook } from '../dev-source-locations-hook.js';
|
|
23
27
|
import { readRegenerateRules, isRegenerateOutputPath } from '../dev-regenerate.js';
|
|
24
|
-
import { classifyChangedPath } from '../dev-classify.js';
|
|
25
|
-
import { ensureStripper } from '../ts-strip.js';
|
|
28
|
+
import { classifyChangedPath, mergeVerdicts } from '../dev-classify.js';
|
|
29
|
+
import { ensureStripper, stripTypeScript } from '../ts-strip.js';
|
|
30
|
+
import { hotSignature, findHotBoundaries, primeHotBaselines } from '../dev-hot-classify.js';
|
|
31
|
+
import { toUrlPath } from '../ssr/preloads.js';
|
|
26
32
|
import { defaultLogger } from '../logger.js';
|
|
27
33
|
import { assertNodeVersion } from '../node-version.js';
|
|
28
34
|
import { applyEnvValidation } from '../env-schema.js';
|
|
29
|
-
import { runInstrumentation } from '../instrumentation.js';
|
|
30
|
-
import {
|
|
35
|
+
import { runInstrumentation, buildRequestErrorReport } from '../instrumentation.js';
|
|
36
|
+
import { validateAppLayout, AppLayoutError, cssImportProblems } from '../app-layout.js';
|
|
37
|
+
import { applyPendingCookies } from '../cookie-store.js';
|
|
38
|
+
import { loadRootMiddleware, findRootMiddleware } from '../middleware-load.js';
|
|
39
|
+
import { loadEnvFiles, envMode } from '../env-files.js';
|
|
40
|
+
import { buildPublicIndex } from '../public-files.js';
|
|
41
|
+
import { withRequest, setCspNonce, setBodyLimits, setAllowedOrigins, setRequestId, requestId as getRequestId, currentCookieStore, setComponentErrorSink, sealCookies } from '../context.js';
|
|
31
42
|
import { buildInfoResponse } from '../build-info.js';
|
|
32
43
|
import { mintNonce, buildCspHeader, cspHeaderName } from '../csp.js';
|
|
33
44
|
import { propagateTrustedRemoteIp } from '../rate-limit.js';
|
|
34
45
|
import { reachedBareImports, resolveVendorImports, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from '../vendor.js';
|
|
35
46
|
import { browserEntryFiles } from '../browser-entries.js';
|
|
36
|
-
import { buildModuleGraph, dynamicEdges, seenFilesFor, appImportsMap, reachableFromEntries, resolveImport } from '../module-graph.js';
|
|
47
|
+
import { buildModuleGraph, dynamicEdges, seenFilesFor, appImportsMap, reachableFromEntries, resolveImport, bareImports, dynamicBareImports } from '../module-graph.js';
|
|
48
|
+
import { foreignImportProblems, registerForeignImportGuard } from '../foreign-imports.js';
|
|
37
49
|
import { primeComponentRegistry, findOrphanComponents, scanComponents } from '../component-scanner.js';
|
|
38
|
-
import {
|
|
50
|
+
import { lazyComponentFiles, bindingImportedLazyFiles } from '../lazy-components.js';
|
|
51
|
+
import { routeModuleBrowserHints } from '../client-signals.js';
|
|
52
|
+
import { routeFrontiers, collectBootRouteModules, routeTableFiles, leadsToComponent } from '../route-frontier.js';
|
|
53
|
+
import { hasModuleScopeSideEffect } from '../module-side-effect.js';
|
|
39
54
|
|
|
40
55
|
import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from '../importmap.js';
|
|
41
56
|
import { stripBasePath, withBasePath } from '../base-path.js';
|
|
42
57
|
import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
|
|
43
58
|
import { resolveActionIdentity } from '../form-action-identity.js';
|
|
44
|
-
import { setAssetRoots, clearAssetHashCache,
|
|
59
|
+
import { setAssetRoots, clearAssetHashCache, setServeFingerprint, withAssetHash, assetHashFor, resolveAssetUrl, setDevModuleVersions } from '../asset-hash.js';
|
|
45
60
|
import { bumpBuildEpoch } from '../build-epoch.js';
|
|
46
61
|
import { hintedBootModules } from '../ssr/shipped-modules.js';
|
|
47
62
|
import { createRetryBackoff } from './vendor-retry.js';
|
|
@@ -52,11 +67,13 @@ import {
|
|
|
52
67
|
applyTrailingSlash,
|
|
53
68
|
} from '../redirects.js';
|
|
54
69
|
import { applyConditionalGet } from '../conditional-get.js';
|
|
55
|
-
import { commitHtmlCache, setAppSourceFingerprint } from '../html-cache.js';
|
|
70
|
+
import { commitHtmlCache, revalidateAll, setAppSourceFingerprint } from '../html-cache.js';
|
|
56
71
|
import { buildDevErrorFrame } from '../dev-error.js';
|
|
72
|
+
import { isControlFlowThrow } from '../action-error.js';
|
|
73
|
+
import { createDevStatus } from './dev-status.js';
|
|
74
|
+
import { devReloadState } from '../dev-reload-state.js';
|
|
57
75
|
|
|
58
76
|
import {
|
|
59
|
-
readElideEnabled,
|
|
60
77
|
readSeedEnabled,
|
|
61
78
|
readClientRouterEnabled,
|
|
62
79
|
readHeaderRules,
|
|
@@ -70,20 +87,21 @@ import {
|
|
|
70
87
|
readDevToolingFromApp,
|
|
71
88
|
} from './config.js';
|
|
72
89
|
import {
|
|
73
|
-
exists, kebab, resolveRequestId, shouldAccessLog,
|
|
90
|
+
exists, kebab, resolveRequestId, shouldAccessLog,
|
|
74
91
|
appTopLevelDirs, locateCoreDir,
|
|
75
92
|
setReloadIdleMs,
|
|
76
93
|
resolveReloadIdleMs,
|
|
77
94
|
} from './helpers.js';
|
|
78
95
|
import {
|
|
79
|
-
handleCore,
|
|
96
|
+
handleCore, tryServeFrameworkStatic, tryServePublicAsset,
|
|
80
97
|
} from './serve.js';
|
|
81
98
|
import { devImport, clearProdImportCache } from '../dev-import.js';
|
|
99
|
+
import { retireFlashOnRedirect } from '../action-flash.js';
|
|
82
100
|
|
|
83
101
|
/**
|
|
84
102
|
* A short content digest of a single file's bytes for the app-source deploy
|
|
85
103
|
* signal (#899). Raw `sha256(bytes)`, deliberately independent of `assetHashFor`
|
|
86
|
-
* (which folds the
|
|
104
|
+
* (which folds the serve fingerprint and memoizes for `?v` emission): the
|
|
87
105
|
* signal only needs "did the source bytes change", and server-only files never
|
|
88
106
|
* enter the asset-hash memo anyway. Returns `''` on a read failure, so a
|
|
89
107
|
* transient error degrades the id rather than throwing during analysis.
|
|
@@ -149,7 +167,8 @@ export async function createRequestHandler(opts) {
|
|
|
149
167
|
// scaffolded apps with a committed .env.example + .env would fail
|
|
150
168
|
// to boot until the user discovered the missing env-load. See
|
|
151
169
|
// tracker #37.
|
|
152
|
-
|
|
170
|
+
// Next.js's env file order (.env.$mode.local, .env.local, .env.$mode, .env).
|
|
171
|
+
loadEnvFiles(appDir, envMode(!!opts.dev));
|
|
153
172
|
// Optional boot-time env validation (#236). If <appDir>/env.{js,ts} exists it
|
|
154
173
|
// default-exports a typed schema or a custom validator function; we run it
|
|
155
174
|
// against process.env (now populated by loadAppEnv) BEFORE buildActionIndex
|
|
@@ -177,7 +196,7 @@ export async function createRequestHandler(opts) {
|
|
|
177
196
|
// before traffic. Any error sink it registers via setOnError composes with
|
|
178
197
|
// the programmatic opts.onError (both fire). Fail-open (a throwing hook is
|
|
179
198
|
// logged, not fatal).
|
|
180
|
-
const { onError: instrumentationOnError } = await runInstrumentation(appDir, { dev, logger });
|
|
199
|
+
const { onError: instrumentationOnError, onRequestError } = await runInstrumentation(appDir, { dev, logger });
|
|
181
200
|
// APM / Sentry integration point (issue #239). Called whenever the request
|
|
182
201
|
// pipeline catches an unhandled error: the top-level handle() catch (the
|
|
183
202
|
// last-resort 500), an unexpected throw inside the produce() funnel, or a
|
|
@@ -189,7 +208,56 @@ export async function createRequestHandler(opts) {
|
|
|
189
208
|
// Both the programmatic opts.onError AND any sink an instrumentation.{js,ts}
|
|
190
209
|
// register() installed via setOnError fire (they compose, #848).
|
|
191
210
|
const onErrorSinks = [opts.onError, instrumentationOnError].filter((f) => typeof f === 'function');
|
|
192
|
-
|
|
211
|
+
// What `/__webjs/dev/status` reports (#1664): the errors since the last
|
|
212
|
+
// rebuild and whether a change is still in flight. Dev only.
|
|
213
|
+
const devStatus = dev ? createDevStatus() : null;
|
|
214
|
+
// Each error OBJECT is recorded once, under the first (most specific) kind
|
|
215
|
+
// that reaches it: a route handler's throw is recorded as `route` before the
|
|
216
|
+
// root middleware wrapping it reports the same error as `middleware`, and a
|
|
217
|
+
// render error the overlay already framed is not recorded again from the
|
|
218
|
+
// onError funnel.
|
|
219
|
+
/** @type {WeakSet<object>} Reset on every rebuild, so an error object an
|
|
220
|
+
* unchanged module throws again after a rebuild (a rejected module-scope
|
|
221
|
+
* promise awaited per request) is recorded again. */
|
|
222
|
+
let devRecorded = new WeakSet();
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Record an error in the dev status. A thrown redirect() / notFound() /
|
|
226
|
+
* forbidden() / unauthorized() is control flow the framework answers, not a
|
|
227
|
+
* failure, so it is never recorded.
|
|
228
|
+
* @param {unknown} error
|
|
229
|
+
* @param {Request | null | undefined} request
|
|
230
|
+
* @param {string} kind
|
|
231
|
+
* @param {Record<string, unknown>} [frame] an already-built dev error frame
|
|
232
|
+
*/
|
|
233
|
+
function recordDevStatus(error, request, kind, frame) {
|
|
234
|
+
if (!devStatus || isControlFlowThrow(error)) return;
|
|
235
|
+
if (error && typeof error === 'object') {
|
|
236
|
+
if (devRecorded.has(error)) return;
|
|
237
|
+
devRecorded.add(error);
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
let url;
|
|
241
|
+
try { url = request && request.url ? new URL(request.url).pathname : undefined; } catch { /* no url */ }
|
|
242
|
+
const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined;
|
|
243
|
+
if (typeof code === 'string' && code.startsWith('WEBJS_FORM_')) {
|
|
244
|
+
// A framework diagnostic about the page's own markup (a POST form that
|
|
245
|
+
// binds no action, a bound form submitted as GET). Its stack points
|
|
246
|
+
// into the framework, so it is recorded against the page instead.
|
|
247
|
+
devStatus.record({ kind: 'form', message: `${code}: ${/** @type {any} */ (error).message}`, url });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
devStatus.record(frame || buildDevErrorFrame(error, { kind, appDir, url }));
|
|
251
|
+
} catch { /* best effort */ }
|
|
252
|
+
}
|
|
253
|
+
const hasOnError = onErrorSinks.length > 0 || typeof onRequestError === 'function';
|
|
254
|
+
/** Each error object is reported once PER REQUEST, under the first (most
|
|
255
|
+
* specific) phase that reaches it: a route handler's throw is reported as
|
|
256
|
+
* `route` before the top-level catch sees the same error again. Keyed by the
|
|
257
|
+
* request id rather than held forever, so an error object a module throws
|
|
258
|
+
* again on every request (a rejected module-scope promise) is reported on
|
|
259
|
+
* each one. @type {WeakMap<object, string | null>} */
|
|
260
|
+
const reportedErrors = new WeakMap();
|
|
193
261
|
|
|
194
262
|
/**
|
|
195
263
|
* Invoke every registered onError sink defensively. The phase is a coarse
|
|
@@ -197,9 +265,27 @@ export async function createRequestHandler(opts) {
|
|
|
197
265
|
* @param {unknown} error
|
|
198
266
|
* @param {Request} request
|
|
199
267
|
* @param {string} phase
|
|
268
|
+
* @param {string} [routePath] the route file path for `onRequestError`
|
|
200
269
|
*/
|
|
201
|
-
function reportError(error, request, phase) {
|
|
270
|
+
function reportError(error, request, phase, routePath) {
|
|
271
|
+
// Dev status (#1664). Recorded BEFORE the no-sink return, because an app
|
|
272
|
+
// with no onError sink is exactly the one whose action errors were
|
|
273
|
+
// invisible to an agent. An `ssr` error is a render failure.
|
|
274
|
+
recordDevStatus(error, request, phase === 'ssr' ? 'render' : phase);
|
|
202
275
|
if (!hasOnError) return;
|
|
276
|
+
if (error && typeof error === 'object') {
|
|
277
|
+
const rid = getRequestId();
|
|
278
|
+
if (reportedErrors.has(error) && reportedErrors.get(error) === rid) return;
|
|
279
|
+
reportedErrors.set(error, rid);
|
|
280
|
+
}
|
|
281
|
+
// Next.js's instrumentation `onRequestError(error, request, context)`.
|
|
282
|
+
// Fire-and-forget like Next: an awaited reporter never delays the response,
|
|
283
|
+
// and a throwing or rejecting one is logged, never surfaced.
|
|
284
|
+
if (typeof onRequestError === 'function' && !isControlFlowThrow(error)) {
|
|
285
|
+
buildRequestErrorReport(error, request, phase, routePath)
|
|
286
|
+
.then((args) => onRequestError(...args))
|
|
287
|
+
.catch((e) => logger.error?.('[webjs] onRequestError threw (ignored)', { err: String(e) }));
|
|
288
|
+
}
|
|
203
289
|
const ctx = { request, requestId: getRequestId(), phase };
|
|
204
290
|
for (const sink of onErrorSinks) {
|
|
205
291
|
try {
|
|
@@ -283,7 +369,7 @@ export async function createRequestHandler(opts) {
|
|
|
283
369
|
|
|
284
370
|
// Install the resolver behind the isomorphic `asset()` helper (#1194), the
|
|
285
371
|
// same provider seam `cspNonce()` uses. An author writes
|
|
286
|
-
// `href=${asset('/
|
|
372
|
+
// `href=${asset('/app.css')}` and gets the fingerprinted url on the
|
|
287
373
|
// server; the browser has no provider and returns the path unchanged.
|
|
288
374
|
//
|
|
289
375
|
// The `public/` gate is defensive rather than decorative. `resolveUrlToFile`
|
|
@@ -305,7 +391,20 @@ export async function createRequestHandler(opts) {
|
|
|
305
391
|
// `webjs.seed` switch. Action identity (#1155) rides the same hook, and it is
|
|
306
392
|
// what a bound `<form action=${action}>` resolves through, so gating the hook
|
|
307
393
|
// on seeding would mean `webjs.seed: false` silently broke every no-JS form.
|
|
394
|
+
//
|
|
395
|
+
// Before it, in dev on Node, the resolve hook that versions edited app
|
|
396
|
+
// modules (#1689), so a rebuild re-evaluates an edit and its importers in
|
|
397
|
+
// this process instead of the supervisor restarting it. Same timing rule: it
|
|
398
|
+
// has to be in place before the first app module is imported. Module URLs
|
|
399
|
+
// are realpaths, so a symlinked app dir registers both forms.
|
|
400
|
+
const hotModules = dev && serverRuntime() === 'node' && installEsmHotHooks(appDir);
|
|
401
|
+
if (hotModules) {
|
|
402
|
+
try { const real = realpathSync(appDir); if (real !== appDir) installEsmHotHooks(real); } catch { /* appDir only */ }
|
|
403
|
+
}
|
|
308
404
|
await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
|
|
405
|
+
// Refuse `next/*`, `react` and `lit` imported from the app's own files at
|
|
406
|
+
// resolve time (Node), even when the package happens to be installed.
|
|
407
|
+
registerForeignImportGuard(appDir);
|
|
309
408
|
|
|
310
409
|
// Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` (or
|
|
311
410
|
// `webjs.dev.sourceLocations: true`) under `webjs dev`
|
|
@@ -323,10 +422,17 @@ export async function createRequestHandler(opts) {
|
|
|
323
422
|
}
|
|
324
423
|
// Bun under `bun --hot` serves a replaced file's old source forever (#1575):
|
|
325
424
|
// load app modules through a plugin that reads them fresh. After the
|
|
326
|
-
// source-locations plugin, so that one wins when it is on.
|
|
327
|
-
|
|
425
|
+
// source-locations plugin, so that one wins when it is on. The plugins also
|
|
426
|
+
// stamp the module versions (#1689), Bun's stand-in for the Node resolve
|
|
427
|
+
// hook, so an edit re-evaluates its chain without a registry reset.
|
|
428
|
+
const bunVersions = dev && serverRuntime() === 'bun';
|
|
429
|
+
if (bunVersions) {
|
|
328
430
|
registerBunAppSource(appDir);
|
|
329
|
-
|
|
431
|
+
installBunModuleVersions(appDir);
|
|
432
|
+
try {
|
|
433
|
+
const real = realpathSync(appDir);
|
|
434
|
+
if (real !== appDir) { registerBunAppSource(real); installBunModuleVersions(real); }
|
|
435
|
+
} catch { /* appDir only */ }
|
|
330
436
|
}
|
|
331
437
|
|
|
332
438
|
// When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
|
|
@@ -338,10 +444,10 @@ export async function createRequestHandler(opts) {
|
|
|
338
444
|
// rendered deterministically at runtime). Pinning stays optional; an unpinned
|
|
339
445
|
// app does no vendor work at boot and publishes its id after the first
|
|
340
446
|
// successful resolve instead. Either way the EXPENSIVE analysis (graph, scan,
|
|
341
|
-
// gate,
|
|
447
|
+
// gate, frontiers) and the UNPINNED jspm resolve stay deferred to the first
|
|
342
448
|
// request, so #143's win is intact; only the cheap committed-file read moves
|
|
343
449
|
// back to boot, and only when a VALID pin exists. A committed pin file is
|
|
344
|
-
// served as-is (
|
|
450
|
+
// served as-is (nothing prunes it), so the boot-resolved map equals the
|
|
345
451
|
// final served map and the published id is authoritative.
|
|
346
452
|
//
|
|
347
453
|
// Validate the pin with readPinFile BEFORE treating the app as pinned-at-boot.
|
|
@@ -367,13 +473,25 @@ export async function createRequestHandler(opts) {
|
|
|
367
473
|
}
|
|
368
474
|
|
|
369
475
|
// Whole-app analysis (module graph, component scan, browser-bound gate,
|
|
370
|
-
// action index, middleware,
|
|
476
|
+
// action index, middleware, frontiers, vendor) is NOT run at boot. It is
|
|
371
477
|
// computed on the first request via ensureReady() below and memoized, so the
|
|
372
478
|
// server starts without walking or reading the app's source, executing any
|
|
373
479
|
// server module, or hitting the network. Only the route table is built
|
|
374
480
|
// eagerly: it is a cheap directory scan (no code reads), and routing, Early
|
|
375
481
|
// Hints, and WebSocket lookups need it available before the first request.
|
|
376
482
|
const routeTable = await buildRouteTable(appDir);
|
|
483
|
+
// A file arrangement that can never serve correctly (a page and a route.ts
|
|
484
|
+
// at one URL, ...) refuses to boot in production, as Next refuses to build.
|
|
485
|
+
// Dev keeps serving so an edit can fix it: ensureReady re-checks on every
|
|
486
|
+
// rebuild and answers each request with the problem until then.
|
|
487
|
+
// Every file under public/, by the root url it is served at (public-files.js).
|
|
488
|
+
const publicFiles = buildPublicIndex(appDir);
|
|
489
|
+
if (!dev) {
|
|
490
|
+
const problems = validateAppLayout(appDir, routeTable, publicFiles);
|
|
491
|
+
if (problems.length) throw new AppLayoutError(problems);
|
|
492
|
+
// Both a middleware.* and a proxy.* at the root: also refused at boot.
|
|
493
|
+
await findRootMiddleware(appDir);
|
|
494
|
+
}
|
|
377
495
|
// Auto-linked favicons: tell the head builder which icon metadata routes
|
|
378
496
|
// exist, so `app/icon.*` is linked when the app declares no metadata.icons.
|
|
379
497
|
// Bound here rather than threaded through ssrOpts, matching
|
|
@@ -445,6 +563,7 @@ export async function createRequestHandler(opts) {
|
|
|
445
563
|
|
|
446
564
|
const state = {
|
|
447
565
|
routeTable,
|
|
566
|
+
publicFiles,
|
|
448
567
|
actionIndex: null,
|
|
449
568
|
middleware: null,
|
|
450
569
|
bodyLimits,
|
|
@@ -458,21 +577,26 @@ export async function createRequestHandler(opts) {
|
|
|
458
577
|
// the core / vendor serving are unchanged, so no server source is exposed.
|
|
459
578
|
testMode: opts.testMode === true,
|
|
460
579
|
moduleGraph: null,
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
580
|
+
// Route module -> the component files the boot imports in its place
|
|
581
|
+
// (#1686). Route modules never ship; see `route-frontier.js`.
|
|
582
|
+
/** @type {Map<string, string[]>} */
|
|
583
|
+
routeFrontiers: new Map(),
|
|
584
|
+
/** @type {Set<string>} */
|
|
585
|
+
routeModuleFiles: new Set(),
|
|
586
|
+
/** @type {Array<{ file: string, message: string }>} dev-only route-module hints (#1686) */
|
|
587
|
+
routeHints: [],
|
|
464
588
|
// `static lazy` component files -> their tags (#1524): a side-effect import
|
|
465
589
|
// of one is served as a lazy-loader registration, and the preload walks
|
|
466
|
-
// skip it.
|
|
590
|
+
// skip it.
|
|
467
591
|
lazyComponentFiles: new Map(),
|
|
468
|
-
// The browser-bound ENTRY files (
|
|
592
|
+
// The browser-bound ENTRY files (components and instrumentation-client),
|
|
469
593
|
// kept alongside the expanded gate set because the vendor scan roots at the
|
|
470
594
|
// entries rather than at the closure.
|
|
471
595
|
browserEntryFiles: new Set(),
|
|
472
596
|
browserBoundFiles: null,
|
|
473
597
|
// Dev live-reload classification (#1398). `shippedFiles` is the transitive
|
|
474
598
|
// closure of what the browser can ACTUALLY load (the gate set minus every
|
|
475
|
-
// module
|
|
599
|
+
// module the frontier walk skips), `graphFiles` is every app source the graph walked,
|
|
476
600
|
// and `pageFiles` is the route table's pages. Together they are the whole
|
|
477
601
|
// input to `classifyChangedPath`, so a watch event can be answered without
|
|
478
602
|
// reaching into the rest of this state. Dev-only; left empty in prod.
|
|
@@ -482,11 +606,28 @@ export async function createRequestHandler(opts) {
|
|
|
482
606
|
graphFiles: new Set(),
|
|
483
607
|
/** @type {Set<string>} */
|
|
484
608
|
pageFiles: new Set(),
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
//
|
|
489
|
-
//
|
|
609
|
+
// Component files from the scan, for the hot-swap boundary walk (#1689).
|
|
610
|
+
/** @type {Set<string>} */
|
|
611
|
+
componentFiles: new Set(),
|
|
612
|
+
// Browser module versions (#1689): a rebuild bumps the edited shipped
|
|
613
|
+
// files and their importers, and the served source of every app module
|
|
614
|
+
// versions its imports of them. Never cleared, so a version only grows.
|
|
615
|
+
browserVersions: createBrowserVersions(),
|
|
616
|
+
// The hot-swap signature (`dev-hot-classify.js`) of each shipped component
|
|
617
|
+
// file as the open tabs hold it (#1689): set from disk at the first
|
|
618
|
+
// analysis, then from every edit's source, since a tab applies every
|
|
619
|
+
// verdict. Never cleared.
|
|
620
|
+
/** @type {Map<string, import('../dev-hot-classify.js').HotSignature | Promise<import('../dev-hot-classify.js').HotSignature | null>>} */
|
|
621
|
+
hotSignatures: new Map(),
|
|
622
|
+
// The disk stamp of every shipped module as the last analysis found it, on
|
|
623
|
+
// Bun only (#1689): a rebuild adds each one that moved since to its changed
|
|
624
|
+
// files, since Bun's `fs.watch` can drop the event (see `missedChanges`).
|
|
625
|
+
/** @type {Map<string, string>} */
|
|
626
|
+
shippedStamps: new Map(),
|
|
627
|
+
// Transformed-source cache (stripped TS + the lazy-import rewrite).
|
|
628
|
+
// Per-handler, NOT module-global: the cached bytes bake in THIS handler's
|
|
629
|
+
// lazy set and source-location setting, so two handlers for the same app
|
|
630
|
+
// must not share it.
|
|
490
631
|
tsCache: new Map(),
|
|
491
632
|
// Dev source locations (#1499), fixed for the handler's lifetime, so the
|
|
492
633
|
// per-handler tsCache above never mixes annotated and plain bytes.
|
|
@@ -495,6 +636,9 @@ export async function createRequestHandler(opts) {
|
|
|
495
636
|
// SSE channel for the live overlay and replayed to a freshly-connected tab.
|
|
496
637
|
// Dev-only (never populated when !dev); cleared on a successful rebuild.
|
|
497
638
|
lastDevError: null,
|
|
639
|
+
// The dev status (#1664), on `state` so the page-render path can tell it a
|
|
640
|
+
// page rendered again. Null in prod.
|
|
641
|
+
devStatus,
|
|
498
642
|
// On-request regeneration rules (#967): `webjs.dev.regenerate` from the app's
|
|
499
643
|
// package.json. Dev-only (prod builds its outputs via `start.before`). Loaded
|
|
500
644
|
// now and refreshed on each rebuild so a config edit takes effect without a
|
|
@@ -509,7 +653,11 @@ export async function createRequestHandler(opts) {
|
|
|
509
653
|
// process running several handlers the fallback therefore resolves against
|
|
510
654
|
// the most recently booted one, which is the same scope the asset-url
|
|
511
655
|
// provider already has.
|
|
512
|
-
setFormActionResolver((fn) => (state.actionIndex ? resolveActionIdentity(state.actionIndex, fn) : null));
|
|
656
|
+
setFormActionResolver((fn) => (state.actionIndex ? resolveActionIdentity(state.actionIndex, fn, { dev }) : null));
|
|
657
|
+
// The boot script, the modulepreload hints and the lazy-loader map name a
|
|
658
|
+
// bumped module with the same `?t=` its importers' rewritten specifiers
|
|
659
|
+
// carry (#1689), so a fresh document holds one instance of it, not two.
|
|
660
|
+
setDevModuleVersions(dev ? (abs) => state.browserVersions.versionOf(abs) : null);
|
|
513
661
|
|
|
514
662
|
/**
|
|
515
663
|
* Report a dev error (#264): build a frame and push it to the open tab via
|
|
@@ -529,6 +677,7 @@ export async function createRequestHandler(opts) {
|
|
|
529
677
|
try {
|
|
530
678
|
const frame = buildDevErrorFrame(error, { ...info, appDir });
|
|
531
679
|
state.lastDevError = frame;
|
|
680
|
+
recordDevStatus(error, null, frame.kind, frame);
|
|
532
681
|
opts.onDevError?.(frame);
|
|
533
682
|
} catch { /* ignore */ }
|
|
534
683
|
}
|
|
@@ -536,7 +685,7 @@ export async function createRequestHandler(opts) {
|
|
|
536
685
|
// All whole-app analysis is built lazily on the first request, memoized so
|
|
537
686
|
// boot does none of it. It runs in two stages. The deterministic analysis
|
|
538
687
|
// (module graph, component scan + prime, browser-bound gate, action index,
|
|
539
|
-
// middleware,
|
|
688
|
+
// middleware, frontiers) is network-free and, once built, never re-runs unless
|
|
540
689
|
// a rebuild invalidates it; readiness gates on it. Vendor resolution is a
|
|
541
690
|
// SEPARATE, best-effort stage: a pinned app reads a committed importmap file,
|
|
542
691
|
// an unpinned app auto-fetches from jspm. It does NOT gate readiness, so an
|
|
@@ -558,7 +707,7 @@ export async function createRequestHandler(opts) {
|
|
|
558
707
|
let analysisEverDone = false;
|
|
559
708
|
// A pinned app applied its FULL vendor map and published the build id at boot
|
|
560
709
|
// (above). The deferred vendor stage still runs once (and after every rebuild)
|
|
561
|
-
// to PRUNE that map to the
|
|
710
|
+
// to PRUNE that map to the browser-reachable specifiers, so a pinned app serves
|
|
562
711
|
// the same map an unpinned one does (#197); it does not re-publish the build id
|
|
563
712
|
// (the boot hash stays the deploy fingerprint). An unpinned app starts false and
|
|
564
713
|
// resolves live on the first request.
|
|
@@ -619,81 +768,117 @@ export async function createRequestHandler(opts) {
|
|
|
619
768
|
const now = () => performance.now();
|
|
620
769
|
try {
|
|
621
770
|
if (!analysisDone) {
|
|
771
|
+
const layoutProblems = validateAppLayout(appDir, state.routeTable, state.publicFiles);
|
|
772
|
+
if (layoutProblems.length) throw new AppLayoutError(layoutProblems);
|
|
622
773
|
let m = now();
|
|
623
774
|
state.moduleGraph = await buildModuleGraph(appDir);
|
|
775
|
+
// A foreign-framework import (next/*, react, lit) anywhere in the
|
|
776
|
+
// app is refused before anything renders: the dev overlay shows
|
|
777
|
+
// it, production readiness stays 503 naming it.
|
|
778
|
+
{
|
|
779
|
+
const relApp = (p) => relative(appDir, p).split(sep).join('/');
|
|
780
|
+
const foreign = [
|
|
781
|
+
...foreignImportProblems(new Map([...bareImports(state.moduleGraph), ...dynamicBareImports(state.moduleGraph)]), relApp),
|
|
782
|
+
// `import './x.css'` too: a bundler feature with no meaning here.
|
|
783
|
+
...cssImportProblems(state.moduleGraph, relApp),
|
|
784
|
+
];
|
|
785
|
+
if (foreign.length) throw new AppLayoutError(foreign);
|
|
786
|
+
}
|
|
624
787
|
t.graph = now() - m; m = now();
|
|
625
788
|
const components = await scanComponents(appDir);
|
|
626
789
|
await primeComponentRegistry(appDir, components);
|
|
627
790
|
t.scan = now() - m; m = now();
|
|
628
791
|
state.browserEntryFiles = browserEntryFiles(state.routeTable, components);
|
|
629
|
-
|
|
792
|
+
// Route-table files never load in the browser (#1686): the gate
|
|
793
|
+
// stops at them and the serve branch refuses them by path.
|
|
794
|
+
state.routeModuleFiles = routeTableFiles(state.routeTable);
|
|
795
|
+
state.browserBoundFiles = reachableFromEntries(state.moduleGraph, [...state.browserEntryFiles], appDir,
|
|
796
|
+
state.routeModuleFiles);
|
|
630
797
|
t.gate = now() - m; m = now();
|
|
631
798
|
state.actionIndex = await buildActionIndex(appDir, dev);
|
|
632
799
|
t.actions = now() - m; m = now();
|
|
633
|
-
|
|
800
|
+
// Fails CLOSED (middleware-load.js): a root middleware or proxy
|
|
801
|
+
// that cannot load stops the app serving rather than letting
|
|
802
|
+
// every request through unguarded.
|
|
803
|
+
try {
|
|
804
|
+
state.middleware = await loadRootMiddleware(appDir, dev, logger);
|
|
805
|
+
} catch (e) {
|
|
806
|
+
if (e && /** @type {any} */ (e).code === 'WEBJS_APP_LAYOUT') throw e;
|
|
807
|
+
const err = new Error(`[webjs] the root middleware failed to load, so no request is served (a guard that cannot load must not let requests through): ${/** @type {Error} */ (e).message}`, { cause: e });
|
|
808
|
+
/** @type {any} */ (err).code = 'WEBJS_APP_LAYOUT';
|
|
809
|
+
throw err;
|
|
810
|
+
}
|
|
634
811
|
t.middleware = now() - m; m = now();
|
|
635
812
|
// Client-router opt-out (#629): re-read each pass so toggling
|
|
636
813
|
// `webjs.clientRouter` takes effect on rebuild without a restart.
|
|
637
814
|
setClientRouterEnabled(await readClientRouterEnabled(appDir));
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
815
|
+
// Route modules never ship (#1686): each is replaced in the boot by
|
|
816
|
+
// its component frontier. Every component ships, as in Lit; a
|
|
817
|
+
// component in a `.server` file is a stub in the browser, so the walk
|
|
818
|
+
// stops there. Every route-table file is in the skip set, so a route
|
|
819
|
+
// module is never emitted or walked through even when another one
|
|
820
|
+
// imports it.
|
|
821
|
+
{
|
|
822
|
+
const routeFiles = collectBootRouteModules(state.routeTable);
|
|
823
|
+
state.routeFrontiers = routeFrontiers(routeFiles, state.moduleGraph,
|
|
824
|
+
new Set(components.map((c) => c.file)),
|
|
825
|
+
state.routeModuleFiles);
|
|
826
|
+
}
|
|
827
|
+
// Dev only: code in a route module that looks meant for the browser
|
|
828
|
+
// but runs only on the server (#1686), reported by `webjs status` as
|
|
829
|
+
// warnings so an agent sees it in the loop it already runs.
|
|
830
|
+
if (dev) {
|
|
831
|
+
const componentFileSet = new Set(components.map((c) => c.file));
|
|
832
|
+
const carriesComponent = leadsToComponent(state.moduleGraph, componentFileSet);
|
|
833
|
+
/** @type {Array<{ file: string, message: string }>} */
|
|
834
|
+
const hints = [];
|
|
835
|
+
for (const f of collectBootRouteModules(state.routeTable)) {
|
|
836
|
+
let src;
|
|
837
|
+
try { src = await readFile(f, 'utf8'); } catch { continue; }
|
|
838
|
+
for (const message of routeModuleBrowserHints(src, {
|
|
839
|
+
file: f, appDir, isComponent: (abs) => componentFileSet.has(abs), carriesComponent,
|
|
840
|
+
})) hints.push({ file: f, message });
|
|
841
|
+
}
|
|
842
|
+
state.routeHints = hints;
|
|
843
|
+
}
|
|
844
|
+
state.lazyComponentFiles = lazyComponentFiles(components);
|
|
650
845
|
// A lazy component some module imports WITH bindings loads eagerly
|
|
651
846
|
// anyway, so it keeps its preload hint and its imports as written.
|
|
652
847
|
for (const f of await bindingImportedLazyFiles(state.lazyComponentFiles, state.moduleGraph,
|
|
653
848
|
(f) => readFile(f, 'utf8'), resolveImport, appDir)) state.lazyComponentFiles.delete(f);
|
|
654
849
|
// Dev live-reload classification (#1398): derive the three sets the
|
|
655
850
|
// watch-event classifier reads. `browserBoundFiles` above is the
|
|
656
|
-
// AUTHORIZATION gate
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
// the browser actually HOLDS. Re-walk from the entries elision
|
|
660
|
-
// keeps, and an edit to anything reachable from those is a reload.
|
|
661
|
-
// An import-only page's substituted components are themselves
|
|
662
|
-
// entries, so dropping the page loses none of its reach.
|
|
851
|
+
// AUTHORIZATION gate, so it is the wrong input here: the question is
|
|
852
|
+
// what the browser actually HOLDS. Re-walk from the entries, and an
|
|
853
|
+
// edit to anything reachable from those is a reload.
|
|
663
854
|
if (dev) {
|
|
664
|
-
const shippedEntries = [...state.browserEntryFiles].filter((f) =>
|
|
665
|
-
!state.elidableComponents.has(f)
|
|
666
|
-
&& !state.inertRouteModules.has(f)
|
|
667
|
-
&& !state.importOnlyRouteModules.has(f));
|
|
855
|
+
const shippedEntries = [...state.browserEntryFiles].filter((f) => !state.routeModuleFiles.has(f));
|
|
668
856
|
state.shippedFiles = reachableFromEntries(state.moduleGraph, shippedEntries, appDir);
|
|
669
857
|
state.graphFiles = seenFilesFor(state.moduleGraph);
|
|
670
858
|
state.pageFiles = new Set((state.routeTable.pages || []).map((p) => p.file).filter(Boolean));
|
|
859
|
+
state.componentFiles = new Set(components.map((c) => c.file));
|
|
860
|
+
if (bunVersions) state.shippedStamps = new Map([...state.shippedFiles].map((f) => [f, diskStamp(f)]));
|
|
861
|
+
// The hot-swap baseline of every shipped component the tabs
|
|
862
|
+
// have not had a signature recorded for yet (#1689), read in
|
|
863
|
+
// the background: only a later edit needs it, so the first
|
|
864
|
+
// request does not wait on it (`hotVerdict` awaits it).
|
|
865
|
+
primeHotBaselines([...state.componentFiles].filter((f) => state.shippedFiles.has(f)),
|
|
866
|
+
state.hotSignatures, readHotSignature);
|
|
671
867
|
}
|
|
672
|
-
// Fold the
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
// so a no-elision app's hash stays exactly `sha256(bytes)`.
|
|
868
|
+
// Fold the lazy set into app-module content hashes (#243, #1524): a
|
|
869
|
+
// file flipping to or from `static lazy` changes how every importer
|
|
870
|
+
// serves its import of it, so the flip must bust the importer's `?v`
|
|
871
|
+
// even when its source is byte-identical. A stable string of the
|
|
872
|
+
// sorted lazy paths, RELATIVIZED to appDir so the fingerprint is a
|
|
873
|
+
// property of the app's STRUCTURE, not its filesystem location.
|
|
874
|
+
// asset-hash digests it into each app-module hash; '' when nothing is
|
|
875
|
+
// lazy, so such an app's hash stays exactly `sha256(bytes)`.
|
|
681
876
|
{
|
|
682
877
|
// `appDir + sep` boundary (matches asset-hash's containment guard)
|
|
683
878
|
// so a sibling-prefix dir cannot be mis-relativized.
|
|
684
879
|
const rel = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
...state.inertRouteModules,
|
|
688
|
-
// An import-only module is dropped from the boot (replaced by its
|
|
689
|
-
// component imports), so a flip in / out of that class must bust
|
|
690
|
-
// the importer's `?v` too.
|
|
691
|
-
...state.importOnlyRouteModules.keys(),
|
|
692
|
-
// A file flipping to or from `static lazy` changes how every
|
|
693
|
-
// importer serves its import of it (#1524).
|
|
694
|
-
...[...state.lazyComponentFiles.keys()].map((f) => `lazy:${f}`),
|
|
695
|
-
].map((p) => (p.startsWith('lazy:') ? `lazy:${rel(p.slice(5))}` : rel(p))).sort();
|
|
696
|
-
setElisionFingerprint(elidedPaths.length ? elidedPaths.join('\n') : '');
|
|
880
|
+
const lazyPaths = [...state.lazyComponentFiles.keys()].map((f) => `lazy:${rel(f)}`).sort();
|
|
881
|
+
setServeFingerprint(lazyPaths.length ? lazyPaths.join('\n') : '');
|
|
697
882
|
}
|
|
698
883
|
// App-source fingerprint for the HTML cache key (#318): a
|
|
699
884
|
// deterministic, location-independent digest of the browser-bound
|
|
@@ -701,18 +886,27 @@ export async function createRequestHandler(opts) {
|
|
|
701
886
|
// module's bytes (which the importmap-only build id misses) re-keys
|
|
702
887
|
// the cache instead of serving a body with stale `?v` boot URLs.
|
|
703
888
|
// PROD only (asset-hash is enabled there); '' in dev, where fs.watch
|
|
704
|
-
// handles staleness. Computed AFTER the
|
|
705
|
-
// per-file hash already reflects the
|
|
889
|
+
// handles staleness. Computed AFTER the serve fingerprint, so each
|
|
890
|
+
// per-file hash already reflects the lazy set (a lazy
|
|
706
891
|
// flip changes the served output, so it moves this fingerprint too).
|
|
707
892
|
// This eagerly hashes the WHOLE browser-bound set once (not just the
|
|
708
893
|
// first route's files), but `assetHashFor` is memoized and already
|
|
709
894
|
// runs for `?v` emission in prod, so it stays a one-time bounded cost
|
|
710
895
|
// inside the lazy analysis (never at boot).
|
|
896
|
+
// Since #1686 a page, layout or boundary is not browser-bound, so an
|
|
897
|
+
// edit to one alone would leave that set unchanged while the HTML it
|
|
898
|
+
// renders moved. The raw digest of every graph file (the #899 set
|
|
899
|
+
// below) is folded in, so a page-only deploy re-keys too.
|
|
711
900
|
if (!dev && state.browserBoundFiles) {
|
|
712
901
|
const relApp = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
|
|
713
902
|
const lines = [...state.browserBoundFiles]
|
|
714
903
|
.map((abs) => `${relApp(abs)}:${assetHashFor(abs)}`)
|
|
715
904
|
.sort();
|
|
905
|
+
if (state.moduleGraph) {
|
|
906
|
+
for (const abs of [...seenFilesFor(state.moduleGraph)].sort()) {
|
|
907
|
+
lines.push(`src:${relApp(abs)}:${fileByteHash(abs)}`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
716
910
|
setAppSourceFingerprint(lines.join('\n'));
|
|
717
911
|
} else {
|
|
718
912
|
setAppSourceFingerprint('');
|
|
@@ -721,7 +915,7 @@ export async function createRequestHandler(opts) {
|
|
|
721
915
|
// fingerprint above: hashes ALL app source (the module-graph `seen`
|
|
722
916
|
// set, INCLUDING server-only `.server.ts` that `browserBoundFiles`
|
|
723
917
|
// omits) with a raw per-file byte digest (decoupled from the
|
|
724
|
-
// asset-hash memo
|
|
918
|
+
// asset-hash memo and serve fingerprint), plus the installed
|
|
725
919
|
// `@webjsdev/server` version, so an app-source OR a server-framework
|
|
726
920
|
// deploy moves it. Sorted + appDir-relative for determinism across
|
|
727
921
|
// instances. Drives the client's soft cache-evict (not a reload).
|
|
@@ -736,45 +930,29 @@ export async function createRequestHandler(opts) {
|
|
|
736
930
|
} else {
|
|
737
931
|
setAppSourceId('');
|
|
738
932
|
}
|
|
739
|
-
t.
|
|
933
|
+
t.frontiers = now() - m;
|
|
740
934
|
if (dev) {
|
|
741
935
|
// An orphan is EITHER shape `findOrphanComponents` reports: no
|
|
742
936
|
// registration call at all, or one whose tag is computed. The
|
|
743
937
|
// scanner matches only a literal tag, so it sees neither, and
|
|
744
|
-
// what is ALWAYS lost is the
|
|
938
|
+
// what is ALWAYS lost is the registry entry, the boot import and the
|
|
745
939
|
// preload hint. The upgrade is the part that differs, and a
|
|
746
940
|
// computed tag only survives when its importer ships WHOLE (see
|
|
747
941
|
// findOrphanComponents for why that is the exception). The doctor
|
|
748
|
-
// check
|
|
942
|
+
// check says the same thing.
|
|
749
943
|
for (const { className, file } of await findOrphanComponents(appDir)) {
|
|
750
944
|
logger.warn?.(
|
|
751
945
|
`[webjs] ${className} extends WebComponent but is never registered with a literal tag in ${file} ` +
|
|
752
946
|
`(either there is no registration call, or its tag is computed), so the scanner cannot see it: ` +
|
|
753
|
-
`no
|
|
947
|
+
`no registry entry, no preload hint, and no boot import. ` +
|
|
754
948
|
`<${kebab(className)}> tags never upgrade with no registration call, and a computed tag ` +
|
|
755
949
|
`upgrades only while its importing module ships whole. ` +
|
|
756
950
|
`Add \`${className}.register('<tag-name>');\` with a literal tag.`,
|
|
757
951
|
);
|
|
758
952
|
}
|
|
759
|
-
// The elision summary (#1308), one server-console line. NOT a
|
|
760
|
-
// browser push: an inert route ships zero application JS, and the
|
|
761
|
-
// most useful manual check an author has is opening the network
|
|
762
|
-
// tab on that route and seeing nothing, which a dev-only boot
|
|
763
|
-
// script would corrupt on exactly the pages this proves. The fact
|
|
764
|
-
// is also app-wide while a browser channel is per-tab, and the
|
|
765
|
-
// per-page detail already has a home in `webjs elision`.
|
|
766
|
-
// Re-emitted after each fs.watch rebuild, because doRebuild sets
|
|
767
|
-
// analysisDone = false and this sits inside that stage.
|
|
768
|
-
logger.info?.(
|
|
769
|
-
elideOn
|
|
770
|
-
? `[webjs] elision: ${r.elidableComponents.size}/${new Set(components.map((c) => c.file)).size} components elided, ` +
|
|
771
|
-
`${r.inertRouteModules.size} route modules inert, ${r.importOnlyRouteModules.size} import-only, ` +
|
|
772
|
-
`${r.shippedRouteModules.size} ship whole. Run \`webjs elision\` for the per-module verdict.`
|
|
773
|
-
: `[webjs] elision: disabled (WEBJS_ELIDE / webjs.elide), every module ships.`,
|
|
774
|
-
);
|
|
775
953
|
}
|
|
776
954
|
// The analysis stage is the last writer of per-build inputs (the
|
|
777
|
-
//
|
|
955
|
+
// serve fingerprint, the frontiers and the lazy set, the route
|
|
778
956
|
// table), so every epoch-keyed memo is invalidated here once,
|
|
779
957
|
// unconditionally: a request that rendered in the pre-warm window
|
|
780
958
|
// hinted against an empty fingerprint and must not keep serving
|
|
@@ -826,11 +1004,11 @@ export async function createRequestHandler(opts) {
|
|
|
826
1004
|
readyDone = true;
|
|
827
1005
|
if (ranAnalysis) {
|
|
828
1006
|
const ms = (x) => Math.round(x || 0);
|
|
829
|
-
const total = ms(t.graph) + ms(t.scan) + ms(t.gate) + ms(t.actions) + ms(t.middleware) + ms(t.
|
|
1007
|
+
const total = ms(t.graph) + ms(t.scan) + ms(t.gate) + ms(t.actions) + ms(t.middleware) + ms(t.frontiers) + ms(t.vendor);
|
|
830
1008
|
logger.info?.(
|
|
831
1009
|
`[webjs] analysis warm in ${total}ms (graph ${ms(t.graph)}, scan ${ms(t.scan)}, ` +
|
|
832
1010
|
`gate ${ms(t.gate)}, actions ${ms(t.actions)}, middleware ${ms(t.middleware)}, ` +
|
|
833
|
-
`
|
|
1011
|
+
`frontiers ${ms(t.frontiers)}, vendor ${ms(t.vendor)})`,
|
|
834
1012
|
);
|
|
835
1013
|
} else if (ranVendor && vendorResolved) {
|
|
836
1014
|
logger.info?.(`[webjs] vendor resolved in ${Math.round(t.vendor || 0)}ms`);
|
|
@@ -861,16 +1039,15 @@ export async function createRequestHandler(opts) {
|
|
|
861
1039
|
vendorResolveInFlight = (async () => {
|
|
862
1040
|
try {
|
|
863
1041
|
const scan = async () => {
|
|
864
|
-
|
|
865
|
-
return reachedBareImports(state.moduleGraph, [...state.browserEntryFiles], appDir, skip);
|
|
1042
|
+
return reachedBareImports(state.moduleGraph, [...state.browserEntryFiles], appDir, state.routeModuleFiles);
|
|
866
1043
|
};
|
|
867
1044
|
const v = await resolveVendorImports(appDir, scan);
|
|
868
1045
|
let { imports, integrity } = v;
|
|
869
1046
|
if (bootVendorPinned) {
|
|
870
1047
|
// resolveVendorImports returns a committed pin VERBATIM (it never runs
|
|
871
|
-
// the scan for a pinned app). Prune it to the
|
|
1048
|
+
// the scan for a pinned app). Prune it to the browser-reachable
|
|
872
1049
|
// specifiers so a pinned app serves the same map an unpinned one does
|
|
873
|
-
// (#197):
|
|
1050
|
+
// (#197): a dep only a page imports is dropped. One scan; the pin
|
|
874
1051
|
// path skipped it. This runs on the first warm AND after every rebuild,
|
|
875
1052
|
// so the pruned map is the single source of truth.
|
|
876
1053
|
const reachable = await scan();
|
|
@@ -928,11 +1105,20 @@ export async function createRequestHandler(opts) {
|
|
|
928
1105
|
* classification of the change that triggered this rebuild (#1398). Absent
|
|
929
1106
|
* (an embedded host calling `rebuild()`, or any caller with no filename to
|
|
930
1107
|
* classify) means a full reload, which is the fail-safe default.
|
|
931
|
-
* @param {{ notify?: boolean }} [rebuildOpts] `notify: false`
|
|
932
|
-
* without a reload frame (a `bun --hot` registry reset, #1575).
|
|
1108
|
+
* @param {{ notify?: boolean, changed?: string[] }} [rebuildOpts] `notify: false`
|
|
1109
|
+
* re-derives without a reload frame (a `bun --hot` registry reset, #1575).
|
|
1110
|
+
* `changed` lists the absolute paths the watcher saw change (#1689); empty
|
|
1111
|
+
* (an embedder's bare `rebuild()`) means nothing is known to have changed.
|
|
933
1112
|
*/
|
|
934
|
-
async function rebuild(verdict, { notify = true } = {}) {
|
|
935
|
-
|
|
1113
|
+
async function rebuild(verdict, { notify = true, changed = [] } = {}) {
|
|
1114
|
+
devStatus?.rebuildStarted();
|
|
1115
|
+
rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict, notify, changed)).then(() => {
|
|
1116
|
+
// The recorded errors described the build this one replaced.
|
|
1117
|
+
devStatus?.rebuildFinished();
|
|
1118
|
+
devRecorded = new WeakSet();
|
|
1119
|
+
}, (e) => {
|
|
1120
|
+
devStatus?.rebuildFinished();
|
|
1121
|
+
devRecorded = new WeakSet();
|
|
936
1122
|
logger.error?.(`[webjs] rebuild failed:`, e);
|
|
937
1123
|
// Push the failure to the open tab so the overlay appears live after a
|
|
938
1124
|
// breaking edit, not only on the next manual navigation (#264).
|
|
@@ -941,23 +1127,95 @@ export async function createRequestHandler(opts) {
|
|
|
941
1127
|
return rebuildInFlight;
|
|
942
1128
|
}
|
|
943
1129
|
|
|
1130
|
+
/**
|
|
1131
|
+
* The rebuild's verdict and changed files plus every served or shipped module
|
|
1132
|
+
* whose disk stamp moved without the watcher reporting it (Bun only). Each
|
|
1133
|
+
* extra file is classified against the analysis the tabs hold, the same
|
|
1134
|
+
* ladder the watcher applies, and its stamp is updated so it is reported
|
|
1135
|
+
* once. Only modules in the directories of the changed files are checked:
|
|
1136
|
+
* the event Bun drops is a sibling written in the same directory, and a
|
|
1137
|
+
* synchronous stat over every module would block the loop on each save.
|
|
1138
|
+
* @param {import('../dev-classify.js').ReloadVerdict | undefined} verdict
|
|
1139
|
+
* @param {string[]} changed
|
|
1140
|
+
* @returns {{ verdict: import('../dev-classify.js').ReloadVerdict | undefined, changed: string[] }}
|
|
1141
|
+
*/
|
|
1142
|
+
function withMissedChanges(verdict, changed) {
|
|
1143
|
+
const seen = new Set(changed);
|
|
1144
|
+
/** @type {Set<string>} */
|
|
1145
|
+
const dirs = new Set();
|
|
1146
|
+
// A reported path may be a directory itself (a folder created or renamed).
|
|
1147
|
+
for (const f of changed) { dirs.add(dirname(f)); dirs.add(f); }
|
|
1148
|
+
/** @type {string[]} */
|
|
1149
|
+
const missed = [];
|
|
1150
|
+
for (const f of movedServedModules(dirs)) if (!seen.has(f)) { seen.add(f); missed.push(f); }
|
|
1151
|
+
for (const [f, stamp] of state.shippedStamps) {
|
|
1152
|
+
if (!dirs.has(dirname(f))) continue;
|
|
1153
|
+
const now = diskStamp(f);
|
|
1154
|
+
if (now === stamp) continue;
|
|
1155
|
+
state.shippedStamps.set(f, now);
|
|
1156
|
+
if (!seen.has(f)) { seen.add(f); missed.push(f); }
|
|
1157
|
+
}
|
|
1158
|
+
for (const f of changed) if (state.shippedStamps.has(f)) state.shippedStamps.set(f, diskStamp(f));
|
|
1159
|
+
if (!missed.length) return { verdict, changed };
|
|
1160
|
+
if (verdict) {
|
|
1161
|
+
const ctx = { appDir, shippedFiles: state.shippedFiles, graphFiles: state.graphFiles, pageFiles: state.pageFiles, analysisReady: analysisEverDone, sep };
|
|
1162
|
+
for (const f of missed) verdict = mergeVerdicts(verdict, classifyChangedPath(f, ctx));
|
|
1163
|
+
}
|
|
1164
|
+
return { verdict, changed: [...changed, ...missed] };
|
|
1165
|
+
}
|
|
1166
|
+
|
|
944
1167
|
/**
|
|
945
1168
|
* @param {import('../dev-classify.js').ReloadVerdict} [verdict]
|
|
946
1169
|
* @param {boolean} [notify] tell the open tabs (`onReload`). False for the
|
|
947
1170
|
* re-derive a `bun --hot` re-run asks for (#1575), whose edit the watcher
|
|
948
1171
|
* already announced.
|
|
1172
|
+
* @param {string[]} [changed] absolute paths of the files that changed
|
|
949
1173
|
*/
|
|
950
|
-
async function doRebuild(verdict, notify = true) {
|
|
1174
|
+
async function doRebuild(verdict, notify = true, changed = []) {
|
|
1175
|
+
// Bun's `fs.watch` (1.3.14) reports only the first of two files written
|
|
1176
|
+
// about a millisecond apart in one directory, so the watcher's list can
|
|
1177
|
+
// be short. Add every module that moved on disk without an event, and
|
|
1178
|
+
// classify it into the verdict the tabs get, BEFORE anything below reads
|
|
1179
|
+
// either, so a missed component edit still swaps or reloads (#1689).
|
|
1180
|
+
if (dev && bunVersions && changed.length) ({ verdict, changed } = withMissedChanges(verdict, changed));
|
|
1181
|
+
// Version the edited modules and their importers FIRST (#1689), so every
|
|
1182
|
+
// import from here on, the re-warm included, gets the new instances. A
|
|
1183
|
+
// no-op where the resolve hook is not installed.
|
|
1184
|
+
if (dev && changed.length) bumpModuleVersions(changed);
|
|
1185
|
+
// The browser's versions walk the PREVIOUS analysis, which is what the open
|
|
1186
|
+
// tabs hold. Captured before anything below resets it.
|
|
1187
|
+
const prev = {
|
|
1188
|
+
graph: state.moduleGraph, shipped: state.shippedFiles, components: state.componentFiles,
|
|
1189
|
+
graphFiles: state.graphFiles, pageFiles: state.pageFiles, entries: state.browserEntryFiles,
|
|
1190
|
+
// Walked once here for both the version bump and the hot-swap walk.
|
|
1191
|
+
importers: dev && changed.length && state.moduleGraph ? shippedImporters(state.moduleGraph, state.shippedFiles) : null,
|
|
1192
|
+
};
|
|
1193
|
+
if (prev.importers) state.browserVersions.bump(changed, prev.importers, prev.shipped);
|
|
1194
|
+
// A component edit the tab can apply by re-importing its module (#1689).
|
|
1195
|
+
if (dev && notify) verdict = await hotVerdict(verdict, changed, prev);
|
|
1196
|
+
// Invalidate the analysis BEFORE anything below can throw (#1689). The
|
|
1197
|
+
// route table scan is the first step that can, and a failure there used to
|
|
1198
|
+
// skip the reset further down, so the next request served the pre-edit
|
|
1199
|
+
// analysis until some later edit rebuilt cleanly. It runs again at the end
|
|
1200
|
+
// in case a request re-warmed against the old route table meanwhile.
|
|
1201
|
+
await invalidateAnalysis();
|
|
951
1202
|
// The route table is the only eager artifact (cheap directory scan); rebuild
|
|
952
1203
|
// it so routing reflects added/removed route files immediately.
|
|
953
1204
|
state.routeTable = await buildRouteTable(appDir);
|
|
1205
|
+
state.publicFiles = buildPublicIndex(appDir);
|
|
954
1206
|
// Adding or deleting app/icon.* changes whether the head auto-links it.
|
|
955
1207
|
setMetadataIconRoutes(state.routeTable.metadataRoutes);
|
|
956
1208
|
// Refresh the generated route types (#258) so adding/removing a route file
|
|
957
1209
|
// updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
|
|
958
1210
|
// best-effort (see emitRouteTypes).
|
|
959
1211
|
if (dev) void emitRouteTypes();
|
|
960
|
-
|
|
1212
|
+
// The resolved vendor map and its SRI are keyed by the sorted install set
|
|
1213
|
+
// and by url, so a new bare import already misses them. Only the app's
|
|
1214
|
+
// package.json (a version bump) can make a hit stale (#1689); clearing on
|
|
1215
|
+
// every edit made an unpinned app re-resolve and re-fetch every bundle's
|
|
1216
|
+
// SRI after each save. A caller that names no files (an embedder's bare
|
|
1217
|
+
// `rebuild()`) keeps the old clear-everything behaviour.
|
|
1218
|
+
if (!changed.length || changed.some((f) => resolve(f) === join(appDir, 'package.json'))) clearVendorCache();
|
|
961
1219
|
// A rebuild changes the bare-import set, so a failed resolve's backoff
|
|
962
1220
|
// window no longer describes anything; the next request may try at once.
|
|
963
1221
|
vendorBackoff.reset();
|
|
@@ -973,23 +1231,11 @@ export async function createRequestHandler(opts) {
|
|
|
973
1231
|
// modules too (#1644); the plain `webjs start` path never gets here.
|
|
974
1232
|
clearProdImportCache();
|
|
975
1233
|
state.tsCache.clear();
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
|
|
979
|
-
// after the reset. A dependency edit can flip an elision verdict without
|
|
980
|
-
// changing an importer's mtime, hence the state.tsCache.clear above.
|
|
981
|
-
if (readyInFlight) { try { await readyInFlight; } catch {} }
|
|
982
|
-
// Bump the vendor generation so a vendor resolve still in flight from the
|
|
983
|
-
// previous build cannot flip vendorResolved against the fresh state.
|
|
984
|
-
vendorGen++;
|
|
985
|
-
analysisDone = false;
|
|
986
|
-
vendorResolved = false;
|
|
987
|
-
vendorAttemptedOnce = false;
|
|
988
|
-
readyDone = false;
|
|
989
|
-
readyError = null;
|
|
990
|
-
readinessFn = undefined;
|
|
1234
|
+
// A dependency edit can flip a lazy or frontier answer without changing an
|
|
1235
|
+
// importer's mtime, hence the state.tsCache.clear above.
|
|
1236
|
+
await invalidateAnalysis();
|
|
991
1237
|
// Refresh the on-request regenerate rules (#967) so a `webjs.dev.regenerate`
|
|
992
|
-
// config edit takes effect without a restart, mirroring the
|
|
1238
|
+
// config edit takes effect without a restart, mirroring the router
|
|
993
1239
|
// re-reads above. Dev-only; a no-op array in prod.
|
|
994
1240
|
if (dev) state.regenerateRules = await readRegenerateRules(appDir);
|
|
995
1241
|
// Optimistically clear the dev error (#264): the rebuild itself only
|
|
@@ -1012,6 +1258,161 @@ export async function createRequestHandler(opts) {
|
|
|
1012
1258
|
// runs on both boundary tiers and swaps in the changed boot script, which
|
|
1013
1259
|
// executes and registers the new component.
|
|
1014
1260
|
if (notify) opts.onReload?.(verdict || { v: 'reload', by: '', why: 'no-verdict' });
|
|
1261
|
+
// Evict every cached HTML response LAST (#1689), on the silent path too. The
|
|
1262
|
+
// cache key does not change across a rebuild, so a page with
|
|
1263
|
+
// `export const revalidate` otherwise served its pre-edit HTML for the whole
|
|
1264
|
+
// window, and that body carries the old `webjs-dev-reload` meta, which the
|
|
1265
|
+
// reload relay reads as a page that is behind and reloads into again. Last,
|
|
1266
|
+
// so any render that started before it is refused by the commit's
|
|
1267
|
+
// generation check.
|
|
1268
|
+
revalidateAll();
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Invalidate the lazy analysis; the next request rebuilds the graph,
|
|
1273
|
+
* component scan, gate, action index, middleware, frontiers, and vendor map.
|
|
1274
|
+
* Waits out any in-flight build first so it cannot commit stale results after
|
|
1275
|
+
* the reset, and bumps the vendor generation so a vendor resolve still in
|
|
1276
|
+
* flight from the previous build cannot flip `vendorResolved` against the
|
|
1277
|
+
* fresh state.
|
|
1278
|
+
*/
|
|
1279
|
+
async function invalidateAnalysis() {
|
|
1280
|
+
if (readyInFlight) { try { await readyInFlight; } catch {} }
|
|
1281
|
+
vendorGen++;
|
|
1282
|
+
analysisDone = false;
|
|
1283
|
+
vendorResolved = false;
|
|
1284
|
+
vendorAttemptedOnce = false;
|
|
1285
|
+
readyDone = false;
|
|
1286
|
+
readyError = null;
|
|
1287
|
+
readinessFn = undefined;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* The hot-swap signature of a file on disk, or null when it cannot be read
|
|
1292
|
+
* or stripped.
|
|
1293
|
+
* @param {string} file
|
|
1294
|
+
*/
|
|
1295
|
+
async function readHotSignature(file) {
|
|
1296
|
+
try {
|
|
1297
|
+
const src = await readFile(file, 'utf8');
|
|
1298
|
+
return hotSignature(/\.m?tsx?$/.test(file) ? await stripTypeScript(src) : src);
|
|
1299
|
+
} catch {
|
|
1300
|
+
return null;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Turn a reload the edit of shipped browser modules caused into a
|
|
1306
|
+
* `component` hot swap when every edited module reaches a component boundary
|
|
1307
|
+
* the tab can re-import (`dev-hot-classify.js`), or into a reload that names
|
|
1308
|
+
* the precise reason it cannot (#1689). Each changed path is classified again
|
|
1309
|
+
* against the PREVIOUS analysis, because the merged verdict keeps only the
|
|
1310
|
+
* strongest file's reason. Fails safe: any surprise returns the verdict as
|
|
1311
|
+
* it came.
|
|
1312
|
+
* @param {import('../dev-classify.js').ReloadVerdict | undefined} verdict
|
|
1313
|
+
* @param {string[]} changed
|
|
1314
|
+
* @param {{ graph: any, shipped: Set<string>, components: Set<string>, graphFiles: Set<string>, pageFiles: Set<string>, entries: Set<string>, importers: Map<string, Set<string>> | null }} prev
|
|
1315
|
+
* @returns {Promise<import('../dev-classify.js').ReloadVerdict | undefined>}
|
|
1316
|
+
*/
|
|
1317
|
+
async function hotVerdict(verdict, changed, prev) {
|
|
1318
|
+
if (!verdict || verdict.v !== 'reload' || !changed.length || !prev.importers || !analysisEverDone) return verdict;
|
|
1319
|
+
const importers = prev.importers;
|
|
1320
|
+
/** @type {Map<string, import('../dev-hot-classify.js').HotSignature | null>} */
|
|
1321
|
+
const sigs = new Map();
|
|
1322
|
+
const signatureOf = async (/** @type {string} */ f) => {
|
|
1323
|
+
if (!sigs.has(f)) sigs.set(f, await readHotSignature(f));
|
|
1324
|
+
return sigs.get(f) || null;
|
|
1325
|
+
};
|
|
1326
|
+
try {
|
|
1327
|
+
const ctx = { appDir, shippedFiles: prev.shipped, graphFiles: prev.graphFiles, pageFiles: prev.pageFiles, analysisReady: true, sep };
|
|
1328
|
+
const perFile = changed.map((f) => classifyChangedPath(f, ctx));
|
|
1329
|
+
const reloads = perFile.filter((v) => v.v === 'reload');
|
|
1330
|
+
if (!reloads.length || reloads.some((v) => v.why !== 'ships-to-browser')) return verdict;
|
|
1331
|
+
const res = await findHotBoundaries(changed.filter((_, i) => perFile[i].v === 'reload'), {
|
|
1332
|
+
shipped: prev.shipped,
|
|
1333
|
+
componentFiles: prev.components,
|
|
1334
|
+
// The browser entries that are not components (`instrumentation-client`;
|
|
1335
|
+
// a route module is never an entry, #1686): an edit reaching one has no
|
|
1336
|
+
// component to re-import.
|
|
1337
|
+
routeFiles: new Set([...prev.entries].filter((f) => !prev.components.has(f))),
|
|
1338
|
+
importersOf: (f) => importers.get(f) || [],
|
|
1339
|
+
signatureOf,
|
|
1340
|
+
baselineOf: (f) => state.hotSignatures.get(f),
|
|
1341
|
+
sideEffectOf: async (f) => {
|
|
1342
|
+
try {
|
|
1343
|
+
// The browser gets an action's RPC stub, never its source.
|
|
1344
|
+
if (isServerFile(f)) return !(await hasUseServerDirective(f));
|
|
1345
|
+
const src = await readFile(f, 'utf8');
|
|
1346
|
+
return hasModuleScopeSideEffect(/\.m?tsx?$/.test(f) ? await stripTypeScript(src) : src);
|
|
1347
|
+
} catch {
|
|
1348
|
+
return true;
|
|
1349
|
+
}
|
|
1350
|
+
},
|
|
1351
|
+
rel: (f) => relative(appDir, f).split(sep).join('/'),
|
|
1352
|
+
});
|
|
1353
|
+
if (!res.ok) return { v: 'reload', by: res.by, why: res.why };
|
|
1354
|
+
const bp = basePath();
|
|
1355
|
+
const modules = res.boundaries.map((b) => withBasePath(toUrlPath(b, appDir), bp) + '?t=' + state.browserVersions.versionOf(b));
|
|
1356
|
+
/** @type {import('../dev-classify.js').ReloadVerdict} */
|
|
1357
|
+
const hot = { v: 'component', by: reloads[0].by, why: 'hot-swap', modules };
|
|
1358
|
+
return /** @type {import('../dev-classify.js').ReloadVerdict} */ (perFile.filter((v) => v.v !== 'reload').reduce((a, b) => mergeVerdicts(a, b), hot));
|
|
1359
|
+
} catch {
|
|
1360
|
+
return verdict;
|
|
1361
|
+
} finally {
|
|
1362
|
+
// The tab applies every verdict, a reload included, so from now on it
|
|
1363
|
+
// holds each changed component's new source.
|
|
1364
|
+
for (const f of changed) {
|
|
1365
|
+
if (!prev.components.has(f)) continue;
|
|
1366
|
+
const sig = await signatureOf(f);
|
|
1367
|
+
if (sig) state.hotSignatures.set(f, sig);
|
|
1368
|
+
else state.hotSignatures.delete(f);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* The `/__webjs/dev/status` body (#1664). Waits up to `waitMs` for a pending
|
|
1375
|
+
* change to be rebuilt, then drives the lazy analysis so readiness describes
|
|
1376
|
+
* the CURRENT source rather than the build before the edit.
|
|
1377
|
+
* @param {number} waitMs
|
|
1378
|
+
* @returns {Promise<Response>}
|
|
1379
|
+
*/
|
|
1380
|
+
async function devStatusResponse(waitMs) {
|
|
1381
|
+
const status = /** @type {NonNullable<typeof devStatus>} */ (devStatus);
|
|
1382
|
+
const deadline = Date.now() + waitMs;
|
|
1383
|
+
while (waitMs && status.changing() && Date.now() < deadline) {
|
|
1384
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1385
|
+
}
|
|
1386
|
+
const warm = ensureReady().catch(() => {});
|
|
1387
|
+
if (waitMs && !status.changing()) {
|
|
1388
|
+
const left = Math.max(0, deadline - Date.now());
|
|
1389
|
+
await Promise.race([warm, new Promise((r) => setTimeout(r, left))]);
|
|
1390
|
+
}
|
|
1391
|
+
const { rebuilds, errors } = status.snapshot();
|
|
1392
|
+
// The same decision `/__webjs/ready` makes: once warm, a later retry's error
|
|
1393
|
+
// (a transient vendor failure the server tolerates) does not un-ready it.
|
|
1394
|
+
const ready = readyDone ? 'ok' : readyError ? 'error' : 'pending';
|
|
1395
|
+
const settled = !status.changing() && ready !== 'pending';
|
|
1396
|
+
if (ready === 'error') {
|
|
1397
|
+
errors.unshift({
|
|
1398
|
+
kind: 'ready', message: String((readyError && readyError.message) || readyError),
|
|
1399
|
+
file: null, line: null, column: null, codeFrame: null, hint: null, url: null, at: new Date().toISOString(),
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
const reload = devReloadState();
|
|
1403
|
+
const body = {
|
|
1404
|
+
status: errors.length ? 'error' : settled ? 'ok' : 'pending',
|
|
1405
|
+
settled,
|
|
1406
|
+
ready,
|
|
1407
|
+
boot: reload ? reload.boot : null,
|
|
1408
|
+
seq: reload ? reload.seq : null,
|
|
1409
|
+
rebuilds,
|
|
1410
|
+
errors,
|
|
1411
|
+
// Advisory, never a failure: code in a page, layout or boundary that
|
|
1412
|
+
// looks meant for the browser but runs only on the server (#1686).
|
|
1413
|
+
warnings: state.routeHints.map((h) => ({ kind: 'route-browser-code', file: h.file, message: h.message })),
|
|
1414
|
+
};
|
|
1415
|
+
return Response.json(body, { headers: { 'cache-control': 'no-store' } });
|
|
1015
1416
|
}
|
|
1016
1417
|
|
|
1017
1418
|
/** A routing-convention module under `app/`, which only the router loads. */
|
|
@@ -1056,6 +1457,20 @@ export async function createRequestHandler(opts) {
|
|
|
1056
1457
|
// request scope, so `readBody` inside a route handler enforces the same
|
|
1057
1458
|
// cap the framework's own RPC / form-dispatch body reads do.
|
|
1058
1459
|
setBodyLimits(state.bodyLimits);
|
|
1460
|
+
setAllowedOrigins(allowedOriginsValue);
|
|
1461
|
+
|
|
1462
|
+
// A component that throws during SSR renders its error state in place and
|
|
1463
|
+
// the page still answers 200, so in dev it is recorded here, as kind
|
|
1464
|
+
// `component`, for `webjs status` and `/__webjs/dev/status` to report.
|
|
1465
|
+
// A `render` entry would not do: a 200 for the url clears those.
|
|
1466
|
+
if (dev) {
|
|
1467
|
+
setComponentErrorSink((tag, error) => {
|
|
1468
|
+
const message = error && typeof error === 'object' && 'message' in error ? String(/** @type {any} */ (error).message) : String(error);
|
|
1469
|
+
const wrapped = new Error(`<${tag}> failed to render during SSR, so it shows its error state: ${message}`);
|
|
1470
|
+
if (error && typeof error === 'object' && 'stack' in error) wrapped.stack = String(/** @type {any} */ (error).stack);
|
|
1471
|
+
recordDevStatus(wrapped, req, 'component');
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1059
1474
|
|
|
1060
1475
|
let pathname = '/';
|
|
1061
1476
|
try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
|
|
@@ -1077,19 +1492,35 @@ export async function createRequestHandler(opts) {
|
|
|
1077
1492
|
let res;
|
|
1078
1493
|
try {
|
|
1079
1494
|
res = await produce(req);
|
|
1495
|
+
// `cookies().set()` / `.delete()` anywhere in the request (an action,
|
|
1496
|
+
// a route handler, middleware, a render) lands here as Set-Cookie.
|
|
1497
|
+
res = applyPendingCookies(res, currentCookieStore());
|
|
1498
|
+
// The headers are decided now; a streamed body still rendering after
|
|
1499
|
+
// this point must not write a cookie that can never be sent.
|
|
1500
|
+
sealCookies();
|
|
1080
1501
|
} catch (e) {
|
|
1081
1502
|
// A throw escaping produce() is the last-resort 500 (every interior
|
|
1082
1503
|
// path catches its own errors, but a surprise still must not crash the
|
|
1083
1504
|
// host). Fire the onError sink (best-effort) and emit a sanitized 500,
|
|
1084
1505
|
// preserving the prior behavior plus the new APM hook.
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1506
|
+
if (dev && e && /** @type {any} */ (e).code === 'WEBJS_APP_LAYOUT') {
|
|
1507
|
+
// A layout problem (see app-layout.js) is the app's to fix, not a
|
|
1508
|
+
// crash: show it on every request and in the overlay until an edit
|
|
1509
|
+
// resolves it.
|
|
1510
|
+
reportDevError(e, { kind: 'rebuild' });
|
|
1511
|
+
res = new Response(`[webjs] ${/** @type {Error} */ (e).message}\n`, {
|
|
1512
|
+
status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
1513
|
+
});
|
|
1514
|
+
} else {
|
|
1515
|
+
reportError(e, req, 'handle');
|
|
1516
|
+
logger.error?.('[webjs] request pipeline threw', {
|
|
1517
|
+
requestId: reqId,
|
|
1518
|
+
method: req.method,
|
|
1519
|
+
path: pathname,
|
|
1520
|
+
err: e instanceof Error ? e.stack : String(e),
|
|
1521
|
+
});
|
|
1522
|
+
res = new Response('Server error', { status: 500 });
|
|
1523
|
+
}
|
|
1093
1524
|
}
|
|
1094
1525
|
|
|
1095
1526
|
// Merge in the secure-by-default headers plus the per-path config
|
|
@@ -1134,6 +1565,13 @@ export async function createRequestHandler(opts) {
|
|
|
1134
1565
|
// is the developer saying "this dev server is framed by these origins".
|
|
1135
1566
|
if (embedOriginsValue.length) allowEmbedFraming(merged.headers, embedOriginsValue);
|
|
1136
1567
|
|
|
1568
|
+
// A form action's flash is retired when the page it is bound to
|
|
1569
|
+
// redirects instead of rendering, so no other page ever shows it
|
|
1570
|
+
// (action-flash.js). Best-effort.
|
|
1571
|
+
if (req.method === 'GET' && merged.status >= 300 && merged.status < 400) {
|
|
1572
|
+
try { await retireFlashOnRedirect(req, merged, basePathValue); } catch { /* ignore */ }
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1137
1575
|
// Server HTML cache write (#241): if the SSR marked this response as a
|
|
1138
1576
|
// cache candidate (an opted-in `revalidate` page), store the FINAL body
|
|
1139
1577
|
// now, after segment middleware has added any per-user Set-Cookie (which
|
|
@@ -1175,6 +1613,15 @@ export async function createRequestHandler(opts) {
|
|
|
1175
1613
|
// Use the STRIPPED path for the suppression check (issue #256) so a
|
|
1176
1614
|
// framework probe at `<basePath>/__webjs/*` is suppressed just like the
|
|
1177
1615
|
// root-mounted `/__webjs/*`. The logged `path` stays the RAW client URL.
|
|
1616
|
+
// Dev status (#1664): a GET that answered below 500 supersedes a render
|
|
1617
|
+
// error recorded for that url, whatever answered it. A page fixed in
|
|
1618
|
+
// place renders 200, a deleted or renamed one 404s or redirects, and in
|
|
1619
|
+
// every case its old render error is stale. A speculative prefetch is
|
|
1620
|
+
// not a view of anything, so it clears nothing.
|
|
1621
|
+
if (devStatus && (req.method === 'GET' || req.method === 'HEAD') && conditioned.status < 500
|
|
1622
|
+
&& req.headers.get('x-webjs-prefetch') !== '1') {
|
|
1623
|
+
devStatus.renderSucceeded([headerPathname, pathname]);
|
|
1624
|
+
}
|
|
1178
1625
|
if (shouldAccessLog(headerPathname)) {
|
|
1179
1626
|
try {
|
|
1180
1627
|
// #1309: fold the dev-only seeding counters into the ONE access-log
|
|
@@ -1268,7 +1715,7 @@ export async function createRequestHandler(opts) {
|
|
|
1268
1715
|
// Default `'ignore'` is a no-op. The root `/` and file paths are
|
|
1269
1716
|
// exempt; `/__webjs/*` is exempt too (defense in depth, the redirects
|
|
1270
1717
|
// above already skip it). The funnel in handle() still wraps this.
|
|
1271
|
-
const canonical = applyTrailingSlash(req, trailingSlashPolicy);
|
|
1718
|
+
const canonical = applyTrailingSlash(req, trailingSlashPolicy, (p) => !!state.publicFiles && state.publicFiles.has(p));
|
|
1272
1719
|
if (canonical) return canonical;
|
|
1273
1720
|
}
|
|
1274
1721
|
|
|
@@ -1335,6 +1782,19 @@ export async function createRequestHandler(opts) {
|
|
|
1335
1782
|
return Response.json({ status: 'ok' }, { headers: noStore });
|
|
1336
1783
|
}
|
|
1337
1784
|
|
|
1785
|
+
// Dev status (#1664): has the last edit settled, and what broke since the
|
|
1786
|
+
// last rebuild. Answered before ensureReady like the other probes, and only
|
|
1787
|
+
// in dev, since it carries file paths and stacks, the same exposure as the
|
|
1788
|
+
// error overlay. `?wait=<seconds>` (capped at 60) holds the response until
|
|
1789
|
+
// the change has been rebuilt and the analysis re-run, which is the part
|
|
1790
|
+
// the reload frame cannot tell anyone: it goes out before any re-import.
|
|
1791
|
+
if (probePath === '/__webjs/dev/status') {
|
|
1792
|
+
if (!devStatus) return new Response('Not Found', { status: 404 });
|
|
1793
|
+
let waitParam = 0;
|
|
1794
|
+
try { waitParam = Number(new URL(req.url).searchParams.get('wait')) || 0; } catch { /* no wait */ }
|
|
1795
|
+
return devStatusResponse(Math.min(Math.max(waitParam, 0), 60) * 1000);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1338
1798
|
// Framework-internal static assets (the @webjsdev/core runtime, the dev
|
|
1339
1799
|
// reload client, downloaded vendor bundles) depend on neither the analysis
|
|
1340
1800
|
// nor the vendor importmap, so serve them BEFORE ensureReady(). Otherwise a
|
|
@@ -1348,20 +1808,20 @@ export async function createRequestHandler(opts) {
|
|
|
1348
1808
|
const earlyStatic = await tryServeFrameworkStatic(path, req.method.toUpperCase(), { coreDir, appDir, dev, versioned: url.searchParams.has('v') });
|
|
1349
1809
|
if (earlyStatic) return earlyStatic;
|
|
1350
1810
|
|
|
1351
|
-
//
|
|
1811
|
+
// A `public/` file (served at the url root) needs neither the module graph nor the vendor importmap, so
|
|
1352
1812
|
// in DEV serve it here rather than behind the analysis (#1397). A cold
|
|
1353
|
-
// `/
|
|
1813
|
+
// `/tailwind.css` measured 1907ms at 4a335549, of which 1900ms was
|
|
1354
1814
|
// `ensureReady()`, which is why the stylesheet is the request most exposed
|
|
1355
1815
|
// to the next `node --watch` restart. DEV ONLY on purpose: in prod
|
|
1356
1816
|
// `/__webjs/ready` already holds traffic off a cold instance until the
|
|
1357
1817
|
// analysis and the first vendor attempt have both completed, and hoisting
|
|
1358
|
-
// there would silently un-gate a
|
|
1818
|
+
// there would silently un-gate a public file that an app middleware
|
|
1359
1819
|
// protects. The consequence in dev is that root middleware does not run
|
|
1360
1820
|
// for these paths, the same trade the framework statics above already make
|
|
1361
1821
|
// in both modes. `state.regenerateRules` is loaded at boot inside
|
|
1362
1822
|
// createRequestHandler, so the #967 on-request rebuild is available here,
|
|
1363
1823
|
// ahead of ensureReady.
|
|
1364
|
-
if (dev) {
|
|
1824
|
+
if (dev && req.method === 'GET') {
|
|
1365
1825
|
const publicResp = await tryServePublicAsset(path, {
|
|
1366
1826
|
appDir, dev, versioned: url.searchParams.has('v'), regenerateRules: state.regenerateRules,
|
|
1367
1827
|
});
|
|
@@ -1379,6 +1839,9 @@ export async function createRequestHandler(opts) {
|
|
|
1379
1839
|
dev,
|
|
1380
1840
|
reportError,
|
|
1381
1841
|
reportDevError,
|
|
1842
|
+
// Dev only: in production the API dispatch takes its plain path with no
|
|
1843
|
+
// recording wrapper at all.
|
|
1844
|
+
recordDevStatus: dev ? recordDevStatus : undefined,
|
|
1382
1845
|
hasOnError,
|
|
1383
1846
|
logger,
|
|
1384
1847
|
cspEnabled: cspConfig.enabled,
|
|
@@ -1431,8 +1894,7 @@ export async function createRequestHandler(opts) {
|
|
|
1431
1894
|
// on the first warm request.
|
|
1432
1895
|
const moduleUrls = hintedBootModules(page.route, {
|
|
1433
1896
|
appDir,
|
|
1434
|
-
|
|
1435
|
-
importOnly: state.importOnlyRouteModules,
|
|
1897
|
+
frontiers: state.routeFrontiers,
|
|
1436
1898
|
lazyFiles: state.lazyComponentFiles,
|
|
1437
1899
|
instrumentationClient: state.routeTable.instrumentationClient,
|
|
1438
1900
|
}, basePathValue);
|
|
@@ -1445,7 +1907,7 @@ export async function createRequestHandler(opts) {
|
|
|
1445
1907
|
routeFor,
|
|
1446
1908
|
/**
|
|
1447
1909
|
* Proactively run the first-request analysis (module graph, component
|
|
1448
|
-
* scan, gate, action index, middleware,
|
|
1910
|
+
* scan, gate, action index, middleware, frontiers, vendor map) in the
|
|
1449
1911
|
* background, so a real first request finds it already memoized. Safe to
|
|
1450
1912
|
* call any number of times and concurrently: the work is single-flighted,
|
|
1451
1913
|
* so this never duplicates it or races a real request. It is a single
|
|
@@ -1464,6 +1926,9 @@ export async function createRequestHandler(opts) {
|
|
|
1464
1926
|
* startServer to a freshly-connected SSE client so the overlay shows even
|
|
1465
1927
|
* after a navigation, not only on the breaking edit. Always null in prod. */
|
|
1466
1928
|
getLastDevError: () => state.lastDevError,
|
|
1929
|
+
/** Tell the dev status (#1664) a watched app file changed, so a status
|
|
1930
|
+
* wait holds until the rebuild that follows. A no-op in prod. */
|
|
1931
|
+
noteChange: () => devStatus?.noteChange(),
|
|
1467
1932
|
/** Whether a dev-watcher filename is a `webjs.dev.regenerate` OUTPUT (#967),
|
|
1468
1933
|
* so `startServer`'s file watcher (a different scope, with no access to
|
|
1469
1934
|
* `state`) can skip a build product the server itself writes and avoid a
|
|
@@ -1492,9 +1957,10 @@ export async function createRequestHandler(opts) {
|
|
|
1492
1957
|
* (#1575), the `bun --hot` re-run `startServer` pokes for. A module no
|
|
1493
1958
|
* other app module imports is only ever loaded through `devImport`, whose
|
|
1494
1959
|
* specifier names the file's content, so its edit is a new specifier and
|
|
1495
|
-
* needs nothing reset
|
|
1496
|
-
*
|
|
1497
|
-
*
|
|
1960
|
+
* needs nothing reset, and neither does a module the Bun dev plugins
|
|
1961
|
+
* already served, which the module versions re-evaluate (#1689). Otherwise anything imported by another
|
|
1962
|
+
* module, a file the last analysis never saw (a new file may be the target
|
|
1963
|
+
* of an import that failed), or any edit before the first analysis does.
|
|
1498
1964
|
*
|
|
1499
1965
|
* @param {string} filename relative to `root`
|
|
1500
1966
|
* @param {string} [root] the watched root, defaulting to the app dir
|
|
@@ -1502,6 +1968,10 @@ export async function createRequestHandler(opts) {
|
|
|
1502
1968
|
*/
|
|
1503
1969
|
needsRegistryReset: (filename, root) => {
|
|
1504
1970
|
const abs = resolve(root || appDir, filename);
|
|
1971
|
+
// On Bun a module the dev plugins already served is versioned in place
|
|
1972
|
+
// (#1689): the rebuild bumps it and its importers, and the next import
|
|
1973
|
+
// evaluates that chain fresh. A new or deleted file still resets.
|
|
1974
|
+
if (bunVersions && isServedModule(abs) && existsSync(abs)) return false;
|
|
1505
1975
|
if (!analysisEverDone) return true;
|
|
1506
1976
|
// A new routing file (a page, layout, route handler, boundary) is only
|
|
1507
1977
|
// ever loaded by the router, and nothing can have tried to import it yet.
|
|
@@ -1516,6 +1986,13 @@ export async function createRequestHandler(opts) {
|
|
|
1516
1986
|
analysisReady: analysisEverDone,
|
|
1517
1987
|
sep,
|
|
1518
1988
|
}),
|
|
1989
|
+
/** Whether this handler re-evaluates edited server modules in place on
|
|
1990
|
+
* Node (#1689), so `startServer` can tell the dev supervisor it need not
|
|
1991
|
+
* restart the process for an app edit. */
|
|
1992
|
+
hotModules: !!hotModules,
|
|
1993
|
+
// Whether an app file is held by the CommonJS loader, which no module
|
|
1994
|
+
// version reaches, so an edit to it needs a restart (#1689). Node only.
|
|
1995
|
+
isCommonJsModule: (abs) => !!hotModules && isCommonJsModule(abs),
|
|
1519
1996
|
appDir,
|
|
1520
1997
|
dev,
|
|
1521
1998
|
logger,
|