@skgo/sveltekit-adapter 0.13.1 → 0.15.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.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "SvelteKit adapter for skgo: builds a frontend that a Go binary renders and answers",
5
5
  "keywords": [
6
6
  "adapter",
@@ -24,6 +24,8 @@ import * as devalue from 'skgo:devalue';
24
24
  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
+ import { serialize_data } from 'skgo:kit/serialize-data';
28
+ import { create_universal_fetch } from 'skgo:kit/load-data';
27
29
 
28
30
  /**
29
31
  * The app's transport hook, installed the way kit installs it
@@ -76,89 +78,79 @@ function make_state() {
76
78
  };
77
79
  }
78
80
 
79
- /**
80
- * event.fetch during a render. Kit's own (runtime/server/fetch.js) resolves a
81
- * relative URL against the page's own origin, forwards the incoming cookies
82
- * on a same-origin request unless credentials is "omit", and otherwise hands
83
- * the request to a real fetch. This engine has no socket to hand a
84
- * cross-origin request to — no application I/O executes in JavaScript here —
85
- * so a same-origin URL is a call back into Go's own handler for it, exactly
86
- * the shape a remote function's call is, and a cross-origin one is refused: a
87
- * thrown TypeError, before anything is dispatched. (Kit's own version also
88
- * forwards the page's own Authorization header on a same-origin request; this
89
- * one does not, because the render's own request never reaches the engine
90
- * with its headers — only its cookies, in req.cookies.)
91
- *
92
- * @param {any} req
93
- * @param {URL} url
94
- */
95
- function create_fetch(req, url) {
96
- return async function (input, init) {
97
- const request = input instanceof Request ? input : new Request(input, init);
98
- const target = new URL(request.url, url);
99
-
100
- if (!same_origin(target, url)) {
101
- throw new TypeError(
102
- "skgo: event.fetch only reaches this app's own routes during server-side rendering (" +
103
- origin_of(target) +
104
- ' is not ' +
105
- origin_of(url) +
106
- ')'
107
- );
108
- }
109
-
110
- const credentials = init?.credentials ?? request.credentials ?? 'same-origin';
111
- if (credentials !== 'omit') {
112
- const cookie = Object.entries(req.cookies ?? {})
113
- .map(([name, value]) => name + '=' + value)
114
- .join('; ');
115
- if (cookie) request.headers.set('cookie', cookie);
116
- }
117
-
118
- const headers = {};
119
- request.headers.forEach((value, name) => {
120
- headers[name] = value;
121
- });
122
-
123
- const envelope = { method: request.method || 'GET', url: target.href, headers };
124
- if (typeof request._body === 'string') envelope.body = request._body;
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
+ };
125
88
 
126
- const raw = await globalThis.__skgo_fetch(JSON.stringify(envelope));
127
- const answer = JSON.parse(raw);
128
- if (answer.error) throw new TypeError(answer.error);
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
+ };
129
95
 
130
- return Promise.resolve(
131
- new Response(answer.response.body ?? '', {
132
- status: answer.response.status,
133
- headers: answer.response.headers
134
- })
135
- );
136
- };
137
- }
96
+ // Statuses a real Response cannot carry a body for (runtime/server/constants.js).
97
+ const NULL_BODY = [101, 204, 205, 304];
138
98
 
139
99
  /**
140
- * Same-origin, spelled out rather than taken from `origin`. The engine's URL is
141
- * Go's (goja_nodejs, over net/url) and its `origin` is scheme and hostname with
142
- * the port left out, which would make a page on :8080 and a URL on :9999 look
143
- * like the same site — and this is the one comparison standing between a
144
- * cross-origin fetch and Go's own handler for the path. Protocol, hostname and
145
- * port are each exactly what the standard compares.
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.
146
104
  *
147
- * @param {URL} a
148
- * @param {URL} b
105
+ * @param {Set<string>} serialized
149
106
  */
150
- function same_origin(a, b) {
151
- return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port;
107
+ function resolve_options(serialized) {
108
+ return {
109
+ filterSerializedResponseHeaders: (name, value) => serialized.has(name + ':' + value)
110
+ };
152
111
  }
153
112
 
154
113
  /**
155
- * What `url.origin` would say if this engine's URL reported it the way the
156
- * standard does. Only the refusal message above needs it.
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.
157
119
  *
120
+ * @param {object} req
158
121
  * @param {URL} url
122
+ * @param {Set<string>} serialized
159
123
  */
160
- function origin_of(url) {
161
- return url.host ? url.protocol + '//' + url.host : 'null';
124
+ function create_fetch(req, url, serialized) {
125
+ return async function (input, init) {
126
+ const request = input instanceof Request ? input : new Request(new URL(input, url).href, init);
127
+ const target = new URL(request.url, url);
128
+ const headers = {};
129
+ request.headers.forEach((value, name) => { headers[name] = value; });
130
+ const envelope = {
131
+ method: request.method || 'GET', url: target.href, headers,
132
+ credentials: request.credentials, mode: request.mode
133
+ };
134
+ if (request.body !== null) {
135
+ const bytes = new Uint8Array(await request._consume());
136
+ if (bytes.length) envelope.bodyBase64 = bytes_to_base64(bytes);
137
+ }
138
+ const answer = JSON.parse(await globalThis.__skgo_fetch(JSON.stringify(envelope)));
139
+ if (answer.error) throw new TypeError(answer.error);
140
+ const value = answer.response;
141
+ for (const [name, header, allowed] of value.headers ?? []) {
142
+ if (allowed) serialized.add(name + ':' + header);
143
+ }
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
+ });
153
+ };
162
154
  }
163
155
 
164
156
  /**
@@ -167,7 +159,7 @@ function origin_of(url) {
167
159
  * route throw. Go owns the real request; the only I/O anything here performs
168
160
  * is `fetch`'s in-process call back into Go for one of the app's own routes.
169
161
  */
170
- function make_event(req, url) {
162
+ function make_event(req, url, serialized) {
171
163
  return {
172
164
  cookies: {
173
165
  get: (name) => (req.cookies ?? {})[name],
@@ -176,12 +168,12 @@ function make_event(req, url) {
176
168
  delete: () => {},
177
169
  serialize: () => ''
178
170
  },
179
- fetch: create_fetch(req, url),
171
+ fetch: create_fetch(req, url, serialized),
180
172
  getClientAddress: () => req.client_address ?? '127.0.0.1',
181
173
  locals: {},
182
174
  params: req.params ?? {},
183
175
  platform: undefined,
184
- request: { headers: { get: () => null }, method: 'GET' },
176
+ request: { headers: new Headers(req.headers), method: req.method ?? 'GET' },
185
177
  route: { id: req.route_id ?? null },
186
178
  setHeaders: () => {},
187
179
  url,
@@ -218,7 +210,7 @@ function node_data(node) {
218
210
  });
219
211
  }
220
212
 
221
- async function build_props(req, url, event, state) {
213
+ async function build_props(req, url, event, state, fetched, serialized) {
222
214
  const form = req.form ? devalue.parse(req.form, decoders) : null;
223
215
  const page = {
224
216
  error: req.error ?? null,
@@ -261,9 +253,7 @@ async function build_props(req, url, event, state) {
261
253
  parent: async () => parent_data,
262
254
  depends: () => {},
263
255
  untrack: (fn) => fn(),
264
- fetch: () => {
265
- throw new Error('skgo: universal load fetch is not supported during rendering');
266
- },
256
+ fetch: create_universal_fetch(event, undefined, fetched, req.csr, resolve_options(serialized)),
267
257
  setHeaders: () => {
268
258
  throw new Error('skgo: universal load setHeaders is not supported during rendering');
269
259
  }
@@ -363,15 +353,18 @@ globalThis.__skgo_render = function (req_json) {
363
353
  status: 200,
364
354
  error: null,
365
355
  head: '',
366
- body: ''
356
+ body: '',
357
+ fetched: ''
367
358
  };
368
359
 
369
360
  try {
370
361
  const req = JSON.parse(req_json);
371
362
  const url = new URL(req.url);
372
363
  const state = make_state();
373
- const event = make_event(req, url);
374
- const props_promise = build_props(req, url, event, state);
364
+ const serialized = new Set();
365
+ const event = make_event(req, url, serialized);
366
+ const fetched = [];
367
+ const props_promise = build_props(req, url, event, state, fetched, serialized);
375
368
  const promise = props_promise.then((props) => {
376
369
  result.status = props.page.status;
377
370
  result.error = req.error ?? null;
@@ -417,6 +410,9 @@ globalThis.__skgo_render = function (req_json) {
417
410
  (rendered) => {
418
411
  result.head = rendered.head;
419
412
  result.body = rendered.body;
413
+ result.fetched = fetched
414
+ .map((item) => serialize_data(item, resolve_options(serialized).filterSerializedResponseHeaders))
415
+ .join("\n\t\t\t");
420
416
  result.done = true;
421
417
  },
422
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.
@@ -132,6 +132,10 @@ function kitAliases(root) {
132
132
  'skgo:kit/remote': join(runtime, 'app/server/remote/index.js'),
133
133
  'skgo:kit/shared': join(runtime, 'shared.js'),
134
134
  'skgo:kit/transport': join(runtime, 'app/internal/transport.js'),
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'),
135
139
  'skgo:kit/props': join(runtime, 'props.svelte.js'),
136
140
  'skgo:kit/root': join(runtime, 'components/root.svelte'),
137
141
  // kit's own `$app/paths` server implementation, which app-paths.js
@@ -165,8 +169,9 @@ function bare(id) {
165
169
  return !id.startsWith('.') && !id.startsWith('\0') && !id.startsWith('/') && !isAbsolute(id);
166
170
  }
167
171
 
168
- /** The constructs goja's parser rejects, and the only reason to lower a module. */
169
- 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/;
170
175
 
171
176
  /**
172
177
  * The goja environment: the vite plugin that declares it, and the call that
@@ -484,7 +489,7 @@ export function nodeTable(root, nodes) {
484
489
  * @param {string} id
485
490
  * @param {{ lowered?: Set<string>, helpers?: string }} options
486
491
  */
487
- function engineTransform(code, id, { lowered, helpers } = {}) {
492
+ export function engineTransform(code, id, { lowered, helpers } = {}) {
488
493
  let out = code;
489
494
  // svelte/src/internal/server/crypto.js hides a `node:crypto` import behind
490
495
  // a variable so bundlers cannot resolve it. goja rejects the syntax
@@ -495,7 +500,7 @@ function engineTransform(code, id, { lowered, helpers } = {}) {
495
500
  '=> Promise.reject(new Error("skgo: no dynamic import in the SSR engine"))'
496
501
  );
497
502
  }
498
- if (!UNPARSEABLE.test(out)) {
503
+ if (!needsLowering(out, id)) {
499
504
  return out === code ? null : { code: out, map: null };
500
505
  }
501
506
  lowered?.add(id);
@@ -510,6 +515,30 @@ function engineTransform(code, id, { lowered, helpers } = {}) {
510
515
  return { code: lower, map: null };
511
516
  }
512
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
+
513
542
  /**
514
543
  * Where oxc's lowering helpers live, resolved the way dev has to resolve them:
515
544
  * from vite rather than from the app. Under pnpm `@oxc-project/runtime` is a
@@ -786,6 +815,13 @@ export function gojaDevEnvironment({ outDir = '.svelte-kit' } = {}) {
786
815
  // temporary version forever.
787
816
  changed.push(file);
788
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
+
789
825
  const routeFile = normalize(file).startsWith(normalize(kit.files.routes) + '/');
790
826
  if (routeFile && (event === 'add' || event === 'unlink')) manifestDirty = true;
791
827
  const name = relative(kit.files.routes, file).split(/[\\/]/).at(-1);
@@ -957,6 +993,138 @@ export function gojaDevEnvironment({ outDir = '.svelte-kit' } = {}) {
957
993
  return plugin;
958
994
  }
959
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
+
960
1128
  const OXC_HELPERS = '@oxc-project/runtime/helpers/';
961
1129
 
962
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,12 +38,14 @@ 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
- for (const [k, v] of init) this.set(k, v);
46
+ for (const [k, v] of init) this.append(k, v);
43
47
  } else if (init && typeof init === 'object') {
44
- for (const k of Object.keys(init)) this.set(k, init[k]);
48
+ for (const k of Object.keys(init)) this.append(k, init[k]);
45
49
  }
46
50
  }
47
51
  set(name, value) {
@@ -49,85 +53,315 @@ 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
- for (const [k, v] of this._) fn.call(thisArg, v, k, this);
87
+ for (const [k, v] of this.entries()) fn.call(thisArg, v, k, this);
70
88
  }
71
89
  keys() {
72
- return this._.keys();
90
+ return Array.from(this.entries(), ([k]) => k)[Symbol.iterator]();
73
91
  }
74
92
  values() {
75
- return this._.values();
93
+ return Array.from(this.entries(), ([, v]) => v)[Symbol.iterator]();
76
94
  }
77
95
  entries() {
78
- return this._.entries();
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
- return this._.entries();
102
+ return this.entries();
82
103
  }
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'); }
339
+ clone() {
340
+ return new Request(this);
341
+ }
114
342
  };
115
343
  }
116
344
 
117
345
  if (typeof globalThis.Response === 'undefined') {
118
- globalThis.Response = class Response {
346
+ globalThis.Response = class Response extends Body {
119
347
  constructor(body, init = {}) {
120
- this._body = body ?? '';
348
+ super();
349
+ this._source = body ?? null;
350
+ this._used = false;
121
351
  this.status = init.status ?? 200;
122
352
  this.statusText = init.statusText ?? '';
123
- this.headers = init.headers instanceof Headers ? init.headers : new Headers(init.headers);
353
+ this.headers = new Headers(init.headers);
124
354
  this.ok = this.status >= 200 && this.status < 300;
355
+ this.url = '';
356
+ this.redirected = false;
357
+ this.type = 'default';
125
358
  }
126
- async text() { return this._body; }
127
- async json() { return JSON.parse(this._body); }
128
- async arrayBuffer() { return new TextEncoder().encode(this._body).buffer; }
129
359
  clone() {
130
- return new Response(this._body, { status: this.status, statusText: this.statusText, headers: this.headers });
360
+ return new Response(this._cloneSource(), {
361
+ status: this.status,
362
+ statusText: this.statusText,
363
+ headers: this.headers
364
+ });
131
365
  }
132
366
  };
133
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