@jxsuite/server 0.37.1 → 1.1.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.37.1",
3
+ "version": "1.1.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,13 +14,17 @@
14
14
  "type": "module",
15
15
  "exports": {
16
16
  ".": "./src/server.ts",
17
+ "./dev": "./src/dev.ts",
17
18
  "./ai-api": "./src/ai-api.ts",
19
+ "./data": "./src/data-api.ts",
18
20
  "./import-api": "./src/import-api.ts",
19
21
  "./packages": "./src/packages.ts",
20
22
  "./project-server": "./src/project-server.ts",
21
23
  "./refactor": "./src/refactor/index.ts",
22
24
  "./resolve": "./src/resolve.ts",
23
- "./types": "./src/types.ts"
25
+ "./types": "./src/types.ts",
26
+ "./jx-mounts": "./src/jx-mounts.ts",
27
+ "./dev-vars": "./src/dev-vars.ts"
24
28
  },
25
29
  "publishConfig": {
26
30
  "provenance": true
@@ -31,15 +35,21 @@
31
35
  "test:coverage": "bun test --isolate --coverage"
32
36
  },
33
37
  "dependencies": {
34
- "@jxsuite/collab": "^0.2.1",
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",
38
+ "@jxsuite/collab": "^0.4.0",
39
+ "@jxsuite/compiler": "^1.1.0",
40
+ "@jxsuite/create": "^1.1.0",
41
+ "@jxsuite/import": "^0.37.0",
42
+ "@jxsuite/protocol": "^0.5.0",
43
+ "@jxsuite/runtime": "^1.1.0",
44
+ "@jxsuite/schema": "^1.1.0",
45
+ "@jxsuite/starters": "^1.1.0",
42
46
  "chokidar": "^5.0.0",
47
+ "kysely": "^0.29.3",
43
48
  "zod": "^4.4.3"
49
+ },
50
+ "devDependencies": {
51
+ "@jxsuite/auth": "^0.3.0",
52
+ "@jxsuite/connector": "^0.3.0",
53
+ "@jxsuite/parser": "^1.1.0"
44
54
  }
45
55
  }
package/src/collab.ts CHANGED
@@ -2,11 +2,15 @@
2
2
  * Collab.ts — the dev server's realtime co-editing endpoint (`/__studio/collab`): the reference
3
3
  * implementation of the Jx collab wire contract, built on `@jxsuite/collab/room`.
4
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.
5
+ * Rooms are keyed by server-root-relative path; a room's Y.Text source seeds from the file on disk.
6
+ * Persistence is EXPLICIT: the shared source is written back to disk only on an explicit flush
7
+ * (Cmd+S / Save) and, as a last-ditch safety, on graceful server shutdown. Unsaved collaborative
8
+ * edits therefore live only in the room's in-memory Y.Doc until saved — non-collab consumers
9
+ * (preview, live reload, plain editors) intentionally keep working off the last-saved file until
10
+ * then. The host owns the room-level dirty signal broadcast to every peer; the embedder just tells
11
+ * it a persist landed via `markPersisted`. Genuinely external file changes (not our own write-back
12
+ * echo) bump the doc's epoch and reset subscribers, per the contract. No persistence beyond the
13
+ * files themselves: this is deliberately a reference implementation.
10
14
  */
11
15
 
12
16
  import { resolve } from "node:path";
@@ -17,7 +21,6 @@ import { assertAccessible } from "./studio-api";
17
21
  import type { ServerWebSocket, WebSocketHandler } from "bun";
18
22
  import type { HostConnection } from "@jxsuite/collab/room";
19
23
 
20
- const WRITE_BACK_DEBOUNCE_MS = 800;
21
24
  const EMPTY_ROOM_GRACE_MS = 30_000;
22
25
 
23
26
  const BINARY_EXTENSIONS = new Set([
@@ -79,8 +82,9 @@ export function createCollabRegistry(opts: {
79
82
  let connectionCounter = 0;
80
83
  let stopped = false;
81
84
 
82
- const writeTimers = new Map<string, ReturnType<typeof setTimeout>>();
83
85
  const destroyTimers = new Map<string, ReturnType<typeof setTimeout>>();
86
+ /** Paths with unsaved edits (source changed since the last persist) — the shutdown worklist. */
87
+ const pendingPersist = new Set<string>();
84
88
  /** Content we last wrote (or are about to write) per path — the watcher-echo signature. */
85
89
  const lastWritten = new Map<string, string>();
86
90
 
@@ -103,13 +107,19 @@ export function createCollabRegistry(opts: {
103
107
  return;
104
108
  }
105
109
  if (lastWritten.get(path) === source) {
110
+ pendingPersist.delete(path);
106
111
  return;
107
112
  }
113
+ // Optimistic: closes the watcher-echo race (our own write must not read back as external).
108
114
  lastWritten.set(path, source);
109
115
  try {
110
116
  await writeFile(absPathFor(path), source, "utf8");
117
+ pendingPersist.delete(path);
118
+ // Tell the host the room is durable now: it resets the dirty baseline and broadcasts clean.
119
+ host.markPersisted(path);
111
120
  } catch {
112
- // Disk write failures leave the room live; the next change retries.
121
+ // Write failed — clear the optimistic signature so the next explicit save retries.
122
+ lastWritten.delete(path);
113
123
  }
114
124
  };
115
125
 
@@ -126,34 +136,22 @@ export function createCollabRegistry(opts: {
126
136
  if (existing) {
127
137
  clearTimeout(existing);
128
138
  }
139
+ // Warnings-only model: an empty room is NOT auto-persisted (the last collaborator was warned
140
+ // Before leaving). Its unsaved edits are dropped when the room is torn down after the grace.
129
141
  destroyTimers.set(
130
142
  path,
131
143
  setTimeout(() => {
132
144
  destroyTimers.delete(path);
133
- void persist(path).then(() => host.destroyRoomIfEmpty(path));
145
+ pendingPersist.delete(path);
146
+ host.destroyRoomIfEmpty(path);
134
147
  }, EMPTY_ROOM_GRACE_MS),
135
148
  );
136
149
  },
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
- },
150
+ onFlush: (path) => persist(path),
145
151
  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
- );
152
+ // No write-back: edits reach disk only on explicit flush (or graceful shutdown). The host
153
+ // Owns the dirty broadcast; we just note the path for the shutdown worklist.
154
+ pendingPersist.add(path);
157
155
  },
158
156
  rejectPath: (path) => {
159
157
  if (isBinaryPath(path)) {
@@ -205,17 +203,15 @@ export function createCollabRegistry(opts: {
205
203
  return;
206
204
  }
207
205
  stopped = true;
208
- for (const timer of writeTimers.values()) {
209
- clearTimeout(timer);
210
- }
211
206
  for (const timer of destroyTimers.values()) {
212
207
  clearTimeout(timer);
213
208
  }
209
+ // Last-ditch safety on graceful shutdown: flush every path with unsaved edits.
214
210
  const flushes: Promise<void>[] = [];
215
- for (const path of writeTimers.keys()) {
211
+ for (const path of pendingPersist) {
216
212
  flushes.push(persist(path));
217
213
  }
218
- writeTimers.clear();
214
+ pendingPersist.clear();
219
215
  destroyTimers.clear();
220
216
  await Promise.all(flushes);
221
217
  host.destroy();