Skip to content

Commit a073f57

Browse files
committed
feat(api): move from http to socket
1 parent 2d5a28f commit a073f57

File tree

12 files changed

+368
-71
lines changed

12 files changed

+368
-71
lines changed

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: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,16 @@ import {
4545
restartToolhive,
4646
stopToolhive,
4747
getToolhivePort,
48+
getToolhiveSocketPath,
4849
isToolhiveRunning,
4950
binPath,
5051
getToolhiveMcpPort,
5152
isUsingCustomPort,
5253
} from './toolhive-manager'
54+
import {
55+
registerApiFetchHandlers,
56+
createMainProcessFetch,
57+
} from './unix-socket-fetch'
5358
import log from './logger'
5459
import { getInstanceId, isOfficialReleaseBuild } from './util'
5560
import { delay } from '../../utils/delay'
@@ -208,10 +213,7 @@ export async function blockQuit(source: string, event?: Electron.Event) {
208213
}
209214

210215
try {
211-
const port = getToolhivePort()
212-
if (port) {
213-
await stopAllServers(binPath, port)
214-
}
216+
await stopAllServers(binPath, { createFetch: createMainProcessFetch })
215217
} catch (err) {
216218
log.error('Teardown failed: ', err)
217219
} finally {
@@ -316,6 +318,9 @@ app.whenReady().then(async () => {
316318
// Start ToolHive with tray reference
317319
await startToolhive()
318320

321+
// Register IPC handlers for renderer -> main -> thv API bridge
322+
registerApiFetchHandlers()
323+
319324
// Create main window
320325
try {
321326
const mainWindow = await createMainWindow()
@@ -378,10 +383,9 @@ app.whenReady().then(async () => {
378383
if (process.env.NODE_ENV === 'development') {
379384
return callback({ responseHeaders: details.responseHeaders })
380385
}
386+
// When using UNIX sockets, API requests go through IPC so no port is
387+
// needed in connect-src. Pass the port only when available (TCP fallback).
381388
const port = getToolhivePort()
382-
if (port == null) {
383-
throw new Error('[content-security-policy] ToolHive port is not set')
384-
}
385389
return callback({
386390
responseHeaders: {
387391
...details.responseHeaders,
@@ -489,10 +493,7 @@ app.on('quit', () => {
489493
setQuittingState(true)
490494
log.info(`[${sig}] delaying exit for teardown...`)
491495
try {
492-
const port = getToolhivePort()
493-
if (port) {
494-
await stopAllServers(binPath, port)
495-
}
496+
await stopAllServers(binPath, { createFetch: createMainProcessFetch })
496497
} finally {
497498
stopToolhive()
498499
safeTrayDestroy()
@@ -569,6 +570,7 @@ ipcMain.handle('set-skip-quit-confirmation', (_e, skip: boolean) =>
569570

570571
ipcMain.handle('get-toolhive-port', () => getToolhivePort())
571572
ipcMain.handle('get-toolhive-mcp-port', () => getToolhiveMcpPort())
573+
ipcMain.handle('get-toolhive-socket-path', () => getToolhiveSocketPath())
572574
ipcMain.handle('is-toolhive-running', () => isToolhiveRunning())
573575
ipcMain.handle('is-using-custom-port', () => isUsingCustomPort())
574576

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)