@webjsdev/server 0.8.1 → 0.8.2
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/package.json +1 -1
- package/src/dev.js +92 -17
- package/src/importmap.js +52 -6
- package/src/ssr.js +9 -7
- package/src/vendor.js +15 -0
package/package.json
CHANGED
package/src/dev.js
CHANGED
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
import { defaultLogger } from './logger.js';
|
|
59
59
|
import { withRequest } from './context.js';
|
|
60
60
|
import { attachWebSocket } from './websocket.js';
|
|
61
|
-
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache } from './vendor.js';
|
|
61
|
+
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile } from './vendor.js';
|
|
62
62
|
import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport } from './module-graph.js';
|
|
63
63
|
import { primeComponentRegistry, findOrphanComponents, scanComponents } from './component-scanner.js';
|
|
64
64
|
import { analyzeElision, elideImportsFromSource } from './component-elision.js';
|
|
@@ -67,7 +67,7 @@ import { analyzeElision, elideImportsFromSource } from './component-elision.js';
|
|
|
67
67
|
function kebab(name) {
|
|
68
68
|
return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
69
69
|
}
|
|
70
|
-
import { setVendorEntries, setCoreInstall } from './importmap.js';
|
|
70
|
+
import { setVendorEntries, setCoreInstall, publishBuildId } from './importmap.js';
|
|
71
71
|
import { urlFromRequest } from './forwarded.js';
|
|
72
72
|
|
|
73
73
|
const MIME = {
|
|
@@ -212,6 +212,43 @@ export async function createRequestHandler(opts) {
|
|
|
212
212
|
existsSync(join(distDir, 'webjs-core-browser.js'));
|
|
213
213
|
await setCoreInstall(coreDir, distComplete);
|
|
214
214
|
|
|
215
|
+
// When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
|
|
216
|
+
// deterministic vendor map that is cheap to read (one file, no analysis, no
|
|
217
|
+
// network). Resolve it AT BOOT and publish the build id immediately so the
|
|
218
|
+
// process advertises a stable, non-empty id from its very first response: a
|
|
219
|
+
// freshly-deployed pinned process is detected as a new deploy by old-deploy
|
|
220
|
+
// clients with zero warmup window. Mirrors Rails importmap (committed pins
|
|
221
|
+
// rendered deterministically at runtime). Pinning stays optional; an unpinned
|
|
222
|
+
// app does no vendor work at boot and publishes its id after the first
|
|
223
|
+
// successful resolve instead. Either way the EXPENSIVE analysis (graph, scan,
|
|
224
|
+
// gate, elision) and the UNPINNED jspm resolve stay deferred to the first
|
|
225
|
+
// request, so #143's win is intact; only the cheap committed-file read moves
|
|
226
|
+
// back to boot, and only when a VALID pin exists. A committed pin file is
|
|
227
|
+
// served as-is (elision never prunes it), so the boot-resolved map equals the
|
|
228
|
+
// final served map and the published id is authoritative.
|
|
229
|
+
//
|
|
230
|
+
// Validate the pin with readPinFile BEFORE treating the app as pinned-at-boot.
|
|
231
|
+
// hasVendorPin is a cheap existence check; a malformed pin (exists but
|
|
232
|
+
// unparseable) must NOT short-circuit here, because resolveVendorImports would
|
|
233
|
+
// then fall through to its bare-import scan thunk, and the boot-time thunk is
|
|
234
|
+
// empty (the real scan is part of the deferred analysis). A broken pin instead
|
|
235
|
+
// falls through to the normal deferred resolve, which carries the real scan
|
|
236
|
+
// thunk and degrades gracefully, exactly as an unpinned app does.
|
|
237
|
+
let bootVendorPinned = false;
|
|
238
|
+
if (hasVendorPin(appDir) && (await readPinFile(appDir))) {
|
|
239
|
+
try {
|
|
240
|
+
const v = await resolveVendorImports(appDir, () => new Set());
|
|
241
|
+
await setVendorEntries(v.imports, v.integrity);
|
|
242
|
+
publishBuildId();
|
|
243
|
+
bootVendorPinned = true;
|
|
244
|
+
} catch (e) {
|
|
245
|
+
// An unexpected failure applying a VALID pin (e.g. setVendorEntries
|
|
246
|
+
// throwing) is non-fatal: leave bootVendorPinned false so the deferred
|
|
247
|
+
// resolve re-attempts on the first request. Boot stays resilient.
|
|
248
|
+
logger.error?.(`[webjs] applying the committed vendor pin at boot failed (will retry on the first request):`, e);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
215
252
|
// Whole-app analysis (module graph, component scan, browser-bound gate,
|
|
216
253
|
// action index, middleware, elision, vendor) is NOT run at boot. It is
|
|
217
254
|
// computed on the first request via ensureReady() below and memoized, so the
|
|
@@ -245,8 +282,11 @@ export async function createRequestHandler(opts) {
|
|
|
245
282
|
// platform's traffic and probes are the retry loop. `readyError` holds a
|
|
246
283
|
// propagating analysis failure so /__webjs/ready can report it.
|
|
247
284
|
let analysisDone = false; // deterministic analysis complete (readiness gate)
|
|
248
|
-
|
|
249
|
-
|
|
285
|
+
// A pinned app already resolved + published its vendor map at boot (above), so
|
|
286
|
+
// the deferred vendor stage is a no-op from the start; an unpinned app starts
|
|
287
|
+
// false and resolves on the first request.
|
|
288
|
+
let vendorResolved = bootVendorPinned; // vendor map fully resolved (or permanently tolerated)
|
|
289
|
+
let vendorAttemptedOnce = bootVendorPinned; // the first (blocking) vendor attempt has run
|
|
250
290
|
let vendorGen = 0; // bumped on rebuild; a stale resolve cannot flip vendorResolved
|
|
251
291
|
let readyDone = false; // mirrors analysisDone; the /__webjs/ready gate
|
|
252
292
|
/** @type {unknown} */
|
|
@@ -256,12 +296,25 @@ export async function createRequestHandler(opts) {
|
|
|
256
296
|
async function ensureReady() {
|
|
257
297
|
// Fully warm: analysis done and vendor resolved. Nothing to do.
|
|
258
298
|
if (analysisDone && vendorResolved) return;
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
299
|
+
// A warm pass is in flight (the analysis and/or the FIRST vendor attempt).
|
|
300
|
+
// Await it rather than serving past it: a concurrent early request must get
|
|
301
|
+
// the FINAL importmap, never a half-resolved one. This is what makes the
|
|
302
|
+
// unpinned warmup flawless. The first attempt's jspm resolve is
|
|
303
|
+
// timeout-bounded (vendor.js), so an offline app cannot hang here: on
|
|
304
|
+
// timeout the resolve returns and the response is served with an empty,
|
|
305
|
+
// reload-safe build id, then the retry below completes it. Without this
|
|
306
|
+
// wait, a request arriving mid-resolve would serve a partial map and an
|
|
307
|
+
// empty-then-changing build id, the exact warmup drift that hard-reloads
|
|
308
|
+
// and wipes a half-filled form.
|
|
309
|
+
if (readyInFlight) { await readyInFlight; return; }
|
|
310
|
+
// Analysis warm but the first vendor attempt already completed and failed:
|
|
311
|
+
// re-attempt WITHOUT blocking this request. The single-flight dedupes
|
|
312
|
+
// concurrent attempts; success flips the flag AND publishes the build id.
|
|
313
|
+
// This is the request/probe-driven retry (no timer). Until it succeeds the
|
|
314
|
+
// served build id stays empty (reload-safe), so no navigation hard-reloads.
|
|
262
315
|
if (analysisDone && vendorAttemptedOnce) {
|
|
263
316
|
const gen = vendorGen;
|
|
264
|
-
resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) vendorResolved = true; }).catch(() => {});
|
|
317
|
+
resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) { vendorResolved = true; publishBuildId(); } }).catch(() => {});
|
|
265
318
|
return;
|
|
266
319
|
}
|
|
267
320
|
// Otherwise run the (single-flighted) full warm: the analysis, then the
|
|
@@ -304,8 +357,6 @@ export async function createRequestHandler(opts) {
|
|
|
304
357
|
analysisDone = true;
|
|
305
358
|
ranAnalysis = true;
|
|
306
359
|
}
|
|
307
|
-
// Readiness gates on the analysis only; vendor is best-effort below.
|
|
308
|
-
readyDone = true;
|
|
309
360
|
readyError = null;
|
|
310
361
|
if (!vendorResolved) {
|
|
311
362
|
const m = now();
|
|
@@ -317,9 +368,28 @@ export async function createRequestHandler(opts) {
|
|
|
317
368
|
// Only memoize success (and only if a rebuild didn't intervene). A
|
|
318
369
|
// transient failure leaves vendorResolved false; the next ensureReady
|
|
319
370
|
// call re-attempts it non-blocking. A permanent unresolvable (jspm
|
|
320
|
-
// 401) reports ok and is tolerated, so it does not loop.
|
|
321
|
-
|
|
371
|
+
// 401) reports ok and is tolerated, so it does not loop. On success
|
|
372
|
+
// the importmap is now authoritatively final, so publish the build
|
|
373
|
+
// id: from here every response advertises the same stable value and
|
|
374
|
+
// the client router's deploy detection works without warmup drift.
|
|
375
|
+
if (ok && gen === vendorGen) { vendorResolved = true; publishBuildId(); }
|
|
322
376
|
}
|
|
377
|
+
// Readiness reflects a FULLY warm instance: the deterministic analysis
|
|
378
|
+
// AND the first vendor attempt have both completed (note: completed,
|
|
379
|
+
// not necessarily succeeded). A readiness-gated platform (Railway
|
|
380
|
+
// healthcheckPath, k8s readinessProbe) therefore admits traffic only
|
|
381
|
+
// AFTER the build id is published (vendor resolved) or definitively
|
|
382
|
+
// empty (a bounded vendor failure), never DURING the vendor-resolution
|
|
383
|
+
// window. This is what makes warm-up actually protect users: the prior
|
|
384
|
+
// instance keeps serving until the new one is fully warm, so a real
|
|
385
|
+
// request lands on a warm instance with a stable build id instead of
|
|
386
|
+
// racing the resolve. The first vendor attempt is bounded (the jspm
|
|
387
|
+
// fetch timeout in vendor.js), so an offline / CDN-degraded app still
|
|
388
|
+
// becomes ready shortly after that timeout, degraded but reload-safe,
|
|
389
|
+
// which preserves the boot resilience #143 introduced. The gate is the
|
|
390
|
+
// FIRST attempt only: a transient failure still flips readyDone here,
|
|
391
|
+
// so a later non-blocking retry never has to re-open the readiness gate.
|
|
392
|
+
readyDone = true;
|
|
323
393
|
if (ranAnalysis) {
|
|
324
394
|
const ms = (x) => Math.round(x || 0);
|
|
325
395
|
const total = ms(t.graph) + ms(t.scan) + ms(t.gate) + ms(t.actions) + ms(t.middleware) + ms(t.elision) + ms(t.vendor);
|
|
@@ -437,12 +507,17 @@ export async function createRequestHandler(opts) {
|
|
|
437
507
|
// Health and readiness probes are answered BEFORE ensureReady so a probe
|
|
438
508
|
// never blocks on the analysis. `/__webjs/health` is liveness (the
|
|
439
509
|
// process is up and accepting connections). `/__webjs/ready` is 503 until
|
|
440
|
-
// the
|
|
510
|
+
// the instance is FULLY warm (the deterministic analysis AND the first
|
|
511
|
+
// vendor attempt have both completed, so the importmap build id is
|
|
512
|
+
// settled), then 200 unless an optional app readiness check
|
|
441
513
|
// (readiness.{js,ts}) reports a dependency down. So a readinessProbe holds
|
|
442
|
-
// traffic off a not-yet-warm or dependency-unhealthy instance
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
514
|
+
// traffic off a not-yet-warm or dependency-unhealthy instance, and admits
|
|
515
|
+
// it only once the build id is stable, never mid vendor-resolution.
|
|
516
|
+
// Probing `/__webjs/ready` also kicks off the warm in the background, so
|
|
517
|
+
// an embedder that never called warmup() still warms. The first vendor
|
|
518
|
+
// attempt is bounded (the jspm fetch timeout), so a vendor CDN failure
|
|
519
|
+
// delays readiness only briefly and then admits the instance (degraded but
|
|
520
|
+
// reload-safe); a transient failure is re-attempted on the next request.
|
|
446
521
|
let probePath;
|
|
447
522
|
try { probePath = new URL(req.url).pathname; } catch { probePath = ''; }
|
|
448
523
|
if (probePath === '/__webjs/health') {
|
package/src/importmap.js
CHANGED
|
@@ -81,6 +81,46 @@ export function importMapHash() {
|
|
|
81
81
|
return _importMapHash;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The published, client-facing build id: the value stamped into the
|
|
86
|
+
* `data-webjs-build` attribute and the `X-Webjs-Build` header that the
|
|
87
|
+
* client router compares across navigations to detect a real deploy.
|
|
88
|
+
*
|
|
89
|
+
* Distinct from `importMapHash()` (the live hash of the current map).
|
|
90
|
+
* The published id is advertised ONLY once the importmap is
|
|
91
|
+
* authoritatively final, so the warmup window never advertises a value
|
|
92
|
+
* that later changes. Runtime-first boot resolves an unpinned app's
|
|
93
|
+
* vendor map over the first request; while that is in flight the live
|
|
94
|
+
* hash mutates (empty, then partial, then complete), but the published
|
|
95
|
+
* id stays `''` until the map is final. The router treats an empty
|
|
96
|
+
* build id as "version unknown" and never hard-reloads against it, so a
|
|
97
|
+
* not-yet-final response is reload-safe by construction and cannot wipe
|
|
98
|
+
* a half-filled form.
|
|
99
|
+
*
|
|
100
|
+
* Promoted by `publishBuildId()`: at boot for a pinned app (the
|
|
101
|
+
* committed map is deterministic), or after the first successful vendor
|
|
102
|
+
* resolve for an unpinned app.
|
|
103
|
+
*
|
|
104
|
+
* @returns {string} the advertised build id, or `''` until final
|
|
105
|
+
*/
|
|
106
|
+
let _publishedBuildId = '';
|
|
107
|
+
export function publishedBuildId() {
|
|
108
|
+
return _publishedBuildId;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Promote the current `importMapHash()` to the advertised build id.
|
|
113
|
+
* Called by `dev.js` when the importmap becomes authoritatively final.
|
|
114
|
+
* Idempotent; the value only changes when the underlying map does, so
|
|
115
|
+
* re-publishing an unchanged map is a no-op for the client. Within a
|
|
116
|
+
* single process the published id therefore never changes after the
|
|
117
|
+
* first publish (a rebuild in dev re-publishes the fresh map, but dev
|
|
118
|
+
* already forces a full reload via SSE).
|
|
119
|
+
*/
|
|
120
|
+
export function publishBuildId() {
|
|
121
|
+
_publishedBuildId = _importMapHash;
|
|
122
|
+
}
|
|
123
|
+
|
|
84
124
|
/**
|
|
85
125
|
* Look up the SRI integrity hash for a vendor URL, or empty string if
|
|
86
126
|
* none. Used by ssr.js to add `integrity="..."` to modulepreload tags
|
|
@@ -229,8 +269,11 @@ export function buildCoreEntries(coreDir, distMode) {
|
|
|
229
269
|
// The check is deliberately broad: `..` substring catches both
|
|
230
270
|
// `../etc/passwd` and `./foo/../bar`.
|
|
231
271
|
if (targetRel.includes('..')) continue;
|
|
232
|
-
// `./
|
|
233
|
-
// `./dist/webjs-core-
|
|
272
|
+
// `./lazy-loader` → `@webjsdev/core/lazy-loader`,
|
|
273
|
+
// `./dist/webjs-core-lazy-loader.js` → `/__webjs/core/dist/webjs-core-lazy-loader.js`.
|
|
274
|
+
// The browser-surface subpaths (`./directives`, `./context`, `./task`,
|
|
275
|
+
// `./client-router`) point their `default` at `webjs-core-browser.js`, so in
|
|
276
|
+
// dist mode they all collapse onto that one URL (the bundle re-exports them).
|
|
234
277
|
out['@webjsdev/core' + subpath.slice(1)] = '/__webjs/core/' + targetRel.slice(2);
|
|
235
278
|
}
|
|
236
279
|
return out;
|
|
@@ -295,9 +338,12 @@ export function importMapTag(opts = {}) {
|
|
|
295
338
|
// base64-ish. A misconfigured upstream emitting `nonce-<bad>` should
|
|
296
339
|
// not get its `<` rendered raw into our HTML.
|
|
297
340
|
const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
298
|
-
// Stamp the build
|
|
299
|
-
// importmap changes on intra-shell partial-response
|
|
300
|
-
//
|
|
301
|
-
|
|
341
|
+
// Stamp the published build id so the client router can detect
|
|
342
|
+
// post-deploy importmap changes on intra-shell partial-response
|
|
343
|
+
// navigations. Uses publishedBuildId() (empty until the map is
|
|
344
|
+
// authoritatively final), NOT the live importMapHash(), so the warmup
|
|
345
|
+
// window never advertises an id that later changes. See
|
|
346
|
+
// publishedBuildId() above for the rationale.
|
|
347
|
+
const b = ` data-webjs-build="${publishedBuildId()}"`;
|
|
302
348
|
return `<script type="importmap"${n}${b}>${jsonForScriptTag(buildImportMap())}</script>`;
|
|
303
349
|
}
|
package/src/ssr.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy } from '@webjsdev/core';
|
|
4
|
-
import { importMapTag, vendorIntegrityFor,
|
|
4
|
+
import { importMapTag, vendorIntegrityFor, publishedBuildId } from './importmap.js';
|
|
5
5
|
import { jsonForScriptTag } from './script-tag-json.js';
|
|
6
6
|
import { readToken, newToken, cookieHeader } from './csrf.js';
|
|
7
7
|
import { transitiveDeps } from './module-graph.js';
|
|
@@ -174,11 +174,13 @@ function htmlResponse(html, status, req, url, metadata) {
|
|
|
174
174
|
// Default: no caching. Pages are dynamic by default: the developer
|
|
175
175
|
// opts in to caching explicitly via metadata.cacheControl.
|
|
176
176
|
headers.set('cache-control', metadata?.cacheControl || 'no-store');
|
|
177
|
-
// X-Webjs-Build carries the
|
|
177
|
+
// X-Webjs-Build carries the published build id so the client
|
|
178
178
|
// router can detect post-deploy importmap changes on EVERY
|
|
179
179
|
// response, including the X-Webjs-Have partial responses that
|
|
180
|
-
// omit the head entirely.
|
|
181
|
-
|
|
180
|
+
// omit the head entirely. Empty until the map is authoritatively
|
|
181
|
+
// final, so a warming response is reload-safe. See router-client.js
|
|
182
|
+
// applySwap and publishedBuildId() in importmap.js.
|
|
183
|
+
headers.set('x-webjs-build', publishedBuildId());
|
|
182
184
|
if (req && !readToken(req)) {
|
|
183
185
|
const secure = url ? url.protocol === 'https:' : false;
|
|
184
186
|
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
@@ -1189,9 +1191,9 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
1189
1191
|
// Default: no caching. Pages are dynamic by default: the developer
|
|
1190
1192
|
// opts in to caching explicitly via metadata.cacheControl.
|
|
1191
1193
|
headers.set('cache-control', metadata?.cacheControl || 'no-store');
|
|
1192
|
-
// See htmlResponse: build
|
|
1193
|
-
// router's importmap-mismatch detection on partial swaps.
|
|
1194
|
-
headers.set('x-webjs-build',
|
|
1194
|
+
// See htmlResponse: published build id on every response for the
|
|
1195
|
+
// client router's importmap-mismatch detection on partial swaps.
|
|
1196
|
+
headers.set('x-webjs-build', publishedBuildId());
|
|
1195
1197
|
if (req && !readToken(req)) {
|
|
1196
1198
|
const secure = url ? url.protocol === 'https:' : false;
|
|
1197
1199
|
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
package/src/vendor.js
CHANGED
|
@@ -480,6 +480,21 @@ function pinFilePath(appDir) {
|
|
|
480
480
|
return join(pinDir(appDir), PIN_FILE);
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
+
/**
|
|
484
|
+
* True when the app commits a vendor pin file (`.webjs/vendor/importmap.json`).
|
|
485
|
+
* A pinned app's importmap is deterministic and cheap to read, so `dev.js`
|
|
486
|
+
* resolves it AT BOOT (no analysis, no network) and publishes the build id
|
|
487
|
+
* immediately, giving the recommended posture a stable id from the first
|
|
488
|
+
* response with zero warmup exposure. An unpinned app returns false and keeps
|
|
489
|
+
* its vendor resolution deferred to the first request.
|
|
490
|
+
*
|
|
491
|
+
* @param {string} appDir
|
|
492
|
+
* @returns {boolean}
|
|
493
|
+
*/
|
|
494
|
+
export function hasVendorPin(appDir) {
|
|
495
|
+
return existsSync(pinFilePath(appDir));
|
|
496
|
+
}
|
|
497
|
+
|
|
483
498
|
/**
|
|
484
499
|
* Filesystem-safe filename for a downloaded bundle. Encodes the full
|
|
485
500
|
* specifier (which may include a subpath) into a flat filename:
|