TanStack Start 是一個由 TanStack Router 提供支持的全棧框架。它支持全文檔 SSR、流式傳輸、服務器函數、打包等,由 TanStack Router 和 Vite 提供支持。
創建新的 TanStack Start 應用
使用交互式 CLI 創建一個新的 TanStack Start 應用。
sh
bun create @tanstack/start@latest my-tanstack-app啟動開發服務器
切換到項目目錄並使用 Bun 運行開發服務器。
sh
cd my-tanstack-app
bun --bun run dev這將使用 Bun 啟動 Vite 開發服務器。
更新 package.json 中的腳本
修改 package.json 中的 scripts 字段,在 Vite CLI 命令前加上 bun --bun 前綴。這確保 Bun 為 dev、build 和 preview 等常見任務執行 Vite CLI。
json
{
"scripts": {
"dev": "bun --bun vite dev",
"build": "bun --bun vite build",
"serve": "bun --bun vite preview"
}
}托管
要托管你的 TanStack Start 應用,你可以使用 Nitro 或自定義 Bun 服務器進行生產部署。
Nitro
將 Nitro 添加到你的項目
將 Nitro 添加到你的項目。此工具允許你將 TanStack Start 應用部署到不同的平台。
sh
bun add nitro更新你的 vite.config.ts 文件
更新你的 vite.config.ts 文件,包含 TanStack Start 與 Bun 所需的插件。
ts
// 其他導入...
import { nitro } from "nitro/vite";
const config = defineConfig({
plugins: [
tanstackStart(),
nitro({ preset: "bun" }),
// 其他插件...
],
});
export default config;NOTE
`bun` 預設是可選的,但它專門為 Bun 運行時配置構建輸出。更新啟動命令
確保 package.json 文件中存在 build 和 start 腳本:
json
{
"scripts": {
"build": "bun --bun vite build",
// .output 文件在你運行 `bun run build` 時由 Nitro 創建。
// 部署到 Vercel 時不需要。
"start": "bun run .output/server/index.mjs"
}
}NOTE
部署到 Vercel 時,你**不**需要自定義 `start` 腳本。部署你的應用
查看我們的指南之一,將你的應用部署到托管提供商。
NOTE
部署到 Vercel 時,你可以將 `"bunVersion": "1.x"` 添加到你的 `vercel.json` 文件,或者將其添加到 `vite.config.ts` 文件中的 `nitro` 配置:警告
部署到 Vercel 時,不要使用 bun Nitro 預設。
ts
export default defineConfig({
plugins: [
tanstackStart(),
nitro({
preset: "bun",
vercel: {
functions: {
runtime: "bun1.x",
},
},
}),
],
});自定義服務器
NOTE
此自定義服務器實現基於 [TanStack 的 Bun 模板](https://github.com/TanStack/router/blob/main/examples/react/start-bun/server.ts)。它提供對靜態資源服務的細粒度控制,包括可配置的內存管理,將小文件預加載到內存中以快速服務,同時按需服務大文件。當你在生產部署中需要精確控制資源使用情況和資源加載行為時,這種方法很有用。創建生產服務器
在項目根目錄中創建一個 server.ts 文件,包含以下自定義服務器實現:
ts
/**
* 使用 Bun 的 TanStack Start 生產服務器
*
* 一個用於 TanStack Start 應用的高性能生產服務器,
* 實現了具有可配置內存管理的智能靜態資源加載。
*
* 特性:
* - 混合加載策略(預加載小文件,按需服務大文件)
* - 具有包含/排除模式的可配置文件過濾
* - 內存高效的響應生成
* - 生產就緒的緩存頭
*
* 環境變量:
*
* PORT (number)
* - 服務器端口號
* - 默認值:3000
*
* ASSET_PRELOAD_MAX_SIZE (number)
* - 預加載到內存中的最大文件大小(字節)
* - 大於此文件將從磁盤按需服務
* - 默認值:5242880 (5MB)
* - 示例:ASSET_PRELOAD_MAX_SIZE=5242880 (5MB)
*
* ASSET_PRELOAD_INCLUDE_PATTERNS (string)
* - 要包含的文件的全局模式逗號分隔列表
* - 如果指定,只有匹配的文件才有資格預加載
* - 模式僅與文件名匹配,而不是完整路徑
* - 示例:ASSET_PRELOAD_INCLUDE_PATTERNS="*.js,*.css,*.woff2"
*
* ASSET_PRELOAD_EXCLUDE_PATTERNS (string)
* - 要排除的文件的全局模式逗號分隔列表
* - 在包含模式之後應用
* - 模式僅與文件名匹配,而不是完整路徑
* - 示例:ASSET_PRELOAD_EXCLUDE_PATTERNS="*.map,*.txt"
*
* ASSET_PRELOAD_VERBOSE_LOGGING (boolean)
* - 啟用已加載和跳過文件的詳細日志
* - 默認值:false
* - 設置為 "true" 啟用詳細輸出
*
* ASSET_PRELOAD_ENABLE_ETAG (boolean)
* - 為預加載資源啟用 ETag 生成
* - 默認值:true
* - 設置為 "false" 禁用 ETag 支持
*
* ASSET_PRELOAD_ENABLE_GZIP (boolean)
* - 為符合條件的資源啟用 Gzip 壓縮
* - 默認值:true
* - 設置為 "false" 禁用 Gzip 壓縮
*
* ASSET_PRELOAD_GZIP_MIN_SIZE (number)
* - Gzip 壓縮所需的最小文件大小(字節)
* - 小於此值的文件將不會被壓縮
* - 默認值:1024 (1KB)
*
* ASSET_PRELOAD_GZIP_MIME_TYPES (string)
* - 符合 Gzip 壓縮條件的 MIME 類型逗號分隔列表
* - 支持以 "/" 結尾的類型部分匹配
* - 默認值:text/,application/javascript,application/json,application/xml,image/svg+xml
*
* 用法:
* bun run server.ts
*/
import path from 'node:path'
// 配置
const SERVER_PORT = Number(process.env.PORT ?? 3000)
const CLIENT_DIRECTORY = './dist/client'
const SERVER_ENTRY_POINT = './dist/server/server.js'
// 用於專業輸出的日志工具
const log = {
info: (message: string) => {
console.log(`[INFO] ${message}`)
},
success: (message: string) => {
console.log(`[SUCCESS] ${message}`)
},
warning: (message: string) => {
console.log(`[WARNING] ${message}`)
},
error: (message: string) => {
console.log(`[ERROR] ${message}`)
},
header: (message: string) => {
console.log(`\n${message}\n`)
},
}
// 從環境變量加載預加載配置
const MAX_PRELOAD_BYTES = Number(
process.env.ASSET_PRELOAD_MAX_SIZE ?? 5 * 1024 * 1024, // 默認 5MB
)
// 解析逗號分隔的包含模式(無默認值)
const INCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((pattern: string) => convertGlobToRegExp(pattern))
// 解析逗號分隔的排除模式(無默認值)
const EXCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((pattern: string) => convertGlobToRegExp(pattern))
// 詳細日志標志
const VERBOSE = process.env.ASSET_PRELOAD_VERBOSE_LOGGING === 'true'
// 可選 ETag 功能
const ENABLE_ETAG = (process.env.ASSET_PRELOAD_ENABLE_ETAG ?? 'true') === 'true'
// 可選 Gzip 功能
const ENABLE_GZIP = (process.env.ASSET_PRELOAD_ENABLE_GZIP ?? 'true') === 'true'
const GZIP_MIN_BYTES = Number(process.env.ASSET_PRELOAD_GZIP_MIN_SIZE ?? 1024) // 1KB
const GZIP_TYPES = (
process.env.ASSET_PRELOAD_GZIP_MIME_TYPES ??
'text/,application/javascript,application/json,application/xml,image/svg+xml'
)
.split(',')
.map((v) => v.trim())
.filter(Boolean)
/**
* 將簡單的全局模式轉換為正則表達式
* 支持 * 通配符匹配任何字符
*/
function convertGlobToRegExp(globPattern: string): RegExp {
// 轉義正則特殊字符(* 除外),然後將 * 替換為 .*
const escapedPattern = globPattern
.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&')
.replace(/\*/g, '.*')
return new RegExp(`^${escapedPattern}$`, 'i')
}
/**
* 計算給定數據緩沖區的 ETag
*/
function computeEtag(data: Uint8Array): string {
const hash = Bun.hash(data)
return `W/"${hash.toString(16)}-${data.byteLength.toString()}"`
}
/**
* 預加載靜態資源的元數據
*/
interface AssetMetadata {
route: string
size: number
type: string
}
/**
* 具有 ETag 和 Gzip 支持的內存資源
*/
interface InMemoryAsset {
raw: Uint8Array
gz?: Uint8Array
etag?: string
type: string
immutable: boolean
size: number
}
/**
* 靜態資源預加載過程的結果
*/
interface PreloadResult {
routes: Record<string, (req: Request) => Response | Promise<Response>>
loaded: AssetMetadata[]
skipped: AssetMetadata[]
}
/**
* 根據配置的模式檢查文件是否有資格預加載
*/
function isFileEligibleForPreloading(relativePath: string): boolean {
const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath
// 如果指定了包含模式,文件必須至少匹配一個
if (INCLUDE_PATTERNS.length > 0) {
if (!INCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
return false
}
}
// 如果指定了排除模式,文件不得匹配任何模式
if (EXCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
return false
}
return true
}
/**
* 檢查 MIME 類型是否可壓縮
*/
function isMimeTypeCompressible(mimeType: string): boolean {
return GZIP_TYPES.some((type) =>
type.endsWith('/') ? mimeType.startsWith(type) : mimeType === type,
)
}
/**
* 根據大小和 MIME 類型有條件地壓縮數據
*/
function compressDataIfAppropriate(
data: Uint8Array,
mimeType: string,
): Uint8Array | undefined {
if (!ENABLE_GZIP) return undefined
if (data.byteLength < GZIP_MIN_BYTES) return undefined
if (!isMimeTypeCompressible(mimeType)) return undefined
try {
return Bun.gzipSync(data.buffer as ArrayBuffer)
} catch {
return undefined
}
}
/**
* 創建具有 ETag 和 Gzip 支持的響應處理函數
*/
function createResponseHandler(
asset: InMemoryAsset,
): (req: Request) => Response {
return (req: Request) => {
const headers: Record<string, string> = {
'Content-Type': asset.type,
'Cache-Control': asset.immutable
? 'public, max-age=31536000, immutable'
: 'public, max-age=3600',
}
if (ENABLE_ETAG && asset.etag) {
const ifNone = req.headers.get('if-none-match')
if (ifNone && ifNone === asset.etag) {
return new Response(null, {
status: 304,
headers: { ETag: asset.etag },
})
}
headers.ETag = asset.etag
}
if (
ENABLE_GZIP &&
asset.gz &&
req.headers.get('accept-encoding')?.includes('gzip')
) {
headers['Content-Encoding'] = 'gzip'
headers['Content-Length'] = String(asset.gz.byteLength)
const gzCopy = new Uint8Array(asset.gz)
return new Response(gzCopy, { status: 200, headers })
}
headers['Content-Length'] = String(asset.raw.byteLength)
const rawCopy = new Uint8Array(asset.raw)
return new Response(rawCopy, { status: 200, headers })
}
}
/**
* 從包含模式創建復合全局模式
*/
function createCompositeGlobPattern(): Bun.Glob {
const raw = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (raw.length === 0) return new Bun.Glob('**/*')
if (raw.length === 1) return new Bun.Glob(raw[0])
return new Bun.Glob(`{${raw.join(',')}}`)
}
/**
* 使用智能預加載策略初始化靜態路由
* 小文件加載到內存中,大文件按需服務
*/
async function initializeStaticRoutes(
clientDirectory: string,
): Promise<PreloadResult> {
const routes: Record<string, (req: Request) => Response | Promise<Response>> =
{}
const loaded: AssetMetadata[] = []
const skipped: AssetMetadata[] = []
log.info(`正在從 ${clientDirectory} 加載靜態資源...`)
if (VERBOSE) {
console.log(
`最大預加載大小:${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`,
)
if (INCLUDE_PATTERNS.length > 0) {
console.log(
`包含模式:${process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? ''}`,
)
}
if (EXCLUDE_PATTERNS.length > 0) {
console.log(
`排除模式:${process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? ''}`,
)
}
}
let totalPreloadedBytes = 0
try {
const glob = createCompositeGlobPattern()
for await (const relativePath of glob.scan({ cwd: clientDirectory })) {
const filepath = path.join(clientDirectory, relativePath)
const route = `/${relativePath.split(path.sep).join(path.posix.sep)}`
try {
// 獲取文件元數據
const file = Bun.file(filepath)
// 如果文件不存在或為空則跳過
if (!(await file.exists()) || file.size === 0) {
continue
}
const metadata: AssetMetadata = {
route,
size: file.size,
type: file.type || 'application/octet-stream',
}
// 確定是否應該預加載文件
const matchesPattern = isFileEligibleForPreloading(relativePath)
const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES
if (matchesPattern && withinSizeLimit) {
// 將小文件預加載到內存中,支持 ETag 和 Gzip
const bytes = new Uint8Array(await file.arrayBuffer())
const gz = compressDataIfAppropriate(bytes, metadata.type)
const etag = ENABLE_ETAG ? computeEtag(bytes) : undefined
const asset: InMemoryAsset = {
raw: bytes,
gz,
etag,
type: metadata.type,
immutable: true,
size: bytes.byteLength,
}
routes[route] = createResponseHandler(asset)
loaded.push({ ...metadata, size: bytes.byteLength })
totalPreloadedBytes += bytes.byteLength
} else {
// 按需服務大文件或過濾的文件
routes[route] = () => {
const fileOnDemand = Bun.file(filepath)
return new Response(fileOnDemand, {
headers: {
'Content-Type': metadata.type,
'Cache-Control': 'public, max-age=3600',
},
})
}
skipped.push(metadata)
}
} catch (error: unknown) {
if (error instanceof Error && error.name !== 'EISDIR') {
log.error(`加載 ${filepath} 失敗:${error.message}`)
}
}
}
// 僅在啟用詳細模式時顯示詳細的文件概述
if (VERBOSE && (loaded.length > 0 || skipped.length > 0)) {
const allFiles = [...loaded, ...skipped].sort((a, b) =>
a.route.localeCompare(b.route),
)
// 計算最大路徑長度以對齊
const maxPathLength = Math.min(
Math.max(...allFiles.map((f) => f.route.length)),
60,
)
// 格式化文件大小(KB 和實際 gzip 大小)
const formatFileSize = (bytes: number, gzBytes?: number) => {
const kb = bytes / 1024
const sizeStr = kb < 100 ? kb.toFixed(2) : kb.toFixed(1)
if (gzBytes !== undefined) {
const gzKb = gzBytes / 1024
const gzStr = gzKb < 100 ? gzKb.toFixed(2) : gzKb.toFixed(1)
return {
size: sizeStr,
gzip: gzStr,
}
}
// 如果沒有實際 gzip 數據,粗略 gzip 估計(通常 30-70% 壓縮)
const gzipKb = kb * 0.35
return {
size: sizeStr,
gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
}
}
if (loaded.length > 0) {
console.log('\n📁 預加載到內存中:')
console.log(
'路徑 │ 大小 │ Gzip 大小',
)
loaded
.sort((a, b) => a.route.localeCompare(b.route))
.forEach((file) => {
const { size, gzip } = formatFileSize(file.size)
const paddedPath = file.route.padEnd(maxPathLength)
const sizeStr = `${size.padStart(7)} kB`
const gzipStr = `${gzip.padStart(7)} kB`
console.log(`${paddedPath} │ ${sizeStr} │ ${gzipStr}`)
})
}
if (skipped.length > 0) {
console.log('\n💾 按需服務:')
console.log(
'路徑 │ 大小 │ Gzip 大小',
)
skipped
.sort((a, b) => a.route.localeCompare(b.route))
.forEach((file) => {
const { size, gzip } = formatFileSize(file.size)
const paddedPath = file.route.padEnd(maxPathLength)
const sizeStr = `${size.padStart(7)} kB`
const gzipStr = `${gzip.padStart(7)} kB`
console.log(`${paddedPath} │ ${sizeStr} │ ${gzipStr}`)
})
}
}
// 如果啟用則顯示詳細詳細信息
if (VERBOSE) {
if (loaded.length > 0 || skipped.length > 0) {
const allFiles = [...loaded, ...skipped].sort((a, b) =>
a.route.localeCompare(b.route),
)
console.log('\n📊 詳細文件信息:')
console.log(
'狀態 │ 路徑 │ MIME 類型 │ 原因',
)
allFiles.forEach((file) => {
const isPreloaded = loaded.includes(file)
const status = isPreloaded ? 'MEMORY' : 'ON-DEMAND'
const reason =
!isPreloaded && file.size > MAX_PRELOAD_BYTES
? '太大'
: !isPreloaded
? '已過濾'
: '已預加載'
const route =
file.route.length > 30
? file.route.substring(0, 27) + '...'
: file.route
console.log(
`${status.padEnd(12)} │ ${route.padEnd(30)} │ ${file.type.padEnd(28)} │ ${reason.padEnd(10)}`,
)
})
} else {
console.log('\n📊 未找到要顯示的文件')
}
}
// 在文件列表後記錄摘要
console.log() // 空行分隔
if (loaded.length > 0) {
log.success(
`已預加載 ${String(loaded.length)} 個文件(${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB)到內存中`,
)
} else {
log.info('沒有文件預加載到內存中')
}
if (skipped.length > 0) {
const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length
const filtered = skipped.length - tooLarge
log.info(
`${String(skipped.length)} 個文件將按需服務(${String(tooLarge)} 太大,${String(filtered)} 已過濾)`,
)
}
} catch (error) {
log.error(
`從 ${clientDirectory} 加載靜態文件失敗:${String(error)}`,
)
}
return { routes, loaded, skipped }
}
/**
* 初始化服務器
*/
async function initializeServer() {
log.header('正在啟動生產服務器')
// 加載 TanStack Start 服務器處理程序
let handler: { fetch: (request: Request) => Response | Promise<Response> }
try {
const serverModule = (await import(SERVER_ENTRY_POINT)) as {
default: { fetch: (request: Request) => Response | Promise<Response> }
}
handler = serverModule.default
log.success('TanStack Start 應用處理程序已初始化')
} catch (error) {
log.error(`加載服務器處理程序失敗:${String(error)}`)
process.exit(1)
}
// 使用智能預加載構建靜態路由
const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY)
// 創建 Bun 服務器
const server = Bun.serve({
port: SERVER_PORT,
routes: {
// 提供靜態資源(預加載或按需)
...routes,
// 所有其他路由回退到 TanStack Start 處理程序
'/*': (req: Request) => {
try {
return handler.fetch(req)
} catch (error) {
log.error(`服務器處理程序錯誤:${String(error)}`)
return new Response('Internal Server Error', { status: 500 })
}
},
},
// 全局錯誤處理程序
error(error) {
log.error(
`未捕獲的服務器錯誤:${error instanceof Error ? error.message : String(error)}`,
)
return new Response('Internal Server Error', { status: 500 })
},
})
log.success(`服務器正在監聽 http://localhost:${String(server.port)}`)
}
// 初始化服務器
initializeServer().catch((error: unknown) => {
log.error(`啟動服務器失敗:${String(error)}`)
process.exit(1)
})更新 package.json 腳本
添加一個 start 腳本來運行自定義服務器:
json
{
"scripts": {
"build": "bun --bun vite build",
"start": "bun run server.ts"
}
}構建和運行
構建你的應用並啟動服務器:
sh
bun run build
bun run start服務器將默認在端口 3000 上啟動(可通過 PORT 環境變量配置)。
模板
→ 查看 TanStack Start 官方文檔 獲取有關托管的更多信息。