@dainprotocol/tunnel 1.1.26 → 1.1.30
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/dist/client/index.d.ts +2 -17
- package/dist/client/index.js +209 -268
- package/dist/server/index.d.ts +6 -4
- package/dist/server/index.js +255 -308
- package/package.json +10 -11
package/dist/server/index.js
CHANGED
|
@@ -10,16 +10,29 @@ const body_parser_1 = __importDefault(require("body-parser"));
|
|
|
10
10
|
const auth_1 = require("@dainprotocol/service-sdk/service/auth");
|
|
11
11
|
const crypto_1 = require("crypto");
|
|
12
12
|
const url_1 = require("url");
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const TIMEOUTS = {
|
|
14
|
+
CHALLENGE_TTL: 30000,
|
|
15
|
+
PING_INTERVAL: 30000,
|
|
16
|
+
REQUEST_TIMEOUT: 30000,
|
|
17
|
+
SSE_KEEPALIVE: 5000,
|
|
18
|
+
TUNNEL_RETRY_DELAY: 100,
|
|
19
|
+
SERVER_KEEPALIVE: 65000,
|
|
20
|
+
SERVER_HEADERS: 66000,
|
|
21
|
+
};
|
|
22
|
+
const LIMITS = {
|
|
23
|
+
MAX_CONCURRENT_REQUESTS_PER_TUNNEL: 100,
|
|
24
|
+
MAX_MISSED_PONGS: 2,
|
|
25
|
+
MAX_PAYLOAD_BYTES: 100 * 1024 * 1024,
|
|
26
|
+
BACKPRESSURE_THRESHOLD: 1024 * 1024,
|
|
27
|
+
SERVER_MAX_HEADERS: 100,
|
|
28
|
+
WS_BACKLOG: 100,
|
|
29
|
+
TUNNEL_RETRY_COUNT: 3,
|
|
30
|
+
};
|
|
15
31
|
let idCounter = 0;
|
|
16
32
|
function fastId() {
|
|
17
33
|
return `${Date.now().toString(36)}-${(idCounter++).toString(36)}-${(0, crypto_1.randomBytes)(4).toString('hex')}`;
|
|
18
34
|
}
|
|
19
35
|
class DainTunnelServer {
|
|
20
|
-
/**
|
|
21
|
-
* Safely send a message to a WebSocket, handling errors gracefully
|
|
22
|
-
*/
|
|
23
36
|
safeSend(ws, data) {
|
|
24
37
|
try {
|
|
25
38
|
if (ws.readyState === ws_1.default.OPEN) {
|
|
@@ -32,6 +45,10 @@ class DainTunnelServer {
|
|
|
32
45
|
}
|
|
33
46
|
return false;
|
|
34
47
|
}
|
|
48
|
+
decrementRequestCount(tunnelId) {
|
|
49
|
+
const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
|
|
50
|
+
this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
|
|
51
|
+
}
|
|
35
52
|
constructor(hostname, port) {
|
|
36
53
|
this.hostname = hostname;
|
|
37
54
|
this.port = port;
|
|
@@ -40,34 +57,35 @@ class DainTunnelServer {
|
|
|
40
57
|
this.challenges = new Map();
|
|
41
58
|
this.sseConnections = new Map();
|
|
42
59
|
this.wsConnections = new Map();
|
|
43
|
-
// High-frequency optimization: Track active request count per tunnel for backpressure
|
|
44
60
|
this.tunnelRequestCount = new Map();
|
|
45
|
-
this.MAX_CONCURRENT_REQUESTS_PER_TUNNEL = 100;
|
|
46
61
|
this.app = (0, express_1.default)();
|
|
47
62
|
this.server = http_1.default.createServer(this.app);
|
|
48
|
-
|
|
49
|
-
this.server.
|
|
50
|
-
this.server.
|
|
51
|
-
this.server.maxHeadersCount = 100; // Limit header count for security
|
|
63
|
+
this.server.keepAliveTimeout = TIMEOUTS.SERVER_KEEPALIVE;
|
|
64
|
+
this.server.headersTimeout = TIMEOUTS.SERVER_HEADERS;
|
|
65
|
+
this.server.maxHeadersCount = LIMITS.SERVER_MAX_HEADERS;
|
|
52
66
|
this.wss = new ws_1.default.Server({
|
|
53
67
|
server: this.server,
|
|
54
|
-
path: undefined,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
maxPayload: 100 * 1024 * 1024, // 100MB max payload
|
|
68
|
+
path: undefined,
|
|
69
|
+
backlog: LIMITS.WS_BACKLOG,
|
|
70
|
+
perMessageDeflate: false,
|
|
71
|
+
maxPayload: LIMITS.MAX_PAYLOAD_BYTES,
|
|
59
72
|
});
|
|
60
|
-
// Single optimized CORS middleware (avoid duplicate cors() + manual headers)
|
|
61
73
|
this.app.use((req, res, next) => {
|
|
62
|
-
|
|
74
|
+
const origin = req.headers.origin;
|
|
75
|
+
if (origin) {
|
|
76
|
+
res.header("Access-Control-Allow-Origin", origin);
|
|
77
|
+
res.header("Vary", "Origin");
|
|
78
|
+
res.header("Access-Control-Allow-Credentials", "true");
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
res.header("Access-Control-Allow-Origin", "*");
|
|
82
|
+
}
|
|
63
83
|
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
|
|
64
84
|
res.header("Access-Control-Allow-Headers", "X-DAIN-SIGNATURE, X-DAIN-SMART-ACCOUNT-PDA, X-DAIN-AGENT-ID, X-DAIN-ORG-ID, X-DAIN-ADDRESS, X-DAIN-TIMESTAMP, X-DAIN-WEBHOOK-URL, Content-Type, Authorization, Accept, Origin, X-Requested-With");
|
|
65
|
-
res.header("Access-Control-Allow-Credentials", "true");
|
|
66
85
|
if (req.method === "OPTIONS")
|
|
67
86
|
return res.sendStatus(204);
|
|
68
87
|
next();
|
|
69
88
|
});
|
|
70
|
-
// Add body-parser middleware (skip for GET/HEAD/OPTIONS which don't have bodies)
|
|
71
89
|
this.app.use((req, res, next) => {
|
|
72
90
|
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
|
|
73
91
|
return next();
|
|
@@ -78,12 +96,10 @@ class DainTunnelServer {
|
|
|
78
96
|
this.setupWebSocketServer();
|
|
79
97
|
}
|
|
80
98
|
setupExpressRoutes() {
|
|
81
|
-
// Health check endpoint for App Platform / load balancers
|
|
82
|
-
// Skip WebSocket upgrade requests - let WebSocket server handle them
|
|
83
99
|
this.app.get("/", (req, res, next) => {
|
|
84
|
-
|
|
100
|
+
var _a;
|
|
101
|
+
if (((_a = req.headers.upgrade) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'websocket')
|
|
85
102
|
return next();
|
|
86
|
-
}
|
|
87
103
|
res.status(200).json({
|
|
88
104
|
status: "healthy",
|
|
89
105
|
tunnels: this.tunnels.size,
|
|
@@ -101,18 +117,16 @@ class DainTunnelServer {
|
|
|
101
117
|
uptime: process.uptime()
|
|
102
118
|
});
|
|
103
119
|
});
|
|
104
|
-
// Generic route handler for all tunnel requests
|
|
105
120
|
this.app.use("/:tunnelId", this.handleRequest.bind(this));
|
|
106
121
|
}
|
|
107
122
|
setupWebSocketServer() {
|
|
108
|
-
// Handle WebSocket connections from tunnel clients
|
|
109
123
|
this.wss.on("connection", (ws, req) => {
|
|
110
124
|
var _a;
|
|
111
125
|
try {
|
|
112
126
|
const url = (0, url_1.parse)(req.url || '', true);
|
|
113
127
|
const pathParts = ((_a = url.pathname) === null || _a === void 0 ? void 0 : _a.split('/')) || [];
|
|
114
|
-
|
|
115
|
-
if (
|
|
128
|
+
const isRootConnection = !url.pathname || url.pathname === '/' || pathParts.length <= 1 || !pathParts[1];
|
|
129
|
+
if (isRootConnection) {
|
|
116
130
|
this.handleTunnelClientConnection(ws, req);
|
|
117
131
|
}
|
|
118
132
|
else {
|
|
@@ -125,25 +139,26 @@ class DainTunnelServer {
|
|
|
125
139
|
}
|
|
126
140
|
});
|
|
127
141
|
}
|
|
128
|
-
// Handle WebSocket connections from tunnel clients
|
|
129
142
|
handleTunnelClientConnection(ws, req) {
|
|
130
143
|
ws.on("message", (message) => {
|
|
131
144
|
try {
|
|
132
145
|
const data = JSON.parse(message);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
146
|
+
switch (data.type) {
|
|
147
|
+
case "challenge_request":
|
|
148
|
+
this.handleChallengeRequest(ws);
|
|
149
|
+
break;
|
|
150
|
+
case "start":
|
|
151
|
+
this.handleStartMessage(ws, data);
|
|
152
|
+
break;
|
|
153
|
+
case "response":
|
|
154
|
+
this.handleResponseMessage(data);
|
|
155
|
+
break;
|
|
156
|
+
case "sse":
|
|
157
|
+
this.handleSSEMessage(data);
|
|
158
|
+
break;
|
|
159
|
+
case "websocket":
|
|
160
|
+
this.handleWebSocketMessage(data);
|
|
161
|
+
break;
|
|
147
162
|
}
|
|
148
163
|
}
|
|
149
164
|
catch (error) {
|
|
@@ -154,7 +169,6 @@ class DainTunnelServer {
|
|
|
154
169
|
ws.on("close", () => this.removeTunnel(ws));
|
|
155
170
|
ws.on("error", (error) => console.error("[Tunnel] WS error:", error));
|
|
156
171
|
}
|
|
157
|
-
// Handle incoming WebSocket connections to be proxied through the tunnel
|
|
158
172
|
handleProxiedWebSocketConnection(ws, req) {
|
|
159
173
|
var _a;
|
|
160
174
|
const url = (0, url_1.parse)(req.url || '', true);
|
|
@@ -170,67 +184,35 @@ class DainTunnelServer {
|
|
|
170
184
|
ws.close(1008, "Tunnel not found");
|
|
171
185
|
return;
|
|
172
186
|
}
|
|
173
|
-
// Create a WebSocket connection ID
|
|
174
187
|
const wsConnectionId = fastId();
|
|
175
|
-
// Store this connection with tunnelId for proper cleanup
|
|
176
188
|
this.wsConnections.set(wsConnectionId, {
|
|
177
189
|
clientSocket: ws,
|
|
178
190
|
id: wsConnectionId,
|
|
179
191
|
path: remainingPath,
|
|
180
192
|
headers: req.headers,
|
|
181
|
-
tunnelId
|
|
193
|
+
tunnelId
|
|
182
194
|
});
|
|
183
|
-
// Notify tunnel client about the new WebSocket connection
|
|
184
195
|
this.safeSend(tunnel.ws, {
|
|
185
196
|
type: "websocket_connection",
|
|
186
197
|
id: wsConnectionId,
|
|
187
198
|
path: remainingPath,
|
|
188
199
|
headers: req.headers
|
|
189
200
|
});
|
|
190
|
-
|
|
191
|
-
ws.on("message", (data) => {
|
|
201
|
+
const sendToTunnel = (event, data) => {
|
|
192
202
|
const currentTunnel = this.tunnels.get(tunnelId);
|
|
193
203
|
if (currentTunnel) {
|
|
194
|
-
this.safeSend(currentTunnel.ws, {
|
|
195
|
-
type: "websocket",
|
|
196
|
-
id: wsConnectionId,
|
|
197
|
-
event: "message",
|
|
198
|
-
data: data.toString('base64')
|
|
199
|
-
});
|
|
204
|
+
this.safeSend(currentTunnel.ws, { type: "websocket", id: wsConnectionId, event, data });
|
|
200
205
|
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
ws.on("close", () => {
|
|
204
|
-
|
|
205
|
-
if (currentTunnel) {
|
|
206
|
-
this.safeSend(currentTunnel.ws, {
|
|
207
|
-
type: "websocket",
|
|
208
|
-
id: wsConnectionId,
|
|
209
|
-
event: "close"
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
this.wsConnections.delete(wsConnectionId);
|
|
213
|
-
});
|
|
214
|
-
// Handle errors
|
|
215
|
-
ws.on("error", (error) => {
|
|
216
|
-
const currentTunnel = this.tunnels.get(tunnelId);
|
|
217
|
-
if (currentTunnel) {
|
|
218
|
-
this.safeSend(currentTunnel.ws, {
|
|
219
|
-
type: "websocket",
|
|
220
|
-
id: wsConnectionId,
|
|
221
|
-
event: "error",
|
|
222
|
-
data: error.message
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
});
|
|
206
|
+
};
|
|
207
|
+
ws.on("message", (data) => sendToTunnel("message", data.toString('base64')));
|
|
208
|
+
ws.on("close", () => { sendToTunnel("close"); this.wsConnections.delete(wsConnectionId); });
|
|
209
|
+
ws.on("error", (error) => sendToTunnel("error", error.message));
|
|
226
210
|
}
|
|
227
211
|
handleChallengeRequest(ws) {
|
|
228
212
|
const challenge = fastId();
|
|
229
|
-
|
|
230
|
-
this.challenges.set(challenge, challengeObj);
|
|
213
|
+
this.challenges.set(challenge, { ws, challenge, timestamp: Date.now() });
|
|
231
214
|
ws.send(JSON.stringify({ type: "challenge", challenge }));
|
|
232
|
-
|
|
233
|
-
setTimeout(() => this.challenges.delete(challenge), 30000);
|
|
215
|
+
setTimeout(() => this.challenges.delete(challenge), TIMEOUTS.CHALLENGE_TTL);
|
|
234
216
|
}
|
|
235
217
|
handleStartMessage(ws, data) {
|
|
236
218
|
const { challenge, signature, tunnelId, apiKey } = data;
|
|
@@ -241,7 +223,6 @@ class DainTunnelServer {
|
|
|
241
223
|
}
|
|
242
224
|
this.challenges.delete(challenge);
|
|
243
225
|
try {
|
|
244
|
-
// Parse API key to get the secret for HMAC validation
|
|
245
226
|
if (!apiKey) {
|
|
246
227
|
ws.close(1008, "API key required");
|
|
247
228
|
return;
|
|
@@ -251,45 +232,28 @@ class DainTunnelServer {
|
|
|
251
232
|
ws.close(1008, "Invalid API key format");
|
|
252
233
|
return;
|
|
253
234
|
}
|
|
254
|
-
|
|
255
|
-
const expectedSignature = (0, crypto_1.createHmac)('sha256', parsed.secret)
|
|
256
|
-
.update(challenge)
|
|
257
|
-
.digest('hex');
|
|
258
|
-
// Convert to buffers for timing-safe comparison
|
|
235
|
+
const expectedSignature = (0, crypto_1.createHmac)('sha256', parsed.secret).update(challenge).digest('hex');
|
|
259
236
|
const expectedSigBuffer = Buffer.from(expectedSignature, 'hex');
|
|
260
237
|
const receivedSigBuffer = Buffer.from(signature, 'hex');
|
|
261
|
-
// Constant-time comparison to prevent timing attacks
|
|
262
238
|
if (expectedSigBuffer.length !== receivedSigBuffer.length ||
|
|
263
239
|
!(0, crypto_1.timingSafeEqual)(expectedSigBuffer, receivedSigBuffer)) {
|
|
264
240
|
ws.close(1008, "Invalid signature");
|
|
265
241
|
return;
|
|
266
242
|
}
|
|
267
|
-
// Verify that tunnelId matches orgId_agentId from the API key
|
|
268
243
|
const expectedTunnelId = `${parsed.orgId}_${parsed.agentId}`;
|
|
269
244
|
if (tunnelId !== expectedTunnelId) {
|
|
270
245
|
ws.close(1008, `Tunnel ID does not match API key. Expected: ${expectedTunnelId}, Got: ${tunnelId}`);
|
|
271
246
|
return;
|
|
272
247
|
}
|
|
273
|
-
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
if (oldTunnel && oldTunnel.ws !== ws) {
|
|
277
|
-
oldTunnel.ws.close(1000, "Replaced by new connection");
|
|
278
|
-
}
|
|
248
|
+
const existingTunnel = this.tunnels.get(tunnelId);
|
|
249
|
+
if (existingTunnel && existingTunnel.ws !== ws) {
|
|
250
|
+
existingTunnel.ws.close(1000, "Replaced by new connection");
|
|
279
251
|
}
|
|
280
252
|
this.tunnels.set(tunnelId, { id: tunnelId, ws });
|
|
281
|
-
// Save tunnel ID on the WebSocket object for easy lookup on close
|
|
282
253
|
ws.tunnelId = tunnelId;
|
|
283
|
-
// Liveness detection: track pong responses
|
|
284
254
|
let isAlive = true;
|
|
285
255
|
let missedPongs = 0;
|
|
286
|
-
|
|
287
|
-
// Handle pong responses to verify connection is alive
|
|
288
|
-
ws.on("pong", () => {
|
|
289
|
-
isAlive = true;
|
|
290
|
-
missedPongs = 0;
|
|
291
|
-
});
|
|
292
|
-
// Add a periodic ping to keep the connection alive and detect dead connections
|
|
256
|
+
ws.on("pong", () => { isAlive = true; missedPongs = 0; });
|
|
293
257
|
const intervalId = setInterval(() => {
|
|
294
258
|
if (!this.tunnels.has(tunnelId)) {
|
|
295
259
|
clearInterval(intervalId);
|
|
@@ -298,38 +262,33 @@ class DainTunnelServer {
|
|
|
298
262
|
const tunnel = this.tunnels.get(tunnelId);
|
|
299
263
|
if (!tunnel || tunnel.ws !== ws || ws.readyState !== ws_1.default.OPEN) {
|
|
300
264
|
clearInterval(intervalId);
|
|
301
|
-
if (tunnel
|
|
265
|
+
if ((tunnel === null || tunnel === void 0 ? void 0 : tunnel.ws) === ws)
|
|
302
266
|
this.tunnels.delete(tunnelId);
|
|
303
|
-
}
|
|
304
267
|
return;
|
|
305
268
|
}
|
|
306
|
-
// Check if previous ping was acknowledged
|
|
307
269
|
if (!isAlive) {
|
|
308
270
|
missedPongs++;
|
|
309
|
-
if (missedPongs >= MAX_MISSED_PONGS) {
|
|
271
|
+
if (missedPongs >= LIMITS.MAX_MISSED_PONGS) {
|
|
310
272
|
console.log(`[Tunnel] ${tunnelId} failed liveness check (${missedPongs} missed pongs), terminating`);
|
|
311
273
|
clearInterval(intervalId);
|
|
312
|
-
ws.terminate();
|
|
274
|
+
ws.terminate();
|
|
313
275
|
return;
|
|
314
276
|
}
|
|
315
277
|
}
|
|
316
|
-
// Send new ping and mark as waiting for pong
|
|
317
278
|
isAlive = false;
|
|
318
279
|
try {
|
|
319
280
|
ws.ping();
|
|
320
281
|
}
|
|
321
|
-
catch (
|
|
282
|
+
catch (_a) {
|
|
322
283
|
clearInterval(intervalId);
|
|
323
284
|
ws.terminate();
|
|
324
285
|
}
|
|
325
|
-
},
|
|
326
|
-
// Store the interval ID on the WebSocket object so we can clean it up
|
|
286
|
+
}, TIMEOUTS.PING_INTERVAL);
|
|
327
287
|
ws.keepAliveInterval = intervalId;
|
|
328
288
|
ws.on("close", () => clearInterval(intervalId));
|
|
329
|
-
let tunnelUrl =
|
|
330
|
-
if (process.env.SKIP_PORT !== "true")
|
|
289
|
+
let tunnelUrl = this.hostname;
|
|
290
|
+
if (process.env.SKIP_PORT !== "true")
|
|
331
291
|
tunnelUrl += `:${this.port}`;
|
|
332
|
-
}
|
|
333
292
|
tunnelUrl += `/${tunnelId}`;
|
|
334
293
|
ws.send(JSON.stringify({ type: "tunnelUrl", url: tunnelUrl }));
|
|
335
294
|
console.log(`[Tunnel] Created: ${tunnelUrl}`);
|
|
@@ -342,43 +301,27 @@ class DainTunnelServer {
|
|
|
342
301
|
handleResponseMessage(data) {
|
|
343
302
|
console.log(`[Response] Received for: ${data.requestId}, status: ${data.status}`);
|
|
344
303
|
const pendingRequest = this.pendingRequests.get(data.requestId);
|
|
345
|
-
if (pendingRequest)
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
delete headers["content-length"];
|
|
359
|
-
const bodyBuffer = Buffer.from(data.body, "base64");
|
|
360
|
-
res
|
|
361
|
-
.status(data.status)
|
|
362
|
-
.set(headers)
|
|
363
|
-
.set("Content-Length", bodyBuffer.length.toString())
|
|
364
|
-
.send(bodyBuffer);
|
|
365
|
-
this.pendingRequests.delete(data.requestId);
|
|
366
|
-
}
|
|
304
|
+
if (!pendingRequest)
|
|
305
|
+
return;
|
|
306
|
+
const { res, startTime, tunnelId, timeoutId } = pendingRequest;
|
|
307
|
+
console.log(`[Response] Completed in ${Date.now() - startTime}ms`);
|
|
308
|
+
if (timeoutId)
|
|
309
|
+
clearTimeout(timeoutId);
|
|
310
|
+
this.decrementRequestCount(tunnelId);
|
|
311
|
+
const headers = { ...data.headers };
|
|
312
|
+
delete headers["transfer-encoding"];
|
|
313
|
+
delete headers["content-length"];
|
|
314
|
+
const bodyBuffer = Buffer.from(data.body, "base64");
|
|
315
|
+
res.status(data.status).set(headers).set("Content-Length", bodyBuffer.length.toString()).send(bodyBuffer);
|
|
316
|
+
this.pendingRequests.delete(data.requestId);
|
|
367
317
|
}
|
|
368
318
|
handleSSEMessage(data) {
|
|
369
|
-
console.log(`[SSE] Received message: id=${data.id}, event=${data.event}`);
|
|
370
319
|
const connection = this.sseConnections.get(data.id);
|
|
371
|
-
if (!connection)
|
|
372
|
-
console.log(`[SSE] Connection not found for id: ${data.id}`);
|
|
320
|
+
if (!connection)
|
|
373
321
|
return;
|
|
374
|
-
}
|
|
375
322
|
const { res, tunnelId } = connection;
|
|
376
|
-
|
|
377
|
-
if (data.event === 'connected') {
|
|
378
|
-
console.log(`[SSE] Received 'connected' event, skipping`);
|
|
323
|
+
if (data.event === 'connected')
|
|
379
324
|
return;
|
|
380
|
-
}
|
|
381
|
-
// Handle close event - end the response and cleanup
|
|
382
325
|
if (data.event === 'close') {
|
|
383
326
|
try {
|
|
384
327
|
res.end();
|
|
@@ -387,7 +330,6 @@ class DainTunnelServer {
|
|
|
387
330
|
this.cleanupSSEConnection(data.id, tunnelId);
|
|
388
331
|
return;
|
|
389
332
|
}
|
|
390
|
-
// Handle error event
|
|
391
333
|
if (data.event === 'error') {
|
|
392
334
|
try {
|
|
393
335
|
res.write(`event: error\ndata: ${data.data}\n\n`);
|
|
@@ -397,30 +339,23 @@ class DainTunnelServer {
|
|
|
397
339
|
this.cleanupSSEConnection(data.id, tunnelId);
|
|
398
340
|
return;
|
|
399
341
|
}
|
|
400
|
-
// Forward SSE event to client
|
|
401
342
|
try {
|
|
402
|
-
if (data.event)
|
|
343
|
+
if (data.event)
|
|
403
344
|
res.write(`event: ${data.event}\n`);
|
|
404
|
-
|
|
405
|
-
// Split data by newlines and send each line with data: prefix
|
|
406
|
-
const dataLines = data.data.split('\n');
|
|
407
|
-
for (const line of dataLines) {
|
|
345
|
+
for (const line of data.data.split('\n')) {
|
|
408
346
|
res.write(`data: ${line}\n`);
|
|
409
347
|
}
|
|
410
348
|
res.write('\n');
|
|
411
349
|
}
|
|
412
350
|
catch (_c) {
|
|
413
|
-
// Client disconnected
|
|
414
351
|
this.cleanupSSEConnection(data.id, tunnelId);
|
|
415
352
|
}
|
|
416
353
|
}
|
|
417
354
|
cleanupSSEConnection(id, tunnelId) {
|
|
418
355
|
const connection = this.sseConnections.get(id);
|
|
419
|
-
if (connection === null || connection === void 0 ? void 0 : connection.keepAliveInterval)
|
|
356
|
+
if (connection === null || connection === void 0 ? void 0 : connection.keepAliveInterval)
|
|
420
357
|
clearInterval(connection.keepAliveInterval);
|
|
421
|
-
|
|
422
|
-
const currentCount = this.tunnelRequestCount.get(tunnelId) || 1;
|
|
423
|
-
this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
|
|
358
|
+
this.decrementRequestCount(tunnelId);
|
|
424
359
|
this.sseConnections.delete(id);
|
|
425
360
|
}
|
|
426
361
|
handleWebSocketMessage(data) {
|
|
@@ -428,90 +363,79 @@ class DainTunnelServer {
|
|
|
428
363
|
if (!connection)
|
|
429
364
|
return;
|
|
430
365
|
const { clientSocket } = connection;
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
this.wsConnections.delete(data.id);
|
|
366
|
+
const isOpen = clientSocket.readyState === ws_1.default.OPEN;
|
|
367
|
+
switch (data.event) {
|
|
368
|
+
case 'message':
|
|
369
|
+
if (data.data && isOpen)
|
|
370
|
+
clientSocket.send(Buffer.from(data.data, 'base64'));
|
|
371
|
+
break;
|
|
372
|
+
case 'close':
|
|
373
|
+
if (isOpen)
|
|
374
|
+
clientSocket.close();
|
|
375
|
+
this.wsConnections.delete(data.id);
|
|
376
|
+
break;
|
|
377
|
+
case 'error':
|
|
378
|
+
if (isOpen)
|
|
379
|
+
clientSocket.close(1011, data.data);
|
|
380
|
+
this.wsConnections.delete(data.id);
|
|
381
|
+
break;
|
|
448
382
|
}
|
|
449
383
|
}
|
|
450
384
|
async handleRequest(req, res) {
|
|
451
|
-
var _a;
|
|
385
|
+
var _a, _b, _c;
|
|
452
386
|
const tunnelId = req.params.tunnelId;
|
|
453
387
|
console.log(`[Request] ${req.method} /${tunnelId}${req.url}, Accept: ${(_a = req.headers.accept) === null || _a === void 0 ? void 0 : _a.substring(0, 30)}`);
|
|
454
|
-
|
|
455
|
-
if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') {
|
|
456
|
-
// This is handled by the WebSocket server now
|
|
388
|
+
if (((_b = req.headers.upgrade) === null || _b === void 0 ? void 0 : _b.toLowerCase()) === 'websocket')
|
|
457
389
|
return;
|
|
458
|
-
}
|
|
459
390
|
let tunnel;
|
|
460
|
-
let retries =
|
|
391
|
+
let retries = LIMITS.TUNNEL_RETRY_COUNT;
|
|
461
392
|
while (retries > 0 && !tunnel) {
|
|
462
393
|
tunnel = this.tunnels.get(tunnelId);
|
|
463
394
|
if (!tunnel) {
|
|
464
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
395
|
+
await new Promise(resolve => setTimeout(resolve, TIMEOUTS.TUNNEL_RETRY_DELAY));
|
|
465
396
|
retries--;
|
|
466
397
|
}
|
|
467
398
|
}
|
|
468
399
|
if (!tunnel) {
|
|
469
400
|
const available = Array.from(this.tunnels.keys());
|
|
470
401
|
console.log(`[Request] Tunnel not found: ${tunnelId}, available tunnels: [${available.join(', ')}], count: ${available.length}`);
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
this.tunnelRequestCount.set(tunnelId, Math.max(0, count - 1));
|
|
474
|
-
return res.status(502).json({
|
|
402
|
+
this.decrementRequestCount(tunnelId);
|
|
403
|
+
res.status(502).json({
|
|
475
404
|
error: "Bad Gateway",
|
|
476
405
|
message: `Tunnel "${tunnelId}" not connected. The service may be offline or reconnecting.`,
|
|
477
406
|
availableTunnels: available.length
|
|
478
407
|
});
|
|
408
|
+
return;
|
|
479
409
|
}
|
|
480
410
|
console.log(`[Request] Tunnel found: ${tunnelId}, wsState: ${tunnel.ws.readyState}`);
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
return
|
|
411
|
+
if (tunnel.ws.bufferedAmount > LIMITS.BACKPRESSURE_THRESHOLD) {
|
|
412
|
+
res.status(503).json({ error: "Service Unavailable", message: "Tunnel high load" });
|
|
413
|
+
return;
|
|
484
414
|
}
|
|
485
|
-
// Check concurrent request limit per tunnel
|
|
486
415
|
const currentCount = this.tunnelRequestCount.get(tunnelId) || 0;
|
|
487
|
-
if (currentCount >=
|
|
488
|
-
|
|
416
|
+
if (currentCount >= LIMITS.MAX_CONCURRENT_REQUESTS_PER_TUNNEL) {
|
|
417
|
+
res.status(503).json({ error: "Service Unavailable", message: "Too many concurrent requests" });
|
|
418
|
+
return;
|
|
489
419
|
}
|
|
490
420
|
this.tunnelRequestCount.set(tunnelId, currentCount + 1);
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
return
|
|
421
|
+
if ((_c = req.headers.accept) === null || _c === void 0 ? void 0 : _c.includes('text/event-stream')) {
|
|
422
|
+
this.handleSSERequest(req, res, tunnelId, tunnel);
|
|
423
|
+
return;
|
|
494
424
|
}
|
|
495
|
-
// Handle regular HTTP request
|
|
496
425
|
const requestId = fastId();
|
|
497
426
|
const startTime = Date.now();
|
|
498
|
-
// Set a timeout for the request (30 seconds)
|
|
499
|
-
const REQUEST_TIMEOUT = 30000;
|
|
500
427
|
const timeoutId = setTimeout(() => {
|
|
501
428
|
const pendingRequest = this.pendingRequests.get(requestId);
|
|
502
429
|
if (pendingRequest) {
|
|
503
|
-
|
|
504
|
-
this.tunnelRequestCount.set(tunnelId, Math.max(0, currentCount - 1));
|
|
430
|
+
this.decrementRequestCount(tunnelId);
|
|
505
431
|
this.pendingRequests.delete(requestId);
|
|
506
432
|
if (!res.headersSent) {
|
|
507
433
|
res.status(504).json({ error: "Gateway Timeout", message: "Request timed out" });
|
|
508
434
|
}
|
|
509
435
|
}
|
|
510
|
-
}, REQUEST_TIMEOUT);
|
|
436
|
+
}, TIMEOUTS.REQUEST_TIMEOUT);
|
|
511
437
|
this.pendingRequests.set(requestId, { res, startTime, tunnelId, timeoutId });
|
|
512
438
|
console.log(`[Request] Sending to tunnel client: ${requestId}, wsState: ${tunnel.ws.readyState}, buffered: ${tunnel.ws.bufferedAmount}`);
|
|
513
|
-
// FIX: Only include body if it has actual content (not empty buffer)
|
|
514
|
-
// Empty buffer is truthy but should be treated as no body
|
|
515
439
|
const hasBody = req.method !== "GET" && req.method !== "HEAD" &&
|
|
516
440
|
req.body && Buffer.isBuffer(req.body) && req.body.length > 0;
|
|
517
441
|
const sent = this.safeSend(tunnel.ws, {
|
|
@@ -523,53 +447,66 @@ class DainTunnelServer {
|
|
|
523
447
|
body: hasBody ? req.body.toString("base64") : undefined,
|
|
524
448
|
});
|
|
525
449
|
console.log(`[Request] Sent to tunnel client: ${sent}`);
|
|
526
|
-
// If send failed, clean up immediately
|
|
527
450
|
if (!sent) {
|
|
528
451
|
clearTimeout(timeoutId);
|
|
529
452
|
this.pendingRequests.delete(requestId);
|
|
530
|
-
|
|
531
|
-
this.tunnelRequestCount.set(tunnelId, Math.max(0, count - 1));
|
|
453
|
+
this.decrementRequestCount(tunnelId);
|
|
532
454
|
if (!res.headersSent) {
|
|
533
455
|
res.status(502).json({ error: "Bad Gateway", message: "Tunnel connection lost" });
|
|
534
456
|
}
|
|
535
457
|
}
|
|
536
458
|
}
|
|
537
459
|
handleSSERequest(req, res, tunnelId, tunnel) {
|
|
538
|
-
var _a;
|
|
460
|
+
var _a, _b;
|
|
539
461
|
const sseId = fastId();
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
462
|
+
const startTime = Date.now();
|
|
463
|
+
console.log(`[SSE] ${sseId} started: ${req.url}`);
|
|
464
|
+
if (req.socket) {
|
|
465
|
+
req.socket.setNoDelay(true);
|
|
466
|
+
req.socket.setTimeout(0);
|
|
467
|
+
}
|
|
468
|
+
const origin = req.headers.origin;
|
|
469
|
+
const sseHeaders = {
|
|
543
470
|
'Content-Type': 'text/event-stream',
|
|
544
|
-
'Cache-Control': 'no-cache',
|
|
545
|
-
'Connection': 'keep-alive'
|
|
546
|
-
|
|
547
|
-
|
|
471
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
472
|
+
'Connection': 'keep-alive',
|
|
473
|
+
'X-Accel-Buffering': 'no',
|
|
474
|
+
'X-Content-Type-Options': 'nosniff',
|
|
475
|
+
'Content-Encoding': 'identity',
|
|
476
|
+
'Transfer-Encoding': 'chunked',
|
|
477
|
+
};
|
|
478
|
+
if (typeof origin === 'string') {
|
|
479
|
+
sseHeaders['Access-Control-Allow-Origin'] = origin;
|
|
480
|
+
sseHeaders['Access-Control-Allow-Credentials'] = 'true';
|
|
481
|
+
sseHeaders['Vary'] = 'Origin';
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
sseHeaders['Access-Control-Allow-Origin'] = '*';
|
|
485
|
+
}
|
|
486
|
+
res.writeHead(200, sseHeaders);
|
|
487
|
+
const flush = () => {
|
|
488
|
+
if (typeof res.flush === 'function')
|
|
489
|
+
res.flush();
|
|
490
|
+
};
|
|
548
491
|
(_a = res.flushHeaders) === null || _a === void 0 ? void 0 : _a.call(res);
|
|
549
492
|
res.write(':keepalive\n\n');
|
|
550
|
-
|
|
551
|
-
const keepAliveIntervalMs = 5000;
|
|
493
|
+
flush();
|
|
552
494
|
const keepAliveInterval = setInterval(() => {
|
|
553
495
|
try {
|
|
554
496
|
if (!res.writableEnded) {
|
|
555
497
|
res.write(':keepalive\n\n');
|
|
498
|
+
flush();
|
|
556
499
|
}
|
|
557
|
-
else
|
|
500
|
+
else
|
|
558
501
|
clearInterval(keepAliveInterval);
|
|
559
|
-
}
|
|
560
502
|
}
|
|
561
503
|
catch (_a) {
|
|
562
504
|
clearInterval(keepAliveInterval);
|
|
563
505
|
}
|
|
564
|
-
},
|
|
565
|
-
|
|
566
|
-
this.sseConnections.set(sseId, {
|
|
567
|
-
req, res, id: sseId, tunnelId, keepAliveInterval
|
|
568
|
-
});
|
|
569
|
-
// FIX: Only include body if it has actual content (not empty buffer)
|
|
506
|
+
}, TIMEOUTS.SSE_KEEPALIVE);
|
|
507
|
+
this.sseConnections.set(sseId, { req, res, id: sseId, tunnelId, keepAliveInterval });
|
|
570
508
|
const hasBody = req.method !== "GET" && req.body &&
|
|
571
509
|
Buffer.isBuffer(req.body) && req.body.length > 0;
|
|
572
|
-
// Notify the tunnel client about the new SSE connection
|
|
573
510
|
const sent = this.safeSend(tunnel.ws, {
|
|
574
511
|
type: "sse_connection",
|
|
575
512
|
id: sseId,
|
|
@@ -578,92 +515,108 @@ class DainTunnelServer {
|
|
|
578
515
|
headers: req.headers,
|
|
579
516
|
body: hasBody ? req.body.toString("base64") : undefined
|
|
580
517
|
});
|
|
581
|
-
console.log(`[SSE] Sent sse_connection to tunnel client: ${sent}`);
|
|
582
518
|
if (!sent) {
|
|
583
|
-
console.log(`[SSE] Failed to send sse_connection, cleaning up`);
|
|
584
519
|
this.cleanupSSEConnection(sseId, tunnelId);
|
|
585
520
|
res.end();
|
|
586
521
|
return;
|
|
587
522
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
523
|
+
let cleanedUp = false;
|
|
524
|
+
const doCleanup = () => {
|
|
525
|
+
if (cleanedUp)
|
|
526
|
+
return;
|
|
527
|
+
cleanedUp = true;
|
|
528
|
+
console.log(`[SSE] ${sseId} completed in ${Date.now() - startTime}ms`);
|
|
591
529
|
this.safeSend(tunnel.ws, { type: "sse_close", id: sseId });
|
|
592
530
|
this.cleanupSSEConnection(sseId, tunnelId);
|
|
531
|
+
};
|
|
532
|
+
req.on('close', () => {
|
|
533
|
+
var _a;
|
|
534
|
+
if (((_a = req.socket) === null || _a === void 0 ? void 0 : _a.destroyed) || res.writableEnded)
|
|
535
|
+
doCleanup();
|
|
593
536
|
});
|
|
537
|
+
(_b = req.socket) === null || _b === void 0 ? void 0 : _b.on('close', doCleanup);
|
|
538
|
+
req.on('error', doCleanup);
|
|
539
|
+
res.on('error', doCleanup);
|
|
594
540
|
}
|
|
595
541
|
removeTunnel(ws) {
|
|
596
542
|
try {
|
|
597
543
|
if (ws.keepAliveInterval)
|
|
598
544
|
clearInterval(ws.keepAliveInterval);
|
|
599
|
-
const
|
|
600
|
-
let removedTunnelId = tunnelId;
|
|
601
|
-
if (tunnelId && this.tunnels.has(tunnelId)) {
|
|
602
|
-
const tunnel = this.tunnels.get(tunnelId);
|
|
603
|
-
if (tunnel && tunnel.ws === ws)
|
|
604
|
-
this.tunnels.delete(tunnelId);
|
|
605
|
-
}
|
|
606
|
-
else {
|
|
607
|
-
removedTunnelId = undefined;
|
|
608
|
-
for (const [id, tunnel] of this.tunnels.entries()) {
|
|
609
|
-
if (tunnel.ws === ws) {
|
|
610
|
-
this.tunnels.delete(id);
|
|
611
|
-
removedTunnelId = id;
|
|
612
|
-
break;
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
}
|
|
545
|
+
const removedTunnelId = this.findAndRemoveTunnel(ws);
|
|
616
546
|
if (removedTunnelId) {
|
|
617
547
|
this.tunnelRequestCount.delete(removedTunnelId);
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
548
|
+
this.cleanupPendingRequests(removedTunnelId);
|
|
549
|
+
this.cleanupTunnelSSEConnections(removedTunnelId);
|
|
550
|
+
this.cleanupTunnelWSConnections(removedTunnelId);
|
|
551
|
+
}
|
|
552
|
+
this.cleanupChallengesForSocket(ws);
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
console.error("[Tunnel] Remove error:", error);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
findAndRemoveTunnel(ws) {
|
|
559
|
+
const tunnelId = ws.tunnelId;
|
|
560
|
+
if (tunnelId && this.tunnels.has(tunnelId)) {
|
|
561
|
+
const tunnel = this.tunnels.get(tunnelId);
|
|
562
|
+
if ((tunnel === null || tunnel === void 0 ? void 0 : tunnel.ws) === ws) {
|
|
563
|
+
this.tunnels.delete(tunnelId);
|
|
564
|
+
return tunnelId;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
for (const [id, tunnel] of this.tunnels.entries()) {
|
|
568
|
+
if (tunnel.ws === ws) {
|
|
569
|
+
this.tunnels.delete(id);
|
|
570
|
+
return id;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
cleanupPendingRequests(tunnelId) {
|
|
576
|
+
for (const [requestId, pending] of this.pendingRequests.entries()) {
|
|
577
|
+
if (pending.tunnelId === tunnelId) {
|
|
578
|
+
try {
|
|
579
|
+
if (pending.timeoutId)
|
|
580
|
+
clearTimeout(pending.timeoutId);
|
|
581
|
+
if (!pending.res.headersSent) {
|
|
582
|
+
pending.res.status(502).json({ error: "Bad Gateway", message: "Tunnel closed" });
|
|
644
583
|
}
|
|
645
584
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
585
|
+
catch (_a) { }
|
|
586
|
+
this.pendingRequests.delete(requestId);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
cleanupTunnelSSEConnections(tunnelId) {
|
|
591
|
+
for (const [sseId, conn] of this.sseConnections.entries()) {
|
|
592
|
+
if (conn.tunnelId === tunnelId) {
|
|
593
|
+
if (conn.keepAliveInterval)
|
|
594
|
+
clearInterval(conn.keepAliveInterval);
|
|
595
|
+
try {
|
|
596
|
+
conn.res.end();
|
|
655
597
|
}
|
|
598
|
+
catch (_a) { }
|
|
599
|
+
this.sseConnections.delete(sseId);
|
|
656
600
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
cleanupTunnelWSConnections(tunnelId) {
|
|
604
|
+
for (const [wsId, conn] of this.wsConnections.entries()) {
|
|
605
|
+
if (conn.tunnelId === tunnelId) {
|
|
606
|
+
try {
|
|
607
|
+
conn.clientSocket.close(1001, "Tunnel closed");
|
|
662
608
|
}
|
|
609
|
+
catch (_a) { }
|
|
610
|
+
this.wsConnections.delete(wsId);
|
|
663
611
|
}
|
|
664
612
|
}
|
|
665
|
-
|
|
666
|
-
|
|
613
|
+
}
|
|
614
|
+
cleanupChallengesForSocket(ws) {
|
|
615
|
+
for (const [challenge, obj] of this.challenges.entries()) {
|
|
616
|
+
if (obj.ws === ws) {
|
|
617
|
+
this.challenges.delete(challenge);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
667
620
|
}
|
|
668
621
|
}
|
|
669
622
|
async start() {
|
|
@@ -677,37 +630,31 @@ class DainTunnelServer {
|
|
|
677
630
|
async stop() {
|
|
678
631
|
return new Promise((resolve) => {
|
|
679
632
|
try {
|
|
680
|
-
|
|
681
|
-
for (const [sseId, sseConnection] of this.sseConnections.entries()) {
|
|
633
|
+
for (const [sseId, conn] of this.sseConnections.entries()) {
|
|
682
634
|
try {
|
|
683
|
-
|
|
635
|
+
conn.res.end();
|
|
684
636
|
}
|
|
685
637
|
catch (error) {
|
|
686
|
-
console.error(`Error closing SSE
|
|
638
|
+
console.error(`Error closing SSE ${sseId}:`, error);
|
|
687
639
|
}
|
|
688
640
|
this.sseConnections.delete(sseId);
|
|
689
641
|
}
|
|
690
|
-
|
|
691
|
-
for (const [wsId, wsConnection] of this.wsConnections.entries()) {
|
|
642
|
+
for (const [wsId, conn] of this.wsConnections.entries()) {
|
|
692
643
|
try {
|
|
693
|
-
|
|
644
|
+
conn.clientSocket.close(1001, "Server shutting down");
|
|
694
645
|
}
|
|
695
646
|
catch (error) {
|
|
696
|
-
console.error(`Error closing
|
|
647
|
+
console.error(`Error closing WS ${wsId}:`, error);
|
|
697
648
|
}
|
|
698
649
|
this.wsConnections.delete(wsId);
|
|
699
650
|
}
|
|
700
|
-
// Close the WebSocket server
|
|
701
651
|
this.wss.close(() => {
|
|
702
|
-
|
|
703
|
-
this.server.close(() => {
|
|
704
|
-
resolve();
|
|
705
|
-
});
|
|
652
|
+
this.server.close(() => resolve());
|
|
706
653
|
});
|
|
707
654
|
}
|
|
708
655
|
catch (error) {
|
|
709
656
|
console.error('Error during server shutdown:', error);
|
|
710
|
-
resolve();
|
|
657
|
+
resolve();
|
|
711
658
|
}
|
|
712
659
|
});
|
|
713
660
|
}
|