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