Zuplo
Programmable Gateway

Gateway logic in TypeScript, not a DSL

Write custom inbound policies, outbound policies, and full request handlers in typed TypeScript. Web-standard APIs (Fetch, Crypto, Streams). V8 isolates across 300+ POPs. Compose other routes with `context.invokeRoute`. Share state with `context.custom`. No proprietary syntax, no separate Lambdas.

TypeScript at the edge
V8 isolates · 300+ POPs · sub-ms most policies
Inbound policyimport { ZuploRequest, ZuploContext } from "@zuplo/runtime"
1import type {
2 ZuploRequest, ZuploContext,
3} from "@zuplo/runtime";
4
5export default async function (
6 request: ZuploRequest,
7 context: ZuploContext,
8) {
9 const country =
10 request.headers.get("cf-ipcountry");
11 if (["RU", "KP", "IR"].includes(country)) {
12 return new Response("Forbidden", {
13 status: 403,
14 });

403 Forbidden for sanctioned regions

policy executes at the edge · type-safe imports from @zuplo/runtime

+1.2 ms
V8 isolates · web-standard APIs
Type-safe · @zuplo/runtime
~1–5 ms · most policies
Why this matters

Every gateway DSL bottoms out the same way: write a Lambda

Whatever the gateway can't express becomes a sidecar service. Once you're stitching sidecars, the gateway has stopped being a gateway and started being a routing layer in front of your real architecture.

×

Proprietary DSLs that fight you on day two

The first rule looks easy. The third rule is a Stack Overflow excavation. The seventh requires a vendor consultant. You wanted gateway logic; you got a custom programming language with no debugger.

×

“We need a Lambda for that”

Custom logic the gateway can't express moves to a Lambda. Now there's a network hop, a separate deploy pipeline, a separate IAM role, and a separate place to look when something breaks at 2am.

×

Gateway code lives outside Git history

Policies were clicked together in a UI. There's no diff for the change that broke production. Reverting means clicking through the audit trail, hoping nothing else changed at the same time.

×

Test? In production, mostly

There's no way to unit-test the proprietary policy language. "Testing" is deploying to staging and hitting endpoints. The fast feedback loop your application code has at the gateway tier doesn't exist.

What you get

Real code, real APIs, real performance

TypeScript, the language you already use

Custom policies are typed TypeScript functions imported from `@zuplo/runtime`. Code review them in the same PR as your application code. Unit-test with Jest or Vitest. Debug them with the same instincts you use everywhere else.

Full request and response context

Mutate the request before the handler. Transform the response before send. Read API key metadata, consumer attributes, geo, environment vars. Pass enrichment data between policies via `context.custom`. Log structured events with `context.log`.

Runs at the edge in 300+ POPs

Custom code runs in the same V8 isolates as Zuplo's built-in policies, on the same edge runtime, in 300+ data centers. Most policies add 1–5 ms. No extra network hop, no Lambda, no separate deploy.

Type-safe @zuplo/runtime · V8 isolates

Inbound, outbound, handler — all just TypeScript

Build an inbound policy that enriches every request with consumer metadata. Compose a BFF that aggregates three downstream routes in one handler. All in TypeScript, typed end-to-end, deployed via GitOps.

TypeScriptCustom inbound policy · TypeScript
import type { ZuploRequest, ZuploContext } from "@zuplo/runtime";

interface Options {
  internalApiUrl: string;
}

export default async function enrichWithCustomer(
  request: ZuploRequest,
  context: ZuploContext,
  options: Options,
) {
  const consumer = request.user?.sub;
  if (!consumer) return request;

  const url = options.internalApiUrl + "/" + consumer;
  const res = await fetch(url);
  const customer = await res.json();

  // Pass enrichment data to downstream policies + handler
  context.custom.customer = customer;
  request.headers.set("x-customer-tier", customer.tier);

  return request;
}
TypeScriptBFF handler · invokeRoute composition
import type { ZuploRequest, ZuploContext } from "@zuplo/runtime";

export default async function dashboardHandler(
  _request: ZuploRequest,
  context: ZuploContext,
) {
  // Each invokeRoute call runs the full inbound + outbound
  // pipeline for that route — auth, rate limit, validation —
  // without leaving the gateway.
  const [orders, profile, billing] = await Promise.all([
    context.invokeRoute("/v1/orders"),
    context.invokeRoute("/v1/profile"),
    context.invokeRoute("/v1/billing"),
  ]);

  return Response.json({
    orders: await orders.json(),
    profile: await profile.json(),
    billing: await billing.json(),
  });
}
Fetch · Crypto · Streams
ZuploRequest / ZuploContext
context.invokeRoute
context.custom shared state
Type-safe TOptions
1–5 ms · most policies
What makes Zuplo different

The skills you have, applied to the gateway tier

Web-standard APIs, not a vendor SDK

fetch, Headers, Request, Response, Web Crypto, Streams, URLPattern, TextEncoder/Decoder. Anything that runs in modern Chrome's Worker context generally runs in Zuplo. Skills transfer. Stack Overflow answers transfer.

context.invokeRoute · compose without leaving the gateway

Build a BFF in one handler that aggregates orders, profile, and billing routes — each one running through its own policies, all in-process. Build a multi-step MCP tool that orchestrates real APIs. No HTTP hops, no parallel infrastructure.

Type-safe options end-to-end

Declare a `TOptions` type in your policy. Reference the same JSON shape in `config/policies.json`. The types are validated at build, the values at deploy. Misconfiguring a policy is a build error, not a runtime surprise.

Share policies as npm packages

Publish your team's custom policies as an internal npm package. Consume them from any Zuplo project. Version-pin, semver, automate updates with Renovate or Dependabot — everything you already do for app code, applied to gateway code.

Real questions, real answers

What teams use this for

“We need a tier-aware response transformer.”

Inbound policy reads the consumer's tier from API key metadata and writes it to `context.custom.tier`. Outbound policy reads `context.custom.tier` and redacts PII fields if the tier is free. Two policies, ~20 lines each, no separate service.

“The mobile app needs an aggregated dashboard payload.”

Custom handler calls `context.invokeRoute("/v1/orders")`, `context.invokeRoute("/v1/profile")`, and `context.invokeRoute("/v1/billing")` in parallel — each one running through its own auth, rate-limit, and validation policies — then assembles the response. No BFF service to maintain.

“We need to enrich every request with internal customer data.”

Inbound policy hits your internal `/customers/{sub}` endpoint via `fetch`, attaches the result to `context.custom.customer`, and forwards. The handler and downstream policies read it without re-fetching. Cache the lookup with a Map for warm-worker reuse.

“Compliance wants every request body archived to S3.”

Outbound policy stringifies request + response, posts to your S3 bucket via the AWS SDK or a signed URL — same pattern documented for Azure Blob and S3. Async, no impact on response latency. Audit trail lives in your bucket, queryable with Athena.

Frequently Asked Questions

Common questions about programmable gateway logic on Zuplo.

Stop bottoming out at “write a Lambda”

Free Zuplo project, write your first custom inbound policy in TypeScript, and ship it to the edge in an afternoon.