Repository: sveltejs/kit (js)

Issue:
Way to access the response body size in the hook
### Describe the problem

I want to set up logging in my sveltekit hook. One of the things I want to log is the number of bytes in the response body. It would be nice if the `Response` exposed the body length so that we can access it. Something like this...

```js
export async function handle({ event, resolve }) {
    response = await resolve(event)
    // these don't actually work, they are undefined. But something LIKE this would be nice
    console.log(response.body.length)
    console.log(response.headers.get("content-length"))
    return response
}
```

I notice that the length is somewhere on the object if I just `console.log(response)` when returning JSON...

```
Response {
  [Symbol(realm)]: { settingsObject: {} },
  [Symbol(state)]: {
    aborted: false,
    rangeRequested: false,
    timingAllowPassed: false,
    requestIncludesCredentials: false,
    type: 'default',
    status: 200,
    timingInfo: null,
    cacheState: '',
    statusText: '',
    headersList: HeadersList {
      [Symbol(headers map)]: [Map],
      [Symbol(headers map sorted)]: null
    },
    urlList: [],
    body: {
      stream: undefined,
      source: '{"result":{"rows":[["to become more autonomous in data analysis"],["to better structure your thoughts"],["to develop a useful professional skill"]],"columns":[{"name":"reason","type":"VARCHAR"}],"moreRows":false}}',
      length: 213
    }
  },
  [Symbol(headers)]: HeadersList {
    [Symbol(headers map)]: Map(2) {
      'content-type' => 'application/json; charset=utf-8',
      'etag' => '"1ba7x9x"'
    },
    [Symbol(headers map sorted)]: null
  }
}
```

...but that piece of data is private (attached to a symbol) and I can't access it.

In my opinion, being able to observe information like this in my hook is important to building debuggable, observable web services. Response payload size is an important metric to monitor. Putting that logging logic in the hook seems to be the only way to make sure it is reliably logged.

### Describe the proposed solution

The workaround I have right now is `await response.clone().text()`, but that is not ideal because I have to consume the stream. This is wasteful especially if I only want the length.


### Alternatives considered

I suppose I could do something with `Object.getOwnPropertySymbols()`, but I think that's frowned upon?

### Importance

would make my life easier

### Additional Information

As I understand it (may be wrong), the content length isn't necessarily always known without reading the stream, but there are many cases where the length is known at the time of the response instantiation, like when JSON or HTML are being returned from a string in memory. Sveltekit should be able to set the length in cases where length is known.

Here is a simple case where content length is known but not exposed: https://stackblitz.com/edit/sveltejs-kit-template-default-9ydsbu?file=src%2Fhooks.js&terminal=dev

Pull request:
feat: add content-length headers to generated responses, via new `text(...)` helper
closes #5749.

This is just an idea; I don't know if it's the right one. In order to make it easier to determine response content length inside `handle`, for logging purposes, this adds a new `text(body)` helper similar to the `json(data)` helper. It returns a `Response` object with a `content-length` header, and uses it in favour of `new Response(...)` when generating pages and other responses.

One observation: it'd be nice if we could unit-test `Server` rather than having Playwright tests for the bulk of the tests in `server.test.js`. Might look into that separately.

### 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/nine-walls-listen.md b/.changeset/nine-walls-listen.md
new file mode 100644
index 000000000..ff046dacf
--- /dev/null
+++ b/.changeset/nine-walls-listen.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+fix: add `Content-Length` header to SvelteKit-generated responses
diff --git a/.changeset/old-weeks-reflect.md b/.changeset/old-weeks-reflect.md
new file mode 100644
index 000000000..21df75b53
--- /dev/null
+++ b/.changeset/old-weeks-reflect.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': minor
+---
+
+feat: add `text(...)` helper for generating text responses
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/index.js b/packages/kit/src/exports/index.js
index f5ba4abcf..b14a6e961 100644
--- a/packages/kit/src/exports/index.js
+++ b/packages/kit/src/exports/index.js
@@ -29,12 +29,36 @@ export function redirect(status, location) {
 export function json(data, init) {
 	// TODO deprecate this in favour of `Response.json` when it's
 	// more widely supported
+	const body = JSON.stringify(data);
+
+	// we can't just do `text(JSON.stringify(data), init)` because
+	// it will set a default `content-type` header. duplicated code
+	// means less duplicated work
 	const headers = new Headers(init?.headers);
+	if (!headers.has('content-length')) {
+		headers.set('content-length', encoder.encode(body).byteLength.toString());
+	}
+
 	if (!headers.has('content-type')) {
 		headers.set('content-type', 'application/json');
 	}
 
-	return new Response(JSON.stringify(data), {
+	return new Response(body, {
+		...init,
+		headers
+	});
+}
+
+const encoder = new TextEncoder();
+
+/** @type {import('@sveltejs/kit').text} */
+export function text(body, init) {
+	const headers = new Headers(init?.headers);
+	if (!headers.has('content-length')) {
+		headers.set('content-length', encoder.encode(body).byteLength.toString());
+	}
+
+	return new Response(body, {
 		...init,
 		headers
 	});
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/data/index.js b/packages/kit/src/runtime/server/data/index.js
index de4113bdb..8c0faa4ff 100644
--- a/packages/kit/src/runtime/server/data/index.js
+++ b/packages/kit/src/runtime/server/data/index.js
@@ -4,6 +4,7 @@ import { once } from '../../../utils/functions.js';
 import { load_server_data } from '../page/load_data.js';
 import { clarify_devalue_error, handle_error_and_jsonify, serialize_data_node } from '../utils.js';
 import { normalize_path } from '../../../utils/url.js';
+import { text } from '../../../exports/index.js';
 
 export const INVALIDATED_PARAM = 'x-sveltekit-invalidated';
 
@@ -139,7 +140,7 @@ export async function render_data(
  * @param {number} [status]
  */
 function json_response(json, status = 200) {
-	return new Response(json, {
+	return text(json, {
 		status,
 		headers: {
 			'content-type': 'application/json',
diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js
index accae7c94..cec1e42ce 100644
--- a/packages/kit/src/runtime/server/page/index.js
+++ b/packages/kit/src/runtime/server/page/index.js
@@ -1,3 +1,4 @@
+import { text } from '../../../exports/index.js';
 import { compact } from '../../../utils/array.js';
 import { normalize_error } from '../../../utils/error.js';
 import { add_data_suffix } from '../../../utils/url.js';
@@ -32,7 +33,7 @@ import { respond_with_error } from './respond_with_error.js';
 export async function render_page(event, route, page, options, manifest, state, resolve_opts) {
 	if (state.initiator === route) {
 		// infinite request cycle detected
-		return new Response(`Not found: ${event.url.pathname}`, {
+		return text(`Not found: ${event.url.pathname}`, {
 			status: 404
 		});
 	}
@@ -239,7 +240,7 @@ export async function render_page(event, route, page, options, manifest, state,
 							});
 
 							state.prerendering.dependencies.set(data_pathname, {
-								response: new Response(body),
+								response: text(body),
 								body
 							});
 						}
@@ -294,7 +295,7 @@ export async function render_page(event, route, page, options, manifest, state,
 				.join(',')}]}`;
 
 			state.prerendering.dependencies.set(data_pathname, {
-				response: new Response(body),
+				response: text(body),
 				body
 			});
 		}
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index 85f06742b..55bba9a9d 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -9,6 +9,7 @@ import { uneval_action_response } from './actions.js';
 import { clarify_devalue_error } from '../utils.js';
 import { assets, base, version } from '../../shared.js';
 import { env } from '../../env-public.js';
+import { text } from '../../../exports/index.js';
 
 // TODO rename this function/module
 
@@ -366,7 +367,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
@@ -407,7 +409,7 @@ export async function render_response({
 		}
 	}
 
-	return new Response(transformed, {
+	return text(transformed, {
 		status,
 		headers
 	});
diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js
index b98a65762..07b7eab1d 100644
--- a/packages/kit/src/runtime/server/respond.js
+++ b/packages/kit/src/runtime/server/respond.js
@@ -23,7 +23,7 @@ import {
 	validate_page_server_exports,
 	validate_server_exports
 } from '../../utils/exports.js';
-import { error, json } from '../../exports/index.js';
+import { error, json, text } from '../../exports/index.js';
 import * as paths from '../shared.js';
 
 /* global __SVELTEKIT_ADAPTER_NAME__ */
@@ -53,7 +53,7 @@ export async function respond(request, options, manifest, state) {
 			if (request.headers.get('accept') === 'application/json') {
 				return json(csrf_error.body, { status: csrf_error.status });
 			}
-			return new Response(csrf_error.body.message, { status: csrf_error.status });
+			return text(csrf_error.body.message, { status: csrf_error.status });
 		}
 	}
 
@@ -61,7 +61,7 @@ export async function respond(request, options, manifest, state) {
 	try {
 		decoded = decode_pathname(url.pathname);
 	} catch {
-		return new Response('Malformed URI', { status: 400 });
+		return text('Malformed URI', { status: 400 });
 	}
 
 	/** @type {import('types').SSRRoute | null} */
@@ -72,7 +72,7 @@ export async function respond(request, options, manifest, state) {
 
 	if (paths.base && !state.prerendering?.fallback) {
 		if (!decoded.startsWith(paths.base)) {
-			return new Response('Not found', { status: 404 });
+			return text('Not found', { status: 404 });
 		}
 		decoded = decoded.slice(paths.base.length) || '/';
 	}
@@ -374,7 +374,7 @@ export async function respond(request, options, manifest, state) {
 			}
 
 			if (state.initiator === GENERIC_ERROR) {
-				return new Response('Internal Server Error', {
+				return text('Internal Server Error', {
 					status: 500
 				});
 			}
@@ -394,7 +394,7 @@ export async function respond(request, options, manifest, state) {
 			}
 
 			if (state.prerendering) {
-				return new Response('not found', { status: 404 });
+				return text('not found', { status: 404 });
 			}
 
 			// we can't load the endpoint from our own manifest,
diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js
index d7e31bb30..49f873e41 100644
--- a/packages/kit/src/runtime/server/utils.js
+++ b/packages/kit/src/runtime/server/utils.js
@@ -1,4 +1,5 @@
 import * as devalue from 'devalue';
+import { json, text } from '../../exports/index.js';
 import { coalesce_to_error } from '../../utils/error.js';
 import { negotiate } from '../../utils/http.js';
 import { has_data_suffix } from '../../utils/url.js';
@@ -27,7 +28,7 @@ export const GENERIC_ERROR = {
  * @param {import('types').HttpMethod} method
  */
 export function method_not_allowed(mod, method) {
-	return new Response(`${method} method not allowed`, {
+	return text(`${method} method not allowed`, {
 		status: 405,
 		headers: {
 			// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405
@@ -75,7 +76,7 @@ export function get_option(nodes, option) {
  * @param {string} message
  */
 export function static_error_page(options, status, message) {
-	return new Response(options.templates.error({ status, message }), {
+	return text(options.templates.error({ status, message }), {
 		headers: { 'content-type': 'text/html; charset=utf-8' },
 		status
 	});
@@ -98,9 +99,8 @@ export async function handle_fatal_error(event, options, error) {
 	]);
 
 	if (has_data_suffix(new URL(event.request.url).pathname) || type === 'application/json') {
-		return new Response(JSON.stringify(body), {
-			status,
-			headers: { 'content-type': 'application/json; charset=utf-8' }
+		return json(body, {
+			status
 		});
 	}
 
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index 1891ec283..d80c735c5 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -1123,10 +1123,17 @@ export interface Redirect {
 /**
  * Create a JSON `Response` object from the supplied data.
  * @param data The value that will be serialized as JSON.
- * @param init Options such as `status` and `headers` that will be added to the response. A `Content-Type: application/json` header will be added automatically.
+ * @param init Options such as `status` and `headers` that will be added to the response. `Content-Type: application/json` and `Content-Length` headers will be added automatically.
  */
 export function json(data: any, init?: ResponseInit): Response;
 
+/**
+ * Create a `Response` object from the supplied body.
+ * @param body The value that will be used as-is.
+ * @param init Options such as `status` and `headers` that will be added to the response. A `Content-Length` header will be added automatically.
+ */
+export function text(body: string, init?: ResponseInit): Response;
+
 /**
  * Create an `ActionFailure` object.
  * @param status The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses). Must be in the range 400-599.
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/src/routes/content-length-header/+page.svelte b/packages/kit/test/apps/basics/src/routes/content-length-header/+page.svelte
new file mode 100644
index 000000000..e69de29bb
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');
+	});
+});
diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js
index ed773bbc1..9cbd17d95 100644
--- a/packages/kit/test/apps/basics/test/server.test.js
+++ b/packages/kit/test/apps/basics/test/server.test.js
@@ -23,6 +23,18 @@ test.describe('Content-Type', () => {
 	});
 });
 
+test.describe('Content-Length', () => {
+	test('sets Content-Length on page', async ({ request }) => {
+		const response = await request.get('/content-length-header');
+
+		// TODO this would ideally be a unit test of `Server`,
+		// as would most of the tests in this file
+		if (!response.headers()['content-encoding']) {
+			expect(+response.headers()['content-length']).toBeGreaterThan(1000);
+		}
+	});
+});
+
 test.describe('Cookies', () => {
 	test('does not forward cookies from external domains', async ({ request, start_server }) => {
 		const { port } = await start_server(async (req, res) => {