diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index d27e4219c..7d6783b0b 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -32,6 +32,8 @@ jobs: - name: Run benchmarks uses: CodSpeedHQ/action@0010eb0ca6e89b80c88e8edaaa07cfe5f3e6664d # v3.5.0 + env: + NUXT_SOCKET: 1 with: run: pnpm vitest bench token: ${{ secrets.CODSPEED_TOKEN }} diff --git a/packages/nuxi/src/commands/dev.ts b/packages/nuxi/src/commands/dev.ts index 8375c0957..d041352b5 100644 --- a/packages/nuxi/src/commands/dev.ts +++ b/packages/nuxi/src/commands/dev.ts @@ -18,6 +18,7 @@ import { isBun, isTest } from 'std-env' import { initialize } from '../dev' import { renderError } from '../dev/error' +import { isSocketURL, parseSocketURL } from '../dev/socket' import { showVersions } from '../utils/banner' import { overrideEnv } from '../utils/env' import { loadKit } from '../utils/kit' @@ -128,6 +129,8 @@ const command = defineCommand({ // Start proxy Listener const devProxy = await createDevProxy(nuxtOptions, listenOptions) + const useSocket = nuxtOptions._majorVersion === 4 || !!process.env.NUXT_SOCKET + const urls = await devProxy.listener.getURLs() // run initially in in no-fork mode const { onRestart, onReady, close } = await initialize({ @@ -141,7 +144,9 @@ const command = defineCommand({ urls, https: devProxy.listener.https, }, - }) + // if running with nuxt v4 or `NUXT_SOCKET=1`, we use the socket listener + // otherwise pass 'true' to listen on a random port instead + }, {}, useSocket ? undefined : true) onReady(address => devProxy.setAddress(address)) @@ -150,7 +155,7 @@ const command = defineCommand({ onRestart(async (devServer) => { await devServer.close() const subprocess = await fork - await subprocess.initialize(devProxy) + await subprocess.initialize(devProxy, useSocket) }) return { @@ -216,7 +221,8 @@ async function createDevProxy(nuxtOptions: NuxtOptions, listenOptions: Partial { @@ -224,8 +230,9 @@ async function createDevProxy(nuxtOptions: NuxtOptions, listenOptions: Partial { if (message.type === 'nuxt:internal:dev:context') { - initialize(message.context) + initialize(message.context, {}, message.socket ? undefined : true) } }) this.send({ type: 'nuxt:internal:dev:fork-ready' }) @@ -42,7 +42,7 @@ class IPC { const ipc = new IPC() -export async function initialize(devContext: NuxtDevContext, ctx: InitializeOptions = {}, listenOptions?: Partial) { +export async function initialize(devContext: NuxtDevContext, ctx: InitializeOptions = {}, listenOptions?: true | Partial) { const devServerOverrides = resolveDevServerOverrides({ public: devContext.public, }) diff --git a/packages/nuxi/src/dev/socket.ts b/packages/nuxi/src/dev/socket.ts new file mode 100644 index 000000000..36a204c9c --- /dev/null +++ b/packages/nuxi/src/dev/socket.ts @@ -0,0 +1,89 @@ +import type { RequestListener } from 'node:http' +import { existsSync, unlinkSync } from 'node:fs' +import { Server } from 'node:http' +import process from 'node:process' + +function generateSocketPath(prefix: string): string { + const timestamp = Date.now() + const random = Math.random().toString(36).slice(2, 8) + + if (process.platform === 'win32') { + // Windows named pipes + return `\\\\.\\pipe\\nuxt-${prefix}-${timestamp}-${random}` + } + + // Unix domain sockets + return `/tmp/nuxt-${prefix}-${timestamp}-${random}.sock` +} + +export function formatSocketURL(socketPath: string, ssl = false): string { + const protocol = ssl ? 'https' : 'http' + if (process.platform === 'win32') { + // Windows named pipes need special encoding + const encodedPath = encodeURIComponent(socketPath) + return `${protocol}+unix://${encodedPath}` + } + + // Unix sockets can use the unix: protocol + return `${protocol}+unix://${socketPath.replace(/\//g, '%2F')}` +} + +export function isSocketURL(url: string): boolean { + return url.startsWith('http+unix://') || url.startsWith('https+unix://') +} + +export function parseSocketURL(url: string): { socketPath: string, protocol: 'https' | 'http' } { + if (!isSocketURL(url)) { + throw new Error(`Invalid socket URL: ${url}`) + } + + const ssl = url.startsWith('https+unix://') + const path = url.slice(ssl ? 'https+unix://'.length : 'http+unix://'.length) + const socketPath = decodeURIComponent(path.replace(/%2F/g, '/')) + + return { socketPath, protocol: ssl ? 'https' : 'http' } +} + +export async function createSocketListener(handler: RequestListener, ssl = false) { + const socketPath = generateSocketPath('nuxt-dev') + const server = new Server(handler) + + if (process.platform !== 'win32' && existsSync(socketPath)) { + try { + unlinkSync(socketPath) + } + catch { + // suppress errors if the socket file cannot be removed + } + } + await new Promise(resolve => server.listen({ path: socketPath }, resolve)) + const url = formatSocketURL(socketPath, ssl) + return { + url, + address: { + socketPath, + address: 'localhost', + port: 3000, + }, + async close() { + try { + server.removeAllListeners() + await new Promise((resolve, reject) => server.close(err => err ? reject(err) : resolve())) + } + finally { + // Clean up socket file on Unix systems + if (process.platform !== 'win32') { + try { + unlinkSync(socketPath) + } + catch { + // suppress errors + } + } + } + }, + getURLs: async () => [{ url, type: 'network' as const }], + https: false as const, + server, + } +} diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index 7b7aa3be3..98bdce37b 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -24,9 +24,10 @@ import { loadKit } from '../utils/kit' import { loadNuxtManifest, resolveNuxtManifest, writeNuxtManifest } from '../utils/nuxt' import { renderError } from './error' +import { createSocketListener, formatSocketURL } from './socket' export type NuxtParentIPCMessage - = | { type: 'nuxt:internal:dev:context', context: NuxtDevContext } + = | { type: 'nuxt:internal:dev:context', context: NuxtDevContext, socket?: boolean } export type NuxtDevIPCMessage = | { type: 'nuxt:internal:dev:fork-ready' } @@ -67,19 +68,21 @@ interface NuxtDevServerOptions { devContext: NuxtDevContext } -export async function createNuxtDevServer(options: NuxtDevServerOptions, listenOptions?: Partial) { +export async function createNuxtDevServer(options: NuxtDevServerOptions, listenOptions?: true | Partial) { // Initialize dev server const devServer = new NuxtDevServer(options) // Attach internal listener - devServer.listener = await listen( - devServer.handler, - listenOptions || { - port: options.port ?? 0, - hostname: '127.0.0.1', - showURL: false, - }, - ) + devServer.listener = listenOptions + ? await listen(devServer.handler, typeof listenOptions === 'object' + ? listenOptions + : { port: options.port ?? 0, hostname: '127.0.0.1', showURL: false }) + : await createSocketListener(devServer.handler) + + if (process.env.DEBUG) { + // eslint-disable-next-line no-console + console.debug(`Using ${listenOptions ? 'network' : 'socket'} listener for Nuxt dev server.`) + } // Merge interface with public context devServer.listener._url = devServer.listener.url @@ -119,7 +122,7 @@ export class NuxtDevServer extends EventEmitter { handler: RequestListener listener: Pick & { _url?: string - address: AddressInfo & { socketPath?: string } + address: { socketPath: string, port: number, address: string } | AddressInfo } constructor(private options: NuxtDevServerOptions) { @@ -317,9 +320,10 @@ export class NuxtDevServer extends EventEmitter { const addr = this.listener.address this._currentNuxt.options.devServer.host = addr.address this._currentNuxt.options.devServer.port = addr.port - this._currentNuxt.options.devServer.url = getAddressURL(addr, !!this.listener.https) - this._currentNuxt.options.devServer.https = this.options.devContext.proxy - ?.https as boolean | { key: string, cert: string } + this._currentNuxt.options.devServer.url = 'socketPath' in addr + ? this.options.devContext.proxy?.url || getAddressURL(addr, !!this.listener.https) + : getAddressURL(addr, !!this.listener.https) + this._currentNuxt.options.devServer.https = this.options.devContext.proxy?.https as boolean | { key: string, cert: string } if (this.listener.https && !process.env.NODE_TLS_REJECT_UNAUTHORIZED) { console.warn('You might need `NODE_TLS_REJECT_UNAUTHORIZED=0` environment variable to make https work.') @@ -343,7 +347,7 @@ export class NuxtDevServer extends EventEmitter { }) this._handler = toNodeListener(this._currentNuxt.server.app) - this.emit('ready', `http://127.0.0.1:${addr.port}`) + this.emit('ready', 'socketPath' in addr ? formatSocketURL(addr.socketPath, !!this.listener.https) : `http://127.0.0.1:${addr.port}`) } async _watchConfig() { @@ -361,7 +365,7 @@ export class NuxtDevServer extends EventEmitter { } } -function getAddressURL(addr: AddressInfo, https: boolean) { +function getAddressURL(addr: Pick, https: boolean) { const proto = https ? 'https' : 'http' let host = addr.address.includes(':') ? `[${addr.address}]` : addr.address if (host === '[::]') {