@stealthscale/sdk-host 0.1.0 → 0.4.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/dist/deploy.js ADDED
@@ -0,0 +1,254 @@
1
+ import { t as DEPLOYMENT_ELEMENT } from "./document-CeEtZo2E.js";
2
+ import { MANIFEST_FILE, RELEASE_FILE, lineOf, preloadsOf, releaseProblems, unmetRequirements } from "@stealthscale/sdk-core";
3
+ //#region src/deploy.ts
4
+ /**
5
+ * Writes the parts of a product's document that depend on its deployment: the import map with the
6
+ * integrity of every file of every release, the content security policy, the module preloads of
7
+ * each release's manifest, and the deployment that the page reads.
8
+ *
9
+ * @remarks
10
+ * The module runs wherever the document is written: a deployment step in Node, or a server that
11
+ * renders the document. It reads each release's description over `fetch` and digests it with Web
12
+ * Crypto, so it imports no Node built-in.
13
+ */
14
+ /**
15
+ * The start tag of the import map that a product's build writes into its document.
16
+ */
17
+ const IMPORT_MAP_START = "<script type=\"importmap\">";
18
+ /**
19
+ * The end tag of a `script` element.
20
+ */
21
+ const SCRIPT_END = "<\/script>";
22
+ /**
23
+ * Fetches a URL with the process's `fetch`.
24
+ */
25
+ const FETCHING = (url) => fetch(url);
26
+ /**
27
+ * Returns the integrity of some bytes, in the form `sha384-<base64>`.
28
+ */
29
+ async function integrityOf(bytes) {
30
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-384", bytes));
31
+ return `sha384-${btoa(String.fromCodePoint(...digest))}`;
32
+ }
33
+ /**
34
+ * Returns a value as JSON that a `script` element contains as written, with every `<` escaped.
35
+ */
36
+ function scriptText(value) {
37
+ return JSON.stringify(value).replaceAll("<", String.raw`\u003c`);
38
+ }
39
+ /**
40
+ * Returns a text escaped for an attribute value between double quotes. The function escapes every
41
+ * `&`, `"` and `<`.
42
+ */
43
+ function attributeText(text) {
44
+ return text.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;");
45
+ }
46
+ /**
47
+ * Reads a file of a release, and returns its bytes or the reason the step cannot use them.
48
+ */
49
+ async function bytesOf(fetching, url) {
50
+ try {
51
+ const response = await fetching(url);
52
+ return response.ok ? await response.arrayBuffer() : `${url} responded with ${String(response.status)}.`;
53
+ } catch (error) {
54
+ return `${url} failed to load: ${String(error)}`;
55
+ }
56
+ }
57
+ /**
58
+ * Returns a release with its description, or the reason the step leaves it out.
59
+ */
60
+ async function listedOf(fetching, { base, integrity, options }) {
61
+ const url = new URL(RELEASE_FILE, base).href;
62
+ const bytes = await bytesOf(fetching, url);
63
+ let value;
64
+ if (typeof bytes === "string") return {
65
+ base,
66
+ detail: bytes
67
+ };
68
+ const digest = await integrityOf(bytes);
69
+ if (digest !== integrity) return {
70
+ base,
71
+ detail: `${url} has the digest ${digest}, and the deployment pins ${integrity}.`
72
+ };
73
+ try {
74
+ value = JSON.parse(new TextDecoder().decode(bytes));
75
+ } catch (error) {
76
+ return {
77
+ base,
78
+ detail: `${url} does not parse: ${String(error)}`
79
+ };
80
+ }
81
+ const problems = releaseProblems(value);
82
+ if (problems.length > 0) return {
83
+ base,
84
+ detail: problems.map((one) => lineOf(one)).join("; ")
85
+ };
86
+ const release = value;
87
+ return options === void 0 ? {
88
+ base,
89
+ integrity,
90
+ release
91
+ } : {
92
+ base,
93
+ integrity,
94
+ options,
95
+ release
96
+ };
97
+ }
98
+ /**
99
+ * Returns the integrity of every file of every release, by the file's absolute URL.
100
+ */
101
+ function integrityMapOf(listed) {
102
+ return Object.fromEntries(listed.flatMap(({ base, release }) => Object.entries(release.files).map(([path, integrity]) => [new URL(path, base).href, integrity])));
103
+ }
104
+ /**
105
+ * Returns the modules of each provided specifier that a listed release imports, keyed by specifier.
106
+ * Each list starts with the URL of the specifier's entry, followed by the URLs of the product's
107
+ * chunks that the entry imports.
108
+ */
109
+ function modulesOf(listed, provided) {
110
+ const modules = [...new Set(listed.flatMap(({ release }) => Object.values(release.imports).flat().filter((imported) => !Object.hasOwn(release.imports, imported))))].flatMap((specifier) => {
111
+ const entry = provided.imports[specifier];
112
+ return entry === void 0 ? [] : [[specifier, [entry, ...provided.chunks[specifier] ?? []]]];
113
+ });
114
+ return Object.fromEntries(modules.toSorted(([one], [two]) => one.localeCompare(two)));
115
+ }
116
+ /**
117
+ * Returns the `link` element that preloads a module, as text.
118
+ */
119
+ function moduleTagOf({ href, integrity }) {
120
+ const checked = integrity === void 0 ? "" : ` integrity="${attributeText(integrity)}"`;
121
+ return `<link rel="modulepreload" crossorigin href="${attributeText(href)}"${checked} />`;
122
+ }
123
+ /**
124
+ * Returns the path of a release's catalogue in the page's language.
125
+ */
126
+ function catalogueOf(provided) {
127
+ return `locales/${provided.language}.json`;
128
+ }
129
+ /**
130
+ * Returns true for a release that the page can load. Such a release has a catalogue in the page's
131
+ * language, and the provided versions meet its requirements.
132
+ */
133
+ function loadedBy(listed, provided) {
134
+ const { files, requires } = listed.release;
135
+ return Object.hasOwn(files, catalogueOf(provided)) && unmetRequirements(requires, provided.versions).length === 0;
136
+ }
137
+ /**
138
+ * Returns the words that the bytes of a catalogue contain, or undefined for bytes that are not a
139
+ * JSON object.
140
+ */
141
+ function wordsIn(bytes) {
142
+ let value;
143
+ try {
144
+ value = JSON.parse(new TextDecoder().decode(bytes));
145
+ } catch {
146
+ return;
147
+ }
148
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
149
+ return value;
150
+ }
151
+ /**
152
+ * Returns a release with its words in the page's language. The step fetches the words and checks
153
+ * them against the integrity in the release's description.
154
+ *
155
+ * @remarks
156
+ * The function returns the release unchanged when the page cannot load it. It also returns the
157
+ * release unchanged when the catalogue fails to load, has another digest or is not a JSON object.
158
+ * The page then fetches those words itself.
159
+ */
160
+ async function withWords(fetching, listed, provided) {
161
+ const path = catalogueOf(provided);
162
+ const integrity = listed.release.files[path];
163
+ if (integrity === void 0 || !loadedBy(listed, provided)) return listed;
164
+ const bytes = await bytesOf(fetching, new URL(path, listed.base).href);
165
+ if (typeof bytes === "string" || await integrityOf(bytes) !== integrity) return listed;
166
+ const words = wordsIn(bytes);
167
+ return words === void 0 ? listed : {
168
+ ...listed,
169
+ words
170
+ };
171
+ }
172
+ /**
173
+ * Returns the content security policy of a document that lists releases.
174
+ *
175
+ * @remarks
176
+ * The policy contains `script-src` alone. A `connect-src` would restrict every other request the
177
+ * product makes, and the page fetches a release's catalogues with their integrity.
178
+ */
179
+ async function policyOf(listed, provided, importMap) {
180
+ const urls = [...listed.map(({ base }) => base), ...Object.values(provided.imports)];
181
+ const origins = new Set(urls.flatMap((url) => URL.canParse(url) ? [new URL(url).origin] : []));
182
+ const digest = await integrityOf(new TextEncoder().encode(importMap));
183
+ return `script-src ${[
184
+ "'self'",
185
+ ...[...origins].toSorted(),
186
+ `'${digest}'`
187
+ ].join(" ")}`;
188
+ }
189
+ /**
190
+ * Returns the parts of the document that depend on the deployment, after the step fetches each
191
+ * release's description and checks it against the deployment.
192
+ *
193
+ * @remarks
194
+ * The step leaves a release out of the document and lists it in `refused` where its
195
+ * `plugin.json` fails to load, has a digest other than the pinned one, does not parse or is not a
196
+ * release description. The step writes the words of each release that the page can load into the
197
+ * deployment. It also writes the module preloads of the release's manifest and of the modules
198
+ * that the manifest imports.
199
+ * @param deployment - The releases the product loads at run time.
200
+ * @param provided - The product build's `.product/provided.json`.
201
+ * @param options - How the step fetches a release's files.
202
+ */
203
+ async function deployDocument(deployment, provided, options = {}) {
204
+ const fetching = options.fetching ?? FETCHING;
205
+ const outcomes = await Promise.all(deployment.releases.map((one) => listedOf(fetching, one)));
206
+ const found = outcomes.flatMap((outcome) => "release" in outcome ? [outcome] : []);
207
+ const listed = await Promise.all(found.map((one) => withWords(fetching, one, provided)));
208
+ const read = {
209
+ id: deployment.id,
210
+ modules: modulesOf(listed, provided),
211
+ provided: provided.versions,
212
+ releases: listed
213
+ };
214
+ const importMap = scriptText({
215
+ imports: provided.imports,
216
+ integrity: integrityMapOf(listed)
217
+ });
218
+ const preloads = listed.filter((one) => loadedBy(one, provided)).flatMap((one) => preloadsOf(read, one, MANIFEST_FILE));
219
+ return {
220
+ csp: await policyOf(listed, provided, importMap),
221
+ deployment: scriptText(read),
222
+ importMap,
223
+ preloads: [...new Map(preloads.map((preload) => [preload.href, preload])).values()],
224
+ refused: outcomes.flatMap((outcome) => "release" in outcome ? [] : [outcome]),
225
+ releases: listed.map(({ release }) => release.id)
226
+ };
227
+ }
228
+ /**
229
+ * Returns a product's built document with the parts that depend on the deployment. The policy, the
230
+ * deployment's import map, the preloads and the deployment that the page reads take the place of
231
+ * the build's import map.
232
+ *
233
+ * @remarks
234
+ * The function finds the import map with two string searches, so its time grows linearly with
235
+ * the length of the document.
236
+ * @param html - The document the product's build wrote, which contains the build's import map.
237
+ * @param deployed - The parts `deployDocument` returned.
238
+ * @throws {@link Error} Where the document does not contain an import map. The build of a product
239
+ * without `runtime` writes such a document.
240
+ */
241
+ function documentWith(html, deployed) {
242
+ const start = html.indexOf(IMPORT_MAP_START);
243
+ const end = start === -1 ? -1 : html.indexOf(SCRIPT_END, start + 25);
244
+ if (end === -1) throw new Error("The document does not contain an import map. Build the product with runtime to write one.");
245
+ const tags = [
246
+ `<meta http-equiv="Content-Security-Policy" content="${deployed.csp}" />`,
247
+ `<script type="importmap">${deployed.importMap}<\/script>`,
248
+ ...deployed.preloads.map((preload) => moduleTagOf(preload)),
249
+ `<script type="application/json" id="${DEPLOYMENT_ELEMENT}">${deployed.deployment}<\/script>`
250
+ ];
251
+ return `${html.slice(0, start)}${tags.join("\n ")}${html.slice(end + 9)}`;
252
+ }
253
+ //#endregion
254
+ export { DEPLOYMENT_ELEMENT, deployDocument, documentWith };
@@ -0,0 +1,38 @@
1
+ import { deploymentProblems, lineOf } from "@stealthscale/sdk-core";
2
+ //#region src/releases/document.ts
3
+ /**
4
+ * Reads the deployment a page's document states: the releases the page loads at run time, which
5
+ * the deployment step writes into the document as JSON.
6
+ */
7
+ /**
8
+ * Id of the element that contains the deployment as JSON in a page's document.
9
+ */
10
+ const DEPLOYMENT_ELEMENT = "stealth-deployment";
11
+ /**
12
+ * Returns the deployment a document states, the faults of one the page cannot read, or none.
13
+ *
14
+ * @param document - The page's document, of which the function reads one element.
15
+ */
16
+ function deploymentOf(document) {
17
+ const text = document.querySelector(`#${DEPLOYMENT_ELEMENT}`)?.textContent;
18
+ let value;
19
+ if (typeof text !== "string") return { kind: "none" };
20
+ try {
21
+ value = JSON.parse(text);
22
+ } catch (error) {
23
+ return {
24
+ faults: [`deployment: ${error.message}`],
25
+ kind: "malformed"
26
+ };
27
+ }
28
+ const problems = deploymentProblems(value);
29
+ return problems.length === 0 ? {
30
+ deployment: value,
31
+ kind: "deployment"
32
+ } : {
33
+ faults: problems.map((problem) => lineOf(problem)),
34
+ kind: "malformed"
35
+ };
36
+ }
37
+ //#endregion
38
+ export { deploymentOf as n, DEPLOYMENT_ELEMENT as t };