Repository: sveltejs/kit (js)

Issue:
Looking for a method to inject a custom <script> tag into the head of any HTML response with relevant CSP hash or nonce
### Describe the problem

We are using newrelic and are exploring their browser timing scripts feature to get access to additional client-side metrics. 

This script includes a number of environment specific variables and we are using CSP, so there are a few approaches we can take:

1. Add the script to `app.html`, which will allow inserting the csp_nonce but doesn't allow us to access custom env variables for deploy specific api keys, etc, so we can't do deploy specific scripts.
2. Insert the script via Svelte in `+layout.svelte`. Pass the script textContent to the browser via load data and insert it into the DOM there. We also calculate the sha hash of the script and manually insert it into the csp header for the request in `hooks.server.ts` (this is what we are doing now)

I'd like to improve our flow since it feels pretty hacky to me right now

### Describe the proposed solution

Proposals:

1. An ability to access other server env variables in `app.html`, eg `%sveltekit.env.{variableName}%`
2. An ability to access the csp nonce within hooks.server.ts, eg `event.nonce`. This would allow the script tag to be manually inserted into the html response and to include the nonce in the tag.

### Alternatives considered

We are very open to suggestions for different ways to solve this problem :)

### Importance

nice to have

### Additional Information

_No response_

Pull request description:
feat: allow access to public env in app.html
closes #8427

This PR is to enable access to public env variables within `app.html` at render-time. 

An example of this being useful would be the insertion of a public api key into a `<script>` based upon the deploy environment configuration.

### 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
- [x] 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


Patch:
diff --git a/.changeset/spotty-points-battle.md b/.changeset/spotty-points-battle.md
new file mode 100644
index 000000000..530627587
--- /dev/null
+++ b/.changeset/spotty-points-battle.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': minor
+---
+
+feat: enable access to public env within app.html
diff --git a/documentation/docs/10-getting-started/30-project-structure.md b/documentation/docs/10-getting-started/30-project-structure.md
index 1a3d9b672..c75a46eb4 100644
--- a/documentation/docs/10-getting-started/30-project-structure.md
+++ b/documentation/docs/10-getting-started/30-project-structure.md
@@ -45,6 +45,7 @@ The `src` directory contains the meat of your project.
   - `%sveltekit.body%` — the markup for a rendered page. This should live inside a `<div>` or other element, rather than directly inside `<body>`, to prevent bugs caused by browser extensions injecting elements that are then destroyed by the hydration process. SvelteKit will warn you in development if this is not the case
   - `%sveltekit.assets%` — either [`paths.assets`](/docs/configuration#paths), if specified, or a relative path to [`paths.base`](/docs/configuration#paths)
   - `%sveltekit.nonce%` — a [CSP](/docs/configuration#csp) nonce for manually included links and scripts, if used
+  - `%sveltekit.env.[NAME]%` - this will be replaced at render time with the `[NAME]` environment variable, which must begin with the [`publicPrefix`](https://kit.svelte.dev/docs/configuration#env) (usually `PUBLIC_`). It will fallback to `''` if not matched.
 - `error.html` (optional) is the page that is rendered when everything else fails. It can contain the following placeholders:
   - `%sveltekit.status%` — the HTTP status
   - `%sveltekit.error.message%` — the error message
diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js
index 047c67532..04a4eaffc 100644
--- a/packages/kit/src/core/config/index.js
+++ b/packages/kit/src/core/config/index.js
@@ -9,24 +9,33 @@ import options from './options.js';
  * @param {string} cwd
  * @param {import('types').ValidatedConfig} config
  */
-export function load_template(cwd, config) {
-	const { appTemplate } = config.kit.files;
-	const relative = path.relative(cwd, appTemplate);
-
-	if (fs.existsSync(appTemplate)) {
-		const contents = fs.readFileSync(appTemplate, 'utf8');
-
-		const expected_tags = ['%sveltekit.head%', '%sveltekit.body%'];
-		expected_tags.forEach((tag) => {
-			if (contents.indexOf(tag) === -1) {
-				throw new Error(`${relative} is missing ${tag}`);
-			}
-		});
-	} else {
+export function load_template(cwd, { kit }) {
+	const { env, files } = kit;
+
+	const relative = path.relative(cwd, files.appTemplate);
+
+	if (!fs.existsSync(files.appTemplate)) {
 		throw new Error(`${relative} does not exist`);
 	}
 
-	return fs.readFileSync(appTemplate, 'utf-8');
+	const contents = fs.readFileSync(files.appTemplate, 'utf8');
+
+	const expected_tags = ['%sveltekit.head%', '%sveltekit.body%'];
+	expected_tags.forEach((tag) => {
+		if (contents.indexOf(tag) === -1) {
+			throw new Error(`${relative} is missing ${tag}`);
+		}
+	});
+
+	for (const match of contents.matchAll(/%sveltekit\.env\.([^%]+)%/g)) {
+		if (!match[1].startsWith(env.publicPrefix)) {
+			throw new Error(
+				`Environment variables in ${relative} must start with ${env.publicPrefix} (saw %sveltekit.env.${match[1]}%)`
+			);
+		}
+	}
+
+	return contents;
 }
 
 /**
diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js
index 162a6efc1..6cb05a8ee 100644
--- a/packages/kit/src/core/sync/write_server.js
+++ b/packages/kit/src/core/sync/write_server.js
@@ -38,11 +38,15 @@ export const options = {
 	root,
 	service_worker: ${has_service_worker},
 	templates: {
-		app: ({ head, body, assets, nonce }) => ${s(template)
+		app: ({ head, body, assets, nonce, env }) => ${s(template)
 			.replace('%sveltekit.head%', '" + head + "')
 			.replace('%sveltekit.body%', '" + body + "')
 			.replace(/%sveltekit\.assets%/g, '" + assets + "')
-			.replace(/%sveltekit\.nonce%/g, '" + nonce + "')},
+			.replace(/%sveltekit\.nonce%/g, '" + nonce + "')
+			.replace(
+				/%sveltekit\.env\.([^%]+)%/g,
+				(_match, capture) => `" + (env[${s(capture)}] ?? "") + "`
+			)},
 		error: ({ status, message }) => ${s(error_page)
 			.replace(/%sveltekit\.status%/g, '" + status + "')
 			.replace(/%sveltekit\.error\.message%/g, '" + message + "')}
diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js
index 21b7f7bc7..a2ba0d881 100644
--- a/packages/kit/src/exports/vite/dev/index.js
+++ b/packages/kit/src/exports/vite/dev/index.js
@@ -73,8 +73,7 @@ export async function dev(vite, vite_config, svelte_config) {
 		} catch (error) {
 			manifest_error = /** @type {Error} */ (error);
 
-			console.error(colors.bold().red('Invalid routes'));
-			console.error(error);
+			console.error(colors.bold().red(manifest_error.message));
 			vite.ws.send({
 				type: 'error',
 				err: {
@@ -444,8 +443,7 @@ export async function dev(vite, vite_config, svelte_config) {
 				}
 
 				if (manifest_error) {
-					console.error(colors.bold().red('Invalid routes'));
-					console.error(manifest_error);
+					console.error(colors.bold().red(manifest_error.message));
 
 					const error_page = load_error_page(svelte_config);
 
diff --git a/packages/kit/src/runtime/env-public.js b/packages/kit/src/runtime/env-public.js
index 481a87046..800f6262a 100644
--- a/packages/kit/src/runtime/env-public.js
+++ b/packages/kit/src/runtime/env-public.js
@@ -1,3 +1,4 @@
+/** @type {Record<string, string>} */
 export let env = {};
 
 /** @type {(environment: Record<string, string>) => void} */
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index 85f06742b..e23e8ee00 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -366,7 +366,8 @@ export async function render_response({
 		head,
 		body,
 		assets: resolved_assets,
-		nonce: /** @type {string} */ (csp.nonce)
+		nonce: /** @type {string} */ (csp.nonce),
+		env
 	});
 
 	// TODO flush chunks as early as we can
diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts
index 54679a608..85734650f 100644
--- a/packages/kit/types/internal.d.ts
+++ b/packages/kit/types/internal.d.ts
@@ -306,7 +306,13 @@ export interface SSROptions {
 	root: SSRComponent['default'];
 	service_worker: boolean;
 	templates: {
-		app(values: { head: string; body: string; assets: string; nonce: string }): string;
+		app(values: {
+			head: string;
+			body: string;
+			assets: string;
+			nonce: string;
+			env: Record<string, string>;
+		}): string;
 		error(values: { message: string; status: number }): string;
 	};
 }


Test patch:
diff --git a/packages/kit/test/apps/basics/.env b/packages/kit/test/apps/basics/.env
index 1597af124..a406fc245 100644
--- a/packages/kit/test/apps/basics/.env
+++ b/packages/kit/test/apps/basics/.env
@@ -3,3 +3,5 @@ PRIVATE_DYNAMIC="accessible to server-side code/evaluated at run time"
 
 PUBLIC_STATIC="accessible anywhere/replaced at build time"
 PUBLIC_DYNAMIC="accessible anywhere/evaluated at run time"
+
+PUBLIC_THEME="groovy"
diff --git a/packages/kit/test/apps/basics/src/app.html b/packages/kit/test/apps/basics/src/app.html
index 4f4316bd9..aa17593fe 100644
--- a/packages/kit/test/apps/basics/src/app.html
+++ b/packages/kit/test/apps/basics/src/app.html
@@ -7,7 +7,7 @@
 		<meta name="transform-page" content="__REPLACEME__" />
 		%sveltekit.head%
 	</head>
-	<body>
+	<body class="%sveltekit.env.PUBLIC_THEME%">
 		<div>%sveltekit.body%</div>
 		<a href="/routing/link-outside-app-target/target">outside app target</a>
 	</body>
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index 06262df37..91b4beec8 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -600,3 +600,10 @@ test.describe('Content negotiation', () => {
 		await expect(page.locator('[data-testid="form-result"]')).toHaveText('form.submitted: true');
 	});
 });
+
+test.describe('env in app.html', () => {
+	test('can access public env', async ({ page }) => {
+		await page.goto('/');
+		expect(await page.locator('body').getAttribute('class')).toContain('groovy');
+	});
+});