@webjsdev/server 0.8.62 → 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.
- package/index.d.ts +32 -3
- package/package.json +2 -2
- package/src/browser-entries.js +64 -0
- package/src/check/helpers.js +365 -0
- package/src/check/rules-actions.js +174 -0
- package/src/check/rules-components.js +275 -0
- package/src/check/rules-imports.js +166 -0
- package/src/check/rules-registry.js +128 -0
- package/src/check/rules-routing.js +224 -0
- package/src/check/rules-typescript.js +177 -0
- package/src/check/rules.js +127 -0
- package/src/check/runner-support.js +405 -0
- package/src/check/runner.js +113 -0
- package/src/check.js +4 -1774
- package/src/component-elision.js +1 -1
- package/src/dev/config.js +338 -0
- package/src/dev/handler.js +1372 -0
- package/src/dev/helpers.js +542 -0
- package/src/dev/serve.js +840 -0
- package/src/dev/server.js +322 -0
- package/src/dev-classify.js +174 -0
- package/src/dev-reload-worker.js +133 -7
- package/src/dev-styles.js +97 -0
- package/src/dev.js +22 -3077
- package/src/form-dispatch.js +12 -3
- package/src/importmap.js +6 -3
- package/src/listener-core.js +16 -2
- package/src/module-graph.js +112 -7
- package/src/rate-limit.js +50 -5
- package/src/router.js +11 -1
- package/src/ssr/client-router-flag.js +42 -0
- package/src/ssr/document.js +361 -0
- package/src/ssr/env-shim.js +51 -0
- package/src/ssr/escape.js +29 -0
- package/src/ssr/head.js +857 -0
- package/src/ssr/preloads.js +195 -0
- package/src/ssr/render.js +1481 -0
- package/src/ssr/responses.js +201 -0
- package/src/ssr.js +28 -2445
- package/src/vendor/audit.js +400 -0
- package/src/vendor/integrity.js +245 -0
- package/src/vendor/jspm.js +348 -0
- package/src/vendor/manifest.js +96 -0
- package/src/vendor/pins.js +892 -0
- package/src/vendor/providers.js +35 -0
- package/src/vendor/resolver.js +225 -0
- package/src/vendor/scanner.js +132 -0
- package/src/vendor.js +29 -2371
package/src/dev/serve.js
ADDED
|
@@ -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
|
+
}
|