@claxedo/workspace-relay 0.10.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,7 @@ signed identity to match the current ready lease; otherwise it returns 401
34
34
 
35
35
  Target lookups are keyed by workspace, host and routing identity. A resolved
36
36
  cloud or host-tunnel target is kept for `CLAXEDO_RELAY_TARGET_CACHE_TTL_MS`
37
- (10 s), the same lag the revocation cache allows; a workspace still starting is
37
+ (30 s), the same lag the revocation cache allows; a workspace still starting is
38
38
  asked about on every request. A `durable-object` target is kept for 30 minutes,
39
39
  the longest life of a Runtime Access Token, because the host id names the
40
40
  session's object and the object never moves; revocation is still asked about
@@ -52,10 +52,25 @@ relay implements it by calling the control plane or an equivalent
52
52
  authority on every new HTTP request and WebSocket upgrade. A false result
53
53
  rejects before forwarding to the host.
54
54
 
55
- Long-lived relayed sockets are authorized at establishment time. Already-open
56
- SSE, PTY, and WebSocket streams may live until normal close, reconnect, relay
57
- drain, host disconnect, or process restart. If immediate stream revocation is a
58
- requirement, the control plane must also close the session/runtime channel.
55
+ The answer is held for 30 s (`CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS`), which is
56
+ the bound on how late a revocation is seen: no byte is forwarded on an answer
57
+ older than that.
58
+
59
+ - **A request** is checked when it arrives.
60
+ - **A WebSocket frame**, in either direction, is checked as it passes. The socket
61
+ itself is swept every 30 s by the room's alarm and closed `1008` once revoked
62
+ or expired, so an idle socket can stay open that long while carrying nothing.
63
+ - **A response stream** (SSE) keeps its answer fresh while it is open
64
+ (`guardStreamStanding`): the room asks the authority again 2 s before the
65
+ held answer reaches the bound, and ends the stream when the token is revoked
66
+ or expires. Ending cancels the tunnel stream, which frees its slot and tells
67
+ the host to stop. This costs one lookup every 28 s for each
68
+ token with a stream open, shared by all of that token's streams and requests.
69
+
70
+ An authority that cannot be reached is given `resolverOutageGraceAttempts`
71
+ consecutive failures (3) before a stream or socket is closed over it, and a
72
+ failed re-read leaves a still-valid held answer in place; the token's `exp`
73
+ ends every path regardless.
59
74
 
60
75
  ### Host Tunnel Serving-Generation Fence
61
76
 
@@ -130,7 +145,8 @@ Re-exported from [`src/index.ts`](src/index.ts):
130
145
  | Concern | Module | Notable exports |
131
146
  | --- | --- | --- |
132
147
  | Token issuance / verification | [`src/auth.ts`](src/auth.ts) | `mintRuntimeAccessToken`, `verifyRuntimeAccessToken`, `mintRelayHostToken`, `verifyRelayHostToken`, `mintHostTunnelToken`, `verifyHostTunnelToken`, types `RelayRole`, `RelayBacking`, `RelayJwtAlgorithm`, `RuntimeAccessTokenClaims`, `RelayKey`, `RelayKeyResolver`, error class `WorkspaceRelayAuthError` |
133
- | Request authorization and forwarding | [`src/server.ts`](src/server.ts) | `authorizeWorkspaceRelayRequest`, `workspaceRelayForwardRequestInit`, `workspaceRelayTargetUrl`, the cached resolver clients, types `WorkspaceRelayOptions`, `WorkspaceRelayTarget`, `RuntimeAccessTokenActiveResult`, `WorkspaceRelayAuditEvent` |
148
+ | Request authorization and forwarding | [`src/server.ts`](src/server.ts) | `authorizeWorkspaceRelayRequest`, `workspaceRelayForwardRequestInit`, `workspaceRelayTargetUrl`, types `WorkspaceRelayOptions`, `WorkspaceRelayTarget`, `WorkspaceRelayAuditEvent` |
149
+ | Resolver clients | [`src/resolver-clients.ts`](src/resolver-clients.ts) | `createCachedRevocationClient`, `createCachedTargetClient`, `createCachedHostGenerationClient`, `runtimeAccessTokenRevocationDelayMs`, type `RuntimeAccessTokenActiveResult` |
134
150
  | CORS policy | [`src/cors-origins.ts`](src/cors-origins.ts) | `DEFAULT_RELAY_APP_ORIGINS`, `RELAY_ALLOWED_REQUEST_HEADERS`, `createOriginMatcher`, `parseAllowedOrigins` |
135
151
  | Active-host directory | [`src/directory.ts`](src/directory.ts) | `createWorkspaceRelayDirectory`, types `WorkspaceRelayDirectory`, `HostTunnelPresence` |
136
152
  | Cloudflare Worker gateway and room | [`src/cloudflare.ts`](src/cloudflare.ts) | `createWorkspaceRelayDurableObjectGateway`, `createWorkspaceRelayDurableObjectRoom` |
@@ -143,8 +159,14 @@ implement the tunnel protocol without pulling Jose.
143
159
 
144
160
  ## Configuration
145
161
 
146
- All knobs are environment variables: Worker vars and secrets in
147
- `wrangler.toml` and `scripts/deploy-cloudflare.ts`.
162
+ All knobs are environment variables. `wrangler.toml` declares what every
163
+ deployment shares; `scripts/deploy-cloudflare.ts` renders it with one
164
+ deployment's names (`CLAXEDO_RELAY_WORKER_NAME`, the session host
165
+ `CLAXEDO_SESSION_HOST_WORKER_NAME` that `SESSION_HOST` binds, an optional
166
+ `CLAXEDO_RELAY_TUNNEL_CHANNEL_CAP`, and the custom domain when
167
+ `CLAXEDO_RELAY_URL` is not on `workers.dev`), passes the deployment's vars
168
+ (`CLAXEDO_CENTRAL_URL`, `CLAXEDO_APP_ORIGINS`, `CLAXEDO_CONTROL_PLANE_JWKS_URL`)
169
+ and uploads its secrets.
148
170
 
149
171
  | Env var | Purpose |
150
172
  | --- | --- |
@@ -152,7 +174,8 @@ All knobs are environment variables: Worker vars and secrets in
152
174
  | `CLAXEDO_RELAY_RESOLVER_TOKEN` | Bearer token the relay sends to the resolver. The Worker refuses to start without it. |
153
175
  | `CLAXEDO_RELAY_HOST_GENERATION_URL` | Optional absolute URL of the host-generation lookup. Unset (the normal case) derives `<CLAXEDO_RELAY_RESOLVER_URL>/host-generation`; set it only when the lookup lives at a different origin than the rest of the resolver. There is no way to turn the fence off on a resolver-backed relay. |
154
176
  | `CLAXEDO_RELAY_HOST_GENERATION_CACHE_TTL_MS` | Cache TTL for host-generation answers. Defaults to 10000. A superseded tunnel closes within the re-check interval (30 s) plus this TTL. |
155
- | `CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS` | Cache TTL for `/revocation` answers (default 10000 ms). Target answers are not retained. |
177
+ | `CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS` | Cache TTL for `/revocation` answers (default 10000 ms). |
178
+ | `CLAXEDO_RELAY_TARGET_CACHE_TTL_MS` | Cache TTL for a resolved cloud or host-tunnel target (default 10000 ms). A workspace still starting is not cached. |
156
179
  | `CLAXEDO_CONTROL_PLANE_JWKS_URL` | Remote JWKS the relay uses to verify runtime-access and host-tunnel tokens. |
157
180
  | `CLAXEDO_RUNTIME_ACCESS_TOKEN_PUBLIC_KEY_PEM` | Inline public key (alternative to JWKS). |
158
181
  | `CLAXEDO_RELAY_HOST_SIGNING_KEY_PEM` | Private PEM the relay uses to mint relay-host tokens. The Worker publishes its public half at `/.well-known/jwks.json`. |
@@ -164,9 +187,9 @@ Cloudflare Worker tracing knobs (Durable Object deployment):
164
187
  | --- | --- |
165
188
  | `CLAXEDO_RELAY_TRACE_SAMPLE_RATE` | 0–1 sampling rate for request traces. Only sampled requests emit `Server-Timing` phase headers and structured trace logs; unsampled responses carry just the `x-claxedo-trace-id` correlation header. Defaults to 0 (off). |
166
189
  | `CLAXEDO_RELAY_TRACE_FORCE_SECRET` | Optional operator secret. When set, a request with `x-claxedo-relay-trace: <secret>` forces sampling for that request (targeted debugging). Unset (default) means the force header is ignored — clients can never force tracing or timing emission. |
167
- | `CLAXEDO_APP_ORIGINS` | Cloudflare-only, **additive** origin entries layered on top of the base allowlist (kept for existing deploys; prefer `CLAXEDO_RELAY_ALLOWED_ORIGINS` for full control). |
190
+ | `CLAXEDO_APP_ORIGINS` | **Additive** origin entries layered on top of the base allowlist; `CLAXEDO_RELAY_ALLOWED_ORIGINS` replaces the base list itself. |
168
191
 
169
- Token verification is pluggable today via the
192
+ Token verification is pluggable via the
170
193
  `RelayKey | RelayKeyResolver` pair on `verifyRuntimeAccessToken` and
171
194
  friends, **and** via the unified
172
195
  [`TokenVerifier`](../workspace-relay-protocol/src/token-verifier.ts)
@@ -200,10 +223,38 @@ the crypto authority and must enforce issuer, audience, expiry, key selection,
200
223
  and replay policy before returning claims. Treat the endpoint as trusted
201
224
  operator configuration, not as tenant/user input.
202
225
 
203
- Long-lived relayed sockets are authorized at establishment. Revocation is
204
- checked for new HTTP requests and WebSocket upgrades; already-established
205
- WebSocket/SSE/PTY streams may live until their normal close, reconnect, relay
206
- drain, or room restart.
226
+ ## Path And Room Placement
227
+
228
+ A request for a connected machine crosses these hops, and each one is there for a reason:
229
+
230
+ 1. **Edge.** Wherever the caller's connection lands. Cloudflare and the caller's ISP decide it; nothing here can.
231
+ 2. **App Worker**, on the app's origin, strips `/relay` and calls this Worker over a service binding in the same
232
+ data center. It exists so a browser makes one same-origin request with no preflight. Measured at 4 ms.
233
+ 3. **Gateway**, this Worker's stateless half, in that same data center. It names the workspace's room and passes the
234
+ request to it.
235
+ 4. **Room**, one Durable Object per workspace, named `relay-room:<workspaceId>`. It is the only place a browser's
236
+ connection and a machine's outbound tunnel can meet, because the machine cannot be dialed.
237
+ 5. **Tunnel**, the machine's one long-lived WebSocket to the room.
238
+
239
+ A room is created by the first request that names it, takes its location from that request's hint, and never
240
+ moves. `relayRoomPlacement` decides the hint:
241
+
242
+ - a session host's room goes beside its Durable Object (`enam`);
243
+ - a cloud runtime's room goes to the region the request declares, which is where its sandbox runs;
244
+ - a person's machine has no region. Its room goes by the edge country of whoever reaches it first with a verified
245
+ credential: the machine's own dial, or its owner's browser. A region anyone else declares for it is ignored. The
246
+ control plane sends the deployment's default region for every workspace, and a room placed by that default sat
247
+ 228 ms from a machine and owner who were both in India.
248
+
249
+ No Indian data center hosts Durable Objects; `apac` from India lands in Singapore. Measured from India on
250
+ 2026-11-10, a relayed GET costs about 270 ms with the room in Singapore and cost 765 ms with it in eastern North
251
+ America. The numbers and the rig are in `docs/perf/2026-11-10-relay-baseline.md`.
252
+
253
+ Renaming rooms moves them, at a price: a room the gateway no longer names still holds its machine's tunnel, and a
254
+ machine with a healthy tunnel does not dial again. Such a room closes its sockets on its next alarm
255
+ (`WorkspaceRelayRoom.alarm`), which took about two minutes on staging. Deploying a Worker that names rooms
256
+ differently from the one running, in either direction, strands tunnels for that long, or until the connector
257
+ restarts if the deployed code has no such release.
207
258
 
208
259
  ## Tunnel Lifecycle
209
260
 
@@ -243,14 +294,14 @@ the request and forwards it to the resolved cloud target or machine tunnel.
243
294
  | --- | --- | --- |
244
295
  | Layer | Control-plane auth | Tunnel transport |
245
296
  | Trust | Authenticates the relay process itself (loopback or bearer) | Authenticates the user's runtime-access token |
246
- | Routes | `GET /internal/relay/target`, `GET /internal/relay/revocation` | WS upgrade + tunnel framing |
297
+ | Routes | `GET /internal/relay/target`, `GET /internal/relay/revocation`, `GET /internal/relay/host-generation` | WS upgrade + tunnel framing |
247
298
  | Owners | `claxedo-server` (depends on the workspace authority, the identity provider, audit log) | `workspace-relay` (no authority or identity-provider dependency) |
248
299
  | Lives in | `packages/claxedo-server/` (server-side only) | `packages/workspace-relay/` (Worker and Durable Object) |
249
300
 
250
301
  This split is deliberate: the relay never reads the workspace authority or the
251
302
  identity provider directly. It calls back to `claxedo-server` over HTTP for resolver
252
303
  decisions. The decision data crosses the seam as
253
- `RuntimeAccessTokenActiveResult` (defined in `server.ts`).
304
+ `RuntimeAccessTokenActiveResult` (defined in `resolver-clients.ts`).
254
305
 
255
306
  ## Development
256
307
 
@@ -259,8 +310,6 @@ bun --cwd packages/workspace-relay test # unit suites, including the work
259
310
  bun --cwd packages/workspace-relay typecheck
260
311
  ```
261
312
 
262
- The TS error baseline for this package is **0** — keep it that way.
263
-
264
313
  ## Boundary rule
265
314
 
266
315
  Consumers outside this package import `@claxedo/workspace-relay` only —
package/dist/auth.d.ts CHANGED
@@ -186,25 +186,23 @@ export declare function deriveRelayHostPublicKey(privateKey: CryptoKey): Promise
186
186
  * WebCrypto rather than `node:crypto` so the workerd bundle can use it.
187
187
  */
188
188
  export declare function deriveRelayHostKid(publicKey: CryptoKey): Promise<string>;
189
- /**
190
- * Mint variants accept only a signing key — never a resolver. Resolvers are
191
- * verification-only. This narrowed alias keeps the mint signatures unchanged.
192
- */
193
189
  type RelaySigningKey = CryptoKey | Uint8Array;
194
190
  export declare function mintRuntimeAccessToken(input: RuntimeInput, key: RelaySigningKey, alg: RelayJwtAlgorithm): Promise<string>;
195
191
  export declare function verifyRuntimeAccessToken(token: string, key: RelayKey, expected: ExpectedTarget): Promise<RuntimeAccessTokenClaims>;
196
192
  export declare function validateRuntimeAccessTokenClaims(input: Record<string, unknown>, expected: ExpectedTarget): RuntimeAccessTokenClaims;
197
193
  export declare function mintHostTunnelToken(input: HostTunnelInput, key: RelaySigningKey, alg: RelayJwtAlgorithm): Promise<string>;
198
194
  export declare function verifyHostTunnelToken(token: string, key: RelayKey, expected: ExpectedHostTunnel): Promise<HostTunnelTokenClaims>;
199
- /**
200
- * Validates a claims result a custom `authorizeHostTunnel` policy returns.
201
- * The policy decides WHO the tunnel is; the binding is the relay's: the
202
- * claims' `host_id` must equal the requested host and its `workspace_ids`
203
- * must cover every requested workspace, so a permissive policy cannot widen
204
- * the identity its own claims assert.
205
- */
206
195
  /** What a Relay Host Token minted from a verified Runtime Access Token carries over from it. */
207
196
  export declare function relayHostInputFromRuntimeClaims(claims: RuntimeAccessTokenClaims): Omit<RelayHostInput, "backing" | "hostId" | "workspaceId">;
197
+ /**
198
+ * A Relay Host Token authenticates one HTTP request or WebSocket upgrade from
199
+ * the relay to the host, and the relay mints a fresh one for every request.
200
+ * Its 60 s life bounds replay while tolerating clock skew. A long-lived socket
201
+ * (PTY, SSE, event stream) outlives it: the token admits the connection, the
202
+ * host's own session bounds its life, and a reconnect mints a new token.
203
+ * Refreshing mid-stream would put the relay, and the control plane behind its
204
+ * resolver, in the streaming path for no security gain.
205
+ */
208
206
  export declare function mintRelayHostToken(input: RelayHostInput, key: RelaySigningKey, alg: RelayJwtAlgorithm): Promise<string>;
209
207
  export declare function verifyRelayHostToken(token: string, key: RelayKey, expected: ExpectedTarget): Promise<RelayHostTokenClaims>;
210
208
  export declare function tokenScopePayload(sessionId: string | undefined): {
@@ -214,11 +212,11 @@ export declare function tokenScopePayload(sessionId: string | undefined): {
214
212
  scope: "session";
215
213
  session_id: string;
216
214
  };
217
- /** The token's reach, or nothing when it names neither the workspace nor exactly one session. */
218
215
  /** The purpose claim, or undefined when it names a purpose this build does not know. */
219
216
  export declare function tokenPurposeClaims(payload: Record<string, unknown>): {
220
217
  purpose?: TokenPurpose;
221
218
  } | undefined;
219
+ /** The token's reach, or nothing when it names neither the workspace nor exactly one session. */
222
220
  export declare function tokenScopeClaims(payload: Record<string, unknown>): {
223
221
  scope: TokenScope;
224
222
  session_id?: string;
@@ -1,6 +1,8 @@
1
1
  import { type AuthorizedWorkspaceRelayRequest, type WorkspaceRelayOptions } from "./server";
2
+ import { type StreamStandingOptions } from "./stream-standing";
2
3
  import { type SessionHostNamespace } from "./session-host-forwarding";
3
- import { type WorkspaceRelayTraceLogEvent } from "./relay-trace";
4
+ import { type RelayLog } from "./relay-log";
5
+ import { type RelayTraceSampling, type WorkspaceRelayTraceLogEvent } from "./relay-trace";
4
6
  export type { WorkspaceRelayTraceLogEvent } from "./relay-trace";
5
7
  export type WorkspaceRelayDurableObjectId = unknown;
6
8
  export type WorkspaceRelayDurableObjectStub = {
@@ -41,7 +43,7 @@ export type WorkspaceRelayDurableObjectSocketPair = {
41
43
  export type WorkspaceRelayDurableObjectConnectWebSocket = (url: string, init: {
42
44
  headers: Record<string, string>;
43
45
  }) => WorkspaceRelayDurableObjectSocket | Promise<WorkspaceRelayDurableObjectSocket>;
44
- export type WorkspaceRelayDurableObjectRoomOptions = WorkspaceRelayOptions & {
46
+ export type WorkspaceRelayDurableObjectRoomOptions = WorkspaceRelayOptions & RelayTraceSampling & {
45
47
  sessionHost?: SessionHostNamespace;
46
48
  createWebSocketPair?: () => WorkspaceRelayDurableObjectSocketPair;
47
49
  connectWebSocket?: WorkspaceRelayDurableObjectConnectWebSocket;
@@ -60,7 +62,7 @@ export type WorkspaceRelayDurableObjectRoomOptions = WorkspaceRelayOptions & {
60
62
  * Defaults to 30s; 0 disables the re-check. Because the check may answer
61
63
  * from the revocation lookup's cache, a revoked token closes the socket
62
64
  * within this interval plus the lookup's cache TTL — see
63
- * `runtimeAccessTokenRevocationDelayMs` in `./server`. A resolver outage
65
+ * `runtimeAccessTokenRevocationDelayMs` in `./resolver-clients`. A resolver outage
64
66
  * gets `resolverOutageGraceAttempts` and never extends the socket past the
65
67
  * token's `exp`, which the watcher enforces locally.
66
68
  */
@@ -91,9 +93,12 @@ export type WorkspaceRelayDurableObjectRoomOptions = WorkspaceRelayOptions & {
91
93
  * non-hibernating path and in tests that do not exercise it.
92
94
  */
93
95
  alarms?: WorkspaceRelayDurableObjectAlarms;
94
- traceSampleRate?: number;
95
- traceForceHeaderSecret?: string;
96
+ /** How an open response stream's token is kept inside the revocation bound; absent, streams are not re-checked. */
97
+ streamStanding?: Pick<StreamStandingOptions, "standing" | "boundMs">;
98
+ /** Where a fault of the room's own code is reported: a frame handler or the revocation alarm throwing. */
99
+ report: (error: unknown, task: string) => void;
96
100
  traceLog?: (event: WorkspaceRelayTraceLogEvent) => void;
101
+ relayLog?: RelayLog;
97
102
  now?: () => number;
98
103
  };
99
104
  export type WorkspaceRelayDurableObjectHibernation = {
@@ -141,30 +146,6 @@ export type WorkspaceRelayDurableObjectDrainController = {
141
146
  remaining: number;
142
147
  }>;
143
148
  };
144
- /**
145
- * Exported for `relay-base64.test.ts`, which proves byte-equality against a
146
- * per-byte reference across the chunk boundaries. This sits on the path every
147
- * frame pays, so its equivalence is worth asserting directly rather than
148
- * inferring from a round-trip.
149
- */
150
- export declare function bytesToBase64(bytes: Uint8Array): string;
151
- /**
152
- * Runs the two frame decoders over one runtime-delivered `event.data` and
153
- * reports what they produced.
154
- *
155
- * Exists so `relay-workerd-binary.test.ts` can assert the decoders against what
156
- * REAL workerd delivers. The rest of the suite drives hand-rolled socket
157
- * doubles, which by construction hand us whatever we chose to hand ourselves —
158
- * so they cannot catch the hazard that binary `event.data` becomes a `Blob` at
159
- * `compatibility_date >= 2026-03-17`. Test-support surface, not a relay path.
160
- */
161
- export declare function relayFrameDiagnostics(data: unknown): Promise<{
162
- frameDropped: boolean;
163
- payloadDropped: boolean;
164
- binary?: boolean | undefined;
165
- dataBase64?: string | undefined;
166
- payloadBase64: string | undefined;
167
- }>;
168
149
  export declare function workspaceRelayDurableObjectRoomName(workspaceId: string): string;
169
150
  export declare function workspaceRelayDurableObjectWorkspaceId(request: Request): string | undefined;
170
151
  export declare function createWorkspaceRelayDurableObjectGateway(options?: WorkspaceRelayDurableObjectGatewayOptions): {
@@ -179,7 +160,7 @@ export declare function createWorkspaceRelayDurableObjectRoom(options: Workspace
179
160
  */
180
161
  alarm(): Promise<void>;
181
162
  webSocketClose(socket: WorkspaceRelayDurableObjectSocket, code: number, reason: string): void;
182
- webSocketError(socket: WorkspaceRelayDurableObjectSocket): void;
163
+ webSocketError(socket: WorkspaceRelayDurableObjectSocket, error?: unknown): void;
183
164
  state(): {
184
165
  hostTunnelCount: number;
185
166
  clientCount: number;
@@ -21,8 +21,6 @@ export declare function socketKindFor(target: {
21
21
  * risks leaking session data to it. A cloud-vm sits behind a dedicated network
22
22
  * boundary where a session cookie may legitimately be needed, so it passes
23
23
  * `Cookie` through unchanged.
24
- *
25
- * Returns a new `Headers` instance; `headers` is not mutated.
26
24
  */
27
25
  export declare function forwardHeadersFor(target: {
28
26
  backing: RelayBacking;
package/dist/http.d.ts CHANGED
@@ -6,7 +6,5 @@ export declare function errorBody(code: string, message: string): {
6
6
  message: string;
7
7
  };
8
8
  };
9
- /** The host a tunnel request names, `/host-tunnels/<hostId>`. */
10
9
  export declare function hostIdFromTunnelPath(pathname: string): string | undefined;
11
- /** The workspaces a tunnel request registers for, under either spelling of the query name. */
12
10
  export declare function workspaceIdsFromSearch(url: URL): string[];