Skip to content

[Bug] overrideGlobalObjects: true breaks openapi-fetch middleware instanceof checks #321

Description

@haleygu64

What version of Hono are you using?

  • hono: 4.11.7
  • @hono/node-server: 1.19.9

What runtime/OS are you seeing the problem on?

  • Bun 1.2.19 (Linux)
  • Also reproduced on Node.js v22.17.1

What steps can reproduce the bug?

When @hono/node-server's serve() runs with overrideGlobalObjects: true (the default), it replaces globalThis.Request and globalThis.Response with lightweight polyfills. Libraries that validate objects via instanceof Request or instanceof Response break silently because the objects returned by native fetch() were constructed with the original classes, not Hono's replacements.

Minimal reproduction with openapi-fetch@0.14.1:

import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import createClient, { type Middleware } from 'openapi-fetch';

type Paths = {
  '/api/v1/data': {
    get: {
      responses: { 200: { content: { 'application/json': { ok: boolean } } } };
    };
  };
};

const apiClient = createClient<Paths>({ baseUrl: 'http://localhost:19100' });

// Standard logging middleware — returns the same request/response unmodified
const logMiddleware: Middleware = {
  async onRequest({ request }) {
    console.log(`--> ${request.method} ${request.url}`);
    return request;
  },
  async onResponse({ response }) {
    console.log(`<-- ${response.status} ${response.url}`);
    return response;
  },
};
apiClient.use(logMiddleware);

const app = new Hono();
app.get('/proxy', async (c) => {
  const { data, error } = await apiClient.GET('/api/v1/data');
  if (error) return c.json({ error }, 502);
  return c.json(data);
});

// overrideGlobalObjects defaults to true — this is the trigger
serve({ fetch: app.fetch, port: 3000 });

Upstream mock (any server returning JSON on :19100).

Trigger:

curl http://localhost:3000/proxy

Bun test proving the issue

import { describe, test, expect, beforeAll, afterAll, afterEach } from 'bun:test';
import { Hono } from 'hono';
import { serve, type ServerType } from '@hono/node-server';
import createClient, { type Middleware } from 'openapi-fetch';

type Paths = {
  '/api/v1/secrets': {
    get: {
      responses: {
        200: {
          content: { 'application/json': { data: Record<string, string> } };
        };
      };
    };
  };
};

const MOCK_PORT = 19_100;
const CLIENT_PORT = 19_101;
const OriginalRequest = globalThis.Request;
const OriginalResponse = globalThis.Response;

const middleware: Middleware = {
  async onRequest({ request }) {
    console.log(`--> ${request.method} ${request.url}`);
    return request;
  },
  async onResponse({ response }) {
    console.log(`<-- ${response.status} ${response.url}`);
    return response;
  },
};

function buildMockApp() {
  const app = new Hono();
  app.get('/api/v1/secrets', (c) =>
    c.json({ data: { apiKey: 'sk-live-XXX', dbPassword: 'secret' } }),
  );
  return app;
}

describe('overrideGlobalObjects breaks instanceof checks', () => {
  let mockServer: ReturnType<typeof Bun.serve>;
  let clientServer: ServerType | undefined;

  beforeAll(() => {
    mockServer = Bun.serve({ port: MOCK_PORT, fetch: buildMockApp().fetch });
  });
  afterEach(() => {
    clientServer?.close();
    clientServer = undefined;
    globalThis.Request = OriginalRequest;
    globalThis.Response = OriginalResponse;
  });
  afterAll(() => { mockServer?.stop(); });

  test('overrideGlobalObjects: true → middleware instanceof check fails', async () => {
    const apiClient = createClient<Paths>({ baseUrl: `http://localhost:${MOCK_PORT}` });
    apiClient.use(middleware);

    const app = new Hono();
    app.get('/fetch', async (c) => {
      try {
        const { data, error } = await apiClient.GET('/api/v1/secrets');
        if (error) return c.json({ error: 'upstream' }, 502);
        return c.json(data);
      } catch (err) {
        return c.json({ error: 'middleware_failure', message: String(err) }, 500);
      }
    });

    clientServer = serve({ fetch: app.fetch, port: CLIENT_PORT }); // default: overrideGlobalObjects true
    await Bun.sleep(200);

    expect(globalThis.Response).not.toBe(OriginalResponse); // confirms override

    const res = await fetch(`http://localhost:${CLIENT_PORT}/fetch`);
    const body = await res.json() as { error: string; message: string };
    expect(res.status).toBe(500);
    expect(body.message).toContain('onResponse: must return new Response() when modifying the response');
  });

  test('overrideGlobalObjects: false → same middleware works fine', async () => {
    const apiClient = createClient<Paths>({ baseUrl: `http://localhost:${MOCK_PORT}` });
    apiClient.use(middleware);

    const app = new Hono();
    app.get('/fetch', async (c) => {
      const { data, error } = await apiClient.GET('/api/v1/secrets');
      if (error) return c.json({ error: 'upstream' }, 502);
      return c.json(data);
    });

    clientServer = serve({ fetch: app.fetch, port: CLIENT_PORT, overrideGlobalObjects: false });
    await Bun.sleep(200);

    const res = await fetch(`http://localhost:${CLIENT_PORT}/fetch`);
    const body = await res.json() as { data: Record<string, string> };
    expect(res.status).toBe(200);
    expect(body.data.apiKey).toBe('sk-live-XXX');
  });
});

What is the expected behavior?

serve() should not break instanceof checks in third-party libraries. Returning the same Request/Response object from middleware callbacks should work regardless of overrideGlobalObjects.

What do you see instead?

Error: onResponse: must return new Response() when modifying the response
    at coreFetch (openapi-fetch/dist/index.mjs:165:23)

openapi-fetch internally validates middleware returns with:

if (result) {
  if (!(result instanceof Response)) {
    throw new Error("onResponse: must return new Response() when modifying the response");
  }
}

The Response object returned by native fetch() was constructed with the original Response class, but after overrideGlobalObjects, instanceof checks against Hono's replacement polyfill — which fails.

Root cause

@hono/node-server's overrideGlobalObjects replaces globalThis.Request and globalThis.Response with lightweight alternatives. Any library relying on instanceof Request or instanceof Response to validate objects created by the native fetch() will break, because native fetch constructs objects using the original constructors, not the replacements.

Additional context

This affects any library that uses instanceof Request or instanceof Response for validation, not just openapi-fetch. The pattern is common in HTTP middleware ecosystems (ky, wretch, custom fetch wrappers, etc.).

Activity

  1. changed the title [-][Bugs] overrideGlobalObjects: true breaks openapi-fetch middleware instanceof checks[/-] [+][Bug] overrideGlobalObjects: true breaks openapi-fetch middleware instanceof checks[/+] on Mar 12, 2026
  2. mahmoodhamdi commented on Mar 15, 2026

    @mahmoodhamdi

    This is a classic problem with global object replacement — instanceof checks break across realm boundaries when the constructor changes.

    The core issue: @hono/node-server replaces globalThis.Request with a lightweight polyfill, but native fetch() still returns instances of the original Request/Response. Libraries using instanceof Request then fail because the prototype chain doesn't match.

    A few potential solutions:

    1. Save and expose the original globals before overriding, so libraries can check against both:
    const OriginalRequest = globalThis.Request
    // After override, check:
    obj instanceof Request || obj instanceof OriginalRequest
    1. Use Symbol.hasInstance on the polyfill to accept both original and polyfill instances:
    class LightweightRequest {
      static [Symbol.hasInstance](instance) {
        return instance instanceof OriginalRequest || 
               Object.getPrototypeOf(instance) === LightweightRequest.prototype
      }
    }
    1. Duck-type instead of instanceof — check for expected properties rather than constructor identity. This is the most robust approach for cross-realm compatibility.

    Option 2 would be the least disruptive fix on the @hono/node-server side.

  3. dadajam4 commented on Sep 21, 2026

    @dadajam4

    We ran into this on 2.1.1 as well — Response$1 is still a standalone class there, so it survived the 2.x rewrite. Same trigger as the original report: openapi-fetch's onResponse check, which turned every server-rendered page that called our API into a 500.

    We're using overrideGlobalObjects: false as a workaround. It works, but it isn't free: a string body no longer takes the responseViaCache shortcut, so every response goes through getReader() and a rebuilt header record instead. On our SSR path that's under 1%, but on a server that doesn't do much per request it's closer to half the throughput.

    The Symbol.hasInstance idea above looks much better from here. I measured it and it's about 5ns per check against a native Response, and no slower than today for node-server's own responses — nothing next to what the flag costs on every response.

    One small thing: the order in that snippet matters. Checking the brand first is roughly twice as slow; instanceof GlobalResponse first is the fast one.

    Happy to put up a PR if that's useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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