@claxedo/workspace-relay 0.8.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.
@@ -0,0 +1,4 @@
1
+ import type { WorkspaceRelayAuditEvent, WorkspaceRelayOptions } from "./server";
2
+ /** Stops the flush timer for `options` and emits any suppressed count it still holds. */
3
+ export declare function disposeAuditSampler(options: WorkspaceRelayOptions): void;
4
+ export declare function audit(options: WorkspaceRelayOptions, event: WorkspaceRelayAuditEvent): Promise<void>;
@@ -0,0 +1,12 @@
1
+ export declare function setWorkspaceRelayAppOrigins(raw: string | undefined): void;
2
+ export declare function setWorkspaceRelayAllowedOrigins(raw: string | undefined): void;
3
+ /**
4
+ * The browser-origin gate on workspace WebSocket upgrades, applied after token
5
+ * authorization and before the upgrade is granted. A request carrying NO
6
+ * `Origin` header is a non-browser client — browsers always send Origin on
7
+ * WebSocket upgrades — so there is no origin verdict to enforce and it is
8
+ * admitted.
9
+ */
10
+ export declare function workspaceUpgradeOriginDenied(request: Request): Response | null;
11
+ export declare function corsPreflight(request: Request): Response;
12
+ export declare function withRelayResponseHeaders(request: Request, response: Response): Response;
@@ -0,0 +1,93 @@
1
+ export declare const DEFAULT_RELAY_LOCATION_HINT = "apac";
2
+ /**
3
+ * Where a session's Durable Object lives, and so where the relay room of a
4
+ * workspace whose sessions run on their objects is created.
5
+ *
6
+ * A Durable Object is created near whoever first reaches it and never moves,
7
+ * so without a hint a session opened from India can land in HKG, where
8
+ * api.anthropic.com answers every request 403 `forbidden` "Request not
9
+ * allowed" whatever the credential. Every turn the object calls its model
10
+ * vendor and the control plane (D1 in ENAM, the API Worker pinned to
11
+ * aws:us-east-1), so it lives beside them rather than beside its first viewer.
12
+ *
13
+ * The room sits on the path of every request to the object and asks the
14
+ * control plane (IAD) for the token's revocation and the target on a cold
15
+ * start and every 10 s: from apac each lookup is ~220 ms, from enam ~10 ms,
16
+ * while the browser's path through the room is the same ~115 ms either way
17
+ * (docs/perf/pi-durable-latency-2026-10-18/README.md). A machine's room stays
18
+ * beside its machine, where those lookups would cost a browser in apac
19
+ * +220 ms a request.
20
+ */
21
+ export declare const SESSION_HOST_LOCATION_HINT = "enam";
22
+ /**
23
+ * The complete set of Cloudflare Durable Object location hints.
24
+ *
25
+ * Cloudflare rejects an unknown hint, and a rejected `get()` fails the request —
26
+ * so an unrecognised region must fall back rather than be passed through. This
27
+ * list is the validator for that.
28
+ */
29
+ export declare const RELAY_LOCATION_HINTS: readonly ["wnam", "enam", "sam", "weur", "eeur", "apac", "oc", "afr", "me"];
30
+ export type RelayLocationHint = typeof RELAY_LOCATION_HINTS[number];
31
+ /** A workspace region carried on the request, if the caller supplied one. */
32
+ export declare const RELAY_REGION_HEADER = "x-claxedo-workspace-region";
33
+ /**
34
+ * Chooses the location hint for a workspace's Durable Object.
35
+ *
36
+ * This decision is PERMANENT: a DO's location is fixed at first creation and
37
+ * never migrates, so a workspace created with the wrong hint pays the extra
38
+ * ocean crossing on every frame for its entire life. That is why an
39
+ * unrecognised value falls back to the configured default instead of being
40
+ * passed through or guessed at.
41
+ *
42
+ * Precedence, most authoritative first:
43
+ * 1. the workspace's own region (header or `?region=`), which is what the
44
+ * control plane knows and the only signal tied to the WORKSPACE rather than
45
+ * to whoever happens to be connecting first;
46
+ * 2. the requesting user's Cloudflare edge country, a decent proxy when the
47
+ * workspace has no region yet;
48
+ * 3. the deployment-wide configured hint (`CLAXEDO_RELAY_LOCATION_HINT`), then
49
+ * `DEFAULT_RELAY_LOCATION_HINT`.
50
+ */
51
+ export declare function relayLocationHint(input: {
52
+ configured?: string;
53
+ region?: string;
54
+ country?: string;
55
+ }): string;
56
+ /** The workspace region a request declares, if any. */
57
+ export declare function relayRequestRegion(request: Request): string | undefined;
58
+ export type RelayRoomPlacement = {
59
+ locationHint: string;
60
+ /** The request's URL and headers with the placement inputs removed. */
61
+ url: string;
62
+ headers: Headers;
63
+ };
64
+ /**
65
+ * What the request's own credential says once its signature verifies for the
66
+ * room's workspace: the host it reaches. A `session-do:` host is the control
67
+ * plane's signed statement that the workspace's sessions run on their own
68
+ * Durable Objects.
69
+ */
70
+ export type RelayPlacementCredential = {
71
+ hostId: string;
72
+ };
73
+ /**
74
+ * Where a workspace's room is created, decided from what the request declares
75
+ * and what its credential proves, and the request as the room should see it.
76
+ * The declaration is consumed here because the room forwards a request's
77
+ * remaining query and headers to the runtime, whose routes have no use for the
78
+ * relay's placement input.
79
+ *
80
+ * A credential naming a session's Durable Object places the room beside that
81
+ * object (`SESSION_HOST_LOCATION_HINT`), whatever the request declares: the
82
+ * host id is signed by the control plane, so no query parameter or header can
83
+ * drag the room elsewhere, and a forged token verifies for no workspace.
84
+ * Request-declared inputs (region, edge country) move the hint off the
85
+ * configured floor only when the credential verifies, so anonymous first-touch
86
+ * traffic cannot pin a workspace's room placement.
87
+ */
88
+ export declare function relayRoomPlacement(request: Request, input: {
89
+ configured?: string;
90
+ credential: () => Promise<RelayPlacementCredential | undefined>;
91
+ }): Promise<RelayRoomPlacement>;
92
+ /** The Cloudflare edge country for the request, when running on Cloudflare. */
93
+ export declare function relayRequestCountry(request: Request): string | undefined;
@@ -0,0 +1,25 @@
1
+ import { type RelayBacking } from "./auth";
2
+ export type WorkspaceRelayTarget = {
3
+ workspaceId: string;
4
+ hostId: string;
5
+ baseUrl: string;
6
+ backing: RelayBacking;
7
+ };
8
+ export declare function parseWorkspaceRelayTarget(input: unknown): WorkspaceRelayTarget | undefined;
9
+ /**
10
+ * Whether a resolved target's `baseUrl` names a destination the relay may
11
+ * forward to.
12
+ *
13
+ * A `cloud-vm` target is fetched over the network with the Relay Host Token
14
+ * attached, so it must be HTTPS — or HTTP to a loopback host, the only
15
+ * plaintext transport that cannot put the token on a network. A
16
+ * `local-worktree` target is reached over the host tunnel and its `baseUrl` is
17
+ * never fetched — the control plane sends the empty string — so only that
18
+ * placeholder or a well-formed HTTP(S) URL is admitted. A `durable-object`
19
+ * target is reached through the relay's own Durable Object binding, so it
20
+ * names no destination at all.
21
+ */
22
+ export declare function isAllowedRelayTargetBaseUrl(target: {
23
+ baseUrl: string;
24
+ backing: RelayBacking;
25
+ }): boolean;
@@ -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,116 @@
1
+ import type { WorkspaceRelayTarget } from "./relay-target";
2
+ export type RuntimeAccessTokenActiveResult = {
3
+ active: true;
4
+ } | {
5
+ active: false;
6
+ code: string;
7
+ reason: string;
8
+ };
9
+ export declare function parseRuntimeAccessTokenActiveResult(input: unknown): RuntimeAccessTokenActiveResult | undefined;
10
+ /**
11
+ * `GET /internal/relay/host-generation?enrollmentId=` as the control plane
12
+ * answers it: the enrollment's current serving generation, or 404 for an
13
+ * enrollment it does not know (`undefined` here).
14
+ */
15
+ export type HostGenerationResult = {
16
+ enrollmentId: string;
17
+ generation: number;
18
+ revoked: boolean;
19
+ };
20
+ export type HostGenerationResolverLookupOptions = {
21
+ headers: Record<string, string>;
22
+ fetch?: (url: URL, init: RequestInit) => Promise<Response>;
23
+ /** Deadline per lookup; past it the lookup rejects, which the relay grades as unavailable. */
24
+ timeoutMs?: number;
25
+ };
26
+ export declare function createHostGenerationResolverLookup(url: string, options: HostGenerationResolverLookupOptions): HostGenerationLookup;
27
+ export type RevocationLookupArgs = {
28
+ jti: string;
29
+ workspaceId: string;
30
+ hostId: string;
31
+ };
32
+ export type RevocationLookup = (args: RevocationLookupArgs) => Promise<RuntimeAccessTokenActiveResult>;
33
+ export type CachedRevocationOptions = {
34
+ /** TTL in milliseconds for cached revocation responses. Defaults to 10_000. */
35
+ ttlMs?: number;
36
+ /** Clock injection for tests. Defaults to `Date.now`. */
37
+ now?: () => number;
38
+ };
39
+ /**
40
+ * `generation` is the value the caller holds (a token's, a socket's). The
41
+ * cached client uses it to decide whether a cached answer may stand in for a
42
+ * fresh one; an uncached lookup ignores it. Resolves `undefined` for an
43
+ * unknown enrollment and THROWS when the control plane cannot be reached —
44
+ * the throw is the "unavailable" signal every consumer grades separately.
45
+ */
46
+ export type HostGenerationLookupArgs = {
47
+ enrollmentId: string;
48
+ generation: number;
49
+ };
50
+ export type HostGenerationLookup = (args: HostGenerationLookupArgs) => Promise<HostGenerationResult | undefined>;
51
+ export type CachedHostGenerationOptions = {
52
+ /** TTL in milliseconds for cached host-generation answers. Defaults to 10_000. */
53
+ ttlMs?: number;
54
+ /** Clock injection for tests. Defaults to `Date.now`. */
55
+ now?: () => number;
56
+ };
57
+ export type TargetLookupArgs = {
58
+ workspaceId: string;
59
+ hostId: string;
60
+ routingId?: string;
61
+ };
62
+ export type TargetLookup = (args: TargetLookupArgs) => Promise<WorkspaceRelayTarget | undefined>;
63
+ export declare function pruneExpiringCache<T extends {
64
+ expiresAt: number;
65
+ }>(cache: Map<string, T>, now: number, maxEntries: number): void;
66
+ /**
67
+ * Wraps a revocation lookup with a small in-memory cache keyed by `jti`.
68
+ * Both positive and negative results are cached for the full TTL; revocation
69
+ * lag is bounded by the same TTL by design. Concurrent misses share one lookup.
70
+ */
71
+ export declare function createCachedRevocationClient(inner: RevocationLookup, options?: CachedRevocationOptions): RevocationLookup;
72
+ /**
73
+ * The longest a revoked Runtime Access Token can keep working after the
74
+ * authority records the revocation, derived from the configured TTLs rather
75
+ * than measured timing.
76
+ *
77
+ * Every relayed HTTP request re-runs the active check, so a cached `active`
78
+ * answer delays denial by at most `revocationCacheTtlMs`. An established
79
+ * socket adds at most one re-check interval — its watcher can tick just
80
+ * before the stale entry expires — so pass the room's
81
+ * `runtimeAccessTokenActiveCheckIntervalMs` as `activeCheckIntervalMs`;
82
+ * omit it for the per-request path. A non-positive interval means the socket
83
+ * has no revocation watcher at all and the function reports no bound: the
84
+ * token's `exp`, enforced locally by the socket expiry timer, is then the
85
+ * only deadline, and it caps every path even while the revocation authority
86
+ * is unreachable.
87
+ */
88
+ export declare function runtimeAccessTokenRevocationDelayMs(input?: {
89
+ revocationCacheTtlMs?: number;
90
+ activeCheckIntervalMs?: number;
91
+ }): number;
92
+ /**
93
+ * Caches host-generation answers by enrollment id with the fence's own rules:
94
+ * a cached answer stands only while it is at least the caller's generation
95
+ * (equal admits; higher is a refusal that needs no fresh read), and a caller
96
+ * holding a HIGHER generation than the cache — or than the answer an in-flight
97
+ * lookup settles on — forces a refresh so a fresh `acquire` is never refused
98
+ * on a stale answer. Unknown enrollments and failures are not cached;
99
+ * concurrent misses share one lookup.
100
+ */
101
+ export declare function createCachedHostGenerationClient(inner: HostGenerationLookup, options?: CachedHostGenerationOptions): HostGenerationLookup;
102
+ export type CachedTargetOptions = {
103
+ ttlMs?: number;
104
+ durableObjectTtlMs?: number;
105
+ now?: () => number;
106
+ };
107
+ /**
108
+ * Wraps the target lookup with a small in-memory cache keyed by workspace,
109
+ * host and routing id. Only a resolved target is cached: a workspace whose
110
+ * sandbox is still starting answers nothing, and must be seen the moment it
111
+ * is ready. Concurrent misses share one lookup. A cloud or host-tunnel target
112
+ * that stopped keeps answering for at most `ttlMs`, the same lag the
113
+ * revocation cache allows; a session's own Durable Object for
114
+ * `durableObjectTtlMs`.
115
+ */
116
+ export declare function createCachedTargetClient(inner: TargetLookup, options?: CachedTargetOptions): TargetLookup;