A ready-to-hack realtime chat app built entirely on Fastly Compute and Fastly Fanout, packaged as a GitHub Codespace.
No Fastly account, no local installs, no signup. Click the button, wait for the container to build, and you have a working Compute + Fanout app running in your browser.
- Click Open in GitHub Codespaces above (or Code ▸ Codespaces ▸ Create codespace on the repo).
- Wait for setup to finish. It installs Node dependencies, Pushpin, the Fastly CLI and the local dev server, then builds the Wasm package. First run takes a few minutes; if the repo has prebuilds enabled it's near-instant.
- The dev server starts automatically and you'll see its log in the terminal.
- When port 7676 is forwarded, open it in your browser. Pick a nickname and start chatting. Open a second tab to watch messages arrive in realtime.
Messages aren't stored anywhere — you see what's sent while you're connected. (Adding history is exercise 6.)
Rooms are just a query parameter: /?room=lobby. Any name works; no room needs
creating.
Forwarded ports are private to you by default. To let someone else join your chat room, make the port public and share the URL:
gh codespace ports visibility 7676:public -c "$CODESPACE_NAME"
echo "https://${CODESPACE_NAME}-7676.app.github.dev/"There is no origin server. One Compute app serves the page, holds nothing open, and publishes messages:
| File | What it does |
|---|---|
src/index.js |
Routing: the page, the stream, the publish endpoint |
src/fanout.js |
The two Fanout-specific bits: GRIP responses and publishing |
src/page.html |
The whole frontend, embedded into the Wasm package at build time |
┌─────────────────────────────────────────────┐
browser ────────▶│ Fastly Compute · :7676 · src/index.js │
▲ │ │
│ │ GET / → page.html │
│ │ GET /rooms/x/events/ → hand to Fanout ─┼──┐
│ │ POST /rooms/x/messages/ → publish ────────┼─┐│
│ └─────────────────────────────────────────────┘ ││
│ ▲ ││
│ │ same app, 2nd request ││
│ │ (Grip-Sig) → GRIP hdrs ││
│ SSE stream, held open │ ││
└────────────────────────────┬───────┴────────────────────────┘│
│ │
┌──────┴─────────────────────┐ │
│ Fanout │◀──────────┘
│ (Pushpin locally) │ publish to channel
└────────────────────────────┘
- The browser opens an
EventSourcetoGET /rooms/default/events/. - The app sees no
Grip-Sigheader, so this request hasn't been through Fanout yet. It callscreateFanoutHandoff(request, 'self')and returns. It is not holding anything open. - Fanout takes the connection and, to find out what to do with it, forwards the
same request to the
selfbackend — which is this app again. That request arrives with aGrip-Sigheader. - This time the app replies with GRIP
headers meaning "hold this connection open and subscribe it to channel
room-default". Then it's done. Fanout owns the socket from here, however long it lives. - When someone sends a message, that's an ordinary short-lived
POST. The app publishes a JSON payload to the channel and returns204. - Fanout writes that message into every held connection subscribed to the channel.
The point of Fanout is step 4: your code stays a plain request/response app that answers in milliseconds, and the edge absorbs every long-lived connection. Ten thousand connected clients cost you no held sockets and no idle processes.
The self-referential self backend is worth sitting with for a second. The app
is invoked twice for one client connection: once as the thing being proxied,
once as the thing that tells Fanout what to do. There's no separate config
service — the routing decision lives in the same file as everything else.
You don't have a Fastly account, so there's no real Fanout. Instead the Fastly
CLI starts Pushpin — the open-source GRIP proxy that
Fanout is built on — next to the local dev server. That's this bit of
fastly.toml:
[local_server.pushpin]
enable = truePushpin listens on 7677 (proxy) and 5561 (publish). Publishing goes to
http://127.0.0.1:5561/publish/ locally and to
https://api.fastly.com/service/<service-id>/publish/ when deployed — same
request body either way. See publish() in src/fanout.js.
See WORKSHOP.md for a set of exercises, from "add a response header" to "add presence" and "keep history in a KV store".
The dev server starts on its own, but you can drive it yourself:
npm run dev # serve on 7676, rebuilding when anything in src/ changes
npm run build # just build bin/main.wasm
npm run deploy # publish to a real Fastly account (see below)Ctrl+C in the terminal running npm run dev stops the server; npm run dev
starts it again.
Saving any file in src/ — including page.html — triggers a rebuild and
reload. Watch the terminal for it; a syntax error shows up there and the old
Wasm keeps serving.
The page loads but messages never appear in the other tab. The SSE stream isn't getting through. First find out where it's broken, by testing inside the container where no port forwarding is involved — that's exercise 0:
curl -N http://127.0.0.1:7676/rooms/default/events/ # in one terminal
curl -X POST http://127.0.0.1:7676/rooms/default/messages/ \
-d 'from=t' -d 'text=hi' # in anotherIf the event arrives in the curl but not in the browser, the app is fine and
the issue is the forwarded-port proxy — try the browser preview, or make the
port public (see above). If it doesn't
arrive in curl either, look for errors in the dev server's terminal output.
failed to find 'pushpin' in your $PATH.
The Pushpin install didn't happen, so the dev server won't start. Verify with
command -v pushpin, then rebuild the container (Codespaces: Rebuild
Container in the command palette).
WARN backend 'self' ... is not up right now at startup.
Harmless. The dev server probes backends before it binds its own port, so self
— which is the dev server — can't answer yet. Same for publisher before Pushpin
is up. Neither warning means anything is broken.
Sending a message returns 502. The publish request failed. The response body says why, and the dev server's terminal has the same message. Locally this usually means Pushpin isn't up — check that port 5561 is listening.
A change to src/ made everything return 500.
Your code threw, and the terminal only says Error while running request handler.
with no stack trace. Wrap the suspect code in try/catch and
console.error(error.message) — see the top of WORKSHOP.md for the
snippet. To see what your code returned before Fanout touched it, send the request
yourself with a Grip-Sig header:
curl -si -H 'Grip-Sig: fake' http://127.0.0.1:7676/rooms/default/events/A change to fastly.toml seems to do nothing.
Only src/ is watched. Ctrl+C the dev server and run npm run dev again.
Setup half-finished / dependencies missing. Re-run it; it's idempotent:
bash .devcontainer/setup.shOn macOS, skipping the container is the easiest route — Homebrew has a native Pushpin build, including on Apple Silicon:
brew install pushpin # see https://pushpin.org/docs/install/
npm install # needs node 20+
npm run devThat's the setup this repo was developed and tested against, on an arm64 Mac.
The devcontainer also works in VS Code locally via Dev Containers: Reopen in
Container — but only on x86_64. Pushpin's apt repository publishes amd64
packages only, so on an Apple Silicon Mac the image build stops with an
explanatory error. Add "runArgs": ["--platform=linux/amd64"] to
.devcontainer/devcontainer.json if you want it anyway, and accept the emulation
slowdown.
Nothing here is Codespaces-specific, and the app code doesn't change. Once you have a Fastly account and a CLI token:
-
npm run deploy. The CLI creates a service and walks you through the setup declared infastly.toml, which enables Fanout and asks for two backends:self— this service's own domain, e.g.my-app.edgecompute.app, port 443, with Override Host set to the same value. It points at itself; see above.publisher—api.fastly.com, port 443. Pre-filled for you.
-
Give the service an API token to publish with. Create a token with global scope (docs), then put it in a secret store named
fanoutunder the keyapi_token:fastly secret-store create --name fanout fastly secret-store-entry create --store-id <id> --name api_token fastly resource-link create --service-id <service-id> --version latest \ --resource-id <id> --autoclone fastly service-version activate --service-id <service-id> --version latest
src/fanout.jsreads it from there. Without it,POST /rooms/*/messages/returns a 502 explaining what's missing — the streaming half still works, and you can drive it by publishing through the API directly (exercise 5).
The one thing to change before putting anything like this in production: verify
the Grip-Sig header's JWT instead of just checking it exists, so clients can't
forge a request that looks like it came from Fanout. See
verifying requests from Fanout.
Before the session:
- Make the repo public (or make sure every attendee has access) — the Codespaces badge needs it.
- Turn on prebuilds: Settings ▸ Codespaces ▸ Set up prebuild for
main. Without one, each attendee waits several minutes while the image builds and dependencies install. With one, they're chatting almost immediately. This is the single biggest difference to how the session feels — the container build installs Pushpin and Qt dependencies, which is not fast. - Re-run the prebuild after changing
.devcontainer/,package.jsonorpackage-lock.json, otherwise attendees get the setup at create time instead. - Check attendee Codespaces quota. Free personal accounts include monthly core-hours; a 2-core machine is enough here and uses the least quota.
- Try the badge yourself from an account without push access, to be sure permissions are right.
During the session, if someone's environment is broken, the fastest fixes are
Ctrl+C then npm run dev, and then Codespaces: Rebuild Container.
The chat app is a rewrite of fastly/fanout-chat-demo (MIT) with the Django origin removed — everything is served from Compute instead, so there's one language and one process to think about. The self-referential-backend pattern follows Fastly's Fanout starter kit (MIT).
Added for the workshop: a devcontainer that installs Pushpin, so Fanout works locally with no Fastly account.
Licensed under MIT.