@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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/bridge/bridge-service.d.ts +46 -0
  4. package/dist/bridge/bridge-service.js +245 -0
  5. package/dist/bridge/control.d.ts +7 -0
  6. package/dist/bridge/control.js +211 -0
  7. package/dist/bridge/daemon.d.ts +16 -0
  8. package/dist/bridge/daemon.js +97 -0
  9. package/dist/bridge/extension-session.d.ts +43 -0
  10. package/dist/bridge/extension-session.js +204 -0
  11. package/dist/bridge/pairing-state.d.ts +22 -0
  12. package/dist/bridge/pairing-state.js +76 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.js +835 -0
  15. package/dist/commands.d.ts +33 -0
  16. package/dist/commands.js +123 -0
  17. package/dist/constants.d.ts +10 -0
  18. package/dist/constants.js +10 -0
  19. package/dist/errors.d.ts +27 -0
  20. package/dist/errors.js +52 -0
  21. package/dist/index.d.ts +10 -0
  22. package/dist/index.js +8 -0
  23. package/dist/mcp.d.ts +13 -0
  24. package/dist/mcp.js +300 -0
  25. package/dist/protocol/schema.d.ts +149 -0
  26. package/dist/protocol/schema.js +155 -0
  27. package/dist/server.d.ts +63 -0
  28. package/dist/server.js +486 -0
  29. package/dist/workflow-watch.d.ts +31 -0
  30. package/dist/workflow-watch.js +304 -0
  31. package/docs/cli.md +112 -0
  32. package/docs/http-api.md +149 -0
  33. package/docs/mcp.md +90 -0
  34. package/docs/operations.md +85 -0
  35. package/docs/workflow-watch.md +86 -0
  36. package/package.json +55 -0
  37. package/src/bridge/bridge-service.ts +291 -0
  38. package/src/bridge/control.ts +281 -0
  39. package/src/bridge/daemon.ts +102 -0
  40. package/src/bridge/extension-session.ts +286 -0
  41. package/src/bridge/pairing-state.ts +92 -0
  42. package/src/cli.ts +1019 -0
  43. package/src/commands.ts +158 -0
  44. package/src/constants.ts +10 -0
  45. package/src/errors.ts +72 -0
  46. package/src/index.ts +35 -0
  47. package/src/mcp.ts +386 -0
  48. package/src/protocol/schema.ts +202 -0
  49. package/src/server.ts +623 -0
  50. 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
+ }