@jxsuite/server 7.0.0 → 8.0.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/src/server.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * proxying, and studio filesystem integration as a single createDevServer() call.
14
14
  */
15
15
 
16
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
16
+ 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";
@@ -40,148 +40,11 @@ import { handleCodeApi } from "./code-api.ts";
40
40
  import { handleAiApi } from "./ai-api.ts";
41
41
  import { handleImportApi } from "./import-api.ts";
42
42
  import { mediaTypeForPath } from "@jxsuite/schema/media-type";
43
- import { existsSync, readFileSync } from "node:fs";
44
43
  import { problem } from "./problem.ts";
45
- import { isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
46
- import type { JsonValue } from "@jxsuite/schema/types";
47
44
 
48
- /**
49
- * Read `key` off a parsed `package.json` value, or `undefined` when the value is not an object.
50
- *
51
- * Every lookup below reads through this one, so a manifest of the wrong shape (an `exports` that is
52
- * a string, a `main` that is an array) yields "absent" rather than a member of something else.
53
- */
54
- function manifestField(value: JsonValue | undefined, key: string): JsonValue | undefined {
55
- return isJsonObject(value) ? value[key] : undefined;
56
- }
57
-
58
- /** Read and parse a package's `package.json`. Throws when the file is not JSON. */
59
- function readManifest(pkgJsonPath: string): JsonValue {
60
- // SAFETY: JSON.parse without a reviver only ever yields a JSON value; its `any` says no more.
61
- return JSON.parse(readFileSync(pkgJsonPath, "utf8")) as JsonValue;
62
- }
63
-
64
- /**
65
- * Resolve an npm-style bare specifier from a URL path via node_modules. Handles scoped packages
66
- * (@scope/pkg/subpath) and respects package.json exports. Strips leading directory segments (e.g.
67
- * /pages/@scope/pkg/file → @scope/pkg/file).
68
- *
69
- * @param {string} root - Absolute project root
70
- * @param {string} urlPath - URL pathname (e.g. "/pages/@jxsuite/parser/Foo.class.json")
71
- * @returns {string | null} Absolute file path or null
72
- */
73
- function resolveNpmPath(rootDir: string, urlPath: string) {
74
- let root = rootDir;
75
- let segments = urlPath.split("/").filter(Boolean);
76
-
77
- // If "node_modules" appears in the path, use everything before it as a subdirectory
78
- // Prefix and everything after as the package specifier.
79
- // E.g. /examples/demo/node_modules/@scope/pkg → root=root/examples/demo, pkg=@scope/pkg
80
- const nmIdx = segments.indexOf("node_modules");
81
- if (nmIdx !== -1) {
82
- if (nmIdx > 0) {
83
- root = join(root, ...segments.slice(0, nmIdx));
84
- }
85
- segments = segments.slice(nmIdx + 1);
86
- }
87
-
88
- // Find the package start — either @scope/pkg or unscoped pkg
89
- let start = -1;
90
- let isScoped = false;
91
- for (let i = 0; i < segments.length; i++) {
92
- if (segments[i]!.startsWith("@")) {
93
- start = i;
94
- isScoped = true;
95
- break;
96
- }
97
- }
98
-
99
- let pkgDir = "";
100
- let subpath = "";
101
-
102
- if (isScoped) {
103
- if (start < 0 || start + 1 >= segments.length) {
104
- return null;
105
- }
106
- const scope = segments[start]!;
107
- const pkg = segments[start + 1]!;
108
- subpath = segments.slice(start + 2).join("/");
109
- pkgDir = join(root, "node_modules", scope, pkg);
110
- } else {
111
- // Unscoped: try each segment as a package name in node_modules
112
- for (let i = 0; i < segments.length; i++) {
113
- const candidate = join(root, "node_modules", segments[i]!);
114
- if (existsSync(join(candidate, "package.json"))) {
115
- start = i;
116
- pkgDir = candidate;
117
- subpath = segments.slice(i + 1).join("/");
118
- break;
119
- }
120
- }
121
- if (start < 0) {
122
- return null;
123
- }
124
- }
125
-
126
- const pkgJsonPath = join(pkgDir, "package.json");
127
- if (!existsSync(pkgJsonPath)) {
128
- return null;
129
- }
130
-
131
- // If there's a subpath, check package.json exports first
132
- if (subpath) {
133
- try {
134
- const pkgJson = readManifest(pkgJsonPath);
135
- const exportVal = manifestField(manifestField(pkgJson, "exports"), `./${subpath}`);
136
- if (isString(exportVal)) {
137
- const mapped = join(pkgDir, exportVal);
138
- if (existsSync(mapped)) {
139
- return mapped;
140
- }
141
- }
142
- } catch {}
143
- // Fall back to direct path
144
- const direct = join(pkgDir, subpath);
145
- if (existsSync(direct)) {
146
- return direct;
147
- }
148
- // CEM-relative: subpath may be relative to the custom elements manifest directory
149
- try {
150
- const customElements = manifestField(readManifest(pkgJsonPath), "customElements");
151
- if (isString(customElements) && customElements) {
152
- const cemDir = customElements.replace(/\/[^/]+$/, "");
153
- const cemRelative = join(pkgDir, cemDir, subpath);
154
- if (existsSync(cemRelative)) {
155
- return cemRelative;
156
- }
157
- }
158
- } catch {}
159
- }
160
-
161
- // Bare package (no subpath): resolve entry point
162
- try {
163
- const pkgJson = readManifest(pkgJsonPath);
164
- const exp = manifestField(manifestField(pkgJson, "exports"), ".");
165
- // An explicit `null` entry declines the package's entry point outright: no `module`/`main`.
166
- if (exp !== null) {
167
- const conditional = isNonNullObject(exp)
168
- ? (manifestField(exp, "import") ?? manifestField(exp, "default"))
169
- : exp;
170
- const entry =
171
- conditional ?? manifestField(pkgJson, "module") ?? manifestField(pkgJson, "main");
172
- if (isString(entry) && entry) {
173
- const resolved = join(pkgDir, entry);
174
- if (existsSync(resolved)) {
175
- return resolved;
176
- }
177
- }
178
- }
179
- } catch {}
180
-
181
- return null;
182
- }
45
+ import { resolveNpmPath } from "./npm-path.ts";
183
46
 
184
- export { resolveNpmPath };
47
+ export { resolveNpmPath } from "./npm-path.ts";
185
48
 
186
49
  /** The watcher options `createDevServer` passes through when `watch` is an object. */
187
50
  type DevWatchOptions = NonNullable<Parameters<typeof createWatcher>[2]>;
@@ -200,7 +63,7 @@ type DevWatchOptions = NonNullable<Parameters<typeof createWatcher>[2]>;
200
63
  * }[]} [options.builds]
201
64
  * - Bun.build entries with optional match regex
202
65
  * @param {boolean | object} [options.watch] - Watch config or false to disable. Default is `true`
203
- * @param {boolean} [options.studio] - Enable /**studio/* endpoints. Default is `true`
66
+ * @param {boolean} [options.studio] - Enable /*_studio/_ endpoints. Default is `true`
204
67
  * @param {Function} [options.middleware] - Custom route handler (req, url) => Response|null
205
68
  * @returns {Promise<object>} The Bun.serve server object
206
69
  */
@@ -395,7 +258,12 @@ export async function createDevServer(options: {
395
258
  if (enableStudio && path.startsWith("/__studio/")) {
396
259
  // Realtime co-editing: WebSocket upgrade or capability probe
397
260
  if (path === "/__studio/collab") {
398
- return collabRegistry.handleRequest(req, bunServer);
261
+ return collabRegistry.handleRequest(req, {
262
+ upgrade: (upgradeReq, { data, headers }) =>
263
+ headers === undefined
264
+ ? bunServer.upgrade(upgradeReq, { data })
265
+ : bunServer.upgrade(upgradeReq, { data, headers: { ...headers } }),
266
+ });
399
267
  }
400
268
 
401
269
  // Activate project — tells the server which project root to use for static file fallback.
@@ -28,6 +28,7 @@
28
28
 
29
29
  import { serveSiteOutput, siteOutDir } from "./site-output.ts";
30
30
  import { problem } from "./problem.ts";
31
+ import { serve } from "#serve";
31
32
 
32
33
  /** A running preview server for one project. */
33
34
  export interface SitePreview {
@@ -37,27 +38,38 @@ export interface SitePreview {
37
38
  }
38
39
 
39
40
  interface Running extends SitePreview {
40
- server: { stop: (closeActiveConnections?: boolean) => void };
41
+ server: { stop: (closeActiveConnections?: boolean) => Promise<void> };
41
42
  }
42
43
 
43
- /** One server per project root — a second `Open in Browser` reuses the first one's port. */
44
- const running = new Map<string, Running>();
44
+ /**
45
+ * One server per project root — a second `Open in Browser` reuses the first one's port. Held as the
46
+ * start's promise, so two calls racing the bind share one server rather than starting two.
47
+ */
48
+ const running = new Map<string, Promise<Running>>();
45
49
 
46
50
  /**
47
51
  * Start (or reuse) the preview server for `projectRoot`, or `null` when there is nothing to serve:
48
52
  * not a site project, or a site nobody has built yet. A caller that has just built gets a server;
49
53
  * one that has not gets the honest answer rather than a port that 404s everything.
50
54
  */
51
- export function startSitePreview(projectRoot: string): SitePreview | null {
55
+ export async function startSitePreview(projectRoot: string): Promise<SitePreview | null> {
52
56
  const outDir = siteOutDir(projectRoot);
53
57
  if (!outDir) {
54
58
  return null;
55
59
  }
56
- const existing = running.get(projectRoot);
57
- if (existing) {
58
- return { origin: existing.origin, port: existing.port };
60
+ let starting = running.get(projectRoot);
61
+ if (!starting) {
62
+ starting = listen(projectRoot);
63
+ running.set(projectRoot, starting);
64
+ starting.catch(() => running.delete(projectRoot));
59
65
  }
60
- const server = Bun.serve({
66
+ const { origin, port } = await starting;
67
+ return { origin, port };
68
+ }
69
+
70
+ /** Bind the preview server for `projectRoot`. */
71
+ async function listen(projectRoot: string): Promise<Running> {
72
+ const server = await serve({
61
73
  fetch: async (req: Request) => {
62
74
  const url = new URL(req.url);
63
75
  let decoded: string;
@@ -83,24 +95,27 @@ export function startSitePreview(projectRoot: string): SitePreview | null {
83
95
  });
84
96
  // `server.url` rather than `server.port`: the origin is the thing being handed to a browser, and
85
97
  // Reading it from the server that is actually listening cannot disagree with it.
86
- const entry: Running = {
87
- origin: server.url.origin,
88
- port: Number(server.url.port),
89
- server,
90
- };
91
- running.set(projectRoot, entry);
92
- return { origin: entry.origin, port: entry.port };
98
+ return { origin: server.url.origin, port: Number(server.url.port), server };
93
99
  }
94
100
 
95
101
  /** The origin already serving `projectRoot`, without starting one. */
96
- export function sitePreviewOrigin(projectRoot: string): string | null {
97
- return running.get(projectRoot)?.origin ?? null;
102
+ export async function sitePreviewOrigin(projectRoot: string): Promise<string | null> {
103
+ const starting = running.get(projectRoot);
104
+ if (!starting) {
105
+ return null;
106
+ }
107
+ const entry = await starting;
108
+ return entry.origin;
98
109
  }
99
110
 
100
111
  /** Stop every preview server (process teardown, and the hook tests close their ports with). */
101
- export function stopSitePreviews(): void {
102
- for (const entry of running.values()) {
103
- entry.server.stop(true);
104
- }
112
+ export async function stopSitePreviews(): Promise<void> {
113
+ const entries = [...running.values()];
105
114
  running.clear();
115
+ await Promise.all(
116
+ entries.map(async (starting) => {
117
+ const entry = await starting.catch(() => null);
118
+ await entry?.server.stop(true);
119
+ }),
120
+ );
106
121
  }
package/src/studio-api.ts CHANGED
@@ -14,8 +14,10 @@ import { mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from
14
14
  import { existsSync, readFileSync, statSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
16
  import {
17
+ anyNowInstalled,
17
18
  buildExtensionsPayload,
18
- buildProjectExtensionRegistry,
19
+ buildInstalledExtensionRegistry,
20
+ missingExtensionsNotice,
19
21
  } from "@jxsuite/compiler/format-host";
20
22
  import { readBundledProjectSchemas } from "@jxsuite/compiler/schema-command";
21
23
  import { handleDataApi } from "./data-api.ts";
@@ -30,10 +32,11 @@ import {
30
32
  import { applyRename } from "./refactor/apply.ts";
31
33
  import { findReferences } from "./refactor/find-refs.ts";
32
34
  import {
33
- bunExecutable,
35
+ addPackage,
34
36
  dependenciesNeedInstall,
35
37
  installDependencies,
36
38
  packageVersions,
39
+ removePackage,
37
40
  setPackageVersions,
38
41
  } from "./packages.ts";
39
42
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
@@ -43,6 +46,7 @@ import type { DesignOptions, ProjectOptions } from "@jxsuite/create/generate";
43
46
  import type { JsonObject, JsonPrimitive, JsonValue, ProjectConfig } from "@jxsuite/schema/types";
44
47
  import { isFunction, isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
45
48
  import { problem } from "./problem.ts";
49
+ import { scanFiles } from "./fs-glob.ts";
46
50
 
47
51
  /** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
48
52
  const fwd = (p: string) => p.replaceAll("\\", "/");
@@ -93,12 +97,19 @@ interface Cem {
93
97
 
94
98
  // ─── Extension registry (per project root, invalidated on project.json change) ──
95
99
 
96
- const extensionRegistryCache = new Map<string, { mtime: number; registry: ExtensionRegistry }>();
100
+ const extensionRegistryCache = new Map<
101
+ string,
102
+ { mtime: number; missing: string[]; registry: ExtensionRegistry }
103
+ >();
97
104
 
98
105
  /**
99
106
  * Build (or reuse) a project's extension registry (built from its project.json `extensions` array).
100
107
  * An empty registry is returned when there is no project.json — only .json files are handled then.
101
108
  *
109
+ * The editor's registry: an enabled package the project has not installed is skipped and said once,
110
+ * not thrown per request (specs/extensions.md §3). A registry that skipped one is rebuilt as soon
111
+ * as it resolves, because an install does not touch `project.json` and so moves no mtime.
112
+ *
102
113
  * @param {string} projectRoot
103
114
  * @returns {Promise<ExtensionRegistry>}
104
115
  */
@@ -115,11 +126,14 @@ async function getExtensionRegistry(projectRoot: string): Promise<ExtensionRegis
115
126
  projectConfig = undefined;
116
127
  }
117
128
  const cached = extensionRegistryCache.get(projectRoot);
118
- if (cached && cached.mtime === mtime) {
129
+ if (cached && cached.mtime === mtime && !anyNowInstalled(projectRoot, cached.missing)) {
119
130
  return cached.registry;
120
131
  }
121
- const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
122
- extensionRegistryCache.set(projectRoot, { mtime, registry });
132
+ const { missing, registry } = await buildInstalledExtensionRegistry(projectRoot, projectConfig);
133
+ if (missing.length > 0 && missing.join("\n") !== cached?.missing.join("\n")) {
134
+ console.warn(`[studio] ${missingExtensionsNotice(missing)}`);
135
+ }
136
+ extensionRegistryCache.set(projectRoot, { missing, mtime, registry });
123
137
  return registry;
124
138
  }
125
139
 
@@ -570,9 +584,8 @@ export async function handleStudioApi(
570
584
  // Discover site projects — find all project.json files under root
571
585
  if (path === "/__studio/sites" && req.method === "GET") {
572
586
  try {
573
- const glob = new Bun.Glob("**/project.json");
574
587
  const sites = [];
575
- for await (const match of glob.scan({ cwd: root, dot: false })) {
588
+ for await (const match of scanFiles("**/project.json", root)) {
576
589
  if (
577
590
  match.includes("node_modules") ||
578
591
  fwd(match).includes("dist/") ||
@@ -726,9 +739,8 @@ export async function handleStudioApi(
726
739
 
727
740
  try {
728
741
  if (pattern) {
729
- const glob = new Bun.Glob(pattern);
730
742
  const files = [];
731
- for await (const match of glob.scan({ cwd: absDir, dot: false })) {
743
+ for await (const match of scanFiles(pattern, absDir)) {
732
744
  const fp = resolve(absDir, match);
733
745
  try {
734
746
  const s = await stat(fp);
@@ -783,9 +795,9 @@ export async function handleStudioApi(
783
795
  try {
784
796
  const registry = await getFormatRegistry(scanRoot);
785
797
  const componentExts = registry.documentExtensions("component").map((e: string) => e.slice(1));
786
- const glob = new Bun.Glob(`**/*.{${["json", ...componentExts].join(",")}}`);
787
798
  const components = [];
788
- for await (const match of glob.scan({ cwd: scanRoot, dot: false })) {
799
+ const pattern = `**/*.{${["json", ...componentExts].join(",")}}`;
800
+ for await (const match of scanFiles(pattern, scanRoot)) {
789
801
  if (
790
802
  match.includes("node_modules") ||
791
803
  fwd(match).includes("dist/") ||
@@ -934,7 +946,7 @@ export async function handleStudioApi(
934
946
  // `clean: false` — this runs on the way to opening a page, and wiping the output directory
935
947
  // First would mean every asset 404s for as long as the build takes.
936
948
  const result = await buildSite(dir, { clean: false, verbose: false });
937
- const preview = startSitePreview(dir);
949
+ const preview = await startSitePreview(dir);
938
950
  const built = { errors: result.errors, files: result.files, routes: result.routes };
939
951
  // `url` follows the build fields, and only when a preview origin exists.
940
952
  return Response.json(preview ? { ...built, url: preview.origin } : built);
@@ -1110,9 +1122,9 @@ export async function handleStudioApi(
1110
1122
  // Add an npm package
1111
1123
  if (path === "/__studio/packages/add" && req.method === "POST") {
1112
1124
  try {
1113
- /* SAFETY: `name` reaches argv, so it is re-checked with isString below; `dir` is only
1114
- path-resolved and `dev` only tested for truthiness, and a wrongly typed `dir` throws in
1115
- isAbsolute inside this try and answers 500. */
1125
+ /* SAFETY: `name` is re-checked with isString below; `dir` is only path-resolved and `dev`
1126
+ only tested for truthiness, and a wrongly typed `dir` throws in isAbsolute inside this try
1127
+ and answers 500. */
1116
1128
  const body = (await req.json()) as { name?: string; dir?: string; dev?: boolean };
1117
1129
  const { name } = body;
1118
1130
  if (!name || !isString(name)) {
@@ -1120,19 +1132,9 @@ export async function handleStudioApi(
1120
1132
  }
1121
1133
  const dir = body.dir || activeProjectRoot;
1122
1134
  const cwd = dir ? (isAbsolute(dir) ? dir : resolve(root, dir)) : root;
1123
- const args = ["add", name];
1124
- if (body.dev) {
1125
- args.splice(1, 0, "-d");
1126
- }
1127
- const proc = Bun.spawn([bunExecutable(), ...args], {
1128
- cwd,
1129
- stderr: "pipe",
1130
- stdout: "pipe",
1131
- });
1132
- const exitCode = await proc.exited;
1133
- if (exitCode !== 0) {
1134
- const stderr = await new Response(proc.stderr).text();
1135
- return problem("internalError", stderr || `bun add exited with ${exitCode}`);
1135
+ const result = await addPackage(cwd, name, body.dev === true);
1136
+ if (!result.ok) {
1137
+ return problem("internalError", result.log ?? "Adding the package failed");
1136
1138
  }
1137
1139
  return Response.json({ ok: true });
1138
1140
  } catch (error) {
@@ -1143,8 +1145,8 @@ export async function handleStudioApi(
1143
1145
  // Remove an npm package
1144
1146
  if (path === "/__studio/packages/remove" && req.method === "POST") {
1145
1147
  try {
1146
- /* SAFETY: `name` reaches argv, so it is re-checked with isString below; a wrongly typed
1147
- `dir` throws in isAbsolute inside this try and answers 500. */
1148
+ /* SAFETY: `name` is re-checked with isString below; a wrongly typed `dir` throws in
1149
+ isAbsolute inside this try and answers 500. */
1148
1150
  const body = (await req.json()) as { name?: string; dir?: string };
1149
1151
  const { name } = body;
1150
1152
  if (!name || !isString(name)) {
@@ -1152,15 +1154,9 @@ export async function handleStudioApi(
1152
1154
  }
1153
1155
  const dir = body.dir || activeProjectRoot;
1154
1156
  const cwd = dir ? (isAbsolute(dir) ? dir : resolve(root, dir)) : root;
1155
- const proc = Bun.spawn([bunExecutable(), "remove", name], {
1156
- cwd,
1157
- stderr: "pipe",
1158
- stdout: "pipe",
1159
- });
1160
- const exitCode = await proc.exited;
1161
- if (exitCode !== 0) {
1162
- const stderr = await new Response(proc.stderr).text();
1163
- return problem("internalError", stderr || `bun remove exited with ${exitCode}`);
1157
+ const result = await removePackage(cwd, name);
1158
+ if (!result.ok) {
1159
+ return problem("internalError", result.log ?? "Removing the package failed");
1164
1160
  }
1165
1161
  return Response.json({ ok: true });
1166
1162
  } catch (error) {
@@ -1168,7 +1164,7 @@ export async function handleStudioApi(
1168
1164
  }
1169
1165
  }
1170
1166
 
1171
- // Install all dependencies (bun install)
1167
+ // Install all dependencies
1172
1168
  if (path === "/__studio/packages/install" && req.method === "POST") {
1173
1169
  try {
1174
1170
  /* SAFETY: `dir` is the only member read and is only path-resolved; a wrongly typed `dir`
@@ -1457,9 +1453,8 @@ export async function handleStudioApi(
1457
1453
  }
1458
1454
 
1459
1455
  try {
1460
- const glob = new Bun.Glob(`**/${name}`);
1461
1456
  const matches = [];
1462
- for await (const match of glob.scan({ cwd: root, dot: false })) {
1457
+ for await (const match of scanFiles(`**/${name}`, root)) {
1463
1458
  // Skip node_modules / dist / hidden dirs
1464
1459
  if (match.includes("node_modules") || fwd(match).includes("dist/")) {
1465
1460
  continue;
@@ -1655,16 +1650,11 @@ export async function handleStudioApi(
1655
1650
  }
1656
1651
  }
1657
1652
  } else {
1658
- // Npm/bare specifier — use createRequire from project root, fall back to server package
1653
+ // Npm/bare specifier — the project's own install, never the server's
1654
+ // (specs/extensions.md §3): before the project's install this answers "not resolvable".
1659
1655
  const projectRoot = activeProjectRoot || root;
1660
1656
  const { createRequire } = await import("node:module");
1661
- const projRequire = createRequire(resolve(projectRoot, "package.json"));
1662
- try {
1663
- moduleAbsPath = projRequire.resolve(src);
1664
- } catch {
1665
- const serverRequire = createRequire(import.meta.url);
1666
- moduleAbsPath = serverRequire.resolve(src);
1667
- }
1657
+ moduleAbsPath = createRequire(resolve(projectRoot, "package.json")).resolve(src);
1668
1658
  }
1669
1659
  } catch (error) {
1670
1660
  return Response.json({
@@ -11,9 +11,9 @@
11
11
  * it did.
12
12
  *
13
13
  * Both were reached at once by a launcher that adopted `$HOME` as a project root. That defect is
14
- * fixed where it was made (`packages/desktop/src/chromium/index.ts`), but a watcher must survive
15
- * being pointed at an ordinary directory rather than depend on never being pointed at one — so the
16
- * rules live here, stated once, because `watch.ts` (dev server) and `refactor/watcher.ts` (desktop
14
+ * fixed where it was made (the since-removed desktop launcher), but a watcher must survive being
15
+ * pointed at an ordinary directory rather than depend on never being pointed at one — so the rules
16
+ * live here, stated once, because `watch.ts` (dev server) and `refactor/watcher.ts` (desktop
17
17
  * session) both need them and neither owns the other.
18
18
  *
19
19
  * Symlinks are contained rather than banned: a link resolving back inside the root is project