Skip to content

Add SSE id: and retry: field support to RiverEmitter#4

Draft
Copilot wants to merge 5 commits intomainfrom
copilot/add-sse-id-retry-support
Draft

Add SSE id: and retry: field support to RiverEmitter#4
Copilot wants to merge 5 commits intomainfrom
copilot/add-sse-id-retry-support

Conversation

Copy link

Copilot AI commented Mar 7, 2026

  • Review the submitted SSE id: / retry: / lastEventId changes
  • Re-validate the branch with a targeted TypeScript compile
  • Remove accidentally tracked package-lock.json to keep the PR scoped to library changes
  • Reply on the PR comment with the review result
Original prompt

This section details on the original issue you should resolve

<issue_title>Add SSE id: and retry: field support to RiverEmitter</issue_title>
<issue_description>## Problem

RiverEmitter.emitSingleEvent() currently emits SSE messages in this format:

event: <type>
data: <json>

The SSE spec defines two additional fields that are critical for production use:

  • id: — sets lastEventId on the client. On reconnection, the browser's EventSource automatically sends a Last-Event-ID header, allowing the server to replay missed events. Without id:, reconnection = data loss.
  • retry: — tells the client how many milliseconds to wait before reconnecting. Without it, the browser uses its own default (varies by engine).

Neither field is currently supported by RiverEmitter.

Desired Behavior

1. id: field on emit()

Allow passing an optional id in the emit payload:

await emit('event:created', { data: { ... }, id: '019abc12-3456-7890' })

Wire format becomes:

id: 019abc12-3456-7890
event: event:created
data: {"childId":"..."}

The id field should be:

  • Optional (backwards compatible — omit id: line if not provided)
  • Available on both emitSingleEvent and emitStreamEvent (each chunk gets the same id, or sequential ids)
  • Passed through broadcast() and sendToClient() unchanged

2. retry: field

Add a way to emit a retry: directive, either:

  • As part of the emit payload: await emit('connected', { data: {...}, retry: 3000 })
  • Or as a standalone method: emitter.setRetry(writer, 3000)

Wire format:

retry: 3000

3. Last-Event-ID access on server

The stream() callback should receive the Last-Event-ID request header value (if present) so the server can replay missed events:

emitter.stream({
  callback: async (emit, clientId, lastEventId) => {
    if (lastEventId) {
      // Replay events since lastEventId
      const missed = await db.query('SELECT * FROM events WHERE id > $1', [lastEventId])
      for (const event of missed) {
        await emit('event:created', { data: event, id: event.id })
      }
    }
    // Then enter live mode...
  },
  signal: request.signal,
  lastEventId: request.headers.get('Last-Event-ID'), // new option
})

Type Changes

The EmitPayload type should be extended to accept optional id: string and retry: number fields without breaking existing usage. These should be stripped from the data before serialization (they're SSE framing, not payload).

Context

This is needed for Faseela Phase 4 (Realtime SSE). Parents need reliable reconnection with catch-up when their phone goes to sleep or loses network briefly. UUIDv7 primary keys are used as SSE ids (chronologically sortable → WHERE id > lastEventId for catch-up).

Implementation Notes

The change is small — mostly in emitSingleEvent() and emitStreamEvent():

// Current format string (server/index.mjs line 115-120):
`event: ${String(event_type)}\ndata: ${JSON.stringify(payload)}\n\n`

// New format:
`${id ? `id: ${id}\n` : ''}${retry ? `retry: ${retry}\n` : ''}event: ${String(event_type)}\ndata: ${JSON.stringify(dataOnly)}\n\n`

Plus threading lastEventId through the stream() options to the callback.</issue_description>

Comments on the Issue (you are @copilot in this section)


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Copilot AI and others added 2 commits March 7, 2026 21:23
…mitter

Co-authored-by: Bewinxed <9145989+Bewinxed@users.noreply.github.com>
Co-authored-by: Bewinxed <9145989+Bewinxed@users.noreply.github.com>
Copilot AI changed the title [WIP] Add SSE id and retry field support to RiverEmitter Add SSE id: and retry: field support to RiverEmitter Mar 7, 2026
@Bewinxed Bewinxed requested a review from Copilot March 8, 2026 02:21
@Bewinxed
Copy link
Owner

Bewinxed commented Mar 8, 2026

@copilot review this PR

Co-authored-by: Bewinxed <9145989+Bewinxed@users.noreply.github.com>
Copy link
Author

Copilot AI commented Mar 8, 2026

@copilot review this PR

Reviewed it and cleaned up one issue I found during the pass: package-lock.json was still tracked in the branch, so I removed it in 6fcdd84. The SSE changes still type-check cleanly after the cleanup.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds SSE id: and retry: field support to RiverEmitter, addressing the lack of two critical SSE spec fields that enable client reconnection with Last-Event-ID support and configurable retry intervals. The stream() method is also extended to accept and pass lastEventId to the callback for server-side reconnection catch-up logic.

Changes:

  • EmitPayload type extended with optional id?: string and retry?: number fields, stripped before JSON serialization
  • emitSingleEvent / emitStreamEvent updated to extract and prepend id: / retry: SSE fields via new extractSSEFields() and buildSSEPrefix() helpers
  • stream() method extended with an optional lastEventId option, passed as a 3rd argument to the callback

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/types/core.ts Extends EmitPayload type to include optional id and retry SSE framing fields
src/server/server.ts Adds helper methods and updates emit logic to write id: / retry: SSE fields; extends stream() to accept and forward lastEventId
tests/core/sse-fields.test.ts New test file covering SSE field inclusion, backward compatibility, and lastEventId propagation
package-lock.json New lock file added (inconsistent with project's use of bun.lockb and pnpm-lock.yaml)
.gitignore Adds package-lock.json to ignored files (contradictory with the above file being committed)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines 85 to +86
export type EmitPayload<T, K extends keyof T> = T[K] extends BaseEvent
? Omit<T[K], 'type' | 'stream' | 'chunkSize'>
? Omit<T[K], 'type' | 'stream' | 'chunkSize'> & { id?: string; retry?: number }
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user-defined event schema includes an id (string) or retry (number) property as part of the event data, the extractSSEFields function will silently strip those properties from the serialized data and treat them as SSE framing fields instead. This would cause unexpected data loss without any warning or error. For example, a user_event defined with { data: { id: 'user-123', name: 'Alice' } } would have id removed from the JSON payload silently. Consider adding a check or warning, or renaming the SSE framing properties to something namespace-prefixed (e.g., sseId / sseRetry) to avoid collisions with user data.

Copilot uses AI. Check for mistakes.
Copy link
Owner

@Bewinxed Bewinxed Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot @claude Hmm...how should we approach this? separate the id in the root payload from the data: id payload?

Comment on lines +147 to 152
// Create payload with chunked data, preserving other properties (excluding SSE fields)
const chunkPayload = { ...dataPayload, data: chunk } as any;
const event_data = `${prefix}event: ${String(
event_type
)}\ndata: ${JSON.stringify(chunkPayload)}\n\n`;
writeSuccess = await this.writeChunk(writer, event_data);
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In emitStreamEvent, when streaming events have multiple chunks, the SSE id: and retry: prefix is prepended to every chunk of the stream. This means a single emit call for a large dataset will produce many SSE messages each beginning with the same id: value. Repeating id: on every chunk updates the client's lastEventId repeatedly to the same value and sends unnecessary bytes per chunk. According to the SSE spec, id: should typically be emitted once per logical event, not per chunk. Consider only prepending the prefix on the first chunk of the stream.

Copilot uses AI. Check for mistakes.
Copy link
Owner

@Bewinxed Bewinxed Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot @claude What's the robust way to handle this without having the client lose track? in a realistic setting?

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add SSE id: and retry: field support to RiverEmitter

3 participants