TanStack Start — это полнофункциональный фреймворк, работающий на TanStack Router. Он поддерживает полнодокументный SSR, потоковую передачу, серверные функции, сборку и многое другое, работающий на TanStack Router и Vite.
Создание нового приложения TanStack Start
Используйте интерактивный CLI для создания нового приложения TanStack Start.
bun create @tanstack/start@latest my-tanstack-appЗапуск dev сервера
Перейдите в директорию проекта и запустите dev сервер с помощью Bun.
cd my-tanstack-app
bun --bun run devЭто запускает Vite dev сервер с Bun.
Обновление скриптов в package.json
Измените поле scripts в вашем package.json, добавив префикс bun --bun к командам Vite CLI. Это гарантирует, что Bun выполняет Vite CLI для общих задач, таких как dev, build и preview.
{
"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 на различных платформах.
bun add nitroОбновление файла vite.config.ts
Обновите ваш файл vite.config.ts, включив необходимые плагины для TanStack Start с Bun.
// другие импорты...
import { nitro } from "nitro/vite";
const config = defineConfig({
plugins: [
tanstackStart(),
nitro({ preset: "bun" }),
// другие плагины...
],
});
export default config;NOTE
Пресет `bun` не является обязательным, но он настраивает выходные данные сборки специально для среды выполнения Bun.Обновление команды запуска
Убедитесь, что скрипты build и start присутствуют в вашем файле package.json:
{
"scripts": {
"build": "bun --bun vite build",
// Файлы .output создаются Nitro при запуске `bun run build`.
// Не требуется при развёртывании на Vercel.
"start": "bun run .output/server/index.mjs"
}
}NOTE
Вам **не** нужен пользовательский скрипт `start` при развёртывании на Vercel.Развёртывание вашего приложения
Ознакомьтесь с одним из наших руководств по развёртыванию вашего приложения на хостинг-провайдере.
NOTE
При развёртывании на Vercel вы можете либо добавить `"bunVersion": "1.x"` в ваш файл `vercel.json`, либо добавить это в конфигурацию `nitro` в вашем файле `vite.config.ts`:Предупреждение
Не используйте пресет bun Nitro при развёртывании на Vercel.
export default defineConfig({
plugins: [
tanstackStart(),
nitro({
preset: "bun",
vercel: {
functions: {
runtime: "bun1.x",
},
},
}),
],
});Пользовательский сервер
NOTE
Эта реализация пользовательского сервера основана на [шаблоне Bun от TanStack](https://github.com/TanStack/router/blob/main/examples/react/start-bun/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)
* - Разделённый запятыми список MIME-типов, подходящих для сжатия Gzip
* - Поддерживает частичное сопоставление для типов, заканчивающихся на "/"
* - По умолчанию: 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-шаблона в регулярное выражение
* Поддерживает * wildcard для сопоставления любых символов
*/
function convertGlobToRegExp(globPattern: string): RegExp {
// Экранирование специальных символов regex кроме *, затем замена * на .*
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}`)
}
}
}
// Показать подробный обзор файлов только когда включён режим verbose
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 (обычно сжатие 30-70%) если нет фактических данных gzip
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}`)
})
}
}
// Показать подробную информацию verbose если включено
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 Type │ Причина',
)
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 для запуска пользовательского сервера:
{
"scripts": {
"build": "bun --bun vite build",
"start": "bun run server.ts"
}
}Сборка и запуск
Соберите ваше приложение и запустите сервер:
bun run build
bun run startСервер запустится на порту 3000 по умолчанию (настраивается через переменную окружения PORT).
Шаблоны
→ Смотрите официальную документацию TanStack Start для получения дополнительной информации о хостинге.