Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: Tooling

Tooling

A Workers deployable

On this page

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' kiosks are two Workers in one account. The app's name is its folder's until app.jadpo gives one (OpenAPI). 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). 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).
  • 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).

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). 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).

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). 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).

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).

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): 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).

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.

Markdown for agents: /docs/a-workers-deployable.md.Built from Jadpo main at a707efb.