An app is an ES module whose default export holds handler functions. Each handler gets the event (a request, a schedule tick, a batch of queue messages or an email), env (bindings, variables and secrets) and ctx (lets work continue after the response). The runtime is workerd, the same as Cloudflare Workers, so code and libraries written for Workers run as is.
Split code into modules and import them with relative paths. A typical layout:
src/
index.js entry: routing only
routes/links.js request handlers for one feature
lib/db.js storage access
lib/http.js json(), errors, CORS helpers
assets/page.html
README.md
wrangler.jsonc so `rr deploy` works too
Set the entry file on the app's page (or main in the config).
Files ending in .html, .css, .svg, .txt, .md, .csv and .xml are imported as strings: import page from "./assets/page.html". .json is imported parsed; other extensions as bytes (Uint8Array).
The console editor uploads files as they are, so write JavaScript there. TypeScript and npm packages need a build: deploy with rr deploy (esbuild) or connect a Git repository with a build command.
Return quickly and keep working: anything passed to ctx.waitUntil() keeps running after the response is sent, for example writing a log or updating a counter.
js
export default {
async fetch(request, env, ctx) {
const slug = new URL(request.url).pathname.slice(1);
const link = await env.DB.prepare("SELECT url FROM links WHERE slug = ?").bind(slug).first();
if (!link) return new Response("not found", { status: 404 });
ctx.waitUntil(
env.DB.prepare("UPDATE links SET clicks = clicks + 1 WHERE slug = ?").bind(slug).run(),
);
return Response.redirect(link.url, 302);
},
};
Plain variables (vars in the config, or Settings → Variables) are strings on env: env.GREETING.
Secrets (rr secret put NAME, or a variable marked secret) are also strings on env, but their values are never shown again in the console, the API or rr compute get.
Never put keys in code or in plain variables. rr warns when a variable named like API_KEY or TOKEN is set without --secret.
env also has NODE_LABEL, NODE_REGION and NODE_IP: which edge node is answering.
Node.js modules: add the nodejs_compat compatibility flag (config compatibility_flags, or Settings) and import node:buffer, node:crypto, node:events, node:stream, node:util and the like. Modules that need a filesystem or child processes are not available.
fetch() reaches any public HTTPS or HTTP address. Private and internal addresses are blocked. Outbound requests leave from the edge node that runs the code, so the source IP is that node's.
/_edge/net.js does network work from the node serving the request, from a random node, or from a random node in a region you choose, so the target sees that node's address: an HTTP request in any HTTP version ("1.1", "2", "3" or "auto"), ping, a TCP connect or exchange, a UDP round trip, or a DNS lookup. Targets must be public addresses, and outbound port 25 is blocked.
js
import { fetchVia, ping, tcp, udp, dns, regions } from "/_edge/net.js";
export default {
async fetch(request) {
// An HTTP/3 request from a random node in us-west.
const res = await fetchVia("https://cloudflare.com/cdn-cgi/trace", {}, { region: "us-west", httpVersion: "3" });
const trace = await res.text();
// Latency to 1.1.1.1 from three regions at once.
const pings = await Promise.all(
["asia-east", "eu-west", "us-west"].map((region) => ping("1.1.1.1", { region, count: 3 })),
);
// Is port 443 open, seen from any node? (send/readBytes for a banner)
const port = await tcp("example.com", 443, { region: "random" });
// One UDP datagram out, one back: { reply, rttMs, timedOut }.
// const r = await udp("203.0.113.9", 27015, queryBytes, { region: "eu-west" });
// A lookup through 8.8.8.8 from a node in asia-east.
const aaaa = await dns("example.com", { type: "AAAA", server: "8.8.8.8", region: "asia-east" });
return Response.json({
node: res.headers.get("x-rf-node"),
proto: res.headers.get("x-rf-proto"),
trace,
pings: pings.map((p) => ({ node: p.node.label, region: p.node.region, avgMs: p.avgMs, loss: p.loss })),
connectMs: port.connectMs,
aaaa: aaaa.records,
regions: await regions(),
});
},
};
Where it runs: { region: "local" } (the default) is the node serving the request, "random" any node, a region name such as "us-west" a random node there (another one is tried if the first is unreachable), and { node: "hk-1" } one node; regions() lists the regions and their nodes. fetchVia returns a normal Response with x-rf-node, x-rf-region, x-rf-proto and x-rf-timing (DNS, connect, TLS, first byte and total, in ms) added. Each app may start 20 operations a second (bursts of 40); up to 1 MiB is sent, 8 MiB returned, and an operation lasts at most 30 seconds.
Apple Push Notification service (APNs) only accepts HTTP/2. fetchHttp2() from /_edge/http2.js is fetchVia(input, init, { httpVersion: "2" }): the same arguments and Response as fetch().
js
import { fetchHttp2 } from "/_edge/http2.js";
export async function pushToIPhone(env, deviceToken, title, body) {
const jwt = await apnsJwt(env); // ES256 JWT from your .p8 key, cached ~50 minutes
const res = await fetchHttp2(`https://api.push.apple.com/3/device/${deviceToken}`, {
method: "POST",
headers: {
authorization: `bearer ${jwt}`,
"apns-topic": env.APNS_BUNDLE_ID,
"apns-push-type": "alert",
},
body: JSON.stringify({ aps: { alert: { title, body }, sound: "default" } }),
});
if (res.status !== 200) throw new Error(`APNs ${res.status}: ${await res.text()}`);
}
/_edge/net.js and fetchHttp2 exist only on the edge (not in rr dev), for JavaScript apps.