From fa1610b053f665a56a4d8d00d14d8f2d65a605ab Mon Sep 17 00:00:00 2001 From: yusufipk Date: Wed, 22 Jul 2026 23:51:18 +0700 Subject: [PATCH] fix(api): serialize BigInt in all API success responses successResponse() used NextResponse.json(), which calls JSON.stringify and throws on BigInt. Prisma returns BigInt for VideoVersion.sizeBytes and VideoAsset.sizeBytes, so any route returning one of those rows returned 500 after its database write had already committed. #27 fixed two such endpoints by narrowing their selects, and two create routes were already wrapped in toJsonSafe(). This closes the bug class at the helper instead: successResponse() now serializes with a shared bigIntReplacer, which covers every route in app/api (none construct a NextResponse.json response directly). The two toJsonSafe() call sites are now redundant and were removed. BigInt values render as strings, matching what toJsonSafe already produced. --- .../videos/[videoId]/versions/route.ts | 3 +-- app/api/projects/[projectId]/videos/route.ts | 3 +-- lib/api-response.ts | 11 ++++++++++- lib/json-serialize.ts | 16 +++++++++++++--- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts index 44d60ea..2ab73ea 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts @@ -2,7 +2,6 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth, checkProjectAccess } from '@/lib/auth'; import { validateUrl, validateOptionalUrlOrAppPath } from '@/lib/validation'; -import { toJsonSafe } from '@/lib/json-serialize'; import { rateLimit } from '@/lib/rate-limit'; import { notifyProjectOwner } from '@/lib/notifications'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; @@ -264,7 +263,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }).catch((err) => logError('Notification failed:', err)); } - const response = successResponse(toJsonSafe(version), 201); + const response = successResponse(version, 201); return withCacheControl(response, 'private, no-store'); } catch (error) { logError('Error creating version:', error); diff --git a/app/api/projects/[projectId]/videos/route.ts b/app/api/projects/[projectId]/videos/route.ts index 66d9838..86ce0fa 100644 --- a/app/api/projects/[projectId]/videos/route.ts +++ b/app/api/projects/[projectId]/videos/route.ts @@ -2,7 +2,6 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth, checkProjectAccess } from '@/lib/auth'; import { validateUrl, validateOptionalUrlOrAppPath } from '@/lib/validation'; -import { toJsonSafe } from '@/lib/json-serialize'; import { rateLimit } from '@/lib/rate-limit'; import { notifyProjectOwner } from '@/lib/notifications'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; @@ -269,7 +268,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }).catch((err) => logError('Notification failed:', err)); } - const response = successResponse(toJsonSafe(video), 201); + const response = successResponse(video, 201); return withCacheControl(response, 'private, no-store'); } catch (error) { logError('Error creating video:', error); diff --git a/lib/api-response.ts b/lib/api-response.ts index 5a59d50..6afa021 100644 --- a/lib/api-response.ts +++ b/lib/api-response.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { bigIntReplacer } from '@/lib/json-serialize'; /** * Standardized API error response format @@ -113,6 +114,11 @@ export function errorResponse( * @param status - HTTP status code (default: 200) * @param meta - Pagination or other metadata (optional) * + * Serialized with bigIntReplacer rather than NextResponse.json(), because + * JSON.stringify throws on BigInt and Prisma returns BigInt for sizeBytes. + * Any payload carrying a VideoVersion or VideoAsset row would otherwise 500 + * after its write had already committed. BigInt values render as strings. + * * @example * ```ts * return successResponse({ projects: [] }); @@ -127,7 +133,10 @@ export function successResponse( const body: ApiSuccessResponse = { data }; if (meta) body.meta = meta; - return NextResponse.json(body, { status }); + return new NextResponse(JSON.stringify(body, bigIntReplacer), { + status, + headers: { 'content-type': 'application/json' }, + }) as NextResponse>; } export function withCacheControl(response: Response, value: string): Response { diff --git a/lib/json-serialize.ts b/lib/json-serialize.ts index 39cc85a..9343256 100644 --- a/lib/json-serialize.ts +++ b/lib/json-serialize.ts @@ -1,8 +1,18 @@ +/** + * JSON.stringify replacer that renders Prisma BigInt columns (sizeBytes) as + * strings. Without it, JSON.stringify throws on any payload carrying one. + */ +export function bigIntReplacer(_key: string, value: unknown): unknown { + return typeof value === 'bigint' ? value.toString() : value; +} + /** * Converts values for JSON responses (e.g. Prisma BigInt fields). + * + * API routes do not need this — successResponse() serializes BigInt already. + * Use it for payloads that bypass that helper, such as data handed from a + * server component to a client component. */ export function toJsonSafe(value: T): T { - return JSON.parse( - JSON.stringify(value, (_key, val) => (typeof val === 'bigint' ? val.toString() : val)) - ) as T; + return JSON.parse(JSON.stringify(value, bigIntReplacer)) as T; }