@jxsuite/server 8.1.0 → 8.2.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": "8.1.0",
3
+ "version": "8.2.1",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,17 +54,17 @@
54
54
  "upgrade": "bun update --latest && bun install"
55
55
  },
56
56
  "dependencies": {
57
- "@jxsuite/ai": "^0.42.0",
58
- "@jxsuite/catalog": "^0.1.6",
59
- "@jxsuite/collab": "^0.11.4",
60
- "@jxsuite/compiler": "^8.0.2",
61
- "@jxsuite/create": "^1.5.2",
62
- "@jxsuite/import": "^0.42.2",
63
- "@jxsuite/protocol": "^6.2.0",
64
- "@jxsuite/runtime": "^6.0.1",
65
- "@jxsuite/schema": "^5.0.0",
66
- "@jxsuite/site": "^2.2.2",
67
- "@jxsuite/starters": "^3.1.4",
57
+ "@jxsuite/ai": "^0.42.1",
58
+ "@jxsuite/catalog": "^0.1.7",
59
+ "@jxsuite/collab": "^0.11.5",
60
+ "@jxsuite/compiler": "^8.1.0",
61
+ "@jxsuite/create": "^1.5.3",
62
+ "@jxsuite/import": "^0.42.3",
63
+ "@jxsuite/protocol": "^6.2.1",
64
+ "@jxsuite/runtime": "^6.0.2",
65
+ "@jxsuite/schema": "^5.1.0",
66
+ "@jxsuite/site": "^2.3.0",
67
+ "@jxsuite/starters": "^3.2.0",
68
68
  "@npmcli/arborist": "^10.0.4",
69
69
  "chokidar": "^6.0.0",
70
70
  "kysely": "^0.29.8",
@@ -74,9 +74,9 @@
74
74
  "zod": "^4.6.7"
75
75
  },
76
76
  "devDependencies": {
77
- "@jxsuite/auth": "^0.8.2",
78
- "@jxsuite/connector": "^0.8.2",
79
- "@jxsuite/parser": "^4.1.2",
77
+ "@jxsuite/auth": "^0.8.3",
78
+ "@jxsuite/connector": "^0.8.3",
79
+ "@jxsuite/parser": "^4.2.0",
80
80
  "@jxsuite/test-support": "^0.0.0",
81
81
  "@types/npm-package-arg": "^6.1.4",
82
82
  "@types/npmcli__arborist": "^6.3.3",
@@ -15,6 +15,7 @@ import { errorMessage } from "@jxsuite/schema/parse";
15
15
  import { isUnder } from "./paths.ts";
16
16
  import { rewriteDocRefs, rewriteTagName } from "./refs.ts";
17
17
  import { documentGlob, fwd, loadDoc, skipScanPath } from "./scan.ts";
18
+ import { invalidateReferenceCache } from "./find-refs.ts";
18
19
  import { refactorMounts } from "./mounts.ts";
19
20
  import type { RefChange } from "./refs.ts";
20
21
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
@@ -74,6 +75,19 @@ export function deriveTag(absPath: string): string {
74
75
  * must have already moved the file on disk.
75
76
  */
76
77
  export async function applyRename(opts: ApplyRenameOptions): Promise<RenameReport> {
78
+ /* The usage cache is otherwise dropped by the file watcher, which reports this rename some time
79
+ after the route has answered it, and not at all under a path it ignores. The caller's very next
80
+ question is "who uses the new name?", and a confident zero read from the old sweep is the one
81
+ answer a delete must never be prompted by. On every exit: a pass that threw may have written. */
82
+ try {
83
+ return await rewriteReferences(opts);
84
+ } finally {
85
+ invalidateReferenceCache(opts.root);
86
+ }
87
+ }
88
+
89
+ /** The refactor pass itself: see {@link applyRename}. */
90
+ async function rewriteReferences(opts: ApplyRenameOptions): Promise<RenameReport> {
77
91
  const root = fwd(opts.root);
78
92
  const absFrom = fwd(opts.absFrom);
79
93
  const absTo = fwd(opts.absTo);
Binary file
@@ -2,11 +2,13 @@
2
2
  * Watcher.ts — a small chokidar wrapper that emits coalesced, batched FS-event payloads to a sink.
3
3
  *
4
4
  * Used by the desktop project session (which has no SSE stream) to push filesystem changes to its
5
- * webview over RPC. The dev server keeps its own watcher in watch.ts (intertwined with
6
- * build/reload); both share the pure `toFsEvent`/`coalesceFsEvents` helpers.
5
+ * webview over RPC, and by the dev server for an active project its own watcher in watch.ts
6
+ * (intertwined with build/reload) cannot see. Both share the pure `toFsEvent`/`coalesceFsEvents`
7
+ * helpers.
7
8
  */
8
9
 
9
10
  import { watch } from "chokidar";
11
+ import { relative } from "node:path";
10
12
  import { errorMessage } from "@jxsuite/schema/parse";
11
13
  import { coalesceFsEvents, toFsEvent } from "./fs-events.ts";
12
14
  import { invalidateReferenceCache } from "./find-refs.ts";
@@ -15,8 +17,14 @@ import type { FsEventPayload } from "./fs-events.ts";
15
17
 
16
18
  const IGNORE_SEGMENTS = ["node_modules", ".git", "dist", ".jx", ".direnv", ".devenv"];
17
19
 
18
- function isIgnored(path: string): boolean {
19
- const f = path.replaceAll("\\", "/");
20
+ /**
21
+ * Whether a path below `root` is one of the folders no project wants events for.
22
+ *
23
+ * Judged from the root down, because what lies above it says nothing about the project: read whole,
24
+ * a project at `~/dist/site` or `~/sites/.jx/site` would have every entry in it ignored.
25
+ */
26
+ function isIgnored(root: string, path: string): boolean {
27
+ const f = `/${relative(root, path).replaceAll("\\", "/")}`;
20
28
  return IGNORE_SEGMENTS.some((s) => f.includes(`/${s}/`) || f.endsWith(`/${s}`));
21
29
  }
22
30
 
@@ -45,7 +53,7 @@ export function createFsWatcher(
45
53
  followSymlinks: true,
46
54
  ignoreInitial: true,
47
55
  ignorePermissionErrors: true,
48
- ignored: createWatchIgnore(root, isIgnored),
56
+ ignored: createWatchIgnore(root, (path) => isIgnored(root, path)),
49
57
  });
50
58
 
51
59
  watcher.on("all", (eventType, changedPath) => {
package/src/server.ts CHANGED
@@ -17,6 +17,8 @@ import { dirname, isAbsolute, relative, resolve } from "node:path";
17
17
  import { buildAll } from "./build.ts";
18
18
  import { createCollabRegistry } from "./collab.ts";
19
19
  import { createWatcher, injectSSE } from "./watch.ts";
20
+ import { invalidateReferenceCache } from "./refactor/find-refs.ts";
21
+ import { createFsWatcher } from "./refactor/watcher.ts";
20
22
  import { handleJxMounts } from "./jx-mounts.ts";
21
23
  import {
22
24
  handleResolve,
@@ -122,15 +124,39 @@ export async function createDevServer(options: {
122
124
  await buildAll(builds);
123
125
  }
124
126
 
127
+ // Active studio project root (set via /__studio/activate, used for static file fallback)
128
+ let activeProjectRoot: string | null = null;
129
+ let activeProjectWatcher: ReturnType<typeof createFsWatcher> | null = null;
130
+
131
+ /** The active project as the Studio that activated it spells it, which its `fs` filter compares. */
132
+ let activeProjectSpelling = "";
133
+
134
+ /**
135
+ * A root-relative path inside the active project, as that project's Studio looks for it.
136
+ *
137
+ * Studio keeps and activates a project by whatever it was opened with, usually an absolute path,
138
+ * and drops an `fs` event that does not start with it. The root watcher's own paths are relative
139
+ * to the server root, so read as they stand they reach no Studio holding an absolute root.
140
+ */
141
+ const spelledForStudio = (path: string): string => {
142
+ if (activeProjectRoot === null) {
143
+ return path;
144
+ }
145
+ const inside = relative(activeProjectRoot, resolve(absRoot, path)).replaceAll("\\", "/");
146
+ const outside = inside === "" || inside === ".." || inside.startsWith("../");
147
+ return outside || isAbsolute(inside) ? path : `${activeProjectSpelling}/${inside}`;
148
+ };
149
+
125
150
  // ─── File watcher + SSE ─────────────────────────────────────────────────────
126
151
 
127
152
  let handleSSE = null;
153
+ let rootWatcher: ReturnType<typeof createWatcher> | null = null;
128
154
  let fsWatcher: ReturnType<typeof createWatcher>["watcher"] | null = null;
129
155
  if (watch !== false) {
130
156
  const watchOpts = watch === true ? {} : watch;
131
- const watcher = createWatcher(absRoot, builds, watchOpts);
132
- ({ handleSSE } = watcher);
133
- fsWatcher = watcher.watcher;
157
+ rootWatcher = createWatcher(absRoot, builds, { eventPath: spelledForStudio, ...watchOpts });
158
+ ({ handleSSE } = rootWatcher);
159
+ fsWatcher = rootWatcher.watcher;
134
160
  }
135
161
 
136
162
  // Bundle cache for npm packages (bare specifier → bundled JS)
@@ -155,8 +181,38 @@ export async function createDevServer(options: {
155
181
  });
156
182
  };
157
183
 
158
- // Active studio project root (set via /__studio/activate, used for static file fallback)
159
- let activeProjectRoot: string | null = null;
184
+ /**
185
+ * Watch the project being activated, when the server's own watcher cannot see it.
186
+ *
187
+ * `allowedRoots` and a project of the account's own can both lie outside the server root, and a
188
+ * project in a folder the root watcher ignores (`.cache`, `dist`) is below it and just as unseen.
189
+ * The usage cache is invalidated by the filesystem and the Files panel is kept current by it, so
190
+ * a project nothing watches would answer from its first sweep and show its first listing for the
191
+ * rest of the session. `createFsWatcher` drops the project's usage answers itself, and its events
192
+ * reach Studio over the same stream as the root watcher's, under the prefix that Studio filters
193
+ * on.
194
+ *
195
+ * Dropped here as well, since the project may have changed while it was not the active one.
196
+ */
197
+ async function watchActiveProject(next: string | null): Promise<void> {
198
+ await activeProjectWatcher?.close();
199
+ activeProjectWatcher = null;
200
+ if (next === null || rootWatcher === null) {
201
+ return;
202
+ }
203
+ invalidateReferenceCache(next);
204
+ const { broadcastEvent, sees } = rootWatcher;
205
+ if (containedPath(next, absRoot) === null || !sees(next)) {
206
+ activeProjectWatcher = createFsWatcher(next, (events) => {
207
+ broadcastEvent("fs", {
208
+ events: events.map((event) => ({
209
+ ...event,
210
+ path: `${activeProjectSpelling}/${event.path}`,
211
+ })),
212
+ });
213
+ });
214
+ }
215
+ }
160
216
 
161
217
  // ─── Realtime co-editing (/__studio/collab) ─────────────────────────────────
162
218
 
@@ -280,6 +336,7 @@ export async function createDevServer(options: {
280
336
  const raw = body.root || null;
281
337
  if (!raw) {
282
338
  activeProjectRoot = null;
339
+ await watchActiveProject(null);
283
340
  return Response.json({ ok: true, root: null });
284
341
  }
285
342
  const requested = resolve(absRoot, raw);
@@ -293,6 +350,10 @@ export async function createDevServer(options: {
293
350
  // And RFC 9457 §3.2 exists precisely so a problem can carry what a type documents.
294
351
  return problem("forbidden", "root not permitted", { ok: false });
295
352
  }
353
+ activeProjectSpelling = raw.replaceAll("\\", "/").replace(/\/+$/, "");
354
+ if (requested !== activeProjectRoot) {
355
+ await watchActiveProject(requested);
356
+ }
296
357
  activeProjectRoot = requested;
297
358
  return Response.json({ ok: true, root: activeProjectRoot });
298
359
  }
package/src/watch.ts CHANGED
@@ -81,10 +81,12 @@ export function injectSSE(html: string) {
81
81
  * debounce?: number;
82
82
  * reloadOnAnyChange?: boolean;
83
83
  * preReload?: (filename: string) => Promise<void> | void;
84
+ * eventPath?: (path: string) => string;
84
85
  * }} [opts]
85
86
  * @returns {{
86
87
  * broadcast: () => void;
87
88
  * handleSSE: (request?: Request) => Response;
89
+ * sees: (path: string) => boolean;
88
90
  * watcher: import("chokidar").FSWatcher;
89
91
  * }}
90
92
  */
@@ -97,6 +99,8 @@ export function createWatcher(
97
99
  reloadOnAnyChange?: boolean;
98
100
  /** Runs before the reload broadcast — e.g. `jx dev` rebuilds the site here. */
99
101
  preReload?: (filename: string) => Promise<void> | void;
102
+ /** How an `fs` event names a root-relative path on the wire. Unchanged by default. */
103
+ eventPath?: (path: string) => string;
100
104
  } = {},
101
105
  ) {
102
106
  const ignore = opts.ignore ?? DEFAULT_IGNORE;
@@ -129,9 +133,11 @@ export function createWatcher(
129
133
  return;
130
134
  }
131
135
 
132
- // The usage query's cache is invalidated by the filesystem, never by a timer — a delete
133
- // Confirmation that says "used on 7 pages" must be answering about the tree as it is now.
134
- invalidateReferenceCache(root);
136
+ /* The usage query's cache is invalidated by the filesystem, never by a timer: a delete
137
+ confirmation that says "used on 7 pages" must be answering about the tree as it is now. All
138
+ of it, not this root's: the answers are filed under the ACTIVE PROJECT, which is usually a
139
+ folder below the server root this watcher is on, and a sweep costs less than a wrong count. */
140
+ invalidateReferenceCache();
135
141
 
136
142
  // Structured FS events for the studio sidebar (coalesced + batched), emitted as a named "fs"
137
143
  // SSE event so the preview iframe ignores them while the studio shell subscribes to them.
@@ -141,7 +147,12 @@ export function createWatcher(
141
147
  clearTimeout(fsTimer ?? undefined);
142
148
  fsTimer = setTimeout(() => {
143
149
  if (fsBuffer.length > 0) {
144
- broadcastEvent("fs", { events: fsBuffer });
150
+ const { eventPath } = opts;
151
+ broadcastEvent("fs", {
152
+ events: eventPath
153
+ ? fsBuffer.map((event) => ({ ...event, path: eventPath(event.path) }))
154
+ : fsBuffer,
155
+ });
145
156
  fsBuffer = [];
146
157
  }
147
158
  }, fsDebounceMs);
@@ -183,5 +194,9 @@ export function createWatcher(
183
194
  console.error("[watch] chokidar error:", message ?? err);
184
195
  });
185
196
 
186
- return { broadcast, broadcastEvent, handleSSE, watcher };
197
+ /* Whether an absolute path below `root` is one this watcher reports, by the rule it hands chokidar
198
+ above. Lets the server tell which active project it has to watch separately. */
199
+ const sees = (path: string) => !shouldIgnore(path, ignore);
200
+
201
+ return { broadcast, broadcastEvent, handleSSE, sees, watcher };
187
202
  }