Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion docs/start/framework/react/guide/hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,70 @@ bun run server.ts
🚀 Server running at http://localhost:3000
```

For a complete working example, check out the [TanStack Start + Bun example](https://github.com/TanStack/router/tree/main/examples/react/start-bun) in this repository.
For a complete working example of **Vite build + Bun HTTP host**, check out the [TanStack Start + Bun example](https://github.com/TanStack/router/tree/main/examples/react/start-bun) in this repository.

### Bun as the bundler (experimental)

There is also an experimental path that uses **Bun as the bundler** (no Vite), via `@tanstack/react-start/plugin/bun`:

```ts
import { tanstackStart } from '@tanstack/react-start/plugin/bun'

const start = tanstackStart({ bun: { port: 3000 } })
await start.build()
// or: await start.dev()
```

Default production output matches the **Rsbuild-style** host: `dist/client` + `dist/server/server.js` + `dist/server/host.js` (static assets then `fetch`). See the [`start-bun-bundler`](https://github.com/TanStack/router/tree/main/examples/react/start-bun-bundler) example. Solid/Vue mirrors: `@tanstack/solid-start/plugin/bun`, `@tanstack/vue-start/plugin/bun`.

#### Optional Nitro bridge (production only)

Unlike Vite Start (app composes `nitro()` from `nitro/vite`), the Bun bundler adapter **cannot** reuse `nitro/vite` (it depends on Vite Environments). Instead you can enable an optional post-build Nitro 3 bridge after dual `Bun.build`:

```bash
npm install nitro
```

```ts
const start = tanstackStart({
bun: {
nitro: {
preset: 'node-server', // or 'bun', 'vercel', …
// config: { /* NitroConfig subset */ },
},
},
})
await start.build()
// → also writes .output/ (public + server); prerender uses .output/public
```

| Path | Role |
|------|------|
| Vite + `nitro/vite` | Bun/Node/… as **runtime** after Vite build |
| Bun bundler + `host.js` | Bun as **bundler**; deploy `dist/` with Bun (default) |
| Bun bundler + `bun.nitro` | Bun as **bundler**, then programmatic Nitro 3 → `.output` |
| Bun bundler + `bun.standalone` | Bun as **bundler**, then `Bun.build({ compile })` → single OS/arch executable (embeds `dist/client`) |

#### Optional Bun standalone executable (production only)

```ts
const start = tanstackStart({
bun: {
standalone: {
outfile: 'dist/server/start', // default
// target: 'linux-x64', // optional cross-compile
},
},
})
await start.build()
// → dist/server/start (large binary; run directly, set PORT/HOST)
```

Always compiles from **`dist/`** (not Nitro `.output`). Binary size includes the Bun runtime and is platform-specific. Experimental — see [Bun executables](https://bun.com/docs/bundler/executables).

Dev still uses the Bun host (`createBunDevServer`); Nitro and standalone compile are production-build only.

> Module-level HMR and RSC are not part of this adapter yet.

### Appwrite Sites

Expand Down
53 changes: 53 additions & 0 deletions examples/react/start-bun-bundler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# TanStack Start + Bun Bundler

Minimal example that builds with **Bun as the bundler** (no Vite).

## vs `start-bun`

| | [`start-bun`](../start-bun) | **this example** |
|--|--|--|
| Dev / build | Vite (`vite dev` / `vite build`) | `tanstackStart().dev()` / `.build()` via Bun |
| Production host | `Bun.serve` + Vite `dist` | `host.js` / 可选 Nitro `.output` / 可选 standalone 可执行文件 |
| Plugin entry | `@tanstack/react-start/plugin/vite` | `@tanstack/react-start/plugin/bun` |

## Scripts

```bash
cd examples/react/start-bun-bundler
bun run build # → dist/client + dist/server/server.js + host.js
bun run start # bun dist/server/host.js
bun run build:nitro # + bun.nitro → .output/
bun run start:nitro # node .output/server/index.mjs
bun run build:standalone # + bun.standalone → dist/server/start
bun run start:standalone # ./dist/server/start
bun run dev
bun run smoke
bun run smoke:nitro
bun run smoke:standalone
```

## Production hosts

1. **Default (Rsbuild-style):** `dist/server/host.js` — deploy `dist/` + Bun
2. **Optional Nitro:** `bun.nitro` → `.output`(多 preset)
3. **Optional standalone executable:** `bun.standalone` → `dist/server/start`(嵌入 `dist/client`;体积大、按 OS/arch)

`bun.nitro` 与 `bun.standalone` 可并存;standalone **始终基于 `dist/`**,不从 `.output` 再编译。

## What this proves

- Dual `Bun.build` without Vite
- SSR + prerender + static host / optional Nitro / optional `--compile` executable
- Code-splitting, import protection, CSS pipeline, ESM HMR(dev)

## Known gaps

- No RSC;Nitro/standalone 仅生产;asset 管线仍薄于 Vite

See `packages/start-plugin-core/src/bun/ARCHITECTURE.md`.

## 给其它仓库用(GitHub Packages)

本 fork 可通过 GitHub Packages 发布 `@running-grass/*`(脚本重写 scope)。其它仓用 npm alias 继续依赖 `@tanstack/*`。

详见 [`scripts/github-packages/README.md`](../../../scripts/github-packages/README.md)。
32 changes: 32 additions & 0 deletions examples/react/start-bun-bundler/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "tanstack-start-bun-bundler",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run ./scripts/dev.ts",
"build": "bun run ./scripts/build.ts",
"build:nitro": "bun run ./scripts/build-nitro.ts",
"build:standalone": "bun run ./scripts/build-standalone.ts",
"start": "bun run ./dist/server/host.js",
"start:nitro": "node .output/server/index.mjs",
"start:standalone": "./dist/server/start",
"smoke": "bun run ./scripts/smoke.ts",
"smoke:nitro": "bun run ./scripts/smoke-nitro.ts",
"smoke:standalone": "bun run ./scripts/smoke-standalone.ts",
"test:e2e": "bun run smoke"
},
"dependencies": {
"@tanstack/react-router": "workspace:*",
"@tanstack/react-start": "workspace:*",
"@tanstack/router-plugin": "workspace:*",
"nitro": "npm:nitro-nightly@latest",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@types/bun": "^1.2.22",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"typescript": "^5.9.0"
}
}
19 changes: 19 additions & 0 deletions examples/react/start-bun-bundler/scripts/build-nitro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { tanstackStart } from '@tanstack/react-start/plugin/bun'

const start = tanstackStart({
pages: [{ path: '/' }],
prerender: {
enabled: true,
failOnError: true,
},
bun: {
nitro: {
preset: 'node-server',
},
},
})

await start.build()
console.info(
'[start-bun-bundler] nitro build complete → dist/* + .output/public + .output/server',
)
19 changes: 19 additions & 0 deletions examples/react/start-bun-bundler/scripts/build-standalone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { tanstackStart } from '@tanstack/react-start/plugin/bun'

const start = tanstackStart({
pages: [{ path: '/' }],
prerender: {
enabled: true,
failOnError: true,
},
bun: {
standalone: {
outfile: 'dist/server/start',
},
},
})

await start.build()
console.info(
'[start-bun-bundler] standalone build complete → dist/server/start',
)
12 changes: 12 additions & 0 deletions examples/react/start-bun-bundler/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { tanstackStart } from '@tanstack/react-start/plugin/bun'

const start = tanstackStart({
pages: [{ path: '/' }],
prerender: {
enabled: true,
failOnError: true,
},
})

await start.build()
console.info('[start-bun-bundler] build complete → dist/client + dist/server')
4 changes: 4 additions & 0 deletions examples/react/start-bun-bundler/scripts/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { tanstackStart } from '@tanstack/react-start/plugin/bun'

const start = tanstackStart({ bun: { port: 3000 } })
await start.dev()
95 changes: 95 additions & 0 deletions examples/react/start-bun-bundler/scripts/smoke-nitro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Smoke check: Nitro bridge build → .output/server → assert `/`, assets, public dir.
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'

const root = join(import.meta.dir, '..')
const port = 3458
const host = '127.0.0.1'

async function waitForServer(url: string, attempts = 60) {
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(url)
if (res.ok || res.status === 200) {
return
}
} catch {
// retry
}
await Bun.sleep(150)
}
throw new Error(`Server did not become ready at ${url}`)
}

console.info('[smoke-nitro] building with bun.nitro…')
const build = spawn('bun', ['run', './scripts/build-nitro.ts'], {
cwd: root,
stdio: 'inherit',
})
await new Promise<void>((resolve, reject) => {
build.on('exit', (code) =>
code === 0 ? resolve() : reject(new Error(`build-nitro exited ${code}`)),
)
})

const publicDir = join(root, '.output/public')
const serverEntry = join(root, '.output/server/index.mjs')
if (!existsSync(publicDir)) {
throw new Error(`missing ${publicDir}`)
}
if (!existsSync(serverEntry)) {
throw new Error(`missing ${serverEntry}`)
}

const assetFiles = [...new Bun.Glob('assets/**/*').scanSync({ cwd: publicDir })]
if (assetFiles.length === 0) {
throw new Error(`.output/public has no assets/ files`)
}

console.info('[smoke-nitro] starting .output/server/index.mjs…')
const server = spawn('node', [serverEntry], {
cwd: root,
env: { ...process.env, PORT: String(port), NITRO_PORT: String(port) },
stdio: ['ignore', 'pipe', 'pipe'],
})

let stderr = ''
server.stderr?.on('data', (chunk) => {
stderr += String(chunk)
})

try {
await waitForServer(`http://${host}:${port}/`)

const home = await fetch(`http://${host}:${port}/`)
const homeHtml = await home.text()
if (!home.ok) {
throw new Error(`GET / → ${home.status}`)
}
if (!homeHtml.includes('Hello from Bun-bundled Start')) {
throw new Error('GET / missing loader message in HTML')
}

const preloadMatch = homeHtml.match(
/modulepreload[^>]+href="(\/assets\/[^"]+\.js)"/,
)
if (!preloadMatch?.[1]) {
throw new Error('GET / missing modulepreload asset href')
}
const asset = await fetch(`http://${host}:${port}${preloadMatch[1]}`)
if (!asset.ok) {
throw new Error(`GET ${preloadMatch[1]} → ${asset.status}`)
}

console.info('[smoke-nitro] ok')
} catch (err) {
if (stderr) {
console.error('[smoke-nitro] server stderr:\n', stderr)
}
throw err
} finally {
server.kill('SIGTERM')
}
Loading