diff --git a/packages/devtools/src/module-main.ts b/packages/devtools/src/module-main.ts index f857a078e9..f40f61cd27 100644 --- a/packages/devtools/src/module-main.ts +++ b/packages/devtools/src/module-main.ts @@ -19,6 +19,7 @@ import { createDefaultTabOptions, setServerTasksEnabledByDefault } from './const import { packageDir, runtimeDir } from './dirs' import { setupRPC } from './server-rpc' import { skipInSSR } from './server-rpc/skip-in-ssr' +import { createDevListenerPlugin } from './utils/dev-listener' import { readLocalOptions } from './utils/local-options' const MULTIPLE_SLASHES_RE = /\/+/g @@ -197,6 +198,10 @@ export async function enableModule(options: ModuleOptions, nuxt: Nuxt) { addVitePlugin(DevTools) } + // Let Vite DevTools bind its RPC WebSocket to Nuxt's HTTPS listener rather + // than a plain-HTTP side-car port, which HTTPS pages can't reach (#1152). + addVitePlugin(createDevListenerPlugin(nuxt), { server: false }) + // Deferred: will be set when Vite DevTools plugin setup runs let connectDevToolsKit: ((ctx: any) => void | Promise) | undefined diff --git a/packages/devtools/src/utils/dev-listener.ts b/packages/devtools/src/utils/dev-listener.ts new file mode 100644 index 0000000000..334fca0af4 --- /dev/null +++ b/packages/devtools/src/utils/dev-listener.ts @@ -0,0 +1,61 @@ +import type { Buffer } from 'node:buffer' +import type { IncomingMessage } from 'node:http' +import type { Duplex } from 'node:stream' +import type { Nuxt } from 'nuxt/schema' +import type { Plugin } from 'vite' +import { Server as TlsServer } from 'node:tls' +import { DEVTOOLS_MOUNT_PATH } from '@vitejs/devtools-kit/constants' + +type UpgradeHandler = (req: IncomingMessage, socket: Duplex, head: Buffer) => unknown + +const SKIPS_DEVTOOLS_UPGRADES = Symbol.for('nuxt-devtools:skips-devtools-upgrades') + +/** + * Lets Vite DevTools serve its RPC WebSocket on Nuxt's HTTPS dev server + * (nuxt/devtools#1152). + * + * Nuxt runs Vite in middleware mode, so `viteServer.httpServer` is `null` and + * Vite DevTools opens its socket on a plain-HTTP side-car port. A page served + * over HTTPS connects to it with `wss://`, and the TLS handshake fails. This + * plugin hands Nuxt's listener to the client Vite server as `httpServer`, so + * Vite DevTools binds the socket on the page's own origin and TLS. + * + * The listener also forwards every upgrade outside `buildAssetsDir` to + * `nuxt.server.upgrade` (Nitro's dev server), which would take the DevTools + * socket as well and end it under the RPC transport. The plugin makes that + * handler skip DevTools paths. + * + * Plain-HTTP listeners keep the side-car, which they can reach. Nuxt 5's + * vite-builder sets `httpServer` itself, and the plugin leaves it alone then. + */ +export function createDevListenerPlugin(nuxt: Nuxt): Plugin { + return { + name: 'nuxt:devtools:dev-listener', + apply: 'serve', + configureServer: { + order: 'pre', + handler(server) { + // Nuxt 4 builds the SSR bundle with its own Vite server. + if (server.config.build?.ssr || server.httpServer) + return + const listener = nuxt._devServerListener + if (!(listener instanceof TlsServer)) + return + server.httpServer = listener + skipDevToolsUpgrades(nuxt.server) + }, + }, + } +} + +function skipDevToolsUpgrades(nuxtServer: { upgrade?: UpgradeHandler, [SKIPS_DEVTOOLS_UPGRADES]?: true } | undefined) { + const upgrade = nuxtServer?.upgrade + if (!nuxtServer || typeof upgrade !== 'function' || nuxtServer[SKIPS_DEVTOOLS_UPGRADES]) + return + nuxtServer[SKIPS_DEVTOOLS_UPGRADES] = true + nuxtServer.upgrade = (req, socket, head) => { + if (req.url?.startsWith(DEVTOOLS_MOUNT_PATH)) + return + return upgrade.call(nuxtServer, req, socket, head) + } +} diff --git a/packages/devtools/test/dev-listener.test.ts b/packages/devtools/test/dev-listener.test.ts new file mode 100644 index 0000000000..b7c57bc9ab --- /dev/null +++ b/packages/devtools/test/dev-listener.test.ts @@ -0,0 +1,74 @@ +import type { IncomingMessage } from 'node:http' +import type { Nuxt } from 'nuxt/schema' +import type { ViteDevServer } from 'vite' +import { Buffer } from 'node:buffer' +import { createServer as createHttpServer } from 'node:http' +import { createServer as createHttpsServer } from 'node:https' +import { describe, expect, it, vi } from 'vitest' +import { createDevListenerPlugin } from '../src/utils/dev-listener' + +function setup(options: { listener?: unknown, ssr?: boolean, httpServer?: unknown } = {}) { + const upgrade = vi.fn() + const nuxt = { + _devServerListener: 'listener' in options + ? options.listener + : createHttpsServer(), + server: { upgrade }, + } as unknown as Nuxt + const server = { + httpServer: options.httpServer ?? null, + config: { + build: { ssr: options.ssr }, + }, + } as unknown as ViteDevServer + + const hook = createDevListenerPlugin(nuxt).configureServer as { order: string, handler: (server: ViteDevServer) => void } + expect(hook.order).toBe('pre') + hook.handler(server) + + const forward = (url: string) => nuxt.server.upgrade({ url } as IncomingMessage, {} as any, Buffer.alloc(0)) + return { nuxt, server, upgrade, forward } +} + +describe('createDevListenerPlugin', () => { + it('hands an HTTPS listener to the client Vite server', () => { + const { nuxt, server } = setup() + expect(server.httpServer).toBe(nuxt._devServerListener) + }) + + it('keeps Nitro off DevTools upgrades only', () => { + const { forward, upgrade } = setup() + forward('/__devtools/__ws') + expect(upgrade).not.toHaveBeenCalled() + forward('/_ws') + expect(upgrade).toHaveBeenCalledOnce() + }) + + it('wraps the Nitro upgrade handler once across Vite restarts', () => { + const { nuxt, forward, upgrade } = setup() + const wrapped = nuxt.server.upgrade + const hook = createDevListenerPlugin(nuxt).configureServer as { handler: (server: ViteDevServer) => void } + hook.handler({ + httpServer: null, + config: { build: {} }, + } as unknown as ViteDevServer) + expect(nuxt.server.upgrade).toBe(wrapped) + forward('/_ws') + expect(upgrade).toHaveBeenCalledOnce() + }) + + it('leaves plain-HTTP listeners on the side-car', () => { + const { server, nuxt, upgrade } = setup({ + listener: createHttpServer(), + }) + expect(server.httpServer).toBeNull() + expect(nuxt.server.upgrade).toBe(upgrade) + }) + + it('leaves the SSR server, an existing httpServer, and a missing listener alone', () => { + expect(setup({ ssr: true }).server.httpServer).toBeNull() + const existing = createHttpsServer() + expect(setup({ httpServer: existing }).server.httpServer).toBe(existing) + expect(setup({ listener: undefined }).server.httpServer).toBeNull() + }) +})