feat: add Redis-backed SSE updates
This commit is contained in:
@@ -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.
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user