Skip to content

Commit 5587569

Browse files
committed
fix(response): properly serialize Array subclasses like Bun SQL results
Fixes #1656 Problem: When returning an Array subclass (e.g., Bun SQL query results), Elysia incorrectly returned '[object Object][object Object]' instead of valid JSON. Root Cause: The response handlers (mapResponse, mapEarlyResponse, mapCompactResponse) used `response?.constructor?.name === 'Array'` to detect arrays. This check fails for Array subclasses because their constructor.name is the subclass name (e.g., 'SQLResults'), not 'Array'. The response then fell through to `new Response(response)` which implicitly calls toString(), producing '[object Object]' for each element. Solution: Added `Array.isArray(response)` check in the default case of all response mapping functions. This correctly identifies all arrays including subclasses, ensuring proper JSON serialization with the correct Content-Type header. Changes: - src/adapter/bun/handler.ts: Add Array.isArray check in mapResponse, mapEarlyResponse (both branches), and mapCompactResponse default cases - src/adapter/web-standard/handler.ts: Same changes for web standard adapter - test/response/array-subclass.test.ts: Add 10 comprehensive tests covering SQL result simulation, ORM result sets, nested data, and edge cases Testing: - All 1456 tests pass (10 new tests added) - Verified with simulated Bun SQL results that previously returned '[object Object][object Object]' now correctly return JSON
1 parent f027642 commit 5587569

File tree

3 files changed

+265
-0
lines changed

3 files changed

+265
-0
lines changed

src/adapter/bun/handler.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ export const mapResponse = (
140140
}
141141
}
142142

143+
// Handle Array subclasses (e.g., Bun SQL results)
144+
if (Array.isArray(response)) {
145+
set.headers['content-type'] = 'application/json'
146+
return new Response(JSON.stringify(response), set as any)
147+
}
148+
143149
return new Response(response as any, set as any)
144150
}
145151
}
@@ -279,6 +285,12 @@ export const mapEarlyResponse = (
279285
}
280286
}
281287

288+
// Handle Array subclasses (e.g., Bun SQL results)
289+
if (Array.isArray(response)) {
290+
set.headers['content-type'] = 'application/json'
291+
return new Response(JSON.stringify(response), set as any)
292+
}
293+
282294
return new Response(response as any, set as any)
283295
}
284296
} else
@@ -397,6 +409,13 @@ export const mapEarlyResponse = (
397409
}
398410
}
399411

412+
// Handle Array subclasses (e.g., Bun SQL results)
413+
if (Array.isArray(response)) {
414+
return new Response(JSON.stringify(response), {
415+
headers: { 'content-type': 'application/json' }
416+
})
417+
}
418+
400419
return new Response(response as any)
401420
}
402421
}
@@ -515,6 +534,13 @@ export const mapCompactResponse = (
515534
}
516535
}
517536

537+
// Handle Array subclasses (e.g., Bun SQL results)
538+
if (Array.isArray(response)) {
539+
return new Response(JSON.stringify(response), {
540+
headers: { 'Content-Type': 'application/json' }
541+
})
542+
}
543+
518544
return new Response(response as any)
519545
}
520546
}

src/adapter/web-standard/handler.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ export const mapResponse = (
172172
}
173173
}
174174

175+
// Handle Array subclasses (e.g., Bun SQL results)
176+
if (Array.isArray(response)) {
177+
set.headers['content-type'] = 'application/json'
178+
return new Response(JSON.stringify(response), set as any)
179+
}
180+
175181
return new Response(response as any, set as any)
176182
}
177183
}
@@ -312,6 +318,12 @@ export const mapEarlyResponse = (
312318
}
313319
}
314320

321+
// Handle Array subclasses (e.g., Bun SQL results)
322+
if (Array.isArray(response)) {
323+
set.headers['content-type'] = 'application/json'
324+
return new Response(JSON.stringify(response), set as any)
325+
}
326+
315327
return new Response(response as any, set as any)
316328
}
317329
} else
@@ -431,6 +443,13 @@ export const mapEarlyResponse = (
431443
}
432444
}
433445

446+
// Handle Array subclasses (e.g., Bun SQL results)
447+
if (Array.isArray(response)) {
448+
return new Response(JSON.stringify(response), {
449+
headers: { 'content-type': 'application/json' }
450+
})
451+
}
452+
434453
return new Response(response as any)
435454
}
436455
}
@@ -553,6 +572,13 @@ export const mapCompactResponse = (
553572
}
554573
}
555574

575+
// Handle Array subclasses (e.g., Bun SQL results)
576+
if (Array.isArray(response)) {
577+
return new Response(JSON.stringify(response), {
578+
headers: { 'Content-Type': 'application/json' }
579+
})
580+
}
581+
556582
return new Response(response as any)
557583
}
558584
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { Elysia } from '../../src'
2+
import { describe, expect, it } from 'bun:test'
3+
import { req } from '../utils'
4+
5+
/**
6+
* Test for issue #1656: Returning non-JSON serializable response when using Elysia with Bun SQL
7+
*
8+
* Problem: When returning an Array subclass (like Bun SQL results), Elysia was using
9+
* constructor.name === 'Array' to detect arrays, which fails for subclasses.
10+
* This caused the response to fall through to `new Response(response)` which
11+
* implicitly calls toString() resulting in "[object Object][object Object]".
12+
*
13+
* Fix: Use Array.isArray() to properly detect all arrays including subclasses.
14+
*/
15+
16+
// Simulate Bun SQL results - an array subclass with extra properties
17+
class SQLResults extends Array<any> {
18+
statement: string = 'SELECT * FROM users'
19+
columns: string[] = ['id', 'name']
20+
21+
constructor(...items: any[]) {
22+
super(...items)
23+
Object.setPrototypeOf(this, SQLResults.prototype)
24+
}
25+
}
26+
27+
// Another common case: TypedArray-like results from ORMs
28+
class ORMResultSet extends Array<any> {
29+
query: string
30+
duration: number
31+
32+
constructor(query: string, duration: number, ...items: any[]) {
33+
super(...items)
34+
this.query = query
35+
this.duration = duration
36+
Object.setPrototypeOf(this, ORMResultSet.prototype)
37+
}
38+
}
39+
40+
describe('Array Subclass Response Serialization', () => {
41+
describe('Issue #1656 - Bun SQL results', () => {
42+
it('should serialize Array subclass (SQLResults) as JSON', async () => {
43+
const app = new Elysia().get('/', () => {
44+
return new SQLResults(
45+
{ id: 1, name: 'Alice' },
46+
{ id: 2, name: 'Bob' }
47+
)
48+
})
49+
50+
const response = await app.handle(req('/'))
51+
const data = await response.json()
52+
53+
expect(response.headers.get('content-type')).toBe('application/json')
54+
expect(data).toEqual([
55+
{ id: 1, name: 'Alice' },
56+
{ id: 2, name: 'Bob' }
57+
])
58+
})
59+
60+
it('should serialize Array subclass with set headers', async () => {
61+
const app = new Elysia().get('/', ({ set }) => {
62+
set.headers['X-Custom'] = 'test'
63+
return new SQLResults({ id: 1, name: 'Alice' })
64+
})
65+
66+
const response = await app.handle(req('/'))
67+
const data = await response.json()
68+
69+
expect(response.headers.get('content-type')).toBe('application/json')
70+
expect(response.headers.get('X-Custom')).toBe('test')
71+
expect(data).toEqual([{ id: 1, name: 'Alice' }])
72+
})
73+
74+
it('should handle empty Array subclass', async () => {
75+
const app = new Elysia().get('/', () => {
76+
return new SQLResults()
77+
})
78+
79+
const response = await app.handle(req('/'))
80+
const data = await response.json()
81+
82+
expect(response.headers.get('content-type')).toBe('application/json')
83+
expect(data).toEqual([])
84+
})
85+
86+
it('should serialize ORM result sets correctly', async () => {
87+
const app = new Elysia().get('/', () => {
88+
return new ORMResultSet(
89+
'SELECT * FROM products',
90+
42,
91+
{ id: 1, name: 'Widget', price: 9.99 },
92+
{ id: 2, name: 'Gadget', price: 19.99 }
93+
)
94+
})
95+
96+
const response = await app.handle(req('/'))
97+
const data = await response.json()
98+
99+
expect(response.headers.get('content-type')).toBe('application/json')
100+
expect(data).toEqual([
101+
{ id: 1, name: 'Widget', price: 9.99 },
102+
{ id: 2, name: 'Gadget', price: 19.99 }
103+
])
104+
})
105+
})
106+
107+
describe('mapEarlyResponse with Array subclass', () => {
108+
it('should serialize Array subclass in beforeHandle', async () => {
109+
const app = new Elysia()
110+
.get('/', () => 'fallback', {
111+
beforeHandle: () => {
112+
return new SQLResults(
113+
{ id: 1, name: 'Early' }
114+
)
115+
}
116+
})
117+
118+
const response = await app.handle(req('/'))
119+
const data = await response.json()
120+
121+
expect(response.headers.get('content-type')).toBe('application/json')
122+
expect(data).toEqual([{ id: 1, name: 'Early' }])
123+
})
124+
125+
it('should serialize Array subclass in beforeHandle with set headers', async () => {
126+
const app = new Elysia()
127+
.get('/', ({ set }) => {
128+
set.headers['X-Test'] = 'value'
129+
return 'fallback'
130+
}, {
131+
beforeHandle: ({ set }) => {
132+
set.status = 201
133+
return new SQLResults({ id: 1, created: true })
134+
}
135+
})
136+
137+
const response = await app.handle(req('/'))
138+
const data = await response.json()
139+
140+
expect(response.status).toBe(201)
141+
expect(response.headers.get('content-type')).toBe('application/json')
142+
expect(data).toEqual([{ id: 1, created: true }])
143+
})
144+
})
145+
146+
describe('Nested Array subclasses', () => {
147+
it('should handle Array subclass containing nested arrays', async () => {
148+
const app = new Elysia().get('/', () => {
149+
return new SQLResults(
150+
{ id: 1, tags: ['a', 'b', 'c'] },
151+
{ id: 2, tags: ['d', 'e'] }
152+
)
153+
})
154+
155+
const response = await app.handle(req('/'))
156+
const data = await response.json()
157+
158+
expect(data).toEqual([
159+
{ id: 1, tags: ['a', 'b', 'c'] },
160+
{ id: 2, tags: ['d', 'e'] }
161+
])
162+
})
163+
164+
it('should handle Array subclass containing nested objects', async () => {
165+
const app = new Elysia().get('/', () => {
166+
return new SQLResults(
167+
{
168+
id: 1,
169+
user: { name: 'Alice', email: 'alice@example.com' },
170+
orders: [{ orderId: 100 }, { orderId: 101 }]
171+
}
172+
)
173+
})
174+
175+
const response = await app.handle(req('/'))
176+
const data = await response.json()
177+
178+
expect(data).toEqual([
179+
{
180+
id: 1,
181+
user: { name: 'Alice', email: 'alice@example.com' },
182+
orders: [{ orderId: 100 }, { orderId: 101 }]
183+
}
184+
])
185+
})
186+
})
187+
188+
describe('Regular arrays still work', () => {
189+
it('should still serialize regular arrays', async () => {
190+
const app = new Elysia().get('/', () => {
191+
return [{ id: 1 }, { id: 2 }]
192+
})
193+
194+
const response = await app.handle(req('/'))
195+
const data = await response.json()
196+
197+
expect(response.headers.get('content-type')).toBe('application/json')
198+
expect(data).toEqual([{ id: 1 }, { id: 2 }])
199+
})
200+
201+
it('should still serialize plain objects', async () => {
202+
const app = new Elysia().get('/', () => {
203+
return { message: 'hello' }
204+
})
205+
206+
const response = await app.handle(req('/'))
207+
const data = await response.json()
208+
209+
expect(response.headers.get('content-type')).toBe('application/json')
210+
expect(data).toEqual({ message: 'hello' })
211+
})
212+
})
213+
})

0 commit comments

Comments
 (0)