Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/devtools/src/module-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void>) | undefined

Expand Down
61 changes: 61 additions & 0 deletions packages/devtools/src/utils/dev-listener.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
74 changes: 74 additions & 0 deletions packages/devtools/test/dev-listener.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading