@jxsuite/server 0.27.0 → 0.28.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -29,11 +29,11 @@
29
29
  "dependencies": {
30
30
  "@anthropic-ai/claude-agent-sdk": "^0.3.170",
31
31
  "@anthropic-ai/sdk": "^0.104.1",
32
- "@jxsuite/compiler": "^0.27.0",
33
- "@jxsuite/create": "^0.27.0",
34
- "@jxsuite/parser": "^0.27.0",
35
- "@jxsuite/runtime": "^0.27.0",
36
- "@jxsuite/schema": "^0.27.0",
32
+ "@jxsuite/compiler": "^0.28.0",
33
+ "@jxsuite/create": "^0.28.0",
34
+ "@jxsuite/parser": "^0.28.0",
35
+ "@jxsuite/runtime": "^0.28.0",
36
+ "@jxsuite/schema": "^0.28.0",
37
37
  "@modelcontextprotocol/sdk": "^1.29.0",
38
38
  "chokidar": "^5.0.0",
39
39
  "zod": "^4.4.3"
@@ -297,7 +297,10 @@ export async function getAuthStatus() {
297
297
  clearTimeout(timeout);
298
298
  ctrl.abort();
299
299
  if (result.is_error) {
300
- return { authenticated: false, error: (result.result as string) || "API error" };
300
+ return {
301
+ authenticated: false,
302
+ error: (result.result as string) || "API error",
303
+ };
301
304
  }
302
305
  return { authenticated: true };
303
306
  }
@@ -318,5 +321,9 @@ export async function getAuthStatus() {
318
321
  export function getSession(id: string) {
319
322
  const session = sessions.get(id);
320
323
  if (!session) return null;
321
- return { id: session.id, status: session.status, messageCount: session.messages.length };
324
+ return {
325
+ id: session.id,
326
+ status: session.status,
327
+ messageCount: session.messages.length,
328
+ };
322
329
  }
package/src/resolve.ts CHANGED
@@ -128,7 +128,10 @@ export async function handleResolve(
128
128
 
129
129
  // Self-contained: construct class from schema
130
130
  const DynClass = classFromSchema(classDef);
131
- const instance = new DynClass(config) as { resolve?: () => unknown; value?: unknown };
131
+ const instance = new DynClass(config) as {
132
+ resolve?: () => unknown;
133
+ value?: unknown;
134
+ };
132
135
  const value =
133
136
  typeof instance.resolve === "function"
134
137
  ? await instance.resolve()
@@ -186,12 +189,16 @@ export async function handleServerFunction(req: Request, root: string) {
186
189
  try {
187
190
  mod = await import(moduleAbsPath);
188
191
  } catch (e) {
189
- return new Response(`Failed to import "${$src}": ${(e as Error).message}`, { status: 500 });
192
+ return new Response(`Failed to import "${$src}": ${(e as Error).message}`, {
193
+ status: 500,
194
+ });
190
195
  }
191
196
 
192
197
  const fn = mod[xport] ?? mod.default?.[xport];
193
198
  if (typeof fn !== "function") {
194
- return new Response(`Export "${xport}" not found in "${$src}"`, { status: 500 });
199
+ return new Response(`Export "${xport}" not found in "${$src}"`, {
200
+ status: 500,
201
+ });
195
202
  }
196
203
 
197
204
  try {
@@ -246,7 +253,10 @@ function classFromSchema(classDef: ClassJsonDef) {
246
253
  const sp = (method.setter.parameters ?? []).map((p) => p.$ref?.split("/").pop() ?? "v");
247
254
  descriptor.set = new Function(...sp, method.setter.body) as (v: unknown) => void;
248
255
  }
249
- Object.defineProperty(DynClass.prototype, name, { ...descriptor, configurable: true });
256
+ Object.defineProperty(DynClass.prototype, name, {
257
+ ...descriptor,
258
+ configurable: true,
259
+ });
250
260
  } else if (method.scope === "static") {
251
261
  (DynClass as DynamicClass)[name] = new Function(...params, bodyStr);
252
262
  } else {
@@ -254,6 +264,9 @@ function classFromSchema(classDef: ClassJsonDef) {
254
264
  }
255
265
  }
256
266
 
257
- Object.defineProperty(DynClass, "name", { value: classDef.title, configurable: true });
267
+ Object.defineProperty(DynClass, "name", {
268
+ value: classDef.title,
269
+ configurable: true,
270
+ });
258
271
  return DynClass;
259
272
  }
package/src/server.ts CHANGED
@@ -285,7 +285,9 @@ export async function createDevServer(options: {
285
285
  const bundled = bundleCache.get(cacheKey);
286
286
  if (bundled) {
287
287
  return new Response(bundled, {
288
- headers: { "Content-Type": "application/javascript; charset=utf-8" },
288
+ headers: {
289
+ "Content-Type": "application/javascript; charset=utf-8",
290
+ },
289
291
  });
290
292
  }
291
293
  }
package/src/studio-api.ts CHANGED
@@ -7,16 +7,45 @@
7
7
  * All paths are relative to the project root. Directory traversal above root is rejected.
8
8
  */
9
9
 
10
- import { resolve, relative, basename, dirname, isAbsolute } from "node:path";
10
+ import { resolve, relative, basename, dirname, extname, isAbsolute } from "node:path";
11
11
  import { readdir, stat, readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
12
- import { readFileSync, existsSync } from "node:fs";
13
- import { transpileJxMarkdown } from "@jxsuite/parser/transpile";
12
+ import { readFileSync, existsSync, statSync } from "node:fs";
13
+ import { buildProjectFormatRegistry } from "@jxsuite/compiler/format-host";
14
+ import type { FormatRegistry } from "@jxsuite/schema/format-registry";
14
15
  import * as claude from "./claude-session.ts";
15
16
  import type { ClassJsonDef } from "./types.ts";
16
17
 
17
18
  /** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
18
19
  const fwd = (p: string) => p.replaceAll("\\", "/");
19
20
 
21
+ // ─── Format registry (per project root, invalidated on project.json change) ──
22
+
23
+ const formatRegistryCache = new Map<string, { mtime: number; registry: FormatRegistry }>();
24
+
25
+ /**
26
+ * Build (or reuse) the format registry for a project root from its project.json imports map. An
27
+ * empty registry is returned when there is no project.json — only .json files are handled then.
28
+ *
29
+ * @param {string} projectRoot
30
+ * @returns {Promise<FormatRegistry>}
31
+ */
32
+ async function getFormatRegistry(projectRoot: string): Promise<FormatRegistry> {
33
+ const projectJsonPath = resolve(projectRoot, "project.json");
34
+ let mtime = 0;
35
+ let projectConfig;
36
+ try {
37
+ mtime = statSync(projectJsonPath).mtimeMs;
38
+ projectConfig = JSON.parse(readFileSync(projectJsonPath, "utf8"));
39
+ } catch {
40
+ projectConfig = undefined;
41
+ }
42
+ const cached = formatRegistryCache.get(projectRoot);
43
+ if (cached && cached.mtime === mtime) return cached.registry;
44
+ const registry = await buildProjectFormatRegistry(projectRoot, projectConfig);
45
+ formatRegistryCache.set(projectRoot, { mtime, registry });
46
+ return registry;
47
+ }
48
+
20
49
  /**
21
50
  * Check that a path is under either the server root OR the active project root. This allows file
22
51
  * operations on external projects that have been explicitly activated via /__studio/activate.
@@ -90,7 +119,11 @@ export function parseGitStatus(out: string) {
90
119
  filePath = parts.slice(8).join(" ");
91
120
  }
92
121
  if (stagedCode !== ".") {
93
- files.push({ path: filePath, status: statusMap[stagedCode] || stagedCode, staged: true });
122
+ files.push({
123
+ path: filePath,
124
+ status: statusMap[stagedCode] || stagedCode,
125
+ staged: true,
126
+ });
94
127
  }
95
128
  if (unstagedCode !== ".") {
96
129
  files.push({
@@ -176,7 +209,12 @@ export async function handleStudioApi(
176
209
  }
177
210
  } catch {}
178
211
 
179
- return Response.json({ isSiteProject, projectConfig, directories, projectRoot });
212
+ return Response.json({
213
+ isSiteProject,
214
+ projectConfig,
215
+ directories,
216
+ projectRoot,
217
+ });
180
218
  } catch (e) {
181
219
  return Response.json({ error: (e as Error).message }, { status: 500 });
182
220
  }
@@ -278,7 +316,12 @@ export async function handleStudioApi(
278
316
  assertAccessible(destPath, root, activeProjectRoot);
279
317
 
280
318
  const { generateProject } = await import("@jxsuite/create/generate");
281
- await generateProject(destPath, { name, description, url: siteUrl, adapter });
319
+ await generateProject(destPath, {
320
+ name,
321
+ description,
322
+ url: siteUrl,
323
+ adapter,
324
+ });
282
325
 
283
326
  const config = JSON.parse(await readFile(resolve(destPath, "project.json"), "utf8"));
284
327
  const projectRoot = fwd(relative(root, destPath));
@@ -359,7 +402,9 @@ export async function handleStudioApi(
359
402
  return Response.json({ error: (e as Error).message }, { status: 400 });
360
403
  }
361
404
  try {
362
- const glob = new Bun.Glob("**/*.{json,md}");
405
+ const registry = await getFormatRegistry(scanRoot);
406
+ const componentExts = registry.documentExtensions("component").map((e: string) => e.slice(1));
407
+ const glob = new Bun.Glob(`**/*.{${["json", ...componentExts].join(",")}}`);
363
408
  const components = [];
364
409
  for await (const match of glob.scan({ cwd: scanRoot, dot: false })) {
365
410
  if (
@@ -371,14 +416,13 @@ export async function handleStudioApi(
371
416
  const fp = resolve(scanRoot, match);
372
417
  try {
373
418
  let content;
374
- if (match.endsWith(".md")) {
375
- const source = await readFile(fp, "utf8");
376
- const fmMatch = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
377
- if (!fmMatch) continue;
378
- if (!/^tagName:\s*.+-.+/m.test(fmMatch[1])) continue;
379
- content = transpileJxMarkdown(source);
380
- } else {
419
+ if (match.endsWith(".json")) {
381
420
  content = JSON.parse(await readFile(fp, "utf8"));
421
+ } else {
422
+ const entry = registry.byExtension(extname(match), "parse");
423
+ if (!entry) continue;
424
+ const source = await readFile(fp, "utf8");
425
+ content = (await entry.call("parse", source)) as Record<string, unknown>;
382
426
  }
383
427
  if (content.tagName && content.tagName.includes("-")) {
384
428
  components.push({
@@ -401,7 +445,12 @@ export async function handleStudioApi(
401
445
  return { name, type: typeof d, default: d };
402
446
  }
403
447
  const obj = d as Record<string, unknown>;
404
- return { name, type: obj.type, default: obj.default, format: obj.format };
448
+ return {
449
+ name,
450
+ type: obj.type,
451
+ default: obj.default,
452
+ format: obj.format,
453
+ };
405
454
  }),
406
455
  hasElements: Array.isArray(content.$elements) && content.$elements.length > 0,
407
456
  });
@@ -514,7 +563,7 @@ export async function handleStudioApi(
514
563
  const depPkg = JSON.parse(await readFile(actualPath, "utf8"));
515
564
  packages.push({
516
565
  name,
517
- version: /** @type {string} */ (version),
566
+ version: /** @type {string} */ version,
518
567
  hasCem: !!depPkg.customElements,
519
568
  customElementsPath: depPkg.customElements || null,
520
569
  });
@@ -563,7 +612,11 @@ export async function handleStudioApi(
563
612
  const cwd = dir ? (isAbsolute(dir) ? dir : resolve(root, dir)) : root;
564
613
  const args = ["add", name];
565
614
  if (body.dev) args.splice(1, 0, "-d");
566
- const proc = Bun.spawn(["bun", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
615
+ const proc = Bun.spawn(["bun", ...args], {
616
+ cwd,
617
+ stdout: "pipe",
618
+ stderr: "pipe",
619
+ });
567
620
  const exitCode = await proc.exited;
568
621
  if (exitCode !== 0) {
569
622
  const stderr = await new Response(proc.stderr).text();
@@ -587,7 +640,11 @@ export async function handleStudioApi(
587
640
  return Response.json({ error: "Missing name" }, { status: 400 });
588
641
  const dir = body.dir || activeProjectRoot;
589
642
  const cwd = dir ? (isAbsolute(dir) ? dir : resolve(root, dir)) : root;
590
- const proc = Bun.spawn(["bun", "remove", name], { cwd, stdout: "pipe", stderr: "pipe" });
643
+ const proc = Bun.spawn(["bun", "remove", name], {
644
+ cwd,
645
+ stdout: "pipe",
646
+ stderr: "pipe",
647
+ });
591
648
  const exitCode = await proc.exited;
592
649
  if (exitCode !== 0) {
593
650
  const stderr = await new Response(proc.stderr).text();
@@ -747,6 +804,71 @@ export async function handleStudioApi(
747
804
  }
748
805
 
749
806
  // Discover a plugin module's schema for studio form rendering
807
+ // Format capability proxy — studio fallback for capabilities whose timing excludes "client".
808
+ // Dispatches parse/serialize through the project's format registry by import name.
809
+ if (path === "/__studio/format" && req.method === "POST") {
810
+ try {
811
+ const body = await req.json();
812
+ const { format, action, source, doc, options } = body as {
813
+ format?: string;
814
+ action?: string;
815
+ source?: string;
816
+ doc?: Record<string, unknown>;
817
+ options?: Record<string, unknown>;
818
+ };
819
+ const dir = (body.dir as string | undefined) || activeProjectRoot || root;
820
+ const projectRoot = isAbsolute(dir) ? dir : resolve(root, dir);
821
+ assertAccessible(projectRoot, root, activeProjectRoot);
822
+
823
+ if (!format || !action) {
824
+ return Response.json({ error: "Missing format or action" }, { status: 400 });
825
+ }
826
+ if (action !== "parse" && action !== "serialize") {
827
+ return Response.json({ error: `Unsupported action "${action}"` }, { status: 400 });
828
+ }
829
+ const registry = await getFormatRegistry(projectRoot);
830
+ const entry = registry.byName(format);
831
+ if (!entry) {
832
+ return Response.json(
833
+ { error: `Format "${format}" is not an imported format class` },
834
+ { status: 404 },
835
+ );
836
+ }
837
+ const result =
838
+ action === "parse"
839
+ ? await entry.call("parse", source ?? "", options)
840
+ : await entry.call("serialize", doc ?? {}, options);
841
+ return Response.json({ result });
842
+ } catch (e) {
843
+ return Response.json({ error: (e as Error).message }, { status: 500 });
844
+ }
845
+ }
846
+
847
+ // Format registry listing — lets the studio introspect available formats without
848
+ // fetching each .class.json itself.
849
+ if (path === "/__studio/formats" && req.method === "GET") {
850
+ const dir = url.searchParams.get("dir") || activeProjectRoot || root;
851
+ const projectRoot = isAbsolute(dir) ? dir : resolve(root, dir);
852
+ try {
853
+ assertAccessible(projectRoot, root, activeProjectRoot);
854
+ const registry = await getFormatRegistry(projectRoot);
855
+ return Response.json({
856
+ formats: registry.entries.map((e) => ({
857
+ name: e.name,
858
+ extensions: e.extensions,
859
+ mediaType: e.mediaType,
860
+ documentKinds: e.documentKinds,
861
+ exportTarget: e.exportTarget,
862
+ remote: e.remote,
863
+ studio: e.studio,
864
+ capabilities: e.capabilities,
865
+ })),
866
+ });
867
+ } catch (e) {
868
+ return Response.json({ error: (e as Error).message }, { status: 400 });
869
+ }
870
+ }
871
+
750
872
  if (path === "/__studio/plugin-schema" && req.method === "GET") {
751
873
  const src = url.searchParams.get("src");
752
874
  const prototype = url.searchParams.get("prototype");
@@ -791,7 +913,9 @@ export async function handleStudioApi(
791
913
  try {
792
914
  const content = readFileSync(moduleAbsPath, "utf8");
793
915
  const classDef = JSON.parse(content);
794
- return Response.json({ schema: extractStudioSchema(classDef, moduleAbsPath) });
916
+ return Response.json({
917
+ schema: extractStudioSchema(classDef, moduleAbsPath),
918
+ });
795
919
  } catch (e) {
796
920
  return Response.json({
797
921
  schema: null,
@@ -807,7 +931,9 @@ export async function handleStudioApi(
807
931
  try {
808
932
  const content = readFileSync(classJsonPath, "utf8");
809
933
  const classDef = JSON.parse(content);
810
- return Response.json({ schema: extractStudioSchema(classDef, classJsonPath) });
934
+ return Response.json({
935
+ schema: extractStudioSchema(classDef, classJsonPath),
936
+ });
811
937
  } catch {
812
938
  // Fall through to JS module import
813
939
  }
@@ -818,7 +944,10 @@ export async function handleStudioApi(
818
944
  const mod = await import(moduleAbsPath);
819
945
  const ExportedClass = mod[exportName] ?? mod.default?.[exportName];
820
946
  if (typeof ExportedClass !== "function") {
821
- return Response.json({ schema: null, error: `Export "${exportName}" not found` });
947
+ return Response.json({
948
+ schema: null,
949
+ error: `Export "${exportName}" not found`,
950
+ });
822
951
  }
823
952
  return Response.json({ schema: ExportedClass.schema ?? null });
824
953
  } catch (e) {
@@ -836,7 +965,11 @@ export async function handleStudioApi(
836
965
  const gitCmd = path.slice("/__studio/git/".length);
837
966
 
838
967
  const runGit = async (args: string[]) => {
839
- const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
968
+ const proc = Bun.spawn(["git", ...args], {
969
+ cwd,
970
+ stdout: "pipe",
971
+ stderr: "pipe",
972
+ });
840
973
  const exitCode = await proc.exited;
841
974
  const stdout = await new Response(proc.stdout).text();
842
975
  const stderr = await new Response(proc.stderr).text();
@@ -1004,7 +1137,7 @@ export async function handleStudioApi(
1004
1137
  if (!fp) return Response.json({ error: "Missing path" }, { status: 400 });
1005
1138
  if (fp.includes("..")) return Response.json({ error: "Invalid path" }, { status: 400 });
1006
1139
  const content = await runGit(["show", `${ref}:${fp}`]);
1007
- return Response.json({ content, format: fp.endsWith(".md") ? "markdown" : "json" });
1140
+ return Response.json({ content });
1008
1141
  }
1009
1142
 
1010
1143
  if (gitCmd === "discard" && req.method === "POST") {
@@ -1183,10 +1316,31 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
1183
1316
 
1184
1317
  const resolveMethod = classDef.$defs?.methods?.resolve;
1185
1318
 
1319
+ // Surface format-extension metadata: the format block, studio hints, and a
1320
+ // capability summary ({ parse: { timing }, serialize: { timing }, ... }).
1321
+ const def = classDef as Record<string, unknown>;
1322
+ const capabilityRoles = new Set(["parse", "serialize", "discover", "load"]);
1323
+ const capabilities: Record<string, { identifier: string; timing: string[] }> = {};
1324
+ const methods = (classDef.$defs?.methods ?? {}) as Record<
1325
+ string,
1326
+ { role?: string; identifier?: string; timing?: string[] }
1327
+ >;
1328
+ for (const [key, method] of Object.entries(methods)) {
1329
+ if (method.role && capabilityRoles.has(method.role)) {
1330
+ capabilities[method.role] = {
1331
+ identifier: method.identifier ?? key,
1332
+ timing: method.timing ?? ["compiler", "server"],
1333
+ };
1334
+ }
1335
+ }
1336
+
1186
1337
  return {
1187
1338
  description: classDef.description ?? classDef.title,
1188
1339
  properties,
1189
1340
  required: [...requiredSet],
1190
1341
  ...(resolveMethod?.returns ? { returns: resolveMethod.returns } : {}),
1342
+ ...(def.format ? { format: def.format } : {}),
1343
+ ...(def.$studio ? { $studio: def.$studio } : {}),
1344
+ ...(Object.keys(capabilities).length > 0 ? { capabilities } : {}),
1191
1345
  };
1192
1346
  }
package/src/watch.ts CHANGED
@@ -67,7 +67,11 @@ export function injectSSE(html: string) {
67
67
  export function createWatcher(
68
68
  root: string,
69
69
  builds: BuildEntry[],
70
- opts: { ignore?: string[]; debounce?: number; reloadOnAnyChange?: boolean } = {},
70
+ opts: {
71
+ ignore?: string[];
72
+ debounce?: number;
73
+ reloadOnAnyChange?: boolean;
74
+ } = {},
71
75
  ) {
72
76
  const ignore = opts.ignore ?? DEFAULT_IGNORE;
73
77
  const debounceMs = opts.debounce ?? 50;