From 3275f8d71d598d570c0458fffee6e27316561dd5 Mon Sep 17 00:00:00 2001 From: jamcalli <48490664+jamcalli@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:53:07 -0700 Subject: [PATCH 1/5] Add @fastify/tanstack renderer for TanStack Router SSR --- examples/tanstack-vanilla-ts/package.json | 30 +++ .../tanstack-vanilla-ts/src/client/create.tsx | 19 ++ .../tanstack-vanilla-ts/src/client/index.html | 12 + .../tanstack-vanilla-ts/src/client/routes.ts | 79 +++++++ .../src/client/tsconfig.json | 16 ++ .../tanstack-vanilla-ts/src/server.test.ts | 12 + examples/tanstack-vanilla-ts/src/server.ts | 44 ++++ examples/tanstack-vanilla-ts/tsconfig.json | 27 +++ examples/tanstack-vanilla-ts/vite.config.js | 11 + packages/fastify-tanstack/package.json | 69 ++++++ packages/fastify-tanstack/src/index.ts | 7 + packages/fastify-tanstack/src/plugin/index.ts | 93 ++++++++ .../fastify-tanstack/src/plugin/preload.ts | 102 +++++++++ .../fastify-tanstack/src/plugin/virtual.ts | 94 ++++++++ packages/fastify-tanstack/src/rendering.ts | 179 +++++++++++++++ packages/fastify-tanstack/src/routing.ts | 143 ++++++++++++ packages/fastify-tanstack/src/server.ts | 20 ++ packages/fastify-tanstack/tsconfig.json | 21 ++ packages/fastify-tanstack/virtual/context.ts | 3 + packages/fastify-tanstack/virtual/create.tsx | 8 + packages/fastify-tanstack/virtual/index.ts | 10 + packages/fastify-tanstack/virtual/mount.ts | 8 + packages/fastify-tanstack/virtual/root.tsx | 3 + packages/fastify-tanstack/virtual/routes.ts | 21 ++ packages/fastify-vite/src/index.test-d.ts | 4 +- packages/fastify-vite/src/types/renderer.ts | 21 +- pnpm-lock.yaml | 205 +++++++++++++++++- 27 files changed, 1230 insertions(+), 31 deletions(-) create mode 100644 examples/tanstack-vanilla-ts/package.json create mode 100644 examples/tanstack-vanilla-ts/src/client/create.tsx create mode 100644 examples/tanstack-vanilla-ts/src/client/index.html create mode 100644 examples/tanstack-vanilla-ts/src/client/routes.ts create mode 100644 examples/tanstack-vanilla-ts/src/client/tsconfig.json create mode 100644 examples/tanstack-vanilla-ts/src/server.test.ts create mode 100644 examples/tanstack-vanilla-ts/src/server.ts create mode 100644 examples/tanstack-vanilla-ts/tsconfig.json create mode 100644 examples/tanstack-vanilla-ts/vite.config.js create mode 100644 packages/fastify-tanstack/package.json create mode 100644 packages/fastify-tanstack/src/index.ts create mode 100644 packages/fastify-tanstack/src/plugin/index.ts create mode 100644 packages/fastify-tanstack/src/plugin/preload.ts create mode 100644 packages/fastify-tanstack/src/plugin/virtual.ts create mode 100644 packages/fastify-tanstack/src/rendering.ts create mode 100644 packages/fastify-tanstack/src/routing.ts create mode 100644 packages/fastify-tanstack/src/server.ts create mode 100644 packages/fastify-tanstack/tsconfig.json create mode 100644 packages/fastify-tanstack/virtual/context.ts create mode 100644 packages/fastify-tanstack/virtual/create.tsx create mode 100644 packages/fastify-tanstack/virtual/index.ts create mode 100644 packages/fastify-tanstack/virtual/mount.ts create mode 100644 packages/fastify-tanstack/virtual/root.tsx create mode 100644 packages/fastify-tanstack/virtual/routes.ts diff --git a/examples/tanstack-vanilla-ts/package.json b/examples/tanstack-vanilla-ts/package.json new file mode 100644 index 00000000..48e3aadd --- /dev/null +++ b/examples/tanstack-vanilla-ts/package.json @@ -0,0 +1,30 @@ +{ + "name": "@fastify-vite/example-tanstack-vanilla-ts", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx src/server.ts --dev", + "start": "NODE_ENV=production node build/server.js", + "build": "pnpm build:server && pnpm build:client", + "build:client": "vite build --app", + "build:server": "tsc", + "test": "tsx --test" + }, + "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/tanstack": "workspace:^", + "@fastify/vite": "workspace:^", + "@tanstack/react-router": "^1.0.0", + "fastify": "catalog:", + "react": "catalog:react", + "react-dom": "catalog:react" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "tsx": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/examples/tanstack-vanilla-ts/src/client/create.tsx b/examples/tanstack-vanilla-ts/src/client/create.tsx new file mode 100644 index 00000000..f4d32c56 --- /dev/null +++ b/examples/tanstack-vanilla-ts/src/client/create.tsx @@ -0,0 +1,19 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routes.ts' + +interface User { + name: string +} + +export interface RouterContext { + user: User | null +} + +export function createAppRouter(req?: { user?: User | null }) { + return createRouter({ + routeTree, + context: { + user: req?.user ?? null, + }, + }) +} diff --git a/examples/tanstack-vanilla-ts/src/client/index.html b/examples/tanstack-vanilla-ts/src/client/index.html new file mode 100644 index 00000000..e289a82c --- /dev/null +++ b/examples/tanstack-vanilla-ts/src/client/index.html @@ -0,0 +1,12 @@ + + + + + + + + +
+ + + diff --git a/examples/tanstack-vanilla-ts/src/client/routes.ts b/examples/tanstack-vanilla-ts/src/client/routes.ts new file mode 100644 index 00000000..2ce1f01d --- /dev/null +++ b/examples/tanstack-vanilla-ts/src/client/routes.ts @@ -0,0 +1,79 @@ +import { createElement, Fragment } from 'react' +import { + createRootRouteWithContext, + createRoute, + Outlet, + Scripts, + redirect, + useLoaderData, +} from '@tanstack/react-router' +import type { RouterContext } from './create.tsx' + +const rootRoute = createRootRouteWithContext()({ + component: function Root() { + return createElement(Fragment, null, createElement(Outlet), createElement(Scripts)) + }, +}) + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: ({ context }) => { + if (!context.user) { + throw redirect({ to: '/login' }) + } + }, + loader: ({ context }) => { + return { user: context.user! } + }, + component: function Index() { + const { user } = useLoaderData({ from: '/' }) + return createElement( + 'div', + null, + createElement('h1', null, `Hello ${user.name} from TanStack Router SSR!`), + createElement( + 'form', + { + onSubmit: async (e: { preventDefault: () => void }) => { + e.preventDefault() + await fetch('/api/logout', { method: 'POST' }) + window.location.href = '/login' + }, + }, + createElement('button', { type: 'submit' }, 'Logout'), + ), + ) + }, +}) + +const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/login', + component: function Login() { + return createElement( + 'div', + null, + createElement('h1', null, 'Login'), + createElement( + 'form', + { + onSubmit: async (e: { preventDefault: () => void; target: any }) => { + e.preventDefault() + const form = new FormData(e.target) + await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: form.get('username') }), + }) + window.location.href = '/' + }, + }, + createElement('input', { name: 'username', placeholder: 'Username', required: true }), + createElement('button', { type: 'submit' }, 'Sign in'), + ), + ) + }, +}) + +export const routeTree = rootRoute.addChildren([indexRoute, loginRoute]) diff --git a/examples/tanstack-vanilla-ts/src/client/tsconfig.json b/examples/tanstack-vanilla-ts/src/client/tsconfig.json new file mode 100644 index 00000000..b076d185 --- /dev/null +++ b/examples/tanstack-vanilla-ts/src/client/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "allowJs": false, + "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "target": "ES2023", + "types": ["vite/client"] + }, + "include": ["**/*"], + "exclude": [] +} diff --git a/examples/tanstack-vanilla-ts/src/server.test.ts b/examples/tanstack-vanilla-ts/src/server.test.ts new file mode 100644 index 00000000..fb2000f7 --- /dev/null +++ b/examples/tanstack-vanilla-ts/src/server.test.ts @@ -0,0 +1,12 @@ +import test from 'node:test' +import { resolve } from 'node:path' +import { makeBuildTest, makeIndexTest } from '../../test-factories.mjs' +import { main } from './server.ts' + +const viteConfigLocation = resolve(import.meta.dirname, '..') + +test('tanstack-vanilla-ts', async (t) => { + await t.test('build production bundle', makeBuildTest({ cwd: viteConfigLocation })) + await t.test('render index page in production', makeIndexTest({ main, dev: false })) + await t.test('render index page in development', makeIndexTest({ main, dev: true })) +}) diff --git a/examples/tanstack-vanilla-ts/src/server.ts b/examples/tanstack-vanilla-ts/src/server.ts new file mode 100644 index 00000000..995783ab --- /dev/null +++ b/examples/tanstack-vanilla-ts/src/server.ts @@ -0,0 +1,44 @@ +import { resolve } from 'node:path' +import Fastify from 'fastify' +import FastifyVite from '@fastify/vite' +import FastifyCookie from '@fastify/cookie' +import * as renderer from '@fastify/tanstack' + +export async function main(dev?: boolean) { + const server = Fastify() + + await server.register(FastifyCookie) + + // Simulate auth: parse the "user" cookie on every request + server.addHook('onRequest', async (req) => { + const username = req.cookies.user + ;(req as any).user = username ? { name: username } : null + }) + + // Login endpoint sets the cookie + server.post('/api/login', async (req, reply) => { + const { username } = req.body as { username: string } + reply.setCookie('user', username, { path: '/', httpOnly: true }) + return { ok: true } + }) + + // Logout endpoint clears the cookie + server.post('/api/logout', async (req, reply) => { + reply.clearCookie('user', { path: '/' }) + return { ok: true } + }) + + await server.register(FastifyVite, { + root: resolve(import.meta.dirname, '..'), + dev: dev || process.argv.includes('--dev'), + renderer, + }) + + await server.vite.ready() + return server +} + +if (process.argv[1] === import.meta.filename) { + const server = await main() + await server.listen({ port: 3000 }) +} diff --git a/examples/tanstack-vanilla-ts/tsconfig.json b/examples/tanstack-vanilla-ts/tsconfig.json new file mode 100644 index 00000000..c431b0ca --- /dev/null +++ b/examples/tanstack-vanilla-ts/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "checkJs": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "incremental": true, + "isolatedModules": true, + "lib": ["ESNext"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "build", + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "removeComments": true, + "resolveJsonModule": true, + "rootDir": "src", + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + }, + "include": ["src"], + "exclude": ["src/client/**/*", "src/**/*.test.ts"] +} diff --git a/examples/tanstack-vanilla-ts/vite.config.js b/examples/tanstack-vanilla-ts/vite.config.js new file mode 100644 index 00000000..80c7c038 --- /dev/null +++ b/examples/tanstack-vanilla-ts/vite.config.js @@ -0,0 +1,11 @@ +import { resolve } from 'node:path' +import viteFastifyTanstack from '@fastify/tanstack/plugin' + +export default { + root: resolve(import.meta.dirname, 'src', 'client'), + plugins: [viteFastifyTanstack()], + build: { + emptyOutDir: true, + outDir: resolve(import.meta.dirname, 'build'), + }, +} diff --git a/packages/fastify-tanstack/package.json b/packages/fastify-tanstack/package.json new file mode 100644 index 00000000..8ec150a4 --- /dev/null +++ b/packages/fastify-tanstack/package.json @@ -0,0 +1,69 @@ +{ + "name": "@fastify/tanstack", + "version": "0.0.1", + "description": "@fastify/vite renderer for TanStack Router SSR", + "keywords": [ + "fastify", + "ssr", + "tanstack", + "tanstack-router", + "vite" + ], + "homepage": "https://github.com/fastify/fastify-vite", + "bugs": { + "url": "https://github.com/fastify/fastify-vite/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/fastify/fastify-vite.git" + }, + "files": [ + "dist/**/*", + "!dist/**/*.test.*", + "virtual/**/*" + ], + "type": "module", + "main": "dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./plugin": { + "types": "./dist/plugin/index.d.ts", + "import": "./dist/plugin/index.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc", + "prepublishOnly": "pnpm build" + }, + "dependencies": { + "@fastify/vite": "workspace:^", + "@tanstack/history": "^1.0.0", + "@tanstack/react-router": "^1.0.0", + "@tanstack/router-core": "^1.0.0", + "mlly": "^1.7.4", + "react": "catalog:react", + "react-dom": "catalog:react", + "youch": "^3.3.4" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "fastify": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/fastify-tanstack/src/index.ts b/packages/fastify-tanstack/src/index.ts new file mode 100644 index 00000000..152bf56d --- /dev/null +++ b/packages/fastify-tanstack/src/index.ts @@ -0,0 +1,7 @@ +export { prepareServer } from './server.ts' + +export { prepareClient, createRouteHandler, createErrorHandler, createRoute } from './routing.ts' + +export { createRenderFunction, createHtmlFunction } from './rendering.ts' + +export const clientModule = '$app/index.ts' diff --git a/packages/fastify-tanstack/src/plugin/index.ts b/packages/fastify-tanstack/src/plugin/index.ts new file mode 100644 index 00000000..446a4430 --- /dev/null +++ b/packages/fastify-tanstack/src/plugin/index.ts @@ -0,0 +1,93 @@ +import viteFastify from '@fastify/vite/plugin' +import type { Plugin, ResolvedConfig } from 'vite' +import { + prefix, + resolveId, + loadSource, + loadVirtualModule, + createPlaceholderExports, +} from './virtual.ts' +import { closeBundle as closeBundleImpl } from './preload.ts' + +interface PluginContext { + root: string + resolvedConfig: ResolvedConfig | null +} + +export default function viteFastifyTanstackPlugin(): Plugin[] { + let resolvedBundle: Record | null = null + + const context: PluginContext = { + root: '', + resolvedConfig: null, + } + return [ + viteFastify({ + clientModule: '$app/index.ts', + }), + { + name: 'vite-plugin-tanstack-fastify', + config: configHook, + configResolved: configResolved.bind(context), + resolveId: resolveId.bind(context), + async load(id) { + if (id.includes('?server') && !this.environment.config.build?.ssr) { + const source = loadSource(id) + return createPlaceholderExports(source) + } + if (id.includes('?client') && this.environment.config.build?.ssr) { + const source = loadSource(id) + return createPlaceholderExports(source) + } + if (prefix.test(id)) { + const [, virtual] = id.split(prefix) + if (virtual) { + return loadVirtualModule(virtual) + } + } + }, + transformIndexHtml: { + order: 'post', + handler(_html, { bundle }) { + if (bundle) { + resolvedBundle = bundle + } + }, + }, + closeBundle() { + closeBundleImpl.call(this, resolvedBundle) + }, + }, + ] +} + +function configResolved(this: PluginContext, config: ResolvedConfig) { + this.resolvedConfig = config + this.root = config.root +} + +function configHook(config: Record, { command }: { command: string }) { + if (command === 'build') { + if (!config.build) { + config.build = {} + } + if (!config.build.rollupOptions) { + config.build.rollupOptions = {} + } + config.build.rollupOptions.onwarn = onwarn + } +} + +function onwarn( + warning: { code?: string; message?: string }, + rollupWarn: (warning: unknown) => void, +) { + if ( + !( + warning.code == 'PLUGIN_WARNING' && + warning.message?.includes?.('dynamic import will not move module into another chunk') + ) + ) { + rollupWarn(warning) + } +} diff --git a/packages/fastify-tanstack/src/plugin/preload.ts b/packages/fastify-tanstack/src/plugin/preload.ts new file mode 100644 index 00000000..b9ab195d --- /dev/null +++ b/packages/fastify-tanstack/src/plugin/preload.ts @@ -0,0 +1,102 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs' +import { join, isAbsolute, parse as parsePath } from 'node:path' + +const imageFileRE = /\.((png)|(jpg)|(svg)|(webp)|(gif))$/ + +interface BundleChunk { + type: 'chunk' + isDynamicEntry?: boolean + name: string + fileName: string + imports?: string[] + moduleIds: string[] + modules: Record + viteMetadata?: { + importedCss?: string[] + } +} + +function isDynamicChunk(meta: unknown): meta is BundleChunk { + return ( + typeof meta === 'object' && + meta !== null && + (meta as Record).type === 'chunk' && + !!(meta as Record).isDynamicEntry + ) +} + +export async function closeBundle( + this: { + environment: { + name: string + config: { + build: { assetsInlineLimit: number | Function; outDir: string } + root: string + base: string + } + } + }, + resolvedBundle: Record | null, +) { + if (this.environment.name !== 'client') { + return + } + const { assetsInlineLimit } = this.environment.config.build + const { root, base } = this.environment.config + let distDir: string + if (isAbsolute(this.environment.config.build.outDir)) { + distDir = this.environment.config.build.outDir + } else { + distDir = join(root, this.environment.config.build.outDir) + } + + const indexHtml = readFileSync(join(distDir, 'index.html'), 'utf8') + + const routeChunks = Object.values(resolvedBundle ?? {}).filter(isDynamicChunk) + + if (routeChunks.length === 0) { + return + } + + const limit = typeof assetsInlineLimit === 'number' ? assetsInlineLimit : 0 + + for (const chunk of routeChunks) { + const jsImports = chunk.imports ?? [] + const cssImports = chunk.viteMetadata?.importedCss ?? [] + const images = chunk.moduleIds.filter((img) => { + return chunk.modules[img].originalLength > limit && imageFileRE.test(img) + }) + + let imagePreloads = '\n' + for (let image of images) { + image = image.slice(root.length + 1) + imagePreloads += ` \n` + } + let cssPreloads = '' + for (const css of cssImports) { + cssPreloads += ` \n` + } + let jsPreloads = '' + for (const js of jsImports) { + jsPreloads += ` \n` + } + + const pageHtml = appendHead(indexHtml, imagePreloads, cssPreloads, jsPreloads) + const htmlPath = `html/${chunk.name}.html` + writeHtml(htmlPath, pageHtml, distDir) + } +} + +function appendHead(html: string, ...tags: string[]) { + const content = tags.join('\n ') + return html.replace(/]*)>/i, `\n ${content}`) +} + +function writeHtml(htmlPath: string, pageHtml: string, distDir: string) { + const { dir, base } = parsePath(htmlPath) + const htmlDir = join(distDir, dir) + if (!existsSync(htmlDir)) { + mkdirSync(htmlDir, { recursive: true }) + } + writeFileSync(join(htmlDir, base), pageHtml) +} diff --git a/packages/fastify-tanstack/src/plugin/virtual.ts b/packages/fastify-tanstack/src/plugin/virtual.ts new file mode 100644 index 00000000..92ca49e6 --- /dev/null +++ b/packages/fastify-tanstack/src/plugin/virtual.ts @@ -0,0 +1,94 @@ +import { readFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { findExports } from 'mlly' + +const virtualRoot = resolve(import.meta.dirname, '..', '..', 'virtual') + +const virtualModules = ['mount.ts', 'routes.ts', 'create.tsx', 'root.tsx', 'context.ts', 'index.ts'] + +export const prefix = /^\/?\$app\// + +export async function resolveId(this: { root: string }, id: string) { + if (process.platform === 'win32' && /^\.\.\/[C-Z]:/.test(id)) { + return id.substring(3) + } + + if (prefix.test(id)) { + const [, virtual] = id.split(prefix) + if (virtual) { + const override = loadVirtualModuleOverride(this.root, virtual) + if (override) { + return override + } + return id + } + } +} + +export function loadVirtualModule(virtual: string): { code: string; map: null } | undefined { + if (!matchesVirtualModule(virtual)) { + return + } + const codePath = resolve(virtualRoot, virtual) + return { + code: readFileSync(codePath, 'utf8'), + map: null, + } +} + +function matchesVirtualModule(virtual: string): boolean { + if (!virtual) { + return false + } + for (const entry of virtualModules) { + if (virtual.startsWith(entry)) { + return true + } + } + return false +} + +function loadVirtualModuleOverride(viteProjectRoot: string, virtual: string): string | undefined { + if (!matchesVirtualModule(virtual)) { + return + } + const overridePath = resolve(viteProjectRoot, virtual) + if (existsSync(overridePath)) { + return overridePath + } + const base = overridePath.replace(/\.[^.]+$/, '') + for (const ext of ['.ts', '.tsx', '.js', '.jsx']) { + const candidate = base + ext + if (candidate !== overridePath && existsSync(candidate)) { + return candidate + } + } +} + +export function loadSource(id: string): string { + const filePath = id.replace(/\?client$/, '').replace(/\?server$/, '') + return readFileSync(filePath, 'utf8') +} + +export function createPlaceholderExports(source: string): string { + let pExports = '' + for (const exp of findExports(source)) { + switch (exp.type) { + case 'named': + for (const name of exp.names) { + pExports += `export const ${name} = {}\n` + } + break + case 'default': + pExports += `export default {}\n` + break + case 'declaration': + pExports += `export const ${exp.name} = {}\n` + break + case 'star': + pExports += `export ${exp.code}\n` + break + } + } + return pExports +} diff --git a/packages/fastify-tanstack/src/rendering.ts b/packages/fastify-tanstack/src/rendering.ts new file mode 100644 index 00000000..1e4757ea --- /dev/null +++ b/packages/fastify-tanstack/src/rendering.ts @@ -0,0 +1,179 @@ +import { PassThrough } from 'node:stream' +import { createElement } from 'react' +import { createRequire } from 'node:module' + +// Bun's react-dom/server shim only activates through require(), not ESM import. +// Without this, renderToPipeableStream silently uses the wrong implementation. +const _require = createRequire(import.meta.url) +const { renderToPipeableStream } = _require( + 'react-dom/server.node', +) as typeof import('react-dom/server') +import { createMemoryHistory } from '@tanstack/history' +import { RouterProvider } from '@tanstack/react-router' +import type { AnyRouter, AnyRedirect } from '@tanstack/router-core' +import { + attachRouterServerSsrUtils, + transformPipeableStreamWithRouter, +} from '@tanstack/router-core/ssr/server' +import { createHtmlTemplateFunction } from '@fastify/vite/utils' +import type { RuntimeConfig } from '@fastify/vite' +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' + +interface RenderResult { + pipeable: NodeJS.ReadableStream | null + router: AnyRouter + redirect?: AnyRedirect +} + +export async function createRenderFunction(client: Record) { + const createAppRouter = client.createAppRouter as (req?: FastifyRequest) => AnyRouter + // Registered as reply.render() + return async function (this: FastifyReply): Promise { + const url = this.request.url + const router = createAppRouter(this.request) + + attachRouterServerSsrUtils({ router, manifest: undefined }) + + const history = createMemoryHistory({ initialEntries: [url] }) + router.update({ history }) + + await router.load() + + const pendingRedirect = router.stores.redirect.get() + if (pendingRedirect) { + return { pipeable: null, router, redirect: pendingRedirect } + } + + await router.serverSsr?.dehydrate() + + const app = createElement(RouterProvider, { router }) + + const pipeable = await new Promise((resolve, reject) => { + try { + const stream = renderToPipeableStream(app, { + onShellReady() { + resolve(stream as unknown as NodeJS.ReadableStream) + }, + onShellError: reject, + onError(error: unknown) { + console.error('SSR streaming error:', error) + }, + }) + } catch (error) { + reject(error) + } + }) + + return { pipeable, router } + } +} + +type HtmlFunction = (this: FastifyReply) => FastifyReply | Promise + +// The return value of this function gets registered as reply.html() +export async function createHtmlFunction( + source: string, + _: FastifyInstance, + config: RuntimeConfig, +): Promise { + if (config.spa) { + const template = createHtmlTemplateFunction(source) + return function (this: FastifyReply) { + this.type('text/html') + this.send(template({ element: '' })) + return this + } + } + + const { beforeHtml, afterHtml } = splitHtmlTemplate(source) + + return async function (this: FastifyReply) { + const { + pipeable, + router, + redirect: pendingRedirect, + } = (await (this as any).render()) as RenderResult + + if (pendingRedirect) { + const resolved = router.resolveRedirect(pendingRedirect) + const href = resolved.headers.get('Location') ?? '/' + this.code(resolved.status) + this.redirect(href) + return this + } + + const statusCode = router.stores.statusCode.get() + this.code(statusCode) + this.type('text/html') + + if (!pipeable) { + this.send(beforeHtml + afterHtml) + return this + } + + const assembled = assembleHtmlStream(beforeHtml, afterHtml, pipeable) + const transformed = transformPipeableStreamWithRouter(router, assembled) + + if (config.dev) { + const checker = new PassThrough() + let sawBarrier = false + checker.on('data', (chunk: Buffer) => { + if (!sawBarrier && chunk.toString().includes('$tsr-stream-barrier')) { + sawBarrier = true + } + }) + checker.on('end', () => { + if (!sawBarrier) { + console.warn( + '[@fastify/tanstack] not found in rendered output. ' + + 'Dehydration will fail. Ensure root route renders .', + ) + } + }) + transformed.pipe(checker) + } + + this.send(transformed) + return this + } +} + +function splitHtmlTemplate(source: string) { + const marker = '' + const idx = source.indexOf(marker) + if (idx !== -1) { + return { + beforeHtml: source.slice(0, idx), + afterHtml: source.slice(idx + marker.length), + } + } + const bodyIdx = source.indexOf('') + if (bodyIdx !== -1) { + return { + beforeHtml: source.slice(0, bodyIdx), + afterHtml: source.slice(bodyIdx), + } + } + return { beforeHtml: source, afterHtml: '' } +} + +function assembleHtmlStream( + beforeHtml: string, + afterHtml: string, + pipeable: NodeJS.ReadableStream, +) { + const stream = new PassThrough() + + const reactOut = new PassThrough() + reactOut.on('data', (chunk: Buffer) => stream.write(chunk)) + reactOut.on('end', () => { + stream.write(afterHtml) + stream.end() + }) + reactOut.on('error', (err: Error) => stream.destroy(err)) + + stream.write(beforeHtml) + ;(pipeable as any).pipe(reactOut) + + return stream +} diff --git a/packages/fastify-tanstack/src/routing.ts b/packages/fastify-tanstack/src/routing.ts new file mode 100644 index 00000000..3a7d5622 --- /dev/null +++ b/packages/fastify-tanstack/src/routing.ts @@ -0,0 +1,143 @@ +import { readFileSync } from 'node:fs' +import { join, isAbsolute } from 'node:path' +import { createHtmlFunction } from './rendering.ts' +import Youch from 'youch' +import type { FastifyInstance, FastifyReply, FastifyRequest, RouteOptions } from 'fastify' +import type { RuntimeConfig, RouteDefinition } from '@fastify/vite' + +interface ClientEntries { + ssr?: unknown + [key: string]: unknown +} + +interface TanStackRoute { + path: string + name?: string + configure?: (scope: FastifyInstance) => void | Promise +} + +interface TanStackClient { + createAppRouter: + | ((...args: unknown[]) => unknown) + | Promise<{ createAppRouter?: unknown; default?: unknown }> + getRoutes?: + | ((...args: unknown[]) => TanStackRoute[]) + | Promise<{ getRoutes?: unknown; default?: unknown }> + routes?: TanStackRoute[] +} + +export async function prepareClient(entries: ClientEntries, _: FastifyInstance) { + const client = entries.ssr as TanStackClient | undefined + if (!client) return null + + if (typeof client.createAppRouter !== 'function') { + if (client.createAppRouter instanceof Promise) { + const mod = (await client.createAppRouter) as Record + client.createAppRouter = (mod.createAppRouter ?? + mod.default) as TanStackClient['createAppRouter'] + } + } + if (typeof client.getRoutes !== 'function') { + if (client.getRoutes instanceof Promise) { + const mod = (await client.getRoutes) as Record + client.getRoutes = (mod.getRoutes ?? mod.default) as TanStackClient['getRoutes'] + } + } + + if (typeof client.getRoutes === 'function') { + const routes = client.getRoutes() + // Without a catch-all, unmatched URLs hit Fastify's default 404 + // instead of TanStack Router's notFoundComponent + const hasCatchAll = routes.some((r) => r.path === '/$' || r.path === '*' || r.path === '/*') + if (!hasCatchAll) { + routes.push({ path: '/*' }) + } + return { ...client, routes } + } + + return client +} + +export function createRouteHandler() { + return (_: FastifyRequest, reply: FastifyReply) => (reply as any).html() +} + +export function createErrorHandler(_: unknown, __: FastifyInstance, config: RuntimeConfig) { + return async (error: Error, req: FastifyRequest, reply: FastifyReply) => { + req.log.error(error) + if (config.dev) { + const youch = new Youch(error, req.raw) + reply.code(500) + reply.type('text/html') + reply.send(await youch.toHTML()) + return reply + } + reply.code(500) + reply.send('') + return reply + } +} + +export async function createRoute( + { + handler, + errorHandler, + route, + }: { + handler?: RouteOptions['handler'] + errorHandler?: RouteOptions['errorHandler'] + route?: RouteDefinition + }, + scope: FastifyInstance, + config: RuntimeConfig, +) { + if (!route?.path || !handler) return + if (route.configure) { + await route.configure(scope) + } + + let routePath = route.path.replace(/\$\$/, '*').replace(/\$([a-zA-Z_]\w*)/g, ':$1') + if (routePath !== '/') { + routePath = routePath.replace(/\/$/, '') + } + + let routeHandler = handler + if (!config.dev && route.path !== '/*') { + const htmlPath = resolveRouteHtmlPath(route, config) + if (htmlPath) { + const htmlSource = readFileSync(htmlPath, 'utf8') + const htmlFunction = await createHtmlFunction(htmlSource, scope, config) + routeHandler = (_: FastifyRequest, reply: FastifyReply) => htmlFunction.call(reply) + } + } + + scope.route({ + url: routePath, + method: 'GET', + errorHandler, + handler: routeHandler, + }) +} + +function resolveRouteHtmlPath(route: RouteDefinition, config: RuntimeConfig): string | null { + let distDir = config.viteConfig.build.outDir + if (!isAbsolute(distDir)) { + distDir = join(config.viteConfig.root, distDir) + } + const candidates: string[] = [] + if (route.name) { + candidates.push(join(distDir, 'html', `${String(route.name)}.html`)) + } + const derived = (route.path ?? '').replace(/^\//, '') || 'index' + candidates.push(join(distDir, 'html', `${derived}.html`)) + + for (const candidate of candidates) { + try { + readFileSync(candidate, 'utf8') + return candidate + } catch { + // not found, try next + } + } + return null +} diff --git a/packages/fastify-tanstack/src/server.ts b/packages/fastify-tanstack/src/server.ts new file mode 100644 index 00000000..c699aeb7 --- /dev/null +++ b/packages/fastify-tanstack/src/server.ts @@ -0,0 +1,20 @@ +import { Server as TlsServer } from 'node:tls' +import type { FastifyInstance } from 'fastify' + +export function prepareServer(server: FastifyInstance) { + let url: string + server.decorate('serverURL', { getter: () => url }) + server.addHook('onListen', () => { + const { port, address, family } = server.server.address() as { + port: number + address: string + family: string + } + const protocol = server.server instanceof TlsServer ? 'https' : 'http' + if (family === 'IPv6') { + url = `${protocol}://[${address}]:${port}` + } else { + url = `${protocol}://${address}:${port}` + } + }) +} diff --git a/packages/fastify-tanstack/tsconfig.json b/packages/fastify-tanstack/tsconfig.json new file mode 100644 index 00000000..65d347a3 --- /dev/null +++ b/packages/fastify-tanstack/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "jsx": "react-jsx", + "lib": ["ES2020"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noImplicitAny": true, + "outDir": "dist", + "rewriteRelativeImportExtensions": true, + "rootDir": "src", + "skipLibCheck": true, + "sourceMap": true, + "target": "esnext" + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.*"] +} diff --git a/packages/fastify-tanstack/virtual/context.ts b/packages/fastify-tanstack/virtual/context.ts new file mode 100644 index 00000000..5d26fd60 --- /dev/null +++ b/packages/fastify-tanstack/virtual/context.ts @@ -0,0 +1,3 @@ +export default function getRouterContext() { + return {} +} diff --git a/packages/fastify-tanstack/virtual/create.tsx b/packages/fastify-tanstack/virtual/create.tsx new file mode 100644 index 00000000..766e0ff8 --- /dev/null +++ b/packages/fastify-tanstack/virtual/create.tsx @@ -0,0 +1,8 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from '$app/routes.ts' + +export function createAppRouter() { + return createRouter({ + routeTree, + }) +} diff --git a/packages/fastify-tanstack/virtual/index.ts b/packages/fastify-tanstack/virtual/index.ts new file mode 100644 index 00000000..fd0ce99d --- /dev/null +++ b/packages/fastify-tanstack/virtual/index.ts @@ -0,0 +1,10 @@ +import { createAppRouter } from '$app/create.tsx' + +export { createAppRouter } + +export function getRoutes() { + const router = createAppRouter() + return Object.entries(router.routesByPath).map(([_path, route]) => ({ + path: route.fullPath, + })) +} diff --git a/packages/fastify-tanstack/virtual/mount.ts b/packages/fastify-tanstack/virtual/mount.ts new file mode 100644 index 00000000..bda4e79d --- /dev/null +++ b/packages/fastify-tanstack/virtual/mount.ts @@ -0,0 +1,8 @@ +import { createElement } from 'react' +import { hydrateRoot } from 'react-dom/client' +import { RouterClient } from '@tanstack/react-router/ssr/client' +import { createAppRouter } from '$app/create.tsx' + +const router = createAppRouter() + +hydrateRoot(document.getElementById('root')!, createElement(RouterClient, { router })) diff --git a/packages/fastify-tanstack/virtual/root.tsx b/packages/fastify-tanstack/virtual/root.tsx new file mode 100644 index 00000000..c33947a0 --- /dev/null +++ b/packages/fastify-tanstack/virtual/root.tsx @@ -0,0 +1,3 @@ +// Override point for wrapping the router in additional providers. +// Not imported by the SSR entry chain - exists for user customization only. +export {} diff --git a/packages/fastify-tanstack/virtual/routes.ts b/packages/fastify-tanstack/virtual/routes.ts new file mode 100644 index 00000000..f034013c --- /dev/null +++ b/packages/fastify-tanstack/virtual/routes.ts @@ -0,0 +1,21 @@ +import { createElement, Fragment } from 'react' +import { createRootRoute, createRoute, Outlet, Scripts } from '@tanstack/react-router' + +const rootRoute = createRootRoute({ + component: function Root() { + return createElement(Fragment, null, createElement(Outlet), createElement(Scripts)) + }, +}) + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Index() { + return createElement('div', null, 'Hello from TanStack Router SSR') + }, +}) + +const routeTree = rootRoute.addChildren([indexRoute]) + +export { routeTree } +export default routeTree diff --git a/packages/fastify-vite/src/index.test-d.ts b/packages/fastify-vite/src/index.test-d.ts index e5678abb..d2d9a734 100644 --- a/packages/fastify-vite/src/index.test-d.ts +++ b/packages/fastify-vite/src/index.test-d.ts @@ -12,8 +12,8 @@ const options = { const resolvedRoutes = await routesPromise return { context, routes: resolvedRoutes, ...others } }, - createHtmlFunction(source) { - return ({ routes, context, body }) => Promise.resolve() + async createHtmlFunction(source) { + return (ctx) => Promise.resolve() }, createRenderFunction({ create, routes, createApp }) { return Promise.resolve((req) => { diff --git a/packages/fastify-vite/src/types/renderer.ts b/packages/fastify-vite/src/types/renderer.ts index 7f7ecd99..edb8d32e 100644 --- a/packages/fastify-vite/src/types/renderer.ts +++ b/packages/fastify-vite/src/types/renderer.ts @@ -1,4 +1,5 @@ import type { FastifyInstance, RouteHandlerMethod, RouteOptions } from 'fastify' +import type { RouteDefinition } from './route.ts' /** Helper type to loosen strict object types by adding index signature */ export type Loosen = T & Record @@ -37,22 +38,8 @@ export interface RendererFunctions { source: string, scope?: unknown, config?: unknown, - ): (ctx: Ctx) => Promise - createRenderFunction( - args: Loosen<{ - routes?: Array - create?: (arg0: Record) => unknown - createApp: unknown - }>, - ): Promise< - ( - server: unknown, - req: unknown, - reply: unknown, - ) => - | (Ctx | { element: string; hydration?: string }) - | Promise - > + ): Promise<(ctx?: Ctx) => unknown> + createRenderFunction(client: Record): Promise<(...args: unknown[]) => unknown> } /** Renderer option interface for custom renderers */ @@ -71,7 +58,7 @@ export interface RendererOption< client?: C handler?: RouteHandlerMethod errorHandler: NonNullable - route?: RouteType + route?: RouteDefinition }>, scope: FastifyInstance, config: unknown, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 682e718d..c91cbedb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -404,6 +404,49 @@ importers: specifier: 'catalog:' version: 8.0.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.7.1) + examples/tanstack-vanilla-ts: + dependencies: + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 + '@fastify/tanstack': + specifier: workspace:^ + version: link:../../packages/fastify-tanstack + '@fastify/vite': + specifier: workspace:^ + version: link:../../packages/fastify-vite + '@tanstack/react-router': + specifier: ^1.0.0 + version: 1.168.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + fastify: + specifier: 'catalog:' + version: 5.8.4 + react: + specifier: catalog:react + version: 19.2.4 + react-dom: + specifier: catalog:react + version: 19.2.4(react@19.2.4) + devDependencies: + '@types/node': + specifier: ^24.12.2 + version: 24.12.2 + '@types/react': + specifier: ^19.1.2 + version: 19.1.2 + '@types/react-dom': + specifier: ^19.1.2 + version: 19.1.2(@types/react@19.1.2) + tsx: + specifier: 'catalog:' + version: 4.21.0 + typescript: + specifier: 'catalog:' + version: 6.0.2 + vite: + specifier: 'catalog:' + version: 8.0.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.7.1) + examples/vue-hydration: dependencies: '@fastify/one-line-logger': @@ -713,6 +756,55 @@ importers: specifier: ^3.3.4 version: 3.3.4 + packages/fastify-tanstack: + dependencies: + '@fastify/vite': + specifier: workspace:^ + version: link:../fastify-vite + '@tanstack/history': + specifier: ^1.0.0 + version: 1.161.6 + '@tanstack/react-router': + specifier: ^1.0.0 + version: 1.168.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': + specifier: ^1.0.0 + version: 1.168.14 + mlly: + specifier: ^1.7.4 + version: 1.8.0 + react: + specifier: catalog:react + version: 19.2.4 + react-dom: + specifier: catalog:react + version: 19.2.4(react@19.2.4) + youch: + specifier: ^3.3.4 + version: 3.3.4 + devDependencies: + '@types/node': + specifier: ^24.12.2 + version: 24.12.2 + '@types/react': + specifier: ^19.1.2 + version: 19.1.2 + '@types/react-dom': + specifier: ^19.1.2 + version: 19.1.2(@types/react@19.1.2) + fastify: + specifier: 'catalog:' + version: 5.8.4 + typescript: + specifier: 'catalog:' + version: 6.0.2 + vite: + specifier: 'catalog:' + version: 8.0.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.7.1) + vitest: + specifier: 'catalog:' + version: 4.1.4(@types/node@24.12.2)(jsdom@23.2.0)(vite@8.0.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.7.1)) + packages/fastify-vite: dependencies: '@fastify/deepmerge': @@ -2167,6 +2259,9 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + '@fastify/deepmerge@3.2.1': resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} @@ -2918,6 +3013,31 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 + '@tanstack/history@1.161.6': + resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} + engines: {node: '>=20.19'} + + '@tanstack/react-router@1.168.19': + resolution: {integrity: sha512-0NCuwMPRlEpffDIF7OTSe3g4d8U93WsHxMi15YLJxjmNbng2of50wx+8UnT8IxKLbSdpFHSEDNTi4qnNyWn/Kw==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.168.14': + resolution: {integrity: sha512-UhCJtjNrd5wcTmhgB2HyUP0+Rj1M7BD4dS11YsF9x6VC2KH/eqxzs/vK+nN5f+cOhPOLZdmLkWMW+WGmacZ8HA==} + engines: {node: '>=20.19'} + hasBin: true + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -3062,9 +3182,6 @@ packages: '@types/node@22.14.0': resolution: {integrity: sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==} - '@types/node@22.19.17': - resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} - '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} @@ -3575,6 +3692,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -4412,6 +4532,10 @@ packages: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} + isbot@5.1.38: + resolution: {integrity: sha512-Cus2702JamTNMEY4zTP+TShgq/3qzjvGcBC4XMOV45BLaxD4iUFENkqu7ZhFeSzwNsCSZLjnGlihDQznnpnEEA==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -5435,6 +5559,16 @@ packages: engines: {node: '>=10'} hasBin: true + seroval-plugins@1.5.2: + resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.2: + resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} + engines: {node: '>=10'} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -5728,6 +5862,11 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -6975,6 +7114,11 @@ snapshots: ajv-formats: 3.0.1(ajv@8.17.1) fast-uri: 3.1.0 + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + '@fastify/deepmerge@3.2.1': {} '@fastify/error@4.2.0': {} @@ -7645,6 +7789,33 @@ snapshots: tailwindcss: 4.1.2 vite: 8.0.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.4.2)(tsx@4.21.0)(yaml@2.7.1) + '@tanstack/history@1.161.6': {} + + '@tanstack/react-router@1.168.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/history': 1.161.6 + '@tanstack/react-store': 0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.168.14 + isbot: 5.1.38 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + + '@tanstack/router-core@1.168.14': + dependencies: + '@tanstack/history': 1.161.6 + cookie-es: 3.1.1 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) + + '@tanstack/store@0.9.3': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -7781,7 +7952,7 @@ snapshots: '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 22.19.17 + '@types/node': 24.12.2 '@types/geojson@7946.0.16': {} @@ -7793,11 +7964,11 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 22.19.17 + '@types/node': 24.12.2 '@types/klaw@3.0.7': dependencies: - '@types/node': 22.19.17 + '@types/node': 24.12.2 '@types/linkify-it@5.0.0': {} @@ -7818,10 +7989,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@22.19.17': - dependencies: - undici-types: 6.21.0 - '@types/node@24.12.2': dependencies: undici-types: 7.16.0 @@ -8445,6 +8612,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + cookie@0.7.2: {} cookie@1.1.1: {} @@ -9426,6 +9595,8 @@ snapshots: is-windows@1.0.2: {} + isbot@5.1.38: {} + isexe@2.0.0: {} jiti@2.4.2: {} @@ -9855,7 +10026,7 @@ snapshots: mlly@1.8.0: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.1 @@ -10098,7 +10269,7 @@ snapshots: pkg-types@1.3.1: dependencies: confbox: 0.1.8 - mlly: 1.7.4 + mlly: 1.8.0 pathe: 2.0.3 points-on-curve@0.2.0: {} @@ -10565,6 +10736,12 @@ snapshots: semver@7.7.4: {} + seroval-plugins@1.5.2(seroval@1.5.2): + dependencies: + seroval: 1.5.2 + + seroval@1.5.2: {} + set-cookie-parser@2.7.2: {} setprototypeof@1.2.0: {} @@ -10835,6 +11012,10 @@ snapshots: requires-port: 1.0.0 optional: true + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + util-deprecate@1.0.2: {} uuid@11.1.0: {} From 164378f366240d73e6ae0d7122a71b855bae3fce Mon Sep 17 00:00:00 2001 From: jamcalli <48490664+jamcalli@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:11:38 -0700 Subject: [PATCH 2/5] Align RendererOption types with runtime signatures and reuse shared type interfaces --- packages/fastify-vite/src/index.test-d.ts | 10 +-- packages/fastify-vite/src/types/renderer.ts | 71 ++++++--------------- 2 files changed, 24 insertions(+), 57 deletions(-) diff --git a/packages/fastify-vite/src/index.test-d.ts b/packages/fastify-vite/src/index.test-d.ts index d2d9a734..55b5f662 100644 --- a/packages/fastify-vite/src/index.test-d.ts +++ b/packages/fastify-vite/src/index.test-d.ts @@ -21,17 +21,17 @@ const options = { }) }, renderer: { - createErrorHandler(client, scope, config) { + createErrorHandler(args, scope, config) { return (error: Error, req: any, reply: any) => {} }, - createRoute({ client }, scope, config) {}, - createRouteHandler(client, scope, config) { + createRoute(args, scope, config) {}, + createRouteHandler(args, scope, config) { return (req, res) => { return Promise.resolve() } }, - prepareClient(clientModule, scope, config) { - return Promise.resolve(clientModule) + prepareClient(entries, scope, config) { + return Promise.resolve(entries.ssr ?? null) }, }, } satisfies FastifyViteOptions diff --git a/packages/fastify-vite/src/types/renderer.ts b/packages/fastify-vite/src/types/renderer.ts index edb8d32e..3b9c7b16 100644 --- a/packages/fastify-vite/src/types/renderer.ts +++ b/packages/fastify-vite/src/types/renderer.ts @@ -1,72 +1,39 @@ import type { FastifyInstance, RouteHandlerMethod, RouteOptions } from 'fastify' -import type { RouteDefinition } from './route.ts' - -/** Helper type to loosen strict object types by adding index signature */ -export type Loosen = T & Record - -/** Route properties available in renderer context */ -export type RouteType = Partial<{ - server: unknown - req: unknown - reply: unknown - head: unknown - state: unknown - data: Record - firstRender: boolean - layout: unknown - getMeta: unknown - getData: unknown - onEnter: unknown - streaming: unknown - clientOnly: unknown - serverOnly: unknown -}> - -/** Renderer context passed to html/render functions */ -export type Ctx = Loosen<{ - routes: Array - context: unknown - body: unknown - stream: unknown - data: unknown -}> +import type { ClientEntries, ClientModule } from './client.ts' +import type { ClientRouteArgs, CreateRouteArgs } from './route.ts' /** Renderer function definitions */ export interface RendererFunctions { createHtmlTemplateFunction(source: string): unknown createHtmlFunction( source: string, - scope?: unknown, + scope?: FastifyInstance, config?: unknown, - ): Promise<(ctx?: Ctx) => unknown> - createRenderFunction(client: Record): Promise<(...args: unknown[]) => unknown> + ): Promise<(...args: unknown[]) => unknown> + createRenderFunction( + client: ClientModule, + scope?: FastifyInstance, + config?: unknown, + ): Promise<(...args: unknown[]) => unknown> } /** Renderer option interface for custom renderers */ -export interface RendererOption< - CM = string | Record | unknown, - C = unknown, -> extends RendererFunctions { - clientModule: CM +export interface RendererOption extends RendererFunctions { + clientModule: string createErrorHandler( - client: C, + args: ClientRouteArgs, scope: FastifyInstance, config?: unknown, ): NonNullable | Promise> - createRoute( - args: Loosen<{ - client?: C - handler?: RouteHandlerMethod - errorHandler: NonNullable - route?: RouteDefinition - }>, - scope: FastifyInstance, - config: unknown, - ): void | Promise + createRoute(args: CreateRouteArgs, scope: FastifyInstance, config: unknown): void | Promise createRouteHandler( - client: C, + args: ClientRouteArgs, scope: FastifyInstance, config?: unknown, ): RouteHandlerMethod | Promise - prepareClient(clientModule: CM, scope?: FastifyInstance, config?: unknown): Promise + prepareClient( + entries: ClientEntries, + scope?: FastifyInstance, + config?: unknown, + ): Promise } From 66168e3cbfdd27a7d88d6a201d24dc0e2a19b989 Mon Sep 17 00:00:00 2001 From: jamcalli <48490664+jamcalli@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:21:16 -0700 Subject: [PATCH 3/5] Remove unused root.tsx virtual module from @fastify/tanstack --- packages/fastify-tanstack/src/plugin/virtual.ts | 2 +- packages/fastify-tanstack/virtual/root.tsx | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 packages/fastify-tanstack/virtual/root.tsx diff --git a/packages/fastify-tanstack/src/plugin/virtual.ts b/packages/fastify-tanstack/src/plugin/virtual.ts index 92ca49e6..90e89ed6 100644 --- a/packages/fastify-tanstack/src/plugin/virtual.ts +++ b/packages/fastify-tanstack/src/plugin/virtual.ts @@ -4,7 +4,7 @@ import { findExports } from 'mlly' const virtualRoot = resolve(import.meta.dirname, '..', '..', 'virtual') -const virtualModules = ['mount.ts', 'routes.ts', 'create.tsx', 'root.tsx', 'context.ts', 'index.ts'] +const virtualModules = ['mount.ts', 'routes.ts', 'create.tsx', 'context.ts', 'index.ts'] export const prefix = /^\/?\$app\// diff --git a/packages/fastify-tanstack/virtual/root.tsx b/packages/fastify-tanstack/virtual/root.tsx deleted file mode 100644 index c33947a0..00000000 --- a/packages/fastify-tanstack/virtual/root.tsx +++ /dev/null @@ -1,3 +0,0 @@ -// Override point for wrapping the router in additional providers. -// Not imported by the SSR entry chain - exists for user customization only. -export {} From 4052e262883c00d30d139123cdfae47be66e21a0 Mon Sep 17 00:00:00 2001 From: jamcalli <48490664+jamcalli@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:01:42 -0700 Subject: [PATCH 4/5] Export ClientEntries and ClientModule types from @fastify/vite --- packages/fastify-tanstack/src/routing.ts | 9 ++------- packages/fastify-vite/src/index.ts | 2 ++ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/fastify-tanstack/src/routing.ts b/packages/fastify-tanstack/src/routing.ts index 3a7d5622..808eaf2a 100644 --- a/packages/fastify-tanstack/src/routing.ts +++ b/packages/fastify-tanstack/src/routing.ts @@ -3,12 +3,7 @@ import { join, isAbsolute } from 'node:path' import { createHtmlFunction } from './rendering.ts' import Youch from 'youch' import type { FastifyInstance, FastifyReply, FastifyRequest, RouteOptions } from 'fastify' -import type { RuntimeConfig, RouteDefinition } from '@fastify/vite' - -interface ClientEntries { - ssr?: unknown - [key: string]: unknown -} +import type { RuntimeConfig, RouteDefinition, ClientEntries, ClientModule } from '@fastify/vite' interface TanStackRoute { path: string @@ -16,7 +11,7 @@ interface TanStackRoute { configure?: (scope: FastifyInstance) => void | Promise } -interface TanStackClient { +interface TanStackClient extends ClientModule { createAppRouter: | ((...args: unknown[]) => unknown) | Promise<{ createAppRouter?: unknown; default?: unknown }> diff --git a/packages/fastify-vite/src/index.ts b/packages/fastify-vite/src/index.ts index ce1ceec1..1401f214 100644 --- a/packages/fastify-vite/src/index.ts +++ b/packages/fastify-vite/src/index.ts @@ -22,6 +22,8 @@ import type { RouteDefinition } from './types/route.ts' // Re-export types for consumers export type { + ClientEntries, + ClientModule, DevRuntimeConfig, FastifyViteOptions, ProdRuntimeConfig, From 29c3f805e2141137e03e231f04f76b30e2c037c4 Mon Sep 17 00:00:00 2001 From: jamcalli <48490664+jamcalli@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:27:02 -0700 Subject: [PATCH 5/5] Simplify tanstack example and build @fastify/tanstack in CI --- .github/workflows/integration-tests.yml | 3 + examples/tanstack-vanilla-ts/package.json | 1 - .../tanstack-vanilla-ts/src/client/create.tsx | 13 +--- .../tanstack-vanilla-ts/src/client/routes.ts | 69 ++----------------- examples/tanstack-vanilla-ts/src/server.ts | 22 ------ pnpm-lock.yaml | 11 --- 6 files changed, 8 insertions(+), 111 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 68a913e4..485afb7e 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -43,5 +43,8 @@ jobs: - name: Build @fastify/vite run: pnpm --filter @fastify/vite build + - name: Build @fastify/tanstack + run: pnpm --filter @fastify/tanstack build + - name: Run integration tests run: pnpm test:examples diff --git a/examples/tanstack-vanilla-ts/package.json b/examples/tanstack-vanilla-ts/package.json index 48e3aadd..82974692 100644 --- a/examples/tanstack-vanilla-ts/package.json +++ b/examples/tanstack-vanilla-ts/package.json @@ -11,7 +11,6 @@ "test": "tsx --test" }, "dependencies": { - "@fastify/cookie": "^11.0.2", "@fastify/tanstack": "workspace:^", "@fastify/vite": "workspace:^", "@tanstack/react-router": "^1.0.0", diff --git a/examples/tanstack-vanilla-ts/src/client/create.tsx b/examples/tanstack-vanilla-ts/src/client/create.tsx index f4d32c56..a040484a 100644 --- a/examples/tanstack-vanilla-ts/src/client/create.tsx +++ b/examples/tanstack-vanilla-ts/src/client/create.tsx @@ -1,19 +1,8 @@ import { createRouter } from '@tanstack/react-router' import { routeTree } from './routes.ts' -interface User { - name: string -} - -export interface RouterContext { - user: User | null -} - -export function createAppRouter(req?: { user?: User | null }) { +export function createAppRouter() { return createRouter({ routeTree, - context: { - user: req?.user ?? null, - }, }) } diff --git a/examples/tanstack-vanilla-ts/src/client/routes.ts b/examples/tanstack-vanilla-ts/src/client/routes.ts index 2ce1f01d..dda788a4 100644 --- a/examples/tanstack-vanilla-ts/src/client/routes.ts +++ b/examples/tanstack-vanilla-ts/src/client/routes.ts @@ -1,15 +1,7 @@ import { createElement, Fragment } from 'react' -import { - createRootRouteWithContext, - createRoute, - Outlet, - Scripts, - redirect, - useLoaderData, -} from '@tanstack/react-router' -import type { RouterContext } from './create.tsx' +import { createRootRoute, createRoute, Outlet, Scripts } from '@tanstack/react-router' -const rootRoute = createRootRouteWithContext()({ +const rootRoute = createRootRoute({ component: function Root() { return createElement(Fragment, null, createElement(Outlet), createElement(Scripts)) }, @@ -18,62 +10,9 @@ const rootRoute = createRootRouteWithContext()({ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - beforeLoad: ({ context }) => { - if (!context.user) { - throw redirect({ to: '/login' }) - } - }, - loader: ({ context }) => { - return { user: context.user! } - }, component: function Index() { - const { user } = useLoaderData({ from: '/' }) - return createElement( - 'div', - null, - createElement('h1', null, `Hello ${user.name} from TanStack Router SSR!`), - createElement( - 'form', - { - onSubmit: async (e: { preventDefault: () => void }) => { - e.preventDefault() - await fetch('/api/logout', { method: 'POST' }) - window.location.href = '/login' - }, - }, - createElement('button', { type: 'submit' }, 'Logout'), - ), - ) - }, -}) - -const loginRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/login', - component: function Login() { - return createElement( - 'div', - null, - createElement('h1', null, 'Login'), - createElement( - 'form', - { - onSubmit: async (e: { preventDefault: () => void; target: any }) => { - e.preventDefault() - const form = new FormData(e.target) - await fetch('/api/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: form.get('username') }), - }) - window.location.href = '/' - }, - }, - createElement('input', { name: 'username', placeholder: 'Username', required: true }), - createElement('button', { type: 'submit' }, 'Sign in'), - ), - ) + return createElement('h1', null, 'Hello from TanStack Router SSR!') }, }) -export const routeTree = rootRoute.addChildren([indexRoute, loginRoute]) +export const routeTree = rootRoute.addChildren([indexRoute]) diff --git a/examples/tanstack-vanilla-ts/src/server.ts b/examples/tanstack-vanilla-ts/src/server.ts index 995783ab..284907a0 100644 --- a/examples/tanstack-vanilla-ts/src/server.ts +++ b/examples/tanstack-vanilla-ts/src/server.ts @@ -1,33 +1,11 @@ import { resolve } from 'node:path' import Fastify from 'fastify' import FastifyVite from '@fastify/vite' -import FastifyCookie from '@fastify/cookie' import * as renderer from '@fastify/tanstack' export async function main(dev?: boolean) { const server = Fastify() - await server.register(FastifyCookie) - - // Simulate auth: parse the "user" cookie on every request - server.addHook('onRequest', async (req) => { - const username = req.cookies.user - ;(req as any).user = username ? { name: username } : null - }) - - // Login endpoint sets the cookie - server.post('/api/login', async (req, reply) => { - const { username } = req.body as { username: string } - reply.setCookie('user', username, { path: '/', httpOnly: true }) - return { ok: true } - }) - - // Logout endpoint clears the cookie - server.post('/api/logout', async (req, reply) => { - reply.clearCookie('user', { path: '/' }) - return { ok: true } - }) - await server.register(FastifyVite, { root: resolve(import.meta.dirname, '..'), dev: dev || process.argv.includes('--dev'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c91cbedb..f18e5338 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -406,9 +406,6 @@ importers: examples/tanstack-vanilla-ts: dependencies: - '@fastify/cookie': - specifier: ^11.0.2 - version: 11.0.2 '@fastify/tanstack': specifier: workspace:^ version: link:../../packages/fastify-tanstack @@ -2259,9 +2256,6 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} - '@fastify/cookie@11.0.2': - resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} - '@fastify/deepmerge@3.2.1': resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} @@ -7114,11 +7108,6 @@ snapshots: ajv-formats: 3.0.1(ajv@8.17.1) fast-uri: 3.1.0 - '@fastify/cookie@11.0.2': - dependencies: - cookie: 1.1.1 - fastify-plugin: 5.1.0 - '@fastify/deepmerge@3.2.1': {} '@fastify/error@4.2.0': {}