Skip to content

Commit 8daf4a4

Browse files
Add fetch to RequestEvent (#7113)
* move fetch.js up one level * move cookie logic out of fetch.js and into cookie.js * move response wrapping inside load_data * return fetch implementation from create_fetch * move fetch onto RequestEvent * defocus test * prevent infinite loops * add changeset * lint * Update packages/kit/src/runtime/server/index.js Co-authored-by: repsac <[email protected]> Co-authored-by: repsac <[email protected]>
1 parent 25313d7 commit 8daf4a4

File tree

18 files changed

+244
-223
lines changed

18 files changed

+244
-223
lines changed

.changeset/curvy-suits-sleep.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
Add `fetch` to `RequestEvent`

packages/kit/src/runtime/server/cookie.js

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ import { parse, serialize } from 'cookie';
55
* @param {URL} url
66
*/
77
export function get_cookies(request, url) {
8-
/** @type {Map<string, import('./page/types').Cookie>} */
9-
const new_cookies = new Map();
8+
const header = request.headers.get('cookie') ?? '';
9+
10+
const initial_cookies = parse(header);
11+
12+
/** @type {Record<string, import('./page/types').Cookie>} */
13+
const new_cookies = {};
1014

1115
/** @type {import('cookie').CookieSerializeOptions} */
1216
const defaults = {
@@ -27,7 +31,7 @@ export function get_cookies(request, url) {
2731
* @param {import('cookie').CookieParseOptions} opts
2832
*/
2933
get(name, opts) {
30-
const c = new_cookies.get(name);
34+
const c = new_cookies[name];
3135
if (
3236
c &&
3337
domain_matches(url.hostname, c.options.domain) &&
@@ -37,7 +41,7 @@ export function get_cookies(request, url) {
3741
}
3842

3943
const decode = opts?.decode || decodeURIComponent;
40-
const req_cookies = parse(request.headers.get('cookie') ?? '', { decode });
44+
const req_cookies = parse(header, { decode });
4145
return req_cookies[name]; // the decoded string or undefined
4246
},
4347

@@ -47,30 +51,30 @@ export function get_cookies(request, url) {
4751
* @param {import('cookie').CookieSerializeOptions} opts
4852
*/
4953
set(name, value, opts = {}) {
50-
new_cookies.set(name, {
54+
new_cookies[name] = {
5155
name,
5256
value,
5357
options: {
5458
...defaults,
5559
...opts
5660
}
57-
});
61+
};
5862
},
5963

6064
/**
6165
* @param {string} name
6266
* @param {import('cookie').CookieSerializeOptions} opts
6367
*/
6468
delete(name, opts = {}) {
65-
new_cookies.set(name, {
69+
new_cookies[name] = {
6670
name,
6771
value: '',
6872
options: {
6973
...defaults,
7074
...opts,
7175
maxAge: 0
7276
}
73-
});
77+
};
7478
},
7579

7680
/**
@@ -86,7 +90,42 @@ export function get_cookies(request, url) {
8690
}
8791
};
8892

89-
return { cookies, new_cookies };
93+
/**
94+
* @param {URL} destination
95+
* @param {string | null} header
96+
*/
97+
function get_cookie_header(destination, header) {
98+
/** @type {Record<string, string>} */
99+
const combined_cookies = {};
100+
101+
// cookies sent by the user agent have lowest precedence
102+
for (const name in initial_cookies) {
103+
combined_cookies[name] = initial_cookies[name];
104+
}
105+
106+
// cookies previous set during this event with cookies.set have higher precedence
107+
for (const key in new_cookies) {
108+
const cookie = new_cookies[key];
109+
if (!domain_matches(destination.hostname, cookie.options.domain)) continue;
110+
if (!path_matches(destination.pathname, cookie.options.path)) continue;
111+
112+
combined_cookies[cookie.name] = cookie.value;
113+
}
114+
115+
// explicit header has highest precedence
116+
if (header) {
117+
const parsed = parse(header);
118+
for (const name in parsed) {
119+
combined_cookies[name] = parsed[name];
120+
}
121+
}
122+
123+
return Object.entries(combined_cookies)
124+
.map(([name, value]) => `${name}=${value}`)
125+
.join('; ');
126+
}
127+
128+
return { cookies, new_cookies, get_cookie_header };
90129
}
91130

92131
/**

packages/kit/src/runtime/server/cookie.spec.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ test('a cookie should not be present after it is deleted', () => {
6464
test('default values when set is called', () => {
6565
const { cookies, new_cookies } = cookies_setup();
6666
cookies.set('a', 'b');
67-
const opts = new_cookies.get('a')?.options;
67+
const opts = new_cookies['a']?.options;
6868
assert.equal(opts?.secure, true);
6969
assert.equal(opts?.httpOnly, true);
7070
assert.equal(opts?.path, undefined);
@@ -74,14 +74,14 @@ test('default values when set is called', () => {
7474
test('default values when on localhost', () => {
7575
const { cookies, new_cookies } = cookies_setup(true);
7676
cookies.set('a', 'b');
77-
const opts = new_cookies.get('a')?.options;
77+
const opts = new_cookies['a']?.options;
7878
assert.equal(opts?.secure, false);
7979
});
8080

8181
test('overridden defaults when set is called', () => {
8282
const { cookies, new_cookies } = cookies_setup();
8383
cookies.set('a', 'b', { secure: false, httpOnly: false, sameSite: 'strict', path: '/a/b/c' });
84-
const opts = new_cookies.get('a')?.options;
84+
const opts = new_cookies['a']?.options;
8585
assert.equal(opts?.secure, false);
8686
assert.equal(opts?.httpOnly, false);
8787
assert.equal(opts?.path, '/a/b/c');
@@ -91,7 +91,7 @@ test('overridden defaults when set is called', () => {
9191
test('default values when delete is called', () => {
9292
const { cookies, new_cookies } = cookies_setup();
9393
cookies.delete('a');
94-
const opts = new_cookies.get('a')?.options;
94+
const opts = new_cookies['a']?.options;
9595
assert.equal(opts?.secure, true);
9696
assert.equal(opts?.httpOnly, true);
9797
assert.equal(opts?.path, undefined);
@@ -102,7 +102,7 @@ test('default values when delete is called', () => {
102102
test('overridden defaults when delete is called', () => {
103103
const { cookies, new_cookies } = cookies_setup();
104104
cookies.delete('a', { secure: false, httpOnly: false, sameSite: 'strict', path: '/a/b/c' });
105-
const opts = new_cookies.get('a')?.options;
105+
const opts = new_cookies['a']?.options;
106106
assert.equal(opts?.secure, false);
107107
assert.equal(opts?.httpOnly, false);
108108
assert.equal(opts?.path, '/a/b/c');
@@ -113,15 +113,15 @@ test('overridden defaults when delete is called', () => {
113113
test('cannot override maxAge on delete', () => {
114114
const { cookies, new_cookies } = cookies_setup();
115115
cookies.delete('a', { maxAge: 1234 });
116-
const opts = new_cookies.get('a')?.options;
116+
const opts = new_cookies['a']?.options;
117117
assert.equal(opts?.maxAge, 0);
118118
});
119119

120120
test('last cookie set with the same name wins', () => {
121121
const { cookies, new_cookies } = cookies_setup();
122122
cookies.set('a', 'foo');
123123
cookies.set('a', 'bar');
124-
const entry = new_cookies.get('a');
124+
const entry = new_cookies['a'];
125125
assert.equal(entry?.value, 'bar');
126126
});
127127

@@ -130,8 +130,8 @@ test('cookie names are case sensitive', () => {
130130
// not that one should do this, but we follow the spec...
131131
cookies.set('a', 'foo');
132132
cookies.set('A', 'bar');
133-
const entrya = new_cookies.get('a');
134-
const entryA = new_cookies.get('A');
133+
const entrya = new_cookies['a'];
134+
const entryA = new_cookies['A'];
135135
assert.equal(entrya?.value, 'foo');
136136
assert.equal(entryA?.value, 'bar');
137137
});

packages/kit/src/runtime/server/page/fetch.js renamed to packages/kit/src/runtime/server/fetch.js

Lines changed: 17 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,25 @@
1-
import * as cookie from 'cookie';
21
import * as set_cookie_parser from 'set-cookie-parser';
3-
import { respond } from '../index.js';
4-
import { domain_matches, path_matches } from '../cookie.js';
2+
import { respond } from './index.js';
53

64
/**
75
* @param {{
86
* event: import('types').RequestEvent;
97
* options: import('types').SSROptions;
108
* state: import('types').SSRState;
11-
* route: import('types').SSRRoute | import('types').SSRErrorPage;
12-
* prerender_default?: import('types').PrerenderOption;
13-
* resolve_opts: import('types').RequiredResolveOptions;
9+
* get_cookie_header: (url: URL, header: string | null) => string;
1410
* }} opts
11+
* @returns {typeof fetch}
1512
*/
16-
export function create_fetch({ event, options, state, route, prerender_default, resolve_opts }) {
17-
/** @type {import('./types').Fetched[]} */
18-
const fetched = [];
19-
20-
const initial_cookies = cookie.parse(event.request.headers.get('cookie') || '');
21-
22-
/** @type {import('./types').Cookie[]} */
23-
const set_cookies = [];
24-
25-
/**
26-
* @param {URL} url
27-
* @param {string | null} header
28-
*/
29-
function get_cookie_header(url, header) {
30-
/** @type {Record<string, string>} */
31-
const new_cookies = {};
32-
33-
for (const cookie of set_cookies) {
34-
if (!domain_matches(url.hostname, cookie.options.domain)) continue;
35-
if (!path_matches(url.pathname, cookie.options.path)) continue;
36-
37-
new_cookies[cookie.name] = cookie.value;
38-
}
39-
40-
// cookies from explicit `cookie` header take precedence over cookies previously set
41-
// during this load with `set-cookie`, which take precedence over the cookies
42-
// sent by the user agent
43-
const combined_cookies = {
44-
...initial_cookies,
45-
...new_cookies,
46-
...cookie.parse(header ?? '')
47-
};
48-
49-
return Object.entries(combined_cookies)
50-
.map(([name, value]) => `${name}=${value}`)
51-
.join('; ');
52-
}
53-
54-
/** @type {typeof fetch} */
55-
const fetcher = async (info, init) => {
13+
export function create_fetch({ event, options, state, get_cookie_header }) {
14+
return async (info, init) => {
5615
const request = normalize_fetch_input(info, init, event.url);
5716

5817
const request_body = init?.body;
5918

6019
/** @type {import('types').PrerenderDependency} */
6120
let dependency;
6221

63-
const response = await options.hooks.handleFetch({
22+
return await options.hooks.handleFetch({
6423
event,
6524
request,
6625
fetch: async (info, init) => {
@@ -174,11 +133,7 @@ export function create_fetch({ event, options, state, route, prerender_default,
174133
throw new Error('Request body must be a string or TypedArray');
175134
}
176135

177-
response = await respond(request, options, {
178-
prerender_default,
179-
...state,
180-
initiator: route
181-
});
136+
response = await respond(request, options, state);
182137

183138
if (state.prerendering) {
184139
dependency = { response, body: null };
@@ -187,104 +142,22 @@ export function create_fetch({ event, options, state, route, prerender_default,
187142

188143
const set_cookie = response.headers.get('set-cookie');
189144
if (set_cookie) {
190-
set_cookies.push(
191-
...set_cookie_parser.splitCookiesString(set_cookie).map((str) => {
192-
const { name, value, ...options } = set_cookie_parser.parseString(str);
193-
// options.sameSite is string, something more specific is required - type cast is safe
194-
return /** @type{import('./types').Cookie} */ ({ name, value, options });
195-
})
196-
);
197-
}
198-
199-
return response;
200-
}
201-
});
202-
203-
const proxy = new Proxy(response, {
204-
get(response, key, _receiver) {
205-
async function text() {
206-
const body = await response.text();
207-
208-
if (!body || typeof body === 'string') {
209-
const status_number = Number(response.status);
210-
if (isNaN(status_number)) {
211-
throw new Error(
212-
`response.status is not a number. value: "${
213-
response.status
214-
}" type: ${typeof response.status}`
215-
);
216-
}
217-
218-
fetched.push({
219-
url: request.url.startsWith(event.url.origin)
220-
? request.url.slice(event.url.origin.length)
221-
: request.url,
222-
method: request.method,
223-
request_body: /** @type {string | ArrayBufferView | undefined} */ (request_body),
224-
response_body: body,
225-
response: response
226-
});
227-
228-
// ensure that excluded headers can't be read
229-
const get = response.headers.get;
230-
response.headers.get = (key) => {
231-
const lower = key.toLowerCase();
232-
const value = get.call(response.headers, lower);
233-
if (value && !lower.startsWith('x-sveltekit-')) {
234-
const included = resolve_opts.filterSerializedResponseHeaders(lower, value);
235-
if (!included) {
236-
throw new Error(
237-
`Failed to get response header "${lower}" — it must be included by the \`filterSerializedResponseHeaders\` option: https://kit.svelte.dev/docs/hooks#handle`
238-
);
239-
}
240-
}
241-
242-
return value;
243-
};
145+
for (const str of set_cookie_parser.splitCookiesString(set_cookie)) {
146+
const { name, value, ...options } = set_cookie_parser.parseString(str);
147+
148+
// options.sameSite is string, something more specific is required - type cast is safe
149+
event.cookies.set(
150+
name,
151+
value,
152+
/** @type {import('cookie').CookieSerializeOptions} */ (options)
153+
);
244154
}
245-
246-
if (dependency) {
247-
dependency.body = body;
248-
}
249-
250-
return body;
251155
}
252156

253-
if (key === 'arrayBuffer') {
254-
return async () => {
255-
const buffer = await response.arrayBuffer();
256-
257-
if (dependency) {
258-
dependency.body = new Uint8Array(buffer);
259-
}
260-
261-
// TODO should buffer be inlined into the page (albeit base64'd)?
262-
// any conditions in which it shouldn't be?
263-
264-
return buffer;
265-
};
266-
}
267-
268-
if (key === 'text') {
269-
return text;
270-
}
271-
272-
if (key === 'json') {
273-
return async () => {
274-
return JSON.parse(await text());
275-
};
276-
}
277-
278-
// TODO arrayBuffer?
279-
280-
return Reflect.get(response, key, response);
157+
return response;
281158
}
282159
});
283-
284-
return proxy;
285160
};
286-
287-
return { fetcher, fetched, cookies: set_cookies };
288161
}
289162

290163
/**

0 commit comments

Comments
 (0)