Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 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,31 @@ 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`:

```ts
// @errors: 2304 2708
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
response.headers.set(
'Content-Security-Policy',
`script-src 'nonce-${nonce}'`
);

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

`hashes.script` will be an array of strings like `["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
37 changes: 37 additions & 0 deletions packages/svelte/src/internal/server/crypto.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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-ignore
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));
// @ts-ignore - we don't install node types in the prod build
return base64_encode(hash_buffer);
}

/**
* @param {Uint8Array} bytes
* @returns {string}
*/
export function base64_encode(bytes) {
// Using `Buffer` is faster than iterating
if (!BROWSER && globalThis.Buffer) {
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);
});
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 };
Comment on lines +64 to +68
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we just use options.csp directly?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not as currently typed -- the input value is a union. Maybe this is undesirable and we should just use an object with optional keys and JSDoc?


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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as of now, this will only ever have one hash in it -- however, with streaming, it would be more...

Thinking ahead, with streaming, this should probably error if you try to access it prior to fully-reading tail, because it needs to be fully-populated before you send the response.

}
};
}

/**
* @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 = `
{

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dedented and changed the formatting of this stuff because it was confusing in the browser... I could be convinced to sacrifice browser readability for code readability but I don't think the readability here is that bad, and it also technically reduces the byte count

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should look into some sort of minification in production?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure I follow — this is what it looked like before:

image

this is what it looks like now:

image

surely it's more readable on main?

not too concerned about minification personally — I think the sacrifice of a dozen bytes is worth it for the readability

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah as far as the actual code output it's pretty much as minified as it can be without just nuking all of the whitespace

Do you have some weird set of settings on your browser? This is what it looks like for me in Chrome Devtools:

CleanShot 2025-12-11 at 18 04 53@2x

And with the old version it would be indented way off to the right

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, it's about whether you're viewing source or inspecting

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even when viewing source that seems wrong -- we don't send formatted HTML so it's like... indenting the tags but not the content? Weird

${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