diff --git a/server/api/github/webhook.post.ts b/server/api/github/webhook.post.ts index 92fb5f1..0428bef 100644 --- a/server/api/github/webhook.post.ts +++ b/server/api/github/webhook.post.ts @@ -9,6 +9,7 @@ import type { import { verify } from '@octokit/webhooks-methods' import { sql } from 'drizzle-orm' import { installation, webhookEvent } from '#server/db/schema' +import { kickWorker } from '#server/utils/kick-worker' import { enqueue } from '#server/utils/queue' import { revokeKeysForInstallation } from '#server/utils/tangled-pubkey' @@ -110,6 +111,9 @@ export default defineEventHandler(async event => { // the raw webhook body. See PLAN.md "Deferred / follow-ups". if (RECOGNISED_EVENTS.has(eventName)) { await enqueueForEvent(event, eventName, deliveryId) + // Nudge the worker to drain the just-enqueued job now rather than on the + // next cron tick. Fire-and-forget via waitUntil; cron is the safety net. + kickWorker(event) } return { ok: true, deliveryId } diff --git a/server/utils/kick-worker.ts b/server/utils/kick-worker.ts new file mode 100644 index 0000000..dd10f1a --- /dev/null +++ b/server/utils/kick-worker.ts @@ -0,0 +1,28 @@ +import type { H3Event } from 'h3' + +/** + * Nudge the job worker to run now, instead of waiting for the next cron tick. + * + * Fire-and-forget: registered via `event.waitUntil` so the serverless function + * stays alive to complete the kick after the response flushes, but the webhook + * never blocks on it. Any failure is swallowed; the per-minute cron is the + * safety net, so a missed kick only costs latency, not correctness. The worker + * itself is safe to run concurrently with cron (claims are `FOR UPDATE SKIP + * LOCKED` with a lease), so a kick racing a tick can't double-process a job. + * + * Auth uses the same `CRON_SECRET` bearer the worker route already requires. + */ +export function kickWorker(event: H3Event): void { + const cronSecret = process.env.CRON_SECRET + const base = useRuntimeConfig().public.url?.replace(/\/$/, '') + if (!cronSecret || !base) return + + const run = globalThis.fetch(`${base}/api/jobs/run`, { + method: 'GET', + headers: { authorization: `Bearer ${cronSecret}` }, + }) + .then(() => undefined) + .catch(() => undefined) + + event.waitUntil(run) +} diff --git a/test/unit/kick-worker.spec.ts b/test/unit/kick-worker.spec.ts new file mode 100644 index 0000000..ce2f261 --- /dev/null +++ b/test/unit/kick-worker.spec.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { H3Event } from 'h3' +import { kickWorker } from '../../server/utils/kick-worker' + +const ORIGINAL_SECRET = process.env.CRON_SECRET +const ORIGINAL_URL = process.env.NUXT_PUBLIC_URL + +function fakeEvent() { + const waitUntil = vi.fn<(p: Promise) => void>() + // eslint-disable-next-line ts/no-unsafe-type-assertion -- only waitUntil is exercised + return { event: { waitUntil } as unknown as H3Event, waitUntil } +} + +describe('kickWorker', () => { + beforeEach(() => { + process.env.CRON_SECRET = 'test-secret' + process.env.NUXT_PUBLIC_URL = 'https://synchub.to' + // kick-worker reads `useRuntimeConfig().public.url`; the Nuxt auto-import + // isn't available in plain unit tests, so stub it to read from env. + vi.stubGlobal('useRuntimeConfig', () => ({ public: { url: process.env.NUXT_PUBLIC_URL ?? '' } })) + }) + + afterEach(() => { + if (ORIGINAL_SECRET === undefined) delete process.env.CRON_SECRET + else process.env.CRON_SECRET = ORIGINAL_SECRET + if (ORIGINAL_URL === undefined) delete process.env.NUXT_PUBLIC_URL + else process.env.NUXT_PUBLIC_URL = ORIGINAL_URL + vi.unstubAllGlobals() + }) + + it('fires a GET to the worker with the cron bearer and registers it via waitUntil', async () => { + const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>(async () => new Response('{}')) + vi.stubGlobal('fetch', fetchMock) + const { event, waitUntil } = fakeEvent() + + kickWorker(event) + + expect(waitUntil).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('https://synchub.to/api/jobs/run') + expect(init).toMatchObject({ method: 'GET', headers: { authorization: 'Bearer test-secret' } }) + }) + + it('strips a trailing slash from the public URL', () => { + process.env.NUXT_PUBLIC_URL = 'https://synchub.to/' + const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>(async () => new Response('{}')) + vi.stubGlobal('fetch', fetchMock) + const { event } = fakeEvent() + + kickWorker(event) + + expect(fetchMock.mock.calls[0]![0]).toBe('https://synchub.to/api/jobs/run') + }) + + it('no-ops when CRON_SECRET is unset', () => { + delete process.env.CRON_SECRET + const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>() + vi.stubGlobal('fetch', fetchMock) + const { event, waitUntil } = fakeEvent() + + kickWorker(event) + + expect(fetchMock).not.toHaveBeenCalled() + expect(waitUntil).not.toHaveBeenCalled() + }) + + it('swallows a fetch rejection (cron is the safety net)', async () => { + const rejecting = vi.fn<(url: string, init?: RequestInit) => Promise>(async () => { throw new Error('network down') }) + vi.stubGlobal('fetch', rejecting) + const captured: Promise[] = [] + // eslint-disable-next-line ts/no-unsafe-type-assertion -- minimal event stub + const event = { waitUntil: (p: Promise) => { captured.push(p) } } as unknown as H3Event + + kickWorker(event) + + await expect(captured[0]).resolves.toBeUndefined() + }) +})