feat: Add CSP support for `hydratable` (#17338)

pull/17358/head
Elliott Johnson 1 week ago committed by GitHub
parent 5f249abeae
commit c86c4fdca1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": minor
---
feat: Add `csp` option to `render(...)`, and emit hashes when using `hydratable`

@ -63,3 +63,61 @@ All data returned from a `hydratable` function must be serializable. But this do
{await promises.one} {await promises.one}
{await promises.two} {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.

@ -55,6 +55,12 @@ Cause:
%stack% %stack%
``` ```
### invalid_csp
```
`csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
```
### lifecycle_function_unavailable ### lifecycle_function_unavailable
``` ```

@ -43,6 +43,10 @@ This error occurs when using `hydratable` multiple times with the same key. To a
> Cause: > Cause:
> %stack% > %stack%
## invalid_csp
> `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
## lifecycle_function_unavailable ## lifecycle_function_unavailable
> `%name%(...)` is not available on the server > `%name%(...)` is not available on the server

@ -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);
}

@ -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);
});

@ -80,6 +80,18 @@ ${stack}\nhttps://svelte.dev/e/hydratable_serialization_failed`);
throw error; 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 * `%name%(...)` is not available on the server
* @param {string} name * @param {string} name

@ -1,7 +1,6 @@
/** @import { ComponentType, SvelteComponent, Component } from 'svelte' */ /** @import { ComponentType, SvelteComponent, Component } from 'svelte' */
/** @import { RenderOutput } from '#server' */ /** @import { Csp, RenderOutput } from '#server' */
/** @import { Store } from '#shared' */ /** @import { Store } from '#shared' */
/** @import { AccumulatedContent } from './renderer.js' */
export { FILENAME, HMR } from '../../constants.js'; export { FILENAME, HMR } from '../../constants.js';
import { attr, clsx, to_class, to_style } from '../shared/attributes.js'; import { attr, clsx, to_class, to_style } from '../shared/attributes.js';
import { is_promise, noop } from '../shared/utils.js'; import { is_promise, noop } from '../shared/utils.js';
@ -18,6 +17,7 @@ import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydra
import { validate_store } from '../shared/validate.js'; import { validate_store } from '../shared/validate.js';
import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js'; import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js';
import { Renderer } from './renderer.js'; import { Renderer } from './renderer.js';
import * as e from './errors.js';
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
// https://infra.spec.whatwg.org/#noncharacter // https://infra.spec.whatwg.org/#noncharacter
@ -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. * 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 * @template {Record<string, any>} Props
* @param {Component<Props> | ComponentType<SvelteComponent<Props>>} component * @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} * @returns {RenderOutput}
*/ */
export function render(component, options = {}) { export function render(component, options = {}) {
if (options.csp?.hash && options.csp.nonce) {
e.invalid_csp();
}
return Renderer.render(/** @type {Component<Props>} */ (component), options); return Renderer.render(/** @type {Component<Props>} */ (component), options);
} }

@ -1,5 +1,5 @@
/** @import { Component } from 'svelte' */ /** @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 { MaybePromise } from '#shared' */
import { async_mode_flag } from '../flags/index.js'; import { async_mode_flag } from '../flags/index.js';
import { abort } from './abort-signal.js'; import { abort } from './abort-signal.js';
@ -9,7 +9,7 @@ import * as w from './warnings.js';
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js'; import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { attributes } from './index.js'; import { attributes } from './index.js';
import { get_render_context, with_render_context, init_render_context } from './render-context.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 {'head' | 'body'} RendererType */
/** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
@ -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. * 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 * @template {Record<string, any>} Props
* @param {Component<Props>} component * @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} * @returns {RenderOutput}
*/ */
static render(component, options = {}) { static render(component, options = {}) {
/** @type {AccumulatedContent | undefined} */ /** @type {AccumulatedContent | undefined} */
let sync; let sync;
/** @type {Promise<AccumulatedContent> | undefined} */ /** @type {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }> | undefined} */
let async; let async;
const result = /** @type {RenderOutput} */ ({}); const result = /** @type {RenderOutput} */ ({});
@ -404,6 +404,11 @@ export class Renderer {
return (sync ??= Renderer.#render(component, options)).body; return (sync ??= Renderer.#render(component, options)).body;
} }
}, },
hashes: {
value: {
script: ''
}
},
then: { then: {
value: value:
/** /**
@ -420,7 +425,8 @@ export class Renderer {
const user_result = onfulfilled({ const user_result = onfulfilled({
head: result.head, head: result.head,
body: result.body, body: result.body,
html: result.body html: result.body,
hashes: { script: [] }
}); });
return Promise.resolve(user_result); return Promise.resolve(user_result);
} }
@ -514,8 +520,8 @@ export class Renderer {
* *
* @template {Record<string, any>} Props * @template {Record<string, any>} Props
* @param {Component<Props>} component * @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 {Promise<AccumulatedContent>} * @returns {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }>}
*/ */
static async #render_async(component, options) { static async #render_async(component, options) {
const previous_context = ssr_context; const previous_context = ssr_context;
@ -585,19 +591,19 @@ export class Renderer {
await comparison; await comparison;
} }
return await Renderer.#hydratable_block(ctx); return await this.#hydratable_block(ctx);
} }
/** /**
* @template {Record<string, any>} Props * @template {Record<string, any>} Props
* @param {'sync' | 'async'} mode * @param {'sync' | 'async'} mode
* @param {import('svelte').Component<Props>} component * @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} * @returns {Renderer}
*/ */
static #open_render(mode, component, options) { static #open_render(mode, component, options) {
const renderer = new Renderer( const renderer = new Renderer(
new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '') new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '', options.csp)
); );
renderer.push(BLOCK_OPEN); renderer.push(BLOCK_OPEN);
@ -623,6 +629,7 @@ export class Renderer {
/** /**
* @param {AccumulatedContent} content * @param {AccumulatedContent} content
* @param {Renderer} renderer * @param {Renderer} renderer
* @returns {AccumulatedContent & { hashes: { script: Sha256Source[] } }}
*/ */
static #close_render(content, renderer) { static #close_render(content, renderer) {
for (const cleanup of renderer.#collect_on_destroy()) { for (const cleanup of renderer.#collect_on_destroy()) {
@ -638,14 +645,17 @@ export class Renderer {
return { return {
head, head,
body body,
hashes: {
script: renderer.global.csp.script_hashes
}
}; };
} }
/** /**
* @param {HydratableContext} ctx * @param {HydratableContext} ctx
*/ */
static async #hydratable_block(ctx) { async #hydratable_block(ctx) {
if (ctx.lookup.size === 0) { if (ctx.lookup.size === 0) {
return null; return null;
} }
@ -669,9 +679,7 @@ export class Renderer {
${prelude}`; ${prelude}`;
} }
// TODO csp -- have discussed but not implemented const body = `
return `
<script>
{ {
${prelude} ${prelude}
@ -681,11 +689,27 @@ export class Renderer {
h.set(k, v); 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 { export class SSRState {
/** @readonly @type {Csp & { script_hashes: Sha256Source[] }} */
csp;
/** @readonly @type {'sync' | 'async'} */ /** @readonly @type {'sync' | 'async'} */
mode; mode;
@ -700,10 +724,12 @@ export class SSRState {
/** /**
* @param {'sync' | 'async'} mode * @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.mode = mode;
this.csp = { ...csp, script_hashes: [] };
let uid = 1; let uid = 1;
this.uid = () => `${id_prefix}s${uid++}`; this.uid = () => `${id_prefix}s${uid++}`;

@ -15,6 +15,8 @@ export interface SSRContext {
element?: Element; element?: Element;
} }
export type Csp = { nonce?: string; hash?: boolean };
export interface HydratableLookupEntry { export interface HydratableLookupEntry {
value: unknown; value: unknown;
serialized: string; serialized: string;
@ -33,6 +35,8 @@ export interface RenderContext {
hydratable: HydratableContext; hydratable: HydratableContext;
} }
export type Sha256Source = `sha256-${string}`;
export interface SyncRenderOutput { export interface SyncRenderOutput {
/** HTML that goes into the `<head>` */ /** HTML that goes into the `<head>` */
head: string; head: string;
@ -40,6 +44,9 @@ export interface SyncRenderOutput {
html: string; html: string;
/** HTML that goes somewhere into the `<body>` */ /** HTML that goes somewhere into the `<body>` */
body: string; body: string;
hashes: {
script: Sha256Source[];
};
} }
export type RenderOutput = SyncRenderOutput & PromiseLike<SyncRenderOutput>; export type RenderOutput = SyncRenderOutput & PromiseLike<SyncRenderOutput>;

@ -1,14 +1,14 @@
/** @import { SvelteComponent } from '../index.js' */ /** @import { SvelteComponent } from '../index.js' */
/** @import { Csp } from '#server' */
import { asClassComponent as as_class_component, createClassComponent } from './legacy-client.js'; import { asClassComponent as as_class_component, createClassComponent } from './legacy-client.js';
import { render } from '../internal/server/index.js'; import { render } from '../internal/server/index.js';
import { async_mode_flag } from '../internal/flags/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 // 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 }; 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. * Takes a Svelte 5 component and returns a Svelte 4 compatible component constructor.
@ -25,10 +25,10 @@ export { createClassComponent };
*/ */
export function asClassComponent(component) { export function asClassComponent(component) {
const component_constructor = as_class_component(component); const component_constructor = as_class_component(component);
/** @type {(props?: {}, opts?: { $$slots?: {}; context?: Map<any, any>; }) => LegacyRenderResult & PromiseLike<LegacyRenderResult> } */ /** @type {(props?: {}, opts?: { $$slots?: {}; context?: Map<any, any>; csp?: Csp }) => LegacyRenderResult & PromiseLike<LegacyRenderResult> } */
const _render = (props, { context } = {}) => { const _render = (props, { context, csp } = {}) => {
// @ts-expect-error the typings are off, but this will work if the component is compiled in SSR mode // @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( const munged = Object.defineProperties(
/** @type {LegacyRenderResult & PromiseLike<LegacyRenderResult>} */ ({}), /** @type {LegacyRenderResult & PromiseLike<LegacyRenderResult>} */ ({}),
@ -65,7 +65,8 @@ export function asClassComponent(component) {
return onfulfilled({ return onfulfilled({
css: munged.css, css: munged.css,
head: result.head, head: result.head,
html: result.body html: result.body,
hashes: result.hashes
}); });
}, onrejected); }, onrejected);
} }

@ -1,4 +1,4 @@
import type { RenderOutput } from '#server'; import type { Csp, RenderOutput } from '#server';
import type { ComponentProps, Component, SvelteComponent, ComponentType } from 'svelte'; import type { ComponentProps, Component, SvelteComponent, ComponentType } from 'svelte';
/** /**
@ -16,6 +16,7 @@ export function render<
props?: Omit<Props, '$$slots' | '$$events'>; props?: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>; context?: Map<any, any>;
idPrefix?: string; idPrefix?: string;
csp?: Csp;
} }
] ]
: [ : [
@ -24,6 +25,7 @@ export function render<
props: Omit<Props, '$$slots' | '$$events'>; props: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>; context?: Map<any, any>;
idPrefix?: string; idPrefix?: string;
csp?: Csp;
} }
] ]
): RenderOutput; ): RenderOutput;

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
mode: ['async'],
csp: { hash: true, nonce: 'test-nonce' },
error: 'invalid_csp'
});

@ -0,0 +1,12 @@
<script nonce="test-nonce">
{
const r = (v) => Promise.resolve(v);
const h = (window.__svelte ??= {}).h ??= new Map();
for (const [k, v] of [
["key",r("bar")]
]) {
h.set(k, v);
}
}
</script>

@ -0,0 +1,4 @@
<script>
import { hydratable } from "svelte";
const foo = await hydratable('key', () => Promise.resolve('bar'));
</script>

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
mode: ['async'],
csp: { hash: true },
script_hashes: ['sha256-J0xwNm40i0NVEdHYeMRThG7y90X+P/I1ElZGnpQ0AbU=']
});

@ -0,0 +1,12 @@
<script>
{
const r = (v) => Promise.resolve(v);
const h = (window.__svelte ??= {}).h ??= new Map();
for (const [k, v] of [
["key",r("bar")]
]) {
h.set(k, v);
}
}
</script>

@ -0,0 +1,4 @@
<script>
import { hydratable } from "svelte";
const foo = await hydratable('key', () => Promise.resolve('bar'));
</script>

@ -0,0 +1,6 @@
import { test } from '../../test';
export default test({
mode: ['async'],
csp: { nonce: 'test-nonce' }
});

@ -0,0 +1,12 @@
<script nonce="test-nonce">
{
const r = (v) => Promise.resolve(v);
const h = (window.__svelte ??= {}).h ??= new Map();
for (const [k, v] of [
["key",r("bar")]
]) {
h.set(k, v);
}
}
</script>

@ -0,0 +1,4 @@
<script>
import { hydratable } from "svelte";
const foo = await hydratable('key', () => Promise.resolve('bar'));
</script>

@ -21,6 +21,8 @@ interface SSRTest extends BaseTest {
id_prefix?: string; id_prefix?: string;
withoutNormalizeHtml?: boolean; withoutNormalizeHtml?: boolean;
error?: string; error?: string;
csp?: { nonce: string } | { hash: true };
script_hashes?: string[];
} }
// TODO remove this shim when we can // TODO remove this shim when we can
@ -77,7 +79,8 @@ const { test, run } = suite_with_variants<SSRTest, 'sync' | 'async', CompileOpti
try { try {
const render_result = render(Component, { const render_result = render(Component, {
props: config.props || {}, props: config.props || {},
idPrefix: config.id_prefix idPrefix: config.id_prefix,
csp: config.csp
}); });
rendered = is_async ? await render_result : render_result; rendered = is_async ? await render_result : render_result;
} catch (error) { } catch (error) {
@ -94,7 +97,7 @@ const { test, run } = suite_with_variants<SSRTest, 'sync' | 'async', CompileOpti
assert.fail('Expected an error to be thrown, but rendering succeeded.'); assert.fail('Expected an error to be thrown, but rendering succeeded.');
} }
const { body, head } = rendered; const { body, head, hashes } = rendered;
fs.writeFileSync( fs.writeFileSync(
`${test_dir}/_output/${is_async ? 'async_rendered.html' : 'rendered.html'}`, `${test_dir}/_output/${is_async ? 'async_rendered.html' : 'rendered.html'}`,
@ -140,6 +143,10 @@ const { test, run } = suite_with_variants<SSRTest, 'sync' | 'async', CompileOpti
} }
} }
} }
if (config.script_hashes !== undefined) {
assert.deepEqual(hashes.script, config.script_hashes);
}
} }
); );

@ -2553,6 +2553,7 @@ declare module 'svelte/server' {
props?: Omit<Props, '$$slots' | '$$events'>; props?: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>; context?: Map<any, any>;
idPrefix?: string; idPrefix?: string;
csp?: Csp;
} }
] ]
: [ : [
@ -2561,9 +2562,14 @@ declare module 'svelte/server' {
props: Omit<Props, '$$slots' | '$$events'>; props: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>; context?: Map<any, any>;
idPrefix?: string; idPrefix?: string;
csp?: Csp;
} }
] ]
): RenderOutput; ): RenderOutput;
type Csp = { nonce?: string; hash?: boolean };
type Sha256Source = `sha256-${string}`;
interface SyncRenderOutput { interface SyncRenderOutput {
/** HTML that goes into the `<head>` */ /** HTML that goes into the `<head>` */
head: string; head: string;
@ -2571,6 +2577,9 @@ declare module 'svelte/server' {
html: string; html: string;
/** HTML that goes somewhere into the `<body>` */ /** HTML that goes somewhere into the `<body>` */
body: string; body: string;
hashes: {
script: Sha256Source[];
};
} }
type RenderOutput = SyncRenderOutput & PromiseLike<SyncRenderOutput>; type RenderOutput = SyncRenderOutput & PromiseLike<SyncRenderOutput>;

Loading…
Cancel
Save