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 signature
Section titled “Function signature”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.
Merge semantics
Section titled “Merge semantics”Each field type follows a specific merge strategy. When multiple configs define the same field, the strategy determines how values combine:
| Field | Strategy |
|---|---|
routes | Concatenated in order (all routes from all configs) |
policies | Deduplicated by name — later config wins on conflict |
name, basePath, debug, requestIdHeader, defaultErrorMessage, defaultPolicyPriority, defaultMethods, onError, adapter | Last-defined wins (undefined values are skipped) |
admin | Shallow-merged when both are objects; last value wins when types differ (e.g. boolean replaces object) |
debugHeaders | Same strategy as admin |
Splitting by domain
Section titled “Splitting by domain”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.
Environment overrides
Section titled “Environment overrides”Layer environment-specific settings on top of a base configuration:
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" }, }, }, ],};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, }),};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.
Composition with scope()
Section titled “Composition with scope()”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 routesconst 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 routesconst 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;Type-safe bindings
Section titled “Type-safe bindings”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.
Validation
Section titled “Validation”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);