diff --git a/.changeset/nine-planets-raise.md b/.changeset/nine-planets-raise.md new file mode 100644 index 00000000000..e708e3c9071 --- /dev/null +++ b/.changeset/nine-planets-raise.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/integrations": patch +"@trigger.dev/sdk": patch +--- + +Added support for webhookEvent trigger diff --git a/.changeset/quick-hairs-punch.md b/.changeset/quick-hairs-punch.md new file mode 100644 index 00000000000..0781a2a38ee --- /dev/null +++ b/.changeset/quick-hairs-punch.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Added retry options to fetch diff --git a/apps/docs/examples/examples.mdx b/apps/docs/examples/examples.mdx index bf39b257665..c8196f5e109 100644 --- a/apps/docs/examples/examples.mdx +++ b/apps/docs/examples/examples.mdx @@ -4,11 +4,6 @@ sidebarTitle: "Overview" description: "Example workflows to use in your projects." --- - - Trigger.dev is in private beta. We are now inviting users from our waitlist - [here.](https://bcymafitv0e.typeform.com/tddsignup?typeform-source=trigger.dev#source=Docs) - - Not sure what to build? Take a look through our examples for inspiration. You can use these workflows in your product by following the instructions on each page. diff --git a/apps/docs/functions/fetch.mdx b/apps/docs/functions/fetch.mdx index e1c4da4856f..855d37af5c8 100644 --- a/apps/docs/functions/fetch.mdx +++ b/apps/docs/functions/fetch.mdx @@ -6,7 +6,7 @@ description: "A generic fetch function that can be used to call any HTTP endpoin ## Usage -A `fetch` function is available to use inside a `Trigger.run` function through the `context` argument, and should be familiar for anyone who has used the standard [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) API, with a few modifications. +A `fetch` function is available to use inside a `Trigger.run` function through the `context` argument, and should be familiar for anyone who has used the standard [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) API, with a few modifications. ```ts import { Trigger } from "@trigger.dev/sdk"; @@ -62,6 +62,11 @@ This is useful if you want to wrap `fetch` to provide an SDK like experience ins ## Response + + Non-ok responses will currently halt the progress of a run, so `response.ok` + is always `true` + + The return value of `fetch` is a similar to a normal fetch response, but we will automatically parse the response body as JSON and provide it as `body`, like so: ```ts @@ -70,7 +75,7 @@ const response = await ctx.fetch("Example Key", "http://httpbin.org/get"); console.log(response.body.url); // http://httpbin.org/get ``` -By default, the `body` is typed as `any`, but you can provide a [`Zod`](/guides/zod) schema to validate the response body and get a more specific type: +By default, the `body` is typed as `any`, but you can provide a [Zod](/guides/zod) schema to validate the response body and get a more specific type: ```ts import { z } from "zod"; @@ -154,6 +159,40 @@ Which will show in the trigger.dev app as: ![title](/images/secure-string.png) +## Retrying + +By default, we will retry a failed request up to 10 times if it has one of the following status codes: `408`, `429`, `500`, `502`, `503`, `504`. You can override this behavior by providing a `retry` option: + +```ts +await ctx.fetch("Example Key", "http://httpbin.org/get", { + retry: { + maxAttempts: 5, + statusCodes: [408, 429, 500, 502, 503, 504, 521, 522, 524], + minTimeout: 1000, + maxTimeout: 10000, + factor: 1.2, + }, +}); +``` + +| Property | Description | Default | +| ----------- | ---------------------------------------- | ---------------------------------------- | +| enabled | Enables retrying of failed requests | true | +| factor | The exponential factor of backoff | 1.8 | +| minTimeout | The minimum amount of ms between retries | 1000 | +| maxTimeout | The maximum amount of ms between retries | 60000 | +| statusCodes | The HTTP Status Codes that are retryable | `408`, `429`, `500`, `502`, `503`, `504` | + +If you'd like to disable retrying, simple pass in the `retry` option with `enabled` set to `false`: + +```ts +await ctx.fetch("Example Key", "http://httpbin.org/get", { + retry: { + enabled: false, + }, +}); +``` + ## Params diff --git a/apps/docs/getting-started.mdx b/apps/docs/getting-started.mdx index 42d05273fcd..83f9bc61c5c 100644 --- a/apps/docs/getting-started.mdx +++ b/apps/docs/getting-started.mdx @@ -4,11 +4,6 @@ sidebarTitle: "Quick start" description: "Get your first workflow running in just a few minutes" --- - - Trigger.dev is in private beta. We are now inviting users from our waitlist - [here.](https://bcymafitv0e.typeform.com/tddsignup?typeform-source=trigger.dev#source=Docs) - - Trigger.dev workflows are written in your codebase and run in your existing infrastructure. This means: - They are version controlled with the rest of your code. diff --git a/apps/docs/guides/event-driven.mdx b/apps/docs/guides/event-driven.mdx index 4392d4a818e..3042a6b11ae 100644 --- a/apps/docs/guides/event-driven.mdx +++ b/apps/docs/guides/event-driven.mdx @@ -4,6 +4,61 @@ sidebarTitle: "Event Driven" description: "How to build an event driven architecture using Trigger.dev" --- -## Intro +## Event-driven Patterns in Trigger.dev -Coming soon... +### Claim check pattern + +Inspired by [this post](https://serverlessland.com/event-driven-architecture/visuals/claim-check-pattern) by [@boyney123](https://twitter.com/boyney123), here is how you could implement the claim check pattern using Trigger.dev: + +```ts +import { Trigger } from "@trigger.dev/sdk"; +import { github } from "@trigger.dev/integrations"; +import { db } from "./db.server"; + +new Trigger({ + id: "issue-producer", + on: github.events.issueEvent({ + repo: "triggerdotdev/trigger.dev", + }), + run: async (event, ctx) => { + // 1. Store the event in a database (e.g. MongoDB, DynamoDB, etc.) + const id = await db.insert(ctx.id, event); + + // 2. Send a new event with the id of the stored event + await ctx.sendEvent("📧 Send Event", { + name: "github.issue", + payload: { + id, + action: event.payload.action, + }, + }); + }, +}).listen(); +``` + +The above workflow will store any issue events from the trigger.dev repo in a database. Then, it will send a new event with the name `github.issue` and the id and action of the stored event. This event can then trigger another workflow and be used to retrieve the original event from the database: + +```ts +import { Trigger, customEvent } from "@trigger.dev/sdk"; +import { db } from "./db.server"; +import { z } from "zod"; + +new Trigger({ + id: "opened-issue-consumer", + on: customEvent({ + name: "github.issue", + filter: { + action: ["opened"], + } + schema: z.object({ id: z.string() }), + }), + run: async (event, ctx) => { + // 3. Retrieve the event from the database + const event = await db.get(ctx.id); + + // 4. Do something with the event + }, +}).listen(); +``` + +As you can see above we filtered the event to only trigger when the action is `opened`. This is because we only want to do something when an issue is opened. We also used a schema to validate the event payload (which in this pattern is pretty simple). diff --git a/apps/docs/guides/event-filters.mdx b/apps/docs/guides/event-filters.mdx new file mode 100644 index 00000000000..834f8c0596b --- /dev/null +++ b/apps/docs/guides/event-filters.mdx @@ -0,0 +1,53 @@ +--- +title: "Event Filters" +sidebarTitle: "Event Filters" +description: "Event filters are used to filter events based on their attributes." +--- + +Event filters are used to filter events based on their attributes in the [customEvent](/reference/custom-event) and [webhookEvent](/reference/webhook-event) triggers. + +They are declarative pattern-matching rules, modeled after [AWS EventBridge patterns](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html). + +Given the following custom event payload: + +```json +{ +{ + "uid": "jexAgaeJFJsrGfans1pxqm", + "type": "15 Min Meeting", + "price": 0, + "title": "15 Min Meeting between Eric Allam and John Doe", + "length": 15, + "status": "ACCEPTED", + "endTime": "2023-01-25T16:00:00Z", + "bookingId": 198052, + "organizer": { + "id": 32794, + "name": "Eric Allam", + "email": "eric@trigger.dev", + "language": { "locale": "en" }, + "timeZone": "Europe/London" + }, + } +} +``` + +The following event filter would match the event: + +```json +{ + "type": ["15 Min Meeting"], + "status": ["ACCEPTED", "REJECTED"], + "organizer": { + "name": ["Eric Allam"] + } +} +``` + +For an event pattern to match an event, the event must contain all the field names listed in the event pattern. The field names must also appear in the event with the same nesting structure. + +The value of each field name in the event pattern must be an array of strings, numbers, or booleans. The event pattern matches the event if the value of the field name in the event is equal to any of the values in the array. + +Effectively, each array is an OR condition, and the entire event pattern is an AND condition. + +So the above event filter will match because `status == "ACCEPTED"`, and it would also match if `status == "REJECTED"`. diff --git a/apps/docs/guides/zod.mdx b/apps/docs/guides/zod.mdx index 646d3681402..d52d175f0ee 100644 --- a/apps/docs/guides/zod.mdx +++ b/apps/docs/guides/zod.mdx @@ -304,3 +304,9 @@ jsonSchema.parse(data); ``` Hat tip to [ggoodman](https://github.com/ggoodman) for this one. + +## Resources + +Matt Pocock [@mattpocock](https://twitter.com/mattpocockuk) has a great free Zod Tutorial up on [Total Typescript](https://www.totaltypescript.com/tutorials/zod) that covers the basics of Zod. + +Easily generate Zod schemas from [JSON](https://transform.tools/json-to-zod), [JSON Schemas](https://transform.tools/json-schema-to-zod), or [TypeScript](https://transform.tools/typescript-to-zod) types. diff --git a/apps/docs/images/webhook-url.png b/apps/docs/images/webhook-url.png new file mode 100644 index 00000000000..2572615f21c Binary files /dev/null and b/apps/docs/images/webhook-url.png differ diff --git a/apps/docs/mint.json b/apps/docs/mint.json index 16669e28583..efa9b0c2ce9 100644 --- a/apps/docs/mint.json +++ b/apps/docs/mint.json @@ -99,10 +99,19 @@ "group": "Guides", "pages": [ "guides/resumability", + "guides/event-filters", "guides/zod", "guides/event-driven" ] }, + { + "group": "Reference", + "pages": [ + "reference/trigger", + "reference/custom-event", + "reference/webhook-event" + ] + }, { "group": "Webhook Catalog", "pages": [ diff --git a/apps/docs/package.json b/apps/docs/package.json index 31ae16e9b98..97d0a20c912 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -7,6 +7,6 @@ "dev": "mintlify dev --port 3050" }, "devDependencies": { - "mintlify": "^2.0.14" + "mintlify": "^2.0.15" } } \ No newline at end of file diff --git a/apps/docs/reference/custom-event.mdx b/apps/docs/reference/custom-event.mdx new file mode 100644 index 00000000000..a124418982c --- /dev/null +++ b/apps/docs/reference/custom-event.mdx @@ -0,0 +1,38 @@ +--- +title: "customEvent Trigger" +sidebarTitle: "customEvent" +description: "Trigger a workflow when a custom event is received." +--- + +## Usage + +```ts +new Trigger({ + id: "user-created-notify-slack", + name: "User Created - Notify Slack", + on: customEvent({ + name: "user.created", + schema: z.object({ id: z.string(), admin: z.boolean() }), + filter: { + admin: [false], + }, + }), + run: async (event, ctx) => {}, +}).listen(); +``` + +## Options + + + The name of the custom event to listen for. + + + + An event filter to apply to the custom event payload. See the [event filter + documentation](/guides/event-filters) for more information. + + + + A Zod schema to validate the webhook event payload against. See our [Zod + guide](/guides/zod) for more information. + diff --git a/apps/docs/reference/trigger.mdx b/apps/docs/reference/trigger.mdx new file mode 100644 index 00000000000..9c8fd5ec9e9 --- /dev/null +++ b/apps/docs/reference/trigger.mdx @@ -0,0 +1,158 @@ +--- +title: "Trigger" +sidebarTitle: "Trigger" +description: "The Trigger class let's you define a workflow that is triggered by a specific event." +--- + +## Constructor + +### Usage + +```ts +const trigger = new Trigger({ + id: "user-created-notify-slack", + name: "User Created - Notify Slack", + on: customEvent({ + name: "user.created", + schema: z.object({ id: z.string(), admin: z.boolean() }), + filter: { + admin: [false], + }, + }), + run: async (event, ctx) => {}, +}); +``` + +### Options + + + An identifier for this trigger. Must unique across your Trigger.dev + organization. + + + + A display name for this trigger. + + + + Your Trigger.dev API key. If not provided, the API key will be read from the + `TRIGGER_API_KEY` environment variable. + + + + Your Trigger.dev API key. If not provided, the API key will be read from the + `TRIGGER_API_KEY` environment variable. + + + + The URL of the Trigger.dev WebSocket server. If not provided, the endpoint + will point to the production server. + + + + The log level for the Trigger. Defaults to `info` + + + + The TTL for the trigger in seconds. If the delay between the event timestamp + and the current time is greater than the TTL, the trigger will not be + executed. Defaults to `3600` seconds, or 1 hour. + + + + The function that will be executed when the trigger is fired. The function + will be passed the event data and a context object. + + +### Context Object + + + The identifier of the individual run of this trigger. + + + + Either `development` or `live`, depending on the API Key used. + + + + The API Key used to authenticate this trigger. + + + + The identifier of the organization that owns this trigger. + + + + The logger object for this trigger. See the [Logging](/functions/logging) page + for more info. + + + + A function that can be used to send an event to trigger.dev. See the [Sending + Events](/functions/send-event) page for more info. + + + + A function that can be used to wait for a specific amount of time before + continuing. See the [Delays](/functions/delays) page for more info. + + + + A function that can be used to wait for a specific date/time before + continuing. See the [Delays](/functions/delays) page for more info. + + + + A function that can be used to make HTTP requests. See the + [fetch](/functions/fetch) page for more info. + + +## listen + +The `listen` function will register the Trigger with Trigger.dev and start listening for events. + +### Usage + +```ts +const trigger = new Trigger({ + id: "user-created-notify-slack", + name: "User Created - Notify Slack", + on: customEvent({ + name: "user.created", + schema: z.object({ id: z.string(), admin: z.boolean() }), + filter: { + admin: [false], + }, + }), + run: async (event, ctx) => {}, +}); + +trigger.listen(); +``` + +You can split the creation of the trigger and the listening for events by calling `trigger.listen()` at a later time. + + + +```ts triggers.ts +export const userCreatedNotifySlack = new Trigger({ + id: "user-created-notify-slack", + name: "User Created - Notify Slack", + on: customEvent({ + name: "user.created", + schema: z.object({ id: z.string(), admin: z.boolean() }), + filter: { + admin: [false], + }, + }), + run: async (event, ctx) => {}, +}); +``` + +```ts index.ts +import { userCreatedNotifySlack } from "./triggers"; + +userCreatedNotifySlack.listen(); +``` + + diff --git a/apps/docs/reference/webhook-event.mdx b/apps/docs/reference/webhook-event.mdx new file mode 100644 index 00000000000..1785cbac668 --- /dev/null +++ b/apps/docs/reference/webhook-event.mdx @@ -0,0 +1,63 @@ +--- +title: "webhookEvent Trigger" +sidebarTitle: "webhookEvent" +description: "Trigger a workflow when a webhook event is received" +--- + +## Usage + +```ts +new Trigger({ + id: "caldotcom-to-slack", + name: "Cal.com To Slack", + on: webhookEvent({ + service: "cal.com", + eventName: "BOOKING_CREATED", + filter: { + triggerEvent: ["BOOKING_CREATED"], + }, + schema: z.any(), + verifyPayload: { + enabled: true, + header: "X-Cal-Signature-256", + }, + }), + run: async (event, ctx) => {}, +}).listen(); +``` + +## Options + + + The name of the service that will be sending the webhook event. + + + + The name of the event that will be sent by the service. + + + + An event filter to apply to the webhook event. See the [event filter + documentation](/guides/event-filters) for more information. + + + + A Zod schema to validate the webhook event payload against. See our [Zod + guide](/guides/zod) for more information. + + + + Verify the payload of the webhook event. Currently only supports sha256 HMAC + signatures. + + + Whether to verify the payload of the webhook event. Defaults to `false`. + + + + The name of the header that contains the signature to verify the payload + against. e.g. `X-Webhook-Signature`. + + + + diff --git a/apps/docs/triggers/custom-events.mdx b/apps/docs/triggers/custom-events.mdx index 96b793c9831..0d226be8e27 100644 --- a/apps/docs/triggers/custom-events.mdx +++ b/apps/docs/triggers/custom-events.mdx @@ -20,6 +20,43 @@ It also means that inside your run function the event param will be typed correc You can always start out by using `z.any()` as your schema, and then later on you can add more strict validation. See our [Zod guide](/guides/zod) for more information. +## Filters + +You can also add filters to your custom event triggers. This allows you to filter out events that you don't want to run your workflow for. + +```ts +new Trigger({ + id: "new-user-slack", + name: "New user slack message", + on: customEvent({ + name: "user.created", + schema: z.object({ + name: z.string(), + email: z.string(), + paidPlan: z.boolean(), + }), + filter: { + //only run the workflow if the user is paying + paidPlan: [true], + }, + }), + //this function is run when the custom event is received + run: async (event, ctx) => { + //send a message to the #new-users Slack channel with user details + const response = await slack.postMessage("send-to-slack", { + channel: "new-users", + text: `New user: ${event.name} (${event.email}) signed up. ${ + event.paidPlan ? "They are paying" : "They are on the free plan" + }.`, + }); + + return response.message; + }, +}).listen(); +``` + +Please see the [Event filters guide](/guides/event-filters) for more information. + ## Example ```ts diff --git a/apps/docs/triggers/webhooks.mdx b/apps/docs/triggers/webhooks.mdx index d507c87d073..e30e860f03a 100644 --- a/apps/docs/triggers/webhooks.mdx +++ b/apps/docs/triggers/webhooks.mdx @@ -4,7 +4,7 @@ sidebarTitle: "Webhooks" description: "Webhooks allow you to subscribe to events from APIs you use." --- -If you want your server to be notified when one of your users has subscribed to your product, or someone has made a change to a support ticket, then you should use webhooks. +Webhooks are a crucial part of API development, allowing for real-time reactions to various events across different systems, such as when a Stripe Payment is made, or when a GitHub issue is created. ## Advantages of using Trigger.dev for webhooks @@ -14,9 +14,74 @@ Webhooks can be difficult to work with, especially when developing locally. We m - They work locally during development without needing to use tunnels (e.g. Ngrok) - We receive the webhook, then keep trying to send it to you until you receive it. If your server goes down, no problem. -## Send a Slack message when a GitHub issue is labeled as Critical +## Usage + +There are two ways to use webhooks with Trigger.dev: + +1. Use one of our built-in integrations, such as [GitHub](/integrations/github). We'll take care of registering the webhook for you. +2. Use our [webhookEvent](/reference/webhook-event) function to create a webhook subscription and you'll register the webhook yourself. + +## Webhook integrations + +We currently have built in integrations for the following webhooks: + +- [GitHub](/integrations/apis/github) + +Please [join our discord community](https://discord.gg/kA47vcd8P6) and let us know which integration you'd like us to add. + +We've documented all the support webhooks for each integration in the sidebar, for example the GitHub [newStarEvent](/integrations/apis/github/events/new-star) webhook: ```ts +import { github } from "@trigger.dev/integrations"; + +new Trigger({ + id: "demo", + on: github.events.newStarEvent({ + repo: "triggerdotdev/trigger.dev", + }), + run: async (event, ctx) => {}, +}).listen(); +``` + +Once you've connected to Trigger.dev, and authorized your GitHub account, we'll go ahead and register the webhook in the repository you've specified and start triggering your `Trigger.run` function when new stars roll in. + +## Manual Webhooks + +If we haven't built out the integration you need, you can use our `webhookEvent` function to create a webhook subscription. You'll need to register the webhook yourself, but we'll take care of the rest. Here's an example for triggering events when a new booking happens in [Cal.com](https://cal.com): + +```ts +new Trigger({ + id: "caldotcom-to-slack", + name: "Cal.com To Slack", + on: webhookEvent({ + service: "cal.com", + eventName: "BOOKING_CREATED", + filter: { + triggerEvent: ["BOOKING_CREATED"], + }, + schema: z.any(), + verifyPayload: { + enabled: true, + header: "X-Cal-Signature-256", + }, + }), + run: async (event, ctx) => {}, +}).listen(); +``` + +For more information on the various options for `webhookEvent`, see the [webhookEvent reference](/reference/webhook-event). + +Once you connect to Trigger.dev, we will display the URL and (optionally) the secret you need to register with the webhook provider on the workflow overview page: + +![Webhook URL](/images/webhook-url.png) + +Copy the URL and secret, and register the webhook with the provider (in this case, Cal.com). Once you've done that, we'll start triggering your `Trigger.run` function when the webhook fires. + +## Examples + + + +```ts Github import { Trigger } from "@trigger.dev/sdk"; import { github, slack } from "@trigger.dev/integrations"; @@ -47,3 +112,5 @@ new Trigger({ }, }).listen(); ``` + + diff --git a/apps/docs/welcome.mdx b/apps/docs/welcome.mdx index 5b7272966a2..7f4e32d62c6 100644 --- a/apps/docs/welcome.mdx +++ b/apps/docs/welcome.mdx @@ -4,11 +4,6 @@ sidebarTitle: "Welcome" description: "" --- - - Trigger.dev is in private beta. We are now inviting users from our waitlist - [here.](https://bcymafitv0e.typeform.com/tddsignup?typeform-source=trigger.dev#source=Docs) - - Trigger.dev is an open source platform that enables developers to create event-driven background tasks directly in their code. Build, test and run workflows locally and subscribe to webhooks, schedule jobs, run background jobs and add long delays easily and reliably. Workflows live in your codebase so you can use your existing types, functions, version control and IDE. They are triggered by us but run on your server so your private data is never exposed. diff --git a/apps/webapp/app/components/CopyTextButton.tsx b/apps/webapp/app/components/CopyTextButton.tsx index 35cd6da0c5c..c862565eb9d 100644 --- a/apps/webapp/app/components/CopyTextButton.tsx +++ b/apps/webapp/app/components/CopyTextButton.tsx @@ -6,7 +6,7 @@ import { CopyText } from "./CopyText"; const variantStyle = { slate: "bg-slate-800 text-white rounded px-2 py-1 transition hover:text-slate-700 hover:bg-slate-700 hover:bg-slate-700 hover:text-slate-100 active:bg-slate-800 active:text-slate-300 focus-visible:outline-slate-900", - blue: "bg-blue-500 rounded px-2 py-1 transition text-white hover:text-slate-100 hover:bg-blue-600 active:bg-blue-800 active:text-blue-100 focus-visible:outline-blue-600", + blue: "bg-indigo-600 rounded px-2 py-1 transition text-white hover:bg-indigo-500 active:bg-indigo-800 active:text-indigo-100 focus-visible:outline-indigo-600", darkTransparent: "bg-black/10 text-slate-900 rounded px-2 py-1 transition hover:bg-blue-50 active:bg-blue-200 active:text-slate-600 focus-visible:outline-white", lightTransparent: diff --git a/apps/webapp/app/components/layout/PanelInfo.tsx b/apps/webapp/app/components/layout/PanelInfo.tsx index 967235abe33..405f7ebf672 100644 --- a/apps/webapp/app/components/layout/PanelInfo.tsx +++ b/apps/webapp/app/components/layout/PanelInfo.tsx @@ -14,7 +14,7 @@ export function PanelInfo({ }) { return (
{children} diff --git a/apps/webapp/app/components/triggers/Trigger.tsx b/apps/webapp/app/components/triggers/Trigger.tsx index 2335eae3588..b31d2f27561 100644 --- a/apps/webapp/app/components/triggers/Trigger.tsx +++ b/apps/webapp/app/components/triggers/Trigger.tsx @@ -36,6 +36,7 @@ function Webhook({ webhook }: { webhook: WebhookEventTrigger }) {
{webhook.source && + !webhook.manualRegistration && Object.entries(webhook.source).map(([key, value]) => (
diff --git a/apps/webapp/app/components/triggers/TriggerIcons.tsx b/apps/webapp/app/components/triggers/TriggerIcons.tsx index 58fd0676527..4dc4e0c4879 100644 --- a/apps/webapp/app/components/triggers/TriggerIcons.tsx +++ b/apps/webapp/app/components/triggers/TriggerIcons.tsx @@ -47,6 +47,14 @@ export function TriggerTypeIcon({ return ( {triggerLabel(type)} ); + case "WEBHOOK": + return ( + {triggerLabel(type)} + ); default: return null; } diff --git a/apps/webapp/app/entry.server.tsx b/apps/webapp/app/entry.server.tsx index 5a5ec14bac9..f06ef773c91 100644 --- a/apps/webapp/app/entry.server.tsx +++ b/apps/webapp/app/entry.server.tsx @@ -3,6 +3,7 @@ import { RemixServer } from "@remix-run/react"; import { renderToString } from "react-dom/server"; import * as Sentry from "~/services/sentry.server"; import * as MessageBroker from "~/services/messageBroker.server"; +import * as Triggers from "~/triggers/init.server"; export default function handleRequest( request: Request, @@ -26,3 +27,4 @@ export default function handleRequest( Sentry.init(); MessageBroker.init(); +Triggers.init(); diff --git a/apps/webapp/app/models/externalSource.server.ts b/apps/webapp/app/models/externalSource.server.ts index 6099949629c..c9a976c1798 100644 --- a/apps/webapp/app/models/externalSource.server.ts +++ b/apps/webapp/app/models/externalSource.server.ts @@ -1,5 +1,8 @@ import { prisma } from "~/db.server"; -export type { ExternalSource } from ".prisma/client"; +import type { ExternalSource } from ".prisma/client"; +import { env } from "process"; + +export type { ExternalSource }; export type ExternalSourceWithConnection = Awaited< ReturnType @@ -31,3 +34,7 @@ export async function connectExternalSource({ }, }); } + +export function buildExternalSourceUrl(id: string, serviceIdentifier: string) { + return `${env.APP_ORIGIN}/api/v1/internal/webhooks/${serviceIdentifier}/${id}`; +} diff --git a/apps/webapp/app/models/workflow.server.ts b/apps/webapp/app/models/workflow.server.ts index be3393c3f9d..91ef3b79f49 100644 --- a/apps/webapp/app/models/workflow.server.ts +++ b/apps/webapp/app/models/workflow.server.ts @@ -33,6 +33,8 @@ export function getWorkflowFromSlugs({ connection: true, key: true, service: true, + manualRegistration: true, + secret: true, }, }, externalServices: { diff --git a/apps/webapp/app/models/workflowRunPresenter.server.ts b/apps/webapp/app/models/workflowRunPresenter.server.ts index 8fd59581393..6d13a489a8b 100644 --- a/apps/webapp/app/models/workflowRunPresenter.server.ts +++ b/apps/webapp/app/models/workflowRunPresenter.server.ts @@ -125,7 +125,9 @@ async function parseStep( const fetchRequest = FetchRequestSchema.parse(original.input); const lastFetchResponse = original.fetchRequest.responses[0]; const lastResponse = lastFetchResponse - ? FetchResponseSchema.parse(lastFetchResponse.output) + ? FetchResponseSchema.safeParse(lastFetchResponse.output).success + ? FetchResponseSchema.parse(lastFetchResponse.output) + : undefined : undefined; return { diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 10350dd6983..1f51c81cd6f 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -143,7 +143,7 @@ function App() { background: "#D1FAE5", padding: "16px 20px", color: "#1E293B", - minWidth: "500px", + maxWidth: "500px", }, iconTheme: { primary: "#10B981", @@ -157,7 +157,7 @@ function App() { background: "#FFE4E6", padding: "16px 20px", color: "#1E293B", - minWidth: "500px", + maxWidth: "500px", }, iconTheme: { primary: "#F43F5E", diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug.tsx index c9cd12ff68f..c9a3d4a41a4 100644 --- a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug.tsx +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug.tsx @@ -1,6 +1,9 @@ import { Outlet } from "@remix-run/react"; import type { LoaderArgs } from "@remix-run/server-runtime"; -import { TriggerMetadataSchema } from "@trigger.dev/common-schemas"; +import { + ManualWebhookSourceSchema, + TriggerMetadataSchema, +} from "@trigger.dev/common-schemas"; import { typedjson } from "remix-typedjson"; import invariant from "tiny-invariant"; import { Container } from "~/components/layout/Container"; @@ -9,6 +12,7 @@ import { WorkflowsSideMenu, } from "~/components/navigation/SideMenu"; import { getConnectedApiConnectionsForOrganizationSlug } from "~/models/apiConnection.server"; +import { buildExternalSourceUrl } from "~/models/externalSource.server"; import { getIntegrations } from "~/models/integrations.server"; import { getRuntimeEnvironmentFromRequest } from "~/models/runtimeEnvironment.server"; import { getWorkflowFromSlugs } from "~/models/workflow.server"; @@ -78,8 +82,28 @@ export const loader = async ({ request, params }: LoaderArgs) => { }), }; + const externalSourceSecret = + workflow.externalSource && + workflow.externalSource.manualRegistration && + ManualWebhookSourceSchema.safeParse(workflow.externalSource.source) + .success && + ManualWebhookSourceSchema.parse(workflow.externalSource.source) + .verifyPayload.enabled + ? workflow.externalSource.secret + : undefined; + return typedjson({ - workflow: { ...workflow, rules }, + workflow: { + ...workflow, + rules, + externalSourceUrl: workflow.externalSource + ? buildExternalSourceUrl( + workflow.externalSource.id, + workflow.externalSource.service + ) + : undefined, + externalSourceSecret: externalSourceSecret, + }, currentEnvironmentSlug, connectionSlots, }); diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/index.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/index.tsx index e86acde79a6..c1d3299a7dd 100644 --- a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/index.tsx +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/index.tsx @@ -1,6 +1,8 @@ import type { LoaderArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import invariant from "tiny-invariant"; +import CodeBlock from "~/components/code/CodeBlock"; +import { CopyTextButton } from "~/components/CopyTextButton"; import { WorkflowConnections } from "~/components/integrations/WorkflowConnections"; import { Panel } from "~/components/layout/Panel"; import { PanelHeader } from "~/components/layout/PanelHeader"; @@ -11,6 +13,7 @@ import { SecondaryLink, TertiaryLink, } from "~/components/primitives/Buttons"; +import { Input } from "~/components/primitives/Input"; import { Body } from "~/components/primitives/text/Body"; import { SubTitle } from "~/components/primitives/text/SubTitle"; import { Title } from "~/components/primitives/text/Title"; @@ -86,9 +89,61 @@ export default function Page() {
{workflow.status === "CREATED" && ( <> - - This workflow requires its APIs to be connected before it can run. - + {eventRule && + eventRule.trigger.type === "WEBHOOK" && + eventRule.trigger.manualRegistration && + workflow.externalSourceUrl ? ( + +
+ + Use these details to register your webhook – this usually + involves logging in to the developer section of the service. + +
+
+
+ + URL + +
+ + +
+
+
+ {workflow.externalSourceSecret && ( +
+ + Secret + +
+ + +
+
+ )} +
+
+
+ ) : ( + + This workflow requires its APIs to be connected before it can run. + + )} )} {workflow.status === "DISABLED" && ( diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/test.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/test.tsx index 91f0912bac6..f17914345af 100644 --- a/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/test.tsx +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug/test.tsx @@ -67,7 +67,9 @@ export default function Page() { workflowSlug={workflow.slug} eventNames={workflow.eventNames} initialValue={ - latestRun == null + workflow.type === "SCHEDULE" + ? JSON.stringify({ scheduledTime: new Date() }, null, 2) + : latestRun == null ? "{\n\n}" : JSON.stringify(latestRun.event.payload, null, 2) } diff --git a/apps/webapp/app/routes/api/v1/internal/webhooks/$serviceIdentifier.$id.ts b/apps/webapp/app/routes/api/v1/internal/webhooks/$serviceIdentifier.$id.ts index c6b0b8072cb..e318a4ee3a2 100644 --- a/apps/webapp/app/routes/api/v1/internal/webhooks/$serviceIdentifier.$id.ts +++ b/apps/webapp/app/routes/api/v1/internal/webhooks/$serviceIdentifier.$id.ts @@ -17,7 +17,10 @@ export async function action({ request, params }: ActionArgs) { }; } - if (externalSource.connection?.apiIdentifier !== serviceIdentifier) { + if ( + !externalSource.manualRegistration && + externalSource.connection?.apiIdentifier !== serviceIdentifier + ) { return { status: 500, body: "Service identifier does not match" }; } diff --git a/apps/webapp/app/routes/api/v1/internal/workflows/$workflowP.ts b/apps/webapp/app/routes/api/v1/internal/workflows/$workflowP.ts index a7eab000af0..90d3d62b291 100644 --- a/apps/webapp/app/routes/api/v1/internal/workflows/$workflowP.ts +++ b/apps/webapp/app/routes/api/v1/internal/workflows/$workflowP.ts @@ -34,8 +34,10 @@ export async function action({ request, params }: ActionArgs) { ); switch (result.status) { - case "validationError": - return json({ error: result.data }, { status: 400 }); + case "validationError": { + return json({ error: result.errors }, { status: 400 }); + } + case "success": return json(result.data); } diff --git a/apps/webapp/app/services/events/dispatch.server.ts b/apps/webapp/app/services/events/dispatch.server.ts index f266f8e4276..7aa3366a96a 100644 --- a/apps/webapp/app/services/events/dispatch.server.ts +++ b/apps/webapp/app/services/events/dispatch.server.ts @@ -5,6 +5,7 @@ import { prisma } from "~/db.server"; import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server"; import type { Workflow } from "~/models/workflow.server"; import { taskQueue } from "../messageBroker.server"; +import { generateErrorMessage } from "zod-error"; export class DispatchEvent { #prismaClient: PrismaClient; @@ -41,8 +42,16 @@ export class DispatchEvent { }, }); + console.log( + `Found ${eventRules.length} event rules to check for event ${ + event.id + }: ${eventRules.map((eventRule) => eventRule.id).join(", ")}` + ); + const matcher = new EventMatcher(event); + console.log(`Matching event rules for event`, matcher.json); + const matchingEventRules = eventRules.filter((eventRule) => { return matcher.matches(eventRule); }); @@ -77,20 +86,28 @@ export class DispatchEvent { } class EventMatcher { - #json: any; + json: any; constructor(event: TriggerEvent) { - this.#json = this.#createEventJsonFromEvent(event); + this.json = this.#createEventJsonFromEvent(event); } public matches(eventRule: EventRule) { + console.log(`Matching against event rule ${eventRule.id}`); + const filter = this.#parseFilter(eventRule); if (!filter.success) { + console.error( + `Could not parse filter for event rule ${ + eventRule.id + }, returning false: ${generateErrorMessage(filter.error.issues)}` + ); + return false; } - return patternMatches(this.#json, filter.data); + return patternMatches(this.json, filter.data); } #parseFilter(eventRule: EventRule) { @@ -104,6 +121,7 @@ class EventMatcher { event: event.name, service: event.service, payload: event.payload, + context: event.context, }; } } @@ -165,8 +183,40 @@ export class DispatchWorkflowRun { status: "PENDING", isTest: event.isTest, }, + include: { + workflow: { + include: { + externalSource: true, + }, + }, + }, }); + if ( + workflowRun.workflow.externalSource && + workflowRun.workflow.externalSource.status === "CREATED" + ) { + await this.#prismaClient.externalSource.update({ + where: { + id: workflowRun.workflow.externalSource.id, + }, + data: { + status: "READY", + }, + }); + + if (workflowRun.workflow.status === "CREATED") { + await this.#prismaClient.workflow.update({ + where: { + id: workflowRun.workflow.id, + }, + data: { + status: "READY", + }, + }); + } + } + console.log( `Created workflow run ${workflowRun.id} for event rule ${eventRule.id}` ); diff --git a/apps/webapp/app/services/externalSources/handleExternalSource.server.ts b/apps/webapp/app/services/externalSources/handleExternalSource.server.ts index 39c166d776b..7a5de15838b 100644 --- a/apps/webapp/app/services/externalSources/handleExternalSource.server.ts +++ b/apps/webapp/app/services/externalSources/handleExternalSource.server.ts @@ -4,6 +4,9 @@ import { github } from "internal-integrations"; import type { ExternalSourceWithConnection } from "~/models/externalSource.server"; import type { NormalizedRequest } from "internal-integrations"; import { IngestEvent } from "../events/ingest.server"; +import { ManualWebhookSourceSchema } from "@trigger.dev/common-schemas"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import { ulid } from "ulid"; type IgnoredEventResponse = { status: "ignored"; @@ -41,16 +44,25 @@ export class HandleExternalSource { async #createNormalizedRequest(request: Request): Promise { const requestUrl = new URL(request.url); const rawSearchParams = requestUrl.searchParams; - const rawBody = await request.json(); + const rawBody = await request.text(); const rawHeaders = Object.fromEntries(request.headers.entries()); return { - body: rawBody, + rawBody, + body: this.#safeJsonParse(rawBody), headers: rawHeaders, searchParams: rawSearchParams, }; } + #safeJsonParse(json: string): any { + try { + return JSON.parse(json); + } catch (error) { + return null; + } + } + public async call( externalSource: NonNullable, serviceIdentifier: string, @@ -123,6 +135,14 @@ export class HandleExternalSource { serviceIdentifier: string, request: NormalizedRequest ): Promise { + if (externalSource.manualRegistration) { + return this.#handleManualWebhook( + externalSource, + serviceIdentifier, + request + ); + } + switch (serviceIdentifier) { case "github": { return github.webhooks.handleWebhookRequest({ @@ -137,4 +157,48 @@ export class HandleExternalSource { } } } + + async #handleManualWebhook( + externalSource: NonNullable, + serviceIdentifier: string, + request: NormalizedRequest + ): Promise { + const source = ManualWebhookSourceSchema.parse(externalSource.source); + + if (source.verifyPayload.enabled && source.verifyPayload.header) { + const hmac = createHmac("sha256", externalSource.secret!); + const digest = Buffer.from( + hmac.update(request.rawBody).digest("hex"), + "utf8" + ); + + const providerSigString = + request.headers[source.verifyPayload.header.toLowerCase()] || ""; + + const providerSig = Buffer.from(providerSigString, "utf8"); + + if ( + digest.length !== providerSig.length || + !timingSafeEqual(digest, providerSig) + ) { + return { + status: "error", + error: "Payload signature did not match", + }; + } + } + + return { + status: "ok", + data: { + id: ulid(), + payload: request.body, + event: source.event, + context: { + headers: request.headers, + externalSourceId: externalSource.id, + }, + }, + }; + } } diff --git a/apps/webapp/app/services/externalSources/registerExternalSource.server.ts b/apps/webapp/app/services/externalSources/registerExternalSource.server.ts index 1b237a57d4d..66ea2ef53b8 100644 --- a/apps/webapp/app/services/externalSources/registerExternalSource.server.ts +++ b/apps/webapp/app/services/externalSources/registerExternalSource.server.ts @@ -5,7 +5,10 @@ import crypto from "node:crypto"; import type { PrismaClient } from "~/db.server"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; -import { findExternalSourceById } from "~/models/externalSource.server"; +import { + buildExternalSourceUrl, + findExternalSourceById, +} from "~/models/externalSource.server"; import { getAccessInfo } from "../accessInfo.server"; export class RegisterExternalSource { @@ -26,6 +29,10 @@ export class RegisterExternalSource { return true; } + if (externalSource.manualRegistration) { + return true; + } + console.log("[RegisterExternalSource] registering external source", { externalSource, }); @@ -56,9 +63,13 @@ export class RegisterExternalSource { throw new Error("No access token found for webhook"); } - const secret = crypto.randomBytes(32).toString("hex"); + const secret = + externalSource.secret ?? crypto.randomBytes(32).toString("hex"); - const webhookUrl = `${env.APP_ORIGIN}/api/v1/internal/webhooks/${connection.apiIdentifier}/${externalSource.id}`; + const webhookUrl = buildExternalSourceUrl( + externalSource, + connection.apiIdentifier + ); const serviceWebhook = await this.#registerWebhookWithConnection( externalSource.service, diff --git a/apps/webapp/app/services/fetches/performFetchRequest.server.ts b/apps/webapp/app/services/fetches/performFetchRequest.server.ts index cb86b4d6af2..eaf147869c8 100644 --- a/apps/webapp/app/services/fetches/performFetchRequest.server.ts +++ b/apps/webapp/app/services/fetches/performFetchRequest.server.ts @@ -1,5 +1,5 @@ import type { FetchRequest } from ".prisma/client"; -import type { SecureString } from "@trigger.dev/common-schemas"; +import type { RetrySchema, SecureString } from "@trigger.dev/common-schemas"; import { FetchRequestSchema } from "@trigger.dev/common-schemas"; import type { NormalizedResponse, @@ -9,8 +9,6 @@ import type { z } from "zod"; import type { PrismaClient } from "~/db.server"; import { prisma } from "~/db.server"; -const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]; - type CallResponse = | { stop: true; @@ -36,12 +34,28 @@ export class PerformFetchRequest { return { stop: true }; } - const performedRequest = await this.#performRequest(fetchRequest); + const request = FetchRequestSchema.parse(fetchRequest.fetch); + + const retryConfig = { + enabled: true, + maxAttempts: 10, + minTimeout: 1000, + maxTimeout: 60000, + factor: 1.8, + statusCodes: [408, 429, 500, 502, 503, 504], + ...(request.retry ?? {}), + }; + + const performedRequest = await this.#performRequest(request, retryConfig); if (performedRequest.ok) { return this.#completeWithSuccess(fetchRequest, performedRequest.response); } else if (performedRequest.isRetryable) { - return this.#attemptRetry(fetchRequest, performedRequest.response); + return this.#attemptRetry( + retryConfig, + fetchRequest, + performedRequest.response + ); } else { return this.#completeWithFailure(fetchRequest, performedRequest.response); } @@ -108,21 +122,11 @@ export class PerformFetchRequest { } async #attemptRetry( + retry: z.infer, fetchRequest: FetchRequest, response: NormalizedResponse ) { - if (fetchRequest.retryCount >= 10) { - await this.#prismaClient.fetchRequest.update({ - where: { - id: fetchRequest.id, - }, - data: { - retryCount: { - increment: 1, - }, - }, - }); - + if (fetchRequest.retryCount >= retry.maxAttempts) { return this.#completeWithFailure(fetchRequest, response); } @@ -143,7 +147,8 @@ export class PerformFetchRequest { return { stop: false as const, retryInSeconds: this.#calculateRetryInSeconds( - updatedFetchRequest.retryCount + updatedFetchRequest.retryCount, + retry ), }; } @@ -151,15 +156,11 @@ export class PerformFetchRequest { // Exponential backoff with a configurable factor and a configurable maximum #calculateRetryInSeconds( retryCount: number, - options: { factor: number; maxTimeout: number; minTimeout: number } = { - factor: 1.8, - minTimeout: 1000, - maxTimeout: 60000, - } + retryOptions: z.infer ) { - const timeout = options.factor ** retryCount * options.minTimeout; + const timeout = retryOptions.factor ** retryCount * retryOptions.minTimeout; - return Math.min(timeout, options.maxTimeout) / 1000; + return Math.min(timeout, retryOptions.maxTimeout) / 1000; } async #createResponse( @@ -182,19 +183,36 @@ export class PerformFetchRequest { } async #performRequest( - fetchRequest: FetchRequest + request: z.infer, + retry: z.infer ): Promise { - const request = FetchRequestSchema.parse(fetchRequest.fetch); - const requestInit = createFetchRequestInit(request); - - const response = await fetch(request.url, requestInit); - - const body = await this.#safeGetJson(response); + try { + const requestInit = createFetchRequestInit(request); + + const response = await fetch(request.url, requestInit); + + const body = await this.#safeGetJson(response); + + if (response.ok) { + return { + ok: true, + isRetryable: false, + response: { + output: { + status: response.status, + headers: headersToRecord(response.headers), + body, + }, + context: {}, + }, + }; + } - if (response.ok) { + // Only retry on retryable status codes return { - ok: true, - isRetryable: false, + ok: false, + isRetryable: + retry.statusCodes.includes(response.status) && retry.enabled, response: { output: { status: response.status, @@ -204,21 +222,33 @@ export class PerformFetchRequest { context: {}, }, }; + } catch (error) { + if (error instanceof Error) { + return { + ok: false, + isRetryable: false, + response: { + output: { + name: error.name, + message: error.message, + }, + context: {}, + }, + }; + } else { + return { + ok: false, + isRetryable: false, + response: { + output: { + name: "UnknownError", + message: "Unknown error", + }, + context: {}, + }, + }; + } } - - // Only retry on retryable status codes - return { - ok: false, - isRetryable: RETRYABLE_STATUS_CODES.includes(response.status), - response: { - output: { - status: response.status, - headers: headersToRecord(response.headers), - body, - }, - context: {}, - }, - }; } #safeGetJson = async (response: Response) => { diff --git a/apps/webapp/app/services/workflows/registerWorkflow.server.ts b/apps/webapp/app/services/workflows/registerWorkflow.server.ts index fb002be1b2d..d4b027d629c 100644 --- a/apps/webapp/app/services/workflows/registerWorkflow.server.ts +++ b/apps/webapp/app/services/workflows/registerWorkflow.server.ts @@ -1,6 +1,7 @@ import { github } from "internal-integrations"; import type { WorkflowMetadata } from "internal-platform"; import { WorkflowMetadataSchema } from "internal-platform"; +import crypto from "node:crypto"; import type { PrismaClient } from "~/db.server"; import { prisma } from "~/db.server"; import type { Organization } from "~/models/organization.server"; @@ -140,46 +141,14 @@ export class RegisterWorkflow { ) { switch (payload.trigger.type) { case "WEBHOOK": { - if (!payload.trigger.source) { - return; - } - - const existingConnection = - await this.#findLatestExistingConnectionInOrg( - payload.trigger.service, - organization - ); - - const externalSource = await this.#prismaClient.externalSource.upsert({ - where: { - organizationId_key: { - key: this.#keyForExternalSource(payload), - organizationId: organization.id, - }, - }, - update: { - source: payload.trigger.source, - }, - create: { - organizationId: organization.id, - key: this.#keyForExternalSource(payload), - type: "WEBHOOK", - source: payload.trigger.source, - status: "CREATED", - connectionId: existingConnection?.id, - service: payload.trigger.service, - }, - }); + const externalSource = await this.#upsertWebhookSource( + payload, + organization, + workflow + ); - if (!externalSource.connectionId && existingConnection) { - await this.#prismaClient.externalSource.update({ - where: { - id: externalSource.id, - }, - data: { - connectionId: existingConnection.id, - }, - }); + if (!externalSource) { + return; } await this.#prismaClient.workflow.update({ @@ -235,6 +204,89 @@ export class RegisterWorkflow { } } + async #upsertWebhookSource( + payload: WorkflowMetadata, + organization: Organization, + workflow: Workflow + ) { + if (payload.trigger.type !== "WEBHOOK") { + return; + } + + if (!payload.trigger.source) { + return; + } + + const secret = crypto.randomBytes(16).toString("hex"); + + if (payload.trigger.manualRegistration) { + const externalSource = await this.#prismaClient.externalSource.upsert({ + where: { + organizationId_key: { + key: `${workflow.id}-${payload.trigger.service}`, + organizationId: organization.id, + }, + }, + update: { + source: payload.trigger.source, + }, + create: { + organizationId: organization.id, + key: `${workflow.id}-${payload.trigger.service}`, + type: "WEBHOOK", + source: payload.trigger.source, + status: "CREATED", + service: payload.trigger.service, + manualRegistration: true, + secret, + }, + }); + + return externalSource; + } else { + const existingConnection = await this.#findLatestExistingConnectionInOrg( + payload.trigger.service, + organization + ); + + const externalSource = await this.#prismaClient.externalSource.upsert({ + where: { + organizationId_key: { + key: this.#keyForExternalSource(payload), + organizationId: organization.id, + }, + }, + update: { + source: payload.trigger.source, + }, + create: { + organizationId: organization.id, + key: this.#keyForExternalSource(payload), + type: "WEBHOOK", + source: payload.trigger.source, + status: "CREATED", + connectionId: existingConnection?.id, + service: payload.trigger.service, + manualRegistration: false, + secret, + }, + }); + + if (!externalSource.connectionId && existingConnection) { + await this.#prismaClient.externalSource.update({ + where: { + id: externalSource.id, + }, + data: { + connectionId: existingConnection.id, + }, + }); + } + + return externalSource; + } + } + #keyForExternalSource(payload: WorkflowMetadata): string { if (payload.trigger.type === "WEBHOOK") { switch (payload.trigger.service) { diff --git a/apps/webapp/app/triggers/init.server.ts b/apps/webapp/app/triggers/init.server.ts new file mode 100644 index 00000000000..f6ebb3d3642 --- /dev/null +++ b/apps/webapp/app/triggers/init.server.ts @@ -0,0 +1,17 @@ +import { uptimeCheck } from "./monitoring.server"; + +declare global { + var __triggers_initialized: boolean; +} + +export function init() { + if (global.__triggers_initialized) { + return; + } + + global.__triggers_initialized = true; + + uptimeCheck.listen(); + + console.log(`🛎 Triggers initialized`); +} diff --git a/apps/webapp/app/triggers/monitoring.server.ts b/apps/webapp/app/triggers/monitoring.server.ts new file mode 100644 index 00000000000..01ff77c68b3 --- /dev/null +++ b/apps/webapp/app/triggers/monitoring.server.ts @@ -0,0 +1,26 @@ +import { Trigger, scheduleEvent } from "@trigger.dev/sdk"; +import { slack } from "@trigger.dev/integrations"; +import { prisma } from "~/db.server"; + +export const uptimeCheck = new Trigger({ + id: "uptime-check", + name: "Uptime Check", + on: scheduleEvent({ rateOf: { minutes: 1 } }), + triggerTTL: 300, + run: async (event, context) => { + // Grab counts of workflows, runs, and steps + const userCount = await prisma.user.count(); + const workflowCount = await prisma.workflow.count(); + const runCount = await prisma.workflowRun.count(); + const stepCount = await prisma.workflowRunStep.count(); + + if (context.environment === "development") { + return; + } + + await slack.postMessage("Uptime Notification", { + channelName: "monitoring", + text: `[${context.environment}] Uptime Check: ${userCount} users, ${workflowCount} workflows, ${runCount} runs, ${stepCount} steps.`, + }); + }, +}); diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 282747443f3..8e3222ee25f 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -64,6 +64,9 @@ "@tanstack/react-table": "^8.0.0-alpha.87", "@trigger.dev/common-schemas": "workspace:*", "@trigger.dev/providers": "workspace:*", + "@trigger.dev/sdk": "workspace:*", + "@trigger.dev/integrations": "workspace:*", + "internal-bridge": "workspace:*", "@uiw/react-codemirror": "^4.13.2", "bcryptjs": "^2.4.3", "classnames": "^2.3.1", @@ -115,7 +118,8 @@ "tiny-invariant": "^1.2.0", "tsx": "^3.4.3", "ulid": "^2.3.0", - "zod": "^3.20.2" + "zod": "^3.20.2", + "zod-error": "^1.1.0" }, "devDependencies": { "@faker-js/faker": "^7.5.0", @@ -182,4 +186,4 @@ "engines": { "node": ">=16.0.0" } -} +} \ No newline at end of file diff --git a/apps/webapp/prisma/migrations/20230124224344_add_retry_to_fetch_request/migration.sql b/apps/webapp/prisma/migrations/20230124224344_add_retry_to_fetch_request/migration.sql new file mode 100644 index 00000000000..adfd35d6529 --- /dev/null +++ b/apps/webapp/prisma/migrations/20230124224344_add_retry_to_fetch_request/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "FetchRequest" ADD COLUMN "retry" JSONB; diff --git a/apps/webapp/prisma/migrations/20230124224734_remove_retry/migration.sql b/apps/webapp/prisma/migrations/20230124224734_remove_retry/migration.sql new file mode 100644 index 00000000000..10a891d6e48 --- /dev/null +++ b/apps/webapp/prisma/migrations/20230124224734_remove_retry/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `retry` on the `FetchRequest` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "FetchRequest" DROP COLUMN "retry"; diff --git a/apps/webapp/prisma/migrations/20230125105732_add_manual_registration_to_external_source/migration.sql b/apps/webapp/prisma/migrations/20230125105732_add_manual_registration_to_external_source/migration.sql new file mode 100644 index 00000000000..bf088658351 --- /dev/null +++ b/apps/webapp/prisma/migrations/20230125105732_add_manual_registration_to_external_source/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ExternalSource" ADD COLUMN "manualRegistration" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/webapp/prisma/schema.prisma b/apps/webapp/prisma/schema.prisma index ddb9fcce460..a3b0319619e 100644 --- a/apps/webapp/prisma/schema.prisma +++ b/apps/webapp/prisma/schema.prisma @@ -197,13 +197,14 @@ model ExternalSource { service String - workflows Workflow[] - type ExternalSourceType - key String - source Json - status ExternalSourceStatus @default(CREATED) - externalData Json? - secret String? + workflows Workflow[] + type ExternalSourceType + key String + source Json + status ExternalSourceStatus @default(CREATED) + externalData Json? + secret String? + manualRegistration Boolean @default(false) readyAt DateTime? createdAt DateTime @default(now()) diff --git a/apps/webapp/public/integrations/Intercom.png b/apps/webapp/public/integrations/Intercom.png new file mode 100644 index 00000000000..9b5c1a26b28 Binary files /dev/null and b/apps/webapp/public/integrations/Intercom.png differ diff --git a/apps/webapp/public/integrations/Twitter.png b/apps/webapp/public/integrations/Twitter.png new file mode 100644 index 00000000000..dd631411286 Binary files /dev/null and b/apps/webapp/public/integrations/Twitter.png differ diff --git a/apps/webapp/public/integrations/Webhook.png b/apps/webapp/public/integrations/Webhook.png new file mode 100644 index 00000000000..83dade04ef8 Binary files /dev/null and b/apps/webapp/public/integrations/Webhook.png differ diff --git a/apps/webapp/public/integrations/airtable.png b/apps/webapp/public/integrations/airtable.png index c6340ff9353..1db66d919c4 100644 Binary files a/apps/webapp/public/integrations/airtable.png and b/apps/webapp/public/integrations/airtable.png differ diff --git a/apps/webapp/public/integrations/asana.png b/apps/webapp/public/integrations/asana.png new file mode 100644 index 00000000000..3df36914c85 Binary files /dev/null and b/apps/webapp/public/integrations/asana.png differ diff --git a/apps/webapp/public/integrations/discord.png b/apps/webapp/public/integrations/discord.png new file mode 100644 index 00000000000..f8cab81b3ff Binary files /dev/null and b/apps/webapp/public/integrations/discord.png differ diff --git a/apps/webapp/public/integrations/github.png b/apps/webapp/public/integrations/github.png index e22a2ef3b06..f84accb814a 100644 Binary files a/apps/webapp/public/integrations/github.png and b/apps/webapp/public/integrations/github.png differ diff --git a/apps/webapp/public/integrations/google-calendar.png b/apps/webapp/public/integrations/google-calendar.png new file mode 100644 index 00000000000..ecea8a31462 Binary files /dev/null and b/apps/webapp/public/integrations/google-calendar.png differ diff --git a/apps/webapp/public/integrations/google-docs.png b/apps/webapp/public/integrations/google-docs.png new file mode 100644 index 00000000000..4cfaee96921 Binary files /dev/null and b/apps/webapp/public/integrations/google-docs.png differ diff --git a/apps/webapp/public/integrations/google-sheets.png b/apps/webapp/public/integrations/google-sheets.png new file mode 100644 index 00000000000..aea70fc8b3e Binary files /dev/null and b/apps/webapp/public/integrations/google-sheets.png differ diff --git a/apps/webapp/public/integrations/hubspot.png b/apps/webapp/public/integrations/hubspot.png new file mode 100644 index 00000000000..9ac383c8b96 Binary files /dev/null and b/apps/webapp/public/integrations/hubspot.png differ diff --git a/apps/webapp/public/integrations/jira.png b/apps/webapp/public/integrations/jira.png new file mode 100644 index 00000000000..742d1f6456e Binary files /dev/null and b/apps/webapp/public/integrations/jira.png differ diff --git a/apps/webapp/public/integrations/linear.png b/apps/webapp/public/integrations/linear.png new file mode 100644 index 00000000000..590185879c5 Binary files /dev/null and b/apps/webapp/public/integrations/linear.png differ diff --git a/apps/webapp/public/integrations/pagerduty.png b/apps/webapp/public/integrations/pagerduty.png new file mode 100644 index 00000000000..34432a0437d Binary files /dev/null and b/apps/webapp/public/integrations/pagerduty.png differ diff --git a/apps/webapp/public/integrations/posthog.png b/apps/webapp/public/integrations/posthog.png new file mode 100644 index 00000000000..901d8991b09 Binary files /dev/null and b/apps/webapp/public/integrations/posthog.png differ diff --git a/apps/webapp/public/integrations/resend.png b/apps/webapp/public/integrations/resend.png index 22f076838ae..29e28c62030 100644 Binary files a/apps/webapp/public/integrations/resend.png and b/apps/webapp/public/integrations/resend.png differ diff --git a/apps/webapp/public/integrations/salesforce.png b/apps/webapp/public/integrations/salesforce.png new file mode 100644 index 00000000000..d4958e66a54 Binary files /dev/null and b/apps/webapp/public/integrations/salesforce.png differ diff --git a/apps/webapp/public/integrations/slack.png b/apps/webapp/public/integrations/slack.png index 4fe667121e9..82e8ae77a8d 100644 Binary files a/apps/webapp/public/integrations/slack.png and b/apps/webapp/public/integrations/slack.png differ diff --git a/apps/webapp/public/integrations/todoist.png b/apps/webapp/public/integrations/todoist.png new file mode 100644 index 00000000000..5ca0e581943 Binary files /dev/null and b/apps/webapp/public/integrations/todoist.png differ diff --git a/apps/webapp/public/integrations/trello.png b/apps/webapp/public/integrations/trello.png new file mode 100644 index 00000000000..fd829419bfd Binary files /dev/null and b/apps/webapp/public/integrations/trello.png differ diff --git a/apps/webapp/public/integrations/twilio.png b/apps/webapp/public/integrations/twilio.png new file mode 100644 index 00000000000..384bf77d1ac Binary files /dev/null and b/apps/webapp/public/integrations/twilio.png differ diff --git a/apps/webapp/public/integrations/typeform.png b/apps/webapp/public/integrations/typeform.png index 84f9fb5cd06..27c66e2897f 100644 Binary files a/apps/webapp/public/integrations/typeform.png and b/apps/webapp/public/integrations/typeform.png differ diff --git a/apps/webapp/public/integrations/whatsapp.png b/apps/webapp/public/integrations/whatsapp.png new file mode 100644 index 00000000000..01adff2cce5 Binary files /dev/null and b/apps/webapp/public/integrations/whatsapp.png differ diff --git a/apps/webapp/public/integrations/zendesk.png b/apps/webapp/public/integrations/zendesk.png new file mode 100644 index 00000000000..5a37032045b Binary files /dev/null and b/apps/webapp/public/integrations/zendesk.png differ diff --git a/apps/webapp/remix.config.js b/apps/webapp/remix.config.js index d6d8684e193..9861e581175 100644 --- a/apps/webapp/remix.config.js +++ b/apps/webapp/remix.config.js @@ -12,19 +12,26 @@ module.exports = { "axios", "internal-platform", "internal-integrations", + "internal-bridge", "@trigger.dev/providers", "@trigger.dev/common-schemas", + "@trigger.dev/sdk", + "@trigger.dev/integrations", "emails", "internal-pulsar", ], watchPaths: async () => { return [ "../../packages/internal-platform/src/**/*", + "../../packages/internal-bridge/src/**/*", "../../packages/common-schemas/src/**/*", "../../packages/internal-integrations/src/**/*", "../../packages/internal-providers/src/**/*", "../../packages/emails/src/**/*", "../../packages/internal-pulsar/src/**/*", + "../../packages/internal-platform/src/**/*", + "../../packages/trigger-sdk/src/**/*", + "../../packages/trigger-integrations/src/**/*", ]; }, }; diff --git a/apps/webapp/tsconfig.json b/apps/webapp/tsconfig.json index 7adff1bff6f..eca32087416 100644 --- a/apps/webapp/tsconfig.json +++ b/apps/webapp/tsconfig.json @@ -24,6 +24,8 @@ "../../packages/internal-integrations/src/index" ], "internal-integrations/*": ["../../packages/internal-integrations/src/*"], + "internal-bridge": ["../../packages/internal-bridge/src/index"], + "internal-bridge/*": ["../../packages/internal-bridge/src/*"], "@trigger.dev/sdk": ["../../packages/trigger-sdk/src/index"], "@trigger.dev/sdk/*": ["../../packages/trigger-sdk/src/*"], "@trigger.dev/integrations": [ @@ -32,12 +34,8 @@ "@trigger.dev/integrations/*": [ "../../packages/trigger-integrations/src/*" ], - "@trigger.dev/providers": [ - "../../packages/trigger-providers/src/index" - ], - "@trigger.dev/providers/*": [ - "../../packages/trigger-providers/src/*" - ], + "@trigger.dev/providers": ["../../packages/trigger-providers/src/index"], + "@trigger.dev/providers/*": ["../../packages/trigger-providers/src/*"], "@trigger.dev/common-schemas": [ "../../packages/common-schemas/src/index" ], diff --git a/examples/fetch-playground/src/index.ts b/examples/fetch-playground/src/index.ts index e54a082598b..9a2f2789f1d 100644 --- a/examples/fetch-playground/src/index.ts +++ b/examples/fetch-playground/src/index.ts @@ -1,9 +1,8 @@ -import { Trigger, customEvent } from "@trigger.dev/sdk"; +import { slack } from "@trigger.dev/integrations"; +import { Trigger, customEvent, webhookEvent } from "@trigger.dev/sdk"; import { z } from "zod"; -const TOKEN = "abc123"; - new Trigger({ id: "fetch-playground", name: "Fetch Playground", @@ -20,6 +19,18 @@ new Trigger({ .default("GET"), headers: z.record(z.string()).optional(), body: z.any().optional(), + retry: z + .object({ + enabled: z.boolean().default(true), + maxAttempts: z.number().default(3), + minTimeout: z.number().default(1000), + maxTimeout: z.number().default(60000), + factor: z.number().default(1.8), + statusCodes: z + .array(z.number()) + .default([408, 429, 500, 502, 503, 504]), + }) + .optional(), }), }), run: async (event, ctx) => { @@ -33,6 +44,7 @@ new Trigger({ responseSchema: z.any(), headers: event.headers, body: event.body ? JSON.stringify(event.body) : undefined, + retry: event.retry, }); await ctx.logger.info("Received the fetch response", { @@ -41,3 +53,106 @@ new Trigger({ }); }, }).listen(); + +export const bookingPayloadSchema = z.object({ + triggerEvent: z.string(), + createdAt: z.coerce.date(), + payload: z.object({ + type: z.string(), + title: z.string(), + description: z.string(), + additionalNotes: z.string(), + customInputs: z.object({}), + startTime: z.coerce.date(), + endTime: z.coerce.date(), + organizer: z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + timeZone: z.string(), + language: z.object({ locale: z.string() }), + }), + attendees: z.array( + z.object({ + email: z.string(), + name: z.string(), + timeZone: z.string(), + language: z.object({ locale: z.string() }), + }) + ), + location: z.string(), + destinationCalendar: z.object({ + id: z.number(), + integration: z.string(), + externalId: z.string(), + userId: z.number(), + eventTypeId: z.null(), + credentialId: z.number(), + }), + hideCalendarNotes: z.boolean(), + requiresConfirmation: z.null(), + eventTypeId: z.number(), + seatsShowAttendees: z.boolean(), + uid: z.string(), + conferenceData: z.object({ + createRequest: z.object({ requestId: z.string() }), + }), + videoCallData: z.object({ + type: z.string(), + id: z.string(), + password: z.string(), + url: z.string(), + }), + appsStatus: z.array( + z.object({ + appName: z.string(), + type: z.string(), + success: z.number(), + failures: z.number(), + errors: z.array(z.any()).optional(), + warnings: z.array(z.any()).optional(), + }) + ), + eventTitle: z.string(), + eventDescription: z.null(), + price: z.number(), + currency: z.string(), + length: z.number(), + bookingId: z.number(), + metadata: z.object({ videoCallUrl: z.string() }), + status: z.string(), + }), +}); + +new Trigger({ + id: "caldotcom-to-slack-2", + name: "Cal.com To Slack", + apiKey: "trigger_dev_zC25mKNn6c0q", + endpoint: "ws://localhost:8889/ws", + logLevel: "debug", + on: webhookEvent({ + service: "cal.com", + eventName: "BOOKING_CREATED", + filter: { + triggerEvent: ["BOOKING_CREATED"], + }, + schema: bookingPayloadSchema, + verifyPayload: { + enabled: true, + header: "X-Cal-Signature-256", + }, + }), + run: async (event, ctx) => { + await ctx.logger.info("Received a cal.com booking", { + event, + wallTime: new Date(), + }); + + await slack.postMessage(`Cal.com booking yo`, { + channelName: "customers", + text: `New Booking: ${ + event.payload.title + } at ${event.payload.startTime.toLocaleDateString()}`, + }); + }, +}).listen(); diff --git a/flightcontrol.json b/flightcontrol.json index 2e3102f414f..c5f096a2e11 100644 --- a/flightcontrol.json +++ b/flightcontrol.json @@ -62,10 +62,11 @@ "packages/internal-pulsar/src/**", "./pnpm-lock.yaml" ], - "dependsOn": [ - "p-db" - ], + "dependsOn": ["p-db"], "envVariables": { + "TRIGGER_API_KEY": { + "fromParameterStore": "/Prod/webapp/TRIGGER_API_KEY" + }, "FROM_EMAIL": "hello@email.trigger.dev", "REPLY_TO_EMAIL": "hello@trigger.dev", "RESEND_API_KEY": { @@ -103,6 +104,9 @@ "PIZZLY_SECRET_KEY": { "fromParameterStore": "/Prod/webapp/PIZZLY_SECRET_KEY" }, + "POSTHOG_PROJECT_KEY": { + "fromParameterStore": "/Prod/webapp/POSTHOG_PROJECT_KEY" + }, "TRIGGER_LOG_LEVEL": "debug", "PULSAR_ENABLED": "1", "PULSAR_DEBUG": true @@ -234,9 +238,7 @@ "packages/internal-pulsar/src/**", "./pnpm-lock.yaml" ], - "dependsOn": [ - "s-db" - ], + "dependsOn": ["s-db"], "envVariables": { "FROM_EMAIL": "hello@email.trigger.dev", "REPLY_TO_EMAIL": "hello@trigger.dev", @@ -346,4 +348,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/packages/common-schemas/src/events.ts b/packages/common-schemas/src/events.ts index 80b1fe66db2..26805d54492 100644 --- a/packages/common-schemas/src/events.ts +++ b/packages/common-schemas/src/events.ts @@ -62,3 +62,11 @@ export const ScheduleSourceSchema = z.union([ ]); export type ScheduleSource = z.infer; + +export const ManualWebhookSourceSchema = z.object({ + verifyPayload: z.object({ + enabled: z.boolean(), + header: z.string().optional(), + }), + event: z.string(), +}); diff --git a/packages/common-schemas/src/fetch.ts b/packages/common-schemas/src/fetch.ts index dab1dd770a9..1a8bf56bc93 100644 --- a/packages/common-schemas/src/fetch.ts +++ b/packages/common-schemas/src/fetch.ts @@ -8,6 +8,15 @@ export const SecureStringSchema = z.object({ export type SecureString = z.infer; +export const RetrySchema = z.object({ + enabled: z.boolean().default(true), + factor: z.number().default(1.8), + maxTimeout: z.number().default(60000), + minTimeout: z.number().default(1000), + maxAttempts: z.number().default(10), + statusCodes: z.array(z.number()).default([408, 429, 500, 502, 503, 504]), +}); + export const FetchRequestSchema = z.object({ url: z.string(), headers: z.record(z.union([z.string(), SecureStringSchema])).optional(), @@ -22,6 +31,7 @@ export const FetchRequestSchema = z.object({ "TRACE", ]), body: z.any(), + retry: RetrySchema.optional(), }); export const FetchOutputSchema = z.object({ diff --git a/packages/common-schemas/src/triggers.ts b/packages/common-schemas/src/triggers.ts index 81611d59172..539adffa4fe 100644 --- a/packages/common-schemas/src/triggers.ts +++ b/packages/common-schemas/src/triggers.ts @@ -15,7 +15,8 @@ export const WebhookEventTriggerSchema = z.object({ service: z.string(), name: z.string(), filter: EventFilterSchema, - source: JsonSchema, + source: JsonSchema.optional(), + manualRegistration: z.boolean().default(false), }); export type WebhookEventTrigger = z.infer; diff --git a/packages/internal-bridge/src/schemas/server.ts b/packages/internal-bridge/src/schemas/server.ts index 3d6d27bc6a1..4f86e838e9d 100644 --- a/packages/internal-bridge/src/schemas/server.ts +++ b/packages/internal-bridge/src/schemas/server.ts @@ -1,6 +1,7 @@ import { CustomEventSchema, FetchRequestSchema, + RetrySchema, TriggerMetadataSchema, WaitSchema, } from "@trigger.dev/common-schemas"; diff --git a/packages/internal-bridge/src/zodRPC.ts b/packages/internal-bridge/src/zodRPC.ts index 96cf468686d..0da7f147a65 100644 --- a/packages/internal-bridge/src/zodRPC.ts +++ b/packages/internal-bridge/src/zodRPC.ts @@ -117,8 +117,10 @@ export class ZodRPC< public send( key: K, - data: z.infer + data: z.input ) { + this.#logger.debug("Sending call", { key, data }); + const id = generateStableId(this.#connection.id, key as string, data); const message = packageMessage({ id, methodName: key as string, data }); diff --git a/packages/internal-integrations/src/types.ts b/packages/internal-integrations/src/types.ts index 40218736881..c9006a3159b 100644 --- a/packages/internal-integrations/src/types.ts +++ b/packages/internal-integrations/src/types.ts @@ -13,6 +13,7 @@ export interface WebhookConfig { } export interface NormalizedRequest { + rawBody: string; body: any; headers: Record; searchParams: URLSearchParams; diff --git a/packages/internal-platform/src/messages/schemas/fetchRequests.ts b/packages/internal-platform/src/messages/schemas/fetchRequests.ts index b60d1c07b3a..7e1a354240e 100644 --- a/packages/internal-platform/src/messages/schemas/fetchRequests.ts +++ b/packages/internal-platform/src/messages/schemas/fetchRequests.ts @@ -2,6 +2,7 @@ import { FetchOutputSchema, FetchRequestSchema, JsonSchema, + RetrySchema, } from "@trigger.dev/common-schemas"; import { z } from "zod"; import { diff --git a/packages/trigger-integrations/src/integrations/github/events.ts b/packages/trigger-integrations/src/integrations/github/events.ts index 985d5c28e98..1ce94e1e861 100644 --- a/packages/trigger-integrations/src/integrations/github/events.ts +++ b/packages/trigger-integrations/src/integrations/github/events.ts @@ -26,6 +26,7 @@ export function commitCommentEvent(params: { repo: params.repo, events: ["commit_comment"], }), + manualRegistration: false, }, schema: github.schemas.commitComments.commitCommentEventSchema, }; @@ -54,6 +55,7 @@ export function issueEvent(params: { repo: params.repo, events: ["issues"], }), + manualRegistration: false, }, schema: github.schemas.issues.issuesEventSchema, }; @@ -82,6 +84,7 @@ export function issueCommentEvent(params: { repo: params.repo, events: ["issue_comment"], }), + manualRegistration: false, }, schema: github.schemas.issuesComments.issueCommentEventSchema, }; @@ -110,6 +113,7 @@ export function pullRequestEvent(params: { repo: params.repo, events: ["pull_request"], }), + manualRegistration: false, }, schema: github.schemas.pullRequest.pullRequestEventSchema, }; @@ -140,6 +144,7 @@ export function pullRequestCommentEvent(params: { repo: params.repo, events: ["pull_request_review_comment"], }), + manualRegistration: false, }, schema: github.schemas.pullRequestComments.pullRequestReviewCommentEventSchema, @@ -171,6 +176,7 @@ export function pullRequestReviewEvent(params: { repo: params.repo, events: ["pull_request_review"], }), + manualRegistration: false, }, schema: github.schemas.pullRequestReviews.pullRequestReviewEventSchema, }; @@ -199,6 +205,7 @@ export function pushEvent(params: { repo: params.repo, events: ["push"], }), + manualRegistration: false, }, schema: github.schemas.push.pushEventSchema, }; @@ -228,6 +235,7 @@ export function newStarEvent(params: { repo: params.repo, events: ["star"], }), + manualRegistration: false, }, schema: github.schemas.stars.starCreatedEventSchema, }; diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index b44973134e5..786daf09e33 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -23,6 +23,7 @@ "@types/debug": "^4.1.7", "@types/node": "16", "@types/node-fetch": "2.6.x", + "@types/slug": "^5.0.3", "@types/uuid": "^9.0.0", "@types/ws": "^8.5.3", "internal-bridge": "workspace:*", @@ -39,6 +40,7 @@ "debug": "^4.3.4", "evt": "^2.4.13", "node-fetch": "2.6.x", + "slug": "^6.0.0", "ulid": "^2.3.0", "uuid": "^9.0.0", "ws": "^8.11.0", diff --git a/packages/trigger-sdk/src/client.ts b/packages/trigger-sdk/src/client.ts index b126dce12de..ae181af679f 100644 --- a/packages/trigger-sdk/src/client.ts +++ b/packages/trigger-sdk/src/client.ts @@ -335,6 +335,7 @@ export class TriggerClient { method: options.method ?? "GET", headers: options.headers, body: options.body, + retry: options.retry, }, timestamp: String(highPrecisionTimestamp()), }); @@ -508,7 +509,14 @@ export class TriggerClient { }; } - console.error(anyError); + const parsedError = z + .object({ name: z.string(), message: z.string() }) + .passthrough() + .safeParse(error); + + if (parsedError.success) { + return parsedError.data; + } return { name: "UnknownError", diff --git a/packages/trigger-sdk/src/events.ts b/packages/trigger-sdk/src/events.ts index f0659c2e86d..5cf8b3cab4b 100644 --- a/packages/trigger-sdk/src/events.ts +++ b/packages/trigger-sdk/src/events.ts @@ -3,8 +3,10 @@ import { TriggerMetadataSchema, ScheduleSourceSchema, ScheduledEventPayloadSchema, + EventFilter, } from "@trigger.dev/common-schemas"; import { z } from "zod"; +import slugify from "slug"; export type EventRule = z.infer; @@ -16,6 +18,7 @@ export type TriggerEvent = { export type TriggerCustomEventOptions = { name: string; schema: TSchema; + filter?: EventFilter; }; export function customEvent( @@ -26,7 +29,7 @@ export function customEvent( type: "CUSTOM_EVENT", service: "trigger", name: options.name, - filter: { event: [options.name] }, + filter: { event: [options.name], payload: options.filter ?? {} }, }, schema: options.schema, }; @@ -47,3 +50,37 @@ export function scheduleEvent( schema: ScheduledEventPayloadSchema, }; } + +export type TriggerWebhookEventOptions = { + schema: TSchema; + service: string; + eventName: string; + filter?: EventFilter; + verifyPayload?: { + enabled: boolean; + header: string; + }; +}; + +export function webhookEvent( + options: TriggerWebhookEventOptions +): TriggerEvent { + return { + metadata: { + type: "WEBHOOK", + service: slugify(options.service), + name: options.eventName, + filter: { + service: [slugify(options.service)], + payload: options.filter ?? {}, + event: [options.eventName], + }, + source: { + verifyPayload: options.verifyPayload ?? { enabled: false }, + event: options.eventName, + }, + manualRegistration: true, + }, + schema: options.schema, + }; +} diff --git a/packages/trigger-sdk/src/localStorage.ts b/packages/trigger-sdk/src/localStorage.ts index 4f3de2402b6..2a61f417719 100644 --- a/packages/trigger-sdk/src/localStorage.ts +++ b/packages/trigger-sdk/src/localStorage.ts @@ -1,11 +1,6 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { z } from "zod"; -import { - FetchOptions, - FetchResponse, - TriggerCustomEvent, - TriggerFetch, -} from "./types"; +import { TriggerCustomEvent, TriggerFetch } from "./types"; type PerformRequestOptions = { service: string; diff --git a/packages/trigger-sdk/src/types.ts b/packages/trigger-sdk/src/types.ts index 8089c10e088..075d94e717d 100644 --- a/packages/trigger-sdk/src/types.ts +++ b/packages/trigger-sdk/src/types.ts @@ -31,6 +31,14 @@ export type FetchOptions< body?: z.infer; headers?: Record; responseSchema?: TResponseBodySchema; + retry?: { + enabled?: boolean; + factor?: number; + maxTimeout?: number; + minTimeout?: number; + maxAttempts?: number; + statusCodes?: number[]; + }; }; export type FetchResponse< diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba2fc009ae1..99fa5003653 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,9 +31,9 @@ importers: apps/docs: specifiers: - mintlify: ^2.0.14 + mintlify: ^2.0.15 devDependencies: - mintlify: 2.0.14 + mintlify: 2.0.15 apps/webapp: specifiers: @@ -75,7 +75,9 @@ importers: '@testing-library/react': ^13.4.0 '@testing-library/user-event': ^14.4.3 '@trigger.dev/common-schemas': workspace:* + '@trigger.dev/integrations': workspace:* '@trigger.dev/providers': workspace:* + '@trigger.dev/sdk': workspace:* '@trigger.dev/tailwind-config': '*' '@types/bcryptjs': ^2.4.2 '@types/compression': ^1.7.2 @@ -122,6 +124,7 @@ importers: glob: ^8.0.3 happy-dom: ^6.0.4 humanize-duration: ^3.27.3 + internal-bridge: workspace:* internal-integrations: workspace:* internal-platform: workspace:* internal-pulsar: workspace:* @@ -176,6 +179,7 @@ importers: vite-tsconfig-paths: ^3.5.1 vitest: ^0.23.4 zod: ^3.20.2 + zod-error: ^1.1.0 dependencies: '@aws-sdk/client-s3': 3.245.0 '@aws-sdk/s3-request-presigner': 3.245.0 @@ -204,7 +208,9 @@ importers: '@tailwindcss/forms': 0.5.3_tailwindcss@3.1.8 '@tanstack/react-table': 8.7.6_biqbaboplfbrettd7655fr4n2y '@trigger.dev/common-schemas': link:../../packages/common-schemas + '@trigger.dev/integrations': link:../../packages/trigger-integrations '@trigger.dev/providers': link:../../packages/trigger-providers + '@trigger.dev/sdk': link:../../packages/trigger-sdk '@uiw/react-codemirror': 4.19.5_aguurb4bmecpxzejz52amioxne bcryptjs: 2.4.3 classnames: 2.3.2 @@ -219,6 +225,7 @@ importers: emails: link:../../packages/emails express: 4.18.2 humanize-duration: 3.27.3 + internal-bridge: link:../../packages/internal-bridge internal-integrations: link:../../packages/internal-integrations internal-platform: link:../../packages/internal-platform internal-pulsar: link:../../packages/internal-pulsar @@ -257,6 +264,7 @@ importers: tsx: 3.12.2 ulid: 2.3.0 zod: 3.20.2 + zod-error: 1.1.0 devDependencies: '@faker-js/faker': 7.6.0 '@remix-run/dev': 1.10.0_biqbaboplfbrettd7655fr4n2y @@ -810,6 +818,7 @@ importers: '@types/debug': ^4.1.7 '@types/node': '16' '@types/node-fetch': 2.6.x + '@types/slug': ^5.0.3 '@types/uuid': ^9.0.0 '@types/ws': ^8.5.3 debug: ^4.3.4 @@ -817,6 +826,7 @@ importers: internal-bridge: workspace:* node-fetch: 2.6.x rimraf: ^3.0.2 + slug: ^6.0.0 tsup: ^6.5.0 tsx: ^3.12.1 ulid: ^2.3.0 @@ -828,6 +838,7 @@ importers: debug: 4.3.4 evt: 2.4.13 node-fetch: 2.6.7 + slug: 6.1.0 ulid: 2.3.0 uuid: 9.0.0 ws: 8.12.0 @@ -839,6 +850,7 @@ importers: '@types/debug': 4.1.7 '@types/node': 16.18.11 '@types/node-fetch': 2.6.2 + '@types/slug': 5.0.3 '@types/uuid': 9.0.0 '@types/ws': 8.5.4 internal-bridge: link:../internal-bridge @@ -6521,7 +6533,7 @@ packages: /axios/0.25.0_debug@4.3.4: resolution: {integrity: sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==} dependencies: - follow-redirects: 1.15.2 + follow-redirects: 1.15.2_debug@4.3.4 transitivePeerDependencies: - debug dev: true @@ -6529,7 +6541,7 @@ packages: /axios/0.27.2: resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} dependencies: - follow-redirects: 1.15.2 + follow-redirects: 1.15.2_debug@4.3.4 form-data: 4.0.0 transitivePeerDependencies: - debug @@ -6538,7 +6550,7 @@ packages: /axios/1.2.2: resolution: {integrity: sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q==} dependencies: - follow-redirects: 1.15.2 + follow-redirects: 1.15.2_debug@4.3.4 form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -9637,7 +9649,7 @@ packages: engines: {node: '>=0.4.0'} dev: true - /follow-redirects/1.15.2: + /follow-redirects/1.15.2_debug@4.3.4: resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} engines: {node: '>=4.0'} peerDependencies: @@ -9645,6 +9657,8 @@ packages: peerDependenciesMeta: debug: optional: true + dependencies: + debug: 4.3.4 /for-each/0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -9753,6 +9767,15 @@ packages: universalify: 2.0.0 dev: true + /fs-extra/11.1.0: + resolution: {integrity: sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==} + engines: {node: '>=14.14'} + dependencies: + graceful-fs: 4.2.10 + jsonfile: 6.1.0 + universalify: 2.0.0 + dev: true + /fs-extra/7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -12514,8 +12537,8 @@ packages: yallist: 4.0.0 dev: true - /mintlify/2.0.14: - resolution: {integrity: sha512-Xe6lE5w5hJKW70s1J+//CUzpH2QMhKF4sHMbxeA4baBIrtyQGdWfgeFjLaZrgXS/ghkQ16NU0Qtqs4EdilgtJg==} + /mintlify/2.0.15: + resolution: {integrity: sha512-GM/rXnEJunAVRsHbk/3utKgF0BO7l9wj6z3VH0Csp18sRKlb7GjucxeVzUX+jXPoKC+t9JX02WbQg28MHJ/etA==} engines: {node: '>=18.0.0'} hasBin: true dependencies: @@ -12527,7 +12550,7 @@ packages: cheerio: 0.22.0 chokidar: 3.5.3 favicons: 7.0.2 - fs-extra: 10.1.0 + fs-extra: 11.1.0 gray-matter: 4.0.3 inquirer: 9.1.4 is-absolute-url: 4.0.1