@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.
- package/package.json +8 -10
- package/src/ai-api.ts +465 -0
- package/src/project-server.ts +408 -0
- package/src/server.ts +29 -6
- package/src/studio-api.ts +0 -74
- package/src/watch.ts +12 -0
- package/src/claude-session.ts +0 -338
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|