@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tapicker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Tapicker Bridge
|
|
2
|
+
|
|
3
|
+
Tapicker Bridge connects external tools and AI agents to a Tapicker browser Extension. Use it to run and control Workflows, access extracted data, or receive Workflow status updates through the CLI, HTTP, or MCP.
|
|
4
|
+
|
|
5
|
+
The Bridge routes commands; browser automation runs in the connected Tapicker Extension.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
**Requirements:** Node.js 22 or later and the Tapicker Extension installed in a supported browser.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install --global @tapicker/bridge
|
|
13
|
+
tapicker bridge start
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Keep the terminal open. Bridge prints a WebSocket endpoint and a four-digit pairing code. In the Extension, open **Settings > General**, enable **Bridge**, then click **Scan** or enter the endpoint and click **Connect**. Enter the pairing code when prompted.
|
|
17
|
+
|
|
18
|
+
Copy a Workflow ID in Tapicker and run it:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
tapicker workflow start <workflowId> --watch
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Bridge listens on `127.0.0.1` by default and tries ports `9520`–`9530`. If it selects a port other than `9520`, pass the actual port with `--port` to CLI commands.
|
|
25
|
+
|
|
26
|
+
## Learn the User Flows
|
|
27
|
+
|
|
28
|
+
- [Getting Started](https://www.tapicker.com/docs/bridge-getting-started) — install Bridge, pair an Extension, and run your first Workflow.
|
|
29
|
+
- [Control Workflows](https://www.tapicker.com/docs/bridge-workflows) — start, pause, resume, stop, and watch Workflows.
|
|
30
|
+
- [Connect an AI Agent](https://www.tapicker.com/docs/bridge-ai-agents) — use MCP or the HTTP API.
|
|
31
|
+
- [Remote Deployment](https://www.tapicker.com/docs/bridge-remote-deployment) — configure TLS and a Bearer Token.
|
|
32
|
+
- [API Reference](https://www.tapicker.com/docs/bridge-reference) — CLI commands, HTTP endpoints, MCP tools, and status events.
|
|
33
|
+
|
|
34
|
+
## Documentation in This Package
|
|
35
|
+
|
|
36
|
+
- [CLI reference](docs/cli.md)
|
|
37
|
+
- [HTTP API reference](docs/http-api.md)
|
|
38
|
+
- [MCP setup and tools](docs/mcp.md)
|
|
39
|
+
- [Pairing, configuration, and security](docs/operations.md)
|
|
40
|
+
- [Workflow status WebSocket](docs/workflow-watch.md)
|
|
41
|
+
|
|
42
|
+
## Security
|
|
43
|
+
|
|
44
|
+
Keep Bridge on its default loopback address unless remote access is required. Local mode has no Bearer Token by default, and HTTP CORS defaults to `*`; restrict `--cors-origin` when browser pages should not access the local API. Non-loopback binding requires TLS and a Bearer Token.
|
|
45
|
+
|
|
46
|
+
Bridge does not manage Tapicker accounts. See the [remote deployment guide](https://www.tapicker.com/docs/bridge-remote-deployment) before exposing it to a network.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { WebSocket } from 'ws';
|
|
3
|
+
import type { WorkflowStatusData } from '../protocol/schema.js';
|
|
4
|
+
import { ExtensionSession } from './extension-session.js';
|
|
5
|
+
import type { ExtensionSessionStatus } from './extension-session.js';
|
|
6
|
+
import type { PairingStatePersistence } from './pairing-state.js';
|
|
7
|
+
export type BridgeServiceOptions = {
|
|
8
|
+
pairingCode: string;
|
|
9
|
+
capabilities: string[];
|
|
10
|
+
pairingState?: PairingStatePersistence;
|
|
11
|
+
};
|
|
12
|
+
export type BridgeWorkflowStatusEvent = WorkflowStatusData & {
|
|
13
|
+
clientId: string;
|
|
14
|
+
};
|
|
15
|
+
export declare class BridgeService extends EventEmitter {
|
|
16
|
+
private readonly options;
|
|
17
|
+
private readonly sessions;
|
|
18
|
+
private readonly sessionsByClientId;
|
|
19
|
+
private readonly pairedCredentials;
|
|
20
|
+
private readonly pairingAttempts;
|
|
21
|
+
private pairingCode;
|
|
22
|
+
private pairingFailures;
|
|
23
|
+
private pairingLocked;
|
|
24
|
+
private pairingInProgress;
|
|
25
|
+
private initialization;
|
|
26
|
+
constructor(options: BridgeServiceOptions);
|
|
27
|
+
initialize(): Promise<void>;
|
|
28
|
+
attachExtension(socket: WebSocket, remoteAddress?: string): ExtensionSession;
|
|
29
|
+
sendCommand<T>(type: string, data: unknown, clientId?: string): Promise<T>;
|
|
30
|
+
sendCommandWithClientId<T>(type: string, data: unknown, clientId?: string): Promise<{
|
|
31
|
+
clientId: string;
|
|
32
|
+
result: Awaited<T>;
|
|
33
|
+
}>;
|
|
34
|
+
assertExtensionCapability(type: string, clientId: string): void;
|
|
35
|
+
resolveCommandClientId(type: string, clientId?: string): string;
|
|
36
|
+
private selectCommandSession;
|
|
37
|
+
getExtensionStatuses(): ExtensionSessionStatus[];
|
|
38
|
+
getPairingCode(): string | undefined;
|
|
39
|
+
refreshPairingCode(): string;
|
|
40
|
+
close(): void;
|
|
41
|
+
private verifyCredential;
|
|
42
|
+
private pair;
|
|
43
|
+
private rotatePairingCode;
|
|
44
|
+
private restorePairingState;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=bridge-service.d.ts.map
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { randomInt, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { BridgeError } from '../errors.js';
|
|
4
|
+
import { WorkflowStatusDataSchema } from '../protocol/schema.js';
|
|
5
|
+
import { VERSION, PROTOCOL_VERSION } from '../constants.js';
|
|
6
|
+
import { ExtensionSession } from './extension-session.js';
|
|
7
|
+
const PAIRING_WINDOW_MS = 60_000;
|
|
8
|
+
const MAX_PAIRING_ATTEMPTS_PER_IP = 5;
|
|
9
|
+
const MAX_PAIRING_FAILURES_PER_CODE = 5;
|
|
10
|
+
export class BridgeService extends EventEmitter {
|
|
11
|
+
options;
|
|
12
|
+
sessions = new Set();
|
|
13
|
+
sessionsByClientId = new Map();
|
|
14
|
+
pairedCredentials = new Map();
|
|
15
|
+
pairingAttempts = new Map();
|
|
16
|
+
pairingCode;
|
|
17
|
+
pairingFailures = 0;
|
|
18
|
+
pairingLocked = false;
|
|
19
|
+
pairingInProgress = false;
|
|
20
|
+
initialization;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
super();
|
|
23
|
+
this.options = options;
|
|
24
|
+
this.pairingCode = options.pairingCode;
|
|
25
|
+
}
|
|
26
|
+
initialize() {
|
|
27
|
+
if (!this.initialization) {
|
|
28
|
+
this.initialization = this.restorePairingState();
|
|
29
|
+
}
|
|
30
|
+
return this.initialization;
|
|
31
|
+
}
|
|
32
|
+
attachExtension(socket, remoteAddress = 'unknown') {
|
|
33
|
+
const session = new ExtensionSession(socket, {
|
|
34
|
+
bridgeVersion: VERSION,
|
|
35
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
36
|
+
capabilities: this.options.capabilities,
|
|
37
|
+
remoteAddress,
|
|
38
|
+
verifyCredential: (clientId, credential) => this.verifyCredential(clientId, credential),
|
|
39
|
+
pair: (input, address) => this.pair(input, address),
|
|
40
|
+
});
|
|
41
|
+
this.sessions.add(session);
|
|
42
|
+
session.on('ready', (hello) => {
|
|
43
|
+
const previousSession = this.sessionsByClientId.get(hello.clientId);
|
|
44
|
+
this.sessionsByClientId.set(hello.clientId, session);
|
|
45
|
+
if (previousSession && previousSession !== session) {
|
|
46
|
+
previousSession.close(1000, 'Replaced by a newer connection for this Extension');
|
|
47
|
+
}
|
|
48
|
+
this.emit('extension.ready', hello);
|
|
49
|
+
});
|
|
50
|
+
session.on('message', (message) => {
|
|
51
|
+
const status = session.getStatus();
|
|
52
|
+
if (status.ready && status.clientId && message.type === 'workflow.status') {
|
|
53
|
+
const parsed = WorkflowStatusDataSchema.safeParse(message.data);
|
|
54
|
+
if (parsed.success) {
|
|
55
|
+
this.emit('workflow.status', {
|
|
56
|
+
clientId: status.clientId,
|
|
57
|
+
...parsed.data,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
this.emit('extension.message', message);
|
|
62
|
+
});
|
|
63
|
+
session.on('error', (error) => this.emit('extension.error', error));
|
|
64
|
+
session.on('close', (details) => {
|
|
65
|
+
this.sessions.delete(session);
|
|
66
|
+
const clientId = session.getStatus().clientId;
|
|
67
|
+
if (clientId && this.sessionsByClientId.get(clientId) === session) {
|
|
68
|
+
this.sessionsByClientId.delete(clientId);
|
|
69
|
+
}
|
|
70
|
+
this.emit('extension.close', details);
|
|
71
|
+
});
|
|
72
|
+
session.start();
|
|
73
|
+
return session;
|
|
74
|
+
}
|
|
75
|
+
async sendCommand(type, data, clientId) {
|
|
76
|
+
const session = this.selectCommandSession(type, clientId);
|
|
77
|
+
return session.request(type, data);
|
|
78
|
+
}
|
|
79
|
+
async sendCommandWithClientId(type, data, clientId) {
|
|
80
|
+
const session = this.selectCommandSession(type, clientId);
|
|
81
|
+
const selectedClientId = session.getStatus().clientId;
|
|
82
|
+
if (!selectedClientId) {
|
|
83
|
+
throw new BridgeError('ExtensionUnavailable', 'The selected Tapicker Extension has no client ID', { status: 503 });
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
clientId: selectedClientId,
|
|
87
|
+
result: await session.request(type, data),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
assertExtensionCapability(type, clientId) {
|
|
91
|
+
this.selectCommandSession(type, clientId);
|
|
92
|
+
}
|
|
93
|
+
resolveCommandClientId(type, clientId) {
|
|
94
|
+
const selectedClientId = this.selectCommandSession(type, clientId).getStatus().clientId;
|
|
95
|
+
if (!selectedClientId) {
|
|
96
|
+
throw new BridgeError('ExtensionUnavailable', 'The selected Tapicker Extension has no client ID', { status: 503 });
|
|
97
|
+
}
|
|
98
|
+
return selectedClientId;
|
|
99
|
+
}
|
|
100
|
+
selectCommandSession(type, clientId) {
|
|
101
|
+
const readySessions = [...this.sessionsByClientId.entries()].filter(([, session]) => {
|
|
102
|
+
const status = session.getStatus();
|
|
103
|
+
return status.connected && status.ready;
|
|
104
|
+
});
|
|
105
|
+
let session;
|
|
106
|
+
if (clientId) {
|
|
107
|
+
session = this.sessionsByClientId.get(clientId);
|
|
108
|
+
const status = session?.getStatus();
|
|
109
|
+
if (!status?.connected || !status.ready) {
|
|
110
|
+
throw new BridgeError('ExtensionNotConnected', `Tapicker Extension ${clientId} is not connected`, {
|
|
111
|
+
status: 409,
|
|
112
|
+
details: { clientId },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (readySessions.length === 0) {
|
|
117
|
+
throw new BridgeError('ExtensionUnavailable', 'No paired Tapicker Extension is connected', { status: 503 });
|
|
118
|
+
}
|
|
119
|
+
else if (readySessions.length > 1) {
|
|
120
|
+
throw new BridgeError('ClientIdRequired', 'Specify clientId when multiple Extensions are connected', {
|
|
121
|
+
status: 409,
|
|
122
|
+
details: {
|
|
123
|
+
extensions: readySessions.map(([clientId, readySession]) => ({
|
|
124
|
+
clientId,
|
|
125
|
+
clientName: readySession.getStatus().clientName,
|
|
126
|
+
})),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
session = readySessions[0]?.[1];
|
|
132
|
+
}
|
|
133
|
+
if (!session) {
|
|
134
|
+
throw new BridgeError('ExtensionUnavailable', 'No paired Tapicker Extension is connected', { status: 503 });
|
|
135
|
+
}
|
|
136
|
+
const status = session.getStatus();
|
|
137
|
+
if (!status.capabilities.includes(type)) {
|
|
138
|
+
throw new BridgeError('ExtensionCapabilityUnavailable', `Tapicker Extension does not support ${type}`, {
|
|
139
|
+
status: 409,
|
|
140
|
+
details: { clientId: status.clientId, clientName: status.clientName, command: type },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return session;
|
|
144
|
+
}
|
|
145
|
+
getExtensionStatuses() {
|
|
146
|
+
return [...this.sessions]
|
|
147
|
+
.map((session) => session.getStatus())
|
|
148
|
+
.filter((status) => status.connected)
|
|
149
|
+
.sort((left, right) => (left.clientName || '').localeCompare(right.clientName || ''));
|
|
150
|
+
}
|
|
151
|
+
getPairingCode() {
|
|
152
|
+
return this.pairingLocked ? undefined : this.pairingCode;
|
|
153
|
+
}
|
|
154
|
+
refreshPairingCode() {
|
|
155
|
+
if (this.pairingInProgress) {
|
|
156
|
+
throw new BridgeError('PairingUnavailable', 'An Extension is currently being paired; try again shortly', {
|
|
157
|
+
status: 409,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
this.rotatePairingCode();
|
|
161
|
+
return this.pairingCode;
|
|
162
|
+
}
|
|
163
|
+
close() {
|
|
164
|
+
for (const session of this.sessions) {
|
|
165
|
+
session.close(1000, 'Bridge shutting down');
|
|
166
|
+
}
|
|
167
|
+
this.sessions.clear();
|
|
168
|
+
this.sessionsByClientId.clear();
|
|
169
|
+
}
|
|
170
|
+
verifyCredential(clientId, credential) {
|
|
171
|
+
const savedCredential = this.pairedCredentials.get(clientId);
|
|
172
|
+
if (!savedCredential)
|
|
173
|
+
return false;
|
|
174
|
+
return constantTimeEqual(credential, savedCredential);
|
|
175
|
+
}
|
|
176
|
+
async pair(input, remoteAddress) {
|
|
177
|
+
if (this.pairingLocked) {
|
|
178
|
+
throw new BridgeError('PairingRateLimited', 'Pairing is locked after too many incorrect codes; run tapicker bridge code --refresh locally', { status: 429, details: { refreshCommand: 'tapicker bridge code --refresh' } });
|
|
179
|
+
}
|
|
180
|
+
if (this.pairingInProgress) {
|
|
181
|
+
throw new BridgeError('PairingUnavailable', 'Another Extension is currently being paired', { status: 409 });
|
|
182
|
+
}
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
for (const [address, attemptWindow] of this.pairingAttempts) {
|
|
185
|
+
if (now - attemptWindow.startedAt >= PAIRING_WINDOW_MS) {
|
|
186
|
+
this.pairingAttempts.delete(address);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const current = this.pairingAttempts.get(remoteAddress);
|
|
190
|
+
const window = !current || now - current.startedAt >= PAIRING_WINDOW_MS ? { startedAt: now, attempts: 0 } : current;
|
|
191
|
+
if (window.attempts >= MAX_PAIRING_ATTEMPTS_PER_IP) {
|
|
192
|
+
throw new BridgeError('PairingRateLimited', 'Too many pairing attempts; try again in one minute', {
|
|
193
|
+
status: 429,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
window.attempts += 1;
|
|
197
|
+
this.pairingAttempts.set(remoteAddress, window);
|
|
198
|
+
if (!constantTimeEqual(input.pairingCode, this.pairingCode)) {
|
|
199
|
+
this.pairingFailures += 1;
|
|
200
|
+
if (this.pairingFailures >= MAX_PAIRING_FAILURES_PER_CODE) {
|
|
201
|
+
this.pairingLocked = true;
|
|
202
|
+
throw new BridgeError('PairingRateLimited', 'Pairing is locked after too many incorrect codes; run tapicker bridge code --refresh locally', { status: 429, details: { refreshCommand: 'tapicker bridge code --refresh' } });
|
|
203
|
+
}
|
|
204
|
+
throw new BridgeError('InvalidPairingCode', 'The pairing code is incorrect', { status: 401 });
|
|
205
|
+
}
|
|
206
|
+
this.pairingInProgress = true;
|
|
207
|
+
try {
|
|
208
|
+
const credential = randomUUID();
|
|
209
|
+
const identities = [...this.pairedCredentials.entries()]
|
|
210
|
+
.filter(([clientId]) => clientId !== input.clientId)
|
|
211
|
+
.map(([clientId, savedCredential]) => ({ clientId, credential: savedCredential }));
|
|
212
|
+
identities.push({ clientId: input.clientId, credential });
|
|
213
|
+
await this.options.pairingState?.save(identities);
|
|
214
|
+
this.pairedCredentials.set(input.clientId, credential);
|
|
215
|
+
this.rotatePairingCode();
|
|
216
|
+
return credential;
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
this.pairingInProgress = false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
rotatePairingCode() {
|
|
223
|
+
const previousCode = this.pairingCode;
|
|
224
|
+
do {
|
|
225
|
+
this.pairingCode = String(randomInt(0, 10_000)).padStart(4, '0');
|
|
226
|
+
} while (this.pairingCode === previousCode);
|
|
227
|
+
this.pairingFailures = 0;
|
|
228
|
+
this.pairingLocked = false;
|
|
229
|
+
this.pairingAttempts.clear();
|
|
230
|
+
}
|
|
231
|
+
async restorePairingState() {
|
|
232
|
+
const pairedIdentities = (await this.options.pairingState?.load()) ?? [];
|
|
233
|
+
for (const identity of pairedIdentities) {
|
|
234
|
+
this.pairedCredentials.set(identity.clientId, identity.credential);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function constantTimeEqual(left, right) {
|
|
239
|
+
const leftBuffer = Buffer.from(left);
|
|
240
|
+
const rightBuffer = Buffer.from(right);
|
|
241
|
+
if (leftBuffer.length !== rightBuffer.length)
|
|
242
|
+
return false;
|
|
243
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
244
|
+
}
|
|
245
|
+
//# sourceMappingURL=bridge-service.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type BridgeControlAction = 'code.get' | 'code.refresh' | 'extensions.list' | 'bridge.stop';
|
|
2
|
+
export declare function getBridgeControlPath(dataDir: string): string;
|
|
3
|
+
export declare function startBridgeControlServer(dataDir: string, handle: (action: BridgeControlAction) => Promise<unknown>): Promise<{
|
|
4
|
+
close(): Promise<void>;
|
|
5
|
+
}>;
|
|
6
|
+
export declare function sendBridgeControlRequest<T>(dataDir: string, action: BridgeControlAction): Promise<T>;
|
|
7
|
+
//# sourceMappingURL=control.d.ts.map
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, unlink } from 'node:fs/promises';
|
|
3
|
+
import { createConnection, createServer } from 'node:net';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { BridgeError, toBridgeError } from '../errors.js';
|
|
6
|
+
const CONTROL_ACTIONS = new Set(['code.get', 'code.refresh', 'extensions.list', 'bridge.stop']);
|
|
7
|
+
const MAX_CONTROL_MESSAGE_BYTES = 4096;
|
|
8
|
+
export function getBridgeControlPath(dataDir) {
|
|
9
|
+
const normalizedDataDir = resolve(dataDir);
|
|
10
|
+
if (process.platform === 'win32') {
|
|
11
|
+
const suffix = createHash('sha256').update(normalizedDataDir.toLowerCase()).digest('hex').slice(0, 24);
|
|
12
|
+
return `\\\\.\\pipe\\tapicker-bridge-${suffix}`;
|
|
13
|
+
}
|
|
14
|
+
return join(normalizedDataDir, 'bridge.sock');
|
|
15
|
+
}
|
|
16
|
+
export async function startBridgeControlServer(dataDir, handle) {
|
|
17
|
+
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
|
18
|
+
const endpoint = getBridgeControlPath(dataDir);
|
|
19
|
+
const server = createServer((socket) => handleControlSocket(socket, handle));
|
|
20
|
+
try {
|
|
21
|
+
await listenControlServer(server, endpoint);
|
|
22
|
+
if (process.platform !== 'win32') {
|
|
23
|
+
await chmod(endpoint, 0o600);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
await closeServer(server);
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
async close() {
|
|
32
|
+
await closeServer(server);
|
|
33
|
+
if (process.platform !== 'win32') {
|
|
34
|
+
await unlink(endpoint).catch((error) => {
|
|
35
|
+
if (error.code !== 'ENOENT')
|
|
36
|
+
throw error;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export async function sendBridgeControlRequest(dataDir, action) {
|
|
43
|
+
const endpoint = getBridgeControlPath(dataDir);
|
|
44
|
+
return new Promise((resolveResponse, rejectResponse) => {
|
|
45
|
+
const socket = createConnection(endpoint);
|
|
46
|
+
let buffer = '';
|
|
47
|
+
let settled = false;
|
|
48
|
+
const finish = (callback) => {
|
|
49
|
+
if (settled)
|
|
50
|
+
return;
|
|
51
|
+
settled = true;
|
|
52
|
+
clearTimeout(timeout);
|
|
53
|
+
socket.destroy();
|
|
54
|
+
callback();
|
|
55
|
+
};
|
|
56
|
+
const timeout = setTimeout(() => {
|
|
57
|
+
finish(() => rejectResponse(new BridgeError('BridgeControlTimeout', 'Timed out communicating with Tapicker Bridge', { status: 504 })));
|
|
58
|
+
}, 3000);
|
|
59
|
+
socket.setEncoding('utf8');
|
|
60
|
+
socket.once('connect', () => socket.write(`${JSON.stringify({ action })}\n`));
|
|
61
|
+
socket.on('data', (chunk) => {
|
|
62
|
+
buffer += chunk;
|
|
63
|
+
if (Buffer.byteLength(buffer) > MAX_CONTROL_MESSAGE_BYTES) {
|
|
64
|
+
finish(() => rejectResponse(new BridgeError('InvalidBridgeControlResponse', 'Bridge control response is too large')));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const newline = buffer.indexOf('\n');
|
|
68
|
+
if (newline < 0)
|
|
69
|
+
return;
|
|
70
|
+
let response;
|
|
71
|
+
try {
|
|
72
|
+
response = JSON.parse(buffer.slice(0, newline));
|
|
73
|
+
}
|
|
74
|
+
catch (cause) {
|
|
75
|
+
finish(() => rejectResponse(new BridgeError('InvalidBridgeControlResponse', 'Bridge returned an invalid control response', {
|
|
76
|
+
status: 502,
|
|
77
|
+
cause,
|
|
78
|
+
})));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!response || typeof response !== 'object') {
|
|
82
|
+
finish(() => rejectResponse(new BridgeError('InvalidBridgeControlResponse', 'Bridge returned an invalid control response', {
|
|
83
|
+
status: 502,
|
|
84
|
+
})));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const result = response;
|
|
88
|
+
if (result.ok === true) {
|
|
89
|
+
finish(() => resolveResponse(result.data));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const error = result.error;
|
|
93
|
+
finish(() => rejectResponse(new BridgeError(typeof error?.code === 'string' ? error.code : 'BridgeControlFailed', typeof error?.message === 'string' ? error.message : 'Bridge control request failed', { status: typeof error?.status === 'number' ? error.status : 500 })));
|
|
94
|
+
});
|
|
95
|
+
socket.once('error', (cause) => {
|
|
96
|
+
const code = cause.code;
|
|
97
|
+
const isUnavailable = code === 'ENOENT' || code === 'ECONNREFUSED' || code === 'EINVAL';
|
|
98
|
+
finish(() => rejectResponse(new BridgeError(isUnavailable ? 'BridgeControlUnavailable' : 'BridgeControlFailed', isUnavailable
|
|
99
|
+
? 'No Tapicker Bridge control service is available'
|
|
100
|
+
: 'Could not communicate with Tapicker Bridge', { status: isUnavailable ? 503 : 500, cause })));
|
|
101
|
+
});
|
|
102
|
+
socket.once('end', () => {
|
|
103
|
+
if (!settled) {
|
|
104
|
+
finish(() => rejectResponse(new BridgeError('InvalidBridgeControlResponse', 'Bridge closed the control connection without a response', {
|
|
105
|
+
status: 502,
|
|
106
|
+
})));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function handleControlSocket(socket, handle) {
|
|
112
|
+
let buffer = '';
|
|
113
|
+
socket.setEncoding('utf8');
|
|
114
|
+
socket.setTimeout(5000, () => socket.destroy());
|
|
115
|
+
socket.on('data', (chunk) => {
|
|
116
|
+
buffer += chunk;
|
|
117
|
+
if (Buffer.byteLength(buffer) > MAX_CONTROL_MESSAGE_BYTES) {
|
|
118
|
+
socket.destroy();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const newline = buffer.indexOf('\n');
|
|
122
|
+
if (newline < 0)
|
|
123
|
+
return;
|
|
124
|
+
socket.removeAllListeners('data');
|
|
125
|
+
let action;
|
|
126
|
+
try {
|
|
127
|
+
const message = JSON.parse(buffer.slice(0, newline));
|
|
128
|
+
action = message?.action;
|
|
129
|
+
}
|
|
130
|
+
catch (cause) {
|
|
131
|
+
sendControlError(socket, new BridgeError('InvalidBridgeControlRequest', 'Control request is not valid JSON', { status: 400, cause }));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (typeof action !== 'string' || !CONTROL_ACTIONS.has(action)) {
|
|
135
|
+
sendControlError(socket, new BridgeError('InvalidBridgeControlRequest', 'Unsupported Bridge control action', { status: 400 }));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
void handle(action)
|
|
139
|
+
.then((data) => socket.end(`${JSON.stringify({ ok: true, data })}\n`))
|
|
140
|
+
.catch((error) => sendControlError(socket, error));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
function sendControlError(socket, error) {
|
|
144
|
+
const bridgeError = toBridgeError(error);
|
|
145
|
+
socket.end(`${JSON.stringify({
|
|
146
|
+
ok: false,
|
|
147
|
+
error: {
|
|
148
|
+
code: bridgeError.code,
|
|
149
|
+
message: bridgeError.message,
|
|
150
|
+
status: bridgeError.status,
|
|
151
|
+
},
|
|
152
|
+
})}\n`);
|
|
153
|
+
}
|
|
154
|
+
function listenControlServer(server, endpoint) {
|
|
155
|
+
return listen(server, endpoint).catch(async (error) => {
|
|
156
|
+
if (error.code !== 'EADDRINUSE')
|
|
157
|
+
throw error;
|
|
158
|
+
if (await canConnect(endpoint)) {
|
|
159
|
+
throw new BridgeError('BridgeControlInUse', 'A Tapicker Bridge control service is already using this data directory', {
|
|
160
|
+
status: 409,
|
|
161
|
+
cause: error,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (process.platform === 'win32')
|
|
165
|
+
throw error;
|
|
166
|
+
await unlink(endpoint).catch((unlinkError) => {
|
|
167
|
+
if (unlinkError.code !== 'ENOENT')
|
|
168
|
+
throw unlinkError;
|
|
169
|
+
});
|
|
170
|
+
await listen(server, endpoint);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function listen(server, endpoint) {
|
|
174
|
+
return new Promise((resolveListen, rejectListen) => {
|
|
175
|
+
const onError = (error) => {
|
|
176
|
+
server.off('listening', onListening);
|
|
177
|
+
rejectListen(error);
|
|
178
|
+
};
|
|
179
|
+
const onListening = () => {
|
|
180
|
+
server.off('error', onError);
|
|
181
|
+
resolveListen();
|
|
182
|
+
};
|
|
183
|
+
server.once('error', onError);
|
|
184
|
+
server.once('listening', onListening);
|
|
185
|
+
server.listen(endpoint);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function canConnect(endpoint) {
|
|
189
|
+
return new Promise((resolveProbe) => {
|
|
190
|
+
const socket = createConnection(endpoint);
|
|
191
|
+
const timeout = setTimeout(() => {
|
|
192
|
+
socket.destroy();
|
|
193
|
+
resolveProbe(false);
|
|
194
|
+
}, 300);
|
|
195
|
+
socket.once('connect', () => {
|
|
196
|
+
clearTimeout(timeout);
|
|
197
|
+
socket.end();
|
|
198
|
+
resolveProbe(true);
|
|
199
|
+
});
|
|
200
|
+
socket.once('error', () => {
|
|
201
|
+
clearTimeout(timeout);
|
|
202
|
+
resolveProbe(false);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
function closeServer(server) {
|
|
207
|
+
if (!server.listening)
|
|
208
|
+
return Promise.resolve();
|
|
209
|
+
return new Promise((resolveClose) => server.close(() => resolveClose()));
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=control.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
declare const BridgeDaemonStateSchema: z.ZodObject<{
|
|
3
|
+
pid: z.ZodNumber;
|
|
4
|
+
bridgeId: z.ZodUUID;
|
|
5
|
+
httpUrl: z.ZodURL;
|
|
6
|
+
startedAt: z.ZodNumber;
|
|
7
|
+
}, z.core.$strict>;
|
|
8
|
+
export type BridgeDaemonState = z.infer<typeof BridgeDaemonStateSchema>;
|
|
9
|
+
export declare function getBridgeDaemonStatePath(dataDir: string): string;
|
|
10
|
+
export declare function getBridgeDaemonLogPath(dataDir: string): string;
|
|
11
|
+
export declare function readBridgeDaemonState(dataDir: string): Promise<BridgeDaemonState | undefined>;
|
|
12
|
+
export declare function writeBridgeDaemonState(dataDir: string, state: BridgeDaemonState): Promise<void>;
|
|
13
|
+
export declare function removeBridgeDaemonState(dataDir: string, expectedPid: number): Promise<void>;
|
|
14
|
+
export declare function isProcessRunning(pid: number): boolean;
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=daemon.d.ts.map
|