termbridge 0.1.1 → 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/README.md CHANGED
@@ -24,7 +24,7 @@ Visit `https://termbridge.dev` for the full docs, architecture notes, and troubl
24
24
  ## CLI usage
25
25
 
26
26
  ```bash
27
- termbridge --port 7000 --session dev --kill-on-exit --no-qr --tunnel cloudflare
27
+ termbridge --port 8080 --session dev --kill-on-exit --no-qr --tunnel cloudflare
28
28
  ```
29
29
 
30
30
  Flags:
package/dist/bin.js CHANGED
@@ -1788,6 +1788,22 @@ var parseArgs = (argv) => {
1788
1788
  options.port = port;
1789
1789
  continue;
1790
1790
  }
1791
+ if (current === "--proxy") {
1792
+ const proxy = parseNumber(args.shift());
1793
+ if (!proxy || proxy <= 0) {
1794
+ throw new Error("invalid proxy port");
1795
+ }
1796
+ options.proxy = proxy;
1797
+ continue;
1798
+ }
1799
+ if (current === "--dev-proxy-url") {
1800
+ const url = args.shift();
1801
+ if (!url) {
1802
+ throw new Error("missing dev proxy URL");
1803
+ }
1804
+ options.devProxyUrl = url;
1805
+ continue;
1806
+ }
1791
1807
  if (current === "--session") {
1792
1808
  const session = args.shift();
1793
1809
  if (!session) {
@@ -1825,6 +1841,7 @@ Usage:
1825
1841
 
1826
1842
  Options:
1827
1843
  --port <port> Bind the local server to a fixed port
1844
+ --proxy <port> Proxy a local dev server (e.g., Vite) through termbridge
1828
1845
  --session <name> Use a specific tmux session name
1829
1846
  --kill-on-exit Kill the tmux session when the CLI exits
1830
1847
  --no-qr Disable QR code output
@@ -2089,6 +2106,7 @@ import { randomBytes, createHash } from "crypto";
2089
2106
  var SESSION_COOKIE_NAME = "termbridge_session";
2090
2107
  var hashToken = (token) => createHash("sha256").update(token).digest("hex");
2091
2108
  var createSessionId = () => randomBytes(18).toString("base64url");
2109
+ var createCsrfToken = () => randomBytes(24).toString("base64url");
2092
2110
  var parseCookies = (cookieHeader) => {
2093
2111
  const cookies = {};
2094
2112
  if (!cookieHeader) {
@@ -2136,6 +2154,7 @@ var createAuth = ({
2136
2154
  record.consumed = true;
2137
2155
  const session = {
2138
2156
  id: createSessionId(),
2157
+ csrfToken: createCsrfToken(),
2139
2158
  createdAt: clock(),
2140
2159
  lastSeen: clock()
2141
2160
  };
@@ -2179,12 +2198,20 @@ var createAuth = ({
2179
2198
  }
2180
2199
  return parts.join("; ");
2181
2200
  };
2201
+ const verifyCsrfToken = (sessionId, csrfToken) => {
2202
+ const session = sessions.get(sessionId);
2203
+ if (!session) {
2204
+ return false;
2205
+ }
2206
+ return session.csrfToken === csrfToken;
2207
+ };
2182
2208
  return {
2183
2209
  issueToken,
2184
2210
  redeemToken,
2185
2211
  getSession,
2186
2212
  getSessionFromRequest,
2187
- createSessionCookie
2213
+ createSessionCookie,
2214
+ verifyCsrfToken
2188
2215
  };
2189
2216
  };
2190
2217
 
@@ -2236,9 +2263,9 @@ var createTerminalRegistry = () => {
2236
2263
  };
2237
2264
 
2238
2265
  // src/server/server.ts
2239
- import { createServer as createHttpServer } from "http";
2266
+ import { createServer as createHttpServer, request as httpRequest } from "http";
2240
2267
  import { randomBytes as randomBytes3 } from "crypto";
2241
- import { WebSocketServer } from "ws";
2268
+ import { WebSocketServer, WebSocket as WsWebSocket } from "ws";
2242
2269
 
2243
2270
  // ../packages/shared/src/index.ts
2244
2271
  var TERMINAL_CONTROL_KEYS = [
@@ -2303,6 +2330,9 @@ var createStaticHandler = (uiDistPath, basePath) => {
2303
2330
  };
2304
2331
 
2305
2332
  // src/server/server.ts
2333
+ var MAX_HTTP_BODY_SIZE = 64 * 1024;
2334
+ var MAX_WS_MESSAGE_SIZE = 1024 * 1024;
2335
+ var MAX_INPUT_LENGTH = 64 * 1024;
2306
2336
  var jsonResponse = (response, status, payload) => {
2307
2337
  const body = JSON.stringify(payload);
2308
2338
  response.statusCode = status;
@@ -2312,7 +2342,12 @@ var jsonResponse = (response, status, payload) => {
2312
2342
  };
2313
2343
  var readJsonBody = async (request) => {
2314
2344
  const chunks = [];
2345
+ let totalSize = 0;
2315
2346
  for await (const chunk of request) {
2347
+ totalSize += chunk.length;
2348
+ if (totalSize > MAX_HTTP_BODY_SIZE) {
2349
+ return "too_large";
2350
+ }
2316
2351
  chunks.push(chunk);
2317
2352
  }
2318
2353
  const body = Buffer.concat(chunks).toString("utf8").trim();
@@ -2338,21 +2373,28 @@ var isAllowedOrigin = (origin, host) => {
2338
2373
  };
2339
2374
  var allowedControlKeys = new Set(TERMINAL_CONTROL_KEYS);
2340
2375
  var parseClientMessage = (payload) => {
2376
+ const size = typeof payload === "string" ? payload.length : payload.byteLength;
2377
+ if (size > MAX_WS_MESSAGE_SIZE) {
2378
+ return { ok: false, error: "too_large" };
2379
+ }
2341
2380
  const text = typeof payload === "string" ? payload : payload.toString();
2342
2381
  try {
2343
2382
  const parsed = JSON.parse(text);
2344
2383
  if (parsed.type === "input" && typeof parsed.data === "string") {
2345
- return parsed;
2384
+ if (parsed.data.length > MAX_INPUT_LENGTH) {
2385
+ return { ok: false, error: "too_large" };
2386
+ }
2387
+ return { ok: true, message: parsed };
2346
2388
  }
2347
2389
  if (parsed.type === "resize" && typeof parsed.cols === "number" && typeof parsed.rows === "number") {
2348
- return parsed;
2390
+ return { ok: true, message: parsed };
2349
2391
  }
2350
2392
  if (parsed.type === "control" && allowedControlKeys.has(parsed.key)) {
2351
- return parsed;
2393
+ return { ok: true, message: parsed };
2352
2394
  }
2353
- return null;
2395
+ return { ok: false, error: "invalid" };
2354
2396
  } catch {
2355
- return null;
2397
+ return { ok: false, error: "invalid" };
2356
2398
  }
2357
2399
  };
2358
2400
  var sendWsMessage = (socket, message) => {
@@ -2360,24 +2402,44 @@ var sendWsMessage = (socket, message) => {
2360
2402
  };
2361
2403
  var createSessionName = () => `termbridge-${randomBytes3(4).toString("hex")}`;
2362
2404
  var createAppServer = (deps) => {
2363
- const staticHandler = createStaticHandler(deps.uiDistPath, "/app");
2405
+ const staticHandler = createStaticHandler(deps.uiDistPath, "/__tb/app");
2364
2406
  const wss = new WebSocketServer({ noServer: true });
2365
2407
  const connectionInfo = /* @__PURE__ */ new WeakMap();
2408
+ const proxyRequest = (request, response, targetPath, search) => {
2409
+ const targetUrl = `http://localhost:${deps.proxyPort}${targetPath}${search}`;
2410
+ const proxyHeaders = { ...request.headers };
2411
+ delete proxyHeaders.cookie;
2412
+ delete proxyHeaders.host;
2413
+ proxyHeaders.host = `localhost:${deps.proxyPort}`;
2414
+ const proxyReq = httpRequest(
2415
+ targetUrl,
2416
+ { method: request.method, headers: proxyHeaders },
2417
+ (proxyRes) => {
2418
+ response.writeHead(proxyRes.statusCode, proxyRes.headers);
2419
+ proxyRes.pipe(response);
2420
+ }
2421
+ );
2422
+ proxyReq.on("error", () => {
2423
+ response.statusCode = 502;
2424
+ response.end("proxy error");
2425
+ });
2426
+ request.pipe(proxyReq);
2427
+ };
2366
2428
  const server = createHttpServer(async (request, response) => {
2367
2429
  const url = new URL(request.url, `http://${request.headers.host}`);
2368
- if (request.method === "GET" && url.pathname === "/healthz") {
2430
+ if (request.method === "GET" && url.pathname === "/__tb/healthz") {
2369
2431
  response.statusCode = 200;
2370
2432
  response.end("ok");
2371
2433
  return;
2372
2434
  }
2373
- if (request.method === "GET" && url.pathname === "/") {
2435
+ if (request.method === "GET" && url.pathname === "/" && !deps.proxyPort) {
2374
2436
  response.statusCode = 302;
2375
- response.setHeader("Location", "/app");
2437
+ response.setHeader("Location", "/__tb/app");
2376
2438
  response.end();
2377
2439
  return;
2378
2440
  }
2379
- if (request.method === "GET" && url.pathname.startsWith("/s/")) {
2380
- const token = url.pathname.slice(3);
2441
+ if (request.method === "GET" && url.pathname.startsWith("/__tb/s/")) {
2442
+ const token = url.pathname.slice("/__tb/s/".length);
2381
2443
  const ip = getIp(request);
2382
2444
  if (!deps.redemptionLimiter.allow(ip)) {
2383
2445
  response.statusCode = 429;
@@ -2392,11 +2454,26 @@ var createAppServer = (deps) => {
2392
2454
  }
2393
2455
  response.statusCode = 302;
2394
2456
  response.setHeader("Set-Cookie", deps.auth.createSessionCookie(session.id));
2395
- response.setHeader("Location", "/app");
2457
+ response.setHeader("Location", "/__tb/app");
2396
2458
  response.end();
2397
2459
  return;
2398
2460
  }
2399
- if (url.pathname === "/api/terminals") {
2461
+ if (url.pathname === "/__tb/api/csrf") {
2462
+ const session = deps.auth.getSessionFromRequest(request);
2463
+ if (!session) {
2464
+ response.statusCode = 401;
2465
+ response.end("unauthorized");
2466
+ return;
2467
+ }
2468
+ if (request.method === "GET") {
2469
+ jsonResponse(response, 200, { csrfToken: session.csrfToken });
2470
+ return;
2471
+ }
2472
+ response.statusCode = 404;
2473
+ response.end("not found");
2474
+ return;
2475
+ }
2476
+ if (url.pathname === "/__tb/api/terminals") {
2400
2477
  const session = deps.auth.getSessionFromRequest(request);
2401
2478
  if (!session) {
2402
2479
  response.statusCode = 401;
@@ -2410,6 +2487,11 @@ var createAppServer = (deps) => {
2410
2487
  }
2411
2488
  if (request.method === "POST") {
2412
2489
  const body = await readJsonBody(request);
2490
+ if (body === "too_large") {
2491
+ response.statusCode = 413;
2492
+ response.end("request body too large");
2493
+ return;
2494
+ }
2413
2495
  if (body && typeof body !== "object") {
2414
2496
  response.statusCode = 400;
2415
2497
  response.end("invalid body");
@@ -2422,15 +2504,48 @@ var createAppServer = (deps) => {
2422
2504
  return;
2423
2505
  }
2424
2506
  }
2507
+ if (request.method === "GET" && url.pathname === "/__tb/api/config") {
2508
+ const session = deps.auth.getSessionFromRequest(request);
2509
+ if (!session) {
2510
+ response.statusCode = 401;
2511
+ response.end("unauthorized");
2512
+ return;
2513
+ }
2514
+ jsonResponse(response, 200, {
2515
+ proxyPort: deps.proxyPort ?? null,
2516
+ devProxyUrl: deps.devProxyUrl ?? null
2517
+ });
2518
+ return;
2519
+ }
2425
2520
  const handled = await staticHandler(request, response);
2426
2521
  if (!handled) {
2522
+ if (deps.proxyPort && deps.auth.getSessionFromRequest(request)) {
2523
+ proxyRequest(request, response, url.pathname, url.search);
2524
+ return;
2525
+ }
2427
2526
  response.statusCode = 404;
2428
2527
  response.end("not found");
2429
2528
  }
2430
2529
  });
2431
2530
  server.on("upgrade", (request, socket, head) => {
2432
2531
  const url = new URL(request.url, `http://${request.headers.host}`);
2433
- if (!url.pathname.startsWith("/ws/terminal/")) {
2532
+ if (!url.pathname.startsWith("/__tb/ws/terminal/")) {
2533
+ if (deps.proxyPort && deps.auth.getSessionFromRequest(request)) {
2534
+ const targetUrl = `ws://localhost:${deps.proxyPort}${url.pathname}${url.search}`;
2535
+ const proxyWs = new WsWebSocket(targetUrl);
2536
+ proxyWs.on("open", () => {
2537
+ wss.handleUpgrade(request, socket, head, (clientWs) => {
2538
+ clientWs.on("message", (data) => proxyWs.send(data));
2539
+ proxyWs.on("message", (data) => clientWs.send(data));
2540
+ clientWs.on("close", () => proxyWs.close());
2541
+ proxyWs.on("close", () => clientWs.close());
2542
+ });
2543
+ });
2544
+ proxyWs.on("error", () => {
2545
+ socket.destroy();
2546
+ });
2547
+ return;
2548
+ }
2434
2549
  socket.destroy();
2435
2550
  return;
2436
2551
  }
@@ -2453,6 +2568,11 @@ var createAppServer = (deps) => {
2453
2568
  socket.destroy();
2454
2569
  return;
2455
2570
  }
2571
+ const csrfToken = url.searchParams.get("csrf");
2572
+ if (!csrfToken || !deps.auth.verifyCsrfToken(session.id, csrfToken)) {
2573
+ socket.destroy();
2574
+ return;
2575
+ }
2456
2576
  const record = deps.terminalRegistry.get(terminalId);
2457
2577
  if (!record) {
2458
2578
  socket.destroy();
@@ -2470,15 +2590,16 @@ var createAppServer = (deps) => {
2470
2590
  sendWsMessage(socket, { type: "output", data });
2471
2591
  });
2472
2592
  socket.on("message", (payload) => {
2473
- const message = parseClientMessage(payload);
2474
- if (!message) {
2593
+ const result = parseClientMessage(payload);
2594
+ if (!result.ok) {
2475
2595
  sendWsMessage(socket, {
2476
2596
  type: "status",
2477
2597
  state: "error",
2478
- message: "invalid payload"
2598
+ message: result.error === "too_large" ? "message too large" : "invalid payload"
2479
2599
  });
2480
2600
  return;
2481
2601
  }
2602
+ const message = result.message;
2482
2603
  if (message.type === "input") {
2483
2604
  void deps.terminalBackend.write(info.sessionName, message.data);
2484
2605
  return;
@@ -2564,7 +2685,9 @@ var startCommand = async (options, deps = {}) => {
2564
2685
  uiDistPath: resolveUiDistPath(),
2565
2686
  auth,
2566
2687
  terminalRegistry,
2567
- terminalBackend
2688
+ terminalBackend,
2689
+ proxyPort: options.proxy,
2690
+ devProxyUrl: options.devProxyUrl
2568
2691
  });
2569
2692
  const started = await server.listen(options.port ?? 0);
2570
2693
  const localUrl = `http://127.0.0.1:${started.port}`;
@@ -2589,7 +2712,7 @@ var startCommand = async (options, deps = {}) => {
2589
2712
  await started.close();
2590
2713
  throw error;
2591
2714
  }
2592
- const redeemUrl = `${publicUrl}/s/${token}`;
2715
+ const redeemUrl = `${publicUrl}/__tb/s/${token}`;
2593
2716
  logger.info(`Local server: ${localUrl}`);
2594
2717
  logger.info(`Tunnel URL: ${redeemUrl}`);
2595
2718
  if (!options.noQr && deps.qr) {