Repository: sveltejs/kit (js)

Issue:
`/_app/version.json` is marked `immutable` and cached forever
### Describe the bug

As mentioned in https://github.com/sveltejs/kit/issues/4188#issuecomment-1119089253, I suspect recent changes to the way that `/_app/version.json` is served have caused it to be treated as a static asset.

### Reproduction

See this simple repro: https://stackblitz.com/edit/sveltejs-kit-template-default-nwjzok?file=package.json&terminal=dev

The offending network requests are visible every 10 seconds:

<details>
  <summary>Example request</summary>
  
```
curl 'https://sveltejs-kit-template-default-nwjzok--3000.local.webcontainer.io/_app/version.json' \
  -H 'sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"' \
  -H 'pragma: no-cache' \
  -H 'cache-control: no-cache' \
  -H 'Referer: https://sveltejs-kit-template-default-nwjzok--3000.local.webcontainer.io/' \
  -H 'sec-ch-ua-mobile: ?0' \
  -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36' \
  -H 'sec-ch-ua-platform: "macOS"' \
  --compressed
```
</details>

<details>
  <summary>Example response headers</summary>
  
```
cache-control: public,max-age=31536000,immutable
connection: keep-alive
content-length: 24
content-type: application/json
date: Fri, 06 May 2022 08:28:08 GMT
etag: W/"24-1651825319850"
keep-alive: timeout=5
last-modified: Fri, 06 May 2022 08:21:59 GMT
vary: Accept-Encoding
```
</details>

A `immutable` cached response for `version.json` means my app will always believe there's an upgrade pending, as the browser has cached the initial version response.

### Logs

_No response_

### System Info

See StackBlitz container - the `npx envinfo` command didn't run to completion.

```shell
	"devDependencies": {
		"@sveltejs/adapter-node": "^1.0.0-next.73",
		"@sveltejs/kit": "next",
		"@types/cookie": "^0.4.1",
		"prettier": "^2.5.1",
		"prettier-plugin-svelte": "^2.5.0",
		"svelte": "^3.46.0",
		"svelte-check": "^2.2.6",
		"typescript": "~4.6.2"
	},
```


### Severity

blocking an upgrade

### Additional Information

_No response_

Pull request description:
Only serve `_app/immutable` with immutable cache headers
Rather than serving everything under `/_app` with an `immutable` cache header (including `/_app/version.json`, which is definitely _not_ immutable), this moves immutable assets into `/_app/build` so they can easily be given separate treatment.

Fixes #4837.

### 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/lemon-planets-sit.md b/.changeset/lemon-planets-sit.md
new file mode 100644
index 000000000..ef79aab28
--- /dev/null
+++ b/.changeset/lemon-planets-sit.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] svelte-kit sync no longer permanently deletes the types directory
diff --git a/.changeset/tricky-beers-unite.md b/.changeset/tricky-beers-unite.md
new file mode 100644
index 000000000..6eb740178
--- /dev/null
+++ b/.changeset/tricky-beers-unite.md
@@ -0,0 +1,5 @@
+---
+"@sveltejs/kit": patch
+---
+
+Update `engines` to specify that Node 16.7 is required
diff --git a/.changeset/wise-berries-flash.md b/.changeset/wise-berries-flash.md
new file mode 100644
index 000000000..8cb721893
--- /dev/null
+++ b/.changeset/wise-berries-flash.md
@@ -0,0 +1,10 @@
+---
+'@sveltejs/adapter-cloudflare': patch
+'@sveltejs/adapter-cloudflare-workers': patch
+'@sveltejs/adapter-netlify': patch
+'@sveltejs/adapter-node': patch
+'@sveltejs/adapter-vercel': patch
+'@sveltejs/kit': patch
+---
+
+only serve `_app/immutable` with immutable cache header, not `_app/version.json`
diff --git a/packages/adapter-cloudflare-workers/files/entry.js b/packages/adapter-cloudflare-workers/files/entry.js
index 2d2a5d32c..1064a1547 100644
--- a/packages/adapter-cloudflare-workers/files/entry.js
+++ b/packages/adapter-cloudflare-workers/files/entry.js
@@ -23,11 +23,15 @@ export default {
 			const res = await get_asset_from_kv(req, env, context);
 			if (is_error(res.status)) return res;
 
+			const cache_control = url.pathname.startsWith(prefix + 'immutable/')
+				? 'public, immutable, max-age=31536000'
+				: 'no-cache';
+
 			return new Response(res.body, {
 				headers: {
-					// include original cache headers, minus cache-control which
+					// include original headers, minus cache-control which
 					// is overridden, and etag which is no longer useful
-					'cache-control': 'public, immutable, max-age=31536000',
+					'cache-control': cache_control,
 					'content-type': res.headers.get('content-type'),
 					'x-robots-tag': 'noindex'
 				}
diff --git a/packages/adapter-cloudflare/src/worker.js b/packages/adapter-cloudflare/src/worker.js
index ad7c46430..4a53b71ea 100644
--- a/packages/adapter-cloudflare/src/worker.js
+++ b/packages/adapter-cloudflare/src/worker.js
@@ -20,11 +20,15 @@ const worker = {
 		if (pathname.startsWith(prefix)) {
 			res = await env.ASSETS.fetch(req);
 
+			const cache_control = pathname.startsWith(prefix + 'immutable/')
+				? 'public, immutable, max-age=31536000'
+				: 'no-cache';
+
 			res = new Response(res.body, {
 				headers: {
-					// include original cache headers, minus cache-control which
+					// include original headers, minus cache-control which
 					// is overridden, and etag which is no longer useful
-					'cache-control': 'public, immutable, max-age=31536000',
+					'cache-control': cache_control,
 					'content-type': res.headers.get('content-type'),
 					'x-robots-tag': 'noindex'
 				}
diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js
index 89fa7305c..774db538a 100644
--- a/packages/adapter-netlify/index.js
+++ b/packages/adapter-netlify/index.js
@@ -64,7 +64,7 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) {
 			builder.copy('_headers', headers_file);
 			appendFileSync(
 				headers_file,
-				`\n\n/${builder.config.kit.appDir}/*\n  cache-control: public\n  cache-control: immutable\n  cache-control: max-age=31536000\n`
+				`\n\n/${builder.config.kit.appDir}/immutable/*\n  cache-control: public\n  cache-control: immutable\n  cache-control: max-age=31536000\n`
 			);
 
 			// for esbuild, use ESM
diff --git a/packages/adapter-netlify/src/edge.js b/packages/adapter-netlify/src/edge.js
index 89d2eb7e5..533d57eb2 100644
--- a/packages/adapter-netlify/src/edge.js
+++ b/packages/adapter-netlify/src/edge.js
@@ -12,6 +12,7 @@ const prefix = `/${manifest.appDir}/`;
 export default function handler(request, context) {
 	if (is_static_file(request)) {
 		// Static files can skip the handler
+		// TODO can we serve _app/immutable files with an immutable cache header?
 		return;
 	}
 
@@ -33,6 +34,7 @@ function is_static_file(request) {
 	if (url.pathname.startsWith(prefix)) {
 		return true;
 	}
+
 	// prerendered pages and index.html files
 	const pathname = url.pathname.replace(/\/$/, '');
 	let file = pathname.substring(1);
diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js
index d5c66d920..4e3cfe1ee 100644
--- a/packages/adapter-node/src/handler.js
+++ b/packages/adapter-node/src/handler.js
@@ -22,18 +22,23 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 /**
  * @param {string} path
- * @param {number} max_age
- * @param {boolean} immutable
+ * @param {boolean} client
  */
-function serve(path, max_age, immutable = false) {
+function serve(path, client = false) {
 	return (
 		fs.existsSync(path) &&
 		sirv(path, {
 			etag: true,
-			maxAge: max_age,
-			immutable,
 			gzip: true,
-			brotli: true
+			brotli: true,
+			setHeaders:
+				client &&
+				((res, pathname) => {
+					// only apply to build directory, not e.g. version.json
+					if (pathname.startsWith(`/${manifest.appDir}/immutable/`)) {
+						res.setHeader('cache-control', 'public,max-age=31536000,immutable');
+					}
+				})
 		})
 	);
 }
@@ -126,9 +131,9 @@ function get_origin(headers) {
 
 export const handler = sequence(
 	[
-		serve(path.join(__dirname, '/client'), 31536000, true),
-		serve(path.join(__dirname, '/static'), 0),
-		serve(path.join(__dirname, '/prerendered'), 0),
+		serve(path.join(__dirname, '/client'), true),
+		serve(path.join(__dirname, '/static')),
+		serve(path.join(__dirname, '/prerendered')),
 		ssr
 	].filter(Boolean)
 );
diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js
index af46b97c8..23e007073 100644
--- a/packages/adapter-vercel/index.js
+++ b/packages/adapter-vercel/index.js
@@ -181,7 +181,7 @@ async function v1(builder, external) {
 			...prerendered_pages,
 			...prerendered_redirects,
 			{
-				src: `/${builder.config.kit.appDir}/.+`,
+				src: `/${builder.config.kit.appDir}/immutable/.+`,
 				headers: {
 					'cache-control': 'public, immutable, max-age=31536000'
 				}
diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json
index c2a219d41..879abde43 100644
--- a/packages/adapter-vercel/package.json
+++ b/packages/adapter-vercel/package.json
@@ -29,7 +29,7 @@
 		"check": "tsc"
 	},
 	"dependencies": {
-		"@vercel/nft": "^0.18.2",
+		"@vercel/nft": "^0.19.0",
 		"esbuild": "^0.14.29"
 	},
 	"devDependencies": {
diff --git a/packages/kit/package.json b/packages/kit/package.json
index b6cd22540..6ee4d5f3e 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -77,6 +77,6 @@
 	},
 	"types": "types/index.d.ts",
 	"engines": {
-		"node": ">=16"
+		"node": ">=16.7"
 	}
 }
diff --git a/packages/kit/src/core/build/build_client.js b/packages/kit/src/core/build/build_client.js
index 1e7f23bc8..8bea2daf3 100644
--- a/packages/kit/src/core/build/build_client.js
+++ b/packages/kit/src/core/build/build_client.js
@@ -59,7 +59,7 @@ export async function build_client({
 		build: {
 			cssCodeSplit: true,
 			manifest: true,
-			outDir: client_out_dir,
+			outDir: `${client_out_dir}/immutable`,
 			polyfillDynamicImport: false,
 			rollupOptions: {
 				input,
@@ -95,7 +95,9 @@ export async function build_client({
 	const { chunks, assets } = await create_build(merged_config);
 
 	/** @type {import('vite').Manifest} */
-	const vite_manifest = JSON.parse(fs.readFileSync(`${client_out_dir}/manifest.json`, 'utf-8'));
+	const vite_manifest = JSON.parse(
+		fs.readFileSync(`${client_out_dir}/immutable/manifest.json`, 'utf-8')
+	);
 
 	const entry = posixify(client_entry_file);
 	const entry_js = new Set();
diff --git a/packages/kit/src/core/build/build_server.js b/packages/kit/src/core/build/build_server.js
index 289abecf0..8cf13eb49 100644
--- a/packages/kit/src/core/build/build_server.js
+++ b/packages/kit/src/core/build/build_server.js
@@ -69,7 +69,7 @@ export class Server {
 			manifest,
 			method_override: ${s(config.kit.methodOverride)},
 			paths: { base, assets },
-			prefix: assets + '/${config.kit.appDir}/',
+			prefix: assets + '/${config.kit.appDir}/immutable/',
 			prerender: {
 				default: ${config.kit.prerender.default},
 				enabled: ${config.kit.prerender.enabled}
diff --git a/packages/kit/src/core/build/build_service_worker.js b/packages/kit/src/core/build/build_service_worker.js
index e193242d6..6b43554bd 100644
--- a/packages/kit/src/core/build/build_service_worker.js
+++ b/packages/kit/src/core/build/build_service_worker.js
@@ -44,7 +44,7 @@ export async function build_service_worker(
 
 			export const build = [
 				${Array.from(build)
-					.map((file) => `${s(`${config.kit.paths.base}/${config.kit.appDir}/${file}`)}`)
+					.map((file) => `${s(`${config.kit.paths.base}/${config.kit.appDir}/immutable/${file}`)}`)
 					.join(',\n\t\t\t\t')}
 			];
 
diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js
index 61def523c..4bd182b64 100644
--- a/packages/kit/src/core/build/index.js
+++ b/packages/kit/src/core/build/index.js
@@ -26,15 +26,18 @@ export async function build(config, { log }) {
 
 	const { manifest_data } = sync.all(config);
 
+	// TODO this is so that Vite's preloading works. Unfortunately, it fails
+	// during `svelte-kit preview`, because we use a local asset path. If Vite
+	// used relative paths, I _think_ this could get fixed. Issue here:
+	// https://github.com/vitejs/vite/issues/2009
+	const { base, assets } = config.kit.paths;
+	const assets_base = `${assets || base}/${config.kit.appDir}/immutable/`;
+
 	const options = {
 		cwd,
 		config,
 		build_dir,
-		// TODO this is so that Vite's preloading works. Unfortunately, it fails
-		// during `svelte-kit preview`, because we use a local asset path. If Vite
-		// used relative paths, I _think_ this could get fixed. Issue here:
-		// https://github.com/vitejs/vite/issues/2009
-		assets_base: `${config.kit.paths.assets || config.kit.paths.base}/${config.kit.appDir}/`,
+		assets_base,
 		manifest_data,
 		output_dir,
 		client_entry_file: path.relative(cwd, `${get_runtime_path(config)}/client/start.js`),
@@ -65,8 +68,8 @@ export async function build(config, { log }) {
 
 	const files = new Set([
 		...static_files,
-		...client.chunks.map((chunk) => `${config.kit.appDir}/${chunk.fileName}`),
-		...client.assets.map((chunk) => `${config.kit.appDir}/${chunk.fileName}`)
+		...client.chunks.map((chunk) => `${config.kit.appDir}/immutable/${chunk.fileName}`),
+		...client.assets.map((chunk) => `${config.kit.appDir}/immutable/${chunk.fileName}`)
 	]);
 
 	// TODO is this right?
diff --git a/packages/kit/src/core/preview/index.js b/packages/kit/src/core/preview/index.js
index 5cf910db5..90adb4017 100644
--- a/packages/kit/src/core/preview/index.js
+++ b/packages/kit/src/core/preview/index.js
@@ -67,8 +67,12 @@ export async function preview({ port, host, https: use_https = false }) {
 		scoped(
 			assets,
 			sirv(join(config.kit.outDir, 'output/client'), {
-				maxAge: 31536000,
-				immutable: true
+				setHeaders: (res, pathname) => {
+					// only apply to build directory, not e.g. version.json
+					if (pathname.startsWith(`/${config.kit.appDir}/immutable`)) {
+						res.setHeader('cache-control', 'public,max-age=31536000,immutable');
+					}
+				}
 			})
 		),
 
diff --git a/packages/kit/src/core/sync/utils.js b/packages/kit/src/core/sync/utils.js
index aaf27a3ef..be1862cd1 100644
--- a/packages/kit/src/core/sync/utils.js
+++ b/packages/kit/src/core/sync/utils.js
@@ -11,12 +11,20 @@ const previous_contents = new Map();
  */
 export function write_if_changed(file, code) {
 	if (code !== previous_contents.get(file)) {
-		previous_contents.set(file, code);
-		mkdirp(path.dirname(file));
-		fs.writeFileSync(file, code);
+		write(file, code);
 	}
 }
 
+/**
+ * @param {string} file
+ * @param {string} code
+ */
+export function write(file, code) {
+	previous_contents.set(file, code);
+	mkdirp(path.dirname(file));
+	fs.writeFileSync(file, code);
+}
+
 /** @param {string} str */
 export function trim(str) {
 	const indentation = /** @type {RegExpExecArray} */ (/\n?(\s*)/.exec(str))[1];
diff --git a/packages/kit/src/core/sync/write_types.js b/packages/kit/src/core/sync/write_types.js
index 01f129aa8..9327020ad 100644
--- a/packages/kit/src/core/sync/write_types.js
+++ b/packages/kit/src/core/sync/write_types.js
@@ -1,6 +1,6 @@
 import { rimraf } from '../../utils/filesystem.js';
 import { parse_route_id } from '../../utils/routing.js';
-import { write_if_changed } from './utils.js';
+import { write } from './utils.js';
 
 /** @param {string} imports */
 const header = (imports) => `
@@ -76,9 +76,6 @@ export function write_types(config, manifest_data) {
 		const parts = (key || 'index').split('/');
 		parts.push('__types', /** @type {string} */ (parts.pop()));
 
-		write_if_changed(
-			`${config.kit.outDir}/types/${parts.join('/')}.d.ts`,
-			content.join('\n').trim()
-		);
+		write(`${config.kit.outDir}/types/${parts.join('/')}.d.ts`, content.join('\n').trim());
 	});
 }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a03b982f9..45b63d559 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -146,10 +146,10 @@ importers:
   packages/adapter-vercel:
     specifiers:
       '@sveltejs/kit': workspace:*
-      '@vercel/nft': ^0.18.2
+      '@vercel/nft': ^0.19.0
       esbuild: ^0.14.29
     dependencies:
-      '@vercel/nft': 0.18.2
+      '@vercel/nft': 0.19.1
       esbuild: 0.14.29
     devDependencies:
       '@sveltejs/kit': link:../kit
@@ -1695,8 +1695,8 @@ packages:
       - supports-color
     dev: true
 
-  /@vercel/nft/0.18.2:
-    resolution: {integrity: sha512-Oxy4y5JDh7CMbaxEGjKKzHcnQ1gRQqtfp+x3xvOmZYixXHwaD2RMJDTzaPz0b2B3pgVbbPOHY87wffJPFDaoFg==}
+  /@vercel/nft/0.19.1:
+    resolution: {integrity: sha512-klR5oN7S3WJsZz0r6Xsq7o8YlFEyU3/00VmlpZzIPVFzKfbcEjXo/sVR5lQBUqNKuOzhcbxaFtzW9aOyHjmPYA==}
     hasBin: true
     dependencies:
       '@mapbox/node-pre-gyp': 1.0.9
@@ -5599,7 +5599,6 @@ packages:
   /svelte/3.48.0:
     resolution: {integrity: sha512-fN2YRm/bGumvjUpu6yI3BpvZnpIm9I6A7HR4oUNYd7ggYyIwSA/BX7DJ+UXXffLp6XNcUijyLvttbPVCYa/3xQ==}
     engines: {node: '>= 8'}
-    dev: true
 
   /svelte2tsx/0.5.0_rxzurg3hjloskwsrdpptpplucy:
     resolution: {integrity: sha512-pgMMPzPylwzb/XTLXZQ0y6LUlYzVTep4537NJzHNyTbML864pfVS27iVFOgUK7XmKLyuIdG7tgweUZTsIwgKHw==}


Test patch:
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index 8bec7f673..a8af8739d 100644
--- a/packages/kit/test/apps/basics/test/test.js
+++ b/packages/kit/test/apps/basics/test/test.js
@@ -347,7 +347,7 @@ test.describe.parallel('Imports', () => {
 			]);
 		} else {
 			expect(sources[0].startsWith('data:image/png;base64,')).toBeTruthy();
-			expect(sources[1]).toBe(`${baseURL}/_app/assets/large-3183867c.jpg`);
+			expect(sources[1]).toBe(`${baseURL}/_app/immutable/assets/large-3183867c.jpg`);
 		}
 	});
 });
@@ -2649,3 +2649,13 @@ test.describe.parallel('XSS', () => {
 		);
 	});
 });
+
+test.describe.parallel('Version', () => {
+	test('does not serve version.json with an immutable cache header', async ({ request }) => {
+		// this isn't actually a great test, because caching behaviour is down to adapters.
+		// but it's better than nothing
+		const response = await request.get('/_app/version.json');
+		const headers = response.headers();
+		expect(headers['cache-control'] || '').not.toContain('immutable');
+	});
+});
diff --git a/packages/kit/test/apps/options-2/test/test.js b/packages/kit/test/apps/options-2/test/test.js
index 0d8ba5409..22b9e99d1 100644
--- a/packages/kit/test/apps/options-2/test/test.js
+++ b/packages/kit/test/apps/options-2/test/test.js
@@ -21,7 +21,7 @@ test.describe.parallel('Service worker', () => {
 			const response = await request.get('/basepath/service-worker.js');
 			const content = await response.text();
 
-			expect(content).toMatch(/\/_app\/start-[a-z0-9]+\.js/);
+			expect(content).toMatch(/\/_app\/immutable\/start-[a-z0-9]+\.js/);
 		});
 
 		test('does not register /basepath/service-worker.js', async ({ page }) => {