Skip to content

Commit 55e9151

Browse files
Add filterSerializedResponseHeaders function (sveltejs#6569)
* refactor * add filterSerializedResponseHeaders option - closes sveltejs#1971 * Update .changeset/silent-cycles-reply.md Co-authored-by: Conduitry <[email protected]> * Update documentation/docs/06-hooks.md Co-authored-by: Conduitry <[email protected]> * throw error if excluded header is read during SSR * argh * fix Co-authored-by: Conduitry <[email protected]>
1 parent cdb1ba9 commit 55e9151

File tree

22 files changed

+167
-103
lines changed

22 files changed

+167
-103
lines changed

.changeset/silent-cycles-reply.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
[breaking] exclude headers from serialized responses by default, add `filterSerializedResponseHeaders` `resolve` option

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ dist
44
test-results/
55
package-lock.json
66
yarn.lock
7+
vite.config.js.timestamp-*
78
/packages/create-svelte/template/CHANGELOG.md
89
/packages/package/test/**/package
910
/documentation/types.js
@@ -16,3 +17,4 @@ yarn.lock
1617
.turbo
1718
.vercel
1819
.test-tmp
20+

documentation/docs/05-load.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export async function load({ depends }) {
143143
- it can be used to make credentialed requests on the server, as it inherits the `cookie` and `authorization` headers for the page request
144144
- it can make relative requests on the server (ordinarily, `fetch` requires a URL with an origin when used in a server context)
145145
- internal requests (e.g. for `+server.js` routes) go direct to the handler function when running on the server, without the overhead of an HTTP call
146-
- during server-side rendering, the response will be captured and inlined into the rendered HTML
146+
- during server-side rendering, the response will be captured and inlined into the rendered HTML. Note that headers will _not_ be serialized, unless explicitly included via [`filterSerializedResponseHeaders`](/docs/hooks#handle)
147147
- during hydration, the response will be read from the HTML, guaranteeing consistency and preventing an additional network request
148148

149149
> Cookies will only be passed through if the target host is the same as the SvelteKit application or a more specific subdomain of it.

documentation/docs/06-hooks.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,15 @@ You can add call multiple `handle` functions with [the `sequence` helper functio
6464
`resolve` also supports a second, optional parameter that gives you more control over how the response will be rendered. That parameter is an object that can have the following fields:
6565

6666
- `transformPageChunk(opts: { html: string, done: boolean }): MaybePromise<string | undefined>` — applies custom transforms to HTML. If `done` is true, it's the final chunk. Chunks are not guaranteed to be well-formed HTML (they could include an element's opening tag but not its closing tag, for example) but they will always be split at sensible boundaries such as `%sveltekit.head%` or layout/page components.
67+
- `filterSerializedResponseHeaders(name: string, value: string): boolean` — determines which headers should be included in serialized responses when a `load` function loads a resource with `fetch`. By default, none will be included.
6768

6869
```js
6970
/// file: src/hooks.js
7071
/** @type {import('@sveltejs/kit').Handle} */
7172
export async function handle({ event, resolve }) {
7273
const response = await resolve(event, {
73-
transformPageChunk: ({ html }) => html.replace('old', 'new')
74+
transformPageChunk: ({ html }) => html.replace('old', 'new'),
75+
filterSerializedResponseHeaders: (name) => name.startsWith('x-')
7476
});
7577

7678
return response;

packages/kit/src/core/prerender/prerender.js

+12-6
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,11 @@ export async function prerender() {
237237
const encoded_dependency_path = new URL(dependency_path, 'http://localhost').pathname;
238238
const decoded_dependency_path = decodeURI(encoded_dependency_path);
239239

240-
const prerender = result.response.headers.get('x-sveltekit-prerender');
240+
const headers = Object.fromEntries(result.response.headers);
241241

242+
const prerender = headers['x-sveltekit-prerender'];
242243
if (prerender) {
243-
const route_id = /** @type {string} */ (result.response.headers.get('x-sveltekit-routeid'));
244+
const route_id = headers['x-sveltekit-routeid'];
244245
const existing_value = prerender_map.get(route_id);
245246
if (existing_value !== 'auto') {
246247
prerender_map.set(route_id, prerender === 'true' ? true : 'auto');
@@ -259,7 +260,10 @@ export async function prerender() {
259260
);
260261
}
261262

262-
if (config.prerender.crawl && response.headers.get('content-type') === 'text/html') {
263+
// avoid triggering `filterSerializeResponseHeaders` guard
264+
const headers = Object.fromEntries(response.headers);
265+
266+
if (config.prerender.crawl && headers['content-type'] === 'text/html') {
263267
for (const href of crawl(body.toString())) {
264268
if (href.startsWith('data:') || href.startsWith('#')) continue;
265269

@@ -288,7 +292,9 @@ export async function prerender() {
288292
*/
289293
function save(category, response, body, decoded, encoded, referrer, referenceType) {
290294
const response_type = Math.floor(response.status / 100);
291-
const type = /** @type {string} */ (response.headers.get('content-type'));
295+
const headers = Object.fromEntries(response.headers);
296+
297+
const type = headers['content-type'];
292298
const is_html = response_type === REDIRECT || type === 'text/html';
293299

294300
const file = output_filename(decoded, is_html);
@@ -297,15 +303,15 @@ export async function prerender() {
297303
if (written.has(file)) return;
298304

299305
if (response_type === REDIRECT) {
300-
const location = response.headers.get('location');
306+
const location = headers['location'];
301307

302308
if (location) {
303309
const resolved = resolve(encoded, location);
304310
if (is_root_relative(resolved)) {
305311
enqueue(decoded, decodeURI(resolved), resolved);
306312
}
307313

308-
if (!response.headers.get('x-sveltekit-normalize')) {
314+
if (!headers['x-sveltekit-normalize']) {
309315
mkdirp(dirname(dest));
310316

311317
log.warn(`${response.status} ${decoded} -> ${location}`);

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

+6-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { DATA_SUFFIX } from '../../constants.js';
1414
/** @param {{ html: string }} opts */
1515
const default_transform = ({ html }) => html;
1616

17+
const default_filter = () => false;
18+
1719
/** @type {import('types').Respond} */
1820
export async function respond(request, options, state) {
1921
let url = new URL(request.url);
@@ -201,7 +203,8 @@ export async function respond(request, options, state) {
201203

202204
/** @type {import('types').RequiredResolveOptions} */
203205
let resolve_opts = {
204-
transformPageChunk: default_transform
206+
transformPageChunk: default_transform,
207+
filterSerializedResponseHeaders: default_filter
205208
};
206209

207210
/**
@@ -226,7 +229,8 @@ export async function respond(request, options, state) {
226229
}
227230

228231
resolve_opts = {
229-
transformPageChunk: opts.transformPageChunk || default_transform
232+
transformPageChunk: opts.transformPageChunk || default_transform,
233+
filterSerializedResponseHeaders: opts.filterSerializedResponseHeaders || default_filter
230234
};
231235
}
232236

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

+22-18
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import { domain_matches, path_matches } from './cookie.js';
1010
* state: import('types').SSRState;
1111
* route: import('types').SSRRoute | import('types').SSRErrorPage;
1212
* prerender_default?: import('types').PrerenderOption;
13+
* resolve_opts: import('types').RequiredResolveOptions;
1314
* }} opts
1415
*/
15-
export function create_fetch({ event, options, state, route, prerender_default }) {
16+
export function create_fetch({ event, options, state, route, prerender_default, resolve_opts }) {
1617
/** @type {import('./types').Fetched[]} */
1718
const fetched = [];
1819

@@ -189,16 +190,6 @@ export function create_fetch({ event, options, state, route, prerender_default }
189190
async function text() {
190191
const body = await response.text();
191192

192-
// TODO just pass `response.headers`, for processing inside `serialize_data`
193-
/** @type {import('types').ResponseHeaders} */
194-
const headers = {};
195-
for (const [key, value] of response.headers) {
196-
// TODO skip others besides set-cookie and etag?
197-
if (key !== 'set-cookie' && key !== 'etag') {
198-
headers[key] = value;
199-
}
200-
}
201-
202193
if (!body || typeof body === 'string') {
203194
const status_number = Number(response.status);
204195
if (isNaN(status_number)) {
@@ -214,14 +205,27 @@ export function create_fetch({ event, options, state, route, prerender_default }
214205
? request.url.slice(event.url.origin.length)
215206
: request.url,
216207
method: request.method,
217-
body: /** @type {string | undefined} */ (request_body),
218-
response: {
219-
status: status_number,
220-
statusText: response.statusText,
221-
headers,
222-
body
223-
}
208+
request_body: /** @type {string | undefined} */ (request_body),
209+
response_body: body,
210+
response: response
224211
});
212+
213+
// ensure that excluded headers can't be read
214+
const get = response.headers.get;
215+
response.headers.get = (key) => {
216+
const lower = key.toLowerCase();
217+
const value = get.call(response.headers, lower);
218+
if (value && !lower.startsWith('x-sveltekit-')) {
219+
const included = resolve_opts.filterSerializedResponseHeaders(lower, value);
220+
if (!included) {
221+
throw new Error(
222+
`Failed to get response header "${lower}" — it must be included by the \`filterSerializedResponseHeaders\` option: https://kit.svelte.dev/docs/hooks#handle`
223+
);
224+
}
225+
}
226+
227+
return value;
228+
};
225229
}
226230

227231
if (dependency) {

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ export async function render_page(event, route, page, options, state, resolve_op
131131
options,
132132
state,
133133
route,
134-
prerender_default: should_prerender
134+
prerender_default: should_prerender,
135+
resolve_opts
135136
});
136137

137138
if (get_option(nodes, 'ssr') === false) {

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

+6-1
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,11 @@ export async function render_response({
284284
}
285285

286286
if (page_config.ssr && page_config.csr) {
287-
body += `\n\t${fetched.map((item) => serialize_data(item, !!state.prerendering)).join('\n\t')}`;
287+
body += `\n\t${fetched
288+
.map((item) =>
289+
serialize_data(item, resolve_opts.filterSerializedResponseHeaders, !!state.prerendering)
290+
)
291+
.join('\n\t')}`;
288292
}
289293

290294
if (options.service_worker) {
@@ -321,6 +325,7 @@ export async function render_response({
321325
})) || '';
322326

323327
const headers = new Headers({
328+
'x-sveltekit-page': 'true',
324329
'content-type': 'text/html',
325330
etag: `"${hash(html)}"`
326331
});

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ export async function respond_with_error({ event, options, state, status, error,
2525
event,
2626
options,
2727
state,
28-
route: GENERIC_ERROR
28+
route: GENERIC_ERROR,
29+
resolve_opts
2930
});
3031

3132
try {

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

+32-17
Original file line numberDiff line numberDiff line change
@@ -35,36 +35,51 @@ const pattern = new RegExp(`[${Object.keys(replacements).join('')}]`, 'g');
3535
* and that the resulting string isn't further modified.
3636
*
3737
* @param {import('./types.js').Fetched} fetched
38+
* @param {(name: string, value: string) => boolean} filter
3839
* @param {boolean} [prerendering]
3940
* @returns {string} The raw HTML of a script element carrying the JSON payload.
4041
* @example const html = serialize_data('/data.json', null, { foo: 'bar' });
4142
*/
42-
export function serialize_data(fetched, prerendering = false) {
43-
const safe_payload = JSON.stringify(fetched.response).replace(
44-
pattern,
45-
(match) => replacements[match]
46-
);
43+
export function serialize_data(fetched, filter, prerendering = false) {
44+
/** @type {Record<string, string>} */
45+
const headers = {};
46+
47+
let cache_control = null;
48+
let age = null;
49+
50+
for (const [key, value] of fetched.response.headers) {
51+
if (filter(key, value)) {
52+
headers[key] = value;
53+
}
54+
55+
if (key === 'cache-control') cache_control = value;
56+
if (key === 'age') age = value;
57+
}
58+
59+
const payload = {
60+
status: fetched.response.status,
61+
statusText: fetched.response.statusText,
62+
headers,
63+
body: fetched.response_body
64+
};
65+
66+
const safe_payload = JSON.stringify(payload).replace(pattern, (match) => replacements[match]);
4767

4868
const attrs = [
4969
'type="application/json"',
5070
'data-sveltekit-fetched',
5171
`data-url=${escape_html_attr(fetched.url)}`
5272
];
5373

54-
if (fetched.body) {
55-
attrs.push(`data-hash=${escape_html_attr(hash(fetched.body))}`);
74+
if (fetched.request_body) {
75+
attrs.push(`data-hash=${escape_html_attr(hash(fetched.request_body))}`);
5676
}
5777

58-
if (!prerendering && fetched.method === 'GET') {
59-
const cache_control = /** @type {string} */ (fetched.response.headers['cache-control']);
60-
if (cache_control) {
61-
const match = /s-maxage=(\d+)/g.exec(cache_control) ?? /max-age=(\d+)/g.exec(cache_control);
62-
if (match) {
63-
const age = /** @type {string} */ (fetched.response.headers['age']) ?? '0';
64-
65-
const ttl = +match[1] - +age;
66-
attrs.push(`data-ttl="${ttl}"`);
67-
}
78+
if (!prerendering && fetched.method === 'GET' && cache_control) {
79+
const match = /s-maxage=(\d+)/g.exec(cache_control) ?? /max-age=(\d+)/g.exec(cache_control);
80+
if (match) {
81+
const ttl = +match[1] - +(age ?? '0');
82+
attrs.push(`data-ttl="${ttl}"`);
6883
}
6984
}
7085

0 commit comments

Comments
 (0)