feat: add Redis-backed SSE updates

This commit is contained in:
2026-07-24 21:31:47 +07:00 Unverified
parent 5cc7edaa13
commit 29df784aeb
22 changed files with 912 additions and 96 deletions
+153
View File
@@ -0,0 +1,153 @@
---
name: erika-sse
description: Build, extend, debug, or review Erika's Server-Sent Events system, Redis pub/sub transport, typed Zod event topics, reconnecting live-update clients, and router.refresh-based real-time UI. Use for SSE, Redis-backed live updates, protected streams, new real-time features, or future Erika topics such as Discord level/XP.
---
# Erika SSE
Use Erika's shared Redis-backed SSE transport for cross-process delivery. Preserve the established reliability, authorization, validation, and RSC refresh behavior.
## Start with the project
1. Read `AGENTS.md`.
2. Read the relevant Next.js guide in `node_modules/next/dist/docs/` before changing routes, client components, or refresh behavior.
3. Inspect `lib/sse.ts`, `lib/redis.ts`, `app/sse/[topic]/route.ts`, and `components/realtime-refresh.tsx`.
4. Trace every mutation and every route that should consume the event before editing.
Keep follower counts outside this SSE system unless the task explicitly changes that boundary.
## Declare a topic
Add the topic and its named Zod event schemas to the `createSseEndpoints` map in `lib/sse.ts`. Use strict object schemas, stable identifiers, and the smallest payload that lets consumers filter an event.
Mark a topic `adminOnly: true` when its payload or existence must not be public. The shared `/sse/[topic]` route validates topic names and enforces this flag; do not add a second transport route.
```ts
export const sse = createSseEndpoints({
discordLevels: {
adminOnly: true,
events: {
update: z.object({
discordId: z.string().min(1),
level: z.number().int().nonnegative(),
xp: z.number().int().nonnegative(),
}).strict(),
},
},
});
```
Choose public access only when every authenticated and unauthenticated visitor may receive the payload. Never rely on the client to hide an admin event.
## Publish after mutations
Publish only after the database operation or transaction succeeds. Keep publishing best-effort with `void`; Redis failure must not turn a committed mutation into an action error.
```ts
await db.update(forms).set(nextData).where(eq(forms.id, formId));
void sse.forms.pub("update", {
formId,
entity: "form",
action: "updated",
});
```
Publish once after a bulk operation:
```ts
await Promise.all(updates.map(updateQuestion));
void sse.forms.pub("update", {
formId,
entity: "question",
action: "updated",
});
```
Do not publish before an awaited mutation, inside an item loop, or from a `finally` block. Include an optional item identifier only when consumers benefit from it; retain a parent identifier such as `formId` for route filtering.
## Subscribe and refresh
Use `sse.<topic>.sub(...)` or `subMany(...)` in a Client Component. Always return `clean()` from the effect so reconnection timers and the underlying `EventSource` stop on unmount.
```tsx
useEffect(() => {
const { clean } = sse.discordLevels.sub("update", handleUpdate);
return clean;
}, [handleUpdate]);
```
For server-rendered page updates, prefer `RealtimeRefresh`. Pass `formId` on detail routes to ignore unrelated events. Subscribe only on routes that display that topic.
```tsx
<RealtimeRefresh topic="submissions" formId={formId} />
```
Keep its debounced `router.refresh()` behavior. It merges the new RSC payload without a full reload, scroll reset, or loss of unaffected client state. Refresh on a reconnect after the first successful connection to recover events missed while disconnected.
When refreshed props feed an autosaving editor:
- Copy server props into local state only while that state is not dirty or saving.
- Track local revisions so an older save cannot clear a newer edit.
- Update state refs together with synchronized props.
- Trigger autosave only from user edit handlers, not from prop-synchronization effects.
- Keep a local deletion out of the next queued bulk save.
## Preserve transport behavior
Keep separate Redis publisher and subscriber connections created with `RedisClient.duplicate()`. A subscribed Redis connection must not run ordinary commands.
Validate data when publishing, when reading Redis messages, and again in the browser. Ignore malformed or unknown Redis messages rather than forwarding them.
Retain these defaults:
- Redis channels: `erika:sse:<topic>`
- Client retry guidance: 3 seconds
- Heartbeat comment: every 90 seconds
- Forced connection renewal: 30 minutes
- Publish deadline: 2 seconds, with logged and contained failure
- Response headers: `text/event-stream`, `no-cache, no-transform`, `keep-alive`, `identity`, and `X-Accel-Buffering: no`
Keep Nginx on HTTP/1.1 with an empty `Connection` proxy header, `proxy_buffering off`, and `proxy_read_timeout` longer than the 90-second heartbeat. Erika's existing `nginx.conf` already has these settings.
## Avoid these patterns
```ts
// Incorrect: process-local and invisible to other instances.
globalThis.listeners = new Set();
// Incorrect: bypasses schemas.
redis.publish("forms", JSON.stringify(uncheckedPayload));
// Incorrect: one event per item creates refresh storms.
for (const item of items) {
await mutate(item);
void sse.forms.pub("update", payload);
}
// Incorrect: loses scroll and client state.
window.location.reload();
```
Do not introduce in-memory event hubs, full-page reloads, unvalidated payloads, broad admin-layout subscriptions, or per-item events for one bulk mutation.
## Validate every change
Run:
```bash
bunx tsc --noEmit
bun run lint
git diff --check
python3 "${CODEX_HOME:-$HOME/.codex}/skills/.system/skill-creator/scripts/quick_validate.py" skill/erika-sse
```
Perform runtime checks on an isolated instance:
1. Confirm an unknown `/sse/<topic>` returns 404.
2. Confirm an unauthenticated admin-only stream returns 401.
3. Use `curl -N` to inspect the status, headers, retry/connected frame, named event, and JSON payload.
4. Temporarily shorten `SSE_HEARTBEAT_MS` and `SSE_CONNECTION_MAX_MS` to observe heartbeat comments and forced renewal; leave production defaults unchanged.
5. Confirm Redis delivery and verify Redis `PUBSUB NUMSUB` returns to zero after abort/renewal.
6. Verify a refused Redis connection logs a publish failure while the completed database mutation still succeeds.
7. Use two browser sessions to verify remote form/submission changes, one debounced refresh for event bursts, preserved scroll/unaffected state, and correct active-form filtering.
8. Re-run TypeScript, ESLint, and `git diff --check` after runtime testing and remove temporary artifacts.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "Erika SSE"
short_description: "Build and extend Erika Redis-backed live updates"
default_prompt: "Use $erika-sse to add a typed, Redis-backed real-time topic to Erika."