bunload
v0.1.1
Published
A collection-based CMS and application backend for Bun and SQLite
Maintainers
Readme
bunload
Reusable Bunload runtime for Bun workspaces.
import { bunload } from "bunload";
import { defineConfig } from "bunload/schema";
import { createBunloadApi } from "bunload/server";
import { generateTypes, runMigrations } from "bunload/tooling";The root bunload export is app-agnostic. After running bun run bunload generate:types, application code should import bunload from its generated file for collection autocomplete and automatic result/input inference:
import { bunload } from "./generated/bunload-types";
const users = await bunload.find({ collection: "users" });Collections with auth: true receive managed email and password inputs plus typed session and password-lifecycle client methods. Passwords are hashed with Argon2id, sessions are stored server-side, and login uses an HttpOnly cookie. Optional recovery uses auth.resetPassword.sendResetToken so the application can deliver one-use tokens through its own email or messaging provider. Authentication does not itself restrict collection CRUD; access control is a separate concern.
Auth collections can enforce password composition and active-session limits, and can deliver redacted audit events to the application:
{
slug: "accounts",
auth: {
maxSessions: 5,
passwordPolicy: {
minLength: 12,
maxLength: 128,
requireLowercase: true,
requireUppercase: true,
requireNumber: true,
requireSymbol: true,
},
onAudit(event) {
return auditWriter.write(event);
},
},
fields: [],
}Use bunload.sessions({ collection: "accounts" }) to list the current user's active sessions and bunload.revokeSession({ collection: "accounts", sessionId }) to revoke one. Audit events contain request metadata but never passwords, cookies, or bearer tokens.
HTTP security
Bunload enables security headers, CSRF origin checks for cookie-authenticated mutations, and an isolated in-memory rate limiter by default. Configure trusted browser origins explicitly when the API and UI use different origins:
export default defineConfig({
security: {
cors: {
origins: ["https://app.example.com"],
credentials: true,
},
csrf: {
trustedOrigins: ["https://app.example.com"],
},
rateLimit: {
windowMs: 60_000,
max: 100,
loginMax: 10,
passwordResetMax: 5,
getClientIp: (request) => request.headers.get("x-real-ip") ?? undefined,
store: distributedRateLimitStore,
},
headers: {
contentSecurityPolicy: "default-src 'none'; frame-ancestors 'none'",
frameOptions: "DENY",
referrerPolicy: "strict-origin-when-cross-origin",
hstsMaxAge: 31_536_000,
},
},
db: { filename: "db.sqlite3" },
collections: [],
});The configured rate-limit store receives consume(key, limit, windowMs) and must return { allowed, limit, remaining, resetAt }, which allows Redis or another shared store in multi-process deployments. IP and authenticated-identity limits are both applied. Only trust forwarded IP headers from a controlled reverse proxy. Credentialed cross-origin clients must be present in both cors.origins and csrf.trustedOrigins. HSTS is emitted only for HTTPS requests; terminate TLS before Bunload and preserve the external scheme when using a reverse proxy.
Use collection access rules for operation or row-level authorization and field access rules for read/write masking. Rules receive the authenticated user; row filters are always combined with client filters using AND. Collections without access rules remain public for backward compatibility.
Applications expose the package CLI through one script, matching Payload's project-local CLI pattern:
{
"scripts": {
"bunload": "bun x bunload"
}
}bun run bunload generate:types
bun run bunload migrate:create add-post-status
bun run bunload migrate:create rename-user --manual
bun run bunload migrate
bun run bunload migrate:status
bun run bunload migrate:status --json
bun run bunload backup backups/pre-deploy.sqlite3
bun run bunload restore backups/pre-deploy.sqlite3The CLI discovers src/bunload.config.ts by default. Set BUNLOAD_CONFIG_PATH or pass --config for another location. The lower-level tooling entry point remains available for programmatic use.
Artifact paths can be configured alongside the collections:
export default defineConfig({
db: {
filename: "db.sqlite3",
migrationDir: "migrations",
},
typescript: {
outputFile: "src/generated/bunload-types.ts",
},
collections: [],
});An application owns its config, migrations, generated types, and database:
import { serve } from "bun";
import { createBunloadApi } from "bunload/server";
import config from "./bunload.config";
const api = createBunloadApi(config);
serve({ routes: { "/api/:slug": api, "/api/:slug/:action": api } });Each API owns its configuration and SQLite connection, so multiple instances can run in one process. Startup fails when migration files and the bunload_migrations ledger differ; apply pending migrations with bun run bunload migrate. The returned handler also exposes api.close() for tests and embedded runtimes.
Operations and observability
Configure a structured logger with debug, info, warn, and error methods. Bunload emits request, migration-check, authentication, and hook events. Entries use a fixed metadata shape and never include request bodies, headers, cookies, passwords, session tokens, or reset tokens. Logger failures do not affect requests.
export default defineConfig({
logger: {
debug: (entry) => logger.debug(entry),
info: (entry) => logger.info(entry),
warn: (entry) => logger.warn(entry),
error: (entry) => logger.error(entry),
},
db: { filename: "db.sqlite3" },
collections: [],
});Every response includes X-Request-ID. A valid incoming X-Request-ID is preserved; otherwise Bunload generates a UUID. JSON error envelopes also include requestId.
Health checks are available through the normal API handler:
GET /api/_health/livechecks the process without writing to SQLite.GET /api/_health/readychecks SQLite and migration drift, returning503when the instance is not ready.
Use api.shutdown() to reject new API requests, wait for active requests and transactions, and close SQLite. shutdownBunloadApis(apis) drains multiple instances. Stop the Bun listener before draining:
import { createBunloadApi, shutdownBunloadApis } from "bunload/server";
const api = createBunloadApi(config);
const server = Bun.serve({
routes: {
"/api/:slug": api,
"/api/:slug/:action": api,
},
});
async function shutdown() {
await server.stop(false);
await shutdownBunloadApis([api]);
}
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);migrate:status --json prints { applied, pending, missing } for deployment gates. Run migrations before starting application processes.
backup [output] serializes a consistent SQLite snapshot and runs PRAGMA integrity_check. restore <backup> validates the source, replaces the destination through a temporary file, and verifies the result. Restore is offline: stop every process that can access the database first. Take and retain a verified pre-migration backup; SQL migrations are forward-only by default, and manual down functions are documentation/escape hatches rather than an automatic rollback mechanism. Restore the pre-migration backup when a deployed migration must be rolled back, then deploy code matching that schema.
CMS capabilities
Collections can opt into drafts, bounded version history, soft delete, custom endpoints, and uploads. These features reuse collection access rules, field access rules, hooks, and transactions:
import { defineConfig } from "bunload/schema";
import { LocalStorageAdapter } from "bunload/server";
export default defineConfig({
localization: {
locales: ["en", "pt"],
defaultLocale: "en",
fallbackLocale: "en",
},
db: { filename: "db.sqlite3" },
collections: [
{
slug: "articles",
drafts: { autosave: true, read: ({ user }) => Boolean(user) },
versions: { maxPerDocument: 20 },
softDelete: true,
fields: [
{ name: "title", type: "text", required: true, localized: true },
{ name: "content", type: "richText" },
{ name: "location", type: "point" },
{
name: "layout",
type: "blocks",
blocks: [{ slug: "hero", fields: [{ name: "heading", type: "text", required: true }] }],
},
{ name: "summary", type: "virtual", compute: ({ doc }) => String(doc.title ?? "") },
{ name: "comments", type: "join", relationTo: "comments", on: "article" },
],
},
{
slug: "media",
upload: {
storage: new LocalStorageAdapter("uploads"),
mimeTypes: ["image/*"],
maxFileSize: 10 * 1024 * 1024,
},
fields: [{ name: "alt", type: "text", required: true }],
},
],
});Localized inputs are objects keyed by locale. Pass locale to reads; locale: "all" returns the complete locale map. Fields support sync or async validate callbacks, including recursive paths inside groups, arrays, and blocks.
Published and non-deleted documents are returned by default. Use the generated client for editorial workflows:
const draft = await bunload.autosave({ collection: "articles", data: { title: { en: "Draft" } } });
await bunload.publish({ collection: "articles", id: draft.doc.id });
const history = await bunload.versions({ collection: "articles", id: draft.doc.id });
await bunload.restoreVersion({ collection: "articles", id: draft.doc.id, version: history.versions[0].version });
await bunload.deleteById({ collection: "articles", id: draft.doc.id });
await bunload.restore({ collection: "articles", id: draft.doc.id });Upload collections use multipart form data through bunload.upload(). Managed filename, mimeType, and filesize fields are returned; internal storage keys are never exposed. File delivery uses the collection's read access and field visibility through bunload.fileURL(). Soft delete retains the object; permanent delete removes it. S3StorageAdapter uses Bun's native S3 client and supports S3-compatible endpoints.
Custom endpoints use /api/:collection/:path and return a standard Response. Their optional access rule receives the authenticated user and collection context.
Run the core suite from the workspace root with bun run test:core.
