From bafb5a2b305bc9a4e8942bf51b599679bbe1aa5d Mon Sep 17 00:00:00 2001 From: mq1 Date: Wed, 4 Nov 2026 16:04:09 +0900 Subject: [PATCH] fix(nuxt): Keep Nitro 2 WebSocket handlers resolvable with the managed tunnel --- .../runtime/plugins/tunnel-legacy.server.ts | 3 +- .../plugins/tunnel-legacy.server.test.ts | 69 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 packages/nuxt/test/runtime/plugins/tunnel-legacy.server.test.ts diff --git a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts index 89b1a04e8958..14329835c956 100644 --- a/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts +++ b/packages/nuxt/src/runtime/plugins/tunnel-legacy.server.ts @@ -18,8 +18,9 @@ export default (nitroApp => { // Not the `request` hook: h3 still runs the first layer after a hook answered, and with a `headers` route rule // that layer throws for writing headers on the sent response. + // `/` (not `''`) so h3's WebSocket resolver skips this layer and finds the app's WebSocket handlers. nitroApp.h3App.stack.unshift({ - route: '', + route: '/', handler: eventHandler(event => { // Checked on the method and path first, so no other request's body gets wrapped in a web `Request`. if (!isServedTunnelRequest({ method: event.method, url: event.path })) { diff --git a/packages/nuxt/test/runtime/plugins/tunnel-legacy.server.test.ts b/packages/nuxt/test/runtime/plugins/tunnel-legacy.server.test.ts new file mode 100644 index 000000000000..21a328815764 --- /dev/null +++ b/packages/nuxt/test/runtime/plugins/tunnel-legacy.server.test.ts @@ -0,0 +1,69 @@ +import { createApp, createRouter, defineWebSocketHandler, eventHandler, toWebHandler } from 'h3'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import tunnelPlugin from '../../../src/runtime/plugins/tunnel-legacy.server'; + +const { handleServedTunnelRequest, isServedTunnelRequest } = vi.hoisted(() => ({ + handleServedTunnelRequest: vi.fn(), + isServedTunnelRequest: vi.fn(), +})); + +vi.mock('@sentry/core/server', () => ({ + _INTERNAL_handleServedTunnelRequest: handleServedTunnelRequest, + _INTERNAL_isServedTunnelRequest: isServedTunnelRequest, + _INTERNAL_setManagedTunnelDefaults: vi.fn(), +})); + +vi.mock('nitropack/runtime', () => ({ + useRuntimeConfig: () => ({ app: { baseURL: '/' } }), +})); + +function setupApp() { + const app = createApp(); + const router = createRouter(); + const upgrade = vi.fn(); + router.use('/ws/:code', defineWebSocketHandler({ upgrade })); + router.get( + '/hello', + eventHandler(() => 'hello'), + ); + // Nitro mounts its router on the app base URL + app.use('/', router.handler); + + tunnelPlugin({ h3App: app } as unknown as Parameters[0]); + + return { app, upgrade }; +} + +describe('tunnel-legacy.server', () => { + beforeEach(() => { + vi.resetAllMocks(); + isServedTunnelRequest.mockImplementation(({ url }: { url: string }) => url === '/tunnel'); + handleServedTunnelRequest.mockResolvedValue(new Response('tunneled')); + }); + + it("keeps the app's WebSocket handlers resolvable", async () => { + const { app, upgrade } = setupApp(); + + const hooks = await app.websocket.resolve!(new Request('http://localhost/ws/abc') as never); + + expect(hooks.upgrade).toBe(upgrade); + }); + + it('answers tunnel requests', async () => { + const { app } = setupApp(); + + const response = await toWebHandler(app)(new Request('http://localhost/tunnel', { method: 'POST', body: '{}' })); + + expect(await response.text()).toBe('tunneled'); + expect(handleServedTunnelRequest).toHaveBeenCalledTimes(1); + }); + + it('hands other requests to the next layer', async () => { + const { app } = setupApp(); + + const response = await toWebHandler(app)(new Request('http://localhost/hello')); + + expect(await response.text()).toBe('hello'); + expect(handleServedTunnelRequest).not.toHaveBeenCalled(); + }); +});