@jxsuite/server 4.4.5 → 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/src/dev.ts CHANGED
@@ -124,6 +124,9 @@ export async function startDev({ port, root }: DevArgs): Promise<DevServerHandle
124
124
  await rebuild();
125
125
  }
126
126
 
127
+ /* SAFETY: createDevServer always calls Bun.serve with a hostname and TCP port, never a unix
128
+ socket, so the returned Server's `port` (typed `number | undefined` for the socket case) is a
129
+ number; its promise-returning `stop()` satisfies `() => void`. */
127
130
  return (await createDevServer({
128
131
  port,
129
132
  root,
@@ -25,12 +25,11 @@
25
25
  import { existsSync, readFileSync } from "node:fs";
26
26
  import { resolve } from "node:path";
27
27
  import { listCatalog } from "@jxsuite/catalog";
28
- import { buildExtensionRegistry } from "@jxsuite/schema/extension-registry";
28
+ import { buildExtensionRegistry, EXTENSION_MANIFEST } from "@jxsuite/schema/extension-registry";
29
+ import { isJsonObject, isString } from "@jxsuite/schema/guards";
29
30
  import { createNodeFormatIO, probeExtension } from "@jxsuite/compiler/format-host";
30
31
  import type { ExtensionCatalogEntry, ExtensionSectionInfo } from "@jxsuite/protocol";
31
-
32
- /** The well-known manifest filename, mirroring `@jxsuite/schema`'s `EXTENSION_MANIFEST`. */
33
- const MANIFEST = "jx-extension.json";
32
+ import type { JsonValue } from "@jxsuite/schema/types";
34
33
 
35
34
  interface PackageJson {
36
35
  dependencies?: Record<string, string>;
@@ -45,6 +44,9 @@ function readPackageJson(projectRoot: string): PackageJson | null {
45
44
  return null;
46
45
  }
47
46
  try {
47
+ /* SAFETY: package.json is the npm manifest the project's installer reads, so its
48
+ `dependencies`/`devDependencies` are objects keyed by package name (only their keys are read
49
+ here); text that is not JSON throws into the catch below. */
48
50
  return JSON.parse(readFileSync(path, "utf8")) as PackageJson;
49
51
  } catch {
50
52
  // A project mid-edit should cost the catalogue its discovered half, not the whole response.
@@ -60,6 +62,14 @@ function declaredDependencies(projectRoot: string): string[] {
60
62
  : [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})];
61
63
  }
62
64
 
65
+ /** What the registry reports for one extension that loaded. */
66
+ interface LoadedEntry {
67
+ description?: string;
68
+ formats: string[];
69
+ sections: ExtensionSectionInfo[];
70
+ title?: string;
71
+ }
72
+
63
73
  /**
64
74
  * Load one extension through the REGISTRY rather than reading its manifest directly.
65
75
  *
@@ -75,10 +85,7 @@ function declaredDependencies(projectRoot: string): string[] {
75
85
  async function loadEntry(
76
86
  projectRoot: string,
77
87
  name: string,
78
- ): Promise<
79
- | { description?: string; formats: string[]; sections: ExtensionSectionInfo[]; title?: string }
80
- | { problem: string }
81
- > {
88
+ ): Promise<LoadedEntry | { problem: string }> {
82
89
  try {
83
90
  const registry = await buildExtensionRegistry(
84
91
  [name],
@@ -94,20 +101,25 @@ async function loadEntry(
94
101
  for (const cls of info.classes) {
95
102
  if (cls.project) {
96
103
  const { key, title } = cls.project;
97
- sections.push({ key, ...(typeof title === "string" ? { title } : {}) });
104
+ const section: ExtensionSectionInfo = { key };
105
+ // The descriptor is the package's own JSON, so its declared string type is not trusted
106
+ if (isString(title)) {
107
+ section.title = title;
108
+ }
109
+ sections.push(section);
98
110
  }
99
111
  for (const ext of cls.extensions) {
100
112
  formats.add(ext);
101
113
  }
102
114
  }
103
- return {
104
- formats: [...formats].toSorted(),
105
- sections,
106
- ...(info.manifest.title === undefined ? {} : { title: info.manifest.title }),
107
- ...(info.manifest.description === undefined
108
- ? {}
109
- : { description: info.manifest.description }),
110
- };
115
+ const loaded: LoadedEntry = { formats: [...formats].toSorted(), sections };
116
+ if (info.manifest.title !== undefined) {
117
+ loaded.title = info.manifest.title;
118
+ }
119
+ if (info.manifest.description !== undefined) {
120
+ loaded.description = info.manifest.description;
121
+ }
122
+ return loaded;
111
123
  } catch (error) {
112
124
  return { problem: error instanceof Error ? error.message : String(error) };
113
125
  }
@@ -131,7 +143,9 @@ function declaresJxField(projectRoot: string, name: string): boolean {
131
143
  return false;
132
144
  }
133
145
  try {
134
- return typeof (JSON.parse(readFileSync(path, "utf8")) as PackageJson).jx === "string";
146
+ // SAFETY: JSON.parse without a reviver only yields JSON values; its `any` says nothing more.
147
+ const manifest = JSON.parse(readFileSync(path, "utf8")) as JsonValue;
148
+ return isJsonObject(manifest) && isString(manifest.jx);
135
149
  } catch {
136
150
  return false;
137
151
  }
@@ -155,7 +169,7 @@ export async function buildExtensionCatalog(projectRoot: string): Promise<Extens
155
169
  for (const meta of listCatalog()) {
156
170
  seen.add(meta.name);
157
171
  const found = probeExtension(projectRoot, meta.name);
158
- entries.push({
172
+ const entry: ExtensionCatalogEntry = {
159
173
  description: meta.description,
160
174
  docs: meta.docs,
161
175
  installed: found.project,
@@ -163,12 +177,19 @@ export async function buildExtensionCatalog(projectRoot: string): Promise<Extens
163
177
  sections: meta.sections,
164
178
  source: "first-party",
165
179
  title: meta.title,
166
- ...(meta.formats ? { formats: meta.formats } : {}),
167
- ...(meta.requires ? { requires: meta.requires } : {}),
168
- // Bundled means "resolves without a project install": the host answers for it, and the
169
- // Project does not have to.
170
- ...(found.host && !found.project ? { bundled: true } : {}),
171
- });
180
+ };
181
+ if (meta.formats) {
182
+ entry.formats = meta.formats;
183
+ }
184
+ if (meta.requires) {
185
+ entry.requires = meta.requires;
186
+ }
187
+ // Bundled means "resolves without a project install": the host answers for it, and the
188
+ // Project does not have to.
189
+ if (found.host && !found.project) {
190
+ entry.bundled = true;
191
+ }
192
+ entries.push(entry);
172
193
  }
173
194
 
174
195
  for (const name of declaredDependencies(projectRoot)) {
@@ -183,7 +204,7 @@ export async function buildExtensionCatalog(projectRoot: string): Promise<Extens
183
204
  installed: true,
184
205
  name,
185
206
  problem:
186
- `declares "jx" but does not export "./${MANIFEST}", so no host can resolve its ` +
207
+ `declares "jx" but does not export "./${EXTENSION_MANIFEST}", so no host can resolve its ` +
187
208
  `manifest — add that entry to its exports map`,
188
209
  sections: [],
189
210
  source: "project",
@@ -202,16 +223,25 @@ export async function buildExtensionCatalog(projectRoot: string): Promise<Extens
202
223
  });
203
224
  continue;
204
225
  }
205
- entries.push({
226
+ const entry: ExtensionCatalogEntry = {
206
227
  installed: true,
207
228
  name,
208
229
  sections: loaded.sections,
209
230
  source: "project",
210
- ...(loaded.title === undefined ? {} : { title: loaded.title }),
211
- ...(loaded.description === undefined ? {} : { description: loaded.description }),
212
- ...(loaded.formats.length > 0 ? { formats: loaded.formats } : {}),
213
- ...(found.host ? { bundled: true } : {}),
214
- });
231
+ };
232
+ if (loaded.title !== undefined) {
233
+ entry.title = loaded.title;
234
+ }
235
+ if (loaded.description !== undefined) {
236
+ entry.description = loaded.description;
237
+ }
238
+ if (loaded.formats.length > 0) {
239
+ entry.formats = loaded.formats;
240
+ }
241
+ if (found.host) {
242
+ entry.bundled = true;
243
+ }
244
+ entries.push(entry);
215
245
  }
216
246
 
217
247
  return entries;
package/src/import-api.ts CHANGED
@@ -7,15 +7,18 @@
7
7
  * Electrobun services server, Chromium loopback server) can mount it unchanged.
8
8
  *
9
9
  * Stream protocol (one JSON object per line):
10
- * {"type":"progress","phase":"...","message":"...","current":n,"total":n}
11
- * {"type":"ready","root":"..."} — the destination exists and is openable
12
- * {"type":"heartbeat"} — keep-alives during silent phases
13
- * {"type":"done","root":"...","config":{…},"result":{…}} — terminal success
14
- * {"type":"error","error":"..."} — terminal failure
10
+ *
11
+ * ```text
12
+ * {"type":"progress","phase":"...","message":"...","current":n,"total":n}
13
+ * {"type":"ready","root":"..."} — the destination exists and is openable
14
+ * {"type":"heartbeat"} — keep-alives during silent phases
15
+ * {"type":"done","root":"...","config":{…},"result":{…}} — terminal success
16
+ * {"type":"error","error":"..."} — terminal failure
17
+ * ```
15
18
  *
16
19
  * `ready` arrives seconds in, before the browser launches; `done` arrives minutes later. They are
17
- * two lines rather than one because they answer different questions — "where is it" and "what did it
18
- * find" — and a caller that waits for the second to learn the first spends the whole crawl with
20
+ * two lines rather than one because they answer different questions — "where is it" and "what did
21
+ * it find" — and a caller that waits for the second to learn the first spends the whole crawl with
19
22
  * nothing to show.
20
23
  *
21
24
  * LLM key flow (matches ai-api.ts): X-Api-Key / Authorization: Bearer header, falling back to the
@@ -30,7 +33,11 @@ import { readFile } from "node:fs/promises";
30
33
  import { resolve } from "node:path";
31
34
  import { problem } from "./problem.ts";
32
35
  import type { BreakpointPolicy, BreakpointRounding } from "@jxsuite/import/breakpoint-plan";
36
+ import type { ImportProgressEvent } from "@jxsuite/import/pipeline";
37
+ import type { ImportSiteOptions, ImportSiteResult } from "@jxsuite/import/run";
38
+ import { IMPORT_MAX_PAGES } from "@jxsuite/protocol";
33
39
  import type { ImportBreakpointPolicy } from "@jxsuite/protocol";
40
+ import type { JsonObject } from "@jxsuite/schema/types";
34
41
 
35
42
  export interface ImportApiOptions {
36
43
  /**
@@ -44,6 +51,14 @@ export interface ImportApiOptions {
44
51
  chromePath?: string;
45
52
  }
46
53
 
54
+ /**
55
+ * The breakpoint policy as a request body carries it: the protocol shape, except that nothing has
56
+ * confirmed `mode` is one of the three until `readBreakpointPolicy` checks it.
57
+ */
58
+ interface RequestedBreakpointPolicy extends Omit<ImportBreakpointPolicy, "mode"> {
59
+ mode?: string;
60
+ }
61
+
47
62
  interface ImportRequestBody {
48
63
  url?: string;
49
64
  directory?: string;
@@ -55,14 +70,35 @@ interface ImportRequestBody {
55
70
  verify?: boolean;
56
71
  verifyThreshold?: number;
57
72
  verifyMinFidelity?: number;
58
- breakpoints?: ImportBreakpointPolicy;
73
+ breakpoints?: RequestedBreakpointPolicy;
74
+ }
75
+
76
+ /** What the `done` line reports about the run. */
77
+ interface ImportDoneSummary {
78
+ pages: ImportSiteResult["pages"];
79
+ fileCount: number;
80
+ warnings: string[];
81
+ verify?: NonNullable<ImportSiteResult["verify"]>;
82
+ }
83
+
84
+ /** One NDJSON line of the import stream (the protocol in this module's header). */
85
+ type ImportStreamLine =
86
+ | { type: "heartbeat" }
87
+ | { type: "ready"; root: string }
88
+ | ({ type: "progress" } & Omit<ImportProgressEvent, "root">)
89
+ | { type: "done"; root: string; config: JsonObject; result: ImportDoneSummary }
90
+ | { type: "error"; error: string };
91
+
92
+ /** The AI pass's credentials, resolved from the request headers or the environment. */
93
+ interface AiConfig {
94
+ apiKey: string | null;
95
+ baseUrl: string | undefined;
59
96
  }
60
97
 
61
98
  /** Seconds between keep-alive lines while a phase is silent (browser launch is the longest gap). */
62
99
  const HEARTBEAT_INTERVAL_MS = 15_000;
63
100
 
64
101
  const MAX_DEPTH = 5;
65
- const MAX_PAGES = 100;
66
102
 
67
103
  /** Pixelmatch colour-tolerance bounds for the optional verify pass. */
68
104
  const MIN_THRESHOLD = 0.01;
@@ -92,7 +128,7 @@ function clamp(value: number, min: number, max: number): number {
92
128
  * `mode` that is not one of the three.
93
129
  */
94
130
  function readBreakpointPolicy(
95
- policy: ImportBreakpointPolicy | undefined,
131
+ policy: RequestedBreakpointPolicy | undefined,
96
132
  ): BreakpointPolicy | undefined {
97
133
  if (!policy) {
98
134
  return undefined;
@@ -118,7 +154,7 @@ function readBreakpointPolicy(
118
154
  return undefined;
119
155
  }
120
156
 
121
- function getAiConfig(req: Request): { apiKey: string | null; baseUrl: string | undefined } {
157
+ function getAiConfig(req: Request): AiConfig {
122
158
  const authHeader = req.headers.get("Authorization") || "";
123
159
  const apiKeyHeader = req.headers.get("X-Api-Key") || "";
124
160
  const baseUrlHeader = req.headers.get("X-Api-Base-URL") || "";
@@ -142,6 +178,9 @@ function getAiConfig(req: Request): { apiKey: string | null; baseUrl: string | u
142
178
  async function handleImportSite(req: Request, opts: ImportApiOptions): Promise<Response> {
143
179
  let body: ImportRequestBody;
144
180
  try {
181
+ /* SAFETY: Studio's import dialog posts this body (`ImportSiteRequest` in @jxsuite/protocol);
182
+ `url` and `directory` are required and checked just below, every number is re-clamped, and
183
+ the breakpoint policy is re-read field by field in readBreakpointPolicy. */
145
184
  body = (await req.json()) as ImportRequestBody;
146
185
  } catch {
147
186
  return problem("invalidRequest", "Invalid JSON body");
@@ -188,7 +227,7 @@ async function handleImportSite(req: Request, opts: ImportApiOptions): Promise<R
188
227
  ),
189
228
  }
190
229
  : false;
191
- const maxPages = clamp(body.maxPages ?? 20, 1, MAX_PAGES);
230
+ const maxPages = clamp(body.maxPages ?? 20, 1, IMPORT_MAX_PAGES);
192
231
  const maxNodesPerPage = clamp(body.maxNodesPerPage ?? 5000, 100, 50_000);
193
232
  const breakpoints = readBreakpointPolicy(body.breakpoints);
194
233
  const { apiKey, baseUrl } = getAiConfig(req);
@@ -197,15 +236,16 @@ async function handleImportSite(req: Request, opts: ImportApiOptions): Promise<R
197
236
  const encoder = new TextEncoder();
198
237
  const stream = new ReadableStream({
199
238
  async start(controller) {
200
- let closed = false;
201
- const writeLine = (obj: unknown) => {
202
- if (closed) {
239
+ // Held in an object because writeLine closes it from inside a callback
240
+ const sink = { closed: false };
241
+ const writeLine = (line: ImportStreamLine) => {
242
+ if (sink.closed) {
203
243
  return;
204
244
  }
205
245
  try {
206
- controller.enqueue(encoder.encode(`${JSON.stringify(obj)}\n`));
246
+ controller.enqueue(encoder.encode(`${JSON.stringify(line)}\n`));
207
247
  } catch {
208
- closed = true;
248
+ sink.closed = true;
209
249
  }
210
250
  };
211
251
  const heartbeat = setInterval(() => writeLine({ type: "heartbeat" }), HEARTBEAT_INTERVAL_MS);
@@ -230,59 +270,61 @@ async function handleImportSite(req: Request, opts: ImportApiOptions): Promise<R
230
270
  }
231
271
  }
232
272
 
233
- const result = await importSite(
234
- {
235
- url,
236
- outDir: destPath,
237
- maxDepth: depth,
238
- maxPages,
239
- maxNodesPerPage,
240
- ai,
241
- verify,
242
- signal: req.signal,
243
- ...(breakpoints === undefined ? {} : { breakpoints }),
244
- ...(opts.chromePath === undefined ? {} : { chromePath: opts.chromePath }),
245
- },
246
- (e) => {
247
- /* The seed event carries the root, and it is the ONE thing a caller cannot wait for the
273
+ const importOptions: ImportSiteOptions = {
274
+ url,
275
+ outDir: destPath,
276
+ maxDepth: depth,
277
+ maxPages,
278
+ maxNodesPerPage,
279
+ ai,
280
+ verify,
281
+ signal: req.signal,
282
+ };
283
+ if (breakpoints !== undefined) {
284
+ importOptions.breakpoints = breakpoints;
285
+ }
286
+ if (opts.chromePath !== undefined) {
287
+ importOptions.chromePath = opts.chromePath;
288
+ }
289
+ const result = await importSite(importOptions, (e) => {
290
+ /* The seed event carries the root, and it is the ONE thing a caller cannot wait for the
248
291
  terminal line to learn: it arrives seconds in, and `done` arrives minutes later. It
249
292
  is re-emitted as its own line rather than left inside a progress message, because a
250
293
  root parsed out of prose is a root that breaks when the prose is reworded. */
251
- if (e.phase === "seed" && e.root) {
252
- writeLine({ type: "ready", root: toRoot(e.root) });
253
- }
254
- const { root: _root, ...progress } = e;
255
- writeLine({ type: "progress", ...progress });
256
- },
257
- );
294
+ if (e.phase === "seed" && e.root) {
295
+ writeLine({ type: "ready", root: toRoot(e.root) });
296
+ }
297
+ const { root: _root, ...progress } = e;
298
+ writeLine({ type: "progress", ...progress });
299
+ });
258
300
 
301
+ /* SAFETY: importSite's emit phase has just written project.json as a Jx project config,
302
+ which is a JSON object; text that is not JSON throws into the error line below. */
259
303
  const config = JSON.parse(
260
304
  await readFile(resolve(result.outDir, "project.json"), "utf8"),
261
- ) as Record<string, unknown>;
305
+ ) as JsonObject;
262
306
  /*
263
307
  * The result travels with the `done` line. It used to be discarded here: the pipeline had
264
308
  * computed the page list, the file count, the warnings and (with verify) a per-page
265
309
  * fidelity score, and the caller learned only that an import had happened. A caller that
266
310
  * cannot see what a run found cannot report it, and cannot ask about it either.
267
311
  */
268
- writeLine({
269
- type: "done",
270
- root: toRoot(result.outDir),
271
- config,
272
- result: {
273
- pages: result.pages,
274
- fileCount: result.fileCount,
275
- warnings: result.warnings,
276
- ...(result.verify ? { verify: result.verify } : {}),
277
- },
278
- });
312
+ const summary: ImportDoneSummary = {
313
+ pages: result.pages,
314
+ fileCount: result.fileCount,
315
+ warnings: result.warnings,
316
+ };
317
+ if (result.verify) {
318
+ summary.verify = result.verify;
319
+ }
320
+ writeLine({ type: "done", root: toRoot(result.outDir), config, result: summary });
279
321
  } catch (error) {
280
322
  const message = error instanceof Error ? error.message : String(error);
281
323
  writeLine({ type: "error", error: message });
282
324
  } finally {
283
325
  clearInterval(heartbeat);
284
- if (!closed) {
285
- closed = true;
326
+ if (!sink.closed) {
327
+ sink.closed = true;
286
328
  try {
287
329
  controller.close();
288
330
  } catch {
package/src/jx-mounts.ts CHANGED
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * Dev conveniences over the production worker:
9
9
  *
10
- * - `env` is process.env merged under the project's `.dev.vars` plus `JX_PROJECT_ROOT`;
10
+ * - `env` is process.env merged under the project's `.dev.vars` plus `JX_PROJECT_ROOT`
11
+ * (`projectDevEnv`, which the server-function proxy shares);
11
12
  * - Connector classes with `local: "<provider>"` are stood in by the registry's class for that
12
13
  * provider (e.g. D1 → sqlite at `<project>/.jx/data/<connection>.sqlite`);
13
14
  * - Mount options set `autoSync: true`, so table schemas sync additively on first touch.
@@ -18,16 +19,16 @@
18
19
  import { existsSync, readFileSync, statSync } from "node:fs";
19
20
  import { resolve } from "node:path";
20
21
  import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
21
- import { loadDevVars } from "./dev-vars.ts";
22
+ import { isString } from "@jxsuite/schema/guards";
23
+ import { projectDevEnv } from "./dev-vars.ts";
24
+ import type { ProjectDevEnv } from "./dev-vars.ts";
22
25
  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>;
26
+ import type { ImplementationExport, MountHandler } from "@jxsuite/schema/format-registry";
27
+ import type { JxDocumentValue, ProjectConfig } from "@jxsuite/schema/types";
27
28
 
28
29
  interface MountRuntime {
29
30
  handlers: { basePath: string; handler: MountHandler }[];
30
- env: Record<string, unknown>;
31
+ env: ProjectDevEnv;
31
32
  }
32
33
 
33
34
  /** Per-project runtime cache, invalidated when project.json's mtime moves. */
@@ -105,6 +106,9 @@ async function buildRuntime(
105
106
  projectRoot: string,
106
107
  projectJsonPath: string,
107
108
  ): Promise<MountRuntime | null> {
109
+ /* SAFETY: trust boundary. project.json is the project's own config, held to its per-project
110
+ schema by the editor and `jx validate`; the sections are only relayed to the mounts that own
111
+ them, and any throw lands in getRuntime's catch, which disables the mounts with a warning. */
108
112
  const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
109
113
  const registry = await buildProjectExtensionRegistry(projectRoot, config);
110
114
  const mounts = registry.serverMounts();
@@ -112,7 +116,7 @@ async function buildRuntime(
112
116
  return null;
113
117
  }
114
118
 
115
- const sections: Record<string, unknown> = {};
119
+ const sections: Record<string, JxDocumentValue | undefined> = {};
116
120
  for (const contribution of registry.projectContributions()) {
117
121
  const { key } = contribution.project!;
118
122
  if (key in config) {
@@ -121,22 +125,19 @@ async function buildRuntime(
121
125
  }
122
126
 
123
127
  const connectors = await resolveConnectorStandins(registry);
124
- const env: Record<string, unknown> = {
125
- ...process.env,
126
- ...loadDevVars(projectRoot),
127
- JX_PROJECT_ROOT: projectRoot,
128
- };
128
+ const env = projectDevEnv(projectRoot);
129
129
 
130
- // One shared mutable context, passed to every mount in order (specs/extensions.md §11).
131
- const ctx: Record<string, unknown> = {};
130
+ /* One shared mutable context, passed to every mount in order (specs/extensions.md §11). Its
131
+ members belong to the mounts; this host never reads them. */
132
+ const ctx = {};
132
133
  const handlers: MountRuntime["handlers"] = [];
133
134
  for (const entry of mounts) {
134
135
  const { basePath } = entry.server!;
135
- const handler = (await entry.call(
136
+ const handler = await entry.call(
136
137
  "mount",
137
138
  { autoSync: true, basePath, connectors, sections },
138
139
  ctx,
139
- )) as MountHandler;
140
+ );
140
141
  handlers.push({ basePath, handler });
141
142
  }
142
143
  return { env, handlers };
@@ -148,30 +149,26 @@ async function buildRuntime(
148
149
  * that provider instead of its own (specs/extensions.md §12). Shared with the data-api push seam so
149
150
  * section-owner deploySchema contributions (e.g. auth) see the same stand-ins as the mounts.
150
151
  *
152
+ * Each provider is its implementation module's export (specs/extensions.md §6.1): this host only
153
+ * routes it, and the mounts and deploy steps that receive it are the ones that call it. A module
154
+ * exporting no such export yields a nullish entry, which the receiving mount reports as an
155
+ * unavailable provider.
156
+ *
151
157
  * @param {ExtensionRegistry} registry
152
- * @returns {Promise<Record<string, unknown>>}
158
+ * @returns {Promise<Record<string, ImplementationExport | null | undefined>>}
153
159
  */
154
160
  export async function resolveConnectorStandins(
155
161
  registry: ExtensionRegistry,
156
- ): Promise<Record<string, unknown>> {
162
+ ): Promise<Record<string, ImplementationExport | null | undefined>> {
157
163
  const entries = registry.connectors();
158
- const out: Record<string, unknown> = {};
164
+ const out: Record<string, ImplementationExport | null | undefined> = {};
159
165
  for (const entry of entries) {
160
166
  const provider = String(entry.connector!.provider);
161
167
  const { local } = entry.connector!;
162
- const target =
163
- typeof local === "string"
164
- ? (entries.find((e) => e.connector!.provider === local) ?? entry)
165
- : entry;
166
- out[provider] = await implementationClass(target);
168
+ const target = isString(local)
169
+ ? (entries.find((e) => e.connector!.provider === local) ?? entry)
170
+ : entry;
171
+ out[provider] = await target.implementationExport();
167
172
  }
168
173
  return out;
169
174
  }
170
-
171
- /** Import a connector entry's implementation module and pick the export named by its title. */
172
- async function implementationClass(entry: FormatEntry): Promise<unknown> {
173
- const mod = await entry.implementation();
174
- const title = (entry.classDef.title as string | undefined) ?? entry.name;
175
- const fromDefault = (mod.default as Record<string, unknown> | undefined)?.[title];
176
- return mod[title] ?? fromDefault ?? mod.default;
177
- }
@@ -42,11 +42,11 @@ import { extname, join, resolve } from "node:path";
42
42
  import { serveSite, siteContext, siteHeaders } from "@jxsuite/site/serve";
43
43
  import { SERVABLE_ROOTS } from "@jxsuite/site/paths";
44
44
  import { buildSiteStyleCSS } from "@jxsuite/site/site-style";
45
- import type { AssetIO, SiteContext } from "@jxsuite/site/serve";
45
+ import type { AssetIO, SiteContext, SiteTree } from "@jxsuite/site/serve";
46
46
  import { readProjectConfig } from "@jxsuite/site/compose";
47
47
  import type { DocumentParser, SiteIO } from "@jxsuite/site/compose";
48
- import { buildProjectFormatRegistry } from "@jxsuite/compiler/format-host";
49
- import type { JxDocument } from "@jxsuite/schema/types";
48
+ import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
49
+ import { isNonNullObject } from "@jxsuite/schema/guards";
50
50
  import { createSseHub } from "./sse.ts";
51
51
  import type { SseHub } from "./sse.ts";
52
52
  import { LIVE_NAMESPACE, PREVIEW_CLIENT_JS } from "./preview-client.ts";
@@ -203,12 +203,13 @@ function documentParser(projectRoot: string): DocumentParser {
203
203
  paths: () => [],
204
204
  read: (file) => readTreeFile(projectRoot, file),
205
205
  });
206
- const registry = await buildProjectFormatRegistry(projectRoot, config);
207
- const entry = registry.byExtension(extname(path), "parse");
206
+ const { formats } = await buildProjectExtensionRegistry(projectRoot, config);
207
+ const entry = formats.byExtension(extname(path), "parse");
208
208
  if (!entry) {
209
209
  return null;
210
210
  }
211
- return (await entry.call("parse", text, { path })) as JxDocument;
211
+ // A page format answers a JxDocument; one that throws instead is caught below.
212
+ return await entry.parseDocument(text, { path });
212
213
  } catch {
213
214
  /* A format that throws is a broken project, not a broken preview: returning null lets the
214
215
  composer report the page by name rather than taking the whole origin down. */
@@ -231,7 +232,7 @@ async function readTreeFile(projectRoot: string, path: string): Promise<string |
231
232
  }
232
233
 
233
234
  /** Reading the tree, overlay first — which is what makes this the canvas rather than the disk. */
234
- function makeIO(projectRoot: string, paths: string[]): { io: SiteIO; assets: AssetIO } {
235
+ function makeIO(projectRoot: string, paths: string[]): SiteTree {
235
236
  const io: SiteIO = {
236
237
  parse: documentParser(projectRoot),
237
238
  paths: () => paths,
@@ -273,11 +274,12 @@ function invalidate(entry: Running) {
273
274
 
274
275
  /** `project.json`'s `style` as a stylesheet, or empty when the project declares none. */
275
276
  function siteStyleCss(context: SiteContext): string {
276
- const { style } = context.config as { style?: Record<string, unknown> };
277
- if (!style || typeof style !== "object") {
277
+ const { style } = context.config;
278
+ // Project.json is hand-edited JSON, so a declared `style` may still be a non-object
279
+ if (!style || !isNonNullObject(style)) {
278
280
  return "";
279
281
  }
280
- const media = (context.config as { $media?: Record<string, string> }).$media ?? {};
282
+ const media = context.config.$media ?? {};
281
283
  /* Identity transposition: a browser tab IS the viewport, so `100vh` already means what it says.
282
284
  The canvas passes a real transposer because its viewport is an iframe it sizes to content. */
283
285
  return buildSiteStyleCSS(style, media, (value) => value);
@@ -314,6 +316,9 @@ async function handle(entry: Running, projectRoot: string, req: Request): Promis
314
316
  if (surface === "ack" && req.method === "POST") {
315
317
  let body: { gen?: number };
316
318
  try {
319
+ /* SAFETY: PREVIEW_CLIENT_JS is this route's only client and posts `{ gen }` with the
320
+ generation it applied; `gen` is only compared with `===`, so a stray value merely fails
321
+ to settle the pending acknowledgement. */
317
322
  body = (await req.json()) as { gen?: number };
318
323
  } catch {
319
324
  return problem("invalidRequest", "Invalid JSON body");
@@ -520,6 +525,7 @@ function enforceOverlayBudget(overlay: Overlay): void {
520
525
  total += value.length;
521
526
  }
522
527
  while (total > OVERLAY_MAX_BYTES && overlay.files.size > 1) {
528
+ // SAFETY: the loop runs only while `files.size > 1`, so the key iterator has a first entry.
523
529
  const oldest = overlay.files.keys().next().value as string;
524
530
  total -= overlay.files.get(oldest)?.length ?? 0;
525
531
  overlay.files.delete(oldest);