@jxsuite/server 0.33.0 → 0.34.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,6 +16,7 @@
16
16
  ".": "./src/server.ts",
17
17
  "./ai-api": "./src/ai-api.ts",
18
18
  "./packages": "./src/packages.ts",
19
+ "./project-server": "./src/project-server.ts",
19
20
  "./refactor": "./src/refactor/index.ts",
20
21
  "./resolve": "./src/resolve.ts",
21
22
  "./types": "./src/types.ts"
@@ -29,11 +30,11 @@
29
30
  "test:coverage": "bun test --isolate --coverage"
30
31
  },
31
32
  "dependencies": {
32
- "@jxsuite/compiler": "^0.33.0",
33
- "@jxsuite/create": "^0.33.0",
34
- "@jxsuite/parser": "^0.33.0",
35
- "@jxsuite/runtime": "^0.33.0",
36
- "@jxsuite/schema": "^0.33.0",
33
+ "@jxsuite/compiler": "^0.34.0",
34
+ "@jxsuite/create": "^0.34.0",
35
+ "@jxsuite/parser": "^0.34.0",
36
+ "@jxsuite/runtime": "^0.34.0",
37
+ "@jxsuite/schema": "^0.34.0",
37
38
  "chokidar": "^5.0.0",
38
39
  "zod": "^4.4.3"
39
40
  }
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Shared project server factory. Generalizes the chromium variant's ad-hoc in-process Bun.serve
3
+ * into one createProjectServer() used by the chromium desktop launcher (and, in Phase 7,
4
+ * electrobun).
5
+ *
6
+ * It serves three surfaces from a single loopback-bound Bun.serve:
7
+ *
8
+ * - The studio shell + canvas iframe doc + iframe bundle, under a non-colliding studio namespace (so
9
+ * a project's own asset tree never collides);
10
+ * - Project files at their natural URLs (absolute-under-root, root-relative, and public/), each
11
+ * contained against path traversal and symlink escape;
12
+ * - The canonical resolve routes (the jx-resolve and jx-server POST endpoints) plus the WS-RPC
13
+ * dispatch, both gated by a per-server token.
14
+ *
15
+ * SECURITY MODEL (see the spec's SECURITY block):
16
+ *
17
+ * - Loopback bind (127.0.0.1) is the PRIMARY control: other local processes / LAN pages cannot read a
18
+ * loopback page's location, so they cannot steal the URL token.
19
+ * - The token is the HARD gate on every privileged surface — the WS upgrade and the two HTTP resolve
20
+ * routes (which both do dynamic import() and are therefore RCE-capable).
21
+ * - Origin/Host is BEST-EFFORT defense-in-depth: a loopback Origin host {127.0.0.1, localhost, ::1}
22
+ * or an absent Origin is accepted; the privileged HTTP routes additionally reject a Host header
23
+ * that is not loopback (anti-DNS-rebinding).
24
+ * - File reads and the project write-path are contained with a lexical relative() check plus a
25
+ * realpath re-check (symlink containment).
26
+ */
27
+
28
+ import { isAbsolute, relative, resolve, sep } from "node:path";
29
+ import { realpathSync } from "node:fs";
30
+ import { handleResolve, handleServerFunction } from "./resolve.ts";
31
+ import { handleAiApi } from "./ai-api.ts";
32
+ import { resolveNpmPath } from "./server.ts";
33
+
34
+ /** A resolved per-window session: its project root plus its RPC handler map. */
35
+ export interface ProjectServerSession {
36
+ projectRoot: string | null;
37
+ handlers: Record<string, (params: unknown) => Promise<unknown>>;
38
+ }
39
+
40
+ export interface CreateProjectServerOptions {
41
+ /**
42
+ * Resolve the session for a given window id (the `win` query param / stashed socket data).
43
+ * chromium passes `() => defaultSession`; electrobun (Phase 7) swaps it for a per-window lookup.
44
+ * A missing/unknown window returns `null`, which the routes treat as a 404 fail-closed.
45
+ */
46
+ resolveSession: (winId: string | null) => ProjectServerSession | null;
47
+ /** Absolute dir holding the studio shell + canvas.html + dist/. */
48
+ studioDir: string;
49
+ /** Per-server RPC token. Defaults to a fresh random UUID. */
50
+ rpcToken?: string;
51
+ /** Bind hostname. Defaults to loopback 127.0.0.1. */
52
+ hostname?: string;
53
+ /** Bind port. Defaults to 0 (ephemeral). */
54
+ port?: number;
55
+ }
56
+
57
+ export interface ProjectServerHandle {
58
+ server: ReturnType<typeof Bun.serve>;
59
+ url: string;
60
+ wsUrl: string;
61
+ rpcToken: string;
62
+ canvasUrl: string;
63
+ stop: () => void;
64
+ }
65
+
66
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
67
+
68
+ /** True when a hostname (no port) is a loopback host. Used for best-effort Origin/Host checks. */
69
+ function isLoopbackHost(host: string | null | undefined): boolean {
70
+ if (!host) {
71
+ return false;
72
+ }
73
+ // Strip a trailing :port (but keep bracketed IPv6 intact for the literal set).
74
+ let h = host;
75
+ if (h.startsWith("[")) {
76
+ const end = h.indexOf("]");
77
+ h = end === -1 ? h : h.slice(0, end + 1);
78
+ } else {
79
+ const colon = h.indexOf(":");
80
+ if (colon !== -1) {
81
+ h = h.slice(0, colon);
82
+ }
83
+ }
84
+ return LOOPBACK_HOSTS.has(h.toLowerCase());
85
+ }
86
+
87
+ /**
88
+ * Best-effort loopback Origin/Host check (defense-in-depth, not the hard gate).
89
+ *
90
+ * Accept when the Origin's host is loopback OR Origin is absent (Bun-native / test clients send no
91
+ * Origin). Do NOT hardcode-match one literal origin — localhost and 127.0.0.1 are distinct origins,
92
+ * and a too-strict check would 403 legitimate clients.
93
+ */
94
+ function originIsLoopbackOrAbsent(req: Request): boolean {
95
+ const origin = req.headers.get("origin");
96
+ if (!origin) {
97
+ return true;
98
+ }
99
+ try {
100
+ return isLoopbackHost(new URL(origin).hostname);
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Reject a Host header that is present and NOT loopback (defeats DNS-rebinding, where Host is the
108
+ * attacker domain). An absent Host (Bun-native clients) is accepted.
109
+ */
110
+ function hostIsLoopbackOrAbsent(req: Request): boolean {
111
+ const host = req.headers.get("host");
112
+ if (!host) {
113
+ return true;
114
+ }
115
+ return isLoopbackHost(host);
116
+ }
117
+
118
+ /** Normalize a path for cross-platform containment comparison (separators + case on Windows). */
119
+ function normalizeForCompare(p: string): string {
120
+ const slashed = p.replaceAll("\\", "/");
121
+ return process.platform === "win32" ? slashed.toLowerCase() : slashed;
122
+ }
123
+
124
+ /**
125
+ * Contain `absPath` within `root`: a lexical relative() check followed by a realpath re-check so a
126
+ * symlink inside the tree cannot point outside it. Returns the (possibly realpath'd) absolute path
127
+ * when contained, or null otherwise. The caller must already have decoded the URL pathname.
128
+ */
129
+ function containedPath(absPath: string, root: string): string | null {
130
+ // (1) Lexical containment.
131
+ const rel = relative(root, absPath);
132
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
133
+ return null;
134
+ }
135
+ // (2) realpath BOTH and re-check the realpath is still under realRoot (symlink containment).
136
+ let realRoot: string;
137
+ let realPath: string;
138
+ try {
139
+ realRoot = realpathSync(root);
140
+ } catch {
141
+ // Root itself is unresolvable: fall back to lexical-only containment.
142
+ return absPath;
143
+ }
144
+ try {
145
+ realPath = realpathSync(absPath);
146
+ } catch {
147
+ // Target does not exist yet — lexical containment already passed; let the caller's existence
148
+ // Check decide. (Reads will 404; the write-path resolves its own parent separately.)
149
+ return absPath;
150
+ }
151
+ const nRoot = normalizeForCompare(realRoot);
152
+ const nPath = normalizeForCompare(realPath);
153
+ if (nPath === nRoot || nPath.startsWith(nRoot.endsWith("/") ? nRoot : `${nRoot}/`)) {
154
+ return realPath;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Serve a file under studioDir (or any root) if it both exists and is contained. Returns the
161
+ * Response, or null when missing/traversed (caller decides the fallthrough status).
162
+ */
163
+ async function serveContained(absPath: string, root: string): Promise<Response | null> {
164
+ const safe = containedPath(absPath, root);
165
+ if (!safe) {
166
+ return null;
167
+ }
168
+ const file = Bun.file(safe);
169
+ if (await file.exists()) {
170
+ return new Response(file);
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /**
176
+ * Try to serve a project file at its natural URL: absolute-under-root, then root-relative, then
177
+ * public/. Each candidate goes through containedPath (realpath). `decodedPath` is the once-decoded
178
+ * URL pathname (leading slash, forward slashes).
179
+ */
180
+ async function serveProjectFile(decodedPath: string, root: string): Promise<Response | null> {
181
+ // Map the URL pathname back to a filesystem path. On Windows the browser requests an absolute
182
+ // Path as /C:/Users/… (leading slash + forward slashes); drop the slash before the drive letter.
183
+ // A POSIX absolute path arrives as //abs/path.
184
+ const fsPath = decodedPath.startsWith("//")
185
+ ? decodedPath.slice(1)
186
+ : decodedPath.replace(/^\/([A-Za-z]:)/, "$1");
187
+
188
+ // 1. Absolute path that falls under the project root.
189
+ if (normalizeForCompare(fsPath).startsWith(normalizeForCompare(root))) {
190
+ const res = await serveContained(fsPath, root);
191
+ if (res) {
192
+ return res;
193
+ }
194
+ }
195
+
196
+ // 2. Root-relative.
197
+ const relRes = await serveContained(resolve(root, `.${decodedPath}`), root);
198
+ if (relRes) {
199
+ return relRes;
200
+ }
201
+
202
+ // 3. public/ subdirectory.
203
+ const pubRes = await serveContained(resolve(root, "public", `.${decodedPath}`), root);
204
+ if (pubRes) {
205
+ return pubRes;
206
+ }
207
+
208
+ return null;
209
+ }
210
+
211
+ /**
212
+ * Create and start a shared project server bound to loopback.
213
+ *
214
+ * @param options See {@link CreateProjectServerOptions}.
215
+ * @returns A handle with the bound url/wsUrl, the rpcToken, the canvas iframe URL, and stop().
216
+ */
217
+ export function createProjectServer(options: CreateProjectServerOptions): ProjectServerHandle {
218
+ const {
219
+ resolveSession,
220
+ studioDir,
221
+ rpcToken = crypto.randomUUID(),
222
+ hostname = "127.0.0.1",
223
+ port = 0,
224
+ } = options;
225
+
226
+ // Bundle cache for npm bare specifiers (mirrors server.ts).
227
+ const bundleCache = new Map<string, string>();
228
+
229
+ const server = Bun.serve<{ winId: string | null }>({
230
+ hostname,
231
+ port,
232
+ // Keep SSE/AI streams alive (mirrors the dev server's generous idle timeout).
233
+ idleTimeout: 120,
234
+ async fetch(req, srv) {
235
+ const url = new URL(req.url);
236
+ // Decode the pathname ONCE, then run ALL containment on the decoded path. Reject a path that
237
+ // Still contains an encoded dot/slash after one decode (over-encoding bypass attempt).
238
+ let path: string;
239
+ try {
240
+ path = decodeURIComponent(url.pathname);
241
+ } catch {
242
+ return new Response("Bad request", { status: 400 });
243
+ }
244
+ if (/%2e|%2f/i.test(path)) {
245
+ return new Response("Not found", { status: 404 });
246
+ }
247
+ // Collapse runs of leading slashes to a single one. The natural-URL branch re-expands a POSIX
248
+ // //abs prefix explicitly, so a single leading slash here is the right normal form.
249
+ const normPath = path.replace(/^\/{2,}/, "/");
250
+
251
+ const winId = url.searchParams.get("win");
252
+
253
+ // 1. WebSocket upgrade — token + loopback Origin/Host are the hard gate.
254
+ const upgrade = req.headers.get("upgrade");
255
+ if (upgrade && upgrade.toLowerCase() === "websocket") {
256
+ const token = url.searchParams.get("token");
257
+ if (token !== rpcToken || !originIsLoopbackOrAbsent(req) || !hostIsLoopbackOrAbsent(req)) {
258
+ return new Response("Forbidden", { status: 403 });
259
+ }
260
+ if (srv.upgrade(req, { data: { winId } })) {
261
+ return;
262
+ }
263
+ return new Response("Upgrade failed", { status: 400 });
264
+ }
265
+
266
+ // 2. AI SSE — keep the existing handleAiApi behavior (rewrite the studio-ai prefix).
267
+ if (normPath.startsWith("/__studio__/ai/")) {
268
+ const aiUrl = new URL(req.url);
269
+ aiUrl.pathname = normPath.replace("/__studio__/ai/", "/__studio/ai/");
270
+ const aiResponse = await handleAiApi(req, aiUrl);
271
+ if (aiResponse) {
272
+ return aiResponse;
273
+ }
274
+ }
275
+
276
+ // 3. Studio assets under the non-colliding /__studio__/ namespace.
277
+ if (normPath.startsWith("/__studio__/")) {
278
+ const assetRel = normPath.slice("/__studio__/".length);
279
+ const assetPath = resolve(studioDir, `.${sep}${assetRel}`);
280
+ const res = await serveContained(assetPath, studioDir);
281
+ if (res) {
282
+ // Never leak the tokened URL cross-origin via Referer, while keeping same-origin requests
283
+ // Normal. MUST be `same-origin`, not `no-referrer`: under `no-referrer` Chromium serializes
284
+ // The Origin header of the canvas iframe's own same-origin POSTs as `null` (Fetch spec
285
+ // Origin-serialization respects referrer policy), which fails originIsLoopbackOrAbsent and
286
+ // Self-403s /__jx_resolve__ + /__jx_server__ from the very document this server serves.
287
+ const headers = new Headers(res.headers);
288
+ headers.set("Referrer-Policy", "same-origin");
289
+ return new Response(res.body, { headers, status: res.status });
290
+ }
291
+ return new Response("Not found", { status: 404 });
292
+ }
293
+
294
+ // 4. Resolve the session (fail-closed).
295
+ const session = resolveSession(winId);
296
+ if (!session) {
297
+ return new Response("Unknown window", { status: 404 });
298
+ }
299
+ const root = session.projectRoot;
300
+ if (!root) {
301
+ return new Response("No project", { status: 404 });
302
+ }
303
+
304
+ // 5. Privileged HTTP routes (RCE-capable: dynamic import()). Gate with token + loopback
305
+ // Origin/Host BEFORE dispatch.
306
+ if (
307
+ (normPath === "/__jx_resolve__" || normPath === "/__jx_server__") &&
308
+ req.method === "POST"
309
+ ) {
310
+ const token = url.searchParams.get("token");
311
+ if (token !== rpcToken || !originIsLoopbackOrAbsent(req) || !hostIsLoopbackOrAbsent(req)) {
312
+ return new Response("Forbidden", { status: 403 });
313
+ }
314
+ if (normPath === "/__jx_resolve__") {
315
+ return handleResolve(req, root, root);
316
+ }
317
+ return handleServerFunction(req, root);
318
+ }
319
+
320
+ // 6. Project files at natural URLs.
321
+ const fileRes = await serveProjectFile(normPath, root);
322
+ if (fileRes) {
323
+ return fileRes;
324
+ }
325
+
326
+ // 7. npm bare specifiers via node_modules (on-demand bundle, mirrors server.ts).
327
+ const resolved = resolveNpmPath(root, normPath);
328
+ if (resolved) {
329
+ if (!bundleCache.has(resolved)) {
330
+ try {
331
+ const result = await Bun.build({
332
+ entrypoints: [resolved],
333
+ format: "esm",
334
+ minify: false,
335
+ });
336
+ if (result.success && result.outputs.length > 0) {
337
+ bundleCache.set(resolved, await result.outputs[0]!.text());
338
+ }
339
+ } catch (error) {
340
+ console.error("Bundle failed for", resolved, error);
341
+ }
342
+ }
343
+ const bundled = bundleCache.get(resolved);
344
+ if (bundled) {
345
+ return new Response(bundled, {
346
+ headers: { "Content-Type": "application/javascript; charset=utf-8" },
347
+ });
348
+ }
349
+ }
350
+
351
+ return new Response("Not found", { status: 404 });
352
+ },
353
+
354
+ websocket: {
355
+ async message(ws, raw) {
356
+ let msg: { id: number; method: string; params?: unknown };
357
+ try {
358
+ msg = JSON.parse(raw as string) as { id: number; method: string; params?: unknown };
359
+ } catch {
360
+ ws.send(JSON.stringify({ error: "Invalid JSON", id: 0 }));
361
+ return;
362
+ }
363
+
364
+ // Re-resolve the session FRESH each message (fail-closed for Phase 7 multi-window).
365
+ const session = resolveSession(ws.data.winId);
366
+ if (!session) {
367
+ ws.send(JSON.stringify({ error: "Unknown window", id: msg.id }));
368
+ ws.close();
369
+ return;
370
+ }
371
+
372
+ const handler = session.handlers[msg.method];
373
+ if (!handler) {
374
+ ws.send(JSON.stringify({ error: `Unknown method: ${msg.method}`, id: msg.id }));
375
+ return;
376
+ }
377
+
378
+ try {
379
+ const result = await handler(msg.params);
380
+ ws.send(JSON.stringify({ id: msg.id, result: result ?? null }));
381
+ } catch (error: unknown) {
382
+ ws.send(
383
+ JSON.stringify({
384
+ error: error instanceof Error ? error.message : String(error),
385
+ id: msg.id,
386
+ }),
387
+ );
388
+ }
389
+ },
390
+ },
391
+ });
392
+
393
+ const boundHost = hostname.includes(":") ? `[${hostname}]` : hostname;
394
+ const url = `http://${boundHost}:${server.port}`;
395
+ const wsUrl = `ws://${boundHost}:${server.port}`;
396
+ const canvasUrl = `${url}/__studio__/canvas.html`;
397
+
398
+ return {
399
+ server,
400
+ url,
401
+ wsUrl,
402
+ rpcToken,
403
+ canvasUrl,
404
+ stop: () => {
405
+ void server.stop(true);
406
+ },
407
+ };
408
+ }
package/src/server.ts CHANGED
@@ -212,6 +212,14 @@ export async function createDevServer(options: {
212
212
  // Bundle cache for npm packages (bare specifier → bundled JS)
213
213
  const bundleCache = new Map<string, string>();
214
214
 
215
+ // Dev-server cache policy: always revalidate. Without any Cache-Control the browser HEURISTICALLY
216
+ // Caches responses (there are no validators either), so a plain reload can serve a stale
217
+ // Studio.js/canvas bundle — which shows up as half-updated UI after a rebuild (the iframe assets
218
+ // Are query-cache-busted per mount, the parent bundle is not).
219
+ const NO_CACHE = { "Cache-Control": "no-cache" };
220
+ const fileResponse = (file: ReturnType<typeof Bun.file>) =>
221
+ new Response(file, { headers: NO_CACHE });
222
+
215
223
  // Active studio project root (set via /__studio/activate, used for static file fallback)
216
224
  let activeProjectRoot: string | null = null;
217
225
 
@@ -289,7 +297,7 @@ export async function createDevServer(options: {
289
297
  if (activeProjectRoot && normSep(fsPath).startsWith(normSep(activeProjectRoot))) {
290
298
  const file = Bun.file(fsPath);
291
299
  if (await file.exists()) {
292
- return new Response(file);
300
+ return fileResponse(file);
293
301
  }
294
302
  }
295
303
 
@@ -299,12 +307,12 @@ export async function createDevServer(options: {
299
307
  if (activeProjectRoot) {
300
308
  const projectFile = Bun.file(resolve(activeProjectRoot, `.${path}`));
301
309
  if (await projectFile.exists()) {
302
- return new Response(projectFile);
310
+ return fileResponse(projectFile);
303
311
  }
304
312
  // Mirror production: public/ contents are served at root
305
313
  const publicFile = Bun.file(resolve(activeProjectRoot, "public", `.${path}`));
306
314
  if (await publicFile.exists()) {
307
- return new Response(publicFile);
315
+ return fileResponse(publicFile);
308
316
  }
309
317
  }
310
318
 
@@ -331,6 +339,7 @@ export async function createDevServer(options: {
331
339
  if (bundled) {
332
340
  return new Response(bundled, {
333
341
  headers: {
342
+ ...NO_CACHE,
334
343
  "Content-Type": "application/javascript; charset=utf-8",
335
344
  },
336
345
  });
@@ -346,11 +355,11 @@ export async function createDevServer(options: {
346
355
  if (handleSSE && path.endsWith(".html") && !path.startsWith("/packages/studio/")) {
347
356
  const html = await file.text();
348
357
  return new Response(injectSSE(html), {
349
- headers: { "Content-Type": "text/html; charset=utf-8" },
358
+ headers: { ...NO_CACHE, "Content-Type": "text/html; charset=utf-8" },
350
359
  });
351
360
  }
352
361
 
353
- return new Response(file);
362
+ return fileResponse(file);
354
363
  },
355
364
 
356
365
  port,