close

DEV Community

Cover image for Conduit: The Gateway I Built to Forget About
Adam - The Developer
Adam - The Developer

Posted on

Conduit: The Gateway I Built to Forget About

Sandboxed Deno plugins for Go gateways

Three microservices, two incompatible auth models, one public endpoint, and a Go gateway that turned into a pile of strings.HasPrefix conditionals. Here's the rewrite, in Go + TypeScript, and why it ended up sandboxed across two runtimes.

I've been trying to keep my publishing schedule to one article a week, but this one felt too special to wait.

The article today might be a little boring depending on how much you're into systems and distributed architecture. And if you're already thinking, "Great, another API gateway," I can't really blame you.

But bear with me for a bit: Conduit isn't just another request-forwarding proxy. Along the way, we'll dive into programmable gateways, sandboxed runtimes, Unix sockets, and some of the engineering decisions that made building it surprisingly fun.


TL;DR

  • Built a small Go API gateway in front of three services with two different auth schemes (custom HMAC sessions, and JWT).
  • It worked. It also became unmaintainable: every routing exception meant editing Go and redeploying.
  • Rewrote it as Conduit: Go stays on the network path (routing, proxying, CORS, SSRF guards). A sandboxed Deno runtime runs policy as plain TypeScript plugins, talking to Go over a Unix socket with a strict snapshot-and-patch protocol.
  • Net result: auth, logging, and routing rules became files I could edit and save, no recompiling the gateway.
  • Full FAQ/defense-of-design-decisions Q&A is here, not in this post.

The setup

Three services, one public API:

  • chat-service: real-time messaging
  • user-service: profiles, sessions, identity
  • admin-service: internal tooling, elevated operations

User and chat traffic ran a custom session + HMAC scheme: Authorization: Session <id>, signed with X-Signature, X-Timestamp, X-Nonce, backed by Redis for sessions and replay protection. Not JWT. Not OAuth. Something built and iterated on for months.

Admin traffic was JWT, verified at the edge, then forwarded with trusted X-Gateway-Admin-* headers so the admin service skipped re-validation.

Different trust models. Different header shapes. Different public-path exceptions. All of it had to land on one hostname, because clients don't care how many services you run behind it.

So I built the obvious thing: a small Go gateway. Service-prefixed paths picked the upstream. A route.Select() function picked the auth mode. A growing stack of prefix conditionals decided which paths were public:

/admin-service/*  → admin upstream   + JWT (except login, refresh, external-api)
/user-service/*   → user upstream    + HMAC (except auth, device, internal, …)
/chat-service/*   → chat upstream    + HMAC for users, JWT for /api/v1/admin/*
Enter fullscreen mode Exit fullscreen mode

It shipped. It worked. It was ugly.

Where the ugliness actually lived

Not in reliability. Requests hit the right upstream, JWT/HMAC validation worked, sessions slid TTL correctly. The ugliness was in extensibility:

res := route.Select(r.URL.Path)
proxy := proxies[res.Backend]
h, ok := applyAuth(w, res.Auth, cfg, sessionStore, nonceStore, proxy)
h.ServeHTTP(w, r)
Enter fullscreen mode Exit fullscreen mode

Clean on paper. But adminAuth(), userAuth(), and chatAuth() were each a wall of strings.HasPrefix branches, each one carrying tribal knowledge about which paths were exceptions. Every new public-path exception meant editing Go and redeploying the gateway. Figuring out why a path behaved a certain way meant reading three functions and cross-referencing a README the size of a small service.

I wasn't failing at microservices. I was failing at boundary discipline: policy had nowhere clean to live except deeper inside the gateway's Go source.

Every new exception made the gateway more specific instead of more general.

That gateway didn't get thrown away. It proved the routing model was right. It just proved the implementation model needed to change. That became Conduit.

Design goals (the actual constraints, not aspirations)

1. Freedom of implementation. If you can write TypeScript, you can extend the gateway. No DSL, no Lua config, no plugin marketplace format: drop a file in ./plugins, export an object with lifecycle hooks:

import type { GatewayContext } from "../runtime/shared/types.ts";

export default {
  beforeRequest(ctx: GatewayContext): void {
    if (!ctx.request?.headers?.Authorization) {
      ctx.reject!(401, "missing token");
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

2. Low operational overhead. No control plane, no Kubernetes requirement, no sidecar ceremony. Default setup is one Go binary supervising a Deno runtime, reading a JSON config:

go run ./cmd/conduit -config conduit.config.json
Enter fullscreen mode Exit fullscreen mode

3. Explicit separation of concerns.

Layer Responsibility
Go gateway HTTP ingress, routing, upstream proxying, CORS, body limits, timeouts, SSRF guards
Deno runtime Sandboxed plugin execution in a warm worker pool
Plugins Business policy: auth, logging, transforms, route control

Go never gets to know about plugin internals. The runtime never owns routing. Plugins never touch the network boundary directly. These invariants are written down because violating them produces cross-language bugs that tests don't reliably catch.

4. Predictable failure modes. A misbehaving plugin shouldn't take down the gateway. Hook timeouts log and continue. Worker crashes replace the isolate, not the process. If the entire runtime is unreachable, failPolicy decides: closed (503, no proxy) or open (skip plugins, proxy anyway), configurable per route.

5. Invisible in production. The bar I actually cared about: deploy a plugin when policy changes, glance at structured logs when something's off, otherwise forget the thing exists.

Architecture: two processes, one socket

Per request:

  1. Client hits the Go gateway.
  2. CORS preflight (OPTIONS) is answered in Go; plugins never see it.
  3. Go builds a serializable context snapshot from the request.
  4. Every plugin's beforeRequest hook runs, in filename order.
  5. Go proxies once to the matched (or plugin-selected) upstream.
  6. The upstream response gets attached to context.
  7. Every plugin's afterResponse hook runs, same order.
  8. Go writes the response to the client.

Upstream services have zero awareness Conduit exists: no SDK, no self-registration. Multi-service routing is config, not code:

{
  "routes": [
    { "path": "/user-service/*",  "upstream": "http://user-service:2001" },
    { "path": "/admin-service/*", "upstream": "http://admin-service:2002" },
    { "path": "/chat-service/*",  "upstream": "http://chat-service:2003" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

One plugin chain, many backends, policy applied uniformly at the edge.

The actual hard part: getting context across the process boundary

The real problem with the first gateway wasn't routing logic. It was where state lived. Mutable request objects passed through middleware created hidden coupling, and that gets worse the moment you're crossing a process boundary: you cannot hand Deno a live http.Request and hope for the best.

Conduit's contract:

  1. Go snapshots the request into serializable JSON.
  2. The snapshot crosses a length-prefixed frame over a Unix socket to Deno.
  3. The plugin operates on a cloned, hook-local context with a frozen inbound request.
  4. The plugin returns a patch: a minimal delta, not a mutated object.

Why patch instead of full context on return: sending the complete context back from every hook means re-serializing headers, identity, and potentially large bodies, twice per request, per plugin. The patch engine instead sends only what changed: omitted fields are unchanged, present fields overwrite, null tombstones a deletion. A logging plugin that sets one state field ships a few dozen bytes back, not the whole request graph.

Why bodies never fully cross the wire: anything above 64 KiB stays in a per-request BodyStore in Go. Plugins get a stream:// reference token, not raw bytes. Megabytes stay in Go; metadata crosses the socket.

Plugins don't own the request. They propose changes to it.

That sentence is the whole design. Reject/forward signals, the patch diffing rules, the frozen snapshot: all of it exists to make "propose, don't own" safe and cheap.

The plugin model

Plugins stay deliberately small:

Hook When
onLoad Once at startup
beforeRequest Before upstream proxying
afterResponse After upstream response is captured
onError When a hook in this plugin throws unexpectedly

ctx.reject() is intentional control flow, not an error; onError is for actual surprises.

Plugins load in filename order, full stop:

plugins/
  001-auth.ts           ← runs first
  002-logging.ts        ← sees ctx.user from auth
  003-header-rewrite.ts
  004-route-control.ts
Enter fullscreen mode Exit fullscreen mode

No dependency graph, no hidden priority system. 001-auth.ts runs before 002-logging.ts because of the filename, and that's grep-able at 3am.

The ctx surface is narrow on purpose:

Member Role
ctx.request Read-only inbound snapshot
ctx.response Outbound builder (setStatus, setHeader, setBody)
ctx.user Identity set by auth plugins
ctx.state Per-request key/value bag shared across hooks
ctx.log Structured logging with trace correlation
ctx.reject(status, msg) Stop pipeline, return HTTP error
ctx.forward(url) Proxy to an alternate upstream (validated by the gateway)
ctx.services Optional http and cache helpers

No global singletons, no ambient mutable request object. Auth sets ctx.user; logging reads it. That's the entire coordination contract.

What happened when I actually ran it

The first deployment was personal: three services behind one port, HMAC checks in one plugin file, JWT checks in another, logging in a third. The prefix-matching spaghetti became three files I could read top to bottom.

Then it spread quietly: internal tools needing a stable hostname, admin surfaces needing stricter gating, experimental routes I could add without redeploying backends.

The moment I knew it worked wasn't a benchmark. It was forgetting it was running, then stopping the Docker container to debug something unrelated and watching everything break. That's the success criterion I actually cared about: not feature parity with Kong, invisibility under normal operation.

Failure modes, stated plainly

Event Behavior
Plugin hook exceeds timeout (default 100ms) Warning logged; request continues proxying
Plugin worker crashes Isolate replaced; gateway process survives
Entire Deno runtime unavailable failPolicy: "closed" → 503; "open" → proxy without plugins
ctx.reject(401) Pipeline stops; remaining hooks skipped
Invalid ctx.forward() target Logged, ignored; original route used
Upstream failure Standard 502 propagated

Hook timeout and runtime death are different failure classes on purpose. A slow logger shouldn't look like a dead security layer.

The riskiest failure mode isn't a crash, though. It's silent semantic change: a plugin that mis-sets ctx.user or swallows a header keeps the system at 200 OK while behavior quietly drifts underneath it. That's why every log line carries [trace:<id>], and why reject and thrown errors are treated as distinct signals.

What it's not

  • Not Envoy: no xDS, no WASM filter ecosystem at scale.
  • Not Kong: no admin UI, no plugin marketplace.
  • Not a service mesh.
  • Not for multi-gigabyte streaming responses. afterResponse buffers the body so plugins can inspect/rewrite it, which costs memory proportional to response size.

What it gains in exchange: a codebase readable in an afternoon, plugins you edit and save instead of recompile, no cloud dependency to define policy, and bounded IPC cost (one warm Unix socket, patches instead of full payloads, body references instead of raw bytes).

It's lean, not free. Four plugins across two phases is up to eight round trips per request. Sub-millisecond on a local socket. Wrong tool if you need per-byte stream processing at scale; right tool if you've got a handful of services, evolving edge policy, and a team that already writes TypeScript.

What stuck

  • Abstraction is only good if it disappears in daily use. The pieces that survived are the ones I stopped thinking about. The pieces that died were baked into Go: the adminAuth()/userAuth()/chatAuth() prefix ladders that worked fine until they needed to change without a redeploy.
  • Cross-process plugin systems are viable if state transfer is strict. Patches, body references, frozen snapshots. Not optimization trivia; the actual precondition for Go and Deno cooperating without shared-memory bugs.
  • Failure design shaped operability more than any plugin API did. failPolicy, hook timeouts, and the reject/onError split mattered more than feature count.
  • "Simple" means boundary discipline, not low line count. Go owns the network. Deno owns execution. Plugins own policy proposals. Upstream owns business logic. Nobody reaches across.

Closing

This wasn't built to be a product. It was a response to a system that stopped fitting in my head: three services, two auth models, one endpoint, and a gateway that grew a new strings.HasPrefix branch every time something shipped.

The goal was never to out-build Envoy or Kong. It was to build something I could forget existed until the day I needed to reason about it. At that point, reasoning about it should be easy: read the plugin files, read the config, follow one request through Go → IPC → Deno → upstream → back.

If you're standing where I was (auth diverging, routing logic leaking into every handler), you might not need another service. You might need a place where policy can live without infecting everything else.


Try it:

go run ./cmd/conduit -config conduit.config.json
Enter fullscreen mode Exit fullscreen mode
export default {
  beforeRequest(ctx) { /* policy */ },
  afterResponse(ctx) { /* transforms */ },
};
Enter fullscreen mode Exit fullscreen mode

Full design-decision Q&A (why Deno over Node/Lua/WASM, scaling, "when is this the wrong choice," etc.) is in my site, not here.

Top comments (7)

Collapse
 
sylwia-lask profile image
Sylwia Laskowska

I really like this approach! And yes, authorization is one of those things that quickly turns into a nightmare when it's scattered across multiple microservices. 😅

Having a single place where authentication and authorization policies live makes the system so much easier to reason about and evolve. The plugin-based approach is especially neat because you can change policy without touching the gateway itself.

Very cool project!

Collapse
 
adamthedeveloper profile image
Adam - The Developer

Thank you!

That's pretty much the problem Conduit came from. The auth wasn't hard, but the exceptions kept piling up and slowly turning the gateway into a mess of special cases.

The plugin system was really just my way of giving policy its own place to live so it could evolve without constantly changing the gateway itself.

I'm glad you enjoyed it!

Collapse
 
jugeni profile image
Mike Czerwinski

The three discipline primitives that survive in this design (propose-don't-own, patches not mutations, filename ordering not dependency graphs) keep showing up in adjacent territory under different vocabularies. The same shape lands in operator-side decision audit work, where the surface authoring policy must not own the artifact it gates and write-side fixes only work if read-side cooperates. Different domain, identical architectural commitment: the gate authority cannot live inside the thing being gated.

The silent-semantic-change failure mode is the part I think the post under-credits relative to how much work it does. A plugin mis-setting ctx.user without throwing keeps the system at 200 OK while behavior drifts, and trace:id alone tells you the request happened, not that the meaning held. The one extension I would push on, less for Conduit specifically and more for the shape: a periodic planted-fault test against the patch engine itself. Submit a patch that should be rejected (a logging plugin trying to set ctx.user, or an auth plugin trying to overwrite a header marked immutable), watch whether the rejection fires or the patch lands silently. The patch engine is only doing the work it claims if it has demonstrably caught a deliberate violation in recent memory. Same shape as the failPolicy calibration: closed only matters if you have evidence it ever closed.

Honest stage marker on this side: I work adjacent (verification engineering on dev.to, no API gateway in production), and the design choice that lands hardest reading the architecture section is the boundary discipline as primitive, not the plugin model. The plugin model is the surface that makes the discipline survive contact with daily edits. That distinction is the part most plugin-system writeups skip.

Collapse
 
adamthedeveloper profile image
Adam - The Developer

Oh wow, thank you.

The distinction you drew between boundary discipline as the primitive and the plugin model as the surface that keeps it alive is exactly what I was reaching for in the architecture section, but you articulated it far better than I did. The "gate authority cannot live inside the thing being gated" framing is especially good—I may end up borrowing that in a future revision.

I think you're right about silent semantic drift, too. A crash is obvious. A timeout is visible. A mis-set ctx.user that still returns 200 OK is much harder to detect because nothing appears broken from the outside.

The planted-fault idea is also a really interesting extension. Conduit already verifies that request snapshots remain frozen on the plugin side, but it doesn't yet enforce ownership boundaries at patch-merge time. Today, ApplyPatch mostly trusts convention: auth owns ctx.user, logging reads it. Turning that into something the system actively proves—rather than something contributors are expected to respect—is a direction worth exploring.

I appreciate the thoughtful read. The fact that the boundary-discipline angle stood out more than the gateway itself probably means that part of the design came through after all.

Collapse
 
jugeni profile image
Mike Czerwinski

The "ApplyPatch mostly trusts convention" line is the part I want to take in trade. It names the failure mode that boundary-discipline systems usually leave undocumented because convention feels like architecture from the inside until somebody breaks it. The path forward you described (turning ownership into something the system actively proves rather than something contributors respect) is exactly the boundary discipline as primitive move you already shipped at the IPC layer, applied one floor down to plugin-vs-plugin write coordination.

One concrete shape that has been holding up for me in adjacent work: declarative ownership per plugin (auth.ts declares OWNS ctx.user, logging.ts declares READS-ONLY ctx.user) plus a patch-merge gate that validates write targets against declarations rather than trusting filename ordering. Same IAM-least-privilege pattern at the plugin layer, with the same planted-fault test as the IPC contract: submit a patch from logging.ts that sets ctx.user, watch whether the gate fires or the patch lands. If the gate ever stops firing for a real reason (somebody legitimately broadened logging.ts ownership), the declaration changes are the audit trail, not the conventions in the readme.

Vocabulary trade in return: "boundary discipline as primitive" only works if the primitive itself has a planted-fault test that demonstrably caught a deliberate violation in recent memory. Otherwise the primitive is also in the convention bucket, just at a higher abstraction. Same trap, longer label.

Collapse
 
nazar_boyko profile image
Nazar Boyko

Since plugins run in filename order and each hook is a round trip over the socket, that chain sets your latency floor per request, right? Four plugins across two phases is the eight round trips you mention, all sequential. I'm curious whether you considered letting independent plugins in the same phase run at the same time, or whether the ordering (002-logging reading the ctx.user that 001-auth set) matters often enough that keeping it serial is just the honest call. The "propose, don't own" patch model is the part that'll stick with me. It's the cleanest answer I've seen to sharing request state across a process boundary without the usual mutation bugs.

Collapse
 
sloan profile image
Sloan the DEV Moderator

Hey, this article appears to have been generated with the assistance of ChatGPT or possibly some other AI tool.

We allow our community members to use AI assistance when writing articles as long as they abide by our guidelines. Please review the guidelines and edit your post to add a disclaimer.

Failure to follow these guidelines could result in DEV admin lowering the score of your post, making it less visible to the rest of the community. Or, if upon review we find this post to be particularly harmful, we may decide to unpublish it completely.

We hope you understand and take care to follow our guidelines going forward!