@webjsdev/server 0.8.5 → 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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/dev.js +29 -11
  3. package/src/vendor.js +49 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
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 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
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
- if (ok && gen === vendorGen) { vendorResolved = true; publishBuildId(); }
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 v = await resolveVendorImports(appDir,
427
- () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules])));
428
- await setVendorEntries(v.imports, v.integrity);
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);
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