@webjsdev/server 0.8.1 → 0.8.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
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/check.js CHANGED
@@ -957,6 +957,17 @@ export async function checkConventions(appDir, opts) {
957
957
  const hasGitignore = await pathExists(join(appDir, '.gitignore'));
958
958
  if (hasGit && hasGitignore) {
959
959
  const { spawnSync } = await import('node:child_process');
960
+ // Strip inherited git env vars so `cwd` is the sole authority on
961
+ // which repo `git check-ignore` consults. Git exports GIT_DIR /
962
+ // GIT_WORK_TREE / GIT_INDEX_FILE / GIT_PREFIX into hook processes
963
+ // (notably a pre-commit hook run from a linked worktree exports
964
+ // GIT_WORK_TREE), and those OVERRIDE cwd-based discovery, so
965
+ // without this the probe would consult the outer repo instead of
966
+ // `appDir`. See the gitignore-vendor-not-ignored regression test.
967
+ const {
968
+ GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
969
+ ...gitEnv
970
+ } = process.env;
960
971
  // Check two representative paths: the pin manifest AND a sample
961
972
  // downloaded bundle. A `.gitignore` that allows the manifest
962
973
  // but blocks bundles (e.g. `*.js` higher up) would still break
@@ -970,6 +981,7 @@ export async function checkConventions(appDir, opts) {
970
981
  const result = spawnSync('git', ['check-ignore', '-q', probe], {
971
982
  cwd: appDir,
972
983
  stdio: 'pipe',
984
+ env: gitEnv,
973
985
  });
974
986
  if (result.status === 0) {
975
987
  violations.push({
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
- let vendorResolved = false; // vendor map fully resolved (or permanently tolerated)
249
- let vendorAttemptedOnce = false; // the first (blocking) vendor attempt has run
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
- // 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).
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
- if (ok && gen === vendorGen) vendorResolved = true;
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 analysis is warm, then 200 unless an optional app readiness check
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. 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).
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
- // `./directives` → `@webjsdev/core/directives`,
233
- // `./dist/webjs-core-directives.js` → `/__webjs/core/dist/webjs-core-directives.js`.
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 hash so the client router can detect post-deploy
299
- // importmap changes on intra-shell partial-response navigations.
300
- // See importMapHash() above for the rationale.
301
- const b = ` data-webjs-build="${importMapHash()}"`;
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
  }
@@ -14,6 +14,7 @@
14
14
  import { readFile, readdir, stat } from 'node:fs/promises';
15
15
  import { existsSync } from 'node:fs';
16
16
  import { join, resolve, dirname, extname, sep } from 'node:path';
17
+ import { redactStringsAndTemplates } from './js-scan.js';
17
18
 
18
19
  /** @type {RegExp} match static `import … from '…'` and `import '…'` */
19
20
  const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
@@ -104,6 +105,17 @@ export function transitiveDeps(graph, entryFiles, appDir, skip) {
104
105
  if (dep.startsWith(appDir)) {
105
106
  result.push(dep);
106
107
  }
108
+ // Stop at server-file boundaries, exactly like reachableFromEntries
109
+ // (the authorization gate). The browser fetches a `.server.*` URL as
110
+ // an RPC or throw-at-load stub, never its source, so the server
111
+ // file's own imports are never fetched. Following them would emit
112
+ // modulepreload hints for server-only modules that the gate then
113
+ // 404s (a preload set wider than the servable set). The `.server.*`
114
+ // file itself stays in the result; the preload emitter filters it via
115
+ // the server-file index. A file imported through BOTH a server file
116
+ // and a real client path is still reached via the client path, so it
117
+ // is not wrongly dropped.
118
+ if (SERVER_FILE_RE.test(dep)) continue;
107
119
  queue.push(dep);
108
120
  }
109
121
  }
@@ -247,9 +259,23 @@ async function parseFile(file, appDir, graph, seen) {
247
259
  try { src = await readFile(file, 'utf8'); }
248
260
  catch { return; }
249
261
 
262
+ // Mask of `src` with all string / template-literal / comment / regex
263
+ // CONTENT blanked to spaces (positions preserved). Used to reject an
264
+ // `import '…'` / `export … from '…'` that appears as TEXT inside a
265
+ // template literal (e.g. example code shown in a `<pre>` inside an
266
+ // `html\`\`` template, as the docs site does) rather than as a real
267
+ // statement. We still read the specifier from the RAW `src` (the
268
+ // specifier is itself a string, blanked in the mask), and only consult
269
+ // the mask to confirm the `import` / `export` KEYWORD survived
270
+ // redaction, i.e. sits in code position and not inside a literal.
271
+ const masked = redactStringsAndTemplates(src);
250
272
  const deps = new Set();
251
273
  for (const re of [IMPORT_RE, EXPORT_FROM_RE]) {
252
274
  for (const m of src.matchAll(re)) {
275
+ // m.index is the keyword start (`\bimport` / `\bexport`). If that
276
+ // position is blanked in the mask, the match lives inside a literal
277
+ // and is not a real import edge.
278
+ if (masked[m.index] === ' ') continue;
253
279
  const spec = m[1];
254
280
  // Only resolve relative imports within the project.
255
281
  if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
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, importMapHash } from './importmap.js';
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 current importmap hash so the client
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. See router-client.js applySwap.
181
- headers.set('x-webjs-build', importMapHash());
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 hash on every response for the client
1193
- // router's importmap-mismatch detection on partial swaps.
1194
- headers.set('x-webjs-build', importMapHash());
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: