@jxsuite/server 0.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/package.json +19 -0
- package/src/build.js +67 -0
- package/src/code-api.js +155 -0
- package/src/resolve.js +206 -0
- package/src/server.js +253 -0
- package/src/studio-api.js +689 -0
- package/src/watch.js +134 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Studio-api.js — Studio filesystem integration
|
|
3
|
+
*
|
|
4
|
+
* REST endpoints under /__studio/* that provide server-backed file operations so the studio can
|
|
5
|
+
* work universally (not just Chrome with File System Access API).
|
|
6
|
+
*
|
|
7
|
+
* All paths are relative to the project root. Directory traversal above root is rejected.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { resolve, relative, basename, dirname } from "node:path";
|
|
11
|
+
import { readdir, stat, readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
|
|
12
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} filePath
|
|
16
|
+
* @param {string} root
|
|
17
|
+
*/
|
|
18
|
+
function assertUnderRoot(filePath, root) {
|
|
19
|
+
const rel = relative(root, filePath);
|
|
20
|
+
if (rel.startsWith("..") || rel.startsWith("/")) throw new Error("Path outside project root");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Handle /__studio/* requests.
|
|
25
|
+
*
|
|
26
|
+
* @param {Request} req
|
|
27
|
+
* @param {URL} url
|
|
28
|
+
* @param {string} root
|
|
29
|
+
*/
|
|
30
|
+
export async function handleStudioApi(req, url, root) {
|
|
31
|
+
const path = url.pathname;
|
|
32
|
+
|
|
33
|
+
// Project metadata
|
|
34
|
+
if (path === "/__studio/project" && req.method === "GET") {
|
|
35
|
+
try {
|
|
36
|
+
const pkg = JSON.parse(await readFile(resolve(root, "package.json"), "utf8"));
|
|
37
|
+
return Response.json({
|
|
38
|
+
root,
|
|
39
|
+
name: pkg.name ?? basename(root),
|
|
40
|
+
workspaces: pkg.workspaces ?? [],
|
|
41
|
+
});
|
|
42
|
+
} catch {
|
|
43
|
+
return Response.json({ root, name: basename(root), workspaces: [] });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Project info — probe a directory for site-project characteristics
|
|
48
|
+
if (path === "/__studio/project-info" && req.method === "GET") {
|
|
49
|
+
const dir = url.searchParams.get("dir") ?? ".";
|
|
50
|
+
const absDir = resolve(root, dir);
|
|
51
|
+
try {
|
|
52
|
+
assertUnderRoot(absDir, root);
|
|
53
|
+
} catch (/** @type {any} */ e) {
|
|
54
|
+
return Response.json({ error: e.message }, { status: 400 });
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const projectRoot = relative(root, absDir) || ".";
|
|
58
|
+
const conventionalDirs = [
|
|
59
|
+
"pages",
|
|
60
|
+
"layouts",
|
|
61
|
+
"components",
|
|
62
|
+
"content",
|
|
63
|
+
"data",
|
|
64
|
+
"public",
|
|
65
|
+
"styles",
|
|
66
|
+
];
|
|
67
|
+
const directories = [];
|
|
68
|
+
for (const d of conventionalDirs) {
|
|
69
|
+
try {
|
|
70
|
+
const s = await stat(resolve(absDir, d));
|
|
71
|
+
if (s.isDirectory()) directories.push(d);
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let isSiteProject = false;
|
|
76
|
+
let projectConfig = null;
|
|
77
|
+
try {
|
|
78
|
+
const raw = JSON.parse(await readFile(resolve(absDir, "project.json"), "utf8"));
|
|
79
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
80
|
+
isSiteProject = true;
|
|
81
|
+
projectConfig = raw;
|
|
82
|
+
}
|
|
83
|
+
} catch {}
|
|
84
|
+
|
|
85
|
+
return Response.json({ isSiteProject, projectConfig, directories, projectRoot });
|
|
86
|
+
} catch (/** @type {any} */ e) {
|
|
87
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Resolve nearest project.json ancestor for a given file path
|
|
92
|
+
if (path === "/__studio/resolve-site" && req.method === "GET") {
|
|
93
|
+
const filePath = url.searchParams.get("path");
|
|
94
|
+
if (!filePath) return Response.json({ error: "Missing path param" }, { status: 400 });
|
|
95
|
+
try {
|
|
96
|
+
// Walk up from file's directory looking for project.json
|
|
97
|
+
let dir = dirname(
|
|
98
|
+
filePath.startsWith("~") ? filePath.replace("~", process.env.HOME || "") : filePath,
|
|
99
|
+
);
|
|
100
|
+
const stopAt = "/";
|
|
101
|
+
while (dir && dir !== stopAt) {
|
|
102
|
+
const candidate = resolve(dir, "project.json");
|
|
103
|
+
if (existsSync(candidate)) {
|
|
104
|
+
const config = JSON.parse(readFileSync(candidate, "utf8"));
|
|
105
|
+
const relPath = relative(root, dir);
|
|
106
|
+
const absFile = filePath.startsWith("~")
|
|
107
|
+
? filePath.replace("~", process.env.HOME || "")
|
|
108
|
+
: filePath;
|
|
109
|
+
const fileRelPath = relative(dir, absFile);
|
|
110
|
+
return Response.json({
|
|
111
|
+
sitePath: dir,
|
|
112
|
+
relPath: relPath || ".",
|
|
113
|
+
fileRelPath,
|
|
114
|
+
projectConfig: config,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const parent = dirname(dir);
|
|
118
|
+
if (parent === dir) break;
|
|
119
|
+
dir = parent;
|
|
120
|
+
}
|
|
121
|
+
return Response.json({ sitePath: null });
|
|
122
|
+
} catch (/** @type {any} */ e) {
|
|
123
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Discover site projects — find all project.json files under root
|
|
128
|
+
if (path === "/__studio/sites" && req.method === "GET") {
|
|
129
|
+
try {
|
|
130
|
+
const glob = new Bun.Glob("**/project.json");
|
|
131
|
+
const sites = [];
|
|
132
|
+
for await (const match of glob.scan({ cwd: root, dot: false })) {
|
|
133
|
+
if (match.includes("node_modules") || match.includes("dist/") || match.includes(".claude/"))
|
|
134
|
+
continue;
|
|
135
|
+
const fp = resolve(root, match);
|
|
136
|
+
try {
|
|
137
|
+
const raw = JSON.parse(await readFile(fp, "utf8"));
|
|
138
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
139
|
+
const projectDir = dirname(match) === "." ? "." : dirname(match).replaceAll("\\", "/");
|
|
140
|
+
sites.push({ path: projectDir, config: raw });
|
|
141
|
+
}
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
return Response.json(sites);
|
|
145
|
+
} catch (/** @type {any} */ e) {
|
|
146
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// List files
|
|
151
|
+
if (path === "/__studio/files" && req.method === "GET") {
|
|
152
|
+
const dir = url.searchParams.get("dir") ?? ".";
|
|
153
|
+
const pattern = url.searchParams.get("glob");
|
|
154
|
+
const absDir = resolve(root, dir);
|
|
155
|
+
try {
|
|
156
|
+
assertUnderRoot(absDir, root);
|
|
157
|
+
} catch (/** @type {any} */ e) {
|
|
158
|
+
return Response.json({ error: e.message }, { status: 400 });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
if (pattern) {
|
|
163
|
+
const glob = new Bun.Glob(pattern);
|
|
164
|
+
const files = [];
|
|
165
|
+
for await (const match of glob.scan({ cwd: absDir, dot: false })) {
|
|
166
|
+
const fp = resolve(absDir, match);
|
|
167
|
+
try {
|
|
168
|
+
const s = await stat(fp);
|
|
169
|
+
if (!s.isDirectory()) {
|
|
170
|
+
files.push({
|
|
171
|
+
name: basename(match),
|
|
172
|
+
path: relative(root, fp),
|
|
173
|
+
size: s.size,
|
|
174
|
+
modified: s.mtime.toISOString(),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
return Response.json(files);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const entries = await readdir(absDir, { withFileTypes: true });
|
|
183
|
+
const files = [];
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
if (entry.name.startsWith(".")) continue;
|
|
186
|
+
const fp = resolve(absDir, entry.name);
|
|
187
|
+
const s = await stat(fp);
|
|
188
|
+
files.push({
|
|
189
|
+
name: entry.name,
|
|
190
|
+
path: relative(root, fp),
|
|
191
|
+
type: entry.isDirectory() ? "directory" : "file",
|
|
192
|
+
size: s.size,
|
|
193
|
+
modified: s.mtime.toISOString(),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return Response.json(files);
|
|
197
|
+
} catch (/** @type {any} */ e) {
|
|
198
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Component discovery — scan project for custom element definitions
|
|
203
|
+
if (path === "/__studio/components" && req.method === "GET") {
|
|
204
|
+
const dir = url.searchParams.get("dir");
|
|
205
|
+
const scanRoot = dir ? resolve(root, dir) : root;
|
|
206
|
+
if (dir) {
|
|
207
|
+
try {
|
|
208
|
+
assertUnderRoot(scanRoot, root);
|
|
209
|
+
} catch (/** @type {any} */ e) {
|
|
210
|
+
return Response.json({ error: e.message }, { status: 400 });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const glob = new Bun.Glob("**/*.json");
|
|
215
|
+
const components = [];
|
|
216
|
+
for await (const match of glob.scan({ cwd: scanRoot, dot: false })) {
|
|
217
|
+
if (match.includes("node_modules") || match.includes("dist/") || match.includes(".claude/"))
|
|
218
|
+
continue;
|
|
219
|
+
const fp = resolve(scanRoot, match);
|
|
220
|
+
try {
|
|
221
|
+
const content = JSON.parse(await readFile(fp, "utf8"));
|
|
222
|
+
if (content.tagName && content.tagName.includes("-")) {
|
|
223
|
+
components.push({
|
|
224
|
+
tagName: content.tagName,
|
|
225
|
+
$id: content.$id || null,
|
|
226
|
+
path: match,
|
|
227
|
+
source: "jx",
|
|
228
|
+
props: Object.entries(content.state || {})
|
|
229
|
+
.filter(
|
|
230
|
+
([, d]) =>
|
|
231
|
+
d && typeof d === "object" && !d.$prototype && !d.$handler && !d.$compute,
|
|
232
|
+
)
|
|
233
|
+
.map(([name, d]) => ({ name, type: d.type, default: d.default })),
|
|
234
|
+
hasElements: Array.isArray(content.$elements) && content.$elements.length > 0,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
} catch {} // skip non-JSON or parse errors
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Discover CEM-bearing npm packages
|
|
241
|
+
try {
|
|
242
|
+
const projectPkgPath = resolve(scanRoot, "package.json");
|
|
243
|
+
if (existsSync(projectPkgPath)) {
|
|
244
|
+
const pkg = JSON.parse(await readFile(projectPkgPath, "utf8"));
|
|
245
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
246
|
+
for (const name of Object.keys(deps)) {
|
|
247
|
+
try {
|
|
248
|
+
const depPkgPath = resolve(
|
|
249
|
+
scanRoot,
|
|
250
|
+
"node_modules",
|
|
251
|
+
...name.split("/"),
|
|
252
|
+
"package.json",
|
|
253
|
+
);
|
|
254
|
+
// Fall back to root node_modules for hoisted packages
|
|
255
|
+
const fallbackPath = resolve(
|
|
256
|
+
root,
|
|
257
|
+
"node_modules",
|
|
258
|
+
...name.split("/"),
|
|
259
|
+
"package.json",
|
|
260
|
+
);
|
|
261
|
+
const actualPath = existsSync(depPkgPath)
|
|
262
|
+
? depPkgPath
|
|
263
|
+
: existsSync(fallbackPath)
|
|
264
|
+
? fallbackPath
|
|
265
|
+
: null;
|
|
266
|
+
if (!actualPath) continue;
|
|
267
|
+
const depPkg = JSON.parse(await readFile(actualPath, "utf8"));
|
|
268
|
+
if (!depPkg.customElements) continue;
|
|
269
|
+
const cemPath = resolve(dirname(actualPath), depPkg.customElements);
|
|
270
|
+
if (!existsSync(cemPath)) continue;
|
|
271
|
+
const cem = JSON.parse(await readFile(cemPath, "utf8"));
|
|
272
|
+
for (const mod of cem.modules || []) {
|
|
273
|
+
for (const decl of mod.declarations || []) {
|
|
274
|
+
if (decl.customElement && decl.tagName) {
|
|
275
|
+
components.push({
|
|
276
|
+
tagName: decl.tagName,
|
|
277
|
+
$id: null,
|
|
278
|
+
path: null,
|
|
279
|
+
modulePath: mod.path,
|
|
280
|
+
source: "npm",
|
|
281
|
+
package: name,
|
|
282
|
+
description: decl.description || null,
|
|
283
|
+
props: (decl.attributes || []).map((/** @type {any} */ a) => ({
|
|
284
|
+
name: a.name,
|
|
285
|
+
type: a.type?.text,
|
|
286
|
+
default: a.default,
|
|
287
|
+
description: a.description || null,
|
|
288
|
+
})),
|
|
289
|
+
members: (decl.members || []).filter(
|
|
290
|
+
(/** @type {any} */ m) => m.kind === "field" && m.privacy !== "private",
|
|
291
|
+
),
|
|
292
|
+
slots: decl.slots || [],
|
|
293
|
+
events: decl.events || [],
|
|
294
|
+
cssProperties: decl.cssProperties || [],
|
|
295
|
+
hasElements: false,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} catch {} // skip packages without valid CEM
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} catch {} // skip if no project package.json
|
|
304
|
+
|
|
305
|
+
return Response.json(components);
|
|
306
|
+
} catch (/** @type {any} */ e) {
|
|
307
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ─── Package management ──────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
// List CEM-bearing npm packages
|
|
314
|
+
if (path === "/__studio/packages" && req.method === "GET") {
|
|
315
|
+
const dir = url.searchParams.get("dir");
|
|
316
|
+
const scanRoot = dir ? resolve(root, dir) : root;
|
|
317
|
+
try {
|
|
318
|
+
const pkgPath = resolve(scanRoot, "package.json");
|
|
319
|
+
if (!existsSync(pkgPath)) return Response.json([]);
|
|
320
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
321
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
322
|
+
/** @type {any[]} */
|
|
323
|
+
const packages = [];
|
|
324
|
+
for (const [name, version] of Object.entries(deps)) {
|
|
325
|
+
const depPkgPath = resolve(scanRoot, "node_modules", ...name.split("/"), "package.json");
|
|
326
|
+
const fallbackPath = resolve(root, "node_modules", ...name.split("/"), "package.json");
|
|
327
|
+
const actualPath = existsSync(depPkgPath)
|
|
328
|
+
? depPkgPath
|
|
329
|
+
: existsSync(fallbackPath)
|
|
330
|
+
? fallbackPath
|
|
331
|
+
: null;
|
|
332
|
+
if (!actualPath) continue;
|
|
333
|
+
try {
|
|
334
|
+
const depPkg = JSON.parse(await readFile(actualPath, "utf8"));
|
|
335
|
+
packages.push({
|
|
336
|
+
name,
|
|
337
|
+
version: /** @type {string} */ (version),
|
|
338
|
+
hasCem: !!depPkg.customElements,
|
|
339
|
+
customElementsPath: depPkg.customElements || null,
|
|
340
|
+
});
|
|
341
|
+
} catch {}
|
|
342
|
+
}
|
|
343
|
+
return Response.json(packages);
|
|
344
|
+
} catch (/** @type {any} */ e) {
|
|
345
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Read CEM from a specific package
|
|
350
|
+
if (path === "/__studio/cem" && req.method === "GET") {
|
|
351
|
+
const pkg = url.searchParams.get("pkg");
|
|
352
|
+
if (!pkg) return new Response("Missing pkg", { status: 400 });
|
|
353
|
+
const dir = url.searchParams.get("dir");
|
|
354
|
+
const scanRoot = dir ? resolve(root, dir) : root;
|
|
355
|
+
try {
|
|
356
|
+
const depPkgPath = resolve(scanRoot, "node_modules", ...pkg.split("/"), "package.json");
|
|
357
|
+
const fallbackPath = resolve(root, "node_modules", ...pkg.split("/"), "package.json");
|
|
358
|
+
const actualPath = existsSync(depPkgPath)
|
|
359
|
+
? depPkgPath
|
|
360
|
+
: existsSync(fallbackPath)
|
|
361
|
+
? fallbackPath
|
|
362
|
+
: null;
|
|
363
|
+
if (!actualPath) return Response.json({ cem: null });
|
|
364
|
+
const depPkg = JSON.parse(await readFile(actualPath, "utf8"));
|
|
365
|
+
if (!depPkg.customElements) return Response.json({ cem: null });
|
|
366
|
+
const cemPath = resolve(dirname(actualPath), depPkg.customElements);
|
|
367
|
+
if (!existsSync(cemPath)) return Response.json({ cem: null });
|
|
368
|
+
const cem = JSON.parse(await readFile(cemPath, "utf8"));
|
|
369
|
+
return Response.json({ cem });
|
|
370
|
+
} catch (/** @type {any} */ e) {
|
|
371
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Add an npm package
|
|
376
|
+
if (path === "/__studio/packages/add" && req.method === "POST") {
|
|
377
|
+
try {
|
|
378
|
+
const body = await req.json();
|
|
379
|
+
const name = body.name;
|
|
380
|
+
if (!name || typeof name !== "string")
|
|
381
|
+
return Response.json({ error: "Missing name" }, { status: 400 });
|
|
382
|
+
const dir = body.dir;
|
|
383
|
+
const cwd = dir ? resolve(root, dir) : root;
|
|
384
|
+
const args = ["add", name];
|
|
385
|
+
if (body.dev) args.splice(1, 0, "-d");
|
|
386
|
+
const proc = Bun.spawn(["bun", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
387
|
+
const exitCode = await proc.exited;
|
|
388
|
+
if (exitCode !== 0) {
|
|
389
|
+
const stderr = await new Response(proc.stderr).text();
|
|
390
|
+
return Response.json(
|
|
391
|
+
{ error: stderr || `bun add exited with ${exitCode}` },
|
|
392
|
+
{ status: 500 },
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return Response.json({ ok: true });
|
|
396
|
+
} catch (/** @type {any} */ e) {
|
|
397
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Remove an npm package
|
|
402
|
+
if (path === "/__studio/packages/remove" && req.method === "POST") {
|
|
403
|
+
try {
|
|
404
|
+
const body = await req.json();
|
|
405
|
+
const name = body.name;
|
|
406
|
+
if (!name || typeof name !== "string")
|
|
407
|
+
return Response.json({ error: "Missing name" }, { status: 400 });
|
|
408
|
+
const dir = body.dir;
|
|
409
|
+
const cwd = dir ? resolve(root, dir) : root;
|
|
410
|
+
const proc = Bun.spawn(["bun", "remove", name], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
411
|
+
const exitCode = await proc.exited;
|
|
412
|
+
if (exitCode !== 0) {
|
|
413
|
+
const stderr = await new Response(proc.stderr).text();
|
|
414
|
+
return Response.json(
|
|
415
|
+
{ error: stderr || `bun remove exited with ${exitCode}` },
|
|
416
|
+
{ status: 500 },
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
return Response.json({ ok: true });
|
|
420
|
+
} catch (/** @type {any} */ e) {
|
|
421
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Read file (supports absolute system paths for ?open= workflow)
|
|
426
|
+
if (path === "/__studio/file" && req.method === "GET") {
|
|
427
|
+
const fp = url.searchParams.get("path");
|
|
428
|
+
if (!fp) return new Response("Missing path", { status: 400 });
|
|
429
|
+
const isAbsolute = fp.startsWith("/") || fp.startsWith("~");
|
|
430
|
+
const abs = isAbsolute
|
|
431
|
+
? fp.startsWith("~")
|
|
432
|
+
? fp.replace("~", process.env.HOME || "")
|
|
433
|
+
: fp
|
|
434
|
+
: resolve(root, fp);
|
|
435
|
+
if (!isAbsolute) {
|
|
436
|
+
try {
|
|
437
|
+
assertUnderRoot(abs, root);
|
|
438
|
+
} catch (/** @type {any} */ e) {
|
|
439
|
+
return new Response(e.message, { status: 400 });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
return Response.json({
|
|
444
|
+
content: await readFile(abs, "utf8"),
|
|
445
|
+
path: isAbsolute ? fp : relative(root, abs),
|
|
446
|
+
});
|
|
447
|
+
} catch (/** @type {any} */ e) {
|
|
448
|
+
return e.code === "ENOENT"
|
|
449
|
+
? new Response("Not found", { status: 404 })
|
|
450
|
+
: Response.json({ error: e.message }, { status: 500 });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Write file
|
|
455
|
+
if (path === "/__studio/file" && req.method === "PUT") {
|
|
456
|
+
const fp = url.searchParams.get("path");
|
|
457
|
+
if (!fp) return new Response("Missing path", { status: 400 });
|
|
458
|
+
const abs = resolve(root, fp);
|
|
459
|
+
try {
|
|
460
|
+
assertUnderRoot(abs, root);
|
|
461
|
+
} catch (/** @type {any} */ e) {
|
|
462
|
+
return new Response(e.message, { status: 400 });
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
466
|
+
await writeFile(abs, await req.text(), "utf8");
|
|
467
|
+
return Response.json({ ok: true, path: relative(root, abs) });
|
|
468
|
+
} catch (/** @type {any} */ e) {
|
|
469
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Delete file
|
|
474
|
+
if (path === "/__studio/file" && req.method === "DELETE") {
|
|
475
|
+
const fp = url.searchParams.get("path");
|
|
476
|
+
if (!fp) return new Response("Missing path", { status: 400 });
|
|
477
|
+
const abs = resolve(root, fp);
|
|
478
|
+
try {
|
|
479
|
+
assertUnderRoot(abs, root);
|
|
480
|
+
} catch (/** @type {any} */ e) {
|
|
481
|
+
return new Response(e.message, { status: 400 });
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
await unlink(abs);
|
|
485
|
+
return Response.json({ ok: true, path: relative(root, abs) });
|
|
486
|
+
} catch (/** @type {any} */ e) {
|
|
487
|
+
return e.code === "ENOENT"
|
|
488
|
+
? new Response("Not found", { status: 404 })
|
|
489
|
+
: Response.json({ error: e.message }, { status: 500 });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Rename file
|
|
494
|
+
if (path === "/__studio/file/rename" && req.method === "POST") {
|
|
495
|
+
let body;
|
|
496
|
+
try {
|
|
497
|
+
body = await req.json();
|
|
498
|
+
} catch {
|
|
499
|
+
return new Response("Invalid JSON", { status: 400 });
|
|
500
|
+
}
|
|
501
|
+
const { from, to } = body;
|
|
502
|
+
if (!from || !to) return new Response("Missing from or to", { status: 400 });
|
|
503
|
+
const absFrom = resolve(root, from);
|
|
504
|
+
const absTo = resolve(root, to);
|
|
505
|
+
try {
|
|
506
|
+
assertUnderRoot(absFrom, root);
|
|
507
|
+
assertUnderRoot(absTo, root);
|
|
508
|
+
} catch (/** @type {any} */ e) {
|
|
509
|
+
return new Response(e.message, { status: 400 });
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
await mkdir(dirname(absTo), { recursive: true });
|
|
513
|
+
await rename(absFrom, absTo);
|
|
514
|
+
return Response.json({ ok: true, from: relative(root, absFrom), to: relative(root, absTo) });
|
|
515
|
+
} catch (/** @type {any} */ e) {
|
|
516
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Locate a file by name within the project root
|
|
521
|
+
if (path === "/__studio/locate" && req.method === "POST") {
|
|
522
|
+
let body;
|
|
523
|
+
try {
|
|
524
|
+
body = await req.json();
|
|
525
|
+
} catch {
|
|
526
|
+
return new Response("Invalid JSON", { status: 400 });
|
|
527
|
+
}
|
|
528
|
+
const { name } = body;
|
|
529
|
+
if (!name) return new Response("Missing name", { status: 400 });
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
const glob = new Bun.Glob(`**/${name}`);
|
|
533
|
+
const matches = [];
|
|
534
|
+
for await (const match of glob.scan({ cwd: root, dot: false })) {
|
|
535
|
+
// Skip node_modules / dist / hidden dirs
|
|
536
|
+
if (match.includes("node_modules") || match.includes("dist/")) continue;
|
|
537
|
+
matches.push(match.split("\\").join("/"));
|
|
538
|
+
}
|
|
539
|
+
if (matches.length === 0) return Response.json({ path: null });
|
|
540
|
+
return Response.json({
|
|
541
|
+
path: matches[0],
|
|
542
|
+
...(matches.length > 1 ? { alternatives: matches } : {}),
|
|
543
|
+
});
|
|
544
|
+
} catch (/** @type {any} */ e) {
|
|
545
|
+
return Response.json({ error: e.message }, { status: 500 });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Discover a plugin module's schema for studio form rendering
|
|
550
|
+
if (path === "/__studio/plugin-schema" && req.method === "GET") {
|
|
551
|
+
const src = url.searchParams.get("src");
|
|
552
|
+
const prototype = url.searchParams.get("prototype");
|
|
553
|
+
const base = url.searchParams.get("base");
|
|
554
|
+
if (!src) return new Response("Missing src param", { status: 400 });
|
|
555
|
+
|
|
556
|
+
let moduleAbsPath;
|
|
557
|
+
try {
|
|
558
|
+
if (base) {
|
|
559
|
+
const docUrlPath = new URL(base).pathname;
|
|
560
|
+
const docDir = docUrlPath.slice(0, docUrlPath.lastIndexOf("/") + 1);
|
|
561
|
+
moduleAbsPath = resolve(resolve(root, "." + docDir), src);
|
|
562
|
+
} else {
|
|
563
|
+
moduleAbsPath = resolve(root, src);
|
|
564
|
+
}
|
|
565
|
+
} catch (/** @type {any} */ e) {
|
|
566
|
+
return Response.json({ schema: null, error: e.message });
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// .class.json: read and extract schema directly
|
|
570
|
+
if (moduleAbsPath.endsWith(".class.json")) {
|
|
571
|
+
try {
|
|
572
|
+
const content = readFileSync(moduleAbsPath, "utf8");
|
|
573
|
+
const classDef = JSON.parse(content);
|
|
574
|
+
return Response.json({ schema: extractStudioSchema(classDef, moduleAbsPath) });
|
|
575
|
+
} catch (/** @type {any} */ e) {
|
|
576
|
+
return Response.json({ schema: null, error: e.message });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Sibling .class.json auto-discovery: check for <ClassName>.class.json next to the .js module
|
|
581
|
+
const exportName = prototype || src;
|
|
582
|
+
const classJsonPath = resolve(dirname(moduleAbsPath), `${exportName}.class.json`);
|
|
583
|
+
if (existsSync(classJsonPath)) {
|
|
584
|
+
try {
|
|
585
|
+
const content = readFileSync(classJsonPath, "utf8");
|
|
586
|
+
const classDef = JSON.parse(content);
|
|
587
|
+
return Response.json({ schema: extractStudioSchema(classDef, classJsonPath) });
|
|
588
|
+
} catch {
|
|
589
|
+
// Fall through to JS module import
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Fallback: import JS module (backwards compat for classes without .class.json)
|
|
594
|
+
try {
|
|
595
|
+
const mod = await import(moduleAbsPath);
|
|
596
|
+
const ExportedClass = mod[exportName] ?? mod.default?.[exportName];
|
|
597
|
+
if (typeof ExportedClass !== "function") {
|
|
598
|
+
return Response.json({ schema: null, error: `Export "${exportName}" not found` });
|
|
599
|
+
}
|
|
600
|
+
return Response.json({ schema: ExportedClass.schema ?? null });
|
|
601
|
+
} catch (/** @type {any} */ e) {
|
|
602
|
+
return Response.json({ schema: null, error: e.message });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Extract a studio-friendly schema from a .class.json definition. Transforms $defs.parameters and
|
|
611
|
+
* $defs.fields into the flat { description, properties, required } shape that renderSchemaFields()
|
|
612
|
+
* in the studio already consumes.
|
|
613
|
+
*
|
|
614
|
+
* @param {any} classDef
|
|
615
|
+
* @param {string} classJsonPath
|
|
616
|
+
* @returns {{ description: any; properties: Record<string, any>; required: string[] }}
|
|
617
|
+
*/
|
|
618
|
+
function extractStudioSchema(classDef, classJsonPath) {
|
|
619
|
+
// If extends.$ref points to a parent, recursively merge
|
|
620
|
+
let parentSchema = null;
|
|
621
|
+
if (classDef.extends && typeof classDef.extends === "object" && classDef.extends.$ref) {
|
|
622
|
+
try {
|
|
623
|
+
const parentPath = resolve(dirname(classJsonPath), classDef.extends.$ref);
|
|
624
|
+
const parentContent = readFileSync(parentPath, "utf8");
|
|
625
|
+
const parentDef = JSON.parse(parentContent);
|
|
626
|
+
parentSchema = extractStudioSchema(parentDef, parentPath);
|
|
627
|
+
} catch {
|
|
628
|
+
// Parent not found — proceed without inheritance
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const params = classDef.$defs?.parameters ?? {};
|
|
633
|
+
const fields = classDef.$defs?.fields ?? {};
|
|
634
|
+
/** @type {Record<string, any>} */
|
|
635
|
+
const properties = {};
|
|
636
|
+
/** @type {string[]} */
|
|
637
|
+
const required = [];
|
|
638
|
+
|
|
639
|
+
// Start with parent properties (child overrides)
|
|
640
|
+
if (parentSchema?.properties) {
|
|
641
|
+
Object.assign(properties, parentSchema.properties);
|
|
642
|
+
}
|
|
643
|
+
if (parentSchema?.required) {
|
|
644
|
+
required.push(...parentSchema.required);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Build properties from parameters (constructor config surface)
|
|
648
|
+
for (const [key, param] of Object.entries(params)) {
|
|
649
|
+
const id = param.identifier ?? key;
|
|
650
|
+
const prop = {};
|
|
651
|
+
if (param.type && typeof param.type === "object") Object.assign(prop, param.type);
|
|
652
|
+
if (param.description) prop.description = param.description;
|
|
653
|
+
if (param.examples) prop.examples = param.examples;
|
|
654
|
+
if (param.format) prop.format = param.format;
|
|
655
|
+
properties[id] = prop;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Build properties from fields (config-visible ones only)
|
|
659
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
660
|
+
if (field.role !== "field") continue;
|
|
661
|
+
if (field.access === "private") continue;
|
|
662
|
+
const id = field.identifier ?? key;
|
|
663
|
+
const prop = {};
|
|
664
|
+
if (field.type && typeof field.type === "object") Object.assign(prop, field.type);
|
|
665
|
+
if (field.description) prop.description = field.description;
|
|
666
|
+
if (field.default !== undefined) prop.default = field.default;
|
|
667
|
+
if (field.initializer !== undefined && prop.default === undefined)
|
|
668
|
+
prop.default = field.initializer;
|
|
669
|
+
if (field.examples) prop.examples = field.examples;
|
|
670
|
+
properties[id] = prop;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Determine required from constructor parameters that have no default
|
|
674
|
+
const ctorParams = classDef.$defs?.constructor?.parameters ?? [];
|
|
675
|
+
/** @type {Set<string>} */
|
|
676
|
+
const requiredSet = new Set(required);
|
|
677
|
+
for (const p of ctorParams) {
|
|
678
|
+
const name = p.$ref ? p.$ref.split("/").pop() : (p.identifier ?? p.name);
|
|
679
|
+
if (name && properties[name] && properties[name].default === undefined) {
|
|
680
|
+
requiredSet.add(name);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return {
|
|
685
|
+
description: classDef.description ?? classDef.title,
|
|
686
|
+
properties,
|
|
687
|
+
required: [...requiredSet],
|
|
688
|
+
};
|
|
689
|
+
}
|