@webjsdev/server 0.8.63 → 0.8.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/index.d.ts +7 -2
  2. package/package.json +2 -2
  3. package/src/browser-entries.js +64 -0
  4. package/src/check/helpers.js +365 -0
  5. package/src/check/rules-actions.js +174 -0
  6. package/src/check/rules-components.js +275 -0
  7. package/src/check/rules-imports.js +166 -0
  8. package/src/check/rules-registry.js +128 -0
  9. package/src/check/rules-routing.js +224 -0
  10. package/src/check/rules-typescript.js +177 -0
  11. package/src/check/rules.js +127 -0
  12. package/src/check/runner-support.js +405 -0
  13. package/src/check/runner.js +113 -0
  14. package/src/check.js +4 -1774
  15. package/src/component-elision.js +1 -1
  16. package/src/dev/config.js +338 -0
  17. package/src/dev/handler.js +1372 -0
  18. package/src/dev/helpers.js +542 -0
  19. package/src/dev/serve.js +840 -0
  20. package/src/dev/server.js +322 -0
  21. package/src/dev-classify.js +174 -0
  22. package/src/dev-reload-worker.js +133 -7
  23. package/src/dev-styles.js +97 -0
  24. package/src/dev.js +22 -3077
  25. package/src/form-dispatch.js +12 -3
  26. package/src/importmap.js +6 -3
  27. package/src/listener-core.js +16 -2
  28. package/src/module-graph.js +112 -7
  29. package/src/router.js +11 -1
  30. package/src/ssr/client-router-flag.js +42 -0
  31. package/src/ssr/document.js +361 -0
  32. package/src/ssr/env-shim.js +51 -0
  33. package/src/ssr/escape.js +29 -0
  34. package/src/ssr/head.js +857 -0
  35. package/src/ssr/preloads.js +195 -0
  36. package/src/ssr/render.js +1481 -0
  37. package/src/ssr/responses.js +201 -0
  38. package/src/ssr.js +28 -2445
  39. package/src/vendor/audit.js +400 -0
  40. package/src/vendor/integrity.js +245 -0
  41. package/src/vendor/jspm.js +348 -0
  42. package/src/vendor/manifest.js +96 -0
  43. package/src/vendor/pins.js +892 -0
  44. package/src/vendor/providers.js +35 -0
  45. package/src/vendor/resolver.js +225 -0
  46. package/src/vendor/scanner.js +132 -0
  47. package/src/vendor.js +29 -2371
@@ -0,0 +1,840 @@
1
+ /**
2
+ * Serving a request once the dev/prod handler has decided what it is: the
3
+ * framework's own static files, an app source module (with TypeScript stripped
4
+ * and elision applied), and the per-segment middleware chain around a route.
5
+ *
6
+ * Split off handler.js, which was 1460 lines. The seam is real rather than
7
+ * arithmetic: nothing here builds or configures the handler, and the handler
8
+ * half calls into it at exactly three points.
9
+ */
10
+ import { stat, readFile } from 'node:fs/promises';
11
+ import { join, extname, resolve, relative, sep } from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
13
+
14
+ import { matchPage, matchApi } from '../router.js';
15
+ import { ssrPage, ssrNotFound } from '../ssr.js';
16
+ import { runFormAction, reportFormSubmittedAsGet } from '../form-dispatch.js';
17
+ import { handleApi } from '../api.js';
18
+ import {
19
+ serveActionStub,
20
+ serveServerOnlyStub,
21
+ invokeAction,
22
+ isServerFile,
23
+ hasUseServerDirective,
24
+ hashFile,
25
+ } from '../actions.js';
26
+ import { maybeRegenerate } from '../dev-regenerate.js';
27
+ import { stripTypeScript } from '../ts-strip.js';
28
+ import { serveDownloadedBundle } from '../vendor.js';
29
+ import { resolveImport } from '../module-graph.js';
30
+ import { elideImportsFromSource } from '../component-elision.js';
31
+ import { basePath } from '../importmap.js';
32
+ import { withBasePath } from '../base-path.js';
33
+ import { versionModuleImports } from '../asset-hash.js';
34
+ import { BUFFERED_MARKER } from '../conditional-get.js';
35
+ import { MIME, TS_CACHE_MAX, exists, reloadClientJs, reloadWorkerJs } from './helpers.js';
36
+
37
+ /**
38
+ * Serve framework-internal static assets that depend on NEITHER the whole-app
39
+ * analysis NOR the vendor importmap: the `@webjsdev/core` runtime files, the
40
+ * dev reload client, and (in `--download` pin mode) the committed vendor
41
+ * bundles. `handle()` calls this BEFORE `ensureReady()`, so a cold instance
42
+ * returns them immediately instead of blocking on the first vendor resolve
43
+ * (issue #190). The core bundle is on every page's boot path, so coupling it
44
+ * to the jspm resolve stalled first interactivity site-wide on a cold instance.
45
+ *
46
+ * Like the health / readiness probes (also answered pre-`ensureReady`), these
47
+ * bypass app middleware. That is correct: they are framework infrastructure the
48
+ * app needs to function, not app routes, and `state.middleware` is not even
49
+ * loaded until `ensureReady()` completes.
50
+ *
51
+ * @param {string} path decoded pathname
52
+ * @param {string} method upper-cased HTTP method
53
+ * @param {{ coreDir: string, appDir: string, dev: boolean, versioned?: boolean }} ctx
54
+ * `versioned` is true when the request carried a `?v=` query (a
55
+ * content-hash-fingerprinted url, issue #243); the core module is then served
56
+ * `immutable` (1 year) instead of the 1h fallback, since the hash in the url
57
+ * busts the cache on the next deploy.
58
+ * @returns {Promise<Response|null>} a Response, or null when path is not one of these assets
59
+ */
60
+ export async function tryServeFrameworkStatic(path, method, { coreDir, appDir, dev, versioned }) {
61
+ // Core module: /__webjs/core/*
62
+ //
63
+ // ETag + ~1h max-age, NOT immutable. The URL path is un-versioned
64
+ // (`/__webjs/core/src/render-client.js` etc.), so bumping `@webjsdev/core`
65
+ // ships different bytes at the same URL. An `immutable` cache-control
66
+ // directive at an edge CDN (Cloudflare, Vercel, Fly) keeps the prior bytes
67
+ // pinned for up to a year, which silently bricks the next deploy: browsers
68
+ // load the old client renderer against a server emitting the new SSR shape,
69
+ // and any exports added in the bump (e.g., the slot.js entry points landed
70
+ // for 0.6.0) resolve to undefined in the cached file.
71
+ // Regression: 2026-05-20, ui.webjs.dev tier-2 components after
72
+ // @webjsdev/core 0.5.0 -> 0.6.0 republish.
73
+ if (path.startsWith('/__webjs/core/')) {
74
+ const rel = path.slice('/__webjs/core/'.length);
75
+ const abs = resolve(coreDir, rel);
76
+ // Trailing-separator boundary check, not a raw string prefix: a raw
77
+ // `startsWith(coreDir)` would admit a sibling like `@webjsdev/core-evil`,
78
+ // reachable via an encoded slash (`..%2f`, which survives URL normalization
79
+ // and then decodes to `../`). Match the public-root branch's guard.
80
+ if (abs !== coreDir && !abs.startsWith(coreDir + sep)) {
81
+ return new Response('forbidden', { status: 403 });
82
+ }
83
+ // A `?v=<hash>` request is content-addressed, so serve it `immutable` (#243):
84
+ // the hash busts the cache on the next core bump, so the regression the
85
+ // un-versioned 1h cap guards against (a stale immutable core after a bump)
86
+ // cannot recur. An un-fingerprinted request keeps the 1h fallback.
87
+ return fileResponse(abs, { dev, immutable: !!versioned });
88
+ }
89
+
90
+ // Vendor URL handler for `webjs vendor pin --download` mode only. In default
91
+ // pin mode (or no-pin mode) the importmap routes bare imports straight to
92
+ // ga.jspm.io URLs and the browser bypasses this server entirely. When the
93
+ // user ran `webjs vendor pin --download`, the importmap has local
94
+ // `/__webjs/vendor/<file>.js` URLs and this serves the committed bundle files
95
+ // from `.webjs/vendor/`. These are read-only static content: allow GET/HEAD
96
+ // for the normal fetch, OPTIONS for any cross-origin preflight (204 with the
97
+ // same Allow header rather than 405, which some intermediaries treat as a
98
+ // hard failure even for a CORS probe), and 405 everything else.
99
+ if (path.startsWith('/__webjs/vendor/') && path.endsWith('.js')) {
100
+ if (method === 'OPTIONS') {
101
+ return new Response(null, { status: 204, headers: { allow: 'GET, HEAD, OPTIONS' } });
102
+ }
103
+ if (method !== 'GET' && method !== 'HEAD') {
104
+ return new Response(null, { status: 405, headers: { allow: 'GET, HEAD, OPTIONS' } });
105
+ }
106
+ const filename = path.slice('/__webjs/vendor/'.length);
107
+ const resp = await serveDownloadedBundle(filename, appDir, dev);
108
+ if (method === 'HEAD') {
109
+ // HEAD must return same headers as GET with no body.
110
+ return new Response(null, { status: resp.status, headers: resp.headers });
111
+ }
112
+ return resp;
113
+ }
114
+
115
+ // Dev live-reload client, and its SharedWorker (one shared connection for all
116
+ // tabs, #887). The `!dev` arm answers 404 rather than falling through, so the
117
+ // path is dead in production instead of being routed like an app url.
118
+ //
119
+ // These live HERE, in the shared helper, rather than inline at the early
120
+ // `handle()` call site, for the same reason `tryServePublicAsset` was
121
+ // extracted in #1397: both callers then run ONE implementation and the two
122
+ // cannot drift. It also keeps the `handleCore` fallback below honest, whose
123
+ // comment promises these assets still serve if a future caller ever bypasses
124
+ // the early path. `basePath()` is the value `setBasePath()` recorded at
125
+ // handler construction, so it is the same base path the inline copy read.
126
+ if (path === '/__webjs/reload.js' || path === '/__webjs/reload-worker.js') {
127
+ if (!dev) return new Response('Not found', { status: 404 });
128
+ const src = path === '/__webjs/reload.js'
129
+ ? reloadClientJs(basePath())
130
+ : reloadWorkerJs(basePath());
131
+ return new Response(src, {
132
+ headers: { 'content-type': 'application/javascript; charset=utf-8' },
133
+ });
134
+ }
135
+
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Serve `/public/*`, plus a small set of ROOT assets that must serve at the
141
+ * site root even though they live under public/. A service worker registered at
142
+ * /sw.js scopes to the origin root, so it MUST serve at / (not /public/sw.js),
143
+ * and so must its offline fallback. Same remap shape as the /favicon.ico
144
+ * special-case. (#830)
145
+ *
146
+ * Extracted (#1397) so the dev pre-`ensureReady()` call site and the
147
+ * `handleCore` one share ONE implementation, and the containment guard below
148
+ * cannot drift between two copies.
149
+ *
150
+ * @param {string} path decoded pathname
151
+ * @param {{ appDir: string, dev: boolean, versioned?: boolean, regenerateRules: any[] }} ctx
152
+ * @returns {Promise<Response|null>} a Response, or null when the path is not a
153
+ * public asset OR the file does not exist, in both of which cases the caller
154
+ * must fall through to the rest of the pipeline (a missing `/public/x.png`
155
+ * 404s through normal routing today, and that must not change). A containment
156
+ * rejection returns a 404 Response and short-circuits.
157
+ */
158
+ export async function tryServePublicAsset(path, ctx) {
159
+ const { appDir, dev, versioned, regenerateRules } = ctx;
160
+ const ROOT_ASSETS = { '/sw.js': '/public/sw.js', '/offline.html': '/public/offline.html' };
161
+ if (!(path.startsWith('/public/') || path === '/favicon.ico' || path in ROOT_ASSETS)) return null;
162
+ const p = path === '/favicon.ico' ? '/public/favicon.ico' : (ROOT_ASSETS[path] || path);
163
+ const abs = join(appDir, p);
164
+ // Containment check. `join` normalises `..` segments, so a path
165
+ // like `/public/..%2Fsecret/x.svg` decodes to `/public/../secret/
166
+ // x.svg` and `join(appDir, ...)` resolves it to `appDir/secret/
167
+ // x.svg`. The resulting `abs` could be inside `appDir` but OUTSIDE
168
+ // `appDir/public/`, exposing files the user reasonably thought were
169
+ // private under their non-public directories. Reject anything
170
+ // that doesn't stay under `appDir/public/` (and the favicon
171
+ // exception, which is already validated above).
172
+ // The live vector encodes the SLASH, not the dots: the WHATWG URL
173
+ // parser decodes `%2E%2E` and normalises the dot segment away, so a
174
+ // `/public/%2E%2E/x` request arrives here as plain `/x` and never
175
+ // enters this branch. `..%2F` survives parsing intact and does.
176
+ const publicRoot = join(appDir, 'public') + sep;
177
+ if (!abs.startsWith(publicRoot)) {
178
+ return new Response(null, { status: 404 });
179
+ }
180
+ // On-request regeneration (#967): in dev, if a `webjs.dev.regenerate` rule
181
+ // matches this output and it is stale (a source is newer, or it is missing),
182
+ // rebuild it to completion BEFORE serving, so a newly added utility class is
183
+ // never served stale. No-op when no rule matches or the output is fresh, and
184
+ // never runs in prod (rules are empty there). This replaces the fragile
185
+ // `tailwindcss --watch` that could die mid-session and serve stale CSS.
186
+ if (dev && regenerateRules.length) {
187
+ await maybeRegenerate(appDir, p.replace(/^\/+/, ''), regenerateRules);
188
+ }
189
+ // A `?v=<hash>` public asset is content-addressed -> immutable (#243).
190
+ if (await exists(abs)) {
191
+ const res = await fileResponse(abs, { dev, immutable: versioned });
192
+ // A worker served below its registration path only controls that subtree
193
+ // unless the response opts it up to the root scope. (#830)
194
+ if (path === '/sw.js') res.headers.set('Service-Worker-Allowed', '/');
195
+ return res;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ export async function handleCore(req, ctx) {
201
+ const { state, appDir, coreDir, dev, reportError, reportDevError, hasOnError, logger, cspEnabled, allowedOrigins } = ctx;
202
+ const url = new URL(req.url);
203
+ // Decode percent-encoded characters so filesystem lookups match real
204
+ // filenames. Dynamic route segments like `[slug]` and route groups like
205
+ // `(marketing)` contain chars that browsers percent-encode in URLs
206
+ // (`%5B`, `%5D`, `%28`, `%29`). Without decoding, the server joins the
207
+ // encoded path with the app directory → file not found → 404 → no JS
208
+ // loads → no interactivity.
209
+ let path;
210
+ try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
211
+ const method = req.method.toUpperCase();
212
+ // Content-hash fingerprint (#243): a `?v=` query marks a content-addressed
213
+ // asset url that may be served `immutable` (the hash busts the cache on a
214
+ // byte change). The pathname (query stripped by `url.pathname`) resolves the
215
+ // file as today; only the cache header changes when `?v` is present.
216
+ const versioned = url.searchParams.has('v');
217
+
218
+ // Health / readiness probes (`/__webjs/health`, `/__webjs/ready`) and the
219
+ // framework-internal static assets (`/__webjs/core/*`, `/__webjs/reload.js`,
220
+ // `/__webjs/reload-worker.js`, downloaded `/__webjs/vendor/*`) are served in
221
+ // `handle()` BEFORE ensureReady,
222
+ // so they are not repeated here. This fallback covers the (currently
223
+ // unreachable) case of handleCore being entered for one of those assets, so
224
+ // the routing stays correct if a future caller bypasses the early path.
225
+ const frameworkStatic = await tryServeFrameworkStatic(path, method, { coreDir, appDir, dev, versioned });
226
+ if (frameworkStatic) return frameworkStatic;
227
+
228
+ // Internal server-action RPC endpoint
229
+ const actMatch = /^\/__webjs\/action\/([a-f0-9]+)\/([A-Za-z0-9_$]+)$/.exec(path);
230
+ if (actMatch) {
231
+ // HTTP-verb actions (#488): any RPC verb may hit the endpoint; invokeAction
232
+ // enforces the action's DECLARED method (405 + Allow on a mismatch) and
233
+ // reads args from the URL (GET/DELETE) or the body (POST/PUT/PATCH).
234
+ if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
235
+ return new Response('method not allowed', { status: 405 });
236
+ }
237
+ // Pass the onError sink (issue #239): a server action that throws
238
+ // unexpectedly is reported to the APM hook before the sanitized 500.
239
+ const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
240
+ return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError, allowedOrigins);
241
+ }
242
+
243
+ // Static: /public/*, the #830 root remaps, and /favicon.ico. In dev this
244
+ // already ran before ensureReady() (#1397); this stays the LIVE path in prod
245
+ // and the fallback in dev, and both call sites share one implementation.
246
+ const publicResp = await tryServePublicAsset(path, { appDir, dev, versioned, regenerateRules: state.regenerateRules });
247
+ if (publicResp) return publicResp;
248
+
249
+ // User source modules (served as ES modules, with action-file rewriting).
250
+ //
251
+ // Authorization gate: only files reachable from a browser-bound entry
252
+ // (page, layout, error, loading, not-found, component) via the module
253
+ // graph are servable. Same posture as Next.js, where the bundler's
254
+ // manifest is the source of truth for what the browser may fetch.
255
+ // Anything not in the set (node_modules/, top-level package.json,
256
+ // scripts/, etc.) 404s here regardless of whether the file exists on
257
+ // disk. The `.server.{js,ts}` stub guardrail runs below as a
258
+ // defense-in-depth layer.
259
+ if (method === 'GET' && /\.(js|mjs|ts|mts|css|svg|png|jpg|jpeg|gif|webp|json|ico|txt)$/.test(path)) {
260
+ let abs = join(appDir, path);
261
+ // When the browser asks for `.js`, allow falling through to a sibling
262
+ // `.ts` (the TypeScript-with-"allowImportingTsExtensions: false" pattern).
263
+ if (!(await exists(abs)) && /\.js$/.test(abs)) {
264
+ const tsAbs = abs.replace(/\.js$/, '.ts');
265
+ if (await exists(tsAbs)) abs = tsAbs;
266
+ else {
267
+ const mtsAbs = abs.replace(/\.js$/, '.mts');
268
+ if (await exists(mtsAbs)) abs = mtsAbs;
269
+ }
270
+ }
271
+ // Gate: must be in the browser-bound module graph. Server-action
272
+ // files (.server.{js,ts}) get a stub via the guardrail below; they
273
+ // ARE included in browserBoundFiles because client code imports
274
+ // them by path (the import rewrites to an RPC stub at request time).
275
+ // In test mode any app file is servable (see the `state.testMode` note
276
+ // above); otherwise the file must be in the browser-bound module graph.
277
+ const inGraph = state.testMode || (state.browserBoundFiles && state.browserBoundFiles.has(abs));
278
+ // Containment: `abs` must be appDir itself or genuinely UNDER it. The
279
+ // trailing `sep` (matching the public-asset branch) stops a `..` path that
280
+ // resolves to a sibling sharing the appDir name-prefix (`/x/app` ->
281
+ // `/x/app-secrets/...`) from passing in test mode, where graph membership
282
+ // is not the gate.
283
+ const underAppDir = abs === appDir || abs.startsWith(appDir + sep);
284
+ if (underAppDir && inGraph && (await exists(abs))) {
285
+ // Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
286
+ // MUST NEVER be served as source to the browser. The extension is
287
+ // the path-level boundary; we re-verify it on every request (not
288
+ // just rely on the action-index snapshot, which is built on the first
289
+ // request and refreshed on rebuild) so files created later, FS races,
290
+ // or developer error never punch through.
291
+ //
292
+ // What the browser gets depends on the file's `'use server'` status:
293
+ // - With `'use server'` => server action: a generated RPC stub
294
+ // whose exports POST to /__webjs/action/:hash/:fn.
295
+ // - Without `'use server'` => server-only utility: a stub that
296
+ // throws at module load with a clear error. The file's source
297
+ // never reaches the browser either way.
298
+ if (isServerFile(abs)) {
299
+ if (await hasUseServerDirective(abs)) {
300
+ // Lazily ensure the index knows about this file so serveActionStub
301
+ // can mint a stable hash and function list.
302
+ if (!state.actionIndex.fileToHash.has(abs)) {
303
+ const h = await hashFile(abs);
304
+ state.actionIndex.fileToHash.set(abs, h);
305
+ state.actionIndex.hashToFile.set(h, abs);
306
+ }
307
+ const stub = await serveActionStub(state.actionIndex, abs);
308
+ return new Response(stub, {
309
+ headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
310
+ });
311
+ }
312
+ const relPath = relative(appDir, abs);
313
+ const stub = serveServerOnlyStub(relPath);
314
+ return new Response(stub, {
315
+ headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
316
+ });
317
+ }
318
+ // TypeScript source: strip types via Node 24+'s built-in, cache by mtime.
319
+ // Both module paths also strip side-effect imports of display-only
320
+ // components so the browser never downloads their JS.
321
+ const elideOpts = {
322
+ moduleGraph: state.moduleGraph,
323
+ elidableComponents: state.elidableComponents,
324
+ appDir,
325
+ };
326
+ // A `?v=<hash>` app-module request is content-addressed -> immutable
327
+ // (#243); an un-fingerprinted request keeps the 1h fallback.
328
+ if (/\.m?ts$/.test(abs)) {
329
+ return tsResponse(abs, dev, elideOpts, state.tsCache, versioned, reportDevError);
330
+ }
331
+ if (/\.m?js$/.test(abs)) {
332
+ return jsModuleResponse(abs, dev, elideOpts, versioned);
333
+ }
334
+ return fileResponse(abs, { dev, immutable: versioned });
335
+ }
336
+ // Dev hint (#751): the request is for a real app source module that EXISTS
337
+ // on disk but is NOT in the browser-bound graph, so the gate 404s it. The
338
+ // most common cause is a dynamic `import()` the static scanner cannot
339
+ // track: a string-literal `import('./x.ts')` IS tracked and servable, but a
340
+ // computed `import(expr)` / `import('./' + name)` cannot be resolved
341
+ // statically and falls through here. Surface the likely cause instead of a
342
+ // bare 404 so the author is not left guessing. Dev-only and diagnostic; it
343
+ // does not change the 404 status, only the body + a server log line.
344
+ if (dev && abs.startsWith(appDir) && /\.m?[jt]s$/.test(abs) && (await exists(abs))) {
345
+ const rel = relative(appDir, abs);
346
+ const hint = `[webjs] 404: ${rel} exists but is not reachable from any browser-bound entry, so it is not servable. If you load it via a dynamic import(), use a STRING-LITERAL specifier (e.g. import('./x.ts')) so the scanner can track it; a computed import(expr) cannot be resolved statically and will 404. Otherwise this module is simply unreferenced by client code.`;
347
+ console.warn(hint);
348
+ return new Response(hint, { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' } });
349
+ }
350
+ }
351
+
352
+ // Metadata routes: /sitemap.xml, /robots.txt, /icon, /opengraph-image, etc.
353
+ if (method === 'GET' && state.routeTable.metadataRoutes) {
354
+ const meta = state.routeTable.metadataRoutes.find((r) => r.urlPath === path);
355
+ if (meta) {
356
+ try {
357
+ const mod = await import(pathToFileURL(meta.file).toString() + (dev ? `?t=${Date.now()}` : ''));
358
+ if (mod.default) {
359
+ const result = await mod.default();
360
+ // If the function returns a Response, use it directly.
361
+ if (result instanceof Response) return result;
362
+ // If it returns a string, determine content type from the URL path.
363
+ const ct = path.endsWith('.xml') ? 'application/xml; charset=utf-8'
364
+ : path.endsWith('.txt') ? 'text/plain; charset=utf-8'
365
+ : path.endsWith('.json') ? 'application/json; charset=utf-8'
366
+ : 'application/octet-stream';
367
+ return new Response(typeof result === 'string' ? result : JSON.stringify(result), {
368
+ headers: { 'content-type': ct, 'cache-control': dev ? 'no-cache' : 'public, max-age=3600' },
369
+ });
370
+ }
371
+ } catch (e) {
372
+ if (reportError) reportError(e, req, 'metadata');
373
+ if (dev) console.error(`[webjs] metadata route error (${meta.stem}):`, e);
374
+ return new Response('Internal error', { status: 500 });
375
+ }
376
+ }
377
+ }
378
+
379
+ // API route (route.js handler)
380
+ const api = matchApi(state.routeTable, path);
381
+ if (api) {
382
+ const handler = () => handleApi(api.route, api.params, req, dev);
383
+ return runWithSegmentMiddleware(req, api.route.middlewares, handler, dev);
384
+ }
385
+
386
+ // Page route. GET/HEAD render the page. A NON-GET/HEAD method (POST/PUT/…)
387
+ // is a form submission (#1155): the `__webjs_action` hidden field names the
388
+ // server action to run, it runs inside the page's segment middleware, and the
389
+ // result either PRG-redirects (303), re-renders the same page (422) with
390
+ // field errors, or honors a thrown redirect()/notFound(). A non-GET carrying
391
+ // no action identity is a 405 on a path that exists but only renders.
392
+ {
393
+ const page = matchPage(state.routeTable, path);
394
+ if (page) {
395
+ // The URL this render is FOR, in the form the browser sees it (#1047), so
396
+ // the overlay's scope gate can compare it against `location.pathname +
397
+ // location.search`. Two details are load-bearing: the RAW `url.pathname`
398
+ // (not the decoded `path`) is used, because `location.pathname` is
399
+ // percent-encoded too; and the base path is put back, because the ingress
400
+ // strip already removed it from `url` while the browser's location still
401
+ // carries it, so without this the gate would suppress every overlay on a
402
+ // sub-path deploy.
403
+ const devErrorUrl = withBasePath(url.pathname, basePath()) + url.search;
404
+ // A speculative link prefetch must never raise an overlay (#1047): the
405
+ // user is only hovering a link to a page that throws, and the page they
406
+ // are actually looking at is fine. Dropping the hook also keeps the throw
407
+ // out of `state.lastDevError`, so the SSE replay cannot hand it to a
408
+ // freshly-connected tab either. The APM `onError` sink below still fires,
409
+ // because the render really did throw.
410
+ const isPrefetch = req.headers.get('x-webjs-prefetch') === '1';
411
+ const ssrOpts = {
412
+ dev, appDir, moduleGraph: state.moduleGraph,
413
+ serverFiles: state.actionIndex.fileToHash,
414
+ elidableComponents: state.elidableComponents,
415
+ inertRouteModules: state.inertRouteModules,
416
+ importOnlyRouteModules: state.importOnlyRouteModules,
417
+ notFoundFile: state.routeTable.notFound,
418
+ // Root-only boundaries (#848): the app-wide catch-all error page and the
419
+ // unmatched-anywhere 404, rendered by ssr.js when a nested boundary is
420
+ // absent.
421
+ globalError: state.routeTable.globalError,
422
+ globalNotFound: state.routeTable.globalNotFound,
423
+ // instrumentation-client.{js,ts} (#848): imported first in the client
424
+ // boot so it runs before app modules.
425
+ instrumentationClient: state.routeTable.instrumentationClient,
426
+ // Server HTML cache (#241): a CSP-enabled page emits a fresh
427
+ // per-request nonce into its body, so its bytes vary per request and
428
+ // it must never be HTML-cached. Pass the flag so the cache guard skips
429
+ // it. CSP is off by default, so the common case stays cacheable.
430
+ cspEnabled,
431
+ // onError sink (issue #239): a page render error that becomes a 500 is
432
+ // reported to the APM hook with the active request's correlation id.
433
+ onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
434
+ // Dev error overlay (#264): a render crash pushes a frame to the open
435
+ // tab. Dev-only (reportDevError early-returns in prod), so no source
436
+ // leaks. Distinct from onError (the APM sink), which always fires.
437
+ onDevError: dev && !isPrefetch
438
+ ? (e) => reportDevError(e, { kind: 'render', url: devErrorUrl })
439
+ : undefined,
440
+ };
441
+ if (method === 'GET' || method === 'HEAD') {
442
+ // #1307: `__webjs_action` in the QUERY STRING means a submission
443
+ // carrying a bound action's identity went out as a GET, so the action
444
+ // never ran. Nothing else in the framework puts that reserved field in
445
+ // a url. Detect only, so the render below is unchanged: answering a GET
446
+ // differently because of a query parameter would hand any visitor a way
447
+ // to turn any page into an error.
448
+ reportFormSubmittedAsGet(
449
+ url, req,
450
+ // Gated on hasOnError, not on `reportError`: that is always a
451
+ // function here and no-ops internally, so passing it would spend a
452
+ // dedupe slot on a report nobody receives.
453
+ hasOnError ? (e) => reportError(e, req, 'action') : undefined,
454
+ logger, dev, page.route,
455
+ );
456
+ // A successful render of URL U supersedes a RETAINED render error for
457
+ // that same URL (#1047), so a reconnecting tab is not handed a frame the
458
+ // page has since recovered from. Keyed on BOTH frame identity and url:
459
+ // identity so a frame this very render just reported is never wiped, url
460
+ // so a good render of an unrelated page cannot erase an error that is
461
+ // still current for the page the user is looking at (the #893 gap the
462
+ // retention exists to close). Dev-only, and skipped for a prefetch,
463
+ // which is not the user's view of anything.
464
+ const handler = dev && !isPrefetch
465
+ ? async () => {
466
+ const before = state.lastDevError;
467
+ const resp = await ssrPage(page.route, page.params, url, { ...ssrOpts, req });
468
+ if (
469
+ before && state.lastDevError === before
470
+ && before.kind === 'render' && before.url === devErrorUrl
471
+ ) state.lastDevError = null;
472
+ return resp;
473
+ }
474
+ : () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
475
+ return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
476
+ }
477
+ // Every non-GET/HEAD to a page runs the page's segment middleware, and
478
+ // the dispatcher always answers with a Response (a 405 when there is no
479
+ // action to run), so a middleware that post-processes `await next()`
480
+ // never has to handle an absent one.
481
+ const deps = {
482
+ actionIndex: state.actionIndex,
483
+ allowedOrigins,
484
+ onError: hasOnError ? (e) => reportError(e, req, 'action') : undefined,
485
+ logger,
486
+ };
487
+ const handler = () => runFormAction(page.route, page.params, url, req, ssrOpts, deps);
488
+ return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
489
+ }
490
+ }
491
+
492
+ // Fallback: content-negotiated 404
493
+ if (wantsJson(req, path)) {
494
+ return Response.json({ error: 'Not found', path }, { status: 404 });
495
+ }
496
+ // Unmatched anywhere: prefer the root not-found.{js,ts}, then a
497
+ // global-not-found.{js,ts} (#848), else the default 404 page.
498
+ // The APM / overlay sinks ride along here too: a root not-found that throws
499
+ // or fails to load must reach them, not only the console (#1298). Built
500
+ // inline rather than reused, because the ssrOpts above is scoped to the
501
+ // matched-page branch, which this path did not take.
502
+ //
503
+ // No `route` is passed on purpose: nothing matched, so there is no layout
504
+ // chain to render the boundary inside, and the response stays a bare
505
+ // document with no boot script.
506
+ // Same supersede rule the matched-page branch follows (#1047 / #893): a
507
+ // LATER successful render of this same url clears the frame it retained, so
508
+ // an intermittently-failing not-found (a query behind it, not a source edit,
509
+ // which the rebuild already clears) cannot leave a frame that paints over a
510
+ // page that has since recovered. Scoped to this url, so a good render here
511
+ // never erases an error still current for a page the user is looking at.
512
+ const devErrorUrl = withBasePath(url.pathname, basePath()) + url.search;
513
+ // Gated on !isPrefetch as well as dev, exactly as the matched-page branch
514
+ // gates its whole supersede closure. The clear below infers "this render
515
+ // succeeded" from "the retained frame is still the one I captured", and that
516
+ // inference is only valid when a sink was installed to write a new one. A
517
+ // prefetch installs none, so a prefetch of a url that throws AGAIN would
518
+ // look like a recovery and wipe a frame that is still current, which is the
519
+ // #893 gap the retention exists to close.
520
+ const isPrefetch = req.headers.get('x-webjs-prefetch') === '1';
521
+ const before = dev && !isPrefetch ? state.lastDevError : null;
522
+ const resp = await ssrNotFound(state.routeTable.notFound || state.routeTable.globalNotFound, {
523
+ dev,
524
+ appDir,
525
+ req,
526
+ url,
527
+ onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
528
+ // The frame's url must be stamped exactly as the matched-page branch
529
+ // stamps it, or the overlay's scope gate refuses it: the browser compares
530
+ // against `location.pathname + location.search`, so dropping the query
531
+ // suppresses the overlay on any unrouted url that carries one, and
532
+ // dropping the base path suppresses it on EVERY url of a sub-path deploy
533
+ // (the ingress strip already removed the prefix from `url`, while the
534
+ // browser's location still has it). A refused frame goes to the pending
535
+ // slot and is then discarded, so the overlay simply never paints.
536
+ //
537
+ // Dropped for a speculative prefetch, on the same #1047 rule the
538
+ // matched-page branch follows: hovering a link to a broken page must not
539
+ // raise an overlay on the page you are actually on, nor become
540
+ // `state.lastDevError` for the SSE to replay into a fresh tab. Prefetch is
541
+ // on by default and fetches unrouted hrefs too, so this is reachable.
542
+ onDevError: dev && !isPrefetch
543
+ ? (e) => reportDevError(e, { kind: 'render', url: devErrorUrl })
544
+ : undefined,
545
+ });
546
+ if (
547
+ before && state.lastDevError === before
548
+ && before.kind === 'render' && before.url === devErrorUrl
549
+ ) state.lastDevError = null;
550
+ return resp;
551
+ }
552
+
553
+ /** @param {Request} req @param {string} path */
554
+ export function wantsJson(req, path) {
555
+ const accept = req.headers.get('accept') || '';
556
+ if (accept.includes('application/json') && !accept.includes('text/html')) return true;
557
+ if (path.startsWith('/api/') || path.startsWith('/__webjs/')) return true;
558
+ return false;
559
+ }
560
+
561
+ /**
562
+ * Chain segment-level middleware.js (outermost first) around a handler.
563
+ * Each middleware is `(req, next) => Response`. If any throws, log and 500.
564
+ *
565
+ * @param {Request} req
566
+ * @param {string[]} files absolute paths of middleware.js files, outermost → innermost
567
+ * @param {() => Promise<Response>} terminal
568
+ * @param {boolean} dev
569
+ */
570
+ export async function runWithSegmentMiddleware(req, files, terminal, dev) {
571
+ if (!files || !files.length) return terminal();
572
+ const handlers = [];
573
+ for (const f of files) {
574
+ try {
575
+ const url = pathToFileURL(f).toString();
576
+ const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
577
+ const mod = await import(url + bust);
578
+ if (typeof mod.default === 'function') handlers.push(mod.default);
579
+ } catch {
580
+ // Bad middleware file: skip; top-level error handler will catch real problems.
581
+ }
582
+ }
583
+ let i = 0;
584
+ const next = () => {
585
+ if (i >= handlers.length) return terminal();
586
+ const fn = handlers[i++];
587
+ return fn(req, next);
588
+ };
589
+ return next();
590
+ }
591
+
592
+ /**
593
+ * Root-middleware filename candidates, in resolution order.
594
+ *
595
+ * Every other routing convention accepts all four extensions (the router
596
+ * matches on the STEM, so `app/<segment>/middleware.ts` has always worked),
597
+ * and both the scaffold and the dev supervisor write / watch `middleware.ts`.
598
+ * This lookup used to be the single literal `middleware.js`, so a root
599
+ * `middleware.ts` was silently never loaded: no error, no warning, the app
600
+ * just ran with no global middleware. TypeScript is the documented default
601
+ * for an app, so that was the common case, and it went unnoticed because the
602
+ * failure is invisible (a missing middleware looks exactly like an app that
603
+ * has none). `.ts` is tried first to match the dev supervisor's order.
604
+ */
605
+ const ROOT_MIDDLEWARE_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
606
+
607
+ /**
608
+ * Load the optional top-level `middleware.{ts,js,mts,mjs}`.
609
+ * @param {string} appDir
610
+ * @param {boolean} dev
611
+ * @param {import('../logger.js').Logger} logger
612
+ */
613
+ export async function loadMiddleware(appDir, dev, logger) {
614
+ let file = null;
615
+ for (const name of ROOT_MIDDLEWARE_FILES) {
616
+ const candidate = join(appDir, name);
617
+ if (await exists(candidate)) { file = candidate; break; }
618
+ }
619
+ if (!file) return null;
620
+ const url = pathToFileURL(file).toString();
621
+ const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
622
+ try {
623
+ const mod = await import(url + bust);
624
+ return typeof mod.default === 'function' ? mod.default : null;
625
+ } catch (e) {
626
+ logger.error('failed to load root middleware', { file, err: String(e) });
627
+ return null;
628
+ }
629
+ }
630
+
631
+ /**
632
+ * Read a file and return a Response with appropriate caching.
633
+ * Dev: no-cache (always revalidate).
634
+ * Prod: ETag + ~1h max-age for user files; `immutable` bumps to 1 year.
635
+ *
636
+ * @param {string} abs
637
+ * @param {{ dev: boolean, immutable: boolean }} opts
638
+ */
639
+ export async function fileResponse(abs, opts) {
640
+ try {
641
+ let data = await readFile(abs);
642
+ // In dev an external watcher (tailwindcss --watch, esbuild, ...) rewrites a
643
+ // public asset with truncate-then-write, so the file is 0 bytes for a short
644
+ // window during a rebuild. A hot reload that lands in that window would
645
+ // serve empty CSS / JS and the page paints unstyled (#891). Close that
646
+ // 0-byte window: when an empty read comes from a file that was JUST modified
647
+ // (the mid-rewrite signal), re-read a few times so the truncated read does
648
+ // not reach the browser. A genuinely empty, untouched asset is served
649
+ // immediately (no delay), and a non-zero-but-partial write is a smaller
650
+ // residual gap this does not cover. Prod has no such watcher and is
651
+ // left untouched.
652
+ if (opts.dev && data.length === 0) {
653
+ let midRewrite = false;
654
+ try { midRewrite = Date.now() - (await stat(abs)).mtimeMs < 500; } catch { /* gone */ }
655
+ for (let i = 0; midRewrite && i < 12 && data.length === 0; i++) {
656
+ await new Promise((r) => setTimeout(r, 20));
657
+ try { data = await readFile(abs); } catch { break; }
658
+ }
659
+ }
660
+ const type = MIME[extname(abs).toLowerCase()] || 'application/octet-stream';
661
+ // The body is fully buffered (read into `data`), so opt it into the
662
+ // conditional-GET funnel, which is the single place that hashes the bytes
663
+ // into a weak ETag and honors If-None-Match -> 304 (dev + prod alike).
664
+ const headers = { 'content-type': type, [BUFFERED_MARKER]: '1' };
665
+ headers['cache-control'] = opts.dev
666
+ ? 'no-cache'
667
+ : opts.immutable
668
+ ? 'public, max-age=31536000, immutable'
669
+ : 'public, max-age=3600';
670
+ return new Response(data, { status: 200, headers });
671
+ } catch {
672
+ return new Response('Not found', { status: 404 });
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Serve a plain `.js` / `.mjs` browser module, stripping side-effect
678
+ * imports of display-only components. Mirrors {@link fileResponse}'s
679
+ * headers but reads as text so the source can be transformed. Used only
680
+ * for files that exist as `.js` on disk (TS apps usually hit
681
+ * {@link tsResponse} via the .js to .ts sibling rewrite instead).
682
+ *
683
+ * @param {string} abs
684
+ * @param {boolean} dev
685
+ * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} elideOpts
686
+ * @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
687
+ * serve `immutable` (1 year) instead of the 1h fallback. Dev stays `no-cache`.
688
+ */
689
+ export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
690
+ let source;
691
+ try { source = await readFile(abs, 'utf8'); }
692
+ catch { return new Response('Not found', { status: 404 }); }
693
+ let code = elideImportsFromSource(
694
+ source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
695
+ );
696
+ // Version same-origin relative import specifiers so the URL the browser
697
+ // fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
698
+ // A no-op in dev (fingerprinting disabled).
699
+ code = versionModuleImports(code, abs);
700
+ // Buffered (string) body, so opt into the conditional-GET funnel for the
701
+ // weak ETag + 304 (see fileResponse).
702
+ const headers = {
703
+ 'content-type': 'application/javascript; charset=utf-8',
704
+ 'cache-control': dev ? 'no-cache' : immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600',
705
+ [BUFFERED_MARKER]: '1',
706
+ };
707
+ return new Response(code, { status: 200, headers });
708
+ }
709
+
710
+ /**
711
+ * Strip TypeScript types from `source` via Node's built-in
712
+ * `module.stripTypeScriptTypes`. Position-preserving whitespace
713
+ * replacement: no sourcemap is needed because every (line, column)
714
+ * maps to itself in the source.
715
+ *
716
+ * Only erasable TypeScript is supported. Non-erasable syntax
717
+ * (`enum`, `namespace` with values, parameter properties, legacy
718
+ * decorators with `emitDecoratorMetadata`, `import = require`)
719
+ * throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` from Node and the
720
+ * dev server returns the error to the caller. The
721
+ * `erasable-typescript-only` and `no-non-erasable-typescript` lint
722
+ * rules catch these at edit time. There is no bundler fallback;
723
+ * WebJs is buildless end-to-end.
724
+ *
725
+ * The backend is the runtime-appropriate stripper (#508): Node 24+'s built-in
726
+ * `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, equally
727
+ * position-preserving), resolved once via `./ts-strip.js`.
728
+ *
729
+ * @param {string} source
730
+ * @param {string} _abs (unused; preserved for symmetry with prior signature)
731
+ * @returns {Promise<string>}
732
+ */
733
+ export async function stripTs(source, _abs) {
734
+ return stripTypeScript(source);
735
+ }
736
+
737
+ /**
738
+ * Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
739
+ * Result is cached by mtime in the handler's own `cache` so subsequent
740
+ * requests are instant; a file edit invalidates naturally. `elideOpts`
741
+ * additionally strips side-effect imports of display-only components from
742
+ * the served code, which is exactly why `cache` is the per-handler
743
+ * `state.tsCache` and not a module-global: the cached bytes bake in this
744
+ * handler's elision verdict.
745
+ *
746
+ * @param {string} abs
747
+ * @param {boolean} dev
748
+ * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} [elideOpts]
749
+ * @param {Map<string, { mtimeMs: number, code: string, map: string | null }>} cache the handler's `state.tsCache`
750
+ * @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
751
+ * serve `immutable` (1 year) instead of the 1h fallback. The cached BODY is
752
+ * the same bytes regardless; only the cache header varies. Dev stays `no-cache`.
753
+ */
754
+ export async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDevError) {
755
+ // The body bytes are identical with or without `?v`; only the cache header
756
+ // changes, so the per-mtime cache stays a single entry.
757
+ const cacheControl = dev ? 'no-cache' : immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
758
+ const st = await stat(abs);
759
+ const cached = cache.get(abs);
760
+ if (cached && cached.mtimeMs === st.mtimeMs) {
761
+ return new Response(cached.code, {
762
+ headers: {
763
+ 'content-type': 'application/javascript; charset=utf-8',
764
+ 'cache-control': cacheControl,
765
+ [BUFFERED_MARKER]: '1',
766
+ },
767
+ });
768
+ }
769
+ const source = await readFile(abs, 'utf8');
770
+ let code;
771
+ try {
772
+ code = await stripTs(source, abs);
773
+ } catch (err) {
774
+ // Node's stripTypeScriptTypes throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
775
+ // for enum, namespace with values, parameter properties, legacy
776
+ // decorators with emitDecoratorMetadata, and import = require.
777
+ // Return a clean 500 with the file path and a pointer at the
778
+ // erasable-typescript-only lint rule rather than letting the
779
+ // error bubble up unstyled.
780
+ if (err && err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
781
+ // Log full detail server-side regardless of mode so operators
782
+ // see what went wrong in their logs.
783
+ // eslint-disable-next-line no-console
784
+ console.error(`[webjs] non-erasable TypeScript in ${abs}: ${err.message}`);
785
+ // Dev error overlay (#264): a TS strip failure breaks only the CLIENT
786
+ // module fetch (the page still SSRs, so hydration is silently dead and
787
+ // the hint below is buried in a JS comment). Push a frame so the open tab
788
+ // shows the overlay with the offending file + the no-non-erasable hint.
789
+ reportDevError?.(err, {
790
+ kind: 'ts-strip',
791
+ file: abs,
792
+ hint: 'webjs is buildless: only erasable TypeScript is supported. Replace enum / namespace-with-values / parameter-property / legacy-decorator / import = require with their erasable equivalents. Run `webjs check` (no-non-erasable-typescript rule).',
793
+ });
794
+ const msg = dev
795
+ // Dev: include the file path and Node's error message so the
796
+ // developer's browser tooling can point them at the offending
797
+ // construct. Replace `*` + `/` with `*\\/` so a path or
798
+ // message containing the comment-close sequence cannot
799
+ // terminate the wrapper comment early.
800
+ ? `[webjs] non-erasable TypeScript in ${abs}: ${err.message}\n\n` +
801
+ `webjs is buildless: only erasable TS syntax is supported. ` +
802
+ `Replace enum / namespace / parameter-property / legacy-decorator / ` +
803
+ `import = require constructs with their erasable equivalents. ` +
804
+ `Run \`webjs check\` for guidance (no-non-erasable-typescript rule). ` +
805
+ `Docs: https://webjs.dev/docs/typescript`
806
+ // Prod: terse, no path leak, no Node-message leak (Node's
807
+ // message can include source snippets). Operators get the
808
+ // detail in server logs above.
809
+ : `[webjs] server error transforming a .ts response. Check server logs.`;
810
+ return new Response(`/* ${msg.replace(/\*\//g, '*\\/')} */`, {
811
+ status: 500,
812
+ headers: { 'content-type': 'application/javascript; charset=utf-8' },
813
+ });
814
+ }
815
+ throw err;
816
+ }
817
+ if (elideOpts) {
818
+ code = elideImportsFromSource(
819
+ code, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
820
+ );
821
+ }
822
+ // Version same-origin relative import specifiers so the URL the browser
823
+ // fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
824
+ // A no-op in dev (fingerprinting disabled). Cached with the elision result:
825
+ // PROD files are static within a deploy, so the baked `?v` stays correct.
826
+ code = versionModuleImports(code, abs);
827
+ // Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
828
+ if (cache.size >= TS_CACHE_MAX) {
829
+ const oldest = cache.keys().next().value;
830
+ cache.delete(oldest);
831
+ }
832
+ cache.set(abs, { mtimeMs: st.mtimeMs, code, map: null });
833
+ return new Response(code, {
834
+ headers: {
835
+ 'content-type': 'application/javascript; charset=utf-8',
836
+ 'cache-control': cacheControl,
837
+ [BUFFERED_MARKER]: '1',
838
+ },
839
+ });
840
+ }