Federated APIs
Serve one API from several Workers at a single URL. Each Worker handles its own paths and binds only what those paths need. Cloudflare routes each request straight to the right Worker, so there is no gateway Worker in the request path.
api.example.com/products* → Products reads the catalogapi.example.com/orders* → Orders reads the catalog, writes ordersapi.example.com/admin/* → Admin writes the catalogapi.example.com/* → Home binds nothingThe full code is in the federated API example.
Define the API once
Section titled “Define the API once”Declare one HttpApi and give each group a path prefix. The prefix
becomes the route each Worker claims:
import * as HttpApi from "effect/http-api/HttpApi";import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint";import * as HttpApiGroup from "effect/http-api/HttpApiGroup";import * as Schema from "effect/Schema";
export class ProductsGroup extends HttpApiGroup.make("products") .add(HttpApiEndpoint.get("list", "/", { success: Schema.Array(Product) })) .add(HttpApiEndpoint.get("get", "/:id", { params: Id, success: Product })) .prefix("/products") {}
export class OrdersGroup extends HttpApiGroup.make("orders") .add(HttpApiEndpoint.post("create", "/", { payload: NewOrder, success: Order })) .prefix("/orders") {}
// What clients see at the one URL.export class StoreApi extends HttpApi.make("StoreApi") .add(ProductsGroup) .add(OrdersGroup) {}
// What each Worker serves.export class ProductsApi extends HttpApi.make("ProductsApi").add(ProductsGroup) {}export class OrdersApi extends HttpApi.make("OrdersApi").add(OrdersGroup) {}Serve one group per Worker
Section titled “Serve one group per Worker”Claim the group’s prefix with routes, and bind only what its
handlers use. Products only reads the catalog:
import * as Cloudflare from "alchemy/Cloudflare";import * as Http from "alchemy/Http";import * as Effect from "effect/Effect";import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";import * as HttpRouter from "effect/http/HttpRouter";import * as Layer from "effect/Layer";import { ProductsApi } from "./Api.ts";import { Catalog } from "./Store.ts";
export default class Products extends Cloudflare.Worker<Products>()( "Products", { main: import.meta.url, routes: [{ pattern: "api.example.com/products*" }], }, Effect.gen(function* () { const catalog = yield* Cloudflare.KV.ReadNamespace(Catalog);
const handlers = HttpApiBuilder.group(ProductsApi, "products", (h) => h .handle("list", () => listProducts(catalog)) .handle("get", ({ params }) => getProduct(catalog, params.id)), );
return { fetch: yield* HttpRouter.toHttpEffect( HttpApiBuilder.layer(ProductsApi).pipe( Layer.provide(handlers), Layer.provide(Http.Platform), ), ), }; }).pipe(Effect.provide(Cloudflare.KV.ReadNamespaceBinding)),) {}Orders follows the same shape with routes: [{ pattern: "api.example.com/orders*" }]
and binds ReadNamespace(Catalog) plus ReadWriteNamespace(OrderBook).
A bug in Products can never write to the catalog or touch orders.
Own the hostname
Section titled “Own the hostname”Give one Worker the hostname as its domain. It gets the DNS record
and certificate, and answers every path no route claims:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/http/HttpServerResponse";
export default class Home extends Cloudflare.Worker<Home>()( "Home", { main: import.meta.url, domain: "api.example.com", }, Effect.succeed({ fetch: HttpServerResponse.json({ endpoints: ["/products", "/orders"] }), }),) {}Deploy the Workers together
Section titled “Deploy the Workers together”Add every Worker to the Stack and return the domain Worker’s URL:
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import Home from "./src/Home.ts";import Orders from "./src/Orders.ts";import Products from "./src/Products.ts";
export default Alchemy.Stack( "StoreApi", { providers: Cloudflare.providers(), state: Cloudflare.state() }, Effect.gen(function* () { const home = yield* Home; yield* Products; yield* Orders; return { url: home.url }; }),);url is https://api.example.com. Every request to it goes straight
to the Worker that owns its path.
How requests are matched
Section titled “How requests are matched”Routes take precedence over the custom domain. When several patterns match, the most specific one wins:
| Request | Worker | Why |
|---|---|---|
/products |
Products |
/products* matches |
/orders/7 |
Orders |
/orders* matches |
/about |
Home |
no route matches |
A trailing * matches any suffix, so /orders/* does not match
/orders itself — use /orders* to claim both.
Opt a path out of a route with a route that has no script. Matching
requests fall through to the domain Worker:
yield* Cloudflare.Workers.WorkerRoute("ProductsHealth", { zoneId: zone.zoneId, pattern: "api.example.com/products/health",});Call it from a client
Section titled “Call it from a client”Use StoreApi against the one URL. The client doesn’t know the API is
split across Workers:
const client = yield* HttpApiClient.make(StoreApi, { baseUrl: url });
const product = yield* client.products.get({ params: { id: "widget" } });const order = yield* client.orders.create({ payload: { productId: product.id, quantity: 3 },});Run it locally
Section titled “Run it locally”alchemy dev applies the same routes locally:
alchemy devThe url output is now a local URL, and requests to it reach the
local Products, Orders, and Home Workers by the same rules as
api.example.com:
curl $URL/products # runs the local Products Workercurl $URL/about # runs the local Home WorkerEach routed Worker sees the original request URL, just as it does when deployed.
Without a custom domain
Section titled “Without a custom domain”Use only routes when no Worker should own the whole hostname. Add a
proxied placeholder record so the hostname resolves through Cloudflare:
yield* Cloudflare.DNS.Record("ApiPlaceholder", { zoneId: zone.zoneId, name: "api.example.com", type: "AAAA", content: "100::", proxied: true,});Read the hostname’s URL from any route. It’s https://api.example.com
when deployed and a local URL under alchemy dev:
const products = yield* Products;return { url: products.routes[0].url };Paths no route claims return a 404 locally.
Where next
Section titled “Where next”- Custom domains & routes — domains, routes, and DNS records in detail.
- Workers — bindings and the Worker runtime.
Reference: