@skgo/sveltekit-adapter 0.11.0 → 0.12.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.12.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"
@@ -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,95 @@ 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 transportDecoders = {};
88
+ async function buildPrerenderBinary() {
89
+ if (!prerenderBuild) {
90
+ prerenderBuild = (async () => {
91
+ const generated = readGenerated();
92
+ if (!generated.prerender) throw new Error('skgo: generated prerender command is missing');
93
+ prerenderRoot = resolve(process.cwd(), generated.prerender.root);
94
+ const dir = mkdtempSync(join(tmpdir(), 'skgo-prerender-'));
95
+ prerenderBinary = join(dir, 'loads');
96
+ await runPrerenderCommand('go', ['build', '-o', prerenderBinary, generated.prerender.package], prerenderRoot);
97
+ process.on('exit', () => rmSync(dir, { recursive: true, force: true }));
98
+ const fromKit = createRequire(realpathSync(join(process.cwd(), 'node_modules/@sveltejs/kit/package.json')));
99
+ ({ unflatten } = await import(pathToFileURL(fromKit.resolve('devalue')).href));
100
+ const hooks = join(process.cwd(), '.svelte-kit/output/server/entries/hooks.universal.js');
101
+ if (existsSync(hooks)) {
102
+ const { transport = {} } = await import(pathToFileURL(hooks).href);
103
+ transportDecoders = Object.fromEntries(Object.entries(transport).map(([key, entry]) => [key, entry.decode]));
104
+ }
105
+ })();
106
+ }
107
+ await prerenderBuild;
108
+ }
60
109
 
61
110
  return {
62
111
  name: 'skgo',
112
+ emulate() {
113
+ return {
114
+ platform: () => ({
115
+ async skgoPrerenderLoad(module, event) {
116
+ await buildPrerenderBinary();
117
+ const headers = Object.fromEntries(
118
+ [...event.request.headers].map(([name, value]) => [name, [value]])
119
+ );
120
+ const cookies = event.cookies.getAll();
121
+ if (cookies.length) {
122
+ headers.cookie = [cookies.map(({ name, value }) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join('; ')];
123
+ }
124
+ const request = {
125
+ module,
126
+ url: event.url.href,
127
+ routeId: event.route.id,
128
+ params: Object.fromEntries(Object.entries(event.params)),
129
+ parent: await event.parent(),
130
+ headers
131
+ };
132
+ const raw = await runPrerenderCommand(prerenderBinary, [], prerenderRoot, JSON.stringify(request));
133
+ const answer = JSON.parse(raw);
134
+ for (const [name, values] of Object.entries(answer.headers ?? {})) {
135
+ event.setHeaders({ [name]: values.join(', ') });
136
+ }
137
+ for (const cookie of answer.cookies ?? []) {
138
+ event.cookies.set(cookie.name, cookie.value, {
139
+ path: cookie.path,
140
+ ...(cookie.domain ? { domain: cookie.domain } : {}),
141
+ ...(cookie.maxAge ? { maxAge: cookie.maxAge } : {}),
142
+ httpOnly: cookie.httpOnly,
143
+ secure: cookie.secure,
144
+ sameSite: ['lax', 'lax', 'strict', 'none'][cookie.sameSite] ?? 'lax'
145
+ });
146
+ }
147
+ if (answer.data) {
148
+ const pending = new Map();
149
+ const revivers = {
150
+ ...transportDecoders,
151
+ Promise: (id) => {
152
+ let resolve;
153
+ let reject;
154
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
155
+ pending.set(id, { promise, resolve, reject });
156
+ return promise;
157
+ }
158
+ };
159
+ answer.data = unflatten(answer.data, revivers);
160
+ for (const chunk of answer.chunks ?? []) {
161
+ const deferred = pending.get(chunk.id);
162
+ if (!deferred) throw new Error(`skgo: unknown prerender deferred id ${chunk.id}`);
163
+ if (chunk.error) deferred.reject(new Error(chunk.error));
164
+ else deferred.resolve(unflatten(chunk.data, revivers));
165
+ }
166
+ }
167
+ return answer;
168
+ }
169
+ })
170
+ };
171
+ },
63
172
  // Both halves of the same environment. Kit hands `vite.plugins.post` to
64
173
  // vite unconditionally — it is a member of the plugin array kit returns
65
174
  // whether it is building or serving — so the environment the build
@@ -75,9 +184,9 @@ export default function skgo({ out = 'build', precompress = true } = {}) {
75
184
  const { manifest: kit, source } = await readKitManifest(builder);
76
185
  const hashes = checkRemoteHashes(kit, generated.remotes);
77
186
 
78
- const nodes = await readNodes(builder, source, kit);
187
+ const { nodes, allServerIds } = await readNodes(builder, source, kit);
79
188
  const serverIds = nodes.map((node) => node.server);
80
- checkServerLoads(serverIds, generated.loads, generated.actions);
189
+ checkServerLoads(allServerIds, generated.loads, generated.actions);
81
190
 
82
191
  const endpoints = checkEndpoints(builder, generated.endpoints);
83
192
 
@@ -85,6 +194,9 @@ export default function skgo({ out = 'build', precompress = true } = {}) {
85
194
  checkRemoteIds(`${out}/client`, generated.remotes, hashes);
86
195
 
87
196
  builder.writePrerendered(`${out}/prerendered`);
197
+ for (const file of walk(`${out}/prerendered`)) {
198
+ if (file.endsWith('.html')) authorizePrerenderedScripts(file);
199
+ }
88
200
  const prerendered = readPrerendered(builder);
89
201
  // The SPA shell is still emitted: it is what a page whose branch turns
90
202
  // SSR off is answered with.
@@ -207,7 +319,7 @@ export default function skgo({ out = 'build', precompress = true } = {}) {
207
319
  * halves were never checked against each other. The shape checks themselves
208
320
  * live in `skgo-adapter/generated.js`, beside the paths they validate.
209
321
  *
210
- * @returns {{ remotes: string[], loads: string[], actions: string[], endpoints: Record<string, string[]> }}
322
+ * @returns {{ remotes: string[], loads: string[], actions: string[], endpoints: Record<string, string[]>, prerender?: { root: string, package: string } }}
211
323
  */
212
324
  function readGenerated() {
213
325
  let raw;
@@ -356,7 +468,7 @@ async function readKitManifest(builder) {
356
468
  * @param {import('@sveltejs/kit').Builder} builder
357
469
  * @param {string} source kit's generated manifest, as text
358
470
  * @param {any} kit the same manifest, imported
359
- * @returns {Promise<Node[]>}
471
+ * @returns {Promise<{ nodes: Node[], allServerIds: string[] }>}
360
472
  */
361
473
  async function readNodes(builder, source, kit) {
362
474
  const dir = join(builder.getServerDirectory(), 'nodes');
@@ -384,6 +496,11 @@ async function readNodes(builder, source, kit) {
384
496
 
385
497
  /** @type {Node[]} */
386
498
  const nodes = [];
499
+ const allServerIds = [];
500
+ for (const file of files.values()) {
501
+ const module = await import(pathToFileURL(file).href);
502
+ if (module.server_id) allServerIds.push(module.server_id);
503
+ }
387
504
  for (const index of order) {
388
505
  const file = files.get(index);
389
506
  if (!file) {
@@ -404,7 +521,7 @@ async function readNodes(builder, source, kit) {
404
521
  });
405
522
  }
406
523
 
407
- return nodes;
524
+ return { nodes, allServerIds };
408
525
  }
409
526
 
410
527
  /**
@@ -647,9 +764,8 @@ function* walk(dir) {
647
764
  /**
648
765
  * The same check for server loads. Kit decides whether its client asks for
649
766
  * `__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.
767
+ * `+*.server.js`. A prerendered leaf is removed from the final manifest, so
768
+ * check all node modules Kit compiled, including the removed leaves.
653
769
  *
654
770
  * @param {string[]} nodes
655
771
  * @param {string[]} loads