@claxedo/workspace-relay 0.6.0 → 0.8.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/server.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import type { RuntimeAccessVerifierClaims, TokenVerifier } from "@claxedo/workspace-relay-protocol";
3
- import { type RelayClaimPair, type RelayJwtAlgorithm, type RelayKey, type RelayRole, type RuntimeAccessTokenClaims } from "./auth";
3
+ import { type HostTunnelTokenClaims, type RelayClaimPair, type RelayJwtAlgorithm, type RelayKey, type RelayRole, type RuntimeAccessTokenClaims } from "./auth";
4
4
  import type { WorkspaceRelayDirectory } from "./directory";
5
5
  export type RelayHostPublicKey = {
6
6
  publicKey: CryptoKey | Uint8Array;
@@ -19,6 +19,49 @@ export type RuntimeAccessTokenActiveResult = {
19
19
  code: string;
20
20
  reason: string;
21
21
  };
22
+ /**
23
+ * Both resolver clients — Bun (`main.ts`) and Worker (`worker.ts`) — read the
24
+ * control plane's JSON with these two parsers rather than asserting a type onto
25
+ * `Response.json()`. A half-built target used to travel all the way into
26
+ * routing and fail there as a confusing upstream error; it is now rejected at
27
+ * the one boundary that knows what the payload was supposed to be.
28
+ */
29
+ export declare function parseWorkspaceRelayTarget(input: unknown): WorkspaceRelayTarget | undefined;
30
+ export declare function parseRuntimeAccessTokenActiveResult(input: unknown): RuntimeAccessTokenActiveResult | undefined;
31
+ /**
32
+ * `GET /internal/relay/host-generation?enrollmentId=` as the control plane
33
+ * answers it: the enrollment's current serving generation, or 404 for an
34
+ * enrollment it does not know (`undefined` here).
35
+ */
36
+ export type HostGenerationResult = {
37
+ enrollmentId: string;
38
+ generation: number;
39
+ revoked: boolean;
40
+ };
41
+ export declare function parseHostGenerationResult(input: unknown): HostGenerationResult | undefined;
42
+ export type HostGenerationResolverLookupOptions = {
43
+ headers: Record<string, string>;
44
+ fetch?: (url: URL, init: RequestInit) => Promise<Response>;
45
+ /** Deadline per lookup; past it the lookup rejects, which the relay grades as unavailable. */
46
+ timeoutMs?: number;
47
+ };
48
+ /**
49
+ * The HTTP lookup behind `resolveHostGeneration`, shared by the Bun process and
50
+ * the Worker. Only the control plane's own "enrollment not found" 404 resolves
51
+ * `undefined`; a bare 404 is a control plane without the route, and like every
52
+ * other non-ok status, a malformed body, or a hit deadline it throws so the
53
+ * relay refuses fenced tokens with a retryable 503 instead of grading them as
54
+ * unknown enrollments.
55
+ */
56
+ export declare function createHostGenerationResolverLookup(url: string, options: HostGenerationResolverLookupOptions): HostGenerationLookup;
57
+ /**
58
+ * The fence between two host tunnels for one identity. An incumbent that
59
+ * carries a generation is displaced only by a candidate at the same or a
60
+ * higher generation — never by a lower one, and never by a token minted
61
+ * without a generation. An incumbent without a generation is displaced by
62
+ * any candidate, which is the pre-fence "newest wins" order.
63
+ */
64
+ export declare function hostTunnelIncumbentOutranks(incumbentGeneration: number | undefined, candidateGeneration: number | undefined): boolean;
22
65
  export type RevocationLookupArgs = {
23
66
  jti: string;
24
67
  workspaceId: string;
@@ -31,6 +74,24 @@ export type CachedRevocationOptions = {
31
74
  /** Clock injection for tests. Defaults to `Date.now`. */
32
75
  now?: () => number;
33
76
  };
77
+ /**
78
+ * `generation` is the value the caller holds (a token's, a socket's). The
79
+ * cached client uses it to decide whether a cached answer may stand in for a
80
+ * fresh one; an uncached lookup ignores it. Resolves `undefined` for an
81
+ * unknown enrollment and THROWS when the control plane cannot be reached —
82
+ * the throw is the "unavailable" signal every consumer grades separately.
83
+ */
84
+ export type HostGenerationLookupArgs = {
85
+ enrollmentId: string;
86
+ generation: number;
87
+ };
88
+ export type HostGenerationLookup = (args: HostGenerationLookupArgs) => Promise<HostGenerationResult | undefined>;
89
+ export type CachedHostGenerationOptions = {
90
+ /** TTL in milliseconds for cached host-generation answers. Defaults to 10_000. */
91
+ ttlMs?: number;
92
+ /** Clock injection for tests. Defaults to `Date.now`. */
93
+ now?: () => number;
94
+ };
34
95
  export type TargetLookupArgs = {
35
96
  workspaceId: string;
36
97
  hostId: string;
@@ -43,17 +104,19 @@ export type CachedTargetOptions = {
43
104
  now?: () => number;
44
105
  };
45
106
  export type WorkspaceRelayAuditEvent = {
46
- action: "relay.request.accepted" | "relay.request.denied" | "relay.request.suppressed_summary" | "host_tunnel.connected" | "host_tunnel.disconnected";
107
+ action: "relay.request.accepted" | "relay.request.denied" | "relay.request.suppressed_summary" | "host_tunnel.connected" | "host_tunnel.disconnected" | "host_tunnel.denied";
47
108
  result: "allow" | "deny";
48
109
  reason?: string;
49
- subject?: string;
110
+ actorId?: string;
111
+ actorKind?: "human" | "agent";
112
+ principalKind?: "user" | "service";
50
113
  role?: RelayRole;
51
114
  workspaceId?: string;
52
115
  hostId?: string;
53
116
  method: string;
54
117
  path: string;
55
118
  /**
56
- * T16: only set on `relay.request.suppressed_summary` events. Counts the
119
+ * Only set on `relay.request.suppressed_summary` events. Counts the
57
120
  * number of `relay.request.accepted` audit events that were dropped by the
58
121
  * sampler in the previous flush interval.
59
122
  */
@@ -61,6 +124,13 @@ export type WorkspaceRelayAuditEvent = {
61
124
  };
62
125
  export type WorkspaceRelayAuthOptions = {
63
126
  runtimeAccessKey: RelayKey;
127
+ /**
128
+ * Serving-generation fence for host tunnels. Unset on desktop and
129
+ * self-hosted relays, where nothing asks the control plane: tokens without
130
+ * a generation are admitted newest-wins, tokens with one are refused
131
+ * `host_generation_unverifiable`.
132
+ */
133
+ resolveHostGeneration?: HostGenerationLookup;
64
134
  /**
65
135
  * Cache signature/introspection-verified Runtime Access Token claims by the
66
136
  * full token string. Revocation/active checks, role enforcement, and target
@@ -79,7 +149,7 @@ export type WorkspaceRelayAuthOptions = {
79
149
  * raw JWT codepath.
80
150
  *
81
151
  * This is the seam downstream users plug their own auth backend
82
- * through (Clerk-issued tokens, OIDC introspection, custom keys, etc.).
152
+ * through (issuer-signed tokens, OIDC introspection, custom keys, etc.).
83
153
  * See `@claxedo/workspace-relay-protocol/token-verifier` for the
84
154
  * `HttpTokenVerifier` and `StaticTokenVerifier` reference impls.
85
155
  */
@@ -121,7 +191,7 @@ export type WorkspaceRelayRoutingOptions = {
121
191
  };
122
192
  export type WorkspaceRelayDrainOptions = {
123
193
  /**
124
- * T9: when this returns true the relay reports unhealthy via `/health` and
194
+ * When this returns true the relay reports unhealthy via `/health` and
125
195
  * fast-paths every workspace request to a 503 (`relay_draining`) before
126
196
  * doing any auth or upstream work. Wired by `createWorkspaceRelayBun` to
127
197
  * the drain controller it exposes.
@@ -131,7 +201,7 @@ export type WorkspaceRelayDrainOptions = {
131
201
  export type WorkspaceRelayTelemetryOptions = {
132
202
  audit?: (event: WorkspaceRelayAuditEvent) => void | Promise<void>;
133
203
  /**
134
- * T16: probability (0.0 – 1.0) at which `relay.request.accepted` audit
204
+ * Probability (0.0 – 1.0) at which `relay.request.accepted` audit
135
205
  * events are emitted to the user `audit` callback. Denies and tunnel
136
206
  * lifecycle events are never sampled. When unset, defaults to 1.0
137
207
  * (emit every accept). Suppressed accepts are aggregated into a periodic
@@ -139,21 +209,21 @@ export type WorkspaceRelayTelemetryOptions = {
139
209
  */
140
210
  auditAcceptSampleRate?: number;
141
211
  /**
142
- * T16: how often (ms) the sampler emits a `relay.request.suppressed_summary`
212
+ * How often (ms) the sampler emits a `relay.request.suppressed_summary`
143
213
  * audit event with the count of suppressed accepts in the past interval.
144
214
  * Defaults to 60_000 (one minute). When sample rate is 1.0 no interval is
145
215
  * armed (nothing to flush). Exposed for tests.
146
216
  */
147
217
  auditFlushIntervalMs?: number;
148
218
  /**
149
- * T16: random source for the sampler. Defaults to `Math.random`. Override
219
+ * Random source for the sampler. Defaults to `Math.random`. Override
150
220
  * for deterministic tests.
151
221
  */
152
222
  random?: () => number;
153
223
  };
154
224
  export type WorkspaceRelayMetricsOptions = {
155
225
  /**
156
- * T31: bearer token required to access `GET /metrics` from non-loopback
226
+ * Bearer token required to access `GET /metrics` from non-loopback
157
227
  * remotes. When unset, the endpoint allows loopback callers only (using
158
228
  * `metricsRemoteAddress` to identify the caller). When set, every request
159
229
  * must present `Authorization: Bearer <metricsToken>` regardless of origin.
@@ -161,7 +231,7 @@ export type WorkspaceRelayMetricsOptions = {
161
231
  */
162
232
  metricsToken?: string;
163
233
  /**
164
- * T31: returns the remote IP of the inbound `/metrics` request (used to
234
+ * Returns the remote IP of the inbound `/metrics` request (used to
165
235
  * gate access to the loopback when `metricsToken` is unset). When this is
166
236
  * also unset, `/metrics` fails closed. The Bun adapter wires this resolver
167
237
  * through `Bun.Server.requestIP`; other callers should set either this or
@@ -169,7 +239,7 @@ export type WorkspaceRelayMetricsOptions = {
169
239
  */
170
240
  metricsRemoteAddress?: (request: Request) => string | undefined;
171
241
  /**
172
- * T31: optional providers used to assemble the `/metrics` response body. The
242
+ * Optional providers used to assemble the `/metrics` response body. The
173
243
  * fragmentation/slow-consumer counters live in the bun adapter's per-handler
174
244
  * telemetry, and the in-flight pending count lives on the drain controller.
175
245
  * `createWorkspaceRelayBun` wires these to make ops counters visible on
@@ -191,7 +261,7 @@ export type WorkspaceRelayMetricsSources = {
191
261
  drainPending?: () => number;
192
262
  };
193
263
  /**
194
- * T31: shape of the JSON body returned by `GET /metrics`. Exported so the
264
+ * Shape of the JSON body returned by `GET /metrics`. Exported so the
195
265
  * bun adapter and ops dashboards can type-narrow against it.
196
266
  */
197
267
  export type WorkspaceRelayMetrics = {
@@ -236,6 +306,41 @@ export type WorkspaceRelayTrace = WorkspaceRelayAuthorizeTrace & {
236
306
  * lag is bounded by the same TTL by design. Concurrent misses share one lookup.
237
307
  */
238
308
  export declare function createCachedRevocationClient(inner: RevocationLookup, options?: CachedRevocationOptions): RevocationLookup;
309
+ /**
310
+ * Caches host-generation answers by enrollment id with the fence's own rules:
311
+ * a cached answer stands only while it is at least the caller's generation
312
+ * (equal admits; higher is a refusal that needs no fresh read), and a caller
313
+ * holding a HIGHER generation than the cache — or than the answer an in-flight
314
+ * lookup settles on — forces a refresh so a fresh `acquire` is never refused
315
+ * on a stale answer. Unknown enrollments and failures are not cached;
316
+ * concurrent misses share one lookup.
317
+ */
318
+ export declare function createCachedHostGenerationClient(inner: HostGenerationLookup, options?: CachedHostGenerationOptions): HostGenerationLookup;
319
+ export type HostTunnelGenerationDecision = {
320
+ ok: true;
321
+ } | {
322
+ ok: false;
323
+ retryable: false;
324
+ code: "host_generation_superseded" | "host_generation_unknown" | "host_generation_unverifiable" | "host_enrollment_revoked" | "host_enrollment_unknown";
325
+ reason: string;
326
+ } | {
327
+ ok: false;
328
+ retryable: true;
329
+ code: "host_generation_lookup_unavailable";
330
+ reason: string;
331
+ };
332
+ /**
333
+ * The one admission/re-check verdict both relay adapters apply to a host
334
+ * tunnel, on connect and on every registration update. A token without a
335
+ * generation is always `ok` — that is the pre-fence behaviour desktop and
336
+ * self-hosted relays keep. A token WITH a generation asserts a fence, so a
337
+ * relay composed without a resolver refuses it rather than admit what it
338
+ * cannot verify; that refusal is not retryable because the resolver is a
339
+ * property of the composition, not of the moment. `retryable` otherwise
340
+ * separates "the control plane said no" (the host must not simply reconnect)
341
+ * from "the control plane could not be asked" (it should).
342
+ */
343
+ export declare function checkHostTunnelGeneration(lookup: HostGenerationLookup | undefined, claims: Pick<HostTunnelTokenClaims, "enrollment_id" | "generation">): Promise<HostTunnelGenerationDecision>;
239
344
  /**
240
345
  * Caches positive workspace target lookups by workspace+host. Missing targets
241
346
  * are only coalesced while in flight, not retained, so cold-start polling can
@@ -250,7 +355,7 @@ export type WorkspaceRelayForwardHeadersOptions = {
250
355
  * verbatim risks leaking sensitive session data to the host. Cloud-vm
251
356
  * workspaces sit behind a dedicated network boundary where session cookies
252
357
  * may legitimately be needed (e.g. workspace dashboards), so the default
253
- * is to NOT strip cookies. T26.
358
+ * is to not strip cookies.
254
359
  */
255
360
  userHosted?: boolean;
256
361
  upstreamHeaders?: Record<string, string>;
@@ -262,13 +367,30 @@ export declare function workspaceRelayTargetUrl(target: WorkspaceRelayTarget, pa
262
367
  export declare function workspaceRelayForwardHeaders(input: Headers, relayHostToken: string, workspaceId: string, options?: WorkspaceRelayForwardHeadersOptions): Headers;
263
368
  export declare function workspaceRelayForwardRequestInit(request: Request, relayHostToken: string, workspaceId: string, options?: WorkspaceRelayForwardRequestInitOptions): RequestInit;
264
369
  /**
265
- * T16: dispose the sampler's periodic flush timer for a given options object.
370
+ * Dispose the sampler's periodic flush timer for a given options object.
266
371
  * Tests call this in cleanup so the bun process can exit.
267
372
  */
268
373
  export declare function disposeAuditSampler(options: WorkspaceRelayOptions): void;
269
374
  export declare function createWorkspaceRelayTrace(): WorkspaceRelayTrace;
270
375
  export declare function workspaceRelayServerTiming(trace: WorkspaceRelayTrace): string;
271
376
  export declare function workspaceRelayTimingResponse(response: Response, trace: WorkspaceRelayTrace): Response;
377
+ /**
378
+ * Request headers a browser may send to the relay, for every adapter.
379
+ *
380
+ * ONE list, because there were three identical copies — `server.ts`,
381
+ * `cloudflare.ts` and `bun.ts` — and three copies of a list means the next
382
+ * header is added to two of them. A header missing from the copy that serves a
383
+ * given deployment does not degrade anything gracefully: the browser refuses to
384
+ * send the request at all, and the failure surfaces somewhere else entirely.
385
+ * The control plane's equivalent list is `BROWSER_ALLOWED_REQUEST_HEADERS` in
386
+ * `claxedo-server-core`; a header the app sends to BOTH must be in both.
387
+ *
388
+ * `Traceparent`/`Tracestate` are W3C Trace Context, carried from the browser
389
+ * through this relay to the laptop.
390
+ */
391
+ export declare const RELAY_ALLOWED_REQUEST_HEADER_LIST: readonly ["Accept", "Authorization", "Content-Type", "Last-Event-ID", "Traceparent", "Tracestate", "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"];
392
+ /** The same list as a header value, for the adapters that write it directly. */
393
+ export declare const RELAY_ALLOWED_REQUEST_HEADERS: string;
272
394
  export declare function authorizeWorkspaceRelayRequest(options: WorkspaceRelayOptions, request: Request, workspaceId: string, trace?: WorkspaceRelayAuthorizeTrace): Promise<{
273
395
  ok: true;
274
396
  request: AuthorizedWorkspaceRelayRequest;
@@ -280,13 +402,13 @@ export declare function authorizeWorkspaceRelayRequest(options: WorkspaceRelayOp
280
402
  export declare function forwardWorkspaceRelayRequest(request: Request, target: WorkspaceRelayTarget, relayHostToken: string, path: string, requestFetch: typeof fetch, timeoutMs: number, trace?: WorkspaceRelayAuthorizeTrace, originAllowed?: (origin: string) => boolean): Promise<Response>;
281
403
  export type WorkspaceRelayApp = Hono & {
282
404
  /**
283
- * T16: clear the sampler's periodic flush timer. Tests must call this so
405
+ * Clear the sampler's periodic flush timer. Tests must call this so
284
406
  * `setInterval` doesn't keep the process alive after the test exits.
285
407
  */
286
408
  disposeAuditSampler(): void;
287
409
  };
288
410
  /**
289
- * T31: read the suppressed-accept counter for a given options object. Surfaced
411
+ * Read the suppressed-accept counter for a given options object. Surfaced
290
412
  * for the `/metrics` route handler. Returns 0 if no sampler state has been
291
413
  * created yet (no requests have been audit-sampled), which is the same value
292
414
  * an external observer would see.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The relay's upstream WebSocket constructor, and the one place that names it.
3
+ *
4
+ * A relay client socket is proxied to a workspace runtime over a second,
5
+ * outbound WebSocket that must carry the runtime's auth headers. Bun's global
6
+ * `WebSocket` really does accept `(url, { headers })` — but that shape is not
7
+ * expressible in this package's type program, for a reason that is worth
8
+ * writing down once rather than rediscovering at the call site:
9
+ *
10
+ * `bun-types` yields its own `WebSocket` declaration to `lib.dom` whenever
11
+ * `lib.dom` is loaded (`Bun.__internal.UseLibDomIfAvailable`), and `lib.dom`
12
+ * declares only the `(url, protocols)` overload — on a `declare var`, which
13
+ * cannot be augmented. So while `lib.dom` is in `tsconfig.json`, the real
14
+ * constructor is unreachable through the global's type.
15
+ *
16
+ * Dropping `lib.dom` (as `bench/tsconfig.json` does) fixes THIS and breaks more
17
+ * than it fixes: `bun-types`' `Request` constructor declares `string`,
18
+ * `RequestInit & { url }` and `Request` inputs and no `URL` input at all, which
19
+ * this package and its tests rely on.
20
+ *
21
+ * The assertion is therefore genuine — the type system cannot express the
22
+ * value — and it is confined to this module so that the lint override excusing
23
+ * it covers ~40 lines whose only job is this constructor, rather than blinding
24
+ * the 4,000-line `bun.ts` that consumes it.
25
+ */
26
+ export type UpstreamWebSocketConstructor = {
27
+ new (url: string, options: Bun.WebSocketOptions): WebSocket;
28
+ };
29
+ /**
30
+ * The constructor used to open the upstream socket: the caller's override when
31
+ * one is supplied (tests inject fakes to drive open timeouts, pre-open queue
32
+ * limits and abnormal closes), otherwise Bun's global `WebSocket`.
33
+ */
34
+ export declare function resolveUpstreamWebSocket(override?: UpstreamWebSocketConstructor): UpstreamWebSocketConstructor;
@@ -0,0 +1,40 @@
1
+ import type { RelayAccess } from "./auth";
2
+ /**
3
+ * The canonical "is this relay target user-hosted" rule.
4
+ *
5
+ * A user-hosted workspace runs on the user's own laptop (backing:
6
+ * "local-worktree"); a cloud workspace runs on a dedicated cloud VM
7
+ * (backing: "cloud-vm"). `RelayAccess` has exactly these two members, so
8
+ * `access === "user-hosted"` and `access !== "cloud"` are equivalent — but
9
+ * spelling the check either way at each call site invites drift if a third
10
+ * access kind is ever added. Every caller that needs to branch on this
11
+ * (choosing a forwarding path, tagging a socket, deciding whether to strip
12
+ * Cookie) should go through this one predicate instead.
13
+ */
14
+ export declare function isUserHostedTarget(target: {
15
+ access: RelayAccess;
16
+ }): boolean;
17
+ export type WorkspaceRelaySocketKind = "user-hosted-client" | "client";
18
+ /**
19
+ * The socket-kind tag applied when a relay client is admitted against
20
+ * `target`. A user-hosted target is multiplexed over the host tunnel
21
+ * (`"user-hosted-client"`); a cloud target gets a direct upstream socket
22
+ * (`"client"`).
23
+ */
24
+ export declare function socketKindFor(target: {
25
+ access: RelayAccess;
26
+ }): WorkspaceRelaySocketKind;
27
+ /**
28
+ * Privacy rule: never forward the browser's `Cookie` header into a
29
+ * user-hosted workspace. Its host process runs on the user's own laptop and
30
+ * may share a cookie jar with the browser (e.g. localhost dev tooling), so
31
+ * passing cookies through verbatim risks leaking session data to the host.
32
+ * Cloud-vm workspaces sit behind a dedicated network boundary where session
33
+ * cookies may legitimately be needed (e.g. workspace dashboards), so cloud
34
+ * targets pass `Cookie` through unchanged.
35
+ *
36
+ * Returns a new `Headers` instance; `headers` is not mutated.
37
+ */
38
+ export declare function forwardHeadersFor(target: {
39
+ access: RelayAccess;
40
+ }, headers: Headers): Headers;
package/dist/worker.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type WorkspaceRelayDurableObjectNamespace, type WorkspaceRelayDurableObjectRoomOptions, type WorkspaceRelayDurableObjectSocket } from "./cloudflare";
2
2
  import type { RuntimeAccessTokenActiveResult, WorkspaceRelayTarget } from "./server";
3
- export type WorkspaceRelayWorkerEnv = Record<string, unknown> & {
3
+ import { type HostGenerationLookup } from "./server";
4
+ type WorkspaceRelayWorkerBindings = {
4
5
  WORKSPACE_RELAY_ROOM?: WorkspaceRelayDurableObjectNamespace;
5
6
  CLAXEDO_RELAY_RESOLVER_URL?: string;
6
7
  CLAXEDO_CENTRAL_URL?: string;
@@ -18,9 +19,17 @@ export type WorkspaceRelayWorkerEnv = Record<string, unknown> & {
18
19
  CLAXEDO_RELAY_TRACE_FORCE_SECRET?: string;
19
20
  CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS?: string;
20
21
  CLAXEDO_RELAY_TARGET_CACHE_TTL_MS?: string;
22
+ /**
23
+ * Absolute URL of the control plane's host-generation lookup. Unset derives
24
+ * `<resolver base>/host-generation` from the same base `/target` and
25
+ * `/revocation` are derived from.
26
+ */
27
+ CLAXEDO_RELAY_HOST_GENERATION_URL?: string;
28
+ CLAXEDO_RELAY_HOST_GENERATION_CACHE_TTL_MS?: string;
21
29
  CLAXEDO_APP_ORIGINS?: string;
22
30
  CLAXEDO_RELAY_ALLOWED_ORIGINS?: string;
23
31
  };
32
+ export type WorkspaceRelayWorkerEnv = Record<string, unknown> & WorkspaceRelayWorkerBindings;
24
33
  type ResolverClient = {
25
34
  target(workspaceId: string, hostId: string): Promise<WorkspaceRelayTarget | undefined>;
26
35
  revocation(args: {
@@ -28,23 +37,54 @@ type ResolverClient = {
28
37
  workspaceId: string;
29
38
  hostId: string;
30
39
  }): Promise<RuntimeAccessTokenActiveResult>;
40
+ hostGeneration: HostGenerationLookup;
31
41
  };
32
42
  type ResolverFetch = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
33
43
  export declare function workspaceRelayWorkerResolverUrl(env: WorkspaceRelayWorkerEnv): string;
34
44
  export declare function workspaceRelayWorkerResolverClient(env: WorkspaceRelayWorkerEnv, fetcher?: ResolverFetch): ResolverClient;
35
45
  export declare function workspaceRelayDurableObjectOptions(env: WorkspaceRelayWorkerEnv): Promise<WorkspaceRelayDurableObjectRoomOptions>;
46
+ /**
47
+ * The `DurableObjectState` workerd hands a room, modelled structurally like the
48
+ * rest of this package's Cloudflare surface (see `./cloudflare`) — this package
49
+ * deliberately carries no `@cloudflare/workers-types` dependency.
50
+ *
51
+ * Everything is optional because `cloudflare.ts` treats hibernation and alarms
52
+ * as capabilities to DETECT: an older runtime, or a harness standing in for
53
+ * one, may provide neither, and the room degrades rather than failing.
54
+ */
55
+ export type WorkspaceRelayRoomState = {
56
+ acceptWebSocket?: (socket: WorkspaceRelayDurableObjectSocket) => void;
57
+ getWebSockets?: () => WorkspaceRelayDurableObjectSocket[];
58
+ storage?: {
59
+ getAlarm?: () => Promise<number | null>;
60
+ setAlarm?: (scheduledTime: number) => Promise<void>;
61
+ deleteAlarm?: () => Promise<void>;
62
+ };
63
+ };
36
64
  export declare class WorkspaceRelayRoom {
37
65
  private state;
38
66
  private env;
39
67
  private room?;
40
68
  private loading?;
41
- constructor(state: unknown, env: WorkspaceRelayWorkerEnv);
69
+ constructor(state: WorkspaceRelayRoomState, env: WorkspaceRelayWorkerEnv);
42
70
  private hibernation;
71
+ /**
72
+ * The DO alarm surface, used for the hibernation-safe revocation re-check.
73
+ * Alarms are the only periodic mechanism that survives hibernation, so this is
74
+ * what enforces revocation on an idle hibernated connection.
75
+ */
76
+ private alarms;
43
77
  private loadRoom;
44
78
  fetch(request: Request): Promise<Response>;
45
79
  webSocketMessage(socket: WorkspaceRelayDurableObjectSocket, message: string | ArrayBuffer): Promise<void>;
46
80
  webSocketClose(socket: WorkspaceRelayDurableObjectSocket, code: number, reason: string, wasClean: boolean): Promise<void>;
47
81
  webSocketError(socket: WorkspaceRelayDurableObjectSocket, error: unknown): Promise<void>;
82
+ /**
83
+ * Forwarded so the room's hibernation-safe revocation sweep actually runs. The
84
+ * DO has ONE alarm slot; the room only ever moves it earlier and never clears
85
+ * it, so adding another alarm user here stays safe.
86
+ */
87
+ alarm(): Promise<void>;
48
88
  }
49
89
  declare const _default: {
50
90
  fetch(request: Request, env: WorkspaceRelayWorkerEnv): Promise<Response>;
@@ -0,0 +1,33 @@
1
+ type FixtureSocket = {
2
+ accept: () => void;
3
+ send: (message: string | ArrayBuffer | Uint8Array) => void;
4
+ close: (code?: number, reason?: string) => void;
5
+ binaryType?: string;
6
+ addEventListener: (type: "message" | "close" | "error", listener: (event: {
7
+ data?: unknown;
8
+ }) => void) => void;
9
+ };
10
+ type FixtureState = {
11
+ acceptWebSocket: (socket: FixtureSocket) => void;
12
+ };
13
+ type FixtureNamespace = {
14
+ idFromName: (name: string) => unknown;
15
+ get: (id: unknown) => {
16
+ fetch: (request: Request) => Promise<Response>;
17
+ };
18
+ };
19
+ export declare class BinaryFrameRoom {
20
+ private state;
21
+ constructor(state: FixtureState);
22
+ fetch(request: Request): Promise<Response>;
23
+ /** Hibernation delivery path — survives eviction, so no listener exists. */
24
+ webSocketMessage(socket: FixtureSocket, message: unknown): Promise<void>;
25
+ webSocketClose(): Promise<void>;
26
+ private report;
27
+ }
28
+ declare const _default: {
29
+ fetch(request: Request, env: {
30
+ ROOM: FixtureNamespace;
31
+ }): Promise<Response>;
32
+ };
33
+ export default _default;
@@ -0,0 +1,18 @@
1
+ import { Miniflare, type MiniflareOptions } from "miniflare";
2
+ /**
3
+ * Kill this process's workerd children outright. The `afterAll` replacement
4
+ * for `Miniflare#dispose()`, which must never be called (see header). Only
5
+ * direct children of this process whose command line names workerd can match,
6
+ * so nothing outside this test process is touched — and because `bun test`
7
+ * runs files sequentially, the only children alive when a file's `afterAll`
8
+ * runs are ones whose tests have already finished.
9
+ */
10
+ export declare function reapWorkerd(): void;
11
+ /**
12
+ * Boot workerd once and return the ready Miniflare. Call it exactly once per
13
+ * test file, from `beforeAll`, with a `workers` array hosting every
14
+ * configuration the file tests. Do NOT dispose the result — not in `afterAll`,
15
+ * not anywhere: disposal wedges every later boot in the process (see header).
16
+ * Tear down with `reapWorkerd()` in `afterAll` instead.
17
+ */
18
+ export declare function bootWorkerd(options: MiniflareOptions): Promise<Miniflare>;
package/package.json CHANGED
@@ -1,6 +1,4 @@
1
1
  {
2
- "name": "@claxedo/workspace-relay",
3
- "version": "0.6.0",
4
2
  "type": "module",
5
3
  "description": "Relay/tunnel server that routes authenticated traffic to workspace runtimes: JWT workspace tokens, header-stripping trust boundary, Bun and Cloudflare adapters",
6
4
  "license": "MIT",
@@ -19,11 +17,18 @@
19
17
  "types": "./dist/index.d.ts",
20
18
  "exports": {
21
19
  ".": {
20
+ "development": "./src/index.ts",
21
+ "bun": "./src/index.ts",
22
22
  "types": "./dist/index.d.ts",
23
- "bun": "./dist/index.mjs",
24
- "development": "./dist/index.mjs",
25
23
  "import": "./dist/index.mjs",
26
24
  "default": "./dist/index.mjs"
25
+ },
26
+ "./bun": {
27
+ "development": "./src/bun.ts",
28
+ "bun": "./src/bun.ts",
29
+ "types": "./dist/bun.d.ts",
30
+ "import": "./dist/bun.mjs",
31
+ "default": "./dist/bun.mjs"
27
32
  }
28
33
  },
29
34
  "bin": {
@@ -37,26 +42,36 @@
37
42
  "publishConfig": {
38
43
  "access": "public"
39
44
  },
45
+ "name": "@claxedo/workspace-relay",
46
+ "version": "0.8.0",
40
47
  "scripts": {
41
48
  "start": "bun run src/main.ts",
42
49
  "dev": "bun run --hot src/main.ts",
43
50
  "build": "tsx scripts/build.ts",
44
51
  "prepublishOnly": "npm run build",
45
52
  "typecheck": "tsc --noEmit -p tsconfig.json",
46
- "test": "bun test src"
53
+ "typecheck:bench": "tsc --noEmit -p bench/tsconfig.json",
54
+ "test": "mkdir -p .artifacts/unit && node ../../scripts/test-deadline.mjs bun test src bench --path-ignore-patterns='**/relay-workerd-*' --path-ignore-patterns='**/bun.test.ts' --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml && node ../../scripts/test-deadline.mjs bun test src/bun.test.ts --timeout 120000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-bun-adapter.xml && node ../../scripts/test-deadline.mjs bun test src/relay-workerd-backpressure.test.ts src/relay-workerd-binary.test.ts --timeout 240000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-workerd.xml",
55
+ "bench:gate": "bun bench/local-dry-run.ts",
56
+ "bench:gate:cf": "bun bench/cf-dev-smoke.ts"
47
57
  },
48
58
  "dependencies": {
49
- "@claxedo/workspace-relay-protocol": "0.6.0",
50
- "@sentry/bun": "10.64.0",
59
+ "@claxedo/helpers": "0.1.0",
60
+ "@claxedo/workspace-relay-protocol": "0.8.0",
51
61
  "hono": "4.10.7",
52
- "jose": "6.0.11"
62
+ "jose": "6.0.11",
63
+ "posthog-node": "4.18.0"
53
64
  },
54
65
  "devDependencies": {
66
+ "@claxedo/sandbox-manager": "0.9.0",
67
+ "@daytona/sdk": "0.192.0",
55
68
  "@tsconfig/node-lts": "22.0.4",
56
69
  "@types/bun": "1.3.13",
57
70
  "esbuild": "0.25.12",
71
+ "miniflare": "4.20260722.0",
58
72
  "tsx": "4.22.3",
59
73
  "typescript": "catalog:",
60
- "@daytona/sdk": "0.192.0"
74
+ "wrangler": "4.114.0",
75
+ "@cloudflare/workers-types": "catalog:"
61
76
  }
62
77
  }