@jxsuite/server 1.3.0 → 2.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.
- package/package.json +19 -16
- package/src/ai-api.ts +30 -13
- package/src/build.ts +20 -17
- package/src/code-api.ts +2 -1
- package/src/collab.ts +35 -4
- package/src/data-api.ts +8 -1
- package/src/import-api.ts +5 -4
- package/src/jx-mounts.ts +11 -0
- package/src/net-guard.ts +188 -28
- package/src/oauth-loopback.ts +373 -0
- package/src/packages.ts +13 -2
- package/src/problem.ts +89 -0
- package/src/project-server.ts +84 -11
- package/src/refactor/apply.ts +3 -57
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/index.ts +7 -0
- package/src/refactor/refs.ts +103 -63
- package/src/refactor/scan.ts +70 -0
- package/src/refactor/watcher.ts +4 -0
- package/src/resolve.ts +9 -10
- package/src/server.ts +76 -14
- package/src/site-output.ts +111 -0
- package/src/site-preview.ts +106 -0
- package/src/studio-api.ts +356 -86
- package/src/watch.ts +54 -3
package/src/server.ts
CHANGED
|
@@ -13,13 +13,18 @@
|
|
|
13
13
|
* proxying, and studio filesystem integration as a single createDevServer() call.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { join, relative, resolve } from "node:path";
|
|
16
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
17
17
|
import { buildAll } from "./build.ts";
|
|
18
18
|
import { createCollabRegistry } from "./collab.ts";
|
|
19
19
|
import { createWatcher, injectSSE } from "./watch.ts";
|
|
20
20
|
import { handleJxMounts } from "./jx-mounts.ts";
|
|
21
21
|
import { handleResolve, handleServerFunction, projectAssetMounts } from "./resolve.ts";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
assertAccessible,
|
|
24
|
+
assertCreatableParent,
|
|
25
|
+
handleStudioApi,
|
|
26
|
+
isOwnedProjectDir,
|
|
27
|
+
} from "./studio-api.ts";
|
|
23
28
|
import {
|
|
24
29
|
containedPath,
|
|
25
30
|
decodeAndNormalizePath,
|
|
@@ -29,7 +34,9 @@ import {
|
|
|
29
34
|
import { handleCodeApi } from "./code-api.ts";
|
|
30
35
|
import { handleAiApi } from "./ai-api.ts";
|
|
31
36
|
import { handleImportApi } from "./import-api.ts";
|
|
37
|
+
import { mediaTypeForPath } from "@jxsuite/schema/media-type";
|
|
32
38
|
import { existsSync, readFileSync } from "node:fs";
|
|
39
|
+
import { problem } from "./problem.ts";
|
|
33
40
|
|
|
34
41
|
/**
|
|
35
42
|
* Resolve an npm-style bare specifier from a URL path via node_modules. Handles scoped packages
|
|
@@ -217,6 +224,14 @@ export async function createDevServer(options: {
|
|
|
217
224
|
}
|
|
218
225
|
const absRoot = resolve(root);
|
|
219
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Projects created through Studio's New Project modal, which land wherever the user pointed the
|
|
229
|
+
* Location field — normally outside `absRoot`. Remembering them lets the very next
|
|
230
|
+
* `/__studio/activate` open what was just created without widening the filesystem API to
|
|
231
|
+
* arbitrary directories.
|
|
232
|
+
*/
|
|
233
|
+
const createdRoots: string[] = [];
|
|
234
|
+
|
|
220
235
|
// ─── Build pipeline ─────────────────────────────────────────────────────────
|
|
221
236
|
|
|
222
237
|
if (builds.length > 0) {
|
|
@@ -242,8 +257,19 @@ export async function createDevServer(options: {
|
|
|
242
257
|
// Studio.js/canvas bundle — which shows up as half-updated UI after a rebuild (the iframe assets
|
|
243
258
|
// Are query-cache-busted per mount, the parent bundle is not).
|
|
244
259
|
const NO_CACHE = { "Cache-Control": "no-cache" };
|
|
245
|
-
|
|
246
|
-
|
|
260
|
+
/*
|
|
261
|
+
* `Bun.file` already infers a type from the extension, and for almost everything it is right. The
|
|
262
|
+
* exceptions are the two RFCs Jx has an opinion about: `.md` comes back as bare `text/markdown`
|
|
263
|
+
* with no `variant`, and `.yaml` comes back as `text/yaml`, the spelling RFC 9512 §5 asks
|
|
264
|
+
* implementations to stop using. `mediaTypeForPath` answers null everywhere else, so the
|
|
265
|
+
* inference stays in charge of the ordinary cases.
|
|
266
|
+
*/
|
|
267
|
+
const fileResponse = (file: ReturnType<typeof Bun.file>) => {
|
|
268
|
+
const corrected = file.name === undefined ? null : mediaTypeForPath(file.name);
|
|
269
|
+
return new Response(file, {
|
|
270
|
+
headers: corrected === null ? NO_CACHE : { ...NO_CACHE, "Content-Type": corrected },
|
|
271
|
+
});
|
|
272
|
+
};
|
|
247
273
|
|
|
248
274
|
// Active studio project root (set via /__studio/activate, used for static file fallback)
|
|
249
275
|
let activeProjectRoot: string | null = null;
|
|
@@ -283,7 +309,7 @@ export async function createDevServer(options: {
|
|
|
283
309
|
|
|
284
310
|
// SSE live reload
|
|
285
311
|
if (handleSSE && path === "/__reload") {
|
|
286
|
-
return handleSSE();
|
|
312
|
+
return handleSSE(req);
|
|
287
313
|
}
|
|
288
314
|
|
|
289
315
|
/*
|
|
@@ -331,8 +357,10 @@ export async function createDevServer(options: {
|
|
|
331
357
|
}
|
|
332
358
|
|
|
333
359
|
// Activate project — tells the server which project root to use for static file fallback.
|
|
334
|
-
// Contain the requested root: it must be under absRoot
|
|
335
|
-
//
|
|
360
|
+
// Contain the requested root: it must be under absRoot, an explicitly allowed root, a
|
|
361
|
+
// Project this server just created, or an existing project of the account's own
|
|
362
|
+
// (`isOwnedProjectDir`), so a hostile activate cannot widen the filesystem API to arbitrary
|
|
363
|
+
// Directories.
|
|
336
364
|
if (path === "/__studio/activate" && req.method === "POST") {
|
|
337
365
|
const body = (await req.json()) as { root?: string };
|
|
338
366
|
const raw = body.root || null;
|
|
@@ -343,9 +371,13 @@ export async function createDevServer(options: {
|
|
|
343
371
|
const requested = resolve(absRoot, raw);
|
|
344
372
|
const permitted =
|
|
345
373
|
containedPath(requested, absRoot) !== null ||
|
|
346
|
-
allowedRoots.some((allowed) => containedPath(requested, resolve(allowed)) !== null)
|
|
374
|
+
allowedRoots.some((allowed) => containedPath(requested, resolve(allowed)) !== null) ||
|
|
375
|
+
createdRoots.some((created) => containedPath(requested, created) !== null) ||
|
|
376
|
+
isOwnedProjectDir(requested);
|
|
347
377
|
if (!permitted) {
|
|
348
|
-
|
|
378
|
+
// `ok: false` rides along as an extension member: the Studio client branches on it,
|
|
379
|
+
// And RFC 9457 §3.2 exists precisely so a problem can carry what a type documents.
|
|
380
|
+
return problem("forbidden", "root not permitted", { ok: false });
|
|
349
381
|
}
|
|
350
382
|
activeProjectRoot = requested;
|
|
351
383
|
return Response.json({ ok: true, root: activeProjectRoot });
|
|
@@ -357,14 +389,26 @@ export async function createDevServer(options: {
|
|
|
357
389
|
return aiRes;
|
|
358
390
|
}
|
|
359
391
|
|
|
360
|
-
// AI-guided site import (/__studio/import-site) — NDJSON progress stream
|
|
392
|
+
// AI-guided site import (/__studio/import-site) — NDJSON progress stream. An ABSOLUTE
|
|
393
|
+
// Directory is a destination the user chose in the New Project modal, so it is vetted the
|
|
394
|
+
// Same way a create is (specs/server.md §4.2). A RELATIVE one predates that field and stays
|
|
395
|
+
// Contained to the server root — resolving it there and then widening the check to the
|
|
396
|
+
// Creatable roots would let "../escape" out.
|
|
361
397
|
const importRes = await handleImportApi(req, url, {
|
|
362
398
|
resolveDest: (dir) => {
|
|
399
|
+
if (isAbsolute(dir)) {
|
|
400
|
+
assertCreatableParent(dirname(dir), absRoot, allowedRoots);
|
|
401
|
+
createdRoots.push(dir);
|
|
402
|
+
return dir;
|
|
403
|
+
}
|
|
363
404
|
const dest = resolve(absRoot, dir);
|
|
364
405
|
assertAccessible(dest, absRoot, activeProjectRoot);
|
|
365
406
|
return dest;
|
|
366
407
|
},
|
|
367
|
-
toRoot: (dest) =>
|
|
408
|
+
toRoot: (dest) =>
|
|
409
|
+
containedPath(dest, absRoot) === null
|
|
410
|
+
? dest
|
|
411
|
+
: relative(absRoot, dest).replaceAll("\\", "/"),
|
|
368
412
|
});
|
|
369
413
|
if (importRes) {
|
|
370
414
|
return importRes;
|
|
@@ -375,7 +419,10 @@ export async function createDevServer(options: {
|
|
|
375
419
|
return codeRes;
|
|
376
420
|
}
|
|
377
421
|
|
|
378
|
-
const res = await handleStudioApi(req, url, absRoot, activeProjectRoot
|
|
422
|
+
const res = await handleStudioApi(req, url, absRoot, activeProjectRoot, {
|
|
423
|
+
allowedRoots,
|
|
424
|
+
onProjectCreated: (created) => createdRoots.push(created),
|
|
425
|
+
});
|
|
379
426
|
if (res) {
|
|
380
427
|
return res;
|
|
381
428
|
}
|
|
@@ -418,7 +465,15 @@ export async function createDevServer(options: {
|
|
|
418
465
|
await projectAssetMounts(activeProjectRoot),
|
|
419
466
|
);
|
|
420
467
|
if (mountRes) {
|
|
421
|
-
|
|
468
|
+
// Carry the resolved response's own headers over. Re-wrapping just the BODY discards the
|
|
469
|
+
// Content-Type Bun inferred from the file, and a JavaScript file that arrives without one
|
|
470
|
+
// Is refused outright by strict MIME checking the moment a page loads it as a MODULE — so
|
|
471
|
+
// A previewed site kept its styles (link elements sniff) and silently lost its scripts.
|
|
472
|
+
const headers = new Headers(mountRes.headers);
|
|
473
|
+
for (const [name, value] of Object.entries(NO_CACHE)) {
|
|
474
|
+
headers.set(name, value);
|
|
475
|
+
}
|
|
476
|
+
return new Response(mountRes.body, { headers });
|
|
422
477
|
}
|
|
423
478
|
}
|
|
424
479
|
|
|
@@ -479,7 +534,14 @@ export async function createDevServer(options: {
|
|
|
479
534
|
});
|
|
480
535
|
}
|
|
481
536
|
}
|
|
482
|
-
|
|
537
|
+
/* The project's BUILT SITE is deliberately NOT served here, at any position in this chain.
|
|
538
|
+
This server's URL space means the project's SOURCES — that is what the canvas reads —
|
|
539
|
+
and a built page addresses the same paths meaning its own output
|
|
540
|
+
(`/components/fetch-demo.js` is the formulas here and the custom element there). Filling
|
|
541
|
+
404s from the output looked like it worked and handed the reader whichever of the two
|
|
542
|
+
happened to be missing. `site-preview.ts` gives the built site its own origin instead,
|
|
543
|
+
where every path has exactly one meaning. */
|
|
544
|
+
return problem("notFound", "Not found");
|
|
483
545
|
}
|
|
484
546
|
|
|
485
547
|
// Inject the live-reload script into served HTML — but NOT into the Studio editor.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve a project's BUILT output at the routes the built pages actually reference.
|
|
3
|
+
*
|
|
4
|
+
* A built page is written for its published origin: `dist/basics/counter/index.html` links to
|
|
5
|
+
* `/basics/counter` and pulls `/components/fetch-demo.css`, both root-absolute. Studio's `View:
|
|
6
|
+
* Open in Browser` used to hand the browser `…/dist/basics/counter/index.html` — the file's path
|
|
7
|
+
* rather than the page's URL — and the consequences were exactly what those two absolutes predict:
|
|
8
|
+
* the HTML arrived, every stylesheet and script 404'd against the server root, and the first link
|
|
9
|
+
* the reader clicked left the built site entirely. The page loaded and nothing else worked.
|
|
10
|
+
*
|
|
11
|
+
* So the fix is not in the URL alone. A site is only "as if published" when it is served AT A ROOT,
|
|
12
|
+
* which is what this module does for whichever project a server is showing: `/basics/counter` and
|
|
13
|
+
* `/basics/counter/` both resolve to `<outDir>/basics/counter/index.html`, `/` to
|
|
14
|
+
* `<outDir>/index.html`, and every asset the page names resolves the same way it will in
|
|
15
|
+
* production.
|
|
16
|
+
*
|
|
17
|
+
* **It is a LAST resort, deliberately.** `serveProjectFile` tries the mounts, the absolute path,
|
|
18
|
+
* the root-relative source file and `public/` before it gets here, so this step can only answer
|
|
19
|
+
* requests that were already going to 404 — it can never shadow a source file that resolves today.
|
|
20
|
+
* That property is what makes it safe to mount for every project on every server rather than behind
|
|
21
|
+
* a flag.
|
|
22
|
+
*
|
|
23
|
+
* `jx dev`'s own `createDistMiddleware` is a different thing and stays: it runs BEFORE the source
|
|
24
|
+
* files (a site project's dev server is showing the built site, not its sources) and it injects the
|
|
25
|
+
* live-reload client. This one is the fallback for the servers that are showing a project's sources
|
|
26
|
+
* — the monorepo dev server and the desktop's project server — where the built site is something a
|
|
27
|
+
* reader is visiting, not the thing being edited.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
31
|
+
import { join, resolve } from "node:path";
|
|
32
|
+
|
|
33
|
+
import { serveContained } from "./net-guard.ts";
|
|
34
|
+
|
|
35
|
+
/** What `project.json` says the build writes to, cached per root against its mtime. */
|
|
36
|
+
const outDirCache = new Map<string, { mtime: number; outDir: string | null }>();
|
|
37
|
+
|
|
38
|
+
/** Reset the output-directory cache (test hook). */
|
|
39
|
+
export function resetSiteOutput(): void {
|
|
40
|
+
outDirCache.clear();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The project's absolute output directory, or `null` when it is not a site project.
|
|
45
|
+
*
|
|
46
|
+
* Read from `project.json` rather than assumed to be `dist`, because `build.outDir` is a documented
|
|
47
|
+
* setting and a project that moved it would otherwise be served its stale old output for ever. The
|
|
48
|
+
* mtime guard is the same idiom `jx-mounts.ts` uses: a config edit is picked up, a request is not a
|
|
49
|
+
* file read.
|
|
50
|
+
*/
|
|
51
|
+
export function siteOutDir(projectRoot: string): string | null {
|
|
52
|
+
const configPath = resolve(projectRoot, "project.json");
|
|
53
|
+
if (!existsSync(configPath)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const { mtimeMs } = statSync(configPath);
|
|
57
|
+
const cached = outDirCache.get(projectRoot);
|
|
58
|
+
if (cached && cached.mtime === mtimeMs) {
|
|
59
|
+
return cached.outDir;
|
|
60
|
+
}
|
|
61
|
+
let outDir: string | null = null;
|
|
62
|
+
try {
|
|
63
|
+
const config = JSON.parse(readFileSync(configPath, "utf8")) as {
|
|
64
|
+
build?: { outDir?: unknown };
|
|
65
|
+
};
|
|
66
|
+
const declared = config.build?.outDir;
|
|
67
|
+
outDir = resolve(projectRoot, typeof declared === "string" && declared ? declared : "dist");
|
|
68
|
+
} catch {
|
|
69
|
+
// An unparseable project.json is the editor's problem to report, not this server's to guess
|
|
70
|
+
// At. No output directory means this fallback simply declines, and the request 404s as before.
|
|
71
|
+
outDir = null;
|
|
72
|
+
}
|
|
73
|
+
outDirCache.set(projectRoot, { mtime: mtimeMs, outDir });
|
|
74
|
+
return outDir;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Serve `decodedPath` from the project's built output, or `null` when it names nothing there.
|
|
79
|
+
*
|
|
80
|
+
* The mapping is the compiler's own, read backwards: a request for a route gets that route's
|
|
81
|
+
* `index.html` whether or not the reader typed the trailing slash, because a built site is browsed
|
|
82
|
+
* by its routes and a reader does not know which spelling the `trailingSlash` setting produced.
|
|
83
|
+
* `<route>.html` is tried too, which is what `trailingSlash: "never"` writes.
|
|
84
|
+
*/
|
|
85
|
+
export async function serveSiteOutput(
|
|
86
|
+
decodedPath: string,
|
|
87
|
+
projectRoot: string,
|
|
88
|
+
): Promise<Response | null> {
|
|
89
|
+
const outDir = siteOutDir(projectRoot);
|
|
90
|
+
if (!outDir || !existsSync(outDir)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const rel = decodedPath.replace(/^\/+/, "");
|
|
94
|
+
const direct = resolve(outDir, `./${rel}`);
|
|
95
|
+
|
|
96
|
+
// A file (an asset, or a page under `trailingSlash: "never"`).
|
|
97
|
+
if (existsSync(direct) && statSync(direct).isFile()) {
|
|
98
|
+
return serveContained(direct, outDir);
|
|
99
|
+
}
|
|
100
|
+
// A route: its directory's index.html, with or without the trailing slash the reader typed.
|
|
101
|
+
const asIndex = join(direct, "index.html");
|
|
102
|
+
if (existsSync(asIndex)) {
|
|
103
|
+
return serveContained(asIndex, outDir);
|
|
104
|
+
}
|
|
105
|
+
// `trailingSlash: "never"` writes `<route>.html` beside its siblings.
|
|
106
|
+
const asHtml = `${direct.replace(/\/+$/, "")}.html`;
|
|
107
|
+
if (rel !== "" && existsSync(asHtml)) {
|
|
108
|
+
return serveContained(asHtml, outDir);
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built site on its own origin — what `View: Open in Browser` opens.
|
|
3
|
+
*
|
|
4
|
+
* A built page is written for its published origin and addresses everything ROOT-absolutely:
|
|
5
|
+
* `dist/basics/fetch/index.html` links to `/basics/counter` and loads `/components/fetch-demo.js`.
|
|
6
|
+
* So it is only browsable when those paths resolve against the OUTPUT — and on the editor's server
|
|
7
|
+
* they resolve against the project's SOURCES, because that is the editor's whole job. The two are
|
|
8
|
+
* the same URLs holding different bytes: `/components/fetch-demo.js` is the module of formulas the
|
|
9
|
+
* compiler reads in the sources, and the custom-element definition the page needs in the output.
|
|
10
|
+
*
|
|
11
|
+
* Serving the output as a 404 fallback on the editor's server looks like it works — the page and
|
|
12
|
+
* the assets that exist in only one of the two spaces arrive — and then the reader gets the source
|
|
13
|
+
* module for anything that exists in both. Measured: the page rendered, `customElements.get(...)`
|
|
14
|
+
* was null, and nothing on it did anything. No ordering fixes that; one origin cannot hold two
|
|
15
|
+
* meanings for one path.
|
|
16
|
+
*
|
|
17
|
+
* Hence a second server, rooted AT the output directory, where every path has exactly the meaning
|
|
18
|
+
* the published site gives it. Nothing is injected — no live-reload client, no rewriting — because
|
|
19
|
+
* the point is to hand the reader the bytes that will be deployed. `View: Open in Browser` builds,
|
|
20
|
+
* then opens a URL here.
|
|
21
|
+
*
|
|
22
|
+
* It is bound to loopback and serves only files under the output directory, which is the artefact
|
|
23
|
+
* the author is about to publish; it carries none of the editor's privileged routes and needs no
|
|
24
|
+
* token. Servers are reused per project root — opening ten pages opens one port — and they live for
|
|
25
|
+
* the life of the process: the map is keyed by root and two windows on one project share an origin,
|
|
26
|
+
* so no single window's teardown may close it.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { serveSiteOutput, siteOutDir } from "./site-output.ts";
|
|
30
|
+
import { problem } from "./problem.ts";
|
|
31
|
+
|
|
32
|
+
/** A running preview server for one project. */
|
|
33
|
+
export interface SitePreview {
|
|
34
|
+
/** Origin to prefix a route with, e.g. `http://127.0.0.1:41234`. */
|
|
35
|
+
origin: string;
|
|
36
|
+
port: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface Running extends SitePreview {
|
|
40
|
+
server: { stop: (closeActiveConnections?: boolean) => void };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One server per project root — a second `Open in Browser` reuses the first one's port. */
|
|
44
|
+
const running = new Map<string, Running>();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Start (or reuse) the preview server for `projectRoot`, or `null` when there is nothing to serve:
|
|
48
|
+
* not a site project, or a site nobody has built yet. A caller that has just built gets a server;
|
|
49
|
+
* one that has not gets the honest answer rather than a port that 404s everything.
|
|
50
|
+
*/
|
|
51
|
+
export function startSitePreview(projectRoot: string): SitePreview | null {
|
|
52
|
+
const outDir = siteOutDir(projectRoot);
|
|
53
|
+
if (!outDir) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const existing = running.get(projectRoot);
|
|
57
|
+
if (existing) {
|
|
58
|
+
return { origin: existing.origin, port: existing.port };
|
|
59
|
+
}
|
|
60
|
+
const server = Bun.serve({
|
|
61
|
+
fetch: async (req: Request) => {
|
|
62
|
+
const url = new URL(req.url);
|
|
63
|
+
let decoded: string;
|
|
64
|
+
try {
|
|
65
|
+
decoded = decodeURIComponent(url.pathname);
|
|
66
|
+
} catch {
|
|
67
|
+
return problem("invalidRequest", "Bad request");
|
|
68
|
+
}
|
|
69
|
+
const res = await serveSiteOutput(decoded, projectRoot);
|
|
70
|
+
if (res) {
|
|
71
|
+
return res;
|
|
72
|
+
}
|
|
73
|
+
/* The site's OWN 404 page when it has one, at the status the published site would send.
|
|
74
|
+
A generated `404.html` is a page the author wrote; showing it is more faithful than
|
|
75
|
+
showing this server's opinion, and it is how the static hosts serve it. */
|
|
76
|
+
const notFound = await serveSiteOutput("/404.html", projectRoot);
|
|
77
|
+
return notFound
|
|
78
|
+
? new Response(notFound.body, { headers: notFound.headers, status: 404 })
|
|
79
|
+
: problem("notFound", "Not found");
|
|
80
|
+
},
|
|
81
|
+
hostname: "127.0.0.1",
|
|
82
|
+
port: 0,
|
|
83
|
+
});
|
|
84
|
+
// `server.url` rather than `server.port`: the origin is the thing being handed to a browser, and
|
|
85
|
+
// Reading it from the server that is actually listening cannot disagree with it.
|
|
86
|
+
const entry: Running = {
|
|
87
|
+
origin: server.url.origin,
|
|
88
|
+
port: Number(server.url.port),
|
|
89
|
+
server,
|
|
90
|
+
};
|
|
91
|
+
running.set(projectRoot, entry);
|
|
92
|
+
return { origin: entry.origin, port: entry.port };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The origin already serving `projectRoot`, without starting one. */
|
|
96
|
+
export function sitePreviewOrigin(projectRoot: string): string | null {
|
|
97
|
+
return running.get(projectRoot)?.origin ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Stop every preview server (process teardown, and the hook tests close their ports with). */
|
|
101
|
+
export function stopSitePreviews(): void {
|
|
102
|
+
for (const entry of running.values()) {
|
|
103
|
+
entry.server.stop(true);
|
|
104
|
+
}
|
|
105
|
+
running.clear();
|
|
106
|
+
}
|