@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/README.md +24 -2
- package/package.json +37 -17
- package/src/collab.ts +9 -14
- package/src/extension-catalog.ts +5 -12
- package/src/file-response.ts +140 -0
- package/src/fs-glob.ts +45 -0
- package/src/live-preview.ts +26 -12
- package/src/net-guard.ts +2 -5
- package/src/npm-path.ts +145 -0
- package/src/packages-service.ts +213 -0
- package/src/packages.ts +233 -35
- package/src/project-server.ts +17 -17
- package/src/refactor/apply.ts +2 -1
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/scan.ts +3 -3
- package/src/resolve.ts +3 -8
- package/src/serve.bun.ts +50 -0
- package/src/serve.node.ts +308 -0
- package/src/serve.ts +67 -0
- package/src/server.ts +10 -142
- package/src/site-preview.ts +36 -21
- package/src/studio-api.ts +41 -51
- package/src/watch-policy.ts +3 -3
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,
|
|
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
|
|
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,
|
|
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.
|
package/src/site-preview.ts
CHANGED
|
@@ -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
|
-
/**
|
|
44
|
-
|
|
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
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
|
122
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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`
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
|
1124
|
-
if (
|
|
1125
|
-
|
|
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`
|
|
1147
|
-
|
|
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
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
|
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
|
|
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 —
|
|
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
|
-
|
|
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({
|
package/src/watch-policy.ts
CHANGED
|
@@ -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 (
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|