diff --git a/.github/workflows/lint-test-sdk.yml b/.github/workflows/lint-test-sdk.yml index 8f916df..5893f25 100644 --- a/.github/workflows/lint-test-sdk.yml +++ b/.github/workflows/lint-test-sdk.yml @@ -19,23 +19,29 @@ on: type: string description: The branch of the SDK to test required: false - + jobs: lint-test-sdk: runs-on: ubuntu-latest + strategy: + matrix: + node-version: [ '18', '20', '22', '23' ] steps: - - uses: actions/checkout@v2 - with: - repository: Eppo-exp/node-server-sdk - ref: ${{ env.SDK_BRANCH_NAME }} + - uses: actions/checkout@v3 - name: Use Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: - node-version: '18.x' - - uses: actions/cache@v2 + node-version: ${{ matrix.node-version }} + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT + - uses: actions/cache@v4 + id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: - path: './node_modules' - key: ${{ runner.os }}-root-node-modules-${{ hashFiles('./yarn.lock') }} + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- - name: Install root dependencies run: yarn --frozen-lockfile working-directory: ./ @@ -50,19 +56,25 @@ jobs: working-directory: ./ typecheck: runs-on: ubuntu-latest + strategy: + matrix: + node-version: [ '18', '20', '22', '23' ] steps: - - uses: actions/checkout@v2 - with: - repository: Eppo-exp/node-server-sdk - ref: ${{ env.SDK_BRANCH_NAME }} + - uses: actions/checkout@v3 - name: Use Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: - node-version: '18.x' - - uses: actions/cache@v2 + node-version: ${{ matrix.node-version }} + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT + - uses: actions/cache@v4 + id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: - path: './node_modules' - key: ${{ runner.os }}-root-node-modules-${{ hashFiles('./yarn.lock') }} + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- - name: Install root dependencies run: yarn --frozen-lockfile working-directory: ./ diff --git a/docs/node-server-sdk.getinstance.md b/docs/node-server-sdk.getinstance.md index 4374653..dbbc128 100644 --- a/docs/node-server-sdk.getinstance.md +++ b/docs/node-server-sdk.getinstance.md @@ -15,5 +15,5 @@ export declare function getInstance(): EppoClient; EppoClient -a singleton client instance +a singleton client instance or throws an Error if init() has not been called diff --git a/docs/node-server-sdk.iclientconfig.apikey.md b/docs/node-server-sdk.iclientconfig.apikey.md deleted file mode 100644 index b8901a1..0000000 --- a/docs/node-server-sdk.iclientconfig.apikey.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/node-server-sdk](./node-server-sdk.md) > [IClientConfig](./node-server-sdk.iclientconfig.md) > [apiKey](./node-server-sdk.iclientconfig.apikey.md) - -## IClientConfig.apiKey property - -Eppo API key - -**Signature:** - -```typescript -apiKey: string; -``` diff --git a/docs/node-server-sdk.iclientconfig.assignmentlogger.md b/docs/node-server-sdk.iclientconfig.assignmentlogger.md deleted file mode 100644 index e1a4e35..0000000 --- a/docs/node-server-sdk.iclientconfig.assignmentlogger.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/node-server-sdk](./node-server-sdk.md) > [IClientConfig](./node-server-sdk.iclientconfig.md) > [assignmentLogger](./node-server-sdk.iclientconfig.assignmentlogger.md) - -## IClientConfig.assignmentLogger property - -Pass a logging implementation to send variation assignments to your data warehouse. - -**Signature:** - -```typescript -assignmentLogger: IAssignmentLogger; -``` diff --git a/docs/node-server-sdk.iclientconfig.banditlogger.md b/docs/node-server-sdk.iclientconfig.banditlogger.md deleted file mode 100644 index 7c106db..0000000 --- a/docs/node-server-sdk.iclientconfig.banditlogger.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/node-server-sdk](./node-server-sdk.md) > [IClientConfig](./node-server-sdk.iclientconfig.md) > [banditLogger](./node-server-sdk.iclientconfig.banditlogger.md) - -## IClientConfig.banditLogger property - -Logging implementation to send bandit actions to your data warehouse - -**Signature:** - -```typescript -banditLogger?: IBanditLogger; -``` diff --git a/docs/node-server-sdk.iclientconfig.baseurl.md b/docs/node-server-sdk.iclientconfig.baseurl.md deleted file mode 100644 index 1098a49..0000000 --- a/docs/node-server-sdk.iclientconfig.baseurl.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/node-server-sdk](./node-server-sdk.md) > [IClientConfig](./node-server-sdk.iclientconfig.md) > [baseUrl](./node-server-sdk.iclientconfig.baseurl.md) - -## IClientConfig.baseUrl property - -Base URL of the Eppo API. Clients should use the default setting in most cases. - -**Signature:** - -```typescript -baseUrl?: string; -``` diff --git a/docs/node-server-sdk.iclientconfig.md b/docs/node-server-sdk.iclientconfig.md deleted file mode 100644 index db1debd..0000000 --- a/docs/node-server-sdk.iclientconfig.md +++ /dev/null @@ -1,29 +0,0 @@ - - -[Home](./index.md) > [@eppo/node-server-sdk](./node-server-sdk.md) > [IClientConfig](./node-server-sdk.iclientconfig.md) - -## IClientConfig interface - -Configuration used for initializing the Eppo client - -**Signature:** - -```typescript -export interface IClientConfig -``` - -## Properties - -| Property | Modifiers | Type | Description | -| --- | --- | --- | --- | -| [apiKey](./node-server-sdk.iclientconfig.apikey.md) | | string | Eppo API key | -| [assignmentLogger](./node-server-sdk.iclientconfig.assignmentlogger.md) | | IAssignmentLogger | Pass a logging implementation to send variation assignments to your data warehouse. | -| [banditLogger?](./node-server-sdk.iclientconfig.banditlogger.md) | | IBanditLogger | _(Optional)_ Logging implementation to send bandit actions to your data warehouse | -| [baseUrl?](./node-server-sdk.iclientconfig.baseurl.md) | | string | _(Optional)_ Base URL of the Eppo API. Clients should use the default setting in most cases. | -| [numInitialRequestRetries?](./node-server-sdk.iclientconfig.numinitialrequestretries.md) | | number | _(Optional)_ Number of additional times the initial configuration request will be attempted if it fails. This is the request servers typically synchronously wait for completion. A small wait will be done between requests. (Default: 1) | -| [numPollRequestRetries?](./node-server-sdk.iclientconfig.numpollrequestretries.md) | | number | _(Optional)_ Number of additional times polling for updated configurations will be attempted before giving up. Polling is done after a successful initial request. Subsequent attempts are done using an exponential backoff. (Default: 7) | -| [pollAfterFailedInitialization?](./node-server-sdk.iclientconfig.pollafterfailedinitialization.md) | | boolean | _(Optional)_ Poll for new configurations even if the initial configuration request failed. (default: false) | -| [pollingIntervalMs?](./node-server-sdk.iclientconfig.pollingintervalms.md) | | number | _(Optional)_ Amount of time to wait between API calls to refresh configuration data. Default of 30\_000 (30 seconds). | -| [requestTimeoutMs?](./node-server-sdk.iclientconfig.requesttimeoutms.md) | | number | _(Optional)_ \* Timeout in milliseconds for the HTTPS request for the experiment configuration. (Default: 5000) | -| [throwOnFailedInitialization?](./node-server-sdk.iclientconfig.throwonfailedinitialization.md) | | boolean | _(Optional)_ Throw on error if unable to fetch an initial configuration during initialization. (default: true) | - diff --git a/docs/node-server-sdk.iclientconfig.numinitialrequestretries.md b/docs/node-server-sdk.iclientconfig.numinitialrequestretries.md deleted file mode 100644 index 615468c..0000000 --- a/docs/node-server-sdk.iclientconfig.numinitialrequestretries.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/node-server-sdk](./node-server-sdk.md) > [IClientConfig](./node-server-sdk.iclientconfig.md) > [numInitialRequestRetries](./node-server-sdk.iclientconfig.numinitialrequestretries.md) - -## IClientConfig.numInitialRequestRetries property - -Number of additional times the initial configuration request will be attempted if it fails. This is the request servers typically synchronously wait for completion. A small wait will be done between requests. (Default: 1) - -**Signature:** - -```typescript -numInitialRequestRetries?: number; -``` diff --git a/docs/node-server-sdk.iclientconfig.numpollrequestretries.md b/docs/node-server-sdk.iclientconfig.numpollrequestretries.md deleted file mode 100644 index 638e3d3..0000000 --- a/docs/node-server-sdk.iclientconfig.numpollrequestretries.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/node-server-sdk](./node-server-sdk.md) > [IClientConfig](./node-server-sdk.iclientconfig.md) > [numPollRequestRetries](./node-server-sdk.iclientconfig.numpollrequestretries.md) - -## IClientConfig.numPollRequestRetries property - -Number of additional times polling for updated configurations will be attempted before giving up. Polling is done after a successful initial request. Subsequent attempts are done using an exponential backoff. (Default: 7) - -**Signature:** - -```typescript -numPollRequestRetries?: number; -``` diff --git a/docs/node-server-sdk.iclientconfig.pollafterfailedinitialization.md b/docs/node-server-sdk.iclientconfig.pollafterfailedinitialization.md deleted file mode 100644 index 9bd8470..0000000 --- a/docs/node-server-sdk.iclientconfig.pollafterfailedinitialization.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/node-server-sdk](./node-server-sdk.md) > [IClientConfig](./node-server-sdk.iclientconfig.md) > [pollAfterFailedInitialization](./node-server-sdk.iclientconfig.pollafterfailedinitialization.md) - -## IClientConfig.pollAfterFailedInitialization property - -Poll for new configurations even if the initial configuration request failed. (default: false) - -**Signature:** - -```typescript -pollAfterFailedInitialization?: boolean; -``` diff --git a/docs/node-server-sdk.iclientconfig.pollingintervalms.md b/docs/node-server-sdk.iclientconfig.pollingintervalms.md deleted file mode 100644 index 337fca9..0000000 --- a/docs/node-server-sdk.iclientconfig.pollingintervalms.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/node-server-sdk](./node-server-sdk.md) > [IClientConfig](./node-server-sdk.iclientconfig.md) > [pollingIntervalMs](./node-server-sdk.iclientconfig.pollingintervalms.md) - -## IClientConfig.pollingIntervalMs property - -Amount of time to wait between API calls to refresh configuration data. Default of 30\_000 (30 seconds). - -**Signature:** - -```typescript -pollingIntervalMs?: number; -``` diff --git a/docs/node-server-sdk.iclientconfig.requesttimeoutms.md b/docs/node-server-sdk.iclientconfig.requesttimeoutms.md deleted file mode 100644 index 587e295..0000000 --- a/docs/node-server-sdk.iclientconfig.requesttimeoutms.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/node-server-sdk](./node-server-sdk.md) > [IClientConfig](./node-server-sdk.iclientconfig.md) > [requestTimeoutMs](./node-server-sdk.iclientconfig.requesttimeoutms.md) - -## IClientConfig.requestTimeoutMs property - -\* Timeout in milliseconds for the HTTPS request for the experiment configuration. (Default: 5000) - -**Signature:** - -```typescript -requestTimeoutMs?: number; -``` diff --git a/docs/node-server-sdk.iclientconfig.throwonfailedinitialization.md b/docs/node-server-sdk.iclientconfig.throwonfailedinitialization.md deleted file mode 100644 index 9cc3ac4..0000000 --- a/docs/node-server-sdk.iclientconfig.throwonfailedinitialization.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@eppo/node-server-sdk](./node-server-sdk.md) > [IClientConfig](./node-server-sdk.iclientconfig.md) > [throwOnFailedInitialization](./node-server-sdk.iclientconfig.throwonfailedinitialization.md) - -## IClientConfig.throwOnFailedInitialization property - -Throw on error if unable to fetch an initial configuration during initialization. (default: true) - -**Signature:** - -```typescript -throwOnFailedInitialization?: boolean; -``` diff --git a/docs/node-server-sdk.init.md b/docs/node-server-sdk.init.md index 9e931db..4319ce8 100644 --- a/docs/node-server-sdk.init.md +++ b/docs/node-server-sdk.init.md @@ -4,7 +4,7 @@ ## init() function -Initializes the Eppo client with configuration parameters. This method should be called once on application startup. After invocation of this method, the SDK will poll Eppo's API at regular intervals to retrieve assignment configurations. +Initializes the Eppo client with configuration parameters. This method should be called once on application startup. After invocation of this method, the SDK will poll Eppo API at regular intervals to retrieve assignment configurations. **Signature:** @@ -16,7 +16,7 @@ export declare function init(config: IClientConfig): Promise; | Parameter | Type | Description | | --- | --- | --- | -| config | [IClientConfig](./node-server-sdk.iclientconfig.md) | client configuration | +| config | IClientConfig | client configuration | **Returns:** diff --git a/docs/node-server-sdk.md b/docs/node-server-sdk.md index 2969951..bf3ebd3 100644 --- a/docs/node-server-sdk.md +++ b/docs/node-server-sdk.md @@ -9,11 +9,5 @@ | Function | Description | | --- | --- | | [getInstance()](./node-server-sdk.getinstance.md) | Used to access a singleton SDK client instance. Use the method after calling init() to initialize the client. | -| [init(config)](./node-server-sdk.init.md) | Initializes the Eppo client with configuration parameters. This method should be called once on application startup. After invocation of this method, the SDK will poll Eppo's API at regular intervals to retrieve assignment configurations. | - -## Interfaces - -| Interface | Description | -| --- | --- | -| [IClientConfig](./node-server-sdk.iclientconfig.md) | Configuration used for initializing the Eppo client | +| [init(config)](./node-server-sdk.init.md) | Initializes the Eppo client with configuration parameters. This method should be called once on application startup. After invocation of this method, the SDK will poll Eppo API at regular intervals to retrieve assignment configurations. | diff --git a/package.json b/package.json index 781e88b..2731de7 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "homepage": "https://github.com/Eppo-exp/node-server-sdk#readme", "dependencies": { - "@eppo/js-client-sdk-common": "4.3.0", + "@eppo/js-client-sdk-common": "^4.5.0", "lru-cache": "^10.0.1" }, "devDependencies": { @@ -46,7 +46,7 @@ "eslint-plugin-import": "^2.25.4", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-promise": "^6.0.0", - "express": "^4.18.0", + "express": "^4.21.1", "husky": "^6.0.0", "jest": "^29.7.0", "lint-staged": "^12.3.5", @@ -58,5 +58,6 @@ "engines": { "node": ">=18.x", "yarn": "1.x" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/events/file-backed-named-event-queue.spec.ts b/src/events/file-backed-named-event-queue.spec.ts new file mode 100644 index 0000000..091a34c --- /dev/null +++ b/src/events/file-backed-named-event-queue.spec.ts @@ -0,0 +1,204 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import FileBackedNamedEventQueue from './file-backed-named-event-queue'; + +describe('FileBackedNamedEventQueue', () => { + describe('string events', () => { + const queueName = 'testQueue'; + let queue: FileBackedNamedEventQueue; + const queueDirectory = path.resolve(process.cwd(), `.queues/${queueName}`); + + beforeEach(() => { + // Clean up the queue directory + if (fs.existsSync(queueDirectory)) { + fs.rmSync(queueDirectory, { recursive: true, force: true }); + } + queue = new FileBackedNamedEventQueue(queueName); + }); + + afterAll(() => { + if (fs.existsSync(queueDirectory)) { + fs.rmSync(queueDirectory, { recursive: true, force: true }); + } + }); + + it('should initialize with an empty queue', () => { + expect(queue.length).toBe(0); + }); + + it('should persist and retrieve events correctly via push and iterator', () => { + queue.push('event1'); + queue.push('event2'); + + expect(queue.length).toBe(2); + + const events = Array.from(queue); + expect(events).toEqual(['event1', 'event2']); + }); + + it('should persist and retrieve events correctly via push and shift', () => { + queue.push('event1'); + queue.push('event2'); + + const firstEvent = queue.shift(); + expect(firstEvent).toBe('event1'); + expect(queue.length).toBe(1); + + const secondEvent = queue.shift(); + expect(secondEvent).toBe('event2'); + expect(queue.length).toBe(0); + }); + + it('should remove events from file system after shift', () => { + queue.push('event1'); + const eventFiles = fs.readdirSync(queueDirectory); + expect(eventFiles.length).toBe(2); // One for metadata.json, one for the event file + + queue.shift(); + const updatedEventFiles = fs.readdirSync(queueDirectory); + expect(updatedEventFiles.length).toBe(1); // Only metadata.json should remain + }); + + it('should reconstruct the queue from metadata file', () => { + queue.push('event1'); + queue.push('event2'); + + const newQueueInstance = new FileBackedNamedEventQueue(queueName); + expect(newQueueInstance.length).toBe(2); + + const events = Array.from(newQueueInstance); + expect(events).toEqual(['event1', 'event2']); + }); + + it('should handle empty shift gracefully', () => { + expect(queue.shift()).toBeUndefined(); + }); + + it('should not fail if metadata file is corrupted', () => { + const corruptedMetadataFile = path.join(queueDirectory, 'metadata.json'); + fs.writeFileSync(corruptedMetadataFile, '{ corrupted state }'); + + const newQueueInstance = new FileBackedNamedEventQueue(queueName); + expect(newQueueInstance.length).toBe(0); + }); + + it('should handle events with the same content correctly using consistent hashing', () => { + queue.push('event1'); + queue.push('event1'); // Push the same event content twice + + expect(queue.length).toBe(2); + + const events = Array.from(queue); + expect(events).toEqual(['event1', 'event1']); + }); + + it('should store each event as a separate file', () => { + queue.push('event1'); + queue.push('event2'); + + const eventFiles = fs.readdirSync(queueDirectory).filter((file) => file !== 'metadata.json'); + expect(eventFiles.length).toBe(2); + + const eventData1 = fs.readFileSync(path.join(queueDirectory, eventFiles[0]), 'utf8'); + const eventData2 = fs.readFileSync(path.join(queueDirectory, eventFiles[1]), 'utf8'); + + expect([JSON.parse(eventData1), JSON.parse(eventData2)]).toEqual(['event1', 'event2']); + }); + }); + + describe('arbitrary object shapes', () => { + const queueName = 'objectQueue'; + let queue: FileBackedNamedEventQueue<{ id: number; name: string }>; + const queueDirectory = path.resolve(process.cwd(), `.queues/${queueName}`); + + beforeEach(() => { + // Clean up the queue directory + if (fs.existsSync(queueDirectory)) { + fs.rmdirSync(queueDirectory, { recursive: true }); + } + queue = new FileBackedNamedEventQueue(queueName); + }); + + afterAll(() => { + if (fs.existsSync(queueDirectory)) { + fs.rmdirSync(queueDirectory, { recursive: true }); + } + }); + + it('should handle objects with arbitrary shapes via push and shift', () => { + queue.push({ id: 1, name: 'event1' }); + queue.push({ id: 2, name: 'event2' }); + + expect(queue.length).toBe(2); + + const firstEvent = queue.shift(); + expect(firstEvent).toEqual({ id: 1, name: 'event1' }); + + const secondEvent = queue.shift(); + expect(secondEvent).toEqual({ id: 2, name: 'event2' }); + + expect(queue.length).toBe(0); + }); + + it('should persist and reconstruct queue with objects from metadata file', () => { + queue.push({ id: 1, name: 'event1' }); + queue.push({ id: 2, name: 'event2' }); + + const newQueueInstance = new FileBackedNamedEventQueue<{ id: number; name: string }>( + queueName, + ); + expect(newQueueInstance.length).toBe(2); + + const events = Array.from(newQueueInstance); + expect(events).toEqual([ + { id: 1, name: 'event1' }, + { id: 2, name: 'event2' }, + ]); + }); + }); + + describe('splice', () => { + const queueName = 'spliceQueue'; + let queue: FileBackedNamedEventQueue; + const queueDirectory = path.resolve(process.cwd(), `.queues/${queueName}`); + + beforeEach(() => { + // Clean up the queue directory + if (fs.existsSync(queueDirectory)) { + fs.rmdirSync(queueDirectory, { recursive: true }); + } + queue = new FileBackedNamedEventQueue(queueName); + }); + + afterAll(() => { + if (fs.existsSync(queueDirectory)) { + fs.rmdirSync(queueDirectory, { recursive: true }); + } + }); + + it('should return the correct number of events and remove them from the queue', () => { + queue.push('event1'); + queue.push('event2'); + queue.push('event3'); + + const splicedEvents = queue.splice(2); + expect(splicedEvents).toEqual(['event1', 'event2']); + expect(queue.length).toBe(1); + }); + + it('should return all events if the count is greater than the queue length', () => { + queue.push('event1'); + queue.push('event2'); + + const splicedEvents = queue.splice(3); + expect(splicedEvents).toEqual(['event1', 'event2']); + expect(queue.length).toBe(0); + }); + + it('should return an empty array if the queue is empty', () => { + const splicedEvents = queue.splice(1); + expect(splicedEvents).toEqual([]); + }); + }); +}); diff --git a/src/events/file-backed-named-event-queue.ts b/src/events/file-backed-named-event-queue.ts new file mode 100644 index 0000000..7db1a9f --- /dev/null +++ b/src/events/file-backed-named-event-queue.ts @@ -0,0 +1,105 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { applicationLogger, NamedEventQueue } from '@eppo/js-client-sdk-common'; + +import { takeWhile } from '../util'; + +export default class FileBackedNamedEventQueue implements NamedEventQueue { + private readonly queueDirectory: string; + private readonly metadataFile: string; + private eventKeys: string[] = []; + + constructor(public readonly name: string) { + this.queueDirectory = path.resolve(process.cwd(), `.queues/${this.name}`); + this.metadataFile = path.join(this.queueDirectory, 'metadata.json'); + + if (!fs.existsSync(this.queueDirectory)) { + fs.mkdirSync(this.queueDirectory, { recursive: true }); + } + + this.loadStateFromFile(); + } + + splice(count: number): T[] { + const arr = Array.from({ length: count }, () => this.shift()); + return takeWhile(arr, (item) => item !== undefined) as T[]; + } + + isEmpty(): boolean { + return this.length === 0; + } + + get length(): number { + return this.eventKeys.length; + } + + push(event: T): void { + const eventKey = this.generateEventKey(event); + const eventFilePath = this.getEventFilePath(eventKey); + fs.writeFileSync(eventFilePath, JSON.stringify(event), 'utf8'); + this.eventKeys.push(eventKey); + this.saveStateToFile(); + } + + *[Symbol.iterator](): IterableIterator { + for (const key of this.eventKeys) { + const eventFilePath = this.getEventFilePath(key); + if (fs.existsSync(eventFilePath)) { + const eventData = fs.readFileSync(eventFilePath, 'utf8'); + yield JSON.parse(eventData) as T; + } + } + } + + shift(): T | undefined { + if (this.isEmpty()) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const eventKey = this.eventKeys.shift()!; + const eventFilePath = this.getEventFilePath(eventKey); + + if (fs.existsSync(eventFilePath)) { + const eventData = fs.readFileSync(eventFilePath, 'utf8'); + fs.unlinkSync(eventFilePath); + this.saveStateToFile(); + return JSON.parse(eventData) as T; + } + } + + private loadStateFromFile(): void { + if (fs.existsSync(this.metadataFile)) { + try { + const metadata = fs.readFileSync(this.metadataFile, 'utf8'); + this.eventKeys = JSON.parse(metadata); + } catch { + applicationLogger.error('Failed to parse metadata file. Initializing empty queue.'); + this.eventKeys = []; + } + } + } + + private saveStateToFile(): void { + fs.writeFileSync(this.metadataFile, JSON.stringify(this.eventKeys), 'utf8'); + } + + private generateEventKey(event: T): string { + return this.hashEvent(event); + } + + private getEventFilePath(eventKey: string): string { + return path.join(this.queueDirectory, `${eventKey}.json`); + } + + private hashEvent(event: T): string { + const value = JSON.stringify(event); + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } + return hash.toString(36); + } +} diff --git a/src/i-client-config.ts b/src/i-client-config.ts new file mode 100644 index 0000000..9175d81 --- /dev/null +++ b/src/i-client-config.ts @@ -0,0 +1,48 @@ +import { IAssignmentLogger, IBanditLogger } from '@eppo/js-client-sdk-common'; + +/** + * Configuration used for initializing the Eppo client + * @public + */ +export interface IClientConfig { + /** Eppo SDK key */ + apiKey: string; + + /** + * Base URL of the Eppo API. + * Clients should use the default setting in most cases. + */ + baseUrl?: string; + + /** Provide a logging implementation to send variation assignments to your data warehouse. */ + assignmentLogger: IAssignmentLogger; + + /** Logging implementation to send bandit actions to your data warehouse */ + banditLogger?: IBanditLogger; + + /** Timeout in milliseconds for the HTTPS request for the experiment configuration. (Default: 5000) */ + requestTimeoutMs?: number; + + /** + * Number of additional times the initial configuration request will be attempted if it fails. + * This is the request servers typically synchronously wait for completion. A small wait will be + * done between requests. (Default: 1) + */ + numInitialRequestRetries?: number; + + /** + * Number of additional times polling for updated configurations will be attempted before giving up. + * Polling is done after a successful initial request. Subsequent attempts are done using an exponential + * backoff. (Default: 7) + */ + numPollRequestRetries?: number; + + /** Throw error if unable to fetch an initial configuration during initialization. (default: true) */ + throwOnFailedInitialization?: boolean; + + /** Poll for new configurations even if the initial configuration request failed. (default: false) */ + pollAfterFailedInitialization?: boolean; + + /** Amount of time in milliseconds to wait between API calls to refresh configuration data. Default of 30_000 (30s). */ + pollingIntervalMs?: number; +} diff --git a/src/index.spec.ts b/src/index.spec.ts index 2dec475..1f9bb4d 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -29,6 +29,8 @@ import { getInstance, IAssignmentEvent, IAssignmentLogger, init } from '.'; const { DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } = constants; +const apiKey = 'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk'; + describe('EppoClient E2E test', () => { const mockLogger: IAssignmentLogger = { logAssignment(assignment: IAssignmentEvent) { @@ -40,7 +42,6 @@ describe('EppoClient E2E test', () => { // functionality is still "on" for the client when we explicitly instantiate the client (vs. using init()) const mockBanditVariationStore = td.object>(); const mockBanditModelStore = td.object>(); - const flagKey = 'mock-experiment'; // Configuration for a single flag within the UFC. @@ -145,7 +146,7 @@ describe('EppoClient E2E test', () => { beforeAll(async () => { await init({ - apiKey: 'dummy', + apiKey, baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, assignmentLogger: mockLogger, }); @@ -189,12 +190,12 @@ describe('EppoClient E2E test', () => { it('returns the default value when ufc config is absent', () => { const mockConfigStore = td.object>(); td.when(mockConfigStore.get(flagKey)).thenReturn(null); - const client = new EppoClient( - mockConfigStore, - mockBanditVariationStore, - mockBanditModelStore, - requestParamsStub, - ); + const client = new EppoClient({ + flagConfigurationStore: mockConfigStore, + banditVariationConfigurationStore: mockBanditVariationStore, + banditModelConfigurationStore: mockBanditModelStore, + configurationRequestParameters: requestParamsStub, + }); const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value'); expect(assignment).toEqual('default-value'); }); @@ -203,12 +204,12 @@ describe('EppoClient E2E test', () => { const mockConfigStore = td.object>(); td.when(mockConfigStore.get(flagKey)).thenReturn(mockUfcFlagConfig); const subjectAttributes = { foo: 3 }; - const client = new EppoClient( - mockConfigStore, - mockBanditVariationStore, - mockBanditModelStore, - requestParamsStub, - ); + const client = new EppoClient({ + flagConfigurationStore: mockConfigStore, + banditVariationConfigurationStore: mockBanditVariationStore, + banditModelConfigurationStore: mockBanditModelStore, + configurationRequestParameters: requestParamsStub, + }); const mockLogger = td.object(); client.setAssignmentLogger(mockLogger); const assignment = client.getStringAssignment( @@ -233,12 +234,12 @@ describe('EppoClient E2E test', () => { const mockConfigStore = td.object>(); td.when(mockConfigStore.get(flagKey)).thenReturn(mockUfcFlagConfig); const subjectAttributes = { foo: 3 }; - const client = new EppoClient( - mockConfigStore, - mockBanditVariationStore, - mockBanditModelStore, - requestParamsStub, - ); + const client = new EppoClient({ + flagConfigurationStore: mockConfigStore, + banditVariationConfigurationStore: mockBanditVariationStore, + banditModelConfigurationStore: mockBanditModelStore, + configurationRequestParameters: requestParamsStub, + }); const mockLogger = td.object(); td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow( new Error('logging error'), @@ -337,7 +338,7 @@ describe('EppoClient E2E test', () => { // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes const initPromise = init({ - apiKey: 'dummy', + apiKey, baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, assignmentLogger: mockLogger, }); @@ -364,7 +365,7 @@ describe('EppoClient E2E test', () => { // timeout queue, message queue stuff) so we don't allow retries when rethrowing. await expect( init({ - apiKey: 'dummy', + apiKey, baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, assignmentLogger: mockLogger, numInitialRequestRetries: 0, @@ -399,7 +400,7 @@ describe('EppoClient E2E test', () => { // By not awaiting (yet) only the first attempt should be fired off before test execution below resumes const initPromise = init({ - apiKey: 'dummy', + apiKey, baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`, assignmentLogger: mockLogger, throwOnFailedInitialization: false, diff --git a/src/index.ts b/src/index.ts index 8bd4194..ee11acf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,77 +1,16 @@ import { - IAssignmentLogger, - validation, EppoClient, + Flag, FlagConfigurationRequestParameters, MemoryOnlyConfigurationStore, - Flag, - IBanditLogger, + newDefaultEventDispatcher, } from '@eppo/js-client-sdk-common'; import { BanditParameters, BanditVariation } from '@eppo/js-client-sdk-common/dist/interfaces'; +import FileBackedNamedEventQueue from './events/file-backed-named-event-queue'; +import { IClientConfig } from './i-client-config'; import { sdkName, sdkVersion } from './sdk-data'; -/** - * Configuration used for initializing the Eppo client - * @public - */ -export interface IClientConfig { - /** - * Eppo API key - */ - apiKey: string; - - /** - * Base URL of the Eppo API. - * Clients should use the default setting in most cases. - */ - baseUrl?: string; - - /** - * Pass a logging implementation to send variation assignments to your data warehouse. - */ - assignmentLogger: IAssignmentLogger; - - /** - * Logging implementation to send bandit actions to your data warehouse - */ - banditLogger?: IBanditLogger; - - /*** - * Timeout in milliseconds for the HTTPS request for the experiment configuration. (Default: 5000) - */ - requestTimeoutMs?: number; - - /** - * Number of additional times the initial configuration request will be attempted if it fails. - * This is the request servers typically synchronously wait for completion. A small wait will be - * done between requests. (Default: 1) - */ - numInitialRequestRetries?: number; - - /** - * Number of additional times polling for updated configurations will be attempted before giving up. - * Polling is done after a successful initial request. Subsequent attempts are done using an exponential - * backoff. (Default: 7) - */ - numPollRequestRetries?: number; - - /** - * Throw on error if unable to fetch an initial configuration during initialization. (default: true) - */ - throwOnFailedInitialization?: boolean; - - /** - * Poll for new configurations even if the initial configuration request failed. (default: false) - */ - pollAfterFailedInitialization?: boolean; - - /** - * Amount of time to wait between API calls to refresh configuration data. Default of 30_000 (30 seconds). - */ - pollingIntervalMs?: number; -} - export { IAssignmentDetails, IAssignmentEvent, @@ -80,42 +19,59 @@ export { IBanditLogger, } from '@eppo/js-client-sdk-common'; +export { IClientConfig }; + let clientInstance: EppoClient; /** * Initializes the Eppo client with configuration parameters. * This method should be called once on application startup. - * After invocation of this method, the SDK will poll Eppo's API at regular intervals to retrieve assignment configurations. + * After invocation of this method, the SDK will poll Eppo API at regular intervals to retrieve assignment configurations. * @param config client configuration * @public */ export async function init(config: IClientConfig): Promise { - validation.validateNotBlank(config.apiKey, 'API key required'); + const { + apiKey, + baseUrl, + requestTimeoutMs, + numInitialRequestRetries, + numPollRequestRetries, + pollingIntervalMs, + throwOnFailedInitialization = true, + pollAfterFailedInitialization = false, + } = config; + if (!apiKey) { + throw new Error('API key is required'); + } - const requestConfiguration: FlagConfigurationRequestParameters = { - apiKey: config.apiKey, + const configurationRequestParameters: FlagConfigurationRequestParameters = { + apiKey, sdkName, sdkVersion, - baseUrl: config.baseUrl ?? undefined, - requestTimeoutMs: config.requestTimeoutMs ?? undefined, - numInitialRequestRetries: config.numInitialRequestRetries ?? undefined, - numPollRequestRetries: config.numPollRequestRetries ?? undefined, - pollAfterSuccessfulInitialization: true, // For servers, we always want to keep polling for the life of the server - pollAfterFailedInitialization: config.pollAfterFailedInitialization ?? false, - pollingIntervalMs: config.pollingIntervalMs ?? undefined, - throwOnFailedInitialization: config.throwOnFailedInitialization ?? true, + baseUrl, + requestTimeoutMs, + numInitialRequestRetries, + numPollRequestRetries, + // For server-side, we always want to keep polling for the life of the process + pollAfterSuccessfulInitialization: true, + pollAfterFailedInitialization, + pollingIntervalMs, + throwOnFailedInitialization, }; const flagConfigurationStore = new MemoryOnlyConfigurationStore(); const banditVariationConfigurationStore = new MemoryOnlyConfigurationStore(); const banditModelConfigurationStore = new MemoryOnlyConfigurationStore(); + const eventDispatcher = newEventDispatcher(apiKey); - clientInstance = new EppoClient( + clientInstance = new EppoClient({ flagConfigurationStore, banditVariationConfigurationStore, banditModelConfigurationStore, - requestConfiguration, - ); + configurationRequestParameters, + eventDispatcher, + }); clientInstance.setAssignmentLogger(config.assignmentLogger); if (config.banditLogger) { clientInstance.setBanditLogger(config.banditLogger); @@ -125,7 +81,7 @@ export async function init(config: IClientConfig): Promise { // we estimate this will use no more than 10 MB of memory // and should be appropriate for most server-side use cases. clientInstance.useLRUInMemoryAssignmentCache(50_000); - clientInstance.useLRUInMemoryBanditAssignmentCache(50_000); + clientInstance.useExpiringInMemoryBanditAssignmentCache(50_000); // Fetch configurations (which will also start regular polling per requestConfiguration) await clientInstance.fetchFlagConfigurations(); @@ -136,7 +92,7 @@ export async function init(config: IClientConfig): Promise { /** * Used to access a singleton SDK client instance. * Use the method after calling init() to initialize the client. - * @returns a singleton client instance + * @returns a singleton client instance or throws an Error if init() has not been called */ export function getInstance(): EppoClient { if (!clientInstance) { @@ -144,3 +100,11 @@ export function getInstance(): EppoClient { } return clientInstance; } + +function newEventDispatcher(sdkKey: string) { + const eventQueue = new FileBackedNamedEventQueue('events'); + const emptyNetworkStatusListener = + // eslint-disable-next-line @typescript-eslint/no-empty-function + { isOffline: () => false, onNetworkStatusChange: () => {} }; + return newDefaultEventDispatcher(eventQueue, emptyNetworkStatusListener, sdkKey); +} diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..b138281 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,11 @@ +/* Returns elements from arr until the predicate returns false. */ +export function takeWhile(arr: T[], predicate: (item: T) => boolean): T[] { + const result = []; + for (const item of arr) { + if (!predicate(item)) { + break; + } + result.push(item); + } + return result; +} diff --git a/test/mockApiServer.ts b/test/mockApiServer.ts index 270e4ca..676fa7c 100644 --- a/test/mockApiServer.ts +++ b/test/mockApiServer.ts @@ -10,7 +10,7 @@ import { const api = express(); export const TEST_SERVER_PORT = 4123; -export const TEST_BANDIT_API_KEY = 'bandit-test-key'; +export const TEST_BANDIT_API_KEY = 'foo.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk'; const flagEndpoint = /flag-config\/v1\/config*/; const banditEndpoint = /flag-config\/v1\/bandits*/; diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 3068443..c158fb4 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -80,7 +80,7 @@ export function getTestAssignments( testCase.defaultValue, obfuscated, ); - assignments.push({ subject: subject, assignment: assignment }); + assignments.push({ subject, assignment }); } return assignments; } diff --git a/yarn.lock b/yarn.lock index 980cbef..75779e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -460,15 +460,16 @@ resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@eppo/js-client-sdk-common@4.3.0": - version "4.3.0" - resolved "https://registry.yarnpkg.com/@eppo/js-client-sdk-common/-/js-client-sdk-common-4.3.0.tgz#66c0e5904091ac1a9c2bc3bf4017637b13404ce8" - integrity sha512-ur270vCZjUuKuEohF1vH7yh1MBxtDbVcduCJzJmJ6m7kjoyvqNPzG/+lYPEol6Bpr9wV42ciIB+A1cYeNZ7gSA== +"@eppo/js-client-sdk-common@^4.5.0": + version "4.5.0" + resolved "https://registry.yarnpkg.com/@eppo/js-client-sdk-common/-/js-client-sdk-common-4.5.0.tgz#8a1745c3d162b882fc57b4bb3428ce3f9c6755fc" + integrity sha512-G9WBt+fz8SIwhIQ8fz4fPL6d1UMCCC0uPR99TQGG9NEREEcyfML+Di1/5cCIeNvTSlxsMLtSa1C+I0tMQAg21g== dependencies: js-base64 "^3.7.7" md5 "^2.3.0" pino "^8.19.0" semver "^7.5.4" + uuid "^8.3.2" "@eslint/eslintrc@^0.4.3": version "0.4.3" @@ -1727,10 +1728,10 @@ cookie-signature@1.0.6: resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= -cookie@0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" - integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== +cookie@0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9" + integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w== create-jest@^29.7.0: version "29.7.0" @@ -2247,24 +2248,24 @@ expect@^29.0.0, expect@^29.7.0: jest-message-util "^29.7.0" jest-util "^29.7.0" -express@^4.18.0: - version "4.20.0" - resolved "https://registry.yarnpkg.com/express/-/express-4.20.0.tgz#f1d08e591fcec770c07be4767af8eb9bcfd67c48" - integrity sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw== +express@^4.21.1: + version "4.21.1" + resolved "https://registry.yarnpkg.com/express/-/express-4.21.1.tgz#9dae5dda832f16b4eec941a4e44aa89ec481b281" + integrity sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ== dependencies: accepts "~1.3.8" array-flatten "1.1.1" body-parser "1.20.3" content-disposition "0.5.4" content-type "~1.0.4" - cookie "0.6.0" + cookie "0.7.1" cookie-signature "1.0.6" debug "2.6.9" depd "2.0.0" encodeurl "~2.0.0" escape-html "~1.0.3" etag "~1.8.1" - finalhandler "1.2.0" + finalhandler "1.3.1" fresh "0.5.2" http-errors "2.0.0" merge-descriptors "1.0.3" @@ -2273,11 +2274,11 @@ express@^4.18.0: parseurl "~1.3.3" path-to-regexp "0.1.10" proxy-addr "~2.0.7" - qs "6.11.0" + qs "6.13.0" range-parser "~1.2.1" safe-buffer "5.2.1" send "0.19.0" - serve-static "1.16.0" + serve-static "1.16.2" setprototypeof "1.2.0" statuses "2.0.1" type-is "~1.6.18" @@ -2358,13 +2359,13 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" -finalhandler@1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz" - integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== +finalhandler@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.1.tgz#0c575f1d1d324ddd1da35ad7ece3df7d19088019" + integrity sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ== dependencies: debug "2.6.9" - encodeurl "~1.0.2" + encodeurl "~2.0.0" escape-html "~1.0.3" on-finished "2.4.1" parseurl "~1.3.3" @@ -4037,13 +4038,6 @@ pure-rand@^6.0.0: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.4.tgz#50b737f6a925468679bff00ad20eade53f37d5c7" integrity sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA== -qs@6.11.0: - version "6.11.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" - integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== - dependencies: - side-channel "^1.0.4" - qs@6.13.0: version "6.13.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" @@ -4261,25 +4255,6 @@ semver@^7.2.1, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@~7.5.4: dependencies: lru-cache "^6.0.0" -send@0.18.0: - version "0.18.0" - resolved "https://registry.npmjs.org/send/-/send-0.18.0.tgz" - integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== - dependencies: - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "2.0.0" - mime "1.6.0" - ms "2.1.3" - on-finished "2.4.1" - range-parser "~1.2.1" - statuses "2.0.1" - send@0.19.0: version "0.19.0" resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" @@ -4299,15 +4274,15 @@ send@0.19.0: range-parser "~1.2.1" statuses "2.0.1" -serve-static@1.16.0: - version "1.16.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.0.tgz#2bf4ed49f8af311b519c46f272bf6ac3baf38a92" - integrity sha512-pDLK8zwl2eKaYrs8mrPZBJua4hMplRWJ1tIFksVC3FtBEBnl8dxgeHtsaMS8DhS9i4fLObaon6ABoc4/hQGdPA== +serve-static@1.16.2: + version "1.16.2" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296" + integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw== dependencies: - encodeurl "~1.0.2" + encodeurl "~2.0.0" escape-html "~1.0.3" parseurl "~1.3.3" - send "0.18.0" + send "0.19.0" set-function-length@^1.2.1: version "1.2.2" @@ -4805,9 +4780,9 @@ utils-merge@1.0.1: resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= -uuid@^8.0.0: +uuid@^8.0.0, uuid@^8.3.2: version "8.3.2" - resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== uuid@^9.0.0: