Skip to content

Stub API (local backend) ​

apps/stub-api stands in for the Worker API on your own machine. It answers the routes the hub, profiles, notifications, version history and live collaboration use, with a populated hub and a handful of made-up people, so you can work on (or record, or screenshot) the signed-in side of the app without a Supabase project, a Cloudflare account or production data.

It was built to record the demo videos in these docs, and it's the quickest way to see any hub feature end to end.

Running it ​

bash
pnpm dev:stub           # the stub, on http://localhost:8787
pnpm dev:web:stub       # the app, on http://localhost:5180, pointed at it

Then open http://localhost:5180/login and sign in as anyone below, with any password. The username alone is enough (maya), and so is maya@example.com.

dev:web:stub runs Vite with --mode stub, which reads apps/web/.env.stub (committed, since it holds nothing but local URLs). It uses port 5180 rather than Vite's usual 5173 so it never collides with an ordinary pnpm dev:web, and it fails rather than drifting to another port, because the stub builds links to that address.

Who's in it ​

Sign in asNameWho they are
mayaMaya RiveraPractitioner. Owns two published lessons and a draft.
danielDaniel OkaforPractitioner. Maya follows him.
priyaPriya ShahPractitioner with the most lessons and followers.
larsLars NielsenPractitioner who writes in Danish and English.
jordanJordan EllisParent and communication partner to Theo. Has a proposal open on one of Maya's lessons.
samSam CarterPractitioner. Mostly comments.

None of them is a real account. They live in apps/stub-api/src/people.js.

What's in the hub ​

The lessons are real published lessons, read from the live hub's public API the first time the stub starts and cached in apps/stub-api/.cache/. That directory is gitignored: the lessons are other people's work and are never committed. Refresh the copy with:

bash
pnpm --filter @spelling-creator/stub-api snapshot

Each lesson is credited to one of the people above instead of its real author (see OWNERS in src/seed.js), and given a git history that grows it a few sections at a time, built with the same git code the editor uses. On top of that the stub seeds ratings, a few discussions, follows, and Maya's notifications, and Jordan's proposal to Maya's Penguins, built as a real branch of its history so the review and merge work. Lesson images are fetched from the live hub on first use and cached alongside the lessons.

Everything you do (comments, follows, publishing, merges) is kept in memory. Restarting the stub puts the hub back the way it started.

What works, and what doesn't ​

Works: the hub and search, lesson pages and all their tabs, comments and ratings, publishing and drafts, forking, version history, proposals and merging, profiles and following, the home page's feeds, notifications including Send link, sign-in and sign-out, and live collaboration with cursors and chat.

Doesn't, by design: AI suggestions and Pixabay image search (both need Cloudflare Turnstile and a real backend, so they show as not configured), Save to Google Docs, moderation, fact checking, and anything that needs real email. Wikimedia image search does work, since it talks to Wikimedia directly.

Where the stub knowingly differs from the real API:

  • A Send link notification pointing into the app is stored as a path, not the full URL. The real API stores the URL, and the bell opens a full URL in a new tab, which would break a recording.
  • Pushing a lesson's history always succeeds. There is no compare-and-swap, because nobody else can be editing the same history.
  • Image uploads and interactive mode's saved answers are accepted and then forgotten.

Two people at once ​

http://localhost:8787/split shows the app twice, side by side: the left pane from localhost:5180 and the right from 127.0.0.1:5180. Those are different origins, so each pane has its own storage and can be signed in as someone different. Sign in as Maya on the left and Daniel on the right, and you can try Collaborate with both sides in view. ?left= and ?right= set each pane's starting path, and ?leftName= and ?rightName= the labels above them.

From a script ​

The server can be started from code, which is how its tests run:

js
import { startStub } from "@spelling-creator/stub-api";

const stub = await startStub({ port: 0 }); // 0 picks a free port
// ... stub.url, stub.state ...
await stub.close();

A browser can also start out signed in, without going through the login page, by seeding the session where supabase-js looks for it. For example, in Playwright:

js
import { sessionFor, storageKey } from "@spelling-creator/stub-api/sessions";

await context.addInitScript(
  ([key, value]) => localStorage.setItem(key, value),
  [storageKey("http://localhost:8787"), JSON.stringify(sessionFor("maya"))],
);

startStub takes port, appOrigin (where the app runs, default http://localhost:5180), dataDir (the snapshot and image cache), hubUrl (where to snapshot from, default the live hub) and log. From the command line the same settings are the PORT, APP_ORIGIN, STUB_DATA_DIR and HUB_URL environment variables.

Where the code lives ​

FileWhat it does
src/server.jsstartStub: loads the snapshot, seeds the hub, starts HTTP and WebSockets.
src/routes.jsThe Worker API routes, plus the Supabase Auth endpoints sign-in uses.
src/seed.jsWho wrote what, ratings, comments, follows, notifications.
src/git.jsLesson histories and the proposal, as real packfiles.
src/collab.jsThe collaboration room, a trimmed copy of the Worker's CollabRoom.
src/people.jsThe made-up people.
src/sessions.jsSigned-in sessions for scripts.
src/snapshot.jsReading and caching the live hub's published lessons and images.
src/split.jsThe side-by-side page.

When a route the app uses is missing, the stub answers 404 and logs not stubbed: <method> <path>, which is the place to start.

Copyright © 2026 Spelling Creator.