@tapicker/bridge 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/bridge/bridge-service.d.ts +46 -0
- package/dist/bridge/bridge-service.js +245 -0
- package/dist/bridge/control.d.ts +7 -0
- package/dist/bridge/control.js +211 -0
- package/dist/bridge/daemon.d.ts +16 -0
- package/dist/bridge/daemon.js +97 -0
- package/dist/bridge/extension-session.d.ts +43 -0
- package/dist/bridge/extension-session.js +204 -0
- package/dist/bridge/pairing-state.d.ts +22 -0
- package/dist/bridge/pairing-state.js +76 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +835 -0
- package/dist/commands.d.ts +33 -0
- package/dist/commands.js +123 -0
- package/dist/constants.d.ts +10 -0
- package/dist/constants.js +10 -0
- package/dist/errors.d.ts +27 -0
- package/dist/errors.js +52 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +8 -0
- package/dist/mcp.d.ts +13 -0
- package/dist/mcp.js +300 -0
- package/dist/protocol/schema.d.ts +149 -0
- package/dist/protocol/schema.js +155 -0
- package/dist/server.d.ts +63 -0
- package/dist/server.js +486 -0
- package/dist/workflow-watch.d.ts +31 -0
- package/dist/workflow-watch.js +304 -0
- package/docs/cli.md +112 -0
- package/docs/http-api.md +149 -0
- package/docs/mcp.md +90 -0
- package/docs/operations.md +85 -0
- package/docs/workflow-watch.md +86 -0
- package/package.json +55 -0
- package/src/bridge/bridge-service.ts +291 -0
- package/src/bridge/control.ts +281 -0
- package/src/bridge/daemon.ts +102 -0
- package/src/bridge/extension-session.ts +286 -0
- package/src/bridge/pairing-state.ts +92 -0
- package/src/cli.ts +1019 -0
- package/src/commands.ts +158 -0
- package/src/constants.ts +10 -0
- package/src/errors.ts +72 -0
- package/src/index.ts +35 -0
- package/src/mcp.ts +386 -0
- package/src/protocol/schema.ts +202 -0
- package/src/server.ts +623 -0
- package/src/workflow-watch.ts +364 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { chmod, mkdir, unlink } from 'node:fs/promises'
|
|
3
|
+
import { createConnection, createServer } from 'node:net'
|
|
4
|
+
import type { Server, Socket } from 'node:net'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
import { BridgeError, toBridgeError } from '../errors.js'
|
|
7
|
+
|
|
8
|
+
export type BridgeControlAction = 'code.get' | 'code.refresh' | 'extensions.list' | 'bridge.stop'
|
|
9
|
+
|
|
10
|
+
const CONTROL_ACTIONS = new Set<BridgeControlAction>(['code.get', 'code.refresh', 'extensions.list', 'bridge.stop'])
|
|
11
|
+
const MAX_CONTROL_MESSAGE_BYTES = 4096
|
|
12
|
+
|
|
13
|
+
export function getBridgeControlPath(dataDir: string) {
|
|
14
|
+
const normalizedDataDir = resolve(dataDir)
|
|
15
|
+
if (process.platform === 'win32') {
|
|
16
|
+
const suffix = createHash('sha256').update(normalizedDataDir.toLowerCase()).digest('hex').slice(0, 24)
|
|
17
|
+
return `\\\\.\\pipe\\tapicker-bridge-${suffix}`
|
|
18
|
+
}
|
|
19
|
+
return join(normalizedDataDir, 'bridge.sock')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function startBridgeControlServer(
|
|
23
|
+
dataDir: string,
|
|
24
|
+
handle: (action: BridgeControlAction) => Promise<unknown>,
|
|
25
|
+
) {
|
|
26
|
+
await mkdir(dataDir, { recursive: true, mode: 0o700 })
|
|
27
|
+
const endpoint = getBridgeControlPath(dataDir)
|
|
28
|
+
const server = createServer((socket) => handleControlSocket(socket, handle))
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await listenControlServer(server, endpoint)
|
|
32
|
+
if (process.platform !== 'win32') {
|
|
33
|
+
await chmod(endpoint, 0o600)
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
await closeServer(server)
|
|
37
|
+
throw error
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
async close() {
|
|
42
|
+
await closeServer(server)
|
|
43
|
+
if (process.platform !== 'win32') {
|
|
44
|
+
await unlink(endpoint).catch((error) => {
|
|
45
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function sendBridgeControlRequest<T>(dataDir: string, action: BridgeControlAction): Promise<T> {
|
|
53
|
+
const endpoint = getBridgeControlPath(dataDir)
|
|
54
|
+
|
|
55
|
+
return new Promise<T>((resolveResponse, rejectResponse) => {
|
|
56
|
+
const socket = createConnection(endpoint)
|
|
57
|
+
let buffer = ''
|
|
58
|
+
let settled = false
|
|
59
|
+
|
|
60
|
+
const finish = (callback: () => void) => {
|
|
61
|
+
if (settled) return
|
|
62
|
+
settled = true
|
|
63
|
+
clearTimeout(timeout)
|
|
64
|
+
socket.destroy()
|
|
65
|
+
callback()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const timeout = setTimeout(() => {
|
|
69
|
+
finish(() =>
|
|
70
|
+
rejectResponse(
|
|
71
|
+
new BridgeError('BridgeControlTimeout', 'Timed out communicating with Tapicker Bridge', { status: 504 }),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
}, 3000)
|
|
75
|
+
|
|
76
|
+
socket.setEncoding('utf8')
|
|
77
|
+
socket.once('connect', () => socket.write(`${JSON.stringify({ action })}\n`))
|
|
78
|
+
socket.on('data', (chunk: string) => {
|
|
79
|
+
buffer += chunk
|
|
80
|
+
if (Buffer.byteLength(buffer) > MAX_CONTROL_MESSAGE_BYTES) {
|
|
81
|
+
finish(() =>
|
|
82
|
+
rejectResponse(new BridgeError('InvalidBridgeControlResponse', 'Bridge control response is too large')),
|
|
83
|
+
)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const newline = buffer.indexOf('\n')
|
|
88
|
+
if (newline < 0) return
|
|
89
|
+
|
|
90
|
+
let response: unknown
|
|
91
|
+
try {
|
|
92
|
+
response = JSON.parse(buffer.slice(0, newline))
|
|
93
|
+
} catch (cause) {
|
|
94
|
+
finish(() =>
|
|
95
|
+
rejectResponse(
|
|
96
|
+
new BridgeError('InvalidBridgeControlResponse', 'Bridge returned an invalid control response', {
|
|
97
|
+
status: 502,
|
|
98
|
+
cause,
|
|
99
|
+
}),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!response || typeof response !== 'object') {
|
|
106
|
+
finish(() =>
|
|
107
|
+
rejectResponse(
|
|
108
|
+
new BridgeError('InvalidBridgeControlResponse', 'Bridge returned an invalid control response', {
|
|
109
|
+
status: 502,
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const result = response as {
|
|
117
|
+
ok?: unknown
|
|
118
|
+
data?: unknown
|
|
119
|
+
error?: { code?: unknown; message?: unknown; status?: unknown }
|
|
120
|
+
}
|
|
121
|
+
if (result.ok === true) {
|
|
122
|
+
finish(() => resolveResponse(result.data as T))
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const error = result.error
|
|
127
|
+
finish(() =>
|
|
128
|
+
rejectResponse(
|
|
129
|
+
new BridgeError(
|
|
130
|
+
typeof error?.code === 'string' ? error.code : 'BridgeControlFailed',
|
|
131
|
+
typeof error?.message === 'string' ? error.message : 'Bridge control request failed',
|
|
132
|
+
{ status: typeof error?.status === 'number' ? error.status : 500 },
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
socket.once('error', (cause) => {
|
|
138
|
+
const code = (cause as NodeJS.ErrnoException).code
|
|
139
|
+
const isUnavailable = code === 'ENOENT' || code === 'ECONNREFUSED' || code === 'EINVAL'
|
|
140
|
+
finish(() =>
|
|
141
|
+
rejectResponse(
|
|
142
|
+
new BridgeError(
|
|
143
|
+
isUnavailable ? 'BridgeControlUnavailable' : 'BridgeControlFailed',
|
|
144
|
+
isUnavailable
|
|
145
|
+
? 'No Tapicker Bridge control service is available'
|
|
146
|
+
: 'Could not communicate with Tapicker Bridge',
|
|
147
|
+
{ status: isUnavailable ? 503 : 500, cause },
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
socket.once('end', () => {
|
|
153
|
+
if (!settled) {
|
|
154
|
+
finish(() =>
|
|
155
|
+
rejectResponse(
|
|
156
|
+
new BridgeError('InvalidBridgeControlResponse', 'Bridge closed the control connection without a response', {
|
|
157
|
+
status: 502,
|
|
158
|
+
}),
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function handleControlSocket(socket: Socket, handle: (action: BridgeControlAction) => Promise<unknown>) {
|
|
167
|
+
let buffer = ''
|
|
168
|
+
socket.setEncoding('utf8')
|
|
169
|
+
socket.setTimeout(5000, () => socket.destroy())
|
|
170
|
+
socket.on('data', (chunk: string) => {
|
|
171
|
+
buffer += chunk
|
|
172
|
+
if (Buffer.byteLength(buffer) > MAX_CONTROL_MESSAGE_BYTES) {
|
|
173
|
+
socket.destroy()
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const newline = buffer.indexOf('\n')
|
|
178
|
+
if (newline < 0) return
|
|
179
|
+
|
|
180
|
+
socket.removeAllListeners('data')
|
|
181
|
+
let action: unknown
|
|
182
|
+
try {
|
|
183
|
+
const message = JSON.parse(buffer.slice(0, newline)) as { action?: unknown }
|
|
184
|
+
action = message?.action
|
|
185
|
+
} catch (cause) {
|
|
186
|
+
sendControlError(
|
|
187
|
+
socket,
|
|
188
|
+
new BridgeError('InvalidBridgeControlRequest', 'Control request is not valid JSON', { status: 400, cause }),
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (typeof action !== 'string' || !CONTROL_ACTIONS.has(action as BridgeControlAction)) {
|
|
194
|
+
sendControlError(
|
|
195
|
+
socket,
|
|
196
|
+
new BridgeError('InvalidBridgeControlRequest', 'Unsupported Bridge control action', { status: 400 }),
|
|
197
|
+
)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
void handle(action as BridgeControlAction)
|
|
202
|
+
.then((data) => socket.end(`${JSON.stringify({ ok: true, data })}\n`))
|
|
203
|
+
.catch((error) => sendControlError(socket, error))
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function sendControlError(socket: Socket, error: unknown) {
|
|
208
|
+
const bridgeError = toBridgeError(error)
|
|
209
|
+
socket.end(
|
|
210
|
+
`${JSON.stringify({
|
|
211
|
+
ok: false,
|
|
212
|
+
error: {
|
|
213
|
+
code: bridgeError.code,
|
|
214
|
+
message: bridgeError.message,
|
|
215
|
+
status: bridgeError.status,
|
|
216
|
+
},
|
|
217
|
+
})}\n`,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function listenControlServer(server: Server, endpoint: string): Promise<void> {
|
|
222
|
+
return listen(server, endpoint).catch(async (error) => {
|
|
223
|
+
if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw error
|
|
224
|
+
if (await canConnect(endpoint)) {
|
|
225
|
+
throw new BridgeError(
|
|
226
|
+
'BridgeControlInUse',
|
|
227
|
+
'A Tapicker Bridge control service is already using this data directory',
|
|
228
|
+
{
|
|
229
|
+
status: 409,
|
|
230
|
+
cause: error,
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (process.platform === 'win32') throw error
|
|
236
|
+
await unlink(endpoint).catch((unlinkError) => {
|
|
237
|
+
if ((unlinkError as NodeJS.ErrnoException).code !== 'ENOENT') throw unlinkError
|
|
238
|
+
})
|
|
239
|
+
await listen(server, endpoint)
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function listen(server: Server, endpoint: string) {
|
|
244
|
+
return new Promise<void>((resolveListen, rejectListen) => {
|
|
245
|
+
const onError = (error: Error) => {
|
|
246
|
+
server.off('listening', onListening)
|
|
247
|
+
rejectListen(error)
|
|
248
|
+
}
|
|
249
|
+
const onListening = () => {
|
|
250
|
+
server.off('error', onError)
|
|
251
|
+
resolveListen()
|
|
252
|
+
}
|
|
253
|
+
server.once('error', onError)
|
|
254
|
+
server.once('listening', onListening)
|
|
255
|
+
server.listen(endpoint)
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function canConnect(endpoint: string) {
|
|
260
|
+
return new Promise<boolean>((resolveProbe) => {
|
|
261
|
+
const socket = createConnection(endpoint)
|
|
262
|
+
const timeout = setTimeout(() => {
|
|
263
|
+
socket.destroy()
|
|
264
|
+
resolveProbe(false)
|
|
265
|
+
}, 300)
|
|
266
|
+
socket.once('connect', () => {
|
|
267
|
+
clearTimeout(timeout)
|
|
268
|
+
socket.end()
|
|
269
|
+
resolveProbe(true)
|
|
270
|
+
})
|
|
271
|
+
socket.once('error', () => {
|
|
272
|
+
clearTimeout(timeout)
|
|
273
|
+
resolveProbe(false)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function closeServer(server: Server) {
|
|
279
|
+
if (!server.listening) return Promise.resolve()
|
|
280
|
+
return new Promise<void>((resolveClose) => server.close(() => resolveClose()))
|
|
281
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { BridgeError } from '../errors.js'
|
|
6
|
+
|
|
7
|
+
const BridgeDaemonStateSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
pid: z.number().int().positive(),
|
|
10
|
+
bridgeId: z.uuid(),
|
|
11
|
+
httpUrl: z.url(),
|
|
12
|
+
startedAt: z.number().int().positive(),
|
|
13
|
+
})
|
|
14
|
+
.strict()
|
|
15
|
+
|
|
16
|
+
export type BridgeDaemonState = z.infer<typeof BridgeDaemonStateSchema>
|
|
17
|
+
|
|
18
|
+
export function getBridgeDaemonStatePath(dataDir: string) {
|
|
19
|
+
return join(dataDir, 'bridge.pid')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getBridgeDaemonLogPath(dataDir: string) {
|
|
23
|
+
return join(dataDir, 'bridge.log')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readBridgeDaemonState(dataDir: string): Promise<BridgeDaemonState | undefined> {
|
|
27
|
+
const filePath = getBridgeDaemonStatePath(dataDir)
|
|
28
|
+
let content: string
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
content = await readFile(filePath, 'utf8')
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
|
34
|
+
throw error
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let value: unknown
|
|
38
|
+
try {
|
|
39
|
+
value = JSON.parse(content)
|
|
40
|
+
} catch (cause) {
|
|
41
|
+
throw new BridgeError('InvalidDaemonState', 'Saved Bridge process state is not valid JSON', {
|
|
42
|
+
status: 500,
|
|
43
|
+
cause,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const parsed = BridgeDaemonStateSchema.safeParse(value)
|
|
48
|
+
if (!parsed.success) {
|
|
49
|
+
throw new BridgeError('InvalidDaemonState', 'Saved Bridge process state has an unsupported format', {
|
|
50
|
+
status: 500,
|
|
51
|
+
details: parsed.error.issues,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return parsed.data
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function writeBridgeDaemonState(dataDir: string, state: BridgeDaemonState) {
|
|
59
|
+
const filePath = getBridgeDaemonStatePath(dataDir)
|
|
60
|
+
const directory = dirname(filePath)
|
|
61
|
+
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
62
|
+
|
|
63
|
+
const temporaryPath = join(directory, `.bridge-${process.pid}-${randomUUID()}.tmp`)
|
|
64
|
+
const content = JSON.stringify(state, null, 2)
|
|
65
|
+
let file: Awaited<ReturnType<typeof open>> | undefined
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
file = await open(temporaryPath, 'wx', 0o600)
|
|
69
|
+
await file.writeFile(content, 'utf8')
|
|
70
|
+
await file.sync()
|
|
71
|
+
await file.close()
|
|
72
|
+
file = undefined
|
|
73
|
+
await rename(temporaryPath, filePath)
|
|
74
|
+
} catch (error) {
|
|
75
|
+
await file?.close().catch(() => undefined)
|
|
76
|
+
await unlink(temporaryPath).catch(() => undefined)
|
|
77
|
+
throw error
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function removeBridgeDaemonState(dataDir: string, expectedPid: number) {
|
|
82
|
+
const state = await readBridgeDaemonState(dataDir)
|
|
83
|
+
if (state?.pid !== expectedPid) return
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await unlink(getBridgeDaemonStatePath(dataDir))
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isProcessRunning(pid: number) {
|
|
93
|
+
try {
|
|
94
|
+
process.kill(pid, 0)
|
|
95
|
+
return true
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
98
|
+
if (code === 'ESRCH') return false
|
|
99
|
+
if (code === 'EPERM') return true
|
|
100
|
+
throw error
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import type { WebSocket } from 'ws'
|
|
3
|
+
import { DEFAULT_REQUEST_TIMEOUT, PROTOCOL_VERSION } from '../constants.js'
|
|
4
|
+
import { BridgeError, safeError, safeSuccess } from '../errors.js'
|
|
5
|
+
import {
|
|
6
|
+
BridgeHelloDataSchema,
|
|
7
|
+
BridgePairDataSchema,
|
|
8
|
+
ExtensionHelloInputSchema,
|
|
9
|
+
ExtensionPairInputSchema,
|
|
10
|
+
SafeResultSchema,
|
|
11
|
+
createEnvelope,
|
|
12
|
+
parseEnvelope,
|
|
13
|
+
} from '../protocol/schema.js'
|
|
14
|
+
import type { ExtensionHelloInput, ExtensionPairInput, MessageEnvelope } from '../protocol/schema.js'
|
|
15
|
+
|
|
16
|
+
export type ExtensionSessionStatus = {
|
|
17
|
+
connected: boolean
|
|
18
|
+
ready: boolean
|
|
19
|
+
pairingRequired?: boolean
|
|
20
|
+
clientId?: string
|
|
21
|
+
clientName?: string
|
|
22
|
+
capabilities: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ExtensionSessionOptions = {
|
|
26
|
+
bridgeVersion: string
|
|
27
|
+
protocolVersion: number
|
|
28
|
+
capabilities: string[]
|
|
29
|
+
remoteAddress: string
|
|
30
|
+
verifyCredential: (clientId: string, credential: string) => boolean
|
|
31
|
+
pair: (input: ExtensionPairInput, remoteAddress: string) => Promise<string>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type PendingRequest = {
|
|
35
|
+
type: string
|
|
36
|
+
timer: ReturnType<typeof setTimeout>
|
|
37
|
+
resolve: (value: unknown) => void
|
|
38
|
+
reject: (error: unknown) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class ExtensionSession extends EventEmitter {
|
|
42
|
+
private ready = false
|
|
43
|
+
private pairingRequired = false
|
|
44
|
+
private clientId: string | undefined
|
|
45
|
+
private clientName: string | undefined
|
|
46
|
+
private capabilities: string[] = []
|
|
47
|
+
private pendingRequests = new Map<string, PendingRequest>()
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
private readonly socket: WebSocket,
|
|
51
|
+
private readonly options: ExtensionSessionOptions,
|
|
52
|
+
) {
|
|
53
|
+
super()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
start() {
|
|
57
|
+
this.socket.on('message', (data) => void this.handleMessage(data).catch((error) => this.reportError(error)))
|
|
58
|
+
this.socket.on('error', (error) => this.reportError(error))
|
|
59
|
+
this.socket.on('close', (code, reason) => {
|
|
60
|
+
this.rejectPending(new BridgeError('ExtensionDisconnected', 'Tapicker Extension disconnected', { status: 503 }))
|
|
61
|
+
this.emit('close', { code, reason: reason.toString() })
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getStatus(): ExtensionSessionStatus {
|
|
66
|
+
return {
|
|
67
|
+
connected: this.socket.readyState === this.socket.OPEN,
|
|
68
|
+
ready: this.ready,
|
|
69
|
+
pairingRequired: this.pairingRequired,
|
|
70
|
+
clientId: this.clientId,
|
|
71
|
+
clientName: this.clientName,
|
|
72
|
+
capabilities: this.ready ? this.capabilities : [],
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
request<T>(type: string, data: unknown, timeout = DEFAULT_REQUEST_TIMEOUT): Promise<T> {
|
|
77
|
+
if (!this.ready || this.socket.readyState !== this.socket.OPEN) {
|
|
78
|
+
return Promise.reject(
|
|
79
|
+
new BridgeError('ExtensionUnavailable', 'No paired Tapicker Extension is connected', { status: 503 }),
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const envelope = createEnvelope(type, data)
|
|
84
|
+
const responseType = `${type}:res`
|
|
85
|
+
|
|
86
|
+
return new Promise<T>((resolve, reject) => {
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
this.pendingRequests.delete(envelope.id)
|
|
89
|
+
reject(
|
|
90
|
+
new BridgeError('ExtensionRequestTimeout', `Extension did not respond to ${type}`, {
|
|
91
|
+
status: 504,
|
|
92
|
+
details: { timeout },
|
|
93
|
+
}),
|
|
94
|
+
)
|
|
95
|
+
}, timeout)
|
|
96
|
+
|
|
97
|
+
this.pendingRequests.set(envelope.id, {
|
|
98
|
+
type: responseType,
|
|
99
|
+
timer,
|
|
100
|
+
resolve: (value) => resolve(value as T),
|
|
101
|
+
reject,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
this.send(envelope)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
clearTimeout(timer)
|
|
108
|
+
this.pendingRequests.delete(envelope.id)
|
|
109
|
+
reject(error)
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
close(code = 1000, reason = 'Bridge session closed') {
|
|
115
|
+
if (this.socket.readyState === this.socket.OPEN || this.socket.readyState === this.socket.CONNECTING) {
|
|
116
|
+
this.socket.close(code, reason)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async handleMessage(raw: unknown) {
|
|
121
|
+
const parsed = parseEnvelope(raw)
|
|
122
|
+
if (!parsed.success) {
|
|
123
|
+
this.reportError(parsed.error)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const message = parsed.data
|
|
128
|
+
if (message.type === 'pong') return
|
|
129
|
+
|
|
130
|
+
const pending = this.pendingRequests.get(message.id)
|
|
131
|
+
if (pending && pending.type === message.type) {
|
|
132
|
+
this.pendingRequests.delete(message.id)
|
|
133
|
+
clearTimeout(pending.timer)
|
|
134
|
+
const result = SafeResultSchema.safeParse(message.data)
|
|
135
|
+
if (!result.success) {
|
|
136
|
+
pending.reject(
|
|
137
|
+
new BridgeError('InvalidExtensionResponse', 'Extension returned an invalid response', { status: 502 }),
|
|
138
|
+
)
|
|
139
|
+
} else if (result.data.ok) {
|
|
140
|
+
pending.resolve(result.data.data)
|
|
141
|
+
} else {
|
|
142
|
+
pending.reject(
|
|
143
|
+
new BridgeError(result.data.error.code, result.data.error.message, {
|
|
144
|
+
status: 502,
|
|
145
|
+
details: result.data.error.details,
|
|
146
|
+
}),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (message.type === 'ping') {
|
|
153
|
+
this.respond(message, 'pong', undefined)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (message.type === 'extension.hello') {
|
|
158
|
+
await this.handleHello(message)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (message.type === 'extension.pair') {
|
|
163
|
+
await this.handlePair(message)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this.emit('message', message)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async handleHello(message: MessageEnvelope) {
|
|
171
|
+
const parsed = ExtensionHelloInputSchema.safeParse(message.data)
|
|
172
|
+
if (!parsed.success) {
|
|
173
|
+
this.respond(
|
|
174
|
+
message,
|
|
175
|
+
`${message.type}:res`,
|
|
176
|
+
safeError(
|
|
177
|
+
new BridgeError('InvalidHello', 'Invalid Extension hello', {
|
|
178
|
+
status: 400,
|
|
179
|
+
details: parsed.error.issues,
|
|
180
|
+
}),
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const hello = parsed.data
|
|
187
|
+
this.clientId = hello.clientId
|
|
188
|
+
this.clientName = hello.clientName
|
|
189
|
+
this.capabilities = hello.capabilities
|
|
190
|
+
|
|
191
|
+
const accepted = Boolean(hello.credential && this.options.verifyCredential(hello.clientId, hello.credential))
|
|
192
|
+
this.pairingRequired = !accepted
|
|
193
|
+
const response = BridgeHelloDataSchema.parse({
|
|
194
|
+
accepted,
|
|
195
|
+
requiresPairing: !accepted,
|
|
196
|
+
bridgeVersion: this.options.bridgeVersion,
|
|
197
|
+
protocolVersion: this.options.protocolVersion,
|
|
198
|
+
capabilities: this.options.capabilities,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
this.respond(message, `${message.type}:res`, safeSuccess(response))
|
|
202
|
+
if (accepted) this.authorize(hello)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async handlePair(message: MessageEnvelope) {
|
|
206
|
+
const parsed = ExtensionPairInputSchema.safeParse(message.data)
|
|
207
|
+
if (!parsed.success) {
|
|
208
|
+
this.respond(
|
|
209
|
+
message,
|
|
210
|
+
`${message.type}:res`,
|
|
211
|
+
safeError(
|
|
212
|
+
new BridgeError('InvalidPairingRequest', 'Invalid pairing request', {
|
|
213
|
+
status: 400,
|
|
214
|
+
details: parsed.error.issues,
|
|
215
|
+
}),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const input = parsed.data
|
|
222
|
+
if (!this.clientId || input.clientId !== this.clientId || input.clientName !== this.clientName) {
|
|
223
|
+
this.respond(
|
|
224
|
+
message,
|
|
225
|
+
`${message.type}:res`,
|
|
226
|
+
safeError(
|
|
227
|
+
new BridgeError('PairingSessionMismatch', 'Pairing request does not match the Extension hello', {
|
|
228
|
+
status: 400,
|
|
229
|
+
}),
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const credential = await this.options.pair(input, this.options.remoteAddress)
|
|
237
|
+
const response = BridgePairDataSchema.parse({
|
|
238
|
+
accepted: true,
|
|
239
|
+
clientId: input.clientId,
|
|
240
|
+
credential,
|
|
241
|
+
})
|
|
242
|
+
this.respond(message, `${message.type}:res`, safeSuccess(response))
|
|
243
|
+
this.authorize({ ...input, capabilities: this.capabilities })
|
|
244
|
+
} catch (error) {
|
|
245
|
+
this.respond(message, `${message.type}:res`, safeError(error))
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private authorize(hello: Pick<ExtensionHelloInput, 'clientId' | 'clientName' | 'capabilities'>) {
|
|
250
|
+
this.ready = true
|
|
251
|
+
this.pairingRequired = false
|
|
252
|
+
this.clientId = hello.clientId
|
|
253
|
+
this.clientName = hello.clientName
|
|
254
|
+
this.capabilities = hello.capabilities
|
|
255
|
+
this.emit('ready', {
|
|
256
|
+
clientId: this.clientId,
|
|
257
|
+
clientName: this.clientName,
|
|
258
|
+
capabilities: this.capabilities,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private respond(request: MessageEnvelope, type: string, data: unknown) {
|
|
263
|
+
this.send(createEnvelope(type, data, { id: request.id, callback: false }))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private send(envelope: MessageEnvelope) {
|
|
267
|
+
if (this.socket.readyState !== this.socket.OPEN) {
|
|
268
|
+
throw new BridgeError('ExtensionUnavailable', 'Tapicker Extension WebSocket is not open', { status: 503 })
|
|
269
|
+
}
|
|
270
|
+
this.socket.send(JSON.stringify(envelope))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private rejectPending(error: unknown) {
|
|
274
|
+
for (const pending of this.pendingRequests.values()) {
|
|
275
|
+
clearTimeout(pending.timer)
|
|
276
|
+
pending.reject(error)
|
|
277
|
+
}
|
|
278
|
+
this.pendingRequests.clear()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private reportError(error: unknown) {
|
|
282
|
+
if (this.listenerCount('error') > 0) {
|
|
283
|
+
this.emit('error', error)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|