( request: NextRequest, requestId: string )
| 84 | const WEBHOOK_BODY_LABEL = 'Webhook request body' |
| 85 | |
| 86 | export async function parseWebhookBody( |
| 87 | request: NextRequest, |
| 88 | requestId: string |
| 89 | ): Promise<{ body: unknown; rawBody: string } | NextResponse> { |
| 90 | let rawBody: string | null = null |
| 91 | try { |
| 92 | assertContentLengthWithinLimit(request.headers, WEBHOOK_MAX_BODY_BYTES, WEBHOOK_BODY_LABEL) |
| 93 | |
| 94 | const buffer = await readStreamToBufferWithLimit(request.clone().body, { |
| 95 | maxBytes: WEBHOOK_MAX_BODY_BYTES, |
| 96 | label: WEBHOOK_BODY_LABEL, |
| 97 | }) |
| 98 | rawBody = new TextDecoder().decode(buffer) |
| 99 | |
| 100 | if (!rawBody || rawBody.length === 0) { |
| 101 | return { body: {}, rawBody: '' } |
| 102 | } |
| 103 | } catch (bodyError) { |
| 104 | if (isPayloadSizeLimitError(bodyError)) { |
| 105 | logger.warn(`[${requestId}] Rejected oversized webhook body`, { |
| 106 | maxBytes: WEBHOOK_MAX_BODY_BYTES, |
| 107 | observedBytes: bodyError.observedBytes, |
| 108 | }) |
| 109 | return new NextResponse('Request body too large', { status: 413 }) |
| 110 | } |
| 111 | logger.error(`[${requestId}] Failed to read request body`, { |
| 112 | error: toError(bodyError).message, |
| 113 | }) |
| 114 | return new NextResponse('Failed to read request body', { status: 400 }) |
| 115 | } |
| 116 | |
| 117 | let body: unknown |
| 118 | try { |
| 119 | const contentType = request.headers.get('content-type') || '' |
| 120 | |
| 121 | if (contentType.includes('application/x-www-form-urlencoded')) { |
| 122 | const formData = new URLSearchParams(rawBody) |
| 123 | const payloadString = formData.get('payload') |
| 124 | |
| 125 | if (payloadString) { |
| 126 | body = JSON.parse(payloadString) |
| 127 | } else { |
| 128 | body = Object.fromEntries(formData.entries()) |
| 129 | } |
| 130 | } else { |
| 131 | body = JSON.parse(rawBody) |
| 132 | } |
| 133 | } catch (parseError) { |
| 134 | logger.error(`[${requestId}] Failed to parse webhook body`, { |
| 135 | error: toError(parseError).message, |
| 136 | contentType: request.headers.get('content-type'), |
| 137 | bodyPreview: `${rawBody?.slice(0, 100)}...`, |
| 138 | }) |
| 139 | return new NextResponse('Invalid payload format', { status: 400 }) |
| 140 | } |
| 141 | |
| 142 | return { body, rawBody } |
| 143 | } |
no test coverage detected