@jxsuite/server 4.4.5 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -10
- 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 +28 -4
- 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 +30 -33
- 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 +235 -83
- package/src/server.ts +51 -29
- 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/resolve.ts
CHANGED
|
@@ -1,44 +1,126 @@
|
|
|
1
1
|
/** Resolve.js — Generic $src module proxy + timing: "server" function proxy */
|
|
2
2
|
|
|
3
3
|
import { siteBasePath } from "@jxsuite/schema/asset-paths";
|
|
4
|
-
import { dirname, relative, resolve } from "node:path";
|
|
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";
|
|
9
|
+
import { projectDevEnv } from "./dev-vars.ts";
|
|
8
10
|
import { containedPath } from "./net-guard.ts";
|
|
9
11
|
import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
|
|
10
12
|
import { loadProjectSections } from "@jxsuite/compiler/project-sections";
|
|
11
13
|
import { loadAssetMounts } from "@jxsuite/compiler/asset-mounts";
|
|
12
14
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
13
|
-
import type {
|
|
14
|
-
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";
|
|
15
26
|
import { problem } from "./problem.ts";
|
|
16
27
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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;
|
|
59
|
+
}
|
|
60
|
+
|
|
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;
|
|
20
84
|
}
|
|
21
85
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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;
|
|
26
96
|
}
|
|
27
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
|
+
|
|
28
110
|
interface ResolveBody {
|
|
29
111
|
$src?: string;
|
|
30
112
|
$prototype?: string;
|
|
31
113
|
$export?: string;
|
|
32
114
|
$base?: string;
|
|
33
|
-
[key: string]:
|
|
115
|
+
[key: string]: JsonValue | undefined;
|
|
34
116
|
}
|
|
35
117
|
|
|
36
118
|
interface ServerFunctionBody {
|
|
37
119
|
$src?: string;
|
|
38
120
|
$export?: string;
|
|
39
121
|
$base?: string;
|
|
40
|
-
arguments?:
|
|
41
|
-
[key: string]:
|
|
122
|
+
arguments?: JsonObject;
|
|
123
|
+
[key: string]: JsonValue | undefined;
|
|
42
124
|
}
|
|
43
125
|
|
|
44
126
|
/**
|
|
@@ -56,11 +138,43 @@ function isImportable(p: string, root: string, activeProjectRoot: string | null)
|
|
|
56
138
|
return false;
|
|
57
139
|
}
|
|
58
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The directory holding the document a `$base` names, which a relative `$src` resolves against.
|
|
143
|
+
*
|
|
144
|
+
* Hosts spell a base two ways. A page served at a site URL (`jx dev`, the live preview) names a
|
|
145
|
+
* path under `root`, and a single leading slash always means that, even when the path would also
|
|
146
|
+
* read as an absolute one: a root `/app` may hold a site directory `app/`. Studio's canvas
|
|
147
|
+
* addresses a project by its absolute filesystem path instead (`documentBase` in
|
|
148
|
+
* `packages/studio`), so its base arrives as `//abs/project/pages/x.json`, or
|
|
149
|
+
* `/C:/project/pages/x.json` on Windows: the spelling the static file lanes map back to the file
|
|
150
|
+
* (`serveProjectFile`). A `file:` URL's path is a filesystem path too. Those spellings name the
|
|
151
|
+
* path itself only when it lies under `root` or the active project, so any other path keeps meaning
|
|
152
|
+
* a site URL. The pathname is percent-decoded first, because a URL encodes a space in a directory
|
|
153
|
+
* name and the filesystem does not.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} $base - The document URL the runtime sent
|
|
156
|
+
* @param {string} root - The root a site URL names a path under
|
|
157
|
+
* @param {string} projectRoot - The active project, which may sit outside `root`
|
|
158
|
+
* @returns {string} An absolute directory
|
|
159
|
+
*/
|
|
160
|
+
function baseDirectory($base: string, root: string, projectRoot: string): string {
|
|
161
|
+
const url = new URL($base);
|
|
162
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
163
|
+
const urlDir = pathname.slice(0, pathname.lastIndexOf("/") + 1);
|
|
164
|
+
const spellsPath = urlDir.startsWith("//") || /^\/[A-Za-z]:/.test(urlDir);
|
|
165
|
+
const fsDir = spellsPath ? urlDir.slice(1) : url.protocol === "file:" ? urlDir : null;
|
|
166
|
+
if (
|
|
167
|
+
fsDir !== null &&
|
|
168
|
+
isAbsolute(fsDir) &&
|
|
169
|
+
(containedPath(fsDir, root) !== null || containedPath(fsDir, projectRoot) !== null)
|
|
170
|
+
) {
|
|
171
|
+
return resolve(fsDir);
|
|
172
|
+
}
|
|
173
|
+
return resolve(root, `.${urlDir}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
59
176
|
/** Per-project context cache, invalidated when project.json changes on disk. */
|
|
60
|
-
const projectContextCache = new Map<
|
|
61
|
-
string,
|
|
62
|
-
{ mtime: number; context: Record<string, unknown>; mounts: AssetMount[] }
|
|
63
|
-
>();
|
|
177
|
+
const projectContextCache = new Map<string, ProjectEntry>();
|
|
64
178
|
|
|
65
179
|
/**
|
|
66
180
|
* Lazy-load project context (project.json + extension-loaded sections) for class instantiation.
|
|
@@ -68,15 +182,15 @@ const projectContextCache = new Map<
|
|
|
68
182
|
* `_project` shape.
|
|
69
183
|
*
|
|
70
184
|
* @param {string} projectRoot
|
|
71
|
-
* @returns {Promise<
|
|
185
|
+
* @returns {Promise<ProjectContext | null>} `{ config, root, ...sections }` or null
|
|
72
186
|
*/
|
|
73
|
-
async function loadProjectContext(projectRoot: string) {
|
|
187
|
+
async function loadProjectContext(projectRoot: string): Promise<ProjectContext | null> {
|
|
74
188
|
const entry = await loadProjectEntry(projectRoot);
|
|
75
189
|
return entry?.context ?? null;
|
|
76
190
|
}
|
|
77
191
|
|
|
78
192
|
/** Load (or reuse) the cached project context plus its extension asset mounts. */
|
|
79
|
-
async function loadProjectEntry(projectRoot: string) {
|
|
193
|
+
async function loadProjectEntry(projectRoot: string): Promise<ProjectEntry | null> {
|
|
80
194
|
const projectJsonPath = resolve(projectRoot, "project.json");
|
|
81
195
|
if (!existsSync(projectJsonPath)) {
|
|
82
196
|
return null;
|
|
@@ -87,12 +201,15 @@ async function loadProjectEntry(projectRoot: string) {
|
|
|
87
201
|
if (cached && cached.mtime === mtimeMs) {
|
|
88
202
|
return cached;
|
|
89
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". */
|
|
90
207
|
const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
|
|
91
208
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
92
209
|
const sections = await loadProjectSections(projectRoot, config, registry);
|
|
93
210
|
const { mounts } = await loadAssetMounts(registry, config, projectRoot);
|
|
94
|
-
const context = { config, root: projectRoot, ...sections };
|
|
95
|
-
const entry = { context, mounts, mtime: mtimeMs };
|
|
211
|
+
const context: ProjectContext = { config, root: projectRoot, ...sections };
|
|
212
|
+
const entry: ProjectEntry = { context, mounts, mtime: mtimeMs };
|
|
96
213
|
projectContextCache.set(projectRoot, entry);
|
|
97
214
|
return entry;
|
|
98
215
|
} catch {
|
|
@@ -112,8 +229,7 @@ async function loadProjectEntry(projectRoot: string) {
|
|
|
112
229
|
*/
|
|
113
230
|
export async function projectSiteBase(projectRoot: string): Promise<string> {
|
|
114
231
|
const entry = await loadProjectEntry(projectRoot);
|
|
115
|
-
|
|
116
|
-
return siteBasePath(config?.url);
|
|
232
|
+
return siteBasePath(entry?.context.config.url);
|
|
117
233
|
}
|
|
118
234
|
|
|
119
235
|
/**
|
|
@@ -142,12 +258,16 @@ export async function handleResolve(
|
|
|
142
258
|
) {
|
|
143
259
|
let body: ResolveBody;
|
|
144
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. */
|
|
145
264
|
body = (await req.json()) as ResolveBody;
|
|
146
265
|
} catch {
|
|
147
266
|
return problem("invalidRequest", "Invalid JSON body");
|
|
148
267
|
}
|
|
149
268
|
|
|
150
|
-
const { $src, $prototype, $export: xport, $base, ...
|
|
269
|
+
const { $src, $prototype, $export: xport, $base, ...members } = body;
|
|
270
|
+
const config: ResolveConfig = members;
|
|
151
271
|
if (!$src) {
|
|
152
272
|
return problem("invalidRequest", "Missing $src");
|
|
153
273
|
}
|
|
@@ -192,7 +312,7 @@ export async function handleResolve(
|
|
|
192
312
|
const docDir = docUrlPath.slice(0, docUrlPath.lastIndexOf("/") + 1);
|
|
193
313
|
const docAbsDir = resolve(root, `.${docDir}`);
|
|
194
314
|
for (const [k, v] of Object.entries(config)) {
|
|
195
|
-
if (
|
|
315
|
+
if (isString(v) && (v.startsWith("./") || v.startsWith("../"))) {
|
|
196
316
|
config[k] = `./${relative(process.cwd(), resolve(docAbsDir, v)).split("\\").join("/")}`;
|
|
197
317
|
}
|
|
198
318
|
}
|
|
@@ -221,38 +341,25 @@ export async function handleResolve(
|
|
|
221
341
|
return problem("pathOutsideProject", "$implementation escapes the project root");
|
|
222
342
|
}
|
|
223
343
|
const exportName = xport ?? classDef.title ?? $prototype;
|
|
224
|
-
const mod =
|
|
344
|
+
const mod = await importModule(implPath);
|
|
225
345
|
const ExportedClass =
|
|
226
346
|
exportName === undefined ? undefined : (mod[exportName] ?? mod.default?.[exportName]);
|
|
227
|
-
if (
|
|
347
|
+
if (!isExportedFunction(ExportedClass)) {
|
|
228
348
|
return new Response(`Export "${exportName}" not found in "${classDef.$implementation}"`, {
|
|
229
349
|
status: 500,
|
|
230
350
|
});
|
|
231
351
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
? instance.value
|
|
239
|
-
: instance;
|
|
240
|
-
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)));
|
|
241
358
|
}
|
|
242
359
|
|
|
243
360
|
// Self-contained: construct class from schema
|
|
244
361
|
const DynClass = classFromSchema(classDef);
|
|
245
|
-
|
|
246
|
-
resolve?: () => unknown;
|
|
247
|
-
value?: unknown;
|
|
248
|
-
};
|
|
249
|
-
const value =
|
|
250
|
-
typeof instance.resolve === "function"
|
|
251
|
-
? await instance.resolve()
|
|
252
|
-
: "value" in instance
|
|
253
|
-
? instance.value
|
|
254
|
-
: instance;
|
|
255
|
-
return Response.json(value);
|
|
362
|
+
return Response.json(await instanceResult(new DynClass(config)));
|
|
256
363
|
} catch (error) {
|
|
257
364
|
return problem("internalError", errorMessage(error));
|
|
258
365
|
}
|
|
@@ -267,15 +374,26 @@ export async function handleResolve(
|
|
|
267
374
|
}
|
|
268
375
|
|
|
269
376
|
/**
|
|
270
|
-
* Handle POST
|
|
271
|
-
*
|
|
377
|
+
* Handle POST `/__jx_server__` — proxy timing: "server" function calls. It is the only way an
|
|
378
|
+
* interpreting runtime calls a server function (spec.md §11.4): the runtime never imports the
|
|
379
|
+
* module in the browser, it posts the call here, and the function runs server-side with `env`.
|
|
380
|
+
*
|
|
381
|
+
* A relative `$src` resolves against the directory of the document `$base` names (`baseDirectory`):
|
|
382
|
+
* a site URL's path under `root`, or the absolute path Studio's canvas sends. The imported module
|
|
383
|
+
* stays cached for as long as the host runs, so an edit to it takes effect when the host restarts.
|
|
272
384
|
*
|
|
273
385
|
* @param {Request} req
|
|
274
|
-
* @param {string} root
|
|
386
|
+
* @param {string} root - The root a site-URL `$base`, or a `$src` without one, resolves under
|
|
387
|
+
* @param {string} [projectRoot] - The active project: the function's `env` carries its `.dev.vars`,
|
|
388
|
+
* and a module under it is importable as one under `root` is. `root` when omitted
|
|
275
389
|
*/
|
|
276
|
-
export async function handleServerFunction(req: Request, root: string) {
|
|
390
|
+
export async function handleServerFunction(req: Request, root: string, projectRoot = root) {
|
|
277
391
|
let body: ServerFunctionBody;
|
|
278
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. */
|
|
279
397
|
body = (await req.json()) as ServerFunctionBody;
|
|
280
398
|
} catch {
|
|
281
399
|
return problem("invalidRequest", "Invalid JSON body");
|
|
@@ -288,24 +406,20 @@ export async function handleServerFunction(req: Request, root: string) {
|
|
|
288
406
|
|
|
289
407
|
let moduleAbsPath;
|
|
290
408
|
try {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
moduleAbsPath = resolve(resolve(root, `.${docDir}`), $src);
|
|
295
|
-
} else {
|
|
296
|
-
moduleAbsPath = resolve(root, $src);
|
|
297
|
-
}
|
|
409
|
+
moduleAbsPath = $base
|
|
410
|
+
? resolve(baseDirectory($base, root, projectRoot), $src)
|
|
411
|
+
: resolve(root, $src);
|
|
298
412
|
} catch (error) {
|
|
299
413
|
return problem("invalidRequest", `Cannot resolve $src: ${errorMessage(error)}`);
|
|
300
414
|
}
|
|
301
415
|
|
|
302
|
-
if (!isImportable(moduleAbsPath, root,
|
|
416
|
+
if (!isImportable(moduleAbsPath, root, projectRoot)) {
|
|
303
417
|
return new Response(`$src "${$src}" escapes the project root`, { status: 403 });
|
|
304
418
|
}
|
|
305
419
|
|
|
306
420
|
let mod: ModuleNamespace;
|
|
307
421
|
try {
|
|
308
|
-
mod =
|
|
422
|
+
mod = await importModule(moduleAbsPath);
|
|
309
423
|
} catch (error) {
|
|
310
424
|
return new Response(`Failed to import "${$src}": ${errorMessage(error)}`, {
|
|
311
425
|
status: 500,
|
|
@@ -313,25 +427,63 @@ export async function handleServerFunction(req: Request, root: string) {
|
|
|
313
427
|
}
|
|
314
428
|
|
|
315
429
|
const fn = mod[xport] ?? mod.default?.[xport];
|
|
316
|
-
if (
|
|
430
|
+
if (!isExportedFunction(fn)) {
|
|
317
431
|
return new Response(`Export "${xport}" not found in "${$src}"`, {
|
|
318
432
|
status: 500,
|
|
319
433
|
});
|
|
320
434
|
}
|
|
321
435
|
|
|
322
436
|
try {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
437
|
+
/* Match the production contract `fn(args, env)` (compiler emits `fn(args, c.env)`): the dev
|
|
438
|
+
proxy runs server-side, so its `env` is the extension mounts' own, `process.env` merged under
|
|
439
|
+
the project's `.dev.vars` plus `JX_PROJECT_ROOT` (server.md §3.3). */
|
|
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));
|
|
329
444
|
return Response.json(result ?? null);
|
|
330
445
|
} catch (error) {
|
|
331
446
|
return problem("internalError", errorMessage(error));
|
|
332
447
|
}
|
|
333
448
|
}
|
|
334
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
|
+
|
|
335
487
|
/**
|
|
336
488
|
* Dynamically construct a class from a .class.json schema definition. Server-side variant — no
|
|
337
489
|
* private field limitations.
|
|
@@ -340,26 +492,24 @@ export async function handleServerFunction(req: Request, root: string) {
|
|
|
340
492
|
*/
|
|
341
493
|
function classFromSchema(classDef: JxClassDef) {
|
|
342
494
|
const fields = classDef.$defs?.fields ?? {};
|
|
343
|
-
// JSON objects inherit Object.prototype.constructor — only an own object value counts.
|
|
344
495
|
const rawCtor = classDef.$defs?.constructor;
|
|
345
|
-
const ctor =
|
|
496
|
+
const ctor = isCtorDef(rawCtor) ? rawCtor : undefined;
|
|
346
497
|
const methods = classDef.$defs?.methods ?? {};
|
|
347
498
|
|
|
348
499
|
// oxlint-disable-next-line typescript/no-extraneous-class -- methods are attached to the prototype dynamically below
|
|
349
500
|
class DynClass {
|
|
350
|
-
constructor(config = {}) {
|
|
351
|
-
|
|
352
|
-
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.
|
|
353
503
|
for (const [key, field] of Object.entries(fields)) {
|
|
354
504
|
const id = field.identifier ?? key;
|
|
355
|
-
if (
|
|
356
|
-
|
|
505
|
+
if (config[id] !== undefined) {
|
|
506
|
+
Object.assign(this, { [id]: config[id] });
|
|
357
507
|
} else if (field.initializer !== undefined) {
|
|
358
|
-
|
|
508
|
+
Object.assign(this, { [id]: field.initializer });
|
|
359
509
|
} else if (field.default !== undefined) {
|
|
360
|
-
|
|
510
|
+
Object.assign(this, { [id]: structuredClone(field.default) });
|
|
361
511
|
} else {
|
|
362
|
-
|
|
512
|
+
Object.assign(this, { [id]: null });
|
|
363
513
|
}
|
|
364
514
|
}
|
|
365
515
|
if (ctor?.body) {
|
|
@@ -376,27 +526,29 @@ function classFromSchema(classDef: JxClassDef) {
|
|
|
376
526
|
return p.$ref.split("/").pop() ?? "arg";
|
|
377
527
|
}
|
|
378
528
|
const n = p.identifier ?? p.name;
|
|
379
|
-
return
|
|
529
|
+
return isString(n) ? n : "arg";
|
|
380
530
|
});
|
|
381
531
|
const bodyStr = Array.isArray(method.body) ? method.body.join("\n") : (method.body ?? "");
|
|
382
532
|
|
|
383
533
|
if (method.role === "accessor") {
|
|
384
534
|
const descriptor: PropertyDescriptor = {};
|
|
385
535
|
if (method.getter) {
|
|
386
|
-
|
|
536
|
+
// SAFETY: `new Function(body)` with no parameter names builds the zero-argument getter.
|
|
537
|
+
descriptor.get = new Function(method.getter.body ?? "") as DynamicMethod;
|
|
387
538
|
}
|
|
388
539
|
if (method.setter) {
|
|
389
540
|
const sp = (method.setter.parameters ?? []).map((p) => p.$ref?.split("/").pop() ?? "v");
|
|
390
|
-
|
|
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;
|
|
391
543
|
}
|
|
392
544
|
Object.defineProperty(DynClass.prototype, name, {
|
|
393
545
|
...descriptor,
|
|
394
546
|
configurable: true,
|
|
395
547
|
});
|
|
396
548
|
} else if (method.scope === "static") {
|
|
397
|
-
(DynClass
|
|
549
|
+
Object.assign(DynClass, { [name]: new Function(...params, bodyStr) });
|
|
398
550
|
} else {
|
|
399
|
-
(DynClass
|
|
551
|
+
Object.assign(DynClass.prototype, { [name]: new Function(...params, bodyStr) });
|
|
400
552
|
}
|
|
401
553
|
}
|
|
402
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;
|
|
@@ -359,9 +375,11 @@ export async function createDevServer(options: {
|
|
|
359
375
|
return handleResolve(req, absRoot, activeProjectRoot);
|
|
360
376
|
}
|
|
361
377
|
|
|
362
|
-
|
|
378
|
+
/* Timing: "server" function proxy. Its `env` carries the active project's `.dev.vars`, as the
|
|
379
|
+
extension mounts' does below, and a module under that project imports even when it sits
|
|
380
|
+
outside absRoot, as the static lanes below serve it. */
|
|
363
381
|
if (path === "/__jx_server__" && req.method === "POST") {
|
|
364
|
-
return handleServerFunction(req, absRoot);
|
|
382
|
+
return handleServerFunction(req, absRoot, activeProjectRoot ?? absRoot);
|
|
365
383
|
}
|
|
366
384
|
|
|
367
385
|
// Extension server mounts (/_jx/data etc.) — registry-driven, same wire contract as the
|
|
@@ -386,6 +404,10 @@ export async function createDevServer(options: {
|
|
|
386
404
|
// (`isOwnedProjectDir`), so a hostile activate cannot widen the filesystem API to arbitrary
|
|
387
405
|
// Directories.
|
|
388
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. */
|
|
389
411
|
const body = (await req.json()) as { root?: string };
|
|
390
412
|
const raw = body.root || null;
|
|
391
413
|
if (!raw) {
|