@jxsuite/server 8.6.0 → 9.0.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.
@@ -23,9 +23,10 @@
23
23
  *
24
24
  * What is served here that a published site would not have is confined to one namespace,
25
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.
26
+ * stylesheet, the reload stream, and the harness that runs one component or layout on its own
27
+ * (`preview-harness.ts`). Not `/_jx/` — that is the extension-mount namespace, which a previewed
28
+ * page still needs — and not `/__studio__/`, because a preview origin must not look like the
29
+ * editor's.
29
30
  *
30
31
  * **The overlay is why this shows the canvas rather than the disk.** Studio publishes the bytes a
31
32
  * save WOULD write, per dirty document, and every read here prefers them. They are held in memory
@@ -40,19 +41,23 @@ import { readFile } from "node:fs/promises";
40
41
  import { createRequire } from "node:module";
41
42
  import { extname, join, resolve } from "node:path";
42
43
  import { serveSite, siteContext, siteHeaders } from "@jxsuite/site/serve";
44
+ import { pageShell, problemShell } from "@jxsuite/site/shell";
45
+ import type { ShellOptions } from "@jxsuite/site/shell";
43
46
  import { SERVABLE_ROOTS } from "@jxsuite/site/paths";
44
47
  import { buildSiteStyleCSS } from "@jxsuite/site/site-style";
45
48
  import type { AssetIO, SiteContext, SiteTree } from "@jxsuite/site/serve";
46
- import { readProjectConfig } from "@jxsuite/site/compose";
49
+ import { ComposeError, readProjectConfig } from "@jxsuite/site/compose";
47
50
  import type { DocumentParser, SiteIO } from "@jxsuite/site/compose";
48
51
  import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
49
52
  import { isNonNullObject } from "@jxsuite/schema/guards";
50
53
  import { createSseHub } from "./sse.ts";
51
54
  import type { SseHub } from "./sse.ts";
52
55
  import { LIVE_NAMESPACE, PREVIEW_CLIENT_JS } from "./preview-client.ts";
56
+ import { HARNESS_KEY_PARAM, composeHarness, harnessTarget } from "./preview-harness.ts";
57
+ import { HARNESS_ROUTE } from "@jxsuite/protocol/preview-harness";
53
58
  import { handleResolve, handleServerFunction } from "./resolve.ts";
54
59
  import { handleJxMounts } from "./jx-mounts.ts";
55
- import { loopbackGate, originHostGate } from "./net-guard.ts";
60
+ import { hostIsLoopbackOrAbsent, loopbackGate, originHostGate, secretsMatch } from "./net-guard.ts";
56
61
  import { problem } from "./problem.ts";
57
62
  import { scanFiles } from "./fs-glob.ts";
58
63
  import { serve } from "#serve";
@@ -89,6 +94,14 @@ const RELOAD_MAX_WAIT_MS = 1000;
89
94
  */
90
95
  const ACK_TIMEOUT_MS = 250;
91
96
 
97
+ /**
98
+ * How long an address the opener asked for may be exchanged for its keyed form.
99
+ *
100
+ * The caller opens a tab the moment it is told none took the route, but "opens" is the operating
101
+ * system starting a browser, which on a cold machine is not quick.
102
+ */
103
+ const HARNESS_GRANT_MS = 120_000;
104
+
92
105
  /** Total bytes of unsaved documents one project may hold before the oldest are dropped. */
93
106
  const OVERLAY_MAX_BYTES = 8 * 1024 * 1024;
94
107
 
@@ -123,6 +136,21 @@ interface Running extends LivePreview {
123
136
  hub: SseHub;
124
137
  /** This origin's own resolver credential, distinct from any editing server's. */
125
138
  token: string;
139
+ /**
140
+ * What a harness address must carry before its test values are applied (`preview-harness.ts`).
141
+ *
142
+ * Minted with the origin and never logged or written into a page: the only place it appears is
143
+ * the address this origin sends its own opener's tab to.
144
+ */
145
+ harnessKey: string;
146
+ /**
147
+ * The one harness address the opener has asked for and no tab has fetched yet.
148
+ *
149
+ * The opener is told an origin and joins the route to it itself, so the first request for an
150
+ * address it asked for arrives without the key. This is how that request is told from one anybody
151
+ * else wrote: it is for exactly the address the opener named, once, and soon.
152
+ */
153
+ harnessGrant: { address: string; expires: number } | null;
126
154
  /** Bumped per retarget, so a late ack cannot answer for the current one. */
127
155
  generation: number;
128
156
  pendingAck: { gen: number; settle: (acked: boolean) => void } | null;
@@ -174,6 +202,22 @@ async function loadRuntimeBundle(): Promise<string> {
174
202
  return runtimeBundle;
175
203
  }
176
204
 
205
+ /** The body eraser's browser bundle, built once per process, the first time a page asks for it. */
206
+ let eraserBundle: string | null = null;
207
+
208
+ /**
209
+ * `@jxsuite/erase/browser` as one browser module (`spec.md` §23.1). A page fetches it only when a
210
+ * function body is TypeScript, so most previews never ask, and none pays for it at startup. A build
211
+ * that fails is not kept, so the next page that needs it tries again.
212
+ */
213
+ async function loadEraserBundle(): Promise<string> {
214
+ eraserBundle ??= await bundleToString(
215
+ createRequire(import.meta.url).resolve("@jxsuite/erase/browser"),
216
+ { minify: true, target: "browser" },
217
+ );
218
+ return eraserBundle;
219
+ }
220
+
177
221
  /** Every path under a servable root, plus anything the overlay is holding. */
178
222
  async function treePaths(projectRoot: string, overlay: Map<string, string>): Promise<string[]> {
179
223
  const found = new Set<string>(overlay.keys());
@@ -289,6 +333,121 @@ function siteStyleCss(context: SiteContext): string {
289
333
  return buildSiteStyleCSS(style, media, (value) => value);
290
334
  }
291
335
 
336
+ /** How this origin shells a page, whichever lane composed it. */
337
+ function shellOptions(entry: Running): ShellOptions {
338
+ return {
339
+ base: "/",
340
+ clientScriptUrl: `${LIVE_NAMESPACE}/client.js`,
341
+ eraserUrl: `${LIVE_NAMESPACE}/eraser.js`,
342
+ resolveToken: entry.token,
343
+ runtimeUrl: `${LIVE_NAMESPACE}/runtime.js`,
344
+ styleUrl: `${LIVE_NAMESPACE}/site.css`,
345
+ };
346
+ }
347
+
348
+ /** A page that says what stopped a harness from rendering, at the status that names whose it is. */
349
+ function harnessProblem(message: string, status: number): Response {
350
+ return new Response(problemShell(message), {
351
+ headers: siteHeaders("text/html; charset=utf-8"),
352
+ status,
353
+ });
354
+ }
355
+
356
+ /** A fresh harness key: 256 random bits, spelled so it needs no encoding in a query. */
357
+ function mintHarnessKey(): string {
358
+ return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) =>
359
+ byte.toString(16).padStart(2, "0"),
360
+ ).join("");
361
+ }
362
+
363
+ /**
364
+ * A harness address that carries test values, as asked for and as this origin will answer it.
365
+ *
366
+ * Null for anything else: a route, a harness address with no values (which needs no key), an
367
+ * address on another origin, and text that is no address.
368
+ */
369
+ function harnessAddresses(entry: Running, route: string): { asked: string; keyed: string } | null {
370
+ let url: URL;
371
+ try {
372
+ url = new URL(route, entry.origin);
373
+ } catch {
374
+ return null;
375
+ }
376
+ if (
377
+ url.origin !== entry.origin ||
378
+ !url.pathname.startsWith(HARNESS_ROUTE) ||
379
+ !url.searchParams.has("props")
380
+ ) {
381
+ return null;
382
+ }
383
+ const asked = `${url.pathname}${url.search}`;
384
+ return { asked, keyed: `${asked}&${HARNESS_KEY_PARAM}=${entry.harnessKey}` };
385
+ }
386
+
387
+ /**
388
+ * Whether this request is the opener's own first fetch of an address it asked for.
389
+ *
390
+ * Spent by the asking, in time or past it, so it answers one request.
391
+ */
392
+ function takeHarnessGrant(entry: Running, address: string): boolean {
393
+ const grant = entry.harnessGrant;
394
+ if (!grant || grant.address !== address) {
395
+ return false;
396
+ }
397
+ entry.harnessGrant = null;
398
+ return Date.now() <= grant.expires;
399
+ }
400
+
401
+ /**
402
+ * Run one component or layout on its own (`preview-harness.ts`).
403
+ *
404
+ * Composed and shelled exactly as a route is, so the reload client is on the page and an edit to
405
+ * the component reloads it. What differs is only where the document came from.
406
+ *
407
+ * Test values are applied only to an address carrying this origin's key. One that carries values
408
+ * and no key is either the opener's first fetch, which is sent on to the keyed address, or somebody
409
+ * else's writing, which renders the component with its defaults.
410
+ *
411
+ * @param entry The running origin.
412
+ * @param projectRoot The project it serves.
413
+ * @param url The request's address. Its path is read as SENT, not decoded: the harness normalizes
414
+ * it the way every other read on this origin does, and decoding twice would turn `%252e` into a
415
+ * dot.
416
+ */
417
+ async function serveHarness(entry: Running, projectRoot: string, url: URL): Promise<Response> {
418
+ const surfacePath = url.pathname.slice(HARNESS_ROUTE.length);
419
+ const { search } = url;
420
+ const presented = url.searchParams.get(HARNESS_KEY_PARAM);
421
+ const keyed = presented !== null && secretsMatch(presented, entry.harnessKey);
422
+ if (presented === null && takeHarnessGrant(entry, `${url.pathname}${search}`)) {
423
+ return new Response(null, {
424
+ headers: {
425
+ ...siteHeaders("text/plain; charset=utf-8"),
426
+ Location: `${url.pathname}${search}&${HARNESS_KEY_PARAM}=${entry.harnessKey}`,
427
+ },
428
+ status: 302,
429
+ });
430
+ }
431
+ const { context, paths } = await ensureCache(entry, projectRoot);
432
+ const target = harnessTarget(surfacePath, paths);
433
+ if (!target) {
434
+ /* Not the path back: an address is anyone's to write, and this one named nothing here. */
435
+ return harnessProblem("This address names no component or layout in this project.", 404);
436
+ }
437
+ const { io } = makeIO(projectRoot, paths);
438
+ try {
439
+ const page = await composeHarness(io, context.config, target, search, keyed);
440
+ return new Response(pageShell(page, shellOptions(entry)), {
441
+ headers: siteHeaders("text/html; charset=utf-8"),
442
+ });
443
+ } catch (error) {
444
+ if (error instanceof ComposeError) {
445
+ return harnessProblem(error.message, 500);
446
+ }
447
+ throw error;
448
+ }
449
+ }
450
+
292
451
  /** Serve one request on a preview origin. */
293
452
  async function handle(entry: Running, projectRoot: string, req: Request): Promise<Response> {
294
453
  const url = new URL(req.url);
@@ -307,6 +466,18 @@ async function handle(entry: Running, projectRoot: string, req: Request): Promis
307
466
  if (pathname.startsWith(`${LIVE_NAMESPACE}/`)) {
308
467
  const surface = pathname.slice(LIVE_NAMESPACE.length + 1);
309
468
 
469
+ /* The two places the harness key travels: a retarget's event on the stream, and the redirect
470
+ and address of the harness itself. A page on a name its owner points at this machine is
471
+ same-origin with what answers here, so it would read both, and with the key it could send
472
+ the author's browser to this origin's real address carrying values of its own. Its `Host`
473
+ is that name, and no reader of this origin has one that is not loopback. */
474
+ if (
475
+ (surface === "reload" || pathname.startsWith(HARNESS_ROUTE)) &&
476
+ !hostIsLoopbackOrAbsent(req)
477
+ ) {
478
+ return problem("forbidden", "Forbidden");
479
+ }
480
+
310
481
  if (surface === "reload") {
311
482
  return entry.hub.handleSSE(req);
312
483
  }
@@ -344,6 +515,15 @@ async function handle(entry: Running, projectRoot: string, req: Request): Promis
344
515
  });
345
516
  }
346
517
 
518
+ if (surface === "eraser.js") {
519
+ return new Response(await loadEraserBundle(), {
520
+ headers: {
521
+ "Cache-Control": "public, max-age=31536000, immutable",
522
+ "Content-Type": "text/javascript; charset=utf-8",
523
+ },
524
+ });
525
+ }
526
+
347
527
  if (surface === "client.js") {
348
528
  return new Response(PREVIEW_CLIENT_JS, {
349
529
  headers: {
@@ -360,6 +540,10 @@ async function handle(entry: Running, projectRoot: string, req: Request): Promis
360
540
  });
361
541
  }
362
542
 
543
+ if (pathname.startsWith(HARNESS_ROUTE) && req.method === "GET") {
544
+ return serveHarness(entry, projectRoot, url);
545
+ }
546
+
363
547
  return problem("notFound", "Not found");
364
548
  }
365
549
 
@@ -400,15 +584,7 @@ async function handle(entry: Running, projectRoot: string, req: Request): Promis
400
584
 
401
585
  const { context, paths } = await ensureCache(entry, projectRoot);
402
586
  const { assets, io } = makeIO(projectRoot, paths);
403
- return serveSite(pathname, io, assets, context, {
404
- shell: {
405
- base: "/",
406
- clientScriptUrl: `${LIVE_NAMESPACE}/client.js`,
407
- resolveToken: entry.token,
408
- runtimeUrl: `${LIVE_NAMESPACE}/runtime.js`,
409
- styleUrl: `${LIVE_NAMESPACE}/site.css`,
410
- },
411
- });
587
+ return serveSite(pathname, io, assets, context, { shell: shellOptions(entry) });
412
588
  }
413
589
 
414
590
  /**
@@ -442,6 +618,8 @@ async function listen(projectRoot: string): Promise<Running> {
442
618
  cache: null,
443
619
  errors: [],
444
620
  generation: 0,
621
+ harnessGrant: null,
622
+ harnessKey: mintHarnessKey(),
445
623
  hub: createSseHub(),
446
624
  origin: "",
447
625
  pendingAck: null,
@@ -599,11 +777,27 @@ function scheduleReload(entry: Running): void {
599
777
  * connected did not answer inside {@link ACK_TIMEOUT_MS}. The acknowledgement is what keeps a
600
778
  * frozen or back/forward-cached tab — which looks connected and will not act — from swallowing the
601
779
  * request silently.
780
+ *
781
+ * This is also where a harness address gets its key, because the caller is this origin's opener and
782
+ * nobody else can reach here. A tab that takes the route is sent the keyed address. When none does,
783
+ * the caller opens the address it asked for, without a key it never saw, so that one address is
784
+ * granted one exchange for the keyed form ({@link takeHarnessGrant}).
602
785
  */
603
786
  export async function navigateLivePreview(projectRoot: string, route: string): Promise<boolean> {
604
787
  const entry = running.get(projectRoot);
605
- if (!entry || entry.hub.clientCount() === 0) {
788
+ if (!entry) {
789
+ return false;
790
+ }
791
+ const harness = harnessAddresses(entry, route);
792
+ const grant = () => {
793
+ entry.harnessGrant = harness && {
794
+ address: harness.asked,
795
+ expires: Date.now() + HARNESS_GRANT_MS,
796
+ };
606
797
  return false;
798
+ };
799
+ if (entry.hub.clientCount() === 0) {
800
+ return grant();
607
801
  }
608
802
  entry.generation += 1;
609
803
  const gen = entry.generation;
@@ -616,12 +810,12 @@ export async function navigateLivePreview(projectRoot: string, route: string): P
616
810
  }
617
811
  }, ACK_TIMEOUT_MS);
618
812
  });
619
- entry.hub.broadcastEvent("navigate", { gen, route });
813
+ entry.hub.broadcastEvent("navigate", { gen, route: harness?.keyed ?? route });
620
814
  const result = await acked;
621
815
  if (entry.pendingAck?.gen === gen) {
622
816
  entry.pendingAck = null;
623
817
  }
624
- return result;
818
+ return result || grant();
625
819
  }
626
820
 
627
821
  /** Stop every preview origin (process teardown, and what the tests close their ports with). */
@@ -126,7 +126,12 @@ function connect() {
126
126
  try {
127
127
  window.focus();
128
128
  } catch {}
129
- if (message.route === location.pathname) {
129
+ /* "Already there" is the whole address, not its path. A harness address carries a component's
130
+ test values in its query, so a retarget that changed or cleared them names this tab's path
131
+ and a different page: reloading would re-request the values the author just replaced. Both
132
+ sides are read through the URL parser, which spells a query one way however it was sent. */
133
+ const target = new URL(message.route, location.href);
134
+ if (target.pathname === location.pathname && target.search === location.search) {
130
135
  reload();
131
136
  } else {
132
137
  saveScroll();