Files
erika/skill/erika-sse/SKILL.md
T

6.4 KiB

name, description
name description
erika-sse 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.

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.

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:

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.

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.

<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

// 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:

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.