@jxsuite/server 6.1.0 → 8.0.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/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
@@ -11,12 +11,13 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from
11
11
  import { componentMetaFrom } from "@jxsuite/schema/component-meta";
12
12
  import { errorMessage } from "@jxsuite/schema/parse";
13
13
  import { mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
14
- import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
14
+ import { existsSync, readFileSync, statSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
- import { LOCATION_ID_FILE } from "@jxsuite/protocol/routes";
17
16
  import {
17
+ anyNowInstalled,
18
18
  buildExtensionsPayload,
19
- buildProjectExtensionRegistry,
19
+ buildInstalledExtensionRegistry,
20
+ missingExtensionsNotice,
20
21
  } from "@jxsuite/compiler/format-host";
21
22
  import { readBundledProjectSchemas } from "@jxsuite/compiler/schema-command";
22
23
  import { handleDataApi } from "./data-api.ts";
@@ -31,10 +32,11 @@ import {
31
32
  import { applyRename } from "./refactor/apply.ts";
32
33
  import { findReferences } from "./refactor/find-refs.ts";
33
34
  import {
34
- bunExecutable,
35
+ addPackage,
35
36
  dependenciesNeedInstall,
36
37
  installDependencies,
37
38
  packageVersions,
39
+ removePackage,
38
40
  setPackageVersions,
39
41
  } from "./packages.ts";
40
42
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
@@ -44,6 +46,7 @@ import type { DesignOptions, ProjectOptions } from "@jxsuite/create/generate";
44
46
  import type { JsonObject, JsonPrimitive, JsonValue, ProjectConfig } from "@jxsuite/schema/types";
45
47
  import { isFunction, isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
46
48
  import { problem } from "./problem.ts";
49
+ import { scanFiles } from "./fs-glob.ts";
47
50
 
48
51
  /** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
49
52
  const fwd = (p: string) => p.replaceAll("\\", "/");
@@ -94,12 +97,19 @@ interface Cem {
94
97
 
95
98
  // ─── Extension registry (per project root, invalidated on project.json change) ──
96
99
 
97
- const extensionRegistryCache = new Map<string, { mtime: number; registry: ExtensionRegistry }>();
100
+ const extensionRegistryCache = new Map<
101
+ string,
102
+ { mtime: number; missing: string[]; registry: ExtensionRegistry }
103
+ >();
98
104
 
99
105
  /**
100
106
  * Build (or reuse) a project's extension registry (built from its project.json `extensions` array).
101
107
  * An empty registry is returned when there is no project.json — only .json files are handled then.
102
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
+ *
103
113
  * @param {string} projectRoot
104
114
  * @returns {Promise<ExtensionRegistry>}
105
115
  */
@@ -116,11 +126,14 @@ async function getExtensionRegistry(projectRoot: string): Promise<ExtensionRegis
116
126
  projectConfig = undefined;
117
127
  }
118
128
  const cached = extensionRegistryCache.get(projectRoot);
119
- if (cached && cached.mtime === mtime) {
129
+ if (cached && cached.mtime === mtime && !anyNowInstalled(projectRoot, cached.missing)) {
120
130
  return cached.registry;
121
131
  }
122
- const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
123
- 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 });
124
137
  return registry;
125
138
  }
126
139
 
@@ -568,108 +581,11 @@ export async function handleStudioApi(
568
581
  }
569
582
  }
570
583
 
571
- // Find a project directory by name — searches $HOME for the first matching directory with a
572
- // Project.json. Dev-mode workaround for when showDirectoryPicker() can't provide absolute paths.
573
- if (path === "/__studio/find-project" && req.method === "GET") {
574
- const name = url.searchParams.get("name");
575
- if (!name) {
576
- return problem("invalidRequest", "Missing name");
577
- }
578
- try {
579
- const home = process.env.HOME || process.env.USERPROFILE || "";
580
- if (!home) {
581
- return Response.json({ path: null });
582
- }
583
- const glob = new Bun.Glob(`**/${name}/project.json`);
584
- try {
585
- for await (const match of glob.scan({ cwd: home, dot: false })) {
586
- if (match.includes("node_modules") || match.includes(".Trash")) {
587
- continue;
588
- }
589
- const abs = resolve(home, dirname(match));
590
- return Response.json({ path: abs });
591
- }
592
- } catch {}
593
- return Response.json({ path: null });
594
- } catch (error) {
595
- return problem("internalError", errorMessage(error));
596
- }
597
- }
598
-
599
- /*
600
- * Recover the absolute path of a directory the browser picked with `showDirectoryPicker()`.
601
- *
602
- * A `FileSystemDirectoryHandle` exposes only `.name`, never a filesystem path (§8.2), and unlike
603
- * /__studio/find-project there is no `project.json` to search for — the whole point is that the
604
- * folder is empty. So the client tags the folder with a hidden LOCATION_ID_FILE holding a random
605
- * id and asks us which directory carries that id.
606
- *
607
- * Identity lives in the file's CONTENTS, not its name: matching the id is exact where matching a
608
- * path shape is only probable — two folders can share a basename, and a file left behind by a
609
- * crashed session can still be on disk. A candidate whose contents do not equal `id` is skipped,
610
- * so a stale tag can never redirect a create. The winning file is deleted here, the moment it has
611
- * served its purpose, rather than being left for the client to tidy up.
612
- */
613
- if (path === "/__studio/locate-directory" && req.method === "GET") {
614
- const name = url.searchParams.get("name");
615
- const id = url.searchParams.get("id");
616
- // `name` is interpolated into a glob; the id is only ever compared, never used as a pattern.
617
- if (!name || !id || !/^[a-f0-9]{32}$/.test(id) || !/^[^/\\]+$/.test(name)) {
618
- return problem("invalidRequest", "Missing or invalid name/id");
619
- }
620
- try {
621
- const home = process.env.HOME || process.env.USERPROFILE || "";
622
- if (!home) {
623
- return Response.json({ path: null });
624
- }
625
- /** The tagged directory when this candidate holds our id, else null. */
626
- const claim = (dir: string): string | null => {
627
- const file = resolve(dir, LOCATION_ID_FILE);
628
- try {
629
- if (readFileSync(file, "utf8").trim() !== id) {
630
- return null;
631
- }
632
- } catch {
633
- return null;
634
- }
635
- try {
636
- unlinkSync(file);
637
- } catch {
638
- // Read-only or already gone; the client's own cleanup is the backstop.
639
- }
640
- return dir;
641
- };
642
-
643
- // The home directory itself is a legitimate pick, and no `**/<name>/` glob can match it.
644
- const atHome = claim(home);
645
- if (atHome) {
646
- return Response.json({ path: atHome });
647
- }
648
- // `dot: true` — the tag is a dotfile, so a dot-blind scan would never see it.
649
- const glob = new Bun.Glob(`**/${name}/${LOCATION_ID_FILE}`);
650
- try {
651
- for await (const match of glob.scan({ cwd: home, dot: true })) {
652
- if (match.includes("node_modules") || match.includes(".Trash")) {
653
- continue;
654
- }
655
- const claimed = claim(resolve(home, dirname(match)));
656
- if (claimed) {
657
- return Response.json({ path: claimed });
658
- }
659
- }
660
- } catch {}
661
- return Response.json({ path: null });
662
- } catch (error) {
663
- return problem("internalError", errorMessage(error));
664
- }
665
- }
666
-
667
584
  // Discover site projects — find all project.json files under root
668
585
  if (path === "/__studio/sites" && req.method === "GET") {
669
586
  try {
670
- const glob = new Bun.Glob("**/project.json");
671
587
  const sites = [];
672
- for await (const match of glob.scan({ cwd: root, dot: false })) {
588
+ for await (const match of scanFiles("**/project.json", root)) {
673
589
  if (
674
590
  match.includes("node_modules") ||
675
591
  fwd(match).includes("dist/") ||
@@ -781,8 +697,7 @@ export async function handleStudioApi(
781
697
  await readFile(resolve(destPath, "project.json"), "utf8"),
782
698
  ) as ProjectConfig;
783
699
  // Root-relative while the project happens to sit under the server root (the historical
784
- // Shape every caller already handles); absolute otherwise, which is the same shape
785
- // /__studio/find-project returns for an external project.
700
+ // Shape every caller already handles); absolute otherwise.
786
701
  const inRoot = containedPath(destPath, root) !== null;
787
702
  opts.onProjectCreated?.(destPath);
788
703
  return Response.json({ config, root: inRoot ? fwd(relative(root, destPath)) : destPath });
@@ -824,9 +739,8 @@ export async function handleStudioApi(
824
739
 
825
740
  try {
826
741
  if (pattern) {
827
- const glob = new Bun.Glob(pattern);
828
742
  const files = [];
829
- for await (const match of glob.scan({ cwd: absDir, dot: false })) {
743
+ for await (const match of scanFiles(pattern, absDir)) {
830
744
  const fp = resolve(absDir, match);
831
745
  try {
832
746
  const s = await stat(fp);
@@ -881,9 +795,9 @@ export async function handleStudioApi(
881
795
  try {
882
796
  const registry = await getFormatRegistry(scanRoot);
883
797
  const componentExts = registry.documentExtensions("component").map((e: string) => e.slice(1));
884
- const glob = new Bun.Glob(`**/*.{${["json", ...componentExts].join(",")}}`);
885
798
  const components = [];
886
- 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)) {
887
801
  if (
888
802
  match.includes("node_modules") ||
889
803
  fwd(match).includes("dist/") ||
@@ -1032,7 +946,7 @@ export async function handleStudioApi(
1032
946
  // `clean: false` — this runs on the way to opening a page, and wiping the output directory
1033
947
  // First would mean every asset 404s for as long as the build takes.
1034
948
  const result = await buildSite(dir, { clean: false, verbose: false });
1035
- const preview = startSitePreview(dir);
949
+ const preview = await startSitePreview(dir);
1036
950
  const built = { errors: result.errors, files: result.files, routes: result.routes };
1037
951
  // `url` follows the build fields, and only when a preview origin exists.
1038
952
  return Response.json(preview ? { ...built, url: preview.origin } : built);
@@ -1208,9 +1122,9 @@ export async function handleStudioApi(
1208
1122
  // Add an npm package
1209
1123
  if (path === "/__studio/packages/add" && req.method === "POST") {
1210
1124
  try {
1211
- /* SAFETY: `name` reaches argv, so it is re-checked with isString below; `dir` is only
1212
- path-resolved and `dev` only tested for truthiness, and a wrongly typed `dir` throws in
1213
- 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. */
1214
1128
  const body = (await req.json()) as { name?: string; dir?: string; dev?: boolean };
1215
1129
  const { name } = body;
1216
1130
  if (!name || !isString(name)) {
@@ -1218,19 +1132,9 @@ export async function handleStudioApi(
1218
1132
  }
1219
1133
  const dir = body.dir || activeProjectRoot;
1220
1134
  const cwd = dir ? (isAbsolute(dir) ? dir : resolve(root, dir)) : root;
1221
- const args = ["add", name];
1222
- if (body.dev) {
1223
- args.splice(1, 0, "-d");
1224
- }
1225
- const proc = Bun.spawn([bunExecutable(), ...args], {
1226
- cwd,
1227
- stderr: "pipe",
1228
- stdout: "pipe",
1229
- });
1230
- const exitCode = await proc.exited;
1231
- if (exitCode !== 0) {
1232
- const stderr = await new Response(proc.stderr).text();
1233
- 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");
1234
1138
  }
1235
1139
  return Response.json({ ok: true });
1236
1140
  } catch (error) {
@@ -1241,8 +1145,8 @@ export async function handleStudioApi(
1241
1145
  // Remove an npm package
1242
1146
  if (path === "/__studio/packages/remove" && req.method === "POST") {
1243
1147
  try {
1244
- /* SAFETY: `name` reaches argv, so it is re-checked with isString below; a wrongly typed
1245
- `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. */
1246
1150
  const body = (await req.json()) as { name?: string; dir?: string };
1247
1151
  const { name } = body;
1248
1152
  if (!name || !isString(name)) {
@@ -1250,15 +1154,9 @@ export async function handleStudioApi(
1250
1154
  }
1251
1155
  const dir = body.dir || activeProjectRoot;
1252
1156
  const cwd = dir ? (isAbsolute(dir) ? dir : resolve(root, dir)) : root;
1253
- const proc = Bun.spawn([bunExecutable(), "remove", name], {
1254
- cwd,
1255
- stderr: "pipe",
1256
- stdout: "pipe",
1257
- });
1258
- const exitCode = await proc.exited;
1259
- if (exitCode !== 0) {
1260
- const stderr = await new Response(proc.stderr).text();
1261
- 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");
1262
1160
  }
1263
1161
  return Response.json({ ok: true });
1264
1162
  } catch (error) {
@@ -1266,7 +1164,7 @@ export async function handleStudioApi(
1266
1164
  }
1267
1165
  }
1268
1166
 
1269
- // Install all dependencies (bun install)
1167
+ // Install all dependencies
1270
1168
  if (path === "/__studio/packages/install" && req.method === "POST") {
1271
1169
  try {
1272
1170
  /* SAFETY: `dir` is the only member read and is only path-resolved; a wrongly typed `dir`
@@ -1555,9 +1453,8 @@ export async function handleStudioApi(
1555
1453
  }
1556
1454
 
1557
1455
  try {
1558
- const glob = new Bun.Glob(`**/${name}`);
1559
1456
  const matches = [];
1560
- for await (const match of glob.scan({ cwd: root, dot: false })) {
1457
+ for await (const match of scanFiles(`**/${name}`, root)) {
1561
1458
  // Skip node_modules / dist / hidden dirs
1562
1459
  if (match.includes("node_modules") || fwd(match).includes("dist/")) {
1563
1460
  continue;
@@ -1753,16 +1650,11 @@ export async function handleStudioApi(
1753
1650
  }
1754
1651
  }
1755
1652
  } else {
1756
- // 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".
1757
1655
  const projectRoot = activeProjectRoot || root;
1758
1656
  const { createRequire } = await import("node:module");
1759
- const projRequire = createRequire(resolve(projectRoot, "package.json"));
1760
- try {
1761
- moduleAbsPath = projRequire.resolve(src);
1762
- } catch {
1763
- const serverRequire = createRequire(import.meta.url);
1764
- moduleAbsPath = serverRequire.resolve(src);
1765
- }
1657
+ moduleAbsPath = createRequire(resolve(projectRoot, "package.json")).resolve(src);
1766
1658
  }
1767
1659
  } catch (error) {
1768
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