diff --git a/.changeset/few-lions-drive.md b/.changeset/few-lions-drive.md new file mode 100644 index 000000000000..89f10014bc2c --- /dev/null +++ b/.changeset/few-lions-drive.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add `paths.relative` option to control interpretation of `paths.assets` and `paths.base` diff --git a/packages/adapter-static/test/test.js b/packages/adapter-static/test/test.js index 0fe826aceefc..c4144c1bd532 100644 --- a/packages/adapter-static/test/test.js +++ b/packages/adapter-static/test/test.js @@ -1,4 +1,4 @@ -import fs from 'fs'; +import fs from 'node:fs'; import * as assert from 'uvu/assert'; import { run } from './utils.js'; diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index b58ee40beb81..614edd3bf5b3 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -97,7 +97,8 @@ const get_defaults = (prefix = '') => ({ typescript: {}, paths: { base: '', - assets: '' + assets: '', + relative: undefined }, prerender: { concurrency: 1, @@ -235,6 +236,7 @@ test('fails if paths.base is not root-relative', () => { validate_config({ kit: { paths: { + // @ts-expect-error base: 'https://example.com/somewhere/else' } } @@ -259,6 +261,7 @@ test('fails if paths.assets is relative', () => { validate_config({ kit: { paths: { + // @ts-expect-error assets: 'foo' } } @@ -293,8 +296,8 @@ test('fails if prerender.entries are invalid', () => { /** * @param {string} name - * @param {{ base?: string, assets?: string }} input - * @param {{ base?: string, assets?: string }} output + * @param {import('types').KitConfig['paths']} input + * @param {import('types').KitConfig['paths']} output */ function validate_paths(name, input, output) { test(name, () => { @@ -316,7 +319,8 @@ validate_paths( }, { base: '/path/to/base', - assets: '' + assets: '', + relative: undefined } ); @@ -327,7 +331,8 @@ validate_paths( }, { base: '', - assets: 'https://cdn.example.com' + assets: 'https://cdn.example.com', + relative: undefined } ); @@ -339,7 +344,8 @@ validate_paths( }, { base: '/path/to/base', - assets: 'https://cdn.example.com' + assets: 'https://cdn.example.com', + relative: undefined } ); diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index ecf69c81815d..7501967f8ee5 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -167,6 +167,13 @@ const options = object( } } + return input; + }), + relative: validate(undefined, (input, keypath) => { + if (typeof input !== 'boolean') { + throw new Error(`${keypath} option must be a boolean or undefined`); + } + return input; }) }), diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index 0ccd98c42bd5..a7140dce749e 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -27,7 +27,8 @@ const server_template = ({ }) => ` import root from '../root.svelte'; import { set_building } from '__sveltekit/environment'; -import { set_assets, set_private_env, set_public_env } from '${runtime_directory}/shared-server.js'; +import { set_assets } from '__sveltekit/paths'; +import { set_private_env, set_public_env } from '${runtime_directory}/shared-server.js'; export const options = { app_template_contains_nonce: ${template.includes('%sveltekit.nonce%')}, diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 05adc2776fb1..27b3a1b5dace 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -451,15 +451,14 @@ export async function dev(vite, vite_config, svelte_config) { await vite.ssrLoadModule(`${runtime_base}/server/index.js`) ); - const { set_assets, set_fix_stack_trace } = - /** @type {import('types').ServerInternalModule} */ ( - await vite.ssrLoadModule(`${runtime_base}/shared-server.js`) - ); + const { set_fix_stack_trace } = await vite.ssrLoadModule( + `${runtime_base}/shared-server.js` + ); + set_fix_stack_trace(fix_stack_trace); + const { set_assets } = await vite.ssrLoadModule('__sveltekit/paths'); set_assets(assets); - set_fix_stack_trace(fix_stack_trace); - const server = new Server(manifest); await server.init({ env }); diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 5517deb6ea44..4e5fd03d1c43 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -374,17 +374,34 @@ function kit({ svelte_config }) { case '\0__sveltekit/paths': const { assets, base } = svelte_config.kit.paths; + // use the values defined in `global`, but fall back to hard-coded values + // for the sake of things like Vitest which may import this module + // outside the context of a page if (browser) { - return `export const base = ${s(base)}; -export const assets = ${global}.assets;`; + return `export const base = ${global}?.base ?? ${s(base)}; +export const assets = ${global}?.assets ?? ${assets ? s(assets) : 'base'};`; } - return `export const base = ${s(base)}; + return `export let base = ${s(base)}; export let assets = ${assets ? s(assets) : 'base'}; +export const relative = ${svelte_config.kit.paths.relative}; + +const initial = { base, assets }; + +export function override(paths) { + base = paths.base; + assets = paths.assets; +} + +export function reset() { + base = initial.base; + assets = initial.assets; +} + /** @param {string} path */ export function set_assets(path) { - assets = path; + assets = initial.assets = path; }`; case '\0__sveltekit/environment': diff --git a/packages/kit/src/internal.d.ts b/packages/kit/src/internal.d.ts index cfe9bf548544..c248d9d528f6 100644 --- a/packages/kit/src/internal.d.ts +++ b/packages/kit/src/internal.d.ts @@ -7,7 +7,10 @@ declare module '__sveltekit/environment' { /** Internal version of $app/paths */ declare module '__sveltekit/paths' { - export const base: `/${string}`; - export let assets: `https://${string}` | `http://${string}`; + export let base: '' | `/${string}`; + export let assets: '' | `https://${string}` | `http://${string}` | '/_svelte_kit_assets'; + export let relative: boolean | undefined; // TODO in 2.0, make this a `boolean` that defaults to `true` + export function reset(): void; + export function override(paths: { base: string; assets: string }): void; export function set_assets(path: string): void; } diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 92da25ad1916..2280cdfd2b4d 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -1,7 +1,7 @@ import * as devalue from 'devalue'; import { readable, writable } from 'svelte/store'; import { DEV } from 'esm-env'; -import { assets, base } from '__sveltekit/paths'; +import * as paths from '__sveltekit/paths'; import { hash } from '../../hash.js'; import { serialize_data } from './serialize_data.js'; import { s } from '../../../utils/misc.js'; @@ -11,6 +11,7 @@ import { clarify_devalue_error, stringify_uses, handle_error_and_jsonify } from import { public_env } from '../../shared-server.js'; import { text } from '../../../exports/index.js'; import { create_async_iterator } from '../../../utils/streaming.js'; +import { SVELTE_KIT_ASSETS } from '../../../constants.js'; // TODO rename this function/module @@ -80,6 +81,43 @@ export async function render_response({ ? action_result.data ?? null : null; + /** @type {string} */ + let base = paths.base; + + /** @type {string} */ + let assets = paths.assets; + + /** + * An expression that will evaluate in the client to determine the resolved base path. + * We use a relative path when possible to support IPFS, the internet archive, etc. + */ + let base_expression = s(paths.base); + + // if appropriate, use relative paths for greater portability + if (paths.relative !== false && !state.prerendering?.fallback) { + const segments = event.url.pathname.slice(paths.base.length).split('/'); + + if (segments.length === 1 && paths.base !== '') { + // if we're on `/my-base-path`, relative links need to start `./my-base-path` rather than `.` + base = `./${paths.base.split('/').at(-1)}`; + + base_expression = `new URL(${s(base)}, location).pathname`; + } else { + base = + segments + .slice(2) + .map(() => '..') + .join('/') || '.'; + + // resolve e.g. '../..' against current location, then remove trailing slash + base_expression = `new URL(${s(base)}, location).pathname.slice(0, -1)`; + } + + if (!paths.assets || (paths.assets[0] === '/' && paths.assets !== SVELTE_KIT_ASSETS)) { + assets = base; + } + } + if (page_config.ssr) { if (__SVELTEKIT_DEV__ && !branch.at(-1)?.node.component) { // Can only be the leaf, layouts have a fallback component generated @@ -116,6 +154,10 @@ export async function render_response({ form: form_value }; + // use relative paths during rendering, so that the resulting HTML is as + // portable as possible, but reset afterwards + if (paths.relative) paths.override({ base, assets }); + if (__SVELTEKIT_DEV__) { const fetch = globalThis.fetch; let warned = false; @@ -138,9 +180,14 @@ export async function render_response({ rendered = options.root.render(props); } finally { globalThis.fetch = fetch; + paths.reset(); } } else { - rendered = options.root.render(props); + try { + rendered = options.root.render(props); + } finally { + paths.reset(); + } } for (const { node } of branch) { @@ -156,35 +203,6 @@ export async function render_response({ rendered = { head: '', html: '', css: { code: '', map: null } }; } - /** - * The prefix to use for static assets. Replaces `%sveltekit.assets%` in the template - * @type {string} - */ - let resolved_assets; - - /** - * An expression that will evaluate in the client to determine the resolved asset path - */ - let asset_expression; - - if (assets) { - // if an asset path is specified, use it - resolved_assets = assets; - asset_expression = s(assets); - } else if (state.prerendering?.fallback) { - // if we're creating a fallback page, asset paths need to be root-relative - resolved_assets = base; - asset_expression = s(base); - } else { - // otherwise we want asset paths to be relative to the page, so that they - // will work in odd contexts like IPFS, the internet archive, and so on - const segments = event.url.pathname.slice(base.length).split('/').slice(2); - resolved_assets = segments.length > 0 ? segments.map(() => '..').join('/') : '.'; - asset_expression = `new URL(${s( - resolved_assets - )}, location.href).pathname.replace(/^\\\/$/, '')`; - } - let head = ''; let body = rendered.html; @@ -198,9 +216,9 @@ export async function render_response({ // Vite makes the start script available through the base path and without it. // We load it via the base path in order to support remote IDE environments which proxy // all URLs under the base path during development. - return base + path; + return paths.base + path; } - return `${resolved_assets}/${path}`; + return `${assets}/${path}`; }; if (inline_styles.size > 0) { @@ -285,9 +303,10 @@ export async function render_response({ const properties = [ `env: ${s(public_env)}`, - `assets: ${asset_expression}`, + paths.assets && `assets: ${s(paths.assets)}`, + `base: ${base_expression}`, `element: document.currentScript.parentElement` - ]; + ].filter(Boolean); if (chunks) { blocks.push(`const deferred = new Map();`); @@ -418,7 +437,7 @@ export async function render_response({ const html = options.templates.app({ head, body, - assets: resolved_assets, + assets, nonce: /** @type {string} */ (csp.nonce), env: public_env }); diff --git a/packages/kit/src/runtime/shared-server.js b/packages/kit/src/runtime/shared-server.js index faaaca4cec79..ed1c5efcb472 100644 --- a/packages/kit/src/runtime/shared-server.js +++ b/packages/kit/src/runtime/shared-server.js @@ -1,5 +1,3 @@ -export { set_assets } from '__sveltekit/paths'; - /** @type {Record} */ export let private_env = {}; diff --git a/packages/kit/test/apps/basics/src/routes/paths/deeply/nested/+page.svelte b/packages/kit/test/apps/basics/src/routes/paths/deeply/nested/+page.svelte new file mode 100644 index 000000000000..73d1cb7eb4ef --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/paths/deeply/nested/+page.svelte @@ -0,0 +1,5 @@ + + +
{JSON.stringify({ base, assets })}
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 05bc19a9f641..6548d634c4d7 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -618,6 +618,15 @@ test.describe('$app/paths', () => { assets: '' }) ); + + await page.goto('/paths/deeply/nested'); + + expect(await page.innerHTML('pre')).toBe( + JSON.stringify({ + base: '', + assets: '' + }) + ); }); // some browsers will re-request assets after a `pushState` diff --git a/packages/kit/test/apps/options-2/src/routes/+page.svelte b/packages/kit/test/apps/options-2/src/routes/+page.svelte index 986a4a1a25bc..d6232afc0cc9 100644 --- a/packages/kit/test/apps/options-2/src/routes/+page.svelte +++ b/packages/kit/test/apps/options-2/src/routes/+page.svelte @@ -1 +1,8 @@ + +

Hello

+ +

base: {base}

+

assets: {assets}

diff --git a/packages/kit/test/apps/options-2/src/routes/deeply/nested/page/+page.svelte b/packages/kit/test/apps/options-2/src/routes/deeply/nested/page/+page.svelte new file mode 100644 index 000000000000..d6232afc0cc9 --- /dev/null +++ b/packages/kit/test/apps/options-2/src/routes/deeply/nested/page/+page.svelte @@ -0,0 +1,8 @@ + + +

Hello

+ +

base: {base}

+

assets: {assets}

diff --git a/packages/kit/test/apps/options-2/svelte.config.js b/packages/kit/test/apps/options-2/svelte.config.js index ade762d9b8a1..ca9cb32020f8 100644 --- a/packages/kit/test/apps/options-2/svelte.config.js +++ b/packages/kit/test/apps/options-2/svelte.config.js @@ -2,7 +2,8 @@ const config = { kit: { paths: { - base: '/basepath' + base: '/basepath', + relative: true }, serviceWorker: { register: false diff --git a/packages/kit/test/apps/options-2/test/test.js b/packages/kit/test/apps/options-2/test/test.js index f9b7cff16d3a..61f0b510720b 100644 --- a/packages/kit/test/apps/options-2/test/test.js +++ b/packages/kit/test/apps/options-2/test/test.js @@ -13,7 +13,7 @@ test.describe('env', () => { }); }); -test.describe('paths.base', () => { +test.describe('paths', () => { test('serves /basepath', async ({ page }) => { await page.goto('/basepath'); expect(await page.textContent('h1')).toBe('Hello'); @@ -23,6 +23,20 @@ test.describe('paths.base', () => { const response = await request.get('/basepath/answer.txt'); expect(await response.text()).toBe('42'); }); + + test('uses relative paths during SSR', async ({ page, javaScriptEnabled }) => { + await page.goto('/basepath'); + + let base = javaScriptEnabled ? '/basepath' : './basepath'; + expect(await page.textContent('[data-testid="base"]')).toBe(`base: ${base}`); + expect(await page.textContent('[data-testid="assets"]')).toBe(`assets: ${base}`); + + await page.goto('/basepath/deeply/nested/page'); + + base = javaScriptEnabled ? '/basepath' : '../..'; + expect(await page.textContent('[data-testid="base"]')).toBe(`base: ${base}`); + expect(await page.textContent('[data-testid="assets"]')).toBe(`assets: ${base}`); + }); }); test.describe('Service worker', () => { diff --git a/packages/kit/test/apps/options/source/pages/slash/+page.svelte b/packages/kit/test/apps/options/source/pages/slash/+page.svelte index 93a915f3661f..7c66a2ad551b 100644 --- a/packages/kit/test/apps/options/source/pages/slash/+page.svelte +++ b/packages/kit/test/apps/options/source/pages/slash/+page.svelte @@ -3,6 +3,6 @@ import { page } from '$app/stores'; -

{$page.url.pathname.replace(base, '')}

+

{$page.url.pathname}

-/slash/child +/slash/child diff --git a/packages/kit/test/apps/options/source/pages/slash/child/+page.svelte b/packages/kit/test/apps/options/source/pages/slash/child/+page.svelte index ab4505e94b0e..fe890859233d 100644 --- a/packages/kit/test/apps/options/source/pages/slash/child/+page.svelte +++ b/packages/kit/test/apps/options/source/pages/slash/child/+page.svelte @@ -1,6 +1,5 @@ -

{$page.url.pathname.replace(base, '')}

+

{$page.url.pathname}

diff --git a/packages/kit/test/apps/options/test/test.js b/packages/kit/test/apps/options/test/test.js index 3746deaea278..d50125ae8c7b 100644 --- a/packages/kit/test/apps/options/test/test.js +++ b/packages/kit/test/apps/options/test/test.js @@ -176,11 +176,11 @@ test.describe('trailingSlash', () => { await page.goto('/path-base/slash'); expect(page.url()).toBe(`${baseURL}/path-base/slash/`); - expect(await page.textContent('h2')).toBe('/slash/'); + expect(await page.textContent('h2')).toBe('/path-base/slash/'); - await clicknav('[href="/path-base/slash/child"]'); + await clicknav('[data-testid="child"]'); expect(page.url()).toBe(`${baseURL}/path-base/slash/child/`); - expect(await page.textContent('h2')).toBe('/slash/child/'); + expect(await page.textContent('h2')).toBe('/path-base/slash/child/'); }); test('removes trailing slash on endpoint', async ({ baseURL, request }) => { diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 73c5683ca8fe..e9ee9b25a939 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -427,12 +427,20 @@ export interface KitConfig { * An absolute path that your app's files are served from. This is useful if your files are served from a storage bucket of some kind. * @default "" */ - assets?: string; + assets?: '' | `http://${string}` | `https://${string}`; /** * A root-relative path that must start, but not end with `/` (e.g. `/base-path`), unless it is the empty string. This specifies where your app is served from and allows the app to live on a non-root path. Note that you need to prepend all your root-relative links with the base value or they will point to the root of your domain, not your `base` (this is how the browser works). You can use [`base` from `$app/paths`](/docs/modules#$app-paths-base) for that: `Link`. If you find yourself writing this often, it may make sense to extract this into a reusable component. * @default "" */ - base?: string; + base?: '' | `/${string}`; + /** + * Whether to use relative asset paths. By default, if `paths.assets` is not external, SvelteKit will replace `%sveltekit.assets%` with a relative path and use relative paths to reference build artifacts, but `base` and `assets` imported from `$app/paths` will be as specified in your config. + * + * If `true`, `base` and `assets` imported from `$app/paths` will be replaced with relative asset paths during server-side rendering, resulting in portable HTML. + * If `false`, `%sveltekit.assets%` and references to build artifacts will always be root-relative paths, unless `paths.assets` is an external URL + * @default undefined + */ + relative?: boolean | undefined; }; /** * See [Prerendering](https://kit.svelte.dev/docs/page-options#prerender).