@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/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 { DynamicClass } from "@jxsuite/runtime/types";
14
- 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";
15
26
  import { problem } from "./problem.ts";
16
27
 
17
- interface ModuleNamespace {
18
- default?: Record<string, unknown>;
19
- [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;
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
- interface ClassInstance {
23
- resolve?: () => unknown;
24
- value?: unknown;
25
- [key: string]: unknown;
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]: unknown;
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?: Record<string, unknown>;
41
- [key: string]: unknown;
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<Record<string, unknown> | null>} `{ config, root, ...sections }` or null
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
- const config = entry?.context.config as ProjectConfig | undefined;
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, ...config } = body;
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 (typeof v === "string" && (v.startsWith("./") || v.startsWith("../"))) {
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 = (await import(implPath)) as ModuleNamespace;
344
+ const mod = await importModule(implPath);
225
345
  const ExportedClass =
226
346
  exportName === undefined ? undefined : (mod[exportName] ?? mod.default?.[exportName]);
227
- if (typeof ExportedClass !== "function") {
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
- const ClassCtor = ExportedClass as new (config: unknown) => ClassInstance;
233
- const instance = new ClassCtor(config);
234
- const value =
235
- typeof instance.resolve === "function"
236
- ? await instance.resolve()
237
- : "value" in instance
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
- const instance = new DynClass(config) as {
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 /**jx_server** — proxy timing: "server" function calls. In dev mode, the runtime
271
- * sends these instead of hitting the production Hono handler.
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
- if ($base) {
292
- const docUrlPath = new URL($base).pathname;
293
- const docDir = docUrlPath.slice(0, docUrlPath.lastIndexOf("/") + 1);
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, null)) {
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 = (await import(moduleAbsPath)) as ModuleNamespace;
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 (typeof fn !== "function") {
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
- // Match the production contract `fn(args, env)` (compiler emits `fn(args, c.env)`): the dev
324
- // Proxy runs server-side, so `process.env` is the analogous environment binding.
325
- const result = await (fn as (args: Record<string, unknown>, env: unknown) => unknown)(
326
- args,
327
- process.env,
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 = typeof rawCtor === "object" ? rawCtor : undefined;
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
- const self = this as Record<string, unknown>;
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 (cfg[id] !== undefined) {
356
- self[id] = cfg[id];
505
+ if (config[id] !== undefined) {
506
+ Object.assign(this, { [id]: config[id] });
357
507
  } else if (field.initializer !== undefined) {
358
- self[id] = field.initializer;
508
+ Object.assign(this, { [id]: field.initializer });
359
509
  } else if (field.default !== undefined) {
360
- self[id] = structuredClone(field.default);
510
+ Object.assign(this, { [id]: structuredClone(field.default) });
361
511
  } else {
362
- self[id] = null;
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 typeof n === "string" ? n : "arg";
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
- 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;
387
538
  }
388
539
  if (method.setter) {
389
540
  const sp = (method.setter.parameters ?? []).map((p) => p.$ref?.split("/").pop() ?? "v");
390
- 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;
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 as DynamicClass)[name] = new Function(...params, bodyStr);
549
+ Object.assign(DynClass, { [name]: new Function(...params, bodyStr) });
398
550
  } else {
399
- (DynClass as DynamicClass).prototype[name] = new Function(...params, bodyStr);
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 = 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;
@@ -359,9 +375,11 @@ export async function createDevServer(options: {
359
375
  return handleResolve(req, absRoot, activeProjectRoot);
360
376
  }
361
377
 
362
- // Timing: "server" function proxy
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) {