Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

proc-ts

Clone this repo, point your coding agent at it, and try building something in this paradigm. We'd love your feedback — open an issue!

CLAUDE.md is a symlink to this file — AI agents read the same doc as humans.

Clojure-style procedural TypeScript. Functions, data, REPL — no classes, no frameworks.

Why

We wanted the simplest possible environment for an AI coding agent — no abstractions, no magic, predictable results.

Most TypeScript projects end up with layers: classes, DI containers, decorators, middleware chains. Each layer hides state and makes it harder for an agent to understand what's happening, verify changes, and move fast.

The foundation is data and functions. That's it. An agent reads a 30-line function, changes it, reloads via REPL, calls it, sees the result — all in one cycle, no restarts. Add some types for guardrails so the compiler catches typos before runtime. And always tests — so changes are verifiable, not just "looks right."

Inspired by Clojure: functions over methods, data over objects, REPL over restart. But in TypeScript, with Bun, zero dependencies.

Core Principles

One function = one file

Every function lives in its own file. Filename = function name. No classes, no closures, no hidden state.

db_query.ts       →  db_query(ctx, sql, params)
http_issues.ts    →  http_issues(ctx, session, request)
escapeHtml.ts     →  escapeHtml(str)

Why: Each file is a self-contained unit. You can read it in isolation, test it in isolation, replace it in isolation. An AI agent can understand a 30-line file instantly. A 500-line class with 20 methods — not so much.

Everything through ctx

ctx is the single global state object. Database connection, HTTP server, routes, runtime state — everything lives here.

type Ctx = {
  fns: CtxFns;                      // all loaded functions
  routes: Record<string, Function>;  // registered HTTP routes
  db: Database | null;               // SQLite connection
  server: Server | null;             // HTTP server
  state: Record<string, any>;        // runtime data (counters, caches)
  t: any;                            // REPL scratch space
}

Why: No hidden singletons, no "what's in this?", no dependency injection. You can inspect all state at any moment via eval 'Object.keys(ctx)'. Tests construct their own ctx — no global setup, no mocking.

state is the escape hatch for runtime data. t is scratch space for REPL debugging. Everything else is typed.

ctx.fns instead of imports

Functions don't import each other. They access dependencies through ctx.fns:

// NOT this:
import db_query from "./db_query";

// This:
export default function system_start(ctx: Ctx, opts = {}) {
  const { db_start, db_query, server_start } = ctx.fns;
  db_start(ctx);
  // ...
}

Why: Traditional imports create frozen references. If you change db_query.ts, every file that imported the old version still holds a stale reference. You need to reload the entire dependency tree.

With ctx.fns, functions resolve at call time — like Clojure vars. Reload one file, and every caller immediately sees the new version. No stale references, no reload_all, no import cache busting.

Global types: Ctx, Req, Session

Types are declared globally in ctx.ts via declare global. No imports needed:

// ctx.ts
declare global {
  type Ctx = import("./ctx").Ctx;
  type Req = import("./ctx").Req;
  type Session = import("./ctx").Session;
}

So every function just writes ctx: Ctx — no import type { Ctx } from "./ctx" boilerplate.

type Req = Request & {                                    // Bun's native Request
  params: Record<string, string>;                         // + route params from router
}

type Session = {
  user: { id: number; username: string } | null;          // resolved from cookie
  token: string | null;
}

Why: These three types appear in every handler signature. Making them global removes repetitive imports while keeping full type safety. Typo in request.methood → compile error.

Strict CtxFns — typo = compile error

load_all generates ctx_fns.d.ts with exact function signatures:

// Auto-generated by load_all — do not edit
export default interface CtxFns {
  db_query: typeof import("./db_query").default;
  db_exec: typeof import("./db_exec").default;
  http_issues: typeof import("./http_issues").default;
  // ...every function in the project
}

The CtxFns type is strict — no index signature. ctx.fns.db_queryyy → compile error.

Why: Without this, ctx.fns would be Record<string, Function> — any key valid, any typo silent. The generated interface catches mistakes at compile time while keeping the runtime dynamic.

The only place that uses as any to write to ctx.fns is repl-proc-start.ts — the loader itself.

db_query<T> and db_exec — typed database

Two functions instead of one, each with a precise return type:

db_query<T>(ctx, sql, params)  T[]             // SELECT — typed rows
db_exec(ctx, sql, params)      { changes, lastInsertRowid }  // mutations

Usage:

type Issue = { id: number; title: string; status: string };

const issues = db_query<Issue>(ctx, "SELECT * FROM issues");
issues[0].title    // string ✓
issues[0].typo     // compile error ✓

const r = db_exec(ctx, "INSERT INTO issues (title) VALUES (?)", ["Bug"]);
r.lastInsertRowid  // number ✓

Why: A single db_query returning any infects everything downstream. Splitting into two functions with precise types means the compiler catches mistakes after the database boundary.

Migrations in db_migrate

All schema lives in one function — db_migrate(ctx). Called by system_start, but can also be called independently via REPL:

bun repl_send.ts eval 'db_migrate(ctx)'

Why: Adding a column? Change one file. Tests call system_start({env: "test"}) which calls db_migrate — schema is always in sync. No separate migration files, no migration runner, no drift between test and prod schema.

Auth guard in the router

server_start resolves session from cookie and checks auth before calling the handler:

const session = ctx.fns.session_from_cookie(ctx, req);
if (!session.user && !publicPaths.includes(pattern)) {
  return redirect("/ui/login");
}
return await handler(ctx, session, req);

Handlers never check auth themselves — they receive a guaranteed session with user populated.

Why: Auth logic in one place, not scattered across handlers. Every handler gets the same typed Session. Public paths (login, health) are explicitly listed.

Quick Start

bun install
tmux new-session -d -s proc-ts 'bun run repl-proc-start.ts'
bun repl_send.ts load_all
bun repl_send.ts eval 'system_start(ctx)'
# Login: admin / admin
open http://localhost:3002/ui/issues

REPL Workflow

The REPL server runs on :3001. The process stays alive — you never restart it during normal development. State (DB connections, server, data) persists across reloads.

Loading functions

bun repl_send.ts load_all         # first time: load all functions, generate types
bun repl_send.ts reload <name>    # after editing: reload one file

load_all is needed once at startup (or when you add new files). After that, reload <name> is enough — since functions resolve through ctx.fns at call time, there are no stale references.

Evaluating code

bun repl_send.ts eval 'db_query(ctx, "SELECT * FROM issues")'
bun repl_send.ts eval 'system_stop(ctx)'
bun repl_send.ts eval 'Object.keys(ctx.fns).length'

All loaded functions are available by name. ctx and session are in scope. await works.

Debugging with scratch space

ctx.t is a scratch pad for interactive debugging — like a notebook:

bun repl_send.ts eval 'ctx.t = {}'
bun repl_send.ts eval 'ctx.t.issues = db_query(ctx, "SELECT * FROM issues")'
bun repl_send.ts eval 'ctx.t.issues'          # inspect
bun repl_send.ts eval 'ctx.t = null'           # cleanup

Running migrations

bun repl_send.ts eval 'db_migrate(ctx)'        # run migrations without restart

Recovery

If things are broken (port conflicts, bad state):

lsof -ti:3001 | xargs kill; lsof -ti:3002 | xargs kill
tmux kill-session -t proc-ts 2>/dev/null
tmux new-session -d -s proc-ts 'bun run repl-proc-start.ts'
bun repl_send.ts load_all
bun repl_send.ts eval 'system_start(ctx)'

Type Checking

bunx tsc --noEmit    # full type check

What the compiler catches:

  • ctx.fns.typo — Property 'typo' does not exist on type 'CtxFns'
  • ctx.blabla = 1 — no index signature on Ctx
  • request.methood — Property 'methood' does not exist on type 'Req'
  • session.usr — Property 'usr' does not exist on type 'Session'
  • db_exec(...).length — Property 'length' does not exist on type 'ExecResult'

What it doesn't catch (by design):

  • ctx.state.anything — runtime data bag, intentionally any
  • ctx.t — REPL scratch, intentionally any
  • db_query rows without generic — returns any[] if you skip <T>

Testing

Tests use system_start(ctx, {env: "test"}):memory: DB, migrations run, no server started:

import db_query from "./db_query";
import db_exec from "./db_exec";
// ...wire all deps into ctx.fns

const ctx = { db: null, fns: { db_start, db_stop, db_query, db_exec, db_migrate, server_start } } as any;
system_start(ctx, { env: "test" });

// typed query
type Issue = { id: number; title: string; status: string };
const issues = db_query<Issue>(ctx, "SELECT * FROM issues");

Tests import functions directly (they don't go through the REPL), but wire them into ctx.fns for the function under test. The as any cast on ctx is needed because we construct a partial object — only the fns the test actually needs.

bun test                    # all tests
bun test login.test.ts      # one file

Route Convention

Files auto-register as HTTP routes on reload:

Prefix Example file Route
http_ http_ui_issues_$id.ts /ui/issues/:id
api_ api_issues_$id.ts /api/issues/:id

$ in filename → :param in route. _/.

Convention: http_* for pages and JSON APIs, api_* for form actions (POST → redirect).

Auth

  • Default user: admin / admin (created by db_migrate)
  • Login page: /ui/login (form prefilled for convenience)
  • All routes except /ui/login and /health require auth
  • Session stored in cookie, resolved by session_from_cookie before handler runs
  • Logout: /ui/logout — clears cookie and DB session

Architecture

ctx.ts / ctx_fns.d.ts          — types: Ctx, Req, Session + auto-generated fn signatures
repl-proc-start.ts             — REPL server (:3001)
repl_send.ts                   — CLI client

db_start / db_stop             — SQLite connection (WAL mode)
db_query<T> / db_exec          — typed SELECT → T[], mutations → {changes, lastInsertRowid}
db_migrate                     — schema + seed data

server_start / server_stop     — HTTP server with routing + auth guard
system_start / system_stop     — boot/shutdown orchestration
session_from_cookie            — resolve session from request cookie

http_ui_login / http_ui_logout — auth UI
http_ui_issues*                — issue tracker UI (Tailwind)
http_issues*                   — JSON API
api_issues*                    — form action handlers (POST → redirect)

layout / escapeHtml            — shared UI helpers

Claude Workflow

  1. Ensure REPL is running:

    tmux new-session -d -s proc-ts 'bun run repl-proc-start.ts'
  2. Write a functionexport default, typed ctx: Ctx, deps from ctx.fns. No imports from other project files.

  3. Load/Reload:

    bun repl_send.ts load_all          # new files
    bun repl_send.ts reload <fn_name>  # changed files
  4. Test:

    bun repl_send.ts eval 'fn_name(ctx, ...args)'
    curl localhost:3002/route
    bun test
    bunx tsc --noEmit
  5. If broken — restart:

    lsof -ti:3001 | xargs kill; lsof -ti:3002 | xargs kill
    tmux kill-session -t proc-ts 2>/dev/null
    tmux new-session -d -s proc-ts 'bun run repl-proc-start.ts'
    bun repl_send.ts load_all
    bun repl_send.ts eval 'system_start(ctx)'

Coherent architecture for a small, REPL-driven, procedural Bun application. Not a generally superior TypeScript architecture — a deliberately biased one. Core strength: operational simplicity. Core weakness: typed-but-global service locator via ctx and ctx.fns.

— OpenAI Codex (GPT-5.4), architectural review

Legitimate experiment in minimalist architecture that delivers on its core promise: fast feedback loops with maximum transparency, at small scale. The strongest genuine use case: an AI agent rapidly prototyping a CRUD app with instant verification via REPL eval. For that specific workflow, this is better than spinning up a Next.js project.

— Claude Opus 4.6, independent architectural review

About

Clojure-style procedural TypeScript. Functions, data, REPL — no classes, no frameworks. Agent-first architecture.

Resources

Stars

19 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages