154 lines
6.4 KiB
Markdown
154 lines
6.4 KiB
Markdown
---
|
|
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.
|