@skgo/sveltekit-adapter 0.14.0 → 0.16.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.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "SvelteKit adapter for skgo: builds a frontend that a Go binary renders and answers",
5
5
  "keywords": [
6
6
  "adapter",
@@ -40,6 +40,6 @@
40
40
  "skgo-adapter/prerender-csp.js"
41
41
  ],
42
42
  "peerDependencies": {
43
- "@sveltejs/kit": "^3.0.0-next.0"
43
+ "@sveltejs/kit": "^3.0.0"
44
44
  }
45
45
  }
@@ -25,6 +25,7 @@ import { decoders, init_transport, parse } from 'skgo:kit/transport';
25
25
  import { components, universalLoads } from 'skgo:nodes';
26
26
  import { transport } from 'skgo:hooks';
27
27
  import { serialize_data } from 'skgo:kit/serialize-data';
28
+ import { create_universal_fetch } from 'skgo:kit/load-data';
28
29
 
29
30
  /**
30
31
  * The app's transport hook, installed the way kit installs it
@@ -77,8 +78,50 @@ function make_state() {
77
78
  };
78
79
  }
79
80
 
80
- /** All render-time I/O is dispatched by Go, including the handleFetch hook. */
81
- function create_fetch(req, url) {
81
+ const bytes_to_base64 = (bytes) => {
82
+ let binary = '';
83
+ for (let i = 0; i < bytes.length; i += 0x8000) {
84
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
85
+ }
86
+ return btoa(binary);
87
+ };
88
+
89
+ const base64_to_bytes = (encoded) => {
90
+ const binary = atob(encoded);
91
+ const bytes = new Uint8Array(binary.length);
92
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
93
+ return bytes;
94
+ };
95
+
96
+ // Statuses a real Response cannot carry a body for (runtime/server/constants.js).
97
+ const NULL_BODY = [101, 204, 205, 304];
98
+
99
+ /**
100
+ * Every response header a fetch answered with, and whether the app's
101
+ * `filterSerializedResponseHeaders` lets Go replay it into the document. Go
102
+ * evaluates that filter, so the one kit hands its serializer answers from what
103
+ * Go said: the same name and value always get the same answer.
104
+ *
105
+ * @param {Set<string>} serialized
106
+ */
107
+ function resolve_options(serialized) {
108
+ return {
109
+ filterSerializedResponseHeaders: (name, value) => serialized.has(name + ':' + value)
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Kit's `event.fetch` (runtime/server/fetch.js) with its whole body — the
115
+ * handleFetch hook, credential inheritance, internal dispatch, cookie
116
+ * write-back — done by Go. What stays here is what the engine owns: turning
117
+ * what a component passed into a Request, and the bytes Go answers with into a
118
+ * Response.
119
+ *
120
+ * @param {object} req
121
+ * @param {URL} url
122
+ * @param {Set<string>} serialized
123
+ */
124
+ function create_fetch(req, url, serialized) {
82
125
  return async function (input, init) {
83
126
  const request = input instanceof Request ? input : new Request(new URL(input, url).href, init);
84
127
  const target = new URL(request.url, url);
@@ -88,138 +131,35 @@ function create_fetch(req, url) {
88
131
  method: request.method || 'GET', url: target.href, headers,
89
132
  credentials: request.credentials, mode: request.mode
90
133
  };
91
- if (typeof request._body === 'string') envelope.body = request._body;
92
- else if (request._body != null) throw new TypeError('skgo: render-time fetch request bodies must be text');
134
+ if (request.body !== null) {
135
+ const bytes = new Uint8Array(await request._consume());
136
+ if (bytes.length) envelope.bodyBase64 = bytes_to_base64(bytes);
137
+ }
93
138
  const answer = JSON.parse(await globalThis.__skgo_fetch(JSON.stringify(envelope)));
94
139
  if (answer.error) throw new TypeError(answer.error);
95
140
  const value = answer.response;
96
- const body = value.bodyBase64
97
- ? Uint8Array.from(atob(value.bodyBase64), (c) => c.charCodeAt(0))
98
- : value.body ?? '';
99
- const response = new Response(body, { status: value.status, statusText: value.statusText, headers: value.headers });
100
- response._serializedHeaders = value.serializedHeaders ?? {};
101
- return response;
102
- };
103
- }
104
-
105
- /**
106
- * Kit's universal-load fetch contract (server/page/load_data.js): record bodies
107
- * when consumed, hash the author's headers/body rather than inherited cookies,
108
- * and reject access to headers hydration will not receive. The final HTML is
109
- * emitted by Kit's own serialize_data, including escaping, hashes and TTLs.
110
- */
111
- function create_universal_fetch(event, req, fetched) {
112
- return async (input, init) => {
113
- const request_headers = input instanceof Request
114
- ? ([...input.headers].length ? new Headers(input.headers) : undefined)
115
- : init?.headers;
116
- const request_body = input instanceof Request ? input._body : init?.body;
117
- const url = new URL(input instanceof Request ? input.url : input, event.url);
118
- let response = await event.fetch(input, init);
119
- if (!same_origin(url, event.url) && (url.protocol === 'http:' || url.protocol === 'https:')) {
120
- const mode = input instanceof Request ? input.mode : init?.mode ?? 'cors';
121
- if (mode === 'no-cors') {
122
- const allowed = response._serializedHeaders;
123
- response = new Response('', { status: response.status, statusText: response.statusText, headers: response.headers });
124
- response._serializedHeaders = allowed;
125
- } else {
126
- const acao = response.headers.get('access-control-allow-origin');
127
- if (!acao || (acao !== '*' && acao !== origin_of(event.url))) {
128
- throw new Error("CORS error: incorrect or missing 'Access-Control-Allow-Origin' header");
129
- }
130
- }
141
+ for (const [name, header, allowed] of value.headers ?? []) {
142
+ if (allowed) serialized.add(name + ':' + header);
131
143
  }
132
- const cache_url = same_origin(url, event.url)
133
- ? url.pathname + url.search
134
- : url.href;
135
- const allowed = response._serializedHeaders;
136
- const wrap = (target) => {
137
- const record = (body, is_b64) => {
138
- fetched.push({
139
- url: cache_url, method: event.request.method,
140
- request_headers, request_body, response: target,
141
- response_body: body, is_b64, allowed
142
- });
143
- };
144
- const text = async () => {
145
- const body = await target.text();
146
- if (body === '' && [101, 204, 205, 304].includes(target.status)) {
147
- record(undefined, false);
148
- return undefined;
149
- }
150
- record(body, false);
151
- return body;
152
- };
153
- const headers = new Headers(target.headers);
154
- const get = headers.get.bind(headers);
155
- if (req.csr) {
156
- headers.get = (key) => {
157
- const lower = key.toLowerCase();
158
- const value = get(lower);
159
- if (value && !lower.startsWith('x-sveltekit-') && !(lower in allowed)) {
160
- throw new Error(`Failed to get response header "${lower}" — it must be included by the filterSerializedResponseHeaders option (at ${event.route.id})`);
161
- }
162
- return value;
163
- };
164
- }
165
- return new Proxy(target, {
166
- get(target, key) {
167
- if (key === 'headers') return headers;
168
- if (key === 'text') return text;
169
- if (key === 'json') return async () => { const body = await text(); return body ? JSON.parse(body) : undefined; };
170
- if (key === 'arrayBuffer') return async () => {
171
- const buffer = await target.arrayBuffer();
172
- let binary = '';
173
- for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte);
174
- record(btoa(binary), true);
175
- return buffer;
176
- };
177
- if (key === 'clone') return () => wrap(target.clone());
178
- const value = Reflect.get(target, key, target);
179
- return typeof value === 'function' ? value.bind(target) : value;
180
- }
181
- });
182
- };
183
- return wrap(response);
144
+ let body = null;
145
+ if (!NULL_BODY.includes(value.status) && request.method !== 'HEAD') {
146
+ body = value.bodyBase64 ? base64_to_bytes(value.bodyBase64) : value.body ?? '';
147
+ }
148
+ return new Response(body, {
149
+ status: value.status,
150
+ statusText: value.statusText,
151
+ headers: (value.headers ?? []).map(([name, header]) => [name, header])
152
+ });
184
153
  };
185
154
  }
186
155
 
187
-
188
- /**
189
- * Same-origin, spelled out rather than taken from `origin`. The engine's URL is
190
- * Go's (goja_nodejs, over net/url) and its `origin` is scheme and hostname with
191
- * the port left out, which would make a page on :8080 and a URL on :9999 look
192
- * like the same site — and this is the one comparison standing between a
193
- * cached response and a foreign response. Protocol, hostname and
194
- * port are each exactly what the standard compares. Go enforces dispatch separately.
195
- *
196
- * @param {URL} a
197
- * @param {URL} b
198
- */
199
- function same_origin(a, b) {
200
- const port = (url) => url.port || (url.protocol === 'https:' ? '443' : '80');
201
- return a.protocol === b.protocol && a.hostname === b.hostname && port(a) === port(b);
202
- }
203
-
204
- /**
205
- * What `url.origin` would say if this engine's URL reported it the way the
206
- * standard does, used for CORS validation.
207
- *
208
- * @param {URL} url
209
- */
210
- function origin_of(url) {
211
- const default_port = (url.protocol === 'http:' && url.port === '80') || (url.protocol === 'https:' && url.port === '443');
212
- const host = default_port ? url.host.replace(/:(80|443)$/, '') : url.host;
213
- return host ? url.protocol + '//' + host : 'null';
214
- }
215
-
216
156
  /**
217
157
  * A RequestEvent stand-in. run_remote_function spreads it and derives the
218
158
  * event a query actually sees, which is where kit makes url, params and
219
159
  * route throw. Go owns the real request; the only I/O anything here performs
220
160
  * is `fetch`'s in-process call back into Go for one of the app's own routes.
221
161
  */
222
- function make_event(req, url) {
162
+ function make_event(req, url, serialized) {
223
163
  return {
224
164
  cookies: {
225
165
  get: (name) => (req.cookies ?? {})[name],
@@ -228,7 +168,7 @@ function make_event(req, url) {
228
168
  delete: () => {},
229
169
  serialize: () => ''
230
170
  },
231
- fetch: create_fetch(req, url),
171
+ fetch: create_fetch(req, url, serialized),
232
172
  getClientAddress: () => req.client_address ?? '127.0.0.1',
233
173
  locals: {},
234
174
  params: req.params ?? {},
@@ -270,7 +210,7 @@ function node_data(node) {
270
210
  });
271
211
  }
272
212
 
273
- async function build_props(req, url, event, state, fetched) {
213
+ async function build_props(req, url, event, state, fetched, serialized) {
274
214
  const form = req.form ? devalue.parse(req.form, decoders) : null;
275
215
  const page = {
276
216
  error: req.error ?? null,
@@ -313,7 +253,7 @@ async function build_props(req, url, event, state, fetched) {
313
253
  parent: async () => parent_data,
314
254
  depends: () => {},
315
255
  untrack: (fn) => fn(),
316
- fetch: create_universal_fetch(event, req, fetched),
256
+ fetch: create_universal_fetch(event, undefined, fetched, req.csr, resolve_options(serialized)),
317
257
  setHeaders: () => {
318
258
  throw new Error('skgo: universal load setHeaders is not supported during rendering');
319
259
  }
@@ -421,9 +361,10 @@ globalThis.__skgo_render = function (req_json) {
421
361
  const req = JSON.parse(req_json);
422
362
  const url = new URL(req.url);
423
363
  const state = make_state();
424
- const event = make_event(req, url);
364
+ const serialized = new Set();
365
+ const event = make_event(req, url, serialized);
425
366
  const fetched = [];
426
- const props_promise = build_props(req, url, event, state, fetched);
367
+ const props_promise = build_props(req, url, event, state, fetched, serialized);
427
368
  const promise = props_promise.then((props) => {
428
369
  result.status = props.page.status;
429
370
  result.error = req.error ?? null;
@@ -469,7 +410,9 @@ globalThis.__skgo_render = function (req_json) {
469
410
  (rendered) => {
470
411
  result.head = rendered.head;
471
412
  result.body = rendered.body;
472
- result.fetched = fetched.map((item) => serialize_data(item, (key) => key in item.allowed)).join("\n\t\t\t");
413
+ result.fetched = fetched
414
+ .map((item) => serialize_data(item, resolve_options(serialized).filterSerializedResponseHeaders))
415
+ .join("\n\t\t\t");
473
416
  result.done = true;
474
417
  },
475
418
  (err) => {
@@ -26,7 +26,7 @@
26
26
 
27
27
  import { createRequire } from 'node:module';
28
28
  import { execFileSync } from 'node:child_process';
29
- import { randomBytes } from 'node:crypto';
29
+ import { createHash, randomBytes } from 'node:crypto';
30
30
  import { protectEnvironmentFiles } from './env-private.js';
31
31
  import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
32
32
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
@@ -63,7 +63,7 @@ async function fromApp(specifier) {
63
63
  }
64
64
 
65
65
  const { rolldown } = await fromApp('vite/rolldown');
66
- const { transformSync } = await fromApp('vite/rolldown/experimental');
66
+ const { parseSync, transformSync } = await fromApp('vite/rolldown/experimental');
67
67
  // `fetchModule` operates on the app's own `DevEnvironment`, so it has to be
68
68
  // the app's copy for the same reason rolldown is: a second module realm's
69
69
  // vite does not recognise this one's environments.
@@ -133,6 +133,9 @@ function kitAliases(root) {
133
133
  'skgo:kit/shared': join(runtime, 'shared.js'),
134
134
  'skgo:kit/transport': join(runtime, 'app/internal/transport.js'),
135
135
  'skgo:kit/serialize-data': join(runtime, 'server/page/serialize_data.js'),
136
+ // kit's own universal fetch, which the entry hands every universal load
137
+ // rather than keeping a second copy of its CORS, replay and header rules.
138
+ 'skgo:kit/load-data': join(runtime, 'server/page/load_data.js'),
136
139
  'skgo:kit/props': join(runtime, 'props.svelte.js'),
137
140
  'skgo:kit/root': join(runtime, 'components/root.svelte'),
138
141
  // kit's own `$app/paths` server implementation, which app-paths.js
@@ -166,8 +169,9 @@ function bare(id) {
166
169
  return !id.startsWith('.') && !id.startsWith('\0') && !id.startsWith('/') && !isAbsolute(id);
167
170
  }
168
171
 
169
- /** The constructs goja's parser rejects, and the only reason to lower a module. */
170
- const UNPARSEABLE = /for\s+await\s*\(|async\s+function\s*\*|async\s*\*/;
172
+ // A conservative prefilter: comments may separate an unsupported construct's
173
+ // tokens. The syntax tree, not their spelling or surrounding text, decides.
174
+ const MAY_NEED_LOWERING = /\b(?:async|await)\b/;
171
175
 
172
176
  /**
173
177
  * The goja environment: the vite plugin that declares it, and the call that
@@ -485,7 +489,7 @@ export function nodeTable(root, nodes) {
485
489
  * @param {string} id
486
490
  * @param {{ lowered?: Set<string>, helpers?: string }} options
487
491
  */
488
- function engineTransform(code, id, { lowered, helpers } = {}) {
492
+ export function engineTransform(code, id, { lowered, helpers } = {}) {
489
493
  let out = code;
490
494
  // svelte/src/internal/server/crypto.js hides a `node:crypto` import behind
491
495
  // a variable so bundlers cannot resolve it. goja rejects the syntax
@@ -496,7 +500,7 @@ function engineTransform(code, id, { lowered, helpers } = {}) {
496
500
  '=> Promise.reject(new Error("skgo: no dynamic import in the SSR engine"))'
497
501
  );
498
502
  }
499
- if (!UNPARSEABLE.test(out)) {
503
+ if (!needsLowering(out, id)) {
500
504
  return out === code ? null : { code: out, map: null };
501
505
  }
502
506
  lowered?.add(id);
@@ -511,6 +515,30 @@ function engineTransform(code, id, { lowered, helpers } = {}) {
511
515
  return { code: lower, map: null };
512
516
  }
513
517
 
518
+ // A text match is only a cheap prefilter. Svelte's renderer has a JSDoc
519
+ // parameter named `render_async` immediately before `*/`, which looks like
520
+ // an async generator to the old regexp. Lowering that comment rewrote all of its
521
+ // private fields to WeakMaps and retained whole render trees in the engine.
522
+ function needsLowering(code, id) {
523
+ if (!MAY_NEED_LOWERING.test(code)) return false;
524
+ const nodes = [parseSync(id.replace(/\0/g, '_'), code).program];
525
+ while (nodes.length) {
526
+ const node = nodes.pop();
527
+ if (!node || typeof node !== 'object') continue;
528
+ if (node.type === 'ForOfStatement' && node.await) return true;
529
+ if ((node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression') &&
530
+ node.async && node.generator) return true;
531
+ for (const value of Object.values(node)) {
532
+ if (value && typeof value === 'object') {
533
+ if (Array.isArray(value)) {
534
+ for (const child of value) nodes.push(child);
535
+ } else nodes.push(value);
536
+ }
537
+ }
538
+ }
539
+ return false;
540
+ }
541
+
514
542
  /**
515
543
  * Where oxc's lowering helpers live, resolved the way dev has to resolve them:
516
544
  * from vite rather than from the app. Under pnpm `@oxc-project/runtime` is a
@@ -787,6 +815,13 @@ export function gojaDevEnvironment({ outDir = '.svelte-kit' } = {}) {
787
815
  // temporary version forever.
788
816
  changed.push(file);
789
817
 
818
+ // The document templates are read afresh for every `/info`, but Go
819
+ // installs them only when the version moves. Nothing else moves it
820
+ // for a template, which is not a route and has no module to drop.
821
+ if ([kit.files.appTemplate, kit.files.errorTemplate].some((t) => normalize(t) === normalize(file))) {
822
+ manifestVersion++;
823
+ }
824
+
790
825
  const routeFile = normalize(file).startsWith(normalize(kit.files.routes) + '/');
791
826
  if (routeFile && (event === 'add' || event === 'unlink')) manifestDirty = true;
792
827
  const name = relative(kit.files.routes, file).split(/[\\/]/).at(-1);
@@ -958,6 +993,138 @@ export function gojaDevEnvironment({ outDir = '.svelte-kit' } = {}) {
958
993
  return plugin;
959
994
  }
960
995
 
996
+ /**
997
+ * A watcher event for a file whose bytes are the ones vite already has is not an edit.
998
+ *
999
+ * chokidar reports a file nobody has read since it was written as changed on
1000
+ * any filesystem event, metadata-only ones included, and something on a
1001
+ * developer's machine (a sync client, a scanner) touches generated files that
1002
+ * way. Kit's own dev server has no answer to it: vite turns each report into a
1003
+ * hot update or a full page reload, and a reload throws away what a visitor
1004
+ * had typed and aborts the documents Go was rendering for them. So the dev
1005
+ * server remembers the content it last loaded or was told about, and lets
1006
+ * through only the reports whose content differs.
1007
+ *
1008
+ * Content means the file's bytes, not the text vite decoded from them: two
1009
+ * different invalid byte sequences decode to the same replacement characters,
1010
+ * and an edit between them is still an edit. What vite loaded is therefore
1011
+ * remembered only when the file's bytes decode to exactly it; a file a plugin
1012
+ * generated or rewrote on the way in has no such bytes, and its first report
1013
+ * is let through.
1014
+ *
1015
+ * The adapter's own output directory is not an input at all. It is where the
1016
+ * build writes the documents Go embeds, vite holds no module for any of them,
1017
+ * and vite answers a report for an html file it holds no module for by
1018
+ * reloading every open page. It is excluded from the watcher the way kit
1019
+ * excludes its own `.svelte-kit` output (kit/exports/vite/index.js, the
1020
+ * `server.watch.ignored` it sets), so nothing written there is ever reported.
1021
+ *
1022
+ * chokidar emits one report twice, `change` and then `all`, and the first
1023
+ * verdict stands for both. A `change` emitted from inside a `change` listener
1024
+ * is a plugin's own (vite-plugin-svelte re-emits it for a preprocessor's
1025
+ * dependants, whose bytes are unchanged); those always pass.
1026
+ *
1027
+ * @param {{ out?: string }} [options] the adapter's output directory, relative to the vite root
1028
+ * @returns {import('vite').Plugin}
1029
+ */
1030
+ export function gojaDevUnchangedFiles({ out = 'build' } = {}) {
1031
+ /** What vite last loaded for a file, or last reported changed: its byte digest. */
1032
+ const known = new Map();
1033
+ const digest = (/** @type {Uint8Array} */ bytes) => createHash('sha256').update(bytes).digest('base64');
1034
+ const read = (/** @type {string} */ file) => {
1035
+ try {
1036
+ return digest(readFileSync(file));
1037
+ } catch {
1038
+ return null;
1039
+ }
1040
+ };
1041
+ /** Remember a file by the bytes vite decoded `code` from, if they are the ones on disk now. */
1042
+ const remember = (/** @type {string} */ file, /** @type {string} */ code) => {
1043
+ try {
1044
+ const bytes = readFileSync(file);
1045
+ if (bytes.toString('utf8') === code) known.set(posix(file), digest(bytes));
1046
+ else known.delete(posix(file));
1047
+ } catch {
1048
+ known.delete(posix(file));
1049
+ }
1050
+ };
1051
+
1052
+ return {
1053
+ name: 'skgo-dev-unchanged-files',
1054
+ apply: 'serve',
1055
+
1056
+ config(config) {
1057
+ const emitted = posix(resolve(config.root ?? process.cwd(), out));
1058
+ return { server: { watch: { ignored: [emitted, `${emitted}/**`] } } };
1059
+ },
1060
+
1061
+ configureServer(server) {
1062
+ const watcher = server.watcher;
1063
+ const emit = watcher.emit;
1064
+ let depth = 0;
1065
+ /** @type {{ file: string, same: boolean } | null} */
1066
+ let verdict = null;
1067
+ /** @type {(file: string) => boolean} */
1068
+ const unchanged = (file) => {
1069
+ const key = posix(file);
1070
+ const now = read(file);
1071
+ const same = now !== null && known.get(key) === now;
1072
+ if (now !== null) known.set(key, now);
1073
+ return same;
1074
+ };
1075
+ // Kit validates templates synchronously from its watcher. An in-place
1076
+ // save can expose truncated bytes; report that validation error but
1077
+ // keep the watcher alive so the completed save can regenerate Kit.
1078
+ const notify = (receiver, event, args, file) => {
1079
+ try {
1080
+ return emit.call(receiver, event, ...args);
1081
+ } catch (error) {
1082
+ const authored = typeof file === 'string' ? relative(server.config.root, file) : null;
1083
+ if (!(error instanceof Error) || authored === null ||
1084
+ !['%sveltekit.head%', '%sveltekit.body%'].some(tag => error.message === `${authored} is missing ${tag}`)) throw error;
1085
+ known.delete(posix(file));
1086
+ server.config.logger.error(error.stack ?? error.message);
1087
+ server.ws.send({ type: 'error', err: { message: error.message, stack: error.stack ?? '', plugin: 'skgo-dev-unchanged-files' } });
1088
+ return false;
1089
+ }
1090
+ };
1091
+ watcher.emit = function (event, ...args) {
1092
+ const all = event === 'all';
1093
+ const kind = all ? args[0] : event;
1094
+ const file = all ? args[1] : args[0];
1095
+ if (typeof file === 'string' && kind === 'add' && !all) {
1096
+ const now = read(file);
1097
+ if (now !== null) known.set(posix(file), now);
1098
+ } else if (typeof file === 'string' && kind === 'change') {
1099
+ if (all) {
1100
+ const reported = verdict && verdict.file === file ? verdict : null;
1101
+ verdict = null;
1102
+ if (reported ? reported.same : unchanged(file)) return false;
1103
+ } else if (depth === 0) {
1104
+ verdict = { file, same: unchanged(file) };
1105
+ if (verdict.same) return false;
1106
+ }
1107
+ }
1108
+ if (all) return notify(this, event, args, file);
1109
+ depth++;
1110
+ try {
1111
+ return notify(this, event, args, file);
1112
+ } finally {
1113
+ depth--;
1114
+ }
1115
+ };
1116
+ },
1117
+
1118
+ transform: {
1119
+ order: 'pre',
1120
+ handler(code, id) {
1121
+ if (!id.includes('?') && !id.startsWith('\0') && isAbsolute(id)) remember(id, code);
1122
+ return null;
1123
+ }
1124
+ }
1125
+ };
1126
+ }
1127
+
961
1128
  const OXC_HELPERS = '@oxc-project/runtime/helpers/';
962
1129
 
963
1130
  /**
@@ -25,8 +25,10 @@ globalThis.process = { versions: { webcontainer: 'skgo' } };
25
25
  // (exports/internal/shared.js), which builds one to reject a location that
26
26
  // could not survive an HTTP header — and does it inside a try/catch, so an
27
27
  // absent Headers would be reported as an invalid location rather than as a
28
- // missing global. Only the construction and the validation it performs are
29
- // needed; nothing in the engine sends a request.
28
+ // missing global. It is also what a render's fetch response carries, so it
29
+ // follows the Fetch standard where kit's universal fetch can see it: names are
30
+ // lowercased and iterate sorted, a repeated name is joined with ", ", and
31
+ // Set-Cookie alone is kept as a list (getSetCookie) and iterates once per value.
30
32
  if (typeof globalThis.Headers === 'undefined') {
31
33
  // RFC 9110 field-name and field-value rules, which is all the real
32
34
  // constructor checks that matters here.
@@ -36,8 +38,10 @@ if (typeof globalThis.Headers === 'undefined') {
36
38
  globalThis.Headers = class Headers {
37
39
  constructor(init) {
38
40
  this._ = new Map();
41
+ this._cookies = [];
39
42
  if (init instanceof Headers) {
40
43
  for (const [k, v] of init._) this._.set(k, v);
44
+ this._cookies = init._cookies.slice();
41
45
  } else if (Array.isArray(init)) {
42
46
  for (const [k, v] of init) this.append(k, v);
43
47
  } else if (init && typeof init === 'object') {
@@ -49,21 +53,35 @@ if (typeof globalThis.Headers === 'undefined') {
49
53
  const v = String(value).trim();
50
54
  if (!NAME.test(n)) throw new TypeError('Invalid header name: ' + n);
51
55
  if (BAD_VALUE.test(v)) throw new TypeError('Invalid header value');
52
- this._.set(n.toLowerCase(), v);
56
+ const k = n.toLowerCase();
57
+ if (k === 'set-cookie') this._cookies = [v];
58
+ else this._.set(k, v);
53
59
  }
54
60
  append(name, value) {
55
- const existing = this.get(name);
56
- this.set(name, existing === null ? value : existing + ', ' + value);
61
+ const n = String(name);
62
+ const v = String(value).trim();
63
+ if (!NAME.test(n)) throw new TypeError('Invalid header name: ' + n);
64
+ if (BAD_VALUE.test(v)) throw new TypeError('Invalid header value');
65
+ const k = n.toLowerCase();
66
+ if (k === 'set-cookie') this._cookies.push(v);
67
+ else this._.set(k, this._.has(k) ? this._.get(k) + ', ' + v : v);
57
68
  }
58
69
  get(name) {
59
70
  const k = String(name).toLowerCase();
71
+ if (k === 'set-cookie') return this._cookies.length ? this._cookies.join(', ') : null;
60
72
  return this._.has(k) ? this._.get(k) : null;
61
73
  }
74
+ getSetCookie() {
75
+ return this._cookies.slice();
76
+ }
62
77
  has(name) {
63
- return this._.has(String(name).toLowerCase());
78
+ const k = String(name).toLowerCase();
79
+ return k === 'set-cookie' ? this._cookies.length > 0 : this._.has(k);
64
80
  }
65
81
  delete(name) {
66
- this._.delete(String(name).toLowerCase());
82
+ const k = String(name).toLowerCase();
83
+ if (k === 'set-cookie') this._cookies = [];
84
+ else this._.delete(k);
67
85
  }
68
86
  forEach(fn, thisArg) {
69
87
  for (const [k, v] of this.entries()) fn.call(thisArg, v, k, this);
@@ -75,7 +93,10 @@ if (typeof globalThis.Headers === 'undefined') {
75
93
  return Array.from(this.entries(), ([, v]) => v)[Symbol.iterator]();
76
94
  }
77
95
  entries() {
78
- return Array.from(this._).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)[Symbol.iterator]();
96
+ const all = Array.from(this._);
97
+ for (const cookie of this._cookies) all.push(['set-cookie', cookie]);
98
+ // A stable sort keeps the Set-Cookie values in the order they arrived.
99
+ return all.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)[Symbol.iterator]();
79
100
  }
80
101
  [Symbol.iterator]() {
81
102
  return this.entries();
@@ -83,52 +104,264 @@ if (typeof globalThis.Headers === 'undefined') {
83
104
  };
84
105
  }
85
106
 
86
- // Request and Response back a render-time `event.fetch`. Kit's own
87
- // `normalize_fetch_input` (runtime/server/fetch.js) turns whatever a
88
- // component passed into a real Request before deciding what to do with it, and
89
- // the answer a render's fetch gets back has to be a real Response — `await
90
- // (await event.fetch(...)).json()` is what a page actually writes. Nothing
91
- // here sends bytes anywhere: building one is string and Map bookkeeping, and
92
- // the one call that leaves the engine is `__skgo_fetch` itself.
107
+ // ReadableStream is what a Response body is on the server too: kit's universal
108
+ // fetch tees `response.body` to record what a load read, and builds a Response
109
+ // over a stream to turn that copy into bytes. This is the part of the standard
110
+ // that path touches — a default reader, tee and cancel — over chunks that are
111
+ // already in memory, because Go hands a render a finished body and the engine
112
+ // holds no socket to stream from.
113
+ if (typeof globalThis.ReadableStream === 'undefined') {
114
+ class ReadableStreamDefaultReader {
115
+ constructor(stream) {
116
+ if (stream._locked) throw new TypeError('ReadableStream is locked');
117
+ stream._locked = true;
118
+ this._stream = stream;
119
+ }
120
+ read() {
121
+ return this._stream._read();
122
+ }
123
+ releaseLock() {
124
+ this._stream._locked = false;
125
+ }
126
+ cancel(reason) {
127
+ return this._stream.cancel(reason, true);
128
+ }
129
+ }
130
+
131
+ globalThis.ReadableStream = class ReadableStream {
132
+ constructor(source = {}) {
133
+ this._source = source;
134
+ this._queue = [];
135
+ this._state = 'readable';
136
+ this._error = undefined;
137
+ this._waiters = [];
138
+ this._locked = false;
139
+ this._disturbed = false;
140
+ const wake = () => this._waiters.splice(0).forEach((resolve) => resolve());
141
+ this._controller = {
142
+ enqueue: (chunk) => {
143
+ if (this._state !== 'readable') throw new TypeError('The stream is not readable');
144
+ this._queue.push(chunk);
145
+ wake();
146
+ },
147
+ close: () => {
148
+ if (this._state === 'readable') this._state = 'closed';
149
+ wake();
150
+ },
151
+ error: (reason) => {
152
+ if (this._state === 'readable') {
153
+ this._state = 'errored';
154
+ this._error = reason;
155
+ this._queue = [];
156
+ }
157
+ wake();
158
+ },
159
+ desiredSize: 1
160
+ };
161
+ this._started = Promise.resolve().then(() => source.start?.(this._controller));
162
+ }
163
+ get locked() {
164
+ return this._locked;
165
+ }
166
+ getReader() {
167
+ return new ReadableStreamDefaultReader(this);
168
+ }
169
+ async _read() {
170
+ this._disturbed = true;
171
+ await this._started;
172
+ for (;;) {
173
+ if (this._queue.length) return { value: this._queue.shift(), done: false };
174
+ if (this._state === 'closed') return { value: undefined, done: true };
175
+ if (this._state === 'errored') throw this._error;
176
+ if (this._source.pull) {
177
+ await this._source.pull(this._controller);
178
+ if (this._queue.length || this._state !== 'readable') continue;
179
+ }
180
+ await new Promise((resolve) => this._waiters.push(resolve));
181
+ }
182
+ }
183
+ async cancel(reason, viaReader = false) {
184
+ if (this._locked && !viaReader) throw new TypeError('ReadableStream is locked');
185
+ this._disturbed = true;
186
+ if (this._state === 'readable') {
187
+ this._state = 'closed';
188
+ this._queue = [];
189
+ this._waiters.splice(0).forEach((resolve) => resolve());
190
+ await this._source.cancel?.(reason);
191
+ }
192
+ }
193
+ tee() {
194
+ const reader = this.getReader();
195
+ const branches = [];
196
+ let chain = Promise.resolve();
197
+ const pump = () => (chain = chain.then(async () => {
198
+ const result = await reader.read().catch((error) => ({ error }));
199
+ if (result.error !== undefined) branches.forEach((branch) => branch._controller.error(result.error));
200
+ else if (result.done) branches.forEach((branch) => branch._controller.close());
201
+ else branches.forEach((branch) => branch._state === 'readable' && branch._controller.enqueue(result.value));
202
+ }));
203
+ for (let i = 0; i < 2; i++) branches.push(new ReadableStream({ pull: pump }));
204
+ return branches;
205
+ }
206
+ [Symbol.asyncIterator]() {
207
+ const reader = this.getReader();
208
+ return { next: () => reader.read(), [Symbol.asyncIterator]() { return this; } };
209
+ }
210
+ };
211
+ }
212
+
213
+ // What a body of any of the kinds `Request` and `Response` take comes to as
214
+ // bytes: text, an ArrayBuffer or a view over one, URLSearchParams (which is
215
+ // text), or a ReadableStream of those.
216
+ const body_bytes = async (body) => {
217
+ if (body === null || body === undefined) return new Uint8Array(0);
218
+ if (typeof body === 'string') return new TextEncoder().encode(body);
219
+ if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
220
+ return new TextEncoder().encode(body.toString());
221
+ }
222
+ if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0));
223
+ if (ArrayBuffer.isView(body)) {
224
+ return new Uint8Array(body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength));
225
+ }
226
+ if (body instanceof ReadableStream) {
227
+ const chunks = [];
228
+ let length = 0;
229
+ const reader = body.getReader();
230
+ for (;;) {
231
+ const { value, done } = await reader.read();
232
+ if (done) break;
233
+ const bytes = await body_bytes(value);
234
+ chunks.push(bytes);
235
+ length += bytes.length;
236
+ }
237
+ const out = new Uint8Array(length);
238
+ let at = 0;
239
+ for (const chunk of chunks) {
240
+ out.set(chunk, at);
241
+ at += chunk.length;
242
+ }
243
+ return out;
244
+ }
245
+ return new TextEncoder().encode(String(body));
246
+ };
247
+ globalThis.__skgo_body_bytes = body_bytes;
248
+
249
+ // The part of Request and Response that is the same on both: one body, readable
250
+ // once, as text, JSON, bytes or a stream.
251
+ const Body = class {
252
+ get bodyUsed() {
253
+ return this._used || (this._stream !== undefined && this._stream._disturbed);
254
+ }
255
+ get body() {
256
+ if (this._source === null || this._source === undefined) return null;
257
+ if (this._stream === undefined) {
258
+ this._stream = this._source instanceof ReadableStream
259
+ ? this._source
260
+ : new ReadableStream({
261
+ start: async (controller) => {
262
+ const bytes = await body_bytes(this._source);
263
+ if (bytes.length) controller.enqueue(bytes);
264
+ controller.close();
265
+ }
266
+ });
267
+ }
268
+ return this._stream;
269
+ }
270
+ async _consume() {
271
+ if (this.bodyUsed) throw new TypeError('Body is unusable: Body has already been read');
272
+ if (this._source === null || this._source === undefined) {
273
+ this._used = true;
274
+ return new Uint8Array(0);
275
+ }
276
+ if (this._stream === undefined && !(this._source instanceof ReadableStream)) {
277
+ this._used = true;
278
+ return body_bytes(this._source);
279
+ }
280
+ const stream = this.body;
281
+ this._used = true;
282
+ return body_bytes(stream);
283
+ }
284
+ async text() {
285
+ return new TextDecoder().decode(await this._consume());
286
+ }
287
+ async json() {
288
+ return JSON.parse(await this.text());
289
+ }
290
+ async arrayBuffer() {
291
+ const bytes = await this._consume();
292
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
293
+ }
294
+ _cloneSource() {
295
+ if (this.bodyUsed) throw new TypeError('Body is unusable: Body has already been read');
296
+ if (this._source === null || this._source === undefined) return null;
297
+ if (this._source instanceof ReadableStream || this._stream !== undefined) {
298
+ const [kept, copy] = this.body.tee();
299
+ this._stream = kept;
300
+ return copy;
301
+ }
302
+ return this._source;
303
+ }
304
+ };
305
+
306
+ // Request and Response back a render's `event.fetch`. Kit's own
307
+ // `normalize_fetch_input` (runtime/server/fetch.js) turns whatever a component
308
+ // passed into a real Request before deciding what to do with it, and the
309
+ // answer a render's fetch gets back has to be a real Response — `await (await
310
+ // event.fetch(...)).json()` is what a page actually writes. Nothing here sends
311
+ // bytes anywhere: building one is bookkeeping, and the one call that leaves
312
+ // the engine is `__skgo_fetch` itself.
93
313
  if (typeof globalThis.Request === 'undefined') {
94
- globalThis.Request = class Request {
314
+ globalThis.Request = class Request extends Body {
95
315
  constructor(input, init = {}) {
96
- if (input instanceof Request) {
97
- this.url = input.url;
98
- this.method = (init.method ?? input.method ?? 'GET').toUpperCase();
99
- this.headers = init.headers ? new Headers(init.headers) : new Headers(input.headers);
100
- this._body = init.body !== undefined ? init.body : input._body;
101
- this.credentials = init.credentials ?? input.credentials ?? 'same-origin';
102
- this.mode = init.mode ?? input.mode ?? 'cors';
103
- } else {
104
- this.url = String(input);
105
- this.method = (init.method ?? 'GET').toUpperCase();
106
- this.headers = new Headers(init.headers);
107
- this._body = init.body;
108
- this.credentials = init.credentials ?? 'same-origin';
109
- this.mode = init.mode ?? 'cors';
316
+ super();
317
+ const from = input instanceof Request ? input : null;
318
+ this.url = from ? from.url : String(input);
319
+ this.method = String(init.method ?? from?.method ?? 'GET').toUpperCase();
320
+ this.headers = new Headers(init.headers ?? from?.headers);
321
+ this.credentials = init.credentials ?? from?.credentials ?? 'same-origin';
322
+ this.mode = init.mode ?? from?.mode ?? 'cors';
323
+ this.cache = init.cache ?? from?.cache ?? 'default';
324
+ this.signal = init.signal ?? from?.signal ?? undefined;
325
+ this._used = false;
326
+ const own = init.body !== undefined && init.body !== null;
327
+ this._source = own ? init.body : from ? from._cloneSource() : null;
328
+ if (this._source !== null && (this.method === 'GET' || this.method === 'HEAD')) {
329
+ throw new TypeError('Request with GET/HEAD method cannot have body.');
330
+ }
331
+ if (own && !this.headers.has('content-type')) {
332
+ if (typeof init.body === 'string') {
333
+ this.headers.set('content-type', 'text/plain;charset=UTF-8');
334
+ } else if (typeof URLSearchParams !== 'undefined' && init.body instanceof URLSearchParams) {
335
+ this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
336
+ }
110
337
  }
111
338
  }
112
- async text() { return this._body ?? ''; }
113
- async json() { return JSON.parse(this._body ?? 'null'); }
114
- clone() { return new Request(this); }
339
+ clone() {
340
+ return new Request(this);
341
+ }
115
342
  };
116
343
  }
117
344
 
118
345
  if (typeof globalThis.Response === 'undefined') {
119
- globalThis.Response = class Response {
346
+ globalThis.Response = class Response extends Body {
120
347
  constructor(body, init = {}) {
121
- this._body = body ?? '';
348
+ super();
349
+ this._source = body ?? null;
350
+ this._used = false;
122
351
  this.status = init.status ?? 200;
123
352
  this.statusText = init.statusText ?? '';
124
- this.headers = init.headers instanceof Headers ? init.headers : new Headers(init.headers);
353
+ this.headers = new Headers(init.headers);
125
354
  this.ok = this.status >= 200 && this.status < 300;
355
+ this.url = '';
356
+ this.redirected = false;
357
+ this.type = 'default';
126
358
  }
127
- async text() { return typeof this._body === 'string' ? this._body : new TextDecoder().decode(this._body); }
128
- async json() { return JSON.parse(await this.text()); }
129
- async arrayBuffer() { return (typeof this._body === 'string' ? new TextEncoder().encode(this._body) : new Uint8Array(this._body)).buffer; }
130
359
  clone() {
131
- return new Response(this._body, { status: this.status, statusText: this.statusText, headers: new Headers(this.headers) });
360
+ return new Response(this._cloneSource(), {
361
+ status: this.status,
362
+ statusText: this.statusText,
363
+ headers: this.headers
364
+ });
132
365
  }
133
366
  };
134
367
  }
package/skgo-adapter.js CHANGED
@@ -14,7 +14,7 @@ import { createRequire } from 'node:module';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { basename, dirname, join, relative, resolve } from 'node:path';
16
16
  import { pathToFileURL } from 'node:url';
17
- import { gojaDevEnvironment, gojaEnvironment, goEnvironmentValues, nodeTable, SSR_TARGET } from './skgo-adapter/env.js';
17
+ import { gojaDevEnvironment, gojaDevUnchangedFiles, gojaEnvironment, goEnvironmentValues, nodeTable, SSR_TARGET } from './skgo-adapter/env.js';
18
18
  import { identity } from './skgo-adapter/identity.js';
19
19
  import { checkEndpoints, validateGenerated } from './skgo-adapter/generated.js';
20
20
  import { authorizePrerenderedScripts } from './skgo-adapter/prerender-csp.js';
@@ -188,7 +188,7 @@ export default function skgo({ out = 'build', precompress = true } = {}) {
188
188
  // compiles the SSR bundle in is also declared in `vite dev`, where Go
189
189
  // pulls one transformed module at a time out of it instead. Each plugin
190
190
  // states its own `apply`, so only one of them is ever live.
191
- vite: { plugins: { post: [environment.plugin, goja.plugin, gojaDevEnvironment()] } },
191
+ vite: { plugins: { post: [environment.plugin, goja.plugin, gojaDevEnvironment(), gojaDevUnchangedFiles({ out })] } },
192
192
  async adapt(builder) {
193
193
  rmSync(out, { force: true, recursive: true });
194
194
 
@@ -404,7 +404,7 @@ function writeAppManifest(file, manifest) {
404
404
  /**
405
405
  * Kit's own server manifest, as an object *and* as text. Kit 3 generates a
406
406
  * complete server-instance module instead of returning manifest source. We
407
- * remove its eager Server import and constructor and export the manifest it
407
+ * remove its eager create_server import and call and export the manifest it
408
408
  * wrote; every remaining import sits inside a lazy thunk, so importing the
409
409
  * result resolves nothing and runs no application code. The source is worth
410
410
  * keeping because those thunks carry information the imported object has
@@ -413,22 +413,27 @@ function writeAppManifest(file, manifest) {
413
413
  * @param {import('@sveltejs/kit').Builder} builder
414
414
  * @returns {Promise<{ manifest: any, source: string }>}
415
415
  */
416
- async function readKitManifest(builder) {
416
+ export async function readKitManifest(builder) {
417
417
  const dir = builder.getBuildDirectory('skgo');
418
418
  const file = join(dir, 'kit-manifest.js');
419
419
  mkdirSync(dir, { recursive: true });
420
420
  builder.generateServerInstance(file);
421
- let source = readFileSync(file, 'utf8');
422
- const original = source;
423
- source = source
424
- .replace(/^import \{ Server \} from '[^']+';\n/, '')
425
- .replace(/^const manifest = /m, 'export const manifest = ')
426
- .replace(/\nexport const server = new Server\(manifest\);\n?$/, '\n');
427
- if (source === original || !source.includes('export const manifest = ')) {
421
+ const original = readFileSync(file, 'utf8');
422
+ // Kit 3's builder writes this wrapper around its lazy manifest. Strip both
423
+ // sides before importing it: importing Kit's server would execute its module
424
+ // graph, while this read should only discover what the Go binary must serve.
425
+ const serverImport = /^import \{ create_server \} from '[^']+';\n/;
426
+ const serverCall = /\nexport const server = create_server\(manifest\);\n?$/;
427
+ if (!serverImport.test(original) || !serverCall.test(original) ||
428
+ !original.replace(serverImport, '').startsWith('const manifest = ')) {
428
429
  throw new Error(
429
430
  'skgo: kit generated a server instance in an unknown shape; refusing to guess where its manifest is'
430
431
  );
431
432
  }
433
+ const source = original
434
+ .replace(serverImport, '')
435
+ .replace(/^const manifest = /, 'export const manifest = ')
436
+ .replace(serverCall, '\n');
432
437
  writeFileSync(file, source);
433
438
  try {
434
439
  // The cache buster matters: `vp build` can run twice in one process.