Skip to content

Commit be81001

Browse files
feat(native-bg-tasks): support webhooks and async workflow executions without trigger.dev (#1106)
* feat(native-bg-tasks): support webhooks and async workflow executions without trigger" * fix tests * fix env var defaults and revert async workflow execution to always use trigger * fix UI for hiding async * hide entire toggle
1 parent 1ee4263 commit be81001

File tree

8 files changed

+617
-571
lines changed

8 files changed

+617
-571
lines changed

apps/sim/app/api/__test-utils__/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,18 @@ export function mockExecutionDependencies() {
354354
}))
355355
}
356356

357+
/**
358+
* Mock Trigger.dev SDK (tasks.trigger and task factory) for tests that import background modules
359+
*/
360+
export function mockTriggerDevSdk() {
361+
vi.mock('@trigger.dev/sdk', () => ({
362+
tasks: {
363+
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
364+
},
365+
task: vi.fn().mockReturnValue({}),
366+
}))
367+
}
368+
357369
export function mockWorkflowAccessValidation(shouldSucceed = true) {
358370
if (shouldSucceed) {
359371
vi.mock('@/app/api/workflows/middleware', () => ({

apps/sim/app/api/webhooks/trigger/[path]/route.test.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,22 @@ import { NextRequest } from 'next/server'
55
* @vitest-environment node
66
*/
77
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8-
import { createMockRequest, mockExecutionDependencies } from '@/app/api/__test-utils__/utils'
8+
import {
9+
createMockRequest,
10+
mockExecutionDependencies,
11+
mockTriggerDevSdk,
12+
} from '@/app/api/__test-utils__/utils'
13+
14+
// Prefer mocking the background module to avoid loading Trigger.dev at all during tests
15+
vi.mock('@/background/webhook-execution', () => ({
16+
executeWebhookJob: vi.fn().mockResolvedValue({
17+
success: true,
18+
workflowId: 'test-workflow-id',
19+
executionId: 'test-exec-id',
20+
output: {},
21+
executedAt: new Date().toISOString(),
22+
}),
23+
}))
924

1025
const hasProcessedMessageMock = vi.fn().mockResolvedValue(false)
1126
const markMessageAsProcessedMock = vi.fn().mockResolvedValue(true)
@@ -111,6 +126,7 @@ describe('Webhook Trigger API Route', () => {
111126
vi.resetAllMocks()
112127

113128
mockExecutionDependencies()
129+
mockTriggerDevSdk()
114130

115131
vi.doMock('@/services/queue', () => ({
116132
RateLimiter: vi.fn().mockImplementation(() => ({
@@ -309,11 +325,7 @@ describe('Webhook Trigger API Route', () => {
309325
const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
310326
const params = Promise.resolve({ path: 'test-path' })
311327

312-
vi.doMock('@trigger.dev/sdk', () => ({
313-
tasks: {
314-
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
315-
},
316-
}))
328+
mockTriggerDevSdk()
317329

318330
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
319331
const response = await POST(req, { params })
@@ -339,11 +351,7 @@ describe('Webhook Trigger API Route', () => {
339351
const req = createMockRequest('POST', { event: 'bearer.test' }, headers)
340352
const params = Promise.resolve({ path: 'test-path' })
341353

342-
vi.doMock('@trigger.dev/sdk', () => ({
343-
tasks: {
344-
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
345-
},
346-
}))
354+
mockTriggerDevSdk()
347355

348356
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
349357
const response = await POST(req, { params })
@@ -369,11 +377,7 @@ describe('Webhook Trigger API Route', () => {
369377
const req = createMockRequest('POST', { event: 'custom.header.test' }, headers)
370378
const params = Promise.resolve({ path: 'test-path' })
371379

372-
vi.doMock('@trigger.dev/sdk', () => ({
373-
tasks: {
374-
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
375-
},
376-
}))
380+
mockTriggerDevSdk()
377381

378382
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
379383
const response = await POST(req, { params })

apps/sim/app/api/webhooks/trigger/[path]/route.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { tasks } from '@trigger.dev/sdk'
22
import { and, eq } from 'drizzle-orm'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { checkServerSideUsageLimits } from '@/lib/billing'
5+
import { env, isTruthy } from '@/lib/env'
56
import { createLogger } from '@/lib/logs/console/logger'
67
import {
78
handleSlackChallenge,
89
handleWhatsAppVerification,
910
validateMicrosoftTeamsSignature,
1011
} from '@/lib/webhooks/utils'
12+
import { executeWebhookJob } from '@/background/webhook-execution'
1113
import { db } from '@/db'
1214
import { subscription, webhook, workflow } from '@/db/schema'
1315
import { RateLimiter } from '@/services/queue'
@@ -17,6 +19,7 @@ const logger = createLogger('WebhookTriggerAPI')
1719

1820
export const dynamic = 'force-dynamic'
1921
export const maxDuration = 300
22+
export const runtime = 'nodejs'
2023

2124
/**
2225
* Webhook Verification Handler (GET)
@@ -330,10 +333,9 @@ export async function POST(
330333
// Continue processing - better to risk usage limit bypass than fail webhook
331334
}
332335

333-
// --- PHASE 5: Queue webhook execution via trigger.dev ---
336+
// --- PHASE 5: Queue webhook execution (trigger.dev or direct based on env) ---
334337
try {
335-
// Queue the webhook execution task
336-
const handle = await tasks.trigger('webhook-execution', {
338+
const payload = {
337339
webhookId: foundWebhook.id,
338340
workflowId: foundWorkflow.id,
339341
userId: foundWorkflow.userId,
@@ -342,11 +344,24 @@ export async function POST(
342344
headers: Object.fromEntries(request.headers.entries()),
343345
path,
344346
blockId: foundWebhook.blockId,
345-
})
347+
}
346348

347-
logger.info(
348-
`[${requestId}] Queued webhook execution task ${handle.id} for ${foundWebhook.provider} webhook`
349-
)
349+
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
350+
351+
if (useTrigger) {
352+
const handle = await tasks.trigger('webhook-execution', payload)
353+
logger.info(
354+
`[${requestId}] Queued webhook execution task ${handle.id} for ${foundWebhook.provider} webhook`
355+
)
356+
} else {
357+
// Fire-and-forget direct execution to avoid blocking webhook response
358+
void executeWebhookJob(payload).catch((error) => {
359+
logger.error(`[${requestId}] Direct webhook execution failed`, error)
360+
})
361+
logger.info(
362+
`[${requestId}] Queued direct webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)`
363+
)
364+
}
350365

351366
// Return immediate acknowledgment with provider-specific format
352367
if (foundWebhook.provider === 'microsoftteams') {

apps/sim/app/api/workflows/[id]/execute/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,7 @@ export async function POST(
540540
)
541541
}
542542

543-
// Rate limit passed - trigger the task
543+
// Rate limit passed - always use Trigger.dev for async executions
544544
const handle = await tasks.trigger('workflow-execution', {
545545
workflowId,
546546
userId: authenticatedUserId,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx

Lines changed: 63 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
DropdownMenuTrigger,
1212
} from '@/components/ui/dropdown-menu'
1313
import { Label } from '@/components/ui/label'
14+
import { getEnv, isTruthy } from '@/lib/env'
1415

1516
interface ExampleCommandProps {
1617
command: string
@@ -32,6 +33,7 @@ export function ExampleCommand({
3233
}: ExampleCommandProps) {
3334
const [mode, setMode] = useState<ExampleMode>('sync')
3435
const [exampleType, setExampleType] = useState<ExampleType>('execute')
36+
const isAsyncEnabled = isTruthy(getEnv('NEXT_PUBLIC_TRIGGER_DEV_ENABLED'))
3537

3638
// Format the curl command to use a placeholder for the API key
3739
const formatCurlCommand = (command: string, apiKey: string) => {
@@ -146,62 +148,67 @@ export function ExampleCommand({
146148
<div className='space-y-1.5'>
147149
<div className='flex items-center justify-between'>
148150
{showLabel && <Label className='font-medium text-sm'>Example</Label>}
149-
<div className='flex items-center gap-1'>
150-
<Button
151-
variant='outline'
152-
size='sm'
153-
onClick={() => setMode('sync')}
154-
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
155-
mode === 'sync'
156-
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
157-
: ''
158-
}`}
159-
>
160-
Sync
161-
</Button>
162-
<Button
163-
variant='outline'
164-
size='sm'
165-
onClick={() => setMode('async')}
166-
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
167-
mode === 'async'
168-
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
169-
: ''
170-
}`}
171-
>
172-
Async
173-
</Button>
174-
<DropdownMenu>
175-
<DropdownMenuTrigger asChild>
176-
<Button
177-
variant='outline'
178-
size='sm'
179-
className='h-6 min-w-[140px] justify-between px-2 py-1 text-xs'
180-
disabled={mode === 'sync'}
181-
>
182-
<span className='truncate'>{getExampleTitle()}</span>
183-
<ChevronDown className='ml-1 h-3 w-3 flex-shrink-0' />
184-
</Button>
185-
</DropdownMenuTrigger>
186-
<DropdownMenuContent align='end'>
187-
<DropdownMenuItem
188-
className='cursor-pointer'
189-
onClick={() => setExampleType('execute')}
190-
>
191-
Async Execution
192-
</DropdownMenuItem>
193-
<DropdownMenuItem className='cursor-pointer' onClick={() => setExampleType('status')}>
194-
Check Job Status
195-
</DropdownMenuItem>
196-
<DropdownMenuItem
197-
className='cursor-pointer'
198-
onClick={() => setExampleType('rate-limits')}
199-
>
200-
Rate Limits & Usage
201-
</DropdownMenuItem>
202-
</DropdownMenuContent>
203-
</DropdownMenu>
204-
</div>
151+
{isAsyncEnabled && (
152+
<div className='flex items-center gap-1'>
153+
<Button
154+
variant='outline'
155+
size='sm'
156+
onClick={() => setMode('sync')}
157+
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
158+
mode === 'sync'
159+
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
160+
: ''
161+
}`}
162+
>
163+
Sync
164+
</Button>
165+
<Button
166+
variant='outline'
167+
size='sm'
168+
onClick={() => setMode('async')}
169+
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
170+
mode === 'async'
171+
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
172+
: ''
173+
}`}
174+
>
175+
Async
176+
</Button>
177+
<DropdownMenu>
178+
<DropdownMenuTrigger asChild>
179+
<Button
180+
variant='outline'
181+
size='sm'
182+
className='h-6 min-w-[140px] justify-between px-2 py-1 text-xs'
183+
disabled={mode === 'sync'}
184+
>
185+
<span className='truncate'>{getExampleTitle()}</span>
186+
<ChevronDown className='ml-1 h-3 w-3 flex-shrink-0' />
187+
</Button>
188+
</DropdownMenuTrigger>
189+
<DropdownMenuContent align='end'>
190+
<DropdownMenuItem
191+
className='cursor-pointer'
192+
onClick={() => setExampleType('execute')}
193+
>
194+
Async Execution
195+
</DropdownMenuItem>
196+
<DropdownMenuItem
197+
className='cursor-pointer'
198+
onClick={() => setExampleType('status')}
199+
>
200+
Check Job Status
201+
</DropdownMenuItem>
202+
<DropdownMenuItem
203+
className='cursor-pointer'
204+
onClick={() => setExampleType('rate-limits')}
205+
>
206+
Rate Limits & Usage
207+
</DropdownMenuItem>
208+
</DropdownMenuContent>
209+
</DropdownMenu>
210+
</div>
211+
)}
205212
</div>
206213

207214
<div className='group relative h-[120px] rounded-md border bg-background transition-colors hover:bg-muted/50'>

0 commit comments

Comments
 (0)