@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
package/src/server.ts
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createServer as createHttpServer,
|
|
3
|
+
type IncomingMessage,
|
|
4
|
+
type Server as HttpServer,
|
|
5
|
+
type ServerResponse,
|
|
6
|
+
} from 'node:http'
|
|
7
|
+
import { createServer as createHttpsServer } from 'node:https'
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { randomInt, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
10
|
+
import { URL } from 'node:url'
|
|
11
|
+
import { join, resolve } from 'node:path'
|
|
12
|
+
import { WebSocketServer } from 'ws'
|
|
13
|
+
import type { WebSocket } from 'ws'
|
|
14
|
+
import { createMcpHandler } from '@modelcontextprotocol/server'
|
|
15
|
+
import type { McpHttpHandler } from '@modelcontextprotocol/server'
|
|
16
|
+
import { localhostHostValidation, toNodeHandler } from '@modelcontextprotocol/node'
|
|
17
|
+
import type { NodeMcpRequestHandler } from '@modelcontextprotocol/node'
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_HOST,
|
|
20
|
+
DEFAULT_MCP_PATH,
|
|
21
|
+
DEFAULT_PORT,
|
|
22
|
+
DEFAULT_PORT_MAX,
|
|
23
|
+
DEFAULT_WS_PATH,
|
|
24
|
+
DEFAULT_WORKFLOW_WATCH_WS_PATH,
|
|
25
|
+
PROTOCOL_VERSION,
|
|
26
|
+
VERSION,
|
|
27
|
+
} from './constants.js'
|
|
28
|
+
import { BridgeService } from './bridge/bridge-service.js'
|
|
29
|
+
import type { BridgeWorkflowStatusEvent } from './bridge/bridge-service.js'
|
|
30
|
+
import { PairingStateFile } from './bridge/pairing-state.js'
|
|
31
|
+
import { getCommandCapabilities, dispatchCommand } from './commands.js'
|
|
32
|
+
import { BridgeError, safeError, safeSuccess, toBridgeError } from './errors.js'
|
|
33
|
+
import { createBridgeMcpApi, createTapickerMcpServer } from './mcp.js'
|
|
34
|
+
import { WorkflowWatchRequestSchema, createEnvelope, parseEnvelope } from './protocol/schema.js'
|
|
35
|
+
|
|
36
|
+
const DEFAULT_CORS_ORIGINS = ['*']
|
|
37
|
+
const CORS_ALLOWED_METHODS = 'GET, POST, DELETE, OPTIONS'
|
|
38
|
+
const CORS_ALLOWED_HEADERS = 'Accept, Authorization, Content-Type, Last-Event-ID, MCP-Protocol-Version, Mcp-Session-Id'
|
|
39
|
+
const CORS_EXPOSED_HEADERS = 'MCP-Protocol-Version, Mcp-Session-Id'
|
|
40
|
+
|
|
41
|
+
export type BridgeTlsOptions = {
|
|
42
|
+
key: string
|
|
43
|
+
cert: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type BridgeServerOptions = {
|
|
47
|
+
host?: string
|
|
48
|
+
port?: number
|
|
49
|
+
portMax?: number
|
|
50
|
+
token?: string
|
|
51
|
+
corsOrigins?: string[]
|
|
52
|
+
tls?: BridgeTlsOptions
|
|
53
|
+
pairingCode?: string
|
|
54
|
+
dataDir?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type NodeServer = HttpServer
|
|
58
|
+
|
|
59
|
+
type WorkflowSubscription = {
|
|
60
|
+
clientId: string
|
|
61
|
+
workflowId: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class BridgeServer {
|
|
65
|
+
readonly bridgeId = randomUUID()
|
|
66
|
+
readonly bridge: BridgeService
|
|
67
|
+
|
|
68
|
+
private readonly host: string
|
|
69
|
+
private readonly requestedPort?: number
|
|
70
|
+
private readonly portMax: number
|
|
71
|
+
private readonly token?: string
|
|
72
|
+
private readonly corsOrigins: string[]
|
|
73
|
+
private readonly tls?: BridgeTlsOptions
|
|
74
|
+
private readonly mcpHandler: McpHttpHandler
|
|
75
|
+
private readonly mcpNodeHandler: NodeMcpRequestHandler
|
|
76
|
+
private readonly validateLocalMcpHost = localhostHostValidation()
|
|
77
|
+
private server: NodeServer | undefined
|
|
78
|
+
private webSocketServer: WebSocketServer | undefined
|
|
79
|
+
private port: number | undefined
|
|
80
|
+
private readonly workflowSubscriptions = new Map<WebSocket, WorkflowSubscription>()
|
|
81
|
+
|
|
82
|
+
constructor(options: BridgeServerOptions = {}) {
|
|
83
|
+
this.host = options.host ?? DEFAULT_HOST
|
|
84
|
+
this.requestedPort = options.port
|
|
85
|
+
this.portMax = options.portMax ?? DEFAULT_PORT_MAX
|
|
86
|
+
this.token = options.token
|
|
87
|
+
this.corsOrigins = normalizeCorsOrigins(options.corsOrigins ?? DEFAULT_CORS_ORIGINS)
|
|
88
|
+
this.tls = options.tls
|
|
89
|
+
const pairingState = options.dataDir
|
|
90
|
+
? new PairingStateFile(join(resolve(options.dataDir), 'paired-clients.json'))
|
|
91
|
+
: undefined
|
|
92
|
+
this.bridge = new BridgeService({
|
|
93
|
+
pairingCode: options.pairingCode ?? String(randomInt(0, 10_000)).padStart(4, '0'),
|
|
94
|
+
capabilities: [
|
|
95
|
+
'workflow.start',
|
|
96
|
+
'workflow.pause',
|
|
97
|
+
'workflow.resume',
|
|
98
|
+
'workflow.stop',
|
|
99
|
+
'workflow.status',
|
|
100
|
+
'workflow.status.get',
|
|
101
|
+
'data.get',
|
|
102
|
+
'data.delete',
|
|
103
|
+
],
|
|
104
|
+
pairingState,
|
|
105
|
+
})
|
|
106
|
+
this.mcpHandler = createMcpHandler(() => createTapickerMcpServer(createBridgeMcpApi(this.bridge)))
|
|
107
|
+
this.mcpNodeHandler = toNodeHandler(this.mcpHandler, {
|
|
108
|
+
onerror: (error) => console.error('Tapicker Bridge MCP request failed:', error),
|
|
109
|
+
})
|
|
110
|
+
this.bridge.on('workflow.status', (event: BridgeWorkflowStatusEvent) => this.publishWorkflowStatus(event))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getPairingCode() {
|
|
114
|
+
return this.bridge.getPairingCode()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
refreshPairingCode() {
|
|
118
|
+
return this.bridge.refreshPairingCode()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async start() {
|
|
122
|
+
if (this.server && this.port !== undefined) {
|
|
123
|
+
return this.getAddress()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await this.bridge.initialize()
|
|
127
|
+
|
|
128
|
+
const ports = this.getPorts()
|
|
129
|
+
let lastError: unknown
|
|
130
|
+
|
|
131
|
+
for (const port of ports) {
|
|
132
|
+
const runtime = this.createRuntime()
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const actualPort = await listen(runtime.server, this.host, port)
|
|
136
|
+
this.server = runtime.server
|
|
137
|
+
this.webSocketServer = runtime.webSocketServer
|
|
138
|
+
this.port = actualPort
|
|
139
|
+
return this.getAddress()
|
|
140
|
+
} catch (error) {
|
|
141
|
+
await closeServer(runtime.server)
|
|
142
|
+
runtime.webSocketServer.close()
|
|
143
|
+
|
|
144
|
+
if (isAddressInUseError(error) && this.requestedPort === undefined) {
|
|
145
|
+
lastError = error
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
throw error
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
throw toBridgeError(lastError, 'PortUnavailable', 503)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async close() {
|
|
157
|
+
this.bridge.close()
|
|
158
|
+
this.workflowSubscriptions.clear()
|
|
159
|
+
await this.mcpHandler.close()
|
|
160
|
+
this.webSocketServer?.clients.forEach((client) => client.terminate())
|
|
161
|
+
await closeWebSocketServer(this.webSocketServer)
|
|
162
|
+
await closeServer(this.server)
|
|
163
|
+
this.server = undefined
|
|
164
|
+
this.webSocketServer = undefined
|
|
165
|
+
this.port = undefined
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getAddress() {
|
|
169
|
+
if (!this.port) {
|
|
170
|
+
throw new BridgeError('BridgeNotStarted', 'Bridge server has not started', { status: 500 })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const secure = Boolean(this.tls)
|
|
174
|
+
const protocol = secure ? 'https' : 'http'
|
|
175
|
+
const webSocketProtocol = secure ? 'wss' : 'ws'
|
|
176
|
+
const displayHost = this.host === '0.0.0.0' || this.host === '::' ? '127.0.0.1' : this.host
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
host: this.host,
|
|
180
|
+
port: this.port,
|
|
181
|
+
httpUrl: `${protocol}://${displayHost}:${this.port}`,
|
|
182
|
+
wsUrl: `${webSocketProtocol}://${displayHost}:${this.port}${DEFAULT_WS_PATH}`,
|
|
183
|
+
workflowWatchWsUrl: `${webSocketProtocol}://${displayHost}:${this.port}${DEFAULT_WORKFLOW_WATCH_WS_PATH}`,
|
|
184
|
+
mcpUrl: `${protocol}://${displayHost}:${this.port}${DEFAULT_MCP_PATH}`,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private getPorts() {
|
|
189
|
+
if (this.requestedPort !== undefined) {
|
|
190
|
+
return [this.requestedPort]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return Array.from({ length: this.portMax - DEFAULT_PORT + 1 }, (_, index) => DEFAULT_PORT + index)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private createRuntime() {
|
|
197
|
+
const requestHandler = (request: IncomingMessage, response: ServerResponse) => {
|
|
198
|
+
void this.handleRequest(request, response)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const server = this.tls
|
|
202
|
+
? createHttpsServer(
|
|
203
|
+
{
|
|
204
|
+
key: readFileSync(this.tls.key),
|
|
205
|
+
cert: readFileSync(this.tls.cert),
|
|
206
|
+
},
|
|
207
|
+
requestHandler,
|
|
208
|
+
)
|
|
209
|
+
: createHttpServer(requestHandler)
|
|
210
|
+
|
|
211
|
+
const webSocketServer = new WebSocketServer({ noServer: true })
|
|
212
|
+
|
|
213
|
+
server.on('upgrade', (request, socket, head) => {
|
|
214
|
+
const requestUrl = new URL(request.url || '/', this.getOrigin(request))
|
|
215
|
+
if (requestUrl.pathname === DEFAULT_WORKFLOW_WATCH_WS_PATH) {
|
|
216
|
+
try {
|
|
217
|
+
this.assertAuthorized(request)
|
|
218
|
+
} catch (error) {
|
|
219
|
+
rejectUpgrade(socket, toBridgeError(error))
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
webSocketServer.handleUpgrade(request, socket, head, (client) => this.attachWorkflowWatcher(client))
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (requestUrl.pathname !== DEFAULT_WS_PATH) {
|
|
228
|
+
socket.destroy()
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
webSocketServer.handleUpgrade(request, socket, head, (client) => {
|
|
233
|
+
this.bridge.attachExtension(client, request.socket.remoteAddress)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
return { server, webSocketServer }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async handleRequest(request: IncomingMessage, response: ServerResponse) {
|
|
241
|
+
try {
|
|
242
|
+
const requestUrl = new URL(request.url || '/', this.getOrigin(request))
|
|
243
|
+
const method = request.method || 'GET'
|
|
244
|
+
|
|
245
|
+
if (!this.applyCors(request, response)) {
|
|
246
|
+
this.sendJson(
|
|
247
|
+
response,
|
|
248
|
+
403,
|
|
249
|
+
safeError(
|
|
250
|
+
new BridgeError('CorsOriginNotAllowed', 'Cross-origin requests from this Origin are not allowed', {
|
|
251
|
+
status: 403,
|
|
252
|
+
}),
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (method === 'OPTIONS') {
|
|
259
|
+
response.statusCode = 204
|
|
260
|
+
response.end()
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (requestUrl.pathname === DEFAULT_MCP_PATH) {
|
|
265
|
+
if (isLoopbackHost(this.host) && !this.validateLocalMcpHost(request, response)) {
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
this.assertAuthorized(request)
|
|
270
|
+
await this.mcpNodeHandler(request, response)
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (method === 'GET' && requestUrl.pathname === '/v1/health') {
|
|
275
|
+
this.sendJson(response, 200, {
|
|
276
|
+
ok: true,
|
|
277
|
+
service: 'tapicker-bridge',
|
|
278
|
+
version: VERSION,
|
|
279
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
280
|
+
bridgeId: this.bridgeId,
|
|
281
|
+
})
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (method === 'GET' && requestUrl.pathname === '/v1/discovery') {
|
|
286
|
+
this.sendJson(response, 200, {
|
|
287
|
+
service: 'tapicker-bridge',
|
|
288
|
+
version: VERSION,
|
|
289
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
290
|
+
bridgeId: this.bridgeId,
|
|
291
|
+
wsPath: DEFAULT_WS_PATH,
|
|
292
|
+
workflowWatchPath: DEFAULT_WORKFLOW_WATCH_WS_PATH,
|
|
293
|
+
requiresAuthentication: Boolean(this.token),
|
|
294
|
+
})
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (method === 'GET' && requestUrl.pathname === '/v1/capabilities') {
|
|
299
|
+
this.sendJson(response, 200, {
|
|
300
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
301
|
+
commands: getCommandCapabilities(),
|
|
302
|
+
})
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (method === 'GET' && requestUrl.pathname === '/v1/extensions') {
|
|
307
|
+
this.assertAuthorized(request)
|
|
308
|
+
this.sendJson(response, 200, {
|
|
309
|
+
extensions: this.bridge.getExtensionStatuses(),
|
|
310
|
+
})
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (requestUrl.pathname === DEFAULT_WORKFLOW_WATCH_WS_PATH) {
|
|
315
|
+
this.assertAuthorized(request)
|
|
316
|
+
this.sendJson(
|
|
317
|
+
response,
|
|
318
|
+
405,
|
|
319
|
+
safeError(
|
|
320
|
+
new BridgeError('WebSocketRequired', 'workflow.watch requires a WebSocket connection', { status: 405 }),
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (method === 'POST' && requestUrl.pathname.startsWith('/v1/commands/')) {
|
|
327
|
+
this.assertAuthorized(request)
|
|
328
|
+
const commandName = decodeURIComponent(requestUrl.pathname.slice('/v1/commands/'.length))
|
|
329
|
+
const body = await readJson(request)
|
|
330
|
+
const data = await dispatchCommand(commandName, body, { bridge: this.bridge })
|
|
331
|
+
this.sendJson(response, 200, safeSuccess(data))
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
this.sendJson(response, 404, safeError(new BridgeError('NotFound', 'Route not found', { status: 404 })))
|
|
336
|
+
} catch (error) {
|
|
337
|
+
const bridgeError = toBridgeError(error)
|
|
338
|
+
this.sendJson(response, bridgeError.status, safeError(bridgeError))
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private assertAuthorized(request: IncomingMessage) {
|
|
343
|
+
if (!this.token) {
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const authorization = request.headers.authorization
|
|
348
|
+
const provided = authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined
|
|
349
|
+
|
|
350
|
+
if (!provided || !constantTimeEqual(provided, this.token)) {
|
|
351
|
+
throw new BridgeError('Unauthorized', 'Bridge authentication is required', { status: 401 })
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private attachWorkflowWatcher(socket: WebSocket) {
|
|
356
|
+
const remove = () => this.workflowSubscriptions.delete(socket)
|
|
357
|
+
socket.on('close', remove)
|
|
358
|
+
socket.on('error', remove)
|
|
359
|
+
socket.on('message', (raw) => {
|
|
360
|
+
const parsedEnvelope = parseEnvelope(raw)
|
|
361
|
+
if (!parsedEnvelope.success) {
|
|
362
|
+
socket.close(1007, 'Invalid Workflow watch message')
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const request = parsedEnvelope.data
|
|
367
|
+
if (request.type !== 'workflow.watch') {
|
|
368
|
+
this.sendWorkflowWatchResult(
|
|
369
|
+
socket,
|
|
370
|
+
request.id,
|
|
371
|
+
safeError(
|
|
372
|
+
new BridgeError('UnsupportedMessage', 'Only workflow.watch is supported on this socket', {
|
|
373
|
+
status: 400,
|
|
374
|
+
}),
|
|
375
|
+
),
|
|
376
|
+
)
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const parsedRequest = WorkflowWatchRequestSchema.safeParse(request.data)
|
|
381
|
+
if (!parsedRequest.success) {
|
|
382
|
+
this.sendWorkflowWatchResult(
|
|
383
|
+
socket,
|
|
384
|
+
request.id,
|
|
385
|
+
safeError(
|
|
386
|
+
new BridgeError('InvalidArguments', 'Invalid Workflow watch arguments', {
|
|
387
|
+
status: 400,
|
|
388
|
+
details: parsedRequest.error.issues,
|
|
389
|
+
}),
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (this.workflowSubscriptions.has(socket)) {
|
|
396
|
+
this.sendWorkflowWatchResult(
|
|
397
|
+
socket,
|
|
398
|
+
request.id,
|
|
399
|
+
safeError(
|
|
400
|
+
new BridgeError('WorkflowWatchAlreadyStarted', 'This socket is already watching a Workflow', {
|
|
401
|
+
status: 409,
|
|
402
|
+
}),
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const watchRequest = parsedRequest.data
|
|
409
|
+
try {
|
|
410
|
+
const clientId = this.bridge.resolveCommandClientId('workflow.status', watchRequest.clientId)
|
|
411
|
+
const subscription = { clientId, workflowId: watchRequest.input.workflowId }
|
|
412
|
+
this.workflowSubscriptions.set(socket, subscription)
|
|
413
|
+
this.sendWorkflowWatchResult(socket, request.id, safeSuccess(subscription))
|
|
414
|
+
} catch (error) {
|
|
415
|
+
this.sendWorkflowWatchResult(socket, request.id, safeError(error))
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private sendWorkflowWatchResult(socket: WebSocket, id: string, data: unknown) {
|
|
421
|
+
if (socket.readyState === socket.OPEN) {
|
|
422
|
+
try {
|
|
423
|
+
socket.send(JSON.stringify(createEnvelope('workflow.watch:res', data, { id, callback: false })))
|
|
424
|
+
} catch {
|
|
425
|
+
this.workflowSubscriptions.delete(socket)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private publishWorkflowStatus(event: BridgeWorkflowStatusEvent) {
|
|
431
|
+
const envelope = JSON.stringify(createEnvelope('workflow.status', event, { callback: false }))
|
|
432
|
+
for (const [socket, subscription] of this.workflowSubscriptions) {
|
|
433
|
+
if (socket.readyState === socket.OPEN && subscriptionKey(subscription) === subscriptionKey(event)) {
|
|
434
|
+
try {
|
|
435
|
+
socket.send(envelope)
|
|
436
|
+
} catch {
|
|
437
|
+
this.workflowSubscriptions.delete(socket)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private getOrigin(request: IncomingMessage) {
|
|
444
|
+
const host = request.headers.host || `${this.host}:${this.port || DEFAULT_PORT}`
|
|
445
|
+
return `${this.tls ? 'https' : 'http'}://${host}`
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private applyCors(request: IncomingMessage, response: ServerResponse) {
|
|
449
|
+
const origin = request.headers.origin
|
|
450
|
+
if (!origin) return true
|
|
451
|
+
|
|
452
|
+
const allowsAnyOrigin = this.corsOrigins.includes('*')
|
|
453
|
+
let allowedOrigin: string
|
|
454
|
+
if (allowsAnyOrigin) {
|
|
455
|
+
allowedOrigin = '*'
|
|
456
|
+
} else {
|
|
457
|
+
try {
|
|
458
|
+
allowedOrigin = normalizeCorsOrigin(origin)
|
|
459
|
+
} catch {
|
|
460
|
+
return false
|
|
461
|
+
}
|
|
462
|
+
if (!this.corsOrigins.includes(allowedOrigin)) return false
|
|
463
|
+
appendVaryOrigin(response)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
response.setHeader('Access-Control-Allow-Origin', allowedOrigin)
|
|
467
|
+
response.setHeader('Access-Control-Allow-Methods', CORS_ALLOWED_METHODS)
|
|
468
|
+
response.setHeader('Access-Control-Allow-Headers', CORS_ALLOWED_HEADERS)
|
|
469
|
+
response.setHeader('Access-Control-Expose-Headers', CORS_EXPOSED_HEADERS)
|
|
470
|
+
response.setHeader('Access-Control-Max-Age', '600')
|
|
471
|
+
return true
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private sendJson(response: ServerResponse, status: number, body: unknown) {
|
|
475
|
+
const payload = JSON.stringify(body)
|
|
476
|
+
response.statusCode = status
|
|
477
|
+
response.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
478
|
+
response.setHeader('Content-Length', Buffer.byteLength(payload))
|
|
479
|
+
response.end(payload)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function normalizeCorsOrigins(origins: string[]) {
|
|
484
|
+
const normalized = [...new Set(origins.map(normalizeCorsOrigin))]
|
|
485
|
+
return normalized.includes('*') ? ['*'] : normalized
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function normalizeCorsOrigin(origin: string) {
|
|
489
|
+
const value = origin.trim()
|
|
490
|
+
if (value === '*') return value
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
const url = new URL(value)
|
|
494
|
+
if (!url.protocol || !url.host || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
|
495
|
+
throw new Error('Expected an origin without a path, query, or fragment')
|
|
496
|
+
}
|
|
497
|
+
return url.origin === 'null' ? `${url.protocol}//${url.host}` : url.origin
|
|
498
|
+
} catch (error) {
|
|
499
|
+
throw new BridgeError('InvalidCorsOrigin', `Invalid CORS origin: ${origin}`, {
|
|
500
|
+
status: 400,
|
|
501
|
+
cause: error,
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function appendVaryOrigin(response: ServerResponse) {
|
|
507
|
+
const existing = response.getHeader('Vary')
|
|
508
|
+
const values = (Array.isArray(existing) ? existing.join(',') : existing?.toString() || '')
|
|
509
|
+
.split(',')
|
|
510
|
+
.map((value) => value.trim())
|
|
511
|
+
.filter(Boolean)
|
|
512
|
+
if (!values.some((value) => value.toLowerCase() === 'origin')) {
|
|
513
|
+
values.push('Origin')
|
|
514
|
+
}
|
|
515
|
+
response.setHeader('Vary', values.join(', '))
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function subscriptionKey(subscription: WorkflowSubscription | BridgeWorkflowStatusEvent) {
|
|
519
|
+
return `${subscription.clientId}\u0000${subscription.workflowId}`
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function rejectUpgrade(socket: import('node:stream').Duplex, error: BridgeError) {
|
|
523
|
+
const body = JSON.stringify(safeError(error))
|
|
524
|
+
const statusMessage = error.status === 401 ? 'Unauthorized' : 'Forbidden'
|
|
525
|
+
socket.write(
|
|
526
|
+
`HTTP/1.1 ${error.status} ${statusMessage}\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`,
|
|
527
|
+
)
|
|
528
|
+
socket.destroy()
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function listen(server: NodeServer, host: string, port: number) {
|
|
532
|
+
return new Promise<number>((resolve, reject) => {
|
|
533
|
+
const onError = (error: Error) => {
|
|
534
|
+
server.off('listening', onListening)
|
|
535
|
+
reject(error)
|
|
536
|
+
}
|
|
537
|
+
const onListening = () => {
|
|
538
|
+
server.off('error', onError)
|
|
539
|
+
const address = server.address()
|
|
540
|
+
if (!address || typeof address === 'string') {
|
|
541
|
+
reject(new BridgeError('InvalidServerAddress', 'Cannot read the Bridge server address', { status: 500 }))
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
resolve(address.port)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
server.once('error', onError)
|
|
548
|
+
server.once('listening', onListening)
|
|
549
|
+
server.listen(port, host)
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function closeServer(server: NodeServer | undefined) {
|
|
554
|
+
if (!server || !server.listening) {
|
|
555
|
+
return Promise.resolve()
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return new Promise<void>((resolve) => {
|
|
559
|
+
server.close(() => resolve())
|
|
560
|
+
})
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function closeWebSocketServer(server: WebSocketServer | undefined) {
|
|
564
|
+
if (!server) {
|
|
565
|
+
return Promise.resolve()
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return new Promise<void>((resolve) => {
|
|
569
|
+
server.close(() => resolve())
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function isAddressInUseError(error: unknown): error is NodeJS.ErrnoException {
|
|
574
|
+
return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'EADDRINUSE')
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function isLoopbackHost(host: string) {
|
|
578
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1'
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function constantTimeEqual(left: string, right: string) {
|
|
582
|
+
const leftBuffer = Buffer.from(left)
|
|
583
|
+
const rightBuffer = Buffer.from(right)
|
|
584
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
585
|
+
return false
|
|
586
|
+
}
|
|
587
|
+
return timingSafeEqual(leftBuffer, rightBuffer)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function readJson(request: IncomingMessage) {
|
|
591
|
+
const body = await readBody(request)
|
|
592
|
+
|
|
593
|
+
try {
|
|
594
|
+
return JSON.parse(body) as unknown
|
|
595
|
+
} catch (error) {
|
|
596
|
+
throw new BridgeError('InvalidJson', 'Request body is not valid JSON', {
|
|
597
|
+
status: 400,
|
|
598
|
+
cause: error,
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function readBody(request: IncomingMessage) {
|
|
604
|
+
const maxBytes = 1_048_576
|
|
605
|
+
|
|
606
|
+
return new Promise<string>((resolve, reject) => {
|
|
607
|
+
const chunks: Buffer[] = []
|
|
608
|
+
let totalBytes = 0
|
|
609
|
+
|
|
610
|
+
request.on('data', (chunk: Buffer | string) => {
|
|
611
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
612
|
+
totalBytes += buffer.length
|
|
613
|
+
if (totalBytes > maxBytes) {
|
|
614
|
+
reject(new BridgeError('RequestTooLarge', 'Request body exceeds 1 MiB', { status: 413 }))
|
|
615
|
+
request.destroy()
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
chunks.push(buffer)
|
|
619
|
+
})
|
|
620
|
+
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
621
|
+
request.on('error', reject)
|
|
622
|
+
})
|
|
623
|
+
}
|