@jxsuite/server 4.2.2 → 4.3.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": "4.2.2",
3
+ "version": "4.3.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,6 +22,7 @@
22
22
  "./packages": "./src/packages.ts",
23
23
  "./project-server": "./src/project-server.ts",
24
24
  "./oauth-loopback": "./src/oauth-loopback.ts",
25
+ "./net-guard": "./src/net-guard.ts",
25
26
  "./live-preview": "./src/live-preview.ts",
26
27
  "./site-preview": "./src/site-preview.ts",
27
28
  "./refactor": "./src/refactor/index.ts",
@@ -40,23 +41,24 @@
40
41
  "upgrade": "bun update --latest && bun install"
41
42
  },
42
43
  "dependencies": {
44
+ "@jxsuite/ai": "^0.38.0",
43
45
  "@jxsuite/catalog": "^0.1.0",
44
- "@jxsuite/collab": "^0.9.0",
45
- "@jxsuite/compiler": "^4.0.1",
46
- "@jxsuite/create": "^1.3.11",
47
- "@jxsuite/import": "^0.40.3",
48
- "@jxsuite/protocol": "^2.3.1",
49
- "@jxsuite/runtime": "^4.0.1",
50
- "@jxsuite/schema": "^2.2.0",
51
- "@jxsuite/site": "^2.0.1",
52
- "@jxsuite/starters": "^1.8.1",
46
+ "@jxsuite/collab": "^0.10.0",
47
+ "@jxsuite/compiler": "^4.0.2",
48
+ "@jxsuite/create": "^1.3.12",
49
+ "@jxsuite/import": "^0.40.4",
50
+ "@jxsuite/protocol": "^2.4.0",
51
+ "@jxsuite/runtime": "^4.0.2",
52
+ "@jxsuite/schema": "^2.3.0",
53
+ "@jxsuite/site": "^2.0.2",
54
+ "@jxsuite/starters": "^1.8.2",
53
55
  "chokidar": "^5.0.0",
54
56
  "kysely": "^0.29.6",
55
57
  "zod": "^4.6.5"
56
58
  },
57
59
  "devDependencies": {
58
- "@jxsuite/auth": "^0.6.0",
59
- "@jxsuite/connector": "^0.6.0",
60
- "@jxsuite/parser": "^1.8.1"
60
+ "@jxsuite/auth": "^0.6.1",
61
+ "@jxsuite/connector": "^0.6.1",
62
+ "@jxsuite/parser": "^1.8.2"
61
63
  }
62
64
  }
package/src/ai-api.ts CHANGED
@@ -5,6 +5,10 @@
5
5
  * The server acts as a thin proxy: validates the request shape, forwards to OpenAI,
6
6
  * normalizes the SSE stream into StreamEvent-compatible format, and pipes back.
7
7
  *
8
+ * The wire half of that (reading the body, the upstream request, the stream normalizer, the SSE
9
+ * framing, the problem responses) is `@jxsuite/ai/gateway`, which every backend serving these
10
+ * routes can share. What stays here is this server's POLICY, which no other host shares:
11
+ *
8
12
  * API key flow:
9
13
  * 1. Request header X-Api-Key or Authorization: Bearer <key>
10
14
  * 2. Fallback: OPENAI_API_KEY env var — attached ONLY to the env/default base URL, never to a
@@ -17,38 +21,27 @@
17
21
  * 3. Default: https://api.openai.com/v1
18
22
  * A base URL resolving to a cloud metadata / link-local host is refused (SSRF defense).
19
23
  *
24
+ * The model catalogue for /models is this server's too: its defaults, and how it reads the
25
+ * upstream's own listing.
26
+ *
20
27
  * @license MIT
21
28
  */
22
- import { problem, problemTypeForStatus } from "./problem.ts";
29
+ import {
30
+ createChatHandler,
31
+ extractUpstreamErrorMessage,
32
+ modelsResponse,
33
+ problemResponse,
34
+ } from "@jxsuite/ai/gateway";
35
+ import type { GatewayRefusal, Upstream } from "@jxsuite/ai/gateway";
23
36
  import { problemDetails } from "@jxsuite/protocol";
37
+ import { problemTypeForStatus } from "./problem.ts";
24
38
 
25
39
  // ─── Configuration ───────────────────────────────────────────────────────────
26
40
 
27
41
  const DEFAULT_BASE_URL = "https://api.openai.com/v1";
28
42
 
29
- /** A tool-call fragment inside an OpenAI streaming `delta`. */
30
- interface ToolCallDelta {
31
- index: number;
32
- id?: string;
33
- type?: string;
34
- function?: { name?: string; arguments?: string };
35
- }
36
-
37
- /** A single OpenAI chat-completions streaming chunk (the JSON after `data: `). */
38
- interface OpenAIStreamChunk {
39
- choices?: {
40
- index?: number;
41
- delta?: {
42
- content?: string;
43
- /** Thinking models' chain-of-thought: DeepSeek/Volcengine spell it this way… */
44
- reasoning_content?: string;
45
- /** …and OpenRouter this way. Both carry the same thing. */
46
- reasoning?: string;
47
- tool_calls?: ToolCallDelta[];
48
- };
49
- finish_reason?: string | null;
50
- }[];
51
- }
43
+ /** The model a chat request that names none is forwarded with. */
44
+ const DEFAULT_MODEL = "gpt-4o";
52
45
 
53
46
  /** A model entry from the upstream `/models` listing. */
54
47
  interface ModelEntry {
@@ -144,347 +137,45 @@ function getConfig(req: Request): AiConfig {
144
137
 
145
138
  // ─── Helpers ─────────────────────────────────────────────────────────────────
146
139
 
147
- /** Write an SSE event to the response stream. */
148
- function writeSSE(controller: ReadableStreamDefaultController, event: unknown): void {
149
- const data = JSON.stringify(event);
150
- controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`));
151
- }
152
-
153
140
  /**
154
- * Extract a human-readable message from an upstream error body, falling back to the raw body (or
155
- * `fallback` when the body is empty).
141
+ * A refusal at a status this file chose.
156
142
  *
157
- * OpenAI-compatible providers return `{ error: "..." }` or `{ error: { message: "..." } }`.
158
- * Cloudflare's own REST API — what a BYOK base URL pointed at Workers AI actually returns — uses a
159
- * different envelope, `{ success: false, errors: [{ code, message }] }`, with no top-level `error`
160
- * key at all. Without this branch that shape parses as valid JSON with nothing matched, and the
161
- * caller was left showing the whole raw body (e.g. the full `{"success":false,"errors":[...]}`
162
- * blob) instead of the human sentence inside it.
143
+ * The status picks the type rather than the other way round, because this file's refusals were
144
+ * always written as statuses (`reject.status`), and the response carries the TYPE's status, exactly
145
+ * as `problem()` answers.
163
146
  */
164
- function extractUpstreamErrorMessage(rawBody: string, fallback: string): string {
165
- if (!rawBody) {
166
- return fallback;
167
- }
168
- try {
169
- const { error, errors } = JSON.parse(rawBody) as {
170
- error?: string | { message?: string };
171
- errors?: { code?: number; message?: string }[];
172
- };
173
- if (typeof error === "string") {
174
- return error;
175
- }
176
- if (error) {
177
- const { message } = error;
178
- if (message) {
179
- return message;
180
- }
181
- }
182
- const [first] = errors ?? [];
183
- if (first) {
184
- const { message } = first;
185
- if (message) {
186
- return message;
187
- }
188
- }
189
- } catch {
190
- /* Not JSON — use the raw body. */
191
- }
192
- return rawBody;
147
+ function refuse(status: number, message: string): GatewayRefusal {
148
+ const problem = problemDetails(problemTypeForStatus(status), message);
149
+ return { problem, status: problem.status };
193
150
  }
194
151
 
195
152
  /**
196
- * Write a failure response for the non-streaming endpoints.
197
- *
198
- * The status is chosen by the caller here rather than by the type, because this file's callers
199
- * forward an upstream provider's status — which is information, and collapsing it to the type's own
200
- * would throw it away.
153
+ * This server's upstream for a request, or its refusal: the key-provenance and SSRF rules above,
154
+ * applied per request (the environment is read each time, so a key set later is honoured).
201
155
  */
202
- function jsonError(status: number, message: string): Response {
203
- return problem(problemTypeForStatus(status), message);
204
- }
205
-
206
- // ─── /__studio/ai/chat — SSE streaming proxy ───────────────────────────────
207
-
208
- /** Handle POST /__studio/ai/chat — proxy chat completions to OpenAI via SSE. */
209
- export async function handleChat(req: Request): Promise<Response> {
210
- const { apiKey, baseUrl, missingKey, reject } = getConfig(req);
156
+ function resolveUpstream(req: Request): Upstream | GatewayRefusal {
157
+ const { apiKey, baseUrl, reject } = getConfig(req);
211
158
  if (reject) {
212
- return jsonError(reject.status, reject.message);
159
+ return refuse(reject.status, reject.message);
213
160
  }
214
- if (missingKey) {
215
- return jsonError(
161
+ if (!apiKey) {
162
+ return refuse(
216
163
  401,
217
164
  "No API key configured. Set OPENAI_API_KEY env var or send X-Api-Key header.",
218
165
  );
219
166
  }
167
+ return { apiKey, baseUrl, defaultModel: DEFAULT_MODEL, family: "openai-compat", managed: false };
168
+ }
220
169
 
221
- // Parse request body
222
- let body: {
223
- messages?: unknown[];
224
- tools?: unknown[];
225
- systemPrompt?: string;
226
- model?: string;
227
- };
228
- try {
229
- body = (await req.json()) as {
230
- messages?: unknown[];
231
- tools?: unknown[];
232
- systemPrompt?: string;
233
- model?: string;
234
- };
235
- } catch {
236
- return jsonError(400, "Invalid JSON body");
237
- }
238
-
239
- const { messages = [], tools = [], systemPrompt = "", model = "gpt-4o" } = body;
240
-
241
- if (!Array.isArray(messages)) {
242
- return jsonError(400, "messages must be an array");
243
- }
244
-
245
- // Build OpenAI request
246
- const openaiBody: {
247
- model: string;
248
- messages: unknown[];
249
- stream: boolean;
250
- stream_options: { include_usage: boolean };
251
- tools?: unknown[];
252
- tool_choice?: string;
253
- parallel_tool_calls?: boolean;
254
- } = {
255
- model,
256
- messages: [{ role: "system", content: systemPrompt }, ...messages],
257
- stream: true,
258
- stream_options: { include_usage: true },
259
- };
260
-
261
- if (tools && tools.length > 0) {
262
- openaiBody.tools = tools;
263
- openaiBody.tool_choice = "auto";
264
- openaiBody.parallel_tool_calls = true;
265
- }
266
-
267
- const upstreamUrl = `${baseUrl}/chat/completions`;
268
-
269
- // Create the SSE stream
270
- const stream = new ReadableStream({
271
- async start(controller) {
272
- let response;
273
- try {
274
- response = await fetch(upstreamUrl, {
275
- method: "POST",
276
- headers: {
277
- "Content-Type": "application/json",
278
- Authorization: `Bearer ${apiKey}`,
279
- },
280
- body: JSON.stringify(openaiBody),
281
- signal: req.signal,
282
- });
283
- } catch (error) {
284
- if ((error as Error).name === "AbortError") {
285
- writeSSE(controller, { type: "done", stopReason: "cancelled" });
286
- } else {
287
- const message = `Network error: ${(error as Error).message}`;
288
- writeSSE(controller, {
289
- message,
290
- /*
291
- * The frame carries a problem rather than being one: the response began with a 200
292
- * long before this failed, so nothing can change the status now. `message` stays for
293
- * the readers that already show it (server.md §4.3).
294
- */
295
- problem: problemDetails("upstreamFailure", message),
296
- type: "error",
297
- });
298
- }
299
- controller.close();
300
- return;
301
- }
302
-
303
- if (!response.ok) {
304
- let errorBody = "";
305
- try {
306
- errorBody = await response.text();
307
- } catch {
308
- /* Ignore */
309
- }
310
- const cleanMessage = extractUpstreamErrorMessage(errorBody, response.statusText);
311
- writeSSE(controller, {
312
- code: String(response.status),
313
- message: cleanMessage,
314
- // The upstream's own status is preserved in `code`; the problem names the KIND.
315
- problem: problemDetails(problemTypeForStatus(response.status), cleanMessage),
316
- type: "error",
317
- });
318
- controller.close();
319
- return;
320
- }
321
-
322
- const reader = response.body?.getReader();
323
- if (!reader) {
324
- writeSSE(controller, {
325
- message: "No response body from upstream",
326
- problem: problemDetails("upstreamFailure", "No response body from upstream"),
327
- type: "error",
328
- });
329
- controller.close();
330
- return;
331
- }
332
-
333
- const decoder = new TextDecoder();
334
- let buffer = "";
335
-
336
- const pendingToolCalls = new Map<number, { id: string; name: string; args: string }>();
337
-
338
- try {
339
- while (true) {
340
- const { done, value } = await reader.read();
341
- if (done) {
342
- break;
343
- }
344
-
345
- buffer += decoder.decode(value, { stream: true });
346
- const lines = buffer.split("\n");
347
- buffer = lines.pop() || "";
348
-
349
- for (const line of lines) {
350
- const trimmed = line.trim();
351
- if (!trimmed || !trimmed.startsWith("data: ")) {
352
- continue;
353
- }
354
-
355
- const dataStr = trimmed.slice(6);
356
- if (dataStr === "[DONE]") {
357
- // Emit pending tool call ends
358
- for (const [, tc] of pendingToolCalls) {
359
- writeSSE(controller, { type: "tool_call_end", id: tc.id });
360
- }
361
- pendingToolCalls.clear();
362
- writeSSE(controller, { type: "done", stopReason: "stop" });
363
- controller.close();
364
- return;
365
- }
366
-
367
- let parsed: OpenAIStreamChunk;
368
- try {
369
- parsed = JSON.parse(dataStr) as OpenAIStreamChunk;
370
- } catch {
371
- continue;
372
- }
373
-
374
- const choice = parsed.choices?.[0];
375
- if (!choice) {
376
- continue;
377
- }
378
-
379
- const { delta } = choice;
380
- if (!delta) {
381
- continue;
382
- }
383
-
384
- // Text content
385
- if (delta.content) {
386
- writeSSE(controller, { type: "delta", content: delta.content });
387
- }
388
-
389
- /* Chain-of-thought, under either of the two names providers give it. Forwarded rather
390
- than dropped: DeepSeek's thinking mode requires every prior turn's reasoning back on
391
- any request carrying `tools`, so a proxy that swallows these frames makes the NEXT
392
- round a 400 the client cannot repair. */
393
- const reasoning = delta.reasoning_content ?? delta.reasoning;
394
- if (typeof reasoning === "string" && reasoning) {
395
- writeSSE(controller, { type: "reasoning", content: reasoning });
396
- }
397
-
398
- // Tool calls
399
- if (delta.tool_calls) {
400
- for (const tc of delta.tool_calls) {
401
- if (tc.id) {
402
- // First appearance
403
- const entry = {
404
- id: tc.id,
405
- name: tc.function?.name || "",
406
- args: tc.function?.arguments || "",
407
- };
408
- pendingToolCalls.set(tc.index, entry);
409
-
410
- writeSSE(controller, { type: "tool_call_start", id: tc.id, name: entry.name });
411
-
412
- if (entry.args) {
413
- writeSSE(controller, { type: "tool_call_delta", id: tc.id, args: entry.args });
414
- }
415
- } else if (tc.function?.arguments) {
416
- // Subsequent fragment
417
- const existing = pendingToolCalls.get(tc.index);
418
- if (existing) {
419
- existing.args += tc.function.arguments;
420
- writeSSE(controller, {
421
- type: "tool_call_delta",
422
- id: existing.id,
423
- args: tc.function.arguments,
424
- });
425
- }
426
- }
427
- }
428
- }
429
-
430
- // Finish reason
431
- if (choice.finish_reason === "tool_calls") {
432
- for (const [, tc] of pendingToolCalls) {
433
- writeSSE(controller, { type: "tool_call_end", id: tc.id });
434
- }
435
- pendingToolCalls.clear();
436
- writeSSE(controller, { type: "done", stopReason: "tool_calls" });
437
- controller.close();
438
- return;
439
- }
170
+ // ─── /__studio/ai/chat — SSE streaming proxy ───────────────────────────────
440
171
 
441
- if (choice.finish_reason === "stop" || choice.finish_reason === "length") {
442
- for (const [, tc] of pendingToolCalls) {
443
- writeSSE(controller, { type: "tool_call_end", id: tc.id });
444
- }
445
- pendingToolCalls.clear();
446
- writeSSE(controller, {
447
- type: "done",
448
- stopReason: choice.finish_reason === "length" ? "length" : "stop",
449
- });
450
- controller.close();
451
- return;
452
- }
453
- }
454
- }
172
+ const chatHandler = createChatHandler<{ readonly signal: AbortSignal }>({
173
+ resolveUpstream: (request) => resolveUpstream(request),
174
+ });
455
175
 
456
- // Stream ended without explicit finish_reason
457
- for (const [, tc] of pendingToolCalls) {
458
- writeSSE(controller, { type: "tool_call_end", id: tc.id });
459
- }
460
- pendingToolCalls.clear();
461
- writeSSE(controller, { type: "done", stopReason: "stop" });
462
- controller.close();
463
- } catch (error) {
464
- void reader.cancel();
465
- if ((error as Error).name === "AbortError") {
466
- writeSSE(controller, { type: "done", stopReason: "cancelled" });
467
- controller.close();
468
- return;
469
- }
470
- const message = `Stream error: ${(error as Error).message}`;
471
- writeSSE(controller, {
472
- message,
473
- problem: problemDetails("upstreamFailure", message),
474
- type: "error",
475
- });
476
- controller.close();
477
- }
478
- },
479
- });
480
-
481
- return new Response(stream, {
482
- headers: {
483
- "Content-Type": "text/event-stream",
484
- "Cache-Control": "no-cache",
485
- Connection: "keep-alive",
486
- },
487
- });
176
+ /** Handle POST /__studio/ai/chat — proxy chat completions to OpenAI via SSE. */
177
+ export async function handleChat(req: Request): Promise<Response> {
178
+ return chatHandler(req, { signal: req.signal });
488
179
  }
489
180
 
490
181
  // ─── /__studio/ai/models — model listing ────────────────────────────────────
@@ -500,7 +191,7 @@ export async function handleChat(req: Request): Promise<Response> {
500
191
  export async function handleModels(req: Request): Promise<Response> {
501
192
  const { apiKey, baseUrl, missingKey, reject } = getConfig(req);
502
193
  if (reject) {
503
- return jsonError(reject.status, reject.message);
194
+ return problemResponse(refuse(reject.status, reject.message));
504
195
  }
505
196
 
506
197
  // No key available → return hardcoded defaults so the UI can at least render.
@@ -511,12 +202,7 @@ export async function handleModels(req: Request): Promise<Response> {
511
202
  { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextWindow: 1_000_000 },
512
203
  { id: "gpt-4o-mini", name: "GPT-4o Mini", contextWindow: 128_000 },
513
204
  ];
514
- return Response.json(
515
- { models: defaults, configured: false, managed: false },
516
- {
517
- headers: { "Content-Type": "application/json" },
518
- },
519
- );
205
+ return modelsResponse({ models: defaults, configured: false, managed: false });
520
206
  }
521
207
 
522
208
  // Key is available — proxy to the upstream /models endpoint.
@@ -536,18 +222,13 @@ export async function handleModels(req: Request): Promise<Response> {
536
222
  }
537
223
  const upstreamMessage = extractUpstreamErrorMessage(errorBody, upstreamResp.statusText);
538
224
  const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
539
- return Response.json(
540
- {
541
- models: defaults,
542
- configured: true,
543
- managed: false,
544
- upstreamError: upstreamResp.status,
545
- upstreamMessage,
546
- },
547
- {
548
- headers: { "Content-Type": "application/json" },
549
- },
550
- );
225
+ return modelsResponse({
226
+ models: defaults,
227
+ configured: true,
228
+ managed: false,
229
+ upstreamError: upstreamResp.status,
230
+ upstreamMessage,
231
+ });
551
232
  }
552
233
 
553
234
  const data = (await upstreamResp.json()) as { data?: ModelEntry[] } | ModelEntry[];
@@ -565,27 +246,17 @@ export async function handleModels(req: Request): Promise<Response> {
565
246
  ownedBy: owned_by,
566
247
  }));
567
248
 
568
- return Response.json(
569
- { models, configured: true, managed: false },
570
- {
571
- headers: { "Content-Type": "application/json" },
572
- },
573
- );
249
+ return modelsResponse({ models, configured: true, managed: false });
574
250
  } catch (error) {
575
251
  // Network error → return defaults.
576
252
  const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
577
- return Response.json(
578
- {
579
- models: defaults,
580
- configured: true,
581
- managed: false,
582
- upstreamError: "network",
583
- upstreamMessage: (error as Error).message,
584
- },
585
- {
586
- headers: { "Content-Type": "application/json" },
587
- },
588
- );
253
+ return modelsResponse({
254
+ models: defaults,
255
+ configured: true,
256
+ managed: false,
257
+ upstreamError: "network",
258
+ upstreamMessage: (error as Error).message,
259
+ });
589
260
  }
590
261
  }
591
262
 
package/src/problem.ts CHANGED
@@ -44,9 +44,12 @@ export function problem(
44
44
  * The declared type for a status a caller already computed.
45
45
  *
46
46
  * Two files need this and no more should: `data-api.ts`, whose `ApiError` predates the registry and
47
- * carries only a status, and `ai-api.ts`, which forwards an upstream provider's status because
48
- * collapsing it to the type's own would discard information. Everywhere else the type is named at
49
- * the call site, which is what keeps the registry meaningful — a status is not a failure kind.
47
+ * carries only a status, and `ai-api.ts`, whose key and base-URL refusals are written as statuses.
48
+ * Everywhere else the type is named at the call site, which is what keeps the registry meaningful —
49
+ * a status is not a failure kind. The AI proxy's other use, forwarding an upstream provider's
50
+ * status because collapsing it would discard information, moved with the stream normalizer into
51
+ * `@jxsuite/ai/gateway`, which carries its own copy of this table; `tests/ai-api-gateway.test.ts`
52
+ * holds the two to agreement for every status.
50
53
  *
51
54
  * @param {number} status
52
55
  * @returns {ProblemTypeName}
package/src/studio-api.ts CHANGED
@@ -1206,6 +1206,33 @@ export async function handleStudioApi(
1206
1206
  }
1207
1207
  }
1208
1208
 
1209
+ /* Read a file's raw BYTES.
1210
+ The read side of `/__studio/file/upload`, and a separate route from `/__studio/file` rather
1211
+ than a flag on it, because the two differ in more than encoding: that route answers a JSON
1212
+ envelope `{ content, path }` with `max-age=5`, and both are wrong here. A caller wanting bytes
1213
+ wants a body, and a caller about to overwrite the file must not be handed a five-second-old
1214
+ copy of it — an edit applied to stale bytes is a silently wrong file.
1215
+
1216
+ `Bun.file` streams from disk and infers the media type from the extension, which is what makes
1217
+ this a handful of lines rather than a decoder. */
1218
+ if (path === "/__studio/file/bytes" && req.method === "GET") {
1219
+ const fp = url.searchParams.get("path");
1220
+ if (!fp) {
1221
+ return problem("invalidRequest", "Missing path");
1222
+ }
1223
+ const abs = expandTilde(fp);
1224
+ try {
1225
+ assertAccessible(abs, root, activeProjectRoot);
1226
+ } catch (error) {
1227
+ return new Response(errorMessage(error), { status: 400 });
1228
+ }
1229
+ const file = Bun.file(abs);
1230
+ if (!(await file.exists())) {
1231
+ return problem("notFound", "Not found");
1232
+ }
1233
+ return new Response(file, { headers: { "Cache-Control": "no-store" } });
1234
+ }
1235
+
1209
1236
  // Write file
1210
1237
  if (path === "/__studio/file" && req.method === "PUT") {
1211
1238
  const fp = url.searchParams.get("path");