@jxsuite/server 8.7.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,148 @@
1
+ /**
2
+ * A document's own server module, as a development host runs it (`spec.md` §23.2, `server.md`
3
+ * §3.3).
4
+ *
5
+ * A `timing: "server"` entry may name the server script of the document it is written in, and that
6
+ * script is text inside the document: there is no file for the host to import. So the host does for
7
+ * one document what a build does for the site worker, with the build's own plugin. It bundles the
8
+ * script from the place it stands beside its document, which is where its relative imports resolve
9
+ * from, imports the bundle, and removes the file it imported.
10
+ *
11
+ * A script is imported once for as long as its text stays the same. An edited script is a different
12
+ * module, so a document saved in an editing host answers with what it now says; a file the script
13
+ * imports is read when the script is bundled, as any module a host imports is read once.
14
+ *
15
+ * @docs framework/concepts/functions
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ import { rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { bundleEntry, useNodeFallback } from "@jxsuite/compiler/bundler";
22
+ import { documentScripts } from "@jxsuite/compiler/document-scripts";
23
+ import type { DocumentModule, DocumentScripts } from "@jxsuite/compiler/document-scripts";
24
+ import { parseScriptSpecifier, scriptSpecifier } from "@jxsuite/schema/document-scripts";
25
+
26
+ /** Matches the scheme of an address a browser reads a document from. */
27
+ const URL_SCHEME = /^(?:https?|file):/iu;
28
+
29
+ /** What a qualified `$src` on a server entry names, as a host can act on it. */
30
+ export type ServerScriptAddress =
31
+ /** A document under the project, by its path from the project root. */
32
+ | { kind: "path"; path: string }
33
+ /** A document a browser read from a URL. Which file that is, is the host's to say. */
34
+ | { kind: "url"; url: URL }
35
+ /** Nothing a host can find, with the reason as a sentence. */
36
+ | { kind: "refused"; reason: string };
37
+
38
+ /**
39
+ * Read a `jx-script:` specifier a server entry carries.
40
+ *
41
+ * A document is named the way whoever read it knew it: by its project path when a build or a
42
+ * composed page read it from the project, and by its URL when a browser fetched it (`spec.md`
43
+ * §23.2). A document handed to the runtime as an object was never at either, so a host has nothing
44
+ * to read its server script from.
45
+ *
46
+ * @param specifier The entry's `$src`, already known to be spelled `jx-script:…`.
47
+ */
48
+ export function serverScriptAddress(specifier: string): ServerScriptAddress {
49
+ const parsed = parseScriptSpecifier(specifier);
50
+ if (parsed === null) {
51
+ return { kind: "refused", reason: `$src "${specifier}" is not a document script specifier` };
52
+ }
53
+ if (parsed.side !== "server") {
54
+ return {
55
+ kind: "refused",
56
+ reason: `$src "${specifier}" names a client script, which a server function cannot be in`,
57
+ };
58
+ }
59
+ if (!URL_SCHEME.test(parsed.path)) {
60
+ return { kind: "path", path: parsed.path };
61
+ }
62
+ if (!URL.canParse(parsed.path)) {
63
+ return { kind: "refused", reason: `$src "${specifier}" names a document at no readable URL` };
64
+ }
65
+ const url = new URL(parsed.path);
66
+ if (url.hash !== "") {
67
+ return {
68
+ kind: "refused",
69
+ reason:
70
+ `$src "${specifier}" names a document that was handed to the page as an object, so ` +
71
+ `there is no file to read its server script from`,
72
+ };
73
+ }
74
+ return { kind: "url", url };
75
+ }
76
+
77
+ /** A stable name for one document's script at one text. */
78
+ function scriptKey(modulePath: string, contents: string): string {
79
+ return createHash("sha256")
80
+ .update(modulePath)
81
+ .update("\0")
82
+ .update(contents)
83
+ .digest("hex")
84
+ .slice(0, 24);
85
+ }
86
+
87
+ /**
88
+ * Import a document's server module.
89
+ *
90
+ * @param documentPath Absolute path of the document. The caller has checked it may be read.
91
+ * @param projectRoot The project root, which bare imports resolve from and messages are relative
92
+ * to.
93
+ * @throws {Error} When the document cannot be read, carries no server script, uses TypeScript that
94
+ * has to be compiled, or its script does not bundle.
95
+ */
96
+ export type ServerScriptImporter<T> = (documentPath: string, projectRoot: string) => Promise<T>;
97
+
98
+ /**
99
+ * A host's importer of documents' server modules.
100
+ *
101
+ * @param importModule How the host imports a file, so a module is typed as that host reads one.
102
+ */
103
+ export function serverScriptImporter<T>(
104
+ importModule: (path: string) => Promise<T>,
105
+ ): ServerScriptImporter<T> {
106
+ /** Every server script imported so far, by its document and its text. */
107
+ const imported = new Map<string, Promise<T>>();
108
+
109
+ const bundled = async (
110
+ scripts: DocumentScripts,
111
+ module: DocumentModule,
112
+ key: string,
113
+ ): Promise<T> => {
114
+ const outfile = join(tmpdir(), `jx-server-script-${process.pid}-${key}.mjs`);
115
+ try {
116
+ await bundleEntry(
117
+ { contents: module.contents, entryPath: module.path, outfile },
118
+ { plugins: [scripts.serverPlugin], target: useNodeFallback() ? "node" : "bun" },
119
+ );
120
+ return await importModule(outfile);
121
+ } finally {
122
+ // Imported or not, the file has done all it is for.
123
+ rmSync(outfile, { force: true });
124
+ }
125
+ };
126
+
127
+ return async (documentPath, projectRoot) => {
128
+ // Read on every call: the text decides whether this is a module already imported.
129
+ const scripts = documentScripts(projectRoot);
130
+ const module = await scripts.module(scriptSpecifier(documentPath, "server"));
131
+ const key = scriptKey(module.path, module.contents);
132
+
133
+ let pending = imported.get(key);
134
+ if (pending === undefined) {
135
+ pending = bundled(scripts, module, key);
136
+ imported.set(key, pending);
137
+ }
138
+ try {
139
+ return await pending;
140
+ } catch (error) {
141
+ // A script that failed may be one whose import is being fixed: the next call tries again.
142
+ if (imported.get(key) === pending) {
143
+ imported.delete(key);
144
+ }
145
+ throw error;
146
+ }
147
+ };
148
+ }
package/src/server.ts CHANGED
@@ -528,7 +528,7 @@ export async function createDevServer(options: {
528
528
  /* The project's BUILT SITE is deliberately NOT served here, at any position in this chain.
529
529
  This server's URL space means the project's SOURCES — that is what the canvas reads —
530
530
  and a built page addresses the same paths meaning its own output
531
- (`/components/fetch-demo.js` is the formulas here and the custom element there). Filling
531
+ (a `/components/<name>.js` is a `$src` module here and the custom element there). Filling
532
532
  404s from the output looked like it worked and handed the reader whichever of the two
533
533
  happened to be missing. `site-preview.ts` gives the built site its own origin instead,
534
534
  where every path has exactly one meaning. */
@@ -5,8 +5,8 @@
5
5
  * `dist/basics/fetch/index.html` links to `/basics/counter` and loads `/components/fetch-demo.js`.
6
6
  * So it is only browsable when those paths resolve against the OUTPUT — and on the editor's server
7
7
  * they resolve against the project's SOURCES, because that is the editor's whole job. The two are
8
- * the same URLs holding different bytes: `/components/fetch-demo.js` is the module of formulas the
9
- * compiler reads in the sources, and the custom-element definition the page needs in the output.
8
+ * the same URLs holding different bytes: a `/components/<name>.js` is the `$src` module a component
9
+ * keeps beside it in the sources, and the custom-element definition the page needs in the output.
10
10
  *
11
11
  * Serving the output as a 404 fallback on the editor's server looks like it works — the page and
12
12
  * the assets that exist in only one of the two spaces arrive — and then the reader gets the source
package/src/studio-api.ts CHANGED
@@ -986,8 +986,9 @@ export async function handleStudioApi(
986
986
  }
987
987
  let body: { route?: string };
988
988
  try {
989
- /* SAFETY: `route` is the only member read, and only when truthy; it is relayed verbatim in
990
- the preview tab's `navigate` event, so nothing here depends on its runtime type. */
989
+ /* SAFETY: `route` is the only member read, and only when truthy; it is relayed in the preview
990
+ tab's `navigate` event (a harness address gaining the origin's key on the way), so nothing
991
+ here depends on its runtime type. */
991
992
  body = (await req.json()) as { route?: string };
992
993
  } catch {
993
994
  return problem("invalidRequest", "Invalid JSON body");