_ ___
___| |_ ___ ___ _ __ ___|
/ __| __/ _ \ / _ \| '_ \ ___|
\__ \ || (_) | (_) | |_) | ___|
|___/\__\___/ \___/| .__/ |
|_| '--- take a seat, ship a site
A Shopify Quick-style instant deployment platform on Cloudflare: drop a ZIP of HTML/CSS/JS, get a live subdomain with a zero-setup realtime database, identity, and auth — no backend code in the site.
+----------------+-----------------------------------------------+
| edge | Cloudflare Workers, wildcard routes |
| storage | R2 (site files) · D1 (orgs, projects, auth) |
| realtime db | one Durable Object per project (SQLite + WS) |
| dashboard | TanStack Start (SSR) |
| auth | Better Auth, organization plugin |
| logic | Effect 4 - worker programs, schema contracts |
| infra as code | Alchemy v2 |
| tooling | Bun workspaces · oxlint · portless dev proxy |
+----------------+-----------------------------------------------+
Links: Alchemy v2 · TanStack Start · Effect · Better Auth · oxlint · portless
Bun-workspaces monorepo (no task runner — bun run --filter covers it):
apps/web— the platform: worker + SSR dashboard + IaC.src/server.ts— worker entry: platform routing first, TanStack Start SSR fall-through for dashboard pages. Exports theProjectDBDO.src/platform.ts— all platform logic as Effect programs with tagged errors (Unauthenticated/Forbidden/NotFound/…) mapped to HTTP at the boundary (src/errors.ts).src/routes/— TanStack Start file routes (dashboard, login,/devicefor CLI sign-in approval).src/project-db.ts— per-project Durable Object (SQLite + WS).alchemy.run.ts— the whole stack as an Effect program (Cloudflare.Website.Vite+ D1 + R2 + DO + wildcard routes).
packages/core(@stoop/core) —effect/SchemaAPI contracts (the worker emits them, the dashboard and CLI decode them — no casts) plus the typed platform client used by the CLI.packages/cli(@stoop/cli, binstoop) — terminal deploys, built oneffect/unstable/cli:stoop login(OAuth device flow),stoop deploy,stoop open. Publishes to npm as a self-contained Node-target bundle (bun run build→dist/index.js, zero runtime deps —@stoop/coreis inlined); the source still runs directly under bun for dev.
+----------------------------------------+
*.yourdomain.com -->| Router Worker (src/router.ts) |
| Host header -> project lookup (D1) |
| Better Auth session gate (cookie on |
| .yourdomain.com -- the "IAP" wall) |
+----+-------------+--------------+------+
| | |
static files | /api/db/* | /api/me, | dashboard,
from R2 | -> per- | /api/auth/* | project CRUD,
sites/<id>/* | project DO | (BetterAuth | ZIP deploys
v v + D1) v
+--------+ +------------+ +---------+
| R2 | | ProjectDB | | D1 |
| | | DO: SQLite | | orgs / |
| | | + WebSocket| | projects|
+--------+ | broadcast | +---------+
+------------+
- One Durable Object per project is that project's entire database:
schemaless JSON collections in the DO's SQLite, realtime subscriptions via
hibernating WebSockets. Isolation is physical, not a
WHERE site_id =. - Sites are pure static files in R2. The client SDK
(
/__platform/sdk.js, source insrc/sdk.ts) gives every siteplatform.db.collection(...)andplatform.identity.me()— Quick's developer experience. - Auth: Better Auth on D1, org plugin, session cookie set on the parent domain so one login works on every subdomain. Private sites 302 to login before a single byte of HTML is served.
bun install
# alchemy is a dependency of apps/web and its binary is not hoisted to the
# root node_modules/.bin, so these need --cwd (or run them from apps/web).
bun --cwd apps/web alchemy login # Cloudflare OAuth (one time)
bun --cwd apps/web alchemy cloudflare bootstrap # alchemy's state store (one time)Local dev expects a global portless install
(bun add -g portless) — it's a system-wide proxy on port 443, not a
project dependency. Subdomain routing needs the proxy in wildcard
mode; if it was already running without it, restart it once:
sudo portless proxy stop -p 443 # next `bun run dev` restarts it with wildcard
# or persist it in the startup service:
sudo portless service install --wildcard(Without wildcard mode you can register sites one-by-one as a stopgap:
portless alias <slug>.stoop <port>.)
Local dev — https://stoop.localhost, no ports
bun run dev runs portless --wildcard, which starts alchemy's local worker
behind portless's HTTPS proxy:
dashboard ....... https://stoop.localhost
every site ...... https://<slug>.stoop.localhost
--wildcard routes unregistered subdomains to the app, and the worker reads
the Host header — exactly like production. portless injects PORT (alchemy
dev listens on it) and PORTLESS_URL (alchemy.run.ts derives BASE_DOMAIN
from it). First run generates and trusts a local CA — no browser warnings.
bun run devOpen https://stoop.localhost, sign up, create an org and a project (e.g.
demo), then deploy the demo guestbook with the CLI:
export STOOP_BASE=https://stoop.localhost # the CLI defaults to https://stoop.run
bun packages/cli/src/index.ts login # device flow: approve in browser
bun packages/cli/src/index.ts deploy demo -p demo
# → https://demo.stoop.localhost(stoop login stores a session token in ~/.config/stoop/config.json; the
CLI sends it as a bearer token. bun run deploy:demo in apps/web is a
shortcut for the same deploy.)
Open the site in two windows — entries sync live.
Google's OAuth console refuses any redirect URI that doesn't end in a
public top-level domain: https://stoop.localhost/... is rejected with
"must end with a public top-level domain", and http://localhost:PORT
is the only http exception (no subdomains). So social sign-in can't be
exercised on the default dev host at all.
Fix it by pointing a domain at loopback and handing it to portless as the
TLD. For stoop that's local.stoop.run, already set up as two DNS-only A
records on the stoop.run zone:
stoop.local.stoop.run A 127.0.0.1
*.stoop.local.stoop.run A 127.0.0.1
They're namespaced under stoop.local rather than sitting at
stoop.stoop.run on purpose: stoop is not a reserved site name
(src/site-name.ts), so a record there would permanently shadow a
production site deployed with that slug. Nothing under stoop.local.
can collide with a real <slug>.stoop.run.
Then run dev on it — portless 0.15.5+ is required, that's the release that accepted dotted TLDs:
sudo portless proxy stop -p 443 # TLD is a proxy-wide setting
PORTLESS_TLD=local.stoop.run,localhost bun run dev # → https://stoop.local.stoop.runKeep localhost in the list: the proxy is system-wide, and dropping it
takes every other project's .localhost route down with it. The first
entry wins as the primary URL, which is what portless reports in
PORTLESS_URL and therefore what alchemy.run.ts turns into
BASE_DOMAIN. Vite needs no config change — portless exports
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS and Vite appends it to
server.allowedHosts.
The Google OAuth client ("stoop web", GCP project stooop) already
carries both callbacks — https://stoop.local.stoop.run/... for dev and
https://stoop.run/... for production. Credentials go in
apps/web/.env (gitignored) as GOOGLE_CLIENT_ID and
GOOGLE_CLIENT_SECRET; without them the provider is skipped and the
login page hides the button.
Use a domain you control for this. Google's authorized domains must be
verified in Search Console, so wildcard-to-loopback services like
lvh.me or localtest.me — which do resolve to 127.0.0.1 and work while
the client is in Testing — are a dead end once the consent screen is
published.
The committed Playwright suite keeps browser coverage thin and focused:
bun run e2e:install # one time, installs Chromium for Playwright
bun run e2e # self-contained browser testsBy default, the suite runs browser-level tests that do not require the full Cloudflare dev stack. To include smoke tests against a running Stoop instance, start local dev in another terminal and pass its base URL:
bun run dev
E2E_BASE_URL=https://stoop.localhost bun run e2eThose app smoke tests currently verify that unauthenticated dashboard traffic lands on the login UI and that unsafe redirect hosts stay hidden in login copy.
Requires a domain on your Cloudflare account:
export BASE_DOMAIN=stoop.example.com
export ZONE_ID=<zone id from the Cloudflare dashboard>
export BETTER_AUTH_SECRET=$(openssl rand -hex 32)
bun run deployThis creates the wildcard *.BASE_DOMAIN DNS records + Worker routes, D1
(with migrations), R2, and the DO namespace. The dashboard lives at
https://BASE_DOMAIN, sites at https://<slug>.BASE_DOMAIN.
Without a domain, deploy with just BETTER_AUTH_SECRET and set BASE_DOMAIN
to the workers.dev hostname printed by the first deploy — the dashboard and
auth work there, but site subdomains need a real zone (workers.dev doesn't
serve nested wildcards).
.github/workflows/ci.yml runs typecheck + lint + tests on every push and PR.
.github/workflows/deploy.yml deploys through Alchemy
(docs): pushes to main deploy the
prod stage; PRs get an ephemeral pr-<n> preview stage that is destroyed
when the PR closes.
Deploys need repository secrets:
CLOUDFLARE_API_TOKEN— create withbun alchemy cloudflare create-token(interactive; asks for your Global API Key), thengh secret set CLOUDFLARE_API_TOKEN.CLOUDFLARE_ACCOUNT_IDBETTER_AUTH_SECRET— must stay stable across deploys or sessions are invalidated.ALCHEMY_STATE_CREDENTIALS— contents of~/.alchemy/credentials/default/cloudflare-state-store.jsonfrom a machine that ranbun alchemy login. Without it, CI would need account Secrets Store access to mint state-store credentials itself.
And optional repository variables for the prod custom domain:
BASE_DOMAIN, ZONE_ID (applied only to the prod stage).
Any deployed site can use, with zero setup:
<script src="/__platform/sdk.js"></script>
<script>
const posts = platform.db.collection("posts");
await posts.create({ title: "hi" }); // POST /api/db/posts
await posts.list(); // GET /api/db/posts
await posts.list({ where: { title: "hi" }, // equality filters, one
sort: "-_created", limit: 50 }); // sort field, page size
const { docs, cursor } = await posts.page({ limit: 100 }); // paginate
await posts.update(id, { title: "hi!" }); // PUT /api/db/posts/:id
await posts.delete(id); // DELETE
await posts.setRules({ write: "members" }); // per-collection ACLs
const off = posts.subscribe({ // WebSocket → project DO
onCreate(doc) {}, onUpdate(doc) {}, onDelete(id) {},
});
const me = await platform.identity.me(); // Better Auth session
// Server-side AI — no client keys, keys held by the platform.
const answer = await platform.ai.chat("Suggest a taco topping"); // → string
await platform.ai.chat("Write a haiku", { onToken: (t) => …}); // stream tokens
const img = await platform.ai.image("a fox on a stoop"); // → image Blob
</script>Plain HTML gets the house style for free with
<link rel="stylesheet" href="/__platform/theme.css">. AI calls are metered
per site and per visitor per day (a 429 means the daily cap is reached).
The full site-developer guide ships as an agent skill distributed through
skills.sh: npx @stoop/cli init installs it into a project (via
npx skills add mislavjc/stoop, which lands it in .agents/skills/stoop/
with per-agent symlinks) and writes an AGENTS.md pointer. The checked-in
copy the registry serves lives at skills/stoop/SKILL.md (regenerate with
bun run --cwd packages/cli render:skill; a drift test keeps it honest).
- Alchemy v2 and Effect 4 are betas — pin versions before upgrading, and
re-run
bun run typecheck && bun run lintafter any bump. package.jsonoverrides@distilled.cloud/*to 0.12.1 — the 0.11.3 pinned by alchemy beta.59 crashes local dev for Vite workers (ctx.exports/RouterInnerEntrypoint, alchemy-effect#697). Drop the override once alchemy ships with ≥0.12.x.- WebSockets don't relay through the local dev chain (alchemy proxy + vite dev). The site SDK falls back to ~2.5s polling automatically and keeps retrying WS, so realtime still works in dev demos and is real WS in production.
apps/web/migrations/0002_better_auth.sqland0003_device_auth.sqlare generated from the installed better-auth version. After upgrading better-auth, regenerate the full schema to a scratch file (generate:auth-schemawrites 0002 in place — don't run it blindly, 0002 is already applied) and diff for new incremental migrations.- Deploys replace the whole site (list + delete + put in R2); no atomic
swap or rollbacks yet (
deploymentstable is bookkeeping only). - Collection queries are equality-only (
where), one sort field, cursor pagination; ACLs are per-collection (read/write:public/members), not per-doc — beyond the always-on visitor-owns-their-docs rule. - Public sites skip the login wall entirely (set visibility per project).
- Upgrade path for user server code: Workers for Platforms dispatch
namespaces (~$25/mo) — Alchemy has
Cloudflare.WorkersForPlatformswhen we get there.