@claxedo/workspace-relay 0.7.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,7 +326,7 @@ 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 = [
@@ -273,22 +368,76 @@ function createOriginMatcher(patterns) {
273
368
  };
274
369
  }
275
370
 
276
- // src/http.ts
277
- function bearerToken(header) {
278
- if (!header) return;
279
- const match = /^Bearer\s+(.+)$/i.exec(header.trim());
280
- return match?.[1]?.trim() || void 0;
371
+ // src/user-hosted-forwarding.ts
372
+ function isUserHostedTarget(target) {
373
+ return target.access === "user-hosted";
281
374
  }
282
- 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;
283
385
  return {
284
- error: {
285
- code,
286
- message
287
- }
386
+ workspaceId,
387
+ hostId,
388
+ baseUrl,
389
+ ...upstreamHeaders ? { upstreamHeaders } : {},
390
+ ...pair
288
391
  };
289
392
  }
290
-
291
- // 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
+ }
292
441
  var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
293
442
  var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
294
443
  var RESOLVER_CACHE_MAX_ENTRIES = 8192;
@@ -328,6 +477,71 @@ function createCachedRevocationClient(inner, options = {}) {
328
477
  return await promise;
329
478
  };
330
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
+ }
331
545
  function createCachedTargetClient(inner, options = {}) {
332
546
  const ttlMs = options.ttlMs ?? 5e3;
333
547
  const now = options.now ?? Date.now;
@@ -389,27 +603,27 @@ function isDangerousInboundHeader(name) {
389
603
  return false;
390
604
  }
391
605
  function forwardHeaders(input, relayHostToken, workspaceId, options = {}) {
392
- const headers2 = new Headers(input);
393
- headers2.delete("host");
606
+ const headers = new Headers(input);
607
+ headers.delete("host");
394
608
  const inboundNames = /* @__PURE__ */ new Set();
395
- headers2.forEach((_value, name) => inboundNames.add(name));
609
+ headers.forEach((_value, name) => inboundNames.add(name));
396
610
  for (const name of inboundNames) {
397
- if (isDangerousInboundHeader(name)) headers2.delete(name);
398
- }
399
- if (options.userHosted) headers2.delete("cookie");
400
- headers2.set("accept-encoding", "identity");
401
- headers2.delete("authorization");
402
- headers2.delete("Authorization");
403
- headers2.set("Authorization", `Bearer ${relayHostToken}`);
404
- headers2.set("x-workspace-id", workspaceId);
405
- headers2.set("X-Daytona-Skip-Preview-Warning", "true");
406
- 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");
407
621
  for (const [name, value] of Object.entries(options.upstreamHeaders ?? {})) {
408
622
  const trimmed = value.trim();
409
- if (trimmed) headers2.set(name, trimmed);
623
+ if (trimmed) headers.set(name, trimmed);
410
624
  }
411
- headers2.set("x-forwarded-by", "workspace-relay");
412
- return headers2;
625
+ headers.set("x-forwarded-by", "workspace-relay");
626
+ return headers;
413
627
  }
414
628
  function targetUrl(target, path, search) {
415
629
  const url = new URL(path.replace(/^\/+/, ""), target.baseUrl.endsWith("/") ? target.baseUrl : `${target.baseUrl}/`);
@@ -418,11 +632,9 @@ function targetUrl(target, path, search) {
418
632
  }
419
633
  var RELAY_VIEWER_DENIED_PATH = /^\/api\/wr\/pty(?:\/|$)/;
420
634
  function relayRuntimePath(request) {
421
- return new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/");
422
- }
423
- function relayAuthorizationPath(path) {
424
635
  try {
425
- 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;
426
638
  } catch {
427
639
  return void 0;
428
640
  }
@@ -430,8 +642,7 @@ function relayAuthorizationPath(path) {
430
642
  function roleAllowsRelayRequest(role, method, path) {
431
643
  if (role === "owner" || role === "admin" || role === "editor") return true;
432
644
  if (role === "viewer") {
433
- const authorizationPath = relayAuthorizationPath(path);
434
- if (!authorizationPath || RELAY_VIEWER_DENIED_PATH.test(authorizationPath)) return false;
645
+ if (RELAY_VIEWER_DENIED_PATH.test(path)) return false;
435
646
  return method === "GET" || method === "HEAD" || method === "OPTIONS";
436
647
  }
437
648
  return false;
@@ -445,7 +656,9 @@ function relayHostTokenCacheTtlMs(options, claims) {
445
656
  function relayHostTokenCacheKey(options, claims, target) {
446
657
  return [
447
658
  claims.jti,
448
- claims.sub,
659
+ claims.principal_kind,
660
+ claims.actor_id,
661
+ claims.actor_kind,
449
662
  claims.org_id,
450
663
  claims.role,
451
664
  claims.workspace_id,
@@ -550,7 +763,15 @@ async function relayHostTokenFor(options, claims, target, trace) {
550
763
  try {
551
764
  const token = await trace.span("rht-mint", async () => {
552
765
  promise = mintRelayHostToken({
553
- 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
+ } : {},
554
775
  orgId: claims.org_id,
555
776
  role: claims.role,
556
777
  ...target,
@@ -574,7 +795,15 @@ async function uncachedRelayHostTokenFor(options, claims, target, trace) {
574
795
  return await trace.span(
575
796
  "rht-mint",
576
797
  async () => await mintRelayHostToken({
577
- 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
+ } : {},
578
807
  orgId: claims.org_id,
579
808
  role: claims.role,
580
809
  ...target,
@@ -680,13 +909,13 @@ function workspaceRelayServerTiming(trace) {
680
909
  function workspaceRelayTimingResponse(response, trace) {
681
910
  const value = workspaceRelayServerTiming(trace);
682
911
  if (!value) return response;
683
- const headers2 = new Headers(response.headers);
684
- const prev = headers2.get("server-timing");
685
- 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);
686
915
  return new Response(response.body, {
687
916
  status: response.status,
688
917
  statusText: response.statusText,
689
- headers: headers2
918
+ headers
690
919
  });
691
920
  }
692
921
  async function audit(options, event) {
@@ -713,13 +942,30 @@ function originMatcherFor(options) {
713
942
  }
714
943
  return matcher;
715
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(", ");
716
962
  function denyCorsHeaders(request, originAllowed = defaultOriginMatcher) {
717
963
  const origin = request.headers.get("origin");
718
964
  if (!origin) return void 0;
719
965
  if (!originAllowed(origin)) return void 0;
720
966
  return {
721
967
  "access-control-allow-origin": origin,
722
- "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,
723
969
  "access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"
724
970
  };
725
971
  }
@@ -729,7 +975,9 @@ async function deny(options, input) {
729
975
  action: "relay.request.denied",
730
976
  result: "deny",
731
977
  reason: input.code,
732
- subject: input.claims?.sub,
978
+ actorId: input.claims?.actor_id,
979
+ actorKind: input.claims?.actor_kind,
980
+ principalKind: input.claims?.principal_kind,
733
981
  ...input.claims ? { role: input.claims.role } : {},
734
982
  workspaceId: input.claims?.workspace_id,
735
983
  hostId: input.claims?.host_id,
@@ -778,7 +1026,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
778
1026
  };
779
1027
  }
780
1028
  const path = relayRuntimePath(request);
781
- if (!roleAllowsRelayRequest(claims.role, request.method, path)) {
1029
+ if (!path || !roleAllowsRelayRequest(claims.role, request.method, path)) {
782
1030
  return {
783
1031
  ok: false,
784
1032
  code: "relay_role_denied",
@@ -805,7 +1053,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
805
1053
  })
806
1054
  };
807
1055
  }
808
- 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 })) {
809
1057
  return {
810
1058
  ok: false,
811
1059
  code: "user_hosted_app_offline",
@@ -821,7 +1069,9 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
821
1069
  await span("audit", async () => await audit(options, {
822
1070
  action: "relay.request.accepted",
823
1071
  result: "allow",
824
- subject: claims.sub,
1072
+ actorId: claims.actor_id,
1073
+ actorKind: claims.actor_kind,
1074
+ principalKind: claims.principal_kind,
825
1075
  role: claims.role,
826
1076
  workspaceId: claims.workspace_id,
827
1077
  hostId: claims.host_id,
@@ -864,20 +1114,20 @@ async function forwardWorkspaceRelayRequest(request, target, relayHostToken, pat
864
1114
  upstreamHeaders: target.upstreamHeaders
865
1115
  })
866
1116
  ));
867
- const headers2 = new Headers(upstream.headers);
868
- headers2.delete("access-control-allow-origin");
869
- headers2.delete("access-control-allow-credentials");
870
- headers2.delete("access-control-allow-headers");
871
- headers2.delete("access-control-allow-methods");
872
- headers2.delete("access-control-expose-headers");
873
- 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");
874
1124
  for (const [key, value] of Object.entries(denyCorsHeaders(request, originAllowed) ?? {})) {
875
- headers2.set(key, value);
1125
+ headers.set(key, value);
876
1126
  }
877
1127
  return new Response(upstream.body, {
878
1128
  status: upstream.status,
879
1129
  statusText: upstream.statusText,
880
- headers: headers2
1130
+ headers
881
1131
  });
882
1132
  } catch (err) {
883
1133
  const aborted = err instanceof Error && err.name === "AbortError";
@@ -904,7 +1154,7 @@ function isLoopbackAddress(address) {
904
1154
  if (!address) return false;
905
1155
  const trimmed = address.trim();
906
1156
  if (LOOPBACK_REMOTES.has(trimmed)) return true;
907
- if (/^127\./.test(trimmed)) return true;
1157
+ if (trimmed.startsWith("127.")) return true;
908
1158
  if (/^::ffff:127\./i.test(trimmed)) return true;
909
1159
  return false;
910
1160
  }
@@ -960,25 +1210,26 @@ function buildMetricsBody(options) {
960
1210
  return body;
961
1211
  }
962
1212
  function createWorkspaceRelay(options) {
963
- const app = new Hono();
1213
+ const app = Object.assign(new Hono(), {
1214
+ disposeAuditSampler: () => disposeAuditSampler(options)
1215
+ });
964
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);
1222
+ }
1223
+ });
965
1224
  app.use("*", cors({
966
1225
  origin: (origin) => {
967
1226
  if (!origin) return void 0;
968
1227
  return originAllowed(origin) ? origin : void 0;
969
1228
  },
970
- allowHeaders: [
971
- "Accept",
972
- "Authorization",
973
- "Content-Type",
974
- "X-Daytona-Skip-Preview-Warning",
975
- "X-Workspace-Id",
976
- "X-OpenCode-Directory",
977
- "X-Claxedo-Runner",
978
- "X-Claxedo-Model",
979
- "X-Claxedo-Draft-Id",
980
- "X-Claxedo-Binary"
981
- ],
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],
982
1233
  allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
983
1234
  }));
984
1235
  app.get("/health", (c) => {
@@ -1006,7 +1257,7 @@ function createWorkspaceRelay(options) {
1006
1257
  let keys;
1007
1258
  try {
1008
1259
  keys = await Promise.all(sources.map(async (source) => {
1009
- const jwk = await exportJWK(source.publicKey);
1260
+ const jwk = await exportJWK2(source.publicKey);
1010
1261
  return {
1011
1262
  ...jwk,
1012
1263
  kid: source.kid,
@@ -1029,7 +1280,6 @@ function createWorkspaceRelay(options) {
1029
1280
  c.header("cache-control", "public, max-age=300");
1030
1281
  return c.json({ keys });
1031
1282
  });
1032
- app.disposeAuditSampler = () => disposeAuditSampler(options);
1033
1283
  app.get("/metrics", (c) => {
1034
1284
  const authResult = checkMetricsAuth(options, c.req.raw);
1035
1285
  if (!authResult.ok) {
@@ -1067,1209 +1317,33 @@ function createWorkspaceRelay(options) {
1067
1317
  return app;
1068
1318
  }
1069
1319
 
1070
- // src/bun.ts
1071
- var TUNNEL_PENDING_HTTP_CAP = 32;
1072
- var TUNNEL_CHANNEL_CAP = 16;
1073
- var HOST_TUNNEL_REGISTRATION_RECONNECT_CAP = 5;
1074
- var HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS = 6e4;
1075
- var WS_MAX_PAYLOAD_LENGTH_BYTES = 16 * 1024 * 1024;
1076
- var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1077
- var TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT = 3e4;
1078
- var DIRECT_HTTP_TIMEOUT_MS_DEFAULT = 3e4;
1079
- var UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT = 1e4;
1080
- var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
1081
- var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
1082
- var WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
1083
- var relayBufferedBytes = (socket) => {
1084
- const method = socket.getBufferedAmount;
1085
- if (typeof method === "function") {
1086
- const measured = method.call(socket);
1087
- if (typeof measured === "number" && Number.isFinite(measured)) return measured;
1088
- return void 0;
1089
- }
1090
- const property = socket.bufferedAmount;
1091
- return typeof property === "number" && Number.isFinite(property) ? property : void 0;
1092
- };
1093
- var relayOverBackpressureLimit = (socket, limitBytes) => {
1094
- const queued = relayBufferedBytes(socket);
1095
- return queued !== void 0 && queued > limitBytes;
1096
- };
1097
- var HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT = 2;
1098
- var TUNNEL_MESSAGE_BUFFER_CAP_BYTES = 4 * 1024 * 1024;
1099
- var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
1100
- var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
1101
- var HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT = 250;
1102
- function createFragmentationStats() {
1103
- return {
1104
- fragmentsBuffered: 0,
1105
- oversizedClosed: 0
1106
- };
1107
- }
1108
- function createSlowConsumerStats() {
1109
- return {
1110
- overflowEvents: 0,
1111
- timerFired: 0,
1112
- droppedRequests: 0
1113
- };
1114
- }
1115
- function readFragmentationStats(stats) {
1116
- return {
1117
- fragmentsBuffered: stats.fragmentsBuffered,
1118
- oversizedClosed: stats.oversizedClosed
1119
- };
1120
- }
1121
- function resetFragmentationStats(stats) {
1122
- stats.fragmentsBuffered = 0;
1123
- stats.oversizedClosed = 0;
1124
- }
1125
- function readSlowConsumerStats(stats) {
1126
- return { ...stats };
1127
- }
1128
- function resetSlowConsumerStats(stats) {
1129
- stats.overflowEvents = 0;
1130
- stats.timerFired = 0;
1131
- stats.droppedRequests = 0;
1132
- }
1133
- function createDirectHttpLimiter(limit) {
1134
- if (!Number.isInteger(limit) || !limit || limit <= 0) return void 0;
1135
- const max = limit;
1136
- let active = 0;
1137
- const queue = [];
1138
- function drain() {
1139
- if (active >= max) return;
1140
- const next = queue.shift();
1141
- if (!next) return;
1142
- active++;
1143
- next();
1144
- }
1145
- return {
1146
- async acquire() {
1147
- if (active < max) {
1148
- active++;
1149
- } else {
1150
- await new Promise((resolve) => queue.push(resolve));
1151
- }
1152
- let released = false;
1153
- return () => {
1154
- if (released) return;
1155
- released = true;
1156
- active = Math.max(0, active - 1);
1157
- drain();
1158
- };
1159
- }
1160
- };
1161
- }
1162
- function jsonError(code, message, status) {
1163
- return new Response(JSON.stringify({ error: { code, message } }), {
1164
- status,
1165
- headers: { "content-type": "application/json" }
1166
- });
1167
- }
1168
- function corsJsonError(request, code, message, status) {
1169
- const headers2 = relayCorsHeaders(request);
1170
- headers2.set("content-type", "application/json");
1171
- return new Response(JSON.stringify({ error: { code, message } }), { status, headers: headers2 });
1172
- }
1173
- function preOpenFrameBytes(message) {
1174
- return typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
1175
- }
1176
- function safeCloseCode(input, fallback = 1011) {
1177
- if (!Number.isInteger(input)) return fallback;
1178
- const code = input;
1179
- if (code === 1e3) return code;
1180
- if (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
1181
- if (code >= 3e3 && code <= 4999) return code;
1182
- return fallback;
1183
- }
1184
- function closeWebSocket(ws, code, reason, fallback = 1011) {
1185
- try {
1186
- ws.close(safeCloseCode(code, fallback), reason);
1187
- } catch {
1188
- try {
1189
- ws.close(fallback, reason);
1190
- } catch {
1191
- }
1192
- }
1193
- }
1194
- function upstreamCloseReason(event) {
1195
- if (event.reason.trim()) return event.reason;
1196
- if (event.code === 1e3) return "";
1197
- if (event.code === 1005 || event.code === 1006) return "Upstream WebSocket closed abnormally";
1198
- return `Upstream WebSocket closed unexpectedly with code ${event.code}`;
1199
- }
1200
- function workspaceIdFromPath(pathname) {
1201
- return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
1202
- }
1203
- function hostIdFromTunnelPath(pathname) {
1204
- return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
1205
- }
1206
- function websocketRequest(request) {
1207
- return request.headers.get("upgrade")?.toLowerCase() === "websocket";
1208
- }
1209
- function roundedMs2(input) {
1210
- return Math.round(input * 100) / 100;
1211
- }
1212
- function relayWebSocketTraceEnabled(request) {
1213
- return request.headers.get("x-claxedo-relay-ws-trace") === "1";
1214
- }
1215
- function sendRelayWebSocketTrace(ws) {
1216
- const trace = ws.data.trace;
1217
- if (!trace || trace.emitted) return;
1218
- trace.emitted = true;
1219
- ws.send(JSON.stringify({
1220
- type: "relay.trace",
1221
- wsUpstreamOpenMs: trace.upstreamOpenMs === void 0 ? void 0 : roundedMs2(trace.upstreamOpenMs),
1222
- queuedFrames: trace.queuedFrames,
1223
- maxQueuedDelayMs: roundedMs2(trace.maxQueuedDelayMs)
1224
- }));
1225
- }
1226
- function relayWebSocketPayload(input) {
1227
- if (typeof input === "string") return input;
1228
- if (input instanceof ArrayBuffer) return input;
1229
- if (ArrayBuffer.isView(input)) {
1230
- const copy = new Uint8Array(input.byteLength);
1231
- copy.set(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
1232
- return copy;
1233
- }
1234
- }
1235
- function headersRecord(headers2) {
1236
- const result = {};
1237
- headers2.forEach((value, key) => {
1238
- result[key] = value;
1239
- });
1240
- return result;
1241
- }
1242
- function headers(input) {
1243
- const result = new Headers();
1244
- for (const [key, value] of Object.entries(input)) {
1245
- result.set(key, value);
1246
- }
1247
- return result;
1248
- }
1249
- function isEventStream(input) {
1250
- return Object.entries(input).some(
1251
- ([key, value]) => key.toLowerCase() === "content-type" && value.toLowerCase().includes("text/event-stream")
1252
- );
1253
- }
1254
- var defaultRelayOriginMatcher = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
1255
- function allowedCorsOrigin(origin) {
1256
- if (!origin) return;
1257
- if (defaultRelayOriginMatcher(origin)) return origin;
1258
- }
1259
- function requireAllowedOrigin(request) {
1260
- const origin = request.headers.get("origin");
1261
- if (allowedCorsOrigin(origin)) return null;
1262
- return jsonError(
1263
- "origin_not_allowed",
1264
- "Origin is not in the allowlist",
1265
- 403
1266
- );
1267
- }
1268
- function relayCorsHeaders(request, input = new Headers()) {
1269
- const result = new Headers(input);
1270
- result.delete("access-control-allow-origin");
1271
- result.delete("access-control-allow-credentials");
1272
- result.delete("access-control-allow-headers");
1273
- result.delete("access-control-allow-methods");
1274
- result.delete("access-control-expose-headers");
1275
- result.delete("access-control-max-age");
1276
- const origin = allowedCorsOrigin(request.headers.get("origin"));
1277
- if (origin) {
1278
- result.set("access-control-allow-origin", origin);
1279
- 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");
1280
- result.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
1281
- }
1282
- return result;
1283
- }
1284
- function encoded(input) {
1285
- return Buffer.from(input).toString("base64");
1286
- }
1287
- function decoded(input) {
1288
- return new Uint8Array(Buffer.from(input, "base64"));
1289
- }
1290
- function encodedFrame(message) {
1291
- if (typeof message === "string") {
1292
- return {
1293
- binary: false,
1294
- data_base64: Buffer.from(message).toString("base64")
1295
- };
1296
- }
1297
- return {
1298
- binary: true,
1299
- data_base64: Buffer.from(message).toString("base64")
1300
- };
1301
- }
1302
- function decodedFrame(frame) {
1303
- const body = Buffer.from(frame.data_base64, "base64");
1304
- return frame.binary ? new Uint8Array(body) : body.toString("utf8");
1305
- }
1306
- function tunnelMessage(ws, input, stats) {
1307
- if (typeof input !== "string") return;
1308
- const combined = ws.data.messageBuffer.length > 0 ? ws.data.messageBuffer + input : input;
1309
- let parsed;
1310
- try {
1311
- parsed = JSON.parse(combined);
1312
- } catch {
1313
- if (combined.length > TUNNEL_MESSAGE_BUFFER_CAP_BYTES) {
1314
- stats.oversizedClosed += 1;
1315
- ws.data.messageBuffer = "";
1316
- ws.close(1009, "Tunnel message buffer exceeded 4 MB");
1317
- return;
1318
- }
1319
- stats.fragmentsBuffered += 1;
1320
- ws.data.messageBuffer = combined;
1321
- return;
1322
- }
1323
- ws.data.messageBuffer = "";
1324
- const validated = validateTunnelMessage(parsed);
1325
- if (validated.ok) return validated.message;
1326
- if (validated.reason === "protocol_mismatch") {
1327
- ws.close(1002, `Tunnel protocol mismatch: expected ${validated.expected_protocol}`);
1328
- }
1329
- return;
1330
- }
1331
- function sendTunnelPing(ws) {
1332
- if (ws.readyState !== WebSocket.OPEN) return;
1333
- ws.data.missedPongs += 1;
1334
- ws.send(JSON.stringify(makeTunnelPing()));
1335
- }
1336
- function clearPendingTimers(entry) {
1337
- clearTimeout(entry.timeout);
1338
- if (entry.slowConsumerTimeout) {
1339
- clearTimeout(entry.slowConsumerTimeout);
1340
- entry.slowConsumerTimeout = void 0;
1341
- }
1342
- }
1343
- function failPendingHttpResponse(input) {
1344
- clearPendingTimers(input.entry);
1345
- input.entry.pendingChunks.length = 0;
1346
- input.entry.bytesQueued = 0;
1347
- try {
1348
- input.entry.controller.error(input.error);
1349
- } catch {
1350
- }
1351
- if (!input.entry.responseStarted) {
1352
- if (input.response) {
1353
- input.entry.resolve(input.response);
1354
- return;
1355
- }
1356
- input.entry.reject(input.error);
1357
- }
1358
- }
1359
- function cleanupHostTunnelSocket(input) {
1360
- if (input.ws.data.heartbeat) {
1361
- clearInterval(input.ws.data.heartbeat);
1362
- input.ws.data.heartbeat = void 0;
1363
- }
1364
- for (const pending of input.ws.data.pending.values()) {
1365
- failPendingHttpResponse({
1366
- entry: pending,
1367
- 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),
1368
- error: new Error("User-hosted tunnel disconnected")
1369
- });
1370
- }
1371
- input.ws.data.pending.clear();
1372
- for (const channel of input.ws.data.channels.values()) {
1373
- if (input.closeChannels) closeWebSocket(channel, 1011, "User-hosted tunnel disconnected");
1374
- }
1375
- input.ws.data.channels.clear();
1376
- if (!input.disconnectDirectory) return;
1377
- if (input.hostTunnels.get(input.ws.data.hostId) === input.ws) {
1378
- input.hostTunnels.delete(input.ws.data.hostId);
1379
- input.options.directory?.disconnectHost(input.ws.data.hostId);
1380
- scheduleHostTunnelStateChange(input.hostTunnelStateDebounce, input.options, {
1381
- hostId: input.ws.data.hostId,
1382
- workspaceId: input.ws.data.workspaceIds[0],
1383
- path: `/host-tunnels/${input.ws.data.hostId}`,
1384
- state: "disconnected",
1385
- debounceMs: input.bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
1386
- });
1387
- }
1388
- }
1389
- async function audit2(options, input) {
1390
- await options.audit?.({
1391
- action: input.action,
1392
- result: "allow",
1393
- hostId: input.hostId,
1394
- workspaceId: input.workspaceId,
1395
- method: "WEBSOCKET",
1396
- path: input.path
1397
- });
1398
- }
1399
- function scheduleHostTunnelStateChange(state, options, input) {
1400
- const existing = state.get(input.hostId);
1401
- const entry = existing ?? {
1402
- intendedState: "disconnected",
1403
- lastWritten: "disconnected",
1404
- flushTimer: void 0,
1405
- pending: { workspaceId: input.workspaceId, path: input.path }
1406
- };
1407
- entry.intendedState = input.state;
1408
- entry.pending = { workspaceId: input.workspaceId, path: input.path };
1409
- if (entry.flushTimer) clearTimeout(entry.flushTimer);
1410
- entry.flushTimer = setTimeout(() => {
1411
- entry.flushTimer = void 0;
1412
- if (entry.intendedState === entry.lastWritten) {
1413
- if (entry.intendedState === "disconnected") {
1414
- state.delete(input.hostId);
1415
- }
1416
- return;
1417
- }
1418
- entry.lastWritten = entry.intendedState;
1419
- void audit2(options, {
1420
- action: entry.intendedState === "connected" ? "host_tunnel.connected" : "host_tunnel.disconnected",
1421
- hostId: input.hostId,
1422
- workspaceId: entry.pending.workspaceId,
1423
- path: entry.pending.path
1424
- });
1425
- }, input.debounceMs);
1426
- state.set(input.hostId, entry);
1427
- }
1428
- function drainPendingChunks(entry) {
1429
- while (entry.pendingChunks.length > 0) {
1430
- const desired = entry.controller.desiredSize;
1431
- if (desired !== null && desired <= 0) break;
1432
- const next = entry.pendingChunks.shift();
1433
- entry.bytesQueued -= next.byteLength;
1434
- try {
1435
- entry.controller.enqueue(next);
1436
- } catch {
1437
- entry.pendingChunks.length = 0;
1438
- entry.bytesQueued = 0;
1439
- break;
1440
- }
1441
- }
1442
- if (entry.pendingChunks.length === 0 && entry.slowConsumerTimeout) {
1443
- clearTimeout(entry.slowConsumerTimeout);
1444
- entry.slowConsumerTimeout = void 0;
1445
- }
1446
- }
1447
- function enqueueChunkWithBackpressure(input) {
1448
- const { entry, chunk } = input;
1449
- const desired = entry.controller.desiredSize;
1450
- const overflowing = desired !== null && desired <= 0;
1451
- if (overflowing || entry.pendingChunks.length > 0) {
1452
- entry.pendingChunks.push(chunk);
1453
- entry.bytesQueued += chunk.byteLength;
1454
- if (!entry.slowConsumerTimeout) {
1455
- input.slowConsumerStats.overflowEvents += 1;
1456
- entry.slowConsumerTimeout = setTimeout(() => {
1457
- input.slowConsumerStats.timerFired += 1;
1458
- const error = new Error("slow_consumer_timeout: downstream consumer did not drain in time");
1459
- try {
1460
- entry.controller.error(error);
1461
- } catch {
1462
- }
1463
- clearTimeout(entry.timeout);
1464
- entry.pendingChunks.length = 0;
1465
- entry.bytesQueued = 0;
1466
- if (!entry.responseStarted) {
1467
- entry.resolve(jsonError("slow_consumer_timeout", "Downstream consumer did not drain in time", 503));
1468
- }
1469
- input.ws.data.pending.delete(input.requestId);
1470
- input.slowConsumerStats.droppedRequests += 1;
1471
- }, input.slowConsumerTimeoutMs);
1472
- }
1473
- return;
1474
- }
1475
- try {
1476
- entry.controller.enqueue(chunk);
1477
- } catch {
1478
- }
1479
- }
1480
- var __slowConsumerInternalsForTest = {
1481
- enqueueChunkWithBackpressure,
1482
- createSlowConsumerStats
1483
- };
1484
- async function tunnelHttpRequest(input) {
1485
- if (input.ws.readyState !== WebSocket.OPEN) {
1486
- return new Response("User-hosted workspace is offline", { status: 503 });
1487
- }
1488
- if (input.ws.data.pending.size >= TUNNEL_PENDING_HTTP_CAP) {
1489
- return jsonError(
1490
- "too_many_in_flight",
1491
- "User-hosted tunnel has too many in-flight HTTP requests",
1492
- 429
1493
- );
1494
- }
1495
- const requestId = crypto.randomUUID();
1496
- let controller;
1497
- const stream = new ReadableStream({
1498
- start(next) {
1499
- controller = next;
1500
- },
1501
- pull() {
1502
- const entry = input.ws.data.pending.get(requestId);
1503
- if (!entry) return;
1504
- drainPendingChunks(entry);
1505
- },
1506
- cancel() {
1507
- const entry = input.ws.data.pending.get(requestId);
1508
- if (!entry) return;
1509
- input.ws.data.pending.delete(requestId);
1510
- clearPendingTimers(entry);
1511
- entry.pendingChunks.length = 0;
1512
- entry.bytesQueued = 0;
1513
- if (input.ws.readyState === WebSocket.OPEN) {
1514
- input.ws.send(JSON.stringify({
1515
- type: "http.response.flow",
1516
- protocol: TUNNEL_PROTOCOL_VERSION,
1517
- request_id: requestId,
1518
- paused: false,
1519
- reason: "closed"
1520
- }));
1521
- }
1522
- }
1523
- }, new ByteLengthQueuingStrategy({ highWaterMark: input.slowConsumerHighWaterMarkBytes }));
1524
- const pending = new Promise((resolve, reject) => {
1525
- const timeout = setTimeout(() => {
1526
- const entry = input.ws.data.pending.get(requestId);
1527
- if (!entry) return;
1528
- input.ws.data.pending.delete(requestId);
1529
- if (entry.responseStarted) {
1530
- clearPendingTimers(entry);
1531
- entry.pendingChunks.length = 0;
1532
- entry.bytesQueued = 0;
1533
- try {
1534
- entry.controller.close();
1535
- } catch {
1536
- }
1537
- return;
1538
- }
1539
- const error = new Error("User-hosted tunnel response timed out");
1540
- failPendingHttpResponse({
1541
- entry,
1542
- response: corsJsonError(input.request, "user_hosted_tunnel_timeout", "User-hosted tunnel response timed out", 504),
1543
- error
1544
- });
1545
- }, input.responseTimeoutMs);
1546
- input.ws.data.pending.set(requestId, {
1547
- controller,
1548
- stream,
1549
- resolve,
1550
- reject,
1551
- timeout,
1552
- pendingChunks: [],
1553
- bytesQueued: 0,
1554
- responseStarted: false
1555
- });
1556
- });
1557
- const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : await readBoundedBody(input.request, input.requestBodyMaxBytes);
1558
- if (body && "response" in body) {
1559
- const entry = input.ws.data.pending.get(requestId);
1560
- if (entry) clearPendingTimers(entry);
1561
- input.ws.data.pending.delete(requestId);
1562
- return body.response;
1563
- }
1564
- input.ws.send(JSON.stringify({
1565
- type: "http.request",
1566
- protocol: TUNNEL_PROTOCOL_VERSION,
1567
- request_id: requestId,
1568
- workspace_id: input.workspaceId,
1569
- method: input.request.method,
1570
- path: input.path,
1571
- headers: headersRecord(workspaceRelayForwardHeaders(
1572
- input.request.headers,
1573
- input.relayHostToken,
1574
- input.workspaceId,
1575
- // T26: user-hosted tunnel — strip Cookie to avoid leaking browser
1576
- // cookies to the host process running on the user's laptop.
1577
- { userHosted: true }
1578
- )),
1579
- ...body?.bodyBase64 ? { body_base64: body.bodyBase64 } : {},
1580
- end: true
1581
- }));
1582
- return await pending;
1583
- }
1584
- async function readBoundedBody(request, maxBytes) {
1585
- if (!request.body) return void 0;
1586
- const reader = request.body.getReader();
1587
- const chunks = [];
1588
- let total = 0;
1589
- for (; ; ) {
1590
- const next = await reader.read();
1591
- if (next.done) break;
1592
- total += next.value.byteLength;
1593
- if (total > maxBytes) {
1594
- try {
1595
- await reader.cancel();
1596
- } catch {
1597
- }
1598
- return {
1599
- response: corsJsonError(request, "request_body_too_large", "Tunnel request body exceeds the relay limit", 413)
1600
- };
1601
- }
1602
- chunks.push(next.value);
1603
- }
1604
- const body = new Uint8Array(total);
1605
- chunks.reduce((offset, chunk) => {
1606
- body.set(chunk, offset);
1607
- return offset + chunk.byteLength;
1608
- }, 0);
1609
- return { bodyBase64: encoded(body.buffer) };
1610
- }
1611
- async function directHttpRequest(input) {
1612
- const controller = new AbortController();
1613
- const timer = setTimeout(() => controller.abort(), input.timeoutMs);
1614
- const span = (name, run) => input.trace ? input.trace.span(name, run) : run();
1615
- try {
1616
- const init = workspaceRelayForwardRequestInit(
1617
- input.request,
1618
- input.relayHostToken,
1619
- input.workspaceId,
1620
- {
1621
- // T26: cloud-vm direct path. Cookies pass through (default) — workspace
1622
- // dashboards in the VM may legitimately need session cookies.
1623
- userHosted: false,
1624
- upstreamHeaders: input.upstreamHeaders,
1625
- signal: controller.signal
1626
- }
1627
- );
1628
- if (input.request.method !== "GET" && input.request.method !== "HEAD" && input.request.body) {
1629
- init.body = await input.request.arrayBuffer();
1630
- }
1631
- const limiter = input.limiter;
1632
- const release = limiter ? await span("direct-http-queue", () => limiter.acquire()) : void 0;
1633
- try {
1634
- const upstream = await span("upstream-fetch", async () => await fetch(input.targetUrl, init));
1635
- return new Response(upstream.body, {
1636
- status: upstream.status,
1637
- statusText: upstream.statusText,
1638
- headers: relayCorsHeaders(input.request, upstream.headers)
1639
- });
1640
- } finally {
1641
- release?.();
1642
- }
1643
- } catch (err) {
1644
- const aborted = err instanceof Error && err.name === "AbortError";
1645
- return corsJsonError(
1646
- input.request,
1647
- aborted ? "upstream_timeout" : "upstream_unavailable",
1648
- aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable",
1649
- aborted ? 504 : 503
1650
- );
1651
- } finally {
1652
- clearTimeout(timer);
1653
- }
1654
- }
1655
- function hostTunnel(hostTunnels, hostId) {
1656
- const tunnel = hostTunnels.get(hostId);
1657
- if (tunnel?.readyState === WebSocket.OPEN) return tunnel;
1658
- return void 0;
1659
- }
1660
- async function authorizeHostTunnel(options, bunOptions, request, input) {
1661
- if (bunOptions.authorizeHostTunnel) return await bunOptions.authorizeHostTunnel(request, input);
1662
- const token = bearerToken(request.headers.get("authorization"));
1663
- if (!token) return false;
1664
- try {
1665
- await verifyHostTunnelToken(token, options.runtimeAccessKey, input);
1666
- return true;
1667
- } catch (err) {
1668
- if (err instanceof WorkspaceRelayAuthError) return false;
1669
- throw err;
1670
- }
1671
- }
1672
- function createWorkspaceRelayBun(options, bunOptions = {}) {
1673
- const hostTunnels = /* @__PURE__ */ new Map();
1674
- const relayClients = /* @__PURE__ */ new Set();
1675
- const hostTunnelRegistrations = /* @__PURE__ */ new Map();
1676
- const directHttpLimiter = createDirectHttpLimiter(bunOptions.directHttpConcurrency);
1677
- const hostTunnelStateDebounce = /* @__PURE__ */ new Map();
1678
- const fragmentationStats = createFragmentationStats();
1679
- const slowConsumerStats = createSlowConsumerStats();
1680
- const telemetry = {
1681
- getFragmentationStats: () => readFragmentationStats(fragmentationStats),
1682
- resetFragmentationStats: () => resetFragmentationStats(fragmentationStats),
1683
- getSlowConsumerStats: () => readSlowConsumerStats(slowConsumerStats),
1684
- resetSlowConsumerStats: () => resetSlowConsumerStats(slowConsumerStats)
1685
- };
1686
- let draining = false;
1687
- const drainController = {
1688
- isDraining: () => draining,
1689
- setDraining: (value) => {
1690
- draining = value;
1691
- if (!value) return;
1692
- for (const tunnel of hostTunnels.values()) {
1693
- closeWebSocket(tunnel, 1012, "Workspace relay is draining", 1012);
1694
- }
1695
- for (const client of relayClients) {
1696
- closeWebSocket(client, 1012, "Workspace relay is draining", 1012);
1697
- }
1698
- },
1699
- pendingCount: () => {
1700
- let total = 0;
1701
- for (const tunnel of hostTunnels.values()) {
1702
- total += tunnel.data.pending.size;
1703
- }
1704
- return total;
1705
- },
1706
- waitForDrain: async (timeoutMs) => {
1707
- const deadline = Date.now() + Math.max(0, timeoutMs);
1708
- const pollMs = 50;
1709
- while (true) {
1710
- const remaining = drainController.pendingCount();
1711
- if (remaining === 0) return { drained: true, remaining: 0 };
1712
- const now = Date.now();
1713
- if (now >= deadline) return { drained: false, remaining };
1714
- const sleep = Math.min(pollMs, Math.max(1, deadline - now));
1715
- await new Promise((resolve) => setTimeout(resolve, sleep));
1716
- }
1717
- }
1718
- };
1719
- const mergedMetricsSources = {
1720
- fragmentation: options.metricsSources?.fragmentation ?? telemetry.getFragmentationStats,
1721
- slowConsumer: options.metricsSources?.slowConsumer ?? telemetry.getSlowConsumerStats,
1722
- drainPending: options.metricsSources?.drainPending ?? drainController.pendingCount
1723
- };
1724
- const requestRemoteAddress = /* @__PURE__ */ new WeakMap();
1725
- const metricsRemoteAddressResolver = options.metricsRemoteAddress ?? ((request) => requestRemoteAddress.get(request));
1726
- const app = createWorkspaceRelay({
1727
- ...options,
1728
- isDraining: drainController.isDraining,
1729
- metricsSources: mergedMetricsSources,
1730
- metricsRemoteAddress: metricsRemoteAddressResolver
1731
- });
1732
- function getRegistrationTracker(hostId) {
1733
- let tracker = hostTunnelRegistrations.get(hostId);
1734
- if (!tracker) {
1735
- tracker = { recent: [] };
1736
- hostTunnelRegistrations.set(hostId, tracker);
1737
- }
1738
- return tracker;
1739
- }
1740
- function pruneReconnects(tracker, now) {
1741
- const cutoff = now - HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS;
1742
- while (tracker.recent.length && tracker.recent[0] < cutoff) {
1743
- tracker.recent.shift();
1744
- }
1745
- }
1746
- return {
1747
- async fetch(request, server) {
1748
- const url = new URL(request.url);
1749
- const workspaceId = workspaceIdFromPath(url.pathname);
1750
- const hostId = hostIdFromTunnelPath(url.pathname);
1751
- try {
1752
- const ip = server.requestIP(request)?.address;
1753
- if (ip) requestRemoteAddress.set(request, ip);
1754
- } catch {
1755
- }
1756
- if (url.pathname === "/health") {
1757
- return Response.json(
1758
- draining ? { ok: false, service: "workspace-relay", draining: true } : { ok: true, service: "workspace-relay" },
1759
- { status: draining ? 503 : 200 }
1760
- );
1761
- }
1762
- if (draining && websocketRequest(request) && hostId) {
1763
- return jsonError(
1764
- "relay_draining",
1765
- "Workspace relay is shutting down; new tunnel registrations are not accepted",
1766
- 503
1767
- );
1768
- }
1769
- if (draining && workspaceId) {
1770
- const headers2 = relayCorsHeaders(request);
1771
- const body = JSON.stringify({
1772
- error: {
1773
- code: "relay_draining",
1774
- message: "Workspace relay is shutting down; try another instance"
1775
- }
1776
- });
1777
- headers2.set("content-type", "application/json");
1778
- return new Response(body, { status: 503, headers: headers2 });
1779
- }
1780
- if (websocketRequest(request) && hostId) {
1781
- const workspaceIds = url.searchParams.getAll("workspaceId");
1782
- if (!workspaceIds.length || !options.directory) {
1783
- return new Response("Host tunnel registration is unavailable", { status: 503 });
1784
- }
1785
- if (!await authorizeHostTunnel(options, bunOptions, request, { hostId, workspaceIds })) {
1786
- return new Response("Host tunnel registration denied", { status: 403 });
1787
- }
1788
- const tracker = getRegistrationTracker(hostId);
1789
- const now = Date.now();
1790
- pruneReconnects(tracker, now);
1791
- if (tracker.recent.length >= HOST_TUNNEL_REGISTRATION_RECONNECT_CAP) {
1792
- return jsonError(
1793
- "too_many_host_tunnel_reconnects",
1794
- "Too many host-tunnel reconnects for this host within the last 60 seconds",
1795
- 429
1796
- );
1797
- }
1798
- if (server.upgrade(request, {
1799
- data: {
1800
- kind: "host-tunnel",
1801
- hostId,
1802
- workspaceIds,
1803
- pending: /* @__PURE__ */ new Map(),
1804
- channels: /* @__PURE__ */ new Map(),
1805
- missedPongs: 0,
1806
- messageBuffer: ""
1807
- }
1808
- })) {
1809
- tracker.recent.push(now);
1810
- return;
1811
- }
1812
- return new Response("WebSocket upgrade failed", { status: 400 });
1813
- }
1814
- if (!workspaceId) return app.request(request);
1815
- if (request.method === "OPTIONS") {
1816
- return new Response(null, {
1817
- status: 204,
1818
- headers: relayCorsHeaders(request)
1819
- });
1820
- }
1821
- if (!websocketRequest(request)) {
1822
- const trace = createWorkspaceRelayTrace();
1823
- const response = await trace.span("relay-total", async () => {
1824
- const relay2 = await authorizeWorkspaceRelayRequest(options, request, workspaceId, trace);
1825
- if (!relay2.ok) return relay2.response;
1826
- if (relay2.request.target.access === "user-hosted") {
1827
- const tunnel = hostTunnel(hostTunnels, relay2.request.target.hostId);
1828
- return tunnel ? await trace.span("tunnel-http", async () => await tunnelHttpRequest({
1829
- ws: tunnel,
1830
- request,
1831
- workspaceId: relay2.request.target.workspaceId,
1832
- path: `${relay2.request.path}${url.search}`,
1833
- relayHostToken: relay2.request.relayHostToken,
1834
- slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
1835
- slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
1836
- slowConsumerStats,
1837
- requestBodyMaxBytes: bunOptions.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT,
1838
- responseTimeoutMs: bunOptions.tunnelHttpResponseTimeoutMs ?? TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT
1839
- })) : new Response("User-hosted workspace is offline", { status: 503 });
1840
- }
1841
- return await directHttpRequest({
1842
- request,
1843
- targetUrl: workspaceRelayTargetUrl(
1844
- relay2.request.target,
1845
- relay2.request.path,
1846
- url.search
1847
- ).toString(),
1848
- relayHostToken: relay2.request.relayHostToken,
1849
- workspaceId: relay2.request.target.workspaceId,
1850
- upstreamHeaders: relay2.request.target.upstreamHeaders,
1851
- timeoutMs: bunOptions.directHttpTimeoutMs ?? DIRECT_HTTP_TIMEOUT_MS_DEFAULT,
1852
- limiter: directHttpLimiter,
1853
- trace
1854
- });
1855
- });
1856
- return workspaceRelayTimingResponse(response, trace);
1857
- }
1858
- const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
1859
- if (!relay.ok) return relay.response;
1860
- const originDenied = requireAllowedOrigin(request);
1861
- if (originDenied) return originDenied;
1862
- if (relay.request.target.access === "user-hosted") {
1863
- const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId);
1864
- if (!tunnel) {
1865
- return new Response("User-hosted workspace is offline", { status: 503 });
1866
- }
1867
- if (tunnel.data.channels.size >= TUNNEL_CHANNEL_CAP) {
1868
- return jsonError(
1869
- "too_many_channels",
1870
- "User-hosted tunnel has too many active WebSocket channels",
1871
- 503
1872
- );
1873
- }
1874
- if (server.upgrade(request, {
1875
- data: {
1876
- kind: "user-hosted-client",
1877
- hostId: relay.request.target.hostId,
1878
- workspaceId: relay.request.target.workspaceId,
1879
- channelId: crypto.randomUUID(),
1880
- path: `${relay.request.path}${url.search}`,
1881
- relayHostToken: relay.request.relayHostToken
1882
- }
1883
- })) {
1884
- return;
1885
- }
1886
- return new Response("WebSocket upgrade failed", { status: 400 });
1887
- }
1888
- if (server.upgrade(request, {
1889
- data: {
1890
- kind: "client",
1891
- upstreamUrl: workspaceRelayTargetUrl(
1892
- relay.request.target,
1893
- relay.request.path,
1894
- url.search
1895
- ).toString().replace(/^http/, "ws"),
1896
- headers: headersRecord(workspaceRelayForwardHeaders(
1897
- new Headers(),
1898
- relay.request.relayHostToken,
1899
- relay.request.target.workspaceId,
1900
- // T26: cloud-vm WS upgrade. Cookies pass through (default).
1901
- { userHosted: false, upstreamHeaders: relay.request.target.upstreamHeaders }
1902
- )),
1903
- queue: [],
1904
- ...relayWebSocketTraceEnabled(request) ? {
1905
- trace: {
1906
- acceptedAt: performance.now(),
1907
- queuedFrames: 0,
1908
- maxQueuedDelayMs: 0,
1909
- emitted: false
1910
- }
1911
- } : {}
1912
- }
1913
- })) {
1914
- return;
1915
- }
1916
- return new Response("WebSocket upgrade failed", { status: 400 });
1917
- },
1918
- websocket: {
1919
- maxPayloadLength: WS_MAX_PAYLOAD_LENGTH_BYTES,
1920
- message(ws, message) {
1921
- if (ws.data.kind === "host-tunnel") {
1922
- const parsed = tunnelMessage(
1923
- ws,
1924
- message,
1925
- fragmentationStats
1926
- );
1927
- if (parsed?.type === "ping") {
1928
- options.directory?.recordPong(ws.data.hostId);
1929
- ws.send(JSON.stringify(makeTunnelPong(parsed)));
1930
- }
1931
- if (parsed?.type === "pong") {
1932
- ws.data.missedPongs = 0;
1933
- options.directory?.recordPong(ws.data.hostId);
1934
- }
1935
- if (parsed?.type === "host.registration.update") {
1936
- const update = parsed;
1937
- const workspaceIds = [...new Set(update.workspace_ids)];
1938
- const hostSocket = ws;
1939
- const hostId = hostSocket.data.hostId;
1940
- const authorizationRequest = new Request(
1941
- `http://relay.local/host-tunnels/${encodeURIComponent(hostId)}`,
1942
- { headers: { authorization: `Bearer ${update.token}` } }
1943
- );
1944
- void authorizeHostTunnel(options, bunOptions, authorizationRequest, {
1945
- hostId,
1946
- workspaceIds
1947
- }).then((authorized) => {
1948
- if (!authorized) {
1949
- closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008);
1950
- return;
1951
- }
1952
- if (hostTunnels.get(hostId) !== hostSocket || hostSocket.readyState !== WebSocket.OPEN) return;
1953
- hostSocket.data.workspaceIds = workspaceIds;
1954
- options.directory?.registerHostTunnel({ hostId, workspaceIds });
1955
- }).catch(() => closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008));
1956
- return;
1957
- }
1958
- if (parsed?.type === "error") {
1959
- if (parsed.request_id) {
1960
- const pending = ws.data.pending.get(parsed.request_id);
1961
- if (pending) {
1962
- ws.data.pending.delete(parsed.request_id);
1963
- failPendingHttpResponse({
1964
- entry: pending,
1965
- response: jsonError(parsed.code, parsed.message, 502),
1966
- error: new Error(parsed.message)
1967
- });
1968
- }
1969
- }
1970
- }
1971
- if (parsed?.type === "http.response.start") {
1972
- const pending = ws.data.pending.get(parsed.request_id);
1973
- if (pending) {
1974
- const response = parsed;
1975
- pending.responseStarted = true;
1976
- if (isEventStream(response.headers)) clearTimeout(pending.timeout);
1977
- pending.resolve(new Response(pending.stream, {
1978
- status: response.status,
1979
- headers: headers(response.headers)
1980
- }));
1981
- }
1982
- }
1983
- if (parsed?.type === "http.response.chunk") {
1984
- const entry = ws.data.pending.get(parsed.request_id);
1985
- if (entry) {
1986
- enqueueChunkWithBackpressure({
1987
- ws,
1988
- requestId: parsed.request_id,
1989
- entry,
1990
- chunk: decoded(parsed.body_base64),
1991
- slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
1992
- slowConsumerStats
1993
- });
1994
- }
1995
- }
1996
- if (parsed?.type === "http.response.end") {
1997
- const pending = ws.data.pending.get(parsed.request_id);
1998
- if (pending) {
1999
- clearTimeout(pending.timeout);
2000
- if (pending.slowConsumerTimeout) {
2001
- clearTimeout(pending.slowConsumerTimeout);
2002
- pending.slowConsumerTimeout = void 0;
2003
- }
2004
- drainPendingChunks(pending);
2005
- if (pending.pendingChunks.length > 0) {
2006
- for (const chunk of pending.pendingChunks) {
2007
- try {
2008
- pending.controller.enqueue(chunk);
2009
- } catch {
2010
- break;
2011
- }
2012
- }
2013
- pending.pendingChunks.length = 0;
2014
- pending.bytesQueued = 0;
2015
- }
2016
- try {
2017
- pending.controller.close();
2018
- } catch {
2019
- }
2020
- ws.data.pending.delete(parsed.request_id);
2021
- }
2022
- }
2023
- if (parsed?.type === "ws.frame") {
2024
- const frame = parsed;
2025
- const channel = ws.data.channels.get(frame.channel_id);
2026
- if (!channel || channel.readyState !== WebSocket.OPEN) {
2027
- ws.data.channels.delete(frame.channel_id);
2028
- return;
2029
- }
2030
- if (relayOverBackpressureLimit(channel, bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
2031
- ws.data.channels.delete(frame.channel_id);
2032
- closeWebSocket(channel, 1011, "Client WebSocket backpressure limit exceeded");
2033
- return;
2034
- }
2035
- channel.send(decodedFrame(frame));
2036
- }
2037
- if (parsed?.type === "ws.close") {
2038
- const close = parsed;
2039
- const channel = ws.data.channels.get(close.channel_id);
2040
- if (channel) {
2041
- ws.data.channels.delete(close.channel_id);
2042
- closeWebSocket(channel, close.code, close.reason);
2043
- }
2044
- }
2045
- return;
2046
- }
2047
- if (ws.data.kind === "user-hosted-client") {
2048
- const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
2049
- if (!tunnel) {
2050
- ws.close(1011, "User-hosted tunnel disconnected");
2051
- return;
2052
- }
2053
- if (relayOverBackpressureLimit(tunnel, bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
2054
- closeWebSocket(ws, 1011, "Host tunnel backpressure limit exceeded");
2055
- return;
2056
- }
2057
- tunnel.send(JSON.stringify({
2058
- type: "ws.frame",
2059
- protocol: TUNNEL_PROTOCOL_VERSION,
2060
- channel_id: ws.data.channelId,
2061
- ...encodedFrame(message)
2062
- }));
2063
- return;
2064
- }
2065
- if (ws.data.upstream?.readyState === WebSocket.OPEN) {
2066
- ws.data.upstream.send(message);
2067
- return;
2068
- }
2069
- const queuedBytes = ws.data.queuedBytes ?? 0;
2070
- const frameBytes = preOpenFrameBytes(message);
2071
- if (ws.data.queue.length >= (bunOptions.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT) && queuedBytes + frameBytes > (bunOptions.upstreamWebSocketPreOpenQueueMaxBytes ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT)) {
2072
- closeWebSocket(ws, 1011, "Upstream WebSocket queue limit exceeded");
2073
- return;
2074
- }
2075
- ws.data.queuedBytes = queuedBytes + frameBytes;
2076
- ws.data.queue.push({ payload: message, queuedAt: performance.now() });
2077
- },
2078
- open(ws) {
2079
- if (ws.data.kind === "host-tunnel") {
2080
- const previous = hostTunnels.get(ws.data.hostId);
2081
- if (previous && previous !== ws) {
2082
- cleanupHostTunnelSocket({
2083
- ws: previous,
2084
- hostTunnels,
2085
- hostTunnelStateDebounce,
2086
- options,
2087
- bunOptions,
2088
- disconnectDirectory: false,
2089
- closeChannels: true
2090
- });
2091
- closeWebSocket(previous, 1012, "Host tunnel replaced by a newer connection", 1012);
2092
- }
2093
- hostTunnels.set(ws.data.hostId, ws);
2094
- options.directory?.registerHostTunnel({
2095
- hostId: ws.data.hostId,
2096
- workspaceIds: ws.data.workspaceIds
2097
- });
2098
- ws.data.heartbeat = setInterval(() => {
2099
- const hostWs = ws;
2100
- if (hostWs.data.missedPongs > (bunOptions.hostTunnelMaxMissedPongs ?? HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT)) {
2101
- closeWebSocket(ws, 1001, "Host tunnel heartbeat timed out", 1001);
2102
- return;
2103
- }
2104
- sendTunnelPing(hostWs);
2105
- }, bunOptions.hostTunnelPingIntervalMs ?? 15e3);
2106
- scheduleHostTunnelStateChange(hostTunnelStateDebounce, options, {
2107
- hostId: ws.data.hostId,
2108
- workspaceId: ws.data.workspaceIds[0],
2109
- path: `/host-tunnels/${ws.data.hostId}`,
2110
- state: "connected",
2111
- debounceMs: bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
2112
- });
2113
- return;
2114
- }
2115
- if (ws.data.kind === "user-hosted-client") {
2116
- const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
2117
- if (!tunnel) {
2118
- ws.close(1011, "User-hosted tunnel disconnected");
2119
- return;
2120
- }
2121
- tunnel.data.channels.set(ws.data.channelId, ws);
2122
- if (tunnel.readyState !== WebSocket.OPEN) {
2123
- closeWebSocket(ws, 1011, "User-hosted tunnel disconnected");
2124
- return;
2125
- }
2126
- tunnel.send(JSON.stringify({
2127
- type: "ws.open",
2128
- protocol: TUNNEL_PROTOCOL_VERSION,
2129
- channel_id: ws.data.channelId,
2130
- workspace_id: ws.data.workspaceId,
2131
- path: ws.data.path,
2132
- headers: headersRecord(workspaceRelayForwardHeaders(
2133
- new Headers(),
2134
- ws.data.relayHostToken,
2135
- ws.data.workspaceId,
2136
- // T26: user-hosted tunnel WS upgrade — strip Cookie. Headers are
2137
- // empty here today, but pass the flag for consistency in case
2138
- // upstream client headers are forwarded in the future.
2139
- { userHosted: true }
2140
- ))
2141
- }));
2142
- return;
2143
- }
2144
- relayClients.add(ws);
2145
- const data = ws.data;
2146
- const UpstreamWebSocket = bunOptions.upstreamWebSocket ?? WebSocket;
2147
- if (data.trace) data.trace.upstreamStartedAt = performance.now();
2148
- const upstream = new UpstreamWebSocket(data.upstreamUrl, {
2149
- headers: data.headers
2150
- });
2151
- upstream.binaryType = "arraybuffer";
2152
- data.upstream = upstream;
2153
- data.upstreamOpenTimer = setTimeout(() => {
2154
- if (upstream.readyState === WebSocket.OPEN) return;
2155
- closeWebSocket(upstream, 1001, "Upstream WebSocket open timed out", 1001);
2156
- closeWebSocket(ws, 1011, "Upstream WebSocket open timed out");
2157
- }, bunOptions.upstreamWebSocketOpenTimeoutMs ?? UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT);
2158
- upstream.onopen = () => {
2159
- const openedAt = performance.now();
2160
- if (data.upstreamOpenTimer) {
2161
- clearTimeout(data.upstreamOpenTimer);
2162
- data.upstreamOpenTimer = void 0;
2163
- }
2164
- if (data.trace) {
2165
- data.trace.upstreamOpenMs = openedAt - (data.trace.upstreamStartedAt ?? data.trace.acceptedAt);
2166
- data.trace.queuedFrames = data.queue.length;
2167
- data.trace.maxQueuedDelayMs = data.queue.reduce(
2168
- (max, item) => Math.max(max, openedAt - item.queuedAt),
2169
- 0
2170
- );
2171
- sendRelayWebSocketTrace(ws);
2172
- }
2173
- for (const item of data.queue.splice(0)) upstream.send(item.payload);
2174
- data.queuedBytes = 0;
2175
- };
2176
- upstream.onmessage = (event) => {
2177
- const payload = relayWebSocketPayload(event.data);
2178
- if (payload !== void 0) ws.send(payload);
2179
- };
2180
- upstream.onclose = (event) => {
2181
- if (data.upstreamOpenTimer) {
2182
- clearTimeout(data.upstreamOpenTimer);
2183
- data.upstreamOpenTimer = void 0;
2184
- }
2185
- closeWebSocket(ws, event.code, upstreamCloseReason(event));
2186
- };
2187
- upstream.onerror = () => {
2188
- if (data.upstreamOpenTimer) {
2189
- clearTimeout(data.upstreamOpenTimer);
2190
- data.upstreamOpenTimer = void 0;
2191
- }
2192
- closeWebSocket(ws, 1011, "Upstream WebSocket connection failed");
2193
- };
2194
- },
2195
- close(ws, code, reason) {
2196
- if (ws.data.kind === "host-tunnel") {
2197
- cleanupHostTunnelSocket({
2198
- ws,
2199
- hostTunnels,
2200
- hostTunnelStateDebounce,
2201
- options,
2202
- bunOptions,
2203
- disconnectDirectory: true,
2204
- closeChannels: true
2205
- });
2206
- const tracker = hostTunnelRegistrations.get(ws.data.hostId);
2207
- if (tracker) {
2208
- pruneReconnects(tracker, Date.now());
2209
- if (tracker.recent.length === 0) hostTunnelRegistrations.delete(ws.data.hostId);
2210
- }
2211
- return;
2212
- }
2213
- if (ws.data.kind === "user-hosted-client") {
2214
- const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
2215
- tunnel?.data.channels.delete(ws.data.channelId);
2216
- if (tunnel?.readyState === WebSocket.OPEN) {
2217
- tunnel.send(JSON.stringify({
2218
- type: "ws.close",
2219
- protocol: TUNNEL_PROTOCOL_VERSION,
2220
- channel_id: ws.data.channelId,
2221
- code: safeCloseCode(code, 1e3),
2222
- reason
2223
- }));
2224
- }
2225
- return;
2226
- }
2227
- relayClients.delete(ws);
2228
- if (ws.data.upstreamOpenTimer) clearTimeout(ws.data.upstreamOpenTimer);
2229
- if (ws.data.upstream) closeWebSocket(ws.data.upstream, code, reason, 1e3);
2230
- }
2231
- },
2232
- drain: drainController,
2233
- telemetry
2234
- };
2235
- }
2236
-
2237
- // src/cloudflare.ts
2238
- import {
2239
- makeTunnelPong as makeTunnelPong2,
2240
- TUNNEL_PROTOCOL_VERSION as TUNNEL_PROTOCOL_VERSION2,
2241
- validateTunnelMessage as validateTunnelMessage2
2242
- } from "@claxedo/workspace-relay-protocol";
2243
-
2244
1320
  // src/directory.ts
1321
+ function presenceKey(hostId, workspaceId) {
1322
+ return `${hostId}\0${workspaceId}`;
1323
+ }
2245
1324
  function createWorkspaceRelayDirectory(options = {}) {
2246
1325
  const ttlMs = Math.max(1, options.ttlMs ?? 45e3);
2247
1326
  const now = options.now ?? Date.now;
2248
1327
  const sweepIntervalMs = options.sweepIntervalMs ?? 3e4;
2249
- const hosts = /* @__PURE__ */ new Map();
2250
- const alive = (presence) => {
2251
- 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;
2252
1332
  if (presence.expiresAt <= now()) {
2253
- hosts.delete(presence.hostId);
2254
- return;
1333
+ entries.delete(key);
1334
+ return void 0;
2255
1335
  }
2256
1336
  return presence;
2257
1337
  };
2258
- const touch = (presence) => {
2259
- const timestamp = now();
2260
- const next = {
2261
- ...presence,
2262
- lastPongAt: timestamp,
2263
- expiresAt: timestamp + ttlMs
2264
- };
2265
- hosts.set(next.hostId, next);
2266
- 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);
2267
1341
  };
2268
1342
  const sweep = () => {
2269
1343
  const at = now();
2270
- for (const [hostId, presence] of hosts) {
1344
+ for (const [key, presence] of entries) {
2271
1345
  if (presence.expiresAt <= at) {
2272
- hosts.delete(hostId);
1346
+ entries.delete(key);
2273
1347
  }
2274
1348
  }
2275
1349
  };
@@ -2287,29 +1361,35 @@ function createWorkspaceRelayDirectory(options = {}) {
2287
1361
  lastPongAt: timestamp,
2288
1362
  expiresAt: timestamp + ttlMs
2289
1363
  };
2290
- hosts.set(next.hostId, next);
1364
+ for (const workspaceId of next.workspaceIds) entries.set(presenceKey(next.hostId, workspaceId), next);
2291
1365
  return next;
2292
1366
  },
2293
- recordPong(hostId) {
2294
- const presence = alive(hosts.get(hostId));
2295
- 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;
2296
1378
  },
2297
- disconnectHost(hostId) {
2298
- hosts.delete(hostId);
1379
+ disconnectHost(hostId, workspaceIds) {
1380
+ for (const key of keysOf(hostId, workspaceIds)) entries.delete(key);
2299
1381
  },
2300
1382
  activeHost(input) {
2301
- const presence = alive(hosts.get(input.hostId));
2302
- if (!presence?.workspaceIds.includes(input.workspaceId)) return;
2303
- return presence;
1383
+ return alive(presenceKey(input.hostId, input.workspaceId));
2304
1384
  },
2305
1385
  sweep,
2306
1386
  size() {
2307
1387
  const at = now();
2308
- let count = 0;
2309
- for (const presence of hosts.values()) {
2310
- 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);
2311
1391
  }
2312
- return count;
1392
+ return hosts.size;
2313
1393
  },
2314
1394
  dispose() {
2315
1395
  if (intervalHandle !== void 0) {
@@ -2327,6 +1407,9 @@ function disposeWorkspaceRelayDirectory(directory) {
2327
1407
  var DEFAULT_RELAY_LOCATION_HINT = "apac";
2328
1408
  var RELAY_LOCATION_HINTS = ["wnam", "enam", "sam", "weur", "eeur", "apac", "oc", "afr", "me"];
2329
1409
  var relayLocationHintSet = new Set(RELAY_LOCATION_HINTS);
1410
+ function isRelayLocationHint(input) {
1411
+ return relayLocationHintSet.has(input);
1412
+ }
2330
1413
  var RELAY_REGION_TO_LOCATION_HINT = {
2331
1414
  // Canonical Claxedo regions.
2332
1415
  "apac-south": "apac",
@@ -2363,14 +1446,15 @@ function locationHintForCountry(country) {
2363
1446
  }
2364
1447
  var RELAY_REGION_HEADER = "x-claxedo-workspace-region";
2365
1448
  function relayLocationHint(input) {
2366
- const configured = clean(input.configured) ?? DEFAULT_RELAY_LOCATION_HINT;
2367
- const region = clean(input.region);
1449
+ const configured = trimToUndefined(input.configured) ?? DEFAULT_RELAY_LOCATION_HINT;
1450
+ const region = trimToUndefined(input.region);
2368
1451
  if (region) {
2369
- const direct = relayLocationHintSet.has(region.toLowerCase()) ? region.toLowerCase() : void 0;
2370
- const mapped = direct ?? RELAY_REGION_TO_LOCATION_HINT[region.toLowerCase()];
1452
+ const lowered = region.toLowerCase();
1453
+ const direct = isRelayLocationHint(lowered) ? lowered : void 0;
1454
+ const mapped = direct ?? RELAY_REGION_TO_LOCATION_HINT[lowered];
2371
1455
  if (mapped) return mapped;
2372
1456
  }
2373
- const country = clean(input.country);
1457
+ const country = trimToUndefined(input.country);
2374
1458
  if (country) {
2375
1459
  const mapped = locationHintForCountry(country);
2376
1460
  if (mapped) return mapped;
@@ -2379,7 +1463,7 @@ function relayLocationHint(input) {
2379
1463
  }
2380
1464
  function relayRequestRegion(request) {
2381
1465
  const url = new URL(request.url);
2382
- return clean(request.headers.get(RELAY_REGION_HEADER)) ?? clean(url.searchParams.get("region")) ?? clean(url.searchParams.get("homeRegion"));
1466
+ return trimToUndefined(request.headers.get(RELAY_REGION_HEADER)) ?? trimToUndefined(url.searchParams.get("region")) ?? trimToUndefined(url.searchParams.get("homeRegion"));
2383
1467
  }
2384
1468
  function relayRequestCountry(request) {
2385
1469
  const cf = request.cf;
@@ -2388,21 +1472,24 @@ function relayRequestCountry(request) {
2388
1472
  var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
2389
1473
  var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
2390
1474
  var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
2391
- var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT2 = 16 * 1024 * 1024;
1475
+ var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
2392
1476
  var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
2393
- var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT2 = 8 * 1024 * 1024;
2394
- var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT2 = 3e4;
2395
- var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT2 = 64;
2396
- var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT2 = 8 * 1024 * 1024;
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;
2397
1481
  var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
2398
1482
  var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
2399
1483
  var RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
2400
1484
  var HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT = 3e4;
1485
+ var HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT = 3e4;
2401
1486
  var TRACE_ID_HEADER = "x-claxedo-trace-id";
2402
1487
  var TRACE_FORCE_HEADER = "x-claxedo-relay-trace";
2403
- function clean(input) {
2404
- const value = input?.trim();
2405
- 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";
2406
1493
  }
2407
1494
  function clampRate(input) {
2408
1495
  if (input === void 0 || !Number.isFinite(input)) return 0;
@@ -2411,8 +1498,8 @@ function clampRate(input) {
2411
1498
  return input;
2412
1499
  }
2413
1500
  function traceRequest(request, routeKind, options) {
2414
- const id = clean(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
2415
- const forceSecret = clean(options.traceForceHeaderSecret);
1501
+ const id = trimToUndefined(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
1502
+ const forceSecret = trimToUndefined(options.traceForceHeaderSecret);
2416
1503
  const forced = !!forceSecret && request.headers.get(TRACE_FORCE_HEADER) === forceSecret;
2417
1504
  const rate = clampRate(options.traceSampleRate);
2418
1505
  return {
@@ -2432,36 +1519,36 @@ async function traceSpan(trace, name, fn) {
2432
1519
  }
2433
1520
  }
2434
1521
  function tracedRequest(request, trace) {
2435
- const headers2 = new Headers(request.headers);
2436
- headers2.set(TRACE_ID_HEADER, trace.id);
2437
- if (trace.sampled) headers2.set("x-claxedo-trace-sampled", "1");
2438
- 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");
2439
1526
  return new Request(request.url, {
2440
1527
  method: request.method,
2441
- headers: headers2,
1528
+ headers,
2442
1529
  body: request.method === "GET" || request.method === "HEAD" ? void 0 : request.body,
2443
1530
  redirect: request.redirect,
2444
1531
  duplex: "half"
2445
1532
  });
2446
1533
  }
2447
- function roundedMs3(value) {
1534
+ function roundedMs2(value) {
2448
1535
  return Math.round(value * 100) / 100;
2449
1536
  }
2450
1537
  function serverTiming(trace) {
2451
- 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(", ");
2452
1539
  }
2453
1540
  function withTraceHeaders(response, trace) {
2454
- const headers2 = new Headers(response.headers);
2455
- headers2.set(TRACE_ID_HEADER, trace.id);
1541
+ const headers = new Headers(response.headers);
1542
+ headers.set(TRACE_ID_HEADER, trace.id);
2456
1543
  const timing = trace.sampled ? serverTiming(trace) : "";
2457
1544
  if (timing) {
2458
- const existing = headers2.get("server-timing");
2459
- headers2.set("server-timing", existing ? `${existing}, ${timing}` : timing);
1545
+ const existing = headers.get("server-timing");
1546
+ headers.set("server-timing", existing ? `${existing}, ${timing}` : timing);
2460
1547
  }
2461
1548
  return new Response(response.body, {
2462
1549
  status: response.status,
2463
1550
  statusText: response.statusText,
2464
- headers: headers2
1551
+ headers
2465
1552
  });
2466
1553
  }
2467
1554
  function logTrace(options, request, workspaceId, trace, response) {
@@ -2476,8 +1563,8 @@ function logTrace(options, request, workspaceId, trace, response) {
2476
1563
  method: request.method,
2477
1564
  path: url.pathname,
2478
1565
  status: response.status,
2479
- totalMs: roundedMs3(totalMs),
2480
- 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)]))
2481
1568
  };
2482
1569
  if (options.traceLog) {
2483
1570
  options.traceLog(event);
@@ -2488,57 +1575,54 @@ function logTrace(options, request, workspaceId, trace, response) {
2488
1575
  function workspaceIdFromRelayPath(pathname) {
2489
1576
  return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
2490
1577
  }
2491
- function hostIdFromTunnelPath2(pathname) {
1578
+ function hostIdFromTunnelPath(pathname) {
2492
1579
  return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
2493
1580
  }
2494
- function websocketRequest2(request) {
1581
+ function websocketRequest(request) {
2495
1582
  return request.headers.get("upgrade")?.toLowerCase() === "websocket";
2496
1583
  }
2497
- function relayWebSocketTraceEnabled2(request) {
1584
+ function relayWebSocketTraceEnabled(request) {
2498
1585
  return request.headers.get("x-claxedo-relay-ws-trace") === "1";
2499
1586
  }
2500
1587
  function workspaceIdsFromSearch(url) {
2501
1588
  return [...new Set([
2502
1589
  ...url.searchParams.getAll("workspaceId"),
2503
1590
  ...url.searchParams.getAll("workspace_id")
2504
- ].map(clean).filter((item) => !!item))];
1591
+ ].map(trimToUndefined).filter((item) => !!item))];
2505
1592
  }
2506
1593
  function randomToken() {
2507
1594
  return crypto.randomUUID();
2508
1595
  }
2509
- function headerRecord(headers2) {
1596
+ function headerRecord(headers) {
2510
1597
  const result = {};
2511
- headers2.forEach((value, key) => {
1598
+ headers.forEach((value, key) => {
2512
1599
  result[key] = value;
2513
1600
  });
2514
1601
  return result;
2515
1602
  }
2516
1603
  function headersFromRecord(input) {
2517
- const headers2 = new Headers();
2518
- for (const [key, value] of Object.entries(input)) headers2.set(key, value);
2519
- return headers2;
1604
+ const headers = new Headers();
1605
+ for (const [key, value] of Object.entries(input)) headers.set(key, value);
1606
+ return headers;
2520
1607
  }
2521
1608
  var BASE64_CHUNK_BYTES = 32768;
2522
1609
  function bytesToBase64(bytes) {
2523
1610
  let binary = "";
2524
1611
  for (let i = 0; i < bytes.length; i += BASE64_CHUNK_BYTES) {
2525
- binary += String.fromCharCode.apply(
2526
- null,
2527
- bytes.subarray(i, i + BASE64_CHUNK_BYTES)
2528
- );
1612
+ binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_BYTES));
2529
1613
  }
2530
1614
  return btoa(binary);
2531
1615
  }
2532
1616
  function base64ToBytes(input) {
2533
1617
  return Uint8Array.from(atob(input), (char) => char.charCodeAt(0));
2534
1618
  }
2535
- function preOpenFrameBytes2(input) {
1619
+ function preOpenFrameBytes(input) {
2536
1620
  if (typeof input === "string") return new TextEncoder().encode(input).byteLength;
2537
1621
  return input.byteLength;
2538
1622
  }
2539
- function blobLike(input) {
2540
- if (!input || typeof input !== "object") return void 0;
2541
- return typeof input.arrayBuffer === "function" ? input : void 0;
1623
+ function isBlobLike(input) {
1624
+ if (!input || typeof input !== "object") return false;
1625
+ return "arrayBuffer" in input && typeof input.arrayBuffer === "function";
2542
1626
  }
2543
1627
  function viewBytes(input) {
2544
1628
  return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
@@ -2568,9 +1652,8 @@ function socketFrame(input) {
2568
1652
  data_base64: bytesToBase64(viewBytes(input))
2569
1653
  };
2570
1654
  }
2571
- const blob = blobLike(input);
2572
- if (!blob) return void 0;
2573
- return blob.arrayBuffer().then((buffer) => ({
1655
+ if (!isBlobLike(input)) return void 0;
1656
+ return input.arrayBuffer().then((buffer) => ({
2574
1657
  binary: true,
2575
1658
  data_base64: bytesToBase64(new Uint8Array(buffer))
2576
1659
  }));
@@ -2580,9 +1663,8 @@ function socketPayload(input) {
2580
1663
  if (input instanceof ArrayBuffer) return input;
2581
1664
  if (input instanceof Uint8Array) return input;
2582
1665
  if (ArrayBuffer.isView(input)) return viewBytes(input);
2583
- const blob = blobLike(input);
2584
- if (!blob) return void 0;
2585
- return blob.arrayBuffer();
1666
+ if (!isBlobLike(input)) return void 0;
1667
+ return input.arrayBuffer();
2586
1668
  }
2587
1669
  function orderedFrameDelivery(deliver) {
2588
1670
  let tail;
@@ -2648,7 +1730,7 @@ async function readRequestBodyCapped(request, maxBytes) {
2648
1730
  }
2649
1731
  return concatBytes(chunks);
2650
1732
  }
2651
- function safeCloseCode2(code, fallback = 1011) {
1733
+ function safeCloseCode(code, fallback = 1011) {
2652
1734
  if (code === void 0) return fallback;
2653
1735
  if (code >= 1e3 && code <= 4999 && ![1004, 1005, 1006].includes(code)) return code;
2654
1736
  return fallback;
@@ -2658,15 +1740,19 @@ function workspaceRelayDurableObjectRoomName(workspaceId) {
2658
1740
  }
2659
1741
  function workspaceRelayDurableObjectWorkspaceId(request) {
2660
1742
  const url = new URL(request.url);
2661
- const workspaceId = clean(workspaceIdFromRelayPath(url.pathname));
1743
+ const workspaceId = trimToUndefined(workspaceIdFromRelayPath(url.pathname));
2662
1744
  if (workspaceId) return decodeURIComponent(workspaceId);
2663
- if (!hostIdFromTunnelPath2(url.pathname)) return;
2664
- 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"));
2665
1747
  }
2666
1748
  function namespaceFromEnv(env, options) {
2667
1749
  if (options.namespace) return options.namespace;
2668
1750
  const binding = env[options.bindingName ?? "WORKSPACE_RELAY_ROOM"];
2669
- 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";
2670
1756
  }
2671
1757
  function json(code, message, status) {
2672
1758
  return Response.json(errorBody(code, message), { status });
@@ -2681,32 +1767,31 @@ function setWorkspaceRelayAllowedOrigins(raw) {
2681
1767
  const patterns = parseAllowedOrigins(raw);
2682
1768
  baseOriginAllowed = patterns ? createOriginMatcher(patterns) : defaultOriginAllowed;
2683
1769
  }
2684
- function allowedCorsOrigin2(origin) {
2685
- if (!origin) return;
2686
- if (baseOriginAllowed(origin)) return origin;
2687
- if (configuredAppOriginAllowed(origin)) return origin;
1770
+ function allowedCorsOrigin(origin) {
1771
+ if (!origin) return void 0;
1772
+ return baseOriginAllowed(origin) || configuredAppOriginAllowed(origin) ? origin : void 0;
2688
1773
  }
2689
1774
  function corsHeaders(request) {
2690
- const origin = allowedCorsOrigin2(request.headers.get("origin"));
2691
- if (!origin) return;
1775
+ const origin = allowedCorsOrigin(request.headers.get("origin"));
1776
+ if (!origin) return void 0;
2692
1777
  return {
2693
1778
  "access-control-allow-origin": origin,
2694
1779
  "access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
2695
- "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,
2696
1781
  "access-control-max-age": "86400",
2697
1782
  vary: "Origin"
2698
1783
  };
2699
1784
  }
2700
1785
  function corsPreflight(request) {
2701
- const headers2 = corsHeaders(request);
2702
- 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 } : {} });
2703
1788
  }
2704
1789
  function withCors(request, response) {
2705
- const headers2 = corsHeaders(request);
2706
- if (!headers2) return response;
2707
- 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;
2708
1793
  const next = new Headers(response.headers);
2709
- 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);
2710
1795
  return new Response(response.body, {
2711
1796
  status: response.status,
2712
1797
  statusText: response.statusText,
@@ -2714,7 +1799,7 @@ function withCors(request, response) {
2714
1799
  });
2715
1800
  }
2716
1801
  function defaultWebSocketPair() {
2717
- const pair = new globalThis.WebSocketPair();
1802
+ const pair = new WebSocketPair();
2718
1803
  return {
2719
1804
  client: pair[0],
2720
1805
  server: pair[1]
@@ -2722,7 +1807,7 @@ function defaultWebSocketPair() {
2722
1807
  }
2723
1808
  function selectedSubprotocol(request) {
2724
1809
  const offered = request.headers.get("sec-websocket-protocol")?.split(",").map((item) => item.trim()).filter(Boolean);
2725
- if (!offered?.length) return;
1810
+ if (!offered?.length) return void 0;
2726
1811
  return offered.find((item) => item.startsWith("claxedo-rat.")) ?? offered[0];
2727
1812
  }
2728
1813
  function upgradeResponse(socket, request) {
@@ -2749,7 +1834,7 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
2749
1834
  return {
2750
1835
  async fetch(request, env = {}) {
2751
1836
  const gatewayStartedAt = performance.now();
2752
- const gatewayTraceId = clean(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
1837
+ const gatewayTraceId = trimToUndefined(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
2753
1838
  const url = new URL(request.url);
2754
1839
  if (request.method === "OPTIONS" && (url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/"))) {
2755
1840
  return corsPreflight(request);
@@ -2798,26 +1883,26 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
2798
1883
  phases: []
2799
1884
  }));
2800
1885
  const fetchFinishedAt = performance.now();
2801
- if (response.status === 101 || response.webSocket) {
1886
+ if (response.status === 101 || "webSocket" in response && response.webSocket) {
2802
1887
  return url.pathname.startsWith("/workspaces/") ? withCors(request, response) : response;
2803
1888
  }
2804
- const headers2 = new Headers(response.headers);
2805
- headers2.set(TRACE_ID_HEADER, headers2.get(TRACE_ID_HEADER) ?? gatewayTraceId);
2806
- headers2.set("x-claxedo-relay-location-hint", locationHint);
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);
2807
1892
  const gatewayResponseStartedAt = performance.now();
2808
1893
  const tracedResponse = new Response(response.body, {
2809
1894
  status: response.status,
2810
1895
  statusText: response.statusText,
2811
- headers: headers2
1896
+ headers
2812
1897
  });
2813
1898
  const gatewayFinishedAt = performance.now();
2814
1899
  const roomTiming = tracedResponse.headers.get("server-timing");
2815
1900
  if (roomTiming) {
2816
1901
  const gatewayTiming = [
2817
- `relay-gateway-route;dur=${roundedMs3(routeFinishedAt - gatewayStartedAt)}`,
2818
- `relay-gateway-do-fetch;dur=${roundedMs3(fetchFinishedAt - routeFinishedAt)}`,
2819
- `relay-gateway-response;dur=${roundedMs3(gatewayFinishedAt - gatewayResponseStartedAt)}`,
2820
- `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)}`
2821
1906
  ].join(", ");
2822
1907
  tracedResponse.headers.set("server-timing", `${roomTiming}, ${gatewayTiming}`);
2823
1908
  }
@@ -2867,6 +1952,8 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2867
1952
  }
2868
1953
  };
2869
1954
  const cleanupTunnelResources = (tunnel, reason) => {
1955
+ if (tunnel.generationWatcher) clearInterval(tunnel.generationWatcher);
1956
+ tunnel.generationWatcher = void 0;
2870
1957
  for (const pending of tunnel.pending.values()) {
2871
1958
  clearTimeout(pending.timeout);
2872
1959
  if (pending.stream?.slowConsumerTimeout) clearTimeout(pending.stream.slowConsumerTimeout);
@@ -2903,6 +1990,9 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2903
1990
  hostId: attachment.hostId,
2904
1991
  workspaceIds: attachment.workspaceIds,
2905
1992
  connectedAt: attachment.connectedAt,
1993
+ ...attachment.enrollmentId ? { enrollmentId: attachment.enrollmentId } : {},
1994
+ ...attachment.generation !== void 0 ? { generation: attachment.generation } : {},
1995
+ generationCheckFailures: attachment.generationCheckFailures ?? 0,
2906
1996
  socket,
2907
1997
  pending: /* @__PURE__ */ new Map(),
2908
1998
  channels: /* @__PURE__ */ new Map()
@@ -2953,7 +2043,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2953
2043
  const resolverUnreachable = (code) => code === "relay_revocation_resolver_unavailable";
2954
2044
  const watchRuntimeAccessToken = (client, onInactive) => {
2955
2045
  const intervalMs = options.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
2956
- if (intervalMs <= 0) return;
2046
+ if (intervalMs <= 0) return void 0;
2957
2047
  const now = options.now ?? Date.now;
2958
2048
  let consecutiveUnreachable = 0;
2959
2049
  const timer = setInterval(() => {
@@ -2983,14 +2073,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2983
2073
  onInactive(active.reason);
2984
2074
  }).catch(() => tolerate("Runtime Access Token active check failed"));
2985
2075
  }, intervalMs);
2986
- if (typeof timer.unref === "function") {
2987
- timer.unref();
2988
- }
2076
+ unrefTimer(timer);
2989
2077
  return timer;
2990
2078
  };
2991
2079
  const watchUserHostedTarget = (client, onInactive) => {
2992
2080
  const intervalMs = options.workspaceTargetActiveCheckIntervalMs ?? WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
2993
- if (intervalMs <= 0) return;
2081
+ if (intervalMs <= 0) return void 0;
2994
2082
  const target = client.request.target;
2995
2083
  let consecutiveUnreachable = 0;
2996
2084
  const timer = setInterval(() => {
@@ -3009,11 +2097,54 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3009
2097
  onInactive("Workspace relay target active check failed");
3010
2098
  });
3011
2099
  }, intervalMs);
3012
- if (typeof timer.unref === "function") {
3013
- timer.unref();
3014
- }
2100
+ unrefTimer(timer);
3015
2101
  return timer;
3016
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
+ };
3017
2148
  const clearSlowConsumerTimer = (pending) => {
3018
2149
  if (!pending.stream?.slowConsumerTimeout) return;
3019
2150
  clearTimeout(pending.stream.slowConsumerTimeout);
@@ -3028,7 +2159,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3028
2159
  pending.stream.flowPaused = paused;
3029
2160
  tunnel.socket.send?.(JSON.stringify({
3030
2161
  type: "http.response.flow",
3031
- protocol: TUNNEL_PROTOCOL_VERSION2,
2162
+ protocol: TUNNEL_PROTOCOL_VERSION,
3032
2163
  request_id: requestId,
3033
2164
  paused,
3034
2165
  reason
@@ -3049,7 +2180,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3049
2180
  if (pending.stream?.slowConsumerTimeout) return;
3050
2181
  pending.stream.slowConsumerTimeout = setTimeout(
3051
2182
  () => failSlowConsumer(tunnel, requestId, pending),
3052
- options.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT2
2183
+ options.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
3053
2184
  );
3054
2185
  };
3055
2186
  const drainPendingStreamChunks = (tunnel, requestId, pending) => {
@@ -3103,7 +2234,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3103
2234
  failPendingTunnelResponse(tunnel, oldestId, oldest, new Error("stream_evicted: tunnel started-stream cap reached"));
3104
2235
  }
3105
2236
  };
3106
- const resolvePendingTunnelStream = (tunnel, requestId, pending, status, headers2) => {
2237
+ const resolvePendingTunnelStream = (tunnel, requestId, pending, status, headers) => {
3107
2238
  if (pending.stream) return;
3108
2239
  pending.startedSeq = ++startedSeqCounter;
3109
2240
  evictOldestStartedStream(tunnel, requestId);
@@ -3127,18 +2258,18 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3127
2258
  }
3128
2259
  tunnel.socket.send?.(JSON.stringify({
3129
2260
  type: "http.response.flow",
3130
- protocol: TUNNEL_PROTOCOL_VERSION2,
2261
+ protocol: TUNNEL_PROTOCOL_VERSION,
3131
2262
  request_id: requestId,
3132
2263
  paused: false,
3133
2264
  reason: "closed"
3134
2265
  }));
3135
2266
  }
3136
2267
  }, new ByteLengthQueuingStrategy({
3137
- highWaterMark: options.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT2
2268
+ highWaterMark: options.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT
3138
2269
  }));
3139
2270
  pending.resolve(new Response(stream, {
3140
2271
  status,
3141
- headers: headers2
2272
+ headers
3142
2273
  }));
3143
2274
  };
3144
2275
  const failPendingTunnelResponse = (tunnel, requestId, pending, error) => {
@@ -3195,7 +2326,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3195
2326
  closeSocket(client.socket, code, reason);
3196
2327
  sendSocket(tunnel.socket, JSON.stringify({
3197
2328
  type: "ws.close",
3198
- protocol: TUNNEL_PROTOCOL_VERSION2,
2329
+ protocol: TUNNEL_PROTOCOL_VERSION,
3199
2330
  channel_id: channelId,
3200
2331
  code,
3201
2332
  reason
@@ -3210,7 +2341,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3210
2341
  const scheduleHibernatedRevocationCheck = async () => {
3211
2342
  const intervalMs = hibernatedRevocationIntervalMs();
3212
2343
  if (!hibernation || !options.alarms || intervalMs <= 0) return;
3213
- if (clients.size === 0) return;
2344
+ if (clients.size === 0 && ![...hostTunnels.values()].some(fenced)) return;
3214
2345
  const now = options.now ?? Date.now;
3215
2346
  const at = now() + intervalMs;
3216
2347
  try {
@@ -3224,16 +2355,17 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3224
2355
  ensureHibernatedSocketsRebuilt();
3225
2356
  const now = options.now ?? Date.now;
3226
2357
  const revoke = (client, reason) => {
3227
- const channelId = client.channelId;
3228
- for (const tunnel of hostTunnels.values()) {
3229
- if (!channelId || tunnel.channels.get(channelId) !== client) continue;
3230
- dropUserHostedChannel(tunnel, channelId, client, 1008, reason);
3231
- return;
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
+ }
3232
2364
  }
3233
2365
  closeSocket(client.socket, 1008, reason);
3234
2366
  clients.delete(client);
3235
2367
  };
3236
- for (const client of [...clients]) {
2368
+ for (const client of Array.from(clients)) {
3237
2369
  if (client.request.claims.exp * 1e3 <= now()) {
3238
2370
  revoke(client, "Runtime Access Token expired");
3239
2371
  continue;
@@ -3248,6 +2380,9 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3248
2380
  if (active.active || resolverUnreachable(active.code)) continue;
3249
2381
  revoke(client, active.reason);
3250
2382
  }
2383
+ for (const tunnel of Array.from(hostTunnels.values())) {
2384
+ await recheckHostGeneration(tunnel);
2385
+ }
3251
2386
  await scheduleHibernatedRevocationCheck();
3252
2387
  };
3253
2388
  const activeUserHostedClient = (channelId, socket) => {
@@ -3255,6 +2390,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3255
2390
  const client = tunnel.channels.get(channelId);
3256
2391
  if (client?.socket === socket) return { tunnel, client };
3257
2392
  }
2393
+ return void 0;
3258
2394
  };
3259
2395
  const handleUserHostedClientMessage = async (channelId, socket, data) => {
3260
2396
  const active = activeUserHostedClient(channelId, socket);
@@ -3272,41 +2408,57 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3272
2408
  if (!frame) return;
3273
2409
  const sent = sendSocket(active.tunnel.socket, JSON.stringify({
3274
2410
  type: "ws.frame",
3275
- protocol: TUNNEL_PROTOCOL_VERSION2,
2411
+ protocol: TUNNEL_PROTOCOL_VERSION,
3276
2412
  channel_id: channelId,
3277
2413
  ...frame
3278
2414
  }));
3279
2415
  if (!sent) closeSocket(socket, 1011, "User-hosted tunnel unavailable");
3280
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
+ };
3281
2449
  const handleTunnelMessage = async (hostId, event) => {
3282
2450
  const row = parseTunnelMessageData(event.data);
3283
2451
  if (!row.ok) return;
3284
- const parsed = validateTunnelMessage2(row.value);
2452
+ const parsed = validateTunnelMessage(row.value);
3285
2453
  const tunnel = hostTunnels.get(hostId);
3286
2454
  if (!parsed.ok || !tunnel) return;
3287
2455
  directory.recordPong(tunnel.hostId);
3288
2456
  if (parsed.message.type === "ping") {
3289
- tunnel.socket.send?.(JSON.stringify(makeTunnelPong2(parsed.message)));
2457
+ tunnel.socket.send?.(JSON.stringify(makeTunnelPong(parsed.message)));
3290
2458
  return;
3291
2459
  }
3292
2460
  if (parsed.message.type === "host.registration.update") {
3293
- const update = parsed.message;
3294
- const workspaceIds = [...new Set(update.workspace_ids)];
3295
- try {
3296
- await verifyHostTunnelToken(update.token, options.runtimeAccessKey, { hostId, workspaceIds });
3297
- } catch {
3298
- closeSocket(tunnel.socket, 1008, "Host tunnel registration update denied");
3299
- return;
3300
- }
3301
- if (hostTunnels.get(hostId) !== tunnel) return;
3302
- tunnel.workspaceIds = workspaceIds;
3303
- tunnel.socket.serializeAttachment?.({
3304
- kind: "host-tunnel",
3305
- hostId,
3306
- workspaceIds,
3307
- connectedAt: tunnel.connectedAt
3308
- });
3309
- directory.registerHostTunnel({ hostId, workspaceIds });
2461
+ await applyRegistrationUpdate(tunnel, [...new Set(parsed.message.workspace_ids)], parsed.message.token);
3310
2462
  return;
3311
2463
  }
3312
2464
  if (parsed.message.type === "error" && parsed.message.request_id) {
@@ -3387,7 +2539,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3387
2539
  if (!channel) return;
3388
2540
  tunnel.channels.delete(message.channel_id);
3389
2541
  clients.delete(channel);
3390
- closeSocket(channel.socket, safeCloseCode2(message.code), message.reason ?? "");
2542
+ closeSocket(channel.socket, safeCloseCode(message.code), message.reason ?? "");
3391
2543
  return;
3392
2544
  }
3393
2545
  if (parsed.message.type === "error" && parsed.message.channel_id) {
@@ -3423,17 +2575,17 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3423
2575
  clients.delete(active);
3424
2576
  sendSocket(tunnel.socket, JSON.stringify({
3425
2577
  type: "ws.close",
3426
- protocol: TUNNEL_PROTOCOL_VERSION2,
2578
+ protocol: TUNNEL_PROTOCOL_VERSION,
3427
2579
  channel_id: channelId,
3428
- code: safeCloseCode2(event?.code, 1e3),
2580
+ code: safeCloseCode(event?.code, 1e3),
3429
2581
  reason: event?.reason ?? "Client WebSocket closed"
3430
2582
  }));
3431
2583
  return;
3432
2584
  }
3433
2585
  };
3434
2586
  const admitHostTunnel = async (request, roomWorkspaceId, url) => {
3435
- if (!websocketRequest2(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
3436
- 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));
3437
2589
  if (!hostId) return json("host_tunnel_host_required", "Host tunnel id is required", 400);
3438
2590
  const workspaceIds = workspaceIdsFromSearch(url);
3439
2591
  if (!workspaceIds.includes(roomWorkspaceId)) {
@@ -3441,35 +2593,45 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3441
2593
  }
3442
2594
  const token = bearerToken(request.headers.get("authorization"));
3443
2595
  if (!token) return json("host_tunnel_token_required", "Host Tunnel Token is required", 401);
2596
+ let claims;
3444
2597
  try {
3445
- await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
2598
+ claims = await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
3446
2599
  } catch (err) {
3447
2600
  if (err instanceof WorkspaceRelayAuthError) {
3448
2601
  return json(err.code, "Host Tunnel Token was denied", 403);
3449
2602
  }
3450
2603
  throw err;
3451
2604
  }
3452
- const connectedAt = options.now?.() ?? Date.now();
3453
- const pair = acceptSocket({
3454
- kind: "host-tunnel",
3455
- hostId,
3456
- workspaceIds,
3457
- connectedAt
3458
- });
2605
+ const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, claims);
2606
+ if (!decision.ok) return json(decision.code, decision.reason, decision.retryable ? 503 : 403);
3459
2607
  const previous = hostTunnels.get(hostId);
3460
- if (previous) {
3461
- cleanupTunnelResources(previous, "Host tunnel replaced");
3462
- 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);
3463
2610
  }
3464
- 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 = {
3465
2618
  hostId,
3466
2619
  workspaceIds,
3467
2620
  connectedAt,
2621
+ ...fence,
2622
+ generationCheckFailures: 0,
3468
2623
  socket: pair.server,
3469
2624
  pending: /* @__PURE__ */ new Map(),
3470
2625
  channels: /* @__PURE__ */ new Map()
3471
- });
2626
+ };
2627
+ if (previous) {
2628
+ cleanupTunnelResources(previous, "Host tunnel replaced");
2629
+ closeSocket(previous.socket, 1012, "Host tunnel replaced");
2630
+ }
2631
+ hostTunnels.set(hostId, tunnel);
3472
2632
  directory.registerHostTunnel({ hostId, workspaceIds });
2633
+ watchHostGeneration(tunnel);
2634
+ void scheduleHibernatedRevocationCheck();
3473
2635
  if (!hibernation) {
3474
2636
  pair.server.addEventListener?.("message", (event) => {
3475
2637
  if (!event || event.data === void 0) return;
@@ -3481,14 +2643,14 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3481
2643
  return upgradeResponse(pair.client, request);
3482
2644
  };
3483
2645
  const admitClient = async (request, workspaceId) => {
3484
- 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);
3485
2647
  const authorized = await authorizeWorkspaceRelayRequest(relayOptions, request, workspaceId);
3486
2648
  if (!authorized.ok) return authorized.response;
3487
- if (authorized.request.target.access !== "cloud") return admitUserHostedClient(request, authorized.request);
2649
+ if (isUserHostedTarget(authorized.request.target)) return admitUserHostedClient(request, authorized.request);
3488
2650
  return await admitCloudClient(request, authorized.request);
3489
2651
  };
3490
2652
  const admitCloudClient = async (request, authorized) => {
3491
- 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;
3492
2654
  let upstream;
3493
2655
  try {
3494
2656
  upstream = await connectWebSocket(
@@ -3522,9 +2684,9 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3522
2684
  trace.emitted = true;
3523
2685
  sendSocket(pair.server, JSON.stringify({
3524
2686
  type: "relay.trace",
3525
- wsUpstreamOpenMs: roundedMs3(openedAt - trace.upstreamStartedAt),
2687
+ wsUpstreamOpenMs: roundedMs2(openedAt - trace.upstreamStartedAt),
3526
2688
  queuedFrames: trace.queueTimes.length,
3527
- 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))
3528
2690
  }));
3529
2691
  };
3530
2692
  const activeTokenTimer = watchRuntimeAccessToken(client, (reason) => {
@@ -3535,14 +2697,14 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3535
2697
  closed = true;
3536
2698
  if (activeTokenTimer) clearInterval(activeTokenTimer);
3537
2699
  clients.delete(client);
3538
- closeSocket(upstream, safeCloseCode2(event?.code, 1e3), event?.reason ?? "Client WebSocket closed");
2700
+ closeSocket(upstream, safeCloseCode(event?.code, 1e3), event?.reason ?? "Client WebSocket closed");
3539
2701
  };
3540
2702
  const cleanupUpstream = (event) => {
3541
2703
  if (closed) return;
3542
2704
  closed = true;
3543
2705
  if (activeTokenTimer) clearInterval(activeTokenTimer);
3544
2706
  clients.delete(client);
3545
- closeSocket(pair.server, safeCloseCode2(event?.code), event?.reason ?? "");
2707
+ closeSocket(pair.server, safeCloseCode(event?.code), event?.reason ?? "");
3546
2708
  };
3547
2709
  const flushQueue = () => {
3548
2710
  upstreamOpen = true;
@@ -3557,8 +2719,8 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3557
2719
  };
3558
2720
  const deliverClientFrame = orderedFrameDelivery((payload) => {
3559
2721
  if (!upstreamOpen) {
3560
- const frameBytes = preOpenFrameBytes2(payload);
3561
- if (queue.length >= (options.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT2) && queuedBytes + frameBytes > (options.upstreamWebSocketPreOpenQueueMaxBytes ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_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)) {
3562
2724
  closeSocket(pair.server, 1011, "Upstream WebSocket queue limit exceeded");
3563
2725
  cleanupClient({ code: 1011, reason: "Upstream WebSocket queue limit exceeded" });
3564
2726
  return;
@@ -3631,7 +2793,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3631
2793
  }
3632
2794
  const opened = sendSocket(tunnel.socket, JSON.stringify({
3633
2795
  type: "ws.open",
3634
- protocol: TUNNEL_PROTOCOL_VERSION2,
2796
+ protocol: TUNNEL_PROTOCOL_VERSION,
3635
2797
  channel_id: channelId,
3636
2798
  workspace_id: authorized.target.workspaceId,
3637
2799
  path: authorized.path,
@@ -3659,7 +2821,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3659
2821
  })
3660
2822
  );
3661
2823
  if (!authorized.ok) return authorized.response;
3662
- if (authorized.request.target.access !== "cloud") {
2824
+ if (isUserHostedTarget(authorized.request.target)) {
3663
2825
  trace && (trace.routeKind = "user-hosted-http");
3664
2826
  return forwardUserHostedHttp(request, authorized.request, trace);
3665
2827
  }
@@ -3709,7 +2871,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3709
2871
  const init = workspaceRelayForwardRequestInit(request, authorized.relayHostToken, authorized.target.workspaceId, {
3710
2872
  userHosted: true
3711
2873
  });
3712
- const bodyMaxBytes = options.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT2;
2874
+ const bodyMaxBytes = options.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT;
3713
2875
  const contentLength = Number(request.headers.get("content-length") ?? Number.NaN);
3714
2876
  if (Number.isFinite(contentLength) && contentLength > bodyMaxBytes) {
3715
2877
  return json("request_body_too_large", "User-hosted request body exceeds the relay limit", 413);
@@ -3724,7 +2886,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3724
2886
  tunnel.pending.delete(requestId);
3725
2887
  tunnel.socket.send?.(JSON.stringify({
3726
2888
  type: "http.response.flow",
3727
- protocol: TUNNEL_PROTOCOL_VERSION2,
2889
+ protocol: TUNNEL_PROTOCOL_VERSION,
3728
2890
  request_id: requestId,
3729
2891
  paused: false,
3730
2892
  reason: "closed"
@@ -3741,7 +2903,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3741
2903
  });
3742
2904
  tunnel.socket.send?.(JSON.stringify({
3743
2905
  type: "http.request",
3744
- protocol: TUNNEL_PROTOCOL_VERSION2,
2906
+ protocol: TUNNEL_PROTOCOL_VERSION,
3745
2907
  request_id: requestId,
3746
2908
  workspace_id: authorized.target.workspaceId,
3747
2909
  method: request.method,
@@ -3788,7 +2950,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3788
2950
  if (!roomWorkspaceId) return json("relay_workspace_required", "Workspace Relay requests must include a workspace id", 400);
3789
2951
  if (url.pathname.startsWith("/host-tunnels/")) return admitHostTunnel(request, roomWorkspaceId, url);
3790
2952
  if (url.pathname.startsWith("/workspaces/")) {
3791
- if (websocketRequest2(request)) return admitClient(request, roomWorkspaceId);
2953
+ if (websocketRequest(request)) return admitClient(request, roomWorkspaceId);
3792
2954
  const trace = traceRequest(request, "workspace-http", options);
3793
2955
  const roomTotalStartedAt = performance.now();
3794
2956
  const nextRequest = tracedRequest(request, trace);
@@ -3816,7 +2978,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3816
2978
  },
3817
2979
  /**
3818
2980
  * Durable Object alarm entry point. Runs the hibernation-safe revocation
3819
- * sweep (W6b.2). The Worker must forward `alarm()` here.
2981
+ * sweep. The Worker must forward `alarm()` here.
3820
2982
  */
3821
2983
  async alarm() {
3822
2984
  try {
@@ -3860,37 +3022,45 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3860
3022
  export {
3861
3023
  DEFAULT_RELAY_APP_ORIGINS,
3862
3024
  DEFAULT_RELAY_LOCATION_HINT,
3025
+ RELAY_ALLOWED_REQUEST_HEADERS,
3026
+ RELAY_ALLOWED_REQUEST_HEADER_LIST,
3863
3027
  RELAY_LOCATION_HINTS,
3864
3028
  RELAY_REGION_HEADER,
3865
3029
  WorkspaceRelayAuthError,
3866
- __slowConsumerInternalsForTest,
3867
3030
  authorizeWorkspaceRelayRequest,
3868
3031
  bytesToBase64,
3032
+ checkHostTunnelGeneration,
3033
+ createCachedHostGenerationClient,
3869
3034
  createCachedRevocationClient,
3870
3035
  createCachedTargetClient,
3036
+ createHostGenerationResolverLookup,
3871
3037
  createOriginMatcher,
3872
3038
  createWorkspaceRelay,
3873
- createWorkspaceRelayBun,
3874
3039
  createWorkspaceRelayDirectory,
3875
3040
  createWorkspaceRelayDurableObjectGateway,
3876
3041
  createWorkspaceRelayDurableObjectRoom,
3877
3042
  createWorkspaceRelayTrace,
3043
+ deriveRelayHostKid,
3044
+ deriveRelayHostPublicKey,
3878
3045
  disposeAuditSampler,
3879
3046
  disposeWorkspaceRelayDirectory,
3880
3047
  forwardWorkspaceRelayRequest,
3881
3048
  getAuditSuppressedCount,
3049
+ hostTunnelIncumbentOutranks,
3882
3050
  hostTunnelTokenAudience,
3051
+ isHostGeneration,
3883
3052
  isRelayClaimPair,
3884
3053
  mintHostTunnelToken,
3885
3054
  mintRelayHostToken,
3886
3055
  mintRuntimeAccessToken,
3887
3056
  parseAllowedOrigins,
3888
- relayBufferedBytes,
3057
+ parseHostGenerationResult,
3058
+ parseRuntimeAccessTokenActiveResult,
3059
+ parseWorkspaceRelayTarget,
3889
3060
  relayFrameDiagnostics,
3890
3061
  relayHostTokenAudience,
3891
3062
  relayHostTokenIssuer,
3892
3063
  relayLocationHint,
3893
- relayOverBackpressureLimit,
3894
3064
  relayRequestCountry,
3895
3065
  relayRequestRegion,
3896
3066
  runtimeAccessTokenAudience,