migrating from deno deploy to cloudflare workers without leaving deno
After Cloudflare acquired the Deno team and announced that Deno Deploy is
shutting down in six months (with the Deno runtime itself only guaranteed a year
of maintenance), we started migrating our production services off Deno Deploy
and onto Cloudflare Workers and Durable Objects, starting with
prompt2bot (api.prompt2bot.com) and followed by
find-scene (api.find-scene.com),
alice-and-bot (api.aliceandbot.com), and
f0mo.
Across those repos we have well over 150,000 lines of TypeScript built around
Deno. They use deno.json import maps, mix jsr: and npm: specifiers, run
hundreds of integration tests via deno test -A --env, and call Deno.env,
Deno.openKv, Deno.cron, and Deno.upgradeWebSocket everywhere.
Even with Deno's future in question, rewriting everything into Node and Wrangler
projects right now was out of the question. Deno's toolchain (deno test,
deno check, deno fmt, deno lint, native TypeScript and JSR support) is
miles ahead of the Node ecosystem. The goal was to keep developing and testing
with the deno binary exactly as before, while compiling each server into a
single Worker bundle that runs on Cloudflare's edge and SQLite-backed Durable
Objects.
Getting a large, stateful agent runtime to behave identically on workerd took
a lot of digging into runtime internals. Here is everything that broke along the
way and how we fixed it.
what a durable object actually is
Whereas a regular Cloudflare Worker is stateless (50 concurrent requests spin up
50 random isolates across the globe that share nothing), a Durable Object
(DO) is a globally unique, single-threaded V8 isolate addressed by name (for
example, idFromName("enam-v1")) and paired with its own private, embedded
SQLite database (ctx.storage.sql) and persistent alarm timer
(ctx.storage.setAlarm).
Cloudflare guarantees that anywhere on Earth, at most one instance of a
given name exists at any moment. Every Worker that calls a method on that name
gets routed to that exact same JavaScript object in memory, where SQLite queries
are synchronous C++ calls that take microseconds instead of network round-trips,
and plain JS variables (Map, Set) work as strongly consistent locks and
caches. You can create one singleton DO for shared storage, or millions of
per-conversation DOs on the fly that hibernate for free when idle.
1. keeping the deno binary and toolchain intact
Wrangler wants to bundle your code with esbuild, which doesn't understand Deno's
jsr: specifiers or deno.json workspace resolution. Instead of fighting
esbuild, we pointed wrangler.jsonc at a custom build script
(deno run -A scripts/buildWorker.ts) that shells out to deno bundle --minify
first and then patches the emitted dist/worker.js for workerd.
The first hurdle was importing DurableObject from "cloudflare:workers". That
module only exists inside Cloudflare's runtime, so importing it directly breaks
deno check and deno test.
We solved this in deno.json by mapping "cloudflare:workers" to a 10-line
local stub:
// server/src/cloudflareStub.ts
export class DurableObject {
ctx: unknown;
env: unknown;
constructor(ctx: unknown, env: unknown) {
this.ctx = ctx;
this.env = env;
}
}
export const env: Record<string, unknown> = {};
In Deno, ConversationDO extends DurableObject inherits from the stub, so all
our Durable Object unit and integration tests run natively under deno test.
During buildWorker.ts, after deno bundle inlines the stub class into
dist/worker.js, a regex swap replaces the inlined stub class with the real
DurableObject imported from "cloudflare:workers" in a prepended header.
The prepended header also shims globalThis.Deno on top of Cloudflare's globals
(env from "cloudflare:workers", process.env, and WebSocketPair), and
buildWorker.ts inlines static markdown files that the server reads at startup
via Deno.readTextFileSync(new URL(...)).
workerd's top-level execution traps
Once the bundle compiled, deploying it to Cloudflare immediately crashed at module initialization before a single request arrived:
- No
evalornew Function. Zod v4 compiles fast validators withnew Functionby default. On Cloudflare Workers, that throwsEvalError: Code generation from strings disallowed for this contextat runtime. Callingz.config({ jitless: true })at startup isn't always enough whendeno bundle --minifypulls in multiple copies of Zod v4 across transitive dependencies, each minified with a different variable name (!r.jitless,!ix.jitless,!ad.jitless). We had to patch every!<ident>.jitlesscheck across the emitted bundle withreplaceAllso a second copy of Zod wouldn't blow up only when specific tool schemas ran. - No entropy or timers in the global scope, and WebIDL
thisbinding. Unlike Deno and Node,workerdforbids callingcrypto.randomUUID(),crypto.getRandomValues(),setTimeout(), orsetInterval()during top-level module evaluation (outside an active request or alarm I/O context). Several third-party npm packages generate a client UUID or schedule a cleanup timer when imported. Our build header wraps those functions intry / catchblocks that fall back to deterministic counters or no-ops when called before a request starts. There is a second trap here: inworkerd,fetch,setTimeout,setInterval,clearTimeout, andclearIntervalare WebIDL methods onWorkerGlobalScope. Calling an unbound reference likeconst orig = globalThis.setTimeout; orig(fn, ms)ordeps.fetch(url)passesthis === undefinedand throwsTypeError: Illegal invocation: function called with incorrect this reference. If yoursetTimeoutwrapper has atry / catchthat returns0on error and you forget.bind(globalThis), everysetTimeoutinside a request silently catchesIllegal invocationand never fires, causing retry sleeps (await new Promise(r => setTimeout(r, 500))) and timeout wrappers to hang forever. - Top-level file reads and WebAssembly in npm packages.
pdf-parsefamously tries to read./test/data/05-versions-space.pdfoff the filesystem when imported ifmodule.parentis undefined, which we fixed by lazy-loading it. Other bundled dependencies likedeno-domembed a 1.4MB base64 WASM blob and callnew WebAssembly.Module(...)synchronously at import time, which Cloudflare rejects withWasm code generation disallowed by embedder, sobuildWorker.tsstrips those out during bundling. - Top-level
Deno.env.get()vs. lazy SDK initialization. On Deno Deploy, environment variables exist inDeno.envbefore module evaluation starts. On Cloudflare Workers, secrets live on theenvobject passed tofetch(req, env)(andenvfrom"cloudflare:workers"). Modules that initialize InstantDB, Google Cloud Storage, or Web Push VAPID keys at top-level import time read empty strings unlessenvis synced intoprocess.envin the bundle header and SDK clients are initialized lazily on first use.
the 128MB durable object memory cliff
On Deno Deploy, an isolate has plenty of heap headroom. On Cloudflare Workers and Durable Objects, every isolate has a hard 128MB RAM limit. Exceeding it kills the isolate immediately.
During our first load tests on Durable Objects, isolates were dying with out-of-memory errors after a couple of turns. Inspecting the bundle showed three massive static allocations sitting in dependencies:
js-tiktokenbundled 5.34MB of base64 BPE rank dictionaries, which expanded into +43MB of V8 heap the first time token counting ran.an-array-of-english-wordsbundled a 3.21MB array of 275,000 strings (~35MB of V8 heap).pdf-parsebundled four separate historical versions ofpdf.js(v1.9.426,v1.10.88,v1.10.100,v2.0.550), wasting 3.05MB of duplicate parser code.
We didn't need exact OpenAI BPE tokenization to estimate context window budgets,
and we didn't need three dead copies of pdf.js. Stripping those three items in
buildWorker.ts cut 11.6MB of raw JS source from the bundle and freed ~80MB of
V8 heap per isolate. We also memoized our tool JSON-Schema conversions so each
turn stopped re-allocating schema objects.
2. moving off deno kv to durable object sqlite
On Deno Deploy, our entire state layer lived in Deno.openKv():
- Append-only conversation history
(
["history-v10", botId, conversationId, timestamp, eventId]) - Per-conversation turn locks (
atomic().check().set()) - Webhook deduplication flags and sliding-window rate limits
- Arbitrary key-value state with TTLs and optimistic concurrency (
versionstampchecks)
Cloudflare KV is eventually consistent and has no atomic check-and-set
operations, so it cannot replace Deno.Kv. Durable Objects with embedded SQLite
(ctx.storage.sql) can.
Rather than rewriting hundreds of Deno.Kv call sites across the codebase, we
built two layers inside a SQLite-backed ConversationDO instance named
"enam-v1" and pinned to Eastern North America (locationHint: "enam",
Cloudflare's region code for US East so it sits right next to our LLM and
database endpoints):
- A first-class
ConversationStoreinterface backed by dedicated SQLite tables (events_v2where allHistoryEventrows live,flags,json_store,rate_limits,scheduled_tasks) and in-memory turn locks (tryAcquireTurn/releaseTurn) for the hot path. - A drop-in
Deno.Kvemulator (createDurableObjectKv) over akv_entriesSQLite table in that same"enam-v1"database for everything else.
The tricky part of emulating Deno.Kv on SQLite is key ordering and atomic
transactions. Deno.Kv keys are tuples of strings, numbers, and booleans
(Deno.KvKeyPart[]), and kv.list({ prefix }) or kv.list({ start, end })
returns entries ordered lexicographically by tuple element, with numbers sorted
numerically.
To make SQLite's WHERE key >= ? AND key < ? ORDER BY key ASC match Deno.Kv's
exact ordering, we encode each tuple part with a type tag and zero-pad numbers
to 20 digits, joined by \x00 bytes:
const encodeKvPart = (part: Deno.KvKeyPart): string =>
typeof part === "number"
? `n:${String(part).padStart(20, "0")}`
: typeof part === "boolean"
? `b:${part ? 1 : 0}`
: `s:${String(part)}`;
const encodeKvKey = (key: Deno.KvKey): string =>
key.map(encodeKvPart).join("\x00");
For kv.atomic().check(...).set(...).delete(...).commit(),
createDurableObjectKv collects the checks and mutations in memory and sends
them in a single RPC call (kvCommitAtomic) to the Durable Object. Because a
Durable Object is single-threaded and this.sql.exec is synchronous, the entire
check-and-set executes atomically without race conditions:
kvCommitAtomic(
checks: { key: string; versionstamp: string | null }[],
mutations: {
type: "set" | "delete";
key: string;
keyJson?: string;
valueJson?: string;
expiresAt?: number | null;
}[],
): { ok: boolean; versionstamp?: string } {
const now = Date.now();
for (const check of checks) {
const rows = [
...this.sql.exec(
"SELECT versionstamp, expires_at FROM kv_entries WHERE key = ?",
check.key,
),
];
const row = rows[0];
const currentVs =
!row || (row.expires_at !== null && Number(row.expires_at) <= now)
? null
: String(row.versionstamp);
if (currentVs !== check.versionstamp) {
return { ok: false };
}
}
const vs = this.nextVersionstamp();
for (const m of mutations) {
if (m.type === "delete") {
this.sql.exec("DELETE FROM kv_entries WHERE key = ?", m.key);
} else if (m.type === "set") {
this.sql.exec(
"INSERT OR REPLACE INTO kv_entries (key, key_json, value, versionstamp, expires_at) VALUES (?, ?, ?, ?, ?)",
m.key,
m.keyJson ?? "[]",
m.valueJson ?? "null",
vs,
m.expiresAt ?? null,
);
}
}
return { ok: true, versionstamp: vs };
}
To cut over prompt2bot with zero downtime, we wired bidirectional live
mirroring (/internal/migrate-history) between Deno KV and the Cloudflare
Durable Object using waitUntil, ran a backfill script over all active
conversations in Deno KV, verified parity, and then switched the
api.prompt2bot.com/* DNS/Worker route over to Cloudflare.
when you don't even need a deno kv emulator
Not every service needed a full SQLite Deno.Kv layer. When we audited the rest
of our repos (alice-and-bot, topic-match, supergreen, lurk, shifter),
almost every Deno.openKv() call fell into one of two buckets:
- Short-lived ephemeral state: 2-minute auth nonces, 5-minute encrypted device-transfer payloads, OAuth PKCE code verifiers, and webhook relay queues.
- Small durable records: Bitcoin payment addresses, match indices, and bot toggle flags.
For services that already use InstantDB (alice-and-bot, topic-match,
supergreen), we moved the durable records straight into InstantDB entities and
replaced the ephemeral KV keys with in-memory TTL maps. In alice-and-bot,
routing requests through a singleton AliceDO Durable Object
(idFromName("global-v1")) meant WebSocket relays (/relay/ws/:token), webhook
pushes, auth nonces, and device transfer payloads all shared a single in-memory
isolate across the globe without touching disk at all.
3. the 508 Loop Detected mystery (CDN-Loop propagation)
Right after we switched api.prompt2bot.com/* to the Cloudflare Worker, bots
that talked to services hosted on Deno Deploy (including our own encrypted web
chat backend alice-and-bot.uriva.deno.net and bots with remote tool or config
servers on Deno Deploy) started failing with a bizarre HTTP error whenever the
Worker called them back:
508: Loop Detected (LOOP_DETECTED)
Loop detected: the deployment is fetching itself.
The flow looked like this:
- A service on Deno Deploy sends a webhook or API request
(
POST https://api.prompt2bot.com/alice-and-bot/:botIdorPOST https://api.prompt2bot.com/api). - Deno Deploy's egress proxy automatically stamps an RFC 8586
CDN-Loopheader onto that outbound request identifying the originating Deno deployment:cdn-loop: deno;d=<deployment_hash> - Our Cloudflare Worker receives the request and starts running the agent turn.
During the turn, the Worker makes a completely separate outbound
fetch()call back to the same Deno Deploy deployment (for example, sending a typing indicator, fetching remote bot config, or delivering the agent's reply), passing a brand-new headers object (headers: { "content-type": "application/json" }). - Cloudflare's runtime silently copies the incoming request's
cdn-loopheader onto every outboundfetch()subrequest made within that request's execution context, and appends, cloudflare; loops=1:cdn-loop: deno;d=<deployment_hash>, cloudflare; loops=1 - When that outbound request arrives at Deno Deploy (
deployd), Deno Deploy inspectscdn-loop, sees its owndeno;d=<deployment_hash>token already in the chain, concludes the deployment is fetching itself in an infinite loop, and rejects the request with508 Loop Detected.
Because Cloudflare attaches CDN-Loop in its C++ edge layer below the
JavaScript Request and Headers objects, calling headers.delete("cdn-loop")
in Worker code does nothing.
why ctx.waitUntil and our first durable object alarm still leaked cdn-loop
Our first thought was to run the agent inside ctx.waitUntil(...) after
returning 200 OK to the webhook. That failed with the exact same 508: in
workerd, ctx.waitUntil() shares the incoming HTTP request's IoContext, so
every outbound fetch() inside waitUntil still inherits the incoming
cdn-loop token.
To get a clean root IoContext with no inherited cdn-loop header, the
outbound fetch() has to originate from a Durable Object alarm()
invocation, which is triggered by Cloudflare's internal alarm scheduler rather
than an incoming HTTP request.
So we routed incoming webhooks into
ConversationDO.scheduleTask(id, Date.now(), payload), which wrote the task to
SQLite and armed ctx.storage.setAlarm(Date.now()).
And then, under concurrent traffic, the 508 Loop Detected error came back!
Tracing it revealed a subtle workerd runtime detail. Our initial alarm()
implementation used a while (this.activeTasks.size > 0) drain loop so that if
a new task arrived while an alarm was already running, scheduleTask() could
wake the running loop immediately by resolving an in-memory Promise
(this.resolveNewTaskSignal()).
In workerd, resolving an in-memory Promise from inside an incoming RPC call
(scheduleTask) propagates the caller's incoming HTTP IoContext (including
its cdn-loop header!) into the awaiting alarm() continuation. Every task
spawned by that awakened loop inherited the second webhook's cdn-loop token
and 508'd again.
The fix (58d125af6) was to never signal a running alarm() from an RPC call,
and never block inside alarm():
async scheduleTask(
id: string,
runAt: number,
payload: unknown,
): Promise<void> {
this.sql.exec(
"INSERT OR REPLACE INTO scheduled_tasks (id, run_at, payload) VALUES (?, ?, ?)",
id,
runAt,
JSON.stringify(payload),
);
await this.updateAlarm();
}
async alarm(): Promise<void> {
this.startDueTasks();
if (!this.doCtx?.waitUntil && this.activeTasks.size > 0) {
await Promise.all([...this.activeTasks]);
}
await this.updateAlarm();
}
When alarm() fires, startDueTasks() deletes the due rows from
scheduled_tasks, launches each task asynchronously into
this.doCtx.waitUntil(promise), and alarm() returns immediately. Because
alarm() exits right away, every subsequent scheduleTask() call arms a
brand-new setAlarm(Date.now()) tick from Cloudflare's scheduler, guaranteeing
that every task executes in a clean root IoContext with zero inherited
headers.
We opened a support ticket with Cloudflare (#02373342) with a minimal
reproduction. Cloudflare Support confirmed the runtime behavior:
"Appreciate your detailed testing. You are correct about the runtime behaviour. To prevent Deno Deploy from detecting its own hop marker, the outbound
fetchmust run from an independent root execution context outside the incoming HTTP request lifecycle."
When we asked whether the Workers runtime team could add a compatibility flag or
avoid copying third-party CDN-Loop tokens onto fresh outbound subrequests,
they forwarded the feature request to the Workers engineering team and closed
the ticket since changing loop-prevention behavior requires platform roadmap
review. So the Durable Object alarm hop isn't a temporary hack; it is the
canonical way to break CDN-Loop inheritance on Cloudflare Workers.
4. other architectural differences that bit us
ctx.waitUntil has a 30-second wall-clock cap; durable object alarms don't
Even for webhooks that don't come from Deno Deploy (like WhatsApp, Telegram, or
AgentMail), running an AI agent inside ctx.waitUntil() on a stateless Worker
turned out to be a trap.
In wrangler.jsonc, we set "limits": { "cpu_ms": 300000 } (5 minutes of CPU
time). What the docs don't make obvious is that cpu_ms only raises the CPU
limit. Once a Worker returns an HTTP response to the webhook caller,
ctx.waitUntil() still enforces a hard 30-second wall-clock timeout.
An AI agent turn is almost entirely network I/O wait time, not CPU time:
streaming LLM reasoning tokens, executing remote tools, waiting on web search or
image generation, and sending replies. A complex turn easily takes 35 to 90
seconds of wall time while burning less than 200ms of CPU. In ctx.waitUntil(),
Cloudflare silently terminated the isolate at the 30-second mark mid-turn.
Durable Object alarm() handlers don't have that 30-second post-response
wall-clock cutoff, and this.doCtx.waitUntil() keeps the Durable Object alive
while its promises are in flight. Moving all channel webhooks onto Durable
Object alarms (e22139f3f) solved both the 508 issue and the 30-second
wall-clock truncation in one stroke.
sharding runner durable objects per conversation (f5833873c)
Once every agent turn was running inside a Durable Object, we had to think carefully about how Durable Objects are sharded:
- Attempt 1: Run everything in the single
enam-v1storage DO. Because each Durable Object is single-threaded, heavy synchronous work in an agent turn (compiling SafeScript ASTs, projecting large conversation histories, parsing big tool payloads) contended withenam-v1's main job: serving sub-millisecond SQLite and KV RPCs for all other conversations. - Attempt 2: Shard a fresh Runner DO per task (
runner:${taskId}). This isolated compute away fromenam-v1, but because every task ID is a new UUID, every single incoming message spun up a brand-new, cold Durable Object isolate. That threw away our in-memory per-isolate caches (our versionstamp-validated conversation history cache and our compiled SafeScript AST cache). - Final architecture: Shard Runner DOs per conversation
(
runner:${botId}:${conversationId}), backed by the sharedenam-v1storage DO.
export const runnerShardKey = (
botId: string | undefined,
conversationId: string | undefined,
taskId: string,
): string =>
botId && conversationId && botId !== "global" &&
conversationId !== "bot-tasks"
? `${botId}:${conversationId}`
: taskId;
export const getRunnerDO = (
ns: ConversationDONamespace,
runnerKey: string,
): ConversationDO =>
ns.get(ns.idFromName(`runner:${runnerKey}`), { locationHint: "enam" });
When a webhook arrives at the stateless edge Worker, it hands the payload (along
with any pre-warmed bot config from the Worker isolate's memory) to
runner:${botId}:${conversationId} via scheduleTask. The conversation's
Runner DO fires its alarm in ~10ms in a clean root IoContext, already warm
from the previous turn of that conversation, with its history and SafeScript AST
caches hot in RAM, while enam-v1 stays a pure, uncontended SQLite storage
engine.
websockets and AsyncLocalStorage context loss (727b0d360)
Our codebase uses @uri/inject (built on node:async_hooks
AsyncLocalStorage) to pass database connections, KV stores, API tokens, and
analytics loggers down the call stack without threading parameters through every
function.
On Deno Deploy, Deno.upgradeWebSocket(req) and all
ws.addEventListener("message", ...) callbacks naturally preserve the
AsyncLocalStorage context that was active when the WebSocket was upgraded.
On Cloudflare Workers (using new WebSocketPair()), we discovered during live
Twilio voice calls (/twilio-voice/stream) that AsyncLocalStorage context was
getting dropped inside WebSocket message and close event listeners and
setInterval billing ticks. Calls would connect and then fail deep inside the
agent runner with missing dependency context errors.
Capturing const runInDepsContext = AsyncLocalStorage.snapshot() right after
initializing the dependencies and wrapping the WebSocket event listeners and
setInterval callbacks in runInDepsContext(() => ...) restored full context
propagation across the lifetime of the voice stream.
cross-request TCP sockets and Error 1101
When we migrated f0mo.com (agent-fomo) and api.find-scene.com
(find-scene), both servers deployed cleanly and then started throwing
Cloudflare Error 1101
(Worker threw exception: Cannot perform I/O on behalf of a different request)
on the second or third request to hit a warm isolate.
On Deno Deploy, keeping a module-level MongoClient or redis TCP client (or
memoizing an in-flight Promise across requests) is normal. On Cloudflare
Workers, any TCP socket opened during Request A is bound to Request A's
IoContext. If Request B reuses that pooled socket, or awaits a cached Promise
whose underlying socket was opened by Request A, or if a background cache write
(rmmbr) finishes after Request A has already returned its Response without
being registered on ctx.waitUntil(), workerd kills the isolate immediately.
We fixed this with three rules across our stateless Workers:
- Scope TCP clients per request. Using
@uri/inject, our Workerfetchandscheduledentrypoints create a freshMongoClientorredisclient at the start of each request (injectMongo,injectNewKvStore) and close it in afinallyblock. - Never cache in-flight Promises across requests. If an in-memory TTL cache
stores a Promise before it resolves, a concurrent or subsequent request
shares the first request's I/O handle. Storing only the resolved value
(
Promise.resolve(val)) avoids cross-request Promise sharing. - Flush background cache writes in
ctx.waitUntil(). Libraries likermmbrfire asynchronous cache writes in the background after a cache miss. Passingctx.waitUntil(waitAllWrites())before returning theResponsekeeps the request'sIoContextalive until all pending writes settle.
node SDKs and cloudflare edge settings that bite in production
Even with "compatibility_flags": ["nodejs_compat"], a few subtle behaviors
broke production flows until we traced them end-to-end:
Gaxios(google-auth-libraryand@google-cloud/storage) checkingtypeof window: Google'sgaxiosHTTP client checkstypeof window !== "undefined" && !!window.fetchto decide whether to use nativefetchor fall back tonode-fetch(node:https). Becausewindowis undefined in Cloudflare Workers,gaxiossilently fell back tonode-fetch, which failed insideworkerdwhen signing Google Cloud Pub/Sub and Storage OAuth tokens infind-scene. Patchingwindow.fetchtoglobalThis.fetchandtypeof window < "u" && !!windowtotrueinbuildWorker.tsforced the entire Google Auth and Storage stack onto native Workerfetch.- Cloudflare's default
browser_checkblocking Python/Modal callbacks (error code: 1010): When you create a new Cloudflare zone for a domain, Cloudflare enables Browser Integrity Check (browser_check: "on") by default. Infind-scene, our GPU video-cutting workers on Modal post progress updates and completion callbacks back toapi.find-scene.comusing Python'surllib.request(User-Agent: Python-urllib/3.11). Cloudflare's edge blocked every Python callback withHTTP 403: error code: 1010before it ever reached the Worker, leaving download progress bars stuck in the chat UI. Turning offbrowser_checkvia the Cloudflare zone settings API fixed it immediately. - Cloudflare's subrequest cache stripping
Rangeheaders on.mp4URLs (Error 1102: Exceeded Memory Limit): To match exact subtitle timings infind-scene, we compute an OpenSubtitles file hash by reading the first and last 64KB of the video file via HTTPRangerequests (Range: bytes=0-65535). That worked for.mkvfiles, which aren't in Cloudflare's default cached extensions list. For.mp4files, Cloudflare's edge cache intercepted the Worker'sfetch()subrequest as a cacheMISS, stripped theRangeheader so it could cache the whole file, and streamed a 2GB200 OKresponse into the Worker until it blew past the 128MB memory limit (Error 1102). Passingcache: "no-store"on rangefetch()calls, verifying206 Partial Contenton non-zero offsets, and explicitly capping and cancelling theReadableStreamreader after 64KB fixed it. @google-cloud/storagemedia uploads: Inprompt2bot, callingfile.save(Buffer.from(bytes))to upload chat media attachments hung inside@google-cloud/storage's Node stream stack on Workers. Replacingfile.exists()andfile.save()with directfetch()calls against Google Cloud Storage's JSON REST API (https://storage.googleapis.com/upload/storage/v1/b/.../o?uploadType=media), authenticated with a Web Crypto JWT bearer token, made uploads faster and eliminated a huge dependency path.Deno.resolveDns: Our SSRF guard (ssrfGuard.ts) callsDeno.resolveDns(host, "A")and"AAAA"to block user-supplied URLs that resolve to private IP ranges (127.0.0.0/8,10.0.0.0/8,169.254.169.254, etc.). Workers doesn't expose raw DNS resolution APIs (and Cloudflare's egress network already blocks fetches to private/loopback IPs at the edge), so we guardedDeno.resolveDnsbehind a feature check when running on Workers while keeping the hostname rules and Deno test coverage intact.
where we landed
We still write 100% Deno code. Every test runs with deno test -A --env, every
package comes from deno.json, and pushing to main bundles the server with
deno bundle and deploys it to Cloudflare Workers in under a minute.
The migration forced us to separate stateless edge intake, per-conversation
compute isolates (runner:${botId}:${conversationId}), and always-warm SQLite
storage (enam-v1), which ended up making the system both faster and more
predictable than what we had before.
This architecture is what makes prompt2bot able to scale agents so easily. You describe an agent in a plain prompt, connect it to WhatsApp, Telegram, email, voice, or web chat, and the platform gives every active conversation its own isolated, warm Durable Object runner with sub-ms state, in-process SafeScript execution, and on-demand VMs. Whether your bot has one user or ten thousand concurrent conversations, there are no queues, worker pools, or servers to provision.