Skip to content

Array subclass responses silently drop all middleware-set headers (CORS, cache-control, etc.) #1842

@dcitron-espoc

Description

@dcitron-espoc

Description

When a route handler returns an Array subclass (e.g. postgres.js RowList, Bun SQL results), all middleware-set response headers are silently discarded — including CORS headers, cache-control, and any custom headers set via set.headers. The JSON body serializes correctly, but the response arrives at the browser with no CORS headers, causing it to reject the response with a misleading "CORS error". No error is thrown or logged server-side.

This is extremely difficult to debug because:

  • The server returns 200 with valid JSON
  • No error appears in server logs
  • The browser shows "CORS error" even though CORS is configured correctly
  • Only Array subclasses are affected — plain arrays work fine

Reproduction

import { Elysia } from 'elysia'

// Simulates postgres.js RowList or Bun.sql results
class RowList<T> extends Array<T> {
  command = 'SELECT'
  count = 0
}

const app = new Elysia()
  .onTransform(({ set }) => {
    set.headers['access-control-allow-origin'] = '*'
    set.headers['x-custom'] = 'test'
  })
  .get('/', () => {
    const result = new RowList()
    result.push({ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' })
    return result
  })
  .listen(3000)
$ curl -D- http://localhost:3000/ -H 'Origin: http://example.com'
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
# ← access-control-allow-origin is MISSING
# ← x-custom is MISSING

[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]

Changing RowList to a plain Array makes headers appear correctly.

Root Cause

In src/adapter/bun/handler.ts and src/adapter/web-standard/handler.ts, mapResponse and mapEarlyResponse use response?.constructor?.name to dispatch response types:

switch (handleSet(set), response?.constructor?.name) {
  case 'Array':
  case 'Object':
    return Response.json(response, set)  // ✅ passes set → headers preserved
  // ...
  default:
    // ...
    if (Array.isArray(response))
      return Response.json(response)  // ❌ missing set → headers lost!
}

For a plain Array, constructor.name is "Array" and the explicit case handles it correctly with set. For an Array subclass like RowList, constructor.name is "RowList", which falls through to default. The Array.isArray() fallback correctly identifies it as an array but calls Response.json(response) without set, silently discarding all middleware-added headers.

In the web-standard adapter, it's even more explicit — the fallback constructs an entirely new headers object:

if (Array.isArray(response))
  return new Response(JSON.stringify(response), {
    headers: { 'Content-Type': 'application/json' }  // ← set completely replaced
  })

Affected Code Paths

The bug exists in 6 locations (3 per adapter):

  • mapResponse default case (bun + web-standard)
  • mapEarlyResponse default case, headers-present branch (bun + web-standard)
  • mapEarlyResponse default case, else branch (bun + web-standard)

Relation to #1656

Issue #1656 reported the same root cause (Array subclasses from database queries) but focused on body serialization ([object Object] instead of JSON). PR #1675 fixed the body serialization by adding the Array.isArray() fallback, but the fallback was added without passing set, leaving the header loss unaddressed.

Environment

  • Elysia: 1.4.28
  • Bun: 1.3.11
  • Affects both bun and web-standard adapters
  • Triggered by: postgres.js (RowList), Bun SQL, or any custom Array subclass

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions