Skip to content

Adapters

An adapter is the one declaration kind where a Bynk capability contract sits adjacent to a non-Bynk implementation. It is the only place the host boundary may exist — the single, named, greppable seam through which a deploy-target runtime or an npm library enters a Bynk program. Everything else stays pure Bynk.

An adapter declares capabilities and the boundary types they reference, names a TypeScript binding that supplies the implementations, and exports the capabilities to consumers. It may not declare services or agents, and its providers are external (bodiless).

adapter tokens {
binding "./tokens.binding.ts" requires { "jose": "^5" }
consumes bynk { Secrets } // v0.18: adapter-to-adapter dependency
exports capability { Jwt }
exports transparent { Claims, JwtError }
type Claims = { sub: String, exp: Int }
type JwtError = enum { Invalid, Expired }
capability Jwt {
fn sign(claims: Claims) -> Effect[String]
fn verify(token: String) -> Effect[Result[Claims, JwtError]]
}
provides Jwt = JoseJwt given Secrets // external: no body; supplied by the binding
}
  • binding "<module>" names the TypeScript module (resolved relative to the adapter’s source file) that exports the provider symbols. requires { … } declares npm dependencies; ranges must be pinned (no */latest). A dependency package’s adapter declares them the same way, and they install with the consuming project’s own when it reaches that adapter; two adapters requiring one npm package must state the same range. The binding is an ES module, checked as one: relative imports name the .js file (from "./runtime.js", not "./runtime", which tsc rejects with TS2835), and CommonJS idioms (require, module.exports, __dirname) are not available.

  • includes ["./x.wasm", …] (after the module, before requires) ships WebAssembly modules beside the binding. Each path is relative to the adapter’s file and must stay inside its directory (no absolute path, no ..: bynk.adapter.include_outside), end in .wasm (bynk.adapter.include_kind), exist (bynk.adapter.include_missing) and appear once (bynk.adapter.include_duplicate). The compiler copies each file byte for byte beside the emitted binding and generates ./x.wasm.js, whose default export is the compiled WebAssembly.Module; the runtime’s loadWasm(module, imports?) instantiates it. The binding imports both and never knows whether it is on Workers (Wrangler compiles the module; the generated wrangler.toml gains an explicit CompiledWasm rule) or under bynk test (the module is inlined and compiled at load). Included modules work in dependency packages too. includes is a contextual word: it is an ordinary identifier everywhere else.

    Trust. An included module is code from the package, like the binding that loads it: a dependency’s module is part of its checksummed tree, so the lock pins its bytes, and it needs no approval of its own (the npm approval gate governs only what npm brings in). It runs with the binding’s reach, sandboxed by the wasm instance and given only the imports the binding passes.

  • provides Cap = Name with no brace block is an external provider: the compiler emits no class, and the binding must export class Name implements Cap. The implements is checked by tsc --strict — that is the contract between the two halves.

  • consumes U { Cap, … } (v0.18) brings another adapter’s capabilities into scope for the adapter’s providers — see Adapter dependencies below.

An adapter may depend on another adapter’s capabilities (v0.18). Its consumes is restricted on two axes, each with its own diagnostic:

  • braced form only — an adapter has no services to call, so the whole-unit and as Alias forms are rejected (bynk.adapter.consumes_requires_selection);
  • adapter targets only — an adapter may not consume a context (bynk.adapter.consumes_context).

An external provider names its dependencies with the ordinary given; compose builds a by-name deps object and passes it to the binding class constructor:

// tokens.binding.ts — keys are the `given` names, checked by tsc
export class JoseJwt implements Jwt {
constructor(private deps: { Secrets: Secrets }) {}
// … this.deps.Secrets.get("JWT_SECRET") …
}
// compose.ts (generated) — the dependency is instantiated recursively
const Jwt = new tokens__binding.JoseJwt({
Secrets: new bynk__binding.SecretsProvider(),
});

The wiring is transitive: depending on a capability pulls its provider’s binding into the compose (and its dependencies, recursively). This is how config and IO reach a binding — a secret or an HTTP client is a capability dependency (given bynk.Secrets, given bynk.Fetch), never an operation parameter or an env read in application code.

FlavourBindingPortability
Library adapterone, npm-backed, user-authoredruns anywhere
The bynk surfaceone per platform, toolchain-suppliedportable
Platform adapter (bynk.<platform>)one, platform-only, toolchain-suppliedplatform-locked

The bynk surface is the reserved, agnostic conformance core shipped with the toolchain. The bynk root namespace is reserved — no user unit may be named bynk or bynk.* — and every first-party adapter lives inside it: the surface unit bynk (consuming only it keeps code portable) and the bynk.<platform> platform adapters (consuming one locks the deployment unit — the prefix means first-party, not portable). As of v0.18 the surface carries the full ambient set:

CapabilityOpsNotes
Clocknow() -> Effect[Instant]an absolute point in time (epoch-millis under the hood)
Randomuuid() -> Effect[Uuid], int(lo, hi) -> Effect[Int]Uuid is refined
Loggerinfo(msg), error(msg)
Fetchsend(req: Request) -> Effect[Result[Response, FetchError]]typed core; see below
Secretsget(name: String) -> Effect[Option[String]]env-backed per platform

Fetch’s Request carries method (Method enum), url, contentType/authorization/body as Option[String] fields, and headers: Map[String, String] for any other request header. headers is a required field, so a request that sends no extra headers says headers: Map.empty(). Response carries status, headers (keys lowercased) and body. See the bynk capabilities reference for the conflict and forbidden-header rules, which return Err(InvalidHeader).

The deploy platform (--platform {cloudflare,node}, default cloudflare) selects which bynk-<platform>.ts binding is linked. It is distinct from --target {bundle,workers}, which chooses emit topology. Because the bynk contract names canonical provider symbols, the generated compose is platform-identical — only the imported binding module differs. Porting Bynk to a new runtime means implementing this one adapter’s interfaces.

A platform adapter exposes a platform’s real infrastructure as it is — no portable intersection. The toolchain ships bynk.cloudflare (Kv since v0.19; putTtl/list since v0.23):

CapabilityOpsBinding maps to
Kvget(key) -> Effect[Option[String]], put(key, value) -> Effect[()], putTtl(key, value, ttlSeconds) -> Effect[()], delete(key) -> Effect[()], list(prefix: Option[String]) -> Effect[List[String]]the Worker KV namespace at env.KV

putTtl writes with an expirationTtl. list is a drain: the binding follows the cursor internally and returns every matching key name — eager and unbounded, so prefer a prefix on large namespaces (cursor-paging is deferred; see ADR 0050).

Structured values are composition with the v0.22 codec, not extra ops — store Json.encode(entry), read back through Json.decode[Entry]:

service cache {
on call(key: String, e: Entry) -> Effect[Option[Entry]] given Kv {
let _ <- Kv.putTtl(key, Json.encode(e), 60)
let stored <- Kv.get(key)
match stored {
Some(s) => match Json.decode[Entry](s) {
Ok(decoded) => Some(decoded)
Err(_) => None
}
None => None
}
}
}
context cache.store {
consumes bynk.cloudflare { Kv } // locks this deployment unit to cloudflare
service cache {
on call(key: String, value: String) -> Effect[Option[String]] given Kv {
let previous <- Kv.get(key)
let _ <- Kv.put(key, value)
previous
}
}
}

Consuming it is derived plumbing, not configuration: the Worker’s Env gains a typed KV: KVNamespace field, its wrangler.toml a [[kv_namespaces]] stanza (fill in the namespace id at deploy time), and on the bundle target composeApp gains an optional env parameter to thread the namespace through. The application never touches env.

It also locks the deployment unit — each context under --target workers, the whole program under bundle — to the platform, along in-process given edges (a service consumes between contexts is RPC and does not propagate). Building with a different --platform is bynk.target.vendor_required; spanning two native platforms in one deployment unit is bynk.target.vendor_conflict. The bynk surface and library adapters never lock; a remote vendor API over HTTPS belongs in a library adapter (given bynk.Fetch), which stays portable. Kv.list, structured values, and Queue arrive with the v0.22 extension.

A context consumes an adapter exactly as it consumes another context. Selected capabilities can be flattened to bare names:

context auth.sessions {
consumes bynk { Logger } // portable
consumes tokens { Jwt } // library adapter; bare `Jwt` in scope
service login {
on call() -> Effect[String] given Jwt, Logger {
let _ <- Logger.info("issuing token")
let token <- Jwt.sign(Claims { sub: "u1", exp: 0 })
token
}
}
}

consumes U { Cap, … } flattens the named capabilities into the consumer’s local namespace, so they read as given Cap / Cap.op(…) — identical to a locally declared capability. The emitted TypeScript is the same as the qualified given U.Cap form.

A consumed adapter is wired in-process (its binding is instantiated in the composition root), never over a Service Binding — an adapter is not a deployment unit.

A binding constructs its adapter’s boundary types, which deliberately pierces Bynk’s construction discipline (only the defining unit may construct a type). Inside a binding that rule does not apply — the binding is the host boundary. To avoid coupling to the emitter’s lowering, bindings construct boundary values only through the emitted constructors — Ok/Err/Some/None from runtime.js, a sum type’s T.Variant, a record as an object literal, and a refined type through its validating .of (handling the Result; a raw cast bypasses the predicate and is disallowed — and a refined type emits no .unsafe to call in the first place, ADR 0182).