@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/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 { existsSync, readFileSync, statSync } from "node:fs";
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 { 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 { 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
- interface ModuleNamespace {
19
- default?: Record<string, unknown>;
20
- [key: string]: unknown;
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
- interface ClassInstance {
24
- resolve?: () => unknown;
25
- value?: unknown;
26
- [key: string]: unknown;
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]: unknown;
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?: Record<string, unknown>;
42
- [key: string]: unknown;
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
- /** 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
- >();
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<Record<string, unknown> | null>} `{ config, root, ...sections }` or null
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
- return entry?.context ?? null;
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
- /** Load (or reuse) the cached project context plus its extension asset mounts. */
115
- async function loadProjectEntry(projectRoot: string) {
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
- if (!existsSync(projectJsonPath)) {
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
- const { mtimeMs } = statSync(projectJsonPath);
122
- const cached = projectContextCache.get(projectRoot);
123
- if (cached && cached.mtime === mtimeMs) {
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
- const context = { config, root: projectRoot, ...sections };
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
- const config = entry?.context.config as ProjectConfig | undefined;
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, ...config } = body;
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 (typeof v === "string" && (v.startsWith("./") || v.startsWith("../"))) {
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 = (await import(implPath)) as ModuleNamespace;
413
+ const mod = await importModule(implPath);
261
414
  const ExportedClass =
262
415
  exportName === undefined ? undefined : (mod[exportName] ?? mod.default?.[exportName]);
263
- if (typeof ExportedClass !== "function") {
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
- 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);
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
- 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);
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 = (await import(moduleAbsPath)) as ModuleNamespace;
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 (typeof fn !== "function") {
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
- const result = await (fn as (args: Record<string, unknown>, env: unknown) => unknown)(
366
- args,
367
- projectDevEnv(projectRoot),
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 = typeof rawCtor === "object" ? rawCtor : undefined;
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
- const self = this as Record<string, unknown>;
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 (cfg[id] !== undefined) {
396
- self[id] = cfg[id];
580
+ if (config[id] !== undefined) {
581
+ Object.assign(this, { [id]: config[id] });
397
582
  } else if (field.initializer !== undefined) {
398
- self[id] = field.initializer;
583
+ Object.assign(this, { [id]: field.initializer });
399
584
  } else if (field.default !== undefined) {
400
- self[id] = structuredClone(field.default);
585
+ Object.assign(this, { [id]: structuredClone(field.default) });
401
586
  } else {
402
- self[id] = null;
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 typeof n === "string" ? n : "arg";
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
- descriptor.get = new Function(method.getter.body ?? "") as () => unknown;
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
- descriptor.set = new Function(...sp, method.setter.body ?? "") as (v: unknown) => void;
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 as DynamicClass)[name] = new Function(...params, bodyStr);
624
+ Object.assign(DynClass, { [name]: new Function(...params, bodyStr) });
438
625
  } else {
439
- (DynClass as DynamicClass).prototype[name] = new Function(...params, bodyStr);
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 = 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) {
@@ -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
- const resolved = resolveNpmPath(absRoot, path);
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)) {