Repository: sveltejs/kit
Pull request #3499 (2022-01-21 21:10:25)

CSP
#93.

Lots to do here. CSP is a big topic and we might not nail it in a single PR, but I think this is a good starting point (let me know if I've overlooked or messed up anything):

* [x] add types for CSP directives
* [x] update `options.js` to expect new `csp` config option (including `mode: 'nonce' | 'hash' | 'auto'`)
* [ ] ~~generate hashes (probably easiest to just use sha256 everywhere?) from static assets~~ not necessary, it turns out
* [x] if `default-src` or `script-src` or `style-src` doesn't specify `unsafe-inline`, generate nonces or hashes (depending on the value of `mode` — defaults to 'auto', which means 'nonce for dynamically rendered stuff, hash for prerendered stuff'). Realistically, many apps will need `style-src: 'unsafe-inline'` until Svelte switches over to using WAAPI for transitions
* [ ] ~~if `default-src` or `script-src` or `style-src` specifies `strict-dynamic`, include nonces/hashes for _everything_ Kit controls~~ also not necessary
* [x] generate header, and add to response (or use http-equiv, for prerendering)
* [x] replace `%svelte.nonce%` in `src/app.html`, but not during prerendering
* [x] in dev, add `style-src 'unsafe-inline'` if necessary, skip nonce, ~~add `connect-src: ws://*`~~
* [x] always generate a nonce if `%svelte.nonce%` is present in the template (unless prerendering, obvs)

Stuff that's important but should probably stay out of scope for this PR:

* [ ] externalising scripts and styles when prerendering
* [ ] report-only 
* [ ] CSP for AMP pages
* [ ] `strict-dynamic` support


### Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
- [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
- [x] This message body should clearly illustrate what problems it solves.
- [x] Ideally, include a test that fails without this PR but passes with it.

### Tests
- [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` and `pnpm check`

### Changesets
- [ ] If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running `pnpx changeset` and following the prompts. All changesets should be `patch` until SvelteKit 1.0


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 12f2a09b8528bd524ea1aefffded4a96990af10b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/sveltejs/kit/new/csp?filename=.changeset/spotty-rules-think.md&value=---%0A%22%40sveltejs%2Fkit%22%3A%20patch%0A---%0A%0ACSP%0A)


netlify[bot]: 👷 Deploy Preview for *kit-demo* processing.


🔨 Explore the source changes: 12f2a09b8528bd524ea1aefffded4a96990af10b

🔍 Inspect the deploy log: [https://app.netlify.com/sites/kit-demo/deploys/61eb21425c916f0007e6688f](https://app.netlify.com/sites/kit-demo/deploys/61eb21425c916f0007e6688f)

Karlinator: I've been experimenting a bit, both with hashing at compile time and general mayhem.

Conclusion: Browsers are weird and inconsistent. 

When using `strict-dynamic`, Chrome will happily load all script files as long as the inline init script is hashed (it appears Chrome sees the preloading as safe, which I suppose is accurate, as long as CSP is satisfied when they are applied). Firefox, however, refuses to load the script imported from the inline init script even though that inline script is hashed. This happens when I remove the `modulepreload` links as well, so might just be an issue with Firefox and `strict-dynamic`. Firefox has no issues when the init script is nonced, however, even if the hashes or nonces for the preload links are not present (just like Chrome).

I think this genuinely is a bug in Firefox's impllementation of `strict-dynamic`.

Without `strict-dynamic`, Chrome allows external scripts to be hashed (but only if the link has an `integrity` attribute with the hash), Firefox does not (a known deficiency). Both function with either hashes or nonces covering the init script as long as the rest are allowed with `script-src 'self'`

Other than that I was able to get Kit to hash the client js files on render and put the hashes in the manifest (though I'm not sure I'm happy with where I put it), and then the runtime fetches those hashes and insert them into `integrity` attributes. It occurs to me that there's no real reason to not include the `integrity` attribute? Even if it isn't required/useful for your specific CSP setup it's still an anti-tamper check I guess. Code: https://github.com/Karlinator/kit/commit/a74901ef864615d8f93d974aa378bcd0fe2c5eae

What's the best way for me to contribute code to this PR btw, if you want it? Should I just make PRs into the `csp` branch?
Rich-Harris: > Conclusion: Browsers are weird and inconsistent.

Yikes. Thanks for investigating, that does indeed sound like a Firefox bug. The best we can do is probably support `strict-dynamic` to the best of our understanding, and note the bug in the documentation (and find/create the corresponding issue on bugzilla).

> What's the best way for me to contribute code to this PR btw, if you want it? Should I just make PRs into the `csp` branch?

I have some work locally that would likely conflict if we were both working in `src` (mostly moving code around in `render.js`, e.g. meta tags need to be prepended _after_ scripts have been generated) — will push it up later when I'm back at my desk. Might be easiest to hold off until then so we don't find ourselves in merge conflict hell. I haven't started thinking about tests though, so that would be super helpful if you get a chance!
Karlinator: > Yikes. Thanks for investigating, that does indeed sound like a Firefox bug. The best we can do is probably support strict-dynamic to the best of our understanding, and note the bug in the documentation (and find/create the corresponding issue on bugzilla).

I'm trying to narrow down the exact issue with reproduction so I can report it. But yeah, we'll just have to do our best with it, and advise that using `nonce` mode seems more consistent.

>  Might be easiest to hold off until then so we don't find ourselves in merge conflict hell

Sure. I was mostly experimenting, trying to get a flow of compile-time hashing (since the chunk files are completely static).

I might try to look at tests—or I'll just make more abstract contributions (read: find bugs in browsers) some more
Karlinator: Bugzilla report: https://bugzilla.mozilla.org/show_bug.cgi?id=1751573
Karlinator: A couple of edge cases with the dev server: 

Vite inserts a whole bunch of inline style tags in the dev server (for perfectly good reasons). The SSR can nonce the style tag it sees as much as it wants; Vite will promptly replace it with un-nonced tags after hydration. Since this only affects dev mode it's probably fine to insert `style-src 'unsafe-inline'`? We also have to remove the nonce from `style-src` then in dev mode otherwise `unsafe-inline` is ignored.

Vite connects over WebSocket in dev mode, so if `default-src` or `connect-src` is specified, it needs to add `connect-src ws://localhost:*` (or `ws://*` if you're hosting over the network?), else Vite can't do hot reloading.

I'd be fine with treating the dev server a little differently and noting it in the docs in case anyone is confused. If you're testing your CSP then you should do it in the adapter's emulator or something anyway.
Rich-Harris: Gah — tests are all failing because Node only added `webcrypto` in version 15. Ideally we'd use the Web Crypto API because that's what's available in modern environments (Node's `crypto` module is only available in Node itself, of course). Not sure what the best solution is — maybe we should use [SJCL](https://bitwiseshiftleft.github.io/sjcl/) or something for now, and move over to Web Crypto in the future when we can drop support for older Node.

I realised we only need to hash the CSS files for `strict-dynamic`, since the only JS on the page (that Kit controls) is the inline init block, so that's the only thing that needs to be nonced or hashed. (Unless modulepreload links need to be nonced/hashed?) Ideally we'd use a similar mechanism to the `inlineStyleThreshold` mechanism, meaning that we only serve hashes in production when `mode === 'hash'` (_not_ `auto`, since in that case we only use hashes for prerendering, in which case we should be able to read the hashes from the build directory rather than including them in the app), and only for the stylesheets needed by the current manifest (i.e. not one big object of hashes).

That, and the dev mode stuff, should be the last pieces of this PR — will look into it in the morning



Patch:
diff --git a/.changeset/funny-ads-move.md b/.changeset/funny-ads-move.md
new file mode 100644
index 000000000..edd82a613
--- /dev/null
+++ b/.changeset/funny-ads-move.md
@@ -0,0 +1,5 @@
+---
+'create-svelte': patch
+---
+
+bump eslint plugin and parser in template
diff --git a/.changeset/gorgeous-carrots-do.md b/.changeset/gorgeous-carrots-do.md
new file mode 100644
index 000000000..75b344ac3
--- /dev/null
+++ b/.changeset/gorgeous-carrots-do.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/adapter-netlify': patch
+---
+
+Fix string replacement in CJS builds
diff --git a/.changeset/khaki-brooms-notice.md b/.changeset/khaki-brooms-notice.md
new file mode 100644
index 000000000..2f8e07940
--- /dev/null
+++ b/.changeset/khaki-brooms-notice.md
@@ -0,0 +1,5 @@
+---
+'create-svelte': patch
+---
+
+add ESLint configuration for mixed JS/TS codebase
diff --git a/.changeset/light-pumas-appear.md b/.changeset/light-pumas-appear.md
new file mode 100644
index 000000000..f7e8f7c6a
--- /dev/null
+++ b/.changeset/light-pumas-appear.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] hydrate real HTTP requests
diff --git a/.changeset/nine-dots-occur.md b/.changeset/nine-dots-occur.md
new file mode 100644
index 000000000..31acf61d1
--- /dev/null
+++ b/.changeset/nine-dots-occur.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] reading from same response body twice during prerender (#3473)
diff --git a/.changeset/pre.json b/.changeset/pre.json
index d068986cd..9bc7a1735 100644
--- a/.changeset/pre.json
+++ b/.changeset/pre.json
@@ -178,6 +178,7 @@
     "friendly-carpets-sip",
     "friendly-chefs-draw",
     "friendly-rivers-argue",
+    "funny-ads-move",
     "funny-needles-talk",
     "funny-terms-repair",
     "funny-trainers-hammer",
@@ -199,6 +200,7 @@
     "good-beans-invite",
     "good-keys-argue",
     "good-monkeys-reflect",
+    "gorgeous-carrots-do",
     "gorgeous-donkeys-admire",
     "gorgeous-hounds-peel",
     "gorgeous-planes-rhyme",
@@ -254,6 +256,7 @@
     "itchy-days-wonder",
     "itchy-lobsters-tie",
     "khaki-bears-film",
+    "khaki-brooms-notice",
     "khaki-ears-repeat",
     "khaki-lions-sell",
     "khaki-socks-tan",
@@ -284,6 +287,7 @@
     "light-kangaroos-tie",
     "light-keys-mix",
     "light-olives-scream",
+    "light-pumas-appear",
     "light-roses-teach",
     "little-boats-allow",
     "little-candles-trade",
@@ -558,6 +562,7 @@
     "stupid-schools-fail",
     "swift-dingos-glow",
     "swift-jokes-compete",
+    "swift-peaches-enjoy",
     "swift-pumpkins-pump",
     "swift-vans-grow",
     "tall-berries-cheer",
diff --git a/.changeset/spicy-glasses-promise.md b/.changeset/spicy-glasses-promise.md
new file mode 100644
index 000000000..308633adb
--- /dev/null
+++ b/.changeset/spicy-glasses-promise.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Add CSP support
diff --git a/.changeset/swift-peaches-enjoy.md b/.changeset/swift-peaches-enjoy.md
new file mode 100644
index 000000000..74738954d
--- /dev/null
+++ b/.changeset/swift-peaches-enjoy.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+reinstate `EndpointOutput` generic
diff --git a/.changeset/tender-buses-talk.md b/.changeset/tender-buses-talk.md
new file mode 100644
index 000000000..561399989
--- /dev/null
+++ b/.changeset/tender-buses-talk.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[chore] remove InternalHandle
diff --git a/documentation/docs/01-routing.md b/documentation/docs/01-routing.md
index 57b3f8703..80c0961df 100644
--- a/documentation/docs/01-routing.md
+++ b/documentation/docs/01-routing.md
@@ -59,18 +59,20 @@ export interface RequestEvent<Locals = Record<string, any>> {
 }
 
 type Body = JSONString | Uint8Array | ReadableStream | stream.Readable;
-export interface EndpointOutput {
+export interface EndpointOutput<Output extends Body = Body> {
 	status?: number;
-	headers?: HeadersInit;
-	body?: Body;
+	headers?: Headers | Partial<ResponseHeaders>;
+	body?: Output;
 }
 
 type MaybePromise<T> = T | Promise<T>;
 interface Fallthrough {
 	fallthrough: true;
 }
-export interface RequestHandler<Locals = Record<string, any>> {
-	(event: RequestEvent<Locals>): MaybePromise<Either<Response | EndpointOutput, Fallthrough>>;
+export interface RequestHandler<Locals = Record<string, any>, Output extends Body = Body> {
+	(event: RequestEvent<Locals>): MaybePromise<
+		Either<Response | EndpointOutput<Output>, Fallthrough>
+	>;
 }
 ```
 
diff --git a/documentation/docs/02-layouts.md b/documentation/docs/02-layouts.md
index 2a889c2f1..6211e825b 100644
--- a/documentation/docs/02-layouts.md
+++ b/documentation/docs/02-layouts.md
@@ -111,4 +111,6 @@ If an error component has a [`load`](#loading) function, it will be called with
 <h1>{title}</h1>
 ```
 
+> Layout components also have access to `error` and `status` via the [page store](#modules-$app-stores)
+>
 > Server-side stack traces will be removed from `error` in production, to avoid exposing privileged information to users.
diff --git a/documentation/docs/05-modules.md b/documentation/docs/05-modules.md
index d536dd0a0..72b606884 100644
--- a/documentation/docs/05-modules.md
+++ b/documentation/docs/05-modules.md
@@ -68,7 +68,7 @@ Because of that, the stores are not free-floating objects: they must be accessed
 The stores themselves attach to the correct context at the point of subscription, which means you can import and use them directly in components without boilerplate. However, it still needs to be called synchronously on component or page initialisation when the `$`-prefix isn't used. Use `getStores` to safely `.subscribe` asynchronously instead.
 
 - `navigating` is a [readable store](https://svelte.dev/tutorial/readable-stores). When navigating starts, its value is `{ from, to }`, where `from` and `to` are both [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) instances. When navigating finishes, its value reverts to `null`.
-- `page` contains an object with the current [`url`](https://developer.mozilla.org/en-US/docs/Web/API/URL), [`params`](#loading-input-params) and [`stuff`](#loading-output-stuff).
+- `page` contains an object with the current [`url`](https://developer.mozilla.org/en-US/docs/Web/API/URL), [`params`](#loading-input-params), [`stuff`](#loading-output-stuff), [`status`](#loading-output-status) and [`error`](#loading-output-error).
 - `session` is a [writable store](https://svelte.dev/tutorial/writable-stores) whose initial value is whatever was returned from [`getSession`](#hooks-getsession). It can be written to, but this will _not_ cause changes to persist on the server — this is something you must implement yourself.
 
 ### $lib
diff --git a/documentation/docs/14-configuration.md b/documentation/docs/14-configuration.md
index 256043b2f..95e3b2f42 100644
--- a/documentation/docs/14-configuration.md
+++ b/documentation/docs/14-configuration.md
@@ -17,6 +17,13 @@ const config = {
 		adapter: null,
 		amp: false,
 		appDir: '_app',
+		csp: {
+			mode: 'auto',
+			directives: {
+				'default-src': undefined
+				// ...
+			}
+		},
 		files: {
 			assets: 'static',
 			hooks: 'src/hooks',
@@ -82,6 +89,29 @@ Enable [AMP](#amp) mode.
 
 The directory relative to `paths.assets` where the built JS and CSS (and imported assets) are served from. (The filenames therein contain content-based hashes, meaning they can be cached indefinitely). Must not start or end with `/`.
 
+### csp
+
+An object containing zero or more of the following values:
+
+- `mode` — 'hash', 'nonce' or 'auto'
+- `directives` — an object of `[directive]: value[]` pairs.
+
+[Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy) configuration. CSP helps to protect your users against cross-site scripting (XSS) attacks, by limiting the places resources can be loaded from. For example, a configuration like this...
+
+```js
+{
+	directives: {
+		'script-src': ['self']
+	}
+}
+```
+
+...would prevent scripts loading from external sites. SvelteKit will augment the specified directives with nonces or hashes (depending on `mode`) for any inline styles and scripts it generates.
+
+When pages are prerendered, the CSP header is added via a `<meta http-equiv>` tag (note that in this case, `frame-ancestors`, `report-uri` and `sandbox` directives will be ignored).
+
+> When `mode` is `'auto'`, SvelteKit will use nonces for dynamically rendered pages and hashes for prerendered pages. Using nonces with prerendered pages is insecure and therefore forbiddem.
+
 ### files
 
 An object containing zero or more of the following `string` values:
diff --git a/documentation/migrating/01-migrating.md b/documentation/migrating/01-migrating.md
index 9ce1f8535..ae1491b71 100644
--- a/documentation/migrating/01-migrating.md
+++ b/documentation/migrating/01-migrating.md
@@ -4,4 +4,4 @@ title: Migrating from Sapper
 
 SvelteKit is the successor to Sapper and shares many elements of its design.
 
-If you have an existing Sapper app that you plan to migrate to SvelteKit, there are a number of changes you will need to make. You may find it helpful to view the [examples](https://github.com/sveltejs/kit/tree/master/examples) while migrating.
+If you have an existing Sapper app that you plan to migrate to SvelteKit, there are a number of changes you will need to make. You may find it helpful to view [some examples](https://kit.svelte.dev/docs#additional-resources-examples) while migrating.
diff --git a/packages/adapter-auto/CHANGELOG.md b/packages/adapter-auto/CHANGELOG.md
index 862393143..e1fb66f16 100644
--- a/packages/adapter-auto/CHANGELOG.md
+++ b/packages/adapter-auto/CHANGELOG.md
@@ -1,5 +1,14 @@
 # @sveltejs/adapter-auto
 
+## 1.0.0-next.16
+
+### Patch Changes
+
+- Updated dependencies [[`90664c9d`](https://github.com/sveltejs/kit/commit/90664c9dd9749086ac0b34aa2acd625174af80cf)]:
+  - @sveltejs/adapter-netlify@1.0.0-next.44
+  - @sveltejs/adapter-cloudflare@1.0.0-next.8
+  - @sveltejs/adapter-vercel@1.0.0-next.39
+
 ## 1.0.0-next.15
 
 ### Patch Changes
diff --git a/packages/adapter-auto/package.json b/packages/adapter-auto/package.json
index 3fb5dd748..56ff47d9e 100644
--- a/packages/adapter-auto/package.json
+++ b/packages/adapter-auto/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@sveltejs/adapter-auto",
-	"version": "1.0.0-next.15",
+	"version": "1.0.0-next.16",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
diff --git a/packages/adapter-netlify/CHANGELOG.md b/packages/adapter-netlify/CHANGELOG.md
index 0fa2fa33a..edfc652a6 100644
--- a/packages/adapter-netlify/CHANGELOG.md
+++ b/packages/adapter-netlify/CHANGELOG.md
@@ -1,5 +1,11 @@
 # @sveltejs/adapter-netlify
 
+## 1.0.0-next.44
+
+### Patch Changes
+
+- Fix string replacement in CJS builds ([#3546](https://github.com/sveltejs/kit/pull/3546))
+
 ## 1.0.0-next.43
 
 ### Patch Changes
diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js
index 2aa36a245..3ebb45324 100644
--- a/packages/adapter-netlify/index.js
+++ b/packages/adapter-netlify/index.js
@@ -51,7 +51,7 @@ export default function ({ split = false } = {}) {
 			const redirects = [];
 
 			const replace = {
-				APP: './server/app.js'
+				'0APP': './server/app.js' // digit prefix prevents CJS build from using this as a variable name, which would also get replaced
 			};
 
 			if (esm) {
diff --git a/packages/adapter-netlify/package.json b/packages/adapter-netlify/package.json
index 4e009c1fd..f111369ac 100644
--- a/packages/adapter-netlify/package.json
+++ b/packages/adapter-netlify/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@sveltejs/adapter-netlify",
-	"version": "1.0.0-next.43",
+	"version": "1.0.0-next.44",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
diff --git a/packages/adapter-netlify/rollup.config.js b/packages/adapter-netlify/rollup.config.js
index 2d88582be..7564de03e 100644
--- a/packages/adapter-netlify/rollup.config.js
+++ b/packages/adapter-netlify/rollup.config.js
@@ -19,6 +19,7 @@ export default [
 			}
 		],
 		plugins: [nodeResolve(), commonjs(), json()],
-		external: ['APP', ...require('module').builtinModules]
+		external: (id) => id === '0APP' || id.startsWith('node:'),
+		preserveEntrySignatures: true
 	}
 ];
diff --git a/packages/adapter-netlify/src/handler.d.ts b/packages/adapter-netlify/src/handler.d.ts
index 556c999c9..75ea691df 100644
--- a/packages/adapter-netlify/src/handler.d.ts
+++ b/packages/adapter-netlify/src/handler.d.ts
@@ -1,3 +1,3 @@
-declare module 'APP' {
+declare module '0APP' {
 	export { App } from '@sveltejs/kit';
 }
diff --git a/packages/adapter-netlify/src/handler.js b/packages/adapter-netlify/src/handler.js
index e75fdd97a..e23c8f96d 100644
--- a/packages/adapter-netlify/src/handler.js
+++ b/packages/adapter-netlify/src/handler.js
@@ -1,5 +1,5 @@
 import './shims';
-import { App } from 'APP';
+import { App } from '0APP';
 
 /**
  * @param {import('@sveltejs/kit').SSRManifest} manifest
diff --git a/packages/create-svelte/CHANGELOG.md b/packages/create-svelte/CHANGELOG.md
index 57eb9f82d..15f26d6f5 100644
--- a/packages/create-svelte/CHANGELOG.md
+++ b/packages/create-svelte/CHANGELOG.md
@@ -1,5 +1,17 @@
 # create-svelte
 
+## 2.0.0-next.101
+
+### Patch Changes
+
+- bump eslint plugin and parser in template ([#3544](https://github.com/sveltejs/kit/pull/3544))
+
+## 2.0.0-next.100
+
+### Patch Changes
+
+- add ESLint configuration for mixed JS/TS codebase ([#3536](https://github.com/sveltejs/kit/pull/3536))
+
 ## 2.0.0-next.99
 
 ### Patch Changes
diff --git a/packages/create-svelte/package.json b/packages/create-svelte/package.json
index 8cce41469..fd4471339 100644
--- a/packages/create-svelte/package.json
+++ b/packages/create-svelte/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "create-svelte",
-	"version": "2.0.0-next.99",
+	"version": "2.0.0-next.101",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
diff --git a/packages/create-svelte/shared/+eslint+typescript/package.json b/packages/create-svelte/shared/+eslint+typescript/package.json
index 4e53c214c..e4aae4b86 100644
--- a/packages/create-svelte/shared/+eslint+typescript/package.json
+++ b/packages/create-svelte/shared/+eslint+typescript/package.json
@@ -1,6 +1,6 @@
 {
 	"devDependencies": {
-		"@typescript-eslint/eslint-plugin": "^4.31.1",
-		"@typescript-eslint/parser": "^4.31.1"
+		"@typescript-eslint/eslint-plugin": "^5.10.1",
+		"@typescript-eslint/parser": "^5.10.1"
 	}
 }
diff --git a/packages/kit/CHANGELOG.md b/packages/kit/CHANGELOG.md
index 74f5a12db..934287c6a 100644
--- a/packages/kit/CHANGELOG.md
+++ b/packages/kit/CHANGELOG.md
@@ -1,5 +1,17 @@
 # @sveltejs/kit
 
+## 1.0.0-next.243
+
+### Patch Changes
+
+- [fix] hydrate real HTTP requests ([#3547](https://github.com/sveltejs/kit/pull/3547))
+
+## 1.0.0-next.242
+
+### Patch Changes
+
+- reinstate `EndpointOutput` generic ([#3537](https://github.com/sveltejs/kit/pull/3537))
+
 ## 1.0.0-next.241
 
 ### Patch Changes
diff --git a/packages/kit/package.json b/packages/kit/package.json
index 9bdf36eb1..7a0eb1ddc 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@sveltejs/kit",
-	"version": "1.0.0-next.241",
+	"version": "1.0.0-next.243",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
diff --git a/packages/kit/src/core/adapt/prerender/prerender.js b/packages/kit/src/core/adapt/prerender/prerender.js
index d3c8f7005..acbf9d434 100644
--- a/packages/kit/src/core/adapt/prerender/prerender.js
+++ b/packages/kit/src/core/adapt/prerender/prerender.js
@@ -130,8 +130,9 @@ export async function prerender({ cwd, out, log, config, build_data, fallback, a
 	 * @param {string?} referrer
 	 */
 	async function visit(path, decoded_path, referrer) {
-		/** @type {Map<string, Response>} */
+		/** @type {Map<string, import('types/internal').PrerenderDependency>} */
 		const dependencies = new Map();
+
 		const render_path = config.kit.paths?.base
 			? `http://sveltekit-prerender${config.kit.paths.base}${path === '/' ? '' : path}`
 			: `http://sveltekit-prerender${path}`;
@@ -192,9 +193,11 @@ export async function prerender({ cwd, out, log, config, build_data, fallback, a
 			}
 
 			for (const [dependency_path, result] of dependencies) {
-				const response_type = Math.floor(result.status / 100);
+				const { status, headers } = result.response;
+
+				const response_type = Math.floor(status / 100);
 
-				const is_html = result.headers.get('content-type') === 'text/html';
+				const is_html = headers.get('content-type') === 'text/html';
 
 				const parts = dependency_path.split('/');
 				if (is_html && parts[parts.length - 1] !== 'index.html') {
@@ -204,16 +207,17 @@ export async function prerender({ cwd, out, log, config, build_data, fallback, a
 				const file = `${out}${parts.join('/')}`;
 				mkdirp(dirname(file));
 
-				if (result.body) {
-					writeFileSync(file, await result.text());
-					paths.push(dependency_path);
-				}
+				writeFileSync(
+					file,
+					result.body === null ? new Uint8Array(await result.response.arrayBuffer()) : result.body
+				);
+				paths.push(dependency_path);
 
 				if (response_type === OK) {
-					log.info(`${result.status} ${dependency_path}`);
+					log.info(`${status} ${dependency_path}`);
 				} else {
 					error({
-						status: result.status,
+						status,
 						path: dependency_path,
 						referrer: path,
 						referenceType: 'fetched'
diff --git a/packages/kit/src/core/build/build_server.js b/packages/kit/src/core/build/build_server.js
index 0e8f9b6cb..5f0954e49 100644
--- a/packages/kit/src/core/build/build_server.js
+++ b/packages/kit/src/core/build/build_server.js
@@ -11,24 +11,25 @@ import { s } from '../../utils/misc.js';
 
 /**
  * @param {{
- *   cwd: string;
  *   hooks: string;
  *   config: import('types/config').ValidatedConfig;
  *   has_service_worker: boolean;
+ *   template: string;
  * }} opts
  * @returns
  */
-const template = ({ cwd, config, hooks, has_service_worker }) => `
+const app_template = ({ config, hooks, has_service_worker, template }) => `
 import root from '__GENERATED__/root.svelte';
 import { respond } from '${runtime}/server/index.js';
 import { set_paths, assets, base } from '${runtime}/paths.js';
 import { set_prerendering } from '${runtime}/env.js';
 import * as user_hooks from ${s(hooks)};
 
-const template = ({ head, body, assets }) => ${s(load_template(cwd, config))
+const template = ({ head, body, assets, nonce }) => ${s(template)
 	.replace('%svelte.head%', '" + head + "')
 	.replace('%svelte.body%', '" + body + "')
-	.replace(/%svelte\.assets%/g, '" + assets + "')};
+	.replace(/%svelte\.assets%/g, '" + assets + "')
+	.replace(/%svelte\.nonce%/g, '" + nonce + "')};
 
 let read = null;
 
@@ -60,6 +61,7 @@ export class App {
 
 		this.options = {
 			amp: ${config.kit.amp},
+			csp: ${s(config.kit.csp)},
 			dev: false,
 			floc: ${config.kit.floc},
 			get_stack: error => String(error), // for security
@@ -89,6 +91,7 @@ export class App {
 			router: ${s(config.kit.router)},
 			target: ${s(config.kit.target)},
 			template,
+			template_contains_nonce: ${template.includes('%svelte.nonce%')},
 			trailing_slash: ${s(config.kit.trailingSlash)}
 		};
 	}
@@ -172,11 +175,11 @@ export async function build_server(
 	// prettier-ignore
 	fs.writeFileSync(
 		input.app,
-		template({
-			cwd,
+		app_template({
 			config,
 			hooks: app_relative(hooks_file),
-			has_service_worker: service_worker_register && !!service_worker_entry_file
+			has_service_worker: service_worker_register && !!service_worker_entry_file,
+			template: load_template(cwd, config)
 		})
 	);
 
diff --git a/packages/kit/src/core/config/test/fixtures/default/svelte.config.js b/packages/kit/src/core/config/fixtures/default/svelte.config.js
similarity index 100%
rename from packages/kit/src/core/config/test/fixtures/default/svelte.config.js
rename to packages/kit/src/core/config/fixtures/default/svelte.config.js
diff --git a/packages/kit/src/core/config/test/fixtures/export-string/svelte.config.js b/packages/kit/src/core/config/fixtures/export-string/svelte.config.js
similarity index 100%
rename from packages/kit/src/core/config/test/fixtures/export-string/svelte.config.js
rename to packages/kit/src/core/config/fixtures/export-string/svelte.config.js
diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js
index df7f0a0e5..0ef2d5f9f 100644
--- a/packages/kit/src/core/config/index.spec.js
+++ b/packages/kit/src/core/config/index.spec.js
@@ -1,8 +1,99 @@
+import { join } from 'path';
+import { fileURLToPath } from 'url';
 import { test } from 'uvu';
 import * as assert from 'uvu/assert';
-
 import { remove_keys } from '../../utils/object.js';
-import { validate_config } from './index.js';
+import { validate_config, load_config } from './index.js';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = join(__filename, '..');
+
+const get_defaults = (prefix = '') => ({
+	extensions: ['.svelte'],
+	kit: {
+		adapter: null,
+		amp: false,
+		appDir: '_app',
+		csp: {
+			mode: 'auto',
+			directives: {
+				'child-src': undefined,
+				'default-src': undefined,
+				'frame-src': undefined,
+				'worker-src': undefined,
+				'connect-src': undefined,
+				'font-src': undefined,
+				'img-src': undefined,
+				'manifest-src': undefined,
+				'media-src': undefined,
+				'object-src': undefined,
+				'prefetch-src': undefined,
+				'script-src': undefined,
+				'script-src-elem': undefined,
+				'script-src-attr': undefined,
+				'style-src': undefined,
+				'style-src-elem': undefined,
+				'style-src-attr': undefined,
+				'base-uri': undefined,
+				sandbox: undefined,
+				'form-action': undefined,
+				'frame-ancestors': undefined,
+				'navigate-to': undefined,
+				'report-uri': undefined,
+				'report-to': undefined,
+				'require-trusted-types-for': undefined,
+				'trusted-types': undefined,
+				'upgrade-insecure-requests': false,
+				'require-sri-for': undefined,
+				'block-all-mixed-content': false,
+				'plugin-types': undefined,
+				referrer: undefined
+			}
+		},
+		files: {
+			assets: join(prefix, 'static'),
+			hooks: join(prefix, 'src/hooks'),
+			lib: join(prefix, 'src/lib'),
+			routes: join(prefix, 'src/routes'),
+			serviceWorker: join(prefix, 'src/service-worker'),
+			template: join(prefix, 'src/app.html')
+		},
+		floc: false,
+		headers: undefined,
+		host: undefined,
+		hydrate: true,
+		inlineStyleThreshold: 0,
+		methodOverride: {
+			parameter: '_method',
+			allowed: []
+		},
+		package: {
+			dir: 'package',
+			emitTypes: true
+		},
+		serviceWorker: {
+			register: true
+		},
+		paths: {
+			base: '',
+			assets: ''
+		},
+		prerender: {
+			concurrency: 1,
+			crawl: true,
+			enabled: true,
+			entries: ['*'],
+			force: undefined,
+			onError: 'fail',
+			pages: undefined
+		},
+		protocol: undefined,
+		router: true,
+		ssr: null,
+		target: null,
+		trailingSlash: 'never'
+	}
+});
 
 test('fills in defaults', () => {
 	const validated = validate_config({});
@@ -14,56 +105,7 @@ test('fills in defaults', () => {
 
 	remove_keys(validated, ([, v]) => typeof v === 'function');
 
-	assert.equal(validated, {
-		extensions: ['.svelte'],
-		kit: {
-			adapter: null,
-			amp: false,
-			appDir: '_app',
-			files: {
-				assets: 'static',
-				hooks: 'src/hooks',
-				lib: 'src/lib',
-				routes: 'src/routes',
-				serviceWorker: 'src/service-worker',
-				template: 'src/app.html'
-			},
-			floc: false,
-			headers: undefined,
-			host: undefined,
-			hydrate: true,
-			inlineStyleThreshold: 0,
-			methodOverride: {
-				parameter: '_method',
-				allowed: []
-			},
-			package: {
-				dir: 'package',
-				emitTypes: true
-			},
-			serviceWorker: {
-				register: true
-			},
-			paths: {
-				base: '',
-				assets: ''
-			},
-			prerender: {
-				concurrency: 1,
-				crawl: true,
-				enabled: true,
-				entries: ['*'],
-				force: undefined,
-				onError: 'fail',
-				pages: undefined
-			},
-			protocol: undefined,
-			router: true,
-			ssr: null,
-			target: null,
-			trailingSlash: 'never'
-		}
-	});
+	assert.equal(validated, get_defaults());
 });
 
 test('errors on invalid values', () => {
@@ -123,56 +165,10 @@ test('fills in partial blanks', () => {
 
 	remove_keys(validated, ([, v]) => typeof v === 'function');
 
-	assert.equal(validated, {
-		extensions: ['.svelte'],
-		kit: {
-			adapter: null,
-			amp: false,
-			appDir: '_app',
-			files: {
-				assets: 'public',
-				hooks: 'src/hooks',
-				lib: 'src/lib',
-				routes: 'src/routes',
-				serviceWorker: 'src/service-worker',
-				template: 'src/app.html'
-			},
-			floc: false,
-			headers: undefined,
-			host: undefined,
-			hydrate: true,
-			inlineStyleThreshold: 0,
-			methodOverride: {
-				parameter: '_method',
-				allowed: []
-			},
-			package: {
-				dir: 'package',
-				emitTypes: true
-			},
-			serviceWorker: {
-				register: true
-			},
-			paths: {
-				base: '',
-				assets: ''
-			},
-			prerender: {
-				concurrency: 1,
-				crawl: true,
-				enabled: true,
-				entries: ['*'],
-				force: undefined,
-				onError: 'fail',
-				pages: undefined
-			},
-			protocol: undefined,
-			router: true,
-			ssr: null,
-			target: null,
-			trailingSlash: 'never'
-		}
-	});
+	const config = get_defaults();
+	config.kit.files.assets = 'public';
+
+	assert.equal(validated, config);
 });
 
 test('fails if kit.appDir is blank', () => {
@@ -327,4 +323,29 @@ validate_paths(
 	}
 );
 
+test('load default config (esm)', async () => {
+	const cwd = join(__dirname, 'fixtures/default');
+
+	const config = await load_config({ cwd });
+	remove_keys(config, ([, v]) => typeof v === 'function');
+
+	assert.equal(config, get_defaults(cwd + '/'));
+});
+
+test('errors on loading config with incorrect default export', async () => {
+	let message = null;
+
+	try {
+		const cwd = join(__dirname, 'fixtures', 'export-string');
+		await load_config({ cwd });
+	} catch (/** @type {any} */ e) {
+		message = e.message;
+	}
+
+	assert.equal(
+		message,
+		'svelte.config.js must have a configuration object as its default export. See https://kit.svelte.dev/docs#configuration'
+	);
+});
+
 test.run();
diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js
index 96d1b8ad9..bda10e275 100644
--- a/packages/kit/src/core/config/options.js
+++ b/packages/kit/src/core/config/options.js
@@ -1,3 +1,5 @@
+import { join } from 'path';
+
 /** @typedef {import('./types').Validator} Validator */
 
 /** @type {Validator} */
@@ -55,13 +57,50 @@ const options = object(
 				return input;
 			}),
 
+			csp: object({
+				mode: list(['auto', 'hash', 'nonce']),
+				directives: object({
+					'child-src': string_array(),
+					'default-src': string_array(),
+					'frame-src': string_array(),
+					'worker-src': string_array(),
+					'connect-src': string_array(),
+					'font-src': string_array(),
+					'img-src': string_array(),
+					'manifest-src': string_array(),
+					'media-src': string_array(),
+					'object-src': string_array(),
+					'prefetch-src': string_array(),
+					'script-src': string_array(),
+					'script-src-elem': string_array(),
+					'script-src-attr': string_array(),
+					'style-src': string_array(),
+					'style-src-elem': string_array(),
+					'style-src-attr': string_array(),
+					'base-uri': string_array(),
+					sandbox: string_array(),
+					'form-action': string_array(),
+					'frame-ancestors': string_array(),
+					'navigate-to': string_array(),
+					'report-uri': string_array(),
+					'report-to': string_array(),
+					'require-trusted-types-for': string_array(),
+					'trusted-types': string_array(),
+					'upgrade-insecure-requests': boolean(false),
+					'require-sri-for': string_array(),
+					'block-all-mixed-content': boolean(false),
+					'plugin-types': string_array(),
+					referrer: string_array()
+				})
+			}),
+
 			files: object({
 				assets: string('static'),
-				hooks: string('src/hooks'),
-				lib: string('src/lib'),
-				routes: string('src/routes'),
-				serviceWorker: string('src/service-worker'),
-				template: string('src/app.html')
+				hooks: string(join('src', 'hooks')),
+				lib: string(join('src', 'lib')),
+				routes: string(join('src', 'routes')),
+				serviceWorker: string(join('src', 'service-worker')),
+				template: string(join('src', 'app.html'))
 			}),
 
 			floc: boolean(false),
@@ -312,6 +351,22 @@ function string(fallback, allow_empty = true) {
 	});
 }
 
+/**
+ * @param {string[] | undefined} [fallback]
+ * @returns {Validator}
+ */
+function string_array(fallback) {
+	return validate(fallback, (input, keypath) => {
+		if (input === undefined) return input;
+
+		if (!Array.isArray(input) || input.some((value) => typeof value !== 'string')) {
+			throw new Error(`${keypath} must be an array of strings, if specified`);
+		}
+
+		return input;
+	});
+}
+
 /**
  * @param {number} fallback
  * @returns {Validator}
diff --git a/packages/kit/src/core/dev/plugin.js b/packages/kit/src/core/dev/plugin.js
index f8116132f..e4ac193c9 100644
--- a/packages/kit/src/core/dev/plugin.js
+++ b/packages/kit/src/core/dev/plugin.js
@@ -214,8 +214,11 @@ export async function create_plugin(config, cwd) {
 							return res.end(err.reason || 'Invalid request body');
 						}
 
+						const template = load_template(cwd, config);
+
 						const rendered = await respond(request, {
 							amp: config.kit.amp,
+							csp: config.kit.csp,
 							dev: true,
 							floc: config.kit.floc,
 							get_stack: (error) => {
@@ -251,9 +254,10 @@ export async function create_plugin(config, cwd) {
 							root,
 							router: config.kit.router,
 							target: config.kit.target,
-							template: ({ head, body, assets }) => {
-								let rendered = load_template(cwd, config)
+							template: ({ head, body, assets, nonce }) => {
+								let rendered = template
 									.replace(/%svelte\.assets%/g, assets)
+									.replace(/%svelte\.nonce%/g, nonce)
 									// head and body must be replaced last, in case someone tries to sneak in %svelte.assets% etc
 									.replace('%svelte.head%', () => head)
 									.replace('%svelte.body%', () => body);
@@ -304,6 +308,7 @@ export async function create_plugin(config, cwd) {
 
 								return rendered;
 							},
+							template_contains_nonce: template.includes('%svelte.nonce%'),
 							trailing_slash: config.kit.trailingSlash
 						});
 
diff --git a/packages/kit/src/install-fetch.js b/packages/kit/src/install-fetch.js
index aee7971d7..96a0e8c27 100644
--- a/packages/kit/src/install-fetch.js
+++ b/packages/kit/src/install-fetch.js
@@ -1,6 +1,6 @@
 import fetch, { Response, Request, Headers } from 'node-fetch';
 
-// exported for dev, prerender, and preview
+// exported for dev/preview and node environments
 export function __fetch_polyfill() {
 	Object.defineProperties(globalThis, {
 		fetch: {
diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js
index f0511349b..e74dc732d 100644
--- a/packages/kit/src/runtime/server/index.js
+++ b/packages/kit/src/runtime/server/index.js
@@ -122,7 +122,9 @@ export async function respond(request, options, state = {}) {
 				let decoded = decodeURI(event.url.pathname);
 
 				if (options.paths.base) {
-					if (!decoded.startsWith(options.paths.base)) return;
+					if (!decoded.startsWith(options.paths.base)) {
+						return new Response(undefined, { status: 404 });
+					}
 					decoded = decoded.slice(options.paths.base.length) || '/';
 				}
 
@@ -187,6 +189,10 @@ export async function respond(request, options, state = {}) {
 						ssr
 					});
 				}
+
+				// we can't load the endpoint from our own manifest,
+				// so we need to make an actual HTTP request
+				return await fetch(request);
 			},
 
 			// TODO remove for 1.0
diff --git a/packages/kit/src/runtime/server/page/crypto.js b/packages/kit/src/runtime/server/page/crypto.js
new file mode 100644
index 000000000..9af02da51
--- /dev/null
+++ b/packages/kit/src/runtime/server/page/crypto.js
@@ -0,0 +1,239 @@
+const encoder = new TextEncoder();
+
+/**
+ * SHA-256 hashing function adapted from https://bitwiseshiftleft.github.io/sjcl
+ * modified and redistributed under BSD license
+ * @param {string} data
+ */
+export function sha256(data) {
+	if (!key[0]) precompute();
+
+	const out = init.slice(0);
+	const array = encode(data);
+
+	for (let i = 0; i < array.length; i += 16) {
+		const w = array.subarray(i, i + 16);
+
+		let tmp;
+		let a;
+		let b;
+
+		let out0 = out[0];
+		let out1 = out[1];
+		let out2 = out[2];
+		let out3 = out[3];
+		let out4 = out[4];
+		let out5 = out[5];
+		let out6 = out[6];
+		let out7 = out[7];
+
+		/* Rationale for placement of |0 :
+		 * If a value can overflow is original 32 bits by a factor of more than a few
+		 * million (2^23 ish), there is a possibility that it might overflow the
+		 * 53-bit mantissa and lose precision.
+		 *
+		 * To avoid this, we clamp back to 32 bits by |'ing with 0 on any value that
+		 * propagates around the loop, and on the hash state out[]. I don't believe
+		 * that the clamps on out4 and on out0 are strictly necessary, but it's close
+		 * (for out4 anyway), and better safe than sorry.
+		 *
+		 * The clamps on out[] are necessary for the output to be correct even in the
+		 * common case and for short inputs.
+		 */
+
+		for (let i = 0; i < 64; i++) {
+			// load up the input word for this round
+
+			if (i < 16) {
+				tmp = w[i];
+			} else {
+				a = w[(i + 1) & 15];
+
+				b = w[(i + 14) & 15];
+
+				tmp = w[i & 15] =
+					(((a >>> 7) ^ (a >>> 18) ^ (a >>> 3) ^ (a << 25) ^ (a << 14)) +
+						((b >>> 17) ^ (b >>> 19) ^ (b >>> 10) ^ (b << 15) ^ (b << 13)) +
+						w[i & 15] +
+						w[(i + 9) & 15]) |
+					0;
+			}
+
+			tmp =
+				tmp +
+				out7 +
+				((out4 >>> 6) ^ (out4 >>> 11) ^ (out4 >>> 25) ^ (out4 << 26) ^ (out4 << 21) ^ (out4 << 7)) +
+				(out6 ^ (out4 & (out5 ^ out6))) +
+				key[i]; // | 0;
+
+			// shift register
+			out7 = out6;
+			out6 = out5;
+			out5 = out4;
+
+			out4 = (out3 + tmp) | 0;
+
+			out3 = out2;
+			out2 = out1;
+			out1 = out0;
+
+			out0 =
+				(tmp +
+					((out1 & out2) ^ (out3 & (out1 ^ out2))) +
+					((out1 >>> 2) ^
+						(out1 >>> 13) ^
+						(out1 >>> 22) ^
+						(out1 << 30) ^
+						(out1 << 19) ^
+						(out1 << 10))) |
+				0;
+		}
+
+		out[0] = (out[0] + out0) | 0;
+		out[1] = (out[1] + out1) | 0;
+		out[2] = (out[2] + out2) | 0;
+		out[3] = (out[3] + out3) | 0;
+		out[4] = (out[4] + out4) | 0;
+		out[5] = (out[5] + out5) | 0;
+		out[6] = (out[6] + out6) | 0;
+		out[7] = (out[7] + out7) | 0;
+	}
+
+	const bytes = new Uint8Array(out.buffer);
+	reverse_endianness(bytes);
+
+	return base64(bytes);
+}
+
+/** The SHA-256 initialization vector */
+const init = new Uint32Array(8);
+
+/** The SHA-256 hash key */
+const key = new Uint32Array(64);
+
+/** Function to precompute init and key. */
+function precompute() {
+	/** @param {number} x */
+	function frac(x) {
+		return (x - Math.floor(x)) * 0x100000000;
+	}
+
+	let prime = 2;
+
+	for (let i = 0; i < 64; prime++) {
+		let is_prime = true;
+
+		for (let factor = 2; factor * factor <= prime; factor++) {
+			if (prime % factor === 0) {
+				is_prime = false;
+
+				break;
+			}
+		}
+
+		if (is_prime) {
+			if (i < 8) {
+				init[i] = frac(prime ** (1 / 2));
+			}
+
+			key[i] = frac(prime ** (1 / 3));
+
+			i++;
+		}
+	}
+}
+
+/** @param {Uint8Array} bytes */
+function reverse_endianness(bytes) {
+	for (let i = 0; i < bytes.length; i += 4) {
+		const a = bytes[i + 0];
+		const b = bytes[i + 1];
+		const c = bytes[i + 2];
+		const d = bytes[i + 3];
+
+		bytes[i + 0] = d;
+		bytes[i + 1] = c;
+		bytes[i + 2] = b;
+		bytes[i + 3] = a;
+	}
+}
+
+/** @param {string} str */
+function encode(str) {
+	const encoded = encoder.encode(str);
+	const length = encoded.length * 8;
+
+	// result should be a multiple of 512 bits in length,
+	// with room for a 1 (after the data) and two 32-bit
+	// words containing the original input bit length
+	const size = 512 * Math.ceil((length + 65) / 512);
+	const bytes = new Uint8Array(size / 8);
+	bytes.set(encoded);
+
+	// append a 1
+	bytes[encoded.length] = 0b10000000;
+
+	reverse_endianness(bytes);
+
+	// add the input bit length
+	const words = new Uint32Array(bytes.buffer);
+	words[words.length - 2] = Math.floor(length / 0x100000000); // this will always be zero for us
+	words[words.length - 1] = length;
+
+	return words;
+}
+
+/*
+	Based on https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727
+
+	MIT License
+	Copyright (c) 2020 Egor Nepomnyaschih
+	Permission is hereby granted, free of charge, to any person obtaining a copy
+	of this software and associated documentation files (the "Software"), to deal
+	in the Software without restriction, including without limitation the rights
+	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+	copies of the Software, and to permit persons to whom the Software is
+	furnished to do so, subject to the following conditions:
+	The above copyright notice and this permission notice shall be included in all
+	copies or substantial portions of the Software.
+	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+	SOFTWARE.
+*/
+const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split('');
+
+/** @param {Uint8Array} bytes */
+export function base64(bytes) {
+	const l = bytes.length;
+
+	let result = '';
+	let i;
+
+	for (i = 2; i < l; i += 3) {
+		result += chars[bytes[i - 2] >> 2];
+		result += chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)];
+		result += chars[((bytes[i - 1] & 0x0f) << 2) | (bytes[i] >> 6)];
+		result += chars[bytes[i] & 0x3f];
+	}
+
+	if (i === l + 1) {
+		// 1 octet yet to write
+		result += chars[bytes[i - 2] >> 2];
+		result += chars[(bytes[i - 2] & 0x03) << 4];
+		result += '==';
+	}
+
+	if (i === l) {
+		// 2 octets yet to write
+		result += chars[bytes[i - 2] >> 2];
+		result += chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)];
+		result += chars[(bytes[i - 1] & 0x0f) << 2];
+		result += '=';
+	}
+
+	return result;
+}
diff --git a/packages/kit/src/runtime/server/page/crypto.spec.js b/packages/kit/src/runtime/server/page/crypto.spec.js
new file mode 100644
index 000000000..cf967e184
--- /dev/null
+++ b/packages/kit/src/runtime/server/page/crypto.spec.js
@@ -0,0 +1,24 @@
+import { test } from 'uvu';
+import * as assert from 'uvu/assert';
+import crypto from 'crypto';
+import { sha256 } from './crypto.js';
+
+const inputs = [
+	'hello world',
+	'',
+	'abcd',
+	'the quick brown fox jumps over the lazy dog',
+	'工欲善其事，必先利其器'
+].slice(0);
+
+inputs.forEach((input) => {
+	test(input, () => {
+		const expected_bytes = crypto.createHash('sha256').update(input, 'utf-8').digest();
+		const expected = expected_bytes.toString('base64');
+
+		const actual = sha256(input);
+		assert.equal(actual, expected);
+	});
+});
+
+test.run();
diff --git a/packages/kit/src/runtime/server/page/csp.js b/packages/kit/src/runtime/server/page/csp.js
new file mode 100644
index 000000000..1f90c4870
--- /dev/null
+++ b/packages/kit/src/runtime/server/page/csp.js
@@ -0,0 +1,211 @@
+import { escape_html_attr } from '../../../utils/escape.js';
+import { sha256, base64 } from './crypto.js';
+
+/** @type {Promise<void>} */
+export let csp_ready;
+
+/** @type {() => string} */
+let generate_nonce;
+
+/** @type {(input: string) => string} */
+let generate_hash;
+
+if (typeof crypto !== 'undefined') {
+	const array = new Uint8Array(16);
+
+	generate_nonce = () => {
+		crypto.getRandomValues(array);
+		return base64(array);
+	};
+
+	generate_hash = sha256;
+} else {
+	// TODO: remove this in favor of web crypto API once we no longer support Node 14
+	const name = 'crypto'; // store in a variable to fool esbuild when adapters bundle kit
+	csp_ready = import(name).then((crypto) => {
+		generate_nonce = () => {
+			return crypto.randomBytes(16).toString('base64');
+		};
+
+		generate_hash = (input) => {
+			return crypto.createHash('sha256').update(input, 'utf-8').digest().toString('base64');
+		};
+	});
+}
+
+const quoted = new Set([
+	'self',
+	'unsafe-eval',
+	'unsafe-hashes',
+	'unsafe-inline',
+	'none',
+	'strict-dynamic',
+	'report-sample'
+]);
+
+const crypto_pattern = /^(nonce|sha\d\d\d)-/;
+
+export class Csp {
+	/** @type {boolean} */
+	#use_hashes;
+
+	/** @type {boolean} */
+	#dev;
+
+	/** @type {boolean} */
+	#script_needs_csp;
+
+	/** @type {boolean} */
+	#style_needs_csp;
+
+	/** @type {import('types/csp').CspDirectives} */
+	#directives;
+
+	/** @type {import('types/csp').Source[]} */
+	#script_src;
+
+	/** @type {import('types/csp').Source[]} */
+	#style_src;
+
+	/**
+	 * @param {{
+	 *   mode: string,
+	 *   directives: import('types/csp').CspDirectives
+	 * }} config
+	 * @param {{
+	 *   dev: boolean;
+	 *   prerender: boolean;
+	 *   needs_nonce: boolean;
+	 * }} opts
+	 */
+	constructor({ mode, directives }, { dev, prerender, needs_nonce }) {
+		this.#use_hashes = mode === 'hash' || (mode === 'auto' && prerender);
+		this.#directives = dev ? { ...directives } : directives; // clone in dev so we can safely mutate
+		this.#dev = dev;
+
+		const d = this.#directives;
+
+		if (dev) {
+			// remove strict-dynamic in dev...
+			// TODO reinstate this if we can figure out how to make strict-dynamic work
+			// if (d['default-src']) {
+			// 	d['default-src'] = d['default-src'].filter((name) => name !== 'strict-dynamic');
+			// 	if (d['default-src'].length === 0) delete d['default-src'];
+			// }
+
+			// if (d['script-src']) {
+			// 	d['script-src'] = d['script-src'].filter((name) => name !== 'strict-dynamic');
+			// 	if (d['script-src'].length === 0) delete d['script-src'];
+			// }
+
+			const effective_style_src = d['style-src'] || d['default-src'];
+
+			// ...and add unsafe-inline so we can inject <style> elements
+			if (effective_style_src && !effective_style_src.includes('unsafe-inline')) {
+				d['style-src'] = [...effective_style_src, 'unsafe-inline'];
+			}
+		}
+
+		this.#script_src = [];
+		this.#style_src = [];
+
+		const effective_script_src = d['script-src'] || d['default-src'];
+		const effective_style_src = d['style-src'] || d['default-src'];
+
+		this.#script_needs_csp =
+			!!effective_script_src &&
+			effective_script_src.filter((value) => value !== 'unsafe-inline').length > 0;
+
+		this.#style_needs_csp =
+			!dev &&
+			!!effective_style_src &&
+			effective_style_src.filter((value) => value !== 'unsafe-inline').length > 0;
+
+		this.script_needs_nonce = this.#script_needs_csp && !this.#use_hashes;
+		this.style_needs_nonce = this.#style_needs_csp && !this.#use_hashes;
+
+		if (this.script_needs_nonce || this.style_needs_nonce || needs_nonce) {
+			this.nonce = generate_nonce();
+		}
+	}
+
+	/** @param {string} content */
+	add_script(content) {
+		if (this.#script_needs_csp) {
+			if (this.#use_hashes) {
+				this.#script_src.push(`sha256-${generate_hash(content)}`);
+			} else if (this.#script_src.length === 0) {
+				this.#script_src.push(`nonce-${this.nonce}`);
+			}
+		}
+	}
+
+	/** @param {string} content */
+	add_style(content) {
+		if (this.#style_needs_csp) {
+			if (this.#use_hashes) {
+				this.#style_src.push(`sha256-${generate_hash(content)}`);
+			} else if (this.#style_src.length === 0) {
+				this.#style_src.push(`nonce-${this.nonce}`);
+			}
+		}
+	}
+
+	/** @param {boolean} [is_meta] */
+	get_header(is_meta = false) {
+		const header = [];
+
+		// due to browser inconsistencies, we can't append sources to default-src
+		// (specifically, Firefox appears to not ignore nonce-{nonce} directives
+		// on default-src), so we ensure that script-src and style-src exist
+
+		const directives = { ...this.#directives };
+
+		if (this.#style_src.length > 0) {
+			directives['style-src'] = [
+				...(directives['style-src'] || directives['default-src'] || []),
+				...this.#style_src
+			];
+		}
+
+		if (this.#script_src.length > 0) {
+			directives['script-src'] = [
+				...(directives['script-src'] || directives['default-src'] || []),
+				...this.#script_src
+			];
+		}
+
+		for (const key in directives) {
+			if (is_meta && (key === 'frame-ancestors' || key === 'report-uri' || key === 'sandbox')) {
+				// these values cannot be used with a <meta> tag
+				// TODO warn?
+				continue;
+			}
+
+			// @ts-expect-error gimme a break typescript, `key` is obviously a member of directives
+			const value = /** @type {string[] | true} */ (directives[key]);
+
+			if (!value) continue;
+
+			const directive = [key];
+			if (Array.isArray(value)) {
+				value.forEach((value) => {
+					if (quoted.has(value) || crypto_pattern.test(value)) {
+						directive.push(`'${value}'`);
+					} else {
+						directive.push(value);
+					}
+				});
+			}
+
+			header.push(directive.join(' '));
+		}
+
+		return header.join('; ');
+	}
+
+	get_meta() {
+		const content = escape_html_attr(this.get_header(true));
+		return `<meta http-equiv="content-security-policy" content=${content}>`;
+	}
+}
diff --git a/packages/kit/src/runtime/server/page/csp.spec.js b/packages/kit/src/runtime/server/page/csp.spec.js
new file mode 100644
index 000000000..69b038b84
--- /dev/null
+++ b/packages/kit/src/runtime/server/page/csp.spec.js
@@ -0,0 +1,211 @@
+import { test } from 'uvu';
+import * as assert from 'uvu/assert';
+import { Csp } from './csp.js';
+
+test('generates blank CSP header', () => {
+	const csp = new Csp(
+		{
+			mode: 'hash',
+			directives: {}
+		},
+		{
+			dev: false,
+			prerender: false,
+			needs_nonce: false
+		}
+	);
+
+	assert.equal(csp.get_header(), '');
+});
+
+test('generates CSP header with directive', () => {
+	const csp = new Csp(
+		{
+			mode: 'hash',
+			directives: {
+				'default-src': ['self']
+			}
+		},
+		{
+			dev: false,
+			prerender: false,
+			needs_nonce: false
+		}
+	);
+
+	assert.equal(csp.get_header(), "default-src 'self'");
+});
+
+test('generates CSP header with nonce', () => {
+	const csp = new Csp(
+		{
+			mode: 'nonce',
+			directives: {
+				'default-src': ['self']
+			}
+		},
+		{
+			dev: false,
+			prerender: false,
+			needs_nonce: false
+		}
+	);
+
+	csp.add_script('');
+
+	assert.ok(csp.get_header().startsWith("default-src 'self'; script-src 'self' 'nonce-"));
+});
+
+test('skips nonce with unsafe-inline', () => {
+	const csp = new Csp(
+		{
+			mode: 'nonce',
+			directives: {
+				'default-src': ['unsafe-inline']
+			}
+		},
+		{
+			dev: false,
+			prerender: false,
+			needs_nonce: false
+		}
+	);
+
+	csp.add_script('');
+	csp.add_style('');
+
+	assert.equal(csp.get_header(), "default-src 'unsafe-inline'");
+});
+
+test('skips hash with unsafe-inline', () => {
+	const csp = new Csp(
+		{
+			mode: 'hash',
+			directives: {
+				'default-src': ['unsafe-inline']
+			}
+		},
+		{
+			dev: false,
+			prerender: false,
+			needs_nonce: false
+		}
+	);
+
+	csp.add_script('');
+	csp.add_style('');
+
+	assert.equal(csp.get_header(), "default-src 'unsafe-inline'");
+});
+
+test('skips frame-ancestors, report-uri, sandbox from meta tags', () => {
+	const csp = new Csp(
+		{
+			mode: 'hash',
+			directives: {
+				'default-src': ['self'],
+				'frame-ancestors': ['self'],
+				'report-uri': ['/csp-violation-report-endpoint/'],
+				sandbox: ['allow-modals']
+			}
+		},
+		{
+			dev: false,
+			prerender: false,
+			needs_nonce: false
+		}
+	);
+
+	assert.equal(
+		csp.get_header(),
+		"default-src 'self'; frame-ancestors 'self'; report-uri /csp-violation-report-endpoint/; sandbox allow-modals"
+	);
+
+	assert.equal(
+		csp.get_meta(),
+		'<meta http-equiv="content-security-policy" content="default-src \'self\'">'
+	);
+});
+
+test('adds unsafe-inline styles in dev', () => {
+	const csp = new Csp(
+		{
+			mode: 'hash',
+			directives: {
+				'default-src': ['self']
+			}
+		},
+		{
+			dev: true,
+			prerender: false,
+			needs_nonce: false
+		}
+	);
+
+	csp.add_style('');
+
+	assert.equal(csp.get_header(), "default-src 'self'; style-src 'self' 'unsafe-inline'");
+});
+
+test.skip('removes strict-dynamic in dev', () => {
+	['default-src', 'script-src'].forEach((name) => {
+		const csp = new Csp(
+			{
+				mode: 'hash',
+				directives: {
+					[name]: ['strict-dynamic']
+				}
+			},
+			{
+				dev: true,
+				prerender: false,
+				needs_nonce: false
+			}
+		);
+
+		csp.add_script('');
+
+		assert.equal(csp.get_header(), '');
+	});
+});
+
+test('uses hashes when prerendering', () => {
+	const csp = new Csp(
+		{
+			mode: 'auto',
+			directives: {
+				'script-src': ['self']
+			}
+		},
+		{
+			dev: false,
+			prerender: true,
+			needs_nonce: false
+		}
+	);
+
+	csp.add_script('');
+
+	assert.equal(
+		csp.get_header(),
+		"script-src 'self' 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='"
+	);
+});
+
+test('always creates a nonce when template needs it', () => {
+	const csp = new Csp(
+		{
+			mode: 'hash',
+			directives: {}
+		},
+		{
+			dev: false,
+			prerender: false,
+			needs_nonce: true
+		}
+	);
+
+	assert.ok(csp.nonce);
+});
+
+test.run();
diff --git a/packages/kit/src/runtime/server/page/load_node.js b/packages/kit/src/runtime/server/page/load_node.js
index cf87f94d3..66a61a9ff 100644
--- a/packages/kit/src/runtime/server/page/load_node.js
+++ b/packages/kit/src/runtime/server/page/load_node.js
@@ -96,8 +96,12 @@ export async function load_node({
 
 				const resolved = resolve(event.url.pathname, requested.split('?')[0]);
 
+				/** @type {Response} */
 				let response;
 
+				/** @type {import('types/internal').PrerenderDependency} */
+				let dependency;
+
 				// handle fetch requests for static assets. e.g. prebaked data, etc.
 				// we need to support everything the browser's fetch supports
 				const prefix = options.paths.assets || options.paths.base;
@@ -124,9 +128,6 @@ export async function load_node({
 						response = await fetch(`${url.origin}/${file}`, /** @type {RequestInit} */ (opts));
 					}
 				} else if (is_root_relative(resolved)) {
-					const relative = resolved;
-
-					// TODO: fix type https://github.com/node-fetch/node-fetch/issues/1113
 					if (opts.credentials !== 'omit') {
 						uses_credentials = true;
 
@@ -150,28 +151,14 @@ export async function load_node({
 						throw new Error('Request body must be a string');
 					}
 
-					const rendered = await respond(
-						new Request(new URL(requested, event.url).href, opts),
-						options,
-						{
-							fetched: requested,
-							initiator: route
-						}
-					);
-
-					if (rendered) {
-						if (state.prerender) {
-							state.prerender.dependencies.set(relative, rendered);
-						}
+					response = await respond(new Request(new URL(requested, event.url).href, opts), options, {
+						fetched: requested,
+						initiator: route
+					});
 
-						response = rendered;
-					} else {
-						// we can't load the endpoint from our own manifest,
-						// so we need to make an actual HTTP request
-						return fetch(new URL(requested, event.url).href, {
-							method: opts.method || 'GET',
-							headers: opts.headers
-						});
+					if (state.prerender) {
+						dependency = { response, body: null };
+						state.prerender.dependencies.set(resolved, dependency);
 					}
 				} else {
 					// external
@@ -204,59 +191,69 @@ export async function load_node({
 					response = await options.hooks.externalFetch.call(null, external_request);
 				}
 
-				if (response) {
-					const proxy = new Proxy(response, {
-						get(response, key, _receiver) {
-							async function text() {
-								const body = await response.text();
-
-								/** @type {import('types/helper').ResponseHeaders} */
-								const headers = {};
-								for (const [key, value] of response.headers) {
-									if (key === 'set-cookie') {
-										set_cookie_headers = set_cookie_headers.concat(value);
-									} else if (key !== 'etag') {
-										headers[key] = value;
-									}
+				const proxy = new Proxy(response, {
+					get(response, key, _receiver) {
+						async function text() {
+							const body = await response.text();
+
+							/** @type {import('types/helper').ResponseHeaders} */
+							const headers = {};
+							for (const [key, value] of response.headers) {
+								if (key === 'set-cookie') {
+									set_cookie_headers = set_cookie_headers.concat(value);
+								} else if (key !== 'etag') {
+									headers[key] = value;
 								}
+							}
 
-								if (!opts.body || typeof opts.body === 'string') {
-									// prettier-ignore
-									fetched.push({
+							if (!opts.body || typeof opts.body === 'string') {
+								// prettier-ignore
+								fetched.push({
 										url: requested,
 										body: /** @type {string} */ (opts.body),
 										json: `{"status":${response.status},"statusText":${s(response.statusText)},"headers":${s(headers)},"body":"${escape_json_string_in_html(body)}"}`
 									});
-								}
-
-								return body;
 							}
 
-							if (key === 'text') {
-								return text;
+							if (dependency) {
+								dependency.body = body;
 							}
 
-							if (key === 'json') {
-								return async () => {
-									return JSON.parse(await text());
-								};
-							}
+							return body;
+						}
+
+						if (key === 'arrayBuffer') {
+							return async () => {
+								const buffer = await response.arrayBuffer();
+
+								if (dependency) {
+									dependency.body = new Uint8Array(buffer);
+								}
 
-							// TODO arrayBuffer?
+								// TODO should buffer be inlined into the page (albeit base64'd)?
+								// any conditions in which it shouldn't be?
 
-							return Reflect.get(response, key, response);
+								return buffer;
+							};
 						}
-					});
 
-					return proxy;
-				}
+						if (key === 'text') {
+							return text;
+						}
+
+						if (key === 'json') {
+							return async () => {
+								return JSON.parse(await text());
+							};
+						}
+
+						// TODO arrayBuffer?
+
+						return Reflect.get(response, key, response);
+					}
+				});
 
-				return (
-					response ||
-					new Response('Not found', {
-						status: 404
-					})
-				);
+				return proxy;
 			},
 			stuff: { ...stuff }
 		};
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index 23b2de5c3..bacafb1a0 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -5,6 +5,7 @@ import { hash } from '../../hash.js';
 import { escape_html_attr } from '../../../utils/escape.js';
 import { s } from '../../../utils/misc.js';
 import { create_prerendering_url_proxy } from './utils.js';
+import { Csp, csp_ready } from './csp.js';
 
 // TODO rename this function/module
 
@@ -36,8 +37,18 @@ export async function render_response({
 	ssr,
 	stuff
 }) {
-	const css = new Set(options.manifest._.entry.css);
-	const js = new Set(options.manifest._.entry.js);
+	if (state.prerender) {
+		if (options.csp.mode === 'nonce') {
+			throw new Error('Cannot use prerendering if config.kit.csp.mode === "nonce"');
+		}
+
+		if (options.template_contains_nonce) {
+			throw new Error('Cannot use prerendering if page template contains %svelte.nonce%');
+		}
+	}
+
+	const stylesheets = new Set(options.manifest._.entry.css);
+	const modulepreloads = new Set(options.manifest._.entry.js);
 	/** @type {Map<string, string>} */
 	const styles = new Map();
 
@@ -55,8 +66,8 @@ export async function render_response({
 
 	if (ssr) {
 		branch.forEach(({ node, loaded, fetched, uses_credentials }) => {
-			if (node.css) node.css.forEach((url) => css.add(url));
-			if (node.js) node.js.forEach((url) => js.add(url));
+			if (node.css) node.css.forEach((url) => stylesheets.add(url));
+			if (node.js) node.js.forEach((url) => modulepreloads.add(url));
 			if (node.styles) Object.entries(node.styles).forEach(([k, v]) => styles.set(k, v));
 
 			// TODO probably better if `fetched` wasn't populated unless `hydrate`
@@ -128,11 +139,44 @@ export async function render_response({
 
 	const inlined_style = Array.from(styles.values()).join('\n');
 
-	if (state.prerender) {
-		if (maxage) {
-			head += `<meta http-equiv="cache-control" content="max-age=${maxage}">`;
+	await csp_ready;
+	const csp = new Csp(options.csp, {
+		dev: options.dev,
+		prerender: !!state.prerender,
+		needs_nonce: options.template_contains_nonce
+	});
+
+	// prettier-ignore
+	const init_app = `
+		import { start } from ${s(options.prefix + options.manifest._.entry.file)};
+		start({
+			target: ${options.target ? `document.querySelector(${s(options.target)})` : 'document.body'},
+			paths: ${s(options.paths)},
+			session: ${try_serialize($session, (error) => {
+				throw new Error(`Failed to serialize session data: ${error.message}`);
+			})},
+			route: ${!!page_config.router},
+			spa: ${!ssr},
+			trailing_slash: ${s(options.trailing_slash)},
+			hydrate: ${ssr && page_config.hydrate ? `{
+				status: ${status},
+				error: ${serialize_error(error)},
+				nodes: [
+					${(branch || [])
+					.map(({ node }) => `import(${s(options.prefix + node.entry)})`)
+					.join(',\n\t\t\t\t\t\t')}
+				],
+				url: new URL(${s(url.href)}),
+				params: ${devalue(params)}
+			}` : 'null'}
+		});
+	`;
+
+	const init_service_worker = `
+		if ('serviceWorker' in navigator) {
+			navigator.serviceWorker.register('${options.service_worker}');
 		}
-	}
+	`;
 
 	if (options.amp) {
 		head += `
@@ -150,49 +194,54 @@ export async function render_response({
 		}
 	} else {
 		if (inlined_style) {
-			head += `\n\t<style${options.dev ? ' data-svelte' : ''}>${inlined_style}</style>`;
+			const attributes = [];
+			if (options.dev) attributes.push(' data-svelte');
+			if (csp.style_needs_nonce) attributes.push(` nonce="${csp.nonce}"`);
+
+			csp.add_style(inlined_style);
+
+			head += `\n\t<style${attributes.join('')}>${inlined_style}</style>`;
 		}
+
 		// prettier-ignore
-		head += Array.from(css)
-			.map((dep) => `\n\t<link${styles.has(dep) ? ' disabled media="(max-width: 0)"' : ''} rel="stylesheet" href="${options.prefix + dep}">`)
+		head += Array.from(stylesheets)
+			.map((dep) => {
+				const attributes = [
+					'rel="stylesheet"',
+					`href="${options.prefix + dep}"`
+				];
+
+				if (csp.style_needs_nonce) {
+					attributes.push(`nonce="${csp.nonce}"`);
+				}
+
+				if (styles.has(dep)) {
+					attributes.push('disabled', 'media="(max-width: 0)"');
+				}
+
+				return `\n\t<link ${attributes.join(' ')}>`;
+			})
 			.join('');
 
 		if (page_config.router || page_config.hydrate) {
-			head += Array.from(js)
+			head += Array.from(modulepreloads)
 				.map((dep) => `\n\t<link rel="modulepreload" href="${options.prefix + dep}">`)
 				.join('');
-			// prettier-ignore
-			head += `
-			<script type="module">
-				import { start } from ${s(options.prefix + options.manifest._.entry.file)};
-				start({
-					target: ${options.target ? `document.querySelector(${s(options.target)})` : 'document.body'},
-					paths: ${s(options.paths)},
-					session: ${try_serialize($session, (error) => {
-						throw new Error(`Failed to serialize session data: ${error.message}`);
-					})},
-					route: ${!!page_config.router},
-					spa: ${!ssr},
-					trailing_slash: ${s(options.trailing_slash)},
-					hydrate: ${ssr && page_config.hydrate ? `{
-						status: ${status},
-						error: ${serialize_error(error)},
-						nodes: [
-							${(branch || [])
-							.map(({ node }) => `import(${s(options.prefix + node.entry)})`)
-							.join(',\n\t\t\t\t\t\t')}
-						],
-						url: new URL(${s(url.href)}),
-						params: ${devalue(params)}
-					}` : 'null'}
-				});
-			</script>`;
 
+			const attributes = ['type="module"'];
+
+			csp.add_script(init_app);
+
+			if (csp.script_needs_nonce) {
+				attributes.push(`nonce="${csp.nonce}"`);
+			}
+
+			head += `<script ${attributes.join(' ')}>${init_app}</script>`;
+
+			// prettier-ignore
 			body += serialized_data
 				.map(({ url, body, json }) => {
-					let attributes = `type="application/json" data-type="svelte-data" data-url=${escape_html_attr(
-						url
-					)}`;
+					let attributes = `type="application/json" data-type="svelte-data" data-url=${escape_html_attr(url)}`;
 					if (body) attributes += ` data-body="${hash(body)}"`;
 
 					return `<script ${attributes}>${json}</script>`;
@@ -202,12 +251,27 @@ export async function render_response({
 
 		if (options.service_worker) {
 			// always include service worker unless it's turned off explicitly
+			csp.add_script(init_service_worker);
+
 			head += `
-			<script>
-				if ('serviceWorker' in navigator) {
-					navigator.serviceWorker.register('${options.service_worker}');
-				}
-			</script>`;
+				<script${csp.script_needs_nonce ? ` nonce="${csp.nonce}"` : ''}>${init_service_worker}</script>`;
+		}
+	}
+
+	if (state.prerender) {
+		const http_equiv = [];
+
+		const csp_headers = csp.get_meta();
+		if (csp_headers) {
+			http_equiv.push(csp_headers);
+		}
+
+		if (maxage) {
+			http_equiv.push(`<meta http-equiv="cache-control" content="max-age=${maxage}">`);
+		}
+
+		if (http_equiv.length > 0) {
+			head = http_equiv.join('\n') + head;
 		}
 	}
 
@@ -215,7 +279,7 @@ export async function render_response({
 	const assets =
 		options.paths.assets || (segments.length > 0 ? segments.map(() => '..').join('/') : '.');
 
-	const html = options.template({ head, body, assets });
+	const html = options.template({ head, body, assets, nonce: /** @type {string} */ (csp.nonce) });
 
 	const headers = new Headers({
 		'content-type': 'text/html',
@@ -230,6 +294,13 @@ export async function render_response({
 		headers.set('permissions-policy', 'interest-cohort=()');
 	}
 
+	if (!state.prerender) {
+		const csp_header = csp.get_header();
+		if (csp_header) {
+			headers.set('content-security-policy', csp_header);
+		}
+	}
+
 	return new Response(html, {
 		status,
 		headers
diff --git a/packages/kit/types/config.d.ts b/packages/kit/types/config.d.ts
index 788bef4ce..4fadf2ee4 100644
--- a/packages/kit/types/config.d.ts
+++ b/packages/kit/types/config.d.ts
@@ -1,5 +1,6 @@
 import { CompileOptions } from 'svelte/types/compiler/interfaces';
 import { UserConfig as ViteConfig } from 'vite';
+import { CspDirectives } from './csp';
 import { RecursiveRequired } from './helper';
 import { HttpMethod, Logger, RouteSegment, TrailingSlash } from './internal';
 
@@ -117,6 +118,10 @@ export interface Config {
 		adapter?: Adapter;
 		amp?: boolean;
 		appDir?: string;
+		csp?: {
+			mode?: 'hash' | 'nonce' | 'auto';
+			directives?: CspDirectives;
+		};
 		files?: {
 			assets?: string;
 			hooks?: string;
diff --git a/packages/kit/types/csp.d.ts b/packages/kit/types/csp.d.ts
new file mode 100644
index 000000000..976c42df6
--- /dev/null
+++ b/packages/kit/types/csp.d.ts
@@ -0,0 +1,115 @@
+/**
+ * Based on https://github.com/josh-hemphill/csp-typed-directives/blob/latest/src/csp.types.ts
+ *
+ * MIT License
+ *
+ * Copyright (c) 2021-present, Joshua Hemphill
+ * Copyright (c) 2021, Tecnico Corporation
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+type SchemeSource = 'http:' | 'https:' | 'data:' | 'mediastream:' | 'blob:' | 'filesystem:';
+
+type HostProtocolSchemes = `${string}://` | '';
+type PortScheme = `:${number}` | '' | ':*';
+/** Can actually be any string, but typed more explicitly to
+ *  restrict the combined optional types of Source from collapsing to just bing `string` */
+type HostNameScheme = `${string}.${string}` | `localhost`;
+type HostSource = `${HostProtocolSchemes}${HostNameScheme}${PortScheme}`;
+
+type CryptoSource = `${'nonce' | 'sha256' | 'sha384' | 'sha512'}-${string}`;
+
+type BaseSource = 'self' | 'unsafe-eval' | 'unsafe-hashes' | 'unsafe-inline' | 'none';
+
+export type Source = HostSource | SchemeSource | CryptoSource | BaseSource;
+type Sources = Source[];
+
+type ActionSource = 'strict-dynamic' | 'report-sample';
+
+type FrameSource = HostSource | SchemeSource | 'self' | 'none';
+
+type HttpDelineator = '/' | '?' | '#' | '\\';
+type UriPath = `${HttpDelineator}${string}`;
+
+export type CspDirectives = {
+	'child-src'?: Sources;
+	'default-src'?: Array<Source | ActionSource>;
+	'frame-src'?: Sources;
+	'worker-src'?: Sources;
+	'connect-src'?: Sources;
+	'font-src'?: Sources;
+	'img-src'?: Sources;
+	'manifest-src'?: Sources;
+	'media-src'?: Sources;
+	'object-src'?: Sources;
+	'prefetch-src'?: Sources;
+	'script-src'?: Array<Source | ActionSource>;
+	'script-src-elem'?: Sources;
+	'script-src-attr'?: Sources;
+	'style-src'?: Array<Source | ActionSource>;
+	'style-src-elem'?: Sources;
+	'style-src-attr'?: Sources;
+	'base-uri'?: Array<Source | ActionSource>;
+	sandbox?: Array<
+		| 'allow-downloads-without-user-activation'
+		| 'allow-forms'
+		| 'allow-modals'
+		| 'allow-orientation-lock'
+		| 'allow-pointer-lock'
+		| 'allow-popups'
+		| 'allow-popups-to-escape-sandbox'
+		| 'allow-presentation'
+		| 'allow-same-origin'
+		| 'allow-scripts'
+		| 'allow-storage-access-by-user-activation'
+		| 'allow-top-navigation'
+		| 'allow-top-navigation-by-user-activation'
+	>;
+	'form-action'?: Array<Source | ActionSource>;
+	'frame-ancestors'?: Array<HostSource | SchemeSource | FrameSource>;
+	'navigate-to'?: Array<Source | ActionSource>;
+	'report-uri'?: UriPath[];
+	'report-to'?: string[];
+
+	'require-trusted-types-for'?: Array<'script'>;
+	'trusted-types'?: Array<'none' | 'allow-duplicates' | '*' | string>;
+	'upgrade-insecure-requests'?: boolean;
+
+	/** @deprecated */
+	'require-sri-for'?: Array<'script' | 'style' | 'script style'>;
+
+	/** @deprecated */
+	'block-all-mixed-content'?: boolean;
+
+	/** @deprecated */
+	'plugin-types'?: Array<`${string}/${string}` | 'none'>;
+
+	/** @deprecated */
+	referrer?: Array<
+		| 'no-referrer'
+		| 'no-referrer-when-downgrade'
+		| 'origin'
+		| 'origin-when-cross-origin'
+		| 'same-origin'
+		| 'strict-origin'
+		| 'strict-origin-when-cross-origin'
+		| 'unsafe-url'
+		| 'none'
+	>;
+};
diff --git a/packages/kit/types/endpoint.d.ts b/packages/kit/types/endpoint.d.ts
index ace4fd18d..b1cc06516 100644
--- a/packages/kit/types/endpoint.d.ts
+++ b/packages/kit/types/endpoint.d.ts
@@ -3,16 +3,18 @@ import { Either, JSONString, MaybePromise, ResponseHeaders } from './helper';
 
 type Body = JSONString | Uint8Array | ReadableStream | import('stream').Readable;
 
-export interface EndpointOutput {
+export interface EndpointOutput<Output extends Body = Body> {
 	status?: number;
 	headers?: Headers | Partial<ResponseHeaders>;
-	body?: Body;
+	body?: Output;
 }
 
 export interface Fallthrough {
 	fallthrough: true;
 }
 
-export interface RequestHandler<Locals = Record<string, any>> {
-	(event: RequestEvent<Locals>): MaybePromise<Either<Response | EndpointOutput, Fallthrough>>;
+export interface RequestHandler<Locals = Record<string, any>, Output extends Body = Body> {
+	(event: RequestEvent<Locals>): MaybePromise<
+		Either<Response | EndpointOutput<Output>, Fallthrough>
+	>;
 }
diff --git a/packages/kit/types/hooks.d.ts b/packages/kit/types/hooks.d.ts
index cb3e7d4e0..0fe4373e5 100644
--- a/packages/kit/types/hooks.d.ts
+++ b/packages/kit/types/hooks.d.ts
@@ -24,15 +24,6 @@ export interface Handle<Locals = Record<string, any>> {
 	}): MaybePromise<Response>;
 }
 
-// internally, `resolve` could return `undefined`, so we differentiate InternalHandle
-// from the public Handle type
-export interface InternalHandle<Locals = Record<string, any>> {
-	(input: {
-		event: RequestEvent<Locals>;
-		resolve(event: RequestEvent<Locals>, opts?: ResolveOpts): MaybePromise<Response | undefined>;
-	}): MaybePromise<Response | undefined>;
-}
-
 export interface HandleError<Locals = Record<string, any>> {
 	(input: { error: Error & { frame?: string }; event: RequestEvent<Locals> }): void;
 }
diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts
index f3e6422e5..4d5a248a8 100644
--- a/packages/kit/types/internal.d.ts
+++ b/packages/kit/types/internal.d.ts
@@ -1,14 +1,20 @@
 import { OutputAsset, OutputChunk } from 'rollup';
+import { ValidatedConfig } from './config';
 import { InternalApp, SSRManifest } from './app';
 import { Fallthrough, RequestHandler } from './endpoint';
 import { Either } from './helper';
-import { ExternalFetch, GetSession, HandleError, InternalHandle, RequestEvent } from './hooks';
+import { ExternalFetch, GetSession, Handle, HandleError, RequestEvent } from './hooks';
 import { Load } from './page';
 
+export interface PrerenderDependency {
+	response: Response;
+	body: null | string | Uint8Array;
+}
+
 export interface PrerenderOptions {
 	fallback?: string;
 	all: boolean;
-	dependencies: Map<string, Response>;
+	dependencies: Map<string, PrerenderDependency>;
 }
 
 export interface AppModule {
@@ -97,7 +103,7 @@ export type SSRNodeLoader = () => Promise<SSRNode>;
 export interface Hooks {
 	externalFetch: ExternalFetch;
 	getSession: GetSession;
-	handle: InternalHandle;
+	handle: Handle;
 	handleError: HandleError;
 }
 
@@ -115,6 +121,7 @@ export interface SSRNode {
 
 export interface SSRRenderOptions {
 	amp: boolean;
+	csp: ValidatedConfig['kit']['csp'];
 	dev: boolean;
 	floc: boolean;
 	get_stack: (error: Error) => string | undefined;
@@ -134,7 +141,18 @@ export interface SSRRenderOptions {
 	router: boolean;
 	service_worker?: string;
 	target: string;
-	template({ head, body, assets }: { head: string; body: string; assets: string }): string;
+	template({
+		head,
+		body,
+		assets,
+		nonce
+	}: {
+		head: string;
+		body: string;
+		assets: string;
+		nonce: string;
+	}): string;
+	template_contains_nonce: boolean;
 	trailing_slash: TrailingSlash;
 }
 
@@ -228,7 +246,5 @@ export interface MethodOverride {
 }
 
 export interface Respond {
-	(request: Request, options: SSRRenderOptions, state?: SSRRenderState): Promise<
-		Response | undefined
-	>;
+	(request: Request, options: SSRRenderOptions, state?: SSRRenderState): Promise<Response>;
 }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d3ac4795f..559be82ff 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -291,6 +291,26 @@ importers:
       tiny-glob: 0.2.9
       uvu: 0.5.2
 
+  packages/kit/test/prerendering/basics:
+    specifiers:
+      '@sveltejs/kit': workspace:*
+      svelte: ^3.43.0
+      uvu: ^0.5.2
+    devDependencies:
+      '@sveltejs/kit': link:../../..
+      svelte: 3.44.2
+      uvu: 0.5.2
+
+  packages/kit/test/prerendering/options:
+    specifiers:
+      '@sveltejs/kit': workspace:*
+      svelte: ^3.43.0
+      uvu: ^0.5.2
+    devDependencies:
+      '@sveltejs/kit': link:../../..
+      svelte: 3.44.2
+      uvu: 0.5.2
+
 packages:
 
   /@actions/core/1.6.0:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 3ef45b869..870f912ef 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,4 +1,5 @@
 packages:
   - 'packages/*'
+  - 'packages/kit/test/prerendering/*'
   - 'packages/create-svelte/templates/*'
   - '.github/actions/*'


Test patch:
diff --git a/packages/kit/src/core/config/test/index.js b/packages/kit/src/core/config/test/index.js
deleted file mode 100644
index 02cb1fd55..000000000
--- a/packages/kit/src/core/config/test/index.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import { join } from 'path';
-import { fileURLToPath } from 'url';
-
-import * as assert from 'uvu/assert';
-import { test } from 'uvu';
-
-import { remove_keys } from '../../../utils/object.js';
-import { load_config } from '../index.js';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = join(__filename, '..');
-
-test('load default config (esm)', async () => {
-	const cwd = join(__dirname, 'fixtures/default');
-
-	const config = await load_config({ cwd });
-	remove_keys(config, ([, v]) => typeof v === 'function');
-
-	assert.equal(config, {
-		extensions: ['.svelte'],
-		kit: {
-			adapter: null,
-			amp: false,
-			appDir: '_app',
-			files: {
-				assets: join(cwd, 'static'),
-				hooks: join(cwd, 'src/hooks'),
-				lib: join(cwd, 'src/lib'),
-				routes: join(cwd, 'src/routes'),
-				serviceWorker: join(cwd, 'src/service-worker'),
-				template: join(cwd, 'src/app.html')
-			},
-			floc: false,
-			headers: undefined,
-			host: undefined,
-			hydrate: true,
-			inlineStyleThreshold: 0,
-			methodOverride: {
-				parameter: '_method',
-				allowed: []
-			},
-			package: {
-				dir: 'package',
-				emitTypes: true
-			},
-			serviceWorker: {
-				register: true
-			},
-			paths: { base: '', assets: '' },
-			prerender: {
-				concurrency: 1,
-				crawl: true,
-				enabled: true,
-				entries: ['*'],
-				force: undefined,
-				onError: 'fail',
-				pages: undefined
-			},
-			protocol: undefined,
-			router: true,
-			ssr: null,
-			target: null,
-			trailingSlash: 'never'
-		}
-	});
-});
-
-test('errors on loading config with incorrect default export', async () => {
-	let message = null;
-
-	try {
-		const cwd = join(__dirname, 'fixtures', 'export-string');
-		await load_config({ cwd });
-	} catch (/** @type {any} */ e) {
-		message = e.message;
-	}
-
-	assert.equal(
-		message,
-		'svelte.config.js must have a configuration object as its default export. See https://kit.svelte.dev/docs#configuration'
-	);
-});
-
-test.run();
diff --git a/packages/kit/test/apps/options/source/pages/csp/index.svelte b/packages/kit/test/apps/options/source/pages/csp/index.svelte
new file mode 100644
index 000000000..8b0c1ae6b
--- /dev/null
+++ b/packages/kit/test/apps/options/source/pages/csp/index.svelte
@@ -0,0 +1,7 @@
+<script>
+	import { page } from '$app/stores';
+</script>
+
+<svelte:head>
+	<script src="http://localhost:{$page.url.searchParams.get('port')}/blocked.js"></script>
+</svelte:head>
diff --git a/packages/kit/test/apps/options/svelte.config.js b/packages/kit/test/apps/options/svelte.config.js
index f916490dd..e11dd0b93 100644
--- a/packages/kit/test/apps/options/svelte.config.js
+++ b/packages/kit/test/apps/options/svelte.config.js
@@ -4,6 +4,11 @@ import path from 'path';
 const config = {
 	extensions: ['.jesuslivesineveryone', '.whokilledthemuffinman', '.svelte.md', '.svelte'],
 	kit: {
+		csp: {
+			directives: {
+				'script-src': ['self']
+			}
+		},
 		files: {
 			assets: 'public',
 			lib: 'source/components',
diff --git a/packages/kit/test/apps/options/test/test.js b/packages/kit/test/apps/options/test/test.js
index d2d7b818f..767af838c 100644
--- a/packages/kit/test/apps/options/test/test.js
+++ b/packages/kit/test/apps/options/test/test.js
@@ -1,5 +1,5 @@
 import { expect } from '@playwright/test';
-import { test } from '../../../utils.js';
+import { start_server, test } from '../../../utils.js';
 
 /** @typedef {import('@playwright/test').Response} Response */
 
@@ -78,6 +78,28 @@ test.describe.parallel('base path', () => {
 	});
 });
 
+test.describe.parallel('CSP', () => {
+	test('blocks script from external site', async ({ page }) => {
+		const { server, port } = await start_server((req, res) => {
+			if (req.url === '/blocked.js') {
+				res.writeHead(200, {
+					'content-type': 'text/javascript'
+				});
+
+				res.end('window.pwned = true');
+			} else {
+				res.writeHead(404).end('not found');
+			}
+		});
+
+		await page.goto(`/path-base/csp?port=${port}`);
+
+		expect(await page.evaluate('window.pwned')).toBe(undefined);
+
+		server.close();
+	});
+});
+
 test.describe.parallel('Custom extensions', () => {
 	test('works with arbitrary extensions', async ({ page }) => {
 		await page.goto('/path-base/custom-extensions/');
diff --git a/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/buffered.json.js b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/buffered.json.js
new file mode 100644
index 000000000..ceb936631
--- /dev/null
+++ b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/buffered.json.js
@@ -0,0 +1,5 @@
+export async function get() {
+	return {
+		body: { answer: 42 }
+	};
+}
diff --git a/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/buffered.svelte b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/buffered.svelte
new file mode 100644
index 000000000..ea6c07029
--- /dev/null
+++ b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/buffered.svelte
@@ -0,0 +1,18 @@
+<script context="module">
+	/** @type {import('@sveltejs/kit').Load} */
+	export async function load({ fetch }) {
+		const url = '/fetch-endpoint/buffered.json';
+		const res = await fetch(url);
+
+		return {
+			props: await res.json()
+		};
+	}
+</script>
+
+<script>
+	/** @type {number} */
+	export let answer;
+</script>
+
+<h1>the answer is {answer}</h1>
diff --git a/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/not-buffered.json.js b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/not-buffered.json.js
new file mode 100644
index 000000000..ceb936631
--- /dev/null
+++ b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/not-buffered.json.js
@@ -0,0 +1,5 @@
+export async function get() {
+	return {
+		body: { answer: 42 }
+	};
+}
diff --git a/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/not-buffered.svelte b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/not-buffered.svelte
new file mode 100644
index 000000000..949a0b4e4
--- /dev/null
+++ b/packages/kit/test/prerendering/basics/src/routes/fetch-endpoint/not-buffered.svelte
@@ -0,0 +1,20 @@
+<script context="module">
+	/** @type {import('@sveltejs/kit').Load} */
+	export async function load({ fetch }) {
+		const url = '/fetch-endpoint/not-buffered.json';
+		const res = await fetch(url);
+
+		return {
+			props: {
+				headers: res.headers
+			}
+		};
+	}
+</script>
+
+<script>
+	/** @type {Headers} */
+	export let headers;
+</script>
+
+<h1>content-type: {headers.get('content-type')}</h1>
diff --git a/packages/kit/test/prerendering/basics/test/test.js b/packages/kit/test/prerendering/basics/test/test.js
index f58833560..16972c334 100644
--- a/packages/kit/test/prerendering/basics/test/test.js
+++ b/packages/kit/test/prerendering/basics/test/test.js
@@ -42,4 +42,20 @@ test('inserts http-equiv tag for cache-control headers', () => {
 	assert.ok(content.includes('<meta http-equiv="cache-control" content="max-age=300">'));
 });
 
+test('renders page with data from endpoint', () => {
+	const content = read('fetch-endpoint/buffered/index.html');
+	assert.ok(content.includes('<h1>the answer is 42</h1>'));
+
+	const json = read('fetch-endpoint/buffered.json');
+	assert.equal(json, JSON.stringify({ answer: 42 }));
+});
+
+test('renders page with unbuffered data from endpoint', () => {
+	const content = read('fetch-endpoint/not-buffered/index.html');
+	assert.ok(content.includes('<h1>content-type: application/json; charset=utf-8</h1>'), content);
+
+	const json = read('fetch-endpoint/not-buffered.json');
+	assert.equal(json, JSON.stringify({ answer: 42 }));
+});
+
 test.run();
diff --git a/packages/kit/test/prerendering/options/svelte.config.js b/packages/kit/test/prerendering/options/svelte.config.js
index 55f288d4a..ac5499a0d 100644
--- a/packages/kit/test/prerendering/options/svelte.config.js
+++ b/packages/kit/test/prerendering/options/svelte.config.js
@@ -5,10 +5,18 @@ import adapter from '../../../../adapter-static/index.js';
 const config = {
 	kit: {
 		adapter: adapter(),
+
+		csp: {
+			directives: {
+				'script-src': ['self']
+			}
+		},
+
 		paths: {
 			base: '/path-base',
 			assets: 'https://cdn.example.com/stuff'
 		},
+
 		vite: {
 			build: {
 				minify: false
diff --git a/packages/kit/test/prerendering/options/test/test.js b/packages/kit/test/prerendering/options/test/test.js
index 6aaaef278..46bf27679 100644
--- a/packages/kit/test/prerendering/options/test/test.js
+++ b/packages/kit/test/prerendering/options/test/test.js
@@ -20,4 +20,13 @@ test('prerenders nested /path-base', () => {
 	assert.ok(content.includes('http://sveltekit-prerender/path-base/nested'));
 });
 
+test('adds CSP headers via meta tag', () => {
+	const content = read('index.html');
+	assert.ok(
+		content.includes(
+			'<meta http-equiv="content-security-policy" content="script-src \'self\' \'sha256-'
+		)
+	);
+});
+
 test.run();