@jxsuite/server 0.36.0 → 0.37.1

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.36.0",
3
+ "version": "0.37.1",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,6 +31,7 @@
31
31
  "test:coverage": "bun test --isolate --coverage"
32
32
  },
33
33
  "dependencies": {
34
+ "@jxsuite/collab": "^0.2.1",
34
35
  "@jxsuite/compiler": "^0.35.0",
35
36
  "@jxsuite/create": "^0.36.0",
36
37
  "@jxsuite/import": "^0.35.1",
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}`);