@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 +14 -13
- package/src/collab.ts +29 -33
- package/src/dev.ts +141 -0
- package/src/watch.ts +17 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
38
|
-
"@jxsuite/compiler": "^1.
|
|
39
|
-
"@jxsuite/create": "^1.
|
|
40
|
-
"@jxsuite/import": "^0.
|
|
41
|
-
"@jxsuite/protocol": "^0.
|
|
42
|
-
"@jxsuite/runtime": "^1.
|
|
43
|
-
"@jxsuite/schema": "^1.
|
|
44
|
-
"@jxsuite/starters": "^1.
|
|
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.
|
|
47
|
+
"kysely": "^0.29.4",
|
|
47
48
|
"zod": "^4.4.3"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
|
-
"@jxsuite/auth": "^0.
|
|
51
|
-
"@jxsuite/connector": "^0.
|
|
52
|
-
"@jxsuite/parser": "^1.
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
211
|
+
for (const path of pendingPersist) {
|
|
216
212
|
flushes.push(persist(path));
|
|
217
213
|
}
|
|
218
|
-
|
|
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 {{
|
|
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) {
|