@tiflis-io/tiflis-code-tunnel 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.js +140 -15
  2. package/package.json +7 -6
package/dist/main.js CHANGED
@@ -84,7 +84,13 @@ var EnvSchema = z.object({
84
84
  /**
85
85
  * Custom WebSocket path (defaults to /ws).
86
86
  */
87
- WS_PATH: z.string().default("/ws")
87
+ WS_PATH: z.string().default("/ws"),
88
+ /**
89
+ * Optional path to web client static files directory.
90
+ * When set, the tunnel server will serve the web client at the root path.
91
+ * Example: "./node_modules/@tiflis-io/tiflis-code-web/dist"
92
+ */
93
+ WEB_CLIENT_PATH: z.string().optional()
88
94
  });
89
95
  function loadEnv() {
90
96
  const result = EnvSchema.safeParse(process.env);
@@ -120,16 +126,16 @@ function getProtocolVersion() {
120
126
  return `${PROTOCOL_VERSION.major}.${PROTOCOL_VERSION.minor}.${PROTOCOL_VERSION.patch}`;
121
127
  }
122
128
  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
129
+ /** How often clients should send ping (5 seconds - fast liveness detection) */
130
+ PING_INTERVAL_MS: 5e3,
131
+ /** Max time to wait for ping before considering connection stale (15 seconds = 3 missed pings) */
132
+ PONG_TIMEOUT_MS: 15e3,
133
+ /** Interval for checking timed-out connections (5 seconds - faster cleanup) */
134
+ TIMEOUT_CHECK_INTERVAL_MS: 5e3,
135
+ /** Minimum reconnect delay (500ms - fast first retry) */
136
+ RECONNECT_DELAY_MIN_MS: 500,
137
+ /** Maximum reconnect delay (5 seconds - don't wait too long) */
138
+ RECONNECT_DELAY_MAX_MS: 5e3
133
139
  };
134
140
  var WEBSOCKET_CONFIG = {
135
141
  /** Path for WebSocket endpoint */
@@ -519,6 +525,114 @@ async function handleError(error, reply, log) {
519
525
  });
520
526
  }
521
527
 
528
+ // src/infrastructure/http/web-client-route.ts
529
+ import fastifyStatic from "@fastify/static";
530
+ import { existsSync, statSync } from "fs";
531
+ import { resolve, join as join2 } from "path";
532
+ var SECURITY_HEADERS = {
533
+ // Prevent MIME type sniffing
534
+ "X-Content-Type-Options": "nosniff",
535
+ // Prevent clickjacking
536
+ "X-Frame-Options": "DENY",
537
+ // XSS protection (legacy, but still useful for older browsers)
538
+ "X-XSS-Protection": "1; mode=block",
539
+ // Referrer policy
540
+ "Referrer-Policy": "strict-origin-when-cross-origin",
541
+ // Permissions policy (restrict sensitive APIs)
542
+ "Permissions-Policy": "camera=(), microphone=(self), geolocation=(), payment=()",
543
+ // Content Security Policy
544
+ "Content-Security-Policy": [
545
+ "default-src 'self'",
546
+ "script-src 'self'",
547
+ "style-src 'self' 'unsafe-inline'",
548
+ // unsafe-inline needed for Tailwind
549
+ "img-src 'self' data: blob:",
550
+ "font-src 'self'",
551
+ "connect-src 'self' ws: wss:",
552
+ "media-src 'self' blob:",
553
+ "worker-src 'self' blob:",
554
+ "frame-ancestors 'none'",
555
+ "base-uri 'self'",
556
+ "form-action 'self'"
557
+ ].join("; ")
558
+ };
559
+ var CACHE_CONTROL = {
560
+ // HTML files: no cache (SPA routing)
561
+ html: "no-cache, no-store, must-revalidate",
562
+ // Hashed assets: long cache (1 year)
563
+ assets: "public, max-age=31536000, immutable",
564
+ // Other static files: short cache (1 hour)
565
+ default: "public, max-age=3600"
566
+ };
567
+ function getCacheControl(url) {
568
+ if (url.endsWith(".html") || url === "/") {
569
+ return CACHE_CONTROL.html;
570
+ }
571
+ if (url.includes("/assets/") || url.includes(".")) {
572
+ const hasHash = /\.[a-f0-9]{8,}\./i.test(url);
573
+ return hasHash ? CACHE_CONTROL.assets : CACHE_CONTROL.default;
574
+ }
575
+ return CACHE_CONTROL.default;
576
+ }
577
+ async function registerWebClientRoute(app, options) {
578
+ const { webClientPath, logger } = options;
579
+ const absolutePath = resolve(webClientPath);
580
+ if (!existsSync(absolutePath)) {
581
+ logger.error(
582
+ { path: absolutePath },
583
+ "Web client path does not exist, skipping web client registration"
584
+ );
585
+ return;
586
+ }
587
+ const stats = statSync(absolutePath);
588
+ if (!stats.isDirectory()) {
589
+ logger.error(
590
+ { path: absolutePath },
591
+ "Web client path is not a directory, skipping web client registration"
592
+ );
593
+ return;
594
+ }
595
+ const indexPath = join2(absolutePath, "index.html");
596
+ if (!existsSync(indexPath)) {
597
+ logger.error(
598
+ { path: indexPath },
599
+ "Web client index.html not found, skipping web client registration"
600
+ );
601
+ return;
602
+ }
603
+ logger.info({ path: absolutePath }, "Registering web client static files");
604
+ app.addHook("onSend", async (request, reply, payload) => {
605
+ const url = request.url;
606
+ if (url.startsWith("/api/") || url.startsWith("/ws") || url.startsWith("/health")) {
607
+ return payload;
608
+ }
609
+ for (const [header, value] of Object.entries(SECURITY_HEADERS)) {
610
+ reply.header(header, value);
611
+ }
612
+ reply.header("Cache-Control", getCacheControl(url));
613
+ return payload;
614
+ });
615
+ await app.register(fastifyStatic, {
616
+ root: absolutePath,
617
+ prefix: "/",
618
+ // Serve index.html for SPA routing
619
+ wildcard: false
620
+ });
621
+ app.setNotFoundHandler(
622
+ async (request, reply) => {
623
+ const url = request.url;
624
+ if (url.startsWith("/api/") || url.startsWith("/ws") || url.startsWith("/health")) {
625
+ return reply.status(404).send({
626
+ error: "Not Found",
627
+ code: "NOT_FOUND"
628
+ });
629
+ }
630
+ return reply.sendFile("index.html");
631
+ }
632
+ );
633
+ logger.info("Web client registered successfully");
634
+ }
635
+
522
636
  // src/domain/value-objects/tunnel-id.ts
523
637
  var TunnelId = class _TunnelId {
524
638
  _value;
@@ -1010,18 +1124,18 @@ var WebSocketServerWrapper = class {
1010
1124
  } catch {
1011
1125
  }
1012
1126
  });
1013
- const closePromise = new Promise((resolve, reject) => {
1127
+ const closePromise = new Promise((resolve2, reject) => {
1014
1128
  wss.close((err) => {
1015
1129
  if (err) {
1016
1130
  this.logger.error({ error: err }, "Error closing WebSocket server");
1017
1131
  reject(err);
1018
1132
  } else {
1019
1133
  this.logger.info("WebSocket server closed gracefully");
1020
- resolve();
1134
+ resolve2();
1021
1135
  }
1022
1136
  });
1023
1137
  });
1024
- const timeoutPromise = new Promise((resolve) => {
1138
+ const timeoutPromise = new Promise((resolve2) => {
1025
1139
  setTimeout(() => {
1026
1140
  this.logger.warn(
1027
1141
  { timeoutMs, remainingClients: wss.clients.size },
@@ -1033,7 +1147,7 @@ var WebSocketServerWrapper = class {
1033
1147
  } catch {
1034
1148
  }
1035
1149
  });
1036
- resolve();
1150
+ resolve2();
1037
1151
  }, timeoutMs);
1038
1152
  });
1039
1153
  await Promise.race([closePromise, timeoutPromise]);
@@ -2287,6 +2401,12 @@ async function bootstrap() {
2287
2401
  httpClientOperations,
2288
2402
  logger
2289
2403
  });
2404
+ if (env.WEB_CLIENT_PATH) {
2405
+ await registerWebClientRoute(app, {
2406
+ webClientPath: env.WEB_CLIENT_PATH,
2407
+ logger
2408
+ });
2409
+ }
2290
2410
  const wsServer = new WebSocketServerWrapper(
2291
2411
  {
2292
2412
  path: env.WS_PATH || WEBSOCKET_CONFIG.PATH,
@@ -2395,6 +2515,11 @@ bootstrap().catch((error) => {
2395
2515
  * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2396
2516
  * @license FSL-1.1-NC
2397
2517
  */
2518
+ /**
2519
+ * @file web-client-route.ts
2520
+ * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
2521
+ * @license FSL-1.1-NC
2522
+ */
2398
2523
  /**
2399
2524
  * @file tunnel-id.ts
2400
2525
  * @copyright 2025 Roman Barinov <rbarinov@gmail.com>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiflis-io/tiflis-code-tunnel",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Tunnel server for tiflis-code - reverse proxy for workstation connections",
5
5
  "author": "Roman Barinov <rbarinov@gmail.com>",
6
6
  "license": "FSL-1.1-NC",
@@ -33,21 +33,22 @@
33
33
  "dist"
34
34
  ],
35
35
  "dependencies": {
36
+ "@fastify/static": "^8.3.0",
37
+ "dotenv": "^16.4.7",
36
38
  "fastify": "^5.2.1",
37
- "ws": "^8.18.0",
38
- "zod": "^3.24.1",
39
+ "nanoid": "^5.0.9",
39
40
  "pino": "^9.5.0",
40
41
  "pino-pretty": "^13.0.0",
41
- "dotenv": "^16.4.7",
42
- "nanoid": "^5.0.9"
42
+ "ws": "^8.18.0",
43
+ "zod": "^3.24.1"
43
44
  },
44
45
  "devDependencies": {
45
46
  "@eslint/js": "^9.17.0",
46
47
  "@types/node": "^22.10.2",
47
48
  "@types/ws": "^8.5.13",
48
49
  "eslint": "^9.17.0",
49
- "tsx": "^4.19.2",
50
50
  "tsup": "^8.3.5",
51
+ "tsx": "^4.19.2",
51
52
  "typescript": "^5.7.2",
52
53
  "typescript-eslint": "^8.18.1",
53
54
  "vitest": "^2.1.8"