-
Notifications
You must be signed in to change notification settings - Fork 3
feat(examples): realtime session queue reference (server gate + web client) #187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
8cbd0f8
feat(examples): add realtime-session-queue reference
AdirAmsalem 9615469
fix(examples): re-mint lapsed token when a user rejoins a held slot
AdirAmsalem d15ce26
fix(examples): per-tab demo identity so extra tabs queue instead of s…
AdirAmsalem 6e03045
refactor(examples): one spot per ticket — capacity limits sessions, n…
AdirAmsalem 3f1e6e1
docs(examples): forward-looking comments
AdirAmsalem f1c2410
feat(examples): preload a bundled sample garment for zero-setup testing
AdirAmsalem 4dab226
style(examples): apply biome formatting across examples
AdirAmsalem 089066d
refactor(examples): generic /api/queue route namespace
AdirAmsalem 83ac1d7
refactor(examples): QUEUE_CAPACITY env var, restore .env.example, tri…
AdirAmsalem 7b00715
fix(examples): address review findings — resilient backstop requeue, …
AdirAmsalem 1e20fe5
fix(examples): align token lifetime with claim grace; prune in started
AdirAmsalem File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| # Your Decart API key (server-side only — never shipped to the app) | ||
| DECART_API_KEY=your-api-key-here | ||
|
|
||
| # Number of concurrent try-on sessions to allow. Must not exceed your | ||
| # account's realtime concurrency limit. | ||
| # TRYON_CAPACITY=10 | ||
|
|
||
| # Hard per-session cap in seconds, enforced server-side by Decart via the | ||
| # ephemeral token's maxSessionDuration constraint. | ||
| # MAX_SESSION_SECONDS=120 | ||
|
|
||
| # PORT=3000 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| # realtime-session-queue | ||
|
|
||
| Reference implementation of a **customer-side waiting queue** for capacity-limited realtime sessions. Use it when your account has a realtime concurrency limit (say, 10 sessions) and you want users beyond that limit to wait in a fair line with live position feedback — instead of seeing failed connections. | ||
|
|
||
| The scenario modeled here is a virtual try-on experience (`lucy-vton-latest`), but the pattern applies to any realtime model. | ||
|
|
||
| > This is *not* Decart's jobs/queue API (`client.queue`, for async video). It's a gate you run in front of `client.realtime.connect()`. | ||
|
|
||
| ## The idea | ||
|
|
||
| Your backend already has to hold your Decart API key and mint [ephemeral client tokens](https://docs.platform.decart.ai) for your app. The queue is nothing more than deciding **when to mint the next token**: | ||
|
|
||
| ``` | ||
| App Your gatekeeper (this example) Decart | ||
| │ POST /api/tryon/tickets │ │ | ||
| ├──────────────────────────>│ enqueue │ | ||
| │ { ticketId, position } │ │ | ||
| │ POST .../poll (every 2s) │ │ | ||
| ├──────────────────────────>│ waiting → { position, queueSize } │ | ||
| │ ... │ │ | ||
| ├──────────────────────────>│ head of line + free slot? │ | ||
| │ ├── tokens.create(expiresIn, maxSessionDuration) | ||
| │ { state: "ready", │<──────────────────────────────────────────┤ | ||
| │ session: { apiKey } } │ │ | ||
| │<──────────────────────────┤ │ | ||
| │ realtime.connect(apiKey) — WebSocket + WebRTC, key never in the app │ | ||
| ├───────────────────────────────────────────────────────────────────────> | ||
| │ POST .../started once connected │ | ||
| │ POST .../release { reason } when done │ | ||
| ``` | ||
|
|
||
| Three properties make this robust without needing to be clever: | ||
|
|
||
| 1. **The token is the no-show timeout.** A granted token expires in 60s (`expiresIn`) — that's the *connect window*, not the session length. If the user never connects, the token dies on its own and the slot's claim grace (45s) returns it to the line. | ||
| 2. **Decart enforces the session cap for you.** The token carries `constraints.realtime.maxSessionDuration` (120s here). Decart kills the session server-side when it's up — even if the app was backgrounded or killed — so a slot can never be squatted and the queue always moves. | ||
| 3. **Decart is the backstop for races.** The gate keeps you at/below the limit, but if a connect still gets refused (see below), the app reports `limit_reached` and the server requeues that ticket **at the head of the line** — the user recovers on the next poll instead of losing their place. | ||
|
|
||
| ## Slot lifecycle | ||
|
|
||
| A granted slot is a *lease* with exactly two phases: | ||
|
|
||
| | Phase | Freed by | | ||
| |---|---| | ||
| | Granted, not yet connected | Explicit `release`, or the 45s claim grace (camera prompt abandoned, app died) | | ||
| | Connected (`started` reported) | Explicit `release`, or the session bound (`maxSessionDuration` + 30s slack) | | ||
|
|
||
| Waiting tickets have the same protection: a ticket that stops polling for 30s leaves the line, so closed apps don't hold up the queue. | ||
|
|
||
| ## What happens on Decart's side | ||
|
|
||
| Your concurrency limit is enforced per organization (or per user, for user-scoped keys) — every ephemeral token minted from the same key counts into one bucket, checked **once at connect time**: | ||
|
|
||
| - Over the limit → the WebSocket is closed with code **1013** and an error message `"Concurrent session limit reached."`. A session that's already running is never dropped for concurrency. | ||
| - A cleanly disconnected session frees its slot within ~5s; a crashed one within ~45s. This lag is exactly why the `limit_reached` backstop exists: your gate can believe a slot is free slightly before Decart does. | ||
| - The SDK currently retries connect errors with backoff before surfacing them, so a 1013 rejection takes ~30s to reach your error handler. This cuts both ways: if capacity frees up mid-retry, the SDK connects on its own and the backstop never fires. It's rare either way — the gate prevents it in the common case — but don't treat the retry delay as a hang. | ||
|
|
||
| ## Running it | ||
|
|
||
| ```sh | ||
| # from the repo root | ||
| pnpm install | ||
| pnpm --filter @decartai/sdk build | ||
|
|
||
| cd examples/realtime-session-queue | ||
| cp .env.example .env # add your DECART_API_KEY | ||
| pnpm dev # gatekeeper on :3000, web app on :5173 | ||
| ``` | ||
|
|
||
| Open http://localhost:5173, pick a garment photo, and hit *Try it on*. | ||
|
|
||
| **Watching the queue actually queue:** set `TRYON_CAPACITY=1` in `.env`, open two browser tabs, and start a session in each. Each tab acts as a distinct user (per-tab identity), so the second tab waits with a live position and is granted the slot as soon as the first session ends — or after at most `MAX_SESSION_SECONDS`. Reloading a tab keeps its identity: a user who restarts mid-session gets their held slot back with fresh credentials. | ||
|
|
||
| ```sh | ||
| curl -s localhost:3000/api/tryon/stats # { waiting, active, capacity } | ||
| ``` | ||
|
|
||
| The queue semantics (FIFO, no-show reclaim, session bound, requeue-at-head, ...) are covered by `pnpm test` — the queue takes time and token-minting as inputs (`server/queue.ts`), so the tests are instant and deterministic. | ||
|
|
||
| ## Protocol | ||
|
|
||
| | Endpoint | Purpose | | ||
| |---|---| | ||
| | `POST /api/tryon/tickets` | Join the line (idempotent per user — no cutting the line with extra tabs). Header `x-user-id` stands in for your real auth. | | ||
| | `POST /api/tryon/tickets/:id/poll` | Poll every ~2s. Returns `waiting` (position), `ready` (session credentials), or `410` if the ticket expired. **Polling is also the claim**: the poll that finds you at the head of a free slot mints your token. | | ||
| | `POST /api/tryon/tickets/:id/started` | Once, when `realtime.connect()` succeeds — extends the lease from the claim grace to the full session bound. | | ||
| | `POST /api/tryon/tickets/:id/release` | `{ reason: "ended" \| "limit_reached" }`. `limit_reached` requeues at the head. | | ||
| | `GET /api/tryon/stats` | `{ waiting, active, capacity }` for dashboards/ops. | | ||
|
|
||
| Plain short-poll keeps mobile clients simple and robust (backgrounding, flaky networks). If you want snappier updates later, swap the poll loop for SSE/WebSocket pushes without touching the queue. | ||
|
|
||
| ## Porting the client to React Native | ||
|
|
||
| The queue client (`web/src/hooks/useQueue.ts`) intentionally uses only `fetch`, timers, and React state — it runs under React Native unchanged. What differs: | ||
|
|
||
| - **Realtime SDK**: `@decartai/sdk` ships a React Native entry point; the camera/rendering component (`TryOnSession.tsx`) is the only part you rewrite, same as any RN port. | ||
| - **Release on app close**: RN `fetch` has no `keepalive`; hook `AppState` changes to fire `release` when backgrounding. If the app is killed outright, the lease expires on its own — that's what it's for. | ||
| - **User identity**: replace the `localStorage` stand-in with your account/device id. | ||
|
|
||
| ## Hardening for production | ||
|
|
||
| This example keeps every mechanism that makes the queue *correct* and drops what a demo doesn't need. Before shipping: | ||
|
|
||
| - **Auth**: replace the `x-user-id` header with your real user authentication on every endpoint. | ||
| - **Never ship your API key in the app.** The app only ever sees the short-lived token (that's the point of this whole design). | ||
| - **Multiple server instances**: the in-memory state in `server/queue.ts` assumes one instance. The state maps directly onto a shared store — waiting line → sorted set keyed by a monotonic sequence, leases → sorted set keyed by expiry — with one rule: the head-of-line check and lease reservation in `poll()` must be atomic (a Redis Lua script, or a transactional `SELECT ... FOR UPDATE`). This is the same technique Decart's own limiter uses. | ||
| - **Faster reclaim of crashed apps**: the lease is only reclaimed at the session bound (~2.5 min worst case) if the app dies mid-session without releasing. If that's too slow, have the app heartbeat every ~10s and expire leases ~30s after the last beat. | ||
| - **Wait-time estimates**: track recent session durations and show `position × avg ÷ capacity` next to the position. | ||
| - **Shrinking the race window**: `GET /v1/realtime/quota` (with your API key) returns your live `{ limit, active, remaining }` — cross-check it before granting if you want to make `limit_reached` even rarer. | ||
| - **Capacity**: run `TRYON_CAPACITY` at your account limit; the backstop handles the rare race. Remember the limit is org-wide — other realtime usage on the same account eats into it. | ||
| - **Tune `MAX_SESSION_SECONDS`** — it's the single biggest lever on queue throughput: wait time ≈ position × session length ÷ capacity. | ||
| - **Priority tiers**, if you need them later, are one change: order the waiting line by (tier, arrival) instead of arrival alone. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| { | ||
| "name": "@example/realtime-session-queue", | ||
| "version": "0.0.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "dev": "concurrently -n server,web -c blue,green \"pnpm dev:server\" \"pnpm dev:web\"", | ||
| "dev:server": "tsx watch server/main.ts", | ||
| "dev:web": "vite web", | ||
| "build:web": "vite build web", | ||
| "test": "tsx --test test/queue.test.ts", | ||
| "typecheck": "tsc -p server/tsconfig.json && tsc -p web/tsconfig.json" | ||
| }, | ||
| "dependencies": { | ||
| "@decartai/sdk": "workspace:*", | ||
| "dotenv": "^16.4.0", | ||
| "express": "^4.21.0", | ||
| "react": "^19.0.0", | ||
| "react-dom": "^19.0.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/express": "^4.17.0", | ||
| "@types/node": "^22.0.0", | ||
| "@types/react": "^19.0.0", | ||
| "@types/react-dom": "^19.0.0", | ||
| "@vitejs/plugin-react": "^4.3.0", | ||
| "concurrently": "^9.0.0", | ||
| "tsx": "^4.19.0", | ||
| "typescript": "^5.8.0", | ||
| "vite": "^6.0.0" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import "dotenv/config"; | ||
|
|
||
| function int(name: string, fallback: number): number { | ||
| const raw = process.env[name]; | ||
| if (!raw) return fallback; | ||
| const value = Number.parseInt(raw, 10); | ||
| if (Number.isNaN(value) || value <= 0) { | ||
| throw new Error(`${name} must be a positive integer, got "${raw}"`); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| const decartApiKey = process.env.DECART_API_KEY; | ||
| if (!decartApiKey) { | ||
| throw new Error("DECART_API_KEY is required — copy .env.example to .env and fill it in"); | ||
| } | ||
|
|
||
| export const config = { | ||
| port: int("PORT", 3000), | ||
| decartApiKey, | ||
| model: "lucy-vton-latest" as const, | ||
| /** Concurrent sessions to allow. Must not exceed your account's realtime concurrency limit. */ | ||
| capacity: int("TRYON_CAPACITY", 10), | ||
| /** Hard per-session cap. Decart enforces it server-side via the token constraint, so a | ||
| * killed app can never squat a slot longer than this — it's what keeps the queue moving. */ | ||
| maxSessionSeconds: int("MAX_SESSION_SECONDS", 120), | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { createDecartClient } from "@decartai/sdk"; | ||
| import express from "express"; | ||
| import { config } from "./config.js"; | ||
| import { type GrantedSession, Queue } from "./queue.js"; | ||
|
|
||
| const decart = createDecartClient({ apiKey: config.decartApiKey }); | ||
|
|
||
| const queue = new Queue({ | ||
| capacity: config.capacity, | ||
| claimGraceMs: 45_000, | ||
| sessionLeaseMs: (config.maxSessionSeconds + 30) * 1000, | ||
| waitingTtlMs: 30_000, | ||
| mint: async (): Promise<GrantedSession> => { | ||
| const token = await decart.tokens.create({ | ||
| // The window the client has to *connect*, not the session length. | ||
| expiresIn: 60, | ||
| allowedModels: [config.model], | ||
| constraints: { realtime: { maxSessionDuration: config.maxSessionSeconds } }, | ||
| }); | ||
| return { | ||
| apiKey: token.apiKey, | ||
| expiresAt: token.expiresAt, | ||
| model: config.model, | ||
| maxSessionSeconds: config.maxSessionSeconds, | ||
| }; | ||
| }, | ||
| }); | ||
|
|
||
| const app = express(); | ||
| app.use(express.json()); | ||
|
|
||
| // Express 4 doesn't catch rejected promises from async handlers; route them | ||
| // to the default error handler (500) instead of an unhandled rejection. | ||
| function asyncHandler(fn: (req: express.Request, res: express.Response) => Promise<void>): express.RequestHandler { | ||
| return (req, res, next) => fn(req, res).catch(next); | ||
| } | ||
|
|
||
| /** Stand-in for real user authentication — see README before shipping. */ | ||
| function requireUserId(req: express.Request, res: express.Response): string | null { | ||
| const userId = req.header("x-user-id"); | ||
| if (!userId) { | ||
| res.status(401).json({ error: "missing x-user-id header" }); | ||
| return null; | ||
| } | ||
| return userId; | ||
| } | ||
|
|
||
| app.post("/api/tryon/tickets", (req, res) => { | ||
| const userId = requireUserId(req, res); | ||
| if (!userId) return; | ||
| res.json(queue.join(userId, Date.now())); | ||
| }); | ||
|
|
||
| app.post( | ||
| "/api/tryon/tickets/:ticketId/poll", | ||
| asyncHandler(async (req, res) => { | ||
| const status = await queue.poll(req.params.ticketId, Date.now()); | ||
| res.status(status.state === "expired" ? 410 : 200).json(status); | ||
| }), | ||
| ); | ||
|
|
||
| app.post("/api/tryon/tickets/:ticketId/started", (req, res) => { | ||
| if (!queue.started(req.params.ticketId, Date.now())) { | ||
| res.status(410).json({ state: "expired" }); | ||
| return; | ||
| } | ||
| res.json({ ok: true }); | ||
| }); | ||
|
|
||
| app.post("/api/tryon/tickets/:ticketId/release", (req, res) => { | ||
| const reason = req.body?.reason; | ||
| if (reason !== "ended" && reason !== "limit_reached") { | ||
| res.status(400).json({ error: 'reason must be "ended" or "limit_reached"' }); | ||
| return; | ||
| } | ||
| queue.release(req.params.ticketId, reason === "limit_reached", Date.now()); | ||
| res.json({ ok: true }); | ||
| }); | ||
|
|
||
| app.get("/api/tryon/stats", (_req, res) => { | ||
| res.json(queue.stats(Date.now())); | ||
| }); | ||
|
|
||
| app.listen(config.port, () => { | ||
| console.log(`Gatekeeper listening on http://localhost:${config.port}`); | ||
| console.log(`Capacity: ${config.capacity} concurrent sessions, ${config.maxSessionSeconds}s max each`); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.