@jxsuite/server 0.37.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -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");