Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
18 changes: 18 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,21 @@ 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 CSP, this script will likely fail to run. You can provide a `nonce` to `render`:

```ts
// @errors: 2304 2708
const { head, body } = await render(App, { csp: { nonce: 'abcd123' } });
```

This will add the `nonce` to the script block. If you need to use hashes instead, you can do that as well:

```ts
// @errors: 2304 2708
const { head, body, hashes } = await render(App, { csp: { hash: true } });
```

`hashes.style` will be `["sha256-abcd123"]`. We recommend using `nonce` over hash if you can, as `hash` will interfere with streaming SSR in the future.
7 changes: 7 additions & 0 deletions documentation/docs/98-reference/.generated/server-warnings.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->

### invalid_csp

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

### unresolved_hydratable

```
Expand Down
5 changes: 5 additions & 0 deletions packages/svelte/messages/server-warnings/warnings.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## invalid_csp

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

## unresolved_hydratable

> A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render.
Expand Down
13 changes: 13 additions & 0 deletions packages/svelte/src/internal/server/crypto.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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-ignore - we don't install node types in the prod build
crypto ??= (await import('node:crypto')).webcrypto;
const hash_buffer = await crypto.subtle.digest('SHA-256', text_encoder.encode(data));
// @ts-ignore - we don't install node types in the prod build
return Buffer.from(hash_buffer).toString('base64');
}
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);
});
23 changes: 19 additions & 4 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 w from './warnings.js';

// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
// https://infra.spec.whatwg.org/#noncharacter
Expand Down Expand Up @@ -56,11 +56,26 @@ 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 = {}) {
return Renderer.render(/** @type {Component<Props>} */ (component), options);
let csp;
if (options.csp) {
csp =
'nonce' in options.csp
? { nonce: options.csp.nonce, hash: false }
: { hash: options.csp.hash, nonce: undefined };

// @ts-expect-error
if (options.csp.hash && options.csp.nonce) {
w.invalid_csp();
}
}
return Renderer.render(/** @type {Component<Props>} */ (component), {
...options,
csp
});
}

/**
Expand Down
81 changes: 53 additions & 28 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 { CspInternal, 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?: CspInternal }} [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?: CspInternal }} 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?: CspInternal }} 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 @@ -665,27 +675,40 @@ export class Renderer {
let prelude = `const h = (window.__svelte ??= {}).h ??= new Map();`;

if (has_promises) {
prelude = `const r = (v) => Promise.resolve(v);
${prelude}`;
prelude = `const r = (v) => Promise.resolve(v);\n\t${prelude}`;
}

// TODO csp -- have discussed but not implemented
return `
<script>
{
${prelude}
const body = `
{
${prelude}

for (const [k, v] of [
${entries.join(',\n\t\t\t\t\t')}
]) {
h.set(k, v);
}
}
</script>`;
for (const [k, v] of [
${entries.join(',\n')}
]) {
h.set(k, v);
}
}
`;

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 `<script${csp_attr}>${body}</script>`;
}
}

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

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

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

/**
* @param {'sync' | 'async'} mode
* @param {string} [id_prefix]
* @param {string} id_prefix
* @param {CspInternal} 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
9 changes: 9 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,10 @@ export interface SSRContext {
element?: Element;
}

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

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

export interface HydratableLookupEntry {
value: unknown;
serialized: string;
Expand All @@ -33,13 +37,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>;
17 changes: 17 additions & 0 deletions packages/svelte/src/internal/server/warnings.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ import { DEV } from 'esm-env';
var bold = 'font-weight: bold';
var normal = 'font-weight: normal';

/**
* `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
* `nonce` will be used.
*/
export function invalid_csp() {
if (DEV) {
console.warn(
`%c[svelte] invalid_csp\n%c\`csp.nonce\` was set while \`csp.hash\` was \`true\`. These options cannot be used simultaneously.
\`nonce\` will be used.\nhttps://svelte.dev/e/invalid_csp`,
bold,
normal
);
} else {
console.warn(`https://svelte.dev/e/invalid_csp`);
}
}

/**
* A `hydratable` value with key `%key%` was created, but at least part of it was not used during the render.
*
Expand Down
Loading
Loading