@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.
@@ -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: unknown;
20
+ doc: JsonValue;
26
21
  /** Round-trips the whole document. Null when the format declares no `serialize`. */
27
- serialize: ((doc: unknown) => Promise<string>) | null;
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
- doc: JSON.parse(raw) as unknown,
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
- const doc = await parseEntry.call("parse", raw);
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
  }
@@ -6,7 +6,8 @@
6
6
  * build/reload); both share the pure `toFsEvent`/`coalesceFsEvents` helpers.
7
7
  */
8
8
 
9
- import { watch as chokidarWatch } from "chokidar";
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 = chokidarWatch(root, {
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", (err) => {
75
- const error = err as NodeJS.ErrnoException;
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 { DynamicClass } from "@jxsuite/runtime/types";
15
- import type { JxClassDef, ProjectConfig } from "@jxsuite/schema/types";
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
- interface ModuleNamespace {
19
- default?: Record<string, unknown>;
20
- [key: string]: unknown;
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
- interface ClassInstance {
24
- resolve?: () => unknown;
25
- value?: unknown;
26
- [key: string]: unknown;
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]: unknown;
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?: Record<string, unknown>;
42
- [key: string]: unknown;
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<Record<string, unknown> | null>} `{ config, root, ...sections }` or null
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
- const config = entry?.context.config as ProjectConfig | undefined;
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, ...config } = body;
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 (typeof v === "string" && (v.startsWith("./") || v.startsWith("../"))) {
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 = (await import(implPath)) as ModuleNamespace;
344
+ const mod = await importModule(implPath);
261
345
  const ExportedClass =
262
346
  exportName === undefined ? undefined : (mod[exportName] ?? mod.default?.[exportName]);
263
- if (typeof ExportedClass !== "function") {
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
- const ClassCtor = ExportedClass as new (config: unknown) => ClassInstance;
269
- const instance = new ClassCtor(config);
270
- const value =
271
- typeof instance.resolve === "function"
272
- ? await instance.resolve()
273
- : "value" in instance
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
- const instance = new DynClass(config) as {
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 = (await import(moduleAbsPath)) as ModuleNamespace;
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 (typeof fn !== "function") {
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
- const result = await (fn as (args: Record<string, unknown>, env: unknown) => unknown)(
366
- args,
367
- projectDevEnv(projectRoot),
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 = typeof rawCtor === "object" ? rawCtor : undefined;
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
- const self = this as Record<string, unknown>;
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 (cfg[id] !== undefined) {
396
- self[id] = cfg[id];
505
+ if (config[id] !== undefined) {
506
+ Object.assign(this, { [id]: config[id] });
397
507
  } else if (field.initializer !== undefined) {
398
- self[id] = field.initializer;
508
+ Object.assign(this, { [id]: field.initializer });
399
509
  } else if (field.default !== undefined) {
400
- self[id] = structuredClone(field.default);
510
+ Object.assign(this, { [id]: structuredClone(field.default) });
401
511
  } else {
402
- self[id] = null;
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 typeof n === "string" ? n : "arg";
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
- descriptor.get = new Function(method.getter.body ?? "") as () => unknown;
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
- descriptor.set = new Function(...sp, method.setter.body ?? "") as (v: unknown) => void;
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 as DynamicClass)[name] = new Function(...params, bodyStr);
549
+ Object.assign(DynClass, { [name]: new Function(...params, bodyStr) });
438
550
  } else {
439
- (DynClass as DynamicClass).prototype[name] = new Function(...params, bodyStr);
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 = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as PackageJson;
125
- const exportKey = `./${subpath}`;
126
- const exportVal = pkgJson.exports?.[exportKey];
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 pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as PackageJson;
142
- if (pkgJson.customElements) {
143
- const cemDir = pkgJson.customElements.replace(/\/[^/]+$/, "");
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 = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as PackageJson;
155
- const exp = pkgJson.exports?.["."];
156
- const entry =
157
- (typeof exp === "object" ? (exp.import ?? exp.default) : exp) ??
158
- pkgJson.module ??
159
- pkgJson.main;
160
- if (entry && typeof entry === "string") {
161
- const resolved = join(pkgDir, entry);
162
- if (existsSync(resolved)) {
163
- return resolved;
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 | object;
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 = typeof watch === "object" ? watch : {};
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) {