@jxsuite/server 0.37.1 → 1.1.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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Dev-vars — `<project>/.dev.vars` parsing (wrangler convention, specs/extensions.md §13).
3
+ *
4
+ * Secret VALUES never enter project.json; locally they live in the git-ignored .dev.vars file and
5
+ * the dev server merges them over process.env when constructing mount environments. The format is
6
+ * dotenv-shaped: KEY=VALUE lines, `#` comments, optional single/double quotes. The compiler CLI
7
+ * keeps its own tiny copy (db-push.ts) — the compiler cannot depend on this package.
8
+ */
9
+
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+
13
+ /**
14
+ * Parse .dev.vars text into a name → value record.
15
+ *
16
+ * @param {string} text
17
+ * @returns {Record<string, string>}
18
+ */
19
+ export function parseDevVars(text: string): Record<string, string> {
20
+ const out: Record<string, string> = {};
21
+ for (const rawLine of text.split("\n")) {
22
+ const line = rawLine.trim();
23
+ if (line === "" || line.startsWith("#")) {
24
+ continue;
25
+ }
26
+ const eq = line.indexOf("=");
27
+ if (eq === -1) {
28
+ continue;
29
+ }
30
+ const key = line.slice(0, eq).trim();
31
+ let value = line.slice(eq + 1).trim();
32
+ if (
33
+ (value.startsWith('"') && value.endsWith('"')) ||
34
+ (value.startsWith("'") && value.endsWith("'"))
35
+ ) {
36
+ value = value.slice(1, -1);
37
+ }
38
+ if (key !== "") {
39
+ out[key] = value;
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /**
46
+ * Load `<projectRoot>/.dev.vars`, or an empty record when absent.
47
+ *
48
+ * @param {string} projectRoot
49
+ * @returns {Record<string, string>}
50
+ */
51
+ export function loadDevVars(projectRoot: string): Record<string, string> {
52
+ const path = resolve(projectRoot, ".dev.vars");
53
+ if (!existsSync(path)) {
54
+ return {};
55
+ }
56
+ try {
57
+ return parseDevVars(readFileSync(path, "utf8"));
58
+ } catch {
59
+ return {};
60
+ }
61
+ }
package/src/dev.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Dev-server CLI entry — what `jx dev` spawns under Bun in a standalone project.
3
+ *
4
+ * Usage: bun @jxsuite/server/dev [--root <dir>] [--port <n>]
5
+ *
6
+ * For a site project (a project.json at the root), the entry builds the site up front, serves the
7
+ * built pages from dist/ ahead of the static-source fallback, and rebuilds before each live-reload
8
+ * broadcast so the browser always reloads into fresh output. Argument parsing and the middleware
9
+ * factory are exported for tests; the server boot runs only when executed directly
10
+ * (import.meta.main).
11
+ *
12
+ * @module @jxsuite/server/dev
13
+ */
14
+
15
+ import { existsSync, statSync } from "node:fs";
16
+ import { join, normalize, resolve, sep } from "node:path";
17
+ import { createDevServer } from "./server.ts";
18
+ import { injectSSE } from "./watch.ts";
19
+
20
+ export interface DevArgs {
21
+ port: number;
22
+ root: string;
23
+ }
24
+
25
+ /**
26
+ * Parse `--root`/`--port` (plus an optional positional root) into dev-server options.
27
+ *
28
+ * @param {string[]} argv - Raw arguments (process.argv.slice(2))
29
+ * @returns {DevArgs} Resolved root and port (default 3000)
30
+ */
31
+ export function parseDevArgs(argv: string[]): DevArgs {
32
+ let root = ".";
33
+ let port = 3000;
34
+ for (let index = 0; index < argv.length; index += 1) {
35
+ const arg = argv[index]!;
36
+ if (arg === "--root") {
37
+ index += 1;
38
+ root = argv[index] ?? root;
39
+ } else if (arg === "--port") {
40
+ index += 1;
41
+ const parsed = Math.trunc(Number(argv[index] ?? ""));
42
+ if (Number.isFinite(parsed) && parsed >= 0) {
43
+ port = parsed;
44
+ }
45
+ } else if (!arg.startsWith("--")) {
46
+ root = arg;
47
+ }
48
+ }
49
+ return { port, root: resolve(root) };
50
+ }
51
+
52
+ /**
53
+ * Middleware serving built pages from `<root>/dist` for page-like GET requests. Directory URLs
54
+ * (with or without a trailing slash) map to their index.html; anything not present in dist falls
55
+ * through to the dev server's static-source handling.
56
+ *
57
+ * @param {string} root - Absolute project root
58
+ * @returns {(req: Request, url: URL) => Response | null}
59
+ */
60
+ export function createDistMiddleware(
61
+ root: string,
62
+ ): (req: Request, url: URL) => Promise<Response | null> {
63
+ const distDir = join(root, "dist");
64
+ return async (req, url) => {
65
+ if (req.method !== "GET") {
66
+ return null;
67
+ }
68
+ const safe = normalize(decodeURIComponent(url.pathname)).replaceAll("\\", "/");
69
+ const candidate = resolve(distDir, `.${safe.startsWith("/") ? safe : `/${safe}`}`);
70
+ if (candidate !== distDir && !candidate.startsWith(distDir + sep)) {
71
+ return null;
72
+ }
73
+ let filePath: string | null = null;
74
+ if (existsSync(candidate)) {
75
+ filePath = statSync(candidate).isDirectory() ? join(candidate, "index.html") : candidate;
76
+ } else {
77
+ filePath = join(candidate, "index.html");
78
+ }
79
+ if (!filePath || !existsSync(filePath)) {
80
+ return null;
81
+ }
82
+ // Built pages get the live-reload client, like the server's own .html responses
83
+ if (filePath.endsWith(".html")) {
84
+ const html = await Bun.file(filePath).text();
85
+ return new Response(injectSSE(html), {
86
+ headers: { "Cache-Control": "no-cache", "Content-Type": "text/html; charset=utf-8" },
87
+ });
88
+ }
89
+ return new Response(Bun.file(filePath), { headers: { "Cache-Control": "no-cache" } });
90
+ };
91
+ }
92
+
93
+ export interface DevServerHandle {
94
+ port: number;
95
+ stop: () => void;
96
+ }
97
+
98
+ /**
99
+ * Boot the dev server for a root. Site projects (a project.json at the root) are built up front and
100
+ * rebuilt before every live-reload broadcast, with built pages served from dist/.
101
+ *
102
+ * @param {DevArgs} args - Resolved root and port
103
+ * @returns {Promise<DevServerHandle>} The listening server
104
+ */
105
+ export async function startDev({ port, root }: DevArgs): Promise<DevServerHandle> {
106
+ const isSite = existsSync(join(root, "project.json"));
107
+
108
+ let building = Promise.resolve();
109
+ const rebuild = () => {
110
+ building = building.then(async () => {
111
+ const { buildSite } = await import("@jxsuite/compiler/site");
112
+ const result = await buildSite(root, { verbose: false });
113
+ if (result.errors.length > 0) {
114
+ for (const err of result.errors) {
115
+ console.error(` build error: ${err}`);
116
+ }
117
+ }
118
+ });
119
+ return building;
120
+ };
121
+
122
+ if (isSite) {
123
+ console.log(`Building ${root}...`);
124
+ await rebuild();
125
+ }
126
+
127
+ return (await createDevServer({
128
+ port,
129
+ root,
130
+ ...(isSite && {
131
+ middleware: createDistMiddleware(root),
132
+ watch: { preReload: () => rebuild(), reloadOnAnyChange: true },
133
+ }),
134
+ })) as DevServerHandle;
135
+ }
136
+
137
+ if (import.meta.main) {
138
+ const { port, root } = parseDevArgs(process.argv.slice(2));
139
+ const server = await startDev({ port, root });
140
+ console.log(`Jx dev server running at http://localhost:${server.port} (root: ${root})`);
141
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Jx-mounts — registry-driven `/_jx/*` dispatch for the dev server (specs/extensions.md §11).
3
+ *
4
+ * Extension classes with a `server` block mount the same fetch-style handlers here that the
5
+ * generated site worker mounts in production: one shared context object per project, handlers built
6
+ * once via the static `mount(options, ctx)` capability and dispatched by basePath prefix.
7
+ *
8
+ * Dev conveniences over the production worker:
9
+ *
10
+ * - `env` is process.env merged under the project's `.dev.vars` plus `JX_PROJECT_ROOT`;
11
+ * - Connector classes with `local: "<provider>"` are stood in by the registry's class for that
12
+ * provider (e.g. D1 → sqlite at `<project>/.jx/data/<connection>.sqlite`);
13
+ * - Mount options set `autoSync: true`, so table schemas sync additively on first touch.
14
+ *
15
+ * The runtime is cached per project root and invalidated when project.json changes on disk.
16
+ */
17
+
18
+ import { existsSync, readFileSync, statSync } from "node:fs";
19
+ import { resolve } from "node:path";
20
+ import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
21
+ import { loadDevVars } from "./dev-vars.ts";
22
+ import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
23
+ import type { FormatEntry } from "@jxsuite/schema/format-registry";
24
+ import type { ProjectConfig } from "@jxsuite/schema/types";
25
+
26
+ type MountHandler = (request: Request, env: Record<string, unknown>) => Promise<Response>;
27
+
28
+ interface MountRuntime {
29
+ handlers: { basePath: string; handler: MountHandler }[];
30
+ env: Record<string, unknown>;
31
+ }
32
+
33
+ /** Per-project runtime cache, invalidated when project.json's mtime moves. */
34
+ const runtimeCache = new Map<string, { mtime: number; runtime: Promise<MountRuntime | null> }>();
35
+
36
+ /** Reset the runtime cache (test hook). */
37
+ export function resetJxMounts(): void {
38
+ runtimeCache.clear();
39
+ }
40
+
41
+ /**
42
+ * Dispatch a `/_jx/*` request to the project's extension mounts.
43
+ *
44
+ * @param {Request} req
45
+ * @param {URL} url
46
+ * @param {string} projectRoot - Absolute project root (the active studio project, or the server
47
+ * root)
48
+ * @returns {Promise<Response | null>} A response, or null when no mount claims the path
49
+ */
50
+ export async function handleJxMounts(
51
+ req: Request,
52
+ url: URL,
53
+ projectRoot: string,
54
+ ): Promise<Response | null> {
55
+ if (!url.pathname.startsWith("/_jx/")) {
56
+ return null;
57
+ }
58
+ const runtime = await getRuntime(projectRoot);
59
+ if (!runtime) {
60
+ return null;
61
+ }
62
+ for (const { basePath, handler } of runtime.handlers) {
63
+ if (url.pathname === basePath || url.pathname.startsWith(`${basePath}/`)) {
64
+ return handler(req, runtime.env);
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /** Load (or reuse) the mount runtime for a project root. */
71
+ function getRuntime(projectRoot: string): Promise<MountRuntime | null> {
72
+ const projectJsonPath = resolve(projectRoot, "project.json");
73
+ if (!existsSync(projectJsonPath)) {
74
+ return Promise.resolve(null);
75
+ }
76
+ const { mtimeMs } = statSync(projectJsonPath);
77
+ const cached = runtimeCache.get(projectRoot);
78
+ if (cached && cached.mtime === mtimeMs) {
79
+ return cached.runtime;
80
+ }
81
+ const runtime = buildRuntime(projectRoot, projectJsonPath).catch((error) => {
82
+ console.warn(
83
+ `jx-mounts: failed to build extension mounts for ${projectRoot}:`,
84
+ error instanceof Error ? error.message : String(error),
85
+ );
86
+ return null;
87
+ });
88
+ runtimeCache.set(projectRoot, { mtime: mtimeMs, runtime });
89
+ return runtime;
90
+ }
91
+
92
+ /** Build the runtime: registry, sections, connector stand-ins, and the ordered mount handlers. */
93
+ async function buildRuntime(
94
+ projectRoot: string,
95
+ projectJsonPath: string,
96
+ ): Promise<MountRuntime | null> {
97
+ const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
98
+ const registry = await buildProjectExtensionRegistry(projectRoot, config);
99
+ const mounts = registry.serverMounts();
100
+ if (mounts.length === 0) {
101
+ return null;
102
+ }
103
+
104
+ const sections: Record<string, unknown> = {};
105
+ for (const contribution of registry.projectContributions()) {
106
+ const { key } = contribution.project!;
107
+ if (key in config) {
108
+ sections[key] = config[key];
109
+ }
110
+ }
111
+
112
+ const connectors = await resolveConnectorStandins(registry);
113
+ const env: Record<string, unknown> = {
114
+ ...process.env,
115
+ ...loadDevVars(projectRoot),
116
+ JX_PROJECT_ROOT: projectRoot,
117
+ };
118
+
119
+ // One shared mutable context, passed to every mount in order (specs/extensions.md §11).
120
+ const ctx: Record<string, unknown> = {};
121
+ const handlers: MountRuntime["handlers"] = [];
122
+ for (const entry of mounts) {
123
+ const { basePath } = entry.server!;
124
+ const handler = (await entry.call(
125
+ "mount",
126
+ { autoSync: true, basePath, connectors, sections },
127
+ ctx,
128
+ )) as MountHandler;
129
+ handlers.push({ basePath, handler });
130
+ }
131
+ return { env, handlers };
132
+ }
133
+
134
+ /**
135
+ * Resolve connector implementation classes keyed by provider id, applying the dev-server `local`
136
+ * stand-in rule: a connector declaring `local: "<provider>"` is served by the registry's class for
137
+ * that provider instead of its own (specs/extensions.md §12). Shared with the data-api push seam so
138
+ * section-owner deploySchema contributions (e.g. auth) see the same stand-ins as the mounts.
139
+ *
140
+ * @param {ExtensionRegistry} registry
141
+ * @returns {Promise<Record<string, unknown>>}
142
+ */
143
+ export async function resolveConnectorStandins(
144
+ registry: ExtensionRegistry,
145
+ ): Promise<Record<string, unknown>> {
146
+ const entries = registry.connectors();
147
+ const out: Record<string, unknown> = {};
148
+ for (const entry of entries) {
149
+ const provider = String(entry.connector!.provider);
150
+ const { local } = entry.connector!;
151
+ const target =
152
+ typeof local === "string"
153
+ ? (entries.find((e) => e.connector!.provider === local) ?? entry)
154
+ : entry;
155
+ out[provider] = await implementationClass(target);
156
+ }
157
+ return out;
158
+ }
159
+
160
+ /** Import a connector entry's implementation module and pick the export named by its title. */
161
+ async function implementationClass(entry: FormatEntry): Promise<unknown> {
162
+ const mod = await entry.implementation();
163
+ const title = (entry.classDef.title as string | undefined) ?? entry.name;
164
+ const fromDefault = (mod.default as Record<string, unknown> | undefined)?.[title];
165
+ return mod[title] ?? fromDefault ?? mod.default;
166
+ }
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { isAbsolute, relative, resolve, sep } from "node:path";
29
29
  import { realpathSync } from "node:fs";
30
+ import { handleJxMounts } from "./jx-mounts.ts";
30
31
  import { handleResolve, handleServerFunction } from "./resolve.ts";
31
32
  import { handleAiApi } from "./ai-api.ts";
32
33
  import { handleImportApi } from "./import-api.ts";
@@ -327,6 +328,15 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
327
328
  return new Response("No project", { status: 404 });
328
329
  }
329
330
 
331
+ // 4b. Extension server mounts (/_jx/data etc.) — registry-driven, same wire contract as
332
+ // The generated site worker and the dev server (specs/extensions.md §11).
333
+ if (normPath.startsWith("/_jx/")) {
334
+ const mountRes = await handleJxMounts(req, url, root);
335
+ if (mountRes) {
336
+ return mountRes;
337
+ }
338
+ }
339
+
330
340
  // 5. Privileged HTTP routes (RCE-capable: dynamic import()). Gate with token + loopback
331
341
  // Origin/Host BEFORE dispatch.
332
342
  if (
package/src/resolve.ts CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  import { dirname, relative, resolve } from "node:path";
4
4
  import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
5
- import { existsSync, readFileSync } from "node:fs";
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
6
  import { createRequire } from "node:module";
7
- import { loadContentTypes } from "@jxsuite/compiler/content-loader";
7
+ import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
8
+ import { loadProjectSections } from "@jxsuite/compiler/project-sections";
8
9
  import type { DynamicClass } from "@jxsuite/runtime/types";
9
10
  import type { JxClassDef, ProjectConfig } from "@jxsuite/schema/types";
10
11
 
@@ -35,15 +36,16 @@ interface ServerFunctionBody {
35
36
  [key: string]: unknown;
36
37
  }
37
38
 
39
+ /** Per-project context cache, invalidated when project.json changes on disk. */
40
+ const projectContextCache = new Map<string, { mtime: number; context: Record<string, unknown> }>();
41
+
38
42
  /**
39
- * Lazy-load project context (project.json + content types) for class instantiation.
43
+ * Lazy-load project context (project.json + extension-loaded sections) for class instantiation.
44
+ * Sections land under their section keys (e.g. `content` for the parser), matching the compiler's
45
+ * `_project` shape.
40
46
  *
41
47
  * @param {string} projectRoot
42
- * @returns {Promise<{
43
- * config: Record<string, unknown>;
44
- * contentTypes: Map<string, unknown[]>;
45
- * root: string;
46
- * } | null>}
48
+ * @returns {Promise<Record<string, unknown> | null>} `{ config, root, ...sections }` or null
47
49
  */
48
50
  async function loadProjectContext(projectRoot: string) {
49
51
  const projectJsonPath = resolve(projectRoot, "project.json");
@@ -51,11 +53,17 @@ async function loadProjectContext(projectRoot: string) {
51
53
  return null;
52
54
  }
53
55
  try {
56
+ const { mtimeMs } = statSync(projectJsonPath);
57
+ const cached = projectContextCache.get(projectRoot);
58
+ if (cached && cached.mtime === mtimeMs) {
59
+ return cached.context;
60
+ }
54
61
  const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
55
- const contentTypes = config.contentTypes
56
- ? await loadContentTypes(projectRoot, config)
57
- : new Map();
58
- return { config, contentTypes, root: projectRoot };
62
+ const registry = await buildProjectExtensionRegistry(projectRoot, config);
63
+ const sections = await loadProjectSections(projectRoot, config, registry);
64
+ const context = { config, root: projectRoot, ...sections };
65
+ projectContextCache.set(projectRoot, { context, mtime: mtimeMs });
66
+ return context;
59
67
  } catch {
60
68
  return null;
61
69
  }
package/src/server.ts CHANGED
@@ -17,6 +17,7 @@ import { join, relative, resolve } from "node:path";
17
17
  import { buildAll } from "./build.ts";
18
18
  import { createCollabRegistry } from "./collab.ts";
19
19
  import { createWatcher, injectSSE } from "./watch.ts";
20
+ import { handleJxMounts } from "./jx-mounts.ts";
20
21
  import { handleResolve, handleServerFunction } from "./resolve.ts";
21
22
  import { assertAccessible, handleStudioApi } from "./studio-api.ts";
22
23
  import { handleCodeApi } from "./code-api.ts";
@@ -268,6 +269,15 @@ export async function createDevServer(options: {
268
269
  return handleServerFunction(req, absRoot);
269
270
  }
270
271
 
272
+ // Extension server mounts (/_jx/data etc.) — registry-driven, same wire contract as the
273
+ // Generated site worker (specs/extensions.md §11).
274
+ if (path.startsWith("/_jx/")) {
275
+ const mountRes = await handleJxMounts(req, url, activeProjectRoot ?? absRoot);
276
+ if (mountRes) {
277
+ return mountRes;
278
+ }
279
+ }
280
+
271
281
  // Studio filesystem API
272
282
  if (enableStudio && path.startsWith("/__studio/")) {
273
283
  // Realtime co-editing: WebSocket upgrade or capability probe
package/src/studio-api.ts CHANGED
@@ -11,7 +11,12 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from
11
11
  import { errorMessage } from "@jxsuite/schema/parse";
12
12
  import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
13
13
  import { existsSync, readFileSync, statSync } from "node:fs";
14
- import { buildProjectFormatRegistry } from "@jxsuite/compiler/format-host";
14
+ import {
15
+ buildExtensionsPayload,
16
+ buildProjectExtensionRegistry,
17
+ } from "@jxsuite/compiler/format-host";
18
+ import { readBundledProjectSchemas } from "@jxsuite/compiler/schema-command";
19
+ import { handleDataApi } from "./data-api.ts";
15
20
  import { applyRename } from "./refactor/apply.ts";
16
21
  import {
17
22
  bunExecutable,
@@ -20,6 +25,7 @@ import {
20
25
  outdatedPackages,
21
26
  setPackageVersions,
22
27
  } from "./packages.ts";
28
+ import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
23
29
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
24
30
  import type { ClassJsonDef } from "./types.ts";
25
31
  import type { DesignOptions } from "@jxsuite/create/generate";
@@ -113,18 +119,18 @@ function collectSlotDefs(node: unknown, out: SlotDef[] = []): SlotDef[] {
113
119
  return out;
114
120
  }
115
121
 
116
- // ─── Format registry (per project root, invalidated on project.json change) ──
122
+ // ─── Extension registry (per project root, invalidated on project.json change) ──
117
123
 
118
- const formatRegistryCache = new Map<string, { mtime: number; registry: FormatRegistry }>();
124
+ const extensionRegistryCache = new Map<string, { mtime: number; registry: ExtensionRegistry }>();
119
125
 
120
126
  /**
121
- * Build (or reuse) the format registry for a project root from its project.json imports map. An
122
- * empty registry is returned when there is no project.json — only .json files are handled then.
127
+ * Build (or reuse) a project's extension registry (built from its project.json `extensions` array).
128
+ * An empty registry is returned when there is no project.json — only .json files are handled then.
123
129
  *
124
130
  * @param {string} projectRoot
125
- * @returns {Promise<FormatRegistry>}
131
+ * @returns {Promise<ExtensionRegistry>}
126
132
  */
127
- async function getFormatRegistry(projectRoot: string): Promise<FormatRegistry> {
133
+ async function getExtensionRegistry(projectRoot: string): Promise<ExtensionRegistry> {
128
134
  const projectJsonPath = resolve(projectRoot, "project.json");
129
135
  let mtime = 0;
130
136
  let projectConfig: ProjectConfig | undefined;
@@ -134,15 +140,21 @@ async function getFormatRegistry(projectRoot: string): Promise<FormatRegistry> {
134
140
  } catch {
135
141
  projectConfig = undefined;
136
142
  }
137
- const cached = formatRegistryCache.get(projectRoot);
143
+ const cached = extensionRegistryCache.get(projectRoot);
138
144
  if (cached && cached.mtime === mtime) {
139
145
  return cached.registry;
140
146
  }
141
- const registry = await buildProjectFormatRegistry(projectRoot, projectConfig);
142
- formatRegistryCache.set(projectRoot, { mtime, registry });
147
+ const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
148
+ extensionRegistryCache.set(projectRoot, { mtime, registry });
143
149
  return registry;
144
150
  }
145
151
 
152
+ /** Format-dispatch view of {@link getExtensionRegistry} (parse/serialize/discover/load lookups). */
153
+ async function getFormatRegistry(projectRoot: string): Promise<FormatRegistry> {
154
+ const registry = await getExtensionRegistry(projectRoot);
155
+ return registry.formats;
156
+ }
157
+
146
158
  /**
147
159
  * Check that a path is under either the server root OR the active project root. This allows file
148
160
  * operations on external projects that have been explicitly activated via /__studio/activate.
@@ -260,6 +272,12 @@ export async function handleStudioApi(
260
272
  ) {
261
273
  const path = url.pathname;
262
274
 
275
+ // Data-domain owner console + secrets (names only) — delegated to data-api.ts.
276
+ const dataRes = await handleDataApi(req, url, root, activeProjectRoot);
277
+ if (dataRes) {
278
+ return dataRes;
279
+ }
280
+
263
281
  // Project metadata
264
282
  if (path === "/__studio/project" && req.method === "GET") {
265
283
  try {
@@ -1154,15 +1172,18 @@ export async function handleStudioApi(
1154
1172
  }
1155
1173
 
1156
1174
  // Format registry listing — lets the studio introspect available formats without
1157
- // Fetching each .class.json itself.
1175
+ // Fetching each .class.json itself. The sibling `extensions` array carries each enabled
1176
+ // Extension's manifest identity and project-section contributions (additive; the `formats`
1177
+ // Shape is unchanged for compat).
1158
1178
  if (path === "/__studio/formats" && req.method === "GET") {
1159
1179
  const dir = url.searchParams.get("dir") || activeProjectRoot || root;
1160
1180
  const projectRoot = isAbsolute(dir) ? dir : resolve(root, dir);
1161
1181
  try {
1162
1182
  assertAccessible(projectRoot, root, activeProjectRoot);
1163
- const registry = await getFormatRegistry(projectRoot);
1183
+ const registry = await getExtensionRegistry(projectRoot);
1164
1184
  return Response.json({
1165
- formats: registry.entries.map((e) => ({
1185
+ extensions: buildExtensionsPayload(registry),
1186
+ formats: registry.formats.entries.map((e) => ({
1166
1187
  capabilities: e.capabilities,
1167
1188
  documentKinds: e.documentKinds,
1168
1189
  exportTarget: e.exportTarget,
@@ -1178,6 +1199,25 @@ export async function handleStudioApi(
1178
1199
  }
1179
1200
  }
1180
1201
 
1202
+ // Pre-bundled per-project entry schemas (project.schema.json / document.schema.json) for the
1203
+ // Studio's editor — regenerated on demand when missing or older than project.json, then bundled
1204
+ // Into self-contained compound documents so no relative $ref resolution is needed client-side.
1205
+ if (path === "/__studio/project-schemas" && req.method === "GET") {
1206
+ const dir = url.searchParams.get("dir") || activeProjectRoot || root;
1207
+ const projectRoot = isAbsolute(dir) ? dir : resolve(root, dir);
1208
+ try {
1209
+ assertAccessible(projectRoot, root, activeProjectRoot);
1210
+ } catch (error) {
1211
+ return Response.json({ error: errorMessage(error) }, { status: 400 });
1212
+ }
1213
+ try {
1214
+ const { document, project } = await readBundledProjectSchemas(projectRoot);
1215
+ return Response.json({ document, project });
1216
+ } catch (error) {
1217
+ return Response.json({ error: errorMessage(error) }, { status: 500 });
1218
+ }
1219
+ }
1220
+
1181
1221
  if (path === "/__studio/plugin-schema" && req.method === "GET") {
1182
1222
  const src = url.searchParams.get("src");
1183
1223
  const prototype = url.searchParams.get("prototype");
package/src/watch.ts CHANGED
@@ -10,6 +10,7 @@ import type { FsEventPayload } from "./refactor/fs-events.ts";
10
10
  const DEFAULT_IGNORE = [
11
11
  "**/node_modules/**",
12
12
  "**/dist/**",
13
+ "**/.cache/**",
13
14
  "**/.git/**",
14
15
  "**/.jx/**",
15
16
  "**/.devenv/**",
@@ -62,7 +63,12 @@ export function injectSSE(html: string) {
62
63
  *
63
64
  * @param {string} root - Absolute path to watch
64
65
  * @param {BuildEntry[]} builds - Build entries (for selective rebuild)
65
- * @param {{ ignore?: string[]; debounce?: number; reloadOnAnyChange?: boolean }} [opts]
66
+ * @param {{
67
+ * ignore?: string[];
68
+ * debounce?: number;
69
+ * reloadOnAnyChange?: boolean;
70
+ * preReload?: (filename: string) => Promise<void> | void;
71
+ * }} [opts]
66
72
  * @returns {{
67
73
  * broadcast: () => void;
68
74
  * handleSSE: () => Response;
@@ -76,11 +82,14 @@ export function createWatcher(
76
82
  ignore?: string[];
77
83
  debounce?: number;
78
84
  reloadOnAnyChange?: boolean;
85
+ /** Runs before the reload broadcast — e.g. `jx dev` rebuilds the site here. */
86
+ preReload?: (filename: string) => Promise<void> | void;
79
87
  } = {},
80
88
  ) {
81
89
  const ignore = opts.ignore ?? DEFAULT_IGNORE;
82
90
  const debounceMs = opts.debounce ?? 50;
83
91
  const reloadOnAnyChange = opts.reloadOnAnyChange ?? false;
92
+ const { preReload } = opts;
84
93
 
85
94
  const clients = new Set<(msg: string) => void>();
86
95
  const encoder = new TextEncoder();
@@ -172,6 +181,13 @@ export function createWatcher(
172
181
 
173
182
  clearTimeout(timer ?? undefined);
174
183
  timer = setTimeout(async () => {
184
+ if (preReload) {
185
+ try {
186
+ await preReload(filename);
187
+ } catch (error) {
188
+ console.error(`[watch] preReload failed: ${(error as Error).message}`);
189
+ }
190
+ }
175
191
  if (builds.length > 0) {
176
192
  const result = await rebuild(builds, filename);
177
193
  if (!result.success) {