@jxsuite/server 8.7.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;
@@ -305,6 +333,121 @@ function siteStyleCss(context: SiteContext): string {
305
333
  return buildSiteStyleCSS(style, media, (value) => value);
306
334
  }
307
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
+
308
451
  /** Serve one request on a preview origin. */
309
452
  async function handle(entry: Running, projectRoot: string, req: Request): Promise<Response> {
310
453
  const url = new URL(req.url);
@@ -323,6 +466,18 @@ async function handle(entry: Running, projectRoot: string, req: Request): Promis
323
466
  if (pathname.startsWith(`${LIVE_NAMESPACE}/`)) {
324
467
  const surface = pathname.slice(LIVE_NAMESPACE.length + 1);
325
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
+
326
481
  if (surface === "reload") {
327
482
  return entry.hub.handleSSE(req);
328
483
  }
@@ -385,6 +540,10 @@ async function handle(entry: Running, projectRoot: string, req: Request): Promis
385
540
  });
386
541
  }
387
542
 
543
+ if (pathname.startsWith(HARNESS_ROUTE) && req.method === "GET") {
544
+ return serveHarness(entry, projectRoot, url);
545
+ }
546
+
388
547
  return problem("notFound", "Not found");
389
548
  }
390
549
 
@@ -425,16 +584,7 @@ async function handle(entry: Running, projectRoot: string, req: Request): Promis
425
584
 
426
585
  const { context, paths } = await ensureCache(entry, projectRoot);
427
586
  const { assets, io } = makeIO(projectRoot, paths);
428
- return serveSite(pathname, io, assets, context, {
429
- shell: {
430
- base: "/",
431
- clientScriptUrl: `${LIVE_NAMESPACE}/client.js`,
432
- eraserUrl: `${LIVE_NAMESPACE}/eraser.js`,
433
- resolveToken: entry.token,
434
- runtimeUrl: `${LIVE_NAMESPACE}/runtime.js`,
435
- styleUrl: `${LIVE_NAMESPACE}/site.css`,
436
- },
437
- });
587
+ return serveSite(pathname, io, assets, context, { shell: shellOptions(entry) });
438
588
  }
439
589
 
440
590
  /**
@@ -468,6 +618,8 @@ async function listen(projectRoot: string): Promise<Running> {
468
618
  cache: null,
469
619
  errors: [],
470
620
  generation: 0,
621
+ harnessGrant: null,
622
+ harnessKey: mintHarnessKey(),
471
623
  hub: createSseHub(),
472
624
  origin: "",
473
625
  pendingAck: null,
@@ -625,12 +777,28 @@ function scheduleReload(entry: Running): void {
625
777
  * connected did not answer inside {@link ACK_TIMEOUT_MS}. The acknowledgement is what keeps a
626
778
  * frozen or back/forward-cached tab — which looks connected and will not act — from swallowing the
627
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}).
628
785
  */
629
786
  export async function navigateLivePreview(projectRoot: string, route: string): Promise<boolean> {
630
787
  const entry = running.get(projectRoot);
631
- if (!entry || entry.hub.clientCount() === 0) {
788
+ if (!entry) {
632
789
  return false;
633
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
+ };
797
+ return false;
798
+ };
799
+ if (entry.hub.clientCount() === 0) {
800
+ return grant();
801
+ }
634
802
  entry.generation += 1;
635
803
  const gen = entry.generation;
636
804
  const acked = new Promise<boolean>((settle) => {
@@ -642,12 +810,12 @@ export async function navigateLivePreview(projectRoot: string, route: string): P
642
810
  }
643
811
  }, ACK_TIMEOUT_MS);
644
812
  });
645
- entry.hub.broadcastEvent("navigate", { gen, route });
813
+ entry.hub.broadcastEvent("navigate", { gen, route: harness?.keyed ?? route });
646
814
  const result = await acked;
647
815
  if (entry.pendingAck?.gen === gen) {
648
816
  entry.pendingAck = null;
649
817
  }
650
- return result;
818
+ return result || grant();
651
819
  }
652
820
 
653
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();