@claxedo/workspace-relay 0.5.0 → 0.5.2

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
@@ -73,6 +73,7 @@ async function verifyJwt(token, key, input) {
73
73
  algorithms: [...algorithms]
74
74
  });
75
75
  } catch (err) {
76
+ if (err instanceof errors.JWKSTimeout) throw err;
76
77
  if (err instanceof errors.JOSEError) {
77
78
  throw new WorkspaceRelayAuthError("invalid_relay_token", "Relay token is invalid");
78
79
  }
@@ -221,6 +222,7 @@ function hostTunnelClaims(payload) {
221
222
 
222
223
  // src/bun.ts
223
224
  import {
225
+ makeTunnelPing,
224
226
  makeTunnelPong,
225
227
  TUNNEL_PROTOCOL_VERSION,
226
228
  validateTunnelMessage
@@ -231,6 +233,48 @@ import { Hono } from "hono";
231
233
  import { cors } from "hono/cors";
232
234
  import { exportJWK } from "jose";
233
235
 
236
+ // src/cors-origins.ts
237
+ var DEFAULT_RELAY_APP_ORIGINS = [
238
+ "http://localhost:*",
239
+ "http://127.0.0.1:*",
240
+ "https://opencode.ai",
241
+ "https://*.opencode.ai",
242
+ "https://claxedo.com",
243
+ "https://*.claxedo.com"
244
+ ];
245
+ function parseAllowedOrigins(raw) {
246
+ const entries = (raw ?? "").split(",").map((item) => item.trim()).filter(Boolean);
247
+ return entries.length ? entries : void 0;
248
+ }
249
+ function createOriginMatcher(patterns) {
250
+ const exact = /* @__PURE__ */ new Set();
251
+ const httpsSuffixes = [];
252
+ const httpPortPrefixes = [];
253
+ for (const pattern of patterns) {
254
+ if (pattern.startsWith("https://*.")) {
255
+ httpsSuffixes.push(pattern.slice("https://*".length));
256
+ } else if (pattern.startsWith("http://") && pattern.endsWith(":*")) {
257
+ httpPortPrefixes.push(pattern.slice(0, -1));
258
+ } else {
259
+ exact.add(pattern);
260
+ }
261
+ }
262
+ return (origin) => {
263
+ if (exact.has(origin)) return true;
264
+ if (origin.startsWith("https://")) {
265
+ return httpsSuffixes.some(
266
+ (suffix) => origin.endsWith(suffix) && origin.length > "https://".length + suffix.length
267
+ );
268
+ }
269
+ if (origin.startsWith("http://")) {
270
+ return httpPortPrefixes.some(
271
+ (prefix) => origin.startsWith(prefix) && /^\d+$/.test(origin.slice(prefix.length))
272
+ );
273
+ }
274
+ return false;
275
+ };
276
+ }
277
+
234
278
  // src/http.ts
235
279
  function bearerToken(header) {
236
280
  if (!header) return;
@@ -374,9 +418,24 @@ function targetUrl(target, path, search) {
374
418
  url.search = search;
375
419
  return url;
376
420
  }
377
- function roleAllowsRelayRequest(role, method) {
421
+ var RELAY_VIEWER_DENIED_PATH = /^\/api\/wr\/pty(?:\/|$)/;
422
+ function relayRuntimePath(request) {
423
+ return new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/");
424
+ }
425
+ function relayAuthorizationPath(path) {
426
+ try {
427
+ return decodeURIComponent(path);
428
+ } catch {
429
+ return void 0;
430
+ }
431
+ }
432
+ function roleAllowsRelayRequest(role, method, path) {
378
433
  if (role === "owner" || role === "admin" || role === "editor") return true;
379
- if (role === "viewer") return method === "GET" || method === "HEAD" || method === "OPTIONS";
434
+ if (role === "viewer") {
435
+ const authorizationPath = relayAuthorizationPath(path);
436
+ if (!authorizationPath || RELAY_VIEWER_DENIED_PATH.test(authorizationPath)) return false;
437
+ return method === "GET" || method === "HEAD" || method === "OPTIONS";
438
+ }
380
439
  return false;
381
440
  }
382
441
  function relayHostTokenCacheTtlMs(options, claims) {
@@ -645,11 +704,21 @@ async function audit(options, event) {
645
704
  }
646
705
  await options.audit?.(event);
647
706
  }
648
- function denyCorsHeaders(request) {
707
+ var defaultOriginMatcher = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
708
+ var originMatchers = /* @__PURE__ */ new WeakMap();
709
+ function originMatcherFor(options) {
710
+ if (!options.allowedOrigins) return defaultOriginMatcher;
711
+ let matcher = originMatchers.get(options);
712
+ if (!matcher) {
713
+ matcher = createOriginMatcher(options.allowedOrigins);
714
+ originMatchers.set(options, matcher);
715
+ }
716
+ return matcher;
717
+ }
718
+ function denyCorsHeaders(request, originAllowed = defaultOriginMatcher) {
649
719
  const origin = request.headers.get("origin");
650
720
  if (!origin) return void 0;
651
- const allowed = origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:") || /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin);
652
- if (!allowed) return void 0;
721
+ if (!originAllowed(origin)) return void 0;
653
722
  return {
654
723
  "access-control-allow-origin": origin,
655
724
  "access-control-allow-headers": "Accept, Authorization, Content-Type, Last-Event-ID, X-Fetch-Bypass-Throttle, X-Daytona-Skip-Preview-Warning, X-Workspace-Id, X-OpenCode-Directory, X-Claxedo-Runner, X-Claxedo-Model, X-Claxedo-Draft-Id, X-Claxedo-Binary",
@@ -671,7 +740,7 @@ async function deny(options, input) {
671
740
  });
672
741
  return Response.json(errorBody(input.code, input.message), {
673
742
  status: input.status,
674
- headers: denyCorsHeaders(input.request)
743
+ headers: denyCorsHeaders(input.request, originMatcherFor(options))
675
744
  });
676
745
  }
677
746
  async function authorizeWorkspaceRelayRequest(options, request, workspaceId, trace) {
@@ -710,7 +779,8 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
710
779
  })
711
780
  };
712
781
  }
713
- if (!roleAllowsRelayRequest(claims.role, request.method)) {
782
+ const path = relayRuntimePath(request);
783
+ if (!roleAllowsRelayRequest(claims.role, request.method, path)) {
714
784
  return {
715
785
  ok: false,
716
786
  code: "relay_role_denied",
@@ -767,7 +837,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
767
837
  claims,
768
838
  target,
769
839
  relayHostToken,
770
- path: new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/")
840
+ path
771
841
  }
772
842
  };
773
843
  } catch (err) {
@@ -784,7 +854,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
784
854
  };
785
855
  }
786
856
  }
787
- async function forwardWorkspaceRelayRequest(request, target, relayHostToken, path, requestFetch, timeoutMs, trace) {
857
+ async function forwardWorkspaceRelayRequest(request, target, relayHostToken, path, requestFetch, timeoutMs, trace, originAllowed = defaultOriginMatcher) {
788
858
  const controller = new AbortController();
789
859
  const timer = setTimeout(() => controller.abort(), timeoutMs);
790
860
  const span = (name, run) => trace ? trace.span(name, run) : run();
@@ -803,7 +873,7 @@ async function forwardWorkspaceRelayRequest(request, target, relayHostToken, pat
803
873
  headers2.delete("access-control-allow-methods");
804
874
  headers2.delete("access-control-expose-headers");
805
875
  headers2.delete("access-control-max-age");
806
- for (const [key, value] of Object.entries(denyCorsHeaders(request) ?? {})) {
876
+ for (const [key, value] of Object.entries(denyCorsHeaders(request, originAllowed) ?? {})) {
807
877
  headers2.set(key, value);
808
878
  }
809
879
  return new Response(upstream.body, {
@@ -818,7 +888,7 @@ async function forwardWorkspaceRelayRequest(request, target, relayHostToken, pat
818
888
  aborted ? "upstream_timeout" : "upstream_unavailable",
819
889
  aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable"
820
890
  ),
821
- { status: aborted ? 504 : 503, headers: denyCorsHeaders(request) }
891
+ { status: aborted ? 504 : 503, headers: denyCorsHeaders(request, originAllowed) }
822
892
  );
823
893
  } finally {
824
894
  clearTimeout(timer);
@@ -893,14 +963,11 @@ function buildMetricsBody(options) {
893
963
  }
894
964
  function createWorkspaceRelay(options) {
895
965
  const app = new Hono();
966
+ const originAllowed = originMatcherFor(options);
896
967
  app.use("*", cors({
897
968
  origin: (origin) => {
898
969
  if (!origin) return void 0;
899
- if (origin.startsWith("http://localhost:")) return origin;
900
- if (origin.startsWith("http://127.0.0.1:")) return origin;
901
- if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
902
- if (/^https:\/\/([a-z0-9-]+\.)*claxedo\.com$/.test(origin)) return origin;
903
- return void 0;
970
+ return originAllowed(origin) ? origin : void 0;
904
971
  },
905
972
  allowHeaders: [
906
973
  "Accept",
@@ -980,7 +1047,7 @@ function createWorkspaceRelay(options) {
980
1047
  if (options.isDraining?.()) {
981
1048
  return Response.json(
982
1049
  errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
983
- { status: 503, headers: denyCorsHeaders(c.req.raw) }
1050
+ { status: 503, headers: denyCorsHeaders(c.req.raw, originAllowed) }
984
1051
  );
985
1052
  }
986
1053
  const response = await trace.span("relay-total", async () => {
@@ -993,7 +1060,8 @@ function createWorkspaceRelay(options) {
993
1060
  relay.request.path,
994
1061
  options.fetch ?? fetch,
995
1062
  options.forwardTimeoutMs ?? 3e4,
996
- trace
1063
+ trace,
1064
+ originAllowed
997
1065
  );
998
1066
  });
999
1067
  return workspaceRelayTimingResponse(response, trace);
@@ -1248,12 +1316,7 @@ function tunnelMessage(ws, input, stats) {
1248
1316
  function sendTunnelPing(ws) {
1249
1317
  if (ws.readyState !== WebSocket.OPEN) return;
1250
1318
  ws.data.missedPongs += 1;
1251
- ws.send(JSON.stringify({
1252
- type: "ping",
1253
- protocol: TUNNEL_PROTOCOL_VERSION,
1254
- id: crypto.randomUUID(),
1255
- sent_at: Date.now()
1256
- }));
1319
+ ws.send(JSON.stringify(makeTunnelPing()));
1257
1320
  }
1258
1321
  function clearPendingTimers(entry) {
1259
1322
  clearTimeout(entry.timeout);
@@ -1854,6 +1917,29 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
1854
1917
  ws.data.missedPongs = 0;
1855
1918
  options.directory?.recordPong(ws.data.hostId);
1856
1919
  }
1920
+ if (parsed?.type === "host.registration.update") {
1921
+ const update = parsed;
1922
+ const workspaceIds = [...new Set(update.workspace_ids)];
1923
+ const hostSocket = ws;
1924
+ const hostId = hostSocket.data.hostId;
1925
+ const authorizationRequest = new Request(
1926
+ `http://relay.local/host-tunnels/${encodeURIComponent(hostId)}`,
1927
+ { headers: { authorization: `Bearer ${update.token}` } }
1928
+ );
1929
+ void authorizeHostTunnel(options, bunOptions, authorizationRequest, {
1930
+ hostId,
1931
+ workspaceIds
1932
+ }).then((authorized) => {
1933
+ if (!authorized) {
1934
+ closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008);
1935
+ return;
1936
+ }
1937
+ if (hostTunnels.get(hostId) !== hostSocket || hostSocket.readyState !== WebSocket.OPEN) return;
1938
+ hostSocket.data.workspaceIds = workspaceIds;
1939
+ options.directory?.registerHostTunnel({ hostId, workspaceIds });
1940
+ }).catch(() => closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008));
1941
+ return;
1942
+ }
1857
1943
  if (parsed?.type === "error") {
1858
1944
  if (parsed.request_id) {
1859
1945
  const pending = ws.data.pending.get(parsed.request_id);
@@ -2244,7 +2330,8 @@ function clampRate(input) {
2244
2330
  }
2245
2331
  function traceRequest(request, routeKind, options) {
2246
2332
  const id = clean(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
2247
- const forced = request.headers.get(TRACE_FORCE_HEADER) === "1";
2333
+ const forceSecret = clean(options.traceForceHeaderSecret);
2334
+ const forced = !!forceSecret && request.headers.get(TRACE_FORCE_HEADER) === forceSecret;
2248
2335
  const rate = clampRate(options.traceSampleRate);
2249
2336
  return {
2250
2337
  id,
@@ -2266,6 +2353,7 @@ function tracedRequest(request, trace) {
2266
2353
  const headers2 = new Headers(request.headers);
2267
2354
  headers2.set(TRACE_ID_HEADER, trace.id);
2268
2355
  if (trace.sampled) headers2.set("x-claxedo-trace-sampled", "1");
2356
+ else headers2.delete("x-claxedo-trace-sampled");
2269
2357
  return new Request(request.url, {
2270
2358
  method: request.method,
2271
2359
  headers: headers2,
@@ -2283,7 +2371,7 @@ function serverTiming(trace) {
2283
2371
  function withTraceHeaders(response, trace) {
2284
2372
  const headers2 = new Headers(response.headers);
2285
2373
  headers2.set(TRACE_ID_HEADER, trace.id);
2286
- const timing = serverTiming(trace);
2374
+ const timing = trace.sampled ? serverTiming(trace) : "";
2287
2375
  if (timing) {
2288
2376
  const existing = headers2.get("server-timing");
2289
2377
  headers2.set("server-timing", existing ? `${existing}, ${timing}` : timing);
@@ -2324,6 +2412,9 @@ function hostIdFromTunnelPath2(pathname) {
2324
2412
  function websocketRequest2(request) {
2325
2413
  return request.headers.get("upgrade")?.toLowerCase() === "websocket";
2326
2414
  }
2415
+ function relayWebSocketTraceEnabled2(request) {
2416
+ return request.headers.get("x-claxedo-relay-ws-trace") === "1";
2417
+ }
2327
2418
  function workspaceIdsFromSearch(url) {
2328
2419
  return [...new Set([
2329
2420
  ...url.searchParams.getAll("workspaceId"),
@@ -2441,25 +2532,17 @@ function json(code, message, status) {
2441
2532
  }
2442
2533
  var configuredAppOriginAllowed = () => false;
2443
2534
  function setWorkspaceRelayAppOrigins(raw) {
2444
- const entries = (raw ?? "").split(",").map((item) => item.trim()).filter(Boolean);
2445
- const exact = /* @__PURE__ */ new Set();
2446
- const suffixes = [];
2447
- for (const entry of entries) {
2448
- if (entry.startsWith("https://*.")) suffixes.push(entry.slice("https://*".length));
2449
- else exact.add(entry);
2450
- }
2451
- configuredAppOriginAllowed = (origin) => {
2452
- if (exact.has(origin)) return true;
2453
- if (!origin.startsWith("https://")) return false;
2454
- return suffixes.some((suffix) => origin.endsWith(suffix) && origin.length > "https://".length + suffix.length);
2455
- };
2535
+ configuredAppOriginAllowed = raw?.trim() ? createOriginMatcher(parseAllowedOrigins(raw) ?? []) : () => false;
2536
+ }
2537
+ var defaultOriginAllowed = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
2538
+ var baseOriginAllowed = defaultOriginAllowed;
2539
+ function setWorkspaceRelayAllowedOrigins(raw) {
2540
+ const patterns = parseAllowedOrigins(raw);
2541
+ baseOriginAllowed = patterns ? createOriginMatcher(patterns) : defaultOriginAllowed;
2456
2542
  }
2457
2543
  function allowedCorsOrigin2(origin) {
2458
2544
  if (!origin) return;
2459
- if (origin.startsWith("http://localhost:")) return origin;
2460
- if (origin.startsWith("http://127.0.0.1:")) return origin;
2461
- if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
2462
- if (/^https:\/\/([a-z0-9-]+\.)*claxedo\.com$/.test(origin)) return origin;
2545
+ if (baseOriginAllowed(origin)) return origin;
2463
2546
  if (configuredAppOriginAllowed(origin)) return origin;
2464
2547
  }
2465
2548
  function corsHeaders(request) {
@@ -2581,16 +2664,16 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
2581
2664
  headers: headers2
2582
2665
  });
2583
2666
  const gatewayFinishedAt = performance.now();
2584
- const gatewayTiming = [
2585
- `relay-gateway-route;dur=${roundedMs3(routeFinishedAt - gatewayStartedAt)}`,
2586
- `relay-gateway-do-fetch;dur=${roundedMs3(fetchFinishedAt - routeFinishedAt)}`,
2587
- `relay-gateway-response;dur=${roundedMs3(gatewayFinishedAt - gatewayResponseStartedAt)}`,
2588
- `relay-gateway;dur=${roundedMs3(gatewayFinishedAt - gatewayStartedAt)}`
2589
- ].join(", ");
2590
- tracedResponse.headers.set(
2591
- "server-timing",
2592
- tracedResponse.headers.get("server-timing") ? `${tracedResponse.headers.get("server-timing")}, ${gatewayTiming}` : gatewayTiming
2593
- );
2667
+ const roomTiming = tracedResponse.headers.get("server-timing");
2668
+ if (roomTiming) {
2669
+ const gatewayTiming = [
2670
+ `relay-gateway-route;dur=${roundedMs3(routeFinishedAt - gatewayStartedAt)}`,
2671
+ `relay-gateway-do-fetch;dur=${roundedMs3(fetchFinishedAt - routeFinishedAt)}`,
2672
+ `relay-gateway-response;dur=${roundedMs3(gatewayFinishedAt - gatewayResponseStartedAt)}`,
2673
+ `relay-gateway;dur=${roundedMs3(gatewayFinishedAt - gatewayStartedAt)}`
2674
+ ].join(", ");
2675
+ tracedResponse.headers.set("server-timing", `${roomTiming}, ${gatewayTiming}`);
2676
+ }
2594
2677
  return url.pathname.startsWith("/workspaces/") ? withCors(request, tracedResponse) : tracedResponse;
2595
2678
  }
2596
2679
  };
@@ -2668,6 +2751,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2668
2751
  hostTunnels.set(attachment.hostId, {
2669
2752
  hostId: attachment.hostId,
2670
2753
  workspaceIds: attachment.workspaceIds,
2754
+ connectedAt: attachment.connectedAt,
2671
2755
  socket,
2672
2756
  pending: /* @__PURE__ */ new Map(),
2673
2757
  channels: /* @__PURE__ */ new Map()
@@ -2704,6 +2788,16 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2704
2788
  if (!hibernation || hibernatedSocketsRebuilt) return;
2705
2789
  rebuildHibernatedSockets();
2706
2790
  };
2791
+ const refreshHostPresenceFromSockets = () => {
2792
+ if (!hibernation) return;
2793
+ for (const socket of hibernation.getWebSockets()) {
2794
+ const attachment = socketAttachment(socket);
2795
+ if (attachment?.kind !== "host-tunnel") continue;
2796
+ if (!directory.recordPong(attachment.hostId)) {
2797
+ directory.registerHostTunnel({ hostId: attachment.hostId, workspaceIds: attachment.workspaceIds });
2798
+ }
2799
+ }
2800
+ };
2707
2801
  const watchRuntimeAccessToken = (client, onInactive) => {
2708
2802
  const intervalMs = options.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
2709
2803
  if (intervalMs <= 0) return;
@@ -2959,11 +3053,31 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2959
3053
  const parsed = validateTunnelMessage2(row.value);
2960
3054
  const tunnel = hostTunnels.get(hostId);
2961
3055
  if (!parsed.ok || !tunnel) return;
3056
+ directory.recordPong(tunnel.hostId);
2962
3057
  if (parsed.message.type === "ping") {
2963
- directory.recordPong(tunnel.hostId);
2964
3058
  tunnel.socket.send?.(JSON.stringify(makeTunnelPong2(parsed.message)));
2965
3059
  return;
2966
3060
  }
3061
+ if (parsed.message.type === "host.registration.update") {
3062
+ const update = parsed.message;
3063
+ const workspaceIds = [...new Set(update.workspace_ids)];
3064
+ try {
3065
+ await verifyHostTunnelToken(update.token, options.runtimeAccessKey, { hostId, workspaceIds });
3066
+ } catch {
3067
+ closeSocket(tunnel.socket, 1008, "Host tunnel registration update denied");
3068
+ return;
3069
+ }
3070
+ if (hostTunnels.get(hostId) !== tunnel) return;
3071
+ tunnel.workspaceIds = workspaceIds;
3072
+ tunnel.socket.serializeAttachment?.({
3073
+ kind: "host-tunnel",
3074
+ hostId,
3075
+ workspaceIds,
3076
+ connectedAt: tunnel.connectedAt
3077
+ });
3078
+ directory.registerHostTunnel({ hostId, workspaceIds });
3079
+ return;
3080
+ }
2967
3081
  if (parsed.message.type === "error" && parsed.message.request_id) {
2968
3082
  const pending = tunnel.pending.get(parsed.message.request_id);
2969
3083
  if (!pending) return;
@@ -3102,11 +3216,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3102
3216
  }
3103
3217
  throw err;
3104
3218
  }
3219
+ const connectedAt = options.now?.() ?? Date.now();
3105
3220
  const pair = acceptSocket({
3106
3221
  kind: "host-tunnel",
3107
3222
  hostId,
3108
3223
  workspaceIds,
3109
- connectedAt: options.now?.() ?? Date.now()
3224
+ connectedAt
3110
3225
  });
3111
3226
  const previous = hostTunnels.get(hostId);
3112
3227
  if (previous) {
@@ -3116,6 +3231,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3116
3231
  hostTunnels.set(hostId, {
3117
3232
  hostId,
3118
3233
  workspaceIds,
3234
+ connectedAt,
3119
3235
  socket: pair.server,
3120
3236
  pending: /* @__PURE__ */ new Map(),
3121
3237
  channels: /* @__PURE__ */ new Map()
@@ -3138,6 +3254,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3138
3254
  return await admitCloudClient(request, authorized.request);
3139
3255
  };
3140
3256
  const admitCloudClient = async (request, authorized) => {
3257
+ const trace = relayWebSocketTraceEnabled2(request) ? { upstreamStartedAt: performance.now(), queueTimes: [], emitted: false } : void 0;
3141
3258
  let upstream;
3142
3259
  try {
3143
3260
  upstream = await connectWebSocket(
@@ -3158,12 +3275,23 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3158
3275
  } catch {
3159
3276
  return json("upstream_unavailable", "Workspace upstream is unavailable", 503);
3160
3277
  }
3278
+ const upstreamConnectedAt = trace ? performance.now() : 0;
3161
3279
  const pair = acceptSocket();
3162
3280
  const client = { request: authorized, socket: pair.server };
3163
3281
  clients.add(client);
3164
3282
  let closed = false;
3165
3283
  let upstreamOpen = upstream.readyState === void 0 || upstream.readyState === 1;
3166
3284
  const queue = [];
3285
+ const emitTrace = (openedAt) => {
3286
+ if (!trace || trace.emitted) return;
3287
+ trace.emitted = true;
3288
+ sendSocket(pair.server, JSON.stringify({
3289
+ type: "relay.trace",
3290
+ wsUpstreamOpenMs: roundedMs3(openedAt - trace.upstreamStartedAt),
3291
+ queuedFrames: trace.queueTimes.length,
3292
+ maxQueuedDelayMs: roundedMs3(trace.queueTimes.reduce((max, at) => Math.max(max, openedAt - at), 0))
3293
+ }));
3294
+ };
3167
3295
  const activeTokenTimer = watchRuntimeAccessToken(client, (reason) => {
3168
3296
  closeSocket(pair.server, 1008, reason);
3169
3297
  });
@@ -3183,6 +3311,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3183
3311
  };
3184
3312
  const flushQueue = () => {
3185
3313
  upstreamOpen = true;
3314
+ if (trace) emitTrace(performance.now());
3186
3315
  for (const item of queue.splice(0)) {
3187
3316
  if (!sendSocket(upstream, item)) {
3188
3317
  closeSocket(pair.server, 1011, "Upstream WebSocket connection failed");
@@ -3199,6 +3328,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3199
3328
  cleanupClient({ code: 1011, reason: "Upstream WebSocket queue limit exceeded" });
3200
3329
  return;
3201
3330
  }
3331
+ if (trace) trace.queueTimes.push(performance.now());
3202
3332
  queue.push(payload);
3203
3333
  return;
3204
3334
  }
@@ -3213,6 +3343,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3213
3343
  });
3214
3344
  upstream.addEventListener?.("close", cleanupUpstream);
3215
3345
  upstream.addEventListener?.("error", () => cleanupUpstream({ code: 1011, reason: "Upstream WebSocket connection failed" }));
3346
+ if (trace && upstreamOpen) emitTrace(upstreamConnectedAt);
3216
3347
  return upgradeResponse(pair.client, request);
3217
3348
  };
3218
3349
  const admitUserHostedClient = (request, authorized) => {
@@ -3390,6 +3521,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3390
3521
  return {
3391
3522
  async fetch(request) {
3392
3523
  ensureHibernatedSocketsRebuilt();
3524
+ refreshHostPresenceFromSockets();
3393
3525
  const url = new URL(request.url);
3394
3526
  const roomWorkspaceId = workspaceRelayDurableObjectWorkspaceId(request);
3395
3527
  if (request.method === "OPTIONS" && (url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/"))) {
@@ -3457,19 +3589,22 @@ function createWorkspaceRelayDurableObjectRoom(options) {
3457
3589
  return {
3458
3590
  hostTunnelCount: hostTunnels.size,
3459
3591
  clientCount: clients.size,
3460
- hostIds: [...hostTunnels.keys()]
3592
+ hostIds: [...hostTunnels.keys()],
3593
+ hostWorkspaceIds: Object.fromEntries([...hostTunnels].map(([hostId, tunnel]) => [hostId, tunnel.workspaceIds]))
3461
3594
  };
3462
3595
  },
3463
3596
  drain: drainController
3464
3597
  };
3465
3598
  }
3466
3599
  export {
3600
+ DEFAULT_RELAY_APP_ORIGINS,
3467
3601
  DEFAULT_RELAY_LOCATION_HINT,
3468
3602
  WorkspaceRelayAuthError,
3469
3603
  __slowConsumerInternalsForTest,
3470
3604
  authorizeWorkspaceRelayRequest,
3471
3605
  createCachedRevocationClient,
3472
3606
  createCachedTargetClient,
3607
+ createOriginMatcher,
3473
3608
  createWorkspaceRelay,
3474
3609
  createWorkspaceRelayBun,
3475
3610
  createWorkspaceRelayDirectory,
@@ -3485,10 +3620,12 @@ export {
3485
3620
  mintHostTunnelToken,
3486
3621
  mintRelayHostToken,
3487
3622
  mintRuntimeAccessToken,
3623
+ parseAllowedOrigins,
3488
3624
  relayHostTokenAudience,
3489
3625
  relayHostTokenIssuer,
3490
3626
  runtimeAccessTokenAudience,
3491
3627
  runtimeAccessTokenIssuer,
3628
+ setWorkspaceRelayAllowedOrigins,
3492
3629
  setWorkspaceRelayAppOrigins,
3493
3630
  validateRuntimeAccessTokenClaims,
3494
3631
  verifyHostTunnelToken,
package/dist/main.d.ts CHANGED
@@ -3,6 +3,49 @@ import { type WorkspaceRelayDirectory } from "./directory";
3
3
  import { type RelayHostPublicKey, type RevocationLookup, type WorkspaceRelayTarget } from "./server";
4
4
  import type { RelayKey } from "./auth";
5
5
  export { createCachedRevocationClient, createCachedTargetClient } from "./server";
6
+ /**
7
+ * D12 observability: Sentry on the relay (ops floor ADR
8
+ * docs/plans/2026-07-11-016-wp-ops-floor-design.md §4).
9
+ *
10
+ * Gated on `CLAXEDO_RELAY_SENTRY_DSN` (the relay's OWN DSN env — separate
11
+ * Sentry project from the control plane). Absent DSN → `Sentry.init` is never
12
+ * called: zero SDK overhead, zero network — the documented no-op posture, safe
13
+ * before the Sentry account exists. Release = git SHA passed by the D11
14
+ * deploy workflow (`CLAXEDO_RELEASE`; `GIT_SHA` accepted as alias); events are
15
+ * tagged unit=relay + deployment_mode (absent → "self-host", mirroring D9's
16
+ * default). Tracing stays off (ADR: it is where Sentry gets expensive).
17
+ *
18
+ * The relay is a Bun process (`Bun.serve`), so this uses `@sentry/bun` —
19
+ * Sentry's Bun-native SDK with the same init-gated pattern as @sentry/node
20
+ * (https://docs.sentry.io/platforms/javascript/guides/bun/).
21
+ */
22
+ export type RelayObservabilityEnv = {
23
+ CLAXEDO_RELAY_SENTRY_DSN?: string | undefined;
24
+ CLAXEDO_RELEASE?: string | undefined;
25
+ GIT_SHA?: string | undefined;
26
+ CLAXEDO_DEPLOYMENT_MODE?: string | undefined;
27
+ /** Accept process.env verbatim (extra keys are ignored). */
28
+ [key: string]: string | undefined;
29
+ };
30
+ export type RelaySentryOptions = {
31
+ dsn: string;
32
+ release?: string;
33
+ tracesSampleRate: 0;
34
+ initialScope: {
35
+ tags: {
36
+ unit: "relay";
37
+ deployment_mode: string;
38
+ };
39
+ };
40
+ };
41
+ /**
42
+ * Pure env → Sentry options resolver. `undefined` = DSN absent = do NOT init
43
+ * (no client, no network). Exported for tests.
44
+ */
45
+ export declare function relaySentryOptions(env: RelayObservabilityEnv): RelaySentryOptions | undefined;
46
+ export declare function initRelayObservability(env?: RelayObservabilityEnv): {
47
+ enabled: boolean;
48
+ };
6
49
  /**
7
50
  * Production fail-closed gate for the relay → claxedo-server resolver channel.
8
51
  *
@@ -168,6 +211,12 @@ export type FatalProcessHandlerOptions = {
168
211
  exit?: (code?: number) => void;
169
212
  log?: (message: string) => void;
170
213
  register?: boolean;
214
+ /**
215
+ * D12: error-tracker hook, invoked (and awaited) before teardown so a fatal
216
+ * crash reaches Sentry before the process exits. Failures are logged and
217
+ * never block the exit path. Defaults to none (tests, DSN-absent runs).
218
+ */
219
+ report?: (error: unknown, source: "uncaughtException" | "unhandledRejection") => void | Promise<void>;
171
220
  stopServer?: () => Promise<void> | void;
172
221
  };
173
222
  export type FatalProcessHandle = {