From 3ddd718ca400c081d6ce2adb40285dc6c67a5ea1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy Date: Fri, 13 Nov 2026 06:21:48 -0500 Subject: [PATCH 1/6] fix(web): send video.ready once every playback URL is ready video.ready fired on the status change to ready, which happens when the first output (usually the MP4) finishes, so every event carried hlsUrl null. The event is now decided inside the locked status write. A video that becomes ready with an output still encoding gets a readyEventDueAt mark in its processing state, and the write that settles the last output clears it and sends the event. A video already announced, or ready before this change, never carries the mark and sends nothing more. The processing workflow's poll sends it after 30 minutes if an output never settles. --- .../unit/developer-webhook-dispatch.test.ts | 375 +++++++++++++++++- apps/web/lib/developer-video-processing.ts | 54 ++- apps/web/lib/developer-webhook-dispatch.ts | 28 +- apps/web/lib/developer-webhook-events.ts | 77 +++- packages/database/types/developer-video.ts | 4 + 5 files changed, 506 insertions(+), 32 deletions(-) diff --git a/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts b/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts index 8b3f28100dc..de57a44e6ac 100644 --- a/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts +++ b/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type DeveloperVideoStore, developerVideo, @@ -20,6 +20,8 @@ const mocks = vi.hoisted(() => ({ failEndpointQuery: false, hangEndpointQuery: false, start: vi.fn(), + attempt: vi.fn(), + sleep: vi.fn(), head: vi.fn(), readText: vi.fn(), })); @@ -71,6 +73,10 @@ vi.mock("workflow/api", () => ({ start: mocks.start })); vi.mock("@/workflows/deliver-developer-webhook", () => ({ deliverDeveloperWebhookWorkflow: "deliver-developer-webhook", })); +vi.mock("workflow", () => ({ sleep: mocks.sleep })); +vi.mock("@/lib/developer-webhook-delivery", () => ({ + deliverDeveloperWebhookAttempt: mocks.attempt, +})); vi.mock("@/lib/developer-video-storage", () => ({ headDeveloperVideoObject: mocks.head, readDeveloperVideoText: mocks.readText, @@ -92,8 +98,18 @@ vi.mock("@/lib/media-client", () => ({ })); vi.mock("@/lib/media-container", () => ({ sniffMediaUrl: vi.fn() })); -const { applyDeveloperVideoProgress, failDeveloperVideoProcessing } = - await import("@/lib/developer-video-processing"); +const { + applyDeveloperVideoProgress, + claimDeveloperVideoProcessing, + failDeveloperVideoProcessing, + readDeveloperVideoParts, +} = await import("@/lib/developer-video-processing"); +const { developerVideoEventStep, READY_EVENT_MAX_WAIT_MS } = await import( + "@/lib/developer-webhook-events" +); +const { deliverDeveloperWebhookWorkflow } = await vi.importActual< + typeof import("@/workflows/deliver-developer-webhook") +>("@/workflows/deliver-developer-webhook"); const { enqueueDeveloperVideoEvent } = await import( "@/lib/developer-webhook-dispatch" ); @@ -145,6 +161,11 @@ const hlsDone = () => const settled = () => new Promise((resolve) => setTimeout(resolve, 0)); +function readyData() { + const job = jobs().find((queued) => queued.eventType === "video.ready"); + return JSON.parse(job?.body ?? "{}").data; +} + function video() { if (!mocks.store.video) throw new Error("no video"); return mocks.store.video; @@ -161,7 +182,12 @@ const jobs = () => }, ); +afterEach(() => { + vi.useRealTimers(); +}); + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); vi.clearAllMocks(); mocks.store.video = developerVideo({ processing: processingState({ mp4: running("m1"), hls: running("h1") }), @@ -180,6 +206,7 @@ beforeEach(() => { mocks.failEndpointQuery = false; mocks.hangEndpointQuery = false; mocks.start.mockResolvedValue({ runId: "run-1" }); + mocks.sleep.mockResolvedValue(undefined); mocks.head.mockResolvedValue({ bytes: 2_000, contentType: "video/mp4" }); mocks.readText.mockResolvedValue(master); vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -189,6 +216,7 @@ beforeEach(() => { describe("video.ready", () => { it("queues one delivery carrying the video as GET /videos/:id returns it", async () => { await mp4Done(); + await hlsDone(); await settled(); expect(video().status).toBe("ready"); @@ -210,34 +238,232 @@ describe("video.ready", () => { ); expect(event.data).toMatchObject({ id: "video-1", status: "ready" }); expect(event.data.mp4Url).toContain("https://play.example/"); + expect(event.data.hlsUrl).toContain("https://play.example/"); expect(job?.body).not.toContain("s3Key"); expect(job?.body).not.toContain("developer/app-1"); }); - it("queues nothing more when the encoder's callback arrives twice", async () => { - await mp4Done(); + it("waits for HLS when the MP4 makes the video ready first", async () => { await mp4Done(); + await settled(); + expect(video().status).toBe("ready"); + expect(video().processing?.readyEventDueAt).toEqual(expect.any(Number)); + expect(mocks.start).not.toHaveBeenCalled(); + + await hlsDone(); + await settled(); + expect(mocks.start).toHaveBeenCalledTimes(1); + expect(readyData()).toMatchObject({ + hlsUrl: expect.stringContaining("/master.m3u8"), + mp4Url: expect.stringContaining("https://play.example/"), + }); + expect(video().processing?.readyEventDueAt).toBeUndefined(); + }); + + it("waits for the MP4 when HLS makes the video ready first", async () => { + await hlsDone(); + await settled(); + expect(video().status).toBe("ready"); + expect(mocks.start).not.toHaveBeenCalled(); + await mp4Done(); await settled(); expect(mocks.start).toHaveBeenCalledTimes(1); + expect(readyData()).toMatchObject({ + hlsUrl: expect.stringContaining("/master.m3u8"), + mp4Url: expect.stringContaining("https://play.example/"), + }); }); - it("queues one event when both outputs finish at once", async () => { + it("queues one event carrying both URLs when both outputs finish at once", async () => { await Promise.all([mp4Done(), hlsDone()]); await settled(); expect(video().status).toBe("ready"); expect(mocks.start).toHaveBeenCalledTimes(1); expect(jobs()[0]?.eventType).toBe("video.ready"); + expect(readyData().hlsUrl).toContain("/master.m3u8"); + expect(readyData().mp4Url).toContain("https://play.example/"); + }); + + it("queues one event when both outputs settle in one update", async () => { + mocks.store.video = developerVideo({ + processing: processingState({ + mp4: { state: "complete", output: "m0" }, + hls: running("h1"), + }), + }); + await hlsDone(); + await settled(); + expect(video().status).toBe("ready"); + expect(mocks.start).toHaveBeenCalledTimes(1); + expect(video().processing?.readyEventDueAt).toBeUndefined(); + }); + + it("queues nothing more when the encoder's callbacks arrive again", async () => { + await mp4Done(); + await mp4Done(); + await hlsDone(); + await hlsDone(); + await mp4Done(); + await settled(); + expect(mocks.start).toHaveBeenCalledTimes(1); }); - it("queues nothing when the second output finishes later", async () => { + it("is sent with hlsUrl null when HLS fails", async () => { + mocks.store.video = developerVideo({ + processing: processingState({ + mp4: running("m1"), + hls: running("h1"), + attempts: { hls: 2, mp4: 1 }, + }), + }); await mp4Done(); await settled(); + expect(mocks.start).not.toHaveBeenCalled(); + + await applyDeveloperVideoProgress( + { videoId: "video-1", phase: "error", error: "boom" }, + "hls", + "h1", + ); + await settled(); + expect(video().processing?.hls.state).toBe("failed"); + expect(jobs().map((job) => job.eventType)).toEqual(["video.ready"]); + expect(readyData()).toMatchObject({ + hlsUrl: null, + mp4Url: expect.stringContaining("https://play.example/"), + }); + }); + + it("is sent once, with hlsUrl null, when HLS never settles", async () => { + const realNow = Date.now(); + vi.setSystemTime(realNow); + await mp4Done(); + await settled(); + expect(mocks.start).not.toHaveBeenCalled(); + + vi.setSystemTime(realNow + READY_EVENT_MAX_WAIT_MS - 1_000); + await readDeveloperVideoParts("video-1", "encode-1"); + await settled(); + expect(mocks.start).not.toHaveBeenCalled(); + + vi.setSystemTime(realNow + READY_EVENT_MAX_WAIT_MS); + const parts = await readDeveloperVideoParts("video-1", "encode-1"); + await settled(); + expect(parts?.hls).toBe("check"); + expect(video().processing?.hls.state).toBe("running"); + expect(jobs().map((job) => job.eventType)).toEqual(["video.ready"]); + expect(readyData()).toMatchObject({ + hlsUrl: null, + mp4Url: expect.stringContaining("https://play.example/"), + }); + + await readDeveloperVideoParts("video-1", "encode-1"); await hlsDone(); await settled(); + expect(video().processing?.hls.state).toBe("complete"); expect(mocks.start).toHaveBeenCalledTimes(1); }); + it("is sent with hlsUrl null when processing times out on a stuck HLS job", async () => { + await mp4Done(); + await settled(); + await failDeveloperVideoProcessing("video-1", "encode-1", "Too long"); + await settled(); + expect(video().status).toBe("ready"); + expect(video().processing?.hls.state).toBe("failed"); + expect(jobs().map((job) => job.eventType)).toEqual(["video.ready"]); + expect(readyData().hlsUrl).toBeNull(); + }); + + it("is sent on the next poll when the last output was settled without sending", async () => { + mocks.store.video = developerVideo({ + status: "ready", + processing: processingState({ + mp4: { state: "complete", output: "m1" }, + hls: { state: "complete", output: "h1" }, + readyEventDueAt: Date.now() - 5_000, + }), + }); + const parts = await readDeveloperVideoParts("video-1", "encode-1"); + await readDeveloperVideoParts("video-1", "encode-1"); + await settled(); + expect(parts).toEqual({ hls: "done", mp4: "done" }); + expect(jobs().map((job) => job.eventType)).toEqual(["video.ready"]); + expect(readyData().hlsUrl).toContain("/master.m3u8"); + expect(video().processing?.readyEventDueAt).toBeUndefined(); + }); + + it("sends no second event for a video that was ready before this change", async () => { + mocks.store.video = developerVideo({ + status: "ready", + processing: processingState({ + mp4: { state: "complete", output: "m0" }, + hls: running("h1"), + }), + }); + await hlsDone(); + await settled(); + expect(video().processing?.hls.state).toBe("complete"); + + vi.setSystemTime(Date.now() + 2 * READY_EVENT_MAX_WAIT_MS); + await readDeveloperVideoParts("video-1", "encode-1"); + await settled(); + expect(mocks.endpointQueries).toBe(0); + expect(mocks.start).not.toHaveBeenCalled(); + }); + + it("sends no event for a video that was ready before webhooks existed", async () => { + mocks.store.video = developerVideo({ + status: "ready", + createdAt: new Date("2026-10-01T00:00:00Z"), + updatedAt: new Date("2026-10-01T00:00:00Z"), + processing: processingState({ + mp4: { state: "complete", output: "m0" }, + hls: { state: "unavailable", reason: "not yet", retryAt: 0 }, + }), + }); + expect(await claimDeveloperVideoProcessing("video-1")).toMatchObject({ + action: "retry-hls", + }); + expect(video().processing?.hls.state).toBe("pending"); + mocks.store.video = { + ...video(), + processing: processingState({ + ...video().processing, + hls: running("h1"), + }), + }; + await hlsDone(); + await settled(); + expect(video().processing?.hls.state).toBe("complete"); + expect(mocks.endpointQueries).toBe(0); + expect(mocks.start).not.toHaveBeenCalled(); + }); + + it("re-sends the same event id and body on every retry", async () => { + await mp4Done(); + await hlsDone(); + await settled(); + const [job] = jobs(); + if (!job) throw new Error("no event was queued"); + mocks.attempt + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("delivered"); + + expect(await deliverDeveloperWebhookWorkflow(job)).toEqual({ + outcome: "delivered", + attempts: 3, + }); + const sent = mocks.attempt.mock.calls.map(([attempt]) => attempt); + expect(sent).toEqual([job, job, job]); + expect(JSON.parse(sent[2]?.body ?? "{}")).toMatchObject({ + id: developerVideoEventId("video-1", "video.ready"), + data: { hlsUrl: expect.stringContaining("/master.m3u8") }, + }); + }); + it("queues one delivery for each endpoint that wants the event", async () => { mocks.endpoints.push( { @@ -269,7 +495,7 @@ describe("video.ready", () => { deletedAt: null, }, ); - await mp4Done(); + await Promise.all([mp4Done(), hlsDone()]); await settled(); expect(jobs().map((job) => job.endpointId)).toEqual(["wh-1", "wh-2"]); expect(new Set(jobs().map((job) => job.eventId)).size).toBe(1); @@ -279,18 +505,20 @@ describe("video.ready", () => { it("reads nothing more and queues nothing for an app with no endpoints", async () => { mocks.endpoints = []; await mp4Done(); + await hlsDone(); await settled(); expect(video().status).toBe("ready"); expect(mocks.endpointQueries).toBe(1); expect(mocks.start).not.toHaveBeenCalled(); }); - it("asks about endpoints only when the status changes", async () => { + it("asks about endpoints only when an event is due", async () => { await applyDeveloperVideoProgress( { videoId: "video-1", phase: "processing", jobId: "job-9" }, "mp4", "m1", ); + await mp4Done(); await settled(); expect(mocks.endpointQueries).toBe(0); }); @@ -317,6 +545,7 @@ describe("video.errored", () => { it("queues nothing for an output that fails after the video is ready", async () => { await mp4Done(); + await hlsDone(); await settled(); await applyDeveloperVideoProgress( { videoId: "video-1", phase: "error", error: "boom" }, @@ -406,3 +635,131 @@ describe("webhooks never hold up a video", () => { expect(mocks.start).not.toHaveBeenCalled(); }); }); + +describe("deciding the event inside the status write", () => { + const complete = { state: "complete" as const, output: "o" }; + const now = 10_000_000; + + it("sends video.ready at once when every output has settled", () => { + const processing = processingState({ + mp4: complete, + hls: { state: "unavailable", reason: "no ladder", retryAt: now + 1 }, + }); + expect( + developerVideoEventStep( + { status: "processing", processing }, + { status: "ready", processing }, + now, + ), + ).toEqual({ type: "video.ready" }); + }); + + it("marks the video and waits while an output is still encoding", () => { + const processing = processingState({ mp4: complete, hls: running("h") }); + expect( + developerVideoEventStep( + { status: "processing", processing }, + { status: "ready", processing }, + now, + ), + ).toEqual({ + type: null, + processing: { ...processing, readyEventDueAt: now }, + }); + }); + + it("sends it once the waiting output settles, and clears the mark", () => { + const waiting = processingState({ + mp4: complete, + hls: running("h"), + readyEventDueAt: now, + }); + const settledState = { ...waiting, hls: complete }; + const step = developerVideoEventStep( + { status: "ready", processing: waiting }, + { status: "ready", processing: settledState }, + now + 5_000, + ); + expect(step.type).toBe("video.ready"); + expect(step.processing).not.toHaveProperty("readyEventDueAt"); + expect( + developerVideoEventStep( + { status: "ready", processing: step.processing ?? null }, + { status: "ready", processing: step.processing ?? null }, + now + 6_000, + ), + ).toEqual({ type: null }); + }); + + it("sends it once the wait runs out, even with an output still encoding", () => { + const waiting = processingState({ + mp4: complete, + hls: running("h"), + readyEventDueAt: now, + }); + const video = { status: "ready", processing: waiting }; + expect( + developerVideoEventStep(video, video, now + READY_EVENT_MAX_WAIT_MS - 1), + ).toEqual({ type: null }); + expect( + developerVideoEventStep(video, video, now + READY_EVENT_MAX_WAIT_MS).type, + ).toBe("video.ready"); + }); + + it("sends nothing for a ready video without the mark", () => { + const before = processingState({ mp4: complete, hls: running("h") }); + const after = { ...before, hls: complete }; + expect( + developerVideoEventStep( + { status: "ready", processing: before }, + { status: "ready", processing: after }, + now + 10 * READY_EVENT_MAX_WAIT_MS, + ), + ).toEqual({ type: null }); + expect( + developerVideoEventStep( + { status: "ready", processing: null }, + { status: "ready", processing: null }, + now, + ), + ).toEqual({ type: null }); + }); + + it("sends video.errored on the change to errored, as before", () => { + const processing = processingState(); + expect( + developerVideoEventStep( + { status: "processing", processing }, + { status: "errored", processing }, + now, + ), + ).toEqual({ type: "video.errored" }); + expect( + developerVideoEventStep( + { status: "errored", processing }, + { status: "errored", processing }, + now, + ), + ).toEqual({ type: null }); + }); +}); + +describe("a rolled back announcement", () => { + it("is not sent while the committed row still carries the mark", async () => { + mocks.store.video = developerVideo({ + status: "ready", + processing: processingState({ + mp4: { state: "complete", output: "m0" }, + hls: { state: "complete", output: "h0" }, + readyEventDueAt: 1, + }), + }); + expect( + await enqueueDeveloperVideoEvent( + { id: "video-1", appId: "app-1" }, + "video.ready", + ), + ).toBe(0); + expect(mocks.start).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/lib/developer-video-processing.ts b/apps/web/lib/developer-video-processing.ts index 9c5bf488359..9157c9ddc5a 100644 --- a/apps/web/lib/developer-video-processing.ts +++ b/apps/web/lib/developer-video-processing.ts @@ -61,7 +61,11 @@ import { internalDeveloperVideoUrl, readDeveloperVideoText, } from "./developer-video-storage"; -import { announceDeveloperVideoStatus } from "./developer-webhook-dispatch"; +import { announceDeveloperVideoEvent } from "./developer-webhook-dispatch"; +import { + developerVideoEventStep, + developerVideoReadyEventDue, +} from "./developer-webhook-events"; import { MEDIA_SERVER_SHUTDOWN_MESSAGE, readMediaServerVideoJob, @@ -123,14 +127,28 @@ async function mutateDeveloperVideo( .where(eq(developerVideos.id, videoId)) .for("update"); if (!video) return null; - const updates = mutate(video); - if (!updates) return null; - becameReady = video.status !== "ready" && updates.status === "ready"; + const changes = mutate(video); + if (!changes) return null; + becameReady = video.status !== "ready" && changes.status === "ready"; + const event = developerVideoEventStep( + video, + { + status: changes.status ?? video.status, + processing: + "processing" in changes + ? (changes.processing ?? null) + : video.processing, + }, + Date.now(), + ); + const updates = event.processing + ? { ...changes, processing: event.processing } + : changes; await tx .update(developerVideos) .set(updates) .where(eq(developerVideos.id, videoId)); - announceDeveloperVideoStatus(video, updates); + announceDeveloperVideoEvent(video, event); return { ...video, ...updates } as DeveloperVideoRow; }; const updated = await (executor ? run(executor) : db().transaction(run)); @@ -591,6 +609,22 @@ export async function prepareDeveloperVideoProcessing( } } +// The processing workflow polls a ready video until its outputs settle, and +// each poll sends a due video.ready. A stuck output holds it back for at most +// READY_EVENT_MAX_WAIT_MS and one poll, and a marked video whose last output +// was settled by a write that did not send still gets it on the final poll. +export async function sendDueDeveloperVideoReady( + videoId: string, +): Promise { + await mutateDeveloperVideo(videoId, (video) => + !video.deletedAt && + video.processing && + developerVideoReadyEventDue(video, Date.now()) + ? { processing: video.processing } + : null, + ); +} + export async function readDeveloperVideoParts( videoId: string, encodeId: string, @@ -598,6 +632,16 @@ export async function readDeveloperVideoParts( const encode = await findEncode(videoId, encodeId); if (!encode) return null; const now = Date.now(); + // Thrown out of this step, a failure here would fail the video's + // processing. The next poll tries again. + if (developerVideoReadyEventDue(encode.video, now)) { + await sendDueDeveloperVideoReady(videoId).catch((error) => { + console.error("[developer-webhook] Failed to send a due event", { + videoId, + message: error instanceof Error ? error.message : String(error), + }); + }); + } return { hls: developerVideoPartAction(encode.processing.hls, now), mp4: developerVideoPartAction(encode.processing.mp4, now), diff --git a/apps/web/lib/developer-webhook-dispatch.ts b/apps/web/lib/developer-webhook-dispatch.ts index fc2393a8945..d4da803251b 100644 --- a/apps/web/lib/developer-webhook-dispatch.ts +++ b/apps/web/lib/developer-webhook-dispatch.ts @@ -7,9 +7,9 @@ import { and, eq, isNull } from "drizzle-orm"; import { after } from "next/server"; import { start } from "workflow/api"; import { serializeDeveloperVideo } from "@/lib/developer-video-api"; -import { - developerVideoEventType, - type WebhookEventType, +import type { + DeveloperVideoEventStep, + WebhookEventType, } from "@/lib/developer-webhook-events"; import { developerVideoEventId } from "@/lib/developer-webhook-signature"; import { deliverDeveloperWebhookWorkflow } from "@/workflows/deliver-developer-webhook"; @@ -19,6 +19,16 @@ const STATUS_OF_EVENT: Record = { "video.errored": "errored", }; +// A ready video still carrying the marker has not been announced, which is +// also what a rolled back announcement leaves behind. +function eventIsCommitted( + video: typeof developerVideos.$inferSelect, + type: WebhookEventType, +): boolean { + if (video.deletedAt || video.status !== STATUS_OF_EVENT[type]) return false; + return type !== "video.ready" || video.processing?.readyEventDueAt == null; +} + async function subscribedEndpointIds( appId: string, type: WebhookEventType, @@ -64,9 +74,7 @@ export async function enqueueDeveloperVideoEvent( if (endpointIds.length === 0) return 0; const video = await committedVideo(target.id); - if (!video || video.deletedAt || video.status !== STATUS_OF_EVENT[type]) { - return 0; - } + if (!video || !eventIsCommitted(video, type)) return 0; const eventId = developerVideoEventId(video.id, type); const body = JSON.stringify({ @@ -95,11 +103,11 @@ export async function enqueueDeveloperVideoEvent( // Never awaited: a video's status change must not wait for, or fail on, // anything to do with webhooks. -export function announceDeveloperVideoStatus( - video: { id: string; appId: string; status: string }, - updates: { status?: string }, +export function announceDeveloperVideoEvent( + video: { id: string; appId: string }, + step: DeveloperVideoEventStep, ): void { - const type = developerVideoEventType(video.status, updates.status); + const type = step.type; if (!type) return; const queued = enqueueDeveloperVideoEvent(video, type).catch((error) => { console.error("[developer-webhook] Failed to queue an event", { diff --git a/apps/web/lib/developer-webhook-events.ts b/apps/web/lib/developer-webhook-events.ts index 76d73fca7be..fdc9c782016 100644 --- a/apps/web/lib/developer-webhook-events.ts +++ b/apps/web/lib/developer-webhook-events.ts @@ -1,3 +1,9 @@ +import type { + DeveloperVideoPartState, + DeveloperVideoProcessingState, +} from "@cap/database/types"; +import { DEVELOPER_VIDEO_PARTS } from "@/lib/developer-video-state"; + export const WEBHOOK_EVENT_TYPES = ["video.ready", "video.errored"] as const; export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number]; @@ -40,12 +46,67 @@ export function isWebhookEventType(value: string): value is WebhookEventType { return WEBHOOK_EVENT_TYPES.some((type) => type === value); } -export function developerVideoEventType( - previous: string, - next: string | undefined, -): WebhookEventType | null { - if (next === undefined || next === previous) return null; - if (next === "ready") return "video.ready"; - if (next === "errored") return "video.errored"; - return null; +// video.ready waits for every output so it carries every playback URL. An +// output still encoding this long after the video became ready is treated as +// stuck, and the event goes out with the URLs that exist. +export const READY_EVENT_MAX_WAIT_MS = 30 * 60 * 1000; + +const SETTLED_PART_STATES: ReadonlySet = + new Set(["complete", "failed", "unavailable"]); + +interface EventVideo { + status: string; + processing: DeveloperVideoProcessingState | null; +} + +export interface DeveloperVideoEventStep { + type: WebhookEventType | null; + processing?: DeveloperVideoProcessingState; +} + +export function developerVideoOutputsSettled( + processing: DeveloperVideoProcessingState, +): boolean { + return DEVELOPER_VIDEO_PARTS.every((part) => + SETTLED_PART_STATES.has(processing[part].state), + ); +} + +// Due once every output has settled or the wait has run out. Also true for a +// marked video whose last output was settled by a write that did not send, +// such as one from the previous deployment while this one rolls out. +export function developerVideoReadyEventDue( + video: EventVideo, + now: number, +): boolean { + const processing = video.processing; + const dueAt = processing?.readyEventDueAt; + if (video.status !== "ready" || !processing || dueAt == null) return false; + return ( + developerVideoOutputsSettled(processing) || + now - dueAt >= READY_EVENT_MAX_WAIT_MS + ); +} + +// Runs inside the transaction that writes `next`, under the video's row lock, +// so concurrent writers see each other's marker and only one of them sends. +export function developerVideoEventStep( + previous: EventVideo, + next: EventVideo, + now: number, +): DeveloperVideoEventStep { + const processing = next.processing; + if (next.status !== previous.status) { + if (next.status === "errored") return { type: "video.errored" }; + if (next.status !== "ready") return { type: null }; + if (!processing || developerVideoOutputsSettled(processing)) { + return { type: "video.ready" }; + } + return { type: null, processing: { ...processing, readyEventDueAt: now } }; + } + if (!processing || !developerVideoReadyEventDue(next, now)) { + return { type: null }; + } + const { readyEventDueAt: _readyEventDueAt, ...announced } = processing; + return { type: "video.ready", processing: announced }; } diff --git a/packages/database/types/developer-video.ts b/packages/database/types/developer-video.ts index 353ee492b3d..b22b84f50dc 100644 --- a/packages/database/types/developer-video.ts +++ b/packages/database/types/developer-video.ts @@ -59,6 +59,10 @@ export interface DeveloperVideoProcessingState { lostJobs?: Partial>; // When every worker last answered busy to a start of this encode. busyAt?: number; + // When the video became ready with an output still encoding. video.ready + // is sent, and this removed, once the outputs settle. A video that became + // ready without it was already announced, by this code or by older code. + readyEventDueAt?: number; unavailableChecks: number; poster: { output: string } | null; bytes: { From 271dce52e7ff4daa2caa8417b2e797941286e0be Mon Sep 17 00:00:00 2001 From: Richie McIlroy Date: Fri, 13 Nov 2026 06:21:48 -0500 Subject: [PATCH 2/6] fix(web): plain webhook event errors and webhook docs links Unknown or missing events get hand-written messages instead of zod's wording, and errors with a webhook code link to the webhooks guide. --- .../unit/developer-webhooks-api.test.ts | 30 +++++++++++++++++++ .../api/developer/v1/[...route]/webhooks.ts | 11 +++++-- apps/web/lib/api-errors.ts | 3 +- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/apps/web/__tests__/unit/developer-webhooks-api.test.ts b/apps/web/__tests__/unit/developer-webhooks-api.test.ts index c0234edcba0..db3ac44aad8 100644 --- a/apps/web/__tests__/unit/developer-webhooks-api.test.ts +++ b/apps/web/__tests__/unit/developer-webhooks-api.test.ts @@ -98,7 +98,9 @@ async function create( body: (await response.json()) as { data: Record & { id: string; secret: string }; code?: string; + message?: string; hint?: string; + docs?: string; }, }; } @@ -175,6 +177,34 @@ describe("creating a webhook endpoint", () => { expect(rows()).toHaveLength(0); }); + it("explains an unknown or missing event in plain words", async () => { + const unknown = await create(A, { + url: "https://a.example/x", + events: ["video.ready", "video.updated"], + }); + expect(unknown.body.message).toBe( + 'Invalid request: events.1: Unknown event "video.updated". Use video.ready or video.errored.', + ); + const none = await create(A, { url: "https://a.example/x", events: [] }); + expect(none.body.message).toBe( + "Invalid request: events: events must list at least one of: video.ready, video.errored", + ); + expect(unknown.body.docs).toBe("https://cap.so/docs/api/rest-api"); + }); + + it("links webhook errors to the webhooks guide", async () => { + const missing = await send("GET", "/wh_missing", A); + expect(await missing.json()).toMatchObject({ + code: "webhook_not_found", + docs: "https://cap.so/docs/api/webhooks", + }); + const refused = await create(A, { url: "http://hooks.example.com/cap" }); + expect(refused.body).toMatchObject({ + code: "invalid_webhook_url", + docs: "https://cap.so/docs/api/webhooks", + }); + }); + it.each([ ["http", "http://hooks.example.com/cap"], ["a private address", "https://10.0.0.8/cap"], diff --git a/apps/web/app/api/developer/v1/[...route]/webhooks.ts b/apps/web/app/api/developer/v1/[...route]/webhooks.ts index ce6d78bae4b..f1bc9cd82cf 100644 --- a/apps/web/app/api/developer/v1/[...route]/webhooks.ts +++ b/apps/web/app/api/developer/v1/[...route]/webhooks.ts @@ -31,8 +31,15 @@ const failureBody = (failure: WebhookFailure) => const endpointFields = { url: z.string().min(1).max(2048), events: z - .array(z.enum(WEBHOOK_EVENT_TYPES)) - .min(1) + .array( + z.enum(WEBHOOK_EVENT_TYPES, { + error: (issue) => + `Unknown event ${JSON.stringify(String(issue.input).slice(0, 100))}. Use ${WEBHOOK_EVENT_TYPES.join(" or ")}.`, + }), + ) + .min(1, { + error: `events must list at least one of: ${WEBHOOK_EVENT_TYPES.join(", ")}`, + }) .refine((types) => new Set(types).size === types.length, { message: "Each event type can be listed once", }), diff --git a/apps/web/lib/api-errors.ts b/apps/web/lib/api-errors.ts index 59ab7906046..b296759efd0 100644 --- a/apps/web/lib/api-errors.ts +++ b/apps/web/lib/api-errors.ts @@ -1,4 +1,5 @@ export const API_DOCS_URL = "https://cap.so/docs/api/rest-api"; +export const WEBHOOK_DOCS_URL = "https://cap.so/docs/api/webhooks"; export const OPENAPI_SPEC_PATH = "/openapi.json"; export type ApiErrorBody = { @@ -18,7 +19,7 @@ export const apiErrorBody = ( code, message, hint, - docs: API_DOCS_URL, + docs: code.includes("webhook") ? WEBHOOK_DOCS_URL : API_DOCS_URL, }); export const apiNotFoundBody = (pathname: string) => From 54277c15fc71649069865fb1bfdd0af787bdcfcf Mon Sep 17 00:00:00 2001 From: Richie McIlroy Date: Fri, 13 Nov 2026 06:21:48 -0500 Subject: [PATCH 3/6] docs: webhook receiver, ready timing, playlist start and embed options The webhooks guide says video.ready waits for every playback URL, adds a runnable node:http receiver (tested), uses 2 space indents, and says a second DELETE returns 200. Get Started links webhooks from step 4, points look and behaviour at the embed options, and the playlist start sentence states the rule from #4097 (720p on most videos). --- .../unit/developer-webhook-docs.test.ts | 86 ++++++++++++++++++- apps/web/content/docs/api/get-started.mdx | 6 +- apps/web/content/docs/api/rest-api.mdx | 4 +- apps/web/content/docs/api/webhooks.mdx | 78 +++++++++++++---- apps/web/lib/developer-openapi.ts | 2 + 5 files changed, 153 insertions(+), 23 deletions(-) diff --git a/apps/web/__tests__/unit/developer-webhook-docs.test.ts b/apps/web/__tests__/unit/developer-webhook-docs.test.ts index 8337172baea..8b68297960f 100644 --- a/apps/web/__tests__/unit/developer-webhook-docs.test.ts +++ b/apps/web/__tests__/unit/developer-webhook-docs.test.ts @@ -1,5 +1,6 @@ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { compileMDX } from "next-mdx-remote/rsc"; @@ -16,6 +17,7 @@ const { app } = await import("@/app/api/developer/v1/[...route]/webhooks"); const { developerOpenApiSpec } = await import("@/lib/developer-openapi"); const { MAX_WEBHOOK_ENDPOINTS_PER_APP, + READY_EVENT_MAX_WAIT_MS, WEBHOOK_DISABLE_AFTER_FAILURES, WEBHOOK_DISABLE_AFTER_MS, WEBHOOK_EVENT_TYPES, @@ -143,6 +145,88 @@ describe("the webhooks guide", () => { } }); + it("gives a receiver that answers Cap's request and refuses a forged one", async () => { + const jsBlocks = [...guide.matchAll(/```js\n([\s\S]*?)```/g)].map( + ([, code]) => code ?? "", + ); + const verify = jsBlocks.find((code) => code.includes("timingSafeEqual")); + const receiver = jsBlocks.find((code) => code.includes("createServer")); + expect(receiver).toContain('from "./verify-cap-webhook.mjs"'); + const folder = mkdtempSync(join(tmpdir(), "cap-webhook-receiver-")); + const port = await new Promise((resolve) => { + const probe = createServer().listen(0, () => { + const address = probe.address(); + probe.close(() => + resolve(typeof address === "object" && address ? address.port : 0), + ); + }); + }); + const secret = "cws_0123456789abcdefghjkmnpqrstvwx"; + writeFileSync(join(folder, "verify-cap-webhook.mjs"), verify ?? ""); + writeFileSync(join(folder, "server.mjs"), receiver ?? ""); + const child = spawn(process.execPath, [join(folder, "server.mjs")], { + env: { ...process.env, PORT: String(port), CAP_WEBHOOK_SECRET: secret }, + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + try { + const url = `http://127.0.0.1:${port}/webhooks/cap`; + const body = JSON.stringify({ + id: "evt_1", + type: "video.ready", + data: { id: "video-1", hlsUrl: "https://play/master.m3u8" }, + }); + const post = async (signature: string) => { + for (let tries = 0; ; tries++) { + try { + return await fetch(url, { + method: "POST", + headers: { "Cap-Signature": signature }, + body, + }); + } catch (error) { + if (tries > 50) throw error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + }; + const now = Math.floor(Date.now() / 1000); + expect( + (await post(signWebhookBody(`${secret}x`, body, now))).status, + ).toBe(400); + expect((await post(signWebhookBody(secret, body, now))).status).toBe(200); + expect((await post(signWebhookBody(secret, body, now))).status).toBe(200); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(output.trim().split("\n")).toEqual([ + "ready video-1 https://play/master.m3u8", + ]); + } finally { + child.kill(); + rmSync(folder, { recursive: true, force: true }); + } + }); + + it("says when video.ready is sent, matching the code", () => { + expect(guide).not.toContain("No second event is sent"); + expect(guide).toContain( + "`video.ready` arrives once every playback URL is ready, so `hlsUrl` is set unless HLS failed", + ); + expect(READY_EVENT_MAX_WAIT_MS).toBe(30 * 60 * 1000); + expect(guide).toContain("still encoding 30 minutes after"); + expect( + (spec.webhooks as Record)[ + "video.ready" + ]?.post.description, + ).toContain("30 minutes after"); + }); + + it("indents code like the rest of the docs", () => { + const blocks = [...guide.matchAll(/```\w*\n([\s\S]*?)```/g)]; + for (const [, code] of blocks) expect(code).not.toMatch(/^\t/m); + }); + it("teaches the REST API only", () => { expect(guide).not.toMatch(/\bcap (developers|caps|webhooks)\b/); expect(guide).not.toMatch(/\bMCP\b/); diff --git a/apps/web/content/docs/api/get-started.mdx b/apps/web/content/docs/api/get-started.mdx index aa16700e071..17ce7aefddf 100644 --- a/apps/web/content/docs/api/get-started.mdx +++ b/apps/web/content/docs/api/get-started.mdx @@ -358,6 +358,8 @@ Save this as `index.html` beside `server.mjs`. It uses no library. Completing the upload starts processing. [Get Video Status](/docs/api/rest-api#get-video-status) moves from `uploading` to `processing`, then to `ready` or `errored`. The page asks your server, and your server asks Cap with the secret key. +Or let Cap tell your server when it's ready: see [Webhooks](/docs/api/webhooks). + Add this to `index.html`, below the upload script: ```html @@ -468,7 +470,7 @@ Safari plays HLS by itself. Every other browser needs [hls.js](https://github.co | `playMp4` on a fatal error | Keep playing from the same position if HLS fails for any other reason | | `stopHls` at the start of `playVideo` | Stop the previous video's player before starting the next one on the same page | -There is no start level to set. The master playlist lists a rendition that starts quickly first, and players begin with the first one listed. +There is no start level to set. Players begin with the first rendition the master playlist lists, and Cap lists the largest one whose peak bitrate is at most 2.6 Mbps first, at 1080p or smaller. On most videos that is 720p. The player steps up or down from there. [Play a Video](/docs/api/rest-api#play-a-video) covers a Content Security Policy, bitrates and what happens when playback stops. @@ -492,7 +494,7 @@ Every upload also returns `shareUrl` and `embedUrl`. They are Cap's hosted playe `loading="lazy"` leaves an embed further down the page unloaded until the viewer scrolls near it. The embed takes options in its URL, such as a start time, autoplay and loop. See [Embed Parameters](/docs/api/rest-api#embed-parameters). -Use the hosted player when you want a video on a page with the least work. Build your own player, as above, when you need to control how it looks or react to what the viewer does. [Share and Embed](/docs/api/rest-api#share-and-embed) has the details. +Use the hosted player for the look and behaviour too: [Embed Options](/docs/api/rest-api#embed-options) set its colour, corners and controls, [Embed Parameters](/docs/api/rest-api#embed-parameters) its start time and autoplay, and [Player Events](/docs/api/rest-api#player-events) tell your page what the viewer does. Build your own player, as above, only when you need full control of the UI. [Share and Embed](/docs/api/rest-api#share-and-embed) has the details. ## 6. Upload from your server diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 37a208a0f8c..de84d2ac7e9 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -572,7 +572,7 @@ Poll this endpoint every 3 seconds after completing an upload, and stop when `st A video is `ready` as soon as its first output can be played, so playback can start while the rest is still encoding. The playback URLs are `null` until then. `hlsUrl` is an adaptive HLS master playlist, `mp4Url` is a progressive MP4 and `posterUrl` is a poster image. They need no API key and can be loaded from any origin. [Play a Video](/docs/api/rest-api#play-a-video) shows how to use them, and [Delete Video](/docs/api/rest-api#delete-video) says when they stop working. -On a ready video, one of `hlsUrl` and `mp4Url` can still be `null` while that output finishes encoding. It is filled in later without the video leaving `ready`, and `renditions` is filled in together with `hlsUrl`. Play whichever URL is present. A player that wants HLS should keep polling after `ready` until `hlsUrl` is set, and can play `mp4Url` in the meantime. +On a ready video, one of `hlsUrl` and `mp4Url` can still be `null` while that output finishes encoding. It is filled in later without the video leaving `ready`, and `renditions` is filled in together with `hlsUrl`. Play whichever URL is present. A player that wants HLS should keep polling after `ready` until `hlsUrl` is set, and can play `mp4Url` in the meantime. A [webhook](/docs/api/webhooks)'s `video.ready` is sent once every playback URL is ready instead. `outputs` says whether a `null` URL is still coming. Each of `outputs.hls` and `outputs.mp4` is `ready` when its URL is set, `processing` while it is being encoded or will be retried, and `failed` when it will not be produced. A video can be `ready` with one output `failed`, so stop waiting for that URL and play the other. `none` only appears on videos uploaded before encoding existed. While `takenDown` is `true` the playback URLs are `null` whatever `outputs` says, and `outputs` keeps describing what was encoded. @@ -1394,7 +1394,7 @@ Replace `HLS_URL`, `MP4_URL` and `POSTER_URL` with the video's `hlsUrl`, `mp4Url - If hls.js hits an error it cannot recover from, the page switches to the MP4 at the same position and keeps playing. - If the browser's own HLS player fails, the page loads the MP4 in its place. A viewer who had already pressed play may have to press it again. - `capLevelToPlayerSize` stops hls.js from loading a rendition larger than the player. Without it, a small player on a fast connection climbs to the largest rendition. -- There is no start level to set. The master playlist lists a rendition that starts quickly first, and players begin with the first one listed. +- There is no start level to set. Players begin with the first rendition the master playlist lists, and Cap lists the largest one whose peak bitrate is at most 2.6 Mbps first, at 1080p or smaller. On most videos that is 720p. ### Play in Safari diff --git a/apps/web/content/docs/api/webhooks.mdx b/apps/web/content/docs/api/webhooks.mdx index 49f0e68a863..7c63d76a337 100644 --- a/apps/web/content/docs/api/webhooks.mdx +++ b/apps/web/content/docs/api/webhooks.mdx @@ -18,7 +18,7 @@ Webhooks belong to an app in [Cap Infra](/dashboard/infra) and are managed with A video sends `video.ready` or `video.errored` once. Handlers should ignore a type they do not know, so a new event type never breaks them. -On a ready video, one of `hlsUrl` and `mp4Url` can still be `null` while that output finishes encoding. No second event is sent when it arrives. Read it with [Get Video](/docs/api/rest-api#get-video). +`video.ready` arrives once every playback URL is ready, so `hlsUrl` is set unless HLS failed. In the rare case that an output is still encoding 30 minutes after the video could first be played, the event is sent without it, and [Get Video](/docs/api/rest-api#get-video) returns it once it is ready. ## Create an endpoint @@ -88,7 +88,7 @@ Anything else returns `400` with code `invalid_webhook_url`. An app can have 5 e | `POST /api/developer/v1/webhooks/:id/rotate-secret` | [Rotate the signing secret](#rotate-the-signing-secret) | | `POST /api/developer/v1/webhooks/:id/test` | [Send a test event](#send-a-test-event) | -An ID that does not belong to the app returns `404` with code `webhook_not_found`. +An ID that does not belong to the app returns `404` with code `webhook_not_found`. Deleting an endpoint that is already deleted returns `200`, the same as deleting a video twice, so a retried delete is safe. Every endpoint is returned with these fields: @@ -143,7 +143,7 @@ Cap-Event-Type: video.ready "width": 1920, "height": 1080, "playbackId": "Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G", - "hlsUrl": null, + "hlsUrl": "https://cap.so/api/developer/playback/Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G/master.m3u8", "mp4Url": "https://cap.so/api/developer/playback/Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G/video.mp4", "posterUrl": "https://cap.so/api/developer/playback/Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G/poster.jpg" } @@ -178,21 +178,21 @@ Sign the body exactly as it arrived, byte for byte. Parsing the JSON and seriali import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyCapWebhook(rawBody, signatureHeader, secret) { - const parts = Object.fromEntries( - String(signatureHeader ?? "") - .split(",") - .map((part) => part.split("=")), - ); - const timestamp = Number(parts.t); - if (!Number.isInteger(timestamp) || !parts.v1) return false; - if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; - const expected = createHmac("sha256", secret) - .update(`${parts.t}.${rawBody}`) - .digest(); - const received = Buffer.from(parts.v1, "hex"); - return ( - received.length === expected.length && timingSafeEqual(received, expected) - ); + const parts = Object.fromEntries( + String(signatureHeader ?? "") + .split(",") + .map((part) => part.split("=")), + ); + const timestamp = Number(parts.t); + if (!Number.isInteger(timestamp) || !parts.v1) return false; + if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; + const expected = createHmac("sha256", secret) + .update(`${parts.t}.${rawBody}`) + .digest(); + const received = Buffer.from(parts.v1, "hex"); + return ( + received.length === expected.length && timingSafeEqual(received, expected) + ); } ``` @@ -223,6 +223,48 @@ def verify_cap_webhook(raw_body: bytes, signature_header: str, secret: str) -> b return hmac.compare_digest(expected, received) ``` +### A complete receiver + +This server uses only Node.js. Save the function above as `verify-cap-webhook.mjs` next to it and start it with your signing secret in `CAP_WEBHOOK_SECRET`. It reads the raw body, checks the signature, answers `200` straight away, then handles the event. + +```js +import { createServer } from "node:http"; +import { verifyCapWebhook } from "./verify-cap-webhook.mjs"; + +const secret = process.env.CAP_WEBHOOK_SECRET; +const handled = new Set(); + +function handleEvent(event) { + if (handled.has(event.id)) return; + handled.add(event.id); + if (event.type === "video.ready") { + console.log("ready", event.data.id, event.data.hlsUrl ?? event.data.mp4Url); + } else if (event.type === "video.errored") { + console.log("errored", event.data.id, event.data.errorReason); + } +} + +createServer((req, res) => { + if (req.method !== "POST" || req.url !== "/webhooks/cap") { + res.writeHead(404).end(); + return; + } + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const rawBody = Buffer.concat(chunks).toString("utf8"); + if (!verifyCapWebhook(rawBody, req.headers["cap-signature"], secret)) { + res.writeHead(400).end(); + return; + } + res.writeHead(200).end(); + handleEvent(JSON.parse(rawBody)); + }); +}).listen(Number(process.env.PORT ?? 3000)); +``` + +With Express, read this route's body with `express.raw({ type: "application/json" })`, not `express.json()`: a parsed and re-serialised body no longer matches the signature. Keep the ids you have handled in your database rather than in memory, so a restart does not forget them. + ## Respond Answer with any `2xx` status within 10 seconds. Do the slow work after you have answered: store the event or put it on a queue, respond, then process it. diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index b2fe049f7dc..ba2c51d5edf 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -19,6 +19,7 @@ import { } from "@/lib/developer-transcript-errors"; import { MAX_WEBHOOK_ENDPOINTS_PER_APP, + READY_EVENT_MAX_WAIT_MS, WEBHOOK_DISABLE_AFTER_FAILURES, WEBHOOK_EVENT_TYPES, WEBHOOK_MAX_ATTEMPTS, @@ -602,6 +603,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ "video.ready", "A video is ready to play", ref("DeveloperVideo"), + `Sent once every playback URL is ready, so hlsUrl is set unless HLS failed. An output still encoding ${READY_EVENT_MAX_WAIT_MS / 60_000} minutes after the video could first be played is left out, and Get Video returns it once it is ready. ${videoEventDescription("video.ready")}`, ), "video.errored": webhookDelivery( "video.errored", From dcf5939324a0b0ab789ebdbe2ce9668f2f7b7ba2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy Date: Fri, 13 Nov 2026 06:34:09 -0500 Subject: [PATCH 4/6] fix(web): keep polling when a due video.ready fails to send A failed send on the final poll reported both parts done, so the workflow ended with the event unsent. Finished parts now read as waiting, and the next poll tries again. The hold-up tests now finish HLS so they reach dispatch. --- .../unit/developer-webhook-dispatch.test.ts | 58 ++++++++++++++++--- apps/web/lib/developer-video-processing.ts | 28 +++++---- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts b/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts index de57a44e6ac..4146dba39db 100644 --- a/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts +++ b/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({ endpointQueries: 0, failEndpointQuery: false, hangEndpointQuery: false, + failTransactions: 0, start: vi.fn(), attempt: vi.fn(), sleep: vi.fn(), @@ -54,7 +55,13 @@ vi.mock("@cap/database", async () => { : videos.select(fields).from(), }), transaction: (run: (tx: unknown) => Promise) => { - const next = turn.then(() => run(database)); + const next = turn.then(() => { + if (mocks.failTransactions > 0) { + mocks.failTransactions -= 1; + throw new Error("connection lost"); + } + return run(database); + }); turn = next.catch(() => undefined); return next; }, @@ -205,6 +212,7 @@ beforeEach(() => { mocks.endpointQueries = 0; mocks.failEndpointQuery = false; mocks.hangEndpointQuery = false; + mocks.failTransactions = 0; mocks.start.mockResolvedValue({ runId: "run-1" }); mocks.sleep.mockResolvedValue(undefined); mocks.head.mockResolvedValue({ bytes: 2_000, contentType: "video/mp4" }); @@ -394,6 +402,32 @@ describe("video.ready", () => { expect(video().processing?.readyEventDueAt).toBeUndefined(); }); + it("keeps the workflow polling when sending a due event fails", async () => { + mocks.store.video = developerVideo({ + status: "ready", + processing: processingState({ + mp4: { state: "complete", output: "m1" }, + hls: { state: "complete", output: "h1" }, + readyEventDueAt: Date.now() - 5_000, + }), + }); + mocks.failTransactions = 1; + expect(await readDeveloperVideoParts("video-1", "encode-1")).toEqual({ + hls: "wait", + mp4: "wait", + }); + await settled(); + expect(mocks.start).not.toHaveBeenCalled(); + expect(video().processing?.readyEventDueAt).toEqual(expect.any(Number)); + + expect(await readDeveloperVideoParts("video-1", "encode-1")).toEqual({ + hls: "done", + mp4: "done", + }); + await settled(); + expect(jobs().map((job) => job.eventType)).toEqual(["video.ready"]); + }); + it("sends no second event for a video that was ready before this change", async () => { mocks.store.video = developerVideo({ status: "ready", @@ -604,26 +638,36 @@ describe("webhooks never hold up a video", () => { const hanging = new Promise(() => {}); mocks.start.mockReturnValue(hanging); - const started = Date.now(); - const result = await mp4Done(); + vi.useRealTimers(); + const started = performance.now(); + expect(await mp4Done()).toEqual({ success: true, applied: true }); + expect(await hlsDone()).toEqual({ success: true, applied: true }); + await settled(); - expect(result).toEqual({ success: true, applied: true }); + expect(mocks.start).toHaveBeenCalledTimes(1); expect(video().status).toBe("ready"); - expect(Date.now() - started).toBeLessThan(1_000); + expect(video().processing?.hls.state).toBe("complete"); + expect(performance.now() - started).toBeLessThan(1_000); }); - it("marks the video ready when queueing the delivery fails", async () => { + it("records the outputs when queueing the delivery fails", async () => { mocks.start.mockRejectedValue(new Error("queue is down")); expect(await mp4Done()).toEqual({ success: true, applied: true }); + expect(await hlsDone()).toEqual({ success: true, applied: true }); await settled(); + expect(mocks.start).toHaveBeenCalledTimes(1); expect(video().status).toBe("ready"); + expect(video().processing?.hls.state).toBe("complete"); }); - it("marks the video ready when the endpoint lookup fails", async () => { + it("records the outputs when the endpoint lookup fails", async () => { mocks.failEndpointQuery = true; expect(await mp4Done()).toEqual({ success: true, applied: true }); + expect(await hlsDone()).toEqual({ success: true, applied: true }); await settled(); + expect(mocks.endpointQueries).toBe(1); expect(video().status).toBe("ready"); + expect(video().processing?.hls.state).toBe("complete"); expect(mocks.start).not.toHaveBeenCalled(); }); diff --git a/apps/web/lib/developer-video-processing.ts b/apps/web/lib/developer-video-processing.ts index 9157c9ddc5a..3d85885ca0d 100644 --- a/apps/web/lib/developer-video-processing.ts +++ b/apps/web/lib/developer-video-processing.ts @@ -632,20 +632,26 @@ export async function readDeveloperVideoParts( const encode = await findEncode(videoId, encodeId); if (!encode) return null; const now = Date.now(); - // Thrown out of this step, a failure here would fail the video's - // processing. The next poll tries again. - if (developerVideoReadyEventDue(encode.video, now)) { - await sendDueDeveloperVideoReady(videoId).catch((error) => { - console.error("[developer-webhook] Failed to send a due event", { - videoId, - message: error instanceof Error ? error.message : String(error), - }); - }); - } - return { + const parts: DeveloperVideoPartSnapshot = { hls: developerVideoPartAction(encode.processing.hls, now), mp4: developerVideoPartAction(encode.processing.mp4, now), }; + if (!developerVideoReadyEventDue(encode.video, now)) return parts; + // Thrown out of this step, a failure here would fail the video's + // processing. Finished parts are reported as waiting instead, so the + // workflow polls again rather than ending with the event unsent. + try { + await sendDueDeveloperVideoReady(videoId); + return parts; + } catch (error) { + console.error("[developer-webhook] Failed to send a due event", { + videoId, + message: error instanceof Error ? error.message : String(error), + }); + const waiting = (action: DeveloperVideoPartAction) => + action === "done" ? "wait" : action; + return { hls: waiting(parts.hls), mp4: waiting(parts.mp4) }; + } } function webhookUrl(part: DeveloperVideoPart, token: string): string { From 43a2d2f1573a1c904c52464a05e564697ab3110e Mon Sep 17 00:00:00 2001 From: Richie McIlroy Date: Fri, 13 Nov 2026 06:34:09 -0500 Subject: [PATCH 5/6] fix(web): only quote a string in the unknown event message String() on a caller's object can throw, which turned a bad event into a 500. A value that is not a string is no longer quoted. --- apps/web/__tests__/unit/developer-webhooks-api.test.ts | 10 ++++++++++ apps/web/app/api/developer/v1/[...route]/webhooks.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/__tests__/unit/developer-webhooks-api.test.ts b/apps/web/__tests__/unit/developer-webhooks-api.test.ts index db3ac44aad8..bae4eac7ffd 100644 --- a/apps/web/__tests__/unit/developer-webhooks-api.test.ts +++ b/apps/web/__tests__/unit/developer-webhooks-api.test.ts @@ -190,6 +190,16 @@ describe("creating a webhook endpoint", () => { "Invalid request: events: events must list at least one of: video.ready, video.errored", ); expect(unknown.body.docs).toBe("https://cap.so/docs/api/rest-api"); + + const objectEvent = await create(A, { + url: "https://a.example/x", + events: [{ toString: null }], + }); + expect(objectEvent.status).toBe(400); + expect(objectEvent.body.message).toBe( + "Invalid request: events.0: Unknown event. Use video.ready or video.errored.", + ); + expect(rows()).toHaveLength(0); }); it("links webhook errors to the webhooks guide", async () => { diff --git a/apps/web/app/api/developer/v1/[...route]/webhooks.ts b/apps/web/app/api/developer/v1/[...route]/webhooks.ts index f1bc9cd82cf..1e53c1aeec1 100644 --- a/apps/web/app/api/developer/v1/[...route]/webhooks.ts +++ b/apps/web/app/api/developer/v1/[...route]/webhooks.ts @@ -34,7 +34,7 @@ const endpointFields = { .array( z.enum(WEBHOOK_EVENT_TYPES, { error: (issue) => - `Unknown event ${JSON.stringify(String(issue.input).slice(0, 100))}. Use ${WEBHOOK_EVENT_TYPES.join(" or ")}.`, + `Unknown event${typeof issue.input === "string" ? ` ${JSON.stringify(issue.input.slice(0, 100))}` : ""}. Use ${WEBHOOK_EVENT_TYPES.join(" or ")}.`, }), ) .min(1, { From 6669a20dda47095f0799c51f988196aa2fec69d5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy Date: Fri, 13 Nov 2026 06:34:09 -0500 Subject: [PATCH 6/6] docs: cap the webhook receiver example's body at 1 MB --- .../__tests__/unit/developer-webhook-docs.test.ts | 7 +++++++ apps/web/content/docs/api/webhooks.mdx | 13 +++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/unit/developer-webhook-docs.test.ts b/apps/web/__tests__/unit/developer-webhook-docs.test.ts index 8b68297960f..ce7c9a606ca 100644 --- a/apps/web/__tests__/unit/developer-webhook-docs.test.ts +++ b/apps/web/__tests__/unit/developer-webhook-docs.test.ts @@ -192,6 +192,13 @@ describe("the webhooks guide", () => { } } }; + const oversized = await post("").then(() => + fetch(url, { method: "POST", body: "x".repeat(2_000_000) }).then( + (response) => response.status, + () => "reset", + ), + ); + expect([413, "reset"]).toContain(oversized); const now = Math.floor(Date.now() / 1000); expect( (await post(signWebhookBody(`${secret}x`, body, now))).status, diff --git a/apps/web/content/docs/api/webhooks.mdx b/apps/web/content/docs/api/webhooks.mdx index 7c63d76a337..873bd4430a0 100644 --- a/apps/web/content/docs/api/webhooks.mdx +++ b/apps/web/content/docs/api/webhooks.mdx @@ -225,7 +225,7 @@ def verify_cap_webhook(raw_body: bytes, signature_header: str, secret: str) -> b ### A complete receiver -This server uses only Node.js. Save the function above as `verify-cap-webhook.mjs` next to it and start it with your signing secret in `CAP_WEBHOOK_SECRET`. It reads the raw body, checks the signature, answers `200` straight away, then handles the event. +This server uses only Node.js. Save the function above as `verify-cap-webhook.mjs` next to it and start it with your signing secret in `CAP_WEBHOOK_SECRET`. It reads the raw body, refuses one over 1 MB (Cap's events are far smaller), checks the signature, answers `200` straight away, then handles the event. ```js import { createServer } from "node:http"; @@ -250,7 +250,16 @@ createServer((req, res) => { return; } const chunks = []; - req.on("data", (chunk) => chunks.push(chunk)); + let size = 0; + req.on("data", (chunk) => { + size += chunk.length; + if (size > 1_000_000) { + res.writeHead(413).end(); + req.destroy(); + return; + } + chunks.push(chunk); + }); req.on("end", () => { const rawBody = Buffer.concat(chunks).toString("utf8"); if (!verifyCapWebhook(rawBody, req.headers["cap-signature"], secret)) {