@jxsuite/server 6.1.0 → 8.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@jxsuite/server`
2
2
 
3
- > Bun-native development server for Jx projects with live reload, proxy resolution, and Studio API.
3
+ > Development server for Jx projects with live reload, proxy resolution, and Studio API, plus the project server, package operations and package service that also run on Node.
4
4
 
5
5
  ## Overview
6
6
 
@@ -73,7 +73,29 @@ OXC-powered tools for the Studio function body editor:
73
73
  | ---------- | ----------------------------- |
74
74
  | `chokidar` | File watching for live reload |
75
75
 
76
- Bun built-ins used: `Bun.serve`, `Bun.build`, `Bun.Transpiler`.
76
+ | `@npmcli/arborist` | Package operations (npm's installer) |
77
+ | `npm-package-arg` | Validating a package spec before anything is installed |
78
+ | `ws` | WebSockets for `serve()` on Node |
79
+
80
+ `createDevServer()` and the build pipeline run on Bun (`Bun.serve`, `Bun.build`, `Bun.Transpiler`).
81
+
82
+ ## Running on Node
83
+
84
+ The desktop app runs the project server on the Node inside Electron, so everything it loads is runtime-neutral, with the Bun implementation chosen by the `bun` export condition:
85
+
86
+ - **`@jxsuite/server/serve`**: `serve()`, one HTTP and WebSocket server over `Bun.serve` under Bun and `node:http` with `ws` under Node. It is async, because Node binds asynchronously.
87
+ - **`@jxsuite/server/fs-glob`** and **`@jxsuite/server/file-response`**: globbing and file responses that answer as Bun's own built-ins do.
88
+ - **`@jxsuite/server/packages`**: add, remove, install and update-versions, run in-process on npm's Arborist. Install scripts never run, only the public registry is read, and the operation writes `package-lock.json` (a project's `bun.lock` is replaced on its first install).
89
+ - **`@jxsuite/server/packages-service`**: the same operations over HTTP, lockfile-only, for a host with no filesystem of its own. The Jx cloud platform runs it from the [jxsuite/package-manager](https://github.com/jxsuite/package-manager) container image.
90
+
91
+ ## Upgrading to 8.0
92
+
93
+ 8.0 is the release that made the server runtime-neutral. Code that imports these from `@jxsuite/server` needs to change:
94
+
95
+ - `createProjectServer()`, `startSitePreview()`, `sitePreviewOrigin()` and `stopSitePreviews()` return promises. Await them.
96
+ - `documentGlob()` returns the glob pattern as a string instead of a `Bun.Glob`. Scan it with `scanFiles()` from `@jxsuite/server/fs-glob`.
97
+ - `bunExecutable()` is removed: package operations no longer run a package manager in a subprocess.
98
+ - `installDependencies()`, `addPackage()`, `removePackage()` and `setPackageVersions()` take an options argument and write `package-lock.json` rather than `bun.lock`.
77
99
 
78
100
  ## License
79
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "6.1.0",
3
+ "version": "8.0.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,6 +12,12 @@
12
12
  "src/"
13
13
  ],
14
14
  "type": "module",
15
+ "imports": {
16
+ "#serve": {
17
+ "bun": "./src/serve.bun.ts",
18
+ "default": "./src/serve.node.ts"
19
+ }
20
+ },
15
21
  "exports": {
16
22
  ".": "./src/server.ts",
17
23
  "./dev": "./src/dev.ts",
@@ -29,7 +35,14 @@
29
35
  "./resolve": "./src/resolve.ts",
30
36
  "./types": "./src/types.ts",
31
37
  "./jx-mounts": "./src/jx-mounts.ts",
32
- "./dev-vars": "./src/dev-vars.ts"
38
+ "./dev-vars": "./src/dev-vars.ts",
39
+ "./fs-glob": "./src/fs-glob.ts",
40
+ "./serve": {
41
+ "bun": "./src/serve.bun.ts",
42
+ "default": "./src/serve.node.ts"
43
+ },
44
+ "./file-response": "./src/file-response.ts",
45
+ "./packages-service": "./src/packages-service.ts"
33
46
  },
34
47
  "publishConfig": {
35
48
  "provenance": true
@@ -41,25 +54,32 @@
41
54
  "upgrade": "bun update --latest && bun install"
42
55
  },
43
56
  "dependencies": {
44
- "@jxsuite/ai": "^0.40.0",
45
- "@jxsuite/catalog": "^0.1.2",
46
- "@jxsuite/collab": "^0.11.2",
47
- "@jxsuite/compiler": "^6.1.0",
48
- "@jxsuite/create": "^1.4.0",
49
- "@jxsuite/import": "^0.41.0",
50
- "@jxsuite/protocol": "^4.0.0",
51
- "@jxsuite/runtime": "^5.2.0",
52
- "@jxsuite/schema": "^4.0.0",
53
- "@jxsuite/site": "^2.2.0",
54
- "@jxsuite/starters": "^3.1.0",
57
+ "@jxsuite/ai": "^0.41.1",
58
+ "@jxsuite/catalog": "^0.1.4",
59
+ "@jxsuite/collab": "^0.11.4",
60
+ "@jxsuite/compiler": "^8.0.0",
61
+ "@jxsuite/create": "^1.5.0",
62
+ "@jxsuite/import": "^0.42.0",
63
+ "@jxsuite/protocol": "^6.0.0",
64
+ "@jxsuite/runtime": "^6.0.1",
65
+ "@jxsuite/schema": "^5.0.0",
66
+ "@jxsuite/site": "^2.2.2",
67
+ "@jxsuite/starters": "^3.1.2",
68
+ "@npmcli/arborist": "^10.0.4",
55
69
  "chokidar": "^6.0.0",
56
70
  "kysely": "^0.29.7",
71
+ "npm-package-arg": "^14.0.0",
57
72
  "oxfmt": "^0.74.0",
73
+ "ws": "^8.22.0",
58
74
  "zod": "^4.6.7"
59
75
  },
60
76
  "devDependencies": {
61
- "@jxsuite/auth": "^0.8.0",
62
- "@jxsuite/connector": "^0.8.0",
63
- "@jxsuite/parser": "^4.1.0"
77
+ "@jxsuite/auth": "^0.8.2",
78
+ "@jxsuite/connector": "^0.8.2",
79
+ "@jxsuite/parser": "^4.1.2",
80
+ "@jxsuite/test-support": "^0.0.0",
81
+ "@types/npm-package-arg": "^6.1.4",
82
+ "@types/npmcli__arborist": "^6.3.3",
83
+ "@types/ws": "^8.18.1"
64
84
  }
65
85
  }
package/src/collab.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  selectSubprotocol,
25
25
  } from "@jxsuite/collab/negotiate";
26
26
  import { assertAccessible } from "./studio-api";
27
- import type { ServerWebSocket, WebSocketHandler } from "bun";
27
+ import type { ServeServer, ServeSocket, ServeWebSocketHandler } from "./serve.ts";
28
28
  import type { HostConnection } from "@jxsuite/collab/room";
29
29
  import { problem } from "./problem.ts";
30
30
 
@@ -66,20 +66,15 @@ export interface CollabSocketData {
66
66
  export interface CollabRegistry {
67
67
  /**
68
68
  * Handle a request to /__studio/collab: WebSocket upgrades become collab connections; a plain GET
69
- * answers the capability probe. Returns undefined when the upgrade was accepted (Bun sends the
70
- * 101 itself).
69
+ * answers the capability probe. Returns undefined when the upgrade was accepted (the server sends
70
+ * the 101 itself).
71
71
  */
72
72
  handleRequest: (
73
73
  req: Request,
74
- server: {
75
- upgrade: (
76
- req: Request,
77
- opts: { data: CollabSocketData; headers?: Record<string, string> },
78
- ) => boolean;
79
- },
74
+ server: Pick<ServeServer<CollabSocketData>, "upgrade">,
80
75
  ) => Response | undefined;
81
- /** Bun.serve websocket handlers backing the upgraded connections. */
82
- websocket: WebSocketHandler<CollabSocketData>;
76
+ /** The websocket handlers backing the upgraded connections (`serve.ts`). */
77
+ websocket: ServeWebSocketHandler<CollabSocketData>;
83
78
  /** Watcher hook: a file changed on disk (absolute path). Echoes of our write-back are ignored. */
84
79
  handleExternalChange: (absPath: string) => void;
85
80
  /** Flush pending write-backs and tear everything down. */
@@ -253,17 +248,17 @@ export function createCollabRegistry(opts: {
253
248
  },
254
249
 
255
250
  websocket: {
256
- close(ws: ServerWebSocket<CollabSocketData>) {
251
+ close(ws: ServeSocket<CollabSocketData>) {
257
252
  ws.data.connection?.close();
258
253
  ws.data.connection = null;
259
254
  },
260
- message(ws: ServerWebSocket<CollabSocketData>, message: string | Buffer) {
255
+ message(ws: ServeSocket<CollabSocketData>, message: string | Buffer) {
261
256
  if (isString(message)) {
262
257
  return;
263
258
  }
264
259
  ws.data.connection?.handleMessage(new Uint8Array(message));
265
260
  },
266
- open(ws: ServerWebSocket<CollabSocketData>) {
261
+ open(ws: ServeSocket<CollabSocketData>) {
267
262
  connectionCounter += 1;
268
263
  const login = `local-${connectionCounter}`;
269
264
  ws.data.connection = host.connect(
package/src/dev.ts CHANGED
@@ -13,7 +13,13 @@
13
13
  */
14
14
 
15
15
  import { existsSync, statSync } from "node:fs";
16
+ import { createRequire } from "node:module";
16
17
  import { join, normalize, resolve, sep } from "node:path";
18
+ import { errorMessage } from "@jxsuite/schema/parse";
19
+ import type { JsonObject } from "@jxsuite/schema/types";
20
+ import { projectDevEnv } from "./dev-vars.ts";
21
+ import { problem } from "./problem.ts";
22
+ import { callServerExport } from "./resolve.ts";
17
23
  import { createDevServer } from "./server.ts";
18
24
  import { injectSSE } from "./watch.ts";
19
25
 
@@ -90,6 +96,84 @@ export function createDistMiddleware(
90
96
  };
91
97
  }
92
98
 
99
+ /** One server entry the last build collected: the export, and where the site worker imports it from. */
100
+ export interface ServerEntry {
101
+ exportName: string;
102
+ /** A `./` path from the project root, or a bare specifier, exactly as the worker bundle reads it. */
103
+ src: string;
104
+ }
105
+
106
+ /** The route every server function is served at, before the deployment base `server.ts` strips. */
107
+ const SERVER_FUNCTION_ROUTE = "/_jx/server/";
108
+
109
+ /**
110
+ * The arguments object a call posted. The worker reads its body the same way: anything that is not
111
+ * JSON is an empty argument object, so a function never sees the failure of a malformed request.
112
+ */
113
+ async function readArguments(req: Request): Promise<JsonObject> {
114
+ try {
115
+ /* SAFETY: trust boundary. The body is whatever JSON the caller posted, and a compiled page posts
116
+ the entry's `arguments` object. The function receives it exactly as the worker passes it. */
117
+ return (await req.json()) as JsonObject;
118
+ } catch {
119
+ return {};
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Middleware that stands in for the site worker's server-function routes (server.md §2): answers
125
+ * `POST /_jx/server/<export>` for each entry the last build collected, and `null` for anything
126
+ * else, so the request falls through to the next middleware.
127
+ *
128
+ * It answers as the worker does, because a built page calls both the same way: the function's
129
+ * return value as JSON, `null` for `undefined`, and `{ ok: false, error }` with status 500 when it
130
+ * cannot be called. The function receives `env` as the extension mounts do (`projectDevEnv`:
131
+ * `process.env` under the project's `.dev.vars`), so it reads the same secrets in development.
132
+ *
133
+ * Each module is resolved as the worker bundle resolves it, from the project root: a `./` path
134
+ * against the root, any other specifier through the project's own `package.json`. It applies no
135
+ * containment check, because the module path comes from the build and never from the request;
136
+ * containment stays in the `/__jx_server__` proxy, where the request names the module.
137
+ *
138
+ * @param {string} root - Absolute project root
139
+ * @param {() => readonly ServerEntry[]} entries - The last build's collected entries, read per
140
+ * request
141
+ * @returns {(req: Request, url: URL) => Promise<Response | null>}
142
+ */
143
+ export function createServerFunctionMiddleware(
144
+ root: string,
145
+ entries: () => readonly ServerEntry[],
146
+ ): (req: Request, url: URL) => Promise<Response | null> {
147
+ const requireFromProject = createRequire(resolve(root, "package.json"));
148
+ return async (req, url) => {
149
+ if (req.method !== "POST" || !url.pathname.startsWith(SERVER_FUNCTION_ROUTE)) {
150
+ return null;
151
+ }
152
+ const name = url.pathname.slice(SERVER_FUNCTION_ROUTE.length);
153
+ const entry = entries().find((candidate) => candidate.exportName === name);
154
+ if (!entry) {
155
+ return problem("notFound", `The last build collected no server function "${name}"`);
156
+ }
157
+
158
+ let modulePath: string;
159
+ try {
160
+ modulePath =
161
+ entry.src.startsWith("./") || entry.src.startsWith("../")
162
+ ? resolve(root, entry.src)
163
+ : requireFromProject.resolve(entry.src);
164
+ } catch (error) {
165
+ return Response.json({ error: errorMessage(error), ok: false }, { status: 500 });
166
+ }
167
+
168
+ const args = await readArguments(req);
169
+ const result = await callServerExport(modulePath, entry.exportName, args, projectDevEnv(root));
170
+ if (result.ok) {
171
+ return Response.json(result.value ?? null);
172
+ }
173
+ return Response.json({ error: result.message, ok: false }, { status: 500 });
174
+ };
175
+ }
176
+
93
177
  export interface DevServerHandle {
94
178
  port: number;
95
179
  stop: () => void;
@@ -106,10 +190,13 @@ export async function startDev({ port, root }: DevArgs): Promise<DevServerHandle
106
190
  const isSite = existsSync(join(root, "project.json"));
107
191
 
108
192
  let building = Promise.resolve();
193
+ // What the last build collected, which the server-function route answers from.
194
+ let serverEntries: readonly ServerEntry[] = [];
109
195
  const rebuild = () => {
110
196
  building = building.then(async () => {
111
197
  const { buildSite } = await import("@jxsuite/compiler/site");
112
198
  const result = await buildSite(root, { verbose: false });
199
+ ({ serverEntries } = result);
113
200
  if (result.errors.length > 0) {
114
201
  for (const err of result.errors) {
115
202
  console.error(` build error: ${err}`);
@@ -124,6 +211,13 @@ export async function startDev({ port, root }: DevArgs): Promise<DevServerHandle
124
211
  await rebuild();
125
212
  }
126
213
 
214
+ // The built pages call the site worker's routes, and `jx dev` has no worker, so it answers them
215
+ // From the entries the last build collected, ahead of the built pages.
216
+ const serverFunctions = createServerFunctionMiddleware(root, () => serverEntries);
217
+ const distPages = createDistMiddleware(root);
218
+ const siteMiddleware = async (req: Request, url: URL) =>
219
+ (await serverFunctions(req, url)) ?? (await distPages(req, url));
220
+
127
221
  /* SAFETY: createDevServer always calls Bun.serve with a hostname and TCP port, never a unix
128
222
  socket, so the returned Server's `port` (typed `number | undefined` for the socket case) is a
129
223
  number; its promise-returning `stop()` satisfies `() => void`. */
@@ -131,7 +225,7 @@ export async function startDev({ port, root }: DevArgs): Promise<DevServerHandle
131
225
  port,
132
226
  root,
133
227
  ...(isSite && {
134
- middleware: createDistMiddleware(root),
228
+ middleware: siteMiddleware,
135
229
  watch: { preReload: () => rebuild(), reloadOnAnyChange: true },
136
230
  }),
137
231
  })) as DevServerHandle;
@@ -8,10 +8,11 @@
8
8
  * `extensions/` tree, so core keeps no dependency on an extension package (§2).
9
9
  * 2. The project's own dependencies, scanned for anything exporting a `jx-extension.json`.
10
10
  *
11
- * Both are then annotated with two facts only a backend holds: whether the PROJECT resolves the
12
- * package, and whether THIS HOST does. Those are probed rather than declared, because what a
13
- * desktop build stages and what a Worker bundles are different facts and neither is knowable from a
14
- * package list — which is also why the catalogue is a platform capability instead of a constant.
11
+ * Both are then annotated with the one fact only a backend holds: whether the PROJECT resolves the
12
+ * package. It is probed rather than declared, and it is the only resolution asked about: an
13
+ * extension resolves from the project's own install and never from this host, first-party ones
14
+ * included (specs/extensions.md §3), so enabling one the project lacks is always
15
+ * install-then-enable.
15
16
  *
16
17
  * **`installed` is answered here rather than by the client**, because `listPackages` does not mean
17
18
  * one thing across backends: the dev server's own packages route drops a declared dependency it
@@ -184,11 +185,6 @@ export async function buildExtensionCatalog(projectRoot: string): Promise<Extens
184
185
  if (meta.requires) {
185
186
  entry.requires = meta.requires;
186
187
  }
187
- // Bundled means "resolves without a project install": the host answers for it, and the
188
- // Project does not have to.
189
- if (found.host && !found.project) {
190
- entry.bundled = true;
191
- }
192
188
  entries.push(entry);
193
189
  }
194
190
 
@@ -238,9 +234,6 @@ export async function buildExtensionCatalog(projectRoot: string): Promise<Extens
238
234
  if (loaded.formats.length > 0) {
239
235
  entry.formats = loaded.formats;
240
236
  }
241
- if (found.host) {
242
- entry.bundled = true;
243
- }
244
237
  entries.push(entry);
245
238
  }
246
239
 
@@ -0,0 +1,140 @@
1
+ /**
2
+ * File responses on `node:fs`, answering what `new Response(Bun.file(path))` answered.
3
+ *
4
+ * The desktop backend runs on Electron's Node (specs/desktop.md §7.1), so the servers it reaches
5
+ * cannot hand `Bun.file` to a `Response`. `openAsBlob` is the shared equivalent: a lazily read,
6
+ * file-backed `Blob` whose `type` becomes the response's `Content-Type`. What it lacks is Bun's
7
+ * inference of that type from the extension, which {@link HOST_MEDIA_TYPES} reproduces for every
8
+ * extension a project or the Studio assets serve; `tests/file-response.test.ts` holds each entry
9
+ * equal to `Bun.file(...).type`. The corrections Jx makes to a host's table (`mediaTypeForPath`)
10
+ * are applied by callers on top of this, exactly as they were on top of Bun's.
11
+ *
12
+ * @docs studio/desktop
13
+ */
14
+ import { openAsBlob, statSync } from "node:fs";
15
+ import { extname } from "node:path";
16
+
17
+ /** The type `Bun.file` infers for an extension, where it is not `application/octet-stream`. */
18
+ export const HOST_MEDIA_TYPES = new Map<string, string>([
19
+ [".7z", "application/x-7z-compressed"],
20
+ [".aac", "audio/x-aac"],
21
+ [".ai", "application/postscript"],
22
+ [".apng", "image/apng"],
23
+ [".atom", "application/atom+xml"],
24
+ [".avif", "image/avif"],
25
+ [".bmp", "image/x-ms-bmp"],
26
+ [".cjs", "text/javascript;charset=utf-8"],
27
+ [".class", "application/java-vm"],
28
+ [".css", "text/css;charset=utf-8"],
29
+ [".csv", "text/csv"],
30
+ [".dmg", "application/x-apple-diskimage"],
31
+ [".doc", "application/msword"],
32
+ [".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
33
+ [".eot", "application/vnd.ms-fontobject"],
34
+ [".eps", "application/postscript"],
35
+ [".epub", "application/epub+zip"],
36
+ [".exe", "application/x-msdownload"],
37
+ [".flac", "audio/x-flac"],
38
+ [".gif", "image/gif"],
39
+ [".glb", "model/gltf-binary"],
40
+ [".gltf", "model/gltf+json"],
41
+ [".gz", "application/gzip"],
42
+ [".hbs", "text/x-handlebars-template"],
43
+ [".heic", "image/heic"],
44
+ [".htm", "text/html;charset=utf-8"],
45
+ [".html", "text/html;charset=utf-8"],
46
+ [".ico", "image/x-icon"],
47
+ [".ics", "text/calendar"],
48
+ [".iso", "application/x-iso9660-image"],
49
+ [".jpeg", "image/jpeg"],
50
+ [".jpg", "image/jpeg"],
51
+ [".js", "text/javascript;charset=utf-8"],
52
+ [".json", "application/json;charset=utf-8"],
53
+ [".jsonld", "application/ld+json"],
54
+ [".jsx", "text/javascript;charset=utf-8"],
55
+ [".log", "text/plain;charset=utf-8"],
56
+ [".m4a", "audio/x-m4a"],
57
+ [".map", "application/json;charset=utf-8"],
58
+ [".markdown", "text/markdown"],
59
+ [".md", "text/markdown"],
60
+ [".mdx", "text/mdx"],
61
+ [".mjs", "text/javascript;charset=utf-8"],
62
+ [".mov", "video/quicktime"],
63
+ [".mp3", "audio/mpeg"],
64
+ [".mp4", "video/mp4"],
65
+ [".mts", "text/javascript;charset=utf-8"],
66
+ [".oga", "audio/ogg"],
67
+ [".ogg", "audio/ogg"],
68
+ [".ogv", "video/ogg"],
69
+ [".opus", "audio/ogg"],
70
+ [".otf", "font/otf"],
71
+ [".pdf", "application/pdf"],
72
+ [".php", "application/x-httpd-php"],
73
+ [".png", "image/png"],
74
+ [".ppt", "application/vnd.ms-powerpoint"],
75
+ [".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"],
76
+ [".psd", "image/vnd.adobe.photoshop"],
77
+ [".rar", "application/x-rar-compressed"],
78
+ [".rss", "application/rss+xml"],
79
+ [".rtf", "text/rtf"],
80
+ [".sh", "application/x-sh"],
81
+ [".srt", "application/x-subrip"],
82
+ [".svg", "image/svg+xml"],
83
+ [".tar", "application/x-tar"],
84
+ [".tif", "image/tiff"],
85
+ [".tiff", "image/tiff"],
86
+ [".toml", "application/toml"],
87
+ [".ts", "text/javascript;charset=utf-8"],
88
+ [".tsv", "text/tab-separated-values"],
89
+ [".tsx", "text/javascript;charset=utf-8"],
90
+ [".ttf", "font/ttf"],
91
+ [".txt", "text/plain;charset=utf-8"],
92
+ [".vtt", "text/vtt"],
93
+ [".wasm", "application/wasm"],
94
+ [".wav", "audio/x-wav"],
95
+ [".weba", "audio/webm"],
96
+ [".webm", "video/webm"],
97
+ [".webmanifest", "application/manifest+json"],
98
+ [".webp", "image/webp"],
99
+ [".woff", "font/woff"],
100
+ [".woff2", "font/woff2"],
101
+ [".xls", "application/vnd.ms-excel"],
102
+ [".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
103
+ [".xml", "application/xml"],
104
+ [".yaml", "text/yaml"],
105
+ [".yml", "text/yaml"],
106
+ [".zip", "application/zip"],
107
+ ]);
108
+
109
+ const FALLBACK_MEDIA_TYPE = "application/octet-stream";
110
+
111
+ /** The media type Bun would infer for `path` from its extension (case-insensitive). */
112
+ export function hostMediaType(path: string): string {
113
+ return HOST_MEDIA_TYPES.get(extname(path).toLowerCase()) ?? FALLBACK_MEDIA_TYPE;
114
+ }
115
+
116
+ /** True when `path` names a regular file (Bun's `exists()` is false for a directory). */
117
+ function isFile(path: string): boolean {
118
+ try {
119
+ return statSync(path).isFile();
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The file at `path` as a lazily read `Blob` typed by its extension, or null when there is no
127
+ * regular file there.
128
+ */
129
+ export async function openFile(path: string): Promise<Blob | null> {
130
+ if (!isFile(path)) {
131
+ return null;
132
+ }
133
+ return openAsBlob(path, { type: hostMediaType(path) });
134
+ }
135
+
136
+ /** A response streaming the file at `path`, or null when there is no regular file there. */
137
+ export async function fileResponse(path: string, init?: ResponseInit): Promise<Response | null> {
138
+ const blob = await openFile(path);
139
+ return blob ? new Response(blob, init) : null;
140
+ }
package/src/fs-glob.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * File globbing on `node:fs`, answering what `Bun.Glob#scan({ cwd, dot: false })` answered.
3
+ *
4
+ * The desktop backend runs on Electron's Node (specs/desktop.md §7.1), so nothing it reaches may
5
+ * call `Bun.Glob`. `fs.promises.glob` is the shared API, and two of its defaults differ from Bun's:
6
+ * it yields directories as well as files, and it yields a symbolic link as itself. A third
7
+ * difference is in the matcher, below. Bun's scan yields regular files only and does not follow
8
+ * links, so both are filtered here. `tests/fs-glob.test.ts` holds the two answers equal on the same
9
+ * tree.
10
+ *
11
+ * Paths are relative to `cwd` with the platform's own separator, as Bun's were.
12
+ */
13
+ import type { Dirent } from "node:fs";
14
+ import { glob, opendir } from "node:fs/promises";
15
+ import { join, relative } from "node:path";
16
+
17
+ /** Bun's scan neither yields nor descends through a symbolic link, file or directory. */
18
+ function isLink(entry: Dirent): boolean {
19
+ return entry.isSymbolicLink();
20
+ }
21
+
22
+ /**
23
+ * Bun expands a one-alternative brace (`*.{json}`) and Node's matcher keeps it literal, so a
24
+ * pattern built from a list that happens to hold one extension would match nothing. Unwrap those.
25
+ */
26
+ function unwrapSingleBraces(pattern: string): string {
27
+ return pattern.replaceAll(/\{([^{},]*)\}/g, "$1");
28
+ }
29
+
30
+ /**
31
+ * Every regular file under `cwd` matching `pattern`, skipping dot-prefixed names.
32
+ *
33
+ * @yields {string} Each match, relative to `cwd`
34
+ */
35
+ export async function* scanFiles(pattern: string, cwd: string): AsyncGenerator<string> {
36
+ // Bun's scan throws for a root it cannot read; Node's glob yields nothing. Opening it first throws.
37
+ const dir = await opendir(cwd);
38
+ await dir.close();
39
+ const options = { cwd, exclude: isLink, withFileTypes: true } as const;
40
+ for await (const entry of glob(unwrapSingleBraces(pattern), options)) {
41
+ if (entry.isFile()) {
42
+ yield relative(cwd, join(entry.parentPath, entry.name));
43
+ }
44
+ }
45
+ }
@@ -54,6 +54,9 @@ import { handleResolve, handleServerFunction } from "./resolve.ts";
54
54
  import { handleJxMounts } from "./jx-mounts.ts";
55
55
  import { loopbackGate, originHostGate } from "./net-guard.ts";
56
56
  import { problem } from "./problem.ts";
57
+ import { scanFiles } from "./fs-glob.ts";
58
+ import { serve } from "#serve";
59
+ import { bundleToString } from "@jxsuite/compiler/bundler";
57
60
 
58
61
  /** A running live preview for one project. */
59
62
  export interface LivePreview {
@@ -116,7 +119,7 @@ function overlayFor(projectRoot: string): Overlay {
116
119
  }
117
120
 
118
121
  interface Running extends LivePreview {
119
- server: { stop: (closeActiveConnections?: boolean) => void };
122
+ server: { stop: (closeActiveConnections?: boolean) => Promise<void> };
120
123
  hub: SseHub;
121
124
  /** This origin's own resolver credential, distinct from any editing server's. */
122
125
  token: string;
@@ -132,6 +135,8 @@ interface Running extends LivePreview {
132
135
 
133
136
  /** One origin per project root — a second Open in Browser reuses the first one's port. */
134
137
  const running = new Map<string, Running>();
138
+ /** Origins mid-bind, so two starts racing for one project share a server. */
139
+ const starting = new Map<string, Promise<Running>>();
135
140
 
136
141
  /** The runtime bundle is the same bytes for every project, so it is read once per process. */
137
142
  let runtimeBundle: string | null = null;
@@ -162,11 +167,10 @@ async function loadRuntimeBundle(): Promise<string> {
162
167
  } catch {
163
168
  // No published dist on this install; fall through and build one.
164
169
  }
165
- const result = await Bun.build({
166
- entrypoints: [require_.resolve("@jxsuite/runtime")],
170
+ runtimeBundle = await bundleToString(require_.resolve("@jxsuite/runtime"), {
171
+ minify: false,
167
172
  target: "browser",
168
173
  });
169
- runtimeBundle = await result.outputs[0]!.text();
170
174
  return runtimeBundle;
171
175
  }
172
176
 
@@ -178,7 +182,7 @@ async function treePaths(projectRoot: string, overlay: Map<string, string>): Pro
178
182
  if (!existsSync(dir)) {
179
183
  continue;
180
184
  }
181
- for await (const match of new Bun.Glob("**/*").scan({ cwd: dir, dot: false })) {
185
+ for await (const match of scanFiles("**/*", dir)) {
182
186
  found.add(`${root}${match.replaceAll("\\", "/")}`);
183
187
  }
184
188
  }
@@ -419,7 +423,20 @@ export async function startLivePreview(projectRoot: string): Promise<LivePreview
419
423
  await ensureCache(existing, projectRoot);
420
424
  return report(existing, projectRoot);
421
425
  }
426
+ let pending = starting.get(projectRoot);
427
+ if (!pending) {
428
+ pending = listen(projectRoot).finally(() => starting.delete(projectRoot));
429
+ starting.set(projectRoot, pending);
430
+ }
431
+ const entry = await pending;
432
+ /* Counted before answering, because the caller reports it to the author and "0 pages" from a site
433
+ with pages is worse than the moment it costs to walk the tree. */
434
+ await ensureCache(entry, projectRoot);
435
+ return report(entry, projectRoot);
436
+ }
422
437
 
438
+ /** Bind the live preview origin for `projectRoot` and register it. */
439
+ async function listen(projectRoot: string): Promise<Running> {
423
440
  const entry: Running = {
424
441
  burstStartedAt: null,
425
442
  cache: null,
@@ -431,11 +448,11 @@ export async function startLivePreview(projectRoot: string): Promise<LivePreview
431
448
  port: 0,
432
449
  reloadTimer: null,
433
450
  routes: 0,
434
- server: { stop: () => {} },
451
+ server: { stop: async () => {} },
435
452
  token: crypto.randomUUID(),
436
453
  };
437
454
 
438
- const server = Bun.serve({
455
+ const server = await serve({
439
456
  fetch: (req) => handle(entry, projectRoot, req),
440
457
  hostname: "127.0.0.1",
441
458
  /* The composed page is assembled per request and the stream is long-lived; the dev server's
@@ -449,10 +466,7 @@ export async function startLivePreview(projectRoot: string): Promise<LivePreview
449
466
  entry.origin = server.url.origin;
450
467
  entry.port = Number(server.url.port);
451
468
  running.set(projectRoot, entry);
452
- /* Counted before answering, because the caller reports it to the author and "0 pages" from a site
453
- with pages is worse than the moment it costs to walk the tree. */
454
- await ensureCache(entry, projectRoot);
455
- return report(entry, projectRoot);
469
+ return entry;
456
470
  }
457
471
 
458
472
  /** What a caller hands back to Studio. */
@@ -616,7 +630,7 @@ export function stopLivePreviews(): void {
616
630
  if (entry.reloadTimer) {
617
631
  clearTimeout(entry.reloadTimer);
618
632
  }
619
- entry.server.stop(true);
633
+ void entry.server.stop(true);
620
634
  }
621
635
  running.clear();
622
636
  }
package/src/net-guard.ts CHANGED
@@ -21,6 +21,7 @@ import { realpathSync } from "node:fs";
21
21
  import { resolveAssetUrl } from "@jxsuite/schema/asset-paths";
22
22
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
23
23
  import { problem } from "./problem.ts";
24
+ import { fileResponse } from "./file-response.ts";
24
25
 
25
26
  export const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
26
27
 
@@ -300,11 +301,7 @@ export async function serveContained(absPath: string, root: string): Promise<Res
300
301
  if (!safe) {
301
302
  return null;
302
303
  }
303
- const file = Bun.file(safe);
304
- if (await file.exists()) {
305
- return new Response(file);
306
- }
307
- return null;
304
+ return fileResponse(safe);
308
305
  }
309
306
 
310
307
  /**