@claxedo/workspace-relay 0.10.1 → 0.10.3

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
@@ -450,6 +450,11 @@ var PUBLIC_API_ERRORS = {
450
450
  org_invitation_invalid: { family: "access", status: 409, message: "Invitation is invalid or unavailable" },
451
451
  org_invitation_pending: { family: "access", status: 409, message: "An invitation to this address is already pending" },
452
452
  org_invitation_rate_limited: { family: "access", status: 429, message: "Organization invitation limit exceeded" },
453
+ org_limit_reached: { family: "access", status: 403, message: "An account owns at most 3 organizations" },
454
+ org_creation_rate_limited: { family: "access", status: 429, message: "Organization creation limit exceeded" },
455
+ project_limit_reached: { family: "access", status: 402, message: "Free accounts have 3 projects. Get Cloud or Self-host for unlimited projects." },
456
+ seats_full: { family: "access", status: 402, message: "Add teammate seats to invite more people." },
457
+ email_verification_required: { family: "access", status: 403, message: "A verified email address is required" },
453
458
  org_invitation_email_mismatch: { family: "access", status: 403, message: "Sign in with the invited verified email address" },
454
459
  org_invitation_delivery_unavailable: { family: "access", status: 503, message: "Invitation delivery is unavailable" },
455
460
  team_not_found: { family: "access", status: 404, message: "Team not found" },
@@ -459,6 +464,12 @@ var PUBLIC_API_ERRORS = {
459
464
  project_member_not_found: { family: "access", status: 404, message: "Project member not found" },
460
465
  project_member_owner_immutable: { family: "access", status: 409, message: "The project owner's access cannot be changed" },
461
466
  resource_conflict: { family: "access", status: 409, message: "Organization or team authority changed concurrently" },
467
+ org_not_deletable: { family: "access", status: 409, message: "A personal organization goes with its account, and a deployment's with the deployment" },
468
+ org_has_workspaces: { family: "access", status: 409, message: "Delete the organization's workspaces first" },
469
+ account_owns_workspaces: { family: "access", status: 409, message: "Delete your workspaces first" },
470
+ account_founds_organization: { family: "access", status: 409, message: "Delete the organizations you founded first" },
471
+ account_has_cloud_subscription: { family: "access", status: 409, message: "Cancel Cloud before deleting this account" },
472
+ recent_sign_in_required: { family: "access", status: 403, message: "Sign in again to confirm this deletion" },
462
473
  session_not_found: { family: "session_share", status: 404, message: "This session is not on the control plane, so it cannot be shared from People yet." },
463
474
  session_share_admin_required: { family: "session_share", status: 403, message: "Only the person who started this session can manage its People." },
464
475
  session_share_target_required: { family: "session_share", status: 400, message: "A session is shared with exactly one person" },
@@ -482,11 +493,19 @@ var PUBLIC_API_ERRORS = {
482
493
  enrollment_paused: { status: 403, message: "enrollment_paused" },
483
494
  enrollment_owner_ineligible: { status: 403, message: "enrollment_owner_ineligible" },
484
495
  enrollment_key_version_mismatch: { status: 403, message: "enrollment_key_version_mismatch" },
496
+ enrollment_denied: { status: 403, message: "enrollment_denied" },
497
+ enrollment_approval_expired: { status: 403, message: "enrollment_approval_expired" },
498
+ enrollment_awaiting_approval: { status: 403, message: "enrollment_awaiting_approval" },
485
499
  invitation_invalid: { status: 403, message: "invitation_invalid" },
486
500
  invitation_expired: { status: 410, message: "invitation_expired" },
487
501
  invitation_revoked: { status: 410, message: "invitation_revoked" },
488
502
  invitation_redeemed: { status: 409, message: "invitation_redeemed" },
489
503
  invitation_host_conflict: { status: 409, message: "invitation_host_conflict" },
504
+ pairing_invalid: { status: 403, message: "pairing_invalid" },
505
+ pairing_expired: { status: 410, message: "pairing_expired" },
506
+ pairing_used: { status: 409, message: "pairing_used" },
507
+ pairing_owner_signed_out: { status: 410, message: "pairing_owner_signed_out" },
508
+ pairing_decided: { status: 409, message: "pairing_decided" },
490
509
  enrollment_generation_superseded: { status: 409, message: "enrollment_generation_superseded" },
491
510
  host_assignment_outside_scope: { status: 400, message: "host_assignment_outside_scope" },
492
511
  host_sealing_key_undeclared: { status: 409, message: "host_sealing_key_undeclared" },
@@ -674,7 +693,7 @@ function hostTunnelIncumbentOutranks(incumbentGeneration, candidateGeneration) {
674
693
  }
675
694
  var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
676
695
  var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
677
- var RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 1e4;
696
+ var RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 3e4;
678
697
  var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
679
698
  var relayHostTokenCaches = /* @__PURE__ */ new WeakMap();
680
699
  var runtimeAccessTokenCaches = /* @__PURE__ */ new WeakMap();
@@ -1081,6 +1100,246 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
1081
1100
  }
1082
1101
  }
1083
1102
 
1103
+ // ../claxedo-helpers/dist/deadline.mjs
1104
+ async function withDeadlineSignal(timeoutMs, work, parent) {
1105
+ const deadline = new AbortController();
1106
+ const timer = setTimeout(() => deadline.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
1107
+ try {
1108
+ return await work(parent ? AbortSignal.any([parent, deadline.signal]) : deadline.signal);
1109
+ } finally {
1110
+ clearTimeout(timer);
1111
+ }
1112
+ }
1113
+
1114
+ // src/resolver-clients.ts
1115
+ function parseRuntimeAccessTokenActiveResult(input) {
1116
+ if (!isRecord(input)) return void 0;
1117
+ const row = input;
1118
+ if (row.active === true) return { active: true };
1119
+ if (row.active !== false) return void 0;
1120
+ if (typeof row.code !== "string" || typeof row.reason !== "string") return void 0;
1121
+ return { active: false, code: row.code, reason: row.reason };
1122
+ }
1123
+ function parseHostGenerationResult(input) {
1124
+ if (!isRecord(input)) return void 0;
1125
+ const row = input;
1126
+ if (typeof row.enrollmentId !== "string" || !row.enrollmentId.trim()) return void 0;
1127
+ if (!isHostGeneration(row.generation) || typeof row.revoked !== "boolean") return void 0;
1128
+ return { enrollmentId: row.enrollmentId, generation: row.generation, revoked: row.revoked };
1129
+ }
1130
+ var HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT = 5e3;
1131
+ var HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE = "relay_resolver_enrollment_not_found";
1132
+ function createHostGenerationResolverLookup(url, options) {
1133
+ const fetcher = options.fetch ?? ((target, init) => fetch(target, init));
1134
+ const timeoutMs = options.timeoutMs ?? HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT;
1135
+ return async ({ enrollmentId }) => {
1136
+ const target = new URL(url);
1137
+ target.searchParams.set("enrollmentId", enrollmentId);
1138
+ return withDeadlineSignal(timeoutMs, async (signal) => {
1139
+ const res = await fetcher(target, { headers: options.headers, signal });
1140
+ if (res.status === 404) {
1141
+ const body = await res.json().catch(() => void 0);
1142
+ const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
1143
+ if (error?.code === HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE) return void 0;
1144
+ throw new Error(`relay host-generation resolver failed: 404 (no host-generation route at ${url})`);
1145
+ }
1146
+ if (!res.ok) throw new Error(`relay host-generation resolver failed: ${res.status} ${await res.text()}`);
1147
+ const result = parseHostGenerationResult(await res.json());
1148
+ if (!result) throw new Error("relay host-generation resolver returned a malformed result");
1149
+ return result;
1150
+ });
1151
+ };
1152
+ }
1153
+ var RESOLVER_CACHE_MAX_ENTRIES = 8192;
1154
+ var REVOCATION_CACHE_TTL_MS_DEFAULT = 3e4;
1155
+ var TARGET_CACHE_TTL_MS_DEFAULT = 3e4;
1156
+ var DURABLE_OBJECT_TARGET_CACHE_TTL_MS_DEFAULT = 30 * 6e4;
1157
+ function pruneExpiringCache(cache, now, maxEntries) {
1158
+ for (const [key, entry] of cache) {
1159
+ if (entry.expiresAt <= now) cache.delete(key);
1160
+ }
1161
+ while (cache.size >= maxEntries) {
1162
+ const key = cache.keys().next().value;
1163
+ if (!key) return;
1164
+ cache.delete(key);
1165
+ }
1166
+ }
1167
+ var REVOCATION_RESOLVER_UNAVAILABLE = "relay_revocation_resolver_unavailable";
1168
+ function createCachedRevocationClient(inner, options = {}) {
1169
+ const ttlMs = options.ttlMs ?? REVOCATION_CACHE_TTL_MS_DEFAULT;
1170
+ const now = options.now ?? Date.now;
1171
+ const cache = /* @__PURE__ */ new Map();
1172
+ const ask = (args, at) => {
1173
+ const prior = cache.get(args.jti)?.held;
1174
+ const settle = (standing) => {
1175
+ const settledAt = now();
1176
+ const kept = prior && settledAt - prior.checkedAt < ttlMs ? prior : void 0;
1177
+ const unreachable = !standing || !standing.result.active && standing.result.code === REVOCATION_RESOLVER_UNAVAILABLE;
1178
+ const held = unreachable && kept ? kept : standing;
1179
+ if (!held) {
1180
+ cache.delete(args.jti);
1181
+ return;
1182
+ }
1183
+ pruneExpiringCache(cache, settledAt, RESOLVER_CACHE_MAX_ENTRIES);
1184
+ cache.set(args.jti, { held, expiresAt: held.checkedAt + ttlMs });
1185
+ };
1186
+ const asking = inner(args).then(
1187
+ (result) => {
1188
+ const standing = { result, checkedAt: now() };
1189
+ settle(standing);
1190
+ return standing;
1191
+ },
1192
+ (err) => {
1193
+ settle();
1194
+ throw err;
1195
+ }
1196
+ );
1197
+ cache.set(args.jti, { asking, ...prior ? { held: prior } : {}, expiresAt: at + ttlMs });
1198
+ return asking;
1199
+ };
1200
+ const within = async (args, maxAgeMs) => {
1201
+ const at = now();
1202
+ const entry = cache.get(args.jti);
1203
+ if (entry?.held && at - entry.held.checkedAt < Math.min(maxAgeMs, ttlMs)) return entry.held;
1204
+ return await (entry?.asking ?? ask(args, at));
1205
+ };
1206
+ return Object.assign(async (args) => (await within(args, ttlMs)).result, { within, ttlMs });
1207
+ }
1208
+ function runtimeAccessTokenRevocationDelayMs(input = {}) {
1209
+ const configuredTtl = input.revocationCacheTtlMs ?? REVOCATION_CACHE_TTL_MS_DEFAULT;
1210
+ const ttlMs = Number.isFinite(configuredTtl) && configuredTtl > 0 ? configuredTtl : 0;
1211
+ if (input.activeCheckIntervalMs === void 0) return ttlMs;
1212
+ if (!Number.isFinite(input.activeCheckIntervalMs) || input.activeCheckIntervalMs <= 0) {
1213
+ return Number.POSITIVE_INFINITY;
1214
+ }
1215
+ return ttlMs + input.activeCheckIntervalMs;
1216
+ }
1217
+ function createCachedHostGenerationClient(inner, options = {}) {
1218
+ const ttlMs = options.ttlMs ?? 1e4;
1219
+ const now = options.now ?? Date.now;
1220
+ const cache = /* @__PURE__ */ new Map();
1221
+ return async (args) => {
1222
+ for (; ; ) {
1223
+ const at = now();
1224
+ const entry = cache.get(args.enrollmentId);
1225
+ if (entry && entry.expiresAt > at) {
1226
+ if (entry.result && entry.result.generation >= args.generation) return entry.result;
1227
+ if (!entry.result && entry.promise) {
1228
+ const shared = await entry.promise;
1229
+ if (!shared || shared.generation >= args.generation) return shared;
1230
+ continue;
1231
+ }
1232
+ }
1233
+ const promise = inner(args).then((result) => {
1234
+ pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
1235
+ if (result) cache.set(args.enrollmentId, { result, expiresAt: now() + ttlMs });
1236
+ else cache.delete(args.enrollmentId);
1237
+ return result;
1238
+ }).catch((err) => {
1239
+ cache.delete(args.enrollmentId);
1240
+ throw err;
1241
+ });
1242
+ cache.set(args.enrollmentId, { promise, expiresAt: at + ttlMs });
1243
+ return await promise;
1244
+ }
1245
+ };
1246
+ }
1247
+ function createCachedTargetClient(inner, options = {}) {
1248
+ const ttlMs = options.ttlMs ?? TARGET_CACHE_TTL_MS_DEFAULT;
1249
+ const durableObjectTtlMs = options.durableObjectTtlMs ?? DURABLE_OBJECT_TARGET_CACHE_TTL_MS_DEFAULT;
1250
+ const now = options.now ?? Date.now;
1251
+ const cache = /* @__PURE__ */ new Map();
1252
+ return async (args) => {
1253
+ const key = `${args.workspaceId}\0${args.hostId}\0${args.routingId ?? ""}`;
1254
+ const at = now();
1255
+ const entry = cache.get(key);
1256
+ if (entry && entry.expiresAt > at) {
1257
+ if (entry.target) return entry.target;
1258
+ if (entry.promise) return await entry.promise;
1259
+ }
1260
+ const promise = inner(args).then((target) => {
1261
+ pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
1262
+ if (target) cache.set(key, { target, expiresAt: now() + (target.backing === "durable-object" ? durableObjectTtlMs : ttlMs) });
1263
+ else cache.delete(key);
1264
+ return target;
1265
+ }).catch((err) => {
1266
+ cache.delete(key);
1267
+ throw err;
1268
+ });
1269
+ cache.set(key, { promise, expiresAt: at + ttlMs });
1270
+ return await promise;
1271
+ };
1272
+ }
1273
+
1274
+ // src/stream-standing.ts
1275
+ var STANDING_REFRESH_LEAD_MS = 2e3;
1276
+ function guardStreamStanding(response, claims, options) {
1277
+ const source = response.body;
1278
+ const read = options.standing;
1279
+ if (!source || !read) return response;
1280
+ const now = options.now ?? Date.now;
1281
+ const reader = source.getReader();
1282
+ const refreshMs = Math.max(options.boundMs - (options.leadMs ?? STANDING_REFRESH_LEAD_MS), 1);
1283
+ let timer;
1284
+ let failures = 0;
1285
+ let done = false;
1286
+ const body = new ReadableStream({
1287
+ start(controller) {
1288
+ const end = (reason) => {
1289
+ if (done) return;
1290
+ done = true;
1291
+ clearTimeout(timer);
1292
+ controller.error(new Error(reason));
1293
+ void reader.cancel(reason).catch(() => {
1294
+ });
1295
+ };
1296
+ const wait = (checkedAt) => {
1297
+ if (done) return;
1298
+ const expiresAt = claims.exp * 1e3;
1299
+ const due = Math.min(checkedAt + refreshMs, expiresAt);
1300
+ timer = setTimeout(() => {
1301
+ if (now() >= expiresAt) return end("Runtime Access Token expired");
1302
+ void check(refreshMs);
1303
+ }, Math.max(due - now(), 0));
1304
+ };
1305
+ const check = async (maxAgeMs) => {
1306
+ const standing = await read(claims, maxAgeMs).catch(() => void 0);
1307
+ if (done) return;
1308
+ const unreachable = !standing || !standing.result.active && standing.result.code === REVOCATION_RESOLVER_UNAVAILABLE;
1309
+ if (unreachable) {
1310
+ failures += 1;
1311
+ return failures >= options.graceAttempts ? end("Runtime Access Token could not be checked") : wait(now());
1312
+ }
1313
+ if (!standing.result.active) return end(standing.result.reason);
1314
+ failures = 0;
1315
+ wait(standing.checkedAt);
1316
+ };
1317
+ void check(options.boundMs);
1318
+ },
1319
+ async pull(controller) {
1320
+ try {
1321
+ const chunk = await reader.read();
1322
+ if (done) return;
1323
+ if (!chunk.done) return controller.enqueue(chunk.value);
1324
+ done = true;
1325
+ clearTimeout(timer);
1326
+ controller.close();
1327
+ } catch (err) {
1328
+ if (done) return;
1329
+ done = true;
1330
+ clearTimeout(timer);
1331
+ controller.error(err);
1332
+ }
1333
+ },
1334
+ cancel(reason) {
1335
+ done = true;
1336
+ clearTimeout(timer);
1337
+ return reader.cancel(reason);
1338
+ }
1339
+ });
1340
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
1341
+ }
1342
+
1084
1343
  // src/directory.ts
1085
1344
  function presenceKey(hostId, workspaceId) {
1086
1345
  return `${hostId}\0${workspaceId}`;
@@ -1323,18 +1582,17 @@ async function relayRoomPlacement(request, input) {
1323
1582
  const configured = trimToUndefined2(input.configured);
1324
1583
  const region = relayRequestRegion(request);
1325
1584
  const country = relayRequestCountry(request);
1326
- const floor = relayLocationHint(configured ? { configured } : {});
1327
- const declared = relayLocationHint({
1328
- ...configured ? { configured } : {},
1329
- ...region ? { region } : {},
1330
- ...country ? { country } : {}
1331
- });
1332
1585
  const headers = new Headers(request.headers);
1333
1586
  headers.delete(RELAY_REGION_HEADER);
1334
1587
  const credential = await input.credential();
1335
1588
  const sessionHost = credential !== void 0 && sessionHostRootOf(credential.hostId) !== void 0;
1589
+ const declared = relayLocationHint({
1590
+ ...configured ? { configured } : {},
1591
+ ...credential?.declaresRegion && region ? { region } : {},
1592
+ ...credential && country ? { country } : {}
1593
+ });
1336
1594
  return {
1337
- locationHint: sessionHost ? SESSION_HOST_LOCATION_HINT : credential || declared === floor ? declared : floor,
1595
+ locationHint: sessionHost ? SESSION_HOST_LOCATION_HINT : declared,
1338
1596
  url: withoutQueryParams(request.url, RELAY_REGION_QUERY_PARAMS),
1339
1597
  headers
1340
1598
  };
@@ -1410,12 +1668,12 @@ async function relayPlacementCredential(request, env, url, workspaceId) {
1410
1668
  const token2 = bearerToken(request.headers.get("authorization"));
1411
1669
  if (!hostId || !token2) return void 0;
1412
1670
  await verifyHostTunnelToken(token2, key, { hostId, workspaceIds: workspaceIdsFromSearch(url) });
1413
- return { hostId };
1671
+ return { hostId, declaresRegion: true };
1414
1672
  }
1415
1673
  const token = runtimeAccessTokenFrom(request);
1416
1674
  if (!token) return void 0;
1417
1675
  const claims = await verifyRuntimeAccessToken(token, key, { workspaceId });
1418
- return { hostId: claims.host_id };
1676
+ return { hostId: claims.host_id, declaresRegion: claims.routing_id !== void 0 };
1419
1677
  } catch {
1420
1678
  return void 0;
1421
1679
  }
@@ -1427,6 +1685,136 @@ function workspaceRelayRoomPlacement(request, env, workspaceId) {
1427
1685
  });
1428
1686
  }
1429
1687
 
1688
+ // src/relay-bytes.ts
1689
+ var BASE64_CHUNK_BYTES = 32768;
1690
+ function bytesToBase64(bytes) {
1691
+ let binary = "";
1692
+ for (let i = 0; i < bytes.length; i += BASE64_CHUNK_BYTES) {
1693
+ binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_BYTES));
1694
+ }
1695
+ return btoa(binary);
1696
+ }
1697
+ function base64ToBytes(input) {
1698
+ return Uint8Array.from(atob(input), (char) => char.charCodeAt(0));
1699
+ }
1700
+ function concatBytes(chunks) {
1701
+ const length = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
1702
+ const result = new Uint8Array(length);
1703
+ let offset = 0;
1704
+ for (const chunk of chunks) {
1705
+ result.set(chunk, offset);
1706
+ offset += chunk.byteLength;
1707
+ }
1708
+ return result;
1709
+ }
1710
+ async function readRequestBodyCapped(request, maxBytes) {
1711
+ if (!request.body) return new Uint8Array(0);
1712
+ const reader = request.body.getReader();
1713
+ const chunks = [];
1714
+ let total = 0;
1715
+ while (true) {
1716
+ const { done, value } = await reader.read();
1717
+ if (done) break;
1718
+ total += value.byteLength;
1719
+ if (total > maxBytes) {
1720
+ await reader.cancel().catch(() => {
1721
+ });
1722
+ return "too_large";
1723
+ }
1724
+ chunks.push(value);
1725
+ }
1726
+ return concatBytes(chunks);
1727
+ }
1728
+
1729
+ // src/relay-log.ts
1730
+ function relayLogOf(options) {
1731
+ return options.relayLog ?? ((event) => console.log(JSON.stringify(event)));
1732
+ }
1733
+ function frameDropWorthLogging(dropped) {
1734
+ return dropped <= 3 || dropped % 100 === 0;
1735
+ }
1736
+ var TIMED_OUT_REQUESTS_REMEMBERED = 32;
1737
+ function rememberTimedOut(timedOut, requestId, at) {
1738
+ timedOut.set(requestId, at);
1739
+ if (timedOut.size <= TIMED_OUT_REQUESTS_REMEMBERED) return;
1740
+ const oldest = timedOut.keys().next().value;
1741
+ if (oldest !== void 0) timedOut.delete(oldest);
1742
+ }
1743
+
1744
+ // src/socket-frames.ts
1745
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_FRAME_OVERHEAD_BYTES = 64;
1746
+ function preOpenFrameBytes(input) {
1747
+ const payload = typeof input === "string" ? new TextEncoder().encode(input).byteLength : input.byteLength;
1748
+ return payload + UPSTREAM_WS_PRE_OPEN_QUEUE_FRAME_OVERHEAD_BYTES;
1749
+ }
1750
+ function isBlobLike(input) {
1751
+ if (!input || typeof input !== "object") return false;
1752
+ return "arrayBuffer" in input && typeof input.arrayBuffer === "function";
1753
+ }
1754
+ function viewBytes(input) {
1755
+ return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
1756
+ }
1757
+ function socketFrame(input) {
1758
+ if (typeof input === "string") {
1759
+ return {
1760
+ binary: false,
1761
+ data_base64: bytesToBase64(new TextEncoder().encode(input))
1762
+ };
1763
+ }
1764
+ if (input instanceof ArrayBuffer) {
1765
+ return {
1766
+ binary: true,
1767
+ data_base64: bytesToBase64(new Uint8Array(input))
1768
+ };
1769
+ }
1770
+ if (input instanceof Uint8Array) {
1771
+ return {
1772
+ binary: true,
1773
+ data_base64: bytesToBase64(input)
1774
+ };
1775
+ }
1776
+ if (ArrayBuffer.isView(input)) {
1777
+ return {
1778
+ binary: true,
1779
+ data_base64: bytesToBase64(viewBytes(input))
1780
+ };
1781
+ }
1782
+ if (!isBlobLike(input)) return void 0;
1783
+ return input.arrayBuffer().then((buffer) => ({
1784
+ binary: true,
1785
+ data_base64: bytesToBase64(new Uint8Array(buffer))
1786
+ }));
1787
+ }
1788
+ function socketPayload(input) {
1789
+ if (typeof input === "string") return input;
1790
+ if (input instanceof ArrayBuffer) return input;
1791
+ if (input instanceof Uint8Array) return input;
1792
+ if (ArrayBuffer.isView(input)) return viewBytes(input);
1793
+ if (!isBlobLike(input)) return void 0;
1794
+ return input.arrayBuffer();
1795
+ }
1796
+ function orderedFrameDelivery(deliver) {
1797
+ let tail;
1798
+ return (data) => {
1799
+ const payload = socketPayload(data);
1800
+ if (payload === void 0) return;
1801
+ if (tail === void 0 && !(payload instanceof Promise)) {
1802
+ deliver(payload);
1803
+ return;
1804
+ }
1805
+ const queued = (tail ?? Promise.resolve()).then(async () => deliver(await payload)).catch(() => {
1806
+ });
1807
+ tail = queued;
1808
+ void queued.then(() => {
1809
+ if (tail === queued) tail = void 0;
1810
+ });
1811
+ };
1812
+ }
1813
+ function tunnelFrame(input) {
1814
+ const bytes = base64ToBytes(input.data_base64);
1815
+ return input.binary ? bytes : new TextDecoder().decode(bytes);
1816
+ }
1817
+
1430
1818
  // src/relay-trace.ts
1431
1819
  import { trimToUndefined as trimToUndefined4 } from "@claxedo/helpers/string";
1432
1820
  var TRACE_ID_HEADER = "x-claxedo-trace-id";
@@ -1443,12 +1831,15 @@ function traceRequest(request, routeKind, options) {
1443
1831
  const forceSecret = trimToUndefined4(options.traceForceHeaderSecret);
1444
1832
  const forced = !!forceSecret && request.headers.get(TRACE_FORCE_HEADER) === forceSecret;
1445
1833
  const rate = clampRate(options.traceSampleRate);
1834
+ const sampled = forced || rate > 0 && (options.random ?? Math.random)() < rate;
1835
+ const roomColo = sampled ? options.roomColo?.() : void 0;
1446
1836
  return {
1447
1837
  id,
1448
- sampled: forced || rate > 0 && (options.random ?? Math.random)() < rate,
1838
+ sampled,
1449
1839
  startedAt: performance.now(),
1450
1840
  routeKind,
1451
- phases: []
1841
+ phases: [],
1842
+ ...roomColo ? { roomColo } : {}
1452
1843
  };
1453
1844
  }
1454
1845
  async function traceSpan(trace, name, fn) {
@@ -1489,7 +1880,8 @@ function webSocketTraceFrame(upstreamStartedAt, queueTimes, openedAt) {
1489
1880
  function withTraceHeaders(response, trace) {
1490
1881
  const headers = new Headers(response.headers);
1491
1882
  headers.set(TRACE_ID_HEADER, trace.id);
1492
- const timing = trace.sampled ? serverTiming(trace.phases) : "";
1883
+ const colo = trace.roomColo ? `relay-room-colo;desc=${trace.roomColo}` : "";
1884
+ const timing = trace.sampled ? [serverTiming(trace.phases), colo].filter(Boolean).join(", ") : "";
1493
1885
  if (timing) {
1494
1886
  const existing = headers.get("server-timing");
1495
1887
  headers.set("server-timing", existing ? `${existing}, ${timing}` : timing);
@@ -1501,10 +1893,11 @@ function withTraceHeaders(response, trace) {
1501
1893
  headers
1502
1894
  });
1503
1895
  }
1896
+ var SLOW_REQUEST_MS = 5e3;
1504
1897
  function logTrace(options, request, workspaceId, trace, response) {
1505
- if (!trace.sampled) return;
1506
- const url = new URL(request.url);
1507
1898
  const totalMs = performance.now() - trace.startedAt;
1899
+ if (!trace.sampled && response.status < 500 && totalMs <= SLOW_REQUEST_MS) return;
1900
+ const url = new URL(request.url);
1508
1901
  const event = {
1509
1902
  event: "workspace_relay_request",
1510
1903
  traceId: trace.id,
@@ -1520,181 +1913,58 @@ function logTrace(options, request, workspaceId, trace, response) {
1520
1913
  options.traceLog(event);
1521
1914
  return;
1522
1915
  }
1523
- console.log(JSON.stringify(event));
1524
- }
1525
-
1526
- // src/cloudflare.ts
1527
- var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
1528
- var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
1529
- var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
1530
- var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1531
- var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1532
- var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
1533
- var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
1534
- var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 8192;
1535
- var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
1536
- var UPSTREAM_WS_PRE_OPEN_QUEUE_FRAME_OVERHEAD_BYTES = 64;
1537
- var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1538
- var RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
1539
- var HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT = 3e4;
1540
- var HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1541
- function unrefTimer(timer) {
1542
- if (typeof timer.unref === "function") timer.unref();
1543
- }
1544
- function isHostTunnelClient(client) {
1545
- return "channelId" in client && typeof client.channelId === "string";
1546
- }
1547
- function workspaceIdFromRelayPath(pathname) {
1548
- return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
1549
- }
1550
- function websocketRequest(request) {
1551
- return request.headers.get("upgrade")?.toLowerCase() === "websocket";
1552
- }
1553
- function relayWebSocketTraceEnabled(request) {
1554
- return request.headers.get("x-claxedo-relay-ws-trace") === "1";
1555
- }
1556
- function randomToken() {
1557
- return crypto.randomUUID();
1558
- }
1559
- function headerRecord(headers) {
1560
- const result = /* @__PURE__ */ Object.create(null);
1561
- headers.forEach((value, key) => {
1562
- result[key] = value;
1563
- });
1564
- return result;
1565
- }
1566
- function headersFromRecord(input) {
1567
- const headers = new Headers();
1568
- for (const [key, value] of Object.entries(input)) headers.set(key, value);
1569
- return headers;
1570
- }
1571
- var BASE64_CHUNK_BYTES = 32768;
1572
- function bytesToBase64(bytes) {
1573
- let binary = "";
1574
- for (let i = 0; i < bytes.length; i += BASE64_CHUNK_BYTES) {
1575
- binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_BYTES));
1576
- }
1577
- return btoa(binary);
1578
- }
1579
- function base64ToBytes(input) {
1580
- return Uint8Array.from(atob(input), (char) => char.charCodeAt(0));
1581
- }
1582
- function preOpenFrameBytes(input) {
1583
- const payload = typeof input === "string" ? new TextEncoder().encode(input).byteLength : input.byteLength;
1584
- return payload + UPSTREAM_WS_PRE_OPEN_QUEUE_FRAME_OVERHEAD_BYTES;
1585
- }
1586
- function isBlobLike(input) {
1587
- if (!input || typeof input !== "object") return false;
1588
- return "arrayBuffer" in input && typeof input.arrayBuffer === "function";
1589
- }
1590
- function viewBytes(input) {
1591
- return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
1592
- }
1593
- function socketFrame(input) {
1594
- if (typeof input === "string") {
1595
- return {
1596
- binary: false,
1597
- data_base64: bytesToBase64(new TextEncoder().encode(input))
1598
- };
1599
- }
1600
- if (input instanceof ArrayBuffer) {
1601
- return {
1602
- binary: true,
1603
- data_base64: bytesToBase64(new Uint8Array(input))
1604
- };
1605
- }
1606
- if (input instanceof Uint8Array) {
1607
- return {
1608
- binary: true,
1609
- data_base64: bytesToBase64(input)
1610
- };
1611
- }
1612
- if (ArrayBuffer.isView(input)) {
1613
- return {
1614
- binary: true,
1615
- data_base64: bytesToBase64(viewBytes(input))
1616
- };
1617
- }
1618
- if (!isBlobLike(input)) return void 0;
1619
- return input.arrayBuffer().then((buffer) => ({
1620
- binary: true,
1621
- data_base64: bytesToBase64(new Uint8Array(buffer))
1622
- }));
1916
+ console.log(JSON.stringify(event));
1623
1917
  }
1624
- function socketPayload(input) {
1625
- if (typeof input === "string") return input;
1626
- if (input instanceof ArrayBuffer) return input;
1627
- if (input instanceof Uint8Array) return input;
1628
- if (ArrayBuffer.isView(input)) return viewBytes(input);
1629
- if (!isBlobLike(input)) return void 0;
1630
- return input.arrayBuffer();
1918
+
1919
+ // src/cloudflare.ts
1920
+ var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
1921
+ var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
1922
+ var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
1923
+ var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1924
+ var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1925
+ var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
1926
+ var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
1927
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 8192;
1928
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
1929
+ var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1930
+ var RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
1931
+ var HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT = 3e4;
1932
+ var HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1933
+ function unrefTimer(timer) {
1934
+ if (typeof timer.unref === "function") timer.unref();
1631
1935
  }
1632
- function orderedFrameDelivery(deliver) {
1633
- let tail;
1634
- return (data) => {
1635
- const payload = socketPayload(data);
1636
- if (payload === void 0) return;
1637
- if (tail === void 0 && !(payload instanceof Promise)) {
1638
- deliver(payload);
1639
- return;
1640
- }
1641
- const queued = (tail ?? Promise.resolve()).then(async () => deliver(await payload)).catch(() => {
1642
- });
1643
- tail = queued;
1644
- void queued.then(() => {
1645
- if (tail === queued) tail = void 0;
1646
- });
1647
- };
1936
+ function isHostTunnelClient(client) {
1937
+ return "channelId" in client && typeof client.channelId === "string";
1648
1938
  }
1649
- async function relayFrameDiagnostics(data) {
1650
- const frame = await socketFrame(data);
1651
- const payload = await socketPayload(data);
1652
- return {
1653
- // `undefined` here is exactly the silent drop: every caller treats it as
1654
- // "nothing to forward" and returns without a throw or a log.
1655
- frameDropped: frame === void 0,
1656
- payloadDropped: payload === void 0,
1657
- ...frame ? { binary: frame.binary, dataBase64: frame.data_base64 } : {},
1658
- payloadBase64: payload === void 0 ? void 0 : typeof payload === "string" ? bytesToBase64(new TextEncoder().encode(payload)) : bytesToBase64(payload instanceof ArrayBuffer ? new Uint8Array(payload) : payload)
1659
- };
1939
+ function workspaceIdFromRelayPath(pathname) {
1940
+ return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
1660
1941
  }
1661
- function tunnelFrame(input) {
1662
- const bytes = base64ToBytes(input.data_base64);
1663
- return input.binary ? bytes : new TextDecoder().decode(bytes);
1942
+ function websocketRequest(request) {
1943
+ return request.headers.get("upgrade")?.toLowerCase() === "websocket";
1664
1944
  }
1665
- function concatBytes(chunks) {
1666
- const length = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
1667
- const result = new Uint8Array(length);
1668
- let offset = 0;
1669
- for (const chunk of chunks) {
1670
- result.set(chunk, offset);
1671
- offset += chunk.byteLength;
1672
- }
1945
+ function relayWebSocketTraceEnabled(request) {
1946
+ return request.headers.get("x-claxedo-relay-ws-trace") === "1";
1947
+ }
1948
+ function randomToken() {
1949
+ return crypto.randomUUID();
1950
+ }
1951
+ function headerRecord(headers) {
1952
+ const result = /* @__PURE__ */ Object.create(null);
1953
+ headers.forEach((value, key) => {
1954
+ result[key] = value;
1955
+ });
1673
1956
  return result;
1674
1957
  }
1958
+ function headersFromRecord(input) {
1959
+ const headers = new Headers();
1960
+ for (const [key, value] of Object.entries(input)) headers.set(key, value);
1961
+ return headers;
1962
+ }
1675
1963
  function responseMustNotHaveBody(status) {
1676
1964
  return status === 204 || status === 205 || status === 304;
1677
1965
  }
1678
- async function readRequestBodyCapped(request, maxBytes) {
1679
- if (!request.body) return new Uint8Array(0);
1680
- const reader = request.body.getReader();
1681
- const chunks = [];
1682
- let total = 0;
1683
- while (true) {
1684
- const { done, value } = await reader.read();
1685
- if (done) break;
1686
- total += value.byteLength;
1687
- if (total > maxBytes) {
1688
- await reader.cancel().catch(() => {
1689
- });
1690
- return "too_large";
1691
- }
1692
- chunks.push(value);
1693
- }
1694
- return concatBytes(chunks);
1695
- }
1696
1966
  function workspaceRelayDurableObjectRoomName(workspaceId) {
1697
- return `workspace:${workspaceId}`;
1967
+ return `relay-room:${workspaceId}`;
1698
1968
  }
1699
1969
  function workspaceRelayDurableObjectWorkspaceId(request) {
1700
1970
  const url = new URL(request.url);
@@ -1865,7 +2135,22 @@ function createWorkspaceRelayDurableObjectRoom(options) {
1865
2135
  return false;
1866
2136
  }
1867
2137
  };
1868
- const cleanupTunnelResources = (tunnel, reason) => {
2138
+ const relayLog = relayLogOf(options);
2139
+ const clock = () => options.now?.() ?? Date.now();
2140
+ const cleanupTunnelResources = (tunnel, reason, detail = {}) => {
2141
+ if (!tunnel.ended) {
2142
+ relayLog({
2143
+ event: "tunnel_ended",
2144
+ hostId: tunnel.hostId,
2145
+ generation: tunnel.generation,
2146
+ reason,
2147
+ ageMs: clock() - tunnel.connectedAt,
2148
+ pendingFailed: tunnel.pending.size,
2149
+ channelsClosed: tunnel.channels.size,
2150
+ ...detail
2151
+ });
2152
+ }
2153
+ tunnel.ended = true;
1869
2154
  if (tunnel.generationWatcher) clearInterval(tunnel.generationWatcher);
1870
2155
  tunnel.generationWatcher = void 0;
1871
2156
  for (const pending of tunnel.pending.values()) {
@@ -1911,6 +2196,8 @@ function createWorkspaceRelayDurableObjectRoom(options) {
1911
2196
  generationCheckFailures: attachment.generationCheckFailures ?? 0,
1912
2197
  socket,
1913
2198
  pending: /* @__PURE__ */ new Map(),
2199
+ timedOut: /* @__PURE__ */ new Map(),
2200
+ droppedFrames: 0,
1914
2201
  channels: /* @__PURE__ */ new Map()
1915
2202
  });
1916
2203
  directory.registerHostTunnel({
@@ -1956,7 +2243,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
1956
2243
  }
1957
2244
  };
1958
2245
  const resolverGraceAttempts = options.resolverOutageGraceAttempts ?? RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT;
1959
- const resolverUnreachable = (code) => code === "relay_revocation_resolver_unavailable";
2246
+ const resolverUnreachable = (code) => code === REVOCATION_RESOLVER_UNAVAILABLE;
1960
2247
  const watchRuntimeAccessToken = (client, onInactive) => {
1961
2248
  const intervalMs = options.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
1962
2249
  if (intervalMs <= 0) return void 0;
@@ -2248,8 +2535,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2248
2535
  }));
2249
2536
  };
2250
2537
  const reportFrameHandlerFailure = (socket, err) => {
2251
- const reason = err instanceof Error ? err.message : String(err);
2252
- console.error(`[workspace-relay] frame handler failed: ${reason}`);
2538
+ options.report(err, "frame_handler");
2253
2539
  closeSocket(socket, 1011, "Workspace relay failed to process a frame");
2254
2540
  };
2255
2541
  const hibernatedRevocationIntervalMs = () => options.hibernatedRevocationCheckIntervalMs ?? HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT;
@@ -2363,10 +2649,23 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2363
2649
  };
2364
2650
  const handleTunnelMessage = async (hostId, socket, event) => {
2365
2651
  const row = parseTunnelMessageData(event.data);
2366
- if (!row.ok) return;
2367
- const parsed = validateTunnelMessage(row.value);
2652
+ const parsed = row.ok ? validateTunnelMessage(row.value) : { ok: false, reason: "not_json" };
2368
2653
  const tunnel = hostTunnels.get(hostId);
2369
- if (!parsed.ok || !tunnel || tunnel.socket !== socket) return;
2654
+ if (!tunnel || tunnel.socket !== socket) return;
2655
+ if (!parsed.ok) {
2656
+ tunnel.droppedFrames++;
2657
+ if (frameDropWorthLogging(tunnel.droppedFrames)) {
2658
+ relayLog({
2659
+ event: "tunnel_frame_dropped",
2660
+ hostId,
2661
+ reason: parsed.reason,
2662
+ dropped: tunnel.droppedFrames,
2663
+ ..."type" in parsed && parsed.type ? { type: parsed.type } : {},
2664
+ ..."actual_protocol" in parsed ? { protocol: String(parsed.actual_protocol) } : {}
2665
+ });
2666
+ }
2667
+ return;
2668
+ }
2370
2669
  directory.recordPong(tunnel.hostId);
2371
2670
  if (parsed.message.type === "ping") {
2372
2671
  tunnel.socket.send?.(JSON.stringify(makeTunnelPong(parsed.message)));
@@ -2385,7 +2684,13 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2385
2684
  if (parsed.message.type === "http.response.start") {
2386
2685
  const message = parsed.message;
2387
2686
  const pending = tunnel.pending.get(message.request_id);
2388
- if (!pending) return;
2687
+ if (!pending) {
2688
+ const timedOutAt = tunnel.timedOut.get(message.request_id);
2689
+ if (timedOutAt === void 0) return;
2690
+ tunnel.timedOut.delete(message.request_id);
2691
+ relayLog({ event: "tunnel_late_response", hostId: tunnel.hostId, requestId: message.request_id, lateMs: clock() - timedOutAt });
2692
+ return;
2693
+ }
2389
2694
  pending.status = message.status;
2390
2695
  pending.headers = headersFromRecord(message.headers);
2391
2696
  if (responseMustNotHaveBody(pending.status)) return;
@@ -2475,10 +2780,10 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2475
2780
  return { ok: false };
2476
2781
  }
2477
2782
  };
2478
- const cleanupHostTunnel = (hostId, socket, reason) => {
2783
+ const cleanupHostTunnel = (hostId, socket, reason, detail) => {
2479
2784
  const tunnel = hostTunnels.get(hostId);
2480
2785
  if (tunnel?.socket !== socket) return;
2481
- cleanupTunnelResources(tunnel, reason);
2786
+ cleanupTunnelResources(tunnel, reason, detail);
2482
2787
  hostTunnels.delete(hostId);
2483
2788
  directory.disconnectHost(hostId);
2484
2789
  };
@@ -2499,29 +2804,33 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2499
2804
  }
2500
2805
  };
2501
2806
  const admitHostTunnel = async (request, roomWorkspaceId, url) => {
2502
- if (!websocketRequest(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
2807
+ const refused = (code, message, status) => {
2808
+ relayLog({ event: "tunnel_refused", code, hostId: trimToUndefined5(hostIdFromTunnelPath(url.pathname)) });
2809
+ return json(code, message, status);
2810
+ };
2811
+ if (!websocketRequest(request)) return refused("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
2503
2812
  const hostId = trimToUndefined5(hostIdFromTunnelPath(url.pathname));
2504
- if (!hostId) return json("host_tunnel_host_required", "Host tunnel id is required", 400);
2813
+ if (!hostId) return refused("host_tunnel_host_required", "Host tunnel id is required", 400);
2505
2814
  const workspaceIds = workspaceIdsFromSearch(url);
2506
2815
  if (!workspaceIds.includes(roomWorkspaceId)) {
2507
- return json("host_tunnel_workspace_mismatch", "Host tunnel must include the room workspace id", 403);
2816
+ return refused("host_tunnel_workspace_mismatch", "Host tunnel must include the room workspace id", 403);
2508
2817
  }
2509
2818
  const token = bearerToken(request.headers.get("authorization"));
2510
- if (!token) return json("host_tunnel_token_required", "Host Tunnel Token is required", 401);
2819
+ if (!token) return refused("host_tunnel_token_required", "Host Tunnel Token is required", 401);
2511
2820
  let claims;
2512
2821
  try {
2513
2822
  claims = await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
2514
2823
  } catch (err) {
2515
2824
  if (err instanceof WorkspaceRelayAuthError) {
2516
- return json(err.code, "Host Tunnel Token was denied", 403);
2825
+ return refused(err.code, "Host Tunnel Token was denied", 403);
2517
2826
  }
2518
2827
  throw err;
2519
2828
  }
2520
2829
  const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, claims);
2521
- if (!decision.ok) return json(decision.code, decision.reason, decision.retryable ? 503 : 403);
2830
+ if (!decision.ok) return refused(decision.code, decision.reason, decision.retryable ? 503 : 403);
2522
2831
  const previous = hostTunnels.get(hostId);
2523
2832
  if (previous && hostTunnelIncumbentOutranks(previous.generation, claims.generation)) {
2524
- return json("host_generation_superseded", "Host tunnel generation was superseded", 403);
2833
+ return refused("host_generation_superseded", "Host tunnel generation was superseded", 403);
2525
2834
  }
2526
2835
  const connectedAt = options.now?.() ?? Date.now();
2527
2836
  const fence = {
@@ -2537,12 +2846,15 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2537
2846
  generationCheckFailures: 0,
2538
2847
  socket: pair.server,
2539
2848
  pending: /* @__PURE__ */ new Map(),
2849
+ timedOut: /* @__PURE__ */ new Map(),
2850
+ droppedFrames: 0,
2540
2851
  channels: /* @__PURE__ */ new Map()
2541
2852
  };
2542
2853
  if (previous) {
2543
2854
  cleanupTunnelResources(previous, "Host tunnel replaced");
2544
2855
  closeSocket(previous.socket, 1012, "Host tunnel replaced");
2545
2856
  }
2857
+ relayLog({ event: "tunnel_admitted", hostId, workspaceIds, generation: claims.generation, ...previous ? { replacedAgeMs: connectedAt - previous.connectedAt } : {} });
2546
2858
  hostTunnels.set(hostId, tunnel);
2547
2859
  directory.registerHostTunnel({ hostId, workspaceIds });
2548
2860
  watchHostGeneration(tunnel);
@@ -2552,7 +2864,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2552
2864
  if (!event || event.data === void 0) return;
2553
2865
  void handleTunnelMessage(hostId, pair.server, { data: event.data }).catch((err) => reportFrameHandlerFailure(pair.server, err));
2554
2866
  });
2555
- pair.server.addEventListener?.("close", () => cleanupHostTunnel(hostId, pair.server, "Host tunnel disconnected"));
2867
+ pair.server.addEventListener?.("close", (event) => cleanupHostTunnel(hostId, pair.server, "Host tunnel disconnected", { closeCode: event?.code, closeReason: event?.reason }));
2556
2868
  pair.server.addEventListener?.("error", () => cleanupHostTunnel(hostId, pair.server, "Host tunnel disconnected"));
2557
2869
  }
2558
2870
  return upgradeResponse(pair.client, request);
@@ -2736,13 +3048,18 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2736
3048
  })
2737
3049
  );
2738
3050
  if (!authorized.ok) return authorized.response;
2739
- if (isHostTunnelTarget(authorized.request.target)) {
3051
+ const response = await forwardAuthorizedHttp(request, workspaceId, authorized.request, trace);
3052
+ if (!options.streamStanding) return response;
3053
+ return guardStreamStanding(response, authorized.request.claims, { ...options.streamStanding, graceAttempts: resolverGraceAttempts, ...options.now ? { now: options.now } : {} });
3054
+ };
3055
+ const forwardAuthorizedHttp = async (request, workspaceId, authorized, trace) => {
3056
+ if (isHostTunnelTarget(authorized.target)) {
2740
3057
  trace && (trace.routeKind = "host-tunnel-http");
2741
- return forwardHostTunnelHttp(request, authorized.request, trace);
3058
+ return forwardHostTunnelHttp(request, authorized, trace);
2742
3059
  }
2743
- if (isSessionHostTarget(authorized.request.target)) {
3060
+ if (isSessionHostTarget(authorized.target)) {
2744
3061
  trace && (trace.routeKind = "session-host-http");
2745
- return traceSpan(trace, "session-host-fetch", () => forwardSessionHostHttp(options.sessionHost, request, authorized.request));
3062
+ return traceSpan(trace, "session-host-fetch", () => forwardSessionHostHttp(options.sessionHost, request, authorized));
2746
3063
  }
2747
3064
  trace && (trace.routeKind = "cloud-http");
2748
3065
  const controller = new AbortController();
@@ -2750,11 +3067,11 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2750
3067
  try {
2751
3068
  const upstream = await traceSpan(trace, "upstream-fetch", () => (options.fetch ?? fetch)(
2752
3069
  workspaceRelayTargetUrl(
2753
- authorized.request.target,
2754
- authorized.request.path,
3070
+ authorized.target,
3071
+ authorized.path,
2755
3072
  new URL(request.url).search
2756
3073
  ),
2757
- workspaceRelayForwardRequestInit(request, authorized.request.relayHostToken, workspaceId, {
3074
+ workspaceRelayForwardRequestInit(request, authorized.relayHostToken, workspaceId, {
2758
3075
  signal: controller.signal
2759
3076
  })
2760
3077
  ));
@@ -2799,9 +3116,23 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2799
3116
  return json("request_body_too_large", "Host tunnel request body exceeds the relay limit", 413);
2800
3117
  }
2801
3118
  const body = bodyBytes ? bytesToBase64(bodyBytes) : void 0;
3119
+ const forwardTimeoutMs = options.forwardTimeoutMs ?? 3e4;
2802
3120
  const pending = new Promise((resolve, reject) => {
2803
3121
  const timeout = setTimeout(() => {
2804
3122
  tunnel.pending.delete(requestId);
3123
+ rememberTimedOut(tunnel.timedOut, requestId, clock());
3124
+ relayLog({
3125
+ event: "tunnel_timeout",
3126
+ hostId: tunnel.hostId,
3127
+ ...trace ? { traceId: trace.id } : {},
3128
+ requestId,
3129
+ method: request.method,
3130
+ path: authorized.path,
3131
+ bodyBytes: bodyBytes ? bodyBytes.byteLength : 0,
3132
+ timeoutMs: forwardTimeoutMs,
3133
+ tunnelAgeMs: clock() - tunnel.connectedAt,
3134
+ pending: tunnel.pending.size
3135
+ });
2805
3136
  tunnel.socket.send?.(JSON.stringify({
2806
3137
  type: "http.response.flow",
2807
3138
  protocol: TUNNEL_PROTOCOL_VERSION,
@@ -2810,7 +3141,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2810
3141
  reason: "closed"
2811
3142
  }));
2812
3143
  reject(hostTunnelFailure("host_tunnel_timeout"));
2813
- }, options.forwardTimeoutMs ?? 3e4);
3144
+ }, forwardTimeoutMs);
2814
3145
  tunnel.pending.set(requestId, {
2815
3146
  chunks: [],
2816
3147
  bytes: 0,
@@ -2894,7 +3225,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2894
3225
  try {
2895
3226
  await runHibernatedRevocationCheck();
2896
3227
  } catch (err) {
2897
- console.error(`[workspace-relay] revocation alarm failed: ${err instanceof Error ? err.message : String(err)}`);
3228
+ options.report(err, "revocation_alarm");
2898
3229
  }
2899
3230
  },
2900
3231
  webSocketClose(socket, code, reason) {
@@ -2903,17 +3234,17 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2903
3234
  const attachment = socketAttachment(socket);
2904
3235
  if (!attachment) return;
2905
3236
  if (attachment.kind === "host-tunnel") {
2906
- cleanupHostTunnel(attachment.hostId, socket, "Host tunnel disconnected");
3237
+ cleanupHostTunnel(attachment.hostId, socket, "Host tunnel disconnected", { closeCode: code, closeReason: reason });
2907
3238
  return;
2908
3239
  }
2909
3240
  cleanupHostTunnelClient(attachment.channelId, socket, { code, reason });
2910
3241
  },
2911
- webSocketError(socket) {
3242
+ webSocketError(socket, error) {
2912
3243
  ensureHibernatedSocketsRebuilt();
2913
3244
  const attachment = socketAttachment(socket);
2914
3245
  if (!attachment) return;
2915
3246
  if (attachment.kind === "host-tunnel") {
2916
- cleanupHostTunnel(attachment.hostId, socket, "Host tunnel disconnected");
3247
+ cleanupHostTunnel(attachment.hostId, socket, "Host tunnel disconnected", { socketError: error instanceof Error ? error.message : typeof error === "string" ? error : "unknown" });
2917
3248
  return;
2918
3249
  }
2919
3250
  cleanupHostTunnelClient(attachment.channelId, socket, { code: 1011, reason: "WebSocket error" });
@@ -2930,171 +3261,18 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2930
3261
  drain: drainController
2931
3262
  };
2932
3263
  }
2933
-
2934
- // ../claxedo-helpers/dist/deadline.mjs
2935
- async function withDeadlineSignal(timeoutMs, work, parent) {
2936
- const deadline = new AbortController();
2937
- const timer = setTimeout(() => deadline.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
2938
- try {
2939
- return await work(parent ? AbortSignal.any([parent, deadline.signal]) : deadline.signal);
2940
- } finally {
2941
- clearTimeout(timer);
2942
- }
2943
- }
2944
-
2945
- // src/resolver-clients.ts
2946
- function parseRuntimeAccessTokenActiveResult(input) {
2947
- if (!isRecord(input)) return void 0;
2948
- const row = input;
2949
- if (row.active === true) return { active: true };
2950
- if (row.active !== false) return void 0;
2951
- if (typeof row.code !== "string" || typeof row.reason !== "string") return void 0;
2952
- return { active: false, code: row.code, reason: row.reason };
2953
- }
2954
- function parseHostGenerationResult(input) {
2955
- if (!isRecord(input)) return void 0;
2956
- const row = input;
2957
- if (typeof row.enrollmentId !== "string" || !row.enrollmentId.trim()) return void 0;
2958
- if (!isHostGeneration(row.generation) || typeof row.revoked !== "boolean") return void 0;
2959
- return { enrollmentId: row.enrollmentId, generation: row.generation, revoked: row.revoked };
2960
- }
2961
- var HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT = 5e3;
2962
- var HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE = "relay_resolver_enrollment_not_found";
2963
- function createHostGenerationResolverLookup(url, options) {
2964
- const fetcher = options.fetch ?? ((target, init) => fetch(target, init));
2965
- const timeoutMs = options.timeoutMs ?? HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT;
2966
- return async ({ enrollmentId }) => {
2967
- const target = new URL(url);
2968
- target.searchParams.set("enrollmentId", enrollmentId);
2969
- return withDeadlineSignal(timeoutMs, async (signal) => {
2970
- const res = await fetcher(target, { headers: options.headers, signal });
2971
- if (res.status === 404) {
2972
- const body = await res.json().catch(() => void 0);
2973
- const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
2974
- if (error?.code === HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE) return void 0;
2975
- throw new Error(`relay host-generation resolver failed: 404 (no host-generation route at ${url})`);
2976
- }
2977
- if (!res.ok) throw new Error(`relay host-generation resolver failed: ${res.status} ${await res.text()}`);
2978
- const result = parseHostGenerationResult(await res.json());
2979
- if (!result) throw new Error("relay host-generation resolver returned a malformed result");
2980
- return result;
2981
- });
2982
- };
2983
- }
2984
- var RESOLVER_CACHE_MAX_ENTRIES = 8192;
2985
- var REVOCATION_CACHE_TTL_MS_DEFAULT = 1e4;
2986
- var TARGET_CACHE_TTL_MS_DEFAULT = 1e4;
2987
- var DURABLE_OBJECT_TARGET_CACHE_TTL_MS_DEFAULT = 30 * 6e4;
2988
- function pruneExpiringCache(cache, now, maxEntries) {
2989
- for (const [key, entry] of cache) {
2990
- if (entry.expiresAt <= now) cache.delete(key);
2991
- }
2992
- while (cache.size >= maxEntries) {
2993
- const key = cache.keys().next().value;
2994
- if (!key) return;
2995
- cache.delete(key);
2996
- }
2997
- }
2998
- function createCachedRevocationClient(inner, options = {}) {
2999
- const ttlMs = options.ttlMs ?? REVOCATION_CACHE_TTL_MS_DEFAULT;
3000
- const now = options.now ?? Date.now;
3001
- const cache = /* @__PURE__ */ new Map();
3002
- return async (args) => {
3003
- const at = now();
3004
- const entry = cache.get(args.jti);
3005
- if (entry && entry.expiresAt > at) {
3006
- if (entry.result) return entry.result;
3007
- if (entry.promise) return await entry.promise;
3008
- }
3009
- const promise = inner(args).then((result) => {
3010
- pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
3011
- cache.set(args.jti, { result, expiresAt: now() + ttlMs });
3012
- return result;
3013
- }).catch((err) => {
3014
- cache.delete(args.jti);
3015
- throw err;
3016
- });
3017
- cache.set(args.jti, { promise, expiresAt: at + ttlMs });
3018
- return await promise;
3019
- };
3020
- }
3021
- function runtimeAccessTokenRevocationDelayMs(input = {}) {
3022
- const configuredTtl = input.revocationCacheTtlMs ?? REVOCATION_CACHE_TTL_MS_DEFAULT;
3023
- const ttlMs = Number.isFinite(configuredTtl) && configuredTtl > 0 ? configuredTtl : 0;
3024
- if (input.activeCheckIntervalMs === void 0) return ttlMs;
3025
- if (!Number.isFinite(input.activeCheckIntervalMs) || input.activeCheckIntervalMs <= 0) {
3026
- return Number.POSITIVE_INFINITY;
3027
- }
3028
- return ttlMs + input.activeCheckIntervalMs;
3029
- }
3030
- function createCachedHostGenerationClient(inner, options = {}) {
3031
- const ttlMs = options.ttlMs ?? 1e4;
3032
- const now = options.now ?? Date.now;
3033
- const cache = /* @__PURE__ */ new Map();
3034
- return async (args) => {
3035
- for (; ; ) {
3036
- const at = now();
3037
- const entry = cache.get(args.enrollmentId);
3038
- if (entry && entry.expiresAt > at) {
3039
- if (entry.result && entry.result.generation >= args.generation) return entry.result;
3040
- if (!entry.result && entry.promise) {
3041
- const shared = await entry.promise;
3042
- if (!shared || shared.generation >= args.generation) return shared;
3043
- continue;
3044
- }
3045
- }
3046
- const promise = inner(args).then((result) => {
3047
- pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
3048
- if (result) cache.set(args.enrollmentId, { result, expiresAt: now() + ttlMs });
3049
- else cache.delete(args.enrollmentId);
3050
- return result;
3051
- }).catch((err) => {
3052
- cache.delete(args.enrollmentId);
3053
- throw err;
3054
- });
3055
- cache.set(args.enrollmentId, { promise, expiresAt: at + ttlMs });
3056
- return await promise;
3057
- }
3058
- };
3059
- }
3060
- function createCachedTargetClient(inner, options = {}) {
3061
- const ttlMs = options.ttlMs ?? TARGET_CACHE_TTL_MS_DEFAULT;
3062
- const durableObjectTtlMs = options.durableObjectTtlMs ?? DURABLE_OBJECT_TARGET_CACHE_TTL_MS_DEFAULT;
3063
- const now = options.now ?? Date.now;
3064
- const cache = /* @__PURE__ */ new Map();
3065
- return async (args) => {
3066
- const key = `${args.workspaceId}\0${args.hostId}\0${args.routingId ?? ""}`;
3067
- const at = now();
3068
- const entry = cache.get(key);
3069
- if (entry && entry.expiresAt > at) {
3070
- if (entry.target) return entry.target;
3071
- if (entry.promise) return await entry.promise;
3072
- }
3073
- const promise = inner(args).then((target) => {
3074
- pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
3075
- if (target) cache.set(key, { target, expiresAt: now() + (target.backing === "durable-object" ? durableObjectTtlMs : ttlMs) });
3076
- else cache.delete(key);
3077
- return target;
3078
- }).catch((err) => {
3079
- cache.delete(key);
3080
- throw err;
3081
- });
3082
- cache.set(key, { promise, expiresAt: at + ttlMs });
3083
- return await promise;
3084
- };
3085
- }
3086
3264
  export {
3087
3265
  DEFAULT_RELAY_APP_ORIGINS,
3088
3266
  DEFAULT_RELAY_LOCATION_HINT,
3089
3267
  RELAY_ALLOWED_REQUEST_HEADERS,
3090
3268
  RELAY_LOCATION_HINTS,
3091
3269
  RELAY_REGION_HEADER,
3270
+ REVOCATION_RESOLVER_UNAVAILABLE,
3092
3271
  RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT,
3093
3272
  RUNTIME_ACCESS_TOKEN_ISSUED_AT_FLOOR_SECONDS,
3094
3273
  SESSION_HOST_LOCATION_HINT,
3095
3274
  WorkspaceRelayAuthError,
3096
3275
  authorizeWorkspaceRelayRequest,
3097
- bytesToBase64,
3098
3276
  checkHostTunnelGeneration,
3099
3277
  corsPreflight,
3100
3278
  createCachedHostGenerationClient,
@@ -3119,7 +3297,6 @@ export {
3119
3297
  parseRuntimeAccessTokenActiveResult,
3120
3298
  parseWorkspaceRelayTarget,
3121
3299
  pruneExpiringCache,
3122
- relayFrameDiagnostics,
3123
3300
  relayHostInputFromRuntimeClaims,
3124
3301
  relayHostTokenAudience,
3125
3302
  relayHostTokenIssuer,