@claxedo/workspace-relay 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -237,8 +237,6 @@ import { exportJWK } from "jose";
237
237
  var DEFAULT_RELAY_APP_ORIGINS = [
238
238
  "http://localhost:*",
239
239
  "http://127.0.0.1:*",
240
- "https://opencode.ai",
241
- "https://*.opencode.ai",
242
240
  "https://claxedo.com",
243
241
  "https://*.claxedo.com"
244
242
  ];
@@ -1080,7 +1078,22 @@ var TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT = 3e4;
1080
1078
  var DIRECT_HTTP_TIMEOUT_MS_DEFAULT = 3e4;
1081
1079
  var UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT = 1e4;
1082
1080
  var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
1081
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
1083
1082
  var WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
1083
+ var relayBufferedBytes = (socket) => {
1084
+ const method = socket.getBufferedAmount;
1085
+ if (typeof method === "function") {
1086
+ const measured = method.call(socket);
1087
+ if (typeof measured === "number" && Number.isFinite(measured)) return measured;
1088
+ return void 0;
1089
+ }
1090
+ const property = socket.bufferedAmount;
1091
+ return typeof property === "number" && Number.isFinite(property) ? property : void 0;
1092
+ };
1093
+ var relayOverBackpressureLimit = (socket, limitBytes) => {
1094
+ const queued = relayBufferedBytes(socket);
1095
+ return queued !== void 0 && queued > limitBytes;
1096
+ };
1084
1097
  var HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT = 2;
1085
1098
  var TUNNEL_MESSAGE_BUFFER_CAP_BYTES = 4 * 1024 * 1024;
1086
1099
  var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
@@ -1157,6 +1170,9 @@ function corsJsonError(request, code, message, status) {
1157
1170
  headers2.set("content-type", "application/json");
1158
1171
  return new Response(JSON.stringify({ error: { code, message } }), { status, headers: headers2 });
1159
1172
  }
1173
+ function preOpenFrameBytes(message) {
1174
+ return typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
1175
+ }
1160
1176
  function safeCloseCode(input, fallback = 1011) {
1161
1177
  if (!Number.isInteger(input)) return fallback;
1162
1178
  const code = input;
@@ -1235,11 +1251,10 @@ function isEventStream(input) {
1235
1251
  ([key, value]) => key.toLowerCase() === "content-type" && value.toLowerCase().includes("text/event-stream")
1236
1252
  );
1237
1253
  }
1254
+ var defaultRelayOriginMatcher = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
1238
1255
  function allowedCorsOrigin(origin) {
1239
1256
  if (!origin) return;
1240
- if (origin.startsWith("http://localhost:")) return origin;
1241
- if (origin.startsWith("http://127.0.0.1:")) return origin;
1242
- if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
1257
+ if (defaultRelayOriginMatcher(origin)) return origin;
1243
1258
  }
1244
1259
  function requireAllowedOrigin(request) {
1245
1260
  const origin = request.headers.get("origin");
@@ -2012,7 +2027,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
2012
2027
  ws.data.channels.delete(frame.channel_id);
2013
2028
  return;
2014
2029
  }
2015
- if (channel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
2030
+ if (relayOverBackpressureLimit(channel, bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
2016
2031
  ws.data.channels.delete(frame.channel_id);
2017
2032
  closeWebSocket(channel, 1011, "Client WebSocket backpressure limit exceeded");
2018
2033
  return;
@@ -2035,7 +2050,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
2035
2050
  ws.close(1011, "User-hosted tunnel disconnected");
2036
2051
  return;
2037
2052
  }
2038
- if (tunnel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
2053
+ if (relayOverBackpressureLimit(tunnel, bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
2039
2054
  closeWebSocket(ws, 1011, "Host tunnel backpressure limit exceeded");
2040
2055
  return;
2041
2056
  }
@@ -2051,10 +2066,13 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
2051
2066
  ws.data.upstream.send(message);
2052
2067
  return;
2053
2068
  }
2054
- if (ws.data.queue.length >= (bunOptions.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT)) {
2069
+ const queuedBytes = ws.data.queuedBytes ?? 0;
2070
+ const frameBytes = preOpenFrameBytes(message);
2071
+ if (ws.data.queue.length >= (bunOptions.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT) && queuedBytes + frameBytes > (bunOptions.upstreamWebSocketPreOpenQueueMaxBytes ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT)) {
2055
2072
  closeWebSocket(ws, 1011, "Upstream WebSocket queue limit exceeded");
2056
2073
  return;
2057
2074
  }
2075
+ ws.data.queuedBytes = queuedBytes + frameBytes;
2058
2076
  ws.data.queue.push({ payload: message, queuedAt: performance.now() });
2059
2077
  },
2060
2078
  open(ws) {
@@ -2153,6 +2171,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
2153
2171
  sendRelayWebSocketTrace(ws);
2154
2172
  }
2155
2173
  for (const item of data.queue.splice(0)) upstream.send(item.payload);
2174
+ data.queuedBytes = 0;
2156
2175
  };
2157
2176
  upstream.onmessage = (event) => {
2158
2177
  const payload = relayWebSocketPayload(event.data);
@@ -2306,6 +2325,66 @@ function disposeWorkspaceRelayDirectory(directory) {
2306
2325
 
2307
2326
  // src/cloudflare.ts
2308
2327
  var DEFAULT_RELAY_LOCATION_HINT = "apac";
2328
+ var RELAY_LOCATION_HINTS = ["wnam", "enam", "sam", "weur", "eeur", "apac", "oc", "afr", "me"];
2329
+ var relayLocationHintSet = new Set(RELAY_LOCATION_HINTS);
2330
+ var RELAY_REGION_TO_LOCATION_HINT = {
2331
+ // Canonical Claxedo regions.
2332
+ "apac-south": "apac",
2333
+ "apac-east": "apac",
2334
+ "eu-west": "weur",
2335
+ "us-east": "enam",
2336
+ "us-west": "wnam",
2337
+ // Provider spellings.
2338
+ "us-central": "wnam",
2339
+ "eu-central": "weur",
2340
+ "eu-east": "eeur",
2341
+ "ap-south": "apac",
2342
+ "ap-southeast": "apac",
2343
+ "ap-northeast": "apac",
2344
+ "sa-east": "sam",
2345
+ "af-south": "afr",
2346
+ "me-central": "me",
2347
+ "oceania": "oc",
2348
+ "au-southeast": "oc"
2349
+ };
2350
+ function locationHintForCountry(country) {
2351
+ const code = country.toUpperCase();
2352
+ if (["US", "CA", "MX"].includes(code)) return code === "US" || code === "CA" ? "enam" : "wnam";
2353
+ if (["BR", "AR", "CL", "CO", "PE", "UY", "PY", "BO", "EC", "VE"].includes(code)) return "sam";
2354
+ if (["GB", "IE", "FR", "ES", "PT", "NL", "BE", "DE", "CH", "AT", "IT", "DK", "NO", "SE", "FI", "IS", "LU"].includes(code)) return "weur";
2355
+ if (["PL", "CZ", "SK", "HU", "RO", "BG", "GR", "UA", "RS", "HR", "SI", "EE", "LV", "LT", "TR", "RU"].includes(code)) return "eeur";
2356
+ if (["IN", "PK", "BD", "LK", "NP", "SG", "MY", "TH", "VN", "PH", "ID", "CN", "HK", "TW", "JP", "KR"].includes(code)) return "apac";
2357
+ if (["AU", "NZ", "FJ", "PG"].includes(code)) return "oc";
2358
+ if (["ZA", "NG", "KE", "GH", "ET", "TZ", "UG", "EG", "MA", "DZ", "TN"].includes(code)) {
2359
+ return ["EG", "MA", "DZ", "TN"].includes(code) ? "me" : "afr";
2360
+ }
2361
+ if (["AE", "SA", "IL", "QA", "KW", "BH", "OM", "JO", "LB", "IQ", "IR"].includes(code)) return "me";
2362
+ return void 0;
2363
+ }
2364
+ var RELAY_REGION_HEADER = "x-claxedo-workspace-region";
2365
+ function relayLocationHint(input) {
2366
+ const configured = clean(input.configured) ?? DEFAULT_RELAY_LOCATION_HINT;
2367
+ const region = clean(input.region);
2368
+ if (region) {
2369
+ const direct = relayLocationHintSet.has(region.toLowerCase()) ? region.toLowerCase() : void 0;
2370
+ const mapped = direct ?? RELAY_REGION_TO_LOCATION_HINT[region.toLowerCase()];
2371
+ if (mapped) return mapped;
2372
+ }
2373
+ const country = clean(input.country);
2374
+ if (country) {
2375
+ const mapped = locationHintForCountry(country);
2376
+ if (mapped) return mapped;
2377
+ }
2378
+ return configured;
2379
+ }
2380
+ function relayRequestRegion(request) {
2381
+ const url = new URL(request.url);
2382
+ return clean(request.headers.get(RELAY_REGION_HEADER)) ?? clean(url.searchParams.get("region")) ?? clean(url.searchParams.get("homeRegion"));
2383
+ }
2384
+ function relayRequestCountry(request) {
2385
+ const cf = request.cf;
2386
+ return typeof cf?.country === "string" ? cf.country : void 0;
2387
+ }
2309
2388
  var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
2310
2389
  var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
2311
2390
  var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
@@ -2314,8 +2393,11 @@ var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
2314
2393
  var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT2 = 8 * 1024 * 1024;
2315
2394
  var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT2 = 3e4;
2316
2395
  var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT2 = 64;
2396
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT2 = 8 * 1024 * 1024;
2317
2397
  var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
2318
2398
  var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
2399
+ var RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
2400
+ var HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT = 3e4;
2319
2401
  var TRACE_ID_HEADER = "x-claxedo-trace-id";
2320
2402
  var TRACE_FORCE_HEADER = "x-claxedo-relay-trace";
2321
2403
  function clean(input) {
@@ -2436,14 +2518,31 @@ function headersFromRecord(input) {
2436
2518
  for (const [key, value] of Object.entries(input)) headers2.set(key, value);
2437
2519
  return headers2;
2438
2520
  }
2521
+ var BASE64_CHUNK_BYTES = 32768;
2439
2522
  function bytesToBase64(bytes) {
2440
2523
  let binary = "";
2441
- for (const byte of bytes) binary += String.fromCharCode(byte);
2524
+ for (let i = 0; i < bytes.length; i += BASE64_CHUNK_BYTES) {
2525
+ binary += String.fromCharCode.apply(
2526
+ null,
2527
+ bytes.subarray(i, i + BASE64_CHUNK_BYTES)
2528
+ );
2529
+ }
2442
2530
  return btoa(binary);
2443
2531
  }
2444
2532
  function base64ToBytes(input) {
2445
2533
  return Uint8Array.from(atob(input), (char) => char.charCodeAt(0));
2446
2534
  }
2535
+ function preOpenFrameBytes2(input) {
2536
+ if (typeof input === "string") return new TextEncoder().encode(input).byteLength;
2537
+ return input.byteLength;
2538
+ }
2539
+ function blobLike(input) {
2540
+ if (!input || typeof input !== "object") return void 0;
2541
+ return typeof input.arrayBuffer === "function" ? input : void 0;
2542
+ }
2543
+ function viewBytes(input) {
2544
+ return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
2545
+ }
2447
2546
  function socketFrame(input) {
2448
2547
  if (typeof input === "string") {
2449
2548
  return {
@@ -2463,14 +2562,56 @@ function socketFrame(input) {
2463
2562
  data_base64: bytesToBase64(input)
2464
2563
  };
2465
2564
  }
2565
+ if (ArrayBuffer.isView(input)) {
2566
+ return {
2567
+ binary: true,
2568
+ data_base64: bytesToBase64(viewBytes(input))
2569
+ };
2570
+ }
2571
+ const blob = blobLike(input);
2572
+ if (!blob) return void 0;
2573
+ return blob.arrayBuffer().then((buffer) => ({
2574
+ binary: true,
2575
+ data_base64: bytesToBase64(new Uint8Array(buffer))
2576
+ }));
2466
2577
  }
2467
2578
  function socketPayload(input) {
2468
2579
  if (typeof input === "string") return input;
2469
2580
  if (input instanceof ArrayBuffer) return input;
2470
2581
  if (input instanceof Uint8Array) return input;
2471
- if (ArrayBuffer.isView(input)) {
2472
- return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
2473
- }
2582
+ if (ArrayBuffer.isView(input)) return viewBytes(input);
2583
+ const blob = blobLike(input);
2584
+ if (!blob) return void 0;
2585
+ return blob.arrayBuffer();
2586
+ }
2587
+ function orderedFrameDelivery(deliver) {
2588
+ let tail;
2589
+ return (data) => {
2590
+ const payload = socketPayload(data);
2591
+ if (payload === void 0) return;
2592
+ if (tail === void 0 && !(payload instanceof Promise)) {
2593
+ deliver(payload);
2594
+ return;
2595
+ }
2596
+ const queued = (tail ?? Promise.resolve()).then(async () => deliver(await payload)).catch(() => {
2597
+ });
2598
+ tail = queued;
2599
+ void queued.then(() => {
2600
+ if (tail === queued) tail = void 0;
2601
+ });
2602
+ };
2603
+ }
2604
+ async function relayFrameDiagnostics(data) {
2605
+ const frame = await socketFrame(data);
2606
+ const payload = await socketPayload(data);
2607
+ return {
2608
+ // `undefined` here is exactly the silent drop: every caller treats it as
2609
+ // "nothing to forward" and returns without a throw or a log.
2610
+ frameDropped: frame === void 0,
2611
+ payloadDropped: payload === void 0,
2612
+ ...frame ? { binary: frame.binary, dataBase64: frame.data_base64 } : {},
2613
+ payloadBase64: payload === void 0 ? void 0 : typeof payload === "string" ? bytesToBase64(new TextEncoder().encode(payload)) : bytesToBase64(payload instanceof ArrayBuffer ? new Uint8Array(payload) : payload)
2614
+ };
2474
2615
  }
2475
2616
  function tunnelFrame(input) {
2476
2617
  const bytes = base64ToBytes(input.data_base64);
@@ -2616,7 +2757,13 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
2616
2757
  if (url.pathname === "/health") {
2617
2758
  return Response.json({ ok: true, service: "workspace-relay", mode: "cloudflare-durable-object" });
2618
2759
  }
2619
- const relayLocationHint = typeof env.CLAXEDO_RELAY_LOCATION_HINT === "string" && env.CLAXEDO_RELAY_LOCATION_HINT ? env.CLAXEDO_RELAY_LOCATION_HINT : DEFAULT_RELAY_LOCATION_HINT;
2760
+ const requestRegion = relayRequestRegion(request);
2761
+ const requestCountry = relayRequestCountry(request);
2762
+ const locationHint = relayLocationHint({
2763
+ ...typeof env.CLAXEDO_RELAY_LOCATION_HINT === "string" ? { configured: env.CLAXEDO_RELAY_LOCATION_HINT } : {},
2764
+ ...requestRegion ? { region: requestRegion } : {},
2765
+ ...requestCountry ? { country: requestCountry } : {}
2766
+ });
2620
2767
  const namespace = namespaceFromEnv(env, options);
2621
2768
  if (!namespace) {
2622
2769
  return json(
@@ -2642,7 +2789,7 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
2642
2789
  }
2643
2790
  const routeFinishedAt = performance.now();
2644
2791
  const response = await namespace.get(namespace.idFromName(workspaceRelayDurableObjectRoomName(workspaceId)), {
2645
- locationHint: relayLocationHint
2792
+ locationHint
2646
2793
  }).fetch(tracedRequest(request, {
2647
2794
  id: gatewayTraceId,
2648
2795
  sampled: request.headers.get("x-claxedo-trace-sampled") === "1",
@@ -2656,7 +2803,7 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
2656
2803
  }
2657
2804
  const headers2 = new Headers(response.headers);
2658
2805
  headers2.set(TRACE_ID_HEADER, headers2.get(TRACE_ID_HEADER) ?? gatewayTraceId);
2659
- headers2.set("x-claxedo-relay-location-hint", relayLocationHint);
2806
+ headers2.set("x-claxedo-relay-location-hint", locationHint);
2660
2807
  const gatewayResponseStartedAt = performance.now();
2661
2808
  const tracedResponse = new Response(response.body, {
2662
2809
  status: response.status,
@@ -2741,6 +2888,10 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2741
2888
  const rebuildHibernatedSockets = () => {
2742
2889
  if (!hibernation) return;
2743
2890
  hibernatedSocketsRebuilt = true;
2891
+ for (const tunnel of hostTunnels.values()) {
2892
+ if (tunnel.pending.size === 0) continue;
2893
+ cleanupTunnelResources(tunnel, "Workspace relay restarted while the request was in flight");
2894
+ }
2744
2895
  hostTunnels.clear();
2745
2896
  clients.clear();
2746
2897
  const clientRows = [];
@@ -2798,10 +2949,13 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2798
2949
  }
2799
2950
  }
2800
2951
  };
2952
+ const resolverGraceAttempts = options.resolverOutageGraceAttempts ?? RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT;
2953
+ const resolverUnreachable = (code) => code === "relay_revocation_resolver_unavailable";
2801
2954
  const watchRuntimeAccessToken = (client, onInactive) => {
2802
2955
  const intervalMs = options.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
2803
2956
  if (intervalMs <= 0) return;
2804
2957
  const now = options.now ?? Date.now;
2958
+ let consecutiveUnreachable = 0;
2805
2959
  const timer = setInterval(() => {
2806
2960
  if (client.request.claims.exp * 1e3 <= now()) {
2807
2961
  clearInterval(timer);
@@ -2809,14 +2963,25 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2809
2963
  return;
2810
2964
  }
2811
2965
  if (!options.isRuntimeAccessTokenActive) return;
2966
+ const tolerate = (reason) => {
2967
+ consecutiveUnreachable += 1;
2968
+ if (consecutiveUnreachable < resolverGraceAttempts) return;
2969
+ clearInterval(timer);
2970
+ onInactive(reason);
2971
+ };
2812
2972
  void Promise.resolve(options.isRuntimeAccessTokenActive(client.request.claims)).then((active) => {
2813
- if (active.active) return;
2973
+ if (active.active) {
2974
+ consecutiveUnreachable = 0;
2975
+ return;
2976
+ }
2977
+ if (resolverUnreachable(active.code)) {
2978
+ tolerate(`Runtime Access Token active check unavailable: ${active.reason}`);
2979
+ return;
2980
+ }
2981
+ consecutiveUnreachable = 0;
2814
2982
  clearInterval(timer);
2815
2983
  onInactive(active.reason);
2816
- }).catch(() => {
2817
- clearInterval(timer);
2818
- onInactive("Runtime Access Token active check failed");
2819
- });
2984
+ }).catch(() => tolerate("Runtime Access Token active check failed"));
2820
2985
  }, intervalMs);
2821
2986
  if (typeof timer.unref === "function") {
2822
2987
  timer.unref();
@@ -2827,14 +2992,19 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2827
2992
  const intervalMs = options.workspaceTargetActiveCheckIntervalMs ?? WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
2828
2993
  if (intervalMs <= 0) return;
2829
2994
  const target = client.request.target;
2995
+ let consecutiveUnreachable = 0;
2830
2996
  const timer = setInterval(() => {
2831
2997
  void Promise.resolve(options.resolveTarget(client.request.claims)).then((activeTarget) => {
2832
2998
  if (activeTarget?.access === "user-hosted" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
2999
+ consecutiveUnreachable = 0;
2833
3000
  return;
2834
3001
  }
3002
+ consecutiveUnreachable = 0;
2835
3003
  clearInterval(timer);
2836
3004
  onInactive("User-hosted workspace is offline");
2837
3005
  }).catch(() => {
3006
+ consecutiveUnreachable += 1;
3007
+ if (consecutiveUnreachable < resolverGraceAttempts) return;
2838
3008
  clearInterval(timer);
2839
3009
  onInactive("Workspace relay target active check failed");
2840
3010
  });
@@ -3019,6 +3189,67 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3019
3189
  const activeTarget = await options.resolveTarget(client.request.claims);
3020
3190
  return !!(activeTarget?.access === "user-hosted" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId }));
3021
3191
  };
3192
+ const dropUserHostedChannel = (tunnel, channelId, client, code, reason) => {
3193
+ tunnel.channels.delete(channelId);
3194
+ clients.delete(client);
3195
+ closeSocket(client.socket, code, reason);
3196
+ sendSocket(tunnel.socket, JSON.stringify({
3197
+ type: "ws.close",
3198
+ protocol: TUNNEL_PROTOCOL_VERSION2,
3199
+ channel_id: channelId,
3200
+ code,
3201
+ reason
3202
+ }));
3203
+ };
3204
+ const reportFrameHandlerFailure = (socket, err) => {
3205
+ const reason = err instanceof Error ? err.message : String(err);
3206
+ console.error(`[workspace-relay] frame handler failed: ${reason}`);
3207
+ closeSocket(socket, 1011, "Workspace relay failed to process a frame");
3208
+ };
3209
+ const hibernatedRevocationIntervalMs = () => options.hibernatedRevocationCheckIntervalMs ?? HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT;
3210
+ const scheduleHibernatedRevocationCheck = async () => {
3211
+ const intervalMs = hibernatedRevocationIntervalMs();
3212
+ if (!hibernation || !options.alarms || intervalMs <= 0) return;
3213
+ if (clients.size === 0) return;
3214
+ const now = options.now ?? Date.now;
3215
+ const at = now() + intervalMs;
3216
+ try {
3217
+ const existing = await options.alarms.getAlarm();
3218
+ if (typeof existing === "number" && existing > 0 && existing <= at) return;
3219
+ await options.alarms.setAlarm(at);
3220
+ } catch {
3221
+ }
3222
+ };
3223
+ const runHibernatedRevocationCheck = async () => {
3224
+ ensureHibernatedSocketsRebuilt();
3225
+ const now = options.now ?? Date.now;
3226
+ const revoke = (client, reason) => {
3227
+ const channelId = client.channelId;
3228
+ for (const tunnel of hostTunnels.values()) {
3229
+ if (!channelId || tunnel.channels.get(channelId) !== client) continue;
3230
+ dropUserHostedChannel(tunnel, channelId, client, 1008, reason);
3231
+ return;
3232
+ }
3233
+ closeSocket(client.socket, 1008, reason);
3234
+ clients.delete(client);
3235
+ };
3236
+ for (const client of [...clients]) {
3237
+ if (client.request.claims.exp * 1e3 <= now()) {
3238
+ revoke(client, "Runtime Access Token expired");
3239
+ continue;
3240
+ }
3241
+ if (!options.isRuntimeAccessTokenActive) continue;
3242
+ let active;
3243
+ try {
3244
+ active = await options.isRuntimeAccessTokenActive(client.request.claims);
3245
+ } catch {
3246
+ continue;
3247
+ }
3248
+ if (active.active || resolverUnreachable(active.code)) continue;
3249
+ revoke(client, active.reason);
3250
+ }
3251
+ await scheduleHibernatedRevocationCheck();
3252
+ };
3022
3253
  const activeUserHostedClient = (channelId, socket) => {
3023
3254
  for (const tunnel of hostTunnels.values()) {
3024
3255
  const client = tunnel.channels.get(channelId);
@@ -3037,7 +3268,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3037
3268
  closeSocket(socket, 1011, "User-hosted workspace is offline");
3038
3269
  return;
3039
3270
  }
3040
- const frame = socketFrame(data);
3271
+ const frame = await socketFrame(data);
3041
3272
  if (!frame) return;
3042
3273
  const sent = sendSocket(active.tunnel.socket, JSON.stringify({
3043
3274
  type: "ws.frame",
@@ -3145,7 +3376,9 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3145
3376
  closeSocket(channel.socket, 1008, token.reason);
3146
3377
  return;
3147
3378
  }
3148
- sendSocket(channel.socket, tunnelFrame(message));
3379
+ if (!sendSocket(channel.socket, tunnelFrame(message))) {
3380
+ dropUserHostedChannel(tunnel, message.channel_id, channel, 1013, "Client WebSocket delivery failed");
3381
+ }
3149
3382
  return;
3150
3383
  }
3151
3384
  if (parsed.message.type === "ws.close") {
@@ -3239,7 +3472,8 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3239
3472
  directory.registerHostTunnel({ hostId, workspaceIds });
3240
3473
  if (!hibernation) {
3241
3474
  pair.server.addEventListener?.("message", (event) => {
3242
- if (event && event.data !== void 0) void handleTunnelMessage(hostId, { data: event.data });
3475
+ if (!event || event.data === void 0) return;
3476
+ void handleTunnelMessage(hostId, { data: event.data }).catch((err) => reportFrameHandlerFailure(pair.server, err));
3243
3477
  });
3244
3478
  pair.server.addEventListener?.("close", () => cleanupHostTunnel(hostId, pair.server, "User-hosted tunnel disconnected"));
3245
3479
  pair.server.addEventListener?.("error", () => cleanupHostTunnel(hostId, pair.server, "User-hosted tunnel disconnected"));
@@ -3282,6 +3516,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3282
3516
  let closed = false;
3283
3517
  let upstreamOpen = upstream.readyState === void 0 || upstream.readyState === 1;
3284
3518
  const queue = [];
3519
+ let queuedBytes = 0;
3285
3520
  const emitTrace = (openedAt) => {
3286
3521
  if (!trace || trace.emitted) return;
3287
3522
  trace.emitted = true;
@@ -3312,6 +3547,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3312
3547
  const flushQueue = () => {
3313
3548
  upstreamOpen = true;
3314
3549
  if (trace) emitTrace(performance.now());
3550
+ queuedBytes = 0;
3315
3551
  for (const item of queue.splice(0)) {
3316
3552
  if (!sendSocket(upstream, item)) {
3317
3553
  closeSocket(pair.server, 1011, "Upstream WebSocket connection failed");
@@ -3319,28 +3555,31 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3319
3555
  }
3320
3556
  }
3321
3557
  };
3322
- pair.server.addEventListener?.("message", (event) => {
3323
- const payload = socketPayload(event?.data);
3324
- if (payload === void 0) return;
3558
+ const deliverClientFrame = orderedFrameDelivery((payload) => {
3325
3559
  if (!upstreamOpen) {
3326
- if (queue.length >= (options.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT2)) {
3560
+ const frameBytes = preOpenFrameBytes2(payload);
3561
+ if (queue.length >= (options.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT2) && queuedBytes + frameBytes > (options.upstreamWebSocketPreOpenQueueMaxBytes ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT2)) {
3327
3562
  closeSocket(pair.server, 1011, "Upstream WebSocket queue limit exceeded");
3328
3563
  cleanupClient({ code: 1011, reason: "Upstream WebSocket queue limit exceeded" });
3329
3564
  return;
3330
3565
  }
3566
+ queuedBytes += frameBytes;
3331
3567
  if (trace) trace.queueTimes.push(performance.now());
3332
3568
  queue.push(payload);
3333
3569
  return;
3334
3570
  }
3335
3571
  if (!sendSocket(upstream, payload)) closeSocket(pair.server, 1011, "Upstream WebSocket connection failed");
3336
3572
  });
3573
+ pair.server.addEventListener?.("message", (event) => deliverClientFrame(event?.data));
3337
3574
  pair.server.addEventListener?.("close", cleanupClient);
3338
3575
  pair.server.addEventListener?.("error", cleanupClient);
3339
3576
  upstream.addEventListener?.("open", flushQueue);
3340
- upstream.addEventListener?.("message", (event) => {
3341
- const payload = socketPayload(event?.data);
3342
- if (payload !== void 0) sendSocket(pair.server, payload);
3577
+ const deliverUpstreamFrame = orderedFrameDelivery((payload) => {
3578
+ if (!sendSocket(pair.server, payload)) {
3579
+ closeSocket(pair.server, 1011, "Client WebSocket send failed");
3580
+ }
3343
3581
  });
3582
+ upstream.addEventListener?.("message", (event) => deliverUpstreamFrame(event?.data));
3344
3583
  upstream.addEventListener?.("close", cleanupUpstream);
3345
3584
  upstream.addEventListener?.("error", () => cleanupUpstream({ code: 1011, reason: "Upstream WebSocket connection failed" }));
3346
3585
  if (trace && upstreamOpen) emitTrace(upstreamConnectedAt);
@@ -3376,6 +3615,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3376
3615
  const activeTargetTimer = hibernation ? void 0 : watchUserHostedTarget(client, (reason) => {
3377
3616
  closeSocket(pair.server, 1011, reason);
3378
3617
  });
3618
+ if (hibernation) void scheduleHibernatedRevocationCheck();
3379
3619
  if (!hibernation) {
3380
3620
  const cleanup = (event) => {
3381
3621
  if (activeTokenTimer) clearInterval(activeTokenTimer);
@@ -3384,12 +3624,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3384
3624
  };
3385
3625
  pair.server.addEventListener?.("message", (event) => {
3386
3626
  if (!event) return;
3387
- void handleUserHostedClientMessage(channelId, pair.server, event.data);
3627
+ void handleUserHostedClientMessage(channelId, pair.server, event.data).catch((err) => reportFrameHandlerFailure(pair.server, err));
3388
3628
  });
3389
3629
  pair.server.addEventListener?.("close", cleanup);
3390
3630
  pair.server.addEventListener?.("error", cleanup);
3391
3631
  }
3392
- sendSocket(tunnel.socket, JSON.stringify({
3632
+ const opened = sendSocket(tunnel.socket, JSON.stringify({
3393
3633
  type: "ws.open",
3394
3634
  protocol: TUNNEL_PROTOCOL_VERSION2,
3395
3635
  channel_id: channelId,
@@ -3402,6 +3642,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3402
3642
  { userHosted: true }
3403
3643
  ))
3404
3644
  }));
3645
+ if (!opened) {
3646
+ tunnel.channels.delete(channelId);
3647
+ clients.delete(client);
3648
+ closeSocket(pair.server, 1011, "User-hosted tunnel unavailable");
3649
+ return json("user_hosted_app_offline", "User-hosted workspace is offline", 503);
3650
+ }
3405
3651
  return upgradeResponse(pair.client, request);
3406
3652
  };
3407
3653
  const forwardCloudHttp = async (request, workspaceId, trace) => {
@@ -3555,14 +3801,29 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3555
3801
  return json("relay_route_not_found", "Workspace Relay route not found", 404);
3556
3802
  },
3557
3803
  async webSocketMessage(socket, message) {
3558
- ensureHibernatedSocketsRebuilt();
3559
- const attachment = socketAttachment(socket);
3560
- if (!attachment) return;
3561
- if (attachment.kind === "host-tunnel") {
3562
- await handleTunnelMessage(attachment.hostId, { data: message });
3563
- return;
3804
+ try {
3805
+ ensureHibernatedSocketsRebuilt();
3806
+ const attachment = socketAttachment(socket);
3807
+ if (!attachment) return;
3808
+ if (attachment.kind === "host-tunnel") {
3809
+ await handleTunnelMessage(attachment.hostId, { data: message });
3810
+ return;
3811
+ }
3812
+ await handleUserHostedClientMessage(attachment.channelId, socket, message);
3813
+ } catch (err) {
3814
+ reportFrameHandlerFailure(socket, err);
3815
+ }
3816
+ },
3817
+ /**
3818
+ * Durable Object alarm entry point. Runs the hibernation-safe revocation
3819
+ * sweep (W6b.2). The Worker must forward `alarm()` here.
3820
+ */
3821
+ async alarm() {
3822
+ try {
3823
+ await runHibernatedRevocationCheck();
3824
+ } catch (err) {
3825
+ console.error(`[workspace-relay] revocation alarm failed: ${err instanceof Error ? err.message : String(err)}`);
3564
3826
  }
3565
- await handleUserHostedClientMessage(attachment.channelId, socket, message);
3566
3827
  },
3567
3828
  webSocketClose(socket, code, reason) {
3568
3829
  ensureHibernatedSocketsRebuilt();
@@ -3599,9 +3860,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3599
3860
  export {
3600
3861
  DEFAULT_RELAY_APP_ORIGINS,
3601
3862
  DEFAULT_RELAY_LOCATION_HINT,
3863
+ RELAY_LOCATION_HINTS,
3864
+ RELAY_REGION_HEADER,
3602
3865
  WorkspaceRelayAuthError,
3603
3866
  __slowConsumerInternalsForTest,
3604
3867
  authorizeWorkspaceRelayRequest,
3868
+ bytesToBase64,
3605
3869
  createCachedRevocationClient,
3606
3870
  createCachedTargetClient,
3607
3871
  createOriginMatcher,
@@ -3621,8 +3885,14 @@ export {
3621
3885
  mintRelayHostToken,
3622
3886
  mintRuntimeAccessToken,
3623
3887
  parseAllowedOrigins,
3888
+ relayBufferedBytes,
3889
+ relayFrameDiagnostics,
3624
3890
  relayHostTokenAudience,
3625
3891
  relayHostTokenIssuer,
3892
+ relayLocationHint,
3893
+ relayOverBackpressureLimit,
3894
+ relayRequestCountry,
3895
+ relayRequestRegion,
3626
3896
  runtimeAccessTokenAudience,
3627
3897
  runtimeAccessTokenIssuer,
3628
3898
  setWorkspaceRelayAllowedOrigins,