# A Workers deployable

A deployable with `target: Target.wasm` runs on Cloudflare Workers.
`jadpo build` writes it as a WebAssembly module, Jadpo's JavaScript shim
and a Wrangler file, ready for `wrangler deploy`:

```text
$ jadpo build kiosk
  worker    build/target/kiosk/wrangler.json
  module    build/target/kiosk/kiosk.wasm
  size      8.1 MB, 2.9 MB gzipped
  database  DATABASE_URL through Hyperdrive c020574a5623407bbe0ccd192bab9545
  build     b_1c5e09d2a7f3
  serves    main
  runs      schedules, subscribers
  commit    8d113e6
  settings  DATABASE_URL

✓ built  kiosk · wasm in 42.3 s
→ jadpo migrate apply  it starts only once its database has every migration
```

## What `jadpo build` writes

Its Rust goes in `build/rust/<deployable>/`: the program's crate and a
`cdylib`, `jadpo-worker-<deployable>`. The `cdylib` exports the Worker's
`fetch`, `scheduled` and `queue`, and `hand_on` for work handed on,
through the Workers host. It is given the program, its build stamp and
each store's migrations, as a native binary is.

It links the package driver, which every Worker has; PostgreSQL's when the
project keeps a database, since every deployable's tick reaches each
store; SMTP's when the deployable reaches an SMTP connection; and no
other.

It is built for `wasm32-unknown-unknown` in release, offline and
`--locked`, without the runtime's `testing` feature, into the cargo target
the native deployables share. Its release profile, from its workspace, is
fat link-time optimisation in one codegen unit at `opt-level = "s"`, for
size, since a Cloudflare plan limits what a Worker uploads. It is then
bound by wasm-bindgen's own library, at the release the lock holds, so
nothing is downloaded and no command-line tool has to match. The module's
name section and its `producers` section are left out.

Into `build/target/<deployable>/`, over what an earlier build left there,
go:

- `<deployable>.wasm`, the module;
- `<deployable>.js`, wasm-bindgen's JavaScript for it;
- `worker.mjs`, Jadpo's module shim, which instantiates the module once
  per isolate and hands it each request, the Worker's `env` and the
  invocation's `ctx`. It exports `JadpoHandOn`, a named entrypoint whose
  `handOn` takes on work another invocation handed on;
- `wrangler.json`;
- the documents of the APIs it serves.

## The Wrangler file

Here is the Wrangler file for the kiosk above:

```json
{
  "name": "repair-cafe-kiosk",
  "main": "worker.mjs",
  "compatibility_date": "2026-10-01",
  "no_bundle": true,
  "find_additional_modules": true,
  "rules": [
    { "type": "ESModule", "globs": ["**/*.mjs", "**/*.js"] },
    { "type": "CompiledWasm", "globs": ["**/*.wasm"], "fallthrough": false }
  ],
  "observability": { "enabled": true },
  "hyperdrive": [
    { "binding": "DATABASE_URL", "id": "c020574a5623407bbe0ccd192bab9545" }
  ],
  "triggers": { "crons": ["* * * * *"] }
}
```

It is built as one value and written out once, so no binding is left out
for another. Its fields:

- **`name`**: the app's name and the deployable's, `<app>-<deployable>`,
  so two apps' `kiosk`s are two Workers in one account. The app's name is
  its folder's until `app.jadpo` gives one ([OpenAPI](openapi.md)).
  Each is lower-cased, and each run of anything but an ASCII letter or
  digit becomes one `-`, since Cloudflare takes letters, digits and
  dashes. It is at most 63 characters, with the deployable's name kept
  whole (`23-repair-cafe-kiosk`).
- **`main`**: the shim.
- **`compatibility_date`**: the date the Workers host is tested at, under
  the workerd Jadpo pins (2026-10-01).
- **`no_bundle` and `find_additional_modules`**, with a rule for each
  kind of file, so the files are uploaded as they are written: `.mjs` and
  `.js` as ES modules, and `.wasm` as compiled WebAssembly. The latter has
  `fallthrough: false`, since Wrangler warns of a rule that hides its own
  default without saying so.
- **`observability: { enabled: true }`**: Workers Logs keeps the lines the
  Worker logs only when it is enabled, and the Wrangler Jadpo pins leaves
  it off when the file doesn't say so.
- **`secrets: { required: [ ] }`**: the variable of each setting without a
  `default:`. A binding on Workers is a variable or a secret, and a
  required secret is Wrangler's one way to name a binding that must be set
  without giving its value. So `wrangler deploy` refuses until each is
  set. A setting with a default is left to it.
- **`hyperdrive: [ ]`**: each database's address setting as a Hyperdrive
  binding by its variable. Hyperdrive is Cloudflare's connection pool in
  front of your database ([PostgreSQL through
  Hyperdrive](05-data.md#postgresql-through-hyperdrive)). The binding has
  its configuration's `id` when the build's environment gives one in
  `JADPO_HYPERDRIVE_<variable>` (`JADPO_HYPERDRIVE_DATABASE_URL`), and
  none otherwise, which `wrangler deploy` refuses.
- **`triggers: { crons: ["* * * * *"] }`**: the minute's cron trigger,
  which runs the tick when the tick has a store to keep or sweep.
- **`ratelimits`**, with rate limits counted in Cloudflare's binding: a
  binding for each limit, with its name, its `namespace_id` and
  `simple: { limit, period }` ([Rate
  limits](16-built-in-services.md#rate-limits)).
- **`queues: { consumers: [ ] }`**: each queue on Cloudflare Queues the
  Worker receives, with its `queue`, `max_batch_size` 10,
  `max_batch_timeout` 5, `max_retries` 30 and its `dead_letter_queue`. The
  dead-letter queue is a consumer too, with `max_retries` 100 and no
  dead-letter queue of its own; the Worker parks its messages for an
  operator ([External services](external-services.md)).

No binding names the Worker itself. What one invocation's 30 seconds
didn't reach is handed on to a fresh one through the loopback binding
`ctx.exports` gives its `JadpoHandOn` entrypoint ([Running background
work](12-jobs-and-events.md#running-background-work)). That reaches the
Worker that handed it on, whatever name `wrangler deploy --name` or
`--env` gave it.

Wrangler's own `deploy --dry-run`, at the version Jadpo pins, accepts the
file with no account and no warning, in tests run with every network
connection refused.

## The build stamp

Its target is `wasm`. The module holds the stamp as a constant, as a
binary does, and the manifest gives the deployable's target as `wasm`.

## The screen

For each Worker, `jadpo build` shows:

- `worker`, its Wrangler file;
- `module`, the module;
- `size`, the module's bytes raw and gzipped at zlib's default level, as
  Wrangler reports an upload, in decimal units, as Cloudflare states its
  limits;
- `database`, each database's address binding through Hyperdrive with its
  id, or `no id`, with an alert saying how to give one and that its
  Hyperdrive must keep no cache;
- its build id, and what it serves and runs;
- `calls`, each connection's `max_concurrent:` it reaches, counted in each
  instance (`pdf 2 and ocr 4 at once in each instance`).

Built with no id in `JADPO_HYPERDRIVE_DATABASE_URL`, its database line
says so, and an alert says what to do:

```text
  database  DATABASE_URL through Hyperdrive, no id
  …
▲ alert  `DATABASE_URL` has no Hyperdrive id: create one with caching off and
  build with it in `JADPO_HYPERDRIVE_DATABASE_URL`
```

The `--json` data gives `wrangler`; the module's `path`, `bytes` and
`gzip_bytes`; `hyperdrive`, each binding and its `id` or null; `binary`
null; and every deployable's `per_instance`, each `connection` and its
`max_concurrent`, empty except on a Worker that reaches a cap.

## Starting

A Worker has no start of its own, so an isolate starts at its first
request, as a native binary starts when its process does. It makes its
program once, and reads only the variables its settings name, from the
Worker's bindings. It refuses a `client_address:` source other than
`cloudflare` ([Client
addresses](10-configuration-and-time.md#client-addresses)).

A problem there is logged once, as `runtime.startup_failed` naming each
setting by its variable and never its value. The isolate then answers
every request `503` with `unavailable` and a request id, until a new
version with other bindings replaces it.

## Its database

Each request builds its connections' drivers, over `connect()` and
`fetch()`, since a socket belongs to the request that opened it. A
database's address is its Hyperdrive binding's, reached without TLS inside
Cloudflare, with Hyperdrive using TLS to the database ([PostgreSQL through
Hyperdrive](05-data.md#postgresql-through-hyperdrive)). A text binding of
that name is reached directly, over TLS through `startTls()`.

Until each database the project keeps migrations in has been compared
with the build's migrations and found current, each request compares them
first. A migration not applied, or one applied that the build doesn't have
or that changed since, is logged as `runtime.startup_failed` and the
request answered `503`. A database it can't reach yet is logged as
`runtime.waiting` and answered the same. So a migration applied after the
Worker is deployed is picked up by its next request, with no restart. Once
current, an isolate doesn't compare again, and logs `runtime.started` with
each connection whose driver wasn't built.

## Serving

Each request goes through the runtime as a native one does: the route
table, input, policy, transactions, `settle:`, rate limits and the
failure envelope. The client's address is `CF-Connecting-IP`, which
Cloudflare sets on every request ([Configuration and
time](10-configuration-and-time.md)).

The work a response leaves runs after it in `waitUntil`, within its 30
seconds. What that doesn't reach is handed on to a fresh invocation of the
Worker through the loopback `ctx.exports` gives its `JadpoHandOn`, which
carries it on within what is left of its own 30 seconds, counted from its
start.

Rate limits are counted in the store `rate_limits:` names, Cloudflare's
rate-limiting binding. They are counted in the isolate's memory only while
the binding can't be asked: missing, failing, or not answering within 500
ms. Each binding's fallback is logged once an isolate, as
`rate_limits.fallback` ([Rate limits](rate-limits.md)).

A streamed answer (`ai.stream`) is a `ReadableStream`, each event sent as
it is read. A client that goes away ends the request, and with it the
model's call.

An invocation has at most six connections waiting for their answer's head
at once, as Cloudflare allows. The runtime counts them across its
database, its providers, SMTP and its own fetches, the work it leaves in
`waitUntil` included. A seventh waits for one within its own timeout, and
one whose timeout passes first never went out, so it is sent again where
a call may be. `max_concurrent:` caps a connection's calls per isolate,
and a slot one invocation gives back goes to the call of another that has
waited longest.

## The tick

Each run of the minute's cron trigger calls the Worker's `scheduled`. That
starts the isolate as a request does and, once its databases are current,
runs the runtime's tick ([The tick](schedules.md#the-tick)): the
schedules due, the outbox's sweep and the deletions, and the queues
`receiving:` reads through a package. Out of date, it logs why as a
request does and ticks nothing.

While the app receives a queue, it ticks again at each 10-second mark of
the cron's minute, one tick after another, so a queue is read as often as
natively. The invocation ends with the minute, and its ticks share the
cron's 15 minutes.

Each tick is logged as `runtime.ticked`, as a native server logs its own,
with each store it couldn't work in. An `every:` under a minute is refused
for a Worker that runs the schedules
([`deployable.target_unsupported`](../errors/deployable.target_unsupported.md)).

## A queue

Each batch Cloudflare Queues hands the Worker's `queue` starts the isolate
as a request does. Once its databases are current, the batch goes through
the runtime's consumer, each message on its own:

- a message is kept once, as its event;
- a repeat, and a message parked for an operator, are acknowledged
  (`ack()`);
- a message the store can't take now is retried after 10 seconds, doubled
  for each attempt up to an hour (`retry({ delaySeconds })`).

Then the kept events' deliveries run in `waitUntil`, handed on past its 30
seconds as a response's work is.

Out of date, or for a batch from a queue it doesn't receive, every message
is retried the same way, and `queue.retried` is logged with the queue, how
many and why. With 30 retries, a message waits out about 22 hours of a
deploy's migration before Cloudflare moves it to the dead-letter queue.

A batch from that dead-letter queue, its messages' retries spent, has
each message parked whole for an operator as `dead_lettered`, with its id
and event when they can be read, and acknowledged. So `jadpo deliveries`
lists it, and a retry has the tick read it again.

A message whose `retry()` throws would be acknowledged when the handler
ends well. So the handler throws once every message is answered, and
Cloudflare hands again each one not acknowledged.

## What it logs

It logs one JSON object a line on the console, which Workers Logs and
`wrangler tail` read, with the same events and stamp fields as a native
binary's: `runtime.startup_failed`, `runtime.waiting`, `runtime.started`,
`runtime.ticked` after each tick of its cron, `operation.failed` for a
fault behind a 500 with its `.jadpo` frames, and `queue.retried` for a
batch with a message retried. A Worker and a native binary start, compare
their stores and log through the same code, so they say the same things
alike.
