From 7725906d7381877d2a047f5e4327be13b5874768 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 3 Oct 2025 01:04:22 +0200 Subject: [PATCH 1/9] fix: basepath --- packages/react-router/src/index.tsx | 2 +- packages/react-router/tests/router.test.tsx | 104 ++++-------------- packages/router-core/src/index.ts | 1 - packages/router-core/src/router.ts | 61 +++++----- packages/solid-router/src/index.tsx | 2 +- .../src/client-rpc/createClientRpc.ts | 14 +-- .../src/client/hydrateStart.ts | 5 +- packages/start-plugin-core/src/global.d.ts | 1 - packages/start-plugin-core/src/plugin.ts | 28 +++-- packages/start-plugin-core/src/prerender.ts | 6 +- packages/start-plugin-core/src/schema.ts | 1 + .../src/start-manifest-plugin/plugin.ts | 29 +++-- .../start-server-core/src/createServerRpc.ts | 18 +-- .../src/createStartHandler.ts | 24 +--- packages/start-server-core/src/global.d.ts | 4 +- .../start-server-core/src/request-response.ts | 3 +- .../start-server-core/src/router-manifest.ts | 2 +- .../src/server-functions-handler.ts | 17 +-- 18 files changed, 118 insertions(+), 204 deletions(-) diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 9138121f209..77270748939 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -349,7 +349,7 @@ export { Asset } from './Asset' export { HeadContent } from './HeadContent' export { Scripts } from './Scripts' export type * from './ssr/serializer' -export { rewriteBasepath, composeRewrites } from '@tanstack/router-core' +export { composeRewrites } from '@tanstack/router-core' export type { LocationRewrite, LocationRewriteFunction, diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 2214e9001eb..2b77f797394 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -19,7 +19,6 @@ import { createRootRoute, createRoute, createRouter, - rewriteBasepath, useNavigate, } from '../src' import type { StandardSchemaValidator } from '@tanstack/router-core' @@ -2602,7 +2601,7 @@ describe('Router rewrite functionality', () => { }) }) -describe('rewriteBasepath utility', () => { +describe('basepath', () => { it('should handle basic basepath rewriting with input', async () => { const rootRoute = createRootRoute({ component: () => , @@ -2627,7 +2626,7 @@ describe('rewriteBasepath utility', () => { history: createMemoryHistory({ initialEntries: ['/my-app/about'], }), - rewrite: rewriteBasepath({ basepath: 'my-app' }), + basepath: 'my-app', }) render() @@ -2640,37 +2639,11 @@ describe('rewriteBasepath utility', () => { expect(router.state.location.pathname).toBe('/about') }) - it('should handle basepath with leading and trailing slashes', async () => { - const rootRoute = createRootRoute({ - component: () => , - }) - - const usersRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/users', - component: () =>
Users
, - }) - - const routeTree = rootRoute.addChildren([usersRoute]) - - const router = createRouter({ - routeTree, - history: createMemoryHistory({ - initialEntries: ['/api/v1/users'], - }), - rewrite: rewriteBasepath({ basepath: '/api/v1/' }), // With leading and trailing slashes - }) - - render() - - await waitFor(() => { - expect(screen.getByTestId('users')).toBeInTheDocument() - }) - - expect(router.state.location.pathname).toBe('/users') - }) - it.each([ + { + description: 'basepath with leading and trailing slashes', + basepath: '/api/v1/', + }, { description: 'basepath with leading slash but without trailing slash', basepath: '/api/v1', @@ -2701,7 +2674,7 @@ describe('rewriteBasepath utility', () => { history: createMemoryHistory({ initialEntries: ['/api/v1/users'], }), - rewrite: rewriteBasepath({ basepath }), + basepath, }) render() @@ -2742,7 +2715,7 @@ describe('rewriteBasepath utility', () => { history: createMemoryHistory({ initialEntries: ['/my-app/'], }), - rewrite: rewriteBasepath({ basepath }), + basepath, }) render() @@ -2791,7 +2764,7 @@ describe('rewriteBasepath utility', () => { const router = createRouter({ routeTree, history, - rewrite: rewriteBasepath({ basepath }), + basepath, }) render() @@ -2826,7 +2799,7 @@ describe('rewriteBasepath utility', () => { history: createMemoryHistory({ initialEntries: ['/test'], }), - rewrite: rewriteBasepath({ basepath: '' }), // Empty basepath + basepath: '', }) render() @@ -2854,19 +2827,16 @@ describe('rewriteBasepath utility', () => { history: createMemoryHistory({ initialEntries: ['/my-app/legacy/api/v1'], }), - rewrite: composeRewrites([ - rewriteBasepath({ basepath: 'my-app' }), - { - // Additional rewrite logic after basepath removal - input: ({ url }) => { - if (url.pathname === '/legacy/api/v1') { - url.pathname = '/api/v2' - return url - } - return undefined - }, + basepath: 'my-app', + rewrite: { + input: ({ url }) => { + if (url.pathname === '/legacy/api/v1') { + url.pathname = '/api/v2' + return url + } + return undefined }, - ]), + }, }) render() @@ -2878,36 +2848,6 @@ describe('rewriteBasepath utility', () => { expect(router.state.location.pathname).toBe('/api/v2') }) - it('should handle complex basepath with subdomain-style paths', async () => { - const rootRoute = createRootRoute({ - component: () => , - }) - - const dashboardRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/dashboard', - component: () =>
Dashboard
, - }) - - const routeTree = rootRoute.addChildren([dashboardRoute]) - - const router = createRouter({ - routeTree, - history: createMemoryHistory({ - initialEntries: ['/tenant-123/dashboard'], - }), - rewrite: rewriteBasepath({ basepath: 'tenant-123' }), - }) - - render() - - await waitFor(() => { - expect(screen.getByTestId('dashboard')).toBeInTheDocument() - }) - - expect(router.state.location.pathname).toBe('/dashboard') - }) - it('should preserve search params and hash when rewriting basepath', async () => { const rootRoute = createRootRoute({ component: () => , @@ -2926,7 +2866,7 @@ describe('rewriteBasepath utility', () => { history: createMemoryHistory({ initialEntries: ['/app/search?q=test&filter=all#results'], }), - rewrite: rewriteBasepath({ basepath: 'app' }), + basepath: 'app', }) render() @@ -2961,8 +2901,8 @@ describe('rewriteBasepath utility', () => { history: createMemoryHistory({ initialEntries: ['/base/legacy/old/path'], }), + basepath: 'base', rewrite: composeRewrites([ - rewriteBasepath({ basepath: 'base' }), { input: ({ url }) => { // First layer: convert legacy paths @@ -3046,7 +2986,7 @@ describe('rewriteBasepath utility', () => { const router = createRouter({ routeTree, history, - rewrite: rewriteBasepath({ basepath: 'my-app' }), + basepath: 'my-app' }) render() diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index ab822a51e73..2bff87234d6 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -434,7 +434,6 @@ export { export { defaultSerovalPlugins } from './ssr/serializer/seroval-plugins' export { - rewriteBasepath, composeRewrites, executeRewriteInput, executeRewriteOutput, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 7a1727d2140..1f52dbd6c2f 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -285,18 +285,6 @@ export interface RouterOptions< routeTree?: TRouteTree /** * The basepath for then entire router. This is useful for mounting a router instance at a subpath. - * - * @deprecated - use `rewrite.input` with the new `rewriteBasepath` utility instead: - * ```ts - * const router = createRouter({ - * routeTree, - * rewrite: rewriteBasepath('/basepath') - * // Or wrap existing rewrite functionality - * rewrite: rewriteBasepath('/basepath', { - * output: ({ url }) => {...}, - * input: ({ url }) => {...}, - * }) - * }) * ``` * @default '/' * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#basepath-property) @@ -472,8 +460,8 @@ export interface RouterOptions< * Configures how the router will rewrite the location between the actual href and the internal href of the router. * * @default undefined - * @description You can provide a custom rewrite pair (in/out) or use the utilities like `rewriteBasepath` as a convenience for common use cases, or even do both! - * This is useful for basepath rewriting, shifting data from the origin to the path (for things like ) + * @description You can provide a custom rewrite pair (in/out). + * This is useful for shifting data from the origin to the path (for things like subdomain routing), or other advanced use cases. */ rewrite?: LocationRewrite origin?: string @@ -485,14 +473,12 @@ export interface RouterOptions< export type LocationRewrite = { /** * A function that will be called to rewrite the URL before it is interpreted by the router from the history instance. - * Utilities like `rewriteBasepath` are provided as a convenience for common use cases. * * @default undefined */ input?: LocationRewriteFunction /** * A function that will be called to rewrite the URL before it is committed to the actual history instance from the router. - * Utilities like `rewriteBasepath` are provided as a convenience for common use cases. * * @default undefined */ @@ -884,7 +870,6 @@ export class RouterCore< rewrite?: LocationRewrite origin?: string latestLocation!: ParsedLocation> - // @deprecated - basepath functionality is now implemented via the `rewrite` option basepath!: string routeTree!: TRouteTree routesById!: RoutesById @@ -976,19 +961,6 @@ export class RouterCore< this.history = this.options.history } } - // For backwards compatibility, we support a basepath option, which we now implement as a rewrite - if (this.options.basepath) { - const basepathRewrite = rewriteBasepath({ - basepath: this.options.basepath, - }) - if (this.options.rewrite) { - this.rewrite = composeRewrites([basepathRewrite, this.options.rewrite]) - } else { - this.rewrite = basepathRewrite - } - } else { - this.rewrite = this.options.rewrite - } this.origin = this.options.origin if (!this.origin) { @@ -999,6 +971,7 @@ export class RouterCore< this.origin = 'http://localhost' } } + if (this.history) { this.updateLatestLocation() } @@ -1023,6 +996,34 @@ export class RouterCore< setupScrollRestoration(this) } + let needsLocationUpdate = false + if (this.basepath !== this.options.basepath) { + needsLocationUpdate = true + if (this.options.basepath) { + this.basepath = this.options.basepath + const basepathRewrite = rewriteBasepath({ + basepath: this.basepath, + }) + if (this.options.rewrite) { + this.rewrite = composeRewrites([ + basepathRewrite, + this.options.rewrite, + ]) + } else { + this.rewrite = basepathRewrite + } + } + } else if (this.options.rewrite !== this.rewrite) { + needsLocationUpdate = true + this.rewrite = this.options.rewrite + } + if (needsLocationUpdate) { + this.__store.state = { + ...this.state, + location: this.latestLocation, + } + } + if ( typeof window !== 'undefined' && 'CSS' in window && diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index 44481e953ba..070216b839e 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -352,7 +352,7 @@ export { ScriptOnce } from './ScriptOnce' export { Asset } from './Asset' export { HeadContent, useTags } from './HeadContent' export { Scripts } from './Scripts' -export { rewriteBasepath, composeRewrites } from '@tanstack/router-core' +export { composeRewrites } from '@tanstack/router-core' export type { LocationRewrite, LocationRewriteFunction, diff --git a/packages/start-client-core/src/client-rpc/createClientRpc.ts b/packages/start-client-core/src/client-rpc/createClientRpc.ts index 334f436ac7e..aed2cf62e9f 100644 --- a/packages/start-client-core/src/client-rpc/createClientRpc.ts +++ b/packages/start-client-core/src/client-rpc/createClientRpc.ts @@ -1,20 +1,8 @@ import { TSS_SERVER_FUNCTION } from '../constants' import { serverFnFetcher } from './serverFnFetcher' -// make sure this get's hoisted -// eslint-disable-next-line no-var -var baseUrl: string -function sanitizeBase(base: string) { - return base.replace(/^\/|\/$/g, '') -} - export function createClientRpc(functionId: string) { - if (!baseUrl) { - const sanitizedAppBase = sanitizeBase(process.env.TSS_APP_BASE || '/') - const sanitizedServerBase = sanitizeBase(process.env.TSS_SERVER_FN_BASE!) - baseUrl = `${sanitizedAppBase ? `/${sanitizedAppBase}` : ''}/${sanitizedServerBase}/` - } - const url = baseUrl + functionId + const url = process.env.TSS_SERVER_FN_BASE + functionId const clientFn = (...args: Array) => { return serverFnFetcher(url, args, fetch) diff --git a/packages/start-client-core/src/client/hydrateStart.ts b/packages/start-client-core/src/client/hydrateStart.ts index d6d43bd4dd1..6f51ca03f68 100644 --- a/packages/start-client-core/src/client/hydrateStart.ts +++ b/packages/start-client-core/src/client/hydrateStart.ts @@ -25,8 +25,11 @@ export async function hydrateStart(): Promise { } serializationAdapters.push(ServerFunctionSerializationAdapter) - router.options.serializationAdapters = serializationAdapters + router.update({ + basepath: process.env.TSS_ROUTER_BASEPATH, + ...{ serializationAdapters }, + }) if (!router.state.matches.length) { await hydrate(router) } diff --git a/packages/start-plugin-core/src/global.d.ts b/packages/start-plugin-core/src/global.d.ts index 878351c9665..f395eaca355 100644 --- a/packages/start-plugin-core/src/global.d.ts +++ b/packages/start-plugin-core/src/global.d.ts @@ -2,7 +2,6 @@ import type { Manifest } from '@tanstack/router-core' /* eslint-disable no-var */ declare global { - var TSS_APP_BASE: string var TSS_ROUTES_MANIFEST: Manifest } export {} diff --git a/packages/start-plugin-core/src/plugin.ts b/packages/start-plugin-core/src/plugin.ts index ab0a26aa05f..3a028cdb9c7 100644 --- a/packages/start-plugin-core/src/plugin.ts +++ b/packages/start-plugin-core/src/plugin.ts @@ -1,4 +1,4 @@ -import { trimPathRight } from '@tanstack/router-core' +import { joinPaths, trimPathRight } from '@tanstack/router-core' import { VIRTUAL_MODULES } from '@tanstack/start-server-core' import { TanStackServerFnPluginEnv } from '@tanstack/server-functions-plugin' import * as vite from 'vite' @@ -41,6 +41,7 @@ export interface ResolvedStartConfig { startFilePath: string | undefined routerFilePath: string srcDirectory: string + viteAppBase: string } export type GetConfigFn = () => { @@ -56,6 +57,7 @@ export function TanStackStartVitePluginCore( startFilePath: undefined, routerFilePath: '', srcDirectory: '', + viteAppBase: '', } let startConfig: TanStackStartOutputConfig | null @@ -90,13 +92,25 @@ export function TanStackStartVitePluginCore( name: 'tanstack-start-core:config', enforce: 'pre', async config(viteConfig, { command }) { - const viteAppBase = trimPathRight(viteConfig.base || '/') - globalThis.TSS_APP_BASE = viteAppBase - + resolvedStartConfig.viteAppBase = trimPathRight(viteConfig.base || '/') const root = viteConfig.root || process.cwd() resolvedStartConfig.root = root const { startConfig } = getConfig() + if (startConfig.router.basepath === undefined) { + startConfig.router.basepath = resolvedStartConfig.viteAppBase + } + startConfig.router.basepath = startConfig.router.basepath.replace( + /^\/|\/$/g, + '', + ) + + const TSS_SERVER_FN_BASE = joinPaths([ + '/', + startConfig.router.basepath, + startConfig.serverFns.base, + '/', + ]) const resolvedSrcDirectory = join(root, startConfig.srcDirectory) resolvedStartConfig.srcDirectory = resolvedSrcDirectory @@ -179,7 +193,6 @@ export function TanStackStartVitePluginCore( }) return { - base: viteAppBase, // see https://vite.dev/config/shared-options.html#apptype // this will prevent vite from injecting middlewares that we don't want appType: viteConfig.appType ?? 'custom', @@ -246,9 +259,9 @@ export function TanStackStartVitePluginCore( // i.e: __FRAMEWORK_NAME__ can be replaced with JSON.stringify("TanStack Start") // This is not the same as injecting environment variables. - ...defineReplaceEnv('TSS_SERVER_FN_BASE', startConfig.serverFns.base), + ...defineReplaceEnv('TSS_SERVER_FN_BASE', TSS_SERVER_FN_BASE), ...defineReplaceEnv('TSS_CLIENT_OUTPUT_DIR', getClientOutputDirectory(viteConfig)), - ...defineReplaceEnv('TSS_APP_BASE', viteAppBase), + ...defineReplaceEnv('TSS_ROUTER_BASEPATH', startConfig.router.basepath), ...(command === 'serve' ? defineReplaceEnv('TSS_SHELL', startConfig.spa?.enabled ? 'true' : 'false') : {}), ...defineReplaceEnv('TSS_DEV_SERVER', command === 'serve' ? 'true' : 'false'), }, @@ -306,6 +319,7 @@ export function TanStackStartVitePluginCore( loadEnvPlugin(), startManifestPlugin({ getClientBundle: () => getBundle(VITE_ENVIRONMENT_NAMES.client), + getConfig, }), devServerPlugin({ getConfig }), { diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index 095ab5fc486..5ff62310478 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -138,6 +138,8 @@ export async function prerender({ ...page.prerender, } + const routerBasePath = startConfig.router.basepath || '/' + // Add the task queue.add(async () => { logger.info(`Crawling: ${page.path}`) @@ -146,7 +148,7 @@ export async function prerender({ // Fetch the route const encodedRoute = encodeURI(page.path) - const res = await localFetch(withBase(encodedRoute, TSS_APP_BASE), { + const res = await localFetch(withBase(encodedRoute, routerBasePath), { headers: { ...prerenderOptions.headers, }, @@ -179,7 +181,7 @@ export async function prerender({ const filename = withoutBase( isImplicitHTML ? htmlPath : routeWithIndex, - TSS_APP_BASE, + routerBasePath, ) const html = await res.text() diff --git a/packages/start-plugin-core/src/schema.ts b/packages/start-plugin-core/src/schema.ts index 8c5331c3c36..427035dbc97 100644 --- a/packages/start-plugin-core/src/schema.ts +++ b/packages/start-plugin-core/src/schema.ts @@ -138,6 +138,7 @@ const tanstackStartOptionsSchema = z router: z .object({ entry: z.string().optional(), + basepath: z.string().optional(), }) .and(tsrConfig.optional().default({})) .optional() diff --git a/packages/start-plugin-core/src/start-manifest-plugin/plugin.ts b/packages/start-plugin-core/src/start-manifest-plugin/plugin.ts index 1e66685a2d0..bcf0ed73956 100644 --- a/packages/start-plugin-core/src/start-manifest-plugin/plugin.ts +++ b/packages/start-plugin-core/src/start-manifest-plugin/plugin.ts @@ -4,6 +4,7 @@ import { VIRTUAL_MODULES } from '@tanstack/start-server-core' import { tsrSplit } from '@tanstack/router-plugin' import { resolveViteId } from '../utils' import { ENTRY_POINTS } from '../constants' +import type { GetConfigFn } from '../plugin' import type { PluginOption, Rollup } from 'vite' import type { RouterManagedTag } from '@tanstack/router-core' @@ -42,6 +43,7 @@ export const getCSSRecursively = ( const resolvedModuleId = resolveViteId(VIRTUAL_MODULES.startManifest) export function startManifestPlugin(opts: { getClientBundle: () => Rollup.OutputBundle + getConfig: GetConfigFn }): PluginOption { return { name: 'tanstack-start:start-manifest-plugin', @@ -60,6 +62,7 @@ export function startManifestPlugin(opts: { id: new RegExp(resolvedModuleId), }, handler(id) { + const { resolvedStartConfig } = opts.getConfig() if (id === resolvedModuleId) { if (this.environment.config.consumer !== 'server') { // this will ultimately fail the build if the plugin is used outside the server environment @@ -67,14 +70,11 @@ export function startManifestPlugin(opts: { return `export default {}` } - // This is the basepath for the application - const APP_BASE = globalThis.TSS_APP_BASE - // If we're in development, return a dummy manifest if (this.environment.config.command === 'serve') { return `export const tsrStartManifest = () => ({ routes: {}, - clientEntry: '${joinURL(APP_BASE, '@id', ENTRY_POINTS.client)}', + clientEntry: '${joinURL(resolvedStartConfig.viteAppBase, '@id', ENTRY_POINTS.client)}', })` } @@ -146,19 +146,21 @@ export function startManifestPlugin(opts: { // Map the relevant imports to their route paths, // so that it can be imported in the browser. const preloads = chunk.imports.map((d) => { - const assetPath = joinURL(APP_BASE, d) + const assetPath = joinURL(resolvedStartConfig.viteAppBase, d) return assetPath }) // Since this is the most important JS entry for the route, // it should be moved to the front of the preloads so that // it has the best chance of being loaded first. - preloads.unshift(joinURL(APP_BASE, chunk.fileName)) + preloads.unshift( + joinURL(resolvedStartConfig.viteAppBase, chunk.fileName), + ) const cssAssetsList = getCSSRecursively( chunk, chunksByFileName, - APP_BASE, + resolvedStartConfig.viteAppBase, ) routeTreeRoutes[routeId] = { @@ -174,8 +176,10 @@ export function startManifestPlugin(opts: { throw new Error('No entry file found') } routeTreeRoutes[rootRouteId]!.preloads = [ - joinURL(APP_BASE, entryFile.fileName), - ...entryFile.imports.map((d) => joinURL(APP_BASE, d)), + joinURL(resolvedStartConfig.viteAppBase, entryFile.fileName), + ...entryFile.imports.map((d) => + joinURL(resolvedStartConfig.viteAppBase, d), + ), ] // Gather all the CSS files from the entry file in @@ -183,7 +187,7 @@ export function startManifestPlugin(opts: { const entryCssAssetsList = getCSSRecursively( entryFile, chunksByFileName, - APP_BASE, + resolvedStartConfig.viteAppBase, ) routeTreeRoutes[rootRouteId]!.assets = [ @@ -218,7 +222,10 @@ export function startManifestPlugin(opts: { const startManifest = { routes: routeTreeRoutes, - clientEntry: joinURL(APP_BASE, entryFile.fileName), + clientEntry: joinURL( + resolvedStartConfig.viteAppBase, + entryFile.fileName, + ), } return `export const tsrStartManifest = () => (${JSON.stringify(startManifest)})` diff --git a/packages/start-server-core/src/createServerRpc.ts b/packages/start-server-core/src/createServerRpc.ts index 6ec52126ca2..dea1b875542 100644 --- a/packages/start-server-core/src/createServerRpc.ts +++ b/packages/start-server-core/src/createServerRpc.ts @@ -1,26 +1,10 @@ import { TSS_SERVER_FUNCTION } from '@tanstack/start-client-core' -import invariant from 'tiny-invariant' - -let baseUrl: string -function sanitizeBase(base: string) { - return base.replace(/^\/|\/$/g, '') -} export const createServerRpc = ( functionId: string, splitImportFn: (...args: any) => any, ) => { - if (!baseUrl) { - const sanitizedAppBase = sanitizeBase(process.env.TSS_APP_BASE || '/') - const sanitizedServerBase = sanitizeBase(process.env.TSS_SERVER_FN_BASE!) - baseUrl = `${sanitizedAppBase ? `/${sanitizedAppBase}` : ''}/${sanitizedServerBase}/` - } - invariant( - splitImportFn, - '🚨splitImportFn required for the server functions server runtime, but was not provided.', - ) - - const url = baseUrl + functionId + const url = process.env.TSS_SERVER_FN_BASE + functionId return Object.assign(splitImportFn, { url, diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 427b412e694..48a180ea123 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -8,8 +8,6 @@ import { executeRewriteInput, isRedirect, isResolvedRedirect, - joinPaths, - trimPath, } from '@tanstack/router-core' import { attachRouterServerSsrUtils, @@ -56,19 +54,7 @@ function getStartResponseHeaders(opts: { router: AnyRouter }) { export function createStartHandler( cb: HandlerCallback, ): RequestHandler { - if (!process.env.TSS_SERVER_FN_BASE) { - throw new Error( - 'tanstack/start-server-core: TSS_SERVER_FN_BASE must be defined in your environment for createStartHandler()', - ) - } - // TODO do we remove this? - const APP_BASE = process.env.TSS_APP_BASE || '/' - // Add trailing slash to sanitise user defined TSS_SERVER_FN_BASE - const serverFnBase = joinPaths([ - APP_BASE, - trimPath(process.env.TSS_SERVER_FN_BASE), - '/', - ]) + const ROUTER_BASEPATH = process.env.TSS_ROUTER_BASEPATH || '/' let startRoutesManifest: Manifest | null = null let startEntry: StartEntry | null = null let routerEntry: RouterEntry | null = null @@ -134,7 +120,6 @@ export function createStartHandler( let router: AnyRouter | null = null const getRouter = async () => { if (router) return router - // TODO how does this work with base path? does the router need to be configured the same as APP_BASE? router = await (await getEntries()).routerEntry.getRouter() // Update the client-side router with the history @@ -162,6 +147,7 @@ export function createStartHandler( defaultSsr: startOptions.defaultSsr, serializationAdapters: startOptions.serializationAdapters, }, + basepath: ROUTER_BASEPATH, }) return router } @@ -185,7 +171,7 @@ export function createStartHandler( async () => { try { // First, let's attempt to handle server functions - if (href.startsWith(serverFnBase)) { + if (href.startsWith(process.env.TSS_SERVER_FN_BASE)) { return await handleServerAction({ request, context: requestOpts?.context, @@ -222,9 +208,7 @@ export function createStartHandler( // if the startRoutesManifest is not loaded yet, load it once if (startRoutesManifest === null) { - startRoutesManifest = await getStartManifest({ - basePath: APP_BASE, - }) + startRoutesManifest = await getStartManifest() } const router = await getRouter() attachRouterServerSsrUtils({ diff --git a/packages/start-server-core/src/global.d.ts b/packages/start-server-core/src/global.d.ts index 49f8db44693..dd9048293ef 100644 --- a/packages/start-server-core/src/global.d.ts +++ b/packages/start-server-core/src/global.d.ts @@ -1,8 +1,8 @@ declare global { namespace NodeJS { interface ProcessEnv { - TSS_APP_BASE?: string - TSS_SERVER_FN_BASE?: string + TSS_ROUTER_BASEPATH: string + TSS_SERVER_FN_BASE: string TSS_CLIENT_OUTPUT_DIR?: string TSS_SHELL?: 'true' | 'false' TSS_PRERENDERING?: 'true' | 'false' diff --git a/packages/start-server-core/src/request-response.ts b/packages/start-server-core/src/request-response.ts index cf0e426db9c..3141e7a92c7 100644 --- a/packages/start-server-core/src/request-response.ts +++ b/packages/start-server-core/src/request-response.ts @@ -15,7 +15,6 @@ import { sanitizeStatusMessage as h3_sanitizeStatusMessage, sealSession as h3_sealSession, setCookie as h3_setCookie, - toResponse as h3_toResponse, unsealSession as h3_unsealSession, updateSession as h3_updateSession, useSession as h3_useSession, @@ -55,7 +54,7 @@ export function requestHandler( const response = eventStorage.run({ h3Event }, () => handler(request, requestOpts), ) - return h3_toResponse(response, h3Event) + return response // h3_toResponse(response, h3Event) } } diff --git a/packages/start-server-core/src/router-manifest.ts b/packages/start-server-core/src/router-manifest.ts index a10e87965a8..9f36b2fa361 100644 --- a/packages/start-server-core/src/router-manifest.ts +++ b/packages/start-server-core/src/router-manifest.ts @@ -8,7 +8,7 @@ import { loadVirtualModule } from './loadVirtualModule' * special assets that are needed for the client. It does not include relationships * between routes or any other data that is not needed for the client. */ -export async function getStartManifest(opts: { basePath: string }) { +export async function getStartManifest() { const { tsrStartManifest } = await loadVirtualModule( VIRTUAL_MODULES.startManifest, ) diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index d6beb47ac98..fa0aa995b88 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -10,15 +10,7 @@ import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval' import { getResponse } from './request-response' import { getServerFnById } from './getServerFnById' -function sanitizeBase(base: string | undefined) { - if (!base) { - throw new Error( - '🚨 process.env.TSS_SERVER_FN_BASE is required in start/server-handler/index', - ) - } - - return base.replace(/^\/|\/$/g, '') -} +let regex: RegExp | undefined = undefined export const handleServerAction = async ({ request, @@ -32,13 +24,14 @@ export const handleServerAction = async ({ const abort = () => controller.abort() request.signal.addEventListener('abort', abort) + if (regex === undefined) { + regex = new RegExp(`${process.env.TSS_SERVER_FN_BASE}([^/?#]+)`) + } + const method = request.method const url = new URL(request.url, 'http://localhost:3000') // extract the serverFnId from the url as host/_serverFn/:serverFnId // Define a regex to match the path and extract the :thing part - const regex = new RegExp( - `${sanitizeBase(process.env.TSS_SERVER_FN_BASE)}/([^/?#]+)`, - ) // Execute the regex const match = url.pathname.match(regex) From 16a544377564515ed5597315dc157422c92a6000 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 3 Oct 2025 01:06:16 +0200 Subject: [PATCH 2/9] example --- examples/react/start-basic/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/react/start-basic/vite.config.ts b/examples/react/start-basic/vite.config.ts index 9291b5563d4..797d124a2f9 100644 --- a/examples/react/start-basic/vite.config.ts +++ b/examples/react/start-basic/vite.config.ts @@ -4,6 +4,7 @@ import tsConfigPaths from 'vite-tsconfig-paths' import viteReact from '@vitejs/plugin-react' export default defineConfig({ + base: '/foo/', server: { port: 3000, }, From f8e36da7e302fef2abc5152aef4ac7213f07ef1f Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 3 Oct 2025 01:24:15 +0200 Subject: [PATCH 3/9] fix update logic --- packages/router-core/src/router.ts | 57 ++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 1f52dbd6c2f..46c52c9ec38 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -933,8 +933,13 @@ export class RouterCore< ) } + const prevOptions = this.options + const prevBasepath = this.basepath ?? prevOptions?.basepath ?? '/' + const basepathWasUnset = this.basepath === undefined + const prevRewriteOption = prevOptions?.rewrite + this.options = { - ...this.options, + ...prevOptions, ...newOptions, } @@ -997,27 +1002,41 @@ export class RouterCore< } let needsLocationUpdate = false - if (this.basepath !== this.options.basepath) { - needsLocationUpdate = true - if (this.options.basepath) { - this.basepath = this.options.basepath - const basepathRewrite = rewriteBasepath({ - basepath: this.basepath, - }) - if (this.options.rewrite) { - this.rewrite = composeRewrites([ - basepathRewrite, - this.options.rewrite, - ]) - } else { - this.rewrite = basepathRewrite - } + const nextBasepath = this.options.basepath ?? '/' + const nextRewriteOption = this.options.rewrite + const basepathChanged = basepathWasUnset || prevBasepath !== nextBasepath + const rewriteChanged = prevRewriteOption !== nextRewriteOption + + if (basepathChanged || rewriteChanged) { + this.basepath = nextBasepath + + const rewrites: Array = [] + if (trimPath(nextBasepath) !== '') { + rewrites.push( + rewriteBasepath({ + basepath: nextBasepath, + }), + ) } - } else if (this.options.rewrite !== this.rewrite) { + if (nextRewriteOption) { + rewrites.push(nextRewriteOption) + } + + this.rewrite = + rewrites.length === 0 + ? undefined + : rewrites.length === 1 + ? rewrites[0] + : composeRewrites(rewrites) + + if (this.history) { + this.updateLatestLocation() + } + needsLocationUpdate = true - this.rewrite = this.options.rewrite } - if (needsLocationUpdate) { + + if (needsLocationUpdate && this.__store) { this.__store.state = { ...this.state, location: this.latestLocation, From be555fbd40fe7c6cd2a73af6375284d05f6f6712 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 3 Oct 2025 01:31:18 +0200 Subject: [PATCH 4/9] fix prerender --- packages/start-plugin-core/src/prerender.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index 5ff62310478..b2e27d77e74 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -138,7 +138,7 @@ export async function prerender({ ...page.prerender, } - const routerBasePath = startConfig.router.basepath || '/' + const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') // Add the task queue.add(async () => { From 83733a1e184d2dff4f0da04e8c4d3a01aa08510a Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 3 Oct 2025 13:37:05 +0200 Subject: [PATCH 5/9] formatting --- packages/react-router/tests/router.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 2b77f797394..90be8b8db29 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -2986,7 +2986,7 @@ describe('basepath', () => { const router = createRouter({ routeTree, history, - basepath: 'my-app' + basepath: 'my-app', }) render() From c13cffe770eb4b080a2ac61690c56f483d2de4c5 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 3 Oct 2025 13:44:53 +0200 Subject: [PATCH 6/9] only inherit `base` if not a full url apply the same to nitro-vite2-plugin --- packages/nitro-v2-vite-plugin/src/index.ts | 25 ++++++++++++++--- packages/start-plugin-core/src/plugin.ts | 32 +++++++++++++++++----- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/nitro-v2-vite-plugin/src/index.ts b/packages/nitro-v2-vite-plugin/src/index.ts index 541fb868e54..b1c4f7a3234 100644 --- a/packages/nitro-v2-vite-plugin/src/index.ts +++ b/packages/nitro-v2-vite-plugin/src/index.ts @@ -1,13 +1,23 @@ import { build, copyPublicAssets, createNitro, prepare } from 'nitropack' import { dirname, resolve } from 'pathe' -import type { PluginOption, Rollup } from 'vite' +import type { PluginOption, ResolvedConfig, Rollup } from 'vite' import type { NitroConfig } from 'nitropack' let ssrBundle: Rollup.OutputBundle let ssrEntryFile: string +function isFullUrl(str: string): boolean { + try { + new URL(str) + return true + } catch { + return false + } +} + export function nitroV2Plugin(nitroConfig?: NitroConfig): Array { + let resolvedConfig: ResolvedConfig return [ { name: 'tanstack-nitro-v2-vite-plugin', @@ -42,7 +52,10 @@ export function nitroV2Plugin(nitroConfig?: NitroConfig): Array { }, }, - async config(_, env) { + configResolved(config) { + resolvedConfig = config + }, + config(_, env) { if (env.command !== 'build') { return } @@ -81,15 +94,19 @@ export function nitroV2Plugin(nitroConfig?: NitroConfig): Array { await builder.build(server) const virtualEntry = '#tanstack/start/entry' + const baseURL = !isFullUrl(resolvedConfig.base) + ? resolvedConfig.base + : undefined const config: NitroConfig = { - ...nitroConfig, + baseURL, publicAssets: [ { dir: client.config.build.outDir, - baseURL: '/', maxAge: 31536000, // 1 year + baseURL: '/', }, ], + ...nitroConfig, renderer: virtualEntry, rollupConfig: { ...nitroConfig?.rollupConfig, diff --git a/packages/start-plugin-core/src/plugin.ts b/packages/start-plugin-core/src/plugin.ts index 3a028cdb9c7..eb144d9274d 100644 --- a/packages/start-plugin-core/src/plugin.ts +++ b/packages/start-plugin-core/src/plugin.ts @@ -1,4 +1,4 @@ -import { joinPaths, trimPathRight } from '@tanstack/router-core' +import { joinPaths } from '@tanstack/router-core' import { VIRTUAL_MODULES } from '@tanstack/start-server-core' import { TanStackServerFnPluginEnv } from '@tanstack/server-functions-plugin' import * as vite from 'vite' @@ -48,6 +48,16 @@ export type GetConfigFn = () => { startConfig: TanStackStartOutputConfig resolvedStartConfig: ResolvedStartConfig } + +function isFullUrl(str: string): boolean { + try { + new URL(str) + return true + } catch { + return false + } +} + export function TanStackStartVitePluginCore( corePluginOpts: TanStackStartVitePluginCoreOptions, startPluginOpts: TanStackStartInputConfig, @@ -92,18 +102,26 @@ export function TanStackStartVitePluginCore( name: 'tanstack-start-core:config', enforce: 'pre', async config(viteConfig, { command }) { - resolvedStartConfig.viteAppBase = trimPathRight(viteConfig.base || '/') + resolvedStartConfig.viteAppBase = viteConfig.base ?? '/' + if (!isFullUrl(resolvedStartConfig.viteAppBase)) { + resolvedStartConfig.viteAppBase = joinPaths([ + '/', + viteConfig.base, + '/', + ]) + } const root = viteConfig.root || process.cwd() resolvedStartConfig.root = root const { startConfig } = getConfig() if (startConfig.router.basepath === undefined) { - startConfig.router.basepath = resolvedStartConfig.viteAppBase + if (!isFullUrl(resolvedStartConfig.viteAppBase)) { + startConfig.router.basepath = + resolvedStartConfig.viteAppBase.replace(/^\/|\/$/g, '') + } else { + startConfig.router.basepath = '/' + } } - startConfig.router.basepath = startConfig.router.basepath.replace( - /^\/|\/$/g, - '', - ) const TSS_SERVER_FN_BASE = joinPaths([ '/', From 9f71f75c72cfefbf28842e7b03185daebcbfd4c1 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 3 Oct 2025 14:21:17 +0200 Subject: [PATCH 7/9] calculate routerBasePath only once --- packages/start-plugin-core/src/prerender.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index b2e27d77e74..a11216416c0 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -107,6 +107,7 @@ export async function prerender({ const concurrency = startConfig.prerender?.concurrency ?? os.cpus().length logger.info(`Concurrency: ${concurrency}`) const queue = new Queue({ concurrency }) + const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') startConfig.pages.forEach((page) => addCrawlPageTask(page)) @@ -138,8 +139,6 @@ export async function prerender({ ...page.prerender, } - const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') - // Add the task queue.add(async () => { logger.info(`Crawling: ${page.path}`) From 3e225196bee8a5fc21af93f0e3d36474cd5c76c3 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 3 Oct 2025 14:40:52 +0200 Subject: [PATCH 8/9] ensure base paths are aligned during dev --- packages/start-plugin-core/src/plugin.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/start-plugin-core/src/plugin.ts b/packages/start-plugin-core/src/plugin.ts index eb144d9274d..070c82731b1 100644 --- a/packages/start-plugin-core/src/plugin.ts +++ b/packages/start-plugin-core/src/plugin.ts @@ -121,6 +121,19 @@ export function TanStackStartVitePluginCore( } else { startConfig.router.basepath = '/' } + } else { + if (command === 'serve' && !viteConfig.server?.middlewareMode) { + // when serving, we must ensure that router basepath and viteAppBase are aligned + if ( + !joinPaths(['/', startConfig.router.basepath, '/']).startsWith( + joinPaths(['/', resolvedStartConfig.viteAppBase, '/']), + ) + ) { + this.error( + '[tanstack-start]: During `vite dev`, `router.basepath` must start with the vite `base` config value', + ) + } + } } const TSS_SERVER_FN_BASE = joinPaths([ From 7b1b6847e20fd2bd88385cbbf592c5ce24571aba Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 3 Oct 2025 14:51:15 +0200 Subject: [PATCH 9/9] revert unrelated change --- packages/start-server-core/src/request-response.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/start-server-core/src/request-response.ts b/packages/start-server-core/src/request-response.ts index 3141e7a92c7..cf0e426db9c 100644 --- a/packages/start-server-core/src/request-response.ts +++ b/packages/start-server-core/src/request-response.ts @@ -15,6 +15,7 @@ import { sanitizeStatusMessage as h3_sanitizeStatusMessage, sealSession as h3_sealSession, setCookie as h3_setCookie, + toResponse as h3_toResponse, unsealSession as h3_unsealSession, updateSession as h3_updateSession, useSession as h3_useSession, @@ -54,7 +55,7 @@ export function requestHandler( const response = eventStorage.run({ h3Event }, () => handler(request, requestOpts), ) - return response // h3_toResponse(response, h3Event) + return h3_toResponse(response, h3Event) } }