Skip to content

Commit

Permalink
Support Cloudflare workers/Edge runtime environments (#962)
Browse files Browse the repository at this point in the history
## Description

This PR aims to make the node SDK (will need to be renamed to JS SDK
maybe?) compatible with Cloudflare workers / Edge runtime environments.

This is achieved by:
- Removing `axios` and using native `fetch` instead
- Removing usage of Node's `crypto` module in favour for native web
`crypto`

Here are the important changes:
- Swapping out axios's client for [our own custom fetch-based
client](https://github.com/workos/workos-node/pull/962/files#diff-32c7ee5e94c4dc53afedbcdb133765b9ebfeee5922e4b6a1fefd0987ee0d86d8)
in [the main `WorkOS`
class](https://github.com/workos/workos-node/pull/962/files#diff-43fae42284da1898f98b43b56a7bc6f5fb979e271bca59f4670751ba2c0020ef)
- Rewrite the webhooks APIs using web `crypto` in [the `Webhooks`
class](https://github.com/workos/workos-node/pull/962/files#diff-8b772e2b2baa9169ed0a1e9039df9b4b4b353690593830c843dff21f3dc525f7)

Most of the rest of the changes are rewriting the tests to ensure they
all pass because of the changes.

## Documentation

Does this require changes to the WorkOS Docs? E.g. the [API
Reference](https://workos.com/docs/reference) or code snippets need
updates.

```
[x] Yes
```

If yes, link a related docs PR and add a docs maintainer as a reviewer.
Their approval is required.

> [!WARNING]  
> TODO: Add Docs PR
  • Loading branch information
benoitgrelard authored Feb 13, 2024
1 parent 5260da3 commit c38dd7d
Show file tree
Hide file tree
Showing 26 changed files with 839 additions and 766 deletions.
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ module.exports = {
resetMocks: true,
restoreMocks: true,
verbose: true,
testEnvironment: "node",
testEnvironment: 'node',
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
roots: ['<rootDir>/src'],
setupFiles: ['./setup-jest.ts'],
transform: {
'^.+\\.ts?$': 'ts-jest',
},
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "5.2.0",
"version": "6.0.0",
"name": "@workos-inc/node",
"author": "WorkOS",
"description": "A Node wrapper for the WorkOS API",
Expand Down Expand Up @@ -34,15 +34,15 @@
"prepublishOnly": "yarn run build"
},
"dependencies": {
"axios": "~1.6.5",
"pluralize": "8.0.0"
},
"devDependencies": {
"@peculiar/webcrypto": "^1.4.5",
"@types/jest": "29.5.3",
"@types/node": "14.18.54",
"@types/pluralize": "0.0.30",
"axios-mock-adapter": "1.21.5",
"jest": "29.6.2",
"jest-fetch-mock": "^3.0.3",
"prettier": "2.8.8",
"supertest": "6.3.3",
"ts-jest": "29.1.1",
Expand Down
6 changes: 6 additions & 0 deletions setup-jest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { enableFetchMocks } from 'jest-fetch-mock';
import { Crypto } from '@peculiar/webcrypto';

enableFetchMocks();

global.crypto = new Crypto();
32 changes: 19 additions & 13 deletions src/audit-logs/audit-logs.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AxiosError } from 'axios';
import fetch from 'jest-fetch-mock';
import { UnauthorizedException } from '../common/exceptions';
import { BadRequestException } from '../common/exceptions/bad-request.exception';
import { mockWorkOsResponse } from '../common/utils/workos-mock-response';
Expand All @@ -13,6 +13,7 @@ import {
serializeAuditLogExportOptions,
serializeCreateAuditLogEventOptions,
} from './serializers';
import { FetchError } from '../common/utils/fetch-error';

const event: CreateAuditLogEventOptions = {
action: 'document.updated',
Expand All @@ -38,6 +39,8 @@ const event: CreateAuditLogEventOptions = {
};

describe('AuditLogs', () => {
beforeEach(() => fetch.resetMocks());

describe('createEvent', () => {
describe('with an idempotency key', () => {
it('includes an idempotency key with request', async () => {
Expand Down Expand Up @@ -96,10 +99,11 @@ describe('AuditLogs', () => {
const workosSpy = jest.spyOn(WorkOS.prototype, 'post');

workosSpy.mockImplementationOnce(() => {
throw new AxiosError(
'Could not authorize the request. Maybe your API key is invalid?',
'401',
);
throw new FetchError({
message:
'Could not authorize the request. Maybe your API key is invalid?',
response: { status: 401, headers: new Headers(), data: {} },
});
});

const workos = new WorkOS('invalid apikey');
Expand Down Expand Up @@ -247,10 +251,11 @@ describe('AuditLogs', () => {
};

workosSpy.mockImplementationOnce(() => {
throw new AxiosError(
'Could not authorize the request. Maybe your API key is invalid?',
'401',
);
throw new FetchError({
message:
'Could not authorize the request. Maybe your API key is invalid?',
response: { status: 401, headers: new Headers(), data: {} },
});
});

const workos = new WorkOS('invalid apikey');
Expand Down Expand Up @@ -306,10 +311,11 @@ describe('AuditLogs', () => {
const workosSpy = jest.spyOn(WorkOS.prototype, 'get');

workosSpy.mockImplementationOnce(() => {
throw new AxiosError(
'Could not authorize the request. Maybe your API key is invalid?',
'401',
);
throw new FetchError({
message:
'Could not authorize the request. Maybe your API key is invalid?',
response: { status: 401, headers: new Headers(), data: {} },
});
});

const workos = new WorkOS('invalid apikey');
Expand Down
4 changes: 1 addition & 3 deletions src/common/interfaces/workos-options.interface.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { AxiosRequestConfig } from 'axios';

export interface WorkOSOptions {
apiHostname?: string;
https?: boolean;
port?: number;
axios?: Omit<AxiosRequestConfig, 'baseURL'>;
config?: RequestInit;
}
107 changes: 107 additions & 0 deletions src/common/utils/fetch-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { FetchError } from './fetch-error';

export class FetchClient {
constructor(readonly baseURL: string, readonly options?: RequestInit) {}

async get(
path: string,
options: { params?: Record<string, any>; headers?: HeadersInit },
) {
const resourceURL = this.getResourceURL(path, options.params);
const response = await this.fetch(resourceURL, {
headers: options.headers,
});
return { data: await response.json() };
}

async post<Entity = any>(
path: string,
entity: Entity,
options: { params?: Record<string, any>; headers?: HeadersInit },
) {
const resourceURL = this.getResourceURL(path, options.params);
const bodyIsSearchParams = entity instanceof URLSearchParams;
const contentTypeHeader = bodyIsSearchParams
? { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' }
: undefined;
const body = bodyIsSearchParams ? entity : JSON.stringify(entity);
const response = await this.fetch(resourceURL, {
method: 'POST',
headers: { ...contentTypeHeader, ...options.headers },
body,
});
return { data: await response.json() };
}

async put<Entity = any>(
path: string,
entity: Entity,
options: { params?: Record<string, any>; headers?: HeadersInit },
) {
const resourceURL = this.getResourceURL(path, options.params);
const response = await this.fetch(resourceURL, {
method: 'PUT',
headers: options.headers,
body: JSON.stringify(entity),
});
return { data: await response.json() };
}

async delete(
path: string,
options: { params?: Record<string, any>; headers?: HeadersInit },
) {
const resourceURL = this.getResourceURL(path, options.params);
await this.fetch(resourceURL, {
method: 'DELETE',
headers: options.headers,
});
}

private getResourceURL(path: string, params?: Record<string, any>) {
const queryString = getQueryString(params);
const url = new URL(
[path, queryString].filter(Boolean).join('?'),
this.baseURL,
);
return url.toString();
}

private async fetch(url: string, options?: RequestInit) {
const response = await fetch(url, {
...this.options,
...options,
headers: {
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json',
...this.options?.headers,
...options?.headers,
},
});

if (!response.ok) {
throw new FetchError({
message: response.statusText,
response: {
status: response.status,
headers: response.headers,
data: await response.json(),
},
});
}

return response;
}
}

function getQueryString(queryObj?: Record<string, any>) {
if (!queryObj) return undefined;

const sanitizedQueryObj: Record<string, any> = {};

Object.entries(queryObj).forEach(([param, value]) => {
if (value !== '' && value !== undefined) sanitizedQueryObj[param] = value;
});

return new URLSearchParams(sanitizedQueryObj).toString();
}
17 changes: 17 additions & 0 deletions src/common/utils/fetch-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export class FetchError<T> extends Error {
readonly name: string = 'FetchError';
readonly message: string = 'The request could not be completed.';
readonly response: { status: number; headers: Headers; data: T };

constructor({
message,
response,
}: {
message: string;
readonly response: FetchError<T>['response'];
}) {
super(message);
this.message = message;
this.response = response;
}
}
28 changes: 28 additions & 0 deletions src/common/utils/test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import fetch, { MockParams } from 'jest-fetch-mock';

export function fetchOnce(
response: any = {},
{ status = 200, ...rest }: MockParams = {},
) {
return fetch.once(JSON.stringify(response), { status, ...rest });
}

export function fetchURL() {
return fetch.mock.calls[0][0];
}

export function fetchSearchParams() {
return Object.fromEntries(new URL(String(fetchURL())).searchParams);
}

export function fetchHeaders() {
return fetch.mock.calls[0][1]?.headers;
}

export function fetchBody() {
const body = fetch.mock.calls[0][1]?.body;
if (body instanceof URLSearchParams) {
return body.toString();
}
return JSON.parse(String(body));
}
Loading

0 comments on commit c38dd7d

Please sign in to comment.