Skip to content

Configuration Splitting

As a gateway grows, keeping the entire configuration in a single file becomes unwieldy. mergeConfigs() accepts any number of partial GatewayConfig objects and merges them left-to-right into a single complete config. This enables patterns like per-domain route files, shared policy sets, and environment-specific overrides.

import { mergeConfigs } from "@homegrower-club/stoma";
function mergeConfigs<TBindings = Record<string, unknown>>(
...configs: Array<Partial<GatewayConfig<TBindings>>>
): GatewayConfig<TBindings>

Accepts one or more partial configs and returns a fully merged GatewayConfig. Throws a GatewayError if the merged result contains zero routes.

Each field type follows a specific merge strategy. When multiple configs define the same field, the strategy determines how values combine:

FieldStrategy
routesConcatenated in order (all routes from all configs)
policiesDeduplicated by name — later config wins on conflict
name, basePath, debug, requestIdHeader, defaultErrorMessage, defaultPolicyPriority, defaultMethods, onError, adapterLast-defined wins (undefined values are skipped)
adminShallow-merged when both are objects; last value wins when types differ (e.g. boolean replaces object)
debugHeadersSame strategy as admin

Organize route definitions by team or domain, with a shared base config for gateway-wide settings:

// Auth routes module — owned by the auth team.
// Defines login and token refresh endpoints.

import { jwtAuth } from "@homegrower-club/stoma";
import type { GatewayConfig } from "@homegrower-club/stoma";

export const authRoutes: Partial<GatewayConfig> = {
  routes: [
    {
      path: "/auth/login",
      methods: ["POST"],
      pipeline: {
        upstream: { type: "url", target: "https://auth.internal" },
      },
    },
    {
      path: "/auth/refresh",
      methods: ["POST"],
      pipeline: {
        policies: [jwtAuth({ secret: "my-jwt-secret" })],
        upstream: { type: "url", target: "https://auth.internal" },
      },
    },
  ],
};
// API routes module — owned by the platform team.
// Defines user-facing endpoints with JWT auth and caching.

import { jwtAuth, cache, InMemoryCacheStore } from "@homegrower-club/stoma";
import type { GatewayConfig } from "@homegrower-club/stoma";

const cacheStore = new InMemoryCacheStore({ maxEntries: 100 });

export const apiRoutes: Partial<GatewayConfig> = {
  routes: [
    {
      path: "/users/*",
      pipeline: {
        policies: [jwtAuth({ secret: "my-jwt-secret" })],
        upstream: { type: "url", target: "https://users.internal" },
      },
    },
    {
      path: "/users/:id/profile",
      methods: ["GET"],
      pipeline: {
        policies: [
          jwtAuth({ secret: "my-jwt-secret" }),
          cache({ ttlSeconds: 60, store: cacheStore }),
        ],
        upstream: { type: "url", target: "https://users.internal" },
      },
    },
  ],
};
// Configuration splitting: organize a large gateway config across
// multiple files using mergeConfigs(). Each team owns their routes
// in a separate module; the entrypoint composes them together.

import { createGateway, health, cors, requestLog, rateLimit } from "@homegrower-club/stoma";
import { mergeConfigs } from "@homegrower-club/stoma/config";
import type { GatewayConfig } from "@homegrower-club/stoma";
import { authRoutes } from "./routes/auth";
import { apiRoutes } from "./routes/api";

// Shared base config — gateway-wide settings
const baseConfig: Partial<GatewayConfig> = {
  name: "platform-api",
  basePath: "/api",
  policies: [
    cors({ origins: ["https://app.example.com"] }),
    requestLog(),
    rateLimit({ max: 500, windowSeconds: 60 }),
  ],
};

// Compose all configs together
const gateway = createGateway(
  mergeConfigs(
    baseConfig,
    { routes: [health({ path: "/health" })] },
    authRoutes,
    apiRoutes,
  ),
);

export default gateway;

Routes are concatenated in the order configs are passed. The global cors, requestLog, and rateLimit policies from the base config apply to every route.

Layer environment-specific settings on top of a base configuration:

gateway/base.ts
import { cors, requestLog } from "@homegrower-club/stoma";
import type { GatewayConfig } from "@homegrower-club/stoma";
export const baseConfig: Partial<GatewayConfig> = {
name: "my-api",
debug: true,
admin: true,
policies: [cors(), requestLog()],
routes: [
{
path: "/users/*",
pipeline: {
upstream: { type: "url", target: "https://users.internal" },
},
},
],
};
gateway/production.ts
import type { GatewayConfig } from "@homegrower-club/stoma";
import { cloudflareAdapter } from "@homegrower-club/stoma/adapters";
export const productionOverrides: Partial<GatewayConfig> = {
debug: false,
admin: {
enabled: true,
auth: (c) => c.req.header("x-admin-key") === env.ADMIN_KEY,
},
adapter: cloudflareAdapter({
rateLimitDo: env.RATE_LIMITER,
cache: caches.default,
}),
};
gateway/index.ts
import { createGateway, mergeConfigs } from "@homegrower-club/stoma";
import { baseConfig } from "./base";
import { productionOverrides } from "./production";
const config = mergeConfigs(baseConfig, productionOverrides);
const gateway = createGateway(config);
export default gateway.app;

The production overrides disable debug logging and add admin authentication without repeating the route definitions or policy list.

Combine mergeConfigs() with scope() for multi-team gateways where each team owns a versioned route namespace:

import {
createGateway,
mergeConfigs,
scope,
jwtAuth,
apiKeyAuth,
health,
cors,
requestLog,
} from "@homegrower-club/stoma";
// Team A owns user routes
const userRoutes = scope({
prefix: "/api/v1/users",
policies: [jwtAuth({ secret: env.JWT_SECRET })],
metadata: { team: "users" },
routes: [
{
path: "/",
pipeline: {
upstream: { type: "service-binding", service: "USERS_SERVICE" },
},
},
{
path: "/:id",
pipeline: {
upstream: { type: "service-binding", service: "USERS_SERVICE" },
},
},
],
});
// Team B owns webhook routes
const webhookRoutes = scope({
prefix: "/webhooks",
policies: [apiKeyAuth({ headerName: "x-webhook-secret", validate: verifyKey })],
metadata: { team: "integrations" },
routes: [
{
path: "/stripe",
methods: ["POST"],
pipeline: {
upstream: { type: "url", target: "https://webhooks.internal" },
},
},
],
});
const gateway = createGateway(
mergeConfigs(
{
name: "platform-api",
policies: [cors(), requestLog()],
routes: [health({ path: "/health" })],
},
{ routes: [...userRoutes, ...webhookRoutes] },
)
);
export default gateway.app;

The TBindings generic flows through mergeConfigs(), so all partial configs share the same Worker bindings type:

interface Env {
USERS_SERVICE: Fetcher;
RATE_LIMITER: DurableObjectNamespace;
}
const config = mergeConfigs<Env>(
{ name: "typed-api" },
{
routes: [
{
path: "/users/*",
pipeline: {
upstream: { type: "service-binding", service: "USERS_SERVICE" },
},
},
],
},
);

TypeScript will verify that service binding names match the Env interface across all merged configs.

mergeConfigs() throws a GatewayError if the merged result has zero routes. This catches cases where all partial configs omit the routes field or only provide empty arrays.

For additional runtime validation of the merged config (schema structure, upstream URLs, policy shapes), pass the result through Zod validation before calling createGateway(). See Configuration: Validation for details.

import { mergeConfigs } from "@homegrower-club/stoma";
import { validateConfig } from "@homegrower-club/stoma/config";
const merged = mergeConfigs(baseConfig, routeConfig, overrides);
const validated = validateConfig(merged);
const gateway = createGateway(validated);