Skip to content

Commit 4922e26

Browse files
authored
add new cookies API (sveltejs#6593)
* add new cookies API - closes sveltejs#6540 * update tests and docs * tidy up * secure by default * fixes * Update packages/kit/src/runtime/server/cookie.js * insecure cookies for benefit of safari
1 parent a4eb945 commit 4922e26

File tree

17 files changed

+156
-89
lines changed

17 files changed

+156
-89
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/03-routing.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export {};
141141
import { error } from '@sveltejs/kit';
142142

143143
/** @type {import('./$types').Action} */
144-
export async function POST({ request, setHeaders, url }) {
144+
export async function POST({ cookies, request, url }) {
145145
const values = await request.formData();
146146

147147
const username = /** @type {string} */ (values.get('username'));
@@ -167,8 +167,8 @@ export async function POST({ request, setHeaders, url }) {
167167
};
168168
}
169169

170-
setHeaders({
171-
'set-cookie': createSessionCookie(user.id)
170+
cookies.set('sessionid', createSessionCookie(user.id), {
171+
httpOnly: true
172172
});
173173

174174
return {

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/06-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

@@ -116,16 +118,16 @@ export async function respond(request, options, state) {
116118
}
117119
}
118120

119-
/** @type {import('types').ResponseHeaders} */
121+
/** @type {Record<string, string>} */
120122
const headers = {};
121123

122-
/** @type {string[]} */
123-
const cookies = [];
124+
const { cookies, new_cookies } = get_cookies(request, url);
124125

125126
if (state.prerendering) disable_search(url);
126127

127128
/** @type {import('types').RequestEvent} */
128129
const event = {
130+
cookies,
129131
getClientAddress:
130132
state.getClientAddress ||
131133
(() => {
@@ -144,15 +146,9 @@ export async function respond(request, options, state) {
144146
const value = new_headers[key];
145147

146148
if (lower === 'set-cookie') {
147-
const new_cookies = /** @type {string[]} */ (Array.isArray(value) ? value : [value]);
148-
149-
for (const cookie of new_cookies) {
150-
if (cookies.includes(cookie)) {
151-
throw new Error(`"${key}" header already has cookie with same value`);
152-
}
153-
154-
cookies.push(cookie);
155-
}
149+
throw new Error(
150+
`Use \`event.cookie.set(name, value, options)\` instead of \`event.setHeaders\` to set cookies`
151+
);
156152
} else if (lower in headers) {
157153
throw new Error(`"${key}" header is already set`);
158154
} else {
@@ -275,8 +271,11 @@ export async function respond(request, options, state) {
275271
}
276272
}
277273

278-
for (const cookie of cookies) {
279-
response.headers.append('set-cookie', cookie);
274+
for (const new_cookie of new_cookies) {
275+
response.headers.append(
276+
'set-cookie',
277+
cookie.serialize(new_cookie.name, new_cookie.value, new_cookie.options)
278+
);
280279
}
281280

282281
// respond with 304 if etag matches
@@ -338,6 +337,14 @@ export async function respond(request, options, state) {
338337
} catch (e) {
339338
const error = coalesce_to_error(e);
340339
return handle_fatal_error(event, options, error);
340+
} finally {
341+
event.cookies.set = () => {
342+
throw new Error('Cannot use `cookies.set(...)` after the response has been generated');
343+
};
344+
345+
event.setHeaders = () => {
346+
throw new Error('Cannot use `setHeaders(...)` after the response has been generated');
347+
};
341348
}
342349
}
343350

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
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { redirect } from '@sveltejs/kit';
22

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

0 commit comments

Comments
 (0)