Skip to content

Commit

Permalink
feat: support edge runtimes (#44)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
connorlindsey authored Jan 17, 2024
1 parent 9060680 commit 2978aff
Show file tree
Hide file tree
Showing 7 changed files with 1,232 additions and 1,151 deletions.
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## v0.6.0

### Major Changes

- Add Vercel Edge runtime compatibility

### Breaking Changes

- `Knock.signUserToken` is now asynchronous and returns `Promise<string>` instead of `string`

## v0.4.18

* Introduce "Idempotency-Key" header for workflow triggers
- Introduce "Idempotency-Key" header for workflow triggers
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Knock Node.js library

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

## Documentation

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

// jhammond is the user id for which to sign this token
const token = Knock.signUserToken("jhammond", {
const token = await Knock.signUserToken("jhammond", {
// The signing key from the Knock Dashboard in base-64 or PEM-encoded format.
// If not provided, the key will be read from the KNOCK_SIGNING_KEY environment variable.
signingKey: "S25vY2sga25vY2sh...",
Expand Down
11 changes: 4 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knocklabs/node",
"version": "0.5.1",
"version": "0.6.0",
"description": "Library for interacting with the Knock API",
"homepage": "https://github.com/knocklabs/knock-node",
"author": "@knocklabs",
Expand Down Expand Up @@ -32,19 +32,16 @@
},
"devDependencies": {
"@types/jest": "26.0.23",
"@types/jsonwebtoken": "^9.0.1",
"@types/node": "^15.0.1",
"@types/pluralize": "0.0.29",
"axios-mock-adapter": "^1.22.0",
"jest": "26.6.3",
"prettier": "2.2.1",
"supertest": "6.1.3",
"ts-jest": "26.5.5",
"tslint": "6.1.3",
"typescript": "4.2.4"
"typescript": "^5.3.3"
},
"dependencies": {
"axios": "1.6",
"jsonwebtoken": "^9.0.0"
"jose": "^5.2.0"
}
}
}
126 changes: 126 additions & 0 deletions src/common/fetchClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
export interface FetchClientConfig {
baseURL?: string;
headers?: Record<string, string>;
}

export interface FetchRequestConfig<D = any> {
params?: Record<string, string>;
headers?: Record<string, string>;
body?: D;
}

export interface FetchResponse<T = any> extends Response {
data: T;
}

export class FetchResponseError extends Error {
readonly response: FetchResponse;

constructor(response: FetchResponse) {
super();
this.response = response;
}
}

const defaultConfig: FetchClientConfig = {
baseURL: "",
headers: {},
};

export default class FetchClient {
config: FetchClientConfig;

constructor(config?: FetchClientConfig) {
this.config = {
...defaultConfig,
...config,
};
}

async get(path: string, config: FetchRequestConfig): Promise<FetchResponse> {
return this.request("GET", path, config);
}

async post(path: string, config: FetchRequestConfig): Promise<FetchResponse> {
return this.request("POST", path, config);
}

async put(path: string, config: FetchRequestConfig): Promise<FetchResponse> {
return this.request("PUT", path, config);
}

async delete(
path: string,
config: FetchRequestConfig,
): Promise<FetchResponse> {
return this.request("DELETE", path, config);
}

private async request(
method: string,
path: string,
config: FetchRequestConfig = {},
): Promise<FetchResponse> {
const url = this.buildUrl(path, config.params);
const headers = {
...this.config.headers,
...(config.headers ?? {}),
};

const response = await fetch(url, {
method,
headers,
body: config.body ? this.prepareRequestBody(config.body) : undefined,
});
const data = await this.getResponseData(response);

// Assign data to the response as other methods of returning the response
// like return { ...response, data } drop the response methods
const fetchResponse: FetchResponse = Object.assign(response, {
data,
});

if (!response.ok) {
throw new FetchResponseError(fetchResponse);
}

return fetchResponse;
}

private buildUrl(path: string, params?: FetchRequestConfig["params"]): URL {
const url = new URL(this.config.baseURL + path);

if (params) {
Object.entries(params).forEach(([key, value]) =>
url.searchParams.append(key, value),
);
}

return url;
}

private prepareRequestBody(data: any): string | FormData {
if (typeof data === "string" || data instanceof FormData) {
return data;
}
return JSON.stringify(data);
}

private async getResponseData(response: Response) {
if (!response.body) {
return undefined;
}

let data;
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
data = await response.json();
} else if (contentType && contentType.includes("text")) {
data = await response.text();
} else {
data = await response.blob();
}

return data;
}
}
57 changes: 31 additions & 26 deletions src/knock.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import axios, { AxiosResponse, AxiosInstance } from "axios";
import jwt from "jsonwebtoken";
import { SignJWT, importPKCS8 } from "jose";

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

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

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

class Knock {
readonly host: string;
private readonly client: AxiosInstance;
private readonly client: FetchClient;

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

this.host = options.host || DEFAULT_HOSTNAME;

this.client = axios.create({
this.client = new FetchClient({
baseURL: this.host,
headers: {
Authorization: `Bearer ${this.key}`,
Expand All @@ -75,9 +75,9 @@ class Knock {
*
* @param userId {string} The ID of the user that needs a token, e.g. the user viewing an in-app feed.
* @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
* @returns {string} A JWT token that can be used to authenticate requests to the Knock API (e.g. by passing into the <KnockFeedProvider /> component)
* @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)
*/
static signUserToken(userId: string, options?: SignUserTokenOptions) {
static async signUserToken(userId: string, options?: SignUserTokenOptions) {
const signingKey = prepareSigningKey(options?.signingKey);

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

return jwt.sign(
{
sub: userId,
iat: currentTime,
exp: currentTime + expireInSeconds,
},
signingKey,
{
algorithm: "RS256",
},
);
// Convert string key to a Crypto-API compatible KeyLike
const keyLike = await importPKCS8(signingKey, "RS256");

return await new SignJWT({
sub: userId,
iat: currentTime,
exp: currentTime + expireInSeconds,
})
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.sign(keyLike);
}

async post(
path: string,
entity: any,
options: PostAndPutOptions = {},
): Promise<AxiosResponse> {
): Promise<FetchResponse> {
try {
return await this.client.post(path, entity, {
return await this.client.post(path, {
params: options.query,
headers: options.headers,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...options.headers,
},
body: entity,
});
} catch (error) {
this.handleErrorResponse(path, error);
Expand All @@ -119,18 +123,19 @@ class Knock {
path: string,
entity: any,
options: PostAndPutOptions = {},
): Promise<AxiosResponse> {
): Promise<FetchResponse> {
try {
return await this.client.put(path, entity, {
return await this.client.put(path, {
params: options.query,
body: entity,
});
} catch (error) {
this.handleErrorResponse(path, error);
throw error;
}
}

async delete(path: string, entity: any = {}): Promise<AxiosResponse> {
async delete(path: string, entity: any = {}): Promise<FetchResponse> {
try {
return await this.client.delete(path, {
params: entity,
Expand All @@ -141,7 +146,7 @@ class Knock {
}
}

async get(path: string, query?: any): Promise<AxiosResponse> {
async get(path: string, query?: any): Promise<FetchResponse> {
try {
return await this.client.get(path, {
params: query,
Expand All @@ -153,9 +158,9 @@ class Knock {
}

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

switch (status) {
case 401: {
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"typeRoots": ["./node_modules/@types"],
"declaration": true,
"outDir": "./dist",
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["src/**/*.spec.ts", "dist"]
}
}
Loading

0 comments on commit 2978aff

Please sign in to comment.