@jxsuite/server 1.0.0 → 1.1.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": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,6 +14,7 @@
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",
18
19
  "./data": "./src/data-api.ts",
19
20
  "./import-api": "./src/import-api.ts",
@@ -34,21 +35,21 @@
34
35
  "test:coverage": "bun test --isolate --coverage"
35
36
  },
36
37
  "dependencies": {
37
- "@jxsuite/collab": "^0.3.0",
38
- "@jxsuite/compiler": "^1.0.0",
39
- "@jxsuite/create": "^1.0.0",
40
- "@jxsuite/import": "^0.36.0",
41
- "@jxsuite/protocol": "^0.4.0",
42
- "@jxsuite/runtime": "^1.0.0",
43
- "@jxsuite/schema": "^1.0.0",
44
- "@jxsuite/starters": "^1.0.0",
38
+ "@jxsuite/collab": "^0.4.1",
39
+ "@jxsuite/compiler": "^1.2.0",
40
+ "@jxsuite/create": "^1.1.1",
41
+ "@jxsuite/import": "^0.37.1",
42
+ "@jxsuite/protocol": "^0.5.1",
43
+ "@jxsuite/runtime": "^1.2.0",
44
+ "@jxsuite/schema": "^1.2.0",
45
+ "@jxsuite/starters": "^1.1.1",
45
46
  "chokidar": "^5.0.0",
46
- "kysely": "^0.29.3",
47
+ "kysely": "^0.29.4",
47
48
  "zod": "^4.4.3"
48
49
  },
49
50
  "devDependencies": {
50
- "@jxsuite/auth": "^0.2.0",
51
- "@jxsuite/connector": "^0.2.0",
52
- "@jxsuite/parser": "^1.0.0"
51
+ "@jxsuite/auth": "^0.3.1",
52
+ "@jxsuite/connector": "^0.3.1",
53
+ "@jxsuite/parser": "^1.2.0"
53
54
  }
54
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();
package/src/dev.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Dev-server CLI entry — what `jx dev` spawns under Bun in a standalone project.
3
+ *
4
+ * Usage: bun @jxsuite/server/dev [--root <dir>] [--port <n>]
5
+ *
6
+ * For a site project (a project.json at the root), the entry builds the site up front, serves the
7
+ * built pages from dist/ ahead of the static-source fallback, and rebuilds before each live-reload
8
+ * broadcast so the browser always reloads into fresh output. Argument parsing and the middleware
9
+ * factory are exported for tests; the server boot runs only when executed directly
10
+ * (import.meta.main).
11
+ *
12
+ * @module @jxsuite/server/dev
13
+ */
14
+
15
+ import { existsSync, statSync } from "node:fs";
16
+ import { join, normalize, resolve, sep } from "node:path";
17
+ import { createDevServer } from "./server.ts";
18
+ import { injectSSE } from "./watch.ts";
19
+
20
+ export interface DevArgs {
21
+ port: number;
22
+ root: string;
23
+ }
24
+
25
+ /**
26
+ * Parse `--root`/`--port` (plus an optional positional root) into dev-server options.
27
+ *
28
+ * @param {string[]} argv - Raw arguments (process.argv.slice(2))
29
+ * @returns {DevArgs} Resolved root and port (default 3000)
30
+ */
31
+ export function parseDevArgs(argv: string[]): DevArgs {
32
+ let root = ".";
33
+ let port = 3000;
34
+ for (let index = 0; index < argv.length; index += 1) {
35
+ const arg = argv[index]!;
36
+ if (arg === "--root") {
37
+ index += 1;
38
+ root = argv[index] ?? root;
39
+ } else if (arg === "--port") {
40
+ index += 1;
41
+ const parsed = Math.trunc(Number(argv[index] ?? ""));
42
+ if (Number.isFinite(parsed) && parsed >= 0) {
43
+ port = parsed;
44
+ }
45
+ } else if (!arg.startsWith("--")) {
46
+ root = arg;
47
+ }
48
+ }
49
+ return { port, root: resolve(root) };
50
+ }
51
+
52
+ /**
53
+ * Middleware serving built pages from `<root>/dist` for page-like GET requests. Directory URLs
54
+ * (with or without a trailing slash) map to their index.html; anything not present in dist falls
55
+ * through to the dev server's static-source handling.
56
+ *
57
+ * @param {string} root - Absolute project root
58
+ * @returns {(req: Request, url: URL) => Response | null}
59
+ */
60
+ export function createDistMiddleware(
61
+ root: string,
62
+ ): (req: Request, url: URL) => Promise<Response | null> {
63
+ const distDir = join(root, "dist");
64
+ return async (req, url) => {
65
+ if (req.method !== "GET") {
66
+ return null;
67
+ }
68
+ const safe = normalize(decodeURIComponent(url.pathname)).replaceAll("\\", "/");
69
+ const candidate = resolve(distDir, `.${safe.startsWith("/") ? safe : `/${safe}`}`);
70
+ if (candidate !== distDir && !candidate.startsWith(distDir + sep)) {
71
+ return null;
72
+ }
73
+ let filePath: string | null = null;
74
+ if (existsSync(candidate)) {
75
+ filePath = statSync(candidate).isDirectory() ? join(candidate, "index.html") : candidate;
76
+ } else {
77
+ filePath = join(candidate, "index.html");
78
+ }
79
+ if (!filePath || !existsSync(filePath)) {
80
+ return null;
81
+ }
82
+ // Built pages get the live-reload client, like the server's own .html responses
83
+ if (filePath.endsWith(".html")) {
84
+ const html = await Bun.file(filePath).text();
85
+ return new Response(injectSSE(html), {
86
+ headers: { "Cache-Control": "no-cache", "Content-Type": "text/html; charset=utf-8" },
87
+ });
88
+ }
89
+ return new Response(Bun.file(filePath), { headers: { "Cache-Control": "no-cache" } });
90
+ };
91
+ }
92
+
93
+ export interface DevServerHandle {
94
+ port: number;
95
+ stop: () => void;
96
+ }
97
+
98
+ /**
99
+ * Boot the dev server for a root. Site projects (a project.json at the root) are built up front and
100
+ * rebuilt before every live-reload broadcast, with built pages served from dist/.
101
+ *
102
+ * @param {DevArgs} args - Resolved root and port
103
+ * @returns {Promise<DevServerHandle>} The listening server
104
+ */
105
+ export async function startDev({ port, root }: DevArgs): Promise<DevServerHandle> {
106
+ const isSite = existsSync(join(root, "project.json"));
107
+
108
+ let building = Promise.resolve();
109
+ const rebuild = () => {
110
+ building = building.then(async () => {
111
+ const { buildSite } = await import("@jxsuite/compiler/site");
112
+ const result = await buildSite(root, { verbose: false });
113
+ if (result.errors.length > 0) {
114
+ for (const err of result.errors) {
115
+ console.error(` build error: ${err}`);
116
+ }
117
+ }
118
+ });
119
+ return building;
120
+ };
121
+
122
+ if (isSite) {
123
+ console.log(`Building ${root}...`);
124
+ await rebuild();
125
+ }
126
+
127
+ return (await createDevServer({
128
+ port,
129
+ root,
130
+ ...(isSite && {
131
+ middleware: createDistMiddleware(root),
132
+ watch: { preReload: () => rebuild(), reloadOnAnyChange: true },
133
+ }),
134
+ })) as DevServerHandle;
135
+ }
136
+
137
+ if (import.meta.main) {
138
+ const { port, root } = parseDevArgs(process.argv.slice(2));
139
+ const server = await startDev({ port, root });
140
+ console.log(`Jx dev server running at http://localhost:${server.port} (root: ${root})`);
141
+ }
package/src/watch.ts CHANGED
@@ -10,6 +10,7 @@ import type { FsEventPayload } from "./refactor/fs-events.ts";
10
10
  const DEFAULT_IGNORE = [
11
11
  "**/node_modules/**",
12
12
  "**/dist/**",
13
+ "**/.cache/**",
13
14
  "**/.git/**",
14
15
  "**/.jx/**",
15
16
  "**/.devenv/**",
@@ -62,7 +63,12 @@ export function injectSSE(html: string) {
62
63
  *
63
64
  * @param {string} root - Absolute path to watch
64
65
  * @param {BuildEntry[]} builds - Build entries (for selective rebuild)
65
- * @param {{ ignore?: string[]; debounce?: number; reloadOnAnyChange?: boolean }} [opts]
66
+ * @param {{
67
+ * ignore?: string[];
68
+ * debounce?: number;
69
+ * reloadOnAnyChange?: boolean;
70
+ * preReload?: (filename: string) => Promise<void> | void;
71
+ * }} [opts]
66
72
  * @returns {{
67
73
  * broadcast: () => void;
68
74
  * handleSSE: () => Response;
@@ -76,11 +82,14 @@ export function createWatcher(
76
82
  ignore?: string[];
77
83
  debounce?: number;
78
84
  reloadOnAnyChange?: boolean;
85
+ /** Runs before the reload broadcast — e.g. `jx dev` rebuilds the site here. */
86
+ preReload?: (filename: string) => Promise<void> | void;
79
87
  } = {},
80
88
  ) {
81
89
  const ignore = opts.ignore ?? DEFAULT_IGNORE;
82
90
  const debounceMs = opts.debounce ?? 50;
83
91
  const reloadOnAnyChange = opts.reloadOnAnyChange ?? false;
92
+ const { preReload } = opts;
84
93
 
85
94
  const clients = new Set<(msg: string) => void>();
86
95
  const encoder = new TextEncoder();
@@ -172,6 +181,13 @@ export function createWatcher(
172
181
 
173
182
  clearTimeout(timer ?? undefined);
174
183
  timer = setTimeout(async () => {
184
+ if (preReload) {
185
+ try {
186
+ await preReload(filename);
187
+ } catch (error) {
188
+ console.error(`[watch] preReload failed: ${(error as Error).message}`);
189
+ }
190
+ }
175
191
  if (builds.length > 0) {
176
192
  const result = await rebuild(builds, filename);
177
193
  if (!result.success) {