惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Google DeepMind News
Google DeepMind News
C
Check Point Blog
J
Java Code Geeks
腾讯CDC
Martin Fowler
Martin Fowler
MongoDB | Blog
MongoDB | Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
博客园 - 三生石上(FineUI控件)
Apple Machine Learning Research
Apple Machine Learning Research
大猫的无限游戏
大猫的无限游戏
Engineering at Meta
Engineering at Meta
罗磊的独立博客
Last Week in AI
Last Week in AI
B
Blog
IT之家
IT之家
S
SegmentFault 最新的问题
D
DataBreaches.Net
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
GbyAI
GbyAI
博客园 - 聂微东
U
Unit 42
有赞技术团队
有赞技术团队
Y
Y Combinator Blog
MyScale Blog
MyScale Blog

Hacker News: Show HN

PurrrrrFocus: Pomodoro Timer App - App Store Workflow Engine — Multi-Step Orchestration for Bun RapidPhoto: Pro Photo Editor App - App Store GitHub - DheerG/swarms: Achieve extraordinary results with claude code across a variety of tasks SPICE simulation → oscilloscope → verification with Claude Code — Lucas Gerads Show HN: VCoding – A 5 MB native Windows IDE with no dynamic dependencies Show HN: LLMs don't hallucinate because they're bad at math, it's the format GitHub - Agent-FM/agentfm-core: AgentFM is a peer-to-peer network that turns everyday computers into a decentralized AI supercomputer. AgentFM lets you run massive AI workloads directly across a global mesh of idle CPUs and GPUs. Show HN: Tracking Top US Science Olympiad Alumni over Last 25 Years GitHub - Potarix/agent-hub: One place to talk to all your agents Show HN: Runtime security for AI agents(injection,tool abuse, data exfiltration) GitHub - dubeyKartikay/lazyspotify: Terminal Spotify client for macOS and Linux GitHub - the-banana-tool/king-louie: Easy to use GUI Personal AI Assistant. Win/Linux/Mac. Show HN I made my vacation rental bookable by AI agents–no Airbnb, 0% commission GitHub - basteez/jsf-autoreload: maven plugin to enable hot reload on jsf projects uvm32/hosts/host-gdbstub at main · ringtailsoftware/uvm32 GitHub - labsai/EDDI: Config-driven engine that turns JSON into production-grade AI agents. Multi-agent orchestration, 12+ LLM providers, MCP/A2A protocols, RAG, persistent memory, and enterprise compliance (EU AI Act, GDPR, HIPAA). Built on Quarkus. GitHub - glitchnsec/fortyone-oss: AI Executive Assistant Platform Quickstart | Alien GitHub - muxshed/shed: One stream in, or many. Every destination, simultaneously. No cloud middleman, no per-channel fees, no limits. GitHub - ocrbase-hq/ocrbase: 📄 PDF/IMG ->.MD/JSON Document OCR API for PaddleOCR and GLMOCR. Self-hostable. GitHub - impactjo/home-memory: MCP server that lets your AI assistant remember everything about your home. GitHub - Sets88/dbcls: DbCls is a powerful terminal database client that supports various databases GitHub - neptun2000/heor-agent-mcp GitHub - SeanFDZ/macmind: Single-layer transformer in HyperTalk for the classic Macintosh RollQuation: Math Puzzles - Apps on Google Play GitHub - dropbox/witchcraft Show HN: Agent-cache – Multi-tier LLM/tool/session caching for Valkey and Redis GitHub - opentalon/opentalon: OpenTalon is an open-source platform built from the ground up in Go as a robust alternative to OpenClaw LinkedIn™ 职位抓取工具 - Chrome 应用商店
GitHub - dmichael-fastly/fastly-examples-live-betting-fan...
dmichael-fas · 2026-05-23 · via Hacker News: Show HN

Fastly

Real-time Sports Betting & Odds Distribution

A working example of distributing live game scores and betting odds to millions of concurrent users without overwhelming origin — built on Fastly's edge stack.

Dashboard (bettor + admin) Behind the Scenes (live Fastly X-ray)
Dashboard Behind the Scenes

Problem Statement

Sportsbooks and gaming platforms have to push constantly changing odds to a fanned-out audience of bettors in real time. Polling architectures don't work: they hammer the origin and still show stale prices, which is both an infrastructure problem and a revenue problem (a bettor locking in a stale price is a direct loss). What's needed is:

  • Instant data delivery — push, not poll, the moment odds move.
  • Low, even latency — every bettor sees the same odds at the same time, regardless of geography.
  • Concurrency under spikes — kickoffs, goals, and final-minute swings drive huge connection counts.

The Fastly Solution

Capability Fastly Product Generic term
Push odds to all connected bettors over a single SSE connection Fastly Fanout publish/subscribe, server-sent events fan-out
Run the application logic at the edge Fastly Compute serverless edge computing, WebAssembly application
Persist live games, current odds, and the bet ledger as the source of truth at the edge Fastly KV Store edge key-value database, distributed durable storage
Hold static reference data (team metadata) Fastly Config Store edge configuration store
Store the Fastly API token used to talk to Fanout's publish API Fastly Secret Store edge secrets management

Architecture / Request Flow

                                          ┌─────────────────┐
                                          │  Config Store   │ teams (static)
                                          └────────┬────────┘
                                                   │ read
  Bettor ──/stream──▶ Fastly Compute ──handoff──▶ Fastly Fanout ──SSE──▶ Bettor
                          │  ▲                       ▲
   Admin ──/api/odds──────┘  │ publish               │
                             ▼                       │
                       ┌──────────────┐              │
                       │   KV Store   │ games / odds / user_bets
                       └──────────────┘
  1. A bettor opens the dashboard. The browser generates a per-tab userId, then opens /stream?channels=global,game-G1,game-G2,…,user-<id> — one SSE connection multiplexed across every channel the tab cares about. Compute calls createFanoutHandoff to upgrade the connection into a GRIP-managed SSE stream.
  2. An admin posts /api/odds to change a line on game G1.
  3. Compute reads the current odds out of the KV Store, mutates the entry, writes it back, then calls the Fastly publish API on channel game-G1.
  4. Fanout pushes the new line down every SSE stream subscribed to game-G1 worldwide — bettors who aren't watching that game receive nothing, and origin never sees the read fan-out.
  5. The bettor UI receives the event and re-renders that one button.

Teams are read straight out of the Config Store on each request — they're static reference data and don't need KV write throughput.

Channels

Channel Carries Who subscribes
global Lifecycle events (seed, clear) and the activity trace Every connected tab
game-<gameId> odd_update, score_update for that one game Only tabs that have the game on screen
user-<userId> new_bet confirmations for that one bettor Only the bettor who placed the bet

The browser passes its random per-tab userId (generated with crypto.randomUUID() and persisted in localStorage) on every request via the X-User-Id header. /api/data uses it to filter the user_bets ledger so each tab only ever sees its own bets. The channel names server-side are validated against a regex allow-list (global | game-<id> | user-<id>) so /stream can't be coerced into subscribing to arbitrary Pushpin channels.

Prerequisites

  • A Fastly account
  • Fastly CLI (brew install fastly/tap/fastly)
  • Node.js 18+
  • For local Fanout: the CLI's --experimental-enable-pushpin flag (no separate install — npm run dev enables it)

Run it locally

npm install
npm run dev

Open http://127.0.0.1:7676 in two browser windows. The first page load auto-seeds the local KV Store with sample games and odds (see "auto-seed" note below). Change an odd's line in the Admin panel and watch the other window's button flash green within milliseconds — that's Fanout pushing through the local Pushpin loopback, with state persisted in the local KV Store.

The 🌓 button in the header cycles through auto → light → dark → auto. Auto follows prefers-color-scheme; the explicit choices override it and persist in localStorage. Native scrollbars and form chrome flip via color-scheme so they match the palette.

Auto-seed (local only): The CLI's local KV Store doesn't persist across restarts. To make the demo usable without an extra click, /api/data checks whether the games KV is empty and FASTLY_HOSTNAME === 'localhost' (the canonical local sentinel under Viceroy); if both are true, it seeds. In production (real POP hostname), auto-seed never fires — data lifecycle stays explicit, driven by the Seed Data / Clear All Data buttons. The Config Store (teams) is seeded from local_stores/teams.json on every start.

HTTP API

Method Path Purpose Publishes to
GET / Static dashboard (bettor + admin in one page)
GET /stream SSE endpoint. Accepts ?channels=a,b,c (multi) or ?channel=a (single). Channel names must match `global game-
GET /api/data Returns the full current state from KV + Config Store, with user_bets filtered by the caller's X-User-Id. Triggers local auto-seed on a cold KV.
POST /api/seed Writes seedGames() / seedOdds() / empty user_bets to KV. global (seed)
POST /api/clear Empties all dynamic KV entries. global (clear)
POST /api/odds Body: {gameId, oddId, newLine}. Mutates the matching odd, writes back to KV. game-<gameId> (odd_update)
POST /api/score Body: {gameId, home, away} (non-negative integers). 400 unless the game is Live; 404 if unknown. game-<gameId> (score_update)
POST /api/bet Body: {gameId, oddId, amount}. Snapshots type / team / line from the live odd at placement time and appends to user_bets. Reads X-User-Id to tag the bet and route the confirmation. user-<userId> (new_bet)

Every successful write also publishes a parallel activity event on the global channel (see Edge activity feed).

Headers and lockdown

  • X-User-Id — every browser request from the dashboard sends this. The router validates it against /^[a-zA-Z0-9_-]{1,64}$/; anything else (or absent) is treated as anonymous.
  • Origin gate/api/* and the browser-side /stream reject requests whose Origin / Referer isn't http://127.0.0.1:7676, http://localhost:7676, or https://fastly-fanout-sports-betting.edgecompute.app with 403. Pushpin's internal callback to /stream (which carries Grip-Sig and has no Origin) is exempt. Local curl with no Origin/Referer is also allowed so the smoke-tests below keep working.
  • Input shapesgameId / oddId must match ^[A-Za-z0-9_-]{1,32}$; newLine must match ^[+-]?\d{1,6}(?:\.\d{1,2})?$; amount must be a finite number in (0, 1_000_000]. Anything else is 400. A per-user cap of 10 bets keeps the user_bets KV entry from being inflated by a single (anonymous) caller — placing an 11th bet evicts that user's oldest one.

Smoke test with curl

# Terminal 1 — subscribe to multiple channels at once
curl -N "http://127.0.0.1:7676/stream?channels=global,game-G1,user-curl"

# Terminal 2 — trigger a seed (publishes on `global`)
curl -X POST "http://127.0.0.1:7676/api/seed"

Terminal 1 should immediately receive two SSE frames — the data event and the activity trace for the same call:

data: {"type":"seed","data":{"teams":{...},"games":{...},"odds":{...},"user_bets":[]}}
data: {"type":"activity","ts":1700000000000,"endpoint":"/api/seed","method":"POST","status":200,"totalMs":12,"publishMs":4,"detail":"Wrote 3 KV entries; broadcast full state"}

Move a line and watch the next frame land:

curl -X POST "http://127.0.0.1:7676/api/odds" \
  -H "Content-Type: application/json" \
  -d '{"gameId":"G1","oddId":"O1","newLine":"+500"}'

Update a live game's score (integers, one per team):

curl -X POST "http://127.0.0.1:7676/api/score" \
  -H "Content-Type: application/json" \
  -d '{"gameId":"G1","home":3,"away":1}'

Place a bet — pass the same X-User-Id you subscribed /stream with to receive the confirmation on your user-… channel. The response includes the bet with type/team/line snapshotted from the odd at this exact moment:

curl -X POST "http://127.0.0.1:7676/api/bet" \
  -H "Content-Type: application/json" \
  -H "X-User-Id: curl" \
  -d '{"gameId":"G1","oddId":"O1","amount":100}'

Design notes

Bet line snapshotting

/api/bet reads the live odd from KV and copies type, team, and line into the persisted bet record before writing. The ledger renders from those snapshots, never from the live odds map. A subsequent /api/odds write that moves the line is visible to every future bet, but never re-prices an existing one. This is the standard sportsbook contract — a bettor's number is locked when they tap "Place Bet."

The behaviour is covered end-to-end by the POST /api/bet locks the line test in test/router.test.js. Run npm test for the full Vitest suite, or npm run lint for ESLint (covers src/router.js and the inline <script> block in src/index.html via eslint-plugin-html).

"Behind the Scenes" tab

The second tab in the UI is a live X-ray of the Fastly stack from the browser's perspective — built so that someone clicking around the demo for the first time can see exactly which Fastly product is doing what work. It has three regions:

  • Stats strip. Live counters for SSE events received (Fanout), edge handler invocations seen (Compute), and KV writes broadcast. A pulsing green dot reflects the live Fanout connection. All numbers update in place as events stream in.
  • Edge State. Pretty-printed JSON of the four non-sensitive stores — games, odds, user_bets (KV) and teams (Config). Updates whenever a Fanout event lands; a Refresh from edge button re-fetches /api/data to confirm the view matches origin truth.
  • Session log. Every outbound fetch and every inbound SSE event for this browser tab, click-to-expand for the raw payload. Outbound rows are tagged Compute (a handler was invoked); inbound SSE rows are tagged Fanout. The browser tab is the whole "session" — refresh resets it. Each Fanout channel the tab subscribes to lands as its own Subscribed: <channel> system row (and a matching [Fanout] line in the browser DevTools console) so you can see the multi-channel handshake one channel at a time.

The Secret Store (fanout_secrets) is intentionally absent from the Edge State grid. There is no endpoint that returns it; the value is only available to the Compute handler via SecretStore.get(...).plaintext() and never serialised onto the wire. That's the Secret Store contract, and the tab documents it explicitly with a callout.

Edge activity feed

The dashboard's bottom panel is fed by a parallel activity event published on the global channel after every write — regardless of which game- or user-channel the data event itself targeted. That way every connected tab sees the activity trace even when the data event was scoped to one game or one bettor. After each write, the Compute handler measures total handler latency and the Fanout publish round-trip, then fires a second publish:

await publishActivity({
  endpoint: '/api/odds', method: 'POST', status: 200,
  totalMs: Date.now() - t0, publishMs: pub.ms,
  detail: 'G1/O1 → +500',
});

The browser subscribes once and routes both event types — data events update the UI, activity events append to the trace pane. Because the trace itself rides Fanout, opening two browser windows shows the same activity in both, which is a useful party trick for demoing the publish/subscribe semantics.

Cost: one extra publish per write. Cheap, but worth knowing if you copy this pattern into a higher-throughput service — gate it behind a debug query param or strip it in production.

Deploy to Fastly

fastly compute publish

Follow the prompts to create a new service. Then provision the resources the service expects:

Heads-up: config-store-entry create and secret-store-entry create take --store-id, not the store's --name — that's why the snippet captures each store's ID from the create call with --json | jq.

# KV Stores (dynamic state) — entries are written by the app on first /api/seed,
# so no entry-create step is needed at provision time.
fastly kv-store create --name=games
fastly kv-store create --name=odds
fastly kv-store create --name=user_bets

# Config Store (static team reference data)
TEAMS_ID=$(fastly config-store create --name=teams --json | jq -r '.id')
fastly config-store-entry create \
  --store-id=$TEAMS_ID \
  --key=all_teams \
  --value="$(jq -r '.all_teams' local_stores/teams.json)"

# Secret Store (Fastly API key for publishing to Fanout)
SECRETS_ID=$(fastly secret-store create --name=fanout_secrets --json | jq -r '.id')
echo -n "<your-fastly-api-token>" | \
  fastly secret-store-entry create \
    --store-id=$SECRETS_ID \
    --name=fastly_api_key \
    --stdin

Two more steps before the deployed service will work — both are easy to forget:

  1. Link each store to the service. Active service versions are read-only, so this means cloning the active version into a draft, attaching all five stores, then activating. In the Fastly UI: open your service → Resources → add the games, odds, user_bets, teams, and fanout_secrets stores (the UI does the clone+activate for you). Or via CLI:
    SID=<your-service-id>
    
    # Clone active into an editable draft. The clone subcommand has no --json flag,
    # so parse the new version number out of its human output.
    NEW=$(fastly service-version clone --service-id=$SID --version=active \
      | grep -oE 'to version [0-9]+' | awk '{print $3}')
    
    # Link all five stores onto the new draft.
    for RID in <games-id> <odds-id> <user_bets-id> <teams-id> <fanout_secrets-id>; do
      fastly resource-link create --service-id=$SID --version=$NEW --resource-id=$RID
    done
    
    fastly service-version activate --service-id=$SID --version=$NEW
    
    # Verify — should list all 5 stores.
    fastly resource-link list --service-id=$SID --version=active
  2. Enable the Fanout product on the service. This is required — without it, createFanoutHandoff and the publish API are inert and the dashboard will load with nothing streaming. In the Fastly UI: your service → ManageProducts → enable Fanout. (No CLI equivalent at time of writing.)

Symptoms if you skip step 1 or 2: the page stays on "Loading games…" forever and clicking Seed Data silently does nothing. Both endpoints (/api/data, /api/seed) 500 because new KVStore('games') throws No KVStore named 'games' exists when the link is missing. The handler catches that and returns {"error":"handler_threw","detail":"..."} as JSON — visible in the browser network tab, or via fastly log-tail --service-id=$SID if you've wired a fastly-stdout logging endpoint.

Tip: the Fastly Agent Toolkit has skills that automate the provisioning + linking flow above.

Production considerations

  • Auth. The admin write endpoints (/api/odds, /api/score, /api/seed, /api/clear) are unauthenticated in this demo. In production, gate them behind an admin auth check at the edge. /api/bet should verify the bettor's identity and balance before accepting — the X-User-Id here is browser-set and trivially spoofable.
  • Source of truth. This demo treats the edge KV Store as the source of truth — fine for a self-contained example. A large operator would typically own the data in a core database and synchronise out to KV via a webhook or background process, so the edge becomes a read-optimised projection.
  • Concurrency on writes. KV Store reads are eventually consistent across POPs. For high-frequency odds movement, consider using KV's gen parameter (compare-and-swap) to avoid lost updates when two admins move the same line simultaneously.
  • Abuse prevention — what this demo does. Three layers prevent the deployed service from being used as a generic KV/Fanout backend for someone else's site: a channel-name regex on /stream (so the subscriber can only pick from global | game-<id> | user-<id>), tight input regexes on every write endpoint (so KV keys/values stay shaped like the app's own data), and an Origin/Referer gate scoped to the demo's own domain. The user_bets ledger has a per-user cap of 10 entries to bound KV bloat.
  • Abuse prevention — what this demo does not do. Origin checks are defense-in-depth, not a security boundary: curl (or any non-browser client) can supply any Origin header it likes. In production layer Fastly Edge Rate Limiting on /api/* and /stream, fronted by the Fastly Next-Gen WAF for bot/abuse signal — plus actual admin auth on the write endpoints. Treat the regex/Origin pair here as a polite "no" that keeps casual misuse off your service, not a wall.

Teardown

Order matters: stores cannot be deleted while a service still links them, so kill the service first.

fastly service delete --force --service-id=$SID

fastly kv-store delete --store-id <games-id>
fastly kv-store delete --store-id <odds-id>
fastly kv-store delete --store-id <user_bets-id>
fastly config-store delete --store-id <teams-id>
fastly secret-store delete --store-id <fanout_secrets-id>