Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { sandboxMiddleware } from "./middleware/sandbox.middleware.js";
import { globalRateLimiter, healthRateLimiter } from "./middleware/rate-limiter.middleware.js";
import { metricsMiddleware } from "./middleware/metrics.middleware.js";
import { requestIdMiddleware } from "./middleware/requestId.js";
import { bigIntSafeJsonMiddleware } from "./lib/serialize.js";
import v1Routes from "./routes/v1/index.js";
import healthRoutes from "./routes/health.routes.js";
import metricsRoutes from "./routes/metrics.routes.js";
Expand Down Expand Up @@ -116,6 +117,10 @@ const BULK_JSON_PATHS = [
app.use(BULK_JSON_PATHS, express.json({ limit: "1mb" }));
app.use(express.json({ limit: "100kb" }));

// BigInt-safe JSON responses (Issue #1493): Prisma bigint columns must be
// emitted as decimal strings, never throw in res.json().
app.use(bigIntSafeJsonMiddleware);

// Sandbox mode detection (before versioning)
app.use(sandboxMiddleware);

Expand Down
58 changes: 58 additions & 0 deletions backend/src/lib/serialize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* BigInt-safe JSON serialization for API responses (Issue #1493).
*
* Prisma maps `bigint` columns (stream ids, i128 amounts) to JavaScript
* `BigInt`, which `JSON.stringify` cannot encode — it throws
* `TypeError: Do not know how to serialize a BigInt`. Relying on a global
* `BigInt.prototype.toJSON` patch is fragile because it only takes effect when
* the module installing it happens to be imported, so this module provides an
* explicit replacer plus an Express middleware that applies it to every
* `res.json()` call, including error bodies.
*/
import type { NextFunction, Request, Response } from "express";

/**
* `JSON.stringify` replacer that encodes `bigint` values as decimal strings.
*
* Strings are used rather than numbers because u64/i128 values routinely exceed
* `Number.MAX_SAFE_INTEGER`, where a number would silently lose precision. All
* other values are returned unchanged.
*/
export function bigIntSafeReplacer(_key: string, value: unknown): unknown {
return typeof value === "bigint" ? value.toString() : value;
}

/**
* `JSON.stringify` with BigInt support. Returns `undefined` (matching
* `JSON.stringify`) for values that cannot be serialized, such as a bare
* `undefined`.
*/
export function stringifyJson(value: unknown): string | undefined {
return JSON.stringify(value, bigIntSafeReplacer);
}

/**
* Express middleware that routes every `res.json()` call through
* `stringifyJson`, so a BigInt anywhere in the response body — including inside
* nested objects and arrays — is emitted as a decimal string instead of
* throwing.
*
* Mount this on the app before the routers (and before `errorHandler`) so that
* every controller response and every error payload is covered.
*/
export function bigIntSafeJsonMiddleware(
_req: Request,
res: Response,
next: NextFunction,
): void {
const jsonWithBigIntSupport = (body?: unknown): Response => {
const json = stringifyJson(body);
if (!res.getHeader("Content-Type")) {
res.setHeader("Content-Type", "application/json; charset=utf-8");
}
return res.send(json);
};

res.json = jsonWithBigIntSupport as Response["json"];
next();
}
79 changes: 79 additions & 0 deletions backend/tests/serialize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
import express from 'express';
import request from 'supertest';
import {
bigIntSafeJsonMiddleware,
bigIntSafeReplacer,
stringifyJson,
} from '../src/lib/serialize.js';

describe('BigInt-safe serializer (#1493)', () => {
it('encodes a top-level BigInt as a decimal string', () => {
expect(stringifyJson({ streamId: 123456789012345678901234567890n })).toBe(
'{"streamId":"123456789012345678901234567890"}',
);
});

it('serializes nested objects and arrays containing BigInts', () => {
const payload = {
streams: [
{ streamId: 42n, meta: { depositedAmount: 9007199254740993n } },
{ streamId: 7n, withdrawable: [1n, 2n, 3n] },
],
total: 2,
};

const parsed = JSON.parse(stringifyJson(payload)!);

expect(parsed.streams[0].streamId).toBe('42');
expect(parsed.streams[0].meta.depositedAmount).toBe('9007199254740993');
expect(parsed.streams[1].withdrawable).toEqual(['1', '2', '3']);
expect(parsed.total).toBe(2);
});

it('leaves plain values untouched', () => {
const payload = {
address: 'GABC',
amount: 1.5,
active: true,
pausedAt: null,
tags: ['a', 1],
};

expect(JSON.parse(stringifyJson(payload)!)).toEqual(payload);
});

it('does not depend on a global BigInt.prototype.toJSON patch', () => {
// `stream-id.ts` installs such a patch as a side effect, but this module
// must work even when the patch was never applied (the module was not
// imported by any earlier request handler).
expect((BigInt.prototype as unknown as { toJSON?: unknown }).toJSON).toBeUndefined();
expect(stringifyJson({ value: 7n })).toBe('{"value":"7"}');
});

it('returns undefined for un-serializable input, like JSON.stringify', () => {
expect(stringifyJson(undefined)).toBeUndefined();
});

it('passes unknown keys through the replacer unchanged', () => {
expect(bigIntSafeReplacer('key', 'str')).toBe('str');
expect(bigIntSafeReplacer('key', 5n)).toBe('5');
});

it('routes every res.json() call through the serializer', async () => {
const app = express();
app.use(bigIntSafeJsonMiddleware);
app.get('/big-int', (_req, res) => {
res.json({ streamId: 9007199254740993n, nested: [{ value: 5n }] });
});

const response = await request(app).get('/big-int');

expect(response.status).toBe(200);
expect(response.headers['content-type']).toContain('application/json');
expect(response.body).toEqual({
streamId: '9007199254740993',
nested: [{ value: '5' }],
});
});
});
Loading