Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add fetch to RequestEvent #7113

Merged
merged 10 commits into from
Oct 4, 2022
Merged
5 changes: 5 additions & 0 deletions .changeset/curvy-suits-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

Add `fetch` to `RequestEvent`
57 changes: 48 additions & 9 deletions packages/kit/src/runtime/server/cookie.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import { parse, serialize } from 'cookie';
* @param {URL} url
*/
export function get_cookies(request, url) {
/** @type {Map<string, import('./page/types').Cookie>} */
const new_cookies = new Map();
const header = request.headers.get('cookie') ?? '';

const initial_cookies = parse(header);

/** @type {Record<string, import('./page/types').Cookie>} */
const new_cookies = {};

/** @type {import('cookie').CookieSerializeOptions} */
const defaults = {
Expand All @@ -27,7 +31,7 @@ export function get_cookies(request, url) {
* @param {import('cookie').CookieParseOptions} opts
*/
get(name, opts) {
const c = new_cookies.get(name);
const c = new_cookies[name];
if (
c &&
domain_matches(url.hostname, c.options.domain) &&
Expand All @@ -37,7 +41,7 @@ export function get_cookies(request, url) {
}

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

Expand All @@ -47,30 +51,30 @@ export function get_cookies(request, url) {
* @param {import('cookie').CookieSerializeOptions} opts
*/
set(name, value, opts = {}) {
new_cookies.set(name, {
new_cookies[name] = {
name,
value,
options: {
...defaults,
...opts
}
});
};
},

/**
* @param {string} name
* @param {import('cookie').CookieSerializeOptions} opts
*/
delete(name, opts = {}) {
new_cookies.set(name, {
new_cookies[name] = {
name,
value: '',
options: {
...defaults,
...opts,
maxAge: 0
}
});
};
},

/**
Expand All @@ -86,7 +90,42 @@ export function get_cookies(request, url) {
}
};

return { cookies, new_cookies };
/**
* @param {URL} destination
* @param {string | null} header
*/
function get_cookie_header(destination, header) {
/** @type {Record<string, string>} */
const combined_cookies = {};

// cookies sent by the user agent have lowest precedence
for (const name in initial_cookies) {
combined_cookies[name] = initial_cookies[name];
}

// cookies previous set during this event with cookies.set have higher precedence
for (const key in new_cookies) {
const cookie = new_cookies[key];
if (!domain_matches(destination.hostname, cookie.options.domain)) continue;
if (!path_matches(destination.pathname, cookie.options.path)) continue;

combined_cookies[cookie.name] = cookie.value;
}

// explicit header has highest precedence
if (header) {
const parsed = parse(header);
for (const name in parsed) {
combined_cookies[name] = parsed[name];
}
}

return Object.entries(combined_cookies)
.map(([name, value]) => `${name}=${value}`)
.join('; ');
}

return { cookies, new_cookies, get_cookie_header };
}

/**
Expand Down
18 changes: 9 additions & 9 deletions packages/kit/src/runtime/server/cookie.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ test('a cookie should not be present after it is deleted', () => {
test('default values when set is called', () => {
const { cookies, new_cookies } = cookies_setup();
cookies.set('a', 'b');
const opts = new_cookies.get('a')?.options;
const opts = new_cookies['a']?.options;
assert.equal(opts?.secure, true);
assert.equal(opts?.httpOnly, true);
assert.equal(opts?.path, undefined);
Expand All @@ -74,14 +74,14 @@ test('default values when set is called', () => {
test('default values when on localhost', () => {
const { cookies, new_cookies } = cookies_setup(true);
cookies.set('a', 'b');
const opts = new_cookies.get('a')?.options;
const opts = new_cookies['a']?.options;
assert.equal(opts?.secure, false);
});

test('overridden defaults when set is called', () => {
const { cookies, new_cookies } = cookies_setup();
cookies.set('a', 'b', { secure: false, httpOnly: false, sameSite: 'strict', path: '/a/b/c' });
const opts = new_cookies.get('a')?.options;
const opts = new_cookies['a']?.options;
assert.equal(opts?.secure, false);
assert.equal(opts?.httpOnly, false);
assert.equal(opts?.path, '/a/b/c');
Expand All @@ -91,7 +91,7 @@ test('overridden defaults when set is called', () => {
test('default values when delete is called', () => {
const { cookies, new_cookies } = cookies_setup();
cookies.delete('a');
const opts = new_cookies.get('a')?.options;
const opts = new_cookies['a']?.options;
assert.equal(opts?.secure, true);
assert.equal(opts?.httpOnly, true);
assert.equal(opts?.path, undefined);
Expand All @@ -102,7 +102,7 @@ test('default values when delete is called', () => {
test('overridden defaults when delete is called', () => {
const { cookies, new_cookies } = cookies_setup();
cookies.delete('a', { secure: false, httpOnly: false, sameSite: 'strict', path: '/a/b/c' });
const opts = new_cookies.get('a')?.options;
const opts = new_cookies['a']?.options;
assert.equal(opts?.secure, false);
assert.equal(opts?.httpOnly, false);
assert.equal(opts?.path, '/a/b/c');
Expand All @@ -113,15 +113,15 @@ test('overridden defaults when delete is called', () => {
test('cannot override maxAge on delete', () => {
const { cookies, new_cookies } = cookies_setup();
cookies.delete('a', { maxAge: 1234 });
const opts = new_cookies.get('a')?.options;
const opts = new_cookies['a']?.options;
assert.equal(opts?.maxAge, 0);
});

test('last cookie set with the same name wins', () => {
const { cookies, new_cookies } = cookies_setup();
cookies.set('a', 'foo');
cookies.set('a', 'bar');
const entry = new_cookies.get('a');
const entry = new_cookies['a'];
assert.equal(entry?.value, 'bar');
});

Expand All @@ -130,8 +130,8 @@ test('cookie names are case sensitive', () => {
// not that one should do this, but we follow the spec...
cookies.set('a', 'foo');
cookies.set('A', 'bar');
const entrya = new_cookies.get('a');
const entryA = new_cookies.get('A');
const entrya = new_cookies['a'];
const entryA = new_cookies['A'];
assert.equal(entrya?.value, 'foo');
assert.equal(entryA?.value, 'bar');
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,66 +1,25 @@
import * as cookie from 'cookie';
import * as set_cookie_parser from 'set-cookie-parser';
import { respond } from '../index.js';
import { domain_matches, path_matches } from '../cookie.js';
import { respond } from './index.js';

/**
* @param {{
* event: import('types').RequestEvent;
* options: import('types').SSROptions;
* state: import('types').SSRState;
* route: import('types').SSRRoute | import('types').SSRErrorPage;
* prerender_default?: import('types').PrerenderOption;
* resolve_opts: import('types').RequiredResolveOptions;
* get_cookie_header: (url: URL, header: string | null) => string;
* }} opts
* @returns {typeof fetch}
*/
export function create_fetch({ event, options, state, route, prerender_default, resolve_opts }) {
/** @type {import('./types').Fetched[]} */
const fetched = [];

const initial_cookies = cookie.parse(event.request.headers.get('cookie') || '');

/** @type {import('./types').Cookie[]} */
const set_cookies = [];

/**
* @param {URL} url
* @param {string | null} header
*/
function get_cookie_header(url, header) {
/** @type {Record<string, string>} */
const new_cookies = {};

for (const cookie of set_cookies) {
if (!domain_matches(url.hostname, cookie.options.domain)) continue;
if (!path_matches(url.pathname, cookie.options.path)) continue;

new_cookies[cookie.name] = cookie.value;
}

// cookies from explicit `cookie` header take precedence over cookies previously set
// during this load with `set-cookie`, which take precedence over the cookies
// sent by the user agent
const combined_cookies = {
...initial_cookies,
...new_cookies,
...cookie.parse(header ?? '')
};

return Object.entries(combined_cookies)
.map(([name, value]) => `${name}=${value}`)
.join('; ');
}

/** @type {typeof fetch} */
const fetcher = async (info, init) => {
export function create_fetch({ event, options, state, get_cookie_header }) {
return async (info, init) => {
const request = normalize_fetch_input(info, init, event.url);

const request_body = init?.body;

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

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

response = await respond(request, options, {
prerender_default,
...state,
initiator: route
});
response = await respond(request, options, state);

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

const set_cookie = response.headers.get('set-cookie');
if (set_cookie) {
set_cookies.push(
...set_cookie_parser.splitCookiesString(set_cookie).map((str) => {
const { name, value, ...options } = set_cookie_parser.parseString(str);
// options.sameSite is string, something more specific is required - type cast is safe
return /** @type{import('./types').Cookie} */ ({ name, value, options });
})
);
}

return response;
}
});

const proxy = new Proxy(response, {
get(response, key, _receiver) {
async function text() {
const body = await response.text();

if (!body || typeof body === 'string') {
const status_number = Number(response.status);
if (isNaN(status_number)) {
throw new Error(
`response.status is not a number. value: "${
response.status
}" type: ${typeof response.status}`
);
}

fetched.push({
url: request.url.startsWith(event.url.origin)
? request.url.slice(event.url.origin.length)
: request.url,
method: request.method,
request_body: /** @type {string | ArrayBufferView | undefined} */ (request_body),
response_body: body,
response: response
});

// ensure that excluded headers can't be read
const get = response.headers.get;
response.headers.get = (key) => {
const lower = key.toLowerCase();
const value = get.call(response.headers, lower);
if (value && !lower.startsWith('x-sveltekit-')) {
const included = resolve_opts.filterSerializedResponseHeaders(lower, value);
if (!included) {
throw new Error(
`Failed to get response header "${lower}" — it must be included by the \`filterSerializedResponseHeaders\` option: https://kit.svelte.dev/docs/hooks#handle`
);
}
}

return value;
};
for (const str of set_cookie_parser.splitCookiesString(set_cookie)) {
const { name, value, ...options } = set_cookie_parser.parseString(str);

// options.sameSite is string, something more specific is required - type cast is safe
event.cookies.set(
name,
value,
/** @type {import('cookie').CookieSerializeOptions} */ (options)
);
}

if (dependency) {
dependency.body = body;
}

return body;
}

if (key === 'arrayBuffer') {
return async () => {
const buffer = await response.arrayBuffer();

if (dependency) {
dependency.body = new Uint8Array(buffer);
}

// TODO should buffer be inlined into the page (albeit base64'd)?
// any conditions in which it shouldn't be?

return buffer;
};
}

if (key === 'text') {
return text;
}

if (key === 'json') {
return async () => {
return JSON.parse(await text());
};
}

// TODO arrayBuffer?

return Reflect.get(response, key, response);
return response;
}
});

return proxy;
};

return { fetcher, fetched, cookies: set_cookies };
}

/**
Expand Down