Skip to content

Workers and frameworks

A Worker app is JavaScript that runs on Cloudflare Workers for each request that passes our sign-in. It can be a single file, a TypeScript project that wrangler builds, or a framework app built with its Cloudflare adapter.

A directory with an index.js and no package.json is a Worker, uploaded as it is:

index.js
export default {
async fetch(request, env) {
return new Response(`Hello from ${new URL(request.url).pathname}`);
},
};
Terminal window
forculus deploy acme/hello . --create
a Worker
because it has index.js
Version 1 (0c4e9f3a7d21) is live.
https://hello--acme.forculuslabs-apps.tech

The entry is the file package.json names as module or main, when it has a default export, or else the first of index.js, index.mjs, src/index.js, src/index.mjs, dist/index.js, dist/index.mjs, worker.js, worker.mjs, src/worker.js and src/worker.mjs. It must be an ES module. Name another with --main or worker.main in forculus.json.

Every module in the directory is uploaded with it: .js and .mjs as ES modules, .wasm as WebAssembly, and .txt, .html, .css, .md, .svg and .json as text. Dotfiles, node_modules and symlinks are left out. worker.include limits the upload to the directories you list.

Nothing is bundled in this case. If the entry imports a package by name, like import { requireUser } from "@forculus/sdk", or imports a file that does, it’s built with wrangler like a TypeScript entry, so the package is bundled into the Worker:

a Worker
because it has index.js, builds with wrangler, since index.js imports @forculus/sdk

The package must be installed first. If it isn’t, the deploy stops before the build:

error: index.js imports @forculus/sdk, which isn't installed. The deploy bundles it into the Worker, so run `npm install` or your package manager's install, then deploy again.

A project with a wrangler config (wrangler.jsonc, wrangler.json or wrangler.toml), or with a TypeScript entry (src/index.ts, index.ts, src/worker.ts or worker.ts), is built with wrangler:

wrangler.jsonc
{
"name": "hello",
"main": "src/index.ts",
"compatibility_date": "2026-09-01",
"compatibility_flags": ["nodejs_compat"]
}

forculus deploy runs wrangler deploy --dry-run --outdir into a scratch directory, which builds the Worker without deploying it, then uploads what wrangler wrote. It uses the project’s own wrangler if node_modules has version 4 or later, and otherwise runs npx --yes wrangler@4.134.0. To use another wrangler, set FORCULUS_WRANGLER to its path.

If a TypeScript entry, or a JavaScript entry that imports a package, has no wrangler config, forculus writes a temporary one for the build. A project can be a single src/index.ts.

After the build, forculus reads the config the build used. If a build tool generates its own config and points .wrangler/deploy/config.json at it, forculus reads that one and checks it the same way as yours.

forculus deploy installs dependencies only for a project with a build script in package.json, or a forculus.json that sets build.command, build.install or build.output. For a TypeScript Worker with none of these, run npm install (or your package manager’s install) before you deploy.

From a build, .js and .mjs are uploaded as ES modules, .wasm as WebAssembly, .txt, .html, .css, .json, .md and .svg as text, and .bin and .dat as data. Source maps (.map) aren’t uploaded. A Worker can’t load CommonJS, so a build that writes .cjs stops the deploy:

error: The build wrote chunks/legacy.cjs, but a Worker can't load CommonJS. Build to ES modules.

So does a failed build:

error: The build failed. Fix it the way you normally would, then deploy again.

The compatibility date comes from --compatibility-date, then forculus.json, then the wrangler config, and is 2026-09-01 if none of them sets one. It must be a valid date, and not in the future. forculus deploy --dry-run shows the date a flag or forculus.json sets, and forculus --json versions has the one each version runs with, as compatibility_date. A version’s sha256 names its code and files, not its date, so two versions that differ only in their date share one.

Five compatibility flags are allowed: nodejs_compat, nodejs_compat_v2, nodejs_als, global_fetch_strictly_public and no_nodejs_compat_v2, which Nitro writes into the config it generates for Cloudflare. Any other is refused before the build:

error: wrangler.jsonc asks for the compatibility flag streams_enable_constructors, which we don't allow. You can use nodejs_compat, nodejs_compat_v2, nodejs_als, global_fetch_strictly_public, no_nodejs_compat_v2.

From compatibility date 2026-08-04, Cloudflare turns on nodejs_compat and nodejs_compat_v2 by default, so you only need those two for a Worker that pins an earlier date. nodejs_als is never on by default.

Your Worker gets these on env:

Name What it is
DB The app’s database, a D1 database.
FILES The app’s own files, an R2 bucket.
ASSETS The files a framework build serves, when it serves any.
FORCULUS_APP_ID The app’s id, which the identity token’s audience has to match.
FORCULUS_IDENTITY_ISSUER Who signs the identity token.
FORCULUS_JWKS_URL Where the keys to check it are published.
Your variables Each environment variable you set, as a string.

The SDK reads the three FORCULUS_ values to check who is calling. Every request carries a signed identity token in X-Forculus-Identity, and requireUser(request, env) from @forculus/sdk verifies it. Node.js has an example in a Worker.

A Worker’s outbound fetch goes through our egress proxy, which adds your secrets to requests for the hosts they’re for.

Cloudflare turns caches.default off for Workers run the way we run them (Workers for Platforms). SvelteKit’s Cloudflare adapter reads it when its Worker loads, and Astro’s image endpoint reads it when it answers, so both would fail on every request.

To prevent that, forculus deploy adds a small module, __forculus_platform.js, that runs before your code. Where caches.default is off, it puts an empty cache in its place: match finds nothing, and put and delete do nothing. Your Worker’s own module is loaded through __forculus_entry.js, which runs __forculus_platform.js first and re-exports everything yours exports. Both names are reserved, so a Worker with its own module named either is refused.

caches.open("name") is unchanged, and each app’s caches are its own. OpenNext’s regional cache and Hono’s cache middleware use it.

Each app gets its D1 database, bound as DB, and its files, bound as FILES. Neither needs anything in the config. On Team and Business, an app whose forculus.json asks gets key-value storage and a queue, bound as KV and QUEUE. A config that asks for anything else is refused before the build, with every problem listed at once:

error: wrangler.jsonc asks for things we don't offer:
vars (API_BASE): set plain values with `forculus env set` instead
forculus env set <app> API_BASE=<value>
ai (AI): we don't offer Workers AI
Refused Why
vars Plain values belong in forculus env set.
r2_buckets The app’s own files come bound as FILES.
ai, vectorize, hyperdrive, browser, send_email, workflows We don’t provide these.
services A service binding would reach another Worker directly.
durable_objects Its fetches would skip the egress proxy.
dispatch_namespaces Only we route requests to apps.
mtls_certificates Certificates belong in forculus secret set.
containers A container app is deployed from a Dockerfile.
unsafe Unsafe bindings can be anything Cloudflare adds in future.
images We don’t provide Cloudflare Images.
routes, route, triggers, crons, zone_id, workers_dev_preview An app answers only at its own URL, through the edge.

A kv_namespaces binding named KV, and a queues producer named QUEUE, are allowed too, so the config you run wrangler dev with deploys as it is. We bind the app’s own there when its forculus.json asks, and the deploy says so. Any other name is refused, and so is a queue consumer: we send the queue’s messages to the path in forculus.json.

A D1 binding is allowed if it’s named DB. We bind the app’s own database there, whatever database_id says. Any other name is refused:

error: wrangler.jsonc asks for things we don't offer:
d1_databases (MAIN_DB): each app gets one database, bound as DB

These keys are allowed: name, main, compatibility_date, compatibility_flags, account_id, workers_dev, preview_urls, minify, keep_vars, keep_names, no_bundle, find_additional_modules, base_dir, preserve_file_names, rules, define, alias, tsconfig, build, dev, assets, d1_databases, kv_namespaces, queues and $schema.

These are also allowed but have no effect here. The deploy warns you when one is turned on:

Key Why it does nothing
upload_source_maps Source maps aren’t uploaded, so stack traces point into the built code.
observability We keep every app’s logs. Read them with forculus logs. See Logs.
placement We decide where an app runs.
limits The plan sets an app’s limits.
wrangler.jsonc sets upload_source_maps, which does nothing here: source maps aren't uploaded, so stack traces point into the built code.

Any other key in a config you wrote is refused, since it could ask for something we don’t provide:

error: wrangler.jsonc has tail_consumers, which we don't know how to deploy. Remove it, or write to support@forculuslabs.tech about what the app needs.

When the config asks for anything in the table too, these keys are listed with the rest, each as we don't know how to deploy it.

In a config generated by a build tool, only the bindings it actually asks for are checked, because such configs list every binding type, empty. An images binding there isn’t refused, since the app may never use it. The deploy says that the app can’t transform images as it runs.

A framework app is a Worker if it depends on the framework’s Cloudflare adapter. Without one, it’s a container or a static site, depending on whether it renders on a server:

Framework Worker with Otherwise
Next.js @opennextjs/cloudflare container
Astro @astrojs/cloudflare static site
SvelteKit @sveltejs/adapter-cloudflare or @sveltejs/adapter-cloudflare-workers container with @sveltejs/adapter-node, static site with @sveltejs/adapter-static, refused with any other
Nuxt nitro-cloudflare-dev container
React Router @react-router/cloudflare container, with @react-router/node
Remix @remix-run/cloudflare
Vite @cloudflare/vite-plugin static site
Hono hono or @hono/vite-build

An adapter’s setup guide has you write a wrangler config naming the Worker it builds and the directory of files it serves. Keep that config in the project, because forculus deploy builds with wrangler only when there’s a wrangler config or a TypeScript entry. For an Astro project with its wrangler config, forculus detect prints:

a Worker
because it has wrangler.jsonc, builds with `npm run build`, builds with wrangler, from wrangler.jsonc
install: pnpm install --frozen-lockfile
build: npm run build

Swapping one adapter for another changes the project’s kind, but an app keeps the kind it was created with. A Next.js app deployed as a container stays a container, so deploy the OpenNext version to a new app.

A framework build produces a Worker and a directory of files, named by assets.directory in the config the build used. The files are uploaded like a static site’s, with the same limits and checks. The directory must be inside the app.

_worker.js, _worker.js.map and _routes.json are not served, as files or as directories. Astro and SvelteKit write the Worker inside the directory they serve, so this keeps your server code from being served.

assets.not_found_handling (single-page-application, 404-page or none) and assets.run_worker_first are passed on. With neither set, a path with no file goes to your Worker.

A _headers file in that directory works as it does for a site. It applies to the files only, not to responses from your Worker. Use it to let browsers keep hashed files, so a return visit doesn’t request each one again.

@opennextjs/cloudflare runs the Next.js build itself, so forculus deploy runs ./node_modules/.bin/opennextjs-cloudflare build instead of your build script. Keep "build": "next build" as it is.

a Worker
because it has wrangler.jsonc, @opennextjs/cloudflare builds what gets deployed, after the framework's own build, builds with `./node_modules/.bin/opennextjs-cloudflare build`, builds with wrangler, from wrangler.jsonc
install: npm ci
build: ./node_modules/.bin/opennextjs-cloudflare build

The config and open-next.config.ts that npx @opennextjs/cloudflare migrate writes ask for three things we don’t offer, so the deploy stops before the build and lists them:

error: wrangler.jsonc asks for things we don't offer:
r2_buckets (NEXT_INC_CACHE_R2_BUCKET): where the platform has storage on, an app's own files come bound as FILES, with nothing to set up
services (WORKER_SELF_REFERENCE): a service binding would reach another Worker directly
images (IMAGES): we don't offer Cloudflare Images
OpenNext's own setup asks for these. Take services, r2_buckets and images out of the config, and the incrementalCache line out of open-next.config.ts.

Before the first deploy:

  1. In wrangler.jsonc, remove services, r2_buckets and images.
  2. In open-next.config.ts, remove the incrementalCache line and its import, so it reads export default defineCloudflareConfig({});.

Without the IMAGES binding, OpenNext doesn’t optimise images. Set images: { unoptimized: true } in next.config.ts, so <Image> serves each one as it is.

Without OpenNext, a Next.js app is a container started with npm start.

With output: "server", Astro’s CSRF check (security.checkOrigin) refuses any non-GET request whose Origin header doesn’t match the site. A browser always sends Origin, but a script, a webhook or forculus curl doesn’t. Every request has passed our sign-in before your app sees it, so an app with endpoints for scripts can turn the check off:

astro.config.mjs
import cloudflare from "@astrojs/cloudflare";
import { defineConfig } from "astro/config";
export default defineConfig({
output: "server",
adapter: cloudflare(),
security: { checkOrigin: false },
});

From @astrojs/cloudflare 14, the adapter keeps sessions in a key-value binding called SESSION, and transforms images with a Cloudflare Images binding called IMAGES. We bind an app’s key-value storage as KV, and we don’t offer Cloudflare Images. So name the sessions’ binding KV, and have images made as the site builds:

astro.config.mjs
export default defineConfig({
output: "server",
adapter: cloudflare({ sessionKVBindingName: "KV", imageService: "compile" }),
security: { checkOrigin: false },
});

If the app uses Astro’s sessions, also ask for key-value storage in forculus.json, which takes Team or Business:

{ "version": 1, "kv": true }

Without the sessionKVBindingName line, the deploy stops after the build and says what to set:

error: wrangler.json asks for things we don't offer:
kv_namespaces (SESSION): an app gets one namespace of its own, bound as KV, when forculus.json says "kv": true
Astro's adapter keeps its sessions in a binding called SESSION. Set sessionKVBindingName: "KV" in the adapter's options. If the app uses sessions, also set "kv": true in forculus.json.

An Astro site without the adapter deploys as a static site.

A project made with sv create has @sveltejs/adapter-auto, which builds only for hosts it recognises. The deploy stops before building and says which adapter to add. For a Worker:

Terminal window
npx sv add sveltekit-adapter="adapter:cloudflare+cfTarget:workers" && npm run gen

This installs @sveltejs/adapter-cloudflare, writes the wrangler.jsonc the build needs, and changes the build script to check the Worker’s types first. npm run gen writes those types.

For a container instead, add the Node adapter with npx sv add sveltekit-adapter="adapter:node". If package.json has no start script, the container starts with node build, where that adapter writes its server. @sveltejs/adapter-static makes a static site.

A Vite project with @cloudflare/vite-plugin builds with npm run build, and the plugin’s output is uploaded. Without the plugin, a Vite project is a static site.

Hono is a Worker. A Hono project with only a src/index.ts needs no wrangler config:

a Worker (Hono)
because package.json depends on hono, builds with wrangler, from src/index.ts

If there’s no build script and nothing under build in forculus.json, dependencies aren’t installed for you, so run npm install before the first deploy.

Code, all modules together 32 MiB
Modules 1,000
CPU time per request set by the plan: see Limits
Subrequests per request set by the plan: see Limits
Files a framework serves the static site limits

The two modules forculus deploy adds, __forculus_platform.js and __forculus_entry.js, don’t count toward the size or the number. Each of them can be up to 64 KiB instead. A Worker with more modules of its own is refused before anything is uploaded:

error: The Worker has 1001 modules, and it can have up to 1000. Deploy only what the app needs.

A live key in the code stops the deploy. Put keys in secrets instead, so your code doesn’t hold them:

error: src/config.js has what looks like a Stripe key in it. Remove it and use `forculus secret set`, which keeps it out of the app entirely.

A live Stripe key is one of the kinds always refused. Other keys, and packages with published vulnerabilities, warn or refuse as the workspace chooses. See Deploy scans.