Repository: sveltejs/kit
Pull request #5825 (2022-08-05 03:41:41)

feat: Increase security by virtualizing `$env/static/*`
Previously, the contents of `$env/static/*` were written to the disk in `.svelte-kit/runtime/env/static/*.js`. We'd like to prevent dumping the environment to the disk, so this is a test implementation of using virtual modules.

A couple of questions for those wiser than I:

1. How do we make this work with `build`? It seems to work in `dev`, but throws a big red error when running `build`.
2. Are there any cases where this might come back to bite us?

Big thanks to @dominikg for the first "test" implementation of this.

---

# HEADS UP!

We're about to embark on a [significant redesign](https://github.com/sveltejs/kit/discussions/5748) that will touch many parts of the codebase. Until that work is finished, PRs are very likely to result in merge conflicts, and will likely be ignored until the redesign work is complete (by which time they will likely be stale). Please consider delaying your PR until then.

Thank you for understanding!

---

### Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
- [ ] 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
- [ ] This message body should clearly illustrate what problems it solves.
- [ ] Ideally, include a test that fails without this PR but passes with it.

### Tests
- [ ] 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 `pnpm changeset` and following the prompts. All changesets should be `patch` until SvelteKit 1.0


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

Latest commit: c751531ab5ef84a710c376257933501108c74a22

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/sejohnson-virtualize-static-env?filename=.changeset/funny-socks-watch.md&value=---%0A%22%40sveltejs%2Fkit%22%3A%20patch%0A---%0A%0Afeat%3A%20Increase%20security%20by%20virtualizing%20%60%24env%2Fstatic%2F*%60%0A)


tcc-sejohnson: Note for those smarter than I am: I tried pulling the env logic out into its own plugin just for fun. (Code below.) It didn't work, unfortunately, with the same behavior: Fine in `dev`, broken on `build`.

```ts
/**
 * @return {import('vite').Plugin[]}
 */
export function sveltekit() {
	return [env(), ...svelte(), kit()];
}

/**
 * Returns the SvelteKit environment plugin. Replaces `$env/static/*` imports
 * with the actual environment variables.
 *
 * @return {import('vite').Plugin}
 */
function env() {
	/** @type {import('types').ValidatedConfig} */
	let svelte_config;

	/** @type {import('vite').ConfigEnv} */
	let vite_config_env;

	return {
		name: 'vite-plugin-svelte-kit',

		async config(_, config_env) {
			vite_config_env = config_env;
			svelte_config = await load_config();
		},

		async resolveId(id) {
			switch (id) {
				case '$env/static/private':
					return '\0$env/static/private';
				case '$env/static/public':
					return '\0$env/static/public';
			}
		},

		async load(id) {
			switch (id) {
				case '\0$env/static/private':
					return create_env_module(
						'$env/static/private',
						get_env(vite_config_env.mode, svelte_config.kit.env.publicPrefix).private
					);
				case '\0$env/static/public':
					return create_env_module(
						'$env/static/private',
						get_env(vite_config_env.mode, svelte_config.kit.env.publicPrefix).public
					);
			}
		}
	};
}
```
tcc-sejohnson: Extracted the env module replacement out into its own Vite plugin, adding it to the array returned by `sveltekit`. Tadaa!
tcc-sejohnson: @Rich-Harris 

I think this should be good to go. The problem was in `utils.js`. Frankly, I have no idea what's going on with the previous code:

```ts
	if (!process.env.BUNDLED) {
		alias.push(
			{
				find: '$env/static/public',
				replacement: path.posix.join(config.outDir, 'runtime/env/static/public.js')
			},
			{
				find: '$env/static/private',
				replacement: path.posix.join(config.outDir, 'runtime/env/static/private.js')
			}
		);
	}

	alias.push({
		find: '$env',
		replacement: `${get_runtime_directory(config)}/env`
	});

	return alias;
```

But the following makes sense, I suppose, now that we're not aliasing `$env` itself?

```ts
	const base = `${get_runtime_directory(config)}/env`;

	alias.push(
		{
			find: '$env/dynamic/public',
			replacement: `${base}/dynamic/public.js`
		},
		{
			find: '$env/dynamic/private',
			replacement: `${base}/dynamic/private.js`
		}
	);

	return alias;
```

The root of the problem is that I just don't know what the heck `process.env.BUNDLED` denotes and why we needed to change the behavior of the specific aliases based on it...

Patch:
diff --git a/.changeset/lovely-boxes-give.md b/.changeset/lovely-boxes-give.md
new file mode 100644
index 000000000..4f4218c15
--- /dev/null
+++ b/.changeset/lovely-boxes-give.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[feat] `$env/static/*` are now virtual to prevent writing sensitive values to disk
diff --git a/.changeset/pre.json b/.changeset/pre.json
index 49501cca8..274355695 100644
--- a/.changeset/pre.json
+++ b/.changeset/pre.json
@@ -984,6 +984,7 @@
     "ten-mice-kneel",
     "ten-plants-sleep",
     "tender-actors-tease",
+    "tender-bottles-cheer",
     "tender-buckets-turn",
     "tender-buses-talk",
     "tender-geckos-agree",
diff --git a/.changeset/tender-bottles-cheer.md b/.changeset/tender-bottles-cheer.md
new file mode 100644
index 000000000..fd86682f2
--- /dev/null
+++ b/.changeset/tender-bottles-cheer.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Lazy load Svelte components to reenable no-ssr use cases
diff --git a/documentation/docs/01-project-structure.md b/documentation/docs/01-project-structure.md
index dff7f3d21..2eac580f2 100644
--- a/documentation/docs/01-project-structure.md
+++ b/documentation/docs/01-project-structure.md
@@ -17,6 +17,8 @@ my-project/
 │ └ hooks.js
 ├ static/
 │ └ [your static assets]
+├ tests/
+│ └ [your tests]
 ├ package.json
 ├ svelte.config.js
 ├ tsconfig.json
@@ -48,6 +50,10 @@ You can use `.ts` files instead of `.js` files, if using TypeScript.
 
 Any static assets that should be served as-is, like `robots.txt` or `favicon.png`, go in here.
 
+#### tests
+
+If you chose to add tests to your project during `npm create svelte@latest`, they will live in this directory.
+
 #### package.json
 
 Your `package.json` file must include `@sveltejs/kit`, `svelte` and `vite` as `devDependencies`.
diff --git a/packages/kit/CHANGELOG.md b/packages/kit/CHANGELOG.md
index ad4d76ca7..dcd64f080 100644
--- a/packages/kit/CHANGELOG.md
+++ b/packages/kit/CHANGELOG.md
@@ -1,5 +1,11 @@
 # @sveltejs/kit
 
+## 1.0.0-next.412
+
+### Patch Changes
+
+- Lazy load Svelte components to reenable no-ssr use cases ([#5930](https://github.com/sveltejs/kit/pull/5930))
+
 ## 1.0.0-next.411
 
 ### Patch Changes
diff --git a/packages/kit/package.json b/packages/kit/package.json
index a58cc1041..76e0b2084 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@sveltejs/kit",
-	"version": "1.0.0-next.411",
+	"version": "1.0.0-next.412",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
diff --git a/packages/kit/src/core/constants.js b/packages/kit/src/core/constants.js
index 75388913b..8dc9aab27 100644
--- a/packages/kit/src/core/constants.js
+++ b/packages/kit/src/core/constants.js
@@ -1,3 +1,5 @@
 // in `vite dev` and `vite preview`, we use a fake asset path so that we can
 // serve local assets while verifying that requests are correctly prefixed
 export const SVELTE_KIT_ASSETS = '/_svelte_kit_assets';
+
+export const GENERATED_COMMENT = '// this file is generated — do not edit it\n';
diff --git a/packages/kit/src/core/env.js b/packages/kit/src/core/env.js
new file mode 100644
index 000000000..f18f1c598
--- /dev/null
+++ b/packages/kit/src/core/env.js
@@ -0,0 +1,91 @@
+import { GENERATED_COMMENT } from './constants.js';
+
+/**
+ * @param {string} id
+ * @param {Record<string, string>} env
+ * @returns {string}
+ */
+export function create_module(id, env) {
+	/** @type {string[]} */
+	const declarations = [];
+
+	for (const key in env) {
+		if (!valid_identifier.test(key) || reserved.has(key)) {
+			continue;
+		}
+
+		const comment = `/** @type {import('${id}').${key}} */`;
+		const declaration = `export const ${key} = ${JSON.stringify(env[key])};`;
+
+		declarations.push(`${comment}\n${declaration}`);
+	}
+
+	return GENERATED_COMMENT + declarations.join('\n\n');
+}
+
+/**
+ * @param {string} id
+ * @param {Record<string, string>} env
+ * @returns {string}
+ */
+export function create_types(id, env) {
+	const declarations = Object.keys(env)
+		.filter((k) => valid_identifier.test(k))
+		.map((k) => `\texport const ${k}: string;`)
+		.join('\n');
+
+	return `declare module '${id}' {\n${declarations}\n}`;
+}
+
+export const reserved = new Set([
+	'do',
+	'if',
+	'in',
+	'for',
+	'let',
+	'new',
+	'try',
+	'var',
+	'case',
+	'else',
+	'enum',
+	'eval',
+	'null',
+	'this',
+	'true',
+	'void',
+	'with',
+	'await',
+	'break',
+	'catch',
+	'class',
+	'const',
+	'false',
+	'super',
+	'throw',
+	'while',
+	'yield',
+	'delete',
+	'export',
+	'import',
+	'public',
+	'return',
+	'static',
+	'switch',
+	'typeof',
+	'default',
+	'extends',
+	'finally',
+	'package',
+	'private',
+	'continue',
+	'debugger',
+	'function',
+	'arguments',
+	'interface',
+	'protected',
+	'implements',
+	'instanceof'
+]);
+
+export const valid_identifier = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
diff --git a/packages/kit/src/core/sync/utils.js b/packages/kit/src/core/sync/utils.js
index dafb6350f..c0ee2f2cd 100644
--- a/packages/kit/src/core/sync/utils.js
+++ b/packages/kit/src/core/sync/utils.js
@@ -42,56 +42,3 @@ export function trim(str) {
 	const pattern = new RegExp(`^${indentation}`, 'gm');
 	return str.replace(pattern, '').trim();
 }
-
-export const reserved = new Set([
-	'do',
-	'if',
-	'in',
-	'for',
-	'let',
-	'new',
-	'try',
-	'var',
-	'case',
-	'else',
-	'enum',
-	'eval',
-	'null',
-	'this',
-	'true',
-	'void',
-	'with',
-	'await',
-	'break',
-	'catch',
-	'class',
-	'const',
-	'false',
-	'super',
-	'throw',
-	'while',
-	'yield',
-	'delete',
-	'export',
-	'import',
-	'public',
-	'return',
-	'static',
-	'switch',
-	'typeof',
-	'default',
-	'extends',
-	'finally',
-	'package',
-	'private',
-	'continue',
-	'debugger',
-	'function',
-	'arguments',
-	'interface',
-	'protected',
-	'implements',
-	'instanceof'
-]);
-
-export const valid_identifier = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
diff --git a/packages/kit/src/core/sync/write_ambient.js b/packages/kit/src/core/sync/write_ambient.js
index 800e1ccce..28d6b919f 100644
--- a/packages/kit/src/core/sync/write_ambient.js
+++ b/packages/kit/src/core/sync/write_ambient.js
@@ -1,9 +1,9 @@
 import path from 'path';
-import colors from 'kleur';
 import { get_env } from '../../vite/utils.js';
-import { write_if_changed, reserved, valid_identifier } from './utils.js';
+import { GENERATED_COMMENT } from '../constants.js';
+import { create_types } from '../env.js';
+import { write_if_changed } from './utils.js';
 
-const autogen_comment = '// this file is generated — do not edit it\n';
 const types_reference = '/// <reference types="@sveltejs/kit" />\n\n';
 
 /**
@@ -16,72 +16,12 @@ const types_reference = '/// <reference types="@sveltejs/kit" />\n\n';
 export function write_ambient(config, mode) {
 	const env = get_env(mode, config.env.publicPrefix);
 
-	// TODO when testing src, `$app` points at `src/runtime/app`... will
-	// probably need to fiddle with aliases
-	write_if_changed(
-		path.join(config.outDir, 'runtime/env/static/public.js'),
-		create_env_module('$env/static/public', env.public)
-	);
-
-	write_if_changed(
-		path.join(config.outDir, 'runtime/env/static/private.js'),
-		create_env_module('$env/static/private', env.private)
-	);
-
 	write_if_changed(
 		path.join(config.outDir, 'ambient.d.ts'),
-		autogen_comment +
+		GENERATED_COMMENT +
 			types_reference +
-			create_env_types('$env/static/public', env.public) +
+			create_types('$env/static/public', env.public) +
 			'\n\n' +
-			create_env_types('$env/static/private', env.private)
+			create_types('$env/static/private', env.private)
 	);
 }
-
-/**
- * @param {string} id
- * @param {Record<string, string>} env
- * @returns {string}
- */
-function create_env_module(id, env) {
-	/** @type {string[]} */
-	const declarations = [];
-
-	for (const key in env) {
-		const warning = !valid_identifier.test(key)
-			? 'not a valid identifier'
-			: reserved.has(key)
-			? 'a reserved word'
-			: null;
-
-		if (warning) {
-			console.error(
-				colors
-					.bold()
-					.yellow(`Omitting environment variable "${key}" from ${id} as it is ${warning}`)
-			);
-			continue;
-		}
-
-		const comment = `/** @type {import('${id}').${key}} */`;
-		const declaration = `export const ${key} = ${JSON.stringify(env[key])};`;
-
-		declarations.push(`${comment}\n${declaration}`);
-	}
-
-	return autogen_comment + declarations.join('\n\n');
-}
-
-/**
- * @param {string} id
- * @param {Record<string, string>} env
- * @returns {string}
- */
-function create_env_types(id, env) {
-	const declarations = Object.keys(env)
-		.filter((k) => valid_identifier.test(k))
-		.map((k) => `\texport const ${k}: string;`)
-		.join('\n');
-
-	return `declare module '${id}' {\n${declarations}\n}`;
-}
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index ea4969da9..1e1fc04b8 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -79,20 +79,6 @@ export async function render_response({
 	}
 
 	if (resolve_opts.ssr) {
-		for (const { node } of branch) {
-			if (node.imports) {
-				node.imports.forEach((url) => modulepreloads.add(url));
-			}
-
-			if (node.stylesheets) {
-				node.stylesheets.forEach((url) => stylesheets.add(url));
-			}
-
-			if (node.inline_styles) {
-				Object.entries(await node.inline_styles()).forEach(([k, v]) => inline_styles.set(k, v));
-			}
-		}
-
 		const session = writable($session);
 
 		/** @type {Record<string, any>} */
@@ -112,7 +98,7 @@ export async function render_response({
 				url: state.prerendering ? new PrerenderingURL(event.url) : event.url,
 				data: branch.reduce((acc, { data }) => (Object.assign(acc, data), acc), {})
 			},
-			components: branch.map(({ node }) => node.component)
+			components: await Promise.all(branch.map(({ node }) => node.component()))
 		};
 
 		// TODO remove this for 1.0
@@ -143,6 +129,20 @@ export async function render_response({
 		}
 
 		rendered = options.root.render(props);
+
+		for (const { node } of branch) {
+			if (node.imports) {
+				node.imports.forEach((url) => modulepreloads.add(url));
+			}
+
+			if (node.stylesheets) {
+				node.stylesheets.forEach((url) => stylesheets.add(url));
+			}
+
+			if (node.inline_styles) {
+				Object.entries(await node.inline_styles()).forEach(([k, v]) => inline_styles.set(k, v));
+			}
+		}
 	} else {
 		rendered = { head: '', html: '', css: { code: '', map: null } };
 	}
diff --git a/packages/kit/src/vite/build/build_server.js b/packages/kit/src/vite/build/build_server.js
index 7e3df1a9d..e7c358fda 100644
--- a/packages/kit/src/vite/build/build_server.js
+++ b/packages/kit/src/vite/build/build_server.js
@@ -239,6 +239,8 @@ export async function build_server(options, client) {
 		/** @type {string[]} */
 		const imports = [];
 
+		// String representation of
+		/** @type {import('types').SSRNode} */
 		/** @type {string[]} */
 		const exports = [`export const index = ${i};`];
 
@@ -255,7 +257,9 @@ export async function build_server(options, client) {
 			stylesheets.push(...entry.stylesheets);
 
 			exports.push(
-				`export { default as component } from '../${vite_manifest[node.component].file}';`,
+				`export const component = async () => (await import('../${
+					vite_manifest[node.component].file
+				}')).default;`,
 				`export const file = '${entry.file}';` // TODO what is this?
 			);
 		}
diff --git a/packages/kit/src/vite/dev/index.js b/packages/kit/src/vite/dev/index.js
index 5b1968236..438c94fb0 100644
--- a/packages/kit/src/vite/dev/index.js
+++ b/packages/kit/src/vite/dev/index.js
@@ -82,19 +82,23 @@ export async function dev(vite, vite_config, svelte_config, illegal_imports) {
 						result.stylesheets = [];
 
 						if (node.component) {
-							const { module, module_node, url } = await resolve(node.component);
-
-							module_nodes.push(module_node);
-
-							result.component = module.default;
-							result.file = url.endsWith('.svelte') ? url : url + '?import'; // TODO what is this for?
-
-							prevent_illegal_vite_imports(
-								module_node,
-								illegal_imports,
-								extensions,
-								svelte_config.kit.outDir
-							);
+							result.component = async () => {
+								const { module_node, module, url } = await resolve(
+									/** @type {string} */ (node.component)
+								);
+
+								module_nodes.push(module_node);
+								result.file = url.endsWith('.svelte') ? url : url + '?import'; // TODO what is this for?
+
+								prevent_illegal_vite_imports(
+									module_node,
+									illegal_imports,
+									extensions,
+									svelte_config.kit.outDir
+								);
+
+								return module.default;
+							};
 						}
 
 						if (node.shared) {
diff --git a/packages/kit/src/vite/index.js b/packages/kit/src/vite/index.js
index 9f2f3a196..70a9cf482 100644
--- a/packages/kit/src/vite/index.js
+++ b/packages/kit/src/vite/index.js
@@ -14,8 +14,9 @@ import { generate_manifest } from '../core/generate_manifest/index.js';
 import { runtime_directory, logger } from '../core/utils.js';
 import { find_deps, get_default_config as get_default_build_config } from './build/utils.js';
 import { preview } from './preview/index.js';
-import { get_aliases, resolve_entry, prevent_illegal_rollup_imports } from './utils.js';
+import { get_aliases, resolve_entry, prevent_illegal_rollup_imports, get_env } from './utils.js';
 import { fileURLToPath } from 'node:url';
+import { create_module } from '../core/env.js';
 
 const cwd = process.cwd();
 
@@ -107,6 +108,9 @@ function kit() {
 	/** @type {string | undefined} */
 	let deferred_warning;
 
+	/** @type {{ public: Record<string, string>; private: Record<string, string> }} */
+	let env;
+
 	/**
 	 * @type {{
 	 *   build_dir: string;
@@ -191,13 +195,13 @@ function kit() {
 		 * @see https://vitejs.dev/guide/api-plugin.html#config
 		 */
 		async config(config, config_env) {
-			// The config is created in build_server for SSR mode and passed inline
-			if (config.build?.ssr) {
-				return;
-			}
-
 			vite_config_env = config_env;
 			svelte_config = await load_config();
+			env = get_env(vite_config_env.mode, svelte_config.kit.env.publicPrefix);
+
+			// The config is created in build_server for SSR mode and passed inline
+			if (config.build?.ssr) return;
+
 			is_build = config_env.command === 'build';
 
 			paths = {
@@ -269,6 +273,20 @@ function kit() {
 			return result;
 		},
 
+		async resolveId(id) {
+			// treat $env/static/[public|private] as virtual
+			if (id.startsWith('$env/static/')) return `\0${id}`;
+		},
+
+		async load(id) {
+			switch (id) {
+				case '\0$env/static/private':
+					return create_module('$env/static/private', env.private);
+				case '\0$env/static/public':
+					return create_module('$env/static/public', env.public);
+			}
+		},
+
 		/**
 		 * Stores the final config.
 		 */
@@ -432,9 +450,10 @@ function kit() {
 				await adapt(svelte_config, build_data, prerendered, { log });
 			} else {
 				console.log(colors.bold().yellow('\nNo adapter specified'));
-				// prettier-ignore
+
+				const link = colors.bold().cyan('https://kit.svelte.dev/docs/adapters');
 				console.log(
-					`See ${colors.bold().cyan('https://kit.svelte.dev/docs/adapters')} to learn how to configure your app to run on the platform of your choosing`
+					`See ${link} to learn how to configure your app to run on the platform of your choosing`
 				);
 			}
 
diff --git a/packages/kit/src/vite/utils.js b/packages/kit/src/vite/utils.js
index 1856c48a6..6c62d2636 100644
--- a/packages/kit/src/vite/utils.js
+++ b/packages/kit/src/vite/utils.js
@@ -105,6 +105,8 @@ export function get_aliases(config) {
 	const alias = [
 		{ find: '__GENERATED__', replacement: path.posix.join(config.outDir, 'generated') },
 		{ find: '$app', replacement: `${runtime_directory}/app` },
+		{ find: '$env/dynamic/public', replacement: `${runtime_directory}/env/dynamic/public.js` },
+		{ find: '$env/dynamic/private', replacement: `${runtime_directory}/env/dynamic/private.js` },
 		// For now, we handle `$lib` specially here rather than make it a default value for
 		// `config.kit.alias` since it has special meaning for packaging, etc.
 		{ find: '$lib', replacement: config.files.lib }
@@ -128,21 +130,6 @@ export function get_aliases(config) {
 		}
 	}
 
-	alias.push(
-		{
-			find: '$env/static/public',
-			replacement: path.posix.join(config.outDir, 'runtime/env/static/public.js')
-		},
-		{
-			find: '$env/static/private',
-			replacement: path.posix.join(config.outDir, 'runtime/env/static/private.js')
-		},
-		{
-			find: '$env',
-			replacement: `${runtime_directory}/env`
-		}
-	);
-
 	return alias;
 }
 
diff --git a/packages/kit/src/vite/utils.spec.js b/packages/kit/src/vite/utils.spec.js
index fa960d8c4..a566434f5 100644
--- a/packages/kit/src/vite/utils.spec.js
+++ b/packages/kit/src/vite/utils.spec.js
@@ -210,39 +210,24 @@ test('transform kit.alias to resolve.alias', () => {
 		kit: { alias: { simpleKey: 'simple/value', key: 'value', 'key/*': 'value/*' } }
 	});
 
-	const prefix = path.resolve('.');
+	const transformed = get_aliases(config.kit).map((entry) => {
+		const replacement = posixify(path.relative('.', entry.replacement));
 
-	const transformed = get_aliases(config.kit)
-		.map((entry) => {
-			const replacement = posixify(
-				entry.replacement.startsWith(prefix)
-					? entry.replacement.slice(prefix.length + 1)
-					: entry.replacement
-			);
-
-			return {
-				find: entry.find.toString(), // else assertion fails
-				replacement
-			};
-		})
-		.filter(
-			(entry) => entry.find !== '$app' && entry.find !== '$env' // testing this would mean to reimplement the logic in the test
-		);
+		return {
+			find: entry.find.toString(), // else assertion fails
+			replacement
+		};
+	});
 
 	assert.equal(transformed, [
 		{ find: '__GENERATED__', replacement: '.svelte-kit/generated' },
+		{ find: '$app', replacement: 'src/runtime/app' },
+		{ find: '$env/dynamic/public', replacement: 'src/runtime/env/dynamic/public.js' },
+		{ find: '$env/dynamic/private', replacement: 'src/runtime/env/dynamic/private.js' },
 		{ find: '$lib', replacement: 'src/lib' },
 		{ find: 'simpleKey', replacement: 'simple/value' },
 		{ find: /^key$/.toString(), replacement: 'value' },
-		{ find: /^key\/(.+)$/.toString(), replacement: 'value/$1' },
-		{
-			find: '$env/static/public',
-			replacement: '.svelte-kit/runtime/env/static/public.js'
-		},
-		{
-			find: '$env/static/private',
-			replacement: '.svelte-kit/runtime/env/static/private.js'
-		}
+		{ find: /^key\/(.+)$/.toString(), replacement: 'value/$1' }
 	]);
 });
 
diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts
index 059e8e078..c6f2d141e 100644
--- a/packages/kit/types/internal.d.ts
+++ b/packages/kit/types/internal.d.ts
@@ -196,7 +196,7 @@ export interface SSREndpoint {
 }
 
 export interface SSRNode {
-	component: SSRComponent;
+	component: SSRComponentLoader;
 	/** index into the `components` array in client-manifest.js */
 	index: number;
 	/** client-side module URL for this component */
@@ -281,11 +281,6 @@ export interface SSRErrorPage {
 	id: '__error';
 }
 
-export interface SSRPagePart {
-	id: string;
-	load: SSRComponentLoader;
-}
-
 export type SSRRoute = SSREndpoint | SSRPage;
 
 export interface SSRState {


Test patch:
diff --git a/packages/create-svelte/test/check.js b/packages/create-svelte/test/check.js
index 5e9fdd8bb..2fa477952 100644
--- a/packages/create-svelte/test/check.js
+++ b/packages/create-svelte/test/check.js
@@ -5,22 +5,25 @@ import { test } from 'uvu';
 import * as assert from 'uvu/assert';
 import { create } from '../index.js';
 import { fileURLToPath } from 'url';
+import glob from 'tiny-glob';
 // use a directory outside of packages to ensure it isn't added to the pnpm workspace
 const test_workspace_dir = fileURLToPath(
 	new URL('../../../.test-tmp/create-svelte/', import.meta.url)
 );
-const overrides = {};
-['kit', 'adapter-auto', 'adapter-cloudflare', 'adapter-netlify', 'adapter-vercel'].forEach(
-	(pkg) => {
-		overrides[`@sveltejs/${pkg}`] = `${path.resolve(
-			test_workspace_dir,
-			'..',
-			'..',
-			'packages',
-			pkg
-		)}`; //'workspace:*';
-	}
-);
+
+const existing_workspace_overrides = JSON.parse(
+	fs.readFileSync(fileURLToPath(new URL('../../../package.json', import.meta.url)), 'utf-8')
+).pnpm?.overrides;
+
+const overrides = { ...existing_workspace_overrides };
+
+(
+	await glob(fileURLToPath(new URL('../../../packages', import.meta.url)) + '/*/package.json')
+).forEach((pkgPath) => {
+	const name = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).name;
+	overrides[name] = path.dirname(path.resolve(pkgPath));
+});
+
 test.before(() => {
 	try {
 		// prepare test pnpm workspace
@@ -69,6 +72,13 @@ for (const template of fs.readdirSync('templates')) {
 				if (pkg.dependencies?.[key]) {
 					pkg.dependencies[key] = value;
 				}
+				if (!pkg.pnpm) {
+					pkg.pnpm = {};
+				}
+				if (!pkg.pnpm.overrides) {
+					pkg.pnpm.overrides = {};
+				}
+				pkg.pnpm.overrides = { ...pkg.pnpm.overrides, ...overrides };
 			});
 			pkg.private = true;
 			fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify(pkg, null, '\t') + '\n');
diff --git a/packages/kit/test/apps/basics/src/routes/no-ssr/browser-only-global/+page.svelte b/packages/kit/test/apps/basics/src/routes/no-ssr/browser-only-global/+page.svelte
new file mode 100644
index 000000000..2e48a4f8e
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/no-ssr/browser-only-global/+page.svelte
@@ -0,0 +1,5 @@
+<script context="module">
+	document;
+</script>
+
+<p>Works</p>
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index f759e94c6..cef1f3721 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -110,10 +110,13 @@ test.describe('Scrolling', () => {
 		const originalScrollY = /** @type {number} */ (await page.evaluate(() => scrollY));
 		await clicknav('#routing-page');
 		await page.goBack();
+		await page.waitForLoadState('networkidle');
 		expect(page.url()).toBe(baseURL + '/anchor#last-anchor-2');
 		expect(await page.evaluate(() => scrollY)).toEqual(originalScrollY);
 
 		await page.goBack();
+		await page.waitForLoadState('networkidle');
+
 		expect(page.url()).toBe(baseURL + '/anchor');
 		expect(await page.evaluate(() => scrollY)).toEqual(0);
 	});
@@ -349,6 +352,7 @@ test.describe('Load', () => {
 		await page.goto('/load/change-detection/session/used');
 		expect(await page.textContent('h2')).toBe('1');
 		await page.click('button');
+		await page.waitForLoadState('networkidle');
 		expect(await page.textContent('h2')).toBe('2');
 
 		await page.goto('/load/change-detection/session/unused');
@@ -375,6 +379,7 @@ test.describe('Load', () => {
 		await clicknav('[href="/load/layout-props/b"]');
 		expect(await page.textContent('h1')).toBe('1');
 		await page.click('button');
+		await page.waitForLoadState('networkidle');
 		expect(await page.textContent('h1')).toBe('2');
 	});
 
@@ -607,6 +612,7 @@ test.describe('Page Store', () => {
 		);
 
 		await page.click('button');
+		await page.waitForLoadState('networkidle');
 
 		expect(await page.textContent('#page-data')).toBe('{"answer":1337}');
 		expect(await page.textContent('#store-data')).toBe(
@@ -614,3 +620,9 @@ test.describe('Page Store', () => {
 		);
 	});
 });
+
+test('Can use browser-only global on client-only page', async ({ page, read_errors }) => {
+	await page.goto('/no-ssr/browser-only-global');
+	await expect(page.locator('p')).toHaveText('Works');
+	expect(read_errors('/no-ssr/browser-only-global')).toBe(undefined);
+});
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index b5dad6f1f..75f6c7a8a 100644
--- a/packages/kit/test/apps/basics/test/test.js
+++ b/packages/kit/test/apps/basics/test/test.js
@@ -1446,6 +1446,7 @@ test.describe('Routing', () => {
 		await clicknav('[href="/routing/a"]');
 
 		await page.goBack();
+		await page.waitForLoadState('networkidle');
 		expect(await page.textContent('h1')).toBe('Great success!');
 	});