@webjsdev/server 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/index.js +21 -3
- package/package.json +1 -3
- package/src/actions.js +25 -9
- package/src/cache.js +19 -2
- package/src/check.js +227 -96
- package/src/component-elision.js +851 -0
- package/src/component-scanner.js +44 -7
- package/src/context.js +36 -0
- package/src/crypto-utils.js +65 -0
- package/src/dev.js +660 -134
- package/src/importmap.js +283 -20
- package/src/js-scan.js +288 -0
- package/src/module-graph.js +194 -20
- package/src/rate-limit.js +100 -12
- package/src/script-tag-json.js +63 -0
- package/src/session.js +60 -14
- package/src/ssr.js +133 -17
- package/src/vendor.js +1261 -103
- package/src/websocket.js +3 -1
package/src/dev.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer as createHttp1Server } from 'node:http';
|
|
2
|
-
import { stat, readFile } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
2
|
+
import { stat, readFile, watch as fsWatch } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { digestHex } from './crypto-utils.js';
|
|
4
5
|
import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
|
|
5
6
|
import { join, extname, resolve, dirname, relative, sep } from 'node:path';
|
|
6
7
|
import { createRequire, stripTypeScriptTypes } from 'node:module';
|
|
@@ -19,7 +20,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
19
20
|
// equivalent built-in, we will need to install `amaro` directly (or
|
|
20
21
|
// an equivalent: Sucrase preserves lines but not columns; SWC's
|
|
21
22
|
// strip-only also works). The fast-path `stripTs` helper would
|
|
22
|
-
// change one import line
|
|
23
|
+
// change one import line.
|
|
23
24
|
//
|
|
24
25
|
// Suppress the one-shot ExperimentalWarning that Node prints the
|
|
25
26
|
// first time `stripTypeScriptTypes` is called. The API is committed
|
|
@@ -57,15 +58,16 @@ import {
|
|
|
57
58
|
import { defaultLogger } from './logger.js';
|
|
58
59
|
import { withRequest } from './context.js';
|
|
59
60
|
import { attachWebSocket } from './websocket.js';
|
|
60
|
-
import { scanBareImports,
|
|
61
|
-
import { buildModuleGraph, transitiveDeps } from './module-graph.js';
|
|
62
|
-
import { primeComponentRegistry, findOrphanComponents } from './component-scanner.js';
|
|
61
|
+
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache } from './vendor.js';
|
|
62
|
+
import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport } from './module-graph.js';
|
|
63
|
+
import { primeComponentRegistry, findOrphanComponents, scanComponents } from './component-scanner.js';
|
|
64
|
+
import { analyzeElision, elideImportsFromSource } from './component-elision.js';
|
|
63
65
|
|
|
64
66
|
/** PascalCase → kebab-case for a helpful diagnostic example tag name. */
|
|
65
67
|
function kebab(name) {
|
|
66
68
|
return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
67
69
|
}
|
|
68
|
-
import { setVendorEntries } from './importmap.js';
|
|
70
|
+
import { setVendorEntries, setCoreInstall } from './importmap.js';
|
|
69
71
|
import { urlFromRequest } from './forwarded.js';
|
|
70
72
|
|
|
71
73
|
const MIME = {
|
|
@@ -92,23 +94,79 @@ const MIME = {
|
|
|
92
94
|
* Capped at 500 entries to prevent unbounded memory growth in
|
|
93
95
|
* long-running production servers.
|
|
94
96
|
*
|
|
95
|
-
*
|
|
97
|
+
* Stripper: `module.stripTypeScriptTypes` (Node 24+ built-in).
|
|
96
98
|
* Position-preserving whitespace replacement. No sourcemap is
|
|
97
99
|
* emitted because every (line, column) maps to itself in the source.
|
|
98
100
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* `webjs check`.
|
|
101
|
+
* Only erasable TypeScript is supported. Non-erasable syntax (`enum`,
|
|
102
|
+
* `namespace` with values, parameter properties, legacy decorators
|
|
103
|
+
* with `emitDecoratorMetadata`, `import = require`) throws at strip
|
|
104
|
+
* time. The `erasable-typescript-only` and `no-non-erasable-typescript`
|
|
105
|
+
* lint rules catch these at edit time. webjs is buildless end-to-end:
|
|
106
|
+
* there is no bundler fallback.
|
|
106
107
|
*
|
|
107
108
|
* @type {Map<string, { mtimeMs: number, code: string, map: string | null }>}
|
|
108
109
|
*/
|
|
109
110
|
const TS_CACHE_MAX = 500;
|
|
110
111
|
const TS_CACHE = new Map();
|
|
111
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Auto-load `<appDir>/.env` into `process.env` once at boot. Mirrors
|
|
115
|
+
* what Rails / Next / Astro do out of the box: a scaffolded app with
|
|
116
|
+
* a committed `.env.example` and a developer-copied `.env` should
|
|
117
|
+
* "just work" without the user having to add a dotenv import or set
|
|
118
|
+
* the file path on the CLI.
|
|
119
|
+
*
|
|
120
|
+
* Uses Node 24+'s built-in `process.loadEnvFile`, which is dotenv-
|
|
121
|
+
* compatible and DOES NOT override pre-existing `process.env` values.
|
|
122
|
+
* Calls that hit a missing file or parse error are silenced; the
|
|
123
|
+
* server should still come up cleanly when there's no `.env`.
|
|
124
|
+
*
|
|
125
|
+
* Idempotent: re-running is a no-op for any env var the user already
|
|
126
|
+
* exported (e.g. via the host shell or a process manager). That
|
|
127
|
+
* keeps the "shell-set wins over file" precedence Rails users
|
|
128
|
+
* expect.
|
|
129
|
+
*
|
|
130
|
+
* Must run before any server-only module is loaded by
|
|
131
|
+
* buildActionIndex, since module-init code in `lib/*.server.ts`
|
|
132
|
+
* (e.g. `createAuth({ secret: process.env.AUTH_SECRET })`) reads
|
|
133
|
+
* process.env at import time. createRequestHandler is the
|
|
134
|
+
* single entry point where this is guaranteed.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} appDir
|
|
137
|
+
*/
|
|
138
|
+
function loadAppEnv(appDir) {
|
|
139
|
+
try {
|
|
140
|
+
if (typeof process.loadEnvFile === 'function') {
|
|
141
|
+
process.loadEnvFile(join(appDir, '.env'));
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// No .env file, malformed file, or Node version without
|
|
145
|
+
// loadEnvFile. Either way, fall through silently: the user
|
|
146
|
+
// may not need any env vars, or they may set them via shell.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read the project-level elision switch from `package.json`.
|
|
152
|
+
* `{ "webjs": { "elide": false } }` disables display-only and inert-route
|
|
153
|
+
* elision app-wide (everything ships, like before the feature existed).
|
|
154
|
+
* Any other value, or an absent key, leaves elision enabled (the default).
|
|
155
|
+
* Re-read on every rebuild so toggling the switch takes effect without a
|
|
156
|
+
* server restart.
|
|
157
|
+
* @param {string} appDir
|
|
158
|
+
* @returns {Promise<boolean>}
|
|
159
|
+
*/
|
|
160
|
+
async function readElideEnabled(appDir) {
|
|
161
|
+
try {
|
|
162
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
163
|
+
if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
|
|
164
|
+
} catch {
|
|
165
|
+
// No package.json, malformed JSON, or unreadable. Keep the default.
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
112
170
|
/**
|
|
113
171
|
* Create a reusable, framework-agnostic request handler for a webjs app.
|
|
114
172
|
* The returned `handle(req)` takes a standard `Request` and resolves to a
|
|
@@ -124,72 +182,301 @@ const TS_CACHE = new Map();
|
|
|
124
182
|
*/
|
|
125
183
|
export async function createRequestHandler(opts) {
|
|
126
184
|
const appDir = resolve(opts.appDir);
|
|
185
|
+
// Load <appDir>/.env into process.env BEFORE anything else.
|
|
186
|
+
// buildActionIndex below imports server-only files (lib/*.server.ts,
|
|
187
|
+
// modules/**/*.server.ts), some of which read process.env at module
|
|
188
|
+
// init (e.g. createAuth reads AUTH_SECRET). Without this call,
|
|
189
|
+
// scaffolded apps with a committed .env.example + .env would fail
|
|
190
|
+
// to boot until the user discovered the missing env-load. See
|
|
191
|
+
// tracker #37.
|
|
192
|
+
loadAppEnv(appDir);
|
|
127
193
|
const dev = !!opts.dev;
|
|
128
194
|
const logger = opts.logger || defaultLogger({ dev });
|
|
129
195
|
const coreDir = locateCoreDir(appDir);
|
|
196
|
+
// Switch the importmap between dist/ bundles and src/ per-file
|
|
197
|
+
// URLs depending on whether the resolved @webjsdev/core install
|
|
198
|
+
// has built bundles on disk. npm-installed copies always do;
|
|
199
|
+
// workspace dev does only after `npm run build:dist`. Without
|
|
200
|
+
// a built dist the server falls back to the historical per-file
|
|
201
|
+
// src/ URLs so dev iteration does not require a build step.
|
|
202
|
+
//
|
|
203
|
+
// Both required bundles must exist. An older @webjsdev/core
|
|
204
|
+
// install built BEFORE the browser-entry split (#119/#128) has
|
|
205
|
+
// `webjs-core.js` but no `webjs-core-browser.js`. Enabling dist
|
|
206
|
+
// mode in that case would route the bare `@webjsdev/core`
|
|
207
|
+
// specifier at a 404 on every page. Require both so a partial
|
|
208
|
+
// dist transparently degrades to src/ mode instead.
|
|
209
|
+
const distDir = join(coreDir, 'dist');
|
|
210
|
+
const distComplete =
|
|
211
|
+
existsSync(join(distDir, 'webjs-core.js')) &&
|
|
212
|
+
existsSync(join(distDir, 'webjs-core-browser.js'));
|
|
213
|
+
await setCoreInstall(coreDir, distComplete);
|
|
214
|
+
|
|
215
|
+
// Whole-app analysis (module graph, component scan, browser-bound gate,
|
|
216
|
+
// action index, middleware, elision, vendor) is NOT run at boot. It is
|
|
217
|
+
// computed on the first request via ensureReady() below and memoized, so the
|
|
218
|
+
// server starts without walking or reading the app's source, executing any
|
|
219
|
+
// server module, or hitting the network. Only the route table is built
|
|
220
|
+
// eagerly: it is a cheap directory scan (no code reads), and routing, Early
|
|
221
|
+
// Hints, and WebSocket lookups need it available before the first request.
|
|
222
|
+
const routeTable = await buildRouteTable(appDir);
|
|
130
223
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
224
|
+
const state = {
|
|
225
|
+
routeTable,
|
|
226
|
+
actionIndex: null,
|
|
227
|
+
middleware: null,
|
|
228
|
+
logger,
|
|
229
|
+
moduleGraph: null,
|
|
230
|
+
elidableComponents: new Set(),
|
|
231
|
+
inertRouteModules: new Set(),
|
|
232
|
+
browserBoundFiles: null,
|
|
233
|
+
};
|
|
134
234
|
|
|
135
|
-
//
|
|
136
|
-
|
|
235
|
+
// All whole-app analysis is built lazily on the first request, memoized so
|
|
236
|
+
// boot does none of it. It runs in two stages. The deterministic analysis
|
|
237
|
+
// (module graph, component scan + prime, browser-bound gate, action index,
|
|
238
|
+
// middleware, elision) is network-free and, once built, never re-runs unless
|
|
239
|
+
// a rebuild invalidates it; readiness gates on it. Vendor resolution is a
|
|
240
|
+
// SEPARATE, best-effort stage: a pinned app reads a committed importmap file,
|
|
241
|
+
// an unpinned app auto-fetches from jspm. It does NOT gate readiness, so an
|
|
242
|
+
// offline or partially-unresolvable app still boots. A transient vendor
|
|
243
|
+
// failure is re-attempted on the NEXT ensureReady call (driven by an incoming
|
|
244
|
+
// request, a readiness probe, or the warm-up), with no background timer: the
|
|
245
|
+
// platform's traffic and probes are the retry loop. `readyError` holds a
|
|
246
|
+
// propagating analysis failure so /__webjs/ready can report it.
|
|
247
|
+
let analysisDone = false; // deterministic analysis complete (readiness gate)
|
|
248
|
+
let vendorResolved = false; // vendor map fully resolved (or permanently tolerated)
|
|
249
|
+
let vendorAttemptedOnce = false; // the first (blocking) vendor attempt has run
|
|
250
|
+
let vendorGen = 0; // bumped on rebuild; a stale resolve cannot flip vendorResolved
|
|
251
|
+
let readyDone = false; // mirrors analysisDone; the /__webjs/ready gate
|
|
252
|
+
/** @type {unknown} */
|
|
253
|
+
let readyError = null;
|
|
254
|
+
/** @type {Promise<void> | null} */
|
|
255
|
+
let readyInFlight = null;
|
|
256
|
+
async function ensureReady() {
|
|
257
|
+
// Fully warm: analysis done and vendor resolved. Nothing to do.
|
|
258
|
+
if (analysisDone && vendorResolved) return;
|
|
259
|
+
// Analysis warm but a prior vendor attempt failed: re-attempt WITHOUT
|
|
260
|
+
// blocking this request. The single-flight dedupes concurrent attempts;
|
|
261
|
+
// success flips the flag. This is the request/probe-driven retry (no timer).
|
|
262
|
+
if (analysisDone && vendorAttemptedOnce) {
|
|
263
|
+
const gen = vendorGen;
|
|
264
|
+
resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) vendorResolved = true; }).catch(() => {});
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// Otherwise run the (single-flighted) full warm: the analysis, then the
|
|
268
|
+
// first vendor attempt, awaited so the first response carries the import map.
|
|
269
|
+
if (!readyInFlight) {
|
|
270
|
+
readyInFlight = (async () => {
|
|
271
|
+
/** @type {Record<string, number>} */
|
|
272
|
+
const t = {};
|
|
273
|
+
let ranAnalysis = false, ranVendor = false;
|
|
274
|
+
const now = () => performance.now();
|
|
275
|
+
try {
|
|
276
|
+
if (!analysisDone) {
|
|
277
|
+
let m = now();
|
|
278
|
+
state.moduleGraph = await buildModuleGraph(appDir);
|
|
279
|
+
t.graph = now() - m; m = now();
|
|
280
|
+
const components = await scanComponents(appDir);
|
|
281
|
+
await primeComponentRegistry(appDir, components);
|
|
282
|
+
t.scan = now() - m; m = now();
|
|
283
|
+
state.browserBoundFiles = computeBrowserBoundFiles(state.routeTable, state.moduleGraph, components, appDir);
|
|
284
|
+
t.gate = now() - m; m = now();
|
|
285
|
+
state.actionIndex = await buildActionIndex(appDir, dev);
|
|
286
|
+
t.actions = now() - m; m = now();
|
|
287
|
+
state.middleware = await loadMiddleware(appDir, dev, logger);
|
|
288
|
+
t.middleware = now() - m; m = now();
|
|
289
|
+
const r = (await readElideEnabled(appDir))
|
|
290
|
+
? await analyzeElision(components, collectRouteModules(state.routeTable),
|
|
291
|
+
state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
|
|
292
|
+
: { elidableComponents: new Set(), inertRouteModules: new Set() };
|
|
293
|
+
state.elidableComponents = r.elidableComponents;
|
|
294
|
+
state.inertRouteModules = r.inertRouteModules;
|
|
295
|
+
t.elision = now() - m;
|
|
296
|
+
if (dev) {
|
|
297
|
+
for (const { className, file } of await findOrphanComponents(appDir)) {
|
|
298
|
+
logger.warn?.(
|
|
299
|
+
`[webjs] ${className} extends WebComponent but has no customElements.define(...) call in ${file}. ` +
|
|
300
|
+
`Add \`customElements.define('<tag-name>', ${className});\` or <${kebab(className)}> tags won't upgrade.`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
analysisDone = true;
|
|
305
|
+
ranAnalysis = true;
|
|
306
|
+
}
|
|
307
|
+
// Readiness gates on the analysis only; vendor is best-effort below.
|
|
308
|
+
readyDone = true;
|
|
309
|
+
readyError = null;
|
|
310
|
+
if (!vendorResolved) {
|
|
311
|
+
const m = now();
|
|
312
|
+
const gen = vendorGen;
|
|
313
|
+
vendorAttemptedOnce = true;
|
|
314
|
+
const ok = await resolveAndApplyVendor();
|
|
315
|
+
t.vendor = now() - m;
|
|
316
|
+
ranVendor = true;
|
|
317
|
+
// Only memoize success (and only if a rebuild didn't intervene). A
|
|
318
|
+
// transient failure leaves vendorResolved false; the next ensureReady
|
|
319
|
+
// call re-attempts it non-blocking. A permanent unresolvable (jspm
|
|
320
|
+
// 401) reports ok and is tolerated, so it does not loop.
|
|
321
|
+
if (ok && gen === vendorGen) vendorResolved = true;
|
|
322
|
+
}
|
|
323
|
+
if (ranAnalysis) {
|
|
324
|
+
const ms = (x) => Math.round(x || 0);
|
|
325
|
+
const total = ms(t.graph) + ms(t.scan) + ms(t.gate) + ms(t.actions) + ms(t.middleware) + ms(t.elision) + ms(t.vendor);
|
|
326
|
+
logger.info?.(
|
|
327
|
+
`[webjs] analysis warm in ${total}ms (graph ${ms(t.graph)}, scan ${ms(t.scan)}, ` +
|
|
328
|
+
`gate ${ms(t.gate)}, actions ${ms(t.actions)}, middleware ${ms(t.middleware)}, ` +
|
|
329
|
+
`elision ${ms(t.elision)}, vendor ${ms(t.vendor)})`,
|
|
330
|
+
);
|
|
331
|
+
} else if (ranVendor && vendorResolved) {
|
|
332
|
+
logger.info?.(`[webjs] vendor resolved in ${Math.round(t.vendor || 0)}ms`);
|
|
333
|
+
}
|
|
334
|
+
} catch (e) {
|
|
335
|
+
readyError = e;
|
|
336
|
+
throw e;
|
|
337
|
+
} finally {
|
|
338
|
+
readyInFlight = null;
|
|
339
|
+
}
|
|
340
|
+
})();
|
|
341
|
+
}
|
|
342
|
+
await readyInFlight;
|
|
343
|
+
}
|
|
137
344
|
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
345
|
+
// All vendor resolves funnel through one single-flight so two never overlap
|
|
346
|
+
// (resolveVendorImports reports a transient failure via a module-global flag
|
|
347
|
+
// that only one in-flight resolve may safely touch). Never rejects; returns
|
|
348
|
+
// the resolve's ok flag (false on a transient failure, applying whatever
|
|
349
|
+
// partial map resolved so the app is no worse off).
|
|
350
|
+
/** @type {Promise<boolean> | null} */
|
|
351
|
+
let vendorResolveInFlight = null;
|
|
352
|
+
function resolveAndApplyVendor() {
|
|
353
|
+
if (vendorResolveInFlight) return vendorResolveInFlight;
|
|
354
|
+
vendorResolveInFlight = (async () => {
|
|
355
|
+
try {
|
|
356
|
+
const v = await resolveVendorImports(appDir,
|
|
357
|
+
() => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules])));
|
|
358
|
+
await setVendorEntries(v.imports, v.integrity);
|
|
359
|
+
return v.ok;
|
|
360
|
+
} catch (e) {
|
|
361
|
+
logger.error?.(`[webjs] vendor resolve failed (will retry on the next request):`, e);
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
})().finally(() => { vendorResolveInFlight = null; });
|
|
365
|
+
return vendorResolveInFlight;
|
|
366
|
+
}
|
|
142
367
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
368
|
+
// Optional app-level readiness check. A `readiness.{js,ts}` file at the app
|
|
369
|
+
// root may default-export an async function; /__webjs/ready runs it once the
|
|
370
|
+
// analysis is warm, so readiness can reflect LIVE dependency health (a DB
|
|
371
|
+
// ping, a queue connection) that the static analysis cannot see. Returning
|
|
372
|
+
// false or throwing reports the instance not ready (503), so a readinessProbe
|
|
373
|
+
// holds traffic off an instance whose deps are down. Absent file => analysis-
|
|
374
|
+
// warm is the only gate. The module is cached per build (cleared on rebuild);
|
|
375
|
+
// the function itself runs on every probe so it reflects current state.
|
|
376
|
+
let readinessFn; // undefined = unloaded, null = no file, function = loaded
|
|
377
|
+
async function getReadinessCheck() {
|
|
378
|
+
if (readinessFn !== undefined) return readinessFn;
|
|
379
|
+
let file = null;
|
|
380
|
+
for (const name of ['readiness.ts', 'readiness.js', 'readiness.mts', 'readiness.mjs']) {
|
|
381
|
+
const p = join(appDir, name);
|
|
382
|
+
if (await exists(p)) { file = p; break; }
|
|
155
383
|
}
|
|
384
|
+
if (!file) { readinessFn = null; return null; }
|
|
385
|
+
try {
|
|
386
|
+
const url = pathToFileURL(file).toString();
|
|
387
|
+
const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
|
|
388
|
+
const mod = await import(url + bust);
|
|
389
|
+
readinessFn = typeof mod.default === 'function' ? mod.default : null;
|
|
390
|
+
} catch (e) {
|
|
391
|
+
logger.error?.(`[webjs] failed to load readiness.{js,ts}`, { err: String(e) });
|
|
392
|
+
readinessFn = null;
|
|
393
|
+
}
|
|
394
|
+
return readinessFn;
|
|
156
395
|
}
|
|
157
396
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
bareImports,
|
|
164
|
-
moduleGraph,
|
|
165
|
-
};
|
|
397
|
+
// Rebuilds are serialized so a slow rebuild #1 cannot overwrite a fresher
|
|
398
|
+
// rebuild #2's route table when it finally finishes. Without this, two file
|
|
399
|
+
// edits inside one fs.watch debounce window could produce a permanently
|
|
400
|
+
// stale state until the next rebuild.
|
|
401
|
+
let rebuildInFlight = Promise.resolve();
|
|
166
402
|
|
|
167
403
|
async function rebuild() {
|
|
404
|
+
rebuildInFlight = rebuildInFlight.then(() => doRebuild()).catch((e) => {
|
|
405
|
+
logger.error?.(`[webjs] rebuild failed:`, e);
|
|
406
|
+
});
|
|
407
|
+
return rebuildInFlight;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function doRebuild() {
|
|
411
|
+
// The route table is the only eager artifact (cheap directory scan); rebuild
|
|
412
|
+
// it so routing reflects added/removed route files immediately.
|
|
168
413
|
state.routeTable = await buildRouteTable(appDir);
|
|
169
|
-
state.actionIndex = await buildActionIndex(appDir, dev);
|
|
170
|
-
state.middleware = await loadMiddleware(appDir, dev, logger);
|
|
171
|
-
// Re-scan bare imports and module graph on rebuild
|
|
172
414
|
clearVendorCache();
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
415
|
+
TS_CACHE.clear();
|
|
416
|
+
// Invalidate the lazy analysis; the next request rebuilds the graph,
|
|
417
|
+
// component scan, gate, action index, middleware, elision, and vendor map.
|
|
418
|
+
// Wait out any in-flight build first so it cannot commit stale results
|
|
419
|
+
// after the reset. A dependency edit can flip an elision verdict without
|
|
420
|
+
// changing an importer's mtime, hence the TS_CACHE.clear above.
|
|
421
|
+
if (readyInFlight) { try { await readyInFlight; } catch {} }
|
|
422
|
+
// Bump the vendor generation so a vendor resolve still in flight from the
|
|
423
|
+
// previous build cannot flip vendorResolved against the fresh state.
|
|
424
|
+
vendorGen++;
|
|
425
|
+
analysisDone = false;
|
|
426
|
+
vendorResolved = false;
|
|
427
|
+
vendorAttemptedOnce = false;
|
|
428
|
+
readyDone = false;
|
|
429
|
+
readyError = null;
|
|
430
|
+
readinessFn = undefined; // reload readiness.{js,ts} after a rebuild
|
|
187
431
|
opts.onReload?.();
|
|
188
432
|
}
|
|
189
433
|
|
|
190
434
|
/** @param {Request} req */
|
|
191
435
|
function handle(req) {
|
|
192
436
|
return withRequest(req, async () => {
|
|
437
|
+
// Health and readiness probes are answered BEFORE ensureReady so a probe
|
|
438
|
+
// never blocks on the analysis. `/__webjs/health` is liveness (the
|
|
439
|
+
// process is up and accepting connections). `/__webjs/ready` is 503 until
|
|
440
|
+
// the analysis is warm, then 200 unless an optional app readiness check
|
|
441
|
+
// (readiness.{js,ts}) reports a dependency down. So a readinessProbe holds
|
|
442
|
+
// traffic off a not-yet-warm or dependency-unhealthy instance. Probing
|
|
443
|
+
// `/__webjs/ready` also kicks off the warm in the background, so an
|
|
444
|
+
// embedder that never called warmup() still warms. A vendor CDN failure
|
|
445
|
+
// does NOT block readiness (vendor is best-effort, retried on the next request).
|
|
446
|
+
let probePath;
|
|
447
|
+
try { probePath = new URL(req.url).pathname; } catch { probePath = ''; }
|
|
448
|
+
if (probePath === '/__webjs/health') {
|
|
449
|
+
return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
|
|
450
|
+
}
|
|
451
|
+
if (probePath === '/__webjs/ready') {
|
|
452
|
+
const noStore = { 'cache-control': 'no-store' };
|
|
453
|
+
if (!readyDone) {
|
|
454
|
+
ensureReady().catch(() => {}); // drive the warm; never block the probe
|
|
455
|
+
const body = readyError
|
|
456
|
+
? { status: 'error', error: String((readyError && readyError.message) || readyError) }
|
|
457
|
+
: { status: 'pending' };
|
|
458
|
+
return Response.json(body, { status: 503, headers: noStore });
|
|
459
|
+
}
|
|
460
|
+
// Analysis is warm. Consult the optional app readiness check (live
|
|
461
|
+
// dependency health, e.g. a DB ping) if the app provides one.
|
|
462
|
+
const check = await getReadinessCheck();
|
|
463
|
+
if (check) {
|
|
464
|
+
try {
|
|
465
|
+
if ((await check()) === false) {
|
|
466
|
+
return Response.json({ status: 'unready' }, { status: 503, headers: noStore });
|
|
467
|
+
}
|
|
468
|
+
} catch (e) {
|
|
469
|
+
return Response.json(
|
|
470
|
+
{ status: 'unready', error: String((e && e.message) || e) },
|
|
471
|
+
{ status: 503, headers: noStore },
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return Response.json({ status: 'ok' }, { headers: noStore });
|
|
476
|
+
}
|
|
477
|
+
// Build all whole-app analysis on the first request (memoized), before
|
|
478
|
+
// any SSR, module serve, gate check, action dispatch, or middleware runs.
|
|
479
|
+
await ensureReady();
|
|
193
480
|
const next = () => handleCore(req, { state, appDir, coreDir, dev });
|
|
194
481
|
if (state.middleware) {
|
|
195
482
|
try {
|
|
@@ -224,6 +511,21 @@ export async function createRequestHandler(opts) {
|
|
|
224
511
|
handle,
|
|
225
512
|
rebuild,
|
|
226
513
|
routeFor,
|
|
514
|
+
/**
|
|
515
|
+
* Proactively run the first-request analysis (module graph, component
|
|
516
|
+
* scan, gate, action index, middleware, elision, vendor map) in the
|
|
517
|
+
* background, so a real first request finds it already memoized. Safe to
|
|
518
|
+
* call any number of times and concurrently: the work is single-flighted,
|
|
519
|
+
* so this never duplicates it or races a real request. It is a single
|
|
520
|
+
* best-effort kick: errors are caught and logged rather than thrown (a
|
|
521
|
+
* background warm-up must not crash the process), and whatever failed simply
|
|
522
|
+
* re-runs on the next request or readiness probe (the platform's traffic and
|
|
523
|
+
* probes are the retry loop, so there is no internal backoff). `startServer`
|
|
524
|
+
* calls this once the HTTP server is listening; embedders can call it after
|
|
525
|
+
* their own listen.
|
|
526
|
+
* @returns {Promise<void>}
|
|
527
|
+
*/
|
|
528
|
+
warmup: () => ensureReady().catch((e) => logger.error?.(`[webjs] background warm-up failed (will retry on the next request):`, e)),
|
|
227
529
|
/** current route table getter: used by the WebSocket subsystem */
|
|
228
530
|
getRouteTable: () => state.routeTable,
|
|
229
531
|
appDir,
|
|
@@ -267,16 +569,41 @@ export async function startServer(opts) {
|
|
|
267
569
|
},
|
|
268
570
|
});
|
|
269
571
|
|
|
572
|
+
/** @type {AbortController | null} */
|
|
573
|
+
let watcherAbort = null;
|
|
270
574
|
if (dev) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
575
|
+
// Watch the app root recursively via Node's built-in
|
|
576
|
+
// `fs.promises.watch`. Stable on macOS, Windows, and Linux as of
|
|
577
|
+
// Node 24. No external dep needed.
|
|
578
|
+
//
|
|
579
|
+
// fs.watch returns relative paths in event.filename. We apply
|
|
580
|
+
// the same ignore filter chokidar used before: skip
|
|
581
|
+
// node_modules, .git, and prisma's dev artefacts (dev.db,
|
|
582
|
+
// dev.db-journal, migrations/) which the dev server writes
|
|
583
|
+
// during db:migrate and would otherwise loop.
|
|
584
|
+
//
|
|
585
|
+
// The prisma branch uses prefix-only matching (no required
|
|
586
|
+
// trailing separator) so the SQLite sidecar files like
|
|
587
|
+
// `prisma/dev.db` and `prisma/dev.db-journal` are ignored too.
|
|
588
|
+
// node_modules / .git stay separator-anchored so unrelated
|
|
589
|
+
// names like `node_modules.bak/foo` don't get caught.
|
|
590
|
+
const IGNORE = /(?:^|[\\/])(?:node_modules|\.git)(?:[\\/]|$)|(?:^|[\\/])prisma[\\/](?:dev|migrations)/;
|
|
591
|
+
const rebuild = debounce(() => app.rebuild(), 80);
|
|
592
|
+
watcherAbort = new AbortController();
|
|
593
|
+
(async () => {
|
|
594
|
+
try {
|
|
595
|
+
const events = fsWatch(app.appDir, { recursive: true, signal: watcherAbort.signal });
|
|
596
|
+
for await (const event of events) {
|
|
597
|
+
const filename = event.filename || '';
|
|
598
|
+
if (IGNORE.test(filename)) continue;
|
|
599
|
+
rebuild();
|
|
600
|
+
}
|
|
601
|
+
} catch (err) {
|
|
602
|
+
if (err && /** @type any */(err).name !== 'AbortError') {
|
|
603
|
+
logger.warn({ err }, 'file watcher exited');
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
})();
|
|
280
607
|
}
|
|
281
608
|
|
|
282
609
|
// SSE keepalive: send a comment frame every 25s to defeat proxy idle timeouts.
|
|
@@ -343,6 +670,11 @@ export async function startServer(opts) {
|
|
|
343
670
|
|
|
344
671
|
server.listen(port, () => {
|
|
345
672
|
logger.info(`webjs ${dev ? 'dev' : 'prod'} server ready on http://localhost:${port}`);
|
|
673
|
+
// The server is now accepting connections; warm the first-request analysis
|
|
674
|
+
// in the background so a real first request finds it memoized. Fire-and-
|
|
675
|
+
// forget: listening (and thus readiness probes / load-balancer health) does
|
|
676
|
+
// not wait on it, and a failure here does not bring the process down.
|
|
677
|
+
app.warmup();
|
|
346
678
|
});
|
|
347
679
|
|
|
348
680
|
const shutdown = gracefulShutdown(server, sseClients, logger);
|
|
@@ -354,7 +686,13 @@ export async function startServer(opts) {
|
|
|
354
686
|
// corrupted, so log + start an orderly shutdown rather than continuing.
|
|
355
687
|
installProcessHandlers(logger, () => shutdown('uncaughtException'));
|
|
356
688
|
|
|
357
|
-
return {
|
|
689
|
+
return {
|
|
690
|
+
server,
|
|
691
|
+
close: () => new Promise((r) => {
|
|
692
|
+
if (watcherAbort) watcherAbort.abort();
|
|
693
|
+
server.close(() => r());
|
|
694
|
+
}),
|
|
695
|
+
};
|
|
358
696
|
}
|
|
359
697
|
|
|
360
698
|
/**
|
|
@@ -375,10 +713,8 @@ async function handleCore(req, ctx) {
|
|
|
375
713
|
try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
|
|
376
714
|
const method = req.method.toUpperCase();
|
|
377
715
|
|
|
378
|
-
// Health / readiness probes
|
|
379
|
-
|
|
380
|
-
return Response.json({ status: 'ok' }, { headers: { 'cache-control': 'no-store' } });
|
|
381
|
-
}
|
|
716
|
+
// Health / readiness probes (`/__webjs/health`, `/__webjs/ready`) are handled
|
|
717
|
+
// in `handle()` BEFORE ensureReady, so they are not repeated here.
|
|
382
718
|
|
|
383
719
|
// Dev live-reload client
|
|
384
720
|
if (path === '/__webjs/reload.js') {
|
|
@@ -408,12 +744,31 @@ async function handleCore(req, ctx) {
|
|
|
408
744
|
return fileResponse(abs, { dev, immutable: false });
|
|
409
745
|
}
|
|
410
746
|
|
|
411
|
-
// Vendor
|
|
412
|
-
//
|
|
413
|
-
//
|
|
747
|
+
// Vendor URL handler for `webjs vendor pin --download` mode only.
|
|
748
|
+
// In default pin mode (or no-pin mode) the importmap routes bare
|
|
749
|
+
// imports straight to ga.jspm.io URLs and the browser bypasses this
|
|
750
|
+
// server entirely. When the user ran `webjs vendor pin --download`,
|
|
751
|
+
// the importmap has local `/__webjs/vendor/<file>.js` URLs and this
|
|
752
|
+
// handler serves the committed bundle files from `.webjs/vendor/`.
|
|
414
753
|
if (path.startsWith('/__webjs/vendor/') && path.endsWith('.js')) {
|
|
415
|
-
|
|
416
|
-
|
|
754
|
+
// Vendor bundles are read-only static content. Allow GET/HEAD for
|
|
755
|
+
// the normal fetch, OPTIONS for any cross-origin preflight (we
|
|
756
|
+
// return 204 with the same Allow header rather than 405, which
|
|
757
|
+
// some intermediaries treat as a hard failure even for a CORS
|
|
758
|
+
// probe), and 405 everything else.
|
|
759
|
+
if (method === 'OPTIONS') {
|
|
760
|
+
return new Response(null, { status: 204, headers: { allow: 'GET, HEAD, OPTIONS' } });
|
|
761
|
+
}
|
|
762
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
763
|
+
return new Response(null, { status: 405, headers: { allow: 'GET, HEAD, OPTIONS' } });
|
|
764
|
+
}
|
|
765
|
+
const filename = path.slice('/__webjs/vendor/'.length);
|
|
766
|
+
const resp = await serveDownloadedBundle(filename, appDir, dev);
|
|
767
|
+
if (method === 'HEAD') {
|
|
768
|
+
// HEAD must return same headers as GET with no body.
|
|
769
|
+
return new Response(null, { status: resp.status, headers: resp.headers });
|
|
770
|
+
}
|
|
771
|
+
return resp;
|
|
417
772
|
}
|
|
418
773
|
|
|
419
774
|
// Internal server-action RPC endpoint
|
|
@@ -450,10 +805,32 @@ async function handleCore(req, ctx) {
|
|
|
450
805
|
if (path.startsWith('/public/') || path === '/favicon.ico') {
|
|
451
806
|
const p = path === '/favicon.ico' ? '/public/favicon.ico' : path;
|
|
452
807
|
const abs = join(appDir, p);
|
|
808
|
+
// Containment check. `join` normalises `..` segments, so a path
|
|
809
|
+
// like `/public/%2E%2E/secret/x.svg` decodes (after URL parsing,
|
|
810
|
+
// which doesn't touch `%2E`) to `/public/../secret/x.svg` and
|
|
811
|
+
// `join(appDir, ...)` resolves it to `appDir/secret/x.svg`. The
|
|
812
|
+
// resulting `abs` could be inside `appDir` but OUTSIDE `appDir/
|
|
813
|
+
// public/`, exposing files the user reasonably thought were
|
|
814
|
+
// private under their non-public directories. Reject anything
|
|
815
|
+
// that doesn't stay under `appDir/public/` (and the favicon
|
|
816
|
+
// exception, which is already validated above).
|
|
817
|
+
const publicRoot = join(appDir, 'public') + sep;
|
|
818
|
+
if (!abs.startsWith(publicRoot)) {
|
|
819
|
+
return new Response(null, { status: 404 });
|
|
820
|
+
}
|
|
453
821
|
if (await exists(abs)) return fileResponse(abs, { dev, immutable: false });
|
|
454
822
|
}
|
|
455
823
|
|
|
456
|
-
// User source modules (served as ES modules, with action-file rewriting)
|
|
824
|
+
// User source modules (served as ES modules, with action-file rewriting).
|
|
825
|
+
//
|
|
826
|
+
// Authorization gate: only files reachable from a browser-bound entry
|
|
827
|
+
// (page, layout, error, loading, not-found, component) via the module
|
|
828
|
+
// graph are servable. Same posture as Next.js, where the bundler's
|
|
829
|
+
// manifest is the source of truth for what the browser may fetch.
|
|
830
|
+
// Anything not in the set (node_modules/, top-level package.json,
|
|
831
|
+
// scripts/, etc.) 404s here regardless of whether the file exists on
|
|
832
|
+
// disk. The `.server.{js,ts}` stub guardrail runs below as a
|
|
833
|
+
// defense-in-depth layer.
|
|
457
834
|
if (method === 'GET' && /\.(js|mjs|ts|mts|css|svg|png|jpg|jpeg|gif|webp|json|ico|txt)$/.test(path)) {
|
|
458
835
|
let abs = join(appDir, path);
|
|
459
836
|
// When the browser asks for `.js`, allow falling through to a sibling
|
|
@@ -466,12 +843,18 @@ async function handleCore(req, ctx) {
|
|
|
466
843
|
if (await exists(mtsAbs)) abs = mtsAbs;
|
|
467
844
|
}
|
|
468
845
|
}
|
|
469
|
-
|
|
846
|
+
// Gate: must be in the browser-bound module graph. Server-action
|
|
847
|
+
// files (.server.{js,ts}) get a stub via the guardrail below; they
|
|
848
|
+
// ARE included in browserBoundFiles because client code imports
|
|
849
|
+
// them by path (the import rewrites to an RPC stub at request time).
|
|
850
|
+
const inGraph = state.browserBoundFiles && state.browserBoundFiles.has(abs);
|
|
851
|
+
if (abs.startsWith(appDir) && inGraph && (await exists(abs))) {
|
|
470
852
|
// Server-file guardrail: a file matching `.server.{js,ts,mjs,mts}`
|
|
471
853
|
// MUST NEVER be served as source to the browser. The extension is
|
|
472
854
|
// the path-level boundary; we re-verify it on every request (not
|
|
473
|
-
// just the action-index snapshot
|
|
474
|
-
//
|
|
855
|
+
// just rely on the action-index snapshot, which is built on the first
|
|
856
|
+
// request and refreshed on rebuild) so files created later, FS races,
|
|
857
|
+
// or developer error never punch through.
|
|
475
858
|
//
|
|
476
859
|
// What the browser gets depends on the file's `'use server'` status:
|
|
477
860
|
// - With `'use server'` => server action: a generated RPC stub
|
|
@@ -484,7 +867,7 @@ async function handleCore(req, ctx) {
|
|
|
484
867
|
// Lazily ensure the index knows about this file so serveActionStub
|
|
485
868
|
// can mint a stable hash and function list.
|
|
486
869
|
if (!state.actionIndex.fileToHash.has(abs)) {
|
|
487
|
-
const h = hashFile(abs);
|
|
870
|
+
const h = await hashFile(abs);
|
|
488
871
|
state.actionIndex.fileToHash.set(abs, h);
|
|
489
872
|
state.actionIndex.hashToFile.set(h, abs);
|
|
490
873
|
}
|
|
@@ -499,9 +882,19 @@ async function handleCore(req, ctx) {
|
|
|
499
882
|
headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
|
|
500
883
|
});
|
|
501
884
|
}
|
|
502
|
-
// TypeScript source:
|
|
885
|
+
// TypeScript source: strip types via Node 24+'s built-in, cache by mtime.
|
|
886
|
+
// Both module paths also strip side-effect imports of display-only
|
|
887
|
+
// components so the browser never downloads their JS.
|
|
888
|
+
const elideOpts = {
|
|
889
|
+
moduleGraph: state.moduleGraph,
|
|
890
|
+
elidableComponents: state.elidableComponents,
|
|
891
|
+
appDir,
|
|
892
|
+
};
|
|
503
893
|
if (/\.m?ts$/.test(abs)) {
|
|
504
|
-
return tsResponse(abs, dev);
|
|
894
|
+
return tsResponse(abs, dev, elideOpts);
|
|
895
|
+
}
|
|
896
|
+
if (/\.m?js$/.test(abs)) {
|
|
897
|
+
return jsModuleResponse(abs, dev, elideOpts);
|
|
505
898
|
}
|
|
506
899
|
return fileResponse(abs, { dev, immutable: false });
|
|
507
900
|
}
|
|
@@ -547,6 +940,8 @@ async function handleCore(req, ctx) {
|
|
|
547
940
|
const handler = () => ssrPage(page.route, page.params, url, {
|
|
548
941
|
dev, appDir, req, moduleGraph: state.moduleGraph,
|
|
549
942
|
serverFiles: state.actionIndex.fileToHash,
|
|
943
|
+
elidableComponents: state.elidableComponents,
|
|
944
|
+
inertRouteModules: state.inertRouteModules,
|
|
550
945
|
});
|
|
551
946
|
return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
|
|
552
947
|
}
|
|
@@ -693,12 +1088,23 @@ function toWebRequest(req, url) {
|
|
|
693
1088
|
/** @type {Record<string,string>} */
|
|
694
1089
|
const headers = {};
|
|
695
1090
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
696
|
-
// Drop HTTP/2 pseudo-headers (`:method`, `:path`, `:scheme`, `:authority`)
|
|
697
|
-
//
|
|
1091
|
+
// Drop HTTP/2 pseudo-headers (`:method`, `:path`, `:scheme`, `:authority`).
|
|
1092
|
+
// They're parsed separately into req.method / req.url and are rejected
|
|
698
1093
|
// by the standard Headers class if we pass them through verbatim.
|
|
699
1094
|
if (k.startsWith(':')) continue;
|
|
1095
|
+
// Strip any inbound `x-webjs-remote-ip` header so clients cannot
|
|
1096
|
+
// spoof the framework-stamped client IP that rate-limit's
|
|
1097
|
+
// `clientIp(req, { trustProxy: false })` reads. We rewrite it
|
|
1098
|
+
// below from the actual TCP socket. Node's IncomingMessage
|
|
1099
|
+
// always lowercases header keys, so a literal compare is enough.
|
|
1100
|
+
if (k === 'x-webjs-remote-ip') continue;
|
|
700
1101
|
headers[k] = Array.isArray(v) ? v.join(',') : String(v ?? '');
|
|
701
1102
|
}
|
|
1103
|
+
// Stamp the framework-trusted remote IP from the socket. Read by
|
|
1104
|
+
// `clientIp(req)` (rate-limit.js) as the bucket key when
|
|
1105
|
+
// `trustProxy: false` (the safe default).
|
|
1106
|
+
const remoteIp = req.socket?.remoteAddress;
|
|
1107
|
+
if (remoteIp) headers['x-webjs-remote-ip'] = remoteIp;
|
|
702
1108
|
let body;
|
|
703
1109
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
704
1110
|
body = new ReadableStream({
|
|
@@ -800,7 +1206,7 @@ async function fileResponse(abs, opts) {
|
|
|
800
1206
|
if (opts.dev) {
|
|
801
1207
|
headers['cache-control'] = 'no-cache';
|
|
802
1208
|
} else {
|
|
803
|
-
const etag = `"${
|
|
1209
|
+
const etag = `"${(await digestHex('SHA-1', data)).slice(0, 16)}"`;
|
|
804
1210
|
headers['etag'] = etag;
|
|
805
1211
|
headers['cache-control'] = opts.immutable
|
|
806
1212
|
? 'public, max-age=31536000, immutable'
|
|
@@ -812,54 +1218,72 @@ async function fileResponse(abs, opts) {
|
|
|
812
1218
|
}
|
|
813
1219
|
}
|
|
814
1220
|
|
|
1221
|
+
/**
|
|
1222
|
+
* Serve a plain `.js` / `.mjs` browser module, stripping side-effect
|
|
1223
|
+
* imports of display-only components. Mirrors {@link fileResponse}'s
|
|
1224
|
+
* headers but reads as text so the source can be transformed. Used only
|
|
1225
|
+
* for files that exist as `.js` on disk (TS apps usually hit
|
|
1226
|
+
* {@link tsResponse} via the .js to .ts sibling rewrite instead).
|
|
1227
|
+
*
|
|
1228
|
+
* @param {string} abs
|
|
1229
|
+
* @param {boolean} dev
|
|
1230
|
+
* @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} elideOpts
|
|
1231
|
+
*/
|
|
1232
|
+
async function jsModuleResponse(abs, dev, elideOpts) {
|
|
1233
|
+
let source;
|
|
1234
|
+
try { source = await readFile(abs, 'utf8'); }
|
|
1235
|
+
catch { return new Response('Not found', { status: 404 }); }
|
|
1236
|
+
const code = elideImportsFromSource(
|
|
1237
|
+
source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
1238
|
+
);
|
|
1239
|
+
const headers = { 'content-type': 'application/javascript; charset=utf-8' };
|
|
1240
|
+
if (dev) {
|
|
1241
|
+
headers['cache-control'] = 'no-cache';
|
|
1242
|
+
} else {
|
|
1243
|
+
headers['etag'] = `"${(await digestHex('SHA-1', code)).slice(0, 16)}"`;
|
|
1244
|
+
headers['cache-control'] = 'public, max-age=3600';
|
|
1245
|
+
}
|
|
1246
|
+
return new Response(code, { status: 200, headers });
|
|
1247
|
+
}
|
|
1248
|
+
|
|
815
1249
|
async function exists(p) {
|
|
816
1250
|
try { await stat(p); return true; } catch { return false; }
|
|
817
1251
|
}
|
|
818
1252
|
|
|
819
1253
|
/**
|
|
820
|
-
* Strip TypeScript types from `source
|
|
821
|
-
* `module.stripTypeScriptTypes
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
* parameter properties, legacy decorators).
|
|
1254
|
+
* Strip TypeScript types from `source` via Node's built-in
|
|
1255
|
+
* `module.stripTypeScriptTypes`. Position-preserving whitespace
|
|
1256
|
+
* replacement: no sourcemap is needed because every (line, column)
|
|
1257
|
+
* maps to itself in the source.
|
|
825
1258
|
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
1259
|
+
* Only erasable TypeScript is supported. Non-erasable syntax
|
|
1260
|
+
* (`enum`, `namespace` with values, parameter properties, legacy
|
|
1261
|
+
* decorators with `emitDecoratorMetadata`, `import = require`)
|
|
1262
|
+
* throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` from Node and the
|
|
1263
|
+
* dev server returns the error to the caller. The
|
|
1264
|
+
* `erasable-typescript-only` and `no-non-erasable-typescript` lint
|
|
1265
|
+
* rules catch these at edit time. There is no bundler fallback;
|
|
1266
|
+
* webjs is buildless end-to-end.
|
|
830
1267
|
*
|
|
831
1268
|
* @param {string} source
|
|
832
|
-
* @param {string}
|
|
1269
|
+
* @param {string} _abs (unused; preserved for symmetry with prior signature)
|
|
833
1270
|
* @returns {Promise<string>}
|
|
834
1271
|
*/
|
|
835
|
-
async function stripTs(source,
|
|
836
|
-
|
|
837
|
-
return stripTypeScriptTypes(source);
|
|
838
|
-
} catch (err) {
|
|
839
|
-
if (err && err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
|
|
840
|
-
const { transform: esbuild } = await loadEsbuild();
|
|
841
|
-
const r = await esbuild(source, {
|
|
842
|
-
loader: 'ts',
|
|
843
|
-
format: 'esm',
|
|
844
|
-
target: 'es2022',
|
|
845
|
-
sourcemap: 'inline',
|
|
846
|
-
sourcefile: abs,
|
|
847
|
-
});
|
|
848
|
-
return r.code;
|
|
849
|
-
}
|
|
850
|
-
throw err;
|
|
851
|
-
}
|
|
1272
|
+
async function stripTs(source, _abs) {
|
|
1273
|
+
return stripTypeScriptTypes(source);
|
|
852
1274
|
}
|
|
853
1275
|
|
|
854
1276
|
/**
|
|
855
1277
|
* Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
|
|
856
1278
|
* Result is cached by mtime so subsequent requests are instant; a
|
|
857
|
-
* file edit invalidates naturally.
|
|
1279
|
+
* file edit invalidates naturally. `elideOpts` additionally strips
|
|
1280
|
+
* side-effect imports of display-only components from the served code.
|
|
858
1281
|
*
|
|
859
1282
|
* @param {string} abs
|
|
860
1283
|
* @param {boolean} dev
|
|
1284
|
+
* @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} [elideOpts]
|
|
861
1285
|
*/
|
|
862
|
-
async function tsResponse(abs, dev) {
|
|
1286
|
+
async function tsResponse(abs, dev, elideOpts) {
|
|
863
1287
|
const st = await stat(abs);
|
|
864
1288
|
const cached = TS_CACHE.get(abs);
|
|
865
1289
|
if (cached && cached.mtimeMs === st.mtimeMs) {
|
|
@@ -871,7 +1295,48 @@ async function tsResponse(abs, dev) {
|
|
|
871
1295
|
});
|
|
872
1296
|
}
|
|
873
1297
|
const source = await readFile(abs, 'utf8');
|
|
874
|
-
|
|
1298
|
+
let code;
|
|
1299
|
+
try {
|
|
1300
|
+
code = await stripTs(source, abs);
|
|
1301
|
+
} catch (err) {
|
|
1302
|
+
// Node's stripTypeScriptTypes throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
|
|
1303
|
+
// for enum, namespace with values, parameter properties, legacy
|
|
1304
|
+
// decorators with emitDecoratorMetadata, and import = require.
|
|
1305
|
+
// Return a clean 500 with the file path and a pointer at the
|
|
1306
|
+
// erasable-typescript-only lint rule rather than letting the
|
|
1307
|
+
// error bubble up unstyled.
|
|
1308
|
+
if (err && err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
|
|
1309
|
+
// Log full detail server-side regardless of mode so operators
|
|
1310
|
+
// see what went wrong in their logs.
|
|
1311
|
+
// eslint-disable-next-line no-console
|
|
1312
|
+
console.error(`[webjs] non-erasable TypeScript in ${abs}: ${err.message}`);
|
|
1313
|
+
const msg = dev
|
|
1314
|
+
// Dev: include the file path and Node's error message so the
|
|
1315
|
+
// developer's browser tooling can point them at the offending
|
|
1316
|
+
// construct. Replace `*` + `/` with `*\\/` so a path or
|
|
1317
|
+
// message containing the comment-close sequence cannot
|
|
1318
|
+
// terminate the wrapper comment early.
|
|
1319
|
+
? `[webjs] non-erasable TypeScript in ${abs}: ${err.message}\n\n` +
|
|
1320
|
+
`webjs is buildless: only erasable TS syntax is supported. ` +
|
|
1321
|
+
`Replace enum / namespace / parameter-property / legacy-decorator / ` +
|
|
1322
|
+
`import = require constructs with their erasable equivalents. ` +
|
|
1323
|
+
`Run \`webjs check\` for guidance (no-non-erasable-typescript rule).`
|
|
1324
|
+
// Prod: terse, no path leak, no Node-message leak (Node's
|
|
1325
|
+
// message can include source snippets). Operators get the
|
|
1326
|
+
// detail in server logs above.
|
|
1327
|
+
: `[webjs] server error transforming a .ts response. Check server logs.`;
|
|
1328
|
+
return new Response(`/* ${msg.replace(/\*\//g, '*\\/')} */`, {
|
|
1329
|
+
status: 500,
|
|
1330
|
+
headers: { 'content-type': 'application/javascript; charset=utf-8' },
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
throw err;
|
|
1334
|
+
}
|
|
1335
|
+
if (elideOpts) {
|
|
1336
|
+
code = elideImportsFromSource(
|
|
1337
|
+
code, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
875
1340
|
// Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
|
|
876
1341
|
if (TS_CACHE.size >= TS_CACHE_MAX) {
|
|
877
1342
|
const oldest = TS_CACHE.keys().next().value;
|
|
@@ -894,6 +1359,81 @@ function debounce(fn, ms) {
|
|
|
894
1359
|
};
|
|
895
1360
|
}
|
|
896
1361
|
|
|
1362
|
+
/**
|
|
1363
|
+
* Walk the route table + component scanner to collect every file the
|
|
1364
|
+
* browser may legitimately fetch as an ES module, then expand via the
|
|
1365
|
+
* module graph into the full transitive closure.
|
|
1366
|
+
*
|
|
1367
|
+
* This is webjs's equivalent of Next.js's bundler-produced page
|
|
1368
|
+
* manifest, derived lazily on the first request (and re-derived on every
|
|
1369
|
+
* rebuild) instead of at compile time. The dev server's source-file branch uses the returned
|
|
1370
|
+
* Set as an authorization gate: in-set → served (subject to the
|
|
1371
|
+
* .server.{js,ts} stub guardrail); out-of-set → 404.
|
|
1372
|
+
*
|
|
1373
|
+
* Browser-bound entries:
|
|
1374
|
+
* - page.{js,ts,mjs,mts} (re-runs on client for hydration)
|
|
1375
|
+
* - layout.{js,ts,mjs,mts} (same)
|
|
1376
|
+
* - error.{js,ts,mjs,mts} (same)
|
|
1377
|
+
* - loading.{js,ts,mjs,mts} (same)
|
|
1378
|
+
* - not-found.{js,ts,mjs,mts} (same)
|
|
1379
|
+
* - component files discovered by the scanner (eager + lazy)
|
|
1380
|
+
*
|
|
1381
|
+
* Server-only entries (NOT in the set):
|
|
1382
|
+
* - route.{js,ts} (API handlers, never fetched as JS module)
|
|
1383
|
+
* - middleware.{js,ts}
|
|
1384
|
+
* - metadata routes (sitemap.js, robots.js, manifest.js, …)
|
|
1385
|
+
* - .server.{js,ts} files (browser gets a stub, not the source)
|
|
1386
|
+
*
|
|
1387
|
+
* Components are passed in (rather than rescanned) so the caller can
|
|
1388
|
+
* share one scan with `primeComponentRegistry`. Saves a full
|
|
1389
|
+
* appDir walk on each analysis (the first request and every rebuild).
|
|
1390
|
+
*
|
|
1391
|
+
* @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
|
|
1392
|
+
* @param {Awaited<ReturnType<typeof buildModuleGraph>>} moduleGraph
|
|
1393
|
+
* @param {Awaited<ReturnType<typeof scanComponents>>} components
|
|
1394
|
+
* @param {string} appDir
|
|
1395
|
+
* @returns {Set<string>}
|
|
1396
|
+
*/
|
|
1397
|
+
/**
|
|
1398
|
+
* Collect every page + layout file across the route table. These are the
|
|
1399
|
+
* modules the client boot script imports, and thus the candidates for
|
|
1400
|
+
* inert-route elision (dropping a module that does no client work).
|
|
1401
|
+
* `route.{js,ts}` / middleware / metadata are excluded: they never ship.
|
|
1402
|
+
*
|
|
1403
|
+
* @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
|
|
1404
|
+
* @returns {string[]}
|
|
1405
|
+
*/
|
|
1406
|
+
function collectRouteModules(routeTable) {
|
|
1407
|
+
/** @type {Set<string>} */
|
|
1408
|
+
const mods = new Set();
|
|
1409
|
+
for (const page of routeTable.pages || []) {
|
|
1410
|
+
if (page.file) mods.add(page.file);
|
|
1411
|
+
for (const f of page.layouts || []) mods.add(f);
|
|
1412
|
+
}
|
|
1413
|
+
return [...mods];
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir) {
|
|
1417
|
+
/** @type {Set<string>} */
|
|
1418
|
+
const entries = new Set();
|
|
1419
|
+
for (const page of routeTable.pages) {
|
|
1420
|
+
if (page.file) entries.add(page.file);
|
|
1421
|
+
for (const f of page.layouts || []) entries.add(f);
|
|
1422
|
+
for (const f of page.errors || []) entries.add(f);
|
|
1423
|
+
for (const f of page.loadings || []) entries.add(f);
|
|
1424
|
+
}
|
|
1425
|
+
if (routeTable.notFound) entries.add(routeTable.notFound);
|
|
1426
|
+
if (routeTable.notFounds) {
|
|
1427
|
+
for (const f of routeTable.notFounds.values()) entries.add(f);
|
|
1428
|
+
}
|
|
1429
|
+
// Lazy components live in the registry but no page imports their
|
|
1430
|
+
// class directly; the lazy-loader fetches their module URLs on
|
|
1431
|
+
// viewport entry. Add every discovered component file as an entry so
|
|
1432
|
+
// the graph walk covers both eager and lazy paths.
|
|
1433
|
+
for (const c of components) entries.add(c.file);
|
|
1434
|
+
return reachableFromEntries(moduleGraph, [...entries], appDir);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
897
1437
|
/**
|
|
898
1438
|
* Find the absolute directory of the `@webjsdev/core` package, regardless of
|
|
899
1439
|
* whether we're running from the monorepo or an installed copy.
|
|
@@ -932,20 +1472,6 @@ function locatePackageDir(appDir, pkgName) {
|
|
|
932
1472
|
return null;
|
|
933
1473
|
}
|
|
934
1474
|
|
|
935
|
-
/**
|
|
936
|
-
* Load esbuild. Resolved as a real dependency of `@webjsdev/server`,
|
|
937
|
-
* so the bare specifier always resolves regardless of where the cli is
|
|
938
|
-
* installed (global, local, workspace-linked).
|
|
939
|
-
*
|
|
940
|
-
* @returns {Promise<typeof import('esbuild')>}
|
|
941
|
-
*/
|
|
942
|
-
let _esbuild = null;
|
|
943
|
-
async function loadEsbuild() {
|
|
944
|
-
if (_esbuild) return _esbuild;
|
|
945
|
-
_esbuild = await import('esbuild');
|
|
946
|
-
return _esbuild;
|
|
947
|
-
}
|
|
948
|
-
|
|
949
1475
|
const RELOAD_CLIENT_JS = `// webjs dev reload client
|
|
950
1476
|
const es = new EventSource('/__webjs/events');
|
|
951
1477
|
es.addEventListener('reload', () => location.reload());
|