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.
- Overview
- Installation
- Quick Start
- Defining Workers
- Hono Adapter
- Service Bindings & RPC
- Durable Objects
- Config Reference
- Multi-Environment
- CLI Reference
- Build Output
- Testing
- Subpath Exports
- Examples
- Development
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 config —
defineWorker(meta, methods)is the single source of truth.workers-forge buildgenerates a ready-to-usewrangler.jsoncfor each module; you never write or edit those files manually. - Fully-typed
this.envfor free — binding declarations are inferred into precisethis.envtypes at compile time. Add a D1 binding andthis.env.DBis immediately aD1Database— no separate type file, no cast. - Typed cross-worker RPC —
service<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 dev —
workers-forge devstarts every worker in parallel with its own port; output is labelled[name:port]. Use--app apito bring up only a worker and its local dependencies. - Dependency-aware deployment —
workers-forge deploybuilds 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
envsonce 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
Prerequisites: Node.js ≥ 20
npm install --save-dev workers-forge
wranglerandtsxare 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 honopnpm:
pnpm add -D hono
| Dependency | Required | Version |
|---|---|---|
wrangler |
✅ | ^4 |
tsx |
✅ | ^4 |
hono |
Optional | ^4 |
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 Cloudflarepnpm:
pnpm build/pnpm dev/pnpm deploy
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+ optionalsuffix) must not exceed 63 characters.
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;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.producersto send messages; usetriggers.queue.consumersto receive them.
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 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):
baseConfiginworkers-forge.config— global defaults for all workersdefineWorkerbindings+triggers— per-worker, subject to name rewrites_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
_rawpass 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.
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);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-serviceis a sibling module in the same build, the kit automatically rewrites theservicefield inwrangler.jsoncto 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" }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.
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 throwDataCloneErrorat runtime — bypassing it would hide real bugs.
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/gateway/wrangler.jsonc — auto-generated
{
"name": "pfx-gateway",
"durable_objects": {
"bindings": [
{ "name": "COUNTER", "class_name": "Counter", "script_name": "pfx-counter" }
]
}
}Sibling rewrite:
script_nameis rewritten withprefix/suffixexactly likeservicesare — 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;
},
},
);onWakeis filtered from the RPC surface (DurableObjectRPC<typeof D>strips it alongsidefetch/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
onWakeif you don't need it — the constructor becomes a no-op pass-through to the baseDurableObject.
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>.
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' },
],
});| 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. |
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.
Use envs to maintain isolated staging and production deployments from the same codebase.
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=testWorker 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 devThe generated wrangler.jsonc will contain "vars": { "TEST": "test" }.
Strict overlay: Only keys already present in
bindings.varsare overridden. Extra keys in theenvFilethat have no matching declaration are ignored, so the envFile can freely contain secrets or CI variables that are unrelated to this worker.
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-hereWorker 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, …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.
workers-forge <build|dev|deploy> [options] [-- <wrangler args>]Arguments after -- are forwarded verbatim to every underlying wrangler invocation.
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. |
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. |
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/stateRules:
- 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--portapplies to it). - A worker may belong to at most one group. Workers not listed in any group continue to spawn individually.
dev.portskeys 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/.
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.
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.
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:
nameset to${prefix}${moduleName}${suffix}mainpointing to the source entry file (relative path)- All bindings and triggers from
defineWorker, plus all fields frombaseConfig - Service binding names rewritten to sibling workers' full deployed names
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.
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(...).
// 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.
| 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. |
Every example under examples/ ships an npm test setup. Patterns covered: cross-worker RPC, Durable Objects (with onWake), Hono routers, queue producer/consumer.
| 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./buildand./testingsubpaths import Node built-ins (node:fs,node:module,globby) that are not available in the Cloudflare Workers runtime and would break your bundle.
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.
# Install dependencies
npm install
# Build (compiles TypeScript → dist/)
npm run build
# Run the test suite
npm test
# Type-check without emitting
npm run typecheckTests 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.