@claxedo/workspace-relay 0.6.0 → 0.8.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
@@ -1,5 +1,21 @@
1
1
  // src/auth.ts
2
- import { SignJWT, errors, jwtVerify } from "jose";
2
+ import { SignJWT, errors, exportJWK, importJWK, jwtVerify } from "jose";
3
+
4
+ // ../claxedo-helpers/dist/guards.mjs
5
+ function isRecord(value) {
6
+ return value !== null && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+ function isFiniteNumber(value) {
9
+ return typeof value === "number" && Number.isFinite(value);
10
+ }
11
+ function asFiniteNumber(value) {
12
+ return isFiniteNumber(value) ? value : void 0;
13
+ }
14
+ function numberClaim(payload, key) {
15
+ return asFiniteNumber(payload[key]);
16
+ }
17
+
18
+ // src/auth.ts
3
19
  var algorithms = ["EdDSA", "ES256", "RS256"];
4
20
  var runtimeAccessTokenIssuer = "claxedo-control-plane";
5
21
  var runtimeAccessTokenAudience = "workspace-relay";
@@ -19,25 +35,51 @@ function seconds(input = Date.now()) {
19
35
  return Math.floor(input / 1e3);
20
36
  }
21
37
  function requireAlgorithm(input) {
22
- if (algorithms.includes(input)) return input;
38
+ const algorithm = algorithms.find((candidate) => candidate === input);
39
+ if (algorithm) return algorithm;
23
40
  throw new WorkspaceRelayAuthError("invalid_relay_token", "Unsupported relay token algorithm");
24
41
  }
25
42
  function stringClaim(payload, key) {
26
43
  const value = payload[key];
27
44
  return typeof value === "string" && value.trim() ? value : void 0;
28
45
  }
29
- function numberClaim(payload, key) {
30
- const value = payload[key];
31
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
32
- }
33
46
  function stringArrayClaim(payload, key) {
34
47
  const value = payload[key];
35
48
  return Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim()) ? value : void 0;
36
49
  }
50
+ function isHostGeneration(value) {
51
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
52
+ }
53
+ function hostGenerationClaims(payload) {
54
+ const enrollment_id = stringClaim(payload, "enrollment_id");
55
+ const generation = payload.generation;
56
+ if (generation === void 0) return enrollment_id ? { enrollment_id } : {};
57
+ if (!isHostGeneration(generation) || !enrollment_id) return null;
58
+ return { enrollment_id, generation };
59
+ }
37
60
  function roleClaim(payload) {
38
61
  const value = stringClaim(payload, "role");
39
62
  return value === "viewer" || value === "editor" || value === "admin" || value === "owner" ? value : void 0;
40
63
  }
64
+ function actorProfileClaims(payload) {
65
+ const actor_public_id = stringClaim(payload, "actor_public_id");
66
+ const actor_name = stringClaim(payload, "actor_name");
67
+ const actor_avatar_url = stringClaim(payload, "actor_avatar_url");
68
+ if (!actor_public_id && !actor_name && !actor_avatar_url) return {};
69
+ if (!actor_public_id || !actor_name) return void 0;
70
+ return { actor_public_id, actor_name, ...actor_avatar_url ? { actor_avatar_url } : {} };
71
+ }
72
+ function actorProfilePayload(input) {
73
+ if (!input.actorPublicId && !input.actorName && !input.actorAvatarUrl) return {};
74
+ if (!input.actorPublicId || !input.actorName) {
75
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Actor display profile claims are incomplete");
76
+ }
77
+ return {
78
+ actor_public_id: input.actorPublicId,
79
+ actor_name: input.actorName,
80
+ ...input.actorAvatarUrl ? { actor_avatar_url: input.actorAvatarUrl } : {}
81
+ };
82
+ }
41
83
  function isRelayClaimPair(input) {
42
84
  return input.access === "cloud" && input.backing === "cloud-vm" || input.access === "user-hosted" && input.backing === "local-worktree";
43
85
  }
@@ -58,6 +100,21 @@ function checkTarget(payload, expected) {
58
100
  throw new WorkspaceRelayAuthError("relay_token_host_mismatch", "Relay token host does not match request");
59
101
  }
60
102
  }
103
+ async function deriveRelayHostPublicKey(privateKey) {
104
+ const jwk = await exportJWK(privateKey);
105
+ const imported = await importJWK({ kty: jwk.kty, crv: jwk.crv, x: jwk.x }, "EdDSA", { extractable: true });
106
+ if (imported instanceof Uint8Array) {
107
+ throw new Error("Relay host public key imported as raw bytes; expected an EdDSA public key");
108
+ }
109
+ return imported;
110
+ }
111
+ async function deriveRelayHostKid(publicKey) {
112
+ const jwk = await exportJWK(publicKey);
113
+ const material = jwk.x ?? jwk.n ?? "";
114
+ if (!material) throw new Error("Cannot derive kid: public key has no public component");
115
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
116
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 16);
117
+ }
61
118
  async function verifyJwt(token, key, input) {
62
119
  try {
63
120
  if (typeof key === "function") {
@@ -83,11 +140,15 @@ async function verifyJwt(token, key, input) {
83
140
  async function mintRuntimeAccessToken(input, key, alg) {
84
141
  const now = seconds(input.now);
85
142
  return await new SignJWT({
143
+ principal_kind: input.principalKind,
144
+ actor_id: input.actorId,
145
+ actor_kind: input.actorKind,
146
+ ...actorProfilePayload(input),
86
147
  org_id: input.orgId,
87
148
  workspace_id: input.workspaceId,
88
149
  host_id: input.hostId,
89
150
  role: input.role
90
- }).setProtectedHeader({ alg: requireAlgorithm(alg) }).setIssuer(runtimeAccessTokenIssuer).setAudience(runtimeAccessTokenAudience).setSubject(input.subject).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 30 * 60)).setJti(input.jti ?? jti()).sign(key);
151
+ }).setProtectedHeader({ alg: requireAlgorithm(alg) }).setIssuer(runtimeAccessTokenIssuer).setAudience(runtimeAccessTokenAudience).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 30 * 60)).setJti(input.jti ?? jti()).sign(key);
91
152
  }
92
153
  async function verifyRuntimeAccessToken(token, key, expected) {
93
154
  const result = await verifyJwt(token, key, {
@@ -115,9 +176,14 @@ function validateRuntimeAccessTokenClaims(input, expected) {
115
176
  }
116
177
  async function mintHostTunnelToken(input, key, alg) {
117
178
  const now = seconds(input.now);
179
+ if (input.generation !== void 0 && (!isHostGeneration(input.generation) || !input.enrollmentId)) {
180
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Host Tunnel Token generation requires a non-negative integer and an enrollment id");
181
+ }
118
182
  return await new SignJWT({
119
183
  host_id: input.hostId,
120
- workspace_ids: input.workspaceIds
184
+ workspace_ids: input.workspaceIds,
185
+ ...input.enrollmentId ? { enrollment_id: input.enrollmentId } : {},
186
+ ...input.generation !== void 0 ? { generation: input.generation } : {}
121
187
  }).setProtectedHeader({ alg: requireAlgorithm(alg) }).setIssuer(runtimeAccessTokenIssuer).setAudience(hostTunnelTokenAudience).setSubject(input.subject).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 5 * 60)).setJti(input.jti ?? jti()).sign(key);
122
188
  }
123
189
  async function verifyHostTunnelToken(token, key, expected) {
@@ -140,13 +206,18 @@ async function mintRelayHostToken(input, key, alg) {
140
206
  const protectedHeader = { alg: requireAlgorithm(alg) };
141
207
  if (input.kid) protectedHeader.kid = input.kid;
142
208
  return await new SignJWT({
209
+ principal_kind: input.principalKind,
210
+ actor_id: input.actorId,
211
+ actor_kind: input.actorKind,
212
+ ...actorProfilePayload(input),
143
213
  org_id: input.orgId,
144
214
  workspace_id: input.workspaceId,
145
215
  host_id: input.hostId,
146
216
  role: input.role,
147
217
  access: input.access,
148
- backing: input.backing
149
- }).setProtectedHeader(protectedHeader).setIssuer(relayHostTokenIssuer).setAudience(relayHostTokenAudience).setSubject(input.subject).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 60)).setJti(input.jti ?? jti()).sign(key);
218
+ backing: input.backing,
219
+ parent_jti: input.parentJti
220
+ }).setProtectedHeader(protectedHeader).setIssuer(relayHostTokenIssuer).setAudience(relayHostTokenAudience).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 60)).setJti(input.jti ?? jti()).sign(key);
150
221
  }
151
222
  async function verifyRelayHostToken(token, key, expected) {
152
223
  const result = await verifyJwt(token, key, {
@@ -163,17 +234,24 @@ async function verifyRelayHostToken(token, key, expected) {
163
234
  function runtimeClaims(payload) {
164
235
  const exp = numberClaim(payload, "exp");
165
236
  const iat = numberClaim(payload, "iat");
166
- const sub = stringClaim(payload, "sub");
237
+ const principal_kind = stringClaim(payload, "principal_kind");
238
+ const actor_id = stringClaim(payload, "actor_id");
239
+ const actor_kind = stringClaim(payload, "actor_kind");
167
240
  const jti2 = stringClaim(payload, "jti");
168
241
  const org_id = stringClaim(payload, "org_id");
169
242
  const workspace_id = stringClaim(payload, "workspace_id");
170
243
  const host_id = stringClaim(payload, "host_id");
171
244
  const role = roleClaim(payload);
172
- if (!exp || !iat || !sub || !jti2 || !org_id || !workspace_id || !host_id || !role) return;
245
+ if (!exp || !iat || !jti2 || !org_id || !workspace_id || !host_id || !role || !actor_id || principal_kind !== "user" && principal_kind !== "service" || actor_kind !== "human" && actor_kind !== "agent" || principal_kind === "user" && actor_kind !== "human" || principal_kind === "service" && actor_kind !== "agent") return void 0;
246
+ const actorProfile = actorProfileClaims(payload);
247
+ if (!actorProfile) return void 0;
173
248
  return {
174
249
  iss: runtimeAccessTokenIssuer,
175
250
  aud: runtimeAccessTokenAudience,
176
- sub,
251
+ principal_kind,
252
+ actor_id,
253
+ actor_kind,
254
+ ...actorProfile,
177
255
  org_id,
178
256
  workspace_id,
179
257
  host_id,
@@ -191,12 +269,14 @@ function relayHostClaims(payload) {
191
269
  });
192
270
  const access = stringClaim(payload, "access");
193
271
  const backing = stringClaim(payload, "backing");
272
+ const parent_jti = stringClaim(payload, "parent_jti");
194
273
  const pair = { access, backing };
195
- if (!base || !isRelayClaimPair(pair)) return;
274
+ if (!base || !parent_jti || !isRelayClaimPair(pair)) return void 0;
196
275
  return {
197
276
  ...base,
198
277
  iss: relayHostTokenIssuer,
199
278
  aud: relayHostTokenAudience,
279
+ parent_jti,
200
280
  ...pair
201
281
  };
202
282
  }
@@ -207,22 +287,37 @@ function hostTunnelClaims(payload) {
207
287
  const jti2 = stringClaim(payload, "jti");
208
288
  const host_id = stringClaim(payload, "host_id");
209
289
  const workspace_ids = stringArrayClaim(payload, "workspace_ids");
210
- if (!exp || !iat || !sub || !jti2 || !host_id || !workspace_ids?.length) return;
290
+ const fence = hostGenerationClaims(payload);
291
+ if (!exp || !iat || !sub || !jti2 || !host_id || !workspace_ids?.length || fence === null) return void 0;
211
292
  return {
212
293
  iss: runtimeAccessTokenIssuer,
213
294
  aud: hostTunnelTokenAudience,
214
295
  sub,
215
296
  host_id,
216
297
  workspace_ids,
298
+ ...fence,
217
299
  exp,
218
300
  iat,
219
301
  jti: jti2
220
302
  };
221
303
  }
222
304
 
223
- // src/bun.ts
305
+ // src/cloudflare.ts
306
+ import { trimToUndefined } from "@claxedo/helpers/string";
307
+
308
+ // src/http.ts
309
+ import { bearerToken } from "@claxedo/helpers/string";
310
+ function errorBody(code, message) {
311
+ return {
312
+ error: {
313
+ code,
314
+ message
315
+ }
316
+ };
317
+ }
318
+
319
+ // src/cloudflare.ts
224
320
  import {
225
- makeTunnelPing,
226
321
  makeTunnelPong,
227
322
  TUNNEL_PROTOCOL_VERSION,
228
323
  validateTunnelMessage
@@ -231,14 +326,12 @@ import {
231
326
  // src/server.ts
232
327
  import { Hono } from "hono";
233
328
  import { cors } from "hono/cors";
234
- import { exportJWK } from "jose";
329
+ import { exportJWK as exportJWK2 } from "jose";
235
330
 
236
331
  // src/cors-origins.ts
237
332
  var DEFAULT_RELAY_APP_ORIGINS = [
238
333
  "http://localhost:*",
239
334
  "http://127.0.0.1:*",
240
- "https://opencode.ai",
241
- "https://*.opencode.ai",
242
335
  "https://claxedo.com",
243
336
  "https://*.claxedo.com"
244
337
  ];
@@ -275,22 +368,76 @@ function createOriginMatcher(patterns) {
275
368
  };
276
369
  }
277
370
 
278
- // src/http.ts
279
- function bearerToken(header) {
280
- if (!header) return;
281
- const match = /^Bearer\s+(.+)$/i.exec(header.trim());
282
- return match?.[1]?.trim() || void 0;
371
+ // src/user-hosted-forwarding.ts
372
+ function isUserHostedTarget(target) {
373
+ return target.access === "user-hosted";
283
374
  }
284
- function errorBody(code, message) {
375
+
376
+ // src/server.ts
377
+ function parseWorkspaceRelayTarget(input) {
378
+ if (!isRecord(input)) return void 0;
379
+ const row = input;
380
+ const { workspaceId, hostId, baseUrl, upstreamHeaders } = row;
381
+ if (typeof workspaceId !== "string" || typeof hostId !== "string" || typeof baseUrl !== "string") return void 0;
382
+ if (upstreamHeaders !== void 0 && !isStringRecord(upstreamHeaders)) return void 0;
383
+ const pair = relayClaimPair(row.access, row.backing);
384
+ if (!pair) return void 0;
285
385
  return {
286
- error: {
287
- code,
288
- message
289
- }
386
+ workspaceId,
387
+ hostId,
388
+ baseUrl,
389
+ ...upstreamHeaders ? { upstreamHeaders } : {},
390
+ ...pair
290
391
  };
291
392
  }
292
-
293
- // src/server.ts
393
+ function parseRuntimeAccessTokenActiveResult(input) {
394
+ if (!isRecord(input)) return void 0;
395
+ const row = input;
396
+ if (row.active === true) return { active: true };
397
+ if (row.active !== false) return void 0;
398
+ if (typeof row.code !== "string" || typeof row.reason !== "string") return void 0;
399
+ return { active: false, code: row.code, reason: row.reason };
400
+ }
401
+ function parseHostGenerationResult(input) {
402
+ if (!isRecord(input)) return void 0;
403
+ const row = input;
404
+ if (typeof row.enrollmentId !== "string" || !row.enrollmentId.trim()) return void 0;
405
+ if (!isHostGeneration(row.generation) || typeof row.revoked !== "boolean") return void 0;
406
+ return { enrollmentId: row.enrollmentId, generation: row.generation, revoked: row.revoked };
407
+ }
408
+ var HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT = 5e3;
409
+ var HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE = "relay_resolver_enrollment_not_found";
410
+ function createHostGenerationResolverLookup(url, options) {
411
+ const fetcher = options.fetch ?? ((target, init) => fetch(target, init));
412
+ const timeoutMs = options.timeoutMs ?? HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT;
413
+ return async ({ enrollmentId }) => {
414
+ const target = new URL(url);
415
+ target.searchParams.set("enrollmentId", enrollmentId);
416
+ const res = await fetcher(target, { headers: options.headers, signal: AbortSignal.timeout(timeoutMs) });
417
+ if (res.status === 404) {
418
+ const body = await res.json().catch(() => void 0);
419
+ const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
420
+ if (error?.code === HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE) return void 0;
421
+ throw new Error(`relay host-generation resolver failed: 404 (no host-generation route at ${url})`);
422
+ }
423
+ if (!res.ok) throw new Error(`relay host-generation resolver failed: ${res.status} ${await res.text()}`);
424
+ const result = parseHostGenerationResult(await res.json());
425
+ if (!result) throw new Error("relay host-generation resolver returned a malformed result");
426
+ return result;
427
+ };
428
+ }
429
+ function hostTunnelIncumbentOutranks(incumbentGeneration, candidateGeneration) {
430
+ if (incumbentGeneration === void 0) return false;
431
+ return candidateGeneration === void 0 || incumbentGeneration > candidateGeneration;
432
+ }
433
+ function relayClaimPair(access, backing) {
434
+ if (access === "cloud" && backing === "cloud-vm") return { access, backing };
435
+ if (access === "user-hosted" && backing === "local-worktree") return { access, backing };
436
+ return void 0;
437
+ }
438
+ function isStringRecord(input) {
439
+ return isRecord(input) && Object.values(input).every((value) => typeof value === "string");
440
+ }
294
441
  var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
295
442
  var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
296
443
  var RESOLVER_CACHE_MAX_ENTRIES = 8192;
@@ -330,6 +477,71 @@ function createCachedRevocationClient(inner, options = {}) {
330
477
  return await promise;
331
478
  };
332
479
  }
480
+ function createCachedHostGenerationClient(inner, options = {}) {
481
+ const ttlMs = options.ttlMs ?? 1e4;
482
+ const now = options.now ?? Date.now;
483
+ const cache = /* @__PURE__ */ new Map();
484
+ return async (args) => {
485
+ for (; ; ) {
486
+ const at = now();
487
+ const entry = cache.get(args.enrollmentId);
488
+ if (entry && entry.expiresAt > at) {
489
+ if (entry.result && entry.result.generation >= args.generation) return entry.result;
490
+ if (!entry.result && entry.promise) {
491
+ const shared = await entry.promise;
492
+ if (!shared || shared.generation >= args.generation) return shared;
493
+ continue;
494
+ }
495
+ }
496
+ const promise = inner(args).then((result) => {
497
+ pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
498
+ if (result) cache.set(args.enrollmentId, { result, expiresAt: now() + ttlMs });
499
+ else cache.delete(args.enrollmentId);
500
+ return result;
501
+ }).catch((err) => {
502
+ cache.delete(args.enrollmentId);
503
+ throw err;
504
+ });
505
+ cache.set(args.enrollmentId, { promise, expiresAt: at + ttlMs });
506
+ return await promise;
507
+ }
508
+ };
509
+ }
510
+ async function checkHostTunnelGeneration(lookup, claims) {
511
+ if (claims.generation === void 0 || !claims.enrollment_id) return { ok: true };
512
+ if (!lookup) {
513
+ return {
514
+ ok: false,
515
+ retryable: false,
516
+ code: "host_generation_unverifiable",
517
+ reason: "Host tunnel generation cannot be verified by a relay without a host-generation resolver"
518
+ };
519
+ }
520
+ let result;
521
+ try {
522
+ result = await lookup({ enrollmentId: claims.enrollment_id, generation: claims.generation });
523
+ } catch (err) {
524
+ return {
525
+ ok: false,
526
+ retryable: true,
527
+ code: "host_generation_lookup_unavailable",
528
+ reason: `Host generation lookup is unavailable: ${err instanceof Error ? err.message : String(err)}`
529
+ };
530
+ }
531
+ if (!result) {
532
+ return { ok: false, retryable: false, code: "host_enrollment_unknown", reason: "Host enrollment is unknown" };
533
+ }
534
+ if (result.revoked) {
535
+ return { ok: false, retryable: false, code: "host_enrollment_revoked", reason: "Host enrollment was revoked" };
536
+ }
537
+ if (result.generation > claims.generation) {
538
+ return { ok: false, retryable: false, code: "host_generation_superseded", reason: "Host tunnel generation was superseded" };
539
+ }
540
+ if (result.generation < claims.generation) {
541
+ return { ok: false, retryable: false, code: "host_generation_unknown", reason: "Host tunnel generation is ahead of the control plane" };
542
+ }
543
+ return { ok: true };
544
+ }
333
545
  function createCachedTargetClient(inner, options = {}) {
334
546
  const ttlMs = options.ttlMs ?? 5e3;
335
547
  const now = options.now ?? Date.now;
@@ -391,27 +603,27 @@ function isDangerousInboundHeader(name) {
391
603
  return false;
392
604
  }
393
605
  function forwardHeaders(input, relayHostToken, workspaceId, options = {}) {
394
- const headers2 = new Headers(input);
395
- headers2.delete("host");
606
+ const headers = new Headers(input);
607
+ headers.delete("host");
396
608
  const inboundNames = /* @__PURE__ */ new Set();
397
- headers2.forEach((_value, name) => inboundNames.add(name));
609
+ headers.forEach((_value, name) => inboundNames.add(name));
398
610
  for (const name of inboundNames) {
399
- if (isDangerousInboundHeader(name)) headers2.delete(name);
400
- }
401
- if (options.userHosted) headers2.delete("cookie");
402
- headers2.set("accept-encoding", "identity");
403
- headers2.delete("authorization");
404
- headers2.delete("Authorization");
405
- headers2.set("Authorization", `Bearer ${relayHostToken}`);
406
- headers2.set("x-workspace-id", workspaceId);
407
- headers2.set("X-Daytona-Skip-Preview-Warning", "true");
408
- headers2.set("X-Daytona-Skip-Last-Activity-Update", "true");
611
+ if (isDangerousInboundHeader(name)) headers.delete(name);
612
+ }
613
+ if (options.userHosted) headers.delete("cookie");
614
+ headers.set("accept-encoding", "identity");
615
+ headers.delete("authorization");
616
+ headers.delete("Authorization");
617
+ headers.set("Authorization", `Bearer ${relayHostToken}`);
618
+ headers.set("x-workspace-id", workspaceId);
619
+ headers.set("X-Daytona-Skip-Preview-Warning", "true");
620
+ headers.set("X-Daytona-Skip-Last-Activity-Update", "true");
409
621
  for (const [name, value] of Object.entries(options.upstreamHeaders ?? {})) {
410
622
  const trimmed = value.trim();
411
- if (trimmed) headers2.set(name, trimmed);
623
+ if (trimmed) headers.set(name, trimmed);
412
624
  }
413
- headers2.set("x-forwarded-by", "workspace-relay");
414
- return headers2;
625
+ headers.set("x-forwarded-by", "workspace-relay");
626
+ return headers;
415
627
  }
416
628
  function targetUrl(target, path, search) {
417
629
  const url = new URL(path.replace(/^\/+/, ""), target.baseUrl.endsWith("/") ? target.baseUrl : `${target.baseUrl}/`);
@@ -420,11 +632,9 @@ function targetUrl(target, path, search) {
420
632
  }
421
633
  var RELAY_VIEWER_DENIED_PATH = /^\/api\/wr\/pty(?:\/|$)/;
422
634
  function relayRuntimePath(request) {
423
- return new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/");
424
- }
425
- function relayAuthorizationPath(path) {
426
635
  try {
427
- return decodeURIComponent(path);
636
+ const decoded = decodeURIComponent(new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/")).replace(/^\/+/, "/");
637
+ return new URL(decoded.replace(/^\/+/, ""), "http://relay.invalid/").pathname;
428
638
  } catch {
429
639
  return void 0;
430
640
  }
@@ -432,8 +642,7 @@ function relayAuthorizationPath(path) {
432
642
  function roleAllowsRelayRequest(role, method, path) {
433
643
  if (role === "owner" || role === "admin" || role === "editor") return true;
434
644
  if (role === "viewer") {
435
- const authorizationPath = relayAuthorizationPath(path);
436
- if (!authorizationPath || RELAY_VIEWER_DENIED_PATH.test(authorizationPath)) return false;
645
+ if (RELAY_VIEWER_DENIED_PATH.test(path)) return false;
437
646
  return method === "GET" || method === "HEAD" || method === "OPTIONS";
438
647
  }
439
648
  return false;
@@ -447,7 +656,9 @@ function relayHostTokenCacheTtlMs(options, claims) {
447
656
  function relayHostTokenCacheKey(options, claims, target) {
448
657
  return [
449
658
  claims.jti,
450
- claims.sub,
659
+ claims.principal_kind,
660
+ claims.actor_id,
661
+ claims.actor_kind,
451
662
  claims.org_id,
452
663
  claims.role,
453
664
  claims.workspace_id,
@@ -552,7 +763,15 @@ async function relayHostTokenFor(options, claims, target, trace) {
552
763
  try {
553
764
  const token = await trace.span("rht-mint", async () => {
554
765
  promise = mintRelayHostToken({
555
- subject: claims.sub,
766
+ principalKind: claims.principal_kind,
767
+ actorId: claims.actor_id,
768
+ actorKind: claims.actor_kind,
769
+ parentJti: claims.jti,
770
+ ...claims.actor_public_id && claims.actor_name ? {
771
+ actorPublicId: claims.actor_public_id,
772
+ actorName: claims.actor_name,
773
+ ...claims.actor_avatar_url ? { actorAvatarUrl: claims.actor_avatar_url } : {}
774
+ } : {},
556
775
  orgId: claims.org_id,
557
776
  role: claims.role,
558
777
  ...target,
@@ -576,7 +795,15 @@ async function uncachedRelayHostTokenFor(options, claims, target, trace) {
576
795
  return await trace.span(
577
796
  "rht-mint",
578
797
  async () => await mintRelayHostToken({
579
- subject: claims.sub,
798
+ principalKind: claims.principal_kind,
799
+ actorId: claims.actor_id,
800
+ actorKind: claims.actor_kind,
801
+ parentJti: claims.jti,
802
+ ...claims.actor_public_id && claims.actor_name ? {
803
+ actorPublicId: claims.actor_public_id,
804
+ actorName: claims.actor_name,
805
+ ...claims.actor_avatar_url ? { actorAvatarUrl: claims.actor_avatar_url } : {}
806
+ } : {},
580
807
  orgId: claims.org_id,
581
808
  role: claims.role,
582
809
  ...target,
@@ -682,13 +909,13 @@ function workspaceRelayServerTiming(trace) {
682
909
  function workspaceRelayTimingResponse(response, trace) {
683
910
  const value = workspaceRelayServerTiming(trace);
684
911
  if (!value) return response;
685
- const headers2 = new Headers(response.headers);
686
- const prev = headers2.get("server-timing");
687
- headers2.set("server-timing", prev ? `${prev}, ${value}` : value);
912
+ const headers = new Headers(response.headers);
913
+ const prev = headers.get("server-timing");
914
+ headers.set("server-timing", prev ? `${prev}, ${value}` : value);
688
915
  return new Response(response.body, {
689
916
  status: response.status,
690
917
  statusText: response.statusText,
691
- headers: headers2
918
+ headers
692
919
  });
693
920
  }
694
921
  async function audit(options, event) {
@@ -715,13 +942,30 @@ function originMatcherFor(options) {
715
942
  }
716
943
  return matcher;
717
944
  }
945
+ var RELAY_ALLOWED_REQUEST_HEADER_LIST = [
946
+ "Accept",
947
+ "Authorization",
948
+ "Content-Type",
949
+ "Last-Event-ID",
950
+ "Traceparent",
951
+ "Tracestate",
952
+ "X-Fetch-Bypass-Throttle",
953
+ "X-Daytona-Skip-Preview-Warning",
954
+ "X-Workspace-Id",
955
+ "X-OpenCode-Directory",
956
+ "X-Claxedo-Runner",
957
+ "X-Claxedo-Model",
958
+ "X-Claxedo-Draft-Id",
959
+ "X-Claxedo-Binary"
960
+ ];
961
+ var RELAY_ALLOWED_REQUEST_HEADERS = RELAY_ALLOWED_REQUEST_HEADER_LIST.join(", ");
718
962
  function denyCorsHeaders(request, originAllowed = defaultOriginMatcher) {
719
963
  const origin = request.headers.get("origin");
720
964
  if (!origin) return void 0;
721
965
  if (!originAllowed(origin)) return void 0;
722
966
  return {
723
967
  "access-control-allow-origin": origin,
724
- "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",
968
+ "access-control-allow-headers": RELAY_ALLOWED_REQUEST_HEADERS,
725
969
  "access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"
726
970
  };
727
971
  }
@@ -731,7 +975,9 @@ async function deny(options, input) {
731
975
  action: "relay.request.denied",
732
976
  result: "deny",
733
977
  reason: input.code,
734
- subject: input.claims?.sub,
978
+ actorId: input.claims?.actor_id,
979
+ actorKind: input.claims?.actor_kind,
980
+ principalKind: input.claims?.principal_kind,
735
981
  ...input.claims ? { role: input.claims.role } : {},
736
982
  workspaceId: input.claims?.workspace_id,
737
983
  hostId: input.claims?.host_id,
@@ -780,7 +1026,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
780
1026
  };
781
1027
  }
782
1028
  const path = relayRuntimePath(request);
783
- if (!roleAllowsRelayRequest(claims.role, request.method, path)) {
1029
+ if (!path || !roleAllowsRelayRequest(claims.role, request.method, path)) {
784
1030
  return {
785
1031
  ok: false,
786
1032
  code: "relay_role_denied",
@@ -807,7 +1053,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
807
1053
  })
808
1054
  };
809
1055
  }
810
- if (target.access === "user-hosted" && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
1056
+ if (isUserHostedTarget(target) && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
811
1057
  return {
812
1058
  ok: false,
813
1059
  code: "user_hosted_app_offline",
@@ -823,7 +1069,9 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
823
1069
  await span("audit", async () => await audit(options, {
824
1070
  action: "relay.request.accepted",
825
1071
  result: "allow",
826
- subject: claims.sub,
1072
+ actorId: claims.actor_id,
1073
+ actorKind: claims.actor_kind,
1074
+ principalKind: claims.principal_kind,
827
1075
  role: claims.role,
828
1076
  workspaceId: claims.workspace_id,
829
1077
  hostId: claims.host_id,
@@ -866,20 +1114,20 @@ async function forwardWorkspaceRelayRequest(request, target, relayHostToken, pat
866
1114
  upstreamHeaders: target.upstreamHeaders
867
1115
  })
868
1116
  ));
869
- const headers2 = new Headers(upstream.headers);
870
- headers2.delete("access-control-allow-origin");
871
- headers2.delete("access-control-allow-credentials");
872
- headers2.delete("access-control-allow-headers");
873
- headers2.delete("access-control-allow-methods");
874
- headers2.delete("access-control-expose-headers");
875
- headers2.delete("access-control-max-age");
1117
+ const headers = new Headers(upstream.headers);
1118
+ headers.delete("access-control-allow-origin");
1119
+ headers.delete("access-control-allow-credentials");
1120
+ headers.delete("access-control-allow-headers");
1121
+ headers.delete("access-control-allow-methods");
1122
+ headers.delete("access-control-expose-headers");
1123
+ headers.delete("access-control-max-age");
876
1124
  for (const [key, value] of Object.entries(denyCorsHeaders(request, originAllowed) ?? {})) {
877
- headers2.set(key, value);
1125
+ headers.set(key, value);
878
1126
  }
879
1127
  return new Response(upstream.body, {
880
1128
  status: upstream.status,
881
1129
  statusText: upstream.statusText,
882
- headers: headers2
1130
+ headers
883
1131
  });
884
1132
  } catch (err) {
885
1133
  const aborted = err instanceof Error && err.name === "AbortError";
@@ -906,7 +1154,7 @@ function isLoopbackAddress(address) {
906
1154
  if (!address) return false;
907
1155
  const trimmed = address.trim();
908
1156
  if (LOOPBACK_REMOTES.has(trimmed)) return true;
909
- if (/^127\./.test(trimmed)) return true;
1157
+ if (trimmed.startsWith("127.")) return true;
910
1158
  if (/^::ffff:127\./i.test(trimmed)) return true;
911
1159
  return false;
912
1160
  }
@@ -962,1295 +1210,140 @@ function buildMetricsBody(options) {
962
1210
  return body;
963
1211
  }
964
1212
  function createWorkspaceRelay(options) {
965
- const app = new Hono();
966
- const originAllowed = originMatcherFor(options);
967
- app.use("*", cors({
968
- origin: (origin) => {
969
- if (!origin) return void 0;
970
- return originAllowed(origin) ? origin : void 0;
971
- },
972
- allowHeaders: [
973
- "Accept",
974
- "Authorization",
975
- "Content-Type",
976
- "X-Daytona-Skip-Preview-Warning",
977
- "X-Workspace-Id",
978
- "X-OpenCode-Directory",
979
- "X-Claxedo-Runner",
980
- "X-Claxedo-Model",
981
- "X-Claxedo-Draft-Id",
982
- "X-Claxedo-Binary"
983
- ],
984
- allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
985
- }));
986
- app.get("/health", (c) => {
987
- if (options.isDraining?.()) {
988
- return c.json(
989
- { ok: false, service: "workspace-relay", draining: true },
990
- 503
991
- );
992
- }
993
- return c.json({ ok: true, service: "workspace-relay" });
1213
+ const app = Object.assign(new Hono(), {
1214
+ disposeAuditSampler: () => disposeAuditSampler(options)
994
1215
  });
995
- app.get("/.well-known/jwks.json", async (c) => {
996
- const sources = options.relayHostPublicKeys ?? [];
997
- if (sources.length === 0) {
998
- return c.json(
999
- {
1000
- error: {
1001
- code: "jwks_no_keys_configured",
1002
- message: "No Relay Host Token public keys are configured for the JWKS endpoint"
1003
- }
1004
- },
1005
- 503
1006
- );
1007
- }
1008
- let keys;
1009
- try {
1010
- keys = await Promise.all(sources.map(async (source) => {
1011
- const jwk = await exportJWK(source.publicKey);
1012
- return {
1013
- ...jwk,
1014
- kid: source.kid,
1015
- alg: "EdDSA",
1016
- use: "sig"
1017
- };
1018
- }));
1019
- } catch (err) {
1020
- return c.json(
1021
- {
1022
- error: {
1023
- code: "jwks_invalid_key",
1024
- message: "A configured RHT public key could not be exported as a JWK",
1025
- detail: err instanceof Error ? err.message : String(err)
1026
- }
1027
- },
1028
- 500
1029
- );
1030
- }
1031
- c.header("cache-control", "public, max-age=300");
1032
- return c.json({ keys });
1033
- });
1034
- app.disposeAuditSampler = () => disposeAuditSampler(options);
1035
- app.get("/metrics", (c) => {
1036
- const authResult = checkMetricsAuth(options, c.req.raw);
1037
- if (!authResult.ok) {
1038
- return c.json(
1039
- errorBody(authResult.code, authResult.message),
1040
- authResult.status
1041
- );
1042
- }
1043
- return c.json(buildMetricsBody(options));
1044
- });
1045
- app.all("/workspaces/:workspaceId/*", async (c) => {
1046
- const trace = createWorkspaceRelayTrace();
1047
- if (options.isDraining?.()) {
1048
- return Response.json(
1049
- errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
1050
- { status: 503, headers: denyCorsHeaders(c.req.raw, originAllowed) }
1051
- );
1052
- }
1053
- const response = await trace.span("relay-total", async () => {
1054
- const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"), trace);
1055
- if (!relay.ok) return relay.response;
1056
- return await forwardWorkspaceRelayRequest(
1057
- new Request(c.req.raw),
1058
- relay.request.target,
1059
- relay.request.relayHostToken,
1060
- relay.request.path,
1061
- options.fetch ?? fetch,
1062
- options.forwardTimeoutMs ?? 3e4,
1063
- trace,
1064
- originAllowed
1065
- );
1066
- });
1067
- return workspaceRelayTimingResponse(response, trace);
1068
- });
1069
- return app;
1070
- }
1071
-
1072
- // src/bun.ts
1073
- var TUNNEL_PENDING_HTTP_CAP = 32;
1074
- var TUNNEL_CHANNEL_CAP = 16;
1075
- var HOST_TUNNEL_REGISTRATION_RECONNECT_CAP = 5;
1076
- var HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS = 6e4;
1077
- var WS_MAX_PAYLOAD_LENGTH_BYTES = 16 * 1024 * 1024;
1078
- var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1079
- var TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT = 3e4;
1080
- var DIRECT_HTTP_TIMEOUT_MS_DEFAULT = 3e4;
1081
- var UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT = 1e4;
1082
- var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
1083
- var WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
1084
- var HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT = 2;
1085
- var TUNNEL_MESSAGE_BUFFER_CAP_BYTES = 4 * 1024 * 1024;
1086
- var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
1087
- var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
1088
- var HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT = 250;
1089
- function createFragmentationStats() {
1090
- return {
1091
- fragmentsBuffered: 0,
1092
- oversizedClosed: 0
1093
- };
1094
- }
1095
- function createSlowConsumerStats() {
1096
- return {
1097
- overflowEvents: 0,
1098
- timerFired: 0,
1099
- droppedRequests: 0
1100
- };
1101
- }
1102
- function readFragmentationStats(stats) {
1103
- return {
1104
- fragmentsBuffered: stats.fragmentsBuffered,
1105
- oversizedClosed: stats.oversizedClosed
1106
- };
1107
- }
1108
- function resetFragmentationStats(stats) {
1109
- stats.fragmentsBuffered = 0;
1110
- stats.oversizedClosed = 0;
1111
- }
1112
- function readSlowConsumerStats(stats) {
1113
- return { ...stats };
1114
- }
1115
- function resetSlowConsumerStats(stats) {
1116
- stats.overflowEvents = 0;
1117
- stats.timerFired = 0;
1118
- stats.droppedRequests = 0;
1119
- }
1120
- function createDirectHttpLimiter(limit) {
1121
- if (!Number.isInteger(limit) || !limit || limit <= 0) return void 0;
1122
- const max = limit;
1123
- let active = 0;
1124
- const queue = [];
1125
- function drain() {
1126
- if (active >= max) return;
1127
- const next = queue.shift();
1128
- if (!next) return;
1129
- active++;
1130
- next();
1131
- }
1132
- return {
1133
- async acquire() {
1134
- if (active < max) {
1135
- active++;
1136
- } else {
1137
- await new Promise((resolve) => queue.push(resolve));
1138
- }
1139
- let released = false;
1140
- return () => {
1141
- if (released) return;
1142
- released = true;
1143
- active = Math.max(0, active - 1);
1144
- drain();
1145
- };
1146
- }
1147
- };
1148
- }
1149
- function jsonError(code, message, status) {
1150
- return new Response(JSON.stringify({ error: { code, message } }), {
1151
- status,
1152
- headers: { "content-type": "application/json" }
1153
- });
1154
- }
1155
- function corsJsonError(request, code, message, status) {
1156
- const headers2 = relayCorsHeaders(request);
1157
- headers2.set("content-type", "application/json");
1158
- return new Response(JSON.stringify({ error: { code, message } }), { status, headers: headers2 });
1159
- }
1160
- function safeCloseCode(input, fallback = 1011) {
1161
- if (!Number.isInteger(input)) return fallback;
1162
- const code = input;
1163
- if (code === 1e3) return code;
1164
- if (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
1165
- if (code >= 3e3 && code <= 4999) return code;
1166
- return fallback;
1167
- }
1168
- function closeWebSocket(ws, code, reason, fallback = 1011) {
1169
- try {
1170
- ws.close(safeCloseCode(code, fallback), reason);
1171
- } catch {
1172
- try {
1173
- ws.close(fallback, reason);
1174
- } catch {
1175
- }
1176
- }
1177
- }
1178
- function upstreamCloseReason(event) {
1179
- if (event.reason.trim()) return event.reason;
1180
- if (event.code === 1e3) return "";
1181
- if (event.code === 1005 || event.code === 1006) return "Upstream WebSocket closed abnormally";
1182
- return `Upstream WebSocket closed unexpectedly with code ${event.code}`;
1183
- }
1184
- function workspaceIdFromPath(pathname) {
1185
- return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
1186
- }
1187
- function hostIdFromTunnelPath(pathname) {
1188
- return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
1189
- }
1190
- function websocketRequest(request) {
1191
- return request.headers.get("upgrade")?.toLowerCase() === "websocket";
1192
- }
1193
- function roundedMs2(input) {
1194
- return Math.round(input * 100) / 100;
1195
- }
1196
- function relayWebSocketTraceEnabled(request) {
1197
- return request.headers.get("x-claxedo-relay-ws-trace") === "1";
1198
- }
1199
- function sendRelayWebSocketTrace(ws) {
1200
- const trace = ws.data.trace;
1201
- if (!trace || trace.emitted) return;
1202
- trace.emitted = true;
1203
- ws.send(JSON.stringify({
1204
- type: "relay.trace",
1205
- wsUpstreamOpenMs: trace.upstreamOpenMs === void 0 ? void 0 : roundedMs2(trace.upstreamOpenMs),
1206
- queuedFrames: trace.queuedFrames,
1207
- maxQueuedDelayMs: roundedMs2(trace.maxQueuedDelayMs)
1208
- }));
1209
- }
1210
- function relayWebSocketPayload(input) {
1211
- if (typeof input === "string") return input;
1212
- if (input instanceof ArrayBuffer) return input;
1213
- if (ArrayBuffer.isView(input)) {
1214
- const copy = new Uint8Array(input.byteLength);
1215
- copy.set(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
1216
- return copy;
1217
- }
1218
- }
1219
- function headersRecord(headers2) {
1220
- const result = {};
1221
- headers2.forEach((value, key) => {
1222
- result[key] = value;
1223
- });
1224
- return result;
1225
- }
1226
- function headers(input) {
1227
- const result = new Headers();
1228
- for (const [key, value] of Object.entries(input)) {
1229
- result.set(key, value);
1230
- }
1231
- return result;
1232
- }
1233
- function isEventStream(input) {
1234
- return Object.entries(input).some(
1235
- ([key, value]) => key.toLowerCase() === "content-type" && value.toLowerCase().includes("text/event-stream")
1236
- );
1237
- }
1238
- function allowedCorsOrigin(origin) {
1239
- if (!origin) return;
1240
- if (origin.startsWith("http://localhost:")) return origin;
1241
- if (origin.startsWith("http://127.0.0.1:")) return origin;
1242
- if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
1243
- }
1244
- function requireAllowedOrigin(request) {
1245
- const origin = request.headers.get("origin");
1246
- if (allowedCorsOrigin(origin)) return null;
1247
- return jsonError(
1248
- "origin_not_allowed",
1249
- "Origin is not in the allowlist",
1250
- 403
1251
- );
1252
- }
1253
- function relayCorsHeaders(request, input = new Headers()) {
1254
- const result = new Headers(input);
1255
- result.delete("access-control-allow-origin");
1256
- result.delete("access-control-allow-credentials");
1257
- result.delete("access-control-allow-headers");
1258
- result.delete("access-control-allow-methods");
1259
- result.delete("access-control-expose-headers");
1260
- result.delete("access-control-max-age");
1261
- const origin = allowedCorsOrigin(request.headers.get("origin"));
1262
- if (origin) {
1263
- result.set("access-control-allow-origin", origin);
1264
- 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");
1265
- result.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
1266
- }
1267
- return result;
1268
- }
1269
- function encoded(input) {
1270
- return Buffer.from(input).toString("base64");
1271
- }
1272
- function decoded(input) {
1273
- return new Uint8Array(Buffer.from(input, "base64"));
1274
- }
1275
- function encodedFrame(message) {
1276
- if (typeof message === "string") {
1277
- return {
1278
- binary: false,
1279
- data_base64: Buffer.from(message).toString("base64")
1280
- };
1281
- }
1282
- return {
1283
- binary: true,
1284
- data_base64: Buffer.from(message).toString("base64")
1285
- };
1286
- }
1287
- function decodedFrame(frame) {
1288
- const body = Buffer.from(frame.data_base64, "base64");
1289
- return frame.binary ? new Uint8Array(body) : body.toString("utf8");
1290
- }
1291
- function tunnelMessage(ws, input, stats) {
1292
- if (typeof input !== "string") return;
1293
- const combined = ws.data.messageBuffer.length > 0 ? ws.data.messageBuffer + input : input;
1294
- let parsed;
1295
- try {
1296
- parsed = JSON.parse(combined);
1297
- } catch {
1298
- if (combined.length > TUNNEL_MESSAGE_BUFFER_CAP_BYTES) {
1299
- stats.oversizedClosed += 1;
1300
- ws.data.messageBuffer = "";
1301
- ws.close(1009, "Tunnel message buffer exceeded 4 MB");
1302
- return;
1303
- }
1304
- stats.fragmentsBuffered += 1;
1305
- ws.data.messageBuffer = combined;
1306
- return;
1307
- }
1308
- ws.data.messageBuffer = "";
1309
- const validated = validateTunnelMessage(parsed);
1310
- if (validated.ok) return validated.message;
1311
- if (validated.reason === "protocol_mismatch") {
1312
- ws.close(1002, `Tunnel protocol mismatch: expected ${validated.expected_protocol}`);
1313
- }
1314
- return;
1315
- }
1316
- function sendTunnelPing(ws) {
1317
- if (ws.readyState !== WebSocket.OPEN) return;
1318
- ws.data.missedPongs += 1;
1319
- ws.send(JSON.stringify(makeTunnelPing()));
1320
- }
1321
- function clearPendingTimers(entry) {
1322
- clearTimeout(entry.timeout);
1323
- if (entry.slowConsumerTimeout) {
1324
- clearTimeout(entry.slowConsumerTimeout);
1325
- entry.slowConsumerTimeout = void 0;
1326
- }
1327
- }
1328
- function failPendingHttpResponse(input) {
1329
- clearPendingTimers(input.entry);
1330
- input.entry.pendingChunks.length = 0;
1331
- input.entry.bytesQueued = 0;
1332
- try {
1333
- input.entry.controller.error(input.error);
1334
- } catch {
1335
- }
1336
- if (!input.entry.responseStarted) {
1337
- if (input.response) {
1338
- input.entry.resolve(input.response);
1339
- return;
1340
- }
1341
- input.entry.reject(input.error);
1342
- }
1343
- }
1344
- function cleanupHostTunnelSocket(input) {
1345
- if (input.ws.data.heartbeat) {
1346
- clearInterval(input.ws.data.heartbeat);
1347
- input.ws.data.heartbeat = void 0;
1348
- }
1349
- for (const pending of input.ws.data.pending.values()) {
1350
- failPendingHttpResponse({
1351
- entry: pending,
1352
- 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),
1353
- error: new Error("User-hosted tunnel disconnected")
1354
- });
1355
- }
1356
- input.ws.data.pending.clear();
1357
- for (const channel of input.ws.data.channels.values()) {
1358
- if (input.closeChannels) closeWebSocket(channel, 1011, "User-hosted tunnel disconnected");
1359
- }
1360
- input.ws.data.channels.clear();
1361
- if (!input.disconnectDirectory) return;
1362
- if (input.hostTunnels.get(input.ws.data.hostId) === input.ws) {
1363
- input.hostTunnels.delete(input.ws.data.hostId);
1364
- input.options.directory?.disconnectHost(input.ws.data.hostId);
1365
- scheduleHostTunnelStateChange(input.hostTunnelStateDebounce, input.options, {
1366
- hostId: input.ws.data.hostId,
1367
- workspaceId: input.ws.data.workspaceIds[0],
1368
- path: `/host-tunnels/${input.ws.data.hostId}`,
1369
- state: "disconnected",
1370
- debounceMs: input.bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
1371
- });
1372
- }
1373
- }
1374
- async function audit2(options, input) {
1375
- await options.audit?.({
1376
- action: input.action,
1377
- result: "allow",
1378
- hostId: input.hostId,
1379
- workspaceId: input.workspaceId,
1380
- method: "WEBSOCKET",
1381
- path: input.path
1382
- });
1383
- }
1384
- function scheduleHostTunnelStateChange(state, options, input) {
1385
- const existing = state.get(input.hostId);
1386
- const entry = existing ?? {
1387
- intendedState: "disconnected",
1388
- lastWritten: "disconnected",
1389
- flushTimer: void 0,
1390
- pending: { workspaceId: input.workspaceId, path: input.path }
1391
- };
1392
- entry.intendedState = input.state;
1393
- entry.pending = { workspaceId: input.workspaceId, path: input.path };
1394
- if (entry.flushTimer) clearTimeout(entry.flushTimer);
1395
- entry.flushTimer = setTimeout(() => {
1396
- entry.flushTimer = void 0;
1397
- if (entry.intendedState === entry.lastWritten) {
1398
- if (entry.intendedState === "disconnected") {
1399
- state.delete(input.hostId);
1400
- }
1401
- return;
1402
- }
1403
- entry.lastWritten = entry.intendedState;
1404
- void audit2(options, {
1405
- action: entry.intendedState === "connected" ? "host_tunnel.connected" : "host_tunnel.disconnected",
1406
- hostId: input.hostId,
1407
- workspaceId: entry.pending.workspaceId,
1408
- path: entry.pending.path
1409
- });
1410
- }, input.debounceMs);
1411
- state.set(input.hostId, entry);
1412
- }
1413
- function drainPendingChunks(entry) {
1414
- while (entry.pendingChunks.length > 0) {
1415
- const desired = entry.controller.desiredSize;
1416
- if (desired !== null && desired <= 0) break;
1417
- const next = entry.pendingChunks.shift();
1418
- entry.bytesQueued -= next.byteLength;
1419
- try {
1420
- entry.controller.enqueue(next);
1421
- } catch {
1422
- entry.pendingChunks.length = 0;
1423
- entry.bytesQueued = 0;
1424
- break;
1425
- }
1426
- }
1427
- if (entry.pendingChunks.length === 0 && entry.slowConsumerTimeout) {
1428
- clearTimeout(entry.slowConsumerTimeout);
1429
- entry.slowConsumerTimeout = void 0;
1430
- }
1431
- }
1432
- function enqueueChunkWithBackpressure(input) {
1433
- const { entry, chunk } = input;
1434
- const desired = entry.controller.desiredSize;
1435
- const overflowing = desired !== null && desired <= 0;
1436
- if (overflowing || entry.pendingChunks.length > 0) {
1437
- entry.pendingChunks.push(chunk);
1438
- entry.bytesQueued += chunk.byteLength;
1439
- if (!entry.slowConsumerTimeout) {
1440
- input.slowConsumerStats.overflowEvents += 1;
1441
- entry.slowConsumerTimeout = setTimeout(() => {
1442
- input.slowConsumerStats.timerFired += 1;
1443
- const error = new Error("slow_consumer_timeout: downstream consumer did not drain in time");
1444
- try {
1445
- entry.controller.error(error);
1446
- } catch {
1447
- }
1448
- clearTimeout(entry.timeout);
1449
- entry.pendingChunks.length = 0;
1450
- entry.bytesQueued = 0;
1451
- if (!entry.responseStarted) {
1452
- entry.resolve(jsonError("slow_consumer_timeout", "Downstream consumer did not drain in time", 503));
1453
- }
1454
- input.ws.data.pending.delete(input.requestId);
1455
- input.slowConsumerStats.droppedRequests += 1;
1456
- }, input.slowConsumerTimeoutMs);
1457
- }
1458
- return;
1459
- }
1460
- try {
1461
- entry.controller.enqueue(chunk);
1462
- } catch {
1463
- }
1464
- }
1465
- var __slowConsumerInternalsForTest = {
1466
- enqueueChunkWithBackpressure,
1467
- createSlowConsumerStats
1468
- };
1469
- async function tunnelHttpRequest(input) {
1470
- if (input.ws.readyState !== WebSocket.OPEN) {
1471
- return new Response("User-hosted workspace is offline", { status: 503 });
1472
- }
1473
- if (input.ws.data.pending.size >= TUNNEL_PENDING_HTTP_CAP) {
1474
- return jsonError(
1475
- "too_many_in_flight",
1476
- "User-hosted tunnel has too many in-flight HTTP requests",
1477
- 429
1478
- );
1479
- }
1480
- const requestId = crypto.randomUUID();
1481
- let controller;
1482
- const stream = new ReadableStream({
1483
- start(next) {
1484
- controller = next;
1485
- },
1486
- pull() {
1487
- const entry = input.ws.data.pending.get(requestId);
1488
- if (!entry) return;
1489
- drainPendingChunks(entry);
1490
- },
1491
- cancel() {
1492
- const entry = input.ws.data.pending.get(requestId);
1493
- if (!entry) return;
1494
- input.ws.data.pending.delete(requestId);
1495
- clearPendingTimers(entry);
1496
- entry.pendingChunks.length = 0;
1497
- entry.bytesQueued = 0;
1498
- if (input.ws.readyState === WebSocket.OPEN) {
1499
- input.ws.send(JSON.stringify({
1500
- type: "http.response.flow",
1501
- protocol: TUNNEL_PROTOCOL_VERSION,
1502
- request_id: requestId,
1503
- paused: false,
1504
- reason: "closed"
1505
- }));
1506
- }
1507
- }
1508
- }, new ByteLengthQueuingStrategy({ highWaterMark: input.slowConsumerHighWaterMarkBytes }));
1509
- const pending = new Promise((resolve, reject) => {
1510
- const timeout = setTimeout(() => {
1511
- const entry = input.ws.data.pending.get(requestId);
1512
- if (!entry) return;
1513
- input.ws.data.pending.delete(requestId);
1514
- if (entry.responseStarted) {
1515
- clearPendingTimers(entry);
1516
- entry.pendingChunks.length = 0;
1517
- entry.bytesQueued = 0;
1518
- try {
1519
- entry.controller.close();
1520
- } catch {
1521
- }
1522
- return;
1523
- }
1524
- const error = new Error("User-hosted tunnel response timed out");
1525
- failPendingHttpResponse({
1526
- entry,
1527
- response: corsJsonError(input.request, "user_hosted_tunnel_timeout", "User-hosted tunnel response timed out", 504),
1528
- error
1529
- });
1530
- }, input.responseTimeoutMs);
1531
- input.ws.data.pending.set(requestId, {
1532
- controller,
1533
- stream,
1534
- resolve,
1535
- reject,
1536
- timeout,
1537
- pendingChunks: [],
1538
- bytesQueued: 0,
1539
- responseStarted: false
1540
- });
1541
- });
1542
- const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : await readBoundedBody(input.request, input.requestBodyMaxBytes);
1543
- if (body && "response" in body) {
1544
- const entry = input.ws.data.pending.get(requestId);
1545
- if (entry) clearPendingTimers(entry);
1546
- input.ws.data.pending.delete(requestId);
1547
- return body.response;
1548
- }
1549
- input.ws.send(JSON.stringify({
1550
- type: "http.request",
1551
- protocol: TUNNEL_PROTOCOL_VERSION,
1552
- request_id: requestId,
1553
- workspace_id: input.workspaceId,
1554
- method: input.request.method,
1555
- path: input.path,
1556
- headers: headersRecord(workspaceRelayForwardHeaders(
1557
- input.request.headers,
1558
- input.relayHostToken,
1559
- input.workspaceId,
1560
- // T26: user-hosted tunnel — strip Cookie to avoid leaking browser
1561
- // cookies to the host process running on the user's laptop.
1562
- { userHosted: true }
1563
- )),
1564
- ...body?.bodyBase64 ? { body_base64: body.bodyBase64 } : {},
1565
- end: true
1566
- }));
1567
- return await pending;
1568
- }
1569
- async function readBoundedBody(request, maxBytes) {
1570
- if (!request.body) return void 0;
1571
- const reader = request.body.getReader();
1572
- const chunks = [];
1573
- let total = 0;
1574
- for (; ; ) {
1575
- const next = await reader.read();
1576
- if (next.done) break;
1577
- total += next.value.byteLength;
1578
- if (total > maxBytes) {
1579
- try {
1580
- await reader.cancel();
1581
- } catch {
1582
- }
1583
- return {
1584
- response: corsJsonError(request, "request_body_too_large", "Tunnel request body exceeds the relay limit", 413)
1585
- };
1586
- }
1587
- chunks.push(next.value);
1588
- }
1589
- const body = new Uint8Array(total);
1590
- chunks.reduce((offset, chunk) => {
1591
- body.set(chunk, offset);
1592
- return offset + chunk.byteLength;
1593
- }, 0);
1594
- return { bodyBase64: encoded(body.buffer) };
1595
- }
1596
- async function directHttpRequest(input) {
1597
- const controller = new AbortController();
1598
- const timer = setTimeout(() => controller.abort(), input.timeoutMs);
1599
- const span = (name, run) => input.trace ? input.trace.span(name, run) : run();
1600
- try {
1601
- const init = workspaceRelayForwardRequestInit(
1602
- input.request,
1603
- input.relayHostToken,
1604
- input.workspaceId,
1605
- {
1606
- // T26: cloud-vm direct path. Cookies pass through (default) — workspace
1607
- // dashboards in the VM may legitimately need session cookies.
1608
- userHosted: false,
1609
- upstreamHeaders: input.upstreamHeaders,
1610
- signal: controller.signal
1611
- }
1612
- );
1613
- if (input.request.method !== "GET" && input.request.method !== "HEAD" && input.request.body) {
1614
- init.body = await input.request.arrayBuffer();
1615
- }
1616
- const limiter = input.limiter;
1617
- const release = limiter ? await span("direct-http-queue", () => limiter.acquire()) : void 0;
1618
- try {
1619
- const upstream = await span("upstream-fetch", async () => await fetch(input.targetUrl, init));
1620
- return new Response(upstream.body, {
1621
- status: upstream.status,
1622
- statusText: upstream.statusText,
1623
- headers: relayCorsHeaders(input.request, upstream.headers)
1624
- });
1625
- } finally {
1626
- release?.();
1627
- }
1628
- } catch (err) {
1629
- const aborted = err instanceof Error && err.name === "AbortError";
1630
- return corsJsonError(
1631
- input.request,
1632
- aborted ? "upstream_timeout" : "upstream_unavailable",
1633
- aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable",
1634
- aborted ? 504 : 503
1635
- );
1636
- } finally {
1637
- clearTimeout(timer);
1638
- }
1639
- }
1640
- function hostTunnel(hostTunnels, hostId) {
1641
- const tunnel = hostTunnels.get(hostId);
1642
- if (tunnel?.readyState === WebSocket.OPEN) return tunnel;
1643
- return void 0;
1644
- }
1645
- async function authorizeHostTunnel(options, bunOptions, request, input) {
1646
- if (bunOptions.authorizeHostTunnel) return await bunOptions.authorizeHostTunnel(request, input);
1647
- const token = bearerToken(request.headers.get("authorization"));
1648
- if (!token) return false;
1649
- try {
1650
- await verifyHostTunnelToken(token, options.runtimeAccessKey, input);
1651
- return true;
1652
- } catch (err) {
1653
- if (err instanceof WorkspaceRelayAuthError) return false;
1654
- throw err;
1655
- }
1656
- }
1657
- function createWorkspaceRelayBun(options, bunOptions = {}) {
1658
- const hostTunnels = /* @__PURE__ */ new Map();
1659
- const relayClients = /* @__PURE__ */ new Set();
1660
- const hostTunnelRegistrations = /* @__PURE__ */ new Map();
1661
- const directHttpLimiter = createDirectHttpLimiter(bunOptions.directHttpConcurrency);
1662
- const hostTunnelStateDebounce = /* @__PURE__ */ new Map();
1663
- const fragmentationStats = createFragmentationStats();
1664
- const slowConsumerStats = createSlowConsumerStats();
1665
- const telemetry = {
1666
- getFragmentationStats: () => readFragmentationStats(fragmentationStats),
1667
- resetFragmentationStats: () => resetFragmentationStats(fragmentationStats),
1668
- getSlowConsumerStats: () => readSlowConsumerStats(slowConsumerStats),
1669
- resetSlowConsumerStats: () => resetSlowConsumerStats(slowConsumerStats)
1670
- };
1671
- let draining = false;
1672
- const drainController = {
1673
- isDraining: () => draining,
1674
- setDraining: (value) => {
1675
- draining = value;
1676
- if (!value) return;
1677
- for (const tunnel of hostTunnels.values()) {
1678
- closeWebSocket(tunnel, 1012, "Workspace relay is draining", 1012);
1679
- }
1680
- for (const client of relayClients) {
1681
- closeWebSocket(client, 1012, "Workspace relay is draining", 1012);
1682
- }
1683
- },
1684
- pendingCount: () => {
1685
- let total = 0;
1686
- for (const tunnel of hostTunnels.values()) {
1687
- total += tunnel.data.pending.size;
1688
- }
1689
- return total;
1690
- },
1691
- waitForDrain: async (timeoutMs) => {
1692
- const deadline = Date.now() + Math.max(0, timeoutMs);
1693
- const pollMs = 50;
1694
- while (true) {
1695
- const remaining = drainController.pendingCount();
1696
- if (remaining === 0) return { drained: true, remaining: 0 };
1697
- const now = Date.now();
1698
- if (now >= deadline) return { drained: false, remaining };
1699
- const sleep = Math.min(pollMs, Math.max(1, deadline - now));
1700
- await new Promise((resolve) => setTimeout(resolve, sleep));
1701
- }
1702
- }
1703
- };
1704
- const mergedMetricsSources = {
1705
- fragmentation: options.metricsSources?.fragmentation ?? telemetry.getFragmentationStats,
1706
- slowConsumer: options.metricsSources?.slowConsumer ?? telemetry.getSlowConsumerStats,
1707
- drainPending: options.metricsSources?.drainPending ?? drainController.pendingCount
1708
- };
1709
- const requestRemoteAddress = /* @__PURE__ */ new WeakMap();
1710
- const metricsRemoteAddressResolver = options.metricsRemoteAddress ?? ((request) => requestRemoteAddress.get(request));
1711
- const app = createWorkspaceRelay({
1712
- ...options,
1713
- isDraining: drainController.isDraining,
1714
- metricsSources: mergedMetricsSources,
1715
- metricsRemoteAddress: metricsRemoteAddressResolver
1716
- });
1717
- function getRegistrationTracker(hostId) {
1718
- let tracker = hostTunnelRegistrations.get(hostId);
1719
- if (!tracker) {
1720
- tracker = { recent: [] };
1721
- hostTunnelRegistrations.set(hostId, tracker);
1722
- }
1723
- return tracker;
1724
- }
1725
- function pruneReconnects(tracker, now) {
1726
- const cutoff = now - HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS;
1727
- while (tracker.recent.length && tracker.recent[0] < cutoff) {
1728
- tracker.recent.shift();
1216
+ const originAllowed = originMatcherFor(options);
1217
+ app.use("*", async (c, next) => {
1218
+ await next();
1219
+ const origin = c.res.headers.get("access-control-allow-origin");
1220
+ if (origin && origin !== "*" && !c.res.headers.has("timing-allow-origin")) {
1221
+ c.res.headers.set("timing-allow-origin", origin);
1729
1222
  }
1730
- }
1731
- return {
1732
- async fetch(request, server) {
1733
- const url = new URL(request.url);
1734
- const workspaceId = workspaceIdFromPath(url.pathname);
1735
- const hostId = hostIdFromTunnelPath(url.pathname);
1736
- try {
1737
- const ip = server.requestIP(request)?.address;
1738
- if (ip) requestRemoteAddress.set(request, ip);
1739
- } catch {
1740
- }
1741
- if (url.pathname === "/health") {
1742
- return Response.json(
1743
- draining ? { ok: false, service: "workspace-relay", draining: true } : { ok: true, service: "workspace-relay" },
1744
- { status: draining ? 503 : 200 }
1745
- );
1746
- }
1747
- if (draining && websocketRequest(request) && hostId) {
1748
- return jsonError(
1749
- "relay_draining",
1750
- "Workspace relay is shutting down; new tunnel registrations are not accepted",
1751
- 503
1752
- );
1753
- }
1754
- if (draining && workspaceId) {
1755
- const headers2 = relayCorsHeaders(request);
1756
- const body = JSON.stringify({
1757
- error: {
1758
- code: "relay_draining",
1759
- message: "Workspace relay is shutting down; try another instance"
1760
- }
1761
- });
1762
- headers2.set("content-type", "application/json");
1763
- return new Response(body, { status: 503, headers: headers2 });
1764
- }
1765
- if (websocketRequest(request) && hostId) {
1766
- const workspaceIds = url.searchParams.getAll("workspaceId");
1767
- if (!workspaceIds.length || !options.directory) {
1768
- return new Response("Host tunnel registration is unavailable", { status: 503 });
1769
- }
1770
- if (!await authorizeHostTunnel(options, bunOptions, request, { hostId, workspaceIds })) {
1771
- return new Response("Host tunnel registration denied", { status: 403 });
1772
- }
1773
- const tracker = getRegistrationTracker(hostId);
1774
- const now = Date.now();
1775
- pruneReconnects(tracker, now);
1776
- if (tracker.recent.length >= HOST_TUNNEL_REGISTRATION_RECONNECT_CAP) {
1777
- return jsonError(
1778
- "too_many_host_tunnel_reconnects",
1779
- "Too many host-tunnel reconnects for this host within the last 60 seconds",
1780
- 429
1781
- );
1782
- }
1783
- if (server.upgrade(request, {
1784
- data: {
1785
- kind: "host-tunnel",
1786
- hostId,
1787
- workspaceIds,
1788
- pending: /* @__PURE__ */ new Map(),
1789
- channels: /* @__PURE__ */ new Map(),
1790
- missedPongs: 0,
1791
- messageBuffer: ""
1792
- }
1793
- })) {
1794
- tracker.recent.push(now);
1795
- return;
1796
- }
1797
- return new Response("WebSocket upgrade failed", { status: 400 });
1798
- }
1799
- if (!workspaceId) return app.request(request);
1800
- if (request.method === "OPTIONS") {
1801
- return new Response(null, {
1802
- status: 204,
1803
- headers: relayCorsHeaders(request)
1804
- });
1805
- }
1806
- if (!websocketRequest(request)) {
1807
- const trace = createWorkspaceRelayTrace();
1808
- const response = await trace.span("relay-total", async () => {
1809
- const relay2 = await authorizeWorkspaceRelayRequest(options, request, workspaceId, trace);
1810
- if (!relay2.ok) return relay2.response;
1811
- if (relay2.request.target.access === "user-hosted") {
1812
- const tunnel = hostTunnel(hostTunnels, relay2.request.target.hostId);
1813
- return tunnel ? await trace.span("tunnel-http", async () => await tunnelHttpRequest({
1814
- ws: tunnel,
1815
- request,
1816
- workspaceId: relay2.request.target.workspaceId,
1817
- path: `${relay2.request.path}${url.search}`,
1818
- relayHostToken: relay2.request.relayHostToken,
1819
- slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
1820
- slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
1821
- slowConsumerStats,
1822
- requestBodyMaxBytes: bunOptions.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT,
1823
- responseTimeoutMs: bunOptions.tunnelHttpResponseTimeoutMs ?? TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT
1824
- })) : new Response("User-hosted workspace is offline", { status: 503 });
1825
- }
1826
- return await directHttpRequest({
1827
- request,
1828
- targetUrl: workspaceRelayTargetUrl(
1829
- relay2.request.target,
1830
- relay2.request.path,
1831
- url.search
1832
- ).toString(),
1833
- relayHostToken: relay2.request.relayHostToken,
1834
- workspaceId: relay2.request.target.workspaceId,
1835
- upstreamHeaders: relay2.request.target.upstreamHeaders,
1836
- timeoutMs: bunOptions.directHttpTimeoutMs ?? DIRECT_HTTP_TIMEOUT_MS_DEFAULT,
1837
- limiter: directHttpLimiter,
1838
- trace
1839
- });
1840
- });
1841
- return workspaceRelayTimingResponse(response, trace);
1842
- }
1843
- const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
1844
- if (!relay.ok) return relay.response;
1845
- const originDenied = requireAllowedOrigin(request);
1846
- if (originDenied) return originDenied;
1847
- if (relay.request.target.access === "user-hosted") {
1848
- const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId);
1849
- if (!tunnel) {
1850
- return new Response("User-hosted workspace is offline", { status: 503 });
1851
- }
1852
- if (tunnel.data.channels.size >= TUNNEL_CHANNEL_CAP) {
1853
- return jsonError(
1854
- "too_many_channels",
1855
- "User-hosted tunnel has too many active WebSocket channels",
1856
- 503
1857
- );
1858
- }
1859
- if (server.upgrade(request, {
1860
- data: {
1861
- kind: "user-hosted-client",
1862
- hostId: relay.request.target.hostId,
1863
- workspaceId: relay.request.target.workspaceId,
1864
- channelId: crypto.randomUUID(),
1865
- path: `${relay.request.path}${url.search}`,
1866
- relayHostToken: relay.request.relayHostToken
1867
- }
1868
- })) {
1869
- return;
1870
- }
1871
- return new Response("WebSocket upgrade failed", { status: 400 });
1872
- }
1873
- if (server.upgrade(request, {
1874
- data: {
1875
- kind: "client",
1876
- upstreamUrl: workspaceRelayTargetUrl(
1877
- relay.request.target,
1878
- relay.request.path,
1879
- url.search
1880
- ).toString().replace(/^http/, "ws"),
1881
- headers: headersRecord(workspaceRelayForwardHeaders(
1882
- new Headers(),
1883
- relay.request.relayHostToken,
1884
- relay.request.target.workspaceId,
1885
- // T26: cloud-vm WS upgrade. Cookies pass through (default).
1886
- { userHosted: false, upstreamHeaders: relay.request.target.upstreamHeaders }
1887
- )),
1888
- queue: [],
1889
- ...relayWebSocketTraceEnabled(request) ? {
1890
- trace: {
1891
- acceptedAt: performance.now(),
1892
- queuedFrames: 0,
1893
- maxQueuedDelayMs: 0,
1894
- emitted: false
1895
- }
1896
- } : {}
1897
- }
1898
- })) {
1899
- return;
1900
- }
1901
- return new Response("WebSocket upgrade failed", { status: 400 });
1902
- },
1903
- websocket: {
1904
- maxPayloadLength: WS_MAX_PAYLOAD_LENGTH_BYTES,
1905
- message(ws, message) {
1906
- if (ws.data.kind === "host-tunnel") {
1907
- const parsed = tunnelMessage(
1908
- ws,
1909
- message,
1910
- fragmentationStats
1911
- );
1912
- if (parsed?.type === "ping") {
1913
- options.directory?.recordPong(ws.data.hostId);
1914
- ws.send(JSON.stringify(makeTunnelPong(parsed)));
1915
- }
1916
- if (parsed?.type === "pong") {
1917
- ws.data.missedPongs = 0;
1918
- options.directory?.recordPong(ws.data.hostId);
1919
- }
1920
- if (parsed?.type === "host.registration.update") {
1921
- const update = parsed;
1922
- const workspaceIds = [...new Set(update.workspace_ids)];
1923
- const hostSocket = ws;
1924
- const hostId = hostSocket.data.hostId;
1925
- const authorizationRequest = new Request(
1926
- `http://relay.local/host-tunnels/${encodeURIComponent(hostId)}`,
1927
- { headers: { authorization: `Bearer ${update.token}` } }
1928
- );
1929
- void authorizeHostTunnel(options, bunOptions, authorizationRequest, {
1930
- hostId,
1931
- workspaceIds
1932
- }).then((authorized) => {
1933
- if (!authorized) {
1934
- closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008);
1935
- return;
1936
- }
1937
- if (hostTunnels.get(hostId) !== hostSocket || hostSocket.readyState !== WebSocket.OPEN) return;
1938
- hostSocket.data.workspaceIds = workspaceIds;
1939
- options.directory?.registerHostTunnel({ hostId, workspaceIds });
1940
- }).catch(() => closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008));
1941
- return;
1942
- }
1943
- if (parsed?.type === "error") {
1944
- if (parsed.request_id) {
1945
- const pending = ws.data.pending.get(parsed.request_id);
1946
- if (pending) {
1947
- ws.data.pending.delete(parsed.request_id);
1948
- failPendingHttpResponse({
1949
- entry: pending,
1950
- response: jsonError(parsed.code, parsed.message, 502),
1951
- error: new Error(parsed.message)
1952
- });
1953
- }
1954
- }
1955
- }
1956
- if (parsed?.type === "http.response.start") {
1957
- const pending = ws.data.pending.get(parsed.request_id);
1958
- if (pending) {
1959
- const response = parsed;
1960
- pending.responseStarted = true;
1961
- if (isEventStream(response.headers)) clearTimeout(pending.timeout);
1962
- pending.resolve(new Response(pending.stream, {
1963
- status: response.status,
1964
- headers: headers(response.headers)
1965
- }));
1966
- }
1967
- }
1968
- if (parsed?.type === "http.response.chunk") {
1969
- const entry = ws.data.pending.get(parsed.request_id);
1970
- if (entry) {
1971
- enqueueChunkWithBackpressure({
1972
- ws,
1973
- requestId: parsed.request_id,
1974
- entry,
1975
- chunk: decoded(parsed.body_base64),
1976
- slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
1977
- slowConsumerStats
1978
- });
1979
- }
1980
- }
1981
- if (parsed?.type === "http.response.end") {
1982
- const pending = ws.data.pending.get(parsed.request_id);
1983
- if (pending) {
1984
- clearTimeout(pending.timeout);
1985
- if (pending.slowConsumerTimeout) {
1986
- clearTimeout(pending.slowConsumerTimeout);
1987
- pending.slowConsumerTimeout = void 0;
1988
- }
1989
- drainPendingChunks(pending);
1990
- if (pending.pendingChunks.length > 0) {
1991
- for (const chunk of pending.pendingChunks) {
1992
- try {
1993
- pending.controller.enqueue(chunk);
1994
- } catch {
1995
- break;
1996
- }
1997
- }
1998
- pending.pendingChunks.length = 0;
1999
- pending.bytesQueued = 0;
2000
- }
2001
- try {
2002
- pending.controller.close();
2003
- } catch {
2004
- }
2005
- ws.data.pending.delete(parsed.request_id);
2006
- }
2007
- }
2008
- if (parsed?.type === "ws.frame") {
2009
- const frame = parsed;
2010
- const channel = ws.data.channels.get(frame.channel_id);
2011
- if (!channel || channel.readyState !== WebSocket.OPEN) {
2012
- ws.data.channels.delete(frame.channel_id);
2013
- return;
2014
- }
2015
- if (channel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
2016
- ws.data.channels.delete(frame.channel_id);
2017
- closeWebSocket(channel, 1011, "Client WebSocket backpressure limit exceeded");
2018
- return;
2019
- }
2020
- channel.send(decodedFrame(frame));
2021
- }
2022
- if (parsed?.type === "ws.close") {
2023
- const close = parsed;
2024
- const channel = ws.data.channels.get(close.channel_id);
2025
- if (channel) {
2026
- ws.data.channels.delete(close.channel_id);
2027
- closeWebSocket(channel, close.code, close.reason);
2028
- }
2029
- }
2030
- return;
2031
- }
2032
- if (ws.data.kind === "user-hosted-client") {
2033
- const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
2034
- if (!tunnel) {
2035
- ws.close(1011, "User-hosted tunnel disconnected");
2036
- return;
2037
- }
2038
- if (tunnel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
2039
- closeWebSocket(ws, 1011, "Host tunnel backpressure limit exceeded");
2040
- return;
2041
- }
2042
- tunnel.send(JSON.stringify({
2043
- type: "ws.frame",
2044
- protocol: TUNNEL_PROTOCOL_VERSION,
2045
- channel_id: ws.data.channelId,
2046
- ...encodedFrame(message)
2047
- }));
2048
- return;
2049
- }
2050
- if (ws.data.upstream?.readyState === WebSocket.OPEN) {
2051
- ws.data.upstream.send(message);
2052
- return;
2053
- }
2054
- if (ws.data.queue.length >= (bunOptions.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT)) {
2055
- closeWebSocket(ws, 1011, "Upstream WebSocket queue limit exceeded");
2056
- return;
2057
- }
2058
- ws.data.queue.push({ payload: message, queuedAt: performance.now() });
2059
- },
2060
- open(ws) {
2061
- if (ws.data.kind === "host-tunnel") {
2062
- const previous = hostTunnels.get(ws.data.hostId);
2063
- if (previous && previous !== ws) {
2064
- cleanupHostTunnelSocket({
2065
- ws: previous,
2066
- hostTunnels,
2067
- hostTunnelStateDebounce,
2068
- options,
2069
- bunOptions,
2070
- disconnectDirectory: false,
2071
- closeChannels: true
2072
- });
2073
- closeWebSocket(previous, 1012, "Host tunnel replaced by a newer connection", 1012);
2074
- }
2075
- hostTunnels.set(ws.data.hostId, ws);
2076
- options.directory?.registerHostTunnel({
2077
- hostId: ws.data.hostId,
2078
- workspaceIds: ws.data.workspaceIds
2079
- });
2080
- ws.data.heartbeat = setInterval(() => {
2081
- const hostWs = ws;
2082
- if (hostWs.data.missedPongs > (bunOptions.hostTunnelMaxMissedPongs ?? HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT)) {
2083
- closeWebSocket(ws, 1001, "Host tunnel heartbeat timed out", 1001);
2084
- return;
2085
- }
2086
- sendTunnelPing(hostWs);
2087
- }, bunOptions.hostTunnelPingIntervalMs ?? 15e3);
2088
- scheduleHostTunnelStateChange(hostTunnelStateDebounce, options, {
2089
- hostId: ws.data.hostId,
2090
- workspaceId: ws.data.workspaceIds[0],
2091
- path: `/host-tunnels/${ws.data.hostId}`,
2092
- state: "connected",
2093
- debounceMs: bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
2094
- });
2095
- return;
2096
- }
2097
- if (ws.data.kind === "user-hosted-client") {
2098
- const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
2099
- if (!tunnel) {
2100
- ws.close(1011, "User-hosted tunnel disconnected");
2101
- return;
2102
- }
2103
- tunnel.data.channels.set(ws.data.channelId, ws);
2104
- if (tunnel.readyState !== WebSocket.OPEN) {
2105
- closeWebSocket(ws, 1011, "User-hosted tunnel disconnected");
2106
- return;
2107
- }
2108
- tunnel.send(JSON.stringify({
2109
- type: "ws.open",
2110
- protocol: TUNNEL_PROTOCOL_VERSION,
2111
- channel_id: ws.data.channelId,
2112
- workspace_id: ws.data.workspaceId,
2113
- path: ws.data.path,
2114
- headers: headersRecord(workspaceRelayForwardHeaders(
2115
- new Headers(),
2116
- ws.data.relayHostToken,
2117
- ws.data.workspaceId,
2118
- // T26: user-hosted tunnel WS upgrade — strip Cookie. Headers are
2119
- // empty here today, but pass the flag for consistency in case
2120
- // upstream client headers are forwarded in the future.
2121
- { userHosted: true }
2122
- ))
2123
- }));
2124
- return;
2125
- }
2126
- relayClients.add(ws);
2127
- const data = ws.data;
2128
- const UpstreamWebSocket = bunOptions.upstreamWebSocket ?? WebSocket;
2129
- if (data.trace) data.trace.upstreamStartedAt = performance.now();
2130
- const upstream = new UpstreamWebSocket(data.upstreamUrl, {
2131
- headers: data.headers
2132
- });
2133
- upstream.binaryType = "arraybuffer";
2134
- data.upstream = upstream;
2135
- data.upstreamOpenTimer = setTimeout(() => {
2136
- if (upstream.readyState === WebSocket.OPEN) return;
2137
- closeWebSocket(upstream, 1001, "Upstream WebSocket open timed out", 1001);
2138
- closeWebSocket(ws, 1011, "Upstream WebSocket open timed out");
2139
- }, bunOptions.upstreamWebSocketOpenTimeoutMs ?? UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT);
2140
- upstream.onopen = () => {
2141
- const openedAt = performance.now();
2142
- if (data.upstreamOpenTimer) {
2143
- clearTimeout(data.upstreamOpenTimer);
2144
- data.upstreamOpenTimer = void 0;
2145
- }
2146
- if (data.trace) {
2147
- data.trace.upstreamOpenMs = openedAt - (data.trace.upstreamStartedAt ?? data.trace.acceptedAt);
2148
- data.trace.queuedFrames = data.queue.length;
2149
- data.trace.maxQueuedDelayMs = data.queue.reduce(
2150
- (max, item) => Math.max(max, openedAt - item.queuedAt),
2151
- 0
2152
- );
2153
- sendRelayWebSocketTrace(ws);
2154
- }
2155
- for (const item of data.queue.splice(0)) upstream.send(item.payload);
2156
- };
2157
- upstream.onmessage = (event) => {
2158
- const payload = relayWebSocketPayload(event.data);
2159
- if (payload !== void 0) ws.send(payload);
2160
- };
2161
- upstream.onclose = (event) => {
2162
- if (data.upstreamOpenTimer) {
2163
- clearTimeout(data.upstreamOpenTimer);
2164
- data.upstreamOpenTimer = void 0;
2165
- }
2166
- closeWebSocket(ws, event.code, upstreamCloseReason(event));
2167
- };
2168
- upstream.onerror = () => {
2169
- if (data.upstreamOpenTimer) {
2170
- clearTimeout(data.upstreamOpenTimer);
2171
- data.upstreamOpenTimer = void 0;
1223
+ });
1224
+ app.use("*", cors({
1225
+ origin: (origin) => {
1226
+ if (!origin) return void 0;
1227
+ return originAllowed(origin) ? origin : void 0;
1228
+ },
1229
+ // Spread from the shared list, not hand-maintained here: a duplicate
1230
+ // array drifts out of sync and silently drops headers like
1231
+ // `Last-Event-ID` or `X-Fetch-Bypass-Throttle`.
1232
+ allowHeaders: [...RELAY_ALLOWED_REQUEST_HEADER_LIST],
1233
+ allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
1234
+ }));
1235
+ app.get("/health", (c) => {
1236
+ if (options.isDraining?.()) {
1237
+ return c.json(
1238
+ { ok: false, service: "workspace-relay", draining: true },
1239
+ 503
1240
+ );
1241
+ }
1242
+ return c.json({ ok: true, service: "workspace-relay" });
1243
+ });
1244
+ app.get("/.well-known/jwks.json", async (c) => {
1245
+ const sources = options.relayHostPublicKeys ?? [];
1246
+ if (sources.length === 0) {
1247
+ return c.json(
1248
+ {
1249
+ error: {
1250
+ code: "jwks_no_keys_configured",
1251
+ message: "No Relay Host Token public keys are configured for the JWKS endpoint"
2172
1252
  }
2173
- closeWebSocket(ws, 1011, "Upstream WebSocket connection failed");
1253
+ },
1254
+ 503
1255
+ );
1256
+ }
1257
+ let keys;
1258
+ try {
1259
+ keys = await Promise.all(sources.map(async (source) => {
1260
+ const jwk = await exportJWK2(source.publicKey);
1261
+ return {
1262
+ ...jwk,
1263
+ kid: source.kid,
1264
+ alg: "EdDSA",
1265
+ use: "sig"
2174
1266
  };
2175
- },
2176
- close(ws, code, reason) {
2177
- if (ws.data.kind === "host-tunnel") {
2178
- cleanupHostTunnelSocket({
2179
- ws,
2180
- hostTunnels,
2181
- hostTunnelStateDebounce,
2182
- options,
2183
- bunOptions,
2184
- disconnectDirectory: true,
2185
- closeChannels: true
2186
- });
2187
- const tracker = hostTunnelRegistrations.get(ws.data.hostId);
2188
- if (tracker) {
2189
- pruneReconnects(tracker, Date.now());
2190
- if (tracker.recent.length === 0) hostTunnelRegistrations.delete(ws.data.hostId);
2191
- }
2192
- return;
2193
- }
2194
- if (ws.data.kind === "user-hosted-client") {
2195
- const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
2196
- tunnel?.data.channels.delete(ws.data.channelId);
2197
- if (tunnel?.readyState === WebSocket.OPEN) {
2198
- tunnel.send(JSON.stringify({
2199
- type: "ws.close",
2200
- protocol: TUNNEL_PROTOCOL_VERSION,
2201
- channel_id: ws.data.channelId,
2202
- code: safeCloseCode(code, 1e3),
2203
- reason
2204
- }));
1267
+ }));
1268
+ } catch (err) {
1269
+ return c.json(
1270
+ {
1271
+ error: {
1272
+ code: "jwks_invalid_key",
1273
+ message: "A configured RHT public key could not be exported as a JWK",
1274
+ detail: err instanceof Error ? err.message : String(err)
2205
1275
  }
2206
- return;
2207
- }
2208
- relayClients.delete(ws);
2209
- if (ws.data.upstreamOpenTimer) clearTimeout(ws.data.upstreamOpenTimer);
2210
- if (ws.data.upstream) closeWebSocket(ws.data.upstream, code, reason, 1e3);
2211
- }
2212
- },
2213
- drain: drainController,
2214
- telemetry
2215
- };
1276
+ },
1277
+ 500
1278
+ );
1279
+ }
1280
+ c.header("cache-control", "public, max-age=300");
1281
+ return c.json({ keys });
1282
+ });
1283
+ app.get("/metrics", (c) => {
1284
+ const authResult = checkMetricsAuth(options, c.req.raw);
1285
+ if (!authResult.ok) {
1286
+ return c.json(
1287
+ errorBody(authResult.code, authResult.message),
1288
+ authResult.status
1289
+ );
1290
+ }
1291
+ return c.json(buildMetricsBody(options));
1292
+ });
1293
+ app.all("/workspaces/:workspaceId/*", async (c) => {
1294
+ const trace = createWorkspaceRelayTrace();
1295
+ if (options.isDraining?.()) {
1296
+ return Response.json(
1297
+ errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
1298
+ { status: 503, headers: denyCorsHeaders(c.req.raw, originAllowed) }
1299
+ );
1300
+ }
1301
+ const response = await trace.span("relay-total", async () => {
1302
+ const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"), trace);
1303
+ if (!relay.ok) return relay.response;
1304
+ return await forwardWorkspaceRelayRequest(
1305
+ new Request(c.req.raw),
1306
+ relay.request.target,
1307
+ relay.request.relayHostToken,
1308
+ relay.request.path,
1309
+ options.fetch ?? fetch,
1310
+ options.forwardTimeoutMs ?? 3e4,
1311
+ trace,
1312
+ originAllowed
1313
+ );
1314
+ });
1315
+ return workspaceRelayTimingResponse(response, trace);
1316
+ });
1317
+ return app;
2216
1318
  }
2217
1319
 
2218
- // src/cloudflare.ts
2219
- import {
2220
- makeTunnelPong as makeTunnelPong2,
2221
- TUNNEL_PROTOCOL_VERSION as TUNNEL_PROTOCOL_VERSION2,
2222
- validateTunnelMessage as validateTunnelMessage2
2223
- } from "@claxedo/workspace-relay-protocol";
2224
-
2225
1320
  // src/directory.ts
1321
+ function presenceKey(hostId, workspaceId) {
1322
+ return `${hostId}\0${workspaceId}`;
1323
+ }
2226
1324
  function createWorkspaceRelayDirectory(options = {}) {
2227
1325
  const ttlMs = Math.max(1, options.ttlMs ?? 45e3);
2228
1326
  const now = options.now ?? Date.now;
2229
1327
  const sweepIntervalMs = options.sweepIntervalMs ?? 3e4;
2230
- const hosts = /* @__PURE__ */ new Map();
2231
- const alive = (presence) => {
2232
- if (!presence) return;
1328
+ const entries = /* @__PURE__ */ new Map();
1329
+ const alive = (key) => {
1330
+ const presence = entries.get(key);
1331
+ if (!presence) return void 0;
2233
1332
  if (presence.expiresAt <= now()) {
2234
- hosts.delete(presence.hostId);
2235
- return;
1333
+ entries.delete(key);
1334
+ return void 0;
2236
1335
  }
2237
1336
  return presence;
2238
1337
  };
2239
- const touch = (presence) => {
2240
- const timestamp = now();
2241
- const next = {
2242
- ...presence,
2243
- lastPongAt: timestamp,
2244
- expiresAt: timestamp + ttlMs
2245
- };
2246
- hosts.set(next.hostId, next);
2247
- return next;
1338
+ const keysOf = (hostId, workspaceIds) => {
1339
+ if (workspaceIds) return [...new Set(workspaceIds)].map((workspaceId) => presenceKey(hostId, workspaceId));
1340
+ return [...entries].filter(([, presence]) => presence.hostId === hostId).map(([key]) => key);
2248
1341
  };
2249
1342
  const sweep = () => {
2250
1343
  const at = now();
2251
- for (const [hostId, presence] of hosts) {
1344
+ for (const [key, presence] of entries) {
2252
1345
  if (presence.expiresAt <= at) {
2253
- hosts.delete(hostId);
1346
+ entries.delete(key);
2254
1347
  }
2255
1348
  }
2256
1349
  };
@@ -2268,29 +1361,35 @@ function createWorkspaceRelayDirectory(options = {}) {
2268
1361
  lastPongAt: timestamp,
2269
1362
  expiresAt: timestamp + ttlMs
2270
1363
  };
2271
- hosts.set(next.hostId, next);
1364
+ for (const workspaceId of next.workspaceIds) entries.set(presenceKey(next.hostId, workspaceId), next);
2272
1365
  return next;
2273
1366
  },
2274
- recordPong(hostId) {
2275
- const presence = alive(hosts.get(hostId));
2276
- return presence ? touch(presence) : void 0;
1367
+ recordPong(hostId, workspaceIds) {
1368
+ const timestamp = now();
1369
+ let touched;
1370
+ for (const key of keysOf(hostId, workspaceIds)) {
1371
+ const presence = alive(key);
1372
+ if (!presence) continue;
1373
+ const next = { ...presence, lastPongAt: timestamp, expiresAt: timestamp + ttlMs };
1374
+ entries.set(key, next);
1375
+ touched ??= next;
1376
+ }
1377
+ return touched;
2277
1378
  },
2278
- disconnectHost(hostId) {
2279
- hosts.delete(hostId);
1379
+ disconnectHost(hostId, workspaceIds) {
1380
+ for (const key of keysOf(hostId, workspaceIds)) entries.delete(key);
2280
1381
  },
2281
1382
  activeHost(input) {
2282
- const presence = alive(hosts.get(input.hostId));
2283
- if (!presence?.workspaceIds.includes(input.workspaceId)) return;
2284
- return presence;
1383
+ return alive(presenceKey(input.hostId, input.workspaceId));
2285
1384
  },
2286
1385
  sweep,
2287
1386
  size() {
2288
1387
  const at = now();
2289
- let count = 0;
2290
- for (const presence of hosts.values()) {
2291
- if (presence.expiresAt > at) count += 1;
1388
+ const hosts = /* @__PURE__ */ new Set();
1389
+ for (const presence of entries.values()) {
1390
+ if (presence.expiresAt > at) hosts.add(presence.hostId);
2292
1391
  }
2293
- return count;
1392
+ return hosts.size;
2294
1393
  },
2295
1394
  dispose() {
2296
1395
  if (intervalHandle !== void 0) {
@@ -2306,21 +1405,91 @@ function disposeWorkspaceRelayDirectory(directory) {
2306
1405
 
2307
1406
  // src/cloudflare.ts
2308
1407
  var DEFAULT_RELAY_LOCATION_HINT = "apac";
1408
+ var RELAY_LOCATION_HINTS = ["wnam", "enam", "sam", "weur", "eeur", "apac", "oc", "afr", "me"];
1409
+ var relayLocationHintSet = new Set(RELAY_LOCATION_HINTS);
1410
+ function isRelayLocationHint(input) {
1411
+ return relayLocationHintSet.has(input);
1412
+ }
1413
+ var RELAY_REGION_TO_LOCATION_HINT = {
1414
+ // Canonical Claxedo regions.
1415
+ "apac-south": "apac",
1416
+ "apac-east": "apac",
1417
+ "eu-west": "weur",
1418
+ "us-east": "enam",
1419
+ "us-west": "wnam",
1420
+ // Provider spellings.
1421
+ "us-central": "wnam",
1422
+ "eu-central": "weur",
1423
+ "eu-east": "eeur",
1424
+ "ap-south": "apac",
1425
+ "ap-southeast": "apac",
1426
+ "ap-northeast": "apac",
1427
+ "sa-east": "sam",
1428
+ "af-south": "afr",
1429
+ "me-central": "me",
1430
+ "oceania": "oc",
1431
+ "au-southeast": "oc"
1432
+ };
1433
+ function locationHintForCountry(country) {
1434
+ const code = country.toUpperCase();
1435
+ if (["US", "CA", "MX"].includes(code)) return code === "US" || code === "CA" ? "enam" : "wnam";
1436
+ if (["BR", "AR", "CL", "CO", "PE", "UY", "PY", "BO", "EC", "VE"].includes(code)) return "sam";
1437
+ if (["GB", "IE", "FR", "ES", "PT", "NL", "BE", "DE", "CH", "AT", "IT", "DK", "NO", "SE", "FI", "IS", "LU"].includes(code)) return "weur";
1438
+ if (["PL", "CZ", "SK", "HU", "RO", "BG", "GR", "UA", "RS", "HR", "SI", "EE", "LV", "LT", "TR", "RU"].includes(code)) return "eeur";
1439
+ if (["IN", "PK", "BD", "LK", "NP", "SG", "MY", "TH", "VN", "PH", "ID", "CN", "HK", "TW", "JP", "KR"].includes(code)) return "apac";
1440
+ if (["AU", "NZ", "FJ", "PG"].includes(code)) return "oc";
1441
+ if (["ZA", "NG", "KE", "GH", "ET", "TZ", "UG", "EG", "MA", "DZ", "TN"].includes(code)) {
1442
+ return ["EG", "MA", "DZ", "TN"].includes(code) ? "me" : "afr";
1443
+ }
1444
+ if (["AE", "SA", "IL", "QA", "KW", "BH", "OM", "JO", "LB", "IQ", "IR"].includes(code)) return "me";
1445
+ return void 0;
1446
+ }
1447
+ var RELAY_REGION_HEADER = "x-claxedo-workspace-region";
1448
+ function relayLocationHint(input) {
1449
+ const configured = trimToUndefined(input.configured) ?? DEFAULT_RELAY_LOCATION_HINT;
1450
+ const region = trimToUndefined(input.region);
1451
+ if (region) {
1452
+ const lowered = region.toLowerCase();
1453
+ const direct = isRelayLocationHint(lowered) ? lowered : void 0;
1454
+ const mapped = direct ?? RELAY_REGION_TO_LOCATION_HINT[lowered];
1455
+ if (mapped) return mapped;
1456
+ }
1457
+ const country = trimToUndefined(input.country);
1458
+ if (country) {
1459
+ const mapped = locationHintForCountry(country);
1460
+ if (mapped) return mapped;
1461
+ }
1462
+ return configured;
1463
+ }
1464
+ function relayRequestRegion(request) {
1465
+ const url = new URL(request.url);
1466
+ return trimToUndefined(request.headers.get(RELAY_REGION_HEADER)) ?? trimToUndefined(url.searchParams.get("region")) ?? trimToUndefined(url.searchParams.get("homeRegion"));
1467
+ }
1468
+ function relayRequestCountry(request) {
1469
+ const cf = request.cf;
1470
+ return typeof cf?.country === "string" ? cf.country : void 0;
1471
+ }
2309
1472
  var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
2310
1473
  var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
2311
1474
  var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
2312
- var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT2 = 16 * 1024 * 1024;
1475
+ var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
2313
1476
  var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
2314
- var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT2 = 8 * 1024 * 1024;
2315
- var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT2 = 3e4;
2316
- var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT2 = 64;
1477
+ var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
1478
+ var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
1479
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
1480
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
2317
1481
  var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
2318
1482
  var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1483
+ var RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
1484
+ var HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT = 3e4;
1485
+ var HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT = 3e4;
2319
1486
  var TRACE_ID_HEADER = "x-claxedo-trace-id";
2320
1487
  var TRACE_FORCE_HEADER = "x-claxedo-relay-trace";
2321
- function clean(input) {
2322
- const value = input?.trim();
2323
- return value ? value : void 0;
1488
+ function unrefTimer(timer) {
1489
+ if (typeof timer.unref === "function") timer.unref();
1490
+ }
1491
+ function isUserHostedClient(client) {
1492
+ return "channelId" in client && typeof client.channelId === "string";
2324
1493
  }
2325
1494
  function clampRate(input) {
2326
1495
  if (input === void 0 || !Number.isFinite(input)) return 0;
@@ -2329,8 +1498,8 @@ function clampRate(input) {
2329
1498
  return input;
2330
1499
  }
2331
1500
  function traceRequest(request, routeKind, options) {
2332
- const id = clean(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
2333
- const forceSecret = clean(options.traceForceHeaderSecret);
1501
+ const id = trimToUndefined(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
1502
+ const forceSecret = trimToUndefined(options.traceForceHeaderSecret);
2334
1503
  const forced = !!forceSecret && request.headers.get(TRACE_FORCE_HEADER) === forceSecret;
2335
1504
  const rate = clampRate(options.traceSampleRate);
2336
1505
  return {
@@ -2350,36 +1519,36 @@ async function traceSpan(trace, name, fn) {
2350
1519
  }
2351
1520
  }
2352
1521
  function tracedRequest(request, trace) {
2353
- const headers2 = new Headers(request.headers);
2354
- headers2.set(TRACE_ID_HEADER, trace.id);
2355
- if (trace.sampled) headers2.set("x-claxedo-trace-sampled", "1");
2356
- else headers2.delete("x-claxedo-trace-sampled");
1522
+ const headers = new Headers(request.headers);
1523
+ headers.set(TRACE_ID_HEADER, trace.id);
1524
+ if (trace.sampled) headers.set("x-claxedo-trace-sampled", "1");
1525
+ else headers.delete("x-claxedo-trace-sampled");
2357
1526
  return new Request(request.url, {
2358
1527
  method: request.method,
2359
- headers: headers2,
1528
+ headers,
2360
1529
  body: request.method === "GET" || request.method === "HEAD" ? void 0 : request.body,
2361
1530
  redirect: request.redirect,
2362
1531
  duplex: "half"
2363
1532
  });
2364
1533
  }
2365
- function roundedMs3(value) {
1534
+ function roundedMs2(value) {
2366
1535
  return Math.round(value * 100) / 100;
2367
1536
  }
2368
1537
  function serverTiming(trace) {
2369
- return trace.phases.map((phase) => `${phase.name};dur=${roundedMs3(phase.ms)}`).join(", ");
1538
+ return trace.phases.map((phase) => `${phase.name};dur=${roundedMs2(phase.ms)}`).join(", ");
2370
1539
  }
2371
1540
  function withTraceHeaders(response, trace) {
2372
- const headers2 = new Headers(response.headers);
2373
- headers2.set(TRACE_ID_HEADER, trace.id);
1541
+ const headers = new Headers(response.headers);
1542
+ headers.set(TRACE_ID_HEADER, trace.id);
2374
1543
  const timing = trace.sampled ? serverTiming(trace) : "";
2375
1544
  if (timing) {
2376
- const existing = headers2.get("server-timing");
2377
- headers2.set("server-timing", existing ? `${existing}, ${timing}` : timing);
1545
+ const existing = headers.get("server-timing");
1546
+ headers.set("server-timing", existing ? `${existing}, ${timing}` : timing);
2378
1547
  }
2379
1548
  return new Response(response.body, {
2380
1549
  status: response.status,
2381
1550
  statusText: response.statusText,
2382
- headers: headers2
1551
+ headers
2383
1552
  });
2384
1553
  }
2385
1554
  function logTrace(options, request, workspaceId, trace, response) {
@@ -2394,8 +1563,8 @@ function logTrace(options, request, workspaceId, trace, response) {
2394
1563
  method: request.method,
2395
1564
  path: url.pathname,
2396
1565
  status: response.status,
2397
- totalMs: roundedMs3(totalMs),
2398
- phases: Object.fromEntries(trace.phases.map((phase) => [phase.name, roundedMs3(phase.ms)]))
1566
+ totalMs: roundedMs2(totalMs),
1567
+ phases: Object.fromEntries(trace.phases.map((phase) => [phase.name, roundedMs2(phase.ms)]))
2399
1568
  };
2400
1569
  if (options.traceLog) {
2401
1570
  options.traceLog(event);
@@ -2406,44 +1575,58 @@ function logTrace(options, request, workspaceId, trace, response) {
2406
1575
  function workspaceIdFromRelayPath(pathname) {
2407
1576
  return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
2408
1577
  }
2409
- function hostIdFromTunnelPath2(pathname) {
1578
+ function hostIdFromTunnelPath(pathname) {
2410
1579
  return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
2411
1580
  }
2412
- function websocketRequest2(request) {
1581
+ function websocketRequest(request) {
2413
1582
  return request.headers.get("upgrade")?.toLowerCase() === "websocket";
2414
1583
  }
2415
- function relayWebSocketTraceEnabled2(request) {
1584
+ function relayWebSocketTraceEnabled(request) {
2416
1585
  return request.headers.get("x-claxedo-relay-ws-trace") === "1";
2417
1586
  }
2418
1587
  function workspaceIdsFromSearch(url) {
2419
1588
  return [...new Set([
2420
1589
  ...url.searchParams.getAll("workspaceId"),
2421
1590
  ...url.searchParams.getAll("workspace_id")
2422
- ].map(clean).filter((item) => !!item))];
1591
+ ].map(trimToUndefined).filter((item) => !!item))];
2423
1592
  }
2424
1593
  function randomToken() {
2425
1594
  return crypto.randomUUID();
2426
1595
  }
2427
- function headerRecord(headers2) {
1596
+ function headerRecord(headers) {
2428
1597
  const result = {};
2429
- headers2.forEach((value, key) => {
1598
+ headers.forEach((value, key) => {
2430
1599
  result[key] = value;
2431
1600
  });
2432
1601
  return result;
2433
1602
  }
2434
1603
  function headersFromRecord(input) {
2435
- const headers2 = new Headers();
2436
- for (const [key, value] of Object.entries(input)) headers2.set(key, value);
2437
- return headers2;
1604
+ const headers = new Headers();
1605
+ for (const [key, value] of Object.entries(input)) headers.set(key, value);
1606
+ return headers;
2438
1607
  }
1608
+ var BASE64_CHUNK_BYTES = 32768;
2439
1609
  function bytesToBase64(bytes) {
2440
1610
  let binary = "";
2441
- for (const byte of bytes) binary += String.fromCharCode(byte);
1611
+ for (let i = 0; i < bytes.length; i += BASE64_CHUNK_BYTES) {
1612
+ binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_BYTES));
1613
+ }
2442
1614
  return btoa(binary);
2443
1615
  }
2444
1616
  function base64ToBytes(input) {
2445
1617
  return Uint8Array.from(atob(input), (char) => char.charCodeAt(0));
2446
1618
  }
1619
+ function preOpenFrameBytes(input) {
1620
+ if (typeof input === "string") return new TextEncoder().encode(input).byteLength;
1621
+ return input.byteLength;
1622
+ }
1623
+ function isBlobLike(input) {
1624
+ if (!input || typeof input !== "object") return false;
1625
+ return "arrayBuffer" in input && typeof input.arrayBuffer === "function";
1626
+ }
1627
+ function viewBytes(input) {
1628
+ return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
1629
+ }
2447
1630
  function socketFrame(input) {
2448
1631
  if (typeof input === "string") {
2449
1632
  return {
@@ -2463,14 +1646,54 @@ function socketFrame(input) {
2463
1646
  data_base64: bytesToBase64(input)
2464
1647
  };
2465
1648
  }
1649
+ if (ArrayBuffer.isView(input)) {
1650
+ return {
1651
+ binary: true,
1652
+ data_base64: bytesToBase64(viewBytes(input))
1653
+ };
1654
+ }
1655
+ if (!isBlobLike(input)) return void 0;
1656
+ return input.arrayBuffer().then((buffer) => ({
1657
+ binary: true,
1658
+ data_base64: bytesToBase64(new Uint8Array(buffer))
1659
+ }));
2466
1660
  }
2467
1661
  function socketPayload(input) {
2468
1662
  if (typeof input === "string") return input;
2469
1663
  if (input instanceof ArrayBuffer) return input;
2470
1664
  if (input instanceof Uint8Array) return input;
2471
- if (ArrayBuffer.isView(input)) {
2472
- return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
2473
- }
1665
+ if (ArrayBuffer.isView(input)) return viewBytes(input);
1666
+ if (!isBlobLike(input)) return void 0;
1667
+ return input.arrayBuffer();
1668
+ }
1669
+ function orderedFrameDelivery(deliver) {
1670
+ let tail;
1671
+ return (data) => {
1672
+ const payload = socketPayload(data);
1673
+ if (payload === void 0) return;
1674
+ if (tail === void 0 && !(payload instanceof Promise)) {
1675
+ deliver(payload);
1676
+ return;
1677
+ }
1678
+ const queued = (tail ?? Promise.resolve()).then(async () => deliver(await payload)).catch(() => {
1679
+ });
1680
+ tail = queued;
1681
+ void queued.then(() => {
1682
+ if (tail === queued) tail = void 0;
1683
+ });
1684
+ };
1685
+ }
1686
+ async function relayFrameDiagnostics(data) {
1687
+ const frame = await socketFrame(data);
1688
+ const payload = await socketPayload(data);
1689
+ return {
1690
+ // `undefined` here is exactly the silent drop: every caller treats it as
1691
+ // "nothing to forward" and returns without a throw or a log.
1692
+ frameDropped: frame === void 0,
1693
+ payloadDropped: payload === void 0,
1694
+ ...frame ? { binary: frame.binary, dataBase64: frame.data_base64 } : {},
1695
+ payloadBase64: payload === void 0 ? void 0 : typeof payload === "string" ? bytesToBase64(new TextEncoder().encode(payload)) : bytesToBase64(payload instanceof ArrayBuffer ? new Uint8Array(payload) : payload)
1696
+ };
2474
1697
  }
2475
1698
  function tunnelFrame(input) {
2476
1699
  const bytes = base64ToBytes(input.data_base64);
@@ -2507,7 +1730,7 @@ async function readRequestBodyCapped(request, maxBytes) {
2507
1730
  }
2508
1731
  return concatBytes(chunks);
2509
1732
  }
2510
- function safeCloseCode2(code, fallback = 1011) {
1733
+ function safeCloseCode(code, fallback = 1011) {
2511
1734
  if (code === void 0) return fallback;
2512
1735
  if (code >= 1e3 && code <= 4999 && ![1004, 1005, 1006].includes(code)) return code;
2513
1736
  return fallback;
@@ -2517,15 +1740,19 @@ function workspaceRelayDurableObjectRoomName(workspaceId) {
2517
1740
  }
2518
1741
  function workspaceRelayDurableObjectWorkspaceId(request) {
2519
1742
  const url = new URL(request.url);
2520
- const workspaceId = clean(workspaceIdFromRelayPath(url.pathname));
1743
+ const workspaceId = trimToUndefined(workspaceIdFromRelayPath(url.pathname));
2521
1744
  if (workspaceId) return decodeURIComponent(workspaceId);
2522
- if (!hostIdFromTunnelPath2(url.pathname)) return;
2523
- return clean(url.searchParams.get("workspaceId")) ?? clean(url.searchParams.get("workspace_id"));
1745
+ if (!hostIdFromTunnelPath(url.pathname)) return void 0;
1746
+ return trimToUndefined(url.searchParams.get("workspaceId")) ?? trimToUndefined(url.searchParams.get("workspace_id"));
2524
1747
  }
2525
1748
  function namespaceFromEnv(env, options) {
2526
1749
  if (options.namespace) return options.namespace;
2527
1750
  const binding = env[options.bindingName ?? "WORKSPACE_RELAY_ROOM"];
2528
- return binding && typeof binding === "object" ? binding : void 0;
1751
+ return isDurableObjectNamespace(binding) ? binding : void 0;
1752
+ }
1753
+ function isDurableObjectNamespace(value) {
1754
+ if (!value || typeof value !== "object") return false;
1755
+ return "idFromName" in value && typeof value.idFromName === "function" && "get" in value && typeof value.get === "function";
2529
1756
  }
2530
1757
  function json(code, message, status) {
2531
1758
  return Response.json(errorBody(code, message), { status });
@@ -2540,32 +1767,31 @@ function setWorkspaceRelayAllowedOrigins(raw) {
2540
1767
  const patterns = parseAllowedOrigins(raw);
2541
1768
  baseOriginAllowed = patterns ? createOriginMatcher(patterns) : defaultOriginAllowed;
2542
1769
  }
2543
- function allowedCorsOrigin2(origin) {
2544
- if (!origin) return;
2545
- if (baseOriginAllowed(origin)) return origin;
2546
- if (configuredAppOriginAllowed(origin)) return origin;
1770
+ function allowedCorsOrigin(origin) {
1771
+ if (!origin) return void 0;
1772
+ return baseOriginAllowed(origin) || configuredAppOriginAllowed(origin) ? origin : void 0;
2547
1773
  }
2548
1774
  function corsHeaders(request) {
2549
- const origin = allowedCorsOrigin2(request.headers.get("origin"));
2550
- if (!origin) return;
1775
+ const origin = allowedCorsOrigin(request.headers.get("origin"));
1776
+ if (!origin) return void 0;
2551
1777
  return {
2552
1778
  "access-control-allow-origin": origin,
2553
1779
  "access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
2554
- "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",
1780
+ "access-control-allow-headers": RELAY_ALLOWED_REQUEST_HEADERS,
2555
1781
  "access-control-max-age": "86400",
2556
1782
  vary: "Origin"
2557
1783
  };
2558
1784
  }
2559
1785
  function corsPreflight(request) {
2560
- const headers2 = corsHeaders(request);
2561
- return new Response(null, { status: headers2 ? 204 : 403, ...headers2 ? { headers: headers2 } : {} });
1786
+ const headers = corsHeaders(request);
1787
+ return new Response(null, { status: headers ? 204 : 403, ...headers ? { headers } : {} });
2562
1788
  }
2563
1789
  function withCors(request, response) {
2564
- const headers2 = corsHeaders(request);
2565
- if (!headers2) return response;
2566
- if (response.status === 101 || response.webSocket) return response;
1790
+ const headers = corsHeaders(request);
1791
+ if (!headers) return response;
1792
+ if (response.status === 101 || "webSocket" in response && response.webSocket) return response;
2567
1793
  const next = new Headers(response.headers);
2568
- for (const [key, value] of Object.entries(headers2)) next.set(key, value);
1794
+ for (const [key, value] of Object.entries(headers)) next.set(key, value);
2569
1795
  return new Response(response.body, {
2570
1796
  status: response.status,
2571
1797
  statusText: response.statusText,
@@ -2573,7 +1799,7 @@ function withCors(request, response) {
2573
1799
  });
2574
1800
  }
2575
1801
  function defaultWebSocketPair() {
2576
- const pair = new globalThis.WebSocketPair();
1802
+ const pair = new WebSocketPair();
2577
1803
  return {
2578
1804
  client: pair[0],
2579
1805
  server: pair[1]
@@ -2581,7 +1807,7 @@ function defaultWebSocketPair() {
2581
1807
  }
2582
1808
  function selectedSubprotocol(request) {
2583
1809
  const offered = request.headers.get("sec-websocket-protocol")?.split(",").map((item) => item.trim()).filter(Boolean);
2584
- if (!offered?.length) return;
1810
+ if (!offered?.length) return void 0;
2585
1811
  return offered.find((item) => item.startsWith("claxedo-rat.")) ?? offered[0];
2586
1812
  }
2587
1813
  function upgradeResponse(socket, request) {
@@ -2608,7 +1834,7 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
2608
1834
  return {
2609
1835
  async fetch(request, env = {}) {
2610
1836
  const gatewayStartedAt = performance.now();
2611
- const gatewayTraceId = clean(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
1837
+ const gatewayTraceId = trimToUndefined(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
2612
1838
  const url = new URL(request.url);
2613
1839
  if (request.method === "OPTIONS" && (url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/"))) {
2614
1840
  return corsPreflight(request);
@@ -2616,7 +1842,13 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
2616
1842
  if (url.pathname === "/health") {
2617
1843
  return Response.json({ ok: true, service: "workspace-relay", mode: "cloudflare-durable-object" });
2618
1844
  }
2619
- const relayLocationHint = typeof env.CLAXEDO_RELAY_LOCATION_HINT === "string" && env.CLAXEDO_RELAY_LOCATION_HINT ? env.CLAXEDO_RELAY_LOCATION_HINT : DEFAULT_RELAY_LOCATION_HINT;
1845
+ const requestRegion = relayRequestRegion(request);
1846
+ const requestCountry = relayRequestCountry(request);
1847
+ const locationHint = relayLocationHint({
1848
+ ...typeof env.CLAXEDO_RELAY_LOCATION_HINT === "string" ? { configured: env.CLAXEDO_RELAY_LOCATION_HINT } : {},
1849
+ ...requestRegion ? { region: requestRegion } : {},
1850
+ ...requestCountry ? { country: requestCountry } : {}
1851
+ });
2620
1852
  const namespace = namespaceFromEnv(env, options);
2621
1853
  if (!namespace) {
2622
1854
  return json(
@@ -2642,7 +1874,7 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
2642
1874
  }
2643
1875
  const routeFinishedAt = performance.now();
2644
1876
  const response = await namespace.get(namespace.idFromName(workspaceRelayDurableObjectRoomName(workspaceId)), {
2645
- locationHint: relayLocationHint
1877
+ locationHint
2646
1878
  }).fetch(tracedRequest(request, {
2647
1879
  id: gatewayTraceId,
2648
1880
  sampled: request.headers.get("x-claxedo-trace-sampled") === "1",
@@ -2651,26 +1883,26 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
2651
1883
  phases: []
2652
1884
  }));
2653
1885
  const fetchFinishedAt = performance.now();
2654
- if (response.status === 101 || response.webSocket) {
1886
+ if (response.status === 101 || "webSocket" in response && response.webSocket) {
2655
1887
  return url.pathname.startsWith("/workspaces/") ? withCors(request, response) : response;
2656
1888
  }
2657
- const headers2 = new Headers(response.headers);
2658
- headers2.set(TRACE_ID_HEADER, headers2.get(TRACE_ID_HEADER) ?? gatewayTraceId);
2659
- headers2.set("x-claxedo-relay-location-hint", relayLocationHint);
1889
+ const headers = new Headers(response.headers);
1890
+ headers.set(TRACE_ID_HEADER, headers.get(TRACE_ID_HEADER) ?? gatewayTraceId);
1891
+ headers.set("x-claxedo-relay-location-hint", locationHint);
2660
1892
  const gatewayResponseStartedAt = performance.now();
2661
1893
  const tracedResponse = new Response(response.body, {
2662
1894
  status: response.status,
2663
1895
  statusText: response.statusText,
2664
- headers: headers2
1896
+ headers
2665
1897
  });
2666
1898
  const gatewayFinishedAt = performance.now();
2667
1899
  const roomTiming = tracedResponse.headers.get("server-timing");
2668
1900
  if (roomTiming) {
2669
1901
  const gatewayTiming = [
2670
- `relay-gateway-route;dur=${roundedMs3(routeFinishedAt - gatewayStartedAt)}`,
2671
- `relay-gateway-do-fetch;dur=${roundedMs3(fetchFinishedAt - routeFinishedAt)}`,
2672
- `relay-gateway-response;dur=${roundedMs3(gatewayFinishedAt - gatewayResponseStartedAt)}`,
2673
- `relay-gateway;dur=${roundedMs3(gatewayFinishedAt - gatewayStartedAt)}`
1902
+ `relay-gateway-route;dur=${roundedMs2(routeFinishedAt - gatewayStartedAt)}`,
1903
+ `relay-gateway-do-fetch;dur=${roundedMs2(fetchFinishedAt - routeFinishedAt)}`,
1904
+ `relay-gateway-response;dur=${roundedMs2(gatewayFinishedAt - gatewayResponseStartedAt)}`,
1905
+ `relay-gateway;dur=${roundedMs2(gatewayFinishedAt - gatewayStartedAt)}`
2674
1906
  ].join(", ");
2675
1907
  tracedResponse.headers.set("server-timing", `${roomTiming}, ${gatewayTiming}`);
2676
1908
  }
@@ -2720,6 +1952,8 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2720
1952
  }
2721
1953
  };
2722
1954
  const cleanupTunnelResources = (tunnel, reason) => {
1955
+ if (tunnel.generationWatcher) clearInterval(tunnel.generationWatcher);
1956
+ tunnel.generationWatcher = void 0;
2723
1957
  for (const pending of tunnel.pending.values()) {
2724
1958
  clearTimeout(pending.timeout);
2725
1959
  if (pending.stream?.slowConsumerTimeout) clearTimeout(pending.stream.slowConsumerTimeout);
@@ -2741,6 +1975,10 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2741
1975
  const rebuildHibernatedSockets = () => {
2742
1976
  if (!hibernation) return;
2743
1977
  hibernatedSocketsRebuilt = true;
1978
+ for (const tunnel of hostTunnels.values()) {
1979
+ if (tunnel.pending.size === 0) continue;
1980
+ cleanupTunnelResources(tunnel, "Workspace relay restarted while the request was in flight");
1981
+ }
2744
1982
  hostTunnels.clear();
2745
1983
  clients.clear();
2746
1984
  const clientRows = [];
@@ -2752,6 +1990,9 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2752
1990
  hostId: attachment.hostId,
2753
1991
  workspaceIds: attachment.workspaceIds,
2754
1992
  connectedAt: attachment.connectedAt,
1993
+ ...attachment.enrollmentId ? { enrollmentId: attachment.enrollmentId } : {},
1994
+ ...attachment.generation !== void 0 ? { generation: attachment.generation } : {},
1995
+ generationCheckFailures: attachment.generationCheckFailures ?? 0,
2755
1996
  socket,
2756
1997
  pending: /* @__PURE__ */ new Map(),
2757
1998
  channels: /* @__PURE__ */ new Map()
@@ -2798,10 +2039,13 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2798
2039
  }
2799
2040
  }
2800
2041
  };
2042
+ const resolverGraceAttempts = options.resolverOutageGraceAttempts ?? RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT;
2043
+ const resolverUnreachable = (code) => code === "relay_revocation_resolver_unavailable";
2801
2044
  const watchRuntimeAccessToken = (client, onInactive) => {
2802
2045
  const intervalMs = options.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
2803
- if (intervalMs <= 0) return;
2046
+ if (intervalMs <= 0) return void 0;
2804
2047
  const now = options.now ?? Date.now;
2048
+ let consecutiveUnreachable = 0;
2805
2049
  const timer = setInterval(() => {
2806
2050
  if (client.request.claims.exp * 1e3 <= now()) {
2807
2051
  clearInterval(timer);
@@ -2809,41 +2053,98 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2809
2053
  return;
2810
2054
  }
2811
2055
  if (!options.isRuntimeAccessTokenActive) return;
2056
+ const tolerate = (reason) => {
2057
+ consecutiveUnreachable += 1;
2058
+ if (consecutiveUnreachable < resolverGraceAttempts) return;
2059
+ clearInterval(timer);
2060
+ onInactive(reason);
2061
+ };
2812
2062
  void Promise.resolve(options.isRuntimeAccessTokenActive(client.request.claims)).then((active) => {
2813
- if (active.active) return;
2063
+ if (active.active) {
2064
+ consecutiveUnreachable = 0;
2065
+ return;
2066
+ }
2067
+ if (resolverUnreachable(active.code)) {
2068
+ tolerate(`Runtime Access Token active check unavailable: ${active.reason}`);
2069
+ return;
2070
+ }
2071
+ consecutiveUnreachable = 0;
2814
2072
  clearInterval(timer);
2815
2073
  onInactive(active.reason);
2816
- }).catch(() => {
2817
- clearInterval(timer);
2818
- onInactive("Runtime Access Token active check failed");
2819
- });
2074
+ }).catch(() => tolerate("Runtime Access Token active check failed"));
2820
2075
  }, intervalMs);
2821
- if (typeof timer.unref === "function") {
2822
- timer.unref();
2823
- }
2076
+ unrefTimer(timer);
2824
2077
  return timer;
2825
2078
  };
2826
2079
  const watchUserHostedTarget = (client, onInactive) => {
2827
2080
  const intervalMs = options.workspaceTargetActiveCheckIntervalMs ?? WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
2828
- if (intervalMs <= 0) return;
2081
+ if (intervalMs <= 0) return void 0;
2829
2082
  const target = client.request.target;
2083
+ let consecutiveUnreachable = 0;
2830
2084
  const timer = setInterval(() => {
2831
2085
  void Promise.resolve(options.resolveTarget(client.request.claims)).then((activeTarget) => {
2832
2086
  if (activeTarget?.access === "user-hosted" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
2087
+ consecutiveUnreachable = 0;
2833
2088
  return;
2834
2089
  }
2090
+ consecutiveUnreachable = 0;
2835
2091
  clearInterval(timer);
2836
2092
  onInactive("User-hosted workspace is offline");
2837
2093
  }).catch(() => {
2094
+ consecutiveUnreachable += 1;
2095
+ if (consecutiveUnreachable < resolverGraceAttempts) return;
2838
2096
  clearInterval(timer);
2839
2097
  onInactive("Workspace relay target active check failed");
2840
2098
  });
2841
2099
  }, intervalMs);
2842
- if (typeof timer.unref === "function") {
2843
- timer.unref();
2844
- }
2100
+ unrefTimer(timer);
2845
2101
  return timer;
2846
2102
  };
2103
+ const hostTunnelAttachment = (tunnel) => ({
2104
+ kind: "host-tunnel",
2105
+ hostId: tunnel.hostId,
2106
+ workspaceIds: tunnel.workspaceIds,
2107
+ connectedAt: tunnel.connectedAt,
2108
+ ...tunnel.enrollmentId ? { enrollmentId: tunnel.enrollmentId } : {},
2109
+ ...tunnel.generation !== void 0 ? { generation: tunnel.generation } : {},
2110
+ ...tunnel.generationCheckFailures ? { generationCheckFailures: tunnel.generationCheckFailures } : {}
2111
+ });
2112
+ const fenced = (tunnel) => Boolean(options.resolveHostGeneration) && tunnel.generation !== void 0;
2113
+ const recheckHostGeneration = async (tunnel) => {
2114
+ if (!fenced(tunnel) || hostTunnels.get(tunnel.hostId) !== tunnel) return;
2115
+ const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, {
2116
+ enrollment_id: tunnel.enrollmentId,
2117
+ generation: tunnel.generation
2118
+ });
2119
+ if (hostTunnels.get(tunnel.hostId) !== tunnel) return;
2120
+ if (decision.ok) {
2121
+ if (tunnel.generationCheckFailures === 0) return;
2122
+ tunnel.generationCheckFailures = 0;
2123
+ tunnel.socket.serializeAttachment?.(hostTunnelAttachment(tunnel));
2124
+ return;
2125
+ }
2126
+ if (decision.retryable) {
2127
+ tunnel.generationCheckFailures += 1;
2128
+ if (tunnel.generationCheckFailures < resolverGraceAttempts) {
2129
+ tunnel.socket.serializeAttachment?.(hostTunnelAttachment(tunnel));
2130
+ return;
2131
+ }
2132
+ cleanupHostTunnel(tunnel.hostId, tunnel.socket, "Host generation check unavailable");
2133
+ closeSocket(tunnel.socket, 1012, "Host generation check unavailable");
2134
+ return;
2135
+ }
2136
+ cleanupHostTunnel(tunnel.hostId, tunnel.socket, decision.reason);
2137
+ closeSocket(tunnel.socket, 1008, decision.reason);
2138
+ };
2139
+ const watchHostGeneration = (tunnel) => {
2140
+ const intervalMs = options.hostGenerationCheckIntervalMs ?? HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT;
2141
+ if (tunnel.generationWatcher || hibernation || !fenced(tunnel) || intervalMs <= 0) return;
2142
+ tunnel.generationWatcher = setInterval(() => {
2143
+ void recheckHostGeneration(tunnel).catch(() => {
2144
+ });
2145
+ }, intervalMs);
2146
+ unrefTimer(tunnel.generationWatcher);
2147
+ };
2847
2148
  const clearSlowConsumerTimer = (pending) => {
2848
2149
  if (!pending.stream?.slowConsumerTimeout) return;
2849
2150
  clearTimeout(pending.stream.slowConsumerTimeout);
@@ -2858,7 +2159,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2858
2159
  pending.stream.flowPaused = paused;
2859
2160
  tunnel.socket.send?.(JSON.stringify({
2860
2161
  type: "http.response.flow",
2861
- protocol: TUNNEL_PROTOCOL_VERSION2,
2162
+ protocol: TUNNEL_PROTOCOL_VERSION,
2862
2163
  request_id: requestId,
2863
2164
  paused,
2864
2165
  reason
@@ -2879,7 +2180,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2879
2180
  if (pending.stream?.slowConsumerTimeout) return;
2880
2181
  pending.stream.slowConsumerTimeout = setTimeout(
2881
2182
  () => failSlowConsumer(tunnel, requestId, pending),
2882
- options.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT2
2183
+ options.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
2883
2184
  );
2884
2185
  };
2885
2186
  const drainPendingStreamChunks = (tunnel, requestId, pending) => {
@@ -2933,7 +2234,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2933
2234
  failPendingTunnelResponse(tunnel, oldestId, oldest, new Error("stream_evicted: tunnel started-stream cap reached"));
2934
2235
  }
2935
2236
  };
2936
- const resolvePendingTunnelStream = (tunnel, requestId, pending, status, headers2) => {
2237
+ const resolvePendingTunnelStream = (tunnel, requestId, pending, status, headers) => {
2937
2238
  if (pending.stream) return;
2938
2239
  pending.startedSeq = ++startedSeqCounter;
2939
2240
  evictOldestStartedStream(tunnel, requestId);
@@ -2957,18 +2258,18 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2957
2258
  }
2958
2259
  tunnel.socket.send?.(JSON.stringify({
2959
2260
  type: "http.response.flow",
2960
- protocol: TUNNEL_PROTOCOL_VERSION2,
2261
+ protocol: TUNNEL_PROTOCOL_VERSION,
2961
2262
  request_id: requestId,
2962
2263
  paused: false,
2963
2264
  reason: "closed"
2964
2265
  }));
2965
2266
  }
2966
2267
  }, new ByteLengthQueuingStrategy({
2967
- highWaterMark: options.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT2
2268
+ highWaterMark: options.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT
2968
2269
  }));
2969
2270
  pending.resolve(new Response(stream, {
2970
2271
  status,
2971
- headers: headers2
2272
+ headers
2972
2273
  }));
2973
2274
  };
2974
2275
  const failPendingTunnelResponse = (tunnel, requestId, pending, error) => {
@@ -3019,11 +2320,77 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3019
2320
  const activeTarget = await options.resolveTarget(client.request.claims);
3020
2321
  return !!(activeTarget?.access === "user-hosted" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId }));
3021
2322
  };
2323
+ const dropUserHostedChannel = (tunnel, channelId, client, code, reason) => {
2324
+ tunnel.channels.delete(channelId);
2325
+ clients.delete(client);
2326
+ closeSocket(client.socket, code, reason);
2327
+ sendSocket(tunnel.socket, JSON.stringify({
2328
+ type: "ws.close",
2329
+ protocol: TUNNEL_PROTOCOL_VERSION,
2330
+ channel_id: channelId,
2331
+ code,
2332
+ reason
2333
+ }));
2334
+ };
2335
+ const reportFrameHandlerFailure = (socket, err) => {
2336
+ const reason = err instanceof Error ? err.message : String(err);
2337
+ console.error(`[workspace-relay] frame handler failed: ${reason}`);
2338
+ closeSocket(socket, 1011, "Workspace relay failed to process a frame");
2339
+ };
2340
+ const hibernatedRevocationIntervalMs = () => options.hibernatedRevocationCheckIntervalMs ?? HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT;
2341
+ const scheduleHibernatedRevocationCheck = async () => {
2342
+ const intervalMs = hibernatedRevocationIntervalMs();
2343
+ if (!hibernation || !options.alarms || intervalMs <= 0) return;
2344
+ if (clients.size === 0 && ![...hostTunnels.values()].some(fenced)) return;
2345
+ const now = options.now ?? Date.now;
2346
+ const at = now() + intervalMs;
2347
+ try {
2348
+ const existing = await options.alarms.getAlarm();
2349
+ if (typeof existing === "number" && existing > 0 && existing <= at) return;
2350
+ await options.alarms.setAlarm(at);
2351
+ } catch {
2352
+ }
2353
+ };
2354
+ const runHibernatedRevocationCheck = async () => {
2355
+ ensureHibernatedSocketsRebuilt();
2356
+ const now = options.now ?? Date.now;
2357
+ const revoke = (client, reason) => {
2358
+ if (isUserHostedClient(client)) {
2359
+ for (const tunnel of hostTunnels.values()) {
2360
+ if (tunnel.channels.get(client.channelId) !== client) continue;
2361
+ dropUserHostedChannel(tunnel, client.channelId, client, 1008, reason);
2362
+ return;
2363
+ }
2364
+ }
2365
+ closeSocket(client.socket, 1008, reason);
2366
+ clients.delete(client);
2367
+ };
2368
+ for (const client of Array.from(clients)) {
2369
+ if (client.request.claims.exp * 1e3 <= now()) {
2370
+ revoke(client, "Runtime Access Token expired");
2371
+ continue;
2372
+ }
2373
+ if (!options.isRuntimeAccessTokenActive) continue;
2374
+ let active;
2375
+ try {
2376
+ active = await options.isRuntimeAccessTokenActive(client.request.claims);
2377
+ } catch {
2378
+ continue;
2379
+ }
2380
+ if (active.active || resolverUnreachable(active.code)) continue;
2381
+ revoke(client, active.reason);
2382
+ }
2383
+ for (const tunnel of Array.from(hostTunnels.values())) {
2384
+ await recheckHostGeneration(tunnel);
2385
+ }
2386
+ await scheduleHibernatedRevocationCheck();
2387
+ };
3022
2388
  const activeUserHostedClient = (channelId, socket) => {
3023
2389
  for (const tunnel of hostTunnels.values()) {
3024
2390
  const client = tunnel.channels.get(channelId);
3025
2391
  if (client?.socket === socket) return { tunnel, client };
3026
2392
  }
2393
+ return void 0;
3027
2394
  };
3028
2395
  const handleUserHostedClientMessage = async (channelId, socket, data) => {
3029
2396
  const active = activeUserHostedClient(channelId, socket);
@@ -3037,45 +2404,61 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3037
2404
  closeSocket(socket, 1011, "User-hosted workspace is offline");
3038
2405
  return;
3039
2406
  }
3040
- const frame = socketFrame(data);
2407
+ const frame = await socketFrame(data);
3041
2408
  if (!frame) return;
3042
2409
  const sent = sendSocket(active.tunnel.socket, JSON.stringify({
3043
2410
  type: "ws.frame",
3044
- protocol: TUNNEL_PROTOCOL_VERSION2,
2411
+ protocol: TUNNEL_PROTOCOL_VERSION,
3045
2412
  channel_id: channelId,
3046
2413
  ...frame
3047
2414
  }));
3048
2415
  if (!sent) closeSocket(socket, 1011, "User-hosted tunnel unavailable");
3049
2416
  };
2417
+ const applyRegistrationUpdate = async (tunnel, workspaceIds, token) => {
2418
+ const hostId = tunnel.hostId;
2419
+ const refuse = (code, reason) => {
2420
+ cleanupHostTunnel(hostId, tunnel.socket, reason);
2421
+ closeSocket(tunnel.socket, code, reason);
2422
+ };
2423
+ let claims;
2424
+ try {
2425
+ claims = await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
2426
+ } catch {
2427
+ if (hostTunnels.get(hostId) === tunnel) refuse(1008, "Host tunnel registration update denied");
2428
+ return;
2429
+ }
2430
+ if (hostTunnels.get(hostId) !== tunnel) return;
2431
+ if (hostTunnelIncumbentOutranks(tunnel.generation, claims.generation)) {
2432
+ refuse(1008, "Host tunnel registration update superseded");
2433
+ return;
2434
+ }
2435
+ const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, claims);
2436
+ if (hostTunnels.get(hostId) !== tunnel) return;
2437
+ if (!decision.ok) {
2438
+ refuse(decision.retryable ? 1012 : 1008, decision.retryable ? "Host generation check unavailable" : decision.reason);
2439
+ return;
2440
+ }
2441
+ tunnel.workspaceIds = workspaceIds;
2442
+ tunnel.enrollmentId = claims.enrollment_id;
2443
+ tunnel.generation = claims.generation;
2444
+ tunnel.socket.serializeAttachment?.(hostTunnelAttachment(tunnel));
2445
+ directory.registerHostTunnel({ hostId, workspaceIds });
2446
+ watchHostGeneration(tunnel);
2447
+ void scheduleHibernatedRevocationCheck();
2448
+ };
3050
2449
  const handleTunnelMessage = async (hostId, event) => {
3051
2450
  const row = parseTunnelMessageData(event.data);
3052
2451
  if (!row.ok) return;
3053
- const parsed = validateTunnelMessage2(row.value);
2452
+ const parsed = validateTunnelMessage(row.value);
3054
2453
  const tunnel = hostTunnels.get(hostId);
3055
2454
  if (!parsed.ok || !tunnel) return;
3056
2455
  directory.recordPong(tunnel.hostId);
3057
2456
  if (parsed.message.type === "ping") {
3058
- tunnel.socket.send?.(JSON.stringify(makeTunnelPong2(parsed.message)));
2457
+ tunnel.socket.send?.(JSON.stringify(makeTunnelPong(parsed.message)));
3059
2458
  return;
3060
2459
  }
3061
2460
  if (parsed.message.type === "host.registration.update") {
3062
- const update = parsed.message;
3063
- const workspaceIds = [...new Set(update.workspace_ids)];
3064
- try {
3065
- await verifyHostTunnelToken(update.token, options.runtimeAccessKey, { hostId, workspaceIds });
3066
- } catch {
3067
- closeSocket(tunnel.socket, 1008, "Host tunnel registration update denied");
3068
- return;
3069
- }
3070
- if (hostTunnels.get(hostId) !== tunnel) return;
3071
- tunnel.workspaceIds = workspaceIds;
3072
- tunnel.socket.serializeAttachment?.({
3073
- kind: "host-tunnel",
3074
- hostId,
3075
- workspaceIds,
3076
- connectedAt: tunnel.connectedAt
3077
- });
3078
- directory.registerHostTunnel({ hostId, workspaceIds });
2461
+ await applyRegistrationUpdate(tunnel, [...new Set(parsed.message.workspace_ids)], parsed.message.token);
3079
2462
  return;
3080
2463
  }
3081
2464
  if (parsed.message.type === "error" && parsed.message.request_id) {
@@ -3145,7 +2528,9 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3145
2528
  closeSocket(channel.socket, 1008, token.reason);
3146
2529
  return;
3147
2530
  }
3148
- sendSocket(channel.socket, tunnelFrame(message));
2531
+ if (!sendSocket(channel.socket, tunnelFrame(message))) {
2532
+ dropUserHostedChannel(tunnel, message.channel_id, channel, 1013, "Client WebSocket delivery failed");
2533
+ }
3149
2534
  return;
3150
2535
  }
3151
2536
  if (parsed.message.type === "ws.close") {
@@ -3154,7 +2539,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3154
2539
  if (!channel) return;
3155
2540
  tunnel.channels.delete(message.channel_id);
3156
2541
  clients.delete(channel);
3157
- closeSocket(channel.socket, safeCloseCode2(message.code), message.reason ?? "");
2542
+ closeSocket(channel.socket, safeCloseCode(message.code), message.reason ?? "");
3158
2543
  return;
3159
2544
  }
3160
2545
  if (parsed.message.type === "error" && parsed.message.channel_id) {
@@ -3190,17 +2575,17 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3190
2575
  clients.delete(active);
3191
2576
  sendSocket(tunnel.socket, JSON.stringify({
3192
2577
  type: "ws.close",
3193
- protocol: TUNNEL_PROTOCOL_VERSION2,
2578
+ protocol: TUNNEL_PROTOCOL_VERSION,
3194
2579
  channel_id: channelId,
3195
- code: safeCloseCode2(event?.code, 1e3),
2580
+ code: safeCloseCode(event?.code, 1e3),
3196
2581
  reason: event?.reason ?? "Client WebSocket closed"
3197
2582
  }));
3198
2583
  return;
3199
2584
  }
3200
2585
  };
3201
2586
  const admitHostTunnel = async (request, roomWorkspaceId, url) => {
3202
- if (!websocketRequest2(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
3203
- const hostId = clean(hostIdFromTunnelPath2(url.pathname));
2587
+ if (!websocketRequest(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
2588
+ const hostId = trimToUndefined(hostIdFromTunnelPath(url.pathname));
3204
2589
  if (!hostId) return json("host_tunnel_host_required", "Host tunnel id is required", 400);
3205
2590
  const workspaceIds = workspaceIdsFromSearch(url);
3206
2591
  if (!workspaceIds.includes(roomWorkspaceId)) {
@@ -3208,38 +2593,49 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3208
2593
  }
3209
2594
  const token = bearerToken(request.headers.get("authorization"));
3210
2595
  if (!token) return json("host_tunnel_token_required", "Host Tunnel Token is required", 401);
2596
+ let claims;
3211
2597
  try {
3212
- await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
2598
+ claims = await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
3213
2599
  } catch (err) {
3214
2600
  if (err instanceof WorkspaceRelayAuthError) {
3215
2601
  return json(err.code, "Host Tunnel Token was denied", 403);
3216
2602
  }
3217
2603
  throw err;
3218
2604
  }
3219
- const connectedAt = options.now?.() ?? Date.now();
3220
- const pair = acceptSocket({
3221
- kind: "host-tunnel",
3222
- hostId,
3223
- workspaceIds,
3224
- connectedAt
3225
- });
2605
+ const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, claims);
2606
+ if (!decision.ok) return json(decision.code, decision.reason, decision.retryable ? 503 : 403);
3226
2607
  const previous = hostTunnels.get(hostId);
3227
- if (previous) {
3228
- cleanupTunnelResources(previous, "Host tunnel replaced");
3229
- closeSocket(previous.socket, 1012, "Host tunnel replaced");
2608
+ if (previous && hostTunnelIncumbentOutranks(previous.generation, claims.generation)) {
2609
+ return json("host_generation_superseded", "Host tunnel generation was superseded", 403);
3230
2610
  }
3231
- hostTunnels.set(hostId, {
2611
+ const connectedAt = options.now?.() ?? Date.now();
2612
+ const fence = {
2613
+ ...claims.enrollment_id ? { enrollmentId: claims.enrollment_id } : {},
2614
+ ...claims.generation !== void 0 ? { generation: claims.generation } : {}
2615
+ };
2616
+ const pair = acceptSocket({ kind: "host-tunnel", hostId, workspaceIds, connectedAt, ...fence });
2617
+ const tunnel = {
3232
2618
  hostId,
3233
2619
  workspaceIds,
3234
2620
  connectedAt,
2621
+ ...fence,
2622
+ generationCheckFailures: 0,
3235
2623
  socket: pair.server,
3236
2624
  pending: /* @__PURE__ */ new Map(),
3237
2625
  channels: /* @__PURE__ */ new Map()
3238
- });
2626
+ };
2627
+ if (previous) {
2628
+ cleanupTunnelResources(previous, "Host tunnel replaced");
2629
+ closeSocket(previous.socket, 1012, "Host tunnel replaced");
2630
+ }
2631
+ hostTunnels.set(hostId, tunnel);
3239
2632
  directory.registerHostTunnel({ hostId, workspaceIds });
2633
+ watchHostGeneration(tunnel);
2634
+ void scheduleHibernatedRevocationCheck();
3240
2635
  if (!hibernation) {
3241
2636
  pair.server.addEventListener?.("message", (event) => {
3242
- if (event && event.data !== void 0) void handleTunnelMessage(hostId, { data: event.data });
2637
+ if (!event || event.data === void 0) return;
2638
+ void handleTunnelMessage(hostId, { data: event.data }).catch((err) => reportFrameHandlerFailure(pair.server, err));
3243
2639
  });
3244
2640
  pair.server.addEventListener?.("close", () => cleanupHostTunnel(hostId, pair.server, "User-hosted tunnel disconnected"));
3245
2641
  pair.server.addEventListener?.("error", () => cleanupHostTunnel(hostId, pair.server, "User-hosted tunnel disconnected"));
@@ -3247,14 +2643,14 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3247
2643
  return upgradeResponse(pair.client, request);
3248
2644
  };
3249
2645
  const admitClient = async (request, workspaceId) => {
3250
- if (!websocketRequest2(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
2646
+ if (!websocketRequest(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
3251
2647
  const authorized = await authorizeWorkspaceRelayRequest(relayOptions, request, workspaceId);
3252
2648
  if (!authorized.ok) return authorized.response;
3253
- if (authorized.request.target.access !== "cloud") return admitUserHostedClient(request, authorized.request);
2649
+ if (isUserHostedTarget(authorized.request.target)) return admitUserHostedClient(request, authorized.request);
3254
2650
  return await admitCloudClient(request, authorized.request);
3255
2651
  };
3256
2652
  const admitCloudClient = async (request, authorized) => {
3257
- const trace = relayWebSocketTraceEnabled2(request) ? { upstreamStartedAt: performance.now(), queueTimes: [], emitted: false } : void 0;
2653
+ const trace = relayWebSocketTraceEnabled(request) ? { upstreamStartedAt: performance.now(), queueTimes: [], emitted: false } : void 0;
3258
2654
  let upstream;
3259
2655
  try {
3260
2656
  upstream = await connectWebSocket(
@@ -3282,14 +2678,15 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3282
2678
  let closed = false;
3283
2679
  let upstreamOpen = upstream.readyState === void 0 || upstream.readyState === 1;
3284
2680
  const queue = [];
2681
+ let queuedBytes = 0;
3285
2682
  const emitTrace = (openedAt) => {
3286
2683
  if (!trace || trace.emitted) return;
3287
2684
  trace.emitted = true;
3288
2685
  sendSocket(pair.server, JSON.stringify({
3289
2686
  type: "relay.trace",
3290
- wsUpstreamOpenMs: roundedMs3(openedAt - trace.upstreamStartedAt),
2687
+ wsUpstreamOpenMs: roundedMs2(openedAt - trace.upstreamStartedAt),
3291
2688
  queuedFrames: trace.queueTimes.length,
3292
- maxQueuedDelayMs: roundedMs3(trace.queueTimes.reduce((max, at) => Math.max(max, openedAt - at), 0))
2689
+ maxQueuedDelayMs: roundedMs2(trace.queueTimes.reduce((max, at) => Math.max(max, openedAt - at), 0))
3293
2690
  }));
3294
2691
  };
3295
2692
  const activeTokenTimer = watchRuntimeAccessToken(client, (reason) => {
@@ -3300,18 +2697,19 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3300
2697
  closed = true;
3301
2698
  if (activeTokenTimer) clearInterval(activeTokenTimer);
3302
2699
  clients.delete(client);
3303
- closeSocket(upstream, safeCloseCode2(event?.code, 1e3), event?.reason ?? "Client WebSocket closed");
2700
+ closeSocket(upstream, safeCloseCode(event?.code, 1e3), event?.reason ?? "Client WebSocket closed");
3304
2701
  };
3305
2702
  const cleanupUpstream = (event) => {
3306
2703
  if (closed) return;
3307
2704
  closed = true;
3308
2705
  if (activeTokenTimer) clearInterval(activeTokenTimer);
3309
2706
  clients.delete(client);
3310
- closeSocket(pair.server, safeCloseCode2(event?.code), event?.reason ?? "");
2707
+ closeSocket(pair.server, safeCloseCode(event?.code), event?.reason ?? "");
3311
2708
  };
3312
2709
  const flushQueue = () => {
3313
2710
  upstreamOpen = true;
3314
2711
  if (trace) emitTrace(performance.now());
2712
+ queuedBytes = 0;
3315
2713
  for (const item of queue.splice(0)) {
3316
2714
  if (!sendSocket(upstream, item)) {
3317
2715
  closeSocket(pair.server, 1011, "Upstream WebSocket connection failed");
@@ -3319,28 +2717,31 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3319
2717
  }
3320
2718
  }
3321
2719
  };
3322
- pair.server.addEventListener?.("message", (event) => {
3323
- const payload = socketPayload(event?.data);
3324
- if (payload === void 0) return;
2720
+ const deliverClientFrame = orderedFrameDelivery((payload) => {
3325
2721
  if (!upstreamOpen) {
3326
- if (queue.length >= (options.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT2)) {
2722
+ const frameBytes = preOpenFrameBytes(payload);
2723
+ if (queue.length >= (options.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT) && queuedBytes + frameBytes > (options.upstreamWebSocketPreOpenQueueMaxBytes ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT)) {
3327
2724
  closeSocket(pair.server, 1011, "Upstream WebSocket queue limit exceeded");
3328
2725
  cleanupClient({ code: 1011, reason: "Upstream WebSocket queue limit exceeded" });
3329
2726
  return;
3330
2727
  }
2728
+ queuedBytes += frameBytes;
3331
2729
  if (trace) trace.queueTimes.push(performance.now());
3332
2730
  queue.push(payload);
3333
2731
  return;
3334
2732
  }
3335
2733
  if (!sendSocket(upstream, payload)) closeSocket(pair.server, 1011, "Upstream WebSocket connection failed");
3336
2734
  });
2735
+ pair.server.addEventListener?.("message", (event) => deliverClientFrame(event?.data));
3337
2736
  pair.server.addEventListener?.("close", cleanupClient);
3338
2737
  pair.server.addEventListener?.("error", cleanupClient);
3339
2738
  upstream.addEventListener?.("open", flushQueue);
3340
- upstream.addEventListener?.("message", (event) => {
3341
- const payload = socketPayload(event?.data);
3342
- if (payload !== void 0) sendSocket(pair.server, payload);
2739
+ const deliverUpstreamFrame = orderedFrameDelivery((payload) => {
2740
+ if (!sendSocket(pair.server, payload)) {
2741
+ closeSocket(pair.server, 1011, "Client WebSocket send failed");
2742
+ }
3343
2743
  });
2744
+ upstream.addEventListener?.("message", (event) => deliverUpstreamFrame(event?.data));
3344
2745
  upstream.addEventListener?.("close", cleanupUpstream);
3345
2746
  upstream.addEventListener?.("error", () => cleanupUpstream({ code: 1011, reason: "Upstream WebSocket connection failed" }));
3346
2747
  if (trace && upstreamOpen) emitTrace(upstreamConnectedAt);
@@ -3376,6 +2777,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3376
2777
  const activeTargetTimer = hibernation ? void 0 : watchUserHostedTarget(client, (reason) => {
3377
2778
  closeSocket(pair.server, 1011, reason);
3378
2779
  });
2780
+ if (hibernation) void scheduleHibernatedRevocationCheck();
3379
2781
  if (!hibernation) {
3380
2782
  const cleanup = (event) => {
3381
2783
  if (activeTokenTimer) clearInterval(activeTokenTimer);
@@ -3384,14 +2786,14 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3384
2786
  };
3385
2787
  pair.server.addEventListener?.("message", (event) => {
3386
2788
  if (!event) return;
3387
- void handleUserHostedClientMessage(channelId, pair.server, event.data);
2789
+ void handleUserHostedClientMessage(channelId, pair.server, event.data).catch((err) => reportFrameHandlerFailure(pair.server, err));
3388
2790
  });
3389
2791
  pair.server.addEventListener?.("close", cleanup);
3390
2792
  pair.server.addEventListener?.("error", cleanup);
3391
2793
  }
3392
- sendSocket(tunnel.socket, JSON.stringify({
2794
+ const opened = sendSocket(tunnel.socket, JSON.stringify({
3393
2795
  type: "ws.open",
3394
- protocol: TUNNEL_PROTOCOL_VERSION2,
2796
+ protocol: TUNNEL_PROTOCOL_VERSION,
3395
2797
  channel_id: channelId,
3396
2798
  workspace_id: authorized.target.workspaceId,
3397
2799
  path: authorized.path,
@@ -3402,6 +2804,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3402
2804
  { userHosted: true }
3403
2805
  ))
3404
2806
  }));
2807
+ if (!opened) {
2808
+ tunnel.channels.delete(channelId);
2809
+ clients.delete(client);
2810
+ closeSocket(pair.server, 1011, "User-hosted tunnel unavailable");
2811
+ return json("user_hosted_app_offline", "User-hosted workspace is offline", 503);
2812
+ }
3405
2813
  return upgradeResponse(pair.client, request);
3406
2814
  };
3407
2815
  const forwardCloudHttp = async (request, workspaceId, trace) => {
@@ -3413,7 +2821,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3413
2821
  })
3414
2822
  );
3415
2823
  if (!authorized.ok) return authorized.response;
3416
- if (authorized.request.target.access !== "cloud") {
2824
+ if (isUserHostedTarget(authorized.request.target)) {
3417
2825
  trace && (trace.routeKind = "user-hosted-http");
3418
2826
  return forwardUserHostedHttp(request, authorized.request, trace);
3419
2827
  }
@@ -3463,7 +2871,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3463
2871
  const init = workspaceRelayForwardRequestInit(request, authorized.relayHostToken, authorized.target.workspaceId, {
3464
2872
  userHosted: true
3465
2873
  });
3466
- const bodyMaxBytes = options.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT2;
2874
+ const bodyMaxBytes = options.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT;
3467
2875
  const contentLength = Number(request.headers.get("content-length") ?? Number.NaN);
3468
2876
  if (Number.isFinite(contentLength) && contentLength > bodyMaxBytes) {
3469
2877
  return json("request_body_too_large", "User-hosted request body exceeds the relay limit", 413);
@@ -3478,7 +2886,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3478
2886
  tunnel.pending.delete(requestId);
3479
2887
  tunnel.socket.send?.(JSON.stringify({
3480
2888
  type: "http.response.flow",
3481
- protocol: TUNNEL_PROTOCOL_VERSION2,
2889
+ protocol: TUNNEL_PROTOCOL_VERSION,
3482
2890
  request_id: requestId,
3483
2891
  paused: false,
3484
2892
  reason: "closed"
@@ -3495,7 +2903,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3495
2903
  });
3496
2904
  tunnel.socket.send?.(JSON.stringify({
3497
2905
  type: "http.request",
3498
- protocol: TUNNEL_PROTOCOL_VERSION2,
2906
+ protocol: TUNNEL_PROTOCOL_VERSION,
3499
2907
  request_id: requestId,
3500
2908
  workspace_id: authorized.target.workspaceId,
3501
2909
  method: request.method,
@@ -3542,7 +2950,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3542
2950
  if (!roomWorkspaceId) return json("relay_workspace_required", "Workspace Relay requests must include a workspace id", 400);
3543
2951
  if (url.pathname.startsWith("/host-tunnels/")) return admitHostTunnel(request, roomWorkspaceId, url);
3544
2952
  if (url.pathname.startsWith("/workspaces/")) {
3545
- if (websocketRequest2(request)) return admitClient(request, roomWorkspaceId);
2953
+ if (websocketRequest(request)) return admitClient(request, roomWorkspaceId);
3546
2954
  const trace = traceRequest(request, "workspace-http", options);
3547
2955
  const roomTotalStartedAt = performance.now();
3548
2956
  const nextRequest = tracedRequest(request, trace);
@@ -3555,14 +2963,29 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3555
2963
  return json("relay_route_not_found", "Workspace Relay route not found", 404);
3556
2964
  },
3557
2965
  async webSocketMessage(socket, message) {
3558
- ensureHibernatedSocketsRebuilt();
3559
- const attachment = socketAttachment(socket);
3560
- if (!attachment) return;
3561
- if (attachment.kind === "host-tunnel") {
3562
- await handleTunnelMessage(attachment.hostId, { data: message });
3563
- return;
2966
+ try {
2967
+ ensureHibernatedSocketsRebuilt();
2968
+ const attachment = socketAttachment(socket);
2969
+ if (!attachment) return;
2970
+ if (attachment.kind === "host-tunnel") {
2971
+ await handleTunnelMessage(attachment.hostId, { data: message });
2972
+ return;
2973
+ }
2974
+ await handleUserHostedClientMessage(attachment.channelId, socket, message);
2975
+ } catch (err) {
2976
+ reportFrameHandlerFailure(socket, err);
2977
+ }
2978
+ },
2979
+ /**
2980
+ * Durable Object alarm entry point. Runs the hibernation-safe revocation
2981
+ * sweep. The Worker must forward `alarm()` here.
2982
+ */
2983
+ async alarm() {
2984
+ try {
2985
+ await runHibernatedRevocationCheck();
2986
+ } catch (err) {
2987
+ console.error(`[workspace-relay] revocation alarm failed: ${err instanceof Error ? err.message : String(err)}`);
3564
2988
  }
3565
- await handleUserHostedClientMessage(attachment.channelId, socket, message);
3566
2989
  },
3567
2990
  webSocketClose(socket, code, reason) {
3568
2991
  ensureHibernatedSocketsRebuilt();
@@ -3599,30 +3022,47 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3599
3022
  export {
3600
3023
  DEFAULT_RELAY_APP_ORIGINS,
3601
3024
  DEFAULT_RELAY_LOCATION_HINT,
3025
+ RELAY_ALLOWED_REQUEST_HEADERS,
3026
+ RELAY_ALLOWED_REQUEST_HEADER_LIST,
3027
+ RELAY_LOCATION_HINTS,
3028
+ RELAY_REGION_HEADER,
3602
3029
  WorkspaceRelayAuthError,
3603
- __slowConsumerInternalsForTest,
3604
3030
  authorizeWorkspaceRelayRequest,
3031
+ bytesToBase64,
3032
+ checkHostTunnelGeneration,
3033
+ createCachedHostGenerationClient,
3605
3034
  createCachedRevocationClient,
3606
3035
  createCachedTargetClient,
3036
+ createHostGenerationResolverLookup,
3607
3037
  createOriginMatcher,
3608
3038
  createWorkspaceRelay,
3609
- createWorkspaceRelayBun,
3610
3039
  createWorkspaceRelayDirectory,
3611
3040
  createWorkspaceRelayDurableObjectGateway,
3612
3041
  createWorkspaceRelayDurableObjectRoom,
3613
3042
  createWorkspaceRelayTrace,
3043
+ deriveRelayHostKid,
3044
+ deriveRelayHostPublicKey,
3614
3045
  disposeAuditSampler,
3615
3046
  disposeWorkspaceRelayDirectory,
3616
3047
  forwardWorkspaceRelayRequest,
3617
3048
  getAuditSuppressedCount,
3049
+ hostTunnelIncumbentOutranks,
3618
3050
  hostTunnelTokenAudience,
3051
+ isHostGeneration,
3619
3052
  isRelayClaimPair,
3620
3053
  mintHostTunnelToken,
3621
3054
  mintRelayHostToken,
3622
3055
  mintRuntimeAccessToken,
3623
3056
  parseAllowedOrigins,
3057
+ parseHostGenerationResult,
3058
+ parseRuntimeAccessTokenActiveResult,
3059
+ parseWorkspaceRelayTarget,
3060
+ relayFrameDiagnostics,
3624
3061
  relayHostTokenAudience,
3625
3062
  relayHostTokenIssuer,
3063
+ relayLocationHint,
3064
+ relayRequestCountry,
3065
+ relayRequestRegion,
3626
3066
  runtimeAccessTokenAudience,
3627
3067
  runtimeAccessTokenIssuer,
3628
3068
  setWorkspaceRelayAllowedOrigins,