npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

  1. R2 bucket: in the Cloudflare dashboard, R2 > Create bucket (for example sites).
  2. 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.
  3. DNS: in the zone, add a proxied wildcard record: type AAAA, name *, content 100::. This is a placeholder origin; the Worker intercepts every request.
  4. 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:production

If 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:

  1. Create a real R2 bucket named sites-dev and a token scoped to it (steps 1 and 2 above; skip DNS).
  2. cd worker && bun run dev (requires bunx wrangler login). The Worker runs locally, but its SITES binding has "remote": true, so it reads the real sites-dev bucket.
  3. Point the SDK at the same bucket with CF_HOST_BUCKET=sites-dev and CF_HOST_BASE_DOMAIN=localhost, then publish.
  4. 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-host

NestJS 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.com

Register 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 publish resolves.
  • 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_AGE in wrangler.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        # vitest

Run 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-tags

prepublishOnly blocks the publish if anything fails, so a broken release cannot ship. CI still runs the same checks on every push and pull request.