@tapicker/bridge 1.0.0 → 1.1.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 (49) hide show
  1. package/README.md +15 -15
  2. package/dist/bridge/bridge-service.d.ts +1 -1
  3. package/dist/bridge/bridge-service.js +7 -7
  4. package/dist/bridge/control.js +1 -1
  5. package/dist/bridge/export-store.d.ts +35 -0
  6. package/dist/bridge/export-store.js +195 -0
  7. package/dist/bridge/extension-session.d.ts +9 -2
  8. package/dist/bridge/extension-session.js +56 -8
  9. package/dist/cli.js +563 -148
  10. package/dist/commands.d.ts +8 -0
  11. package/dist/commands.js +276 -40
  12. package/dist/constants.d.ts +2 -2
  13. package/dist/constants.js +4 -2
  14. package/dist/http-client.d.ts +24 -0
  15. package/dist/http-client.js +101 -0
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +2 -2
  18. package/dist/mcp.d.ts +4 -3
  19. package/dist/mcp.js +221 -134
  20. package/dist/protocol/schema.d.ts +503 -34
  21. package/dist/protocol/schema.js +396 -15
  22. package/dist/server.d.ts +7 -4
  23. package/dist/server.js +70 -26
  24. package/dist/update-notifier-worker.d.ts +2 -0
  25. package/dist/update-notifier-worker.js +3 -0
  26. package/dist/update-notifier.d.ts +9 -0
  27. package/dist/update-notifier.js +178 -0
  28. package/dist/workflow-watch.d.ts +8 -7
  29. package/dist/workflow-watch.js +39 -29
  30. package/package.json +16 -6
  31. package/docs/cli.md +0 -112
  32. package/docs/http-api.md +0 -149
  33. package/docs/mcp.md +0 -90
  34. package/docs/operations.md +0 -85
  35. package/docs/workflow-watch.md +0 -86
  36. package/src/bridge/bridge-service.ts +0 -291
  37. package/src/bridge/control.ts +0 -281
  38. package/src/bridge/daemon.ts +0 -102
  39. package/src/bridge/extension-session.ts +0 -286
  40. package/src/bridge/pairing-state.ts +0 -92
  41. package/src/cli.ts +0 -1019
  42. package/src/commands.ts +0 -158
  43. package/src/constants.ts +0 -10
  44. package/src/errors.ts +0 -72
  45. package/src/index.ts +0 -35
  46. package/src/mcp.ts +0 -386
  47. package/src/protocol/schema.ts +0 -202
  48. package/src/server.ts +0 -623
  49. package/src/workflow-watch.ts +0 -364
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Tapicker Bridge
2
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.
3
+ Tapicker Bridge connects external tools and AI agents to a Tapicker browser Extension. Use it to read Workflow guides and schemas, validate nodes, create and update Workflows, run and control Workflows, access extracted data, or receive Workflow status updates through the CLI, HTTP, or MCP.
4
4
 
5
5
  The Bridge routes commands; browser automation runs in the connected Tapicker Extension.
6
6
 
@@ -8,8 +8,15 @@ The Bridge routes commands; browser automation runs in the connected Tapicker Ex
8
8
 
9
9
  **Requirements:** Node.js 22 or later and the Tapicker Extension installed in a supported browser.
10
10
 
11
+ Install the CLI globally:
12
+
11
13
  ```bash
12
14
  npm install --global @tapicker/bridge
15
+ ```
16
+
17
+ Start Bridge in the foreground:
18
+
19
+ ```bash
13
20
  tapicker bridge start
14
21
  ```
15
22
 
@@ -21,23 +28,16 @@ Copy a Workflow ID in Tapicker and run it:
21
28
  tapicker workflow start <workflowId> --watch
22
29
  ```
23
30
 
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.
31
+ 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 address with `--url http://127.0.0.1:<port>` to business CLI commands, or set `TAPICKER_BRIDGE_URL`.
25
32
 
26
- ## Learn the User Flows
33
+ ## Documentation
27
34
 
35
+ - [Overview](https://www.tapicker.com/docs/bridge-overview) — understand how Bridge connects external tools to Tapicker.
28
36
  - [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)
37
+ - [CLI Commands](https://www.tapicker.com/docs/bridge-cli-commands) — manage Bridge and use Workflow/data commands.
38
+ - [HTTP API](https://www.tapicker.com/docs/bridge-http-api) — call the API and subscribe to status events.
39
+ - [MCP Integration](https://www.tapicker.com/docs/bridge-mcp-integration) — configure an agent and author Workflows.
40
+ - [Remote Deployment](https://www.tapicker.com/docs/bridge-remote-deployment) — configure TLS, authentication, and remote clients.
41
41
 
42
42
  ## Security
43
43
 
@@ -27,7 +27,7 @@ export declare class BridgeService extends EventEmitter {
27
27
  initialize(): Promise<void>;
28
28
  attachExtension(socket: WebSocket, remoteAddress?: string): ExtensionSession;
29
29
  sendCommand<T>(type: string, data: unknown, clientId?: string): Promise<T>;
30
- sendCommandWithClientId<T>(type: string, data: unknown, clientId?: string): Promise<{
30
+ sendCommandWithClientId<T>(type: string, data: unknown, clientId?: string, timeout?: number): Promise<{
31
31
  clientId: string;
32
32
  result: Awaited<T>;
33
33
  }>;
@@ -49,10 +49,10 @@ export class BridgeService extends EventEmitter {
49
49
  });
50
50
  session.on('message', (message) => {
51
51
  const status = session.getStatus();
52
- if (status.ready && status.clientId && message.type === 'workflow.status') {
52
+ if (status.ready && status.clientId && message.type === 'workflow.status.changed') {
53
53
  const parsed = WorkflowStatusDataSchema.safeParse(message.data);
54
54
  if (parsed.success) {
55
- this.emit('workflow.status', {
55
+ this.emit('workflow.status.changed', {
56
56
  clientId: status.clientId,
57
57
  ...parsed.data,
58
58
  });
@@ -76,7 +76,7 @@ export class BridgeService extends EventEmitter {
76
76
  const session = this.selectCommandSession(type, clientId);
77
77
  return session.request(type, data);
78
78
  }
79
- async sendCommandWithClientId(type, data, clientId) {
79
+ async sendCommandWithClientId(type, data, clientId, timeout) {
80
80
  const session = this.selectCommandSession(type, clientId);
81
81
  const selectedClientId = session.getStatus().clientId;
82
82
  if (!selectedClientId) {
@@ -84,7 +84,7 @@ export class BridgeService extends EventEmitter {
84
84
  }
85
85
  return {
86
86
  clientId: selectedClientId,
87
- result: await session.request(type, data),
87
+ result: await session.request(type, data, timeout),
88
88
  };
89
89
  }
90
90
  assertExtensionCapability(type, clientId) {
@@ -100,13 +100,13 @@ export class BridgeService extends EventEmitter {
100
100
  selectCommandSession(type, clientId) {
101
101
  const readySessions = [...this.sessionsByClientId.entries()].filter(([, session]) => {
102
102
  const status = session.getStatus();
103
- return status.connected && status.ready;
103
+ return session.isConnected() && status.ready;
104
104
  });
105
105
  let session;
106
106
  if (clientId) {
107
107
  session = this.sessionsByClientId.get(clientId);
108
108
  const status = session?.getStatus();
109
- if (!status?.connected || !status.ready) {
109
+ if (!session?.isConnected() || !status?.ready) {
110
110
  throw new BridgeError('ExtensionNotConnected', `Tapicker Extension ${clientId} is not connected`, {
111
111
  status: 409,
112
112
  details: { clientId },
@@ -144,8 +144,8 @@ export class BridgeService extends EventEmitter {
144
144
  }
145
145
  getExtensionStatuses() {
146
146
  return [...this.sessions]
147
+ .filter((session) => session.isConnected())
147
148
  .map((session) => session.getStatus())
148
- .filter((status) => status.connected)
149
149
  .sort((left, right) => (left.clientName || '').localeCompare(right.clientName || ''));
150
150
  }
151
151
  getPairingCode() {
@@ -135,7 +135,7 @@ function handleControlSocket(socket, handle) {
135
135
  sendControlError(socket, new BridgeError('InvalidBridgeControlRequest', 'Unsupported Bridge control action', { status: 400 }));
136
136
  return;
137
137
  }
138
- void handle(action)
138
+ handle(action)
139
139
  .then((data) => socket.end(`${JSON.stringify({ ok: true, data })}\n`))
140
140
  .catch((error) => sendControlError(socket, error));
141
141
  });
@@ -0,0 +1,35 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { DataExportResult } from '../protocol/schema.js';
3
+ /**
4
+ * 暂存文件并在导出确认后发布;回执与文件一起保留至过期。
5
+ */
6
+ export declare class ExportStore {
7
+ private readonly directory;
8
+ private readonly pending;
9
+ private readonly downloads;
10
+ constructor(directory: string);
11
+ create(generate: (upload: {
12
+ exportId: string;
13
+ uploadToken: string;
14
+ maxBytes: number;
15
+ }) => Promise<Omit<DataExportResult, 'fileSize' | 'downloadPath' | 'expiresAt'>>): Promise<{
16
+ tableId: string;
17
+ tableName: string;
18
+ clientId: string;
19
+ format: "xlsx" | "xls" | "csv" | "json" | "xml" | "txt";
20
+ filename: string;
21
+ rowCount: number;
22
+ byteSize: number;
23
+ fileSize: string;
24
+ downloadPath: string;
25
+ expiresAt: string;
26
+ }>;
27
+ upload(exportId: string, request: IncomingMessage): Promise<void>;
28
+ download(exportId: string, response: ServerResponse): Promise<void>;
29
+ cleanup(): Promise<void>;
30
+ private usedBytes;
31
+ private listIds;
32
+ private fileStat;
33
+ private readReceipt;
34
+ }
35
+ //# sourceMappingURL=export-store.d.ts.map
@@ -0,0 +1,195 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { mkdir, open, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { Transform } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { BridgeError } from '../errors.js';
8
+ import { DataExportResultSchema } from '../protocol/schema.js';
9
+ const EXPORT_TTL = 24 * 60 * 60_000;
10
+ const UPLOAD_TTL = 30 * 60_000;
11
+ const MAX_FILE_BYTES = 128 * 1024 * 1024;
12
+ const MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
13
+ const EXPORT_ID = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/;
14
+ const StoredExportReceiptSchema = DataExportResultSchema.partial({ fileSize: true });
15
+ /**
16
+ * 暂存文件并在导出确认后发布;回执与文件一起保留至过期。
17
+ */
18
+ export class ExportStore {
19
+ directory;
20
+ pending = new Map();
21
+ downloads = new Map();
22
+ constructor(directory) {
23
+ this.directory = directory;
24
+ }
25
+ async create(generate) {
26
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
27
+ await this.cleanup();
28
+ const exportId = randomUUID();
29
+ const pending = { token: randomUUID() };
30
+ this.pending.set(exportId, pending);
31
+ const dir = join(this.directory, exportId);
32
+ try {
33
+ // 每个在途导出预留单文件上限,避免多个上传同时超过总容量。
34
+ const reserved = new Set(this.pending.keys());
35
+ if ((await this.usedBytes(reserved)) + reserved.size * MAX_FILE_BYTES > MAX_TOTAL_BYTES) {
36
+ throw new BridgeError('ExportStorageFull', 'The export directory is full. Try again after exports expire.', {
37
+ status: 507,
38
+ });
39
+ }
40
+ await mkdir(dir, { mode: 0o700 });
41
+ const receipt = await generate({ exportId, uploadToken: pending.token, maxBytes: MAX_FILE_BYTES });
42
+ if (this.pending.get(exportId)?.bytes !== receipt.byteSize) {
43
+ throw new BridgeError('InvalidExtensionResponse', 'The export file was not uploaded completely', {
44
+ status: 502,
45
+ });
46
+ }
47
+ const result = DataExportResultSchema.parse({
48
+ ...receipt,
49
+ fileSize: formatExportFileSize(receipt.byteSize),
50
+ downloadPath: `/v1/exports/${exportId}`,
51
+ expiresAt: new Date(Date.now() + EXPORT_TTL).toISOString(),
52
+ });
53
+ await rename(join(dir, 'file.part'), join(dir, 'file'));
54
+ await writeFile(join(dir, 'receipt.part'), JSON.stringify(result), { mode: 0o600 });
55
+ await rename(join(dir, 'receipt.part'), join(dir, 'receipt.json'));
56
+ return result;
57
+ }
58
+ finally {
59
+ this.pending.delete(exportId);
60
+ // 上传失败或进程中断留下的文件统一按过期时间回收。
61
+ }
62
+ }
63
+ async upload(exportId, request) {
64
+ const pending = this.pending.get(exportId);
65
+ if (!pending || request.headers.authorization !== `Bearer ${pending.token}`) {
66
+ throw new BridgeError('Unauthorized', 'Invalid export upload credentials', { status: 401 });
67
+ }
68
+ if (pending.isUploading || pending.bytes !== undefined) {
69
+ throw new BridgeError('ExportAlreadyUploaded', 'This export already has an upload', { status: 409 });
70
+ }
71
+ pending.isUploading = true;
72
+ let bytes = 0;
73
+ let oversized = false;
74
+ const limiter = new Transform({
75
+ transform(chunk, _encoding, callback) {
76
+ bytes += chunk.length;
77
+ if (bytes > MAX_FILE_BYTES) {
78
+ oversized = true;
79
+ callback(new BridgeError('ExportTooLarge', 'The export file exceeds the size limit', { status: 413 }));
80
+ }
81
+ else
82
+ callback(null, chunk);
83
+ },
84
+ });
85
+ if (Number(request.headers['content-length']) > MAX_FILE_BYTES) {
86
+ request.resume();
87
+ throw new BridgeError('ExportTooLarge', 'The export file exceeds the size limit', { status: 413 });
88
+ }
89
+ try {
90
+ // 单独传输文件字节,避免整个请求进入内存。
91
+ await pipeline(request, limiter, createWriteStream(join(this.directory, exportId, 'file.part'), {
92
+ flags: 'wx',
93
+ mode: 0o600,
94
+ }));
95
+ pending.bytes = bytes;
96
+ }
97
+ catch (error) {
98
+ if (oversized)
99
+ throw new BridgeError('ExportTooLarge', 'The export file exceeds the size limit', { status: 413 });
100
+ throw error;
101
+ }
102
+ }
103
+ async download(exportId, response) {
104
+ const dir = join(this.directory, exportId);
105
+ const receipt = await this.readReceipt(dir);
106
+ if (!receipt || Date.parse(receipt.expiresAt) <= Date.now()) {
107
+ throw new BridgeError('ExportNotFound', 'The export is unavailable or has expired', { status: 404 });
108
+ }
109
+ this.downloads.set(exportId, (this.downloads.get(exportId) ?? 0) + 1);
110
+ try {
111
+ const file = await open(join(dir, 'file'), 'r').catch((error) => {
112
+ if (error.code === 'ENOENT')
113
+ throw new BridgeError('ExportNotFound', 'The export is unavailable', { status: 404 });
114
+ throw error;
115
+ });
116
+ const filename = encodeURIComponent(Buffer.from(receipt.filename).toString('utf8')).replace(/['()*]/g, (char) => '%' + char.charCodeAt(0).toString(16).toUpperCase());
117
+ response.setHeader('Content-Type', 'application/octet-stream');
118
+ response.setHeader('Content-Length', receipt.byteSize);
119
+ response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${filename}`);
120
+ response.setHeader('Cache-Control', 'no-store');
121
+ await pipeline(file.createReadStream(), response);
122
+ }
123
+ finally {
124
+ const count = this.downloads.get(exportId) ?? 1;
125
+ if (count > 1)
126
+ this.downloads.set(exportId, count - 1);
127
+ else
128
+ this.downloads.delete(exportId);
129
+ }
130
+ }
131
+ async cleanup() {
132
+ for (const id of await this.listIds()) {
133
+ if (this.pending.has(id) || this.downloads.has(id))
134
+ continue;
135
+ const dir = join(this.directory, id);
136
+ const receipt = await this.readReceipt(dir);
137
+ const expiresAt = receipt ? Date.parse(receipt.expiresAt) : (await this.fileStat(dir))?.mtimeMs;
138
+ if (expiresAt !== undefined && (receipt ? expiresAt : expiresAt + UPLOAD_TTL) <= Date.now()) {
139
+ await rm(dir, { recursive: true, force: true });
140
+ }
141
+ }
142
+ }
143
+ async usedBytes(reserved) {
144
+ let bytes = 0;
145
+ for (const id of await this.listIds()) {
146
+ if (reserved.has(id))
147
+ continue;
148
+ const dir = join(this.directory, id);
149
+ bytes +=
150
+ (await this.fileStat(join(dir, 'file')))?.size ?? (await this.fileStat(join(dir, 'file.part')))?.size ?? 0;
151
+ }
152
+ return bytes;
153
+ }
154
+ async listIds() {
155
+ const entries = await readdir(this.directory, { withFileTypes: true }).catch((error) => {
156
+ if (error.code === 'ENOENT')
157
+ return [];
158
+ throw error;
159
+ });
160
+ return entries.filter((entry) => entry.isDirectory() && EXPORT_ID.test(entry.name)).map((entry) => entry.name);
161
+ }
162
+ async fileStat(path) {
163
+ return stat(path).catch((error) => {
164
+ if (error.code === 'ENOENT')
165
+ return undefined;
166
+ throw error;
167
+ });
168
+ }
169
+ async readReceipt(dir) {
170
+ const raw = await readFile(join(dir, 'receipt.json'), 'utf8').catch((error) => {
171
+ if (error.code === 'ENOENT')
172
+ return undefined;
173
+ throw error;
174
+ });
175
+ if (raw === undefined)
176
+ return undefined;
177
+ try {
178
+ const result = StoredExportReceiptSchema.safeParse(JSON.parse(raw));
179
+ return result.success ? result.data : undefined;
180
+ }
181
+ catch {
182
+ return undefined;
183
+ }
184
+ }
185
+ }
186
+ function formatExportFileSize(bytes) {
187
+ if (bytes < 1024)
188
+ return `${bytes}B`;
189
+ if (bytes < 1024 ** 2)
190
+ return `${Number((bytes / 1024).toFixed(1))}K`;
191
+ if (bytes < 1024 ** 3)
192
+ return `${Number((bytes / 1024 ** 2).toFixed(1))}M`;
193
+ return `${Number((bytes / 1024 ** 3).toFixed(1))}G`;
194
+ }
195
+ //# sourceMappingURL=export-store.js.map
@@ -1,12 +1,13 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import type { WebSocket } from 'ws';
3
- import type { ExtensionPairInput } from '../protocol/schema.js';
3
+ import type { ExtensionHelloInput, ExtensionPairInput } from '../protocol/schema.js';
4
4
  export type ExtensionSessionStatus = {
5
- connected: boolean;
6
5
  ready: boolean;
7
6
  pairingRequired?: boolean;
8
7
  clientId?: string;
9
8
  clientName?: string;
9
+ clientVersion?: string;
10
+ user?: ExtensionHelloInput['user'];
10
11
  capabilities: string[];
11
12
  };
12
13
  export type ExtensionSessionOptions = {
@@ -24,10 +25,16 @@ export declare class ExtensionSession extends EventEmitter {
24
25
  private pairingRequired;
25
26
  private clientId;
26
27
  private clientName;
28
+ private clientVersion;
29
+ private user;
27
30
  private capabilities;
28
31
  private pendingRequests;
29
32
  constructor(socket: WebSocket, options: ExtensionSessionOptions);
30
33
  start(): void;
34
+ /**
35
+ * 判断底层连接是否仍可用,供会话筛选和命令发送使用。
36
+ */
37
+ isConnected(): boolean;
31
38
  getStatus(): ExtensionSessionStatus;
32
39
  request<T>(type: string, data: unknown, timeout?: number): Promise<T>;
33
40
  close(code?: number, reason?: string): void;
@@ -1,7 +1,9 @@
1
+ import { z } from 'zod';
1
2
  import { EventEmitter } from 'node:events';
2
3
  import { DEFAULT_REQUEST_TIMEOUT } from '../constants.js';
3
4
  import { BridgeError, safeError, safeSuccess } from '../errors.js';
4
5
  import { BridgeHelloDataSchema, BridgePairDataSchema, ExtensionHelloInputSchema, ExtensionPairInputSchema, SafeResultSchema, createEnvelope, parseEnvelope, } from '../protocol/schema.js';
6
+ const UpstreamErrorDetailsSchema = z.looseObject({ upstreamStatus: z.number().int().min(400).max(599) });
5
7
  export class ExtensionSession extends EventEmitter {
6
8
  socket;
7
9
  options;
@@ -9,6 +11,8 @@ export class ExtensionSession extends EventEmitter {
9
11
  pairingRequired = false;
10
12
  clientId;
11
13
  clientName;
14
+ clientVersion;
15
+ user;
12
16
  capabilities = [];
13
17
  pendingRequests = new Map();
14
18
  constructor(socket, options) {
@@ -17,25 +21,34 @@ export class ExtensionSession extends EventEmitter {
17
21
  this.options = options;
18
22
  }
19
23
  start() {
20
- this.socket.on('message', (data) => void this.handleMessage(data).catch((error) => this.reportError(error)));
24
+ this.socket.on('message', (data) => {
25
+ this.handleMessage(data).catch((error) => this.reportError(error));
26
+ });
21
27
  this.socket.on('error', (error) => this.reportError(error));
22
28
  this.socket.on('close', (code, reason) => {
23
29
  this.rejectPending(new BridgeError('ExtensionDisconnected', 'Tapicker Extension disconnected', { status: 503 }));
24
30
  this.emit('close', { code, reason: reason.toString() });
25
31
  });
26
32
  }
33
+ /**
34
+ * 判断底层连接是否仍可用,供会话筛选和命令发送使用。
35
+ */
36
+ isConnected() {
37
+ return this.socket.readyState === this.socket.OPEN;
38
+ }
27
39
  getStatus() {
28
40
  return {
29
- connected: this.socket.readyState === this.socket.OPEN,
30
41
  ready: this.ready,
31
42
  pairingRequired: this.pairingRequired,
32
43
  clientId: this.clientId,
33
44
  clientName: this.clientName,
45
+ clientVersion: this.clientVersion,
46
+ user: this.ready && !this.pairingRequired ? this.user : undefined,
34
47
  capabilities: this.ready ? this.capabilities : [],
35
48
  };
36
49
  }
37
50
  request(type, data, timeout = DEFAULT_REQUEST_TIMEOUT) {
38
- if (!this.ready || this.socket.readyState !== this.socket.OPEN) {
51
+ if (!this.ready || !this.isConnected()) {
39
52
  return Promise.reject(new BridgeError('ExtensionUnavailable', 'No paired Tapicker Extension is connected', { status: 503 }));
40
53
  }
41
54
  const envelope = createEnvelope(type, data);
@@ -90,9 +103,42 @@ export class ExtensionSession extends EventEmitter {
90
103
  pending.resolve(result.data.data);
91
104
  }
92
105
  else {
93
- pending.reject(new BridgeError(result.data.error.code, result.data.error.message, {
94
- status: 502,
95
- details: result.data.error.details,
106
+ const { error } = result.data;
107
+ const upstream = UpstreamErrorDetailsSchema.safeParse(error.details);
108
+ if (upstream.success) {
109
+ const { upstreamStatus, ...details } = upstream.data;
110
+ pending.reject(new BridgeError(error.code, error.message, {
111
+ status: upstreamStatus === 401 ? 403 : upstreamStatus,
112
+ details: Object.keys(details).length > 0 ? details : undefined,
113
+ }));
114
+ return;
115
+ }
116
+ const code = ['InvalidArgument', 'InvalidBridgeMessage'].includes(error.code) ? 'InvalidArguments' : error.code;
117
+ const statuses = {
118
+ InvalidArguments: 400,
119
+ PermissionDenied: 403,
120
+ SignInRequired: 403,
121
+ EmailNotVerified: 403,
122
+ AccountBanned: 403,
123
+ ExportQuotaExceeded: 403,
124
+ DataResultTooLarge: 413,
125
+ ExportTooLarge: 413,
126
+ WorkflowNotFound: 404,
127
+ NotFound: 404,
128
+ WorkflowConflict: 409,
129
+ DataTableInUse: 409,
130
+ WorkflowRunChanged: 409,
131
+ WorkflowStartInProgress: 409,
132
+ WorkflowAlreadyRunning: 409,
133
+ WorkflowCleanupInProgress: 409,
134
+ ProcessStopped: 409,
135
+ NetworkError: 502,
136
+ RequestTimeout: 504,
137
+ WorkflowStartTimeout: 504,
138
+ };
139
+ pending.reject(new BridgeError(code, error.message, {
140
+ status: Object.hasOwn(statuses, code) ? statuses[code] : 502,
141
+ details: error.details,
96
142
  }));
97
143
  }
98
144
  return;
@@ -123,6 +169,8 @@ export class ExtensionSession extends EventEmitter {
123
169
  const hello = parsed.data;
124
170
  this.clientId = hello.clientId;
125
171
  this.clientName = hello.clientName;
172
+ this.clientVersion = hello.clientVersion;
173
+ this.user = hello.user;
126
174
  this.capabilities = hello.capabilities;
127
175
  const accepted = Boolean(hello.credential && this.options.verifyCredential(hello.clientId, hello.credential));
128
176
  this.pairingRequired = !accepted;
@@ -147,7 +195,7 @@ export class ExtensionSession extends EventEmitter {
147
195
  return;
148
196
  }
149
197
  const input = parsed.data;
150
- if (!this.clientId || input.clientId !== this.clientId || input.clientName !== this.clientName) {
198
+ if (!this.clientId || input.clientId !== this.clientId) {
151
199
  this.respond(message, `${message.type}:res`, safeError(new BridgeError('PairingSessionMismatch', 'Pairing request does not match the Extension hello', {
152
200
  status: 400,
153
201
  })));
@@ -183,7 +231,7 @@ export class ExtensionSession extends EventEmitter {
183
231
  this.send(createEnvelope(type, data, { id: request.id, callback: false }));
184
232
  }
185
233
  send(envelope) {
186
- if (this.socket.readyState !== this.socket.OPEN) {
234
+ if (!this.isConnected()) {
187
235
  throw new BridgeError('ExtensionUnavailable', 'Tapicker Extension WebSocket is not open', { status: 503 });
188
236
  }
189
237
  this.socket.send(JSON.stringify(envelope));