@claxedo/workspace-relay 0.1.0 → 0.5.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.mjs CHANGED
@@ -13,9 +13,9 @@ import {
13
13
 
14
14
  // src/bun.ts
15
15
  import {
16
- isTunnelMessage,
17
16
  makeTunnelPong,
18
- TUNNEL_PROTOCOL_VERSION
17
+ TUNNEL_PROTOCOL_VERSION,
18
+ validateTunnelMessage
19
19
  } from "@claxedo/workspace-relay-protocol";
20
20
 
21
21
  // src/server.ts
@@ -63,6 +63,9 @@ function roleClaim(payload) {
63
63
  const value = stringClaim(payload, "role");
64
64
  return value === "viewer" || value === "editor" || value === "admin" || value === "owner" ? value : void 0;
65
65
  }
66
+ function isRelayClaimPair(input) {
67
+ return input.access === "cloud" && input.backing === "cloud-vm" || input.access === "user-hosted" && input.backing === "local-worktree";
68
+ }
66
69
  function checkHostTunnelTarget(payload, expected) {
67
70
  if (stringClaim(payload, "host_id") !== expected.hostId) {
68
71
  throw new WorkspaceRelayAuthError("relay_token_host_mismatch", "Relay token host does not match request");
@@ -113,6 +116,18 @@ async function verifyRuntimeAccessToken(token, key, expected) {
113
116
  }
114
117
  return claims;
115
118
  }
119
+ function validateRuntimeAccessTokenClaims(input, expected) {
120
+ const payload = input;
121
+ if (stringClaim(payload, "iss") !== runtimeAccessTokenIssuer || stringClaim(payload, "aud") !== runtimeAccessTokenAudience) {
122
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token issuer or audience is invalid");
123
+ }
124
+ checkTarget(payload, expected);
125
+ const claims = runtimeClaims(payload);
126
+ if (!claims) {
127
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token claims are incomplete");
128
+ }
129
+ return claims;
130
+ }
116
131
  async function verifyHostTunnelToken(token, key, expected) {
117
132
  const result = await verifyJwt(token, key, {
118
133
  issuer: runtimeAccessTokenIssuer,
@@ -126,6 +141,9 @@ async function verifyHostTunnelToken(token, key, expected) {
126
141
  return claims;
127
142
  }
128
143
  async function mintRelayHostToken(input, key, alg) {
144
+ if (!isRelayClaimPair(input)) {
145
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Relay Host Token access/backing claims are inconsistent");
146
+ }
129
147
  const now = seconds(input.now);
130
148
  const protectedHeader = { alg: requireAlgorithm(alg) };
131
149
  if (input.kid) protectedHeader.kid = input.kid;
@@ -197,6 +215,70 @@ function errorBody(code, message) {
197
215
  }
198
216
 
199
217
  // src/server.ts
218
+ var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
219
+ var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
220
+ var RESOLVER_CACHE_MAX_ENTRIES = 8192;
221
+ var RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 1e4;
222
+ var relayHostTokenCaches = /* @__PURE__ */ new WeakMap();
223
+ var runtimeAccessTokenCaches = /* @__PURE__ */ new WeakMap();
224
+ function pruneExpiringCache(cache, now, maxEntries) {
225
+ for (const [key, entry] of cache) {
226
+ if (entry.expiresAt <= now) cache.delete(key);
227
+ }
228
+ while (cache.size >= maxEntries) {
229
+ const key = cache.keys().next().value;
230
+ if (!key) return;
231
+ cache.delete(key);
232
+ }
233
+ }
234
+ function createCachedRevocationClient(inner, options = {}) {
235
+ const ttlMs = options.ttlMs ?? 1e4;
236
+ const now = options.now ?? Date.now;
237
+ const cache = /* @__PURE__ */ new Map();
238
+ return async (args) => {
239
+ const at = now();
240
+ const entry = cache.get(args.jti);
241
+ if (entry && entry.expiresAt > at) {
242
+ if (entry.result) return entry.result;
243
+ if (entry.promise) return await entry.promise;
244
+ }
245
+ const promise = inner(args).then((result) => {
246
+ pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
247
+ cache.set(args.jti, { result, expiresAt: now() + ttlMs });
248
+ return result;
249
+ }).catch((err) => {
250
+ cache.delete(args.jti);
251
+ throw err;
252
+ });
253
+ cache.set(args.jti, { promise, expiresAt: at + ttlMs });
254
+ return await promise;
255
+ };
256
+ }
257
+ function createCachedTargetClient(inner, options = {}) {
258
+ const ttlMs = options.ttlMs ?? 5e3;
259
+ const now = options.now ?? Date.now;
260
+ const cache = /* @__PURE__ */ new Map();
261
+ return async (args) => {
262
+ const key = `${args.workspaceId}\0${args.hostId}`;
263
+ const at = now();
264
+ const entry = cache.get(key);
265
+ if (entry && entry.expiresAt > at) {
266
+ if (entry.result) return entry.result;
267
+ if (entry.promise) return await entry.promise;
268
+ }
269
+ const promise = inner(args).then((result) => {
270
+ pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
271
+ if (result) cache.set(key, { result, expiresAt: now() + ttlMs });
272
+ else cache.delete(key);
273
+ return result;
274
+ }).catch((err) => {
275
+ cache.delete(key);
276
+ throw err;
277
+ });
278
+ cache.set(key, { promise, expiresAt: at + ttlMs });
279
+ return await promise;
280
+ };
281
+ }
200
282
  function protocolToken(header) {
201
283
  return header?.split(",").map((item) => item.trim()).find((item) => item.startsWith("claxedo-rat."))?.slice("claxedo-rat.".length);
202
284
  }
@@ -209,6 +291,16 @@ var DANGEROUS_INBOUND_HEADERS = [
209
291
  "x-forwarded-proto",
210
292
  "x-real-ip"
211
293
  ];
294
+ var HOP_BY_HOP_HEADERS = [
295
+ "connection",
296
+ "keep-alive",
297
+ "proxy-authenticate",
298
+ "proxy-authorization",
299
+ "te",
300
+ "trailer",
301
+ "transfer-encoding",
302
+ "upgrade"
303
+ ];
212
304
  var DANGEROUS_INBOUND_HEADER_PATTERNS = [
213
305
  /^x-claxedo-internal-/i,
214
306
  /^x-supervisor-/i
@@ -216,6 +308,7 @@ var DANGEROUS_INBOUND_HEADER_PATTERNS = [
216
308
  function isDangerousInboundHeader(name) {
217
309
  const lower = name.toLowerCase();
218
310
  if (DANGEROUS_INBOUND_HEADERS.includes(lower)) return true;
311
+ if (HOP_BY_HOP_HEADERS.includes(lower)) return true;
219
312
  for (const pattern of DANGEROUS_INBOUND_HEADER_PATTERNS) {
220
313
  if (pattern.test(lower)) return true;
221
314
  }
@@ -231,9 +324,16 @@ function forwardHeaders(input, relayHostToken, workspaceId, options = {}) {
231
324
  }
232
325
  if (options.userHosted) headers2.delete("cookie");
233
326
  headers2.set("accept-encoding", "identity");
234
- headers2.set("authorization", `Bearer ${relayHostToken}`);
327
+ headers2.delete("authorization");
328
+ headers2.delete("Authorization");
329
+ headers2.set("Authorization", `Bearer ${relayHostToken}`);
235
330
  headers2.set("x-workspace-id", workspaceId);
236
331
  headers2.set("X-Daytona-Skip-Preview-Warning", "true");
332
+ headers2.set("X-Daytona-Skip-Last-Activity-Update", "true");
333
+ for (const [name, value] of Object.entries(options.upstreamHeaders ?? {})) {
334
+ const trimmed = value.trim();
335
+ if (trimmed) headers2.set(name, trimmed);
336
+ }
237
337
  headers2.set("x-forwarded-by", "workspace-relay");
238
338
  return headers2;
239
339
  }
@@ -243,8 +343,155 @@ function targetUrl(target, path, search) {
243
343
  return url;
244
344
  }
245
345
  function roleAllowsRelayRequest(role, method) {
246
- if (role !== "viewer") return true;
247
- return method === "GET" || method === "HEAD" || method === "OPTIONS";
346
+ if (role === "owner" || role === "admin" || role === "editor") return true;
347
+ if (role === "viewer") return method === "GET" || method === "HEAD" || method === "OPTIONS";
348
+ return false;
349
+ }
350
+ function relayHostTokenCacheTtlMs(options, claims) {
351
+ const configured = options.relayHostTokenCacheTtlMs ?? 3e4;
352
+ if (!Number.isFinite(configured) || configured <= 0) return 0;
353
+ const tokenRemaining = claims.exp * 1e3 - Date.now() - 5e3;
354
+ return Math.max(0, Math.min(configured, tokenRemaining));
355
+ }
356
+ function relayHostTokenCacheKey(options, claims, target) {
357
+ return [
358
+ claims.jti,
359
+ claims.sub,
360
+ claims.org_id,
361
+ claims.role,
362
+ claims.workspace_id,
363
+ claims.host_id,
364
+ target.access,
365
+ target.backing,
366
+ options.relayHostMintKid ?? ""
367
+ ].join("\0");
368
+ }
369
+ function relayHostTokenCache(options) {
370
+ let cache = relayHostTokenCaches.get(options);
371
+ if (cache) return cache;
372
+ cache = /* @__PURE__ */ new Map();
373
+ relayHostTokenCaches.set(options, cache);
374
+ return cache;
375
+ }
376
+ function runtimeAccessTokenCache(options) {
377
+ let cache = runtimeAccessTokenCaches.get(options);
378
+ if (cache) return cache;
379
+ cache = /* @__PURE__ */ new Map();
380
+ runtimeAccessTokenCaches.set(options, cache);
381
+ return cache;
382
+ }
383
+ function runtimeAccessTokenCacheTtlMs(options, claims, now) {
384
+ const configured = options.runtimeAccessTokenCacheTtlMs ?? RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT;
385
+ if (!Number.isFinite(configured) || configured <= 0) return 0;
386
+ const tokenRemaining = claims.exp * 1e3 - now - 5e3;
387
+ return Math.max(0, Math.min(configured, tokenRemaining));
388
+ }
389
+ function pruneRuntimeAccessTokenCache(cache, now) {
390
+ for (const [key, entry] of cache) {
391
+ if (entry.expiresAt <= now) cache.delete(key);
392
+ }
393
+ while (cache.size >= RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES) {
394
+ const key = cache.keys().next().value;
395
+ if (!key) return;
396
+ cache.delete(key);
397
+ }
398
+ }
399
+ async function verifyRuntimeAccessTokenForRequest(options, token, workspaceId) {
400
+ const verified = options.tokenVerifier ? await options.tokenVerifier.verify(token).then(
401
+ (result) => validateRuntimeAccessTokenClaims(result.claims, { workspaceId })
402
+ ) : await verifyRuntimeAccessToken(token, options.runtimeAccessKey, { workspaceId });
403
+ return verified;
404
+ }
405
+ async function cachedRuntimeAccessTokenClaims(options, token, workspaceId) {
406
+ const configured = options.runtimeAccessTokenCacheTtlMs ?? RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT;
407
+ if (!Number.isFinite(configured) || configured <= 0) {
408
+ return await verifyRuntimeAccessTokenForRequest(options, token, workspaceId);
409
+ }
410
+ const now = Date.now();
411
+ const cache = runtimeAccessTokenCache(options);
412
+ const key = `${workspaceId}\0${token}`;
413
+ const cached = cache.get(key);
414
+ if (cached && cached.expiresAt > now) {
415
+ if (cached.claims) return cached.claims;
416
+ if (cached.promise) return await cached.promise;
417
+ }
418
+ let promise;
419
+ try {
420
+ promise = verifyRuntimeAccessTokenForRequest(options, token, workspaceId);
421
+ pruneRuntimeAccessTokenCache(cache, now);
422
+ cache.set(key, { promise, expiresAt: now + configured });
423
+ const claims = await promise;
424
+ const ttlMs = runtimeAccessTokenCacheTtlMs(options, claims, now);
425
+ if (ttlMs > 0) cache.set(key, { claims, expiresAt: now + ttlMs });
426
+ else cache.delete(key);
427
+ return claims;
428
+ } catch (err) {
429
+ if (promise && cache.get(key)?.promise === promise) cache.delete(key);
430
+ throw err;
431
+ }
432
+ }
433
+ function pruneRelayHostTokenCache(cache, now) {
434
+ for (const [key, entry] of cache) {
435
+ if (entry.expiresAt <= now) cache.delete(key);
436
+ }
437
+ while (cache.size >= RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES) {
438
+ const key = cache.keys().next().value;
439
+ if (!key) return;
440
+ cache.delete(key);
441
+ }
442
+ }
443
+ async function relayHostTokenFor(options, claims, target, trace) {
444
+ const ttlMs = relayHostTokenCacheTtlMs(options, claims);
445
+ if (ttlMs <= 0) {
446
+ return await uncachedRelayHostTokenFor(options, claims, target, trace);
447
+ }
448
+ const key = relayHostTokenCacheKey(options, claims, target);
449
+ const now = Date.now();
450
+ const cache = relayHostTokenCache(options);
451
+ const cached = cache.get(key);
452
+ if (cached && cached.expiresAt > now) {
453
+ if (cached.promise) {
454
+ return await trace.span("rht-cache", async () => await cached.promise);
455
+ }
456
+ await trace.span("rht-cache", async () => {
457
+ });
458
+ return cached.token;
459
+ }
460
+ let promise;
461
+ try {
462
+ const token = await trace.span("rht-mint", async () => {
463
+ promise = mintRelayHostToken({
464
+ subject: claims.sub,
465
+ orgId: claims.org_id,
466
+ role: claims.role,
467
+ ...target,
468
+ ...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
469
+ }, options.relayHostSigningKey, options.relayHostAlgorithm);
470
+ pruneRelayHostTokenCache(cache, now);
471
+ cache.set(key, { promise, expiresAt: now + ttlMs });
472
+ return await promise;
473
+ });
474
+ cache.set(key, {
475
+ token,
476
+ expiresAt: now + ttlMs
477
+ });
478
+ return token;
479
+ } catch (err) {
480
+ if (promise && cache.get(key)?.promise === promise) cache.delete(key);
481
+ throw err;
482
+ }
483
+ }
484
+ async function uncachedRelayHostTokenFor(options, claims, target, trace) {
485
+ return await trace.span(
486
+ "rht-mint",
487
+ async () => await mintRelayHostToken({
488
+ subject: claims.sub,
489
+ orgId: claims.org_id,
490
+ role: claims.role,
491
+ ...target,
492
+ ...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
493
+ }, options.relayHostSigningKey, options.relayHostAlgorithm)
494
+ );
248
495
  }
249
496
  function workspaceRelayTargetUrl(target, path, search) {
250
497
  return targetUrl(target, path, search);
@@ -252,6 +499,18 @@ function workspaceRelayTargetUrl(target, path, search) {
252
499
  function workspaceRelayForwardHeaders(input, relayHostToken, workspaceId, options = {}) {
253
500
  return forwardHeaders(input, relayHostToken, workspaceId, options);
254
501
  }
502
+ function workspaceRelayForwardRequestInit(request, relayHostToken, workspaceId, options = {}) {
503
+ const init = {
504
+ method: request.method,
505
+ headers: forwardHeaders(request.headers, relayHostToken, workspaceId, options),
506
+ body: request.body,
507
+ duplex: "half",
508
+ redirect: "manual",
509
+ ...options.signal ? { signal: options.signal } : {}
510
+ };
511
+ if (request.method === "GET" || request.method === "HEAD") delete init.body;
512
+ return init;
513
+ }
255
514
  var auditSamplers = /* @__PURE__ */ new WeakMap();
256
515
  function getOrCreateAuditSampler(options) {
257
516
  let state = auditSamplers.get(options);
@@ -309,6 +568,38 @@ function disposeAuditSampler(options) {
309
568
  flushSuppressedSummary(options, state);
310
569
  auditSamplers.delete(options);
311
570
  }
571
+ function createWorkspaceRelayTrace() {
572
+ const phases = [];
573
+ return {
574
+ phases,
575
+ async span(name, run) {
576
+ const startedAt = performance.now();
577
+ try {
578
+ return await run();
579
+ } finally {
580
+ phases.push({ name, ms: performance.now() - startedAt });
581
+ }
582
+ }
583
+ };
584
+ }
585
+ function roundedMs(value) {
586
+ return Math.round(value * 100) / 100;
587
+ }
588
+ function workspaceRelayServerTiming(trace) {
589
+ return trace.phases.map((phase) => `${phase.name};dur=${roundedMs(phase.ms)}`).join(", ");
590
+ }
591
+ function workspaceRelayTimingResponse(response, trace) {
592
+ const value = workspaceRelayServerTiming(trace);
593
+ if (!value) return response;
594
+ const headers2 = new Headers(response.headers);
595
+ const prev = headers2.get("server-timing");
596
+ headers2.set("server-timing", prev ? `${prev}, ${value}` : value);
597
+ return new Response(response.body, {
598
+ status: response.status,
599
+ statusText: response.statusText,
600
+ headers: headers2
601
+ });
602
+ }
312
603
  async function audit(options, event) {
313
604
  if (event.action === "relay.request.accepted") {
314
605
  const state = getOrCreateAuditSampler(options);
@@ -329,7 +620,7 @@ function denyCorsHeaders(request) {
329
620
  if (!allowed) return void 0;
330
621
  return {
331
622
  "access-control-allow-origin": origin,
332
- "access-control-allow-headers": "Accept, Authorization, Content-Type, X-Daytona-Skip-Preview-Warning, X-Workspace-Id",
623
+ "access-control-allow-headers": "Accept, Authorization, Content-Type, Last-Event-ID, X-Fetch-Bypass-Throttle, X-Daytona-Skip-Preview-Warning, X-Workspace-Id, X-OpenCode-Directory, X-Claxedo-Runner, X-Claxedo-Model, X-Claxedo-Draft-Id, X-Claxedo-Binary",
333
624
  "access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"
334
625
  };
335
626
  }
@@ -351,11 +642,12 @@ async function deny(options, input) {
351
642
  headers: denyCorsHeaders(input.request)
352
643
  });
353
644
  }
354
- async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
645
+ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, trace) {
355
646
  const token = runtimeAccessToken(request);
356
647
  if (!token) {
357
648
  return {
358
649
  ok: false,
650
+ code: "runtime_access_token_required",
359
651
  response: await deny(options, {
360
652
  code: "runtime_access_token_required",
361
653
  message: "Runtime Access Token is required",
@@ -365,14 +657,18 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
365
657
  };
366
658
  }
367
659
  try {
368
- const claims = options.tokenVerifier ? await (async () => {
369
- const verified = await options.tokenVerifier.verify(token);
370
- return verified.claims;
371
- })() : await verifyRuntimeAccessToken(token, options.runtimeAccessKey, { workspaceId });
372
- const active = await options.isRuntimeAccessTokenActive?.(claims) ?? { active: true };
660
+ const span = (name, run) => trace ? trace.span(name, run) : run();
661
+ const claims = await span(
662
+ "rat-verify",
663
+ async () => await cachedRuntimeAccessTokenClaims(options, token, workspaceId)
664
+ );
665
+ const active = await span("rat-active", async () => {
666
+ return await options.isRuntimeAccessTokenActive?.(claims) ?? { active: true };
667
+ });
373
668
  if (!active.active) {
374
669
  return {
375
670
  ok: false,
671
+ code: active.code,
376
672
  response: await deny(options, {
377
673
  code: active.code,
378
674
  message: active.reason,
@@ -385,6 +681,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
385
681
  if (!roleAllowsRelayRequest(claims.role, request.method)) {
386
682
  return {
387
683
  ok: false,
684
+ code: "relay_role_denied",
388
685
  response: await deny(options, {
389
686
  code: "relay_role_denied",
390
687
  message: "Workspace role does not allow this relay request",
@@ -394,10 +691,11 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
394
691
  })
395
692
  };
396
693
  }
397
- const target = await options.resolveTarget(claims);
694
+ const target = await span("target-resolve", async () => await options.resolveTarget(claims));
398
695
  if (!target || target.workspaceId !== claims.workspace_id || target.hostId !== claims.host_id) {
399
696
  return {
400
697
  ok: false,
698
+ code: "relay_target_unavailable",
401
699
  response: await deny(options, {
402
700
  code: "relay_target_unavailable",
403
701
  message: "Workspace relay target is unavailable",
@@ -410,6 +708,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
410
708
  if (target.access === "user-hosted" && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
411
709
  return {
412
710
  ok: false,
711
+ code: "user_hosted_app_offline",
413
712
  response: await deny(options, {
414
713
  code: "user_hosted_app_offline",
415
714
  message: "User-hosted workspace is offline",
@@ -419,7 +718,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
419
718
  })
420
719
  };
421
720
  }
422
- await audit(options, {
721
+ await span("audit", async () => await audit(options, {
423
722
  action: "relay.request.accepted",
424
723
  result: "allow",
425
724
  subject: claims.sub,
@@ -428,22 +727,14 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
428
727
  hostId: claims.host_id,
429
728
  method: request.method,
430
729
  path: new URL(request.url).pathname
431
- });
730
+ }));
731
+ const relayHostToken = await relayHostTokenFor(options, claims, target, { span });
432
732
  return {
433
733
  ok: true,
434
734
  request: {
435
735
  claims,
436
736
  target,
437
- relayHostToken: await mintRelayHostToken({
438
- subject: claims.sub,
439
- orgId: claims.org_id,
440
- workspaceId: claims.workspace_id,
441
- hostId: claims.host_id,
442
- role: claims.role,
443
- access: target.access,
444
- backing: target.backing,
445
- ...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
446
- }, options.relayHostSigningKey, options.relayHostAlgorithm),
737
+ relayHostToken,
447
738
  path: new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/")
448
739
  }
449
740
  };
@@ -451,6 +742,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
451
742
  const code = err instanceof WorkspaceRelayAuthError ? err.code : "relay_request_failed";
452
743
  return {
453
744
  ok: false,
745
+ code,
454
746
  response: await deny(options, {
455
747
  code,
456
748
  message: "Workspace relay request was denied",
@@ -460,28 +752,45 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
460
752
  };
461
753
  }
462
754
  }
463
- async function forwardRequest(request, target, relayHostToken, path, requestFetch) {
464
- const init = {
465
- method: request.method,
466
- headers: forwardHeaders(request.headers, relayHostToken, target.workspaceId),
467
- body: request.body,
468
- duplex: "half",
469
- redirect: "manual"
470
- };
471
- if (request.method === "GET" || request.method === "HEAD") delete init.body;
472
- const upstream = await requestFetch(targetUrl(target, path, new URL(request.url).search), init);
473
- const headers2 = new Headers(upstream.headers);
474
- headers2.delete("access-control-allow-origin");
475
- headers2.delete("access-control-allow-credentials");
476
- headers2.delete("access-control-allow-headers");
477
- headers2.delete("access-control-allow-methods");
478
- headers2.delete("access-control-expose-headers");
479
- headers2.delete("access-control-max-age");
480
- return new Response(upstream.body, {
481
- status: upstream.status,
482
- statusText: upstream.statusText,
483
- headers: headers2
484
- });
755
+ async function forwardWorkspaceRelayRequest(request, target, relayHostToken, path, requestFetch, timeoutMs, trace) {
756
+ const controller = new AbortController();
757
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
758
+ const span = (name, run) => trace ? trace.span(name, run) : run();
759
+ try {
760
+ const upstream = await span("upstream-fetch", async () => await requestFetch(
761
+ targetUrl(target, path, new URL(request.url).search),
762
+ workspaceRelayForwardRequestInit(request, relayHostToken, target.workspaceId, {
763
+ signal: controller.signal,
764
+ upstreamHeaders: target.upstreamHeaders
765
+ })
766
+ ));
767
+ const headers2 = new Headers(upstream.headers);
768
+ headers2.delete("access-control-allow-origin");
769
+ headers2.delete("access-control-allow-credentials");
770
+ headers2.delete("access-control-allow-headers");
771
+ headers2.delete("access-control-allow-methods");
772
+ headers2.delete("access-control-expose-headers");
773
+ headers2.delete("access-control-max-age");
774
+ for (const [key, value] of Object.entries(denyCorsHeaders(request) ?? {})) {
775
+ headers2.set(key, value);
776
+ }
777
+ return new Response(upstream.body, {
778
+ status: upstream.status,
779
+ statusText: upstream.statusText,
780
+ headers: headers2
781
+ });
782
+ } catch (err) {
783
+ const aborted = err instanceof Error && err.name === "AbortError";
784
+ return Response.json(
785
+ errorBody(
786
+ aborted ? "upstream_timeout" : "upstream_unavailable",
787
+ aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable"
788
+ ),
789
+ { status: aborted ? 504 : 503, headers: denyCorsHeaders(request) }
790
+ );
791
+ } finally {
792
+ clearTimeout(timer);
793
+ }
485
794
  }
486
795
  function getAuditSuppressedCount(options) {
487
796
  return auditSamplers.get(options)?.suppressedCount ?? 0;
@@ -512,7 +821,14 @@ function checkMetricsAuth(options, request) {
512
821
  }
513
822
  return { ok: true };
514
823
  }
515
- if (!options.metricsRemoteAddress) return { ok: true };
824
+ if (!options.metricsRemoteAddress) {
825
+ return {
826
+ ok: false,
827
+ status: 401,
828
+ code: "metrics_unauthorized",
829
+ message: "Metrics endpoint is restricted to loopback or bearer-token clients"
830
+ };
831
+ }
516
832
  const address = options.metricsRemoteAddress(request);
517
833
  if (isLoopbackAddress(address)) return { ok: true };
518
834
  return {
@@ -551,6 +867,7 @@ function createWorkspaceRelay(options) {
551
867
  if (origin.startsWith("http://localhost:")) return origin;
552
868
  if (origin.startsWith("http://127.0.0.1:")) return origin;
553
869
  if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
870
+ if (/^https:\/\/([a-z0-9-]+\.)*claxedo\.com$/.test(origin)) return origin;
554
871
  return void 0;
555
872
  },
556
873
  allowHeaders: [
@@ -558,7 +875,12 @@ function createWorkspaceRelay(options) {
558
875
  "Authorization",
559
876
  "Content-Type",
560
877
  "X-Daytona-Skip-Preview-Warning",
561
- "X-Workspace-Id"
878
+ "X-Workspace-Id",
879
+ "X-OpenCode-Directory",
880
+ "X-Claxedo-Runner",
881
+ "X-Claxedo-Model",
882
+ "X-Claxedo-Draft-Id",
883
+ "X-Claxedo-Binary"
562
884
  ],
563
885
  allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
564
886
  }));
@@ -622,21 +944,27 @@ function createWorkspaceRelay(options) {
622
944
  return c.json(buildMetricsBody(options));
623
945
  });
624
946
  app.all("/workspaces/:workspaceId/*", async (c) => {
947
+ const trace = createWorkspaceRelayTrace();
625
948
  if (options.isDraining?.()) {
626
949
  return Response.json(
627
950
  errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
628
951
  { status: 503, headers: denyCorsHeaders(c.req.raw) }
629
952
  );
630
953
  }
631
- const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"));
632
- if (!relay.ok) return relay.response;
633
- return await forwardRequest(
634
- new Request(c.req.raw),
635
- relay.request.target,
636
- relay.request.relayHostToken,
637
- relay.request.path,
638
- options.fetch ?? fetch
639
- );
954
+ const response = await trace.span("relay-total", async () => {
955
+ const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"), trace);
956
+ if (!relay.ok) return relay.response;
957
+ return await forwardWorkspaceRelayRequest(
958
+ new Request(c.req.raw),
959
+ relay.request.target,
960
+ relay.request.relayHostToken,
961
+ relay.request.path,
962
+ options.fetch ?? fetch,
963
+ options.forwardTimeoutMs ?? 3e4,
964
+ trace
965
+ );
966
+ });
967
+ return workspaceRelayTimingResponse(response, trace);
640
968
  });
641
969
  return app;
642
970
  }
@@ -644,31 +972,79 @@ function createWorkspaceRelay(options) {
644
972
  // src/bun.ts
645
973
  var TUNNEL_PENDING_HTTP_CAP = 32;
646
974
  var TUNNEL_CHANNEL_CAP = 16;
647
- var HOST_TUNNEL_REGISTRATION_CONCURRENT_CAP = 3;
648
975
  var HOST_TUNNEL_REGISTRATION_RECONNECT_CAP = 5;
649
976
  var HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS = 6e4;
650
977
  var WS_MAX_PAYLOAD_LENGTH_BYTES = 16 * 1024 * 1024;
978
+ var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
979
+ var TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT = 3e4;
980
+ var DIRECT_HTTP_TIMEOUT_MS_DEFAULT = 3e4;
981
+ var UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT = 1e4;
982
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
983
+ var WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
984
+ var HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT = 2;
651
985
  var TUNNEL_MESSAGE_BUFFER_CAP_BYTES = 4 * 1024 * 1024;
652
986
  var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
653
987
  var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
654
988
  var HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT = 250;
655
- var fragmentationStats = {
656
- fragmentsBuffered: 0,
657
- oversizedClosed: 0
658
- };
659
- function getFragmentationStats() {
989
+ function createFragmentationStats() {
660
990
  return {
661
- fragmentsBuffered: fragmentationStats.fragmentsBuffered,
662
- oversizedClosed: fragmentationStats.oversizedClosed
991
+ fragmentsBuffered: 0,
992
+ oversizedClosed: 0
663
993
  };
664
994
  }
665
- var slowConsumerStats = {
666
- overflowEvents: 0,
667
- timerFired: 0,
668
- droppedRequests: 0
669
- };
670
- function getSlowConsumerStats() {
671
- return { ...slowConsumerStats };
995
+ function createSlowConsumerStats() {
996
+ return {
997
+ overflowEvents: 0,
998
+ timerFired: 0,
999
+ droppedRequests: 0
1000
+ };
1001
+ }
1002
+ function readFragmentationStats(stats) {
1003
+ return {
1004
+ fragmentsBuffered: stats.fragmentsBuffered,
1005
+ oversizedClosed: stats.oversizedClosed
1006
+ };
1007
+ }
1008
+ function resetFragmentationStats(stats) {
1009
+ stats.fragmentsBuffered = 0;
1010
+ stats.oversizedClosed = 0;
1011
+ }
1012
+ function readSlowConsumerStats(stats) {
1013
+ return { ...stats };
1014
+ }
1015
+ function resetSlowConsumerStats(stats) {
1016
+ stats.overflowEvents = 0;
1017
+ stats.timerFired = 0;
1018
+ stats.droppedRequests = 0;
1019
+ }
1020
+ function createDirectHttpLimiter(limit) {
1021
+ if (!Number.isInteger(limit) || !limit || limit <= 0) return void 0;
1022
+ const max = limit;
1023
+ let active = 0;
1024
+ const queue = [];
1025
+ function drain() {
1026
+ if (active >= max) return;
1027
+ const next = queue.shift();
1028
+ if (!next) return;
1029
+ active++;
1030
+ next();
1031
+ }
1032
+ return {
1033
+ async acquire() {
1034
+ if (active < max) {
1035
+ active++;
1036
+ } else {
1037
+ await new Promise((resolve) => queue.push(resolve));
1038
+ }
1039
+ let released = false;
1040
+ return () => {
1041
+ if (released) return;
1042
+ released = true;
1043
+ active = Math.max(0, active - 1);
1044
+ drain();
1045
+ };
1046
+ }
1047
+ };
672
1048
  }
673
1049
  function jsonError(code, message, status) {
674
1050
  return new Response(JSON.stringify({ error: { code, message } }), {
@@ -676,6 +1052,35 @@ function jsonError(code, message, status) {
676
1052
  headers: { "content-type": "application/json" }
677
1053
  });
678
1054
  }
1055
+ function corsJsonError(request, code, message, status) {
1056
+ const headers2 = relayCorsHeaders(request);
1057
+ headers2.set("content-type", "application/json");
1058
+ return new Response(JSON.stringify({ error: { code, message } }), { status, headers: headers2 });
1059
+ }
1060
+ function safeCloseCode(input, fallback = 1011) {
1061
+ if (!Number.isInteger(input)) return fallback;
1062
+ const code = input;
1063
+ if (code === 1e3) return code;
1064
+ if (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
1065
+ if (code >= 3e3 && code <= 4999) return code;
1066
+ return fallback;
1067
+ }
1068
+ function closeWebSocket(ws, code, reason, fallback = 1011) {
1069
+ try {
1070
+ ws.close(safeCloseCode(code, fallback), reason);
1071
+ } catch {
1072
+ try {
1073
+ ws.close(fallback, reason);
1074
+ } catch {
1075
+ }
1076
+ }
1077
+ }
1078
+ function upstreamCloseReason(event) {
1079
+ if (event.reason.trim()) return event.reason;
1080
+ if (event.code === 1e3) return "";
1081
+ if (event.code === 1005 || event.code === 1006) return "Upstream WebSocket closed abnormally";
1082
+ return `Upstream WebSocket closed unexpectedly with code ${event.code}`;
1083
+ }
679
1084
  function workspaceIdFromPath(pathname) {
680
1085
  return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
681
1086
  }
@@ -685,6 +1090,23 @@ function hostIdFromTunnelPath(pathname) {
685
1090
  function websocketRequest(request) {
686
1091
  return request.headers.get("upgrade")?.toLowerCase() === "websocket";
687
1092
  }
1093
+ function roundedMs2(input) {
1094
+ return Math.round(input * 100) / 100;
1095
+ }
1096
+ function relayWebSocketTraceEnabled(request) {
1097
+ return request.headers.get("x-claxedo-relay-ws-trace") === "1";
1098
+ }
1099
+ function sendRelayWebSocketTrace(ws) {
1100
+ const trace = ws.data.trace;
1101
+ if (!trace || trace.emitted) return;
1102
+ trace.emitted = true;
1103
+ ws.send(JSON.stringify({
1104
+ type: "relay.trace",
1105
+ wsUpstreamOpenMs: trace.upstreamOpenMs === void 0 ? void 0 : roundedMs2(trace.upstreamOpenMs),
1106
+ queuedFrames: trace.queuedFrames,
1107
+ maxQueuedDelayMs: roundedMs2(trace.maxQueuedDelayMs)
1108
+ }));
1109
+ }
688
1110
  function relayWebSocketPayload(input) {
689
1111
  if (typeof input === "string") return input;
690
1112
  if (input instanceof ArrayBuffer) return input;
@@ -708,6 +1130,11 @@ function headers(input) {
708
1130
  }
709
1131
  return result;
710
1132
  }
1133
+ function isEventStream(input) {
1134
+ return Object.entries(input).some(
1135
+ ([key, value]) => key.toLowerCase() === "content-type" && value.toLowerCase().includes("text/event-stream")
1136
+ );
1137
+ }
711
1138
  function allowedCorsOrigin(origin) {
712
1139
  if (!origin) return;
713
1140
  if (origin.startsWith("http://localhost:")) return origin;
@@ -734,7 +1161,7 @@ function relayCorsHeaders(request, input = new Headers()) {
734
1161
  const origin = allowedCorsOrigin(request.headers.get("origin"));
735
1162
  if (origin) {
736
1163
  result.set("access-control-allow-origin", origin);
737
- result.set("access-control-allow-headers", "Accept, Authorization, Content-Type, X-Daytona-Skip-Preview-Warning, X-Workspace-Id");
1164
+ result.set("access-control-allow-headers", "Accept, Authorization, Content-Type, Last-Event-ID, X-Fetch-Bypass-Throttle, X-Daytona-Skip-Preview-Warning, X-Workspace-Id, X-OpenCode-Directory, X-Claxedo-Runner, X-Claxedo-Model, X-Claxedo-Draft-Id, X-Claxedo-Binary");
738
1165
  result.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
739
1166
  }
740
1167
  return result;
@@ -761,7 +1188,7 @@ function decodedFrame(frame) {
761
1188
  const body = Buffer.from(frame.data_base64, "base64");
762
1189
  return frame.binary ? new Uint8Array(body) : body.toString("utf8");
763
1190
  }
764
- function tunnelMessage(ws, input) {
1191
+ function tunnelMessage(ws, input, stats) {
765
1192
  if (typeof input !== "string") return;
766
1193
  const combined = ws.data.messageBuffer.length > 0 ? ws.data.messageBuffer + input : input;
767
1194
  let parsed;
@@ -769,20 +1196,26 @@ function tunnelMessage(ws, input) {
769
1196
  parsed = JSON.parse(combined);
770
1197
  } catch {
771
1198
  if (combined.length > TUNNEL_MESSAGE_BUFFER_CAP_BYTES) {
772
- fragmentationStats.oversizedClosed += 1;
1199
+ stats.oversizedClosed += 1;
773
1200
  ws.data.messageBuffer = "";
774
1201
  ws.close(1009, "Tunnel message buffer exceeded 4 MB");
775
1202
  return;
776
1203
  }
777
- fragmentationStats.fragmentsBuffered += 1;
1204
+ stats.fragmentsBuffered += 1;
778
1205
  ws.data.messageBuffer = combined;
779
1206
  return;
780
1207
  }
781
1208
  ws.data.messageBuffer = "";
782
- return isTunnelMessage(parsed) ? parsed : void 0;
1209
+ const validated = validateTunnelMessage(parsed);
1210
+ if (validated.ok) return validated.message;
1211
+ if (validated.reason === "protocol_mismatch") {
1212
+ ws.close(1002, `Tunnel protocol mismatch: expected ${validated.expected_protocol}`);
1213
+ }
1214
+ return;
783
1215
  }
784
1216
  function sendTunnelPing(ws) {
785
1217
  if (ws.readyState !== WebSocket.OPEN) return;
1218
+ ws.data.missedPongs += 1;
786
1219
  ws.send(JSON.stringify({
787
1220
  type: "ping",
788
1221
  protocol: TUNNEL_PROTOCOL_VERSION,
@@ -790,6 +1223,59 @@ function sendTunnelPing(ws) {
790
1223
  sent_at: Date.now()
791
1224
  }));
792
1225
  }
1226
+ function clearPendingTimers(entry) {
1227
+ clearTimeout(entry.timeout);
1228
+ if (entry.slowConsumerTimeout) {
1229
+ clearTimeout(entry.slowConsumerTimeout);
1230
+ entry.slowConsumerTimeout = void 0;
1231
+ }
1232
+ }
1233
+ function failPendingHttpResponse(input) {
1234
+ clearPendingTimers(input.entry);
1235
+ input.entry.pendingChunks.length = 0;
1236
+ input.entry.bytesQueued = 0;
1237
+ try {
1238
+ input.entry.controller.error(input.error);
1239
+ } catch {
1240
+ }
1241
+ if (!input.entry.responseStarted) {
1242
+ if (input.response) {
1243
+ input.entry.resolve(input.response);
1244
+ return;
1245
+ }
1246
+ input.entry.reject(input.error);
1247
+ }
1248
+ }
1249
+ function cleanupHostTunnelSocket(input) {
1250
+ if (input.ws.data.heartbeat) {
1251
+ clearInterval(input.ws.data.heartbeat);
1252
+ input.ws.data.heartbeat = void 0;
1253
+ }
1254
+ for (const pending of input.ws.data.pending.values()) {
1255
+ failPendingHttpResponse({
1256
+ entry: pending,
1257
+ response: input.request ? corsJsonError(input.request, "user_hosted_app_offline", "User-hosted workspace is offline", 503) : jsonError("user_hosted_app_offline", "User-hosted workspace is offline", 503),
1258
+ error: new Error("User-hosted tunnel disconnected")
1259
+ });
1260
+ }
1261
+ input.ws.data.pending.clear();
1262
+ for (const channel of input.ws.data.channels.values()) {
1263
+ if (input.closeChannels) closeWebSocket(channel, 1011, "User-hosted tunnel disconnected");
1264
+ }
1265
+ input.ws.data.channels.clear();
1266
+ if (!input.disconnectDirectory) return;
1267
+ if (input.hostTunnels.get(input.ws.data.hostId) === input.ws) {
1268
+ input.hostTunnels.delete(input.ws.data.hostId);
1269
+ input.options.directory?.disconnectHost(input.ws.data.hostId);
1270
+ scheduleHostTunnelStateChange(input.hostTunnelStateDebounce, input.options, {
1271
+ hostId: input.ws.data.hostId,
1272
+ workspaceId: input.ws.data.workspaceIds[0],
1273
+ path: `/host-tunnels/${input.ws.data.hostId}`,
1274
+ state: "disconnected",
1275
+ debounceMs: input.bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
1276
+ });
1277
+ }
1278
+ }
793
1279
  async function audit2(options, input) {
794
1280
  await options.audit?.({
795
1281
  action: input.action,
@@ -800,9 +1286,8 @@ async function audit2(options, input) {
800
1286
  path: input.path
801
1287
  });
802
1288
  }
803
- var hostTunnelStateDebounce = /* @__PURE__ */ new Map();
804
- function scheduleHostTunnelStateChange(options, input) {
805
- const existing = hostTunnelStateDebounce.get(input.hostId);
1289
+ function scheduleHostTunnelStateChange(state, options, input) {
1290
+ const existing = state.get(input.hostId);
806
1291
  const entry = existing ?? {
807
1292
  intendedState: "disconnected",
808
1293
  lastWritten: "disconnected",
@@ -816,7 +1301,7 @@ function scheduleHostTunnelStateChange(options, input) {
816
1301
  entry.flushTimer = void 0;
817
1302
  if (entry.intendedState === entry.lastWritten) {
818
1303
  if (entry.intendedState === "disconnected") {
819
- hostTunnelStateDebounce.delete(input.hostId);
1304
+ state.delete(input.hostId);
820
1305
  }
821
1306
  return;
822
1307
  }
@@ -828,7 +1313,7 @@ function scheduleHostTunnelStateChange(options, input) {
828
1313
  path: entry.pending.path
829
1314
  });
830
1315
  }, input.debounceMs);
831
- hostTunnelStateDebounce.set(input.hostId, entry);
1316
+ state.set(input.hostId, entry);
832
1317
  }
833
1318
  function drainPendingChunks(entry) {
834
1319
  while (entry.pendingChunks.length > 0) {
@@ -857,9 +1342,9 @@ function enqueueChunkWithBackpressure(input) {
857
1342
  entry.pendingChunks.push(chunk);
858
1343
  entry.bytesQueued += chunk.byteLength;
859
1344
  if (!entry.slowConsumerTimeout) {
860
- slowConsumerStats.overflowEvents += 1;
1345
+ input.slowConsumerStats.overflowEvents += 1;
861
1346
  entry.slowConsumerTimeout = setTimeout(() => {
862
- slowConsumerStats.timerFired += 1;
1347
+ input.slowConsumerStats.timerFired += 1;
863
1348
  const error = new Error("slow_consumer_timeout: downstream consumer did not drain in time");
864
1349
  try {
865
1350
  entry.controller.error(error);
@@ -872,7 +1357,7 @@ function enqueueChunkWithBackpressure(input) {
872
1357
  entry.resolve(jsonError("slow_consumer_timeout", "Downstream consumer did not drain in time", 503));
873
1358
  }
874
1359
  input.ws.data.pending.delete(input.requestId);
875
- slowConsumerStats.droppedRequests += 1;
1360
+ input.slowConsumerStats.droppedRequests += 1;
876
1361
  }, input.slowConsumerTimeoutMs);
877
1362
  }
878
1363
  return;
@@ -903,13 +1388,47 @@ async function tunnelHttpRequest(input) {
903
1388
  const entry = input.ws.data.pending.get(requestId);
904
1389
  if (!entry) return;
905
1390
  drainPendingChunks(entry);
1391
+ },
1392
+ cancel() {
1393
+ const entry = input.ws.data.pending.get(requestId);
1394
+ if (!entry) return;
1395
+ input.ws.data.pending.delete(requestId);
1396
+ clearPendingTimers(entry);
1397
+ entry.pendingChunks.length = 0;
1398
+ entry.bytesQueued = 0;
1399
+ if (input.ws.readyState === WebSocket.OPEN) {
1400
+ input.ws.send(JSON.stringify({
1401
+ type: "http.response.flow",
1402
+ protocol: TUNNEL_PROTOCOL_VERSION,
1403
+ request_id: requestId,
1404
+ paused: false,
1405
+ reason: "closed"
1406
+ }));
1407
+ }
906
1408
  }
907
1409
  }, new ByteLengthQueuingStrategy({ highWaterMark: input.slowConsumerHighWaterMarkBytes }));
908
1410
  const pending = new Promise((resolve, reject) => {
909
1411
  const timeout = setTimeout(() => {
1412
+ const entry = input.ws.data.pending.get(requestId);
1413
+ if (!entry) return;
910
1414
  input.ws.data.pending.delete(requestId);
911
- reject(new Error("User-hosted tunnel response timed out"));
912
- }, 3e4);
1415
+ if (entry.responseStarted) {
1416
+ clearPendingTimers(entry);
1417
+ entry.pendingChunks.length = 0;
1418
+ entry.bytesQueued = 0;
1419
+ try {
1420
+ entry.controller.close();
1421
+ } catch {
1422
+ }
1423
+ return;
1424
+ }
1425
+ const error = new Error("User-hosted tunnel response timed out");
1426
+ failPendingHttpResponse({
1427
+ entry,
1428
+ response: corsJsonError(input.request, "user_hosted_tunnel_timeout", "User-hosted tunnel response timed out", 504),
1429
+ error
1430
+ });
1431
+ }, input.responseTimeoutMs);
913
1432
  input.ws.data.pending.set(requestId, {
914
1433
  controller,
915
1434
  stream,
@@ -921,7 +1440,13 @@ async function tunnelHttpRequest(input) {
921
1440
  responseStarted: false
922
1441
  });
923
1442
  });
924
- const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : encoded(await input.request.arrayBuffer());
1443
+ const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : await readBoundedBody(input.request, input.requestBodyMaxBytes);
1444
+ if (body && "response" in body) {
1445
+ const entry = input.ws.data.pending.get(requestId);
1446
+ if (entry) clearPendingTimers(entry);
1447
+ input.ws.data.pending.delete(requestId);
1448
+ return body.response;
1449
+ }
925
1450
  input.ws.send(JSON.stringify({
926
1451
  type: "http.request",
927
1452
  protocol: TUNNEL_PROTOCOL_VERSION,
@@ -937,33 +1462,81 @@ async function tunnelHttpRequest(input) {
937
1462
  // cookies to the host process running on the user's laptop.
938
1463
  { userHosted: true }
939
1464
  )),
940
- ...body ? { body_base64: body } : {},
1465
+ ...body?.bodyBase64 ? { body_base64: body.bodyBase64 } : {},
941
1466
  end: true
942
1467
  }));
943
1468
  return await pending;
944
1469
  }
1470
+ async function readBoundedBody(request, maxBytes) {
1471
+ if (!request.body) return void 0;
1472
+ const reader = request.body.getReader();
1473
+ const chunks = [];
1474
+ let total = 0;
1475
+ for (; ; ) {
1476
+ const next = await reader.read();
1477
+ if (next.done) break;
1478
+ total += next.value.byteLength;
1479
+ if (total > maxBytes) {
1480
+ try {
1481
+ await reader.cancel();
1482
+ } catch {
1483
+ }
1484
+ return {
1485
+ response: corsJsonError(request, "request_body_too_large", "Tunnel request body exceeds the relay limit", 413)
1486
+ };
1487
+ }
1488
+ chunks.push(next.value);
1489
+ }
1490
+ const body = new Uint8Array(total);
1491
+ chunks.reduce((offset, chunk) => {
1492
+ body.set(chunk, offset);
1493
+ return offset + chunk.byteLength;
1494
+ }, 0);
1495
+ return { bodyBase64: encoded(body.buffer) };
1496
+ }
945
1497
  async function directHttpRequest(input) {
946
- const init = {
947
- method: input.request.method,
948
- headers: workspaceRelayForwardHeaders(
949
- input.request.headers,
1498
+ const controller = new AbortController();
1499
+ const timer = setTimeout(() => controller.abort(), input.timeoutMs);
1500
+ const span = (name, run) => input.trace ? input.trace.span(name, run) : run();
1501
+ try {
1502
+ const init = workspaceRelayForwardRequestInit(
1503
+ input.request,
950
1504
  input.relayHostToken,
951
1505
  input.workspaceId,
952
- // T26: cloud-vm direct path. Cookies pass through (default) — workspace
953
- // dashboards in the VM may legitimately need session cookies.
954
- { userHosted: false }
955
- ),
956
- body: input.request.body,
957
- duplex: "half",
958
- redirect: "manual"
959
- };
960
- if (input.request.method === "GET" || input.request.method === "HEAD") delete init.body;
961
- const upstream = await fetch(input.targetUrl, init);
962
- return new Response(upstream.body, {
963
- status: upstream.status,
964
- statusText: upstream.statusText,
965
- headers: relayCorsHeaders(input.request, upstream.headers)
966
- });
1506
+ {
1507
+ // T26: cloud-vm direct path. Cookies pass through (default) — workspace
1508
+ // dashboards in the VM may legitimately need session cookies.
1509
+ userHosted: false,
1510
+ upstreamHeaders: input.upstreamHeaders,
1511
+ signal: controller.signal
1512
+ }
1513
+ );
1514
+ if (input.request.method !== "GET" && input.request.method !== "HEAD" && input.request.body) {
1515
+ init.body = await input.request.arrayBuffer();
1516
+ }
1517
+ const limiter = input.limiter;
1518
+ const release = limiter ? await span("direct-http-queue", () => limiter.acquire()) : void 0;
1519
+ try {
1520
+ const upstream = await span("upstream-fetch", async () => await fetch(input.targetUrl, init));
1521
+ return new Response(upstream.body, {
1522
+ status: upstream.status,
1523
+ statusText: upstream.statusText,
1524
+ headers: relayCorsHeaders(input.request, upstream.headers)
1525
+ });
1526
+ } finally {
1527
+ release?.();
1528
+ }
1529
+ } catch (err) {
1530
+ const aborted = err instanceof Error && err.name === "AbortError";
1531
+ return corsJsonError(
1532
+ input.request,
1533
+ aborted ? "upstream_timeout" : "upstream_unavailable",
1534
+ aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable",
1535
+ aborted ? 504 : 503
1536
+ );
1537
+ } finally {
1538
+ clearTimeout(timer);
1539
+ }
967
1540
  }
968
1541
  function hostTunnel(hostTunnels, hostId) {
969
1542
  const tunnel = hostTunnels.get(hostId);
@@ -984,12 +1557,30 @@ async function authorizeHostTunnel(options, bunOptions, request, input) {
984
1557
  }
985
1558
  function createWorkspaceRelayBun(options, bunOptions = {}) {
986
1559
  const hostTunnels = /* @__PURE__ */ new Map();
1560
+ const relayClients = /* @__PURE__ */ new Set();
987
1561
  const hostTunnelRegistrations = /* @__PURE__ */ new Map();
1562
+ const directHttpLimiter = createDirectHttpLimiter(bunOptions.directHttpConcurrency);
1563
+ const hostTunnelStateDebounce = /* @__PURE__ */ new Map();
1564
+ const fragmentationStats = createFragmentationStats();
1565
+ const slowConsumerStats = createSlowConsumerStats();
1566
+ const telemetry = {
1567
+ getFragmentationStats: () => readFragmentationStats(fragmentationStats),
1568
+ resetFragmentationStats: () => resetFragmentationStats(fragmentationStats),
1569
+ getSlowConsumerStats: () => readSlowConsumerStats(slowConsumerStats),
1570
+ resetSlowConsumerStats: () => resetSlowConsumerStats(slowConsumerStats)
1571
+ };
988
1572
  let draining = false;
989
1573
  const drainController = {
990
1574
  isDraining: () => draining,
991
1575
  setDraining: (value) => {
992
1576
  draining = value;
1577
+ if (!value) return;
1578
+ for (const tunnel of hostTunnels.values()) {
1579
+ closeWebSocket(tunnel, 1012, "Workspace relay is draining", 1012);
1580
+ }
1581
+ for (const client of relayClients) {
1582
+ closeWebSocket(client, 1012, "Workspace relay is draining", 1012);
1583
+ }
993
1584
  },
994
1585
  pendingCount: () => {
995
1586
  let total = 0;
@@ -1012,8 +1603,8 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1012
1603
  }
1013
1604
  };
1014
1605
  const mergedMetricsSources = {
1015
- fragmentation: options.metricsSources?.fragmentation ?? getFragmentationStats,
1016
- slowConsumer: options.metricsSources?.slowConsumer ?? getSlowConsumerStats,
1606
+ fragmentation: options.metricsSources?.fragmentation ?? telemetry.getFragmentationStats,
1607
+ slowConsumer: options.metricsSources?.slowConsumer ?? telemetry.getSlowConsumerStats,
1017
1608
  drainPending: options.metricsSources?.drainPending ?? drainController.pendingCount
1018
1609
  };
1019
1610
  const requestRemoteAddress = /* @__PURE__ */ new WeakMap();
@@ -1027,7 +1618,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1027
1618
  function getRegistrationTracker(hostId) {
1028
1619
  let tracker = hostTunnelRegistrations.get(hostId);
1029
1620
  if (!tracker) {
1030
- tracker = { current: 0, recent: [] };
1621
+ tracker = { recent: [] };
1031
1622
  hostTunnelRegistrations.set(hostId, tracker);
1032
1623
  }
1033
1624
  return tracker;
@@ -1048,6 +1639,12 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1048
1639
  if (ip) requestRemoteAddress.set(request, ip);
1049
1640
  } catch {
1050
1641
  }
1642
+ if (url.pathname === "/health") {
1643
+ return Response.json(
1644
+ draining ? { ok: false, service: "workspace-relay", draining: true } : { ok: true, service: "workspace-relay" },
1645
+ { status: draining ? 503 : 200 }
1646
+ );
1647
+ }
1051
1648
  if (draining && websocketRequest(request) && hostId) {
1052
1649
  return jsonError(
1053
1650
  "relay_draining",
@@ -1077,13 +1674,6 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1077
1674
  const tracker = getRegistrationTracker(hostId);
1078
1675
  const now = Date.now();
1079
1676
  pruneReconnects(tracker, now);
1080
- if (tracker.current >= HOST_TUNNEL_REGISTRATION_CONCURRENT_CAP) {
1081
- return jsonError(
1082
- "too_many_concurrent_host_tunnels",
1083
- "Too many concurrent host-tunnel registrations for this host",
1084
- 429
1085
- );
1086
- }
1087
1677
  if (tracker.recent.length >= HOST_TUNNEL_REGISTRATION_RECONNECT_CAP) {
1088
1678
  return jsonError(
1089
1679
  "too_many_host_tunnel_reconnects",
@@ -1098,10 +1688,10 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1098
1688
  workspaceIds,
1099
1689
  pending: /* @__PURE__ */ new Map(),
1100
1690
  channels: /* @__PURE__ */ new Map(),
1691
+ missedPongs: 0,
1101
1692
  messageBuffer: ""
1102
1693
  }
1103
1694
  })) {
1104
- tracker.current += 1;
1105
1695
  tracker.recent.push(now);
1106
1696
  return;
1107
1697
  }
@@ -1114,32 +1704,45 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1114
1704
  headers: relayCorsHeaders(request)
1115
1705
  });
1116
1706
  }
1117
- const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
1118
- if (!relay.ok) return relay.response;
1119
- if (!websocketRequest(request) && relay.request.target.access === "user-hosted") {
1120
- const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId);
1121
- return tunnel ? await tunnelHttpRequest({
1122
- ws: tunnel,
1123
- request,
1124
- workspaceId: relay.request.target.workspaceId,
1125
- path: `${relay.request.path}${url.search}`,
1126
- relayHostToken: relay.request.relayHostToken,
1127
- slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
1128
- slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
1129
- }) : new Response("User-hosted workspace is offline", { status: 503 });
1130
- }
1131
1707
  if (!websocketRequest(request)) {
1132
- return await directHttpRequest({
1133
- request,
1134
- targetUrl: workspaceRelayTargetUrl(
1135
- relay.request.target,
1136
- relay.request.path,
1137
- url.search
1138
- ).toString(),
1139
- relayHostToken: relay.request.relayHostToken,
1140
- workspaceId: relay.request.target.workspaceId
1708
+ const trace = createWorkspaceRelayTrace();
1709
+ const response = await trace.span("relay-total", async () => {
1710
+ const relay2 = await authorizeWorkspaceRelayRequest(options, request, workspaceId, trace);
1711
+ if (!relay2.ok) return relay2.response;
1712
+ if (relay2.request.target.access === "user-hosted") {
1713
+ const tunnel = hostTunnel(hostTunnels, relay2.request.target.hostId);
1714
+ return tunnel ? await trace.span("tunnel-http", async () => await tunnelHttpRequest({
1715
+ ws: tunnel,
1716
+ request,
1717
+ workspaceId: relay2.request.target.workspaceId,
1718
+ path: `${relay2.request.path}${url.search}`,
1719
+ relayHostToken: relay2.request.relayHostToken,
1720
+ slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
1721
+ slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
1722
+ slowConsumerStats,
1723
+ requestBodyMaxBytes: bunOptions.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT,
1724
+ responseTimeoutMs: bunOptions.tunnelHttpResponseTimeoutMs ?? TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT
1725
+ })) : new Response("User-hosted workspace is offline", { status: 503 });
1726
+ }
1727
+ return await directHttpRequest({
1728
+ request,
1729
+ targetUrl: workspaceRelayTargetUrl(
1730
+ relay2.request.target,
1731
+ relay2.request.path,
1732
+ url.search
1733
+ ).toString(),
1734
+ relayHostToken: relay2.request.relayHostToken,
1735
+ workspaceId: relay2.request.target.workspaceId,
1736
+ upstreamHeaders: relay2.request.target.upstreamHeaders,
1737
+ timeoutMs: bunOptions.directHttpTimeoutMs ?? DIRECT_HTTP_TIMEOUT_MS_DEFAULT,
1738
+ limiter: directHttpLimiter,
1739
+ trace
1740
+ });
1141
1741
  });
1742
+ return workspaceRelayTimingResponse(response, trace);
1142
1743
  }
1744
+ const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
1745
+ if (!relay.ok) return relay.response;
1143
1746
  const originDenied = requireAllowedOrigin(request);
1144
1747
  if (originDenied) return originDenied;
1145
1748
  if (relay.request.target.access === "user-hosted") {
@@ -1181,9 +1784,17 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1181
1784
  relay.request.relayHostToken,
1182
1785
  relay.request.target.workspaceId,
1183
1786
  // T26: cloud-vm WS upgrade. Cookies pass through (default).
1184
- { userHosted: false }
1787
+ { userHosted: false, upstreamHeaders: relay.request.target.upstreamHeaders }
1185
1788
  )),
1186
- queue: []
1789
+ queue: [],
1790
+ ...relayWebSocketTraceEnabled(request) ? {
1791
+ trace: {
1792
+ acceptedAt: performance.now(),
1793
+ queuedFrames: 0,
1794
+ maxQueuedDelayMs: 0,
1795
+ emitted: false
1796
+ }
1797
+ } : {}
1187
1798
  }
1188
1799
  })) {
1189
1800
  return;
@@ -1196,22 +1807,39 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1196
1807
  if (ws.data.kind === "host-tunnel") {
1197
1808
  const parsed = tunnelMessage(
1198
1809
  ws,
1199
- message
1810
+ message,
1811
+ fragmentationStats
1200
1812
  );
1201
1813
  if (parsed?.type === "ping") {
1202
1814
  options.directory?.recordPong(ws.data.hostId);
1203
1815
  ws.send(JSON.stringify(makeTunnelPong(parsed)));
1204
1816
  }
1205
1817
  if (parsed?.type === "pong") {
1818
+ ws.data.missedPongs = 0;
1206
1819
  options.directory?.recordPong(ws.data.hostId);
1207
1820
  }
1821
+ if (parsed?.type === "error") {
1822
+ if (parsed.request_id) {
1823
+ const pending = ws.data.pending.get(parsed.request_id);
1824
+ if (pending) {
1825
+ ws.data.pending.delete(parsed.request_id);
1826
+ failPendingHttpResponse({
1827
+ entry: pending,
1828
+ response: jsonError(parsed.code, parsed.message, 502),
1829
+ error: new Error(parsed.message)
1830
+ });
1831
+ }
1832
+ }
1833
+ }
1208
1834
  if (parsed?.type === "http.response.start") {
1209
1835
  const pending = ws.data.pending.get(parsed.request_id);
1210
1836
  if (pending) {
1837
+ const response = parsed;
1211
1838
  pending.responseStarted = true;
1839
+ if (isEventStream(response.headers)) clearTimeout(pending.timeout);
1212
1840
  pending.resolve(new Response(pending.stream, {
1213
- status: parsed.status,
1214
- headers: headers(parsed.headers)
1841
+ status: response.status,
1842
+ headers: headers(response.headers)
1215
1843
  }));
1216
1844
  }
1217
1845
  }
@@ -1223,7 +1851,8 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1223
1851
  requestId: parsed.request_id,
1224
1852
  entry,
1225
1853
  chunk: decoded(parsed.body_base64),
1226
- slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
1854
+ slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
1855
+ slowConsumerStats
1227
1856
  });
1228
1857
  }
1229
1858
  }
@@ -1256,14 +1885,24 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1256
1885
  }
1257
1886
  if (parsed?.type === "ws.frame") {
1258
1887
  const frame = parsed;
1259
- ws.data.channels.get(frame.channel_id)?.send(decodedFrame(frame));
1888
+ const channel = ws.data.channels.get(frame.channel_id);
1889
+ if (!channel || channel.readyState !== WebSocket.OPEN) {
1890
+ ws.data.channels.delete(frame.channel_id);
1891
+ return;
1892
+ }
1893
+ if (channel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
1894
+ ws.data.channels.delete(frame.channel_id);
1895
+ closeWebSocket(channel, 1011, "Client WebSocket backpressure limit exceeded");
1896
+ return;
1897
+ }
1898
+ channel.send(decodedFrame(frame));
1260
1899
  }
1261
1900
  if (parsed?.type === "ws.close") {
1262
1901
  const close = parsed;
1263
1902
  const channel = ws.data.channels.get(close.channel_id);
1264
1903
  if (channel) {
1265
1904
  ws.data.channels.delete(close.channel_id);
1266
- channel.close(close.code, close.reason);
1905
+ closeWebSocket(channel, close.code, close.reason);
1267
1906
  }
1268
1907
  }
1269
1908
  return;
@@ -1274,6 +1913,10 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1274
1913
  ws.close(1011, "User-hosted tunnel disconnected");
1275
1914
  return;
1276
1915
  }
1916
+ if (tunnel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
1917
+ closeWebSocket(ws, 1011, "Host tunnel backpressure limit exceeded");
1918
+ return;
1919
+ }
1277
1920
  tunnel.send(JSON.stringify({
1278
1921
  type: "ws.frame",
1279
1922
  protocol: TUNNEL_PROTOCOL_VERSION,
@@ -1286,19 +1929,41 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1286
1929
  ws.data.upstream.send(message);
1287
1930
  return;
1288
1931
  }
1289
- ws.data.queue.push(message);
1932
+ if (ws.data.queue.length >= (bunOptions.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT)) {
1933
+ closeWebSocket(ws, 1011, "Upstream WebSocket queue limit exceeded");
1934
+ return;
1935
+ }
1936
+ ws.data.queue.push({ payload: message, queuedAt: performance.now() });
1290
1937
  },
1291
1938
  open(ws) {
1292
1939
  if (ws.data.kind === "host-tunnel") {
1940
+ const previous = hostTunnels.get(ws.data.hostId);
1941
+ if (previous && previous !== ws) {
1942
+ cleanupHostTunnelSocket({
1943
+ ws: previous,
1944
+ hostTunnels,
1945
+ hostTunnelStateDebounce,
1946
+ options,
1947
+ bunOptions,
1948
+ disconnectDirectory: false,
1949
+ closeChannels: true
1950
+ });
1951
+ closeWebSocket(previous, 1012, "Host tunnel replaced by a newer connection", 1012);
1952
+ }
1293
1953
  hostTunnels.set(ws.data.hostId, ws);
1294
1954
  options.directory?.registerHostTunnel({
1295
1955
  hostId: ws.data.hostId,
1296
1956
  workspaceIds: ws.data.workspaceIds
1297
1957
  });
1298
1958
  ws.data.heartbeat = setInterval(() => {
1299
- sendTunnelPing(ws);
1959
+ const hostWs = ws;
1960
+ if (hostWs.data.missedPongs > (bunOptions.hostTunnelMaxMissedPongs ?? HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT)) {
1961
+ closeWebSocket(ws, 1001, "Host tunnel heartbeat timed out", 1001);
1962
+ return;
1963
+ }
1964
+ sendTunnelPing(hostWs);
1300
1965
  }, bunOptions.hostTunnelPingIntervalMs ?? 15e3);
1301
- scheduleHostTunnelStateChange(options, {
1966
+ scheduleHostTunnelStateChange(hostTunnelStateDebounce, options, {
1302
1967
  hostId: ws.data.hostId,
1303
1968
  workspaceId: ws.data.workspaceIds[0],
1304
1969
  path: `/host-tunnels/${ws.data.hostId}`,
@@ -1314,6 +1979,10 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1314
1979
  return;
1315
1980
  }
1316
1981
  tunnel.data.channels.set(ws.data.channelId, ws);
1982
+ if (tunnel.readyState !== WebSocket.OPEN) {
1983
+ closeWebSocket(ws, 1011, "User-hosted tunnel disconnected");
1984
+ return;
1985
+ }
1317
1986
  tunnel.send(JSON.stringify({
1318
1987
  type: "ws.open",
1319
1988
  protocol: TUNNEL_PROTOCOL_VERSION,
@@ -1332,73 +2001,95 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1332
2001
  }));
1333
2002
  return;
1334
2003
  }
2004
+ relayClients.add(ws);
1335
2005
  const data = ws.data;
1336
- const upstream = new WebSocket(data.upstreamUrl, {
2006
+ const UpstreamWebSocket = bunOptions.upstreamWebSocket ?? WebSocket;
2007
+ if (data.trace) data.trace.upstreamStartedAt = performance.now();
2008
+ const upstream = new UpstreamWebSocket(data.upstreamUrl, {
1337
2009
  headers: data.headers
1338
2010
  });
1339
2011
  upstream.binaryType = "arraybuffer";
1340
2012
  data.upstream = upstream;
2013
+ data.upstreamOpenTimer = setTimeout(() => {
2014
+ if (upstream.readyState === WebSocket.OPEN) return;
2015
+ closeWebSocket(upstream, 1001, "Upstream WebSocket open timed out", 1001);
2016
+ closeWebSocket(ws, 1011, "Upstream WebSocket open timed out");
2017
+ }, bunOptions.upstreamWebSocketOpenTimeoutMs ?? UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT);
1341
2018
  upstream.onopen = () => {
1342
- for (const item of data.queue.splice(0)) upstream.send(item);
2019
+ const openedAt = performance.now();
2020
+ if (data.upstreamOpenTimer) {
2021
+ clearTimeout(data.upstreamOpenTimer);
2022
+ data.upstreamOpenTimer = void 0;
2023
+ }
2024
+ if (data.trace) {
2025
+ data.trace.upstreamOpenMs = openedAt - (data.trace.upstreamStartedAt ?? data.trace.acceptedAt);
2026
+ data.trace.queuedFrames = data.queue.length;
2027
+ data.trace.maxQueuedDelayMs = data.queue.reduce(
2028
+ (max, item) => Math.max(max, openedAt - item.queuedAt),
2029
+ 0
2030
+ );
2031
+ sendRelayWebSocketTrace(ws);
2032
+ }
2033
+ for (const item of data.queue.splice(0)) upstream.send(item.payload);
1343
2034
  };
1344
2035
  upstream.onmessage = (event) => {
1345
2036
  const payload = relayWebSocketPayload(event.data);
1346
2037
  if (payload !== void 0) ws.send(payload);
1347
2038
  };
1348
2039
  upstream.onclose = (event) => {
1349
- ws.close(event.code, event.reason);
2040
+ if (data.upstreamOpenTimer) {
2041
+ clearTimeout(data.upstreamOpenTimer);
2042
+ data.upstreamOpenTimer = void 0;
2043
+ }
2044
+ closeWebSocket(ws, event.code, upstreamCloseReason(event));
1350
2045
  };
1351
2046
  upstream.onerror = () => {
1352
- ws.close(1011, "Upstream WebSocket connection failed");
2047
+ if (data.upstreamOpenTimer) {
2048
+ clearTimeout(data.upstreamOpenTimer);
2049
+ data.upstreamOpenTimer = void 0;
2050
+ }
2051
+ closeWebSocket(ws, 1011, "Upstream WebSocket connection failed");
1353
2052
  };
1354
2053
  },
1355
2054
  close(ws, code, reason) {
1356
2055
  if (ws.data.kind === "host-tunnel") {
1357
- if (ws.data.heartbeat) clearInterval(ws.data.heartbeat);
1358
- hostTunnels.delete(ws.data.hostId);
2056
+ cleanupHostTunnelSocket({
2057
+ ws,
2058
+ hostTunnels,
2059
+ hostTunnelStateDebounce,
2060
+ options,
2061
+ bunOptions,
2062
+ disconnectDirectory: true,
2063
+ closeChannels: true
2064
+ });
1359
2065
  const tracker = hostTunnelRegistrations.get(ws.data.hostId);
1360
2066
  if (tracker) {
1361
- tracker.current = Math.max(0, tracker.current - 1);
1362
2067
  pruneReconnects(tracker, Date.now());
1363
- if (tracker.current === 0 && tracker.recent.length === 0) {
1364
- hostTunnelRegistrations.delete(ws.data.hostId);
1365
- }
1366
- }
1367
- for (const pending of ws.data.pending.values()) {
1368
- clearTimeout(pending.timeout);
1369
- pending.reject(new Error("User-hosted tunnel disconnected"));
2068
+ if (tracker.recent.length === 0) hostTunnelRegistrations.delete(ws.data.hostId);
1370
2069
  }
1371
- ws.data.pending.clear();
1372
- for (const channel of ws.data.channels.values()) {
1373
- channel.close(1011, "User-hosted tunnel disconnected");
1374
- }
1375
- ws.data.channels.clear();
1376
- options.directory?.disconnectHost(ws.data.hostId);
1377
- scheduleHostTunnelStateChange(options, {
1378
- hostId: ws.data.hostId,
1379
- workspaceId: ws.data.workspaceIds[0],
1380
- path: `/host-tunnels/${ws.data.hostId}`,
1381
- state: "disconnected",
1382
- debounceMs: bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
1383
- });
1384
2070
  return;
1385
2071
  }
1386
2072
  if (ws.data.kind === "user-hosted-client") {
1387
2073
  const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
1388
2074
  tunnel?.data.channels.delete(ws.data.channelId);
1389
- tunnel?.send(JSON.stringify({
1390
- type: "ws.close",
1391
- protocol: TUNNEL_PROTOCOL_VERSION,
1392
- channel_id: ws.data.channelId,
1393
- code,
1394
- reason
1395
- }));
2075
+ if (tunnel?.readyState === WebSocket.OPEN) {
2076
+ tunnel.send(JSON.stringify({
2077
+ type: "ws.close",
2078
+ protocol: TUNNEL_PROTOCOL_VERSION,
2079
+ channel_id: ws.data.channelId,
2080
+ code: safeCloseCode(code, 1e3),
2081
+ reason
2082
+ }));
2083
+ }
1396
2084
  return;
1397
2085
  }
1398
- ws.data.upstream?.close(code, reason);
2086
+ relayClients.delete(ws);
2087
+ if (ws.data.upstreamOpenTimer) clearTimeout(ws.data.upstreamOpenTimer);
2088
+ if (ws.data.upstream) closeWebSocket(ws.data.upstream, code, reason, 1e3);
1399
2089
  }
1400
2090
  },
1401
- drain: drainController
2091
+ drain: drainController,
2092
+ telemetry
1402
2093
  };
1403
2094
  }
1404
2095
 
@@ -1600,10 +2291,17 @@ function startSyntheticProbe(options) {
1600
2291
  }
1601
2292
 
1602
2293
  // src/main.ts
2294
+ var BUN_TARGET_CACHE_TTL_MS_DEFAULT = 3e4;
2295
+ var BUN_REVOCATION_CACHE_TTL_MS_DEFAULT = 1e4;
2296
+ var BUN_RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 1e4;
1603
2297
  function clean(input) {
1604
2298
  const value = input?.trim();
1605
2299
  return value ? value : void 0;
1606
2300
  }
2301
+ function positiveInteger(input) {
2302
+ const parsed = Number(clean(input));
2303
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : void 0;
2304
+ }
1607
2305
  function pem(input) {
1608
2306
  return clean(input)?.replaceAll("\\n", "\n");
1609
2307
  }
@@ -1710,25 +2408,31 @@ function parseAuditAcceptSampleRate(env) {
1710
2408
  function parseMetricsToken(env) {
1711
2409
  return clean(env.CLAXEDO_RELAY_METRICS_TOKEN);
1712
2410
  }
1713
- function createCachedRevocationClient(inner, options = {}) {
1714
- const ttlMs = options.ttlMs ?? 1e4;
1715
- const now = options.now ?? Date.now;
1716
- const cache = /* @__PURE__ */ new Map();
1717
- return async (args) => {
1718
- const entry = cache.get(args.jti);
1719
- const at = now();
1720
- if (entry && entry.expiresAt > at) {
1721
- return entry.result;
1722
- }
1723
- const result = await inner(args);
1724
- cache.set(args.jti, { result, expiresAt: at + ttlMs });
1725
- return result;
2411
+ function runtimeAccessTokenCacheTtlMsFromEnv(env) {
2412
+ return positiveInteger(env.CLAXEDO_RELAY_RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS) ?? BUN_RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT;
2413
+ }
2414
+ function directHttpConcurrencyFromEnv(env) {
2415
+ return positiveInteger(env.CLAXEDO_RELAY_DIRECT_HTTP_CONCURRENCY);
2416
+ }
2417
+ function resolverClientCacheOptionsFromEnv(env) {
2418
+ return {
2419
+ targetCacheTtlMs: positiveInteger(env.CLAXEDO_RELAY_TARGET_CACHE_TTL_MS) ?? BUN_TARGET_CACHE_TTL_MS_DEFAULT,
2420
+ revocationCacheTtlMs: positiveInteger(env.CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS) ?? BUN_REVOCATION_CACHE_TTL_MS_DEFAULT
1726
2421
  };
1727
2422
  }
1728
- function createResolverClient(baseUrl, token) {
2423
+ function createResolverClient(baseUrl, token, options = {}) {
1729
2424
  const headers2 = { accept: "application/json" };
1730
2425
  if (token) headers2.authorization = `Bearer ${token}`;
1731
2426
  const root = baseUrl.replace(/\/+$/, "");
2427
+ const targetUncached = async (args) => {
2428
+ const url = new URL(`${root}/target`);
2429
+ url.searchParams.set("workspaceId", args.workspaceId);
2430
+ url.searchParams.set("hostId", args.hostId);
2431
+ const res = await fetch(url, { headers: headers2 });
2432
+ if (res.status === 404) return void 0;
2433
+ if (!res.ok) throw new Error(`relay target resolver failed: ${res.status} ${await res.text()}`);
2434
+ return await res.json();
2435
+ };
1732
2436
  const revocationUncached = async (args) => {
1733
2437
  const url = new URL(`${root}/revocation`);
1734
2438
  url.searchParams.set("jti", args.jti);
@@ -1744,17 +2448,14 @@ function createResolverClient(baseUrl, token) {
1744
2448
  }
1745
2449
  return await res.json();
1746
2450
  };
1747
- const revocation = createCachedRevocationClient(revocationUncached);
2451
+ const target = createCachedTargetClient(targetUncached, {
2452
+ ttlMs: options.targetCacheTtlMs ?? BUN_TARGET_CACHE_TTL_MS_DEFAULT
2453
+ });
2454
+ const revocation = createCachedRevocationClient(revocationUncached, {
2455
+ ttlMs: options.revocationCacheTtlMs ?? BUN_REVOCATION_CACHE_TTL_MS_DEFAULT
2456
+ });
1748
2457
  return {
1749
- async target(workspaceId, hostId) {
1750
- const url = new URL(`${root}/target`);
1751
- url.searchParams.set("workspaceId", workspaceId);
1752
- url.searchParams.set("hostId", hostId);
1753
- const res = await fetch(url, { headers: headers2 });
1754
- if (res.status === 404) return void 0;
1755
- if (!res.ok) throw new Error(`relay target resolver failed: ${res.status} ${await res.text()}`);
1756
- return await res.json();
1757
- },
2458
+ target: (workspaceId, hostId) => target({ workspaceId, hostId }),
1758
2459
  revocation
1759
2460
  };
1760
2461
  }
@@ -1806,6 +2507,47 @@ function installShutdownDrainHandler(options) {
1806
2507
  isShuttingDown: () => shuttingDown
1807
2508
  };
1808
2509
  }
2510
+ function installFatalProcessHandlers(options) {
2511
+ const exit = options.exit ?? ((code) => process.exit(code));
2512
+ const log = options.log ?? (() => {
2513
+ });
2514
+ const register = options.register ?? true;
2515
+ let fatal = false;
2516
+ let pending;
2517
+ async function trigger(error, source) {
2518
+ if (pending) return pending;
2519
+ fatal = true;
2520
+ pending = (async () => {
2521
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
2522
+ log(`[workspace-relay] fatal ${source}: ${message}`);
2523
+ options.drain.setDraining(true);
2524
+ try {
2525
+ await options.stopServer?.();
2526
+ } catch (err) {
2527
+ log(`[workspace-relay] fatal stopServer failed: ${err instanceof Error ? err.message : String(err)}`);
2528
+ }
2529
+ try {
2530
+ options.directory.dispose();
2531
+ } catch (err) {
2532
+ log(`[workspace-relay] fatal directory.dispose() failed: ${err instanceof Error ? err.message : String(err)}`);
2533
+ }
2534
+ exit(1);
2535
+ })();
2536
+ return pending;
2537
+ }
2538
+ if (register) {
2539
+ process.on("uncaughtException", (err) => {
2540
+ void trigger(err, "uncaughtException");
2541
+ });
2542
+ process.on("unhandledRejection", (err) => {
2543
+ void trigger(err, "unhandledRejection");
2544
+ });
2545
+ }
2546
+ return {
2547
+ trigger,
2548
+ isFatal: () => fatal
2549
+ };
2550
+ }
1809
2551
  async function main() {
1810
2552
  const validation = validateProductionEnv({
1811
2553
  NODE_ENV: process.env.NODE_ENV,
@@ -1850,7 +2592,10 @@ async function main() {
1850
2592
  const relayHostSigningKey = relayHostMaterial.privateKey;
1851
2593
  const relayHostPublicKeys = [relayHostMaterial.current];
1852
2594
  if (relayHostMaterial.next) relayHostPublicKeys.push(relayHostMaterial.next);
1853
- const resolver = createResolverClient(resolverUrl, resolverToken);
2595
+ const resolver = createResolverClient(resolverUrl, resolverToken, resolverClientCacheOptionsFromEnv({
2596
+ CLAXEDO_RELAY_TARGET_CACHE_TTL_MS: process.env.CLAXEDO_RELAY_TARGET_CACHE_TTL_MS,
2597
+ CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS: process.env.CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS
2598
+ }));
1854
2599
  const directory = createWorkspaceRelayDirectory();
1855
2600
  const auditAcceptSampleRate = parseAuditAcceptSampleRate({
1856
2601
  CLAXEDO_RELAY_AUDIT_ACCEPT_SAMPLE_RATE: process.env.CLAXEDO_RELAY_AUDIT_ACCEPT_SAMPLE_RATE,
@@ -1859,8 +2604,15 @@ async function main() {
1859
2604
  const metricsToken = parseMetricsToken({
1860
2605
  CLAXEDO_RELAY_METRICS_TOKEN: process.env.CLAXEDO_RELAY_METRICS_TOKEN
1861
2606
  });
2607
+ const runtimeAccessTokenCacheTtlMs2 = runtimeAccessTokenCacheTtlMsFromEnv({
2608
+ CLAXEDO_RELAY_RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS: process.env.CLAXEDO_RELAY_RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS
2609
+ });
2610
+ const directHttpConcurrency = directHttpConcurrencyFromEnv({
2611
+ CLAXEDO_RELAY_DIRECT_HTTP_CONCURRENCY: process.env.CLAXEDO_RELAY_DIRECT_HTTP_CONCURRENCY
2612
+ });
1862
2613
  const handler = createWorkspaceRelayBun({
1863
2614
  runtimeAccessKey,
2615
+ runtimeAccessTokenCacheTtlMs: runtimeAccessTokenCacheTtlMs2,
1864
2616
  relayHostSigningKey,
1865
2617
  relayHostAlgorithm: "EdDSA",
1866
2618
  relayHostPublicKeys,
@@ -1881,6 +2633,8 @@ async function main() {
1881
2633
  );
1882
2634
  }
1883
2635
  }
2636
+ }, {
2637
+ ...directHttpConcurrency ? { directHttpConcurrency } : {}
1884
2638
  });
1885
2639
  const server = Bun.serve({
1886
2640
  port,
@@ -1914,6 +2668,15 @@ async function main() {
1914
2668
  syntheticProbe?.stop();
1915
2669
  }
1916
2670
  });
2671
+ installFatalProcessHandlers({
2672
+ drain: handler.drain,
2673
+ directory,
2674
+ log: (message) => console.error(message),
2675
+ stopServer: async () => {
2676
+ server.stop(true);
2677
+ syntheticProbe?.stop();
2678
+ }
2679
+ });
1917
2680
  void exportPKCS8;
1918
2681
  }
1919
2682
  if (import.meta.main) {
@@ -1924,10 +2687,16 @@ if (import.meta.main) {
1924
2687
  }
1925
2688
  export {
1926
2689
  createCachedRevocationClient,
2690
+ createCachedTargetClient,
2691
+ createResolverClient,
2692
+ directHttpConcurrencyFromEnv,
2693
+ installFatalProcessHandlers,
1927
2694
  installShutdownDrainHandler,
1928
2695
  loadRelayHostKeyMaterial,
1929
2696
  loadRuntimeAccessKeyOrJwks,
1930
2697
  parseAuditAcceptSampleRate,
1931
2698
  parseMetricsToken,
2699
+ resolverClientCacheOptionsFromEnv,
2700
+ runtimeAccessTokenCacheTtlMsFromEnv,
1932
2701
  validateProductionEnv
1933
2702
  };