@jxsuite/server 4.5.0 → 4.5.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 +5 -5
- package/src/ai-api.ts +8 -4
- package/src/build.ts +6 -6
- package/src/code-api.ts +29 -16
- package/src/collab.ts +9 -5
- package/src/data-api.ts +190 -137
- package/src/dev-vars.ts +15 -5
- package/src/dev.ts +3 -0
- package/src/extension-catalog.ts +62 -32
- package/src/import-api.ts +95 -53
- package/src/jx-mounts.ts +27 -27
- package/src/live-preview.ts +16 -10
- package/src/oauth-loopback.ts +23 -18
- package/src/packages.ts +26 -36
- package/src/problem.ts +3 -6
- package/src/project-server.ts +34 -4
- package/src/refactor/apply.ts +3 -2
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/fs-events.ts +14 -15
- package/src/refactor/paths.ts +11 -3
- package/src/refactor/refs.ts +60 -63
- package/src/refactor/scan.ts +13 -19
- package/src/refactor/watcher.ts +5 -5
- package/src/resolve.ts +180 -68
- package/src/server.ts +47 -27
- package/src/site-output.ts +11 -5
- package/src/sse.ts +16 -2
- package/src/studio-api.ts +264 -115
- package/src/types.ts +17 -7
- package/src/watch.ts +8 -6
package/src/studio-api.ts
CHANGED
|
@@ -40,8 +40,9 @@ import {
|
|
|
40
40
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
41
41
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
42
42
|
import type { ClassJsonDef } from "./types.ts";
|
|
43
|
-
import type { DesignOptions } from "@jxsuite/create/generate";
|
|
44
|
-
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
43
|
+
import type { DesignOptions, ProjectOptions } from "@jxsuite/create/generate";
|
|
44
|
+
import type { JsonObject, JsonPrimitive, JsonValue, ProjectConfig } from "@jxsuite/schema/types";
|
|
45
|
+
import { isFunction, isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
45
46
|
import { problem } from "./problem.ts";
|
|
46
47
|
|
|
47
48
|
/** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
|
|
@@ -55,13 +56,13 @@ const fwd = (p: string) => p.replaceAll("\\", "/");
|
|
|
55
56
|
const expandTilde = (p: string) =>
|
|
56
57
|
p.startsWith("~") ? join(process.env.HOME || process.env.USERPROFILE || "", p.slice(1)) : p;
|
|
57
58
|
|
|
59
|
+
/** The `package.json` members these routes read. */
|
|
58
60
|
interface PackageJson {
|
|
59
61
|
name?: string;
|
|
60
|
-
workspaces?:
|
|
62
|
+
workspaces?: JsonValue;
|
|
61
63
|
dependencies?: Record<string, string>;
|
|
62
64
|
devDependencies?: Record<string, string>;
|
|
63
65
|
customElements?: string;
|
|
64
|
-
[key: string]: unknown;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
/** A custom-element-manifest declaration (subset consumed here). */
|
|
@@ -69,17 +70,17 @@ interface CemDeclaration {
|
|
|
69
70
|
customElement?: boolean;
|
|
70
71
|
tagName?: string;
|
|
71
72
|
description?: string;
|
|
72
|
-
cssProperties?:
|
|
73
|
-
events?:
|
|
74
|
-
slots?:
|
|
75
|
-
|
|
73
|
+
cssProperties?: JsonValue[];
|
|
74
|
+
events?: JsonValue[];
|
|
75
|
+
slots?: JsonValue[];
|
|
76
|
+
/** Relayed verbatim once filtered on `kind` and `privacy`. */
|
|
77
|
+
members?: JsonObject[];
|
|
76
78
|
attributes?: {
|
|
77
79
|
name?: string;
|
|
78
|
-
default?:
|
|
80
|
+
default?: JsonValue;
|
|
79
81
|
description?: string;
|
|
80
82
|
type?: { text?: string };
|
|
81
83
|
}[];
|
|
82
|
-
[key: string]: unknown;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
interface CemModule {
|
|
@@ -91,15 +92,6 @@ interface Cem {
|
|
|
91
92
|
modules?: CemModule[];
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
/** A parsed Jx component document (subset consumed by component discovery). */
|
|
95
|
-
interface ComponentDoc {
|
|
96
|
-
$id?: string;
|
|
97
|
-
tagName?: string;
|
|
98
|
-
$elements?: unknown[];
|
|
99
|
-
state?: Record<string, unknown>;
|
|
100
|
-
[key: string]: unknown;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
95
|
// ─── Extension registry (per project root, invalidated on project.json change) ──
|
|
104
96
|
|
|
105
97
|
const extensionRegistryCache = new Map<string, { mtime: number; registry: ExtensionRegistry }>();
|
|
@@ -117,6 +109,8 @@ async function getExtensionRegistry(projectRoot: string): Promise<ExtensionRegis
|
|
|
117
109
|
let projectConfig: ProjectConfig | undefined;
|
|
118
110
|
try {
|
|
119
111
|
mtime = statSync(projectJsonPath).mtimeMs;
|
|
112
|
+
/* SAFETY: buildProjectExtensionRegistry reads only `projectConfig?.extensions` and validates
|
|
113
|
+
that list itself; every other ProjectConfig member is optional and unread on this path. */
|
|
120
114
|
projectConfig = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
|
|
121
115
|
} catch {
|
|
122
116
|
projectConfig = undefined;
|
|
@@ -206,16 +200,6 @@ export function isOwnedProjectDir(dir: string): boolean {
|
|
|
206
200
|
return existsSync(resolve(dir, "project.json"));
|
|
207
201
|
}
|
|
208
202
|
|
|
209
|
-
const statusMap: Record<string, string> = {
|
|
210
|
-
A: "A",
|
|
211
|
-
C: "C",
|
|
212
|
-
D: "D",
|
|
213
|
-
M: "M",
|
|
214
|
-
R: "R",
|
|
215
|
-
T: "T",
|
|
216
|
-
U: "U",
|
|
217
|
-
};
|
|
218
|
-
|
|
219
203
|
/**
|
|
220
204
|
* Parse raw `git status --porcelain=v2 --branch` output into structured data.
|
|
221
205
|
*
|
|
@@ -262,18 +246,19 @@ export function parseGitStatus(out: string) {
|
|
|
262
246
|
} else {
|
|
263
247
|
filePath = parts.slice(8).join(" ");
|
|
264
248
|
}
|
|
249
|
+
// Porcelain v2 XY codes (A, C, D, M, R, T, U, ...) are reported verbatim; "." means unchanged.
|
|
265
250
|
if (stagedCode !== ".") {
|
|
266
251
|
files.push({
|
|
267
252
|
path: filePath,
|
|
268
253
|
staged: true,
|
|
269
|
-
status:
|
|
254
|
+
status: stagedCode,
|
|
270
255
|
});
|
|
271
256
|
}
|
|
272
257
|
if (unstagedCode !== ".") {
|
|
273
258
|
files.push({
|
|
274
259
|
path: filePath,
|
|
275
260
|
staged: false,
|
|
276
|
-
status:
|
|
261
|
+
status: unstagedCode,
|
|
277
262
|
});
|
|
278
263
|
}
|
|
279
264
|
} else if (line.startsWith("? ")) {
|
|
@@ -284,6 +269,33 @@ export function parseGitStatus(out: string) {
|
|
|
284
269
|
return { ahead, behind, branch, files };
|
|
285
270
|
}
|
|
286
271
|
|
|
272
|
+
/** An imported plugin module, or its default export, read by export name. */
|
|
273
|
+
interface PluginModule {
|
|
274
|
+
readonly [exportName: string]: PluginExport;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A function a plugin module exports: the plugin class, whose static `schema` the studio renders. */
|
|
278
|
+
interface PluginClass extends PluginModule {
|
|
279
|
+
(...args: never[]): void;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* What schema discovery can meet under an export name, spelled out by kind. Only a function is used
|
|
284
|
+
* (as the plugin class); an object is searched one level, as a default export; every other kind of
|
|
285
|
+
* value is a miss.
|
|
286
|
+
*/
|
|
287
|
+
type PluginExport = PluginClass | PluginModule | JsonPrimitive | bigint | symbol | undefined;
|
|
288
|
+
|
|
289
|
+
/** Whether an export is a function, and so a candidate plugin class. */
|
|
290
|
+
function isPluginClass(value: PluginExport): value is PluginClass {
|
|
291
|
+
return isFunction(value);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The body of `POST /__studio/git/push`; an absent or unparsable body means a plain push. */
|
|
295
|
+
interface PushBody {
|
|
296
|
+
setUpstream?: boolean;
|
|
297
|
+
}
|
|
298
|
+
|
|
287
299
|
/** Host hooks for the routes that reach outside the server root (project creation). */
|
|
288
300
|
export interface StudioApiOptions {
|
|
289
301
|
/** Extra roots a new project may be created under, mirroring `createDevServer.allowedRoots`. */
|
|
@@ -357,6 +369,27 @@ function byPathOrder<T extends { path: string }>(entries: T[]): T[] {
|
|
|
357
369
|
return entries.toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
358
370
|
}
|
|
359
371
|
|
|
372
|
+
/**
|
|
373
|
+
* A git command that exited non-zero, carrying both output streams (git splits failures across
|
|
374
|
+
* them).
|
|
375
|
+
*/
|
|
376
|
+
class GitCommandError extends Error {
|
|
377
|
+
readonly stderr: string;
|
|
378
|
+
readonly stdout: string;
|
|
379
|
+
|
|
380
|
+
constructor(message: string, stderr: string, stdout: string) {
|
|
381
|
+
super(message);
|
|
382
|
+
this.name = "GitCommandError";
|
|
383
|
+
this.stderr = stderr;
|
|
384
|
+
this.stdout = stdout;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** True when a failed filesystem call failed because the path does not exist (`ENOENT`). */
|
|
389
|
+
function isNotFound(cause: unknown): boolean {
|
|
390
|
+
return isNonNullObject(cause) && "code" in cause && cause.code === "ENOENT";
|
|
391
|
+
}
|
|
392
|
+
|
|
360
393
|
export async function handleStudioApi(
|
|
361
394
|
req: Request,
|
|
362
395
|
url: URL,
|
|
@@ -375,6 +408,9 @@ export async function handleStudioApi(
|
|
|
375
408
|
// Project metadata
|
|
376
409
|
if (path === "/__studio/project" && req.method === "GET") {
|
|
377
410
|
try {
|
|
411
|
+
/* SAFETY: only `name` and `workspaces` are read, each behind a `??` fallback; a file whose
|
|
412
|
+
JSON is not an object yields undefined members (or throws on null) and answers the same
|
|
413
|
+
fallback shape as the catch below. */
|
|
378
414
|
const pkg = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")) as PackageJson;
|
|
379
415
|
return Response.json({
|
|
380
416
|
name: pkg.name ?? basename(root),
|
|
@@ -419,9 +455,14 @@ export async function handleStudioApi(
|
|
|
419
455
|
let isSiteProject = false;
|
|
420
456
|
let projectConfig: ProjectConfig | null = null;
|
|
421
457
|
try {
|
|
422
|
-
|
|
423
|
-
|
|
458
|
+
// SAFETY: JSON.parse without a reviver only ever produces JSON values.
|
|
459
|
+
const raw = JSON.parse(
|
|
460
|
+
await readFile(resolve(absDir, "project.json"), "utf8"),
|
|
461
|
+
) as JsonValue;
|
|
462
|
+
if (isJsonObject(raw)) {
|
|
424
463
|
isSiteProject = true;
|
|
464
|
+
/* SAFETY: `raw` is a JSON object and every ProjectConfig member is optional; the config
|
|
465
|
+
is only relayed to the client here, never interpreted. */
|
|
425
466
|
projectConfig = raw as ProjectConfig;
|
|
426
467
|
}
|
|
427
468
|
} catch {}
|
|
@@ -462,6 +503,8 @@ export async function handleStudioApi(
|
|
|
462
503
|
while (dir) {
|
|
463
504
|
const candidate = resolve(dir, "project.json");
|
|
464
505
|
if (existsSync(candidate)) {
|
|
506
|
+
/* SAFETY: the config is relayed to the client verbatim and not interpreted here, so its
|
|
507
|
+
member types are the client's to check; malformed JSON throws into the catch below. */
|
|
465
508
|
const config = JSON.parse(readFileSync(candidate, "utf8")) as ProjectConfig;
|
|
466
509
|
const relPath = fwd(dir);
|
|
467
510
|
const fileRelPath = fwd(relative(dir, absFile));
|
|
@@ -595,9 +638,12 @@ export async function handleStudioApi(
|
|
|
595
638
|
}
|
|
596
639
|
const fp = resolve(root, match);
|
|
597
640
|
try {
|
|
598
|
-
|
|
599
|
-
|
|
641
|
+
// SAFETY: JSON.parse without a reviver only ever produces JSON values.
|
|
642
|
+
const raw = JSON.parse(await readFile(fp, "utf8")) as JsonValue;
|
|
643
|
+
if (isJsonObject(raw)) {
|
|
600
644
|
const projectDir = fwd(dirname(fp));
|
|
645
|
+
/* SAFETY: `raw` is a JSON object and every ProjectConfig member is optional; the site
|
|
646
|
+
list only relays the config to the client. */
|
|
601
647
|
sites.push({ config: raw as ProjectConfig, path: projectDir });
|
|
602
648
|
}
|
|
603
649
|
} catch {}
|
|
@@ -611,6 +657,12 @@ export async function handleStudioApi(
|
|
|
611
657
|
// Create a new project
|
|
612
658
|
if (path === "/__studio/create-project" && req.method === "POST") {
|
|
613
659
|
try {
|
|
660
|
+
/* SAFETY: trust boundary. The only client is Studio's New Project modal, which sends these
|
|
661
|
+
members with these types. `name`, `directory` and `destination` are checked below before
|
|
662
|
+
any filesystem use, and `template` goes through isTemplateId. `description`, `url`,
|
|
663
|
+
`adapter`, `starter` and `design` are relayed to generateProject unchecked (a falsy
|
|
664
|
+
`adapter` is dropped so the static default applies), and it writes them into the scaffold
|
|
665
|
+
as given. */
|
|
614
666
|
const body = (await req.json()) as {
|
|
615
667
|
name?: string;
|
|
616
668
|
description?: string;
|
|
@@ -661,16 +713,29 @@ export async function handleStudioApi(
|
|
|
661
713
|
const destPath = resolve(destination.parent, directory);
|
|
662
714
|
|
|
663
715
|
const { generateProject } = await import("@jxsuite/create/generate");
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
716
|
+
const projectOptions: ProjectOptions = { name };
|
|
717
|
+
if (adapter) {
|
|
718
|
+
projectOptions.adapter = adapter;
|
|
719
|
+
}
|
|
720
|
+
if (description !== undefined) {
|
|
721
|
+
projectOptions.description = description;
|
|
722
|
+
}
|
|
723
|
+
if (siteUrl !== undefined) {
|
|
724
|
+
projectOptions.url = siteUrl;
|
|
725
|
+
}
|
|
726
|
+
if (starter !== undefined) {
|
|
727
|
+
projectOptions.starter = starter;
|
|
728
|
+
}
|
|
729
|
+
if (template !== undefined && isTemplateId(template)) {
|
|
730
|
+
projectOptions.template = template;
|
|
731
|
+
}
|
|
732
|
+
if (design !== undefined) {
|
|
733
|
+
projectOptions.design = design;
|
|
734
|
+
}
|
|
735
|
+
await generateProject(destPath, projectOptions);
|
|
673
736
|
|
|
737
|
+
/* SAFETY: generateProject just wrote this project.json from its own template, so it is a
|
|
738
|
+
project config; it is relayed to the client, not interpreted here. */
|
|
674
739
|
const config = JSON.parse(
|
|
675
740
|
await readFile(resolve(destPath, "project.json"), "utf8"),
|
|
676
741
|
) as ProjectConfig;
|
|
@@ -784,16 +849,20 @@ export async function handleStudioApi(
|
|
|
784
849
|
}
|
|
785
850
|
const fp = resolve(scanRoot, match);
|
|
786
851
|
try {
|
|
787
|
-
let content:
|
|
852
|
+
let content: JsonValue;
|
|
788
853
|
if (match.endsWith(".json")) {
|
|
789
|
-
|
|
854
|
+
// SAFETY: JSON.parse without a reviver only ever produces JSON values.
|
|
855
|
+
content = JSON.parse(await readFile(fp, "utf8")) as JsonValue;
|
|
790
856
|
} else {
|
|
791
857
|
const entry = registry.byExtension(extname(match), "parse");
|
|
792
858
|
if (!entry) {
|
|
793
859
|
continue;
|
|
794
860
|
}
|
|
795
861
|
const source = await readFile(fp, "utf8");
|
|
796
|
-
|
|
862
|
+
const parsed = await entry.call("parse", source);
|
|
863
|
+
/* Only a document object can declare a component, and componentMetaFrom answers null
|
|
864
|
+
for anything else, so loaded entries reach it as null rather than as a document. */
|
|
865
|
+
content = isJsonObject(parsed) ? parsed : null;
|
|
797
866
|
}
|
|
798
867
|
/* The metadata rules live in @jxsuite/schema/component-meta because THREE backends need
|
|
799
868
|
the same answer — this one, the desktop session, and now the cloud adapter, which can
|
|
@@ -811,6 +880,9 @@ export async function handleStudioApi(
|
|
|
811
880
|
try {
|
|
812
881
|
const projectPkgPath = resolve(scanRoot, "package.json");
|
|
813
882
|
if (existsSync(projectPkgPath)) {
|
|
883
|
+
/* SAFETY: only the keys of the dependency maps are used, each as a package name that must
|
|
884
|
+
resolve to node_modules/<name>/package.json before anything else is read, so a name
|
|
885
|
+
that does not is skipped; a throw skips this whole scan. */
|
|
814
886
|
const pkg = JSON.parse(await readFile(projectPkgPath, "utf8")) as PackageJson;
|
|
815
887
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
816
888
|
for (const name of Object.keys(deps)) {
|
|
@@ -836,6 +908,8 @@ export async function handleStudioApi(
|
|
|
836
908
|
if (!actualPath) {
|
|
837
909
|
continue;
|
|
838
910
|
}
|
|
911
|
+
/* SAFETY: `customElements` is the one member read; npm defines it as the manifest
|
|
912
|
+
path, and a non-string value makes resolve() throw into this package's catch. */
|
|
839
913
|
const depPkg = JSON.parse(await readFile(actualPath, "utf8")) as PackageJson;
|
|
840
914
|
if (!depPkg.customElements) {
|
|
841
915
|
continue;
|
|
@@ -844,6 +918,9 @@ export async function handleStudioApi(
|
|
|
844
918
|
if (!existsSync(cemPath)) {
|
|
845
919
|
continue;
|
|
846
920
|
}
|
|
921
|
+
/* SAFETY: the package names this file as its custom-elements manifest, and every
|
|
922
|
+
member read below has a fallback; a manifest of another shape either throws into
|
|
923
|
+
this package's catch or contributes no declarations. */
|
|
847
924
|
const cem = JSON.parse(await readFile(cemPath, "utf8")) as Cem;
|
|
848
925
|
for (const mod of cem.modules || []) {
|
|
849
926
|
for (const decl of mod.declarations || []) {
|
|
@@ -912,12 +989,9 @@ export async function handleStudioApi(
|
|
|
912
989
|
// First would mean every asset 404s for as long as the build takes.
|
|
913
990
|
const result = await buildSite(dir, { clean: false, verbose: false });
|
|
914
991
|
const preview = startSitePreview(dir);
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
routes: result.routes,
|
|
919
|
-
...(preview ? { url: preview.origin } : {}),
|
|
920
|
-
});
|
|
992
|
+
const built = { errors: result.errors, files: result.files, routes: result.routes };
|
|
993
|
+
// `url` follows the build fields, and only when a preview origin exists.
|
|
994
|
+
return Response.json(preview ? { ...built, url: preview.origin } : built);
|
|
921
995
|
} catch (error) {
|
|
922
996
|
return problem("internalError", errorMessage(error));
|
|
923
997
|
}
|
|
@@ -943,6 +1017,8 @@ export async function handleStudioApi(
|
|
|
943
1017
|
}
|
|
944
1018
|
let body: { route?: string };
|
|
945
1019
|
try {
|
|
1020
|
+
/* SAFETY: `route` is the only member read, and only when truthy; it is relayed verbatim in
|
|
1021
|
+
the preview tab's `navigate` event, so nothing here depends on its runtime type. */
|
|
946
1022
|
body = (await req.json()) as { route?: string };
|
|
947
1023
|
} catch {
|
|
948
1024
|
return problem("invalidRequest", "Invalid JSON body");
|
|
@@ -976,19 +1052,21 @@ export async function handleStudioApi(
|
|
|
976
1052
|
const dir = activeProjectRoot ?? root;
|
|
977
1053
|
let body: { path?: string; contents?: string };
|
|
978
1054
|
try {
|
|
1055
|
+
/* SAFETY: both members are re-checked with isString below before either reaches the
|
|
1056
|
+
overlay store. */
|
|
979
1057
|
body = (await req.json()) as { path?: string; contents?: string };
|
|
980
1058
|
} catch {
|
|
981
1059
|
return problem("invalidRequest", "Invalid JSON body");
|
|
982
1060
|
}
|
|
983
1061
|
if (req.method === "POST") {
|
|
984
|
-
if (
|
|
1062
|
+
if (!isString(body.path) || !isString(body.contents)) {
|
|
985
1063
|
return problem("invalidRequest", "Missing path or contents");
|
|
986
1064
|
}
|
|
987
1065
|
setLivePreviewOverlay(dir, body.path, body.contents);
|
|
988
1066
|
return new Response(null, { status: 204 });
|
|
989
1067
|
}
|
|
990
1068
|
if (req.method === "DELETE") {
|
|
991
|
-
clearLivePreviewOverlay(dir,
|
|
1069
|
+
clearLivePreviewOverlay(dir, isString(body.path) ? body.path : undefined);
|
|
992
1070
|
return new Response(null, { status: 204 });
|
|
993
1071
|
}
|
|
994
1072
|
}
|
|
@@ -1004,6 +1082,8 @@ export async function handleStudioApi(
|
|
|
1004
1082
|
if (!existsSync(pkgPath)) {
|
|
1005
1083
|
return Response.json([]);
|
|
1006
1084
|
}
|
|
1085
|
+
/* SAFETY: npm and bun write `dependencies` / `devDependencies` as name-to-range string maps,
|
|
1086
|
+
which is all this route reads; a package.json that is not JSON throws into the catch. */
|
|
1007
1087
|
const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as PackageJson;
|
|
1008
1088
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1009
1089
|
const packages: {
|
|
@@ -1025,6 +1105,8 @@ export async function handleStudioApi(
|
|
|
1025
1105
|
continue;
|
|
1026
1106
|
}
|
|
1027
1107
|
try {
|
|
1108
|
+
/* SAFETY: `customElements` is the one member read, and it is relayed verbatim or tested
|
|
1109
|
+
for truthiness; malformed JSON skips the package in the catch. */
|
|
1028
1110
|
const depPkg = JSON.parse(await readFile(actualPath, "utf8")) as PackageJson;
|
|
1029
1111
|
packages.push({
|
|
1030
1112
|
customElementsPath: depPkg.customElements || null,
|
|
@@ -1060,6 +1142,8 @@ export async function handleStudioApi(
|
|
|
1060
1142
|
if (!actualPath) {
|
|
1061
1143
|
return Response.json({ cem: null });
|
|
1062
1144
|
}
|
|
1145
|
+
/* SAFETY: `customElements` is the one member read; npm defines it as the manifest path, and
|
|
1146
|
+
a non-string value makes resolve() throw into the catch below. */
|
|
1063
1147
|
const depPkg = JSON.parse(await readFile(actualPath, "utf8")) as PackageJson;
|
|
1064
1148
|
if (!depPkg.customElements) {
|
|
1065
1149
|
return Response.json({ cem: null });
|
|
@@ -1068,7 +1152,9 @@ export async function handleStudioApi(
|
|
|
1068
1152
|
if (!existsSync(cemPath)) {
|
|
1069
1153
|
return Response.json({ cem: null });
|
|
1070
1154
|
}
|
|
1071
|
-
|
|
1155
|
+
/* SAFETY: JSON.parse without a reviver only ever produces JSON values, and the manifest is
|
|
1156
|
+
relayed verbatim, so it needs decoding no further than that. */
|
|
1157
|
+
const cem = JSON.parse(await readFile(cemPath, "utf8")) as JsonValue;
|
|
1072
1158
|
return Response.json({ cem });
|
|
1073
1159
|
} catch (error) {
|
|
1074
1160
|
return problem("internalError", errorMessage(error));
|
|
@@ -1078,9 +1164,12 @@ export async function handleStudioApi(
|
|
|
1078
1164
|
// Add an npm package
|
|
1079
1165
|
if (path === "/__studio/packages/add" && req.method === "POST") {
|
|
1080
1166
|
try {
|
|
1167
|
+
/* SAFETY: `name` reaches argv, so it is re-checked with isString below; `dir` is only
|
|
1168
|
+
path-resolved and `dev` only tested for truthiness, and a wrongly typed `dir` throws in
|
|
1169
|
+
isAbsolute inside this try and answers 500. */
|
|
1081
1170
|
const body = (await req.json()) as { name?: string; dir?: string; dev?: boolean };
|
|
1082
1171
|
const { name } = body;
|
|
1083
|
-
if (!name ||
|
|
1172
|
+
if (!name || !isString(name)) {
|
|
1084
1173
|
return problem("invalidRequest", "Missing name");
|
|
1085
1174
|
}
|
|
1086
1175
|
const dir = body.dir || activeProjectRoot;
|
|
@@ -1108,9 +1197,11 @@ export async function handleStudioApi(
|
|
|
1108
1197
|
// Remove an npm package
|
|
1109
1198
|
if (path === "/__studio/packages/remove" && req.method === "POST") {
|
|
1110
1199
|
try {
|
|
1200
|
+
/* SAFETY: `name` reaches argv, so it is re-checked with isString below; a wrongly typed
|
|
1201
|
+
`dir` throws in isAbsolute inside this try and answers 500. */
|
|
1111
1202
|
const body = (await req.json()) as { name?: string; dir?: string };
|
|
1112
1203
|
const { name } = body;
|
|
1113
|
-
if (!name ||
|
|
1204
|
+
if (!name || !isString(name)) {
|
|
1114
1205
|
return problem("invalidRequest", "Missing name");
|
|
1115
1206
|
}
|
|
1116
1207
|
const dir = body.dir || activeProjectRoot;
|
|
@@ -1134,6 +1225,8 @@ export async function handleStudioApi(
|
|
|
1134
1225
|
// Install all dependencies (bun install)
|
|
1135
1226
|
if (path === "/__studio/packages/install" && req.method === "POST") {
|
|
1136
1227
|
try {
|
|
1228
|
+
/* SAFETY: `dir` is the only member read and is only path-resolved; a wrongly typed `dir`
|
|
1229
|
+
throws in isAbsolute inside this try and answers 500. */
|
|
1137
1230
|
const body = (await req.json().catch(() => ({}))) as { dir?: string };
|
|
1138
1231
|
const dir = body.dir || activeProjectRoot || root;
|
|
1139
1232
|
const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
|
|
@@ -1164,6 +1257,9 @@ export async function handleStudioApi(
|
|
|
1164
1257
|
// Set version ranges for one or more packages, then reinstall
|
|
1165
1258
|
if (path === "/__studio/packages/set-versions" && req.method === "POST") {
|
|
1166
1259
|
try {
|
|
1260
|
+
/* SAFETY: `updates` is checked to be an array below and each entry is Studio's own
|
|
1261
|
+
`{ name, version, dev }` triple from the versions panel; a wrongly typed `dir` throws in
|
|
1262
|
+
isAbsolute inside this try and answers 500. */
|
|
1167
1263
|
const body = (await req.json()) as {
|
|
1168
1264
|
dir?: string;
|
|
1169
1265
|
updates?: { name: string; version: string; dev?: boolean }[];
|
|
@@ -1200,7 +1296,7 @@ export async function handleStudioApi(
|
|
|
1200
1296
|
{ headers: { "Cache-Control": "public, max-age=5" } },
|
|
1201
1297
|
);
|
|
1202
1298
|
} catch (error) {
|
|
1203
|
-
return (error
|
|
1299
|
+
return isNotFound(error)
|
|
1204
1300
|
? problem("notFound", "Not found")
|
|
1205
1301
|
: problem("internalError", errorMessage(error));
|
|
1206
1302
|
}
|
|
@@ -1292,7 +1388,7 @@ export async function handleStudioApi(
|
|
|
1292
1388
|
await unlink(abs);
|
|
1293
1389
|
return Response.json({ ok: true, path: fwd(relative(root, abs)) });
|
|
1294
1390
|
} catch (error) {
|
|
1295
|
-
return (error
|
|
1391
|
+
return isNotFound(error)
|
|
1296
1392
|
? problem("notFound", "Not found")
|
|
1297
1393
|
: problem("internalError", errorMessage(error));
|
|
1298
1394
|
}
|
|
@@ -1302,6 +1398,8 @@ export async function handleStudioApi(
|
|
|
1302
1398
|
if (path === "/__studio/file/rename" && req.method === "POST") {
|
|
1303
1399
|
let body: { from?: string; to?: string };
|
|
1304
1400
|
try {
|
|
1401
|
+
/* SAFETY: both members are truthiness-checked below and then path-resolved, where a
|
|
1402
|
+
non-string throws before any filesystem call is made. */
|
|
1305
1403
|
body = (await req.json()) as { from?: string; to?: string };
|
|
1306
1404
|
} catch {
|
|
1307
1405
|
return problem("invalidRequest", "Invalid JSON");
|
|
@@ -1386,6 +1484,8 @@ export async function handleStudioApi(
|
|
|
1386
1484
|
if (path === "/__studio/locate" && req.method === "POST") {
|
|
1387
1485
|
let body: { name?: string };
|
|
1388
1486
|
try {
|
|
1487
|
+
/* SAFETY: `name` is truthiness-checked below and then interpolated into a glob pattern, so
|
|
1488
|
+
a non-string value is stringified the same way a template literal always would. */
|
|
1389
1489
|
body = (await req.json()) as { name?: string };
|
|
1390
1490
|
} catch {
|
|
1391
1491
|
return problem("invalidRequest", "Invalid JSON");
|
|
@@ -1405,13 +1505,13 @@ export async function handleStudioApi(
|
|
|
1405
1505
|
}
|
|
1406
1506
|
matches.push(fwd(match));
|
|
1407
1507
|
}
|
|
1408
|
-
|
|
1508
|
+
const [first] = matches;
|
|
1509
|
+
if (first === undefined) {
|
|
1409
1510
|
return Response.json({ path: null });
|
|
1410
1511
|
}
|
|
1411
|
-
return Response.json(
|
|
1412
|
-
path: matches
|
|
1413
|
-
|
|
1414
|
-
});
|
|
1512
|
+
return Response.json(
|
|
1513
|
+
matches.length > 1 ? { path: first, alternatives: matches } : { path: first },
|
|
1514
|
+
);
|
|
1415
1515
|
} catch (error) {
|
|
1416
1516
|
return problem("internalError", errorMessage(error));
|
|
1417
1517
|
}
|
|
@@ -1433,8 +1533,11 @@ export async function handleStudioApi(
|
|
|
1433
1533
|
if (!cfToken) {
|
|
1434
1534
|
return problem("unauthorized", "Missing X-CF-Token header");
|
|
1435
1535
|
}
|
|
1436
|
-
let payload: { path?: string; method?: string; body?:
|
|
1536
|
+
let payload: { path?: string; method?: string; body?: JsonValue };
|
|
1437
1537
|
try {
|
|
1538
|
+
/* SAFETY: `path` is matched against CF_PROXY_ALLOWLIST before use, `method` goes to fetch,
|
|
1539
|
+
which converts and validates it per the Fetch standard, and `body` is re-serialized with
|
|
1540
|
+
JSON.stringify, so it only ever needs to be the JSON value it was parsed as. */
|
|
1438
1541
|
payload = (await req.json()) as typeof payload;
|
|
1439
1542
|
} catch {
|
|
1440
1543
|
return problem("invalidRequest", "Invalid JSON body");
|
|
@@ -1444,14 +1547,16 @@ export async function handleStudioApi(
|
|
|
1444
1547
|
return problem("forbidden", `Path not allowed: ${apiPath}`);
|
|
1445
1548
|
}
|
|
1446
1549
|
const method = payload.method ?? "GET";
|
|
1447
|
-
const
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
Authorization:
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1550
|
+
const authorization = `Bearer ${cfToken}`;
|
|
1551
|
+
const init: RequestInit =
|
|
1552
|
+
payload.body === undefined
|
|
1553
|
+
? { method, headers: { Authorization: authorization } }
|
|
1554
|
+
: {
|
|
1555
|
+
method,
|
|
1556
|
+
headers: { Authorization: authorization, "Content-Type": "application/json" },
|
|
1557
|
+
body: JSON.stringify(payload.body),
|
|
1558
|
+
};
|
|
1559
|
+
const upstream = await fetch(`https://api.cloudflare.com/client/v4${apiPath}`, init);
|
|
1455
1560
|
return new Response(upstream.body, {
|
|
1456
1561
|
headers: { "Content-Type": upstream.headers.get("Content-Type") ?? "application/json" },
|
|
1457
1562
|
status: upstream.status,
|
|
@@ -1462,12 +1567,15 @@ export async function handleStudioApi(
|
|
|
1462
1567
|
// Dispatches parse/serialize through the project's format registry by import name.
|
|
1463
1568
|
if (path === "/__studio/format" && req.method === "POST") {
|
|
1464
1569
|
try {
|
|
1570
|
+
/* SAFETY: `format` and `action` are checked below (`action` against the two capability
|
|
1571
|
+
names), and `source`, `doc` and `options` are handed to the format's own parse/serialize,
|
|
1572
|
+
which owns their validation; any throw answers 500 from this try. */
|
|
1465
1573
|
const body = (await req.json()) as {
|
|
1466
1574
|
format?: string;
|
|
1467
1575
|
action?: string;
|
|
1468
1576
|
source?: string;
|
|
1469
|
-
doc?:
|
|
1470
|
-
options?:
|
|
1577
|
+
doc?: JsonObject;
|
|
1578
|
+
options?: JsonObject;
|
|
1471
1579
|
dir?: string;
|
|
1472
1580
|
};
|
|
1473
1581
|
const { format, action, source, doc, options } = body;
|
|
@@ -1608,6 +1716,8 @@ export async function handleStudioApi(
|
|
|
1608
1716
|
if (moduleAbsPath.endsWith(".class.json")) {
|
|
1609
1717
|
try {
|
|
1610
1718
|
const content = readFileSync(moduleAbsPath, "utf8");
|
|
1719
|
+
/* SAFETY: every ClassJsonDef member is optional and extractStudioSchema guards or relays
|
|
1720
|
+
each one it reads; a non-object file throws into the catch below or yields no members. */
|
|
1611
1721
|
const classDef = JSON.parse(content) as ClassJsonDef;
|
|
1612
1722
|
return Response.json({
|
|
1613
1723
|
schema: extractStudioSchema(classDef, moduleAbsPath),
|
|
@@ -1626,6 +1736,8 @@ export async function handleStudioApi(
|
|
|
1626
1736
|
if (existsSync(classJsonPath)) {
|
|
1627
1737
|
try {
|
|
1628
1738
|
const content = readFileSync(classJsonPath, "utf8");
|
|
1739
|
+
/* SAFETY: every ClassJsonDef member is optional and extractStudioSchema guards or relays
|
|
1740
|
+
each one it reads; a malformed file throws and falls through to the module import. */
|
|
1629
1741
|
const classDef = JSON.parse(content) as ClassJsonDef;
|
|
1630
1742
|
return Response.json({
|
|
1631
1743
|
schema: extractStudioSchema(classDef, classJsonPath),
|
|
@@ -1637,18 +1749,20 @@ export async function handleStudioApi(
|
|
|
1637
1749
|
|
|
1638
1750
|
// Fallback: import JS module (backwards compat for classes without .class.json)
|
|
1639
1751
|
try {
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
const ExportedClass =
|
|
1645
|
-
|
|
1752
|
+
/* SAFETY: import() only ever resolves to a module namespace object, and PluginExport names
|
|
1753
|
+
every kind of value (primitive, function, object) a string-keyed member can hold. */
|
|
1754
|
+
const mod = (await import(moduleAbsPath)) as PluginModule;
|
|
1755
|
+
const fallback = mod.default;
|
|
1756
|
+
const ExportedClass =
|
|
1757
|
+
mod[exportName] ??
|
|
1758
|
+
(isNonNullObject(fallback) || isPluginClass(fallback) ? fallback[exportName] : undefined);
|
|
1759
|
+
if (!isPluginClass(ExportedClass)) {
|
|
1646
1760
|
return Response.json({
|
|
1647
1761
|
error: `Export "${exportName}" not found`,
|
|
1648
1762
|
schema: null,
|
|
1649
1763
|
});
|
|
1650
1764
|
}
|
|
1651
|
-
return Response.json({ schema:
|
|
1765
|
+
return Response.json({ schema: ExportedClass.schema ?? null });
|
|
1652
1766
|
} catch (error) {
|
|
1653
1767
|
return Response.json({
|
|
1654
1768
|
error: errorMessage(error),
|
|
@@ -1680,10 +1794,11 @@ export async function handleStudioApi(
|
|
|
1680
1794
|
* one failure this API publishes a shape for into a contentless message, and the 409 the
|
|
1681
1795
|
* route table promises could never be produced.
|
|
1682
1796
|
*/
|
|
1683
|
-
throw
|
|
1797
|
+
throw new GitCommandError(
|
|
1798
|
+
stderr || stdout || `git exited with ${exitCode}`,
|
|
1684
1799
|
stderr,
|
|
1685
1800
|
stdout,
|
|
1686
|
-
|
|
1801
|
+
);
|
|
1687
1802
|
}
|
|
1688
1803
|
return stdout;
|
|
1689
1804
|
};
|
|
@@ -1727,6 +1842,9 @@ export async function handleStudioApi(
|
|
|
1727
1842
|
}
|
|
1728
1843
|
|
|
1729
1844
|
if (gitCmd === "add-remote" && req.method === "POST") {
|
|
1845
|
+
/* SAFETY: both members are truthiness-checked below and reach git as their own argv
|
|
1846
|
+
entries with no shell; Bun.spawn stringifies a non-string entry, so a wrong type can only
|
|
1847
|
+
become a literal remote name or URL, which git validates and refuses or records. */
|
|
1730
1848
|
const { name, url: remoteUrl } = (await req.json()) as {
|
|
1731
1849
|
name?: string;
|
|
1732
1850
|
url?: string;
|
|
@@ -1770,6 +1888,9 @@ export async function handleStudioApi(
|
|
|
1770
1888
|
}
|
|
1771
1889
|
|
|
1772
1890
|
if (gitCmd === "stage" && req.method === "POST") {
|
|
1891
|
+
/* SAFETY: `files` is checked to be a non-empty array below; each entry is screened with
|
|
1892
|
+
`.includes("..")` (an entry without that method throws inside this try) and reaches git
|
|
1893
|
+
after `--`, where it can only be read as a pathspec. */
|
|
1773
1894
|
const { files } = (await req.json()) as { files?: string[] };
|
|
1774
1895
|
if (!Array.isArray(files) || files.length === 0) {
|
|
1775
1896
|
return problem("invalidRequest", "Missing files");
|
|
@@ -1784,6 +1905,8 @@ export async function handleStudioApi(
|
|
|
1784
1905
|
}
|
|
1785
1906
|
|
|
1786
1907
|
if (gitCmd === "unstage" && req.method === "POST") {
|
|
1908
|
+
/* SAFETY: `files` is checked to be a non-empty array below, and its entries reach git
|
|
1909
|
+
after `--`, so they can only ever be read as pathspecs. */
|
|
1787
1910
|
const { files } = (await req.json()) as { files?: string[] };
|
|
1788
1911
|
if (!Array.isArray(files) || files.length === 0) {
|
|
1789
1912
|
return problem("invalidRequest", "Missing files");
|
|
@@ -1793,8 +1916,9 @@ export async function handleStudioApi(
|
|
|
1793
1916
|
}
|
|
1794
1917
|
|
|
1795
1918
|
if (gitCmd === "commit" && req.method === "POST") {
|
|
1919
|
+
// SAFETY: `message` is re-checked with isString below before it reaches argv.
|
|
1796
1920
|
const { message } = (await req.json()) as { message?: string };
|
|
1797
|
-
if (!message ||
|
|
1921
|
+
if (!message || !isString(message)) {
|
|
1798
1922
|
return problem("invalidRequest", "Missing message");
|
|
1799
1923
|
}
|
|
1800
1924
|
const statusOut = await runGit(["status", "--porcelain"]);
|
|
@@ -1808,9 +1932,10 @@ export async function handleStudioApi(
|
|
|
1808
1932
|
}
|
|
1809
1933
|
|
|
1810
1934
|
if (gitCmd === "push" && req.method === "POST") {
|
|
1811
|
-
let body:
|
|
1935
|
+
let body: PushBody = {};
|
|
1812
1936
|
try {
|
|
1813
|
-
|
|
1937
|
+
// SAFETY: `setUpstream` is the only member read, and only for truthiness.
|
|
1938
|
+
body = (await req.json()) as PushBody;
|
|
1814
1939
|
} catch {}
|
|
1815
1940
|
const { setUpstream } = body;
|
|
1816
1941
|
if (setUpstream) {
|
|
@@ -1835,8 +1960,10 @@ export async function handleStudioApi(
|
|
|
1835
1960
|
try {
|
|
1836
1961
|
await runGit(["pull"]);
|
|
1837
1962
|
} catch (error) {
|
|
1838
|
-
const
|
|
1839
|
-
|
|
1963
|
+
const conflicts =
|
|
1964
|
+
error instanceof GitCommandError
|
|
1965
|
+
? conflictingPaths(`${error.stdout}\n${error.stderr}`)
|
|
1966
|
+
: [];
|
|
1840
1967
|
if (conflicts.length === 0) {
|
|
1841
1968
|
throw error;
|
|
1842
1969
|
}
|
|
@@ -1855,8 +1982,9 @@ export async function handleStudioApi(
|
|
|
1855
1982
|
}
|
|
1856
1983
|
|
|
1857
1984
|
if (gitCmd === "checkout" && req.method === "POST") {
|
|
1985
|
+
// SAFETY: `branch` is re-checked with isString below before it reaches argv.
|
|
1858
1986
|
const { branch } = (await req.json()) as { branch?: string };
|
|
1859
|
-
if (!branch ||
|
|
1987
|
+
if (!branch || !isString(branch)) {
|
|
1860
1988
|
return problem("invalidRequest", "Missing branch");
|
|
1861
1989
|
}
|
|
1862
1990
|
await runGit(["checkout", branch]);
|
|
@@ -1864,8 +1992,9 @@ export async function handleStudioApi(
|
|
|
1864
1992
|
}
|
|
1865
1993
|
|
|
1866
1994
|
if (gitCmd === "create-branch" && req.method === "POST") {
|
|
1995
|
+
// SAFETY: `name` is re-checked with isString below before it reaches argv.
|
|
1867
1996
|
const { name } = (await req.json()) as { name?: string };
|
|
1868
|
-
if (!name ||
|
|
1997
|
+
if (!name || !isString(name)) {
|
|
1869
1998
|
return problem("invalidRequest", "Missing name");
|
|
1870
1999
|
}
|
|
1871
2000
|
await runGit(["checkout", "-b", name]);
|
|
@@ -1904,6 +2033,9 @@ export async function handleStudioApi(
|
|
|
1904
2033
|
}
|
|
1905
2034
|
|
|
1906
2035
|
if (gitCmd === "discard" && req.method === "POST") {
|
|
2036
|
+
/* SAFETY: `files` is checked to be a non-empty array below; each entry is screened with
|
|
2037
|
+
`.includes("..")` (an entry without that method throws inside this try) and reaches git
|
|
2038
|
+
after `--`, where it can only be read as a pathspec. */
|
|
1907
2039
|
const { files } = (await req.json()) as { files?: string[] };
|
|
1908
2040
|
if (!Array.isArray(files) || files.length === 0) {
|
|
1909
2041
|
return problem("invalidRequest", "Missing files");
|
|
@@ -1918,8 +2050,9 @@ export async function handleStudioApi(
|
|
|
1918
2050
|
}
|
|
1919
2051
|
|
|
1920
2052
|
if (gitCmd === "clone" && req.method === "POST") {
|
|
2053
|
+
// SAFETY: `url` is re-checked with isString below before it reaches argv.
|
|
1921
2054
|
const { url: repoUrl } = (await req.json()) as { url?: string };
|
|
1922
|
-
if (!repoUrl ||
|
|
2055
|
+
if (!repoUrl || !isString(repoUrl)) {
|
|
1923
2056
|
return problem("invalidRequest", "Missing url");
|
|
1924
2057
|
}
|
|
1925
2058
|
const repoName = basename(repoUrl.replace(/\.git$/, ""));
|
|
@@ -1944,6 +2077,17 @@ export async function handleStudioApi(
|
|
|
1944
2077
|
return null;
|
|
1945
2078
|
}
|
|
1946
2079
|
|
|
2080
|
+
/** The flat form-rendering schema the studio's renderSchemaFields() consumes. */
|
|
2081
|
+
interface StudioSchema {
|
|
2082
|
+
description: string | undefined;
|
|
2083
|
+
properties: Record<string, JsonObject>;
|
|
2084
|
+
required: string[];
|
|
2085
|
+
returns?: JsonValue;
|
|
2086
|
+
format?: JsonValue;
|
|
2087
|
+
$studio?: JsonValue;
|
|
2088
|
+
capabilities?: Record<string, { identifier: string; timing: string[] }>;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
1947
2091
|
/**
|
|
1948
2092
|
* Extract a studio-friendly schema from a .class.json definition. Transforms $defs.parameters and
|
|
1949
2093
|
* $defs.fields into the flat { description, properties, required } shape that renderSchemaFields()
|
|
@@ -1951,19 +2095,18 @@ export async function handleStudioApi(
|
|
|
1951
2095
|
*
|
|
1952
2096
|
* @param {ClassJsonDef} classDef
|
|
1953
2097
|
* @param {string} classJsonPath
|
|
1954
|
-
* @returns {{
|
|
1955
|
-
* description: string | undefined;
|
|
1956
|
-
* properties: Record<string, Record<string, unknown>>;
|
|
1957
|
-
* required: string[];
|
|
1958
|
-
* }}
|
|
1959
2098
|
*/
|
|
1960
|
-
function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
|
|
2099
|
+
function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string): StudioSchema {
|
|
1961
2100
|
// If extends.$ref points to a parent, recursively merge
|
|
1962
2101
|
let parentSchema = null;
|
|
1963
|
-
|
|
2102
|
+
// `?.$ref` is undefined for a non-object `extends`, so a primitive never reaches resolve().
|
|
2103
|
+
if (classDef.extends?.$ref) {
|
|
1964
2104
|
try {
|
|
1965
2105
|
const parentPath = resolve(dirname(classJsonPath), classDef.extends.$ref);
|
|
1966
2106
|
const parentContent = readFileSync(parentPath, "utf8");
|
|
2107
|
+
/* SAFETY: every ClassJsonDef member is optional and each one read here is either guarded
|
|
2108
|
+
(isNonNullObject on `type`, truthiness elsewhere) or relayed verbatim; a non-object file
|
|
2109
|
+
throws into the catch below or yields no members. */
|
|
1967
2110
|
const parentDef = JSON.parse(parentContent) as ClassJsonDef;
|
|
1968
2111
|
parentSchema = extractStudioSchema(parentDef, parentPath);
|
|
1969
2112
|
} catch {
|
|
@@ -1973,7 +2116,7 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
|
|
|
1973
2116
|
|
|
1974
2117
|
const params = classDef.$defs?.parameters ?? {};
|
|
1975
2118
|
const fields = classDef.$defs?.fields ?? {};
|
|
1976
|
-
const properties: Record<string,
|
|
2119
|
+
const properties: Record<string, JsonObject> = {};
|
|
1977
2120
|
const required: string[] = [];
|
|
1978
2121
|
|
|
1979
2122
|
// Start with parent properties (child overrides)
|
|
@@ -1987,8 +2130,8 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
|
|
|
1987
2130
|
// Build properties from parameters (constructor config surface)
|
|
1988
2131
|
for (const [key, param] of Object.entries(params)) {
|
|
1989
2132
|
const id = param.identifier ?? key;
|
|
1990
|
-
const prop:
|
|
1991
|
-
if (param.type
|
|
2133
|
+
const prop: JsonObject = {};
|
|
2134
|
+
if (isNonNullObject(param.type)) {
|
|
1992
2135
|
Object.assign(prop, param.type);
|
|
1993
2136
|
}
|
|
1994
2137
|
if (param.description) {
|
|
@@ -2012,8 +2155,8 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
|
|
|
2012
2155
|
continue;
|
|
2013
2156
|
}
|
|
2014
2157
|
const id = field.identifier ?? key;
|
|
2015
|
-
const prop:
|
|
2016
|
-
if (field.type
|
|
2158
|
+
const prop: JsonObject = {};
|
|
2159
|
+
if (isNonNullObject(field.type)) {
|
|
2017
2160
|
Object.assign(prop, field.type);
|
|
2018
2161
|
}
|
|
2019
2162
|
if (field.description) {
|
|
@@ -2045,13 +2188,9 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
|
|
|
2045
2188
|
|
|
2046
2189
|
// Surface format-extension metadata: the format block, studio hints, and a
|
|
2047
2190
|
// Capability summary ({ parse: { timing }, serialize: { timing }, ... }).
|
|
2048
|
-
const def = classDef as Record<string, unknown>;
|
|
2049
2191
|
const capabilityRoles = new Set(["parse", "serialize", "discover", "load"]);
|
|
2050
2192
|
const capabilities: Record<string, { identifier: string; timing: string[] }> = {};
|
|
2051
|
-
const methods =
|
|
2052
|
-
string,
|
|
2053
|
-
{ role?: string; identifier?: string; timing?: string[] }
|
|
2054
|
-
>;
|
|
2193
|
+
const methods = classDef.$defs?.methods ?? {};
|
|
2055
2194
|
for (const [key, method] of Object.entries(methods)) {
|
|
2056
2195
|
if (method.role && capabilityRoles.has(method.role)) {
|
|
2057
2196
|
capabilities[method.role] = {
|
|
@@ -2061,13 +2200,23 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
|
|
|
2061
2200
|
}
|
|
2062
2201
|
}
|
|
2063
2202
|
|
|
2064
|
-
|
|
2203
|
+
// Optional members are added in this order, which is the key order the studio receives.
|
|
2204
|
+
const schema: StudioSchema = {
|
|
2065
2205
|
description: classDef.description ?? classDef.title,
|
|
2066
2206
|
properties,
|
|
2067
2207
|
required: [...requiredSet],
|
|
2068
|
-
...(resolveMethod?.returns ? { returns: resolveMethod.returns } : {}),
|
|
2069
|
-
...(def.format ? { format: def.format } : {}),
|
|
2070
|
-
...(def.$studio ? { $studio: def.$studio } : {}),
|
|
2071
|
-
...(Object.keys(capabilities).length > 0 ? { capabilities } : {}),
|
|
2072
2208
|
};
|
|
2209
|
+
if (resolveMethod?.returns) {
|
|
2210
|
+
schema.returns = resolveMethod.returns;
|
|
2211
|
+
}
|
|
2212
|
+
if (classDef.format) {
|
|
2213
|
+
schema.format = classDef.format;
|
|
2214
|
+
}
|
|
2215
|
+
if (classDef.$studio) {
|
|
2216
|
+
schema.$studio = classDef.$studio;
|
|
2217
|
+
}
|
|
2218
|
+
if (Object.keys(capabilities).length > 0) {
|
|
2219
|
+
schema.capabilities = capabilities;
|
|
2220
|
+
}
|
|
2221
|
+
return schema;
|
|
2073
2222
|
}
|