@webjsdev/server 0.9.0 → 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 +427 -109
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -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 +96 -14
- 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/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- 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 +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -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/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -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 +177 -129
- package/src/dev/server.js +92 -33
- 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 +2 -2
- 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.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 +140 -65
- 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 +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +80 -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 +1 -6
- package/src/component-elision.js +0 -1828
- 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,14 +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';
|
|
57
72
|
import { isControlFlowThrow } from '../action-error.js';
|
|
58
73
|
import { createDevStatus } from './dev-status.js';
|
|
59
74
|
import { devReloadState } from '../dev-reload-state.js';
|
|
60
75
|
|
|
61
76
|
import {
|
|
62
|
-
readElideEnabled,
|
|
63
77
|
readSeedEnabled,
|
|
64
78
|
readClientRouterEnabled,
|
|
65
79
|
readHeaderRules,
|
|
@@ -73,20 +87,21 @@ import {
|
|
|
73
87
|
readDevToolingFromApp,
|
|
74
88
|
} from './config.js';
|
|
75
89
|
import {
|
|
76
|
-
exists, kebab, resolveRequestId, shouldAccessLog,
|
|
90
|
+
exists, kebab, resolveRequestId, shouldAccessLog,
|
|
77
91
|
appTopLevelDirs, locateCoreDir,
|
|
78
92
|
setReloadIdleMs,
|
|
79
93
|
resolveReloadIdleMs,
|
|
80
94
|
} from './helpers.js';
|
|
81
95
|
import {
|
|
82
|
-
handleCore,
|
|
96
|
+
handleCore, tryServeFrameworkStatic, tryServePublicAsset,
|
|
83
97
|
} from './serve.js';
|
|
84
98
|
import { devImport, clearProdImportCache } from '../dev-import.js';
|
|
99
|
+
import { retireFlashOnRedirect } from '../action-flash.js';
|
|
85
100
|
|
|
86
101
|
/**
|
|
87
102
|
* A short content digest of a single file's bytes for the app-source deploy
|
|
88
103
|
* signal (#899). Raw `sha256(bytes)`, deliberately independent of `assetHashFor`
|
|
89
|
-
* (which folds the
|
|
104
|
+
* (which folds the serve fingerprint and memoizes for `?v` emission): the
|
|
90
105
|
* signal only needs "did the source bytes change", and server-only files never
|
|
91
106
|
* enter the asset-hash memo anyway. Returns `''` on a read failure, so a
|
|
92
107
|
* transient error degrades the id rather than throwing during analysis.
|
|
@@ -152,7 +167,8 @@ export async function createRequestHandler(opts) {
|
|
|
152
167
|
// scaffolded apps with a committed .env.example + .env would fail
|
|
153
168
|
// to boot until the user discovered the missing env-load. See
|
|
154
169
|
// tracker #37.
|
|
155
|
-
|
|
170
|
+
// Next.js's env file order (.env.$mode.local, .env.local, .env.$mode, .env).
|
|
171
|
+
loadEnvFiles(appDir, envMode(!!opts.dev));
|
|
156
172
|
// Optional boot-time env validation (#236). If <appDir>/env.{js,ts} exists it
|
|
157
173
|
// default-exports a typed schema or a custom validator function; we run it
|
|
158
174
|
// against process.env (now populated by loadAppEnv) BEFORE buildActionIndex
|
|
@@ -180,7 +196,7 @@ export async function createRequestHandler(opts) {
|
|
|
180
196
|
// before traffic. Any error sink it registers via setOnError composes with
|
|
181
197
|
// the programmatic opts.onError (both fire). Fail-open (a throwing hook is
|
|
182
198
|
// logged, not fatal).
|
|
183
|
-
const { onError: instrumentationOnError } = await runInstrumentation(appDir, { dev, logger });
|
|
199
|
+
const { onError: instrumentationOnError, onRequestError } = await runInstrumentation(appDir, { dev, logger });
|
|
184
200
|
// APM / Sentry integration point (issue #239). Called whenever the request
|
|
185
201
|
// pipeline catches an unhandled error: the top-level handle() catch (the
|
|
186
202
|
// last-resort 500), an unexpected throw inside the produce() funnel, or a
|
|
@@ -234,7 +250,14 @@ export async function createRequestHandler(opts) {
|
|
|
234
250
|
devStatus.record(frame || buildDevErrorFrame(error, { kind, appDir, url }));
|
|
235
251
|
} catch { /* best effort */ }
|
|
236
252
|
}
|
|
237
|
-
const hasOnError = onErrorSinks.length > 0;
|
|
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();
|
|
238
261
|
|
|
239
262
|
/**
|
|
240
263
|
* Invoke every registered onError sink defensively. The phase is a coarse
|
|
@@ -242,13 +265,27 @@ export async function createRequestHandler(opts) {
|
|
|
242
265
|
* @param {unknown} error
|
|
243
266
|
* @param {Request} request
|
|
244
267
|
* @param {string} phase
|
|
268
|
+
* @param {string} [routePath] the route file path for `onRequestError`
|
|
245
269
|
*/
|
|
246
|
-
function reportError(error, request, phase) {
|
|
270
|
+
function reportError(error, request, phase, routePath) {
|
|
247
271
|
// Dev status (#1664). Recorded BEFORE the no-sink return, because an app
|
|
248
272
|
// with no onError sink is exactly the one whose action errors were
|
|
249
273
|
// invisible to an agent. An `ssr` error is a render failure.
|
|
250
274
|
recordDevStatus(error, request, phase === 'ssr' ? 'render' : phase);
|
|
251
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
|
+
}
|
|
252
289
|
const ctx = { request, requestId: getRequestId(), phase };
|
|
253
290
|
for (const sink of onErrorSinks) {
|
|
254
291
|
try {
|
|
@@ -332,7 +369,7 @@ export async function createRequestHandler(opts) {
|
|
|
332
369
|
|
|
333
370
|
// Install the resolver behind the isomorphic `asset()` helper (#1194), the
|
|
334
371
|
// same provider seam `cspNonce()` uses. An author writes
|
|
335
|
-
// `href=${asset('/
|
|
372
|
+
// `href=${asset('/app.css')}` and gets the fingerprinted url on the
|
|
336
373
|
// server; the browser has no provider and returns the path unchanged.
|
|
337
374
|
//
|
|
338
375
|
// The `public/` gate is defensive rather than decorative. `resolveUrlToFile`
|
|
@@ -354,7 +391,20 @@ export async function createRequestHandler(opts) {
|
|
|
354
391
|
// `webjs.seed` switch. Action identity (#1155) rides the same hook, and it is
|
|
355
392
|
// what a bound `<form action=${action}>` resolves through, so gating the hook
|
|
356
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
|
+
}
|
|
357
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);
|
|
358
408
|
|
|
359
409
|
// Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` (or
|
|
360
410
|
// `webjs.dev.sourceLocations: true`) under `webjs dev`
|
|
@@ -372,10 +422,17 @@ export async function createRequestHandler(opts) {
|
|
|
372
422
|
}
|
|
373
423
|
// Bun under `bun --hot` serves a replaced file's old source forever (#1575):
|
|
374
424
|
// load app modules through a plugin that reads them fresh. After the
|
|
375
|
-
// source-locations plugin, so that one wins when it is on.
|
|
376
|
-
|
|
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) {
|
|
377
430
|
registerBunAppSource(appDir);
|
|
378
|
-
|
|
431
|
+
installBunModuleVersions(appDir);
|
|
432
|
+
try {
|
|
433
|
+
const real = realpathSync(appDir);
|
|
434
|
+
if (real !== appDir) { registerBunAppSource(real); installBunModuleVersions(real); }
|
|
435
|
+
} catch { /* appDir only */ }
|
|
379
436
|
}
|
|
380
437
|
|
|
381
438
|
// When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
|
|
@@ -387,10 +444,10 @@ export async function createRequestHandler(opts) {
|
|
|
387
444
|
// rendered deterministically at runtime). Pinning stays optional; an unpinned
|
|
388
445
|
// app does no vendor work at boot and publishes its id after the first
|
|
389
446
|
// successful resolve instead. Either way the EXPENSIVE analysis (graph, scan,
|
|
390
|
-
// gate,
|
|
447
|
+
// gate, frontiers) and the UNPINNED jspm resolve stay deferred to the first
|
|
391
448
|
// request, so #143's win is intact; only the cheap committed-file read moves
|
|
392
449
|
// back to boot, and only when a VALID pin exists. A committed pin file is
|
|
393
|
-
// served as-is (
|
|
450
|
+
// served as-is (nothing prunes it), so the boot-resolved map equals the
|
|
394
451
|
// final served map and the published id is authoritative.
|
|
395
452
|
//
|
|
396
453
|
// Validate the pin with readPinFile BEFORE treating the app as pinned-at-boot.
|
|
@@ -416,13 +473,25 @@ export async function createRequestHandler(opts) {
|
|
|
416
473
|
}
|
|
417
474
|
|
|
418
475
|
// Whole-app analysis (module graph, component scan, browser-bound gate,
|
|
419
|
-
// action index, middleware,
|
|
476
|
+
// action index, middleware, frontiers, vendor) is NOT run at boot. It is
|
|
420
477
|
// computed on the first request via ensureReady() below and memoized, so the
|
|
421
478
|
// server starts without walking or reading the app's source, executing any
|
|
422
479
|
// server module, or hitting the network. Only the route table is built
|
|
423
480
|
// eagerly: it is a cheap directory scan (no code reads), and routing, Early
|
|
424
481
|
// Hints, and WebSocket lookups need it available before the first request.
|
|
425
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
|
+
}
|
|
426
495
|
// Auto-linked favicons: tell the head builder which icon metadata routes
|
|
427
496
|
// exist, so `app/icon.*` is linked when the app declares no metadata.icons.
|
|
428
497
|
// Bound here rather than threaded through ssrOpts, matching
|
|
@@ -494,6 +563,7 @@ export async function createRequestHandler(opts) {
|
|
|
494
563
|
|
|
495
564
|
const state = {
|
|
496
565
|
routeTable,
|
|
566
|
+
publicFiles,
|
|
497
567
|
actionIndex: null,
|
|
498
568
|
middleware: null,
|
|
499
569
|
bodyLimits,
|
|
@@ -507,21 +577,26 @@ export async function createRequestHandler(opts) {
|
|
|
507
577
|
// the core / vendor serving are unchanged, so no server source is exposed.
|
|
508
578
|
testMode: opts.testMode === true,
|
|
509
579
|
moduleGraph: null,
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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: [],
|
|
513
588
|
// `static lazy` component files -> their tags (#1524): a side-effect import
|
|
514
589
|
// of one is served as a lazy-loader registration, and the preload walks
|
|
515
|
-
// skip it.
|
|
590
|
+
// skip it.
|
|
516
591
|
lazyComponentFiles: new Map(),
|
|
517
|
-
// The browser-bound ENTRY files (
|
|
592
|
+
// The browser-bound ENTRY files (components and instrumentation-client),
|
|
518
593
|
// kept alongside the expanded gate set because the vendor scan roots at the
|
|
519
594
|
// entries rather than at the closure.
|
|
520
595
|
browserEntryFiles: new Set(),
|
|
521
596
|
browserBoundFiles: null,
|
|
522
597
|
// Dev live-reload classification (#1398). `shippedFiles` is the transitive
|
|
523
598
|
// closure of what the browser can ACTUALLY load (the gate set minus every
|
|
524
|
-
// module
|
|
599
|
+
// module the frontier walk skips), `graphFiles` is every app source the graph walked,
|
|
525
600
|
// and `pageFiles` is the route table's pages. Together they are the whole
|
|
526
601
|
// input to `classifyChangedPath`, so a watch event can be answered without
|
|
527
602
|
// reaching into the rest of this state. Dev-only; left empty in prod.
|
|
@@ -531,11 +606,28 @@ export async function createRequestHandler(opts) {
|
|
|
531
606
|
graphFiles: new Set(),
|
|
532
607
|
/** @type {Set<string>} */
|
|
533
608
|
pageFiles: new Set(),
|
|
534
|
-
//
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
//
|
|
538
|
-
//
|
|
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.
|
|
539
631
|
tsCache: new Map(),
|
|
540
632
|
// Dev source locations (#1499), fixed for the handler's lifetime, so the
|
|
541
633
|
// per-handler tsCache above never mixes annotated and plain bytes.
|
|
@@ -561,7 +653,11 @@ export async function createRequestHandler(opts) {
|
|
|
561
653
|
// process running several handlers the fallback therefore resolves against
|
|
562
654
|
// the most recently booted one, which is the same scope the asset-url
|
|
563
655
|
// provider already has.
|
|
564
|
-
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);
|
|
565
661
|
|
|
566
662
|
/**
|
|
567
663
|
* Report a dev error (#264): build a frame and push it to the open tab via
|
|
@@ -589,7 +685,7 @@ export async function createRequestHandler(opts) {
|
|
|
589
685
|
// All whole-app analysis is built lazily on the first request, memoized so
|
|
590
686
|
// boot does none of it. It runs in two stages. The deterministic analysis
|
|
591
687
|
// (module graph, component scan + prime, browser-bound gate, action index,
|
|
592
|
-
// middleware,
|
|
688
|
+
// middleware, frontiers) is network-free and, once built, never re-runs unless
|
|
593
689
|
// a rebuild invalidates it; readiness gates on it. Vendor resolution is a
|
|
594
690
|
// SEPARATE, best-effort stage: a pinned app reads a committed importmap file,
|
|
595
691
|
// an unpinned app auto-fetches from jspm. It does NOT gate readiness, so an
|
|
@@ -611,7 +707,7 @@ export async function createRequestHandler(opts) {
|
|
|
611
707
|
let analysisEverDone = false;
|
|
612
708
|
// A pinned app applied its FULL vendor map and published the build id at boot
|
|
613
709
|
// (above). The deferred vendor stage still runs once (and after every rebuild)
|
|
614
|
-
// to PRUNE that map to the
|
|
710
|
+
// to PRUNE that map to the browser-reachable specifiers, so a pinned app serves
|
|
615
711
|
// the same map an unpinned one does (#197); it does not re-publish the build id
|
|
616
712
|
// (the boot hash stays the deploy fingerprint). An unpinned app starts false and
|
|
617
713
|
// resolves live on the first request.
|
|
@@ -672,81 +768,117 @@ export async function createRequestHandler(opts) {
|
|
|
672
768
|
const now = () => performance.now();
|
|
673
769
|
try {
|
|
674
770
|
if (!analysisDone) {
|
|
771
|
+
const layoutProblems = validateAppLayout(appDir, state.routeTable, state.publicFiles);
|
|
772
|
+
if (layoutProblems.length) throw new AppLayoutError(layoutProblems);
|
|
675
773
|
let m = now();
|
|
676
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
|
+
}
|
|
677
787
|
t.graph = now() - m; m = now();
|
|
678
788
|
const components = await scanComponents(appDir);
|
|
679
789
|
await primeComponentRegistry(appDir, components);
|
|
680
790
|
t.scan = now() - m; m = now();
|
|
681
791
|
state.browserEntryFiles = browserEntryFiles(state.routeTable, components);
|
|
682
|
-
|
|
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);
|
|
683
797
|
t.gate = now() - m; m = now();
|
|
684
798
|
state.actionIndex = await buildActionIndex(appDir, dev);
|
|
685
799
|
t.actions = now() - m; m = now();
|
|
686
|
-
|
|
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
|
+
}
|
|
687
811
|
t.middleware = now() - m; m = now();
|
|
688
812
|
// Client-router opt-out (#629): re-read each pass so toggling
|
|
689
813
|
// `webjs.clientRouter` takes effect on rebuild without a restart.
|
|
690
814
|
setClientRouterEnabled(await readClientRouterEnabled(appDir));
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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);
|
|
703
845
|
// A lazy component some module imports WITH bindings loads eagerly
|
|
704
846
|
// anyway, so it keeps its preload hint and its imports as written.
|
|
705
847
|
for (const f of await bindingImportedLazyFiles(state.lazyComponentFiles, state.moduleGraph,
|
|
706
848
|
(f) => readFile(f, 'utf8'), resolveImport, appDir)) state.lazyComponentFiles.delete(f);
|
|
707
849
|
// Dev live-reload classification (#1398): derive the three sets the
|
|
708
850
|
// watch-event classifier reads. `browserBoundFiles` above is the
|
|
709
|
-
// AUTHORIZATION gate
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
// the browser actually HOLDS. Re-walk from the entries elision
|
|
713
|
-
// keeps, and an edit to anything reachable from those is a reload.
|
|
714
|
-
// An import-only page's substituted components are themselves
|
|
715
|
-
// 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.
|
|
716
854
|
if (dev) {
|
|
717
|
-
const shippedEntries = [...state.browserEntryFiles].filter((f) =>
|
|
718
|
-
!state.elidableComponents.has(f)
|
|
719
|
-
&& !state.inertRouteModules.has(f)
|
|
720
|
-
&& !state.importOnlyRouteModules.has(f));
|
|
855
|
+
const shippedEntries = [...state.browserEntryFiles].filter((f) => !state.routeModuleFiles.has(f));
|
|
721
856
|
state.shippedFiles = reachableFromEntries(state.moduleGraph, shippedEntries, appDir);
|
|
722
857
|
state.graphFiles = seenFilesFor(state.moduleGraph);
|
|
723
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);
|
|
724
867
|
}
|
|
725
|
-
// Fold the
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
// 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)`.
|
|
734
876
|
{
|
|
735
877
|
// `appDir + sep` boundary (matches asset-hash's containment guard)
|
|
736
878
|
// so a sibling-prefix dir cannot be mis-relativized.
|
|
737
879
|
const rel = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
...state.inertRouteModules,
|
|
741
|
-
// An import-only module is dropped from the boot (replaced by its
|
|
742
|
-
// component imports), so a flip in / out of that class must bust
|
|
743
|
-
// the importer's `?v` too.
|
|
744
|
-
...state.importOnlyRouteModules.keys(),
|
|
745
|
-
// A file flipping to or from `static lazy` changes how every
|
|
746
|
-
// importer serves its import of it (#1524).
|
|
747
|
-
...[...state.lazyComponentFiles.keys()].map((f) => `lazy:${f}`),
|
|
748
|
-
].map((p) => (p.startsWith('lazy:') ? `lazy:${rel(p.slice(5))}` : rel(p))).sort();
|
|
749
|
-
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') : '');
|
|
750
882
|
}
|
|
751
883
|
// App-source fingerprint for the HTML cache key (#318): a
|
|
752
884
|
// deterministic, location-independent digest of the browser-bound
|
|
@@ -754,18 +886,27 @@ export async function createRequestHandler(opts) {
|
|
|
754
886
|
// module's bytes (which the importmap-only build id misses) re-keys
|
|
755
887
|
// the cache instead of serving a body with stale `?v` boot URLs.
|
|
756
888
|
// PROD only (asset-hash is enabled there); '' in dev, where fs.watch
|
|
757
|
-
// handles staleness. Computed AFTER the
|
|
758
|
-
// 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
|
|
759
891
|
// flip changes the served output, so it moves this fingerprint too).
|
|
760
892
|
// This eagerly hashes the WHOLE browser-bound set once (not just the
|
|
761
893
|
// first route's files), but `assetHashFor` is memoized and already
|
|
762
894
|
// runs for `?v` emission in prod, so it stays a one-time bounded cost
|
|
763
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.
|
|
764
900
|
if (!dev && state.browserBoundFiles) {
|
|
765
901
|
const relApp = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
|
|
766
902
|
const lines = [...state.browserBoundFiles]
|
|
767
903
|
.map((abs) => `${relApp(abs)}:${assetHashFor(abs)}`)
|
|
768
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
|
+
}
|
|
769
910
|
setAppSourceFingerprint(lines.join('\n'));
|
|
770
911
|
} else {
|
|
771
912
|
setAppSourceFingerprint('');
|
|
@@ -774,7 +915,7 @@ export async function createRequestHandler(opts) {
|
|
|
774
915
|
// fingerprint above: hashes ALL app source (the module-graph `seen`
|
|
775
916
|
// set, INCLUDING server-only `.server.ts` that `browserBoundFiles`
|
|
776
917
|
// omits) with a raw per-file byte digest (decoupled from the
|
|
777
|
-
// asset-hash memo
|
|
918
|
+
// asset-hash memo and serve fingerprint), plus the installed
|
|
778
919
|
// `@webjsdev/server` version, so an app-source OR a server-framework
|
|
779
920
|
// deploy moves it. Sorted + appDir-relative for determinism across
|
|
780
921
|
// instances. Drives the client's soft cache-evict (not a reload).
|
|
@@ -789,45 +930,29 @@ export async function createRequestHandler(opts) {
|
|
|
789
930
|
} else {
|
|
790
931
|
setAppSourceId('');
|
|
791
932
|
}
|
|
792
|
-
t.
|
|
933
|
+
t.frontiers = now() - m;
|
|
793
934
|
if (dev) {
|
|
794
935
|
// An orphan is EITHER shape `findOrphanComponents` reports: no
|
|
795
936
|
// registration call at all, or one whose tag is computed. The
|
|
796
937
|
// scanner matches only a literal tag, so it sees neither, and
|
|
797
|
-
// what is ALWAYS lost is the
|
|
938
|
+
// what is ALWAYS lost is the registry entry, the boot import and the
|
|
798
939
|
// preload hint. The upgrade is the part that differs, and a
|
|
799
940
|
// computed tag only survives when its importer ships WHOLE (see
|
|
800
941
|
// findOrphanComponents for why that is the exception). The doctor
|
|
801
|
-
// check
|
|
942
|
+
// check says the same thing.
|
|
802
943
|
for (const { className, file } of await findOrphanComponents(appDir)) {
|
|
803
944
|
logger.warn?.(
|
|
804
945
|
`[webjs] ${className} extends WebComponent but is never registered with a literal tag in ${file} ` +
|
|
805
946
|
`(either there is no registration call, or its tag is computed), so the scanner cannot see it: ` +
|
|
806
|
-
`no
|
|
947
|
+
`no registry entry, no preload hint, and no boot import. ` +
|
|
807
948
|
`<${kebab(className)}> tags never upgrade with no registration call, and a computed tag ` +
|
|
808
949
|
`upgrades only while its importing module ships whole. ` +
|
|
809
950
|
`Add \`${className}.register('<tag-name>');\` with a literal tag.`,
|
|
810
951
|
);
|
|
811
952
|
}
|
|
812
|
-
// The elision summary (#1308), one server-console line. NOT a
|
|
813
|
-
// browser push: an inert route ships zero application JS, and the
|
|
814
|
-
// most useful manual check an author has is opening the network
|
|
815
|
-
// tab on that route and seeing nothing, which a dev-only boot
|
|
816
|
-
// script would corrupt on exactly the pages this proves. The fact
|
|
817
|
-
// is also app-wide while a browser channel is per-tab, and the
|
|
818
|
-
// per-page detail already has a home in `webjs elision`.
|
|
819
|
-
// Re-emitted after each fs.watch rebuild, because doRebuild sets
|
|
820
|
-
// analysisDone = false and this sits inside that stage.
|
|
821
|
-
logger.info?.(
|
|
822
|
-
elideOn
|
|
823
|
-
? `[webjs] elision: ${r.elidableComponents.size}/${new Set(components.map((c) => c.file)).size} components elided, ` +
|
|
824
|
-
`${r.inertRouteModules.size} route modules inert, ${r.importOnlyRouteModules.size} import-only, ` +
|
|
825
|
-
`${r.shippedRouteModules.size} ship whole. Run \`webjs elision\` for the per-module verdict.`
|
|
826
|
-
: `[webjs] elision: disabled (WEBJS_ELIDE / webjs.elide), every module ships.`,
|
|
827
|
-
);
|
|
828
953
|
}
|
|
829
954
|
// The analysis stage is the last writer of per-build inputs (the
|
|
830
|
-
//
|
|
955
|
+
// serve fingerprint, the frontiers and the lazy set, the route
|
|
831
956
|
// table), so every epoch-keyed memo is invalidated here once,
|
|
832
957
|
// unconditionally: a request that rendered in the pre-warm window
|
|
833
958
|
// hinted against an empty fingerprint and must not keep serving
|
|
@@ -879,11 +1004,11 @@ export async function createRequestHandler(opts) {
|
|
|
879
1004
|
readyDone = true;
|
|
880
1005
|
if (ranAnalysis) {
|
|
881
1006
|
const ms = (x) => Math.round(x || 0);
|
|
882
|
-
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);
|
|
883
1008
|
logger.info?.(
|
|
884
1009
|
`[webjs] analysis warm in ${total}ms (graph ${ms(t.graph)}, scan ${ms(t.scan)}, ` +
|
|
885
1010
|
`gate ${ms(t.gate)}, actions ${ms(t.actions)}, middleware ${ms(t.middleware)}, ` +
|
|
886
|
-
`
|
|
1011
|
+
`frontiers ${ms(t.frontiers)}, vendor ${ms(t.vendor)})`,
|
|
887
1012
|
);
|
|
888
1013
|
} else if (ranVendor && vendorResolved) {
|
|
889
1014
|
logger.info?.(`[webjs] vendor resolved in ${Math.round(t.vendor || 0)}ms`);
|
|
@@ -914,16 +1039,15 @@ export async function createRequestHandler(opts) {
|
|
|
914
1039
|
vendorResolveInFlight = (async () => {
|
|
915
1040
|
try {
|
|
916
1041
|
const scan = async () => {
|
|
917
|
-
|
|
918
|
-
return reachedBareImports(state.moduleGraph, [...state.browserEntryFiles], appDir, skip);
|
|
1042
|
+
return reachedBareImports(state.moduleGraph, [...state.browserEntryFiles], appDir, state.routeModuleFiles);
|
|
919
1043
|
};
|
|
920
1044
|
const v = await resolveVendorImports(appDir, scan);
|
|
921
1045
|
let { imports, integrity } = v;
|
|
922
1046
|
if (bootVendorPinned) {
|
|
923
1047
|
// resolveVendorImports returns a committed pin VERBATIM (it never runs
|
|
924
|
-
// the scan for a pinned app). Prune it to the
|
|
1048
|
+
// the scan for a pinned app). Prune it to the browser-reachable
|
|
925
1049
|
// specifiers so a pinned app serves the same map an unpinned one does
|
|
926
|
-
// (#197):
|
|
1050
|
+
// (#197): a dep only a page imports is dropped. One scan; the pin
|
|
927
1051
|
// path skipped it. This runs on the first warm AND after every rebuild,
|
|
928
1052
|
// so the pruned map is the single source of truth.
|
|
929
1053
|
const reachable = await scan();
|
|
@@ -981,12 +1105,14 @@ export async function createRequestHandler(opts) {
|
|
|
981
1105
|
* classification of the change that triggered this rebuild (#1398). Absent
|
|
982
1106
|
* (an embedded host calling `rebuild()`, or any caller with no filename to
|
|
983
1107
|
* classify) means a full reload, which is the fail-safe default.
|
|
984
|
-
* @param {{ notify?: boolean }} [rebuildOpts] `notify: false`
|
|
985
|
-
* 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.
|
|
986
1112
|
*/
|
|
987
|
-
async function rebuild(verdict, { notify = true } = {}) {
|
|
1113
|
+
async function rebuild(verdict, { notify = true, changed = [] } = {}) {
|
|
988
1114
|
devStatus?.rebuildStarted();
|
|
989
|
-
rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict, notify)).then(() => {
|
|
1115
|
+
rebuildInFlight = rebuildInFlight.then(() => doRebuild(verdict, notify, changed)).then(() => {
|
|
990
1116
|
// The recorded errors described the build this one replaced.
|
|
991
1117
|
devStatus?.rebuildFinished();
|
|
992
1118
|
devRecorded = new WeakSet();
|
|
@@ -1001,23 +1127,95 @@ export async function createRequestHandler(opts) {
|
|
|
1001
1127
|
return rebuildInFlight;
|
|
1002
1128
|
}
|
|
1003
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
|
+
|
|
1004
1167
|
/**
|
|
1005
1168
|
* @param {import('../dev-classify.js').ReloadVerdict} [verdict]
|
|
1006
1169
|
* @param {boolean} [notify] tell the open tabs (`onReload`). False for the
|
|
1007
1170
|
* re-derive a `bun --hot` re-run asks for (#1575), whose edit the watcher
|
|
1008
1171
|
* already announced.
|
|
1172
|
+
* @param {string[]} [changed] absolute paths of the files that changed
|
|
1009
1173
|
*/
|
|
1010
|
-
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();
|
|
1011
1202
|
// The route table is the only eager artifact (cheap directory scan); rebuild
|
|
1012
1203
|
// it so routing reflects added/removed route files immediately.
|
|
1013
1204
|
state.routeTable = await buildRouteTable(appDir);
|
|
1205
|
+
state.publicFiles = buildPublicIndex(appDir);
|
|
1014
1206
|
// Adding or deleting app/icon.* changes whether the head auto-links it.
|
|
1015
1207
|
setMetadataIconRoutes(state.routeTable.metadataRoutes);
|
|
1016
1208
|
// Refresh the generated route types (#258) so adding/removing a route file
|
|
1017
1209
|
// updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
|
|
1018
1210
|
// best-effort (see emitRouteTypes).
|
|
1019
1211
|
if (dev) void emitRouteTypes();
|
|
1020
|
-
|
|
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();
|
|
1021
1219
|
// A rebuild changes the bare-import set, so a failed resolve's backoff
|
|
1022
1220
|
// window no longer describes anything; the next request may try at once.
|
|
1023
1221
|
vendorBackoff.reset();
|
|
@@ -1033,23 +1231,11 @@ export async function createRequestHandler(opts) {
|
|
|
1033
1231
|
// modules too (#1644); the plain `webjs start` path never gets here.
|
|
1034
1232
|
clearProdImportCache();
|
|
1035
1233
|
state.tsCache.clear();
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
|
|
1039
|
-
// after the reset. A dependency edit can flip an elision verdict without
|
|
1040
|
-
// changing an importer's mtime, hence the state.tsCache.clear above.
|
|
1041
|
-
if (readyInFlight) { try { await readyInFlight; } catch {} }
|
|
1042
|
-
// Bump the vendor generation so a vendor resolve still in flight from the
|
|
1043
|
-
// previous build cannot flip vendorResolved against the fresh state.
|
|
1044
|
-
vendorGen++;
|
|
1045
|
-
analysisDone = false;
|
|
1046
|
-
vendorResolved = false;
|
|
1047
|
-
vendorAttemptedOnce = false;
|
|
1048
|
-
readyDone = false;
|
|
1049
|
-
readyError = null;
|
|
1050
|
-
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();
|
|
1051
1237
|
// Refresh the on-request regenerate rules (#967) so a `webjs.dev.regenerate`
|
|
1052
|
-
// config edit takes effect without a restart, mirroring the
|
|
1238
|
+
// config edit takes effect without a restart, mirroring the router
|
|
1053
1239
|
// re-reads above. Dev-only; a no-op array in prod.
|
|
1054
1240
|
if (dev) state.regenerateRules = await readRegenerateRules(appDir);
|
|
1055
1241
|
// Optimistically clear the dev error (#264): the rebuild itself only
|
|
@@ -1072,6 +1258,116 @@ export async function createRequestHandler(opts) {
|
|
|
1072
1258
|
// runs on both boundary tiers and swaps in the changed boot script, which
|
|
1073
1259
|
// executes and registers the new component.
|
|
1074
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
|
+
}
|
|
1075
1371
|
}
|
|
1076
1372
|
|
|
1077
1373
|
/**
|
|
@@ -1112,6 +1408,9 @@ export async function createRequestHandler(opts) {
|
|
|
1112
1408
|
seq: reload ? reload.seq : null,
|
|
1113
1409
|
rebuilds,
|
|
1114
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 })),
|
|
1115
1414
|
};
|
|
1116
1415
|
return Response.json(body, { headers: { 'cache-control': 'no-store' } });
|
|
1117
1416
|
}
|
|
@@ -1158,6 +1457,20 @@ export async function createRequestHandler(opts) {
|
|
|
1158
1457
|
// request scope, so `readBody` inside a route handler enforces the same
|
|
1159
1458
|
// cap the framework's own RPC / form-dispatch body reads do.
|
|
1160
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
|
+
}
|
|
1161
1474
|
|
|
1162
1475
|
let pathname = '/';
|
|
1163
1476
|
try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
|
|
@@ -1179,19 +1492,35 @@ export async function createRequestHandler(opts) {
|
|
|
1179
1492
|
let res;
|
|
1180
1493
|
try {
|
|
1181
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();
|
|
1182
1501
|
} catch (e) {
|
|
1183
1502
|
// A throw escaping produce() is the last-resort 500 (every interior
|
|
1184
1503
|
// path catches its own errors, but a surprise still must not crash the
|
|
1185
1504
|
// host). Fire the onError sink (best-effort) and emit a sanitized 500,
|
|
1186
1505
|
// preserving the prior behavior plus the new APM hook.
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
+
}
|
|
1195
1524
|
}
|
|
1196
1525
|
|
|
1197
1526
|
// Merge in the secure-by-default headers plus the per-path config
|
|
@@ -1236,6 +1565,13 @@ export async function createRequestHandler(opts) {
|
|
|
1236
1565
|
// is the developer saying "this dev server is framed by these origins".
|
|
1237
1566
|
if (embedOriginsValue.length) allowEmbedFraming(merged.headers, embedOriginsValue);
|
|
1238
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
|
+
|
|
1239
1575
|
// Server HTML cache write (#241): if the SSR marked this response as a
|
|
1240
1576
|
// cache candidate (an opted-in `revalidate` page), store the FINAL body
|
|
1241
1577
|
// now, after segment middleware has added any per-user Set-Cookie (which
|
|
@@ -1379,7 +1715,7 @@ export async function createRequestHandler(opts) {
|
|
|
1379
1715
|
// Default `'ignore'` is a no-op. The root `/` and file paths are
|
|
1380
1716
|
// exempt; `/__webjs/*` is exempt too (defense in depth, the redirects
|
|
1381
1717
|
// above already skip it). The funnel in handle() still wraps this.
|
|
1382
|
-
const canonical = applyTrailingSlash(req, trailingSlashPolicy);
|
|
1718
|
+
const canonical = applyTrailingSlash(req, trailingSlashPolicy, (p) => !!state.publicFiles && state.publicFiles.has(p));
|
|
1383
1719
|
if (canonical) return canonical;
|
|
1384
1720
|
}
|
|
1385
1721
|
|
|
@@ -1472,20 +1808,20 @@ export async function createRequestHandler(opts) {
|
|
|
1472
1808
|
const earlyStatic = await tryServeFrameworkStatic(path, req.method.toUpperCase(), { coreDir, appDir, dev, versioned: url.searchParams.has('v') });
|
|
1473
1809
|
if (earlyStatic) return earlyStatic;
|
|
1474
1810
|
|
|
1475
|
-
//
|
|
1811
|
+
// A `public/` file (served at the url root) needs neither the module graph nor the vendor importmap, so
|
|
1476
1812
|
// in DEV serve it here rather than behind the analysis (#1397). A cold
|
|
1477
|
-
// `/
|
|
1813
|
+
// `/tailwind.css` measured 1907ms at 4a335549, of which 1900ms was
|
|
1478
1814
|
// `ensureReady()`, which is why the stylesheet is the request most exposed
|
|
1479
1815
|
// to the next `node --watch` restart. DEV ONLY on purpose: in prod
|
|
1480
1816
|
// `/__webjs/ready` already holds traffic off a cold instance until the
|
|
1481
1817
|
// analysis and the first vendor attempt have both completed, and hoisting
|
|
1482
|
-
// there would silently un-gate a
|
|
1818
|
+
// there would silently un-gate a public file that an app middleware
|
|
1483
1819
|
// protects. The consequence in dev is that root middleware does not run
|
|
1484
1820
|
// for these paths, the same trade the framework statics above already make
|
|
1485
1821
|
// in both modes. `state.regenerateRules` is loaded at boot inside
|
|
1486
1822
|
// createRequestHandler, so the #967 on-request rebuild is available here,
|
|
1487
1823
|
// ahead of ensureReady.
|
|
1488
|
-
if (dev) {
|
|
1824
|
+
if (dev && req.method === 'GET') {
|
|
1489
1825
|
const publicResp = await tryServePublicAsset(path, {
|
|
1490
1826
|
appDir, dev, versioned: url.searchParams.has('v'), regenerateRules: state.regenerateRules,
|
|
1491
1827
|
});
|
|
@@ -1558,8 +1894,7 @@ export async function createRequestHandler(opts) {
|
|
|
1558
1894
|
// on the first warm request.
|
|
1559
1895
|
const moduleUrls = hintedBootModules(page.route, {
|
|
1560
1896
|
appDir,
|
|
1561
|
-
|
|
1562
|
-
importOnly: state.importOnlyRouteModules,
|
|
1897
|
+
frontiers: state.routeFrontiers,
|
|
1563
1898
|
lazyFiles: state.lazyComponentFiles,
|
|
1564
1899
|
instrumentationClient: state.routeTable.instrumentationClient,
|
|
1565
1900
|
}, basePathValue);
|
|
@@ -1572,7 +1907,7 @@ export async function createRequestHandler(opts) {
|
|
|
1572
1907
|
routeFor,
|
|
1573
1908
|
/**
|
|
1574
1909
|
* Proactively run the first-request analysis (module graph, component
|
|
1575
|
-
* scan, gate, action index, middleware,
|
|
1910
|
+
* scan, gate, action index, middleware, frontiers, vendor map) in the
|
|
1576
1911
|
* background, so a real first request finds it already memoized. Safe to
|
|
1577
1912
|
* call any number of times and concurrently: the work is single-flighted,
|
|
1578
1913
|
* so this never duplicates it or races a real request. It is a single
|
|
@@ -1622,9 +1957,10 @@ export async function createRequestHandler(opts) {
|
|
|
1622
1957
|
* (#1575), the `bun --hot` re-run `startServer` pokes for. A module no
|
|
1623
1958
|
* other app module imports is only ever loaded through `devImport`, whose
|
|
1624
1959
|
* specifier names the file's content, so its edit is a new specifier and
|
|
1625
|
-
* needs nothing reset
|
|
1626
|
-
*
|
|
1627
|
-
*
|
|
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.
|
|
1628
1964
|
*
|
|
1629
1965
|
* @param {string} filename relative to `root`
|
|
1630
1966
|
* @param {string} [root] the watched root, defaulting to the app dir
|
|
@@ -1632,6 +1968,10 @@ export async function createRequestHandler(opts) {
|
|
|
1632
1968
|
*/
|
|
1633
1969
|
needsRegistryReset: (filename, root) => {
|
|
1634
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;
|
|
1635
1975
|
if (!analysisEverDone) return true;
|
|
1636
1976
|
// A new routing file (a page, layout, route handler, boundary) is only
|
|
1637
1977
|
// ever loaded by the router, and nothing can have tried to import it yet.
|
|
@@ -1646,6 +1986,13 @@ export async function createRequestHandler(opts) {
|
|
|
1646
1986
|
analysisReady: analysisEverDone,
|
|
1647
1987
|
sep,
|
|
1648
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),
|
|
1649
1996
|
appDir,
|
|
1650
1997
|
dev,
|
|
1651
1998
|
logger,
|