@jxsuite/server 0.36.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 +2 -1
- package/src/collab.ts +250 -0
- package/src/server.ts +24 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "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,6 +31,7 @@
|
|
|
31
31
|
"test:coverage": "bun test --isolate --coverage"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
+
"@jxsuite/collab": "^0.2.0",
|
|
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}`);
|