@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 +3 -2
- package/skgo-adapter/generated.js +9 -2
- package/skgo-adapter/prerender-csp.js +19 -0
- package/skgo-adapter.js +124 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skgo/sveltekit-adapter",
|
|
3
|
-
"version": "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(
|
|
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
|
|
651
|
-
*
|
|
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
|