diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e61f52874..f61e658ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [16, 18, 20, 22] + node: [18, 20, 22] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/.nvmrc b/.nvmrc index f46d5e394..216afccff 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -14.21.3 +v18.20.7 diff --git a/package.json b/package.json index a89c5cb0c..96325b8b3 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "yarn": "1.22.19" }, "engines": { - "node": ">=16" + "node": ">=18" }, "typings": "lib/index.d.ts", "files": [ @@ -72,4 +72,5 @@ "default": "./lib/index.js" } } -} \ No newline at end of file +} + diff --git a/src/common/interfaces/event.interface.ts b/src/common/interfaces/event.interface.ts index 72d9620d4..20cd6c942 100644 --- a/src/common/interfaces/event.interface.ts +++ b/src/common/interfaces/event.interface.ts @@ -201,16 +201,6 @@ export interface DsyncActivatedEventResponse extends EventResponseBase { data: EventDirectoryResponse; } -export interface DsyncDeactivatedEvent extends EventBase { - event: 'dsync.deactivated'; - data: EventDirectory; -} - -export interface DsyncDeactivatedEventResponse extends EventResponseBase { - event: 'dsync.deactivated'; - data: EventDirectoryResponse; -} - export interface DsyncDeletedEvent extends EventBase { event: 'dsync.deleted'; data: Omit; @@ -561,7 +551,6 @@ export type Event = | ConnectionDeactivatedEvent | ConnectionDeletedEvent | DsyncActivatedEvent - | DsyncDeactivatedEvent | DsyncDeletedEvent | DsyncGroupCreatedEvent | DsyncGroupUpdatedEvent @@ -608,7 +597,6 @@ export type EventResponse = | ConnectionDeactivatedEventResponse | ConnectionDeletedEventResponse | DsyncActivatedEventResponse - | DsyncDeactivatedEventResponse | DsyncDeletedEventResponse | DsyncGroupCreatedEventResponse | DsyncGroupUpdatedEventResponse diff --git a/src/common/net/index.ts b/src/common/net/index.ts index db39b5fea..0b6c2235b 100644 --- a/src/common/net/index.ts +++ b/src/common/net/index.ts @@ -1,6 +1,5 @@ import { FetchHttpClient } from './fetch-client'; import { HttpClient } from './http-client'; -import { NodeHttpClient } from './node-client'; export function createHttpClient( baseURL: string, @@ -10,10 +9,9 @@ export function createHttpClient( if (typeof fetch !== 'undefined' || typeof fetchFn !== 'undefined') { return new FetchHttpClient(baseURL, options, fetchFn); } else { - return new NodeHttpClient(baseURL, options); + throw new Error('Please upgrade your Node.js version to 18 or higher'); } } export * from './fetch-client'; -export * from './node-client'; export * from './http-client'; diff --git a/src/common/net/node-client.spec.ts b/src/common/net/node-client.spec.ts deleted file mode 100644 index 37dfcb88f..000000000 --- a/src/common/net/node-client.spec.ts +++ /dev/null @@ -1,199 +0,0 @@ -import nock from 'nock'; -import { NodeHttpClient } from './node-client'; - -const nodeClient = new NodeHttpClient('https://test.workos.com', { - headers: { - Authorization: `Bearer sk_test`, - 'User-Agent': 'test-node-client', - }, -}); - -describe('Node client', () => { - beforeEach(() => nock.cleanAll()); - - it('get for FGA path should call nodeRequestWithRetry and return response', async () => { - nock('https://test.workos.com') - .get('/fga/v1/resources') - .reply(200, { data: 'response' }); - const mockNodeRequestWithRetry = jest.spyOn( - NodeHttpClient.prototype as any, - 'nodeRequestWithRetry', - ); - - const response = await nodeClient.get('/fga/v1/resources', {}); - - expect(mockNodeRequestWithRetry).toHaveBeenCalledTimes(1); - expect(await response.toJSON()).toEqual({ data: 'response' }); - }); - - it('post for FGA path should call nodeRequestWithRetry and return response', async () => { - nock('https://test.workos.com') - .post('/fga/v1/resources') - .reply(200, { data: 'response' }); - const mockNodeRequestWithRetry = jest.spyOn( - NodeHttpClient.prototype as any, - 'nodeRequestWithRetry', - ); - - const response = await nodeClient.post('/fga/v1/resources', {}, {}); - - expect(mockNodeRequestWithRetry).toHaveBeenCalledTimes(1); - expect(await response.toJSON()).toEqual({ data: 'response' }); - }); - - it('put for FGA path should call nodeRequestWithRetry and return response', async () => { - nock('https://test.workos.com') - .put('/fga/v1/resources/user/user-1') - .reply(200, { data: 'response' }); - const mockNodeRequestWithRetry = jest.spyOn( - NodeHttpClient.prototype as any, - 'nodeRequestWithRetry', - ); - - const response = await nodeClient.put( - '/fga/v1/resources/user/user-1', - {}, - {}, - ); - - expect(mockNodeRequestWithRetry).toHaveBeenCalledTimes(1); - expect(await response.toJSON()).toEqual({ data: 'response' }); - }); - - it('delete for FGA path should call nodeRequestWithRetry and return response', async () => { - nock('https://test.workos.com') - .delete('/fga/v1/resources/user/user-1') - .reply(200, { data: 'response' }); - const mockNodeRequestWithRetry = jest.spyOn( - NodeHttpClient.prototype as any, - 'nodeRequestWithRetry', - ); - - const response = await nodeClient.delete( - '/fga/v1/resources/user/user-1', - {}, - ); - - expect(mockNodeRequestWithRetry).toHaveBeenCalledTimes(1); - expect(await response.toJSON()).toEqual({ data: 'response' }); - }); - - it('should retry request on 500 status code', async () => { - nock('https://test.workos.com') - .get('/fga/v1/resources') - .reply(500) - .get('/fga/v1/resources') - .reply(200, { data: 'response' }); - const mockShouldRetryRequest = jest.spyOn( - NodeHttpClient.prototype as any, - 'shouldRetryRequest', - ); - const mockSleep = jest.spyOn(nodeClient, 'sleep'); - mockSleep.mockImplementation(() => Promise.resolve()); - - const response = await nodeClient.get('/fga/v1/resources', {}); - - expect(mockShouldRetryRequest).toHaveBeenCalledTimes(2); - expect(mockSleep).toHaveBeenCalledTimes(1); - expect(await response.toJSON()).toEqual({ data: 'response' }); - }); - - it('should retry request on 502 status code', async () => { - nock('https://test.workos.com') - .get('/fga/v1/resources') - .reply(502) - .get('/fga/v1/resources') - .reply(200, { data: 'response' }); - const mockShouldRetryRequest = jest.spyOn( - NodeHttpClient.prototype as any, - 'shouldRetryRequest', - ); - const mockSleep = jest.spyOn(nodeClient, 'sleep'); - mockSleep.mockImplementation(() => Promise.resolve()); - - const response = await nodeClient.get('/fga/v1/resources', {}); - - expect(mockShouldRetryRequest).toHaveBeenCalledTimes(2); - expect(mockSleep).toHaveBeenCalledTimes(1); - expect(await response.toJSON()).toEqual({ data: 'response' }); - }); - - it('should retry request on 504 status code', async () => { - nock('https://test.workos.com') - .get('/fga/v1/resources') - .reply(504) - .get('/fga/v1/resources') - .reply(200, { data: 'response' }); - const mockShouldRetryRequest = jest.spyOn( - NodeHttpClient.prototype as any, - 'shouldRetryRequest', - ); - const mockSleep = jest.spyOn(nodeClient, 'sleep'); - mockSleep.mockImplementation(() => Promise.resolve()); - - const response = await nodeClient.get('/fga/v1/resources', {}); - - expect(mockShouldRetryRequest).toHaveBeenCalledTimes(2); - expect(mockSleep).toHaveBeenCalledTimes(1); - expect(await response.toJSON()).toEqual({ data: 'response' }); - }); - - it('should retry request up to 3 times on retryable status code', async () => { - nock('https://test.workos.com') - .get('/fga/v1/resources') - .reply(504) - .get('/fga/v1/resources') - .reply(502) - .get('/fga/v1/resources') - .reply(500) - .get('/fga/v1/resources') - .reply(500); - const mockShouldRetryRequest = jest.spyOn( - NodeHttpClient.prototype as any, - 'shouldRetryRequest', - ); - const mockSleep = jest.spyOn(nodeClient, 'sleep'); - mockSleep.mockImplementation(() => Promise.resolve()); - - await expect( - nodeClient.get('/fga/v1/resources', {}), - ).rejects.toThrowError(); - - expect(mockShouldRetryRequest).toHaveBeenCalledTimes(4); - expect(mockSleep).toHaveBeenCalledTimes(3); - }); - - it('should not retry request on non-retryable status code', async () => { - nock('https://test.workos.com').get('/fga/v1/resources').reply(400); - const mockShouldRetryRequest = jest.spyOn( - NodeHttpClient.prototype as any, - 'shouldRetryRequest', - ); - - await expect( - nodeClient.get('/fga/v1/resources', {}), - ).rejects.toThrowError(); - - expect(mockShouldRetryRequest).toHaveBeenCalledTimes(1); - }); - - it('should retry request on TypeError', async () => { - nock('https://test.workos.com') - .get('/fga/v1/resources') - .replyWithError(new TypeError('Network request failed')) - .get('/fga/v1/resources') - .reply(200, { data: 'response' }); - const mockShouldRetryRequest = jest.spyOn( - NodeHttpClient.prototype as any, - 'shouldRetryRequest', - ); - const mockSleep = jest.spyOn(nodeClient, 'sleep'); - mockSleep.mockImplementation(() => Promise.resolve()); - - const response = await nodeClient.get('/fga/v1/resources', {}); - - expect(mockShouldRetryRequest).toHaveBeenCalledTimes(1); - expect(mockSleep).toHaveBeenCalledTimes(1); - expect(await response.toJSON()).toEqual({ data: 'response' }); - }); -}); diff --git a/src/common/net/node-client.ts b/src/common/net/node-client.ts deleted file mode 100644 index 1635ca9d0..000000000 --- a/src/common/net/node-client.ts +++ /dev/null @@ -1,364 +0,0 @@ -import { HttpClient, HttpClientError, HttpClientResponse } from './http-client'; -import { - HttpClientInterface, - HttpClientResponseInterface, - RequestHeaders, - RequestOptions, -} from '../interfaces/http-client.interface'; - -import { - RequestOptions as HttpRequestOptions, - Agent as HttpAgent, -} from 'node:http'; -import { Agent as HttpsAgent } from 'node:https'; - -import * as http_ from 'node:http'; -import * as https_ from 'node:https'; - -// `import * as http_ from 'http'` creates a "Module Namespace Exotic Object" -// which is immune to monkey-patching, whereas http_.default (in an ES Module context) -// will resolve to the same thing as require('http'), which is -// monkey-patchable. We care about this because users in their test -// suites might be using a library like "nock" which relies on the ability -// to monkey-patch and intercept calls to http.request. -const http = (http_ as unknown as { default: typeof http_ }).default || http_; -const https = - (https_ as unknown as { default: typeof https_ }).default || https_; - -export class NodeHttpClient extends HttpClient implements HttpClientInterface { - private httpAgent: HttpAgent; - private httpsAgent: HttpsAgent; - - constructor(readonly baseURL: string, readonly options?: RequestInit) { - super(baseURL, options); - - this.httpAgent = new http.Agent({ keepAlive: true }); - this.httpsAgent = new https.Agent({ keepAlive: true }); - } - - getClientName(): string { - return 'node'; - } - - static override getBody(entity: unknown): string | null { - if (entity === null || entity === undefined) { - return null; - } - - if (entity instanceof URLSearchParams) { - return entity.toString(); - } - - return JSON.stringify(entity); - } - - async get( - path: string, - options: RequestOptions, - ): Promise { - const resourceURL = HttpClient.getResourceURL( - this.baseURL, - path, - options.params, - ); - - if (path.startsWith('/fga/')) { - return await this.nodeRequestWithRetry( - resourceURL, - 'GET', - null, - options.headers, - ); - } else { - return await this.nodeRequest(resourceURL, 'GET', null, options.headers); - } - } - - async post( - path: string, - entity: Entity, - options: RequestOptions, - ): Promise { - const resourceURL = HttpClient.getResourceURL( - this.baseURL, - path, - options.params, - ); - - if (path.startsWith('/fga/')) { - return await this.nodeRequestWithRetry( - resourceURL, - 'POST', - NodeHttpClient.getBody(entity), - { - ...HttpClient.getContentTypeHeader(entity), - ...options.headers, - }, - ); - } else { - return await this.nodeRequest( - resourceURL, - 'POST', - NodeHttpClient.getBody(entity), - { - ...HttpClient.getContentTypeHeader(entity), - ...options.headers, - }, - ); - } - } - - async put( - path: string, - entity: Entity, - options: RequestOptions, - ): Promise { - const resourceURL = HttpClient.getResourceURL( - this.baseURL, - path, - options.params, - ); - - if (path.startsWith('/fga/')) { - return await this.nodeRequestWithRetry( - resourceURL, - 'PUT', - NodeHttpClient.getBody(entity), - { - ...HttpClient.getContentTypeHeader(entity), - ...options.headers, - }, - ); - } else { - return await this.nodeRequest( - resourceURL, - 'PUT', - NodeHttpClient.getBody(entity), - { - ...HttpClient.getContentTypeHeader(entity), - ...options.headers, - }, - ); - } - } - - async delete( - path: string, - options: RequestOptions, - ): Promise { - const resourceURL = HttpClient.getResourceURL( - this.baseURL, - path, - options.params, - ); - - if (path.startsWith('/fga/')) { - return await this.nodeRequestWithRetry( - resourceURL, - 'DELETE', - null, - options.headers, - ); - } else { - return await this.nodeRequest( - resourceURL, - 'DELETE', - null, - options.headers, - ); - } - } - - private async nodeRequest( - url: string, - method: string, - body: string | null, - headers?: RequestHeaders, - ): Promise { - return new Promise((resolve, reject) => { - const isSecureConnection = url.startsWith('https'); - const agent = isSecureConnection ? this.httpsAgent : this.httpAgent; - const lib = isSecureConnection ? https : http; - - const { 'User-Agent': userAgent } = this.options - ?.headers as RequestHeaders; - - const options: HttpRequestOptions = { - method, - headers: { - Accept: 'application/json, text/plain, */*', - 'Content-Type': 'application/json', - ...(this.options?.headers as http_.OutgoingHttpHeaders), - ...headers, - 'User-Agent': this.addClientToUserAgent(userAgent.toString()), - }, - agent, - }; - - const req = lib.request(url, options, async (res) => { - const clientResponse = new NodeHttpClientResponse(res); - - if (res.statusCode && (res.statusCode < 200 || res.statusCode > 299)) { - reject( - new HttpClientError({ - message: res.statusMessage as string, - response: { - status: res.statusCode, - headers: res.headers, - data: await clientResponse.toJSON(), - }, - }), - ); - } - - resolve(clientResponse); - }); - - req.on('error', (err) => { - reject(new Error(err.message)); - }); - - if (body) { - req.setHeader('Content-Length', Buffer.byteLength(body)); - req.write(body); - } - req.end(); - }); - } - - private async nodeRequestWithRetry( - url: string, - method: string, - body: string | null, - headers?: RequestHeaders, - ): Promise { - const isSecureConnection = url.startsWith('https'); - const agent = isSecureConnection ? this.httpsAgent : this.httpAgent; - const lib = isSecureConnection ? https : http; - - const { 'User-Agent': userAgent } = this.options?.headers as RequestHeaders; - - const options: HttpRequestOptions = { - method, - headers: { - Accept: 'application/json, text/plain, */*', - 'Content-Type': 'application/json', - ...(this.options?.headers as http_.OutgoingHttpHeaders), - ...headers, - 'User-Agent': this.addClientToUserAgent(userAgent.toString()), - }, - agent, - }; - - let retryAttempts = 1; - - const makeRequest = async (): Promise => - new Promise((resolve, reject) => { - const req = lib.request(url, options, async (res) => { - const clientResponse = new NodeHttpClientResponse(res); - - if (this.shouldRetryRequest(res, retryAttempts)) { - retryAttempts++; - - await this.sleep(retryAttempts); - - return makeRequest().then(resolve).catch(reject); - } - - if ( - res.statusCode && - (res.statusCode < 200 || res.statusCode > 299) - ) { - reject( - new HttpClientError({ - message: res.statusMessage as string, - response: { - status: res.statusCode, - headers: res.headers, - data: await clientResponse.toJSON(), - }, - }), - ); - } - - resolve(new NodeHttpClientResponse(res)); - }); - - req.on('error', async (err) => { - if (err != null && err instanceof TypeError) { - retryAttempts++; - await this.sleep(retryAttempts); - return makeRequest().then(resolve).catch(reject); - } - - reject(new Error(err.message)); - }); - - if (body) { - req.setHeader('Content-Length', Buffer.byteLength(body)); - req.write(body); - } - req.end(); - }); - - return makeRequest(); - } - - private shouldRetryRequest(response: any, retryAttempt: number): boolean { - if (retryAttempt > this.MAX_RETRY_ATTEMPTS) { - return false; - } - - if ( - response != null && - this.RETRY_STATUS_CODES.includes(response.statusCode) - ) { - return true; - } - - return false; - } -} - -// tslint:disable-next-line -export class NodeHttpClientResponse - extends HttpClientResponse - implements HttpClientResponseInterface -{ - _res: http_.IncomingMessage; - - constructor(res: http_.IncomingMessage) { - // @ts-ignore - super(res.statusCode, res.headers || {}); - this._res = res; - } - - getRawResponse(): http_.IncomingMessage { - return this._res; - } - - toJSON(): Promise | any { - return new Promise((resolve, reject) => { - const contentType = this._res.headers['content-type']; - const isJsonResponse = contentType?.includes('application/json'); - - if (!isJsonResponse) { - resolve(null); - } - - let response = ''; - - this._res.setEncoding('utf8'); - this._res.on('data', (chunk) => { - response += chunk; - }); - this._res.once('end', () => { - try { - resolve(JSON.parse(response)); - } catch (e) { - reject(e); - } - }); - }); - } -} diff --git a/src/common/serializers/event.serializer.ts b/src/common/serializers/event.serializer.ts index 450d9409c..3e411d106 100644 --- a/src/common/serializers/event.serializer.ts +++ b/src/common/serializers/event.serializer.ts @@ -53,7 +53,6 @@ export const deserializeEvent = (event: EventResponse): Event => { data: deserializeConnection(event.data), }; case 'dsync.activated': - case 'dsync.deactivated': return { ...eventBase, event: event.event, diff --git a/src/index.ts b/src/index.ts index 077208705..47b56ee1a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,6 @@ import { CryptoProvider } from './common/crypto/crypto-provider'; import { HttpClient } from './common/net/http-client'; import { FetchHttpClient } from './common/net/fetch-client'; -import { NodeHttpClient } from './common/net/node-client'; import { Actions } from './actions/actions'; import { Webhooks } from './webhooks/webhooks'; @@ -48,7 +47,7 @@ class WorkOSNode extends WorkOS { ) { return new FetchHttpClient(this.baseURL, opts, options.fetchFn); } else { - return new NodeHttpClient(this.baseURL, opts); + throw new Error('Please upgrade your Node.js version to 18 or higher'); } } diff --git a/src/worker.spec.ts b/src/worker.spec.ts index d986c9693..c96126f4d 100644 --- a/src/worker.spec.ts +++ b/src/worker.spec.ts @@ -1,9 +1,32 @@ /** * @jest-environment miniflare */ - +import { SubtleCryptoProvider } from './common/crypto/subtle-crypto-provider'; +import { FetchHttpClient } from './common/net'; import { WorkOS } from './index.worker'; -test('WorkOS is initialized without errors', () => { - expect(() => new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU')).not.toThrow(); +describe('WorkOS in Worker environment', () => { + let workos: WorkOS; + + beforeEach(() => { + workos = new WorkOS('sk_test_key'); + }); + + test('initializes without errors', () => { + expect(workos).toBeInstanceOf(WorkOS); + }); + + test('uses FetchHttpClient', () => { + // @ts-ignore - accessing private property for testing + expect(workos['client']).toBeInstanceOf(FetchHttpClient); + }); + + test('uses SubtleCryptoProvider', () => { + // @ts-ignore - accessing private property for testing + expect( + workos.webhooks['signatureProvider']['cryptoProvider'], + ).toBeInstanceOf(SubtleCryptoProvider); + }); + + // Add more tests for core API functionality }); diff --git a/src/workos.spec.ts b/src/workos.spec.ts index bd15d3bfc..ab42eb9b4 100644 --- a/src/workos.spec.ts +++ b/src/workos.spec.ts @@ -11,7 +11,6 @@ import { WorkOS } from './index'; import { WorkOS as WorkOSWorker } from './index.worker'; import { RateLimitExceededException } from './common/exceptions/rate-limit-exceeded.exception'; import { FetchHttpClient } from './common/net/fetch-client'; -import { NodeHttpClient } from './common/net/node-client'; import { SubtleCryptoProvider } from './common/crypto/subtle-crypto-provider'; describe('WorkOS', () => { @@ -322,11 +321,10 @@ describe('WorkOS', () => { globalThis.fetch = fetchFn; }); - it('automatically uses the node HTTP client', () => { - const workos = new WorkOS('sk_test_key'); - - // tslint:disable-next-line - expect(workos['client']).toBeInstanceOf(NodeHttpClient); + it('throws an error', () => { + expect(() => new WorkOS('sk_test_key')).toThrowError( + 'Please upgrade your Node.js version to 18 or higher', + ); }); it('uses a fetch function if provided', () => {