@jxsuite/server 4.5.0 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/ai-api.ts +8 -4
- package/src/build.ts +6 -6
- package/src/code-api.ts +29 -16
- package/src/collab.ts +9 -5
- package/src/data-api.ts +190 -137
- package/src/dev-vars.ts +15 -5
- package/src/dev.ts +3 -0
- package/src/extension-catalog.ts +62 -32
- package/src/import-api.ts +95 -53
- package/src/jx-mounts.ts +27 -27
- package/src/live-preview.ts +16 -10
- package/src/oauth-loopback.ts +23 -18
- package/src/packages.ts +26 -36
- package/src/problem.ts +3 -6
- package/src/project-server.ts +34 -4
- package/src/refactor/apply.ts +3 -2
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/fs-events.ts +14 -15
- package/src/refactor/paths.ts +11 -3
- package/src/refactor/refs.ts +60 -63
- package/src/refactor/scan.ts +13 -19
- package/src/refactor/watcher.ts +5 -5
- package/src/resolve.ts +180 -68
- package/src/server.ts +47 -27
- package/src/site-output.ts +11 -5
- package/src/sse.ts +16 -2
- package/src/studio-api.ts +264 -115
- package/src/types.ts +17 -7
- package/src/watch.ts +8 -6
package/src/extension-catalog.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 "./${
|
|
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
|
-
|
|
226
|
+
const entry: ExtensionCatalogEntry = {
|
|
206
227
|
installed: true,
|
|
207
228
|
name,
|
|
208
229
|
sections: loaded.sections,
|
|
209
230
|
source: "project",
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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?:
|
|
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:
|
|
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):
|
|
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,
|
|
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
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
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(
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
@@ -19,16 +19,16 @@
|
|
|
19
19
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
20
20
|
import { resolve } from "node:path";
|
|
21
21
|
import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
|
|
22
|
+
import { isString } from "@jxsuite/schema/guards";
|
|
22
23
|
import { projectDevEnv } from "./dev-vars.ts";
|
|
24
|
+
import type { ProjectDevEnv } from "./dev-vars.ts";
|
|
23
25
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
24
|
-
import type {
|
|
25
|
-
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
26
|
-
|
|
27
|
-
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";
|
|
28
28
|
|
|
29
29
|
interface MountRuntime {
|
|
30
30
|
handlers: { basePath: string; handler: MountHandler }[];
|
|
31
|
-
env:
|
|
31
|
+
env: ProjectDevEnv;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** Per-project runtime cache, invalidated when project.json's mtime moves. */
|
|
@@ -106,6 +106,9 @@ async function buildRuntime(
|
|
|
106
106
|
projectRoot: string,
|
|
107
107
|
projectJsonPath: string,
|
|
108
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. */
|
|
109
112
|
const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
|
|
110
113
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
111
114
|
const mounts = registry.serverMounts();
|
|
@@ -113,7 +116,7 @@ async function buildRuntime(
|
|
|
113
116
|
return null;
|
|
114
117
|
}
|
|
115
118
|
|
|
116
|
-
const sections: Record<string,
|
|
119
|
+
const sections: Record<string, JxDocumentValue | undefined> = {};
|
|
117
120
|
for (const contribution of registry.projectContributions()) {
|
|
118
121
|
const { key } = contribution.project!;
|
|
119
122
|
if (key in config) {
|
|
@@ -122,18 +125,19 @@ async function buildRuntime(
|
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
const connectors = await resolveConnectorStandins(registry);
|
|
125
|
-
const env
|
|
128
|
+
const env = projectDevEnv(projectRoot);
|
|
126
129
|
|
|
127
|
-
|
|
128
|
-
|
|
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 = {};
|
|
129
133
|
const handlers: MountRuntime["handlers"] = [];
|
|
130
134
|
for (const entry of mounts) {
|
|
131
135
|
const { basePath } = entry.server!;
|
|
132
|
-
const handler =
|
|
136
|
+
const handler = await entry.call(
|
|
133
137
|
"mount",
|
|
134
138
|
{ autoSync: true, basePath, connectors, sections },
|
|
135
139
|
ctx,
|
|
136
|
-
)
|
|
140
|
+
);
|
|
137
141
|
handlers.push({ basePath, handler });
|
|
138
142
|
}
|
|
139
143
|
return { env, handlers };
|
|
@@ -145,30 +149,26 @@ async function buildRuntime(
|
|
|
145
149
|
* that provider instead of its own (specs/extensions.md §12). Shared with the data-api push seam so
|
|
146
150
|
* section-owner deploySchema contributions (e.g. auth) see the same stand-ins as the mounts.
|
|
147
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
|
+
*
|
|
148
157
|
* @param {ExtensionRegistry} registry
|
|
149
|
-
* @returns {Promise<Record<string,
|
|
158
|
+
* @returns {Promise<Record<string, ImplementationExport | null | undefined>>}
|
|
150
159
|
*/
|
|
151
160
|
export async function resolveConnectorStandins(
|
|
152
161
|
registry: ExtensionRegistry,
|
|
153
|
-
): Promise<Record<string,
|
|
162
|
+
): Promise<Record<string, ImplementationExport | null | undefined>> {
|
|
154
163
|
const entries = registry.connectors();
|
|
155
|
-
const out: Record<string,
|
|
164
|
+
const out: Record<string, ImplementationExport | null | undefined> = {};
|
|
156
165
|
for (const entry of entries) {
|
|
157
166
|
const provider = String(entry.connector!.provider);
|
|
158
167
|
const { local } = entry.connector!;
|
|
159
|
-
const target =
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
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();
|
|
164
172
|
}
|
|
165
173
|
return out;
|
|
166
174
|
}
|
|
167
|
-
|
|
168
|
-
/** Import a connector entry's implementation module and pick the export named by its title. */
|
|
169
|
-
async function implementationClass(entry: FormatEntry): Promise<unknown> {
|
|
170
|
-
const mod = await entry.implementation();
|
|
171
|
-
const title = (entry.classDef.title as string | undefined) ?? entry.name;
|
|
172
|
-
const fromDefault = (mod.default as Record<string, unknown> | undefined)?.[title];
|
|
173
|
-
return mod[title] ?? fromDefault ?? mod.default;
|
|
174
|
-
}
|
package/src/live-preview.ts
CHANGED
|
@@ -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 {
|
|
49
|
-
import
|
|
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
|
|
207
|
-
const entry =
|
|
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
|
-
|
|
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[]):
|
|
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
|
|
277
|
-
|
|
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 =
|
|
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);
|
package/src/oauth-loopback.ts
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* string of its own and `Referrer-Policy: no-referrer`, so nothing carries it onward.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { isJsonObject, isNumber, isString } from "@jxsuite/schema/guards";
|
|
31
32
|
import { secretsMatch } from "./net-guard.ts";
|
|
32
33
|
|
|
33
34
|
/** The loopback redirect path this server hosts. */
|
|
@@ -345,29 +346,33 @@ export async function exchangeCode(params: {
|
|
|
345
346
|
headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
|
|
346
347
|
method: "POST",
|
|
347
348
|
});
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
error_description?: unknown;
|
|
352
|
-
expires_in?: unknown;
|
|
353
|
-
refresh_token?: unknown;
|
|
354
|
-
scope?: unknown;
|
|
355
|
-
token_type?: unknown;
|
|
356
|
-
} | null;
|
|
349
|
+
// A body that is not JSON reads as no payload, and is reported by status below.
|
|
350
|
+
const payload: unknown = await response.json().catch(() => null);
|
|
351
|
+
const fields = isJsonObject(payload) ? payload : null;
|
|
357
352
|
|
|
358
|
-
if (
|
|
359
|
-
const description =
|
|
360
|
-
|
|
353
|
+
if (fields && isString(fields.error)) {
|
|
354
|
+
const description = isString(fields.error_description)
|
|
355
|
+
? fields.error_description
|
|
356
|
+
: fields.error;
|
|
361
357
|
throw new Error(`The provider refused the sign-in: ${description}`);
|
|
362
358
|
}
|
|
363
|
-
if (!response.ok || !
|
|
359
|
+
if (!response.ok || !fields || !isString(fields.access_token)) {
|
|
364
360
|
throw new Error(`The token exchange failed (HTTP ${response.status}).`);
|
|
365
361
|
}
|
|
362
|
+
// RFC 6749 §5.1 makes these optional; each is kept only when the provider sent the right type.
|
|
363
|
+
const optional: Pick<TokenResult, "expiresIn" | "refreshToken" | "scope"> = {};
|
|
364
|
+
if (isNumber(fields.expires_in)) {
|
|
365
|
+
optional.expiresIn = fields.expires_in;
|
|
366
|
+
}
|
|
367
|
+
if (isString(fields.refresh_token)) {
|
|
368
|
+
optional.refreshToken = fields.refresh_token;
|
|
369
|
+
}
|
|
370
|
+
if (isString(fields.scope)) {
|
|
371
|
+
optional.scope = fields.scope;
|
|
372
|
+
}
|
|
366
373
|
return {
|
|
367
|
-
accessToken:
|
|
368
|
-
...
|
|
369
|
-
|
|
370
|
-
...(typeof payload.scope === "string" ? { scope: payload.scope } : {}),
|
|
371
|
-
tokenType: typeof payload.token_type === "string" ? payload.token_type : "bearer",
|
|
374
|
+
accessToken: fields.access_token,
|
|
375
|
+
...optional,
|
|
376
|
+
tokenType: isString(fields.token_type) ? fields.token_type : "bearer",
|
|
372
377
|
};
|
|
373
378
|
}
|