Para comenzar, importa archivos HTML y pásalos a la opción routes en Bun.serve().
import { serve } from "bun";
import dashboard from "./dashboard.html";
import homepage from "./index.html";
const server = serve({
routes: {
// ** Imports HTML **
// Empaqueta y enruta index.html a "/". Esto usa HTMLRewriter para escanear
// el HTML en busca de etiquetas `<script>` y `<link>`, ejecuta el empaquetador
// de JavaScript y CSS de Bun en ellos, transpila cualquier TypeScript, JSX y TSX,
// convierte CSS con el parser CSS de Bun y sirve el resultado.
"/": homepage,
// Empaqueta y enruta dashboard.html a "/dashboard"
"/dashboard": dashboard,
// ** Endpoints de API ** (requiere Bun v1.2.3+)
"/api/users": {
async GET(req) {
const users = await sql`SELECT * FROM users`;
return Response.json(users);
},
async POST(req) {
const { name, email } = await req.json();
const [user] = await sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`;
return Response.json(user);
},
},
"/api/users/:id": async req => {
const { id } = req.params;
const [user] = await sql`SELECT * FROM users WHERE id = ${id}`;
return Response.json(user);
},
},
// Habilitar modo desarrollo para:
// - Mensajes de error detallados
// - Recarga en caliente (requiere Bun v1.2.3+)
development: true,
});
console.log(`Escuchando en ${server.url}`);bun run app.tsRutas HTML
Imports HTML como Rutas
La web comienza con HTML, y el servidor de desarrollo fullstack de Bun también.
Para especificar entrypoints a tu frontend, importa archivos HTML en tus archivos JavaScript/TypeScript/TSX/JSX.
import dashboard from "./dashboard.html";
import homepage from "./index.html";Estos archivos HTML se usan como rutas en el servidor de desarrollo de Bun que puedes pasar a Bun.serve().
Bun.serve({
routes: {
"/": homepage,
"/dashboard": dashboard,
},
fetch(req) {
// ... peticiones api
},
});Cuando haces una petición a /dashboard o /, Bun automáticamente empaqueta las etiquetas <script> y <link> en los archivos HTML, las expone como rutas estáticas y sirve el resultado.
Ejemplo de Procesamiento HTML
Un archivo index.html como este:
<!DOCTYPE html>
<html>
<head>
<title>Inicio</title>
<link rel="stylesheet" href="./reset.css" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./sentry-and-preloads.ts"></script>
<script type="module" src="./my-app.tsx"></script>
</body>
</html>Se convierte en algo como esto:
<!DOCTYPE html>
<html>
<head>
<title>Inicio</title>
<link rel="stylesheet" href="/index-[hash].css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/index-[hash].js"></script>
</body>
</html>Integración con React
Para usar React en tu código del lado del cliente, importa react-dom/client y renderiza tu aplicación.
import dashboard from "../public/dashboard.html";
import { serve } from "bun";
serve({
routes: {
"/": dashboard,
},
async fetch(req) {
// ...peticiones api
return new Response("hola mundo");
},
});import { createRoot } from 'react-dom/client';
import App from './app';
const container = document.getElementById('root');
const root = createRoot(container!);
root.render(<App />);<!DOCTYPE html>
<html>
<head>
<title>Dashboard</title>
<link rel="stylesheet" href="../src/styles.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="../src/frontend.tsx"></script>
</body>
</html>import { useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Dashboard</h1>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
</div>
);
}Modo Desarrollo
Al construir localmente, habilita el modo desarrollo estableciendo development: true en Bun.serve().
import homepage from "./index.html";
import dashboard from "./dashboard.html";
Bun.serve({
routes: {
"/": homepage,
"/dashboard": dashboard,
},
development: true,
fetch(req) {
// ... peticiones api
},
});Características del Modo Desarrollo
Cuando development es true, Bun:
- Incluye la cabecera SourceMap en la respuesta para que las devtools puedan mostrar el código fuente original
- Deshabilita la minificación
- Vuelve a empaquetar assets en cada petición a un archivo
.html - Habilita recarga de módulos en caliente (a menos que
hmr: falseesté establecido) - Repite logs de consola del navegador a la terminal
Configuración Avanzada de Desarrollo
Bun.serve() soporta repetir logs de consola del navegador a la terminal.
Para habilitar esto, pasa console: true en el objeto development en Bun.serve().
import homepage from "./index.html";
Bun.serve({
// development también puede ser un objeto.
development: {
// Habilitar Recarga de Módulos en Caliente
hmr: true,
// Repetir logs de consola del navegador a la terminal
console: true,
},
routes: {
"/": homepage,
},
});Cuando console: true está establecido, Bun transmitirá logs de consola del navegador a la terminal. Esto reutiliza la conexión WebSocket existente de HMR para enviar los logs.
Desarrollo vs Producción
| Característica | Desarrollo | Producción |
|---|---|---|
| Source maps | ✅ Habilitado | ❌ Deshabilitado |
| Minificación | ❌ Deshabilitado | ✅ Habilitado |
| Recarga en caliente | ✅ Habilitado | ❌ Deshabilitado |
| Empaquetado de assets | 🔄 En cada petición | 💾 En caché |
| Logs de consola | 🖥️ Navegador → Terminal | ❌ Deshabilitado |
| Detalles de error | 📝 Detallado | 🔒 Mínimo |
Modo Producción
La recarga en caliente y development: true te ayudan a iterar rápidamente, pero en producción, tu servidor debería ser lo más rápido posible y tener la menor cantidad de dependencias externas posible.
Empaquetado Anticipado (Recomendado)
A partir de Bun v1.2.17, puedes usar Bun.build o bun build para empaquetar tu aplicación full-stack con anticipación.
bun build --target=bun --production --outdir=dist ./src/index.tsCuando el empaquetador de Bun ve una importación HTML desde código del lado del servidor, empaquetará los archivos JavaScript/TypeScript/TSX/JSX y CSS referenciados en un objeto manifiesto que Bun.serve() puede usar para servir los assets.
import { serve } from "bun";
import index from "./index.html";
serve({
routes: { "/": index },
});Empaquetado en Runtime
Cuando agregar un paso de construcción es demasiado complicado, puedes establecer development: false en Bun.serve().
Esto:
- Habilita el almacenamiento en caché en memoria de assets empaquetados. Bun empaquetará assets perezosamente en la primera petición a un archivo
.html, y almacenará en caché el resultado en memoria hasta que el servidor se reinicie. - Habilita cabeceras
Cache-Controly cabecerasETag - Minifica archivos JavaScript/TypeScript/TSX/JSX
import { serve } from "bun";
import homepage from "./index.html";
serve({
routes: {
"/": homepage,
},
// Modo producción
development: false,
});Rutas de API
Manejadores de Método HTTP
Define endpoints de API con manejadores de método HTTP:
import { serve } from "bun";
serve({
routes: {
"/api/users": {
async GET(req) {
// Manejar peticiones GET
const users = await getUsers();
return Response.json(users);
},
async POST(req) {
// Manejar peticiones POST
const userData = await req.json();
const user = await createUser(userData);
return Response.json(user, { status: 201 });
},
async PUT(req) {
// Manejar peticiones PUT
const userData = await req.json();
const user = await updateUser(userData);
return Response.json(user);
},
async DELETE(req) {
// Manejar peticiones DELETE
await deleteUser(req.params.id);
return new Response(null, { status: 204 });
},
},
},
});Rutas Dinámicas
Usa parámetros URL en tus rutas:
serve({
routes: {
// Parámetro único
"/api/users/:id": async req => {
const { id } = req.params;
const user = await getUserById(id);
return Response.json(user);
},
// Múltiples parámetros
"/api/users/:userId/posts/:postId": async req => {
const { userId, postId } = req.params;
const post = await getPostByUser(userId, postId);
return Response.json(post);
},
// Rutas comodín
"/api/files/*": async req => {
const filePath = req.params["*"];
const file = await getFile(filePath);
return new Response(file);
},
},
});Manejo de Peticiones
serve({
routes: {
"/api/data": {
async POST(req) {
// Analizar cuerpo JSON
const body = await req.json();
// Acceder a cabeceras
const auth = req.headers.get("Authorization");
// Acceder a parámetros URL
const { id } = req.params;
// Acceder a parámetros de query
const url = new URL(req.url);
const page = url.searchParams.get("page") || "1";
// Devolver respuesta
return Response.json({
message: "Datos procesados",
page: parseInt(page),
authenticated: !!auth,
});
},
},
},
});Plugins
Los plugins del empaquetador de Bun también son compatibles al empaquetar rutas estáticas.
Para configurar plugins para Bun.serve, agrega un array plugins en la sección [serve.static] de tu bunfig.toml.
Plugin TailwindCSS
Puedes usar TailwindCSS instalando y agregando el paquete tailwindcss y el plugin bun-plugin-tailwind.
bun add tailwindcss bun-plugin-tailwind[serve.static]
plugins = ["bun-plugin-tailwind"]Esto te permitirá usar clases de utilidad TailwindCSS en tus archivos HTML y CSS. Todo lo que necesitas hacer es importar tailwindcss en algún lugar:
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="tailwindcss" />
</head>
<!-- el resto de tu HTML... -->
</html>Alternativamente, puedes importar TailwindCSS en tu archivo CSS:
@import "tailwindcss";
.custom-class {
@apply bg-red-500 text-white;
}<!doctype html>
<html>
<head>
<link rel="stylesheet" href="./style.css" />
</head>
<!-- el resto de tu HTML... -->
</html>Plugins Personalizados
Cualquier archivo JS o módulo que exporte un objeto de plugin de empaquetador válido (esencialmente un objeto con un campo name y setup) puede colocarse dentro del array de plugins:
[serve.static]
plugins = ["./my-plugin-implementation.ts"]import type { BunPlugin } from "bun";
const myPlugin: BunPlugin = {
name: "my-custom-plugin",
setup(build) {
// Implementación del plugin
build.onLoad({ filter: /\.custom$/ }, async args => {
const text = await Bun.file(args.path).text();
return {
contents: `export default ${JSON.stringify(text)};`,
loader: "js",
};
});
},
};
export default myPlugin;Bun resolverá y cargará perezosamente cada plugin y los usará para empaquetar tus rutas.
NOTE
Esto está actualmente en `bunfig.toml` para hacer posible saber estáticamente qué plugins están en uso cuando eventualmente integremos esto con el CLI `bun build`. Estos plugins funcionan en la API de JS de `Bun.build()`, pero aún no son compatibles en el CLI.Variables de Entorno en Línea
Bun puede reemplazar referencias process.env.* en tu JavaScript y TypeScript del frontend con sus valores reales en tiempo de construcción. Configura la opción env en tu bunfig.toml:
[serve.static]
env = "PUBLIC_*" # solo variables de entorno que comienzan con PUBLIC_ (recomendado)
# env = "inline" # incluir todas las variables de entorno
# env = "disable" # deshabilitar reemplazo de variables de entorno (predeterminado)Nota
Esto solo funciona con referencias literales process.env.FOO, no import.meta.env o acceso indirecto como const env = process.env; env.FOO.
Si una variable de entorno no está establecida, podrías ver errores en runtime como ReferenceError: process is not defined en el navegador.
Consulta la documentación de HTML y sitios estáticos para más detalles sobre configuración en tiempo de construcción y ejemplos.
Cómo Funciona
Bun usa HTMLRewriter para escanear etiquetas <script> y <link> en archivos HTML, las usa como entrypoints para el empaquetador de Bun, genera un bundle optimizado para los archivos JavaScript/TypeScript/TSX/JSX y CSS, y sirve el resultado.
Pipeline de Procesamiento
1. Procesamiento de script
- Transpila TypeScript, JSX y TSX en etiquetas
<script> - Empaqueta dependencias importadas
- Genera sourcemaps para depuración
- Minifica cuando
developmentno estrueenBun.serve()
<script type="module" src="./counter.tsx"></script>2. Procesamiento de
- Procesa imports CSS y etiquetas
<link> - Concatena archivos CSS
- Reescribe rutas url y de assets para incluir hashes content-addressable en URLs
<link rel="stylesheet" href="./styles.css" />3. Procesamiento de
y Assets
- Los enlaces a assets se reescriben para incluir hashes content-addressable en URLs
- Assets pequeños en archivos CSS se incluyen en línea como URLs
data:, reduciendo el número total de peticiones HTTP enviadas
4. Reescritura HTML
- Combina todas las etiquetas
<script>en una única etiqueta<script>con un hash content-addressable en la URL - Combina todas las etiquetas
<link>en una única etiqueta<link>con un hash content-addressable en la URL - Genera un nuevo archivo HTML
5. Sirviendo
- Todos los archivos de salida del empaquetador se exponen como rutas estáticas, usando el mismo mecanismo internamente que cuando pasas un objeto Response a
staticenBun.serve(). - Esto funciona de manera similar a cómo
Bun.buildprocesa archivos HTML.
Ejemplo Completo
Aquí hay un ejemplo completo de aplicación fullstack:
import { serve } from "bun";
import { Database } from "bun:sqlite";
import homepage from "./public/index.html";
import dashboard from "./public/dashboard.html";
// Inicializar base de datos
const db = new Database("app.db");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
const server = serve({
routes: {
// Rutas del frontend
"/": homepage,
"/dashboard": dashboard,
// Rutas de API
"/api/users": {
async GET() {
const users = db.query("SELECT * FROM users").all();
return Response.json(users);
},
async POST(req) {
const { name, email } = await req.json();
try {
const result = db.query("INSERT INTO users (name, email) VALUES (?, ?) RETURNING *").get(name, email);
return Response.json(result, { status: 201 });
} catch (error) {
return Response.json({ error: "El email ya existe" }, { status: 400 });
}
},
},
"/api/users/:id": {
async GET(req) {
const { id } = req.params;
const user = db.query("SELECT * FROM users WHERE id = ?").get(id);
if (!user) {
return Response.json({ error: "Usuario no encontrado" }, { status: 404 });
}
return Response.json(user);
},
async DELETE(req) {
const { id } = req.params;
const result = db.query("DELETE FROM users WHERE id = ?").run(id);
if (result.changes === 0) {
return Response.json({ error: "Usuario no encontrado" }, { status: 404 });
}
return new Response(null, { status: 204 });
},
},
// Endpoint de health check
"/api/health": {
GET() {
return Response.json({
status: "ok",
timestamp: new Date().toISOString(),
});
},
},
},
// Habilitar modo desarrollo
development: {
hmr: true,
console: true,
},
// Fallback para rutas no coincidentes
fetch(req) {
return new Response("No encontrado", { status: 404 });
},
});
console.log(`🚀 Servidor ejecutándose en ${server.url}`);<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Aplicación Fullstack Bun</title>
<link rel="stylesheet" href="../src/styles.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="../src/main.tsx"></script>
</body>
</html>import { createRoot } from "react-dom/client";
import { App } from "./App";
const container = document.getElementById("root")!;
const root = createRoot(container);
root.render(<App />);import { useState, useEffect } from "react";
interface User {
id: number;
name: string;
email: string;
created_at: string;
}
export function App() {
const [users, setUsers] = useState<User[]>([]);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const fetchUsers = async () => {
const response = await fetch("/api/users");
const data = await response.json();
setUsers(data);
};
const createUser = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email }),
});
if (response.ok) {
setName("");
setEmail("");
await fetchUsers();
} else {
const error = await response.json();
alert(error.error);
}
} catch (error) {
alert("Error al crear usuario");
} finally {
setLoading(false);
}
};
const deleteUser = async (id: number) => {
if (!confirm("¿Estás seguro?")) return;
try {
const response = await fetch(`/api/users/${id}`, {
method: "DELETE",
});
if (response.ok) {
await fetchUsers();
}
} catch (error) {
alert("Error al eliminar usuario");
}
};
useEffect(() => {
fetchUsers();
}, []);
return (
<div className="container">
<h1>Gestión de Usuarios</h1>
<form onSubmit={createUser} className="form">
<input type="text" placeholder="Nombre" value={name} onChange={e => setName(e.target.value)} required />
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required />
<button type="submit" disabled={loading}>
{loading ? "Creando..." : "Crear Usuario"}
</button>
</form>
<div className="users">
<h2>Usuarios ({users.length})</h2>
{users.map(user => (
<div key={user.id} className="user-card">
<div>
<strong>{user.name}</strong>
<br />
<span>{user.email}</span>
</div>
<button onClick={() => deleteUser(user.id)} className="delete-btn">
Eliminar
</button>
</div>
))}
</div>
</div>
);
}* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5f5f5;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
color: #2563eb;
margin-bottom: 2rem;
}
.form {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.form input {
flex: 1;
min-width: 200px;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.form button {
padding: 0.75rem 1.5rem;
background: #2563eb;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.form button:hover {
background: #1d4ed8;
}
.form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.users {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.user-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #eee;
}
.user-card:last-child {
border-bottom: none;
}
.delete-btn {
padding: 0.5rem 1rem;
background: #dc2626;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.delete-btn:hover {
background: #b91c1c;
}Mejores Prácticas
Estructura del Proyecto
my-app/
├── src/
│ ├── components/
│ │ ├── Header.tsx
│ │ └── UserList.tsx
│ ├── styles/
│ │ ├── globals.css
│ │ └── components.css
│ ├── utils/
│ │ └── api.ts
│ ├── App.tsx
│ └── main.tsx
├── public/
│ ├── index.html
│ ├── dashboard.html
│ └── favicon.ico
├── server/
│ ├── routes/
│ │ ├── users.ts
│ │ └── auth.ts
│ ├── db/
│ │ └── schema.sql
│ └── index.ts
├── bunfig.toml
└── package.jsonConfiguración Basada en Entorno
export const config = {
development: process.env.NODE_ENV !== "production",
port: process.env.PORT || 3000,
database: {
url: process.env.DATABASE_URL || "./dev.db",
},
cors: {
origin: process.env.CORS_ORIGIN || "*",
},
};Manejo de Errores
export function errorHandler(error: Error, req: Request) {
console.error("Error del servidor:", error);
if (process.env.NODE_ENV === "production") {
return Response.json({ error: "Error interno del servidor" }, { status: 500 });
}
return Response.json(
{
error: error.message,
stack: error.stack,
},
{ status: 500 },
);
}Helpers de Respuesta de API
export function json(data: any, status = 200) {
return Response.json(data, { status });
}
export function error(message: string, status = 400) {
return Response.json({ error: message }, { status });
}
export function notFound(message = "No encontrado") {
return error(message, 404);
}
export function unauthorized(message = "No autorizado") {
return error(message, 401);
}Seguridad de Tipos
export interface User {
id: number;
name: string;
email: string;
created_at: string;
}
export interface CreateUserRequest {
name: string;
email: string;
}
export interface ApiResponse<T> {
data?: T;
error?: string;
}Despliegue
Construcción de Producción
# Construir para producción
bun build --target=bun --production --outdir=dist ./server/index.ts
# Ejecutar servidor de producción
NODE_ENV=production bun dist/index.jsDespliegue con Docker
FROM oven/bun:1 as base
WORKDIR /usr/src/app
# Instalar dependencias
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
# Copiar código fuente
COPY . .
# Construir aplicación
RUN bun build --target=bun --production --outdir=dist ./server/index.ts
# Etapa de producción
FROM oven/bun:1-slim
WORKDIR /usr/src/app
COPY --from=base /usr/src/app/dist ./
COPY --from=base /usr/src/app/public ./public
EXPOSE 3000
CMD ["bun", "index.js"]Variables de Entorno
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp
CORS_ORIGIN=https://myapp.comMigración desde Otros Frameworks
Desde Express + Webpack
// Antes (Express + Webpack)
app.use(express.static("dist"));
app.get("/api/users", (req, res) => {
res.json(users);
});
// Después (Bun fullstack)
serve({
routes: {
"/": homepage, // Reemplaza express.static
"/api/users": {
GET() {
return Response.json(users);
},
},
},
});Desde Rutas de API de Next.js
// Antes (Next.js)
export default function handler(req, res) {
if (req.method === 'GET') {
res.json(users);
}
}
// Después (Bun)
"/api/users": {
GET() { return Response.json(users); }
}Limitaciones y Planes Futuros
Limitaciones Actuales
- La integración con el CLI
bun buildaún no está disponible para aplicaciones fullstack - El auto-descubrimiento de rutas de API no está implementado
- El renderizado del lado del servidor (SSR) no está integrado
Características Planificadas
- Integración con el CLI
bun build - Enrutamiento basado en archivos para endpoints de API
- Soporte SSR integrado
- Ecosistema de plugins mejorado
NOTE
Este es un trabajo en progreso. Las características y APIs pueden cambiar a medida que Bun continúa evolucionando.