@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/README.md +24 -2
- package/package.json +36 -16
- package/src/collab.ts +9 -14
- package/src/dev.ts +95 -1
- 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 +212 -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 +52 -22
- package/src/serve.bun.ts +50 -0
- package/src/serve.node.ts +311 -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 +43 -151
- 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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
|
123
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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`
|
|
1212
|
-
|
|
1213
|
-
|
|
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
|
|
1222
|
-
if (
|
|
1223
|
-
|
|
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`
|
|
1245
|
-
|
|
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
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
|
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
|
|
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 —
|
|
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
|
-
|
|
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({
|
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
|