@jxsuite/server 2.0.0 → 2.2.1
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/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 +124 -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 +36 -6
- package/src/site-output.ts +111 -0
- package/src/site-preview.ts +106 -0
- package/src/studio-api.ts +198 -89
- package/src/watch.ts +54 -3
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan.ts — the project document sweep the refactor engines share.
|
|
3
|
+
*
|
|
4
|
+
* `applyRename` (write pass) and `findReferences` (read pass) walk exactly the same file set: every
|
|
5
|
+
* JSON document plus every extension the project's format registry claims, minus the vendored /
|
|
6
|
+
* build / agent directories. Keeping one definition of "the project's documents" is the point — a
|
|
7
|
+
* rename that rewrites a file the usage count never looked at is a lie in both directions.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { extname } from "node:path";
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
13
|
+
|
|
14
|
+
/** Normalise a path to forward slashes (Windows `path` returns backslashes). */
|
|
15
|
+
export const fwd = (p: string) => p.replaceAll("\\", "/");
|
|
16
|
+
|
|
17
|
+
/** A parsed document plus the serializer that round-trips it back to disk (null when none). */
|
|
18
|
+
export interface LoadedDoc {
|
|
19
|
+
doc: unknown;
|
|
20
|
+
serialize: ((doc: unknown) => Promise<string>) | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const isJsonPath = (p: string) => p.endsWith(".json");
|
|
24
|
+
|
|
25
|
+
/** Skip vendored / build / agent directories (mirrors the component-discovery filter). */
|
|
26
|
+
export function skipScanPath(match: string): boolean {
|
|
27
|
+
const f = fwd(match);
|
|
28
|
+
return match.includes("node_modules") || f.includes("dist/") || f.includes(".claude/");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build the recursive glob covering JSON plus every registered document/content extension. */
|
|
32
|
+
export function documentGlob(registry: FormatRegistry): Bun.Glob {
|
|
33
|
+
const exts = new Set(["json"]);
|
|
34
|
+
for (const ext of registry.documentExtensions()) {
|
|
35
|
+
exts.add(ext.replace(/^\./, ""));
|
|
36
|
+
}
|
|
37
|
+
return new Bun.Glob(`**/*.{${[...exts].join(",")}}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read + parse a file, returning the doc and a matching serializer (null when the format can parse
|
|
42
|
+
* but not serialize). Throws when no parser claims the extension.
|
|
43
|
+
*/
|
|
44
|
+
export async function loadDoc(fp: string, registry: FormatRegistry): Promise<LoadedDoc> {
|
|
45
|
+
const raw = await readFile(fp, "utf8");
|
|
46
|
+
if (isJsonPath(fp)) {
|
|
47
|
+
const trailingNewline = raw.endsWith("\n");
|
|
48
|
+
return {
|
|
49
|
+
doc: JSON.parse(raw) as unknown,
|
|
50
|
+
serialize: (doc) =>
|
|
51
|
+
Promise.resolve(JSON.stringify(doc, null, 2) + (trailingNewline ? "\n" : "")),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const ext = extname(fp);
|
|
55
|
+
const parseEntry = registry.byExtension(ext, "parse");
|
|
56
|
+
if (!parseEntry) {
|
|
57
|
+
throw new Error(`No parser for "${ext}"`);
|
|
58
|
+
}
|
|
59
|
+
const doc = await parseEntry.call("parse", raw);
|
|
60
|
+
const serializeEntry = registry.byExtension(ext, "serialize");
|
|
61
|
+
return {
|
|
62
|
+
doc,
|
|
63
|
+
serialize: serializeEntry
|
|
64
|
+
? async (d) => {
|
|
65
|
+
const out = await serializeEntry.call("serialize", d);
|
|
66
|
+
return typeof out === "string" ? out : String(out);
|
|
67
|
+
}
|
|
68
|
+
: null,
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/refactor/watcher.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { watch as chokidarWatch } from "chokidar";
|
|
10
10
|
import { coalesceFsEvents, toFsEvent } from "./fs-events.ts";
|
|
11
|
+
import { invalidateReferenceCache } from "./find-refs.ts";
|
|
11
12
|
import type { FsEventPayload } from "./fs-events.ts";
|
|
12
13
|
|
|
13
14
|
const IGNORE_SEGMENTS = ["node_modules", ".git", "dist", ".jx", ".direnv", ".devenv"];
|
|
@@ -45,6 +46,9 @@ export function createFsWatcher(
|
|
|
45
46
|
if (!event) {
|
|
46
47
|
return;
|
|
47
48
|
}
|
|
49
|
+
// Any move under the root can change who references what, so the usage cache drops the whole
|
|
50
|
+
// Project rather than trying to derive which queries this one path could have affected.
|
|
51
|
+
invalidateReferenceCache(root);
|
|
48
52
|
buffer = coalesceFsEvents(buffer, event);
|
|
49
53
|
if (timer) {
|
|
50
54
|
clearTimeout(timer);
|
package/src/resolve.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { loadAssetMounts } from "@jxsuite/compiler/asset-mounts";
|
|
|
11
11
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
12
12
|
import type { DynamicClass } from "@jxsuite/runtime/types";
|
|
13
13
|
import type { JxClassDef, ProjectConfig } from "@jxsuite/schema/types";
|
|
14
|
+
import { problem } from "./problem.ts";
|
|
14
15
|
|
|
15
16
|
interface ModuleNamespace {
|
|
16
17
|
default?: Record<string, unknown>;
|
|
@@ -126,12 +127,12 @@ export async function handleResolve(
|
|
|
126
127
|
try {
|
|
127
128
|
body = (await req.json()) as ResolveBody;
|
|
128
129
|
} catch {
|
|
129
|
-
return
|
|
130
|
+
return problem("invalidRequest", "Invalid JSON body");
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
const { $src, $prototype, $export: xport, $base, ...config } = body;
|
|
133
134
|
if (!$src) {
|
|
134
|
-
return
|
|
135
|
+
return problem("invalidRequest", "Missing $src");
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
let moduleAbsPath;
|
|
@@ -200,7 +201,7 @@ export async function handleResolve(
|
|
|
200
201
|
// A trusted (bare-specifier) class file names its own sibling implementation; only a
|
|
201
202
|
// Project-local relative $src is held to the root-escape guard.
|
|
202
203
|
if (!trustedResolution && !isImportable(implPath, root, activeProjectRoot)) {
|
|
203
|
-
return
|
|
204
|
+
return problem("pathOutsideProject", "$implementation escapes the project root");
|
|
204
205
|
}
|
|
205
206
|
const exportName = xport ?? classDef.title ?? $prototype;
|
|
206
207
|
const mod = (await import(implPath)) as ModuleNamespace;
|
|
@@ -236,7 +237,7 @@ export async function handleResolve(
|
|
|
236
237
|
: instance;
|
|
237
238
|
return Response.json(value);
|
|
238
239
|
} catch (error) {
|
|
239
|
-
return
|
|
240
|
+
return problem("internalError", errorMessage(error));
|
|
240
241
|
}
|
|
241
242
|
}
|
|
242
243
|
|
|
@@ -260,12 +261,12 @@ export async function handleServerFunction(req: Request, root: string) {
|
|
|
260
261
|
try {
|
|
261
262
|
body = (await req.json()) as ServerFunctionBody;
|
|
262
263
|
} catch {
|
|
263
|
-
return
|
|
264
|
+
return problem("invalidRequest", "Invalid JSON body");
|
|
264
265
|
}
|
|
265
266
|
|
|
266
267
|
const { $src, $export: xport, $base, arguments: args = {} } = body;
|
|
267
268
|
if (!$src || !xport) {
|
|
268
|
-
return
|
|
269
|
+
return problem("invalidRequest", "Missing $src or $export");
|
|
269
270
|
}
|
|
270
271
|
|
|
271
272
|
let moduleAbsPath;
|
|
@@ -278,9 +279,7 @@ export async function handleServerFunction(req: Request, root: string) {
|
|
|
278
279
|
moduleAbsPath = resolve(root, $src);
|
|
279
280
|
}
|
|
280
281
|
} catch (error) {
|
|
281
|
-
return
|
|
282
|
-
status: 400,
|
|
283
|
-
});
|
|
282
|
+
return problem("invalidRequest", `Cannot resolve $src: ${errorMessage(error)}`);
|
|
284
283
|
}
|
|
285
284
|
|
|
286
285
|
if (!isImportable(moduleAbsPath, root, null)) {
|
|
@@ -312,7 +311,7 @@ export async function handleServerFunction(req: Request, root: string) {
|
|
|
312
311
|
);
|
|
313
312
|
return Response.json(result ?? null);
|
|
314
313
|
} catch (error) {
|
|
315
|
-
return
|
|
314
|
+
return problem("internalError", errorMessage(error));
|
|
316
315
|
}
|
|
317
316
|
}
|
|
318
317
|
|
package/src/server.ts
CHANGED
|
@@ -34,7 +34,9 @@ import {
|
|
|
34
34
|
import { handleCodeApi } from "./code-api.ts";
|
|
35
35
|
import { handleAiApi } from "./ai-api.ts";
|
|
36
36
|
import { handleImportApi } from "./import-api.ts";
|
|
37
|
+
import { mediaTypeForPath } from "@jxsuite/schema/media-type";
|
|
37
38
|
import { existsSync, readFileSync } from "node:fs";
|
|
39
|
+
import { problem } from "./problem.ts";
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* Resolve an npm-style bare specifier from a URL path via node_modules. Handles scoped packages
|
|
@@ -255,8 +257,19 @@ export async function createDevServer(options: {
|
|
|
255
257
|
// Studio.js/canvas bundle — which shows up as half-updated UI after a rebuild (the iframe assets
|
|
256
258
|
// Are query-cache-busted per mount, the parent bundle is not).
|
|
257
259
|
const NO_CACHE = { "Cache-Control": "no-cache" };
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
};
|
|
260
273
|
|
|
261
274
|
// Active studio project root (set via /__studio/activate, used for static file fallback)
|
|
262
275
|
let activeProjectRoot: string | null = null;
|
|
@@ -296,7 +309,7 @@ export async function createDevServer(options: {
|
|
|
296
309
|
|
|
297
310
|
// SSE live reload
|
|
298
311
|
if (handleSSE && path === "/__reload") {
|
|
299
|
-
return handleSSE();
|
|
312
|
+
return handleSSE(req);
|
|
300
313
|
}
|
|
301
314
|
|
|
302
315
|
/*
|
|
@@ -362,7 +375,9 @@ export async function createDevServer(options: {
|
|
|
362
375
|
createdRoots.some((created) => containedPath(requested, created) !== null) ||
|
|
363
376
|
isOwnedProjectDir(requested);
|
|
364
377
|
if (!permitted) {
|
|
365
|
-
|
|
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 });
|
|
366
381
|
}
|
|
367
382
|
activeProjectRoot = requested;
|
|
368
383
|
return Response.json({ ok: true, root: activeProjectRoot });
|
|
@@ -450,7 +465,15 @@ export async function createDevServer(options: {
|
|
|
450
465
|
await projectAssetMounts(activeProjectRoot),
|
|
451
466
|
);
|
|
452
467
|
if (mountRes) {
|
|
453
|
-
|
|
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 });
|
|
454
477
|
}
|
|
455
478
|
}
|
|
456
479
|
|
|
@@ -511,7 +534,14 @@ export async function createDevServer(options: {
|
|
|
511
534
|
});
|
|
512
535
|
}
|
|
513
536
|
}
|
|
514
|
-
|
|
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");
|
|
515
545
|
}
|
|
516
546
|
|
|
517
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
|
+
}
|