Skip to content

Commit ae71840

Browse files
committed
feat(api): move from http to socket
1 parent 76863e1 commit ae71840

16 files changed

Lines changed: 369 additions & 73 deletions

main/src/app-events/block-quit.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import {
88
recreateMainWindowForShutdown,
99
sendToMainWindowRenderer,
1010
} from '../main-window'
11-
import { getToolhivePort, stopToolhive, binPath } from '../toolhive-manager'
11+
import { stopToolhive, binPath } from '../toolhive-manager'
1212
import { stopAllServers } from '../graceful-exit'
13+
import { createMainProcessFetch } from '../unix-socket-fetch'
1314
import { safeTrayDestroy } from '../system-tray'
1415
import { delay } from '../../../utils/delay'
1516
import log from '../logger'
@@ -39,10 +40,7 @@ export async function blockQuit(source: string, event?: Electron.Event) {
3940
}
4041

4142
try {
42-
const port = getToolhivePort()
43-
if (port) {
44-
await stopAllServers(binPath, port)
45-
}
43+
await stopAllServers(binPath, { createFetch: createMainProcessFetch })
4644
} catch (err) {
4745
log.error('Teardown failed: ', err)
4846
} finally {

main/src/app-events/process-signals.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import {
33
setTearingDownState,
44
setQuittingState,
55
} from '../app-state'
6-
import { getToolhivePort, stopToolhive, binPath } from '../toolhive-manager'
6+
import { stopToolhive, binPath } from '../toolhive-manager'
77
import { stopAllServers } from '../graceful-exit'
8+
import { createMainProcessFetch } from '../unix-socket-fetch'
89
import { safeTrayDestroy } from '../system-tray'
910
import log from '../logger'
1011

@@ -17,10 +18,7 @@ export function register() {
1718
setQuittingState(true)
1819
log.info(`[${sig}] delaying exit for teardown...`)
1920
try {
20-
const port = getToolhivePort()
21-
if (port) {
22-
await stopAllServers(binPath, port)
23-
}
21+
await stopAllServers(binPath, { createFetch: createMainProcessFetch })
2422
} finally {
2523
stopToolhive()
2624
safeTrayDestroy()

main/src/app-events/when-ready.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isToolhiveRunning,
1414
stopToolhive,
1515
} from '../toolhive-manager'
16+
import { registerApiFetchHandlers } from '../unix-socket-fetch'
1617
import { getMainWindow, createMainWindow, hideMainWindow } from '../main-window'
1718
import { extractDeepLinkFromArgs, handleDeepLink } from '../deep-links'
1819
import { getCspString } from '../csp'
@@ -69,6 +70,9 @@ export function register() {
6970
// Start ToolHive with tray reference
7071
await startToolhive()
7172

73+
// Register IPC handlers for renderer -> main -> thv API bridge
74+
registerApiFetchHandlers()
75+
7276
// Create main window
7377
try {
7478
const mainWindow = await createMainWindow()
@@ -131,10 +135,9 @@ export function register() {
131135
if (process.env.NODE_ENV === 'development') {
132136
return callback({ responseHeaders: details.responseHeaders })
133137
}
138+
// When using UNIX sockets, API requests go through IPC so no port is
139+
// needed in connect-src. Pass the port only when available (TCP fallback).
134140
const port = getToolhivePort()
135-
if (port == null) {
136-
throw new Error('[content-security-policy] ToolHive port is not set')
137-
}
138141
return callback({
139142
responseHeaders: {
140143
...details.responseHeaders,

main/src/auto-update.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,8 @@ import { app, autoUpdater, dialog, ipcMain, type BrowserWindow } from 'electron'
22
import { updateElectronApp } from 'update-electron-app'
33
import * as Sentry from '@sentry/electron/main'
44
import { stopAllServers } from './graceful-exit'
5-
import {
6-
stopToolhive,
7-
getToolhivePort,
8-
binPath,
9-
isToolhiveRunning,
10-
} from './toolhive-manager'
5+
import { stopToolhive, binPath, isToolhiveRunning } from './toolhive-manager'
6+
import { createMainProcessFetch } from './unix-socket-fetch'
117
import { safeTrayDestroy } from './system-tray'
128
import { getAppVersion, pollWindowReady } from './util'
139
import { delay } from '../../utils/delay'
@@ -37,14 +33,7 @@ let updateState: UpdateState = 'none'
3733

3834
async function safeServerShutdown(): Promise<boolean> {
3935
try {
40-
const port = getToolhivePort()
41-
if (!port) {
42-
log.info('[update] No ToolHive port available, skipping server shutdown')
43-
return true
44-
}
45-
46-
await stopAllServers(binPath, port)
47-
36+
await stopAllServers(binPath, { createFetch: createMainProcessFetch })
4837
log.info('[update] All servers stopped successfully')
4938
return true
5039
} catch (error) {

main/src/csp.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,33 @@
1-
const getCspMap = (port: number, sentryDsn?: string) => {
2-
// In production with Sentry enabled, allow blob workers for replay
1+
const getCspMap = (port: number | undefined, sentryDsn?: string) => {
32
const hasSentry = Boolean(sentryDsn)
43
const workerSrc = hasSentry ? "'self' blob:" : "'self'"
54

5+
// When using UNIX sockets the renderer never makes direct HTTP requests
6+
// to the thv server, so no localhost entry is needed in connect-src.
7+
const connectParts = ["'self'"]
8+
if (port != null) connectParts.push(`http://localhost:${port}`)
9+
if (hasSentry) connectParts.push('https://*.sentry.io')
10+
611
return {
712
'default-src': "'self'",
813
'script-src': "'self'",
914
'style-src': "'self' 'unsafe-inline'",
1015
'img-src': "'self' data: blob:",
1116
'font-src': "'self' data:",
12-
'connect-src': `'self' http://localhost:${port}${hasSentry ? ' https://*.sentry.io' : ''}`,
17+
'connect-src': connectParts.join(' '),
1318
'frame-src': "'none'",
1419
'object-src': "'none'",
1520
'base-uri': "'self'",
1621
'form-action': "'self'",
1722
'frame-ancestors': "'none'",
1823
'manifest-src': "'self'",
1924
'media-src': "'self' blob: data:",
20-
// Allow blob: workers only when Sentry is configured
2125
'worker-src': workerSrc,
2226
'child-src': "'none'",
2327
}
2428
}
2529

26-
export const getCspString = (port: number, sentryDsn?: string) =>
30+
export const getCspString = (port: number | undefined, sentryDsn?: string) =>
2731
Object.entries(getCspMap(port, sentryDsn))
2832
.map(([key, value]) => `${key} ${value}`)
2933
.join('; ')

main/src/graceful-exit.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@ export const shutdownStore = new Store({
2424
},
2525
})
2626

27-
/** Create API client for the given port */
28-
function createApiClient(port: number) {
27+
/**
28+
* Create API client. When a custom fetch is provided (UNIX socket transport),
29+
* the baseUrl is a dummy since the custom fetch handles routing.
30+
*/
31+
function createApiClient(opts: { port?: number; customFetch?: typeof fetch }) {
2932
return createClient({
30-
baseUrl: `http://localhost:${port}`,
33+
baseUrl: opts.port ? `http://localhost:${opts.port}` : 'http://localhost',
3134
headers: getHeaders(),
35+
...(opts.customFetch ? { fetch: opts.customFetch } : {}),
3236
})
3337
}
3438

@@ -116,10 +120,11 @@ async function pollUntilAllStopped(
116120

117121
/** Stop every running server in parallel and wait until *all* are down. */
118122
export async function stopAllServers(
119-
_binPath: string, // Kept for backward compatibility
120-
port: number
123+
_binPath: string,
124+
opts: { port?: number; createFetch?: () => typeof fetch }
121125
): Promise<void> {
122-
const client = createApiClient(port)
126+
const customFetch = opts.createFetch?.()
127+
const client = createApiClient({ port: opts.port, customFetch })
123128
const servers = await getRunningServers(client)
124129
log.info(
125130
`Found ${servers.length} running servers: `,

main/src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import { telemetryStore } from './telemetry-store'
9595
import {
9696
restartToolhive,
9797
getToolhivePort,
98+
getToolhiveSocketPath,
9899
isToolhiveRunning,
99100
binPath,
100101
getToolhiveMcpPort,
@@ -195,6 +196,7 @@ ipcMain.handle('set-skip-quit-confirmation', (_e, skip: boolean) =>
195196

196197
ipcMain.handle('get-toolhive-port', () => getToolhivePort())
197198
ipcMain.handle('get-toolhive-mcp-port', () => getToolhiveMcpPort())
199+
ipcMain.handle('get-toolhive-socket-path', () => getToolhiveSocketPath())
198200
ipcMain.handle('is-toolhive-running', () => isToolhiveRunning())
199201
ipcMain.handle('is-using-custom-port', () => isUsingCustomPort())
200202

main/src/tests/auto-update.test.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ vi.mock('../toolhive-manager', () => ({
122122
binPath: '/mock/bin/path',
123123
}))
124124

125+
vi.mock('../unix-socket-fetch', () => ({
126+
createMainProcessFetch: vi.fn(() => vi.fn()),
127+
}))
128+
125129
vi.mock('../system-tray', () => ({
126130
safeTrayDestroy: vi.fn(),
127131
}))
@@ -154,7 +158,7 @@ vi.mock('../app-state', () => ({
154158
}))
155159

156160
import { stopAllServers } from '../graceful-exit'
157-
import { stopToolhive, getToolhivePort } from '../toolhive-manager'
161+
import { stopToolhive } from '../toolhive-manager'
158162
import { safeTrayDestroy } from '../system-tray'
159163
import { pollWindowReady } from '../util'
160164
import { delay } from '../../../utils/delay'
@@ -197,7 +201,6 @@ describe('auto-update', () => {
197201
// Setup default mocks
198202
vi.mocked(stopAllServers).mockResolvedValue(undefined)
199203
vi.mocked(stopToolhive).mockReturnValue(undefined)
200-
vi.mocked(getToolhivePort).mockReturnValue(3000)
201204
vi.mocked(pollWindowReady).mockResolvedValue(undefined)
202205
vi.mocked(delay).mockResolvedValue(undefined)
203206
vi.mocked(dialog.showMessageBox).mockResolvedValue({
@@ -801,8 +804,7 @@ describe('auto-update', () => {
801804
expect(vi.mocked(autoUpdater).quitAndInstall).toHaveBeenCalled()
802805
})
803806

804-
it('integrates with toolhive manager port detection', async () => {
805-
vi.mocked(getToolhivePort).mockReturnValue(undefined)
807+
it('always attempts server shutdown via IPC fetch bridge', async () => {
806808
vi.mocked(dialog.showMessageBox).mockResolvedValue({
807809
response: 0,
808810
checkboxChecked: false,
@@ -821,13 +823,14 @@ describe('auto-update', () => {
821823

822824
await updatePromise
823825

824-
// Should skip server shutdown when no port is available
825-
expect(vi.mocked(getToolhivePort)).toHaveBeenCalled()
826-
expect(vi.mocked(stopAllServers)).not.toHaveBeenCalled()
826+
// Always attempts server shutdown (connection errors handled internally)
827+
expect(vi.mocked(stopAllServers)).toHaveBeenCalled()
827828
})
828829

829-
it('handles missing toolhive port gracefully', async () => {
830-
vi.mocked(getToolhivePort).mockReturnValue(undefined)
830+
it('handles server shutdown failure gracefully', async () => {
831+
vi.mocked(stopAllServers).mockRejectedValueOnce(
832+
new Error('No ToolHive connection available')
833+
)
831834
vi.mocked(dialog.showMessageBox).mockResolvedValue({
832835
response: 0,
833836
checkboxChecked: false,
@@ -846,8 +849,9 @@ describe('auto-update', () => {
846849

847850
await updatePromise
848851

849-
expect(vi.mocked(log).info).toHaveBeenCalledWith(
850-
'[update] No ToolHive port available, skipping server shutdown'
852+
expect(vi.mocked(log).error).toHaveBeenCalledWith(
853+
expect.stringContaining('[update] Server shutdown failed'),
854+
expect.anything()
851855
)
852856
})
853857

main/src/tests/graceful-exit.test.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ describe('graceful-exit', () => {
124124
createMockWorkloadsResponse([])
125125
)
126126

127-
await stopAllServers('', 3000)
127+
await stopAllServers('', { port: 3000 })
128128

129129
expect(mockLog.info).toHaveBeenCalledWith(
130130
'No running servers – teardown complete'
@@ -147,7 +147,7 @@ describe('graceful-exit', () => {
147147

148148
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
149149

150-
await stopAllServers('', 3000)
150+
await stopAllServers('', { port: 3000 })
151151

152152
expect(mockPostApiV1BetaWorkloadsStop).toHaveBeenCalledTimes(1)
153153
expect(mockPostApiV1BetaWorkloadsStop).toHaveBeenCalledWith({
@@ -172,7 +172,7 @@ describe('graceful-exit', () => {
172172

173173
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
174174

175-
await stopAllServers('', 3000)
175+
await stopAllServers('', { port: 3000 })
176176

177177
expect(mockLog.info).toHaveBeenCalledWith(
178178
'All servers have reached final state'
@@ -189,7 +189,9 @@ describe('graceful-exit', () => {
189189
new Error('Stop failed')
190190
)
191191

192-
await expect(stopAllServers('', 3000)).rejects.toThrow('Stop failed')
192+
await expect(stopAllServers('', { port: 3000 })).rejects.toThrow(
193+
'Stop failed'
194+
)
193195
})
194196

195197
it('handles timeout when servers do not stop', async () => {
@@ -208,7 +210,7 @@ describe('graceful-exit', () => {
208210

209211
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
210212

211-
await expect(stopAllServers('', 3000)).rejects.toThrow(
213+
await expect(stopAllServers('', { port: 3000 })).rejects.toThrow(
212214
'Some servers failed to stop within timeout'
213215
)
214216
})
@@ -220,7 +222,7 @@ describe('graceful-exit', () => {
220222

221223
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
222224

223-
await stopAllServers('', 3000)
225+
await stopAllServers('', { port: 3000 })
224226

225227
expect(mockStoreInstance.set).toHaveBeenCalledWith(
226228
'lastShutdownServers',
@@ -244,7 +246,7 @@ describe('graceful-exit', () => {
244246

245247
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
246248

247-
await stopAllServers('', 3000)
249+
await stopAllServers('', { port: 3000 })
248250

249251
// Should only include the server with a name in the batch call
250252
expect(mockPostApiV1BetaWorkloadsStop).toHaveBeenCalledTimes(1)
@@ -311,7 +313,7 @@ describe('graceful-exit', () => {
311313

312314
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
313315

314-
await stopAllServers('', 3000)
316+
await stopAllServers('', { port: 3000 })
315317

316318
expect(mockLog.info).toHaveBeenCalledWith(
317319
'Still waiting for 1 servers to reach final state: server1(stopping)'
@@ -337,7 +339,7 @@ describe('graceful-exit', () => {
337339

338340
mockPostApiV1BetaWorkloadsStop.mockResolvedValue(createMockStopResponse())
339341

340-
await stopAllServers('', 3000)
342+
await stopAllServers('', { port: 3000 })
341343

342344
// Should call delay between polling attempts (not on first attempt)
343345
expect(mockDelay).toHaveBeenCalledWith(2000)

0 commit comments

Comments
 (0)