@claxedo/workspace-relay 0.10.0 → 0.10.1

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.
@@ -1,5 +1,7 @@
1
1
  import { type AuthorizedWorkspaceRelayRequest, type WorkspaceRelayOptions } from "./server";
2
2
  import { type SessionHostNamespace } from "./session-host-forwarding";
3
+ import { type WorkspaceRelayTraceLogEvent } from "./relay-trace";
4
+ export type { WorkspaceRelayTraceLogEvent } from "./relay-trace";
3
5
  export type WorkspaceRelayDurableObjectId = unknown;
4
6
  export type WorkspaceRelayDurableObjectStub = {
5
7
  fetch: (request: Request) => Promise<Response>;
@@ -94,17 +96,6 @@ export type WorkspaceRelayDurableObjectRoomOptions = WorkspaceRelayOptions & {
94
96
  traceLog?: (event: WorkspaceRelayTraceLogEvent) => void;
95
97
  now?: () => number;
96
98
  };
97
- export type WorkspaceRelayTraceLogEvent = {
98
- event: "workspace_relay_request";
99
- traceId: string;
100
- workspaceId?: string;
101
- routeKind: string;
102
- method: string;
103
- path: string;
104
- status: number;
105
- totalMs: number;
106
- phases: Record<string, number>;
107
- };
108
99
  export type WorkspaceRelayDurableObjectHibernation = {
109
100
  acceptWebSocket: (socket: WorkspaceRelayDurableObjectSocket) => void;
110
101
  getWebSockets: () => WorkspaceRelayDurableObjectSocket[];
@@ -199,4 +190,3 @@ export declare function createWorkspaceRelayDurableObjectRoom(options: Workspace
199
190
  };
200
191
  drain: WorkspaceRelayDurableObjectDrainController;
201
192
  };
202
- export {};
package/dist/index.mjs CHANGED
@@ -525,7 +525,7 @@ function hostTunnelFailureResponse(error) {
525
525
  }
526
526
 
527
527
  // src/cloudflare.ts
528
- import { trimToUndefined as trimToUndefined4 } from "@claxedo/helpers/string";
528
+ import { trimToUndefined as trimToUndefined5 } from "@claxedo/helpers/string";
529
529
 
530
530
  // src/http.ts
531
531
  import { bearerToken, trimToUndefined } from "@claxedo/helpers/string";
@@ -549,6 +549,8 @@ function workspaceIdsFromSearch(url) {
549
549
 
550
550
  // src/cloudflare.ts
551
551
  import {
552
+ closeCode,
553
+ closeReason,
552
554
  makeTunnelPong,
553
555
  TUNNEL_PROTOCOL_VERSION,
554
556
  validateTunnelMessage
@@ -1425,30 +1427,11 @@ function workspaceRelayRoomPlacement(request, env, workspaceId) {
1425
1427
  });
1426
1428
  }
1427
1429
 
1428
- // src/cloudflare.ts
1429
- var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
1430
- var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
1431
- var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
1432
- var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1433
- var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1434
- var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
1435
- var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
1436
- var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 8192;
1437
- var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
1438
- var UPSTREAM_WS_PRE_OPEN_QUEUE_FRAME_OVERHEAD_BYTES = 64;
1439
- var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1440
- var RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
1441
- var HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT = 3e4;
1442
- var HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1430
+ // src/relay-trace.ts
1431
+ import { trimToUndefined as trimToUndefined4 } from "@claxedo/helpers/string";
1443
1432
  var TRACE_ID_HEADER = "x-claxedo-trace-id";
1444
1433
  var TRACE_FORCE_HEADER = "x-claxedo-relay-trace";
1445
1434
  var TRACE_SAMPLED_RESPONSE_HEADER = "x-claxedo-relay-sampled";
1446
- function unrefTimer(timer) {
1447
- if (typeof timer.unref === "function") timer.unref();
1448
- }
1449
- function isHostTunnelClient(client) {
1450
- return "channelId" in client && typeof client.channelId === "string";
1451
- }
1452
1435
  function clampRate(input) {
1453
1436
  if (input === void 0 || !Number.isFinite(input)) return 0;
1454
1437
  if (input <= 0) return 0;
@@ -1492,13 +1475,21 @@ function tracedRequest(request, trace, forwarded = request) {
1492
1475
  function roundedMs(value) {
1493
1476
  return Math.round(value * 100) / 100;
1494
1477
  }
1495
- function serverTiming(trace) {
1496
- return trace.phases.map((phase) => `${phase.name};dur=${roundedMs(phase.ms)}`).join(", ");
1478
+ function serverTiming(phases) {
1479
+ return phases.map((phase) => `${phase.name};dur=${roundedMs(phase.ms)}`).join(", ");
1480
+ }
1481
+ function webSocketTraceFrame(upstreamStartedAt, queueTimes, openedAt) {
1482
+ return {
1483
+ type: "relay.trace",
1484
+ wsUpstreamOpenMs: roundedMs(openedAt - upstreamStartedAt),
1485
+ queuedFrames: queueTimes.length,
1486
+ maxQueuedDelayMs: roundedMs(queueTimes.reduce((max, at) => Math.max(max, openedAt - at), 0))
1487
+ };
1497
1488
  }
1498
1489
  function withTraceHeaders(response, trace) {
1499
1490
  const headers = new Headers(response.headers);
1500
1491
  headers.set(TRACE_ID_HEADER, trace.id);
1501
- const timing = trace.sampled ? serverTiming(trace) : "";
1492
+ const timing = trace.sampled ? serverTiming(trace.phases) : "";
1502
1493
  if (timing) {
1503
1494
  const existing = headers.get("server-timing");
1504
1495
  headers.set("server-timing", existing ? `${existing}, ${timing}` : timing);
@@ -1531,6 +1522,28 @@ function logTrace(options, request, workspaceId, trace, response) {
1531
1522
  }
1532
1523
  console.log(JSON.stringify(event));
1533
1524
  }
1525
+
1526
+ // src/cloudflare.ts
1527
+ var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
1528
+ var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
1529
+ var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
1530
+ var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1531
+ var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
1532
+ var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
1533
+ var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
1534
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 8192;
1535
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
1536
+ var UPSTREAM_WS_PRE_OPEN_QUEUE_FRAME_OVERHEAD_BYTES = 64;
1537
+ var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1538
+ var RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
1539
+ var HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT = 3e4;
1540
+ var HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT = 3e4;
1541
+ function unrefTimer(timer) {
1542
+ if (typeof timer.unref === "function") timer.unref();
1543
+ }
1544
+ function isHostTunnelClient(client) {
1545
+ return "channelId" in client && typeof client.channelId === "string";
1546
+ }
1534
1547
  function workspaceIdFromRelayPath(pathname) {
1535
1548
  return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
1536
1549
  }
@@ -1680,20 +1693,15 @@ async function readRequestBodyCapped(request, maxBytes) {
1680
1693
  }
1681
1694
  return concatBytes(chunks);
1682
1695
  }
1683
- function safeCloseCode(code, fallback = 1011) {
1684
- if (code === void 0) return fallback;
1685
- if (code >= 1e3 && code <= 4999 && ![1004, 1005, 1006].includes(code)) return code;
1686
- return fallback;
1687
- }
1688
1696
  function workspaceRelayDurableObjectRoomName(workspaceId) {
1689
1697
  return `workspace:${workspaceId}`;
1690
1698
  }
1691
1699
  function workspaceRelayDurableObjectWorkspaceId(request) {
1692
1700
  const url = new URL(request.url);
1693
- const workspaceId = trimToUndefined4(workspaceIdFromRelayPath(url.pathname));
1701
+ const workspaceId = trimToUndefined5(workspaceIdFromRelayPath(url.pathname));
1694
1702
  if (workspaceId) return decodeURIComponent(workspaceId);
1695
1703
  if (!hostIdFromTunnelPath(url.pathname)) return void 0;
1696
- return trimToUndefined4(url.searchParams.get("workspaceId")) ?? trimToUndefined4(url.searchParams.get("workspace_id"));
1704
+ return trimToUndefined5(url.searchParams.get("workspaceId")) ?? trimToUndefined5(url.searchParams.get("workspace_id"));
1697
1705
  }
1698
1706
  function namespaceFromEnv(env, options) {
1699
1707
  if (options.namespace) return options.namespace;
@@ -1743,7 +1751,7 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
1743
1751
  return {
1744
1752
  async fetch(request, env = {}) {
1745
1753
  const gatewayStartedAt = performance.now();
1746
- const gatewayTraceId = trimToUndefined4(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
1754
+ const gatewayTraceId = trimToUndefined5(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
1747
1755
  const url = new URL(request.url);
1748
1756
  if (request.method === "OPTIONS" && (url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/"))) {
1749
1757
  return corsPreflight(request);
@@ -1804,12 +1812,12 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
1804
1812
  tracedResponse.headers.delete(TRACE_SAMPLED_RESPONSE_HEADER);
1805
1813
  const roomTiming = tracedResponse.headers.get("server-timing") ?? "";
1806
1814
  if (sampled) {
1807
- const gatewayTiming = [
1808
- `relay-gateway-route;dur=${roundedMs(routeFinishedAt - gatewayStartedAt)}`,
1809
- `relay-gateway-do-fetch;dur=${roundedMs(fetchFinishedAt - routeFinishedAt)}`,
1810
- `relay-gateway-response;dur=${roundedMs(gatewayFinishedAt - gatewayResponseStartedAt)}`,
1811
- `relay-gateway;dur=${roundedMs(gatewayFinishedAt - gatewayStartedAt)}`
1812
- ].join(", ");
1815
+ const gatewayTiming = serverTiming([
1816
+ { name: "relay-gateway-route", ms: routeFinishedAt - gatewayStartedAt },
1817
+ { name: "relay-gateway-do-fetch", ms: fetchFinishedAt - routeFinishedAt },
1818
+ { name: "relay-gateway-response", ms: gatewayFinishedAt - gatewayResponseStartedAt },
1819
+ { name: "relay-gateway", ms: gatewayFinishedAt - gatewayStartedAt }
1820
+ ]);
1813
1821
  tracedResponse.headers.set("server-timing", `${roomTiming}, ${gatewayTiming}`);
1814
1822
  }
1815
1823
  return url.pathname.startsWith("/workspaces/") ? withRelayResponseHeaders(request, tracedResponse) : tracedResponse;
@@ -1845,7 +1853,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
1845
1853
  };
1846
1854
  const closeSocket = (socket, code, reason) => {
1847
1855
  try {
1848
- socket.close?.(code, reason);
1856
+ socket.close?.(code, closeReason(reason));
1849
1857
  } catch {
1850
1858
  }
1851
1859
  };
@@ -2446,7 +2454,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2446
2454
  if (!channel) return;
2447
2455
  tunnel.channels.delete(message.channel_id);
2448
2456
  clients.delete(channel);
2449
- closeSocket(channel.socket, safeCloseCode(message.code), message.reason ?? "");
2457
+ closeSocket(channel.socket, closeCode(message.code), message.reason ?? "");
2450
2458
  return;
2451
2459
  }
2452
2460
  if (parsed.message.type === "error" && parsed.message.channel_id) {
@@ -2484,7 +2492,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2484
2492
  type: "ws.close",
2485
2493
  protocol: TUNNEL_PROTOCOL_VERSION,
2486
2494
  channel_id: channelId,
2487
- code: safeCloseCode(event?.code, 1e3),
2495
+ code: closeCode(event?.code, 1e3),
2488
2496
  reason: event?.reason ?? "Client WebSocket closed"
2489
2497
  }));
2490
2498
  return;
@@ -2492,7 +2500,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2492
2500
  };
2493
2501
  const admitHostTunnel = async (request, roomWorkspaceId, url) => {
2494
2502
  if (!websocketRequest(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
2495
- const hostId = trimToUndefined4(hostIdFromTunnelPath(url.pathname));
2503
+ const hostId = trimToUndefined5(hostIdFromTunnelPath(url.pathname));
2496
2504
  if (!hostId) return json("host_tunnel_host_required", "Host tunnel id is required", 400);
2497
2505
  const workspaceIds = workspaceIdsFromSearch(url);
2498
2506
  if (!workspaceIds.includes(roomWorkspaceId)) {
@@ -2594,12 +2602,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2594
2602
  const emitTrace = (openedAt) => {
2595
2603
  if (!trace || trace.emitted) return;
2596
2604
  trace.emitted = true;
2597
- sendSocket(pair.server, JSON.stringify({
2598
- type: "relay.trace",
2599
- wsUpstreamOpenMs: roundedMs(openedAt - trace.upstreamStartedAt),
2600
- queuedFrames: trace.queueTimes.length,
2601
- maxQueuedDelayMs: roundedMs(trace.queueTimes.reduce((max, at) => Math.max(max, openedAt - at), 0))
2602
- }));
2605
+ sendSocket(pair.server, JSON.stringify(webSocketTraceFrame(trace.upstreamStartedAt, trace.queueTimes, openedAt)));
2603
2606
  };
2604
2607
  const activeTokenTimer = watchRuntimeAccessToken(client, (reason) => {
2605
2608
  closeSocket(pair.server, 1008, reason);
@@ -2609,14 +2612,14 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2609
2612
  closed = true;
2610
2613
  if (activeTokenTimer) clearInterval(activeTokenTimer);
2611
2614
  clients.delete(client);
2612
- closeSocket(upstream, safeCloseCode(event?.code, 1e3), event?.reason ?? "Client WebSocket closed");
2615
+ closeSocket(upstream, closeCode(event?.code, 1e3), event?.reason ?? "Client WebSocket closed");
2613
2616
  };
2614
2617
  const cleanupUpstream = (event) => {
2615
2618
  if (closed) return;
2616
2619
  closed = true;
2617
2620
  if (activeTokenTimer) clearInterval(activeTokenTimer);
2618
2621
  clients.delete(client);
2619
- closeSocket(pair.server, safeCloseCode(event?.code), event?.reason ?? "");
2622
+ closeSocket(pair.server, closeCode(event?.code), event?.reason ?? "");
2620
2623
  };
2621
2624
  const flushQueue = () => {
2622
2625
  upstreamOpen = true;
@@ -2896,6 +2899,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
2896
2899
  },
2897
2900
  webSocketClose(socket, code, reason) {
2898
2901
  ensureHibernatedSocketsRebuilt();
2902
+ closeSocket(socket, closeCode(code, 1e3), reason);
2899
2903
  const attachment = socketAttachment(socket);
2900
2904
  if (!attachment) return;
2901
2905
  if (attachment.kind === "host-tunnel") {
@@ -0,0 +1,54 @@
1
+ export declare const TRACE_ID_HEADER = "x-claxedo-trace-id";
2
+ /**
3
+ * Set by a room on a sampled trace's response and consumed by the gateway: the
4
+ * gateway appends its own phases only behind this marker, never behind an
5
+ * upstream's `server-timing`, which a session host answers on every response.
6
+ */
7
+ export declare const TRACE_SAMPLED_RESPONSE_HEADER = "x-claxedo-relay-sampled";
8
+ export type WorkspaceRelayTraceLogEvent = {
9
+ event: "workspace_relay_request";
10
+ traceId: string;
11
+ workspaceId?: string;
12
+ routeKind: string;
13
+ method: string;
14
+ path: string;
15
+ status: number;
16
+ totalMs: number;
17
+ phases: Record<string, number>;
18
+ };
19
+ export type RelayTraceSampling = {
20
+ traceSampleRate?: number;
21
+ traceForceHeaderSecret?: string;
22
+ random?: () => number;
23
+ };
24
+ export type RelayTrace = {
25
+ id: string;
26
+ sampled: boolean;
27
+ startedAt: number;
28
+ routeKind: string;
29
+ phases: Array<{
30
+ name: string;
31
+ ms: number;
32
+ }>;
33
+ };
34
+ export declare function traceRequest(request: Request, routeKind: string, options: RelayTraceSampling): RelayTrace;
35
+ export declare function traceSpan<T>(trace: RelayTrace | undefined, name: string, fn: () => Promise<T>): Promise<T>;
36
+ export declare function tracedRequest(request: Request, trace: RelayTrace, forwarded?: {
37
+ url: string;
38
+ headers: Headers;
39
+ }): Request;
40
+ export declare function serverTiming(phases: ReadonlyArray<{
41
+ name: string;
42
+ ms: number;
43
+ }>): string;
44
+ /** The `relay.trace` frame a client that opted in with `x-claxedo-relay-ws-trace` receives once the upstream opens. */
45
+ export declare function webSocketTraceFrame(upstreamStartedAt: number, queueTimes: readonly number[], openedAt: number): {
46
+ type: string;
47
+ wsUpstreamOpenMs: number;
48
+ queuedFrames: number;
49
+ maxQueuedDelayMs: number;
50
+ };
51
+ export declare function withTraceHeaders(response: Response, trace: RelayTrace): Response;
52
+ export declare function logTrace(options: {
53
+ traceLog?: (event: WorkspaceRelayTraceLogEvent) => void;
54
+ }, request: Request, workspaceId: string | undefined, trace: RelayTrace, response: Response): void;
@@ -0,0 +1,2 @@
1
+ /** The relay's deployed `compatibility_date`, so a real-workerd test boots the relay as it ships. */
2
+ export declare function relayCompatibilityDate(config?: string): string;
package/package.json CHANGED
@@ -33,26 +33,26 @@
33
33
  "access": "public"
34
34
  },
35
35
  "name": "@claxedo/workspace-relay",
36
- "version": "0.10.0",
36
+ "version": "0.10.1",
37
37
  "scripts": {
38
38
  "build": "tsx scripts/build.ts",
39
39
  "prepublishOnly": "npm run build",
40
40
  "typecheck": "tsc --noEmit -p tsconfig.json",
41
- "test": "mkdir -p .artifacts/unit && node ../../script/test-deadline.mjs bun test src scripts --path-ignore-patterns='**/relay-workerd-*' --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml && node ../../script/test-deadline.mjs bun test src/relay-workerd-backpressure.test.ts src/relay-workerd-binary.test.ts src/relay-workerd-session-host.test.ts --timeout 240000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-workerd.xml"
41
+ "test": "mkdir -p .artifacts/unit && node ../../script/test-deadline.mjs bun test src scripts --path-ignore-patterns='**/relay-workerd-*' --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml && node ../../script/test-deadline.mjs bun test src/relay-workerd-backpressure.test.ts src/relay-workerd-binary.test.ts src/relay-workerd-close.test.ts src/relay-workerd-session-host.test.ts --timeout 240000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-workerd.xml"
42
42
  },
43
43
  "dependencies": {
44
- "@claxedo/helpers": "0.2.0",
45
- "@claxedo/workspace-relay-protocol": "0.10.0",
44
+ "@claxedo/helpers": "0.2.1",
45
+ "@claxedo/workspace-relay-protocol": "0.10.1",
46
46
  "jose": "6.0.11"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@tsconfig/node-lts": "22.0.4",
50
50
  "@types/bun": "1.3.13",
51
51
  "esbuild": "0.25.12",
52
- "miniflare": "4.20260722.0",
52
+ "miniflare": "4.20260730.0",
53
53
  "tsx": "4.22.3",
54
54
  "typescript": "catalog:",
55
- "wrangler": "4.114.0",
55
+ "wrangler": "4.116.0",
56
56
  "@cloudflare/workers-types": "catalog:"
57
57
  }
58
58
  }