DEV Community

Cover image for I got tired of gluing routers, queues, cron, and auth together — so I built okengine (Bun/TS, MIT, self-hostable)
Omq Khafi
Omq Khafi

Posted on

I got tired of gluing routers, queues, cron, and auth together — so I built okengine (Bun/TS, MIT, self-hostable)

The problem: a backend isn't one thing, it's ~40 things wearing a trench coat

When people say "just use Express/Hono/Fastify," they're right that routing is solved. But routing is maybe one of forty things a real backend actually needs: HTTP, request validation, auth, rate limiting, background jobs, cron, pub/sub, caching, cache invalidation, secrets, retries, dead-letter handling, observability, API docs, a typed client for your frontend, a Docker setup...

Each of those is usually a separate library, each with its own mental model, its own failure modes, and its own way of telling you something went wrong (or not telling you at all). The actual cost isn't "40 libraries" — it's the seams between them: the auth middleware that doesn't know about the queue consumer's context, the cron job that has no idea what the HTTP validation layer does with a bad payload, the cache invalidation you wire up by hand and forget about six months later. By my own count building this, a naive stack made of ~40 separate concerns produces something like 136 of those seams — places where two systems have to agree on something and nobody enforces that they do.

okengine's bet is that most of those 40 concerns aren't actually independent. They're one of eight primitives wearing different clothes. Model the primitives instead of the libraries, and the seam count drops hard — down to something like 48 in okengine's own architecture, because most of what used to be "two systems agreeing on a contract by convention" becomes "one declaration the compiler checks."

That's the pitch, and it's specific enough to check against the actual element list:

Element What it is Replaces
Flow behavior endpoint, handler, consumer, job, workflow
Signal data in motion queue, pub/sub, stream, websocket, SSE
Store data at rest DB, cache, KV, files, search
Clock time cron, delay, timeout, durable sleep, TTL
Gate permission to act auth, session, ABAC, rate limit, quota, flags
Vault protected knowledge secrets, config, env
Channel reaching humans email, SMS, WhatsApp, push
AI reaching machine intelligence models, prompts, embeddings, agents, RAG

Here's what two of these look like in practice.

Problem: an endpoint, a cron job, and a queue consumer are the same idea wearing three costumes

Most frameworks give you a different API for each: app.get() for routes, some scheduler library for cron, a queue client with its own connection/ack/retry vocabulary for consumers. You end up learning three testing strategies, three ways to see a stack trace, three ways to validate input — for what is, underneath, the same question every time: "given this input, when this thing happens, do this, and here's what can go wrong."

okengine's answer: there's one shape, called a Flow, and only the trigger changes.

export const createOrder = on(
  http.post("/orders"),
  flow("orders.create", {
    in: z.object({ sku: z.string(), qty: z.number().int().min(1) }),
    out: z.object({ id: z.string() }),
    errors: { OutOfStock: z.object({ left: z.number() }) },
    do: async (input, fx) => {
      const id = fx.id();
      await fx.store(db).insert(orders).values({ id, ...input, status: "pending" });
      return { id };
    },
  }),
);
Enter fullscreen mode Exit fullscreen mode

on(trigger, flow). Swap http.post("/orders") for every("1h") (cron), or another Flow's emitted event, or a database row changing — the flow(...) on the right never changes shape. in/out are runtime-checked contracts (Standard Schema — Zod, Valibot, whatever), not just compile-time types, so "the request looked fine to TypeScript but blew up at runtime" stops being a category of bug. do is the only place that touches the outside world, and it only does that through fx, a single effects handle — which means the compiler can actually see what a given Flow reads and writes, instead of trusting a comment.

out is optional — drop it and TypeScript still infers the return shape from do, but nothing checks it at runtime, and it won't exist as a typed contract anywhere downstream. Fine for a quick internal Flow; worth adding back the moment something else depends on the shape.

Problem: picking a queue, a pub/sub system, and a websocket layer locks in an architecture decision way too early

This is the one I'd actually want pushback on.

Say you emit "an order was placed." Depending on what happens next, you need completely different delivery guarantees:

  • A fulfillment job should run exactly once per order — if two workers grab it, you've double-shipped. If a worker dies mid-handler after charging a card but before acknowledging the message, the message retries, and now you might double-charge unless the handler is idempotent.
  • A cache-invalidation step and a customer-facing notification should both fire independently off the same event, without either one knowing the other exists — otherwise every time someone adds a new side effect, they're opening the same function and hoping they don't break the existing logic already living in it.
  • A live order-status page needs a subscriber who connects late to still see the full history, not just "whatever happens to arrive after they load the page."

Three different problems, and most stacks solve them with three different pieces of infrastructure: a queue library, a pub/sub client, a websocket/SSE layer — each bolted on separately, each requiring you to learn its own client API. okengine treats all three as one primitive (Signal) with a single required field, delivery, that has no default:

export const orderPlaced = signal("order-placed", {
  schema: z.object({ orderId: z.string(), total: z.number() }),
  delivery: "once",        // queue: exactly one consumer claims it, retries + DLQ
  retries: 2,
  deadLetter: true,
});
Enter fullscreen mode Exit fullscreen mode
  • once — queue semantics, solving the "exactly one worker" problem. Competing consumers, one claim per message, a retry budget, dead-letter queue on exhaustion. It's still at-least-once, not exactly-once under the hood — a handler that finishes its side effects but dies before acking can get reclaimed and rerun — so idempotency is still your responsibility, just not something the framework pretends doesn't exist.
  • broadcast — pub/sub, solving the "N independent listeners on the same event" problem. Every subscriber gets its own copy; adding a third listener later touches zero existing code.
  • live — a retained stream, solving the "late subscriber" problem. bus.live() replays the full retained history to whoever connects, then keeps streaming new events. No TTL or max-count on that retention today. Worth knowing: that replay is server-side only right now — createClient doesn't expose SSE/WebSocket/client.live yet, so a browser status page has to poll a normal HTTP Flow until that ships.

The part I think is actually the interesting design bet: delivery has no default. Omitting it is a compile error, not a silent choice of "whatever the library defaults to." The reasoning is that "how should this arrive" is a semantic decision about your data, not a detail — guessing wrong here produces the kind of bug that's expensive and quiet (a job that silently ran twice, a notification nobody got). Making it unskippable moves that decision to write-time, where it's cheap to think about, instead of debug-time, where it isn't.

Same declaration shape across all three — switching physics later is a one-word change to delivery, not a rewrite against a different library's API. One driver-level caveat on once/broadcast today: the Redis driver relays emits but consumption is still process-local, so competing consumers don't yet share state across multiple replicas — fine for a single consumer instance, not yet for horizontally scaled workers.

Problem: your API docs, your typed client, and your actual code all drift apart independently

Anyone who's maintained a hand-written OpenAPI spec next to real route handlers knows how this goes: the spec is accurate on the day someone remembers to update it, and stale every other day. Same story for a hand-maintained SDK/client package, an architecture diagram in a wiki, or a Docker setup nobody's touched since the project started.

okengine's answer is to make all of those derived, not hand-maintained. Your TypeScript compiles to one build artifact, manifest.oke.json, and everything else comes from that one file instead of being maintained by hand in parallel: a typed client (with live queries), OpenAPI + AsyncAPI docs, an architecture diagram that's "provably accurate" (it's generated from the actual system, not drawn to describe it), Console panels and traces, a least-privilege capability matrix, cache-invalidation keys, a replica read-routing plan, the Dockerfile and per-role compose files, tree-shaken bundle contents, and test-harness wiring. There's nothing to remember to keep in sync, because there's nothing hand-authored to fall out of sync in the first place.

The part aimed squarely at where tooling is headed: the Manifest also exposes an MCP surface, so an LLM agent can read what a backend actually does — real endpoints, real contracts — instead of inferring it from source and guessing wrong. oke dev boots your app alongside a live OpenTelemetry Console (15 panels — overview, flows, signals, store, clock, gates, vault, channels, AI, architecture, traces, runs, manifest diff, access, and plugins), a runtime MCP server, and a read-only docs MCP server, all generated from that same manifest.

"Self-hostable" as an actual workflow, not just a license badge

A lot of projects say "self-hostable" and mean "you can technically run our Dockerfile." The deployment path here is derived the same way everything else is: oke doctor checks secrets, ports, drivers, tenancy config, and schema drift before you ship anything; oke docker --prod generates the Dockerfile and per-role compose files with healthchecks, volumes, and resource limits already filled in from the Manifest; oke images pin freezes tag references to digests so a base image can't silently drift out from under you. oke start is literally the same command the container runs — there's no separate "production mode" that behaves differently from what you tested locally.

Numbers, not vibes

CI fails the build if these move:

Budget
Kernel 14.64kB / 15.36kB gzip
Client 2.06kB / 3kB gzip
Cold start (Bun) 10ms / 75ms
Routing <1ms p99

Stack and licensing

Bun-first, TypeScript, oxc for the toolchain, Drizzle for SQL, Postgres/DuckDB/Redis as the default drivers. MIT licensed, fully self-hostable — works against Neon, Supabase, CockroachDB, Upstash, Railway, or your own box. No hosted-only lock-in.

Links

I'd genuinely like criticism on two decisions specifically: forcing delivery to be explicit with no default on Signal, and betting on the Manifest/MCP surface before client-side live subscriptions even exist. Both are bets I'm not 100% sure about yet.

Top comments (0)