Skip to content
Closed
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
28 changes: 28 additions & 0 deletions .github/workflows/eventsource-node.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: shared/eventsource-node

on:
push:
branches: [main, 'feat/**']
paths-ignore:
- '**.md' #Do not need to run CI for markdown changes.
pull_request:
branches: [main, 'feat/**']
paths-ignore:
- '**.md'

jobs:
build-test-eventsource-node:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./actions/setup-yarn
with:
node-version: 24.x
registry-url: 'https://registry.npmjs.org'
- id: shared
name: Shared CI Steps
uses: ./actions/ci
with:
workspace_name: '@launchdarkly/eventsource-node'
workspace_path: packages/shared/eventsource-node
# TODO: Add contract tests
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"packages/shared/sdk-server-edge",
"packages/shared/akamai-edgeworker-sdk",
"packages/shared/openfeature-server-common",
"packages/shared/eventsource-node",
"packages/sdk/server-node",
"packages/sdk/server-node/contract-tests",
"packages/sdk/server-node/examples/features/custom-socks-proxy",
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/eventsource-node/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Changelog

All notable changes to `@launchdarkly/eventsource-node` will be documented in this file. This
project adheres to [Semantic Versioning](http://semver.org).
44 changes: 44 additions & 0 deletions packages/shared/eventsource-node/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
Copyright 2026 Catamorphic, Co.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

-------------------------------------------------------------------------------

Third-party notices

This package is derived from the "launchdarkly-eventsource" npm package
(a fork of the "eventsource" npm package), used under the following
license:

The MIT License

Copyright (c) EventSource GitHub organisation

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
134 changes: 134 additions & 0 deletions packages/shared/eventsource-node/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# LaunchDarkly EventSource for Node

[![NPM][eventsource-node-npm-badge]][eventsource-node-npm-link]
[![Actions Status][eventsource-node-ci-badge]][eventsource-node-ci]
[![Documentation][eventsource-node-ghp-badge]][eventsource-node-ghp-link]
[![NPM][eventsource-node-dm-badge]][eventsource-node-npm-link]
[![NPM][eventsource-node-dt-badge]][eventsource-node-npm-link]

> [!CAUTION]
> This package is in pre-release and not subject to backwards compatibility
> guarantees. The API may change based on feedback.
>
> Pin to a specific minor version and review the [changelog](CHANGELOG.md) before upgrading.

This package contains a W3C-compliant EventSource (server-sent events) client for Node, using
`http`/`https`. It is used for streaming connections by the LaunchDarkly SDKs.

This package is not intended to be used directly.

This package is derived from the
[`launchdarkly-eventsource`](https://www.npmjs.com/package/launchdarkly-eventsource) npm package
([launchdarkly/js-eventsource](https://github.com/launchdarkly/js-eventsource)), itself a fork of
the [`eventsource`](https://www.npmjs.com/package/eventsource) npm package. See
[LICENSE](LICENSE) for the original license terms.

## Options reference

This section documents the behavior of the constructor's second argument for maintainers of this
package and its consumers. The authoritative definitions, including any behavior not covered here,
are the TSDoc comments on `EventSourceInitDict` and `NodeEventSourceInitDict` in `src/types.ts`.

### Events

Beyond the standard `open`/`message`/`error` events, this implementation emits:

- `closed`: the stream has been permanently closed, either by a non-retryable error or by calling
`close()`.
- `end`: the server ended the stream. Not reported as an `error`, but still treated as one for
retry purposes.
- `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.

### Retry delay: backoff and jitter

- `initialRetryDelayMillis` -- base delay before the first reconnect attempt (default 1000ms).
- `maxBackoffMillis` -- if set, the delay grows exponentially on each successive retry, up to this
ceiling.
- `jitterRatio` -- if set, each computed delay is randomly reduced by up to this fraction.
- `retryResetIntervalMillis` -- how long the stream must have been healthy before the backoff
counter resets to the initial delay.

### Error retry behavior

By default, connection failures and I/O errors are always retried; HTTP error responses are
retried only for 500, 502, 503, and 504. Set `errorFilter` to override this -- it receives the
error and returns `true` to retry or `false` to close the stream and raise `error`. There is no
guard against a filter that throws, matching the original package: the exception propagates into
the transport's callback, and the stream does not recover. Redirects (301/307) with a present
`Location` header are never treated as errors; they are always followed by hand. Any other
redirect status (302, 303, 308) is reported as a non-200 HTTP error, subject to the default
`errorFilter`.

### 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.

### 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.

### TLS (`https`)

`https` accepts the options recognized by Node's `tls.connect()`/`tls.createSecureContext()`
(`EventSourceHttpsOptions` in `src/types.ts`: `pfx`, `key`, `passphrase`, `cert`, `ca`, `ciphers`,
`rejectUnauthorized`, `secureProtocol`, `servername`, `checkServerIdentity`). Certificate
validation follows Node's own default (`rejectUnauthorized: true`); `https.rejectUnauthorized`
is the only way to change it. The original package instead disabled validation unless the caller
opted in through a top-level `rejectUnauthorized` legacy alias -- this port removes both the
alias and that default. The three SDK consumers pass their `tlsParams` configuration through as
this option.

### Proxy and agent

`proxy` sends the request through the given HTTP/HTTPS proxy URL. `agent` supplies a Node
`http`/`https` agent directly, which is also how a tunneling agent would be configured for
proxying.

### Feature detection

`EventSource.supportedOptions` is an object with a `true` value for each option name this
implementation recognizes, for callers that may be running against a different EventSource
implementation and want to check support before using a non-W3C-standard option. Note that the
list is not exhaustive: `body` and `readTimeoutMillis` are real, documented options that are
absent from the `supportedOptions`/`SupportedOptionName` lists, as is `agent`. `urlBuilder`,
which the platform `EventSourceInitDict` declares, is not supported by this package at all.

## Contributing

See [Contributing](../CONTRIBUTING.md).

## Verifying SDK build provenance with the SLSA framework

LaunchDarkly uses the [SLSA framework](https://slsa.dev/spec/v1.0/about) (Supply-chain Levels for Software Artifacts) to help developers make their supply chain more secure by ensuring the authenticity and build integrity of our published SDK packages. To learn more, see the [provenance guide](PROVENANCE.md).

## About LaunchDarkly

- LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can:
- Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases.
- Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?).
- Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file.
- Grant access to certain features based on user attributes, like payment plan (eg: users on the 'gold' plan get access to more features than users in the 'silver' plan).
- Disable parts of your application to facilitate maintenance, without taking everything offline.
- LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/sdk) for a complete list.
- Explore LaunchDarkly
- [launchdarkly.com](https://www.launchdarkly.com/ 'LaunchDarkly Main Website') for more information
- [docs.launchdarkly.com](https://docs.launchdarkly.com/ 'LaunchDarkly Documentation') for our documentation and SDK reference guides
- [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ 'LaunchDarkly API Documentation') for our API documentation
- [blog.launchdarkly.com](https://blog.launchdarkly.com/ 'LaunchDarkly Blog Documentation') for the latest product updates

[eventsource-node-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/eventsource-node.yml/badge.svg
[eventsource-node-ci]: https://github.com/launchdarkly/js-core/actions/workflows/eventsource-node.yml
[eventsource-node-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/eventsource-node.svg?style=flat-square
[eventsource-node-npm-link]: https://www.npmjs.com/package/@launchdarkly/eventsource-node
[eventsource-node-ghp-badge]: https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8
[eventsource-node-ghp-link]: https://launchdarkly.github.io/js-core/packages/shared/eventsource-node/docs/
[eventsource-node-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/eventsource-node.svg?style=flat-square
[eventsource-node-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/eventsource-node.svg?style=flat-square
35 changes: 35 additions & 0 deletions packages/shared/eventsource-node/__tests__/capacity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import calculateCapacity from '../src/capacity';

const maxOverAllocation = 1024 * 1024;

it('uses the minimum capacity', () => {
const [resize, newCapacity] = calculateCapacity(0, 1, maxOverAllocation);
expect(resize).toBe(true);
// The implementation hardcodes this minimum so that it does not depend on Node's
// `Buffer.poolSize`, which host applications can mutate.
expect(newCapacity).toEqual(8192);
});

it('does not increase capacity when there is sufficient capacity', () => {
const [resize, newCapacity] = calculateCapacity(1024, 1023, maxOverAllocation);
expect(resize).toBe(false);
expect(newCapacity).toEqual(0);
});

it('uses exponential doubling capacity scaling', () => {
expect(calculateCapacity(8192, 8193, maxOverAllocation)).toEqual([true, 16384]);
expect(calculateCapacity(16384, 16385, maxOverAllocation)).toEqual([true, 32768]);
});

it('uses required capacity when it exceeds doubling', () => {
const [resize, newCapacity] = calculateCapacity(1024, 16384, maxOverAllocation);
expect(resize).toBe(true);
expect(newCapacity).toEqual(16384);
});

it('does not exceed max over allocation', () => {
const capacity = 1024 * 1024 * 3;
const [resize, newCapacity] = calculateCapacity(capacity, capacity + 1, maxOverAllocation);
expect(resize).toBe(true);
expect(newCapacity).toEqual(capacity + maxOverAllocation + 1);
});
97 changes: 97 additions & 0 deletions packages/shared/eventsource-node/__tests__/retryDelay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { RetryDelayStrategy, defaultBackoff, defaultJitter } from '../src/retryDelay';

function expectInRange(value: number, min: number, max: number) {
expect(value).toBeGreaterThanOrEqual(min);
expect(value).toBeLessThanOrEqual(max);
}

it('can return fixed delay with no backoff or jitter', () => {
const d0 = 1000;
const r = RetryDelayStrategy(d0, 0);
const t0 = new Date().getTime() - 10000;
expect(r.nextRetryDelay(t0)).toEqual(d0);
expect(r.nextRetryDelay(t0 + 1000)).toEqual(d0);
expect(r.nextRetryDelay(t0 + 2000)).toEqual(d0);
});

it('can use backoff without jitter', () => {
const d0 = 10000;
const max = 60000;
const r = RetryDelayStrategy(d0, 0, defaultBackoff(max));
const t0 = new Date().getTime() - 10000;
expect(r.nextRetryDelay(t0)).toEqual(d0);
expect(r.nextRetryDelay(t0 + 1000)).toEqual(d0 * 2);
expect(r.nextRetryDelay(t0 + 2000)).toEqual(d0 * 4);
expect(r.nextRetryDelay(t0 + 3000)).toEqual(max);
});

it('can use jitter without backoff', () => {
const d0 = 1000;
const r = RetryDelayStrategy(d0, 0, null, defaultJitter(0.5));
const t0 = new Date().getTime() - 10000;
expectInRange(r.nextRetryDelay(t0), d0 / 2, d0);
expectInRange(r.nextRetryDelay(t0 + 1000), d0 / 2, d0);
expectInRange(r.nextRetryDelay(t0 + 2000), d0 / 2, d0);
});

it('can use jitter with backoff', () => {
const d0 = 10000;
const max = 60000;
const r = RetryDelayStrategy(d0, 0, defaultBackoff(max), defaultJitter(0.5));
const t0 = new Date().getTime() - 10000;
expectInRange(r.nextRetryDelay(t0), d0 / 2, d0);
expectInRange(r.nextRetryDelay(t0 + 1000), d0, d0 * 2);
expectInRange(r.nextRetryDelay(t0 + 2000), d0 * 2, d0 * 4);
expectInRange(r.nextRetryDelay(t0 + 3000), max / 2, max);
});

it('can reset backoff based on reset interval', () => {
const d0 = 10000;
const max = 60000;
const resetInterval = 45000;
const r = RetryDelayStrategy(d0, resetInterval, defaultBackoff(max));
const t0 = new Date().getTime() - 10000;
r.setGoodSince(t0);

const t1 = t0 + 1000;
const d1 = r.nextRetryDelay(t1);
expect(d1).toEqual(d0);

const t2 = t1 + d1;
r.setGoodSince(t2);

const t3 = t2 + 10000;
const d2 = r.nextRetryDelay(t3);
expect(d2).toEqual(d0 * 2);

const t4 = t3 + d2;
r.setGoodSince(t4);

const t5 = t4 + resetInterval;
expect(r.nextRetryDelay(t5)).toEqual(d0);
});

it('resets the retry count when the base delay is changed', () => {
const d0 = 1000;
const r = RetryDelayStrategy(d0, 0, defaultBackoff(60000));
const t0 = new Date().getTime() - 10000;
expect(r.nextRetryDelay(t0)).toEqual(d0);
expect(r.nextRetryDelay(t0)).toEqual(d0 * 2);
r.setBaseDelay(500);
expect(r.nextRetryDelay(t0)).toEqual(500);
});

it('correctly handles backoff and jitter with high retry count', () => {
// Verifies that we don't get numeric overflow errors due to using a very high exponential
// backoff that should not be a concern in floating point.
const d0 = 1000;
const max = 1000 * 60 * 60 * 24 * 365 * 200; // 200 years
const retryCount = 35; // 2^35 seconds
const r = RetryDelayStrategy(d0, 0, defaultBackoff(max), defaultJitter(0.5));

let d = 0;
for (let i = 0; i < retryCount; i += 1) {
d = r.nextRetryDelay(new Date().getTime());
}
expectInRange(d, max / 2, max);
});
11 changes: 11 additions & 0 deletions packages/shared/eventsource-node/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module.exports = {
transform: { '^.+\\.ts?$': 'ts-jest' },
testMatch: ['**/__tests__/**/*test.ts?(x)'],
testEnvironment: 'node',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
collectCoverageFrom: ['src/**/*.ts'],
// launchdarkly-js-test-helpers hands out ports from a per-process static counter, so running
// this package's test files across multiple jest worker processes lets two files allocate the
// same port and cross-talk. Run in a single worker so the counter stays globally unique.
maxWorkers: 1,
};
Loading
Loading