@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/index.mjs CHANGED
@@ -38,6 +38,9 @@ function roleClaim(payload) {
38
38
  const value = stringClaim(payload, "role");
39
39
  return value === "viewer" || value === "editor" || value === "admin" || value === "owner" ? value : void 0;
40
40
  }
41
+ function isRelayClaimPair(input) {
42
+ return input.access === "cloud" && input.backing === "cloud-vm" || input.access === "user-hosted" && input.backing === "local-worktree";
43
+ }
41
44
  function checkHostTunnelTarget(payload, expected) {
42
45
  if (stringClaim(payload, "host_id") !== expected.hostId) {
43
46
  throw new WorkspaceRelayAuthError("relay_token_host_mismatch", "Relay token host does not match request");
@@ -97,6 +100,18 @@ async function verifyRuntimeAccessToken(token, key, expected) {
97
100
  }
98
101
  return claims;
99
102
  }
103
+ function validateRuntimeAccessTokenClaims(input, expected) {
104
+ const payload = input;
105
+ if (stringClaim(payload, "iss") !== runtimeAccessTokenIssuer || stringClaim(payload, "aud") !== runtimeAccessTokenAudience) {
106
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token issuer or audience is invalid");
107
+ }
108
+ checkTarget(payload, expected);
109
+ const claims = runtimeClaims(payload);
110
+ if (!claims) {
111
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token claims are incomplete");
112
+ }
113
+ return claims;
114
+ }
100
115
  async function mintHostTunnelToken(input, key, alg) {
101
116
  const now = seconds(input.now);
102
117
  return await new SignJWT({
@@ -117,6 +132,9 @@ async function verifyHostTunnelToken(token, key, expected) {
117
132
  return claims;
118
133
  }
119
134
  async function mintRelayHostToken(input, key, alg) {
135
+ if (!isRelayClaimPair(input)) {
136
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Relay Host Token access/backing claims are inconsistent");
137
+ }
120
138
  const now = seconds(input.now);
121
139
  const protectedHeader = { alg: requireAlgorithm(alg) };
122
140
  if (input.kid) protectedHeader.kid = input.kid;
@@ -172,14 +190,13 @@ function relayHostClaims(payload) {
172
190
  });
173
191
  const access = stringClaim(payload, "access");
174
192
  const backing = stringClaim(payload, "backing");
175
- if (!base || access !== "cloud" && access !== "user-hosted") return;
176
- if (backing !== "local-worktree" && backing !== "cloud-vm") return;
193
+ const pair = { access, backing };
194
+ if (!base || !isRelayClaimPair(pair)) return;
177
195
  return {
178
196
  ...base,
179
197
  iss: relayHostTokenIssuer,
180
198
  aud: relayHostTokenAudience,
181
- access,
182
- backing
199
+ ...pair
183
200
  };
184
201
  }
185
202
  function hostTunnelClaims(payload) {
@@ -204,9 +221,9 @@ function hostTunnelClaims(payload) {
204
221
 
205
222
  // src/bun.ts
206
223
  import {
207
- isTunnelMessage,
208
224
  makeTunnelPong,
209
- TUNNEL_PROTOCOL_VERSION
225
+ TUNNEL_PROTOCOL_VERSION,
226
+ validateTunnelMessage
210
227
  } from "@claxedo/workspace-relay-protocol";
211
228
 
212
229
  // src/server.ts
@@ -230,6 +247,70 @@ function errorBody(code, message) {
230
247
  }
231
248
 
232
249
  // src/server.ts
250
+ var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
251
+ var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
252
+ var RESOLVER_CACHE_MAX_ENTRIES = 8192;
253
+ var RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 1e4;
254
+ var relayHostTokenCaches = /* @__PURE__ */ new WeakMap();
255
+ var runtimeAccessTokenCaches = /* @__PURE__ */ new WeakMap();
256
+ function pruneExpiringCache(cache, now, maxEntries) {
257
+ for (const [key, entry] of cache) {
258
+ if (entry.expiresAt <= now) cache.delete(key);
259
+ }
260
+ while (cache.size >= maxEntries) {
261
+ const key = cache.keys().next().value;
262
+ if (!key) return;
263
+ cache.delete(key);
264
+ }
265
+ }
266
+ function createCachedRevocationClient(inner, options = {}) {
267
+ const ttlMs = options.ttlMs ?? 1e4;
268
+ const now = options.now ?? Date.now;
269
+ const cache = /* @__PURE__ */ new Map();
270
+ return async (args) => {
271
+ const at = now();
272
+ const entry = cache.get(args.jti);
273
+ if (entry && entry.expiresAt > at) {
274
+ if (entry.result) return entry.result;
275
+ if (entry.promise) return await entry.promise;
276
+ }
277
+ const promise = inner(args).then((result) => {
278
+ pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
279
+ cache.set(args.jti, { result, expiresAt: now() + ttlMs });
280
+ return result;
281
+ }).catch((err) => {
282
+ cache.delete(args.jti);
283
+ throw err;
284
+ });
285
+ cache.set(args.jti, { promise, expiresAt: at + ttlMs });
286
+ return await promise;
287
+ };
288
+ }
289
+ function createCachedTargetClient(inner, options = {}) {
290
+ const ttlMs = options.ttlMs ?? 5e3;
291
+ const now = options.now ?? Date.now;
292
+ const cache = /* @__PURE__ */ new Map();
293
+ return async (args) => {
294
+ const key = `${args.workspaceId}\0${args.hostId}`;
295
+ const at = now();
296
+ const entry = cache.get(key);
297
+ if (entry && entry.expiresAt > at) {
298
+ if (entry.result) return entry.result;
299
+ if (entry.promise) return await entry.promise;
300
+ }
301
+ const promise = inner(args).then((result) => {
302
+ pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
303
+ if (result) cache.set(key, { result, expiresAt: now() + ttlMs });
304
+ else cache.delete(key);
305
+ return result;
306
+ }).catch((err) => {
307
+ cache.delete(key);
308
+ throw err;
309
+ });
310
+ cache.set(key, { promise, expiresAt: at + ttlMs });
311
+ return await promise;
312
+ };
313
+ }
233
314
  function protocolToken(header) {
234
315
  return header?.split(",").map((item) => item.trim()).find((item) => item.startsWith("claxedo-rat."))?.slice("claxedo-rat.".length);
235
316
  }
@@ -242,6 +323,16 @@ var DANGEROUS_INBOUND_HEADERS = [
242
323
  "x-forwarded-proto",
243
324
  "x-real-ip"
244
325
  ];
326
+ var HOP_BY_HOP_HEADERS = [
327
+ "connection",
328
+ "keep-alive",
329
+ "proxy-authenticate",
330
+ "proxy-authorization",
331
+ "te",
332
+ "trailer",
333
+ "transfer-encoding",
334
+ "upgrade"
335
+ ];
245
336
  var DANGEROUS_INBOUND_HEADER_PATTERNS = [
246
337
  /^x-claxedo-internal-/i,
247
338
  /^x-supervisor-/i
@@ -249,6 +340,7 @@ var DANGEROUS_INBOUND_HEADER_PATTERNS = [
249
340
  function isDangerousInboundHeader(name) {
250
341
  const lower = name.toLowerCase();
251
342
  if (DANGEROUS_INBOUND_HEADERS.includes(lower)) return true;
343
+ if (HOP_BY_HOP_HEADERS.includes(lower)) return true;
252
344
  for (const pattern of DANGEROUS_INBOUND_HEADER_PATTERNS) {
253
345
  if (pattern.test(lower)) return true;
254
346
  }
@@ -264,9 +356,16 @@ function forwardHeaders(input, relayHostToken, workspaceId, options = {}) {
264
356
  }
265
357
  if (options.userHosted) headers2.delete("cookie");
266
358
  headers2.set("accept-encoding", "identity");
267
- headers2.set("authorization", `Bearer ${relayHostToken}`);
359
+ headers2.delete("authorization");
360
+ headers2.delete("Authorization");
361
+ headers2.set("Authorization", `Bearer ${relayHostToken}`);
268
362
  headers2.set("x-workspace-id", workspaceId);
269
363
  headers2.set("X-Daytona-Skip-Preview-Warning", "true");
364
+ headers2.set("X-Daytona-Skip-Last-Activity-Update", "true");
365
+ for (const [name, value] of Object.entries(options.upstreamHeaders ?? {})) {
366
+ const trimmed = value.trim();
367
+ if (trimmed) headers2.set(name, trimmed);
368
+ }
270
369
  headers2.set("x-forwarded-by", "workspace-relay");
271
370
  return headers2;
272
371
  }
@@ -276,8 +375,155 @@ function targetUrl(target, path, search) {
276
375
  return url;
277
376
  }
278
377
  function roleAllowsRelayRequest(role, method) {
279
- if (role !== "viewer") return true;
280
- return method === "GET" || method === "HEAD" || method === "OPTIONS";
378
+ if (role === "owner" || role === "admin" || role === "editor") return true;
379
+ if (role === "viewer") return method === "GET" || method === "HEAD" || method === "OPTIONS";
380
+ return false;
381
+ }
382
+ function relayHostTokenCacheTtlMs(options, claims) {
383
+ const configured = options.relayHostTokenCacheTtlMs ?? 3e4;
384
+ if (!Number.isFinite(configured) || configured <= 0) return 0;
385
+ const tokenRemaining = claims.exp * 1e3 - Date.now() - 5e3;
386
+ return Math.max(0, Math.min(configured, tokenRemaining));
387
+ }
388
+ function relayHostTokenCacheKey(options, claims, target) {
389
+ return [
390
+ claims.jti,
391
+ claims.sub,
392
+ claims.org_id,
393
+ claims.role,
394
+ claims.workspace_id,
395
+ claims.host_id,
396
+ target.access,
397
+ target.backing,
398
+ options.relayHostMintKid ?? ""
399
+ ].join("\0");
400
+ }
401
+ function relayHostTokenCache(options) {
402
+ let cache = relayHostTokenCaches.get(options);
403
+ if (cache) return cache;
404
+ cache = /* @__PURE__ */ new Map();
405
+ relayHostTokenCaches.set(options, cache);
406
+ return cache;
407
+ }
408
+ function runtimeAccessTokenCache(options) {
409
+ let cache = runtimeAccessTokenCaches.get(options);
410
+ if (cache) return cache;
411
+ cache = /* @__PURE__ */ new Map();
412
+ runtimeAccessTokenCaches.set(options, cache);
413
+ return cache;
414
+ }
415
+ function runtimeAccessTokenCacheTtlMs(options, claims, now) {
416
+ const configured = options.runtimeAccessTokenCacheTtlMs ?? RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT;
417
+ if (!Number.isFinite(configured) || configured <= 0) return 0;
418
+ const tokenRemaining = claims.exp * 1e3 - now - 5e3;
419
+ return Math.max(0, Math.min(configured, tokenRemaining));
420
+ }
421
+ function pruneRuntimeAccessTokenCache(cache, now) {
422
+ for (const [key, entry] of cache) {
423
+ if (entry.expiresAt <= now) cache.delete(key);
424
+ }
425
+ while (cache.size >= RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES) {
426
+ const key = cache.keys().next().value;
427
+ if (!key) return;
428
+ cache.delete(key);
429
+ }
430
+ }
431
+ async function verifyRuntimeAccessTokenForRequest(options, token, workspaceId) {
432
+ const verified = options.tokenVerifier ? await options.tokenVerifier.verify(token).then(
433
+ (result) => validateRuntimeAccessTokenClaims(result.claims, { workspaceId })
434
+ ) : await verifyRuntimeAccessToken(token, options.runtimeAccessKey, { workspaceId });
435
+ return verified;
436
+ }
437
+ async function cachedRuntimeAccessTokenClaims(options, token, workspaceId) {
438
+ const configured = options.runtimeAccessTokenCacheTtlMs ?? RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT;
439
+ if (!Number.isFinite(configured) || configured <= 0) {
440
+ return await verifyRuntimeAccessTokenForRequest(options, token, workspaceId);
441
+ }
442
+ const now = Date.now();
443
+ const cache = runtimeAccessTokenCache(options);
444
+ const key = `${workspaceId}\0${token}`;
445
+ const cached = cache.get(key);
446
+ if (cached && cached.expiresAt > now) {
447
+ if (cached.claims) return cached.claims;
448
+ if (cached.promise) return await cached.promise;
449
+ }
450
+ let promise;
451
+ try {
452
+ promise = verifyRuntimeAccessTokenForRequest(options, token, workspaceId);
453
+ pruneRuntimeAccessTokenCache(cache, now);
454
+ cache.set(key, { promise, expiresAt: now + configured });
455
+ const claims = await promise;
456
+ const ttlMs = runtimeAccessTokenCacheTtlMs(options, claims, now);
457
+ if (ttlMs > 0) cache.set(key, { claims, expiresAt: now + ttlMs });
458
+ else cache.delete(key);
459
+ return claims;
460
+ } catch (err) {
461
+ if (promise && cache.get(key)?.promise === promise) cache.delete(key);
462
+ throw err;
463
+ }
464
+ }
465
+ function pruneRelayHostTokenCache(cache, now) {
466
+ for (const [key, entry] of cache) {
467
+ if (entry.expiresAt <= now) cache.delete(key);
468
+ }
469
+ while (cache.size >= RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES) {
470
+ const key = cache.keys().next().value;
471
+ if (!key) return;
472
+ cache.delete(key);
473
+ }
474
+ }
475
+ async function relayHostTokenFor(options, claims, target, trace) {
476
+ const ttlMs = relayHostTokenCacheTtlMs(options, claims);
477
+ if (ttlMs <= 0) {
478
+ return await uncachedRelayHostTokenFor(options, claims, target, trace);
479
+ }
480
+ const key = relayHostTokenCacheKey(options, claims, target);
481
+ const now = Date.now();
482
+ const cache = relayHostTokenCache(options);
483
+ const cached = cache.get(key);
484
+ if (cached && cached.expiresAt > now) {
485
+ if (cached.promise) {
486
+ return await trace.span("rht-cache", async () => await cached.promise);
487
+ }
488
+ await trace.span("rht-cache", async () => {
489
+ });
490
+ return cached.token;
491
+ }
492
+ let promise;
493
+ try {
494
+ const token = await trace.span("rht-mint", async () => {
495
+ promise = mintRelayHostToken({
496
+ subject: claims.sub,
497
+ orgId: claims.org_id,
498
+ role: claims.role,
499
+ ...target,
500
+ ...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
501
+ }, options.relayHostSigningKey, options.relayHostAlgorithm);
502
+ pruneRelayHostTokenCache(cache, now);
503
+ cache.set(key, { promise, expiresAt: now + ttlMs });
504
+ return await promise;
505
+ });
506
+ cache.set(key, {
507
+ token,
508
+ expiresAt: now + ttlMs
509
+ });
510
+ return token;
511
+ } catch (err) {
512
+ if (promise && cache.get(key)?.promise === promise) cache.delete(key);
513
+ throw err;
514
+ }
515
+ }
516
+ async function uncachedRelayHostTokenFor(options, claims, target, trace) {
517
+ return await trace.span(
518
+ "rht-mint",
519
+ async () => await mintRelayHostToken({
520
+ subject: claims.sub,
521
+ orgId: claims.org_id,
522
+ role: claims.role,
523
+ ...target,
524
+ ...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
525
+ }, options.relayHostSigningKey, options.relayHostAlgorithm)
526
+ );
281
527
  }
282
528
  function workspaceRelayTargetUrl(target, path, search) {
283
529
  return targetUrl(target, path, search);
@@ -285,6 +531,18 @@ function workspaceRelayTargetUrl(target, path, search) {
285
531
  function workspaceRelayForwardHeaders(input, relayHostToken, workspaceId, options = {}) {
286
532
  return forwardHeaders(input, relayHostToken, workspaceId, options);
287
533
  }
534
+ function workspaceRelayForwardRequestInit(request, relayHostToken, workspaceId, options = {}) {
535
+ const init = {
536
+ method: request.method,
537
+ headers: forwardHeaders(request.headers, relayHostToken, workspaceId, options),
538
+ body: request.body,
539
+ duplex: "half",
540
+ redirect: "manual",
541
+ ...options.signal ? { signal: options.signal } : {}
542
+ };
543
+ if (request.method === "GET" || request.method === "HEAD") delete init.body;
544
+ return init;
545
+ }
288
546
  var auditSamplers = /* @__PURE__ */ new WeakMap();
289
547
  function getOrCreateAuditSampler(options) {
290
548
  let state = auditSamplers.get(options);
@@ -342,6 +600,38 @@ function disposeAuditSampler(options) {
342
600
  flushSuppressedSummary(options, state);
343
601
  auditSamplers.delete(options);
344
602
  }
603
+ function createWorkspaceRelayTrace() {
604
+ const phases = [];
605
+ return {
606
+ phases,
607
+ async span(name, run) {
608
+ const startedAt = performance.now();
609
+ try {
610
+ return await run();
611
+ } finally {
612
+ phases.push({ name, ms: performance.now() - startedAt });
613
+ }
614
+ }
615
+ };
616
+ }
617
+ function roundedMs(value) {
618
+ return Math.round(value * 100) / 100;
619
+ }
620
+ function workspaceRelayServerTiming(trace) {
621
+ return trace.phases.map((phase) => `${phase.name};dur=${roundedMs(phase.ms)}`).join(", ");
622
+ }
623
+ function workspaceRelayTimingResponse(response, trace) {
624
+ const value = workspaceRelayServerTiming(trace);
625
+ if (!value) return response;
626
+ const headers2 = new Headers(response.headers);
627
+ const prev = headers2.get("server-timing");
628
+ headers2.set("server-timing", prev ? `${prev}, ${value}` : value);
629
+ return new Response(response.body, {
630
+ status: response.status,
631
+ statusText: response.statusText,
632
+ headers: headers2
633
+ });
634
+ }
345
635
  async function audit(options, event) {
346
636
  if (event.action === "relay.request.accepted") {
347
637
  const state = getOrCreateAuditSampler(options);
@@ -362,7 +652,7 @@ function denyCorsHeaders(request) {
362
652
  if (!allowed) return void 0;
363
653
  return {
364
654
  "access-control-allow-origin": origin,
365
- "access-control-allow-headers": "Accept, Authorization, Content-Type, X-Daytona-Skip-Preview-Warning, X-Workspace-Id",
655
+ "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",
366
656
  "access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"
367
657
  };
368
658
  }
@@ -384,11 +674,12 @@ async function deny(options, input) {
384
674
  headers: denyCorsHeaders(input.request)
385
675
  });
386
676
  }
387
- async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
677
+ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, trace) {
388
678
  const token = runtimeAccessToken(request);
389
679
  if (!token) {
390
680
  return {
391
681
  ok: false,
682
+ code: "runtime_access_token_required",
392
683
  response: await deny(options, {
393
684
  code: "runtime_access_token_required",
394
685
  message: "Runtime Access Token is required",
@@ -398,14 +689,18 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
398
689
  };
399
690
  }
400
691
  try {
401
- const claims = options.tokenVerifier ? await (async () => {
402
- const verified = await options.tokenVerifier.verify(token);
403
- return verified.claims;
404
- })() : await verifyRuntimeAccessToken(token, options.runtimeAccessKey, { workspaceId });
405
- const active = await options.isRuntimeAccessTokenActive?.(claims) ?? { active: true };
692
+ const span = (name, run) => trace ? trace.span(name, run) : run();
693
+ const claims = await span(
694
+ "rat-verify",
695
+ async () => await cachedRuntimeAccessTokenClaims(options, token, workspaceId)
696
+ );
697
+ const active = await span("rat-active", async () => {
698
+ return await options.isRuntimeAccessTokenActive?.(claims) ?? { active: true };
699
+ });
406
700
  if (!active.active) {
407
701
  return {
408
702
  ok: false,
703
+ code: active.code,
409
704
  response: await deny(options, {
410
705
  code: active.code,
411
706
  message: active.reason,
@@ -418,6 +713,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
418
713
  if (!roleAllowsRelayRequest(claims.role, request.method)) {
419
714
  return {
420
715
  ok: false,
716
+ code: "relay_role_denied",
421
717
  response: await deny(options, {
422
718
  code: "relay_role_denied",
423
719
  message: "Workspace role does not allow this relay request",
@@ -427,10 +723,11 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
427
723
  })
428
724
  };
429
725
  }
430
- const target = await options.resolveTarget(claims);
726
+ const target = await span("target-resolve", async () => await options.resolveTarget(claims));
431
727
  if (!target || target.workspaceId !== claims.workspace_id || target.hostId !== claims.host_id) {
432
728
  return {
433
729
  ok: false,
730
+ code: "relay_target_unavailable",
434
731
  response: await deny(options, {
435
732
  code: "relay_target_unavailable",
436
733
  message: "Workspace relay target is unavailable",
@@ -443,6 +740,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
443
740
  if (target.access === "user-hosted" && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
444
741
  return {
445
742
  ok: false,
743
+ code: "user_hosted_app_offline",
446
744
  response: await deny(options, {
447
745
  code: "user_hosted_app_offline",
448
746
  message: "User-hosted workspace is offline",
@@ -452,7 +750,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
452
750
  })
453
751
  };
454
752
  }
455
- await audit(options, {
753
+ await span("audit", async () => await audit(options, {
456
754
  action: "relay.request.accepted",
457
755
  result: "allow",
458
756
  subject: claims.sub,
@@ -461,22 +759,14 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
461
759
  hostId: claims.host_id,
462
760
  method: request.method,
463
761
  path: new URL(request.url).pathname
464
- });
762
+ }));
763
+ const relayHostToken = await relayHostTokenFor(options, claims, target, { span });
465
764
  return {
466
765
  ok: true,
467
766
  request: {
468
767
  claims,
469
768
  target,
470
- relayHostToken: await mintRelayHostToken({
471
- subject: claims.sub,
472
- orgId: claims.org_id,
473
- workspaceId: claims.workspace_id,
474
- hostId: claims.host_id,
475
- role: claims.role,
476
- access: target.access,
477
- backing: target.backing,
478
- ...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
479
- }, options.relayHostSigningKey, options.relayHostAlgorithm),
769
+ relayHostToken,
480
770
  path: new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/")
481
771
  }
482
772
  };
@@ -484,6 +774,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
484
774
  const code = err instanceof WorkspaceRelayAuthError ? err.code : "relay_request_failed";
485
775
  return {
486
776
  ok: false,
777
+ code,
487
778
  response: await deny(options, {
488
779
  code,
489
780
  message: "Workspace relay request was denied",
@@ -493,28 +784,45 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
493
784
  };
494
785
  }
495
786
  }
496
- async function forwardRequest(request, target, relayHostToken, path, requestFetch) {
497
- const init = {
498
- method: request.method,
499
- headers: forwardHeaders(request.headers, relayHostToken, target.workspaceId),
500
- body: request.body,
501
- duplex: "half",
502
- redirect: "manual"
503
- };
504
- if (request.method === "GET" || request.method === "HEAD") delete init.body;
505
- const upstream = await requestFetch(targetUrl(target, path, new URL(request.url).search), init);
506
- const headers2 = new Headers(upstream.headers);
507
- headers2.delete("access-control-allow-origin");
508
- headers2.delete("access-control-allow-credentials");
509
- headers2.delete("access-control-allow-headers");
510
- headers2.delete("access-control-allow-methods");
511
- headers2.delete("access-control-expose-headers");
512
- headers2.delete("access-control-max-age");
513
- return new Response(upstream.body, {
514
- status: upstream.status,
515
- statusText: upstream.statusText,
516
- headers: headers2
517
- });
787
+ async function forwardWorkspaceRelayRequest(request, target, relayHostToken, path, requestFetch, timeoutMs, trace) {
788
+ const controller = new AbortController();
789
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
790
+ const span = (name, run) => trace ? trace.span(name, run) : run();
791
+ try {
792
+ const upstream = await span("upstream-fetch", async () => await requestFetch(
793
+ targetUrl(target, path, new URL(request.url).search),
794
+ workspaceRelayForwardRequestInit(request, relayHostToken, target.workspaceId, {
795
+ signal: controller.signal,
796
+ upstreamHeaders: target.upstreamHeaders
797
+ })
798
+ ));
799
+ const headers2 = new Headers(upstream.headers);
800
+ headers2.delete("access-control-allow-origin");
801
+ headers2.delete("access-control-allow-credentials");
802
+ headers2.delete("access-control-allow-headers");
803
+ headers2.delete("access-control-allow-methods");
804
+ headers2.delete("access-control-expose-headers");
805
+ headers2.delete("access-control-max-age");
806
+ for (const [key, value] of Object.entries(denyCorsHeaders(request) ?? {})) {
807
+ headers2.set(key, value);
808
+ }
809
+ return new Response(upstream.body, {
810
+ status: upstream.status,
811
+ statusText: upstream.statusText,
812
+ headers: headers2
813
+ });
814
+ } catch (err) {
815
+ const aborted = err instanceof Error && err.name === "AbortError";
816
+ return Response.json(
817
+ errorBody(
818
+ aborted ? "upstream_timeout" : "upstream_unavailable",
819
+ aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable"
820
+ ),
821
+ { status: aborted ? 504 : 503, headers: denyCorsHeaders(request) }
822
+ );
823
+ } finally {
824
+ clearTimeout(timer);
825
+ }
518
826
  }
519
827
  function getAuditSuppressedCount(options) {
520
828
  return auditSamplers.get(options)?.suppressedCount ?? 0;
@@ -545,7 +853,14 @@ function checkMetricsAuth(options, request) {
545
853
  }
546
854
  return { ok: true };
547
855
  }
548
- if (!options.metricsRemoteAddress) return { ok: true };
856
+ if (!options.metricsRemoteAddress) {
857
+ return {
858
+ ok: false,
859
+ status: 401,
860
+ code: "metrics_unauthorized",
861
+ message: "Metrics endpoint is restricted to loopback or bearer-token clients"
862
+ };
863
+ }
549
864
  const address = options.metricsRemoteAddress(request);
550
865
  if (isLoopbackAddress(address)) return { ok: true };
551
866
  return {
@@ -584,6 +899,7 @@ function createWorkspaceRelay(options) {
584
899
  if (origin.startsWith("http://localhost:")) return origin;
585
900
  if (origin.startsWith("http://127.0.0.1:")) return origin;
586
901
  if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
902
+ if (/^https:\/\/([a-z0-9-]+\.)*claxedo\.com$/.test(origin)) return origin;
587
903
  return void 0;
588
904
  },
589
905
  allowHeaders: [
@@ -591,7 +907,12 @@ function createWorkspaceRelay(options) {
591
907
  "Authorization",
592
908
  "Content-Type",
593
909
  "X-Daytona-Skip-Preview-Warning",
594
- "X-Workspace-Id"
910
+ "X-Workspace-Id",
911
+ "X-OpenCode-Directory",
912
+ "X-Claxedo-Runner",
913
+ "X-Claxedo-Model",
914
+ "X-Claxedo-Draft-Id",
915
+ "X-Claxedo-Binary"
595
916
  ],
596
917
  allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
597
918
  }));
@@ -655,21 +976,27 @@ function createWorkspaceRelay(options) {
655
976
  return c.json(buildMetricsBody(options));
656
977
  });
657
978
  app.all("/workspaces/:workspaceId/*", async (c) => {
979
+ const trace = createWorkspaceRelayTrace();
658
980
  if (options.isDraining?.()) {
659
981
  return Response.json(
660
982
  errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
661
983
  { status: 503, headers: denyCorsHeaders(c.req.raw) }
662
984
  );
663
985
  }
664
- const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"));
665
- if (!relay.ok) return relay.response;
666
- return await forwardRequest(
667
- new Request(c.req.raw),
668
- relay.request.target,
669
- relay.request.relayHostToken,
670
- relay.request.path,
671
- options.fetch ?? fetch
672
- );
986
+ const response = await trace.span("relay-total", async () => {
987
+ const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"), trace);
988
+ if (!relay.ok) return relay.response;
989
+ return await forwardWorkspaceRelayRequest(
990
+ new Request(c.req.raw),
991
+ relay.request.target,
992
+ relay.request.relayHostToken,
993
+ relay.request.path,
994
+ options.fetch ?? fetch,
995
+ options.forwardTimeoutMs ?? 3e4,
996
+ trace
997
+ );
998
+ });
999
+ return workspaceRelayTimingResponse(response, trace);
673
1000
  });
674
1001
  return app;
675
1002
  }
@@ -677,38 +1004,79 @@ function createWorkspaceRelay(options) {
677
1004
  // src/bun.ts
678
1005
  var TUNNEL_PENDING_HTTP_CAP = 32;
679
1006
  var TUNNEL_CHANNEL_CAP = 16;
680
- var HOST_TUNNEL_REGISTRATION_CONCURRENT_CAP = 3;
681
1007
  var HOST_TUNNEL_REGISTRATION_RECONNECT_CAP = 5;
682
1008
  var HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS = 6e4;
683
1009
  var WS_MAX_PAYLOAD_LENGTH_BYTES = 16 * 1024 * 1024;
1010
+ var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1011
+ var TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT = 3e4;
1012
+ var DIRECT_HTTP_TIMEOUT_MS_DEFAULT = 3e4;
1013
+ var UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT = 1e4;
1014
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
1015
+ var WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
1016
+ var HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT = 2;
684
1017
  var TUNNEL_MESSAGE_BUFFER_CAP_BYTES = 4 * 1024 * 1024;
685
1018
  var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
686
1019
  var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
687
1020
  var HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT = 250;
688
- var fragmentationStats = {
689
- fragmentsBuffered: 0,
690
- oversizedClosed: 0
691
- };
692
- function getFragmentationStats() {
1021
+ function createFragmentationStats() {
693
1022
  return {
694
- fragmentsBuffered: fragmentationStats.fragmentsBuffered,
695
- oversizedClosed: fragmentationStats.oversizedClosed
1023
+ fragmentsBuffered: 0,
1024
+ oversizedClosed: 0
696
1025
  };
697
1026
  }
698
- function resetFragmentationStats() {
699
- fragmentationStats.fragmentsBuffered = 0;
700
- fragmentationStats.oversizedClosed = 0;
1027
+ function createSlowConsumerStats() {
1028
+ return {
1029
+ overflowEvents: 0,
1030
+ timerFired: 0,
1031
+ droppedRequests: 0
1032
+ };
701
1033
  }
702
- var slowConsumerStats = {
703
- overflowEvents: 0,
704
- timerFired: 0,
705
- droppedRequests: 0
706
- };
707
- function getSlowConsumerStats() {
708
- return { ...slowConsumerStats };
1034
+ function readFragmentationStats(stats) {
1035
+ return {
1036
+ fragmentsBuffered: stats.fragmentsBuffered,
1037
+ oversizedClosed: stats.oversizedClosed
1038
+ };
709
1039
  }
710
- function resetSlowConsumerStats() {
711
- slowConsumerStats = { overflowEvents: 0, timerFired: 0, droppedRequests: 0 };
1040
+ function resetFragmentationStats(stats) {
1041
+ stats.fragmentsBuffered = 0;
1042
+ stats.oversizedClosed = 0;
1043
+ }
1044
+ function readSlowConsumerStats(stats) {
1045
+ return { ...stats };
1046
+ }
1047
+ function resetSlowConsumerStats(stats) {
1048
+ stats.overflowEvents = 0;
1049
+ stats.timerFired = 0;
1050
+ stats.droppedRequests = 0;
1051
+ }
1052
+ function createDirectHttpLimiter(limit) {
1053
+ if (!Number.isInteger(limit) || !limit || limit <= 0) return void 0;
1054
+ const max = limit;
1055
+ let active = 0;
1056
+ const queue = [];
1057
+ function drain() {
1058
+ if (active >= max) return;
1059
+ const next = queue.shift();
1060
+ if (!next) return;
1061
+ active++;
1062
+ next();
1063
+ }
1064
+ return {
1065
+ async acquire() {
1066
+ if (active < max) {
1067
+ active++;
1068
+ } else {
1069
+ await new Promise((resolve) => queue.push(resolve));
1070
+ }
1071
+ let released = false;
1072
+ return () => {
1073
+ if (released) return;
1074
+ released = true;
1075
+ active = Math.max(0, active - 1);
1076
+ drain();
1077
+ };
1078
+ }
1079
+ };
712
1080
  }
713
1081
  function jsonError(code, message, status) {
714
1082
  return new Response(JSON.stringify({ error: { code, message } }), {
@@ -716,6 +1084,35 @@ function jsonError(code, message, status) {
716
1084
  headers: { "content-type": "application/json" }
717
1085
  });
718
1086
  }
1087
+ function corsJsonError(request, code, message, status) {
1088
+ const headers2 = relayCorsHeaders(request);
1089
+ headers2.set("content-type", "application/json");
1090
+ return new Response(JSON.stringify({ error: { code, message } }), { status, headers: headers2 });
1091
+ }
1092
+ function safeCloseCode(input, fallback = 1011) {
1093
+ if (!Number.isInteger(input)) return fallback;
1094
+ const code = input;
1095
+ if (code === 1e3) return code;
1096
+ if (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
1097
+ if (code >= 3e3 && code <= 4999) return code;
1098
+ return fallback;
1099
+ }
1100
+ function closeWebSocket(ws, code, reason, fallback = 1011) {
1101
+ try {
1102
+ ws.close(safeCloseCode(code, fallback), reason);
1103
+ } catch {
1104
+ try {
1105
+ ws.close(fallback, reason);
1106
+ } catch {
1107
+ }
1108
+ }
1109
+ }
1110
+ function upstreamCloseReason(event) {
1111
+ if (event.reason.trim()) return event.reason;
1112
+ if (event.code === 1e3) return "";
1113
+ if (event.code === 1005 || event.code === 1006) return "Upstream WebSocket closed abnormally";
1114
+ return `Upstream WebSocket closed unexpectedly with code ${event.code}`;
1115
+ }
719
1116
  function workspaceIdFromPath(pathname) {
720
1117
  return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
721
1118
  }
@@ -725,6 +1122,23 @@ function hostIdFromTunnelPath(pathname) {
725
1122
  function websocketRequest(request) {
726
1123
  return request.headers.get("upgrade")?.toLowerCase() === "websocket";
727
1124
  }
1125
+ function roundedMs2(input) {
1126
+ return Math.round(input * 100) / 100;
1127
+ }
1128
+ function relayWebSocketTraceEnabled(request) {
1129
+ return request.headers.get("x-claxedo-relay-ws-trace") === "1";
1130
+ }
1131
+ function sendRelayWebSocketTrace(ws) {
1132
+ const trace = ws.data.trace;
1133
+ if (!trace || trace.emitted) return;
1134
+ trace.emitted = true;
1135
+ ws.send(JSON.stringify({
1136
+ type: "relay.trace",
1137
+ wsUpstreamOpenMs: trace.upstreamOpenMs === void 0 ? void 0 : roundedMs2(trace.upstreamOpenMs),
1138
+ queuedFrames: trace.queuedFrames,
1139
+ maxQueuedDelayMs: roundedMs2(trace.maxQueuedDelayMs)
1140
+ }));
1141
+ }
728
1142
  function relayWebSocketPayload(input) {
729
1143
  if (typeof input === "string") return input;
730
1144
  if (input instanceof ArrayBuffer) return input;
@@ -748,6 +1162,11 @@ function headers(input) {
748
1162
  }
749
1163
  return result;
750
1164
  }
1165
+ function isEventStream(input) {
1166
+ return Object.entries(input).some(
1167
+ ([key, value]) => key.toLowerCase() === "content-type" && value.toLowerCase().includes("text/event-stream")
1168
+ );
1169
+ }
751
1170
  function allowedCorsOrigin(origin) {
752
1171
  if (!origin) return;
753
1172
  if (origin.startsWith("http://localhost:")) return origin;
@@ -774,7 +1193,7 @@ function relayCorsHeaders(request, input = new Headers()) {
774
1193
  const origin = allowedCorsOrigin(request.headers.get("origin"));
775
1194
  if (origin) {
776
1195
  result.set("access-control-allow-origin", origin);
777
- result.set("access-control-allow-headers", "Accept, Authorization, Content-Type, X-Daytona-Skip-Preview-Warning, X-Workspace-Id");
1196
+ 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");
778
1197
  result.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
779
1198
  }
780
1199
  return result;
@@ -801,7 +1220,7 @@ function decodedFrame(frame) {
801
1220
  const body = Buffer.from(frame.data_base64, "base64");
802
1221
  return frame.binary ? new Uint8Array(body) : body.toString("utf8");
803
1222
  }
804
- function tunnelMessage(ws, input) {
1223
+ function tunnelMessage(ws, input, stats) {
805
1224
  if (typeof input !== "string") return;
806
1225
  const combined = ws.data.messageBuffer.length > 0 ? ws.data.messageBuffer + input : input;
807
1226
  let parsed;
@@ -809,20 +1228,26 @@ function tunnelMessage(ws, input) {
809
1228
  parsed = JSON.parse(combined);
810
1229
  } catch {
811
1230
  if (combined.length > TUNNEL_MESSAGE_BUFFER_CAP_BYTES) {
812
- fragmentationStats.oversizedClosed += 1;
1231
+ stats.oversizedClosed += 1;
813
1232
  ws.data.messageBuffer = "";
814
1233
  ws.close(1009, "Tunnel message buffer exceeded 4 MB");
815
1234
  return;
816
1235
  }
817
- fragmentationStats.fragmentsBuffered += 1;
1236
+ stats.fragmentsBuffered += 1;
818
1237
  ws.data.messageBuffer = combined;
819
1238
  return;
820
1239
  }
821
1240
  ws.data.messageBuffer = "";
822
- return isTunnelMessage(parsed) ? parsed : void 0;
1241
+ const validated = validateTunnelMessage(parsed);
1242
+ if (validated.ok) return validated.message;
1243
+ if (validated.reason === "protocol_mismatch") {
1244
+ ws.close(1002, `Tunnel protocol mismatch: expected ${validated.expected_protocol}`);
1245
+ }
1246
+ return;
823
1247
  }
824
1248
  function sendTunnelPing(ws) {
825
1249
  if (ws.readyState !== WebSocket.OPEN) return;
1250
+ ws.data.missedPongs += 1;
826
1251
  ws.send(JSON.stringify({
827
1252
  type: "ping",
828
1253
  protocol: TUNNEL_PROTOCOL_VERSION,
@@ -830,6 +1255,59 @@ function sendTunnelPing(ws) {
830
1255
  sent_at: Date.now()
831
1256
  }));
832
1257
  }
1258
+ function clearPendingTimers(entry) {
1259
+ clearTimeout(entry.timeout);
1260
+ if (entry.slowConsumerTimeout) {
1261
+ clearTimeout(entry.slowConsumerTimeout);
1262
+ entry.slowConsumerTimeout = void 0;
1263
+ }
1264
+ }
1265
+ function failPendingHttpResponse(input) {
1266
+ clearPendingTimers(input.entry);
1267
+ input.entry.pendingChunks.length = 0;
1268
+ input.entry.bytesQueued = 0;
1269
+ try {
1270
+ input.entry.controller.error(input.error);
1271
+ } catch {
1272
+ }
1273
+ if (!input.entry.responseStarted) {
1274
+ if (input.response) {
1275
+ input.entry.resolve(input.response);
1276
+ return;
1277
+ }
1278
+ input.entry.reject(input.error);
1279
+ }
1280
+ }
1281
+ function cleanupHostTunnelSocket(input) {
1282
+ if (input.ws.data.heartbeat) {
1283
+ clearInterval(input.ws.data.heartbeat);
1284
+ input.ws.data.heartbeat = void 0;
1285
+ }
1286
+ for (const pending of input.ws.data.pending.values()) {
1287
+ failPendingHttpResponse({
1288
+ entry: pending,
1289
+ 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),
1290
+ error: new Error("User-hosted tunnel disconnected")
1291
+ });
1292
+ }
1293
+ input.ws.data.pending.clear();
1294
+ for (const channel of input.ws.data.channels.values()) {
1295
+ if (input.closeChannels) closeWebSocket(channel, 1011, "User-hosted tunnel disconnected");
1296
+ }
1297
+ input.ws.data.channels.clear();
1298
+ if (!input.disconnectDirectory) return;
1299
+ if (input.hostTunnels.get(input.ws.data.hostId) === input.ws) {
1300
+ input.hostTunnels.delete(input.ws.data.hostId);
1301
+ input.options.directory?.disconnectHost(input.ws.data.hostId);
1302
+ scheduleHostTunnelStateChange(input.hostTunnelStateDebounce, input.options, {
1303
+ hostId: input.ws.data.hostId,
1304
+ workspaceId: input.ws.data.workspaceIds[0],
1305
+ path: `/host-tunnels/${input.ws.data.hostId}`,
1306
+ state: "disconnected",
1307
+ debounceMs: input.bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
1308
+ });
1309
+ }
1310
+ }
833
1311
  async function audit2(options, input) {
834
1312
  await options.audit?.({
835
1313
  action: input.action,
@@ -840,9 +1318,8 @@ async function audit2(options, input) {
840
1318
  path: input.path
841
1319
  });
842
1320
  }
843
- var hostTunnelStateDebounce = /* @__PURE__ */ new Map();
844
- function scheduleHostTunnelStateChange(options, input) {
845
- const existing = hostTunnelStateDebounce.get(input.hostId);
1321
+ function scheduleHostTunnelStateChange(state, options, input) {
1322
+ const existing = state.get(input.hostId);
846
1323
  const entry = existing ?? {
847
1324
  intendedState: "disconnected",
848
1325
  lastWritten: "disconnected",
@@ -856,7 +1333,7 @@ function scheduleHostTunnelStateChange(options, input) {
856
1333
  entry.flushTimer = void 0;
857
1334
  if (entry.intendedState === entry.lastWritten) {
858
1335
  if (entry.intendedState === "disconnected") {
859
- hostTunnelStateDebounce.delete(input.hostId);
1336
+ state.delete(input.hostId);
860
1337
  }
861
1338
  return;
862
1339
  }
@@ -868,13 +1345,7 @@ function scheduleHostTunnelStateChange(options, input) {
868
1345
  path: entry.pending.path
869
1346
  });
870
1347
  }, input.debounceMs);
871
- hostTunnelStateDebounce.set(input.hostId, entry);
872
- }
873
- function __resetHostTunnelStateDebounce() {
874
- for (const entry of hostTunnelStateDebounce.values()) {
875
- if (entry.flushTimer) clearTimeout(entry.flushTimer);
876
- }
877
- hostTunnelStateDebounce.clear();
1348
+ state.set(input.hostId, entry);
878
1349
  }
879
1350
  function drainPendingChunks(entry) {
880
1351
  while (entry.pendingChunks.length > 0) {
@@ -903,9 +1374,9 @@ function enqueueChunkWithBackpressure(input) {
903
1374
  entry.pendingChunks.push(chunk);
904
1375
  entry.bytesQueued += chunk.byteLength;
905
1376
  if (!entry.slowConsumerTimeout) {
906
- slowConsumerStats.overflowEvents += 1;
1377
+ input.slowConsumerStats.overflowEvents += 1;
907
1378
  entry.slowConsumerTimeout = setTimeout(() => {
908
- slowConsumerStats.timerFired += 1;
1379
+ input.slowConsumerStats.timerFired += 1;
909
1380
  const error = new Error("slow_consumer_timeout: downstream consumer did not drain in time");
910
1381
  try {
911
1382
  entry.controller.error(error);
@@ -918,7 +1389,7 @@ function enqueueChunkWithBackpressure(input) {
918
1389
  entry.resolve(jsonError("slow_consumer_timeout", "Downstream consumer did not drain in time", 503));
919
1390
  }
920
1391
  input.ws.data.pending.delete(input.requestId);
921
- slowConsumerStats.droppedRequests += 1;
1392
+ input.slowConsumerStats.droppedRequests += 1;
922
1393
  }, input.slowConsumerTimeoutMs);
923
1394
  }
924
1395
  return;
@@ -929,7 +1400,8 @@ function enqueueChunkWithBackpressure(input) {
929
1400
  }
930
1401
  }
931
1402
  var __slowConsumerInternalsForTest = {
932
- enqueueChunkWithBackpressure
1403
+ enqueueChunkWithBackpressure,
1404
+ createSlowConsumerStats
933
1405
  };
934
1406
  async function tunnelHttpRequest(input) {
935
1407
  if (input.ws.readyState !== WebSocket.OPEN) {
@@ -952,13 +1424,47 @@ async function tunnelHttpRequest(input) {
952
1424
  const entry = input.ws.data.pending.get(requestId);
953
1425
  if (!entry) return;
954
1426
  drainPendingChunks(entry);
1427
+ },
1428
+ cancel() {
1429
+ const entry = input.ws.data.pending.get(requestId);
1430
+ if (!entry) return;
1431
+ input.ws.data.pending.delete(requestId);
1432
+ clearPendingTimers(entry);
1433
+ entry.pendingChunks.length = 0;
1434
+ entry.bytesQueued = 0;
1435
+ if (input.ws.readyState === WebSocket.OPEN) {
1436
+ input.ws.send(JSON.stringify({
1437
+ type: "http.response.flow",
1438
+ protocol: TUNNEL_PROTOCOL_VERSION,
1439
+ request_id: requestId,
1440
+ paused: false,
1441
+ reason: "closed"
1442
+ }));
1443
+ }
955
1444
  }
956
1445
  }, new ByteLengthQueuingStrategy({ highWaterMark: input.slowConsumerHighWaterMarkBytes }));
957
1446
  const pending = new Promise((resolve, reject) => {
958
1447
  const timeout = setTimeout(() => {
1448
+ const entry = input.ws.data.pending.get(requestId);
1449
+ if (!entry) return;
959
1450
  input.ws.data.pending.delete(requestId);
960
- reject(new Error("User-hosted tunnel response timed out"));
961
- }, 3e4);
1451
+ if (entry.responseStarted) {
1452
+ clearPendingTimers(entry);
1453
+ entry.pendingChunks.length = 0;
1454
+ entry.bytesQueued = 0;
1455
+ try {
1456
+ entry.controller.close();
1457
+ } catch {
1458
+ }
1459
+ return;
1460
+ }
1461
+ const error = new Error("User-hosted tunnel response timed out");
1462
+ failPendingHttpResponse({
1463
+ entry,
1464
+ response: corsJsonError(input.request, "user_hosted_tunnel_timeout", "User-hosted tunnel response timed out", 504),
1465
+ error
1466
+ });
1467
+ }, input.responseTimeoutMs);
962
1468
  input.ws.data.pending.set(requestId, {
963
1469
  controller,
964
1470
  stream,
@@ -970,7 +1476,13 @@ async function tunnelHttpRequest(input) {
970
1476
  responseStarted: false
971
1477
  });
972
1478
  });
973
- const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : encoded(await input.request.arrayBuffer());
1479
+ const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : await readBoundedBody(input.request, input.requestBodyMaxBytes);
1480
+ if (body && "response" in body) {
1481
+ const entry = input.ws.data.pending.get(requestId);
1482
+ if (entry) clearPendingTimers(entry);
1483
+ input.ws.data.pending.delete(requestId);
1484
+ return body.response;
1485
+ }
974
1486
  input.ws.send(JSON.stringify({
975
1487
  type: "http.request",
976
1488
  protocol: TUNNEL_PROTOCOL_VERSION,
@@ -986,33 +1498,81 @@ async function tunnelHttpRequest(input) {
986
1498
  // cookies to the host process running on the user's laptop.
987
1499
  { userHosted: true }
988
1500
  )),
989
- ...body ? { body_base64: body } : {},
1501
+ ...body?.bodyBase64 ? { body_base64: body.bodyBase64 } : {},
990
1502
  end: true
991
1503
  }));
992
1504
  return await pending;
993
1505
  }
1506
+ async function readBoundedBody(request, maxBytes) {
1507
+ if (!request.body) return void 0;
1508
+ const reader = request.body.getReader();
1509
+ const chunks = [];
1510
+ let total = 0;
1511
+ for (; ; ) {
1512
+ const next = await reader.read();
1513
+ if (next.done) break;
1514
+ total += next.value.byteLength;
1515
+ if (total > maxBytes) {
1516
+ try {
1517
+ await reader.cancel();
1518
+ } catch {
1519
+ }
1520
+ return {
1521
+ response: corsJsonError(request, "request_body_too_large", "Tunnel request body exceeds the relay limit", 413)
1522
+ };
1523
+ }
1524
+ chunks.push(next.value);
1525
+ }
1526
+ const body = new Uint8Array(total);
1527
+ chunks.reduce((offset, chunk) => {
1528
+ body.set(chunk, offset);
1529
+ return offset + chunk.byteLength;
1530
+ }, 0);
1531
+ return { bodyBase64: encoded(body.buffer) };
1532
+ }
994
1533
  async function directHttpRequest(input) {
995
- const init = {
996
- method: input.request.method,
997
- headers: workspaceRelayForwardHeaders(
998
- input.request.headers,
1534
+ const controller = new AbortController();
1535
+ const timer = setTimeout(() => controller.abort(), input.timeoutMs);
1536
+ const span = (name, run) => input.trace ? input.trace.span(name, run) : run();
1537
+ try {
1538
+ const init = workspaceRelayForwardRequestInit(
1539
+ input.request,
999
1540
  input.relayHostToken,
1000
1541
  input.workspaceId,
1001
- // T26: cloud-vm direct path. Cookies pass through (default) — workspace
1002
- // dashboards in the VM may legitimately need session cookies.
1003
- { userHosted: false }
1004
- ),
1005
- body: input.request.body,
1006
- duplex: "half",
1007
- redirect: "manual"
1008
- };
1009
- if (input.request.method === "GET" || input.request.method === "HEAD") delete init.body;
1010
- const upstream = await fetch(input.targetUrl, init);
1011
- return new Response(upstream.body, {
1012
- status: upstream.status,
1013
- statusText: upstream.statusText,
1014
- headers: relayCorsHeaders(input.request, upstream.headers)
1015
- });
1542
+ {
1543
+ // T26: cloud-vm direct path. Cookies pass through (default) — workspace
1544
+ // dashboards in the VM may legitimately need session cookies.
1545
+ userHosted: false,
1546
+ upstreamHeaders: input.upstreamHeaders,
1547
+ signal: controller.signal
1548
+ }
1549
+ );
1550
+ if (input.request.method !== "GET" && input.request.method !== "HEAD" && input.request.body) {
1551
+ init.body = await input.request.arrayBuffer();
1552
+ }
1553
+ const limiter = input.limiter;
1554
+ const release = limiter ? await span("direct-http-queue", () => limiter.acquire()) : void 0;
1555
+ try {
1556
+ const upstream = await span("upstream-fetch", async () => await fetch(input.targetUrl, init));
1557
+ return new Response(upstream.body, {
1558
+ status: upstream.status,
1559
+ statusText: upstream.statusText,
1560
+ headers: relayCorsHeaders(input.request, upstream.headers)
1561
+ });
1562
+ } finally {
1563
+ release?.();
1564
+ }
1565
+ } catch (err) {
1566
+ const aborted = err instanceof Error && err.name === "AbortError";
1567
+ return corsJsonError(
1568
+ input.request,
1569
+ aborted ? "upstream_timeout" : "upstream_unavailable",
1570
+ aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable",
1571
+ aborted ? 504 : 503
1572
+ );
1573
+ } finally {
1574
+ clearTimeout(timer);
1575
+ }
1016
1576
  }
1017
1577
  function hostTunnel(hostTunnels, hostId) {
1018
1578
  const tunnel = hostTunnels.get(hostId);
@@ -1033,12 +1593,30 @@ async function authorizeHostTunnel(options, bunOptions, request, input) {
1033
1593
  }
1034
1594
  function createWorkspaceRelayBun(options, bunOptions = {}) {
1035
1595
  const hostTunnels = /* @__PURE__ */ new Map();
1596
+ const relayClients = /* @__PURE__ */ new Set();
1036
1597
  const hostTunnelRegistrations = /* @__PURE__ */ new Map();
1598
+ const directHttpLimiter = createDirectHttpLimiter(bunOptions.directHttpConcurrency);
1599
+ const hostTunnelStateDebounce = /* @__PURE__ */ new Map();
1600
+ const fragmentationStats = createFragmentationStats();
1601
+ const slowConsumerStats = createSlowConsumerStats();
1602
+ const telemetry = {
1603
+ getFragmentationStats: () => readFragmentationStats(fragmentationStats),
1604
+ resetFragmentationStats: () => resetFragmentationStats(fragmentationStats),
1605
+ getSlowConsumerStats: () => readSlowConsumerStats(slowConsumerStats),
1606
+ resetSlowConsumerStats: () => resetSlowConsumerStats(slowConsumerStats)
1607
+ };
1037
1608
  let draining = false;
1038
1609
  const drainController = {
1039
1610
  isDraining: () => draining,
1040
1611
  setDraining: (value) => {
1041
1612
  draining = value;
1613
+ if (!value) return;
1614
+ for (const tunnel of hostTunnels.values()) {
1615
+ closeWebSocket(tunnel, 1012, "Workspace relay is draining", 1012);
1616
+ }
1617
+ for (const client of relayClients) {
1618
+ closeWebSocket(client, 1012, "Workspace relay is draining", 1012);
1619
+ }
1042
1620
  },
1043
1621
  pendingCount: () => {
1044
1622
  let total = 0;
@@ -1061,8 +1639,8 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1061
1639
  }
1062
1640
  };
1063
1641
  const mergedMetricsSources = {
1064
- fragmentation: options.metricsSources?.fragmentation ?? getFragmentationStats,
1065
- slowConsumer: options.metricsSources?.slowConsumer ?? getSlowConsumerStats,
1642
+ fragmentation: options.metricsSources?.fragmentation ?? telemetry.getFragmentationStats,
1643
+ slowConsumer: options.metricsSources?.slowConsumer ?? telemetry.getSlowConsumerStats,
1066
1644
  drainPending: options.metricsSources?.drainPending ?? drainController.pendingCount
1067
1645
  };
1068
1646
  const requestRemoteAddress = /* @__PURE__ */ new WeakMap();
@@ -1076,7 +1654,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1076
1654
  function getRegistrationTracker(hostId) {
1077
1655
  let tracker = hostTunnelRegistrations.get(hostId);
1078
1656
  if (!tracker) {
1079
- tracker = { current: 0, recent: [] };
1657
+ tracker = { recent: [] };
1080
1658
  hostTunnelRegistrations.set(hostId, tracker);
1081
1659
  }
1082
1660
  return tracker;
@@ -1097,6 +1675,12 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1097
1675
  if (ip) requestRemoteAddress.set(request, ip);
1098
1676
  } catch {
1099
1677
  }
1678
+ if (url.pathname === "/health") {
1679
+ return Response.json(
1680
+ draining ? { ok: false, service: "workspace-relay", draining: true } : { ok: true, service: "workspace-relay" },
1681
+ { status: draining ? 503 : 200 }
1682
+ );
1683
+ }
1100
1684
  if (draining && websocketRequest(request) && hostId) {
1101
1685
  return jsonError(
1102
1686
  "relay_draining",
@@ -1126,13 +1710,6 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1126
1710
  const tracker = getRegistrationTracker(hostId);
1127
1711
  const now = Date.now();
1128
1712
  pruneReconnects(tracker, now);
1129
- if (tracker.current >= HOST_TUNNEL_REGISTRATION_CONCURRENT_CAP) {
1130
- return jsonError(
1131
- "too_many_concurrent_host_tunnels",
1132
- "Too many concurrent host-tunnel registrations for this host",
1133
- 429
1134
- );
1135
- }
1136
1713
  if (tracker.recent.length >= HOST_TUNNEL_REGISTRATION_RECONNECT_CAP) {
1137
1714
  return jsonError(
1138
1715
  "too_many_host_tunnel_reconnects",
@@ -1147,10 +1724,10 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1147
1724
  workspaceIds,
1148
1725
  pending: /* @__PURE__ */ new Map(),
1149
1726
  channels: /* @__PURE__ */ new Map(),
1727
+ missedPongs: 0,
1150
1728
  messageBuffer: ""
1151
1729
  }
1152
1730
  })) {
1153
- tracker.current += 1;
1154
1731
  tracker.recent.push(now);
1155
1732
  return;
1156
1733
  }
@@ -1163,32 +1740,45 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1163
1740
  headers: relayCorsHeaders(request)
1164
1741
  });
1165
1742
  }
1166
- const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
1167
- if (!relay.ok) return relay.response;
1168
- if (!websocketRequest(request) && relay.request.target.access === "user-hosted") {
1169
- const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId);
1170
- return tunnel ? await tunnelHttpRequest({
1171
- ws: tunnel,
1172
- request,
1173
- workspaceId: relay.request.target.workspaceId,
1174
- path: `${relay.request.path}${url.search}`,
1175
- relayHostToken: relay.request.relayHostToken,
1176
- slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
1177
- slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
1178
- }) : new Response("User-hosted workspace is offline", { status: 503 });
1179
- }
1180
1743
  if (!websocketRequest(request)) {
1181
- return await directHttpRequest({
1182
- request,
1183
- targetUrl: workspaceRelayTargetUrl(
1184
- relay.request.target,
1185
- relay.request.path,
1186
- url.search
1187
- ).toString(),
1188
- relayHostToken: relay.request.relayHostToken,
1189
- workspaceId: relay.request.target.workspaceId
1744
+ const trace = createWorkspaceRelayTrace();
1745
+ const response = await trace.span("relay-total", async () => {
1746
+ const relay2 = await authorizeWorkspaceRelayRequest(options, request, workspaceId, trace);
1747
+ if (!relay2.ok) return relay2.response;
1748
+ if (relay2.request.target.access === "user-hosted") {
1749
+ const tunnel = hostTunnel(hostTunnels, relay2.request.target.hostId);
1750
+ return tunnel ? await trace.span("tunnel-http", async () => await tunnelHttpRequest({
1751
+ ws: tunnel,
1752
+ request,
1753
+ workspaceId: relay2.request.target.workspaceId,
1754
+ path: `${relay2.request.path}${url.search}`,
1755
+ relayHostToken: relay2.request.relayHostToken,
1756
+ slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
1757
+ slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
1758
+ slowConsumerStats,
1759
+ requestBodyMaxBytes: bunOptions.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT,
1760
+ responseTimeoutMs: bunOptions.tunnelHttpResponseTimeoutMs ?? TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT
1761
+ })) : new Response("User-hosted workspace is offline", { status: 503 });
1762
+ }
1763
+ return await directHttpRequest({
1764
+ request,
1765
+ targetUrl: workspaceRelayTargetUrl(
1766
+ relay2.request.target,
1767
+ relay2.request.path,
1768
+ url.search
1769
+ ).toString(),
1770
+ relayHostToken: relay2.request.relayHostToken,
1771
+ workspaceId: relay2.request.target.workspaceId,
1772
+ upstreamHeaders: relay2.request.target.upstreamHeaders,
1773
+ timeoutMs: bunOptions.directHttpTimeoutMs ?? DIRECT_HTTP_TIMEOUT_MS_DEFAULT,
1774
+ limiter: directHttpLimiter,
1775
+ trace
1776
+ });
1190
1777
  });
1778
+ return workspaceRelayTimingResponse(response, trace);
1191
1779
  }
1780
+ const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
1781
+ if (!relay.ok) return relay.response;
1192
1782
  const originDenied = requireAllowedOrigin(request);
1193
1783
  if (originDenied) return originDenied;
1194
1784
  if (relay.request.target.access === "user-hosted") {
@@ -1230,9 +1820,17 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1230
1820
  relay.request.relayHostToken,
1231
1821
  relay.request.target.workspaceId,
1232
1822
  // T26: cloud-vm WS upgrade. Cookies pass through (default).
1233
- { userHosted: false }
1823
+ { userHosted: false, upstreamHeaders: relay.request.target.upstreamHeaders }
1234
1824
  )),
1235
- queue: []
1825
+ queue: [],
1826
+ ...relayWebSocketTraceEnabled(request) ? {
1827
+ trace: {
1828
+ acceptedAt: performance.now(),
1829
+ queuedFrames: 0,
1830
+ maxQueuedDelayMs: 0,
1831
+ emitted: false
1832
+ }
1833
+ } : {}
1236
1834
  }
1237
1835
  })) {
1238
1836
  return;
@@ -1245,22 +1843,39 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1245
1843
  if (ws.data.kind === "host-tunnel") {
1246
1844
  const parsed = tunnelMessage(
1247
1845
  ws,
1248
- message
1846
+ message,
1847
+ fragmentationStats
1249
1848
  );
1250
1849
  if (parsed?.type === "ping") {
1251
1850
  options.directory?.recordPong(ws.data.hostId);
1252
1851
  ws.send(JSON.stringify(makeTunnelPong(parsed)));
1253
1852
  }
1254
1853
  if (parsed?.type === "pong") {
1854
+ ws.data.missedPongs = 0;
1255
1855
  options.directory?.recordPong(ws.data.hostId);
1256
1856
  }
1857
+ if (parsed?.type === "error") {
1858
+ if (parsed.request_id) {
1859
+ const pending = ws.data.pending.get(parsed.request_id);
1860
+ if (pending) {
1861
+ ws.data.pending.delete(parsed.request_id);
1862
+ failPendingHttpResponse({
1863
+ entry: pending,
1864
+ response: jsonError(parsed.code, parsed.message, 502),
1865
+ error: new Error(parsed.message)
1866
+ });
1867
+ }
1868
+ }
1869
+ }
1257
1870
  if (parsed?.type === "http.response.start") {
1258
1871
  const pending = ws.data.pending.get(parsed.request_id);
1259
1872
  if (pending) {
1873
+ const response = parsed;
1260
1874
  pending.responseStarted = true;
1875
+ if (isEventStream(response.headers)) clearTimeout(pending.timeout);
1261
1876
  pending.resolve(new Response(pending.stream, {
1262
- status: parsed.status,
1263
- headers: headers(parsed.headers)
1877
+ status: response.status,
1878
+ headers: headers(response.headers)
1264
1879
  }));
1265
1880
  }
1266
1881
  }
@@ -1272,7 +1887,8 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1272
1887
  requestId: parsed.request_id,
1273
1888
  entry,
1274
1889
  chunk: decoded(parsed.body_base64),
1275
- slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
1890
+ slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
1891
+ slowConsumerStats
1276
1892
  });
1277
1893
  }
1278
1894
  }
@@ -1305,14 +1921,24 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1305
1921
  }
1306
1922
  if (parsed?.type === "ws.frame") {
1307
1923
  const frame = parsed;
1308
- ws.data.channels.get(frame.channel_id)?.send(decodedFrame(frame));
1924
+ const channel = ws.data.channels.get(frame.channel_id);
1925
+ if (!channel || channel.readyState !== WebSocket.OPEN) {
1926
+ ws.data.channels.delete(frame.channel_id);
1927
+ return;
1928
+ }
1929
+ if (channel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
1930
+ ws.data.channels.delete(frame.channel_id);
1931
+ closeWebSocket(channel, 1011, "Client WebSocket backpressure limit exceeded");
1932
+ return;
1933
+ }
1934
+ channel.send(decodedFrame(frame));
1309
1935
  }
1310
1936
  if (parsed?.type === "ws.close") {
1311
1937
  const close = parsed;
1312
1938
  const channel = ws.data.channels.get(close.channel_id);
1313
1939
  if (channel) {
1314
1940
  ws.data.channels.delete(close.channel_id);
1315
- channel.close(close.code, close.reason);
1941
+ closeWebSocket(channel, close.code, close.reason);
1316
1942
  }
1317
1943
  }
1318
1944
  return;
@@ -1323,6 +1949,10 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1323
1949
  ws.close(1011, "User-hosted tunnel disconnected");
1324
1950
  return;
1325
1951
  }
1952
+ if (tunnel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
1953
+ closeWebSocket(ws, 1011, "Host tunnel backpressure limit exceeded");
1954
+ return;
1955
+ }
1326
1956
  tunnel.send(JSON.stringify({
1327
1957
  type: "ws.frame",
1328
1958
  protocol: TUNNEL_PROTOCOL_VERSION,
@@ -1335,19 +1965,41 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1335
1965
  ws.data.upstream.send(message);
1336
1966
  return;
1337
1967
  }
1338
- ws.data.queue.push(message);
1968
+ if (ws.data.queue.length >= (bunOptions.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT)) {
1969
+ closeWebSocket(ws, 1011, "Upstream WebSocket queue limit exceeded");
1970
+ return;
1971
+ }
1972
+ ws.data.queue.push({ payload: message, queuedAt: performance.now() });
1339
1973
  },
1340
1974
  open(ws) {
1341
1975
  if (ws.data.kind === "host-tunnel") {
1976
+ const previous = hostTunnels.get(ws.data.hostId);
1977
+ if (previous && previous !== ws) {
1978
+ cleanupHostTunnelSocket({
1979
+ ws: previous,
1980
+ hostTunnels,
1981
+ hostTunnelStateDebounce,
1982
+ options,
1983
+ bunOptions,
1984
+ disconnectDirectory: false,
1985
+ closeChannels: true
1986
+ });
1987
+ closeWebSocket(previous, 1012, "Host tunnel replaced by a newer connection", 1012);
1988
+ }
1342
1989
  hostTunnels.set(ws.data.hostId, ws);
1343
1990
  options.directory?.registerHostTunnel({
1344
1991
  hostId: ws.data.hostId,
1345
1992
  workspaceIds: ws.data.workspaceIds
1346
1993
  });
1347
1994
  ws.data.heartbeat = setInterval(() => {
1348
- sendTunnelPing(ws);
1995
+ const hostWs = ws;
1996
+ if (hostWs.data.missedPongs > (bunOptions.hostTunnelMaxMissedPongs ?? HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT)) {
1997
+ closeWebSocket(ws, 1001, "Host tunnel heartbeat timed out", 1001);
1998
+ return;
1999
+ }
2000
+ sendTunnelPing(hostWs);
1349
2001
  }, bunOptions.hostTunnelPingIntervalMs ?? 15e3);
1350
- scheduleHostTunnelStateChange(options, {
2002
+ scheduleHostTunnelStateChange(hostTunnelStateDebounce, options, {
1351
2003
  hostId: ws.data.hostId,
1352
2004
  workspaceId: ws.data.workspaceIds[0],
1353
2005
  path: `/host-tunnels/${ws.data.hostId}`,
@@ -1363,6 +2015,10 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1363
2015
  return;
1364
2016
  }
1365
2017
  tunnel.data.channels.set(ws.data.channelId, ws);
2018
+ if (tunnel.readyState !== WebSocket.OPEN) {
2019
+ closeWebSocket(ws, 1011, "User-hosted tunnel disconnected");
2020
+ return;
2021
+ }
1366
2022
  tunnel.send(JSON.stringify({
1367
2023
  type: "ws.open",
1368
2024
  protocol: TUNNEL_PROTOCOL_VERSION,
@@ -1381,76 +2037,105 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1381
2037
  }));
1382
2038
  return;
1383
2039
  }
2040
+ relayClients.add(ws);
1384
2041
  const data = ws.data;
1385
- const upstream = new WebSocket(data.upstreamUrl, {
2042
+ const UpstreamWebSocket = bunOptions.upstreamWebSocket ?? WebSocket;
2043
+ if (data.trace) data.trace.upstreamStartedAt = performance.now();
2044
+ const upstream = new UpstreamWebSocket(data.upstreamUrl, {
1386
2045
  headers: data.headers
1387
2046
  });
1388
2047
  upstream.binaryType = "arraybuffer";
1389
2048
  data.upstream = upstream;
2049
+ data.upstreamOpenTimer = setTimeout(() => {
2050
+ if (upstream.readyState === WebSocket.OPEN) return;
2051
+ closeWebSocket(upstream, 1001, "Upstream WebSocket open timed out", 1001);
2052
+ closeWebSocket(ws, 1011, "Upstream WebSocket open timed out");
2053
+ }, bunOptions.upstreamWebSocketOpenTimeoutMs ?? UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT);
1390
2054
  upstream.onopen = () => {
1391
- for (const item of data.queue.splice(0)) upstream.send(item);
2055
+ const openedAt = performance.now();
2056
+ if (data.upstreamOpenTimer) {
2057
+ clearTimeout(data.upstreamOpenTimer);
2058
+ data.upstreamOpenTimer = void 0;
2059
+ }
2060
+ if (data.trace) {
2061
+ data.trace.upstreamOpenMs = openedAt - (data.trace.upstreamStartedAt ?? data.trace.acceptedAt);
2062
+ data.trace.queuedFrames = data.queue.length;
2063
+ data.trace.maxQueuedDelayMs = data.queue.reduce(
2064
+ (max, item) => Math.max(max, openedAt - item.queuedAt),
2065
+ 0
2066
+ );
2067
+ sendRelayWebSocketTrace(ws);
2068
+ }
2069
+ for (const item of data.queue.splice(0)) upstream.send(item.payload);
1392
2070
  };
1393
2071
  upstream.onmessage = (event) => {
1394
2072
  const payload = relayWebSocketPayload(event.data);
1395
2073
  if (payload !== void 0) ws.send(payload);
1396
2074
  };
1397
2075
  upstream.onclose = (event) => {
1398
- ws.close(event.code, event.reason);
2076
+ if (data.upstreamOpenTimer) {
2077
+ clearTimeout(data.upstreamOpenTimer);
2078
+ data.upstreamOpenTimer = void 0;
2079
+ }
2080
+ closeWebSocket(ws, event.code, upstreamCloseReason(event));
1399
2081
  };
1400
2082
  upstream.onerror = () => {
1401
- ws.close(1011, "Upstream WebSocket connection failed");
2083
+ if (data.upstreamOpenTimer) {
2084
+ clearTimeout(data.upstreamOpenTimer);
2085
+ data.upstreamOpenTimer = void 0;
2086
+ }
2087
+ closeWebSocket(ws, 1011, "Upstream WebSocket connection failed");
1402
2088
  };
1403
2089
  },
1404
2090
  close(ws, code, reason) {
1405
2091
  if (ws.data.kind === "host-tunnel") {
1406
- if (ws.data.heartbeat) clearInterval(ws.data.heartbeat);
1407
- hostTunnels.delete(ws.data.hostId);
2092
+ cleanupHostTunnelSocket({
2093
+ ws,
2094
+ hostTunnels,
2095
+ hostTunnelStateDebounce,
2096
+ options,
2097
+ bunOptions,
2098
+ disconnectDirectory: true,
2099
+ closeChannels: true
2100
+ });
1408
2101
  const tracker = hostTunnelRegistrations.get(ws.data.hostId);
1409
2102
  if (tracker) {
1410
- tracker.current = Math.max(0, tracker.current - 1);
1411
2103
  pruneReconnects(tracker, Date.now());
1412
- if (tracker.current === 0 && tracker.recent.length === 0) {
1413
- hostTunnelRegistrations.delete(ws.data.hostId);
1414
- }
1415
- }
1416
- for (const pending of ws.data.pending.values()) {
1417
- clearTimeout(pending.timeout);
1418
- pending.reject(new Error("User-hosted tunnel disconnected"));
1419
- }
1420
- ws.data.pending.clear();
1421
- for (const channel of ws.data.channels.values()) {
1422
- channel.close(1011, "User-hosted tunnel disconnected");
2104
+ if (tracker.recent.length === 0) hostTunnelRegistrations.delete(ws.data.hostId);
1423
2105
  }
1424
- ws.data.channels.clear();
1425
- options.directory?.disconnectHost(ws.data.hostId);
1426
- scheduleHostTunnelStateChange(options, {
1427
- hostId: ws.data.hostId,
1428
- workspaceId: ws.data.workspaceIds[0],
1429
- path: `/host-tunnels/${ws.data.hostId}`,
1430
- state: "disconnected",
1431
- debounceMs: bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
1432
- });
1433
2106
  return;
1434
2107
  }
1435
2108
  if (ws.data.kind === "user-hosted-client") {
1436
2109
  const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
1437
2110
  tunnel?.data.channels.delete(ws.data.channelId);
1438
- tunnel?.send(JSON.stringify({
1439
- type: "ws.close",
1440
- protocol: TUNNEL_PROTOCOL_VERSION,
1441
- channel_id: ws.data.channelId,
1442
- code,
1443
- reason
1444
- }));
2111
+ if (tunnel?.readyState === WebSocket.OPEN) {
2112
+ tunnel.send(JSON.stringify({
2113
+ type: "ws.close",
2114
+ protocol: TUNNEL_PROTOCOL_VERSION,
2115
+ channel_id: ws.data.channelId,
2116
+ code: safeCloseCode(code, 1e3),
2117
+ reason
2118
+ }));
2119
+ }
1445
2120
  return;
1446
2121
  }
1447
- ws.data.upstream?.close(code, reason);
2122
+ relayClients.delete(ws);
2123
+ if (ws.data.upstreamOpenTimer) clearTimeout(ws.data.upstreamOpenTimer);
2124
+ if (ws.data.upstream) closeWebSocket(ws.data.upstream, code, reason, 1e3);
1448
2125
  }
1449
2126
  },
1450
- drain: drainController
2127
+ drain: drainController,
2128
+ telemetry
1451
2129
  };
1452
2130
  }
1453
2131
 
2132
+ // src/cloudflare.ts
2133
+ import {
2134
+ makeTunnelPong as makeTunnelPong2,
2135
+ TUNNEL_PROTOCOL_VERSION as TUNNEL_PROTOCOL_VERSION2,
2136
+ validateTunnelMessage as validateTunnelMessage2
2137
+ } from "@claxedo/workspace-relay-protocol";
2138
+
1454
2139
  // src/directory.ts
1455
2140
  function createWorkspaceRelayDirectory(options = {}) {
1456
2141
  const ttlMs = Math.max(1, options.ttlMs ?? 45e3);
@@ -1532,32 +2217,1288 @@ function createWorkspaceRelayDirectory(options = {}) {
1532
2217
  function disposeWorkspaceRelayDirectory(directory) {
1533
2218
  directory.dispose();
1534
2219
  }
2220
+
2221
+ // src/cloudflare.ts
2222
+ var DEFAULT_RELAY_LOCATION_HINT = "apac";
2223
+ var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
2224
+ var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
2225
+ var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
2226
+ var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT2 = 16 * 1024 * 1024;
2227
+ var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
2228
+ var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT2 = 8 * 1024 * 1024;
2229
+ var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT2 = 3e4;
2230
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT2 = 64;
2231
+ var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
2232
+ var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
2233
+ var TRACE_ID_HEADER = "x-claxedo-trace-id";
2234
+ var TRACE_FORCE_HEADER = "x-claxedo-relay-trace";
2235
+ function clean(input) {
2236
+ const value = input?.trim();
2237
+ return value ? value : void 0;
2238
+ }
2239
+ function clampRate(input) {
2240
+ if (input === void 0 || !Number.isFinite(input)) return 0;
2241
+ if (input <= 0) return 0;
2242
+ if (input >= 1) return 1;
2243
+ return input;
2244
+ }
2245
+ function traceRequest(request, routeKind, options) {
2246
+ const id = clean(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
2247
+ const forced = request.headers.get(TRACE_FORCE_HEADER) === "1";
2248
+ const rate = clampRate(options.traceSampleRate);
2249
+ return {
2250
+ id,
2251
+ sampled: forced || rate > 0 && (options.random ?? Math.random)() < rate,
2252
+ startedAt: performance.now(),
2253
+ routeKind,
2254
+ phases: []
2255
+ };
2256
+ }
2257
+ async function traceSpan(trace, name, fn) {
2258
+ const startedAt = performance.now();
2259
+ try {
2260
+ return await fn();
2261
+ } finally {
2262
+ trace?.phases.push({ name, ms: performance.now() - startedAt });
2263
+ }
2264
+ }
2265
+ function tracedRequest(request, trace) {
2266
+ const headers2 = new Headers(request.headers);
2267
+ headers2.set(TRACE_ID_HEADER, trace.id);
2268
+ if (trace.sampled) headers2.set("x-claxedo-trace-sampled", "1");
2269
+ return new Request(request.url, {
2270
+ method: request.method,
2271
+ headers: headers2,
2272
+ body: request.method === "GET" || request.method === "HEAD" ? void 0 : request.body,
2273
+ redirect: request.redirect,
2274
+ duplex: "half"
2275
+ });
2276
+ }
2277
+ function roundedMs3(value) {
2278
+ return Math.round(value * 100) / 100;
2279
+ }
2280
+ function serverTiming(trace) {
2281
+ return trace.phases.map((phase) => `${phase.name};dur=${roundedMs3(phase.ms)}`).join(", ");
2282
+ }
2283
+ function withTraceHeaders(response, trace) {
2284
+ const headers2 = new Headers(response.headers);
2285
+ headers2.set(TRACE_ID_HEADER, trace.id);
2286
+ const timing = serverTiming(trace);
2287
+ if (timing) {
2288
+ const existing = headers2.get("server-timing");
2289
+ headers2.set("server-timing", existing ? `${existing}, ${timing}` : timing);
2290
+ }
2291
+ return new Response(response.body, {
2292
+ status: response.status,
2293
+ statusText: response.statusText,
2294
+ headers: headers2
2295
+ });
2296
+ }
2297
+ function logTrace(options, request, workspaceId, trace, response) {
2298
+ if (!trace.sampled) return;
2299
+ const url = new URL(request.url);
2300
+ const totalMs = performance.now() - trace.startedAt;
2301
+ const event = {
2302
+ event: "workspace_relay_request",
2303
+ traceId: trace.id,
2304
+ workspaceId,
2305
+ routeKind: trace.routeKind,
2306
+ method: request.method,
2307
+ path: url.pathname,
2308
+ status: response.status,
2309
+ totalMs: roundedMs3(totalMs),
2310
+ phases: Object.fromEntries(trace.phases.map((phase) => [phase.name, roundedMs3(phase.ms)]))
2311
+ };
2312
+ if (options.traceLog) {
2313
+ options.traceLog(event);
2314
+ return;
2315
+ }
2316
+ console.log(JSON.stringify(event));
2317
+ }
2318
+ function workspaceIdFromRelayPath(pathname) {
2319
+ return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
2320
+ }
2321
+ function hostIdFromTunnelPath2(pathname) {
2322
+ return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
2323
+ }
2324
+ function websocketRequest2(request) {
2325
+ return request.headers.get("upgrade")?.toLowerCase() === "websocket";
2326
+ }
2327
+ function workspaceIdsFromSearch(url) {
2328
+ return [...new Set([
2329
+ ...url.searchParams.getAll("workspaceId"),
2330
+ ...url.searchParams.getAll("workspace_id")
2331
+ ].map(clean).filter((item) => !!item))];
2332
+ }
2333
+ function randomToken() {
2334
+ return crypto.randomUUID();
2335
+ }
2336
+ function headerRecord(headers2) {
2337
+ const result = {};
2338
+ headers2.forEach((value, key) => {
2339
+ result[key] = value;
2340
+ });
2341
+ return result;
2342
+ }
2343
+ function headersFromRecord(input) {
2344
+ const headers2 = new Headers();
2345
+ for (const [key, value] of Object.entries(input)) headers2.set(key, value);
2346
+ return headers2;
2347
+ }
2348
+ function bytesToBase64(bytes) {
2349
+ let binary = "";
2350
+ for (const byte of bytes) binary += String.fromCharCode(byte);
2351
+ return btoa(binary);
2352
+ }
2353
+ function base64ToBytes(input) {
2354
+ return Uint8Array.from(atob(input), (char) => char.charCodeAt(0));
2355
+ }
2356
+ function socketFrame(input) {
2357
+ if (typeof input === "string") {
2358
+ return {
2359
+ binary: false,
2360
+ data_base64: bytesToBase64(new TextEncoder().encode(input))
2361
+ };
2362
+ }
2363
+ if (input instanceof ArrayBuffer) {
2364
+ return {
2365
+ binary: true,
2366
+ data_base64: bytesToBase64(new Uint8Array(input))
2367
+ };
2368
+ }
2369
+ if (input instanceof Uint8Array) {
2370
+ return {
2371
+ binary: true,
2372
+ data_base64: bytesToBase64(input)
2373
+ };
2374
+ }
2375
+ }
2376
+ function socketPayload(input) {
2377
+ if (typeof input === "string") return input;
2378
+ if (input instanceof ArrayBuffer) return input;
2379
+ if (input instanceof Uint8Array) return input;
2380
+ if (ArrayBuffer.isView(input)) {
2381
+ return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
2382
+ }
2383
+ }
2384
+ function tunnelFrame(input) {
2385
+ const bytes = base64ToBytes(input.data_base64);
2386
+ return input.binary ? bytes : new TextDecoder().decode(bytes);
2387
+ }
2388
+ function concatBytes(chunks) {
2389
+ const length = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
2390
+ const result = new Uint8Array(length);
2391
+ let offset = 0;
2392
+ for (const chunk of chunks) {
2393
+ result.set(chunk, offset);
2394
+ offset += chunk.byteLength;
2395
+ }
2396
+ return result;
2397
+ }
2398
+ function responseMustNotHaveBody(status) {
2399
+ return status === 204 || status === 205 || status === 304;
2400
+ }
2401
+ async function readRequestBodyCapped(request, maxBytes) {
2402
+ if (!request.body) return new Uint8Array(0);
2403
+ const reader = request.body.getReader();
2404
+ const chunks = [];
2405
+ let total = 0;
2406
+ while (true) {
2407
+ const { done, value } = await reader.read();
2408
+ if (done) break;
2409
+ total += value.byteLength;
2410
+ if (total > maxBytes) {
2411
+ await reader.cancel().catch(() => {
2412
+ });
2413
+ return "too_large";
2414
+ }
2415
+ chunks.push(value);
2416
+ }
2417
+ return concatBytes(chunks);
2418
+ }
2419
+ function safeCloseCode2(code, fallback = 1011) {
2420
+ if (code === void 0) return fallback;
2421
+ if (code >= 1e3 && code <= 4999 && ![1004, 1005, 1006].includes(code)) return code;
2422
+ return fallback;
2423
+ }
2424
+ function workspaceRelayDurableObjectRoomName(workspaceId) {
2425
+ return `workspace:${workspaceId}`;
2426
+ }
2427
+ function workspaceRelayDurableObjectWorkspaceId(request) {
2428
+ const url = new URL(request.url);
2429
+ const workspaceId = clean(workspaceIdFromRelayPath(url.pathname));
2430
+ if (workspaceId) return decodeURIComponent(workspaceId);
2431
+ if (!hostIdFromTunnelPath2(url.pathname)) return;
2432
+ return clean(url.searchParams.get("workspaceId")) ?? clean(url.searchParams.get("workspace_id"));
2433
+ }
2434
+ function namespaceFromEnv(env, options) {
2435
+ if (options.namespace) return options.namespace;
2436
+ const binding = env[options.bindingName ?? "WORKSPACE_RELAY_ROOM"];
2437
+ return binding && typeof binding === "object" ? binding : void 0;
2438
+ }
2439
+ function json(code, message, status) {
2440
+ return Response.json(errorBody(code, message), { status });
2441
+ }
2442
+ var configuredAppOriginAllowed = () => false;
2443
+ function setWorkspaceRelayAppOrigins(raw) {
2444
+ const entries = (raw ?? "").split(",").map((item) => item.trim()).filter(Boolean);
2445
+ const exact = /* @__PURE__ */ new Set();
2446
+ const suffixes = [];
2447
+ for (const entry of entries) {
2448
+ if (entry.startsWith("https://*.")) suffixes.push(entry.slice("https://*".length));
2449
+ else exact.add(entry);
2450
+ }
2451
+ configuredAppOriginAllowed = (origin) => {
2452
+ if (exact.has(origin)) return true;
2453
+ if (!origin.startsWith("https://")) return false;
2454
+ return suffixes.some((suffix) => origin.endsWith(suffix) && origin.length > "https://".length + suffix.length);
2455
+ };
2456
+ }
2457
+ function allowedCorsOrigin2(origin) {
2458
+ if (!origin) return;
2459
+ if (origin.startsWith("http://localhost:")) return origin;
2460
+ if (origin.startsWith("http://127.0.0.1:")) return origin;
2461
+ if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
2462
+ if (/^https:\/\/([a-z0-9-]+\.)*claxedo\.com$/.test(origin)) return origin;
2463
+ if (configuredAppOriginAllowed(origin)) return origin;
2464
+ }
2465
+ function corsHeaders(request) {
2466
+ const origin = allowedCorsOrigin2(request.headers.get("origin"));
2467
+ if (!origin) return;
2468
+ return {
2469
+ "access-control-allow-origin": origin,
2470
+ "access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
2471
+ "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",
2472
+ "access-control-max-age": "86400",
2473
+ vary: "Origin"
2474
+ };
2475
+ }
2476
+ function corsPreflight(request) {
2477
+ const headers2 = corsHeaders(request);
2478
+ return new Response(null, { status: headers2 ? 204 : 403, ...headers2 ? { headers: headers2 } : {} });
2479
+ }
2480
+ function withCors(request, response) {
2481
+ const headers2 = corsHeaders(request);
2482
+ if (!headers2) return response;
2483
+ if (response.status === 101 || response.webSocket) return response;
2484
+ const next = new Headers(response.headers);
2485
+ for (const [key, value] of Object.entries(headers2)) next.set(key, value);
2486
+ return new Response(response.body, {
2487
+ status: response.status,
2488
+ statusText: response.statusText,
2489
+ headers: next
2490
+ });
2491
+ }
2492
+ function defaultWebSocketPair() {
2493
+ const pair = new globalThis.WebSocketPair();
2494
+ return {
2495
+ client: pair[0],
2496
+ server: pair[1]
2497
+ };
2498
+ }
2499
+ function selectedSubprotocol(request) {
2500
+ const offered = request.headers.get("sec-websocket-protocol")?.split(",").map((item) => item.trim()).filter(Boolean);
2501
+ if (!offered?.length) return;
2502
+ return offered.find((item) => item.startsWith("claxedo-rat.")) ?? offered[0];
2503
+ }
2504
+ function upgradeResponse(socket, request) {
2505
+ const protocol = selectedSubprotocol(request);
2506
+ return new Response(null, {
2507
+ status: 101,
2508
+ ...protocol ? { headers: { "sec-websocket-protocol": protocol } } : {},
2509
+ webSocket: socket
2510
+ });
2511
+ }
2512
+ async function defaultConnectWebSocket(url, init) {
2513
+ const res = await fetch(url, {
2514
+ headers: {
2515
+ ...init.headers,
2516
+ Upgrade: "websocket"
2517
+ }
2518
+ });
2519
+ const socket = res.webSocket;
2520
+ if (!socket) throw new Error("Upstream WebSocket upgrade was not accepted");
2521
+ socket.accept?.();
2522
+ return socket;
2523
+ }
2524
+ function createWorkspaceRelayDurableObjectGateway(options = {}) {
2525
+ return {
2526
+ async fetch(request, env = {}) {
2527
+ const gatewayStartedAt = performance.now();
2528
+ const gatewayTraceId = clean(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
2529
+ const url = new URL(request.url);
2530
+ if (request.method === "OPTIONS" && (url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/"))) {
2531
+ return corsPreflight(request);
2532
+ }
2533
+ if (url.pathname === "/health") {
2534
+ return Response.json({ ok: true, service: "workspace-relay", mode: "cloudflare-durable-object" });
2535
+ }
2536
+ const relayLocationHint = typeof env.CLAXEDO_RELAY_LOCATION_HINT === "string" && env.CLAXEDO_RELAY_LOCATION_HINT ? env.CLAXEDO_RELAY_LOCATION_HINT : DEFAULT_RELAY_LOCATION_HINT;
2537
+ const namespace = namespaceFromEnv(env, options);
2538
+ if (!namespace) {
2539
+ return json(
2540
+ "relay_durable_object_unconfigured",
2541
+ "Workspace Relay Durable Object binding is not configured",
2542
+ 503
2543
+ );
2544
+ }
2545
+ if (url.pathname.startsWith("/host-tunnels/") && workspaceIdsFromSearch(url).length > 1) {
2546
+ return json(
2547
+ "host_tunnel_single_workspace_required",
2548
+ "Durable Object relay rooms are per-workspace; host tunnels must register exactly one workspaceId per connection",
2549
+ 400
2550
+ );
2551
+ }
2552
+ const workspaceId = workspaceRelayDurableObjectWorkspaceId(request);
2553
+ if (!workspaceId) {
2554
+ return json(
2555
+ "relay_workspace_required",
2556
+ "Workspace Relay requests must include a workspace id",
2557
+ url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/") ? 400 : 404
2558
+ );
2559
+ }
2560
+ const routeFinishedAt = performance.now();
2561
+ const response = await namespace.get(namespace.idFromName(workspaceRelayDurableObjectRoomName(workspaceId)), {
2562
+ locationHint: relayLocationHint
2563
+ }).fetch(tracedRequest(request, {
2564
+ id: gatewayTraceId,
2565
+ sampled: request.headers.get("x-claxedo-trace-sampled") === "1",
2566
+ startedAt: gatewayStartedAt,
2567
+ routeKind: "gateway",
2568
+ phases: []
2569
+ }));
2570
+ const fetchFinishedAt = performance.now();
2571
+ if (response.status === 101 || response.webSocket) {
2572
+ return url.pathname.startsWith("/workspaces/") ? withCors(request, response) : response;
2573
+ }
2574
+ const headers2 = new Headers(response.headers);
2575
+ headers2.set(TRACE_ID_HEADER, headers2.get(TRACE_ID_HEADER) ?? gatewayTraceId);
2576
+ headers2.set("x-claxedo-relay-location-hint", relayLocationHint);
2577
+ const gatewayResponseStartedAt = performance.now();
2578
+ const tracedResponse = new Response(response.body, {
2579
+ status: response.status,
2580
+ statusText: response.statusText,
2581
+ headers: headers2
2582
+ });
2583
+ const gatewayFinishedAt = performance.now();
2584
+ const gatewayTiming = [
2585
+ `relay-gateway-route;dur=${roundedMs3(routeFinishedAt - gatewayStartedAt)}`,
2586
+ `relay-gateway-do-fetch;dur=${roundedMs3(fetchFinishedAt - routeFinishedAt)}`,
2587
+ `relay-gateway-response;dur=${roundedMs3(gatewayFinishedAt - gatewayResponseStartedAt)}`,
2588
+ `relay-gateway;dur=${roundedMs3(gatewayFinishedAt - gatewayStartedAt)}`
2589
+ ].join(", ");
2590
+ tracedResponse.headers.set(
2591
+ "server-timing",
2592
+ tracedResponse.headers.get("server-timing") ? `${tracedResponse.headers.get("server-timing")}, ${gatewayTiming}` : gatewayTiming
2593
+ );
2594
+ return url.pathname.startsWith("/workspaces/") ? withCors(request, tracedResponse) : tracedResponse;
2595
+ }
2596
+ };
2597
+ }
2598
+ function createWorkspaceRelayDurableObjectRoom(options) {
2599
+ const directory = options.directory ?? createWorkspaceRelayDirectory({ sweepIntervalMs: 0 });
2600
+ const relayOptions = { ...options, directory };
2601
+ const hostTunnels = /* @__PURE__ */ new Map();
2602
+ const clients = /* @__PURE__ */ new Set();
2603
+ const pairFactory = options.createWebSocketPair ?? defaultWebSocketPair;
2604
+ const connectWebSocket = options.connectWebSocket ?? defaultConnectWebSocket;
2605
+ const hibernation = options.hibernation;
2606
+ let draining = false;
2607
+ let hibernatedSocketsRebuilt = false;
2608
+ const socketAttachment = (socket) => {
2609
+ try {
2610
+ return socket.deserializeAttachment?.();
2611
+ } catch {
2612
+ return void 0;
2613
+ }
2614
+ };
2615
+ const acceptSocket = (attachment) => {
2616
+ const pair = pairFactory();
2617
+ if (hibernation && attachment) {
2618
+ hibernation.acceptWebSocket(pair.server);
2619
+ pair.server.serializeAttachment?.(attachment);
2620
+ } else {
2621
+ pair.server.accept?.();
2622
+ }
2623
+ return pair;
2624
+ };
2625
+ const closeSocket = (socket, code, reason) => {
2626
+ try {
2627
+ socket.close?.(code, reason);
2628
+ } catch {
2629
+ }
2630
+ };
2631
+ const sendSocket = (socket, message) => {
2632
+ try {
2633
+ socket.send?.(message);
2634
+ return true;
2635
+ } catch {
2636
+ return false;
2637
+ }
2638
+ };
2639
+ const cleanupTunnelResources = (tunnel, reason) => {
2640
+ for (const pending of tunnel.pending.values()) {
2641
+ clearTimeout(pending.timeout);
2642
+ if (pending.stream?.slowConsumerTimeout) clearTimeout(pending.stream.slowConsumerTimeout);
2643
+ const error = new Error(reason);
2644
+ if (pending.stream && !pending.stream.closed) {
2645
+ pending.stream.closed = true;
2646
+ pending.stream.controller.error(error);
2647
+ } else {
2648
+ pending.reject(error);
2649
+ }
2650
+ }
2651
+ tunnel.pending.clear();
2652
+ for (const client of tunnel.channels.values()) {
2653
+ clients.delete(client);
2654
+ closeSocket(client.socket, 1011, reason);
2655
+ }
2656
+ tunnel.channels.clear();
2657
+ };
2658
+ const rebuildHibernatedSockets = () => {
2659
+ if (!hibernation) return;
2660
+ hibernatedSocketsRebuilt = true;
2661
+ hostTunnels.clear();
2662
+ clients.clear();
2663
+ const clientRows = [];
2664
+ for (const socket of hibernation.getWebSockets()) {
2665
+ const attachment = socketAttachment(socket);
2666
+ if (!attachment) continue;
2667
+ if (attachment.kind === "host-tunnel") {
2668
+ hostTunnels.set(attachment.hostId, {
2669
+ hostId: attachment.hostId,
2670
+ workspaceIds: attachment.workspaceIds,
2671
+ socket,
2672
+ pending: /* @__PURE__ */ new Map(),
2673
+ channels: /* @__PURE__ */ new Map()
2674
+ });
2675
+ directory.registerHostTunnel({
2676
+ hostId: attachment.hostId,
2677
+ workspaceIds: attachment.workspaceIds
2678
+ });
2679
+ continue;
2680
+ }
2681
+ clientRows.push({ socket, attachment });
2682
+ }
2683
+ for (const row of clientRows) {
2684
+ const tunnel = hostTunnels.get(row.attachment.target.hostId);
2685
+ if (!tunnel) {
2686
+ closeSocket(row.socket, 1011, "User-hosted tunnel unavailable");
2687
+ continue;
2688
+ }
2689
+ const client = {
2690
+ request: {
2691
+ claims: row.attachment.claims,
2692
+ target: row.attachment.target,
2693
+ path: row.attachment.path,
2694
+ relayHostToken: ""
2695
+ },
2696
+ socket: row.socket,
2697
+ channelId: row.attachment.channelId
2698
+ };
2699
+ clients.add(client);
2700
+ tunnel.channels.set(row.attachment.channelId, client);
2701
+ }
2702
+ };
2703
+ const ensureHibernatedSocketsRebuilt = () => {
2704
+ if (!hibernation || hibernatedSocketsRebuilt) return;
2705
+ rebuildHibernatedSockets();
2706
+ };
2707
+ const watchRuntimeAccessToken = (client, onInactive) => {
2708
+ const intervalMs = options.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
2709
+ if (intervalMs <= 0) return;
2710
+ const now = options.now ?? Date.now;
2711
+ const timer = setInterval(() => {
2712
+ if (client.request.claims.exp * 1e3 <= now()) {
2713
+ clearInterval(timer);
2714
+ onInactive("Runtime Access Token expired");
2715
+ return;
2716
+ }
2717
+ if (!options.isRuntimeAccessTokenActive) return;
2718
+ void Promise.resolve(options.isRuntimeAccessTokenActive(client.request.claims)).then((active) => {
2719
+ if (active.active) return;
2720
+ clearInterval(timer);
2721
+ onInactive(active.reason);
2722
+ }).catch(() => {
2723
+ clearInterval(timer);
2724
+ onInactive("Runtime Access Token active check failed");
2725
+ });
2726
+ }, intervalMs);
2727
+ if (typeof timer.unref === "function") {
2728
+ timer.unref();
2729
+ }
2730
+ return timer;
2731
+ };
2732
+ const watchUserHostedTarget = (client, onInactive) => {
2733
+ const intervalMs = options.workspaceTargetActiveCheckIntervalMs ?? WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
2734
+ if (intervalMs <= 0) return;
2735
+ const target = client.request.target;
2736
+ const timer = setInterval(() => {
2737
+ void Promise.resolve(options.resolveTarget(client.request.claims)).then((activeTarget) => {
2738
+ if (activeTarget?.access === "user-hosted" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
2739
+ return;
2740
+ }
2741
+ clearInterval(timer);
2742
+ onInactive("User-hosted workspace is offline");
2743
+ }).catch(() => {
2744
+ clearInterval(timer);
2745
+ onInactive("Workspace relay target active check failed");
2746
+ });
2747
+ }, intervalMs);
2748
+ if (typeof timer.unref === "function") {
2749
+ timer.unref();
2750
+ }
2751
+ return timer;
2752
+ };
2753
+ const clearSlowConsumerTimer = (pending) => {
2754
+ if (!pending.stream?.slowConsumerTimeout) return;
2755
+ clearTimeout(pending.stream.slowConsumerTimeout);
2756
+ pending.stream.slowConsumerTimeout = void 0;
2757
+ };
2758
+ const sendHttpResponseFlow = (tunnel, requestId, pending, paused, reason) => {
2759
+ if (!pending.stream || pending.stream.flowPaused === paused) return;
2760
+ if (!paused && pending.stream.flowPaused === void 0) {
2761
+ pending.stream.flowPaused = false;
2762
+ return;
2763
+ }
2764
+ pending.stream.flowPaused = paused;
2765
+ tunnel.socket.send?.(JSON.stringify({
2766
+ type: "http.response.flow",
2767
+ protocol: TUNNEL_PROTOCOL_VERSION2,
2768
+ request_id: requestId,
2769
+ paused,
2770
+ reason
2771
+ }));
2772
+ };
2773
+ const failSlowConsumer = (tunnel, requestId, pending) => {
2774
+ sendHttpResponseFlow(tunnel, requestId, pending, false, "closed");
2775
+ tunnel.pending.delete(requestId);
2776
+ clearTimeout(pending.timeout);
2777
+ clearSlowConsumerTimer(pending);
2778
+ pending.chunks.length = 0;
2779
+ if (!pending.stream || pending.stream.closed) return;
2780
+ pending.stream.bytesQueued = 0;
2781
+ pending.stream.closed = true;
2782
+ pending.stream.controller.error(new Error("slow_consumer_timeout: downstream consumer did not drain in time"));
2783
+ };
2784
+ const armSlowConsumerTimer = (tunnel, requestId, pending) => {
2785
+ if (pending.stream?.slowConsumerTimeout) return;
2786
+ pending.stream.slowConsumerTimeout = setTimeout(
2787
+ () => failSlowConsumer(tunnel, requestId, pending),
2788
+ options.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT2
2789
+ );
2790
+ };
2791
+ const drainPendingStreamChunks = (tunnel, requestId, pending) => {
2792
+ if (!pending.stream || pending.stream.closed) return;
2793
+ while (pending.chunks.length > 0) {
2794
+ const desired = pending.stream.controller.desiredSize;
2795
+ if (desired !== null && desired <= 0) {
2796
+ sendHttpResponseFlow(tunnel, requestId, pending, true, "slow_consumer");
2797
+ armSlowConsumerTimer(tunnel, requestId, pending);
2798
+ return;
2799
+ }
2800
+ const chunk = pending.chunks.shift();
2801
+ pending.stream.bytesQueued -= chunk.byteLength;
2802
+ try {
2803
+ pending.stream.controller.enqueue(chunk);
2804
+ } catch {
2805
+ pending.chunks.length = 0;
2806
+ pending.stream.bytesQueued = 0;
2807
+ return;
2808
+ }
2809
+ }
2810
+ clearSlowConsumerTimer(pending);
2811
+ sendHttpResponseFlow(tunnel, requestId, pending, false, "drained");
2812
+ };
2813
+ const enqueuePendingStreamChunk = (tunnel, requestId, pending, chunk) => {
2814
+ if (!pending.stream || pending.stream.closed) {
2815
+ pending.chunks.push(chunk);
2816
+ return;
2817
+ }
2818
+ const desired = pending.stream.controller.desiredSize;
2819
+ if (desired !== null && desired <= 0 || pending.chunks.length > 0) {
2820
+ pending.chunks.push(chunk);
2821
+ pending.stream.bytesQueued += chunk.byteLength;
2822
+ sendHttpResponseFlow(tunnel, requestId, pending, true, "slow_consumer");
2823
+ armSlowConsumerTimer(tunnel, requestId, pending);
2824
+ return;
2825
+ }
2826
+ try {
2827
+ pending.stream.controller.enqueue(chunk);
2828
+ } catch {
2829
+ pending.stream.closed = true;
2830
+ }
2831
+ };
2832
+ let startedSeqCounter = 0;
2833
+ const evictOldestStartedStream = (tunnel, keepRequestId) => {
2834
+ const cap = options.tunnelStartedStreamCap ?? TUNNEL_STARTED_STREAM_CAP_DEFAULT;
2835
+ while (true) {
2836
+ const started = [...tunnel.pending.entries()].filter(([id, entry]) => entry.startedSeq !== void 0 && id !== keepRequestId).sort((a, b) => (a[1].startedSeq ?? 0) - (b[1].startedSeq ?? 0));
2837
+ if (started.length + 1 <= cap) return;
2838
+ const [oldestId, oldest] = started[0];
2839
+ failPendingTunnelResponse(tunnel, oldestId, oldest, new Error("stream_evicted: tunnel started-stream cap reached"));
2840
+ }
2841
+ };
2842
+ const resolvePendingTunnelStream = (tunnel, requestId, pending, status, headers2) => {
2843
+ if (pending.stream) return;
2844
+ pending.startedSeq = ++startedSeqCounter;
2845
+ evictOldestStartedStream(tunnel, requestId);
2846
+ clearTimeout(pending.timeout);
2847
+ const stream = new ReadableStream({
2848
+ start(controller) {
2849
+ pending.stream = { controller, closed: false, bytesQueued: 0 };
2850
+ drainPendingStreamChunks(tunnel, requestId, pending);
2851
+ },
2852
+ pull() {
2853
+ drainPendingStreamChunks(tunnel, requestId, pending);
2854
+ },
2855
+ cancel() {
2856
+ tunnel.pending.delete(requestId);
2857
+ clearTimeout(pending.timeout);
2858
+ clearSlowConsumerTimer(pending);
2859
+ pending.chunks.length = 0;
2860
+ if (pending.stream) {
2861
+ pending.stream.closed = true;
2862
+ pending.stream.bytesQueued = 0;
2863
+ }
2864
+ tunnel.socket.send?.(JSON.stringify({
2865
+ type: "http.response.flow",
2866
+ protocol: TUNNEL_PROTOCOL_VERSION2,
2867
+ request_id: requestId,
2868
+ paused: false,
2869
+ reason: "closed"
2870
+ }));
2871
+ }
2872
+ }, new ByteLengthQueuingStrategy({
2873
+ highWaterMark: options.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT2
2874
+ }));
2875
+ pending.resolve(new Response(stream, {
2876
+ status,
2877
+ headers: headers2
2878
+ }));
2879
+ };
2880
+ const failPendingTunnelResponse = (tunnel, requestId, pending, error) => {
2881
+ tunnel.pending.delete(requestId);
2882
+ clearTimeout(pending.timeout);
2883
+ clearSlowConsumerTimer(pending);
2884
+ sendHttpResponseFlow(tunnel, requestId, pending, false, "closed");
2885
+ if (pending.stream && !pending.stream.closed) {
2886
+ pending.stream.closed = true;
2887
+ pending.stream.controller.error(error);
2888
+ return;
2889
+ }
2890
+ pending.reject(error);
2891
+ };
2892
+ const pendingCount = () => [...hostTunnels.values()].reduce((sum, tunnel) => sum + tunnel.pending.size, 0);
2893
+ const closeActiveSocketsForDrain = () => {
2894
+ for (const tunnel of hostTunnels.values()) closeSocket(tunnel.socket, 1012, "Workspace relay is draining");
2895
+ for (const client of clients) closeSocket(client.socket, 1012, "Workspace relay is draining");
2896
+ };
2897
+ const drainController = {
2898
+ isDraining: () => draining,
2899
+ setDraining(value) {
2900
+ draining = value;
2901
+ if (draining) closeActiveSocketsForDrain();
2902
+ },
2903
+ pendingCount,
2904
+ async waitForDrain(timeoutMs) {
2905
+ const deadline = Date.now() + timeoutMs;
2906
+ while (true) {
2907
+ const remaining = pendingCount();
2908
+ if (remaining === 0) return { drained: true, remaining: 0 };
2909
+ if (Date.now() >= deadline) return { drained: false, remaining };
2910
+ await new Promise((resolve) => setTimeout(resolve, 10));
2911
+ }
2912
+ }
2913
+ };
2914
+ const runtimeAccessTokenActive = async (client) => {
2915
+ const now = options.now ?? Date.now;
2916
+ if (client.request.claims.exp * 1e3 <= now()) {
2917
+ return { active: false, reason: "Runtime Access Token expired" };
2918
+ }
2919
+ if (!options.isRuntimeAccessTokenActive) return { active: true };
2920
+ const active = await options.isRuntimeAccessTokenActive(client.request.claims);
2921
+ return active.active ? { active: true } : { active: false, reason: active.reason };
2922
+ };
2923
+ const userHostedTargetActive = async (client) => {
2924
+ const target = client.request.target;
2925
+ const activeTarget = await options.resolveTarget(client.request.claims);
2926
+ return !!(activeTarget?.access === "user-hosted" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId }));
2927
+ };
2928
+ const activeUserHostedClient = (channelId, socket) => {
2929
+ for (const tunnel of hostTunnels.values()) {
2930
+ const client = tunnel.channels.get(channelId);
2931
+ if (client?.socket === socket) return { tunnel, client };
2932
+ }
2933
+ };
2934
+ const handleUserHostedClientMessage = async (channelId, socket, data) => {
2935
+ const active = activeUserHostedClient(channelId, socket);
2936
+ if (!active) return;
2937
+ const token = await runtimeAccessTokenActive(active.client);
2938
+ if (!token.active) {
2939
+ closeSocket(socket, 1008, token.reason);
2940
+ return;
2941
+ }
2942
+ if (!await userHostedTargetActive(active.client)) {
2943
+ closeSocket(socket, 1011, "User-hosted workspace is offline");
2944
+ return;
2945
+ }
2946
+ const frame = socketFrame(data);
2947
+ if (!frame) return;
2948
+ const sent = sendSocket(active.tunnel.socket, JSON.stringify({
2949
+ type: "ws.frame",
2950
+ protocol: TUNNEL_PROTOCOL_VERSION2,
2951
+ channel_id: channelId,
2952
+ ...frame
2953
+ }));
2954
+ if (!sent) closeSocket(socket, 1011, "User-hosted tunnel unavailable");
2955
+ };
2956
+ const handleTunnelMessage = async (hostId, event) => {
2957
+ const row = parseTunnelMessageData(event.data);
2958
+ if (!row.ok) return;
2959
+ const parsed = validateTunnelMessage2(row.value);
2960
+ const tunnel = hostTunnels.get(hostId);
2961
+ if (!parsed.ok || !tunnel) return;
2962
+ if (parsed.message.type === "ping") {
2963
+ directory.recordPong(tunnel.hostId);
2964
+ tunnel.socket.send?.(JSON.stringify(makeTunnelPong2(parsed.message)));
2965
+ return;
2966
+ }
2967
+ if (parsed.message.type === "error" && parsed.message.request_id) {
2968
+ const pending = tunnel.pending.get(parsed.message.request_id);
2969
+ if (!pending) return;
2970
+ failPendingTunnelResponse(tunnel, parsed.message.request_id, pending, new Error(parsed.message.message));
2971
+ return;
2972
+ }
2973
+ if (parsed.message.type === "http.response.start") {
2974
+ const message = parsed.message;
2975
+ const pending = tunnel.pending.get(message.request_id);
2976
+ if (!pending) return;
2977
+ pending.status = message.status;
2978
+ pending.headers = headersFromRecord(message.headers);
2979
+ if (responseMustNotHaveBody(pending.status)) return;
2980
+ resolvePendingTunnelStream(tunnel, message.request_id, pending, pending.status, pending.headers);
2981
+ return;
2982
+ }
2983
+ if (parsed.message.type === "http.response.chunk") {
2984
+ const message = parsed.message;
2985
+ const pending = tunnel.pending.get(message.request_id);
2986
+ if (!pending) return;
2987
+ const chunk = base64ToBytes(message.body_base64);
2988
+ if (pending.bytes + chunk.byteLength > (options.tunnelResponseBodyMaxBytes ?? TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT)) {
2989
+ failPendingTunnelResponse(tunnel, message.request_id, pending, new Error("User-hosted response body exceeds the relay limit"));
2990
+ return;
2991
+ }
2992
+ pending.bytes += chunk.byteLength;
2993
+ enqueuePendingStreamChunk(tunnel, message.request_id, pending, chunk);
2994
+ return;
2995
+ }
2996
+ if (parsed.message.type === "http.response.end") {
2997
+ const message = parsed.message;
2998
+ const pending = tunnel.pending.get(message.request_id);
2999
+ if (!pending) return;
3000
+ tunnel.pending.delete(message.request_id);
3001
+ clearTimeout(pending.timeout);
3002
+ clearSlowConsumerTimer(pending);
3003
+ sendHttpResponseFlow(tunnel, message.request_id, pending, false, "closed");
3004
+ if (pending.stream) {
3005
+ if (!pending.stream.closed) {
3006
+ drainPendingStreamChunks(tunnel, message.request_id, pending);
3007
+ while (pending.chunks.length > 0) {
3008
+ const chunk = pending.chunks.shift();
3009
+ pending.stream.bytesQueued -= chunk.byteLength;
3010
+ pending.stream.controller.enqueue(chunk);
3011
+ }
3012
+ pending.stream.closed = true;
3013
+ pending.stream.controller.close();
3014
+ }
3015
+ return;
3016
+ }
3017
+ pending.resolve(new Response(responseMustNotHaveBody(pending.status ?? 200) ? null : concatBytes(pending.chunks), {
3018
+ status: pending.status ?? 200,
3019
+ headers: pending.headers
3020
+ }));
3021
+ return;
3022
+ }
3023
+ if (parsed.message.type === "ws.frame") {
3024
+ const message = parsed.message;
3025
+ const channel = tunnel.channels.get(message.channel_id);
3026
+ if (!channel) return;
3027
+ const token = await runtimeAccessTokenActive(channel);
3028
+ if (!token.active) {
3029
+ tunnel.channels.delete(message.channel_id);
3030
+ clients.delete(channel);
3031
+ closeSocket(channel.socket, 1008, token.reason);
3032
+ return;
3033
+ }
3034
+ sendSocket(channel.socket, tunnelFrame(message));
3035
+ return;
3036
+ }
3037
+ if (parsed.message.type === "ws.close") {
3038
+ const message = parsed.message;
3039
+ const channel = tunnel.channels.get(message.channel_id);
3040
+ if (!channel) return;
3041
+ tunnel.channels.delete(message.channel_id);
3042
+ clients.delete(channel);
3043
+ closeSocket(channel.socket, safeCloseCode2(message.code), message.reason ?? "");
3044
+ return;
3045
+ }
3046
+ if (parsed.message.type === "error" && parsed.message.channel_id) {
3047
+ const message = parsed.message;
3048
+ const channelId = parsed.message.channel_id;
3049
+ const channel = tunnel.channels.get(channelId);
3050
+ if (!channel) return;
3051
+ tunnel.channels.delete(channelId);
3052
+ clients.delete(channel);
3053
+ closeSocket(channel.socket, 1011, message.message);
3054
+ }
3055
+ };
3056
+ const parseTunnelMessageData = (data) => {
3057
+ if (typeof data !== "string") return { ok: true, value: data };
3058
+ try {
3059
+ return { ok: true, value: JSON.parse(data) };
3060
+ } catch {
3061
+ return { ok: false };
3062
+ }
3063
+ };
3064
+ const cleanupHostTunnel = (hostId, socket, reason) => {
3065
+ const tunnel = hostTunnels.get(hostId);
3066
+ if (tunnel?.socket !== socket) return;
3067
+ cleanupTunnelResources(tunnel, reason);
3068
+ hostTunnels.delete(hostId);
3069
+ directory.disconnectHost(hostId);
3070
+ };
3071
+ const cleanupUserHostedClient = (channelId, socket, event) => {
3072
+ for (const tunnel of hostTunnels.values()) {
3073
+ const active = tunnel.channels.get(channelId);
3074
+ if (!active || active.socket !== socket) continue;
3075
+ tunnel.channels.delete(channelId);
3076
+ clients.delete(active);
3077
+ sendSocket(tunnel.socket, JSON.stringify({
3078
+ type: "ws.close",
3079
+ protocol: TUNNEL_PROTOCOL_VERSION2,
3080
+ channel_id: channelId,
3081
+ code: safeCloseCode2(event?.code, 1e3),
3082
+ reason: event?.reason ?? "Client WebSocket closed"
3083
+ }));
3084
+ return;
3085
+ }
3086
+ };
3087
+ const admitHostTunnel = async (request, roomWorkspaceId, url) => {
3088
+ if (!websocketRequest2(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
3089
+ const hostId = clean(hostIdFromTunnelPath2(url.pathname));
3090
+ if (!hostId) return json("host_tunnel_host_required", "Host tunnel id is required", 400);
3091
+ const workspaceIds = workspaceIdsFromSearch(url);
3092
+ if (!workspaceIds.includes(roomWorkspaceId)) {
3093
+ return json("host_tunnel_workspace_mismatch", "Host tunnel must include the room workspace id", 403);
3094
+ }
3095
+ const token = bearerToken(request.headers.get("authorization"));
3096
+ if (!token) return json("host_tunnel_token_required", "Host Tunnel Token is required", 401);
3097
+ try {
3098
+ await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
3099
+ } catch (err) {
3100
+ if (err instanceof WorkspaceRelayAuthError) {
3101
+ return json(err.code, "Host Tunnel Token was denied", 403);
3102
+ }
3103
+ throw err;
3104
+ }
3105
+ const pair = acceptSocket({
3106
+ kind: "host-tunnel",
3107
+ hostId,
3108
+ workspaceIds,
3109
+ connectedAt: options.now?.() ?? Date.now()
3110
+ });
3111
+ const previous = hostTunnels.get(hostId);
3112
+ if (previous) {
3113
+ cleanupTunnelResources(previous, "Host tunnel replaced");
3114
+ closeSocket(previous.socket, 1012, "Host tunnel replaced");
3115
+ }
3116
+ hostTunnels.set(hostId, {
3117
+ hostId,
3118
+ workspaceIds,
3119
+ socket: pair.server,
3120
+ pending: /* @__PURE__ */ new Map(),
3121
+ channels: /* @__PURE__ */ new Map()
3122
+ });
3123
+ directory.registerHostTunnel({ hostId, workspaceIds });
3124
+ if (!hibernation) {
3125
+ pair.server.addEventListener?.("message", (event) => {
3126
+ if (event && event.data !== void 0) void handleTunnelMessage(hostId, { data: event.data });
3127
+ });
3128
+ pair.server.addEventListener?.("close", () => cleanupHostTunnel(hostId, pair.server, "User-hosted tunnel disconnected"));
3129
+ pair.server.addEventListener?.("error", () => cleanupHostTunnel(hostId, pair.server, "User-hosted tunnel disconnected"));
3130
+ }
3131
+ return upgradeResponse(pair.client, request);
3132
+ };
3133
+ const admitClient = async (request, workspaceId) => {
3134
+ if (!websocketRequest2(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
3135
+ const authorized = await authorizeWorkspaceRelayRequest(relayOptions, request, workspaceId);
3136
+ if (!authorized.ok) return authorized.response;
3137
+ if (authorized.request.target.access !== "cloud") return admitUserHostedClient(request, authorized.request);
3138
+ return await admitCloudClient(request, authorized.request);
3139
+ };
3140
+ const admitCloudClient = async (request, authorized) => {
3141
+ let upstream;
3142
+ try {
3143
+ upstream = await connectWebSocket(
3144
+ workspaceRelayTargetUrl(
3145
+ authorized.target,
3146
+ authorized.path,
3147
+ new URL(request.url).search
3148
+ ).toString(),
3149
+ {
3150
+ headers: headerRecord(workspaceRelayForwardHeaders(
3151
+ new Headers(),
3152
+ authorized.relayHostToken,
3153
+ authorized.target.workspaceId,
3154
+ { userHosted: false, upstreamHeaders: authorized.target.upstreamHeaders }
3155
+ ))
3156
+ }
3157
+ );
3158
+ } catch {
3159
+ return json("upstream_unavailable", "Workspace upstream is unavailable", 503);
3160
+ }
3161
+ const pair = acceptSocket();
3162
+ const client = { request: authorized, socket: pair.server };
3163
+ clients.add(client);
3164
+ let closed = false;
3165
+ let upstreamOpen = upstream.readyState === void 0 || upstream.readyState === 1;
3166
+ const queue = [];
3167
+ const activeTokenTimer = watchRuntimeAccessToken(client, (reason) => {
3168
+ closeSocket(pair.server, 1008, reason);
3169
+ });
3170
+ const cleanupClient = (event) => {
3171
+ if (closed) return;
3172
+ closed = true;
3173
+ if (activeTokenTimer) clearInterval(activeTokenTimer);
3174
+ clients.delete(client);
3175
+ closeSocket(upstream, safeCloseCode2(event?.code, 1e3), event?.reason ?? "Client WebSocket closed");
3176
+ };
3177
+ const cleanupUpstream = (event) => {
3178
+ if (closed) return;
3179
+ closed = true;
3180
+ if (activeTokenTimer) clearInterval(activeTokenTimer);
3181
+ clients.delete(client);
3182
+ closeSocket(pair.server, safeCloseCode2(event?.code), event?.reason ?? "");
3183
+ };
3184
+ const flushQueue = () => {
3185
+ upstreamOpen = true;
3186
+ for (const item of queue.splice(0)) {
3187
+ if (!sendSocket(upstream, item)) {
3188
+ closeSocket(pair.server, 1011, "Upstream WebSocket connection failed");
3189
+ return;
3190
+ }
3191
+ }
3192
+ };
3193
+ pair.server.addEventListener?.("message", (event) => {
3194
+ const payload = socketPayload(event?.data);
3195
+ if (payload === void 0) return;
3196
+ if (!upstreamOpen) {
3197
+ if (queue.length >= (options.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT2)) {
3198
+ closeSocket(pair.server, 1011, "Upstream WebSocket queue limit exceeded");
3199
+ cleanupClient({ code: 1011, reason: "Upstream WebSocket queue limit exceeded" });
3200
+ return;
3201
+ }
3202
+ queue.push(payload);
3203
+ return;
3204
+ }
3205
+ if (!sendSocket(upstream, payload)) closeSocket(pair.server, 1011, "Upstream WebSocket connection failed");
3206
+ });
3207
+ pair.server.addEventListener?.("close", cleanupClient);
3208
+ pair.server.addEventListener?.("error", cleanupClient);
3209
+ upstream.addEventListener?.("open", flushQueue);
3210
+ upstream.addEventListener?.("message", (event) => {
3211
+ const payload = socketPayload(event?.data);
3212
+ if (payload !== void 0) sendSocket(pair.server, payload);
3213
+ });
3214
+ upstream.addEventListener?.("close", cleanupUpstream);
3215
+ upstream.addEventListener?.("error", () => cleanupUpstream({ code: 1011, reason: "Upstream WebSocket connection failed" }));
3216
+ return upgradeResponse(pair.client, request);
3217
+ };
3218
+ const admitUserHostedClient = (request, authorized) => {
3219
+ const tunnel = hostTunnels.get(authorized.target.hostId);
3220
+ if (!tunnel || !directory.activeHost({ hostId: authorized.target.hostId, workspaceId: authorized.target.workspaceId })) {
3221
+ return json("user_hosted_app_offline", "User-hosted workspace is offline", 503);
3222
+ }
3223
+ if (tunnel.channels.size >= (options.tunnelChannelCap ?? TUNNEL_CHANNEL_CAP_DEFAULT)) {
3224
+ return json("too_many_channels", "User-hosted tunnel has too many active WebSocket channels", 503);
3225
+ }
3226
+ const channelId = randomToken();
3227
+ const pair = acceptSocket({
3228
+ kind: "user-hosted-client",
3229
+ channelId,
3230
+ claims: authorized.claims,
3231
+ target: authorized.target,
3232
+ path: authorized.path,
3233
+ connectedAt: options.now?.() ?? Date.now()
3234
+ });
3235
+ const client = {
3236
+ request: authorized,
3237
+ socket: pair.server,
3238
+ channelId
3239
+ };
3240
+ clients.add(client);
3241
+ tunnel.channels.set(channelId, client);
3242
+ const activeTokenTimer = hibernation ? void 0 : watchRuntimeAccessToken(client, (reason) => {
3243
+ closeSocket(pair.server, 1008, reason);
3244
+ });
3245
+ const activeTargetTimer = hibernation ? void 0 : watchUserHostedTarget(client, (reason) => {
3246
+ closeSocket(pair.server, 1011, reason);
3247
+ });
3248
+ if (!hibernation) {
3249
+ const cleanup = (event) => {
3250
+ if (activeTokenTimer) clearInterval(activeTokenTimer);
3251
+ if (activeTargetTimer) clearInterval(activeTargetTimer);
3252
+ cleanupUserHostedClient(channelId, pair.server, event);
3253
+ };
3254
+ pair.server.addEventListener?.("message", (event) => {
3255
+ if (!event) return;
3256
+ void handleUserHostedClientMessage(channelId, pair.server, event.data);
3257
+ });
3258
+ pair.server.addEventListener?.("close", cleanup);
3259
+ pair.server.addEventListener?.("error", cleanup);
3260
+ }
3261
+ sendSocket(tunnel.socket, JSON.stringify({
3262
+ type: "ws.open",
3263
+ protocol: TUNNEL_PROTOCOL_VERSION2,
3264
+ channel_id: channelId,
3265
+ workspace_id: authorized.target.workspaceId,
3266
+ path: authorized.path,
3267
+ headers: headerRecord(workspaceRelayForwardHeaders(
3268
+ new Headers(),
3269
+ authorized.relayHostToken,
3270
+ authorized.target.workspaceId,
3271
+ { userHosted: true }
3272
+ ))
3273
+ }));
3274
+ return upgradeResponse(pair.client, request);
3275
+ };
3276
+ const forwardCloudHttp = async (request, workspaceId, trace) => {
3277
+ const authorized = await traceSpan(
3278
+ trace,
3279
+ "relay-auth",
3280
+ () => authorizeWorkspaceRelayRequest(relayOptions, request, workspaceId, {
3281
+ span: (name, run) => traceSpan(trace, name, run)
3282
+ })
3283
+ );
3284
+ if (!authorized.ok) return authorized.response;
3285
+ if (authorized.request.target.access !== "cloud") {
3286
+ trace && (trace.routeKind = "user-hosted-http");
3287
+ return forwardUserHostedHttp(request, authorized.request, trace);
3288
+ }
3289
+ trace && (trace.routeKind = "cloud-http");
3290
+ const controller = new AbortController();
3291
+ const timer = setTimeout(() => controller.abort(), options.forwardTimeoutMs ?? 3e4);
3292
+ try {
3293
+ const upstream = await traceSpan(trace, "upstream-fetch", () => (options.fetch ?? fetch)(
3294
+ workspaceRelayTargetUrl(
3295
+ authorized.request.target,
3296
+ authorized.request.path,
3297
+ new URL(request.url).search
3298
+ ),
3299
+ workspaceRelayForwardRequestInit(request, authorized.request.relayHostToken, workspaceId, {
3300
+ signal: controller.signal,
3301
+ upstreamHeaders: authorized.request.target.upstreamHeaders
3302
+ })
3303
+ ));
3304
+ return new Response(upstream.body, {
3305
+ status: upstream.status,
3306
+ statusText: upstream.statusText,
3307
+ headers: upstream.headers
3308
+ });
3309
+ } catch (err) {
3310
+ const aborted = err instanceof Error && err.name === "AbortError";
3311
+ return Response.json(
3312
+ errorBody(
3313
+ aborted ? "upstream_timeout" : "upstream_unavailable",
3314
+ aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable"
3315
+ ),
3316
+ { status: aborted ? 504 : 503 }
3317
+ );
3318
+ } finally {
3319
+ clearTimeout(timer);
3320
+ }
3321
+ };
3322
+ const forwardUserHostedHttp = async (request, authorized, trace) => {
3323
+ const tunnel = hostTunnels.get(authorized.target.hostId);
3324
+ if (!tunnel || !directory.activeHost({ hostId: authorized.target.hostId, workspaceId: authorized.target.workspaceId })) {
3325
+ return json("user_hosted_app_offline", "User-hosted workspace is offline", 503);
3326
+ }
3327
+ const awaitingStart = [...tunnel.pending.values()].filter((entry) => entry.startedSeq === void 0).length;
3328
+ if (awaitingStart >= (options.tunnelPendingHttpCap ?? TUNNEL_PENDING_HTTP_CAP_DEFAULT)) {
3329
+ return json("too_many_pending_requests", "User-hosted tunnel has too many pending HTTP requests", 503);
3330
+ }
3331
+ const requestId = randomToken();
3332
+ const init = workspaceRelayForwardRequestInit(request, authorized.relayHostToken, authorized.target.workspaceId, {
3333
+ userHosted: true
3334
+ });
3335
+ const bodyMaxBytes = options.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT2;
3336
+ const contentLength = Number(request.headers.get("content-length") ?? Number.NaN);
3337
+ if (Number.isFinite(contentLength) && contentLength > bodyMaxBytes) {
3338
+ return json("request_body_too_large", "User-hosted request body exceeds the relay limit", 413);
3339
+ }
3340
+ const bodyBytes = request.method === "GET" || request.method === "HEAD" ? void 0 : await traceSpan(trace, "request-body", () => readRequestBodyCapped(request, bodyMaxBytes));
3341
+ if (bodyBytes === "too_large") {
3342
+ return json("request_body_too_large", "User-hosted request body exceeds the relay limit", 413);
3343
+ }
3344
+ const body = bodyBytes ? bytesToBase64(bodyBytes) : void 0;
3345
+ const pending = new Promise((resolve, reject) => {
3346
+ const timeout = setTimeout(() => {
3347
+ tunnel.pending.delete(requestId);
3348
+ tunnel.socket.send?.(JSON.stringify({
3349
+ type: "http.response.flow",
3350
+ protocol: TUNNEL_PROTOCOL_VERSION2,
3351
+ request_id: requestId,
3352
+ paused: false,
3353
+ reason: "closed"
3354
+ }));
3355
+ reject(new Error("User-hosted tunnel timed out"));
3356
+ }, options.forwardTimeoutMs ?? 3e4);
3357
+ tunnel.pending.set(requestId, {
3358
+ chunks: [],
3359
+ bytes: 0,
3360
+ timeout,
3361
+ resolve,
3362
+ reject
3363
+ });
3364
+ });
3365
+ tunnel.socket.send?.(JSON.stringify({
3366
+ type: "http.request",
3367
+ protocol: TUNNEL_PROTOCOL_VERSION2,
3368
+ request_id: requestId,
3369
+ workspace_id: authorized.target.workspaceId,
3370
+ method: request.method,
3371
+ path: `${authorized.path}${new URL(request.url).search}`,
3372
+ headers: headerRecord(init.headers instanceof Headers ? init.headers : new Headers(init.headers)),
3373
+ ...body ? { body_base64: body } : {},
3374
+ end: true
3375
+ }));
3376
+ try {
3377
+ return await traceSpan(trace, "tunnel-wait", () => pending);
3378
+ } catch (err) {
3379
+ const timeout = err instanceof Error && err.message.includes("timed out");
3380
+ const responseTooLarge = err instanceof Error && err.message.includes("response body exceeds");
3381
+ return Response.json(
3382
+ errorBody(
3383
+ timeout ? "user_hosted_tunnel_timeout" : responseTooLarge ? "user_hosted_response_body_too_large" : "user_hosted_tunnel_unavailable",
3384
+ timeout ? "User-hosted tunnel timed out" : responseTooLarge ? "User-hosted response body exceeds the relay limit" : "User-hosted tunnel is unavailable"
3385
+ ),
3386
+ { status: responseTooLarge ? 413 : 503 }
3387
+ );
3388
+ }
3389
+ };
3390
+ return {
3391
+ async fetch(request) {
3392
+ ensureHibernatedSocketsRebuilt();
3393
+ const url = new URL(request.url);
3394
+ const roomWorkspaceId = workspaceRelayDurableObjectWorkspaceId(request);
3395
+ if (request.method === "OPTIONS" && (url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/"))) {
3396
+ return corsPreflight(request);
3397
+ }
3398
+ if (url.pathname === "/health") {
3399
+ return Response.json({
3400
+ ok: !draining,
3401
+ service: "workspace-relay-room",
3402
+ mode: "cloudflare-durable-object",
3403
+ workspaceId: roomWorkspaceId,
3404
+ hostTunnelCount: hostTunnels.size,
3405
+ clientCount: clients.size,
3406
+ draining
3407
+ }, { status: draining ? 503 : 200 });
3408
+ }
3409
+ if (draining) return json("relay_draining", "Workspace relay is shutting down; try another instance", 503);
3410
+ if (!roomWorkspaceId) return json("relay_workspace_required", "Workspace Relay requests must include a workspace id", 400);
3411
+ if (url.pathname.startsWith("/host-tunnels/")) return admitHostTunnel(request, roomWorkspaceId, url);
3412
+ if (url.pathname.startsWith("/workspaces/")) {
3413
+ if (websocketRequest2(request)) return admitClient(request, roomWorkspaceId);
3414
+ const trace = traceRequest(request, "workspace-http", options);
3415
+ const roomTotalStartedAt = performance.now();
3416
+ const nextRequest = tracedRequest(request, trace);
3417
+ const response = await traceSpan(trace, "relay-room", () => forwardCloudHttp(nextRequest, roomWorkspaceId, trace));
3418
+ trace.phases.push({ name: "relay-room-total", ms: performance.now() - roomTotalStartedAt });
3419
+ const tracedResponse = withTraceHeaders(response, trace);
3420
+ logTrace(options, nextRequest, roomWorkspaceId, trace, tracedResponse);
3421
+ return withCors(request, tracedResponse);
3422
+ }
3423
+ return json("relay_route_not_found", "Workspace Relay route not found", 404);
3424
+ },
3425
+ async webSocketMessage(socket, message) {
3426
+ ensureHibernatedSocketsRebuilt();
3427
+ const attachment = socketAttachment(socket);
3428
+ if (!attachment) return;
3429
+ if (attachment.kind === "host-tunnel") {
3430
+ await handleTunnelMessage(attachment.hostId, { data: message });
3431
+ return;
3432
+ }
3433
+ await handleUserHostedClientMessage(attachment.channelId, socket, message);
3434
+ },
3435
+ webSocketClose(socket, code, reason) {
3436
+ ensureHibernatedSocketsRebuilt();
3437
+ const attachment = socketAttachment(socket);
3438
+ if (!attachment) return;
3439
+ if (attachment.kind === "host-tunnel") {
3440
+ cleanupHostTunnel(attachment.hostId, socket, "User-hosted tunnel disconnected");
3441
+ return;
3442
+ }
3443
+ cleanupUserHostedClient(attachment.channelId, socket, { code, reason });
3444
+ },
3445
+ webSocketError(socket) {
3446
+ ensureHibernatedSocketsRebuilt();
3447
+ const attachment = socketAttachment(socket);
3448
+ if (!attachment) return;
3449
+ if (attachment.kind === "host-tunnel") {
3450
+ cleanupHostTunnel(attachment.hostId, socket, "User-hosted tunnel disconnected");
3451
+ return;
3452
+ }
3453
+ cleanupUserHostedClient(attachment.channelId, socket, { code: 1011, reason: "WebSocket error" });
3454
+ },
3455
+ state() {
3456
+ ensureHibernatedSocketsRebuilt();
3457
+ return {
3458
+ hostTunnelCount: hostTunnels.size,
3459
+ clientCount: clients.size,
3460
+ hostIds: [...hostTunnels.keys()]
3461
+ };
3462
+ },
3463
+ drain: drainController
3464
+ };
3465
+ }
1535
3466
  export {
3467
+ DEFAULT_RELAY_LOCATION_HINT,
1536
3468
  WorkspaceRelayAuthError,
1537
- __resetHostTunnelStateDebounce,
1538
3469
  __slowConsumerInternalsForTest,
1539
3470
  authorizeWorkspaceRelayRequest,
3471
+ createCachedRevocationClient,
3472
+ createCachedTargetClient,
1540
3473
  createWorkspaceRelay,
1541
3474
  createWorkspaceRelayBun,
1542
3475
  createWorkspaceRelayDirectory,
3476
+ createWorkspaceRelayDurableObjectGateway,
3477
+ createWorkspaceRelayDurableObjectRoom,
3478
+ createWorkspaceRelayTrace,
1543
3479
  disposeAuditSampler,
1544
3480
  disposeWorkspaceRelayDirectory,
3481
+ forwardWorkspaceRelayRequest,
1545
3482
  getAuditSuppressedCount,
1546
- getFragmentationStats,
1547
- getSlowConsumerStats,
1548
3483
  hostTunnelTokenAudience,
3484
+ isRelayClaimPair,
1549
3485
  mintHostTunnelToken,
1550
3486
  mintRelayHostToken,
1551
3487
  mintRuntimeAccessToken,
1552
3488
  relayHostTokenAudience,
1553
3489
  relayHostTokenIssuer,
1554
- resetFragmentationStats,
1555
- resetSlowConsumerStats,
1556
3490
  runtimeAccessTokenAudience,
1557
3491
  runtimeAccessTokenIssuer,
3492
+ setWorkspaceRelayAppOrigins,
3493
+ validateRuntimeAccessTokenClaims,
1558
3494
  verifyHostTunnelToken,
1559
3495
  verifyRelayHostToken,
1560
3496
  verifyRuntimeAccessToken,
3497
+ workspaceRelayDurableObjectRoomName,
3498
+ workspaceRelayDurableObjectWorkspaceId,
1561
3499
  workspaceRelayForwardHeaders,
1562
- workspaceRelayTargetUrl
3500
+ workspaceRelayForwardRequestInit,
3501
+ workspaceRelayServerTiming,
3502
+ workspaceRelayTargetUrl,
3503
+ workspaceRelayTimingResponse
1563
3504
  };