@claxedo/workspace-relay 0.10.2 → 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" },
@@ -682,7 +693,7 @@ function hostTunnelIncumbentOutranks(incumbentGeneration, candidateGeneration) {
682
693
  }
683
694
  var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
684
695
  var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
685
- var RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 1e4;
696
+ var RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 3e4;
686
697
  var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
687
698
  var relayHostTokenCaches = /* @__PURE__ */ new WeakMap();
688
699
  var runtimeAccessTokenCaches = /* @__PURE__ */ new WeakMap();
@@ -1089,6 +1100,246 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
1089
1100
  }
1090
1101
  }
1091
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
+
1092
1343
  // src/directory.ts
1093
1344
  function presenceKey(hostId, workspaceId) {
1094
1345
  return `${hostId}\0${workspaceId}`;
@@ -1331,18 +1582,17 @@ async function relayRoomPlacement(request, input) {
1331
1582
  const configured = trimToUndefined2(input.configured);
1332
1583
  const region = relayRequestRegion(request);
1333
1584
  const country = relayRequestCountry(request);
1334
- const floor = relayLocationHint(configured ? { configured } : {});
1335
- const declared = relayLocationHint({
1336
- ...configured ? { configured } : {},
1337
- ...region ? { region } : {},
1338
- ...country ? { country } : {}
1339
- });
1340
1585
  const headers = new Headers(request.headers);
1341
1586
  headers.delete(RELAY_REGION_HEADER);
1342
1587
  const credential = await input.credential();
1343
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
+ });
1344
1594
  return {
1345
- locationHint: sessionHost ? SESSION_HOST_LOCATION_HINT : credential || declared === floor ? declared : floor,
1595
+ locationHint: sessionHost ? SESSION_HOST_LOCATION_HINT : declared,
1346
1596
  url: withoutQueryParams(request.url, RELAY_REGION_QUERY_PARAMS),
1347
1597
  headers
1348
1598
  };
@@ -1418,12 +1668,12 @@ async function relayPlacementCredential(request, env, url, workspaceId) {
1418
1668
  const token2 = bearerToken(request.headers.get("authorization"));
1419
1669
  if (!hostId || !token2) return void 0;
1420
1670
  await verifyHostTunnelToken(token2, key, { hostId, workspaceIds: workspaceIdsFromSearch(url) });
1421
- return { hostId };
1671
+ return { hostId, declaresRegion: true };
1422
1672
  }
1423
1673
  const token = runtimeAccessTokenFrom(request);
1424
1674
  if (!token) return void 0;
1425
1675
  const claims = await verifyRuntimeAccessToken(token, key, { workspaceId });
1426
- return { hostId: claims.host_id };
1676
+ return { hostId: claims.host_id, declaresRegion: claims.routing_id !== void 0 };
1427
1677
  } catch {
1428
1678
  return void 0;
1429
1679
  }
@@ -1435,6 +1685,136 @@ function workspaceRelayRoomPlacement(request, env, workspaceId) {
1435
1685
  });
1436
1686
  }
1437
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
+
1438
1818
  // src/relay-trace.ts
1439
1819
  import { trimToUndefined as trimToUndefined4 } from "@claxedo/helpers/string";
1440
1820
  var TRACE_ID_HEADER = "x-claxedo-trace-id";
@@ -1451,12 +1831,15 @@ function traceRequest(request, routeKind, options) {
1451
1831
  const forceSecret = trimToUndefined4(options.traceForceHeaderSecret);
1452
1832
  const forced = !!forceSecret && request.headers.get(TRACE_FORCE_HEADER) === forceSecret;
1453
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;
1454
1836
  return {
1455
1837
  id,
1456
- sampled: forced || rate > 0 && (options.random ?? Math.random)() < rate,
1838
+ sampled,
1457
1839
  startedAt: performance.now(),
1458
1840
  routeKind,
1459
- phases: []
1841
+ phases: [],
1842
+ ...roomColo ? { roomColo } : {}
1460
1843
  };
1461
1844
  }
1462
1845
  async function traceSpan(trace, name, fn) {
@@ -1497,7 +1880,8 @@ function webSocketTraceFrame(upstreamStartedAt, queueTimes, openedAt) {
1497
1880
  function withTraceHeaders(response, trace) {
1498
1881
  const headers = new Headers(response.headers);
1499
1882
  headers.set(TRACE_ID_HEADER, trace.id);
1500
- 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(", ") : "";
1501
1885
  if (timing) {
1502
1886
  const existing = headers.get("server-timing");
1503
1887
  headers.set("server-timing", existing ? `${existing}, ${timing}` : timing);
@@ -1509,10 +1893,11 @@ function withTraceHeaders(response, trace) {
1509
1893
  headers
1510
1894
  });
1511
1895
  }
1896
+ var SLOW_REQUEST_MS = 5e3;
1512
1897
  function logTrace(options, request, workspaceId, trace, response) {
1513
- if (!trace.sampled) return;
1514
- const url = new URL(request.url);
1515
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);
1516
1901
  const event = {
1517
1902
  event: "workspace_relay_request",
1518
1903
  traceId: trace.id,
@@ -1528,181 +1913,58 @@ function logTrace(options, request, workspaceId, trace, response) {
1528
1913
  options.traceLog(event);
1529
1914
  return;
1530
1915
  }
1531
- console.log(JSON.stringify(event));
1532
- }
1533
-
1534
- // src/cloudflare.ts
1535
- var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
1536
- var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
1537
- var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
1538
- var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1539
- var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1540
- var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
1541
- var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
1542
- var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 8192;
1543
- var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
1544
- var UPSTREAM_WS_PRE_OPEN_QUEUE_FRAME_OVERHEAD_BYTES = 64;
1545
- var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1546
- var RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
1547
- var HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT = 3e4;
1548
- var HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1549
- function unrefTimer(timer) {
1550
- if (typeof timer.unref === "function") timer.unref();
1551
- }
1552
- function isHostTunnelClient(client) {
1553
- return "channelId" in client && typeof client.channelId === "string";
1554
- }
1555
- function workspaceIdFromRelayPath(pathname) {
1556
- return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
1557
- }
1558
- function websocketRequest(request) {
1559
- return request.headers.get("upgrade")?.toLowerCase() === "websocket";
1560
- }
1561
- function relayWebSocketTraceEnabled(request) {
1562
- return request.headers.get("x-claxedo-relay-ws-trace") === "1";
1563
- }
1564
- function randomToken() {
1565
- return crypto.randomUUID();
1566
- }
1567
- function headerRecord(headers) {
1568
- const result = /* @__PURE__ */ Object.create(null);
1569
- headers.forEach((value, key) => {
1570
- result[key] = value;
1571
- });
1572
- return result;
1573
- }
1574
- function headersFromRecord(input) {
1575
- const headers = new Headers();
1576
- for (const [key, value] of Object.entries(input)) headers.set(key, value);
1577
- return headers;
1578
- }
1579
- var BASE64_CHUNK_BYTES = 32768;
1580
- function bytesToBase64(bytes) {
1581
- let binary = "";
1582
- for (let i = 0; i < bytes.length; i += BASE64_CHUNK_BYTES) {
1583
- binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_BYTES));
1584
- }
1585
- return btoa(binary);
1586
- }
1587
- function base64ToBytes(input) {
1588
- return Uint8Array.from(atob(input), (char) => char.charCodeAt(0));
1589
- }
1590
- function preOpenFrameBytes(input) {
1591
- const payload = typeof input === "string" ? new TextEncoder().encode(input).byteLength : input.byteLength;
1592
- return payload + UPSTREAM_WS_PRE_OPEN_QUEUE_FRAME_OVERHEAD_BYTES;
1593
- }
1594
- function isBlobLike(input) {
1595
- if (!input || typeof input !== "object") return false;
1596
- return "arrayBuffer" in input && typeof input.arrayBuffer === "function";
1597
- }
1598
- function viewBytes(input) {
1599
- return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
1600
- }
1601
- function socketFrame(input) {
1602
- if (typeof input === "string") {
1603
- return {
1604
- binary: false,
1605
- data_base64: bytesToBase64(new TextEncoder().encode(input))
1606
- };
1607
- }
1608
- if (input instanceof ArrayBuffer) {
1609
- return {
1610
- binary: true,
1611
- data_base64: bytesToBase64(new Uint8Array(input))
1612
- };
1613
- }
1614
- if (input instanceof Uint8Array) {
1615
- return {
1616
- binary: true,
1617
- data_base64: bytesToBase64(input)
1618
- };
1619
- }
1620
- if (ArrayBuffer.isView(input)) {
1621
- return {
1622
- binary: true,
1623
- data_base64: bytesToBase64(viewBytes(input))
1624
- };
1625
- }
1626
- if (!isBlobLike(input)) return void 0;
1627
- return input.arrayBuffer().then((buffer) => ({
1628
- binary: true,
1629
- data_base64: bytesToBase64(new Uint8Array(buffer))
1630
- }));
1916
+ console.log(JSON.stringify(event));
1631
1917
  }
1632
- function socketPayload(input) {
1633
- if (typeof input === "string") return input;
1634
- if (input instanceof ArrayBuffer) return input;
1635
- if (input instanceof Uint8Array) return input;
1636
- if (ArrayBuffer.isView(input)) return viewBytes(input);
1637
- if (!isBlobLike(input)) return void 0;
1638
- 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();
1639
1935
  }
1640
- function orderedFrameDelivery(deliver) {
1641
- let tail;
1642
- return (data) => {
1643
- const payload = socketPayload(data);
1644
- if (payload === void 0) return;
1645
- if (tail === void 0 && !(payload instanceof Promise)) {
1646
- deliver(payload);
1647
- return;
1648
- }
1649
- const queued = (tail ?? Promise.resolve()).then(async () => deliver(await payload)).catch(() => {
1650
- });
1651
- tail = queued;
1652
- void queued.then(() => {
1653
- if (tail === queued) tail = void 0;
1654
- });
1655
- };
1936
+ function isHostTunnelClient(client) {
1937
+ return "channelId" in client && typeof client.channelId === "string";
1656
1938
  }
1657
- async function relayFrameDiagnostics(data) {
1658
- const frame = await socketFrame(data);
1659
- const payload = await socketPayload(data);
1660
- return {
1661
- // `undefined` here is exactly the silent drop: every caller treats it as
1662
- // "nothing to forward" and returns without a throw or a log.
1663
- frameDropped: frame === void 0,
1664
- payloadDropped: payload === void 0,
1665
- ...frame ? { binary: frame.binary, dataBase64: frame.data_base64 } : {},
1666
- payloadBase64: payload === void 0 ? void 0 : typeof payload === "string" ? bytesToBase64(new TextEncoder().encode(payload)) : bytesToBase64(payload instanceof ArrayBuffer ? new Uint8Array(payload) : payload)
1667
- };
1939
+ function workspaceIdFromRelayPath(pathname) {
1940
+ return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
1668
1941
  }
1669
- function tunnelFrame(input) {
1670
- const bytes = base64ToBytes(input.data_base64);
1671
- return input.binary ? bytes : new TextDecoder().decode(bytes);
1942
+ function websocketRequest(request) {
1943
+ return request.headers.get("upgrade")?.toLowerCase() === "websocket";
1672
1944
  }
1673
- function concatBytes(chunks) {
1674
- const length = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
1675
- const result = new Uint8Array(length);
1676
- let offset = 0;
1677
- for (const chunk of chunks) {
1678
- result.set(chunk, offset);
1679
- offset += chunk.byteLength;
1680
- }
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
+ });
1681
1956
  return result;
1682
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
+ }
1683
1963
  function responseMustNotHaveBody(status) {
1684
1964
  return status === 204 || status === 205 || status === 304;
1685
1965
  }
1686
- async function readRequestBodyCapped(request, maxBytes) {
1687
- if (!request.body) return new Uint8Array(0);
1688
- const reader = request.body.getReader();
1689
- const chunks = [];
1690
- let total = 0;
1691
- while (true) {
1692
- const { done, value } = await reader.read();
1693
- if (done) break;
1694
- total += value.byteLength;
1695
- if (total > maxBytes) {
1696
- await reader.cancel().catch(() => {
1697
- });
1698
- return "too_large";
1699
- }
1700
- chunks.push(value);
1701
- }
1702
- return concatBytes(chunks);
1703
- }
1704
1966
  function workspaceRelayDurableObjectRoomName(workspaceId) {
1705
- return `workspace:${workspaceId}`;
1967
+ return `relay-room:${workspaceId}`;
1706
1968
  }
1707
1969
  function workspaceRelayDurableObjectWorkspaceId(request) {
1708
1970
  const url = new URL(request.url);
@@ -1873,7 +2135,22 @@ function createWorkspaceRelayDurableObjectRoom(options) {
1873
2135
  return false;
1874
2136
  }
1875
2137
  };
1876
- 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;
1877
2154
  if (tunnel.generationWatcher) clearInterval(tunnel.generationWatcher);
1878
2155
  tunnel.generationWatcher = void 0;
1879
2156
  for (const pending of tunnel.pending.values()) {
@@ -1919,6 +2196,8 @@ function createWorkspaceRelayDurableObjectRoom(options) {
1919
2196
  generationCheckFailures: attachment.generationCheckFailures ?? 0,
1920
2197
  socket,
1921
2198
  pending: /* @__PURE__ */ new Map(),
2199
+ timedOut: /* @__PURE__ */ new Map(),
2200
+ droppedFrames: 0,
1922
2201
  channels: /* @__PURE__ */ new Map()
1923
2202
  });
1924
2203
  directory.registerHostTunnel({
@@ -1964,7 +2243,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
1964
2243
  }
1965
2244
  };
1966
2245
  const resolverGraceAttempts = options.resolverOutageGraceAttempts ?? RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT;
1967
- const resolverUnreachable = (code) => code === "relay_revocation_resolver_unavailable";
2246
+ const resolverUnreachable = (code) => code === REVOCATION_RESOLVER_UNAVAILABLE;
1968
2247
  const watchRuntimeAccessToken = (client, onInactive) => {
1969
2248
  const intervalMs = options.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
1970
2249
  if (intervalMs <= 0) return void 0;
@@ -2256,8 +2535,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2256
2535
  }));
2257
2536
  };
2258
2537
  const reportFrameHandlerFailure = (socket, err) => {
2259
- const reason = err instanceof Error ? err.message : String(err);
2260
- console.error(`[workspace-relay] frame handler failed: ${reason}`);
2538
+ options.report(err, "frame_handler");
2261
2539
  closeSocket(socket, 1011, "Workspace relay failed to process a frame");
2262
2540
  };
2263
2541
  const hibernatedRevocationIntervalMs = () => options.hibernatedRevocationCheckIntervalMs ?? HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT;
@@ -2371,10 +2649,23 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2371
2649
  };
2372
2650
  const handleTunnelMessage = async (hostId, socket, event) => {
2373
2651
  const row = parseTunnelMessageData(event.data);
2374
- if (!row.ok) return;
2375
- const parsed = validateTunnelMessage(row.value);
2652
+ const parsed = row.ok ? validateTunnelMessage(row.value) : { ok: false, reason: "not_json" };
2376
2653
  const tunnel = hostTunnels.get(hostId);
2377
- 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
+ }
2378
2669
  directory.recordPong(tunnel.hostId);
2379
2670
  if (parsed.message.type === "ping") {
2380
2671
  tunnel.socket.send?.(JSON.stringify(makeTunnelPong(parsed.message)));
@@ -2393,7 +2684,13 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2393
2684
  if (parsed.message.type === "http.response.start") {
2394
2685
  const message = parsed.message;
2395
2686
  const pending = tunnel.pending.get(message.request_id);
2396
- 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
+ }
2397
2694
  pending.status = message.status;
2398
2695
  pending.headers = headersFromRecord(message.headers);
2399
2696
  if (responseMustNotHaveBody(pending.status)) return;
@@ -2483,10 +2780,10 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2483
2780
  return { ok: false };
2484
2781
  }
2485
2782
  };
2486
- const cleanupHostTunnel = (hostId, socket, reason) => {
2783
+ const cleanupHostTunnel = (hostId, socket, reason, detail) => {
2487
2784
  const tunnel = hostTunnels.get(hostId);
2488
2785
  if (tunnel?.socket !== socket) return;
2489
- cleanupTunnelResources(tunnel, reason);
2786
+ cleanupTunnelResources(tunnel, reason, detail);
2490
2787
  hostTunnels.delete(hostId);
2491
2788
  directory.disconnectHost(hostId);
2492
2789
  };
@@ -2507,29 +2804,33 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2507
2804
  }
2508
2805
  };
2509
2806
  const admitHostTunnel = async (request, roomWorkspaceId, url) => {
2510
- 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);
2511
2812
  const hostId = trimToUndefined5(hostIdFromTunnelPath(url.pathname));
2512
- 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);
2513
2814
  const workspaceIds = workspaceIdsFromSearch(url);
2514
2815
  if (!workspaceIds.includes(roomWorkspaceId)) {
2515
- 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);
2516
2817
  }
2517
2818
  const token = bearerToken(request.headers.get("authorization"));
2518
- 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);
2519
2820
  let claims;
2520
2821
  try {
2521
2822
  claims = await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
2522
2823
  } catch (err) {
2523
2824
  if (err instanceof WorkspaceRelayAuthError) {
2524
- return json(err.code, "Host Tunnel Token was denied", 403);
2825
+ return refused(err.code, "Host Tunnel Token was denied", 403);
2525
2826
  }
2526
2827
  throw err;
2527
2828
  }
2528
2829
  const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, claims);
2529
- 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);
2530
2831
  const previous = hostTunnels.get(hostId);
2531
2832
  if (previous && hostTunnelIncumbentOutranks(previous.generation, claims.generation)) {
2532
- return json("host_generation_superseded", "Host tunnel generation was superseded", 403);
2833
+ return refused("host_generation_superseded", "Host tunnel generation was superseded", 403);
2533
2834
  }
2534
2835
  const connectedAt = options.now?.() ?? Date.now();
2535
2836
  const fence = {
@@ -2545,12 +2846,15 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2545
2846
  generationCheckFailures: 0,
2546
2847
  socket: pair.server,
2547
2848
  pending: /* @__PURE__ */ new Map(),
2849
+ timedOut: /* @__PURE__ */ new Map(),
2850
+ droppedFrames: 0,
2548
2851
  channels: /* @__PURE__ */ new Map()
2549
2852
  };
2550
2853
  if (previous) {
2551
2854
  cleanupTunnelResources(previous, "Host tunnel replaced");
2552
2855
  closeSocket(previous.socket, 1012, "Host tunnel replaced");
2553
2856
  }
2857
+ relayLog({ event: "tunnel_admitted", hostId, workspaceIds, generation: claims.generation, ...previous ? { replacedAgeMs: connectedAt - previous.connectedAt } : {} });
2554
2858
  hostTunnels.set(hostId, tunnel);
2555
2859
  directory.registerHostTunnel({ hostId, workspaceIds });
2556
2860
  watchHostGeneration(tunnel);
@@ -2560,7 +2864,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2560
2864
  if (!event || event.data === void 0) return;
2561
2865
  void handleTunnelMessage(hostId, pair.server, { data: event.data }).catch((err) => reportFrameHandlerFailure(pair.server, err));
2562
2866
  });
2563
- 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 }));
2564
2868
  pair.server.addEventListener?.("error", () => cleanupHostTunnel(hostId, pair.server, "Host tunnel disconnected"));
2565
2869
  }
2566
2870
  return upgradeResponse(pair.client, request);
@@ -2744,13 +3048,18 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2744
3048
  })
2745
3049
  );
2746
3050
  if (!authorized.ok) return authorized.response;
2747
- 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)) {
2748
3057
  trace && (trace.routeKind = "host-tunnel-http");
2749
- return forwardHostTunnelHttp(request, authorized.request, trace);
3058
+ return forwardHostTunnelHttp(request, authorized, trace);
2750
3059
  }
2751
- if (isSessionHostTarget(authorized.request.target)) {
3060
+ if (isSessionHostTarget(authorized.target)) {
2752
3061
  trace && (trace.routeKind = "session-host-http");
2753
- return traceSpan(trace, "session-host-fetch", () => forwardSessionHostHttp(options.sessionHost, request, authorized.request));
3062
+ return traceSpan(trace, "session-host-fetch", () => forwardSessionHostHttp(options.sessionHost, request, authorized));
2754
3063
  }
2755
3064
  trace && (trace.routeKind = "cloud-http");
2756
3065
  const controller = new AbortController();
@@ -2758,11 +3067,11 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2758
3067
  try {
2759
3068
  const upstream = await traceSpan(trace, "upstream-fetch", () => (options.fetch ?? fetch)(
2760
3069
  workspaceRelayTargetUrl(
2761
- authorized.request.target,
2762
- authorized.request.path,
3070
+ authorized.target,
3071
+ authorized.path,
2763
3072
  new URL(request.url).search
2764
3073
  ),
2765
- workspaceRelayForwardRequestInit(request, authorized.request.relayHostToken, workspaceId, {
3074
+ workspaceRelayForwardRequestInit(request, authorized.relayHostToken, workspaceId, {
2766
3075
  signal: controller.signal
2767
3076
  })
2768
3077
  ));
@@ -2807,9 +3116,23 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2807
3116
  return json("request_body_too_large", "Host tunnel request body exceeds the relay limit", 413);
2808
3117
  }
2809
3118
  const body = bodyBytes ? bytesToBase64(bodyBytes) : void 0;
3119
+ const forwardTimeoutMs = options.forwardTimeoutMs ?? 3e4;
2810
3120
  const pending = new Promise((resolve, reject) => {
2811
3121
  const timeout = setTimeout(() => {
2812
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
+ });
2813
3136
  tunnel.socket.send?.(JSON.stringify({
2814
3137
  type: "http.response.flow",
2815
3138
  protocol: TUNNEL_PROTOCOL_VERSION,
@@ -2818,7 +3141,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2818
3141
  reason: "closed"
2819
3142
  }));
2820
3143
  reject(hostTunnelFailure("host_tunnel_timeout"));
2821
- }, options.forwardTimeoutMs ?? 3e4);
3144
+ }, forwardTimeoutMs);
2822
3145
  tunnel.pending.set(requestId, {
2823
3146
  chunks: [],
2824
3147
  bytes: 0,
@@ -2902,7 +3225,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2902
3225
  try {
2903
3226
  await runHibernatedRevocationCheck();
2904
3227
  } catch (err) {
2905
- console.error(`[workspace-relay] revocation alarm failed: ${err instanceof Error ? err.message : String(err)}`);
3228
+ options.report(err, "revocation_alarm");
2906
3229
  }
2907
3230
  },
2908
3231
  webSocketClose(socket, code, reason) {
@@ -2911,17 +3234,17 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2911
3234
  const attachment = socketAttachment(socket);
2912
3235
  if (!attachment) return;
2913
3236
  if (attachment.kind === "host-tunnel") {
2914
- cleanupHostTunnel(attachment.hostId, socket, "Host tunnel disconnected");
3237
+ cleanupHostTunnel(attachment.hostId, socket, "Host tunnel disconnected", { closeCode: code, closeReason: reason });
2915
3238
  return;
2916
3239
  }
2917
3240
  cleanupHostTunnelClient(attachment.channelId, socket, { code, reason });
2918
3241
  },
2919
- webSocketError(socket) {
3242
+ webSocketError(socket, error) {
2920
3243
  ensureHibernatedSocketsRebuilt();
2921
3244
  const attachment = socketAttachment(socket);
2922
3245
  if (!attachment) return;
2923
3246
  if (attachment.kind === "host-tunnel") {
2924
- 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" });
2925
3248
  return;
2926
3249
  }
2927
3250
  cleanupHostTunnelClient(attachment.channelId, socket, { code: 1011, reason: "WebSocket error" });
@@ -2938,171 +3261,18 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2938
3261
  drain: drainController
2939
3262
  };
2940
3263
  }
2941
-
2942
- // ../claxedo-helpers/dist/deadline.mjs
2943
- async function withDeadlineSignal(timeoutMs, work, parent) {
2944
- const deadline = new AbortController();
2945
- const timer = setTimeout(() => deadline.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
2946
- try {
2947
- return await work(parent ? AbortSignal.any([parent, deadline.signal]) : deadline.signal);
2948
- } finally {
2949
- clearTimeout(timer);
2950
- }
2951
- }
2952
-
2953
- // src/resolver-clients.ts
2954
- function parseRuntimeAccessTokenActiveResult(input) {
2955
- if (!isRecord(input)) return void 0;
2956
- const row = input;
2957
- if (row.active === true) return { active: true };
2958
- if (row.active !== false) return void 0;
2959
- if (typeof row.code !== "string" || typeof row.reason !== "string") return void 0;
2960
- return { active: false, code: row.code, reason: row.reason };
2961
- }
2962
- function parseHostGenerationResult(input) {
2963
- if (!isRecord(input)) return void 0;
2964
- const row = input;
2965
- if (typeof row.enrollmentId !== "string" || !row.enrollmentId.trim()) return void 0;
2966
- if (!isHostGeneration(row.generation) || typeof row.revoked !== "boolean") return void 0;
2967
- return { enrollmentId: row.enrollmentId, generation: row.generation, revoked: row.revoked };
2968
- }
2969
- var HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT = 5e3;
2970
- var HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE = "relay_resolver_enrollment_not_found";
2971
- function createHostGenerationResolverLookup(url, options) {
2972
- const fetcher = options.fetch ?? ((target, init) => fetch(target, init));
2973
- const timeoutMs = options.timeoutMs ?? HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT;
2974
- return async ({ enrollmentId }) => {
2975
- const target = new URL(url);
2976
- target.searchParams.set("enrollmentId", enrollmentId);
2977
- return withDeadlineSignal(timeoutMs, async (signal) => {
2978
- const res = await fetcher(target, { headers: options.headers, signal });
2979
- if (res.status === 404) {
2980
- const body = await res.json().catch(() => void 0);
2981
- const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
2982
- if (error?.code === HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE) return void 0;
2983
- throw new Error(`relay host-generation resolver failed: 404 (no host-generation route at ${url})`);
2984
- }
2985
- if (!res.ok) throw new Error(`relay host-generation resolver failed: ${res.status} ${await res.text()}`);
2986
- const result = parseHostGenerationResult(await res.json());
2987
- if (!result) throw new Error("relay host-generation resolver returned a malformed result");
2988
- return result;
2989
- });
2990
- };
2991
- }
2992
- var RESOLVER_CACHE_MAX_ENTRIES = 8192;
2993
- var REVOCATION_CACHE_TTL_MS_DEFAULT = 1e4;
2994
- var TARGET_CACHE_TTL_MS_DEFAULT = 1e4;
2995
- var DURABLE_OBJECT_TARGET_CACHE_TTL_MS_DEFAULT = 30 * 6e4;
2996
- function pruneExpiringCache(cache, now, maxEntries) {
2997
- for (const [key, entry] of cache) {
2998
- if (entry.expiresAt <= now) cache.delete(key);
2999
- }
3000
- while (cache.size >= maxEntries) {
3001
- const key = cache.keys().next().value;
3002
- if (!key) return;
3003
- cache.delete(key);
3004
- }
3005
- }
3006
- function createCachedRevocationClient(inner, options = {}) {
3007
- const ttlMs = options.ttlMs ?? REVOCATION_CACHE_TTL_MS_DEFAULT;
3008
- const now = options.now ?? Date.now;
3009
- const cache = /* @__PURE__ */ new Map();
3010
- return async (args) => {
3011
- const at = now();
3012
- const entry = cache.get(args.jti);
3013
- if (entry && entry.expiresAt > at) {
3014
- if (entry.result) return entry.result;
3015
- if (entry.promise) return await entry.promise;
3016
- }
3017
- const promise = inner(args).then((result) => {
3018
- pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
3019
- cache.set(args.jti, { result, expiresAt: now() + ttlMs });
3020
- return result;
3021
- }).catch((err) => {
3022
- cache.delete(args.jti);
3023
- throw err;
3024
- });
3025
- cache.set(args.jti, { promise, expiresAt: at + ttlMs });
3026
- return await promise;
3027
- };
3028
- }
3029
- function runtimeAccessTokenRevocationDelayMs(input = {}) {
3030
- const configuredTtl = input.revocationCacheTtlMs ?? REVOCATION_CACHE_TTL_MS_DEFAULT;
3031
- const ttlMs = Number.isFinite(configuredTtl) && configuredTtl > 0 ? configuredTtl : 0;
3032
- if (input.activeCheckIntervalMs === void 0) return ttlMs;
3033
- if (!Number.isFinite(input.activeCheckIntervalMs) || input.activeCheckIntervalMs <= 0) {
3034
- return Number.POSITIVE_INFINITY;
3035
- }
3036
- return ttlMs + input.activeCheckIntervalMs;
3037
- }
3038
- function createCachedHostGenerationClient(inner, options = {}) {
3039
- const ttlMs = options.ttlMs ?? 1e4;
3040
- const now = options.now ?? Date.now;
3041
- const cache = /* @__PURE__ */ new Map();
3042
- return async (args) => {
3043
- for (; ; ) {
3044
- const at = now();
3045
- const entry = cache.get(args.enrollmentId);
3046
- if (entry && entry.expiresAt > at) {
3047
- if (entry.result && entry.result.generation >= args.generation) return entry.result;
3048
- if (!entry.result && entry.promise) {
3049
- const shared = await entry.promise;
3050
- if (!shared || shared.generation >= args.generation) return shared;
3051
- continue;
3052
- }
3053
- }
3054
- const promise = inner(args).then((result) => {
3055
- pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
3056
- if (result) cache.set(args.enrollmentId, { result, expiresAt: now() + ttlMs });
3057
- else cache.delete(args.enrollmentId);
3058
- return result;
3059
- }).catch((err) => {
3060
- cache.delete(args.enrollmentId);
3061
- throw err;
3062
- });
3063
- cache.set(args.enrollmentId, { promise, expiresAt: at + ttlMs });
3064
- return await promise;
3065
- }
3066
- };
3067
- }
3068
- function createCachedTargetClient(inner, options = {}) {
3069
- const ttlMs = options.ttlMs ?? TARGET_CACHE_TTL_MS_DEFAULT;
3070
- const durableObjectTtlMs = options.durableObjectTtlMs ?? DURABLE_OBJECT_TARGET_CACHE_TTL_MS_DEFAULT;
3071
- const now = options.now ?? Date.now;
3072
- const cache = /* @__PURE__ */ new Map();
3073
- return async (args) => {
3074
- const key = `${args.workspaceId}\0${args.hostId}\0${args.routingId ?? ""}`;
3075
- const at = now();
3076
- const entry = cache.get(key);
3077
- if (entry && entry.expiresAt > at) {
3078
- if (entry.target) return entry.target;
3079
- if (entry.promise) return await entry.promise;
3080
- }
3081
- const promise = inner(args).then((target) => {
3082
- pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
3083
- if (target) cache.set(key, { target, expiresAt: now() + (target.backing === "durable-object" ? durableObjectTtlMs : ttlMs) });
3084
- else cache.delete(key);
3085
- return target;
3086
- }).catch((err) => {
3087
- cache.delete(key);
3088
- throw err;
3089
- });
3090
- cache.set(key, { promise, expiresAt: at + ttlMs });
3091
- return await promise;
3092
- };
3093
- }
3094
3264
  export {
3095
3265
  DEFAULT_RELAY_APP_ORIGINS,
3096
3266
  DEFAULT_RELAY_LOCATION_HINT,
3097
3267
  RELAY_ALLOWED_REQUEST_HEADERS,
3098
3268
  RELAY_LOCATION_HINTS,
3099
3269
  RELAY_REGION_HEADER,
3270
+ REVOCATION_RESOLVER_UNAVAILABLE,
3100
3271
  RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT,
3101
3272
  RUNTIME_ACCESS_TOKEN_ISSUED_AT_FLOOR_SECONDS,
3102
3273
  SESSION_HOST_LOCATION_HINT,
3103
3274
  WorkspaceRelayAuthError,
3104
3275
  authorizeWorkspaceRelayRequest,
3105
- bytesToBase64,
3106
3276
  checkHostTunnelGeneration,
3107
3277
  corsPreflight,
3108
3278
  createCachedHostGenerationClient,
@@ -3127,7 +3297,6 @@ export {
3127
3297
  parseRuntimeAccessTokenActiveResult,
3128
3298
  parseWorkspaceRelayTarget,
3129
3299
  pruneExpiringCache,
3130
- relayFrameDiagnostics,
3131
3300
  relayHostInputFromRuntimeClaims,
3132
3301
  relayHostTokenAudience,
3133
3302
  relayHostTokenIssuer,