@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.
- package/package.json +17 -16
- package/src/code-api.ts +11 -24
- package/src/code-format.ts +134 -0
- package/src/dev.ts +30 -12
- package/src/format.ts +271 -0
- package/src/live-preview.ts +186 -18
- package/src/preview-client.ts +6 -1
- package/src/preview-harness.ts +428 -0
- package/src/resolve.ts +78 -23
- package/src/server-script.ts +148 -0
- package/src/server.ts +1 -1
- package/src/site-preview.ts +2 -2
- package/src/studio-api.ts +3 -2
package/src/live-preview.ts
CHANGED
|
@@ -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,
|
|
27
|
-
*
|
|
28
|
-
* the
|
|
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
|
|
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). */
|
package/src/preview-client.ts
CHANGED
|
@@ -126,7 +126,12 @@ function connect() {
|
|
|
126
126
|
try {
|
|
127
127
|
window.focus();
|
|
128
128
|
} catch {}
|
|
129
|
-
|
|
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();
|