@jxsuite/server 8.6.0 → 9.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.
@@ -0,0 +1,428 @@
1
+ /**
2
+ * A component or a layout, run on its own on the live preview origin (`specs/server.md` §3.4).
3
+ *
4
+ * A page has a route and a component does not, so until now the only place a component's handlers
5
+ * ran was the canvas. This gives one an address: `/__jx_live__/harness/<project path>`, answered
6
+ * with a page nobody wrote, which uses the component once, or wraps placeholder content in the
7
+ * layout.
8
+ *
9
+ * **There is no second rendering path.** The host page is a document handed to the same
10
+ * `composePage` a route is composed by, through a reader that answers one extra path. So the
11
+ * project's `<head>`, its `style`, its `$elements`, `$site`/`$page` and the reload client all
12
+ * arrive the way they do for a page, and whatever a page gains later this gains with it.
13
+ *
14
+ * **Test props travel in the address, as a `props` query parameter holding JSON.** The alternative
15
+ * was a store the editor POSTs into, and it loses on every count that matters here: a reload, the
16
+ * live-reload stream and a retarget all re-request the URL, so an address that carries its own
17
+ * inputs keeps rendering the same thing where a store has to outlive the tab that reads it; and a
18
+ * store needs a new route on every backend, where the address needs none. What an address costs is
19
+ * that anyone can write one, so the values are held to four rules:
20
+ *
21
+ * - **Keyed.** A component that binds a prop into `innerHTML` or an `href` runs what the value says,
22
+ * and this origin's pages hold its resolver credential. So values are applied only when the
23
+ * address also carries this origin's harness key ({@link HARNESS_KEY_PARAM}), which the server
24
+ * mints per origin and adds itself to an address its own opener asked for (`live-preview.ts`).
25
+ * Without it the component renders with its declared defaults and the page says why. An address
26
+ * with no values reflects nothing, so it needs no key.
27
+ * - **Bounded.** The query is refused past {@link HARNESS_QUERY_MAX} characters, well inside what an
28
+ * HTTP request line carries.
29
+ * - **Plain data only.** A value reaches the instance as `$props`, where the runtime reads a string
30
+ * holding `${` as a template and an object holding `$ref` as a binding. Either would let an
31
+ * address run an expression on this origin, so a value holding one anywhere is left out and the
32
+ * page says which prop was.
33
+ * - **Never echoed.** A value is serialized into the page's `application/json` block by the shell,
34
+ * which escapes `<`, and nowhere else. The note this module writes names only props the
35
+ * component's own file declares, so nothing from the address becomes markup or a template.
36
+ *
37
+ * **Only the project's own components and layouts are addressable.** The path must be one the tree
38
+ * already lists under `components/` or `layouts/`, both of which this origin serves as files
39
+ * anyway, so the harness reads nothing a reader could not already fetch.
40
+ *
41
+ * @docs framework/build/dev-server
42
+ * @docs studio/design/components
43
+ */
44
+
45
+ import { ComposeError, composePage } from "@jxsuite/site/compose";
46
+ import type { ComposedPage, PageRoute, SiteIO } from "@jxsuite/site/compose";
47
+ import { normalizeRequestPath } from "@jxsuite/site/paths";
48
+ import {
49
+ isNonNullObject,
50
+ isPrivateStateKey,
51
+ isString,
52
+ isTemplateString,
53
+ } from "@jxsuite/schema/guards";
54
+ import { isNativeDocumentPath, parseNativeDocument } from "@jxsuite/schema/jxn";
55
+ import { errorMessage } from "@jxsuite/schema/parse";
56
+ import { HARNESS_QUERY_MAX } from "@jxsuite/protocol/preview-harness";
57
+ import type {
58
+ JsonValue,
59
+ JxDocument,
60
+ JxElement,
61
+ JxStateDefinition,
62
+ ProjectConfig,
63
+ } from "@jxsuite/schema/types";
64
+
65
+ /**
66
+ * The query parameter a harness address carries this origin's key in.
67
+ *
68
+ * Read and written by the server alone. Studio never builds it: it asks for an address without one,
69
+ * and the origin adds the key on the way to the tab.
70
+ */
71
+ export const HARNESS_KEY_PARAM = "key";
72
+
73
+ /** What the page says when an address carried values and not the key. */
74
+ export const UNKEYED_NOTE =
75
+ "Test values were not applied, because this address was not opened from Studio. Open the component from Studio to run it with them.";
76
+
77
+ /**
78
+ * A harness query without the key the origin appended to it.
79
+ *
80
+ * The bound on a query is the one `@jxsuite/protocol` holds an address to before the key exists, so
81
+ * the key is not counted against it.
82
+ */
83
+ export function withoutHarnessKey(search: string): string {
84
+ return search.replace(new RegExp(`&${HARNESS_KEY_PARAM}=[^&]*$`), "");
85
+ }
86
+
87
+ /** The path the host page is read from. It names no file, and no route table ever lists it. */
88
+ const HOST_PAGE = "pages/__jx_live__/harness.json";
89
+
90
+ /**
91
+ * Whether a string would be read as a template wherever the runtime meets it.
92
+ *
93
+ * `isTemplateString` asks the same question as a type guard, which narrows a string it refuses to
94
+ * `never`; this one is for a value already known to be a string.
95
+ */
96
+ function holdsTemplate(text: string): boolean {
97
+ return text.includes("${");
98
+ }
99
+
100
+ /** What a harness address names. */
101
+ export interface HarnessTarget {
102
+ kind: "component" | "layout";
103
+ /** The document's project path, e.g. `components/user-card.json`. */
104
+ path: string;
105
+ }
106
+
107
+ /**
108
+ * The document a harness address names, or null when it names none this origin will run.
109
+ *
110
+ * Membership in `paths` is the whole check that matters: that list is the tree's servable files
111
+ * plus the editor's unsaved ones, so a path outside the project, a dotfile and `project.json` are
112
+ * all absent from it. The normalization ahead of it refuses traversal rather than resolving it, as
113
+ * every other read on this origin does.
114
+ *
115
+ * @param surfacePath What follows `/__jx_live__/harness/`, still URL-encoded.
116
+ * @param paths Every path the tree lists.
117
+ */
118
+ export function harnessTarget(surfacePath: string, paths: readonly string[]): HarnessTarget | null {
119
+ const path = normalizeRequestPath(surfacePath);
120
+ if (path === null || holdsTemplate(path) || !paths.includes(path)) {
121
+ return null;
122
+ }
123
+ if (path.startsWith("components/")) {
124
+ return { kind: "component", path };
125
+ }
126
+ if (path.startsWith("layouts/")) {
127
+ return { kind: "layout", path };
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /** The object shapes a state entry takes when it is neither a primitive nor an array. */
133
+ function isStateObject(def: JxStateDefinition | undefined): def is Record<string, JsonValue> {
134
+ return isNonNullObject(def) && !Array.isArray(def);
135
+ }
136
+
137
+ /**
138
+ * A component's props and their declared defaults: its public, plain-data state entries.
139
+ *
140
+ * The same subset Studio offers test values for (`packages/studio/src/component-props.ts`): never a
141
+ * private entry, an expression, a computed, a handler, a data source or a template, because
142
+ * overriding one of those would replace behavior with a value.
143
+ */
144
+ export function declaredProps(doc: JxDocument): Map<string, JsonValue | undefined> {
145
+ const props = new Map<string, JsonValue | undefined>();
146
+ for (const [name, def] of Object.entries(doc.state ?? {})) {
147
+ if (isPrivateStateKey(name) || holdsTemplate(name) || isTemplateString(def)) {
148
+ continue;
149
+ }
150
+ if (!isStateObject(def)) {
151
+ /* SAFETY: a state entry that is no object is a JSON primitive or an array of JSON values,
152
+ which is the document's own literal for the prop. */
153
+ props.set(name, def as JsonValue);
154
+ continue;
155
+ }
156
+ if (def.$expression || def.$compute || def.$prototype || def.$handler) {
157
+ continue;
158
+ }
159
+ props.set(name, "default" in def || "type" in def ? def.default : def);
160
+ }
161
+ return props;
162
+ }
163
+
164
+ /**
165
+ * Whether a value is data all the way down: no template string, and no `$`-prefixed key.
166
+ *
167
+ * Deeper than the runtime strictly needs, which reads only the top of a `$props` value. The rule is
168
+ * the cheap one to state and to keep true if a later reader looks further in.
169
+ */
170
+ function isPlainData(value: JsonValue): boolean {
171
+ if (isString(value)) {
172
+ return !holdsTemplate(value);
173
+ }
174
+ if (Array.isArray(value)) {
175
+ return value.every((item) => isPlainData(item));
176
+ }
177
+ if (isNonNullObject(value)) {
178
+ return Object.entries(value).every(
179
+ ([key, member]) => !key.startsWith("$") && isPlainData(member),
180
+ );
181
+ }
182
+ return true;
183
+ }
184
+
185
+ /** What an address's query gave, sorted into what is applied and what the page must say. */
186
+ export interface HarnessProps {
187
+ /** Values for declared props, each plain data. */
188
+ applied: Record<string, JsonValue>;
189
+ /** Declared props whose value was left out because it was not plain data. */
190
+ refused: string[];
191
+ /** Why no value was read at all, when none was. */
192
+ unread: string | null;
193
+ }
194
+
195
+ /**
196
+ * Read the `props` query parameter against what the component declares.
197
+ *
198
+ * A name the component does not declare is dropped without a word: it changes nothing about the
199
+ * render, and repeating it on the page would be the one way text from an address could reach a
200
+ * reader.
201
+ *
202
+ * @param search The request's query string, as sent.
203
+ * @param declared The component's props.
204
+ * @param keyed Whether the address carried this origin's harness key. Without it no value is
205
+ * applied or even parsed, whatever the query holds.
206
+ */
207
+ export function readHarnessProps(
208
+ search: string,
209
+ declared: ReadonlyMap<string, JsonValue | undefined>,
210
+ keyed: boolean,
211
+ ): HarnessProps {
212
+ const result: HarnessProps = { applied: {}, refused: [], unread: null };
213
+ if (withoutHarnessKey(search).length > HARNESS_QUERY_MAX) {
214
+ result.unread = "The test values in this address are too large, so none were applied.";
215
+ return result;
216
+ }
217
+ const raw = new URLSearchParams(search).get("props");
218
+ if (raw === null) {
219
+ return result;
220
+ }
221
+ if (!keyed) {
222
+ result.unread = UNKEYED_NOTE;
223
+ return result;
224
+ }
225
+ let parsed: JsonValue;
226
+ try {
227
+ /* SAFETY: trust boundary. Anyone can write an address, so the value is used below only once
228
+ it is seen to be an object, and each member only once it is seen to be plain data. */
229
+ parsed = JSON.parse(raw) as JsonValue;
230
+ } catch {
231
+ parsed = null;
232
+ }
233
+ if (!isNonNullObject(parsed) || Array.isArray(parsed)) {
234
+ result.unread = "The test values in this address could not be read, so none were applied.";
235
+ return result;
236
+ }
237
+ for (const [name, value] of Object.entries(parsed)) {
238
+ if (!declared.has(name)) {
239
+ continue;
240
+ }
241
+ if (isPlainData(value)) {
242
+ result.applied[name] = value;
243
+ } else {
244
+ result.refused.push(name);
245
+ }
246
+ }
247
+ return result;
248
+ }
249
+
250
+ /** The sentences a component's host page opens with, or none when there is nothing to say. */
251
+ export function harnessNotes(
252
+ declared: ReadonlyMap<string, JsonValue | undefined>,
253
+ props: HarnessProps,
254
+ ): string[] {
255
+ const notes: string[] = [];
256
+ if (props.unread) {
257
+ notes.push(props.unread);
258
+ }
259
+ if (props.refused.length > 0) {
260
+ notes.push(
261
+ `Left out, because an address carries plain values and not expressions: ${props.refused.join(", ")}.`,
262
+ );
263
+ }
264
+ const missing = [...declared].flatMap(([name, value]) =>
265
+ (value === undefined || value === null) && !Object.hasOwn(props.applied, name) ? [name] : [],
266
+ );
267
+ if (missing.length > 0) {
268
+ notes.push(
269
+ `Shown with its declared defaults. No test value is set for: ${missing.join(", ")}. Set them in Studio's "resolving with" popover and open this again.`,
270
+ );
271
+ }
272
+ return notes;
273
+ }
274
+
275
+ /** How the note above a harnessed component is drawn: apart from the component, and plainly. */
276
+ const NOTE_STYLE = {
277
+ background: "#fff8e1",
278
+ borderBottom: "1px solid #e0c36a",
279
+ color: "#3d3200",
280
+ font: "13px/1.5 system-ui, sans-serif",
281
+ margin: "0",
282
+ padding: "8px 12px",
283
+ };
284
+
285
+ /** How a layout's placeholder content is drawn: visibly a stand-in, never mistaken for the site. */
286
+ const PLACEHOLDER_STYLE = {
287
+ border: "1px dashed currentColor",
288
+ font: "13px/1.5 system-ui, sans-serif",
289
+ opacity: "0.7",
290
+ padding: "24px 12px",
291
+ textAlign: "center",
292
+ };
293
+
294
+ /** Read one of the project's documents in a native format, or null when it is not one. */
295
+ async function readNative(io: SiteIO, path: string): Promise<JxDocument | null> {
296
+ const text = await io.read(path);
297
+ if (text === null || !isNativeDocumentPath(path)) {
298
+ return null;
299
+ }
300
+ try {
301
+ return parseNativeDocument(text, path);
302
+ } catch (error) {
303
+ throw new ComposeError(`${path} could not be read: ${errorMessage(error)}`);
304
+ }
305
+ }
306
+
307
+ /** The page that uses a component once, with its test props. */
308
+ async function componentHost(
309
+ io: SiteIO,
310
+ path: string,
311
+ search: string,
312
+ keyed: boolean,
313
+ ): Promise<JxDocument> {
314
+ const definition = await readNative(io, path);
315
+ if (!definition) {
316
+ /* The browser registers a component by fetching its file and reading it as a document, so one
317
+ in a format that needs an extension's parser cannot be registered from here by anyone. */
318
+ throw new ComposeError(
319
+ `${path} could not be run on its own. Only .json and .jxn components run here; other formats need their extension's parser, which a browser does not have.`,
320
+ );
321
+ }
322
+ const { tagName } = definition;
323
+ if (!isString(tagName) || !tagName.includes("-") || holdsTemplate(tagName)) {
324
+ throw new ComposeError(
325
+ `${path} does not define a custom element, so there is nothing to run on its own.`,
326
+ );
327
+ }
328
+ const declared = declaredProps(definition);
329
+ const props = readHarnessProps(search, declared, keyed);
330
+ const instance: JxElement = { tagName };
331
+ if (Object.keys(props.applied).length > 0) {
332
+ instance.$props = props.applied;
333
+ }
334
+ const notes: JxElement[] = harnessNotes(declared, props).map((textContent) => ({
335
+ attributes: { "data-jx-harness-note": "", role: "status" },
336
+ style: NOTE_STYLE,
337
+ tagName: "p",
338
+ textContent,
339
+ }));
340
+ return {
341
+ $elements: [{ $ref: `./${path}` }],
342
+ attributes: { "data-jx-harness": "component" },
343
+ children: [...notes, instance],
344
+ tagName: "main",
345
+ title: tagName,
346
+ };
347
+ }
348
+
349
+ /** Every named slot a layout declares, in document order, each once. */
350
+ function namedSlots(node: JxElement | string, into: Set<string>): Set<string> {
351
+ if (!isNonNullObject(node)) {
352
+ return into;
353
+ }
354
+ const name = node.tagName === "slot" ? node.attributes?.name : undefined;
355
+ if (isString(name) && name !== "" && !holdsTemplate(name)) {
356
+ into.add(name);
357
+ }
358
+ if (Array.isArray(node.children)) {
359
+ for (const child of node.children) {
360
+ namedSlots(child, into);
361
+ }
362
+ }
363
+ return into;
364
+ }
365
+
366
+ /** The page that wraps placeholder content in a layout: one stand-in per slot it declares. */
367
+ async function layoutHost(io: SiteIO, path: string): Promise<JxDocument> {
368
+ const layout = await readNative(io, path);
369
+ const placeholder = (label: string, slot?: string): JxElement => ({
370
+ attributes:
371
+ slot === undefined ? { "data-jx-harness-slot": "" } : { "data-jx-harness-slot": slot, slot },
372
+ style: PLACEHOLDER_STYLE,
373
+ tagName: "div",
374
+ textContent: label,
375
+ });
376
+ const named = layout ? [...namedSlots(layout, new Set())] : [];
377
+ return {
378
+ $layout: `./${path}`,
379
+ children: [
380
+ placeholder("Page content goes here"),
381
+ ...named.map((slot) => placeholder(`Content for the "${slot}" slot goes here`, slot)),
382
+ ],
383
+ title: path,
384
+ };
385
+ }
386
+
387
+ /**
388
+ * Compose the page a harness address asks for.
389
+ *
390
+ * Throws a `ComposeError` for anything the author can act on, exactly as composing a route does, so
391
+ * the caller reports both through one page.
392
+ *
393
+ * @param io Reads the working tree, unsaved documents first.
394
+ * @param config The project's configuration.
395
+ * @param target The component or layout to run.
396
+ * @param search The request's query string, as sent.
397
+ * @param keyed Whether the address carried this origin's harness key.
398
+ */
399
+ export async function composeHarness(
400
+ io: SiteIO,
401
+ config: ProjectConfig,
402
+ target: HarnessTarget,
403
+ search: string,
404
+ keyed: boolean,
405
+ ): Promise<ComposedPage> {
406
+ /* A component's host says `$layout: false`, the authored way to say no layout wraps a page
407
+ (`site-architecture.md` §5): without it the project's default layout would wrap the component,
408
+ and it would no longer be on its own. It is written here, into the text, because the declared
409
+ type spells `$layout` as a string alone. */
410
+ const text =
411
+ target.kind === "component"
412
+ ? JSON.stringify({ ...(await componentHost(io, target.path, search, keyed)), $layout: false })
413
+ : JSON.stringify(await layoutHost(io, target.path));
414
+ const route: PageRoute = {
415
+ isCatchAll: false,
416
+ isDynamic: false,
417
+ params: [],
418
+ sourcePath: HOST_PAGE,
419
+ /* A fixed pattern, not the address: it becomes `$page.url`, a state string, and a state string
420
+ holding `${` is a template. */
421
+ urlPattern: "/__jx_live__/harness",
422
+ };
423
+ const hostIO: SiteIO = {
424
+ ...io,
425
+ read: (path) => (path === HOST_PAGE ? Promise.resolve(text) : io.read(path)),
426
+ };
427
+ return composePage(hostIO, route, config, {});
428
+ }
package/src/resolve.ts CHANGED
@@ -4,11 +4,13 @@ import { eraseClass } from "@jxsuite/erase";
4
4
  import { siteBasePath } from "@jxsuite/schema/asset-paths";
5
5
  import { dirname, isAbsolute, relative, resolve } from "node:path";
6
6
  import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
7
+ import { isScriptSpecifier } from "@jxsuite/schema/document-scripts";
7
8
  import { isFunction, isNonNullObject, isString } from "@jxsuite/schema/guards";
8
9
  import { readFileSync } from "node:fs";
9
10
  import { createRequire } from "node:module";
10
11
  import { projectDevEnv } from "./dev-vars.ts";
11
12
  import { containedPath } from "./net-guard.ts";
13
+ import { serverScriptAddress, serverScriptImporter } from "./server-script.ts";
12
14
  import { buildProjectExtensionRegistry } from "@jxsuite/compiler/format-host";
13
15
  import { loadProjectSections } from "@jxsuite/compiler/project-sections";
14
16
  import { loadAssetMounts } from "@jxsuite/compiler/asset-mounts";
@@ -58,7 +60,7 @@ interface ExportedObject {
58
60
  * A loaded module's namespace object. `default` is read as an object because the proxies only ever
59
61
  * read a named member off it, which is defined for any non-nullish value.
60
62
  */
61
- interface ModuleNamespace extends ExportedObject {
63
+ export interface ModuleNamespace extends ExportedObject {
62
64
  readonly default?: ExportedObject;
63
65
  }
64
66
 
@@ -172,16 +174,43 @@ function baseDirectory($base: string, root: string, projectRoot: string): string
172
174
  const url = new URL($base);
173
175
  const pathname = decodeURIComponent(url.pathname);
174
176
  const urlDir = pathname.slice(0, pathname.lastIndexOf("/") + 1);
175
- const spellsPath = urlDir.startsWith("//") || /^\/[A-Za-z]:/.test(urlDir);
176
- const fsDir = spellsPath ? urlDir.slice(1) : url.protocol === "file:" ? urlDir : null;
177
+ return pathOfUrl(url, urlDir, root, projectRoot);
178
+ }
179
+
180
+ /**
181
+ * The file a document's URL names, by the rule {@link baseDirectory} reads a directory with: the
182
+ * address a browser read a document from, back to the document on disk.
183
+ *
184
+ * @param {URL} url - The document's URL
185
+ * @param {string} root - The root a site URL names a path under
186
+ * @param {string} projectRoot - The active project, which may sit outside `root`
187
+ * @returns {string} An absolute path
188
+ */
189
+ function documentFile(url: URL, root: string, projectRoot: string): string {
190
+ return pathOfUrl(url, decodeURIComponent(url.pathname), root, projectRoot);
191
+ }
192
+
193
+ /**
194
+ * The filesystem path one URL path names: itself when it spells a path under `root` or the active
195
+ * project, and otherwise a path under `root`.
196
+ *
197
+ * @param {URL} url - The URL the path was read from, for its scheme
198
+ * @param {string} urlPath - The decoded path to map: the URL's own, or its directory
199
+ * @param {string} root - The root a site URL names a path under
200
+ * @param {string} projectRoot - The active project, which may sit outside `root`
201
+ * @returns {string} An absolute path
202
+ */
203
+ function pathOfUrl(url: URL, urlPath: string, root: string, projectRoot: string): string {
204
+ const spellsPath = urlPath.startsWith("//") || /^\/[A-Za-z]:/.test(urlPath);
205
+ const fsPath = spellsPath ? urlPath.slice(1) : url.protocol === "file:" ? urlPath : null;
177
206
  if (
178
- fsDir !== null &&
179
- isAbsolute(fsDir) &&
180
- (containedPath(fsDir, root) !== null || containedPath(fsDir, projectRoot) !== null)
207
+ fsPath !== null &&
208
+ isAbsolute(fsPath) &&
209
+ (containedPath(fsPath, root) !== null || containedPath(fsPath, projectRoot) !== null)
181
210
  ) {
182
- return resolve(fsDir);
211
+ return resolve(fsPath);
183
212
  }
184
- return resolve(root, `.${urlDir}`);
213
+ return resolve(root, `.${urlPath}`);
185
214
  }
186
215
 
187
216
  /**
@@ -214,7 +243,7 @@ async function loadProjectContext(projectRoot: string): Promise<ProjectContext |
214
243
  if (!entry) {
215
244
  return null;
216
245
  }
217
- // This host draws the canvas and Preview, so entries a project holds back stay visible to authors.
246
+ // This host draws the canvas, so entries a project holds back stay visible to authors.
218
247
  entry.context ??= loadProjectSections(projectRoot, entry.config, entry.registry, {
219
248
  includeHeld: true,
220
249
  lazy: true,
@@ -474,23 +503,38 @@ export async function handleServerFunction(req: Request, root: string, projectRo
474
503
  return problem("invalidRequest", "Missing $src or $export");
475
504
  }
476
505
 
477
- let moduleAbsPath;
478
- try {
479
- moduleAbsPath = $base
480
- ? resolve(baseDirectory($base, root, projectRoot), $src)
481
- : resolve(root, $src);
482
- } catch (error) {
483
- return problem("invalidRequest", `Cannot resolve $src: ${errorMessage(error)}`);
484
- }
506
+ let module: string | (() => Promise<ModuleNamespace>);
507
+ if (isScriptSpecifier($src)) {
508
+ // The document's own server script (spec.md §23.2): text inside a document, named by where
509
+ // Whoever read the document found it.
510
+ const address = serverScriptAddress($src);
511
+ if (address.kind === "refused") {
512
+ return problem("invalidRequest", address.reason);
513
+ }
514
+ const documentPath =
515
+ address.kind === "path"
516
+ ? resolve(projectRoot, address.path)
517
+ : documentFile(address.url, root, projectRoot);
518
+ if (!isImportable(documentPath, root, projectRoot)) {
519
+ return new Response(`$src "${$src}" escapes the project root`, { status: 403 });
520
+ }
521
+ module = () => importServerScript(documentPath, projectRoot);
522
+ } else {
523
+ try {
524
+ module = $base ? resolve(baseDirectory($base, root, projectRoot), $src) : resolve(root, $src);
525
+ } catch (error) {
526
+ return problem("invalidRequest", `Cannot resolve $src: ${errorMessage(error)}`);
527
+ }
485
528
 
486
- if (!isImportable(moduleAbsPath, root, projectRoot)) {
487
- return new Response(`$src "${$src}" escapes the project root`, { status: 403 });
529
+ if (!isImportable(module, root, projectRoot)) {
530
+ return new Response(`$src "${$src}" escapes the project root`, { status: 403 });
531
+ }
488
532
  }
489
533
 
490
534
  // Match the production contract `fn(args, env)` (compiler emits `fn(args, c.env)`): the dev proxy
491
535
  // Runs server-side, so its `env` is the extension mounts' own, `process.env` merged under the
492
536
  // Project's `.dev.vars` plus `JX_PROJECT_ROOT` (server.md §3.3).
493
- const result = await callServerExport(moduleAbsPath, xport, args, projectDevEnv(projectRoot));
537
+ const result = await callServerExport(module, xport, args, projectDevEnv(projectRoot));
494
538
  if (result.ok) {
495
539
  return Response.json(result.value ?? null);
496
540
  }
@@ -518,21 +562,23 @@ export type ServerExportResult =
518
562
  * run: the proxy's caller names one in a request and checks containment first, while the stand-in
519
563
  * route's modules come from the build, so each does its own checking before it gets here.
520
564
  *
521
- * @param {string} modulePath - Absolute path of the module, or a specifier already resolved
565
+ * @param {string | (() => Promise<ModuleNamespace>)} module - Absolute path of the module or a
566
+ * specifier already resolved, or how the module is imported when it is no file: a document's own
567
+ * server script (`importServerScript`)
522
568
  * @param {string} exportName - The export to call
523
569
  * @param {JsonObject} args - The function's first argument
524
570
  * @param {ProjectDevEnv} env - The function's second argument: the platform environment
525
571
  * @returns {Promise<ServerExportResult>}
526
572
  */
527
573
  export async function callServerExport(
528
- modulePath: string,
574
+ module: string | (() => Promise<ModuleNamespace>),
529
575
  exportName: string,
530
576
  args: JsonObject,
531
577
  env: ProjectDevEnv,
532
578
  ): Promise<ServerExportResult> {
533
579
  let mod: ModuleNamespace;
534
580
  try {
535
- mod = await importModule(modulePath);
581
+ mod = await (isString(module) ? importModule(module) : module());
536
582
  } catch (error) {
537
583
  return { message: errorMessage(error), ok: false, stage: "import" };
538
584
  }
@@ -567,6 +613,15 @@ async function importModule(path: string): Promise<ModuleNamespace> {
567
613
  return (await import(path)) as ModuleNamespace;
568
614
  }
569
615
 
616
+ /**
617
+ * Import the server module a document carries (spec.md §23.2), as its namespace object. One
618
+ * importer for every dev host in the process, so a script is imported once whichever route asks.
619
+ *
620
+ * @param documentPath Absolute path of the document. The caller has checked it may be read.
621
+ * @param projectRoot The project the document belongs to.
622
+ */
623
+ export const importServerScript = serverScriptImporter(importModule);
624
+
570
625
  /** What a constructed class answers with: its `resolve()` result, else its `value`, else itself. */
571
626
  async function instanceResult(
572
627
  instance: ResolvableInstance,