CodeForge is a Forgejo-style Git forge built with Hono + HonoX + Drizzle ORM
- Typia, deployable serverlessly on Cloudflare (Workers +
D1 + R2). It ships a real git server —
git clone/git push/git pullover smart-HTTP — alongside the capacity-composed data models.
bun install
# 1. Build the app (wires the typia transform via @ttsc/unplugin/bun)
bun run build
# 2. Build the models' SQL projections, generate + apply migrations
bun run src/main.ts models:build
bun run src/main.ts db:generate # -> drizzle/<timestamp>_sqlite_create.sql
bun run src/main.ts db:migrate # apply to the DB (DATABASE_URL)
# 3. Seed a realistic BBS dataset (Randomisable.random())
bun run src/main.ts db:seed
# 4. Query the data (CLI or HTTP)
bun run src/main.ts query boards --count
bun run src/main.ts query users '{"role":"admin"}' --limit 5
bun run src/main.ts serve # JSON API at http://localhost:8787/api/...
# 5. (Optional) Serve the Honox UI in /app
bun run src/main.ts ui:build # two-phase build -> dist/index.js (+ dist/static)
bun run src/main.ts serve # UI at / , JSON API at /api, on :8787 (or PORT=)
# dev server with HMR instead: bun run src/main.ts ui:dev
# NOTE: `serve` only mounts the UI when dist/index.js exists. After ui:build,
# verify both `dist/index.js` AND `dist/static/` are present — an interrupted
# build leaves only the client bundle and no UI. Re-run ui:build if missing.
# 6. (Optional) Deploy UI + API to Cloudflare Workers
bun run src/main.ts ui:cf-build # -> dist/ui-cf/index.js + dist/static/*
NODE_ENV=production DATABASE_TYPE=d1 bun run src/main.ts wrangler-config
wrangler deploy # worker + static assetsWith serve running (or the Worker deployed), every repository is a git remote at
https://host/owner/repo(.git). The transport is standard smart-HTTP, so any git
client works — including the real git CLI and isomorphic-git.
# Clone
git clone https://localhost:8787/owner/repo.git
cd repo
echo "# Hello from CodeForge" > README.md
git add README.md
git commit -m "initial commit"
git push origin mainDefault object format is SHA-1. SHA-256 (
git --object-format=sha256 --compat-object-format=sha1) is not supported by the pure-Worker backend (isomorphic-git is SHA-1 only) — it is a planned additive path via a serverless Cloudflare Container (see Git server backend below).
# End-to-end test (push then clone round-trip, run with the suite):
bun test src/gitSet DATABASE_URL (e.g. file:./dev.db) in the environment or .env.
Routes when
serveruns with a built UI: the Honox UI is at/and the JSON query API is at/api/…. Without a UI build, the JSON API is served at both/and/api. See REST API services below for the full endpoint list and architecture.
Better Auth (email + password) is mounted at /api/auth/* whenever
BETTER_AUTH_ENABLED is not "false" at build time. It follows the Hono
reference example: a per-env factory (src/auth/index.ts), shared options
(src/auth/options.ts), and better-auth.config.ts for the CLI.
# 1. Environment — see .env.development / .env.production
# BETTER_AUTH_URL public base URL of the auth endpoints (defaults to the worker URL)
# BETTER_AUTH_SECRET >= 32 chars (prod: `wrangler secret put BETTER_AUTH_SECRET`)
# 2. Schema — auth tables are emitted into drizzle/ by `db:generate`
bun run src/main.ts db:generate # writes <ts>_sqlite_create.sql + <ts>_auth_sqlite_create.sql
bun run src/main.ts db:migrate
# 3. (After adding Better Auth plugins) regenerate src/auth/schema.ts
bun run better-auth:generate
# 4. Use the typed client from islands
# import { authClient } from "@/auth/client"; // signIn / signUp / signOut / getSessionOpt out entirely with BETTER_AUTH_ENABLED=false at build time: the
betterAuthEnabled() macro inlines to false, so the better-auth +
drizzle-adapter bundle is dead-code-eliminated from the worker/UI builds.
CodeForge runs a real git server inside a Cloudflare Worker — no VM, no
container. The transport is implemented with
isomorphic-git (v1.41.4,
pure-JS, SHA-1 only) over smart-HTTP v1 with side-band-64k framing, so any
standard client (git, isomorphic-git, CI runners) can clone / push /
pull. This follows the Forgejo model: the Repository DB row is a metadata
catalog, and the actual git objects (loose + packs) live in object storage.
- Repository = catalog. The
repositoriesrow storesownerId,defaultBranch,objectFormatName('sha1'),topics, counters, etc. There are noBlob/Tree/CommitSQL tables — objects are resolved from storage on demand. - Objects in storage. Two swappable backends sit behind one
GitBackendinterface (src/git/backend.ts):localGitBackend(root)→node:fs,gitdir = ${root}/${owner}/${repo}.git(devserve+ tests).r2GitBackend(bucket)→ R2,gitdir = ${owner}/${repo}.git(production Worker).
- Hand-rolled smart-HTTP v1. isomorphic-git exposes
packObjects/indexPack/writeRef/resolveRefbut notuploadPack/receivePack, sosrc/git/upload.ts+src/git/receive.tsbuild the advertisements and packfiles directly. Both advertisements and the receive-pack report-status are framed on side-band-64k band 1 (0x01) so the isomorphic-git client'sGitSideBand.demuxparses them correctly (src/git/protocol.ts).
| Method & path | Notes |
|---|---|
GET /owner/repo(.git)/info/refs?service=git-upload-pack |
clone / fetch advertisement |
GET /owner/repo(.git)/info/refs?service=git-receive-pack |
push advertisement — lazily ensureRepos the bare repo on first push |
POST /owner/repo(.git)/git-upload-pack |
pack negotiation + packfile (clone / fetch) |
POST /owner/repo(.git)/git-receive-pack |
401 if unauthenticated, 403 if not the owner; indexPack writes objects then updates main |
The remote URL is https://host/owner/repo(.git) (the .git suffix is stripped
server-side). Public repos are world-readable; private repos and all pushes
require a session (push additionally requires ownership).
All three read objects from storage via isomorphic-git; the Repository row stays
metadata-only (src/git/read.ts):
| Method & path | Returns |
|---|---|
GET /api/page/repositories/:id/tree?ref=&path= |
{ entries:[{name,type,oid,mode}], readme, ref, path, branches } — root tree + rendered README (case-insensitive, common extensions) |
GET /api/page/repositories/:id/read?ref=&path= |
single file { path, ref, encoding, content } (utf8 or base64) |
GET /api/page/repositories/:id/commits?ref=&page= |
{ commits:[{oid,message,author,committer,timestamp,parent}], ref, page } (30/page) |
GET /api/page/repositories/:id/commit/:oid |
{ commit:{…}, diff:{base,head,files,stats} } — single commit metadata + diff against parent |
GET /api/page/repositories/:id/commit/:oid/diff |
{ diff } — just the commit's diff |
GET /api/page/repositories/:id/compare?from=&to= |
{ diff } — diff between two refs (branches/tags/SHAs) |
GET /api/page/repositories/:id/branches?page=&perPage= |
{ branches, total } — paginated branch list with tip commits |
GET /api/page/repositories/:id/tags |
{ tags } — lightweight + annotated tags (peeled to commit) |
GET /api/page/repositories/:id/raw?ref=&path= |
streamed raw file bytes (Content-Disposition: inline) |
The Honox UI renders a GitHub/Forgejo-style web forge, all server-side. Repository
pages live under /{owner}/{repo} (the canonical forge URL — the same address the
git transport uses, so a repo's web page and clone URL always agree):
| Route | What it shows |
|---|---|
/{owner}/{repo} |
repo home — default-branch tree + README + recent commits + branch selector + clone URL |
/{owner}/{repo}/src/{ref}/{path…} |
file / tree browser — resolves ref + path and renders either the directory tree or the blob (mirrors Forgejo's /src/branch|commit/* → repo.Home) |
/{owner}/{repo}/commit/{oid} |
single commit — title/body, author, timestamp + unified diff against parent |
/{owner}/{repo}/commits?ref=&page= |
paginated commit history; each links to its commit diff page |
/{owner}/{repo}/compare?from=&to= |
cross-ref diff (Forgejo's /compare/{base}...{head}) with a "Create PR" placeholder |
/{owner}/{repo}/branches |
branch management — paginated list + create / delete |
/{owner}/{repo}/tags |
tag list + create / delete (lightweight + annotated) |
/{owner}/{repo}/releases |
release list + delete; /releases/new to publish (tag, target, notes, draft/prerelease) |
/{owner}/{repo}/issues |
issue list with open/closed toggle + pagination |
/{owner}/{repo}/issues/new |
create-issue form |
/{owner}/{repo}/issues/{index} |
issue detail — body, comments, open/close toggle, comment form |
/{owner}/{repo}/settings |
repository settings — name/slug, visibility, description, default branch, website, template, archive/unarchive (Forgejo-style left settings nav) |
/{owner}/settings |
user settings — username/email, password, personal access tokens, danger zone |
/user/settings |
session-scoped settings entry (redirects to /{owner}/settings) |
Phase 3 adds the collaboration layer — DB entities (SQLite via Drizzle, same as
users/repositories) that sit alongside the git objects. The schema is in
drizzle/2026-08-17T19-00-00-000Z_phase3_issues_releases_sqlite_create.sql:
issues— repo-scoped, per-repo{index}numbering,state(open/closed),is_pull(a pull request is an issue + apull_requestsrow), milestone / assignee / labels, comment count.pull_requests— 1:1 with anis_pullissue; carrieshead_branch/base_branch/mergedstate.issue_comments,labels(hex color),milestones(due date),releases(tag_name + target commitish, draft/prerelease flags).
Services (src/services/{issues,releases,labels,milestones}.ts) are bound via
createServices(db); the HTTP endpoints are under
/api/page/repositories/:id/{issues,labels,milestones,releases}*.
src/services/access-tokens.ts implements Forgejo-style PATs: a token is a
40-hex string; only its SHA-256 is stored, and the raw token is returned
exactly once on creation. Tokens authenticate the git smart-HTTP transport
via HTTP Basic auth. Manage them at /{owner}/settings (Applications section)
or /user/settings.
- SHA-1 (default, shipped). isomorphic-git is SHA-1 only; the
repositoriesrow recordsobjectFormatName = 'sha1'. Runs entirely in a Worker. - SHA-256 /
--compat-object-format=sha1. Not supported by the pure-Worker path (isomorphic-git has zero SHA-256 support). The planned approach is an additive serverless Cloudflare Container running the realgit-http-backend(objects in R2), proxied by the samemountGitRouteswhile the Worker keeps serving the read API. Containers are GA (2026-04-13), scale to zero, and bill on active CPU — no persistent VM.
src/git/git.e2e.test.ts boots the real Hono app (buildQueryApp +
mountGitRoutes) with the local-fs backend, seeds a user + repository, then uses
isomorphic-git as a client to push a commit and clone it back — asserting
object parity, commit history, and the /tree / /commits read APIs.
bun test src/git # git smart-HTTP e2e + protocol/read unit testsThe forge ships a JSON/HTTP API built on a Drizzle-ORM-backed service layer.
The same Hono app (buildQueryApp in src/http/app.ts) is mounted by both the
local dev server (scripts/serve.ts) and the Cloudflare Worker, so the API is
identical everywhere. The UI routes (app/routes) never touch SQL themselves —
they fetch these endpoints over HTTP.
request ─▶ src/http/app.ts (buildQueryApp(db, auth?))
│ calls service functions, never sql.unsafe
▼
src/services/ (createServices(db) ─▶ { boards, threads, posts, home, search, users })
│ all SQL via the Db interface (drizzle-orm/libsql locally, drizzle-orm/d1 on Workers)
▼
src/db/client.ts (createQueryDb) ── derived Drizzle tables from the SqlSerialisable capacity
src/services/— the data-access service layer.createServices(db)returns a bound object ({ db, repository, users, home, search, webhooks, runs, accessTokens, issues, releases, labels, milestones }); every function is partially-applied withdb, so handlers callsvc.repository.getPage(id)without threadingdbthrough. None of these functions usesql.unsafe— all dynamic values reach SQL through?bind params (seetoSqlinsrc/services/types.ts). The forge services (issues/releases/labels/milestones/accessTokens) use the hand-writtenall/runhelpers; the primary models (User/Repository) use the full typia/capacity pipeline.src/http/app.ts—buildQueryApp(db, authInstance?)wires the service layer to the routes. Hand-written read models are registered first; the generated aggregate + CRUD routes are registered last so the rich read models win on identical method+path.src/db/client.ts—createQueryDb(target)turns aDatabaseTarget(resolved fromDATABASE_URL) into the request-pathDbviadrizzle-orm/libsql. One libSQLClientbacks both the schema bootstrap (ensureSchema/hasSchemaover anunsafeadapter) and the query path, so the tables seeded at startup are exactly what queries see. The Cloudflare Worker usesdrizzle-orm/d1from theenv.DBbinding instead.
Why
drizzle-orm/libsqland notdrizzle-orm/bun-sql? The defaultdrizzle()fromdrizzle-orm/bun-sqlwraps a BunSQLclient in the Postgres driver, which has no.all/.run/.getraw methods the service layer needs. The SQLite variant in that package fails to bind the client to its session in this drizzle version.libsqlis the stable, first-class SQLite driver.
The service layer is driver-agnostic: it talks to any SQL executor through a
small Db interface (src/services/types.ts):
all(query, params)/run(query, params)/get(query, params)— raw?-parameterised helpers (converted to parameterised DrizzleSQLbytoSql).select/insert/update/delete— the Drizzle query builder.
All read/mutation endpoints return pretty-printed JSON of the shape
{ "ok": true, "data": … } (or { "ok": false, "data": { "error": "…" } } on
failure). They are grouped into three families.
1. Read models (/api/*)
| Method & path | Notes |
|---|---|
GET /api/stats |
site-wide counts |
GET /api/repositories?limit=&cursor= |
cursor-paginated index |
GET /api/repositories/:id |
404 if missing |
GET /api/repositories/aggregate |
Aggregable grouping (e.g. repos per owner) |
GET /api/users/:id |
404 if missing |
GET /api/users/aggregate |
Aggregable grouping (e.g. avg age per role) |
GET /api/search?q=&limit= |
q is required |
GET /api/latest-posts?limit= |
|
GET /api/<model>/aggregate |
generated by the Aggregable capacity, for posts, users, threads, boards, replies |
2. Composite /api/page/* endpoints — the exact payloads the SSR route
handlers consume (so the UI never runs SQL):
GET /api/page/home ·
GET /api/page/boards?cursor= ·
GET /api/page/boards/:id?cursor= ·
GET /api/page/boards/:id/edit ·
GET /api/page/threads?board=&locked=&cursor= ·
GET /api/page/threads/:id (also returns currentUserId from the session) ·
GET /api/page/threads/:id/edit ·
GET /api/page/posts?published=&cursor= ·
GET /api/page/posts/:id ·
GET /api/page/posts/:id/edit ·
GET /api/page/users/:id
3. Mutations (POST /api/page/*) — form-encoded (application/x-www-form-urlencoded),
return 3xx redirects so the SSR route streams them to the browser. They
require an authenticated session (redirect or 401 otherwise).
POST /api/page/threadswithaction=create | create-thread | update-title | toggle-pin | toggle-lock | deletePOST /api/page/threads/:id/edit(action=save)POST /api/page/threads/:id/replyPOST /api/page/threads/:id/reply/:replyId/update(author-only)POST /api/page/boards(action=create),/api/page/boards/:id(create-thread | save),/api/page/boards/:id/edit(action=save)POST /api/page/posts/:id/edit(action=save)
Each model can also register a full CRUD surface via its Servable capacity:
GET/POST /api/<model> and GET/PUT/DELETE /api/<model>/:id. These are
registered last, so the hand-written read models above win on an identical
method+path. Thread adds auth guards: onBeforeCreate requires a session and
forces authorId to the caller's id; onBeforeUpdate strips authorId.
# JSON API only (no UI build required):
PORT=8787 bun scripts/serve.ts --port=8787 --mode=api
# or through the CLI entry:
bun run src/main.ts serve --mode=api
# With a built UI: the Honox UI is at / and the same API at /api (port 8787 /
# PORT=):
bun run src/main.ts ui:build && bun run src/main.ts serve200—{ "ok": true, "data": … }404—{ "ok": false, "data": { "error": "<entity> not found" } }400— validation / missing input (e.g.searchwithout?q=)401— guarded create/update with no session:{ "ok": false, "data": { "error": "authentication required to create a thread" } }
A model is defineModel (see src/models/base.ts) applied to a reflected typia
schema plus a fixed bundle of typia functions (the schema module), then folded
with a list of capacities — tiny mixins that each own one cross-cutting
concern. The forge's primary models are User and Repository (the catalogue row
for a git repo); the starter also ships Post and the BBS example models Board,
Thread, Reply, all composed from the same reusable capacity set (Identifiable,
Timestamped, SqlSerialisable, Referencible, Versionable, Hashable,
Validatable, Queriable, Siftable, Servable, Randomisable, Meterable,
…).
See docs/data-models-storage.md for the data
model & storage architecture, and docs/cli.md for the CLI and
query reference.
src/
cli/ CLI entry (main.ts via index.ts barrel) + query command (query.ts)
models/ defineModel-based models (User, Repository, Post, Board, Thread, Reply)
capacities/ the reusable capacity mixins (compose.ts folds them)
storage/ identity map + store
tags/ custom typia tags (Reference, Sha256, …)
macros/ build-time macros (env, databaseUrl, databaseType, …)
services/ data-access service layer (repository, users, issues, releases, labels,
milestones, accessTokens, webhooks, runs, home, search) bound via
createServices(db) — no sql.unsafe
git/ the git server backend: protocol.ts (pkt-line), upload.ts/receive.ts (smart-HTTP
v1), read.ts (tree/blob/README/commits), backend.ts (local + R2), routes.ts, refs.ts
http/ buildQueryApp(db, auth?, gitBackend?) — the reusable Hono query/REST app
(mounted by serve.ts + the Worker); git read API + mountGitRoutes wiring
scripts/
build.ts programmatic Bun.build (typia transform plugin)
model-build.ts models:build — models → src/generated/models.json
db-generate.ts db:generate — models.json → CREATE TABLE SQL in drizzle/
db-migrate.ts db:migrate — apply migration SQL via drizzle-orm/bun-sql
seed.ts db:seed — BBS dataset via Randomisable.random()
serve.ts serve — local server: Honox UI at / + JSON API at /api
ui-build.ts ui:build — build the Honox UI -> dist/index.js (+ static)
ui-cf-build.ts ui:cf-build — build the UI into a CF Worker -> dist/ui-cf/index.js
app/ Honox UI (routes/, islands/, client.ts, style.css — Panda CSS)
routes/
index.tsx home — hero, stats, repository grid
user/settings.tsx session-scoped account settings entry
[userId].tsx user profile — repo list
[userId]/settings.tsx user settings (profile/password/tokens)
[userId]/[repositoryName].tsx repo home (tree + README + commits)
[userId]/[repositoryName]/
src/[...path].tsx file/tree browser (/src/{ref}/{path})
commit/[oid].tsx single commit + diff
commits.tsx commit history (paginated)
compare.tsx cross-ref diff
branches.tsx / tags.tsx branch & tag management
issues.tsx / issues/new / issues/[index]
releases.tsx / releases/new
settings.tsx repository settings
components/
repo-layout.tsx shared repo page shell (header + tab bar + breadcrumb)
repo-settings-layout.tsx shared repo settings shell (Forgejo left nav)
user-settings-layout.tsx shared user settings shell
diff-view.tsx unified diff renderer (stats + per-file hunks)
server.ts local UI server entry (bun:sql, mounts /api)
server.cf.ts CF Worker UI entry (D1, mounts /api)
panda.config.ts Panda CSS config (tokens + utilities -> design-system/)
vite.ui.config.ts Vite config for the local UI build (@hono/vite-build/bun)
vite.ui.cf.config.ts Vite config for the CF Worker UI build (@hono/vite-build/cloudflare-workers)
wrangler.config.ts generates wrangler.jsonc (main + assets + D1 binding)
docs/ data-models-storage + CLI reference