Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6669b9a
tests
elliott-with-the-longest-name-on-github Dec 9, 2025
66f3255
tweak
elliott-with-the-longest-name-on-github Dec 9, 2025
2be2594
tweaks
elliott-with-the-longest-name-on-github Dec 9, 2025
59301a3
Update packages/svelte/src/internal/server/renderer.js
elliott-with-the-longest-name-on-github Dec 9, 2025
3e04518
docs
elliott-with-the-longest-name-on-github Dec 9, 2025
342ec99
Merge branch 'elliott/hydratable-csp' of github.com:sveltejs/svelte i…
elliott-with-the-longest-name-on-github Dec 9, 2025
7a5886d
typescript
elliott-with-the-longest-name-on-github Dec 9, 2025
479deb6
node 18
elliott-with-the-longest-name-on-github Dec 9, 2025
691cd47
ts
elliott-with-the-longest-name-on-github Dec 9, 2025
25b9b93
ts
elliott-with-the-longest-name-on-github Dec 9, 2025
4736669
tweak
elliott-with-the-longest-name-on-github Dec 10, 2025
f614efe
fix tests?
elliott-with-the-longest-name-on-github Dec 10, 2025
25e4050
hopefully fix docs
elliott-with-the-longest-name-on-github Dec 10, 2025
6460284
Apply suggestions from code review
elliott-with-the-longest-name-on-github Dec 12, 2025
ae7bbe0
base64_encode
elliott-with-the-longest-name-on-github Dec 12, 2025
87aebaf
ugh
elliott-with-the-longest-name-on-github Dec 12, 2025
2f394b8
eightieth times the charm
elliott-with-the-longest-name-on-github Dec 12, 2025
d3ff660
Update documentation/docs/06-runtime/05-hydratable.md
Rich-Harris Dec 12, 2025
8a91d7c
Update documentation/docs/06-runtime/05-hydratable.md
Rich-Harris Dec 12, 2025
833fb10
Update documentation/docs/06-runtime/05-hydratable.md
Rich-Harris Dec 12, 2025
e8aa7f9
Update documentation/docs/06-runtime/05-hydratable.md
Rich-Harris Dec 12, 2025
eb60df8
add remaining ts-ignores, use ts-expect-error instead where possible
Rich-Harris Dec 12, 2025
dc691ca
restore indentation
Rich-Harris Dec 12, 2025
4d78970
oops
Rich-Harris Dec 12, 2025
f0f8d1a
oops
Rich-Harris Dec 12, 2025
14a6a2f
example of using hashes in CSP header
Rich-Harris Dec 12, 2025
e872840
fix hash
Rich-Harris Dec 12, 2025
22dda42
Merge branch 'main' into elliott/hydratable-csp
Rich-Harris Dec 12, 2025
2c289a9
fix docs
Rich-Harris Dec 12, 2025
cdfed0e
switch to error
elliott-with-the-longest-name-on-github Dec 12, 2025
22ffb7b
Merge branch 'elliott/hydratable-csp' of github.com:sveltejs/svelte i…
elliott-with-the-longest-name-on-github Dec 12, 2025
ad6513e
tweaks
elliott-with-the-longest-name-on-github Dec 12, 2025
8c901a2
changeset
Rich-Harris Dec 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/soft-donkeys-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte": minor
---

feat: Add `csp` option to `render(...)`, and emit hashes when using `hydratable`
58 changes: 58 additions & 0 deletions documentation/docs/06-runtime/05-hydratable.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,61 @@ All data returned from a `hydratable` function must be serializable. But this do
{await promises.one}
{await promises.two}
```

## CSP

`hydratable` adds an inline `<script>` block to the `head` returned from `render`. If you're using [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) (CSP), this script will likely fail to run. You can provide a `nonce` to `render`:

```js
/// file: server.js
import { render } from 'svelte/server';
import App from './App.svelte';
// ---cut---
const nonce = crypto.randomUUID();

const { head, body } = await render(App, {
csp: { nonce }
});
```

This will add the `nonce` to the script block, on the assumption that you will later add the same nonce to the CSP header of the document that contains it:

```js
/// file: server.js
let response = new Response();
let nonce = 'xyz123';
// ---cut---
response.headers.set(
'Content-Security-Policy',
`script-src 'nonce-${nonce}'`
);
```

It's essential that a `nonce` — which, British slang definition aside, means 'number used once' — is only used when dynamically server rendering an individual response.

If instead you are generating static HTML ahead of time, you must use hashes instead:

```js
/// file: server.js
import { render } from 'svelte/server';
import App from './App.svelte';
// ---cut---
const { head, body, hashes } = await render(App, {
csp: { hash: true }
});
```

`hashes.script` will be an array of strings like `["sha256-abcd123"]`. As with `nonce`, the hashes should be used in your CSP header:

```js
/// file: server.js
let response = new Response();
let hashes = { script: ['sha256-xyz123'] };
// ---cut---
response.headers.set(
'Content-Security-Policy',
`script-src ${hashes.script.map((hash) => `'${hash}'`).join(' ')}`
);
```

We recommend using `nonce` over hash if you can, as `hash` will interfere with streaming SSR in the future.
6 changes: 6 additions & 0 deletions documentation/docs/98-reference/.generated/server-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ Cause:
%stack%
```

### invalid_csp

```
`csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
```

### lifecycle_function_unavailable

```
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/messages/server-errors/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ This error occurs when using `hydratable` multiple times with the same key. To a
> Cause:
> %stack%
## invalid_csp

> `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
## lifecycle_function_unavailable

> `%name%(...)` is not available on the server
Expand Down
41 changes: 41 additions & 0 deletions packages/svelte/src/internal/server/crypto.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { BROWSER } from 'esm-env';

let text_encoder;
// TODO - remove this and use global `crypto` when we drop Node 18
let crypto;

/** @param {string} data */
export async function sha256(data) {
text_encoder ??= new TextEncoder();

// @ts-expect-error
crypto ??= globalThis.crypto?.subtle?.digest
? globalThis.crypto
: // @ts-ignore - we don't install node types in the prod build
(await import('node:crypto')).webcrypto;

const hash_buffer = await crypto.subtle.digest('SHA-256', text_encoder.encode(data));

return base64_encode(hash_buffer);
}

/**
* @param {Uint8Array} bytes
* @returns {string}
*/
export function base64_encode(bytes) {
// Using `Buffer` is faster than iterating
// @ts-ignore
if (!BROWSER && globalThis.Buffer) {
// @ts-ignore
return globalThis.Buffer.from(bytes).toString('base64');
}

let binary = '';

for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}

return btoa(binary);
}
15 changes: 15 additions & 0 deletions packages/svelte/src/internal/server/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { assert, test } from 'vitest';
import { sha256 } from './crypto.js';

const inputs = [
['hello world', 'uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek='],
['', '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='],
['abcd', 'iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk='],
['the quick brown fox jumps over the lazy dog', 'Bcbgjx2f2voDFH/Lj4LxJMdtL3Dj2Ynciq2159dFC+w='],
['工欲善其事,必先利其器', 'oPOthkQ1c5BbPpvrr5WlUBJPyD5e6JeVdWcqBs9zvjA=']
];

test.each(inputs)('sha256("%s")', async (input, expected) => {
const actual = await sha256(input);
assert.equal(actual, expected);
});
12 changes: 12 additions & 0 deletions packages/svelte/src/internal/server/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ ${stack}\nhttps://svelte.dev/e/hydratable_serialization_failed`);
throw error;
}

/**
* `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
* @returns {never}
*/
export function invalid_csp() {
const error = new Error(`invalid_csp\n\`csp.nonce\` was set while \`csp.hash\` was \`true\`. These options cannot be used simultaneously.\nhttps://svelte.dev/e/invalid_csp`);

error.name = 'Svelte error';

throw error;
}

/**
* `%name%(...)` is not available on the server
* @param {string} name
Expand Down
9 changes: 6 additions & 3 deletions packages/svelte/src/internal/server/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/** @import { ComponentType, SvelteComponent, Component } from 'svelte' */
/** @import { RenderOutput } from '#server' */
/** @import { Csp, RenderOutput } from '#server' */
/** @import { Store } from '#shared' */
/** @import { AccumulatedContent } from './renderer.js' */
export { FILENAME, HMR } from '../../constants.js';
import { attr, clsx, to_class, to_style } from '../shared/attributes.js';
import { is_promise, noop } from '../shared/utils.js';
Expand All @@ -18,6 +17,7 @@ import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydra
import { validate_store } from '../shared/validate.js';
import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js';
import { Renderer } from './renderer.js';
import * as e from './errors.js';

// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
// https://infra.spec.whatwg.org/#noncharacter
Expand Down Expand Up @@ -56,10 +56,13 @@ export function element(renderer, tag, attributes_fn = noop, children_fn = noop)
* Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app.
* @template {Record<string, any>} Props
* @param {Component<Props> | ComponentType<SvelteComponent<Props>>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} [options]
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} [options]
* @returns {RenderOutput}
*/
export function render(component, options = {}) {
if (options.csp?.hash && options.csp.nonce) {
e.invalid_csp();
}
return Renderer.render(/** @type {Component<Props>} */ (component), options);
}

Expand Down
62 changes: 44 additions & 18 deletions packages/svelte/src/internal/server/renderer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @import { Component } from 'svelte' */
/** @import { HydratableContext, RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */
/** @import { Csp, HydratableContext, RenderOutput, SSRContext, SyncRenderOutput, Sha256Source } from './types.js' */
/** @import { MaybePromise } from '#shared' */
import { async_mode_flag } from '../flags/index.js';
import { abort } from './abort-signal.js';
Expand All @@ -9,7 +9,7 @@ import * as w from './warnings.js';
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { attributes } from './index.js';
import { get_render_context, with_render_context, init_render_context } from './render-context.js';
import { DEV } from 'esm-env';
import { sha256 } from './crypto.js';

/** @typedef {'head' | 'body'} RendererType */
/** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
Expand Down Expand Up @@ -376,13 +376,13 @@ export class Renderer {
* Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app.
* @template {Record<string, any>} Props
* @param {Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} [options]
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} [options]
* @returns {RenderOutput}
*/
static render(component, options = {}) {
/** @type {AccumulatedContent | undefined} */
let sync;
/** @type {Promise<AccumulatedContent> | undefined} */
/** @type {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }> | undefined} */
let async;

const result = /** @type {RenderOutput} */ ({});
Expand All @@ -404,6 +404,11 @@ export class Renderer {
return (sync ??= Renderer.#render(component, options)).body;
}
},
hashes: {
value: {
script: ''
}
},
then: {
value:
/**
Expand All @@ -420,7 +425,8 @@ export class Renderer {
const user_result = onfulfilled({
head: result.head,
body: result.body,
html: result.body
html: result.body,
hashes: { script: [] }
});
return Promise.resolve(user_result);
}
Expand Down Expand Up @@ -514,8 +520,8 @@ export class Renderer {
*
* @template {Record<string, any>} Props
* @param {Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} options
* @returns {Promise<AccumulatedContent>}
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} options
* @returns {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }>}
*/
static async #render_async(component, options) {
const previous_context = ssr_context;
Expand Down Expand Up @@ -585,19 +591,19 @@ export class Renderer {
await comparison;
}

return await Renderer.#hydratable_block(ctx);
return await this.#hydratable_block(ctx);
}

/**
* @template {Record<string, any>} Props
* @param {'sync' | 'async'} mode
* @param {import('svelte').Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} options
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} options
* @returns {Renderer}
*/
static #open_render(mode, component, options) {
const renderer = new Renderer(
new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '')
new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '', options.csp)
);

renderer.push(BLOCK_OPEN);
Expand All @@ -623,6 +629,7 @@ export class Renderer {
/**
* @param {AccumulatedContent} content
* @param {Renderer} renderer
* @returns {AccumulatedContent & { hashes: { script: Sha256Source[] } }}
*/
static #close_render(content, renderer) {
for (const cleanup of renderer.#collect_on_destroy()) {
Expand All @@ -638,14 +645,17 @@ export class Renderer {

return {
head,
body
body,
hashes: {
script: renderer.global.csp.script_hashes
}
};
}

/**
* @param {HydratableContext} ctx
*/
static async #hydratable_block(ctx) {
async #hydratable_block(ctx) {
if (ctx.lookup.size === 0) {
return null;
}
Expand All @@ -669,9 +679,7 @@ export class Renderer {
${prelude}`;
}

// TODO csp -- have discussed but not implemented
return `
<script>
const body = `
{
${prelude}
Expand All @@ -681,11 +689,27 @@ export class Renderer {
h.set(k, v);
}
}
</script>`;
`;

let csp_attr = '';
if (this.global.csp.nonce) {
csp_attr = ` nonce="${this.global.csp.nonce}"`;
} else if (this.global.csp.hash) {
// note to future selves: this doesn't need to be optimized with a Map<body, hash>
// because the it's impossible for identical data to occur multiple times in a single render
// (this would require the same hydratable key:value pair to be serialized multiple times)
const hash = await sha256(body);
this.global.csp.script_hashes.push(`sha256-${hash}`);
}

return `\n\t\t<script${csp_attr}>${body}</script>`;
}
}

export class SSRState {
/** @readonly @type {Csp & { script_hashes: Sha256Source[] }} */
csp;

/** @readonly @type {'sync' | 'async'} */
mode;

Expand All @@ -700,10 +724,12 @@ export class SSRState {

/**
* @param {'sync' | 'async'} mode
* @param {string} [id_prefix]
* @param {string} id_prefix
* @param {Csp} csp
*/
constructor(mode, id_prefix = '') {
constructor(mode, id_prefix = '', csp = { hash: false }) {
this.mode = mode;
this.csp = { ...csp, script_hashes: [] };

let uid = 1;
this.uid = () => `${id_prefix}s${uid++}`;
Expand Down
7 changes: 7 additions & 0 deletions packages/svelte/src/internal/server/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface SSRContext {
element?: Element;
}

export type Csp = { nonce?: string; hash?: boolean };

export interface HydratableLookupEntry {
value: unknown;
serialized: string;
Expand All @@ -33,13 +35,18 @@ export interface RenderContext {
hydratable: HydratableContext;
}

export type Sha256Source = `sha256-${string}`;

export interface SyncRenderOutput {
/** HTML that goes into the `<head>` */
head: string;
/** @deprecated use `body` instead */
html: string;
/** HTML that goes somewhere into the `<body>` */
body: string;
hashes: {
script: Sha256Source[];
};
}

export type RenderOutput = SyncRenderOutput & PromiseLike<SyncRenderOutput>;
Loading
Loading