@jxsuite/server 3.0.0 → 4.1.0

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.
@@ -0,0 +1,616 @@
1
+ /**
2
+ * The project's working tree, browsable as a site, on an origin of its own.
3
+ *
4
+ * `site-preview.ts` serves the compiler's OUTPUT and argues at length for why that needs its own
5
+ * origin. This is its sibling and the argument inverts: a live preview's paths mean the project's
6
+ * SOURCES, which is what the editing server already serves — so the reason for a second origin here
7
+ * is not that the paths would collide, it is everything else.
8
+ *
9
+ * **Lifetime.** One tab per project needs one origin per project, and the editing server is per
10
+ * WINDOW: a tab pointed at it dies when that window closes, and two windows on one project would
11
+ * produce two tabs. The map below is keyed by project root and lives for the process, exactly as
12
+ * `site-preview.ts`'s does and for the same reason — no single window's teardown may close it.
13
+ *
14
+ * **Isolation.** A previewed page runs the project's own JavaScript, which routinely includes
15
+ * third-party script. On the chromium build the studio shell is served BY the editing server, so a
16
+ * preview mounted there would share `localStorage`, IndexedDB and service-worker scope with the
17
+ * editor. A different port is a different origin, and on loopback that costs nothing.
18
+ *
19
+ * **Rules.** `serveProjectFile` serves the whole project root as an explicitly-temporary lane that
20
+ * warns about itself. On the editor's origin that is Studio addressing files it already holds paths
21
+ * for; on an origin running project script it is a way to read `.dev.vars`. This origin uses
22
+ * `@jxsuite/site`'s allowlist instead, which defaults closed.
23
+ *
24
+ * What is served here that a published site would not have is confined to one namespace,
25
+ * `/__jx_live__/`, dispatched ahead of everything: the runtime bundle, the reload client, the site
26
+ * stylesheet, and the reload stream. Not `/_jx/` — that is the extension-mount namespace, which a
27
+ * previewed page still needs — and not `/__studio__/`, because a preview origin must not look like
28
+ * the editor's.
29
+ *
30
+ * **The overlay is why this shows the canvas rather than the disk.** Studio publishes the bytes a
31
+ * save WOULD write, per dirty document, and every read here prefers them. They are held in memory
32
+ * and never written anywhere: there is no file to go stale, so a crash leaves a preview showing the
33
+ * saved state, which is the right answer rather than a stale one.
34
+ *
35
+ * @docs framework/build/dev-server
36
+ */
37
+
38
+ import { existsSync } from "node:fs";
39
+ import { readFile } from "node:fs/promises";
40
+ import { createRequire } from "node:module";
41
+ import { extname, join, resolve } from "node:path";
42
+ import { serveSite, siteContext, siteHeaders } from "@jxsuite/site/serve";
43
+ import { SERVABLE_ROOTS } from "@jxsuite/site/paths";
44
+ import { buildSiteStyleCSS } from "@jxsuite/site/site-style";
45
+ import type { AssetIO, SiteContext } from "@jxsuite/site/serve";
46
+ import { readProjectConfig } from "@jxsuite/site/compose";
47
+ import type { DocumentParser, SiteIO } from "@jxsuite/site/compose";
48
+ import { buildProjectFormatRegistry } from "@jxsuite/compiler/format-host";
49
+ import type { JxDocument } from "@jxsuite/schema/types";
50
+ import { createSseHub } from "./sse.ts";
51
+ import type { SseHub } from "./sse.ts";
52
+ import { LIVE_NAMESPACE, PREVIEW_CLIENT_JS } from "./preview-client.ts";
53
+ import { handleResolve, handleServerFunction } from "./resolve.ts";
54
+ import { handleJxMounts } from "./jx-mounts.ts";
55
+ import { loopbackGate, originHostGate } from "./net-guard.ts";
56
+ import { problem } from "./problem.ts";
57
+
58
+ /** A running live preview for one project. */
59
+ export interface LivePreview {
60
+ /** Origin to prefix a route with, e.g. `http://127.0.0.1:41234`. */
61
+ origin: string;
62
+ port: number;
63
+ /** How many routes the tree currently answers — what a caller reports as `routes`. */
64
+ routes: number;
65
+ /** Problems the origin already knows about, named rather than thrown. */
66
+ errors: string[];
67
+ }
68
+
69
+ /**
70
+ * Coalescing window for a reload broadcast.
71
+ *
72
+ * Trailing, because a burst of keystrokes is one change as far as a reader is concerned. The max
73
+ * wait is not belt-and-braces: a git checkout emits hundreds of events closer together than the
74
+ * debounce, and a pure trailing timer would starve until the checkout finished.
75
+ */
76
+ const RELOAD_DEBOUNCE_MS = 150;
77
+ const RELOAD_MAX_WAIT_MS = 1000;
78
+
79
+ /**
80
+ * How long a retarget waits for a tab to say it heard.
81
+ *
82
+ * A closed tab's stream usually cancels promptly on loopback, but a frozen or back/forward-cached
83
+ * one looks open and will not act. So the answer is acknowledged rather than assumed, and when the
84
+ * wait loses the race the reader gets a second tab — the visible failure, chosen deliberately over
85
+ * the invisible one.
86
+ */
87
+ const ACK_TIMEOUT_MS = 250;
88
+
89
+ /** Total bytes of unsaved documents one project may hold before the oldest are dropped. */
90
+ const OVERLAY_MAX_BYTES = 8 * 1024 * 1024;
91
+
92
+ /**
93
+ * What Studio is looking at but has not written, per project.
94
+ *
95
+ * Deliberately NOT part of the running server. Studio may publish before anyone presses the button
96
+ * — and the flush that runs on the way to opening a tab does exactly that — so an overlay tied to
97
+ * an origin's lifetime would drop the newest edit precisely on the first render, which is the one
98
+ * the author is watching for.
99
+ */
100
+ interface Overlay {
101
+ files: Map<string, string>;
102
+ /** Paths the budget had to drop, so a caller can say so rather than showing stale content. */
103
+ dropped: string[];
104
+ }
105
+
106
+ const overlays = new Map<string, Overlay>();
107
+
108
+ /** This project's overlay, created on demand. */
109
+ function overlayFor(projectRoot: string): Overlay {
110
+ let overlay = overlays.get(projectRoot);
111
+ if (!overlay) {
112
+ overlay = { dropped: [], files: new Map() };
113
+ overlays.set(projectRoot, overlay);
114
+ }
115
+ return overlay;
116
+ }
117
+
118
+ interface Running extends LivePreview {
119
+ server: { stop: (closeActiveConnections?: boolean) => void };
120
+ hub: SseHub;
121
+ /** This origin's own resolver credential, distinct from any editing server's. */
122
+ token: string;
123
+ /** Bumped per retarget, so a late ack cannot answer for the current one. */
124
+ generation: number;
125
+ pendingAck: { gen: number; settle: (acked: boolean) => void } | null;
126
+ reloadTimer: ReturnType<typeof setTimeout> | null;
127
+ /** When the current burst started, so the max wait can fire through a storm of events. */
128
+ burstStartedAt: number | null;
129
+ /** Recomputed when the tree moves under it. */
130
+ cache: { paths: string[]; context: SiteContext } | null;
131
+ }
132
+
133
+ /** One origin per project root — a second Open in Browser reuses the first one's port. */
134
+ const running = new Map<string, Running>();
135
+
136
+ /** The runtime bundle is the same bytes for every project, so it is read once per process. */
137
+ let runtimeBundle: string | null = null;
138
+
139
+ /**
140
+ * The `@jxsuite/runtime` browser bundle.
141
+ *
142
+ * Preferred from the package's own `dist`, which is what it publishes and what a packaged app
143
+ * ships. A checkout that has not run `bun run build:runtime` has none, and building it here on
144
+ * first request is better than an origin that renders nothing until someone runs a script.
145
+ *
146
+ * That fallback is the one branch in this file no test reaches, and deliberately so: reaching it
147
+ * means `@jxsuite/runtime/dist/runtime.js` neither resolving nor existing, which in this repository
148
+ * it always does, and `mock.module` cannot override a `node:fs` builtin to pretend otherwise. It is
149
+ * exercised for real by any fresh clone that previews before it builds.
150
+ */
151
+ async function loadRuntimeBundle(): Promise<string> {
152
+ if (runtimeBundle !== null) {
153
+ return runtimeBundle;
154
+ }
155
+ const require_ = createRequire(import.meta.url);
156
+ try {
157
+ const built = require_.resolve("@jxsuite/runtime/dist/runtime.js");
158
+ if (existsSync(built)) {
159
+ runtimeBundle = await readFile(built, "utf8");
160
+ return runtimeBundle;
161
+ }
162
+ } catch {
163
+ // No published dist on this install; fall through and build one.
164
+ }
165
+ const result = await Bun.build({
166
+ entrypoints: [require_.resolve("@jxsuite/runtime")],
167
+ target: "browser",
168
+ });
169
+ runtimeBundle = await result.outputs[0]!.text();
170
+ return runtimeBundle;
171
+ }
172
+
173
+ /** Every path under a servable root, plus anything the overlay is holding. */
174
+ async function treePaths(projectRoot: string, overlay: Map<string, string>): Promise<string[]> {
175
+ const found = new Set<string>(overlay.keys());
176
+ for (const root of SERVABLE_ROOTS) {
177
+ const dir = resolve(projectRoot, root);
178
+ if (!existsSync(dir)) {
179
+ continue;
180
+ }
181
+ for await (const match of new Bun.Glob("**/*").scan({ cwd: dir, dot: false })) {
182
+ found.add(`${root}${match.replaceAll("\\", "/")}`);
183
+ }
184
+ }
185
+ return [...found];
186
+ }
187
+
188
+ /**
189
+ * Parse a non-JSON page through the project's own format registry.
190
+ *
191
+ * This is the capability a Worker does not have and a desktop backend does, and it is the whole
192
+ * difference between a markdown page rendering and reporting that its parser does not run here.
193
+ */
194
+ function documentParser(projectRoot: string): DocumentParser {
195
+ return async (path, text) => {
196
+ try {
197
+ /* `project.json` is the file that NAMES the extensions, so the registry is empty without it —
198
+ which is not "no parser for .md" but "no extensions at all", and the page reported that it
199
+ needed a parser this host does not run while the host was in fact running one. Read through
200
+ the overlay like every other read here, so adding `@jxsuite/parser` to the project renders
201
+ markdown without saving first. */
202
+ const config = await readProjectConfig({
203
+ paths: () => [],
204
+ read: (file) => readTreeFile(projectRoot, file),
205
+ });
206
+ const registry = await buildProjectFormatRegistry(projectRoot, config);
207
+ const entry = registry.byExtension(extname(path), "parse");
208
+ if (!entry) {
209
+ return null;
210
+ }
211
+ return (await entry.call("parse", text, { path })) as JxDocument;
212
+ } catch {
213
+ /* A format that throws is a broken project, not a broken preview: returning null lets the
214
+ composer report the page by name rather than taking the whole origin down. */
215
+ return null;
216
+ }
217
+ };
218
+ }
219
+
220
+ /** One file's text, overlay first — which is what makes this the canvas rather than the disk. */
221
+ async function readTreeFile(projectRoot: string, path: string): Promise<string | null> {
222
+ const overlaid = overlayFor(projectRoot).files.get(path);
223
+ if (overlaid !== undefined) {
224
+ return overlaid;
225
+ }
226
+ try {
227
+ return await readFile(join(projectRoot, path), "utf8");
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ /** Reading the tree, overlay first — which is what makes this the canvas rather than the disk. */
234
+ function makeIO(projectRoot: string, paths: string[]): { io: SiteIO; assets: AssetIO } {
235
+ const io: SiteIO = {
236
+ parse: documentParser(projectRoot),
237
+ paths: () => paths,
238
+ read: (path) => readTreeFile(projectRoot, path),
239
+ };
240
+ const assets: AssetIO = {
241
+ bytes: async (path) => {
242
+ const overlaid = overlayFor(projectRoot).files.get(path);
243
+ if (overlaid !== undefined) {
244
+ return new TextEncoder().encode(overlaid);
245
+ }
246
+ try {
247
+ return new Uint8Array(await readFile(join(projectRoot, path)));
248
+ } catch {
249
+ return null;
250
+ }
251
+ },
252
+ };
253
+ return { assets, io };
254
+ }
255
+
256
+ /** The route table and project config, recomputed only when something moved under them. */
257
+ async function ensureCache(entry: Running, projectRoot: string) {
258
+ if (entry.cache) {
259
+ return entry.cache;
260
+ }
261
+ const paths = await treePaths(projectRoot, overlayFor(projectRoot).files);
262
+ const { io } = makeIO(projectRoot, paths);
263
+ const context = await siteContext(io);
264
+ entry.cache = { context, paths };
265
+ entry.routes = context.routes.length;
266
+ return entry.cache;
267
+ }
268
+
269
+ /** Drop the derived view of the tree; the next request rebuilds it. */
270
+ function invalidate(entry: Running) {
271
+ entry.cache = null;
272
+ }
273
+
274
+ /** `project.json`'s `style` as a stylesheet, or empty when the project declares none. */
275
+ function siteStyleCss(context: SiteContext): string {
276
+ const { style } = context.config as { style?: Record<string, unknown> };
277
+ if (!style || typeof style !== "object") {
278
+ return "";
279
+ }
280
+ const media = (context.config as { $media?: Record<string, string> }).$media ?? {};
281
+ /* Identity transposition: a browser tab IS the viewport, so `100vh` already means what it says.
282
+ The canvas passes a real transposer because its viewport is an iframe it sizes to content. */
283
+ return buildSiteStyleCSS(style, media, (value) => value);
284
+ }
285
+
286
+ /** Serve one request on a preview origin. */
287
+ async function handle(entry: Running, projectRoot: string, req: Request): Promise<Response> {
288
+ const url = new URL(req.url);
289
+ let pathname: string;
290
+ try {
291
+ pathname = decodeURIComponent(url.pathname);
292
+ } catch {
293
+ return problem("invalidRequest", "Bad request");
294
+ }
295
+
296
+ /*
297
+ * The host's own namespace, ahead of everything. A project that happens to hold
298
+ * `pages/__jx_live__/x.json` loses that one route, which is the documented cost of having a
299
+ * namespace at all — and it is not in SERVABLE_ROOTS, so no project file is addressable here.
300
+ */
301
+ if (pathname.startsWith(`${LIVE_NAMESPACE}/`)) {
302
+ const surface = pathname.slice(LIVE_NAMESPACE.length + 1);
303
+
304
+ if (surface === "reload") {
305
+ return entry.hub.handleSSE(req);
306
+ }
307
+
308
+ /* What a client asks when EventSource has failed enough times that "restarting" and "gone" need
309
+ telling apart. A live origin always answers; a dead one refuses the connection. */
310
+ if (surface === "ping") {
311
+ return new Response("ok", { headers: siteHeaders("text/plain; charset=utf-8") });
312
+ }
313
+
314
+ if (surface === "ack" && req.method === "POST") {
315
+ let body: { gen?: number };
316
+ try {
317
+ body = (await req.json()) as { gen?: number };
318
+ } catch {
319
+ return problem("invalidRequest", "Invalid JSON body");
320
+ }
321
+ if (entry.pendingAck && body.gen === entry.pendingAck.gen) {
322
+ entry.pendingAck.settle(true);
323
+ }
324
+ return new Response(null, { status: 204 });
325
+ }
326
+
327
+ if (surface === "runtime.js") {
328
+ return new Response(await loadRuntimeBundle(), {
329
+ headers: {
330
+ /* Immutable: the bundle is this build's, and a preview reload must not re-fetch 120 KB
331
+ of runtime every time a keystroke lands. */
332
+ "Cache-Control": "public, max-age=31536000, immutable",
333
+ "Content-Type": "text/javascript; charset=utf-8",
334
+ },
335
+ });
336
+ }
337
+
338
+ if (surface === "client.js") {
339
+ return new Response(PREVIEW_CLIENT_JS, {
340
+ headers: {
341
+ "Cache-Control": "no-cache",
342
+ "Content-Type": "text/javascript; charset=utf-8",
343
+ },
344
+ });
345
+ }
346
+
347
+ if (surface === "site.css") {
348
+ const { context } = await ensureCache(entry, projectRoot);
349
+ return new Response(siteStyleCss(context), {
350
+ headers: siteHeaders("text/css; charset=utf-8"),
351
+ });
352
+ }
353
+
354
+ return problem("notFound", "Not found");
355
+ }
356
+
357
+ /*
358
+ * The resolver, on this origin's OWN credential.
359
+ *
360
+ * Both routes do a dynamic `import()` of project code, so they are gated exactly as the editing
361
+ * server gates them — token, Origin, Host, Fetch Metadata — but against a token minted for this
362
+ * origin, so compromising one does not hand over the other. The page's own POST is same-origin,
363
+ * which the strict policy admits.
364
+ *
365
+ * Without them a content collection renders as an empty list: `ContentEntry` always needs a
366
+ * server. That is the difference between previewing a site and previewing its chrome.
367
+ */
368
+ if ((pathname === "/__jx_resolve__" || pathname === "/__jx_server__") && req.method === "POST") {
369
+ const gate = loopbackGate(req, url, entry.token);
370
+ if (gate) {
371
+ return gate;
372
+ }
373
+ return pathname === "/__jx_resolve__"
374
+ ? handleResolve(req, projectRoot, projectRoot)
375
+ : handleServerFunction(req, projectRoot);
376
+ }
377
+
378
+ /* Extension server mounts — a previewed page's content and data endpoints. Origin-gated rather
379
+ than token-gated for the reason the editing server gives: a page cannot rewrite the URLs its
380
+ own content asks for, so a token is the wrong instrument. */
381
+ if (pathname.startsWith("/_jx/")) {
382
+ const gate = originHostGate(req, "embeddable");
383
+ if (gate) {
384
+ return gate;
385
+ }
386
+ const mounted = await handleJxMounts(req, url, projectRoot);
387
+ if (mounted) {
388
+ return mounted;
389
+ }
390
+ }
391
+
392
+ const { context, paths } = await ensureCache(entry, projectRoot);
393
+ const { assets, io } = makeIO(projectRoot, paths);
394
+ return serveSite(pathname, io, assets, context, {
395
+ shell: {
396
+ base: "/",
397
+ clientScriptUrl: `${LIVE_NAMESPACE}/client.js`,
398
+ resolveToken: entry.token,
399
+ runtimeUrl: `${LIVE_NAMESPACE}/runtime.js`,
400
+ styleUrl: `${LIVE_NAMESPACE}/site.css`,
401
+ },
402
+ });
403
+ }
404
+
405
+ /**
406
+ * Start (or reuse) the live preview origin for `projectRoot`.
407
+ *
408
+ * Always answers: unlike `startSitePreview`, there is nothing to have built first. A project with
409
+ * no pages yet gets an origin that 404s honestly rather than no origin at all.
410
+ */
411
+ export async function startLivePreview(projectRoot: string): Promise<LivePreview> {
412
+ const existing = running.get(projectRoot);
413
+ if (existing) {
414
+ await ensureCache(existing, projectRoot);
415
+ return report(existing, projectRoot);
416
+ }
417
+
418
+ const entry: Running = {
419
+ burstStartedAt: null,
420
+ cache: null,
421
+ errors: [],
422
+ generation: 0,
423
+ hub: createSseHub(),
424
+ origin: "",
425
+ pendingAck: null,
426
+ port: 0,
427
+ reloadTimer: null,
428
+ routes: 0,
429
+ server: { stop: () => {} },
430
+ token: crypto.randomUUID(),
431
+ };
432
+
433
+ const server = Bun.serve({
434
+ fetch: (req) => handle(entry, projectRoot, req),
435
+ hostname: "127.0.0.1",
436
+ /* The composed page is assembled per request and the stream is long-lived; the dev server's
437
+ generous idle timeout applies here for the same reason. */
438
+ idleTimeout: 120,
439
+ port: 0,
440
+ });
441
+ entry.server = server;
442
+ // `server.url` rather than `server.port`: the origin is the thing being handed to a browser, and
443
+ // Reading it from the server that is actually listening cannot disagree with it.
444
+ entry.origin = server.url.origin;
445
+ entry.port = Number(server.url.port);
446
+ running.set(projectRoot, entry);
447
+ /* Counted before answering, because the caller reports it to the author and "0 pages" from a site
448
+ with pages is worse than the moment it costs to walk the tree. */
449
+ await ensureCache(entry, projectRoot);
450
+ return report(entry, projectRoot);
451
+ }
452
+
453
+ /** What a caller hands back to Studio. */
454
+ function report(entry: Running, projectRoot: string): LivePreview {
455
+ return {
456
+ errors: overlayErrors(projectRoot),
457
+ origin: entry.origin,
458
+ port: entry.port,
459
+ routes: entry.routes,
460
+ };
461
+ }
462
+
463
+ /** The origin already serving `projectRoot`, without starting one. */
464
+ export function livePreviewOrigin(projectRoot: string): string | null {
465
+ return running.get(projectRoot)?.origin ?? null;
466
+ }
467
+
468
+ /** How many tabs are holding this project's reload stream. */
469
+ export function livePreviewClients(projectRoot: string): number {
470
+ return running.get(projectRoot)?.hub.clientCount() ?? 0;
471
+ }
472
+
473
+ /**
474
+ * Publish the bytes a save WOULD write for one document.
475
+ *
476
+ * Byte-identical to a save by construction — Studio serializes through the same function
477
+ * `writeFile` uses — so what the reader sees and what saving would produce cannot drift.
478
+ */
479
+ export function setLivePreviewOverlay(projectRoot: string, path: string, contents: string): void {
480
+ const overlay = overlayFor(projectRoot);
481
+ if (overlay.files.get(path) === contents) {
482
+ /* Identical bytes are not a change. Studio hashes before publishing too, but a backend that
483
+ reloads a tab because a keystroke was undone would be wrong on its own account. */
484
+ return;
485
+ }
486
+ overlay.files.set(path, contents);
487
+ enforceOverlayBudget(overlay);
488
+ touched(projectRoot);
489
+ }
490
+
491
+ /** Drop one document's overlay, or every one of them. */
492
+ export function clearLivePreviewOverlay(projectRoot: string, path?: string): void {
493
+ const overlay = overlays.get(projectRoot);
494
+ if (!overlay) {
495
+ return;
496
+ }
497
+ if (path === undefined) {
498
+ if (overlay.files.size === 0 && overlay.dropped.length === 0) {
499
+ return;
500
+ }
501
+ overlay.files.clear();
502
+ overlay.dropped = [];
503
+ } else if (!overlay.files.delete(path)) {
504
+ return;
505
+ }
506
+ touched(projectRoot);
507
+ }
508
+
509
+ /**
510
+ * Keep the overlay bounded, and SAY what was given up.
511
+ *
512
+ * An overlay that silently forgets a document is worse than one that admits it: the reader would be
513
+ * shown the saved bytes for a file the author is actively editing, with nothing anywhere to explain
514
+ * the difference. Insertion order is edit order, so the oldest entry is the least recently
515
+ * written.
516
+ */
517
+ function enforceOverlayBudget(overlay: Overlay): void {
518
+ let total = 0;
519
+ for (const value of overlay.files.values()) {
520
+ total += value.length;
521
+ }
522
+ while (total > OVERLAY_MAX_BYTES && overlay.files.size > 1) {
523
+ const oldest = overlay.files.keys().next().value as string;
524
+ total -= overlay.files.get(oldest)?.length ?? 0;
525
+ overlay.files.delete(oldest);
526
+ if (!overlay.dropped.includes(oldest)) {
527
+ overlay.dropped.push(oldest);
528
+ }
529
+ }
530
+ }
531
+
532
+ /** What a caller reports beside the routes: the unsaved documents this origin gave up on. */
533
+ function overlayErrors(projectRoot: string): string[] {
534
+ return (overlays.get(projectRoot)?.dropped ?? []).map(
535
+ (path) => `${path} is too large to preview unsaved; it is shown as last saved.`,
536
+ );
537
+ }
538
+
539
+ /** The tree changed, from whichever side. Invalidate the derived view and coalesce a reload. */
540
+ function touched(projectRoot: string): void {
541
+ const entry = running.get(projectRoot);
542
+ if (!entry) {
543
+ return;
544
+ }
545
+ invalidate(entry);
546
+ scheduleReload(entry);
547
+ }
548
+
549
+ /** Something moved on disk — a save, a git checkout, another editor. */
550
+ export function notifyLivePreviewChange(projectRoot: string): void {
551
+ touched(projectRoot);
552
+ }
553
+
554
+ /**
555
+ * Coalesce a burst into one reload.
556
+ *
557
+ * A save fires twice — the overlay clears and the watcher reports the write — and both compose to
558
+ * identical bytes, so folding them here is what makes one save one reload.
559
+ */
560
+ function scheduleReload(entry: Running): void {
561
+ const now = Date.now();
562
+ entry.burstStartedAt ??= now;
563
+ if (entry.reloadTimer) {
564
+ clearTimeout(entry.reloadTimer);
565
+ }
566
+ const sinceBurstStart = now - entry.burstStartedAt;
567
+ const delay = Math.max(0, Math.min(RELOAD_DEBOUNCE_MS, RELOAD_MAX_WAIT_MS - sinceBurstStart));
568
+ entry.reloadTimer = setTimeout(() => {
569
+ entry.reloadTimer = null;
570
+ entry.burstStartedAt = null;
571
+ entry.hub.broadcast();
572
+ }, delay);
573
+ }
574
+
575
+ /**
576
+ * Point the project's open tab at `route`, and report whether one actually took it.
577
+ *
578
+ * `false` means the caller should open a tab itself: either nothing is connected, or what is
579
+ * connected did not answer inside {@link ACK_TIMEOUT_MS}. The acknowledgement is what keeps a
580
+ * frozen or back/forward-cached tab — which looks connected and will not act — from swallowing the
581
+ * request silently.
582
+ */
583
+ export async function navigateLivePreview(projectRoot: string, route: string): Promise<boolean> {
584
+ const entry = running.get(projectRoot);
585
+ if (!entry || entry.hub.clientCount() === 0) {
586
+ return false;
587
+ }
588
+ entry.generation += 1;
589
+ const gen = entry.generation;
590
+ const acked = new Promise<boolean>((settle) => {
591
+ entry.pendingAck = { gen, settle };
592
+ setTimeout(() => {
593
+ if (entry.pendingAck?.gen === gen) {
594
+ entry.pendingAck = null;
595
+ settle(false);
596
+ }
597
+ }, ACK_TIMEOUT_MS);
598
+ });
599
+ entry.hub.broadcastEvent("navigate", { gen, route });
600
+ const result = await acked;
601
+ if (entry.pendingAck?.gen === gen) {
602
+ entry.pendingAck = null;
603
+ }
604
+ return result;
605
+ }
606
+
607
+ /** Stop every preview origin (process teardown, and what the tests close their ports with). */
608
+ export function stopLivePreviews(): void {
609
+ for (const entry of running.values()) {
610
+ if (entry.reloadTimer) {
611
+ clearTimeout(entry.reloadTimer);
612
+ }
613
+ entry.server.stop(true);
614
+ }
615
+ running.clear();
616
+ }
package/src/packages.ts CHANGED
@@ -16,11 +16,19 @@ export interface PackageInfo {
16
16
  dev?: boolean;
17
17
  }
18
18
 
19
- export interface OutdatedInfo {
19
+ /**
20
+ * A dependency's pinned range beside the newest version published for it on the registry.
21
+ *
22
+ * Every registry-range dependency gets a row, behind or not. Deciding whether a row is an upgrade
23
+ * needs a semver comparison against the range's base, and the caller — which has to make that
24
+ * comparison anyway to tell an upgrade from a project deliberately pinned ahead — is where it
25
+ * belongs. A backend that filtered here could only describe an up-to-date package as an absence.
26
+ */
27
+ export interface PackageVersionInfo {
20
28
  name: string;
21
29
  /** The version range pinned in package.json (e.g. "^0.19.0"). */
22
30
  current: string;
23
- /** The newest published version. */
31
+ /** The newest version published for this package on the registry. */
24
32
  latest: string;
25
33
  dev?: boolean;
26
34
  }
@@ -165,31 +173,41 @@ export async function fetchLatestVersion(
165
173
  }
166
174
 
167
175
  /**
168
- * List dependencies whose newest published version differs from the pinned base. Registry lookups
169
- * run concurrently; non-registry specs (workspace:, file:, …) and lookup failures are skipped.
176
+ * The newest published version of every dependency, asked of the registry package by package.
177
+ *
178
+ * Lookups run concurrently; non-registry specs (workspace:, file:, …) and lookup failures are
179
+ * skipped, because there is no honest answer for either and a guess is worse than a blank.
180
+ *
181
+ * This used to be `outdatedPackages`, which dropped any package whose latest matched its pinned
182
+ * base. Two assumptions were folded into that filter and both have since gone: that the only caller
183
+ * wanted a to-do list, and that `latest !== base` is the same question as "is there an upgrade" (it
184
+ * is not — a project pinned ahead of the registry, on a prerelease or a range bumped before the
185
+ * publish landed, differs from latest in the other direction). Reporting the registry's answer for
186
+ * every package leaves both judgements to the caller and lets the Packages table show a Latest
187
+ * column for rows that are already current.
170
188
  */
171
- export async function outdatedPackages(
189
+ export async function packageVersions(
172
190
  root: string,
173
191
  fetchImpl: FetchLike = fetch,
174
- ): Promise<OutdatedInfo[]> {
192
+ ): Promise<PackageVersionInfo[]> {
175
193
  const pkgs = await listPackages(root);
176
194
  const checks = await Promise.all(
177
- pkgs.map(async (p): Promise<OutdatedInfo | null> => {
195
+ pkgs.map(async (p): Promise<PackageVersionInfo | null> => {
178
196
  if (!isRegistryRange(p.version)) {
179
197
  return null;
180
198
  }
181
199
  const latest = await fetchLatestVersion(p.name, fetchImpl);
182
- if (!latest || latest === stripRange(p.version)) {
200
+ if (!latest) {
183
201
  return null;
184
202
  }
185
- const entry: OutdatedInfo = { current: p.version, latest, name: p.name };
203
+ const entry: PackageVersionInfo = { current: p.version, latest, name: p.name };
186
204
  if (p.dev) {
187
205
  entry.dev = true;
188
206
  }
189
207
  return entry;
190
208
  }),
191
209
  );
192
- return checks.filter((entry): entry is OutdatedInfo => entry !== null);
210
+ return checks.filter((entry): entry is PackageVersionInfo => entry !== null);
193
211
  }
194
212
 
195
213
  /**