Management and fleet library for Cloudflare Workers
workforce wraps the Cloudflare Workers management API and the operations built on top of it. You give it a credential and a target; it creates, updates, patches, forks, deletes, monitors, diagnoses and rolls back Workers, one at a time or a thousand at once.
It runs on Node and inside a Worker. The second one is the point: a Worker can provision another Worker from its own assets, from a tarball, or from files it received over HTTP.
The Cloudflare API is a REST surface, and everything above one Worker is left to the caller: paging, the request budget, which operations a dispatch namespace does not have, what a version can and cannot carry, and what happens to a fleet operation when the fortieth of five hundred fails.
workforce is that layer. The official cloudflare SDK covers the whole API and unpacks to 65 MB
across 16,289 files, which is not a dependency that belongs inside a Worker.
node: import outside the /node subpathbun add @drupflare/workforce
npm i @drupflare/workforce
import { cloudflare, fromFiles, workforce } from '@drupflare/workforce';
const cf = workforce({
plane: cloudflare({
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
token: process.env.CLOUDFLARE_API_TOKEN!
})
});
await cf.worker('my-api').upload({
source: fromFiles({
'index.js': 'export default { fetch: () => new Response("hello") };'
}),
metadata: { compatibility_date: '2026-08-01' }
});
A plane is the executor. The operations are the same on each; what differs is declared rather than discovered.
import { cloudflare, dispatch, workerd, workforce } from '@drupflare/workforce';
const account = workforce({ plane: cloudflare({ accountId, token }) });
const tenants = workforce({ plane: dispatch({ accountId, token, namespace: 'tenants' }) });
const node = workforce({ plane: workerd({ endpoint: 'https://node.example.edu', token: bst }) });
await account.worker('api').upload({ source });
await tenants.worker('acme').upload({ source });
await node.plane.upload('www.example.edu', built);
tenants.capabilities.versions.supported; // false
tenants.capabilities.versions.reason; // why, and what to use instead
Reaching for an unsupported operation throws a CapabilityError before any request is made.
cloudflare |
dispatch |
workerd |
|
|---|---|---|---|
| versions, deployments, rollback | yes | no | yes |
| tails | yes | no | yes |
| schedules | yes | no | no |
| subdomain | yes | no | no |
| assets, tags, analytics | yes | yes | yes |
| Access | yes | yes | no |
| tags per script | unlimited | 8 | unlimited |
Everything that uploads takes a ModuleSet, which is a Map<string, Uint8Array> of path to bytes.
import { fromFiles, fromGitHub, fromRequest, fromTarball, fromAssets } from '@drupflare/workforce';
import { fromDirectory } from '@drupflare/workforce/node';
fromFiles({ 'index.js': 'export default {}' });
await fromTarball(bytes);
await fromGitHub('drupflare/some-template', { ref: 'main' });
await fromRequest(request);
await fromDirectory('./dist');
fromAssets reads the calling Worker's own assets, which is how a Worker provisions another Worker:
const source = await fromAssets(env.ASSETS, '/templates/manifest.json', { budget: 40 });
if (!source.done) {
// resume from source.cursor on the next invocation
}
The budget is there because an ASSETS binding spends one subrequest per file and the free plan
allows fifty per invocation. When it runs out it hands back a cursor instead of failing.
await cf.worker('api').patchSettings({
bindings: [{ type: 'kv_namespace', name: 'CACHE', namespace_id: id }]
});
A settings patch replaces the whole binding list, so naming two bindings would delete every other
one. patchSettings reads the current list first and turns the rest into inherit entries.
replaceBindings is there for a caller who means to replace them.
await cf.worker('api').secrets.put('API_KEY', value);
await cf.worker('api').secrets.putJson('CONFIG', { region: 'enam' });
await cf.worker('api').secrets.list(); // names and types; the API never returns values
const result = await cf.worker('api').assets.sync(await fromDirectory('./public'));
await cf.worker('api').upload({ source, metadata: { assets: { jwt: result.completionToken } } });
sync takes the whole tree. A path absent from it is absent from the resulting version, which is the
only way a rollout deletes a file, so there is deliberately no partial form.
const next = await cf.worker('api').versions.create({ upload, message: 'pr-412' });
await cf.worker('api').versions.deploy([
{ version: next.id, percentage: 10 },
{ version: current.id, percentage: 90 }
]);
await cf.worker('api').versions.rollback(previous.id);
Under a gradual deployment, requests to a given Durable Object use the same version for the life of that deployment. A ten percent split therefore puts roughly ten percent of OBJECTS wholly on the new code rather than ten percent of each object's requests.
The platform does not return a past version's code, so workforce keeps it when you give it a store.
import { RevisionStore, planRevert } from '@drupflare/workforce';
import { hybridStore, r2Frames, d1Index } from '@drupflare/workforce/store';
const store = hybridStore({ frames: r2Frames(env.FRAMES), index: d1Index(env.DB) });
const revisions = new RevisionStore(store);
const upload = await cf.worker('api').upload({ source });
await revisions.write('api', source, { versionId: upload.versionId, etag: upload.etag });
const plan = await planRevert(revisions, 'api', someRevisionId);
await cf.worker('api').versions.create({ upload: plan.upload, message: plan.message });
rollback re-points the deployment at a version the platform still holds; no new version appears.
revert uploads old content as a NEW version and deploys that. Different operations, named apart.
Content is framed at 16 KiB and addressed by digest, so an edit stores the frames it touched rather than the whole bundle.
diffModules compares two module sets; diffWorkers adds bindings, compatibility date and flags,
tags and secret names, and sets contentUnavailable when one side has no stored content.
import { diffModules } from '@drupflare/workforce';
for (const file of diffModules(before, after)) {
if (file.change !== 'unchanged') console.log(file.change, file.path);
}
Three-way merge lives behind its own subpath, so a Worker that never merges does not bundle it. Text merges line by line; binary files that both sides changed are reported as conflicts.
import { merge } from '@drupflare/workforce/merge';
const result = merge(base, ours, theirs);
if (!result.clean) {
for (const conflict of result.conflicts) {
// resolve conflict.path here; conflict.reason says why it could not merge
}
}
import { Observability, summariseCpu } from '@drupflare/workforce';
const obs = new Observability(cf.raw, accountId);
const events = await obs.query({ from: Date.now() - 3_600_000, to: Date.now() });
const report = summariseCpu(events);
if (report.instrumentFailure) {
// the capture is not usable; see report.notes
}
Prefer the query API over tail. wrangler tail --format json has been measured returning stateless
events and zero durable-object events for invocations the query API reported in full, so
summariseCpu reports a capture in that shape as a broken instrument rather than as a reading.
import { mapFleet, planFleet, applyPlan } from '@drupflare/workforce';
const workers = await cf.list();
const result = await mapFleet(workers, (w) => w.patchSettings({ bindings }), { concurrency: 8 });
const plan = planFleet(desired, actual);
const report = await applyPlan(plan, async (action) => {
// perform the action here
});
A fleet operation finishes and reports rather than stopping at the first failure. planFleet keys a
worker on plane, scope and name, and throws on a duplicate key in either list.
applyPlan keeps one state per action (pending, running, succeeded or failed) in a cursor of
the form { digest, states }. The digest is a SHA-256 over the plan's actions, so a cursor from a
different plan throws a UsageError. A failed action stays resumable, and done is true only when
every action has succeeded. onTransition is called on each state change so a caller can persist the
cursor, and a throw from it stops the apply. A runner can be re-run after a crash between its side
effect and the succeeded write, so runners must be idempotent.
import { planFork } from '@drupflare/workforce';
const plan = planFork({
source: 'api',
target: 'api-dev',
modules,
durableObjects: 'fresh'
});
fresh gives the fork its own empty Durable Object namespaces, shared points its bindings at the
source's, and transfer MOVES them and needs an explicit confirmation. A fork never carries secrets,
because the API does not return their values; plan.secretsNotCarried names the ones you still owe.
import { Access } from '@drupflare/workforce';
await new Access(cf.raw, accountId).ensure({ name: 'preview', workerId });
Worker-level Access covers every hostname a Worker is reached by, including its workers.dev one,
and needs no zone.
import { Resources } from '@drupflare/workforce';
const resources = new Resources(cf.raw, accountId, { plan: 'free' });
await resources.createD1('tenant-acme');
createD1 counts the account's databases first and refuses by name at the cap rather than relaying a
quota error. The free plan allows ten.
Providers take a binding and hold no state of their own:
import { d1Index, hybridStore, kvIndex, memoryStore, r2Frames } from '@drupflare/workforce/store';
Frames belong in an object store and the index in a database, which is what hybridStore composes.
The index is one small record per Worker, so it never outgrows a single D1 database.
import { emit, memorySink } from '@drupflare/workforce';
const sink = memorySink();
await emit(sink, {
name: 'worker.created',
plane: 'cloudflare',
target: accountId,
subject: 'api'
});
workforce emits; it never decides what an event costs. A sink is optional and failures in one never fail the operation being described.
The Action gives every pull request its own preview Worker. Previews are always managed Workers: Cloudflare generates no preview URL for a Worker implementing a Durable Object, and serves no logs for any preview URL, so a named Worker is the only shape that works for every case.
name: Preview
on:
pull_request:
types: [opened, synchronize, closed]
schedule:
- cron: '0 3 * * *'
jobs:
preview:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v5
- run: npm ci && npm run build
- uses: drupflare/workforce/action@v0.1.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
worker: api
directory: dist
mode: ${{ github.event_name == 'schedule' && 'sweep' || github.event.action == 'closed' && 'destroy' || 'deploy' }}
A preview is named from nameTemplate, which defaults to {worker}-pr-{pr}, and is reachable on the
account's workers.dev subdomain:
https://{worker}-pr-{pr}.{account-subdomain}.workers.dev
Worker api on pull request 7, under an account whose subdomain is acme, answers on
https://api-pr-7.acme.workers.dev. The Action enables that hostname and turns per-version preview
URLs off, so each preview has exactly one URL. Set access: true to put Cloudflare Access in front
of it.
Set hostnameTemplate and zone to publish on your own domain instead, for example
hostnameTemplate: '{branch}-staging-app.drupflare.com' with zone: drupflare.com. The branch becomes
one DNS label: lowercase, with every other character turned into -. A label longer than 63
characters is truncated and suffixed with six hex characters of the branch hash. The Action resolves
the zone id with GET /zones?name=, so the token needs Zone Read on that zone. The workers.dev
subdomain is turned off for these previews. A value in vars may contain {hostname}, which the Action
replaces with the finished hostname, so a Worker can be told the address it is served on.
Access with accessEmails creates one allow policy naming exactly those addresses. Access with
neither accessPolicyId nor accessEmails is refused, because the preview would be open.
mode runs when no command is given.
| mode | when | what it does |
|---|---|---|
deploy |
pull_request opened or synchronized |
uploads the preview, writes the wf:pr and wf:ttl tags, comments the URL |
destroy |
pull_request closed |
deletes the Worker and the Access application covering it |
sweep |
schedule |
deletes previews whose pull request is closed or untouched for longer than staleAfter |
Inactivity is measured from the pull request's updated_at, which arrives for every open pull
request in one call. wf:ttl is written at creation and honoured whether or not the sweep runs.
apiToken, accountId, command, preCommands, postCommands, workingDirectory, quiet,
environment, secrets, vars, packageManager and gitHubToken carry the same meaning as in
cloudflare/wrangler-action, so a workflow converts by changing the uses: line. Kebab-case
spellings are accepted too.
| input | default | what it sets |
|---|---|---|
mode |
deploy |
which preview operation to run |
worker |
the Worker previews are made from | |
directory |
dist |
the built Worker to upload |
nameTemplate |
{worker}-pr-{pr} |
how a preview is named |
hostnameTemplate |
the hostname a preview is published on; empty keeps workers.dev |
|
zone |
the zone the hostname belongs to; required with hostnameTemplate |
|
config |
a rendered wrangler JSON file for bindings, flags and assets | |
ttl |
7d |
how long a preview may live regardless of the pull request |
staleAfter |
14d |
how long a pull request may go untouched before sweep removes its preview |
access |
false |
put the preview behind Cloudflare Access |
accessPolicyId |
an existing Access policy to attach instead of creating one | |
accessEmails |
addresses allowed through Access, newline separated | |
cleanup |
true |
delete the preview when the pull request closes |
comment |
true |
comment the preview URL on the pull request |
Outputs are command-output, command-stderr, deployment-url, worker-name, version-id and
swept.
secrets and vars are newline-separated variable NAMES whose values come from the workflow's own
env. Values are never logged.
Every refusal is a WorkforceError with a kind:
| kind | means |
|---|---|
auth |
the credential was missing, malformed or rejected |
api |
the API answered and said no; errors carries its codes |
limit |
a rate limit or quota refused it; retryAfterMs when one was named |
not-found |
it is not there |
capability |
this plane cannot do this at all, and the reason says why |
usage |
the call was refused before a request was made |
transport |
the request never produced a readable response |
A 200 carrying success: false is how this API reports a permission problem, so status alone is
never the check.
RevisionStore; without one they report themselves unavailable.exports.RevisionStore does not
read the platform, so history, diff and planRevert work on a namespaced script exactly as they do
on an account-scoped one. What is genuinely unavailable is TRAFFIC SPLITTING, because with no
/deployments endpoint there is nothing to split across, so a revert is a re-upload and is atomic
per script rather than gradual. A staged rollout there belongs in the dispatch Worker, which
decides what reaches which script.workers.dev, no Access and no cron triggers, and its secrets are
host-level rather than per-site. Versions, deployments, tails, routes and assets are real there
because the node owns its router and its store; a version id is the content address of the bundle, so
the same bytes uploaded twice are one version. Logs and metrics come from the node's own endpoints
instead of Cloudflare's query API, and the node does not serve a deployed bundle back.edgeport is required only for the SSH and SFTP sources, is imported lazily, and works only on
the Workers runtime.Generated docs: https://drupflare.github.io/workforce.
bun install
bun run typecheck
bun run test:unit
bun run test:e2e
bunx prettier --check .
test:e2e runs against a local plane over wrangler dev. test:e2e:integration runs the same specs
against Cloudflare and needs FREE_CLOUDFLARE_ACCOUNT_ID and FREE_CLOUDFLARE_API_TOKEN.
MIT. See LICENSE.