@skgo/sveltekit-adapter 0.10.1 → 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.10.1",
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",
@@ -32,10 +32,12 @@
32
32
  "skgo-adapter/app-paths.js",
33
33
  "skgo-adapter/app-server.js",
34
34
  "skgo-adapter/entry.js",
35
+ "skgo-adapter/env-private.js",
35
36
  "skgo-adapter/env.js",
36
37
  "skgo-adapter/generated.js",
37
38
  "skgo-adapter/identity.js",
38
- "skgo-adapter/polyfill.js"
39
+ "skgo-adapter/polyfill.js",
40
+ "skgo-adapter/prerender-csp.js"
39
41
  ],
40
42
  "peerDependencies": {
41
43
  "@sveltejs/kit": "^3.0.0-next.0"
@@ -0,0 +1,48 @@
1
+ import { resolve } from 'node:path';
2
+ import { realpathSync } from 'node:fs';
3
+
4
+ function canonical(file) {
5
+ try { return realpathSync.native(file); } catch { return file; }
6
+ }
7
+
8
+ // Vite's raw/transform middleware can serve generated server files directly.
9
+ // Deny them before those middleware run, including absolute /@fs requests.
10
+ export function protectEnvironmentFiles({ root, kitOut, output, base = '/', token }) {
11
+ const files = [
12
+ resolve(root, '.svelte-kit/skgo-env-values.js'),
13
+ resolve(root, '.svelte-kit/skgo-env-runtime.json'),
14
+ resolve(root, kitOut, 'generated/build/env/config.js'),
15
+ resolve(root, kitOut, 'generated/dev/env/config.js'),
16
+ resolve(root, output, 'env.json')
17
+ ].map(canonical);
18
+ const directories = [
19
+ resolve(root, kitOut, 'output/server'),
20
+ resolve(root, kitOut, 'generated/build/env/private'),
21
+ resolve(root, kitOut, 'generated/dev/env/private'),
22
+ resolve(root, output, 'ssr')
23
+ ].map(canonical);
24
+ return (req, res, next) => {
25
+ let path;
26
+ try { path = decodeURIComponent((req.url || '').split('?')[0]); }
27
+ catch { res.statusCode = 400; res.end('Bad request'); return; }
28
+ if (base !== '/' && path.startsWith(base)) path = '/' + path.slice(base.length);
29
+ if (token && /^\/__skgo_dev\/(?:module|styles)(?:\/|$)/.test(path) && req.headers['x-skgo-dev-token'] !== token) {
30
+ res.statusCode = 403; res.end('Forbidden'); return;
31
+ }
32
+ let file;
33
+ if (path.startsWith('/@id/')) {
34
+ const id = path.slice(5).replace('__x00__', '\0');
35
+ if (id === '$app/env/private' || id === '#app/env/private' || /<sveltekit:generated>\/env\/(?:config\.js|private(?:\/|$))/.test(id)) {
36
+ res.statusCode = 403; res.end('Forbidden'); return;
37
+ }
38
+ file = resolve(id);
39
+ } else {
40
+ file = path.startsWith('/@fs/') ? resolve(path.slice(4)) : resolve(root, '.' + path);
41
+ }
42
+ file = canonical(file);
43
+ if (files.includes(file) || directories.some(dir => file === dir || file.startsWith(dir + '/'))) {
44
+ res.statusCode = 403; res.end('Forbidden'); return;
45
+ }
46
+ next();
47
+ };
48
+ }
@@ -25,7 +25,10 @@
25
25
  */
26
26
 
27
27
  import { createRequire } from 'node:module';
28
- import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
28
+ import { execFileSync } from 'node:child_process';
29
+ import { randomBytes } from 'node:crypto';
30
+ import { protectEnvironmentFiles } from './env-private.js';
31
+ import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
29
32
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
30
33
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
34
 
@@ -64,7 +67,7 @@ const { transformSync } = await fromApp('vite/rolldown/experimental');
64
67
  // `fetchModule` operates on the app's own `DevEnvironment`, so it has to be
65
68
  // the app's copy for the same reason rolldown is: a second module realm's
66
69
  // vite does not recognise this one's environments.
67
- const { fetchModule, isCSSRequest } = await fromApp('vite');
70
+ const { fetchModule, isCSSRequest, loadEnv } = await fromApp('vite');
68
71
 
69
72
  // These are the same two Kit functions its own dev server calls in
70
73
  // `update_manifest`: one derives the route/node graph from the authored tree,
@@ -324,6 +327,11 @@ export function gojaEnvironment() {
324
327
  load: {
325
328
  order: 'pre',
326
329
  handler(id) {
330
+ if (/\/\.svelte-kit\/skgo-env-(?:values\.js|runtime\.json)(?:\?|$)/.test(id)) {
331
+ throw new Error('skgo: environment build inputs are private');
332
+ }
333
+ const environment = goEnvironmentModule(id);
334
+ if (environment !== null) return environment;
327
335
  if (!id.startsWith(PREFIX)) return null;
328
336
  const make = sources[id.slice(PREFIX.length)];
329
337
  if (!make) throw new Error('skgo: no virtual module ' + id.slice(PREFIX.length));
@@ -926,6 +934,11 @@ export function gojaDevEnvironment({ outDir = '.svelte-kit' } = {}) {
926
934
  load: {
927
935
  order: 'pre',
928
936
  handler(id) {
937
+ if (/\/\.svelte-kit\/skgo-env-(?:values\.js|runtime\.json)(?:\?|$)/.test(id)) {
938
+ throw new Error('skgo: environment build inputs are private');
939
+ }
940
+ const environment = goEnvironmentModule(id);
941
+ if (environment !== null) return environment;
929
942
  if (!id.startsWith(PREFIX)) return null;
930
943
  const make = sources[id.slice(PREFIX.length)];
931
944
  if (!make) throw new Error('skgo: no virtual module ' + id.slice(PREFIX.length));
@@ -1056,3 +1069,75 @@ function errorText(e) {
1056
1069
  if (e instanceof Error) return e.stack ?? e.message;
1057
1070
  return String(e);
1058
1071
  }
1072
+
1073
+
1074
+ // Values are validated by Go once before Kit loads its environment declaration.
1075
+ // This must be a real file: Kit imports env.ts in a separate Vite server with
1076
+ // configFile:false, where this adapter's virtual modules do not exist.
1077
+ export function goEnvironmentValues(output = 'build') {
1078
+ let token = '';
1079
+ let kitOut = '.svelte-kit';
1080
+ let fields = [];
1081
+ let values = {};
1082
+ return {
1083
+ plugin: {
1084
+ name: 'skgo-environment-values',
1085
+ configureServer(server) {
1086
+ // A server-only build input can contain private values. Vite's
1087
+ // filesystem endpoint must not make it downloadable in development.
1088
+ server.middlewares.use(protectEnvironmentFiles({
1089
+ root: server.config.root, kitOut, output, base: server.config.base, token
1090
+ }));
1091
+ },
1092
+ configResolved(config) {
1093
+ const schema = join(config.root, 'skgo.env.json');
1094
+ if (!existsSync(schema)) return;
1095
+ fields = JSON.parse(readFileSync(schema, 'utf8')).fields;
1096
+ const setup = config.plugins.find((p) => p.name === 'vite-plugin-sveltekit-setup');
1097
+ const kit = setup?.api?.options;
1098
+ if (!kit) throw new Error('skgo: Kit environment configuration is unavailable');
1099
+ kitOut = kit.outDir;
1100
+ const raw = loadEnv(config.mode, resolve(config.root, kit.env.dir), '');
1101
+ // Pass only declared values; diagnostics never echo the full process env.
1102
+ const input = Object.fromEntries(fields.filter((f) => f.name in raw).map((f) => [f.name, raw[f.name]]));
1103
+ try {
1104
+ values = JSON.parse(execFileSync('go', ['tool', 'skgo', 'env', '--schema', schema], {
1105
+ cwd: config.root, input: JSON.stringify(input), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
1106
+ }));
1107
+ } catch (error) {
1108
+ throw new Error('skgo: Go environment validation failed\n' + String(error.stderr || error.message));
1109
+ }
1110
+ const file = join(config.root, '.svelte-kit/skgo-env-values.js');
1111
+ mkdirSync(dirname(file), { recursive: true });
1112
+ writeFileSync(file, 'export const values = ' + JSON.stringify(values) + ';\n', { mode: 0o600 });
1113
+ chmodSync(file, 0o600);
1114
+
1115
+ if (config.command === 'serve') {
1116
+ token = randomBytes(32).toString('hex');
1117
+ const artifact = { token, fields, static: Object.fromEntries(fields.filter((f) => f.static && f.name in values).map((f) => [f.name, values[f.name]])), values: input };
1118
+ const runtimeFile = join(config.root, '.svelte-kit/skgo-env-runtime.json');
1119
+ writeFileSync(runtimeFile, JSON.stringify(artifact), { mode: 0o600 });
1120
+ chmodSync(runtimeFile, 0o600);
1121
+ }
1122
+ },
1123
+ },
1124
+ artifact() {
1125
+ return { fields, static: Object.fromEntries(fields.filter((f) => f.static && f.name in values).map((f) => [f.name, values[f.name]])) };
1126
+ }
1127
+ };
1128
+ }
1129
+
1130
+ // Kit's public/private modules snapshot these properties during evaluation.
1131
+ // Go installs the snapshot before loading any application module. In particular,
1132
+ // importing Kit's original config here would re-run JavaScript validators and
1133
+ // bake the development/build environment into newly-created renderer runtimes.
1134
+ function goEnvironmentModule(id) {
1135
+ if (!existsSync(join(process.cwd(), 'skgo.env.json'))) return null;
1136
+ if (!/\/generated\/(?:build|dev)\/env\/config\.js$/.test(id.replaceAll('\\', '/'))) return null;
1137
+ return `const snapshot = globalThis.__skgo_environment || {};
1138
+ export const dynamic_private_env = snapshot.private || {};
1139
+ export const explicit_public_env = snapshot.public || {};
1140
+ export const rendered_env = snapshot.dynamicPublic || {};
1141
+ export const variables = {};
1142
+ export function set_env() {}`;
1143
+ }
@@ -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
- import { gojaDevEnvironment, gojaEnvironment, nodeTable, SSR_TARGET } from './skgo-adapter/env.js';
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
@@ -56,26 +79,114 @@ export default function skgo({ out = 'build', precompress = true } = {}) {
56
79
  // runs, so the plugin that declares the environment has to exist here; the
57
80
  // build itself waits until adapt() knows the node table.
58
81
  const goja = gojaEnvironment();
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
+ }
59
109
 
60
110
  return {
61
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
+ },
62
172
  // Both halves of the same environment. Kit hands `vite.plugins.post` to
63
173
  // vite unconditionally — it is a member of the plugin array kit returns
64
174
  // whether it is building or serving — so the environment the build
65
175
  // compiles the SSR bundle in is also declared in `vite dev`, where Go
66
176
  // pulls one transformed module at a time out of it instead. Each plugin
67
177
  // states its own `apply`, so only one of them is ever live.
68
- vite: { plugins: { post: [goja.plugin, gojaDevEnvironment()] } },
178
+ vite: { plugins: { post: [environment.plugin, goja.plugin, gojaDevEnvironment()] } },
69
179
  async adapt(builder) {
70
180
  rmSync(out, { force: true, recursive: true });
71
181
 
182
+ write(`${out}/env.json`, JSON.stringify(environment.artifact()));
72
183
  const generated = readGenerated();
73
184
  const { manifest: kit, source } = await readKitManifest(builder);
74
185
  const hashes = checkRemoteHashes(kit, generated.remotes);
75
186
 
76
- const nodes = await readNodes(builder, source, kit);
187
+ const { nodes, allServerIds } = await readNodes(builder, source, kit);
77
188
  const serverIds = nodes.map((node) => node.server);
78
- checkServerLoads(serverIds, generated.loads, generated.actions);
189
+ checkServerLoads(allServerIds, generated.loads, generated.actions);
79
190
 
80
191
  const endpoints = checkEndpoints(builder, generated.endpoints);
81
192
 
@@ -83,6 +194,9 @@ export default function skgo({ out = 'build', precompress = true } = {}) {
83
194
  checkRemoteIds(`${out}/client`, generated.remotes, hashes);
84
195
 
85
196
  builder.writePrerendered(`${out}/prerendered`);
197
+ for (const file of walk(`${out}/prerendered`)) {
198
+ if (file.endsWith('.html')) authorizePrerenderedScripts(file);
199
+ }
86
200
  const prerendered = readPrerendered(builder);
87
201
  // The SPA shell is still emitted: it is what a page whose branch turns
88
202
  // SSR off is answered with.
@@ -205,7 +319,7 @@ export default function skgo({ out = 'build', precompress = true } = {}) {
205
319
  * halves were never checked against each other. The shape checks themselves
206
320
  * live in `skgo-adapter/generated.js`, beside the paths they validate.
207
321
  *
208
- * @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 } }}
209
323
  */
210
324
  function readGenerated() {
211
325
  let raw;
@@ -354,7 +468,7 @@ async function readKitManifest(builder) {
354
468
  * @param {import('@sveltejs/kit').Builder} builder
355
469
  * @param {string} source kit's generated manifest, as text
356
470
  * @param {any} kit the same manifest, imported
357
- * @returns {Promise<Node[]>}
471
+ * @returns {Promise<{ nodes: Node[], allServerIds: string[] }>}
358
472
  */
359
473
  async function readNodes(builder, source, kit) {
360
474
  const dir = join(builder.getServerDirectory(), 'nodes');
@@ -382,6 +496,11 @@ async function readNodes(builder, source, kit) {
382
496
 
383
497
  /** @type {Node[]} */
384
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
+ }
385
504
  for (const index of order) {
386
505
  const file = files.get(index);
387
506
  if (!file) {
@@ -402,7 +521,7 @@ async function readNodes(builder, source, kit) {
402
521
  });
403
522
  }
404
523
 
405
- return nodes;
524
+ return { nodes, allServerIds };
406
525
  }
407
526
 
408
527
  /**
@@ -645,9 +764,8 @@ function* walk(dir) {
645
764
  /**
646
765
  * The same check for server loads. Kit decides whether its client asks for
647
766
  * `__data.json` at all by looking for a `load` export in the *built*
648
- * `+*.server.js`, so a stub that did not reach the build is a page whose Go load
649
- * is never called, and a compiled stub with no Go load behind it is a page whose
650
- * 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.
651
769
  *
652
770
  * @param {string[]} nodes
653
771
  * @param {string[]} loads