@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.
- package/README.md +15 -15
- package/dist/bridge/bridge-service.d.ts +1 -1
- package/dist/bridge/bridge-service.js +7 -7
- package/dist/bridge/control.js +1 -1
- package/dist/bridge/export-store.d.ts +35 -0
- package/dist/bridge/export-store.js +195 -0
- package/dist/bridge/extension-session.d.ts +9 -2
- package/dist/bridge/extension-session.js +56 -8
- package/dist/cli.js +563 -148
- package/dist/commands.d.ts +8 -0
- package/dist/commands.js +276 -40
- package/dist/constants.d.ts +2 -2
- package/dist/constants.js +4 -2
- package/dist/http-client.d.ts +24 -0
- package/dist/http-client.js +101 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/mcp.d.ts +4 -3
- package/dist/mcp.js +221 -134
- package/dist/protocol/schema.d.ts +503 -34
- package/dist/protocol/schema.js +396 -15
- package/dist/server.d.ts +7 -4
- package/dist/server.js +70 -26
- package/dist/update-notifier-worker.d.ts +2 -0
- package/dist/update-notifier-worker.js +3 -0
- package/dist/update-notifier.d.ts +9 -0
- package/dist/update-notifier.js +178 -0
- package/dist/workflow-watch.d.ts +8 -7
- package/dist/workflow-watch.js +39 -29
- package/package.json +16 -6
- package/docs/cli.md +0 -112
- package/docs/http-api.md +0 -149
- package/docs/mcp.md +0 -90
- package/docs/operations.md +0 -85
- package/docs/workflow-watch.md +0 -86
- package/src/bridge/bridge-service.ts +0 -291
- package/src/bridge/control.ts +0 -281
- package/src/bridge/daemon.ts +0 -102
- package/src/bridge/extension-session.ts +0 -286
- package/src/bridge/pairing-state.ts +0 -92
- package/src/cli.ts +0 -1019
- package/src/commands.ts +0 -158
- package/src/constants.ts +0 -10
- package/src/errors.ts +0 -72
- package/src/index.ts +0 -35
- package/src/mcp.ts +0 -386
- package/src/protocol/schema.ts +0 -202
- package/src/server.ts +0 -623
- 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
|
|
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
|
-
##
|
|
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
|
-
- [
|
|
30
|
-
- [
|
|
31
|
-
- [
|
|
32
|
-
- [
|
|
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
|
|
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 (!
|
|
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() {
|
package/dist/bridge/control.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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.
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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.
|
|
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));
|