@skgo/sveltekit-adapter 0.11.0 → 0.13.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skgo/sveltekit-adapter",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "SvelteKit adapter for skgo: builds a frontend that a Go binary renders and answers",
5
5
  "keywords": [
6
6
  "adapter",
@@ -36,7 +36,8 @@
36
36
  "skgo-adapter/env.js",
37
37
  "skgo-adapter/generated.js",
38
38
  "skgo-adapter/identity.js",
39
- "skgo-adapter/polyfill.js"
39
+ "skgo-adapter/polyfill.js",
40
+ "skgo-adapter/prerender-csp.js"
40
41
  ],
41
42
  "peerDependencies": {
42
43
  "@sveltejs/kit": "^3.0.0-next.0"
@@ -18,7 +18,7 @@
18
18
  // host binding is the only path by which a value can reach the engine.
19
19
  //
20
20
  // Everything this file does not name is kit's, by `export *`: `read`,
21
- // `getRequestEvent`, `prerender` and `requested` come from kit's own module,
21
+ // `getRequestEvent` and `requested` come from kit's own module,
22
22
  // and an export kit adds arrives without an edit here. An explicit export
23
23
  // below shadows the star, which is how the four substitutions take effect.
24
24
  import * as real from 'skgo:kit/remote';
@@ -62,6 +62,14 @@ export function query(validate_or_fn, maybe_fn) {
62
62
  return wrapper;
63
63
  }
64
64
 
65
+ export function prerender(validate_or_fn, maybe_fn, options) {
66
+ const fn = (arg) => host(wrapper.__.id, stringify_remote_arg(arg));
67
+ const wrapper = typeof maybe_fn === 'function'
68
+ ? real.prerender(validate_or_fn, fn, options)
69
+ : real.prerender(fn, maybe_fn);
70
+ return wrapper;
71
+ }
72
+
65
73
  /**
66
74
  * Calls Go once for a whole batch. Kit's own enqueue has already collected
67
75
  * every call made to this function in one macrotask and deduplicated them by
@@ -10,7 +10,7 @@
10
10
  * first-class.
11
11
  *
12
12
  * @param {any} parsed the parsed contents of `skgo.remotes.json`
13
- * @returns {{ remotes: string[], loads: string[], actions: string[], endpoints: Record<string, string[]> }}
13
+ * @returns {{ remotes: string[], loads: string[], actions: string[], endpoints: Record<string, string[]>, prerender?: { root: string, package: string } }}
14
14
  */
15
15
  export function validateGenerated(parsed) {
16
16
  if (!Array.isArray(parsed.remotes)) {
@@ -37,6 +37,13 @@ export function validateGenerated(parsed) {
37
37
  );
38
38
  }
39
39
  }
40
+ if (parsed.loads.length > 0 && (
41
+ typeof parsed.prerender?.root !== 'string' ||
42
+ typeof parsed.prerender?.package !== 'string' ||
43
+ !parsed.prerender.package.startsWith('./')
44
+ )) {
45
+ throw new Error('skgo: generated Go loads need a build-time prerender command; run `go generate ./...`.');
46
+ }
40
47
  if (!Array.isArray(parsed.actions)) {
41
48
  throw new Error('skgo: skgo.remotes.json has no `actions` array. Run `go generate ./...` before building the frontend.');
42
49
  }
@@ -57,7 +64,7 @@ export function validateGenerated(parsed) {
57
64
  );
58
65
  }
59
66
  }
60
- return { remotes: parsed.remotes, loads: parsed.loads, actions: parsed.actions, endpoints: parsed.endpoints };
67
+ return { remotes: parsed.remotes, loads: parsed.loads, actions: parsed.actions, endpoints: parsed.endpoints, prerender: parsed.prerender };
61
68
  }
62
69
 
63
70
  /**
@@ -0,0 +1,19 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+
4
+ // Kit writes streamed promise settlements after the closing HTML tag. Its
5
+ // prerendered CSP meta tag hashes the boot script but does not hash those later
6
+ // inline scripts. Add only the hashes of scripts present in the finished file.
7
+ export function authorizePrerenderedScripts(file) {
8
+ const html = readFileSync(file, 'utf8');
9
+ const meta = /<meta http-equiv="content-security-policy" content="([^"]*)">/i.exec(html);
10
+ if (!meta) return;
11
+ const additions = [];
12
+ for (const [, attributes, body] of html.matchAll(/<script([^>]*)>([\s\S]*?)<\/script>/gi)) {
13
+ if (/\bsrc\s*=/.test(attributes)) continue;
14
+ const hash = `'sha256-${createHash('sha256').update(body).digest('base64')}'`;
15
+ if (!meta[1].includes(hash)) additions.push(hash);
16
+ }
17
+ if (!additions.length) return;
18
+ writeFileSync(file, html.replace(meta[0], meta[0].replace(meta[1], `${meta[1]} ${[...new Set(additions)].join(' ')}`)));
19
+ }
package/skgo-adapter.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  existsSync,
3
+ mkdtempSync,
3
4
  mkdirSync,
4
5
  readdirSync,
5
6
  readFileSync,
@@ -8,11 +9,33 @@ import {
8
9
  statSync,
9
10
  writeFileSync
10
11
  } from 'node:fs';
12
+ import { spawn } from 'node:child_process';
13
+ import { createRequire } from 'node:module';
14
+ import { tmpdir } from 'node:os';
11
15
  import { basename, dirname, join, relative, resolve } from 'node:path';
12
16
  import { pathToFileURL } from 'node:url';
13
17
  import { gojaDevEnvironment, gojaEnvironment, goEnvironmentValues, nodeTable, SSR_TARGET } from './skgo-adapter/env.js';
14
18
  import { identity } from './skgo-adapter/identity.js';
15
19
  import { checkEndpoints, validateGenerated } from './skgo-adapter/generated.js';
20
+ import { authorizePrerenderedScripts } from './skgo-adapter/prerender-csp.js';
21
+
22
+ function runPrerenderCommand(command, args, cwd, input = '') {
23
+ return new Promise((resolve, reject) => {
24
+ const child = spawn(command, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
25
+ let stdout = '';
26
+ let stderr = '';
27
+ child.stdout.setEncoding('utf8');
28
+ child.stderr.setEncoding('utf8');
29
+ child.stdout.on('data', (chunk) => (stdout += chunk));
30
+ child.stderr.on('data', (chunk) => (stderr += chunk));
31
+ child.on('error', reject);
32
+ child.on('close', (code) => {
33
+ if (code === 0) resolve(stdout);
34
+ else reject(new Error(`skgo prerender command exited ${code}: ${stderr}`));
35
+ });
36
+ child.stdin.end(input);
37
+ });
38
+ }
16
39
 
17
40
  // Which skgo this adapter is: the version this package was published at, and a
18
41
  // fingerprint taken over its own files. The Go that reads the manifest below
@@ -57,9 +80,108 @@ export default function skgo({ out = 'build', precompress = true } = {}) {
57
80
  // build itself waits until adapt() knows the node table.
58
81
  const goja = gojaEnvironment();
59
82
  const environment = goEnvironmentValues(out);
83
+ let prerenderBinary;
84
+ let prerenderRoot;
85
+ let prerenderBuild;
86
+ let unflatten;
87
+ let stringify;
88
+ let transportDecoders = {};
89
+ async function buildPrerenderBinary() {
90
+ if (!prerenderBuild) {
91
+ prerenderBuild = (async () => {
92
+ const generated = readGenerated();
93
+ if (!generated.prerender) throw new Error('skgo: generated prerender command is missing');
94
+ prerenderRoot = resolve(process.cwd(), generated.prerender.root);
95
+ const dir = mkdtempSync(join(tmpdir(), 'skgo-prerender-'));
96
+ prerenderBinary = join(dir, 'build');
97
+ await runPrerenderCommand('go', ['build', '-o', prerenderBinary, generated.prerender.package], prerenderRoot);
98
+ process.on('exit', () => rmSync(dir, { recursive: true, force: true }));
99
+ const fromKit = createRequire(realpathSync(join(process.cwd(), 'node_modules/@sveltejs/kit/package.json')));
100
+ ({ unflatten, stringify } = await import(pathToFileURL(fromKit.resolve('devalue')).href));
101
+ const hooks = join(process.cwd(), '.svelte-kit/output/server/entries/hooks.universal.js');
102
+ if (existsSync(hooks)) {
103
+ const { transport = {} } = await import(pathToFileURL(hooks).href);
104
+ transportDecoders = Object.fromEntries(Object.entries(transport).map(([key, entry]) => [key, entry.decode]));
105
+ }
106
+ })();
107
+ }
108
+ await prerenderBuild;
109
+ }
60
110
 
61
111
  return {
62
112
  name: 'skgo',
113
+ emulate() {
114
+ return {
115
+ platform: () => ({
116
+ async skgoPrerenderLoad(module, event) {
117
+ await buildPrerenderBinary();
118
+ const headers = Object.fromEntries(
119
+ [...event.request.headers].map(([name, value]) => [name, [value]])
120
+ );
121
+ const cookies = event.cookies.getAll();
122
+ if (cookies.length) {
123
+ headers.cookie = [cookies.map(({ name, value }) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join('; ')];
124
+ }
125
+ const request = {
126
+ kind: 'load',
127
+ module,
128
+ url: event.url.href,
129
+ routeId: event.route.id,
130
+ params: Object.fromEntries(Object.entries(event.params)),
131
+ parent: await event.parent(),
132
+ headers
133
+ };
134
+ const raw = await runPrerenderCommand(prerenderBinary, [], prerenderRoot, JSON.stringify(request));
135
+ const answer = JSON.parse(raw);
136
+ for (const [name, values] of Object.entries(answer.headers ?? {})) {
137
+ event.setHeaders({ [name]: values.join(', ') });
138
+ }
139
+ for (const cookie of answer.cookies ?? []) {
140
+ event.cookies.set(cookie.name, cookie.value, {
141
+ path: cookie.path,
142
+ ...(cookie.domain ? { domain: cookie.domain } : {}),
143
+ ...(cookie.maxAge ? { maxAge: cookie.maxAge } : {}),
144
+ httpOnly: cookie.httpOnly,
145
+ secure: cookie.secure,
146
+ sameSite: ['lax', 'lax', 'strict', 'none'][cookie.sameSite] ?? 'lax'
147
+ });
148
+ }
149
+ if (answer.data) {
150
+ const pending = new Map();
151
+ const revivers = {
152
+ ...transportDecoders,
153
+ Promise: (id) => {
154
+ let resolve;
155
+ let reject;
156
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
157
+ pending.set(id, { promise, resolve, reject });
158
+ return promise;
159
+ }
160
+ };
161
+ answer.data = unflatten(answer.data, revivers);
162
+ for (const chunk of answer.chunks ?? []) {
163
+ const deferred = pending.get(chunk.id);
164
+ if (!deferred) throw new Error(`skgo: unknown prerender deferred id ${chunk.id}`);
165
+ if (chunk.error) deferred.reject(new Error(chunk.error));
166
+ else deferred.resolve(unflatten(chunk.data, revivers));
167
+ }
168
+ }
169
+ return answer;
170
+ },
171
+ async skgoPrerenderRemote(module, name, arg, event) {
172
+ await buildPrerenderBinary();
173
+ const payload = arg === undefined ? '' : Buffer.from(stringify(arg)).toString('base64url');
174
+ const request = {
175
+ kind: 'remote', module, name, payload, url: event.url.href,
176
+ headers: Object.fromEntries([...event.request.headers].map(([key, value]) => [key, [value]]))
177
+ };
178
+ const raw = await runPrerenderCommand(prerenderBinary, [], prerenderRoot, JSON.stringify(request));
179
+ const answer = JSON.parse(raw);
180
+ return unflatten(JSON.parse(answer.data), transportDecoders)._;
181
+ }
182
+ })
183
+ };
184
+ },
63
185
  // Both halves of the same environment. Kit hands `vite.plugins.post` to
64
186
  // vite unconditionally — it is a member of the plugin array kit returns
65
187
  // whether it is building or serving — so the environment the build
@@ -75,9 +197,9 @@ export default function skgo({ out = 'build', precompress = true } = {}) {
75
197
  const { manifest: kit, source } = await readKitManifest(builder);
76
198
  const hashes = checkRemoteHashes(kit, generated.remotes);
77
199
 
78
- const nodes = await readNodes(builder, source, kit);
200
+ const { nodes, allServerIds } = await readNodes(builder, source, kit);
79
201
  const serverIds = nodes.map((node) => node.server);
80
- checkServerLoads(serverIds, generated.loads, generated.actions);
202
+ checkServerLoads(allServerIds, generated.loads, generated.actions);
81
203
 
82
204
  const endpoints = checkEndpoints(builder, generated.endpoints);
83
205
 
@@ -85,6 +207,9 @@ export default function skgo({ out = 'build', precompress = true } = {}) {
85
207
  checkRemoteIds(`${out}/client`, generated.remotes, hashes);
86
208
 
87
209
  builder.writePrerendered(`${out}/prerendered`);
210
+ for (const file of walk(`${out}/prerendered`)) {
211
+ if (file.endsWith('.html')) authorizePrerenderedScripts(file);
212
+ }
88
213
  const prerendered = readPrerendered(builder);
89
214
  // The SPA shell is still emitted: it is what a page whose branch turns
90
215
  // SSR off is answered with.
@@ -207,7 +332,7 @@ export default function skgo({ out = 'build', precompress = true } = {}) {
207
332
  * halves were never checked against each other. The shape checks themselves
208
333
  * live in `skgo-adapter/generated.js`, beside the paths they validate.
209
334
  *
210
- * @returns {{ remotes: string[], loads: string[], actions: string[], endpoints: Record<string, string[]> }}
335
+ * @returns {{ remotes: string[], loads: string[], actions: string[], endpoints: Record<string, string[]>, prerender?: { root: string, package: string } }}
211
336
  */
212
337
  function readGenerated() {
213
338
  let raw;
@@ -356,7 +481,7 @@ async function readKitManifest(builder) {
356
481
  * @param {import('@sveltejs/kit').Builder} builder
357
482
  * @param {string} source kit's generated manifest, as text
358
483
  * @param {any} kit the same manifest, imported
359
- * @returns {Promise<Node[]>}
484
+ * @returns {Promise<{ nodes: Node[], allServerIds: string[] }>}
360
485
  */
361
486
  async function readNodes(builder, source, kit) {
362
487
  const dir = join(builder.getServerDirectory(), 'nodes');
@@ -384,6 +509,11 @@ async function readNodes(builder, source, kit) {
384
509
 
385
510
  /** @type {Node[]} */
386
511
  const nodes = [];
512
+ const allServerIds = [];
513
+ for (const file of files.values()) {
514
+ const module = await import(pathToFileURL(file).href);
515
+ if (module.server_id) allServerIds.push(module.server_id);
516
+ }
387
517
  for (const index of order) {
388
518
  const file = files.get(index);
389
519
  if (!file) {
@@ -404,7 +534,7 @@ async function readNodes(builder, source, kit) {
404
534
  });
405
535
  }
406
536
 
407
- return nodes;
537
+ return { nodes, allServerIds };
408
538
  }
409
539
 
410
540
  /**
@@ -647,9 +777,8 @@ function* walk(dir) {
647
777
  /**
648
778
  * The same check for server loads. Kit decides whether its client asks for
649
779
  * `__data.json` at all by looking for a `load` export in the *built*
650
- * `+*.server.js`, so a stub that did not reach the build is a page whose Go load
651
- * is never called, and a compiled stub with no Go load behind it is a page whose
652
- * data nobody answers.
780
+ * `+*.server.js`. A prerendered leaf is removed from the final manifest, so
781
+ * check all node modules Kit compiled, including the removed leaves.
653
782
  *
654
783
  * @param {string[]} nodes
655
784
  * @param {string[]} loads