@jxsuite/server 2.0.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.
@@ -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
+ }