Compare commits

...

47 Commits

Author SHA1 Message Date
Matt Aitken 23c93a5712 Latest lockfile 2023-09-05 19:12:56 +01:00
github-actions[bot] 208907e10e chore: Update version for release (beta) (#437)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-09-05 18:55:35 +01:00
Matt Aitken 3ef588e4e5 Supabase changeset: Don't retry enableDatabaseWebhooks 2023-09-05 18:01:11 +01:00
Matt Aitken 10836735be Supabase SDK tsconfig now matches OpenAI. enableDatabaseWebhooks doesn’t retry 2023-09-05 18:00:25 +01:00
Matt Aitken 090bc2e7d2 OverridableRunTaskOptions allows integrations to expose override task options to users 2023-09-05 17:59:46 +01:00
Matt Aitken 4fe2fd63de Latest lockfile 2023-09-05 17:00:27 +01:00
github-actions[bot] 145fb24e4d chore: Update version for release (beta) (#434)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-09-05 16:48:28 +01:00
Matt Aitken 2382d4a5c5 Added patch back in 2023-09-05 16:29:23 +01:00
Matt Aitken adbcfe6f20 Use experimental changeset peer dependency flag 2023-09-05 15:57:49 +01:00
Matt Aitken 0754e99507 Removed changeset patch (it didn’t work) and instead use the documented experimental flag 2023-09-05 15:57:37 +01:00
Matt Aitken dfb0a79abc Delete changeset 2023-09-05 15:40:15 +01:00
Matt Aitken c808e40f61 Changset onlyUpdatePeerDependentsWhenOutOfRange flag on 2023-09-05 15:28:45 +01:00
Matt Aitken a9bb53b529 Use patched changeset package, to get unreleased feature with peer dependencies 2023-09-05 15:28:33 +01:00
Matt Aitken 2636d9091e Release workflow: the changeset folder wasn’t set correctly 2023-09-05 15:14:46 +01:00
Matt Aitken 878da3c01f The changeset for the integrations changes 2023-09-05 14:53:21 +01:00
Matt Aitken 588461188f Removed old changeset 2023-09-05 14:43:02 +01:00
Matt Aitken d4145de9a7 Bumped Airtable starting version to match others 2023-09-05 14:40:41 +01:00
Matt Aitken 188c4b0e24 Trying Changeset experimental option to prevent minor + patch resulting in a major 2023-09-05 14:37:52 +01:00
Matt Aitken ecd050bece Airtable integration with webhook and integration changes (#399)
* CLI create-integration command now accepts an Open AI api key

* Create integration docs separated into multiple pages

* Initial Airtable integration commit, with OpenAI generated code

* OAuth page coming soon

* Export DisplayProperty from the SDK

* TSConfig made to match GitHub’s with paths

* getRecords

* Removed duplicate Stripe job from the catalog

* Renamed Airtable apiKey option to token

* First Airtable job

* Export Collaborator and Attachment field types

* A typesafe example that uses runTask

* WIP on new integration tasks… not working yet

* Attempt with class

* Revert "Attempt with class"

This reverts commit 93a48330019f754c3216c5b49964fa4b0218bd3f.

* WIP changing how tasks work

* Mock of async local storage

* Moved client creation from constructor

* New approach with a clone method on TriggerIntegration

* Added runTask to Airtable which is used by integration tasks

* Added the Airtable icon and connection when using runTask

* base().table() is working

* runTask options moved to the 3rd param, made optional with optional name

* Added some generic arguments

* Added generic type to table

* Removed old comment

* We don’t need to repeat the icon

* getRecords and getRecord now returning the right data and types

* Creating records

* Update records

* Delete records

* The internal properties of integrations are now hidden by the TypeScript types

* Sprinkled a Prettify in there

* Improved the types

* Added Airtable to the integration catalog

* Early work on Airtable webhook registration

* More progress with webhooks

* connectionKey needs to be cloned for webhooks to work

* connectionKey needs to be cloned for webhooks to work

* It was unclear that the ActivateSourceService was using a graphileJob id

* ActivateSourceService optionally takes a jobId, if missing it generate a unique id

* When retrying trigger registration, don’t pass an id so it is generated

* Removed Airtable webhooks tasks from the job-catalog example

* Added TriggerSourceOption, removed TriggerSourceEvent

* WIP with new ExternalSource options

* ExternalSourceTrigger setup

* DynamicTrigger changed to options, will need some more work

* filter gets options passed to it

* SourceMetadata v2 renamed to SourceMetadataV2, kept original

* Started versioning the backend

* Moved param order on io.getEvent and io.cancelEvent

* The runTask stuff that allows unknown to work is back

* Indexing for v1 and v2, with version on “activateSource” schema

* Added todos, to deal with Airtable SDK calls inside the webhook handler

* “deliverHttpSourceRequest” queueName changed to the source id so they process in order

* ActivateSource changes to deal with old and new data formats

* Update existing TriggerSources to v2

* Fix for dynamic.ts typescript errors, need to revisit this later

* UpdateSourceService v1 and v2, with new v2 API endpoint

* Removed unused imports

* More progress on v1 and v2

* Airtable webhooks are now triggering a job

* Moved webhooks to a new file

* You can do API calls in the webhook handler now, Airtable webhook data is being processed

* Airtable events coming through

* Defined the Airtable table payload type

* TriggerSource metadata is being stored and used

* Removed some logs

* Added filtering and don’t allow any webhooks that use automated sources

* Resend switched to new integration

* Moved Resend test jobs to the catalog, and tested it worked

* WIP on Slack, there are compile errors

* Created a generic type that strips out indexes

* Slack updated to use new integration

* SendGrid migrated over

* Integration runTask is now allowing regular types

* Changed io.runTask types so it only allows Json-able types

* OpenAI models tasks working

* Added Airtable changes to runTask

* Removed the index signature crap from the Slack integration

* Don’t need to cast the callback result

* Updated Resend

* Re-ordered runTask params

* WIP on openai

* onAccountUpdated is Connect only

* Removed RunTaskResult

* Handle Resend errors, the official SDK doesn’t expose them properly at the moment

* Removed OmitIndexSignature

* OpenAI converted to new integration, with backwards compatible functions

* Put the openai catalog back to what it was originally

* Export a standard retry with backoff, to be used

* Use the standard exponential backoff in the integrations

* Retry options moved earlier so they can be overriden by a task

* GitHub tasks migrated

* Added sources, fixed one bundling issue

* Added GitHub jobs to catalog

* Remove duplicate options

* Deduplicate events

* Removed duplicate Job

* Switched Plain over

* Set the Plain icon

* Converted Stripe over

* Supabase adapted

* Typeform working

* Added dynamic-schedule to catalog

* Added background-fetch job catalog

* Created dynamic-triggers catalog file

* Fixed old general file with runTask param order

* Dynamic triggers working

* SendGrid updated to use the same tsconfig as other integrations

* Removed Airtable webhook, until we have batch support

* Added OAuth airtable auth example

* Created beta changeset tag

* Beta changesets for most packages

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2023-09-05 14:21:42 +01:00
Eric Allam b24aeea592 Added a long running job to stress tests 2023-09-05 12:24:58 +01:00
Eric Allam 30ba73c4f1 Various run execution fixes (incl massive memory bloat issue)
🚀 Publish Trigger.dev Docker / ʦ TypeScript (push) Has been cancelled
🚀 Publish Trigger.dev Docker / Unit Tests (push) Has been cancelled
🚀 Publish Trigger.dev Docker / e2e Tests (push) Has been cancelled
🚀 Publish Trigger.dev Docker / publish (push) Has been cancelled
This commit fixes various issues with run executions, including a pretty gnarly memory bloat issue when resuming a run that had a decent number of completed tasks (e.g. anything over a few). Other issues fixed:

- Run executions no longer are bound to a queue, which will allow more parallel runs in a single job (instead of 1).
- Serverless function timeouts (504) errors are now handled better, and no longer are retried using the graphile worker failure/retry mechanism (causing massive delays).
- Fixed the job_key design of the performRunExecutionV2 task, which will ensure resumed runs are executed
- Added a mechanism to measure the amount of execution time a given run has accrued, and added a maximum execution duration on the org to be able to limit total execution time for a single run
2023-09-05 09:51:43 +01:00
Matt Aitken bf6a2a0319 Fix for seedCloud script having a duplicate pkApiKey in it 2023-09-04 20:19:53 +01:00
James Ritchie 4fc5de0dac Fixed console errors 2023-09-04 16:48:39 +01:00
Eric Allam 11ff63a9d2 Fix pnpm lock file 2023-09-02 07:08:36 +01:00
Eric Allam 0d1bdac8ab Merge branch 'main' of https://github.com/triggerdotdev/trigger.dev 2023-09-01 22:05:09 +01:00
Eric Allam 9f076631ae #346 Improve run dashboard perf when tasks have large outputs
Performance degradation came from the syntax highlighting of large code blocks and from doing that on the server and the client, so fixed this in a couple of ways:

1. Stream the task details data using defer and Suspense/Await
2. Skipped syntax highlighting code blocks with more than 1k lines
2023-09-01 22:05:06 +01:00
Matt Aitken a0d663c0fb Setup debugging for VSCode 2023-09-01 16:41:11 +01:00
D-K-P 8fd68e30e9 Removed typeform line 2023-08-31 11:15:47 +01:00
Akshay Patel 5c13512091 Better handle retrying rate-limited resend requests (#320)
* Better handle retrying rate-limited resend requests

* Better handle Retrying  Rate Request

* update try of better handle Resend Email retry

* added  retry options for when  the onError callback returns undefined

* Changed header to ratelimit-reset

* Create three-pots-flow.md

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2023-08-30 10:09:57 +01:00
Eric Allam 1d7e5737a0 Fix pnpm lock 2023-08-28 16:07:18 +01:00
github-actions[bot] 305e3b7ef2 chore: Update version for release (#417)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-28 16:06:35 +01:00
Eric Allam 03721cb18d Merge branch 'Chigala-refactor/lower-max-retries-for-specific-tasks' 2023-08-28 14:25:59 +01:00
Eric Allam 773a6e2c81 A couple of fixes 2023-08-28 14:25:41 +01:00
Eric Allam 2f13ac100f Merge branch 'refactor/lower-max-retries-for-specific-tasks' of https://github.com/Chigala/trigger.dev into Chigala-refactor/lower-max-retries-for-specific-tasks 2023-08-28 14:02:03 +01:00
Eric Allam a10782490f @trigger.dev/stripe: Added PaymentIntent and Payout trigger events 2023-08-28 12:40:02 +01:00
Eric Allam 6ad91123f2 Turned off automatic github releases 2023-08-28 10:49:24 +01:00
Eric Allam 81f2d5e4ec Fix pnpm lock 2023-08-28 10:45:16 +01:00
github-actions[bot] f249d8defa chore: Update version for release (#414)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-28 10:43:25 +01:00
Eric Allam 09aef5cda7 Merge branch 'main' of https://github.com/triggerdotdev/trigger.dev 2023-08-28 10:38:12 +01:00
Eric Allam 3028b6ad9d Try to automatically create github releases when packages are updated 2023-08-28 10:38:01 +01:00
Wesley 060b650845 fix/TRI 1149/cover all duplicated cases (#398)
* feat: select node by property key name

* feat: get `IfStatement`s recursively

* refactor: make `getNodeBody` more readable

* refactor: enshorter rule code

* refactor: remove additional spaces

* Create lovely-teachers-taste.md

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2023-08-28 10:26:31 +01:00
Chigala 6186a14398 fix: CLI init doesn't work outside of a GIT repo (#405) 2023-08-28 10:25:30 +01:00
Eric Allam 916a353660 bugfix: retrying tasks no longer incorrectly complete successfully 2023-08-28 10:16:47 +01:00
neo773 699878a5b1 feat: Add update checker for CLI (#412)
* feat: Add update checker for CLI

* Create soft-hounds-clap.md

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2023-08-28 09:36:53 +01:00
Chigala d2c9b64212 fix: CLI dev command doesn't work if you're already running ngrok (#407)
* fix: CLI dev command doesn't work if you're already running ngrok

* added changeset
2023-08-28 09:26:26 +01:00
Eric Allam 3102deccfb Add onAccountUpdated to the list of stripe triggers in the docs 2023-08-27 21:54:22 +01:00
Chigala aa7458fe37 refactor: reduced max retries on some task to 1 on development 2023-08-24 23:50:33 +01:00
202 changed files with 11820 additions and 5503 deletions
+20 -4
View File
@@ -1,11 +1,27 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json",
"changelog": "@changesets/cli/changelog",
"changelog": [
"@remix-run/changelog-github",
{
"repo": "triggerdotdev/trigger.dev"
}
],
"commit": false,
"fixed": [["@trigger.dev/*"]],
"fixed": [
[
"@trigger.dev/*"
]
],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["webapp", "emails", "@trigger.dev/database"]
}
"ignore": [
"webapp",
"emails",
"@trigger.dev/database"
],
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true
}
}
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/supabase": patch
---
Don't retry enableDatabaseWebhooks
+32
View File
@@ -0,0 +1,32 @@
{
"mode": "pre",
"tag": "beta",
"initialVersions": {
"webapp": "1.0.0",
"@trigger.dev/airtable": "2.0.14",
"@trigger.dev/github": "2.0.14",
"@trigger.dev/openai": "2.0.14",
"@trigger.dev/plain": "2.0.14",
"@trigger.dev/resend": "2.0.14",
"@trigger.dev/sendgrid": "2.0.14",
"@trigger.dev/slack": "2.0.14",
"@trigger.dev/stripe": "2.0.14",
"@trigger.dev/supabase": "2.0.14",
"@trigger.dev/typeform": "2.0.14",
"@trigger.dev/astro": "2.0.14",
"@trigger.dev/cli": "2.0.14",
"@trigger.dev/core": "2.0.14",
"@trigger.dev/database": "0.0.0",
"emails": "1.0.0",
"@trigger.dev/eslint-plugin": "2.0.14",
"@trigger.dev/express": "2.0.14",
"@trigger.dev/integration-kit": "2.0.14",
"@trigger.dev/nextjs": "2.0.14",
"@trigger.dev/react": "2.0.14",
"@trigger.dev/sdk": "2.0.14"
},
"changesets": [
"metal-ladybugs-behave",
"young-trains-give"
]
}
+23
View File
@@ -0,0 +1,23 @@
---
"@trigger.dev/airtable": minor
"@trigger.dev/github": minor
"@trigger.dev/openai": minor
"@trigger.dev/plain": minor
"@trigger.dev/resend": minor
"@trigger.dev/sendgrid": minor
"@trigger.dev/slack": minor
"@trigger.dev/stripe": minor
"@trigger.dev/supabase": minor
"@trigger.dev/typeform": minor
"@trigger.dev/astro": minor
"@trigger.dev/cli": minor
"@trigger.dev/core": minor
"@trigger.dev/eslint-plugin": minor
"@trigger.dev/express": minor
"@trigger.dev/integration-kit": minor
"@trigger.dev/nextjs": minor
"@trigger.dev/react": minor
"@trigger.dev/sdk": minor
---
Integrations are now simpler and support authentication during webhook registration
+1 -1
View File
@@ -8,7 +8,7 @@ on:
- ".github/workflows/release.yml"
- "packages/**"
- "!packages/**/*.md"
- "changesets/**"
- ".changeset/**"
- "integrations/**"
- "!integrations/**/*.md"
- "pnpm-lock.yaml"
+7 -16
View File
@@ -5,29 +5,20 @@
"version": "0.2.0",
"configurations": [
{
"command": "pnpm run dev --filter webapp",
"name": "Run webapp",
"request": "launch",
"type": "node-terminal",
"cwd": "${workspaceFolder}"
"request": "launch",
"name": "Debug WebApp",
"command": "pnpm run dev --filter webapp",
"envFile": "${workspaceFolder}/apps/webapp/.env",
"cwd": "${workspaceFolder}",
"sourceMaps": true
},
{
"type": "chrome",
"request": "launch",
"name": "Chrome webapp",
"url": "http://localhost:3000",
"url": "http://localhost:3030",
"webRoot": "${workspaceFolder}/apps/webapp/app"
},
{
"type": "node",
"request": "launch",
"name": "Debug Current Test File",
"autoAttachChildProcesses": true,
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
"args": ["run", "${relativeFile}"],
"smartStep": true,
"console": "integratedTerminal"
}
]
}
+103 -87
View File
@@ -1,10 +1,9 @@
import { Clipboard, ClipboardCheck } from "lucide-react";
import type { Language, PrismTheme } from "prism-react-renderer";
import Highlight, { defaultProps } from "prism-react-renderer";
import { forwardRef, useCallback, useState } from "react";
import { cn } from "~/utils/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { ClipboardDocumentCheckIcon, ClipboardIcon } from "@heroicons/react/24/solid";
import { Clipboard, ClipboardCheck, ClipboardCheckIcon } from "lucide-react";
//This is a fork of https://github.com/mantinedev/mantine/blob/master/src/mantine-prism/src/Prism/Prism.tsx
//it didn't support highlighting lines by dimming the rest of the code, or animations on the highlighting
@@ -192,6 +191,9 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
Array.from({ length: end - start + 1 }, (_, i) => start + i)
);
// if there are more than 1000 lines, don't highlight
const shouldHighlight = lineCount <= 1000;
return (
<div
className={cn("relative overflow-hidden rounded-md border border-slate-800", className)}
@@ -229,99 +231,113 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
</TooltipProvider>
)}
<Highlight {...defaultProps} theme={theme} code={code} language={language}>
{({
className: inheritedClassName,
style: inheritedStyle,
tokens,
getLineProps,
getTokenProps,
}) => (
<div
dir="ltr"
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
style={{
maxHeight,
}}
>
<pre
className={cn(
"relative mr-2 font-mono text-xs leading-relaxed",
inheritedClassName
)}
style={inheritedStyle}
{shouldHighlight ? (
<Highlight {...defaultProps} theme={theme} code={code} language={language}>
{({
className: inheritedClassName,
style: inheritedStyle,
tokens,
getLineProps,
getTokenProps,
}) => (
<div
dir="ltr"
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
style={{
maxHeight,
}}
>
{tokens
.map((line, index) => {
if (
index === tokens.length - 1 &&
line.length === 1 &&
line[0].content === "\n"
) {
return null;
}
<pre
className={cn(
"relative mr-2 font-mono text-xs leading-relaxed",
inheritedClassName
)}
style={inheritedStyle}
dir="ltr"
>
{tokens
.map((line, index) => {
if (
index === tokens.length - 1 &&
line.length === 1 &&
line[0].content === "\n"
) {
return null;
}
const lineNumber = index + 1;
const lineProps = getLineProps({ line, key: index });
const lineNumber = index + 1;
const lineProps = getLineProps({ line, key: index });
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
let shouldDim = hasAnyHighlights;
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
shouldDim = false;
}
let shouldDim = hasAnyHighlights;
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
shouldDim = false;
}
return (
<div
key={lineProps.key}
{...lineProps}
className={cn(
"flex w-full justify-start transition-opacity duration-500",
lineProps.className
)}
style={{
opacity: shouldDim ? dimAmount : undefined,
...lineProps.style,
}}
>
{showLineNumbers && (
<div
className={
"mr-2 flex-none select-none text-right text-slate-500 transition-opacity duration-500"
}
style={{
width: `calc(8 * ${maxLineWidth / 16}rem)`,
}}
>
{lineNumber}
return (
<div
key={lineProps.key}
{...lineProps}
className={cn(
"flex w-full justify-start transition-opacity duration-500",
lineProps.className
)}
style={{
opacity: shouldDim ? dimAmount : undefined,
...lineProps.style,
}}
>
{showLineNumbers && (
<div
className={
"mr-2 flex-none select-none text-right text-slate-500 transition-opacity duration-500"
}
style={{
width: `calc(8 * ${maxLineWidth / 16}rem)`,
}}
>
{lineNumber}
</div>
)}
<div className="flex-1">
{line.map((token, key) => {
const tokenProps = getTokenProps({ token, key });
return (
<span
key={tokenProps.key}
{...tokenProps}
style={{
color: tokenProps?.style?.color as string,
...tokenProps.style,
}}
/>
);
})}
</div>
)}
<div className="flex-1">
{line.map((token, key) => {
const tokenProps = getTokenProps({ token, key });
return (
<span
key={tokenProps.key}
{...tokenProps}
style={{
color: tokenProps?.style?.color as string,
...tokenProps.style,
}}
/>
);
})}
<div className="w-4 flex-none" />
</div>
<div className="w-4 flex-none" />
</div>
);
})
.filter(Boolean)}
</pre>
</div>
)}
</Highlight>
);
})
.filter(Boolean)}
</pre>
</div>
)}
</Highlight>
) : (
<div
dir="ltr"
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
style={{
maxHeight,
}}
>
<pre className="relative mr-2 p-2 font-mono text-xs leading-relaxed" dir="ltr">
{code}
</pre>
</div>
)}
</div>
);
}
@@ -54,7 +54,7 @@ export function RunCompletedDetail({ run }: { run: MatchedRun }) {
<RunPanelDivider />
{run.error && <RunPanelError text={run.error.message} stackTrace={run.error.stack} />}
{run.output ? (
<CodeBlock language="json" code={run.output} />
<CodeBlock language="json" code={run.output} maxLines={36} />
) : (
run.output === null && <Paragraph variant="small">This run returned nothing</Paragraph>
)}
+11 -16
View File
@@ -1,4 +1,3 @@
import { DetailedTask } from "~/presenters/TaskDetailsPresenter.server";
import {
RunPanel,
RunPanelBody,
@@ -29,22 +28,16 @@ import {
} from "../primitives/Table";
import { TaskAttemptStatusLabel } from "./TaskAttemptStatus";
import { TaskStatusIcon } from "./TaskStatus";
import { ClientOnly } from "remix-utils";
import { Spinner } from "../primitives/Spinner";
import type { DetailedTask } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route";
export function TaskDetail({ task }: { task: DetailedTask }) {
const {
name,
description,
icon,
startedAt,
completedAt,
status,
delayUntil,
params,
properties,
output,
style,
attempts,
} = task;
const { name, description, icon, status, params, properties, output, style, attempts } = task;
const startedAt = task.startedAt ? new Date(task.startedAt) : undefined;
const completedAt = task.completedAt ? new Date(task.completedAt) : undefined;
const delayUntil = task.delayUntil ? new Date(task.delayUntil) : undefined;
return (
<RunPanel selected={false}>
@@ -150,7 +143,9 @@ export function TaskDetail({ task }: { task: DetailedTask }) {
<div className="mt-4 flex flex-col gap-2">
<Header3>Output</Header3>
{output ? (
<CodeBlock code={JSON.stringify(output, null, 2)} />
<ClientOnly fallback={<Spinner />}>
{() => <CodeBlock code={output} maxLines={35} />}
</ClientOnly>
) : (
<Paragraph variant="small">No output</Paragraph>
)}
@@ -27,6 +27,8 @@ export type EnqueueRunExecutionV2Options = {
runAt?: Date;
resumeTaskId?: string;
isRetry?: boolean;
skipRetrying?: boolean;
executionCount?: number;
};
export async function enqueueRunExecutionV2(
@@ -43,10 +45,12 @@ export async function enqueueRunExecutionV2(
isRetry: typeof options.isRetry === "boolean" ? options.isRetry : false,
},
{
queueName: `job:${run.jobId}:env:${run.environmentId}`,
tx,
runAt: options.runAt,
jobKey: `job_run:${run.id}`,
jobKey: `job_run:${run.id}:${options.executionCount ?? 0}${
options.resumeTaskId ? `:task:${options.resumeTaskId}` : ""
}`,
maxAttempts: options.skipRetrying ? 1 : undefined,
}
);
}
@@ -0,0 +1,61 @@
import { ExternalAccount, Integration, TriggerSource } from "@trigger.dev/database";
import { ConnectionAuth } from "@trigger.dev/core";
import { PrismaClientOrTransaction } from "~/db.server";
import { integrationAuthRepository } from "~/services/externalApis/integrationAuthRepository.server";
import { logger } from "~/services/logger.server";
type ResolvableTriggerSource = TriggerSource & {
integration: Integration;
externalAccount: ExternalAccount | null;
};
export async function resolveSourceConnection(
tx: PrismaClientOrTransaction,
source: ResolvableTriggerSource
): Promise<ConnectionAuth | undefined> {
if (source.integration.authSource !== "HOSTED") return;
const connection = await getConnection(tx, source);
if (!connection) {
logger.error(
`Integration connection not found for source ${source.id}, integration ${source.integration.id}`
);
return;
}
const response = await integrationAuthRepository.getCredentials(connection);
if (!response) {
return;
}
return {
type: "oauth2",
scopes: response.scopes,
accessToken: response.accessToken,
};
}
function getConnection(tx: PrismaClientOrTransaction, source: ResolvableTriggerSource) {
if (source.externalAccount) {
return tx.integrationConnection.findFirst({
where: {
integrationId: source.integration.id,
externalAccountId: source.externalAccount.id,
},
include: {
dataReference: true,
},
});
}
return tx.integrationConnection.findFirst({
where: {
integrationId: source.integration.id,
},
include: {
dataReference: true,
},
});
}
+12 -2
View File
@@ -195,12 +195,13 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
): Promise<GraphileJob> {
const task = this.#tasks[identifier];
const optionsWithoutTx = omit(options ?? {}, ["tx"]);
const optionsWithoutTx = removeUndefinedKeys(omit(options ?? {}, ["tx"]));
const taskWithoutJobKey = omit(task, ["jobKey"]);
// Make sure options passed in to enqueue take precedence over task options
const spec = {
...optionsWithoutTx,
...taskWithoutJobKey,
...optionsWithoutTx,
};
if (typeof task.queueName === "function") {
@@ -437,3 +438,12 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
logger.debug(`[worker][${this.#name}] ${message}`, args);
}
}
function removeUndefinedKeys<T extends object>(obj: T): T {
for (let key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key) && obj[key] === undefined) {
delete obj[key];
}
}
return obj;
}
@@ -1,4 +1,4 @@
import { DisplayPropertiesSchema, StyleSchema } from "@trigger.dev/core";
import { StyleSchema } from "@trigger.dev/core";
import { PrismaClient, prisma } from "~/db.server";
import { mergeProperties } from "~/utils/mergeProperties.server";
@@ -7,8 +7,6 @@ type DetailsProps = {
userId: string;
};
export type DetailedTask = NonNullable<Awaited<ReturnType<TaskDetailsPresenter["call"]>>>;
export class TaskDetailsPresenter {
#prismaClient: PrismaClient;
@@ -87,6 +85,7 @@ export class TaskDetailsPresenter {
return {
...task,
output: task.output ? JSON.stringify(task.output, null, 2) : undefined,
connection: task.runConnection,
params: task.params as Record<string, any>,
properties: mergeProperties(task.properties, task.outputProperties),
@@ -62,6 +62,22 @@ export class TriggerSourcePresenter {
},
},
},
dynamicTrigger: {
select: {
id: true,
slug: true,
sourceRegistrationJob: {
select: {
job: {
select: {
id: true,
slug: true,
},
},
},
},
},
},
},
where: {
id: triggerSourceId,
@@ -73,10 +89,15 @@ export class TriggerSourcePresenter {
}
const runListPresenter = new RunListPresenter(this.#prismaClient);
const runList = trigger.sourceRegistrationJob
const jobSlug = getJobSlug(
trigger.sourceRegistrationJob?.job.slug,
trigger.dynamicTrigger?.sourceRegistrationJob?.job.slug
);
const runList = jobSlug
? await runListPresenter.call({
userId,
jobSlug: trigger.sourceRegistrationJob.job.slug,
jobSlug,
organizationSlug,
projectSlug,
direction,
@@ -95,7 +116,21 @@ export class TriggerSourcePresenter {
params: trigger.params,
registrationJob: trigger.sourceRegistrationJob?.job,
runList,
dynamic: trigger.dynamicTrigger
? { id: trigger.dynamicTrigger.id, slug: trigger.dynamicTrigger.slug }
: undefined,
},
};
}
}
function getJobSlug(
sourceRegistrationJobSlug: string | undefined,
dynamicSourceRegistrationJobSlug: string | undefined
) {
if (sourceRegistrationJobSlug) {
return sourceRegistrationJobSlug;
}
return dynamicSourceRegistrationJobSlug;
}
@@ -1,5 +1,7 @@
import { LoaderArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { Await, useLoaderData } from "@remix-run/react";
import { LoaderArgs, SerializeFrom, defer } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { Spinner } from "~/components/primitives/Spinner";
import { TaskDetail } from "~/components/run/TaskDetail";
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
import { requireUserId } from "~/services/session.server";
@@ -10,23 +12,26 @@ export const loader = async ({ request, params }: LoaderArgs) => {
const { taskParam } = TaskParamsSchema.parse(params);
const presenter = new TaskDetailsPresenter();
const task = await presenter.call({
const taskPromise = presenter.call({
userId,
id: taskParam,
});
if (!task) {
throw new Response(null, {
status: 404,
});
}
return typedjson({
task,
return defer({
taskPromise,
});
};
export type DetailedTask = NonNullable<Awaited<SerializeFrom<typeof loader>["taskPromise"]>>;
export default function Page() {
const { task } = useTypedLoaderData<typeof loader>();
return <TaskDetail task={task} />;
const { taskPromise } = useLoaderData<typeof loader>();
return (
<Suspense fallback={<Spinner />}>
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
</Await>
</Suspense>
);
}
@@ -43,6 +43,7 @@ import { parse } from "@conform-to/zod";
import { z } from "zod";
import { ActivateSourceService } from "~/services/sources/activateSource.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { nanoid } from "nanoid";
export const loader = async ({ request, params }: LoaderArgs) => {
const user = await requireUser(request);
@@ -90,7 +91,7 @@ export const action: ActionFunction = async ({ request, params }) => {
try {
const service = new ActivateSourceService();
const result = await service.call(triggerParam, submission.value.jobId);
const result = await service.call(triggerParam);
return redirectWithSuccessMessage(
externalTriggerPath({ slug: organizationSlug }, { slug: projectParam }, { id: triggerParam }),
@@ -167,6 +168,17 @@ export default function Page() {
<NamedIcon name={trigger.active ? "active" : "inactive"} className="h-4 w-4" />
}
/>
{trigger.dynamic && (
<PageInfoProperty
label="Dynamic"
value={
<span className="flex items-center gap-0.5">
<NamedIcon name="dynamic" className="h-4 w-4" />
{trigger.dynamic.slug}
</span>
}
/>
)}
<PageInfoProperty
label="Environment"
value={<EnvironmentLabel environment={trigger.environment} />}
@@ -206,7 +218,7 @@ export default function Page() {
</Button>
</Callout>
</Form>
) : (
) : trigger.dynamic ? null : (
<Callout variant="error" className="justiy-between mb-4 items-center">
This External Trigger hasn't registered successfully. Contact support for help:{" "}
{trigger.id}
@@ -1,5 +1,7 @@
import { LoaderArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { Await, useLoaderData } from "@remix-run/react";
import { LoaderArgs, defer } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { Spinner } from "~/components/primitives/Spinner";
import { TaskDetail } from "~/components/run/TaskDetail";
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
import { requireUserId } from "~/services/session.server";
@@ -10,23 +12,24 @@ export const loader = async ({ request, params }: LoaderArgs) => {
const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params);
const presenter = new TaskDetailsPresenter();
const task = await presenter.call({
const taskPromise = presenter.call({
userId,
id: taskParam,
});
if (!task) {
throw new Response(null, {
status: 404,
});
}
return typedjson({
task,
return defer({
taskPromise,
});
};
export default function Page() {
const { task } = useTypedLoaderData<typeof loader>();
return <TaskDetail task={task} />;
const { taskPromise } = useLoaderData<typeof loader>();
return (
<Suspense fallback={<Spinner />}>
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
</Await>
</Suspense>
);
}
@@ -19,7 +19,10 @@ export async function action({ request }: ActionArgs) {
},
body: JSON.stringify({
title: body.type,
content: body,
content: {
...body,
example: { id: body.type, name: body.type, icon: "stripe", payload: body.data.object },
},
readOnly: true,
}),
});
@@ -1,10 +1,10 @@
import type { ActionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { UpdateTriggerSourceBodySchema } from "@trigger.dev/core";
import { UpdateTriggerSourceBodyV1Schema } from "@trigger.dev/core";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { UpdateSourceService } from "~/services/sources/updateSource.server";
import { UpdateSourceServiceV1 } from "~/services/sources/updateSourceV1.server";
const ParamsSchema = z.object({
endpointSlug: z.string(),
@@ -40,13 +40,13 @@ export async function action({ request, params }: ActionArgs) {
// Now parse the request body
const anyBody = await request.json();
const body = UpdateTriggerSourceBodySchema.safeParse(anyBody);
const body = UpdateTriggerSourceBodyV1Schema.safeParse(anyBody);
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
const service = new UpdateSourceService();
const service = new UpdateSourceServiceV1();
try {
const source = await service.call({
@@ -1,10 +1,10 @@
import type { ActionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { RegisterTriggerBodySchema } from "@trigger.dev/core";
import { RegisterTriggerBodySchemaV1 } from "@trigger.dev/core";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { RegisterTriggerSourceService } from "~/services/triggers/registerTriggerSource.server";
import { RegisterTriggerSourceServiceV1 } from "~/services/triggers/registerTriggerSourceV1.server";
const ParamsSchema = z.object({
endpointSlug: z.string(),
@@ -41,13 +41,13 @@ export async function action({ request, params }: ActionArgs) {
// Now parse the request body
const anyBody = await request.json();
const body = RegisterTriggerBodySchema.safeParse(anyBody);
const body = RegisterTriggerBodySchemaV1.safeParse(anyBody);
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
const service = new RegisterTriggerSourceService();
const service = new RegisterTriggerSourceServiceV1();
try {
const registration = await service.call({
@@ -1,4 +1,5 @@
import { ActionArgs, LoaderArgs, json } from "@remix-run/server-runtime";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
@@ -99,6 +100,9 @@ export class TriggerEndpointIndexHookService {
slug: endpointSlug,
},
},
include: {
environment: true,
},
});
if (!endpoint) {
@@ -122,6 +126,8 @@ export class TriggerEndpointIndexHookService {
},
{
runAt: new Date(Date.now() + 5000),
maxAttempts:
endpoint.environment.type === RuntimeEnvironmentType.DEVELOPMENT ? 1 : undefined,
}
);
}
@@ -183,7 +183,7 @@ export class RunTaskService {
},
},
parent: taskBody.parentId ? { connect: { id: taskBody.parentId } } : undefined,
name: taskBody.name,
name: taskBody.name ?? "Task",
description: taskBody.description,
status,
startedAt: new Date(),
@@ -0,0 +1,72 @@
import type { ActionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { UpdateTriggerSourceBodyV2Schema } from "@trigger.dev/core";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { UpdateSourceServiceV2 } from "~/services/sources/updateSourceV2.server";
const ParamsSchema = z.object({
endpointSlug: z.string(),
id: z.string(),
});
export async function action({ request, params }: ActionArgs) {
logger.info("Updating source", { url: request.url });
// Ensure this is a POST request
if (request.method.toUpperCase() !== "PUT") {
return { status: 405, body: "Method Not Allowed" };
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
logger.info("Invalid params", { params });
return json({ error: "Invalid params" }, { status: 400 });
}
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const authenticatedEnv = authenticationResult.environment;
// Now parse the request body
const anyBody = await request.json();
const body = UpdateTriggerSourceBodyV2Schema.safeParse(anyBody);
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
const service = new UpdateSourceServiceV2();
try {
const source = await service.call({
environment: authenticatedEnv,
payload: body.data,
endpointSlug: parsedParams.data.endpointSlug,
id: parsedParams.data.id,
});
return json(source);
} catch (error) {
if (error instanceof Error) {
logger.error("Error activating http source", {
url: request.url,
error: error.message,
});
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
@@ -0,0 +1,106 @@
import type { ActionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import {
REGISTER_SOURCE_EVENT_V2,
RegisterSourceEventV2,
RegisterTriggerBodySchemaV2,
} from "@trigger.dev/core";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { IngestSendEvent } from "~/services/events/ingestSendEvent.server";
import { logger } from "~/services/logger.server";
import { RegisterTriggerSourceServiceV2 } from "~/services/triggers/registerTriggerSourceV2.server";
const ParamsSchema = z.object({
endpointSlug: z.string(),
id: z.string(),
key: z.string(),
});
export async function action({ request, params }: ActionArgs) {
logger.info("Registering trigger", { url: request.url });
// Ensure this is a POST request
if (request.method.toUpperCase() !== "PUT") {
return { status: 405, body: "Method Not Allowed" };
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
logger.info("Invalid params", { params });
return json({ error: "Invalid params" }, { status: 400 });
}
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const authenticatedEnv = authenticationResult.environment;
// Now parse the request body
const anyBody = await request.json();
const body = RegisterTriggerBodySchemaV2.safeParse(anyBody);
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
const service = new RegisterTriggerSourceServiceV2();
try {
const registration = await service.call({
environment: authenticatedEnv,
payload: body.data,
endpointSlug: parsedParams.data.endpointSlug,
id: parsedParams.data.id,
key: parsedParams.data.key,
});
if (!registration) {
return json({ error: "Could not register trigger" }, { status: 500 });
}
//the source is already active
if (registration.source.active) {
return json(registration);
}
const payload: RegisterSourceEventV2 = {
...registration,
dynamicTriggerId: parsedParams.data.id,
};
const ingestEventService = new IngestSendEvent();
await ingestEventService.call(
authenticatedEnv,
{
id: registration.id,
name: REGISTER_SOURCE_EVENT_V2,
source: "trigger.dev",
payload,
}
//todo accountId?
// {accountId: body.data.accountId}
);
return json(registration);
} catch (error) {
if (error instanceof Error) {
logger.error("Error registering trigger", {
url: request.url,
error: error.message,
});
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
+13 -7
View File
@@ -1,6 +1,7 @@
import {
ApiEventLog,
DeliverEventResponseSchema,
DeserializedJson,
ErrorWithStackSchema,
HttpSourceRequest,
HttpSourceResponseSchema,
@@ -9,8 +10,8 @@ import {
PongResponseSchema,
PreprocessRunBody,
PreprocessRunResponseSchema,
RegisterTriggerBody,
RegisterTriggerBodySchema,
RegisterTriggerBodySchemaV1,
RegisterTriggerBodyV1,
RunJobBody,
RunJobResponseSchema,
ValidateResponse,
@@ -18,6 +19,8 @@ import {
} from "@trigger.dev/core";
import { safeBodyFromResponse, safeParseBodyFromResponse } from "~/utils/json";
import { logger } from "./logger.server";
import { ConnectionAuth } from "@trigger.dev/core";
import { performance } from "node:perf_hooks";
export class EndpointApiError extends Error {
constructor(message: string, stack?: string) {
@@ -162,9 +165,7 @@ export class EndpointApi {
}
async executeJobRequest(options: RunJobBody) {
logger.debug("executeJobRequest()", {
options,
});
const startTimeInMs = performance.now();
const response = await safeFetch(this.url, {
method: "POST",
@@ -180,6 +181,7 @@ export class EndpointApi {
response,
parser: RunJobResponseSchema,
errorParser: ErrorWithStackSchema,
durationInMs: Math.floor(performance.now() - startTimeInMs),
};
}
@@ -197,7 +199,7 @@ export class EndpointApi {
return { response, parser: PreprocessRunResponseSchema };
}
async initializeTrigger(id: string, params: any): Promise<RegisterTriggerBody | undefined> {
async initializeTrigger(id: string, params: any): Promise<RegisterTriggerBodyV1 | undefined> {
const response = await safeFetch(this.url, {
method: "POST",
headers: {
@@ -231,7 +233,7 @@ export class EndpointApi {
body: anyBody,
});
return RegisterTriggerBodySchema.parse(anyBody);
return RegisterTriggerBodySchemaV1.parse(anyBody);
}
async deliverHttpSourceRequest(options: {
@@ -241,6 +243,8 @@ export class EndpointApi {
params: any;
data: any;
request: HttpSourceRequest;
auth?: ConnectionAuth;
metadata?: any;
}) {
const response = await safeFetch(this.url, {
method: "POST",
@@ -255,7 +259,9 @@ export class EndpointApi {
"x-ts-http-url": options.request.url,
"x-ts-http-method": options.request.method,
"x-ts-http-headers": JSON.stringify(options.request.headers),
...(options.auth && { "x-ts-auth": JSON.stringify(options.auth) }),
...(options.dynamicId && { "x-ts-dynamic-id": options.dynamicId }),
...(options.metadata && { "x-ts-metadata": JSON.stringify(options.metadata) }),
},
body: options.request.rawBody,
});
@@ -4,6 +4,7 @@ import { AuthenticatedEnvironment } from "../apiAuth.server";
import { EndpointApi } from "../endpointApi.server";
import { workerQueue } from "../worker.server";
import { env } from "~/env.server";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
const indexingHookIdentifier = customAlphabet("0123456789abcdefghijklmnopqrstuvxyz", 10);
@@ -51,6 +52,9 @@ export class CreateEndpointService {
slug: id,
},
},
include: {
environment: true,
},
create: {
environment: {
connect: {
@@ -83,7 +87,11 @@ export class CreateEndpointService {
id: endpoint.id,
source: "INTERNAL",
},
{ tx }
{
tx,
maxAttempts:
endpoint.environment.type === RuntimeEnvironmentType.DEVELOPMENT ? 1 : undefined,
}
);
return endpoint;
@@ -4,16 +4,18 @@ import { findEndpoint } from "~/models/endpoint.server";
import { EndpointApi } from "../endpointApi.server";
import { RegisterJobService } from "../jobs/registerJob.server";
import { logger } from "../logger.server";
import { RegisterSourceService } from "../sources/registerSource.server";
import { RegisterSourceServiceV1 } from "../sources/registerSourceV1.server";
import { RegisterDynamicScheduleService } from "../triggers/registerDynamicSchedule.server";
import { RegisterDynamicTriggerService } from "../triggers/registerDynamicTrigger.server";
import { DisableJobService } from "../jobs/disableJob.server";
import { RegisterSourceServiceV2 } from "../sources/registerSourceV2.server";
export class IndexEndpointService {
#prismaClient: PrismaClient;
#registerJobService = new RegisterJobService();
#disableJobService = new DisableJobService();
#registerSourceService = new RegisterSourceService();
#registerSourceServiceV1 = new RegisterSourceServiceV1();
#registerSourceServiceV2 = new RegisterSourceServiceV2();
#registerDynamicTriggerService = new RegisterDynamicTriggerService();
#registerDynamicScheduleService = new RegisterDynamicScheduleService();
@@ -156,7 +158,17 @@ export class IndexEndpointService {
for (const source of sources) {
try {
await this.#registerSourceService.call(endpoint, source);
switch (source.version) {
default:
case "1": {
await this.#registerSourceServiceV1.call(endpoint, source);
break;
}
case "2": {
await this.#registerSourceServiceV2.call(endpoint, source);
break;
}
}
indexStats.sources++;
} catch (error) {
@@ -5,6 +5,7 @@ import { AuthenticatedEnvironment } from "../apiAuth.server";
import { workerQueue } from "../worker.server";
import { CreateEndpointError } from "./createEndpoint.server";
import { EndpointApi } from "../endpointApi.server";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
const indexingHookIdentifier = customAlphabet("0123456789abcdefghijklmnopqrstuvxyz", 10);
@@ -35,6 +36,9 @@ export class ValidateCreateEndpointService {
slug: validationResult.endpointId,
},
},
include: {
environment: true,
},
create: {
environment: {
connect: {
@@ -67,7 +71,11 @@ export class ValidateCreateEndpointService {
id: endpoint.id,
source: "INTERNAL",
},
{ tx }
{
tx,
maxAttempts:
endpoint.environment.type === RuntimeEnvironmentType.DEVELOPMENT ? 1 : undefined,
}
);
return endpoint;
@@ -7,7 +7,10 @@ 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;
}
@@ -1,11 +1,12 @@
import { airtable } from "./integrations/airtable";
import { github } from "./integrations/github";
import { openai } from "./integrations/openai";
import { plain } from "./integrations/plain";
import { resend } from "./integrations/resend";
import { sendgrid } from "./integrations/sendgrid";
import { slack } from "./integrations/slack";
import { stripe } from "./integrations/stripe";
import { sendgrid } from "./integrations/sendgrid";
import { supabaseManagement, supabase } from "./integrations/supabase";
import { supabase, supabaseManagement } from "./integrations/supabase";
import { typeform } from "./integrations/typeform";
import type { Integration } from "./types";
@@ -30,6 +31,7 @@ export class IntegrationCatalog {
}
export const integrationCatalog = new IntegrationCatalog({
airtable,
github,
openai,
plain,
@@ -1,4 +1,46 @@
import type { Integration } from "../types";
import type { HelpSample, Integration } from "../types";
function usageSample(hasApiKey: boolean): HelpSample {
return {
title: "Using the client",
code: `
import { Airtable } from "@trigger.dev/airtable";
const airtable = new Airtable({
id: "__SLUG__"${hasApiKey ? ",\n token: process.env.AIRTABLE_API_KEY!" : ""}
});
client.defineJob({
id: "alert-on-new-github-issues",
name: "Alert on new GitHub issues",
version: "0.1.1",
trigger: github.triggers.repo({
event: events.onIssueOpened,
owner: "triggerdotdev",
repo: "trigger.dev",
}),
run: async (payload, io, ctx) => {
//wrap the SDK call in runTask
const { data } = await io.runTask(
"create-card",
{ name: "Create card" },
async () => {
//create a project card using the underlying client
return io.github.client.rest.projects.createCard({
column_id: 123,
note: "test",
});
}
);
//log the url of the created card
await io.logger.info(data.url);
},
});
`,
};
}
export const airtable: Integration = {
identifier: "airtable",
@@ -70,18 +112,13 @@ export const airtable: Integration = {
},
],
help: {
samples: [
{
title: "Creating the client",
code: `
import { Airtable } from "@trigger.dev/airtable";
const airtable = new Airtable({
id: "__SLUG__"
});
`,
},
],
samples: [usageSample(false)],
},
},
apiKey: {
type: "apikey",
help: {
samples: [usageSample(true)],
},
},
},
@@ -1,3 +1,4 @@
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { $transaction, Prisma, PrismaClient, prisma } from "~/db.server";
import { enqueueRunExecutionV2 } from "~/models/jobRunExecution.server";
@@ -16,6 +17,9 @@ export class ContinueRunService {
async (tx) => {
const run = await tx.jobRun.findUniqueOrThrow({
where: { id: runId },
include: {
environment: true,
},
});
if (!RESUMABLE_STATUSES.includes(run.status)) {
@@ -35,7 +39,9 @@ export class ContinueRunService {
},
});
await enqueueRunExecutionV2(run, tx);
await enqueueRunExecutionV2(run, tx, {
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
},
{ timeout: 10000 }
);
@@ -6,7 +6,7 @@ import {
RunJobSuccess,
RunSourceContextSchema,
} from "@trigger.dev/core";
import type { Task } from "@trigger.dev/database";
import { RuntimeEnvironmentType, type Task } from "@trigger.dev/database";
import { generateErrorMessage } from "zod-error";
import { eventRecordToApiJson } from "~/api.server";
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
@@ -20,6 +20,13 @@ import { logger } from "../logger.server";
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
type FoundTask = FoundRun["tasks"][number];
export type PerformRunExecutionV2Input = {
id: string;
reason: "PREPROCESS" | "EXECUTE_JOB";
isRetry: boolean;
resumeTaskId?: string;
};
export class PerformRunExecutionV2Service {
#prismaClient: PrismaClient;
@@ -27,25 +34,20 @@ export class PerformRunExecutionV2Service {
this.#prismaClient = prismaClient;
}
public async call(
id: string,
reason: "PREPROCESS" | "EXECUTE_JOB",
isRetry: boolean = false,
resumeTaskId?: string
) {
const run = await findRun(this.#prismaClient, id);
public async call(input: PerformRunExecutionV2Input) {
const run = await findRun(this.#prismaClient, input.id);
if (!run) {
return;
}
switch (reason) {
switch (input.reason) {
case "PREPROCESS": {
await this.#executePreprocessing(run);
break;
}
case "EXECUTE_JOB": {
await this.#executeJob(run, isRetry, resumeTaskId);
await this.#executeJob(run, input);
break;
}
}
@@ -135,11 +137,15 @@ export class PerformRunExecutionV2Service {
},
});
await enqueueRunExecutionV2(run, tx);
await enqueueRunExecutionV2(run, tx, {
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
});
}
}
async #executeJob(run: FoundRun, isRetry: boolean, resumeTaskId?: string) {
async #executeJob(run: FoundRun, input: PerformRunExecutionV2Input) {
const { isRetry, resumeTaskId } = input;
if (run.status === "CANCELED") {
await this.#cancelExecution(run);
return;
@@ -150,21 +156,27 @@ export class PerformRunExecutionV2Service {
const startedAt = new Date();
await this.#prismaClient.jobRun.update({
const { executionCount } = await this.#prismaClient.jobRun.update({
where: {
id: run.id,
},
data: {
status: run.status === "QUEUED" ? "STARTED" : run.status,
startedAt: run.startedAt ?? new Date(),
executionCount: {
increment: 1,
},
},
select: {
executionCount: true,
},
});
const connections = await resolveRunConnections(run.runConnections);
if (!connections.success) {
return this.#failRunExecutionWithRetry({
message: `Could not resolve all connections for run ${run.id}, attempting to retry`,
return this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
message: `Could not resolve all connections for run ${run.id}. This should not happen`,
});
}
@@ -193,7 +205,7 @@ export class PerformRunExecutionV2Service {
const sourceContext = RunSourceContextSchema.safeParse(run.event.sourceContext);
const { response, parser, errorParser } = await client.executeJobRequest({
const { response, parser, errorParser, durationInMs } = await client.executeJobRequest({
event,
job: {
id: run.version.job.slug,
@@ -259,50 +271,82 @@ export class PerformRunExecutionV2Service {
// Only retry if the error isn't a 4xx
if (response.status >= 400 && response.status <= 499 && response.status !== 408) {
return await this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
message: `Endpoint responded with ${response.status} status code`,
});
return await this.#failRunExecution(
this.#prismaClient,
"EXECUTE_JOB",
run,
{
message: `Endpoint responded with ${response.status} status code`,
},
"FAILURE",
durationInMs
);
} else {
return await this.#failRunExecutionWithRetry({
message: `Endpoint responded with ${response.status} status code`,
});
// If the error is a 504 timeout, we should mark this execution as succeeded (by not throwing an error) and enqueue a new execution
if (response.status === 504) {
return await this.#resumeRunExecutionAfterTimeout(
this.#prismaClient,
run,
input,
durationInMs,
executionCount
);
} else {
return await this.#failRunExecutionWithRetry({
message: `Endpoint responded with ${response.status} status code`,
});
}
}
}
const safeBody = safeJsonZodParse(parser, rawBody);
if (!safeBody) {
return await this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
message: "Endpoint responded with invalid JSON",
});
return await this.#failRunExecution(
this.#prismaClient,
"EXECUTE_JOB",
run,
{
message: "Endpoint responded with invalid JSON",
},
"FAILURE",
durationInMs
);
}
if (!safeBody.success) {
return await this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
message: generateErrorMessage(safeBody.error.issues),
});
return await this.#failRunExecution(
this.#prismaClient,
"EXECUTE_JOB",
run,
{
message: generateErrorMessage(safeBody.error.issues),
},
"FAILURE",
durationInMs
);
}
const status = safeBody.data.status;
switch (status) {
case "SUCCESS": {
await this.#completeRunWithSuccess(run, safeBody.data);
await this.#completeRunWithSuccess(run, safeBody.data, durationInMs);
break;
}
case "RESUME_WITH_TASK": {
await this.#resumeRunWithTask(run, safeBody.data, isRetry);
await this.#resumeRunWithTask(run, safeBody.data, isRetry, durationInMs, executionCount);
break;
}
case "ERROR": {
await this.#failRunWithError(run, safeBody.data);
await this.#failRunWithError(run, safeBody.data, durationInMs);
break;
}
case "RETRY_WITH_TASK": {
await this.#retryRunWithTask(run, safeBody.data, isRetry);
await this.#retryRunWithTask(run, safeBody.data, isRetry, durationInMs, executionCount);
break;
}
@@ -317,19 +361,37 @@ export class PerformRunExecutionV2Service {
}
}
async #completeRunWithSuccess(run: FoundRun, data: RunJobSuccess) {
async #completeRunWithSuccess(run: FoundRun, data: RunJobSuccess, durationInMs: number) {
await this.#prismaClient.jobRun.update({
where: { id: run.id },
data: {
completedAt: new Date(),
status: "SUCCESS",
output: data.output ?? undefined,
executionDuration: {
increment: durationInMs,
},
},
});
}
async #resumeRunWithTask(run: FoundRun, data: RunJobResumeWithTask, isRetry: boolean) {
async #resumeRunWithTask(
run: FoundRun,
data: RunJobResumeWithTask,
isRetry: boolean,
durationInMs: number,
executionCount: number
) {
return await $transaction(this.#prismaClient, async (tx) => {
await tx.jobRun.update({
where: { id: run.id },
data: {
executionDuration: {
increment: durationInMs,
},
},
});
// If the task has an operation, then the next performRunExecution will occur
// when that operation has finished
if (!data.task.operation) {
@@ -337,12 +399,14 @@ export class PerformRunExecutionV2Service {
runAt: data.task.delayUntil ?? undefined,
resumeTaskId: data.task.id,
isRetry,
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
executionCount,
});
}
});
}
async #failRunWithError(execution: FoundRun, data: RunJobError) {
async #failRunWithError(execution: FoundRun, data: RunJobError, durationInMs: number) {
return await $transaction(this.#prismaClient, async (tx) => {
if (data.task) {
await tx.task.update({
@@ -357,11 +421,24 @@ export class PerformRunExecutionV2Service {
});
}
await this.#failRunExecution(tx, "EXECUTE_JOB", execution, data.error ?? undefined);
await this.#failRunExecution(
tx,
"EXECUTE_JOB",
execution,
data.error ?? undefined,
"FAILURE",
durationInMs
);
});
}
async #retryRunWithTask(run: FoundRun, data: RunJobRetryWithTask, isRetry: boolean) {
async #retryRunWithTask(
run: FoundRun,
data: RunJobRetryWithTask,
isRetry: boolean,
durationInMs: number,
executionCount: number
) {
return await $transaction(this.#prismaClient, async (tx) => {
// We need to check for an existing task attempt
const existingAttempt = await tx.taskAttempt.findFirst({
@@ -402,6 +479,13 @@ export class PerformRunExecutionV2Service {
},
data: {
status: "WAITING",
run: {
update: {
executionDuration: {
increment: durationInMs,
},
},
},
},
});
@@ -409,6 +493,56 @@ export class PerformRunExecutionV2Service {
runAt: data.retryAt,
resumeTaskId: data.task.id,
isRetry,
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
executionCount,
});
});
}
async #resumeRunExecutionAfterTimeout(
prisma: PrismaClientOrTransaction,
run: FoundRun,
input: PerformRunExecutionV2Input,
durationInMs: number,
executionCount: number
) {
await $transaction(prisma, async (tx) => {
const executionDuration = run.executionDuration + durationInMs;
// If the execution duration is greater than the maximum execution time, we need to fail the run
if (executionDuration >= run.organization.maximumExecutionTimePerRunInMs) {
await this.#failRunExecution(
tx,
"EXECUTE_JOB",
run,
{
message: `Execution timed out after ${
run.organization.maximumExecutionTimePerRunInMs / 1000
} seconds`,
},
"TIMED_OUT",
durationInMs
);
return;
}
await tx.jobRun.update({
where: {
id: run.id,
},
data: {
executionDuration: {
increment: durationInMs,
},
},
});
// The run has timed out, so we need to enqueue a new execution
await enqueueRunExecutionV2(run, tx, {
resumeTaskId: input.resumeTaskId,
isRetry: input.isRetry,
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
executionCount,
});
});
}
@@ -422,7 +556,8 @@ export class PerformRunExecutionV2Service {
reason: "EXECUTE_JOB" | "PREPROCESS",
run: FoundRun,
output: Record<string, any>,
status: "FAILURE" | "ABORTED" = "FAILURE"
status: "FAILURE" | "ABORTED" | "TIMED_OUT" = "FAILURE",
durationInMs: number = 0
): Promise<void> {
await $transaction(prisma, async (tx) => {
switch (reason) {
@@ -434,6 +569,9 @@ export class PerformRunExecutionV2Service {
completedAt: new Date(),
status,
output,
executionDuration: {
increment: durationInMs,
},
},
});
@@ -464,7 +602,9 @@ export class PerformRunExecutionV2Service {
},
});
await enqueueRunExecutionV2(run, tx);
await enqueueRunExecutionV2(run, tx, {
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
break;
}
@@ -477,7 +617,9 @@ export class PerformRunExecutionV2Service {
}
}
function prepareTasksForRun(tasks: FoundTask[]): CachedTask[] {
function prepareTasksForRun(possibleTasks: FoundTask[]): CachedTask[] {
const tasks = possibleTasks.filter((task) => task.status === "COMPLETED");
// We need to limit the cached tasks to not be too large >3.5MB when serialized
const TOTAL_CACHED_TASK_BYTE_LIMIT = 3500000;
@@ -502,38 +644,23 @@ function prepareTasksForRun(tasks: FoundTask[]): CachedTask[] {
return cachedTaskSizes.get(taskId)!;
}
// Create a dynamic programming array to store intermediate results
const dp: number[][] = [];
for (let i = 0; i <= tasks.length; i++) {
dp[i] = [];
for (let j = 0; j <= TOTAL_CACHED_TASK_BYTE_LIMIT; j++) {
dp[i][j] = 0;
}
}
// Fill the dynamic programming array
for (let i = 1; i <= tasks.length; i++) {
const task = tasks[i - 1];
// Prepare tasks and calculate their sizes
const availableTasks = tasks.map((task) => {
const cachedTask = getCachedTask(task);
const taskSize = getCachedTaskSize(cachedTask);
for (let j = 0; j <= TOTAL_CACHED_TASK_BYTE_LIMIT; j++) {
if (taskSize <= j) {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - taskSize] + taskSize);
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return { task: cachedTask, size: getCachedTaskSize(cachedTask) };
});
// Traverse the dynamic programming array to find the included tasks
// Sort tasks in ascending order by size
availableTasks.sort((a, b) => a.size - b.size);
// Select tasks using greedy approach
const tasksToRun: CachedTask[] = [];
let j = TOTAL_CACHED_TASK_BYTE_LIMIT;
for (let i = tasks.length; i > 0 && j > 0; i--) {
if (dp[i][j] !== dp[i - 1][j]) {
const task = tasks[i - 1];
const cachedTask = getCachedTask(task);
tasksToRun.unshift(cachedTask);
j -= getCachedTaskSize(cachedTask);
let remainingSize = TOTAL_CACHED_TASK_BYTE_LIMIT;
for (const { task, size } of availableTasks) {
if (size <= remainingSize) {
tasksToRun.push(task);
remainingSize -= size;
}
}
@@ -563,7 +690,6 @@ async function findRun(prisma: PrismaClientOrTransaction, id: string) {
endpoint: true,
organization: true,
externalAccount: true,
queue: true,
runConnections: {
include: {
integration: true,
@@ -580,6 +706,14 @@ async function findRun(prisma: PrismaClientOrTransaction, id: string) {
in: ["COMPLETED"],
},
},
select: {
id: true,
idempotencyKey: true,
status: true,
noop: true,
output: true,
parentId: true,
},
},
event: true,
version: {
@@ -1,4 +1,9 @@
import type { ConnectionType, Integration, IntegrationConnection } from "@trigger.dev/database";
import {
RuntimeEnvironmentType,
type ConnectionType,
type Integration,
type IntegrationConnection,
} from "@trigger.dev/database";
import type { PrismaClient, PrismaClientOrTransaction } from "~/db.server";
import { prisma } from "~/db.server";
import { enqueueRunExecutionV2 } from "~/models/jobRunExecution.server";
@@ -83,7 +88,9 @@ export class StartRunService {
const updatedRun = await updateRun();
await enqueueRunExecutionV2(updatedRun, this.#prismaClient);
await enqueueRunExecutionV2(updatedRun, this.#prismaClient, {
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
}
async #handleMissingConnections(id: string, runConnectionsByKey: RunConnectionsByKey) {
@@ -140,6 +147,7 @@ async function findRun(tx: PrismaClientOrTransaction, id: string) {
where: { id },
include: {
queue: true,
environment: true,
version: {
include: {
integrations: {
@@ -1,13 +1,21 @@
import {
REGISTER_SOURCE_EVENT_V2,
REGISTER_SOURCE_EVENT_V1,
RegisterTriggerSource,
RegisterSourceEventV1,
RegisterSourceEventV2,
RegisterSourceEventOptions,
RegisteredOptionsDiff,
} from "@trigger.dev/core";
import type { SecretReference, TriggerSource, TriggerSourceOption } from "@trigger.dev/database";
import { z } from "zod";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import type { TriggerSource, SecretReference, TriggerSourceEvent } from "@trigger.dev/database";
import type { AuthenticatedEnvironment } from "../apiAuth.server";
import { env } from "~/env.server";
import { REGISTER_SOURCE_EVENT, RegisterTriggerSource } from "@trigger.dev/core";
import { SecretStoreProvider, getSecretStore } from "../secrets/secretStore.server";
import { SecretStore } from "../secrets/secretStore.server";
import { z } from "zod";
import type { AuthenticatedEnvironment } from "../apiAuth.server";
import { IngestSendEvent } from "../events/ingestSendEvent.server";
import { getSecretStore } from "../secrets/secretStore.server";
import { nanoid } from "nanoid";
export class ActivateSourceService {
#prismaClient: PrismaClient;
@@ -16,7 +24,7 @@ export class ActivateSourceService {
this.#prismaClient = prismaClient;
}
public async call(id: string, jobId: string, orphanedEvents?: Array<string>) {
public async call(id: string, jobId?: string, orphanedOptions?: Record<string, string[]>) {
const triggerSource = await this.#prismaClient.triggerSource.findUniqueOrThrow({
where: {
id,
@@ -29,12 +37,12 @@ export class ActivateSourceService {
project: true,
},
},
events: true,
options: true,
secretReference: true,
},
});
const eventId = `${id}:${jobId}`;
const eventId = `${id}:${jobId ?? nanoid()}`;
// TODO: support more channels
switch (triggerSource.channel) {
@@ -42,10 +50,10 @@ export class ActivateSourceService {
await this.#activateHttpSource(
triggerSource.environment,
triggerSource,
triggerSource.events,
triggerSource.options,
triggerSource.secretReference,
eventId,
orphanedEvents
orphanedOptions
);
}
}
@@ -54,10 +62,10 @@ export class ActivateSourceService {
async #activateHttpSource(
environment: AuthenticatedEnvironment,
triggerSource: TriggerSource,
events: Array<TriggerSourceEvent>,
options: Array<TriggerSourceOption>,
secretReference: SecretReference,
eventId: string,
orphanedEvents?: Array<string>
orphanedOptions?: Record<string, string[]>
) {
const secretStore = getSecretStore(secretReference.provider);
const httpSecret = await secretStore.getSecret(
@@ -71,14 +79,6 @@ export class ActivateSourceService {
throw new Error("HTTP Secret not found");
}
const eventNames = triggerSource.active
? events.filter((e) => e.registered).map((e) => e.name)
: events.map((e) => e.name);
const missingEvents = triggerSource.active
? events.filter((e) => !e.registered).map((e) => e.name)
: [];
const service = new IngestSendEvent();
const source: RegisterTriggerSource = {
@@ -92,17 +92,85 @@ export class ActivateSourceService {
},
};
await service.call(environment, {
id: eventId,
name: REGISTER_SOURCE_EVENT,
source: "trigger.dev",
payload: {
id: triggerSource.id,
source,
events: eventNames,
missingEvents,
orphanedEvents: orphanedEvents ?? [],
},
});
switch (triggerSource.version) {
case "1": {
const events = triggerSource.active
? options.filter((e) => e.registered).map((e) => e.value)
: options.map((e) => e.value);
const missingEvents = triggerSource.active
? options.filter((e) => !e.registered).map((e) => e.value)
: [];
const orphanedEvents = orphanedOptions
? Object.values(orphanedOptions).flatMap((vals) => vals)
: [];
const payload: RegisterSourceEventV1 = {
id: triggerSource.id,
source,
events,
missingEvents,
orphanedEvents,
};
await service.call(environment, {
id: eventId,
name: REGISTER_SOURCE_EVENT_V1,
source: "trigger.dev",
payload,
});
break;
}
case "2": {
//group the options by the name
const optionsRecord = options.reduce((acc, option) => {
if (!acc[option.name]) {
acc[option.name] = [];
}
acc[option.name].push(option);
return acc;
}, {} as Record<string, Array<TriggerSourceOption>>);
//for each of the optionsRecord, create the diff
const payloadOptions = Object.entries(optionsRecord).reduce(
(acc, [key, value]) => ({
...acc,
[key]: getOptionsDiff(triggerSource.active, orphanedOptions?.[key] ?? [], value),
}),
{} as Record<string, RegisteredOptionsDiff>
) as RegisterSourceEventOptions;
const payload: RegisterSourceEventV2 = {
id: triggerSource.id,
source,
options: payloadOptions,
};
await service.call(environment, {
id: eventId,
name: REGISTER_SOURCE_EVENT_V2,
source: "trigger.dev",
payload,
});
}
}
}
}
function getOptionsDiff(
sourceIsActive: boolean,
orphaned: string[],
options: Array<TriggerSourceOption>
): RegisteredOptionsDiff {
const desired = sourceIsActive
? options.filter((e) => e.registered).map((e) => e.value)
: options.map((e) => e.value);
const missing = sourceIsActive ? options.filter((e) => !e.registered).map((e) => e.value) : [];
return {
desired: [...new Set(desired)],
missing: [...new Set(missing)],
orphaned: [...new Set(orphaned)],
};
}
@@ -4,6 +4,9 @@ import { prisma } from "~/db.server";
import { EndpointApi } from "../endpointApi.server";
import { IngestSendEvent } from "../events/ingestSendEvent.server";
import { getSecretStore } from "../secrets/secretStore.server";
import { resolveApiConnection, resolveRunConnection } from "~/models/runConnection.server";
import { ConnectionAuth } from "@trigger.dev/sdk";
import { resolveSourceConnection } from "~/models/sourceConnection.server";
export class DeliverHttpSourceRequestService {
#prismaClient: PrismaClient;
@@ -28,7 +31,11 @@ export class DeliverHttpSourceRequestService {
secretReference: true,
dynamicTrigger: true,
externalAccount: true,
integration: true,
integration: {
include: {
connections: true,
},
},
},
},
},
@@ -51,14 +58,14 @@ export class DeliverHttpSourceRequestService {
throw new Error(`Secret not found for ${httpSourceRequest.source.key}`);
}
// TODO: implement auth for http source requests
const auth = await resolveSourceConnection(this.#prismaClient, httpSourceRequest.source);
const clientApi = new EndpointApi(
httpSourceRequest.environment.apiKey,
httpSourceRequest.endpoint.url
);
const { response, events } = await clientApi.deliverHttpSourceRequest({
const { response, events, metadata } = await clientApi.deliverHttpSourceRequest({
key: httpSourceRequest.source.key,
dynamicId: httpSourceRequest.source.dynamicTrigger?.slug,
secret: secret.secret,
@@ -70,6 +77,8 @@ export class DeliverHttpSourceRequestService {
headers: httpSourceRequest.headers as Record<string, string>,
rawBody: httpSourceRequest.body,
},
auth,
metadata: httpSourceRequest.source.metadata,
});
await this.#prismaClient.httpSourceRequestDelivery.update({
@@ -81,6 +90,17 @@ export class DeliverHttpSourceRequestService {
},
});
if (metadata) {
await this.#prismaClient.triggerSource.update({
where: {
id: httpSourceRequest.source.id,
},
data: {
metadata: metadata,
},
});
}
const ingestService = new IngestSendEvent();
for (const event of events) {
@@ -2,6 +2,7 @@ import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { workerQueue } from "../worker.server";
import { requestUrl } from "~/utils/requestUrl.server";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
export class HandleHttpSourceService {
#prismaClient: PrismaClient;
@@ -53,8 +54,10 @@ export class HandleHttpSourceService {
id: delivery.id,
},
{
queueName: `endpoint-${triggerSource.endpointId}`,
queueName: `deliver:${triggerSource.id}`,
tx,
maxAttempts:
triggerSource.environment.type === RuntimeEnvironmentType.DEVELOPMENT ? 1 : undefined,
}
);
});
@@ -1,5 +1,5 @@
import type { Endpoint } from "@trigger.dev/database";
import type { SourceMetadata } from "@trigger.dev/core";
import type { SourceMetadataV1 } from "@trigger.dev/core";
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
import { prisma } from "~/db.server";
import type { AuthenticatedEnvironment } from "../apiAuth.server";
@@ -7,7 +7,7 @@ import { workerQueue } from "../worker.server";
import { generateSecret } from "./utils.server";
import { ExtendedEndpoint, findEndpoint } from "~/models/endpoint.server";
export class RegisterSourceService {
export class RegisterSourceServiceV1 {
#prismaClient: PrismaClientOrTransaction;
constructor(prismaClient: PrismaClientOrTransaction = prisma) {
@@ -16,7 +16,7 @@ export class RegisterSourceService {
public async call(
endpointIdOrEndpoint: string | ExtendedEndpoint,
metadata: SourceMetadata,
metadata: SourceMetadataV1,
dynamicTriggerId?: string,
accountId?: string,
dynamicSource?: { id: string; metadata: any }
@@ -39,7 +39,7 @@ export class RegisterSourceService {
async #upsertSource(
endpoint: Endpoint,
environment: AuthenticatedEnvironment,
metadata: SourceMetadata,
metadata: SourceMetadataV1,
dynamicTriggerId?: string,
accountId?: string,
dynamicSource?: { id: string; metadata: any }
@@ -121,9 +121,10 @@ export class RegisterSourceService {
}
: undefined,
externalAccount: externalAccount ? { connect: { id: externalAccount.id } } : undefined,
events: {
options: {
create: metadata.events.map((event) => ({
name: event,
name: "event",
value: event,
})),
},
secretReference: {
@@ -175,7 +176,7 @@ export class RegisterSourceService {
: undefined,
},
include: {
events: true,
options: true,
secretReference: true,
},
});
@@ -200,22 +201,24 @@ export class RegisterSourceService {
const newEvents = new Set<string>(metadata.events);
const orphanedEvents = new Set<string>();
for (const event of triggerSource.events) {
if (!newEvents.has(event.name)) {
orphanedEvents.add(event.name);
for (const option of triggerSource.options) {
if (!newEvents.has(option.value)) {
orphanedEvents.add(option.value);
}
}
for (const event of newEvents) {
await tx.triggerSourceEvent.upsert({
await tx.triggerSourceOption.upsert({
where: {
name_sourceId: {
name: event,
name_value_sourceId: {
name: "event",
value: event,
sourceId: triggerSource.id,
},
},
create: {
name: event,
name: "event",
value: event,
source: {
connect: {
id: triggerSource.id,
@@ -249,7 +252,7 @@ export class RegisterSourceService {
id: id,
},
include: {
events: true,
options: true,
secretReference: true,
integration: true,
},
@@ -261,11 +264,12 @@ export class RegisterSourceService {
const triggerIsActive = triggerSource.active;
const triggerHasOrphanedEvents = orphanedEvents.length > 0;
const triggerHasUnregisteredEvents = triggerSource.events.some((event) => !event.registered);
const triggerHasUnregisteredEvents = triggerSource.options.some((option) => !option.registered);
if (!triggerIsActive || triggerHasOrphanedEvents || triggerHasUnregisteredEvents) {
// We need to re-activate the source, and there could be orphaned events
await workerQueue.enqueue("activateSource", {
version: "1",
id: triggerSource.id,
orphanedEvents: orphanedEvents,
});
@@ -277,7 +281,7 @@ export class RegisterSourceService {
async #findOrCreateIntegration(
tx: PrismaClientOrTransaction,
organizationId: string,
config: SourceMetadata["integration"]
config: SourceMetadataV1["integration"]
) {
if (config.authSource === "HOSTED") {
return tx.integration.findUnique({
@@ -0,0 +1,361 @@
import type { Endpoint } from "@trigger.dev/database";
import type { SourceMetadataV2 } from "@trigger.dev/core";
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
import { prisma } from "~/db.server";
import type { AuthenticatedEnvironment } from "../apiAuth.server";
import { workerQueue } from "../worker.server";
import { generateSecret } from "./utils.server";
import { ExtendedEndpoint, findEndpoint } from "~/models/endpoint.server";
export class RegisterSourceServiceV2 {
#prismaClient: PrismaClientOrTransaction;
constructor(prismaClient: PrismaClientOrTransaction = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
endpointIdOrEndpoint: string | ExtendedEndpoint,
metadata: SourceMetadataV2,
dynamicTriggerId?: string,
accountId?: string,
dynamicSource?: { id: string; metadata: any }
) {
const endpoint =
typeof endpointIdOrEndpoint === "string"
? await findEndpoint(endpointIdOrEndpoint)
: endpointIdOrEndpoint;
return this.#upsertSource(
endpoint,
endpoint.environment,
metadata,
dynamicTriggerId,
accountId,
dynamicSource
);
}
async #upsertSource(
endpoint: Endpoint,
environment: AuthenticatedEnvironment,
metadata: SourceMetadataV2,
dynamicTriggerId?: string,
accountId?: string,
dynamicSource?: { id: string; metadata: any }
) {
const key = [dynamicTriggerId, dynamicSource?.id, metadata.key].filter(Boolean).join(":");
const registrationJob = metadata.registerSourceJob
? await this.#prismaClient.job.findUnique({
where: {
projectId_slug: {
projectId: endpoint.projectId,
slug: metadata.registerSourceJob.id,
},
},
})
: undefined;
const source = await $transaction(
this.#prismaClient,
async (tx) => {
const integration = await this.#findOrCreateIntegration(
tx,
environment.organizationId,
metadata.integration
);
if (!integration) {
throw new Error("Integration not found");
}
const externalAccount = accountId
? await tx.externalAccount.findUniqueOrThrow({
where: {
environmentId_identifier: {
environmentId: environment.id,
identifier: accountId,
},
},
})
: undefined;
// options
const createOptions = Object.entries(metadata.options).flatMap(([name, values]) => {
const uniqueValues = [...new Set(values)];
return uniqueValues.map((value) => ({ name, value }));
});
const triggerSource = await tx.triggerSource.upsert({
where: {
key_environmentId: {
environmentId: environment.id,
key,
},
},
create: {
version: "2",
params: metadata.params,
key,
channel: metadata.channel,
organization: {
connect: {
id: environment.organizationId,
},
},
endpoint: {
connect: {
id: endpoint.id,
},
},
project: {
connect: {
id: environment.projectId,
},
},
environment: {
connect: {
id: environment.id,
},
},
integration: { connect: { id: integration.id } },
dynamicTrigger: dynamicTriggerId
? {
connect: {
id: dynamicTriggerId,
},
}
: undefined,
externalAccount: externalAccount ? { connect: { id: externalAccount.id } } : undefined,
options: {
create: createOptions,
},
secretReference: {
connectOrCreate: {
where: {
key: `${endpoint.id}:${key}`,
},
create: {
key: `${endpoint.id}:${key}`,
provider: "DATABASE",
},
},
},
dynamicSourceId: dynamicSource?.id,
dynamicSourceMetadata: dynamicSource?.metadata,
sourceRegistrationJob:
registrationJob && metadata.registerSourceJob
? {
connect: {
jobId_version_environmentId: {
jobId: registrationJob.id,
version: metadata.registerSourceJob.version,
environmentId: endpoint.environmentId,
},
},
}
: undefined,
},
update: {
version: "2",
endpoint: {
connect: {
id: endpoint.id,
},
},
integration: { connect: { id: integration.id } },
dynamicSourceId: dynamicSource?.id,
dynamicSourceMetadata: dynamicSource?.metadata,
sourceRegistrationJob:
registrationJob && metadata.registerSourceJob
? {
connect: {
jobId_version_environmentId: {
jobId: registrationJob.id,
version: metadata.registerSourceJob.version,
environmentId: endpoint.environmentId,
},
},
}
: undefined,
},
include: {
options: true,
secretReference: true,
},
});
switch (metadata.channel) {
case "HTTP": {
await tx.secretStore.upsert({
where: {
key: triggerSource.secretReference.key,
},
create: {
key: triggerSource.secretReference.key,
value: {
secret: generateSecret(),
},
},
update: {},
});
}
}
// Collect the options that are no longer being used so we can remove them
const newOptions = metadata.options;
const orphanedOptions: Record<string, string[]> = {};
for (const event of triggerSource.options) {
const values = newOptions[event.name];
if (values === undefined) {
orphanedOptions[event.name] = [event.value];
continue;
}
if (values!.includes(event.value)) {
orphanedOptions[event.name] = [...values, event.value];
}
}
//add or update the options
const flatOptions = Object.entries(newOptions).flatMap(([name, values]) =>
values.map((v) => ({ name, value: v }))
);
for (const { name, value } of flatOptions) {
await tx.triggerSourceOption.upsert({
where: {
name_value_sourceId: {
name,
value,
sourceId: triggerSource.id,
},
},
create: {
name,
value,
source: {
connect: {
id: triggerSource.id,
},
},
},
update: {},
});
}
return {
id: triggerSource.id,
orphanedOptions,
};
},
{ timeout: 15000 }
);
if (!source) {
return;
}
const { id, orphanedOptions } = source;
// We need to activate the source if:
// 1. It's not active
// 2. There are orphaned events
// 3. There are trigger events that are not registered
const triggerSource = await this.#prismaClient.triggerSource.findUniqueOrThrow({
where: {
id: id,
},
include: {
options: true,
secretReference: true,
integration: true,
},
});
if (dynamicTriggerId) {
return triggerSource;
}
const triggerIsActive = triggerSource.active;
const triggerHasOrphanedEvents = Object.keys(orphanedOptions).length > 0;
const triggerHasUnregisteredEvents = triggerSource.options.some((option) => !option.registered);
if (!triggerIsActive || triggerHasOrphanedEvents || triggerHasUnregisteredEvents) {
// We need to re-activate the source, and there could be orphaned events
await workerQueue.enqueue("activateSource", {
version: "2",
id: triggerSource.id,
orphanedOptions,
});
}
return triggerSource;
}
async #findOrCreateIntegration(
tx: PrismaClientOrTransaction,
organizationId: string,
config: SourceMetadataV2["integration"]
) {
if (config.authSource === "HOSTED") {
return tx.integration.findUnique({
where: {
organizationId_slug: {
organizationId,
slug: config.id,
},
},
});
} else {
return tx.integration.upsert({
where: {
organizationId_slug: {
organizationId,
slug: config.id,
},
},
create: {
slug: config.id,
title: config.metadata.name,
authSource: "LOCAL",
connectionType: "DEVELOPER",
organization: {
connect: {
id: organizationId,
},
},
definition: {
connectOrCreate: {
where: {
id: config.metadata.id,
},
create: {
id: config.metadata.id,
name: config.metadata.name,
instructions: config.metadata.instructions,
},
},
},
},
update: {
title: config.metadata.name,
authSource: "LOCAL",
connectionType: "DEVELOPER",
definition: {
connectOrCreate: {
where: {
id: config.metadata.id,
},
create: {
id: config.metadata.id,
name: config.metadata.name,
instructions: config.metadata.instructions,
},
},
},
},
});
}
}
}
@@ -1,10 +1,10 @@
import type { RuntimeEnvironment } from "@trigger.dev/database";
import type { TriggerSource, UpdateTriggerSourceBody } from "@trigger.dev/core";
import type { TriggerSource, UpdateTriggerSourceBodyV1 } from "@trigger.dev/core";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { getSecretStore } from "../secrets/secretStore.server";
export class UpdateSourceService {
export class UpdateSourceServiceV1 {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
@@ -18,7 +18,7 @@ export class UpdateSourceService {
endpointSlug,
}: {
environment: RuntimeEnvironment;
payload: UpdateTriggerSourceBody;
payload: UpdateTriggerSourceBodyV1;
id: string;
endpointSlug: string;
}): Promise<TriggerSource> {
@@ -55,10 +55,11 @@ export class UpdateSourceService {
});
for (const event of payload.registeredEvents) {
await this.#prismaClient.triggerSourceEvent.update({
await this.#prismaClient.triggerSourceOption.update({
where: {
name_sourceId: {
name: event,
name_value_sourceId: {
name: "event",
value: event,
sourceId: triggerSource.id,
},
},
@@ -0,0 +1,93 @@
import type { TriggerSource, UpdateTriggerSourceBodyV2 } from "@trigger.dev/core";
import type { RuntimeEnvironment } from "@trigger.dev/database";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { getSecretStore } from "../secrets/secretStore.server";
export class UpdateSourceServiceV2 {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
environment,
payload,
id,
endpointSlug,
}: {
environment: RuntimeEnvironment;
payload: UpdateTriggerSourceBodyV2;
id: string;
endpointSlug: string;
}): Promise<TriggerSource> {
const endpoint = await this.#prismaClient.endpoint.findUniqueOrThrow({
where: {
environmentId_slug: {
environmentId: environment.id,
slug: endpointSlug,
},
},
});
const triggerSource = await this.#prismaClient.triggerSource.findUniqueOrThrow({
where: {
key_environmentId: {
environmentId: environment.id,
key: id,
},
},
include: {
secretReference: true,
},
});
await this.#prismaClient.triggerSource.update({
where: {
id: triggerSource.id,
},
data: {
active: true,
channelData: payload.data as any,
endpointId: endpoint.id,
},
});
const flatOptions = Object.entries(payload.options).flatMap(([name, value]) =>
value.map((v) => ({
name,
value: v,
}))
);
for (const { name, value } of flatOptions) {
await this.#prismaClient.triggerSourceOption.update({
where: {
name_value_sourceId: {
name,
value,
sourceId: triggerSource.id,
},
},
data: {
registered: true,
},
});
}
if (payload.secret) {
// We need to update the secret reference in the store
const secretStore = getSecretStore(triggerSource.secretReference.provider);
await secretStore.setSecret<{ secret: string }>(triggerSource.secretReference.key, {
secret: payload.secret,
});
}
return {
id: triggerSource.id,
key: triggerSource.key,
};
}
}
@@ -1,3 +1,5 @@
import { env } from "process";
import { Run } from "~/presenters/RunPresenter.server";
import {
FetchOperationSchema,
FetchRequestInit,
@@ -6,7 +8,7 @@ import {
RedactString,
calculateRetryAt,
} from "@trigger.dev/core";
import type { Task } from "@trigger.dev/database";
import { RuntimeEnvironmentType, type Task } from "@trigger.dev/database";
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
import { enqueueRunExecutionV2 } from "~/models/jobRunExecution.server";
import { formatUnknownError } from "~/utils/formatErrors.server";
@@ -244,7 +246,9 @@ export class PerformTaskOperationService {
}
async #resumeRunExecution(task: NonNullable<FoundTask>, prisma: PrismaClientOrTransaction) {
await enqueueRunExecutionV2(task.run, prisma);
await enqueueRunExecutionV2(task.run, prisma, {
skipRetrying: task.run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
}
}
@@ -281,6 +285,7 @@ async function findTask(prisma: PrismaClient, id: string) {
attempts: true,
run: {
include: {
environment: true,
queue: true,
},
},
@@ -1,14 +1,14 @@
import { InitializeTriggerBody, REGISTER_SOURCE_EVENT } from "@trigger.dev/core";
import { InitializeTriggerBody, REGISTER_SOURCE_EVENT_V1 } from "@trigger.dev/core";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { AuthenticatedEnvironment } from "../apiAuth.server";
import { EndpointApi } from "../endpointApi.server";
import { RegisterTriggerSourceService } from "./registerTriggerSource.server";
import { RegisterTriggerSourceServiceV1 } from "./registerTriggerSourceV1.server";
import { IngestSendEvent } from "../events/ingestSendEvent.server";
export class InitializeTriggerService {
#prismaClient: PrismaClient;
#registerTriggerSource = new RegisterTriggerSourceService();
#registerTriggerSource = new RegisterTriggerSourceServiceV1();
#sendEvent = new IngestSendEvent();
constructor(prismaClient: PrismaClient = prisma) {
@@ -71,7 +71,7 @@ export class InitializeTriggerService {
environment,
{
id: registration.id,
name: REGISTER_SOURCE_EVENT,
name: REGISTER_SOURCE_EVENT_V1,
source: "trigger.dev",
payload: {
...registration,
@@ -1,12 +1,12 @@
import { RegisterSourceEvent, RegisterTriggerBody } from "@trigger.dev/core";
import { RegisterSourceEventV1, RegisterTriggerBodyV1 } from "@trigger.dev/core";
import { z } from "zod";
import { $transaction, PrismaClient, prisma } from "~/db.server";
import { env } from "~/env.server";
import { AuthenticatedEnvironment } from "../apiAuth.server";
import { getSecretStore } from "../secrets/secretStore.server";
import { RegisterSourceService } from "../sources/registerSource.server";
import { RegisterSourceServiceV1 } from "../sources/registerSourceV1.server";
export class RegisterTriggerSourceService {
export class RegisterTriggerSourceServiceV1 {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
@@ -23,13 +23,13 @@ export class RegisterTriggerSourceService {
registrationMetadata,
}: {
environment: AuthenticatedEnvironment;
payload: RegisterTriggerBody;
payload: RegisterTriggerBodyV1;
id: string;
endpointSlug: string;
key: string;
accountId?: string;
registrationMetadata?: any;
}): Promise<RegisterSourceEvent | undefined> {
}): Promise<RegisterSourceEventV1 | undefined> {
const endpoint = await this.#prismaClient.endpoint.findUniqueOrThrow({
where: {
environmentId_slug: {
@@ -52,7 +52,7 @@ export class RegisterTriggerSourceService {
return await $transaction(
this.#prismaClient,
async (tx) => {
const service = new RegisterSourceService(tx);
const service = new RegisterSourceServiceV1(tx);
const triggerSource = await service.call(
endpoint.id,
@@ -76,7 +76,7 @@ export class RegisterTriggerSourceService {
create: {
dispatchableId: triggerSource.id,
environmentId: environment.id,
event: Array.isArray(payload.rule.event) ? payload.rule.event : [payload.rule.event],
event: Array.isArray(payload.rule.event) ? payload.rule.event : [payload.rule.event],
source: payload.rule.source,
payloadFilter: payload.rule.payload,
contextFilter: payload.rule.context,
@@ -141,7 +141,7 @@ export class RegisterTriggerSourceService {
},
clientId: triggerSource.integration.slug,
},
events: triggerSource.events.map((e) => e.name),
events: triggerSource.options.map((e) => e.value),
missingEvents: [],
orphanedEvents: [],
};
@@ -0,0 +1,170 @@
import {
RegisterSourceEventOptions,
RegisterSourceEventV2,
RegisterTriggerBodyV2,
RegisteredOptionsDiff,
} from "@trigger.dev/core";
import { z } from "zod";
import { $transaction, PrismaClient, prisma } from "~/db.server";
import { env } from "~/env.server";
import { AuthenticatedEnvironment } from "../apiAuth.server";
import { getSecretStore } from "../secrets/secretStore.server";
import { RegisterSourceServiceV2 } from "../sources/registerSourceV2.server";
export class RegisterTriggerSourceServiceV2 {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
environment,
payload,
endpointSlug,
id,
key,
accountId,
registrationMetadata,
}: {
environment: AuthenticatedEnvironment;
payload: RegisterTriggerBodyV2;
id: string;
endpointSlug: string;
key: string;
accountId?: string;
registrationMetadata?: any;
}): Promise<RegisterSourceEventV2 | undefined> {
const endpoint = await this.#prismaClient.endpoint.findUniqueOrThrow({
where: {
environmentId_slug: {
environmentId: environment.id,
slug: endpointSlug,
},
},
});
const dynamicTrigger = await this.#prismaClient.dynamicTrigger.findUniqueOrThrow({
where: {
endpointId_slug_type: {
endpointId: endpoint.id,
slug: id,
type: "EVENT",
},
},
});
return await $transaction(
this.#prismaClient,
async (tx) => {
const service = new RegisterSourceServiceV2(tx);
const triggerSource = await service.call(
endpoint.id,
payload.source,
dynamicTrigger.id,
accountId,
{ id: key, metadata: registrationMetadata }
);
if (!triggerSource) {
return;
}
const eventDispatcher = await tx.eventDispatcher.upsert({
where: {
dispatchableId_environmentId: {
dispatchableId: triggerSource.id,
environmentId: environment.id,
},
},
create: {
dispatchableId: triggerSource.id,
environmentId: environment.id,
event: Array.isArray(payload.rule.event) ? payload.rule.event : [payload.rule.event],
source: payload.rule.source,
payloadFilter: payload.rule.payload,
contextFilter: payload.rule.context,
dispatchable: {
type: "DYNAMIC_TRIGGER",
id: dynamicTrigger.id,
},
},
update: {
event: Array.isArray(payload.rule.event) ? payload.rule.event : [payload.rule.event],
source: payload.rule.source,
payloadFilter: payload.rule.payload,
contextFilter: payload.rule.context,
dispatchable: {
type: "DYNAMIC_TRIGGER",
id: dynamicTrigger.id,
},
},
});
const registration = await tx.dynamicTriggerRegistration.upsert({
where: {
key_dynamicTriggerId: {
key,
dynamicTriggerId: dynamicTrigger.id,
},
},
create: {
key,
dynamicTriggerId: dynamicTrigger.id,
sourceId: triggerSource.id,
eventDispatcherId: eventDispatcher.id,
metadata: registrationMetadata,
},
update: {
metadata: registrationMetadata,
},
});
const secretStore = getSecretStore(triggerSource.secretReference.provider, {
prismaClient: tx,
});
const { secret } = await secretStore.getSecretOrThrow(
z.object({
secret: z.string(),
}),
triggerSource.secretReference.key
);
//turn into required format
const optionsArray = Object.entries(payload.source.options).flatMap(([name, values]) => {
return { name, values };
});
const options = optionsArray.reduce((acc, { name, values }) => {
acc[name] = {
desired: [...new Set(values)],
missing: [],
orphaned: [],
};
return acc;
}, {} as Record<string, { desired: string[]; missing: string[]; orphaned: string[] }>) as RegisterSourceEventOptions;
const data: RegisterSourceEventV2 = {
id: registration.id,
source: {
key: triggerSource.key,
active: triggerSource.active,
params: triggerSource.params,
secret,
data: triggerSource.channelData as any,
channel: {
type: "HTTP",
url: `${env.APP_ORIGIN}/api/v1/sources/http/${triggerSource.id}`,
},
clientId: triggerSource.integration.slug,
},
options,
};
return data;
},
{ timeout: 15000 }
);
}
}
+42 -9
View File
@@ -19,6 +19,7 @@ import { DeliverScheduledEventService } from "./schedules/deliverScheduledEvent.
import { ActivateSourceService } from "./sources/activateSource.server";
import { DeliverHttpSourceRequestService } from "./sources/deliverHttpSourceRequest.server";
import { PerformTaskOperationService } from "./tasks/performTaskOperation.server";
import { addMissingVersionField } from "@trigger.dev/core";
const workerCatalog = {
indexEndpoint: z.object({
@@ -37,11 +38,21 @@ const workerCatalog = {
organizationId: z.string(),
connectionId: z.string(),
}),
activateSource: z.object({
id: z.string(),
orphanedEvents: z.array(z.string()).optional(),
}),
activateSource: z.preprocess(
addMissingVersionField,
z.discriminatedUnion("version", [
z.object({
version: z.literal("1"),
id: z.string(),
orphanedEvents: z.array(z.string()).optional(),
}),
z.object({
version: z.literal("2"),
id: z.string(),
orphanedOptions: z.record(z.string(), z.array(z.string())).optional(),
}),
])
),
deliverEvent: z.object({ id: z.string() }),
"events.invokeDispatcher": z.object({
id: z.string(),
@@ -187,10 +198,27 @@ function getWorkerQueue() {
activateSource: {
priority: 10, // smaller number = higher priority
maxAttempts: 3,
handler: async (payload, job) => {
handler: async (payload, graphileJob) => {
const service = new ActivateSourceService();
await service.call(payload.id, job.id, payload.orphanedEvents);
switch (payload.version) {
case "1": {
//change the input data to match the new schema
await service.call(
payload.id,
graphileJob.id,
payload.orphanedEvents
? {
event: payload.orphanedEvents,
}
: undefined
);
break;
}
case "2": {
await service.call(payload.id, graphileJob.id, payload.orphanedOptions);
break;
}
}
},
},
deliverHttpSourceRequest: {
@@ -292,7 +320,12 @@ function getExecutionWorkerQueue() {
handler: async (payload, job) => {
const service = new PerformRunExecutionV2Service();
await service.call(payload.id, payload.reason, payload.isRetry, payload.resumeTaskId);
await service.call({
id: payload.id,
reason: payload.reason,
resumeTaskId: payload.resumeTaskId,
isRetry: payload.isRetry,
});
},
},
},
+1 -1
View File
@@ -87,7 +87,7 @@ export async function seedCloud(prisma: PrismaClient) {
},
create: {
apiKey: "tr_prod_bNaLxayOXqoj",
pkApiKey: "pk_dev_323f3650218e370508cf",
pkApiKey: "pk_dev_323f3650218e378191cf",
slug: "prod",
type: "PRODUCTION",
project: {
@@ -16,7 +16,7 @@ export const client = new TriggerClient({
});
```
View the [Client API Reference](//sdk/triggerclient) for more information.
View the [Client API Reference](/sdk/triggerclient) for more information.
## Adaptors
@@ -11,9 +11,9 @@ You can view the [source code here](https://github.com/triggerdotdev/examples/tr
className="w-full aspect-video"
src="https://www.youtube.com/embed/uocBQt2HeQo"
title="Create a serverless background job in 10 mins"
frameborder="0"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
allowFullScreen
></iframe>
We cover how to:
-2
View File
@@ -25,5 +25,3 @@ Click the links below to view the Job code. You can also easily test these Jobs
| [GitHub new star alert](https://github.com/triggerdotdev/examples/blob/main/github/src/jobs/newStarAlert.ts) | When a repo is starred a message is logged with the new Stargazers count. | [GitHub](/integrations/apis/github) |
| [Send a Slack message when an event is received](https://github.com/triggerdotdev/examples/blob/main/slack/src/jobs/sendSlackMessage.ts) | Sends a Slack message to a specific channel when an event is received. | [Slack](/integrations/apis/slack) |
| [Send an email using Resend](https://github.com/triggerdotdev/examples/blob/main/resend/src/jobs/resendBasicEmail.ts) | Send a basic email using Resend | [Resend](/integrations/apis/resend) |
If you have any ideas for Jobs you would like to build, you can fill in the [Job request form](https://bcymafitv0e.typeform.com/to/YLUKy9my#source=example-jobs-docs).
+80 -40
View File
@@ -9,11 +9,11 @@ title: Stripe
<CodeGroup>
```bash npm
npm install @trigger.dev/stripe@latest
npm add @trigger.dev/stripe@latest
```
```bash pnpm
pnpm install @trigger.dev/stripe@latest
pnpm add @trigger.dev/stripe@latest
```
```bash yarn
@@ -83,47 +83,87 @@ client.defineJob({
Available triggers are listed below:
| Function Name | Events | Description | Aggregate Version |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | ------------------------ |
| `onCharge` | `charge.succeeded`, `charge.failed`, `charge.captured`, `charge.refunded`, `charge.updated` | Triggered on all charge events | ✔️ |
| `onChargeSucceeded` | `charge.succeeded` | Triggered on charge succeeded events | `onCharge` |
| `onChargeFailed` | `charge.failed` | Triggered on charge failed events | `onCharge` |
| `onChargeCaptured` | `charge.captured` | Triggered on charge captured events | `onCharge` |
| `onChargeRefunded` | `charge.refunded` | Triggered on charge refunded events | `onCharge` |
| `onChargeUpdated` | `charge.updated` | Triggered on charge updated events | `onCharge` |
| `onProduct` | `product.created`, `product.updated`, `product.deleted` | Triggered on all product events | ✔️ |
| `onProductCreated` | `product.created` | Triggered on product created events | `onProduct` |
| `onProductUpdated` | `product.updated` | Triggered on product updated events | `onProduct` |
| `onProductDeleted` | `product.deleted` | Triggered on product deleted events | `onProduct` |
| `onPrice` | `price.created`, `price.updated`, `price.deleted` | Triggered on all price events | ✔️ |
| `onPriceCreated` | `price.created` | Triggered on price created events | `onPrice` |
| `onPriceUpdated` | `price.updated` | Triggered on price updated events | `onPrice` |
| `onPriceDeleted` | `price.deleted` | Triggered on price deleted events | `onPrice` |
| `onCheckoutSession` | `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired` | Triggered on all checkout session events | ✔️ |
| `onCheckoutSessionCompleted` | `checkout.session.completed` | Triggered on checkout session completed events | `onCheckoutSession` |
| `onCheckoutSessionExpired` | `checkout.session.expired` | Triggered on checkout session expired events | `onCheckoutSession` |
| `onCustomerSubscription` | `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `customer.subscription.paused`, `customer.subscription.pending_updated_applied`, `customer.subscription.pending_update_expired`, `customer.subscription.resumed` | Triggered on all customer subscription events | ✔️ |
| `onCustomerSubscriptionCreated` | `customer.subscription.created` | Triggered on customer subscription created events | `onCustomerSubscription` |
| `onCustomerSubscriptionUpdated` | `customer.subscription.updated` | Triggered on customer subscription updated events | `onCustomerSubscription` |
| `onCustomerSubscriptionDeleted` | `customer.subscription.deleted` | Triggered on customer subscription deleted events | `onCustomerSubscription` |
| `onCustomerSubscriptionPaused` | `customer.subscription.paused` | Triggered on customer subscription paused events | `onCustomerSubscription` |
| `onCustomerSubscriptionPending` | `customer.subscription.pending_updated_applied`, `customer.subscription.pending_update_expired` | Triggered on customer subscription pending events | `onCustomerSubscription` |
| `onCustomerSubscriptionResumed` | `customer.subscription.resumed` | Triggered on customer subscription resumed events | `onCustomerSubscription` |
| `onCustomer` | `customer.created`, `customer.updated`, `customer.deleted` | Triggered on all customer events | ✔️ |
| `onCustomerCreated` | `customer.created` | Triggered on customer created events | `onCustomer` |
| `onCustomerUpdated` | `customer.updated` | Triggered on customer updated events | `onCustomer` |
| `onCustomerDeleted` | `customer.deleted` | Triggered on customer deleted events | `onCustomer` |
| `onExternalAccount` | `account.external_account.created`, `account.external_account.updated`, `account.external_account.deleted` | Triggered on all external account events | ✔️ |
| `onExternalAccountCreated` | `account.external_account.created` | Triggered on external account created events | `onExternalAccount` |
| `onExternalAccountUpdated` | `account.external_account.updated` | Triggered on external account updated events | `onExternalAccount` |
| `onExternalAccountDeleted` | `account.external_account.deleted` | Triggered on external account deleted events | `onExternalAccount` |
| `onPerson` | `account.person.created`, `account.person.updated`, `account.person.deleted` | Triggered on all person events | ✔️ |
| `onPersonCreated` | `account.person.created` | Triggered on person created events | `onPerson` |
| `onPersonUpdated` | `account.person.updated` | Triggered on person updated events | `onPerson` |
| `onPersonDeleted` | `account.person.deleted` | Triggered on person deleted events | `onPerson` |
| Function Name | Payload Object | Events | Aggregate Version |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| `onCharge` | [Charge](https://stripe.com/docs/api/events/types#charge_object) | `charge.succeeded`, `charge.failed`, `charge.captured`, `charge.refunded`, `charge.updated` | ✔️ |
| `onChargeSucceeded` | [Charge](https://stripe.com/docs/api/events/types#charge_object) | `charge.succeeded` | `onCharge` |
| `onChargeFailed` | [Charge](https://stripe.com/docs/api/events/types#charge_object) | `charge.failed` | `onCharge` |
| `onChargeCaptured` | [Charge](https://stripe.com/docs/api/events/types#charge_object) | `charge.captured` | `onCharge` |
| `onChargeRefunded` | [Charge](https://stripe.com/docs/api/events/types#charge_object) | `charge.refunded` | `onCharge` |
| `onChargeUpdated` | [Charge](https://stripe.com/docs/api/events/types#charge_object) | `charge.updated` | `onCharge` |
| `onProduct` | [Product](https://stripe.com/docs/api/events/types#product_object) | `product.created`, `product.updated`, `product.deleted` | ✔️ |
| `onProductCreated` | [Product](https://stripe.com/docs/api/events/types#product_object) | `product.created` | `onProduct` |
| `onProductUpdated` | [Product](https://stripe.com/docs/api/events/types#product_object) | `product.updated` | `onProduct` |
| `onProductDeleted` | [Product](https://stripe.com/docs/api/events/types#product_object) | `product.deleted` | `onProduct` |
| `onPrice` | [Price](https://stripe.com/docs/api/events/types#price_object) | `price.created`, `price.updated`, `price.deleted` | ✔️ |
| `onPriceCreated` | [Price](https://stripe.com/docs/api/events/types#price_object) | `price.created` | `onPrice` |
| `onPriceUpdated` | [Price](https://stripe.com/docs/api/events/types#price_object) | `price.updated` | `onPrice` |
| `onPriceDeleted` | [Price](https://stripe.com/docs/api/events/types#price_object) | `price.deleted` | `onPrice` |
| `onCheckoutSession` | [CheckoutSession](https://stripe.com/docs/api/events/types#checkout_session_object) | `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired` | ✔️ |
| `onCheckoutSessionCompleted` | [CheckoutSession](https://stripe.com/docs/api/events/types#checkout_session_object) | `checkout.session.completed` | `onCheckoutSession` |
| `onCheckoutSessionExpired` | [CheckoutSession](https://stripe.com/docs/api/events/types#checkout_session_object) | `checkout.session.expired` | `onCheckoutSession` |
| `onCustomerSubscription` | [Subscription](https://stripe.com/docs/api/events/types#subscription_object) | `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `customer.subscription.paused`, `customer.subscription.pending_updated_applied`, `customer.subscription.pending_update_expired`, `customer.subscription.resumed` | ✔️ |
| `onCustomerSubscriptionCreated` | [Subscription](https://stripe.com/docs/api/events/types#subscription_object) | `customer.subscription.created` | `onCustomerSubscription` |
| `onCustomerSubscriptionUpdated` | [Subscription](https://stripe.com/docs/api/events/types#subscription_object) | `customer.subscription.updated` | `onCustomerSubscription` |
| `onCustomerSubscriptionDeleted` | [Subscription](https://stripe.com/docs/api/events/types#subscription_object) | `customer.subscription.deleted` | `onCustomerSubscription` |
| `onCustomerSubscriptionPaused` | [Subscription](https://stripe.com/docs/api/events/types#subscription_object) | `customer.subscription.paused` | `onCustomerSubscription` |
| `onCustomerSubscriptionPending` | [Subscription](https://stripe.com/docs/api/events/types#subscription_object) | `customer.subscription.pending_updated_applied`, `customer.subscription.pending_update_expired` | `onCustomerSubscription` |
| `onCustomerSubscriptionResumed` | [Subscription](https://stripe.com/docs/api/events/types#subscription_object) | `customer.subscription.resumed` | `onCustomerSubscription` |
| `onCustomer` | [Customer](https://stripe.com/docs/api/events/types#customer_object) | `customer.created`, `customer.updated`, `customer.deleted` | ✔️ |
| `onCustomerCreated` | [Customer](https://stripe.com/docs/api/events/types#customer_object) | `customer.created` | `onCustomer` |
| `onCustomerUpdated` | [Customer](https://stripe.com/docs/api/events/types#customer_object) | `customer.updated` | `onCustomer` |
| `onCustomerDeleted` | [Customer](https://stripe.com/docs/api/events/types#customer_object) | `customer.deleted` | `onCustomer` |
| `onExternalAccount` | [Card](https://stripe.com/docs/api/events/types#account_card_object) or [Bank Account](https://stripe.com/docs/api/events/types#account_bank_account_object) | `account.external_account.created`, `account.external_account.updated`, `account.external_account.deleted` | ✔️ |
| `onExternalAccountCreated` | [Card](https://stripe.com/docs/api/events/types#account_card_object) or [Bank Account](https://stripe.com/docs/api/events/types#account_bank_account_object) | `account.external_account.created` | `onExternalAccount` |
| `onExternalAccountUpdated` | [Card](https://stripe.com/docs/api/events/types#account_card_object) or [Bank Account](https://stripe.com/docs/api/events/types#account_bank_account_object) | `account.external_account.updated` | `onExternalAccount` |
| `onExternalAccountDeleted` | [Card](https://stripe.com/docs/api/events/types#account_card_object) or [Bank Account](https://stripe.com/docs/api/events/types#account_bank_account_object) | `account.external_account.deleted` | `onExternalAccount` |
| `onPerson` | [Person](https://stripe.com/docs/api/events/types#person_object) | `account.person.created`, `account.person.updated`, `account.person.deleted` | ✔️ |
| `onPersonCreated` | [Person](https://stripe.com/docs/api/events/types#person_object) | `account.person.created` | `onPerson` |
| `onPersonUpdated` | [Person](https://stripe.com/docs/api/events/types#person_object) | `account.person.updated` | `onPerson` |
| `onPersonDeleted` | [Person](https://stripe.com/docs/api/events/types#person_object) | `account.person.deleted` | `onPerson` |
| `onAccountUpdated` | [Account](https://stripe.com/docs/api/events/types#account_object) | `account.updated` | N/A |
| `onPaymentIntent` | [PaymentIntent](https://stripe.com/docs/api/events/types#payment_intent_object) | `payment_intent.created`, `payment_intent.succeeded`, `payment_intent.payment_failed`, `payment_intent.canceled`, `payment_intent.processing`, `payment_intent.amount_capturable_updated`, `payment_intent.requires_action`, `payment_intent.partially_funded` | ✔️ |
| `onPaymentIntentCreated` | [PaymentIntent](https://stripe.com/docs/api/events/types#payment_intent_object) | `payment_intent.created` | `onPaymentIntent` |
| `onPaymentIntentSucceeded` | [PaymentIntent](https://stripe.com/docs/api/events/types#payment_intent_object) | `payment_intent.succeeded` | `onPaymentIntent` |
| `onPaymentIntentPaymentFailed` | [PaymentIntent](https://stripe.com/docs/api/events/types#payment_intent_object) | `payment_intent.payment_failed` | `onPaymentIntent` |
| `onPaymentIntentCanceled` | [PaymentIntent](https://stripe.com/docs/api/events/types#payment_intent_object) | `payment_intent.canceled` | `onPaymentIntent` |
| `onPaymentIntentProcessing` | [PaymentIntent](https://stripe.com/docs/api/events/types#payment_intent_object) | `payment_intent.processing` | `onPaymentIntent` |
| `onPaymentIntentRequiresAction` | [PaymentIntent](https://stripe.com/docs/api/events/types#payment_intent_object) | `payment_intent.requires_action` | `onPaymentIntent` |
| `onPaymentIntentAmountCapturableUpdated` | [PaymentIntent](https://stripe.com/docs/api/events/types#payment_intent_object) | `payment_intent.amount_capturable_updated` | `onPaymentIntent` |
| `onPaymentIntentPartiallyFunded` | [PaymentIntent](https://stripe.com/docs/api/events/types#payment_intent_object) | `payment_intent.partially_funded` | `onPaymentIntent` |
| `onPayout` | [Payout](https://stripe.com/docs/api/events/types#payout_object) | `payout.created`, `payout.updated`, `payout.canceled`, `payout.paid`, `payout.failed`, `payout.reconciliation_completed` | ✔️ |
| `onPayoutCreated` | [Payout](https://stripe.com/docs/api/events/types#payout_object) | `payout.created` | `onPayout` |
| `onPayoutUpdated` | [Payout](https://stripe.com/docs/api/events/types#payout_object) | `payout.updated` | `onPayout` |
| `onPayoutCanceled` | [Payout](https://stripe.com/docs/api/events/types#payout_object) | `payout.canceled` | `onPayout` |
| `onPayoutPaid` | [Payout](https://stripe.com/docs/api/events/types#payout_object) | `payout.paid` | `onPayout` |
| `onPayoutFailed` | [Payout](https://stripe.com/docs/api/events/types#payout_object) | `payout.failed` | `onPayout` |
| `onPayoutReconciliationCompleted` | [Payout](https://stripe.com/docs/api/events/types#payout_object) | `payout.reconciliation_completed` | `onPayout` |
If there are any triggers missing that you'd like to see added, please [open a new GitHub Issue](https://github.com/triggerdotdev/trigger.dev/issues/new)
### Filtering
All the Stripe triggers take an optional `filter` parameter that allows you to only run the job when the filter matches the event payload:
```ts
// Only trigger when the currency is USD
client.defineJob({
id: "stripe-on-subscription-created",
name: "Stripe On Subscription Created",
version: "0.1.0",
trigger: stripe.onCustomerSubscriptionCreated({
filter: {
currency: ["usd"],
},
}),
run: async (payload, io, ctx) => {
await io.logger.info("ctx", { ctx });
},
});
```
Check out our [Event Filter docs](/documentation/guides/event-filter) for more information on how to use the filter.
## Tasks
You can make reliable calls to the Stripe API inside of jobs using the exposed stripe tasks:
+44
View File
@@ -0,0 +1,44 @@
---
title: The structure of an integration
description: "What files are in an Integration package and what do they do?"
---
```sh
stripe
├── README.md
├── package.json
├── tsup.config.ts
├── src
│ ├── index.ts
│ ├── tasks.ts
│ └── types.ts
└── tsconfig.json
```
Once you've created your Integration package, you can start developing it. In this stage of development, you'll mainly be making changes to the `src/index.ts`, `src/types.ts`, and `src/tasks.ts` files. Below is an explanation of each of the files and their roles:
### `src/index.ts`
This is the entry point of the Integration package and where the client lives. See [the Client docs](/integrations/create-client).
### `src/types.ts`
This file contains all of the types that are used in the Integration package. It's a good idea to keep all of the types in this file so that they can be easily imported into both the `src/index.ts` and `src/tasks.ts` files.
You'll want to export the following types from this file
- **SDK Client Type** - Export the type of the underlying SDK client that will be used both in authenticated tasks (more below) and the main Integration class.
- **Authenticated Task Param Types** - Export a single type for each authenticated task input params
- **Authenticated Task Response Types** - Export a single type for each authenticated task output response
### `src/tasks.ts`
This file contains all of the authenticated tasks that the Integration will support. Tasks are the main way that developers will interact with your Integration. They are the actions that developers will be able to perform in their jobs.
For more about how tasks work, see the [tasks documentation](/documentation/concepts/tasks).
See the guide for creating [authenticated tasks](/integrations/create-tasks) for more information.
### Triggers
See the guide for creating [triggers](/integrations/create-triggers) for more information.
+99
View File
@@ -0,0 +1,99 @@
---
title: Creating the client
description: "Each integration has a client that performs the actual HTTP requests. Usually this is a wrapper around an official SDK provided by the service."
---
### `src/index.ts`
This is where the client should live.
<Tip>
We're adopting the naming convention of naming the class after the service, without a suffix or
prefix. We prefer the exported name be `Slack` instead of something like `SlackIntegration` or
`SlackConnector`
</Tip>
<Accordion title="Example: OpenAI">
```ts
import type { IntegrationClient, TriggerIntegration } from "@trigger.dev/sdk";
import { Configuration, OpenAIApi } from "openai";
import * as tasks from "./tasks";
import { OpenAIIntegrationOptions } from "./types";
export class OpenAI implements TriggerIntegration<IntegrationClient<OpenAIApi, typeof tasks>> {
client: IntegrationClient<OpenAIApi, typeof tasks>;
constructor(private options: OpenAIIntegrationOptions) {
this.client = {
tasks,
usesLocalAuth: true,
client: new OpenAIApi(
new Configuration({
apiKey: options.apiKey,
organization: options.organization,
})
),
auth: {
apiKey: options.apiKey,
organization: options.organization,
},
};
}
get id() {
return this.options.id;
}
get metadata() {
return { id: "openai", name: "OpenAI" };
}
}
```
</Accordion>
The `TriggerIntegration` interface requires three properties to be implemented:
<ParamField body="id" type="string" required>
The `id` that uniquely identifies the Integration. This should always be passed through the
constructor options.
</ParamField>
<ParamField body="metadata" type="object" required>
<Expandable title="properties">
<ParamField body="id" type="string" required>
A unique identifier for the Integration. For example, the OpenAI Integration has an id of
`"openai"`.
</ParamField>
<ParamField body="name" type="string" required>
The name of the Integration. For example, the OpenAI Integration has a name of `"OpenAI"`.
</ParamField>
</Expandable>
</ParamField>
<ParamField body="client" type="IntegrationClient" required>
An IntegrationClient object that contains either the underlying SDK client (if
using local auth) or a `clientFactory` function to create new clients using
authenticated credentials.
{" "}
<Expandable title="properties" defaultOpen>
<ParamField body="usesLocalAuth" type="boolean" required>
Specifies whether the client uses local auth or not. If `true`, the `client` property should contain the underlying SDK client. If `false`, the `client` property should contain a `clientFactory` function that can be used to create new clients using authenticated credentials.
</ParamField>
<ParamField body="tasks" type="Record<string, AuthenticatedTask>" required>
An object that contains all of the authenticated tasks that are supported by the Integration. The keys of the object should be the names of the tasks and the values should be the authenticated tasks. More on authenticated tasks below.
</ParamField>
<ParamField body="clientFactory" type="function">
A function that takes in an `auth` object (of type [ConnectionAuth](/sdk/connection-auth)) and returns a new SDK client that is authenticated with the credentials in the `auth` object. This should only be set if `usesLocalAuth` is `false`.
</ParamField>
<ParamField body="client" type="SDK Client">
The underlying SDK client that will be used to make requests to the service. This should only be set if `usesLocalAuth` is `true`.
</ParamField>
<ParamField body="auth" type="any">
The authenticated credentials that were used to create the `client`. This should only be set if `usesLocalAuth` is `true`. This is used to access the credentials inside of authenticated tasks, but is only necessary in specific cases (like when doing a [`backgroundFetch`](/sdk/io/backgroundfetch) as a subtask)
</ParamField>
</Expandable>
</ParamField>
+6
View File
@@ -0,0 +1,6 @@
---
title: OAuth
description: "OAuth is used to authenticate users by passing them to a third-party service, such as Google or Facebook, to log in."
---
<Note>The guide on adding a new OAuth app to Trigger.dev is coming soon</Note>
+17
View File
@@ -0,0 +1,17 @@
---
title: Publishing
description: "Publishing an official integration package to the public registry."
---
## 1. Check your integration
Ensure that you have done the following:
- [ ] Create the integrations in the `integrations` folder.
- [ ] Created a file in the `examples/job-catalog` folder with some example jobs.
- [ ] Have exported types.
- [ ] Avoid using `any` types and `@ts-ignore` comments. If there are any, please explain them in the PR.
## 2. Create a Pull Request
[Create a Pull Request](https://github.com/triggerdotdev/trigger.dev/pulls) in the Trigger.dev repository.
+60
View File
@@ -0,0 +1,60 @@
---
title: Setup the package
description: "How to create the folders and basic files for an integration package."
---
Before you embark on creating an Integration, you have to decide where the Integration code will be located. You have two options, either in the [Trigger.dev monorepo](https://github.com/triggerdotdev/trigger.dev) and namespaced under the `@trigger.dev` NPM organization, or in a separate repository you control and published independently.
## Using OpenAI to generate the initial code
When you use the `@trigger.dev/cli create-integration` command you can pass in an OpenAI API key to generate the initial code for your Integration. Use the `-o` option to do this.
```bash
npx @trigger.dev/cli create-integration -o=sk-abcdefghijk integrations/stripe
```
## In the Trigger.dev monorepo
Before you can create an Integration in the Trigger.dev monorepo, you'll need to follow our [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) to get your local environment setup.
Once you've forked the repository and cloned it locally, create a new branch for your Integration and prefix it with the `integrations/` namespace. For example, if you were creating an Integration for [Stripe](https://stripe.com), you would create a branch named `integrations/stripe`.
```bash
git checkout -b integrations/stripe
```
Now you are ready to create your Integration. We've created a CLI tool to help you scaffold out the Integration package. You can run the following command to create a new Integration package in the `integrations` directory:
```bash
npx @trigger.dev/cli create-integration integrations/stripe
```
This will ask you a few questions about your Integration:
1. `What is the name of your Integration package?` - This is the name of the NPM package that will be created. It should be prefixed with `@trigger.dev/integration-` and be all lowercase. For example, if you were creating an Integration for Stripe, you would enter `@trigger.dev/stripe`.
2. `What is the name of the npm package of the Integration?` - This is the name of the NPM package that the Integration will be wrapping. For example, if you were creating an Integration for Stripe, you would enter `stripe`.
From this point, the CLI will create the Integration package for you and install all of the dependencies. It creates the following package structure:
```bash
integrations
└── stripe
├── README.md
├── package.json
├── tsup.config.ts
├── src
│ ├── index.ts
│ ├── tasks.ts
│ └── types.ts
└── tsconfig.json
```
Next, head to the [How to develop an Integration package](#how-to-develop-an-integration-package) section to learn how to develop your Integration.
## In your own repository
You can also create an Integration in your own repository and publish it independently. This is useful if you want to keep your Integration code separate from the Trigger.dev monorepo. In this case, you should use the `@trigger.dev/cli` to create the Integration package in your repository or to start a new one:
```bash
npx @trigger.dev/cli create-integration my-internal-integration
```
+428
View File
@@ -0,0 +1,428 @@
---
title: Authenticated Tasks
description: "Tasks are the main way that developers will interact with your Integration. They are the actions that developers will be able to perform in their jobs."
---
## `src/tasks.ts`
This file contains all of the authenticated tasks that the Integration will support.
## Authenticated tasks
Authenticated tasks are the backbone of an Integration and so we're going to cover them in more detail before moving on the main Integration class.
Authenticated tasks are a specially crafted object that allows the `@trigger.dev/sdk` to run the task seeded with an authenticated SDK client.
For example, here is the `getForm` authenticated task defined in the `@trigger.dev/typeform` Integration package:
<CodeGroup>
```ts tasks.ts
import type { AuthenticatedTask } from "@trigger.dev/sdk";
import type { GetFormParams, GetFormResponse, TypeformSDK } from "./types";
export const getForm: AuthenticatedTask<TypeformSDK, GetFormParams, GetFormResponse> = {
init: (params) => {
return {
name: "Get Form",
params,
icon: "typeform",
properties: [
{
label: "Form ID",
text: params.uid,
},
],
};
},
run: async (params, client) => {
return client.forms.get(params);
},
};
```
```ts types.ts
import { Prettify } from "@trigger.dev/integration-kit";
import { createClient } from "@typeform/api-client";
export type TypeformSDK = ReturnType<typeof createClient>;
export type GetFormParams = {
uid: string;
};
export type GetFormResponse = Prettify<Typeform.Form>;
```
```ts usage.ts
client.defineJob({
id: "typeform-playground",
name: "Typeform Playground",
version: "0.1.1",
integrations: {
typeform,
},
run: async (payload, io, ctx) => {
const form = await io.typeform.getForm("get-form", {
uid: payload.formId,
});
},
});
```
</CodeGroup>
The first thing to notice is the explicit typing of the `getForm` export as an `AuthenticatedTask<TypeformSDK, GetFormParams, GetFormResponse>`.
- The first type parameter is the type of the SDK client that will be used to run the task. In this case, it's the `TypeformSDK` type that is exported from the `src/types.ts` file.
- The second type parameter is the type of the input params that will be passed to the task. The `params` argument in the `run` and `init` functions will be typed as this type parameter.
- The third type parameter is the type of the output response that will be returned from the task. The return type of the `run` function needs to match this type.
If you take a look at the `usage.ts` file above, you can see how this task is used in a job. The `io.typeform.getForm` function is typed as returning `Promise<GetFormResponse>` and the `params` argument is typed as `GetFormParams`.
<Note>
Notice how the params are the _second_ argument to `getForm`, that's because the first argument is
always the task key. See our [Keys and Resumability docs](/documentation/concepts/resumability)
for more on why this is important
</Note>
#### `run` function
The `run` function is the main function that will be called when the task is run. It's an async function that takes up to 5 arguments:
<ParamField body="params" type="type parameter" required>
The input params that were passed to the task. This is the second argument to the `getForm`
function in the example above.
</ParamField>
<ParamField body="client" type="type parameter" required>
The authenticated SDK client that was seeded into the task.
</ParamField>
<ParamField body="task" type="Task">
The underlying [task](/documentation/concepts/tasks) object
</ParamField>
<ParamField body="io" type="IO">
The [IO](/sdk/io/overview) object that can be used to run subtasks using
[`io.runTask`](/sdk/io/runtask)
</ParamField>
<ParamField body="auth" type="ConnectionAuth">
If for some reason you need to access the auth object that was used to seed the SDK client, you
can access it here. The `AuthenticatedTask` generic type takes an optional 4th type parameter that
allows you to specify the auth type
</ParamField>
#### `init` function
The `init` function is used to initialize the task. It's a synchronous function that takes a single argument:
<ParamField body="params" type="type parameter" required>
The input params that were passed to the task. This is the second argument to the `getForm`
function in the example above.
</ParamField>
#### `onError` function
Authenticated tasks take an optional `onError` function that can be used to handle errors that occur during executing of the `run` function of the task. It takes two arguments:
<ParamField body="error" type="unknown" required>
The error that was thrown during the execution of the `run` function.
</ParamField>
<ParamField body="task" type="Task" required>
The underlying [task](/documentation/concepts/tasks) object
</ParamField>
The `onError` function allows you to reformated errors that occur during the execution of the `run` function. For example, all the tasks in `@trigger.dev/openai` specify the following `onError` function:
<CodeGroup>
```ts tasks.ts
import { OpenAIErrorSchema } from "./types";
function onTaskError(error: unknown) {
const openAIError = OpenAIErrorSchema.safeParse(error);
if (!openAIError.success) {
return;
}
const { message, code, type } = openAIError.data.response.data.error;
return new Error(`${type}: ${message}${code ? ` (${code})` : ""}`);
}
```
```ts types.ts
const OpenAIErrorSchema = z.object({
response: z.object({
data: z.object({
error: z.object({
code: z.string().nullable().optional(),
message: z.string(),
type: z.string(),
}),
}),
}),
});
```
</CodeGroup>
You can also use the `onError` function to specify a specific time the task should be retried. The `@trigger.dev/github` Integration uses this to retry rate-limited requests:
```ts
function isRequestError(error: unknown): error is RequestError {
return typeof error === "object" && error !== null && "status" in error;
}
function onError(error: unknown) {
if (!isRequestError(error)) {
return;
}
// Check if this is a rate limit error
if (error.status === 403 && error.response) {
const rateLimitRemaining = error.response.headers["x-ratelimit-remaining"];
const rateLimitReset = error.response.headers["x-ratelimit-reset"];
if (rateLimitRemaining === "0" && rateLimitReset) {
const resetDate = new Date(Number(rateLimitReset) * 1000);
return {
retryAt: resetDate,
error,
};
}
}
}
```
### Triggers
<Note>The guide on creating Integration triggers is coming soon</Note>
## Testing an Integration package
<Note>This section is coming soon</Note>
## Publishing an Integration package
<Note>This section is coming soon</Note>
## Example authenticated tasks
### OpenAI examples
<CodeGroup>
```ts retrieveModel
export const retrieveModel: AuthenticatedTask<
OpenAIClientType,
Prettify<RetrieveModelRequest>,
RetrieveModelResponseData
> = {
onError: onTaskError,
run: async (params, client) => {
return client.retrieveModel(params.model).then((res) => res.data);
},
init: (params) => {
return {
name: "Retrieve model",
params,
icon: "openai",
properties: [
{
label: "Model id",
text: params.model,
},
],
};
},
};
```
```ts createCompletion
export const createCompletion: AuthenticatedTask<
OpenAIClientType,
Prettify<CreateCompletionRequest>,
Prettify<Awaited<ReturnType<OpenAIClientType["createCompletion"]>>["data"]>
> = {
run: async (params, client, task) => {
const response = await client.createCompletion(params);
task.outputProperties = createTaskUsageProperties(response.data.usage);
return response.data;
},
init: (params) => {
return {
name: "Completion",
params,
icon: "openai",
properties: [
{
label: "model",
text: params.model,
},
],
};
},
};
```
```ts backgroundCompletion
export const backgroundCreateCompletion: AuthenticatedTask<
OpenAIClientType,
Prettify<CreateCompletionRequest>,
Prettify<CreateCompletionResponseData>,
OpenAIIntegrationAuth
> = {
run: async (params, client, task, io, auth) => {
const response = await io.backgroundFetch<CreateCompletionResponseData>(
"background",
"https://api.openai.com/v1/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: redactString`Bearer ${auth.apiKey}`,
...(auth.organization ? { "OpenAI-Organization": auth.organization } : {}),
},
body: JSON.stringify(params),
}
);
task.outputProperties = createTaskUsageProperties(response.usage);
return response;
},
init: (params) => {
return {
name: "Background Completion",
params,
icon: "openai",
properties: [
{
label: "model",
text: params.model,
},
],
};
},
};
```
</CodeGroup>
### GitHub examples
<CodeGroup>
```ts createIssue
const createIssue: GithubAuthenticatedTask<
{ title: string; owner: string; repo: string },
OctokitClient["rest"]["issues"]["create"]
> = {
onError,
run: async (params, client, task, io) => {
return client.rest.issues
.create({
owner: params.owner,
repo: params.repo,
title: params.title,
})
.then((res) => res.data);
},
init: (params) => {
return {
name: "Create Issue",
params,
properties: [
...repoProperties(params),
{
label: "Title",
text: params.title,
},
],
retry: {
limit: 3,
factor: 2,
minTimeoutInMs: 500,
maxTimeoutInMs: 30000,
randomize: true,
},
};
},
};
```
```ts createIssueCommentWithReaction
const createIssueCommentWithReaction: GithubAuthenticatedTask<
{
body: string;
owner: string;
repo: string;
issueNumber: number;
reaction: ReactionContent;
},
OctokitClient["rest"]["issues"]["createComment"]
> = {
onError,
run: async (params, client, task, io, auth) => {
const comment = await io.runTask(
`Comment on Issue #${params.issueNumber}`,
createIssueComment.init(params),
async (t) => {
return createIssueComment.run(params, client, t, io, auth);
}
);
await io.runTask(
`React with ${params.reaction}`,
addIssueCommentReaction.init({
owner: params.owner,
repo: params.repo,
commentId: comment.id,
content: params.reaction,
}),
async (t) => {
return addIssueCommentReaction.run(
{
owner: params.owner,
repo: params.repo,
commentId: comment.id,
content: params.reaction,
},
client,
t,
io,
auth
);
}
);
return comment;
},
init: (params) => {
return {
name: "Create Issue Comment",
params,
properties: [
{
label: "Repo",
text: params.repo,
},
{
label: "Issue",
text: `#${params.issueNumber}`,
},
],
};
},
};
```
</CodeGroup>
+6
View File
@@ -0,0 +1,6 @@
---
title: Triggers
description: "Triggers cause a Job Run to start. Webhooks and polling are the most relevant for integrations."
---
<Note>The guide on creating Integration triggers is coming soon</Note>
+8 -590
View File
@@ -1,5 +1,5 @@
---
title: Create an Integration
title: Introduction
description: "You can create Integrations of your own."
---
@@ -7,592 +7,10 @@ Before creating an Integration, make sure to read the [Integration overview](/do
What we'll cover in this guide:
- [Creating an Integration package](#creating-an-integration-package)
- [Developing an Integration package](#developing-an-integration-package)
- [Testing an Integration package](#testing-an-integration-package)
- [Publishing an Integration package](#publishing-an-integration-package)
## Creating an Integration package
Before you embark on creating an Integration, you have to decide where the Integration code will be located. You have two options, either in the [Trigger.dev monorepo](https://github.com/triggerdotdev/trigger.dev) and namespaced under the `@trigger.dev` NPM organization, or in a separate repository you control and published independently.
### In the Trigger.dev monorepo
Before you can create an Integration in the Trigger.dev monorepo, you'll need to follow our [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) to get your local environment setup.
Once you've forked the repository and cloned it locally, create a new branch for your Integration and prefix it with the `integrations/` namespace. For example, if you were creating an Integration for [Stripe](https://stripe.com), you would create a branch named `integrations/stripe`.
```bash
git checkout -b integrations/stripe
```
Now you are ready to create your Integration. We've created a CLI tool to help you scaffold out the Integration package. You can run the following command to create a new Integration package in the `integrations` directory:
```bash
npx @trigger.dev/cli create-integration integrations/stripe
```
This will ask you a few questions about your Integration:
1. `What is the name of your Integration package?` - This is the name of the NPM package that will be created. It should be prefixed with `@trigger.dev/integration-` and be all lowercase. For example, if you were creating an Integration for Stripe, you would enter `@trigger.dev/stripe`.
2. `What is the name of the npm package of the Integration?` - This is the name of the NPM package that the Integration will be wrapping. For example, if you were creating an Integration for Stripe, you would enter `stripe`.
From this point, the CLI will create the Integration package for you and install all of the dependencies. It creates the following package structure:
```bash
integrations
└── stripe
├── README.md
├── package.json
├── tsup.config.ts
├── src
│ ├── index.ts
│ ├── tasks.ts
│ └── types.ts
└── tsconfig.json
```
Next, head to the [How to develop an Integration package](#how-to-develop-an-integration-package) section to learn how to develop your Integration.
### In your own repository
You can also create an Integration in your own repository and publish it independently. This is useful if you want to keep your Integration code separate from the Trigger.dev monorepo. In this case, you should use the `@trigger.dev/cli` to create the Integration package in your repository or to start a new one:
```bash
npx @trigger.dev/cli create-integration my-internal-integration
```
## Developing an Integration package
Once you've created your Integration package, you can start developing it. In this stage of development, you'll mainly be making changes to the `src/index.ts`, `src/types.ts`, and `src/tasks.ts` files. Below is an explanation of each of the files and their roles:
### `src/index.ts`
This is the entry point of the Integration package. It exports a main "integration" class that implements the `TriggerIntegration` interface. For example, the `@trigger.dev/github` Integration exports a `Github` class that implements.
<Tip>
We're adopting the naming convention of naming the class after the service, without a suffix or
prefix. We prefer the exported name be `Slack` instead of something like `SlackIntegration` or
`SlackConnector`
</Tip>
<Accordion title="Example: OpenAI">
```ts
import type { IntegrationClient, TriggerIntegration } from "@trigger.dev/sdk";
import { Configuration, OpenAIApi } from "openai";
import * as tasks from "./tasks";
import { OpenAIIntegrationOptions } from "./types";
export class OpenAI implements TriggerIntegration<IntegrationClient<OpenAIApi, typeof tasks>> {
client: IntegrationClient<OpenAIApi, typeof tasks>;
constructor(private options: OpenAIIntegrationOptions) {
this.client = {
tasks,
usesLocalAuth: true,
client: new OpenAIApi(
new Configuration({
apiKey: options.apiKey,
organization: options.organization,
})
),
auth: {
apiKey: options.apiKey,
organization: options.organization,
},
};
}
get id() {
return this.options.id;
}
get metadata() {
return { id: "openai", name: "OpenAI" };
}
}
```
</Accordion>
The `TriggerIntegration` interface requires three properties to be implemented:
<ParamField body="id" type="string" required>
The `id` that uniquely identifies the Integration. This should always be passed through the
constructor options.
</ParamField>
<ParamField body="metadata" type="object" required>
<Expandable title="properties">
<ParamField body="id" type="string" required>
A unique identifier for the Integration. For example, the OpenAI Integration has an id of
`"openai"`.
</ParamField>
<ParamField body="name" type="string" required>
The name of the Integration. For example, the OpenAI Integration has a name of `"OpenAI"`.
</ParamField>
</Expandable>
</ParamField>
<ParamField body="client" type="IntegrationClient" required>
An IntegrationClient object that contains either the underlying SDK client (if
using local auth) or a `clientFactory` function to create new clients using
authenticated credentials.
{" "}
<Expandable title="properties" defaultOpen>
<ParamField body="usesLocalAuth" type="boolean" required>
Specifies whether the client uses local auth or not. If `true`, the `client` property should contain the underlying SDK client. If `false`, the `client` property should contain a `clientFactory` function that can be used to create new clients using authenticated credentials.
</ParamField>
<ParamField body="tasks" type="Record<string, AuthenticatedTask>" required>
An object that contains all of the authenticated tasks that are supported by the Integration. The keys of the object should be the names of the tasks and the values should be the authenticated tasks. More on authenticated tasks below.
</ParamField>
<ParamField body="clientFactory" type="function">
A function that takes in an `auth` object (of type [ConnectionAuth](/sdk/connection-auth)) and returns a new SDK client that is authenticated with the credentials in the `auth` object. This should only be set if `usesLocalAuth` is `false`.
</ParamField>
<ParamField body="client" type="SDK Client">
The underlying SDK client that will be used to make requests to the service. This should only be set if `usesLocalAuth` is `true`.
</ParamField>
<ParamField body="auth" type="any">
The authenticated credentials that were used to create the `client`. This should only be set if `usesLocalAuth` is `true`. This is used to access the credentials inside of authenticated tasks, but is only necessary in specific cases (like when doing a [`backgroundFetch`](/sdk/io/backgroundfetch) as a subtask)
</ParamField>
</Expandable>
</ParamField>
### `src/types.ts`
This file contains all of the types that are used in the Integration package. It's a good idea to keep all of the types in this file so that they can be easily imported into both the `src/index.ts` and `src/tasks.ts` files.
You'll want to export the following types from this file
- **SDK Client Type** - Export the type of the underlying SDK client that will be used both in authenticated tasks (more below) and the main Integration class.
- **Authenticated Task Param Types** - Export a single type for each authenticated task input params
- **Authenticated Task Response Types** - Export a single type for each authenticated task output response
### `src/tasks.ts`
This file contains all of the authenticated tasks that the Integration will support. Tasks are the main way that developers will interact with your Integration. They are the actions that developers will be able to perform in their jobs.
For more about how tasks workd, see the [tasks documentation](/documentation/concepts/tasks).
### Authenticated tasks
Authenticated tasks are the backbone of an Integration and so we're going to cover them in more detail before moving on the main Integration class.
Authenticated tasks are a specially crafted object that allows the `@trigger.dev/sdk` to run the task seeded with an authenticated SDK client.
For example, here is the `getForm` authenticated task defined in the `@trigger.dev/typeform` Integration package:
<CodeGroup>
```ts tasks.ts
import type { AuthenticatedTask } from "@trigger.dev/sdk";
import type { GetFormParams, GetFormResponse, TypeformSDK } from "./types";
export const getForm: AuthenticatedTask<TypeformSDK, GetFormParams, GetFormResponse> = {
init: (params) => {
return {
name: "Get Form",
params,
icon: "typeform",
properties: [
{
label: "Form ID",
text: params.uid,
},
],
};
},
run: async (params, client) => {
return client.forms.get(params);
},
};
```
```ts types.ts
import { Prettify } from "@trigger.dev/integration-kit";
import { createClient } from "@typeform/api-client";
export type TypeformSDK = ReturnType<typeof createClient>;
export type GetFormParams = {
uid: string;
};
export type GetFormResponse = Prettify<Typeform.Form>;
```
```ts usage.ts
client.defineJob({
id: "typeform-playground",
name: "Typeform Playground",
version: "0.1.1",
integrations: {
typeform,
},
run: async (payload, io, ctx) => {
const form = await io.typeform.getForm("get-form", {
uid: payload.formId,
});
},
});
```
</CodeGroup>
The first thing to notice is the explicit typing of the `getForm` export as an `AuthenticatedTask<TypeformSDK, GetFormParams, GetFormResponse>`.
- The first type parameter is the type of the SDK client that will be used to run the task. In this case, it's the `TypeformSDK` type that is exported from the `src/types.ts` file.
- The second type parameter is the type of the input params that will be passed to the task. The `params` argument in the `run` and `init` functions will be typed as this type parameter.
- The third type parameter is the type of the output response that will be returned from the task. The return type of the `run` function needs to match this type.
If you take a look at the `usage.ts` file above, you can see how this task is used in a job. The `io.typeform.getForm` function is typed as returning `Promise<GetFormResponse>` and the `params` argument is typed as `GetFormParams`.
<Note>
Notice how the params are the _second_ argument to `getForm`, that's because the first argument is
always the task key. See our [Keys and Resumability docs](/documentation/concepts/resumability)
for more on why this is important
</Note>
#### `run` function
The `run` function is the main function that will be called when the task is run. It's an async function that takes up to 5 arguments:
<ParamField body="params" type="type parameter" required>
The input params that were passed to the task. This is the second argument to the `getForm`
function in the example above.
</ParamField>
<ParamField body="client" type="type parameter" required>
The authenticated SDK client that was seeded into the task.
</ParamField>
<ParamField body="task" type="Task">
The underlying [task](/documentation/concepts/tasks) object
</ParamField>
<ParamField body="io" type="IO">
The [IO](/sdk/io/overview) object that can be used to run subtasks using
[`io.runTask`](/sdk/io/runtask)
</ParamField>
<ParamField body="auth" type="ConnectionAuth">
If for some reason you need to access the auth object that was used to seed the SDK client, you
can access it here. The `AuthenticatedTask` generic type takes an optional 4th type parameter that
allows you to specify the auth type
</ParamField>
#### `init` function
The `init` function is used to initialize the task. It's a synchronous function that takes a single argument:
<ParamField body="params" type="type parameter" required>
The input params that were passed to the task. This is the second argument to the `getForm`
function in the example above.
</ParamField>
#### `onError` function
Authenticated tasks take an optional `onError` function that can be used to handle errors that occur during executing of the `run` function of the task. It takes two arguments:
<ParamField body="error" type="unknown" required>
The error that was thrown during the execution of the `run` function.
</ParamField>
<ParamField body="task" type="Task" required>
The underlying [task](/documentation/concepts/tasks) object
</ParamField>
The `onError` function allows you to reformated errors that occur during the execution of the `run` function. For example, all the tasks in `@trigger.dev/openai` specify the following `onError` function:
<CodeGroup>
```ts tasks.ts
import { OpenAIErrorSchema } from "./types";
function onTaskError(error: unknown) {
const openAIError = OpenAIErrorSchema.safeParse(error);
if (!openAIError.success) {
return;
}
const { message, code, type } = openAIError.data.response.data.error;
return new Error(`${type}: ${message}${code ? ` (${code})` : ""}`);
}
```
```ts types.ts
const OpenAIErrorSchema = z.object({
response: z.object({
data: z.object({
error: z.object({
code: z.string().nullable().optional(),
message: z.string(),
type: z.string(),
}),
}),
}),
});
```
</CodeGroup>
You can also use the `onError` function to specify a specific time the task should be retried. The `@trigger.dev/github` Integration uses this to retry rate-limited requests:
```ts
function isRequestError(error: unknown): error is RequestError {
return typeof error === "object" && error !== null && "status" in error;
}
function onError(error: unknown) {
if (!isRequestError(error)) {
return;
}
// Check if this is a rate limit error
if (error.status === 403 && error.response) {
const rateLimitRemaining = error.response.headers["x-ratelimit-remaining"];
const rateLimitReset = error.response.headers["x-ratelimit-reset"];
if (rateLimitRemaining === "0" && rateLimitReset) {
const resetDate = new Date(Number(rateLimitReset) * 1000);
return {
retryAt: resetDate,
error,
};
}
}
}
```
### Triggers
<Note>The guide on creating Integration triggers is coming soon</Note>
## Testing an Integration package
<Note>This section is coming soon</Note>
## Publishing an Integration package
<Note>This section is coming soon</Note>
## Example authenticated tasks
### OpenAI examples
<CodeGroup>
```ts retrieveModel
export const retrieveModel: AuthenticatedTask<
OpenAIClientType,
Prettify<RetrieveModelRequest>,
RetrieveModelResponseData
> = {
onError: onTaskError,
run: async (params, client) => {
return client.retrieveModel(params.model).then((res) => res.data);
},
init: (params) => {
return {
name: "Retrieve model",
params,
icon: "openai",
properties: [
{
label: "Model id",
text: params.model,
},
],
};
},
};
```
```ts createCompletion
export const createCompletion: AuthenticatedTask<
OpenAIClientType,
Prettify<CreateCompletionRequest>,
Prettify<Awaited<ReturnType<OpenAIClientType["createCompletion"]>>["data"]>
> = {
run: async (params, client, task) => {
const response = await client.createCompletion(params);
task.outputProperties = createTaskUsageProperties(response.data.usage);
return response.data;
},
init: (params) => {
return {
name: "Completion",
params,
icon: "openai",
properties: [
{
label: "model",
text: params.model,
},
],
};
},
};
```
```ts backgroundCompletion
export const backgroundCreateCompletion: AuthenticatedTask<
OpenAIClientType,
Prettify<CreateCompletionRequest>,
Prettify<CreateCompletionResponseData>,
OpenAIIntegrationAuth
> = {
run: async (params, client, task, io, auth) => {
const response = await io.backgroundFetch<CreateCompletionResponseData>(
"background",
"https://api.openai.com/v1/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: redactString`Bearer ${auth.apiKey}`,
...(auth.organization ? { "OpenAI-Organization": auth.organization } : {}),
},
body: JSON.stringify(params),
}
);
task.outputProperties = createTaskUsageProperties(response.usage);
return response;
},
init: (params) => {
return {
name: "Background Completion",
params,
icon: "openai",
properties: [
{
label: "model",
text: params.model,
},
],
};
},
};
```
</CodeGroup>
### GitHub examples
<CodeGroup>
```ts createIssue
const createIssue: GithubAuthenticatedTask<
{ title: string; owner: string; repo: string },
OctokitClient["rest"]["issues"]["create"]
> = {
onError,
run: async (params, client, task, io) => {
return client.rest.issues
.create({
owner: params.owner,
repo: params.repo,
title: params.title,
})
.then((res) => res.data);
},
init: (params) => {
return {
name: "Create Issue",
params,
properties: [
...repoProperties(params),
{
label: "Title",
text: params.title,
},
],
retry: {
limit: 3,
factor: 2,
minTimeoutInMs: 500,
maxTimeoutInMs: 30000,
randomize: true,
},
};
},
};
```
```ts createIssueCommentWithReaction
const createIssueCommentWithReaction: GithubAuthenticatedTask<
{
body: string;
owner: string;
repo: string;
issueNumber: number;
reaction: ReactionContent;
},
OctokitClient["rest"]["issues"]["createComment"]
> = {
onError,
run: async (params, client, task, io, auth) => {
const comment = await io.runTask(
`Comment on Issue #${params.issueNumber}`,
createIssueComment.init(params),
async (t) => {
return createIssueComment.run(params, client, t, io, auth);
}
);
await io.runTask(
`React with ${params.reaction}`,
addIssueCommentReaction.init({
owner: params.owner,
repo: params.repo,
commentId: comment.id,
content: params.reaction,
}),
async (t) => {
return addIssueCommentReaction.run(
{
owner: params.owner,
repo: params.repo,
commentId: comment.id,
content: params.reaction,
},
client,
t,
io,
auth
);
}
);
return comment;
},
init: (params) => {
return {
name: "Create Issue Comment",
params,
properties: [
{
label: "Repo",
text: params.repo,
},
{
label: "Issue",
text: `#${params.issueNumber}`,
},
],
};
},
};
```
</CodeGroup>
- [Setup the package](/integrations/create-setup)
- [The structure of an integration](/integrations/create-anatomy)
- [OAuth](/integrations/create-oauth)
- [The integration client](/integrations/create-client)
- [Creating Tasks](/integrations/create-tasks)
- [Creating Triggers](/integrations/create-triggers)
- [Publishing an Integration package](/integrations/create-publishing)
+13 -1
View File
@@ -173,7 +173,19 @@
"group": "Overview",
"pages": [
"integrations/introduction",
"integrations/create"
{
"group": "Create an Integration",
"pages": [
"integrations/create",
"integrations/create-setup",
"integrations/create-anatomy",
"integrations/create-oauth",
"integrations/create-client",
"integrations/create-tasks",
"integrations/create-triggers",
"integrations/create-publishing"
]
}
]
},
{
+11 -2
View File
@@ -13,6 +13,14 @@
"schedules": "nodemon --watch src/schedules.ts -r tsconfig-paths/register -r dotenv/config src/schedules.ts",
"stressTest": "nodemon --watch src/stressTest.ts -r tsconfig-paths/register -r dotenv/config src/stressTest.ts",
"delays": "nodemon --watch src/delays.ts -r tsconfig-paths/register -r dotenv/config src/delays.ts",
"airtable": "nodemon --watch src/airtable.ts -r tsconfig-paths/register -r dotenv/config src/airtable.ts",
"resend": "nodemon --watch src/resend.ts -r tsconfig-paths/register -r dotenv/config src/resend.ts",
"github": "nodemon --watch src/github.ts -r tsconfig-paths/register -r dotenv/config src/github.ts",
"plain": "nodemon --watch src/plain.ts -r tsconfig-paths/register -r dotenv/config src/plain.ts",
"typeform": "nodemon --watch src/typeform.ts -r tsconfig-paths/register -r dotenv/config src/typeform.ts",
"dynamic-schedule": "nodemon --watch src/dynamic-schedule.ts -r tsconfig-paths/register -r dotenv/config src/dynamic-schedule.ts",
"dynamic-triggers": "nodemon --watch src/dynamic-triggers.ts -r tsconfig-paths/register -r dotenv/config src/dynamic-triggers.ts",
"background-fetch": "nodemon --watch src/background-fetch.ts -r tsconfig-paths/register -r dotenv/config src/background-fetch.ts",
"dev:trigger": "trigger-cli dev --port 8080"
},
"dependencies": {
@@ -29,7 +37,8 @@
"@trigger.dev/supabase": "workspace:*",
"@types/node": "20.4.2",
"typescript": "5.1.6",
"zod": "3.21.4"
"zod": "3.21.4",
"@trigger.dev/airtable": "workspace:*"
},
"trigger.dev": {
"endpointId": "job-catalog"
@@ -43,4 +52,4 @@
"ts-node": "^10.9.1",
"tsconfig-paths": "^3.14.1"
}
}
}
+123
View File
@@ -0,0 +1,123 @@
import { TriggerClient, eventTrigger } from "@trigger.dev/sdk";
import { createExpressServer } from "@trigger.dev/express";
import { z } from "zod";
import { Airtable, Collaborator } from "@trigger.dev/airtable";
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 airtable = new Airtable({
// id: "airtable",
// token: process.env["AIRTABLE_TOKEN"],
// });
const airtable = new Airtable({
id: "airtable-oauth",
});
type Status = "Live" | "Complete" | "In progress" | "Planning" | "In reviews";
type LaunchGoalsAndOkRs = {
"Launch goals"?: string;
DRI?: Collaborator;
Team?: string;
Status?: "On track" | "In progress" | "At risk";
"Key results"?: Array<string>;
"Features (from 💻 Features table)"?: Array<string>;
"Status (from 💻 Features)": Array<Status>;
};
client.defineJob({
id: "airtable-example-1",
name: "Airtable Example 1: getRecords",
version: "0.1.0",
trigger: eventTrigger({
name: "airtable.example",
schema: z.object({
baseId: z.string(),
tableName: z.string(),
}),
}),
integrations: {
airtable,
},
run: async (payload, io, ctx) => {
const table = io.airtable.base(payload.baseId).table<LaunchGoalsAndOkRs>(payload.tableName);
const records = await table.getRecords("muliple records", { fields: ["Status"] });
await io.logger.log(records[0].fields.Status ?? "no status");
const aRecord = await table.getRecord("single", records[0].id);
const newRecords = await table.createRecords("create records", [
{
fields: { "Launch goals": "Created from Trigger.dev", Status: "In progress" },
},
]);
const updatedRecords = await table.updateRecords(
"update records",
newRecords.map((record) => ({
id: record.id,
fields: { Status: "At risk" },
}))
);
await io.wait("5 secs", 5);
const deletedRecords = await table.deleteRecords(
"delete records",
updatedRecords.map((record) => record.id)
);
},
});
//todo webhooks require batch support
// client.defineJob({
// id: "airtable-delete-webhooks",
// name: "Airtable Example 2: webhook admin",
// version: "0.1.0",
// trigger: eventTrigger({
// name: "airtable.example",
// schema: z.object({
// baseId: z.string(),
// deleteWebhooks: z.boolean().optional(),
// }),
// }),
// integrations: {
// airtable,
// },
// run: async (payload, io, ctx) => {
// const webhooks = await io.airtable.webhooks().list("list webhooks", { baseId: payload.baseId });
// if (payload.deleteWebhooks === true) {
// for (const webhook of webhooks.webhooks) {
// await io.airtable.webhooks().delete(`delete webhook: ${webhook.id}`, {
// baseId: payload.baseId,
// webhookId: webhook.id,
// });
// }
// }
// },
// });
//todo changes the structure of the trigger so it's airtable.base("val").onChanges({
// client.defineJob({
// id: "airtable-on-table",
// name: "Airtable Example: onTable",
// version: "0.1.0",
// trigger: airtable.onTableChanges({
// baseId: "appSX6ly4nZGfdUSy",
// tableId: "tblr5BReu2yeOMk7n",
// }),
// run: async (payload, io, ctx) => {
// await io.logger.log(`transaction number ${payload.baseTransactionNumber}`);
// },
// });
createExpressServer(client);
@@ -0,0 +1,41 @@
import { createExpressServer } from "@trigger.dev/express";
import { TriggerClient, eventTrigger } from "@trigger.dev/sdk";
import { z } from "zod";
export const client = new TriggerClient({
id: "job-catalog",
apiKey: process.env["TRIGGER_API_KEY"],
apiUrl: process.env["TRIGGER_API_URL"],
verbose: false,
ioLogLocalEnabled: true,
});
client.defineJob({
id: "test-background-fetch-retry",
name: "Test background fetch retry",
version: "0.0.1",
trigger: eventTrigger({
name: "test.background-fetch",
schema: z.object({
url: z.string(),
method: z.string().optional(),
headers: z.record(z.string()).optional(),
body: z.any().optional(),
retry: z.any().optional(),
}),
}),
run: async (payload, io, ctx) => {
return await io.backgroundFetch<any>(
"fetch",
payload.url,
{
method: payload.method ?? "GET",
headers: payload.headers,
body: payload.body ? JSON.stringify(payload.body) : undefined,
},
payload.retry
);
},
});
createExpressServer(client);
@@ -0,0 +1,72 @@
import { DynamicSchedule, TriggerClient, eventTrigger } from "@trigger.dev/sdk";
import { createExpressServer } from "@trigger.dev/express";
import { z } from "zod";
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 dynamicSchedule = new DynamicSchedule(client, {
id: "dynamic-interval",
});
client.defineJob({
id: "get-user-repo-on-schedule",
name: "Get User Repo On Schedule",
version: "0.1.1",
trigger: dynamicSchedule,
run: async (payload, io, ctx) => {
io.logger.log("Hello World");
},
});
client.defineJob({
id: "register-dynamic-interval",
name: "Register Dynamic Interval",
version: "0.1.1",
trigger: eventTrigger({
name: "dynamic.interval",
schema: z.object({
id: z.string(),
seconds: z.number().int().positive(),
}),
}),
run: async (payload, io, ctx) => {
await io.registerInterval("📆", dynamicSchedule, payload.id, {
seconds: payload.seconds,
});
await io.wait("wait", payload.seconds + 10);
await io.unregisterInterval("❌📆", dynamicSchedule, payload.id);
},
});
client.defineJob({
id: "register-dynamic-cron",
name: "Register Dynamic Cron",
version: "0.1.1",
trigger: eventTrigger({
name: "dynamic.cron",
schema: z.object({
id: z.string(),
cron: z.string(),
}),
}),
run: async (payload, io, ctx) => {
await io.registerCron("📆", dynamicSchedule, payload.id, {
cron: payload.cron,
});
await io.wait("wait", 60);
await io.unregisterCron("❌📆", dynamicSchedule, payload.id);
},
});
createExpressServer(client);
@@ -0,0 +1,76 @@
import { createExpressServer } from "@trigger.dev/express";
import { Github, events } from "@trigger.dev/github";
import { DynamicTrigger, TriggerClient, eventTrigger } from "@trigger.dev/sdk";
import { z } from "zod";
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 github = new Github({
id: "github-api-key",
token: process.env["GITHUB_API_KEY"]!,
});
const dynamicOnIssueOpenedTrigger = new DynamicTrigger(client, {
id: "github-issue-opened",
event: events.onIssueOpened,
source: github.sources.repo,
});
const dynamicUserTrigger = new DynamicTrigger(client, {
id: "dynamic-user-trigger",
event: events.onIssueOpened,
source: github.sources.repo,
});
client.defineJob({
id: "register-dynamic-trigger-on-new-repo",
name: "Register dynamic trigger on new repo",
version: "0.1.1",
trigger: eventTrigger({
name: "new.repo",
schema: z.object({ owner: z.string(), repo: z.string() }),
}),
run: async (payload, io, ctx) => {
return await io.registerTrigger("register-repo", dynamicOnIssueOpenedTrigger, payload.repo, {
owner: payload.owner,
repo: payload.repo,
});
},
});
client.defineJob({
id: "listen-for-dynamic-trigger",
name: "Listen for dynamic trigger",
version: "0.1.1",
trigger: dynamicOnIssueOpenedTrigger,
run: async (payload, io, ctx) => {},
});
client.defineJob({
id: "listen-for-dynamic-trigger-2",
name: "Listen for dynamic trigger-2",
version: "0.1.1",
trigger: dynamicOnIssueOpenedTrigger,
run: async (payload, io, ctx) => {},
});
client.defineJob({
id: "user-on-issue-opened",
name: "user on issue opened",
version: "0.1.1",
trigger: dynamicUserTrigger,
run: async (payload, io, ctx) => {
await io.logger.info("user-on-issue-opened", { ctx });
},
});
createExpressServer(client);
+43 -1
View File
@@ -1,5 +1,6 @@
import { createExpressServer } from "@trigger.dev/express";
import { TriggerClient, eventTrigger } from "@trigger.dev/sdk";
import { z } from "zod";
export const client = new TriggerClient({
id: "job-catalog",
@@ -18,7 +19,7 @@ client.defineJob({
name: "event.example",
}),
run: async (payload, io, ctx) => {
await io.runTask("task-example-1", { name: "Task 1" }, async () => {
await io.runTask("task-example-1", async () => {
return {
message: "Hello World",
};
@@ -56,4 +57,45 @@ client.defineJob({
},
});
client.defineJob({
id: "example-job",
name: "Example Job: a joke with a delay",
version: "0.0.2",
trigger: eventTrigger({
name: "shayan.event",
schema: z.object({
userId: z.string(),
delay: z.number(),
}),
}),
run: async (payload, io, ctx) => {
await io.wait("sleeping", payload.delay);
await io.runTask("init", { name: "init" }, async () => {
console.log("init function ran", payload.userId);
});
await io.runTask("failable", { name: "task-1", retry: { limit: 3 } }, async (task) => {
if (task.attempts > 2) {
console.log("task succeeded");
return {
ok: true,
};
}
console.log("task failed");
throw new Error(`Task failed on ${task.attempts} attempt(s)`);
});
await io.runTask(
"log",
{
name: "log",
},
async () => {
console.log("hello from the job", payload.userId);
}
);
},
});
createExpressServer(client);
+382
View File
@@ -0,0 +1,382 @@
import { TriggerClient, eventTrigger } from "@trigger.dev/sdk";
import { createExpressServer } from "@trigger.dev/express";
import { Github, events } from "@trigger.dev/github";
import { Slack } from "@trigger.dev/slack";
import { z } from "zod";
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 githubApiKey = new Github({
id: "github-api-key",
token: process.env["GITHUB_API_KEY"]!,
});
const github = new Github({
id: "github",
octokitRequest: { fetch },
});
const slack = new Slack({ id: "my-slack-new" });
client.defineJob({
id: "github-create-issue",
name: "GitHub Integration - Create issue",
version: "0.1.0",
trigger: eventTrigger({
name: "create-issue",
schema: z.object({
owner: z.string(),
repo: z.string(),
title: z.string(),
}),
}),
integrations: {
github: githubApiKey,
},
run: async (payload, io, ctx) => {
const issue = await io.github.createIssue("create issue", {
title: payload.title,
owner: payload.owner,
repo: payload.repo,
});
await io.github.createIssueCommentWithReaction("comment on issue with reaction", {
body: "This is a comment",
owner: payload.owner,
repo: payload.repo,
issueNumber: issue.number,
reaction: "heart",
});
},
});
client.defineJob({
id: "github-integration-on-issue",
name: "GitHub Integration - On Issue",
version: "0.1.0",
trigger: githubApiKey.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 };
},
});
client.defineJob({
id: "github-integration-on-issue-opened",
name: "GitHub Integration - On Issue Opened",
version: "0.1.0",
integrations: { github: githubApiKey },
trigger: githubApiKey.triggers.repo({
event: events.onIssueOpened,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.github.addIssueAssignees("add assignee", {
owner: payload.repository.owner.login,
repo: payload.repository.name,
issueNumber: payload.issue.number,
assignees: ["matt-aitken"],
});
await io.github.addIssueLabels("add label", {
owner: payload.repository.owner.login,
repo: payload.repository.name,
issueNumber: payload.issue.number,
labels: ["bug"],
});
return { payload, ctx };
},
});
client.defineJob({
id: "github-integration-on-issue-assigned",
name: "GitHub Integration - On Issue assigned",
version: "0.1.0",
trigger: githubApiKey.triggers.repo({
event: events.onIssueAssigned,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
return { payload, ctx };
},
});
client.defineJob({
id: "github-integration-on-issue-commented",
name: "GitHub Integration - On Issue commented",
version: "0.1.0",
trigger: githubApiKey.triggers.repo({
event: events.onIssueComment,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
return { payload, ctx };
},
});
client.defineJob({
id: "star-slack-notification",
name: "New Star Slack Notification",
version: "0.1.0",
integrations: { slack },
trigger: githubApiKey.triggers.repo({
event: events.onNewStar,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
const response = await io.slack.postMessage("Slack star", {
text: `${payload.sender.login} starred ${payload.repository.full_name}.\nTotal: ${payload.repository.stargazers_count}⭐️`,
channel: "C04GWUTDC3W",
});
},
});
client.defineJob({
id: "github-integration-on-new-star",
name: "GitHub Integration - On New Star",
version: "0.1.0",
trigger: githubApiKey.triggers.repo({
event: events.onNewStar,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
return { payload, ctx };
},
});
client.defineJob({
id: "github-integration-on-new-repo",
name: "GitHub Integration - On New Repository",
version: "0.1.0",
trigger: githubApiKey.triggers.repo({
event: events.onNewRepository,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
return { payload, ctx };
},
});
client.defineJob({
id: "github-integration-on-new-branch-or-tag",
name: "GitHub Integration - On New Branch or Tag",
version: "0.1.0",
trigger: githubApiKey.triggers.repo({
event: events.onNewBranchOrTag,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
return { payload, ctx };
},
});
client.defineJob({
id: "github-integration-on-new-branch",
name: "GitHub Integration - On New Branch",
version: "0.1.0",
trigger: githubApiKey.triggers.repo({
event: events.onNewBranch,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
return { payload, ctx };
},
});
client.defineJob({
id: "github-integration-on-push",
name: "GitHub Integration - On Push",
version: "0.1.0",
trigger: githubApiKey.triggers.repo({
event: events.onPush,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
return { payload, ctx };
},
});
client.defineJob({
id: "github-integration-on-pull-request",
name: "GitHub Integration - On Pull Request",
version: "0.1.0",
trigger: githubApiKey.triggers.repo({
event: events.onPullRequest,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
return { payload, ctx };
},
});
client.defineJob({
id: "github-integration-on-pull-request-review",
name: "GitHub Integration - On Pull Request Review",
version: "0.1.0",
trigger: githubApiKey.triggers.repo({
event: events.onPullRequestReview,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
return { payload, ctx };
},
});
client.defineJob({
id: "github-integration-on-pull-request-merge-commit",
name: "GitHub Integration - on Pull Request Merge Commit",
version: "0.1.0",
integrations: { github },
trigger: githubApiKey.triggers.repo({
event: events.onPullRequest,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
if (payload.pull_request.merged && payload.pull_request.merge_commit_sha) {
const commit = await io.github.getCommit("get merge commit", {
owner: payload.repository.owner.login,
repo: payload.repository.name,
commitSHA: payload.pull_request.merge_commit_sha,
});
await io.logger.info("Merge Commit Details", commit);
}
return { payload, ctx };
},
});
client.defineJob({
id: "github-integration-get-tree",
name: "GitHub Integration - Get Tree",
version: "0.1.0",
integrations: { github },
trigger: githubApiKey.triggers.repo({
event: events.onPullRequest,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
if (payload.pull_request.merged && payload.pull_request.merge_commit_sha) {
const tree = await io.github.getTree("get merge commit", {
owner: payload.repository.owner.login,
repo: payload.repository.name,
treeSHA: payload.pull_request.merge_commit_sha,
});
await io.logger.info("Tree ", tree);
}
return { payload, ctx };
},
});
client.defineJob({
id: "github-integration-get-reference",
name: "GitHub Integration - Get Reference",
integrations: { github },
version: "0.1.0",
trigger: githubApiKey.triggers.repo({
event: events.onNewBranch,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
const ref = await io.github.getReference("Get reference", {
owner: payload.repository.owner.login,
repo: payload.repository.name,
ref: payload.ref,
});
await io.logger.info("Reference ", ref);
return { payload, ctx };
},
});
client.defineJob({
id: "github-integration-list-matching-references",
name: "GitHub Integration - List Matching References",
integrations: { github },
version: "0.1.0",
trigger: githubApiKey.triggers.repo({
event: events.onNewBranch,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
const ref = await io.github.listMatchingReferences("List Matching References", {
owner: payload.repository.owner.login,
repo: payload.repository.name,
ref: payload.ref,
});
await io.logger.info("Reference ", ref);
return { payload, ctx };
},
});
client.defineJob({
id: "github-integration-get-tag",
name: "GitHub Integration - Get Tag",
version: "0.1.0",
integrations: { github },
trigger: githubApiKey.triggers.repo({
event: events.onNewBranchOrTag,
owner: "triggerdotdev",
repo: "empty",
}),
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
if (payload.ref_type === "tag") {
const tag = io.github.getTag("Get Tag", {
owner: payload.repository.owner.login,
repo: payload.repository.name,
tagSHA: payload.ref,
});
await io.logger.info("Tag ", tag);
}
return { payload, ctx };
},
});
createExpressServer(client);
+91
View File
@@ -0,0 +1,91 @@
import { TriggerClient, eventTrigger } from "@trigger.dev/sdk";
import { createExpressServer } from "@trigger.dev/express";
import {
ComponentDividerSpacingSize,
ComponentTextColor,
ComponentTextSize,
Plain,
} from "@trigger.dev/plain";
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 plain = new Plain({
id: "plain-1",
apiKey: process.env["PLAIN_API_KEY"]!,
});
client.defineJob({
id: "plain-playground",
name: "Plain Playground",
version: "0.1.1",
integrations: {
plain,
},
trigger: eventTrigger({
name: "plain.playground",
}),
run: async (payload, io, ctx) => {
const { customer } = await io.plain.upsertCustomer("upsert-customer", {
identifier: {
emailAddress: "eric@trigger.dev",
},
onCreate: {
email: {
email: "eric@trigger.dev",
isVerified: true,
},
fullName: "Eric Allam",
externalId: "123",
},
onUpdate: {
fullName: {
value: "Eric Allam",
},
externalId: {
value: "123",
},
},
});
const foundCustomer = await io.plain.getCustomerById("get-customer", {
customerId: customer.id,
});
const timelineEntry = await io.plain.upsertCustomTimelineEntry("upsert-timeline-entry", {
customerId: customer.id,
title: "My timeline entry",
components: [
{
componentText: {
text: `This is a nice title`,
},
},
{
componentDivider: {
dividerSpacingSize: ComponentDividerSpacingSize.M,
},
},
{
componentText: {
textSize: ComponentTextSize.S,
textColor: ComponentTextColor.Muted,
text: "External id",
},
},
{
componentText: {
text: foundCustomer?.externalId ?? "",
},
},
],
});
},
});
createExpressServer(client);
@@ -1,8 +1,16 @@
import { client } from "@/trigger";
import { createExpressServer } from "@trigger.dev/express";
import { Resend } from "@trigger.dev/resend";
import { Job, eventTrigger } from "@trigger.dev/sdk";
import { TriggerClient, eventTrigger } from "@trigger.dev/sdk";
import { z } from "zod";
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-client",
apiKey: process.env.RESEND_API_KEY!,
@@ -24,11 +32,15 @@ client.defineJob({
resend,
},
run: async (payload, io, ctx) => {
await io.resend.sendEmail("📧", {
const response = await io.resend.sendEmail("📧", {
to: payload.to,
subject: payload.subject,
text: payload.text,
from: "Trigger.dev <hello@email.trigger.dev>",
});
await io.logger.info("Sent email", { response });
},
});
createExpressServer(client);
+42 -2
View File
@@ -1,5 +1,6 @@
import { createExpressServer } from "@trigger.dev/express";
import { TriggerClient, intervalTrigger } from "@trigger.dev/sdk";
import { Resend } from "@trigger.dev/resend";
import { TriggerClient, cronTrigger, intervalTrigger } from "@trigger.dev/sdk";
export const client = new TriggerClient({
id: "job-catalog",
@@ -18,7 +19,7 @@ client.defineJob({
seconds: 60 * 3, // 3 minutes
}),
run: async (payload, io, ctx) => {
await io.runTask("task-example-1", { name: "Task 1" }, async () => {
await io.runTask("task-example-1", async () => {
return {
message: "Hello World",
};
@@ -30,4 +31,43 @@ client.defineJob({
},
});
const resend = new Resend({
id: "resend-client",
apiKey: process.env.RESEND_API_KEY!,
});
client.defineJob({
id: "weekly-kpi-report",
name: "Weekly KPI report",
version: "1.0.0",
trigger: cronTrigger({
// Every Friday at 5pm (UTC)
cron: "0 17 * * 5",
}),
integrations: {
resend,
},
run: async (payload, io, ctx) => {
const kpis = await io.runTask("get-kpis", async () => {
return await getKpiData();
});
const emailList = ["jen@whatever.com", "ann@whatever.com"];
await io.resend.sendEmail("send-kpis", {
to: emailList,
subject: "Weekly KPI report",
text: `Users: ${kpis.users}, Revenue: ${kpis.revenue}`,
from: "Trigger.dev <hello@email.trigger.dev>",
});
},
});
async function getKpiData() {
return {
users: 100_000,
revenue: 1_000_000,
};
}
createExpressServer(client);
+1 -1
View File
@@ -23,7 +23,7 @@ client.defineJob({
slack,
},
run: async (payload, io, ctx) => {
await io.slack.postMessage("Slack 📝", {
const message = await io.slack.postMessage("Slack 📝", {
channel: "C04GWUTDC3W",
text: "Welcome to the team, Eric!",
});
+67
View File
@@ -38,4 +38,71 @@ client.defineJob({
},
});
client.defineJob({
id: "stress-test-2",
name: "Stress Test 2",
version: "1.0.0",
trigger: eventTrigger({
name: "stress.test.2",
}),
run: async (payload, io, ctx) => {
await io.runTask(`task-1`, { name: `Task 1` }, async (task) => {
const response = await fetch("https://jsonplaceholder.typicode.com/photos");
return await response.json();
});
await io.runTask(`task-2`, { name: `Task 2` }, async (task) => {
const response = await fetch("https://jsonplaceholder.typicode.com/comments");
return await response.json();
});
await io.runTask(`task-3`, { name: `Task 3` }, async (task) => {
const response = await fetch("https://jsonplaceholder.typicode.com/photos");
return await response.json();
});
await io.runTask(`task-4`, { name: `Task 4` }, async (task) => {
const response = await fetch("https://jsonplaceholder.typicode.com/comments");
return await response.json();
});
const response = await io.runTask(`task-5`, { name: `Task 5` }, async (task) => {
const response = await fetch("https://jsonplaceholder.typicode.com/photos");
return await response.json();
});
await io.runTask(`task-6`, { name: `Task 6` }, async (task) => {
const response = await fetch("https://jsonplaceholder.typicode.com/users");
return await response.json();
});
return response;
},
});
client.defineJob({
id: "long.running",
name: "Long Running Job",
version: "1.0.0",
trigger: eventTrigger({
name: "long.running",
}),
run: async (payload, io, ctx) => {
// Perform X tasks in an iteration, each one taking X milliseconds
for (let i = 0; i < payload.iterations; i++) {
await io.runTask(`task.${i}`, { name: `Task ${i}` }, async (task) => {
await new Promise((resolve) => setTimeout(resolve, payload.duration ?? 5000));
return { i };
});
}
},
});
createExpressServer(client);
+10 -24
View File
@@ -40,30 +40,6 @@ client.defineJob({
},
});
client.defineJob({
id: "stripe-example-1",
name: "Stripe Example 1",
version: "0.1.0",
trigger: eventTrigger({
name: "stripe.example",
schema: z.object({
customerId: z.string(),
source: z.string(),
}),
}),
integrations: {
stripe,
},
run: async (payload, io, ctx) => {
await io.stripe.createCharge("create-charge", {
amount: 100,
currency: "usd",
source: payload.source,
customer: payload.customerId,
});
},
});
client.defineJob({
id: "stripe-create-customer",
name: "Stripe Create Customer",
@@ -186,4 +162,14 @@ client.defineJob({
},
});
client.defineJob({
id: "stripe-on-charge",
name: "Stripe On Charge",
version: "0.1.0",
trigger: stripe.onCharge(),
run: async (payload, io, ctx) => {
await io.logger.info("ctx", { ctx });
},
});
createExpressServer(client);
+63
View File
@@ -0,0 +1,63 @@
import { createExpressServer } from "@trigger.dev/express";
import { TriggerClient, eventTrigger } from "@trigger.dev/sdk";
import { Typeform } from "@trigger.dev/typeform";
import { z } from "zod";
export const client = new TriggerClient({
id: "job-catalog",
apiKey: process.env["TRIGGER_API_KEY"],
apiUrl: process.env["TRIGGER_API_URL"],
verbose: false,
ioLogLocalEnabled: true,
});
export const typeform = new Typeform({
id: "typeform-1",
token: process.env["TYPEFORM_API_KEY"]!,
});
client.defineJob({
id: "typeform-playground",
name: "Typeform Playground",
version: "0.1.1",
integrations: {
typeform,
},
trigger: eventTrigger({
name: "typeform.playground",
schema: z.object({
formId: z.string().optional(),
}),
}),
run: async (payload, io, ctx) => {
await io.typeform.listForms("list-forms");
if (payload.formId) {
const form = await io.typeform.getForm("get-form", {
uid: payload.formId,
});
const listResponses = await io.typeform.listResponses("list-responses", {
uid: payload.formId,
pageSize: 50,
});
const allResponses = await io.typeform.getAllResponses("get-all-responses", {
uid: payload.formId,
});
}
},
});
client.defineJob({
id: "typeform-webhook-2",
name: "Typeform Webhook 2",
version: "0.1.1",
trigger: typeform.onFormResponse({
uid: "KywLXMeB",
tag: "tag1",
}),
run: async (payload, io, ctx) => {},
});
createExpressServer(client);
+95 -30
View File
@@ -1,37 +1,102 @@
{
"extends": "@trigger.dev/tsconfig/node18.json",
"include": ["./src/**/*.ts"],
"include": [
"./src/**/*.ts"
],
"compilerOptions": {
"baseUrl": ".",
"lib": ["DOM", "DOM.Iterable"],
"lib": [
"DOM",
"DOM.Iterable"
],
"paths": {
"@/*": ["./src/*"],
"@trigger.dev/sdk": ["../../packages/trigger-sdk/src/index"],
"@trigger.dev/sdk/*": ["../../packages/trigger-sdk/src/*"],
"@trigger.dev/express": ["../../packages/express/src/index"],
"@trigger.dev/express/*": ["../../packages/express/src/*"],
"@trigger.dev/core": ["../../packages/core/src/index"],
"@trigger.dev/core/*": ["../../packages/core/src/*"],
"@trigger.dev/integration-kit": ["../../packages/integration-kit/src/index"],
"@trigger.dev/integration-kit/*": ["../../packages/integration-kit/src/*"],
"@trigger.dev/github": ["../../integrations/github/src/index"],
"@trigger.dev/github/*": ["../../integrations/github/src/*"],
"@trigger.dev/slack": ["../../integrations/slack/src/index"],
"@trigger.dev/slack/*": ["../../integrations/slack/src/*"],
"@trigger.dev/openai": ["../../integrations/openai/src/index"],
"@trigger.dev/openai/*": ["../../integrations/openai/src/*"],
"@trigger.dev/resend": ["../../integrations/resend/src/index"],
"@trigger.dev/resend/*": ["../../integrations/resend/src/*"],
"@trigger.dev/typeform": ["../../integrations/typeform/src/index"],
"@trigger.dev/typeform/*": ["../../integrations/typeform/src/*"],
"@trigger.dev/plain": ["../../integrations/plain/src/index"],
"@trigger.dev/plain/*": ["../../integrations/plain/src/*"],
"@trigger.dev/supabase": ["../../integrations/supabase/src/index"],
"@trigger.dev/supabase/*": ["../../integrations/supabase/src/*"],
"@trigger.dev/stripe": ["../../integrations/stripe/src/index"],
"@trigger.dev/stripe/*": ["../../integrations/stripe/src/*"],
"@trigger.dev/sendgrid": ["../../integrations/sendgrid/src/index"],
"@trigger.dev/sendgrid/*": ["../../integrations/sendgrid/src/*"]
"@/*": [
"./src/*"
],
"@trigger.dev/sdk": [
"../../packages/trigger-sdk/src/index"
],
"@trigger.dev/sdk/*": [
"../../packages/trigger-sdk/src/*"
],
"@trigger.dev/express": [
"../../packages/express/src/index"
],
"@trigger.dev/express/*": [
"../../packages/express/src/*"
],
"@trigger.dev/core": [
"../../packages/core/src/index"
],
"@trigger.dev/core/*": [
"../../packages/core/src/*"
],
"@trigger.dev/integration-kit": [
"../../packages/integration-kit/src/index"
],
"@trigger.dev/integration-kit/*": [
"../../packages/integration-kit/src/*"
],
"@trigger.dev/github": [
"../../integrations/github/src/index"
],
"@trigger.dev/github/*": [
"../../integrations/github/src/*"
],
"@trigger.dev/slack": [
"../../integrations/slack/src/index"
],
"@trigger.dev/slack/*": [
"../../integrations/slack/src/*"
],
"@trigger.dev/openai": [
"../../integrations/openai/src/index"
],
"@trigger.dev/openai/*": [
"../../integrations/openai/src/*"
],
"@trigger.dev/resend": [
"../../integrations/resend/src/index"
],
"@trigger.dev/resend/*": [
"../../integrations/resend/src/*"
],
"@trigger.dev/typeform": [
"../../integrations/typeform/src/index"
],
"@trigger.dev/typeform/*": [
"../../integrations/typeform/src/*"
],
"@trigger.dev/plain": [
"../../integrations/plain/src/index"
],
"@trigger.dev/plain/*": [
"../../integrations/plain/src/*"
],
"@trigger.dev/supabase": [
"../../integrations/supabase/src/index"
],
"@trigger.dev/supabase/*": [
"../../integrations/supabase/src/*"
],
"@trigger.dev/stripe": [
"../../integrations/stripe/src/index"
],
"@trigger.dev/stripe/*": [
"../../integrations/stripe/src/*"
],
"@trigger.dev/sendgrid": [
"../../integrations/sendgrid/src/index"
],
"@trigger.dev/sendgrid/*": [
"../../integrations/sendgrid/src/*"
],
"@trigger.dev/airtable": [
"../../integrations/airtable/src/index"
],
"@trigger.dev/airtable/*": [
"../../integrations/airtable/src/*"
]
}
}
}
}
+32 -394
View File
@@ -1,8 +1,6 @@
import { client, github, githubUser, openai, slack } from "@/trigger";
import { events } from "@trigger.dev/github";
import {
DynamicSchedule,
DynamicTrigger,
Job,
cronTrigger,
eventTrigger,
@@ -13,169 +11,8 @@ import {
} from "@trigger.dev/sdk";
import { z } from "zod";
const dynamicOnIssueOpenedTrigger = new DynamicTrigger(client, {
id: "github-issue-opened",
event: events.onIssueOpened,
source: github.sources.repo,
});
const dynamicUserTrigger = new DynamicTrigger(client, {
id: "dynamic-user-trigger",
event: events.onIssueOpened,
source: githubUser.sources.repo,
});
const dynamicSchedule = new DynamicSchedule(client, {
id: "dynamic-interval",
});
const enabled = true;
client.defineJob({
id: "test-background-fetch-retry",
name: "Test background fetch retry",
version: "0.0.1",
enabled,
trigger: eventTrigger({
name: "test.background-fetch",
schema: z.object({
url: z.string(),
method: z.string().optional(),
headers: z.record(z.string()).optional(),
body: z.any().optional(),
retry: z.any().optional(),
}),
}),
run: async (payload, io, ctx) => {
return await io.backgroundFetch<any>(
"fetch",
payload.url,
{
method: payload.method ?? "GET",
headers: payload.headers,
body: payload.body ? JSON.stringify(payload.body) : undefined,
},
payload.retry
);
},
});
const CHAT_MODELS = [
"gpt-3.5-turbo",
"gpt-3.5-turbo-0301",
"gpt-3.5-turbo-0613",
"gpt-3.5-turbo-16k",
"gpt-3.5-turbo-16k-0613",
"gpt-4",
"gpt-4-0314",
"gpt-4-0613",
];
client.defineJob({
id: "openai-test",
name: "OpenAI Test",
version: "0.0.1",
enabled,
trigger: eventTrigger({
name: "openai.test",
schema: z.object({
model: z.string(),
prompt: z.string(),
background: z.boolean().optional(),
}),
}),
integrations: {
openai,
},
run: async (payload, io, ctx) => {
if (CHAT_MODELS.includes(payload.model)) {
if (payload.background) {
const completion = await io.openai.backgroundCreateChatCompletion("✨", {
model: payload.model,
messages: [
{
role: "user",
content: payload.prompt,
},
],
});
return completion;
}
const completion = await io.openai.createChatCompletion("✨", {
model: payload.model,
messages: [
{
role: "user",
content: payload.prompt,
},
],
});
return completion;
}
if (payload.background) {
const completion = await io.openai.backgroundCreateCompletion("✨", {
model: payload.model,
prompt: payload.prompt,
});
return completion;
}
const completion = await io.openai.createCompletion("✨", {
model: payload.model,
prompt: payload.prompt,
});
return completion;
},
});
client.defineJob({
id: "openai-errors",
name: "OpenAI Errors",
version: "0.0.1",
enabled,
trigger: eventTrigger({
name: "openai.errors",
}),
integrations: {
openai,
},
run: async (payload, io, ctx) => {
await io.openai.createChatCompletion("chat-completion", {
model: "gpt-3.5-turbo",
messages: [
{
role: "system",
content: "You are an AI assistant that is helpful, creative, clever, and very friendly.",
},
{
role: "user",
content: "Call the supplied function that will tweet a really funny joke",
},
],
function_call: { name: "tweetFunnyJoke" },
functions: [
{
name: "tweetFunnyJoke",
description:
"Tweets a really funny joke. The joke is so funny that it will make you laugh out loud.",
parameters: {
type: "array",
items: {
type: "string",
},
},
},
],
});
},
});
client.defineJob({
id: "on-missing-auth-connection",
name: "On missing auth connection",
@@ -232,25 +69,6 @@ client.defineJob({
},
});
client.defineJob({
id: "user-on-issue-opened",
name: "user on issue opened",
version: "0.1.1",
enabled,
trigger: dynamicUserTrigger,
integrations: {
github: githubUser,
},
run: async (payload, io, ctx) => {
await io.logger.info("user-on-issue-opened", { ctx });
return await io.github.getRepo("get.repo", {
owner: payload.repository.owner.login,
repo: payload.repository.name,
});
},
});
client.defineJob({
id: "get-user-repo",
name: "Get User Repo",
@@ -271,88 +89,6 @@ client.defineJob({
},
});
client.defineJob({
id: "get-user-repo-on-schedule",
name: "Get User Repo On Schedule",
version: "0.1.1",
enabled,
trigger: dynamicSchedule,
integrations: {
github: githubUser,
},
run: async (payload, io, ctx) => {
return await io.github.getRepo("get.repo", {
owner: ctx.event.context.source.metadata.owner,
repo: ctx.event.context.source.metadata.repo,
});
},
});
client.defineJob({
id: "register-dynamic-interval",
name: "Register Dynamic Interval",
version: "0.1.1",
enabled,
trigger: eventTrigger({
name: "dynamic.interval",
schema: z.object({
id: z.string(),
seconds: z.number().int().positive(),
}),
}),
run: async (payload, io, ctx) => {
await io.registerInterval("📆", dynamicSchedule, payload.id, {
seconds: payload.seconds,
});
await io.wait("wait", 60);
await io.unregisterInterval("❌📆", dynamicSchedule, payload.id);
},
});
client.defineJob({
id: "register-dynamic-cron",
name: "Register Dynamic Cron",
version: "0.1.1",
enabled,
trigger: eventTrigger({
name: "dynamic.cron",
schema: z.object({
id: z.string(),
cron: z.string(),
}),
}),
run: async (payload, io, ctx) => {
await io.registerCron("📆", dynamicSchedule, payload.id, {
cron: payload.cron,
});
await io.wait("wait", 60);
await io.unregisterCron("❌📆", dynamicSchedule, payload.id);
},
});
client.defineJob({
id: "use-dynamic-interval",
name: "Use Dynamic Interval",
version: "0.1.1",
enabled,
trigger: dynamicSchedule,
run: async (payload, io, ctx) => {
await io.wait("wait", 5); // wait for 5 seconds
await io.logger.info("This is a log info message", {
payload,
});
await io.sendEvent("send-event", {
name: "custom.event",
payload,
context: ctx,
});
},
});
client.defineJob({
id: "event-1",
name: "Run when the foo.bar event happens",
@@ -364,15 +100,19 @@ client.defineJob({
run: async (payload, io, ctx) => {
await io.try(
async () => {
return await io.runTask("task-1", { name: "task-1", retry: { limit: 3 } }, async (task) => {
if (task.attempts > 2) {
return {
bar: "foo",
};
}
return await io.runTask(
"task-1",
async (task) => {
if (task.attempts > 2) {
return {
bar: "foo",
};
}
throw new Error(`Task failed on ${task.attempts} attempt(s)`);
});
throw new Error(`Task failed on ${task.attempts} attempt(s)`);
},
{ name: "task-1", retry: { limit: 3 } }
);
},
async (error) => {
// These should never be reached
@@ -390,9 +130,13 @@ client.defineJob({
);
try {
await io.runTask("task-2", { name: "task-2", retry: { limit: 5 } }, async (task) => {
throw new Error(`Task failed on ${task.attempts} attempt(s)`);
});
await io.runTask(
"task-2",
async (task) => {
throw new Error(`Task failed on ${task.attempts} attempt(s)`);
},
{ name: "task-2", retry: { limit: 5 } }
);
} catch (error) {
if (isTriggerError(error)) {
throw error;
@@ -433,57 +177,21 @@ client.defineJob({
context: ctx,
});
await io.runTask(
"level 1",
{
name: "Level 1",
},
async () => {
await io.runTask(
"level 2",
{
name: "Level 2",
},
async () => {
await io.runTask(
"level 3",
{
name: "Level 3",
},
async () => {
await io.runTask(
"level 4",
{
name: "Level 4",
},
async () => {
await io.runTask(
"level 5",
{
name: "Level 5",
},
async () => {}
);
}
);
}
);
}
);
}
);
await io.runTask("level 1", async () => {
await io.runTask("level 2", async () => {
await io.runTask("level 3", async () => {
await io.runTask("level 4", async () => {
await io.runTask("level 5", async () => {});
});
});
});
});
await io.wait("5 minutes", 5 * 60);
await io.runTask(
"Fingers crossed",
{
name: "Just a task 🤞",
},
async () => {
throw new Error("You messed up buddy!");
}
);
await io.runTask("Fingers crossed", async () => {
throw new Error("You messed up buddy!");
});
},
});
@@ -529,76 +237,6 @@ client.defineJob({
},
});
client.defineJob({
id: "register-dynamic-trigger-on-new-repo",
name: "Register dynamic trigger on new repo",
version: "0.1.1",
enabled,
trigger: eventTrigger({
name: "new.repo",
schema: z.object({ owner: z.string(), repo: z.string() }),
}),
run: async (payload, io, ctx) => {
return await io.registerTrigger("register-repo", dynamicOnIssueOpenedTrigger, payload.repo, {
owner: payload.owner,
repo: payload.repo,
});
},
});
client.defineJob({
id: "listen-for-dynamic-trigger",
name: "Listen for dynamic trigger",
version: "0.1.1",
enabled,
trigger: dynamicOnIssueOpenedTrigger,
integrations: {
slack,
},
run: async (payload, io, ctx) => {
await io.slack.postMessage("Slack 📝", {
text: `New Issue opened on dynamically triggered repo: ${
payload.issue.html_url
}. \n\n${JSON.stringify(ctx)}`,
channel: "C04GWUTDC3W",
});
},
});
client.defineJob({
id: "listen-for-dynamic-trigger-2",
name: "Listen for dynamic trigger-2",
version: "0.1.1",
enabled,
trigger: dynamicOnIssueOpenedTrigger,
integrations: {
slack,
},
run: async (payload, io, ctx) => {
await io.slack.postMessage("Slack 📝", {
text: `New Issue opened on dynamically triggered repo 2: ${payload.issue.html_url}`,
channel: "C04GWUTDC3W",
});
},
});
client.defineJob({
id: "listen-for-dynamic-trigger-3",
name: "Listen for dynamic trigger-3",
version: "0.1.1",
enabled,
trigger: dynamicOnIssueOpenedTrigger,
integrations: {
slack,
},
run: async (payload, io, ctx) => {
await io.slack.postMessage("Slack 📝", {
text: `New Issue opened on dynamically triggered repo 3: ${payload.issue.html_url}`,
channel: "C04GWUTDC3W",
});
},
});
client.defineJob({
id: "alert-on-new-github-issues-3",
name: "Alert on new GitHub issues",
@@ -613,7 +251,7 @@ client.defineJob({
repo: "basic-starter-12k",
}),
run: async (payload, io, ctx) => {
await io.runTask("slow task", { name: "slow task" }, async () => {
await io.runTask("slow task", async () => {
await new Promise((resolve) => setTimeout(resolve, 5000));
});
+21
View File
@@ -0,0 +1,21 @@
# @trigger.dev/airtable
## 2.1.0-beta.1
### Patch Changes
- Updated dependencies:
- `@trigger.dev/integration-kit@2.1.0-beta.1`
- `@trigger.dev/sdk@2.1.0-beta.1`
## 2.1.0-beta.0
### Minor Changes
- Integrations are now simpler and support authentication during webhook registration ([`878da3c0`](https://github.com/triggerdotdev/trigger.dev/commit/878da3c01f0a4dfaf33a1f8943a7ad4eed8b8877))
### Patch Changes
- Updated dependencies:
- `@trigger.dev/integration-kit@2.1.0-beta.0`
- `@trigger.dev/sdk@2.1.0-beta.0`
+3
View File
@@ -0,0 +1,3 @@
# @trigger.dev/airtable
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@trigger.dev/airtable",
"version": "2.1.0-beta.1",
"description": "Trigger.dev integration for airtable",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"publishConfig": {
"access": "public"
},
"files": [
"dist/index.js",
"dist/index.d.ts",
"dist/index.js.map"
],
"devDependencies": {
"@trigger.dev/tsconfig": "workspace:*",
"@types/node": "16.x",
"rimraf": "^3.0.2",
"tsup": "7.1.x",
"typescript": "4.9.4"
},
"scripts": {
"clean": "rimraf dist",
"build": "npm run clean && npm run build:tsup",
"build:tsup": "tsup",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trigger.dev/integration-kit": "workspace:^2.1.0-beta.1",
"@trigger.dev/sdk": "workspace:^2.1.0-beta.1",
"airtable": "^0.12.1",
"zod": "3.21.4"
},
"engines": {
"node": ">=16.8.0"
}
}
+158
View File
@@ -0,0 +1,158 @@
import { DisplayProperty, IOWithIntegrations, IntegrationTaskKey } from "@trigger.dev/sdk";
import { FieldSet, Records, SelectOptions } from "airtable";
import { AirtableFieldSet, AirtableRecord, AirtableRunTask, CreateAirtableRecord } from ".";
import { QueryParams } from "airtable/lib/query_params";
type TableParams<Params extends Record<string, unknown>> = {
tableName: string;
} & Params;
export type AirtableRecordsParams = TableParams<{}>;
export type AirtableRecords = Records<FieldSet>;
export class Base {
runTask: AirtableRunTask;
baseId: string;
constructor(runTask: AirtableRunTask, baseId: string) {
this.runTask = runTask;
this.baseId = baseId;
}
table<TFields extends AirtableFieldSet>(tableName: string) {
return new Table<TFields>(this.runTask, this.baseId, tableName);
}
}
export class Table<TFields extends AirtableFieldSet> {
runTask: AirtableRunTask;
baseId: string;
tableName: string;
constructor(runTask: AirtableRunTask, baseId: string, tableName: string) {
this.runTask = runTask;
this.baseId = baseId;
this.tableName = tableName;
}
getRecords(key: IntegrationTaskKey, params?: SelectOptions<TFields>) {
return this.runTask(
key,
async (client) => {
const result = await client
.base(this.baseId)
.table<TFields>(this.tableName)
.select(params)
.all();
return result.map((record) => toSerializableRecord<TFields>(record));
},
{
name: "Get Records",
params,
properties: [...tableParams({ baseId: this.baseId, tableName: this.tableName })],
}
);
}
getRecord(key: IntegrationTaskKey, recordId: string) {
return this.runTask(
key,
async (client) => {
const result = await client.base(this.baseId).table<TFields>(this.tableName).find(recordId);
return toSerializableRecord<TFields>(result);
},
{
name: "Get Record",
params: { recordId },
properties: [
...tableParams({ baseId: this.baseId, tableName: this.tableName }),
{ label: "Record", text: recordId },
],
}
);
}
createRecords(key: IntegrationTaskKey, records: { fields: Partial<TFields> }[]) {
return this.runTask(
key,
async (client) => {
const result = await client
.base(this.baseId)
.table<TFields>(this.tableName)
.create(records);
return result.map((record) => toSerializableRecord<TFields>(record));
},
{
name: "Create Records",
params: records,
properties: [
...tableParams({ baseId: this.baseId, tableName: this.tableName }),
{ label: "Created records", text: records.length.toString() },
],
}
);
}
updateRecords(key: IntegrationTaskKey, records: { id: string; fields: Partial<TFields> }[]) {
return this.runTask(
key,
async (client) => {
const result = await client
.base(this.baseId)
.table<TFields>(this.tableName)
.update(records);
return result.map((record) => toSerializableRecord<TFields>(record));
},
{
name: "Update Records",
params: records,
properties: [
...tableParams({ baseId: this.baseId, tableName: this.tableName }),
{ label: "Updated records", text: records.length.toString() },
],
}
);
}
deleteRecords(key: IntegrationTaskKey, recordIds: string[]) {
return this.runTask(
key,
async (client) => {
const result = await client
.base(this.baseId)
.table<TFields>(this.tableName)
.destroy(recordIds);
return result.map((record) => toSerializableRecord<TFields>(record));
},
{
name: "Delete Records",
params: { recordIds },
properties: [
...tableParams({ baseId: this.baseId, tableName: this.tableName }),
{ label: "Deleted records", text: recordIds.length.toString() },
],
}
);
}
}
function toSerializableRecord<TFields extends AirtableFieldSet>(record: AirtableRecord<TFields>) {
return {
id: record.id,
fields: record.fields,
commentCount: record.commentCount,
} as AirtableRecord<TFields>;
}
function tableParams(params: { baseId: string; tableName: string }): DisplayProperty[] {
return [
{
label: "Base",
text: params.baseId,
},
{
label: "Table",
text: params.tableName,
},
];
}
+50
View File
@@ -0,0 +1,50 @@
import { EventSpecification } from "@trigger.dev/sdk";
import { WebhookPayload } from "./schemas";
type OnTableChanged = WebhookPayload;
export const onTableChanged: EventSpecification<OnTableChanged> = {
name: "changed",
title: "On Table Changed",
source: "airtable.com",
icon: "airtable",
//todo properties with base and table (if there is one), maybe other specs too
// properties: [
//todo: add a payload example
// examples: [
// {
// id: "recurring",
// name: "Recurring Price",
// icon: "airtable",
// payload: {
// id: "price_1NYV6vI0XSgju2urKsSmI53v",
// object: "price",
// active: true,
// billing_scheme: "per_unit",
// created: 1690467853,
// currency: "usd",
// custom_unit_amount: null,
// livemode: false,
// lookup_key: null,
// metadata: {},
// nickname: null,
// product: "prod_OLBTh0QPxDXkIU",
// recurring: {
// aggregate_usage: null,
// interval: "month",
// interval_count: 1,
// trial_period_days: null,
// usage_type: "licensed",
// },
// tax_behavior: "unspecified",
// tiers_mode: null,
// transform_quantity: null,
// type: "recurring",
// unit_amount: 1500,
// unit_amount_decimal: "1500",
// },
// },
// ],
parsePayload: (payload) => payload as OnTableChanged,
runProperties: (payload) => [{ label: "Change source", text: payload.actionMetadata.source }],
};
+134
View File
@@ -0,0 +1,134 @@
import { Prettify } from "@trigger.dev/integration-kit";
import {
Json,
type ConnectionAuth,
type IO,
type IOTask,
type IntegrationTaskKey,
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";
export * from "./types";
export type AirtableIntegrationOptions = {
/** An ID for this client */
id: string;
/** Use this if you pass in a [Personal Access Token](https://airtable.com/developers/web/guides/personal-access-tokens). If omitted, it will use OAuth. */
token?: string;
};
export type AirtableRunTask = InstanceType<typeof Airtable>["runTask"];
export class Airtable implements TriggerIntegration {
private _options: AirtableIntegrationOptions;
private _client?: AirtableSDK;
private _io?: IO;
private _connectionKey?: string;
constructor(options: Prettify<AirtableIntegrationOptions>) {
if (Object.keys(options).includes("token") && !options.token) {
throw `Can't create Airtable integration (${options.id}) as token was passed in but undefined`;
}
this._options = options;
}
get authSource() {
return this._options.token ? ("LOCAL" as const) : ("HOSTED" as const);
}
get id() {
return this._options.id;
}
get metadata() {
return { id: "airtable", name: "Airtable" };
}
get source() {
return createWebhookEventSource(this);
}
cloneForRun(io: IO, connectionKey: string, auth?: ConnectionAuth) {
const airtable = new Airtable(this._options);
airtable._io = io;
airtable._connectionKey = connectionKey;
airtable._client = this.createClient(auth);
return airtable;
}
createClient(auth?: ConnectionAuth) {
if (auth) {
return new AirtableSDK({
apiKey: auth.accessToken,
});
}
if (this._options.token) {
return new AirtableSDK({
apiKey: this._options.token,
});
}
throw new Error("No auth");
}
runTask<T, TResult extends Json<T> | void>(
key: IntegrationTaskKey,
callback: (client: AirtableSDK, task: IOTask, io: IO) => Promise<TResult>,
options?: RunTaskOptions,
errorCallback?: RunTaskErrorCallback
): Promise<TResult> {
if (!this._io) throw new Error("No IO");
if (!this._connectionKey) throw new Error("No connection key");
return this._io.runTask<TResult>(
key,
(task, io) => {
if (!this._client) throw new Error("No client");
return callback(this._client, task, io);
},
{
icon: "airtable",
retry: retry.standardBackoff,
...(options ?? {}),
connectionKey: this._connectionKey,
},
errorCallback
);
}
base(baseId: string) {
return new Base(this.runTask.bind(this), baseId);
}
//todo these require batch support because they send too many events
// onTableChanges(params: {
// baseId: string;
// tableId?: string;
// changeTypes?: WebhookChangeType[];
// dataTypes?: WebhookDataType[];
// }) {
// return createTrigger(this.source, events.onTableChanged, params, {
// changeTypes: params.changeTypes,
// dataTypes: ["tableData", "tableFields", "tableMetadata"],
// });
// }
webhooks() {
return new Webhooks(this.runTask.bind(this));
}
}
+176
View File
@@ -0,0 +1,176 @@
import { z } from "zod";
const UserSourceMetadata = z.object({
user: z.object({
id: z.string(),
email: z.string(),
permissionLevel: z.union([
z.literal("none"),
z.literal("read"),
z.literal("comment"),
z.literal("edit"),
z.literal("create"),
]),
name: z.string().optional(),
profilePicUrl: z.string().optional(),
}),
});
const WebhookAction = z.discriminatedUnion("source", [
z.object({
source: z.literal("client"),
sourceMetadata: UserSourceMetadata,
}),
z.object({
source: z.literal("publicApi"),
sourceMetadata: UserSourceMetadata,
}),
z.object({
source: z.literal("formSubmission"),
sourceMetadata: z.object({
viewId: z.string(),
}),
}),
z.object({
source: z.literal("automation"),
sourceMetadata: z.object({
automationId: z.string(),
}),
}),
z.object({
source: z.literal("system"),
}),
z.object({
source: z.literal("sync"),
}),
z.object({
source: z.literal("anonymousUser"),
}),
z.object({
source: z.literal("unknown"),
}),
]);
const CreatedFieldSchema = z.object({
name: z.string(),
type: z.union([
z.literal("singleLineText"),
z.literal("email"),
z.literal("url"),
z.literal("multilineText"),
z.literal("number"),
z.literal("percent"),
z.literal("currency"),
z.literal("singleSelect"),
z.literal("multipleSelects"),
z.literal("singleCollaborator"),
z.literal("multipleCollaborators"),
z.literal("multipleRecordLinks"),
z.literal("date"),
z.literal("dateTime"),
z.literal("phoneNumber"),
z.literal("multipleAttachments"),
z.literal("checkbox"),
z.literal("formula"),
z.literal("createdTime"),
z.literal("rollup"),
z.literal("count"),
z.literal("lookup"),
z.literal("multipleLookupValues"),
z.literal("autoNumber"),
z.literal("barcode"),
z.literal("rating"),
z.literal("richText"),
z.literal("duration"),
z.literal("lastModifiedTime"),
z.literal("button"),
z.literal("createdBy"),
z.literal("lastModifiedBy"),
z.literal("externalSyncSource"),
z.literal("aiText"),
z.string(),
]),
});
const ChangedRecordFieldSchema = z.object({
cellValuesByFieldId: z.record(z.any()),
});
const CreatedRecordSchema = ChangedRecordFieldSchema.and(
z.object({
createdTime: z.string(),
})
);
const ChangedRecordSchema = z.object({
current: ChangedRecordFieldSchema,
previous: ChangedRecordFieldSchema.optional(),
unchanged: ChangedRecordFieldSchema.optional(),
});
const ChangedTableMetadata = z.object({
name: z.string().optional(),
description: z.string().nullish(),
});
const ChangedTableSchema = z.object({
changedViewsById: z
.record(
z.object({
changedRecordsById: z.record(ChangedRecordSchema).optional(),
createdRecordsById: z.record(CreatedRecordSchema).optional(),
destroyedRecordIds: z.array(z.string()).optional(),
})
)
.optional(),
changedFieldsById: z
.record(
z.object({
current: CreatedFieldSchema.partial(),
previous: CreatedFieldSchema.partial().optional(),
})
)
.optional(),
changedRecordsById: z.record(ChangedRecordSchema).optional(),
createdFieldsById: z.record(CreatedFieldSchema).optional(),
createdRecordsById: z.record(CreatedRecordSchema).optional(),
changedMetadata: z
.object({
current: ChangedTableMetadata,
previous: ChangedTableMetadata.optional(),
})
.optional(),
destroyedFieldIds: z.array(z.string()).optional(),
destroyedRecordIds: z.array(z.string()).optional(),
});
const CreatedTableSchema = z.object({
metadata: z
.object({
name: z.string(),
description: z.string().optional(),
})
.optional(),
fieldsById: z.record(CreatedFieldSchema).optional(),
recordsById: z.record(CreatedRecordSchema).optional(),
});
export const WebhookPayloadSchema = z.object({
timestamp: z.coerce.date(),
baseTransactionNumber: z.number(),
payloadFormat: z.literal("v0"),
actionMetadata: WebhookAction,
changedTablesById: z.record(ChangedTableSchema).optional(),
createdTablesById: z.record(CreatedTableSchema).optional(),
destroyedTableIds: z.array(z.string()).optional(),
});
export type WebhookPayload = z.infer<typeof WebhookPayloadSchema>;
export const ListWebhooksResponseSchema = z.object({
cursor: z.number(),
mightHaveMore: z.boolean(),
payloads: z.array(WebhookPayloadSchema),
});
export type ListWebhooksResponse = z.infer<typeof ListWebhooksResponseSchema>;
+49
View File
@@ -0,0 +1,49 @@
import { Prettify } from "@trigger.dev/integration-kit";
export type AirtableFieldSet = {
[key: string]:
| undefined
| string
| number
| boolean
| Collaborator
| Collaborator[]
| string[]
| Attachment[];
};
export type Collaborator = {
id: string;
email: string;
name: string;
};
export type Attachment = {
id: string;
url: string;
filename: string;
size: number;
type: string;
thumbnails?: {
small: Thumbnail;
large: Thumbnail;
full: Thumbnail;
};
};
export type Thumbnail = {
url: string;
width: number;
height: number;
};
export type AirtableRecord<TFields extends AirtableFieldSet> = {
id: string;
fields: TFields;
commentCount?: number;
};
export type CreateAirtableRecord<TFields extends AirtableFieldSet> = Pick<
AirtableRecord<Partial<TFields>>,
"fields"
>;
+460
View File
@@ -0,0 +1,460 @@
import {
EventFilter,
ExternalSource,
ExternalSourceTrigger,
HandlerEvent,
IntegrationTaskKey,
Logger,
} from "@trigger.dev/sdk";
import AirtableSDK from "airtable";
import { z } from "zod";
import * as events from "./events";
import { Airtable, AirtableRunTask } from "./index";
import { ListWebhooksResponse, ListWebhooksResponseSchema } from "./schemas";
const WebhookFromSourceSchema = z.union([
z.literal("formSubmission"),
z.literal("client"),
z.literal("anonymousUser"),
// we don't currently support these as they can cause feedback loops
// z.literal("publicApi"),
// z.literal("automation"),
// z.literal("system"),
// z.literal("sync"),
// z.literal("unknown"),
]);
type WebhookFromSource = z.infer<typeof WebhookFromSourceSchema>;
const WebhookDataTypeSchema = z.union([
z.literal("tableData"),
z.literal("tableFields"),
z.literal("tableMetadata"),
]);
export type WebhookDataType = z.infer<typeof WebhookDataTypeSchema>;
const WebhookChangeTypeSchema = z.union([
z.literal("add"),
z.literal("remove"),
z.literal("update"),
]);
export type WebhookChangeType = z.infer<typeof WebhookChangeTypeSchema>;
type WebhookSpecification = {
filters: {
dataTypes: WebhookDataType[];
recordChangeScope?: string;
changeTypes?: WebhookChangeType[];
fromSources?: WebhookFromSource[];
};
};
const apiUrl = "https://api.airtable.com/v0/bases";
export class Webhooks {
runTask: AirtableRunTask;
constructor(runTask: AirtableRunTask) {
this.runTask = runTask;
}
create(
key: IntegrationTaskKey,
{ baseId, url, options }: { baseId: string; url: string; options: WebhookSpecification }
): Promise<WebhookRegistrationData> {
return this.runTask(
key,
async (client, task, io) => {
// create webhook
const response = await fetch(`${apiUrl}/${baseId}/webhooks`, {
method: "POST",
headers: {
Authorization: `Bearer ${client._apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
notificationUrl: url,
specification: {
options: {
...options,
includes: {
includePreviousCellValues: true,
includePreviousFieldDefinitions: true,
},
},
},
}),
redirect: "follow",
});
if (!response.ok) {
const errorText = await response
.text()
.then((t) => t)
.catch((e) => "No body");
throw new Error(
`Failed to create webhook: ${response.status} ${response.statusText}\n${errorText}`
);
}
const webhook = await response.json();
const parsed = WebhookRegistrationDataSchema.parse(webhook);
return parsed;
},
{
name: "Create webhook",
params: {
baseId,
url,
options,
},
}
);
}
list(key: IntegrationTaskKey, { baseId }: { baseId: string }): Promise<WebhookListData> {
return this.runTask(
key,
async (client, task, io) => {
// create webhook
const response = await fetch(`${apiUrl}/${baseId}/webhooks`, {
headers: {
Authorization: `Bearer ${client._apiKey}`,
},
redirect: "follow",
});
if (!response.ok) {
throw new Error(`Failed to list webhooks: ${response.statusText}`);
}
const webhook = await response.json();
const parsed = WebhookListDataSchema.parse(webhook);
return parsed;
},
{
name: "List webhooks",
params: {
baseId,
},
}
);
}
delete(key: IntegrationTaskKey, { baseId, webhookId }: { baseId: string; webhookId: string }) {
return this.runTask(
key,
async (client, task, io) => {
// create webhook
const response = await fetch(`${apiUrl}/${baseId}/webhooks/${webhookId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${client._apiKey}`,
},
redirect: "follow",
});
if (!response.ok) {
throw new Error(`Failed to delete webhook: ${response.statusText}`);
}
},
{
name: "Delete webhook",
params: {
baseId,
webhookId,
},
}
);
}
async update(
key: IntegrationTaskKey,
{
baseId,
url,
webhookId,
options,
}: { baseId: string; url: string; webhookId: string; options: WebhookSpecification }
) {
await this.delete(`${key}-delete`, { baseId, webhookId });
return await this.create(`${key}-create`, { baseId, url, options });
}
}
type AirtableEvents = (typeof events)[keyof typeof events];
export type TriggerParams = {
baseId: string;
filter?: EventFilter;
};
type CreateTriggersResult<TEventSpecification extends AirtableEvents> = ExternalSourceTrigger<
TEventSpecification,
ReturnType<typeof createWebhookEventSource>
>;
export function createTrigger<TEventSpecification extends AirtableEvents>(
source: ReturnType<typeof createWebhookEventSource>,
event: TEventSpecification,
params: TriggerParams,
options: {
dataTypes: WebhookDataType[];
changeTypes?: WebhookChangeType[];
fromSources?: WebhookFromSource[];
}
): CreateTriggersResult<TEventSpecification> {
return new ExternalSourceTrigger({
event,
params,
source,
options,
});
}
const WebhookRegistrationDataSchema = z.object({
id: z.string(),
expirationTime: z.string(),
macSecretBase64: z.string(),
});
type WebhookRegistrationData = z.infer<typeof WebhookRegistrationDataSchema>;
const WebhookListDataSchema = z.object({
webhooks: z.array(
z.object({
id: z.string(),
notificationUrl: z.string(),
expirationTime: z.coerce.date(),
areNotificationsEnabled: z.boolean(),
isHookEnabled: z.boolean(),
})
),
});
type WebhookListData = z.infer<typeof WebhookListDataSchema>;
export function createWebhookEventSource(
integration: Airtable
): ExternalSource<
Airtable,
{ baseId: string; tableId?: string },
"HTTP",
{ dataTypes: WebhookDataType[]; fromSources?: WebhookFromSource[] }
> {
return new ExternalSource("HTTP", {
id: "airtable.webhook",
schema: z.object({ baseId: z.string(), tableId: z.string().optional() }),
optionSchema: z.object({
dataTypes: z.array(WebhookDataTypeSchema),
fromSources: z.array(WebhookFromSourceSchema).optional(),
}),
version: "0.1.0",
integration,
filter: (params, options) => ({
actionMetadata: {
source: options?.fromSources ?? ["client", "anonymousUser", "formSubmission"],
},
}),
key: (params) =>
`airtable.webhook.${params.baseId}${params.tableId ? `.${params.tableId}` : ""}`,
handler: webhookHandler,
register: async (event, io, ctx) => {
const { params, source: httpSource, options } = event;
const webhookData = WebhookRegistrationDataSchema.safeParse(httpSource.data);
const registeredOptions = {
event: options.event.desired,
dataTypes: options.dataTypes.desired,
fromSources: options.fromSources?.desired,
};
const specification: WebhookSpecification = {
filters: {
dataTypes: options.dataTypes.desired as WebhookDataType[],
changeTypes: options.event.desired as WebhookChangeType[],
fromSources: (options.fromSources?.desired ?? [
"client",
"anonymousUser",
"formSubmission",
]) as WebhookFromSource[],
recordChangeScope: params.tableId,
},
};
if (httpSource.active && webhookData.success) {
const hasMissingOptions = Object.values(options).some(
(option) => option.missing.length > 0
);
if (!hasMissingOptions) return;
const updatedWebhook = await io.integration.webhooks().update("update-webhook", {
baseId: params.baseId,
url: httpSource.url,
webhookId: webhookData.data.id,
options: specification,
});
return {
data: WebhookRegistrationDataSchema.parse(updatedWebhook),
options: registeredOptions,
};
}
const listResponse = await io.integration.webhooks().list("list-webhooks", {
baseId: params.baseId,
});
const existingWebhook = listResponse.webhooks.find(
(w) => w.notificationUrl === httpSource.url
);
if (existingWebhook) {
const updatedWebhook = await io.integration.webhooks().update("update-webhook", {
baseId: params.baseId,
url: httpSource.url,
webhookId: existingWebhook.id,
options: specification,
});
return {
data: WebhookRegistrationDataSchema.parse(updatedWebhook),
options: registeredOptions,
};
}
const webhook = await io.integration.webhooks().create("create-webhook", {
url: httpSource.url,
baseId: params.baseId,
options: specification,
});
return {
data: WebhookRegistrationDataSchema.parse(webhook),
secret: webhook.macSecretBase64,
options: registeredOptions,
};
},
});
}
/** This is the data received from Airtable. It's not useful on its own */
const ReceivedPayload = z.object({
base: z.object({
id: z.string(),
}),
webhook: z.object({
id: z.string(),
}),
timestamp: z.coerce.date(),
});
const SourceMetadataSchema = z
.object({
cursor: z.number().optional(),
})
.optional();
async function webhookHandler(event: HandlerEvent<"HTTP">, logger: Logger, integration: Airtable) {
logger.debug("[@trigger.dev/airtable] Handling webhook payload");
const client = integration.createClient(event.source.auth);
const { rawEvent: request, source } = event;
if (!request.body) {
logger.debug("[@trigger.dev/airtable] No body found");
return { events: [] };
}
const rawBody = await request.text();
const signature = request.headers.get("X-Airtable-Content-MAC");
if (!signature) {
logger.error("[@trigger.dev/airtable] Error validating webhook signature, no signature found");
throw Error("[@trigger.dev/airtable] No signature found");
}
const hmac = require("crypto").createHmac("sha256", source.secret);
hmac.update(rawBody, "ascii");
const expectedContentHmac = "hmac-sha256=" + hmac.digest("hex");
if (signature !== expectedContentHmac) {
logger.error("[@trigger.dev/airtable] Error validating webhook signature, they don't match");
}
const webhookPayload = ReceivedPayload.parse(JSON.parse(rawBody));
const parsedMetadata = SourceMetadataSchema.parse(source.metadata);
//fetch the actual payloads
const response = await getAllPayloads(
webhookPayload.base.id,
webhookPayload.webhook.id,
client,
parsedMetadata?.cursor
);
return {
events: response
? response.payloads.map((payload) => ({
id: `${payload.timestamp}-${payload.baseTransactionNumber}`,
payload: payload,
source: "airtable.com",
name: "changed",
timestamp: payload.timestamp,
context: {},
}))
: [],
metadata: response?.cursor ? { cursor: response.cursor } : undefined,
};
}
async function getAllPayloads(
baseId: string,
webhookId: string,
sdk: AirtableSDK,
cursor: number | undefined
) {
let response: ListWebhooksResponse | undefined = undefined;
let hasMore = true;
while (hasMore) {
const newResponse = await getPayload(baseId, webhookId, sdk, cursor);
cursor = newResponse.cursor;
hasMore = newResponse.mightHaveMore;
if (response) {
response.payloads.push(...newResponse.payloads);
} else {
response = newResponse;
}
}
return response;
}
async function getPayload(
baseId: string,
webhookId: string,
sdk: AirtableSDK,
cursor: number | undefined
) {
const url = new URL(`${apiUrl}/${baseId}/webhooks/${webhookId}/payloads`);
if (cursor) {
url.searchParams.append("cursor", cursor.toString());
}
const response = await fetch(url.href, {
headers: {
Authorization: `Bearer ${sdk._apiKey}`,
},
redirect: "follow",
});
if (!response.ok) {
throw new Error(`Failed to list webhooks: ${response.statusText}`);
}
const webhook = await response.json();
return ListWebhooksResponseSchema.parse(webhook);
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "@trigger.dev/tsconfig/node18.json",
"include": ["./src/**/*.ts", "tsup.config.ts"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2019"],
"paths": {
"@trigger.dev/sdk/*": ["../../packages/trigger-sdk/src/*"],
"@trigger.dev/sdk": ["../../packages/trigger-sdk/src/index"],
"@trigger.dev/integration-kit/*": ["../../packages/integration-kit/src/*"],
"@trigger.dev/integration-kit": ["../../packages/integration-kit/src/index"]
},
"declaration": false,
"declarationMap": false,
"baseUrl": "."
},
"exclude": ["node_modules"]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from "tsup";
export default defineConfig([
{
name: "main",
entry: ["./src/index.ts"],
outDir: "./dist",
platform: "node",
format: ["cjs"],
legacyOutput: true,
sourcemap: true,
clean: true,
bundle: true,
splitting: false,
dts: true,
treeshake: {
preset: "smallest",
},
esbuildPlugins: [],
external: ["http", "https", "util", "events", "tty", "os", "timers"],
},
]);
+36
View File
@@ -1,5 +1,41 @@
# @trigger.dev/github
## 2.1.0-beta.1
### Patch Changes
- Updated dependencies:
- `@trigger.dev/integration-kit@2.1.0-beta.1`
- `@trigger.dev/sdk@2.1.0-beta.1`
## 2.1.0-beta.0
### Minor Changes
- Integrations are now simpler and support authentication during webhook registration ([`878da3c0`](https://github.com/triggerdotdev/trigger.dev/commit/878da3c01f0a4dfaf33a1f8943a7ad4eed8b8877))
### Patch Changes
- Updated dependencies:
- `@trigger.dev/integration-kit@2.1.0-beta.0`
- `@trigger.dev/sdk@2.1.0-beta.0`
## 2.0.14
### Patch Changes
- Updated dependencies:
- `@trigger.dev/integration-kit@2.0.14`
- `@trigger.dev/sdk@2.0.14`
## 2.0.13
### Patch Changes
- Updated dependencies:
- `@trigger.dev/sdk@2.0.13`
- `@trigger.dev/integration-kit@2.0.13`
## 2.0.12
### Patch Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/github",
"version": "2.0.12",
"version": "2.1.0-beta.1",
"description": "The official GitHub integration for Trigger.dev",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -29,8 +29,8 @@
"@octokit/request": "^6.2.5",
"@octokit/request-error": "^4.0.1",
"@octokit/webhooks": "^10.4.0",
"@trigger.dev/sdk": "workspace:^2.0.12",
"@trigger.dev/integration-kit": "workspace:^2.0.12",
"@trigger.dev/sdk": "workspace:^2.1.0-beta.1",
"@trigger.dev/integration-kit": "workspace:^2.1.0-beta.1",
"octokit": "^2.0.14",
"zod": "3.21.4"
},
-8
View File
@@ -1,8 +0,0 @@
import { ClientFactory } from "@trigger.dev/sdk";
import { Octokit } from "octokit";
export const clientFactory: ClientFactory<Octokit> = (auth) => {
return new Octokit({
auth: auth.accessToken,
});
};
+63
View File
@@ -0,0 +1,63 @@
import { IntegrationTaskKey } from "@trigger.dev/sdk";
import { Octokit } from "octokit";
import { GitHubReturnType, GitHubRunTask, onError } from "./index";
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;
}
createIssueCommentWithReaction(
key: IntegrationTaskKey,
params: {
body: string;
owner: string;
repo: string;
issueNumber: number;
reaction: ReactionContent;
}
): GitHubReturnType<Octokit["rest"]["issues"]["createComment"]> {
return this.runTask(
key,
async () => {
const comment = await this.issues.createComment(
`Comment on Issue #${params.issueNumber}`,
params
);
const reaction = await this.reactions.createForIssueComment(
`React with ${params.reaction}`,
{
owner: params.owner,
repo: params.repo,
commentId: comment.id,
content: params.reaction,
}
);
return comment;
},
{
name: "Create Issue Comment",
params,
properties: [
{
label: "Repo",
text: params.repo,
},
{
label: "Issue",
text: `#${params.issueNumber}`,
},
],
},
onError
);
}
}
+470
View File
@@ -0,0 +1,470 @@
import { IntegrationTaskKey } from "@trigger.dev/sdk";
import { Octokit } from "octokit";
import { GitHubReturnType, GitHubRunTask, onError } from "./index";
import { repoProperties } from "./propertyHelpers";
type Endcoding = "utf-8" | "base-64";
type AuthorContent = {
name: string;
email: string;
date?: string;
};
type CommitterContent = {
name?: string;
email?: string;
date?: string;
};
type TagType = "commit" | "tree" | "blob";
type TaggerContent = {
name: string;
email: string;
date?: string;
};
type TreeType = {
path?: string | undefined;
mode?: "100644" | "100755" | "040000" | "160000" | "120000" | undefined;
type?: "commit" | "tree" | "blob" | undefined;
sha?: string | null | undefined;
content?: string | undefined;
};
export class Git {
runTask: GitHubRunTask;
constructor(runTask: GitHubRunTask) {
this.runTask = runTask;
}
createBlob(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
content: string;
encoding?: Endcoding;
}
): GitHubReturnType<Octokit["rest"]["git"]["createBlob"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.git.createBlob({
owner: params.owner,
repo: params.repo,
content: params.content,
encoding: params.encoding,
});
return result.data;
},
{
name: "Create Blob",
params,
properties: [
...repoProperties(params),
{
label: "Content",
text: params.content,
},
],
},
onError
);
}
getBlob(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
fileSHA: string;
}
): GitHubReturnType<Octokit["rest"]["git"]["getBlob"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.git.getBlob({
owner: params.owner,
repo: params.repo,
file_sha: params.fileSHA,
});
return result.data;
},
{
name: "Get Blob",
params,
properties: [...repoProperties(params)],
},
onError
);
}
createCommit(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
message: string;
tree: string;
parents?: string[];
author?: AuthorContent;
committer?: CommitterContent;
signature?: string;
}
): GitHubReturnType<Octokit["rest"]["git"]["createCommit"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.git.createCommit({
...params,
});
return result.data;
},
{
name: "Create Commit",
params,
properties: [
...repoProperties(params),
{
label: "Message",
text: params.message,
},
{
label: "Tree",
text: params.tree,
},
],
},
onError
);
}
getCommit(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
commitSHA: string;
}
): GitHubReturnType<Octokit["rest"]["git"]["getCommit"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.git.getCommit({
owner: params.owner,
repo: params.repo,
commit_sha: params.commitSHA,
});
return result.data;
},
{
name: "Get Commit",
params,
properties: [
...repoProperties(params),
{
label: "Commit SHA",
text: params.commitSHA,
},
],
},
onError
);
}
listMatchingRefs(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
ref: string;
}
): GitHubReturnType<Octokit["rest"]["git"]["listMatchingRefs"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.git.listMatchingRefs(params);
return result.data;
},
{
name: "List Matching References",
params,
properties: [
...repoProperties(params),
{
label: "Ref",
text: params.ref,
},
],
},
onError
);
}
getRef(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
ref: string;
}
): GitHubReturnType<Octokit["rest"]["git"]["getRef"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.git.getRef(params);
return result.data;
},
{
name: "Get Reference",
params,
properties: [
...repoProperties(params),
{
label: "Ref",
text: params.ref,
},
],
},
onError
);
}
createRef(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
ref: string;
sha: string;
}
): GitHubReturnType<Octokit["rest"]["git"]["createRef"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.git.createRef(params);
return result.data;
},
{
name: "Create Reference",
params,
properties: [
...repoProperties(params),
{
label: "Ref",
text: params.ref,
},
{
label: "SHA",
text: params.ref,
},
],
},
onError
);
}
updateRef(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
ref: string;
sha: string;
force?: boolean;
}
): GitHubReturnType<Octokit["rest"]["git"]["updateRef"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.git.updateRef(params);
return result.data;
},
{
name: "Update Reference",
params,
properties: [
...repoProperties(params),
{
label: "Ref",
text: params.ref,
},
{
label: "SHA",
text: params.ref,
},
],
},
onError
);
}
deleteRef(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
ref: string;
}
): GitHubReturnType<Octokit["rest"]["git"]["deleteRef"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.git.deleteRef(params);
return result.data;
},
{
name: "Delete Reference",
params,
properties: [
...repoProperties(params),
{
label: "Ref",
text: params.ref,
},
],
},
onError
);
}
createTag(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
tag: string;
message: string;
object: string;
type: TagType;
tagger?: TaggerContent;
}
): GitHubReturnType<Octokit["rest"]["git"]["createTag"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.git.createTag(params);
return result.data;
},
{
name: "Create Tag",
params,
properties: [
...repoProperties(params),
{
label: "Tag",
text: params.tag,
},
{
label: "Message",
text: params.message,
},
{
label: "Object",
text: params.object,
},
{
label: "Tag Type",
text: params.type,
},
],
},
onError
);
}
getTag(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
tagSHA: string;
}
): GitHubReturnType<Octokit["rest"]["git"]["getTag"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.git.getTag({
owner: params.owner,
repo: params.repo,
tag_sha: params.tagSHA,
});
return result.data;
},
{
name: "Get Tag",
params,
properties: [
...repoProperties(params),
{
label: "Tag SHA",
text: params.tagSHA,
},
],
},
onError
);
}
createTree(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
tree: TreeType[];
baseTree?: string;
}
): GitHubReturnType<Octokit["rest"]["git"]["createTree"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.git.createTree(params);
return result.data;
},
{
name: "Create Tree",
params,
properties: [...repoProperties(params)],
},
onError
);
}
getTree(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
treeSHA: string;
recursive?: string;
}
): GitHubReturnType<Octokit["rest"]["git"]["getTree"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.git.getTree({
owner: params.owner,
repo: params.repo,
tree_sha: params.treeSHA,
recursive: params.recursive,
});
return result.data;
},
{
name: "Get Tree",
params,
properties: [
...repoProperties(params),
{
label: "Tree SHA",
text: params.treeSHA,
},
],
},
onError
);
}
}
+163 -28
View File
@@ -1,3 +1,4 @@
import { RequestError } from "@octokit/request-error";
import { RequestRequestOptions } from "@octokit/types";
import {
CreateEvent,
@@ -12,15 +13,22 @@ import {
StarCreatedEvent,
StarEvent,
} from "@octokit/webhooks-types";
import { truncate } from "@trigger.dev/integration-kit";
import {
ConnectionAuth,
EventSpecification,
ExternalSourceTrigger,
IntegrationClient,
IO,
IOTask,
IntegrationTaskKey,
Json,
RunTaskErrorCallback,
RunTaskOptions,
TriggerIntegration,
retry,
} from "@trigger.dev/sdk";
import { Octokit } from "octokit";
import { createOrgEventSource, createRepoEventSource } from "./sources";
import { tasks } from "./tasks";
import {
issueAssigned,
issueCommentCreated,
@@ -31,7 +39,12 @@ import {
push,
starredRepo,
} from "./webhook-examples";
import { truncate } from "@trigger.dev/integration-kit";
import { Issues } from "./issues";
import { Repos } from "./repos";
import { Reactions } from "./reactions";
import { Compound } from "./compound";
import { Orgs } from "./orgs";
import { Git } from "./git";
export type GithubIntegrationOptions = {
id: string;
@@ -49,15 +62,29 @@ type GithubTriggers = {
org: ReturnType<typeof createOrgTrigger>;
};
export class Github implements TriggerIntegration<IntegrationClient<Octokit, typeof tasks>> {
client: IntegrationClient<Octokit, typeof tasks>;
export type GitHubRunTask = InstanceType<typeof Github>["runTask"];
export type GitHubReturnType<T extends (params: any) => Promise<{ data: K }>, K = any> = Promise<
Awaited<ReturnType<T>>["data"]
>;
export class Github implements TriggerIntegration {
private _options: GithubIntegrationOptions;
private _client?: Octokit;
private _io?: IO;
private _connectionKey?: string;
_repoSource: ReturnType<typeof createRepoEventSource>;
_orgSource: ReturnType<typeof createOrgEventSource>;
_repoTrigger: ReturnType<typeof createRepoTrigger>;
_orgTrigger: ReturnType<typeof createOrgTrigger>;
constructor(private options: GithubIntegrationOptions) {
this.client = createClientFromOptions(options);
if (Object.keys(options).includes("token") && !options.token) {
throw `Can't create GitHub integration (${options.id}) as token was undefined`;
}
this._options = options;
this._repoSource = createRepoEventSource(this);
this._orgSource = createOrgEventSource(this);
this._repoTrigger = createRepoTrigger(this._repoSource);
@@ -72,6 +99,18 @@ export class Github implements TriggerIntegration<IntegrationClient<Octokit, typ
return { name: "GitHub", id: "github" };
}
get authSource() {
return this._options.token ? ("LOCAL" as const) : ("HOSTED" as const);
}
cloneForRun(io: IO, connectionKey: string, auth?: ConnectionAuth) {
const github = new Github(this._options);
github._io = io;
github._connectionKey = connectionKey;
github._client = createClientFromOptions(this._options, auth);
return github;
}
get sources(): GithubSources {
return {
repo: this._repoSource,
@@ -85,45 +124,114 @@ export class Github implements TriggerIntegration<IntegrationClient<Octokit, typ
org: this._orgTrigger,
};
}
runTask<T, TResult extends Json<T> | void>(
key: IntegrationTaskKey,
callback: (client: Octokit, task: IOTask, io: IO) => Promise<TResult>,
options?: RunTaskOptions,
errorCallback?: RunTaskErrorCallback
): Promise<TResult> {
if (!this._io) throw new Error("No IO");
if (!this._connectionKey) throw new Error("No connection key");
return this._io.runTask<TResult>(
key,
(task, io) => {
if (!this._client) throw new Error("No client");
return callback(this._client, task, io);
},
{
icon: "github",
retry: retry.standardBackoff,
...(options ?? {}),
connectionKey: this._connectionKey,
},
errorCallback
);
}
get issues() {
return new Issues(this.runTask.bind(this));
}
get repos() {
return new Repos(this.runTask.bind(this));
}
get reactions() {
return new Reactions(this.runTask.bind(this));
}
get compound() {
return new Compound(this.runTask.bind(this), this.issues, this.reactions);
}
get orgs() {
return new Orgs(this.runTask.bind(this));
}
get git() {
return new Git(this.runTask.bind(this));
}
createIssue = this.issues.create;
addIssueAssignees = this.issues.addAssignees;
addIssueLabels = this.issues.addLabels;
createIssueComment = this.issues.createComment;
getIssue = this.issues.get;
getRepo = this.repos.get;
updateWebhook = this.repos.updateWebhook;
createWebhook = this.repos.createWebhook;
listWebhooks = this.repos.listWebhooks;
addIssueCommentReaction = this.reactions.createForIssueComment;
createIssueCommentWithReaction = this.compound.createIssueCommentWithReaction;
updateOrgWebhook = this.orgs.updateWebhook;
createOrgWebhook = this.orgs.createWebhook;
listOrgWebhooks = this.orgs.listWebhooks;
createBlob = this.git.createBlob;
getBlob = this.git.getBlob;
createCommit = this.git.createCommit;
getCommit = this.git.getCommit;
listMatchingReferences = this.git.listMatchingRefs;
getReference = this.git.getRef;
createReference = this.git.createRef;
updateReference = this.git.updateRef;
deleteReference = this.git.deleteRef;
createTag = this.git.createTag;
getTag = this.git.getTag;
createTree = this.git.createTree;
getTree = this.git.getTree;
}
function createClientFromOptions(
options: GithubIntegrationOptions
): IntegrationClient<Octokit, typeof tasks> {
options: GithubIntegrationOptions,
auth?: ConnectionAuth
): Octokit {
if (Object.keys(options).includes("token") && !options.token) {
throw `Can't create GitHub integration (${options.id}) as token was undefined`;
}
if (options.token) {
const client = new Octokit({
return new Octokit({
auth: options.token,
request: options.octokitRequest,
retry: {
enabled: false,
},
});
return {
usesLocalAuth: true,
client,
tasks,
auth: options.token,
};
}
return {
usesLocalAuth: false,
clientFactory: (auth) => {
return new Octokit({
auth: auth.accessToken,
request: options.octokitRequest,
retry: {
enabled: false,
},
});
if (!auth) {
throw new Error("No auth");
}
return new Octokit({
auth: auth.accessToken,
request: options.octokitRequest,
retry: {
enabled: false,
},
tasks,
};
});
}
const onIssue: EventSpecification<IssuesEvent> = {
@@ -461,6 +569,7 @@ function createRepoTrigger(
event,
params: { owner, repo },
source,
options: {},
});
};
}
@@ -484,6 +593,32 @@ function createOrgTrigger(
event,
params: { org },
source,
options: {},
});
};
}
export function isRequestError(error: unknown): error is RequestError {
return typeof error === "object" && error !== null && "status" in error;
}
export function onError(error: unknown) {
if (!isRequestError(error)) {
return;
}
// Check if this is a rate limit error
if (error.status === 403 && error.response) {
const rateLimitRemaining = error.response.headers["x-ratelimit-remaining"];
const rateLimitReset = error.response.headers["x-ratelimit-reset"];
if (rateLimitRemaining === "0" && rateLimitReset) {
const resetDate = new Date(Number(rateLimitReset) * 1000);
return {
retryAt: resetDate,
error,
};
}
}
}
+152
View File
@@ -0,0 +1,152 @@
import { truncate } from "@trigger.dev/integration-kit";
import { IntegrationTaskKey, Prettify, retry } from "@trigger.dev/sdk";
import { GitHubReturnType, GitHubRunTask, onError } from "./index";
import { Octokit } from "octokit";
import { issueProperties, repoProperties } from "./propertyHelpers";
type AddIssueLabels = GitHubReturnType<Octokit["rest"]["issues"]["addLabels"]>;
export class Issues {
runTask: GitHubRunTask;
constructor(runTask: GitHubRunTask) {
this.runTask = runTask;
}
create(
key: IntegrationTaskKey,
params: { title: string; owner: string; repo: string }
): GitHubReturnType<Octokit["rest"]["issues"]["create"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.issues.create({
owner: params.owner,
repo: params.repo,
title: params.title,
});
return result.data;
},
{
name: "Create Issue",
params,
properties: [
...repoProperties(params),
{
label: "Title",
text: params.title,
},
],
},
onError
);
}
addAssignees(
key: IntegrationTaskKey,
params: { owner: string; repo: string; issueNumber: number; assignees: string[] }
): GitHubReturnType<Octokit["rest"]["issues"]["addAssignees"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.issues.addAssignees({
owner: params.owner,
repo: params.repo,
issue_number: params.issueNumber,
assignees: params.assignees,
});
return result.data;
},
{
name: "Add Issue Assignees",
params,
properties: [
...repoProperties(params),
...issueProperties(params),
{
label: "assignees",
text: params.assignees.join(", "),
},
],
},
onError
);
}
addLabels(
key: IntegrationTaskKey,
params: { owner: string; repo: string; issueNumber: number; labels: string[] }
): AddIssueLabels {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.issues.addLabels({
owner: params.owner,
repo: params.repo,
issue_number: params.issueNumber,
labels: params.labels,
});
return result.data;
},
{
name: "Add Issue Labels",
params,
properties: [
...repoProperties(params),
...issueProperties(params),
{
label: "Labels",
text: params.labels.join(", "),
},
],
},
onError
);
}
createComment(
key: IntegrationTaskKey,
params: { body: string; owner: string; repo: string; issueNumber: number }
): GitHubReturnType<Octokit["rest"]["issues"]["createComment"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.issues.createComment({
owner: params.owner,
repo: params.repo,
body: params.body,
issue_number: params.issueNumber,
});
return result.data;
},
{
name: "Create Issue Comment",
params,
properties: [...repoProperties(params), ...issueProperties(params)],
},
onError
);
}
get(
key: IntegrationTaskKey,
params: { owner: string; repo: string; issueNumber: number }
): GitHubReturnType<Octokit["rest"]["issues"]["get"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.issues.get({
owner: params.owner,
repo: params.repo,
issue_number: params.issueNumber,
});
return result.data;
},
{
name: "Get Issue",
params,
properties: [...repoProperties(params), ...issueProperties(params)],
},
onError
);
}
}
+124
View File
@@ -0,0 +1,124 @@
import { IntegrationTaskKey } from "@trigger.dev/sdk";
import { Octokit } from "octokit";
import { GitHubReturnType, GitHubRunTask, onError } from "./index";
export class Orgs {
runTask: GitHubRunTask;
constructor(runTask: GitHubRunTask) {
this.runTask = runTask;
}
updateWebhook(
key: IntegrationTaskKey,
params: {
org: string;
hookId: number;
url: string;
secret: string;
addEvents?: string[];
}
): GitHubReturnType<Octokit["rest"]["orgs"]["updateWebhook"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.orgs.updateWebhook({
org: params.org,
hook_id: params.hookId,
config: {
content_type: "json",
url: params.url,
secret: params.secret,
},
add_events: params.addEvents,
});
return result.data;
},
{
name: "Update Org Webhook",
params,
properties: [
{
label: "Org",
text: params.org,
},
{
label: "Hook ID",
text: String(params.hookId),
},
],
},
onError
);
}
createWebhook(
key: IntegrationTaskKey,
params: {
org: string;
url: string;
secret: string;
events: string[];
}
): GitHubReturnType<Octokit["rest"]["orgs"]["createWebhook"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.orgs.createWebhook({
org: params.org,
name: "web",
config: {
content_type: "json",
url: params.url,
secret: params.secret,
},
events: params.events,
});
return result.data;
},
{
name: "Create Org Webhook",
params,
properties: [
{
label: "Org",
text: params.org,
},
{
label: "Events",
text: params.events.join(", "),
},
],
},
onError
);
}
listWebhooks(
key: IntegrationTaskKey,
params: {
org: string;
}
): GitHubReturnType<Octokit["rest"]["orgs"]["listWebhooks"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.orgs.listWebhooks({
org: params.org,
});
return result.data;
},
{
name: "List Org Webhooks",
params,
properties: [
{
label: "Org",
text: params.org,
},
],
},
onError
);
}
}
+91
View File
@@ -0,0 +1,91 @@
import { truncate } from "@trigger.dev/integration-kit";
import { IntegrationTaskKey, Prettify, retry } from "@trigger.dev/sdk";
import { GitHubReturnType, GitHubRunTask, onError } from "./index";
import { Octokit } from "octokit";
import { issueProperties, repoProperties } from "./propertyHelpers";
export type ReactionContent =
| "+1"
| "-1"
| "laugh"
| "confused"
| "heart"
| "hooray"
| "rocket"
| "eyes";
export class Reactions {
runTask: GitHubRunTask;
constructor(runTask: GitHubRunTask) {
this.runTask = runTask;
}
createForIssueComment(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
commentId: number;
content: ReactionContent;
}
): GitHubReturnType<Octokit["rest"]["reactions"]["createForIssueComment"]> {
let emoji = "";
switch (params.content) {
case "+1":
emoji = "👍";
break;
case "-1":
emoji = "👎";
break;
case "laugh":
emoji = "😄";
break;
case "confused":
emoji = "😕";
break;
case "heart":
emoji = "❤️";
break;
case "hooray":
emoji = "🎉";
break;
case "rocket":
emoji = "🚀";
break;
case "eyes":
emoji = "👀";
break;
}
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.reactions.createForIssueComment({
owner: params.owner,
repo: params.repo,
comment_id: params.commentId,
content: params.content,
});
return result.data;
},
{
name: "Add Issue Reaction",
params,
properties: [
{
label: "Repo",
text: params.repo,
},
{
label: "Comment",
text: `#${params.commentId}`,
},
{ label: "reaction", text: emoji },
],
},
onError
);
}
}
+173
View File
@@ -0,0 +1,173 @@
import { truncate } from "@trigger.dev/integration-kit";
import { IntegrationTaskKey, Prettify, retry } from "@trigger.dev/sdk";
import { GitHubReturnType, GitHubRunTask, onError } from "./index";
import { Octokit } from "octokit";
import { issueProperties, repoProperties } from "./propertyHelpers";
export class Repos {
runTask: GitHubRunTask;
constructor(runTask: GitHubRunTask) {
this.runTask = runTask;
}
get(
key: IntegrationTaskKey,
params: { owner: string; repo: string }
): GitHubReturnType<Octokit["rest"]["repos"]["get"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.repos.get({
owner: params.owner,
repo: params.repo,
headers: {
"x-trigger-attempt": String(task.attempts),
},
});
return result.data;
},
{
name: "Get Repo",
params,
properties: [
{
label: "Repo",
text: params.repo,
},
],
},
onError
);
}
updateWebhook(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
hookId: number;
url: string;
secret: string;
addEvents?: string[];
}
): GitHubReturnType<Octokit["rest"]["repos"]["updateWebhook"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.repos.updateWebhook({
owner: params.owner,
repo: params.repo,
hook_id: params.hookId,
config: {
content_type: "json",
url: params.url,
secret: params.secret,
},
add_events: params.addEvents,
});
return result.data;
},
{
name: "Update Webhook",
params,
properties: [
{
label: "Owner",
text: params.owner,
},
{
label: "Repo",
text: params.repo,
},
{
label: "Hook ID",
text: String(params.hookId),
},
],
},
onError
);
}
createWebhook(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
url: string;
secret: string;
events: string[];
}
): GitHubReturnType<Octokit["rest"]["repos"]["createWebhook"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.repos.createWebhook({
owner: params.owner,
repo: params.repo,
config: {
content_type: "json",
url: params.url,
secret: params.secret,
},
events: params.events,
});
return result.data;
},
{
name: "Create Webhook",
params,
properties: [
{
label: "Owner",
text: params.owner,
},
{
label: "Repo",
text: params.repo,
},
{
label: "Events",
text: params.events.join(", "),
},
],
},
onError
);
}
listWebhooks(
key: IntegrationTaskKey,
params: {
owner: string;
repo: string;
}
): GitHubReturnType<Octokit["rest"]["repos"]["listWebhooks"]> {
return this.runTask(
key,
async (client, task) => {
const result = await client.rest.repos.listWebhooks({
owner: params.owner,
repo: params.repo,
});
return result.data;
},
{
name: "List Webhooks",
params,
properties: [
{
label: "Owner",
text: params.owner,
},
{
label: "Repo",
text: params.repo,
},
],
},
onError
);
}
}
+45 -47
View File
@@ -1,15 +1,10 @@
import { Webhooks } from "@octokit/webhooks";
import {
IntegrationClient,
ExternalSource,
TriggerIntegration,
HandlerEvent,
} from "@trigger.dev/sdk";
import { ExternalSource, TriggerIntegration, HandlerEvent } from "@trigger.dev/sdk";
import type { Logger } from "@trigger.dev/sdk";
import { safeJsonParse, omit } from "@trigger.dev/integration-kit";
import { Octokit } from "octokit";
import { z } from "zod";
import { tasks } from "./tasks";
import { Github } from "./index";
type WebhookData = {
id: number;
@@ -30,12 +25,8 @@ function webhookData(data: any): data is WebhookData {
}
export function createRepoEventSource(
integration: TriggerIntegration<IntegrationClient<Octokit, typeof tasks>>
): ExternalSource<
TriggerIntegration<IntegrationClient<Octokit, typeof tasks>>,
{ owner: string; repo: string },
"HTTP"
> {
integration: Github
): ExternalSource<Github, { owner: string; repo: string }, "HTTP", {}> {
return new ExternalSource("HTTP", {
id: "github.repo",
version: "0.1.1",
@@ -61,27 +52,32 @@ export function createRepoEventSource(
}),
handler: webhookHandler,
register: async (event, io, ctx) => {
const { params, source: httpSource, events, missingEvents } = event;
const { params, source: httpSource, options } = event;
const registeredOptions = {
event: options.event.desired,
};
if (httpSource.active && webhookData(httpSource.data)) {
if (missingEvents.length > 0) {
// We need to update the webhook to add the new events and then return
const newWebhookData = await io.integration.updateWebhook("update-webhook", {
owner: params.owner,
repo: params.repo,
hookId: httpSource.data.id,
url: httpSource.url,
secret: httpSource.secret,
addEvents: missingEvents,
});
const hasMissingOptions = Object.values(options).some(
(option) => option.missing.length > 0
);
if (!hasMissingOptions) return;
return {
data: newWebhookData,
registeredEvents: newWebhookData.events,
};
}
// We need to update the webhook to add the new events and then return
const newWebhookData = await io.integration.updateWebhook("update-webhook", {
owner: params.owner,
repo: params.repo,
hookId: httpSource.data.id,
url: httpSource.url,
secret: httpSource.secret,
addEvents: options.event.missing,
});
return;
return {
data: newWebhookData,
options: registeredOptions,
};
}
const webhooks = await io.integration.listWebhooks("list-webhooks", {
@@ -99,35 +95,31 @@ export function createRepoEventSource(
hookId: existingWebhook.id,
url: httpSource.url,
secret: httpSource.secret,
addEvents: missingEvents,
addEvents: options.event.missing,
});
return {
data: updatedWebhook,
registeredEvents: updatedWebhook.events,
options: registeredOptions,
};
}
const webhook = await io.integration.createWebhook("create-webhook", {
owner: params.owner,
repo: params.repo,
events,
events: options.event.desired,
url: httpSource.url,
secret: httpSource.secret,
});
return { data: webhook, registeredEvents: webhook.events };
return { data: webhook, options: registeredOptions };
},
});
}
export function createOrgEventSource(
integration: TriggerIntegration<IntegrationClient<Octokit, typeof tasks>>
): ExternalSource<
TriggerIntegration<IntegrationClient<Octokit, typeof tasks>>,
{ org: string },
"HTTP"
> {
integration: Github
): ExternalSource<Github, { org: string }, "HTTP", {}> {
return new ExternalSource("HTTP", {
id: "github.org",
version: "0.1.1",
@@ -148,13 +140,19 @@ export function createOrgEventSource(
}),
handler: webhookHandler,
register: async (event, io, ctx) => {
const { params, source: httpSource, events, missingEvents } = event;
const { params, source: httpSource, options } = event;
const registeredOptions = {
event: options.event.desired,
};
const hasMissingOptions = Object.values(options).some((option) => option.missing.length > 0);
if (
httpSource.active &&
webhookData(httpSource.data) &&
httpSource.secret &&
missingEvents.length > 0
hasMissingOptions
) {
const existingData = httpSource.data;
@@ -164,13 +162,13 @@ export function createOrgEventSource(
hookId: existingData.id,
url: httpSource.url,
secret: httpSource.secret,
addEvents: missingEvents,
addEvents: options.event.missing,
});
return {
secret: httpSource.secret,
data: newWebhookData,
registeredEvents: newWebhookData.events,
options: registeredOptions,
};
}
@@ -193,18 +191,18 @@ export function createOrgEventSource(
return {
secret,
data: updatedWebhook,
registeredEvents: updatedWebhook.events,
options: registeredOptions,
};
}
const webhook = await io.integration.createOrgWebhook("create-webhook", {
org: params.org,
events,
events: options.event.desired,
url: httpSource.url,
secret,
});
return { secret, data: webhook, registeredEvents: webhook.events };
return { secret, data: webhook, options: registeredOptions };
},
});
}
File diff suppressed because it is too large Load Diff
+36
View File
@@ -1,5 +1,41 @@
# @trigger.dev/slack
## 2.1.0-beta.1
### Patch Changes
- Updated dependencies:
- `@trigger.dev/integration-kit@2.1.0-beta.1`
- `@trigger.dev/sdk@2.1.0-beta.1`
## 2.1.0-beta.0
### Minor Changes
- Integrations are now simpler and support authentication during webhook registration ([`878da3c0`](https://github.com/triggerdotdev/trigger.dev/commit/878da3c01f0a4dfaf33a1f8943a7ad4eed8b8877))
### Patch Changes
- Updated dependencies:
- `@trigger.dev/integration-kit@2.1.0-beta.0`
- `@trigger.dev/sdk@2.1.0-beta.0`
## 2.0.14
### Patch Changes
- Updated dependencies:
- `@trigger.dev/integration-kit@2.0.14`
- `@trigger.dev/sdk@2.0.14`
## 2.0.13
### Patch Changes
- Updated dependencies:
- `@trigger.dev/sdk@2.0.13`
- `@trigger.dev/integration-kit@2.0.13`
## 2.0.12
### Patch Changes

Some files were not shown because too many files have changed in this diff Show More