@jxsuite/server 0.32.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.
@@ -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
@@ -19,6 +19,7 @@ import { createWatcher, injectSSE } from "./watch.ts";
19
19
  import { handleResolve, handleServerFunction } from "./resolve.ts";
20
20
  import { handleStudioApi } from "./studio-api.ts";
21
21
  import { handleCodeApi } from "./code-api.ts";
22
+ import { handleAiApi } from "./ai-api.ts";
22
23
  import { existsSync, readFileSync } from "node:fs";
23
24
 
24
25
  /**
@@ -211,6 +212,14 @@ export async function createDevServer(options: {
211
212
  // Bundle cache for npm packages (bare specifier → bundled JS)
212
213
  const bundleCache = new Map<string, string>();
213
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
+
214
223
  // Active studio project root (set via /__studio/activate, used for static file fallback)
215
224
  let activeProjectRoot: string | null = null;
216
225
 
@@ -252,6 +261,12 @@ export async function createDevServer(options: {
252
261
  return Response.json({ ok: true, root: activeProjectRoot });
253
262
  }
254
263
 
264
+ // AI proxy endpoints (/__studio/ai/chat, /__studio/ai/models)
265
+ const aiRes = await handleAiApi(req, url);
266
+ if (aiRes) {
267
+ return aiRes;
268
+ }
269
+
255
270
  const codeRes = await handleCodeApi(req, url);
256
271
  if (codeRes) {
257
272
  return codeRes;
@@ -282,7 +297,7 @@ export async function createDevServer(options: {
282
297
  if (activeProjectRoot && normSep(fsPath).startsWith(normSep(activeProjectRoot))) {
283
298
  const file = Bun.file(fsPath);
284
299
  if (await file.exists()) {
285
- return new Response(file);
300
+ return fileResponse(file);
286
301
  }
287
302
  }
288
303
 
@@ -292,12 +307,12 @@ export async function createDevServer(options: {
292
307
  if (activeProjectRoot) {
293
308
  const projectFile = Bun.file(resolve(activeProjectRoot, `.${path}`));
294
309
  if (await projectFile.exists()) {
295
- return new Response(projectFile);
310
+ return fileResponse(projectFile);
296
311
  }
297
312
  // Mirror production: public/ contents are served at root
298
313
  const publicFile = Bun.file(resolve(activeProjectRoot, "public", `.${path}`));
299
314
  if (await publicFile.exists()) {
300
- return new Response(publicFile);
315
+ return fileResponse(publicFile);
301
316
  }
302
317
  }
303
318
 
@@ -324,6 +339,7 @@ export async function createDevServer(options: {
324
339
  if (bundled) {
325
340
  return new Response(bundled, {
326
341
  headers: {
342
+ ...NO_CACHE,
327
343
  "Content-Type": "application/javascript; charset=utf-8",
328
344
  },
329
345
  });
@@ -332,17 +348,24 @@ export async function createDevServer(options: {
332
348
  return new Response("Not found", { status: 404 });
333
349
  }
334
350
 
335
- if (handleSSE && path.endsWith(".html")) {
351
+ // Inject the live-reload script into served HTML — but NOT into the Studio editor.
352
+ // Studio manages its own state (open tabs, undo history, chat) and refreshes edited
353
+ // Files in-place; a blanket location.reload() would destroy that, e.g. when the AI
354
+ // Assistant writes a file matching a build glob inside the watched root.
355
+ if (handleSSE && path.endsWith(".html") && !path.startsWith("/packages/studio/")) {
336
356
  const html = await file.text();
337
357
  return new Response(injectSSE(html), {
338
- headers: { "Content-Type": "text/html; charset=utf-8" },
358
+ headers: { ...NO_CACHE, "Content-Type": "text/html; charset=utf-8" },
339
359
  });
340
360
  }
341
361
 
342
- return new Response(file);
362
+ return fileResponse(file);
343
363
  },
344
364
 
345
365
  port,
366
+ // Keep SSE connections alive — heartbeats are every 15 s, and AI streaming can take
367
+ // 30+ s. The default 10 s idleTimeout kills them prematurely.
368
+ idleTimeout: 120,
346
369
  });
347
370
 
348
371
  console.log(`\n@jxsuite/server listening on http://localhost:${server.port}`);
package/src/studio-api.ts CHANGED
@@ -21,7 +21,6 @@ import {
21
21
  setPackageVersions,
22
22
  } from "./packages.ts";
23
23
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
24
- import * as claude from "./claude-session.ts";
25
24
  import type { ClassJsonDef } from "./types.ts";
26
25
  import type { ProjectConfig } from "@jxsuite/schema/types";
27
26
 
@@ -1438,79 +1437,6 @@ export async function handleStudioApi(
1438
1437
  }
1439
1438
  }
1440
1439
 
1441
- // ─── AI Assistant ─────────────────────────────────────────────────────────
1442
-
1443
- if (path === "/__studio/ai/auth-status" && req.method === "GET") {
1444
- const status = await claude.getAuthStatus();
1445
- return Response.json(status);
1446
- }
1447
-
1448
- if (path === "/__studio/ai/session" && req.method === "POST") {
1449
- try {
1450
- const body = (await req.json()) as { message?: string; systemPrompt?: string };
1451
- const projectDir = activeProjectRoot || root;
1452
- const opts: { systemPrompt?: string } = {};
1453
- if (body.systemPrompt !== undefined) {
1454
- opts.systemPrompt = body.systemPrompt;
1455
- }
1456
- const result = claude.createSession(projectDir, body.message!, opts);
1457
- return Response.json(result);
1458
- } catch (error) {
1459
- return Response.json(
1460
- { error: error instanceof Error ? error.message : String(error) },
1461
- { status: 500 },
1462
- );
1463
- }
1464
- }
1465
-
1466
- if (
1467
- path.startsWith("/__studio/ai/session/") &&
1468
- path.endsWith("/stream") &&
1469
- req.method === "GET"
1470
- ) {
1471
- const id = path.split("/").slice(4)[0]!;
1472
- return claude.streamSession(id);
1473
- }
1474
-
1475
- if (
1476
- path.startsWith("/__studio/ai/session/") &&
1477
- path.endsWith("/message") &&
1478
- req.method === "POST"
1479
- ) {
1480
- try {
1481
- const id = path.split("/").slice(4)[0]!;
1482
- const body = (await req.json()) as { message?: string };
1483
- claude.sendMessage(id, body.message!);
1484
- return Response.json({ ok: true });
1485
- } catch (error) {
1486
- return Response.json(
1487
- { error: error instanceof Error ? error.message : String(error) },
1488
- { status: 500 },
1489
- );
1490
- }
1491
- }
1492
-
1493
- if (path.startsWith("/__studio/ai/session/") && path.endsWith("/stop") && req.method === "POST") {
1494
- const id = path.split("/").slice(4)[0]!;
1495
- claude.stopSession(id);
1496
- return Response.json({ ok: true });
1497
- }
1498
-
1499
- if (path.startsWith("/__studio/ai/session/") && req.method === "DELETE") {
1500
- const id = path.split("/").slice(4)[0]!;
1501
- claude.deleteSession(id);
1502
- return Response.json({ ok: true });
1503
- }
1504
-
1505
- if (path.startsWith("/__studio/ai/session/") && req.method === "GET") {
1506
- const id = path.split("/").slice(4)[0]!;
1507
- const info = claude.getSession(id);
1508
- if (!info) {
1509
- return Response.json({ error: "Not found" }, { status: 404 });
1510
- }
1511
- return Response.json(info);
1512
- }
1513
-
1514
1440
  return null;
1515
1441
  }
1516
1442
 
package/src/watch.ts CHANGED
@@ -16,6 +16,9 @@ const DEFAULT_IGNORE = [
16
16
  "**/.direnv/**",
17
17
  "**/bun.lockb",
18
18
  "**/bun.lock",
19
+ // Bun test temporary directories — created/deleted transiently, cause EINVAL on
20
+ // Fs.watch when cleaned up mid-scan.
21
+ "**/__test-*/**",
19
22
  ];
20
23
 
21
24
  /** @param {string} value */
@@ -186,5 +189,14 @@ export function createWatcher(
186
189
  }, debounceMs);
187
190
  });
188
191
 
192
+ // Gracefully handle watch errors (e.g. EINVAL on transient Bun test dirs).
193
+ watcher.on("error", (err) => {
194
+ const error = err as Error;
195
+ if (error.message?.includes("EINVAL")) {
196
+ return;
197
+ }
198
+ console.error("[watch] chokidar error:", error.message ?? error);
199
+ });
200
+
189
201
  return { broadcast, broadcastEvent, handleSSE, watcher };
190
202
  }