diff --git a/.changeset/soft-donkeys-serve.md b/.changeset/soft-donkeys-serve.md
new file mode 100644
index 000000000000..f0c723669445
--- /dev/null
+++ b/.changeset/soft-donkeys-serve.md
@@ -0,0 +1,5 @@
+---
+"svelte": minor
+---
+
+feat: Add `csp` option to `render(...)`, and emit hashes when using `hydratable`
diff --git a/documentation/docs/06-runtime/05-hydratable.md b/documentation/docs/06-runtime/05-hydratable.md
index a5302f264dd1..f8d513058161 100644
--- a/documentation/docs/06-runtime/05-hydratable.md
+++ b/documentation/docs/06-runtime/05-hydratable.md
@@ -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 ``;
+ `;
+
+ 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
+ // 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`;
}
}
export class SSRState {
+ /** @readonly @type {Csp & { script_hashes: Sha256Source[] }} */
+ csp;
+
/** @readonly @type {'sync' | 'async'} */
mode;
@@ -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++}`;
diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts
index 05ee34fb172c..ea6282c176ac 100644
--- a/packages/svelte/src/internal/server/types.d.ts
+++ b/packages/svelte/src/internal/server/types.d.ts
@@ -15,6 +15,8 @@ export interface SSRContext {
element?: Element;
}
+export type Csp = { nonce?: string; hash?: boolean };
+
export interface HydratableLookupEntry {
value: unknown;
serialized: string;
@@ -33,6 +35,8 @@ export interface RenderContext {
hydratable: HydratableContext;
}
+export type Sha256Source = `sha256-${string}`;
+
export interface SyncRenderOutput {
/** HTML that goes into the `` */
head: string;
@@ -40,6 +44,9 @@ export interface SyncRenderOutput {
html: string;
/** HTML that goes somewhere into the `` */
body: string;
+ hashes: {
+ script: Sha256Source[];
+ };
}
export type RenderOutput = SyncRenderOutput & PromiseLike;
diff --git a/packages/svelte/src/legacy/legacy-server.js b/packages/svelte/src/legacy/legacy-server.js
index a50d961751d8..05b329bea12e 100644
--- a/packages/svelte/src/legacy/legacy-server.js
+++ b/packages/svelte/src/legacy/legacy-server.js
@@ -1,14 +1,14 @@
/** @import { SvelteComponent } from '../index.js' */
+/** @import { Csp } from '#server' */
import { asClassComponent as as_class_component, createClassComponent } from './legacy-client.js';
import { render } from '../internal/server/index.js';
import { async_mode_flag } from '../internal/flags/index.js';
-import * as w from '../internal/server/warnings.js';
// By having this as a separate entry point for server environments, we save the client bundle from having to include the server runtime
export { createClassComponent };
-/** @typedef {{ head: string, html: string, css: { code: string, map: null }}} LegacyRenderResult */
+/** @typedef {{ head: string, html: string, css: { code: string, map: null }; hashes?: { script: `sha256-${string}`[] } }} LegacyRenderResult */
/**
* Takes a Svelte 5 component and returns a Svelte 4 compatible component constructor.
@@ -25,10 +25,10 @@ export { createClassComponent };
*/
export function asClassComponent(component) {
const component_constructor = as_class_component(component);
- /** @type {(props?: {}, opts?: { $$slots?: {}; context?: Map; }) => LegacyRenderResult & PromiseLike } */
- const _render = (props, { context } = {}) => {
+ /** @type {(props?: {}, opts?: { $$slots?: {}; context?: Map; csp?: Csp }) => LegacyRenderResult & PromiseLike } */
+ const _render = (props, { context, csp } = {}) => {
// @ts-expect-error the typings are off, but this will work if the component is compiled in SSR mode
- const result = render(component, { props, context });
+ const result = render(component, { props, context, csp });
const munged = Object.defineProperties(
/** @type {LegacyRenderResult & PromiseLike} */ ({}),
@@ -65,7 +65,8 @@ export function asClassComponent(component) {
return onfulfilled({
css: munged.css,
head: result.head,
- html: result.body
+ html: result.body,
+ hashes: result.hashes
});
}, onrejected);
}
diff --git a/packages/svelte/src/server/index.d.ts b/packages/svelte/src/server/index.d.ts
index d5a3b813e6cb..f54bd5a5cadc 100644
--- a/packages/svelte/src/server/index.d.ts
+++ b/packages/svelte/src/server/index.d.ts
@@ -1,4 +1,4 @@
-import type { RenderOutput } from '#server';
+import type { Csp, RenderOutput } from '#server';
import type { ComponentProps, Component, SvelteComponent, ComponentType } from 'svelte';
/**
@@ -16,6 +16,7 @@ export function render<
props?: Omit;
context?: Map;
idPrefix?: string;
+ csp?: Csp;
}
]
: [
@@ -24,6 +25,7 @@ export function render<
props: Omit;
context?: Map;
idPrefix?: string;
+ csp?: Csp;
}
]
): RenderOutput;
diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-config-error/_config.js b/packages/svelte/tests/server-side-rendering/samples/csp-config-error/_config.js
new file mode 100644
index 000000000000..03626fc37bd6
--- /dev/null
+++ b/packages/svelte/tests/server-side-rendering/samples/csp-config-error/_config.js
@@ -0,0 +1,7 @@
+import { test } from '../../test';
+
+export default test({
+ mode: ['async'],
+ csp: { hash: true, nonce: 'test-nonce' },
+ error: 'invalid_csp'
+});
diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-config-error/_expected_head.html b/packages/svelte/tests/server-side-rendering/samples/csp-config-error/_expected_head.html
new file mode 100644
index 000000000000..fb3c95f51fe3
--- /dev/null
+++ b/packages/svelte/tests/server-side-rendering/samples/csp-config-error/_expected_head.html
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-config-error/main.svelte b/packages/svelte/tests/server-side-rendering/samples/csp-config-error/main.svelte
new file mode 100644
index 000000000000..2c4726edf478
--- /dev/null
+++ b/packages/svelte/tests/server-side-rendering/samples/csp-config-error/main.svelte
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-hash/_config.js b/packages/svelte/tests/server-side-rendering/samples/csp-hash/_config.js
new file mode 100644
index 000000000000..fe28087d8694
--- /dev/null
+++ b/packages/svelte/tests/server-side-rendering/samples/csp-hash/_config.js
@@ -0,0 +1,7 @@
+import { test } from '../../test';
+
+export default test({
+ mode: ['async'],
+ csp: { hash: true },
+ script_hashes: ['sha256-J0xwNm40i0NVEdHYeMRThG7y90X+P/I1ElZGnpQ0AbU=']
+});
diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-hash/_expected_head.html b/packages/svelte/tests/server-side-rendering/samples/csp-hash/_expected_head.html
new file mode 100644
index 000000000000..56319255d6b6
--- /dev/null
+++ b/packages/svelte/tests/server-side-rendering/samples/csp-hash/_expected_head.html
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-hash/main.svelte b/packages/svelte/tests/server-side-rendering/samples/csp-hash/main.svelte
new file mode 100644
index 000000000000..2c4726edf478
--- /dev/null
+++ b/packages/svelte/tests/server-side-rendering/samples/csp-hash/main.svelte
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-nonce/_config.js b/packages/svelte/tests/server-side-rendering/samples/csp-nonce/_config.js
new file mode 100644
index 000000000000..320c0f67f82e
--- /dev/null
+++ b/packages/svelte/tests/server-side-rendering/samples/csp-nonce/_config.js
@@ -0,0 +1,6 @@
+import { test } from '../../test';
+
+export default test({
+ mode: ['async'],
+ csp: { nonce: 'test-nonce' }
+});
diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-nonce/_expected_head.html b/packages/svelte/tests/server-side-rendering/samples/csp-nonce/_expected_head.html
new file mode 100644
index 000000000000..fb3c95f51fe3
--- /dev/null
+++ b/packages/svelte/tests/server-side-rendering/samples/csp-nonce/_expected_head.html
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-nonce/main.svelte b/packages/svelte/tests/server-side-rendering/samples/csp-nonce/main.svelte
new file mode 100644
index 000000000000..2c4726edf478
--- /dev/null
+++ b/packages/svelte/tests/server-side-rendering/samples/csp-nonce/main.svelte
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/packages/svelte/tests/server-side-rendering/test.ts b/packages/svelte/tests/server-side-rendering/test.ts
index 4b3368560870..2bfc84c7a1c7 100644
--- a/packages/svelte/tests/server-side-rendering/test.ts
+++ b/packages/svelte/tests/server-side-rendering/test.ts
@@ -21,6 +21,8 @@ interface SSRTest extends BaseTest {
id_prefix?: string;
withoutNormalizeHtml?: boolean;
error?: string;
+ csp?: { nonce: string } | { hash: true };
+ script_hashes?: string[];
}
// TODO remove this shim when we can
@@ -77,7 +79,8 @@ const { test, run } = suite_with_variants;
context?: Map;
idPrefix?: string;
+ csp?: Csp;
}
]
: [
@@ -2561,9 +2562,14 @@ declare module 'svelte/server' {
props: Omit;
context?: Map;
idPrefix?: string;
+ csp?: Csp;
}
]
): RenderOutput;
+ type Csp = { nonce?: string; hash?: boolean };
+
+ type Sha256Source = `sha256-${string}`;
+
interface SyncRenderOutput {
/** HTML that goes into the `` */
head: string;
@@ -2571,6 +2577,9 @@ declare module 'svelte/server' {
html: string;
/** HTML that goes somewhere into the `` */
body: string;
+ hashes: {
+ script: Sha256Source[];
+ };
}
type RenderOutput = SyncRenderOutput & PromiseLike;