@webjsdev/server 0.8.70 → 0.8.71

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.70",
3
+ "version": "0.8.71",
4
4
  "type": "module",
5
5
  "description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
6
6
  "main": "index.js",
@@ -816,7 +816,7 @@ export async function createRequestHandler(opts) {
816
816
  // path skipped it. This runs on the first warm AND after every rebuild,
817
817
  // so the pruned map is the single source of truth.
818
818
  const reachable = await scan();
819
- ({ imports, integrity } = prunePinToReachable(imports, integrity, reachable));
819
+ ({ imports, integrity } = prunePinToReachable(imports, integrity, reachable, { appDir }));
820
820
  }
821
821
  await setVendorEntries(imports, integrity);
822
822
  return v.ok;
@@ -531,12 +531,27 @@ function __webjsDirectEvents() {
531
531
  // and its hello says whether an edit landed meanwhile. \`bye\` on pagehide
532
532
  // drops the tab outright, and a bfcache restore reports in again.
533
533
  function __webjsReportVisibility(send) {
534
+ // The state this page was rendered at (#1516). The relay compares it with
535
+ // the server's on connect and on every reconnect, so an edit whose reload
536
+ // frame was lost (a stream replaced at a host's wake, a fresh relay, a tab
537
+ // that connected after the fanout) still reloads this page.
538
+ // Sent again on a bfcache restore, since \`bye\` dropped this tab's entry.
539
+ function sendPage() {
540
+ try {
541
+ var metas = document.querySelectorAll('meta[name="webjs-dev-reload"]');
542
+ var meta = metas.length ? metas[metas.length - 1] : null;
543
+ if (!meta) return;
544
+ var page = JSON.parse(meta.getAttribute('content') || 'null');
545
+ if (page && typeof page.boot === 'string') send({ type: 'page', boot: page.boot, seq: typeof page.seq === 'number' ? page.seq : null });
546
+ } catch (_) { /* no usable state: the relay's own hello check still runs */ }
547
+ }
548
+ sendPage();
534
549
  function report() {
535
550
  try { send({ type: 'visibility', visible: document.visibilityState !== 'hidden' }); } catch (_) { /* relay gone */ }
536
551
  }
537
552
  document.addEventListener('visibilitychange', report);
538
553
  addEventListener('pagehide', function () { try { send({ type: 'bye' }); } catch (_) { /* relay gone */ } });
539
- addEventListener('pageshow', function (e) { if (e.persisted) report(); });
554
+ addEventListener('pageshow', function (e) { if (e.persisted) { sendPage(); report(); } });
540
555
  report();
541
556
  // Interaction keeps an idle-closing stream (webjs.dev.reloadIdle) open and
542
557
  // reopens a closed one, at most once a second. The dev embed bridge calls
package/src/dev/server.js CHANGED
@@ -6,7 +6,8 @@ import { createServer as createHttp1Server } from 'node:http';
6
6
  import { createRequestHandler } from './handler.js';
7
7
  import { readServerTimeoutsFromApp, readDevWatchPathsFromApp } from './config.js';
8
8
  import { defaultLogger } from '../logger.js';
9
- import { SseHub, makeShutdown, installProcessHandlers, serverRuntime } from '../listener-core.js';
9
+ import { SseHub, makeShutdown, installProcessHandlers, serverRuntime, DEV_BOOT_ID } from '../listener-core.js';
10
+ import { setDevReloadState } from '../dev-reload-state.js';
10
11
  import { urlFromRequest } from '../forwarded.js';
11
12
  import { stripBasePath } from '../base-path.js';
12
13
  import { basePath } from '../importmap.js';
@@ -229,6 +230,9 @@ export async function startServer(opts) {
229
230
  // identically on both). Built before the handler so its onReload / onDevError
230
231
  // callbacks can fan out through it.
231
232
  const hub = new SseHub();
233
+ // Pages render the stream's state into a meta tag (#1516), so the relay can
234
+ // re-check a page against the server on every reconnect.
235
+ if (dev) setDevReloadState(() => ({ boot: DEV_BOOT_ID, seq: hub.seq }));
232
236
  let app;
233
237
  try {
234
238
  app = await createRequestHandler({
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The live-reload state a dev page was rendered at (#1516).
3
+ *
4
+ * `startServer` owns the reload stream's `SseHub`, which knows this process's
5
+ * boot id and how many reload frames it has sent. SSR renders that pair into a
6
+ * `webjs-dev-reload` meta tag, and the reload client hands it to the relay, so
7
+ * on every reconnect the relay can tell whether the page on screen is behind
8
+ * the server even when the reload frame that would have said so was lost.
9
+ *
10
+ * A module-level provider, the same shape as the base path and the embed
11
+ * origins, because the head is rendered far from the listener. An embedded
12
+ * `createRequestHandler` with no `startServer` has no reload stream, so no
13
+ * provider is set and no meta tag is rendered.
14
+ */
15
+
16
+ /** @type {(() => { boot: string, seq: number }) | null} */
17
+ let provider = null;
18
+
19
+ /**
20
+ * Set (or clear with null) the function that reports the current state.
21
+ * @param {(() => { boot: string, seq: number }) | null} fn
22
+ */
23
+ export function setDevReloadState(fn) {
24
+ provider = typeof fn === 'function' ? fn : null;
25
+ }
26
+
27
+ /** The current `{ boot, seq }`, or null when there is no reload stream. */
28
+ export function devReloadState() {
29
+ if (!provider) return null;
30
+ try { return provider(); } catch { return null; }
31
+ }
@@ -98,6 +98,23 @@ export function parseHello(data) {
98
98
  return { boot: String(data), seq: null };
99
99
  }
100
100
 
101
+ /**
102
+ * Whether a tab's page is behind the server (#1516). `page` is the
103
+ * `{ boot, seq }` the page was rendered at (the `webjs-dev-reload` meta tag),
104
+ * `boot` / `seq` what the server reports now. A different boot id is a
105
+ * restarted process; a different seq from the same process is an edit the page
106
+ * has not picked up. A missing seq on either side cannot say, so it is not
107
+ * stale (the boot id alone still decides).
108
+ * @param {{ boot: string, seq: number | null } | undefined} page
109
+ * @param {string | null} boot
110
+ * @param {number | null} seq
111
+ */
112
+ export function pageIsStale(page, boot, seq) {
113
+ if (!page || boot === null) return false;
114
+ if (page.boot !== boot) return true;
115
+ return page.seq !== null && seq !== null && page.seq !== seq;
116
+ }
117
+
101
118
  /** A reload frame's `seq` (#1507), or null when it carries none. @param {string} data */
102
119
  function reloadSeq(data) {
103
120
  try {
@@ -118,6 +135,16 @@ function reloadSeq(data) {
118
135
  * server's boot id and reload `seq`, and a difference from what this relay last
119
136
  * saw is a reload.
120
137
  *
138
+ * Every reconnect also re-checks each tab against the state its PAGE was
139
+ * rendered at (#1516). A tab sends `{ type: 'page', boot, seq }` from the
140
+ * page's `webjs-dev-reload` meta tag when it connects. A frame emitted while no
141
+ * stream was there to carry it is gone: a host that holds the quiet stream
142
+ * across a suspend and closes it at the next wake swallows the reload the wake
143
+ * itself caused, a fresh relay has no previous hello to compare with, and a tab
144
+ * that connects after a reload was fanned out never got it. In each case the
145
+ * next hello (or the tab's own connect) shows the page is behind, and that tab
146
+ * reloads.
147
+ *
121
148
  * The browser's own EventSource retry is replaced by an explicit one with
122
149
  * backoff (300ms doubling to 30s, reset by the next `hello`), so a server that
123
150
  * is gone for a while is probed less and less often instead of every 300ms.
@@ -137,6 +164,8 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
137
164
  const idleMs = opts && opts.idleMs > 0 ? opts.idleMs : 0;
138
165
  /** @type {Map<any, boolean>} each connected tab's port, and whether it is visible */
139
166
  const ports = new Map();
167
+ /** @type {Map<any, { boot: string, seq: number | null }>} the state each tab's page reflects (#1516) */
168
+ const pages = new Map();
140
169
  /** @type {string | null} the last error frame, cached for late-joining tabs */
141
170
  let lastError = null;
142
171
  /** @type {string | null} the last-seen per-process boot id (#893) */
@@ -157,13 +186,26 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
157
186
 
158
187
  /** @type {string} the strongest verdict seen in the CURRENT batch (#1398) */
159
188
  let batchVerdict = 'page';
189
+ /** Whether the batch reloads every tab, or only the stale ones in `batchPorts` (#1516). */
190
+ let batchAll = false;
191
+ /** @type {Set<any>} tabs whose page fell behind, reloaded on their own */
192
+ const batchPorts = new Set();
160
193
 
161
194
  function emitReload() {
162
195
  if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
163
196
  if (capTimer !== null) { timers.clearTimeout(capTimer); capTimer = null; }
164
197
  const v = batchVerdict;
165
198
  batchVerdict = 'page'; // a fresh batch starts at the weakest verdict
166
- fanout({ type: 'reload', verdict: v });
199
+ const msg = { type: 'reload', verdict: v };
200
+ const targets = batchAll ? Array.from(ports.keys()) : Array.from(batchPorts).filter((p) => ports.has(p));
201
+ batchAll = false;
202
+ batchPorts.clear();
203
+ for (const p of targets) {
204
+ // The tab applies this reload, so from here its page reflects the state
205
+ // the relay knows now; a later hello compares against that.
206
+ if (pages.has(p) && typeof lastBoot === 'string') pages.set(p, { boot: lastBoot, seq: lastSeq });
207
+ try { p.postMessage(msg); } catch (_) { ports.delete(p); pages.delete(p); }
208
+ }
167
209
  }
168
210
 
169
211
  /** Strength rank; an unrecognised name ranks strongest, so it can only ever
@@ -173,8 +215,12 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
173
215
  return i === -1 ? 0 : i;
174
216
  }
175
217
 
176
- /** @param {string} verdict */
177
- function requestReload(verdict) {
218
+ /**
219
+ * @param {string} verdict
220
+ * @param {any} [port] reload only this tab (its page fell behind); omitted, every tab
221
+ */
222
+ function requestReload(verdict, port) {
223
+ if (port === undefined) batchAll = true; else batchPorts.add(port);
178
224
  if (rank(verdict) < rank(batchVerdict)) batchVerdict = VERDICT_STRENGTH[rank(verdict)];
179
225
  if (quietTimer !== null) timers.clearTimeout(quietTimer);
180
226
  quietTimer = timers.setTimeout(emitReload, RELOAD_QUIET_MS);
@@ -199,6 +245,13 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
199
245
  idleTimer = timers.setTimeout(() => { idleTimer = null; close(); }, idleMs);
200
246
  }
201
247
 
248
+ // Reload every tab whose page is behind what the server reports now (#1516).
249
+ function checkPages() {
250
+ for (const [p, page] of pages) {
251
+ if (pageIsStale(page, lastBoot, lastSeq)) requestReload('reload', p);
252
+ }
253
+ }
254
+
202
255
  function anyVisible() {
203
256
  for (const v of ports.values()) if (v) return true;
204
257
  return false;
@@ -218,6 +271,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
218
271
  }
219
272
  lastBoot = h.boot;
220
273
  lastSeq = h.seq;
274
+ checkPages();
221
275
  });
222
276
  es.addEventListener('reload', (e) => {
223
277
  touch();
@@ -258,8 +312,18 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
258
312
  ports.set(port, true);
259
313
  port.onmessage = (m) => {
260
314
  const d = (m && m.data) || {};
315
+ if (d.type === 'page') {
316
+ // The state this tab's page was rendered at (#1516). Checked now
317
+ // against what the relay already knows, so a tab that missed a fanned
318
+ // out reload catches up, and again on every hello.
319
+ if (typeof d.boot === 'string') {
320
+ pages.set(port, { boot: d.boot, seq: typeof d.seq === 'number' ? d.seq : null });
321
+ if (pageIsStale(pages.get(port), lastBoot, lastSeq)) requestReload('reload', port);
322
+ }
323
+ return;
324
+ }
261
325
  if (d.type === 'visibility') ports.set(port, d.visible !== false);
262
- else if (d.type === 'bye') ports.delete(port);
326
+ else if (d.type === 'bye') { ports.delete(port); pages.delete(port); }
263
327
  else if (d.type === 'activity') { if (!ports.get(port)) return; touch(); }
264
328
  else return;
265
329
  sync();
package/src/ssr/head.js CHANGED
@@ -7,6 +7,7 @@ import { vendorIntegrityFor } from '../importmap.js';
7
7
  import { publicEnvShim } from './env-shim.js';
8
8
  import { clientRouterEnabled } from './client-router-flag.js';
9
9
  import { embedScriptTag } from '../dev-embed.js';
10
+ import { devReloadState } from '../dev-reload-state.js';
10
11
 
11
12
  // Which icon metadata ROUTES the app has (`app/icon.*`, `app/apple-icon.*`).
12
13
  // Set at boot and on each route rebuild from the route table, the same shape
@@ -315,8 +316,12 @@ export function wrapHead(opts) {
315
316
  : '';
316
317
 
317
318
  const boot = (imports || lazyBoot) ? `<script type="module"${n}>\n${imports}${lazyBoot}\n</script>` : '';
319
+ // The reload state this page is rendered at (#1516), so the reload client can
320
+ // tell on every reconnect whether the page fell behind the server.
321
+ const reloadState = opts.dev ? devReloadState() : null;
318
322
  const reload = opts.dev
319
- ? `<script type="module"${n} src="${escapeAttr(withBasePath('/__webjs/reload.js', bp))}"></script>`
323
+ ? (reloadState ? `<meta name="webjs-dev-reload" content="${escapeAttr(JSON.stringify(reloadState))}">\n` : '') +
324
+ `<script type="module"${n} src="${escapeAttr(withBasePath('/__webjs/reload.js', bp))}"></script>`
320
325
  : '';
321
326
  const suspenseBoot = opts.streaming
322
327
  ? `<script${n}>(function(){` +
@@ -91,6 +91,33 @@ export function getPackageManifest(pkgName, appDir) {
91
91
  }
92
92
  }
93
93
 
94
+ /**
95
+ * The names a package depends on at runtime (`dependencies`,
96
+ * `peerDependencies`, `optionalDependencies`) and its installed directory,
97
+ * resolved from `fromDir` (#1518). Resolving a dependency from its DEPENDENT's
98
+ * directory finds a nested install as well as a hoisted one, so the pin prune
99
+ * can walk the real installed graph. Null when the package is not installed.
100
+ *
101
+ * @param {string} pkgName
102
+ * @param {string} fromDir a directory holding a package.json (the app, or a dependent package)
103
+ * @returns {{ dir: string, names: string[] } | null}
104
+ */
105
+ export function getPackageDeps(pkgName, fromDir) {
106
+ const real = resolvePackageDir(pkgName, fromDir);
107
+ if (!real) return null;
108
+ try {
109
+ const pkg = JSON.parse(readFileSync(join(real, 'package.json'), 'utf8'));
110
+ const names = new Set([
111
+ ...Object.keys(pkg.dependencies || {}),
112
+ ...Object.keys(pkg.peerDependencies || {}),
113
+ ...Object.keys(pkg.optionalDependencies || {}),
114
+ ]);
115
+ return { dir: real, names: [...names] };
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
94
121
  // ---------------------------------------------------------------------------
95
122
  // JSPM Generator API client
96
123
  // ---------------------------------------------------------------------------
@@ -1,9 +1,9 @@
1
1
  import { readFile, writeFile, mkdir, unlink, stat, rename, readdir } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { BUILTIN, FRAMEWORK_SERVER_ONLY, extractPackageName, scanBareImports } from './scanner.js';
6
- import { getPackageVersion } from './manifest.js';
6
+ import { getPackageVersion, getPackageDeps } from './manifest.js';
7
7
  import { SUPPORTED_PROVIDERS } from './providers.js';
8
8
  import { PIN_BUNDLE_TIMEOUT_MS, fetchIntegrity, sha384Integrity } from './integrity.js';
9
9
  import { jspmGenerate } from './jspm.js';
@@ -506,17 +506,54 @@ export function basePackage(spec) {
506
506
  * when code imports `dayjs/plugin/utc`, and vice versa). Integrity hashes for
507
507
  * dropped URLs are pruned too.
508
508
  *
509
+ * With `opts.appDir` it also keeps the TRANSITIVE dependencies of every kept
510
+ * package (#1518). App code never imports a transitive (`@codemirror/view`
511
+ * imports `style-mod`, `@codemirror/language` imports `@lezer/lr`), so the
512
+ * filter above alone dropped every one of them and the browser failed to
513
+ * resolve the bare specifier. The closure walks each kept package's installed
514
+ * `package.json`, resolving each dependency from its dependent's directory, and
515
+ * keeps every pin entry whose base package it reaches, which is the set the
516
+ * live resolve serves (jspm flattens the transitives of the reachable
517
+ * installs). When a kept package is not on disk the graph cannot be walked, so
518
+ * every pin entry the app does not itself declare is kept instead: an unused
519
+ * entry costs nothing, a missing one breaks the page.
520
+ *
509
521
  * @param {Record<string, string>} imports pin entries (specifier -> URL)
510
522
  * @param {Record<string, string>} integrity SRI hashes keyed by URL
511
523
  * @param {Set<string>} reachable bare specifiers used by non-elided modules
524
+ * @param {{ appDir?: string }} [opts]
512
525
  * @returns {{ imports: Record<string, string>, integrity: Record<string, string> }}
513
526
  */
514
- export function prunePinToReachable(imports, integrity, reachable) {
515
- const reachableBases = new Set([...reachable].map(basePackage));
527
+ export function prunePinToReachable(imports, integrity, reachable, opts = {}) {
528
+ const keepBases = new Set([...reachable].map(basePackage));
529
+ if (opts.appDir) {
530
+ const pinnedBases = new Set(Object.keys(imports || {}).map(basePackage));
531
+ let complete = true;
532
+ /** @type {Array<[string, string]>} base package, directory to resolve it from */
533
+ const queue = [...keepBases].filter((b) => pinnedBases.has(b)).map((b) => [b, opts.appDir]);
534
+ const walked = new Set();
535
+ while (queue.length) {
536
+ const [base, from] = /** @type {[string, string]} */ (queue.shift());
537
+ const key = `${base}\0${from}`;
538
+ if (walked.has(key)) continue;
539
+ walked.add(key);
540
+ const deps = getPackageDeps(base, from);
541
+ if (!deps) { complete = false; continue; }
542
+ for (const name of deps.names) {
543
+ if (!pinnedBases.has(name)) continue;
544
+ keepBases.add(name);
545
+ queue.push([name, deps.dir]);
546
+ }
547
+ }
548
+ if (!complete) {
549
+ const declared = declaredDeps(opts.appDir);
550
+ for (const b of pinnedBases) if (!declared.has(b)) keepBases.add(b);
551
+ }
552
+ }
516
553
  /** @type {Record<string, string>} */
517
554
  const keptImports = {};
518
555
  for (const [spec, url] of Object.entries(imports || {})) {
519
- if (reachable.has(spec) || reachableBases.has(basePackage(spec))) {
556
+ if (reachable.has(spec) || keepBases.has(basePackage(spec))) {
520
557
  keptImports[spec] = url;
521
558
  }
522
559
  }
@@ -529,6 +566,25 @@ export function prunePinToReachable(imports, integrity, reachable) {
529
566
  return { imports: keptImports, integrity: keptIntegrity };
530
567
  }
531
568
 
569
+ /**
570
+ * Every package the app's own package.json declares, in any dependency field.
571
+ * @param {string} appDir
572
+ * @returns {Set<string>}
573
+ */
574
+ function declaredDeps(appDir) {
575
+ try {
576
+ const pkg = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8'));
577
+ return new Set([
578
+ ...Object.keys(pkg.dependencies || {}),
579
+ ...Object.keys(pkg.devDependencies || {}),
580
+ ...Object.keys(pkg.peerDependencies || {}),
581
+ ...Object.keys(pkg.optionalDependencies || {}),
582
+ ]);
583
+ } catch {
584
+ return new Set();
585
+ }
586
+ }
587
+
532
588
  // ---------------------------------------------------------------------------
533
589
  // Importmap coherence check (issue #450)
534
590
  // ---------------------------------------------------------------------------