@tiflis-io/tiflis-code-tunnel 0.3.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/dist/main.js ADDED
@@ -0,0 +1,2264 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/main.ts
4
+ import { nanoid } from "nanoid";
5
+
6
+ // src/app.ts
7
+ import Fastify from "fastify";
8
+ function createApp(config2) {
9
+ const app = Fastify({
10
+ loggerInstance: config2.logger,
11
+ trustProxy: config2.env.TRUST_PROXY,
12
+ // Disable request logging since we use pino directly
13
+ disableRequestLogging: true
14
+ });
15
+ app.addHook("onRequest", async (request, _reply) => {
16
+ request.log.info(
17
+ {
18
+ method: request.method,
19
+ url: request.url,
20
+ // Include forwarded headers if behind proxy
21
+ ...config2.env.TRUST_PROXY && {
22
+ forwardedFor: request.headers["x-forwarded-for"],
23
+ forwardedProto: request.headers["x-forwarded-proto"]
24
+ }
25
+ },
26
+ "Incoming request"
27
+ );
28
+ });
29
+ app.addHook("onResponse", async (request, reply) => {
30
+ request.log.info(
31
+ {
32
+ method: request.method,
33
+ url: request.url,
34
+ statusCode: reply.statusCode,
35
+ responseTime: reply.elapsedTime
36
+ },
37
+ "Request completed"
38
+ );
39
+ });
40
+ app.setErrorHandler((error, request, reply) => {
41
+ request.log.error({ error }, "Request error");
42
+ const statusCode = error.statusCode ?? 500;
43
+ const code = error.code || "INTERNAL_ERROR";
44
+ void reply.status(statusCode).send({
45
+ error: error.message,
46
+ code
47
+ });
48
+ });
49
+ app.setNotFoundHandler((request, reply) => {
50
+ request.log.warn({ url: request.url }, "Route not found");
51
+ reply.status(404).send({
52
+ error: "Not Found",
53
+ code: "NOT_FOUND"
54
+ });
55
+ });
56
+ return app;
57
+ }
58
+
59
+ // src/config/env.ts
60
+ import { z } from "zod";
61
+ import { config } from "dotenv";
62
+ config({ path: ".env.local" });
63
+ config({ path: ".env" });
64
+ var EnvSchema = z.object({
65
+ // Server
66
+ NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
67
+ PORT: z.coerce.number().default(3001),
68
+ HOST: z.string().default("0.0.0.0"),
69
+ LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error", "fatal"]).default("info"),
70
+ // Security
71
+ TUNNEL_REGISTRATION_API_KEY: z.string().min(32, "API key must be at least 32 characters"),
72
+ // Reverse Proxy Configuration
73
+ /**
74
+ * Whether the server is running behind a reverse proxy.
75
+ * When true, the server will trust X-Forwarded-* headers.
76
+ */
77
+ TRUST_PROXY: z.coerce.boolean().default(false),
78
+ /**
79
+ * Public base URL for the tunnel server (used to generate public_url in registration response).
80
+ * Example: "wss://tunnel.example.com" or "ws://localhost:3001"
81
+ * If not set, will be auto-generated based on HOST and PORT.
82
+ */
83
+ PUBLIC_BASE_URL: z.string().url().optional(),
84
+ /**
85
+ * Custom WebSocket path (defaults to /ws).
86
+ */
87
+ WS_PATH: z.string().default("/ws")
88
+ });
89
+ function loadEnv() {
90
+ const result = EnvSchema.safeParse(process.env);
91
+ if (!result.success) {
92
+ console.error("\u274C Invalid environment variables:");
93
+ console.error(result.error.format());
94
+ process.exit(1);
95
+ }
96
+ return result.data;
97
+ }
98
+ function generatePublicUrl(env) {
99
+ if (env.PUBLIC_BASE_URL) {
100
+ const baseUrl = env.PUBLIC_BASE_URL.replace(/\/$/, "");
101
+ return `${baseUrl}${env.WS_PATH}`;
102
+ }
103
+ const protocol = env.NODE_ENV === "production" ? "wss" : "ws";
104
+ const host = env.HOST === "0.0.0.0" ? "localhost" : env.HOST;
105
+ return `${protocol}://${host}:${env.PORT}${env.WS_PATH}`;
106
+ }
107
+ var envInstance = null;
108
+ function getEnv() {
109
+ envInstance ??= loadEnv();
110
+ return envInstance;
111
+ }
112
+
113
+ // src/config/constants.ts
114
+ var PROTOCOL_VERSION = {
115
+ major: 1,
116
+ minor: 0,
117
+ patch: 0
118
+ };
119
+ function getProtocolVersion() {
120
+ return `${PROTOCOL_VERSION.major}.${PROTOCOL_VERSION.minor}.${PROTOCOL_VERSION.patch}`;
121
+ }
122
+ var CONNECTION_TIMING = {
123
+ /** How often clients should send ping (15 seconds - keeps connection alive through proxies) */
124
+ PING_INTERVAL_MS: 15e3,
125
+ /** Max time to wait for ping before considering connection stale (45 seconds) */
126
+ PONG_TIMEOUT_MS: 45e3,
127
+ /** Interval for checking timed-out connections (10 seconds) */
128
+ TIMEOUT_CHECK_INTERVAL_MS: 1e4,
129
+ /** Minimum reconnect delay (1 second) */
130
+ RECONNECT_DELAY_MIN_MS: 1e3,
131
+ /** Maximum reconnect delay (30 seconds) */
132
+ RECONNECT_DELAY_MAX_MS: 3e4
133
+ };
134
+ var WEBSOCKET_CONFIG = {
135
+ /** Path for WebSocket endpoint */
136
+ PATH: "/ws"
137
+ };
138
+
139
+ // src/utils/version.ts
140
+ import { readFileSync } from "fs";
141
+ import { join } from "path";
142
+ function getTunnelVersion() {
143
+ try {
144
+ const packageJsonPath = join(process.cwd(), "package.json");
145
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
146
+ return packageJson.version;
147
+ } catch (error) {
148
+ console.error("Failed to read tunnel version from package.json:", error);
149
+ return "unknown";
150
+ }
151
+ }
152
+
153
+ // src/infrastructure/logging/pino-logger.ts
154
+ import pino from "pino";
155
+ function createLogger(config2) {
156
+ const options = {
157
+ name: config2.name,
158
+ level: config2.level,
159
+ formatters: {
160
+ level: (label) => ({ level: label })
161
+ },
162
+ // Redact sensitive data from logs
163
+ redact: {
164
+ paths: [
165
+ "api_key",
166
+ "auth_key",
167
+ "payload.api_key",
168
+ "payload.auth_key",
169
+ "*.api_key",
170
+ "*.auth_key"
171
+ ],
172
+ censor: "****"
173
+ }
174
+ };
175
+ if (config2.pretty) {
176
+ return pino({
177
+ ...options,
178
+ transport: {
179
+ target: "pino-pretty",
180
+ options: {
181
+ colorize: true,
182
+ translateTime: "SYS:standard",
183
+ ignore: "pid,hostname"
184
+ }
185
+ }
186
+ });
187
+ }
188
+ return pino(options);
189
+ }
190
+
191
+ // src/infrastructure/persistence/in-memory-registry.ts
192
+ var InMemoryWorkstationRegistry = class {
193
+ workstations = /* @__PURE__ */ new Map();
194
+ register(workstation) {
195
+ this.workstations.set(workstation.tunnelId.value, workstation);
196
+ }
197
+ unregister(tunnelId) {
198
+ return this.workstations.delete(tunnelId.value);
199
+ }
200
+ get(tunnelId) {
201
+ return this.workstations.get(tunnelId.value);
202
+ }
203
+ has(tunnelId) {
204
+ return this.workstations.has(tunnelId.value);
205
+ }
206
+ getAll() {
207
+ return Array.from(this.workstations.values());
208
+ }
209
+ count() {
210
+ return this.workstations.size;
211
+ }
212
+ findTimedOut(timeoutMs) {
213
+ return this.getAll().filter((ws) => ws.hasTimedOut(timeoutMs));
214
+ }
215
+ };
216
+ var InMemoryClientRegistry = class {
217
+ clients = /* @__PURE__ */ new Map();
218
+ register(client) {
219
+ this.clients.set(client.deviceId, client);
220
+ }
221
+ unregister(deviceId) {
222
+ return this.clients.delete(deviceId);
223
+ }
224
+ get(deviceId) {
225
+ return this.clients.get(deviceId);
226
+ }
227
+ has(deviceId) {
228
+ return this.clients.has(deviceId);
229
+ }
230
+ getByTunnelId(tunnelId) {
231
+ return this.getAll().filter(
232
+ (client) => client.tunnelId.equals(tunnelId)
233
+ );
234
+ }
235
+ getAll() {
236
+ return Array.from(this.clients.values());
237
+ }
238
+ count() {
239
+ return this.clients.size;
240
+ }
241
+ findTimedOut(timeoutMs) {
242
+ return this.getAll().filter((client) => client.hasTimedOut(timeoutMs));
243
+ }
244
+ };
245
+ var InMemoryHttpClientRegistry = class {
246
+ clients = /* @__PURE__ */ new Map();
247
+ register(client) {
248
+ this.clients.set(client.deviceId, client);
249
+ }
250
+ unregister(deviceId) {
251
+ return this.clients.delete(deviceId);
252
+ }
253
+ get(deviceId) {
254
+ return this.clients.get(deviceId);
255
+ }
256
+ has(deviceId) {
257
+ return this.clients.has(deviceId);
258
+ }
259
+ getByTunnelId(tunnelId) {
260
+ return this.getAll().filter(
261
+ (client) => client.tunnelId.equals(tunnelId)
262
+ );
263
+ }
264
+ getAll() {
265
+ return Array.from(this.clients.values());
266
+ }
267
+ count() {
268
+ return this.clients.size;
269
+ }
270
+ findTimedOut(timeoutMs) {
271
+ return this.getAll().filter((client) => client.hasTimedOut(timeoutMs));
272
+ }
273
+ };
274
+
275
+ // src/infrastructure/http/health-route.ts
276
+ function registerHealthRoute(app, config2, deps) {
277
+ const startTime = Date.now();
278
+ app.get("/health", async (_request, reply) => {
279
+ const workstationCount = deps.workstationRegistry.count();
280
+ const clientCount = deps.clientRegistry.count();
281
+ const response = {
282
+ status: "healthy",
283
+ version: config2.version,
284
+ uptime: Math.floor((Date.now() - startTime) / 1e3),
285
+ connections: {
286
+ workstations: workstationCount,
287
+ clients: clientCount
288
+ },
289
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
290
+ };
291
+ return reply.status(200).send(response);
292
+ });
293
+ app.get("/healthz", async (_request, reply) => {
294
+ return reply.status(200).send({ status: "ok" });
295
+ });
296
+ app.get("/readyz", async (_request, reply) => {
297
+ return reply.status(200).send({ status: "ready" });
298
+ });
299
+ }
300
+
301
+ // src/domain/errors/domain-errors.ts
302
+ var DomainError = class extends Error {
303
+ constructor(message) {
304
+ super(message);
305
+ this.name = this.constructor.name;
306
+ Error.captureStackTrace(this, this.constructor);
307
+ }
308
+ toJSON() {
309
+ return {
310
+ code: this.code,
311
+ message: this.message
312
+ };
313
+ }
314
+ };
315
+ var InvalidApiKeyError = class extends DomainError {
316
+ code = "INVALID_API_KEY";
317
+ statusCode = 401;
318
+ constructor() {
319
+ super("Invalid API key for workstation registration");
320
+ }
321
+ };
322
+ var InvalidAuthKeyError = class extends DomainError {
323
+ code = "INVALID_AUTH_KEY";
324
+ statusCode = 401;
325
+ constructor() {
326
+ super("Invalid authentication key");
327
+ }
328
+ };
329
+ var TunnelNotFoundError = class extends DomainError {
330
+ code = "TUNNEL_NOT_FOUND";
331
+ statusCode = 404;
332
+ constructor(tunnelId) {
333
+ super(`Tunnel not found: ${tunnelId}`);
334
+ }
335
+ };
336
+ var WorkstationOfflineError = class extends DomainError {
337
+ code = "WORKSTATION_OFFLINE";
338
+ statusCode = 503;
339
+ constructor(tunnelId) {
340
+ super(`Workstation is offline: ${tunnelId}`);
341
+ }
342
+ };
343
+
344
+ // src/infrastructure/http/watch-api-route.ts
345
+ function registerWatchApiRoute(app, deps) {
346
+ const { httpClientOperations, logger } = deps;
347
+ const log = logger.child({ route: "watch-api" });
348
+ app.post("/api/v1/watch/connect", async (request, reply) => {
349
+ try {
350
+ const { tunnel_id, auth_key, device_id } = request.body;
351
+ if (!tunnel_id || !auth_key || !device_id) {
352
+ return await reply.status(400).send({
353
+ error: "missing_parameters",
354
+ message: "tunnel_id, auth_key, and device_id are required"
355
+ });
356
+ }
357
+ const result = httpClientOperations.connect({
358
+ tunnelId: tunnel_id,
359
+ authKey: auth_key,
360
+ deviceId: device_id
361
+ });
362
+ log.info({ deviceId: device_id, tunnelId: tunnel_id }, "Watch connected via HTTP");
363
+ return await reply.status(200).send({
364
+ success: true,
365
+ tunnel_id: result.tunnelId,
366
+ workstation_online: result.workstationOnline,
367
+ workstation_name: result.workstationName
368
+ });
369
+ } catch (error) {
370
+ return await handleError(error, reply, log);
371
+ }
372
+ });
373
+ app.post("/api/v1/watch/command", async (request, reply) => {
374
+ try {
375
+ const { device_id, message } = request.body;
376
+ if (!device_id) {
377
+ return await reply.status(400).send({
378
+ error: "missing_parameters",
379
+ message: "device_id and message are required"
380
+ });
381
+ }
382
+ const sent = httpClientOperations.sendCommand({
383
+ deviceId: device_id,
384
+ message
385
+ });
386
+ if (!sent) {
387
+ return await reply.status(503).send({
388
+ error: "send_failed",
389
+ message: "Failed to send command to workstation"
390
+ });
391
+ }
392
+ return await reply.status(200).send({
393
+ success: true
394
+ });
395
+ } catch (error) {
396
+ return await handleError(error, reply, log);
397
+ }
398
+ });
399
+ app.get("/api/v1/watch/messages", async (request, reply) => {
400
+ try {
401
+ const { device_id, since, ack } = request.query;
402
+ if (!device_id) {
403
+ return await reply.status(400).send({
404
+ error: "missing_parameters",
405
+ message: "device_id is required"
406
+ });
407
+ }
408
+ const sinceSequence = since ? parseInt(since, 10) : 0;
409
+ const ackSequence = ack ? parseInt(ack, 10) : void 0;
410
+ const result = httpClientOperations.pollMessages({
411
+ deviceId: device_id,
412
+ sinceSequence,
413
+ acknowledgeSequence: ackSequence
414
+ });
415
+ const messages = result.messages.map((msg) => ({
416
+ sequence: msg.sequence,
417
+ timestamp: msg.timestamp.toISOString(),
418
+ data: JSON.parse(msg.data)
419
+ }));
420
+ return await reply.status(200).send({
421
+ messages,
422
+ current_sequence: result.currentSequence,
423
+ workstation_online: result.workstationOnline
424
+ });
425
+ } catch (error) {
426
+ return await handleError(error, reply, log);
427
+ }
428
+ });
429
+ app.get("/api/v1/watch/state", async (request, reply) => {
430
+ try {
431
+ const { device_id } = request.query;
432
+ if (!device_id) {
433
+ return await reply.status(400).send({
434
+ error: "missing_parameters",
435
+ message: "device_id is required"
436
+ });
437
+ }
438
+ const result = httpClientOperations.getState({
439
+ deviceId: device_id
440
+ });
441
+ return await reply.status(200).send({
442
+ connected: result.connected,
443
+ workstation_online: result.workstationOnline,
444
+ workstation_name: result.workstationName,
445
+ queue_size: result.queueSize,
446
+ current_sequence: result.currentSequence
447
+ });
448
+ } catch (error) {
449
+ return await handleError(error, reply, log);
450
+ }
451
+ });
452
+ app.post("/api/v1/watch/disconnect", async (request, reply) => {
453
+ try {
454
+ const { device_id } = request.body;
455
+ if (!device_id) {
456
+ return await reply.status(400).send({
457
+ error: "missing_parameters",
458
+ message: "device_id is required"
459
+ });
460
+ }
461
+ const disconnected = httpClientOperations.disconnect(device_id);
462
+ log.info({ deviceId: device_id }, "Watch disconnected via HTTP");
463
+ return await reply.status(200).send({
464
+ success: disconnected
465
+ });
466
+ } catch (error) {
467
+ return await handleError(error, reply, log);
468
+ }
469
+ });
470
+ }
471
+ async function handleError(error, reply, log) {
472
+ if (error instanceof TunnelNotFoundError) {
473
+ return await reply.status(404).send({
474
+ error: "tunnel_not_found",
475
+ message: error.message
476
+ });
477
+ }
478
+ if (error instanceof InvalidAuthKeyError) {
479
+ return await reply.status(401).send({
480
+ error: "invalid_auth_key",
481
+ message: "Invalid authentication key"
482
+ });
483
+ }
484
+ if (error instanceof WorkstationOfflineError) {
485
+ return await reply.status(503).send({
486
+ error: "workstation_offline",
487
+ message: "Workstation is offline"
488
+ });
489
+ }
490
+ log.error({ error }, "Unexpected error in Watch API");
491
+ return await reply.status(500).send({
492
+ error: "internal_error",
493
+ message: "An unexpected error occurred"
494
+ });
495
+ }
496
+
497
+ // src/domain/value-objects/tunnel-id.ts
498
+ var TunnelId = class _TunnelId {
499
+ _value;
500
+ constructor(value) {
501
+ this._value = value;
502
+ }
503
+ get value() {
504
+ return this._value;
505
+ }
506
+ static create(value) {
507
+ if (!value || value.trim().length === 0) {
508
+ throw new Error("TunnelId cannot be empty");
509
+ }
510
+ return new _TunnelId(value.trim());
511
+ }
512
+ static generate(generator) {
513
+ return new _TunnelId(generator());
514
+ }
515
+ equals(other) {
516
+ return this._value === other._value;
517
+ }
518
+ toString() {
519
+ return this._value;
520
+ }
521
+ };
522
+
523
+ // src/protocol/schemas.ts
524
+ import { z as z2 } from "zod";
525
+ var WorkstationRegisterPayloadSchema = z2.object({
526
+ api_key: z2.string().min(32, "API key must be at least 32 characters"),
527
+ name: z2.string().min(1, "Workstation name is required"),
528
+ auth_key: z2.string().min(16, "Auth key must be at least 16 characters"),
529
+ reconnect: z2.boolean().optional(),
530
+ previous_tunnel_id: z2.string().optional()
531
+ });
532
+ var WorkstationRegisterSchema = z2.object({
533
+ type: z2.literal("workstation.register"),
534
+ payload: WorkstationRegisterPayloadSchema
535
+ });
536
+ var ConnectPayloadSchema = z2.object({
537
+ tunnel_id: z2.string().min(1, "Tunnel ID is required"),
538
+ auth_key: z2.string().min(16, "Auth key must be at least 16 characters"),
539
+ device_id: z2.string().min(1, "Device ID is required"),
540
+ reconnect: z2.boolean().optional()
541
+ });
542
+ var ConnectSchema = z2.object({
543
+ type: z2.literal("connect"),
544
+ payload: ConnectPayloadSchema
545
+ });
546
+ var PingSchema = z2.object({
547
+ type: z2.literal("ping"),
548
+ timestamp: z2.number()
549
+ });
550
+ var PongSchema = z2.object({
551
+ type: z2.literal("pong"),
552
+ timestamp: z2.number()
553
+ });
554
+ var ForwardToDeviceSchema = z2.object({
555
+ type: z2.literal("forward.to_device"),
556
+ device_id: z2.string().min(1, "Device ID is required"),
557
+ payload: z2.string()
558
+ });
559
+ var BaseMessageSchema = z2.object({
560
+ type: z2.string()
561
+ });
562
+ var IncomingMessageSchema = z2.discriminatedUnion("type", [
563
+ WorkstationRegisterSchema,
564
+ ConnectSchema,
565
+ PingSchema
566
+ ]);
567
+ function getMessageType(data) {
568
+ const result = BaseMessageSchema.safeParse(data);
569
+ if (result.success) {
570
+ return result.data.type;
571
+ }
572
+ return void 0;
573
+ }
574
+
575
+ // src/protocol/errors.ts
576
+ function createErrorMessage(code, message, requestId, details) {
577
+ const result = {
578
+ type: "error",
579
+ payload: {
580
+ code,
581
+ message
582
+ }
583
+ };
584
+ if (requestId) {
585
+ result.id = requestId;
586
+ }
587
+ if (details !== void 0) {
588
+ result.payload.details = details;
589
+ }
590
+ return result;
591
+ }
592
+ var ProtocolErrors = {
593
+ invalidApiKey: (requestId) => createErrorMessage("INVALID_API_KEY", "Invalid API key for workstation registration", requestId),
594
+ invalidAuthKey: (requestId) => createErrorMessage("INVALID_AUTH_KEY", "Invalid authentication key", requestId),
595
+ tunnelNotFound: (tunnelId, requestId) => createErrorMessage("TUNNEL_NOT_FOUND", `Tunnel not found: ${tunnelId}`, requestId),
596
+ workstationOffline: (tunnelId, requestId) => createErrorMessage("WORKSTATION_OFFLINE", `Workstation is offline: ${tunnelId}`, requestId),
597
+ registrationFailed: (reason, requestId) => createErrorMessage("REGISTRATION_FAILED", `Workstation registration failed: ${reason}`, requestId),
598
+ invalidPayload: (message, requestId, details) => createErrorMessage("INVALID_PAYLOAD", message, requestId, details),
599
+ internalError: (message = "An internal error occurred", requestId) => createErrorMessage("INTERNAL_ERROR", message, requestId)
600
+ };
601
+
602
+ // src/infrastructure/websocket/connection-handler.ts
603
+ var ConnectionHandler = class {
604
+ meta = /* @__PURE__ */ new WeakMap();
605
+ deps;
606
+ logger;
607
+ constructor(deps) {
608
+ this.deps = deps;
609
+ this.logger = deps.logger.child({ component: "ConnectionHandler" });
610
+ }
611
+ /**
612
+ * Sets up event handlers for a new WebSocket connection.
613
+ */
614
+ handleConnection(socket) {
615
+ this.meta.set(socket, { role: "unknown" });
616
+ socket.on("message", (data) => {
617
+ let message;
618
+ if (Array.isArray(data)) {
619
+ message = Buffer.concat(data).toString("utf8");
620
+ } else if (data instanceof ArrayBuffer) {
621
+ message = Buffer.from(new Uint8Array(data)).toString("utf8");
622
+ } else {
623
+ message = data.toString("utf8");
624
+ }
625
+ this.handleMessage(socket, message);
626
+ });
627
+ socket.on("close", () => {
628
+ this.handleClose(socket);
629
+ });
630
+ socket.on("error", (error) => {
631
+ this.logger.error({ error }, "WebSocket error");
632
+ });
633
+ }
634
+ /**
635
+ * Processes an incoming message from a WebSocket connection.
636
+ */
637
+ handleMessage(socket, data) {
638
+ let parsed;
639
+ try {
640
+ parsed = JSON.parse(data);
641
+ } catch {
642
+ this.sendError(socket, "INVALID_PAYLOAD", "Invalid JSON");
643
+ return;
644
+ }
645
+ const messageType = getMessageType(parsed);
646
+ if (!messageType) {
647
+ this.sendError(socket, "INVALID_PAYLOAD", "Missing message type");
648
+ return;
649
+ }
650
+ const meta = this.meta.get(socket);
651
+ if (!meta) {
652
+ this.logger.error("Connection metadata not found");
653
+ socket.close(1011, "Internal error");
654
+ return;
655
+ }
656
+ try {
657
+ switch (messageType) {
658
+ case "workstation.register":
659
+ this.handleWorkstationRegister(socket, parsed, meta);
660
+ break;
661
+ case "connect":
662
+ this.handleClientConnect(socket, parsed, meta);
663
+ break;
664
+ case "ping":
665
+ this.handlePing(socket, parsed, meta);
666
+ break;
667
+ case "forward.to_device":
668
+ this.handleForwardToDevice(socket, parsed, meta);
669
+ break;
670
+ default:
671
+ this.logger.info(
672
+ { messageType, role: meta.role, tunnelId: meta.tunnelId },
673
+ "Forwarding message based on role"
674
+ );
675
+ this.forwardMessage(socket, data, meta);
676
+ break;
677
+ }
678
+ } catch (error) {
679
+ this.handleError(socket, error);
680
+ }
681
+ }
682
+ /**
683
+ * Handles workstation registration request.
684
+ */
685
+ handleWorkstationRegister(socket, data, meta) {
686
+ const parseResult = WorkstationRegisterSchema.safeParse(data);
687
+ if (!parseResult.success) {
688
+ this.sendError(
689
+ socket,
690
+ "INVALID_PAYLOAD",
691
+ "Invalid registration payload",
692
+ void 0,
693
+ parseResult.error.flatten()
694
+ );
695
+ return;
696
+ }
697
+ const { payload } = parseResult.data;
698
+ const result = this.deps.registerWorkstation.execute(socket, {
699
+ apiKey: payload.api_key,
700
+ name: payload.name,
701
+ authKey: payload.auth_key,
702
+ reconnect: payload.reconnect,
703
+ previousTunnelId: payload.previous_tunnel_id
704
+ });
705
+ meta.role = "workstation";
706
+ meta.tunnelId = result.tunnelId;
707
+ if (result.restored) {
708
+ const onlineMessage = {
709
+ type: "connection.workstation_online",
710
+ payload: {
711
+ tunnel_id: result.tunnelId
712
+ }
713
+ };
714
+ this.deps.forwardMessage.broadcastToClients(
715
+ TunnelId.create(result.tunnelId),
716
+ onlineMessage
717
+ );
718
+ }
719
+ const response = {
720
+ type: "workstation.registered",
721
+ payload: {
722
+ tunnel_id: result.tunnelId,
723
+ public_url: result.publicUrl,
724
+ restored: result.restored || void 0
725
+ }
726
+ };
727
+ socket.send(JSON.stringify(response));
728
+ this.logger.info(
729
+ { tunnelId: result.tunnelId, restored: result.restored },
730
+ "Workstation registered"
731
+ );
732
+ }
733
+ /**
734
+ * Handles mobile client connection request.
735
+ */
736
+ handleClientConnect(socket, data, meta) {
737
+ const parseResult = ConnectSchema.safeParse(data);
738
+ if (!parseResult.success) {
739
+ this.sendError(
740
+ socket,
741
+ "INVALID_PAYLOAD",
742
+ "Invalid connection payload",
743
+ void 0,
744
+ parseResult.error.flatten()
745
+ );
746
+ return;
747
+ }
748
+ const { payload } = parseResult.data;
749
+ const result = this.deps.connectClient.execute(socket, {
750
+ tunnelId: payload.tunnel_id,
751
+ authKey: payload.auth_key,
752
+ deviceId: payload.device_id,
753
+ reconnect: payload.reconnect
754
+ });
755
+ meta.role = "client";
756
+ meta.tunnelId = result.tunnelId;
757
+ meta.deviceId = payload.device_id;
758
+ const response = {
759
+ type: "connected",
760
+ payload: {
761
+ tunnel_id: result.tunnelId,
762
+ tunnel_version: this.deps.tunnelVersion,
763
+ protocol_version: this.deps.protocolVersion,
764
+ restored: result.restored || void 0
765
+ }
766
+ };
767
+ socket.send(JSON.stringify(response));
768
+ this.logger.info(
769
+ {
770
+ tunnelId: result.tunnelId,
771
+ deviceId: payload.device_id,
772
+ restored: result.restored
773
+ },
774
+ "Client connected"
775
+ );
776
+ }
777
+ /**
778
+ * Handles ping message.
779
+ */
780
+ handlePing(socket, data, meta) {
781
+ const parseResult = PingSchema.safeParse(data);
782
+ if (!parseResult.success) {
783
+ return;
784
+ }
785
+ if (meta.role === "workstation" && meta.tunnelId) {
786
+ const workstation = this.deps.workstationRegistry.get(
787
+ TunnelId.create(meta.tunnelId)
788
+ );
789
+ workstation?.recordPing();
790
+ } else if (meta.role === "client" && meta.deviceId) {
791
+ const client = this.deps.clientRegistry.get(meta.deviceId);
792
+ client?.recordPing();
793
+ }
794
+ const pong = {
795
+ type: "pong",
796
+ timestamp: parseResult.data.timestamp
797
+ };
798
+ socket.send(JSON.stringify(pong));
799
+ }
800
+ /**
801
+ * Handles forward.to_device message from workstation.
802
+ * Sends the payload to a specific device by device_id.
803
+ */
804
+ handleForwardToDevice(socket, data, meta) {
805
+ if (meta.role !== "workstation" || !meta.tunnelId) {
806
+ this.sendError(
807
+ socket,
808
+ "INVALID_PAYLOAD",
809
+ "Only workstations can send forward.to_device"
810
+ );
811
+ return;
812
+ }
813
+ const parseResult = ForwardToDeviceSchema.safeParse(data);
814
+ if (!parseResult.success) {
815
+ this.sendError(
816
+ socket,
817
+ "INVALID_PAYLOAD",
818
+ "Invalid forward.to_device payload",
819
+ void 0,
820
+ parseResult.error.flatten()
821
+ );
822
+ return;
823
+ }
824
+ const { device_id, payload } = parseResult.data;
825
+ const tunnelId = TunnelId.create(meta.tunnelId);
826
+ this.deps.forwardMessage.forwardToDevice(tunnelId, device_id, payload);
827
+ }
828
+ /**
829
+ * Forwards a message based on the connection role.
830
+ */
831
+ forwardMessage(socket, data, meta) {
832
+ let msgType = "unknown";
833
+ try {
834
+ const parsed = JSON.parse(data);
835
+ msgType = parsed.type ?? "unknown";
836
+ } catch {
837
+ }
838
+ this.logger.info(
839
+ { messageType: msgType, role: meta.role, tunnelId: meta.tunnelId },
840
+ "forwardMessage called"
841
+ );
842
+ if (meta.role === "workstation" && meta.tunnelId) {
843
+ this.deps.forwardMessage.forwardToClients(
844
+ TunnelId.create(meta.tunnelId),
845
+ data
846
+ );
847
+ } else if (meta.role === "client" && meta.deviceId) {
848
+ try {
849
+ this.deps.forwardMessage.forwardToWorkstation(meta.deviceId, data);
850
+ } catch (error) {
851
+ this.handleError(socket, error);
852
+ }
853
+ } else {
854
+ this.sendError(socket, "INVALID_PAYLOAD", "Not authenticated");
855
+ }
856
+ }
857
+ /**
858
+ * Handles WebSocket connection close.
859
+ */
860
+ handleClose(socket) {
861
+ const meta = this.meta.get(socket);
862
+ if (!meta) return;
863
+ if (meta.role === "workstation" && meta.tunnelId) {
864
+ this.deps.handleDisconnection.handleWorkstationDisconnection(
865
+ TunnelId.create(meta.tunnelId)
866
+ );
867
+ } else if (meta.role === "client" && meta.deviceId) {
868
+ this.deps.handleDisconnection.handleClientDisconnection(meta.deviceId);
869
+ }
870
+ this.meta.delete(socket);
871
+ }
872
+ /**
873
+ * Handles errors and sends appropriate error responses.
874
+ */
875
+ handleError(socket, error) {
876
+ if (error instanceof DomainError) {
877
+ const errorMessage = ProtocolErrors.invalidPayload(error.message);
878
+ errorMessage.payload.code = error.code;
879
+ socket.send(JSON.stringify(errorMessage));
880
+ } else {
881
+ this.logger.error({ error }, "Unexpected error");
882
+ socket.send(JSON.stringify(ProtocolErrors.internalError()));
883
+ }
884
+ }
885
+ /**
886
+ * Sends an error message to the socket.
887
+ */
888
+ sendError(socket, code, message, requestId, details) {
889
+ const payload = {
890
+ code,
891
+ message
892
+ };
893
+ if (details !== void 0) {
894
+ payload.details = details;
895
+ }
896
+ const errorMessage = {
897
+ type: "error",
898
+ payload
899
+ };
900
+ if (requestId) {
901
+ errorMessage.id = requestId;
902
+ }
903
+ socket.send(JSON.stringify(errorMessage));
904
+ }
905
+ };
906
+
907
+ // src/infrastructure/websocket/websocket-server.ts
908
+ import { WebSocketServer as WSServer } from "ws";
909
+ var WebSocketServerWrapper = class {
910
+ wss = null;
911
+ heartbeatInterval = null;
912
+ config;
913
+ deps;
914
+ logger;
915
+ constructor(config2, deps) {
916
+ this.config = config2;
917
+ this.deps = deps;
918
+ this.logger = deps.logger.child({ component: "WebSocketServer" });
919
+ }
920
+ /**
921
+ * Attaches the WebSocket server to an HTTP server.
922
+ */
923
+ attach(httpServer) {
924
+ this.wss = new WSServer({
925
+ server: httpServer,
926
+ path: this.config.path,
927
+ maxPayload: 50 * 1024 * 1024
928
+ // 50MB - allow large messages for audio sync
929
+ });
930
+ this.wss.on("connection", (socket) => {
931
+ this.logger.debug("New WebSocket connection");
932
+ this.deps.connectionHandler.handleConnection(socket);
933
+ });
934
+ this.wss.on("error", (error) => {
935
+ this.logger.error({ error }, "WebSocket server error");
936
+ });
937
+ this.startHeartbeatCheck();
938
+ this.logger.info(
939
+ { path: this.config.path },
940
+ "WebSocket server attached"
941
+ );
942
+ }
943
+ /**
944
+ * Starts the periodic heartbeat/timeout check.
945
+ */
946
+ startHeartbeatCheck() {
947
+ this.heartbeatInterval = setInterval(() => {
948
+ this.deps.onTimeoutCheck?.();
949
+ }, this.config.heartbeatIntervalMs);
950
+ }
951
+ /**
952
+ * Broadcasts a message to all connected clients.
953
+ */
954
+ broadcast(message) {
955
+ if (!this.wss) return;
956
+ const data = typeof message === "string" ? message : JSON.stringify(message);
957
+ this.wss.clients.forEach((client) => {
958
+ if (client.readyState === 1) {
959
+ client.send(data);
960
+ }
961
+ });
962
+ }
963
+ /**
964
+ * Returns the number of connected clients.
965
+ */
966
+ get connectionCount() {
967
+ return this.wss?.clients.size ?? 0;
968
+ }
969
+ /**
970
+ * Closes the WebSocket server gracefully.
971
+ */
972
+ async close() {
973
+ if (this.heartbeatInterval) {
974
+ clearInterval(this.heartbeatInterval);
975
+ this.heartbeatInterval = null;
976
+ }
977
+ const wss = this.wss;
978
+ if (!wss) return;
979
+ wss.clients.forEach((client) => {
980
+ client.close(1001, "Server shutting down");
981
+ });
982
+ return new Promise((resolve, reject) => {
983
+ wss.close((err) => {
984
+ if (err) {
985
+ this.logger.error({ error: err }, "Error closing WebSocket server");
986
+ reject(err);
987
+ } else {
988
+ this.logger.info("WebSocket server closed");
989
+ resolve();
990
+ }
991
+ });
992
+ });
993
+ }
994
+ };
995
+
996
+ // src/domain/entities/workstation.ts
997
+ var Workstation = class {
998
+ _tunnelId;
999
+ _name;
1000
+ _authKey;
1001
+ _socket;
1002
+ _publicUrl;
1003
+ _status;
1004
+ _lastPingAt;
1005
+ _connectedAt;
1006
+ constructor(props) {
1007
+ this._tunnelId = props.tunnelId;
1008
+ this._name = props.name;
1009
+ this._authKey = props.authKey;
1010
+ this._socket = props.socket;
1011
+ this._publicUrl = props.publicUrl;
1012
+ this._status = "online";
1013
+ this._lastPingAt = /* @__PURE__ */ new Date();
1014
+ this._connectedAt = /* @__PURE__ */ new Date();
1015
+ }
1016
+ get tunnelId() {
1017
+ return this._tunnelId;
1018
+ }
1019
+ get name() {
1020
+ return this._name;
1021
+ }
1022
+ get authKey() {
1023
+ return this._authKey;
1024
+ }
1025
+ get socket() {
1026
+ return this._socket;
1027
+ }
1028
+ get publicUrl() {
1029
+ return this._publicUrl;
1030
+ }
1031
+ get status() {
1032
+ return this._status;
1033
+ }
1034
+ get lastPingAt() {
1035
+ return this._lastPingAt;
1036
+ }
1037
+ get connectedAt() {
1038
+ return this._connectedAt;
1039
+ }
1040
+ get isOnline() {
1041
+ return this._status === "online";
1042
+ }
1043
+ /**
1044
+ * Validates the provided auth key against this workstation's auth key.
1045
+ */
1046
+ validateAuthKey(authKey) {
1047
+ return this._authKey.secureEquals(authKey);
1048
+ }
1049
+ /**
1050
+ * Updates the last ping timestamp.
1051
+ */
1052
+ recordPing() {
1053
+ this._lastPingAt = /* @__PURE__ */ new Date();
1054
+ if (this._status === "offline") {
1055
+ this._status = "online";
1056
+ }
1057
+ }
1058
+ /**
1059
+ * Marks the workstation as offline.
1060
+ */
1061
+ markOffline() {
1062
+ this._status = "offline";
1063
+ }
1064
+ /**
1065
+ * Marks the workstation as online.
1066
+ */
1067
+ markOnline() {
1068
+ this._status = "online";
1069
+ this._lastPingAt = /* @__PURE__ */ new Date();
1070
+ }
1071
+ /**
1072
+ * Updates the socket connection (for reconnection scenarios).
1073
+ */
1074
+ updateSocket(socket) {
1075
+ this._socket = socket;
1076
+ this.markOnline();
1077
+ }
1078
+ /**
1079
+ * Sends a message to the workstation.
1080
+ */
1081
+ send(message) {
1082
+ if (!this.isOnline || this._socket.readyState !== 1) {
1083
+ return false;
1084
+ }
1085
+ this._socket.send(message);
1086
+ return true;
1087
+ }
1088
+ /**
1089
+ * Checks if the workstation has timed out (no ping received within timeout period).
1090
+ */
1091
+ hasTimedOut(timeoutMs) {
1092
+ const elapsed = Date.now() - this._lastPingAt.getTime();
1093
+ return elapsed > timeoutMs;
1094
+ }
1095
+ };
1096
+
1097
+ // src/domain/value-objects/auth-key.ts
1098
+ var MIN_AUTH_KEY_LENGTH = 16;
1099
+ var AuthKey = class _AuthKey {
1100
+ _value;
1101
+ constructor(value) {
1102
+ this._value = value;
1103
+ }
1104
+ get value() {
1105
+ return this._value;
1106
+ }
1107
+ static create(value) {
1108
+ if (!value || value.trim().length < MIN_AUTH_KEY_LENGTH) {
1109
+ throw new Error(`AuthKey must be at least ${MIN_AUTH_KEY_LENGTH} characters`);
1110
+ }
1111
+ return new _AuthKey(value.trim());
1112
+ }
1113
+ /**
1114
+ * Creates an AuthKey without validation.
1115
+ * Use for cases where the key was already validated (e.g., from storage).
1116
+ */
1117
+ static fromTrusted(value) {
1118
+ return new _AuthKey(value);
1119
+ }
1120
+ equals(other) {
1121
+ return this._value === other._value;
1122
+ }
1123
+ /**
1124
+ * Performs a timing-safe comparison to prevent timing attacks.
1125
+ */
1126
+ secureEquals(other) {
1127
+ const a = this._value;
1128
+ const b = other._value;
1129
+ if (a.length !== b.length) {
1130
+ return false;
1131
+ }
1132
+ let result = 0;
1133
+ for (let i = 0; i < a.length; i++) {
1134
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
1135
+ }
1136
+ return result === 0;
1137
+ }
1138
+ toString() {
1139
+ return `${this._value.substring(0, 4)}****`;
1140
+ }
1141
+ };
1142
+
1143
+ // src/application/register-workstation.ts
1144
+ var RegisterWorkstationUseCase = class {
1145
+ workstationRegistry;
1146
+ generateTunnelId;
1147
+ getPublicUrl;
1148
+ expectedApiKey;
1149
+ logger;
1150
+ constructor(deps) {
1151
+ this.workstationRegistry = deps.workstationRegistry;
1152
+ this.generateTunnelId = deps.generateTunnelId;
1153
+ this.getPublicUrl = deps.getPublicUrl;
1154
+ this.expectedApiKey = deps.expectedApiKey;
1155
+ this.logger = deps.logger.child({ useCase: "RegisterWorkstation" });
1156
+ }
1157
+ execute(socket, params) {
1158
+ if (params.apiKey !== this.expectedApiKey) {
1159
+ this.logger.warn("Invalid API key attempted");
1160
+ throw new InvalidApiKeyError();
1161
+ }
1162
+ let tunnelId;
1163
+ let restored = false;
1164
+ if (params.reconnect && params.previousTunnelId) {
1165
+ const requestedTunnelId = TunnelId.create(params.previousTunnelId);
1166
+ const existing = this.workstationRegistry.get(requestedTunnelId);
1167
+ if (existing) {
1168
+ tunnelId = existing.tunnelId;
1169
+ existing.updateSocket(socket);
1170
+ restored = true;
1171
+ this.logger.info(
1172
+ { tunnelId: tunnelId.value, name: params.name },
1173
+ "Workstation reconnected with restored tunnel ID"
1174
+ );
1175
+ } else {
1176
+ if (this.workstationRegistry.has(requestedTunnelId)) {
1177
+ tunnelId = TunnelId.generate(this.generateTunnelId);
1178
+ this.logger.warn(
1179
+ {
1180
+ tunnelId: tunnelId.value,
1181
+ previousTunnelId: params.previousTunnelId,
1182
+ name: params.name
1183
+ },
1184
+ "Previous tunnel ID is in use, generated new tunnel ID"
1185
+ );
1186
+ } else {
1187
+ tunnelId = requestedTunnelId;
1188
+ this.logger.info(
1189
+ {
1190
+ tunnelId: tunnelId.value,
1191
+ name: params.name
1192
+ },
1193
+ "Workstation reclaimed tunnel ID after tunnel server restart"
1194
+ );
1195
+ }
1196
+ }
1197
+ } else {
1198
+ tunnelId = TunnelId.generate(this.generateTunnelId);
1199
+ }
1200
+ if (!restored) {
1201
+ const workstation = new Workstation({
1202
+ tunnelId,
1203
+ name: params.name,
1204
+ authKey: AuthKey.create(params.authKey),
1205
+ socket,
1206
+ publicUrl: this.getPublicUrl()
1207
+ });
1208
+ this.workstationRegistry.register(workstation);
1209
+ this.logger.info(
1210
+ { tunnelId: tunnelId.value, name: params.name },
1211
+ "Workstation registered"
1212
+ );
1213
+ }
1214
+ return {
1215
+ tunnelId: tunnelId.value,
1216
+ publicUrl: this.getPublicUrl(),
1217
+ restored
1218
+ };
1219
+ }
1220
+ };
1221
+
1222
+ // src/domain/entities/mobile-client.ts
1223
+ var MobileClient = class {
1224
+ _deviceId;
1225
+ _tunnelId;
1226
+ _socket;
1227
+ _status;
1228
+ _lastPingAt;
1229
+ _connectedAt;
1230
+ constructor(props) {
1231
+ this._deviceId = props.deviceId;
1232
+ this._tunnelId = props.tunnelId;
1233
+ this._socket = props.socket;
1234
+ this._status = "connected";
1235
+ this._lastPingAt = /* @__PURE__ */ new Date();
1236
+ this._connectedAt = /* @__PURE__ */ new Date();
1237
+ }
1238
+ get deviceId() {
1239
+ return this._deviceId;
1240
+ }
1241
+ get tunnelId() {
1242
+ return this._tunnelId;
1243
+ }
1244
+ get socket() {
1245
+ return this._socket;
1246
+ }
1247
+ get status() {
1248
+ return this._status;
1249
+ }
1250
+ get lastPingAt() {
1251
+ return this._lastPingAt;
1252
+ }
1253
+ get connectedAt() {
1254
+ return this._connectedAt;
1255
+ }
1256
+ get isConnected() {
1257
+ return this._status === "connected";
1258
+ }
1259
+ /**
1260
+ * Updates the last ping timestamp.
1261
+ */
1262
+ recordPing() {
1263
+ this._lastPingAt = /* @__PURE__ */ new Date();
1264
+ }
1265
+ /**
1266
+ * Marks the client as disconnected.
1267
+ */
1268
+ markDisconnected() {
1269
+ this._status = "disconnected";
1270
+ }
1271
+ /**
1272
+ * Updates the socket connection (for reconnection scenarios).
1273
+ */
1274
+ updateSocket(socket) {
1275
+ this._socket = socket;
1276
+ this._status = "connected";
1277
+ this._lastPingAt = /* @__PURE__ */ new Date();
1278
+ }
1279
+ /**
1280
+ * Sends a message to the client.
1281
+ */
1282
+ send(message) {
1283
+ if (!this.isConnected || this._socket.readyState !== 1) {
1284
+ return false;
1285
+ }
1286
+ this._socket.send(message);
1287
+ return true;
1288
+ }
1289
+ /**
1290
+ * Checks if the client has timed out (no ping received within timeout period).
1291
+ */
1292
+ hasTimedOut(timeoutMs) {
1293
+ const elapsed = Date.now() - this._lastPingAt.getTime();
1294
+ return elapsed > timeoutMs;
1295
+ }
1296
+ };
1297
+
1298
+ // src/application/connect-client.ts
1299
+ var ConnectClientUseCase = class {
1300
+ workstationRegistry;
1301
+ clientRegistry;
1302
+ logger;
1303
+ constructor(deps) {
1304
+ this.workstationRegistry = deps.workstationRegistry;
1305
+ this.clientRegistry = deps.clientRegistry;
1306
+ this.logger = deps.logger.child({ useCase: "ConnectClient" });
1307
+ }
1308
+ execute(socket, params) {
1309
+ const tunnelId = TunnelId.create(params.tunnelId);
1310
+ const workstation = this.workstationRegistry.get(tunnelId);
1311
+ if (!workstation) {
1312
+ this.logger.warn(
1313
+ { tunnelId: params.tunnelId, deviceId: params.deviceId },
1314
+ "Tunnel not found"
1315
+ );
1316
+ throw new TunnelNotFoundError(params.tunnelId);
1317
+ }
1318
+ if (!workstation.isOnline) {
1319
+ this.logger.warn(
1320
+ { tunnelId: params.tunnelId, deviceId: params.deviceId },
1321
+ "Workstation is offline"
1322
+ );
1323
+ throw new WorkstationOfflineError(params.tunnelId);
1324
+ }
1325
+ const authKey = AuthKey.fromTrusted(params.authKey);
1326
+ if (!workstation.validateAuthKey(authKey)) {
1327
+ this.logger.warn(
1328
+ { tunnelId: params.tunnelId, deviceId: params.deviceId },
1329
+ "Invalid auth key"
1330
+ );
1331
+ throw new InvalidAuthKeyError();
1332
+ }
1333
+ let restored = false;
1334
+ if (params.reconnect) {
1335
+ const existingClient = this.clientRegistry.get(params.deviceId);
1336
+ if (existingClient?.tunnelId.equals(tunnelId)) {
1337
+ existingClient.updateSocket(socket);
1338
+ restored = true;
1339
+ this.logger.info(
1340
+ { tunnelId: params.tunnelId, deviceId: params.deviceId },
1341
+ "Client reconnected"
1342
+ );
1343
+ }
1344
+ }
1345
+ if (!restored) {
1346
+ this.clientRegistry.unregister(params.deviceId);
1347
+ const client = new MobileClient({
1348
+ deviceId: params.deviceId,
1349
+ tunnelId,
1350
+ socket
1351
+ });
1352
+ this.clientRegistry.register(client);
1353
+ this.logger.info(
1354
+ { tunnelId: params.tunnelId, deviceId: params.deviceId },
1355
+ "Client connected"
1356
+ );
1357
+ }
1358
+ return {
1359
+ tunnelId: tunnelId.value,
1360
+ restored
1361
+ };
1362
+ }
1363
+ };
1364
+
1365
+ // src/application/forward-message.ts
1366
+ var ForwardMessageUseCase = class {
1367
+ workstationRegistry;
1368
+ clientRegistry;
1369
+ httpClientRegistry;
1370
+ logger;
1371
+ constructor(deps) {
1372
+ this.workstationRegistry = deps.workstationRegistry;
1373
+ this.clientRegistry = deps.clientRegistry;
1374
+ this.httpClientRegistry = deps.httpClientRegistry;
1375
+ this.logger = deps.logger.child({ useCase: "ForwardMessage" });
1376
+ }
1377
+ /**
1378
+ * Forwards a message from a mobile client to the workstation.
1379
+ * Injects device_id into the message so workstation can identify the sender.
1380
+ */
1381
+ forwardToWorkstation(deviceId, message) {
1382
+ this.logger.info({ deviceId, messageLength: message.length }, "forwardToWorkstation called");
1383
+ const client = this.clientRegistry.get(deviceId);
1384
+ if (!client) {
1385
+ this.logger.warn({ deviceId }, "Client not found for forwarding");
1386
+ return false;
1387
+ }
1388
+ const workstation = this.workstationRegistry.get(client.tunnelId);
1389
+ if (!workstation) {
1390
+ this.logger.warn(
1391
+ { tunnelId: client.tunnelId.value, deviceId },
1392
+ "Workstation not found for forwarding"
1393
+ );
1394
+ throw new TunnelNotFoundError(client.tunnelId.value);
1395
+ }
1396
+ if (!workstation.isOnline) {
1397
+ this.logger.warn(
1398
+ { tunnelId: client.tunnelId.value, deviceId },
1399
+ "Workstation offline, cannot forward"
1400
+ );
1401
+ throw new WorkstationOfflineError(client.tunnelId.value);
1402
+ }
1403
+ let enrichedMessage = message;
1404
+ try {
1405
+ const parsed = JSON.parse(message);
1406
+ parsed.device_id = deviceId;
1407
+ enrichedMessage = JSON.stringify(parsed);
1408
+ this.logger.info({ deviceId, messageType: parsed.type, enrichedLength: enrichedMessage.length }, "Injected device_id into message");
1409
+ } catch {
1410
+ this.logger.warn({ deviceId, message: message.slice(0, 100) }, "Could not inject device_id, message is not JSON");
1411
+ }
1412
+ const sent = workstation.send(enrichedMessage);
1413
+ if (!sent) {
1414
+ this.logger.warn(
1415
+ { tunnelId: client.tunnelId.value, deviceId },
1416
+ "Failed to send message to workstation"
1417
+ );
1418
+ }
1419
+ return sent;
1420
+ }
1421
+ /**
1422
+ * Forwards a message from a workstation to all connected clients.
1423
+ * Also queues the message for HTTP polling clients (watchOS).
1424
+ */
1425
+ forwardToClients(tunnelId, message) {
1426
+ const clients = this.clientRegistry.getByTunnelId(tunnelId);
1427
+ let sentCount = 0;
1428
+ let messageType = "unknown";
1429
+ try {
1430
+ const parsed = JSON.parse(message);
1431
+ messageType = parsed.type ?? "unknown";
1432
+ } catch {
1433
+ }
1434
+ const clientsToRemove = [];
1435
+ for (const client of clients) {
1436
+ if (client.send(message)) {
1437
+ sentCount++;
1438
+ } else {
1439
+ this.logger.warn(
1440
+ {
1441
+ tunnelId: tunnelId.value,
1442
+ deviceId: client.deviceId,
1443
+ clientStatus: client.status,
1444
+ socketReadyState: client.socket.readyState,
1445
+ isConnected: client.isConnected,
1446
+ messageType
1447
+ },
1448
+ "forwardToClients - send failed, marking client for removal"
1449
+ );
1450
+ client.markDisconnected();
1451
+ clientsToRemove.push(client.deviceId);
1452
+ }
1453
+ }
1454
+ for (const deviceId of clientsToRemove) {
1455
+ this.clientRegistry.unregister(deviceId);
1456
+ this.logger.info({ deviceId, tunnelId: tunnelId.value }, "Removed zombie client from registry");
1457
+ }
1458
+ let httpQueuedCount = 0;
1459
+ if (this.httpClientRegistry) {
1460
+ const httpClients = this.httpClientRegistry.getByTunnelId(tunnelId);
1461
+ for (const httpClient of httpClients) {
1462
+ if (httpClient.isActive) {
1463
+ httpClient.queueMessage(message);
1464
+ httpQueuedCount++;
1465
+ }
1466
+ }
1467
+ }
1468
+ this.logger.info(
1469
+ {
1470
+ tunnelId: tunnelId.value,
1471
+ wsClients: clients.length,
1472
+ wsSent: sentCount,
1473
+ httpClients: httpQueuedCount,
1474
+ messageType
1475
+ },
1476
+ "forwardToClients"
1477
+ );
1478
+ return sentCount + httpQueuedCount;
1479
+ }
1480
+ /**
1481
+ * Broadcasts a message to all clients connected to a specific tunnel.
1482
+ * Used for system events like workstation_offline/online.
1483
+ */
1484
+ broadcastToClients(tunnelId, message) {
1485
+ return this.forwardToClients(tunnelId, JSON.stringify(message));
1486
+ }
1487
+ /**
1488
+ * Forwards a message to a specific device by device_id.
1489
+ * Used for targeted delivery (e.g., session output to subscribed clients only).
1490
+ */
1491
+ forwardToDevice(tunnelId, deviceId, payload) {
1492
+ const wsClient = this.clientRegistry.get(deviceId);
1493
+ if (wsClient && wsClient.tunnelId.value === tunnelId.value) {
1494
+ const sent = wsClient.send(payload);
1495
+ if (sent) {
1496
+ this.logger.debug({ tunnelId: tunnelId.value, deviceId }, "forwardToDevice via WebSocket");
1497
+ return true;
1498
+ } else {
1499
+ this.logger.warn(
1500
+ {
1501
+ tunnelId: tunnelId.value,
1502
+ deviceId,
1503
+ clientStatus: wsClient.status,
1504
+ socketReadyState: wsClient.socket.readyState,
1505
+ isConnected: wsClient.isConnected
1506
+ },
1507
+ "forwardToDevice - WebSocket send failed, removing zombie client"
1508
+ );
1509
+ wsClient.markDisconnected();
1510
+ this.clientRegistry.unregister(deviceId);
1511
+ }
1512
+ }
1513
+ if (this.httpClientRegistry) {
1514
+ const httpClient = this.httpClientRegistry.get(deviceId);
1515
+ if (httpClient && httpClient.tunnelId.value === tunnelId.value && httpClient.isActive) {
1516
+ httpClient.queueMessage(payload);
1517
+ this.logger.debug({ tunnelId: tunnelId.value, deviceId }, "forwardToDevice via HTTP queue");
1518
+ return true;
1519
+ }
1520
+ }
1521
+ const allClients = this.clientRegistry.getByTunnelId(tunnelId);
1522
+ this.logger.warn(
1523
+ {
1524
+ tunnelId: tunnelId.value,
1525
+ deviceId,
1526
+ registeredClients: allClients.map((c) => ({
1527
+ deviceId: c.deviceId,
1528
+ status: c.status,
1529
+ socketState: c.socket.readyState
1530
+ }))
1531
+ },
1532
+ "forwardToDevice - client not found or not deliverable"
1533
+ );
1534
+ return false;
1535
+ }
1536
+ };
1537
+
1538
+ // src/application/handle-disconnection.ts
1539
+ var HandleDisconnectionUseCase = class {
1540
+ workstationRegistry;
1541
+ clientRegistry;
1542
+ httpClientRegistry;
1543
+ forwardMessage;
1544
+ logger;
1545
+ constructor(deps) {
1546
+ this.workstationRegistry = deps.workstationRegistry;
1547
+ this.clientRegistry = deps.clientRegistry;
1548
+ this.httpClientRegistry = deps.httpClientRegistry;
1549
+ this.forwardMessage = deps.forwardMessage;
1550
+ this.logger = deps.logger.child({ useCase: "HandleDisconnection" });
1551
+ }
1552
+ /**
1553
+ * Handles workstation disconnection.
1554
+ * Marks the workstation as offline and notifies all connected clients.
1555
+ */
1556
+ handleWorkstationDisconnection(tunnelId) {
1557
+ const workstation = this.workstationRegistry.get(tunnelId);
1558
+ if (!workstation) {
1559
+ this.logger.warn(
1560
+ { tunnelId: tunnelId.value },
1561
+ "Attempted to handle disconnection for unknown workstation"
1562
+ );
1563
+ return;
1564
+ }
1565
+ workstation.markOffline();
1566
+ const offlineMessage = {
1567
+ type: "connection.workstation_offline",
1568
+ payload: {
1569
+ tunnel_id: tunnelId.value
1570
+ }
1571
+ };
1572
+ const notifiedCount = this.forwardMessage.broadcastToClients(
1573
+ tunnelId,
1574
+ offlineMessage
1575
+ );
1576
+ this.logger.info(
1577
+ { tunnelId: tunnelId.value, notifiedClients: notifiedCount },
1578
+ "Workstation disconnected, clients notified"
1579
+ );
1580
+ }
1581
+ /**
1582
+ * Handles workstation removal.
1583
+ * Called when the workstation connection is completely closed
1584
+ * and we don't expect a reconnection.
1585
+ */
1586
+ handleWorkstationRemoval(tunnelId) {
1587
+ this.handleWorkstationDisconnection(tunnelId);
1588
+ const removed = this.workstationRegistry.unregister(tunnelId);
1589
+ if (removed) {
1590
+ this.logger.info(
1591
+ { tunnelId: tunnelId.value },
1592
+ "Workstation removed from registry"
1593
+ );
1594
+ }
1595
+ }
1596
+ /**
1597
+ * Handles client disconnection.
1598
+ * Removes the client from the registry and notifies the workstation.
1599
+ */
1600
+ handleClientDisconnection(deviceId) {
1601
+ const client = this.clientRegistry.get(deviceId);
1602
+ if (!client) {
1603
+ this.logger.warn(
1604
+ { deviceId },
1605
+ "Attempted to handle disconnection for unknown client"
1606
+ );
1607
+ return;
1608
+ }
1609
+ const tunnelId = client.tunnelId;
1610
+ client.markDisconnected();
1611
+ this.clientRegistry.unregister(deviceId);
1612
+ const workstation = this.workstationRegistry.get(tunnelId);
1613
+ if (workstation?.isOnline) {
1614
+ const disconnectMessage = {
1615
+ type: "client.disconnected",
1616
+ payload: {
1617
+ device_id: deviceId,
1618
+ tunnel_id: tunnelId.value
1619
+ }
1620
+ };
1621
+ try {
1622
+ workstation.send(JSON.stringify(disconnectMessage));
1623
+ this.logger.debug(
1624
+ { deviceId, tunnelId: tunnelId.value },
1625
+ "Sent client.disconnected notification to workstation"
1626
+ );
1627
+ } catch (error) {
1628
+ this.logger.warn(
1629
+ { deviceId, tunnelId: tunnelId.value, error },
1630
+ "Failed to send client.disconnected notification to workstation"
1631
+ );
1632
+ }
1633
+ }
1634
+ this.logger.info(
1635
+ { deviceId, tunnelId: tunnelId.value },
1636
+ "Client disconnected"
1637
+ );
1638
+ }
1639
+ /**
1640
+ * Handles timeout check for all connections.
1641
+ * Returns the number of connections that were closed due to timeout.
1642
+ */
1643
+ handleTimeoutCheck(timeoutMs) {
1644
+ let workstationsClosed = 0;
1645
+ let clientsClosed = 0;
1646
+ let httpClientsClosed = 0;
1647
+ const timedOutWorkstations = this.workstationRegistry.findTimedOut(timeoutMs);
1648
+ for (const workstation of timedOutWorkstations) {
1649
+ try {
1650
+ workstation.socket.close(1e3, "Connection timed out");
1651
+ } catch {
1652
+ }
1653
+ this.handleWorkstationDisconnection(workstation.tunnelId);
1654
+ workstationsClosed++;
1655
+ }
1656
+ const timedOutClients = this.clientRegistry.findTimedOut(timeoutMs);
1657
+ for (const client of timedOutClients) {
1658
+ this.handleClientDisconnection(client.deviceId);
1659
+ try {
1660
+ client.socket.close(1e3, "Connection timed out");
1661
+ } catch {
1662
+ }
1663
+ clientsClosed++;
1664
+ }
1665
+ const httpTimeoutMs = timeoutMs * 4;
1666
+ if (this.httpClientRegistry) {
1667
+ const timedOutHttpClients = this.httpClientRegistry.findTimedOut(httpTimeoutMs);
1668
+ for (const httpClient of timedOutHttpClients) {
1669
+ httpClient.markInactive();
1670
+ this.httpClientRegistry.unregister(httpClient.deviceId);
1671
+ httpClientsClosed++;
1672
+ this.logger.info(
1673
+ { deviceId: httpClient.deviceId },
1674
+ "HTTP client timed out and removed"
1675
+ );
1676
+ }
1677
+ }
1678
+ if (workstationsClosed > 0 || clientsClosed > 0 || httpClientsClosed > 0) {
1679
+ this.logger.info(
1680
+ { workstationsClosed, clientsClosed, httpClientsClosed },
1681
+ "Timeout check completed"
1682
+ );
1683
+ }
1684
+ return { workstations: workstationsClosed, clients: clientsClosed, httpClients: httpClientsClosed };
1685
+ }
1686
+ };
1687
+
1688
+ // src/domain/entities/http-client.ts
1689
+ var HttpClient = class _HttpClient {
1690
+ _deviceId;
1691
+ _tunnelId;
1692
+ _status;
1693
+ _lastPollAt;
1694
+ _connectedAt;
1695
+ _messageQueue;
1696
+ _sequence;
1697
+ // Maximum messages to keep in queue (prevent memory bloat)
1698
+ static MAX_QUEUE_SIZE = 100;
1699
+ // Message TTL in milliseconds (5 minutes)
1700
+ static MESSAGE_TTL_MS = 5 * 60 * 1e3;
1701
+ constructor(props) {
1702
+ this._deviceId = props.deviceId;
1703
+ this._tunnelId = props.tunnelId;
1704
+ this._status = "active";
1705
+ this._lastPollAt = /* @__PURE__ */ new Date();
1706
+ this._connectedAt = /* @__PURE__ */ new Date();
1707
+ this._messageQueue = [];
1708
+ this._sequence = 0;
1709
+ }
1710
+ get deviceId() {
1711
+ return this._deviceId;
1712
+ }
1713
+ get tunnelId() {
1714
+ return this._tunnelId;
1715
+ }
1716
+ get status() {
1717
+ return this._status;
1718
+ }
1719
+ get lastPollAt() {
1720
+ return this._lastPollAt;
1721
+ }
1722
+ get connectedAt() {
1723
+ return this._connectedAt;
1724
+ }
1725
+ get isActive() {
1726
+ return this._status === "active";
1727
+ }
1728
+ get queueSize() {
1729
+ return this._messageQueue.length;
1730
+ }
1731
+ get currentSequence() {
1732
+ return this._sequence;
1733
+ }
1734
+ /**
1735
+ * Records a poll request, updating last poll timestamp.
1736
+ */
1737
+ recordPoll() {
1738
+ this._lastPollAt = /* @__PURE__ */ new Date();
1739
+ this._status = "active";
1740
+ }
1741
+ /**
1742
+ * Marks the client as inactive.
1743
+ */
1744
+ markInactive() {
1745
+ this._status = "inactive";
1746
+ }
1747
+ /**
1748
+ * Queues a message for delivery to this client.
1749
+ */
1750
+ queueMessage(message) {
1751
+ this._sequence++;
1752
+ const queuedMessage = {
1753
+ sequence: this._sequence,
1754
+ timestamp: /* @__PURE__ */ new Date(),
1755
+ data: message
1756
+ };
1757
+ this._messageQueue.push(queuedMessage);
1758
+ if (this._messageQueue.length > _HttpClient.MAX_QUEUE_SIZE) {
1759
+ this._messageQueue = this._messageQueue.slice(-_HttpClient.MAX_QUEUE_SIZE);
1760
+ }
1761
+ return this._sequence;
1762
+ }
1763
+ /**
1764
+ * Gets messages since a given sequence number.
1765
+ * Also cleans up expired messages.
1766
+ */
1767
+ getMessagesSince(sinceSequence) {
1768
+ const now = Date.now();
1769
+ this._messageQueue = this._messageQueue.filter(
1770
+ (msg) => now - msg.timestamp.getTime() < _HttpClient.MESSAGE_TTL_MS
1771
+ );
1772
+ return this._messageQueue.filter((msg) => msg.sequence > sinceSequence);
1773
+ }
1774
+ /**
1775
+ * Clears messages up to a given sequence (acknowledged by client).
1776
+ */
1777
+ acknowledgeMessages(upToSequence) {
1778
+ this._messageQueue = this._messageQueue.filter(
1779
+ (msg) => msg.sequence > upToSequence
1780
+ );
1781
+ }
1782
+ /**
1783
+ * Checks if the client has timed out (no poll received within timeout period).
1784
+ */
1785
+ hasTimedOut(timeoutMs) {
1786
+ const elapsed = Date.now() - this._lastPollAt.getTime();
1787
+ return elapsed > timeoutMs;
1788
+ }
1789
+ };
1790
+
1791
+ // src/application/http-client-operations.ts
1792
+ var HttpClientOperationsUseCase = class {
1793
+ workstationRegistry;
1794
+ httpClientRegistry;
1795
+ logger;
1796
+ constructor(deps) {
1797
+ this.workstationRegistry = deps.workstationRegistry;
1798
+ this.httpClientRegistry = deps.httpClientRegistry;
1799
+ this.logger = deps.logger.child({ useCase: "HttpClientOperations" });
1800
+ }
1801
+ /**
1802
+ * Connects an HTTP polling client (watchOS).
1803
+ * Validates auth key and registers the client.
1804
+ */
1805
+ connect(input) {
1806
+ const { tunnelId: tunnelIdStr, authKey: authKeyStr, deviceId } = input;
1807
+ this.logger.info({ tunnelId: tunnelIdStr, deviceId }, "HTTP client connect request");
1808
+ const tunnelId = TunnelId.create(tunnelIdStr);
1809
+ const authKey = AuthKey.create(authKeyStr);
1810
+ const workstation = this.workstationRegistry.get(tunnelId);
1811
+ if (!workstation) {
1812
+ this.logger.warn({ tunnelId: tunnelIdStr, deviceId }, "Tunnel not found for HTTP client");
1813
+ throw new TunnelNotFoundError(tunnelIdStr);
1814
+ }
1815
+ if (!workstation.validateAuthKey(authKey)) {
1816
+ this.logger.warn({ tunnelId: tunnelIdStr, deviceId }, "Invalid auth key for HTTP client");
1817
+ throw new InvalidAuthKeyError();
1818
+ }
1819
+ let client = this.httpClientRegistry.get(deviceId);
1820
+ if (client) {
1821
+ client.recordPoll();
1822
+ this.logger.info({ deviceId, tunnelId: tunnelIdStr }, "HTTP client reconnected");
1823
+ } else {
1824
+ client = new HttpClient({
1825
+ deviceId,
1826
+ tunnelId
1827
+ });
1828
+ this.httpClientRegistry.register(client);
1829
+ this.logger.info({ deviceId, tunnelId: tunnelIdStr }, "HTTP client registered");
1830
+ }
1831
+ return {
1832
+ success: true,
1833
+ tunnelId: tunnelIdStr,
1834
+ workstationOnline: workstation.isOnline,
1835
+ workstationName: workstation.name
1836
+ };
1837
+ }
1838
+ /**
1839
+ * Sends a command from HTTP client to workstation.
1840
+ */
1841
+ sendCommand(input) {
1842
+ const { deviceId, message } = input;
1843
+ this.logger.info({ deviceId, messageType: message.type }, "HTTP client sending command");
1844
+ const client = this.httpClientRegistry.get(deviceId);
1845
+ if (!client) {
1846
+ this.logger.warn({ deviceId }, "HTTP client not found for command");
1847
+ return false;
1848
+ }
1849
+ client.recordPoll();
1850
+ const workstation = this.workstationRegistry.get(client.tunnelId);
1851
+ if (!workstation) {
1852
+ this.logger.warn({ deviceId, tunnelId: client.tunnelId.value }, "Workstation not found");
1853
+ throw new TunnelNotFoundError(client.tunnelId.value);
1854
+ }
1855
+ if (!workstation.isOnline) {
1856
+ this.logger.warn({ deviceId, tunnelId: client.tunnelId.value }, "Workstation offline");
1857
+ throw new WorkstationOfflineError(client.tunnelId.value);
1858
+ }
1859
+ const enrichedMessage = {
1860
+ ...message,
1861
+ device_id: deviceId
1862
+ };
1863
+ const sent = workstation.send(JSON.stringify(enrichedMessage));
1864
+ if (!sent) {
1865
+ this.logger.warn({ deviceId }, "Failed to send command to workstation");
1866
+ }
1867
+ return sent;
1868
+ }
1869
+ /**
1870
+ * Polls for messages for an HTTP client.
1871
+ */
1872
+ pollMessages(input) {
1873
+ const { deviceId, sinceSequence, acknowledgeSequence } = input;
1874
+ const client = this.httpClientRegistry.get(deviceId);
1875
+ if (!client) {
1876
+ this.logger.warn({ deviceId }, "HTTP client not found for poll");
1877
+ return {
1878
+ messages: [],
1879
+ currentSequence: 0,
1880
+ workstationOnline: false
1881
+ };
1882
+ }
1883
+ client.recordPoll();
1884
+ if (acknowledgeSequence !== void 0 && acknowledgeSequence > 0) {
1885
+ client.acknowledgeMessages(acknowledgeSequence);
1886
+ }
1887
+ const messages = client.getMessagesSince(sinceSequence);
1888
+ const workstation = this.workstationRegistry.get(client.tunnelId);
1889
+ const workstationOnline = workstation?.isOnline ?? false;
1890
+ this.logger.debug(
1891
+ { deviceId, sinceSequence, messageCount: messages.length, currentSequence: client.currentSequence },
1892
+ "HTTP client poll"
1893
+ );
1894
+ return {
1895
+ messages,
1896
+ currentSequence: client.currentSequence,
1897
+ workstationOnline
1898
+ };
1899
+ }
1900
+ /**
1901
+ * Gets current state for an HTTP client.
1902
+ */
1903
+ getState(input) {
1904
+ const { deviceId } = input;
1905
+ const client = this.httpClientRegistry.get(deviceId);
1906
+ if (!client) {
1907
+ return {
1908
+ connected: false,
1909
+ workstationOnline: false,
1910
+ queueSize: 0,
1911
+ currentSequence: 0
1912
+ };
1913
+ }
1914
+ client.recordPoll();
1915
+ const workstation = this.workstationRegistry.get(client.tunnelId);
1916
+ return {
1917
+ connected: true,
1918
+ workstationOnline: workstation?.isOnline ?? false,
1919
+ workstationName: workstation?.name,
1920
+ queueSize: client.queueSize,
1921
+ currentSequence: client.currentSequence
1922
+ };
1923
+ }
1924
+ /**
1925
+ * Disconnects an HTTP client.
1926
+ */
1927
+ disconnect(deviceId) {
1928
+ const client = this.httpClientRegistry.get(deviceId);
1929
+ if (!client) {
1930
+ return false;
1931
+ }
1932
+ client.markInactive();
1933
+ this.httpClientRegistry.unregister(deviceId);
1934
+ this.logger.info({ deviceId }, "HTTP client disconnected");
1935
+ return true;
1936
+ }
1937
+ /**
1938
+ * Queues a message for all HTTP clients connected to a tunnel.
1939
+ * Called when workstation sends a message.
1940
+ */
1941
+ queueMessageForTunnel(tunnelId, message) {
1942
+ const clients = this.httpClientRegistry.getByTunnelId(tunnelId);
1943
+ let queuedCount = 0;
1944
+ for (const client of clients) {
1945
+ if (client.isActive) {
1946
+ client.queueMessage(message);
1947
+ queuedCount++;
1948
+ }
1949
+ }
1950
+ if (queuedCount > 0) {
1951
+ this.logger.debug(
1952
+ { tunnelId: tunnelId.value, queuedCount, totalClients: clients.length },
1953
+ "Message queued for HTTP clients"
1954
+ );
1955
+ }
1956
+ return queuedCount;
1957
+ }
1958
+ /**
1959
+ * Cleans up timed-out HTTP clients.
1960
+ */
1961
+ cleanupTimedOut(timeoutMs) {
1962
+ const timedOut = this.httpClientRegistry.findTimedOut(timeoutMs);
1963
+ let cleanedCount = 0;
1964
+ for (const client of timedOut) {
1965
+ client.markInactive();
1966
+ this.httpClientRegistry.unregister(client.deviceId);
1967
+ cleanedCount++;
1968
+ this.logger.info({ deviceId: client.deviceId }, "HTTP client timed out and removed");
1969
+ }
1970
+ return cleanedCount;
1971
+ }
1972
+ };
1973
+
1974
+ // src/main.ts
1975
+ function printBanner(tunnelVersion) {
1976
+ const dim = "\x1B[2m";
1977
+ const blue = "\x1B[38;5;69m";
1978
+ const purple = "\x1B[38;5;135m";
1979
+ const white = "\x1B[97m";
1980
+ const reset = "\x1B[0m";
1981
+ const banner = `
1982
+ ${white}-#####${reset}
1983
+ ${white}# #${reset}
1984
+ ${blue} -####.${reset} ${white}# #${reset} ${purple}-###+.${reset}
1985
+ ${blue} .## .${reset} ${white}.. # #....${reset} ${purple}- ##-${reset}
1986
+ ${blue} -## #.${reset} ${white}##### #####+${reset} ${purple}-- #+.${reset}
1987
+ ${blue} +# ##-.${reset} ${white}# #${reset} ${purple}.## ##.${reset}
1988
+ ${blue} # ##.${reset} ${white}# #${reset} ${purple}.+## +.${reset}
1989
+ ${blue} # ##${reset} ${white}##### #####+${reset} ${purple}.# #-${reset}
1990
+ ${blue} # +-${reset} ${white}# #${reset} ${purple}# #-${reset}
1991
+ ${blue} # +-${reset} ${white}# #${reset} ${purple}# #-${reset}
1992
+ ${blue} # +-${reset} ${blue}---.${reset} ${white}# #${reset} ${purple}# #-${reset}
1993
+ ${blue} # +-${reset} ${blue}+ ###.${reset} ${white}# #${reset} ${purple}# #-${reset}
1994
+ ${blue} # +-${reset} ${blue}+ ##-${reset}${white}# #${reset} ${purple}# #-${reset}
1995
+ ${blue} # +-${reset} ${blue}-## #${reset}${white}# #${reset} ${purple}# #-${reset}
1996
+ ${blue} # ##.${reset} ${blue}.### ${reset}${white}# #.${reset} ${purple}.+# #.${reset}
1997
+ ${blue} # ##+${reset} ${blue}+ ###${reset}${white}# #####+${reset} ${purple}.## #.${reset}
1998
+ ${blue} -## ##.${reset} ${blue}+ ##+. ${reset}${white}# #${reset} ${purple}-# #+.${reset}
1999
+ ${blue} .## .${reset} ${blue}-##+.${reset} ${white}+## #${reset} ${purple}- ##-${reset}
2000
+ ${blue} .-## #.${reset} ${white}-#########+${reset} ${purple}-+ -#+.${reset}
2001
+
2002
+ ${white}T I F L I S C O D E${reset} ${dim}\xB7${reset} Tunnel Server
2003
+ ${dim}Secure WebSocket Relay for Remote Agents${reset}
2004
+
2005
+ ${dim}v${tunnelVersion} \xB7 \xA9 2025 Roman Barinov \xB7 FSL-1.1-NC${reset}
2006
+ ${dim}https://github.com/tiflis-io/tiflis-code${reset}
2007
+ `;
2008
+ process.stdout.write(banner);
2009
+ }
2010
+ async function bootstrap() {
2011
+ const tunnelVersion = getTunnelVersion();
2012
+ const protocolVersion = getProtocolVersion();
2013
+ printBanner(tunnelVersion);
2014
+ const env = getEnv();
2015
+ const logger = createLogger({
2016
+ name: "tiflis-tunnel",
2017
+ level: env.LOG_LEVEL,
2018
+ pretty: env.NODE_ENV === "development"
2019
+ });
2020
+ logger.info(
2021
+ {
2022
+ version: tunnelVersion,
2023
+ nodeEnv: env.NODE_ENV,
2024
+ port: env.PORT,
2025
+ trustProxy: env.TRUST_PROXY,
2026
+ publicBaseUrl: env.PUBLIC_BASE_URL ?? "auto"
2027
+ },
2028
+ "Starting tunnel server"
2029
+ );
2030
+ const workstationRegistry = new InMemoryWorkstationRegistry();
2031
+ const clientRegistry = new InMemoryClientRegistry();
2032
+ const httpClientRegistry = new InMemoryHttpClientRegistry();
2033
+ const registerWorkstation = new RegisterWorkstationUseCase({
2034
+ workstationRegistry,
2035
+ generateTunnelId: () => nanoid(12),
2036
+ getPublicUrl: () => generatePublicUrl(env),
2037
+ expectedApiKey: env.TUNNEL_REGISTRATION_API_KEY,
2038
+ logger
2039
+ });
2040
+ const connectClient = new ConnectClientUseCase({
2041
+ workstationRegistry,
2042
+ clientRegistry,
2043
+ logger
2044
+ });
2045
+ const forwardMessage = new ForwardMessageUseCase({
2046
+ workstationRegistry,
2047
+ clientRegistry,
2048
+ httpClientRegistry,
2049
+ logger
2050
+ });
2051
+ const httpClientOperations = new HttpClientOperationsUseCase({
2052
+ workstationRegistry,
2053
+ httpClientRegistry,
2054
+ logger
2055
+ });
2056
+ const handleDisconnection = new HandleDisconnectionUseCase({
2057
+ workstationRegistry,
2058
+ clientRegistry,
2059
+ httpClientRegistry,
2060
+ forwardMessage,
2061
+ logger
2062
+ });
2063
+ const connectionHandler = new ConnectionHandler({
2064
+ workstationRegistry,
2065
+ clientRegistry,
2066
+ registerWorkstation,
2067
+ connectClient,
2068
+ forwardMessage,
2069
+ handleDisconnection,
2070
+ tunnelVersion,
2071
+ protocolVersion,
2072
+ logger
2073
+ });
2074
+ const app = createApp({ env, logger });
2075
+ registerHealthRoute(
2076
+ app,
2077
+ { version: tunnelVersion },
2078
+ { workstationRegistry, clientRegistry }
2079
+ );
2080
+ registerWatchApiRoute(app, {
2081
+ httpClientOperations,
2082
+ logger
2083
+ });
2084
+ const wsServer = new WebSocketServerWrapper(
2085
+ {
2086
+ path: env.WS_PATH || WEBSOCKET_CONFIG.PATH,
2087
+ heartbeatIntervalMs: CONNECTION_TIMING.TIMEOUT_CHECK_INTERVAL_MS,
2088
+ connectionTimeoutMs: CONNECTION_TIMING.PONG_TIMEOUT_MS
2089
+ },
2090
+ {
2091
+ connectionHandler,
2092
+ onTimeoutCheck: () => {
2093
+ handleDisconnection.handleTimeoutCheck(
2094
+ CONNECTION_TIMING.PONG_TIMEOUT_MS
2095
+ );
2096
+ },
2097
+ logger
2098
+ }
2099
+ );
2100
+ try {
2101
+ await app.listen({ port: env.PORT, host: env.HOST });
2102
+ wsServer.attach(app.server);
2103
+ logger.info(
2104
+ {
2105
+ address: `http://${env.HOST}:${env.PORT}`,
2106
+ wsPath: env.WS_PATH || WEBSOCKET_CONFIG.PATH
2107
+ },
2108
+ "\u{1F680} Tunnel server is running"
2109
+ );
2110
+ } catch (error) {
2111
+ logger.fatal({ error }, "Failed to start server");
2112
+ process.exit(1);
2113
+ }
2114
+ const shutdown = async (signal) => {
2115
+ logger.info({ signal }, "Shutdown signal received");
2116
+ try {
2117
+ await wsServer.close();
2118
+ await app.close();
2119
+ logger.info("Shutdown complete");
2120
+ process.exit(0);
2121
+ } catch (error) {
2122
+ logger.error({ error }, "Error during shutdown");
2123
+ process.exit(1);
2124
+ }
2125
+ };
2126
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
2127
+ process.on("SIGINT", () => void shutdown("SIGINT"));
2128
+ process.on("unhandledRejection", (reason, promise) => {
2129
+ logger.error({ reason, promise }, "Unhandled rejection");
2130
+ });
2131
+ process.on("uncaughtException", (error) => {
2132
+ logger.fatal({ error }, "Uncaught exception");
2133
+ process.exit(1);
2134
+ });
2135
+ }
2136
+ bootstrap().catch((error) => {
2137
+ console.error("Failed to bootstrap:", error);
2138
+ process.exit(1);
2139
+ });
2140
+ /**
2141
+ * @file app.ts
2142
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2143
+ * @license FSL-1.1-NC
2144
+ */
2145
+ /**
2146
+ * @file env.ts
2147
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2148
+ * @license FSL-1.1-NC
2149
+ */
2150
+ /**
2151
+ * @file constants.ts
2152
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2153
+ * @license FSL-1.1-NC
2154
+ */
2155
+ /**
2156
+ * @file version.ts
2157
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2158
+ * @license FSL-1.1-NC
2159
+ */
2160
+ /**
2161
+ * @file pino-logger.ts
2162
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2163
+ * @license FSL-1.1-NC
2164
+ */
2165
+ /**
2166
+ * @file in-memory-registry.ts
2167
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2168
+ * @license FSL-1.1-NC
2169
+ */
2170
+ /**
2171
+ * @file health-route.ts
2172
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2173
+ * @license FSL-1.1-NC
2174
+ */
2175
+ /**
2176
+ * @file domain-errors.ts
2177
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2178
+ * @license FSL-1.1-NC
2179
+ */
2180
+ /**
2181
+ * @file watch-api-route.ts
2182
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2183
+ * @license FSL-1.1-NC
2184
+ */
2185
+ /**
2186
+ * @file tunnel-id.ts
2187
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2188
+ * @license FSL-1.1-NC
2189
+ */
2190
+ /**
2191
+ * @file schemas.ts
2192
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2193
+ * @license FSL-1.1-NC
2194
+ */
2195
+ /**
2196
+ * @file errors.ts
2197
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2198
+ * @license FSL-1.1-NC
2199
+ */
2200
+ /**
2201
+ * @file connection-handler.ts
2202
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2203
+ * @license FSL-1.1-NC
2204
+ */
2205
+ /**
2206
+ * @file websocket-server.ts
2207
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2208
+ * @license FSL-1.1-NC
2209
+ */
2210
+ /**
2211
+ * @file index.ts
2212
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2213
+ * @license FSL-1.1-NC
2214
+ */
2215
+ /**
2216
+ * @file workstation.ts
2217
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2218
+ * @license FSL-1.1-NC
2219
+ */
2220
+ /**
2221
+ * @file auth-key.ts
2222
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2223
+ * @license FSL-1.1-NC
2224
+ */
2225
+ /**
2226
+ * @file register-workstation.ts
2227
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2228
+ * @license FSL-1.1-NC
2229
+ */
2230
+ /**
2231
+ * @file mobile-client.ts
2232
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2233
+ * @license FSL-1.1-NC
2234
+ */
2235
+ /**
2236
+ * @file connect-client.ts
2237
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2238
+ * @license FSL-1.1-NC
2239
+ */
2240
+ /**
2241
+ * @file forward-message.ts
2242
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2243
+ * @license FSL-1.1-NC
2244
+ */
2245
+ /**
2246
+ * @file handle-disconnection.ts
2247
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2248
+ * @license FSL-1.1-NC
2249
+ */
2250
+ /**
2251
+ * @file http-client.ts
2252
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2253
+ * @license FSL-1.1-NC
2254
+ */
2255
+ /**
2256
+ * @file http-client-operations.ts
2257
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2258
+ * @license FSL-1.1-NC
2259
+ */
2260
+ /**
2261
+ * @file main.ts
2262
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2263
+ * @license FSL-1.1-NC
2264
+ */