Skip to content

Commit 2978aff

Browse files
feat: support edge runtimes (#44)
* feat: support edge runtimes * chore: replace jsonwebtoken with edge-compatible jose package * fix: throw on non-2xx responses and improve data preparation * fix: update signuserToken return type * fix: jwt signing by importing token as a KeyLike * update example in readme * chore: prepare for 0.6.0 release
1 parent 9060680 commit 2978aff

File tree

7 files changed

+1232
-1151
lines changed

7 files changed

+1232
-1151
lines changed

CHANGELOG.md

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
## v0.6.0
2+
3+
### Major Changes
4+
5+
- Add Vercel Edge runtime compatibility
6+
7+
### Breaking Changes
8+
9+
- `Knock.signUserToken` is now asynchronous and returns `Promise<string>` instead of `string`
10+
111
## v0.4.18
212

3-
* Introduce "Idempotency-Key" header for workflow triggers
13+
- Introduce "Idempotency-Key" header for workflow triggers

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Knock Node.js library
22

3-
Knock API access for applications written in server-side Javascript.
3+
Knock API access for applications written in server-side Javascript. This package is compatible with the Vercel Edge runtime.
44

55
## Documentation
66

@@ -148,7 +148,7 @@ const { Knock } = require("@knocklabs/node");
148148
// When signing user tokens, you do not need to instantiate a Knock client.
149149

150150
// jhammond is the user id for which to sign this token
151-
const token = Knock.signUserToken("jhammond", {
151+
const token = await Knock.signUserToken("jhammond", {
152152
// The signing key from the Knock Dashboard in base-64 or PEM-encoded format.
153153
// If not provided, the key will be read from the KNOCK_SIGNING_KEY environment variable.
154154
signingKey: "S25vY2sga25vY2sh...",

package.json

+4-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knocklabs/node",
3-
"version": "0.5.1",
3+
"version": "0.6.0",
44
"description": "Library for interacting with the Knock API",
55
"homepage": "https://github.com/knocklabs/knock-node",
66
"author": "@knocklabs",
@@ -32,19 +32,16 @@
3232
},
3333
"devDependencies": {
3434
"@types/jest": "26.0.23",
35-
"@types/jsonwebtoken": "^9.0.1",
3635
"@types/node": "^15.0.1",
3736
"@types/pluralize": "0.0.29",
38-
"axios-mock-adapter": "^1.22.0",
3937
"jest": "26.6.3",
4038
"prettier": "2.2.1",
4139
"supertest": "6.1.3",
4240
"ts-jest": "26.5.5",
4341
"tslint": "6.1.3",
44-
"typescript": "4.2.4"
42+
"typescript": "^5.3.3"
4543
},
4644
"dependencies": {
47-
"axios": "1.6",
48-
"jsonwebtoken": "^9.0.0"
45+
"jose": "^5.2.0"
4946
}
50-
}
47+
}

src/common/fetchClient.ts

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
export interface FetchClientConfig {
2+
baseURL?: string;
3+
headers?: Record<string, string>;
4+
}
5+
6+
export interface FetchRequestConfig<D = any> {
7+
params?: Record<string, string>;
8+
headers?: Record<string, string>;
9+
body?: D;
10+
}
11+
12+
export interface FetchResponse<T = any> extends Response {
13+
data: T;
14+
}
15+
16+
export class FetchResponseError extends Error {
17+
readonly response: FetchResponse;
18+
19+
constructor(response: FetchResponse) {
20+
super();
21+
this.response = response;
22+
}
23+
}
24+
25+
const defaultConfig: FetchClientConfig = {
26+
baseURL: "",
27+
headers: {},
28+
};
29+
30+
export default class FetchClient {
31+
config: FetchClientConfig;
32+
33+
constructor(config?: FetchClientConfig) {
34+
this.config = {
35+
...defaultConfig,
36+
...config,
37+
};
38+
}
39+
40+
async get(path: string, config: FetchRequestConfig): Promise<FetchResponse> {
41+
return this.request("GET", path, config);
42+
}
43+
44+
async post(path: string, config: FetchRequestConfig): Promise<FetchResponse> {
45+
return this.request("POST", path, config);
46+
}
47+
48+
async put(path: string, config: FetchRequestConfig): Promise<FetchResponse> {
49+
return this.request("PUT", path, config);
50+
}
51+
52+
async delete(
53+
path: string,
54+
config: FetchRequestConfig,
55+
): Promise<FetchResponse> {
56+
return this.request("DELETE", path, config);
57+
}
58+
59+
private async request(
60+
method: string,
61+
path: string,
62+
config: FetchRequestConfig = {},
63+
): Promise<FetchResponse> {
64+
const url = this.buildUrl(path, config.params);
65+
const headers = {
66+
...this.config.headers,
67+
...(config.headers ?? {}),
68+
};
69+
70+
const response = await fetch(url, {
71+
method,
72+
headers,
73+
body: config.body ? this.prepareRequestBody(config.body) : undefined,
74+
});
75+
const data = await this.getResponseData(response);
76+
77+
// Assign data to the response as other methods of returning the response
78+
// like return { ...response, data } drop the response methods
79+
const fetchResponse: FetchResponse = Object.assign(response, {
80+
data,
81+
});
82+
83+
if (!response.ok) {
84+
throw new FetchResponseError(fetchResponse);
85+
}
86+
87+
return fetchResponse;
88+
}
89+
90+
private buildUrl(path: string, params?: FetchRequestConfig["params"]): URL {
91+
const url = new URL(this.config.baseURL + path);
92+
93+
if (params) {
94+
Object.entries(params).forEach(([key, value]) =>
95+
url.searchParams.append(key, value),
96+
);
97+
}
98+
99+
return url;
100+
}
101+
102+
private prepareRequestBody(data: any): string | FormData {
103+
if (typeof data === "string" || data instanceof FormData) {
104+
return data;
105+
}
106+
return JSON.stringify(data);
107+
}
108+
109+
private async getResponseData(response: Response) {
110+
if (!response.body) {
111+
return undefined;
112+
}
113+
114+
let data;
115+
const contentType = response.headers.get("content-type");
116+
if (contentType && contentType.includes("application/json")) {
117+
data = await response.json();
118+
} else if (contentType && contentType.includes("text")) {
119+
data = await response.text();
120+
} else {
121+
data = await response.blob();
122+
}
123+
124+
return data;
125+
}
126+
}

src/knock.ts

+31-26
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import axios, { AxiosResponse, AxiosInstance } from "axios";
2-
import jwt from "jsonwebtoken";
1+
import { SignJWT, importPKCS8 } from "jose";
32

43
import { version } from "../package.json";
54

@@ -25,12 +24,13 @@ import { BulkOperations } from "./resources/bulk_operations";
2524
import { Objects } from "./resources/objects";
2625
import { Messages } from "./resources/messages";
2726
import { Tenants } from "./resources/tenants";
27+
import FetchClient, { FetchResponse } from "./common/fetchClient";
2828

2929
const DEFAULT_HOSTNAME = "https://api.knock.app";
3030

3131
class Knock {
3232
readonly host: string;
33-
private readonly client: AxiosInstance;
33+
private readonly client: FetchClient;
3434

3535
// Service accessors
3636
readonly users = new Users(this);
@@ -51,7 +51,7 @@ class Knock {
5151

5252
this.host = options.host || DEFAULT_HOSTNAME;
5353

54-
this.client = axios.create({
54+
this.client = new FetchClient({
5555
baseURL: this.host,
5656
headers: {
5757
Authorization: `Bearer ${this.key}`,
@@ -75,9 +75,9 @@ class Knock {
7575
*
7676
* @param userId {string} The ID of the user that needs a token, e.g. the user viewing an in-app feed.
7777
* @param options Optionally specify the signing key to use (in PEM or base-64 encoded format), and how long the token should be valid for in seconds
78-
* @returns {string} A JWT token that can be used to authenticate requests to the Knock API (e.g. by passing into the <KnockFeedProvider /> component)
78+
* @returns {Promise<string>} A JWT token that can be used to authenticate requests to the Knock API (e.g. by passing into the <KnockFeedProvider /> component)
7979
*/
80-
static signUserToken(userId: string, options?: SignUserTokenOptions) {
80+
static async signUserToken(userId: string, options?: SignUserTokenOptions) {
8181
const signingKey = prepareSigningKey(options?.signingKey);
8282

8383
// JWT NumericDates specified in seconds:
@@ -86,28 +86,32 @@ class Knock {
8686
// Default to 1 hour from now
8787
const expireInSeconds = options?.expiresInSeconds ?? 60 * 60;
8888

89-
return jwt.sign(
90-
{
91-
sub: userId,
92-
iat: currentTime,
93-
exp: currentTime + expireInSeconds,
94-
},
95-
signingKey,
96-
{
97-
algorithm: "RS256",
98-
},
99-
);
89+
// Convert string key to a Crypto-API compatible KeyLike
90+
const keyLike = await importPKCS8(signingKey, "RS256");
91+
92+
return await new SignJWT({
93+
sub: userId,
94+
iat: currentTime,
95+
exp: currentTime + expireInSeconds,
96+
})
97+
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
98+
.sign(keyLike);
10099
}
101100

102101
async post(
103102
path: string,
104103
entity: any,
105104
options: PostAndPutOptions = {},
106-
): Promise<AxiosResponse> {
105+
): Promise<FetchResponse> {
107106
try {
108-
return await this.client.post(path, entity, {
107+
return await this.client.post(path, {
109108
params: options.query,
110-
headers: options.headers,
109+
headers: {
110+
"Content-Type": "application/json",
111+
Accept: "application/json",
112+
...options.headers,
113+
},
114+
body: entity,
111115
});
112116
} catch (error) {
113117
this.handleErrorResponse(path, error);
@@ -119,18 +123,19 @@ class Knock {
119123
path: string,
120124
entity: any,
121125
options: PostAndPutOptions = {},
122-
): Promise<AxiosResponse> {
126+
): Promise<FetchResponse> {
123127
try {
124-
return await this.client.put(path, entity, {
128+
return await this.client.put(path, {
125129
params: options.query,
130+
body: entity,
126131
});
127132
} catch (error) {
128133
this.handleErrorResponse(path, error);
129134
throw error;
130135
}
131136
}
132137

133-
async delete(path: string, entity: any = {}): Promise<AxiosResponse> {
138+
async delete(path: string, entity: any = {}): Promise<FetchResponse> {
134139
try {
135140
return await this.client.delete(path, {
136141
params: entity,
@@ -141,7 +146,7 @@ class Knock {
141146
}
142147
}
143148

144-
async get(path: string, query?: any): Promise<AxiosResponse> {
149+
async get(path: string, query?: any): Promise<FetchResponse> {
145150
try {
146151
return await this.client.get(path, {
147152
params: query,
@@ -153,9 +158,9 @@ class Knock {
153158
}
154159

155160
handleErrorResponse(path: string, error: any) {
156-
if (axios.isAxiosError(error) && error.response) {
161+
if (error.response) {
157162
const { status, data, headers } = error.response;
158-
const requestID = headers["X-Request-ID"];
163+
const requestID = headers.get("X-Request-ID");
159164

160165
switch (status) {
161166
case 401: {

tsconfig.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"typeRoots": ["./node_modules/@types"],
1717
"declaration": true,
1818
"outDir": "./dist",
19+
"skipLibCheck": true
1920
},
2021
"include": ["src/**/*"],
2122
"exclude": ["src/**/*.spec.ts", "dist"]
22-
}
23+
}

0 commit comments

Comments
 (0)