Skip to content

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:

test/fixtures/static/app.ts
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:

packages/docs/deploy/docs.ts
/** 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.

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. A URL from import.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 nearest 404.html), or 'single-page-application' (index.html, for an app that routes in the browser).
  • html: how .html and trailing slashes go, Cloudflare’s auto-trailing-slash by default. So /about.html redirects 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:

Terminal window
pnpm infra dev test/fixtures/static/app.ts

When 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.

Most sites want a little code too. Give the Worker a fetch, and say which paths are the code’s with runWorkerFirst:

test/fixtures/site/web.ts
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 fetch only for what they don’t have, or for the runWorkerFirst paths.
  • Site is 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.

test/fixtures/site/web.ts
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.

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:

test/fixtures/site/web.ts
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:

web.ts
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 --account says 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 destroy detaches them all before deleting the Worker.
$ pnpm infra graph test/fixtures/site/app.ts
site-web (fetch)
SITE: assets file:///…/test/fixtures/site/public/
TITLE: var (a value in the declaration)
route api.example.com/v1/*
domain site.example.com

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:

example/links/links.api.ts
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:

example/links/links.ts
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 drop Clicks from bind and it’s used-undeclared, as for fetch.
  • Each request is an invocation, as a fetch handler’s is: a route gets Worker.Request, Worker.waitUntil and your requestLayer’s services. The API’s Layer is built once per isolate, with the Worker’s resources and layer.
  • Plain routes sit beside it for what an endpoint can’t be: a redirect here, a WebSocket upgrade in example/chat’s api.ts, a byte stream in example/shop.

http and fetch are one or the other: a Worker with both is refused.

Assets.memory serves files from an object, and Var.memory provides a variable:

test/site.test.ts
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.

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.

  • Checked live: a preview served its files (with Cloudflare’s redirect of /about.html to /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 an HttpApi carries 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.