@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.
- package/package.json +17 -16
- package/src/code-api.ts +15 -45
- package/src/code-format.ts +134 -0
- package/src/dev.ts +30 -12
- package/src/format.ts +271 -0
- package/src/live-preview.ts +211 -17
- package/src/preview-client.ts +6 -1
- package/src/preview-harness.ts +428 -0
- package/src/resolve.ts +78 -23
- package/src/server-script.ts +148 -0
- package/src/server.ts +1 -1
- package/src/site-preview.ts +2 -2
- package/src/studio-api.ts +3 -2
|
@@ -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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
isAbsolute(
|
|
180
|
-
(containedPath(
|
|
207
|
+
fsPath !== null &&
|
|
208
|
+
isAbsolute(fsPath) &&
|
|
209
|
+
(containedPath(fsPath, root) !== null || containedPath(fsPath, projectRoot) !== null)
|
|
181
210
|
) {
|
|
182
|
-
return resolve(
|
|
211
|
+
return resolve(fsPath);
|
|
183
212
|
}
|
|
184
|
-
return resolve(root, `.${
|
|
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
|
|
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
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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(
|
|
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}
|
|
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
|
-
|
|
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(
|
|
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,
|