Skip to content

RFC: connectSseByContract#889

Merged
leonaves merged 15 commits intomainfrom
rf/connect-sse-by-contract
Mar 17, 2026
Merged

RFC: connectSseByContract#889
leonaves merged 15 commits intomainfrom
rf/connect-sse-by-contract

Conversation

@leonaves
Copy link
Copy Markdown
Contributor

@leonaves leonaves commented Mar 12, 2026

Changes

Adds connectSseByContract to @lokalise/frontend-http-client — a new function for opening SSE connections using SSE and dual-mode contracts. As we migrate from websockets to SSE, this gives frontend consumers a typed, contract-driven way to connect to streaming endpoints using the same wretch instance and auth middleware they already use for sync requests.

  • Accepts any contract built with buildSseContract (both SSE-only and dual-mode)
  • Supports all SSE HTTP methods (GET, POST, PUT, PATCH) with typed path params, query params, body, and headers
  • Validates request params and body against contract schemas before connecting
  • Parses the SSE wire format from the response stream and validates each event's data against the contract's event schemas
  • Dispatches to typed per-event callbacks (onEvent), plus onOpen and onError lifecycle hooks
  • Returns an SseConnection handle with close() to abort the stream

New exports

  • connectSseByContract — the main function
  • SseConnection, SseCallbacks, SseRouteRequestParams — supporting types

Usage example

Using getWorkspacePortalRequestsContract (a dual-mode contract):

import { connectSseByContract } from '@lokalise/frontend-http-client'
import { getWorkspacePortalRequestsContract } from '@lokalise/portal-api-schemas'

const connection = connectSseByContract(
  wretch, // your wretch instance with auth middleware
  getWorkspacePortalRequestsContract,
  {
    pathParams: { workspaceId: '01234567-abcd-abcd-abcd-0123456789ab' },
    queryParams: { limit: 50 },
  },
  {
    onEvent: {
      'portal_request.ready': (data) => {
        // data is typed as { portalRequest: PortalRequest }
        console.log('Request ready:', data.portalRequest)
      },
    },
    onOpen: () => console.log('SSE connected'),
    onError: (err) => console.error('SSE error:', err),
  },
)

// Later, to tear down:
connection.close()

Checklist

  • Apply one of following labels; major, minor, patch or skip-release
  • I've updated the documentation, or no changes were necessary
  • I've updated the tests, or no changes were necessary

AI Assistance Tracking

We're running a metric to understand where AI assists our engineering work. Please select exactly one of the options below:

Mark "Yes" if AI helped in any part of this work, for example: generating code, refactoring, debugging support,
explaining something, reviewing an idea, or suggesting an approach.

  • Yes, AI assisted with this PR
  • No, AI did not assist with this PR

@lokalise lokalise deleted a comment from coderabbitai bot Mar 12, 2026
@leonaves leonaves added enhancement New feature or request ai-assisted minor labels Mar 12, 2026
@lokalise lokalise deleted a comment from coderabbitai bot Mar 12, 2026
})

it('silently ignores unknown event names', async () => {
await mockServer.forGet('/events/stream').thenCallback(() => ({
Copy link
Copy Markdown
Collaborator

@kibertoad kibertoad Mar 16, 2026

Choose a reason for hiding this comment

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

@leonaves would providing some test server implementation for FE testing inside opinionated-machine for cases like this be helpful? to endure mocking is fully accurate with how the real thing works

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

that sounds good, maybe could we make it part of a follow up?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I can build it already :D.

but yeah, migrating to it can be a follow-up

/* v8 ignore stop */
const reader = response.body.getReader()

for await (const { event, data } of parseSseStream(reader, abortController.signal)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

do I inderstand correctly that this iteration is supposed to keep running indefinitely until the user closes the page/navigates away?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You can also close manually with connection.close() with the returned connection object.

if (state.currentData) {
state.currentData += '\n'
}
state.currentData += line.slice(5).trim()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

are we sure we don't want to use classes for managing state that has logic?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated 👍

buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
// Keep the last element — it may be a partial line (split always returns at least 1 element)
/* v8 ignore start */
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why is the branch unreachable?

Copy link
Copy Markdown
Contributor Author

@leonaves leonaves Mar 17, 2026

Choose a reason for hiding this comment

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

it's just the part after ??, it's needed for typescript but split never actually returns no elements, so that's never executed, breaking coverage a bit.

@lokalise lokalise deleted a comment from coderabbitai bot Mar 17, 2026
@leonaves leonaves marked this pull request as ready for review March 17, 2026 10:25
@leonaves leonaves requested review from a team, CarlosGamero and dariacm as code owners March 17, 2026 10:25
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 17, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository: lokalise/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bc5d84ac-6f90-4015-99bc-9974455f4452

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request adds a new SSE (Server-Sent Events) client module to the frontend HTTP client package. The implementation includes a typed SSE connection mechanism driven by contract definitions, with utilities for parsing SSE streams, parameter validation, header resolution, and event callback dispatching. A new test suite with 649 lines provides comprehensive coverage for connection handling, parameter validation, event parsing, error scenarios, and callback invocation. The module exports are re-exposed through the package's main entry point.

Sequence Diagram

sequenceDiagram
    actor Client
    participant connectSse as connectSseByContract
    participant Validation as Parameter Validation
    participant Wretch as HTTP Client (Wretch)
    participant Stream as SSE Stream
    participant Parser as parseSseStream
    participant Contracts as Contract Schemas
    participant Callbacks as User Callbacks

    Client->>connectSse: connectSseByContract(wretch, contract, params, callbacks)
    connectSse->>Validation: Validate pathParams, queryParams, body
    Validation-->>connectSse: Validation result
    alt Validation fails
        connectSse->>Callbacks: onError(ValidationError)
    else Validation succeeds
        connectSse->>Wretch: GET/POST with headers, params
        Wretch-->>Stream: text/event-stream response
        connectSse->>Parser: parseSseStream(reader, abortSignal)
        loop Until stream ends or aborts
            Parser->>Stream: Read SSE data chunks
            Stream-->>Parser: Raw bytes
            Parser->>Parser: Decode and parse SSE format
            Parser-->>connectSse: SseEvent {event, data}
            connectSse->>Contracts: Validate event against schema
            alt Validation succeeds
                Contracts-->>connectSse: Parsed event data
                connectSse->>Callbacks: onEvent[eventType](data)
            else Validation fails
                connectSse->>Callbacks: onError(ValidationError)
            end
        end
        connectSse->>Callbacks: onOpen() [on connection established]
    end
    Client->>connectSse: close()
    connectSse->>Stream: Abort stream
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'RFC: connectSseByContract' directly corresponds to the main feature being added—a new connectSseByContract function for SSE connections. It is specific, concise, and clearly indicates the primary change.
Description check ✅ Passed The description fully covers the template requirements: changes section explains the new feature with context, all three checklist items are marked complete, and AI assistance is disclosed. The description is comprehensive, includes usage examples, and clearly documents the new exports.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rf/connect-sse-by-contract
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can suggest fixes for GitHub Check annotations.

Configure the reviews.tools.github-checks setting to adjust the time to wait for GitHub Checks to complete.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/app/frontend-http-client/src/sse.spec.ts (2)

550-582: Header resolution test doesn't verify headers were sent.

The test verifies that providing a headers function doesn't break the connection, but it doesn't assert that the custom header (x-custom: test-value) was actually sent to the server. The mock callback receives the request but ignores it.

Consider adding an assertion:

Proposed enhancement
-    await mockServer.forGet('/events/stream').thenCallback((_req) => ({
+    await mockServer.forGet('/events/stream').thenCallback((req) => {
+      expect(req.headers['x-custom']).toBe('test-value')
+      return {
       statusCode: 200,
       headers: { 'Content-Type': 'text/event-stream' },
       body: sseResponse([{ event: 'done', data: JSON.stringify({ total: 0 }) }]),
-    }))
+      }
+    })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app/frontend-http-client/src/sse.spec.ts` around lines 550 - 582,
The test should assert the custom header was actually sent: modify the
mockServer.forGet('/events/stream') callback to inspect the incoming request
(the _req argument) and either store or directly assert that
_req.headers['x-custom'] (or _req.getHeader('x-custom') depending on the mock
API) equals 'test-value'; keep the rest of the test using headersFn,
headerContract, connectSseByContract and onDone as-is, and perform the header
assertion before closing the connection.

584-616: Same applies to async header resolution test.

Similar to the sync header test, this test should verify the async-resolved header was actually included in the request.

Proposed enhancement
-    await mockServer.forGet('/events/stream').thenCallback(() => ({
+    await mockServer.forGet('/events/stream').thenCallback((req) => {
+      expect(req.headers['x-custom']).toBe('async-value')
+      return {
       statusCode: 200,
       headers: { 'Content-Type': 'text/event-stream' },
       body: sseResponse([{ event: 'done', data: JSON.stringify({ total: 0 }) }]),
-    }))
+      }
+    })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app/frontend-http-client/src/sse.spec.ts` around lines 584 - 616,
The test currently doesn't assert that the async-resolved header is actually
sent; update the mockServer callback used in mockServer.forGet('/events/stream')
to capture/inspect the incoming request headers (the request param provided to
thenCallback) and assert that request.headers['x-custom'] === 'async-value' (or
equivalent) before returning the SSE response; keep using asyncHeadersFn,
headerContract, connectSseByContract and onDone as-is but add this header
assertion to verify async header resolution.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/app/frontend-http-client/src/sse.spec.ts`:
- Around line 483-516: The test constructs a multi-line SSE JSON payload that,
when joined with '\n', produces invalid JSON and causes handleSseEvent to hit
callbacks.onError instead of invoking the expected handler; update the test in
sse.spec.ts so the SSE split occurs at a valid JSON token boundary (e.g., split
after the '[' or after a complete key/value) so the joined data string is valid
JSON, or alternatively change the test to send multi-line plain text (not JSON)
or add an onError mock and assert it is called; locate the construction of
multiLineData / firstHalf / secondHalf and the connectSseByContract call to
apply the fix.

In `@packages/app/frontend-http-client/src/sse.ts`:
- Around line 226-241: The parseRequestBody result is validated but its
transformed value isn't applied, so downstream code uses the original
sseParams.body; update sseParams.body with the validated result before calling
runSseConnection. Specifically, after calling parseRequestBody(...) and after
the isFailure(body) check, set sseParams.body = body.result (or the appropriate
success value from parseRequestBody) so runSseConnection(wretch, contract,
sseParams, ...) sends the validated/transformed body; keep the existing error
handling using callbacks.onError and return behavior unchanged.

---

Nitpick comments:
In `@packages/app/frontend-http-client/src/sse.spec.ts`:
- Around line 550-582: The test should assert the custom header was actually
sent: modify the mockServer.forGet('/events/stream') callback to inspect the
incoming request (the _req argument) and either store or directly assert that
_req.headers['x-custom'] (or _req.getHeader('x-custom') depending on the mock
API) equals 'test-value'; keep the rest of the test using headersFn,
headerContract, connectSseByContract and onDone as-is, and perform the header
assertion before closing the connection.
- Around line 584-616: The test currently doesn't assert that the async-resolved
header is actually sent; update the mockServer callback used in
mockServer.forGet('/events/stream') to capture/inspect the incoming request
headers (the request param provided to thenCallback) and assert that
request.headers['x-custom'] === 'async-value' (or equivalent) before returning
the SSE response; keep using asyncHeadersFn, headerContract,
connectSseByContract and onDone as-is but add this header assertion to verify
async header resolution.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: lokalise/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8042fc77-46e1-4264-9b8a-21f0b3bd62e1

📥 Commits

Reviewing files that changed from the base of the PR and between 713caa7 and 4a559f1.

📒 Files selected for processing (4)
  • packages/app/frontend-http-client/src/index.ts
  • packages/app/frontend-http-client/src/sse.spec.ts
  • packages/app/frontend-http-client/src/sse.ts
  • packages/app/frontend-http-client/src/utils/sseUtils.ts

handler?.(result.data)
} catch (err) {
/* v8 ignore start */
const message = err instanceof Error ? err.message : String(err)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

instanceof Error checks are unreliable in JS, this is the recommended check:

export function isError(maybeError: unknown): maybeError is Error {
  return (
    maybeError instanceof Error || Object.prototype.toString.call(maybeError) === '[object Error]'
  )
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated


/* v8 ignore start */
if (!response.body) {
throw new Error('Response body is null')
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

since we are going to immediately catch the error anyway, I wonder if we should provide more direct escape hatch for just passing this to callbacks directly and returning

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't really mind, seems much the same to me, this way means we call the callback the same place whether its this error or one from another function in this block, but maybe is less obvious to follow. I think it's quite a small block though. I'll change if you strongly prefer but if it's down to subjectivity I'd say might as well leave it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

no strong opinion, good to keep as is!

Copy link
Copy Markdown
Collaborator

@kibertoad kibertoad left a comment

Choose a reason for hiding this comment

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

great job!

@leonaves leonaves merged commit 072fef8 into main Mar 17, 2026
6 checks passed
@leonaves leonaves deleted the rf/connect-sse-by-contract branch March 17, 2026 15:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants