Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/manual-publish-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ on:
- packages/sdk/openfeature-cloudflare-server
- packages/sdk/openfeature-node-server
- packages/sdk/vue
- packages/shared/eventsource
name: Publish Documentation
jobs:
build-publish:
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ on:
type: choice
options:
- packages/shared/common
- packages/shared/eventsource
- packages/shared/sdk-client
- packages/shared/sdk-server
- packages/shared/sdk-server-edge
Expand Down Expand Up @@ -86,6 +87,7 @@ jobs:
pull-requests: write
outputs:
package-common-released: ${{ steps.release.outputs['packages/shared/common--release_created'] }}
package-eventsource-released: ${{ steps.release.outputs['packages/shared/eventsource--release_created'] }}
package-sdk-client-released: ${{ steps.release.outputs['packages/shared/sdk-client--release_created'] }}
package-sdk-server-released: ${{ steps.release.outputs['packages/shared/sdk-server--release_created'] }}
package-sdk-server-edge-released: ${{ steps.release.outputs['packages/shared/sdk-server-edge--release_created'] }}
Expand Down Expand Up @@ -137,6 +139,22 @@ jobs:
workspace_path: packages/shared/common
aws_assume_role: ${{ vars.AWS_ROLE_ARN }}

release-eventsource:
runs-on: ubuntu-latest
needs: ['release-please']
permissions:
id-token: write
contents: write
if: ${{ needs.release-please.outputs.package-eventsource-released == 'true' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: release-eventsource
name: Full release of packages/shared/eventsource
uses: ./actions/full-release
with:
workspace_path: packages/shared/eventsource
aws_assume_role: ${{ vars.AWS_ROLE_ARN }}
Comment thread
joker23 marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

release-sdk-client:
runs-on: ubuntu-latest
needs: ['release-please', 'release-common']
Expand Down
1 change: 1 addition & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"packages/sdk/vercel": "1.3.67",
"packages/shared/akamai-edgeworker-sdk": "2.0.42",
"packages/shared/common": "2.29.0",
"packages/shared/eventsource": "0.0.1",
"packages/shared/openfeature-server-common": "2.0.6",
"packages/shared/sdk-client": "1.32.5",
"packages/shared/sdk-server": "2.24.1",
Expand Down
122 changes: 113 additions & 9 deletions packages/shared/eventsource/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,59 @@ The stream parser at `src/parser` is a fork of the
4.1.1. It is maintained in this repository; see the third-party notices in [LICENSE](LICENSE) for
its original MIT license terms.

## Install

```shell
npm install @launchdarkly/eventsource
```

## Quick usage

```js
import { createEventSource } from '@launchdarkly/eventsource';

const es = createEventSource('https://example.com/stream', {
initialRetryDelayMillis: 1000,
maxBackoffMillis: 30000,
jitterRatio: 0.5,
});

// The connection attempt starts before createEventSource returns. Attach
// listeners immediately after the call, before any other code runs, to avoid
// missing an event.
es.onopen = (event) => {
console.log('connected', event.headers);
};
es.onerror = (event) => {
if (event?.status === 401) {
console.log('not authorized');
}
};
es.addEventListener('message', (event) => {
console.log('received', event.data);
});

// Later, when the stream is no longer needed:
es.close();
```

## The EventSource instance

`createEventSource` returns an object that satisfies the exported `EventSource` interface:

- `readyState` -- the connection state: `CONNECTING` (0), `OPEN` (1), or `CLOSED` (2). The
package exports these constants.
- `url` -- the URL of the most recent connection attempt. With a `urlBuilder` it can change
between attempts.
- `reconnectInterval` -- mirrors the most recent server `retry:` field, in milliseconds; starts
at 1000. The retry strategy owns the actual reconnect timing; this slot only supplies the
fallback delay for a reconnect whose strategy call throws.
- `close()` -- permanently closes the stream, invokes `onclose`, and dispatches the `closed`
event. Idempotent.

Unlike the standard `EventSource`, there is no `onmessage` slot; it is left out on purpose.
Register message listeners with `addEventListener('message', ...)`.

## Options reference

This section documents the behavior of `createEventSource`'s second argument for maintainers of
Expand Down Expand Up @@ -63,10 +116,29 @@ Beyond the standard `open`/`message`/`error` events, this implementation dispatc
- `retrying`: after an error, indicates a reconnect is scheduled. The event's `delayMillis`
property gives the delay.

The `open` event's `headers` property carries the HTTP response headers from the stream. The
`error` event carries `status`/`message` for HTTP errors. A server-sent SSE frame named `error`
also dispatches under the `error` type, with a `MessageEvent` payload; only that frame carries a
string `data` property.
The `open` event's `headers` property carries the HTTP response headers from the stream, as a
plain object:

```js
{
'content-type': 'text/event-stream; charset=utf-8',
'transfer-encoding': 'chunked',
'cache-control': 'no-cache, no-store, must-revalidate',
}
```

The `error` event carries `status`/`message` for HTTP errors:

```js
es.onerror = (err) => {
if (err?.status === 401 || err?.status === 403) {
console.log('not authorized');
}
};
```

A server-sent SSE frame named `error` also dispatches under the `error` type, with a
`MessageEvent` payload; only that frame carries a string `data` property.

An exception thrown by the `onopen`, `onerror`, or `onretrying` slot does not stop dispatch to
the `addEventListener` listeners for that event. An exception from a registered listener does
Expand All @@ -83,6 +155,19 @@ exception surfaces later, asynchronously, as an uncaught error.
events, measured from its first event, before the backoff counter resets to the initial
delay. A connection that fails before it delivers an event does not count as healthy.

Without `maxBackoffMillis` and `jitterRatio`, every retry waits the same base delay. Set both, so
that clients which lose their connections at the same time -- for example, in a server outage --
do not all reconnect at the same time:

```js
const es = createEventSource(url, {
initialRetryDelayMillis: 2000, // the first retry waits 2 seconds
maxBackoffMillis: 30000, // enables backoff, with a maximum of 30 seconds
retryResetIntervalMillis: 60000, // backoff resets after 60 seconds of delivered events
jitterRatio: 0.5, // each delay is reduced by a random amount of up to 50%
});
```

### Custom retry delay strategy

The `retryDelayStrategy` option replaces the built-in retry timing with a caller-supplied object.
Expand Down Expand Up @@ -115,17 +200,36 @@ returned `false`. The stream then closes cleanly and releases its connection, an
surfaces asynchronously as an uncaught error, like a throwing listener's. Each status other than
200, including a redirect status that the transport did not follow, is an HTTP error response.

```js
const es = createEventSource(url, {
// Retry every error except an unauthorized response.
errorFilter: (err) => err.status !== 401,
});
```

### Headers, method, and body

`headers` sets additional request headers. Normally `Cache-Control: no-cache` and
`Accept: text/event-stream` are also sent; `skipDefaultHeaders: true` sends only the headers you
specify. `method` overrides the default `GET`; `body` sets a request body, for use with a
non-`GET` method.
`headers` sets additional request headers, for example to send cookies or an initial
`Last-Event-ID` value. Normally `Cache-Control: no-cache` and `Accept: text/event-stream` are
also sent; `skipDefaultHeaders: true` sends only the headers you specify. This matters for a
cross-origin request, because CORS restricts the headers a request can carry. `method` overrides
the default `GET`; `body` sets a request body, for use with a non-`GET` method.

```js
const es = createEventSource(url, {
method: 'REPORT',
body: JSON.stringify(context),
headers: { Authorization: sdkKey },
skipDefaultHeaders: true,
});
```

### Read timeout

`readTimeoutMillis` drops and retries the connection if that many milliseconds elapse with no data
received, guarding against a TCP connection that fails without an I/O error.
received, guarding against a TCP connection that fails without an I/O error. When the server sends
heartbeat data at a known interval -- such as a `:` comment line, which SSE ignores -- set the
timeout longer than that interval.

### Listener registry

Expand Down
14 changes: 13 additions & 1 deletion release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,19 @@
"bump-minor-pre-major": true,
"extra-files": ["src/platform/OxygenInfo.ts"]
},
"packages/shared/common": {},
"packages/shared/common": {
"extra-files": [
{
"type": "json",
"path": "/packages/shared/eventsource/package.json",
"jsonpath": "$.devDependencies['@launchdarkly/js-sdk-common']"
}
]
},
"packages/shared/eventsource": {
"bump-minor-pre-major": true,
"prerelease": true
},
"packages/shared/sdk-client": {
"extra-files": [
{
Expand Down
Loading