@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.
- package/package.json +17 -16
- package/src/code-api.ts +15 -45
- package/src/code-format.ts +134 -0
- package/src/dev.ts +30 -12
- package/src/format.ts +271 -0
- package/src/live-preview.ts +211 -17
- 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;
|
|
@@ -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
|
|
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). */
|
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();
|