@sveltejs/kit
Version:
SvelteKit is the fastest way to build Svelte apps
335 lines (280 loc) • 9.87 kB
JavaScript
import { parseCookie, parseSetCookie, stringifySetCookie } from 'cookie';
import { DEV } from 'esm-env';
import { normalize_path, resolve } from '../../utils/url.js';
import { add_data_suffix } from '../pathname.js';
import { text_encoder } from '../utils.js';
import * as e from '../../messages/server-errors.js';
import * as w from '../../messages/server-warnings.js';
/**
* Tracks all cookies set during dev mode so we can emit warnings
* when we detect that there's likely cookie misusage due to wrong paths
*
* @type {Record<string, Set<string>>} */
const cookie_paths = {};
/**
* Cookies whose name and value combined are larger than this size are
* discarded by browsers. This is the limit codified for the name/value pair
* in RFC 6265bis: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-20#section-5.6-7.5.1
*/
const MAX_COOKIE_SIZE = 4096;
/**
* Generates a unique key for a cookie based on its domain, path, and name in
* the format: `<domain>/<path>?<name>`.
* If domain is undefined, it will be omitted.
* For example: `/?name`, `example.com/foo?name`.
*
* @param {string | undefined} domain
* @param {string} path
* @param {string} name
* @returns {string}
*/
function generate_cookie_key(domain, path, name) {
return `${domain || ''}${path}?${encodeURIComponent(name)}`;
}
/**
* @param {Request} request
* @param {URL} url
*/
export function get_cookies(request, url) {
const header = request.headers.get('cookie') ?? '';
const initial_cookies = /** @type {Record<string, string>} */ (
parseCookie(header, { decode: (value) => value })
);
/** @type {ReturnType<typeof parseCookie> | undefined} */
let default_cookies;
/**
* The header never changes during the request, so the default-decode parse is cached
* @param {import('cookie').ParseOptions} [opts]
*/
function parse_header(opts) {
return opts?.decode ? parseCookie(header, opts) : (default_cookies ??= parseCookie(header));
}
/** @param {import('./page/types.js').Cookie} cookie */
function matches_url(cookie) {
return (
domain_matches(url.hostname, cookie.options.domain) &&
path_matches(url.pathname, cookie.options.path)
);
}
/** @type {string | undefined} */
let normalized_url;
/** @type {Map<string, import('./page/types.js').Cookie>} */
const new_cookies = new Map();
/** @type {Omit<import('cookie').SetCookie, 'name' | 'value'>} */
const defaults = {
httpOnly: true,
path: '/',
sameSite: 'lax',
secure: !__SVELTEKIT_DEV__ && !(url.hostname === 'localhost' && url.protocol === 'http:')
};
/** @type {import('@sveltejs/kit').Cookies} */
const cookies = {
// The JSDoc param annotations appearing below for get, set and delete
// are necessary to expose the `cookie` library types to
// typescript users. `@type {import('@sveltejs/kit').Cookies}` above is not
// sufficient to do so.
get(name, opts) {
// Look for the most specific matching cookie from new_cookies
/** @type {import('./page/types.js').Cookie | undefined} */
let best_match;
for (const c of new_cookies.values()) {
if (
c.name === name &&
matches_url(c) &&
(!best_match || c.options.path.length > best_match.options.path.length)
) {
best_match = c;
}
}
if (best_match) {
return best_match.options.maxAge === 0 ? undefined : best_match.value;
}
const cookie = parse_header(opts)[name]; // the decoded string or undefined
// in development, if the cookie was set during this session with `cookies.set`,
// but at a different path, warn the user. (ignore cookies from request headers,
// since we don't know which path they were set at)
if (DEV && !cookie) {
const paths = Array.from(cookie_paths[name] ?? []).filter((path) => {
// we only care about paths that are _more_ specific than the current path
return path_matches(path, url.pathname) && path !== url.pathname;
});
if (paths.length > 0) {
w.cookie_path_mismatch({ name, pathname: url.pathname, paths: conjoin([...paths]) });
}
}
return cookie;
},
getAll(opts) {
// copy, so the cached parse isn't mutated below
const cookies = { ...parse_header(opts) };
// Group cookies by name and find the most specific one for each name
const lookup = new Map();
for (const c of new_cookies.values()) {
if (matches_url(c)) {
const existing = lookup.get(c.name);
// If no existing cookie or this one has a more specific (longer) path, use this one
if (!existing || c.options.path.length > existing.options.path.length) {
lookup.set(c.name, c);
}
}
}
// Add the most specific cookies to the result
for (const c of lookup.values()) {
// tombstones (deleted cookies) shadow request-header cookies,
// mirroring the behavior of `get()`
if (c.options.maxAge === 0) {
delete cookies[c.name];
} else {
cookies[c.name] = c.value;
}
}
return /** @type {Array<{ name: string; value: string }>} */ (
Object.entries(cookies)
.filter(([, value]) => value != null)
.map(([name, value]) => ({ name, value }))
);
},
set(name, value, options) {
set_internal(name, value, { ...defaults, ...options });
},
delete(name, options) {
cookies.set(name, '', { ...options, maxAge: 0 });
},
parse: parseSetCookie,
serialize(name, value, { encode, ...options } = {}) {
let path = options.path ?? '/';
if (!options.domain || options.domain === url.hostname) {
if (!normalized_url) {
e.cookies_serialize_before_route();
}
path = resolve(normalized_url, path);
}
return stringifySetCookie({ name, value, ...defaults, ...options, path }, { encode });
}
};
/**
* @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
...initial_cookies
};
// cookies previous set during this event with cookies.set have higher precedence
for (const cookie of new_cookies.values()) {
if (!domain_matches(destination.hostname, cookie.options.domain)) continue;
if (!path_matches(destination.pathname, cookie.options.path)) continue;
const encoder = cookie.options.encode || encodeURIComponent;
combined_cookies[cookie.name] = encoder(cookie.value);
}
// explicit header has highest precedence
if (header) {
const parsed = /** @type {Record<string, string>} */ (
parseCookie(header, { decode: (value) => value })
);
for (const name in parsed) {
combined_cookies[name] = parsed[name];
}
}
return Object.entries(combined_cookies)
.map(([name, value]) => `${name}=${value}`)
.join('; ');
}
/** @type {Array<() => void>} */
const internal_queue = [];
/**
* @param {string} name
* @param {string} value
* @param {import('cookie').SerializeOptions} options
*/
function set_internal(name, value, options) {
if (!normalized_url) {
internal_queue.push(() => set_internal(name, value, options));
return;
}
let path = options.path ?? '/';
if (!options.domain || options.domain === url.hostname) {
path = resolve(normalized_url, path);
}
// Generate unique key for cookie storage
const cookie_key = generate_cookie_key(options.domain, path, name);
const cookie = { name, value, options: { ...options, path } };
new_cookies.set(cookie_key, cookie);
if (DEV) {
const size =
// only the name/value pair counts towards MAX_COOKIE_SIZE, not the other attributes
text_encoder.encode(name).byteLength +
text_encoder.encode((options.encode ?? encodeURIComponent)(value)).byteLength;
if (size > MAX_COOKIE_SIZE) {
e.cookie_too_large({ name });
}
cookie_paths[name] ??= new Set();
if (!value) {
cookie_paths[name].delete(path);
} else {
cookie_paths[name].add(path);
}
}
}
/**
* @param {import('types').TrailingSlash} trailing_slash
*/
function set_trailing_slash(trailing_slash) {
normalized_url = normalize_path(url.pathname, trailing_slash);
internal_queue.forEach((fn) => fn());
}
return { cookies, new_cookies, get_cookie_header, set_internal, set_trailing_slash };
}
/**
* @param {string} hostname
* @param {string} [constraint]
*/
export function domain_matches(hostname, constraint) {
if (!constraint) return true;
const normalized = constraint[0] === '.' ? constraint.slice(1) : constraint;
if (hostname === normalized) return true;
return hostname.endsWith('.' + normalized);
}
/**
* @param {string} path
* @param {string} [constraint]
*/
export function path_matches(path, constraint) {
if (!constraint) return true;
const normalized = constraint.endsWith('/') ? constraint.slice(0, -1) : constraint;
if (path === normalized) return true;
return path.startsWith(normalized + '/');
}
/**
* @param {Headers} headers
* @param {MapIterator<import('./page/types.js').Cookie>} cookies
*/
export function add_cookies_to_headers(headers, cookies) {
for (const new_cookie of cookies) {
const {
name,
value,
options: { encode, ...options }
} = new_cookie;
headers.append('set-cookie', stringifySetCookie({ name, value, ...options }, { encode }));
// special case — for routes ending with .html, the route data lives in a sibling
// `.html__data.json` file rather than a child `/__data.json` file, which means
// we need to duplicate the cookie
if (options.path.endsWith('.html')) {
const path = add_data_suffix(options.path);
headers.append(
'set-cookie',
stringifySetCookie({ name, value, ...options, path }, { encode })
);
}
}
}
/**
* @param {string[]} array
*/
function conjoin(array) {
if (array.length <= 2) return array.join(' and ');
return `${array.slice(0, -1).join(', ')} and ${array.at(-1)}`;
}