Skip to content

Commit 36612ae

Browse files
v0.5.111: non-polling webhook execs off trigger.dev, gmail subject headers, webhook trigger configs (#3530)
2 parents 1c2c2c6 + 68d207d commit 36612ae

File tree

20 files changed

+252
-26
lines changed

20 files changed

+252
-26
lines changed

apps/sim/app/api/webhooks/route.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -367,9 +367,7 @@ export async function POST(request: NextRequest) {
367367
)
368368
}
369369

370-
// Configure each new webhook (for providers that need configuration)
371-
const pollingProviders = ['gmail', 'outlook']
372-
const needsConfiguration = pollingProviders.includes(provider)
370+
const needsConfiguration = provider === 'gmail' || provider === 'outlook'
373371

374372
if (needsConfiguration) {
375373
const configureFunc =

apps/sim/blocks/blocks/generic_webhook.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const GenericWebhookBlock: BlockConfig = {
1818
bestPractices: `
1919
- You can test the webhook by sending a request to the webhook URL. E.g. depending on authorization: curl -X POST http://localhost:3000/api/webhooks/trigger/d8abcf0d-1ee5-4b77-bb07-b1e8142ea4e9 -H "Content-Type: application/json" -H "X-Sim-Secret: 1234" -d '{"message": "Test webhook trigger", "data": {"key": "v"}}'
2020
- Continuing example above, the body can be accessed in downstream block using dot notation. E.g. <webhook1.message> and <webhook1.data.key>
21+
- To deduplicate incoming events, set the Deduplication Field to a dot-notation path of a unique field in the payload (e.g. "event.id"). Duplicate values within 7 days will be skipped.
2122
- Only use when there's no existing integration for the service with triggerAllowed flag set to true.
2223
`,
2324
subBlocks: [...getTrigger('generic_webhook').subBlocks],

apps/sim/executor/handlers/trigger/trigger-handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export class TriggerBlockHandler implements BlockHandler {
2222
}
2323

2424
const existingState = ctx.blockStates.get(block.id)
25-
if (existingState?.output && Object.keys(existingState.output).length > 0) {
25+
if (existingState?.output) {
2626
return existingState.output
2727
}
2828

apps/sim/lib/core/async-jobs/config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const logger = createLogger('AsyncJobsConfig')
77

88
let cachedBackend: JobQueueBackend | null = null
99
let cachedBackendType: AsyncBackendType | null = null
10+
let cachedInlineBackend: JobQueueBackend | null = null
1011

1112
/**
1213
* Determines which async backend to use based on environment configuration.
@@ -71,6 +72,31 @@ export function getCurrentBackendType(): AsyncBackendType | null {
7172
return cachedBackendType
7273
}
7374

75+
/**
76+
* Gets a job queue backend that bypasses Trigger.dev (Redis -> Database).
77+
* Used for non-polling webhooks that should always execute inline.
78+
*/
79+
export async function getInlineJobQueue(): Promise<JobQueueBackend> {
80+
if (cachedInlineBackend) {
81+
return cachedInlineBackend
82+
}
83+
84+
const redis = getRedisClient()
85+
let type: string
86+
if (redis) {
87+
const { RedisJobQueue } = await import('@/lib/core/async-jobs/backends/redis')
88+
cachedInlineBackend = new RedisJobQueue(redis)
89+
type = 'redis'
90+
} else {
91+
const { DatabaseJobQueue } = await import('@/lib/core/async-jobs/backends/database')
92+
cachedInlineBackend = new DatabaseJobQueue()
93+
type = 'database'
94+
}
95+
96+
logger.info(`Inline job backend initialized: ${type}`)
97+
return cachedInlineBackend
98+
}
99+
74100
/**
75101
* Checks if jobs should be executed inline (fire-and-forget).
76102
* For Redis/DB backends, we execute inline. Trigger.dev handles execution itself.
@@ -85,4 +111,5 @@ export function shouldExecuteInline(): boolean {
85111
export function resetJobQueueCache(): void {
86112
cachedBackend = null
87113
cachedBackendType = null
114+
cachedInlineBackend = null
88115
}

apps/sim/lib/core/async-jobs/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export {
22
getAsyncBackendType,
33
getCurrentBackendType,
4+
getInlineJobQueue,
45
getJobQueue,
56
resetJobQueueCache,
67
shouldExecuteInline,

apps/sim/lib/core/idempotency/service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ export class IdempotencyService {
413413
: undefined
414414

415415
const webhookIdHeader =
416+
normalizedHeaders?.['x-sim-idempotency-key'] ||
416417
normalizedHeaders?.['webhook-id'] ||
417418
normalizedHeaders?.['x-webhook-id'] ||
418419
normalizedHeaders?.['x-shopify-webhook-id'] ||

apps/sim/lib/webhooks/processor.ts

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { and, eq, isNull, or } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { v4 as uuidv4 } from 'uuid'
77
import { checkEnterprisePlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
8-
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
8+
import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
99
import { isProd } from '@/lib/core/config/feature-flags'
1010
import { safeCompare } from '@/lib/core/security/encryption'
1111
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
@@ -29,6 +29,7 @@ import {
2929
import { executeWebhookJob } from '@/background/webhook-execution'
3030
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
3131
import { isConfluencePayloadMatch } from '@/triggers/confluence/utils'
32+
import { isPollingWebhookProvider } from '@/triggers/constants'
3233
import { isGitHubEventMatch } from '@/triggers/github/utils'
3334
import { isHubSpotContactEventMatch } from '@/triggers/hubspot/utils'
3435
import { isJiraEventMatch } from '@/triggers/jira/utils'
@@ -1049,7 +1050,7 @@ export async function queueWebhookExecution(
10491050
}
10501051
}
10511052

1052-
const headers = Object.fromEntries(request.headers.entries())
1053+
const { 'x-sim-idempotency-key': _, ...headers } = Object.fromEntries(request.headers.entries())
10531054

10541055
// For Microsoft Teams Graph notifications, extract unique identifiers for idempotency
10551056
if (
@@ -1067,9 +1068,20 @@ export async function queueWebhookExecution(
10671068
}
10681069
}
10691070

1070-
// Extract credentialId from webhook config
1071-
// Note: Each webhook now has its own credentialId (credential sets are fanned out at save time)
10721071
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
1072+
1073+
if (foundWebhook.provider === 'generic') {
1074+
const idempotencyField = providerConfig.idempotencyField as string | undefined
1075+
if (idempotencyField && body) {
1076+
const value = idempotencyField
1077+
.split('.')
1078+
.reduce((acc: any, key: string) => acc?.[key], body)
1079+
if (value !== undefined && value !== null && typeof value !== 'object') {
1080+
headers['x-sim-idempotency-key'] = String(value)
1081+
}
1082+
}
1083+
}
1084+
10731085
const credentialId = providerConfig.credentialId as string | undefined
10741086

10751087
// credentialSetId is a direct field on webhook table, not in providerConfig
@@ -1105,15 +1117,24 @@ export async function queueWebhookExecution(
11051117
...(credentialId ? { credentialId } : {}),
11061118
}
11071119

1108-
const jobQueue = await getJobQueue()
1109-
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
1110-
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
1111-
})
1112-
logger.info(
1113-
`[${options.requestId}] Queued webhook execution task ${jobId} for ${foundWebhook.provider} webhook`
1114-
)
1120+
const isPolling = isPollingWebhookProvider(payload.provider)
11151121

1116-
if (shouldExecuteInline()) {
1122+
if (isPolling && !shouldExecuteInline()) {
1123+
const jobQueue = await getJobQueue()
1124+
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
1125+
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
1126+
})
1127+
logger.info(
1128+
`[${options.requestId}] Queued polling webhook execution task ${jobId} for ${foundWebhook.provider} webhook via job queue`
1129+
)
1130+
} else {
1131+
const jobQueue = await getInlineJobQueue()
1132+
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
1133+
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
1134+
})
1135+
logger.info(
1136+
`[${options.requestId}] Executing ${foundWebhook.provider} webhook ${jobId} inline`
1137+
)
11171138
void (async () => {
11181139
try {
11191140
await jobQueue.startJob(jobId)
@@ -1193,6 +1214,26 @@ export async function queueWebhookExecution(
11931214
})
11941215
}
11951216

1217+
if (foundWebhook.provider === 'generic' && providerConfig.responseMode === 'custom') {
1218+
const rawCode = Number(providerConfig.responseStatusCode) || 200
1219+
const statusCode = rawCode >= 100 && rawCode <= 599 ? rawCode : 200
1220+
const responseBody = (providerConfig.responseBody as string | undefined)?.trim()
1221+
1222+
if (!responseBody) {
1223+
return new NextResponse(null, { status: statusCode })
1224+
}
1225+
1226+
try {
1227+
const parsed = JSON.parse(responseBody)
1228+
return NextResponse.json(parsed, { status: statusCode })
1229+
} catch {
1230+
return new NextResponse(responseBody, {
1231+
status: statusCode,
1232+
headers: { 'Content-Type': 'text/plain' },
1233+
})
1234+
}
1235+
}
1236+
11961237
return NextResponse.json({ message: 'Webhook processed' })
11971238
} catch (error: any) {
11981239
logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error)

apps/sim/lib/webhooks/utils.server.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
refreshAccessTokenIfNeeded,
2020
resolveOAuthAccountId,
2121
} from '@/app/api/auth/oauth/utils'
22+
import { isPollingWebhookProvider } from '@/triggers/constants'
2223

2324
const logger = createLogger('WebhookUtils')
2425

@@ -2222,10 +2223,7 @@ export async function syncWebhooksForCredentialSet(params: {
22222223
`[${requestId}] Syncing webhooks for credential set ${credentialSetId}, provider ${provider}`
22232224
)
22242225

2225-
// Polling providers get unique paths per credential (for independent state)
2226-
// External webhook providers share the same path (external service sends to one URL)
2227-
const pollingProviders = ['gmail', 'outlook', 'rss', 'imap']
2228-
const useUniquePaths = pollingProviders.includes(provider)
2226+
const useUniquePaths = isPollingWebhookProvider(provider)
22292227

22302228
const credentials = await getCredentialsForCredentialSet(credentialSetId, oauthProviderId)
22312229

apps/sim/lib/workflows/comparison/compare.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ describe('hasWorkflowChanged', () => {
433433
expect(hasWorkflowChanged(state1, state2)).toBe(true)
434434
})
435435

436-
it.concurrent('should detect subBlock type changes', () => {
436+
it.concurrent('should ignore subBlock type changes', () => {
437437
const state1 = createWorkflowState({
438438
blocks: {
439439
block1: createBlock('block1', {
@@ -448,7 +448,7 @@ describe('hasWorkflowChanged', () => {
448448
}),
449449
},
450450
})
451-
expect(hasWorkflowChanged(state1, state2)).toBe(true)
451+
expect(hasWorkflowChanged(state1, state2)).toBe(false)
452452
})
453453

454454
it.concurrent('should handle null/undefined subBlock values consistently', () => {

apps/sim/lib/workflows/comparison/normalize.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,14 @@ export function normalizeSubBlockValue(subBlockId: string, value: unknown): unkn
496496
* @returns SubBlock fields excluding value and is_diff
497497
*/
498498
export function extractSubBlockRest(subBlock: Record<string, unknown>): Record<string, unknown> {
499-
const { value: _v, is_diff: _sd, ...rest } = subBlock as SubBlockWithDiffMarker
499+
const {
500+
value: _v,
501+
is_diff: _sd,
502+
type: _type,
503+
...rest
504+
} = subBlock as SubBlockWithDiffMarker & {
505+
type?: unknown
506+
}
500507
return rest
501508
}
502509

0 commit comments

Comments
 (0)