@jxsuite/server 4.5.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +18 -18
- 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 +96 -54
- 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 +28 -38
- package/src/problem.ts +3 -6
- package/src/project-server.ts +39 -9
- 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 +19 -10
- 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 +269 -82
- package/src/server.ts +52 -28
- package/src/site-output.ts +11 -5
- package/src/sse.ts +16 -2
- package/src/studio-api.ts +389 -145
- package/src/types.ts +17 -7
- package/src/watch.ts +8 -6
package/src/resolve.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
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 {
|
|
6
|
+
import { isFunction, isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
7
|
+
import { readFileSync, statSync } from "node:fs";
|
|
7
8
|
import { createRequire } from "node:module";
|
|
8
9
|
import { projectDevEnv } from "./dev-vars.ts";
|
|
9
10
|
import { containedPath } from "./net-guard.ts";
|
|
@@ -11,35 +12,123 @@ 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 { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
18
|
+
import type {
|
|
19
|
+
JsonObject,
|
|
20
|
+
JsonPrimitive,
|
|
21
|
+
JsonValue,
|
|
22
|
+
JxClassCtorDef,
|
|
23
|
+
JxClassDef,
|
|
24
|
+
ProjectConfig,
|
|
25
|
+
} from "@jxsuite/schema/types";
|
|
26
|
+
import type { ProjectDevEnv } from "./dev-vars.ts";
|
|
16
27
|
import { problem } from "./problem.ts";
|
|
17
28
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Any value a module exports, as the proxies see it before they check it. Nothing about an export
|
|
31
|
+
* is known statically (it is project or package code loaded by path), so the type only says what
|
|
32
|
+
* reading it can yield: a function, an object whose members can be read in turn, or a primitive.
|
|
33
|
+
*/
|
|
34
|
+
type ExportedValue =
|
|
35
|
+
| ExportedFunction
|
|
36
|
+
| ExportedConstructor
|
|
37
|
+
| ExportedObject
|
|
38
|
+
| JsonPrimitive
|
|
39
|
+
| bigint
|
|
40
|
+
| symbol
|
|
41
|
+
| undefined;
|
|
42
|
+
|
|
43
|
+
/** A function export as it is called. Its real signature is the contract of whoever calls it. */
|
|
44
|
+
type ExportedFunction = (...args: never[]) => ExportedValue | Promise<ExportedValue>;
|
|
45
|
+
|
|
46
|
+
/** A class export as it is constructed. Its real signature is the contract of whoever builds it. */
|
|
47
|
+
type ExportedConstructor = new (...args: never[]) => object;
|
|
48
|
+
|
|
49
|
+
/** A non-primitive export. Reading any member of it yields another {@link ExportedValue}. */
|
|
50
|
+
interface ExportedObject {
|
|
51
|
+
readonly [member: string]: ExportedValue;
|
|
21
52
|
}
|
|
22
53
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
54
|
+
/**
|
|
55
|
+
* A loaded module's namespace object. `default` is read as an object because the proxies only ever
|
|
56
|
+
* read a named member off it, which is defined for any non-nullish value.
|
|
57
|
+
*/
|
|
58
|
+
interface ModuleNamespace extends ExportedObject {
|
|
59
|
+
readonly default?: ExportedObject;
|
|
27
60
|
}
|
|
28
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The project context a class receives as `config._project`: the config, the root, and every
|
|
64
|
+
* extension-loaded section under its section key (the compiler's `_project` shape).
|
|
65
|
+
*/
|
|
66
|
+
type ProjectContext = ProjectSections & { config: ProjectConfig; root: string };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One `project.json` generation: its config, extension registry and asset mounts, loaded together
|
|
70
|
+
* because they are cheap, and the extension-loaded sections, loaded only when a class asks for
|
|
71
|
+
* them.
|
|
72
|
+
*/
|
|
73
|
+
interface ProjectEntry {
|
|
74
|
+
mtime: number;
|
|
75
|
+
config: ProjectConfig;
|
|
76
|
+
registry: ExtensionRegistry;
|
|
77
|
+
mounts: AssetMount[];
|
|
78
|
+
/** The full `_project` context, started by the first {@link loadProjectContext} and shared. */
|
|
79
|
+
context?: Promise<ProjectContext> | undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A value the resolve proxy hands a class constructor under one config key. */
|
|
83
|
+
type ConfigValue = JsonValue | ProjectContext | undefined;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The `config` a class is constructed with: the request's own members, plus the injected project
|
|
87
|
+
* context and an empty document scope.
|
|
88
|
+
*/
|
|
89
|
+
interface ResolveConfig {
|
|
90
|
+
[key: string]: ConfigValue;
|
|
91
|
+
_project?: ProjectContext;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A class `/__jx_resolve__` constructs from an `$implementation` module. */
|
|
95
|
+
type ResolvableClass = new (config: ResolveConfig) => ResolvableInstance;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What the resolve proxy reads off a constructed instance, in the order spec.md §12.3 fixes:
|
|
99
|
+
* `resolve()` when it is a method, else `value` when the instance has one, else the instance.
|
|
100
|
+
*/
|
|
101
|
+
interface ResolvableInstance {
|
|
102
|
+
readonly resolve?: ExportedValue;
|
|
103
|
+
readonly value?: ExportedValue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* What a server function may answer: a JSON value, sent as the response body (`undefined` →
|
|
108
|
+
* `null`).
|
|
109
|
+
*/
|
|
110
|
+
type ServerFunctionResult = JsonValue | undefined;
|
|
111
|
+
|
|
112
|
+
/** A `timing: "server"` function: `fn(args, env)`, the production contract the compiler emits. */
|
|
113
|
+
type ServerFunction = (
|
|
114
|
+
args: JsonObject,
|
|
115
|
+
env: ProjectDevEnv,
|
|
116
|
+
) => ServerFunctionResult | Promise<ServerFunctionResult>;
|
|
117
|
+
|
|
29
118
|
interface ResolveBody {
|
|
30
119
|
$src?: string;
|
|
31
120
|
$prototype?: string;
|
|
32
121
|
$export?: string;
|
|
33
122
|
$base?: string;
|
|
34
|
-
[key: string]:
|
|
123
|
+
[key: string]: JsonValue | undefined;
|
|
35
124
|
}
|
|
36
125
|
|
|
37
126
|
interface ServerFunctionBody {
|
|
38
127
|
$src?: string;
|
|
39
128
|
$export?: string;
|
|
40
129
|
$base?: string;
|
|
41
|
-
arguments?:
|
|
42
|
-
[key: string]:
|
|
130
|
+
arguments?: JsonObject;
|
|
131
|
+
[key: string]: JsonValue | undefined;
|
|
43
132
|
}
|
|
44
133
|
|
|
45
134
|
/**
|
|
@@ -92,11 +181,19 @@ function baseDirectory($base: string, root: string, projectRoot: string): string
|
|
|
92
181
|
return resolve(root, `.${urlDir}`);
|
|
93
182
|
}
|
|
94
183
|
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Per-project cache, invalidated when project.json changes on disk. It holds the PROMISE, keyed by
|
|
186
|
+
* the mtime it was started for, so concurrent requests on a cold cache share one load.
|
|
187
|
+
*
|
|
188
|
+
* Both halves of that are load-bearing. Every project file the canvas fetches asks for the asset
|
|
189
|
+
* mounts, and the canvas fetches a document's `$elements` in parallel: before the promise was
|
|
190
|
+
* cached, each of them started its own load, so a page with 25 components ran 25 at once. And the
|
|
191
|
+
* sections are not part of it: a content section loads every entry of every collection, which for a
|
|
192
|
+
* 910-entry knowledge base is ten seconds of parsing, so serving a JSON file next to it waited ten
|
|
193
|
+
* seconds for data it never reads. Both together put a component fetch past the canvas's register
|
|
194
|
+
* timeout on every cold open.
|
|
195
|
+
*/
|
|
196
|
+
const projectEntryCache = new Map<string, { mtime: number; entry: Promise<ProjectEntry | null> }>();
|
|
100
197
|
|
|
101
198
|
/**
|
|
102
199
|
* Lazy-load project context (project.json + extension-loaded sections) for class instantiation.
|
|
@@ -104,33 +201,86 @@ const projectContextCache = new Map<
|
|
|
104
201
|
* `_project` shape.
|
|
105
202
|
*
|
|
106
203
|
* @param {string} projectRoot
|
|
107
|
-
* @returns {Promise<
|
|
204
|
+
* @returns {Promise<ProjectContext | null>} `{ config, root, ...sections }` or null
|
|
108
205
|
*/
|
|
109
|
-
async function loadProjectContext(projectRoot: string) {
|
|
206
|
+
async function loadProjectContext(projectRoot: string): Promise<ProjectContext | null> {
|
|
110
207
|
const entry = await loadProjectEntry(projectRoot);
|
|
111
|
-
|
|
208
|
+
if (!entry) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
entry.context ??= loadProjectSections(projectRoot, entry.config, entry.registry, {
|
|
212
|
+
lazy: true,
|
|
213
|
+
}).then((sections) => ({ config: entry.config, root: projectRoot, ...sections }));
|
|
214
|
+
try {
|
|
215
|
+
return await entry.context;
|
|
216
|
+
} catch {
|
|
217
|
+
// A failed load is not remembered: the next class to ask tries again.
|
|
218
|
+
entry.context = undefined;
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Whether a class reads `_project` through its sections' on-demand methods, by declaring `static
|
|
225
|
+
* projectOnDemand = true` (specs/extensions.md §8). The sections here are loaded lazily, so a class
|
|
226
|
+
* that reads one synchronously (a `content.get(type)`) would find nothing it had not loaded; such a
|
|
227
|
+
* class is handed every section whole instead, exactly what it was always given.
|
|
228
|
+
*/
|
|
229
|
+
function readsProjectOnDemand(ExportedClass: ExportedFunction | ExportedConstructor): boolean {
|
|
230
|
+
return "projectOnDemand" in ExportedClass && ExportedClass.projectOnDemand === true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A section an owner loaded on demand: `complete()` makes it whole (extensions.md §8). */
|
|
234
|
+
interface OnDemandSection {
|
|
235
|
+
complete: () => Promise<void>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Whether a project-context value is a section loaded on demand, by its `complete` method. */
|
|
239
|
+
function isOnDemandSection(
|
|
240
|
+
value: ProjectContext[string],
|
|
241
|
+
): value is ProjectContext[string] & OnDemandSection {
|
|
242
|
+
return isNonNullObject(value) && "complete" in value && isFunction(value.complete);
|
|
112
243
|
}
|
|
113
244
|
|
|
114
|
-
/**
|
|
115
|
-
async function
|
|
245
|
+
/** Make every section of a project context whole, for a class that reads them synchronously. */
|
|
246
|
+
async function completeSections(context: ProjectContext): Promise<void> {
|
|
247
|
+
for (const value of Object.values(context)) {
|
|
248
|
+
if (isOnDemandSection(value)) {
|
|
249
|
+
await value.complete();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Load (or reuse) the cached project config, extension registry and asset mounts. */
|
|
255
|
+
async function loadProjectEntry(projectRoot: string): Promise<ProjectEntry | null> {
|
|
116
256
|
const projectJsonPath = resolve(projectRoot, "project.json");
|
|
117
|
-
|
|
257
|
+
const mtime = statSync(projectJsonPath, { throwIfNoEntry: false })?.mtimeMs;
|
|
258
|
+
if (mtime === undefined) {
|
|
118
259
|
return null;
|
|
119
260
|
}
|
|
261
|
+
const cached = projectEntryCache.get(projectRoot);
|
|
262
|
+
if (cached && cached.mtime === mtime) {
|
|
263
|
+
return cached.entry;
|
|
264
|
+
}
|
|
265
|
+
const entry = readProjectEntry(projectRoot, projectJsonPath, mtime);
|
|
266
|
+
projectEntryCache.set(projectRoot, { entry, mtime });
|
|
267
|
+
return entry;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Read one `project.json` generation. Never rejects: any failure answers "no project context". */
|
|
271
|
+
async function readProjectEntry(
|
|
272
|
+
projectRoot: string,
|
|
273
|
+
projectJsonPath: string,
|
|
274
|
+
mtime: number,
|
|
275
|
+
): Promise<ProjectEntry | null> {
|
|
120
276
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return cached;
|
|
125
|
-
}
|
|
277
|
+
/* SAFETY: trust boundary. project.json is the project's own config file, which the editor
|
|
278
|
+
and `jx validate` hold to its per-project schema. Any failure reading it below is caught
|
|
279
|
+
here and answers "no project context". */
|
|
126
280
|
const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
|
|
127
281
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
128
|
-
const sections = await loadProjectSections(projectRoot, config, registry);
|
|
129
282
|
const { mounts } = await loadAssetMounts(registry, config, projectRoot);
|
|
130
|
-
|
|
131
|
-
const entry = { context, mounts, mtime: mtimeMs };
|
|
132
|
-
projectContextCache.set(projectRoot, entry);
|
|
133
|
-
return entry;
|
|
283
|
+
return { config, mounts, mtime, registry };
|
|
134
284
|
} catch {
|
|
135
285
|
return null;
|
|
136
286
|
}
|
|
@@ -148,8 +298,7 @@ async function loadProjectEntry(projectRoot: string) {
|
|
|
148
298
|
*/
|
|
149
299
|
export async function projectSiteBase(projectRoot: string): Promise<string> {
|
|
150
300
|
const entry = await loadProjectEntry(projectRoot);
|
|
151
|
-
|
|
152
|
-
return siteBasePath(config?.url);
|
|
301
|
+
return siteBasePath(entry?.config.url);
|
|
153
302
|
}
|
|
154
303
|
|
|
155
304
|
/**
|
|
@@ -178,12 +327,16 @@ export async function handleResolve(
|
|
|
178
327
|
) {
|
|
179
328
|
let body: ResolveBody;
|
|
180
329
|
try {
|
|
330
|
+
/* SAFETY: trust boundary. The body parsed as JSON, and this route's caller is the runtime's
|
|
331
|
+
`$prototype` proxy, which posts the entry's own JSON members with string `$`-keys. A body
|
|
332
|
+
that breaks that throws in this handler, as it always has. */
|
|
181
333
|
body = (await req.json()) as ResolveBody;
|
|
182
334
|
} catch {
|
|
183
335
|
return problem("invalidRequest", "Invalid JSON body");
|
|
184
336
|
}
|
|
185
337
|
|
|
186
|
-
const { $src, $prototype, $export: xport, $base, ...
|
|
338
|
+
const { $src, $prototype, $export: xport, $base, ...members } = body;
|
|
339
|
+
const config: ResolveConfig = members;
|
|
187
340
|
if (!$src) {
|
|
188
341
|
return problem("invalidRequest", "Missing $src");
|
|
189
342
|
}
|
|
@@ -228,7 +381,7 @@ export async function handleResolve(
|
|
|
228
381
|
const docDir = docUrlPath.slice(0, docUrlPath.lastIndexOf("/") + 1);
|
|
229
382
|
const docAbsDir = resolve(root, `.${docDir}`);
|
|
230
383
|
for (const [k, v] of Object.entries(config)) {
|
|
231
|
-
if (
|
|
384
|
+
if (isString(v) && (v.startsWith("./") || v.startsWith("../"))) {
|
|
232
385
|
config[k] = `./${relative(process.cwd(), resolve(docAbsDir, v)).split("\\").join("/")}`;
|
|
233
386
|
}
|
|
234
387
|
}
|
|
@@ -257,38 +410,31 @@ export async function handleResolve(
|
|
|
257
410
|
return problem("pathOutsideProject", "$implementation escapes the project root");
|
|
258
411
|
}
|
|
259
412
|
const exportName = xport ?? classDef.title ?? $prototype;
|
|
260
|
-
const mod =
|
|
413
|
+
const mod = await importModule(implPath);
|
|
261
414
|
const ExportedClass =
|
|
262
415
|
exportName === undefined ? undefined : (mod[exportName] ?? mod.default?.[exportName]);
|
|
263
|
-
if (
|
|
416
|
+
if (!isExportedFunction(ExportedClass)) {
|
|
264
417
|
return new Response(`Export "${exportName}" not found in "${classDef.$implementation}"`, {
|
|
265
418
|
status: 500,
|
|
266
419
|
});
|
|
267
420
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
return Response.json(
|
|
421
|
+
if (config._project && !readsProjectOnDemand(ExportedClass)) {
|
|
422
|
+
await completeSections(config._project);
|
|
423
|
+
}
|
|
424
|
+
/* SAFETY: trust boundary. A class file's `$implementation` names the module exporting the
|
|
425
|
+
class it describes, whose constructor takes one config object (spec.md §12.3, §12.4).
|
|
426
|
+
It is a function, checked above; one that is not constructible throws here, inside the
|
|
427
|
+
try whose catch answers a problem. */
|
|
428
|
+
const ClassCtor = ExportedClass as ResolvableClass;
|
|
429
|
+
return Response.json(await instanceResult(new ClassCtor(config)));
|
|
277
430
|
}
|
|
278
431
|
|
|
279
|
-
// Self-contained: construct class from schema
|
|
432
|
+
// Self-contained: construct class from schema. Its JSON bodies cannot await a section.
|
|
433
|
+
if (config._project) {
|
|
434
|
+
await completeSections(config._project);
|
|
435
|
+
}
|
|
280
436
|
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);
|
|
437
|
+
return Response.json(await instanceResult(new DynClass(config)));
|
|
292
438
|
} catch (error) {
|
|
293
439
|
return problem("internalError", errorMessage(error));
|
|
294
440
|
}
|
|
@@ -319,6 +465,10 @@ export async function handleResolve(
|
|
|
319
465
|
export async function handleServerFunction(req: Request, root: string, projectRoot = root) {
|
|
320
466
|
let body: ServerFunctionBody;
|
|
321
467
|
try {
|
|
468
|
+
/* SAFETY: trust boundary. The body parsed as JSON, and this route's caller is the runtime's
|
|
469
|
+
server-function proxy, which posts `{ $src, $export, $base, arguments }` with string
|
|
470
|
+
`$`-keys and an object of arguments. A body that breaks that throws in this handler, as it
|
|
471
|
+
always has. */
|
|
322
472
|
body = (await req.json()) as ServerFunctionBody;
|
|
323
473
|
} catch {
|
|
324
474
|
return problem("invalidRequest", "Invalid JSON body");
|
|
@@ -344,7 +494,7 @@ export async function handleServerFunction(req: Request, root: string, projectRo
|
|
|
344
494
|
|
|
345
495
|
let mod: ModuleNamespace;
|
|
346
496
|
try {
|
|
347
|
-
mod =
|
|
497
|
+
mod = await importModule(moduleAbsPath);
|
|
348
498
|
} catch (error) {
|
|
349
499
|
return new Response(`Failed to import "${$src}": ${errorMessage(error)}`, {
|
|
350
500
|
status: 500,
|
|
@@ -352,7 +502,7 @@ export async function handleServerFunction(req: Request, root: string, projectRo
|
|
|
352
502
|
}
|
|
353
503
|
|
|
354
504
|
const fn = mod[xport] ?? mod.default?.[xport];
|
|
355
|
-
if (
|
|
505
|
+
if (!isExportedFunction(fn)) {
|
|
356
506
|
return new Response(`Export "${xport}" not found in "${$src}"`, {
|
|
357
507
|
status: 500,
|
|
358
508
|
});
|
|
@@ -362,16 +512,53 @@ export async function handleServerFunction(req: Request, root: string, projectRo
|
|
|
362
512
|
/* Match the production contract `fn(args, env)` (compiler emits `fn(args, c.env)`): the dev
|
|
363
513
|
proxy runs server-side, so its `env` is the extension mounts' own, `process.env` merged under
|
|
364
514
|
the project's `.dev.vars` plus `JX_PROJECT_ROOT` (server.md §3.3). */
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
);
|
|
515
|
+
/* SAFETY: trust boundary. A `timing: "server"` module exports its functions with the signature
|
|
516
|
+
`fn(args, env)` and a JSON answer (spec.md §11.4); this one is a function, checked above. */
|
|
517
|
+
const serverFn = fn as ServerFunction;
|
|
518
|
+
const result = await serverFn(args, projectDevEnv(projectRoot));
|
|
369
519
|
return Response.json(result ?? null);
|
|
370
520
|
} catch (error) {
|
|
371
521
|
return problem("internalError", errorMessage(error));
|
|
372
522
|
}
|
|
373
523
|
}
|
|
374
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Whether an export is a function. Every function is callable, constructible or both, which is all
|
|
527
|
+
* the narrowed type claims; which of the two a proxy needs is that proxy's own contract.
|
|
528
|
+
*/
|
|
529
|
+
function isExportedFunction(value: ExportedValue): value is ExportedFunction | ExportedConstructor {
|
|
530
|
+
return isFunction(value);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Import a module by absolute path, as its namespace object. */
|
|
534
|
+
async function importModule(path: string): Promise<ModuleNamespace> {
|
|
535
|
+
/* SAFETY: a dynamic import() resolves to the module's namespace object, whose own members are its
|
|
536
|
+
exports; every member the proxies read off one is checked to be a function before use. */
|
|
537
|
+
return (await import(path)) as ModuleNamespace;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** What a constructed class answers with: its `resolve()` result, else its `value`, else itself. */
|
|
541
|
+
async function instanceResult(
|
|
542
|
+
instance: ResolvableInstance,
|
|
543
|
+
): Promise<ExportedValue | ResolvableInstance> {
|
|
544
|
+
if (isFunction(instance.resolve)) {
|
|
545
|
+
return await instance.resolve();
|
|
546
|
+
}
|
|
547
|
+
return "value" in instance ? instance.value : instance;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Whether a class document's `$defs.constructor` is its own constructor definition, rather than the
|
|
552
|
+
* `Object.prototype.constructor` function every object parsed from JSON inherits.
|
|
553
|
+
*
|
|
554
|
+
* @param rawCtor What `$defs.constructor` reads as
|
|
555
|
+
*/
|
|
556
|
+
function isCtorDef(
|
|
557
|
+
rawCtor: NonNullable<JxClassDef["$defs"]>["constructor"],
|
|
558
|
+
): rawCtor is JxClassCtorDef {
|
|
559
|
+
return isNonNullObject(rawCtor);
|
|
560
|
+
}
|
|
561
|
+
|
|
375
562
|
/**
|
|
376
563
|
* Dynamically construct a class from a .class.json schema definition. Server-side variant — no
|
|
377
564
|
* private field limitations.
|
|
@@ -380,26 +567,24 @@ export async function handleServerFunction(req: Request, root: string, projectRo
|
|
|
380
567
|
*/
|
|
381
568
|
function classFromSchema(classDef: JxClassDef) {
|
|
382
569
|
const fields = classDef.$defs?.fields ?? {};
|
|
383
|
-
// JSON objects inherit Object.prototype.constructor — only an own object value counts.
|
|
384
570
|
const rawCtor = classDef.$defs?.constructor;
|
|
385
|
-
const ctor =
|
|
571
|
+
const ctor = isCtorDef(rawCtor) ? rawCtor : undefined;
|
|
386
572
|
const methods = classDef.$defs?.methods ?? {};
|
|
387
573
|
|
|
388
574
|
// oxlint-disable-next-line typescript/no-extraneous-class -- methods are attached to the prototype dynamically below
|
|
389
575
|
class DynClass {
|
|
390
|
-
constructor(config = {}) {
|
|
391
|
-
|
|
392
|
-
const cfg = config as Record<string, unknown>;
|
|
576
|
+
constructor(config: ResolveConfig = {}) {
|
|
577
|
+
// Each field lands as an own property by assignment, as `this[id] = value` would put it.
|
|
393
578
|
for (const [key, field] of Object.entries(fields)) {
|
|
394
579
|
const id = field.identifier ?? key;
|
|
395
|
-
if (
|
|
396
|
-
|
|
580
|
+
if (config[id] !== undefined) {
|
|
581
|
+
Object.assign(this, { [id]: config[id] });
|
|
397
582
|
} else if (field.initializer !== undefined) {
|
|
398
|
-
|
|
583
|
+
Object.assign(this, { [id]: field.initializer });
|
|
399
584
|
} else if (field.default !== undefined) {
|
|
400
|
-
|
|
585
|
+
Object.assign(this, { [id]: structuredClone(field.default) });
|
|
401
586
|
} else {
|
|
402
|
-
|
|
587
|
+
Object.assign(this, { [id]: null });
|
|
403
588
|
}
|
|
404
589
|
}
|
|
405
590
|
if (ctor?.body) {
|
|
@@ -416,27 +601,29 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
416
601
|
return p.$ref.split("/").pop() ?? "arg";
|
|
417
602
|
}
|
|
418
603
|
const n = p.identifier ?? p.name;
|
|
419
|
-
return
|
|
604
|
+
return isString(n) ? n : "arg";
|
|
420
605
|
});
|
|
421
606
|
const bodyStr = Array.isArray(method.body) ? method.body.join("\n") : (method.body ?? "");
|
|
422
607
|
|
|
423
608
|
if (method.role === "accessor") {
|
|
424
609
|
const descriptor: PropertyDescriptor = {};
|
|
425
610
|
if (method.getter) {
|
|
426
|
-
|
|
611
|
+
// SAFETY: `new Function(body)` with no parameter names builds the zero-argument getter.
|
|
612
|
+
descriptor.get = new Function(method.getter.body ?? "") as DynamicMethod;
|
|
427
613
|
}
|
|
428
614
|
if (method.setter) {
|
|
429
615
|
const sp = (method.setter.parameters ?? []).map((p) => p.$ref?.split("/").pop() ?? "v");
|
|
430
|
-
|
|
616
|
+
// SAFETY: `new Function(...sp, body)` builds a function over the setter's own parameters.
|
|
617
|
+
descriptor.set = new Function(...sp, method.setter.body ?? "") as DynamicMethod;
|
|
431
618
|
}
|
|
432
619
|
Object.defineProperty(DynClass.prototype, name, {
|
|
433
620
|
...descriptor,
|
|
434
621
|
configurable: true,
|
|
435
622
|
});
|
|
436
623
|
} else if (method.scope === "static") {
|
|
437
|
-
(DynClass
|
|
624
|
+
Object.assign(DynClass, { [name]: new Function(...params, bodyStr) });
|
|
438
625
|
} else {
|
|
439
|
-
(DynClass
|
|
626
|
+
Object.assign(DynClass.prototype, { [name]: new Function(...params, bodyStr) });
|
|
440
627
|
}
|
|
441
628
|
}
|
|
442
629
|
|
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) {
|
|
@@ -534,7 +554,11 @@ export async function createDevServer(options: {
|
|
|
534
554
|
|
|
535
555
|
// Resolve npm-style bare specifiers via node_modules.
|
|
536
556
|
// Bundle on-demand so internal bare specifiers (e.g. lit/...) resolve.
|
|
537
|
-
|
|
557
|
+
/* The open project's own packages first: it pins the versions its pages are built with,
|
|
558
|
+
and a project outside the served root has no other way to reach them. */
|
|
559
|
+
const resolved =
|
|
560
|
+
(activeProjectRoot ? resolveNpmPath(activeProjectRoot, path) : null) ??
|
|
561
|
+
resolveNpmPath(absRoot, path);
|
|
538
562
|
if (resolved) {
|
|
539
563
|
const cacheKey = resolved;
|
|
540
564
|
if (!bundleCache.has(cacheKey)) {
|