Skip to content

Workers and frameworks

A Worker app is JavaScript that runs on Cloudflare Workers for each request that gets past the platform’s sign-in. It can be a single file you wrote, a TypeScript project wrangler builds, or a framework app built with its Cloudflare adapter.

A directory with an index.js and no package.json is a Worker that goes up as it is:

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

The entry is the first of index.js, index.mjs, src/index.js, src/index.mjs, dist/index.js and dist/index.mjs, and it must be an ES module. Name another with --main or worker.main in jiayang.json.

Every module in the directory goes up beside 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 on this path. An entry that imports a package by name, like import { requireUser } from "@jiayang-cloud/sdk", or that imports a file which does, is built with wrangler instead, as a TypeScript entry is, so the package goes into the Worker:

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

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

error: index.js imports @jiayang-cloud/sdk, which isn't installed, and the deploy bundles it into the Worker. 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"]
}

jiayang deploy runs wrangler deploy --dry-run --outdir into a scratch directory, which builds the Worker without sending it anywhere, and uploads what wrangler wrote. It uses the project’s own wrangler when node_modules has version 4 or later. Otherwise it runs npx --yes wrangler@4.134.0. Set JIAYANG_WRANGLER to the path of another wrangler to use that one.

A TypeScript entry, or a JavaScript one that imports a package, with no wrangler config gets a config written for the build and thrown away after, so a project can be a single src/index.ts.

The config read afterwards is the one the build used. A build tool that generates its own config and points .wrangler/deploy/config.json at it is followed, and that config is checked the same way as yours.

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

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

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

A build that fails stops it too:

error: the build failed. Fix it where you'd normally run it, then deploy again.

The compatibility date comes from --compatibility-date, then jiayang.json, then the wrangler config, and is 2026-09-01 when none of them sets one. It has to be a real date, and not one in the future.

Four compatibility flags are allowed: nodejs_compat, nodejs_compat_v2, nodejs_als and global_fetch_strictly_public. Any other is refused before the build:

error: wrangler.jsonc asks for the compatibility flag streams_enable_constructors, which this platform doesn't allow. Allowed: nodejs_compat, nodejs_compat_v2, nodejs_als, global_fetch_strictly_public.

From compatibility date 2026-08-04, Cloudflare turns nodejs_compat and nodejs_compat_v2 on by default, so those two matter only to a Worker that pins an earlier date. nodejs_als has no such date.

These are on env:

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

The three JIAYANG_ values are what the SDK reads to check who is calling. Every request carries a signed identity token in X-Jiayang-Identity, and requireUser(request, env) from @jiayang-cloud/sdk verifies it. Node.js shows it in a Worker.

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

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

jiayang deploy adds a small module, __jiayang_platform.js, that runs before your code. Where caches.default is off, it puts a cache there that never holds anything: match finds nothing, and put and delete do nothing. Your Worker’s own module is loaded through __jiayang_entry.js, which runs that module first and hands on everything yours exports. Those two names are taken: a Worker with a module of its own by either name is refused.

caches.open("name") is left as it is. Each app’s caches are its own. OpenNext’s regional cache and Hono’s cache middleware use it.

The platform hands out one binding, the app’s D1 database, bound as DB. A config that asks for anything else is refused before the build, with everything wrong listed at once:

error: wrangler.jsonc asks for things this platform doesn't hand out:
vars (API_BASE): plain values belong in `jiayang env set`
jiayang env set <app> API_BASE=<value>
kv_namespaces (CACHE): the platform doesn't hand out KV
Refused Why
vars Plain values belong in jiayang env set.
kv_namespaces, r2_buckets, queues, ai, vectorize, hyperdrive, browser, send_email, workflows The platform doesn’t hand these out.
services A service binding would reach another Worker directly.
durable_objects Its fetches would skip the egress proxy.
dispatch_namespaces Only the platform dispatches.
mtls_certificates Certificates belong in jiayang secret set.
containers A container app is deployed from a Dockerfile.
unsafe Unsafe bindings are whatever Cloudflare adds next.
routes, route, triggers, crons, zone_id, workers_dev_preview An app answers only at its own URL, through the edge.

A D1 binding is allowed when it’s named DB. The platform binds the app’s own database there, whatever database_id says. Any other name is refused:

error: wrangler.jsonc binds D1 as MAIN_DB; the platform gives each app 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 and $schema.

These are allowed too, but do nothing here, and the deploy says so 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 The platform keeps every app’s logs itself. jiayang logs reads them: see Logs.
placement The platform decides 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 the platform doesn’t hand out:

error: wrangler.jsonc has tail_consumers, which jiayang doesn't know how to deploy. Take it out, or say what the app needs and it can be looked at.

A config a build tool generated is read for what it asks for, since those list every binding type empty.

A framework app is a Worker when 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 it in the project: jiayang deploy builds with wrangler only when there’s a wrangler config or a TypeScript entry. For an Astro project with its wrangler config, jiayang detect says:

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 moves a project between kinds, but an app keeps the kind it was created with. A Next.js app that shipped as a container stays one: deploy the OpenNext version to a new app.

A framework build is a Worker and a directory of files, named by assets.directory in the config the build used. The files go up the same way a static site’s do, with the same limits and the same checks. The directory has to be inside the app.

_worker.js, _worker.js.map and _routes.json are never served, as files or as directories. Astro and SvelteKit write the Worker inside the directory they serve, so this keeps your server’s source off the web.

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, for the files only, not for what your Worker answers. Use it to let browsers keep hashed files, so a return visit doesn’t ask for each one again.

@opennextjs/cloudflare runs the Next.js build itself, so jiayang 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

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

With output: "server", Astro’s own CSRF check (security.checkOrigin) refuses any request other than a GET whose Origin header doesn’t match the site. A browser always sends one, and a script, a webhook or jiayang curl doesn’t. Every request has already been through the platform’s sign-in before your app sees it, so an app with endpoints for machines 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 },
});

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

That 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". With no start script in package.json, the container starts with node build, which is 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 own output is what goes up. 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

With no build script, and nothing under build in jiayang.json, nothing is 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 1,000
Files a framework serves the static site limits

A live key in the code stops the deploy. Keys belong in secrets, which your code never holds:

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