diff --git a/e2e/react-start/basic/src/routes/users.$userId.tsx b/e2e/react-start/basic/src/routes/users.$userId.tsx index e293be37317..59ddc3df873 100644 --- a/e2e/react-start/basic/src/routes/users.$userId.tsx +++ b/e2e/react-start/basic/src/routes/users.$userId.tsx @@ -1,5 +1,6 @@ import { ErrorComponent, createFileRoute } from '@tanstack/react-router' import axios from 'redaxios' +import { getRouterInstance } from '@tanstack/react-start' import type { ErrorComponentProps } from '@tanstack/react-router' import type { User } from '~/utils/users' @@ -7,8 +8,9 @@ import { NotFound } from '~/components/NotFound' export const Route = createFileRoute('/users/$userId')({ loader: async ({ params: { userId } }) => { + const router = await getRouterInstance() return await axios - .get('/api/users/' + userId) + .get('/api/users/' + userId, { baseURL: router.options.origin }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch user') diff --git a/e2e/react-start/basic/src/routes/users.tsx b/e2e/react-start/basic/src/routes/users.tsx index 7b08d616527..f7a57210971 100644 --- a/e2e/react-start/basic/src/routes/users.tsx +++ b/e2e/react-start/basic/src/routes/users.tsx @@ -1,12 +1,14 @@ import { Link, Outlet, createFileRoute } from '@tanstack/react-router' +import { getRouterInstance } from '@tanstack/react-start' import axios from 'redaxios' import type { User } from '~/utils/users' export const Route = createFileRoute('/users')({ loader: async () => { + const router = await getRouterInstance() return await axios - .get>('/api/users') + .get>('/api/users', { baseURL: router.options.origin }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch users') diff --git a/e2e/react-start/basic/tests/prerendering.spec.ts b/e2e/react-start/basic/tests/prerendering.spec.ts index 9a3ec145dc4..7718fe86ef0 100644 --- a/e2e/react-start/basic/tests/prerendering.spec.ts +++ b/e2e/react-start/basic/tests/prerendering.spec.ts @@ -14,7 +14,6 @@ test.describe('Prerender Static Path Discovery', () => { // These static routes should be automatically discovered and prerendered expect(existsSync(join(distDir, 'index.html'))).toBe(true) expect(existsSync(join(distDir, 'posts/index.html'))).toBe(true) - expect(existsSync(join(distDir, 'users/index.html'))).toBe(true) expect(existsSync(join(distDir, 'deferred/index.html'))).toBe(true) expect(existsSync(join(distDir, 'scripts/index.html'))).toBe(true) expect(existsSync(join(distDir, 'inline-scripts/index.html'))).toBe(true) @@ -40,14 +39,5 @@ test.describe('Prerender Static Path Discovery', () => { const html = readFileSync(join(distDir, 'posts/index.html'), 'utf-8') expect(html).toContain('Select a post.') }) - - test('should contain prerendered content in users.html', () => { - const distDir = join(process.cwd(), 'dist', 'client') - expect(existsSync(join(distDir, 'users/index.html'))).toBe(true) - - // "Select a user." should be in the prerendered HTML - const html = readFileSync(join(distDir, 'users/index.html'), 'utf-8') - expect(html).toContain('Select a user.') - }) }) }) diff --git a/e2e/react-start/basic/vite.config.ts b/e2e/react-start/basic/vite.config.ts index c34468b880f..f9249150d92 100644 --- a/e2e/react-start/basic/vite.config.ts +++ b/e2e/react-start/basic/vite.config.ts @@ -21,6 +21,7 @@ const prerenderConfiguration = { '/i-do-not-exist', '/not-found/via-beforeLoad', '/not-found/via-loader', + '/users', ].some((p) => page.path.includes(p)), maxRedirects: 100, } @@ -33,7 +34,6 @@ export default defineConfig({ tsConfigPaths({ projects: ['./tsconfig.json'], }), - // @ts-ignore we want to keep one test with verboseFileRoutes off even though the option is hidden tanstackStart({ spa: isSpaMode ? spaModeConfiguration : undefined, prerender: isPrerender ? prerenderConfiguration : undefined, diff --git a/e2e/solid-start/basic/src/routes/users.$userId.tsx b/e2e/solid-start/basic/src/routes/users.$userId.tsx index 4afb3bbe7cc..ccc37e78f5b 100644 --- a/e2e/solid-start/basic/src/routes/users.$userId.tsx +++ b/e2e/solid-start/basic/src/routes/users.$userId.tsx @@ -1,14 +1,16 @@ import { createFileRoute } from '@tanstack/solid-router' import axios from 'redaxios' +import { getRouterInstance } from '@tanstack/solid-start' import type { User } from '~/utils/users' import { NotFound } from '~/components/NotFound' import { UserErrorComponent } from '~/components/UserErrorComponent' export const Route = createFileRoute('/users/$userId')({ loader: async ({ params: { userId } }) => { + const router = await getRouterInstance() return await axios - .get('/api/users/' + userId) + .get('/api/users/' + userId, { baseURL: router.options.origin }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch user') diff --git a/e2e/solid-start/basic/src/routes/users.tsx b/e2e/solid-start/basic/src/routes/users.tsx index 18a79ca6642..65878a704ae 100644 --- a/e2e/solid-start/basic/src/routes/users.tsx +++ b/e2e/solid-start/basic/src/routes/users.tsx @@ -1,12 +1,14 @@ import { Link, Outlet, createFileRoute } from '@tanstack/solid-router' +import { getRouterInstance } from '@tanstack/solid-start' import axios from 'redaxios' import type { User } from '~/utils/users' export const Route = createFileRoute('/users')({ loader: async () => { + const router = await getRouterInstance() return await axios - .get>('/api/users') + .get>('/api/users', { baseURL: router.options.origin }) .then((r) => r.data) .catch(() => { throw new Error('Failed to fetch users') diff --git a/e2e/solid-start/basic/tests/prerendering.spec.ts b/e2e/solid-start/basic/tests/prerendering.spec.ts index 9a3ec145dc4..02e26a14c46 100644 --- a/e2e/solid-start/basic/tests/prerendering.spec.ts +++ b/e2e/solid-start/basic/tests/prerendering.spec.ts @@ -15,7 +15,6 @@ test.describe('Prerender Static Path Discovery', () => { expect(existsSync(join(distDir, 'index.html'))).toBe(true) expect(existsSync(join(distDir, 'posts/index.html'))).toBe(true) expect(existsSync(join(distDir, 'users/index.html'))).toBe(true) - expect(existsSync(join(distDir, 'deferred/index.html'))).toBe(true) expect(existsSync(join(distDir, 'scripts/index.html'))).toBe(true) expect(existsSync(join(distDir, 'inline-scripts/index.html'))).toBe(true) expect(existsSync(join(distDir, '대한민국/index.html'))).toBe(true) @@ -40,14 +39,5 @@ test.describe('Prerender Static Path Discovery', () => { const html = readFileSync(join(distDir, 'posts/index.html'), 'utf-8') expect(html).toContain('Select a post.') }) - - test('should contain prerendered content in users.html', () => { - const distDir = join(process.cwd(), 'dist', 'client') - expect(existsSync(join(distDir, 'users/index.html'))).toBe(true) - - // "Select a user." should be in the prerendered HTML - const html = readFileSync(join(distDir, 'users/index.html'), 'utf-8') - expect(html).toContain('Select a user.') - }) }) }) diff --git a/e2e/solid-start/basic/vite.config.ts b/e2e/solid-start/basic/vite.config.ts index 8d83108574e..6af116d625b 100644 --- a/e2e/solid-start/basic/vite.config.ts +++ b/e2e/solid-start/basic/vite.config.ts @@ -23,6 +23,7 @@ const prerenderConfiguration = { '/not-found/via-loader', '/search-params/default', '/transition', + '/users', ].some((p) => page.path.includes(p)), maxRedirects: 100, } @@ -35,7 +36,6 @@ export default defineConfig({ tsConfigPaths({ projects: ['./tsconfig.json'], }), - // @ts-ignore we want to keep one test with verboseFileRoutes off even though the option is hidden tanstackStart({ spa: isSpaMode ? spaModeConfiguration : undefined, prerender: isPrerender ? prerenderConfiguration : undefined, diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index dec03fe80b8..ee2abcab437 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -35,13 +35,17 @@ type ReadablePassthrough = { destroyed: boolean } -function createPassthrough() { +function createPassthrough(onCancel?: () => void) { let controller: ReadableStreamDefaultController const encoder = new TextEncoder() const stream = new ReadableStream({ start(c) { controller = c }, + cancel() { + res.destroyed = true + onCancel?.() + }, }) const res: ReadablePassthrough = { @@ -96,7 +100,13 @@ export function transformStreamWithRouter( timeoutMs?: number }, ) { - const finalPassThrough = createPassthrough() + let stopListeningToInjectedHtml: (() => void) | undefined = undefined + let timeoutHandle: NodeJS.Timeout + + const finalPassThrough = createPassthrough(() => { + stopListeningToInjectedHtml?.() + clearTimeout(timeoutHandle) + }) const textDecoder = new TextDecoder() let isAppRendering = true as boolean @@ -105,7 +115,6 @@ export function transformStreamWithRouter( let streamBarrierLifted = false as boolean let leftover = '' let leftoverHtml = '' - let timeoutHandle: NodeJS.Timeout function getBufferedRouterStream() { const html = routerStreamBuffer @@ -130,12 +139,9 @@ export function transformStreamWithRouter( }) // Listen for any new injected HTML - const stopListeningToInjectedHtml = router.subscribe( - 'onInjectedHtml', - (e) => { - handleInjectedHtml(e.promise) - }, - ) + stopListeningToInjectedHtml = router.subscribe('onInjectedHtml', (e) => { + handleInjectedHtml(e.promise) + }) function handleInjectedHtml(promise: Promise) { processingCount++ @@ -170,7 +176,7 @@ export function transformStreamWithRouter( console.error('Error reading routerStream:', err) finalPassThrough.destroy(err) }) - .finally(stopListeningToInjectedHtml) + .finally(() => stopListeningToInjectedHtml?.()) // Transform the appStream readStream(appStream, { diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 08ce599e40c..65345148827 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -76,208 +76,225 @@ export function createStartHandler( } } - const originalFetch = globalThis.fetch - const startRequestResolver: RequestHandler = async ( request, requestOpts, ) => { - const origin = getOrigin(request) - - // Patching fetch function to use our request resolver - // if the input starts with `/` which is a common pattern for - // client-side routing. - // When we encounter similar requests, we can assume that the - // user wants to use the same origin as the current request. - globalThis.fetch = async function (input, init) { - function resolve(url: URL, requestOptions: RequestInit | undefined) { - const fetchRequest = new Request(url, requestOptions) - return startRequestResolver(fetchRequest, requestOpts) - } + let router: AnyRouter | null = null as AnyRouter | null + try { + const origin = getOrigin(request) - if (typeof input === 'string' && input.startsWith('/')) { - // e.g: fetch('/api/data') - const url = new URL(input, origin) - return resolve(url, init) - } else if ( - typeof input === 'object' && - 'url' in input && - typeof input.url === 'string' && - input.url.startsWith('/') - ) { - // e.g: fetch(new Request('/api/data')) - const url = new URL(input.url, origin) - return resolve(url, init) - } + const url = new URL(request.url) + const href = url.href.replace(url.origin, '') - // If not, it should just use the original fetch - return originalFetch(input, init) - } as typeof fetch - - const url = new URL(request.url) - const href = url.href.replace(url.origin, '') - - let router: AnyRouter | null = null - const getRouter = async () => { - if (router) return router - router = await (await getEntries()).routerEntry.getRouter() - - // Update the client-side router with the history - const isPrerendering = process.env.TSS_PRERENDERING === 'true' - // env var is set during dev is SPA mode is enabled - let isShell = process.env.TSS_SHELL === 'true' - if (isPrerendering && !isShell) { - // only read the shell header if we are prerendering - // to avoid runtime behavior changes by injecting this header - // the header is set by the prerender plugin - isShell = request.headers.get(HEADERS.TSS_SHELL) === 'true' - } + const startOptions: AnyStartInstanceOptions = + (await (await getEntries()).startEntry.startInstance?.getOptions()) || + ({} as AnyStartInstanceOptions) - // Create a history for the client-side router - const history = createMemoryHistory({ - initialEntries: [href], - }) + const serializationAdapters = [ + ...(startOptions.serializationAdapters || []), + ServerFunctionSerializationAdapter, + ] - router.update({ - history, - isShell, - isPrerendering, - origin: router.options.origin ?? origin, - ...{ - defaultSsr: startOptions.defaultSsr, - serializationAdapters: [ - ...(startOptions.serializationAdapters || []), - ...(router.options.serializationAdapters || []), - ], - }, - basepath: ROUTER_BASEPATH, - }) - return router - } + const requestStartOptions = { + ...startOptions, + serializationAdapters, + } - const startOptions: AnyStartInstanceOptions = - (await (await getEntries()).startEntry.startInstance?.getOptions()) || - ({} as AnyStartInstanceOptions) - startOptions.serializationAdapters = - startOptions.serializationAdapters || [] - // insert start specific default serialization adapters - startOptions.serializationAdapters.push(ServerFunctionSerializationAdapter) - - const requestHandlerMiddleware = handlerToMiddleware( - async ({ context }) => { - const response = await runWithStartContext( - { - getRouter, - startOptions, - contextAfterGlobalMiddlewares: context, - request, - }, - async () => { - try { - // First, let's attempt to handle server functions - if (href.startsWith(process.env.TSS_SERVER_FN_BASE)) { - return await handleServerAction({ - request, - context: requestOpts?.context, - }) - } + const getRouter = async () => { + if (router) return router + router = await (await getEntries()).routerEntry.getRouter() + + // Update the client-side router with the history + const isPrerendering = process.env.TSS_PRERENDERING === 'true' + // env var is set during dev is SPA mode is enabled + let isShell = process.env.TSS_SHELL === 'true' + if (isPrerendering && !isShell) { + // only read the shell header if we are prerendering + // to avoid runtime behavior changes by injecting this header + // the header is set by the prerender plugin + isShell = request.headers.get(HEADERS.TSS_SHELL) === 'true' + } - const executeRouter = async ({ - serverContext, - }: { - serverContext: any - }) => { - const requestAcceptHeader = - request.headers.get('Accept') || '*/*' - const splitRequestAcceptHeader = requestAcceptHeader.split(',') - - const supportedMimeTypes = ['*/*', 'text/html'] - const isRouterAcceptSupported = supportedMimeTypes.some( - (mimeType) => - splitRequestAcceptHeader.some((acceptedMimeType) => - acceptedMimeType.trim().startsWith(mimeType), - ), - ) - - if (!isRouterAcceptSupported) { - return json( - { - error: 'Only HTML requests are supported here', - }, - { - status: 500, - }, - ) - } + // Create a history for the client-side router + const history = createMemoryHistory({ + initialEntries: [href], + }) + + router.update({ + history, + isShell, + isPrerendering, + origin: router.options.origin ?? origin, + ...{ + defaultSsr: requestStartOptions.defaultSsr, + serializationAdapters: [ + ...requestStartOptions.serializationAdapters, + ...(router.options.serializationAdapters || []), + ], + }, + basepath: ROUTER_BASEPATH, + }) + return router + } - // if the startRoutesManifest is not loaded yet, load it once - if (startRoutesManifest === null) { - startRoutesManifest = await getStartManifest() + const requestHandlerMiddleware = handlerToMiddleware( + async ({ context }) => { + const response = await runWithStartContext( + { + getRouter, + startOptions: requestStartOptions, + contextAfterGlobalMiddlewares: context, + request, + }, + async () => { + try { + // First, let's attempt to handle server functions + if (href.startsWith(process.env.TSS_SERVER_FN_BASE)) { + return await handleServerAction({ + request, + context: requestOpts?.context, + }) } - const router = await getRouter() - attachRouterServerSsrUtils({ - router, - manifest: startRoutesManifest, - }) - router.update({ additionalContext: { serverContext } }) - await router.load() + const executeRouter = async ({ + serverContext, + }: { + serverContext: any + }) => { + const requestAcceptHeader = + request.headers.get('Accept') || '*/*' + const splitRequestAcceptHeader = + requestAcceptHeader.split(',') + + const supportedMimeTypes = ['*/*', 'text/html'] + const isRouterAcceptSupported = supportedMimeTypes.some( + (mimeType) => + splitRequestAcceptHeader.some((acceptedMimeType) => + acceptedMimeType.trim().startsWith(mimeType), + ), + ) - // If there was a redirect, skip rendering the page at all - if (router.state.redirect) { - return router.state.redirect + if (!isRouterAcceptSupported) { + return json( + { + error: 'Only HTML requests are supported here', + }, + { + status: 500, + }, + ) + } + + // if the startRoutesManifest is not loaded yet, load it once + if (startRoutesManifest === null) { + startRoutesManifest = await getStartManifest() + } + const router = await getRouter() + attachRouterServerSsrUtils({ + router, + manifest: startRoutesManifest, + }) + + router.update({ additionalContext: { serverContext } }) + await router.load() + + // If there was a redirect, skip rendering the page at all + if (router.state.redirect) { + return router.state.redirect + } + + await router.serverSsr!.dehydrate() + + const responseHeaders = getStartResponseHeaders({ router }) + const response = await cb({ + request, + router, + responseHeaders, + }) + + return response } - await router.serverSsr!.dehydrate() - - const responseHeaders = getStartResponseHeaders({ router }) - const response = await cb({ + const response = await handleServerRoutes({ + getRouter, request, - router, - responseHeaders, + executeRouter, + context, }) return response - } + } catch (err) { + if (err instanceof Response) { + return err + } - const response = await handleServerRoutes({ - getRouter, - request, - executeRouter, - context, - }) - - return response - } catch (err) { - if (err instanceof Response) { - return err + throw err } + }, + ) + return response + }, + ) + + const flattenedMiddlewares = startOptions.requestMiddleware + ? flattenMiddlewares(startOptions.requestMiddleware) + : [] + const middlewares = flattenedMiddlewares.map((d) => d.options.server) + const ctx = await executeMiddleware( + [...middlewares, requestHandlerMiddleware], + { + request, + + context: requestOpts?.context || {}, + }, + ) + + const response: Response = ctx.response + + if (isRedirect(response)) { + if (isResolvedRedirect(response)) { + if (request.headers.get('x-tsr-redirect') === 'manual') { + return json( + { + ...response.options, + isSerializedRedirect: true, + }, + { + headers: response.headers, + }, + ) + } + return response + } + if ( + response.options.to && + typeof response.options.to === 'string' && + !response.options.to.startsWith('/') + ) { + throw new Error( + `Server side redirects must use absolute paths via the 'href' or 'to' options. The redirect() method's "to" property accepts an internal path only. Use the "href" property to provide an external URL. Received: ${JSON.stringify(response.options)}`, + ) + } - throw err - } - }, - ) - return response - }, - ) - - const flattenedMiddlewares = startOptions.requestMiddleware - ? flattenMiddlewares(startOptions.requestMiddleware) - : [] - const middlewares = flattenedMiddlewares.map((d) => d.options.server) - const ctx = await executeMiddleware( - [...middlewares, requestHandlerMiddleware], - { - request, - - context: requestOpts?.context || {}, - }, - ) + if ( + ['params', 'search', 'hash'].some( + (d) => typeof (response.options as any)[d] === 'function', + ) + ) { + throw new Error( + `Server side redirects must use static search, params, and hash values and do not support functional values. Received functional values for: ${Object.keys( + response.options, + ) + .filter((d) => typeof (response.options as any)[d] === 'function') + .map((d) => `"${d}"`) + .join(', ')}`, + ) + } - const response: Response = ctx.response + const router = await getRouter() + const redirect = router.resolveRedirect(response) - if (isRedirect(response)) { - if (isResolvedRedirect(response)) { if (request.headers.get('x-tsr-redirect') === 'manual') { return json( { @@ -289,52 +306,16 @@ export function createStartHandler( }, ) } - return response - } - if ( - response.options.to && - typeof response.options.to === 'string' && - !response.options.to.startsWith('/') - ) { - throw new Error( - `Server side redirects must use absolute paths via the 'href' or 'to' options. The redirect() method's "to" property accepts an internal path only. Use the "href" property to provide an external URL. Received: ${JSON.stringify(response.options)}`, - ) - } - if ( - ['params', 'search', 'hash'].some( - (d) => typeof (response.options as any)[d] === 'function', - ) - ) { - throw new Error( - `Server side redirects must use static search, params, and hash values and do not support functional values. Received functional values for: ${Object.keys( - response.options, - ) - .filter((d) => typeof (response.options as any)[d] === 'function') - .map((d) => `"${d}"`) - .join(', ')}`, - ) + return redirect } - const router = await getRouter() - const redirect = router.resolveRedirect(response) - - if (request.headers.get('x-tsr-redirect') === 'manual') { - return json( - { - ...response.options, - isSerializedRedirect: true, - }, - { - headers: response.headers, - }, - ) + return response + } finally { + if (router) { + router = null } - - return redirect } - - return response } return requestHandler(startRequestResolver)