@jxsuite/server 0.35.0 → 0.37.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.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,13 +31,14 @@
31
31
  "test:coverage": "bun test --isolate --coverage"
32
32
  },
33
33
  "dependencies": {
34
- "@jxsuite/compiler": "^0.34.1",
35
- "@jxsuite/create": "^0.35.0",
36
- "@jxsuite/import": "^0.35.0",
37
- "@jxsuite/parser": "^0.35.0",
38
- "@jxsuite/runtime": "^0.34.1",
39
- "@jxsuite/schema": "^0.34.0",
40
- "@jxsuite/starters": "^0.35.0",
34
+ "@jxsuite/collab": "^0.2.0",
35
+ "@jxsuite/compiler": "^0.35.0",
36
+ "@jxsuite/create": "^0.36.0",
37
+ "@jxsuite/import": "^0.35.1",
38
+ "@jxsuite/parser": "^0.35.1",
39
+ "@jxsuite/runtime": "^0.34.2",
40
+ "@jxsuite/schema": "^0.35.0",
41
+ "@jxsuite/starters": "^0.35.1",
41
42
  "chokidar": "^5.0.0",
42
43
  "zod": "^4.4.3"
43
44
  }
package/src/ai-api.ts CHANGED
@@ -385,7 +385,7 @@ export async function handleModels(req: Request): Promise<Response> {
385
385
  { id: "gpt-4o-mini", name: "GPT-4o Mini", contextWindow: 128_000 },
386
386
  ];
387
387
  return Response.json(
388
- { models: defaults, configured: false },
388
+ { models: defaults, configured: false, managed: false },
389
389
  {
390
390
  headers: { "Content-Type": "application/json" },
391
391
  },
@@ -403,7 +403,7 @@ export async function handleModels(req: Request): Promise<Response> {
403
403
  // Upstream failed — return defaults with configured flag so user can still try.
404
404
  const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
405
405
  return Response.json(
406
- { models: defaults, configured: true, upstreamError: upstreamResp.status },
406
+ { models: defaults, configured: true, managed: false, upstreamError: upstreamResp.status },
407
407
  {
408
408
  headers: { "Content-Type": "application/json" },
409
409
  },
@@ -426,7 +426,7 @@ export async function handleModels(req: Request): Promise<Response> {
426
426
  }));
427
427
 
428
428
  return Response.json(
429
- { models, configured: true },
429
+ { models, configured: true, managed: false },
430
430
  {
431
431
  headers: { "Content-Type": "application/json" },
432
432
  },
@@ -435,7 +435,7 @@ export async function handleModels(req: Request): Promise<Response> {
435
435
  // Network error → return defaults.
436
436
  const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
437
437
  return Response.json(
438
- { models: defaults, configured: true, upstreamError: "network" },
438
+ { models: defaults, configured: true, managed: false, upstreamError: "network" },
439
439
  {
440
440
  headers: { "Content-Type": "application/json" },
441
441
  },
package/src/collab.ts ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Collab.ts — the dev server's realtime co-editing endpoint (`/__studio/collab`): the reference
3
+ * implementation of the Jx collab wire contract, built on `@jxsuite/collab/room`.
4
+ *
5
+ * Rooms are keyed by server-root-relative path; a room's Y.Text source seeds from the file on disk
6
+ * and writes back (debounced) so every non-collab consumer — preview, live reload, plain editors —
7
+ * keeps working off the real file. Genuinely external file changes (not our own write-back echo)
8
+ * bump the doc's epoch and reset subscribers, per the contract. No persistence beyond the files
9
+ * themselves: this is deliberately a reference implementation.
10
+ */
11
+
12
+ import { resolve } from "node:path";
13
+ import { readFile, writeFile } from "node:fs/promises";
14
+ import { colorForKey } from "@jxsuite/collab/awareness-types";
15
+ import { createCollabHost } from "@jxsuite/collab/room";
16
+ import { assertAccessible } from "./studio-api";
17
+ import type { ServerWebSocket, WebSocketHandler } from "bun";
18
+ import type { HostConnection } from "@jxsuite/collab/room";
19
+
20
+ const WRITE_BACK_DEBOUNCE_MS = 800;
21
+ const EMPTY_ROOM_GRACE_MS = 30_000;
22
+
23
+ const BINARY_EXTENSIONS = new Set([
24
+ "png",
25
+ "jpg",
26
+ "jpeg",
27
+ "gif",
28
+ "webp",
29
+ "avif",
30
+ "ico",
31
+ "bmp",
32
+ "woff",
33
+ "woff2",
34
+ "ttf",
35
+ "otf",
36
+ "eot",
37
+ "mp3",
38
+ "mp4",
39
+ "webm",
40
+ "wav",
41
+ "ogg",
42
+ "pdf",
43
+ "zip",
44
+ "gz",
45
+ ]);
46
+
47
+ function isBinaryPath(path: string): boolean {
48
+ const dot = path.lastIndexOf(".");
49
+ return dot !== -1 && BINARY_EXTENSIONS.has(path.slice(dot + 1).toLowerCase());
50
+ }
51
+
52
+ export interface CollabSocketData {
53
+ connection: HostConnection | null;
54
+ }
55
+
56
+ export interface CollabRegistry {
57
+ /**
58
+ * Handle a request to /__studio/collab: WebSocket upgrades become collab connections; a plain GET
59
+ * answers the capability probe. Returns undefined when the upgrade was accepted (Bun sends the
60
+ * 101 itself).
61
+ */
62
+ handleRequest: (
63
+ req: Request,
64
+ server: { upgrade: (req: Request, opts: { data: CollabSocketData }) => boolean },
65
+ ) => Response | undefined;
66
+ /** Bun.serve websocket handlers backing the upgraded connections. */
67
+ websocket: WebSocketHandler<CollabSocketData>;
68
+ /** Watcher hook: a file changed on disk (absolute path). Echoes of our write-back are ignored. */
69
+ handleExternalChange: (absPath: string) => void;
70
+ /** Flush pending write-backs and tear everything down. */
71
+ stop: () => Promise<void>;
72
+ }
73
+
74
+ export function createCollabRegistry(opts: {
75
+ absRoot: string;
76
+ activeProjectRoot: () => string | null;
77
+ }): CollabRegistry {
78
+ const { absRoot } = opts;
79
+ let connectionCounter = 0;
80
+ let stopped = false;
81
+
82
+ const writeTimers = new Map<string, ReturnType<typeof setTimeout>>();
83
+ const destroyTimers = new Map<string, ReturnType<typeof setTimeout>>();
84
+ /** Content we last wrote (or are about to write) per path — the watcher-echo signature. */
85
+ const lastWritten = new Map<string, string>();
86
+
87
+ const absPathFor = (relPath: string): string => {
88
+ const abs = resolve(absRoot, relPath);
89
+ assertAccessible(abs, absRoot, opts.activeProjectRoot());
90
+ return abs;
91
+ };
92
+
93
+ const relPathFor = (absPath: string): string => {
94
+ const rel = absPath.startsWith(absRoot)
95
+ ? absPath.slice(absRoot.length).replace(/^\/+/, "")
96
+ : absPath;
97
+ return rel.replaceAll("\\", "/");
98
+ };
99
+
100
+ const persist = async (path: string): Promise<void> => {
101
+ const source = host.sourceOf(path);
102
+ if (source === null) {
103
+ return;
104
+ }
105
+ if (lastWritten.get(path) === source) {
106
+ return;
107
+ }
108
+ lastWritten.set(path, source);
109
+ try {
110
+ await writeFile(absPathFor(path), source, "utf8");
111
+ } catch {
112
+ // Disk write failures leave the room live; the next change retries.
113
+ }
114
+ };
115
+
116
+ const host = createCollabHost({
117
+ loadSource: async (path) => {
118
+ try {
119
+ return await readFile(absPathFor(path), "utf8");
120
+ } catch {
121
+ return null;
122
+ }
123
+ },
124
+ onEmpty: (path) => {
125
+ const existing = destroyTimers.get(path);
126
+ if (existing) {
127
+ clearTimeout(existing);
128
+ }
129
+ destroyTimers.set(
130
+ path,
131
+ setTimeout(() => {
132
+ destroyTimers.delete(path);
133
+ void persist(path).then(() => host.destroyRoomIfEmpty(path));
134
+ }, EMPTY_ROOM_GRACE_MS),
135
+ );
136
+ },
137
+ onFlush: (path) => {
138
+ const timer = writeTimers.get(path);
139
+ if (timer) {
140
+ clearTimeout(timer);
141
+ writeTimers.delete(path);
142
+ }
143
+ return persist(path);
144
+ },
145
+ onSourceChange: (path) => {
146
+ const existing = writeTimers.get(path);
147
+ if (existing) {
148
+ clearTimeout(existing);
149
+ }
150
+ writeTimers.set(
151
+ path,
152
+ setTimeout(() => {
153
+ writeTimers.delete(path);
154
+ void persist(path);
155
+ }, WRITE_BACK_DEBOUNCE_MS),
156
+ );
157
+ },
158
+ rejectPath: (path) => {
159
+ if (isBinaryPath(path)) {
160
+ return "binary-file";
161
+ }
162
+ try {
163
+ absPathFor(path);
164
+ return null;
165
+ } catch {
166
+ return "content-not-loaded";
167
+ }
168
+ },
169
+ });
170
+
171
+ return {
172
+ handleExternalChange(absPath) {
173
+ const path = relPathFor(absPath);
174
+ if (host.subscriberCount(path) === 0 && host.sourceOf(path) === null) {
175
+ return;
176
+ }
177
+ void (async () => {
178
+ let content: string | null = null;
179
+ try {
180
+ content = await readFile(absPathFor(path), "utf8");
181
+ } catch {
182
+ content = null;
183
+ }
184
+ // Our own write-back (or a no-op save) is not an external change.
185
+ if (
186
+ content !== null &&
187
+ (content === lastWritten.get(path) || content === host.sourceOf(path))
188
+ ) {
189
+ return;
190
+ }
191
+ host.resetDoc(path);
192
+ })();
193
+ },
194
+
195
+ handleRequest(req, server) {
196
+ if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
197
+ const upgraded = server.upgrade(req, { data: { connection: null } });
198
+ return upgraded ? undefined : new Response("Upgrade failed", { status: 400 });
199
+ }
200
+ return Response.json({ collab: true, version: 1 });
201
+ },
202
+
203
+ async stop() {
204
+ if (stopped) {
205
+ return;
206
+ }
207
+ stopped = true;
208
+ for (const timer of writeTimers.values()) {
209
+ clearTimeout(timer);
210
+ }
211
+ for (const timer of destroyTimers.values()) {
212
+ clearTimeout(timer);
213
+ }
214
+ const flushes: Promise<void>[] = [];
215
+ for (const path of writeTimers.keys()) {
216
+ flushes.push(persist(path));
217
+ }
218
+ writeTimers.clear();
219
+ destroyTimers.clear();
220
+ await Promise.all(flushes);
221
+ host.destroy();
222
+ },
223
+
224
+ websocket: {
225
+ close(ws: ServerWebSocket<CollabSocketData>) {
226
+ ws.data.connection?.close();
227
+ ws.data.connection = null;
228
+ },
229
+ message(ws: ServerWebSocket<CollabSocketData>, message: string | Buffer) {
230
+ if (typeof message === "string") {
231
+ return;
232
+ }
233
+ ws.data.connection?.handleMessage(new Uint8Array(message));
234
+ },
235
+ open(ws: ServerWebSocket<CollabSocketData>) {
236
+ connectionCounter += 1;
237
+ const login = `local-${connectionCounter}`;
238
+ ws.data.connection = host.connect(
239
+ {
240
+ close: () => ws.close(),
241
+ send: (data) => {
242
+ ws.send(data);
243
+ },
244
+ },
245
+ { color: colorForKey(login), login, permission: "write" },
246
+ );
247
+ },
248
+ },
249
+ };
250
+ }
package/src/server.ts CHANGED
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { join, relative, resolve } from "node:path";
17
17
  import { buildAll } from "./build.ts";
18
+ import { createCollabRegistry } from "./collab.ts";
18
19
  import { createWatcher, injectSSE } from "./watch.ts";
19
20
  import { handleResolve, handleServerFunction } from "./resolve.ts";
20
21
  import { assertAccessible, handleStudioApi } from "./studio-api.ts";
@@ -204,10 +205,12 @@ export async function createDevServer(options: {
204
205
  // ─── File watcher + SSE ─────────────────────────────────────────────────────
205
206
 
206
207
  let handleSSE = null;
208
+ let fsWatcher: ReturnType<typeof createWatcher>["watcher"] | null = null;
207
209
  if (watch !== false) {
208
210
  const watchOpts = typeof watch === "object" ? watch : {};
209
211
  const watcher = createWatcher(absRoot, builds, watchOpts);
210
212
  ({ handleSSE } = watcher);
213
+ fsWatcher = watcher.watcher;
211
214
  }
212
215
 
213
216
  // Bundle cache for npm packages (bare specifier → bundled JS)
@@ -224,10 +227,24 @@ export async function createDevServer(options: {
224
227
  // Active studio project root (set via /__studio/activate, used for static file fallback)
225
228
  let activeProjectRoot: string | null = null;
226
229
 
230
+ // ─── Realtime co-editing (/__studio/collab) ─────────────────────────────────
231
+
232
+ // Created unconditionally (it is inert until an upgrade) so Bun.serve always has real
233
+ // Websocket handlers; the route below is what gates the capability on enableStudio.
234
+ const collabRegistry = createCollabRegistry({
235
+ absRoot,
236
+ activeProjectRoot: () => activeProjectRoot,
237
+ });
238
+ if (enableStudio && fsWatcher) {
239
+ fsWatcher.on("change", (changedPath: string) => {
240
+ collabRegistry.handleExternalChange(resolve(absRoot, changedPath));
241
+ });
242
+ }
243
+
227
244
  // ─── HTTP server ────────────────────────────────────────────────────────────
228
245
 
229
246
  const server = Bun.serve({
230
- async fetch(req) {
247
+ async fetch(req, bunServer) {
231
248
  const url = new URL(req.url);
232
249
  let path = decodeURIComponent(url.pathname);
233
250
  if (path.endsWith("/")) {
@@ -253,6 +270,11 @@ export async function createDevServer(options: {
253
270
 
254
271
  // Studio filesystem API
255
272
  if (enableStudio && path.startsWith("/__studio/")) {
273
+ // Realtime co-editing: WebSocket upgrade or capability probe
274
+ if (path === "/__studio/collab") {
275
+ return collabRegistry.handleRequest(req, bunServer);
276
+ }
277
+
256
278
  // Activate project — tells the server which project root to use for static file fallback
257
279
  if (path === "/__studio/activate" && req.method === "POST") {
258
280
  const body = (await req.json()) as { root?: string };
@@ -380,6 +402,7 @@ export async function createDevServer(options: {
380
402
  // Keep SSE connections alive — heartbeats are every 15 s, and AI streaming can take
381
403
  // 30+ s. The default 10 s idleTimeout kills them prematurely.
382
404
  idleTimeout: 120,
405
+ websocket: collabRegistry.websocket,
383
406
  });
384
407
 
385
408
  console.log(`\n@jxsuite/server listening on http://localhost:${server.port}`);
package/src/studio-api.ts CHANGED
@@ -1072,6 +1072,46 @@ export async function handleStudioApi(
1072
1072
  }
1073
1073
 
1074
1074
  // Discover a plugin module's schema for studio form rendering
1075
+ /* Cloudflare API passthrough — the backend of the publish surface's `cfApi`.
1076
+ Stateless: the credential arrives per-request in X-CF-Token (the user's
1077
+ pasted API token, kept client-side) and is never stored. Only account
1078
+ listing and Pages project/deployment paths are reachable. */
1079
+ if (path === "/__studio/cf/proxy" && req.method === "POST") {
1080
+ const CF_PROXY_ALLOWLIST = [
1081
+ /^\/accounts$/,
1082
+ /^\/accounts\/[0-9a-f]{32}\/pages\/projects(?:\/[\w-]+)?$/,
1083
+ /^\/accounts\/[0-9a-f]{32}\/pages\/projects\/[\w-]+\/deployments(?:\/[\w-]+)?$/,
1084
+ /^\/accounts\/[0-9a-f]{32}\/pages\/projects\/[\w-]+\/deployments\/[\w-]+\/retry$/,
1085
+ ];
1086
+ const cfToken = req.headers.get("X-CF-Token");
1087
+ if (!cfToken) {
1088
+ return Response.json({ error: "Missing X-CF-Token header" }, { status: 401 });
1089
+ }
1090
+ let payload: { path?: string; method?: string; body?: unknown };
1091
+ try {
1092
+ payload = (await req.json()) as typeof payload;
1093
+ } catch {
1094
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
1095
+ }
1096
+ const apiPath = payload.path ?? "";
1097
+ if (!CF_PROXY_ALLOWLIST.some((re) => re.test(apiPath))) {
1098
+ return Response.json({ error: `Path not allowed: ${apiPath}` }, { status: 403 });
1099
+ }
1100
+ const method = payload.method ?? "GET";
1101
+ const upstream = await fetch(`https://api.cloudflare.com/client/v4${apiPath}`, {
1102
+ method,
1103
+ headers: {
1104
+ Authorization: `Bearer ${cfToken}`,
1105
+ ...(payload.body === undefined ? {} : { "Content-Type": "application/json" }),
1106
+ },
1107
+ ...(payload.body === undefined ? {} : { body: JSON.stringify(payload.body) }),
1108
+ });
1109
+ return new Response(upstream.body, {
1110
+ headers: { "Content-Type": upstream.headers.get("Content-Type") ?? "application/json" },
1111
+ status: upstream.status,
1112
+ });
1113
+ }
1114
+
1075
1115
  // Format capability proxy — studio fallback for capabilities whose timing excludes "client".
1076
1116
  // Dispatches parse/serialize through the project's format registry by import name.
1077
1117
  if (path === "/__studio/format" && req.method === "POST") {