Emission
Bynk compiles to TypeScript. This page is the practical companion to the
emission spec: where the spec states the normative rules
by which each construct is translated, this page walks an integrator through what
lands on disk and how to read, debug, or wire into it. The output is
deterministic and every emitted file is headed
// Generated by bynkc — do not edit by hand.
Wherever an exact rule matters — a lowering, a validator shape, a runtime signature — this page links to the spec rather than restating it, so the two never drift. In particular, §7 “Meaning by translation” governs per-construct lowering and §7.4 the runtime-library contract governs the shared helpers.
Targets
Section titled “Targets”bynkc emits one of two build targets, selected by --target.
| Target | Flag | Layout | Cross-context calls |
|---|---|---|---|
| Bundle | --target bundle (default) | flat .ts tree mirroring source | direct in-process calls; agents backed by an in-memory StateRegistry |
| Workers | --target workers | one Cloudflare Worker directory per context | validated JSON over a Service Binding; agents are Durable Objects |
The same source compiles cleanly under either target: the difference is entirely in how contexts reach one another and where agent state lives. Bundle is the fast inner loop — everything runs in one process, cross-context calls are ordinary function calls, and there is no serialisation on the hot path. Workers is the deployment target — each context is isolated, calls cross an HTTP boundary that validates its payload, and agents become durable, individually-addressable objects. See Target Cloudflare Workers for the deployment guide, and §7.2 for the normative target definitions.
One construct, deterministic output — and what that output is depends on the target.
Text equivalent: a commons emits plain TypeScript types and functions on either
target. On bundle, the output is a flat .ts tree mirroring the source, with a
StateRegistry backing agents. On workers, each context emits index.ts
(router and boundary validation), handlers.ts (logic), compose.ts (wiring), and
wrangler.toml; each agent emits a Durable Object class with loadState /
commitState; and records crossing a boundary gain __serialise_* / __deserialise_*
helpers.
Project shape on disk
Section titled “Project shape on disk”Every project emits the runtime library as per-module files under
out/__bynk_runtime/ — only the modules its emitted code imports — and every
other emitted file imports each helper from the module that defines it (for
example ./__bynk_runtime/result.js). The runtime carries the pieces that
are identical across a whole project: Result / Option / HttpResult, the agent
state surface (StateRegistry, makeAgent, invariantViolation), the cross-Worker
boundary (callService, BoundaryError), the actor verifiers, and the streams /
connection helpers. Its contract is normative — see
§7.4 — so integrating code should import from it
rather than reconstruct it.
Alongside every emitted .ts module, bynkc writes two sidecars:
<file>.ts.map— a standard source map, with a//# sourceMappingURLtrailer appended to the module. This is what maps a stack frame in the generated TypeScript back to the Bynk line that produced it.<file>.bynkdbg.json— a debug-metadata sidecar recording how emitted handler symbols correspond to the Bynk constructs that declared them, so a debugger can name stack frames in Bynk terms. It is a sibling like the source map; it is never bundled into the shipped Worker.
An emitted tsconfig.json pins target: ES2022, module: NodeNext, and
strict, and an emitted package.json declares "type": "module", so the
output type-checks under the same settings bynkc assumes and builds as ES
modules.
Nominal type constructs lower to a branded base type plus a constructor surface. Branding gives Bynk’s nominal guarantees inside TypeScript’s structural system; the constructors are where the runtime checks live. The exact branding and constructor rules are in §7.3.
| Bynk | TypeScript |
|---|---|
type Id = Int (alias) | branded number + Id.of |
| refined type | branded base + .of (runs the refinement predicate, returns Result) |
| opaque type | branded base + .of / .unsafe constructors; representation is not structurally accessible |
| record | interface with readonly fields; object-literal construction |
| sum | discriminated union on tag + a constructor namespace |
.of(value)returns aResult— it runs any refinement predicate and yieldsErron failure. This is the checked path, emitted for every aliased, refined, or opaque type; prefer it at the edges..unsafe(value)brands without checking, and is emitted only for opaque types (ADR 0182). A refined or alias type has no.unsafe, so integrating code cannot brand a value past its predicate — an admitted literal lowers to an inline(literal as T)cast instead, not a constructor call.
An opaque type additionally hides its representation: the generated base type is not exported structurally, so callers cannot read or reconstruct the underlying value.
For a sum, match becomes a switch (see below), and construction goes through the
emitted namespace:
type Status = | Pending | Shipped(tracking: String)export type Status = { readonly tag: "Pending" } | { readonly tag: "Shipped"; readonly tracking: string };
export const Status = { Pending: { tag: "Pending" } as Status, Shipped: (tracking: string): Status => ({ tag: "Shipped", tracking }),};Expressions
Section titled “Expressions”Expression lowering is mostly what you would write by hand; the interesting cases are the ones that carry control flow or effects. Exact semantics are in §7.3.
| Bynk | TypeScript |
|---|---|
if … else … | conditional expression / if |
match | switch on .tag, payloads bound as const |
| admitted literal | inline brand cast (literal as T) |
? (try) | early-return on Err |
<- (bind) | await |
~> (async send) | ctx.__exec.waitUntil(…) on workers; in-process on bundle |
Two of these are worth reading closely when debugging generated code:
?unwraps aResult: onOkit continues with the inner value; onErrit returns thatErrfrom the enclosing function immediately. In a stack trace, an early return from the middle of a handler is almost always a?.~>is fire-and-forget. On the workers target it is registered withctx.__exec.waitUntil(…)so the runtime keeps the invocation alive until the send completes, even after the response has been returned. On bundle it runs in-process. Either way the caller does not await the result.
A match arm binds its payload as const before the arm body, so a debugger sees
named locals rather than repeated .tag access:
match status { Pending => "waiting" Shipped(tracking) => "sent: \(tracking)"}Agents
Section titled “Agents”An agent lowers to three things: a state interface, a zero-value factory, and a
class exposing loadState / commitState. makeAgent selects the backing store —
in-memory StateRegistry on bundle, Durable Object on workers — by whether a
Durable Object binding is present, so the same handler code drives both targets.
function __zeroOfCounterState(): CounterState { return { count: 0 }; }// loadState(): return stored ?? __zeroOfCounterState();The load/commit cycle is where an agent’s guarantees are enforced, and it is the part integrators most need to understand:
loadState()returns the persisted state, or the zero value if none exists. On workers, rehydrating persisted state re-validates it against the current type definitions; if a refined field, key, or entry no longer satisfies its type (for instance a refinement tightened across a deploy, orphaning previously-valid data) it raises arehydrationViolation(agent, detail). This is an internal fault, not a caller-facingBoundaryError: the supplier is trusted past-self, so the failure is logged with the agent type, the field path and the kind of failure only (RehydrationViolation <Agent>,{ agent, path, kind }) — never the key or the offending value. In a build withbynk.schema.lock, it first checks the raw record’s__vschema-version marker (__checkStoredSchemaVersion), faulting with detailNewerSchemaorNoMigrationPathbefore decoding anything, andcommitStatestamps__vin the sameput. Every build reports a stored key the definition no longer declares once per isolate (__reportUnknownStoredKeys). An agent withmigrate from Nsteps also gets a__migrate<Agent>From<N>function per step and a__migrate<Agent>Statedispatcher: after the marker check,loadStatepasses the raw record through the nearest step covering its version. The step decodes the declared old type, carries unchanged fields, applies the assignments (a throw becomesMigrationFailed), and re-encodes, before the usual zero-merge and gate.commitState(state)checks every invariant predicate before writing. If an invariant fails, it throwsinvariantViolation(agent, name)and does not persist the offending state — so a rejected write leaves the stored state exactly as it was. On workers this is the Durable Object’s storage; on bundle it is the registry entry.
See §7.3 agents for the lowering and
§7.4 for the StateRegistry / makeAgent /
invariantViolation contract.
Services and the Worker entry (workers target)
Section titled “Services and the Worker entry (workers target)”A context with services emits four files plus its Worker config:
| File | Role |
|---|---|
handlers.ts | the handler logic — the code you wrote, lowered |
index.ts | the Worker entry: router + boundary validation |
compose.ts | wiring — how handlers, agents, and bindings are assembled |
wrangler.toml | the Cloudflare Worker configuration |
A context that declares a foreign caller also emits bynk-client.ts, the typed
client a Worker that isn’t Bynk calls it through. It is for that Worker’s build,
not this one: see TypeScript client.
commons (types and functions) lowers to plain TypeScript and is identical on both
targets; only services, agents, and the boundary differ.
The fetch entry
Section titled “The fetch entry”Each context’s index.ts exports the Cloudflare Worker shape:
export default { async fetch(request: globalThis.Request, env: Env, ctx?: { waitUntil(promise: globalThis.Promise<unknown>): void }): globalThis.Promise<globalThis.Response> { // dispatch … },};ctx is Cloudflare’s execution context. It is optional unless a handler sends
with ~>, so an in-process caller such as the test runner can omit it. A
fault fetch or queue catches is logged, then handed to ctx.waitUntil as
a rejected promise, so Cloudflare records it as the invocation’s exception and
applies an uploaded source map to its stack. See
When a handler faults.
fetch dispatches, in order, over:
- HTTP routes — matched by method and path. Routes are ordered by literal specificity, so a literal segment wins over a parameter at the same position; this makes routing deterministic regardless of declaration order.
- Cron schedules — handlers declared
on cron. - Queue consumers — handlers declared
on queue. - WebSocket upgrades — upgrade requests are accepted and handed to the connection machinery.
- Internal Service-Binding calls — requests under the
/_bynk/call/<servicePath>prefix, which is how one Worker reaches another (see below).
The router, the boundary validation it performs, and the response encoding
(httpResultToResponse) are specified in §7.3 HTTP services.
wrangler.toml
Section titled “wrangler.toml”One wrangler.toml is generated per Worker, pinned to a compile-time
compatibility_date (currently 2026-07-01). Its contents are derived from the
context’s closure, so the config always matches what the code actually reaches:
nameandmain = "index.ts"— the Worker identity and entry.compatibility_flags—global_fetch_strictly_publicon every Worker, so aFetchto a URL goes over the public internet even when the URL is another Worker on the same account, followed by any flags the project lists inbynk.toml’s[workers]table.[[services]]— one Service Binding per consumed context. The binding name is the consumed context uppercased with dots replaced by underscores (commerce.payment→COMMERCE_PAYMENT).[[kv_namespaces]]— emitted only if the closure reaches thebynk.cloudflareKV adapter. Theidis a deploy-time placeholder.[[durable_objects.bindings]]and[exports.<Class>]— one binding per agent (binding name is the class in screaming-snake-case, e.g.OrderEntity→ORDER_ENTITY), plus anexportsentry declaring each class a SQLite-backed Durable Object (type = "durable-object",storage = "sqlite"). A context whose handlers emit events gets one more of each, for the__EventsFanoutclass. There is no[[migrations]]block: Cloudflare reconciles the declaredexportsagainst the Worker’s namespaces on every deploy, so an agent added later is registered too.[triggers] crons— everyon cronschedule in the context, aggregated.[[queues.consumers]]— one consumer peron queueservice.
Because the config is generated from the closure, an integrator can read
wrangler.toml as a manifest of everything the Worker touches — every downstream
service, every agent, every trigger.
The cross-Worker boundary
Section titled “The cross-Worker boundary”On the workers target, a call from one context to another goes as JSON over a
Service Binding to the callee’s /_bynk/call/<servicePath> endpoint. The runtime
helper callService performs the call; the callee’s index.ts validates the
payload before it reaches any handler. The full protocol is normative in
§7.4.
Serialisation validators
Section titled “Serialisation validators”Any record that crosses a boundary gets generated __serialise_<Type> /
__deserialise_<Type> helpers. __deserialise_* returns a Result<T, BoundaryError>
and validates structurally and by refinement — so a payload that is shaped
correctly but violates a refined field is rejected, not silently admitted. The
generated Result, Option, List, and Map deserialisers compose the same way,
threading a path (e.g. $.items[2].id) so a failure points at the exact field.
BoundaryError has four kinds, and reading them tells you where a failed call went
wrong:
| Kind | Meaning |
|---|---|
MalformedJson | the body was not valid JSON |
StructuralMismatch | a field was missing or the wrong shape (carries path, expected, actual) |
RefinementViolation | the shape was right but a refinement predicate failed (carries path and the ValidationError) |
Transport | the call itself failed (carries an HTTP status) |
A call can also fail in two ways no codec could report, so they are call errors
beside BoundaryError, not variants of it: ContractMismatch, a deploy skew
the callee refuses with a 409, and CalleeFaulted, the callee’s handler
faulting. A faulted callee answers 500 marked X-Bynk-Fault: <context>, and
the caller logs CalleeFaulted <caller> -> <context> call <service> before it
faults in turn, so its log names the Worker whose log holds the cause.
Caller identity
Section titled “Caller identity”callService stamps the calling context’s qualified name into an X-Bynk-Caller
header. The callee threads this into a handler’s by c: Caller binding so it can
present a live CallerId.
The callee proves that name (#1971). Each pair of contexts that talk, a caller
and a callee or a publisher and a subscriber, shares a key of its own:
BYNK_CALL_TO_<CALLEE> on the sender, BYNK_CALL_FROM_<CALLER> on the
receiver. callService and the events fan-out sign every request with it
(HMAC-SHA256 over the caller, the callee, the path, the contract and wire
headers, a timestamp and the body). Every Worker refuses a /_bynk/call/ or
/_bynk/event/ request whose signature does not verify under the key it holds
for the claimed caller (401, checked straight after the method and a 1 MiB
body cap), because those paths answer on the Worker’s public address too. A
key’s holder can claim only its own name.
The whole request and response format of /_bynk/call/, with its own version
(X-Bynk-Wire), is published in the wire format reference.
Actor authentication
Section titled “Actor authentication”Where a service is reached over an authenticated channel — a public HTTP edge rather than a trusted internal binding — the actor scheme’s verifier runs at the boundary, before any handler. Two runtime verifiers are emitted on demand:
verifyBearerJwtHs256— verifies a bearer JWT (HS256), enforcingexp/nbf. Used for authenticated end-user actors; the verified claims become the handler’s actor identity.verifySignatureHmacSha256— a constant-time HMAC-SHA256 signature check, used for signed webhooks, with timestamp tolerance to bound replay.
Only the schemes a context actually uses pull their verifier into the emitted imports; a zero-crypto prelude scheme mints no runtime export at all, so an integrator auditing which crypto a Worker performs can read it off the runtime imports. The verifier contracts are in §7.4.
HTTP results
Section titled “HTTP results”Handlers return an HttpResult<T>, and the runtime’s httpResultToResponse encodes
it into a Response — so status codes and bodies are produced uniformly rather than
hand-assembled per handler. The HttpResult surface and its encoding are part of
the runtime contract; see §7.4.
Streams and WebSockets (workers target)
Section titled “Streams and WebSockets (workers target)”A WebSocket handler lowers to an upgrade path in fetch plus a Connection<F>
abstraction backed by Cloudflare’s hibernatable WebSockets. On bundle the same
handler runs against a TestConnection<F>, so streaming logic is exercisable without
a Worker. The connection helpers (acceptHibernatableConnection, resolveConnection,
newWebSocketPair) are part of the runtime library — see
§7.4 — and the lowering is in
§7.3 streams/WebSockets.
Tests emit a per-target test module plus an aggregating tests/main.ts runner.
bynkc test compiles the project, type-checks it with tsc, and runs the aggregated
suite with Node — so a test failure is either a Bynk assertion or a type error in the
emitted output, both surfaced against the same source-mapped frames as production
code.