diff --git a/.changeset/curvy-cats-talk.md b/.changeset/curvy-cats-talk.md new file mode 100644 index 000000000000..7614d6c3cd23 --- /dev/null +++ b/.changeset/curvy-cats-talk.md @@ -0,0 +1,5 @@ +--- +"@sveltejs/kit": minor +--- + +feat: add `resolveDestination` and `rewriteURL` hooks, enabling i18n routing diff --git a/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md index d979ec88b1b1..0708efc679e8 100644 --- a/documentation/docs/30-advanced/20-hooks.md +++ b/documentation/docs/30-advanced/20-hooks.md @@ -4,12 +4,13 @@ title: Hooks 'Hooks' are app-wide functions you declare that SvelteKit will call in response to specific events, giving you fine-grained control over the framework's behaviour. -There are two hooks files, both optional: +There are three hooks files, all optional: - `src/hooks.server.js` — your app's server hooks - `src/hooks.client.js` — your app's client hooks +- `src/hooks.router.js` — your app's router hooks -Code in these modules will run when the application starts up, making them useful for initializing database clients and so on. +Code in the client & server modules will run when the application starts up, making them useful for initializing database clients and so on. > You can configure the location of these files with [`config.kit.files.hooks`](configuration#files). @@ -232,6 +233,60 @@ During development, if an error occurs because of a syntax error in your Svelte > Make sure that `handleError` _never_ throws an error +## Router hooks + +The following can be added to `src/hooks.router.js`. Router hooks run both on the server and the client. + +### rewriteURL + +This function allows you to rewrite URLs before they are processed by SvelteKit. It receives a `url` object and should return a `URL` object. + +```js +/// file: src/hooks.router.js +// @errors: 2345 +// @errors: 2304 +/** @type {import('@sveltejs/kit').RewriteURL} */ +export function rewriteURL({ url }) { + //Process requests to '//about' as if they were to '/about' + const language = getLanguageFromPath(url.pathname); + if(language) url.pathname = url.pathname.slice(language.length + 1); + + return url; +} +``` + +Rewrites happen in place, and are completely invisible to the user. For example, if you rewrite `/about` to `/about-us`, the user will still see `/about` in their browser's address bar. Only the server will know that the URL has been rewritten. + +### resolveDestination + +This function allows you to change the destination of an outgoing navigation event, such as: +- A link click +- A `goto` call +- A `redirect` call + +It receives the current url, and the destination url, and should return a `URL` object. + +```js +/// file: src/hooks.router.js +// @errors: 2345 +// @errors: 2304 +/** @type {import('@sveltejs/kit').ResolveDestination} */ +export function resolveDestination({ from, to }) { + if(from.origin !== to.origin) return to; //Ignore cross-origin navigations + + //If the destination-path already includes a language, leave it + const destinationLanguage = getLanguageFromPath(to.pathname); + if(destinationLanguage) return to; + + //Otherwise, add the language from the current page + const language = getLanguageFromPath(from.pathname) ?? defaultLanguage; + to.pathname = `/${language}${to.pathname}`; + return to; +} +``` + +The `resolveDestination` hook is applied to all links and forms during prerendering and SSR, so it can safely be used even when JavaScript is disabled. + ## Further reading - [Tutorial: Hooks](https://learn.svelte.dev/tutorial/handle) diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index cb2a44670bfd..a520627e20e8 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -93,6 +93,7 @@ function process_config(config, { cwd = process.cwd() } = {}) { if (key === 'hooks') { validated.kit.files.hooks.client = path.resolve(cwd, validated.kit.files.hooks.client); validated.kit.files.hooks.server = path.resolve(cwd, validated.kit.files.hooks.server); + validated.kit.files.hooks.router = path.resolve(cwd, validated.kit.files.hooks.router); } else { // @ts-expect-error validated.kit.files[key] = path.resolve(cwd, validated.kit.files[key]); diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 673515c60802..8c796c3d9ee8 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -79,7 +79,8 @@ const get_defaults = (prefix = '') => ({ assets: join(prefix, 'static'), hooks: { client: join(prefix, 'src/hooks.client'), - server: join(prefix, 'src/hooks.server') + server: join(prefix, 'src/hooks.server'), + router: join(prefix, 'src/hooks.router') }, lib: join(prefix, 'src/lib'), params: join(prefix, 'src/params'), diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index dbbb19d97cce..4f8b4fbb538f 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -123,7 +123,8 @@ const options = object( assets: string('static'), hooks: object({ client: string(join('src', 'hooks.client')), - server: string(join('src', 'hooks.server')) + server: string(join('src', 'hooks.server')), + router: string(join('src', 'hooks.router')) }), lib: string(join('src', 'lib')), params: string(join('src', 'params')), diff --git a/packages/kit/src/core/sync/write_client_manifest.js b/packages/kit/src/core/sync/write_client_manifest.js index b8b920ffb5f1..deaa122f8a34 100644 --- a/packages/kit/src/core/sync/write_client_manifest.js +++ b/packages/kit/src/core/sync/write_client_manifest.js @@ -108,7 +108,8 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { } `; - const hooks_file = resolve_entry(kit.files.hooks.client); + const client_hooks_file = resolve_entry(kit.files.hooks.client); + const router_hooks_file = resolve_entry(kit.files.hooks.router); const typo = resolve_entry('src/+hooks.client'); if (typo) { @@ -125,7 +126,16 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { write_if_changed( `${output}/app.js`, dedent` - ${hooks_file ? `import * as client_hooks from '${relative_path(output, hooks_file)}';` : ''} + ${ + client_hooks_file + ? `import * as client_hooks from '${relative_path(output, client_hooks_file)}';` + : '' + } + ${ + router_hooks_file + ? `import * as router_hooks from '${relative_path(output, router_hooks_file)}';` + : '' + } export { matchers } from './matchers.js'; @@ -139,8 +149,14 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { export const hooks = { handleError: ${ - hooks_file ? 'client_hooks.handleError || ' : '' + client_hooks_file ? 'client_hooks.handleError || ' : '' }(({ error }) => { console.error(error) }), + + resolveDestination: ${ + router_hooks_file ? 'router_hooks.resolveDestination || ' : '' + }((event) => event.to), + + rewriteURL: ${router_hooks_file ? 'router_hooks.rewriteURL || ' : ''}(({url}) => url) }; export { default as root } from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}'; diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index c73168962ccc..8898bc5706f4 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -10,6 +10,7 @@ import colors from 'kleur'; /** * @param {{ * hooks: string | null; + * router_hooks: string | null; * config: import('types').ValidatedConfig; * has_service_worker: boolean; * runtime_directory: string; @@ -20,6 +21,7 @@ import colors from 'kleur'; const server_template = ({ config, hooks, + router_hooks, has_service_worker, runtime_directory, template, @@ -59,8 +61,11 @@ export const options = { version_hash: ${s(hash(config.kit.version.name))} }; -export function get_hooks() { - return ${hooks ? `import(${s(hooks)})` : '{}'}; +export async function get_hooks() { + return { + ${hooks ? `...(await import(${s(hooks)})),` : ''} + ${router_hooks ? `...(await import(${s(router_hooks)})),` : ''} + }; } export { set_assets, set_building, set_prerendering, set_private_env, set_public_env, set_safe_public_env }; @@ -77,6 +82,7 @@ export { set_assets, set_building, set_prerendering, set_private_env, set_public */ export function write_server(config, output) { const hooks_file = resolve_entry(config.kit.files.hooks.server); + const router_hooks_file = resolve_entry(config.kit.files.hooks.router); const typo = resolve_entry('src/+hooks.server'); if (typo) { @@ -100,6 +106,7 @@ export function write_server(config, output) { server_template({ config, hooks: hooks_file ? relative(hooks_file) : null, + router_hooks: router_hooks_file ? relative(router_hooks_file) : null, has_service_worker: config.kit.serviceWorker.register && !!resolve_entry(config.kit.files.serviceWorker), runtime_directory: relative(runtime_directory), diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 7caa0c4bf038..6c73d03bbfa3 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -400,6 +400,12 @@ export interface KitConfig { * @default "src/hooks.server" */ server?: string; + + /** + * The location of your router hooks. + * @default "src/hooks.router" + */ + router?: string; }; /** * your app's internal library, accessible throughout the codebase as `$lib` @@ -674,6 +680,31 @@ export type HandleClientError = (input: { message: string; }) => MaybePromise; +/** + * Maps an href value to a destination + * @example + * ```js + * export const resolveDestination = ({ from, to }) => { + * if(to.host !== from.host) return to; //Don't remap external links + * const lang = getLanguageFromURL(from); + * return applyLanguage(to, lang); + * } + * ``` + */ +export type ResolveDestination = (event: { from: URL; to: URL }) => URL; + +/** + * Remap an incoming URL to a different URL. + * + * @example + * ```js + * export const rewriteURL = (url) => { + * return urlWithoutLanguage(url); + * } + * ``` + */ +export type RewriteURL = (event: { url: URL }) => URL; + /** * The [`handleFetch`](https://kit.svelte.dev/docs/hooks#server-hooks-handlefetch) hook allows you to modify (or replace) a `fetch` request that happens inside a `load` function that runs on the server (or during pre-rendering) */ diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 5d685e8ee0d2..9dd67801b121 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -25,6 +25,8 @@ import analyse from '../../core/postbuild/analyse.js'; import { s } from '../../utils/misc.js'; import { hash } from '../../runtime/hash.js'; import { dedent, isSvelte5Plus } from '../../core/sync/utils.js'; +import { resolve_destination_preprocessor } from './preprocessor/resolveDestination.js'; + import { env_dynamic_private, env_dynamic_public, @@ -857,7 +859,19 @@ function kit({ svelte_config }) { } }; - return [plugin_setup, plugin_virtual_modules, plugin_guard, plugin_compile]; + const router_hook_entry = resolve_entry(kit.files.hooks.router); + /** @type {import('vite').Plugin} */ + const resolve_destination = { + name: 'vite-plugin-sveltekit-resolve-destination', + + api: { + sveltePreprocess: router_hook_entry + ? resolve_destination_preprocessor({ router_hook_entry }) + : undefined + } + }; + + return [plugin_setup, plugin_virtual_modules, plugin_guard, plugin_compile, resolve_destination]; } /** diff --git a/packages/kit/src/exports/vite/preprocessor/resolveDestination.js b/packages/kit/src/exports/vite/preprocessor/resolveDestination.js new file mode 100644 index 000000000000..7f96fdd062ba --- /dev/null +++ b/packages/kit/src/exports/vite/preprocessor/resolveDestination.js @@ -0,0 +1,199 @@ +import { parse } from 'svelte/compiler'; +import MagicString from 'magic-string'; +import { dedent } from '../../../core/sync/utils.js'; +import { fileURLToPath } from 'node:url'; +import { metaUrl } from '../../../utils/diff-urls.js'; + +const diffUrlUtilFile = fileURLToPath(metaUrl); + +const rewritten_attributes = [ + { element: 'a', attribute: 'href' }, + { element: 'form', attribute: 'action' }, + { element: 'button', attribute: 'formaction' }, + { element: 'img', attribute: 'src' }, + { element: 'link', attribute: 'href' } +]; + +/** + * Rewrites every single href attribute in the markup, so that it's wrapped + * with the resolveDestination router-hook. + * + * @example + * ```diff + * - About + * + About + * ``` + * + * @param {{ router_hook_entry : string }} config + * @returns {import("svelte/compiler").PreprocessorGroup} + */ +export const resolve_destination_preprocessor = ({ router_hook_entry }) => ({ + markup({ content }) { + //Do some quick checks to see if we need to do anything + // keep trach of the tag_name - attribute_name pairs that may be present + const matchedAttributeIndexes = []; + for (let i = 0; i < rewritten_attributes.length; i++) { + const { attribute: attribute_name } = rewritten_attributes[i]; + if (content.includes(attribute_name)) matchedAttributeIndexes.push(i); + } + + //If none of the attributes are present, skip parsing & processing + if (matchedAttributeIndexes.length === 0) return; + + const ast = parse(content); + const s = new MagicString(content); + + let rewroteAttribute = false; + + //For all the matched attributes, find all the elements and rewrite the attributes + for (const index of matchedAttributeIndexes) { + const { element: element_name, attribute: attribute_name } = rewritten_attributes[index]; + const elements = getElements(ast, element_name); + if (!elements.length) continue; + + for (const element of elements) { + /** @type {import("svelte/types/compiler/interfaces").Attribute[]} */ + const attributes = element.attributes || []; + + const attribute = attributes.find((attribute) => attribute.name === attribute_name); + if (!attribute) continue; + + const attributeTemplateString = attributeToTemplateString(attribute, content); + + const newAttribute = `${attribute_name}={${i('resolveHref')}(${attributeTemplateString})}`; + s.overwrite(attribute.start, attribute.end, newAttribute); + rewroteAttribute = true; + } + } + + //If none of the attributes were rewritten, skip adding the code + if (!rewroteAttribute) return; + + addCodeToScript( + ast, + s, + dedent` + import { page as ${i('page')} } from "$app/stores"; + import * as ${i('router_hooks')} from "${router_hook_entry}"; + import { getHrefBetween as ${i('getHrefBetween')} } from "${diffUrlUtilFile}"; + + /** + * @param {string} href + * @returns {string} + */ + function ${i('resolveHref')}(href) { + const resolve_destination = ${i('router_hooks')}.resolveDestination; + + //If there is no hook, bail + if (!resolve_destination) return href; + + //Resolve the origin & destination of the navigation + const from = $${i('page')}.url; + const to = new URL(href, from); + + const resolved = resolve_destination({ from: new URL(from), to: new URL(to) }); + return resolved.href === to.href + ? href + : ${i('getHrefBetween')}(from, resolved); + } + ` + ); + + const code = s.toString(); + const map = s.generateMap({ hires: true }); + return { code, map }; + } +}); + +/** + * @param {import('svelte/types/compiler/interfaces').Ast} ast + * @param {MagicString} s + * @param {string} code + */ +function addCodeToScript(ast, s, code) { + if (ast.instance) { + // @ts-ignore + const start = ast.instance.content.start; + s.appendRight(start, '\n' + code + '\n'); + } else { + s.prepend( + dedent` + + ` + ); + } +} + +/** + * @param {import("svelte/types/compiler/interfaces").Attribute} attribute + * @param {string} originalCode + * @returns {string} A string that contains the source code of a template string + */ +function attributeToTemplateString(attribute, originalCode) { + const values = attribute.value; + let templateString = '`'; + + for (const value of values) { + switch (value.type) { + case 'Text': + templateString += escapeStringLiteral(value.data); + break; + case 'AttributeShorthand': + case 'MustacheTag': { + const expressionCode = originalCode.slice(value.expression.start, value.expression.end); + templateString += '${'; + templateString += expressionCode; + templateString += '}'; + break; + } + } + } + + templateString += '`'; + return templateString; +} + +/** + * @param {import("svelte/types/compiler/interfaces").Ast} ast + * @param {string} name + * @returns {import("svelte/types/compiler/interfaces").TemplateNode[]} + */ +function getElements(ast, name) { + /** @type {import("svelte/types/compiler/interfaces").TemplateNode[]} */ + const elements = []; + + /** @param {import("svelte/types/compiler/interfaces").TemplateNode} templateNode */ + function walk(templateNode) { + if (templateNode.type === 'Element' && templateNode.name === name) { + elements.push(templateNode); + } + + if (!templateNode.children) return; + for (const child of templateNode.children) { + walk(child); + } + } + + walk(ast.html); + return elements; +} + +/** + * @param {string} string + * @returns {string} + */ +function escapeStringLiteral(string) { + return string.replace(/`/g, '\\`').replace(/\${/g, '\\${'); +} + +/** + * Takes in a JS identifier and returns a globally unique, deterministic alias for it that can be used safely + * @name Identifier + * @param {string} original_identifier + * @returns {string} + */ +function i(original_identifier) { + return `sk_internal_${original_identifier}`; +} diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 95668d2579e2..5a98459eb90a 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -307,9 +307,14 @@ export function create_client(app, target) { * @param {{}} [nav_token] */ async function goto(url, options, redirect_count, nav_token) { + const resolved_url = app.hooks.resolveDestination({ + from: new URL(location.href), + to: resolve_url(url) + }); + return navigate({ type: 'goto', - url: resolve_url(url), + url: resolved_url, keepfocus: options.keepFocus, noscroll: options.noScroll, replace_state: options.replaceState, @@ -352,7 +357,6 @@ export function create_client(app, target) { /** @param {import('./types.js').NavigationFinished} result */ function initialize(result) { if (DEV && result.state.error && document.querySelector('vite-error-overlay')) return; - current = result.state; const style = document.querySelector('style[data-sveltekit]'); @@ -1038,21 +1042,25 @@ export function create_client(app, target) { } /** - * @param {URL} url + * @param {URL | undefined} originalURL * @param {boolean} invalidating */ - function get_navigation_intent(url, invalidating) { - if (is_external_url(url, base)) return; + function get_navigation_intent(originalURL, invalidating) { + if (!originalURL) return; - const path = get_url_path(url.pathname); + //Apply the rewrite rules to the url + const rewrittenURL = app.hooks.rewriteURL({ url: new URL(originalURL) }); + if (is_external_url(rewrittenURL, base)) return; + + const path = get_url_path(rewrittenURL.pathname); for (const route of routes) { const params = route.exec(path); if (params) { - const id = url.pathname + url.search; + const id = originalURL.pathname + originalURL.search; /** @type {import('./types.js').NavigationIntent} */ - const intent = { id, invalidating, route, params: decode_params(params), url }; + const intent = { id, invalidating, route, params: decode_params(params), url: originalURL }; return intent; } } @@ -1149,6 +1157,7 @@ export function create_client(app, target) { } token = nav_token; + let navigation_result = intent && (await load_route(intent)); if (!navigation_result) { @@ -1167,8 +1176,6 @@ export function create_client(app, target) { ); } - // if this is an internal navigation intent, use the normalized - // URL for the rest of the function url = intent?.url || url; // abort if user navigated during update @@ -1201,6 +1208,9 @@ export function create_client(app, target) { } } + // navigation_result.state.url has been normalized with the trailing slash option + //url = navigation_result.state.url; + // reset invalidation only after a finished navigation. If there are redirects or // additional invalidations, they should get the same invalidation treatment invalidated.length = 0; @@ -1617,7 +1627,12 @@ export function create_client(app, target) { [PAGE_URL_KEY]: page.url.href }; - original_push_state.call(history, opts, '', resolve_url(url)); + const resolvedURL = app.hooks.resolveDestination({ + from: new URL(page.url), + to: new URL(resolve_url(url)) + }); + + original_push_state.call(history, opts, '', resolvedURL); page = { ...page, state }; root.$set({ page }); @@ -1642,7 +1657,12 @@ export function create_client(app, target) { [PAGE_URL_KEY]: page.url.href }; - original_replace_state.call(history, opts, '', resolve_url(url)); + const resolvedURL = app.hooks.resolveDestination({ + from: new URL(page.url), + to: new URL(resolve_url(url)) + }); + + original_replace_state.call(history, opts, '', resolvedURL); page = { ...page, state }; root.$set({ page }); @@ -2074,7 +2094,16 @@ export function create_client(app, target) { if (error instanceof Redirect) { // this is a real edge case — `load` would need to return // a redirect but only in the browser - await native_navigation(new URL(error.location, location.href)); + + const from = new URL(location.href); + const to = new URL(error.location, from); + + const destination = app.hooks.resolveDestination({ + from, + to + }); + + await native_navigation(destination); return; } diff --git a/packages/kit/src/runtime/server/ambient.d.ts b/packages/kit/src/runtime/server/ambient.d.ts index c893c94ff32b..06895d537c68 100644 --- a/packages/kit/src/runtime/server/ambient.d.ts +++ b/packages/kit/src/runtime/server/ambient.d.ts @@ -4,5 +4,7 @@ declare module '__SERVER__/internal.js' { handle?: import('@sveltejs/kit').Handle; handleError?: import('@sveltejs/kit').HandleServerError; handleFetch?: import('@sveltejs/kit').HandleFetch; + resolveDestination?: import('@sveltejs/kit').ResolveDestination; + rewriteURL: import('@sveltejs/kit').RewriteURL; }>; } diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index c0316d278e8d..73bad1396859 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -148,6 +148,7 @@ export async function render_data( } ); } catch (e) { + console.error(e); const error = normalize_error(e); if (error instanceof Redirect) { diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index 55bcd87807b9..e57ded33bf2c 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -1,4 +1,5 @@ import { ENDPOINT_METHODS, PAGE_METHODS } from '../../constants.js'; +import { getHrefBetween } from '../../utils/diff-urls.js'; import { negotiate } from '../../utils/http.js'; import { Redirect } from '../control.js'; import { method_not_allowed } from './utils.js'; @@ -7,9 +8,10 @@ import { method_not_allowed } from './utils.js'; * @param {import('@sveltejs/kit').RequestEvent} event * @param {import('types').SSREndpoint} mod * @param {import('types').SSRState} state + * @param {import('types').SSROptions} options * @returns {Promise} */ -export async function render_endpoint(event, mod, state) { +export async function render_endpoint(event, mod, state, options) { const method = /** @type {import('types').HttpMethod} */ (event.request.method); let handler = mod[method] || mod.fallback; @@ -64,9 +66,19 @@ export async function render_endpoint(event, mod, state) { return response; } catch (e) { if (e instanceof Redirect) { + const from = event.url; + const to = new URL(e.location, event.url); + const resolvedUrl = options.hooks.resolveDestination({ + from: new URL(from), + to: new URL(to) + }); + + const resolvedLocation = + resolvedUrl.href === to.href ? e.location : getHrefBetween(from, resolvedUrl); + return new Response(undefined, { status: e.status, - headers: { location: e.location } + headers: { location: resolvedLocation } }); } diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 37608c28eba8..64279f92f91b 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -62,7 +62,9 @@ export class Server { this.#options.hooks = { handle: module.handle || (({ event, resolve }) => resolve(event)), handleError: module.handleError || (({ error }) => console.error(error)), - handleFetch: module.handleFetch || (({ request, fetch }) => fetch(request)) + handleFetch: module.handleFetch || (({ request, fetch }) => fetch(request)), + resolveDestination: module.resolveDestination || ((event) => event.to), + rewriteURL: module.rewriteURL || (({ url }) => url) }; } catch (error) { if (DEV) { @@ -71,7 +73,9 @@ export class Server { throw error; }, handleError: ({ error }) => console.error(error), - handleFetch: ({ request, fetch }) => fetch(request) + handleFetch: ({ request, fetch }) => fetch(request), + resolveDestination: (event) => event.to, + rewriteURL: ({ url }) => url }; } else { throw error; diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index f8878e112328..62e7ce985241 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -15,6 +15,7 @@ import { render_response } from './render.js'; import { respond_with_error } from './respond_with_error.js'; import { get_option } from '../../../utils/options.js'; import { get_data_json } from '../data/index.js'; +import { getHrefBetween } from '../../../utils/diff-urls.js'; /** * The maximum request depth permitted before assuming we're stuck in an infinite loop @@ -62,6 +63,16 @@ export async function render_page(event, page, options, manifest, state, resolve // (this also determines status code) action_result = await handle_action_request(event, leaf_node.server); if (action_result?.type === 'redirect') { + //apply resolveDestination + const from = new URL(event.url); + const to = new URL(action_result.location, from); + const resolvedUrl = options.hooks.resolveDestination({ + from: new URL(from), + to: new URL(to) + }); + action_result.location = + resolvedUrl.href === to.href ? action_result.location : getHrefBetween(from, resolvedUrl); + return redirect_response(action_result.status, action_result.location); } if (action_result?.type === 'error') { @@ -208,10 +219,21 @@ export async function render_page(event, page, options, manifest, state, resolve const err = normalize_error(e); if (err instanceof Redirect) { + const from = event.url; + const to = new URL(err.location, from); + + const resolvedUrl = options.hooks.resolveDestination({ + from: new URL(from), + to: new URL(to) + }); + + const resolvedLocation = + resolvedUrl.href === to.href ? err.location : getHrefBetween(from, resolvedUrl); + if (state.prerendering && should_prerender_data) { const body = JSON.stringify({ type: 'redirect', - location: err.location + location: resolvedLocation }); state.prerendering.dependencies.set(data_pathname, { @@ -220,7 +242,7 @@ export async function render_page(event, page, options, manifest, state, resolve }); } - return redirect_response(err.status, err.location); + return redirect_response(err.status, resolvedLocation); } const status = get_status(err); diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index 567097184bf5..41af558f8ad6 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -31,6 +31,7 @@ import { json, text } from '../../exports/index.js'; import { action_json_redirect, is_action_json_request } from './page/actions.js'; import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM } from '../shared.js'; import { get_public_env } from './env_module.js'; +import { getHrefBetween } from '../../utils/diff-urls.js'; /* global __SVELTEKIT_ADAPTER_NAME__ */ @@ -56,7 +57,18 @@ const allowed_page_methods = new Set(['GET', 'HEAD', 'OPTIONS']); */ export async function respond(request, options, manifest, state) { /** URL but stripped from the potential `/__data.json` suffix and its search param */ - const url = new URL(request.url); + const originalURL = new URL(request.url); + const rewrittenURL = options.hooks.rewriteURL({ url: new URL(request.url) }); + + //If the URL has been rewritten to a different origin, return a redirect + if (rewrittenURL.origin !== originalURL.origin) { + return new Response('', { + status: 301, + headers: { + location: rewrittenURL.href + } + }); + } if (options.csrf_check_origin) { const forbidden = @@ -65,7 +77,7 @@ export async function respond(request, options, manifest, state) { request.method === 'PUT' || request.method === 'PATCH' || request.method === 'DELETE') && - request.headers.get('origin') !== url.origin; + request.headers.get('origin') !== originalURL.origin; if (forbidden) { const csrf_error = new HttpError( @@ -81,7 +93,7 @@ export async function respond(request, options, manifest, state) { let decoded; try { - decoded = decode_pathname(url.pathname); + decoded = decode_pathname(rewrittenURL.pathname); } catch { return text('Malformed URI', { status: 400 }); } @@ -93,6 +105,7 @@ export async function respond(request, options, manifest, state) { let params = {}; if (base && !state.prerendering?.fallback) { + console.log('base', base); if (!decoded.startsWith(base)) { return text('Not found', { status: 404 }); } @@ -108,15 +121,15 @@ export async function respond(request, options, manifest, state) { let invalidated_data_nodes; if (is_data_request) { decoded = strip_data_suffix(decoded) || '/'; - url.pathname = - strip_data_suffix(url.pathname) + - (url.searchParams.get(TRAILING_SLASH_PARAM) === '1' ? '/' : '') || '/'; - url.searchParams.delete(TRAILING_SLASH_PARAM); - invalidated_data_nodes = url.searchParams + originalURL.pathname = + strip_data_suffix(originalURL.pathname) + + (originalURL.searchParams.get(TRAILING_SLASH_PARAM) === '1' ? '/' : '') || '/'; + originalURL.searchParams.delete(TRAILING_SLASH_PARAM); + invalidated_data_nodes = originalURL.searchParams .get(INVALIDATED_PARAM) ?.split('') .map((node) => node === '1'); - url.searchParams.delete(INVALIDATED_PARAM); + originalURL.searchParams.delete(INVALIDATED_PARAM); } if (!state.prerendering?.fallback) { @@ -183,7 +196,7 @@ export async function respond(request, options, manifest, state) { } } }, - url, + url: originalURL, isDataRequest: is_data_request, isSubRequest: state.depth > 0 }; @@ -200,7 +213,7 @@ export async function respond(request, options, manifest, state) { if (route) { // if `paths.base === '/a/b/c`, then the root route is `/a/b/c/`, // regardless of the `trailingSlash` route option - if (url.pathname === base || url.pathname === base + '/') { + if (originalURL.pathname === base || originalURL.pathname === base + '/') { trailing_slash = 'always'; } else if (route.page) { const nodes = await Promise.all([ @@ -243,17 +256,17 @@ export async function respond(request, options, manifest, state) { } if (!is_data_request) { - const normalized = normalize_path(url.pathname, trailing_slash ?? 'never'); + const normalized = normalize_path(originalURL.pathname, trailing_slash ?? 'never'); - if (normalized !== url.pathname && !state.prerendering?.fallback) { + if (normalized !== originalURL.pathname && !state.prerendering?.fallback) { return new Response(undefined, { status: 308, headers: { 'x-sveltekit-normalize': '1', location: // ensure paths starting with '//' are not treated as protocol-relative - (normalized.startsWith('//') ? url.origin + normalized : normalized) + - (url.search === '?' ? '' : url.search) + (normalized.startsWith('//') ? originalURL.origin + normalized : normalized) + + (originalURL.search === '?' ? '' : originalURL.search) } }); } @@ -262,7 +275,7 @@ export async function respond(request, options, manifest, state) { const { cookies, new_cookies, get_cookie_header, set_internal } = get_cookies( request, - url, + originalURL, trailing_slash ?? 'never' ); @@ -277,7 +290,7 @@ export async function respond(request, options, manifest, state) { set_internal }); - if (state.prerendering && !state.prerendering.fallback) disable_search(url); + if (state.prerendering && !state.prerendering.fallback) disable_search(originalURL); const response = await options.hooks.handle({ event, @@ -346,6 +359,18 @@ export async function respond(request, options, manifest, state) { return response; } catch (e) { if (e instanceof Redirect) { + const originalDestination = new URL(e.location, originalURL); + + const resolvedDestination = options.hooks.resolveDestination({ + from: new URL(originalURL), + to: new URL(originalDestination) + }); + + e.location = + resolvedDestination.href === originalDestination.href + ? e.location + : getHrefBetween(originalURL, resolvedDestination); + const response = is_data_request ? redirect_json_response(e) : route?.page && is_action_json_request(event) @@ -403,7 +428,7 @@ export async function respond(request, options, manifest, state) { trailing_slash ?? 'never' ); } else if (route.endpoint && (!route.page || is_endpoint_request(event))) { - response = await render_endpoint(event, await route.endpoint(), state); + response = await render_endpoint(event, await route.endpoint(), state, options); } else if (route.page) { if (page_methods.has(method)) { response = await render_page(event, route.page, options, manifest, state, resolve_opts); @@ -495,6 +520,7 @@ export async function respond(request, options, manifest, state) { // so we need to make an actual HTTP request return await fetch(request); } catch (e) { + console.error('fatal', e); // TODO if `e` is instead named `error`, some fucked up Vite transformation happens // and I don't even know how to describe it. need to investigate at some point diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index a5d91c4c9f59..0cdd400eabd7 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -12,7 +12,9 @@ import { ServerInitOptions, HandleFetch, Actions, - HandleClientError + HandleClientError, + ResolveDestination, + RewriteURL } from '@sveltejs/kit'; import { HttpMethod, @@ -99,10 +101,14 @@ export interface ServerHooks { handleFetch: HandleFetch; handle: Handle; handleError: HandleServerError; + resolveDestination: ResolveDestination; + rewriteURL: RewriteURL; } export interface ClientHooks { handleError: HandleClientError; + resolveDestination: ResolveDestination; + rewriteURL: RewriteURL; } export interface Env { diff --git a/packages/kit/src/utils/diff-urls.js b/packages/kit/src/utils/diff-urls.js new file mode 100644 index 000000000000..5faa166e251e --- /dev/null +++ b/packages/kit/src/utils/diff-urls.js @@ -0,0 +1,30 @@ +export const metaUrl = import.meta.url; + +/** + * Get's the shortest href that gets from `from` to `to` + * + * @param {URL} from + * @param {URL} to + * + * @returns {string} The shortest href that gets from `from` to `to` + */ +export function getHrefBetween(from, to) { + //check if they use the same protocol - If not, we can't do anything + if (from.protocol !== to.protocol) { + return to.href; + } + + //If the credentials are included, we always need to include them - so there is no point in diffing further + if (to.password || to.username) { + const credentials = [to.username, to.password].filter(Boolean).join(':'); + return '//' + credentials + '@' + to.host + to.pathname + to.search + to.hash; + } + + // host = hostname + port + if (from.host !== to.host) { + //since they have the same protocol, we can omit the protocol + return '//' + to.host + to.pathname + to.search + to.hash; + } + + return to.pathname + to.search + to.hash; +} diff --git a/packages/kit/src/utils/diff-urls.spec.js b/packages/kit/src/utils/diff-urls.spec.js new file mode 100644 index 000000000000..a71ec9ad097b --- /dev/null +++ b/packages/kit/src/utils/diff-urls.spec.js @@ -0,0 +1,184 @@ +import { describe, expect, test } from 'vitest'; +import { getHrefBetween } from './diff-urls.js'; + +describe('getHrefBetween', () => { + test.concurrent('two identical urls with different search query', () => { + const from = new URL('http://localhost:3000'); + const to = new URL('http://localhost:3000?foo=bar'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('/?foo=bar'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('two identical urls with different fragment', () => { + const from = new URL('http://localhost:3000'); + const to = new URL('http://localhost:3000#some-fragment'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('/#some-fragment'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('two identical urls with different search query and fragment', () => { + const from = new URL('http://localhost:3000'); + const to = new URL('http://localhost:3000?foo=bar#some-fragment'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('/?foo=bar#some-fragment'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('two identical urls with different protocols', () => { + const from = new URL('http://localhost:3000'); + const to = new URL('https://localhost:3000'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('https://localhost:3000/'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test('child page, no trailing slash', () => { + const from = new URL('http://localhost:5173/en'); + const to = new URL('http://localhost:5173/en/about'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('/en/about'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('two identical urls with different hosts', () => { + const from = new URL('http://localhost:3000'); + const to = new URL('http://localhost:3001'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('//localhost:3001/'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('two identical urls with different ports', () => { + const from = new URL('http://localhost:3000'); + const to = new URL('http://localhost:3001'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('//localhost:3001/'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('get to parents-page', () => { + const from = new URL('https://example.com/foo/some-page'); + const to = new URL('https://example.com/foo/'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('/foo/'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('get to grand-parents-page', () => { + const from = new URL('https://example.com/foo/bar/some-page'); + const to = new URL('https://example.com/foo/'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('/foo/'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('get to child page', () => { + const from = new URL('https://example.com/foo/'); + const to = new URL('https://example.com/foo/some-page/'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('/foo/some-page/'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('get to grand-child page', () => { + const from = new URL('https://example.com/foo/'); + const to = new URL('https://example.com/foo/bar/some-page'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('/foo/bar/some-page'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('get to sibling page, with trailing slash', () => { + const from = new URL('https://example.com/foo/bar/some-page/'); + const to = new URL('https://example.com/foo/bar/another-page/'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('/foo/bar/another-page/'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('absolute path is shorter than relative path', () => { + const from = new URL('https://example.com/foo/bar/some-page'); + const to = new URL('https://example.com/'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('/'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('urls with different credentials', () => { + const from = new URL('https://user:pass1@example.com/some-page'); + const to = new URL('https://user:pass2@example.com/some-page'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('//user:pass2@example.com/some-page'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('same credentials, different host', () => { + const from = new URL('https://user:pass@localhost:3000'); + const to = new URL('https://user:pass@localhost:3001'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('//user:pass@localhost:3001/'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('same credentials, different path', () => { + const from = new URL('https://user:pass@localhost:3000/'); + const to = new URL('https://user:pass@localhost:3000/about'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('//user:pass@localhost:3000/about'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('same credentials, different protocol', () => { + const from = new URL('https://user:pass@localhost:3000/'); + const to = new URL('http://user:pass@localhost:3000/'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('http://user:pass@localhost:3000/'); + expect(new URL(href, from).href).toBe(to.href); + }); + + test.concurrent('only username', () => { + const from = new URL('https://user@localhost:3000/'); + const to = new URL('https://user@localhost:3001/'); + + const href = getHrefBetween(from, to); + + expect(href).toBe('//user@localhost:3001/'); + expect(new URL(href, from).href).toBe(to.href); + }); +}); diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index fdf76661f67e..3db45378ea84 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -809,7 +809,7 @@ test.describe('Streaming', () => { test('Catches fetch errors from server load functions (client nav)', async ({ page }) => { await page.goto('/streaming'); - page.click('[href="/streaming/server-error"]'); + await page.click('[href="/streaming/server-error"]'); await expect(page.locator('p.eager')).toHaveText('eager'); expect(page.locator('p.fail')).toBeVisible(); @@ -920,8 +920,8 @@ test.describe('untrack', () => { expect(await page.textContent('p.url')).toBe('/untrack/server/1'); const id = await page.textContent('p.id'); await page.click('a[href="/untrack/server/2"]'); - expect(await page.textContent('p.url')).toBe('/untrack/server/2'); expect(await page.textContent('p.id')).toBe(id); + expect(await page.textContent('p.url')).toBe('/untrack/server/2'); }); test('untracks universal load function', async ({ page }) => { diff --git a/packages/kit/test/apps/rewrites/.gitignore b/packages/kit/test/apps/rewrites/.gitignore new file mode 100644 index 000000000000..fad4d3e1518d --- /dev/null +++ b/packages/kit/test/apps/rewrites/.gitignore @@ -0,0 +1,3 @@ +/test/errors.json +!/.env +/src/routes/routing/symlink-from \ No newline at end of file diff --git a/packages/kit/test/apps/rewrites/package.json b/packages/kit/test/apps/rewrites/package.json new file mode 100644 index 000000000000..252b89c37631 --- /dev/null +++ b/packages/kit/test/apps/rewrites/package.json @@ -0,0 +1,27 @@ +{ + "name": "test-rewrites", + "private": true, + "version": "0.0.2-next.0", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && tsc && svelte-check", + "test": "node test/setup.js && pnpm test:dev && pnpm test:build", + "test:dev": "node -e \"fs.rmSync('test/errors.json', { force: true })\" && cross-env DEV=true playwright test", + "test:build": "node -e \"fs.rmSync('test/errors.json', { force: true })\" && cross-env PUBLIC_PRERENDERING=false playwright test", + "test:cross-platform:dev": "node test/setup.js && node -e \"fs.rmSync('test/errors.json', { force: true })\" && cross-env DEV=true playwright test test/cross-platform/", + "test:cross-platform:build": "node test/setup.js && node -e \"fs.rmSync('test/errors.json', { force: true })\" && playwright test test/cross-platform/" + }, + "devDependencies": { + "@sveltejs/kit": "workspace:^", + "@sveltejs/vite-plugin-svelte": "^3.0.1", + "cross-env": "^7.0.3", + "marked": "^11.1.0", + "svelte": "^4.2.8", + "svelte-check": "^3.6.2", + "typescript": "^5.3.3", + "vite": "^5.0.8" + }, + "type": "module" +} diff --git a/packages/kit/test/apps/rewrites/playwright.config.js b/packages/kit/test/apps/rewrites/playwright.config.js new file mode 100644 index 000000000000..0f7c1458a513 --- /dev/null +++ b/packages/kit/test/apps/rewrites/playwright.config.js @@ -0,0 +1,11 @@ +import { config } from '../../utils.js'; + +export default { + ...config, + webServer: { + command: process.env.DEV + ? 'cross-env PUBLIC_PRERENDERING=false pnpm dev' + : 'cross-env PUBLIC_PRERENDERING=true pnpm build && pnpm preview', + port: process.env.DEV ? 5173 : 4173 + } +}; diff --git a/packages/kit/test/apps/rewrites/src/app.d.ts b/packages/kit/test/apps/rewrites/src/app.d.ts new file mode 100644 index 000000000000..16bdf501b907 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/app.d.ts @@ -0,0 +1,19 @@ +declare global { + namespace App { + interface Locals { + answer: number; + name?: string; + key: string; + params: Record; + url?: URL; + } + + interface PageState { + active: boolean; + } + + interface Platform {} + } +} + +export {}; diff --git a/packages/kit/test/apps/rewrites/src/app.html b/packages/kit/test/apps/rewrites/src/app.html new file mode 100644 index 000000000000..9605018e1385 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/kit/test/apps/rewrites/src/hooks.router.js b/packages/kit/test/apps/rewrites/src/hooks.router.js new file mode 100644 index 000000000000..7d4528fa7909 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/hooks.router.js @@ -0,0 +1,44 @@ +/** + * @type {import("@sveltejs/kit").RewriteURL} + */ +export const rewriteURL = ({ url }) => { + if (url.pathname.startsWith('/rewrites/from')) { + url.pathname = url.pathname.replace('/rewrites/from', '/rewrites/to'); + console.log('rewrites', url.pathname); + return url; + } + + if (url.pathname.startsWith('/chained/intermediate')) { + url.pathname = '/chained/to'; + return url; + } + + return url; +}; + +/** + * @type {import("@sveltejs/kit").ResolveDestination} + */ +export const resolveDestination = ({ to }) => { + if (to.pathname.startsWith('/resolveDestination') && to.pathname.endsWith('from')) { + to.pathname = to.pathname.replace('from', 'to'); + return to; + } + + if (to.pathname === '/chained/from') { + to.pathname = '/chained/intermediate'; + return to; + } + + //If it matches the pattern /once/ then redirect to /once/ + if (to.pathname.startsWith('/once')) { + const match = to.pathname.match(/\/once\/(\d+)/); + if (match) { + const num = parseInt(match[1]); + to.pathname = `/once/${num + 1}`; + } + return to; + } + + return to; +}; diff --git a/packages/kit/test/apps/rewrites/src/hooks.server.js b/packages/kit/test/apps/rewrites/src/hooks.server.js new file mode 100644 index 000000000000..92fb2a26ebd8 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/hooks.server.js @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit'; + +export const handle = async ({ event, resolve }) => { + if (event.url.pathname == '/resolveDestination/handle') { + redirect(307, '/resolveDestination/handle/from'); + } + + return await resolve(event); +}; diff --git a/packages/kit/test/apps/rewrites/src/routes/chained/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/chained/+page.svelte new file mode 100644 index 000000000000..36ce04951b09 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/chained/+page.svelte @@ -0,0 +1 @@ +go diff --git a/packages/kit/test/apps/rewrites/src/routes/chained/from/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/chained/from/+page.svelte new file mode 100644 index 000000000000..c80e2b1bf110 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/chained/from/+page.svelte @@ -0,0 +1 @@ +

Didn't resolve Destination

diff --git a/packages/kit/test/apps/rewrites/src/routes/chained/intermediate/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/chained/intermediate/+page.svelte new file mode 100644 index 000000000000..db323650d71b --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/chained/intermediate/+page.svelte @@ -0,0 +1 @@ +

Only resolvedDestination, but no rewrite

diff --git a/packages/kit/test/apps/rewrites/src/routes/chained/to/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/chained/to/+page.svelte new file mode 100644 index 000000000000..abe3472fef31 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/chained/to/+page.svelte @@ -0,0 +1 @@ +

Successfully Chained

diff --git a/packages/kit/test/apps/rewrites/src/routes/once/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/once/+page.svelte new file mode 100644 index 000000000000..afa3fd0eefee --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/once/+page.svelte @@ -0,0 +1 @@ +The href should be resolved to "/once/1" diff --git a/packages/kit/test/apps/rewrites/src/routes/once/0/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/once/0/+page.svelte new file mode 100644 index 000000000000..9b90c741c6a3 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/once/0/+page.svelte @@ -0,0 +1 @@ +

0

diff --git a/packages/kit/test/apps/rewrites/src/routes/once/1/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/once/1/+page.svelte new file mode 100644 index 000000000000..6c65218032da --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/once/1/+page.svelte @@ -0,0 +1 @@ +

1

diff --git a/packages/kit/test/apps/rewrites/src/routes/once/2/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/once/2/+page.svelte new file mode 100644 index 000000000000..9a2b96695025 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/once/2/+page.svelte @@ -0,0 +1 @@ +

2

diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/a/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/a/+page.svelte new file mode 100644 index 000000000000..9d285b449771 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/a/+page.svelte @@ -0,0 +1 @@ +Follow Me diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/a/from/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/a/from/+page.svelte new file mode 100644 index 000000000000..5c7a4be88893 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/a/from/+page.svelte @@ -0,0 +1 @@ +

It shouldn't link here

diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/a/to/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/a/to/+page.svelte new file mode 100644 index 000000000000..e6237808c4f3 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/a/to/+page.svelte @@ -0,0 +1 @@ +

Successfully Resolved

diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/form/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/form/+page.svelte new file mode 100644 index 000000000000..ca822af83865 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/form/+page.svelte @@ -0,0 +1,4 @@ +
+ + +
diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/form/from/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/form/from/+page.svelte new file mode 100644 index 000000000000..5c7a4be88893 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/form/from/+page.svelte @@ -0,0 +1 @@ +

It shouldn't link here

diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/form/to/+page.server.js b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/form/to/+page.server.js new file mode 100644 index 000000000000..345708e72a6b --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/form/to/+page.server.js @@ -0,0 +1,7 @@ +export const actions = { + default: () => { + return { + text: 'Successfully Resolved' + }; + } +}; diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/form/to/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/form/to/+page.svelte new file mode 100644 index 000000000000..23c8f835f7bd --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/form/to/+page.svelte @@ -0,0 +1,5 @@ + + +

{form.text}

diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/handle/from/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/handle/from/+page.svelte new file mode 100644 index 000000000000..5c7a4be88893 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/handle/from/+page.svelte @@ -0,0 +1 @@ +

It shouldn't link here

diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/handle/to/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/handle/to/+page.svelte new file mode 100644 index 000000000000..e6237808c4f3 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/handle/to/+page.svelte @@ -0,0 +1 @@ +

Successfully Resolved

diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/+page.server.js b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/+page.server.js new file mode 100644 index 000000000000..f31663cecf94 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/+page.server.js @@ -0,0 +1,7 @@ +import { redirect } from '@sveltejs/kit'; + +export const actions = { + default: () => { + redirect(307, '/resolveDestination/redirect-in-action/from'); + } +}; diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/+page.svelte new file mode 100644 index 000000000000..ae073c379739 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/+page.svelte @@ -0,0 +1,4 @@ +
+ + +
diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/from/+page.server.js b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/from/+page.server.js new file mode 100644 index 000000000000..54777fa21fb9 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/from/+page.server.js @@ -0,0 +1,7 @@ +export const actions = { + default: () => { + return { + text: "It shouldn't link here" + }; + } +}; diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/from/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/from/+page.svelte new file mode 100644 index 000000000000..1d64421c9768 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/from/+page.svelte @@ -0,0 +1,5 @@ + + +

{form?.text}

diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/to/+page.server.js b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/to/+page.server.js new file mode 100644 index 000000000000..345708e72a6b --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/to/+page.server.js @@ -0,0 +1,7 @@ +export const actions = { + default: () => { + return { + text: 'Successfully Resolved' + }; + } +}; diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/to/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/to/+page.svelte new file mode 100644 index 000000000000..1d64421c9768 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect-in-action/to/+page.svelte @@ -0,0 +1,5 @@ + + +

{form?.text}

diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect/+page.js b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect/+page.js new file mode 100644 index 000000000000..63dee45039d5 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect/+page.js @@ -0,0 +1,5 @@ +import { redirect } from '@sveltejs/kit'; + +export function load() { + redirect(307, '/resolveDestination/redirect/from'); +} diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect/from/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect/from/+page.svelte new file mode 100644 index 000000000000..5c7a4be88893 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect/from/+page.svelte @@ -0,0 +1 @@ +

It shouldn't link here

diff --git a/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect/to/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect/to/+page.svelte new file mode 100644 index 000000000000..e6237808c4f3 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/resolveDestination/redirect/to/+page.svelte @@ -0,0 +1 @@ +

Successfully Resolved

diff --git a/packages/kit/test/apps/rewrites/src/routes/rewrites/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/rewrites/+page.svelte new file mode 100644 index 000000000000..74239422f739 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/rewrites/+page.svelte @@ -0,0 +1 @@ +Navigate to Page with Rewrite diff --git a/packages/kit/test/apps/rewrites/src/routes/rewrites/from/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/rewrites/from/+page.svelte new file mode 100644 index 000000000000..5189380a18a5 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/rewrites/from/+page.svelte @@ -0,0 +1 @@ +

I should be unreachable

diff --git a/packages/kit/test/apps/rewrites/src/routes/rewrites/to/+page.svelte b/packages/kit/test/apps/rewrites/src/routes/rewrites/to/+page.svelte new file mode 100644 index 000000000000..540f76eae7b6 --- /dev/null +++ b/packages/kit/test/apps/rewrites/src/routes/rewrites/to/+page.svelte @@ -0,0 +1 @@ +

Successfully rewritten

diff --git a/packages/kit/test/apps/rewrites/svelte.config.js b/packages/kit/test/apps/rewrites/svelte.config.js new file mode 100644 index 000000000000..67d403fb0e0c --- /dev/null +++ b/packages/kit/test/apps/rewrites/svelte.config.js @@ -0,0 +1,10 @@ +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + prerender: { + handleHttpError: 'warn' + } + } +}; + +export default config; diff --git a/packages/kit/test/apps/rewrites/test/client.test.js b/packages/kit/test/apps/rewrites/test/client.test.js new file mode 100644 index 000000000000..4de30ae90224 --- /dev/null +++ b/packages/kit/test/apps/rewrites/test/client.test.js @@ -0,0 +1,73 @@ +import { expect } from '@playwright/test'; +import { test } from '../../../utils.js'; + +test.describe.configure({ mode: 'parallel' }); + +test.skip(({ javaScriptEnabled }) => !!javaScriptEnabled); + +test.describe('Rewrites', () => { + test('rewrites url during client navigation', async ({ page, clicknav }) => { + await page.goto('/rewrites'); + await clicknav("a[href='/rewrites/from']"); + await expect(page.locator('h1')).toHaveText('Successfully rewritten'); + }); +}); + +test.describe('resolveDestination', () => { + test('a tags should be rewritten', async ({ page, clicknav }) => { + await page.goto('/resolveDestination/a'); + await clicknav("a:has-text('Follow me')"); + await expect(page.locator('h1')).toHaveText('Successfully Resolved'); + }); + + test('redirects in load functions should be rewritten', async ({ page }) => { + await page.goto('/resolveDestination/redirect'); + await expect(page.locator('h1')).toHaveText('Successfully Resolved'); + }); + + test('redirects in handle hooks should be rewritten', async ({ page }) => { + await page.goto('/resolveDestination/handle'); + await expect(page.locator('h1')).toHaveText('Successfully Resolved'); + }); + + test('redirects in actions should be rewritten', async ({ page }) => { + await page.goto('/resolveDestination/redirect-in-action'); + + //Submit the form + await page.locator('button').click(); + + await expect(page.locator('h1')).toHaveText('Successfully Resolved'); + }); + + test('resolves form actions correctly', async ({ page }) => { + await page.goto('/resolveDestination/form'); + await page.locator('button').click(); + + await expect(page.locator('h1')).toHaveText('Successfully Resolved'); + }); +}); + +test.describe('chained', () => { + test('client side navigation applies resolveDestination and rewrites', async ({ + page, + clicknav + }) => { + await page.goto('/chained'); + await clicknav('a'); + + await expect(page.locator('h1')).toHaveText('Successfully Chained'); + }); +}); + +test.describe('once', () => { + // There is some potential for an easy bug where `href` get's run through resolveDestination twice + // 1. During the initial render + // 2. During the client side navigation + + test('the rewrites on links should only be applied once', async ({ page, clicknav }) => { + await page.goto('/once'); + await clicknav('a'); + + await expect(page.locator('h1')).toHaveText('1'); + }); +}); diff --git a/packages/kit/test/apps/rewrites/test/cross-platform/client.test.js b/packages/kit/test/apps/rewrites/test/cross-platform/client.test.js new file mode 100644 index 000000000000..102e1564784c --- /dev/null +++ b/packages/kit/test/apps/rewrites/test/cross-platform/client.test.js @@ -0,0 +1,12 @@ +import { expect } from '@playwright/test'; +import { test } from '../../../../utils.js'; + +/** @typedef {import('@playwright/test').Response} Response */ + +test.skip(({ javaScriptEnabled }) => !javaScriptEnabled); + +test.describe.configure({ mode: 'parallel' }); + +test('placeholder', async () => { + expect(1).toBe(1); +}); diff --git a/packages/kit/test/apps/rewrites/test/cross-platform/server.test.js b/packages/kit/test/apps/rewrites/test/cross-platform/server.test.js new file mode 100644 index 000000000000..481c436ebb00 --- /dev/null +++ b/packages/kit/test/apps/rewrites/test/cross-platform/server.test.js @@ -0,0 +1,12 @@ +import { expect } from '@playwright/test'; +import { test } from '../../../../utils.js'; + +/** @typedef {import('@playwright/test').Response} Response */ + +test.skip(({ javaScriptEnabled }) => !!javaScriptEnabled); + +test.describe.configure({ mode: 'parallel' }); + +test('placeholder', async () => { + expect(1).toBe(1); +}); diff --git a/packages/kit/test/apps/rewrites/test/cross-platform/test.js b/packages/kit/test/apps/rewrites/test/cross-platform/test.js new file mode 100644 index 000000000000..5ee57c69c94e --- /dev/null +++ b/packages/kit/test/apps/rewrites/test/cross-platform/test.js @@ -0,0 +1,10 @@ +import { expect } from '@playwright/test'; +import { test } from '../../../../utils.js'; + +/** @typedef {import('@playwright/test').Response} Response */ + +test.describe.configure({ mode: 'parallel' }); + +test('placeholder', async () => { + expect(1).toBe(1); +}); diff --git a/packages/kit/test/apps/rewrites/test/server.test.js b/packages/kit/test/apps/rewrites/test/server.test.js new file mode 100644 index 000000000000..ca428042d927 --- /dev/null +++ b/packages/kit/test/apps/rewrites/test/server.test.js @@ -0,0 +1,13 @@ +import { expect } from '@playwright/test'; +import { test } from '../../../utils.js'; + +test.skip(({ javaScriptEnabled }) => !!javaScriptEnabled); + +test.describe.configure({ mode: 'parallel' }); + +test.describe('Rewrites', () => { + test('Rewrites to a different page', async ({ page }) => { + await page.goto('/rewrites/from'); + expect(await page.textContent('h1')).toBe('Successfully rewritten'); + }); +}); diff --git a/packages/kit/test/apps/rewrites/test/setup.js b/packages/kit/test/apps/rewrites/test/setup.js new file mode 100644 index 000000000000..cb0ff5c3b541 --- /dev/null +++ b/packages/kit/test/apps/rewrites/test/setup.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/kit/test/apps/rewrites/tsconfig.json b/packages/kit/test/apps/rewrites/tsconfig.json new file mode 100644 index 000000000000..f7ebdb5e4e7c --- /dev/null +++ b/packages/kit/test/apps/rewrites/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "noEmit": true, + "paths": { + "@sveltejs/kit": ["../../../types"], + "types": ["../../../types/internal"] + }, + "resolveJsonModule": true + }, + "extends": "./.svelte-kit/tsconfig.json" +} diff --git a/packages/kit/test/apps/rewrites/vite.config.js b/packages/kit/test/apps/rewrites/vite.config.js new file mode 100644 index 000000000000..0afea7f14a85 --- /dev/null +++ b/packages/kit/test/apps/rewrites/vite.config.js @@ -0,0 +1,23 @@ +import * as path from 'node:path'; +import { sveltekit } from '@sveltejs/kit/vite'; + +/** @type {import('vite').UserConfig} */ +const config = { + build: { + minify: false + }, + clearScreen: false, + optimizeDeps: { + // for CI, we need to explicitly prebundle deps, since + // the reload confuses Playwright + include: ['cookie', 'marked'] + }, + plugins: [sveltekit()], + server: { + fs: { + allow: [path.resolve('../../../src')] + } + } +}; + +export default config; diff --git a/packages/kit/test/prerendering/basics/src/hooks.router.js b/packages/kit/test/prerendering/basics/src/hooks.router.js new file mode 100644 index 000000000000..fb74888f14b3 --- /dev/null +++ b/packages/kit/test/prerendering/basics/src/hooks.router.js @@ -0,0 +1,12 @@ +/** + * @type {import("@sveltejs/kit").ResolveDestination} + */ +export const resolveDestination = ({ from, to }) => { + if (!from.pathname.startsWith('/resolve-destination')) return to; + + if (to.pathname === '/home') { + to.pathname = '/'; + } + + return to; +}; diff --git a/packages/kit/test/prerendering/basics/src/routes/resolve-destination/+page.svelte b/packages/kit/test/prerendering/basics/src/routes/resolve-destination/+page.svelte new file mode 100644 index 000000000000..1407ef6669d2 --- /dev/null +++ b/packages/kit/test/prerendering/basics/src/routes/resolve-destination/+page.svelte @@ -0,0 +1 @@ +My href should be rewritten to "/" diff --git a/packages/kit/test/prerendering/basics/test/tests.spec.js b/packages/kit/test/prerendering/basics/test/tests.spec.js index 04bb754ef70f..b4ee8c6798cf 100644 --- a/packages/kit/test/prerendering/basics/test/tests.spec.js +++ b/packages/kit/test/prerendering/basics/test/tests.spec.js @@ -253,3 +253,8 @@ test('prerenders paths with optional parameters with empty values', () => { const content = read('optional-params.html'); expect(content).includes('Path with Value'); }); + +test('applies resolveDestination during prerendering', () => { + const content = read('resolve-destination.html'); + expect(content).includes('href="/"'); +}); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 11f94fa85c9a..bdcbf7444287 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -382,6 +382,12 @@ declare module '@sveltejs/kit' { * @default "src/hooks.server" */ server?: string; + + /** + * The location of your router hooks. + * @default "src/hooks.router" + */ + router?: string; }; /** * your app's internal library, accessible throughout the codebase as `$lib` @@ -656,6 +662,31 @@ declare module '@sveltejs/kit' { message: string; }) => MaybePromise; + /** + * Maps an href value to a destination + * @example + * ```js + * export const resolveDestination = ({ from, to }) => { + * if(to.host !== from.host) return to; //Don't remap external links + * const lang = getLanguageFromURL(from); + * return applyLanguage(to, lang); + * } + * ``` + */ + export type ResolveDestination = (event: { from: URL; to: URL }) => URL; + + /** + * Remap an incoming URL to a different URL. + * + * @example + * ```js + * export const rewriteURL = (url) => { + * return urlWithoutLanguage(url); + * } + * ``` + */ + export type RewriteURL = (event: { url: URL }) => URL; + /** * The [`handleFetch`](https://kit.svelte.dev/docs/hooks#server-hooks-handlefetch) hook allows you to modify (or replace) a `fetch` request that happens inside a `load` function that runs on the server (or during pre-rendering) */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9745e628738a..e6fa55cef55d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -667,6 +667,33 @@ importers: specifier: ^5.0.8 version: 5.0.8(@types/node@18.19.3)(lightningcss@1.22.1) + packages/kit/test/apps/rewrites: + devDependencies: + '@sveltejs/kit': + specifier: workspace:^ + version: link:../../.. + '@sveltejs/vite-plugin-svelte': + specifier: ^3.0.1 + version: 3.0.1(svelte@4.2.8)(vite@5.0.8) + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + marked: + specifier: ^11.1.0 + version: 11.1.0 + svelte: + specifier: ^4.2.8 + version: 4.2.8 + svelte-check: + specifier: ^3.6.2 + version: 3.6.2(postcss@8.4.32)(svelte@4.2.8) + typescript: + specifier: ^5.3.3 + version: 5.3.3 + vite: + specifier: ^5.0.8 + version: 5.0.8(@types/node@18.19.3)(lightningcss@1.22.1) + packages/kit/test/apps/writes: devDependencies: '@sveltejs/kit':