Files
claude-code/src/hooks/usePipeIpc.ts
claude-code-best ca1c87f460 fix: 修复 usePipeIpc 中 require 返回 undefined 导致启动崩溃
将 lazy require() 调用全部替换为静态 import,解决构建产物中
模块加载时序问题导致的 'undefined is not an object' 错误。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 23:21:38 +08:00

621 lines
18 KiB
TypeScript

/**
* usePipeIpc — Pipe IPC lifecycle hook.
*
* Extracted from REPL.tsx's 575-line inline useEffect. Manages:
* 1. Server creation (UDS + optional TCP for LAN)
* 2. LAN beacon startup
* 3. Message handlers (ping, attach, prompt, permission, detach)
* 4. Heartbeat loop (main: auto-attach + cleanup; sub: detect main alive)
* 5. Cleanup on unmount
*
* Feature-gated by UDS_INBOX. LAN extensions gated by LAN_PIPES.
*/
import { feature } from 'bun:bundle'
import { useEffect } from 'react'
import * as pt from '../utils/pipeTransport.js'
import * as pr from '../utils/pipeRegistry.js'
import * as mm from './useMasterMonitor.js'
import { getSessionId as _getSessionId } from '../bootstrap/state.js'
import * as lb from '../utils/lanBeacon.js'
import * as pp from '../utils/pipePermissionRelay.js'
import * as osm from 'os'
import type {
PipeMessage,
PipeServer,
PipeIpcState,
} from '../utils/pipeTransport.js'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type StoreApi = {
getState: () => any
setState: (updater: (prev: any) => any) => void
}
export type UsePipeIpcOptions = {
store: StoreApi
handleIncomingPrompt: (content: string) => boolean
}
// ---------------------------------------------------------------------------
// Helper: remove a dead slave from registry + state
// ---------------------------------------------------------------------------
function removeDeadSlave(slaveName: string, store: StoreApi): void {
mm.removeSlaveClient(slaveName)
store.setState((prev: any) => {
const pipeIpc = pt.getPipeIpc(prev)
const { [slaveName]: _removed, ...remainingSlaves } = pipeIpc.slaves
return {
...prev,
pipeIpc: {
...pipeIpc,
role: Object.keys(remainingSlaves).length > 0 ? 'master' : 'main',
displayRole:
Object.keys(remainingSlaves).length > 0 ? 'master' : 'main',
slaves: remainingSlaves,
selectedPipes: (pipeIpc.selectedPipes ?? []).filter(
(name: string) => name !== slaveName,
),
discoveredPipes: (pipeIpc.discoveredPipes ?? []).filter(
(pipe: { pipeName: string }) => pipe.pipeName !== slaveName,
),
},
}
})
}
// ---------------------------------------------------------------------------
// Helper: refresh discovered pipes (local subs + LAN peers)
// ---------------------------------------------------------------------------
function refreshDiscoveredPipes(
pipeName: string,
aliveSubs: Array<{
id: string
pipeName: string
subIndex: number
machineId: string
ip: string
hostname: string
}>,
store: StoreApi,
): void {
const freshDiscovered = aliveSubs
.filter(sub => sub.pipeName !== pipeName)
.map(sub => ({
id: sub.id,
pipeName: sub.pipeName,
role: `sub-${sub.subIndex}`,
machineId: sub.machineId,
ip: sub.ip,
hostname: sub.hostname,
alive: true,
}))
// Include LAN beacon peers so they aren't wiped out by heartbeat
let lanDiscovered: typeof freshDiscovered = []
if (feature('LAN_PIPES')) {
const beacon = lb.getLanBeacon()
if (beacon) {
const localNames = new Set(freshDiscovered.map(p => p.pipeName))
localNames.add(pipeName)
for (const [pName, peer] of beacon.getPeers()) {
if (!localNames.has(pName)) {
lanDiscovered.push({
id: `lan-${pName}`,
pipeName: pName,
role: peer.role,
machineId: peer.machineId,
ip: peer.ip,
hostname: peer.hostname,
alive: true,
})
}
}
}
}
const allDiscovered = [...freshDiscovered, ...lanDiscovered]
// Only update state if the list actually changed
const prev = pt.getPipeIpc(store.getState())
const prevNames = (prev.discoveredPipes ?? [])
.map((p: any) => p.pipeName)
.join(',')
const newNames = allDiscovered.map(p => p.pipeName).join(',')
if (prevNames === newNames) return
store.setState((prev: any) => {
const pipeIpc = pt.getPipeIpc(prev)
const aliveNames = new Set(allDiscovered.map(pipe => pipe.pipeName))
return {
...prev,
pipeIpc: {
...pipeIpc,
discoveredPipes: allDiscovered,
selectedPipes: (pipeIpc.selectedPipes ?? []).filter((name: string) =>
aliveNames.has(name),
),
},
}
})
}
// ---------------------------------------------------------------------------
// Phase: Register message handlers on server
// ---------------------------------------------------------------------------
function registerMessageHandlers(
server: PipeServer,
pipeName: string,
machineId: string,
store: StoreApi,
handleIncomingPrompt: (content: string) => boolean,
): void {
// Auto-reply pings for health checks
server.onMessage((msg: PipeMessage, reply) => {
if (msg.type === 'ping') reply({ type: 'pong' })
})
// Handle attach requests
server.onMessage((msg: PipeMessage, reply) => {
if (msg.type !== 'attach_request') return
const state = store.getState()
const currentPipeState = pt.getPipeIpc(state)
if (pt.isPipeControlled(currentPipeState)) {
reply({ type: 'attach_reject', data: 'Already controlled' })
return
}
// Allow LAN peers (different machineId) to attach regardless of role.
const isLanPeer = msg.meta?.machineId && msg.meta.machineId !== machineId
if (!isLanPeer && currentPipeState.role !== 'sub') {
reply({
type: 'attach_reject',
data: 'Only sub sessions can be attached.',
})
return
}
reply({ type: 'attach_accept' })
const clients = Array.from((server as any).clients as Set<any>)
const masterSocket = clients[clients.length - 1]
pp.setPipeRelay((relayMsg: any) => {
if (masterSocket && !masterSocket.destroyed) {
relayMsg.from = relayMsg.from ?? pipeName
relayMsg.ts = relayMsg.ts ?? new Date().toISOString()
masterSocket.write(JSON.stringify(relayMsg) + '\n')
}
})
store.setState((prev: any) => ({
...prev,
pipeIpc: {
...pt.getPipeIpc(prev),
role: 'sub',
displayRole: pt.getPipeDisplayRole(pt.getPipeIpc(prev)),
attachedBy: msg.from ?? 'unknown',
},
}))
})
// Handle prompts from master
server.onMessage((msg: PipeMessage, reply) => {
if (msg.type === 'prompt' && msg.data) {
const accepted = handleIncomingPrompt(msg.data)
if (accepted) {
reply({ type: 'prompt_ack', data: 'accepted' })
} else {
reply({
type: 'error',
data: 'Slave is busy and could not accept the prompt.',
})
}
}
})
// Handle permission decisions from master
server.onMessage((msg: PipeMessage, _reply) => {
if (msg.type !== 'permission_response' && msg.type !== 'permission_cancel')
return
const { resolvePipePermissionResponse, cancelPipePermissionRequest } = pp
try {
const payload = msg.data ? JSON.parse(msg.data) : undefined
if (!payload?.requestId) return
if (msg.type === 'permission_response') {
resolvePipePermissionResponse(payload)
} else {
cancelPipePermissionRequest(payload.requestId, payload.reason)
}
} catch {
// Malformed — ignore
}
})
// Handle relay mute/unmute from master
server.onMessage((msg: PipeMessage, _reply) => {
if (msg.type === 'relay_mute') {
pp.setRelayMuted(true)
} else if (msg.type === 'relay_unmute') {
pp.setRelayMuted(false)
}
})
// Handle detach
server.onMessage((msg: PipeMessage, _reply) => {
if (msg.type !== 'detach') return
const { clearPendingPipePermissions } = pp
clearPendingPipePermissions('Pipe detached before permission was resolved.')
pp.setPipeRelay(null)
store.setState((prev: any) => ({
...prev,
pipeIpc: (() => {
const pipeIpc = pt.getPipeIpc(prev)
const nextRole = pipeIpc.subIndex != null ? 'sub' : 'main'
const nextPipeState = { ...pipeIpc, role: nextRole, attachedBy: null }
return {
...nextPipeState,
displayRole: pt.getPipeDisplayRole(nextPipeState as PipeIpcState),
}
})(),
}))
})
}
// ---------------------------------------------------------------------------
// Phase: Heartbeat
// ---------------------------------------------------------------------------
function runMainHeartbeat(
pipeName: string,
machineId: string,
store: StoreApi,
disposed: { current: boolean },
): void {
void (async () => {
try {
await pr.cleanupStaleEntries()
const aliveSubs = await pr.getAliveSubs()
refreshDiscoveredPipes(pipeName, aliveSubs, store)
const connectedSlaves = mm.getAllSlaveClients()
const aliveSubNames = new Set(aliveSubs.map(sub => sub.pipeName))
// Build unified attach target list: local subs + LAN peers
type AttachTarget = {
pipeName: string
tcpEndpoint?: { host: string; port: number }
}
const attachTargets: AttachTarget[] = aliveSubs.map(sub => ({
pipeName: sub.pipeName,
}))
// Add LAN peers as attach targets
if (feature('LAN_PIPES')) {
const beacon = lb.getLanBeacon()
if (beacon) {
const localNames = new Set(attachTargets.map(t => t.pipeName))
localNames.add(pipeName)
for (const [pName, peer] of beacon.getPeers()) {
if (!localNames.has(pName)) {
attachTargets.push({
pipeName: pName,
tcpEndpoint: { host: peer.ip, port: peer.tcpPort },
})
aliveSubNames.add(pName)
}
}
}
}
const currentPipeState = pt.getPipeIpc(store.getState())
for (const target of attachTargets) {
if (target.pipeName === pipeName) continue
if (connectedSlaves.has(target.pipeName)) continue
try {
const myName = currentPipeState.serverName ?? pipeName
const client = await pt.connectToPipe(
target.pipeName,
myName,
3000,
target.tcpEndpoint,
)
const attached = await new Promise<boolean>(resolve => {
const timeout = setTimeout(() => {
client.disconnect()
resolve(false)
}, 3000)
client.onMessage((msg: any) => {
if (msg.type === 'attach_accept') {
clearTimeout(timeout)
resolve(true)
} else if (msg.type === 'attach_reject') {
clearTimeout(timeout)
client.disconnect()
resolve(false)
}
})
client.send({
type: 'attach_request',
meta: { machineId },
})
})
if (attached && !disposed.current) {
mm.addSlaveClient(target.pipeName, client)
client.on('disconnect', () => {
removeDeadSlave(target.pipeName, store)
})
store.setState((prev: any) => ({
...prev,
pipeIpc: {
...pt.getPipeIpc(prev),
role: 'master',
displayRole: 'master',
slaves: {
...pt.getPipeIpc(prev).slaves,
[target.pipeName]: {
name: target.pipeName,
connectedAt: new Date().toISOString(),
status: 'idle',
unreadCount: 0,
history: [],
},
},
},
}))
}
} catch {
// Connection failed — skip this cycle
}
}
// Clean up slaves that are no longer alive
let lanPeerNames: Set<string> | null = null
if (feature('LAN_PIPES')) {
const beacon = lb.getLanBeacon()
if (beacon) {
lanPeerNames = new Set(beacon.getPeers().keys())
}
}
for (const [slaveName, client] of connectedSlaves.entries()) {
const inLocalRegistry = aliveSubNames.has(slaveName)
const inLanBeacon = lanPeerNames?.has(slaveName) ?? false
if (!client.connected || (!inLocalRegistry && !inLanBeacon)) {
removeDeadSlave(slaveName, store)
}
}
} catch {
// Heartbeat cycle error — non-fatal
}
})()
}
function runSubHeartbeat(
pipeName: string,
machineId: string,
entry: any,
store: StoreApi,
disposed: { current: boolean },
): void {
void (async () => {
try {
const mainAlive = await pr.isMainAlive()
if (!mainAlive && !disposed.current) {
const registry = await pr.readRegistry()
const isSameMachine = pr.isMainMachine(machineId, registry)
if (isSameMachine) {
await pr.registerAsMain(entry)
} else {
await pr.revertToIndependent(pipeName)
}
store.setState((prev: any) => ({
...prev,
pipeIpc: {
...pt.getPipeIpc(prev),
role: 'main',
subIndex: null,
displayRole: 'main',
attachedBy: null,
},
}))
pp.setPipeRelay(null)
}
} catch {
// Heartbeat check error — non-fatal
}
})()
}
// ---------------------------------------------------------------------------
// Main hook
// ---------------------------------------------------------------------------
export function usePipeIpc({
store,
handleIncomingPrompt,
}: UsePipeIpcOptions): void {
if (!feature('UDS_INBOX')) return
useEffect(() => {
const sessionId = _getSessionId()
if (!sessionId) return
const pipeName = `cli-${sessionId.slice(0, 8)}`
const disposed = { current: false }
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
let heartbeatBusy = false
let pipeServer: PipeServer | null = null
void (async () => {
try {
// --- Phase 1: Role determination ---
const machId = await pr.getMachineId()
const mac = pr.getMacAddress()
const localIp = pt.getLocalIp()
const host = osm.hostname()
const roleResult = await pr.determineRole(machId)
const entry = {
id: pipeName,
pid: process.pid,
machineId: machId,
startedAt: Date.now(),
ip: localIp,
mac,
hostname: host,
pipeName,
}
let initialRole: 'main' | 'sub' = 'main'
let subIndex: number | null = null
let displayRole = 'main'
if (roleResult.role === 'main' || roleResult.role === 'main-recover') {
await pr.registerAsMain(entry)
} else {
subIndex = roleResult.subIndex
await pr.registerAsSub(entry, subIndex)
initialRole = 'sub'
displayRole = `sub-${subIndex}`
}
// --- Phase 2: Server creation ---
const server = await pt.createPipeServer(
pipeName,
feature('LAN_PIPES') ? { enableTcp: true, tcpPort: 0 } : undefined,
)
pipeServer = server
if (disposed.current) {
await server.close()
await pr.unregister(pipeName)
return
}
// --- Phase 3: LAN beacon ---
if (feature('LAN_PIPES') && server.tcpAddress) {
const beacon = new (lb.LanBeacon)({
pipeName,
machineId: machId,
hostname: host,
ip: localIp,
tcpPort: server.tcpAddress.port,
role: initialRole,
})
beacon.start()
lb.setLanBeacon(beacon)
const entryWithTcp = {
...entry,
tcpPort: server.tcpAddress.port,
lanVisible: true,
}
if (initialRole === 'main') {
await pr.registerAsMain(entryWithTcp)
} else if (subIndex != null) {
await pr.registerAsSub(entryWithTcp, subIndex)
}
}
// Update store
store.setState((prev: any) => ({
...prev,
pipeIpc: {
...pt.getPipeIpc(prev),
serverName: pipeName,
role: initialRole,
subIndex,
displayRole,
localIp,
hostname: host,
machineId: machId,
mac,
},
}))
// --- Phase 4: Message handlers ---
registerMessageHandlers(
server,
pipeName,
machId,
store,
handleIncomingPrompt,
)
// --- Phase 5: Heartbeat ---
const HEARTBEAT_INTERVAL_MS = 5000
heartbeatTimer = setInterval(() => {
if (disposed.current || heartbeatBusy) return
heartbeatBusy = true
const currentPipeState = pt.getPipeIpc(store.getState())
if (
currentPipeState.role === 'main' ||
currentPipeState.role === 'master'
) {
runMainHeartbeat(pipeName, machId, store, disposed)
} else if (currentPipeState.role === 'sub') {
runSubHeartbeat(pipeName, machId, entry, store, disposed)
}
// Reset busy flag after a short delay to allow the async work to settle
setTimeout(() => {
heartbeatBusy = false
}, 4000)
}, HEARTBEAT_INTERVAL_MS)
} catch {
// PipeServer creation failed — non-fatal
}
})()
// --- Phase 6: Cleanup ---
return () => {
disposed.current = true
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
// Send detach to all slaves
const allClients = mm.getAllSlaveClients()
for (const [name, client] of allClients.entries()) {
try {
client.send({ type: 'detach' })
} catch {}
client.disconnect()
removeDeadSlave(name, store)
}
// Stop LAN beacon
const beacon = lb.getLanBeacon()
if (beacon) {
try {
beacon.stop()
} catch {}
lb.setLanBeacon(null)
}
// Unregister + close server
pr.unregister(pipeName).catch(() => {})
if (pipeServer) {
void pipeServer.close().catch(() => {})
pipeServer = null
}
pp.setPipeRelay(null)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
}