@gecho-ai/gecho-bridge 1.0.1 → 1.1.2
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 +40 -0
- package/index.js +4 -4
- package/mcp-client.js +79 -34
- package/package.json +2 -2
- package/server.js +107 -9
package/README.md
CHANGED
|
@@ -42,12 +42,52 @@ Add a new MCP server:
|
|
|
42
42
|
- **Type**: command
|
|
43
43
|
- **Command**: `npx -y @gecho-ai/gecho-bridge@latest`
|
|
44
44
|
|
|
45
|
+
### Save Path Configuration
|
|
46
|
+
Search results are persisted to a JSON file. You can control the save directory in two ways:
|
|
47
|
+
|
|
48
|
+
1. Pass `save_dir` in the MCP tool call arguments.
|
|
49
|
+
2. Set environment variable `GECHO_DATA_DIR` for the MCP process.
|
|
50
|
+
|
|
51
|
+
Priority order used by the service:
|
|
52
|
+
|
|
53
|
+
1. `save_dir` from request arguments
|
|
54
|
+
2. `GECHO_DATA_DIR` environment variable
|
|
55
|
+
3. Default `data` directory (`path.join(__dirname, "..", "data")`)
|
|
56
|
+
|
|
57
|
+
File naming:
|
|
58
|
+
|
|
59
|
+
- Result file pattern: `<query>_search_results.json`
|
|
60
|
+
- Query text is sanitized to a safe file name before writing.
|
|
61
|
+
|
|
62
|
+
Example (Claude Desktop with `GECHO_DATA_DIR`):
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"mcpServers": {
|
|
67
|
+
"gecho-bridge": {
|
|
68
|
+
"command": "npx",
|
|
69
|
+
"args": ["-y", "@gecho-ai/gecho-bridge@latest"],
|
|
70
|
+
"env": {
|
|
71
|
+
"GECHO_DATA_DIR": "/Users/yourname/gecho-data"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Example (Cursor command with env):
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
GECHO_DATA_DIR=/Users/yourname/gecho-data npx -y @gecho-ai/gecho-bridge@latest
|
|
82
|
+
```
|
|
83
|
+
|
|
45
84
|
## Usage
|
|
46
85
|
|
|
47
86
|
Once configured, you can ask your AI:
|
|
48
87
|
|
|
49
88
|
- "Search TikTok for 'cooking recipes'"
|
|
50
89
|
- "Find trending TikTok videos about AI agents"
|
|
90
|
+
- "Search TikTok for 'computer' and save to `/Users/yourname/data` (`save_dir`)"
|
|
51
91
|
|
|
52
92
|
## Development
|
|
53
93
|
|
package/index.js
CHANGED
|
@@ -148,9 +148,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
148
148
|
const timeoutId = setTimeout(() => {
|
|
149
149
|
if (pendingRequests.has(requestId)) {
|
|
150
150
|
pendingRequests.delete(requestId);
|
|
151
|
-
resolve({ error: "抓取超时 (
|
|
151
|
+
resolve({ error: "抓取超时 (300s),请检查浏览器是否已停止滚动" });
|
|
152
152
|
}
|
|
153
|
-
},
|
|
153
|
+
}, 300000);
|
|
154
154
|
|
|
155
155
|
pendingRequests.set(requestId, { resolve, reject, timeoutId });
|
|
156
156
|
|
|
@@ -243,8 +243,8 @@ async function run() {
|
|
|
243
243
|
const result = await new Promise((resolve) => {
|
|
244
244
|
const timeoutId = setTimeout(() => {
|
|
245
245
|
pendingRequests.delete(requestId);
|
|
246
|
-
resolve({ error: "抓取超时 (
|
|
247
|
-
},
|
|
246
|
+
resolve({ error: "抓取超时 (300s)" });
|
|
247
|
+
}, 300000);
|
|
248
248
|
|
|
249
249
|
pendingRequests.set(requestId, { resolve, timeoutId });
|
|
250
250
|
|
package/mcp-client.js
CHANGED
|
@@ -19,11 +19,14 @@ const http = require("http");
|
|
|
19
19
|
const { spawn } = require("child_process");
|
|
20
20
|
const path = require("path");
|
|
21
21
|
|
|
22
|
-
const
|
|
22
|
+
const SERVICE_BASE_URL = "http://127.0.0.1:18793";
|
|
23
|
+
const HTTP_SERVICE_URL = `${SERVICE_BASE_URL}/search`;
|
|
24
|
+
const PING_URL = `${SERVICE_BASE_URL}/ping`;
|
|
25
|
+
const SHUTDOWN_URL = `${SERVICE_BASE_URL}/shutdown`;
|
|
23
26
|
const SERVICE_PATH = path.join(__dirname, "server.js");
|
|
24
27
|
|
|
25
28
|
const server = new Server(
|
|
26
|
-
{ name: "tiktok-bridge-client", version: "1.1.
|
|
29
|
+
{ name: "tiktok-bridge-client", version: "1.1.1" },
|
|
27
30
|
{ capabilities: { tools: {} } }
|
|
28
31
|
);
|
|
29
32
|
|
|
@@ -32,7 +35,7 @@ const server = new Server(
|
|
|
32
35
|
*/
|
|
33
36
|
function checkServiceAlive() {
|
|
34
37
|
return new Promise((resolve) => {
|
|
35
|
-
const req = http.get(
|
|
38
|
+
const req = http.get(PING_URL, (res) => {
|
|
36
39
|
resolve(res.statusCode === 200);
|
|
37
40
|
});
|
|
38
41
|
req.on("error", () => resolve(false));
|
|
@@ -41,35 +44,65 @@ function checkServiceAlive() {
|
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
/**
|
|
44
|
-
*
|
|
47
|
+
* 请求旧服务优雅退出
|
|
45
48
|
*/
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
function requestShutdown() {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const req = http.request(SHUTDOWN_URL, { method: "POST" }, (res) => {
|
|
52
|
+
resolve(res.statusCode >= 200 && res.statusCode < 300);
|
|
53
|
+
});
|
|
54
|
+
req.on("error", () => resolve(false));
|
|
55
|
+
req.setTimeout(1000, () => {
|
|
56
|
+
req.destroy();
|
|
57
|
+
resolve(false);
|
|
58
|
+
});
|
|
59
|
+
req.end();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
52
62
|
|
|
53
|
-
|
|
54
|
-
|
|
63
|
+
function startServiceDetached() {
|
|
55
64
|
const child = spawn("node", [SERVICE_PATH], {
|
|
56
65
|
detached: true,
|
|
57
66
|
stdio: "ignore" // 静默启动,不占用当前终端
|
|
58
67
|
});
|
|
59
|
-
|
|
60
68
|
child.unref(); // 让子进程独立运行,父进程退出时不影响它
|
|
69
|
+
}
|
|
61
70
|
|
|
62
|
-
|
|
63
|
-
let retries =
|
|
71
|
+
async function waitForServiceDown() {
|
|
72
|
+
let retries = 10;
|
|
64
73
|
while (retries > 0) {
|
|
65
|
-
await new Promise(r => setTimeout(r,
|
|
74
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
75
|
+
if (!(await checkServiceAlive())) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
retries--;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function waitForServiceUp() {
|
|
84
|
+
let retries = 8;
|
|
85
|
+
while (retries > 0) {
|
|
86
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
66
87
|
if (await checkServiceAlive()) {
|
|
67
|
-
|
|
68
|
-
return;
|
|
88
|
+
return true;
|
|
69
89
|
}
|
|
70
90
|
retries--;
|
|
71
91
|
}
|
|
72
|
-
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 启动即接管:每次启动 MCP 时先尝试关闭旧服务,再拉起当前服务
|
|
97
|
+
*/
|
|
98
|
+
async function ensureServiceRunning() {
|
|
99
|
+
await requestShutdown();
|
|
100
|
+
await waitForServiceDown();
|
|
101
|
+
startServiceDetached();
|
|
102
|
+
const ready = await waitForServiceUp();
|
|
103
|
+
if (!ready) {
|
|
104
|
+
throw new Error("Failed to start Service Layer.");
|
|
105
|
+
}
|
|
73
106
|
}
|
|
74
107
|
|
|
75
108
|
// 1. 定义工具
|
|
@@ -82,7 +115,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
82
115
|
inputSchema: {
|
|
83
116
|
type: "object",
|
|
84
117
|
properties: {
|
|
85
|
-
query: { type: "string", description: "搜索关键词 (例如: '猫薄荷')" }
|
|
118
|
+
query: { type: "string", description: "搜索关键词 (例如: '猫薄荷')" },
|
|
119
|
+
save_dir: { type: "string", description: "可选的保存目录绝对路径 (例如: '/Users/xxx/data')" }
|
|
86
120
|
},
|
|
87
121
|
required: ["query"]
|
|
88
122
|
}
|
|
@@ -94,7 +128,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
94
128
|
// 2. 转发工具请求到 Service 层
|
|
95
129
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
96
130
|
if (request.params.name === "tiktok_search_top_200") {
|
|
97
|
-
const { query } = request.params.arguments;
|
|
131
|
+
const { query, save_dir } = request.params.arguments;
|
|
98
132
|
|
|
99
133
|
try {
|
|
100
134
|
const requestService = () => new Promise((resolve, reject) => {
|
|
@@ -114,40 +148,53 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
114
148
|
if (res.statusCode >= 400) {
|
|
115
149
|
reject(new Error(parsed.error || `HTTP ${res.statusCode}`));
|
|
116
150
|
} else {
|
|
117
|
-
resolve(parsed
|
|
151
|
+
resolve(parsed); // 返回整个响应对象
|
|
118
152
|
}
|
|
119
153
|
});
|
|
120
154
|
});
|
|
121
155
|
|
|
122
156
|
req.on("error", () => reject(new Error("Service Layer communication error")));
|
|
123
|
-
req.write(JSON.stringify({ query }));
|
|
157
|
+
req.write(JSON.stringify({ query, save_dir }));
|
|
124
158
|
req.end();
|
|
125
159
|
});
|
|
126
160
|
|
|
127
|
-
let
|
|
161
|
+
let serviceResponse;
|
|
128
162
|
try {
|
|
129
|
-
|
|
163
|
+
serviceResponse = await requestService();
|
|
130
164
|
} catch (firstError) {
|
|
131
165
|
if (String(firstError.message || "").includes("communication error")) {
|
|
132
166
|
await ensureServiceRunning();
|
|
133
167
|
await new Promise(r => setTimeout(r, 800));
|
|
134
|
-
|
|
168
|
+
serviceResponse = await requestService();
|
|
135
169
|
} else {
|
|
136
170
|
throw firstError;
|
|
137
171
|
}
|
|
138
172
|
}
|
|
139
173
|
|
|
140
|
-
if (
|
|
141
|
-
return { content: [{ type: "text", text: `❌ 错误: ${
|
|
174
|
+
if (serviceResponse.error) {
|
|
175
|
+
return { content: [{ type: "text", text: `❌ 错误: ${serviceResponse.error}` }], isError: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const result = serviceResponse.data;
|
|
179
|
+
if (typeof result === 'object' && result !== null && result.error) {
|
|
180
|
+
return { content: [{ type: "text", text: `❌ 抓取错误: ${result.error}` }], isError: true };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!Array.isArray(result)) {
|
|
184
|
+
return { content: [{ type: "text", text: `❌ 异常: 服务端未返回数组格式的数据` }], isError: true };
|
|
142
185
|
}
|
|
143
186
|
|
|
144
|
-
const
|
|
187
|
+
const savePath = serviceResponse.savePath || "";
|
|
188
|
+
const saveLine = savePath
|
|
189
|
+
? `📂 完整结果已保存到: ${savePath}\n\n`
|
|
190
|
+
: `📂 未返回保存路径,请检查服务端日志。\n\n`;
|
|
191
|
+
const top20 = result.slice(0, 20);
|
|
145
192
|
return {
|
|
146
193
|
content: [
|
|
147
194
|
{
|
|
148
195
|
type: "text",
|
|
149
|
-
text: `✅ 抓取完成!共获取 ${
|
|
150
|
-
|
|
196
|
+
text: `✅ 抓取完成!共获取 ${result.length} 条数据。\n` +
|
|
197
|
+
saveLine +
|
|
151
198
|
`以下是点赞最高的前 20 条结果:\n` +
|
|
152
199
|
JSON.stringify(top20, null, 2)
|
|
153
200
|
}
|
|
@@ -169,11 +216,9 @@ async function main() {
|
|
|
169
216
|
await ensureServiceRunning();
|
|
170
217
|
const transport = new StdioServerTransport();
|
|
171
218
|
await server.connect(transport);
|
|
172
|
-
console.error("🐷 TikTok Bridge MCP Client is running (STDIO)");
|
|
173
219
|
} catch (e) {
|
|
174
|
-
console.error(`❌ Client initialization failed: ${e.message}`);
|
|
175
220
|
process.exit(1);
|
|
176
221
|
}
|
|
177
222
|
}
|
|
178
223
|
|
|
179
|
-
main().catch(e =>
|
|
224
|
+
main().catch(e => {});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gecho-ai/gecho-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "MCP Bridge for TikTok Extension Relay - Connecting LLMs to TikTok via Chrome Extension",
|
|
5
5
|
"main": "mcp-client.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,6 +30,6 @@
|
|
|
30
30
|
"ws": "^8.19.0"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
|
-
|
|
33
|
+
"access": "public"
|
|
34
34
|
}
|
|
35
35
|
}
|
package/server.js
CHANGED
|
@@ -19,6 +19,81 @@ const HTTP_PORT = 18793;
|
|
|
19
19
|
let extensionSocket = null;
|
|
20
20
|
const pendingRequests = new Map();
|
|
21
21
|
let requestIdCounter = 1;
|
|
22
|
+
let shuttingDown = false;
|
|
23
|
+
|
|
24
|
+
function decodeBase64Utf8(value) {
|
|
25
|
+
try {
|
|
26
|
+
return Buffer.from(String(value || ""), "base64").toString("utf8");
|
|
27
|
+
} catch (_e) {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeQuery(rawQuery, queryB64) {
|
|
33
|
+
const queryText = String(rawQuery || "").trim();
|
|
34
|
+
const decodedFromB64 = decodeBase64Utf8(queryB64).trim();
|
|
35
|
+
if (decodedFromB64) {
|
|
36
|
+
if (!queryText || /^[??]+$/.test(queryText)) {
|
|
37
|
+
return decodedFromB64;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!queryText) {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
if (/%[0-9A-Fa-f]{2}/.test(queryText)) {
|
|
45
|
+
return decodeURIComponent(queryText);
|
|
46
|
+
}
|
|
47
|
+
} catch (_e) {}
|
|
48
|
+
return queryText;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toSafeFileName(name) {
|
|
52
|
+
const value = String(name || "").trim();
|
|
53
|
+
const replaced = value
|
|
54
|
+
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_")
|
|
55
|
+
.replace(/[. ]+$/g, "")
|
|
56
|
+
.slice(0, 80);
|
|
57
|
+
const fallback = `query_${Date.now()}`;
|
|
58
|
+
const candidate = replaced || fallback;
|
|
59
|
+
const upper = candidate.toUpperCase();
|
|
60
|
+
const reserved = new Set([
|
|
61
|
+
"CON","PRN","AUX","NUL",
|
|
62
|
+
"COM1","COM2","COM3","COM4","COM5","COM6","COM7","COM8","COM9",
|
|
63
|
+
"LPT1","LPT2","LPT3","LPT4","LPT5","LPT6","LPT7","LPT8","LPT9"
|
|
64
|
+
]);
|
|
65
|
+
return reserved.has(upper) ? `${candidate}_` : candidate;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function gracefulShutdown(reason) {
|
|
69
|
+
if (shuttingDown) return;
|
|
70
|
+
shuttingDown = true;
|
|
71
|
+
console.log(`🛑 Service shutting down: ${reason}`);
|
|
72
|
+
|
|
73
|
+
for (const [_requestId, pending] of pendingRequests) {
|
|
74
|
+
clearTimeout(pending.timeoutId);
|
|
75
|
+
pending.resolve({ error: "Service is shutting down" });
|
|
76
|
+
}
|
|
77
|
+
pendingRequests.clear();
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
if (extensionSocket && extensionSocket.readyState === 1) {
|
|
81
|
+
extensionSocket.close(1001, "service_shutdown");
|
|
82
|
+
}
|
|
83
|
+
} catch (_e) {}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
wss.close(() => {
|
|
87
|
+
server.close(() => process.exit(0));
|
|
88
|
+
});
|
|
89
|
+
} catch (_e) {
|
|
90
|
+
try {
|
|
91
|
+
server.close(() => process.exit(0));
|
|
92
|
+
} catch (__e) {
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
22
97
|
|
|
23
98
|
// --- WebSocket Server (与插件通信) ---
|
|
24
99
|
const wss = new WebSocketServer({ port: WS_PORT, host: "127.0.0.1" });
|
|
@@ -61,19 +136,31 @@ wss.on("error", (err) => {
|
|
|
61
136
|
|
|
62
137
|
// --- HTTP Server (供 Client 层调用) ---
|
|
63
138
|
const server = http.createServer(async (req, res) => {
|
|
64
|
-
res.setHeader("Content-Type", "application/json");
|
|
139
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
65
140
|
|
|
66
141
|
// 健康检查接口
|
|
67
142
|
if (req.method === "GET" && req.url === "/ping") {
|
|
68
143
|
return res.end(JSON.stringify({ status: "ok" }));
|
|
69
144
|
}
|
|
70
145
|
|
|
146
|
+
if (req.method === "POST" && req.url === "/shutdown") {
|
|
147
|
+
res.end(JSON.stringify({ status: "ok", message: "shutdown accepted" }));
|
|
148
|
+
setTimeout(() => gracefulShutdown("remote_shutdown"), 20).unref?.();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
71
152
|
if (req.method === "POST" && req.url === "/search") {
|
|
153
|
+
if (shuttingDown) {
|
|
154
|
+
res.statusCode = 503;
|
|
155
|
+
return res.end(JSON.stringify({ error: "Service is shutting down" }));
|
|
156
|
+
}
|
|
157
|
+
|
|
72
158
|
let body = "";
|
|
73
159
|
req.on("data", chunk => { body += chunk; });
|
|
74
160
|
req.on("end", async () => {
|
|
75
161
|
try {
|
|
76
|
-
const
|
|
162
|
+
const payload = JSON.parse(body);
|
|
163
|
+
const query = normalizeQuery(payload.query, payload.query_b64);
|
|
77
164
|
if (!query) {
|
|
78
165
|
res.statusCode = 400;
|
|
79
166
|
return res.end(JSON.stringify({ error: "Missing query" }));
|
|
@@ -90,8 +177,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
90
177
|
const result = await new Promise((resolve) => {
|
|
91
178
|
const timeoutId = setTimeout(() => {
|
|
92
179
|
pendingRequests.delete(requestId);
|
|
93
|
-
resolve({ error: "Scraping timeout (
|
|
94
|
-
},
|
|
180
|
+
resolve({ error: "Scraping timeout (300s)" });
|
|
181
|
+
}, 300000);
|
|
95
182
|
|
|
96
183
|
pendingRequests.set(requestId, { resolve, timeoutId });
|
|
97
184
|
|
|
@@ -103,14 +190,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
103
190
|
});
|
|
104
191
|
|
|
105
192
|
// 持久化存储
|
|
193
|
+
let savePath = "";
|
|
194
|
+
let saveWarning = "";
|
|
106
195
|
if (Array.isArray(result) && result.length > 0) {
|
|
107
|
-
|
|
196
|
+
// 优先级: 1. 请求中传来的 save_dir 2. 环境变量 GECHO_DATA_DIR 3. 默认 data 目录
|
|
197
|
+
const dataDir = payload.save_dir || process.env.GECHO_DATA_DIR || path.join(__dirname, "..", "data");
|
|
108
198
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
109
|
-
const
|
|
110
|
-
|
|
199
|
+
const safeName = toSafeFileName(query);
|
|
200
|
+
const fixedPath = path.join(dataDir, `${safeName}_search_results.json`);
|
|
201
|
+
try {
|
|
202
|
+
fs.writeFileSync(fixedPath, JSON.stringify(result, null, 2), "utf8");
|
|
203
|
+
savePath = fixedPath;
|
|
204
|
+
} catch (e) {
|
|
205
|
+
saveWarning = e.message;
|
|
206
|
+
}
|
|
111
207
|
}
|
|
112
|
-
|
|
113
|
-
res.end(JSON.stringify({ success: true, data: result }));
|
|
208
|
+
res.end(JSON.stringify({ success: true, data: result, savePath, saveWarning }));
|
|
114
209
|
} catch (e) {
|
|
115
210
|
res.statusCode = 500;
|
|
116
211
|
res.end(JSON.stringify({ error: e.message }));
|
|
@@ -127,3 +222,6 @@ server.listen(HTTP_PORT, "127.0.0.1", () => {
|
|
|
127
222
|
console.log(` - WebSocket (Extension): ws://127.0.0.1:${WS_PORT}`);
|
|
128
223
|
console.log(` - HTTP API (Client): http://127.0.0.1:${HTTP_PORT}`);
|
|
129
224
|
});
|
|
225
|
+
|
|
226
|
+
process.on("SIGTERM", () => gracefulShutdown("sigterm"));
|
|
227
|
+
process.on("SIGINT", () => gracefulShutdown("sigint"));
|