WorkflowStep is an RPC receiver, so step.do.bind() throws

Call step.do as a method on its WorkflowStep. Detaching it with .bind() asks the RPC receiver for a method literally named bind and fails.

Workflows

· Chapter

2

·

2

min read

The answer. In the deployed Workflows runtime the step object passed to run() is not a plain JavaScript object. It is an RPC receiver, and a property access on one of its methods becomes a remote method lookup. step.do.bind(step) — the natural move when you want a typed adapter around do, or to pass it as a callback — asks the receiver for a method literally named bind. The instance dies with TypeError: The RPC receiver does not implement the method "bind" before its first durable step. Keep the call attached to its receiver: write the adapter so it calls step.do(...) inline, and cast only to satisfy the compiler.

The pattern.

import type { WorkflowStep, WorkflowStepConfig } from "cloudflare:workers";

// Bridges Rpc.Serializable<T> for `unknown` provider JSON without ever
// detaching `do` from its RPC receiver.
export function stepDo<T>(
  step: WorkflowStep,
  name: string,
  configOrCb: WorkflowStepConfig | (() => Promise<T>),
  cb?: () => Promise<T>,
): Promise<T> {
  const s = step as unknown as { do: (...a: unknown[]) => Promise<T> };
  return typeof configOrCb === "function"
    ? s.do(name, configOrCb)            // attached call
    : s.do(name, configOrCb, cb);
}
// DON'T: const doStep = step.do.bind(step);  // -> remote method "bind"

// Regression test: a receiver that throws on any `bind` access.
const rpcDo = new Proxy(() => undefined, {
  get: (_t, p) => { if (p === "bind") throw new TypeError("no bind"); },
  apply: (_t, _this, args) => (args[1] ?? args[2])(),
});

The receipt. First production canary of HarperFlow's Workflow-based pipeline (the port of a 154-node n8n workflow to Cloudflare Workflows), 2026-07-25: the instance failed before its first step.do with exactly that TypeError. Every local test had passed, because a local step is an ordinary object that happily returns Function.prototype.bind. The adapter above plus the Proxy regression test shipped the same day (33 tests across 8 files green, TypeScript clean, Wrangler dry-runs clean), and a recovery instance then ran the status callback, opportunity discovery and single-keyword selection live before stopping on an unrelated credential rejection.

Watch out.

  • Neither unit tests nor wrangler dev hand you a real RPC receiver. Simulate one, or you will only find this in production.
  • Destructuring (const { do: run } = step) detaches the method the same way. We only proved .bind; treat .call and .apply as suspect too.
  • Cast at the boundary, not everywhere: the as unknown as exists only to bridge Rpc.Serializable<T> for unknown JSON.

Related: production-canary-version-pinned-cutover · cron-one-tick-many-isolated-legs