Chapters Hosting

Chapter 9 of 13

Hosting

Static files with CORS for every origin, and an engine if you have one: Cloudflare in one command, or any host that meets the same rules.

You need something that serves static files with CORS for every origin, and, in engine mode, the engine. Any host that does both works. The template deploys to Cloudflare, which is recommended, not required:

  • static files are free and unlimited, so a host-mode game costs nothing to run, however many people play it;
  • the pages and the engine run in one Worker at one address, so there is one deploy and no order to keep;
  • the free plan allows 100,000 Worker invocations a day, and an engine is called once per move or timer.

With Cloudflare, both modes deploy with one command. To host elsewhere, see Hosting outside Cloudflare.

Host mode: static files only.

  • Cloudflare Pages (what the template uses): ADDON_ORIGIN=https://my-game.pages.dev pnpm run deploy. public/_headers sets CORS (Access-Control-Allow-Origin: *) and a five-minute cache on the JSON routes. Ship a 404.html next to the files (the template does): it keeps Pages from treating the project as a single-page app, and _headers then applies to the 404 as well.
  • Any other static host: GitHub Pages, Netlify, S3 behind a CDN, your own server. Make sure it sends the CORS headers (_headers is a Cloudflare/Netlify convention; other hosts have their own).

Engine mode: one Worker. The template’s --engine project is a single Cloudflare Worker with static assets: dist/ is served as assets, and /engine/* runs your reducer.

ADDON_ORIGIN=https://my-game.<your-account>.workers.dev pnpm run deploy   # vite build && wrangler deploy

There are no two deploys and no order to keep: the pages and the engine share one origin, so the manifest’s engine.url is <origin>/engine/<game>. The free plan allows 100,000 Worker invocations a day, one per move or timer; static assets are free. pnpm dev runs the same Worker locally.

Notes:

  • The routes need absolute URLs (uiUrl, logo, engine URLs), so build with your real origin: ADDON_ORIGIN=https://my-game.pages.dev pnpm build.
  • Game ids contain a colon (my-game:tap-race), and a static host serves meta/game/my-game:tap-race.json as a file named with a colon. That is fine on Linux, macOS and Cloudflare; a Windows checkout cannot hold such a file, so build on CI or WSL.
  • Serve everything over HTTPS. CamTable only installs https manifests (plain http on localhost is accepted by a CamTable running on your machine, but the store extension refuses plain-http game pages).
  • Nothing you host is touched by CamTable: no upload, no account, no quota beyond your own host’s.

Hosting outside Cloudflare

Nothing in the protocol depends on Cloudflare. The scaffold deploys only there, so elsewhere you build with ADDON_ORIGIN=<your origin> pnpm build and put the pieces up yourself. A host must provide the following.

For a host-mode addon (Host Authority): static files over HTTPS. Upload dist/. Every answer, the 404 included, must carry the CORS headers the template’s public/_headers sets:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD, OPTIONS
Access-Control-Allow-Headers: *

CamTable reads the manifest, catalog, meta and play routes from an extension page, without cookies, so * is enough. The game page and its assets are framed, not read, and need nothing extra. A missing id must be answered with a real 404 (section 3: The addon protocol), not a single-page-app fallback. Netlify reads _headers as Cloudflare does; nginx needs add_header ... always, without which it leaves the headers off its 404.

For an engine: an HTTPS endpoint that runs a Request → Response handler. createEngineHandler returns one, so an engine runs wherever Request and Response exist: Node 18 or later, Deno, Bun, most serverless platforms. The endpoint must:

  • answer at the https address your Play Option’s engine.url names, reachable from the internet and without redirects (Engine URLs);
  • answer within 3 seconds;
  • be able to fetch CamTable’s published keys (outbound HTTPS), and be given its engineUrl from your configuration.

An engine needs no CORS headers: CamTable’s server calls it, not a browser. It may live at another origin than the pages; engine.url names any https address.

A plain Node server, whose code CamTable’s own tests run on every change. coinFlips stands in for your reducer:

import { createServer, type Server } from "node:http";
import { Readable } from "node:stream";
import { fileURLToPath } from "node:url";
import { createEngineHandler, createEngineKeySource, type EngineKeySource } from "@camtable/sdk/addon";
import { coinFlips } from "./game.js";

/**
 * Your rules as a `Request → Response` handler. `engineUrl` is your Play Option's `engine.url`;
 * `keys` is where CamTable's published keys come from (CamTable's server unless a test says otherwise).
 */
export function createEngine(
  engineUrl: string,
  keys: EngineKeySource = createEngineKeySource(),
): (request: Request) => Promise<Response> {
  return createEngineHandler({ ...coinFlips, engineUrl, keys });
}

/**
 * Serves a handler from Node's own HTTP server. Only the request's path reaches the signature
 * check: its origin is checked against `engineUrl`, so the address Node listens on does not matter.
 */
export function serve(handler: (request: Request) => Promise<Response>, port: number): Server {
  return createServer(async (incoming, outgoing) => {
    const headers = new Headers();
    for (const [name, values] of Object.entries(incoming.headersDistinct)) {
      for (const value of values ?? []) headers.append(name, value);
    }
    const hasBody = incoming.method !== "GET" && incoming.method !== "HEAD";
    // SAFETY: Node's web ReadableStream is the one `Request` reads; the two types differ only in name.
    const body = hasBody ? (Readable.toWeb(incoming) as ReadableStream<Uint8Array>) : null;
    const init: RequestInit & { duplex: "half" } = { method: incoming.method ?? "GET", headers, body, duplex: "half" };
    try {
      const response = await handler(new Request(new URL(incoming.url ?? "/", "http://localhost"), init));
      outgoing.writeHead(response.status, Object.fromEntries(response.headers));
      outgoing.end(Buffer.from(await response.arrayBuffer()));
    } catch {
      outgoing.writeHead(500).end();
    }
  }).listen(port);
}

// `ENGINE_URL=https://my-game.example/engine/coin-flips PORT=8788 node server.js`
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const engineUrl = process.env["ENGINE_URL"];
  if (!engineUrl) throw new Error("Set ENGINE_URL to your Play Option's engine.url.");
  serve(createEngine(engineUrl), Number(process.env["PORT"] ?? 8788));
}

Node’s http serves plain HTTP: put it behind something that terminates TLS at the address engine.url names (Caddy, nginx, or your platform’s load balancer), and start it with ENGINE_URL=https://my-game.example/engine/coin-flips PORT=8788 node server.js. Deno and Bun take the handler as it is: Deno.serve(createEngine(engineUrl)), Bun.serve({ fetch: createEngine(engineUrl) }).