@skgo/sveltekit-adapter 0.18.0 → 0.19.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.
@@ -1,833 +1,747 @@
1
- import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
2
- import { createHash, randomUUID } from 'node:crypto';
3
- import { spawn } from 'node:child_process';
4
- import { createRequire } from 'node:module';
5
- import process from 'node:process';
6
- import { tmpdir } from 'node:os';
7
- import { dirname, join, resolve } from 'node:path';
8
- import { pathToFileURL } from 'node:url';
9
- import { isMainThread, parentPort } from 'node:worker_threads';
1
+ import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { createServer, createConnection } from "node:net";
4
+ import { spawn } from "node:child_process";
5
+ import { createRequire } from "node:module";
6
+ import process from "node:process";
7
+ import { tmpdir } from "node:os";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+ import { isMainThread } from "node:worker_threads";
10
11
 
11
- const TAG = 'skgo-prerender-v1';
12
12
  const owners = new Map();
13
- const requestWaiters = new Map();
14
- const ownerByRoot = new Map();
15
- let parentPortListening = false;
16
-
13
+ const SERVICE_URL = "SKGO_PRERENDER_SERVICE_URL";
14
+ const SERVICE_SECRET = "SKGO_PRERENDER_SERVICE_SECRET";
15
+ const CALLBACK_TIMEOUT = 30_000;
16
+ const STARTUP_TIMEOUT = 60_000;
17
+ const SHUTDOWN_GRACE = 150;
18
+ const CLEANUP_TIMEOUT = 5_000;
17
19
  function appRoot() {
18
- return resolve(process.cwd());
20
+ return resolve(process.cwd());
21
+ }
22
+ function reportOwnerFailure(error) {
23
+ const cleanup = error?.cleanupError ? `; cleanup failed: ${error.cleanupError.message}` : "";
24
+ process.stderr.write(`skgo prerender owner failure: ${error?.message ?? error}${cleanup}\n`);
19
25
  }
20
-
21
26
  function kitRootFor(root) {
22
- return realpathSync(join(root, 'node_modules/@sveltejs/kit'));
27
+ return realpathSync(join(root, "node_modules/@sveltejs/kit"));
23
28
  }
24
29
 
25
30
  function kitQueueFailure(detail) {
26
- const error = makeError(`SKGO_KIT_PRERENDER_QUEUE: ${detail}. Run "go tool skgo kit-patch --web web --apply" from the Go app root, then "cd web && node_modules/.bin/vp install --no-frozen-lockfile", then "go tool skgo kit-patch --web web --check".`);
27
- error.name = 'SKGO_KIT_PRERENDER_QUEUE';
28
- error.code = 'SKGO_KIT_PRERENDER_QUEUE';
29
- return error;
31
+ const error = new Error(
32
+ `SKGO_KIT_PRERENDER_QUEUE: ${detail}. Run "go tool skgo kit-patch --web web --apply" from the Go app root, then "cd web && node_modules/.bin/vp install --no-frozen-lockfile", then "go tool skgo kit-patch --web web --check".`,
33
+ );
34
+ error.name = "SKGO_KIT_PRERENDER_QUEUE";
35
+ error.code = "SKGO_KIT_PRERENDER_QUEUE";
36
+ return error;
30
37
  }
31
38
 
32
39
  function verifyKitQueue(root) {
33
- let metadata;
34
- try {
35
- const appRequire = createRequire(join(root, 'package.json'));
36
- const adapterPackage = appRequire.resolve('@skgo/sveltekit-adapter/package.json');
37
- metadata = JSON.parse(readFileSync(join(dirname(adapterPackage), 'skgo-adapter/compat/kit-3.0.0-queue.json'), 'utf8'));
38
- } catch (error) {
39
- throw kitQueueFailure(`adapter compatibility metadata could not be read: ${error.message}`);
40
- }
41
- try {
42
- const kitRoot = kitRootFor(root);
43
- const pkg = JSON.parse(readFileSync(join(kitRoot, 'package.json'), 'utf8'));
44
- const queuePath = join(kitRoot, metadata.queuePath);
45
- const queue = readFileSync(queuePath);
46
- const digest = createHash('sha256').update(queue).digest('hex');
47
- if (pkg.name !== metadata.package || pkg.version !== metadata.version || digest !== metadata.correctedSHA256) {
48
- throw kitQueueFailure(`resolved ${pkg.name ?? 'unknown'}@${pkg.version ?? 'unknown'} queue SHA-256 ${digest}; expected ${metadata.package}@${metadata.version} with SHA-256 ${metadata.correctedSHA256}`);
49
- }
50
- } catch (error) {
51
- if (error?.code === 'SKGO_KIT_PRERENDER_QUEUE') throw error;
52
- throw kitQueueFailure(`resolved Kit queue could not be verified: ${error.message}`);
53
- }
54
- }
55
-
56
- function generatedConfig(root) {
57
- const manifest = JSON.parse(readFileSync(join(root, 'skgo.remotes.json'), 'utf8'));
58
- if (!manifest.prerender?.root || !manifest.prerender?.package) {
59
- throw new Error('skgo: generated prerender command is missing');
60
- }
61
- return {
62
- cwd: resolve(root, manifest.prerender.root),
63
- pkg: manifest.prerender.package
64
- };
40
+ let metadata;
41
+ try {
42
+ const appRequire = createRequire(join(root, "package.json"));
43
+ const adapterPackage = appRequire.resolve("@skgo/sveltekit-adapter/package.json");
44
+ metadata = JSON.parse(
45
+ readFileSync(
46
+ join(dirname(adapterPackage), "skgo-adapter/compat/kit-3.0.0-queue.json"),
47
+ "utf8",
48
+ ),
49
+ );
50
+ } catch (error) {
51
+ throw kitQueueFailure(`adapter compatibility metadata could not be read: ${error.message}`);
52
+ }
53
+ try {
54
+ const kitRoot = kitRootFor(root);
55
+ const pkg = JSON.parse(readFileSync(join(kitRoot, "package.json"), "utf8"));
56
+ const queuePath = join(kitRoot, metadata.queuePath);
57
+ const queue = readFileSync(queuePath);
58
+ const digest = createHash("sha256").update(queue).digest("hex");
59
+ if (
60
+ pkg.name !== metadata.package ||
61
+ pkg.version !== metadata.version ||
62
+ digest !== metadata.correctedSHA256
63
+ ) {
64
+ throw kitQueueFailure(
65
+ `resolved ${pkg.name ?? "unknown"}@${pkg.version ?? "unknown"} queue SHA-256 ${digest}; expected ${metadata.package}@${metadata.version} with SHA-256 ${metadata.correctedSHA256}`,
66
+ );
67
+ }
68
+ } catch (error) {
69
+ if (error?.code === "SKGO_KIT_PRERENDER_QUEUE") throw error;
70
+ throw kitQueueFailure(`resolved Kit queue could not be verified: ${error.message}`);
71
+ }
65
72
  }
66
73
 
67
74
  function vitePlusBuildParent(root) {
68
- if (!isMainThread || process.env.NODE_PACKAGE_MANAGER !== 'vite-plus' || process.argv[2] !== 'build' || !process.argv[1]) return null;
69
- try {
70
- const appRequire = createRequire(join(root, 'package.json'));
71
- const vpRequire = createRequire(appRequire.resolve('vite-plus/package.json'));
72
- const expected = realpathSync(join(dirname(vpRequire.resolve('vite')), 'cli.js'));
73
- return realpathSync(process.argv[1]) === expected && process.ppid > 1 ? process.ppid : null;
74
- } catch {
75
- return null;
76
- }
77
- }
78
-
79
- function makeError(message) {
80
- const error = new Error(message);
81
- return error;
82
- }
83
-
84
- function reportOwnerFailure(error) {
85
- const primary = error?.message ?? String(error);
86
- const cleanup = error?.cleanupError ? `; cleanup failed: ${error.cleanupError.message ?? error.cleanupError}` : '';
87
- try { process.stderr.write(`skgo prerender owner failure: ${primary}${cleanup}\n`); } catch {}
75
+ if (
76
+ !isMainThread ||
77
+ process.env.NODE_PACKAGE_MANAGER !== "vite-plus" ||
78
+ process.argv[2] !== "build" ||
79
+ !process.argv[1]
80
+ )
81
+ return null;
82
+ try {
83
+ const appRequire = createRequire(join(root, "package.json"));
84
+ const vpRequire = createRequire(appRequire.resolve("vite-plus/package.json"));
85
+ const expected = realpathSync(join(dirname(vpRequire.resolve("vite")), "cli.js"));
86
+ return realpathSync(process.argv[1]) === expected && process.ppid > 1 ? process.ppid : null;
87
+ } catch {
88
+ return null;
89
+ }
88
90
  }
89
91
 
90
92
  function validateEnvelope(request, raw) {
91
- let value;
92
- try { value = JSON.parse(raw); }
93
- catch (error) { throw makeError(`skgo prerender command returned malformed JSON: ${error.message}`); }
94
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw makeError('skgo prerender command returned a non-object response');
95
- if (request.kind === 'remote-inputs') {
96
- if (typeof value.inputs !== 'string') throw makeError('skgo prerender inputs response has no devalue payload');
97
- } else if (request.kind === 'remote') {
98
- if (value.type === 'result' && typeof value.data === 'string') return;
99
- if (value.type === 'error' && value.kind === 'app' && value.error && Number.isInteger(value.error.status) && typeof value.error.message === 'string') return;
100
- if (value.type === 'error' && value.kind === 'unknown' && value.error && Number.isInteger(value.error.status) && typeof value.error.message === 'string' && typeof value.diagnostic === 'string') return;
101
- if (value.type === 'redirect' && Number.isInteger(value.redirect?.status) && value.redirect.status >= 300 && value.redirect.status <= 308 && typeof value.redirect.location === 'string') return;
102
- throw makeError('skgo prerender remote response has an invalid envelope');
103
- } else if (request.kind === 'load') {
104
- const allowed = ['data', 'chunks', 'error', 'failure', 'redirect', 'headers', 'cookies'];
105
- if (!allowed.some((key) => key in value)) throw makeError('skgo prerender load response has no recognized fields');
106
- if (value.data !== undefined && typeof value.data !== 'string' && !(value.data && typeof value.data === 'object')) throw makeError('skgo prerender load response has invalid data');
107
- if (value.failure !== undefined && typeof value.failure !== 'string') throw makeError('skgo prerender load response has invalid failure');
108
- } else {
109
- throw makeError(`skgo prerender owner received unsupported request kind ${request.kind}`);
110
- }
111
- }
112
-
113
- function groupAlive(job) {
114
- if (process.platform === 'win32' || !job.group) return false;
115
- try {
116
- process.kill(-job.group, 0);
117
- return true;
118
- } catch (error) {
119
- if (error?.code === 'ESRCH') return false;
120
- return true;
121
- }
122
- }
123
-
124
- function signalTree(job, signal) {
125
- if (process.platform === 'win32') {
126
- if (!job.pid) return;
127
- try {
128
- const killer = spawn('taskkill', ['/PID', String(job.pid), '/T', '/F'], {
129
- stdio: 'ignore',
130
- windowsHide: true
131
- });
132
- job.killers.add(killer);
133
- killer.once('close', () => job.killers.delete(killer));
134
- killer.once('error', () => job.killers.delete(killer));
135
- } catch {
136
- // The direct child is still retained and will be awaited below.
137
- }
138
- return;
139
- }
140
- if (!job.group) return;
141
- try {
142
- process.kill(-job.group, signal);
143
- } catch (error) {
144
- if (error?.code !== 'ESRCH') throw error;
145
- }
146
- }
147
-
148
- async function stopGroup(job, graceMs = 150) {
149
- if (process.platform === 'win32') {
150
- signalTree(job, 'SIGKILL');
151
- await Promise.allSettled([...job.killers].map((killer) => new Promise((resolve) => killer.once('close', resolve))));
152
- return;
153
- }
154
- if (!groupAlive(job)) return;
155
- signalTree(job, 'SIGTERM');
156
- await new Promise((resolve) => setTimeout(resolve, graceMs));
157
- if (groupAlive(job)) signalTree(job, 'SIGKILL');
158
- const deadline = Date.now() + 5000;
159
- while (groupAlive(job) && Date.now() < deadline) {
160
- await new Promise((resolve) => setTimeout(resolve, 20));
161
- }
162
- if (groupAlive(job)) throw makeError(`skgo could not drain process group ${job.group}`);
163
- }
164
-
165
- function beginTermination(job) {
166
- try { signalTree(job, 'SIGTERM'); } catch {}
167
- if (process.platform === 'win32') return Promise.resolve();
168
- return new Promise((resolve) => setTimeout(() => {
169
- if (groupAlive(job)) {
170
- try { signalTree(job, 'SIGKILL'); } catch {}
171
- }
172
- resolve();
173
- }, 150));
174
- }
175
-
176
- function startDrain(job) {
177
- if (!job.drainPromise) job.drainPromise = beginTermination(job);
178
- return job.drainPromise;
179
- }
180
-
181
- function createJob(session, command, args, options = {}) {
182
- const detached = process.platform !== 'win32';
183
- const child = spawn(command, args, {
184
- cwd: options.cwd,
185
- env: options.env ?? process.env,
186
- stdio: options.input === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
187
- detached,
188
- windowsHide: true
189
- });
190
- const job = {
191
- child,
192
- pid: child.pid,
193
- group: detached ? child.pid : null,
194
- killers: new Set(),
195
- stdout: '',
196
- stderr: '',
197
- closed: false,
198
- settled: false,
199
- closePromise: null,
200
- resolveClose: null,
201
- drainPromise: null,
202
- done: null,
203
- resolve: null,
204
- reject: null
205
- };
206
- session.jobs.add(job);
207
- session.groups.add(job);
208
- job.done = new Promise((resolve, reject) => {
209
- job.resolve = resolve;
210
- job.reject = reject;
211
- });
212
- job.closePromise = new Promise((resolve) => { job.resolveClose = resolve; });
213
- job.done.catch(() => {});
214
- const settle = (error, value) => {
215
- if (job.settled) return;
216
- job.settled = true;
217
- if (error) job.reject(error);
218
- else job.resolve(value);
219
- };
220
- const pipeError = (label) => (error) => {
221
- settle(makeError(`skgo prerender ${label} pipe failed: ${error.message}`));
222
- signalTree(job, 'SIGTERM');
223
- };
224
- child.on('error', (error) => {
225
- settle(makeError(`skgo prerender process failed to start: ${error.message}`));
226
- });
227
- // Do not wait for inherited stdout/stderr pipes to close before starting
228
- // descendant termination: a child which inherited either pipe can hold the
229
- // leader's `close` event open after the Go process itself has exited.
230
- child.on('exit', () => { void startDrain(job); });
231
- child.stdout.setEncoding('utf8');
232
- child.stderr.setEncoding('utf8');
233
- child.stdout.on('data', (chunk) => { job.stdout += chunk; });
234
- child.stderr.on('data', (chunk) => { job.stderr += chunk; });
235
- child.stdin?.on('error', pipeError('stdin'));
236
- child.stdout.on('error', pipeError('stdout'));
237
- child.stderr.on('error', pipeError('stderr'));
238
- child.stdin?.on('close', () => {
239
- if (options.input !== undefined && !job.closed && !child.stdin.writableFinished) {
240
- settle(makeError('skgo prerender process closed stdin before accepting its request'));
241
- signalTree(job, 'SIGTERM');
242
- }
243
- });
244
- child.on('close', async (code, signal) => {
245
- job.closed = true;
246
- const exit = { code, signal, stdout: job.stdout, stderr: job.stderr };
247
- let treeError;
248
- // A successful leader can leave compiler or application descendants in
249
- // its process group. Drain them before any request or outer build settles.
250
- await startDrain(job);
251
- await stopGroup(job).catch((error) => { treeError = error; });
252
- session.jobs.delete(job);
253
- if (!treeError) session.groups.delete(job);
254
- job.resolveClose();
255
- if (treeError) {
256
- settle(treeError);
257
- } else if (!job.settled) {
258
- if (code !== 0) {
259
- settle(makeError(`skgo prerender command exited ${code ?? signal}: ${job.stderr.trim()}`));
260
- } else if (options.request) {
261
- try {
262
- validateEnvelope(options.request, job.stdout);
263
- settle(null, exit);
264
- } catch (error) {
265
- settle(makeError(`${error.message}; ${job.stderr.trim()}`));
266
- }
267
- } else {
268
- settle(null, exit);
269
- }
270
- }
271
- });
272
- if (options.input !== undefined) {
273
- // All child and pipe listeners exist before writing the request.
274
- child.stdin.end(options.input);
275
- }
276
- return job;
277
- }
278
-
279
- function makeSession(root, ownerParentPid) {
280
- const session = {
281
- id: randomUUID(),
282
- root,
283
- cwd: null,
284
- pkg: null,
285
- dir: null,
286
- binary: null,
287
- compilePromise: null,
288
- jobs: new Set(),
289
- groups: new Set(),
290
- workers: new Map(),
291
- observedWorkers: new Map(),
292
- closing: false,
293
- failed: null,
294
- cleanupPromise: null,
295
- ownerParentPid,
296
- parentObserver: null,
297
- parentLossStarted: false,
298
- signalHandlers: new Map(),
299
- watchParent() {
300
- if (session.ownerParentPid === null || session.parentObserver) return;
301
- session.parentObserver = setInterval(() => {
302
- if (process.ppid === session.ownerParentPid && process.ppid > 1) return;
303
- clearInterval(session.parentObserver);
304
- session.parentObserver = null;
305
- if (session.parentLossStarted) return;
306
- session.parentLossStarted = true;
307
- void (async () => {
308
- try {
309
- await session.fail(makeError('skgo prerender Vite build parent exited'));
310
- for (const [signal, handler] of session.signalHandlers) process.off(signal, handler);
311
- session.signalHandlers.clear();
312
- process.exitCode = 1;
313
- try { process.kill(process.pid, 'SIGTERM'); }
314
- catch { process.exit(1); }
315
- } catch (error) {
316
- process.exitCode = 1;
317
- reportOwnerFailure(error);
318
- }
319
- })();
320
- }, 50);
321
- session.parentObserver.unref();
322
- },
323
- fail(error) {
324
- if (!session.failed) session.failed = error;
325
- session.closing = true;
326
- return session.cleanup().catch((cleanupError) => {
327
- session.cleanupFailure = cleanupError;
328
- if (session.failed && typeof session.failed === 'object') session.failed.cleanupError = cleanupError;
329
- throw session.failed ?? cleanupError;
330
- });
331
- },
332
- async ensureCompiled() {
333
- if (session.closing) throw session.failed ?? makeError('skgo prerender owner is closed');
334
- if (!session.compilePromise) {
335
- const config = generatedConfig(root);
336
- session.cwd = config.cwd;
337
- session.pkg = config.pkg;
338
- session.dir = mkdtempSync(join(tmpdir(), 'skgo-prerender-'));
339
- session.binary = join(session.dir, process.platform === 'win32' ? 'build.exe' : 'build');
340
- const onExit = () => {
341
- for (const job of session.groups) signalTree(job, 'SIGKILL');
342
- try { rmSync(session.dir, { recursive: true, force: true }); } catch {}
343
- };
344
- process.once('exit', onExit);
345
- session.exitHandler = onExit;
346
- for (const signal of ['SIGINT', 'SIGTERM']) {
347
- const handler = async () => {
348
- try {
349
- await session.fail(makeError(`skgo prerender build interrupted by ${signal}`));
350
- process.off(signal, handler);
351
- try { process.kill(process.pid, signal); } catch { process.exitCode = 1; }
352
- } catch (error) {
353
- process.exitCode = 1;
354
- reportOwnerFailure(error);
355
- process.once(signal, handler);
356
- }
357
- };
358
- session.signalHandlers.set(signal, handler);
359
- process.once(signal, handler);
360
- }
361
- mkdirSync(join(session.dir, 'go-tmp'), { recursive: true });
362
- session.compilePromise = (async () => {
363
- try {
364
- const job = createJob(session, 'go', ['build', '-o', session.binary, session.pkg], {
365
- cwd: session.cwd,
366
- env: { ...process.env, GOTMPDIR: join(session.dir, 'go-tmp') }
367
- });
368
- const result = await job.done;
369
- if (result.code !== 0) throw makeError(`skgo prerender compiler failed: ${result.stderr.trim()}`);
370
- } catch (error) {
371
- session.failed ??= error;
372
- session.closing = true;
373
- await session.cleanup();
374
- throw error;
375
- }
376
- })();
377
- }
378
- await session.compilePromise;
379
- if (session.closing) throw session.failed ?? makeError('skgo prerender owner is closed');
380
- },
381
- async invoke(request) {
382
- await session.ensureCompiled();
383
- if (session.closing) throw session.failed ?? makeError('skgo prerender owner is closed');
384
- const job = createJob(session, session.binary, [], {
385
- cwd: session.cwd,
386
- input: JSON.stringify(request),
387
- request
388
- });
389
- try {
390
- const result = await job.done;
391
- if (session.closing) throw session.failed ?? makeError('skgo prerender owner is closed');
392
- return result.stdout;
393
- } catch (error) {
394
- await session.fail(error);
395
- throw error;
396
- }
397
- },
398
- async cleanup() {
399
- if (session.cleanupPromise) return session.cleanupPromise;
400
- session.closing = true;
401
- const cleanupPromise = (async () => {
402
- const jobs = [...session.jobs];
403
- const escalations = jobs.map(startDrain);
404
- // Start TERM-to-KILL escalation before waiting for children to close.
405
- await Promise.allSettled([...jobs.map((job) => job.closePromise), ...escalations]);
406
- const drainErrors = [];
407
- for (const job of [...session.groups]) {
408
- try {
409
- await stopGroup(job);
410
- session.groups.delete(job);
411
- } catch (error) {
412
- drainErrors.push(error);
413
- }
414
- }
415
- if (drainErrors.length) {
416
- const error = new AggregateError(drainErrors, 'skgo could not drain every owned prerender process tree');
417
- session.cleanupFailure = error;
418
- if (session.failed && typeof session.failed === 'object') session.failed.cleanupError = error;
419
- throw error;
420
- }
421
- for (const workerRecord of session.workers.values()) {
422
- for (const requestId of workerRecord.handshakes) {
423
- try { workerRecord.worker.postMessage({ tag: TAG, type: 'failed', appRoot: root, session: session.id, requestId, error: session.failed?.message ?? 'owner closed' }); } catch {}
424
- }
425
- workerRecord.handshakes.clear();
426
- for (const requestId of workerRecord.pending.keys()) {
427
- try { workerRecord.worker.postMessage({ tag: TAG, type: 'failed', appRoot: root, session: session.id, requestId, error: session.failed?.message ?? 'owner closed' }); } catch {}
428
- }
429
- workerRecord.pending.clear();
430
- workerRecord.worker.off('message', workerRecord.onMessage);
431
- workerRecord.worker.off('error', workerRecord.onError);
432
- workerRecord.worker.off('exit', workerRecord.onExit);
433
- }
434
- for (const [worker, listener] of session.observedWorkers) worker.off('message', listener);
435
- session.observedWorkers.clear();
436
- session.workers.clear();
437
- if (session.dir) rmSync(session.dir, { recursive: true, force: true });
438
- if (session.exitHandler) process.off('exit', session.exitHandler);
439
- if (session.parentObserver) clearInterval(session.parentObserver);
440
- session.parentObserver = null;
441
- for (const [signal, handler] of session.signalHandlers) process.off(signal, handler);
442
- process.off('worker', session.onWorker);
443
- session.jobs.clear();
444
- session.groups.clear();
445
- ownerByRoot.delete(root);
446
- })();
447
- session.cleanupPromise = cleanupPromise;
448
- try {
449
- await cleanupPromise;
450
- } catch (error) {
451
- // Retain listeners, directories and failed group records so later
452
- // cleanup attempts can retry rather than forgetting live ownership.
453
- session.cleanupPromise = null;
454
- throw error;
455
- }
456
- }
457
- };
458
- return session;
459
- }
460
-
461
- function response(worker, message, fields) {
462
- try { worker.postMessage({ tag: TAG, appRoot: message.appRoot, session: message.session, requestId: message.requestId, ...fields }); } catch {}
463
- }
464
-
465
- async function connectWorker(session, worker, message) {
466
- if (session.closing) {
467
- await session.cleanup();
468
- response(worker, message, { type: 'failed', error: session.failed?.message ?? 'owner is closed' });
469
- return;
470
- }
471
- let record = session.workers.get(worker);
472
- if (!record) {
473
- record = { worker, pending: new Map(), handshakes: new Set(), exited: false };
474
- session.workers.set(worker, record);
475
- record.onMessage = (next) => {
476
- if (!next || next.tag !== TAG || next.appRoot !== session.root || next.session !== session.id) return;
477
- if (next.type === 'connected-ack') {
478
- record.handshakes.delete(next.requestId);
479
- return;
480
- }
481
- void handleWorkerRequest(session, record, next).catch(reportOwnerFailure);
482
- };
483
- record.onError = (error) => { void session.fail(error).catch(reportOwnerFailure); };
484
- record.onExit = (code) => {
485
- record.exited = true;
486
- if (record.pending.size > 0 || record.handshakes.size > 0 || code !== 0) {
487
- session.closing = true;
488
- void session.fail(makeError(`Kit prerender worker exited ${code} with ${record.pending.size} pending Go requests and ${record.handshakes.size} pending handshakes`)).catch(reportOwnerFailure);
489
- }
490
- };
491
- worker.on('message', record.onMessage);
492
- worker.on('error', record.onError);
493
- worker.on('exit', record.onExit);
494
- }
495
- record.handshakes.add(message.requestId);
496
- try { worker.postMessage({ tag: TAG, type: 'connected', appRoot: session.root, session: session.id, requestId: message.requestId }); }
497
- catch (error) {
498
- record.handshakes.delete(message.requestId);
499
- await session.fail(error);
500
- }
501
- }
502
-
503
- async function handleWorkerRequest(session, record, message) {
504
- if (record.exited) return;
505
- if (session.closing) {
506
- await session.cleanup();
507
- response(record.worker, message, { type: 'failed', error: session.failed?.message ?? 'owner is closed' });
508
- return;
509
- }
510
- if (message.type === 'fatal') {
511
- record.pending.set(message.requestId, { worker: record.worker, requestId: message.requestId });
512
- await session.fail(makeError(`Kit prerender worker reported a fatal protocol error: ${message.error ?? 'unknown error'}`));
513
- return;
514
- }
515
- if (message.type !== 'invoke') {
516
- response(record.worker, message, { type: 'failed', error: session.failed?.message ?? 'owner is closed' });
517
- return;
518
- }
519
- const pending = { worker: record.worker, requestId: message.requestId };
520
- record.pending.set(message.requestId, pending);
521
- try {
522
- const raw = await session.invoke(message.request);
523
- if (record.exited || session.closing) throw session.failed ?? makeError('owner closed while request was running');
524
- record.pending.delete(message.requestId);
525
- response(record.worker, message, { type: 'result', raw });
526
- } catch (error) {
527
- await session.fail(error);
528
- record.pending.delete(message.requestId);
529
- response(record.worker, message, { type: 'failed', error: error.message });
530
- }
531
- }
532
-
533
- /** Create the process owner from the main Vite build thread. */
93
+ let value;
94
+ try {
95
+ value = JSON.parse(raw);
96
+ } catch (error) {
97
+ throw new Error(`skgo prerender command returned malformed JSON: ${error.message}`);
98
+ }
99
+ if (!value || typeof value !== "object" || Array.isArray(value))
100
+ throw new Error("skgo prerender command returned a non-object response");
101
+ if (request.kind === "remote-inputs") {
102
+ if (typeof value.inputs !== "string")
103
+ throw new Error("skgo prerender inputs response has no devalue payload");
104
+ } else if (request.kind === "remote") {
105
+ if (value.type === "result" && typeof value.data === "string") return;
106
+ if (
107
+ value.type === "error" &&
108
+ value.kind === "app" &&
109
+ value.error &&
110
+ Number.isInteger(value.error.status) &&
111
+ typeof value.error.message === "string"
112
+ )
113
+ return;
114
+ if (
115
+ value.type === "error" &&
116
+ value.kind === "unknown" &&
117
+ value.error &&
118
+ Number.isInteger(value.error.status) &&
119
+ typeof value.error.message === "string" &&
120
+ typeof value.diagnostic === "string"
121
+ )
122
+ return;
123
+ if (
124
+ value.type === "redirect" &&
125
+ Number.isInteger(value.redirect?.status) &&
126
+ value.redirect.status >= 300 &&
127
+ value.redirect.status <= 308 &&
128
+ typeof value.redirect.location === "string"
129
+ )
130
+ return;
131
+ throw new Error("skgo prerender remote response has an invalid envelope");
132
+ } else if (request.kind === "load") {
133
+ const allowed = ["data", "chunks", "error", "failure", "redirect", "headers", "cookies"];
134
+ if (!allowed.some((key) => key in value))
135
+ throw new Error("skgo prerender load response has no recognized fields");
136
+ if (
137
+ value.data !== undefined &&
138
+ typeof value.data !== "string" &&
139
+ !(value.data && typeof value.data === "object")
140
+ )
141
+ throw new Error("skgo prerender load response has invalid data");
142
+ if (value.failure !== undefined && typeof value.failure !== "string")
143
+ throw new Error("skgo prerender load response has invalid failure");
144
+ } else {
145
+ throw new Error(`skgo prerender owner received unsupported request kind ${request.kind}`);
146
+ }
147
+ }
148
+
149
+ function alive(job) {
150
+ if (!job.child.pid) return false;
151
+ try {
152
+ process.kill(process.platform === "win32" ? job.child.pid : -job.child.pid, 0);
153
+ return true;
154
+ } catch (error) {
155
+ if (error.code === "ESRCH") return false;
156
+ // EPERM is an existence result for signal 0; actual termination below still
157
+ // reports permission failures rather than mistaking this probe for a drain.
158
+ if (error.code === "EPERM") return true;
159
+ error.message = `skgo process group ${job.child.pid} probe failed: ${error.message}`;
160
+ throw error;
161
+ }
162
+ }
163
+ async function signalTree(job, force) {
164
+ if (!job.child.pid) return;
165
+ if (process.platform === "win32") {
166
+ await bounded(
167
+ new Promise((resolve, reject) => {
168
+ const args = ["/PID", String(job.child.pid), "/T", ...(force ? ["/F"] : [])];
169
+ const killer = spawn("taskkill", args, { stdio: "ignore", windowsHide: true });
170
+ killer.once("error", reject);
171
+ killer.once("close", resolve);
172
+ }),
173
+ CLEANUP_TIMEOUT,
174
+ "skgo prerender taskkill did not finish",
175
+ );
176
+ } else {
177
+ try {
178
+ process.kill(-job.child.pid, force ? "SIGKILL" : "SIGTERM");
179
+ } catch (error) {
180
+ if (error.code === "ESRCH") return;
181
+ // Darwin can reject signals before Node observes an exited child.
182
+ // Retain the error; only bounded reaping and group absence prove cleanup.
183
+ if (error.code === "EPERM") job.signalError ??= error;
184
+ else throw error;
185
+ }
186
+ }
187
+ }
188
+ function delay(ms) {
189
+ return new Promise((resolve) => setTimeout(resolve, ms));
190
+ }
191
+ async function bounded(promise, ms, message) {
192
+ let timer;
193
+ try {
194
+ return await Promise.race([
195
+ promise,
196
+ new Promise((_, reject) => {
197
+ timer = setTimeout(() => reject(new Error(message)), ms);
198
+ }),
199
+ ]);
200
+ } finally {
201
+ clearTimeout(timer);
202
+ }
203
+ }
204
+ async function stopJob(job, cooperative = false) {
205
+ if (cooperative) {
206
+ job.control?.end();
207
+ }
208
+ if (cooperative || job.hasExited) {
209
+ // Join the child's close before probing its just-exited process group.
210
+ // Descendants holding inherited output pipes still hit the same fixed grace.
211
+ await bounded(job.closed, SHUTDOWN_GRACE, "shutdown grace elapsed").catch((error) => {
212
+ if (error.message !== "shutdown grace elapsed") throw error;
213
+ });
214
+ }
215
+ if (alive(job)) {
216
+ await signalTree(job, false);
217
+ await delay(150);
218
+ if (alive(job)) await signalTree(job, true);
219
+ }
220
+ await bounded(
221
+ job.closed,
222
+ CLEANUP_TIMEOUT,
223
+ `skgo prerender owned process did not close${job.signalError ? ": " + job.signalError.message : ""}`,
224
+ );
225
+ const deadline = Date.now() + CLEANUP_TIMEOUT;
226
+ while (alive(job) && Date.now() < deadline) await delay(20);
227
+ if (alive(job))
228
+ throw new Error(`skgo could not drain process group ${job.child.pid}`, {
229
+ cause: job.signalError,
230
+ });
231
+ }
232
+ // Node's generated stdin pipe is write-only at the parent. On Windows, pass
233
+ // a connected duplex pipe as stdin so Go receives its native inherited handle.
234
+ async function windowsControl() {
235
+ const listener = createServer();
236
+ const path = `\\\\.\\pipe\\skgo-prerender-${randomUUID()}`;
237
+ let childEnd, parentEnd;
238
+ const accepted = new Promise((resolve, reject) => {
239
+ listener.once("connection", (socket) => {
240
+ parentEnd = socket;
241
+ resolve(socket);
242
+ });
243
+ listener.once("error", reject);
244
+ });
245
+ accepted.catch(() => {});
246
+ try {
247
+ await bounded(
248
+ new Promise((resolve, reject) => {
249
+ listener.once("error", reject);
250
+ listener.listen(path, resolve);
251
+ }),
252
+ STARTUP_TIMEOUT,
253
+ "skgo prerender control pipe startup timed out",
254
+ );
255
+ childEnd = createConnection(path);
256
+ const connected = new Promise((resolve, reject) => {
257
+ childEnd.once("connect", resolve);
258
+ childEnd.once("error", reject);
259
+ });
260
+ await bounded(
261
+ Promise.all([accepted, connected]),
262
+ STARTUP_TIMEOUT,
263
+ "skgo prerender control pipe startup timed out",
264
+ );
265
+ listener.close();
266
+ return { childEnd, parentEnd };
267
+ } catch (error) {
268
+ childEnd?.destroy();
269
+ parentEnd?.destroy();
270
+ listener.close();
271
+ throw error;
272
+ }
273
+ }
274
+
275
+ function startJob(owner, command, args, options = {}) {
276
+ const child = spawn(command, args, {
277
+ cwd: owner.cwd,
278
+ env: options.env ?? process.env,
279
+ stdio: options.service
280
+ ? options.control
281
+ ? [options.control.childEnd, "pipe", "pipe"]
282
+ : ["ignore", "pipe", "pipe", "pipe"]
283
+ : ["ignore", "pipe", "pipe"],
284
+ detached: process.platform !== "win32",
285
+ windowsHide: true,
286
+ });
287
+ const job = {
288
+ child,
289
+ stderr: "",
290
+ control: options.service ? (options.control?.parentEnd ?? child.stdio[3]) : null,
291
+ };
292
+ options.control?.childEnd.destroy();
293
+ owner.jobs.add(job);
294
+ job.exited = new Promise((resolve, reject) => {
295
+ child.once("error", reject);
296
+ child.once("exit", (code, signal) => {
297
+ job.hasExited = true;
298
+ resolve({ code, signal });
299
+ });
300
+ });
301
+ job.closed = new Promise((resolve) => {
302
+ child.once("close", resolve);
303
+ });
304
+ job.exited.catch(() => {});
305
+ child.stdout.on("data", (chunk) => {
306
+ if (options.service) process.stdout.write(chunk);
307
+ });
308
+ child.stderr.on("data", (chunk) => {
309
+ if (options.service) process.stderr.write(chunk);
310
+ else job.stderr += chunk;
311
+ });
312
+ job.control?.on("error", () => {});
313
+ // Descendants can inherit stdout/stderr and hold close open after the leader exits.
314
+ child.once("exit", () => {
315
+ void signalTree(job, false).catch(reportOwnerFailure);
316
+ });
317
+ return job;
318
+ }
319
+
320
+ /** The main build thread owns its compiler, HTTP service and scratch directory. */
534
321
  export function createPrerenderOwner(root = appRoot()) {
535
- if (!isMainThread) return null;
536
- root = resolve(root);
537
- const existing = ownerByRoot.get(root);
538
- if (existing) return existing;
539
- const session = makeSession(root, vitePlusBuildParent(root));
540
- ownerByRoot.set(root, session);
541
- session.watchParent();
542
- session.onWorker = (worker) => {
543
- const listener = (message) => {
544
- if (!message || message.tag !== TAG || message.type !== 'connect' || message.appRoot !== root) return;
545
- if (message.session !== undefined && message.session !== session.id) return;
546
- void connectWorker(session, worker, message).catch(reportOwnerFailure);
547
- };
548
- session.observedWorkers.set(worker, listener);
549
- worker.on('message', listener);
550
- };
551
- process.on('worker', session.onWorker);
552
- return session;
553
- }
554
-
555
- /** Wrap Vite's whole native app-build boundary so its caller cannot reject before Go drains. */
322
+ if (!isMainThread) return null;
323
+ root = resolve(root);
324
+ if (owners.has(root)) return owners.get(root);
325
+ const manifest = JSON.parse(readFileSync(join(root, "skgo.remotes.json"), "utf8"));
326
+ // The generator emits this entry only when loads or prerender remotes exist.
327
+ if (!manifest.prerender) return null;
328
+ const owner = {
329
+ root,
330
+ cwd: resolve(root, manifest.prerender.root),
331
+ pkg: manifest.prerender.package,
332
+ jobs: new Set(),
333
+ closing: false,
334
+ failed: null,
335
+ dir: null,
336
+ service: null,
337
+ previousEnv: null,
338
+ signals: new Map(),
339
+ cleanupPromise: null,
340
+ preparePromise: null,
341
+ fail(error) {
342
+ owner.failed ??= error;
343
+ return owner.cleanup().catch((cleanupError) => {
344
+ owner.cleanupFailure = cleanupError;
345
+ if (typeof owner.failed === "object" && owner.failed !== cleanupError) {
346
+ if (!owner.failed.cleanupError) {
347
+ const detail =
348
+ cleanupError.errors?.map((error) => error.message).join("; ") ?? cleanupError.message;
349
+ const diagnostic = `cleanup failed: ${detail}`;
350
+ owner.failed.message += `; ${diagnostic}`;
351
+ if (owner.failed.stack) owner.failed.stack += `\n${diagnostic}`;
352
+ }
353
+ owner.failed.cleanupError = cleanupError;
354
+ }
355
+ throw owner.failed;
356
+ });
357
+ },
358
+ prepare() {
359
+ return (owner.preparePromise ??= (async () => {
360
+ owner.dir = mkdtempSync(join(tmpdir(), "skgo-prerender-"));
361
+ mkdirSync(join(owner.dir, "go-tmp"));
362
+ const binary = join(owner.dir, process.platform === "win32" ? "build.exe" : "build");
363
+ try {
364
+ const compiler = startJob(owner, "go", ["build", "-o", binary, owner.pkg], {
365
+ env: { ...process.env, GOTMPDIR: join(owner.dir, "go-tmp") },
366
+ });
367
+ const result = await compiler.exited;
368
+ await stopJob(compiler);
369
+ owner.jobs.delete(compiler);
370
+ if (result.code !== 0)
371
+ throw new Error(`skgo prerender compiler failed: ${compiler.stderr.trim()}`);
372
+ if (owner.closing) throw owner.failed ?? new Error("skgo prerender owner closed");
373
+ const control = process.platform === "win32" ? await windowsControl() : null;
374
+ if (owner.closing) {
375
+ control?.childEnd.destroy();
376
+ control?.parentEnd.destroy();
377
+ throw owner.failed ?? new Error("skgo prerender owner closed");
378
+ }
379
+ let service;
380
+ try {
381
+ service = owner.service = startJob(owner, binary, [], { service: true, control });
382
+ } catch (error) {
383
+ control?.childEnd.destroy();
384
+ control?.parentEnd.destroy();
385
+ throw error;
386
+ }
387
+ const ready = new Promise((resolve, reject) => {
388
+ let data = "";
389
+ const receive = (chunk) => {
390
+ data += chunk;
391
+ if (!data.includes("\n")) return;
392
+ service.control.off("data", receive);
393
+ try {
394
+ const value = JSON.parse(data.slice(0, data.indexOf("\n")));
395
+ const url = new URL(value.url);
396
+ if (
397
+ url.protocol !== "http:" ||
398
+ url.hostname !== "127.0.0.1" ||
399
+ !url.port ||
400
+ typeof value.secret !== "string" ||
401
+ !value.secret
402
+ )
403
+ throw new Error("invalid readiness");
404
+ resolve(value);
405
+ } catch {
406
+ reject(new Error("skgo prerender service returned invalid readiness"));
407
+ }
408
+ };
409
+ service.control.setEncoding("utf8");
410
+ service.control.on("data", receive);
411
+ service.control.once("error", () =>
412
+ reject(new Error("skgo prerender service control channel failed")),
413
+ );
414
+ service.exited.then(
415
+ ({ code, signal }) =>
416
+ reject(
417
+ new Error(`skgo prerender service exited before readiness: ${code ?? signal}`),
418
+ ),
419
+ reject,
420
+ );
421
+ });
422
+ owner.connection = await bounded(
423
+ ready,
424
+ STARTUP_TIMEOUT,
425
+ "skgo prerender service startup timed out",
426
+ );
427
+ process.stderr.write(`skgo prerender service started (pid ${service.child.pid})\n`);
428
+ service.exited.then(
429
+ ({ code, signal }) => {
430
+ if (!owner.closing || code !== 0) {
431
+ const error = new Error(
432
+ `skgo prerender service exited unexpectedly: ${code ?? signal}`,
433
+ );
434
+ owner.failed ??= error;
435
+ if (!owner.closing) void owner.fail(error).catch(reportOwnerFailure);
436
+ }
437
+ },
438
+ (error) => {
439
+ if (!owner.closing) void owner.fail(error).catch(reportOwnerFailure);
440
+ },
441
+ );
442
+ } catch (error) {
443
+ await owner.fail(error);
444
+ throw error;
445
+ }
446
+ })());
447
+ },
448
+ publish() {
449
+ if (owner.closing) throw owner.failed ?? new Error("skgo prerender owner closed");
450
+ if (owner.previousEnv) return;
451
+ owner.previousEnv = new Map([
452
+ [SERVICE_URL, process.env[SERVICE_URL]],
453
+ [SERVICE_SECRET, process.env[SERVICE_SECRET]],
454
+ ]);
455
+ process.env[SERVICE_URL] = owner.connection.url;
456
+ process.env[SERVICE_SECRET] = owner.connection.secret;
457
+ },
458
+ cleanup() {
459
+ if (owner.cleanupPromise) return owner.cleanupPromise;
460
+ owner.closing = true;
461
+ owner.cleanupPromise = (async () => {
462
+ const errors = [];
463
+ for (const job of owner.jobs) {
464
+ try {
465
+ await stopJob(job, job === owner.service);
466
+ } catch (error) {
467
+ errors.push(error);
468
+ } finally {
469
+ job.control?.destroy();
470
+ }
471
+ }
472
+ if (owner.previousEnv)
473
+ for (const [key, value] of owner.previousEnv) {
474
+ if (value === undefined) delete process.env[key];
475
+ else process.env[key] = value;
476
+ }
477
+ for (const [signal, handler] of owner.signals) process.off(signal, handler);
478
+ if (owner.parentObserver) clearInterval(owner.parentObserver);
479
+ process.off("exit", owner.exitHandler);
480
+ if (!errors.length && owner.dir) rmSync(owner.dir, { recursive: true, force: true });
481
+ owners.delete(root);
482
+ if (owner.service && !errors.length)
483
+ process.stderr.write(`skgo prerender service stopped (pid ${owner.service.child.pid})\n`);
484
+ if (errors.length)
485
+ throw new AggregateError(errors, "skgo could not drain owned prerender processes");
486
+ })();
487
+ return owner.cleanupPromise;
488
+ },
489
+ };
490
+ owner.exitHandler = () => {
491
+ for (const job of owner.jobs) {
492
+ if (process.platform !== "win32") {
493
+ try {
494
+ process.kill(-job.child.pid, "SIGKILL");
495
+ } catch {}
496
+ }
497
+ }
498
+ };
499
+ process.once("exit", owner.exitHandler);
500
+ const interrupted = async (error, signal) => {
501
+ try {
502
+ await owner.fail(error);
503
+ } catch (error) {
504
+ reportOwnerFailure(error);
505
+ }
506
+ process.exitCode = 1;
507
+ try {
508
+ process.kill(process.pid, signal);
509
+ } catch {
510
+ process.exit(1);
511
+ }
512
+ };
513
+ for (const signal of ["SIGINT", "SIGTERM"]) {
514
+ const handler = () => {
515
+ void interrupted(new Error(`skgo prerender build interrupted by ${signal}`), signal);
516
+ };
517
+ owner.signals.set(signal, handler);
518
+ process.on(signal, handler);
519
+ }
520
+ const parent = vitePlusBuildParent(root);
521
+ if (parent !== null) {
522
+ owner.parentObserver = setInterval(() => {
523
+ if (process.ppid === parent) return;
524
+ clearInterval(owner.parentObserver);
525
+ void interrupted(new Error("skgo prerender Vite build parent exited"), "SIGTERM");
526
+ }, 50);
527
+ owner.parentObserver.unref();
528
+ }
529
+ owners.set(root, owner);
530
+ return owner;
531
+ }
532
+
533
+ /** Vite 8's native buildApp has no failure-finally and its CLI exits immediately.
534
+ * Wrap only those whole-app hooks so their rejection waits for the owner. */
556
535
  export function installPrerenderFailureBoundary(config, owner) {
557
- if (!owner || typeof config?.getSortedPlugins !== 'function') return;
558
- const boundary = owner.boundary ??= {
559
- seenPlugins: new WeakSet(),
560
- wrappedHandlers: new WeakMap(),
561
- wrappers: new WeakSet()
562
- };
563
- const wrap = (handler) => {
564
- if (boundary.wrappers.has(handler)) return handler;
565
- const existing = boundary.wrappedHandlers.get(handler);
566
- if (existing) return existing;
567
- const wrapped = async function (...args) {
568
- let result;
569
- try {
570
- result = await handler.apply(this, args);
571
- } catch (error) {
572
- try {
573
- await owner.fail(error);
574
- } catch (failure) {
575
- const cleanupFailure = owner.cleanupFailure ?? failure?.cleanupError ?? failure;
576
- if (error && typeof error === 'object') {
577
- try { error.cleanupError = cleanupFailure; }
578
- catch { reportOwnerFailure({ message: 'could not attach prerender cleanup failure to the native build error', cleanupError: cleanupFailure }); }
579
- } else {
580
- reportOwnerFailure({ message: `native build error: ${String(error)}`, cleanupError: cleanupFailure });
581
- }
582
- }
583
- throw error;
584
- }
585
- if (owner.failed) {
586
- try { await owner.cleanup(); }
587
- catch (cleanupError) {
588
- if (typeof owner.failed === 'object') owner.failed.cleanupError = cleanupError;
589
- }
590
- throw owner.failed;
591
- }
592
- return result;
593
- };
594
- boundary.wrappedHandlers.set(handler, wrapped);
595
- boundary.wrappers.add(wrapped);
596
- return wrapped;
597
- };
598
- const setHook = (target, replacement) => {
599
- const descriptor = Object.getOwnPropertyDescriptor(target, 'buildApp') ?? {
600
- configurable: true, enumerable: true, writable: true
601
- };
602
- Object.defineProperty(target, 'buildApp', { ...descriptor, value: replacement });
603
- };
604
- for (const plugin of config.getSortedPlugins('buildApp')) {
605
- if (!plugin || typeof plugin !== 'object' || boundary.seenPlugins.has(plugin)) continue;
606
- boundary.seenPlugins.add(plugin);
607
- const hook = plugin.buildApp;
608
- if (typeof hook === 'function') {
609
- setHook(plugin, wrap(hook));
610
- } else if (hook && typeof hook.handler === 'function') {
611
- const descriptors = Object.getOwnPropertyDescriptors(hook);
612
- descriptors.handler = { ...descriptors.handler, value: wrap(hook.handler) };
613
- setHook(plugin, Object.create(Object.getPrototypeOf(hook), descriptors));
614
- }
615
- }
616
- const builder = config.builder;
617
- if (builder && typeof builder === 'object' && typeof builder.buildApp === 'function') {
618
- setHook(builder, wrap(builder.buildApp));
619
- }
620
- }
621
-
622
- /** Wait for cleanup only when a native failure or signal has begun draining. */
623
- export async function joinFailedPrerenderOwner(owner) {
624
- if (owner?.closing) {
625
- try { await owner.cleanup(); }
626
- catch (cleanupError) {
627
- if (owner.failed && typeof owner.failed === 'object') owner.failed.cleanupError = cleanupError;
628
- throw owner.failed ?? cleanupError;
629
- }
630
- }
631
- }
632
-
633
- function receiveWorkerMessage(message) {
634
- if (!message || message.tag !== TAG || !message.requestId) return;
635
- const waiter = requestWaiters.get(message.requestId);
636
- if (!waiter) return;
637
- if (message.type === 'connected') waiter.resolve(message.session);
638
- else if (message.type === 'result') waiter.resolve(message.raw);
639
- else if (message.type === 'failed') waiter.reject(makeError(message.error ?? 'prerender owner failed'));
640
- else return;
641
- requestWaiters.delete(message.requestId);
642
- }
643
-
644
- function ensureWorkerListener() {
645
- if (parentPortListening || !parentPort) return;
646
- parentPortListening = true;
647
- parentPort.on('message', receiveWorkerMessage);
648
- }
649
-
650
- function workerRequest(message) {
651
- if (!parentPort || isMainThread) return Promise.reject(makeError('prerender callbacks require a registered Kit worker'));
652
- ensureWorkerListener();
653
- return new Promise((resolve, reject) => {
654
- const timer = message.type === 'connect' ? setTimeout(() => {
655
- requestWaiters.delete(message.requestId);
656
- reject(makeError('skgo prerender owner is unavailable or did not answer the worker handshake'));
657
- }, 5000) : null;
658
- requestWaiters.set(message.requestId, {
659
- resolve: (value) => { if (timer) clearTimeout(timer); resolve(value); },
660
- reject: (error) => { if (timer) clearTimeout(timer); reject(error); }
661
- });
662
- try { parentPort.postMessage({ tag: TAG, ...message }); }
663
- catch (error) {
664
- if (timer) clearTimeout(timer);
665
- requestWaiters.delete(message.requestId);
666
- reject(error);
667
- }
668
- });
669
- }
670
-
671
- async function workerSession(root) {
672
- const requestId = randomUUID();
673
- const session = await workerRequest({ type: 'connect', appRoot: root, requestId });
674
- if (typeof session !== 'string' || !session) throw makeError('skgo prerender owner handshake returned no session');
675
- parentPort.postMessage({ tag: TAG, type: 'connected-ack', appRoot: root, session, requestId });
676
- return session;
677
- }
678
-
679
- async function invokeWorker(request) {
680
- const root = appRoot();
681
- const session = await workerSession(root);
682
- const requestId = randomUUID();
683
- return workerRequest({ type: 'invoke', appRoot: root, session, requestId, request });
684
- }
685
-
686
- async function reportWorkerFatal(error) {
687
- const root = appRoot();
688
- let session;
689
- try { session = await workerSession(root); }
690
- catch { throw error; }
691
- const requestId = randomUUID();
692
- try {
693
- await workerRequest({
694
- type: 'fatal', appRoot: root, session, requestId,
695
- error: error instanceof Error ? error.message : String(error)
696
- });
697
- } catch {
698
- // The owner replies only after it has drained. Preserve the original
699
- // worker-side failure as the exception Kit sees.
700
- }
701
- throw error;
536
+ if (!owner || typeof config.getSortedPlugins !== "function") return;
537
+ const wrapped = (owner.wrapped ??= new WeakSet());
538
+ const wrap = (handler) => {
539
+ if (wrapped.has(handler)) return handler;
540
+ const replacement = async function (...args) {
541
+ try {
542
+ const result = await handler.apply(this, args);
543
+ if (owner.failed) throw owner.failed;
544
+ return result;
545
+ } catch (error) {
546
+ await owner.fail(error);
547
+ throw error;
548
+ }
549
+ };
550
+ wrapped.add(replacement);
551
+ return replacement;
552
+ };
553
+ for (const plugin of config.getSortedPlugins("buildApp")) {
554
+ const hook = plugin.buildApp;
555
+ if (typeof hook === "function") plugin.buildApp = wrap(hook);
556
+ else if (hook?.handler) plugin.buildApp = { ...hook, handler: wrap(hook.handler) };
557
+ }
558
+ if (typeof config.builder?.buildApp === "function")
559
+ config.builder.buildApp = wrap(config.builder.buildApp);
560
+ }
561
+
562
+ async function invokeService(request) {
563
+ const url = process.env[SERVICE_URL];
564
+ const secret = process.env[SERVICE_SECRET];
565
+ const identity = `${request.kind} ${request.module}/${request.name ?? ""}`;
566
+ if (!url || !secret) throw new Error(`skgo prerender service unavailable for ${identity}`);
567
+ const { kind, ...body } = request;
568
+ const path = { load: "/load", remote: "/remote", "remote-inputs": "/inputs" }[kind];
569
+ const controller = new AbortController();
570
+ const timer = setTimeout(() => controller.abort(), CALLBACK_TIMEOUT);
571
+ try {
572
+ const response = await fetch(url + path, {
573
+ method: "POST",
574
+ headers: { authorization: `Bearer ${secret}`, "content-type": "application/json" },
575
+ body: JSON.stringify(body),
576
+ signal: controller.signal,
577
+ });
578
+ const raw = await response.text();
579
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${raw}`);
580
+ validateEnvelope(request, raw);
581
+ return raw;
582
+ } catch (error) {
583
+ if (controller.signal.aborted) {
584
+ const message = `skgo prerender ${identity} timed out after ${CALLBACK_TIMEOUT}ms`;
585
+ process.stderr.write(`${message}\n`);
586
+ throw new Error(message);
587
+ }
588
+ throw new Error(`skgo prerender ${identity} failed: ${error.message}`);
589
+ } finally {
590
+ clearTimeout(timer);
591
+ }
702
592
  }
703
593
 
704
594
  async function kitRuntime(root) {
705
- const kitRoot = kitRootFor(root);
706
- const transportURL = pathToFileURL(join(kitRoot, 'src/runtime/app/internal/transport.js')).href;
707
- const internalURL = pathToFileURL(join(kitRoot, 'src/exports/internal/shared.js')).href;
708
- const transport = await import(transportURL);
709
- const hooks = join(root, '.svelte-kit/output/server/entries/hooks.universal.js');
710
- let definitions = {};
711
- try { ({ transport: definitions = {} } = await import(pathToFileURL(hooks).href)); }
712
- catch (error) {
713
- if (error?.code !== 'ERR_MODULE_NOT_FOUND') throw error;
714
- }
715
- transport.init_transport(definitions);
716
- const internal = await import(internalURL);
717
- const require = createRequire(join(kitRoot, 'package.json'));
718
- const devalue = await import(pathToFileURL(require.resolve('devalue')).href);
719
- return { transport, internal, devalue };
595
+ const kitRoot = kitRootFor(root);
596
+ const transportURL = pathToFileURL(join(kitRoot, "src/runtime/app/internal/transport.js")).href;
597
+ const internalURL = pathToFileURL(join(kitRoot, "src/exports/internal/shared.js")).href;
598
+ const transport = await import(transportURL);
599
+ const hooks = join(root, ".svelte-kit/output/server/entries/hooks.universal.js");
600
+ let definitions = {};
601
+ try {
602
+ ({ transport: definitions = {} } = await import(pathToFileURL(hooks).href));
603
+ } catch (error) {
604
+ if (error?.code !== "ERR_MODULE_NOT_FOUND") throw error;
605
+ }
606
+ transport.init_transport(definitions);
607
+ const internal = await import(internalURL);
608
+ const require = createRequire(join(kitRoot, "package.json"));
609
+ const devalue = await import(pathToFileURL(require.resolve("devalue")).href);
610
+ return { transport, internal, devalue };
720
611
  }
721
612
 
722
613
  let runtimePromise;
723
614
  function runtime() {
724
- if (!runtimePromise) runtimePromise = kitRuntime(appRoot());
725
- return runtimePromise;
615
+ if (!runtimePromise) runtimePromise = kitRuntime(appRoot());
616
+ return runtimePromise;
726
617
  }
727
618
 
728
619
  /** Called by the generated native Kit input producer. */
729
620
  export async function remoteInputs(module, name) {
730
- try { verifyKitQueue(appRoot()); }
731
- catch (error) { return reportWorkerFatal(error); }
732
- const raw = await invokeWorker({ kind: 'remote-inputs', module, name });
733
- try {
734
- const answer = JSON.parse(raw);
735
- if (typeof answer.inputs !== 'string') throw makeError('skgo prerender inputs response has no devalue payload');
736
- const { transport } = await runtime();
737
- const values = transport.parse(answer.inputs);
738
- if (!Array.isArray(values)) throw makeError('skgo prerender inputs response did not decode to an array');
739
- return values;
740
- } catch (error) {
741
- return reportWorkerFatal(error);
742
- }
621
+ verifyKitQueue(appRoot());
622
+ const raw = await invokeService({ kind: "remote-inputs", module, name });
623
+ const answer = JSON.parse(raw);
624
+ const { transport } = await runtime();
625
+ const values = transport.parse(answer.inputs);
626
+ if (!Array.isArray(values))
627
+ throw new Error("skgo prerender inputs response did not decode to an array");
628
+ return values;
743
629
  }
744
630
 
745
631
  /** The emulator callback for a Go load, executing entirely in its Kit worker. */
746
632
  export async function remoteLoad(module, source, event) {
747
- let kit;
748
- try { kit = await runtime(); } catch (error) { return reportWorkerFatal(error); }
749
- const { devalue, transport } = kit;
750
- const headers = Object.fromEntries([...event.request.headers].map(([name, value]) => [name, [value]]));
751
- const cookies = event.cookies.getAll();
752
- if (cookies.length) {
753
- headers.cookie = [cookies.map(({ name, value }) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join('; ')];
754
- }
755
- // Kit's JS matcher results cannot construct named Go values. Send its
756
- // actual route metadata and the unconverted path to the build-only Go load.
757
- const { manifest } = await import(pathToFileURL(join(appRoot(), '.svelte-kit/output/server/manifest-full.js')).href);
758
- const route = manifest.routes.find((candidate) => candidate.id === event.route.id);
759
- const prefix = manifest.app_path.slice(0, -manifest.app_dir.length);
760
- const base = prefix ? '/' + prefix.slice(0, -1) : '';
761
- const { decode_pathname } = await import(pathToFileURL(join(kitRootFor(appRoot()), 'src/utils/url.js')).href);
762
- const request = {
763
- kind: 'load', module, url: event.url.href, routeId: event.route.id,
764
- params: {}, parent: await event.parent(), headers,
765
- ...(route ? { routePattern: route.pattern.source, routeParams: route.params,
766
- routePath: decode_pathname(event.url.pathname).slice(base.length) || '/' } : {})
767
- };
768
- const raw = await invokeWorker(request);
769
- const answer = JSON.parse(raw);
770
- if (typeof answer.failure === 'string') {
771
- throw new Error(`skgo: Go load failed during prerender: route ID ${event.route.id}, path ${event.url.pathname}, source ${source}: ${answer.failure}`);
772
- }
773
- for (const [name, values] of Object.entries(answer.headers ?? {})) event.setHeaders({ [name]: values.join(', ') });
774
- for (const cookie of answer.cookies ?? []) {
775
- event.cookies.set(cookie.name, cookie.value, {
776
- path: cookie.path, ...(cookie.domain ? { domain: cookie.domain } : {}),
777
- ...(cookie.maxAge ? { maxAge: cookie.maxAge } : {}), httpOnly: cookie.httpOnly,
778
- secure: cookie.secure, sameSite: ['lax', 'lax', 'strict', 'none'][cookie.sameSite] ?? 'lax'
779
- });
780
- }
781
- if (answer.data) {
782
- const pending = new Map();
783
- const revivers = {
784
- ...transport.decoders,
785
- Promise: (id) => {
786
- let resolve;
787
- let reject;
788
- const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
789
- pending.set(id, { promise, resolve, reject });
790
- return promise;
791
- }
792
- };
793
- try {
794
- answer.data = devalue.unflatten(answer.data, revivers);
795
- for (const chunk of answer.chunks ?? []) {
796
- const deferred = pending.get(chunk.id);
797
- if (!deferred) throw makeError(`skgo: unknown prerender deferred id ${chunk.id}`);
798
- if (chunk.error) deferred.reject(new Error(chunk.error));
799
- else deferred.resolve(devalue.unflatten(chunk.data, revivers));
800
- }
801
- } catch (error) {
802
- return reportWorkerFatal(error);
803
- }
804
- }
805
- return answer;
633
+ const kit = await runtime();
634
+ const { devalue, transport } = kit;
635
+ const headers = Object.fromEntries(
636
+ [...event.request.headers].map(([name, value]) => [name, [value]]),
637
+ );
638
+ const cookies = event.cookies.getAll();
639
+ if (cookies.length) {
640
+ headers.cookie = [
641
+ cookies
642
+ .map(({ name, value }) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`)
643
+ .join("; "),
644
+ ];
645
+ }
646
+ // Kit's JS matcher results cannot construct named Go values. Send its
647
+ // actual route metadata and the unconverted path to the build-only Go load.
648
+ const { manifest } = await import(
649
+ pathToFileURL(join(appRoot(), ".svelte-kit/output/server/manifest-full.js")).href
650
+ );
651
+ const route = manifest.routes.find((candidate) => candidate.id === event.route.id);
652
+ const prefix = manifest.app_path.slice(0, -manifest.app_dir.length);
653
+ const base = prefix ? "/" + prefix.slice(0, -1) : "";
654
+ const { decode_pathname } = await import(
655
+ pathToFileURL(join(kitRootFor(appRoot()), "src/utils/url.js")).href
656
+ );
657
+ const request = {
658
+ kind: "load",
659
+ module,
660
+ url: event.url.href,
661
+ routeId: event.route.id,
662
+ params: {},
663
+ parent: await event.parent(),
664
+ headers,
665
+ ...(route
666
+ ? {
667
+ routePattern: route.pattern.source,
668
+ routeParams: route.params,
669
+ routePath: decode_pathname(event.url.pathname).slice(base.length) || "/",
670
+ }
671
+ : {}),
672
+ };
673
+ const raw = await invokeService(request);
674
+ const answer = JSON.parse(raw);
675
+ if (typeof answer.failure === "string") {
676
+ throw new Error(
677
+ `skgo: Go load failed during prerender: route ID ${event.route.id}, path ${event.url.pathname}, source ${source}: ${answer.failure}`,
678
+ );
679
+ }
680
+ for (const [name, values] of Object.entries(answer.headers ?? {}))
681
+ event.setHeaders({ [name]: values.join(", ") });
682
+ for (const cookie of answer.cookies ?? []) {
683
+ event.cookies.set(cookie.name, cookie.value, {
684
+ path: cookie.path,
685
+ ...(cookie.domain ? { domain: cookie.domain } : {}),
686
+ ...(cookie.maxAge ? { maxAge: cookie.maxAge } : {}),
687
+ httpOnly: cookie.httpOnly,
688
+ secure: cookie.secure,
689
+ sameSite: ["lax", "lax", "strict", "none"][cookie.sameSite] ?? "lax",
690
+ });
691
+ }
692
+ if (answer.data) {
693
+ const pending = new Map();
694
+ const revivers = {
695
+ ...transport.decoders,
696
+ Promise: (id) => {
697
+ let resolve;
698
+ let reject;
699
+ const promise = new Promise((yes, no) => {
700
+ resolve = yes;
701
+ reject = no;
702
+ });
703
+ pending.set(id, { promise, resolve, reject });
704
+ return promise;
705
+ },
706
+ };
707
+ answer.data = devalue.unflatten(answer.data, revivers);
708
+ for (const chunk of answer.chunks ?? []) {
709
+ const deferred = pending.get(chunk.id);
710
+ if (!deferred) throw new Error(`skgo: unknown prerender deferred id ${chunk.id}`);
711
+ if (chunk.error) deferred.reject(new Error(chunk.error));
712
+ else deferred.resolve(devalue.unflatten(chunk.data, revivers));
713
+ }
714
+ }
715
+ return answer;
806
716
  }
807
717
 
808
718
  /** The emulator callback for a Go prerender remote, executing in Kit's worker. */
809
719
  export async function remoteFunction(module, name, arg, event) {
810
- let kit;
811
- try { kit = await runtime(); } catch (error) { return reportWorkerFatal(error); }
812
- const { internal, transport } = kit;
813
- let payload;
814
- try { payload = arg === undefined ? '' : Buffer.from(transport.stringify(arg), 'utf8').toString('base64url'); }
815
- catch (error) { return reportWorkerFatal(error); }
816
- const headers = Object.fromEntries([...event.request.headers].map(([key, value]) => [key, [value]]));
817
- const raw = await invokeWorker({
818
- kind: 'remote', module, name, payload, url: event.url.href, headers
819
- });
820
- const answer = JSON.parse(raw);
821
- if (answer.type === 'error' && answer.kind === 'app') throw new internal.HttpError(answer.error);
822
- if (answer.type === 'error' && answer.kind === 'unknown') throw new Error(answer.diagnostic);
823
- if (answer.type === 'redirect') throw new internal.Redirect(answer.redirect.status, answer.redirect.location);
824
- if (answer.type !== 'result' || typeof answer.data !== 'string') throw makeError('skgo prerender remote response is malformed');
825
- try {
826
- const result = transport.parse(answer.data);
827
- if (!result || typeof result !== 'object' || !Object.hasOwn(result, '_')) {
828
- throw makeError('skgo prerender remote result omitted its value member');
829
- }
830
- return result._;
831
- }
832
- catch (error) { return reportWorkerFatal(error); }
720
+ const kit = await runtime();
721
+ const { internal, transport } = kit;
722
+ const payload =
723
+ arg === undefined ? "" : Buffer.from(transport.stringify(arg), "utf8").toString("base64url");
724
+ const headers = Object.fromEntries(
725
+ [...event.request.headers].map(([key, value]) => [key, [value]]),
726
+ );
727
+ const raw = await invokeService({
728
+ kind: "remote",
729
+ module,
730
+ name,
731
+ payload,
732
+ url: event.url.href,
733
+ headers,
734
+ });
735
+ const answer = JSON.parse(raw);
736
+ if (answer.type === "error" && answer.kind === "app") throw new internal.HttpError(answer.error);
737
+ if (answer.type === "error" && answer.kind === "unknown") throw new Error(answer.diagnostic);
738
+ if (answer.type === "redirect")
739
+ throw new internal.Redirect(answer.redirect.status, answer.redirect.location);
740
+ if (answer.type !== "result" || typeof answer.data !== "string")
741
+ throw new Error("skgo prerender remote response is malformed");
742
+ const result = transport.parse(answer.data);
743
+ if (!result || typeof result !== "object" || !Object.hasOwn(result, "_")) {
744
+ throw new Error("skgo prerender remote result omitted its value member");
745
+ }
746
+ return result._;
833
747
  }