diff --git a/.changeset/tiny-pillows-dream.md b/.changeset/tiny-pillows-dream.md new file mode 100644 index 0000000000..57c6e5ce1f --- /dev/null +++ b/.changeset/tiny-pillows-dream.md @@ -0,0 +1,16 @@ +--- +"@trigger.dev/airtable": patch +"@trigger.dev/sendgrid": patch +"@trigger.dev/supabase": patch +"@trigger.dev/typeform": patch +"@trigger.dev/sdk": patch +"@trigger.dev/github": patch +"@trigger.dev/openai": patch +"@trigger.dev/resend": patch +"@trigger.dev/stripe": patch +"@trigger.dev/plain": patch +"@trigger.dev/slack": patch +"@trigger.dev/core": patch +--- + +Add support for Bring Your Own Auth diff --git a/.vscode/launch.json b/.vscode/launch.json index 21288b65a1..37375ca33d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -19,6 +19,15 @@ "name": "Chrome webapp", "url": "http://localhost:3030", "webRoot": "${workspaceFolder}/apps/webapp/app" + }, + { + "type": "node-terminal", + "request": "launch", + "name": "Debug BYO Auth", + "command": "pnpm run byo-auth", + "envFile": "${workspaceFolder}/references/job-catalog/.env", + "cwd": "${workspaceFolder}/references/job-catalog", + "sourceMaps": true } ] } diff --git a/apps/webapp/app/components/jobs/JobsTable.tsx b/apps/webapp/app/components/jobs/JobsTable.tsx index 7b15074d25..4884e388ee 100644 --- a/apps/webapp/app/components/jobs/JobsTable.tsx +++ b/apps/webapp/app/components/jobs/JobsTable.tsx @@ -197,6 +197,7 @@ function classForJobStatus(status: JobRunStatus) { case "TIMED_OUT": case "WAITING_ON_CONNECTIONS": case "PENDING": + case "UNRESOLVED_AUTH": return "text-rose-500"; default: return ""; diff --git a/apps/webapp/app/components/run/RunOverview.tsx b/apps/webapp/app/components/run/RunOverview.tsx index 7abceb941f..79bdfe22b4 100644 --- a/apps/webapp/app/components/run/RunOverview.tsx +++ b/apps/webapp/app/components/run/RunOverview.tsx @@ -167,7 +167,13 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps diff --git a/apps/webapp/app/components/run/TriggerDetail.tsx b/apps/webapp/app/components/run/TriggerDetail.tsx index 006f3b5769..7f5dd41e89 100644 --- a/apps/webapp/app/components/run/TriggerDetail.tsx +++ b/apps/webapp/app/components/run/TriggerDetail.tsx @@ -45,6 +45,13 @@ export function TriggerDetail({ /> )} + {trigger.externalAccount && ( + + )}
diff --git a/apps/webapp/app/components/runs/RunStatuses.tsx b/apps/webapp/app/components/runs/RunStatuses.tsx index 0a2e16827d..4628a1ba44 100644 --- a/apps/webapp/app/components/runs/RunStatuses.tsx +++ b/apps/webapp/app/components/runs/RunStatuses.tsx @@ -1,15 +1,14 @@ -import type { JobRunExecution, JobRunStatus } from "@trigger.dev/database"; +import { NoSymbolIcon } from "@heroicons/react/20/solid"; import { CheckCircleIcon, ClockIcon, ExclamationTriangleIcon, - StopIcon, WrenchIcon, XCircleIcon, } from "@heroicons/react/24/solid"; +import type { JobRunStatus } from "@trigger.dev/database"; import { cn } from "~/utils/cn"; import { Spinner } from "../primitives/Spinner"; -import { HandRaisedIcon, NoSymbolIcon } from "@heroicons/react/20/solid"; export function hasFinished(status: JobRunStatus): boolean { return ( @@ -17,7 +16,8 @@ export function hasFinished(status: JobRunStatus): boolean { status === "FAILURE" || status === "ABORTED" || status === "TIMED_OUT" || - status === "CANCELED" + status === "CANCELED" || + status === "UNRESOLVED_AUTH" ); } @@ -48,6 +48,8 @@ export function RunStatusIcon({ status, className }: { status: JobRunStatus; cla return ; case "TIMED_OUT": return ; + case "UNRESOLVED_AUTH": + return ; case "WAITING_ON_CONNECTIONS": return ; case "ABORTED": @@ -63,26 +65,25 @@ export type RunBasicStatus = "WAITING" | "PENDING" | "RUNNING" | "COMPLETED" | " export function runBasicStatus(status: JobRunStatus): RunBasicStatus { switch (status) { - case "SUCCESS": - return "COMPLETED"; + case "WAITING_ON_CONNECTIONS": + case "QUEUED": + case "PREPROCESSING": case "PENDING": return "PENDING"; case "STARTED": return "RUNNING"; - case "QUEUED": - return "PENDING"; case "FAILURE": - return "FAILED"; case "TIMED_OUT": - return "FAILED"; - case "WAITING_ON_CONNECTIONS": - return "PENDING"; - case "ABORTED": - return "FAILED"; - case "PREPROCESSING": - return "PENDING"; + case "UNRESOLVED_AUTH": case "CANCELED": + case "ABORTED": return "FAILED"; + case "SUCCESS": + return "COMPLETED"; + default: { + const _exhaustiveCheck: never = status; + throw new Error(`Non-exhaustive match for value: ${status}`); + } } } @@ -108,6 +109,12 @@ export function runStatusTitle(status: JobRunStatus): string { return "Preprocessing"; case "CANCELED": return "Canceled"; + case "UNRESOLVED_AUTH": + return "Unresolved auth"; + default: { + const _exhaustiveCheck: never = status; + throw new Error(`Non-exhaustive match for value: ${status}`); + } } } @@ -122,6 +129,7 @@ export function runStatusClassNameColor(status: JobRunStatus): string { case "QUEUED": return "text-amber-300"; case "FAILURE": + case "UNRESOLVED_AUTH": return "text-rose-500"; case "TIMED_OUT": return "text-amber-300"; diff --git a/apps/webapp/app/models/runConnection.server.ts b/apps/webapp/app/models/runConnection.server.ts index 2ddd2bef56..732781ecc7 100644 --- a/apps/webapp/app/models/runConnection.server.ts +++ b/apps/webapp/app/models/runConnection.server.ts @@ -16,7 +16,7 @@ export async function resolveRunConnections( const result: Record = {}; for (const connection of connections) { - if (connection.integration.authSource === "LOCAL") { + if (connection.integration.authSource !== "HOSTED") { continue; } diff --git a/apps/webapp/app/presenters/IntegrationClientPresenter.server.ts b/apps/webapp/app/presenters/IntegrationClientPresenter.server.ts index 3dea16ff00..901d79b171 100644 --- a/apps/webapp/app/presenters/IntegrationClientPresenter.server.ts +++ b/apps/webapp/app/presenters/IntegrationClientPresenter.server.ts @@ -120,8 +120,12 @@ export class IntegrationClientPresenter { icon: integration.definition.icon, }, authMethod: { - type: integration.authMethod?.type ?? "local", - name: integration.authMethod?.name ?? "Local Auth", + type: + integration.authMethod?.type ?? integration.authSource === "RESOLVER" ? "local" : "local", + name: + integration.authMethod?.name ?? integration.authSource === "RESOLVER" + ? "Auth Resolver" + : "Local Auth", }, help, }; diff --git a/apps/webapp/app/presenters/IntegrationsPresenter.server.ts b/apps/webapp/app/presenters/IntegrationsPresenter.server.ts index 45fe635476..c38449a175 100644 --- a/apps/webapp/app/presenters/IntegrationsPresenter.server.ts +++ b/apps/webapp/app/presenters/IntegrationsPresenter.server.ts @@ -125,8 +125,9 @@ export class IntegrationsPresenter { name: c.definition.name, }, authMethod: { - type: c.authMethod?.type ?? "local", - name: c.authMethod?.name ?? "Local Only", + type: c.authMethod?.type ?? c.authSource === "RESOLVER" ? "resolver" : "local", + name: + c.authMethod?.name ?? c.authSource === "RESOLVER" ? "Auth Resolver" : "Local Only", }, authSource: c.authSource, setupStatus: c.setupStatus, diff --git a/apps/webapp/app/presenters/RunPresenter.server.ts b/apps/webapp/app/presenters/RunPresenter.server.ts index a3e380aa1a..369ce882e3 100644 --- a/apps/webapp/app/presenters/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/RunPresenter.server.ts @@ -115,6 +115,11 @@ export class RunPresenter { payload: true, timestamp: true, deliveredAt: true, + externalAccount: { + select: { + identifier: true, + }, + }, }, }, tasks: { diff --git a/apps/webapp/app/presenters/TestJobPresenter.server.ts b/apps/webapp/app/presenters/TestJobPresenter.server.ts index 3e0a7a8fd6..3028199e2b 100644 --- a/apps/webapp/app/presenters/TestJobPresenter.server.ts +++ b/apps/webapp/app/presenters/TestJobPresenter.server.ts @@ -39,6 +39,15 @@ export class TestJobPresenter { payload: true, }, }, + integrations: { + select: { + integration: { + select: { + authSource: true, + }, + }, + }, + }, }, }, environment: { @@ -99,6 +108,9 @@ export class TestJobPresenter { ...example, payload: JSON.stringify(example.payload, exampleReplacer, 2), })), + hasAuthResolver: alias.version.integrations.some( + (i) => i.integration.authSource === "RESOLVER" + ), })), hasTestRuns: job._count.runs > 0, }; diff --git a/apps/webapp/app/presenters/TriggerDetailsPresenter.server.ts b/apps/webapp/app/presenters/TriggerDetailsPresenter.server.ts index 127f734390..d4b6fa6e38 100644 --- a/apps/webapp/app/presenters/TriggerDetailsPresenter.server.ts +++ b/apps/webapp/app/presenters/TriggerDetailsPresenter.server.ts @@ -22,6 +22,11 @@ export class TriggerDetailsPresenter { payload: true, timestamp: true, deliveredAt: true, + externalAccount: { + select: { + identifier: true, + }, + }, }, }, }, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.test/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.test/route.tsx index 37cca5fa2e..21917e7bfe 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.test/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.test/route.tsx @@ -1,4 +1,4 @@ -import { useForm } from "@conform-to/react"; +import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { PopoverTrigger } from "@radix-ui/react-popover"; import { Form, useActionData, useSubmit } from "@remix-run/react"; @@ -14,6 +14,9 @@ import { Button, ButtonContent } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { FormError } from "~/components/primitives/FormError"; import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; import { Popover, PopoverContent } from "~/components/primitives/Popover"; import { Select, @@ -69,6 +72,7 @@ const schema = z.object({ }), environmentId: z.string(), versionId: z.string(), + accountId: z.string().optional(), }); //todo save the chosen environment to a cookie (for that user), use it to default the env dropdown @@ -84,11 +88,7 @@ export const action: ActionFunction = async ({ request, params }) => { } const testService = new TestJobService(); - const run = await testService.call({ - environmentId: submission.value.environmentId, - payload: submission.value.payload, - versionId: submission.value.versionId, - }); + const run = await testService.call(submission.value); if (!run) { return redirectBackWithErrorMessage( @@ -124,6 +124,7 @@ export default function Page() { const [defaultJson, setDefaultJson] = useState(startingJson); const currentJson = useRef(defaultJson); const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(environments[0].id); + const [currentAccountId, setCurrentAccountId] = useState(undefined); const selectedEnvironment = environments.find((e) => e.id === selectedEnvironmentId); @@ -139,6 +140,7 @@ export default function Page() { payload: currentJson.current, environmentId: selectedEnvironmentId, versionId: selectedEnvironment?.versionId ?? "", + ...(currentAccountId ? { accountId: currentAccountId } : {}), }, { action: "", @@ -147,10 +149,10 @@ export default function Page() { ); e.preventDefault(); }, - [currentJson, selectedEnvironmentId] + [currentJson, selectedEnvironmentId, currentAccountId] ); - const [form, { environmentId, payload }] = useForm({ + const [form, { environmentId, payload, accountId }] = useForm({ id: "test-job", lastSubmission, onValidate({ formData }) { @@ -234,15 +236,32 @@ export default function Page() {
-
- (currentJson.current = v)} - minHeight="150px" - /> -
+ + +
+ (currentJson.current = v)} + minHeight="150px" + /> +
+
+ + {selectedEnvironment?.hasAuthResolver && ( + + + setCurrentAccountId(e.target.value)} + /> + {accountId.error} + + )}
{payload.error ? ( {payload.error} diff --git a/apps/webapp/app/routes/api.v1.accounts.$accountId.connections.$clientSlug.ts b/apps/webapp/app/routes/api.v1.accounts.$accountId.connections.$clientSlug.ts index 5763ce8a37..e9f8d39b6f 100644 --- a/apps/webapp/app/routes/api.v1.accounts.$accountId.connections.$clientSlug.ts +++ b/apps/webapp/app/routes/api.v1.accounts.$accountId.connections.$clientSlug.ts @@ -81,13 +81,19 @@ class CreateExternalConnectionService { environment: AuthenticatedEnvironment, payload: CreateExternalConnectionBody ) { - const externalAccount = await this.#prismaClient.externalAccount.findUniqueOrThrow({ + const externalAccount = await this.#prismaClient.externalAccount.upsert({ where: { environmentId_identifier: { environmentId: environment.id, identifier: accountIdentifier, }, }, + create: { + environmentId: environment.id, + organizationId: environment.organizationId, + identifier: accountIdentifier, + }, + update: {}, }); const integration = await this.#prismaClient.integration.findUniqueOrThrow({ diff --git a/apps/webapp/app/services/events/ingestSendEvent.server.ts b/apps/webapp/app/services/events/ingestSendEvent.server.ts index a4130bfdbc..f2c5e79335 100644 --- a/apps/webapp/app/services/events/ingestSendEvent.server.ts +++ b/apps/webapp/app/services/events/ingestSendEvent.server.ts @@ -7,10 +7,7 @@ import { logger } from "../logger.server"; export class IngestSendEvent { #prismaClient: PrismaClientOrTransaction; - constructor( - prismaClient: PrismaClientOrTransaction = prisma, - private deliverEvents = true - ) { + constructor(prismaClient: PrismaClientOrTransaction = prisma, private deliverEvents = true) { this.#prismaClient = prismaClient; } @@ -41,13 +38,19 @@ export class IngestSendEvent { this.#prismaClient, async (tx) => { const externalAccount = options?.accountId - ? await tx.externalAccount.findUniqueOrThrow({ + ? await tx.externalAccount.upsert({ where: { environmentId_identifier: { environmentId: environment.id, identifier: options.accountId, }, }, + create: { + environmentId: environment.id, + organizationId: environment.organizationId, + identifier: options.accountId, + }, + update: {}, }) : undefined; diff --git a/apps/webapp/app/services/jobs/registerJob.server.ts b/apps/webapp/app/services/jobs/registerJob.server.ts index c66a6e1572..401c69c8aa 100644 --- a/apps/webapp/app/services/jobs/registerJob.server.ts +++ b/apps/webapp/app/services/jobs/registerJob.server.ts @@ -4,7 +4,14 @@ import { SCHEDULED_EVENT, TriggerMetadata, } from "@trigger.dev/core"; -import type { Endpoint, Integration, Job, JobIntegration, JobVersion } from "@trigger.dev/database"; +import type { + Endpoint, + Integration, + Job, + JobIntegration, + JobIntegrationPayload, + JobVersion, +} from "@trigger.dev/database"; import { DEFAULT_MAX_CONCURRENT_RUNS } from "~/consts"; import type { PrismaClient } from "~/db.server"; import { prisma } from "~/db.server"; @@ -62,83 +69,7 @@ export class RegisterJobService { }); if (!integration) { - if (jobIntegration.authSource === "LOCAL") { - integration = await this.#prismaClient.integration.upsert({ - where: { - organizationId_slug: { - organizationId: environment.organizationId, - slug: jobIntegration.id, - }, - }, - create: { - slug: jobIntegration.id, - title: jobIntegration.metadata.name, - authSource: "LOCAL", - connectionType: "DEVELOPER", - organization: { - connect: { - id: environment.organizationId, - }, - }, - definition: { - connectOrCreate: { - where: { - id: jobIntegration.metadata.id, - }, - create: { - id: jobIntegration.metadata.id, - name: jobIntegration.metadata.name, - instructions: jobIntegration.metadata.instructions, - }, - }, - }, - }, - update: { - title: jobIntegration.metadata.name, - authSource: "LOCAL", - connectionType: "DEVELOPER", - definition: { - connectOrCreate: { - where: { - id: jobIntegration.metadata.id, - }, - create: { - id: jobIntegration.metadata.id, - name: jobIntegration.metadata.name, - instructions: jobIntegration.metadata.instructions, - }, - }, - }, - }, - }); - } else { - integration = await this.#prismaClient.integration.create({ - data: { - slug: jobIntegration.id, - title: jobIntegration.id, - authSource: "HOSTED", - setupStatus: "MISSING_FIELDS", - connectionType: "DEVELOPER", - organization: { - connect: { - id: environment.organizationId, - }, - }, - definition: { - connectOrCreate: { - where: { - id: jobIntegration.metadata.id, - }, - create: { - id: jobIntegration.metadata.id, - name: jobIntegration.metadata.name, - instructions: jobIntegration.metadata.instructions, - }, - }, - }, - }, - }); - } + integration = await this.#upsertIntegrationForJobIntegration(environment, jobIntegration); } integrations.set(jobIntegration.id, integration); @@ -472,6 +403,7 @@ export class RegisterJobService { key: job.id, dispatcher: eventDispatcher, schedule: trigger.schedule, + organizationId: job.organizationId, }); break; @@ -479,6 +411,145 @@ export class RegisterJobService { } } + async #upsertIntegrationForJobIntegration( + environment: AuthenticatedEnvironment, + jobIntegration: IntegrationConfig + ): Promise { + switch (jobIntegration.authSource) { + case "LOCAL": { + return await this.#prismaClient.integration.upsert({ + where: { + organizationId_slug: { + organizationId: environment.organizationId, + slug: jobIntegration.id, + }, + }, + create: { + slug: jobIntegration.id, + title: jobIntegration.metadata.name, + authSource: "LOCAL", + connectionType: "DEVELOPER", + organization: { + connect: { + id: environment.organizationId, + }, + }, + definition: { + connectOrCreate: { + where: { + id: jobIntegration.metadata.id, + }, + create: { + id: jobIntegration.metadata.id, + name: jobIntegration.metadata.name, + instructions: jobIntegration.metadata.instructions, + }, + }, + }, + }, + update: { + title: jobIntegration.metadata.name, + authSource: "LOCAL", + connectionType: "DEVELOPER", + definition: { + connectOrCreate: { + where: { + id: jobIntegration.metadata.id, + }, + create: { + id: jobIntegration.metadata.id, + name: jobIntegration.metadata.name, + instructions: jobIntegration.metadata.instructions, + }, + }, + }, + }, + }); + } + case "HOSTED": { + return await this.#prismaClient.integration.create({ + data: { + slug: jobIntegration.id, + title: jobIntegration.id, + authSource: "HOSTED", + setupStatus: "MISSING_FIELDS", + connectionType: "DEVELOPER", + organization: { + connect: { + id: environment.organizationId, + }, + }, + definition: { + connectOrCreate: { + where: { + id: jobIntegration.metadata.id, + }, + create: { + id: jobIntegration.metadata.id, + name: jobIntegration.metadata.name, + instructions: jobIntegration.metadata.instructions, + }, + }, + }, + }, + }); + } + case "RESOLVER": { + return await this.#prismaClient.integration.upsert({ + where: { + organizationId_slug: { + organizationId: environment.organizationId, + slug: jobIntegration.id, + }, + }, + create: { + slug: jobIntegration.id, + title: jobIntegration.metadata.name, + authSource: "RESOLVER", + connectionType: "EXTERNAL", + organization: { + connect: { + id: environment.organizationId, + }, + }, + definition: { + connectOrCreate: { + where: { + id: jobIntegration.metadata.id, + }, + create: { + id: jobIntegration.metadata.id, + name: jobIntegration.metadata.name, + instructions: jobIntegration.metadata.instructions, + }, + }, + }, + }, + update: { + title: jobIntegration.metadata.name, + authSource: "RESOLVER", + connectionType: "EXTERNAL", + definition: { + connectOrCreate: { + where: { + id: jobIntegration.metadata.id, + }, + create: { + id: jobIntegration.metadata.id, + name: jobIntegration.metadata.name, + instructions: jobIntegration.metadata.instructions, + }, + }, + }, + }, + }); + } + default: { + assertExhaustive(jobIntegration.authSource); + } + } + } + async #upsertJobIntegration( job: Job & { integrations: Array; @@ -572,3 +643,7 @@ export class RegisterJobService { }); } } + +function assertExhaustive(x: never): never { + throw new Error("Unexpected object: " + x); +} diff --git a/apps/webapp/app/services/jobs/testJob.server.ts b/apps/webapp/app/services/jobs/testJob.server.ts index 2b154cc0e6..ab95d10856 100644 --- a/apps/webapp/app/services/jobs/testJob.server.ts +++ b/apps/webapp/app/services/jobs/testJob.server.ts @@ -13,10 +13,12 @@ export class TestJobService { environmentId, versionId, payload, + accountId, }: { environmentId: string; versionId: string; - payload: any; + payload?: any; + accountId?: string; }) { return await $transaction( this.#prismaClient, @@ -41,10 +43,27 @@ export class TestJobService { }, }); + const externalAccount = accountId + ? await tx.externalAccount.upsert({ + where: { + environmentId_identifier: { + environmentId: environment.id, + identifier: accountId, + }, + }, + create: { + environmentId: environment.id, + organizationId: environment.organizationId, + identifier: accountId, + }, + update: {}, + }) + : undefined; + const event = EventSpecificationSchema.parse(version.eventSpecification); const eventName = Array.isArray(event.name) ? event.name[0] : event.name; - const eventLog = await this.#prismaClient.eventRecord.create({ + const eventLog = await tx.eventRecord.create({ data: { organization: { connect: { @@ -61,6 +80,13 @@ export class TestJobService { id: environment.id, }, }, + externalAccount: externalAccount + ? { + connect: { + id: externalAccount.id, + }, + } + : undefined, eventId: `test:${eventName}:${new Date().getTime()}`, name: eventName, timestamp: new Date(), diff --git a/apps/webapp/app/services/runs/continueRun.server.ts b/apps/webapp/app/services/runs/continueRun.server.ts index 00dad4ad22..644cc2e0e9 100644 --- a/apps/webapp/app/services/runs/continueRun.server.ts +++ b/apps/webapp/app/services/runs/continueRun.server.ts @@ -2,7 +2,7 @@ import { RuntimeEnvironmentType } from "@trigger.dev/database"; import { $transaction, Prisma, PrismaClient, prisma } from "~/db.server"; import { enqueueRunExecutionV2 } from "~/models/jobRunExecution.server"; -const RESUMABLE_STATUSES = ["FAILURE", "TIMED_OUT", "ABORTED", "CANCELED"]; +const RESUMABLE_STATUSES = ["FAILURE", "TIMED_OUT", "UNRESOLVED_AUTH", "ABORTED", "CANCELED"]; export class ContinueRunService { #prismaClient: PrismaClient; diff --git a/apps/webapp/app/services/runs/performRunExecutionV1.server.ts b/apps/webapp/app/services/runs/performRunExecutionV1.server.ts index c886427db9..016d71477f 100644 --- a/apps/webapp/app/services/runs/performRunExecutionV1.server.ts +++ b/apps/webapp/app/services/runs/performRunExecutionV1.server.ts @@ -4,6 +4,7 @@ import { RunJobResumeWithTask, RunJobRetryWithTask, RunJobSuccess, + RunJobUnresolvedAuthError, RunSourceContextSchema, } from "@trigger.dev/core"; import type { Task } from "@trigger.dev/database"; @@ -342,6 +343,11 @@ export class PerformRunExecutionV1Service { await this.#cancelExecution(execution); break; } + case "UNRESOLVED_AUTH_ERROR": { + await this.#failRunWithUnresolvedAuthError(execution, safeBody.data); + + break; + } default: { const _exhaustiveCheck: never = status; throw new Error(`Non-exhaustive match for value: ${status}`); @@ -438,6 +444,15 @@ export class PerformRunExecutionV1Service { }); } + async #failRunWithUnresolvedAuthError( + execution: FoundRunExecution, + data: RunJobUnresolvedAuthError + ) { + return await $transaction(this.#prismaClient, async (tx) => { + await this.#failRunExecution(tx, execution, data.issues, "UNRESOLVED_AUTH"); + }); + } + async #retryRunWithTask(execution: FoundRunExecution, data: RunJobRetryWithTask) { const { run } = execution; @@ -557,7 +572,7 @@ export class PerformRunExecutionV1Service { prisma: PrismaClientOrTransaction, execution: FoundRunExecution, output: Record, - status: "FAILURE" | "ABORTED" = "FAILURE" + status: "FAILURE" | "ABORTED" | "UNRESOLVED_AUTH" = "FAILURE" ): Promise { const { run } = execution; diff --git a/apps/webapp/app/services/runs/performRunExecutionV2.server.ts b/apps/webapp/app/services/runs/performRunExecutionV2.server.ts index 763318153a..7d5f979c2c 100644 --- a/apps/webapp/app/services/runs/performRunExecutionV2.server.ts +++ b/apps/webapp/app/services/runs/performRunExecutionV2.server.ts @@ -4,6 +4,7 @@ import { RunJobResumeWithTask, RunJobRetryWithTask, RunJobSuccess, + RunJobUnresolvedAuthError, RunSourceContextSchema, } from "@trigger.dev/core"; import { RuntimeEnvironmentType, type Task } from "@trigger.dev/database"; @@ -354,6 +355,11 @@ export class PerformRunExecutionV2Service { await this.#cancelExecution(run); break; } + case "UNRESOLVED_AUTH_ERROR": { + await this.#failRunWithUnresolvedAuthError(run, safeBody.data, durationInMs); + + break; + } default: { const _exhaustiveCheck: never = status; throw new Error(`Non-exhaustive match for value: ${status}`); @@ -432,6 +438,23 @@ export class PerformRunExecutionV2Service { }); } + async #failRunWithUnresolvedAuthError( + execution: FoundRun, + data: RunJobUnresolvedAuthError, + durationInMs: number + ) { + return await $transaction(this.#prismaClient, async (tx) => { + await this.#failRunExecution( + tx, + "EXECUTE_JOB", + execution, + data.issues, + "UNRESOLVED_AUTH", + durationInMs + ); + }); + } + async #retryRunWithTask( run: FoundRun, data: RunJobRetryWithTask, @@ -556,7 +579,7 @@ export class PerformRunExecutionV2Service { reason: "EXECUTE_JOB" | "PREPROCESS", run: FoundRun, output: Record, - status: "FAILURE" | "ABORTED" | "TIMED_OUT" = "FAILURE", + status: "FAILURE" | "ABORTED" | "TIMED_OUT" | "UNRESOLVED_AUTH" = "FAILURE", durationInMs: number = 0 ): Promise { await $transaction(prisma, async (tx) => { diff --git a/apps/webapp/app/services/runs/reRun.server.ts b/apps/webapp/app/services/runs/reRun.server.ts index 8c1b8449ed..9a604c16b1 100644 --- a/apps/webapp/app/services/runs/reRun.server.ts +++ b/apps/webapp/app/services/runs/reRun.server.ts @@ -20,6 +20,7 @@ export class ReRunService { version: true, job: true, event: true, + externalAccount: true, }, where: { id: runId, @@ -43,6 +44,13 @@ export class ReRunService { id: existingRun.environment.id, }, }, + externalAccount: existingRun.externalAccount + ? { + connect: { + id: existingRun.externalAccount.id, + }, + } + : undefined, eventId: `${existingRun.event.eventId}:retry:${new Date().getTime()}`, name: existingRun.event.name, timestamp: new Date(), diff --git a/apps/webapp/app/services/runs/startRun.server.ts b/apps/webapp/app/services/runs/startRun.server.ts index 818e310c54..0773eda6cb 100644 --- a/apps/webapp/app/services/runs/startRun.server.ts +++ b/apps/webapp/app/services/runs/startRun.server.ts @@ -50,11 +50,11 @@ export class StartRunService { integrationId: runConnection.integration.id, authSource: "HOSTED", } as const) - : runConnection.result === "resolvedLocal" + : runConnection.result === "resolvedLocal" || runConnection.result === "resolvedResolver" ? ({ key, integrationId: runConnection.integration.id, - authSource: "LOCAL", + authSource: runConnection.result === "resolvedLocal" ? "LOCAL" : "RESOLVER", } as const) : undefined ) @@ -173,6 +173,7 @@ async function createRunConnections(tx: PrismaClientOrTransaction, run: FoundRun integration: Integration; } | { result: "resolvedLocal"; integration: Integration } + | { result: "resolvedResolver"; integration: Integration } | { result: "missing"; connectionType: ConnectionType; @@ -190,6 +191,11 @@ async function createRunConnections(tx: PrismaClientOrTransaction, run: FoundRun result: "resolvedLocal", integration: jobIntegration.integration, }; + } else if (jobIntegration.integration.authSource === "RESOLVER") { + acc[jobIntegration.key] = { + result: "resolvedResolver", + integration: jobIntegration.integration, + }; } else { const connection = run.externalAccountId ? await tx.integrationConnection.findFirst({ diff --git a/apps/webapp/app/services/schedules/registerSchedule.server.ts b/apps/webapp/app/services/schedules/registerSchedule.server.ts index bda3daf894..950942f05b 100644 --- a/apps/webapp/app/services/schedules/registerSchedule.server.ts +++ b/apps/webapp/app/services/schedules/registerSchedule.server.ts @@ -59,6 +59,7 @@ export class RegisterScheduleService { schedule: payload, accountId: payload.accountId, dynamicTrigger, + organizationId: environment.organizationId, }); return registration; diff --git a/apps/webapp/app/services/schedules/registerScheduleSource.server.ts b/apps/webapp/app/services/schedules/registerScheduleSource.server.ts index f27267b855..1aa58fd82d 100644 --- a/apps/webapp/app/services/schedules/registerScheduleSource.server.ts +++ b/apps/webapp/app/services/schedules/registerScheduleSource.server.ts @@ -16,24 +16,32 @@ export class RegisterScheduleSourceService { schedule, accountId, dynamicTrigger, + organizationId, }: { key: string; dispatcher: EventDispatcher; schedule: ScheduleMetadata; accountId?: string; dynamicTrigger?: DynamicTrigger; + organizationId: string; }) { const validatedSchedule = validateSchedule(schedule); return await $transaction(this.#prismaClient, async (tx) => { const externalAccount = accountId - ? await tx.externalAccount.findUniqueOrThrow({ + ? await tx.externalAccount.upsert({ where: { environmentId_identifier: { environmentId: dispatcher.environmentId, identifier: accountId, }, }, + create: { + environmentId: dispatcher.environmentId, + organizationId: organizationId, + identifier: accountId, + }, + update: {}, }) : undefined; diff --git a/apps/webapp/app/services/sources/registerSourceV1.server.ts b/apps/webapp/app/services/sources/registerSourceV1.server.ts index 2be96c6d62..828d8a0c24 100644 --- a/apps/webapp/app/services/sources/registerSourceV1.server.ts +++ b/apps/webapp/app/services/sources/registerSourceV1.server.ts @@ -71,13 +71,19 @@ export class RegisterSourceServiceV1 { } const externalAccount = accountId - ? await tx.externalAccount.findUniqueOrThrow({ + ? await tx.externalAccount.upsert({ where: { environmentId_identifier: { environmentId: environment.id, identifier: accountId, }, }, + create: { + environmentId: environment.id, + organizationId: environment.organizationId, + identifier: accountId, + }, + update: {}, }) : undefined; diff --git a/apps/webapp/app/services/sources/registerSourceV2.server.ts b/apps/webapp/app/services/sources/registerSourceV2.server.ts index 837793080d..4b6bea2527 100644 --- a/apps/webapp/app/services/sources/registerSourceV2.server.ts +++ b/apps/webapp/app/services/sources/registerSourceV2.server.ts @@ -71,13 +71,19 @@ export class RegisterSourceServiceV2 { } const externalAccount = accountId - ? await tx.externalAccount.findUniqueOrThrow({ + ? await tx.externalAccount.upsert({ where: { environmentId_identifier: { environmentId: environment.id, identifier: accountId, }, }, + create: { + environmentId: environment.id, + organizationId: environment.organizationId, + identifier: accountId, + }, + update: {}, }) : undefined; diff --git a/apps/webapp/app/services/triggers/registerTriggerSourceV2.server.ts b/apps/webapp/app/services/triggers/registerTriggerSourceV2.server.ts index 861d4fb9ad..3118e58a1c 100644 --- a/apps/webapp/app/services/triggers/registerTriggerSourceV2.server.ts +++ b/apps/webapp/app/services/triggers/registerTriggerSourceV2.server.ts @@ -24,7 +24,6 @@ export class RegisterTriggerSourceServiceV2 { endpointSlug, id, key, - accountId, registrationMetadata, }: { environment: AuthenticatedEnvironment; @@ -32,7 +31,6 @@ export class RegisterTriggerSourceServiceV2 { id: string; endpointSlug: string; key: string; - accountId?: string; registrationMetadata?: any; }): Promise { const endpoint = await this.#prismaClient.endpoint.findUniqueOrThrow({ @@ -63,7 +61,7 @@ export class RegisterTriggerSourceServiceV2 { endpoint.id, payload.source, dynamicTrigger.id, - accountId, + payload.accountId, { id: key, metadata: registrationMetadata } ); diff --git a/docs/_snippets/installs/slack.mdx b/docs/_snippets/installs/slack.mdx new file mode 100644 index 0000000000..1c6a647af5 --- /dev/null +++ b/docs/_snippets/installs/slack.mdx @@ -0,0 +1,15 @@ + + +```bash npm +npm install @trigger.dev/slack@latest +``` + +```bash pnpm +pnpm install @trigger.dev/slack@latest +``` + +```bash yarn +yarn add @trigger.dev/slack@latest +``` + + diff --git a/docs/_snippets/jobs/options.mdx b/docs/_snippets/jobs/options.mdx new file mode 100644 index 0000000000..d9fb0cdcb4 --- /dev/null +++ b/docs/_snippets/jobs/options.mdx @@ -0,0 +1,49 @@ + + + + The `id` property is used to uniquely identify the Job. Only change this if you want to create a new Job. + + + The `name` of the Job that you want to appear in the dashboard and logs. You can change this without creating a new Job. + + + The `version` property is used to version your Job. A new version will be created if you change this property. We recommend using [semantic versioning](https://www.baeldung.com/cs/semantic-versioning), e.g. `1.0.3`. + + + The `trigger` property is used to define when the Job should run. There are currently the following Trigger types: + - [cronTrigger](/sdk/crontrigger) + - [intervalTrigger](/sdk/intervaltrigger) + - [eventTrigger](/sdk/eventtrigger) + - [DynamicTrigger](/sdk/dynamictrigger) + - [DynamicSchedule](/sdk/dynamicschedule) + - integration Triggers, like webhooks. See the [integrations](/integrations) page for more information. + + + This function gets called automatically when a Run is Triggered. It has three parameters: + 1. `payload` – The payload that was sent to the Trigger API. + 2. [io](/sdk/io) – An object that contains the integrations that you specified in the `integrations` property and other useful functions like delays and running Tasks. + 3. [context](/sdk/context) – An object that contains information about the Organization, Job, Run and more. + + This is where you put the code you want to run for a Job. You can use normal code in here and you can also use Tasks. + + You can return a value from this function and it will be sent back to the Trigger API. + + + Imports the specified integrations into the Job. The integrations will be available on the `io` object in the `run()` function with the same name as the key. For example: + + + + The `enabled` property is an optional property that specifies whether the Job is enabled or not. The Job will be enabled by default if you omit this property. When a job is disabled, no new runs will be triggered or resumed. In progress runs will continue to run until they are finished or delayed by using `io.wait`. + + + The `logLevel` property is an optional property that specifies the level of + logging for the Job. The level is inherited from the client if you omit this property. + - `log` - logs only essential messages + - `error` - logs error messages + - `warn` - logs errors and warning messages + - `info` - logs errors, warnings and info messages + - `debug` - logs everything with full verbosity + + + + diff --git a/docs/documentation/concepts/triggers/dynamic.mdx b/docs/documentation/concepts/triggers/dynamic.mdx index dda10dfede..5a12b2b5bc 100644 --- a/docs/documentation/concepts/triggers/dynamic.mdx +++ b/docs/documentation/concepts/triggers/dynamic.mdx @@ -12,7 +12,7 @@ Sometimes you don't know when you write the code what the trigger or schedule wi ```typescript //1. create a DynamicSchedule -const dynamicSchedule = new DynamicSchedule(client, { +const dynamicSchedule = client.defineDynamicSchedule({ id: "dynamicinterval", }); @@ -53,15 +53,18 @@ client.defineJob({ }), }), run: async (payload, io, ctx) => { - //6. Register the DynamicSchedule - await io.registerInterval("📆", dynamicSchedule, payload.userId, { - seconds: payload.seconds, + //6. Register the DynamicSchedule (this will automatically create a task) + await dynamicSchedule.register(userId, { + type: "cron", + options: { + cron: userSchedule, + }, }); await io.wait("wait", 60); - //7. Unregister the DynamicSchedule if you want - await io.unregisterInterval("❌📆", dynamicSchedule, payload.id); + //7. Unregister the DynamicSchedule if you want (this will automatically create a task) + await dynamicSchedule.unregister(userId); }, }); ``` @@ -70,7 +73,7 @@ client.defineJob({ ```typescript //1. create a DynamicTrigger -const dynamicOnIssueOpenedTrigger = new DynamicTrigger(client, { +const dynamicOnIssueOpenedTrigger = client.defineDynamicTrigger({ id: "github-issue-opened", event: events.onIssueOpened, source: github.sources.repo, @@ -96,7 +99,7 @@ client.defineJob({ //3. Register the DynamicTrigger anywhere in your app async function registerRepo(owner: string, repo: string) { //the first param (key) should be unique - await dynamicOnIssueOpenedTrigger.register(`${owner}/${repo}`, { + await dynamicOnIssueOpenedTrigger.register(`${owner}-${repo}`, { owner, repo, }); @@ -114,15 +117,10 @@ client.defineJob({ }), run: async (payload, io, ctx) => { //6. Register the dynamic trigger so you get notified when an issue is opened - return await io.registerTrigger( - "register-repo", - dynamicOnIssueOpenedTrigger, - payload.repository.name, - { - owner: payload.repository.owner.login, - repo: payload.repository.name, - } - ); + await dynamicOnIssueOpenedTrigger.register(`${owner}-${repo}`, { + owner, + repo, + }); }, }); ``` diff --git a/docs/documentation/guides/using-integrations-apikeys.mdx b/docs/documentation/guides/using-integrations-apikeys.mdx index 330aee59b3..512dcc9e83 100644 --- a/docs/documentation/guides/using-integrations-apikeys.mdx +++ b/docs/documentation/guides/using-integrations-apikeys.mdx @@ -1,6 +1,7 @@ --- title: "API Keys and Personal Access Tokens" description: "Lots of APIs use API Keys or Personal Access Tokens to authenticate. This guide will show you how to use them." +sidebarTitle: "API Keys and PATs" --- ## 1. Create an Integration client diff --git a/docs/documentation/guides/using-integrations-byo-auth.mdx b/docs/documentation/guides/using-integrations-byo-auth.mdx new file mode 100644 index 0000000000..a46d94680b --- /dev/null +++ b/docs/documentation/guides/using-integrations-byo-auth.mdx @@ -0,0 +1,515 @@ +--- +title: "Bring Your Own Auth" +description: "Use Auth Resolvers to provide custom authentication credentials" +--- + +In the previous guides we've covered how you can use our integrations with [API Keys](/documentation/guides/using-integrations-apikeys) or [OAuth](/documentation/guides/using-integrations-oauth), but in both cases those authentication credentials belong **to you** the developer. + +If you want to use our integrations using auth credentials of **your users** you can use an Auth Resolver which allows you to implement your own custom auth resolving using a third-party service like [Clerk](https://clerk.com/) or [Nango](https://www.nango.dev/) + +In this guide we'll demonstrate how to use Clerk.com's [Social Connections](https://clerk.com/docs/authentication/social-connections/oauth) to allow you to make requests with your user's Slack credentials and the official Trigger.dev [Slack integration](/integrations/apis/slack) + + + We won't be covering how to setup Clerk.com and their Social Connections to get the auth. This + guide assumes you already have all that setup. + + +## 1. Install the Slack integration package + + + +## 2. Create a Slack integration + +```ts slack.ts +import { Slack } from "@trigger.dev/slack"; + +const byoSlack = new Slack({ + id: "byo-slack", +}); +``` + +## 3. Define an Auth Resolver + +Using your `TriggerClient` instance, define a new Auth Resolver for the `slack` integration: + +```ts slack.ts +import { Slack } from "@trigger.dev/slack"; +// Import your TriggerClient instance. This is merely an example of how you could do it +import { client } from "./trigger"; + +const byoSlack = new Slack({ + id: "byo-slack", +}); + +client.defineAuthResolver(byoSlack, async (ctx) => { + // this is where we'll use the clerk backend SDK +}); +``` + +## 4. Define a job + +Before we finish the Slack Auth Resolver, let's create an example job that uses the Slack integration: + +```ts slack.ts +import { z } from "zod"; + +client.defineJob({ + id: "post-a-message", + name: "Post a Slack Message", + version: "1.0.0", + trigger: eventTrigger({ + name: "post.message", + schema: z.object({ + text: z.string(), + channel: z.string(), + }), + }), + integrations: { + slack: byoSlack, + }, + run: async (payload, io, ctx) => { + await io.slack.postMessage("💬", { + channel: payload.channel, + text: payload.text, + }); + }, +}); +``` + +As you can see above, we're passing the `byoSlack` integration into the Job and using it by calling `io.slack.postMessage`. + +## 5. Install the Clerk backend SDK + + + +```bash npm +npm install @clerk/backend@latest +``` + +```bash pnpm +pnpm install @clerk/backend@latest +``` + +```bash yarn +yarn add @clerk/backend@latest +``` + + + +## 6. Import and initialize the Clerk SDK + +```ts slack.ts +import { Clerk } from "@clerk/backend"; + +// Clerk is not a class so the omission of `new Clerk` here is on purpose +const clerk = Clerk({ apiKey: process.env.CLERK_API_KEY }); +``` + +## 7. Implement the Auth Resolver + +Now we'll implement the Auth Resolver to provide authentication credentials saved in Clerk.com for Job runs, depending on the account ID of the run. + +```ts slack.ts +client.defineAuthResolver(slack, async (ctx) => { + if (!ctx.account?.id) { + return; + } + + const tokens = await clerk.users.getUserOauthAccessToken(ctx.account.id, "oauth_slack"); + + if (tokens.length === 0) { + throw new Error(`Could not find Slack auth for account ${ctx.account.id}`); + } + + return { + type: "oauth", + token: tokens[0].token, + }; +}); +``` + +The first parameter to the Auth Resolver callback is the run context ([reference docs](/sdk/context)), which optionally contains an associated account (more on this below). + + + If the Auth Resolver returns undefined or throws an Error, any Job Run that uses the `byoSlack` + integration will fail with an "Unresolved auth" error. + + +## Bonus: Multiple Slack integration clients + +If you want to also use Slack with your own authentication credentials, you can always create _another_ slack integration with a different `id`. + +```ts slack.ts +const ourSlack = new Slack({ id: "our-slack" }); + +client.defineJob({ + id: "post-a-message", + name: "Post a Slack Message", + version: "1.0.0", + trigger: eventTrigger({ + name: "post.message", + schema: z.object({ + text: z.string(), + channel: z.string(), + }), + }), + integrations: { + byoSlack: byoSlack, + ourSlack: ourSlack, + }, + run: async (payload, io, ctx) => { + await io.byoSlack.postMessage("💬", { + channel: payload.channel, + text: payload.text, + }); + + await io.ourSlack.postMessage("📢", { + channel: "C01234567", + text: `We just sent the following message to ${ctx.account?.id}: ${payload.text}`, + }); + }, +}); +``` + +# How to Trigger Job runs with an Account ID + +Now that we have a working Clerk.com Auth Resolver for Slack we're ready to start triggering jobs with an associated account ID. The way you do this is different depending on the Trigger type. + +## Event Triggers + +Jobs that have [Event Triggers](/documentation/concepts/triggers/events) can be run with an associated account by providing an `accountId` when calling `sendEvent`: + +```ts backend.ts +// This is an instance of `TriggerClient` +await client.sendEvent( + { + name: "post.created", + payload: { id: "post_123" }, + }, + { + accountId: "user_123", + } +); +``` + +The `accountId` value is completely arbitrary and doesn't map to anything inside Trigger.dev, but generally it should be a unique ID that can be used to lookup Auth credentials in your Auth Resolvers. + +You can also send events with an associated account ID from the run of another job: + +```ts anotherJob.ts +client.defineJob({ + id: "event-1", + name: "Run when the foo.bar event happens", + version: "0.0.1", + trigger: eventTrigger({ + name: "foo.bar", + }), + run: async (payload, io, ctx) => { + //send an event using `io` + await io.sendEvent( + "🎫", + { + name: "post.created", + payload: { id: "post_123" }, + }, + { + accountId: "user_123", + } + ); + }, +}); +``` + +When a run is triggered with an associated account ID, you'll see the account ID in the run dashboard: + +![Event Trigger with Account ID](/images/byo-auth/run-dashboard-account-id.png) + +## Scheduled Triggers + +Running a job with an associated account ID that is triggered by a [Scheduled Trigger](/documentation/concepts/triggers/scheduled) works a bit differently than Event Triggers as you'll need to convert your normal `intervalTrigger` or `cronTrigger` into using a [Dynamic Schedule](/documentation/concepts/triggers/dynamic#dynamicschedule) and then registering schedules with an associated account ID. + +### 1. Convert a job to using a Dynamic Schedule + +First let's convert the following job from an `intervalTrigger` to a Dynamic Schedule: + +```ts dynamicSchedule.ts +// Before +client.defineJob({ + id: "scheduled-job", + name: "Scheduled Job", + version: "1.0.0", + trigger: intervalTrigger({ + seconds: 60, + }), + run: async (payload, io, ctx) => { + await io.logger.info("This runs every 60 seconds"); + }, +}); + +// After +export const dynamicInterval = client.defineDynamicSchedule({ id: "my-schedule" }); + +client.defineJob({ + id: "scheduled-job", + name: "Scheduled Job", + version: "1.0.0", + trigger: dynamicInterval, + run: async (payload, io, ctx) => { + await io.logger.info("This runs dynamic schedules"); + }, +}); +``` + +As you can see above, we've dropped the specific interval when defining the trigger as that will now be specific when registering schedules. + +### 2. Register a schedule + +You can now use the `dynamicInterval` instance to register a schedule, which will trigger the `scheduled-job`: + +```ts backend.ts +import { dynamicInterval } from "./dynamicSchedule"; + +// Somewhere in your backend +await dynamicInterval.register("schedule_123", { + type: "interval", + options: { seconds: 60 }, + accountId: "user_123", // associate runs triggered by this schedule with user_123 +}); +``` + +As you can see above, we've associated this registered schedule with an `accountId`, so any runs triggered by this schedule will be associated with `"user_123"` + +The first parameter above `"schedule_123"` is the Schedule ID and can be used to unregister the schedule at a later point: + +```ts backend.ts +import { dynamicInterval } from "./dynamicSchedule"; + +// Somewhere in your backend +await dynamicInterval.unregister("schedule_123"); +``` + +You can also use register/unregister inside another job run and it will automatically create a [Task](/documentation/concepts/tasks): + +```ts otherJob.ts +import { dynamicInterval } from "./dynamicSchedule"; + +client.defineJob({ + id: "event-1", + name: "Run when the foo.bar event happens", + version: "0.0.1", + trigger: eventTrigger({ + name: "foo.bar", + }), + run: async (payload, io, ctx) => { + await dynamicInterval.register("schedule_123", { + type: "interval", + options: { seconds: 60 }, + accountId: "user_123", // associate runs triggered by this schedule with user_123 + }); + }, +}); +``` + +Will produce the following run dashboard: + +![Dynamic Schedule Task](/images/byo-auth/dynamic-schedule-task.png) + + + If you will only ever add a single schedule for a user on a given Dynamic Schedule, you can just + use the accountId as the Schedule ID + +```ts +const accountId = "user_123"; +await dynamicInterval.register(accountId, { + type: "interval", + options: { seconds: 60 }, + accountId, +}); +``` + + + +## Webhook Triggers + +Running a job with an associated account ID that is triggered by a [Webhook Trigger](/documentation/concepts/triggers/webhook) requires converting to the use of a [Dynamic Trigger](/documentation/concepts/triggers/dynamic#dynamictrigger) + +Dynamic Trigger's work very similarly to Dynamic Schedules, but instead of registering schedules, you register triggers: + + + + +Using the GitHub integration we'll create a Dynamic Trigger that is triggered by the `onIssueOpened` event: + +```ts github.ts +import { Github, events } from "@trigger.dev/github"; + +const github = new Github({ + id: "github", +}); + +const dynamicOnIssueOpenedTrigger = client.defineDynamicTrigger({ + id: "github-issue-opened", + event: events.onIssueOpened, + source: github.sources.repo, +}); +``` + + + + + +Now we'll use the Dynamic Trigger to define a Job that is triggered by it: + +```ts github.ts +client.defineJob({ + id: "listen-for-dynamic-trigger", + name: "Listen for dynamic trigger", + version: "0.1.1", + trigger: dynamicOnIssueOpenedTrigger, + integrations: { + github, + }, + run: async (payload, io, ctx) => { + await io.github.issues.createComment("create-issue-comment", { + owner: payload.repository.owner.login, + repo: payload.repository.name, + issueNumber: payload.issue.number, + body: "First! 🥇", + }); + }, +}); +``` + + + + + +Define an Auth Resolver to fetch the GitHub OAuth token from Clerk.com: + +```ts github.ts +client.defineAuthResolver(github, async (ctx) => { + if (!ctx.account?.id) { + return; + } + + const tokens = await clerk.users.getUserOauthAccessToken(ctx.account.id, "oauth_github"); + + if (tokens.length === 0) { + throw new Error(`Could not find GitHub auth for account ${ctx.account.id}`); + } + + return { + type: "oauth", + token: tokens[0].token, + }; +}); +``` + + + If you are using clerk, you'll probably want to [Add additional + scopes](https://clerk.com/docs/authentication/social-connections/oauth#request-additional-o-auth-scopes-after-sign-up) + to be able to do useful things with the GitHub integration. For example, if you plan on + registering GitHub triggers you'll need `write:repo_hook` and `read:repo_hook` or just + `admin:repo_hook`. If you want to create issues you'll need `repo` or `public_repo`. + + + + + + + Finally, we can register a new Trigger at "runtime", either inside another Job run or in your backend: + +```ts github.ts +// Register inside another job run: +client.defineJob({ + id: "register-issue-opened", + name: "Register Issue Opened for Account", + version: "0.0.1", + trigger: eventTrigger({ + name: "register.issue.opened", + }), + run: async (payload, io, ctx) => { + // This will automatically create a task in this run with the `payload.id` as the Task Key. + await dynamicOnIssueOpenedTrigger.register( + payload.id, + { + owner: payload.owner, + repo: payload.repo, + }, + { + accountId: payload.accountId, + } + ); + }, +}); + +// Register in your backend: +// This skips creating a Task since it's outside a job and will just call our backend API directly +async function registerIssueOpenedTrigger( + id: string, + owner: string, + repo: string, + accountId?: string +) { + return await dynamicOnIssueOpenedTrigger.register( + id, + { + owner, + repo, + }, + { + accountId, + } + ); +} +``` + + + + +# Testing jobs with Account ID + +If a job uses any integrations with an Auth Resolver that requires an account ID, you'll need to provide an account ID when testing the job: + +![Test Job with Account ID](/images/byo-auth/run-test-account-id.png) + +# Auth Resolver reference + +The Auth Resolver callback has the following signature: + +```ts +type TriggerAuthResolver = ( + ctx: TriggerContext, + integration: TriggerIntegration +) => Promise; + +type AuthResolverResult = { + type: "apiKey" | "oauth"; + token: string; + additionalFields?: Record; +}; +``` + +The `ctx` parameter is the [TriggerContext](/sdk/context) for the run and the `integration` parameter is the [TriggerIntegration](/sdk/integrations) instance that the Auth Resolver is being called for. You can use the `integration` parameter to check the `id` of the integration to determine which integration the Auth Resolver is being called for: + +```ts +client.defineAuthResolver(slack, async (ctx, integration) => { + if (integration.id === "byo-slack") { + // do something + } +}); +``` + +You can also return `additionalFields` in the Auth Resolver result which will be passed to the integration when making requests. This is useful if you need to provide additional fields to the integration that are not part of the standard integration options. + +```ts +client.defineAuthResolver(shopify, async (ctx, integration) => { + return { + type: "apiKey", + token: "my-api-key", + additionalFields: { + shop: "my-shop-name", + }, + }; +}); +``` diff --git a/docs/documentation/guides/using-integrations.mdx b/docs/documentation/guides/using-integrations.mdx index b8af49a61f..b2a3dc8bf5 100644 --- a/docs/documentation/guides/using-integrations.mdx +++ b/docs/documentation/guides/using-integrations.mdx @@ -1,12 +1,12 @@ --- -title: "Using Integrations" -description: "How to use Integrations" +title: "Integrations Overview" +description: "How to use Trigger.dev Integrations" +sidebarTitle: "Overview" --- - You can use any API in your Jobs by using existing Node.js SDKs or HTTP - requests. Integrations just make it much easier especially when you want to - use OAuth. And you get great logging. + You can use any API in your Jobs by using existing Node.js SDKs or HTTP requests. Integrations + just make it much easier especially when you want to use OAuth. And you get great logging. [Integrations](/documentation/concepts/integrations) allow you to quickly use APIs, including webhooks and Tasks. @@ -37,6 +37,14 @@ There are two ways to authenticate Integrations, OAuth and API Keys/Access Token > Use OAuth to connect an Integration for your team or your users + + Use our integrations with your user’s auth credentials, using Clerk.com, Nango.dev, or rolling + your own with our custom auth resolvers + ## Using for Jobs & Tasks @@ -121,7 +129,7 @@ import { Stripe } from "@trigger.dev/stripe"; const stripe = new Stripe({ id: "stripe", - apiKey: process.env.STRIPE_SECRET_KEY! + apiKey: process.env.STRIPE_SECRET_KEY!, }); async function createCustomer() { @@ -161,7 +169,6 @@ client.defineJob({ Behind the scenes, our `@trigger.dev/github` integration will create a webhook on your repository that will call our API when a new push event is received. We will then start your Job with the payload from the push event. - If you are just using an integration to trigger a job but not using - authenticated tasks inside the job run, there is no need to pass the - integration in the job `integrations` option. + If you are just using an integration to trigger a job but not using authenticated tasks inside the + job run, there is no need to pass the integration in the job `integrations` option. diff --git a/docs/images/byo-auth/dynamic-schedule-task.png b/docs/images/byo-auth/dynamic-schedule-task.png new file mode 100644 index 0000000000..6fbbffdce2 Binary files /dev/null and b/docs/images/byo-auth/dynamic-schedule-task.png differ diff --git a/docs/images/byo-auth/run-dashboard-account-id.png b/docs/images/byo-auth/run-dashboard-account-id.png new file mode 100644 index 0000000000..831fa882b2 Binary files /dev/null and b/docs/images/byo-auth/run-dashboard-account-id.png differ diff --git a/docs/images/byo-auth/run-test-account-id.png b/docs/images/byo-auth/run-test-account-id.png new file mode 100644 index 0000000000..8120e90746 Binary files /dev/null and b/docs/images/byo-auth/run-test-account-id.png differ diff --git a/docs/mint.json b/docs/mint.json index 81c5dfb171..6c041b32bc 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -151,7 +151,6 @@ "documentation/guides/manual/fastify" ] }, - "documentation/guides/running-jobs", "documentation/guides/jobs/managing", { @@ -168,7 +167,8 @@ "pages": [ "documentation/guides/using-integrations", "documentation/guides/using-integrations-apikeys", - "documentation/guides/using-integrations-oauth" + "documentation/guides/using-integrations-oauth", + "documentation/guides/using-integrations-byo-auth" ] }, { @@ -277,7 +277,11 @@ "sdk/triggerclient/instancemethods/sendevent", "sdk/triggerclient/instancemethods/getevent", "sdk/triggerclient/instancemethods/getruns", - "sdk/triggerclient/instancemethods/getrun" + "sdk/triggerclient/instancemethods/getrun", + "sdk/triggerclient/instancemethods/define-job", + "sdk/triggerclient/instancemethods/define-dynamic-trigger", + "sdk/triggerclient/instancemethods/define-dynamic-schedule", + "sdk/triggerclient/instancemethods/define-auth-resolver" ] } ] @@ -311,7 +315,10 @@ "sdk/dynamictrigger/constructor", { "group": "Instance methods", - "pages": ["sdk/dynamictrigger/register", "sdk/dynamictrigger/unregister"] + "pages": [ + "sdk/dynamictrigger/register", + "sdk/dynamictrigger/unregister" + ] } ] }, @@ -322,7 +329,10 @@ "sdk/dynamicschedule/constructor", { "group": "Instance methods", - "pages": ["sdk/dynamicschedule/register", "sdk/dynamicschedule/unregister"] + "pages": [ + "sdk/dynamicschedule/register", + "sdk/dynamicschedule/unregister" + ] } ] }, @@ -343,7 +353,9 @@ }, { "group": "Overview", - "pages": ["examples/introduction"] + "pages": [ + "examples/introduction" + ] } ], "footerSocials": { @@ -356,4 +368,4 @@ "apiKey": "phc_hwYmedO564b3Ik8nhA4Csrb5SueY0EwFJWCbseGwWW" } } -} +} \ No newline at end of file diff --git a/docs/sdk/dynamicschedule/overview.mdx b/docs/sdk/dynamicschedule/overview.mdx index 4525aac74c..f6faaa0a80 100644 --- a/docs/sdk/dynamicschedule/overview.mdx +++ b/docs/sdk/dynamicschedule/overview.mdx @@ -34,7 +34,7 @@ Use this method to unregister a schedule from the DynamicSchedule, using the id ```typescript //1. create a DynamicSchedule -const dynamicSchedule = new DynamicSchedule(client, { +const dynamicSchedule = client.defineDynamicSchedule({ id: "dynamicinterval", }); @@ -76,14 +76,17 @@ client.defineJob({ }), run: async (payload, io, ctx) => { //6. Register the DynamicSchedule - await io.registerInterval("📆", dynamicSchedule, payload.userId, { - seconds: payload.seconds, + await dynamicSchedule.register(payload.userId, { + type: "interval", + options: { + seconds: payload.seconds, + }, }); await io.wait("wait", 60); - //7. Unregister the DynamicSchedule if you want - await io.unregisterInterval("❌📆", dynamicSchedule, payload.id); + //7. Unregister the DynamicSchedule at some later date + await dynamicSchedule.unregister(payload.userId); }, }); ``` diff --git a/docs/sdk/dynamicschedule/register.mdx b/docs/sdk/dynamicschedule/register.mdx index 71b9cd76c5..8b15bcc164 100644 --- a/docs/sdk/dynamicschedule/register.mdx +++ b/docs/sdk/dynamicschedule/register.mdx @@ -7,8 +7,8 @@ description: "Use this method to register a new schedule with the DynamicSchedul ## Parameters - The id of the schedule to register. The identifier you use will be available - in the `context.source.id` when the Job runs. + The id of the schedule to register. The identifier you use will be available in the + `context.source.id` when the Job runs. The schedule to register. It is either a `cron` or `interval` schedule. @@ -24,8 +24,13 @@ description: "Use this method to register a new schedule with the DynamicSchedul - Any additional data you wish to store with the schedule. This will be - available in the `context.source.metadata` when the Job runs. + Any additional data you wish to store with the schedule. This will be available in the + `context.source.metadata` when the Job runs. + + + An optional account ID to use when running the job. This will be available in the Job + [context](/sdk/context) and can be used in [auth + resolvers](/sdk/triggerclient/instancemethods/define-auth-resolver) @@ -40,8 +45,13 @@ description: "Use this method to register a new schedule with the DynamicSchedul - Any additional data you wish to store with the schedule. This will be - available in the `context.source.metadata` when the Job runs. + Any additional data you wish to store with the schedule. This will be available in the + `context.source.metadata` when the Job runs. + + + An optional account ID to use when running the job. This will be available in the Job + [context](/sdk/context) and can be used in [auth + resolvers](/sdk/triggerclient/instancemethods/define-auth-resolver) diff --git a/docs/sdk/dynamictrigger/constructor.mdx b/docs/sdk/dynamictrigger/constructor.mdx index 477d264e10..aea8078d00 100644 --- a/docs/sdk/dynamictrigger/constructor.mdx +++ b/docs/sdk/dynamictrigger/constructor.mdx @@ -16,9 +16,8 @@ description: "The `DynamicTrigger()` constructor creates a new [DynamicTrigger]( Used to uniquely identify a DynamicTrigger - An event from an [Integration](/integrations) package that you want to - attach to the DynamicTrigger. The event types will come through to the - payload in your Job's run. + An event from an [Integration](/integrations) package that you want to attach to the + DynamicTrigger. The event types will come through to the payload in your Job's run. An external source fron an [Integration](/integrations) package diff --git a/docs/sdk/dynamictrigger/overview.mdx b/docs/sdk/dynamictrigger/overview.mdx index c6626d90ac..b78330cf3b 100644 --- a/docs/sdk/dynamictrigger/overview.mdx +++ b/docs/sdk/dynamictrigger/overview.mdx @@ -9,7 +9,7 @@ Sometimes you want to subscribe to a webhook but you don't know the exact config ### [DynamicTrigger()](/sdk/dynamictrigger/constructor) -Creates a new `DynamicTrigger` instance. +Creates a new `DynamicTrigger` instance. You should use the [`TriggerClient.defineDynamicTrigger`]() method instead of calling this directly. ## Instance methods @@ -33,7 +33,7 @@ Use this method to unregister a schedule from the DynamicTrigger, using the id y ```typescript DynamicTrigger //1. create a DynamicTrigger -const dynamicOnIssueOpenedTrigger = new DynamicTrigger(client, { +const dynamicOnIssueOpenedTrigger = client.defineDynamicTrigger({ id: "github-issue-opened", event: events.onIssueOpened, source: github.sources.repo, @@ -59,7 +59,7 @@ client.defineJob({ //3. Register the DynamicTrigger anywhere in your app async function registerRepo(owner: string, repo: string) { //the first param (key) should be unique - await dynamicOnIssueOpenedTrigger.register(`${owner}/${repo}`, { + await dynamicOnIssueOpenedTrigger.register(`${owner}-${repo}`, { owner, repo, }); @@ -76,16 +76,13 @@ client.defineJob({ org: "triggerdotdev", }), run: async (payload, io, ctx) => { - //6. Register the dynamic trigger so you get notified when an issue is opened - return await io.registerTrigger( - "register-repo", - dynamicOnIssueOpenedTrigger, - payload.repository.name, - { - owner: payload.repository.owner.login, - repo: payload.repository.name, - } - ); + const owner = payload.repository.owner.login; + const repo = payload.repository.name; + //6. Register the dynamic trigger so you get notified when an issue is opened. A task will automatically be created + await dynamicOnIssueOpenedTrigger.register(`${owner}-${repo}`, { + owner, + repo, + }); }, }); ``` diff --git a/docs/sdk/dynamictrigger/register.mdx b/docs/sdk/dynamictrigger/register.mdx index 18136e7a71..95e2049c8c 100644 --- a/docs/sdk/dynamictrigger/register.mdx +++ b/docs/sdk/dynamictrigger/register.mdx @@ -7,12 +7,25 @@ description: "Use this method to register a new configuration with the DynamicTr ## Parameters - The id of the registration. The identifier you use will be available in the - `context.source.id` when the Job runs. It will also be used to unregister. + The id of the registration. The identifier you use will be available in the `context.source.id` + when the Job runs. It will also be used to unregister. - The shape of this object will depend on the type of event you set when - constructing the `DynamicTrigger`. + The shape of this object will depend on the type of event you set when constructing the + `DynamicTrigger`. + + + + + An optional account ID to use when running the job. This will be available in the Job + [context](/sdk/context) and can be used in [auth + resolvers](/sdk/triggerclient/instancemethods/define-auth-resolver) + + + An optional filter to apply to the event. See our [EventFilter + guide](/documentation/guides/event-filter) for more + + ## Returns diff --git a/docs/sdk/io/registercron.mdx b/docs/sdk/io/registercron.mdx index ab9bc9708a..769acc0913 100644 --- a/docs/sdk/io/registercron.mdx +++ b/docs/sdk/io/registercron.mdx @@ -4,6 +4,10 @@ sidebarTitle: "registerCron()" description: "`io.registerCron()` allows you to register a [DynamicSchedule](/sdk/dynamicschedule) that will trigger any jobs it's attached to on a regular CRON schedule." --- + + This has been deprecated in favor of [DynamicSchedule.register](/sdk/dynamicschedule/register) + + ## Parameters diff --git a/docs/sdk/io/registerinterval.mdx b/docs/sdk/io/registerinterval.mdx index 48ef44bbf3..7704267ec7 100644 --- a/docs/sdk/io/registerinterval.mdx +++ b/docs/sdk/io/registerinterval.mdx @@ -4,6 +4,10 @@ sidebarTitle: "registerInterval()" description: "`io.registerInterval()` allows you to register a [DynamicSchedule](/sdk/dynamicschedule) that will trigger any jobs it's attached to on a regular interval." --- + + This has been deprecated in favor of [DynamicSchedule.register](/sdk/dynamicschedule/register) + + ## Parameters diff --git a/docs/sdk/io/registertrigger.mdx b/docs/sdk/io/registertrigger.mdx index e3bf1fb029..6562ef8cef 100644 --- a/docs/sdk/io/registertrigger.mdx +++ b/docs/sdk/io/registertrigger.mdx @@ -4,6 +4,10 @@ sidebarTitle: "registerTrigger()" description: "`io.registerTrigger()` allows you to register a [DynamicTrigger](/sdk/dynamictrigger) with the specified trigger data." --- + + This has been deprecated in favor of [DynamicTrigger.register](/sdk/dynamictrigger/register) + + ## Parameters @@ -30,9 +34,9 @@ A Promise that resolves to an object with the following fields: ## Example -```typescript +```ts //1. create a DynamicTrigger -const dynamicOnIssueOpenedTrigger = new DynamicTrigger(client, { +const dynamicOnIssueOpenedTrigger = client.defineDynamicTrigger({ id: "github-issue-opened", event: events.onIssueOpened, source: github.sources.repo, diff --git a/docs/sdk/io/unregistercron.mdx b/docs/sdk/io/unregistercron.mdx index 7f750efef2..9b43e0e166 100644 --- a/docs/sdk/io/unregistercron.mdx +++ b/docs/sdk/io/unregistercron.mdx @@ -4,6 +4,10 @@ sidebarTitle: "unregisterCron()" description: "`io.unregisterCron()` allows you to unregister a [DynamicSchedule](/sdk/dynamicschedule) that was previously registered with `io.registerCron()`." --- + + This has been deprecated in favor of [DynamicSchedule.unregister](/sdk/dynamicschedule/unregister) + + ## Parameters diff --git a/docs/sdk/io/unregisterinterval.mdx b/docs/sdk/io/unregisterinterval.mdx index 86d0ff9bd7..7b5fcf3d25 100644 --- a/docs/sdk/io/unregisterinterval.mdx +++ b/docs/sdk/io/unregisterinterval.mdx @@ -4,6 +4,10 @@ sidebarTitle: "unregisterInterval()" description: "`io.unregisterInterval()` allows you to unregister a [DynamicSchedule](/sdk/dynamicschedule) that was previously registered with `io.registerInterval()`." --- + + This has been deprecated in favor of [DynamicSchedule.unregister](/sdk/dynamicschedule/unregister) + + ## Parameters diff --git a/docs/sdk/io/unregistertrigger.mdx b/docs/sdk/io/unregistertrigger.mdx index ca47e9541d..3fa9fa2c2f 100644 --- a/docs/sdk/io/unregistertrigger.mdx +++ b/docs/sdk/io/unregistertrigger.mdx @@ -4,6 +4,10 @@ sidebarTitle: "unregisterTrigger()" description: "`io.unregisterTrigger()` allows you to unregister a [DynamicTrigger](/sdk/dynamictrigger) that was previously registered with `io.registerTrigger()`." --- + + This has been deprecated in favor of [DynamicTrigger.unregister](/sdk/dynamictrigger/unregister) + + ## Parameters diff --git a/docs/sdk/job.mdx b/docs/sdk/job.mdx index a9bc27f818..229bbf13c5 100644 --- a/docs/sdk/job.mdx +++ b/docs/sdk/job.mdx @@ -59,55 +59,7 @@ client.defineJob({ An instance of [TriggerClient](/sdk/triggerclient) that is used to send events to the Trigger API. - - - - The `id` property is used to uniquely identify the Job. Only change this if you want to create a new Job. - - - The `name` of the Job that you want to appear in the dashboard and logs. You can change this without creating a new Job. - - - The `version` property is used to version your Job. A new version will be created if you change this property. We recommend using [semantic versioning](https://www.baeldung.com/cs/semantic-versioning), e.g. `1.0.3`. - - - The `trigger` property is used to define when the Job should run. There are currently the following Trigger types: - - [cronTrigger](/sdk/crontrigger) - - [intervalTrigger](/sdk/intervaltrigger) - - [eventTrigger](/sdk/eventtrigger) - - [DynamicTrigger](/sdk/dynamictrigger) - - [DynamicSchedule](/sdk/dynamicschedule) - - integration Triggers, like webhooks. See the [integrations](/integrations) page for more information. - - - This function gets called automatically when a Run is Triggered. It has three parameters: - 1. `payload` – The payload that was sent to the Trigger API. - 2. [io](/sdk/io) – An object that contains the integrations that you specified in the `integrations` property and other useful functions like delays and running Tasks. - 3. [context](/sdk/context) – An object that contains information about the Organization, Job, Run and more. - - This is where you put the code you want to run for a Job. You can use normal code in here and you can also use Tasks. - - You can return a value from this function and it will be sent back to the Trigger API. - - - Imports the specified integrations into the Job. The integrations will be available on the `io` object in the `run()` function with the same name as the key. For example: - - - - The `enabled` property is an optional property that specifies whether the Job is enabled or not. The Job will be enabled by default if you omit this property. When a job is disabled, no new runs will be triggered or resumed. In progress runs will continue to run until they are finished or delayed by using `io.wait`. - - - The `logLevel` property is an optional property that specifies the level of - logging for the Job. The level is inherited from the client if you omit this property. - - `log` - logs only essential messages - - `error` - logs error messages - - `warn` - logs errors and warning messages - - `info` - logs errors, warnings and info messages - - `debug` - logs everything with full verbosity - - - - + ## Returns diff --git a/docs/sdk/triggerclient/instancemethods/define-auth-resolver.mdx b/docs/sdk/triggerclient/instancemethods/define-auth-resolver.mdx new file mode 100644 index 0000000000..b6f34a577b --- /dev/null +++ b/docs/sdk/triggerclient/instancemethods/define-auth-resolver.mdx @@ -0,0 +1,67 @@ +--- +title: "defineAuthResolver()" +description: "Define a custom auth resolver for a specific integration" +--- + +Auth Resolvers allow you to inject the authentication credentials of **your users**, using a third-party service like [Clerk](https://clerk.com/) or [Nango](https://www.nango.dev/) or your own custom solution. + +See our [Bring-your-own Auth Guide](/documentation/guides/using-integrations-byo-auth) for more about how this works. + + + +```ts example +client.defineAuthResolver(slack, async (ctx) => { + if (!ctx.account?.id) { + return; + } + + const tokens = await clerk.users.getUserOauthAccessToken(ctx.account.id, "oauth_slack"); + + if (tokens.length === 0) { + throw new Error(`Could not find Slack auth for account ${ctx.account.id}`); + } + + return { + type: "oauth", + token: tokens[0].token, + }; +}); +``` + + + +## Parameters + + + The Integration client (e.g. `slack`) to define the auth resolver for. + + + + The resolver function to use for this integration. Should return a [AuthResolverResult](#authresolverresult) object. + +{" "} + + + + The [TriggerContext](/sdk/context) object for the run that is requesting authentication. + + + The Integration client that is requesting authentication. + + + + + +## AuthResolverResult + + + Should be either "apiKey" or "oauth" + + + + The authentication token to use for this integration. + + + + Additional fields to pass to the integration. + diff --git a/docs/sdk/triggerclient/instancemethods/define-dynamic-schedule.mdx b/docs/sdk/triggerclient/instancemethods/define-dynamic-schedule.mdx new file mode 100644 index 0000000000..3509ce38e3 --- /dev/null +++ b/docs/sdk/triggerclient/instancemethods/define-dynamic-schedule.mdx @@ -0,0 +1,29 @@ +--- +title: "defineDynamicSchedule()" +description: "Define a Dynamic Schedule" +--- + +## Parameters + + + The options for the dynamic schedule. + + + Used to uniquely identify a DynamicSchedule + + + + +## Returns + + + + + +```ts example +const dynamicSchedule = client.defineDynamicSchedule({ + id: "dynamicinterval", +}); +``` + + diff --git a/docs/sdk/triggerclient/instancemethods/define-dynamic-trigger.mdx b/docs/sdk/triggerclient/instancemethods/define-dynamic-trigger.mdx new file mode 100644 index 0000000000..42dd6b850c --- /dev/null +++ b/docs/sdk/triggerclient/instancemethods/define-dynamic-trigger.mdx @@ -0,0 +1,38 @@ +--- +title: "defineDynamicTrigger()" +description: "Define a Dynamic Trigger" +--- + +## Parameters + + + The options for the dynamic trigger. + + + Used to uniquely identify a DynamicTrigger + + + An event from an [Integration](/integrations) package that you want to attach to the + DynamicTrigger. The event types will come through to the payload in your Job's run. + + + An external source fron an [Integration](/integrations) package + + + + +## Returns + + + + + +```ts example +const dynamicOnIssueOpenedTrigger = client.defineDynamicTrigger({ + id: "github-issue-opened", + event: events.onIssueOpened, + source: github.sources.repo, +}); +``` + + diff --git a/docs/sdk/triggerclient/instancemethods/define-job.mdx b/docs/sdk/triggerclient/instancemethods/define-job.mdx new file mode 100644 index 0000000000..2b9732e1b0 --- /dev/null +++ b/docs/sdk/triggerclient/instancemethods/define-job.mdx @@ -0,0 +1,35 @@ +--- +title: "defineJob()" +description: "Defines a job" +--- + +A [Job](/documentation/concepts/jobs) is used to define the [Trigger](/documentation/concepts/triggers), metadata, and what happens when it runs. + + + +```ts example +client.defineJob({ + id: "github-integration-on-issue", + name: "GitHub Integration - On Issue", + version: "0.1.0", + trigger: github.triggers.repo({ + event: events.onIssue, + owner: "triggerdotdev", + repo: "empty", + }), + run: async (payload, io, ctx) => { + await io.logger.info("This is a simple log info message"); + return { payload, ctx }; + }, +}); +``` + + + +## Parameters + + + +## Returns + + diff --git a/docs/sdk/triggerclient/instancemethods/sendevent.mdx b/docs/sdk/triggerclient/instancemethods/sendevent.mdx index ba2c393b70..05c0b4bf88 100644 --- a/docs/sdk/triggerclient/instancemethods/sendevent.mdx +++ b/docs/sdk/triggerclient/instancemethods/sendevent.mdx @@ -4,7 +4,7 @@ sidebarTitle: "sendEvent()" description: "The `sendEvent()` instance method send an event that triggers any Jobs that are listening for that event (based on the name)." --- -You can call this function from anywhere in your code to send an event. The other way to send an event is by using [io.sendEvent()](/sdk/io/sendevent) from inside a `run()` function. +You can call this function from anywhere in your backend to send an event. The other way to send an event is by using [io.sendEvent()](/sdk/io/sendevent) from inside a `run()` function. Use [eventTrigger()](/sdk/eventtrigger) on a Job to listen for events. diff --git a/docs/sdk/triggerclient/overview.mdx b/docs/sdk/triggerclient/overview.mdx index c9484812aa..f7583caeb0 100644 --- a/docs/sdk/triggerclient/overview.mdx +++ b/docs/sdk/triggerclient/overview.mdx @@ -43,3 +43,19 @@ The `getRuns()` method gets runs for a Job. #### [getRun()](/sdk/triggerclient/instancemethods/getrun) The `getRun()` method gets the details for a given Run. + +#### [defineJob()](/sdk/triggerclient/instancemethods/define-job) + +The `defineJob()` method defines a new Job. + +#### [defineDynamicTrigger()](/sdk/triggerclient/instancemethods/define-dynamic-trigger) + +The `defineDynamicTrigger()` method defines a new Dynamic Trigger. + +#### [defineDynamicSchedule()](/sdk/triggerclient/instancemethods/define-dynamic-schedule) + +The `defineDynamicSchedule()` method defines a new Dynamic Schedule. + +#### [defineAuthResolver()](/sdk/triggerclient/instancemethods/define-auth-resolver) + +The `defineAuthResolver()` method defines a new Auth Resolver. diff --git a/integrations/airtable/src/base.ts b/integrations/airtable/src/base.ts index 821a56fced..b354bc136c 100644 --- a/integrations/airtable/src/base.ts +++ b/integrations/airtable/src/base.ts @@ -11,13 +11,10 @@ export type AirtableRecordsParams = TableParams<{}>; export type AirtableRecords = Records
; export class Base { - runTask: AirtableRunTask; - baseId: string; - - constructor(runTask: AirtableRunTask, baseId: string) { - this.runTask = runTask; - this.baseId = baseId; - } + constructor( + private runTask: AirtableRunTask, + public baseId: string + ) {} table(tableName: string) { return new Table(this.runTask, this.baseId, tableName); diff --git a/integrations/airtable/src/index.ts b/integrations/airtable/src/index.ts index c4443d7013..f8c2d99f15 100644 --- a/integrations/airtable/src/index.ts +++ b/integrations/airtable/src/index.ts @@ -1,6 +1,7 @@ import { Prettify } from "@trigger.dev/integration-kit"; import { Json, + retry, type ConnectionAuth, type IO, type IOTask, @@ -8,18 +9,10 @@ import { type RunTaskErrorCallback, type RunTaskOptions, type TriggerIntegration, - retry, } from "@trigger.dev/sdk"; import AirtableSDK from "airtable"; import { Base } from "./base"; -import * as events from "./events"; -import { - WebhookChangeType, - WebhookDataType, - Webhooks, - createTrigger, - createWebhookEventSource, -} from "./webhooks"; +import { Webhooks, createWebhookEventSource } from "./webhooks"; export * from "./types"; @@ -33,9 +26,13 @@ export type AirtableIntegrationOptions = { export type AirtableRunTask = InstanceType["runTask"]; export class Airtable implements TriggerIntegration { + // @internal private _options: AirtableIntegrationOptions; + // @internal private _client?: AirtableSDK; + // @internal private _io?: IO; + // @internal private _connectionKey?: string; constructor(options: Prettify) { diff --git a/integrations/airtable/tsconfig.json b/integrations/airtable/tsconfig.json index 9616ac98a0..d1b7924087 100644 --- a/integrations/airtable/tsconfig.json +++ b/integrations/airtable/tsconfig.json @@ -11,7 +11,8 @@ }, "declaration": false, "declarationMap": false, - "baseUrl": "." + "baseUrl": ".", + "stripInternal": true }, "exclude": ["node_modules"] } diff --git a/integrations/github/src/compound.ts b/integrations/github/src/compound.ts index 1e2c8f8a5e..ae8c85f4c3 100644 --- a/integrations/github/src/compound.ts +++ b/integrations/github/src/compound.ts @@ -5,15 +5,11 @@ import { Issues } from "./issues"; import { ReactionContent, Reactions } from "./reactions"; export class Compound { - runTask: GitHubRunTask; - issues: Issues; - reactions: Reactions; - - constructor(runTask: GitHubRunTask, issues: Issues, reactions: Reactions) { - this.runTask = runTask; - this.issues = issues; - this.reactions = reactions; - } + constructor( + private runTask: GitHubRunTask, + public issues: Issues, + public reactions: Reactions + ) {} createIssueCommentWithReaction( key: IntegrationTaskKey, diff --git a/integrations/github/src/git.ts b/integrations/github/src/git.ts index dda8837cd8..34bfd95404 100644 --- a/integrations/github/src/git.ts +++ b/integrations/github/src/git.ts @@ -34,11 +34,7 @@ type TreeType = { }; export class Git { - runTask: GitHubRunTask; - - constructor(runTask: GitHubRunTask) { - this.runTask = runTask; - } + constructor(private runTask: GitHubRunTask) {} createBlob( key: IntegrationTaskKey, diff --git a/integrations/github/src/index.ts b/integrations/github/src/index.ts index 57b8d4a66d..59e2dc968f 100644 --- a/integrations/github/src/index.ts +++ b/integrations/github/src/index.ts @@ -68,9 +68,13 @@ export type GitHubReturnType Promise<{ data: K }>, K >; export class Github implements TriggerIntegration { + // @internal private _options: GithubIntegrationOptions; + // @internal private _client?: Octokit; + // @internal private _io?: IO; + // @internal private _connectionKey?: string; _repoSource: ReturnType; diff --git a/integrations/github/src/issues.ts b/integrations/github/src/issues.ts index 5c4f343bef..80eea79556 100644 --- a/integrations/github/src/issues.ts +++ b/integrations/github/src/issues.ts @@ -6,11 +6,7 @@ import { issueProperties, repoProperties } from "./propertyHelpers"; type AddIssueLabels = GitHubReturnType; export class Issues { - runTask: GitHubRunTask; - - constructor(runTask: GitHubRunTask) { - this.runTask = runTask; - } + constructor(private runTask: GitHubRunTask) {} create( key: IntegrationTaskKey, diff --git a/integrations/github/src/orgs.ts b/integrations/github/src/orgs.ts index cd0dbedfe6..495aeb7b4e 100644 --- a/integrations/github/src/orgs.ts +++ b/integrations/github/src/orgs.ts @@ -3,11 +3,7 @@ import { Octokit } from "octokit"; import { GitHubReturnType, GitHubRunTask, onError } from "./index"; export class Orgs { - runTask: GitHubRunTask; - - constructor(runTask: GitHubRunTask) { - this.runTask = runTask; - } + constructor(private runTask: GitHubRunTask) {} updateWebhook( key: IntegrationTaskKey, diff --git a/integrations/github/src/reactions.ts b/integrations/github/src/reactions.ts index 84a6c31f88..e3b3c10d6c 100644 --- a/integrations/github/src/reactions.ts +++ b/integrations/github/src/reactions.ts @@ -15,11 +15,7 @@ export type ReactionContent = | "eyes"; export class Reactions { - runTask: GitHubRunTask; - - constructor(runTask: GitHubRunTask) { - this.runTask = runTask; - } + constructor(private runTask: GitHubRunTask) {} createForIssueComment( key: IntegrationTaskKey, diff --git a/integrations/github/src/repos.ts b/integrations/github/src/repos.ts index ef5b718692..50d5690853 100644 --- a/integrations/github/src/repos.ts +++ b/integrations/github/src/repos.ts @@ -1,15 +1,9 @@ -import { truncate } from "@trigger.dev/integration-kit"; -import { IntegrationTaskKey, Prettify, retry } from "@trigger.dev/sdk"; -import { GitHubReturnType, GitHubRunTask, onError } from "./index"; +import { IntegrationTaskKey } from "@trigger.dev/sdk"; import { Octokit } from "octokit"; -import { issueProperties, repoProperties } from "./propertyHelpers"; +import { GitHubReturnType, GitHubRunTask, onError } from "./index"; export class Repos { - runTask: GitHubRunTask; - - constructor(runTask: GitHubRunTask) { - this.runTask = runTask; - } + constructor(private runTask: GitHubRunTask) {} get( key: IntegrationTaskKey, diff --git a/integrations/github/src/sources.ts b/integrations/github/src/sources.ts index 1996f86259..a65a0a00e1 100644 --- a/integrations/github/src/sources.ts +++ b/integrations/github/src/sources.ts @@ -1,8 +1,7 @@ import { Webhooks } from "@octokit/webhooks"; -import { ExternalSource, TriggerIntegration, HandlerEvent } from "@trigger.dev/sdk"; +import { omit, safeJsonParse } from "@trigger.dev/integration-kit"; import type { Logger } from "@trigger.dev/sdk"; -import { safeJsonParse, omit } from "@trigger.dev/integration-kit"; -import { Octokit } from "octokit"; +import { ExternalSource, HandlerEvent } from "@trigger.dev/sdk"; import { z } from "zod"; import { Github } from "./index"; diff --git a/integrations/github/tsconfig.json b/integrations/github/tsconfig.json index 9616ac98a0..d1b7924087 100644 --- a/integrations/github/tsconfig.json +++ b/integrations/github/tsconfig.json @@ -11,7 +11,8 @@ }, "declaration": false, "declarationMap": false, - "baseUrl": "." + "baseUrl": ".", + "stripInternal": true }, "exclude": ["node_modules"] } diff --git a/integrations/openai/src/chat.ts b/integrations/openai/src/chat.ts index df647bcb3f..2cabd4c2a4 100644 --- a/integrations/openai/src/chat.ts +++ b/integrations/openai/src/chat.ts @@ -4,11 +4,7 @@ import { OpenAIRunTask } from "./index"; import { createTaskUsageProperties } from "./taskUtils"; export class Chat { - runTask: OpenAIRunTask; - - constructor(runTask: OpenAIRunTask) { - this.runTask = runTask; - } + constructor(private runTask: OpenAIRunTask) {} completions = { create: ( diff --git a/integrations/openai/src/index.ts b/integrations/openai/src/index.ts index 0da54a2f2b..4f3e2283c9 100644 --- a/integrations/openai/src/index.ts +++ b/integrations/openai/src/index.ts @@ -23,9 +23,13 @@ import { FineTunes } from "./fineTunes"; export type OpenAIRunTask = InstanceType["runTask"]; export class OpenAI implements TriggerIntegration { + // @internal private _options: OpenAIIntegrationOptions; + // @internal private _client?: OpenAIApi; + // @internal private _io?: IO; + // @internal private _connectionKey?: string; /** @@ -46,10 +50,6 @@ export class OpenAI implements TriggerIntegration { public readonly native: OpenAIApi; constructor(private options: OpenAIIntegrationOptions) { - if (Object.keys(options).includes("apiKey") && !options.apiKey) { - throw `Can't create OpenAI integration (${options.id}) as apiKey was undefined`; - } - this._options = options; this.native = new OpenAIApi({ @@ -63,11 +63,19 @@ export class OpenAI implements TriggerIntegration { } cloneForRun(io: IO, connectionKey: string, auth?: ConnectionAuth) { + const apiKey = this._options.apiKey ?? auth?.accessToken; + + if (!apiKey) { + throw new Error( + `Can't initialize OpenAI integration (${this._options.id}) as apiKey was undefined` + ); + } + const openai = new OpenAI(this._options); openai._io = io; openai._connectionKey = connectionKey; openai._client = new OpenAIApi({ - apiKey: this._options.apiKey, + apiKey, organization: this._options.organization, }); return openai; diff --git a/integrations/openai/src/types.ts b/integrations/openai/src/types.ts index db6869887d..481bf224dd 100644 --- a/integrations/openai/src/types.ts +++ b/integrations/openai/src/types.ts @@ -1,6 +1,6 @@ export type OpenAIIntegrationOptions = { id: string; - apiKey: string; + apiKey?: string; organization?: string; }; diff --git a/integrations/openai/tsconfig.json b/integrations/openai/tsconfig.json index 9616ac98a0..d1b7924087 100644 --- a/integrations/openai/tsconfig.json +++ b/integrations/openai/tsconfig.json @@ -11,7 +11,8 @@ }, "declaration": false, "declarationMap": false, - "baseUrl": "." + "baseUrl": ".", + "stripInternal": true }, "exclude": ["node_modules"] } diff --git a/integrations/plain/src/index.ts b/integrations/plain/src/index.ts index 206c7729bb..1761f5da80 100644 --- a/integrations/plain/src/index.ts +++ b/integrations/plain/src/index.ts @@ -23,21 +23,21 @@ import { export type PlainIntegrationOptions = { id: string; - apiKey: string; + apiKey?: string; apiUrl?: string; }; export class Plain implements TriggerIntegration { + // @internal private _options: PlainIntegrationOptions; + // @internal private _client?: PlainClient; + // @internal private _io?: IO; + // @internal private _connectionKey?: string; constructor(private options: PlainIntegrationOptions) { - if (Object.keys(options).includes("apiKey") && !options.apiKey) { - throw `Can't create Plain integration (${options.id}) as apiKey was undefined`; - } - this._options = options; } @@ -46,11 +46,19 @@ export class Plain implements TriggerIntegration { } cloneForRun(io: IO, connectionKey: string, auth?: ConnectionAuth) { + const apiKey = this._options.apiKey ?? auth?.accessToken; + + if (!apiKey) { + throw new Error( + `Can't initialize Plain integration (${this._options.id}) as apiKey was undefined` + ); + } + const plain = new Plain(this._options); plain._io = io; plain._connectionKey = connectionKey; plain._client = new PlainClient({ - apiKey: this._options.apiKey, + apiKey, apiUrl: this._options.apiUrl, }); return plain; diff --git a/integrations/plain/tsconfig.json b/integrations/plain/tsconfig.json index 9616ac98a0..d1b7924087 100644 --- a/integrations/plain/tsconfig.json +++ b/integrations/plain/tsconfig.json @@ -11,7 +11,8 @@ }, "declaration": false, "declarationMap": false, - "baseUrl": "." + "baseUrl": ".", + "stripInternal": true }, "exclude": ["node_modules"] } diff --git a/integrations/resend/src/index.ts b/integrations/resend/src/index.ts index 7b30670a50..f93be1964d 100644 --- a/integrations/resend/src/index.ts +++ b/integrations/resend/src/index.ts @@ -39,20 +39,27 @@ function onError(error: unknown) { export type ResendIntegrationOptions = { id: string; - apiKey: string; + apiKey?: string; }; export class Resend implements TriggerIntegration { + /** + * @internal + */ private _options: ResendIntegrationOptions; + /** + * @internal + */ private _client?: ResendClient; + /** + * @internal + */ private _io?: IO; - private _connectionKey?: string; - constructor(private options: ResendIntegrationOptions) { - if (Object.keys(options).includes("apiKey") && !options.apiKey) { - throw `Can't create Resend integration (${options.id}) as apiKey was undefined`; - } + // @internal + private _connectionKey?: string; + constructor(options: ResendIntegrationOptions) { this._options = options; } @@ -61,15 +68,23 @@ export class Resend implements TriggerIntegration { } cloneForRun(io: IO, connectionKey: string, auth?: ConnectionAuth) { + const apiKey = this._options.apiKey ?? auth?.accessToken; + + if (!apiKey) { + throw new Error( + `Can't create Resend integration (${this._options.id}) as apiKey was undefined` + ); + } + const resend = new Resend(this._options); resend._io = io; resend._connectionKey = connectionKey; - resend._client = new ResendClient(this._options.apiKey); + resend._client = new ResendClient(apiKey); return resend; } get id() { - return this.options.id; + return this._options.id; } get metadata() { diff --git a/integrations/resend/tsconfig.json b/integrations/resend/tsconfig.json index 9616ac98a0..d1b7924087 100644 --- a/integrations/resend/tsconfig.json +++ b/integrations/resend/tsconfig.json @@ -11,7 +11,8 @@ }, "declaration": false, "declarationMap": false, - "baseUrl": "." + "baseUrl": ".", + "stripInternal": true }, "exclude": ["node_modules"] } diff --git a/integrations/sendgrid/src/index.ts b/integrations/sendgrid/src/index.ts index fabf90158a..49a1a55f2d 100644 --- a/integrations/sendgrid/src/index.ts +++ b/integrations/sendgrid/src/index.ts @@ -15,20 +15,20 @@ type SendEmailData = Parameters["send"]>[0]; export type SendGridIntegrationOptions = { id: string; - apiKey: string; + apiKey?: string; }; export class SendGrid implements TriggerIntegration { + // @internal private _options: SendGridIntegrationOptions; + // @internal private _client?: MailService; + // @internal private _io?: IO; + // @internal private _connectionKey?: string; constructor(private options: SendGridIntegrationOptions) { - if (!options.apiKey) { - throw new Error(`Can't create SendGrid integration (${options.id}) as apiKey was undefined`); - } - this._options = options; } @@ -37,11 +37,19 @@ export class SendGrid implements TriggerIntegration { } cloneForRun(io: IO, connectionKey: string, auth?: ConnectionAuth) { + const apiKey = this._options.apiKey ?? auth?.accessToken; + + if (!apiKey) { + throw new Error( + `Can't initialize SendGrid integration (${this._options.id}) as apiKey was undefined` + ); + } + const sendgrid = new SendGrid(this._options); sendgrid._io = io; sendgrid._connectionKey = connectionKey; sendgrid._client = new MailService(); - sendgrid._client.setApiKey(this._options.apiKey); + sendgrid._client.setApiKey(apiKey); return sendgrid; } diff --git a/integrations/sendgrid/tsconfig.json b/integrations/sendgrid/tsconfig.json index 9616ac98a0..d1b7924087 100644 --- a/integrations/sendgrid/tsconfig.json +++ b/integrations/sendgrid/tsconfig.json @@ -11,7 +11,8 @@ }, "declaration": false, "declarationMap": false, - "baseUrl": "." + "baseUrl": ".", + "stripInternal": true }, "exclude": ["node_modules"] } diff --git a/integrations/slack/src/index.ts b/integrations/slack/src/index.ts index 4150246e8a..9a8571d4ce 100644 --- a/integrations/slack/src/index.ts +++ b/integrations/slack/src/index.ts @@ -47,9 +47,13 @@ export type ChatPostMessageArguments = { }; export class Slack implements TriggerIntegration { + // @internal private _options: SlackIntegrationOptions; + // @internal private _client?: WebClient; + // @internal private _io?: IO; + // @internal private _connectionKey?: string; constructor(private options: SlackIntegrationOptions) { diff --git a/integrations/slack/tsconfig.json b/integrations/slack/tsconfig.json index ce79f20799..1ab3bda72d 100644 --- a/integrations/slack/tsconfig.json +++ b/integrations/slack/tsconfig.json @@ -9,7 +9,8 @@ }, "declaration": false, "declarationMap": false, - "baseUrl": "." + "baseUrl": ".", + "stripInternal": true }, "exclude": ["node_modules"] } diff --git a/integrations/stripe/src/charges.ts b/integrations/stripe/src/charges.ts index 3698ae3b56..13383c6be0 100644 --- a/integrations/stripe/src/charges.ts +++ b/integrations/stripe/src/charges.ts @@ -2,11 +2,7 @@ import { IntegrationTaskKey } from "@trigger.dev/sdk"; import { CreateChargeParams, CreateChargeResponse, StripeRunTask } from "./index"; export class Charges { - runTask: StripeRunTask; - - constructor(runTask: StripeRunTask) { - this.runTask = runTask; - } + constructor(private runTask: StripeRunTask) {} /** * Use the [Payment Intents API](https://stripe.com/docs/api/payment_intents) to initiate a new payment instead diff --git a/integrations/stripe/src/checkout.ts b/integrations/stripe/src/checkout.ts index 205b9a9a1d..1107bcab76 100644 --- a/integrations/stripe/src/checkout.ts +++ b/integrations/stripe/src/checkout.ts @@ -2,11 +2,7 @@ import { IntegrationTaskKey } from "@trigger.dev/sdk"; import { CreateCheckoutSessionParams, CreateCheckoutSessionResponse, StripeRunTask } from "./index"; export class Checkout { - runTask: StripeRunTask; - - constructor(runTask: StripeRunTask) { - this.runTask = runTask; - } + constructor(private runTask: StripeRunTask) {} sessions = { /** diff --git a/integrations/stripe/src/customers.ts b/integrations/stripe/src/customers.ts index cf1344e862..beeeb12416 100644 --- a/integrations/stripe/src/customers.ts +++ b/integrations/stripe/src/customers.ts @@ -9,11 +9,7 @@ import { import { omit } from "./utils"; export class Customers { - runTask: StripeRunTask; - - constructor(runTask: StripeRunTask) { - this.runTask = runTask; - } + constructor(private runTask: StripeRunTask) {} create(key: IntegrationTaskKey, params: CreateCustomerParams): Promise { return this.runTask( diff --git a/integrations/stripe/src/index.ts b/integrations/stripe/src/index.ts index bf2f93e318..496fe6d0aa 100644 --- a/integrations/stripe/src/index.ts +++ b/integrations/stripe/src/index.ts @@ -51,9 +51,13 @@ export * from "./types"; export type StripeRunTask = InstanceType["runTask"]; export class Stripe implements TriggerIntegration { + // @internal private _options: StripeIntegrationOptions; + // @internal private _client?: StripeClient; + // @internal private _io?: IO; + // @internal private _connectionKey?: string; /** @@ -71,23 +75,25 @@ export class Stripe implements TriggerIntegration { * const customer = await stripe.native.customers.create({}); // etc. * ``` */ - public readonly native: StripeClient; + public readonly native?: StripeClient; constructor(private options: StripeIntegrationOptions) { this._options = options; - this.native = new StripeClient(options.apiKey, { - apiVersion: "2022-11-15", - typescript: true, - timeout: 10000, - maxNetworkRetries: 0, - stripeAccount: options.stripeAccount, - appInfo: { - name: "Trigger.dev Stripe Integration", - version: "0.1.0", - url: "https://trigger.dev", - }, - }); + this.native = options.apiKey + ? new StripeClient(options.apiKey, { + apiVersion: "2022-11-15", + typescript: true, + timeout: 10000, + maxNetworkRetries: 0, + stripeAccount: options.stripeAccount, + appInfo: { + name: "Trigger.dev Stripe Integration", + version: "0.1.0", + url: "https://trigger.dev", + }, + }) + : undefined; } get authSource() { @@ -95,10 +101,18 @@ export class Stripe implements TriggerIntegration { } cloneForRun(io: IO, connectionKey: string, auth?: ConnectionAuth) { + const apiKey = this._options.apiKey ?? auth?.accessToken; + + if (!apiKey) { + throw new Error( + `Can't initialize Stripe integration (${this._options.id}) as apiKey was undefined` + ); + } + const stripe = new Stripe(this._options); stripe._io = io; stripe._connectionKey = connectionKey; - stripe._client = new StripeClient(this._options.apiKey, { + stripe._client = new StripeClient(apiKey, { apiVersion: "2022-11-15", typescript: true, timeout: 10000, diff --git a/integrations/stripe/src/subscriptions.ts b/integrations/stripe/src/subscriptions.ts index f72c60de86..4fa05baaeb 100644 --- a/integrations/stripe/src/subscriptions.ts +++ b/integrations/stripe/src/subscriptions.ts @@ -3,11 +3,7 @@ import { RetrieveSubscriptionParams, RetrieveSubscriptionResponse, StripeRunTask import { omit } from "./utils"; export class Subscriptions { - runTask: StripeRunTask; - - constructor(runTask: StripeRunTask) { - this.runTask = runTask; - } + constructor(private runTask: StripeRunTask) {} /** * Retrieves the subscription with the given ID. diff --git a/integrations/stripe/src/types.ts b/integrations/stripe/src/types.ts index 67cddd55a0..28f6a45a87 100644 --- a/integrations/stripe/src/types.ts +++ b/integrations/stripe/src/types.ts @@ -6,7 +6,7 @@ export type StripeSDK = Stripe; export type StripeIntegrationOptions = { id: string; - apiKey: string; + apiKey?: string; /** * An account id on whose behalf you wish to make every request. diff --git a/integrations/stripe/src/webhookEndpoints.ts b/integrations/stripe/src/webhookEndpoints.ts index 2a7658dec2..d17b248e5a 100644 --- a/integrations/stripe/src/webhookEndpoints.ts +++ b/integrations/stripe/src/webhookEndpoints.ts @@ -11,11 +11,7 @@ import { import { omit } from "./utils"; export class WebhookEndpoints { - runTask: StripeRunTask; - - constructor(runTask: StripeRunTask) { - this.runTask = runTask; - } + constructor(private runTask: StripeRunTask) {} create(key: IntegrationTaskKey, params: CreateWebhookParams): Promise { return this.runTask( diff --git a/integrations/stripe/tsconfig.json b/integrations/stripe/tsconfig.json index 20a53815e4..e57743ca1f 100644 --- a/integrations/stripe/tsconfig.json +++ b/integrations/stripe/tsconfig.json @@ -19,7 +19,8 @@ "resolveJsonModule": true, "lib": ["es2019"], "module": "commonjs", - "target": "es2021" + "target": "es2021", + "stripInternal": true }, "include": ["./src/**/*.ts", "tsup.config.ts"], "exclude": ["node_modules"] diff --git a/integrations/supabase/src/database/index.ts b/integrations/supabase/src/database/index.ts index c7f286e112..40816163e1 100644 --- a/integrations/supabase/src/database/index.ts +++ b/integrations/supabase/src/database/index.ts @@ -55,9 +55,13 @@ export class Supabase< : any, > implements TriggerIntegration { + // @internal private _options: SupabaseIntegrationOptions; + // @internal private _client?: SupabaseClient; + // @internal private _io?: IO; + // @internal private _connectionKey?: string; /** diff --git a/integrations/supabase/src/management/index.ts b/integrations/supabase/src/management/index.ts index eec724abc8..6d638abf00 100644 --- a/integrations/supabase/src/management/index.ts +++ b/integrations/supabase/src/management/index.ts @@ -266,9 +266,13 @@ class SupabaseDatabase { } export class SupabaseManagement implements TriggerIntegration { + // @internal private _options: SupabaseManagementIntegrationOptions; + // @internal private _client?: SupabaseManagementAPI; + // @internal private _io?: IO; + // @internal private _connectionKey?: string; constructor(private options: SupabaseManagementIntegrationOptions) { diff --git a/integrations/supabase/tsconfig.json b/integrations/supabase/tsconfig.json index 9616ac98a0..d1b7924087 100644 --- a/integrations/supabase/tsconfig.json +++ b/integrations/supabase/tsconfig.json @@ -11,7 +11,8 @@ }, "declaration": false, "declarationMap": false, - "baseUrl": "." + "baseUrl": ".", + "stripInternal": true }, "exclude": ["node_modules"] } diff --git a/integrations/typeform/src/forms.ts b/integrations/typeform/src/forms.ts index d75fc513c1..a06a530a8f 100644 --- a/integrations/typeform/src/forms.ts +++ b/integrations/typeform/src/forms.ts @@ -3,11 +3,7 @@ import { GetFormParams, GetFormResponse, ListFormsParams, TypeformRunTask } from import { Typeform } from "@typeform/api-client"; export class Forms { - runTask: TypeformRunTask; - - constructor(runTask: TypeformRunTask) { - this.runTask = runTask; - } + constructor(private runTask: TypeformRunTask) {} list(key: IntegrationTaskKey, params: ListFormsParams): Promise { return this.runTask( diff --git a/integrations/typeform/src/index.ts b/integrations/typeform/src/index.ts index beb8f0319c..c2696453c2 100644 --- a/integrations/typeform/src/index.ts +++ b/integrations/typeform/src/index.ts @@ -37,16 +37,16 @@ type TypeformTrigger = ReturnType; export type TypeformRunTask = InstanceType["runTask"]; export class Typeform implements TriggerIntegration { + // @internal private _options: TypeformIntegrationOptions; + // @internal private _client?: TypeformSDK; + // @internal private _io?: IO; + // @internal private _connectionKey?: string; constructor(private options: TypeformIntegrationOptions) { - if (Object.keys(options).includes("token") && !options.token) { - throw `Can't create Typeform integration (${options.id}) as token was undefined`; - } - this._options = options; } @@ -63,10 +63,18 @@ export class Typeform implements TriggerIntegration { } cloneForRun(io: IO, connectionKey: string, auth?: ConnectionAuth) { + const token = this._options.token ?? auth?.accessToken; + + if (!token) { + throw new Error( + `Can't initialize Typeform integration (${this._options.id}) as token was undefined` + ); + } + const typeform = new Typeform(this._options); typeform._io = io; typeform._connectionKey = connectionKey; - typeform._client = createClient({ token: this._options.token }); + typeform._client = createClient({ token }); return typeform; } diff --git a/integrations/typeform/src/types.ts b/integrations/typeform/src/types.ts index d1e6d9cacf..817f617383 100644 --- a/integrations/typeform/src/types.ts +++ b/integrations/typeform/src/types.ts @@ -3,7 +3,7 @@ import { Typeform, createClient } from "@typeform/api-client"; export type TypeformIntegrationOptions = { id: string; - token: string; + token?: string; apiBaseUrl?: string; }; diff --git a/integrations/typeform/src/webhooks.ts b/integrations/typeform/src/webhooks.ts index d25d887759..ddb0065db5 100644 --- a/integrations/typeform/src/webhooks.ts +++ b/integrations/typeform/src/webhooks.ts @@ -12,11 +12,7 @@ import { } from "."; export class Webhooks { - runTask: TypeformRunTask; - - constructor(runTask: TypeformRunTask) { - this.runTask = runTask; - } + constructor(private runTask: TypeformRunTask) {} create(key: IntegrationTaskKey, params: CreateWebhookParams): Promise { return this.runTask( diff --git a/integrations/typeform/tsconfig.json b/integrations/typeform/tsconfig.json index 20a53815e4..e57743ca1f 100644 --- a/integrations/typeform/tsconfig.json +++ b/integrations/typeform/tsconfig.json @@ -19,7 +19,8 @@ "resolveJsonModule": true, "lib": ["es2019"], "module": "commonjs", - "target": "es2021" + "target": "es2021", + "stripInternal": true }, "include": ["./src/**/*.ts", "tsup.config.ts"], "exclude": ["node_modules"] diff --git a/packages/core/src/schemas/api.ts b/packages/core/src/schemas/api.ts index b59f23be76..9f47e9de0f 100644 --- a/packages/core/src/schemas/api.ts +++ b/packages/core/src/schemas/api.ts @@ -437,6 +437,13 @@ export const RunJobErrorSchema = z.object({ export type RunJobError = z.infer; +export const RunJobUnresolvedAuthErrorSchema = z.object({ + status: z.literal("UNRESOLVED_AUTH_ERROR"), + issues: z.record(z.object({ id: z.string(), error: z.string() })), +}); + +export type RunJobUnresolvedAuthError = z.infer; + export const RunJobResumeWithTaskSchema = z.object({ status: z.literal("RESUME_WITH_TASK"), task: TaskSchema, @@ -469,6 +476,7 @@ export type RunJobSuccess = z.infer; export const RunJobResponseSchema = z.discriminatedUnion("status", [ RunJobErrorSchema, + RunJobUnresolvedAuthErrorSchema, RunJobResumeWithTaskSchema, RunJobRetryWithTaskSchema, RunJobCanceledWithTaskSchema, @@ -675,6 +683,7 @@ export type RegisterTriggerBodyV1 = z.infer; export const RegisterTriggerBodySchemaV2 = z.object({ rule: EventRuleSchema, source: SourceMetadataV2Schema, + accountId: z.string().optional(), }); export type RegisterTriggerBodyV2 = z.infer; @@ -693,7 +702,7 @@ const RegisterCommonScheduleBodySchema = z.object({ id: z.string(), /** Any additional metadata about the schedule. */ metadata: z.any(), - /** This will be used by the Trigger.dev Connect feature, which is coming soon. */ + /** An optional Account ID to associate with runs triggered by this schedule */ accountId: z.string().optional(), }); diff --git a/packages/core/src/schemas/integrations.ts b/packages/core/src/schemas/integrations.ts index dd22ff40c4..1ec5ca1ce8 100644 --- a/packages/core/src/schemas/integrations.ts +++ b/packages/core/src/schemas/integrations.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const ConnectionAuthSchema = z.object({ - type: z.enum(["oauth2"]), + type: z.enum(["oauth2", "apiKey"]), accessToken: z.string(), scopes: z.array(z.string()).optional(), additionalFields: z.record(z.string()).optional(), @@ -20,7 +20,7 @@ export type IntegrationMetadata = z.infer; export const IntegrationConfigSchema = z.object({ id: z.string(), metadata: IntegrationMetadataSchema, - authSource: z.enum(["HOSTED", "LOCAL"]), + authSource: z.enum(["HOSTED", "LOCAL", "RESOLVER"]), }); export type IntegrationConfig = z.infer; diff --git a/packages/core/src/schemas/runs.ts b/packages/core/src/schemas/runs.ts index e22b964535..9a4535ff26 100644 --- a/packages/core/src/schemas/runs.ts +++ b/packages/core/src/schemas/runs.ts @@ -1,4 +1,4 @@ -import { ZodObject, z } from "zod"; +import { z } from "zod"; import { TaskStatusSchema } from "./tasks"; import { JobRunStatusRecordSchema } from "./statuses"; @@ -13,6 +13,7 @@ export const RunStatusSchema = z.union([ z.literal("TIMED_OUT"), z.literal("ABORTED"), z.literal("CANCELED"), + z.literal("UNRESOLVED_AUTH"), ]); export const RunTaskSchema = z.object({ diff --git a/packages/core/src/schemas/schedules.ts b/packages/core/src/schemas/schedules.ts index 5c23f37b5a..56b7538806 100644 --- a/packages/core/src/schemas/schedules.ts +++ b/packages/core/src/schemas/schedules.ts @@ -30,6 +30,8 @@ export type CronOptions = z.infer; export const CronMetadataSchema = z.object({ type: z.literal("cron"), options: CronOptionsSchema, + /** An optional Account ID to associate with runs triggered by this interval */ + accountId: z.string().optional(), metadata: z.any(), }); @@ -40,6 +42,8 @@ export const IntervalMetadataSchema = z.object({ type: z.literal("interval"), /** An object containing options about the interval. */ options: IntervalOptionsSchema, + /** An optional Account ID to associate with runs triggered by this interval */ + accountId: z.string().optional(), /** Any additional metadata about the schedule. */ metadata: z.any(), }); diff --git a/packages/database/prisma/migrations/20230919124531_add_resolver_auth_source/migration.sql b/packages/database/prisma/migrations/20230919124531_add_resolver_auth_source/migration.sql new file mode 100644 index 0000000000..e3a59cee4b --- /dev/null +++ b/packages/database/prisma/migrations/20230919124531_add_resolver_auth_source/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "IntegrationAuthSource" ADD VALUE 'RESOLVER'; diff --git a/packages/database/prisma/migrations/20230919150351_add_unresolved_auth_job_run_status/migration.sql b/packages/database/prisma/migrations/20230919150351_add_unresolved_auth_job_run_status/migration.sql new file mode 100644 index 0000000000..663479aaf6 --- /dev/null +++ b/packages/database/prisma/migrations/20230919150351_add_unresolved_auth_job_run_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "JobRunStatus" ADD VALUE 'UNRESOLVED_AUTH'; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 8e36acef9a..90cf7a5590 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -189,6 +189,7 @@ model Integration { enum IntegrationAuthSource { HOSTED LOCAL + RESOLVER } enum IntegrationSetupStatus { @@ -730,6 +731,7 @@ enum JobRunStatus { TIMED_OUT ABORTED CANCELED + UNRESOLVED_AUTH } model JobRunExecution { diff --git a/packages/trigger-sdk/src/apiClient.ts b/packages/trigger-sdk/src/apiClient.ts index f7a4bcb399..744a5cb8c9 100644 --- a/packages/trigger-sdk/src/apiClient.ts +++ b/packages/trigger-sdk/src/apiClient.ts @@ -12,8 +12,8 @@ import { LogLevel, Logger, RegisterScheduleResponseBodySchema, - RegisterSourceEventV2, RegisterSourceEventSchemaV2, + RegisterSourceEventV2, RunTaskBodyInput, ScheduleMetadata, SendEvent, @@ -21,12 +21,12 @@ import { ServerTaskSchema, TriggerSource, TriggerSourceSchema, - urlWithSearchParams, UpdateTriggerSourceBodyV2, RegisterTriggerBodyV2, GetRunStatusesSchema, JobRunStatusRecordSchema, StatusUpdate, + urlWithSearchParams, } from "@trigger.dev/core"; import fetch, { type RequestInit } from "node-fetch"; diff --git a/packages/trigger-sdk/src/io.ts b/packages/trigger-sdk/src/io.ts index f9cd6de238..9286e71090 100644 --- a/packages/trigger-sdk/src/io.ts +++ b/packages/trigger-sdk/src/io.ts @@ -355,6 +355,7 @@ export class IO { * @param id A unique id for the interval. This is used to identify and unregister the interval later. * @param options The options for the interval. * @returns A promise that has information about the interval. + * @deprecated Use `DynamicSchedule.register` instead. */ async registerInterval( key: string | any[], @@ -386,6 +387,7 @@ export class IO { * @param key Should be a stable and unique key inside the `run()`. See [resumability](https://trigger.dev/docs/documentation/concepts/resumability) for more information. * @param dynamicSchedule The [DynamicSchedule](https://trigger.dev/docs/sdk/dynamicschedule) to unregister a schedule on. * @param id A unique id for the interval. This is used to identify and unregister the interval later. + * @deprecated Use `DynamicSchedule.unregister` instead. */ async unregisterInterval(key: string | any[], dynamicSchedule: DynamicSchedule, id: string) { return await this.runTask( @@ -408,6 +410,7 @@ export class IO { * @param dynamicSchedule The [DynamicSchedule](https://trigger.dev/docs/sdk/dynamicschedule) to register a new schedule on. * @param id A unique id for the schedule. This is used to identify and unregister the schedule later. * @param options The options for the CRON schedule. + * @deprecated Use `DynamicSchedule.register` instead. */ async registerCron( key: string | any[], @@ -439,6 +442,7 @@ export class IO { * @param key Should be a stable and unique key inside the `run()`. See [resumability](https://trigger.dev/docs/documentation/concepts/resumability) for more information. * @param dynamicSchedule The [DynamicSchedule](https://trigger.dev/docs/sdk/dynamicschedule) to unregister a schedule on. * @param id A unique id for the interval. This is used to identify and unregister the interval later. + * @deprecated Use `DynamicSchedule.unregister` instead. */ async unregisterCron(key: string | any[], dynamicSchedule: DynamicSchedule, id: string) { return await this.runTask( @@ -461,6 +465,7 @@ export class IO { * @param trigger The [DynamicTrigger](https://trigger.dev/docs/sdk/dynamictrigger) to register. * @param id A unique id for the trigger. This is used to identify and unregister the trigger later. * @param params The params for the trigger. + * @deprecated Use `DynamicTrigger.register` instead. */ async registerTrigger< TTrigger extends DynamicTrigger, ExternalSource>, @@ -552,7 +557,7 @@ export class IO { const task = await this._apiClient.runTask(this._id, { idempotencyKey, - displayKey: typeof key === "string" ? key : undefined, + displayKey: typeof key === "string" ? key : key.join("."), noop: false, ...(options ?? {}), parentId, diff --git a/packages/trigger-sdk/src/job.ts b/packages/trigger-sdk/src/job.ts index ab6997f4be..651b475786 100644 --- a/packages/trigger-sdk/src/job.ts +++ b/packages/trigger-sdk/src/job.ts @@ -58,6 +58,9 @@ export type JobOptions< io: IOWithIntegrations, context: TriggerContext ) => Promise; + + // @internal + __internal?: boolean; }; export type JobPayload = TJob extends Job>, any> @@ -110,45 +113,10 @@ export class Job< return this.options.version; } - get integrations(): Record { - return Object.keys(this.options.integrations ?? {}).reduce( - (acc: Record, key) => { - const integration = this.options.integrations![key]; - - acc[key] = { - id: integration.id, - metadata: integration.metadata, - authSource: integration.authSource, - }; - - return acc; - }, - {} - ); - } - get logLevel() { return this.options.logLevel; } - toJSON(): JobMetadata { - // @ts-ignore - const internal = this.options.__internal as JobMetadata["internal"]; - - return { - id: this.id, - name: this.name, - version: this.version, - event: this.trigger.event, - trigger: this.trigger.toJSON(), - integrations: this.integrations, - startPosition: "latest", // this is deprecated, leaving this for now to make sure newer clients work with older servers - enabled: this.enabled, - preprocessRuns: this.trigger.preprocessRuns, - internal, - }; - } - // Make sure the id is valid (must only contain alphanumeric characters and dashes) // Make sure the version is valid (must be a valid semver version) #validate() { diff --git a/packages/trigger-sdk/src/runLocalStorage.ts b/packages/trigger-sdk/src/runLocalStorage.ts new file mode 100644 index 0000000000..e16274f2e9 --- /dev/null +++ b/packages/trigger-sdk/src/runLocalStorage.ts @@ -0,0 +1,10 @@ +import { IO } from "./io"; +import { TriggerContext } from "./types"; +import { TypedAsyncLocalStorage } from "./utils/typedAsyncLocalStorage"; + +export type RunStore = { + io: IO; + ctx: TriggerContext; +}; + +export const runLocalStorage = new TypedAsyncLocalStorage(); diff --git a/packages/trigger-sdk/src/triggerClient.ts b/packages/trigger-sdk/src/triggerClient.ts index d193c53d3f..ed8adaa924 100644 --- a/packages/trigger-sdk/src/triggerClient.ts +++ b/packages/trigger-sdk/src/triggerClient.ts @@ -9,6 +9,8 @@ import { HttpSourceResponseMetadata, IndexEndpointResponse, InitializeTriggerBodySchema, + IntegrationConfig, + JobMetadata, LogLevel, Logger, NormalizedResponse, @@ -34,9 +36,11 @@ import { TriggerIntegration } from "./integrations"; import { IO } from "./io"; import { createIOWithIntegrations } from "./ioWithIntegrations"; import { Job, JobOptions } from "./job"; -import { DynamicTrigger } from "./triggers/dynamic"; +import { runLocalStorage } from "./runLocalStorage"; +import { DynamicTrigger, DynamicTriggerOptions } from "./triggers/dynamic"; import { EventTrigger } from "./triggers/eventTrigger"; import { ExternalSource } from "./triggers/externalSource"; +import { DynamicIntervalOptions, DynamicSchedule } from "./triggers/scheduled"; import type { EventSpecification, Trigger, @@ -72,6 +76,17 @@ export type TriggerClientOptions = { ioLogLocalEnabled?: boolean; }; +export type AuthResolverResult = { + type: "apiKey" | "oauth"; + token: string; + additionalFields?: Record; +}; + +export type TriggerAuthResolver = ( + ctx: TriggerContext, + integration: TriggerIntegration +) => Promise; + /** A [TriggerClient](https://trigger.dev/docs/documentation/concepts/client-adaptors) is used to connect to a specific [Project](https://trigger.dev/docs/documentation/concepts/projects) by using an [API Key](https://trigger.dev/docs/documentation/concepts/environments-apikeys). */ export class TriggerClient { #options: TriggerClientOptions; @@ -94,6 +109,7 @@ export class TriggerClient { > = {}; #jobMetadataByDynamicTriggers: Record> = {}; #registeredSchedules: Record> = {}; + #authResolvers: Record = {}; #client: ApiClient; #internalLogger: Logger; @@ -199,29 +215,8 @@ export class TriggerClient { }; } case "INDEX_ENDPOINT": { - // if the x-trigger-job-id header is set, we return the job with that id - const jobId = request.headers.get("x-trigger-job-id"); - - if (jobId) { - const job = this.#registeredJobs[jobId]; - - if (!job) { - return { - status: 404, - body: { - message: "Job not found", - }, - }; - } - - return { - status: 200, - body: job.toJSON(), - }; - } - const body: IndexEndpointResponse = { - jobs: Object.values(this.#registeredJobs).map((job) => job.toJSON()), + jobs: this.#buildJobsIndex(), sources: Object.values(this.#registeredSources), dynamicTriggers: Object.values(this.#registeredDynamicTriggers).map((trigger) => ({ id: trigger.id, @@ -421,6 +416,35 @@ export class TriggerClient { }; } + defineJob< + TTrigger extends Trigger>, + TIntegrations extends Record = {}, + >(options: JobOptions) { + return new Job(this, options); + } + + defineAuthResolver( + integration: TriggerIntegration, + resolver: TriggerAuthResolver + ): TriggerClient { + this.#authResolvers[integration.id] = resolver; + + return this; + } + + defineDynamicSchedule(options: DynamicIntervalOptions): DynamicSchedule { + return new DynamicSchedule(this, options); + } + + defineDynamicTrigger< + TEventSpec extends EventSpecification, + TExternalSource extends ExternalSource, + >( + options: DynamicTriggerOptions + ): DynamicTrigger { + return new DynamicTrigger(this, options); + } + attach(job: Job, any>): void { this.#registeredJobs[job.id] = job; @@ -430,7 +454,7 @@ export class TriggerClient { attachDynamicTrigger(trigger: DynamicTrigger): void { this.#registeredDynamicTriggers[trigger.id] = trigger; - new Job(this, { + this.defineJob({ id: dynamicTriggerRegisterSourceJobId(trigger.id), name: `Register dynamic trigger ${trigger.id}`, version: trigger.source.version, @@ -454,7 +478,6 @@ export class TriggerClient { ...updates, }); }, - // @ts-ignore __internal: true, }); } @@ -534,12 +557,17 @@ export class TriggerClient { ...updates, }); }, - // @ts-ignore __internal: true, }); } - attachDynamicSchedule(key: string, job: Job, any>): void { + attachDynamicSchedule(key: string): void { + const jobs = this.#registeredSchedules[key] ?? []; + + this.#registeredSchedules[key] = jobs; + } + + attachDynamicScheduleToJob(key: string, job: Job, any>): void { const jobs = this.#registeredSchedules[key] ?? []; jobs.push({ id: job.id, version: job.version }); @@ -629,10 +657,14 @@ export class TriggerClient { }; } - async #executeJob(body: RunJobBody, job: Job, any>): Promise { + async #executeJob( + body: RunJobBody, + job: Job, Record> + ): Promise { this.#internalLogger.debug("executing job", { execution: body, - job: job.toJSON(), + job: job.id, + version: job.version, }); const context = this.#createRunContext(body); @@ -650,18 +682,33 @@ export class TriggerClient { : undefined, }); + const resolvedConnections = await this.#resolveConnections( + context, + job.options.integrations, + body.connections + ); + + if (!resolvedConnections.ok) { + return { + status: "UNRESOLVED_AUTH_ERROR", + issues: resolvedConnections.issues, + }; + } + const ioWithConnections = createIOWithIntegrations( io, - body.connections, + resolvedConnections.data, job.options.integrations ); try { - const output = await job.options.run( - job.trigger.event.parsePayload(body.event.payload ?? {}), - ioWithConnections, - context - ); + const output = await runLocalStorage.runWith({ io, ctx: context }, () => { + return job.options.run( + job.trigger.event.parsePayload(body.event.payload ?? {}), + ioWithConnections, + context + ); + }); return { status: "SUCCESS", output }; } catch (error) { @@ -866,11 +913,191 @@ export class TriggerClient { }; } - defineJob< - TTrigger extends Trigger>, - TIntegrations extends Record = {}, - >(options: JobOptions) { - return new Job(this, options); + async #resolveConnections( + ctx: TriggerContext, + integrations?: Record, + connections?: Record + ): Promise< + | { ok: true; data: Record } + | { ok: false; issues: Record } + > { + if (!integrations) { + return { ok: true, data: {} }; + } + + const resolvedAuthResults = await Promise.all( + Object.keys(integrations).map(async (key) => { + const integration = integrations[key]; + const auth = (connections ?? {})[key]; + + const result = await this.#resolveConnection(ctx, integration, auth); + + if (result.ok) { + return { + ok: true as const, + auth: result.auth, + key, + }; + } else { + return { + ok: false as const, + error: result.error, + key, + }; + } + }) + ); + + const allResolved = resolvedAuthResults.every((result) => result.ok); + + if (allResolved) { + return { + ok: true, + data: resolvedAuthResults.reduce((acc: Record, result) => { + acc[result.key] = result.auth!; + + return acc; + }, {}), + }; + } else { + return { + ok: false, + issues: resolvedAuthResults.reduce( + (acc: Record, result) => { + if (result.ok) { + return acc; + } + + const integration = integrations[result.key]; + + acc[result.key] = { id: integration.id, error: result.error }; + + return acc; + }, + {} + ), + }; + } + } + + async #resolveConnection( + ctx: TriggerContext, + integration: TriggerIntegration, + auth?: ConnectionAuth + ): Promise<{ ok: true; auth: ConnectionAuth | undefined } | { ok: false; error: string }> { + if (auth) { + return { ok: true, auth }; + } + + const authResolver = this.#authResolvers[integration.id]; + + if (!authResolver) { + if (integration.authSource === "HOSTED") { + return { + ok: false, + error: `Something went wrong: Integration ${integration.id} is missing auth credentials from Trigger.dev`, + }; + } + + return { + ok: true, + auth: undefined, + }; + } + + try { + const resolvedAuth = await authResolver(ctx, integration); + + if (!resolvedAuth) { + return { + ok: false, + error: `Auth could not be resolved for ${integration.id}: auth resolver returned null or undefined`, + }; + } + + return { + ok: true, + auth: + resolvedAuth.type === "apiKey" + ? { + type: "apiKey", + accessToken: resolvedAuth.token, + additionalFields: resolvedAuth.additionalFields, + } + : { + type: "oauth2", + accessToken: resolvedAuth.token, + additionalFields: resolvedAuth.additionalFields, + }, + }; + } catch (resolverError) { + if (resolverError instanceof Error) { + return { + ok: false, + error: `Auth could not be resolved for ${integration.id}: auth resolver threw. ${resolverError.name}: ${resolverError.message}`, + }; + } else if (typeof resolverError === "string") { + return { + ok: false, + error: `Auth could not be resolved for ${integration.id}: auth resolver threw an error: ${resolverError}`, + }; + } + + return { + ok: false, + error: `Auth could not be resolved for ${ + integration.id + }: auth resolver threw an unknown error: ${JSON.stringify(resolverError)}`, + }; + } + } + + #buildJobsIndex(): IndexEndpointResponse["jobs"] { + return Object.values(this.#registeredJobs).map((job) => this.#buildJobIndex(job)); + } + + #buildJobIndex(job: Job, any>): IndexEndpointResponse["jobs"][number] { + const internal = job.options.__internal as JobMetadata["internal"]; + + return { + id: job.id, + name: job.name, + version: job.version, + event: job.trigger.event, + trigger: job.trigger.toJSON(), + integrations: this.#buildJobIntegrations(job), + startPosition: "latest", // job is deprecated, leaving job for now to make sure newer clients work with older servers + enabled: job.enabled, + preprocessRuns: job.trigger.preprocessRuns, + internal, + }; + } + + #buildJobIntegrations( + job: Job, Record> + ): IndexEndpointResponse["jobs"][number]["integrations"] { + return Object.keys(job.options.integrations ?? {}).reduce( + (acc: Record, key) => { + const integration = job.options.integrations![key]; + + acc[key] = this.#buildJobIntegration(integration); + + return acc; + }, + {} + ); + } + + #buildJobIntegration( + integration: TriggerIntegration + ): IndexEndpointResponse["jobs"][number]["integrations"][string] { + const authSource = this.#authResolvers[integration.id] ? "RESOLVER" : integration.authSource; + + return { + id: integration.id, + metadata: integration.metadata, + authSource, + }; } } diff --git a/packages/trigger-sdk/src/triggers/dynamic.ts b/packages/trigger-sdk/src/triggers/dynamic.ts index 219af52de7..84203e33d8 100644 --- a/packages/trigger-sdk/src/triggers/dynamic.ts +++ b/packages/trigger-sdk/src/triggers/dynamic.ts @@ -9,6 +9,8 @@ import { TriggerClient } from "../triggerClient"; import { EventSpecification, Trigger } from "../types"; import { slugifyId } from "../utils"; import { ExternalSource, ExternalSourceParams } from "./externalSource"; +import { runLocalStorage } from "../runLocalStorage"; +import { EventFilter } from "@trigger.dev/core"; /** Options for a DynamicTrigger */ export type DynamicTriggerOptions< @@ -24,7 +26,7 @@ export type DynamicTriggerOptions< * ```ts * import { events } from "@trigger.dev/github"; * - * const dynamicOnIssueOpened = new DynamicTrigger(client, { + * const dynamicOnIssueOpened = client.defineDynamicTrigger({ id: "github-issue-opened", event: events.onIssueOpened, source: github.sources.repo, @@ -56,6 +58,7 @@ export class DynamicTrigger< client.attachDynamicTrigger(this); } + // @internal toJSON(): TriggerMetadata { return { type: "dynamic", @@ -71,14 +74,22 @@ export class DynamicTrigger< return this.#options.event; } - registeredTriggerForParams(params: ExternalSourceParams): RegisterTriggerBodyV2 { + // @internal + registeredTriggerForParams( + params: ExternalSourceParams, + options: { accountId?: string; filter?: EventFilter } = {} + ): RegisterTriggerBodyV2 { const key = slugifyId(this.source.key(params)); return { rule: { event: this.event.name, source: this.event.source, - payload: deepMergeFilters(this.source.filter(params), this.event.filter ?? {}), + payload: deepMergeFilters( + this.source.filter(params), + this.event.filter ?? {}, + options.filter ?? {} + ), }, source: { version: "2", @@ -95,18 +106,53 @@ export class DynamicTrigger< authSource: this.source.integration.authSource, }, }, + accountId: options.accountId, }; } /** Use this method to register a new configuration with the DynamicTrigger. * @param key The key for the configuration. This will be used to identify the configuration when it is triggered. * @param params The params for the configuration. + * @param options Options for the configuration. + * @param options.accountId The accountId to associate with the configuration. + * @param options.filter The filter to use for the configuration. + * */ async register( key: string, - params: ExternalSourceParams + params: ExternalSourceParams, + options: { accountId?: string; filter?: EventFilter } = {} ): Promise { - return this.#client.registerTrigger(this.id, key, this.registeredTriggerForParams(params)); + const runStore = runLocalStorage.getStore(); + + if (!runStore) { + return this.#client.registerTrigger( + this.id, + key, + this.registeredTriggerForParams(params, options) + ); + } + + const { io } = runStore; + + return await io.runTask( + [key, "register"], + async (task) => { + return this.#client.registerTrigger( + this.id, + key, + this.registeredTriggerForParams(params, options) + ); + }, + { + name: "Register Dynamic Trigger", + properties: [ + { label: "Dynamic Trigger ID", text: this.id }, + { label: "ID", text: key }, + ], + params: params as any, + } + ); } attachToJob(triggerClient: TriggerClient, job: Job, any>): void { diff --git a/packages/trigger-sdk/src/triggers/externalSource.ts b/packages/trigger-sdk/src/triggers/externalSource.ts index ccc274bdc3..8fa50f0247 100644 --- a/packages/trigger-sdk/src/triggers/externalSource.ts +++ b/packages/trigger-sdk/src/triggers/externalSource.ts @@ -260,9 +260,7 @@ export class ExternalSource< } export type ExternalSourceParams> = - TExternalSource extends ExternalSource - ? TParams & { filter?: EventFilter } - : never; + TExternalSource extends ExternalSource ? TParams : never; export type ExternalSourceTriggerOptions< TEventSpecification extends EventSpecification, diff --git a/packages/trigger-sdk/src/triggers/scheduled.ts b/packages/trigger-sdk/src/triggers/scheduled.ts index fed614eb6d..3dc2fe38e5 100644 --- a/packages/trigger-sdk/src/triggers/scheduled.ts +++ b/packages/trigger-sdk/src/triggers/scheduled.ts @@ -11,6 +11,7 @@ import { Job } from "../job"; import { TriggerClient } from "../triggerClient"; import { EventSpecification, Trigger } from "../types"; import cronstrue from "cronstrue"; +import { runLocalStorage } from "../runLocalStorage"; type ScheduledEventSpecification = EventSpecification; @@ -146,7 +147,9 @@ export class DynamicSchedule implements Trigger { constructor( private client: TriggerClient, private options: DynamicIntervalOptions - ) {} + ) { + client.attachDynamicSchedule(this.options.id); + } get id() { return this.options.id; @@ -164,18 +167,61 @@ export class DynamicSchedule implements Trigger { } async register(key: string, metadata: ScheduleMetadata) { - return this.client.registerSchedule(this.id, key, metadata); + const runStore = runLocalStorage.getStore(); + + if (!runStore) { + return this.client.registerSchedule(this.id, key, metadata); + } + + const { io } = runStore; + + return await io.runTask( + [key, "register"], + async (task) => { + return this.client.registerSchedule(this.id, key, metadata); + }, + { + name: "Register Schedule", + icon: metadata.type === "cron" ? "schedule-cron" : "schedule-interval", + properties: [ + { label: "Dynamic Schedule", text: this.id }, + { label: "Schedule ID", text: key }, + ], + params: metadata, + } + ); } async unregister(key: string) { - return this.client.unregisterSchedule(this.id, key); + const runStore = runLocalStorage.getStore(); + + if (!runStore) { + return this.client.unregisterSchedule(this.id, key); + } + + const { io } = runStore; + + return await io.runTask( + [key, "unregister"], + async (task) => { + return this.client.unregisterSchedule(this.id, key); + }, + { + name: "Unregister Schedule", + icon: "schedule", + properties: [ + { label: "Dynamic Schedule", text: this.id }, + { label: "Schedule ID", text: key }, + ], + } + ); } attachToJob( triggerClient: TriggerClient, job: Job, any> ): void { - triggerClient.attachDynamicSchedule(this.options.id, job); + triggerClient.attachDynamicScheduleToJob(this.options.id, job); } get preprocessRuns() { diff --git a/packages/trigger-sdk/src/utils/typedAsyncLocalStorage.ts b/packages/trigger-sdk/src/utils/typedAsyncLocalStorage.ts new file mode 100644 index 0000000000..e06496fa6e --- /dev/null +++ b/packages/trigger-sdk/src/utils/typedAsyncLocalStorage.ts @@ -0,0 +1,17 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export class TypedAsyncLocalStorage { + private storage: AsyncLocalStorage; + + constructor() { + this.storage = new AsyncLocalStorage(); + } + + runWith Promise>(context: T, fn: R): Promise> { + return this.storage.run(context, fn); + } + + getStore(): T | undefined { + return this.storage.getStore(); + } +} diff --git a/references/job-catalog/package.json b/references/job-catalog/package.json index 1cdeac4224..b0f84e0765 100644 --- a/references/job-catalog/package.json +++ b/references/job-catalog/package.json @@ -23,20 +23,23 @@ "background-fetch": "nodemon --watch src/background-fetch.ts -r tsconfig-paths/register -r dotenv/config src/background-fetch.ts", "linear": "nodemon --watch src/linear.ts -r tsconfig-paths/register -r dotenv/config src/linear.ts", "status": "nodemon --watch src/status.ts -r tsconfig-paths/register -r dotenv/config src/status.ts", + "byo-auth": "nodemon --watch src/byo-auth.ts -r tsconfig-paths/register -r dotenv/config src/byo-auth.ts", "dev:trigger": "trigger-cli dev --port 8080" }, "dependencies": { + "@clerk/backend": "^0.29.1", + "@trigger.dev/airtable": "workspace:*", "@trigger.dev/express": "workspace:*", "@trigger.dev/github": "workspace:*", "@trigger.dev/openai": "workspace:*", "@trigger.dev/plain": "workspace:*", "@trigger.dev/resend": "workspace:*", - "@trigger.dev/sendgrid": "workspace:*", "@trigger.dev/sdk": "workspace:*", + "@trigger.dev/sendgrid": "workspace:*", "@trigger.dev/slack": "workspace:*", "@trigger.dev/stripe": "workspace:*", - "@trigger.dev/typeform": "workspace:*", "@trigger.dev/supabase": "workspace:*", + "@trigger.dev/typeform": "workspace:*", "@types/node": "20.4.2", "typescript": "5.1.6", "zod": "3.21.4", @@ -55,4 +58,4 @@ "ts-node": "^10.9.1", "tsconfig-paths": "^3.14.1" } -} +} \ No newline at end of file diff --git a/references/job-catalog/src/byo-auth.ts b/references/job-catalog/src/byo-auth.ts new file mode 100644 index 0000000000..e77786bb13 --- /dev/null +++ b/references/job-catalog/src/byo-auth.ts @@ -0,0 +1,188 @@ +import { createExpressServer } from "@trigger.dev/express"; +import { TriggerClient, eventTrigger } from "@trigger.dev/sdk"; +import { Resend } from "@trigger.dev/resend"; +import { Stripe } from "@trigger.dev/stripe"; +import { Slack } from "@trigger.dev/slack"; +import { OpenAI } from "@trigger.dev/openai"; +import { Github, events } from "@trigger.dev/github"; +import { Clerk } from "@clerk/backend"; + +const clerk = Clerk({ apiKey: process.env.CLERK_API_KEY }); + +export const client = new TriggerClient({ + id: "job-catalog", + apiKey: process.env["TRIGGER_API_KEY"], + apiUrl: process.env["TRIGGER_API_URL"], + verbose: false, + ioLogLocalEnabled: true, +}); + +const resend = new Resend({ id: "resend" }); +const stripe = new Stripe({ + id: "stripe", +}); +const slack = new Slack({ id: "slack" }); +const openai = new OpenAI({ id: "openai" }); +const github = new Github({ id: "github" }); + +client.defineAuthResolver(resend, async (ctx, integration) => { + return { + type: "apiKey", + token: process.env.RESEND_API_KEY!, + additionalFields: { + baseUrl: "bar", + }, + }; +}); + +client.defineAuthResolver(resend, async (ctx, integration) => {}); + +client.defineAuthResolver(stripe, async (ctx, integration) => { + return { + type: "apiKey", + token: process.env.STRIPE_API_KEY!, + }; +}); + +client.defineAuthResolver(github, async (ctx, integration) => { + return { + type: "apiKey", + token: process.env.GITHUB_PAT!, + }; +}); + +client.defineAuthResolver(slack, async (ctx, integration) => { + if (!ctx.account?.id) { + return; + } + + const tokens = await clerk.users.getUserOauthAccessToken(ctx.account.id, "oauth_slack"); + + if (tokens.length === 0) { + throw new Error(`Could not find Slack auth for account ${ctx.account.id}`); + } + + return { + type: "oauth", + token: tokens[0].token, + }; +}); + +client.defineAuthResolver(openai, async (ctx, integration) => { + return { + type: "apiKey", + token: process.env.OPENAI_API_KEY!, + }; +}); + +client.defineJob({ + id: "send-account-event", + name: "Send Account Event", + version: "1.0.0", + enabled: true, + trigger: eventTrigger({ + name: "foo.bar", + }), + integrations: { + resend, + stripe, + slack, + openai, + }, + run: async (payload, io, ctx) => { + await io.logger.info("Sending email with context", { ctx }); + + await io.slack.postMessage("💬", { + channel: "C04GWUTDC3W", + text: "This is from an auth resolver", + }); + + await io.stripe.subscriptions.retrieve("🤑", { id: "1234" }); + + await io.resend.sendEmail("📧", { + subject: "Hello there", + text: "This is an email", + to: "eric@trigger.dev", + from: "hi@email.trigger.dev", + }); + }, +}); + +const dynamicInterval = client.defineDynamicSchedule({ id: "dynamic-schedule" }); + +client.defineJob({ + id: "register-interval", + name: "Register Interval for Account", + version: "0.0.1", + trigger: eventTrigger({ + name: "register.interval", + }), + run: async (payload, io, ctx) => { + await dynamicInterval.register("schedule_1235", { + type: "interval", + options: { seconds: payload.seconds }, // runs X seconds + accountId: "user_1235", // associate runs triggered by this schedule with user_123 + }); + }, +}); + +client.defineJob({ + id: "use-interval", + name: "Use Interval", + version: "0.0.1", + trigger: dynamicInterval, + run: async (payload, io, ctx) => { + await io.logger.info("Running interval", { ctx }); + }, +}); + +const dynamicOnIssueOpenedTrigger = client.defineDynamicTrigger({ + id: "github-issue-opened", + event: events.onIssueOpened, + source: github.sources.repo, +}); + +client.defineJob({ + id: "register-issue-opened", + name: "Register Issue Opened for Account", + version: "0.0.1", + trigger: eventTrigger({ + name: "register.issue.opened", + }), + run: async (payload, io, ctx) => { + await dynamicOnIssueOpenedTrigger.register( + payload.id, + { + owner: payload.owner, + repo: payload.repo, + }, + { + accountId: payload.accountId, + } + ); + }, +}); + +client.defineJob({ + id: "dynamic-issue-opened", + name: "Dynamic Issue Opened for Account", + version: "0.0.1", + trigger: dynamicOnIssueOpenedTrigger, + integrations: { + github, + }, + run: async (payload, io, ctx) => { + await io.github.issues.createComment("create-issue-comment", { + owner: payload.repository.owner.login, + repo: payload.repository.name, + issueNumber: payload.issue.number, + body: `Hello there: \n\n\`\`\`json\n${JSON.stringify( + payload, + null, + 2 + )}\`\`\`\n\n\`\`\`json\n${JSON.stringify(ctx, null, 2)}\`\`\``, + }); + }, +}); + +createExpressServer(client); diff --git a/references/job-catalog/src/dynamic-schedule.ts b/references/job-catalog/src/dynamic-schedule.ts index 1a9287362d..b3b7421951 100644 --- a/references/job-catalog/src/dynamic-schedule.ts +++ b/references/job-catalog/src/dynamic-schedule.ts @@ -1,5 +1,5 @@ -import { DynamicSchedule, TriggerClient, eventTrigger } from "@trigger.dev/sdk"; import { createExpressServer } from "@trigger.dev/express"; +import { TriggerClient, eventTrigger } from "@trigger.dev/sdk"; import { z } from "zod"; export const client = new TriggerClient({ @@ -10,7 +10,7 @@ export const client = new TriggerClient({ ioLogLocalEnabled: true, }); -const dynamicSchedule = new DynamicSchedule(client, { +const dynamicSchedule = client.defineDynamicSchedule({ id: "dynamic-interval", }); diff --git a/references/job-catalog/src/dynamic-triggers.ts b/references/job-catalog/src/dynamic-triggers.ts index fb2f82dadd..2aa80852c3 100644 --- a/references/job-catalog/src/dynamic-triggers.ts +++ b/references/job-catalog/src/dynamic-triggers.ts @@ -16,13 +16,13 @@ const github = new Github({ token: process.env["GITHUB_API_KEY"]!, }); -const dynamicOnIssueOpenedTrigger = new DynamicTrigger(client, { +const dynamicOnIssueOpenedTrigger = client.defineDynamicTrigger({ id: "github-issue-opened", event: events.onIssueOpened, source: github.sources.repo, }); -const dynamicUserTrigger = new DynamicTrigger(client, { +const dynamicUserTrigger = client.defineDynamicTrigger({ id: "dynamic-user-trigger", event: events.onIssueOpened, source: github.sources.repo, diff --git a/references/job-catalog/tsconfig.json b/references/job-catalog/tsconfig.json index e38922dd97..6ec3167e67 100644 --- a/references/job-catalog/tsconfig.json +++ b/references/job-catalog/tsconfig.json @@ -1,14 +1,9 @@ { "extends": "@trigger.dev/tsconfig/node18.json", - "include": [ - "./src/**/*.ts" - ], + "include": ["./src/**/*.ts"], "compilerOptions": { "baseUrl": ".", - "lib": [ - "DOM", - "DOM.Iterable" - ], + "lib": ["DOM", "DOM.Iterable"], "paths": { "@/*": [ "./src/*" @@ -105,4 +100,4 @@ ] } } -} \ No newline at end of file +}