Skip to content

Commit f2cdab2

Browse files
committed
merge
2 parents a0d7580 + 4922e26 commit f2cdab2

File tree

16 files changed

+152
-86
lines changed

16 files changed

+152
-86
lines changed

.changeset/grumpy-jobs-poke.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
[breaking] add API for interacting with cookies

documentation/docs/05-load.md

+3-20
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function load(event) {
1818

1919
### Input properties
2020

21-
The argument to a `load` function is a `LoadEvent` (or, for server-only `load` functions, a `ServerLoadEvent` which inherits `clientAddress`, `locals`, `platform` and `request` from `RequestEvent`). All events have the following properties:
21+
The argument to a `load` function is a `LoadEvent` (or, for server-only `load` functions, a `ServerLoadEvent` which inherits `clientAddress`, `cookies`, `locals`, `platform` and `request` from `RequestEvent`). All events have the following properties:
2222

2323
#### data
2424

@@ -221,6 +221,7 @@ export async function load({ parent, fetch }) {
221221
If you need to set headers for the response, you can do so using the `setHeaders` method. This is useful if you want the page to be cached, for example:
222222

223223
```js
224+
// @errors: 2322
224225
/// file: src/routes/blog/+page.js
225226
/** @type {import('./$types').PageLoad} */
226227
export async function load({ fetch, setHeaders }) {
@@ -240,25 +241,7 @@ export async function load({ fetch, setHeaders }) {
240241
241242
Setting the same header multiple times (even in separate `load` functions) is an error — you can only set a given header once.
242243

243-
The exception is `set-cookie`, which can be set multiple times and can be passed an array of strings:
244-
245-
```js
246-
/// file: src/routes/+layout.server.js
247-
/** @type {import('./$types').LayoutLoad} */
248-
export async function load({ setHeaders }) {
249-
setHeaders({
250-
'set-cookie': 'a=1; HttpOnly'
251-
});
252-
253-
setHeaders({
254-
'set-cookie': 'b=2; HttpOnly'
255-
});
256-
257-
setHeaders({
258-
'set-cookie': ['c=3; HttpOnly', 'd=4; HttpOnly']
259-
});
260-
}
261-
```
244+
You cannot add a `set-cookie` header with `setHeaders` — use the [`cookies`](/docs/types#sveltejs-kit-cookies) API in a server-only `load` function instead.
262245

263246
### Output
264247

documentation/docs/07-hooks.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ declare namespace App {
4040
}
4141
}
4242

43-
const getUserInformation: (cookie: string | null) => Promise<User>;
43+
const getUserInformation: (cookie: string | undefined) => Promise<User>;
4444

4545
// declare global {
4646
// const getUserInformation: (cookie: string) => Promise<User>;
@@ -50,7 +50,7 @@ const getUserInformation: (cookie: string | null) => Promise<User>;
5050
// ---cut---
5151
/** @type {import('@sveltejs/kit').Handle} */
5252
export async function handle({ event, resolve }) {
53-
event.locals.user = await getUserInformation(event.request.headers.get('cookie'));
53+
event.locals.user = await getUserInformation(event.cookies.get('sessionid'));
5454

5555
const response = await resolve(event);
5656
response.headers.set('x-custom-header', 'potato');
+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as cookie from 'cookie';
2+
3+
/**
4+
* @param {Request} request
5+
* @param {URL} url
6+
*/
7+
export function get_cookies(request, url) {
8+
const initial_cookies = cookie.parse(request.headers.get('cookie') ?? '');
9+
10+
/** @type {Array<{ name: string, value: string, options: import('cookie').CookieSerializeOptions }>} */
11+
const new_cookies = [];
12+
13+
/** @type {import('types').Cookies} */
14+
const cookies = {
15+
get(name, opts) {
16+
const decode = opts?.decode || decodeURIComponent;
17+
18+
let i = new_cookies.length;
19+
while (i--) {
20+
const cookie = new_cookies[i];
21+
22+
if (
23+
cookie.name === name &&
24+
domain_matches(url.hostname, cookie.options.domain) &&
25+
path_matches(url.pathname, cookie.options.path)
26+
) {
27+
return cookie.value;
28+
}
29+
}
30+
31+
return name in initial_cookies ? decode(initial_cookies[name]) : undefined;
32+
},
33+
set(name, value, options = {}) {
34+
new_cookies.push({
35+
name,
36+
value,
37+
options: {
38+
httpOnly: true,
39+
secure: true,
40+
...options
41+
}
42+
});
43+
},
44+
delete(name) {
45+
new_cookies.push({ name, value: '', options: { expires: new Date(0) } });
46+
}
47+
};
48+
49+
return { cookies, new_cookies };
50+
}
51+
52+
/**
53+
* @param {string} hostname
54+
* @param {string} [constraint]
55+
*/
56+
export function domain_matches(hostname, constraint) {
57+
if (!constraint) return true;
58+
59+
const normalized = constraint[0] === '.' ? constraint.slice(1) : constraint;
60+
61+
if (hostname === normalized) return true;
62+
return hostname.endsWith('.' + normalized);
63+
}
64+
65+
/**
66+
* @param {string} path
67+
* @param {string} [constraint]
68+
*/
69+
export function path_matches(path, constraint) {
70+
if (!constraint) return true;
71+
72+
const normalized = constraint.endsWith('/') ? constraint.slice(0, -1) : constraint;
73+
74+
if (path === normalized) return true;
75+
return path.startsWith(normalized + '/');
76+
}

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

+21-14
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as cookie from 'cookie';
12
import { render_endpoint } from './endpoint.js';
23
import { render_page } from './page/index.js';
34
import { render_response } from './page/render.js';
@@ -8,6 +9,7 @@ import { decode_params, disable_search, normalize_path } from '../../utils/url.j
89
import { exec } from '../../utils/routing.js';
910
import { render_data } from './data/index.js';
1011
import { DATA_SUFFIX } from '../../constants.js';
12+
import { get_cookies } from './cookie.js';
1113

1214
/* global __SVELTEKIT_ADAPTER_NAME__ */
1315

@@ -91,16 +93,16 @@ export async function respond(request, options, state) {
9193
}
9294
}
9395

94-
/** @type {import('types').ResponseHeaders} */
96+
/** @type {Record<string, string>} */
9597
const headers = {};
9698

97-
/** @type {string[]} */
98-
const cookies = [];
99+
const { cookies, new_cookies } = get_cookies(request, url);
99100

100101
if (state.prerendering) disable_search(url);
101102

102103
/** @type {import('types').RequestEvent} */
103104
const event = {
105+
cookies,
104106
getClientAddress:
105107
state.getClientAddress ||
106108
(() => {
@@ -119,15 +121,9 @@ export async function respond(request, options, state) {
119121
const value = new_headers[key];
120122

121123
if (lower === 'set-cookie') {
122-
const new_cookies = /** @type {string[]} */ (Array.isArray(value) ? value : [value]);
123-
124-
for (const cookie of new_cookies) {
125-
if (cookies.includes(cookie)) {
126-
throw new Error(`"${key}" header already has cookie with same value`);
127-
}
128-
129-
cookies.push(cookie);
130-
}
124+
throw new Error(
125+
`Use \`event.cookie.set(name, value, options)\` instead of \`event.setHeaders\` to set cookies`
126+
);
131127
} else if (lower in headers) {
132128
throw new Error(`"${key}" header is already set`);
133129
} else {
@@ -249,8 +245,11 @@ export async function respond(request, options, state) {
249245
}
250246
}
251247

252-
for (const cookie of cookies) {
253-
response.headers.append('set-cookie', cookie);
248+
for (const new_cookie of new_cookies) {
249+
response.headers.append(
250+
'set-cookie',
251+
cookie.serialize(new_cookie.name, new_cookie.value, new_cookie.options)
252+
);
254253
}
255254

256255
// respond with 304 if etag matches
@@ -312,6 +311,14 @@ export async function respond(request, options, state) {
312311
} catch (e) {
313312
const error = coalesce_to_error(e);
314313
return handle_fatal_error(event, options, error);
314+
} finally {
315+
event.cookies.set = () => {
316+
throw new Error('Cannot use `cookies.set(...)` after the response has been generated');
317+
};
318+
319+
event.setHeaders = () => {
320+
throw new Error('Cannot use `setHeaders(...)` after the response has been generated');
321+
};
315322
}
316323
}
317324

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

-25
This file was deleted.

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as cookie from 'cookie';
22
import * as set_cookie_parser from 'set-cookie-parser';
33
import { respond } from '../index.js';
4-
import { domain_matches, path_matches } from './cookie.js';
4+
import { domain_matches, path_matches } from '../cookie.js';
55

66
/**
77
* @param {{

packages/kit/test/apps/basics/src/hooks.js

+1-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import fs from 'fs';
2-
import cookie from 'cookie';
32
import { sequence } from '@sveltejs/kit/hooks';
43

54
/** @type {import('@sveltejs/kit').HandleError} */
@@ -23,8 +22,7 @@ export const handle = sequence(
2322
return resolve(event);
2423
},
2524
({ event, resolve }) => {
26-
const cookies = cookie.parse(event.request.headers.get('cookie') || '');
27-
event.locals.name = cookies.name;
25+
event.locals.name = event.cookies.get('name');
2826
return resolve(event);
2927
},
3028
async ({ event, resolve }) => {
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
export function load({ setHeaders }) {
2-
setHeaders({
3-
'set-cookie': 'cookie1=value1'
1+
/** @type {import('./$types').LayoutServerLoad} */
2+
export function load({ cookies }) {
3+
cookies.set('cookie1', 'value1', {
4+
secure: false // safari
45
});
56
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
export function load({ setHeaders }) {
2-
setHeaders({
3-
'set-cookie': 'cookie2=value2'
1+
/** @type {import('./$types').PageServerLoad} */
2+
export function load({ cookies }) {
3+
cookies.set('cookie2', 'value2', {
4+
secure: false // safari
45
});
56
}

packages/kit/test/apps/basics/src/routes/load/set-cookie-fetch/b.json/+server.js

+2-5
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import { json } from '@sveltejs/kit';
22

33
/** @type {import('./$types').RequestHandler} */
4-
export function GET({ request }) {
5-
const cookie = request.headers.get('cookie');
6-
7-
const match = /answer=([^;]+)/.exec(cookie);
8-
const answer = +match?.[1];
4+
export function GET({ cookies }) {
5+
const answer = +cookies.get('answer');
96

107
return json(
118
{ answer },
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { redirect } from '@sveltejs/kit';
22

3-
export function load({ setHeaders }) {
4-
setHeaders({ 'set-cookie': 'shadow-redirect=happy' });
3+
/** @type {import('./$types').PageServerLoad} */
4+
export function load({ cookies }) {
5+
cookies.set('shadow-redirect', 'happy', {
6+
secure: false // safari
7+
});
58
throw redirect(302, '/shadowed/redirected');
69
}

packages/kit/test/apps/basics/src/routes/shadowed/redirect-post-with-cookie/+page.server.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { redirect } from '@sveltejs/kit';
22

33
/** @type {import('./$types').Actions} */
44
export const actions = {
5-
default: ({ setHeaders }) => {
6-
setHeaders({ 'set-cookie': 'shadow-redirect=happy' });
5+
default: ({ cookies }) => {
6+
cookies.set('shadow-redirect', 'happy', {
7+
secure: false // safari
8+
});
79
throw redirect(302, '/shadowed/redirected');
810
}
911
};

packages/kit/types/index.d.ts

+24-3
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
Prerendered,
1313
PrerenderOnErrorValue,
1414
RequestOptions,
15-
ResponseHeaders,
1615
RouteDefinition,
1716
TrailingSlash
1817
} from './private.js';
@@ -119,6 +118,27 @@ export interface Config {
119118
[key: string]: any;
120119
}
121120

121+
export interface Cookies {
122+
/**
123+
* Gets a cookie that was previously set with `cookies.set`, or from the request headers.
124+
*/
125+
get(name: string, opts?: import('cookie').CookieParseOptions): string | undefined;
126+
127+
/**
128+
* Sets a cookie. This will add a `set-cookie` header to the response, but also make
129+
* the cookie available via `cookies.get` during the current request.
130+
*
131+
* The `httpOnly` and `secure` options are `true` by default, and must be explicitly
132+
* disabled if you want cookies to be readable by client-side JavaScript and/or transmitted over HTTP
133+
*/
134+
set(name: string, value: string, opts?: import('cookie').CookieSerializeOptions): void;
135+
136+
/**
137+
* Deletes a cookie by setting its value to an empty string and setting the expiry date in the past.
138+
*/
139+
delete(name: string): void;
140+
}
141+
122142
export interface KitConfig {
123143
adapter?: Adapter;
124144
alias?: Record<string, string>;
@@ -209,7 +229,7 @@ export interface LoadEvent<
209229
params: Params;
210230
data: Data;
211231
routeId: string | null;
212-
setHeaders: (headers: ResponseHeaders) => void;
232+
setHeaders: (headers: Record<string, string>) => void;
213233
url: URL;
214234
parent: () => Promise<ParentData>;
215235
depends: (...deps: string[]) => void;
@@ -246,13 +266,14 @@ export interface ParamMatcher {
246266
export interface RequestEvent<
247267
Params extends Partial<Record<string, string>> = Partial<Record<string, string>>
248268
> {
269+
cookies: Cookies;
249270
getClientAddress: () => string;
250271
locals: App.Locals;
251272
params: Params;
252273
platform: Readonly<App.Platform>;
253274
request: Request;
254275
routeId: string | null;
255-
setHeaders: (headers: ResponseHeaders) => void;
276+
setHeaders: (headers: Record<string, string>) => void;
256277
url: URL;
257278
}
258279

0 commit comments

Comments
 (0)