diff --git a/apps/webapp/app/components/Icon.tsx b/apps/webapp/app/components/Icon.tsx index a20abc1fe..8ee863bc0 100644 --- a/apps/webapp/app/components/Icon.tsx +++ b/apps/webapp/app/components/Icon.tsx @@ -26,6 +26,9 @@ const icons = { alt="Airtable" /> ), + github: (className: string) => ( + GitHub + ), }; export type IconNames = keyof typeof icons; diff --git a/apps/webapp/app/routes/resources/connection/oauth2.callback.ts b/apps/webapp/app/routes/resources/connection/oauth2.callback.ts index 6ae22064d..61256e9e2 100644 --- a/apps/webapp/app/routes/resources/connection/oauth2.callback.ts +++ b/apps/webapp/app/routes/resources/connection/oauth2.callback.ts @@ -55,6 +55,7 @@ export async function loader({ request }: LoaderArgs) { code: parsedParams.data.code, title: attempt.title, pkceCode: attempt.securityCode ?? undefined, + url, }); return redirect(attempt.redirectTo); diff --git a/apps/webapp/app/routes/resources/connection/oauth2.ts b/apps/webapp/app/routes/resources/connection/oauth2.ts index 4f48f50bf..254a3243c 100644 --- a/apps/webapp/app/routes/resources/connection/oauth2.ts +++ b/apps/webapp/app/routes/resources/connection/oauth2.ts @@ -75,6 +75,8 @@ export async function action({ request }: ActionArgs) { ); } + const url = new URL(request.url); + const redirectUrl = await apiConnectionRepository.createConnectionAttempt({ organizationId, apiIdentifier: api, @@ -82,6 +84,7 @@ export async function action({ request }: ActionArgs) { scopes, title, redirectTo, + url, }); return redirect(redirectUrl); diff --git a/apps/webapp/app/services/externalApis/apiAuthenticationRepository.server.ts b/apps/webapp/app/services/externalApis/apiAuthenticationRepository.server.ts index 8a943067c..9a5b9573b 100644 --- a/apps/webapp/app/services/externalApis/apiAuthenticationRepository.server.ts +++ b/apps/webapp/app/services/externalApis/apiAuthenticationRepository.server.ts @@ -81,6 +81,7 @@ export class APIAuthenticationRepository { scopes, title, redirectTo, + url, }: { organizationId: string; apiIdentifier: string; @@ -88,6 +89,7 @@ export class APIAuthenticationRepository { scopes: string[]; title: string; redirectTo: string; + url: URL; }) { const api = this.apiCatalog.getApi(apiIdentifier); if (!api) { @@ -128,14 +130,14 @@ export class APIAuthenticationRepository { authenticationMethod.client.id.envName, authenticationMethod.client.secret.envName ); - const callbackHostName = this.#callbackUrl(authenticationMethod); + const callbackUrl = this.#buildCallbackUrl(authenticationMethod, url); const createAuthorizationParams = { authorizationUrl: authenticationMethod.config.authorization.url, clientId: getClientConfig.id, clientSecret: getClientConfig.secret, key: connectionAttempt.id, - callbackUrl: `${callbackHostName}/resources/connection/oauth2/callback`, + callbackUrl, scopeParamName: authenticationMethod.config.authorization.scopeParamName ?? "scope", scopes, @@ -174,6 +176,7 @@ export class APIAuthenticationRepository { code, title, pkceCode, + url, }: { organizationId: string; apiIdentifier: string; @@ -182,6 +185,7 @@ export class APIAuthenticationRepository { code: string; title: string; pkceCode?: string; + url: URL; }) { const api = this.apiCatalog.getApi(apiIdentifier); if (!api) { @@ -202,14 +206,14 @@ export class APIAuthenticationRepository { authenticationMethod.client.id.envName, authenticationMethod.client.secret.envName ); - const callbackHostName = this.#callbackUrl(authenticationMethod); + const callbackUrl = this.#buildCallbackUrl(authenticationMethod, url); const params: GrantTokenParams = { tokenUrl: authenticationMethod.config.token.url, clientId: getClientConfig.id, clientSecret: getClientConfig.secret, code, - callbackUrl: `${callbackHostName}/resources/connection/oauth2/callback`, + callbackUrl, requestedScopes: scopes, scopeSeparator: authenticationMethod.config.authorization.scopeSeparator, @@ -362,7 +366,6 @@ export class APIAuthenticationRepository { authenticationMethod.client.id.envName, authenticationMethod.client.secret.envName ); - const callbackHostName = this.#callbackUrl(authenticationMethod); const secretStore = new SecretStore( connection.dataReference.provider as SecretStoreProvider @@ -394,7 +397,6 @@ export class APIAuthenticationRepository { refreshUrl: authenticationMethod.config.refresh.url, clientId: getClientConfig.id, clientSecret: getClientConfig.secret, - callbackUrl: `${callbackHostName}/resources/connection/oauth2/callback`, requestedScopes: connection.scopes, scopeSeparator: authenticationMethod.config.authorization.scopeSeparator, @@ -522,10 +524,16 @@ export class APIAuthenticationRepository { }; } - #callbackUrl(authenticationMethod: ApiAuthenticationMethodOAuth2) { - return authenticationMethod.config.appHostEnvName - ? process.env[authenticationMethod.config.appHostEnvName] - : env.APP_ORIGIN; + #buildCallbackUrl( + authenticationMethod: ApiAuthenticationMethodOAuth2, + url: URL + ) { + return new URL( + `/resources/connection/oauth2/callback`, + authenticationMethod.config.appHostEnvName + ? process.env[authenticationMethod.config.appHostEnvName] + : url + ).href; } #getMetadataFromToken({ diff --git a/apps/webapp/app/services/externalApis/apiCatalog.server.ts b/apps/webapp/app/services/externalApis/apiCatalog.server.ts index 3274ae5a6..4ea6173ba 100644 --- a/apps/webapp/app/services/externalApis/apiCatalog.server.ts +++ b/apps/webapp/app/services/externalApis/apiCatalog.server.ts @@ -1,4 +1,5 @@ import { airtable } from "./apis/airtable"; +import { github } from "./apis/github"; import { slack } from "./apis/slack"; import type { ExternalApi } from "./types"; @@ -22,4 +23,4 @@ export class ApiCatalog { } } -export const apiCatalog = new ApiCatalog({ slack, airtable }); +export const apiCatalog = new ApiCatalog({ slack, airtable, github }); diff --git a/apps/webapp/app/services/externalApis/apis/github.ts b/apps/webapp/app/services/externalApis/apis/github.ts new file mode 100644 index 000000000..51d8f08fc --- /dev/null +++ b/apps/webapp/app/services/externalApis/apis/github.ts @@ -0,0 +1,269 @@ +import type { ExternalApi, ScopeAnnotation } from "../types"; + +const repoAnnotation: ScopeAnnotation = { + label: "Repo", + color: "#FFF067", +}; + +const webhookAnnotation: ScopeAnnotation = { + label: "Webhooks", + color: "#00FFA3", +}; + +const orgAnnotation: ScopeAnnotation = { + label: "Orgs", + color: "#FF00FF", +}; + +const keysAnnotation: ScopeAnnotation = { + label: "Keys", + color: "#FF00FF", +}; + +const userAnnotation: ScopeAnnotation = { + label: "User", + color: "#FF00FF", +}; + +export const github: ExternalApi = { + identifier: "github", + name: "GitHub", + authenticationMethods: { + oauth2: { + name: "OAuth", + type: "oauth2", + client: { + id: { + envName: "EXTERNAL_GITHUB_CLIENT_ID", + }, + secret: { + envName: "EXTERNAL_GITHUB_CLIENT_SECRET", + }, + }, + config: { + authorization: { + url: "https://github.com/login/oauth/authorize", + scopeSeparator: " ", + }, + token: { + url: "https://github.com/login/oauth/access_token", + metadata: { + accountPointer: "/team/name", + }, + }, + refresh: { + url: "https://github.com/login/oauth/authorize", + }, + }, + scopes: [ + { + name: "repo", + description: + "Grants full access to public and private repositories including read and write access to code, commit statuses, repository invitations, collaborators, deployment statuses, and repository webhooks. Note: In addition to repository related resources, the repo scope also grants access to manage organization-owned resources including projects, invitations, team memberships and webhooks. This scope also grants the ability to manage projects owned by users.", + annotations: [repoAnnotation], + }, + + { + name: "repo:status", + description: + "Grants read/write access to commit statuses in public and private repositories. This scope is only necessary to grant other users or services access to private repository commit statuses without granting access to the code.", + annotations: [repoAnnotation], + }, + + { + name: "repo_deployment", + description: + "Grants access to deployment statuses for public and private repositories. This scope is only necessary to grant other users or services access to deployment statuses, without granting access to the code.", + annotations: [repoAnnotation], + }, + + { + name: "public_repo", + description: + "Limits access to public repositories. That includes read/write access to code, commit statuses, repository projects, collaborators, and deployment statuses for public repositories and organizations. Also required for starring public repositories.", + annotations: [repoAnnotation], + }, + + { + name: "repo:invite", + description: + "Grants accept/decline abilities for invitations to collaborate on a repository. This scope is only necessary to grant other users or services access to invites without granting access to the code.", + annotations: [repoAnnotation], + }, + + { + name: "delete_repo", + description: "Grants access to delete adminable repositories.", + annotations: [repoAnnotation], + }, + + { + name: "security_events", + description: + "Grants read and write access to security events in the code scanning API. This scope is only necessary to grant other users or services access to security events without granting access to the code.", + }, + + { + name: "admin:repo_hook", + description: + "Grants read, write, ping, and delete access to repository hooks in public or private repositories. The repo and public_repo scopes grant full access to repositories, including repository hooks. Use the admin:repo_hook scope to limit access to only repository hooks.", + defaultChecked: true, + annotations: [webhookAnnotation], + }, + { + name: "write:repo_hook", + description: + "Grants read, write, and ping access to hooks in public or private repositories.", + annotations: [webhookAnnotation], + }, + { + name: "read:repo_hook", + description: + "Grants read and ping access to hooks in public or private repositories.", + annotations: [webhookAnnotation], + }, + + { + name: "admin:org", + description: + "Fully manage the organization and its teams, projects, and memberships.", + annotations: [orgAnnotation], + }, + { + name: "write:org", + description: + "Read and write access to organization membership, organization projects, and team membership.", + annotations: [orgAnnotation], + }, + + { + name: "read:org", + description: + "Read-only access to organization membership, organization projects, and team membership.", + annotations: [orgAnnotation], + }, + + { + name: "admin:public_key", + description: "Fully manage public keys.", + annotations: [keysAnnotation], + }, + + { + name: "write:public_key", + description: "Create, list, and view details for public keys.", + annotations: [keysAnnotation], + }, + { + name: "read:public_key", + description: "List and view details for public keys.", + annotations: [keysAnnotation], + }, + + { + name: "admin:org_hook", + description: + "Grants read, write, ping, and delete access to organization hooks. Note: OAuth tokens will only be able to perform these actions on organization hooks which were created by the OAuth App. Personal access tokens will only be able to perform these actions on organization hooks created by a user.", + annotations: [orgAnnotation, webhookAnnotation], + }, + + { + name: "gist", + description: "Grants write access to gists.", + }, + { + name: "notifications", + description: + "Grants read access to a user's notifications, mark as read access to threads, watch and unwatch access to a repository, and read, write, and delete access to thread subscriptions.", + }, + { + name: "user", + description: + " Grants read/write access to profile info only. Note that this scope includes user:email and user:follow.", + annotations: [userAnnotation], + }, + { + name: "read:user", + description: "Grants read access to a user's profile data.", + annotations: [userAnnotation], + }, + + { + name: "user:email", + description: "Grants read access to a user's email addresses.", + annotations: [userAnnotation], + }, + { + name: "user:follow", + description: "Grants access to follow or unfollow other users.", + annotations: [userAnnotation], + }, + { + name: "project", + description: + "Grants read/write access to user and organization projects.", + }, + + { + name: "read:project", + description: + "Grants read only access to user and organization projects.", + }, + + { + name: "write:discussion", + description: "Allows read and write access for team discussions.", + }, + + { + name: "read:discussion", + description: "Allows read access for team discussions.", + }, + + { + name: "write:packages", + description: + "Grants access to upload or publish a package in GitHub Packages.", + }, + + { + name: "read:packages", + description: + "Grants access to download or install packages from GitHub Packages.", + }, + + { + name: "delete:packages", + description: "Grants access to delete packages from GitHub Packages.", + }, + + { + name: "admin:gpg_key", + description: "Fully manage GPG keys.", + }, + + { + name: "write:gpg_key", + description: "Create, list, and view details for GPG keys.", + }, + + { + name: "read:gpg_key", + description: "List and view details for GPG keys.", + }, + + { + name: "codespace", + description: + "Grants the ability to create and manage codespaces. Codespaces can expose a GITHUB_TOKEN which may have a different set of scopes", + }, + + { + name: "workflow", + description: + "Grants the ability to add and update GitHub Actions workflow files. Workflow files can be committed without this scope if the same file (with both the same path and contents) exists on another branch in the same repository. Workflow files can expose GITHUB_TOKEN which may have a different set of scopes.", + }, + ], + }, + }, +}; diff --git a/apps/webapp/app/services/externalApis/oauth2.server.ts b/apps/webapp/app/services/externalApis/oauth2.server.ts index 751f928dc..9332ee86c 100644 --- a/apps/webapp/app/services/externalApis/oauth2.server.ts +++ b/apps/webapp/app/services/externalApis/oauth2.server.ts @@ -155,7 +155,6 @@ export async function refreshOAuth2Token({ refreshUrl, clientId, clientSecret, - callbackUrl, requestedScopes, scopeSeparator, token: { accessToken, refreshToken, expiresAt }, diff --git a/apps/webapp/app/services/externalApis/types.ts b/apps/webapp/app/services/externalApis/types.ts index 33c8bd310..e80495dff 100644 --- a/apps/webapp/app/services/externalApis/types.ts +++ b/apps/webapp/app/services/externalApis/types.ts @@ -47,7 +47,6 @@ export type RefreshTokenParams = { refreshUrl: string; clientId: string; clientSecret: string; - callbackUrl: string; requestedScopes: string[]; scopeSeparator: string; token: { accessToken: string; refreshToken: string; expiresAt: Date }; diff --git a/examples/nextjs-example/src/pages/api/trigger.ts b/examples/nextjs-example/src/pages/api/trigger.ts index 47d9aee4b..ae24b30f7 100644 --- a/examples/nextjs-example/src/pages/api/trigger.ts +++ b/examples/nextjs-example/src/pages/api/trigger.ts @@ -9,7 +9,7 @@ import { slack } from "@trigger.dev/slack"; import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; -const gh = github({ token: process.env.GITHUB_TOKEN! }); +const gh = github({ id: "github" }); const sl = slack({ id: "my-slack-new" }); const client = new TriggerClient("nextjs", { @@ -32,7 +32,7 @@ new Job({ repo: "ericallam/basic-starter-100k", }), run: async (event, io, ctx) => { - await io.sl.postMessage("Slack 📝", { + const slackMessage = await io.sl.postMessage("Slack 📝", { text: `New Issue opened: ${event.issue.html_url}`, channel: "C04GWUTDC3W", }); @@ -82,6 +82,26 @@ new Job({ }, }).registerWith(client); +new Job({ + id: "notify-slack-on-new-comments", + name: "Notify Slack on new GitHub comments", + version: "0.1.1", + logLevel: "debug", + connections: { + gh, + sl, + }, + trigger: gh.triggers.onIssueComment({ + repo: "ericallam/basic-starter-100k", + }), + run: async (event, io, ctx) => { + await io.sl.postMessage("Slack 📝", { + text: `New Comment on Issue: ${event.comment.html_url}`, + channel: "C04GWUTDC3W", + }); + }, +}).registerWith(client); + // TODO: Support parameterized jobs // Example: // const job = new Job({}); diff --git a/integrations/github/src/clientFactory.ts b/integrations/github/src/clientFactory.ts new file mode 100644 index 000000000..d8811109d --- /dev/null +++ b/integrations/github/src/clientFactory.ts @@ -0,0 +1,8 @@ +import { ClientFactory } from "@trigger.dev/sdk"; +import { Octokit } from "octokit"; + +export const clientFactory: ClientFactory = (auth) => { + return new Octokit({ + auth: auth.accessToken, + }); +}; diff --git a/integrations/github/src/index.ts b/integrations/github/src/index.ts index 2f35bce67..d94d1d411 100644 --- a/integrations/github/src/index.ts +++ b/integrations/github/src/index.ts @@ -6,6 +6,7 @@ import { import type { Connection, EventFilter } from "@trigger.dev/sdk"; import { ExternalSourceEventTrigger, Trigger } from "@trigger.dev/sdk/triggers"; import { Octokit } from "octokit"; +import { clientFactory } from "./clientFactory"; import { metadata } from "./metadata"; import { repositoryWebhookSource } from "./sources"; import { @@ -14,6 +15,7 @@ import { createIssueCommentWithReaction, getRepo, } from "./tasks"; +import { ClientOptions } from "./types"; const tasks = { createIssue, @@ -22,13 +24,55 @@ const tasks = { createIssueCommentWithReaction, }; -function createTriggers(client: Octokit) { +export type GitHubConnectionOptions = + | { + token: string; + } + | { + id: string; + }; + +export const github = (options: GitHubConnectionOptions) => { + if ("token" in options) { + const client = new Octokit({ + auth: options.token, + }); + + return { + metadata, + tasks, + usesLocalAuth: true, + client, + triggers: createTriggers({ usesLocalAuth: true, octokit: client }), + } satisfies Connection; + } + return { - onIssue: buildRepoWebhookTrigger("On Issue", "issues", client), + id: options.id, + metadata, + tasks, + usesLocalAuth: false, + clientFactory, + triggers: createTriggers( + { usesLocalAuth: false, clientFactory }, + options.id + ), + } satisfies Connection; +}; +0; +function createTriggers(client: ClientOptions, id?: string) { + return { + onIssue: buildRepoWebhookTrigger( + "On Issue", + "issues", + client, + id + ), onIssueOpened: buildRepoWebhookTrigger( "On Issue Opened", "issues", client, + id, { action: ["opened"], } @@ -36,29 +80,17 @@ function createTriggers(client: Octokit) { onIssueComment: buildRepoWebhookTrigger( "On Issue Comment", "issue_comment", - client + client, + id ), }; } -export const github = (options: { token: string }) => { - const client = new Octokit({ - auth: options.token, - }); - - return { - metadata, - tasks, - usesLocalAuth: true, - client, - triggers: createTriggers(client), - } satisfies Connection; -}; - function buildRepoWebhookTrigger( title: string, event: string, - client: Octokit, + client: ClientOptions, + id?: string, filter?: EventFilter ): (params: { repo: string }) => Trigger { return (params: { repo: string }) => @@ -79,7 +111,8 @@ function buildRepoWebhookTrigger( repo: params.repo, events: [event], }, - client + client, + id ), eventRule: { event, diff --git a/integrations/github/src/sources.ts b/integrations/github/src/sources.ts index 35313f23c..34738c51f 100644 --- a/integrations/github/src/sources.ts +++ b/integrations/github/src/sources.ts @@ -1,7 +1,7 @@ import { Webhooks } from "@octokit/webhooks"; import { ExternalSource } from "@trigger.dev/sdk/externalSource"; -import { Octokit } from "octokit"; import { metadata } from "./metadata"; +import { ClientOptions } from "./types"; type WebhookData = { id: number; @@ -27,19 +27,25 @@ export function repositoryWebhookSource( events: string[]; secret?: string; }, - client: Octokit + client: ClientOptions, + id?: string ) { // Create a stable key for this source so we only register it once const key = `github.repo.${params.repo}.webhook`; return new ExternalSource("http", metadata, { - usesLocalAuth: true, + id, + usesLocalAuth: client.usesLocalAuth, key, register: async (triggerClient, auth) => { if (!auth) { throw new Error("No auth provided"); } + const octokit = client.usesLocalAuth + ? client.octokit + : client.clientFactory(auth); + const httpSource = await triggerClient.registerHttpSource({ key, }); @@ -65,7 +71,7 @@ export function repositoryWebhookSource( if (missingEvents.length > 0) { // We need to update the webhook to add the new events and then return const { data: newWebhookData } = - await client.rest.repos.updateWebhook({ + await octokit.rest.repos.updateWebhook({ owner, repo, hook_id: existingData.id, @@ -85,7 +91,7 @@ export function repositoryWebhookSource( return; } - const { data: webhooks } = await client.rest.repos.listWebhooks({ + const { data: webhooks } = await octokit.rest.repos.listWebhooks({ owner, repo, }); @@ -97,7 +103,7 @@ export function repositoryWebhookSource( const secret = params.secret || Math.random().toString(36).slice(2); if (existingWebhook && existingWebhook.active) { - await client.rest.repos.updateWebhook({ + await octokit.rest.repos.updateWebhook({ owner, repo, hook_id: existingWebhook.id, @@ -125,7 +131,7 @@ export function repositoryWebhookSource( ); } - const { data: webhook } = await client.rest.repos.createWebhook({ + const { data: webhook } = await octokit.rest.repos.createWebhook({ owner, repo, events: params.events, diff --git a/integrations/github/src/types.ts b/integrations/github/src/types.ts new file mode 100644 index 000000000..a2fa1663a --- /dev/null +++ b/integrations/github/src/types.ts @@ -0,0 +1,12 @@ +import { ClientFactory } from "@trigger.dev/sdk"; +import { Octokit } from "octokit"; + +export type ClientOptions = + | { + usesLocalAuth: true; + octokit: Octokit; + } + | { + usesLocalAuth: false; + clientFactory: ClientFactory; + }; diff --git a/packages/trigger-sdk/src/connections.ts b/packages/trigger-sdk/src/connections.ts index 183afb20f..13b057da7 100644 --- a/packages/trigger-sdk/src/connections.ts +++ b/packages/trigger-sdk/src/connections.ts @@ -13,15 +13,23 @@ export type ClientFactory = (auth: ConnectionAuth) => TClientType; export type Connection< TClientType, TTasks extends Record> -> = { - usesLocalAuth: boolean; - metadata: ConnectionMetadata; - clientFactory?: ClientFactory; - client?: TClientType; - tasks?: TTasks; - id?: string; - [key: string]: any; -}; +> = + | { + usesLocalAuth: true; + metadata: ConnectionMetadata; + client: TClientType; + tasks?: TTasks; + id?: string; + [key: string]: any; + } + | { + usesLocalAuth: false; + metadata: ConnectionMetadata; + clientFactory: ClientFactory; + tasks?: TTasks; + id?: string; + [key: string]: any; + }; export type ConnectionEvent = { trigger: (params: TParams) => Trigger; @@ -64,19 +72,23 @@ type ExtractTasks< [key in keyof TTasks]: ExtractRunFunction; }; -type ExtractClient< - TClientFactory extends ClientFactory | undefined, - TClient extends any | undefined -> = TClientFactory extends ClientFactory - ? { client: TClientType } - : TClient extends any - ? { client: TClient } - : never; +type ExtractClient> = + TConnection extends { + usesLocalAuth: true; + client: infer TClient; + } + ? { client: TClient } + : TConnection extends { + usesLocalAuth: false; + clientFactory: ClientFactory; + } + ? { client: TClientType } + : never; type ExtractConnection> = ExtractTasks< TConnection["tasks"] > & - ExtractClient; + ExtractClient; type ExtractConnections< TConnections extends Record> diff --git a/packages/trigger-sdk/src/externalSource.ts b/packages/trigger-sdk/src/externalSource.ts index 2c7fab834..e107c2f4d 100644 --- a/packages/trigger-sdk/src/externalSource.ts +++ b/packages/trigger-sdk/src/externalSource.ts @@ -51,6 +51,7 @@ export type HandlerFunction< export type ExternalSourceOptions = { key: string; usesLocalAuth: boolean; + id?: string; register: ( triggerClient: TriggerClient, auth?: ConnectionAuth @@ -65,6 +66,7 @@ export type ExternalSourceOptions = { export interface AnyExternalSource { key: string; usesLocalAuth: boolean; + id?: string; connection: ConnectionMetadata; channel: ChannelNames; handler: ( @@ -94,6 +96,10 @@ export class ExternalSource this.connection = connection; } + get id() { + return this.options.id; + } + get key() { return this.options.key; } diff --git a/packages/trigger-sdk/src/triggers.ts b/packages/trigger-sdk/src/triggers.ts index 0433b2d76..c2a626c35 100644 --- a/packages/trigger-sdk/src/triggers.ts +++ b/packages/trigger-sdk/src/triggers.ts @@ -92,6 +92,7 @@ export class ExternalSourceEventTrigger implements Trigger { connection: { metadata: this.options.source.connection, usesLocalAuth: this.options.source.usesLocalAuth, + id: this.options.source.id, }, eventRule: this.options.eventRule, };