@tanstack/devtools-vite
Version:
TanStack Vite plugin used to enhance the core devtools with additional functionalities
636 lines (585 loc) • 21.3 kB
text/typescript
import { devtoolsEventClient } from '@tanstack/devtools-client'
import { ServerEventBus } from '@tanstack/devtools-event-bus/server'
import chalk from 'chalk'
import {
DEFAULT_EDITOR_CONFIG,
TANSTACK_DEVTOOLS_PACKAGES,
addPluginToDevtools,
addSourceToJsx,
detectDevtoolsFile,
emitOutdatedDeps,
enhanceConsoleLog,
generateConsolePipeCode,
handleDevToolsRequest,
handleOpenSource,
injectPluginIntoFile,
injectRuntimeBridge,
installPackage,
normalizePath,
readPackageJson,
removeDevtools,
stripEnhancedLogPrefix,
wireRuntimeBridgeChannels,
} from '@tanstack/devtools-bundler-core'
import type {
ConsoleLevel,
EditorConfig,
TanStackDevtoolsConfig,
} from '@tanstack/devtools-bundler-core'
import type { ServerResponse } from 'node:http'
import type { Plugin } from 'vite'
import type { HttpServerLike } from '@tanstack/devtools-event-bus/server'
export type { ConsoleLevel } from '@tanstack/devtools-bundler-core'
export type TanStackDevtoolsViteConfig = TanStackDevtoolsConfig
export const defineDevtoolsConfig = (config: TanStackDevtoolsViteConfig) =>
config
export const devtools = (args?: TanStackDevtoolsViteConfig): Array<Plugin> => {
let port = 5173
const logging = args?.logging ?? true
const enhancedLogsConfig = args?.enhancedLogs ?? { enabled: true }
const injectSourceConfig = args?.injectSource ?? { enabled: true }
const removeDevtoolsOnBuild = args?.removeDevtoolsOnBuild ?? true
const serverBusEnabled = args?.eventBusConfig?.enabled ?? true
const consolePipingConfig = args?.consolePiping ?? { enabled: true }
const consolePipingLevels: Array<ConsoleLevel> =
consolePipingConfig.levels ?? ['log', 'warn', 'error', 'info', 'debug']
let devtoolsFileId: string | null = null
let devtoolsPort: number | null = null
let devtoolsHost: string | null = null
let devtoolsProtocol: 'http' | 'https' | null = null
return [
{
enforce: 'pre',
name: '@tanstack/devtools:inject-source',
apply(config) {
return config.mode === 'development' && injectSourceConfig.enabled
},
transform: {
// Run before the transforms of plugins without `order: 'pre'`, also when
// devtools() is not first in the plugins array. A plugin that changes
// the code of one environment only would shift the lines and make the
// SSR and client locations differ, which breaks hydration. An earlier
// plugin whose transform also has `order: 'pre'` still runs first.
order: 'pre',
filter: {
id: {
exclude: [/node_modules/, /\?raw/, /\/dist\//, /\/build\//],
},
},
handler(code, id) {
return addSourceToJsx(code, id, args?.injectSource?.ignore)
},
},
},
{
name: '@tanstack/devtools:config',
enforce: 'pre',
config(_, { command }) {
// we do not apply any config changes for build
if (command !== 'serve') {
return
}
/* const solidDedupeDeps = [
'solid-js',
'solid-js/web',
'solid-js/store',
'solid-js/html',
'solid-js/h',
]
return {
resolve: {
dedupe: solidDedupeDeps,
},
optimizeDeps: {
include: solidDedupeDeps,
},
} */
},
},
{
enforce: 'pre',
name: '@tanstack/devtools:custom-server',
apply(config) {
// Custom server is only needed in development for piping events to the client
return config.mode === 'development'
},
async configureServer(server) {
if (serverBusEnabled) {
const preferredPort = args?.eventBusConfig?.port ?? 4206
const isHttps = !!server.config.server.https
const serverHost =
typeof server.config.server.host === 'string'
? server.config.server.host
: 'localhost'
devtoolsProtocol = isHttps ? 'https' : 'http'
devtoolsHost = serverHost
const bus = new ServerEventBus({
...args?.eventBusConfig,
port: preferredPort,
host: serverHost,
// When HTTPS is enabled, piggyback on Vite's server
// so WebSocket/SSE connections share the same TLS certificate
...(isHttps && server.httpServer
? { httpServer: server.httpServer as HttpServerLike }
: {}),
})
// start() now handles EADDRINUSE and returns the actual port
devtoolsPort = await bus.start()
if ((server as any).environments) {
const teardownBridge = wireRuntimeBridgeChannels(
server as unknown as Parameters<
typeof wireRuntimeBridgeChannels
>[0],
() => globalThis.__TANSTACK_EVENT_TARGET__,
)
server.httpServer?.on('close', teardownBridge)
}
}
server.middlewares.use((req, _res, next) => {
if (req.socket.localPort && req.socket.localPort !== port) {
port = req.socket.localPort
}
next()
})
if (server.config.server.port) {
port = server.config.server.port
}
server.httpServer?.on('listening', () => {
port = server.config.server.port
})
const editor = args?.editor ?? DEFAULT_EDITOR_CONFIG
const openInEditor: EditorConfig['open'] = async (
path,
lineNum,
columnNum,
) => {
if (!path) {
return
}
await editor.open(path, lineNum, columnNum)
}
const originalConsole = Object.fromEntries(
consolePipingLevels.map((l) => [l, console[l].bind(console)]),
) as Partial<Record<ConsoleLevel, typeof console.log>>
// SSE clients for broadcasting server logs to browser
const sseClients: Array<{
res: ServerResponse
id: number
}> = []
let sseClientId = 0
const consolePipingEnabled = consolePipingConfig.enabled ?? true
server.middlewares.use((req, res, next) =>
handleDevToolsRequest(req, res, next, {
onOpenSource: (parsedData) => {
const { data, routine } = parsedData
if (routine === 'open-source') {
return handleOpenSource({
data: { type: data.type, data },
openInEditor,
})
}
return
},
...(consolePipingEnabled
? {
onConsolePipe: (entries) => {
for (const entry of entries) {
const prefix = chalk.cyan('[Client]')
const logMethod =
originalConsole[entry.level as ConsoleLevel] ??
// log exists in consolePipingLevels
originalConsole.log!
const cleanedArgs = stripEnhancedLogPrefix(
entry.args,
(loc) => chalk.gray(loc),
)
logMethod(prefix, ...cleanedArgs)
}
},
onConsolePipeSSE: (res, req) => {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('Access-Control-Allow-Origin', '*')
res.flushHeaders()
const clientId = ++sseClientId
sseClients.push({ res, id: clientId })
req.on('close', () => {
const index = sseClients.findIndex(
(c) => c.id === clientId,
)
if (index !== -1) {
sseClients.splice(index, 1)
}
})
},
onServerConsolePipe: (entries) => {
try {
const data = JSON.stringify({
entries: entries.map((e) => ({
level: e.level,
args: e.args,
source: 'server',
timestamp: e.timestamp || Date.now(),
})),
})
for (const client of sseClients) {
client.res.write(`data: ${data}\n\n`)
}
} catch {}
},
}
: {}),
}),
)
},
},
{
name: '@tanstack/devtools:remove-devtools-on-build',
apply(config, { command }) {
// Check both command and mode to support various hosting providers
// Some providers (Cloudflare, Netlify, Heroku) might not use 'build' command
// but will always set mode to 'production' for production builds
return (
(command !== 'serve' || config.mode === 'production') &&
removeDevtoolsOnBuild
)
},
enforce: 'pre',
transform(code, id) {
if (
id.includes('node_modules') ||
id.includes('?raw') ||
!TANSTACK_DEVTOOLS_PACKAGES.some((pkg) => code.includes(pkg))
)
return
const transform = removeDevtools(code, id)
if (!transform) return
if (logging) {
console.log(
`\n${chalk.greenBright(`[@tanstack/devtools-vite]`)} Removed devtools code from: ${id.replace(normalizePath(process.cwd()), '')}\n`,
)
}
return transform
},
},
{
name: '@tanstack/devtools:event-client-setup',
apply(config, { command }) {
if (
process.env.CI ||
process.env.NODE_ENV !== 'development' ||
command !== 'serve'
)
return false
return config.mode === 'development'
},
async configureServer() {
const packageJson = await readPackageJson()
const outdatedDeps = emitOutdatedDeps().then((deps) => deps)
// Listen for package installation requests
devtoolsEventClient.on('install-devtools', async (event) => {
const result = await installPackage(event.payload.packageName)
devtoolsEventClient.emit('devtools-installed', {
packageName: event.payload.packageName,
success: result.success,
error: result.error,
})
// If installation was successful, automatically add the plugin to devtools
if (result.success) {
const { packageName, pluginName, pluginImport } = event.payload
console.log(
chalk.blueBright(
`[@tanstack/devtools-vite] Auto-adding ${packageName} to devtools...`,
),
)
const injectResult = addPluginToDevtools(
devtoolsFileId,
packageName,
pluginName,
pluginImport,
)
if (injectResult.success) {
// Emit plugin-added event so the UI updates
devtoolsEventClient.emit('plugin-added', {
packageName,
success: true,
})
// Also re-read package.json to update the UI with the newly installed package
const updatedPackageJson = await readPackageJson()
devtoolsEventClient.emit('package-json-read', {
packageJson: updatedPackageJson,
})
}
}
})
// Listen for add plugin to devtools requests
devtoolsEventClient.on('add-plugin-to-devtools', (event) => {
const { packageName, pluginName, pluginImport } = event.payload
console.log(
chalk.blueBright(
`[@tanstack/devtools-vite] Adding ${packageName} to devtools...`,
),
)
const result = addPluginToDevtools(
devtoolsFileId,
packageName,
pluginName,
pluginImport,
)
devtoolsEventClient.emit('plugin-added', {
packageName,
success: result.success,
error: result.error,
})
})
// Handle bump-package-version event
devtoolsEventClient.on('bump-package-version', async (event) => {
const {
packageName,
devtoolsPackage,
pluginName,
minVersion,
pluginImport,
} = event.payload
console.log(
chalk.blueBright(
`[@tanstack/devtools-vite] Bumping ${packageName} to version ${minVersion}...`,
),
)
// Install the package with the minimum version
const packageWithVersion = minVersion
? `${packageName}@^${minVersion}`
: packageName
const result = await installPackage(packageWithVersion)
if (!result.success) {
console.log(
chalk.redBright(
`[@tanstack/devtools-vite] Failed to bump ${packageName}: ${result.error}`,
),
)
devtoolsEventClient.emit('devtools-installed', {
packageName: devtoolsPackage,
success: false,
error: result.error,
})
return
}
console.log(
chalk.greenBright(
`[@tanstack/devtools-vite] Successfully bumped ${packageName} to ${minVersion}!`,
),
)
// Check if we found the devtools file
if (!devtoolsFileId) {
console.log(
chalk.yellowBright(
`[@tanstack/devtools-vite] Devtools file not found. Skipping auto-injection.`,
),
)
devtoolsEventClient.emit('devtools-installed', {
packageName: devtoolsPackage,
success: true,
})
return
}
// Now inject the devtools plugin
console.log(
chalk.blueBright(
`[@tanstack/devtools-vite] Adding ${devtoolsPackage} to devtools...`,
),
)
const injectResult = injectPluginIntoFile(devtoolsFileId, {
packageName: devtoolsPackage,
pluginName,
pluginImport,
})
if (injectResult.success) {
console.log(
chalk.greenBright(
`[@tanstack/devtools-vite] Successfully added ${devtoolsPackage} to devtools!`,
),
)
devtoolsEventClient.emit('plugin-added', {
packageName: devtoolsPackage,
success: true,
})
// Re-read package.json to update the UI
const updatedPackageJson = await readPackageJson()
devtoolsEventClient.emit('package-json-read', {
packageJson: updatedPackageJson,
})
} else {
console.log(
chalk.redBright(
`[@tanstack/devtools-vite] Failed to add ${devtoolsPackage} to devtools: ${injectResult.error}`,
),
)
devtoolsEventClient.emit('plugin-added', {
packageName: devtoolsPackage,
success: false,
error: injectResult.error,
})
}
})
// whenever a client mounts we send all the current info to the subscribers
devtoolsEventClient.on('mounted', async () => {
devtoolsEventClient.emit('outdated-deps-read', {
outdatedDeps: await outdatedDeps,
})
devtoolsEventClient.emit('package-json-read', {
packageJson,
})
})
// Console piping is now handled via HTTP endpoints in the custom-server plugin
},
async handleHotUpdate({ file }) {
if (file.endsWith('package.json')) {
const newPackageJson = await readPackageJson()
devtoolsEventClient.emit('package-json-read', {
packageJson: newPackageJson,
})
emitOutdatedDeps()
}
},
},
// Enhanced logs must run BEFORE console-pipe-transform: the latter
// prepends a multi-line IIFE to root entry files, which would shift
// every line number computed by the oxc-parser AST and produce
// "Go to Source" links pointing past the end of the user's file.
{
name: '@tanstack/devtools:better-console-logs',
enforce: 'pre',
apply(config) {
return config.mode === 'development' && enhancedLogsConfig.enabled
},
transform: {
filter: {
id: {
exclude: [/node_modules/, /\?raw/, /\/dist\//, /\/build\//],
},
code: {
include: 'console.',
},
},
handler(code, id) {
return enhanceConsoleLog(code, id, port)
},
},
},
// Inject console piping code into entry files (both client and server)
{
name: '@tanstack/devtools:console-pipe-transform',
enforce: 'pre',
apply(config, { command }) {
return (
config.mode === 'development' &&
command === 'serve' &&
(consolePipingConfig.enabled ?? true)
)
},
transform: {
filter: {
id: {
include: /\.(tsx?|jsx?)$/,
exclude: [/node_modules/, /\/dist\//, /\?/],
},
code: {
exclude: /__tsdConsolePipe/, // avoid transforming files that already contain the console pipe code
},
},
handler(code) {
// Check if this is a root entry file (with <html> JSX or client entry points)
// In SSR frameworks, this file runs on BOTH server (SSR) and client (hydration)
// so our runtime check (typeof window === 'undefined') handles both environments
const isRootEntry =
/<html[\s>]/i.test(code) ||
code.includes('StartClient') ||
code.includes('hydrateRoot') ||
code.includes('createRoot') ||
(code.includes('solid-js/web') && code.includes('render('))
if (isRootEntry) {
const viteServerUrl = `http://localhost:${port}`
const inlineCode = generateConsolePipeCode(
consolePipingLevels,
viteServerUrl,
)
return `${inlineCode}\n${code}`
}
return undefined
},
},
},
{
name: '@tanstack/devtools:inject-plugin',
apply(config, { command }) {
return config.mode === 'development' && command === 'serve'
},
transform(code, id) {
// First pass: find where TanStackDevtools is imported
if (!devtoolsFileId && detectDevtoolsFile(code)) {
// Extract actual file path (remove query params)
const [filePath] = id.split('?')
if (filePath) {
devtoolsFileId = filePath
}
}
return undefined
},
},
{
name: '@tanstack/devtools:connection-injection',
apply(config, { command }) {
return config.mode === 'development' && command === 'serve'
},
transform(code, id) {
// Only transform @tanstack packages that contain the connection placeholders
const hasPlaceholder =
code.includes('__TANSTACK_DEVTOOLS_PORT__') ||
code.includes('__TANSTACK_DEVTOOLS_HOST__') ||
code.includes('__TANSTACK_DEVTOOLS_PROTOCOL__')
if (!hasPlaceholder) return
// Vite pre-bundles npm installs into chunks under `.vite/deps*`, whose
// ids no longer name the package.
if (
!id.includes('@tanstack/devtools') &&
!id.includes('@tanstack/event-bus') &&
!id.includes('/.vite/deps')
)
return
// Replace placeholders with actual values (or fallback defaults)
const portValue = devtoolsPort ?? 4206
const hostValue = devtoolsHost ?? 'localhost'
const protocolValue = devtoolsProtocol ?? 'http'
let result = code
result = result.replace(
/__TANSTACK_DEVTOOLS_PORT__/g,
String(portValue),
)
result = result.replace(
/__TANSTACK_DEVTOOLS_HOST__/g,
JSON.stringify(hostValue),
)
result = result.replace(
/__TANSTACK_DEVTOOLS_PROTOCOL__/g,
JSON.stringify(protocolValue),
)
return result
},
},
{
name: '@tanstack/devtools:runtime-bridge',
apply(config, { command }) {
return config.mode === 'development' && command === 'serve'
},
transform(code, id) {
if (id.includes('?')) return
return injectRuntimeBridge(
code,
id,
(this as any).environment?.name as string | undefined,
)
},
},
]
}