Repository: sveltejs/kit (js)

Issue:
Allow the `routes` folder to be empty 
### Describe the problem

When there are no routes present in the `routes` folder, the following error is thrown: 
```
error when starting dev server:
Error: The filesystem router API has changed, see https://github.com/sveltejs/kit/discussions/5774 for details
    at create_routes_and_nodes (file:///P:/weather-apps-api/node_modules/.pnpm/@sveltejs+kit@1.0.0-next.490_svelte@3.50.1+vite@3.1.3/node_modules/@sveltejs/kit/src/core/sync/create_manifest_data/index.js:229:11)
```


### Describe the proposed solution

Allowing the `routes` folder to be empty (when the `static` folder has content in it?)

### Alternatives considered

 To work around this annoyance, I created an empty `+page.svelte` component in the routes folder.

### Importance

nice to have

### Additional Information

_No response_

Pull request description:
[fix] allow missing routes folder
Fixes #6921

### 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.
- [ ] 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/dirty-candles-peel.md b/.changeset/dirty-candles-peel.md
new file mode 100644
index 000000000..5ea2f31ae
--- /dev/null
+++ b/.changeset/dirty-candles-peel.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] allow missing routes folder
diff --git a/.changeset/witty-shoes-know.md b/.changeset/witty-shoes-know.md
new file mode 100644
index 000000000..045cf5139
--- /dev/null
+++ b/.changeset/witty-shoes-know.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Only forward set-cookie headers for internal fetches
diff --git a/packages/kit/scripts/extract-types.js b/packages/kit/scripts/extract-types.js
index f06110520..36a95b78d 100644
--- a/packages/kit/scripts/extract-types.js
+++ b/packages/kit/scripts/extract-types.js
@@ -22,6 +22,11 @@ function get_types(code, statements) {
 
 	if (statements) {
 		for (const statement of statements) {
+			const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
+
+			const export_modifier = modifiers?.find((modifier) => modifier.kind === 93);
+			if (!export_modifier) continue;
+
 			if (
 				ts.isClassDeclaration(statement) ||
 				ts.isInterfaceDeclaration(statement) ||
diff --git a/packages/kit/src/core/sync/create_manifest_data/index.js b/packages/kit/src/core/sync/create_manifest_data/index.js
index e9cd7205e..852a02a86 100644
--- a/packages/kit/src/core/sync/create_manifest_data/index.js
+++ b/packages/kit/src/core/sync/create_manifest_data/index.js
@@ -225,97 +225,115 @@ function create_routes_and_nodes(cwd, config, fallback) {
 		walk(0, '', '', null);
 
 		const root = /** @type {import('types').RouteData} */ (route_map.get(''));
-
-		// TODO remove for 1.0
 		if (route_map.size === 1) {
 			if (!root.leaf && !root.error && !root.layout && !root.endpoint) {
 				throw new Error(
+					// TODO adjust this error message for 1.0
+					// 'No routes found. If you are using a custom src/routes directory, make sure it is specified in svelte.config.js'
 					'The filesystem router API has changed, see https://github.com/sveltejs/kit/discussions/5774 for details'
 				);
 			}
 		}
+	} else {
+		// If there's no routes directory, we'll just create a single empty route. This ensures the root layout and
+		// error components are included in the manifest, which is needed for subsequent build/dev commands to work
+		route_map.set('', {
+			id: '',
+			segment: '',
+			pattern: /^$/,
+			names: [],
+			types: [],
+			parent: null,
+			layout: null,
+			error: null,
+			leaf: null,
+			page: null,
+			endpoint: null
+		});
+	}
 
-		if (!root.layout?.component) {
-			if (!root.layout) root.layout = { depth: 0, child_pages: [] };
-			root.layout.component = posixify(path.relative(cwd, `${fallback}/layout.svelte`));
-		}
+	const root = /** @type {import('types').RouteData} */ (route_map.get(''));
 
-		if (!root.error?.component) {
-			if (!root.error) root.error = { depth: 0 };
-			root.error.component = posixify(path.relative(cwd, `${fallback}/error.svelte`));
-		}
+	if (!root.layout?.component) {
+		if (!root.layout) root.layout = { depth: 0, child_pages: [] };
+		root.layout.component = posixify(path.relative(cwd, `${fallback}/layout.svelte`));
+	}
 
-		// we do layouts/errors first as they are more likely to be reused,
-		// and smaller indexes take fewer bytes. also, this guarantees that
-		// the default error/layout are 0/1
-		route_map.forEach((route) => {
-			if (route.layout) nodes.push(route.layout);
-			if (route.error) nodes.push(route.error);
-		});
+	if (!root.error?.component) {
+		if (!root.error) root.error = { depth: 0 };
+		root.error.component = posixify(path.relative(cwd, `${fallback}/error.svelte`));
+	}
 
-		/** @type {Map<string, string>} */
-		const conflicts = new Map();
+	// we do layouts/errors first as they are more likely to be reused,
+	// and smaller indexes take fewer bytes. also, this guarantees that
+	// the default error/layout are 0/1
+	route_map.forEach((route) => {
+		if (route.layout) nodes.push(route.layout);
+		if (route.error) nodes.push(route.error);
+	});
 
-		route_map.forEach((route) => {
-			if (!route.leaf) return;
+	/** @type {Map<string, string>} */
+	const conflicts = new Map();
 
-			nodes.push(route.leaf);
+	route_map.forEach((route) => {
+		if (!route.leaf) return;
 
-			const normalized = route.id.split('/').filter(affects_path).join('/');
+		nodes.push(route.leaf);
 
-			if (conflicts.has(normalized)) {
-				throw new Error(`${conflicts.get(normalized)} and ${route.id} occupy the same route`);
-			}
+		const normalized = route.id.split('/').filter(affects_path).join('/');
 
-			conflicts.set(normalized, route.id);
-		});
+		if (conflicts.has(normalized)) {
+			throw new Error(`${conflicts.get(normalized)} and ${route.id} occupy the same route`);
+		}
 
-		const indexes = new Map(nodes.map((node, i) => [node, i]));
+		conflicts.set(normalized, route.id);
+	});
 
-		route_map.forEach((route) => {
-			if (!route.leaf) return;
+	const indexes = new Map(nodes.map((node, i) => [node, i]));
 
-			route.page = {
-				layouts: [],
-				errors: [],
-				leaf: /** @type {number} */ (indexes.get(route.leaf))
-			};
+	route_map.forEach((route) => {
+		if (!route.leaf) return;
 
-			/** @type {import('types').RouteData | null} */
-			let current_route = route;
-			let current_node = route.leaf;
-			let parent_id = route.leaf.parent_id;
-
-			while (current_route) {
-				if (parent_id === undefined || current_route.segment === parent_id) {
-					if (current_route.layout || current_route.error) {
-						route.page.layouts.unshift(
-							current_route.layout ? indexes.get(current_route.layout) : undefined
-						);
-						route.page.errors.unshift(
-							current_route.error ? indexes.get(current_route.error) : undefined
-						);
-					}
+		route.page = {
+			layouts: [],
+			errors: [],
+			leaf: /** @type {number} */ (indexes.get(route.leaf))
+		};
 
-					if (current_route.layout) {
-						/** @type {import('types').PageNode[]} */ (current_route.layout.child_pages).push(
-							route.leaf
-						);
-						current_node.parent = current_node = current_route.layout;
-						parent_id = current_node.parent_id;
-					} else {
-						parent_id = undefined;
-					}
+		/** @type {import('types').RouteData | null} */
+		let current_route = route;
+		let current_node = route.leaf;
+		let parent_id = route.leaf.parent_id;
+
+		while (current_route) {
+			if (parent_id === undefined || current_route.segment === parent_id) {
+				if (current_route.layout || current_route.error) {
+					route.page.layouts.unshift(
+						current_route.layout ? indexes.get(current_route.layout) : undefined
+					);
+					route.page.errors.unshift(
+						current_route.error ? indexes.get(current_route.error) : undefined
+					);
 				}
 
-				current_route = current_route.parent;
+				if (current_route.layout) {
+					/** @type {import('types').PageNode[]} */ (current_route.layout.child_pages).push(
+						route.leaf
+					);
+					current_node.parent = current_node = current_route.layout;
+					parent_id = current_node.parent_id;
+				} else {
+					parent_id = undefined;
+				}
 			}
 
-			if (parent_id !== undefined) {
-				throw new Error(`${current_node.component} references missing segment "${parent_id}"`);
-			}
-		});
-	}
+			current_route = current_route.parent;
+		}
+
+		if (parent_id !== undefined) {
+			throw new Error(`${current_node.component} references missing segment "${parent_id}"`);
+		}
+	});
 
 	const routes = Array.from(route_map.values()).sort((a, b) => compare(a, b, segment_map));
 
diff --git a/packages/kit/src/core/sync/create_manifest_data/index.spec.js b/packages/kit/src/core/sync/create_manifest_data/index.spec.js
index f1a863077..031071a4d 100644
--- a/packages/kit/src/core/sync/create_manifest_data/index.spec.js
+++ b/packages/kit/src/core/sync/create_manifest_data/index.spec.js
@@ -173,8 +173,16 @@ test('creates routes with layout', () => {
 
 test('succeeds when routes does not exist', () => {
 	const { nodes, routes } = create('samples/basic/routes');
-	assert.equal(nodes, []);
-	assert.equal(routes, []);
+	assert.equal(nodes.map(simplify_node), [
+		{ component: 'layout.svelte' },
+		{ component: 'error.svelte' }
+	]);
+	assert.equal(routes.map(simplify_route), [
+		{
+			id: '',
+			pattern: '/^$/'
+		}
+	]);
 });
 
 // TODO some characters will need to be URL-encoded in the filename
diff --git a/packages/kit/src/runtime/server/page/fetch.js b/packages/kit/src/runtime/server/page/fetch.js
index ec728254a..fdd0a4658 100644
--- a/packages/kit/src/runtime/server/page/fetch.js
+++ b/packages/kit/src/runtime/server/page/fetch.js
@@ -172,21 +172,21 @@ export function create_fetch({ event, options, state, route, prerender_default,
 					state.prerendering.dependencies.set(url.pathname, dependency);
 				}
 
+				const set_cookie = response.headers.get('set-cookie');
+				if (set_cookie) {
+					set_cookies.push(
+						...set_cookie_parser.splitCookiesString(set_cookie).map((str) => {
+							const { name, value, ...options } = set_cookie_parser.parseString(str);
+							// options.sameSite is string, something more specific is required - type cast is safe
+							return /** @type{import('./types').Cookie} */ ({ name, value, options });
+						})
+					);
+				}
+
 				return response;
 			}
 		});
 
-		const set_cookie = response.headers.get('set-cookie');
-		if (set_cookie) {
-			set_cookies.push(
-				...set_cookie_parser.splitCookiesString(set_cookie).map((str) => {
-					const { name, value, ...options } = set_cookie_parser.parseString(str);
-					// options.sameSite is string, something more specific is required - type cast is safe
-					return /** @type{import('./types').Cookie} */ ({ name, value, options });
-				})
-			);
-		}
-
 		const proxy = new Proxy(response, {
 			get(response, key, _receiver) {
 				async function text() {
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index b2b00d240..247f3b710 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -13,7 +13,8 @@ import {
 	PrerenderOnErrorValue,
 	RequestOptions,
 	RouteDefinition,
-	TrailingSlash
+	TrailingSlash,
+	UniqueInterface
 } from './private.js';
 import { SSRNodeLoader, SSRRoute, ValidatedConfig } from './internal.js';
 import { HttpError, Redirect } from '../src/runtime/control.js';
@@ -52,7 +53,8 @@ type OptionalUnion<
 > = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
 
 // Needs to be here, else ActionData will be resolved to unknown - probably because of "d.ts file imports .js file" in combination with allowJs
-interface ValidationError<T extends Record<string, unknown> | undefined = undefined> {
+export interface ValidationError<T extends Record<string, unknown> | undefined = undefined>
+	extends UniqueInterface {
 	status: number;
 	data: T;
 }
diff --git a/packages/kit/types/private.d.ts b/packages/kit/types/private.d.ts
index 18a2c1091..f5101b88b 100644
--- a/packages/kit/types/private.d.ts
+++ b/packages/kit/types/private.d.ts
@@ -207,3 +207,12 @@ export interface RouteSegment {
 }
 
 export type TrailingSlash = 'never' | 'always' | 'ignore';
+
+/**
+ * This doesn't actually exist, it's a way to better distinguish the type
+ */
+declare const uniqueSymbol: unique symbol;
+
+export interface UniqueInterface {
+	readonly [uniqueSymbol]: unknown;
+}


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-external-no-cookies/+page.js b/packages/kit/test/apps/basics/src/routes/load/fetch-external-no-cookies/+page.js
new file mode 100644
index 000000000..a968114e2
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/fetch-external-no-cookies/+page.js
@@ -0,0 +1,7 @@
+/** @type {import('./$types').PageLoad} */
+export async function load({ fetch, url }) {
+	const port = url.searchParams.get('port');
+	const res = await fetch(`http://localhost:${port}`);
+
+	return await res.json();
+}
diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-external-no-cookies/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/fetch-external-no-cookies/+page.svelte
new file mode 100644
index 000000000..6e7c1e6ac
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/fetch-external-no-cookies/+page.svelte
@@ -0,0 +1,1 @@
+<h1>hello</h1>
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index a1a31b5e3..9f8a5ed62 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -904,18 +904,9 @@ test('+server.js next to +page.svelte works', async ({ page }) => {
 	await page.goto('/routing/endpoint-next-to-page');
 	expect(await page.textContent('p')).toBe('Hi');
 
-	await page.click('button:has-text("GET")');
-	await expect(page.locator('pre')).toHaveText('GET');
-
-	await page.click('button:has-text("PUT")');
-	await expect(page.locator('pre')).toHaveText('PUT');
-
-	await page.click('button:has-text("PATCH")');
-	await expect(page.locator('pre')).toHaveText('PATCH');
-
-	await page.click('button:has-text("POST")');
-	await expect(page.locator('pre')).toHaveText('POST');
-
-	await page.click('button:has-text("DELETE")');
-	await expect(page.locator('pre')).toHaveText('DELETE');
+	for (const method of ['GET', 'PUT', 'PATCH', 'POST', 'DELETE']) {
+		page.click(`button:has-text("${method}")`);
+		await page.waitForResponse('/routing/endpoint-next-to-page');
+		expect(await page.textContent('pre')).toBe(method);
+	}
 });
diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js
index 87bb6ea0b..59f1c00ec 100644
--- a/packages/kit/test/apps/basics/test/server.test.js
+++ b/packages/kit/test/apps/basics/test/server.test.js
@@ -1,5 +1,5 @@
 import { expect } from '@playwright/test';
-import { test } from '../../../utils.js';
+import { start_server, test } from '../../../utils.js';
 import { fetch } from 'undici';
 import { createHash, randomBytes } from 'node:crypto';
 
@@ -23,6 +23,29 @@ test.describe('Content-Type', () => {
 	});
 });
 
+test.describe('Cookies', () => {
+	test('does not forward cookies from external domains', async ({ request }) => {
+		const { close, port } = await start_server(async (req, res) => {
+			if (req.url === '/') {
+				res.writeHead(200, {
+					'set-cookie': 'external=true',
+					'access-control-allow-origin': '*'
+				});
+
+				res.end('ok');
+			} else {
+				res.writeHead(404);
+				res.end('not found');
+			}
+		});
+
+		const response = await request.get(`/load/fetch-external-no-cookies?port=${port}`);
+		expect(response.headers()['set-cookie']).not.toContain('external=true');
+
+		close();
+	});
+});
+
 test.describe('CSRF', () => {
 	test('Blocks requests with incorrect origin', async ({ baseURL }) => {
 		const res = await fetch(`${baseURL}/csrf`, {