diff --git a/.github/workflows/eventsource-node.yml b/.github/workflows/eventsource-node.yml new file mode 100644 index 0000000000..60c0bcdee4 --- /dev/null +++ b/.github/workflows/eventsource-node.yml @@ -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 diff --git a/package.json b/package.json index 180d7f1d06..f7bfbade07 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared/eventsource-node/CHANGELOG.md b/packages/shared/eventsource-node/CHANGELOG.md new file mode 100644 index 0000000000..dfe36fbce1 --- /dev/null +++ b/packages/shared/eventsource-node/CHANGELOG.md @@ -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). diff --git a/packages/shared/eventsource-node/LICENSE b/packages/shared/eventsource-node/LICENSE new file mode 100644 index 0000000000..ab6a49f1a0 --- /dev/null +++ b/packages/shared/eventsource-node/LICENSE @@ -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. diff --git a/packages/shared/eventsource-node/README.md b/packages/shared/eventsource-node/README.md new file mode 100644 index 0000000000..e8d74471ac --- /dev/null +++ b/packages/shared/eventsource-node/README.md @@ -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 diff --git a/packages/shared/eventsource-node/__tests__/capacity.test.ts b/packages/shared/eventsource-node/__tests__/capacity.test.ts new file mode 100644 index 0000000000..ecc3b0f786 --- /dev/null +++ b/packages/shared/eventsource-node/__tests__/capacity.test.ts @@ -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); +}); diff --git a/packages/shared/eventsource-node/__tests__/retryDelay.test.ts b/packages/shared/eventsource-node/__tests__/retryDelay.test.ts new file mode 100644 index 0000000000..07f1b3762d --- /dev/null +++ b/packages/shared/eventsource-node/__tests__/retryDelay.test.ts @@ -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); +}); diff --git a/packages/shared/eventsource-node/jest.config.cjs b/packages/shared/eventsource-node/jest.config.cjs new file mode 100644 index 0000000000..022055c1ee --- /dev/null +++ b/packages/shared/eventsource-node/jest.config.cjs @@ -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, +}; diff --git a/packages/shared/eventsource-node/package.json b/packages/shared/eventsource-node/package.json new file mode 100644 index 0000000000..872ebdd11c --- /dev/null +++ b/packages/shared/eventsource-node/package.json @@ -0,0 +1,60 @@ +{ + "name": "@launchdarkly/eventsource-node", + "version": "0.0.1", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/shared/eventsource-node", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "description": "W3C compliant EventSource (server-sent events) client for Node, used by LaunchDarkly SDKs", + "files": [ + "dist" + ], + "keywords": [ + "launchdarkly", + "eventsource", + "sse", + "server-sent-events", + "streaming" + ], + "scripts": { + "build": "tsup", + "test": "npx jest --ci", + "lint": "oxlint .", + "lint:fix": "yarn run lint --fix", + "format": "oxfmt .", + "format:check": "oxfmt --check ." + }, + "license": "Apache-2.0", + "dependencies": { + "@launchdarkly/js-sdk-common": "2.25.2" + }, + "devDependencies": { + "@types/jest": "^29.4.0", + "@types/node": "^25.9.1", + "jest": "^29.5.0", + "launchdarkly-js-test-helpers": "^2.2.0", + "oxfmt": "0.63.0", + "oxlint": "1.78.0", + "ts-jest": "^29.0.5", + "tsup": "^8.5.1", + "typedoc": "0.25.0", + "typescript": "5.1.6" + } +} diff --git a/packages/shared/eventsource-node/src/capacity.ts b/packages/shared/eventsource-node/src/capacity.ts new file mode 100644 index 0000000000..b1160a3089 --- /dev/null +++ b/packages/shared/eventsource-node/src/capacity.ts @@ -0,0 +1,51 @@ +/** + * The smallest capacity that this module requests. The value equals Node's default + * `Buffer.poolSize`. The original module read `Buffer.poolSize` directly. This module contains the + * value as a constant, so that this module does not depend on Node's `Buffer`. + */ +const MIN_CAPACITY = 8192; + +/** + * Determine if a new capacity is needed for a buffer, and if it is, then what + * that new capacity should be. + * + * @param currentCapacity The current capacity of the buffer. + * @param requiredCapacity The required capacity from the buffer. + * @param maxOverAllocation The maximum extra capacity to allocate. This is how much the capacity + * can exceed the required capacity. If the over allocation exceeds this amount (from doubling), + * then instead the amount over allocated will be equal to maxOverAllocation. + * + * @returns Either `[false, 0]` if no allocation is needed, or `[true, capacity]` if an allocation + * is needed. + */ +export default function calculateCapacity( + currentCapacity: number, + requiredCapacity: number, + maxOverAllocation: number, +): [boolean, number] { + if (requiredCapacity > currentCapacity) { + let newCapacity = requiredCapacity; + + // Might as well start with a buffer of the maximum size that can be pooled. + // It is unlikely the initial buffer would be small enough to encounter + // this case. + if (newCapacity < MIN_CAPACITY) { + newCapacity = MIN_CAPACITY; + } + + // Exponential doubling capacity scaling. + const doubleCapacity = currentCapacity * 2; + if (newCapacity < doubleCapacity) { + newCapacity = doubleCapacity; + } + + // Doubling could become problematic over a certain size, so limit the total + // amount of extra capacity allocated. + const overAllocation = newCapacity - requiredCapacity; + if (overAllocation > maxOverAllocation) { + newCapacity = requiredCapacity + maxOverAllocation; + } + return [true, newCapacity]; + } + return [false, 0]; +} diff --git a/packages/shared/eventsource-node/src/index.ts b/packages/shared/eventsource-node/src/index.ts new file mode 100644 index 0000000000..a5326d45fb --- /dev/null +++ b/packages/shared/eventsource-node/src/index.ts @@ -0,0 +1,2 @@ +// TODO(scaffold): the `EventSource` implementation and its export arrive in a later layer. +export * from './types'; diff --git a/packages/shared/eventsource-node/src/retryDelay.ts b/packages/shared/eventsource-node/src/retryDelay.ts new file mode 100644 index 0000000000..c90c1489b6 --- /dev/null +++ b/packages/shared/eventsource-node/src/retryDelay.ts @@ -0,0 +1,79 @@ +/** + * Computes a backoff delay from the current base delay and the number of consecutive retries. + */ +export type BackoffStrategy = (baseDelayMillis: number, retryCount: number) => number; + +/** + * Applies jitter to a computed delay. + */ +export type JitterStrategy = (computedDelayMillis: number) => number; + +/** + * The object that the `RetryDelayStrategy` factory returns. This interface is TypeScript + * scaffolding. It has no counterpart in the original `retry-delay.js`. The factory function below + * is the port of that file. + */ +export interface RetryDelayStrategy { + nextRetryDelay(currentTimeMillis: number): number; + setGoodSince(goodSinceTimeMillis: number): void; + setBaseDelay(baseDelay: number): void; +} + +// Encapsulation of configurable backoff/jitter behavior. +// +// - The system can either be in a "good" state or a "bad" state. The initial state is "bad"; the +// caller is responsible for indicating when it transitions to "good". When we ask for a new retry +// delay, that implies the state is now transitioning to "bad". +// +// - There is a configurable base delay, which can be changed at any time (if the SSE server sends +// us a "retry:" directive). +// +// - There are optional strategies for applying backoff and jitter to the delay. +// +// The factory keeps the PascalCase name of the original function. +export function RetryDelayStrategy( + baseDelayMillis: number, + resetIntervalMillis?: number, + backoff?: BackoffStrategy | null, + jitter?: JitterStrategy | null, +): RetryDelayStrategy { + let currentBaseDelay = baseDelayMillis; + let retryCount = 0; + let goodSince: number | null | undefined; + return { + nextRetryDelay(currentTimeMillis: number): number { + if ( + goodSince && + resetIntervalMillis && + currentTimeMillis - goodSince >= resetIntervalMillis + ) { + retryCount = 0; + } + goodSince = null; + const delay = backoff ? backoff(currentBaseDelay, retryCount) : currentBaseDelay; + retryCount += 1; + return jitter ? jitter(delay) : delay; + }, + + setGoodSince(goodSinceTimeMillis: number): void { + goodSince = goodSinceTimeMillis; + }, + + setBaseDelay(baseDelay: number): void { + currentBaseDelay = baseDelay; + retryCount = 0; + }, + }; +} + +export function defaultBackoff(maxDelayMillis: number): BackoffStrategy { + return (baseDelayMillis: number, retryCount: number) => { + const d = baseDelayMillis * 2 ** retryCount; + return d > maxDelayMillis ? maxDelayMillis : d; + }; +} + +export function defaultJitter(ratio: number): JitterStrategy { + return (computedDelayMillis: number) => + computedDelayMillis - Math.trunc(Math.random() * ratio * computedDelayMillis); +} diff --git a/packages/shared/eventsource-node/src/types.ts b/packages/shared/eventsource-node/src/types.ts new file mode 100644 index 0000000000..1600bed31a --- /dev/null +++ b/packages/shared/eventsource-node/src/types.ts @@ -0,0 +1,154 @@ +import type { + EventSourceInitDict as PlatformEventSourceInitDict, + HttpErrorResponse, +} from '@launchdarkly/js-sdk-common'; + +/** + * The payload of the `error` and `end` events. + * + * Extends the platform's `HttpErrorResponse` -- it is the argument of `onerror` and of the SDK's + * `errorFilter` callback, so it must stay interchangeable with that shape in both directions. + * The only addition is the `type` field that this implementation stamps on every event payload. + */ +export interface ErrorEvent extends HttpErrorResponse { + readonly type?: string; +} + +/** + * The payload of the `open` event. + */ +export interface OpenEvent { + readonly type?: string; + readonly headers?: Record; +} + +/** + * The payload of the `retrying` event. + */ +export interface RetryingEvent { + readonly type?: string; + readonly delayMillis: number; +} + +/** + * The payload of the `closed` event. + */ +export interface ClosedEvent { + readonly type?: string; +} + +/** + * The payload of the `message` event, and of each event for a named SSE `event:` type. + */ +export interface MessageEvent { + readonly type: string; + readonly data: string; + readonly lastEventId: string; + readonly origin: string; +} + +/** + * A listener registered through `addEventListener`. + * + * Deviation from the platform's `EventListener` type (`(event?: { data?: any }) => void`): the + * payloads this implementation dispatches are not limited to `data`-carrying message events + * (`open` carries `headers`, `retrying` carries `delayMillis`, ...), so the parameter stays `any` + * for contextual-typing ergonomics. A listener typed with the platform's shape remains assignable. + */ +export type EventSourceListener = (event?: any) => void; + +/** + * The base options that the EventSource implementation understands. `NodeEventSourceInitDict` + * extends this interface with the Node transport options. + * + * Inherits the platform's `EventSourceInitDict` fields, with two deviations: + * + * - `urlBuilder` is omitted: this package does not support it. + * - `errorFilter` is redeclared to receive this package's `ErrorEvent` (the platform's + * `HttpErrorResponse` plus the `type` field); the two remain interchangeable. + * + * Each field that the SDK supplies stays required here, to match the platform contract. A caller + * that constructs an EventSource directly can omit each field, because the constructor parameter + * is `Partial<...>`. A missing field falls back to a default (`errorFilter`, + * `initialRetryDelayMillis`), or the feature stays off (`headers`, `readTimeoutMillis`, + * `retryResetIntervalMillis`). + */ +export interface EventSourceInitDict extends Omit { + /** + * Decides if the client retries a given error. Return false to stop the retries and close the + * stream. When this field is not set, the client retries I/O errors and HTTP 500, 502, 503, + * and 504. + */ + errorFilter: (err: ErrorEvent) => boolean; + + /** + * If set, the client decreases each computed retry delay by a random amount. The maximum + * decrease is this fraction of the delay. + */ + jitterRatio?: number; + + /** + * If set, retry delays grow exponentially up to this limit. + */ + maxBackoffMillis?: number; + + /** + * When true, the request omits the default `Cache-Control: no-cache` and + * `Accept: text/event-stream` headers. + */ + skipDefaultHeaders?: boolean; +} + +/** + * TLS options that the client merges into the outgoing request. The client recognizes only these + * names and ignores all other names. + */ +export interface EventSourceHttpsOptions { + pfx?: string | string[] | Buffer | Buffer[] | object[]; + key?: string | string[] | Buffer | Buffer[] | object[]; + passphrase?: string; + cert?: string | string[] | Buffer | Buffer[]; + ca?: string | string[] | Buffer | Buffer[]; + ciphers?: string; + rejectUnauthorized?: boolean; + secureProtocol?: string; + servername?: string; + checkServerIdentity?: (hostname: string, cert: any) => Error | undefined; +} + +/** + * The options for the Node EventSource constructor. These are the shared options plus the options + * that apply only to a Node http/https request. + */ +export interface NodeEventSourceInitDict extends EventSourceInitDict { + /** + * TLS options for https requests. + */ + https?: EventSourceHttpsOptions; + + /** + * The URL of an HTTP proxy. The client sends the request through this proxy. + */ + proxy?: string; + + /** + * A Node http/https agent for the request. An agent is an alternative way to configure a proxy. + */ + agent?: any; +} + +/** + * The names of the custom (non-W3C) options that the Node implementation understands. The runtime + * value `EventSource.supportedOptions` contains these names. + */ +export type SupportedOptionName = + | 'errorFilter' + | 'headers' + | 'https' + | 'initialRetryDelayMillis' + | 'jitterRatio' + | 'maxBackoffMillis' + | 'method' + | 'proxy' + | 'retryResetIntervalMillis' + | 'skipDefaultHeaders'; diff --git a/packages/shared/eventsource-node/tsconfig.json b/packages/shared/eventsource-node/tsconfig.json new file mode 100644 index 0000000000..d148c1ef4b --- /dev/null +++ b/packages/shared/eventsource-node/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "target": "ES2017", + "lib": ["es2020"], + "module": "commonjs", + "strict": true, + "noImplicitOverride": true, + // Needed for CommonJS modules. + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, // enables importers to jump to source + "stripInternal": true, + "types": ["node", "jest"] + }, + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__", "contract-tests"] +} diff --git a/packages/shared/eventsource-node/tsconfig.ref.json b/packages/shared/eventsource-node/tsconfig.ref.json new file mode 100644 index 0000000000..0c86b2c554 --- /dev/null +++ b/packages/shared/eventsource-node/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "composite": true + } +} diff --git a/packages/shared/eventsource-node/tsup.config.js b/packages/shared/eventsource-node/tsup.config.js new file mode 100644 index 0000000000..2174bc1f74 --- /dev/null +++ b/packages/shared/eventsource-node/tsup.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig([ + { + entry: { + index: 'src/index.ts', + }, + minify: true, + format: ['esm', 'cjs'], + sourcemap: true, + clean: true, + dts: true, + metafile: true, + }, +]); diff --git a/packages/shared/eventsource-node/typedoc.json b/packages/shared/eventsource-node/typedoc.json new file mode 100644 index 0000000000..7ac616b544 --- /dev/null +++ b/packages/shared/eventsource-node/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"], + "out": "docs" +} diff --git a/tsconfig.json b/tsconfig.json index 1ebba5d3f9..76d24756ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,9 @@ { "path": "./packages/shared/openfeature-server-common/tsconfig.ref.json" }, + { + "path": "./packages/shared/eventsource-node/tsconfig.ref.json" + }, { "path": "./packages/sdk/server-node/tsconfig.ref.json" },