LaunchAnnouncement

Introducing DaloyJS: One Route, Many Runtimes, Zero Ceremony

The launch post. One app.route({...}) becomes your validation, types, OpenAPI, typed client, and contract tests — and the same app runs on Node, Bun, Deno, Workers, and Vercel Edge.

Devlin DuldulaoFullstack cloud engineer11 min read

Hi, I'm Devlin. I've been writing fullstack web apps for about ten years — long enough to have shipped to production using Express, Koa, Hapi, NestJS, Fastify, and Hono, and long enough to have written the same little "just a tiny wrapper" library around all of them, six times, in three different jobs. I live in Norway now, where the sun sets at 11pm in May and the coffee costs about as much as a small server. Today I want to introduce you to the framework I've been working on, the one I quietly wished existed every one of those six times.

It's called DaloyJS. The pitch fits on one line:

One route. Many runtimes. Zero ceremony.

Translated into engineering: a single app.route({...}) call is the source of truth for validation, TypeScript types, OpenAPI, the typed client, and your contract tests. And that same appruns on Node, Bun, Deno, Cloudflare Workers, and Vercel Edge — the same file, no rewrites, no "works on my Node version" magic.

If that paragraph already made you go "wait, really?", the rest of this post is the proof. With code. In a fake editor, because I've been told my blog posts are easier to read when they look a bit more like an IDE and a bit less like a wall of grey.

The smallest end-to-end example that actually means something

I'm going to define a single route, start a server, hit /openapi.json, and then call the same route through the typed client — without a network in the middle, because the typed client knows it's the same process. Four things, one source of truth, no codegen step. Let's go.

Step 1 — Define the route

Open your editor. (I'm going to draw mine for you, so we look at the same thing.)

src/app.tssrc/server.tsscripts/smoke.ts
ts
// src/app.ts
import { z } from "zod";
import { App, secureHeaders, rateLimit, requestId } from "@daloyjs/core";

export const app = new App({
  bodyLimitBytes: 1 << 20,   // 1 MiB
  requestTimeoutMs: 5_000,   // 5s
});

app.use(requestId());
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 120 }));

app.route({
  method: "GET",
  path: "/books/:id",
  operationId: "getBookById",
  summary: "Fetch a book by id",
  request: {
    params: z.object({ id: z.string().min(1) }),
  },
  responses: {
    200: {
      description: "Found",
      body: z.object({ id: z.string(), title: z.string() }),
    },
    404: { description: "Not found" },
  },
  handler: async ({ params }) => {
    if (params.id === "missing") {
      return { status: 404 };
    }
    return {
      status: 200,
      body: { id: params.id, title: `Book ${params.id}` },
    };
  },
});
● src/app.ts — saved

Things to notice before we move on, because they look small but aren't:

  • request and responses use Standard Schema. I used Zod here, but you can swap in Valibot or ArkType without changing the framework. The schema isn't a decoration — it's the validator, the OpenAPI body, and the TypeScript type of params inside handler.
  • The handler's return type is a discriminated union of every status you declared. Forget to handle a status? The compiler will tell you. Try to return a body for 404when you didn't declare one? Also a compile error. This is the part that quietly removes about a third of the bugs I've shipped in the last decade.
  • bodyLimitBytes and requestTimeoutMs are constructor arguments, not optional middlewares you forgot to register at 4pm on a Friday. Security defaults are on. You opt out, not in.

Step 2 — Serve it (on Node, for now)

src/app.tssrc/server.tsscripts/smoke.ts
ts
// src/server.ts
import { serve } from "@daloyjs/core/node";
import { app } from "./app";

serve(app, { port: 3000 });
▶ pnpm dev — listening on :3000

That's the whole server. serveis the Node adapter; we'll swap it in a minute. Run it:

bash
pnpm dev

Step 3 — Hit /openapi.json (the spec was free)

You did not write an OpenAPI document. You did not run a codegen. You did not maintain a YAML file in a folder called openapi/ that your team agreed to update and then quietly stopped updating around sprint 4. The spec is just… there:

bash
# the spec is generated from the route, not the other way around
curl http://localhost:3000/openapi.json | jq '.paths."/books/{id}".get.operationId'
# "getBookById"

This is, I think, the moment where most people stop and reload the URL in a browser. Go ahead. The whole document is consistent with the route by construction, not by convention. The docs explain how to customize info, tags, servers, and security schemes, but the default is: open your browser, see your API.

Step 4 — Call it through the typed in-process client

Now for the part that, the first time I saw it work, made me say a word I will not type here because my mother reads this blog. We're going to call the route without going through HTTP. Same app object, same validation, same response shape — just no socket in the middle. Perfect for tests, scripts, and anywhere you want speed without spinning up a server.

src/app.tssrc/server.tsscripts/smoke.ts
ts
// scripts/smoke.ts
import { app } from "../src/app";
import { createInProcessClient } from "@daloyjs/core/client";

const api = createInProcessClient(app);

// 1) Happy path — body is narrowed to { id: string; title: string }
const ok = await api.getBookById({ params: { id: "42" } });
if (ok.status === 200) {
  console.log(ok.body.title.toUpperCase()); // ✅ string method, fully typed
}

// 2) Unhappy path — TS knows there's no body to read
const miss = await api.getBookById({ params: { id: "missing" } });
if (miss.status === 404) {
  // miss.body // ❌ TS error: Property 'body' does not exist on type { status: 404 }
}
✓ tsc --noEmit — 0 errors

Read the comments carefully — that if (ok.status === 200) branch is a real discriminated union. Inside it, TypeScript narrows ok.body to { id: string; title: string }. Outside of it, ok.bodydoesn't exist as far as the compiler is concerned. You get this without writing types, without a codegen step, and without keeping a hand-written client in sync. It just comes from the route.

(If you do want a real over-the-wire fetch SDK to ship to a separate frontend repo, you also get that — run pnpm gen and you get a typed fetch client off the generated OpenAPI. The in-process one above is what I reach for in tests.)

Same app, five runtimes, one file changed

Here is where I usually have to convince people I'm not lying. The app object you defined above never imported a runtime. It only knows about Request in and Responseout. The runtime quirks live in adapters at the edges, where they belong. So when your platform changes — because your CFO discovered Cloudflare, or your team migrated to Bun, or your boss said the word "edge" in a meeting — you change exactly one import.

src/server.node.tssrc/server.bun.tssrc/server.deno.tssrc/worker.tsapp/api/route.ts
ts
// node
import { serve } from "@daloyjs/core/node";
serve(app, { port: 3000 });

// bun
import { serve } from "@daloyjs/core/bun";
serve(app, { port: 3000 });

// deno
import { serve } from "@daloyjs/core/deno";
serve(app, { port: 3000 });

// cloudflare workers
import { toFetch } from "@daloyjs/core/fetch";
export default { fetch: toFetch(app) };

// vercel edge / any Web Fetch runtime
import { toFetch } from "@daloyjs/core/fetch";
export const GET = toFetch(app);
5 entrypoints · 1 app

That is the entire diff between running on a Node container and running on a Cloudflare Worker. Your route file does not change. Your tests do not change. Your OpenAPI does not change. I've done this migration in real apps. It used to take a week. Now it takes a coffee, which in Norway, to be fair, is still expensive.

Contract tests come for free, because the contract is the route

One of my favourite side effects of having a single source of truth is that "contract testing" stops being a separate initiative with its own Confluence page. You just write a test against the typed client, and if the contract drifts, the compiler screams before the test even runs.

tests/books.contract.test.ts
ts
// tests/books.contract.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { createInProcessClient } from "@daloyjs/core/client";
import { app } from "../src/app";

const api = createInProcessClient(app);

test("getBookById returns a typed 200", async () => {
  const res = await api.getBookById({ params: { id: "42" } });
  assert.equal(res.status, 200);
  if (res.status === 200) {
    assert.equal(res.body.id, "42");
    assert.match(res.body.title, /^Book/);
  }
});

test("getBookById returns 404 for missing id", async () => {
  const res = await api.getBookById({ params: { id: "missing" } });
  assert.equal(res.status, 404);
});
node --test · 2 passing

Notice that the test imports the same appas the server. There is no mocked schema, no parallel type definition, no re-derived response shape. If someone changes the route's 200 body to remove title, this test fails to compile. Not fails to run — fails to compile. That's the bug being caught at the earliest possible moment in the lifecycle, which is roughly nine months earlier than I usually catch them.

What "zero ceremony" actually means

I want to be specific about the "zero ceremony" part, because every framework on Earth claims it and most of them are being a little optimistic. Here's what we mean, concretely:

  • No decorators, no reflect-metadata, no "please enable experimental TS flags". Routes are objects. Handlers are functions. If you can read JavaScript, you can read this.
  • No separate OpenAPI fileto maintain. The spec is generated; you customize it, you don't author it.
  • No separate client repo to keep in sync. The in-process client is one import. The generated fetch SDK is one command.
  • No security checklist to remember. Body limits, request timeouts, prototype-pollution-safe JSON parsing, path-traversal rejection, and 5xx redaction in production are defaults. secureHeaders(), rateLimit(), requestId(), CSRF, sessions, and tracing are first-party — same repo, same release cadence, same test suite.
  • No runtime lock-in. Same app, five adapters.

What this post is anchoring

This is the launch post, and every later post on this blog will point back here, because everything we build sits on this one idea: the route is the contract, and the contract is the route. The next posts will dig into specific pieces — the typed client in depth, running on Cloudflare Workers in production, how we keep the supply chain hardened with pnpm and SHA-pinned actions, OpenTelemetry without a 60-page setup doc — but they all assume the example you just read.

Try it in two minutes

You can try this in less time than it takes my espresso machine to warm up. (Mine is slow. Yours is probably fine.)

bash
pnpm create daloy@latest my-api
cd my-api
pnpm dev

# in another tab
curl http://localhost:3000/openapi.json | jq '.info.title'
# "my-api"

Then open Getting started, poke at /openapi.json, change one field in the route and watch the typed client complain at you in red squiggles. If something breaks, please tell me — the only way a framework earns the right to exist is by surviving other people's real code.

The honest part

DaloyJS is not magic, it is not going to make you a better developer overnight, and it is definitely not going to make my coffee any cheaper. What it isis the framework I would have wanted ten years ago, eight years ago, five years ago, and last Thursday. It removes a stack of recurring, boring, soul-eroding problems — the kind that cost you a Saturday at 2am — so you can spend your energy on the actually interesting parts of your product. That's the whole promise. One route, many runtimes, zero ceremony.

Thanks for reading. Go write a route. I'll be in Oslo, watching the sun refuse to set, very politely.

— Devlin