@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/README.md +63 -9
- package/dist/auth.d.ts +54 -3
- package/dist/bun.d.ts +105 -19
- package/dist/bun.mjs +2492 -0
- package/dist/cloudflare.d.ts +111 -0
- package/dist/directory.d.ts +17 -6
- package/dist/http.d.ts +2 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.mjs +957 -1517
- package/dist/main.d.ts +72 -43
- package/dist/main.mjs +20454 -39451
- package/dist/server.d.ts +139 -17
- package/dist/upstream-websocket.d.ts +34 -0
- package/dist/user-hosted-forwarding.d.ts +40 -0
- package/dist/worker.d.ts +42 -2
- package/dist/workerd-fixture/binary-frame-worker.d.ts +33 -0
- package/dist/workerd-fixture/boot.d.ts +18 -0
- package/package.json +24 -9
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
|
-
|
|
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
|
-
*
|
|
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 (
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
-
"
|
|
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/
|
|
50
|
-
"@
|
|
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
|
-
"
|
|
74
|
+
"wrangler": "4.114.0",
|
|
75
|
+
"@cloudflare/workers-types": "catalog:"
|
|
61
76
|
}
|
|
62
77
|
}
|