diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index b7c59eef29b5d..865f60581d7c5 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -9,13 +9,17 @@ "version": "1.0.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "simple-oauth2": "^5.1.0" + "@rails/actioncable": "^8.0.0", + "simple-oauth2": "^5.1.0", + "ws": "^8.18.0" }, "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", @@ -982,6 +986,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@rails/actioncable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-8.0.0.tgz", + "integrity": "sha512-9IXyJeaBggOzlD3pF4/yWELdyUWZm/KTyKBRqxNf9laLBXPqxJt3t6fO+X4s0WajMR8cIhzkxvq1gxsXVbn3LA==", + "license": "MIT" + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -1120,14 +1130,22 @@ } }, "node_modules/@types/node": { - "version": "20.14.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", - "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", + "version": "20.17.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.6.tgz", + "integrity": "sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, + "node_modules/@types/rails__actioncable": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/@types/rails__actioncable/-/rails__actioncable-6.1.11.tgz", + "integrity": "sha512-L6A3Rg6sGsv2cqalOgdOmyFvL1Pw69Mg0WuG6NtY9chzabhtkiSFY5fczo72mqRGezrMvl0Jy80v+N719CW+Tg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/simple-oauth2": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/@types/simple-oauth2/-/simple-oauth2-5.0.7.tgz", @@ -1140,6 +1158,16 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "node_modules/@types/ws": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -3892,10 +3920,11 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/update-browserslist-db": { "version": "1.1.0", @@ -4017,6 +4046,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e56bb2c947e0a..cc30bbdeecb86 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -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", @@ -38,8 +39,10 @@ "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", @@ -47,6 +50,8 @@ "typescript": "^5.5.2" }, "dependencies": { - "simple-oauth2": "^5.1.0" + "@rails/actioncable": "^8.0.0", + "simple-oauth2": "^5.1.0", + "ws": "^8.18.0" } } diff --git a/packages/sdk/src/browser/async.ts b/packages/sdk/src/browser/async.ts new file mode 100644 index 0000000000000..ba36d0b76da91 --- /dev/null +++ b/packages/sdk/src/browser/async.ts @@ -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 +} + +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 { + const token = await this.getConnectToken() + const url = `wss://${this.apiHost}/websocket?ctok=${token}` + return createConsumer(url) as Consumer + } +} diff --git a/packages/sdk/src/browser/index.ts b/packages/sdk/src/browser/index.ts index 74d3507770c7b..91e52b29f3e2a 100644 --- a/packages/sdk/src/browser/index.ts +++ b/packages/sdk/src/browser/index.ts @@ -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. @@ -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; + /** * 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 @@ -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. @@ -98,12 +126,17 @@ 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; + externalUserId?: string; /** * Constructs a new `BrowserClient` instance. @@ -111,9 +144,56 @@ class BrowserClient { * @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; } /** @@ -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. } /** @@ -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) { @@ -216,4 +298,11 @@ class BrowserClient { document.body.appendChild(iframe); } + + protected async authHeaders(): Promise { + if (!(await this.token())) { + throw new Error("No token provided"); + } + return `Bearer ${await this.token()}`; + } } diff --git a/packages/sdk/src/server/async.ts b/packages/sdk/src/server/async.ts new file mode 100644 index 0000000000000..5a95e94fc0302 --- /dev/null +++ b/packages/sdk/src/server/async.ts @@ -0,0 +1,34 @@ +import type { AccessToken } from "simple-oauth2" +import { AsyncResponseManager } from "../shared/async" +import type { AsyncResponseManagerOpts } from "../shared/async" +import { adapters, createConsumer } from "@rails/actioncable" +import type { Consumer } from "@rails/actioncable" +import * as WS from "ws" + +type ConsumerWithSubProtocols = Consumer & { + addSubProtocol: (protocol: string) => void +} + +export type ServerAsyncResponseManagerOpts = AsyncResponseManagerOpts & { + getOauthToken: () => AccessToken +} + +export class ServerAsyncResponseManager extends AsyncResponseManager { + private getOauthToken: ServerAsyncResponseManagerOpts["getOauthToken"] + + constructor(opts: ServerAsyncResponseManagerOpts) { + const { getOauthToken, ..._opts } = opts + super(_opts) + this.getOauthToken = getOauthToken + } + + protected override async createCable(): Promise { + if (typeof adapters.WebSocket === "undefined") + adapters.WebSocket == WS + const token = await this.getOauthToken() + const url = `wss://${this.apiHost}/websocket` + const cable = createConsumer(url) as ConsumerWithSubProtocols + cable.addSubProtocol(`token=${token}`) + return cable + } +} diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index 0616c32fefb21..1d71684ab3d04 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -3,9 +3,12 @@ // See the browser/ directory for the browser client. import { - AccessToken, - ClientCredentials, + AccessToken, ClientCredentials, } from "simple-oauth2"; +import { + AppInfo, BaseClient, ConnectTokenResponse, +} from "../shared"; +import { ServerAsyncResponseManager } from "./async"; /** * OAuth credentials for your Pipedream account, containing client ID and @@ -18,7 +21,7 @@ export type OAuthCredentials = { /** * Options for creating a server-side client. - * This is used to configure the BackendClient instance. + * This is used to configure the ServerClient instance. */ export type BackendClientOpts = { /** @@ -50,21 +53,12 @@ export type BackendClientOpts = { workflowDomain?: string; }; -/** - * Different ways in which customers can authorize requests to HTTP endpoints - */ -export const enum HTTPAuthType { - None = "none", - StaticBearer = "static_bearer_token", - OAuth = "oauth" -} - /** * Options for creating a Connect token. */ -export type ConnectTokenOpts = { +export type ConnectTokenCreateOpts = { /** - * An external user ID associated with the token. + * The ID of the user in your system. */ external_user_id: string; @@ -92,19 +86,11 @@ export type ConnectTokenOpts = { * This field is completely ignored. */ environment_name?: string; -}; - -export type AppInfo = { - /** - * ID of the app. Only applies for OAuth apps. - */ - id?: string; /** - * The name slug of the target app (see - * https://pipedream.com/docs/connect/quickstart#find-your-apps-name-slug) + * Specify which origins can use the token to call the Pipedream API. */ - name_slug: string; + allowed_origins?: string[]; }; /** @@ -117,210 +103,20 @@ export type ProjectInfoResponse = { apps: AppInfo[]; }; -/** - * Response received after creating a connect token. - */ -export type ConnectTokenResponse = { - /** - * The generated token. - */ - token: string; - - /** - * The expiration time of the token in ISO 8601 format. - */ - expires_at: string; - - /** - * The Connect Link URL - */ - connect_link_url: string; -}; - -/** - * The types of authentication that Pipedream apps support. - */ -export const enum AppAuthType { - OAuth = "oauth", - Keys = "keys", - None = "none", -} - -/** - * Response object for a Pipedream app's metadata - */ -export type AppResponse = AppInfo & { - /** - * The human-readable name of the app. - */ - name: string; - - /** - * The authentication type used by the app. - */ - auth_type: AppAuthType; - - /** - * The URL to the app's logo. - */ - img_src: string; - - /** - * A JSON string representing the custom fields for the app. - */ - custom_fields_json: string; - - /** - * Categories associated with the app. - */ - categories: string[]; -}; - -/** - * Parameters for the retrieval of accounts from the Connect API - */ -export type GetAccountOpts = { - /** - * The ID or name slug of the app, in case you want to only retrieve the - * accounts for a specific app. - */ - app?: string; - - /** - * The ID of the app (if it's an OAuth app), in case you want to only retrieve - * the accounts for a specific app. - */ - oauth_app_id?: string; - - /** - * The external user ID associated with the account, in case you want to only - * retrieve the accounts of a specific user. - */ - external_user_id?: string; - - /** - * Whether to retrieve the account's credentials or not. - */ - include_credentials?: boolean; -}; - -/** - * End user account data, returned from the API. - */ -export type Account = { - /** - * The unique ID of the account. - */ - id: string; - - /** - * The name of the account. - */ - name: string; - - /** - * The external ID associated with the account. - */ - external_id: string; - - /** - * Indicates if the account is healthy. Pipedream will periodically retry - * token refresh and test requests for unhealthy accounts. - */ - healthy: boolean; - - /** - * Indicates if the account is no longer active. - */ - dead: boolean; - - /** - * The app associated with the account. - */ - app: AppResponse; - - /** - * The date and time the account was created, an ISO 8601 formatted string. - */ - created_at: string; - - /** - * The date and time the account was last updated, an ISO 8601 formatted - * string. - */ - updated_at: string; - - /** - * The credentials associated with the account, if the `include_credentials` - * parameter was set to true in the request. - */ - credentials?: Record; -}; - -/** - * Error response returned by the API in case of an error. - */ -export type ErrorResponse = { - /** - * The error message returned by the API. - */ - error: string; -}; - -/** - * A generic API response that can either be a success or an error. - */ -export type ConnectAPIResponse = T | ErrorResponse; - -/** - * Options for making a request to the Pipedream API. - */ -interface RequestOptions extends Omit { - /** - * Query parameters to include in the request URL. - */ - params?: Record; - - /** - * Headers to include in the request. - */ - headers?: Record; - - /** - * The URL to make the request to. - */ - baseURL?: string; - - /** - * The body of the request. - */ - body?: Record | string | FormData | URLSearchParams | null; - - /** - * A flag to indicate that you want to get the full response object, not just - * the body. Note that when this flag is set, responses with unsuccessful HTTP - * statuses won't throw exceptions. Instead, you'll need to check the status - * code in the response object. Defaults to false. - */ - fullResponse?: boolean; -} - /** * Creates a new instance of BackendClient with the provided options. * * @example * * ```typescript - * const client = createBackendClient({ - * credentials: { - * clientId: "your-client-id", - * clientSecret: "your-client-secret", - * }, + * const client = createClient({ + * publicKey: "your-public-key", + * secretKey: "your-secret-key", * }); * ``` * * @param opts - The options for creating the server client. - * @returns A new instance of BackendClient. + * @returns A new instance of ServerClient. */ export function createBackendClient(opts: BackendClientOpts) { return new BackendClient(opts); @@ -329,41 +125,40 @@ export function createBackendClient(opts: BackendClientOpts) { /** * A client for interacting with the Pipedream Connect API on the server-side. */ -export class BackendClient { - private environment: string; +export class BackendClient extends BaseClient { + protected override asyncResponseManager: ServerAsyncResponseManager; private oauthClient: ClientCredentials; private oauthToken?: AccessToken; - private projectId: string; - private readonly baseApiUrl: string; - private readonly workflowDomain: string; + protected projectId: string; /** - * Constructs a new BackendClient instance. + * Constructs a new ServerClient instance. * * @param opts - The options for configuring the server client. + * @param oauthClient - An optional OAuth client to use for authentication in tests */ constructor(opts: BackendClientOpts) { - this.environment = opts.environment ?? "production"; + super(opts); this.projectId = opts.projectId; - if (!this.projectId) { - throw new Error("Project ID is required"); - } - - const { - apiHost = "api.pipedream.com", - workflowDomain = "m.pipedream.net", - } = opts; - this.baseApiUrl = `https://${apiHost}/v1`; - this.workflowDomain = workflowDomain; this.oauthClient = this.newOauthClient(opts.credentials, this.baseApiUrl); + this.asyncResponseManager = new ServerAsyncResponseManager({ + apiHost: this.apiHost, + getOauthToken: () => { + if (!this.oauthToken) throw "Attempted to connect to websocket without a valid OAuth token"; + return this.oauthToken; + }, + }) + } + + protected authHeaders(): string | Promise { + return this.oauthAuthorizationHeader(); } private newOauthClient( { - clientId, - clientSecret, + clientId, clientSecret, }: OAuthCredentials, tokenHost: string, ) { @@ -414,135 +209,6 @@ export class BackendClient { return `Bearer ${this.oauthToken.token.access_token}`; } - /** - * Makes an HTTP request - * - * @template T - The expected response type. - * @param path - The API endpoint path. - * @param opts - The options for the request. - * @returns A promise resolving to the API response. - * @throws Will throw an error if the response status is not OK. - */ - public async makeRequest( - path: string, - opts: RequestOptions = {}, - ): Promise { - const { - params, - headers: customHeaders, - body, - method = "GET", - baseURL = this.baseApiUrl, - fullResponse = false, - ...fetchOpts - } = opts; - - const url = new URL(`${baseURL}${path}`); - - if (params) { - for (const [ - key, - value, - ] of Object.entries(params)) { - if (value !== undefined && value !== null) { - url.searchParams.append(key, String(value)); - } - } - } - - const headers: Record = { - ...customHeaders, - "X-PD-Environment": this.environment, - }; - - let processedBody: string | Buffer | URLSearchParams | FormData | null = null; - - if (body) { - if (body instanceof FormData || body instanceof URLSearchParams || typeof body === "string") { - // For FormData, URLSearchParams, or strings, pass the body as-is - processedBody = body; - } else { - // For objects, assume it's JSON and serialize it - processedBody = JSON.stringify(body); - // Set the Content-Type header to application/json if not already set - headers["Content-Type"] = headers["Content-Type"] || "application/json"; - } - } - - const requestOptions: RequestInit = { - method, - headers, - ...fetchOpts, - }; - - if ([ - "POST", - "PUT", - "PATCH", - ].includes(method.toUpperCase()) && processedBody) { - requestOptions.body = processedBody; - } - - const response: Response = await fetch(url.toString(), requestOptions); - if (fullResponse) { - return response as unknown as T; - } - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`HTTP error! status: ${response.status}, body: ${errorBody}`); - } - - // Attempt to parse JSON, fall back to raw text if it fails - const contentType = response.headers.get("Content-Type"); - if (contentType && contentType.includes("application/json")) { - return await response.json() as T; - } - - return await response.text() as unknown as T; - } - - /** - * Makes a request to the Pipedream API with appropriate authorization. - * - * @template T - The expected response type. - * @param path - The API endpoint path. - * @param opts - The options for the request. - * @returns A promise resolving to the API response. - * @throws Will throw an error if the response status is not OK. - */ - public async makeAuthorizedRequest( - path: string, - opts: RequestOptions = {}, - ): Promise { - const headers: Record = { - "Content-Type": "application/json", - ...opts.headers, - "Authorization": await this.oauthAuthorizationHeader(), - }; - - return this.makeRequest(path, { - headers, - ...opts, - }); - } - - /** - * Makes a request to the Connect API using Connect authorization. - * - * @template T - The expected response type. - * @param path - The API endpoint path. - * @param opts - The options for the request. - * @returns A promise resolving to the API response. - */ - private makeConnectRequest( - path: string, - opts: RequestOptions = {}, - ): Promise { - const fullPath = `/connect/${this.projectId}${path}`; - return this.makeAuthorizedRequest(fullPath, opts); - } - /** * Creates a new Pipedream Connect token. See * https://pipedream.com/docs/connect/quickstart#connect-to-the-pipedream-api-from-your-server-and-create-a-token @@ -551,17 +217,20 @@ export class BackendClient { * @returns A promise resolving to the connect token response. * * @example + * * ```typescript - * const tokenResponse = await client.createConnectToken({ - * external_user_id: "external-user-id", - * }); + * const tokenResponse = await client.connectTokenCreate({ + * external_user_id: "external-user-id", }); * console.log(tokenResponse.token); * ``` */ - public createConnectToken(opts: ConnectTokenOpts): Promise { + public createConnectToken( + opts: ConnectTokenCreateOpts, + ): Promise { const body = { ...opts, external_id: opts.external_user_id, + environment_name: this.environment, // TODO fix sdk 1.0.0 being out of sync, etc. }; return this.makeConnectRequest("/tokens", { method: "POST", @@ -569,43 +238,6 @@ export class BackendClient { }); } - /** - * Retrieves the list of accounts associated with the project. - * - * @param params - The query parameters for retrieving accounts. - * @returns A promise resolving to a list of accounts. - * - * @example - * ```typescript - * const accounts = await client.getAccounts({ include_credentials: 1 }); - * console.log(accounts); - * ``` - */ - public getAccounts(params: GetAccountOpts = {}): Promise { - return this.makeConnectRequest("/accounts", { - method: "GET", - params, - }); - } - - /** - * Retrieves a specific account by ID. - * - * @param accountId - The ID of the account to retrieve. - * @returns A promise resolving to the account. - * - * @example - * ```typescript - * const account = await client.getAccountById("account-id"); - * console.log(account); - * ``` - */ - public getAccountById(accountId: string): Promise { - return this.makeConnectRequest(`/accounts/${accountId}`, { - method: "GET", - }); - } - /** * Deletes a specific account by ID. * @@ -676,214 +308,4 @@ export class BackendClient { method: "GET", }); } - - /** - * Builds a full workflow URL based on the input. - * - * @param input - Either a full URL (with or without protocol) or just an - * endpoint ID. - * @returns The fully constructed URL. - * @throws If the input is a malformed URL, throws an error with a clear - * message. - * - * @example - * ```typescript - * // Full URL input - * this.buildWorkflowUrl("https://en123.m.pipedream.net"); - * // Returns: "https://en123.m.pipedream.net" - * ``` - * - * @example - * ```typescript - * // Partial URL (without protocol) - * this.buildWorkflowUrl("en123.m.pipedream.net"); - * // Returns: "https://en123.m.pipedream.net" - * ``` - * - * @example - * ```typescript - * // ID only input - * this.buildWorkflowUrl("en123"); - * // Returns: "https://en123.yourdomain.com" (where `yourdomain.com` is set in `workflowDomain`) - * ``` - */ - private buildWorkflowUrl(input: string): string { - const sanitizedInput = input - .trim() - .replace(/[^\w-./:]/g, "") - .toLowerCase(); - if (!sanitizedInput) { - throw new Error("URL or endpoint ID is required"); - } - - let url: string; - const isUrl = sanitizedInput.includes(".") || sanitizedInput.startsWith("http"); - - if (isUrl) { - // Try to parse the input as a URL - let parsedUrl: URL; - try { - const urlString = sanitizedInput.startsWith("http") - ? sanitizedInput - : `https://${sanitizedInput}`; - parsedUrl = new URL(urlString); - } catch (error) { - throw new Error(` - The provided URL is malformed: "${sanitizedInput}". - Please provide a valid URL. - `); - } - - // Validate the hostname to prevent potential DNS rebinding attacks - if (!parsedUrl.hostname.endsWith(this.workflowDomain)) { - throw new Error(`Invalid workflow domain. URL must end with ${this.workflowDomain}`); - } - - url = parsedUrl.href; - } else { - // If the input is an ID, construct the full URL using the base domain - if (!/^e(n|o)[a-z0-9-]+$/i.test(sanitizedInput)) { - throw new Error(` - Invalid endpoint ID format. - Must contain only letters, numbers, and hyphens, and start with either "en" or "eo". - `); - } - - url = `https://${sanitizedInput}.${this.workflowDomain}`; - } - - return url; - } - - /** - * Invokes a workflow using the URL of its HTTP interface(s), by sending an - * - * @param urlOrEndpoint - The URL of the workflow's HTTP interface, or the ID of the endpoint - * @param opts - The options for the request. - * @param opts.body - The body of the request. It must be a JSON-serializable - * value (e.g. an object, null, a string, etc.). - * @param opts.headers - The headers to include in the request. Note that the - * Authorization header will always be set with an OAuth access token - * retrieved by the client. - * @param authType - The type of authorization to use for the request. - * @returns A promise resolving to the response from the workflow. - * - * @example - * ```typescript - * const response: JSON = await client.invokeWorkflow( - * "https://en-your-endpoint.m.pipedream.net", - * { - * body: { - * foo: 123, - * bar: "abc", - * baz: null, - * }, - * headers: { - * "Accept": "application/json", - * }, - * }, - * "oauth", - * ); - * console.log(response); - * ``` - */ - public async invokeWorkflow( - urlOrEndpoint: string, - opts: RequestOptions = {}, - authType: HTTPAuthType = HTTPAuthType.None, - ): Promise { - const { - body, - headers = {}, - } = opts; - - const url = this.buildWorkflowUrl(urlOrEndpoint); - - let authHeader: string | undefined; - switch (authType) { - case HTTPAuthType.StaticBearer: - // It's expected that users will pass their own Authorization header in - // the static bearer case - authHeader = headers["Authorization"]; - break; - case HTTPAuthType.OAuth: - authHeader = await this.oauthAuthorizationHeader(); - break; - default: - break; - } - - return this.makeRequest("", { - ...opts, - baseURL: url, - method: opts.method || "POST", // Default to POST if not specified - headers: authHeader - ? { - ...headers, - "Authorization": authHeader, - } - : headers, - body, - }); - } - - /** - * Invokes a workflow for a Pipedream Connect user in a project - * - * @param url - The URL of the workflow's HTTP interface. - * @param externalUserId — Your end user ID, for whom you're invoking the - * workflow. - * @param opts - The options for the request. - * @param opts.body - The body of the request. It must be a JSON-serializable - * value (e.g. an object, null, a string, etc.). - * @param opts.headers - The headers to include in the request. Note that the - * Authorization header will always be set with an OAuth access token - * retrieved by the client. - * @returns A promise resolving to the response from the workflow. - * - * @example - * ```typescript - * const response = await client.invokeWorkflowForExternalUser( - * "https://your-workflow-url.m.pipedream.net", - * "your-external-user-id", - * { - * body: { - * foo: 123, - * bar: "abc", - * baz: null, - * }, - * headers: { - * "Accept": "application/json", - * }, - * }, - * ); - * console.log(response); - * ``` - */ - public async invokeWorkflowForExternalUser( - url: string, - externalUserId: string, - opts: RequestOptions = {}, - ): Promise { - if (!externalUserId?.trim()) { - throw new Error("External user ID is required"); - } - - if (!url.trim()) { - throw new Error("Workflow URL is required"); - } - - if (!this.oauthClient) { - throw new Error("OAuth is required for invoking workflows for external users. Please pass credentials for a valid OAuth client"); - } - - const { headers = {} } = opts; - return this.invokeWorkflow(url, { - ...opts, - headers: { - ...headers, - "X-PD-External-User-ID": externalUserId, - }, - }, HTTPAuthType.OAuth); // OAuth auth is required for invoking workflows for external users - } } diff --git a/packages/sdk/src/shared/async.ts b/packages/sdk/src/shared/async.ts new file mode 100644 index 0000000000000..fb285e238fb5b --- /dev/null +++ b/packages/sdk/src/shared/async.ts @@ -0,0 +1,101 @@ +import type { Consumer, Subscription } from "@rails/actioncable" + +/** + * A generic API response that returns asynchronously. + * See AsyncResponseManager for details. + */ +export type AsyncResponse = { + async_handle: string +} + +export type AsyncErrorResponse = { + errors: string[] +} + + +export type AsyncResponseManagerOpts = { + apiHost: string +} + +type Handle = { + resolve: (value: any) => void + reject: (reason: any) => void + promise: Promise +} + +const createHandle = (): Handle => { + const handle: any = {} + handle.promise = new Promise((resolve, reject) => { + handle.resolve = resolve + handle.reject = reject + }) + return handle +} + +function randomString(n: number) { + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return Array(n).fill(0).map(() => alphabet[Math.floor(Math.random()*alphabet.length)]).join('') +} + +export abstract class AsyncResponseManager { + protected apiHost: string + protected cable?: Consumer + protected handles: Record = {} + protected ready?: Promise + protected subscription?: Subscription + + constructor(opts: AsyncResponseManagerOpts) { + this.apiHost = opts.apiHost + } + + async connect() { + if (!this.ready) { + this.cable = await this.createCable() + this.cable.ensureActiveConnection() + this.ready = this.createSubscription() + await this.ready + } + } + + createAsyncHandle() { + const asyncHandle = randomString(12) + this.handles[asyncHandle] = createHandle() + return asyncHandle + } + + protected abstract createCable(): Promise + + protected async createSubscription(): Promise { + this.subscription = await new Promise((resolve, reject) => { + this.subscription = this.cable?.subscriptions?.create("AsyncResponseChannel", { + connected: () => resolve(this.subscription as Subscription), + rejected: (reason?: any) => reject(reason), + received: (d: { asyncHandle: string }) => { + const handle = this.handles[d.asyncHandle] + if (handle) { + handle.resolve(d) + setTimeout(() => delete this.handles[d.asyncHandle], 60000) + } + }, + disconnected: (opts?: { willAttemptReconnect: boolean }) => { + if (!opts?.willAttemptReconnect) { + for (const asyncHandle of Object.keys(this.handles)) { + const handle: Handle = this.handles[asyncHandle] + handle.reject("AsyncResponseChannel disconnected") + } + this.handles = {} + } + }, + }) + }) + return this.subscription + } + + async waitFor(asyncHandle: string): Promise { + await this.connect() + const handle = this.handles[asyncHandle] ?? createHandle() + this.handles[asyncHandle] = handle + return handle.promise + } +} + diff --git a/packages/sdk/src/shared/index.ts b/packages/sdk/src/shared/index.ts new file mode 100644 index 0000000000000..13707587ce523 --- /dev/null +++ b/packages/sdk/src/shared/index.ts @@ -0,0 +1,846 @@ +// This code is meant to be shared between the browser and server. +import { AsyncResponseManager } from "./async"; +import type { AsyncResponse, AsyncErrorResponse } from "./async"; + +/** + * Options for creating a server-side client. + * This is used to configure the BackendClient instance. + */ +export type ClientOpts = { + /** + * The environment in which the server client is running (e.g., "production", + * "development"). + */ + environment?: string; + + /** + * The API host URL. Used by Pipedream employees. Defaults to + * "api.pipedream.com" if not provided. + */ + apiHost?: string; + + /** + * Base domain for workflows. Used for custom domains: + * https://pipedream.com/docs/workflows/domains + */ + workflowDomain?: string; +}; + +export type AppInfo = { + /** + * ID of the app. Only applies for OAuth apps. + */ + id?: string; + + /** + * The name slug of the target app (see + * https://pipedream.com/docs/connect/quickstart#find-your-apps-name-slug) + */ + name_slug: string; +}; + +/** + * The types of authentication that Pipedream apps support. + */ +export const enum AppAuthType { + OAuth = "oauth", + Keys = "keys", + None = "none", +} + +/** + * Response object for a Pipedream app's metadata + */ +export type AppResponse = AppInfo & { + /** + * The human-readable name of the app. + */ + name: string; + + /** + * The authentication type used by the app. + */ + auth_type: AppAuthType; + + /** + * The URL to the app's logo. + */ + img_src: string; + + /** + * A JSON string representing the custom fields for the app. + */ + custom_fields_json: string; + + /** + * Categories associated with the app. + */ + categories: string[]; +}; + +export type ComponentConfigureResponse = { + options: { label: string; value: string }[]; + string_options: string[]; + errors: string[]; +}; + +/** + * Parameters for the retrieval of apps from the Connect API + */ +export type GetAppsOpts = { + /** + * A search query to filter the apps. + */ + q?: string; +}; + +/** + * Parameters for the retrieval of accounts from the Connect API + */ +export type GetAccountOpts = { + /** + * The ID or name slug of the app, in case you want to only retrieve the + * accounts for a specific app. + */ + app?: string; + + /** + * The ID of the app (if it's an OAuth app), in case you want to only retrieve + * the accounts for a specific app. + */ + oauth_app_id?: string; + + /** + * Whether to retrieve the account's credentials or not. + */ + include_credentials?: boolean; +}; + +/** + * End user account data, returned from the API. + */ +export type Account = { + /** + * The unique ID of the account. + */ + id: string; + + /** + * The name of the account. + */ + name: string; + + /** + * The external ID associated with the account. + */ + external_id: string; + + /** + * Indicates if the account is healthy. Pipedream will periodically retry + * token refresh and test requests for unhealthy accounts. + */ + healthy: boolean; + + /** + * Indicates if the account is no longer active. + */ + dead: boolean; + + /** + * The app associated with the account. + */ + app: AppResponse; + + /** + * The date and time the account was created, an ISO 8601 formatted string. + */ + created_at: string; + + /** + * The date and time the account was last updated, an ISO 8601 formatted + * string. + */ + updated_at: string; + + /** + * The credentials associated with the account, if the `include_credentials` + * parameter was set to true in the request. + */ + credentials?: Record; +}; + +export type ComponentReloadPropsOpts = { + userId: string; + componentId: string; + configuredProps: any; + dynamicPropsId?: string; +}; + +export type ComponentConfigureOpts = { + userId: string; + componentId: string; + propName: string; + configuredProps: any; + dynamicPropsId?: string; + query?: string; +}; + +export type GetComponentOpts = { + q?: string; + app?: string; + componentType?: "trigger" | "action"; +}; + +// TODO Split up the base component and component response +export type V1Component = { + key: string; + name: string; + version: string; + configurable_props?: any; // only when fetched directly, XXX fix type +}; + +/** + * Response received after creating a connect token. + */ +export type ConnectTokenResponse = { + /** + * The generated token. + */ + token: string; + + /** + * The expiration time of the token in ISO 8601 format. + */ + expires_at: string; + /** + * The Connect Link URL + */ + connect_link_url: string; +}; + +/** + * Different ways in which customers can authorize requests to HTTP endpoints + */ +export const enum HTTPAuthType { + None = "none", + StaticBearer = "static_bearer_token", + OAuth = "oauth", +} + +/** + * Error response returned by the API in case of an error. + */ +export type ErrorResponse = { + /** + * The error message returned by the API. + */ + error: string; +}; + +/** + * A generic API response that can either be a success or an error. + */ +export type ConnectAPIResponse = T | ErrorResponse; + +/** + * Options for making a request to the Pipedream API. + */ +export interface RequestOptions extends Omit { + /** + * Query parameters to include in the request URL. + */ + params?: Record; + + /** + * Headers to include in the request. + */ + headers?: Record; + + /** + * The URL to make the request to. + */ + baseURL?: string; + + /** + * The body of the request. + */ + body?: Record | string | FormData | URLSearchParams | null; +} + +export interface AsyncRequestOptions extends RequestOptions { + body: { async_handle: string } & Required; +}; + +/** + * A client for interacting with the Pipedream Connect API on the server-side. + */ +export abstract class BaseClient { + protected apiHost: string; + protected abstract asyncResponseManager: AsyncResponseManager; + protected readonly baseApiUrl: string; + protected environment: string; + protected projectId?: string; + protected readonly workflowDomain: string; + + /** + * Constructs a new BackendClient instance. + * + * @param opts - The options for configuring the server client. + */ + constructor(opts: ClientOpts) { + this.environment = opts.environment ?? "production"; + + const { + apiHost = "api.pipedream.com", + workflowDomain = "m.pipedream.net", + } = opts; + this.apiHost = apiHost; + this.baseApiUrl = `https://${apiHost}/v1`; + this.workflowDomain = workflowDomain; + } + + /** + * Makes an HTTP request + * + * @template T - The expected response type. + * @param path - The API endpoint path. + * @param opts - The options for the request. + * @returns A promise resolving to the API response. + * @throws Will throw an error if the response status is not OK. + */ + public async makeRequest( + path: string, + opts: RequestOptions = {}, + ): Promise { + const { + params, + headers: customHeaders, + body, + method = "GET", + baseURL = this.baseApiUrl, + ...fetchOpts + } = opts; + + const url = new URL(`${baseURL}${path}`); + + if (params) { + for (const [ + key, + value, + ] of Object.entries(params)) { + if (value !== undefined && value !== null) { + url.searchParams.append(key, String(value)); + } + } + } + + const headers: Record = { + ...customHeaders, + "X-PD-Environment": this.environment, + }; + + let processedBody: string | URLSearchParams | FormData | null = null; + + if (body) { + if ( + body instanceof FormData || + body instanceof URLSearchParams || + typeof body === "string" + ) { + // For FormData, URLSearchParams, or strings, pass the body as-is + processedBody = body; + } else { + // For objects, assume it's JSON and serialize it + processedBody = JSON.stringify(body); + // Set the Content-Type header to application/json if not already set + headers["Content-Type"] = headers["Content-Type"] || "application/json"; + } + } + + const requestOptions: RequestInit = { + method, + headers, + ...fetchOpts, + }; + + if ( + [ + "POST", + "PUT", + "PATCH", + ].includes(method.toUpperCase()) && + processedBody + ) { + requestOptions.body = processedBody; + } + + const response: Response = await fetch(url.toString(), requestOptions); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `HTTP error! status: ${response.status}, body: ${errorBody}`, + ); + } + + // Attempt to parse JSON, fall back to raw text if it fails + const contentType = response.headers.get("Content-Type"); + if (contentType && contentType.includes("application/json")) { + return (await response.json()) as T; + } + + return (await response.text()) as unknown as T; + } + + protected abstract authHeaders(): string | Promise; + + /** + * Makes a request to the Pipedream API with appropriate authorization. + * + * @template T - The expected response type. + * @param path - The API endpoint path. + * @param opts - The options for the request. + * @returns A promise resolving to the API response. + * @throws Will throw an error if the response status is not OK. + */ + public async makeAuthorizedRequest( + path: string, + opts: RequestOptions = {}, + ): Promise { + const headers: Record = { + "Content-Type": "application/json", + ...opts.headers, + "Authorization": await this.authHeaders(), + }; + + return this.makeRequest(path, { + headers, + ...opts, + }); + } + + /** + * Makes a request to the Connect API using Connect authorization. + * + * @template T - The expected response type. + * @param path - The API endpoint path. + * @param opts - The options for the request. + * @returns A promise resolving to the API response. + */ + protected makeConnectRequest( + path: string, + opts: RequestOptions = {}, + ): Promise { + let fullPath = "/connect"; + if (this.projectId) { + fullPath += `/${this.projectId}`; + } + fullPath += path; + return this.makeAuthorizedRequest(fullPath, opts); + } + + /** + * Makes a request to the Connect API using Connect authorization. + * This version makes an asynchronous request, fulfilled via Websocket. + * + * @template T - The expected response type. + * @param path - The API endpoint path. + * @param opts - The options for the request. + * @returns A promise resolving to the API response. + */ + protected async makeConnectRequestAsync( + path: string, + opts: AsyncRequestOptions + ): Promise { + await this.asyncResponseManager.connect(); + const data = await this.makeConnectRequest(path, opts); + if ("errors" in data && data.errors.length) { + throw new Error(data.errors[0]); + } + if ("async_handle" in data && data.async_handle) { + const result = await this.asyncResponseManager.waitFor(data.async_handle); + return result; + } + return data as T; + } + + /** + * Retrieves the list of accounts associated with the project. + * + * @param params - The query parameters for retrieving accounts. + * @returns A promise resolving to a list of accounts. + * + * @example + * ```typescript + * const accounts = await client.getAccounts({ include_credentials: 1 }); + * console.log(accounts); + * ``` + */ + public async getAccounts(params: GetAccountOpts = {}): Promise { + const resp = await this.makeConnectRequest<{ + data: Account[]; + }>("/accounts", { + method: "GET", + params, + }); + + return resp.data; + } + + /** + * Retrieves a specific account by ID. + * + * @param accountId - The ID of the account to retrieve. + * @returns A promise resolving to the account. + * + * @example + * ```typescript + * const account = await client.getAccountById("account-id"); + * console.log(account); + * ``` + */ + public getAccountById(accountId: string): Promise { + return this.makeConnectRequest(`/accounts/${accountId}`, { + method: "GET", + }); + } + + // XXX only here while need project auth + public async apps(opts?: GetAppsOpts) { + const params: Record = {}; + if (opts?.q) { + params.q = opts.q; + } + const resp = await this.makeAuthorizedRequest<{ data: AppResponse[]; }>( + "/apps", + { + method: "GET", + params, + }, + ); + return resp.data; + } + + public async app(idOrNameSlug: string) { + const url = `/apps/${idOrNameSlug}`; + const resp = await this.makeAuthorizedRequest<{ data: AppResponse; }>(url, { + method: "GET", + }); + return resp.data; + } + + // XXX only here while need project auth + public async components(opts?: GetComponentOpts) { + const params: Record = { + limit: "20", + }; + if (opts?.app) { + params.app = opts.app; + } + if (opts?.q) { + params.q = opts.q; + } + // XXX can just use /components and ?type instead when supported + let path = "/components"; + if (opts?.componentType === "trigger") { + path = "/triggers"; + } else if (opts?.componentType === "action") { + path = "/actions"; + } + // XXX Is V1Component the correct type for triggers and actions? + const resp = await this.makeConnectRequest<{ data: V1Component[]; }>(path, { + method: "GET", + params, + }); + return resp.data; + } + + public async component({ key }: { key: string; }) { + const url = `/actions/${key}`; // TODO we don't check type here... but should rename or something (trigger) + const resp = await this.makeConnectRequest<{ data: V1Component; }>(url, { + method: "GET", + }); + return resp.data; + } + + public async componentConfigure(opts: ComponentConfigureOpts) { + const body = { + async_handle: this.asyncResponseManager.createAsyncHandle(), + external_user_id: opts.userId, + id: opts.componentId, + prop_name: opts.propName, + configured_props: opts.configuredProps, + dynamic_props_id: opts.dynamicPropsId, + environment: this.environment, + }; + return await this.makeConnectRequestAsync<{ + options: { label: string; value: string }[] + string_options: string[] + errors: string[] + }>("/actions/configure", { + // TODO trigger + method: "POST", + body, + }); + } + + public async componentReloadProps(opts: ComponentReloadPropsOpts) { + // RpcActionReloadPropsInput + const body = { + async_handle: this.asyncResponseManager.createAsyncHandle(), + external_user_id: opts.userId, + id: opts.componentId, + configured_props: opts.configuredProps, + dynamic_props_id: opts.dynamicPropsId, + environment: this.environment, + }; + return await this.makeConnectRequestAsync<{}>("/actions/props", { + // TODO trigger + method: "POST", + body, + }); + } + + public async actionRun(opts: { + userId: string; + actionId: string; + configuredProps: Record; + dynamicPropsId?: string; + }) { + const body = { + async_handle: this.asyncResponseManager.createAsyncHandle(), + external_user_id: opts.userId, + id: opts.actionId, + configured_props: opts.configuredProps, + dynamic_props_id: opts.dynamicPropsId, + environment: this.environment, + }; + return await this.makeConnectRequestAsync<{ + exports: unknown; + os: unknown[]; + ret: unknown; + }>("/actions/run", { + method: "POST", + body, + }); + } + + /** + * Builds a full workflow URL based on the input. + * + * @param input - Either a full URL (with or without protocol) or just an + * endpoint ID. + * @returns The fully constructed URL. + * @throws If the input is a malformed URL, throws an error with a clear + * message. + * + * @example + * ```typescript + * // Full URL input + * this.buildWorkflowUrl("https://en123.m.pipedream.net"); + * // Returns: "https://en123.m.pipedream.net" + * ``` + * + * @example + * ```typescript + * // Partial URL (without protocol) + * this.buildWorkflowUrl("en123.m.pipedream.net"); + * // Returns: "https://en123.m.pipedream.net" + * ``` + * + * @example + * ```typescript + * // ID only input + * this.buildWorkflowUrl("en123"); + * // Returns: "https://en123.yourdomain.com" (where `yourdomain.com` is set in `workflowDomain`) + * ``` + */ + private buildWorkflowUrl(input: string): string { + const sanitizedInput = input + .trim() + .replace(/[^\w-./:]/g, "") + .toLowerCase(); + if (!sanitizedInput) { + throw new Error("URL or endpoint ID is required"); + } + + let url: string; + const isUrl = + sanitizedInput.includes(".") || sanitizedInput.startsWith("http"); + + if (isUrl) { + // Try to parse the input as a URL + let parsedUrl: URL; + try { + const urlString = sanitizedInput.startsWith("http") + ? sanitizedInput + : `https://${sanitizedInput}`; + parsedUrl = new URL(urlString); + } catch (error) { + throw new Error(` + The provided URL is malformed: "${sanitizedInput}". + Please provide a valid URL. + `); + } + + // Validate the hostname to prevent potential DNS rebinding attacks + if (!parsedUrl.hostname.endsWith(this.workflowDomain)) { + throw new Error( + `Invalid workflow domain. URL must end with ${this.workflowDomain}`, + ); + } + + url = parsedUrl.href; + } else { + // If the input is an ID, construct the full URL using the base domain + if (!/^e(n|o)[a-z0-9-]+$/i.test(sanitizedInput)) { + throw new Error(` + Invalid endpoint ID format. + Must contain only letters, numbers, and hyphens, and start with either "en" or "eo". + `); + } + + url = `https://${sanitizedInput}.${this.workflowDomain}`; + } + + return url; + } + + /** + * Invokes a workflow using the URL of its HTTP interface(s), by sending an + * + * @param urlOrEndpoint - The URL of the workflow's HTTP interface, or the ID of the endpoint + * @param opts - The options for the request. + * @param opts.body - The body of the request. It must be a JSON-serializable + * value (e.g. an object, null, a string, etc.). + * @param opts.headers - The headers to include in the request. Note that the + * Authorization header will always be set with an OAuth access token + * retrieved by the client. + * @param authType - The type of authorization to use for the request. + * @returns A promise resolving to the response from the workflow. + * + * @example + * ```typescript + * const response: JSON = await client.invokeWorkflow( + * "https://en-your-endpoint.m.pipedream.net", + * { + * body: { + * foo: 123, + * bar: "abc", + * baz: null, + * }, + * headers: { + * "Accept": "application/json", + * }, + * }, + * "oauth", + * ); + * console.log(response); + * ``` + */ + public async invokeWorkflow( + urlOrEndpoint: string, + opts: RequestOptions = {}, + authType: HTTPAuthType = HTTPAuthType.None, + ): Promise { + const { + body, headers = {}, + } = opts; + + const url = this.buildWorkflowUrl(urlOrEndpoint); + + let authHeader: string | undefined; + switch (authType) { + case HTTPAuthType.StaticBearer: + // It's expected that users will pass their own Authorization header in + // the static bearer case + authHeader = headers["Authorization"]; + break; + case HTTPAuthType.OAuth: + authHeader = await this.authHeaders(); // TODO How to handle this client side? We should pass the auth even if it's not OAuth + break; + default: + break; + } + + return this.makeRequest("", { + ...opts, + baseURL: url, + method: opts.method || "POST", // Default to POST if not specified + headers: authHeader + ? { + ...headers, + Authorization: authHeader, + } + : headers, + body, + }); + } + + /** + * Invokes a workflow for a Pipedream Connect user in a project + * + * @param url - The URL of the workflow's HTTP interface. + * @param externalUserId — Your end user ID, for whom you're invoking the + * workflow. + * @param opts - The options for the request. + * @param opts.body - The body of the request. It must be a JSON-serializable + * value (e.g. an object, null, a string, etc.). + * @param opts.headers - The headers to include in the request. Note that the + * Authorization header will always be set with an OAuth access token + * retrieved by the client. + * @returns A promise resolving to the response from the workflow. + * + * @example + * ```typescript + * const response = await client.invokeWorkflowForExternalUser( + * "https://your-workflow-url.m.pipedream.net", + * "your-external-user-id", + * { + * body: { + * foo: 123, + * bar: "abc", + * baz: null, + * }, + * headers: { + * "Accept": "application/json", + * }, + * }, + * ); + * console.log(response); + * ``` + */ + public async invokeWorkflowForExternalUser( + url: string, + externalUserId: string, + opts: RequestOptions = {}, + ): Promise { + if (!externalUserId?.trim()) { + throw new Error("External user ID is required"); + } + + if (!url.trim()) { + throw new Error("Workflow URL is required"); + } + + if (!(await this.authHeaders())) { + throw new Error( + // TODO Test that this works with token auth + "OAuth or token is required for invoking workflows for external users. Please pass credentials for a valid OAuth client", + ); + } + + const { headers = {} } = opts; + return this.invokeWorkflow( + url, + { + ...opts, + headers: { + ...headers, + "X-PD-External-User-ID": externalUserId, + }, + }, + HTTPAuthType.OAuth, + ); // OAuth auth is required for invoking workflows for external users + } +} diff --git a/packages/sdk/tsconfig.browser.json b/packages/sdk/tsconfig.browser.json index a0cafbd664f44..83828fc1f2d5f 100644 --- a/packages/sdk/tsconfig.browser.json +++ b/packages/sdk/tsconfig.browser.json @@ -1,8 +1,8 @@ { "compilerOptions": { "module": "ESNext", - "target": "ES6", - "lib": ["ES5", "DOM"], + "target": "es2017", + "lib": ["ES5", "DOM", "es2017"], "declaration": true, "outDir": "dist/browser", "strict": true, diff --git a/packages/sdk/tsconfig.node.json b/packages/sdk/tsconfig.node.json index d53df43f701a9..aac923e1f200d 100644 --- a/packages/sdk/tsconfig.node.json +++ b/packages/sdk/tsconfig.node.json @@ -1,9 +1,9 @@ { "compilerOptions": { "module": "NodeNext", - "target": "ES6", + "target": "es2017", "lib": [ - "ES6" + "es2017" ], "declaration": true, "outDir": "dist/server", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bb40f3699cb3..7a59994e4a9ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12154,24 +12154,32 @@ importers: packages/sdk: specifiers: + '@rails/actioncable': ^8.0.0 '@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 simple-oauth2: ^5.1.0 ts-jest: ^29.2.5 typescript: ^5.5.2 + ws: ^8.18.0 dependencies: + '@rails/actioncable': 8.0.0 simple-oauth2: 5.1.0 + ws: 8.18.0 devDependencies: '@types/fetch-mock': 7.3.8 '@types/jest': 29.5.13 - '@types/node': 20.16.1 + '@types/node': 20.17.6 + '@types/rails__actioncable': 6.1.11 '@types/simple-oauth2': 5.0.7 - jest: 29.7.0_@types+node@20.16.1 + '@types/ws': 8.5.13 + jest: 29.7.0_@types+node@20.17.6 jest-fetch-mock: 3.0.3 nodemon: 3.1.7 ts-jest: 29.2.5_q3xqhaztsvh2r5udjscjs67zn4 @@ -17188,7 +17196,7 @@ packages: engines: {node: ^8.13.0 || >=10.10.0} dependencies: '@grpc/proto-loader': 0.7.13 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false optional: true @@ -17197,7 +17205,7 @@ packages: engines: {node: ^8.13.0 || >=10.10.0} dependencies: '@grpc/proto-loader': 0.7.13 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@grpc/grpc-js/1.9.9: @@ -17205,7 +17213,7 @@ packages: engines: {node: ^8.13.0 || >=10.10.0} dependencies: '@grpc/proto-loader': 0.7.13 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@grpc/proto-loader/0.6.13: @@ -17323,7 +17331,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -17344,14 +17352,14 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0_@types+node@20.16.1 + jest-config: 29.7.0_@types+node@20.17.6 jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -17379,7 +17387,7 @@ packages: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 jest-mock: 29.7.0 dev: true @@ -17406,7 +17414,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.16.1 + '@types/node': 20.17.6 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -17439,7 +17447,7 @@ packages: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 - '@types/node': 20.16.1 + '@types/node': 20.17.6 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -17527,7 +17535,7 @@ packages: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.16.1 + '@types/node': 20.17.6 '@types/yargs': 17.0.32 chalk: 4.1.2 dev: true @@ -19732,6 +19740,10 @@ packages: npmlog: 4.1.2 dev: true + /@rails/actioncable/8.0.0: + resolution: {integrity: sha512-9IXyJeaBggOzlD3pF4/yWELdyUWZm/KTyKBRqxNf9laLBXPqxJt3t6fO+X4s0WajMR8cIhzkxvq1gxsXVbn3LA==} + dev: false + /@readme/better-ajv-errors/1.6.0_ajv@8.17.1: resolution: {integrity: sha512-9gO9rld84Jgu13kcbKRU+WHseNhaVt76wYMeRDGsUGYxwJtI3RmEJ9LY9dZCYQGI8eUZLuxb5qDja0nqklpFjQ==} engines: {node: '>=14'} @@ -19942,14 +19954,14 @@ packages: resolution: {integrity: sha512-OkIJpiU2fz6HOJujhlhfIGrc8hB4ibqtf7nnbJQDerG0BqwZCfmgtK5sWzZ0TkXVRBKD5MpLrTmCYyMxoMCgPw==} engines: {node: '>= 8.9.0', npm: '>= 5.5.1'} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@slack/logger/4.0.0: resolution: {integrity: sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==} engines: {node: '>= 18', npm: '>= 8.6.0'} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@slack/types/1.10.0: @@ -21452,8 +21464,8 @@ packages: dependencies: '@supabase/node-fetch': 2.6.15 '@types/phoenix': 1.6.2 - '@types/ws': 8.5.12 - ws: 8.16.0 + '@types/ws': 8.5.13 + ws: 8.18.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -21585,7 +21597,7 @@ packages: resolution: {integrity: sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==} dependencies: '@types/connect': 3.4.36 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/cacheable-request/6.0.3: @@ -21593,7 +21605,7 @@ packages: dependencies: '@types/http-cache-semantics': 4.0.2 '@types/keyv': 3.1.4 - '@types/node': 20.16.1 + '@types/node': 20.17.6 '@types/responselike': 1.0.1 dev: false @@ -21604,7 +21616,7 @@ packages: /@types/connect/3.4.36: resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/debug/4.1.12: @@ -21616,7 +21628,7 @@ packages: /@types/duplexify/3.6.2: resolution: {integrity: sha512-2/0R4riyD/OS6GNJLIhwRaj+8ZbxHUZl3I0a3PHwH7zhZEEAACUWjzaBrY1qVWckueZ5pouDRP0UxX6P8Hzfww==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/estree-jsx/1.0.5: @@ -21632,7 +21644,7 @@ packages: /@types/express-serve-static-core/4.17.37: resolution: {integrity: sha512-ZohaCYTgGFcOP7u6aJOhY9uIZQgZ2vxC2yWoArY+FeDXlqeH66ZVBjgvg+RLVAS/DWNq4Ap9ZXu1+SUQiiWYMg==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 '@types/qs': 6.9.8 '@types/range-parser': 1.2.5 '@types/send': 0.17.2 @@ -21662,20 +21674,20 @@ packages: resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} dependencies: '@types/minimatch': 5.1.2 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/glob/8.1.0: resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==} dependencies: '@types/minimatch': 5.1.2 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/graceful-fs/4.1.9: resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: true /@types/hast/2.3.10: @@ -21695,7 +21707,7 @@ packages: /@types/is-stream/1.1.0: resolution: {integrity: sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/istanbul-lib-coverage/2.0.6: @@ -21738,13 +21750,13 @@ packages: /@types/jsonwebtoken/8.5.9: resolution: {integrity: sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/keyv/3.1.4: resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/linkify-it/3.0.3: @@ -21843,8 +21855,8 @@ packages: undici-types: 5.26.5 dev: true - /@types/node/20.16.1: - resolution: {integrity: sha512-zJDo7wEadFtSyNz5QITDfRcrhqDvQI1xQNQ0VoizPjM/dVAODqqIUWbJPkvsxmTI0MYRGRikcdjMPhOssnPejQ==} + /@types/node/20.17.6: + resolution: {integrity: sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==} dependencies: undici-types: 6.19.8 @@ -21869,6 +21881,10 @@ packages: resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==} dev: false + /@types/rails__actioncable/6.1.11: + resolution: {integrity: sha512-L6A3Rg6sGsv2cqalOgdOmyFvL1Pw69Mg0WuG6NtY9chzabhtkiSFY5fczo72mqRGezrMvl0Jy80v+N719CW+Tg==} + dev: true + /@types/range-parser/1.2.5: resolution: {integrity: sha512-xrO9OoVPqFuYyR/loIHjnbvvyRZREYKLjxV4+dY6v3FQR3stQ9ZxIGkaclF7YhI9hfjpuTbu14hZEy94qKLtOA==} dev: false @@ -21877,7 +21893,7 @@ packages: resolution: {integrity: sha512-HuihY1+Vss5RS9ZHzRyTGIzwPTdrJBkCm/mAeLRYrOQu/MGqyezKXWOK1VhCnR+SDbp9G2mRUP+OVEqCrzpcfA==} dependencies: '@types/caseless': 0.12.4 - '@types/node': 20.16.1 + '@types/node': 20.17.6 '@types/tough-cookie': 4.0.4 form-data: 2.5.1 dev: false @@ -21885,7 +21901,7 @@ packages: /@types/responselike/1.0.1: resolution: {integrity: sha512-TiGnitEDxj2X0j+98Eqk5lv/Cij8oHd32bU4D/Yw6AOq7vvTk0gSD2GPj0G/HkvhMoVsdlhYF4yqqlyPBTM6Sg==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/retry/0.12.0: @@ -21896,13 +21912,13 @@ packages: resolution: {integrity: sha512-F3OznnSLAUxFrCEu/L5PY8+ny8DtcFRjx7fZZ9bycvXRi3KPTRS9HOitGZwvPg0juRhXFWIeKX58cnX5YqLohQ==} dependencies: '@types/glob': 8.1.0 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/sax/1.2.5: resolution: {integrity: sha512-9jWta97bBVC027/MShr3gLab8gPhKy4l6qpb+UJLF5pDm3501NvA7uvqVCW+REFtx00oTi6Cq9JzLwgq6evVgw==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: true /@types/semver/7.5.3: @@ -21913,7 +21929,7 @@ packages: resolution: {integrity: sha512-aAG6yRf6r0wQ29bkS+x97BIs64ZLxeE/ARwyS6wrldMm3C1MdKwCcnnEwMC1slI8wuxJOpiUH9MioC0A0i+GJw==} dependencies: '@types/mime': 1.3.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/serve-static/1.15.3: @@ -21921,7 +21937,7 @@ packages: dependencies: '@types/http-errors': 2.0.2 '@types/mime': 3.0.2 - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/simple-oauth2/5.0.7: @@ -21950,7 +21966,7 @@ packages: /@types/tunnel/0.0.3: resolution: {integrity: sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/unist/2.0.10: @@ -21979,20 +21995,19 @@ packages: /@types/whatwg-url/8.2.2: resolution: {integrity: sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 '@types/webidl-conversions': 7.0.1 dev: false - /@types/ws/8.5.12: - resolution: {integrity: sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==} + /@types/ws/8.5.13: + resolution: {integrity: sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==} dependencies: - '@types/node': 20.16.1 - dev: false + '@types/node': 20.17.6 /@types/ws/8.5.3: resolution: {integrity: sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false /@types/yargs-parser/21.0.3: @@ -22009,7 +22024,7 @@ packages: resolution: {integrity: sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==} requiresBuild: true dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 dev: false optional: true @@ -24267,7 +24282,7 @@ packages: - ts-node dev: true - /create-jest/29.7.0_@types+node@20.16.1: + /create-jest/29.7.0_@types+node@20.17.6: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -24276,7 +24291,7 @@ packages: chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0_@types+node@20.16.1 + jest-config: 29.7.0_@types+node@20.17.6 jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -28730,7 +28745,7 @@ packages: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.3 @@ -28779,7 +28794,7 @@ packages: - ts-node dev: true - /jest-cli/29.7.0_@types+node@20.16.1: + /jest-cli/29.7.0_@types+node@20.17.6: resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -28793,10 +28808,10 @@ packages: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0_@types+node@20.16.1 + create-jest: 29.7.0_@types+node@20.17.6 exit: 0.1.2 import-local: 3.1.0 - jest-config: 29.7.0_@types+node@20.16.1 + jest-config: 29.7.0_@types+node@20.17.6 jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -28874,7 +28889,7 @@ packages: - supports-color dev: true - /jest-config/29.7.0_@types+node@20.16.1: + /jest-config/29.7.0_@types+node@20.17.6: resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: @@ -28889,7 +28904,7 @@ packages: '@babel/core': 7.25.2 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 babel-jest: 29.7.0_@babel+core@7.25.2 chalk: 4.1.2 ci-info: 3.9.0 @@ -28999,7 +29014,7 @@ packages: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 jest-mock: 29.7.0 jest-util: 29.7.0 dev: true @@ -29029,7 +29044,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 20.16.1 + '@types/node': 20.17.6 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -29090,7 +29105,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 jest-util: 29.7.0 dev: true @@ -29145,7 +29160,7 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -29176,7 +29191,7 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 chalk: 4.1.2 cjs-module-lexer: 1.3.1 collect-v8-coverage: 1.0.2 @@ -29228,7 +29243,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -29253,7 +29268,7 @@ packages: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.17.6 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -29265,7 +29280,7 @@ packages: resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@types/node': 20.16.1 + '@types/node': 20.17.6 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -29292,7 +29307,7 @@ packages: - ts-node dev: true - /jest/29.7.0_@types+node@20.16.1: + /jest/29.7.0_@types+node@20.17.6: resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -29305,7 +29320,7 @@ packages: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.1.0 - jest-cli: 29.7.0_@types+node@20.16.1 + jest-cli: 29.7.0_@types+node@20.17.6 transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -29595,7 +29610,7 @@ packages: jws: 3.2.2 lodash: 4.17.21 ms: 2.1.3 - semver: 7.5.4 + semver: 7.6.3 dev: false /jsonwebtoken/9.0.2: @@ -33009,7 +33024,7 @@ packages: '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 '@types/long': 4.0.2 - '@types/node': 20.16.1 + '@types/node': 20.17.6 long: 4.0.0 dev: false optional: true @@ -33030,7 +33045,7 @@ packages: '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 '@types/long': 4.0.2 - '@types/node': 20.16.1 + '@types/node': 20.17.6 long: 4.0.0 dev: false optional: true @@ -33050,7 +33065,7 @@ packages: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 20.16.1 + '@types/node': 20.17.6 long: 5.2.3 dev: false @@ -33069,7 +33084,7 @@ packages: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 20.16.1 + '@types/node': 20.17.6 long: 5.2.3 dev: false @@ -33088,7 +33103,7 @@ packages: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 20.16.1 + '@types/node': 20.17.6 long: 5.2.3 dev: false @@ -35859,7 +35874,7 @@ packages: bs-logger: 0.2.6 ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0_@types+node@20.16.1 + jest: 29.7.0_@types+node@20.17.6 jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -37222,6 +37237,19 @@ packages: optional: true dev: false + /ws/8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /ws/8.7.0: resolution: {integrity: sha512-c2gsP0PRwcLFzUiA8Mkr37/MI7ilIlHQxaEAtd0uNMbVMoy8puJyafRlm0bV9MbGSabUPeLrRRaqIBcFcA2Pqg==} engines: {node: '>=10.0.0'}