完全运行在 Bun 上:Bun.serve 提供 HTTP、bun:sqlite 存储数据、Bun.build 打包、bun test 测试
dulak — Svelte 启动器模板
作者 maulanashalihin
Dulak 是一个完全运行在 Bun 上的全栈启动器,集成 Hono、bun:sqlite 与 Inertia v3,提供 React、Svelte、Vue 三套模板,内置认证、OAuth、角色、上传、迁移、测试与 Docker。
看看你的网站会是什么样
浏览真实页面截图,比较内容排版与手机效果。点击下方缩略图切换,放大查看整页细节。
全部页面与视图 3
截图用于了解视觉与布局,交互效果请以实际项目为准。
值得关注的模板亮点
从已有项目资料中,快速了解它的特点。
Inertia v3 在同一个 Bun 进程内完成 SSR,首屏直出 HTML,后续为 SPA 导航
提供 React、Svelte、Vue 三种模板,并可选原生 CSS 或 Tailwind CSS v4
内置认证、Google OAuth、密码重置、user/admin 角色与 /admin 分页用户列表
支持常规 multipart 上传与 tus v1 可恢复分块上传两种协议
已接通 CSRF、速率限制、安全响应头、版本化迁移、Docker 与 CI
模板详细介绍
Dulak 是一个基于 Bun 运行时的全栈启动器,HTTP 层使用 Hono,数据层使用 bun:sqlite,界面层通过 Inertia v3 实现服务端驱动渲染,并在同一个 Bun 进程内完成 SSR。项目提供 React、Svelte、Vue 三种前端模板,其中 default 模板使用 React 19 与原生 CSS,svelte-vanilla 和 vue-vanilla 使用组件内 scoped style,另有三个 *-tailwind 模板接入 Tailwind CSS v4。
功能层面,项目已接通注册登录、密码重置邮件、Google OAuth、user/admin 角色与 /admin 分页用户列表、全局与认证接口的速率限制、CSRF 校验、版本化 SQL 迁移、请求日志、gzip、安全响应头、/health 与 /metrics、优雅关闭和 Docker。上传支持两种协议:常规 multipart/form-data(≤100 MB,头像会经 Bun.Image 解码、缩放为 256×256 并转码为 WebP)以及 tus v1 可恢复分块上传(用于大于 100 MB 的文件)。
配置通过 .env 管理,涵盖端口、数据库路径、SSR 开关、邮件驱动(log/resend/mailtrap)、Google OAuth 凭据、速率限制参数、上传目录与 tus 过期设置;配置不完整时启动阶段会直接报错。测试使用 bun test,针对内存 SQLite 启动应用。
限制方面:项目定位是带生产级防护的骨架,不包含业务逻辑;演示图片、字体、图标等第三方素材需单独核对许可;域名、托管、数据库、邮件等外部服务可能产生费用。
适合你的项目吗?
适用场景
- 需要一个基于 Bun 与 Hono 的全栈项目起点,并希望前端可在 React、Svelte、Vue 之间选择
- 希望使用 Inertia 做服务端驱动渲染,同时保留 SPA 导航体验
- 需要开箱可用的认证、OAuth、角色与密码重置流程作为业务开发基础
- 需要同时支持小文件常规上传与大文件可恢复分块上传的后端骨架
- 希望用 bun test 对认证、角色、Inertia、tus 等流程做端到端测试
适合人群
- 熟悉 Bun 运行时并希望减少多语言运行时与外部服务依赖的开发者
- 需要 React、Svelte 或 Vue 任一前端栈的全栈项目起步模板的团队
- 希望从已接通安全防护与运维基础的项目骨架开始编写业务逻辑的开发者
- 需要可恢复分块上传或头像处理能力的应用原型
上手与定制指南
结合仓库当前版本的说明,按下面的资料检查内容、配置与运行要求。
- 1
先安装 Bun,再执行 bun create dulak@latest my-app 创建项目,可用 --template 指定模板
- 2
进入项目目录后运行 bun run dev,默认访问 http://localhost:4000
- 3
按需在 .env 中配置邮件驱动、Google OAuth 凭据、速率限制与上传相关参数
- 4
使用 bun run db:seed 创建演示用户,参数为邮箱、密码与角色
- 5
生产构建使用 bun run build 生成 dist/ 与 manifest.json,再以 bun run start 启动
授权与来源资料
使用条件与限制
免费使用 · 保留许可声明本次收录的是公开仓库中采用 MIT 许可的免费版本;免费范围为该版本代码,不代表外部服务或第三方素材免费。
使用时需要遵守
- 允许免费使用、修改、商用和分发该版本代码。
- 分发代码副本或实质性部分时,保留原作者版权声明和完整 MIT 许可声明。
- 代码按现状提供;担保与责任限制以原许可证及适用法律为准。
额外费用与待核对事项
- 演示图片、字体、图标及其他第三方素材需按各自许可核对,不能仅凭代码的 MIT 许可判断。
- 域名、托管、数据库、支付或 AI 等外部服务可能另外收费。
- 项目要求 Bun 1.4 及以上版本,头像上传依赖 Bun.Image 进行解码、缩放与 WebP 转码,低版本 Bun 无法使用该功能。
- 使用 resend 邮件驱动时必须配置 RESEND_API_KEY,否则邮件发送功能不可用。
- 使用 mailtrap 邮件驱动时必须配置 MAILTRAP_API_TOKEN,否则邮件发送功能不可用。
- Google OAuth 登录需要同时配置 GOOGLE_CLIENT_ID 与 GOOGLE_CLIENT_SECRET,未配置时该登录按钮隐藏。
- 未设置 METRICS_TOKEN 时,/metrics 端点仅允许本机回环访问,远程无法读取指标。
- 速率限制信任 X-Forwarded-For 头,必须部署在会设置该头的代理之后,否则限流可能被绕过或误判。
- 项目定位为带生产级防护的骨架,并非可直接上线的生产应用,业务逻辑需自行实现。
- 项目不包含业务逻辑,使用者需自行编写业务代码后才能用于实际产品。
- 速率限制默认使用内存存储,水平扩展时需自行替换为 Redis 等共享存储,否则多实例限流不生效。
- 默认模板使用原生 CSS,Tailwind v4 需按项目自行接入,不属于默认提供的能力。
查看原文依据
Permission is hereby granted, free of charge
许可证原文
**Bun 1.4+ required** — avatar upload uses `Bun.Image` (decode, resize, re-encode to WebP), which is only available in Bun 1.4+.
README 原文
`RESEND_API_KEY` | — | required when `MAIL_DRIVER=resend`
README 原文
`MAILTRAP_API_TOKEN` | — | required when `MAIL_DRIVER=mailtrap`
README 原文
`GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | — | enable Google OAuth (both or none)
README 原文
`METRICS_TOKEN` | — | bearer token for `/metrics`; if unset, `/metrics` is restricted to loopback only
README 原文
`X-Forwarded-For` is trusted for rate limiting — only run behind a proxy that sets it.
README 原文
**Production-grade guardrails, not a production app.**
README 原文
What is missing is your business logic, and that is the point
README 原文
Swap the store for Redis behind the same hook signature when scaling horizontally.
README 原文
https://github.com/maulanashalihin/dulak/blob/main/LICENSEhttps://github.com/maulanashalihin/dulak/blob/main/README.mdAdding **Tailwind v4** is a per-project decision — the boilerplate stays vanilla CSS by design.
README 原文
DeepSeek 根据仓库许可证和 README 整理 ·2026-10-05。结果仅覆盖已读取资料,依赖、图片、字体及后续条款变更以各自原文为准。
项目核对记录与介绍依据
模板资料
- 来源平台
- GitHub
- 资料与维护参考分
- 资料较完整 · 88 / 100
- 技术栈
- Svelte · Vue · React
- 网站类型
- 启动器
这个分数如何计算
本站按许可资料(最高30分)、演示链接(12分)、预览图(10分)、Star数(15分)、记录的维护时间(15分)、描述完整度(8分)、功能记录(5分)和分类资料(5分)计算。它不是代码、安全或性能测试,也不是 Google 评分;有链接不代表已实测可用。
这个项目需要核对什么
- 记录的仓库更新时间
- 2026-09-14
- 截图检查日期
- 2026-10-05
已收录演示链接。请用你的内容与屏幕尺寸检查;有演示不代表表单、认证或支付已经可用。
目录记录的主要技术栈是 Svelte。请通过依赖清单和锁文件核对实际版本与托管要求。这些说明基于收录资料,不代表代码审计或部署实测。
查看具体选型方法 →介绍的制作与核对状态
介绍由 AI 辅助整理目录与仓库资料。仓库作者不等于这篇介绍的审核者。
审核声明: [email protected] · 2026-10-05
查看当时使用的来源资料
SHA-256: 915022b3e5281b3c23a3ecf700771e7f5ca743134681cab63380cd0472435fde
2026-10-05T02:26:01.260Z
来源文件 / 仓库# Dulak
The Banjar word for *bored* — a deliberately boring full-stack starter.
[](https://github.com/maulanashalihin/dulak/actions/workflows/ci.yml)
[](LICENSE)
[](https://bun.sh)
[](https://dulak.pages.dev)
[](https://www.npmjs.com/package/create-dulak)
A full-stack starter running entirely on **Bun**: **Hono** for HTTP,
**bun:sqlite** for data, **Inertia v3** for server-driven UI with
**in-process SSR** — no separate SSR server, it runs inside the same Bun
process — with React, Svelte, and Vue templates. Auth, uploads,
migrations, tests, Docker — wired end to end.
```mermaid
flowchart LR
Browser -->|Inertia XHR / full HTML| Hono
subgraph Bun process
Hono -->|session, flash| Auth
Hono -->|page payloads| InertiaAdapter
InertiaAdapter -->|renderToString| ReactSSR
Hono -->|SQL| bun:sqlite
end
ReactSSR --> Browser
Google -->|OAuth callback| Hono
Mail -->|reset emails| Hono
subgraph Assets
Bun.build --> dist
end
```
## Philosophy
**Dulak** is the Banjar word for *bored* — and the name is the manifesto:
code that is "boring" is the most valuable code there is. Boring here does
not mean dull — it means **predictable**. No surprises, no clever tricks
that require the next maintainer to reverse-engineer intent. Whoever comes
later — human or AI agent — should be able to understand the code, change
it, and not be afraid of breaking it.
- **Deliberately boring.** Every choice trades "clever" for "obviously
right". When two ways of doing the same thing exist, only one is kept —
the simpler one. Route handlers are written inline in their route file
instead of being split into abstract controllers. Boring? Yes.
Followable at a glance? Far more.
- **Zero-dependency where it's cheap.** Every dependency is a liability:
it must be upgraded, audited, and can break under you. When 60 lines of
our own code are enough, we write them: the rate limiter is hand-rolled
(`rate-limit.ts`), the Google OAuth client is plain `fetch` (no SDK),
CSS is vanilla by default, and the database layer is raw `bun:sqlite`
prepared statements — no ORM.
- **One runtime, zero setup.** Not a PHP/Laravel-style stack — no separate
language runtime, package manager, web server, and database to install
and wire together before your first `php artisan serve`. The whole stack
is Bun: `Bun.serve` is the HTTP server, `bun:sqlite` is the database,
`Bun.build` is the bundler, `bun test` is the test runner, `bun install`
is the package manager. Setup is exactly three steps — install Bun,
scaffold with `bun create dulak`, run `bun run dev` — and you have a
running app with auth, migrations, SSR, and tests.
- **One obvious way to do things.** Structure is standardized, on purpose:
routes only live in `routes/<feature>.routes.ts`, all SQL lives in
`db.ts`, environment variables are read only in `config.ts`. No
"structural creativity" — that is the point. When everyone writes the
same way, anyone can find anything.
- **Discoverability as a contract.** Given a URL you can name the file
that owns it: `/login` → `routes/auth.routes.ts`, `/uploads` →
`routes/uploads.routes.ts`. Every URL lives in exactly one file, with
its GET render and POST actions together. Paste a broken URL and you
land in exactly one place — no guessing.
- **Production-grade guardrails, not a production app.** The
infrastructure a deployed app needs — CSRF, rate limiting, security
headers, versioned migrations, graceful shutdown, Docker, CI — is wired
and tested from day one, not scaffolded. What is missing is your
business logic, and that is the point: you start from a skeleton that
already works, not one you have to harden.
- **Boring versions, pinned, not chased.** A starter gets forked and
maintained for years — its dependencies decide how much forced
maintenance lands on the fork owner. "Current" does not mean "always
the latest": Dulak pins specific stable versions (Hono 4.x, Bun 1.4+,
Inertia v3) and upgrades only when there is a concrete reason. A pinned
version is a resting point, not a moving target that drags every fork
into an upgrade cycle.
- **No inherited rewrite churn.** A dependency whose next major is a
full rewrite strands the boilerplate on a legacy track with no upgrade
path.
- **Breaking changes become schedulable.** On a stable track, upgrades
are deliberate, documented migrations you can plan and test — not
emergency fixes when a beta dependency shifts under you.
- **A new major must prove itself.** It ships when there is a concrete
reason (security, ecosystem, maintenance), not because it is the
newest thing.
- **Correctness over cleverness.** Synchronous, explicitly typed,
parameterized queries; fail-fast configuration; deterministic tests
(`bun test --isolate`). Prefer the boring implementation that is
obviously correct over the clever one that is hard to verify.
- **Built for AI agents.** The "next maintainer" includes the agent
writing the next feature — which, in this project, is the main way the
code evolves. That is why conventions are codified where agents read
them (`AGENTS.md`), validation errors have exact, documented shapes
(TypeBox), mistakes fail at compile time (`strict` +
`noUncheckedIndexedAccess`), and the test suite runs deterministically
as the safety net. A codebase an agent can extend without inventing
conventions is a codebase that stays coherent.
## Quick start
```bash
# Scaffold a new project (prompts for template, installs deps, creates .env)
bun create dulak@latest my-app
cd my-app
bun run dev # http://localhost:4000
# Or pick a template directly:
bun create dulak@latest my-app --template svelte-vanilla
```
### Templates
| Template | Stack | Branch |
| ----------------- | ---------------------------------- | ------------------------- |
| `default` | React 19 + vanilla CSS | `main` |
| `svelte-vanilla` | Svelte 5 + scoped `<style>` CSS | `template/svelte-vanilla` |
| `vue-vanilla` | Vue 3 + scoped `<style>` CSS | `template/vue-vanilla` |
| `svelte-tailwind` | Svelte 5 + Tailwind CSS v4 | `template/svelte-tailwind`|
| `react-tailwind` | React 19 + Tailwind CSS v4 | `template/react-tailwind` |
| `vue-tailwind` | Vue 3 + Tailwind CSS v4 | `template/vue-tailwind` |
The `default` template (this branch) uses React 19 with co-located vanilla
CSS — no CSS framework. The `svelte-vanilla` and `vue-vanilla` templates
use native scoped `<style>` blocks (Svelte/Vue SFCs). The `*-tailwind`
templates add Tailwind CSS v4 (via `@tailwindcss/cli`, no PostCSS) with
the same auth, roles, SSR, and test suite.
### Scripts
| Command | What it does |
| ------------------- | --------------------------------------------------------- |
| `bun run dev` | Watch mode; rebuilds client assets on restart |
| `bun run build` | Prebuild client assets → `dist/` (+ `manifest.json`) |
| `bun run start` | Serve prebuilt assets (`NODE_ENV=production`) |
| `bun run test` | E2E suite (auth, roles, reset flow, Inertia, tus, avatar upload) |
| `bun run db:seed` | Create a demo user (`[email] [password] [role]` args) |
| `bun run typecheck` | `tsc --noEmit` |
## Features
- **Auth**: register, login, logout — argon2id passwords, DB-backed sessions
(httpOnly `SameSite=Lax` cookies, 30-day expiry), CSRF (Origin check).
- **Forgot / reset password** with email delivery (see Mail below) and
hashed reset tokens (60-minute expiry).
- **Google OAuth** register-or-login (zero-dep, plain fetch; button hidden
when not configured). The profile picture is downloaded and stored locally
(the CSP blocks external images), so avatars always load from our origin.
- **Roles**: `user` / `admin`, `requireRole('admin')` guard, `/admin` page
with paginated user list.
- **Rate limiting**: global per-IP limit on all routes (DDoS baseline, excludes `/health` and `/assets/*`) plus a stricter layer on auth endpoints (brute-force protection). In-memory fixed window, configurable via env.
- **Inertia v3**: full SSR on first load, SPA navigation after, asset-version
negotiation (409 + reload), partial reloads, flash messages, shared props.
- **Two upload protocols**:
- **Regular upload** (multipart/form-data) for files ≤ 100 MB. Avatar
uploads use this path: the image is decoded, resized to 256×256 and
re-encoded to WebP with `Bun.Image` (Bun 1.4) before storage, so
avatars are always small, raster, and served from our own origin.
- **tus protocol v1** at `/uploads` (creation, creation-with-upload,
termination, expiration, checksum) with SQLite state and on-disk
storage — for resumable chunked uploads of files > 100 MB where a
plain multipart request would be unreliable.
- **Migrations**: versioned SQL files applied at startup in transactions.
- **Ops**: batched request logging with correlation id, gzip compression,
security headers (CSP, nosniff, frame denial), `/health`, graceful
shutdown, Docker.
- **Testing**: `bun test` — boots the app against an in-memory SQLite DB.
## Configuration (.env)
| Variable | Default | Notes |
| --- | --- | --- |
| `PORT` | `4000` | In dev, auto-increments to the next free port if busy (prod fails fast) |
| `APP_URL` | `http://localhost:4000` | Absolute base URL (email links, OAuth redirects) |
| `DATABASE_PATH` | `./data/app.sqlite` | |
| `SSR` | `true` | `false` ships an empty shell — client renders from scratch (no hydrate) |
| `MAIL_DRIVER` | `log` | `log` \| `resend` \| `mailtrap` |
| `MAIL_FROM` | `[email protected]` | |
| `RESEND_API_KEY` | — | required when `MAIL_DRIVER=resend` |
| `MAILTRAP_API_TOKEN` | — | required when `MAIL_DRIVER=mailtrap` |
| `MAILTRAP_INBOX_ID` | — | use the sandbox endpoint when set |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | — | enable Google OAuth (both or none) |
| `RATE_LIMIT_GLOBAL_MAX` / `RATE_LIMIT_GLOBAL_WINDOW` | `200` / `60` | per-IP requests per window on all routes (excludes `/health`, `/assets/*`) |
| `RATE_LIMIT_AUTH_MAX` / `RATE_LIMIT_AUTH_WINDOW` | `30` / `60` | stricter per-IP limit on auth endpoints (brute-force protection) |
| `UPLOAD_DIR` | `./data/uploads` | tus upload bytes on disk |
| `TUS_MAX_SIZE` | `0` | max upload size in bytes (`0` = unlimited) |
| `TUS_EXPIRATION_SECONDS` | `0` | unfinished upload TTL in seconds (`0` = no expiry) |
| `METRICS_TOKEN` | — | bearer token for `/metrics`; if unset, `/metrics` is restricted to loopback only |
Invalid/incomplete config fails fast at startup with a clear message
(`src/server/config.ts`).
### Google OAuth setup
1. [Google Cloud Console](https://console.cloud.google.com/apis/credentials) →
create OAuth client (Web application).
2. Authorized redirect URI: `https://<your-domain>/auth/google/callback`
(`http://localhost:4000/auth/google/callback` for local dev).
3. Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in `.env`.
### Mail drivers
- **log** (default): prints a formatted message and records it in
`sentMails` — usable in dev and asserted in tests.
- **resend**: set `RESEND_API_KEY` (`MAIL_DRIVER=resend`).
- **mailtrap**: set `MAILTRAP_API_TOKEN` (`MAIL_DRIVER=mailtrap`); add
`MAILTRAP_INBOX_ID` to use the sandbox endpoint.
## Architecture
AI agents: follow [`AGENTS.md`](AGENTS.md) — it codifies the layout rules
below so new code stays structurally consistent.
```
src/
├── index.ts # entry: build assets (dev), Bun.serve, graceful shutdown
├── server/
│ ├── app.ts # composition: logging, CSRF, secureHeaders, rate limit, onError, routes
│ ├── config.ts # validated env config (fails fast)
│ ├── db.ts # bun:sqlite: connection, prepared statements
│ ├── migrations.ts # SQL migration runner
│ ├── auth.ts # argon2id, sessions, flash, cookies, reset tokens, guards
│ ├── inertia.ts # Inertia v3 server adapter (SSR shell, XHR, 409)
│ ├── inertia-middleware.ts # per-request session resolve → c.var (AppEnv)
│ ├── validation.ts # TypeBox JSON validation → ValidationFailed (422)
│ ├── mailer.ts # mail drivers: log / resend / mailtrap
│ ├── rate-limit.ts # in-memory fixed-window rate limiter
│ ├── logger.ts # request logging + x-request-id
│ ├── security.ts # CSRF origin check (headers via hono/secure-headers)
│ ├── url.ts # defensive request-URL parsing
│ ├── assets.ts # Bun.build pipeline + manifest + static serving
│ ├── tus-protocol.ts # tus v1 protocol constants & helpers
│ ├── tus-storage.ts # tus upload bytes on disk (data/uploads)
│ └── routes/
│ ├── auth.routes.ts # /login /register /logout /forgot/reset (GET+POST)
│ ├── google-oauth.routes.ts # /auth/google, /auth/google/callback
│ ├── pages.routes.ts # app-shell pages: /, /dashboard, /admin
│ ├── profile.routes.ts # /profile page + /profile/avatar (multipart + Bun.Image)
│ └── uploads.routes.ts # /uploads* (tus resumable upload)
├── client/
│ ├── app.tsx # Inertia client bootstrap (hydrate or render)
│ ├── ssr.tsx # in-process SSR renderer (react-dom/server)
│ ├── pages.ts # explicit page registry (shared by SSR + bundle)
│ ├── pages/ # Login, Register, Dashboard, ForgotPassword,
│ │ # ResetPassword, Admin, NotFound
│ ├── components/ # Layout, AuthLayout, Field
│ └── styles.css # plain CSS, light/dark
├── shared/
│ ├── types.ts # User, Role, FlashData, SharedPageProps, Paginated
│ └── inertia.d.ts # InertiaConfig augmentation → typed props.auth
├── migrations/ # versioned SQL schema files (0001, 0002, …)
├── tests/ # bun:test E2E suite (in-memory DB)
└── scripts/ # build.ts, seed.ts
```
## How the pieces fit
- **Request lifecycle**: `requestLogger` (correlation id) → `checkOrigin`
(CSRF) → `secureHeaders` → inertia session resolve → global rate limit →
guards + handler → Inertia render (SSR HTML for browsers, JSON for
`X-Inertia` XHR) → `onError` (422 validation with friendly field
messages, 500) / `notFound` (404 Inertia page).
- **Auth**: argon2id via `Bun.password`; 256-bit random session tokens in
SQLite; cookies httpOnly/`SameSite=Lax`/Secure-in-prod. Logout deletes the
session row server-side. `passwordHash` never leaves the server.
- **Guards** are Hono middleware: `requireAuth`, `guestOnly`,
`requireRole('admin')` (non-admins redirect to `/dashboard`). They return a
Response to short-circuit the chain, or call `next()`.
- **Rate limiting** is an in-memory fixed-window limiter keyed by
`X-Forwarded-For`/peer IP (Bun's `server.requestIP` via `c.env`). Two
layers: a global limiter in `app.ts` (DDoS baseline, excludes `/health`
and `/assets/*`) and a stricter one on auth routes (brute-force). Swap
the store for Redis behind the same hook signature when scaling
horizontally.
- **Inertia v3 protocol** (`inertia.ts`): full HTML with SSR markup +
`data-page` JSON for browser visits; JSON page payloads for XHR;
`409 + X-Inertia-Location` on asset-version mismatch; partial reloads via
`X-Inertia-Partial-*`; one-shot flash and shared props merged per page.
- **SSR + hydration**: `renderPage()` renders with
`createInertiaApp({ page, render: renderToString })`; the client hydrates
when `data-server-rendered` is present. Same page registry on both sides.
- **Asset versioning**: `Bun.build` emits content-hashed files; the hash is
the Inertia `version`. Stale clients get a 409 and reload.
- **Validation**: TypeBox schemas at the route level (see
`src/server/validation.ts`); `onError` maps `ValidationFailed` to 422
Inertia page payloads (`VALIDATION_MESSAGES` in
`routes/auth.routes.ts`). The `email` format is registered explicitly —
plain `@sinclair/typebox` does not pre-register string formats.
## Database migrations
Schema changes are plain SQL files in `migrations/`, applied automatically at
startup in filename order, each inside a transaction, recorded in
`schema_migrations` (never re-applied).
```bash
# add a column to an existing table
cat > migrations/0003_add_last_login.sql <<'SQL'
ALTER TABLE users ADD COLUMN last_login_at TEXT;
SQL
bun run dev # migration runs on boot
```
Rules:
- **Never edit an applied migration** — add a new numbered file instead.
- SQLite `ALTER TABLE ADD COLUMN` with `NOT NULL` requires a `DEFAULT`.
- A failed migration rolls back and aborts startup.
- Edited an applied migration anyway (e.g. while prototyping)? Applied
migrations are never re-run, so delete `data/app.sqlite*` and re-start to
rebuild the dev database from scratch.
## Testing
```bash
bun test --isolate # or: bun run test
```
63 tests (snapshot — the suite grows; AGENTS.md only requires it stays green). The suite boots the full app against an in-memory SQLite database
and drives it through `app.request()`: registration/login/logout, guards and
roles, password reset end to end (via the log mail driver), Inertia protocol
(409/404/SSR), CSRF, `/health`, static asset serving, the tus
resumable-upload flow (creation, resume, checksum, termination, ownership),
and avatar upload via multipart with Bun.Image re-encoding to WebP.
`--isolate` gives each test file fresh globals. It is required: the files
are written as independent suites — each sets its env (`DATABASE_PATH`,
`UPLOAD_DIR`, …) in `beforeAll` before importing the app, and `db.close()`s
in `afterAll` — so running them in one shared process would let one file's
teardown finalize the next file's prepared statements.
## Deployment
```bash
docker compose up -d --build
```
- Multi-stage `Dockerfile` (`oven/bun:1.4-alpine`): assets prebuilt in the
build stage, production deps only at runtime. The app process runs as a
non-root user (`bun`, UID 1000) — the entrypoint fixes the `./data`
bind-mount ownership, then drops privileges before exec'ing Bun.
- `./data` volume keeps the SQLite database across restarts; healthcheck hits
`/health`.
Alternatives: `bun build --compile` for a single binary, or plain
`bun run start` behind your process supervisor (it handles SIGTERM
gracefully). No Docker? Follow the
[Linux VPS guide](https://dulak.pages.dev/deployment/vps/) — Bun +
systemd + Cloudflare (origin rule or tunnel), no reverse proxy to
install.
## Styling
**Vanilla CSS by design** (`src/client/styles.css`, ~6KB): design tokens via CSS
variables, light/dark via `prefers-color-scheme`, no framework. Chosen to keep
the starter zero-dependency and zero extra build steps — the CSS is bundled and
content-hashed by the same `Bun.build` pipeline as the JS.
Adding **Tailwind v4** is a per-project decision — the boilerplate stays
vanilla CSS by design. It is verified to work without PostCSS using only
`@tailwindcss/cli` as a pre-build step; dark mode and existing CSS variables
bridge cleanly. See the
[Tailwind v4 setup guide](.llm-wiki/wiki/concepts/tailwind-v4-setup.md).
## Notes / decisions
- **Bun 1.4+ required** — avatar upload uses `Bun.Image` (decode, resize,
re-encode to WebP), which is only available in Bun 1.4+. The `bun`
engine constraint in `package.json` enforces this.
- **Hono integration gotchas handled**: Hono converts HEAD requests to GET
(body stripped, headers kept) while `c.req.method` still reports "HEAD" —
the tus `dispatch` uses that to route HEAD correctly; `c.header()`-queued
headers (cookies!) are dropped when a handler returns a custom `Response`,
so cookie helpers append to `c.res.headers` directly; the `/*` wildcard
produces no named param, so upload ids are derived from `c.req.path`;
`hono/conninfo`'s ESM build is an empty stub in 4.13, so the rate limiter
reads the peer IP from `c.env` (the Bun server) instead.
- `import.meta.glob` was removed from Bun 1.3 — the page registry uses
explicit imports.
- In dev, `bun --watch` rebuilds client assets on every change, so the
Inertia version changes; an already-open tab does one 409 + full page
reload after a rebuild (version negotiation), then settles back to SPA
navigation. Refresh after a server restart if you see a one-off reload.
- CSP uses `script-src 'unsafe-inline'` because Inertia embeds the page
payload as inline JSON; external script injection is still blocked.
- `X-Forwarded-For` is trusted for rate limiting — only run behind a proxy
that sets it.
- gzip compression is a custom middleware (`compress.ts`) — Hono's built-in
relies on the Web `CompressionStream`, which is not reliably present in
every Bun 1.3.14 context; `node:zlib` is. `busy_timeout = 5000` is set so
concurrent writes wait instead of failing with SQLITE_BUSY.
- Prefer Svelte, Vue, or Tailwind? `bun create dulak@latest my-app --template svelte-tailwind`
(or `react-tailwind` / `vue-tailwind`). The server side is adapter-agnostic —
Inertia v3 works with React, Svelte, or Vue. The Svelte template follows a
verified migration guide (Bun.build plugin, SSR, API mapping) in the
[Svelte 5 migration guide](.llm-wiki/wiki/concepts/svelte-5-migration.md);
the Vue template uses the same architecture (`vue-plugin.ts`, `@inertiajs/vue3`).
看看同类模板
对比同技术栈与用途的模板,找到更适合的视觉与内容结构。

CMSaasStarter
A modern SaaS template/boilerplate built with SvelteKit, Tailwind, and Supabase. Includes marketing page, blog…
Svelte · 后台

sveltekit-electron
Minimal Sveltekit + Electron starter template.
Svelte · 启动器

tauri-sveltekit
A minimal Tauri & SvelteKit starter template
Svelte · 启动器

modernstack-saas
A modern SaaS template/boilerplate built with SvelteKit, Convex, Better Auth, and shadcn-svelte. Includes subs…
Svelte · 后台

sveltekit-mdsvex-blog-template
MDsveX blog starter template built with Sveltekit & Tailwind
Svelte · 作品集

sveltekit-shadcn-blog-starter
Production-ready SvelteKit blog starter featuring shadcn-svelte UI, MDsveX for Markdown, Tailwind CSS styling,…
Svelte · 博客
