使用 Bun 啟動 TanStack Start
TanStack Start 是一個由 TanStack Router 和 Vite 驅動的全棧框架。它支援完整文件 SSR、流式傳輸、伺服器函式和打包。
- 建立一個新的 TanStack Start 應用
使用互動式 CLI 建立一個新的 TanStack Start 應用。
terminalbunx @tanstack/cli create my-tanstack-app - 啟動開發伺服器
切換到專案目錄,並使用 Bun 啟動 Vite 開發伺服器。
terminalcd my-tanstack-app bun --bun run dev - 更新 package.json 中的指令碼
在
package.json的 scripts 欄位中,為 Vite CLI 命令新增bun --bun字首,以便 Bun 為dev、build和preview執行 Vite CLI。package.json{ "scripts": { "dev": "bun --bun vite dev", // [!code ++] "build": "bun --bun vite build", // [!code ++] "preview": "bun --bun vite preview" // [!code ++] } }
部署託管
要在生產環境中託管你的 TanStack Start 應用,請使用 Nitro 或自定義 Bun 伺服器。
- 將 Nitro 新增到專案
將 Nitro 新增到專案中,以便將你的 TanStack Start 應用部署到不同的平臺。
terminalbun add nitro - 更新你的
vite.config.ts檔案將 Nitro 外掛新增到你的
vite.config.ts檔案中。vite.config.ts
// 其他匯入... import { nitro } from "nitro/vite"; // [!code ++] const config = defineConfig({ plugins: [ tanstackStart(), nitro({ preset: "bun" }), // [!code ++] // 其他外掛... ], }); export default config; - 更新啟動命令
確保你的
package.json檔案中包含build和start指令碼:package.json{ "scripts": { "build": "bun --bun vite build", // [!code ++] // 當你執行 `bun run build` 時,Nitro 會建立 .output 資料夾。 // 部署到 Vercel 時不需要此設定。 "start": "bun run .output/server/index.mjs" // [!code ++] } } - 部署你的應用
使用以下指南之一,將你的應用部署到託管服務提供商。
此自定義伺服器基於 TanStack's Bun template。它讓你能夠精細控制靜態資源的提供方式:伺服器會將小檔案預載入到記憶體中,並按需提供較大的檔案。你可以設定伺服器預載入內容的限制。
- 建立生產伺服器
在專案根目錄中建立一個
server.ts檔案:server.ts
/** * TanStack Start 生產伺服器(基於 Bun) * * 一個高效能的 TanStack Start 生產伺服器,實現了智慧的靜態資源載入策略,支援記憶體管理設定。 * * 功能: * - 混合載入策略(預載入小檔案,按需提供大檔案) * - 可設定的檔案包含/排除規則 * - 記憶體高效響應生成 * - 生產環境快取頭設定 * * 環境變數說明: * * PORT (number) * - 伺服器監聽埠 * - 預設: 3000 * * ASSET_PRELOAD_MAX_SIZE (number) * - 預載入到記憶體的最大檔案大小(位元組) * - 超出此大小的檔案將按需從磁碟讀取 * - 預設: 5242880 (5MB) * - 例如: ASSET_PRELOAD_MAX_SIZE=5242880 (5MB) * * ASSET_PRELOAD_INCLUDE_PATTERNS (string) * - 逗號分隔的 glob 模式列表,指定包含的檔案 * - 如果設定,只有匹配的檔案才會被預載入 * - 匹配檔名而非完整路徑 * - 例如: ASSET_PRELOAD_INCLUDE_PATTERNS="*.js,*.css,*.woff2" * * ASSET_PRELOAD_EXCLUDE_PATTERNS (string) * - 逗號分隔的 glob 模式列表,指定排除的檔案 * - 在包含規則之後應用 * - 匹配檔名而非完整路徑 * - 例如: 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) /** * 將簡單的 glob 模式轉換為正規表示式 * 支援 * 萬用字元匹配任意字元 */ 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 }) } } /** * 建立複合 glob 模式,用於掃描檔案 */ 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, ) // 格式化檔案大小,包含實際 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 估算(通常 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 ? '記憶體' : '按需' 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('伺服器內部錯誤', { status: 500 }) } }, }, // 全域性錯誤處理 error(error) { log.error( `伺服器未捕獲錯誤: ${error instanceof Error ? error.message : String(error)}`, ) return new Response('伺服器內部錯誤', { status: 500 }) }, }) log.success(`伺服器已啟動,監聽地址:http://localhost:${String(server.port)}`) } // 啟動伺服器 initializeServer().catch((error: unknown) => { log.error(`啟動伺服器失敗: ${String(error)}`) process.exit(1) }) - 更新 package.json 指令碼
新增
start指令碼以執行自定義伺服器:package.json{ "scripts": { "build": "bun --bun vite build", "start": "bun run server.ts" // [!code ++] } } - 建置並執行
建置應用並啟動伺服器:
terminalbun run build bun run start伺服器預設監聽 3000 埠;設定
PORT環境變數即可更改埠。
在 Vercel 上部署
在 Render 上部署
在 Railway 上部署
在 DigitalOcean 上部署
在 AWS Lambda 上部署
在 Google Cloud Run 上部署
模板
一個使用 Bun、TanStack Start 和 PostgreSQL 建置的待辦事項應用。
一個使用 Bun 的 SSR 和基於檔案路由的 TanStack Start 模板。
基礎的 TanStack 啟動模板,使用 Bun 執行環境和 Bun 的檔案 APIs。