Skip to content

Repository files navigation

workers-forge

npm version CI License: MIT Node ≥ 20

English | 中文

Convention-driven build, dev, and deploy tooling for Cloudflare Workers monorepos.

Declare your workers and bindings once in TypeScript — the kit generates wrangler.jsonc per module, gives you fully-typed this.env.* access without any manual configuration, and orchestrates wrangler dev / wrangler deploy across all workers at once.


Table of Contents


Overview

In a Cloudflare Workers monorepo, every worker normally demands its own handwritten wrangler.jsonc and a matching TypeScript env type that must be kept in sync with it — forever. Add a KV namespace, update two files. Rename a service binding, hunt down every reference. workers-forge collapses that duplication: you declare a worker once in TypeScript and the kit generates the config files and infers all the types for you.

What you get:

  • Zero-duplication configdefineWorker(meta, methods) is the single source of truth. workers-forge build generates a ready-to-use wrangler.jsonc for each module; you never write or edit those files manually.
  • Fully-typed this.env for free — binding declarations are inferred into precise this.env types at compile time. Add a D1 binding and this.env.DB is immediately a D1Database — no separate type file, no cast.
  • Typed cross-worker RPCservice<MyWorkerRPC>('worker-name') attaches the target worker's method signatures to the binding, giving you full IntelliSense and type checking on every inter-worker call.
  • Automatic sibling rewrites — service bindings that point to other workers in the same project are automatically rewritten to their full deployed name (prefix + name + suffix). You write short names in source; the kit handles the rest.
  • One-command local devworkers-forge dev starts every worker in parallel with its own port; output is labelled [name:port]. Use --app api to bring up only a worker and its local dependencies.
  • Dependency-aware deploymentworkers-forge deploy builds a DAG from service bindings and deploys in the correct order. A failing worker skips only its transitive dependents; unrelated workers continue.
  • Multi-environment without duplication — declare envs once in the config file. Per-env infrastructure IDs (CF_CONFIG_*) and runtime variable overrides are injected at build time; the same source tree deploys to staging and production.

The mental model is straightforward:

defineWorker(meta, methods)
       │
  workers-forge build
       │
  .build/<name>/wrangler.jsonc   ← handed to wrangler
  InferEnv<typeof meta>          ← used by TypeScript

Installation

Prerequisites: Node.js ≥ 20

npm install --save-dev workers-forge

wrangler and tsx are required peer dependencies. npm v7+ installs them automatically. If you use pnpm, install peers explicitly:

pnpm add -D workers-forge wrangler tsx

When using the Hono adapter, also install:

npm install --save-dev hono

pnpm: pnpm add -D hono

Dependency Required Version
wrangler ^4
tsx ^4
hono Optional ^4

Quick Start

1. Create a config file at the project root:

// workers-forge.config.ts
import { defineConfig } from 'workers-forge/build';

export default defineConfig({
  prefix: 'my-app-',
  modules: ['src/modules/*/index.ts'],
});

2. Write a worker module (src/modules/api/index.ts):

import { defineWorker } from 'workers-forge';

const meta = {
  name: 'api',
  bindings: {
    vars: { GREETING: 'Hello' },
    kv_namespaces: [{ binding: 'CACHE', id: 'your-kv-id' }],
  },
} as const;

export default defineWorker(meta, {
  async fetch(request) {
    const cached = await this.env.CACHE.get('key'); // typed KVNamespace
    return new Response(this.env.GREETING);          // typed string
  },
});

3. Add CLI scripts to package.json:

{
  "scripts": {
    "build":  "workers-forge build",
    "dev":    "workers-forge dev",
    "deploy": "workers-forge deploy --build"
  },
}

4. Add a tsconfig.json at the project root:

{
  "extends": "workers-forge/tsconfig",
  "include": ["src/**/*", "workers-forge.config.ts"]
}

5. Run:

npm run build    # generates .build/<name>/wrangler.jsonc for each module
npm run dev      # starts all workers with wrangler dev
npm run deploy   # build + deploy to Cloudflare

pnpm: pnpm build / pnpm dev / pnpm deploy


Defining Workers

Workers are declared with defineWorker(meta, methods):

import { defineWorker } from 'workers-forge';

export default defineWorker(
  {
    name: 'my-worker',        // short name; prefix is added at build time
    bindings: {},          // see Bindings reference below
    triggers: {},          // see Worker triggers below
  },
  {
    // Worker methods — all handlers and RPC methods go here.
    // `this` is typed as WorkerEntrypoint with fully-typed this.env.
    async fetch(request: Request) {
      return new Response('ok');
    },
    async myRpcMethod(arg: string): Promise<string> {
      return `hello ${arg}`;
    },
  },
);

Worker names must match [a-z0-9-]+ and the final deployed name (prefix + name + optional suffix) must not exceed 63 characters.

Bindings reference

All fields are optional. Each field corresponds directly to a top-level section in the generated wrangler.jsonc.

Field TypeScript type Runtime type wrangler.jsonc key
vars Record<string, string> string vars
kv_namespaces { binding, id, preview_id? }[] KVNamespace kv_namespaces
d1_databases { binding, database_id, database_name? }[] D1Database d1_databases
r2_buckets { binding, bucket_name }[] R2Bucket r2_buckets
services Record<string, ServiceBindingDecl> ServiceStub<RPC> services
queues.producers { binding, queue }[] Queue queues.producers
ai { binding } Ai ai
secrets_store_secrets { binding, store_id, secret_name }[] SecretsStoreSecret secrets_store_secrets
vectorize { binding, index_name }[] VectorizeIndex vectorize
browser { binding } Fetcher browser
analytics_engine_datasets { binding, dataset? }[] AnalyticsEngineDataset analytics_engine_datasets
hyperdrive { binding, id }[] Hyperdrive hyperdrive
send_email SendEmailDecl[] (send method) send_email

Example — multiple bindings:

const meta = {
  name: 'api',
  bindings: {
    vars: { API_URL: 'https://api.example.com' },
    kv_namespaces:  [{ binding: 'CACHE',  id: 'abc123' }],
    d1_databases:   [{ binding: 'DB',     database_id: 'def456' }],
    r2_buckets:     [{ binding: 'ASSETS', bucket_name: 'my-bucket' }],
    ai:             { binding: 'AI' },
    vectorize:      [{ binding: 'VECTORS', index_name: 'my-index' }],
  },
} as const;

Worker triggers

Triggers define how the worker is invoked, not what it binds to.

const meta = {
  name: 'processor',
  triggers: {
    // Cron — runs on a schedule
    cron: '0 * * * *',
    // or multiple: cron: ['0 * * * *', '30 * * * *'],

    // Queue consumer — triggered by incoming queue messages
    queue: {
      consumers: [{
        queue: 'my-queue',
        max_batch_size: 10,
        max_batch_timeout: 5,
        max_retries: 3,
        dead_letter_queue: 'my-queue-dlq',
        retry_delay: 60,
      }],
    },

    // Tail consumer — receives tail events from another worker
    tail: {
      producers: [{ service: 'api' }],
    },
  },
} as const;

Queue producer vs consumer: Use bindings.queues.producers to send messages; use triggers.queue.consumers to receive them.

Typed environment (InferEnv)

this.env is automatically typed based on your bindings declaration. You can also export the env type for use elsewhere:

import type { InferEnv } from 'workers-forge';

const meta = { name: 'api', bindings: { vars: { TOKEN: '' } } } as const;

type Env = InferEnv<typeof meta>; // { TOKEN: string }

Raw wrangler config override (_raw)

_raw lets you inject arbitrary wrangler config fields at the highest priority for a specific worker. Its contents are written verbatim into the generated wrangler.jsonc — no prefix, suffix, or sibling service-name rewriting is applied.

Override priority (lowest → highest):

  1. baseConfig in workers-forge.config — global defaults for all workers
  2. defineWorker bindings + triggers — per-worker, subject to name rewrites
  3. _raw — per-worker, no name rewrites, overwrites anything above it

The _raw type is Omit<Unstable_RawEnvironment, 'name' | 'main'> — the same as BaseConfig. The name and main fields are always managed by the kit and cannot be overridden.

export default defineWorker(
  {
    name: 'api',
    bindings: {
      vars: { TIMEOUT: '5000' },
    },
    _raw: {
      // Raise the CPU limit for this worker only
      limits: { cpu_ms: 500 },
      // Override vars entirely (bypasses the bindings.vars merge logic)
      vars: { TIMEOUT: '30000', EXTRA_FLAG: 'true' },
    },
  },
  { fetch: () => new Response('ok') },
);

No name rewriting: Service names, binding IDs, and other fields inside _raw pass through exactly as written. If you reference a sibling worker in _raw.services, you must use its full deployed name (with prefix and suffix) yourself.


Hono Adapter

For Hono-based workers, use defineHonoWorker from the ./hono subpath:

// src/modules/web/index.ts
import { Hono } from 'hono';
import { defineHonoWorker, type InferHonoEnv } from 'workers-forge/hono';

const meta = {
  name: 'web',
  bindings: {
    vars: { GREETING: 'Hello' },
    kv_namespaces: [{ binding: 'CACHE', id: 'abc123' }],
  },
} as const;

// Pass meta as the Hono generic so c.env is fully typed
const app = new Hono<InferHonoEnv<typeof meta>>();

app.get('/hello', async (c) => {
  const cached = await c.env.CACHE.get('key'); // KVNamespace
  return c.text(c.env.GREETING);               // string
});

export default defineHonoWorker(meta, app);

Service Bindings & RPC

Workers communicate via Cloudflare service bindings. The kit gives service stubs a typed RPC interface so callers get autocomplete and type checking.

1. Export the RPC type from the target worker:

// src/modules/db-service/index.ts
import { defineWorker, type WorkerRPC } from 'workers-forge';

const worker = defineWorker(
  { name: 'db-service', bindings: {} },
  {
    async getUser(id: string): Promise<{ id: string; name: string } | null> {
      return null; // real implementation here
    },
  },
);

export type DbServiceRPC = WorkerRPC<typeof worker>;
// ^ { getUser(id: string): Promise<{ id: string; name: string } | null> }

export default worker;

2. Bind the target worker using service<RPC>():

// src/modules/api/index.ts
import { defineWorker, service } from 'workers-forge';
import type { DbServiceRPC } from '../db-service';

export default defineWorker(
  {
    name: 'api',
    bindings: {
      // The Record key ('DB_SERVICE') becomes the binding name in wrangler.jsonc
      // and in this.env. Pass the RPC type as a generic for IntelliSense.
      services: { DB_SERVICE: service<DbServiceRPC>('db-service') },
    },
  },
  {
    async fetch(request: Request) {
      // this.env.DB_SERVICE is typed as ServiceStub<DbServiceRPC>
      const user = await this.env.DB_SERVICE.getUser('user-123');
      return Response.json(user);
    },
  },
);

Sibling rewrite: When db-service is a sibling module in the same build, the kit automatically rewrites the service field in wrangler.jsonc to the full deployed name (${prefix}db-service${suffix}). You don't need to track the prefix in your source code.

Binding to a named environment of another worker:

services: { MY_WORKER: service<MyWorkerRPC>('my-worker', 'production') }
// wrangler.jsonc: { "binding": "MY_WORKER", "service": "my-worker", "environment": "production" }

Promise pipelining

When an RPC method returns an instance of a class that extends RpcTarget, Cloudflare Workers RPC supports promise pipelining: the caller can chain further method calls on the returned stub immediately, without an intermediate await. The two calls are delivered in a single network round-trip.

See the Cloudflare Workers RPC documentation for the full spec.

Target worker — expose a method that returns an RpcTarget subclass:

// src/modules/user-service/index.ts
import { defineWorker, RpcTarget, type WorkerRPC } from 'workers-forge';

class UserQuery extends RpcTarget {
  constructor(private db: D1Database, private userId: string) { super(); }

  async profile(): Promise<{ id: string; name: string; email: string }> {
    return this.db.prepare('SELECT * FROM users WHERE id = ?').bind(this.userId).first();
  }

  async posts(): Promise<{ id: string; title: string }[]> {
    return this.db.prepare('SELECT id, title FROM posts WHERE user_id = ?').bind(this.userId).all().then(r => r.results);
  }
}

const worker = defineWorker(
  { name: 'user-service', bindings: { d1_databases: [{ binding: 'DB', database_id: '...' }] } },
  {
    // Returns RpcTarget subclass — enables pipelining on the caller side
    user(id: string): UserQuery {
      return new UserQuery(this.env.DB, id);
    },
  },
);

export type UserServiceRPC = WorkerRPC<typeof worker>;
export default worker;

Caller — chain calls without an intermediate await:

// Two separate round-trips (without pipelining):
const query = await this.env.USER_SERVICE.user(userId);
const profile = await query.profile();

// One round-trip (with pipelining — single await):
const profile = await this.env.USER_SERVICE.user(userId).profile();

ServiceStub<RPC> automatically maps any method whose return type extends Rpc.Stubable (which RpcTarget subclasses do) to Rpc.Result<T>, so TypeScript understands the chaining and preserves full return-type inference on the final awaited call.

FAQ: Why is my chained RPC call typed as never?

If you chain a call through a service binding — e.g. this.env.DB_SERVICE.someTable().getByIds(ids) — and TypeScript reports the result as never, the cause is almost always a field typed as unknown (commonly Record<string, unknown>) somewhere in the returned shape.

Why it happens. Cloudflare Workers RPC transports values across worker boundaries using structured clone. @cloudflare/workers-types enforces this at compile time via Rpc.Result<T> and Rpc.Serializable<T>, which recursively check every field. Because unknown is the top type — it could contain functions, Symbols, or unresolved Promises that the runtime would reject — Serializable<T> cannot prove safety for unknown fields and falls through to never. That never then propagates up through the whole chained call.

The runtime is not broken: structured-clone still serializes any actually-JSON-safe value. The type system is just being conservative — and correctly so, because the same code could later be called with a non-cloneable value and crash at runtime.

Fix. Narrow unknown fields to a JSON-safe type. Declare a small RpcJson helper in your own project — split the recursive array and object arms into named interfaces rather than inlining them in a single union alias. TypeScript memoizes recursive interface instantiations but not recursive type alias instantiations, so an inline-only RpcJson combined with Rpc.Serializable<T>'s own deep recursion will trip TS2589: Type instantiation is excessively deep at some call sites:

// local types.ts in your project
export type RpcJson = string | number | boolean | null | RpcJsonArray | RpcJsonObject;
export interface RpcJsonArray extends ReadonlyArray<RpcJson> {}
export interface RpcJsonObject { readonly [key: string]: RpcJson }

// Drizzle schema — declare a JSON-mode column as JSON, not `unknown`:
payload: text('payload', { mode: 'json' }).$type<Record<string, RpcJson>>().notNull(),

This is a precise statement of what the column actually holds (JSON), not a workaround. The chained RPC type then resolves correctly without disabling Cloudflare's structured-clone guarantee. workers-forge deliberately does not export RpcJson itself — the declaration is tiny, must use interfaces to avoid TS2589, and is most useful when it lives next to your own JSON-shaped data.

Do not patch or widen Rpc.Serializable<T> in @cloudflare/workers-types. The check exists to catch values that would throw DataCloneError at runtime — bypassing it would hide real bugs.


Durable Objects

defineDurableObject(meta, methods) is the DO equivalent of defineWorker — one module file becomes one Worker script that hosts the DO class. The build emits the wrangler.jsonc (with auto-generated migrations) and an entry barrel that re-exports the class under its derived PascalCase name so workerd can resolve it. Consumers reference the DO with durableObject<RPC>('name'), mirroring service<RPC>('name') exactly: type-only import on the consumer side, zero runtime coupling.

1. Define the DO (single file):

// src/modules/counter/index.ts
import { defineDurableObject, type DurableObjectRPC } from 'workers-forge';

const counter = defineDurableObject(
  {
    name: 'counter',          // worker script name; class_name is derived (kebab/snake → PascalCase)
    // storage: 'sqlite',     // default; use 'kv' for the legacy KV-backed backend
    // bindings: { ... },     // same WorkerBindings shape — typed this.env inside DO methods
  },
  {
    async increment(by = 1) {
      const v = (await this.ctx.storage.get<number>('n')) ?? 0;
      await this.ctx.storage.put('n', v + by);
      return v + by;
    },
    async value() {
      return (await this.ctx.storage.get<number>('n')) ?? 0;
    },
  },
);

export type CounterRPC = DurableObjectRPC<typeof counter>;
export default counter;

2. Consume it from another worker:

// src/modules/gateway/index.ts
import { defineWorker, durableObject } from 'workers-forge';
import type { CounterRPC } from '../counter';

export default defineWorker(
  {
    name: 'gateway',
    bindings: {
      durable_objects: {
        // Record key becomes the binding name on this.env.
        COUNTER: durableObject<CounterRPC>('counter'),
      },
    },
  },
  {
    async fetch() {
      const stub = this.env.COUNTER.get(this.env.COUNTER.idFromName('global'));
      const n = await stub.increment();   // fully typed RPC call
      return Response.json({ n });
    },
  },
);

3. Generated config (handled by the kit):

// .build/counter/wrangler.jsonc — auto-generated
{
  "name": "pfx-counter",
  "main": "entry.ts",
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }]
}
// .build/gateway/wrangler.jsonc — auto-generated
{
  "name": "pfx-gateway",
  "durable_objects": {
    "bindings": [
      { "name": "COUNTER", "class_name": "Counter", "script_name": "pfx-counter" }
    ]
  }
}

Sibling rewrite: script_name is rewritten with prefix/suffix exactly like services are — same code path. Non-sibling names (e.g. an external DO worker) pass through unchanged.

Constructor hook (onWake) — the framework auto-generates the DO class for you, so there's no place to write a literal constructor. Instead, declare a reserved method named onWake in the methods object:

defineDurableObject(
  { name: 'counter' },
  {
    onWake() {
      // Runs on every wake — cold start AND wake from WebSocket hibernation.
      // `this` is the new instance; `this.ctx`, `this.env`, and your other
      // methods are all available, exactly like inside a real constructor.
      this.ctx.blockConcurrencyWhile(async () => {
        (this as any)._n = (await this.ctx.storage.get<number>('n')) ?? 0;
      });
    },
    async increment(by = 1) {
      const next = ((this as any)._n as number) + by;
      (this as any)._n = next;
      await this.ctx.storage.put('n', next);
      return next;
    },
  },
);
  • onWake is filtered from the RPC surface (DurableObjectRPC<typeof D> strips it alongside fetch/alarm/WebSocket handlers) and is not mounted on the prototype — consumers can't call it.
  • It runs on every wake, not just once per DO ID. Keep it light. For async first-time initialization, wrap the work in this.ctx.blockConcurrencyWhile(...) so concurrent requests gate on it.
  • Omit onWake if you don't need it — the constructor becomes a no-op pass-through to the base DurableObject.

See examples/durable-objects for an end-to-end demo (/wakes endpoint reports the per-instance wake count).

Advanced migrations — the default migration only handles the initial new_classes / new_sqlite_classes. For rename or delete migrations, override via _raw.migrations:

defineDurableObject(
  {
    name: 'counter',
    _raw: {
      migrations: [
        { tag: 'v1', new_sqlite_classes: ['Counter'] },
        { tag: 'v2', renamed_classes: [{ from: 'Counter', to: 'CounterV2' }] },
      ],
    },
  },
  { /* methods */ },
);

DurableObjectRPC<typeof counter> extracts the public RPC surface from a defineDurableObject instance — strips built-in handlers (alarm, fetch, connect, webSocketMessage/Close/Error) and the framework-defined onWake hook, leaving your custom methods, mirroring WorkerRPC<typeof worker>.


Config Reference

Create workers-forge.config.ts at the project root (or pass --config <path> to any CLI command):

import { defineConfig } from 'workers-forge/build';

export default defineConfig({
  prefix: 'my-app-',
  modules: ['src/modules/*/index.ts'],
  outDir: '.build',
  baseConfig: {
    compatibility_date: '2026-04-08',
    compatibility_flags: ['nodejs_compat'],
  },
  dev: {
    persistTo: '.wrangler/state',
    ports: { api: 8787, web: 8788 },
    // groups: { 'queue-stack': ['producer', 'consumer'] },  // optional: merge into one wrangler dev
  },
  envs: [
    { name: 'production', envFile: '.env.production', suffix: '' },
    { name: 'staging',    envFile: '.env.staging',    suffix: '-staging' },
  ],
});

KitConfig fields

Field Type Default Description
prefix string (required) Prepended to every worker name: ${prefix}${name}. E.g. "my-app-"my-app-api.
modules string[] ['src/modules/**/index.ts', '!**/_*/**', '!**/__tests__/**'] Glob patterns for worker entry files (passed to globby).
outDir string ".build" Directory where wrangler.jsonc files are generated. Resolved relative to the config file.
baseConfig BaseConfig (see below) Wrangler config fields merged into every generated wrangler.jsonc.
dev.persistTo string (none) Forwarded to wrangler dev --persist-to. Override per-run with --persist-to.
dev.ports Record<string, number> (auto) Fixed primary-port assignments. Keys are either a module short name (for ungrouped workers) or a dev.groups group name (for merged sessions). Unassigned units get a free port.
dev.groups Record<string, string[]> (none) Co-host workers in a single wrangler dev process. Each entry maps a group name to an ordered list of worker short names; the first listed worker is the primary -c. Useful for queue producer/consumer pairs that must share one dev session. See Merged dev sessions.
envs EnvConfig[] [] Named environments for staging/production deploys.

Shared wrangler config (baseConfig)

baseConfig accepts any field from wrangler.jsonc (typed as Omit<Unstable_RawEnvironment, 'name' | 'main'>). It is merged into every generated config as the lowest-priority layer. Module-specific bindings/triggers win over baseConfig on conflict, and per-worker _raw fields win over everything.

The built-in defaults are:

{
  compatibility_date: '2026-04-08',
  compatibility_flags: ['nodejs_compat'],
  observability: { logs: { enabled: true, invocation_logs: true } },
}

Override any of these, or add extra fields, via baseConfig in your config file:

baseConfig: {
  compatibility_date: '2026-01-01',
  limits: { cpu_ms: 50 },
  upload_source_maps: true,
}

See the wrangler configuration reference for the full list of supported fields.


Multi-Environment

Use envs to maintain isolated staging and production deployments from the same codebase.

Runtime variable overrides (vars)

Declare runtime variables in bindings.vars with a default (or empty) value, then override them per environment in an envFile. Any key in the envFile that is not prefixed with CF_CONFIG_ and already exists in bindings.vars is overwritten in the generated wrangler.jsonc. Extra keys that are not declared in bindings.vars are silently ignored.

These values are available at runtime via this.env.<KEY> (typed as string).

.env.dev:

TEST=test

Worker module:

import { defineWorker, service } from 'workers-forge';

export default defineWorker(
  {
    name: 'crawler-fetcher',
    bindings: {
      // Declare vars with a default (or empty) value.
      // The actual value is injected at build time from the envFile.
      vars: { TEST: '' },
    },
  },
  {
    async fetch() {
      return new Response(this.env.TEST); // "test" when built with --env dev
    },
  },
);

Config file:

export default defineConfig({
  prefix: 'my-app-',
  envs: [
    { name: 'dev', envFile: '.env.dev', suffix: '-dev' },
  ],
});

Build with the env active:

workers-forge build --env dev   # also: dev --env dev / deploy --build --env dev

The generated wrangler.jsonc will contain "vars": { "TEST": "test" }.

Strict overlay: Only keys already present in bindings.vars are overridden. Extra keys in the envFile that have no matching declaration are ignored, so the envFile can freely contain secrets or CI variables that are unrelated to this worker.

Infrastructure IDs (CF_CONFIG_*)

Infrastructure binding IDs (D1 database_id, KV id, etc.) differ per environment. Store them in a dotenv-style file and prefix them with CF_CONFIG_ — the kit injects these into process.env before your worker modules are imported, making them available inside defineWorker.

.env.production:

CF_CONFIG_DB_ID=prod-db-uuid-here
CF_CONFIG_KV_ID=prod-kv-uuid-here

.env.staging:

CF_CONFIG_DB_ID=staging-db-uuid-here
CF_CONFIG_KV_ID=staging-kv-uuid-here

Worker module:

import { defineWorker } from 'workers-forge';

export default defineWorker(
  {
    name: 'api',
    bindings: {
      d1_databases: [{ binding: 'DB',    database_id: process.env.CF_CONFIG_DB_ID! }],
      kv_namespaces: [{ binding: 'CACHE', id:          process.env.CF_CONFIG_KV_ID! }],
    },
  },
  { fetch: () => new Response('ok') },
);

Config file:

export default defineConfig({
  prefix: 'my-app-',
  envs: [
    { name: 'production', envFile: '.env.production', suffix: '' },
    { name: 'staging',    envFile: '.env.staging',    suffix: '-staging' },
  ],
});

Deploy to staging:

workers-forge deploy --build --env staging
# Workers deployed as: my-app-api-staging, my-app-web-staging, …

Deploy to production:

workers-forge deploy --build --env production
# Workers deployed as: my-app-api, my-app-web, …

Build-time env context (envs)

The envs singleton is set by the build pipeline before your modules are imported. Use it to construct environment-specific resource names at build time:

import { defineWorker, envs } from 'workers-forge';

export default defineWorker(
  {
    name: 'db-service',
    bindings: {
      d1_databases: [{
        binding: 'DB',
        database_id: process.env.CF_CONFIG_DB_ID!,
        database_name: 'mydb' + envs.suffix,   // e.g. "mydb-staging" or "mydb"
      }],
    },
  },
  { fetch: () => new Response('ok') },
);
Field Value
envs.suffix The active env's suffix (e.g. "-staging"). Empty string when no --env is active.
envs.prefix The global prefix from workers-forge.config.ts (e.g. "my-app-").

Both fields default to '' so code compiles without null-checks during a plain build with no --env.


CLI Reference

workers-forge <build|dev|deploy> [options] [-- <wrangler args>]

Arguments after -- are forwarded verbatim to every underlying wrangler invocation.

build

Discovers module files, imports each one, and writes a wrangler.jsonc to outDir/<name>/.

workers-forge build [options]
Flag Default Description
--config <path> workers-forge.config.ts Path to the config file.
--env <name> (none) Activate a named env (must match an envs[].name entry). Vars from the env file are overlaid on declared vars; worker names get the env suffix.
--app <name> (all) Build only this module. Repeatable: --app api --app web. Other workers' existing outputs in outDir are preserved.

dev

Builds (unless --no-build) then starts all workers with wrangler dev in parallel. Each worker gets its own port. Output lines are prefixed with [name:port].

workers-forge dev [options] [-- <wrangler args>]
Flag Default Description
--config <path> workers-forge.config.ts Path to the config file.
--no-build off Skip the build step; use existing output in outDir. Incompatible with --env.
--app <name> (all) Run only this module and all other local workers it transitively depends on via service bindings. Repeatable: --app api --app web. If the named worker belongs to a dev.groups group, the whole group is launched together.
--env <name> (none) Activate a named env (requires a fresh build; incompatible with --no-build).
--persist-to <path> from config Override dev.persistTo for local storage (KV, D1, R2, etc.).
-- <wrangler args> Forwarded to every wrangler dev child. Reserved flags (--port, --config, --name, --persist-to, --inspector-port) are rejected — configure these via the config file.

Merged dev sessions

By default workers-forge dev spawns one wrangler dev process per worker. Some workloads — typically Cloudflare Queue producer/consumer pairs — must share a single wrangler dev session so the binding resolves in-process. Declare a group under dev.groups:

// workers-forge.config.ts
export default defineConfig({
  prefix: 'qmdemo-',
  modules: ['src/modules/*/index.ts'],
  dev: {
    groups: {
      'queue-stack': ['producer', 'consumer'],
    },
    ports: {
      'queue-stack': 8787,   // primary port for the merged child
    },
    persistTo: '.wrangler/state',
  },
});

The kit then launches one merged child for the group:

wrangler dev \
  -c .build/producer/wrangler.jsonc \
  -c .build/consumer/wrangler.jsonc \
  --port 8787 \
  --persist-to .wrangler/state

Rules:

  • Group names must not collide with any worker short name and must match [a-z0-9-]+.
  • The first listed worker is the primary (its config becomes the first -c, and --port applies to it).
  • A worker may belong to at most one group. Workers not listed in any group continue to spawn individually.
  • dev.ports keys may be group names or ungrouped worker short names. Pointing a port at a worker that lives inside a group is rejected — set the port on the group instead.
  • A working example lives under examples/queues-merged-dev/.

deploy

Deploys all workers in the build output using a dependency-aware parallel scheduler. A failed worker skips only its transitive dependents; unrelated workers continue.

workers-forge deploy [options] [-- <wrangler args>]
Flag Default Description
--config <path> workers-forge.config.ts Path to the config file.
--build off Run build before deploying. Mutually exclusive with --path.
--path <dir> outDir (.build) Deploy from a pre-built directory. Mutually exclusive with --build.
--env <name> (none) Activate a named env during build. Requires --build (env values are baked at build time).
--concurrency <n> unbounded Cap concurrent wrangler deploy invocations. The DAG width is the natural limit.
--verbose off Print full wrangler deploy output per worker. Auto-enabled in non-TTY / CI=1.
-- <wrangler args> Forwarded to every wrangler deploy call.

Cloudflare credentials are read by wrangler from CLOUDFLARE_API_TOKEN (and optionally CLOUDFLARE_ACCOUNT_ID) in the environment:

export CLOUDFLARE_API_TOKEN="your_api_token_here"
export CLOUDFLARE_ACCOUNT_ID="your_account_id_here"

Deploy output shows an ASCII dependency tree with status icons ( deployed, failed, skipped), followed by a summary. Failed workers print their full wrangler output so errors are always visible.

gen

Generates a single wrangler.jsonc from one user-authored meta file. Use this when you have a project (Next.js via @opennextjs/cloudflare, Remix, a standalone Worker, etc.) that needs a typed, env-aware wrangler.jsonc but doesn't fit the multi-module build model — typically a sibling package in a monorepo that consumes the same envs and prefix as the Workers package.

workers-forge gen <metaFile> [options]
Flag Default Description
<metaFile> (required) TS/JS file that named-exports meta (a WorkerMeta, typically authored with defineWorkerMeta).
--out <path> ./wrangler.jsonc Output path for the generated config.
--env <name> (none) Activate a named env from envs[] in the config (overlays vars, applies suffix, injects CF_CONFIG_* into process.env before importing the meta).
--config <path> workers-forge.config.ts Path to the config file. Only envs[], baseConfig, prefix, and modules are consulted; dev / outDir etc. are ignored by gen.

When the shared config carries a modules glob, gen also discovers sibling worker names from those modules and rewrites services[].service entries to the full deployed name (${prefix}${name}${suffix}) — same behavior as build. So in a monorepo you can write service<MyRpc>('my-worker') and have it resolve correctly per env.

See examples/monorepo-opennext for a full pnpm monorepo wiring up a Workers package + a Next.js (OpenNext) package against the same shared config.


Build Output

After workers-forge build, the output directory (default .build) contains one subdirectory per module:

.build/
├── api/
│   └── wrangler.jsonc    # generated config for the 'api' worker
├── web/
│   └── wrangler.jsonc
└── db-service/
    └── wrangler.jsonc

Each wrangler.jsonc is a complete, standalone config with:

  • name set to ${prefix}${moduleName}${suffix}
  • main pointing to the source entry file (relative path)
  • All bindings and triggers from defineWorker, plus all fields from baseConfig
  • Service binding names rewritten to sibling workers' full deployed names

Testing

workers-forge/testing wires the kit's per-worker wrangler.jsonc output into @cloudflare/vitest-pool-workers, so tests run inside the real workerd runtime.

Setup

npm install --save-dev @cloudflare/vitest-pool-workers vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { defineVitestProject } from 'workers-forge/testing';

const gateway = await defineVitestProject({ worker: 'gateway' });

export default defineConfig({
  test: {
    projects: [
      {
        ...gateway,
        test: {
          ...(gateway.test as Record<string, unknown> | undefined),
          name: 'gateway',
          include: ['src/modules/gateway/__tests__/**/*.test.ts'],
        },
      },
    ],
  },
});
// package.json
{
  "scripts": {
    "test": "workers-forge build && vitest run",
    "test:watch": "workers-forge build && vitest"
  }
}

defineVitestProject reads <outDir>/<worker>/wrangler.jsonc and feeds it to pool-workers as the main worker. Every sibling worker referenced via a service binding or Durable Object script_name is auto-registered as an auxiliary miniflare worker (with TS bundled by esbuild) so cross-worker calls resolve in-process. When the target is a DO host script, a self-binding is injected so the test can call env.<CLASS>.get(...).

Writing tests

// src/modules/gateway/__tests__/gateway.test.ts
import { SELF } from 'cloudflare:test';
import { describe, expect, it } from 'vitest';
import gateway from '..';
import type { WorkerEnv } from 'workers-forge/testing';

declare global {
  namespace Cloudflare {
    interface Env extends WorkerEnv<typeof gateway> {}
  }
}

it('forwards through COUNTER DO', async () => {
  const res = await SELF.fetch('https://x/increment');
  expect(await res.json()).toEqual({ n: 1 });
});

For a Durable Object module, type the self-binding via DurableObjectTestEnv<typeof counter, 'COUNTER'>:

import { env } from 'cloudflare:test';
import counter from '..';
import type { DurableObjectTestEnv } from 'workers-forge/testing';

declare global {
  namespace Cloudflare {
    interface Env extends DurableObjectTestEnv<typeof counter, 'COUNTER'> {}
  }
}

const stub = env.COUNTER.get(env.COUNTER.idFromName('t1'));
expect(await stub.increment()).toBe(1);

cloudflare:test's ambient module declaration is loaded automatically via workers-forge/testing — no env.d.ts or compilerOptions.types entry required.

Exports

Symbol Purpose
defineVitestProject({ worker, outDir?, test?, ... }) Synthesize a vitest project config for a built worker.
WorkerEnv<W> Augment Cloudflare.Env for a defineWorker target.
DurableObjectTestEnv<D, K> Augment Cloudflare.Env with a DO self-binding named K.

Further reading

Every example under examples/ ships an npm test setup. Patterns covered: cross-worker RPC, Durable Objects (with onWake), Hono routers, queue producer/consumer.


Subpath Exports

Subpath Import from What it provides
workers-forge Worker source files / app meta files defineWorker, defineWorkerMeta, service, envs, WorkerRPC, InferEnv, WorkerBindings, …
workers-forge/hono Worker source files (Hono) defineHonoWorker, InferHonoEnv
workers-forge/build workers-forge.config.ts, Node scripts defineConfig, build, dev, deploy, gen, KitConfig, BaseConfig, …
workers-forge/testing vitest.config.ts, test files defineVitestProject, WorkerEnv, DurableObjectTestEnv

Important: Worker source files must only import from . and ./hono. The ./build and ./testing subpaths import Node built-ins (node:fs, node:module, globby) that are not available in the Cloudflare Workers runtime and would break your bundle.


Examples

Ready-to-run examples are in the examples/ directory.

Example Description
rpc-multi-env KV → data-worker --RPC--> api-worker with local/stage env isolation
rpc-multi-env-hono Same as above but api-worker uses the Hono adapter (defineHonoWorker); workers defined as flat files in src/
durable-objects gateway worker calling into a sibling DO module (Counter) with onWake lifecycle; full vitest-pool-workers test suite
queues-merged-dev Queue producer + consumer co-hosted in one wrangler dev process via dev.groups; queue handler tested via createMessageBatch + direct prototype invocation
monorepo-opennext pnpm monorepo: a Workers package + a Next.js 16 (@opennextjs/cloudflare) package sharing one config. The Next.js side uses workers-forge gen purely as a wrangler.jsonc generator and calls into a sibling Worker via typed RPC.

Each example is a self-contained project with its own package.json and README.md.


Development

# Install dependencies
npm install

# Build (compiles TypeScript → dist/)
npm run build

# Run the test suite
npm test

# Type-check without emitting
npm run typecheck

Tests live under __tests__/{runtime,build,cli,deploy,dev}/ mirroring the source tree. The runtime tests include TypeScript type-level assertions (*.test-d.ts) validated by vitest's expectTypeOf.

About

Convention-driven tooling for Cloudflare Workers monorepos. Declare workers once in TypeScript → auto-generated wrangler.jsonc, fully-typed this.env, typed cross-worker RPC, parallel dev, and dependency-aware deploy.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages