@playableintelligence/cloudflare-host
v0.1.0
Published
Publish static sites to unique subdomains on your own domain, backed by Cloudflare Workers + R2. Ships a framework-agnostic client and a NestJS module.
Readme
@playableintelligence/cloudflare-host
Publish static sites (HTML5 games, landing pages, anything built to flat files) to unique subdomains on a domain you own. Backed by one Cloudflare Worker and one R2 bucket. Publishing is a direct upload, so a new site is live at https://<slug>.yourdomain.com within seconds of the call resolving. No build pipeline, no per-site provisioning, no deploy step.
const { url } = await host.publish(generateSlug(), {
'index.html': '<html>...</html>',
'game.js': bundle,
})
// url -> https://k3x9q2m1p7ab.yourdomain.com/How it works
- A wildcard DNS record and a single Worker route (
*.yourdomain.com/*) catch every subdomain of the base domain. - The Worker reads the slug from the hostname and serves files from the R2 bucket under that slug's prefix, with edge caching in front of R2 reads.
- This package uploads files straight to R2 through its S3-compatible API. The moment the upload finishes, the wildcard route serves it.
Costs scale linearly and stay small: Workers Paid is $5/month, R2 storage is $0.015/GB-month with free egress, and the edge cache absorbs most read operations. First-level subdomains are covered by Cloudflare's free Universal SSL certificate, so there is nothing to manage on the TLS side.
One-time Cloudflare setup
You do this once per environment. Everything afterward goes through the SDK.
- R2 bucket: in the Cloudflare dashboard, R2 > Create bucket (for example
sites). - R2 API token: R2 > Manage R2 API Tokens > Create API Token with Object Read & Write, scoped to the bucket. Save the Access Key ID and Secret Access Key. Your account ID is on the dashboard sidebar.
- DNS: in the zone, add a proxied wildcard record: type
AAAA, name*, content100::. This is a placeholder origin; the Worker intercepts every request. - Worker: fill in the environment's block in
worker/wrangler.jsonc(zone,BASE_DOMAIN, bucket name), then:
cd worker
bun install
bunx wrangler login
bun run deploy:productionIf the zone also serves real apps on subdomains (www, app, api, ...), list them in RESERVED_SLUGS in wrangler.jsonc. The Worker passes those through to their own DNS origins instead of serving them from R2.
Environments
worker/wrangler.jsonc defines one Worker per environment, deployed from this repo:
| Environment | Deploy | Worker name | Notes |
| --- | --- | --- | --- |
| default | bun run dev (never deployed) | local only | BASE_DOMAIN=localhost, zero cache TTLs, reads the real sites-dev bucket |
| staging | bun run deploy:staging | cloudflare-host-staging | own domain + sites-staging bucket |
| production | bun run deploy:production | cloudflare-host-production | own domain + sites bucket |
Because slugs are first-level subdomains (free Universal SSL only covers one level), each environment needs its own domain, not a subdomain of production.
Adding a parent app or environment never touches the consuming app's code: copy an env block in wrangler.jsonc, run the one-time checklist for its zone (steps 1 to 3 above), deploy with wrangler deploy -e <name>, and hand the app its five CF_HOST_* values. Parent apps only install the package; all Cloudflare infrastructure stays in this repo.
Testing locally without a domain
You do not need a domain to exercise the full publish-and-play loop. Browsers resolve *.localhost to your machine, and the dev Worker is configured for it:
- Create a real R2 bucket named
sites-devand a token scoped to it (steps 1 and 2 above; skip DNS). cd worker && bun run dev(requiresbunx wrangler login). The Worker runs locally, but itsSITESbinding has"remote": true, so it reads the realsites-devbucket.- Point the SDK at the same bucket with
CF_HOST_BUCKET=sites-devandCF_HOST_BASE_DOMAIN=localhost, then publish. - Open
http://<slug>.localhost:8787/in a browser. With curl, send the host explicitly:curl -H "Host: <slug>.localhost" http://localhost:8787/.
A public, shareable URL does require a real zone in the Cloudflare account: workers.dev URLs cannot host wildcard slug subdomains (routing and TLS only cover one label, and <slug>.<worker>.<account>.workers.dev is two). Any spare domain on the free plan works, or Cloudflare Registrar sells domains at cost.
Installing in an app
The package is public on npm:
bun add @playableintelligence/cloudflare-host
# or: npm install @playableintelligence/cloudflare-hostNestJS usage
Set the environment variables:
CF_HOST_ACCOUNT_ID=...
CF_HOST_ACCESS_KEY_ID=...
CF_HOST_SECRET_ACCESS_KEY=...
CF_HOST_BUCKET=sites
CF_HOST_BASE_DOMAIN=yourdomain.comRegister the module once. With the env vars set, forRoot({}) is all it takes, and the module is global by default:
import { Module } from '@nestjs/common'
import { CloudflareHostModule } from '@playableintelligence/cloudflare-host/nestjs'
@Module({
imports: [CloudflareHostModule.forRoot({})],
})
export class AppModule {}Inject the service anywhere:
import { Injectable } from '@nestjs/common'
import { CloudflareHostService, generateSlug } from '@playableintelligence/cloudflare-host/nestjs'
@Injectable()
export class GamesService {
constructor(private readonly host: CloudflareHostService) {}
async publishGame(files: Record<string, string | Uint8Array>) {
const { url, slug } = await this.host.publish(generateSlug(), files)
return { url, slug }
}
}Prefer explicit configuration? Pass options to forRoot, or wire it through ConfigService:
CloudflareHostModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
accountId: config.getOrThrow('CF_ACCOUNT_ID'),
accessKeyId: config.getOrThrow('R2_ACCESS_KEY_ID'),
secretAccessKey: config.getOrThrow('R2_SECRET_ACCESS_KEY'),
bucket: 'sites',
baseDomain: 'yourdomain.com',
}),
})Plain Node usage
The core client has no NestJS dependency:
import { CloudflareHostClient, generateSlug } from '@playableintelligence/cloudflare-host'
const host = new CloudflareHostClient() // reads CF_HOST_* env vars
const result = await host.publish(generateSlug(), { 'index.html': '<h1>hi</h1>' })
console.log(result.url)API
| Method | Description |
| --- | --- |
| publish(slug, files, opts?) | Uploads a file map (path -> string \| Uint8Array) and returns { slug, url, fileCount, totalBytes, prunedCount }. Requires an index.html unless requireIndexHtml: false. Removes files left over from a previous publish unless prune: false. |
| publishDirectory(slug, dir, opts?) | Same, but reads a local directory recursively (a build output folder, for example). |
| unpublish(slug) | Deletes every file for the slug. Returns { slug, deletedCount }. |
| exists(slug) | Whether anything is published at the slug. |
| listSites() | Every published slug in the bucket. |
| urlFor(slug) | The public URL for a slug. |
| generateSlug(length?) | Random URL-safe slug, 12 characters by default. |
| isValidSlug(slug) / assertValidSlug(slug) | Slug validation. Slugs become subdomains: 1 to 63 characters, lowercase letters, digits, and hyphens, no leading or trailing hyphen. |
Behavior notes
- New sites are instant. Nothing is cached for a slug that has never been served, so the URL works as soon as
publishresolves. - Republishing an existing slug propagates within the edge cache TTL: 60 seconds for HTML and 5 minutes for assets by default (
HTML_MAX_AGE/ASSET_MAX_AGEinwrangler.jsonc). - Uploads set Content-Type per file from the extension, so games with WASM, audio, and fonts serve correctly. The Worker also answers HTTP Range requests, which Safari requires for media playback.
- Reserved or unknown subdomains return a 404 page from the Worker.
Developing this package
bun install
bun run typecheck # SDK + worker
bun run build # tsdown -> dist/ (dual ESM + CJS)
bun run test # vitestRun the Worker locally with cd worker && bun run dev (see "Testing locally without a domain" above).
Releasing
Publishing happens from the CLI. One-time setup: npm login, and make sure your npm account is a member of the playableintelligence org on npmjs.com (create the org once, it is free for public packages).
To ship a release from a clean working tree on main:
npm version patch # or minor / major; bumps package.json, commits, and tags
npm publish # prepublishOnly runs typecheck + tests + build first
git push --follow-tagsprepublishOnly blocks the publish if anything fails, so a broken release cannot ship. CI still runs the same checks on every push and pull request.
