Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 60 additions & 10 deletions packages/sdk/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"access": "public"
},
"scripts": {
"lint": "eslint --fix --ext .ts src",
"prepublish": "rm -rf dist && npm run build",
"build": "npm run build:node && npm run build:browser",
"build:node": "tsc -p tsconfig.node.json",
Expand All @@ -38,15 +39,19 @@
"devDependencies": {
"@types/fetch-mock": "^7.3.8",
"@types/jest": "^29.5.13",
"@types/node": "^20.14.9",
"@types/node": "^20.17.6",
"@types/rails__actioncable": "^6.1.11",
"@types/simple-oauth2": "^5.0.7",
"@types/ws": "^8.5.13",
"jest": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"nodemon": "^3.1.7",
"ts-jest": "^29.2.5",
"typescript": "^5.5.2"
},
"dependencies": {
"simple-oauth2": "^5.1.0"
"@rails/actioncable": "^8.0.0",
"simple-oauth2": "^5.1.0",
"ws": "^8.18.0"
}
}
24 changes: 24 additions & 0 deletions packages/sdk/src/browser/async.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { AsyncResponseManager } from "../shared/async"
import type { AsyncResponseManagerOpts } from "../shared/async"
import { createConsumer } from "@rails/actioncable"
import type { Consumer } from "@rails/actioncable"

export type BrowserAsyncResponseManagerOpts = AsyncResponseManagerOpts & {
getConnectToken: () => Promise<string>
}

export class BrowserAsyncResponseManager extends AsyncResponseManager {
private getConnectToken: BrowserAsyncResponseManagerOpts["getConnectToken"]

constructor(opts: BrowserAsyncResponseManagerOpts) {
const { getConnectToken, ..._opts } = opts
super(_opts)
this.getConnectToken = getConnectToken
}

protected override async createCable(): Promise<Consumer> {
const token = await this.getConnectToken()
const url = `wss://${this.apiHost}/websocket?ctok=${token}`
return createConsumer(url) as Consumer
}
}
131 changes: 110 additions & 21 deletions packages/sdk/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
// operations, like connecting accounts via Pipedream Connect. See the server/
// directory for the server client.

import { BrowserAsyncResponseManager } from "./async";
import { BaseClient, ConnectTokenResponse } from "../shared";

/**
* Options for creating a browser-side client. This is used to configure the
* BrowserClient instance.
Expand All @@ -20,8 +23,31 @@ type CreateBrowserClientOpts = {
* "pipedream.com" if not provided.
*/
frontendHost?: string;

/**
* The API host URL. Used by Pipedream employees. Defaults to
* "api.pipedream.com" if not provided.
*/
apiHost?: string;

/**
* Will be called whenever we need a new token.
*
* The callback function should return the response from
* `serverClient.createConnectToken`.
*/
tokenCallback?: TokenCallback;

/**
* An external user ID associated with the token.
*/
externalUserId?: string;
};

export type TokenCallback = (opts: {
externalUserId: string;
}) => Promise<ConnectTokenResponse>;

/**
* The name slug for an app, a unique, human-readable identifier like "github"
* or "google_sheets". Find this in the Authentication section for any app's
Expand Down Expand Up @@ -51,8 +77,10 @@ class ConnectError extends Error {}
type StartConnectOpts = {
/**
* The token used for authenticating the connection.
*
* Optional if client already initialized with token
*/
token: string;
token?: string;

/**
* The app to connect to, either as an ID or an object containing the ID.
Expand Down Expand Up @@ -98,22 +126,74 @@ export function createFrontendClient(opts: CreateBrowserClientOpts = {}) {
/**
* A client for interacting with the Pipedream Connect API from the browser.
*/
class BrowserClient {
private environment?: string;
export class BrowserClient extends BaseClient {
protected override asyncResponseManager: BrowserAsyncResponseManager;
private baseURL: string;
private iframeURL: string;
private iframe?: HTMLIFrameElement;
private iframeId = 0;
private tokenCallback?: TokenCallback;
private _token?: string;
private _tokenExpiresAt?: Date;
private _tokenRequest?: Promise<string>;
externalUserId?: string;

/**
* Constructs a new `BrowserClient` instance.
*
* @param opts - The options for configuring the browser client.
*/
constructor(opts: CreateBrowserClientOpts) {
this.environment = opts.environment;
super(opts);
this.baseURL = `https://${opts.frontendHost || "pipedream.com"}`;
this.iframeURL = `${this.baseURL}/_static/connect.html`;
this.tokenCallback = opts.tokenCallback;
this.externalUserId = opts.externalUserId;
this.asyncResponseManager = new BrowserAsyncResponseManager({
apiHost: this.apiHost,
getConnectToken: () => this.token(),
});
}

private async token() {
if (
this._token &&
this._tokenExpiresAt &&
this._tokenExpiresAt > new Date()
) {
return this._token;
}

if (this._tokenRequest) {
return this._tokenRequest;
}

const tokenCallback = this.tokenCallback;
const externalUserId = this.externalUserId;

if (!tokenCallback) {
throw new Error("No token callback provided");
}
if (!externalUserId) {
throw new Error("No external user ID provided");
}

// Ensure only one token request is in-flight at a time.
this._tokenRequest = (async () => {
const { token, expires_at } = await tokenCallback({
externalUserId: externalUserId,
});
this._token = token;
this._tokenExpiresAt = new Date(expires_at);
this._tokenRequest = undefined;
return token;
})();

return this._tokenRequest;
}

private refreshToken() {
this._token = undefined;
}

/**
Expand All @@ -135,32 +215,33 @@ class BrowserClient {
* });
* ```
*/
public connectAccount(opts: StartConnectOpts) {
public async connectAccount(opts: StartConnectOpts) {
const onMessage = (e: MessageEvent) => {
switch (e.data?.type) {
case "success":
opts.onSuccess?.({
id: e.data?.authProvisionId,
});
break;
case "error":
opts.onError?.(new ConnectError(e.data.error));
break;
case "close":
this.cleanup(onMessage);
break;
default:
break;
case "success":
opts.onSuccess?.({
id: e.data?.authProvisionId,
});
break;
case "error":
opts.onError?.(new ConnectError(e.data.error));
break;
case "close":
this.cleanup(onMessage);
break;
default:
break;
}
};

window.addEventListener("message", onMessage);

try {
this.createIframe(opts);
await this.createIframe(opts);
} catch (err) {
opts.onError?.(err as ConnectError);
}
this.refreshToken(); // token expires once it's used to create a connected account. We need to get a new token for the next requests.
}

/**
Expand All @@ -182,9 +263,10 @@ class BrowserClient {
*
* @throws {ConnectError} If the app option is not a string.
*/
private createIframe(opts: StartConnectOpts) {
private async createIframe(opts: StartConnectOpts) {
const token = opts.token || (await this.token());
const qp = new URLSearchParams({
token: opts.token,
token,
});

if (this.environment) {
Expand Down Expand Up @@ -216,4 +298,11 @@ class BrowserClient {

document.body.appendChild(iframe);
}

protected async authHeaders(): Promise<string> {
if (!(await this.token())) {
throw new Error("No token provided");
}
return `Bearer ${await this.token()}`;
}
}
Loading