@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/refactor/scan.ts
CHANGED
|
@@ -9,22 +9,17 @@
|
|
|
9
9
|
|
|
10
10
|
import { extname } from "node:path";
|
|
11
11
|
import { readFile } from "node:fs/promises";
|
|
12
|
-
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
12
|
+
import type { FormatRegistry, ValueEdit } from "@jxsuite/schema/format-registry";
|
|
13
|
+
import type { JsonValue } from "@jxsuite/schema/types";
|
|
13
14
|
|
|
14
15
|
/** Normalise a path to forward slashes (Windows `path` returns backslashes). */
|
|
15
16
|
export const fwd = (p: string) => p.replaceAll("\\", "/");
|
|
16
17
|
|
|
17
|
-
/** One authored value to replace in a document's own source text. */
|
|
18
|
-
export interface ValueEdit {
|
|
19
|
-
from: string;
|
|
20
|
-
to: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
18
|
/** A parsed document and the two ways it might be written back. */
|
|
24
19
|
export interface LoadedDoc {
|
|
25
|
-
doc:
|
|
20
|
+
doc: JsonValue;
|
|
26
21
|
/** Round-trips the whole document. Null when the format declares no `serialize`. */
|
|
27
|
-
serialize: ((doc:
|
|
22
|
+
serialize: ((doc: JsonValue) => Promise<string>) | null;
|
|
28
23
|
/**
|
|
29
24
|
* Replaces authored values in the file's own bytes, changing nothing else. Null when the format
|
|
30
25
|
* declares no `rewrite`.
|
|
@@ -84,7 +79,8 @@ export async function loadDoc(fp: string, registry: FormatRegistry): Promise<Loa
|
|
|
84
79
|
if (isJsonPath(fp)) {
|
|
85
80
|
const trailingNewline = raw.endsWith("\n");
|
|
86
81
|
return {
|
|
87
|
-
|
|
82
|
+
// SAFETY: JSON.parse without a reviver only ever yields a JSON value; its `any` says no more.
|
|
83
|
+
doc: JSON.parse(raw) as JsonValue,
|
|
88
84
|
rewrite: null,
|
|
89
85
|
serialize: (doc) =>
|
|
90
86
|
Promise.resolve(JSON.stringify(doc, null, 2) + (trailingNewline ? "\n" : "")),
|
|
@@ -95,22 +91,20 @@ export async function loadDoc(fp: string, registry: FormatRegistry): Promise<Loa
|
|
|
95
91
|
if (!parseEntry) {
|
|
96
92
|
throw new Error(`No parser for "${ext}"`);
|
|
97
93
|
}
|
|
98
|
-
|
|
94
|
+
/* SAFETY: read as a JSON tree. A `parse` answer is a JxDocument or a ContentLoaderEntry[]
|
|
95
|
+
(specs/extensions.md §8), both JSON trees but for leaves such as a frontmatter Date. Every walk
|
|
96
|
+
over it in refs.ts re-checks each node with Array.isArray / isJsonObject / isString before
|
|
97
|
+
reading it, so such a leaf is skipped rather than misread. */
|
|
98
|
+
const doc = (await parseEntry.call("parse", raw)) as JsonValue;
|
|
99
99
|
const serializeEntry = registry.byExtension(ext, "serialize");
|
|
100
100
|
const rewriteEntry = registry.byExtension(ext, "rewrite");
|
|
101
101
|
return {
|
|
102
102
|
doc,
|
|
103
103
|
rewrite: rewriteEntry
|
|
104
|
-
? async (edits) =>
|
|
105
|
-
const out = await rewriteEntry.call("rewrite", raw, edits);
|
|
106
|
-
return typeof out === "string" ? out : String(out);
|
|
107
|
-
}
|
|
104
|
+
? async (edits) => String(await rewriteEntry.call("rewrite", raw, edits))
|
|
108
105
|
: null,
|
|
109
106
|
serialize: serializeEntry
|
|
110
|
-
? async (d) =>
|
|
111
|
-
const out = await serializeEntry.call("serialize", d);
|
|
112
|
-
return typeof out === "string" ? out : String(out);
|
|
113
|
-
}
|
|
107
|
+
? async (d) => String(await serializeEntry.call("serialize", d))
|
|
114
108
|
: null,
|
|
115
109
|
};
|
|
116
110
|
}
|
package/src/refactor/watcher.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* build/reload); both share the pure `toFsEvent`/`coalesceFsEvents` helpers.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { watch
|
|
9
|
+
import { watch } from "chokidar";
|
|
10
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
10
11
|
import { coalesceFsEvents, toFsEvent } from "./fs-events.ts";
|
|
11
12
|
import { invalidateReferenceCache } from "./find-refs.ts";
|
|
12
13
|
import { createWatchIgnore } from "../watch-policy.ts";
|
|
@@ -36,7 +37,7 @@ export function createFsWatcher(
|
|
|
36
37
|
let buffer: FsEventPayload[] = [];
|
|
37
38
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
38
39
|
|
|
39
|
-
const watcher =
|
|
40
|
+
const watcher = watch(root, {
|
|
40
41
|
/* Left on deliberately: a symlink inside a project is project content, and its events belong
|
|
41
42
|
in the sidebar. It is the link pointing OUT of the root that walks the watcher off into the
|
|
42
43
|
rest of the filesystem, and createWatchIgnore drops exactly those — along with the sockets
|
|
@@ -71,9 +72,8 @@ export function createFsWatcher(
|
|
|
71
72
|
directory holding a few unix sockets took the whole launcher down rather than losing a few
|
|
72
73
|
watches. The filter above means these should now be rare; the listener means they are never
|
|
73
74
|
fatal when they are not. */
|
|
74
|
-
watcher.on("error", (
|
|
75
|
-
|
|
76
|
-
console.error(`[fs-watch] ${error.message ?? String(error)}`);
|
|
75
|
+
watcher.on("error", (cause) => {
|
|
76
|
+
console.error(`[fs-watch] ${errorMessage(cause)}`);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
return {
|
package/src/resolve.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { siteBasePath } from "@jxsuite/schema/asset-paths";
|
|
4
4
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
5
|
import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
|
|
6
|
+
import { isFunction, isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
6
7
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
7
8
|
import { createRequire } from "node:module";
|
|
8
9
|
import { projectDevEnv } from "./dev-vars.ts";
|
|
@@ -11,35 +12,115 @@ import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
|
|
|
11
12
|
import { loadProjectSections } from "@jxsuite/compiler/project-sections";
|
|
12
13
|
import { loadAssetMounts } from "@jxsuite/compiler/asset-mounts";
|
|
13
14
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
14
|
-
import type {
|
|
15
|
-
import type {
|
|
15
|
+
import type { DynamicMethod } from "@jxsuite/runtime/types";
|
|
16
|
+
import type { ProjectSections } from "@jxsuite/compiler/project-sections";
|
|
17
|
+
import type {
|
|
18
|
+
JsonObject,
|
|
19
|
+
JsonPrimitive,
|
|
20
|
+
JsonValue,
|
|
21
|
+
JxClassCtorDef,
|
|
22
|
+
JxClassDef,
|
|
23
|
+
ProjectConfig,
|
|
24
|
+
} from "@jxsuite/schema/types";
|
|
25
|
+
import type { ProjectDevEnv } from "./dev-vars.ts";
|
|
16
26
|
import { problem } from "./problem.ts";
|
|
17
27
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Any value a module exports, as the proxies see it before they check it. Nothing about an export
|
|
30
|
+
* is known statically (it is project or package code loaded by path), so the type only says what
|
|
31
|
+
* reading it can yield: a function, an object whose members can be read in turn, or a primitive.
|
|
32
|
+
*/
|
|
33
|
+
type ExportedValue =
|
|
34
|
+
| ExportedFunction
|
|
35
|
+
| ExportedConstructor
|
|
36
|
+
| ExportedObject
|
|
37
|
+
| JsonPrimitive
|
|
38
|
+
| bigint
|
|
39
|
+
| symbol
|
|
40
|
+
| undefined;
|
|
41
|
+
|
|
42
|
+
/** A function export as it is called. Its real signature is the contract of whoever calls it. */
|
|
43
|
+
type ExportedFunction = (...args: never[]) => ExportedValue | Promise<ExportedValue>;
|
|
44
|
+
|
|
45
|
+
/** A class export as it is constructed. Its real signature is the contract of whoever builds it. */
|
|
46
|
+
type ExportedConstructor = new (...args: never[]) => object;
|
|
47
|
+
|
|
48
|
+
/** A non-primitive export. Reading any member of it yields another {@link ExportedValue}. */
|
|
49
|
+
interface ExportedObject {
|
|
50
|
+
readonly [member: string]: ExportedValue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A loaded module's namespace object. `default` is read as an object because the proxies only ever
|
|
55
|
+
* read a named member off it, which is defined for any non-nullish value.
|
|
56
|
+
*/
|
|
57
|
+
interface ModuleNamespace extends ExportedObject {
|
|
58
|
+
readonly default?: ExportedObject;
|
|
21
59
|
}
|
|
22
60
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
61
|
+
/**
|
|
62
|
+
* The project context a class receives as `config._project`: the config, the root, and every
|
|
63
|
+
* extension-loaded section under its section key (the compiler's `_project` shape).
|
|
64
|
+
*/
|
|
65
|
+
type ProjectContext = ProjectSections & { config: ProjectConfig; root: string };
|
|
66
|
+
|
|
67
|
+
/** A cached project context, its extension asset mounts, and the `project.json` mtime it is for. */
|
|
68
|
+
interface ProjectEntry {
|
|
69
|
+
mtime: number;
|
|
70
|
+
context: ProjectContext;
|
|
71
|
+
mounts: AssetMount[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A value the resolve proxy hands a class constructor under one config key. */
|
|
75
|
+
type ConfigValue = JsonValue | ProjectContext | undefined;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The `config` a class is constructed with: the request's own members, plus the injected project
|
|
79
|
+
* context and an empty document scope.
|
|
80
|
+
*/
|
|
81
|
+
interface ResolveConfig {
|
|
82
|
+
[key: string]: ConfigValue;
|
|
83
|
+
_project?: ProjectContext;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A class `/__jx_resolve__` constructs from an `$implementation` module. */
|
|
87
|
+
type ResolvableClass = new (config: ResolveConfig) => ResolvableInstance;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What the resolve proxy reads off a constructed instance, in the order spec.md §12.3 fixes:
|
|
91
|
+
* `resolve()` when it is a method, else `value` when the instance has one, else the instance.
|
|
92
|
+
*/
|
|
93
|
+
interface ResolvableInstance {
|
|
94
|
+
readonly resolve?: ExportedValue;
|
|
95
|
+
readonly value?: ExportedValue;
|
|
27
96
|
}
|
|
28
97
|
|
|
98
|
+
/**
|
|
99
|
+
* What a server function may answer: a JSON value, sent as the response body (`undefined` →
|
|
100
|
+
* `null`).
|
|
101
|
+
*/
|
|
102
|
+
type ServerFunctionResult = JsonValue | undefined;
|
|
103
|
+
|
|
104
|
+
/** A `timing: "server"` function: `fn(args, env)`, the production contract the compiler emits. */
|
|
105
|
+
type ServerFunction = (
|
|
106
|
+
args: JsonObject,
|
|
107
|
+
env: ProjectDevEnv,
|
|
108
|
+
) => ServerFunctionResult | Promise<ServerFunctionResult>;
|
|
109
|
+
|
|
29
110
|
interface ResolveBody {
|
|
30
111
|
$src?: string;
|
|
31
112
|
$prototype?: string;
|
|
32
113
|
$export?: string;
|
|
33
114
|
$base?: string;
|
|
34
|
-
[key: string]:
|
|
115
|
+
[key: string]: JsonValue | undefined;
|
|
35
116
|
}
|
|
36
117
|
|
|
37
118
|
interface ServerFunctionBody {
|
|
38
119
|
$src?: string;
|
|
39
120
|
$export?: string;
|
|
40
121
|
$base?: string;
|
|
41
|
-
arguments?:
|
|
42
|
-
[key: string]:
|
|
122
|
+
arguments?: JsonObject;
|
|
123
|
+
[key: string]: JsonValue | undefined;
|
|
43
124
|
}
|
|
44
125
|
|
|
45
126
|
/**
|
|
@@ -93,10 +174,7 @@ function baseDirectory($base: string, root: string, projectRoot: string): string
|
|
|
93
174
|
}
|
|
94
175
|
|
|
95
176
|
/** Per-project context cache, invalidated when project.json changes on disk. */
|
|
96
|
-
const projectContextCache = new Map<
|
|
97
|
-
string,
|
|
98
|
-
{ mtime: number; context: Record<string, unknown>; mounts: AssetMount[] }
|
|
99
|
-
>();
|
|
177
|
+
const projectContextCache = new Map<string, ProjectEntry>();
|
|
100
178
|
|
|
101
179
|
/**
|
|
102
180
|
* Lazy-load project context (project.json + extension-loaded sections) for class instantiation.
|
|
@@ -104,15 +182,15 @@ const projectContextCache = new Map<
|
|
|
104
182
|
* `_project` shape.
|
|
105
183
|
*
|
|
106
184
|
* @param {string} projectRoot
|
|
107
|
-
* @returns {Promise<
|
|
185
|
+
* @returns {Promise<ProjectContext | null>} `{ config, root, ...sections }` or null
|
|
108
186
|
*/
|
|
109
|
-
async function loadProjectContext(projectRoot: string) {
|
|
187
|
+
async function loadProjectContext(projectRoot: string): Promise<ProjectContext | null> {
|
|
110
188
|
const entry = await loadProjectEntry(projectRoot);
|
|
111
189
|
return entry?.context ?? null;
|
|
112
190
|
}
|
|
113
191
|
|
|
114
192
|
/** Load (or reuse) the cached project context plus its extension asset mounts. */
|
|
115
|
-
async function loadProjectEntry(projectRoot: string) {
|
|
193
|
+
async function loadProjectEntry(projectRoot: string): Promise<ProjectEntry | null> {
|
|
116
194
|
const projectJsonPath = resolve(projectRoot, "project.json");
|
|
117
195
|
if (!existsSync(projectJsonPath)) {
|
|
118
196
|
return null;
|
|
@@ -123,12 +201,15 @@ async function loadProjectEntry(projectRoot: string) {
|
|
|
123
201
|
if (cached && cached.mtime === mtimeMs) {
|
|
124
202
|
return cached;
|
|
125
203
|
}
|
|
204
|
+
/* SAFETY: trust boundary. project.json is the project's own config file, which the editor
|
|
205
|
+
and `jx validate` hold to its per-project schema. Any failure reading it below is caught
|
|
206
|
+
here and answers "no project context". */
|
|
126
207
|
const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
|
|
127
208
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
128
209
|
const sections = await loadProjectSections(projectRoot, config, registry);
|
|
129
210
|
const { mounts } = await loadAssetMounts(registry, config, projectRoot);
|
|
130
|
-
const context = { config, root: projectRoot, ...sections };
|
|
131
|
-
const entry = { context, mounts, mtime: mtimeMs };
|
|
211
|
+
const context: ProjectContext = { config, root: projectRoot, ...sections };
|
|
212
|
+
const entry: ProjectEntry = { context, mounts, mtime: mtimeMs };
|
|
132
213
|
projectContextCache.set(projectRoot, entry);
|
|
133
214
|
return entry;
|
|
134
215
|
} catch {
|
|
@@ -148,8 +229,7 @@ async function loadProjectEntry(projectRoot: string) {
|
|
|
148
229
|
*/
|
|
149
230
|
export async function projectSiteBase(projectRoot: string): Promise<string> {
|
|
150
231
|
const entry = await loadProjectEntry(projectRoot);
|
|
151
|
-
|
|
152
|
-
return siteBasePath(config?.url);
|
|
232
|
+
return siteBasePath(entry?.context.config.url);
|
|
153
233
|
}
|
|
154
234
|
|
|
155
235
|
/**
|
|
@@ -178,12 +258,16 @@ export async function handleResolve(
|
|
|
178
258
|
) {
|
|
179
259
|
let body: ResolveBody;
|
|
180
260
|
try {
|
|
261
|
+
/* SAFETY: trust boundary. The body parsed as JSON, and this route's caller is the runtime's
|
|
262
|
+
`$prototype` proxy, which posts the entry's own JSON members with string `$`-keys. A body
|
|
263
|
+
that breaks that throws in this handler, as it always has. */
|
|
181
264
|
body = (await req.json()) as ResolveBody;
|
|
182
265
|
} catch {
|
|
183
266
|
return problem("invalidRequest", "Invalid JSON body");
|
|
184
267
|
}
|
|
185
268
|
|
|
186
|
-
const { $src, $prototype, $export: xport, $base, ...
|
|
269
|
+
const { $src, $prototype, $export: xport, $base, ...members } = body;
|
|
270
|
+
const config: ResolveConfig = members;
|
|
187
271
|
if (!$src) {
|
|
188
272
|
return problem("invalidRequest", "Missing $src");
|
|
189
273
|
}
|
|
@@ -228,7 +312,7 @@ export async function handleResolve(
|
|
|
228
312
|
const docDir = docUrlPath.slice(0, docUrlPath.lastIndexOf("/") + 1);
|
|
229
313
|
const docAbsDir = resolve(root, `.${docDir}`);
|
|
230
314
|
for (const [k, v] of Object.entries(config)) {
|
|
231
|
-
if (
|
|
315
|
+
if (isString(v) && (v.startsWith("./") || v.startsWith("../"))) {
|
|
232
316
|
config[k] = `./${relative(process.cwd(), resolve(docAbsDir, v)).split("\\").join("/")}`;
|
|
233
317
|
}
|
|
234
318
|
}
|
|
@@ -257,38 +341,25 @@ export async function handleResolve(
|
|
|
257
341
|
return problem("pathOutsideProject", "$implementation escapes the project root");
|
|
258
342
|
}
|
|
259
343
|
const exportName = xport ?? classDef.title ?? $prototype;
|
|
260
|
-
const mod =
|
|
344
|
+
const mod = await importModule(implPath);
|
|
261
345
|
const ExportedClass =
|
|
262
346
|
exportName === undefined ? undefined : (mod[exportName] ?? mod.default?.[exportName]);
|
|
263
|
-
if (
|
|
347
|
+
if (!isExportedFunction(ExportedClass)) {
|
|
264
348
|
return new Response(`Export "${exportName}" not found in "${classDef.$implementation}"`, {
|
|
265
349
|
status: 500,
|
|
266
350
|
});
|
|
267
351
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
? instance.value
|
|
275
|
-
: instance;
|
|
276
|
-
return Response.json(value);
|
|
352
|
+
/* SAFETY: trust boundary. A class file's `$implementation` names the module exporting the
|
|
353
|
+
class it describes, whose constructor takes one config object (spec.md §12.3, §12.4).
|
|
354
|
+
It is a function, checked above; one that is not constructible throws here, inside the
|
|
355
|
+
try whose catch answers a problem. */
|
|
356
|
+
const ClassCtor = ExportedClass as ResolvableClass;
|
|
357
|
+
return Response.json(await instanceResult(new ClassCtor(config)));
|
|
277
358
|
}
|
|
278
359
|
|
|
279
360
|
// Self-contained: construct class from schema
|
|
280
361
|
const DynClass = classFromSchema(classDef);
|
|
281
|
-
|
|
282
|
-
resolve?: () => unknown;
|
|
283
|
-
value?: unknown;
|
|
284
|
-
};
|
|
285
|
-
const value =
|
|
286
|
-
typeof instance.resolve === "function"
|
|
287
|
-
? await instance.resolve()
|
|
288
|
-
: "value" in instance
|
|
289
|
-
? instance.value
|
|
290
|
-
: instance;
|
|
291
|
-
return Response.json(value);
|
|
362
|
+
return Response.json(await instanceResult(new DynClass(config)));
|
|
292
363
|
} catch (error) {
|
|
293
364
|
return problem("internalError", errorMessage(error));
|
|
294
365
|
}
|
|
@@ -319,6 +390,10 @@ export async function handleResolve(
|
|
|
319
390
|
export async function handleServerFunction(req: Request, root: string, projectRoot = root) {
|
|
320
391
|
let body: ServerFunctionBody;
|
|
321
392
|
try {
|
|
393
|
+
/* SAFETY: trust boundary. The body parsed as JSON, and this route's caller is the runtime's
|
|
394
|
+
server-function proxy, which posts `{ $src, $export, $base, arguments }` with string
|
|
395
|
+
`$`-keys and an object of arguments. A body that breaks that throws in this handler, as it
|
|
396
|
+
always has. */
|
|
322
397
|
body = (await req.json()) as ServerFunctionBody;
|
|
323
398
|
} catch {
|
|
324
399
|
return problem("invalidRequest", "Invalid JSON body");
|
|
@@ -344,7 +419,7 @@ export async function handleServerFunction(req: Request, root: string, projectRo
|
|
|
344
419
|
|
|
345
420
|
let mod: ModuleNamespace;
|
|
346
421
|
try {
|
|
347
|
-
mod =
|
|
422
|
+
mod = await importModule(moduleAbsPath);
|
|
348
423
|
} catch (error) {
|
|
349
424
|
return new Response(`Failed to import "${$src}": ${errorMessage(error)}`, {
|
|
350
425
|
status: 500,
|
|
@@ -352,7 +427,7 @@ export async function handleServerFunction(req: Request, root: string, projectRo
|
|
|
352
427
|
}
|
|
353
428
|
|
|
354
429
|
const fn = mod[xport] ?? mod.default?.[xport];
|
|
355
|
-
if (
|
|
430
|
+
if (!isExportedFunction(fn)) {
|
|
356
431
|
return new Response(`Export "${xport}" not found in "${$src}"`, {
|
|
357
432
|
status: 500,
|
|
358
433
|
});
|
|
@@ -362,16 +437,53 @@ export async function handleServerFunction(req: Request, root: string, projectRo
|
|
|
362
437
|
/* Match the production contract `fn(args, env)` (compiler emits `fn(args, c.env)`): the dev
|
|
363
438
|
proxy runs server-side, so its `env` is the extension mounts' own, `process.env` merged under
|
|
364
439
|
the project's `.dev.vars` plus `JX_PROJECT_ROOT` (server.md §3.3). */
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
);
|
|
440
|
+
/* SAFETY: trust boundary. A `timing: "server"` module exports its functions with the signature
|
|
441
|
+
`fn(args, env)` and a JSON answer (spec.md §11.4); this one is a function, checked above. */
|
|
442
|
+
const serverFn = fn as ServerFunction;
|
|
443
|
+
const result = await serverFn(args, projectDevEnv(projectRoot));
|
|
369
444
|
return Response.json(result ?? null);
|
|
370
445
|
} catch (error) {
|
|
371
446
|
return problem("internalError", errorMessage(error));
|
|
372
447
|
}
|
|
373
448
|
}
|
|
374
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Whether an export is a function. Every function is callable, constructible or both, which is all
|
|
452
|
+
* the narrowed type claims; which of the two a proxy needs is that proxy's own contract.
|
|
453
|
+
*/
|
|
454
|
+
function isExportedFunction(value: ExportedValue): value is ExportedFunction | ExportedConstructor {
|
|
455
|
+
return isFunction(value);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Import a module by absolute path, as its namespace object. */
|
|
459
|
+
async function importModule(path: string): Promise<ModuleNamespace> {
|
|
460
|
+
/* SAFETY: a dynamic import() resolves to the module's namespace object, whose own members are its
|
|
461
|
+
exports; every member the proxies read off one is checked to be a function before use. */
|
|
462
|
+
return (await import(path)) as ModuleNamespace;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** What a constructed class answers with: its `resolve()` result, else its `value`, else itself. */
|
|
466
|
+
async function instanceResult(
|
|
467
|
+
instance: ResolvableInstance,
|
|
468
|
+
): Promise<ExportedValue | ResolvableInstance> {
|
|
469
|
+
if (isFunction(instance.resolve)) {
|
|
470
|
+
return await instance.resolve();
|
|
471
|
+
}
|
|
472
|
+
return "value" in instance ? instance.value : instance;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Whether a class document's `$defs.constructor` is its own constructor definition, rather than the
|
|
477
|
+
* `Object.prototype.constructor` function every object parsed from JSON inherits.
|
|
478
|
+
*
|
|
479
|
+
* @param rawCtor What `$defs.constructor` reads as
|
|
480
|
+
*/
|
|
481
|
+
function isCtorDef(
|
|
482
|
+
rawCtor: NonNullable<JxClassDef["$defs"]>["constructor"],
|
|
483
|
+
): rawCtor is JxClassCtorDef {
|
|
484
|
+
return isNonNullObject(rawCtor);
|
|
485
|
+
}
|
|
486
|
+
|
|
375
487
|
/**
|
|
376
488
|
* Dynamically construct a class from a .class.json schema definition. Server-side variant — no
|
|
377
489
|
* private field limitations.
|
|
@@ -380,26 +492,24 @@ export async function handleServerFunction(req: Request, root: string, projectRo
|
|
|
380
492
|
*/
|
|
381
493
|
function classFromSchema(classDef: JxClassDef) {
|
|
382
494
|
const fields = classDef.$defs?.fields ?? {};
|
|
383
|
-
// JSON objects inherit Object.prototype.constructor — only an own object value counts.
|
|
384
495
|
const rawCtor = classDef.$defs?.constructor;
|
|
385
|
-
const ctor =
|
|
496
|
+
const ctor = isCtorDef(rawCtor) ? rawCtor : undefined;
|
|
386
497
|
const methods = classDef.$defs?.methods ?? {};
|
|
387
498
|
|
|
388
499
|
// oxlint-disable-next-line typescript/no-extraneous-class -- methods are attached to the prototype dynamically below
|
|
389
500
|
class DynClass {
|
|
390
|
-
constructor(config = {}) {
|
|
391
|
-
|
|
392
|
-
const cfg = config as Record<string, unknown>;
|
|
501
|
+
constructor(config: ResolveConfig = {}) {
|
|
502
|
+
// Each field lands as an own property by assignment, as `this[id] = value` would put it.
|
|
393
503
|
for (const [key, field] of Object.entries(fields)) {
|
|
394
504
|
const id = field.identifier ?? key;
|
|
395
|
-
if (
|
|
396
|
-
|
|
505
|
+
if (config[id] !== undefined) {
|
|
506
|
+
Object.assign(this, { [id]: config[id] });
|
|
397
507
|
} else if (field.initializer !== undefined) {
|
|
398
|
-
|
|
508
|
+
Object.assign(this, { [id]: field.initializer });
|
|
399
509
|
} else if (field.default !== undefined) {
|
|
400
|
-
|
|
510
|
+
Object.assign(this, { [id]: structuredClone(field.default) });
|
|
401
511
|
} else {
|
|
402
|
-
|
|
512
|
+
Object.assign(this, { [id]: null });
|
|
403
513
|
}
|
|
404
514
|
}
|
|
405
515
|
if (ctor?.body) {
|
|
@@ -416,27 +526,29 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
416
526
|
return p.$ref.split("/").pop() ?? "arg";
|
|
417
527
|
}
|
|
418
528
|
const n = p.identifier ?? p.name;
|
|
419
|
-
return
|
|
529
|
+
return isString(n) ? n : "arg";
|
|
420
530
|
});
|
|
421
531
|
const bodyStr = Array.isArray(method.body) ? method.body.join("\n") : (method.body ?? "");
|
|
422
532
|
|
|
423
533
|
if (method.role === "accessor") {
|
|
424
534
|
const descriptor: PropertyDescriptor = {};
|
|
425
535
|
if (method.getter) {
|
|
426
|
-
|
|
536
|
+
// SAFETY: `new Function(body)` with no parameter names builds the zero-argument getter.
|
|
537
|
+
descriptor.get = new Function(method.getter.body ?? "") as DynamicMethod;
|
|
427
538
|
}
|
|
428
539
|
if (method.setter) {
|
|
429
540
|
const sp = (method.setter.parameters ?? []).map((p) => p.$ref?.split("/").pop() ?? "v");
|
|
430
|
-
|
|
541
|
+
// SAFETY: `new Function(...sp, body)` builds a function over the setter's own parameters.
|
|
542
|
+
descriptor.set = new Function(...sp, method.setter.body ?? "") as DynamicMethod;
|
|
431
543
|
}
|
|
432
544
|
Object.defineProperty(DynClass.prototype, name, {
|
|
433
545
|
...descriptor,
|
|
434
546
|
configurable: true,
|
|
435
547
|
});
|
|
436
548
|
} else if (method.scope === "static") {
|
|
437
|
-
(DynClass
|
|
549
|
+
Object.assign(DynClass, { [name]: new Function(...params, bodyStr) });
|
|
438
550
|
} else {
|
|
439
|
-
(DynClass
|
|
551
|
+
Object.assign(DynClass.prototype, { [name]: new Function(...params, bodyStr) });
|
|
440
552
|
}
|
|
441
553
|
}
|
|
442
554
|
|
package/src/server.ts
CHANGED
|
@@ -42,6 +42,24 @@ import { handleImportApi } from "./import-api.ts";
|
|
|
42
42
|
import { mediaTypeForPath } from "@jxsuite/schema/media-type";
|
|
43
43
|
import { existsSync, readFileSync } from "node:fs";
|
|
44
44
|
import { problem } from "./problem.ts";
|
|
45
|
+
import { isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
46
|
+
import type { JsonValue } from "@jxsuite/schema/types";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read `key` off a parsed `package.json` value, or `undefined` when the value is not an object.
|
|
50
|
+
*
|
|
51
|
+
* Every lookup below reads through this one, so a manifest of the wrong shape (an `exports` that is
|
|
52
|
+
* a string, a `main` that is an array) yields "absent" rather than a member of something else.
|
|
53
|
+
*/
|
|
54
|
+
function manifestField(value: JsonValue | undefined, key: string): JsonValue | undefined {
|
|
55
|
+
return isJsonObject(value) ? value[key] : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read and parse a package's `package.json`. Throws when the file is not JSON. */
|
|
59
|
+
function readManifest(pkgJsonPath: string): JsonValue {
|
|
60
|
+
// SAFETY: JSON.parse without a reviver only ever yields a JSON value; its `any` says no more.
|
|
61
|
+
return JSON.parse(readFileSync(pkgJsonPath, "utf8")) as JsonValue;
|
|
62
|
+
}
|
|
45
63
|
|
|
46
64
|
/**
|
|
47
65
|
* Resolve an npm-style bare specifier from a URL path via node_modules. Handles scoped packages
|
|
@@ -52,14 +70,6 @@ import { problem } from "./problem.ts";
|
|
|
52
70
|
* @param {string} urlPath - URL pathname (e.g. "/pages/@jxsuite/parser/Foo.class.json")
|
|
53
71
|
* @returns {string | null} Absolute file path or null
|
|
54
72
|
*/
|
|
55
|
-
interface PackageJson {
|
|
56
|
-
exports?: Record<string, string | { import?: string; default?: string }>;
|
|
57
|
-
customElements?: string;
|
|
58
|
-
module?: string;
|
|
59
|
-
main?: string;
|
|
60
|
-
[key: string]: unknown;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
73
|
function resolveNpmPath(rootDir: string, urlPath: string) {
|
|
64
74
|
let root = rootDir;
|
|
65
75
|
let segments = urlPath.split("/").filter(Boolean);
|
|
@@ -121,10 +131,9 @@ function resolveNpmPath(rootDir: string, urlPath: string) {
|
|
|
121
131
|
// If there's a subpath, check package.json exports first
|
|
122
132
|
if (subpath) {
|
|
123
133
|
try {
|
|
124
|
-
const pkgJson =
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
if (typeof exportVal === "string") {
|
|
134
|
+
const pkgJson = readManifest(pkgJsonPath);
|
|
135
|
+
const exportVal = manifestField(manifestField(pkgJson, "exports"), `./${subpath}`);
|
|
136
|
+
if (isString(exportVal)) {
|
|
128
137
|
const mapped = join(pkgDir, exportVal);
|
|
129
138
|
if (existsSync(mapped)) {
|
|
130
139
|
return mapped;
|
|
@@ -138,9 +147,9 @@ function resolveNpmPath(rootDir: string, urlPath: string) {
|
|
|
138
147
|
}
|
|
139
148
|
// CEM-relative: subpath may be relative to the custom elements manifest directory
|
|
140
149
|
try {
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
143
|
-
const cemDir =
|
|
150
|
+
const customElements = manifestField(readManifest(pkgJsonPath), "customElements");
|
|
151
|
+
if (isString(customElements) && customElements) {
|
|
152
|
+
const cemDir = customElements.replace(/\/[^/]+$/, "");
|
|
144
153
|
const cemRelative = join(pkgDir, cemDir, subpath);
|
|
145
154
|
if (existsSync(cemRelative)) {
|
|
146
155
|
return cemRelative;
|
|
@@ -151,16 +160,20 @@ function resolveNpmPath(rootDir: string, urlPath: string) {
|
|
|
151
160
|
|
|
152
161
|
// Bare package (no subpath): resolve entry point
|
|
153
162
|
try {
|
|
154
|
-
const pkgJson =
|
|
155
|
-
const exp = pkgJson
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
163
|
+
const pkgJson = readManifest(pkgJsonPath);
|
|
164
|
+
const exp = manifestField(manifestField(pkgJson, "exports"), ".");
|
|
165
|
+
// An explicit `null` entry declines the package's entry point outright: no `module`/`main`.
|
|
166
|
+
if (exp !== null) {
|
|
167
|
+
const conditional = isNonNullObject(exp)
|
|
168
|
+
? (manifestField(exp, "import") ?? manifestField(exp, "default"))
|
|
169
|
+
: exp;
|
|
170
|
+
const entry =
|
|
171
|
+
conditional ?? manifestField(pkgJson, "module") ?? manifestField(pkgJson, "main");
|
|
172
|
+
if (isString(entry) && entry) {
|
|
173
|
+
const resolved = join(pkgDir, entry);
|
|
174
|
+
if (existsSync(resolved)) {
|
|
175
|
+
return resolved;
|
|
176
|
+
}
|
|
164
177
|
}
|
|
165
178
|
}
|
|
166
179
|
} catch {}
|
|
@@ -170,6 +183,9 @@ function resolveNpmPath(rootDir: string, urlPath: string) {
|
|
|
170
183
|
|
|
171
184
|
export { resolveNpmPath };
|
|
172
185
|
|
|
186
|
+
/** The watcher options `createDevServer` passes through when `watch` is an object. */
|
|
187
|
+
type DevWatchOptions = NonNullable<Parameters<typeof createWatcher>[2]>;
|
|
188
|
+
|
|
173
189
|
/**
|
|
174
190
|
* Create and start a Jx development server.
|
|
175
191
|
*
|
|
@@ -209,7 +225,7 @@ export async function createDevServer(options: {
|
|
|
209
225
|
match?: ((path: string) => boolean) | RegExp;
|
|
210
226
|
label?: string;
|
|
211
227
|
}[];
|
|
212
|
-
watch?: boolean |
|
|
228
|
+
watch?: boolean | DevWatchOptions;
|
|
213
229
|
studio?: boolean;
|
|
214
230
|
middleware?: (req: Request, url: URL) => Response | null | Promise<Response | null>;
|
|
215
231
|
}) {
|
|
@@ -248,7 +264,7 @@ export async function createDevServer(options: {
|
|
|
248
264
|
let handleSSE = null;
|
|
249
265
|
let fsWatcher: ReturnType<typeof createWatcher>["watcher"] | null = null;
|
|
250
266
|
if (watch !== false) {
|
|
251
|
-
const watchOpts =
|
|
267
|
+
const watchOpts = watch === true ? {} : watch;
|
|
252
268
|
const watcher = createWatcher(absRoot, builds, watchOpts);
|
|
253
269
|
({ handleSSE } = watcher);
|
|
254
270
|
fsWatcher = watcher.watcher;
|
|
@@ -388,6 +404,10 @@ export async function createDevServer(options: {
|
|
|
388
404
|
// (`isOwnedProjectDir`), so a hostile activate cannot widen the filesystem API to arbitrary
|
|
389
405
|
// Directories.
|
|
390
406
|
if (path === "/__studio/activate" && req.method === "POST") {
|
|
407
|
+
/* SAFETY: trust boundary. The body parsed as JSON, and this route's caller is Studio's
|
|
408
|
+
project activation, which posts `{ root }` with a path or nothing. A body of another
|
|
409
|
+
shape throws in this handler (resolve() refuses a non-string root, and a `null` body
|
|
410
|
+
has no members), which Bun answers with a 500, as it always has. */
|
|
391
411
|
const body = (await req.json()) as { root?: string };
|
|
392
412
|
const raw = body.root || null;
|
|
393
413
|
if (!raw) {
|