@webjsdev/server 0.8.4 → 0.8.6
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 +108 -46
- package/src/vendor.js +49 -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, hasVendorPin, readPinFile } from './vendor.js';
|
|
61
|
+
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } 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';
|
|
@@ -282,11 +282,14 @@ export async function createRequestHandler(opts) {
|
|
|
282
282
|
// platform's traffic and probes are the retry loop. `readyError` holds a
|
|
283
283
|
// propagating analysis failure so /__webjs/ready can report it.
|
|
284
284
|
let analysisDone = false; // deterministic analysis complete (readiness gate)
|
|
285
|
-
// A pinned app
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
285
|
+
// A pinned app applied its FULL vendor map and published the build id at boot
|
|
286
|
+
// (above). The deferred vendor stage still runs once (and after every rebuild)
|
|
287
|
+
// to PRUNE that map to the elision-reachable specifiers, so a pinned app serves
|
|
288
|
+
// the same map an unpinned one does (#197); it does not re-publish the build id
|
|
289
|
+
// (the boot hash stays the deploy fingerprint). An unpinned app starts false and
|
|
290
|
+
// resolves live on the first request.
|
|
291
|
+
let vendorResolved = false; // vendor map fully resolved/pruned (or permanently tolerated)
|
|
292
|
+
let vendorAttemptedOnce = false; // the first (blocking) vendor attempt has run
|
|
290
293
|
let vendorGen = 0; // bumped on rebuild; a stale resolve cannot flip vendorResolved
|
|
291
294
|
let readyDone = false; // mirrors analysisDone; the /__webjs/ready gate
|
|
292
295
|
/** @type {unknown} */
|
|
@@ -314,7 +317,7 @@ export async function createRequestHandler(opts) {
|
|
|
314
317
|
// served build id stays empty (reload-safe), so no navigation hard-reloads.
|
|
315
318
|
if (analysisDone && vendorAttemptedOnce) {
|
|
316
319
|
const gen = vendorGen;
|
|
317
|
-
resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) { vendorResolved = true; publishBuildId(); } }).catch(() => {});
|
|
320
|
+
resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); } }).catch(() => {});
|
|
318
321
|
return;
|
|
319
322
|
}
|
|
320
323
|
// Otherwise run the (single-flighted) full warm: the analysis, then the
|
|
@@ -372,7 +375,11 @@ export async function createRequestHandler(opts) {
|
|
|
372
375
|
// the importmap is now authoritatively final, so publish the build
|
|
373
376
|
// id: from here every response advertises the same stable value and
|
|
374
377
|
// the client router's deploy detection works without warmup drift.
|
|
375
|
-
|
|
378
|
+
// A pinned app published the build id at boot (hash of the committed
|
|
379
|
+
// pin) and the prune only shrinks the served map, so do NOT re-publish
|
|
380
|
+
// (that would drift the id mid-process). An unpinned app publishes its
|
|
381
|
+
// now-final live map here.
|
|
382
|
+
if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); }
|
|
376
383
|
}
|
|
377
384
|
// Readiness reflects a FULLY warm instance: the deterministic analysis
|
|
378
385
|
// AND the first vendor attempt have both completed (note: completed,
|
|
@@ -423,9 +430,20 @@ export async function createRequestHandler(opts) {
|
|
|
423
430
|
if (vendorResolveInFlight) return vendorResolveInFlight;
|
|
424
431
|
vendorResolveInFlight = (async () => {
|
|
425
432
|
try {
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
433
|
+
const scan = () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules]));
|
|
434
|
+
const v = await resolveVendorImports(appDir, scan);
|
|
435
|
+
let { imports, integrity } = v;
|
|
436
|
+
if (bootVendorPinned) {
|
|
437
|
+
// resolveVendorImports returns a committed pin VERBATIM (it never runs
|
|
438
|
+
// the scan for a pinned app). Prune it to the elision-reachable
|
|
439
|
+
// specifiers so a pinned app serves the same map an unpinned one does
|
|
440
|
+
// (#197): an elided-only dep like dayjs is dropped. One scan; the pin
|
|
441
|
+
// path skipped it. This runs on the first warm AND after every rebuild,
|
|
442
|
+
// so the pruned map is the single source of truth.
|
|
443
|
+
const reachable = await scan();
|
|
444
|
+
({ imports, integrity } = prunePinToReachable(imports, integrity, reachable));
|
|
445
|
+
}
|
|
446
|
+
await setVendorEntries(imports, integrity);
|
|
429
447
|
return v.ok;
|
|
430
448
|
} catch (e) {
|
|
431
449
|
logger.error?.(`[webjs] vendor resolve failed (will retry on the next request):`, e);
|
|
@@ -549,6 +567,16 @@ export async function createRequestHandler(opts) {
|
|
|
549
567
|
}
|
|
550
568
|
return Response.json({ status: 'ok' }, { headers: noStore });
|
|
551
569
|
}
|
|
570
|
+
// Framework-internal static assets (the @webjsdev/core runtime, the dev
|
|
571
|
+
// reload client, downloaded vendor bundles) depend on neither the analysis
|
|
572
|
+
// nor the vendor importmap, so serve them BEFORE ensureReady(). Otherwise a
|
|
573
|
+
// cold instance blocks them behind the first vendor resolve (issue #190),
|
|
574
|
+
// and the core bundle is on every page's boot path, so that stalled first
|
|
575
|
+
// interactivity site-wide. Matched on the decoded path, like handleCore.
|
|
576
|
+
let assetPath = probePath;
|
|
577
|
+
try { assetPath = decodeURIComponent(probePath); } catch { /* keep raw on malformed escape */ }
|
|
578
|
+
const staticResp = await tryServeFrameworkStatic(assetPath, req.method.toUpperCase(), { coreDir, appDir, dev });
|
|
579
|
+
if (staticResp) return staticResp;
|
|
552
580
|
// Build all whole-app analysis on the first request (memoized), before
|
|
553
581
|
// any SSR, module serve, gate check, action dispatch, or middleware runs.
|
|
554
582
|
await ensureReady();
|
|
@@ -775,23 +803,29 @@ export async function startServer(opts) {
|
|
|
775
803
|
* @param {Request} req
|
|
776
804
|
* @param {{state: any, appDir: string, coreDir: string, dev: boolean}} ctx
|
|
777
805
|
*/
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
806
|
+
/**
|
|
807
|
+
* Serve framework-internal static assets that depend on NEITHER the whole-app
|
|
808
|
+
* analysis NOR the vendor importmap: the `@webjsdev/core` runtime files, the
|
|
809
|
+
* dev reload client, and (in `--download` pin mode) the committed vendor
|
|
810
|
+
* bundles. `handle()` calls this BEFORE `ensureReady()`, so a cold instance
|
|
811
|
+
* returns them immediately instead of blocking on the first vendor resolve
|
|
812
|
+
* (issue #190). The core bundle is on every page's boot path, so coupling it
|
|
813
|
+
* to the jspm resolve stalled first interactivity site-wide on a cold instance.
|
|
814
|
+
*
|
|
815
|
+
* Like the health / readiness probes (also answered pre-`ensureReady`), these
|
|
816
|
+
* bypass app middleware. That is correct: they are framework infrastructure the
|
|
817
|
+
* app needs to function, not app routes, and `state.middleware` is not even
|
|
818
|
+
* loaded until `ensureReady()` completes.
|
|
819
|
+
*
|
|
820
|
+
* @param {string} path decoded pathname
|
|
821
|
+
* @param {string} method upper-cased HTTP method
|
|
822
|
+
* @param {{ coreDir: string, appDir: string, dev: boolean }} ctx
|
|
823
|
+
* @returns {Promise<Response|null>} a Response, or null when path is not one of these assets
|
|
824
|
+
*/
|
|
825
|
+
async function tryServeFrameworkStatic(path, method, ctx) {
|
|
826
|
+
const { coreDir, appDir, dev } = ctx;
|
|
793
827
|
|
|
794
|
-
// Dev live-reload client
|
|
828
|
+
// Dev live-reload client.
|
|
795
829
|
if (path === '/__webjs/reload.js') {
|
|
796
830
|
if (!dev) return new Response('Not found', { status: 404 });
|
|
797
831
|
return new Response(RELOAD_CLIENT_JS, {
|
|
@@ -802,35 +836,38 @@ async function handleCore(req, ctx) {
|
|
|
802
836
|
// Core module: /__webjs/core/*
|
|
803
837
|
//
|
|
804
838
|
// ETag + ~1h max-age, NOT immutable. The URL path is un-versioned
|
|
805
|
-
// (`/__webjs/core/src/render-client.js` etc.), so bumping
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
// exports added in the bump (e.g., the slot.js entry points landed
|
|
839
|
+
// (`/__webjs/core/src/render-client.js` etc.), so bumping `@webjsdev/core`
|
|
840
|
+
// ships different bytes at the same URL. An `immutable` cache-control
|
|
841
|
+
// directive at an edge CDN (Cloudflare, Vercel, Fly) keeps the prior bytes
|
|
842
|
+
// pinned for up to a year, which silently bricks the next deploy: browsers
|
|
843
|
+
// load the old client renderer against a server emitting the new SSR shape,
|
|
844
|
+
// and any exports added in the bump (e.g., the slot.js entry points landed
|
|
812
845
|
// for 0.6.0) resolve to undefined in the cached file.
|
|
813
846
|
// Regression: 2026-05-20, ui.webjs.dev tier-2 components after
|
|
814
847
|
// @webjsdev/core 0.5.0 -> 0.6.0 republish.
|
|
815
848
|
if (path.startsWith('/__webjs/core/')) {
|
|
816
849
|
const rel = path.slice('/__webjs/core/'.length);
|
|
817
850
|
const abs = resolve(coreDir, rel);
|
|
818
|
-
|
|
851
|
+
// Trailing-separator boundary check, not a raw string prefix: a raw
|
|
852
|
+
// `startsWith(coreDir)` would admit a sibling like `@webjsdev/core-evil`,
|
|
853
|
+
// reachable via an encoded slash (`..%2f`, which survives URL normalization
|
|
854
|
+
// and then decodes to `../`). Match the public-root branch's guard.
|
|
855
|
+
if (abs !== coreDir && !abs.startsWith(coreDir + sep)) {
|
|
856
|
+
return new Response('forbidden', { status: 403 });
|
|
857
|
+
}
|
|
819
858
|
return fileResponse(abs, { dev, immutable: false });
|
|
820
859
|
}
|
|
821
860
|
|
|
822
|
-
// Vendor URL handler for `webjs vendor pin --download` mode only.
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
//
|
|
861
|
+
// Vendor URL handler for `webjs vendor pin --download` mode only. In default
|
|
862
|
+
// pin mode (or no-pin mode) the importmap routes bare imports straight to
|
|
863
|
+
// ga.jspm.io URLs and the browser bypasses this server entirely. When the
|
|
864
|
+
// user ran `webjs vendor pin --download`, the importmap has local
|
|
865
|
+
// `/__webjs/vendor/<file>.js` URLs and this serves the committed bundle files
|
|
866
|
+
// from `.webjs/vendor/`. These are read-only static content: allow GET/HEAD
|
|
867
|
+
// for the normal fetch, OPTIONS for any cross-origin preflight (204 with the
|
|
868
|
+
// same Allow header rather than 405, which some intermediaries treat as a
|
|
869
|
+
// hard failure even for a CORS probe), and 405 everything else.
|
|
828
870
|
if (path.startsWith('/__webjs/vendor/') && path.endsWith('.js')) {
|
|
829
|
-
// Vendor bundles are read-only static content. Allow GET/HEAD for
|
|
830
|
-
// the normal fetch, OPTIONS for any cross-origin preflight (we
|
|
831
|
-
// return 204 with the same Allow header rather than 405, which
|
|
832
|
-
// some intermediaries treat as a hard failure even for a CORS
|
|
833
|
-
// probe), and 405 everything else.
|
|
834
871
|
if (method === 'OPTIONS') {
|
|
835
872
|
return new Response(null, { status: 204, headers: { allow: 'GET, HEAD, OPTIONS' } });
|
|
836
873
|
}
|
|
@@ -846,6 +883,31 @@ async function handleCore(req, ctx) {
|
|
|
846
883
|
return resp;
|
|
847
884
|
}
|
|
848
885
|
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function handleCore(req, ctx) {
|
|
890
|
+
const { state, appDir, coreDir, dev } = ctx;
|
|
891
|
+
const url = new URL(req.url);
|
|
892
|
+
// Decode percent-encoded characters so filesystem lookups match real
|
|
893
|
+
// filenames. Dynamic route segments like `[slug]` and route groups like
|
|
894
|
+
// `(marketing)` contain chars that browsers percent-encode in URLs
|
|
895
|
+
// (`%5B`, `%5D`, `%28`, `%29`). Without decoding, the server joins the
|
|
896
|
+
// encoded path with the app directory → file not found → 404 → no JS
|
|
897
|
+
// loads → no interactivity.
|
|
898
|
+
let path;
|
|
899
|
+
try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
|
|
900
|
+
const method = req.method.toUpperCase();
|
|
901
|
+
|
|
902
|
+
// Health / readiness probes (`/__webjs/health`, `/__webjs/ready`) and the
|
|
903
|
+
// framework-internal static assets (`/__webjs/core/*`, `/__webjs/reload.js`,
|
|
904
|
+
// downloaded `/__webjs/vendor/*`) are served in `handle()` BEFORE ensureReady,
|
|
905
|
+
// so they are not repeated here. This fallback covers the (currently
|
|
906
|
+
// unreachable) case of handleCore being entered for one of those assets, so
|
|
907
|
+
// the routing stays correct if a future caller bypasses the early path.
|
|
908
|
+
const frameworkStatic = await tryServeFrameworkStatic(path, method, { coreDir, appDir, dev });
|
|
909
|
+
if (frameworkStatic) return frameworkStatic;
|
|
910
|
+
|
|
849
911
|
// Internal server-action RPC endpoint
|
|
850
912
|
const actMatch = /^\/__webjs\/action\/([a-f0-9]+)\/([A-Za-z0-9_$]+)$/.exec(path);
|
|
851
913
|
if (actMatch) {
|
package/src/vendor.js
CHANGED
|
@@ -1308,6 +1308,55 @@ function maxSemverVersion(versions) {
|
|
|
1308
1308
|
* on the unpinned path (so a pinned app never pays the whole-app walk).
|
|
1309
1309
|
* @returns {Promise<{ imports: Record<string, string>, integrity: Record<string, string> }>}
|
|
1310
1310
|
*/
|
|
1311
|
+
/**
|
|
1312
|
+
* Base package of a bare specifier: `dayjs` -> `dayjs`,
|
|
1313
|
+
* `dayjs/plugin/utc` -> `dayjs`, `@scope/pkg/sub` -> `@scope/pkg`.
|
|
1314
|
+
*
|
|
1315
|
+
* @param {string} spec
|
|
1316
|
+
* @returns {string}
|
|
1317
|
+
*/
|
|
1318
|
+
function basePackage(spec) {
|
|
1319
|
+
const parts = spec.split('/');
|
|
1320
|
+
return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Prune a pinned import map to the vendor specifiers still reachable from
|
|
1325
|
+
* NON-elided modules. A committed pin is the whole map, but elision can make
|
|
1326
|
+
* a pinned package unreachable (its only importer is a display-only component
|
|
1327
|
+
* that ships no JS, e.g. dayjs via the blog's vendor-badge). The live-resolve
|
|
1328
|
+
* path prunes such a package by excluding elided components from the bare-
|
|
1329
|
+
* import scan; this brings the pinned path to the same result, so a pinned app
|
|
1330
|
+
* and an unpinned app serve the same import map (issue #197).
|
|
1331
|
+
*
|
|
1332
|
+
* Keeps an entry when its specifier is reachable, OR when its base package is
|
|
1333
|
+
* the base of any reachable specifier (so a pinned base entry `dayjs` survives
|
|
1334
|
+
* when code imports `dayjs/plugin/utc`, and vice versa). Integrity hashes for
|
|
1335
|
+
* dropped URLs are pruned too.
|
|
1336
|
+
*
|
|
1337
|
+
* @param {Record<string, string>} imports pin entries (specifier -> URL)
|
|
1338
|
+
* @param {Record<string, string>} integrity SRI hashes keyed by URL
|
|
1339
|
+
* @param {Set<string>} reachable bare specifiers used by non-elided modules
|
|
1340
|
+
* @returns {{ imports: Record<string, string>, integrity: Record<string, string> }}
|
|
1341
|
+
*/
|
|
1342
|
+
export function prunePinToReachable(imports, integrity, reachable) {
|
|
1343
|
+
const reachableBases = new Set([...reachable].map(basePackage));
|
|
1344
|
+
/** @type {Record<string, string>} */
|
|
1345
|
+
const keptImports = {};
|
|
1346
|
+
for (const [spec, url] of Object.entries(imports || {})) {
|
|
1347
|
+
if (reachable.has(spec) || reachableBases.has(basePackage(spec))) {
|
|
1348
|
+
keptImports[spec] = url;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
const keptUrls = new Set(Object.values(keptImports));
|
|
1352
|
+
/** @type {Record<string, string>} */
|
|
1353
|
+
const keptIntegrity = {};
|
|
1354
|
+
for (const [url, hash] of Object.entries(integrity || {})) {
|
|
1355
|
+
if (keptUrls.has(url)) keptIntegrity[url] = hash;
|
|
1356
|
+
}
|
|
1357
|
+
return { imports: keptImports, integrity: keptIntegrity };
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1311
1360
|
export async function resolveVendorImports(appDir, getBareImports) {
|
|
1312
1361
|
const file = await readPinFile(appDir);
|
|
1313
1362
|
// A committed pin file IS the import map. The whole-app bare-import scan is
|