diff --git a/packages/devtools-kit/src/_types/client-api.ts b/packages/devtools-kit/src/_types/client-api.ts index 121696f979..9ac2b14e04 100644 --- a/packages/devtools-kit/src/_types/client-api.ts +++ b/packages/devtools-kit/src/_types/client-api.ts @@ -1,5 +1,4 @@ import type {} from '@nuxt/schema' -import type { BirpcReturn } from 'birpc' import type { Hookable } from 'hookable' import type { NuxtApp } from 'nuxt/app' import type { AppConfig } from 'nuxt/schema' @@ -7,7 +6,7 @@ import type { $Fetch } from 'ofetch' import type { BuiltinLanguage } from 'shiki' import type { Ref } from 'vue' import type { HookInfo, LoadingTimeMetric, PluginMetric } from './integrations' -import type { ClientFunctions, ServerFunctions } from './rpc' +import type { ServerFunctions } from './rpc' import type { TimelineMetrics } from './timeline-metrics' export interface DevToolsFrameState { @@ -106,8 +105,12 @@ export interface CodeHighlightOptions { grammarContextCode?: string } +export type AsyncServerFunctions = { + [K in keyof ServerFunctions]: (...args: Parameters) => Promise>> +} + export interface NuxtDevtoolsClient { - rpc: BirpcReturn + rpc: AsyncServerFunctions renderCodeHighlight: (code: string, lang?: BuiltinLanguage, options?: CodeHighlightOptions) => { code: string supported: boolean @@ -115,7 +118,7 @@ export interface NuxtDevtoolsClient { renderMarkdown: (markdown: string) => string colorMode: string - extendClientRpc: , ClientFunctions extends object = Record>(name: string, functions: ClientFunctions) => BirpcReturn + extendClientRpc: , ClientFunctions extends object = Record>(name: string, functions: ClientFunctions) => ServerFunctions } export interface NuxtDevtoolsIframeClient { diff --git a/packages/devtools-kit/src/_types/options.ts b/packages/devtools-kit/src/_types/options.ts index 3e78dc757c..f637bfb7d1 100644 --- a/packages/devtools-kit/src/_types/options.ts +++ b/packages/devtools-kit/src/_types/options.ts @@ -44,12 +44,7 @@ export interface ModuleOptions { viteInspect?: boolean /** - * Disable dev time authorization check. - * - * **NOT RECOMMENDED**, only use this if you know what you are doing. - * - * @see https://github.com/nuxt/devtools/pull/257 - * @default false + * @deprecated Auth is now handled by Vite DevTools. This option is ignored. */ disableAuthorization?: boolean diff --git a/packages/devtools-kit/src/_types/server-ctx.ts b/packages/devtools-kit/src/_types/server-ctx.ts index 8cbb2f70d9..f3c194c29d 100644 --- a/packages/devtools-kit/src/_types/server-ctx.ts +++ b/packages/devtools-kit/src/_types/server-ctx.ts @@ -1,8 +1,26 @@ -import type { BirpcGroup } from 'birpc' import type { Nuxt, NuxtDebugModuleMutationRecord } from 'nuxt/schema' import type { ModuleOptions } from './options' import type { ClientFunctions, ServerFunctions } from './rpc' +/** + * Compatibility RPC interface that supports broadcast and function access. + * Backed by Vite DevTools Kit's RpcFunctionsHost internally. + */ +export interface NuxtDevtoolsRpc { + /** + * Broadcast proxy for calling client functions. + * Supports `rpc.broadcast.refresh.asEvent(event)` pattern for backward compatibility. + */ + broadcast: { + [K in keyof ClientFunctions]: ClientFunctions[K] & { asEvent: ClientFunctions[K] } + } + + /** + * Proxy for accessing server functions locally. + */ + functions: ServerFunctions +} + /** * @internal */ @@ -10,7 +28,7 @@ export interface NuxtDevtoolsServerContext { nuxt: Nuxt options: ModuleOptions - rpc: BirpcGroup + rpc: NuxtDevtoolsRpc /** * Hook to open file in editor @@ -23,11 +41,11 @@ export interface NuxtDevtoolsServerContext { refresh: (event: keyof ServerFunctions) => void /** - * Ensure dev auth token is valid, throw if not + * @deprecated Auth is now handled by Vite DevTools. This is a noop. */ ensureDevAuthToken: (token: string) => Promise - extendServerRpc: , ServerFunctions extends object = Record>(name: string, functions: ServerFunctions) => BirpcGroup + extendServerRpc: , ServerFunctions extends object = Record>(name: string, functions: ServerFunctions) => { broadcast: ClientFunctions } } export interface NuxtDevtoolsInfo { diff --git a/packages/devtools-kit/src/index.ts b/packages/devtools-kit/src/index.ts index 72904b2962..788f73bfeb 100644 --- a/packages/devtools-kit/src/index.ts +++ b/packages/devtools-kit/src/index.ts @@ -1,4 +1,3 @@ -import type { BirpcGroup } from 'birpc' import type { ChildProcess } from 'node:child_process' import type { Result } from 'tinyexec' import type { ModuleCustomTab, NuxtDevtoolsInfo, NuxtDevtoolsServerContext, SubprocessOptions, TerminalState } from './types' @@ -132,11 +131,16 @@ export function startSubprocess( } } +/** + * Extend server RPC with namespaced functions. + * + * Returns an object with a `broadcast` proxy for calling client functions. + */ export function extendServerRpc, ServerFunctions extends object = Record>( namespace: string, functions: ServerFunctions, nuxt = useNuxt(), -): BirpcGroup { +): { broadcast: ClientFunctions } { const ctx = _getContext(nuxt) if (!ctx) throw new Error('[Nuxt DevTools] Failed to get devtools context.') diff --git a/packages/devtools/client/components/AuthConfirmDialog.vue b/packages/devtools/client/components/AuthConfirmDialog.vue index b433b8ee4c..04d85e1bdb 100644 --- a/packages/devtools/client/components/AuthConfirmDialog.vue +++ b/packages/devtools/client/components/AuthConfirmDialog.vue @@ -1,17 +1,3 @@ - - diff --git a/packages/devtools/client/components/AuthRequiredPanel.vue b/packages/devtools/client/components/AuthRequiredPanel.vue index bbe104f43f..5535dd5e18 100644 --- a/packages/devtools/client/components/AuthRequiredPanel.vue +++ b/packages/devtools/client/components/AuthRequiredPanel.vue @@ -1,76 +1,4 @@ - - diff --git a/packages/devtools/client/composables/dev-auth.ts b/packages/devtools/client/composables/dev-auth.ts index 213454e597..44afac4ac1 100644 --- a/packages/devtools/client/composables/dev-auth.ts +++ b/packages/devtools/client/composables/dev-auth.ts @@ -1,88 +1,26 @@ -import { devtoolsUiShowNotification } from '#imports' -import { until } from '@vueuse/core' import { parseUA } from 'ua-parser-modern' import { ref } from 'vue' -import { AuthConfirm } from './dialog' -import { rpc } from './rpc' -export const devAuthToken = ref(localStorage.getItem('__nuxt_dev_token__')) +/** @deprecated Auth is now handled by Vite DevTools */ +export const devAuthToken = ref('disabled') -export const isDevAuthed = ref(false) +/** @deprecated Auth is now handled by Vite DevTools */ +export const isDevAuthed = ref(true) -const bc = new BroadcastChannel('__nuxt_dev_token__') - -bc.addEventListener('message', (e) => { - if (e.data.event === 'new-token') { - if (e.data.data === devAuthToken.value) - return - const token = e.data.data - rpc.verifyAuthToken(token) - .then((result) => { - devAuthToken.value = result ? token : null - isDevAuthed.value = result - }) - } -}) - -export function updateDevAuthToken(token: string) { - devAuthToken.value = token - isDevAuthed.value = true - localStorage.setItem('__nuxt_dev_token__', token) - bc.postMessage({ event: 'new-token', data: token }) +/** @deprecated Auth is now handled by Vite DevTools */ +export function updateDevAuthToken(_token: string) { + console.warn('[nuxt-devtools] `updateDevAuthToken` is deprecated. Auth is now handled by Vite DevTools.') } +/** @deprecated Auth is now handled by Vite DevTools */ export async function ensureDevAuthToken() { - if (isDevAuthed.value) - return devAuthToken.value! - - if (!devAuthToken.value) - await authConfirmAction() - - isDevAuthed.value = await rpc.verifyAuthToken(devAuthToken.value!) - if (!isDevAuthed.value) { - devAuthToken.value = null - devtoolsUiShowNotification({ - message: 'Invalid auth token, action canceled', - icon: 'i-carbon-warning-alt', - classes: 'text-red', - }) - await authConfirmAction() - throw new Error('[Nuxt DevTools] Invalid auth token') - } - - return devAuthToken.value! + console.warn('[nuxt-devtools] `ensureDevAuthToken` is deprecated. Auth is now handled by Vite DevTools.') + return '' } export const userAgentInfo = parseUA(navigator.userAgent) +/** @deprecated Auth is now handled by Vite DevTools */ export async function requestForAuth() { - const desc = [ - userAgentInfo.browser.name, - userAgentInfo.browser.version, - '|', - userAgentInfo.os.name, - userAgentInfo.os.version, - userAgentInfo.device.type, - ].filter(i => i).join(' ') - return await rpc.requestForAuth(desc, window.location.origin) -} - -async function authConfirmAction() { - if (!devAuthToken.value) - requestForAuth() - - const result = await Promise.race([ - AuthConfirm.start(), - until(devAuthToken.value).toBeTruthy(), - ]) - - if (result === false) { - // @unocss-include - devtoolsUiShowNotification({ - message: 'Action canceled', - icon: 'carbon-close', - classes: 'text-orange', - }) - throw new Error('[Nuxt DevTools] User canceled auth') - } + console.warn('[nuxt-devtools] `requestForAuth` is deprecated. Auth is now handled by Vite DevTools.') } diff --git a/packages/devtools/client/composables/rpc.ts b/packages/devtools/client/composables/rpc.ts index 56f853f261..007e2dc73d 100644 --- a/packages/devtools/client/composables/rpc.ts +++ b/packages/devtools/client/composables/rpc.ts @@ -1,98 +1,109 @@ -import type { ClientFunctions, ServerFunctions } from '../../src/types' +import type { DevToolsRpcClient } from '@vitejs/devtools-kit/client' +import type { AsyncServerFunctions, ClientFunctions } from '../../src/types' +import { getDevToolsRpcClient } from '@vitejs/devtools-kit/client' import { useDebounce } from '@vueuse/core' -import { createBirpc } from 'birpc' -import { parse, stringify } from 'structured-clone-es' -import { tryCreateHotContext } from 'vite-hot-client' import { ref, shallowRef } from 'vue' -import { WS_EVENT_NAME } from '../../src/constant' -const LEADING_TRAILING_SLASH_RE = /^\/|\/$/g -const DEVTOOLS_CLIENT_PATH_RE = /\/__nuxt_devtools__\/client\/.*$/ - -export const wsConnecting = ref(false) +export const wsConnecting = ref(true) export const wsError = shallowRef() export const wsConnectingDebounced = useDebounce(wsConnecting, 2000) -const connectPromise = connectVite() -let onMessage: any = () => {} - export const clientFunctions = { - // will be added in app.vue + // will be added in setup/client-rpc.ts } as ClientFunctions export const extendedRpcMap = new Map() -export const rpc = createBirpc(clientFunctions, { - post: async (d) => { - (await connectPromise).send(WS_EVENT_NAME, d) - }, - on: (fn) => { - onMessage = fn - }, - serialize: stringify, - deserialize: parse, - resolver(name, fn) { - if (fn) - return fn - if (!name.includes(':')) - return - const [namespace, fnName] = name.split(':') as [string, string] - return extendedRpcMap.get(namespace)?.[fnName] - }, - onFunctionError(error, name) { - console.error(`[nuxt-devtools] RPC error on executing "${name}":`) - console.error(error) - return true - }, - onGeneralError(error) { - console.error(`[nuxt-devtools] RPC error:`) - console.error(error) - return true +let rpcClient: DevToolsRpcClient | undefined + +const connectPromise = connectDevToolsRpc() + +/** + * Proxy-based RPC object that provides backward-compatible `rpc.functionName()` interface. + * Server functions are called via Vite DevTools Kit's RPC client. + */ +export const rpc = new Proxy({} as AsyncServerFunctions, { + get: (_, method: string) => { + return async (...args: any[]) => { + const client = rpcClient || await connectPromise + // Check extended RPC map first for namespaced functions + if (method.includes(':')) { + const [namespace, fnName] = method.split(':') as [string, string] + const extFn = extendedRpcMap.get(namespace)?.[fnName] + if (extFn) + return extFn(...args) + } + return client.call(method as any, ...args as any) + } }, - timeout: 120_000, }) -async function connectVite() { - const appConfig = window.parent?.__NUXT__?.config?.app - ?? window.parent?.useNuxtApp?.()?.payload?.config?.app // Nuxt 4 removes __NUXT__ - - let base = appConfig?.baseURL ?? '/' - const buildAssetsDir = appConfig?.buildAssetsDir?.replace(LEADING_TRAILING_SLASH_RE, '') ?? '_nuxt' - if (base && !base.endsWith('/')) - base += '/' - const current = window.location.href.replace(DEVTOOLS_CLIENT_PATH_RE, '/') - const hot = await tryCreateHotContext(undefined, [...new Set([ - `${base}${buildAssetsDir}/`, - `${base}_nuxt/`, - base, - `${current}${buildAssetsDir}/`, - `${current}_nuxt/`, - current, - ])]) +async function connectDevToolsRpc(): Promise { + try { + const client = await getDevToolsRpcClient() - if (!hot) { - wsConnecting.value = true - console.error('[Nuxt DevTools] Unable to find Vite HMR context') - throw new Error('[Nuxt DevTools] Unable to connect to devtools') - } + rpcClient = client - hot.on(WS_EVENT_NAME, (data) => { - wsConnecting.value = false - onMessage(data) - }) + // Register client functions so the server can call them + for (const [name, handler] of Object.entries(clientFunctions)) { + if (typeof handler === 'function') { + client.client.register({ + name, + type: 'event', + handler: handler as any, + }) + } + } - wsConnecting.value = true + // Register extended client RPC functions + for (const [namespace, fns] of extendedRpcMap) { + for (const [fnName, handler] of Object.entries(fns)) { + if (typeof handler === 'function') { + client.client.register({ + name: `${namespace}:${fnName}`, + type: 'event', + handler: handler as any, + }) + } + } + } - hot.on('vite:ws:connect', () => { // eslint-disable-next-line no-console - console.log('[nuxt-devtools] Connected to WebSocket') + console.log('[nuxt-devtools] Connected to Vite DevTools RPC') wsConnecting.value = false - }) - hot.on('vite:ws:disconnect', () => { - // eslint-disable-next-line no-console - console.log('[nuxt-devtools] Disconnected from WebSocket') + + return client + } + catch (e) { wsConnecting.value = true - }) + wsError.value = e + console.error('[Nuxt DevTools] Unable to connect to Vite DevTools RPC', e) + throw e + } +} - return hot +/** + * Register additional client functions after initial connection. + * Used by setup/client-rpc.ts to register functions that are set up later. + */ +export async function registerClientFunctions() { + const client = rpcClient || await connectPromise + for (const [name, handler] of Object.entries(clientFunctions)) { + if (typeof handler === 'function') { + try { + client.client.update({ + name, + type: 'event', + handler: handler as any, + }) + } + catch { + client.client.register({ + name, + type: 'event', + handler: handler as any, + }) + } + } + } } diff --git a/packages/devtools/client/setup/client-rpc.ts b/packages/devtools/client/setup/client-rpc.ts index 7483f8cbf4..dfef0e3fec 100644 --- a/packages/devtools/client/setup/client-rpc.ts +++ b/packages/devtools/client/setup/client-rpc.ts @@ -1,8 +1,7 @@ import type { ClientFunctions } from '../../src/types' import { useNuxtApp, useRouter } from '#imports' import { useClient } from '../composables/client' -import { devAuthToken, isDevAuthed } from '../composables/dev-auth' -import { clientFunctions, rpc } from '../composables/rpc' +import { clientFunctions, registerClientFunctions } from '../composables/rpc' import { processAnalyzeBuildInfo, processInstallingModules } from '../composables/state-subprocess' import { useDevToolsOptions } from '../composables/storage-options' import { telemetry } from '../composables/telemetry' @@ -43,13 +42,8 @@ export function setupClientRPC() { }, } satisfies ClientFunctions) - rpc.getModuleOptions() - .then((options) => { - if (options.disableAuthorization) { - isDevAuthed.value = true - devAuthToken.value ||= 'disabled' - } - }) + // Re-register client functions now that they're populated + registerClientFunctions() const { hiddenTabs, diff --git a/packages/devtools/src/constant.ts b/packages/devtools/src/constant.ts index c7e009854b..83c21195ad 100644 --- a/packages/devtools/src/constant.ts +++ b/packages/devtools/src/constant.ts @@ -1,8 +1,6 @@ import type { ModuleOptions, NuxtDevToolsOptions } from './types' import { provider } from 'std-env' -export const WS_EVENT_NAME = 'nuxt:devtools:rpc' - const isSandboxed = provider === 'stackblitz' || provider === 'codesandbox' export const defaultOptions: ModuleOptions = { diff --git a/packages/devtools/src/module-main.ts b/packages/devtools/src/module-main.ts index ccfe102fb1..3ef583effc 100644 --- a/packages/devtools/src/module-main.ts +++ b/packages/devtools/src/module-main.ts @@ -13,7 +13,6 @@ import sirv from 'sirv' import { searchForWorkspaceRoot } from 'vite' import { version } from '../package.json' import { defaultTabOptions } from './constant' -import { getDevAuthToken } from './dev-auth' import { clientDir, packageDir, runtimeDir } from './dirs' import { setupRPC } from './server-rpc' import { readLocalOptions } from './utils/local-options' @@ -39,6 +38,10 @@ export async function enableModule(options: ModuleOptions, nuxt: Nuxt) { await nuxt.callHook('devtools:before') + if (nuxt.options.devtools && typeof nuxt.options.devtools !== 'boolean' && 'disableAuthorization' in nuxt.options.devtools) { + logger.warn('[nuxt-devtools] `disableAuthorization` option is deprecated. Auth is now handled by Vite DevTools.') + } + if (options.iframeProps) { nuxt.options.runtimeConfig.app.devtools ||= {} nuxt.options.runtimeConfig.app.devtools.iframeProps = options.iframeProps @@ -59,6 +62,10 @@ export async function enableModule(options: ModuleOptions, nuxt: Nuxt) { const DevTools = await import('@vitejs/devtools').then(r => r.DevTools()) addVitePlugin(DevTools) + + // Deferred: will be set when Vite DevTools plugin setup runs + let connectRpcHost: ((host: any) => void) | undefined + addVitePlugin(defineViteDevToolsPlugin({ name: 'nuxt:devtools', devtools: { @@ -70,6 +77,9 @@ export async function enableModule(options: ModuleOptions, nuxt: Nuxt) { title: 'Nuxt DevTools', url: '/__nuxt_devtools__/client/', }) + + // Connect Nuxt DevTools RPC to Vite DevTools Kit's RPC host + connectRpcHost?.(ctx.rpc) }, }, })) @@ -120,11 +130,11 @@ window.__NUXT_DEVTOOLS_TIME_METRIC__.appInit = Date.now() }) const { - vitePlugin, + connectRpcHost: _connectRpcHost, ...ctx } = setupRPC(nuxt, options) - addVitePlugin(vitePlugin) + connectRpcHost = _connectRpcHost const clientDirExists = existsSync(clientDir) @@ -151,8 +161,6 @@ window.__NUXT_DEVTOOLS_TIME_METRIC__.appInit = Date.now() const ROUTE_PATH = `${nuxt.options.app.baseURL || '/'}/__nuxt_devtools__`.replace(MULTIPLE_SLASHES_RE, '/') const ROUTE_CLIENT = `${ROUTE_PATH}/client` - const ROUTE_AUTH = `${ROUTE_PATH}/auth` - const ROUTE_AUTH_VERIFY = `${ROUTE_PATH}/auth-verify` const ROUTE_ANALYZE = `${ROUTE_PATH}/analyze` // TODO: Use WS from nitro server when possible @@ -182,28 +190,6 @@ window.__NUXT_DEVTOOLS_TIME_METRIC__.appInit = Date.now() return handleStatic(req, res, () => handleIndex(res)) }) } - server.middlewares.use(ROUTE_AUTH, sirv(join(runtimeDir, 'auth'), { single: true, dev: true })) - server.middlewares.use(ROUTE_AUTH_VERIFY, async (req, res) => { - const search = req.url?.split('?')[1] - if (!search) { - res.statusCode = 400 - res.end('No token provided') - } - const query = new URLSearchParams(search) - const token = query.get('token') - if (!token) { - res.statusCode = 400 - res.end('No token provided') - } - if (token === await getDevAuthToken()) { - res.statusCode = 200 - res.end('Valid token') - } - else { - res.statusCode = 403 - res.end('Invalid token') - } - }) }) await import('./integrations/plugin-metrics').then(({ setup }) => setup(ctx)) diff --git a/packages/devtools/src/server-rpc/general.ts b/packages/devtools/src/server-rpc/general.ts index fbe40d286f..b319d5ef37 100644 --- a/packages/devtools/src/server-rpc/general.ts +++ b/packages/devtools/src/server-rpc/general.ts @@ -5,13 +5,11 @@ import type { AutoImportsWithMetadata, HookInfo, NuxtDevtoolsServerContext, Serv import { existsSync } from 'node:fs' import fs from 'node:fs/promises' import { logger } from '@nuxt/kit' -import { colors } from 'consola/utils' import destr from 'destr' import { dirname, join, resolve } from 'pathe' import { snakeCase } from 'scule' import { resolveBuiltinPresets } from 'unimport' -import { getDevAuthToken } from '../dev-auth' import { setupHooksDebug } from '../runtime/shared/hooks' import { toJsLiteral } from '../utils/serialize-js-literal' import { getOptions } from './options' @@ -19,7 +17,6 @@ import { getOptions } from './options' const ABSOLUTE_PATH_RE = /^[a-z]:|^\//i // eslint-disable-next-line regexp/no-super-linear-backtracking const FILE_LINE_COL_RE = /^(.*?)(:[:\d]*)$/ -const MULTIPLE_SLASHES_RE = /\/+/g const NUXT_WELCOME_RE = // export function setupGeneralRPC({ @@ -270,40 +267,14 @@ export function setupGeneralRPC({ logger.info('Restarting Nuxt...') return nuxt.callHook('restart', { hard }) }, - async requestForAuth(info, origin?) { - if (options.disableAuthorization) - return - - const token = await getDevAuthToken() - - origin ||= `${nuxt.options.devServer.https ? 'https' : 'http'}://${nuxt.options.devServer.host === '::' ? 'localhost' : (nuxt.options.devServer.host || 'localhost')}:${nuxt.options.devServer.port}` - - const ROUTE_AUTH = `${nuxt.options.app.baseURL || '/'}/__nuxt_devtools__/auth`.replace(MULTIPLE_SLASHES_RE, '/') - - const message = [ - `A browser is requesting permissions of ${colors.bold(colors.yellow('writing files and running commands'))} from the DevTools UI.`, - colors.bold(info || 'Unknown'), - '', - 'Please open the following URL in the browser:', - colors.bold(colors.green(`${origin}${ROUTE_AUTH}?token=${token}`)), - '', - 'Or manually copy and paste the following token:', - colors.bold(colors.cyan(token)), - ] - - logger.box({ - message: message.join('\n'), - title: colors.bold(colors.yellow(' Permission Request ')), - style: { - borderColor: 'yellow', - borderStyle: 'rounded', - }, - }) + /** @deprecated Auth is now handled by Vite DevTools */ + async requestForAuth() { + logger.warn('[nuxt-devtools] `requestForAuth` is deprecated. Auth is now handled by Vite DevTools.') }, - async verifyAuthToken(token: string) { - if (options.disableAuthorization) - return true - return token === await getDevAuthToken() + /** @deprecated Auth is now handled by Vite DevTools */ + async verifyAuthToken() { + logger.warn('[nuxt-devtools] `verifyAuthToken` is deprecated. Auth is now handled by Vite DevTools.') + return true }, } satisfies Partial } diff --git a/packages/devtools/src/server-rpc/index.ts b/packages/devtools/src/server-rpc/index.ts index 5d16c3783a..db801c9da0 100644 --- a/packages/devtools/src/server-rpc/index.ts +++ b/packages/devtools/src/server-rpc/index.ts @@ -1,15 +1,9 @@ -import type { ChannelOptions } from 'birpc' +import type { RpcFunctionsHost } from '@vitejs/devtools-kit' import type { Nuxt } from 'nuxt/schema' -import type { Plugin } from 'vite' -import type { WebSocket } from 'ws' -import type { ClientFunctions, ModuleOptions, NuxtDevtoolsServerContext, ServerFunctions } from '../types' +import type { ModuleOptions, NuxtDevtoolsServerContext, ServerFunctions } from '../types' import { logger } from '@nuxt/kit' -import { createBirpcGroup } from 'birpc' import { colors } from 'consola/utils' -import { parse, stringify } from 'structured-clone-es' -import { WS_EVENT_NAME } from '../constant' -import { getDevAuthToken } from '../dev-auth' import { setupAnalyzeBuildRPC } from './analyze-build' import { setupAssetsRPC } from './assets' import { setupCustomTabRPC } from './custom-tabs' @@ -26,61 +20,95 @@ import { setupTimelineRPC } from './timeline' export function setupRPC(nuxt: Nuxt, options: ModuleOptions) { const serverFunctions = {} as ServerFunctions - const extendedRpcMap = new Map() - const rpc = createBirpcGroup( - serverFunctions, - [], - { - resolver: (name, fn) => { - if (fn) - return fn - - if (!name.includes(':')) - return + const extendedRpcMap = new Map any>>() + let rpcHost: RpcFunctionsHost | undefined + const pendingBroadcasts: { method: string, args: any[] }[] = [] - const [namespace, fnName] = name.split(':') - return extendedRpcMap.get(namespace!)?.[fnName!] - }, - onFunctionError(error, name) { - logger.error( - colors.yellow(`[nuxt-devtools] RPC error on executing "${colors.bold(name)}":\n`) - + colors.red(error?.message || ''), - ) + function broadcast(method: string, ...args: any[]) { + if (!rpcHost) { + pendingBroadcasts.push({ method, args }) + return + } + rpcHost.broadcast({ + method: method as any, + args: args as any, + event: true, + }) + } + + /** + * Compatibility broadcast proxy that supports the old birpc-style API: + * `rpc.broadcast.refresh.asEvent(event)` and `rpc.broadcast.onTerminalData.asEvent({ id, data })` + */ + function createBroadcastProxy(prefix = ''): any { + return new Proxy({}, { + get: (_, method) => { + if (typeof method !== 'string') + return + const fullMethod = prefix ? `${prefix}:${method}` : method + const fn = (...args: any[]) => broadcast(fullMethod, ...args) + fn.asEvent = (...args: any[]) => broadcast(fullMethod, ...args) + return fn }, - timeout: 120_000, + }) + } + + /** + * Compatibility proxy for `rpc.functions` that reads/writes to serverFunctions + * and also updates the RpcFunctionsHost when available. + */ + const functionsProxy = new Proxy(serverFunctions, { + set(target, prop, value) { + (target as any)[prop] = value + // Also update on RpcFunctionsHost if available + if (rpcHost && typeof prop === 'string') { + if (rpcHost.has(prop)) { + rpcHost.update({ name: prop, handler: value }) + } + else { + rpcHost.register({ name: prop, handler: value }) + } + } + return true }, - ) + }) + + const rpc = { + broadcast: createBroadcastProxy(), + functions: functionsProxy, + } function refresh(event: keyof ServerFunctions) { - rpc.broadcast.refresh.asEvent(event) + broadcast('refresh', event) } function extendServerRpc(namespace: string, functions: any): any { extendedRpcMap.set(namespace, functions) + // Register on RpcFunctionsHost if already available + if (rpcHost) { + for (const [fnName, handler] of Object.entries(functions)) { + if (typeof handler === 'function') { + rpcHost.register({ name: `${namespace}:${fnName}`, handler: handler as any }) + } + } + } + return { - broadcast: new Proxy({}, { - get: (_, key) => { - if (typeof key !== 'string') - return - return (rpc.broadcast as any)[`${namespace}:${key}`] - }, - }), + broadcast: createBroadcastProxy(namespace), } } const ctx: NuxtDevtoolsServerContext = { nuxt, options, - rpc, + rpc: rpc as any, refresh, extendServerRpc, openInEditorHooks: [], - async ensureDevAuthToken(token: string) { - if (options.disableAuthorization) - return - if (token !== await getDevAuthToken()) - throw new Error('[Nuxt DevTools] Invalid dev auth token.') + /** @deprecated Auth is now handled by Vite DevTools */ + async ensureDevAuthToken(_token: string) { + logger.warn('[nuxt-devtools] `ensureDevAuthToken` is deprecated. Auth is now handled by Vite DevTools.') }, } @@ -103,51 +131,60 @@ export function setupRPC(nuxt: Nuxt, options: ModuleOptions) { ...setupServerDataRPC(ctx), } as ServerFunctions) - const wsClients = new Set() - - const vitePlugin: Plugin = { - name: 'nuxt:devtools:rpc', - configureServer(server) { - server.ws.on('connection', (ws) => { - wsClients.add(ws) - const channel: ChannelOptions = { - post: d => ws.send(JSON.stringify({ - type: 'custom', - event: WS_EVENT_NAME, - data: d, - })), - on: (fn) => { - ws.on('message', (e) => { - try { - const data = JSON.parse(String(e)) || {} - if (data.type === 'custom' && data.event === WS_EVENT_NAME) { - // console.log(data.data) - fn(data.data) - } - } - catch {} + /** + * Connect to Vite DevTools Kit's RPC host. + * Called from the Vite DevTools plugin setup callback. + */ + function connectRpcHost(host: RpcFunctionsHost) { + rpcHost = host + + // Flush any broadcasts that were queued before connection + for (const { method, args } of pendingBroadcasts) { + broadcast(method, ...args) + } + pendingBroadcasts.length = 0 + + // Register all collected server functions + for (const [name, handler] of Object.entries(serverFunctions)) { + if (typeof handler === 'function') { + try { + host.register({ + name, + handler: handler as any, + }) + } + catch (e) { + logger.warn( + colors.yellow(`[nuxt-devtools] Failed to register RPC function "${name}":\n`) + + colors.red((e as Error)?.message || ''), + ) + } + } + } + + // Register extended (namespaced) functions + for (const [namespace, fns] of extendedRpcMap) { + for (const [fnName, handler] of Object.entries(fns)) { + if (typeof handler === 'function') { + try { + host.register({ + name: `${namespace}:${fnName}`, + handler: handler as any, }) - }, - serialize: stringify, - deserialize: parse, + } + catch (e) { + logger.warn( + colors.yellow(`[nuxt-devtools] Failed to register RPC function "${namespace}:${fnName}":\n`) + + colors.red((e as Error)?.message || ''), + ) + } } - rpc.updateChannels((c) => { - c.push(channel) - }) - ws.on('close', () => { - wsClients.delete(ws) - rpc.updateChannels((c) => { - const index = c.indexOf(channel) - if (index >= 0) - c.splice(index, 1) - }) - }) - }) - }, + } + } } return { - vitePlugin, + connectRpcHost, ...ctx, } }