Websites: files, routes, domains and HTTP APIs
A website on Cloudflare is a Worker with files. Here’s a whole one, with no code of its own:
import { App, Assets, Worker } from '@forge-effect/infra';
export class Pages extends Assets.Directory<Pages>()('pages', { directory: new URL('./public/', import.meta.url), notFound: '404-page',}) {}
export default App.make('static', { workers: [Worker.make('pages', {}).bind(Pages)] });A Worker with no handlers, binding a folder. It ships as Cloudflare’s
assets-only Worker: no code at all. Cloudflare answers every request
from the files, a missing page included (here, with 404.html), and
requests served from files are free and unlimited, so a static site can’t
run up a bill however much traffic finds it. infra check knows a Worker
of files alone has handlers enough.
This very guide ships that way. Its
deploy/docs.ts
is a Worker named docs, binding Starlight’s build:
/** The built site: `pnpm build` writes it to dist/. */export class Site extends Assets.Directory<Site>()('site', { directory: new URL('../dist/', import.meta.url), notFound: '404-page',}) {}
export default Worker.make('docs', {}).bind(Site);This page walks from there to a site with an API beside its files, values that differ per environment, its own domain, and a typed HTTP API.
Step 1: A folder of files
Section titled “Step 1: A folder of files”Assets.Directory declares the files, as a class, like every other
resource. It takes Cloudflare’s own settings, by Cloudflare’s names:
directory: where the files are. AURLfromimport.meta.url(as above) or a path from the app’s module.notFound: what a path with no file answers.'none'(a plain 404, the default),'404-page'(the nearest404.html), or'single-page-application'(index.html, for an app that routes in the browser).html: how.htmland trailing slashes go, Cloudflare’sauto-trailing-slashby default. So/about.htmlredirects to/about.runWorkerFirst: paths your code answers before the files are looked at. More on this next.
Run it locally and the files are there, 404 page and all:
pnpm infra dev test/fixtures/static/app.tsWhen you ship, each file is content-hashed, and only those Cloudflare doesn’t have yet are uploaded. Change one page of a hundred, and one page goes up.
Step 2: Files and an API beside them
Section titled “Step 2: Files and an API beside them”Most sites want a little code too. Give the Worker a fetch, and say which
paths are the code’s with runWorkerFirst:
export class Site extends Assets.Directory<Site>()('site', { directory: new URL('./public/', import.meta.url), notFound: '404-page', runWorkerFirst: ['/api/*'], // these go to fetch; the rest to the files first}) {}
const fetch = Effect.gen(function* () { const request = yield* Worker.Request; const path = new URL(request.url).pathname; if (path === '/api/title') { return Response.json(yield* Title); } // Anything else the files don't have. return yield* (yield* Site).fetch('/index.html');});Two things to notice:
- Cloudflare answers a path from the files when it can, and calls your
fetchonly for what they don’t have, or for therunWorkerFirstpaths. Siteis also a client.(yield* Site).fetch(path)asks for a file the way Cloudflare would serve it, so your code can answer with one.
A Worker serves one folder of files. Bind two and it’s refused where you
declare it (assets-twice), and a directory that doesn’t exist is
assets-missing in infra check.
Step 3: Values that differ per environment
Section titled “Step 3: Values that differ per environment”That Title is a variable: a value the Worker ships with, typed by a
schema.
export class Title extends Var.Make<Title>()('title', Schema.Struct({ text: Schema.String, beta: Schema.Boolean }), (env) => ({ text: env === 'production' ? 'The site' : `The site (${env})`, beta: env !== 'production',})) {}The third argument is the value: one value for every environment, or, as
here, a function of the environment’s name. Production gets “The site”,
and a preview shipped with --env pr-3 gets “The site (pr-3)”.
In a handler, yield* Title is the value, decoded, so it’s
{ text: string; beta: boolean }, not a string you parse. Each ship
sets it. A value the schema refuses never ships: it’s var-invalid in
infra check, for the environment you check.
Step 4: Your own domain
Section titled “Step 4: Your own domain”A Worker that serves HTTP (a fetch, an HTTP app, or files) answers at
<app>-<worker>.<you>.workers.dev from its first ship. To serve it at your own domain, name it:
export default Worker.make('web', { fetch, domains: ['site.example.com'], routes: ['api.example.com/v1/*'],}).bind(Site, Title);- A custom domain makes the Worker the origin of a whole hostname. Cloudflare sets up its DNS record and certificate.
- A route is a pattern on a zone’s traffic,
host/path, with no scheme. The Worker answers what matches; the rest goes where it went.
A list like this is production’s alone. If every preview took
site.example.com too, your first --env pr-7 would take production’s
traffic. To give other environments their own, write a function of the
environment’s name:
export default Worker.make('web', { fetch, // Production at its own domain; each preview at one of its own. domains: (env) => (env === 'production' ? ['shop.example.com'] : [`${env}.shop.example.com`]), // A route, in the zone it names. routes: [{ pattern: 'example.com/shop/*', zone: 'example.com' }],}).bind();A route is in the zone its host ends with, or the one it names. And a
Worker that should only answer on your domain can leave workers.dev:
workersDev: false.
What a ship does with them:
- It checks first. A route or domain in no zone of your account stops
the ship before anything is made (
zone-missing;infra check --accountsays so too). One that serves another Worker stops it as well (route-taken), naming whose it is: another app’s, another environment’s, or one the tool didn’t make. If the move is meant, ship with--take-routes. - It attaches them once the new version is live, and records them as the app’s.
- It tidies up. One you stop declaring is detached by the next ship.
infra destroydetaches them all before deleting the Worker.
$ pnpm infra graph test/fixtures/site/app.tssite-web (fetch) SITE: assets file:///…/test/fixtures/site/public/ TITLE: var (a value in the declaration) route api.example.com/v1/* domain site.example.comStep 5: A typed HTTP API
Section titled “Step 5: A typed HTTP API”For more than a route or two, Effect has
HttpApi: each endpoint’s path, payload,
success and errors as schemas. The server decodes and encodes with them, a
client can be typed from them, and OpenAPI describes them. A Worker serves
one with http instead of fetch.
The link shortener in
example/links
starts with the API, schemas only, in a file of its own:
export class LinksGroup extends HttpApiGroup.make('links').add( HttpApiEndpoint.post('create', '/links', { payload: Schema.Struct({ url: Schema.String, slug: Schema.optional(Slug) }), success: Link.pipe(HttpApiSchema.status(201)), error: [SlugTaken.pipe(HttpApiSchema.status(409)), TooManyLinks.pipe(HttpApiSchema.status(429)), NotAUrl.pipe(HttpApiSchema.status(400))], }), HttpApiEndpoint.get('get', '/links/:slug', { params: { slug: Schema.String }, success: Link, error: LinkNotFound.pipe(HttpApiSchema.status(404)) }),) {}
export class LinksApi extends HttpApi.make('links').add(LinksGroup) {}Then the handlers, which use the app’s resources as any handler does, and
the Worker, which serves them with Http.app from
@forge-effect/infra/http:
import * as Http from '@forge-effect/infra/http';
const links = HttpApiBuilder.group(LinksApi, 'links', (handlers) => handlers.handleAll({ create: ({ payload }) => Effect.gen(function* () { const request = yield* Worker.Request; // the invocation's services are here too if (!(yield* Effect.orDie((yield* Creates).limit(request.headers.get('cf-connecting-ip') ?? 'unknown')))) { return yield* new TooManyLinks(); // answered 429, as the API declares } // … }), // … }),);
// The redirect, which counts the click: a plain route beside the API.const redirect = HttpRouter.add('GET', '/:slug', (request) => /* … */);
export default Worker.make('links', { http: Http.app(Layer.mergeAll(HttpApiBuilder.layer(LinksApi, { openapiPath: '/openapi.json' }).pipe(Layer.provide(links)), redirect, root)),}).bind(Links, Creates, Clicks);What makes it fit:
- The compiler still checks the bindings. What the routes use (
Links,Creates,Clicks) counts toward the Worker’s, so dropClicksfrombindand it’sused-undeclared, as forfetch. - Each request is an invocation, as a
fetchhandler’s is: a route getsWorker.Request,Worker.waitUntiland yourrequestLayer’s services. The API’s Layer is built once per isolate, with the Worker’s resources andlayer. - Plain routes sit beside it for what an endpoint can’t be: a
redirect here, a WebSocket upgrade in
example/chat’sapi.ts, a byte stream inexample/shop.
http and fetch are one or the other: a Worker with both is refused.
Test it
Section titled “Test it”Assets.memory serves files from an object, and Var.memory provides a
variable:
test('in memory, the handlers get the variable and the files', async () => { const layer = Layer.mergeAll(Var.memory(Title, { text: 't', beta: true }), Assets.memory(Site, { '/index.html': 'home' })); const call = (path: string) => Effect.runPromise(Effect.flatMap(Worker.handle(web, new Request(`https://x.dev${path}`)), (r) => Effect.promise(() => r.text())).pipe(Effect.provide(layer))); assert.equal(await call('/api/title'), '{"text":"t","beta":true}'); assert.equal(await call('/nowhere'), 'home');});An HttpApi Worker tests the same way: Worker.handle(links, request)
runs the whole router in memory. See
test/links.test.ts
and Testing.
How it works, briefly
Section titled “How it works, briefly”The files are part of the Worker’s deploy, not a separate site: ship hashes them as Wrangler does, opens an upload session, and sends only what Cloudflare asks for, before the Worker’s version goes up. So a version’s code and files roll out together, step by step, and roll back together.
A variable is a JSON binding in the Worker’s env (TITLE), set by each
ship from your declaration. Your handler never sees the JSON: the class
decodes it with its schema.
Good to know
Section titled “Good to know”- Checked live: a preview served its files (with Cloudflare’s
redirect of
/about.htmlto/about) and its own variable. - Routes and custom domains are tested against a fake Cloudflare only. The test accounts have no zones, so attaching to a real one hasn’t been tried yet. Production’s ship, whose domain was in no zone, did stop before making anything, as it should.
- Effect’s HTTP modules are in their own entry,
@forge-effect/infra/http, so only a Worker that serves anHttpApicarries them: about 300 KB, 64 KB gzipped.
- Secrets: the values that don’t go in your code.
- Plan and ship: what a ship does with all of the above.