@claxedo/workspace-relay 0.7.0 → 0.10.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 +114 -191
- package/dist/auth.d.ts +130 -20
- package/dist/cloudflare.d.ts +26 -43
- package/dist/cors-origins.d.ts +2 -0
- package/dist/directory.d.ts +10 -32
- package/dist/gateway-placement.d.ts +7 -0
- package/dist/host-tunnel-failure.d.ts +7 -0
- package/dist/host-tunnel-forwarding.d.ts +29 -0
- package/dist/http.d.ts +6 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.mjs +1285 -2049
- package/dist/relay-audit.d.ts +4 -0
- package/dist/relay-cors.d.ts +12 -0
- package/dist/relay-location.d.ts +93 -0
- package/dist/relay-target.d.ts +25 -0
- package/dist/resolver-clients.d.ts +116 -0
- package/dist/server.d.ts +51 -188
- package/dist/session-host-forwarding.d.ts +22 -0
- package/dist/test-support/unfenced-host-token.d.ts +6 -0
- package/dist/worker.d.ts +32 -4
- package/dist/workerd-fixture/boot.d.ts +18 -0
- package/package.json +9 -20
- package/dist/bun.d.ts +0 -170
- package/dist/main.d.ts +0 -246
- package/dist/main.mjs +0 -23219
- package/dist/synthetic.d.ts +0 -60
package/dist/index.mjs
CHANGED
|
@@ -1,11 +1,39 @@
|
|
|
1
1
|
// src/auth.ts
|
|
2
|
-
import { SignJWT, errors, jwtVerify } from "jose";
|
|
2
|
+
import { SignJWT, errors, exportJWK, importJWK, jwtVerify } from "jose";
|
|
3
|
+
|
|
4
|
+
// ../claxedo-helpers/dist/guards.mjs
|
|
5
|
+
function isRecord(value) {
|
|
6
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
function asRecord(value) {
|
|
9
|
+
return isRecord(value) ? value : void 0;
|
|
10
|
+
}
|
|
11
|
+
function isFiniteNumber(value) {
|
|
12
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
13
|
+
}
|
|
14
|
+
function asFiniteNumber(value) {
|
|
15
|
+
return isFiniteNumber(value) ? value : void 0;
|
|
16
|
+
}
|
|
17
|
+
function numberClaim(payload, key) {
|
|
18
|
+
return asFiniteNumber(payload[key]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/auth.ts
|
|
22
|
+
import { CURRENT_CHANNEL_IDENTITY_VERSION } from "@claxedo/workspace-relay-protocol";
|
|
3
23
|
var algorithms = ["EdDSA", "ES256", "RS256"];
|
|
4
24
|
var runtimeAccessTokenIssuer = "claxedo-control-plane";
|
|
5
25
|
var runtimeAccessTokenAudience = "workspace-relay";
|
|
6
26
|
var hostTunnelTokenAudience = "workspace-relay-host-tunnel";
|
|
7
27
|
var relayHostTokenIssuer = "workspace-relay";
|
|
8
28
|
var relayHostTokenAudience = "workspace-host-service";
|
|
29
|
+
var RUNTIME_ACCESS_TOKEN_ISSUED_AT_FLOOR_SECONDS = 1789948800;
|
|
30
|
+
function toChannelIdentityClaim(input) {
|
|
31
|
+
return {
|
|
32
|
+
channel: input.channel,
|
|
33
|
+
external_user_id: input.externalUserId,
|
|
34
|
+
identity_version: input.identityVersion
|
|
35
|
+
};
|
|
36
|
+
}
|
|
9
37
|
var WorkspaceRelayAuthError = class extends Error {
|
|
10
38
|
constructor(code, message) {
|
|
11
39
|
super(message);
|
|
@@ -19,27 +47,84 @@ function seconds(input = Date.now()) {
|
|
|
19
47
|
return Math.floor(input / 1e3);
|
|
20
48
|
}
|
|
21
49
|
function requireAlgorithm(input) {
|
|
22
|
-
|
|
50
|
+
const algorithm = algorithms.find((candidate) => candidate === input);
|
|
51
|
+
if (algorithm) return algorithm;
|
|
23
52
|
throw new WorkspaceRelayAuthError("invalid_relay_token", "Unsupported relay token algorithm");
|
|
24
53
|
}
|
|
25
54
|
function stringClaim(payload, key) {
|
|
26
55
|
const value = payload[key];
|
|
27
56
|
return typeof value === "string" && value.trim() ? value : void 0;
|
|
28
57
|
}
|
|
29
|
-
function numberClaim(payload, key) {
|
|
30
|
-
const value = payload[key];
|
|
31
|
-
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
32
|
-
}
|
|
33
58
|
function stringArrayClaim(payload, key) {
|
|
34
59
|
const value = payload[key];
|
|
35
60
|
return Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim()) ? value : void 0;
|
|
36
61
|
}
|
|
62
|
+
function isHostGeneration(value) {
|
|
63
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
64
|
+
}
|
|
65
|
+
function hostGenerationClaims(payload) {
|
|
66
|
+
const enrollment_id = stringClaim(payload, "enrollment_id");
|
|
67
|
+
const generation = payload.generation;
|
|
68
|
+
if (!isHostGeneration(generation) || !enrollment_id) return null;
|
|
69
|
+
return { enrollment_id, generation };
|
|
70
|
+
}
|
|
37
71
|
function roleClaim(payload) {
|
|
38
72
|
const value = stringClaim(payload, "role");
|
|
39
73
|
return value === "viewer" || value === "editor" || value === "admin" || value === "owner" ? value : void 0;
|
|
40
74
|
}
|
|
41
|
-
function
|
|
42
|
-
|
|
75
|
+
function actorProfileClaims(payload) {
|
|
76
|
+
const actor_public_id = stringClaim(payload, "actor_public_id");
|
|
77
|
+
const actor_name = stringClaim(payload, "actor_name");
|
|
78
|
+
const actor_avatar_url = stringClaim(payload, "actor_avatar_url");
|
|
79
|
+
if (!actor_public_id && !actor_name && !actor_avatar_url) return {};
|
|
80
|
+
if (!actor_public_id || !actor_name) return void 0;
|
|
81
|
+
return { actor_public_id, actor_name, ...actor_avatar_url ? { actor_avatar_url } : {} };
|
|
82
|
+
}
|
|
83
|
+
function actorProfilePayload(input) {
|
|
84
|
+
if (!input.actorPublicId && !input.actorName && !input.actorAvatarUrl) return {};
|
|
85
|
+
if (!input.actorPublicId || !input.actorName) {
|
|
86
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Actor display profile claims are incomplete");
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
actor_public_id: input.actorPublicId,
|
|
90
|
+
actor_name: input.actorName,
|
|
91
|
+
...input.actorAvatarUrl ? { actor_avatar_url: input.actorAvatarUrl } : {}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function channelIdentityPayload(input) {
|
|
95
|
+
return input.channelIdentity ? { channel_identity: toChannelIdentityClaim(input.channelIdentity) } : {};
|
|
96
|
+
}
|
|
97
|
+
function channelIdentityClaims(payload) {
|
|
98
|
+
const value = payload.channel_identity;
|
|
99
|
+
if (value === void 0) return void 0;
|
|
100
|
+
if (!isRecord(value)) {
|
|
101
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Channel provenance claim is not a claims object");
|
|
102
|
+
}
|
|
103
|
+
const claim = value;
|
|
104
|
+
const channel = stringClaim(claim, "channel");
|
|
105
|
+
const external_user_id = stringClaim(claim, "external_user_id");
|
|
106
|
+
const identity_version = numberClaim(claim, "identity_version");
|
|
107
|
+
if (!channel || !external_user_id || identity_version === void 0 || !Number.isInteger(identity_version)) {
|
|
108
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Channel provenance claim is incomplete");
|
|
109
|
+
}
|
|
110
|
+
if (identity_version < CURRENT_CHANNEL_IDENTITY_VERSION) {
|
|
111
|
+
throw new WorkspaceRelayAuthError(
|
|
112
|
+
"relay_token_claims_invalid",
|
|
113
|
+
"Channel provenance claim predates the current channel identity version"
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return { channel, external_user_id, identity_version };
|
|
117
|
+
}
|
|
118
|
+
function checkIssuedAtFloor(iat) {
|
|
119
|
+
if (iat < RUNTIME_ACCESS_TOKEN_ISSUED_AT_FLOOR_SECONDS) {
|
|
120
|
+
throw new WorkspaceRelayAuthError(
|
|
121
|
+
"relay_token_claims_invalid",
|
|
122
|
+
"Runtime Access Token was issued before the channel provenance floor"
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function isRelayBacking(input) {
|
|
127
|
+
return input === "cloud-vm" || input === "local-worktree" || input === "durable-object";
|
|
43
128
|
}
|
|
44
129
|
function checkHostTunnelTarget(payload, expected) {
|
|
45
130
|
if (stringClaim(payload, "host_id") !== expected.hostId) {
|
|
@@ -58,6 +143,21 @@ function checkTarget(payload, expected) {
|
|
|
58
143
|
throw new WorkspaceRelayAuthError("relay_token_host_mismatch", "Relay token host does not match request");
|
|
59
144
|
}
|
|
60
145
|
}
|
|
146
|
+
async function deriveRelayHostPublicKey(privateKey) {
|
|
147
|
+
const jwk = await exportJWK(privateKey);
|
|
148
|
+
const imported = await importJWK({ kty: jwk.kty, crv: jwk.crv, x: jwk.x }, "EdDSA", { extractable: true });
|
|
149
|
+
if (imported instanceof Uint8Array) {
|
|
150
|
+
throw new Error("Relay host public key imported as raw bytes; expected an EdDSA public key");
|
|
151
|
+
}
|
|
152
|
+
return imported;
|
|
153
|
+
}
|
|
154
|
+
async function deriveRelayHostKid(publicKey) {
|
|
155
|
+
const jwk = await exportJWK(publicKey);
|
|
156
|
+
const material = jwk.x ?? jwk.n ?? "";
|
|
157
|
+
if (!material) throw new Error("Cannot derive kid: public key has no public component");
|
|
158
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
|
|
159
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 16);
|
|
160
|
+
}
|
|
61
161
|
async function verifyJwt(token, key, input) {
|
|
62
162
|
try {
|
|
63
163
|
if (typeof key === "function") {
|
|
@@ -83,11 +183,20 @@ async function verifyJwt(token, key, input) {
|
|
|
83
183
|
async function mintRuntimeAccessToken(input, key, alg) {
|
|
84
184
|
const now = seconds(input.now);
|
|
85
185
|
return await new SignJWT({
|
|
186
|
+
principal_kind: input.principalKind,
|
|
187
|
+
actor_id: input.actorId,
|
|
188
|
+
...input.userId ? { user_id: input.userId } : {},
|
|
189
|
+
actor_kind: input.actorKind,
|
|
190
|
+
...actorProfilePayload(input),
|
|
191
|
+
...channelIdentityPayload(input),
|
|
86
192
|
org_id: input.orgId,
|
|
87
193
|
workspace_id: input.workspaceId,
|
|
88
194
|
host_id: input.hostId,
|
|
89
|
-
role: input.role
|
|
90
|
-
|
|
195
|
+
role: input.role,
|
|
196
|
+
...tokenScopePayload(input.sessionId),
|
|
197
|
+
...input.purpose ? { purpose: input.purpose } : {},
|
|
198
|
+
...input.routingId !== void 0 ? { routing_id: input.routingId } : {}
|
|
199
|
+
}).setProtectedHeader({ alg: requireAlgorithm(alg) }).setIssuer(runtimeAccessTokenIssuer).setAudience(runtimeAccessTokenAudience).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 30 * 60)).setJti(input.jti ?? jti()).sign(key);
|
|
91
200
|
}
|
|
92
201
|
async function verifyRuntimeAccessToken(token, key, expected) {
|
|
93
202
|
const result = await verifyJwt(token, key, {
|
|
@@ -99,8 +208,27 @@ async function verifyRuntimeAccessToken(token, key, expected) {
|
|
|
99
208
|
if (!claims) {
|
|
100
209
|
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token claims are incomplete");
|
|
101
210
|
}
|
|
211
|
+
checkIssuedAtFloor(claims.iat);
|
|
102
212
|
return claims;
|
|
103
213
|
}
|
|
214
|
+
var TOKEN_CLAIMS_CLOCK_SKEW_SECONDS = 60;
|
|
215
|
+
var TOKEN_CLAIMS_MAX_LIFETIME_SECONDS = 24 * 60 * 60;
|
|
216
|
+
function checkTokenTimeClaims(payload, exp, tokenName) {
|
|
217
|
+
const now = seconds();
|
|
218
|
+
if (exp <= now - TOKEN_CLAIMS_CLOCK_SKEW_SECONDS) {
|
|
219
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", `${tokenName} is expired`);
|
|
220
|
+
}
|
|
221
|
+
const nbf = numberClaim(payload, "nbf");
|
|
222
|
+
if (payload.nbf !== void 0 && nbf === void 0) {
|
|
223
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", `${tokenName} nbf claim is not a finite number`);
|
|
224
|
+
}
|
|
225
|
+
if (nbf !== void 0 && nbf > now + TOKEN_CLAIMS_CLOCK_SKEW_SECONDS) {
|
|
226
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", `${tokenName} is not yet valid`);
|
|
227
|
+
}
|
|
228
|
+
if (exp > now + TOKEN_CLAIMS_MAX_LIFETIME_SECONDS) {
|
|
229
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", `${tokenName} lifetime exceeds the relay maximum`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
104
232
|
function validateRuntimeAccessTokenClaims(input, expected) {
|
|
105
233
|
const payload = input;
|
|
106
234
|
if (stringClaim(payload, "iss") !== runtimeAccessTokenIssuer || stringClaim(payload, "aud") !== runtimeAccessTokenAudience) {
|
|
@@ -111,13 +239,20 @@ function validateRuntimeAccessTokenClaims(input, expected) {
|
|
|
111
239
|
if (!claims) {
|
|
112
240
|
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token claims are incomplete");
|
|
113
241
|
}
|
|
242
|
+
checkIssuedAtFloor(claims.iat);
|
|
243
|
+
checkTokenTimeClaims(payload, claims.exp, "Runtime Access Token");
|
|
114
244
|
return claims;
|
|
115
245
|
}
|
|
116
246
|
async function mintHostTunnelToken(input, key, alg) {
|
|
117
247
|
const now = seconds(input.now);
|
|
248
|
+
if (!isHostGeneration(input.generation) || !input.enrollmentId?.trim()) {
|
|
249
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Host Tunnel Token generation requires a non-negative integer and an enrollment id");
|
|
250
|
+
}
|
|
118
251
|
return await new SignJWT({
|
|
119
252
|
host_id: input.hostId,
|
|
120
|
-
workspace_ids: input.workspaceIds
|
|
253
|
+
workspace_ids: input.workspaceIds,
|
|
254
|
+
enrollment_id: input.enrollmentId,
|
|
255
|
+
generation: input.generation
|
|
121
256
|
}).setProtectedHeader({ alg: requireAlgorithm(alg) }).setIssuer(runtimeAccessTokenIssuer).setAudience(hostTunnelTokenAudience).setSubject(input.subject).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 5 * 60)).setJti(input.jti ?? jti()).sign(key);
|
|
122
257
|
}
|
|
123
258
|
async function verifyHostTunnelToken(token, key, expected) {
|
|
@@ -132,21 +267,54 @@ async function verifyHostTunnelToken(token, key, expected) {
|
|
|
132
267
|
}
|
|
133
268
|
return claims;
|
|
134
269
|
}
|
|
270
|
+
function relayHostInputFromRuntimeClaims(claims) {
|
|
271
|
+
return {
|
|
272
|
+
principalKind: claims.principal_kind,
|
|
273
|
+
actorId: claims.actor_id,
|
|
274
|
+
...claims.user_id ? { userId: claims.user_id } : {},
|
|
275
|
+
actorKind: claims.actor_kind,
|
|
276
|
+
parentJti: claims.jti,
|
|
277
|
+
...claims.actor_public_id && claims.actor_name ? {
|
|
278
|
+
actorPublicId: claims.actor_public_id,
|
|
279
|
+
actorName: claims.actor_name,
|
|
280
|
+
...claims.actor_avatar_url ? { actorAvatarUrl: claims.actor_avatar_url } : {}
|
|
281
|
+
} : {},
|
|
282
|
+
...claims.channel_identity ? {
|
|
283
|
+
channelIdentity: {
|
|
284
|
+
channel: claims.channel_identity.channel,
|
|
285
|
+
externalUserId: claims.channel_identity.external_user_id,
|
|
286
|
+
identityVersion: claims.channel_identity.identity_version
|
|
287
|
+
}
|
|
288
|
+
} : {},
|
|
289
|
+
orgId: claims.org_id,
|
|
290
|
+
role: claims.role,
|
|
291
|
+
...claims.session_id ? { sessionId: claims.session_id } : {},
|
|
292
|
+
...claims.purpose ? { purpose: claims.purpose } : {}
|
|
293
|
+
};
|
|
294
|
+
}
|
|
135
295
|
async function mintRelayHostToken(input, key, alg) {
|
|
136
|
-
if (!
|
|
137
|
-
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Relay Host Token
|
|
296
|
+
if (!isRelayBacking(input.backing)) {
|
|
297
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Relay Host Token backing claim is not a placement");
|
|
138
298
|
}
|
|
139
299
|
const now = seconds(input.now);
|
|
140
300
|
const protectedHeader = { alg: requireAlgorithm(alg) };
|
|
141
301
|
if (input.kid) protectedHeader.kid = input.kid;
|
|
142
302
|
return await new SignJWT({
|
|
303
|
+
principal_kind: input.principalKind,
|
|
304
|
+
actor_id: input.actorId,
|
|
305
|
+
...input.userId ? { user_id: input.userId } : {},
|
|
306
|
+
actor_kind: input.actorKind,
|
|
307
|
+
...actorProfilePayload(input),
|
|
308
|
+
...channelIdentityPayload(input),
|
|
143
309
|
org_id: input.orgId,
|
|
144
310
|
workspace_id: input.workspaceId,
|
|
145
311
|
host_id: input.hostId,
|
|
146
312
|
role: input.role,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
313
|
+
...tokenScopePayload(input.sessionId),
|
|
314
|
+
...input.purpose ? { purpose: input.purpose } : {},
|
|
315
|
+
backing: input.backing,
|
|
316
|
+
parent_jti: input.parentJti
|
|
317
|
+
}).setProtectedHeader(protectedHeader).setIssuer(relayHostTokenIssuer).setAudience(relayHostTokenAudience).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 60)).setJti(input.jti ?? jti()).sign(key);
|
|
150
318
|
}
|
|
151
319
|
async function verifyRelayHostToken(token, key, expected) {
|
|
152
320
|
const result = await verifyJwt(token, key, {
|
|
@@ -163,41 +331,72 @@ async function verifyRelayHostToken(token, key, expected) {
|
|
|
163
331
|
function runtimeClaims(payload) {
|
|
164
332
|
const exp = numberClaim(payload, "exp");
|
|
165
333
|
const iat = numberClaim(payload, "iat");
|
|
166
|
-
const
|
|
334
|
+
const principal_kind = stringClaim(payload, "principal_kind");
|
|
335
|
+
const actor_id = stringClaim(payload, "actor_id");
|
|
336
|
+
const user_id = stringClaim(payload, "user_id");
|
|
337
|
+
const actor_kind = stringClaim(payload, "actor_kind");
|
|
167
338
|
const jti2 = stringClaim(payload, "jti");
|
|
168
339
|
const org_id = stringClaim(payload, "org_id");
|
|
169
340
|
const workspace_id = stringClaim(payload, "workspace_id");
|
|
170
341
|
const host_id = stringClaim(payload, "host_id");
|
|
171
342
|
const role = roleClaim(payload);
|
|
172
|
-
|
|
343
|
+
const reach = tokenScopeClaims(payload);
|
|
344
|
+
const purpose = tokenPurposeClaims(payload);
|
|
345
|
+
if (!exp || !iat || !jti2 || !org_id || !workspace_id || !host_id || !role || !actor_id || !reach || !purpose || payload.user_id !== void 0 && !user_id || principal_kind !== "user" && principal_kind !== "service" || actor_kind !== "human" && actor_kind !== "agent" || principal_kind === "user" && actor_kind !== "human" || principal_kind === "service" && actor_kind !== "agent") return void 0;
|
|
346
|
+
const actorProfile = actorProfileClaims(payload);
|
|
347
|
+
if (!actorProfile) return void 0;
|
|
348
|
+
const channel_identity = channelIdentityClaims(payload);
|
|
349
|
+
const routing_id = stringClaim(payload, "routing_id");
|
|
173
350
|
return {
|
|
174
351
|
iss: runtimeAccessTokenIssuer,
|
|
175
352
|
aud: runtimeAccessTokenAudience,
|
|
176
|
-
|
|
353
|
+
principal_kind,
|
|
354
|
+
actor_id,
|
|
355
|
+
...user_id ? { user_id } : {},
|
|
356
|
+
actor_kind,
|
|
357
|
+
...actorProfile,
|
|
358
|
+
...channel_identity ? { channel_identity } : {},
|
|
359
|
+
...routing_id !== void 0 ? { routing_id } : {},
|
|
177
360
|
org_id,
|
|
178
361
|
workspace_id,
|
|
179
362
|
host_id,
|
|
180
363
|
role,
|
|
364
|
+
...reach,
|
|
365
|
+
...purpose,
|
|
181
366
|
exp,
|
|
182
367
|
iat,
|
|
183
368
|
jti: jti2
|
|
184
369
|
};
|
|
185
370
|
}
|
|
371
|
+
function tokenScopePayload(sessionId) {
|
|
372
|
+
return sessionId === void 0 ? { scope: "workspace" } : { scope: "session", session_id: sessionId };
|
|
373
|
+
}
|
|
374
|
+
function tokenPurposeClaims(payload) {
|
|
375
|
+
if (payload.purpose === void 0) return {};
|
|
376
|
+
return payload.purpose === "turn-execution" ? { purpose: payload.purpose } : void 0;
|
|
377
|
+
}
|
|
378
|
+
function tokenScopeClaims(payload) {
|
|
379
|
+
const sessionId = stringClaim(payload, "session_id");
|
|
380
|
+
if (payload.scope === "workspace" && payload.session_id === void 0) return { scope: "workspace" };
|
|
381
|
+
if (payload.scope === "session" && sessionId) return { scope: "session", session_id: sessionId };
|
|
382
|
+
return void 0;
|
|
383
|
+
}
|
|
186
384
|
function relayHostClaims(payload) {
|
|
187
385
|
const base = runtimeClaims({
|
|
188
386
|
...payload,
|
|
189
387
|
iss: runtimeAccessTokenIssuer,
|
|
190
388
|
aud: runtimeAccessTokenAudience
|
|
191
389
|
});
|
|
192
|
-
|
|
390
|
+
if (payload.access !== void 0) return void 0;
|
|
193
391
|
const backing = stringClaim(payload, "backing");
|
|
194
|
-
const
|
|
195
|
-
if (!base || !
|
|
392
|
+
const parent_jti = stringClaim(payload, "parent_jti");
|
|
393
|
+
if (!base || !parent_jti || !isRelayBacking(backing)) return void 0;
|
|
196
394
|
return {
|
|
197
395
|
...base,
|
|
198
396
|
iss: relayHostTokenIssuer,
|
|
199
397
|
aud: relayHostTokenAudience,
|
|
200
|
-
|
|
398
|
+
parent_jti,
|
|
399
|
+
backing
|
|
201
400
|
};
|
|
202
401
|
}
|
|
203
402
|
function hostTunnelClaims(payload) {
|
|
@@ -207,151 +406,309 @@ function hostTunnelClaims(payload) {
|
|
|
207
406
|
const jti2 = stringClaim(payload, "jti");
|
|
208
407
|
const host_id = stringClaim(payload, "host_id");
|
|
209
408
|
const workspace_ids = stringArrayClaim(payload, "workspace_ids");
|
|
210
|
-
|
|
409
|
+
const fence = hostGenerationClaims(payload);
|
|
410
|
+
if (!exp || !iat || !sub || !jti2 || !host_id || !workspace_ids?.length || fence === null) return void 0;
|
|
211
411
|
return {
|
|
212
412
|
iss: runtimeAccessTokenIssuer,
|
|
213
413
|
aud: hostTunnelTokenAudience,
|
|
214
414
|
sub,
|
|
215
415
|
host_id,
|
|
216
416
|
workspace_ids,
|
|
417
|
+
...fence,
|
|
217
418
|
exp,
|
|
218
419
|
iat,
|
|
219
420
|
jti: jti2
|
|
220
421
|
};
|
|
221
422
|
}
|
|
222
423
|
|
|
223
|
-
//
|
|
424
|
+
// ../claxedo-helpers/dist/api-error.mjs
|
|
425
|
+
function isRecord2(value) {
|
|
426
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
427
|
+
}
|
|
428
|
+
function asRecord2(value) {
|
|
429
|
+
return isRecord2(value) ? value : void 0;
|
|
430
|
+
}
|
|
431
|
+
var PUBLIC_API_ERRORS = {
|
|
432
|
+
repository_provider_unauthorized: { status: 502, message: "Repository provider refused authorization" },
|
|
433
|
+
repository_provider_unavailable: { status: 502, message: "Repository provider is unavailable" },
|
|
434
|
+
host_tunnel_timeout: { status: 503, retryable: true, message: "Host tunnel timed out" },
|
|
435
|
+
host_tunnel_response_body_too_large: { status: 413, message: "Host tunnel response body exceeds the relay limit" },
|
|
436
|
+
host_tunnel_unavailable: { status: 503, retryable: true, message: "Host tunnel is unavailable" },
|
|
437
|
+
workspace_checkpoint_unavailable: { status: 503, retryable: true, message: "Workspace checkpoint is unavailable" },
|
|
438
|
+
workspace_checkpoint_conflict: { status: 409, message: "Workspace checkpoint conflicted" },
|
|
439
|
+
invalid_input: { family: "access", status: 400, message: "The request names an invalid value" },
|
|
440
|
+
organization_policy_denied: { family: "access", status: 403, message: "Organization creation is disabled for this deployment" },
|
|
441
|
+
org_admin_required: { family: "access", status: 403, message: "Organization administrator authority is required" },
|
|
442
|
+
org_owner_required: { family: "access", status: 403, message: "Only an organization owner may grant, change or remove the owner role" },
|
|
443
|
+
org_owner_protected: { family: "access", status: 409, message: "The organization's founding owner cannot be removed or demoted" },
|
|
444
|
+
team_member_org_membership_required: { family: "access", status: 403, message: "The team member must belong to the team organization" },
|
|
445
|
+
project_member_org_membership_required: { family: "access", status: 403, message: "The project member must belong to the project organization" },
|
|
446
|
+
org_membership_required: { family: "access", status: 403, message: "Organization membership is required" },
|
|
447
|
+
project_admin_required: { family: "access", status: 403, message: "Project administrator authority is required" },
|
|
448
|
+
team_not_allowed_on_personal_org: { family: "access", status: 400, message: "Personal organizations cannot contain teams" },
|
|
449
|
+
team_member_target_required: { family: "access", status: 400, message: "Exactly one team member target is required" },
|
|
450
|
+
org_invitation_invalid: { family: "access", status: 409, message: "Invitation is invalid or unavailable" },
|
|
451
|
+
org_invitation_pending: { family: "access", status: 409, message: "An invitation to this address is already pending" },
|
|
452
|
+
org_invitation_rate_limited: { family: "access", status: 429, message: "Organization invitation limit exceeded" },
|
|
453
|
+
org_invitation_email_mismatch: { family: "access", status: 403, message: "Sign in with the invited verified email address" },
|
|
454
|
+
org_invitation_delivery_unavailable: { family: "access", status: 503, message: "Invitation delivery is unavailable" },
|
|
455
|
+
team_not_found: { family: "access", status: 404, message: "Team not found" },
|
|
456
|
+
team_member_not_found: { family: "access", status: 404, message: "Team member not found" },
|
|
457
|
+
org_member_not_found: { family: "access", status: 404, message: "Organization member not found" },
|
|
458
|
+
project_not_found: { family: "access", status: 404, message: "Project not found" },
|
|
459
|
+
project_member_not_found: { family: "access", status: 404, message: "Project member not found" },
|
|
460
|
+
project_member_owner_immutable: { family: "access", status: 409, message: "The project owner's access cannot be changed" },
|
|
461
|
+
resource_conflict: { family: "access", status: 409, message: "Organization or team authority changed concurrently" },
|
|
462
|
+
session_not_found: { family: "session_share", status: 404, message: "This session is not on the control plane, so it cannot be shared from People yet." },
|
|
463
|
+
session_share_admin_required: { family: "session_share", status: 403, message: "Only the person who started this session can manage its People." },
|
|
464
|
+
session_share_target_required: { family: "session_share", status: 400, message: "A session is shared with exactly one person" },
|
|
465
|
+
session_share_target_not_found: { family: "session_share", status: 404, message: "That person was not found in this organization." },
|
|
466
|
+
session_share_level_invalid: { family: "session_share", status: 400, message: "A share level is either follow or send." },
|
|
467
|
+
workspace_backing_conflict: { status: 409, message: "Workspace is provisioned in the cloud and cannot be placed on a machine" },
|
|
468
|
+
invalid_session_list_cursor: { status: 400, message: "Session list cursor does not match this query" },
|
|
469
|
+
BAD_REQUEST: { status: 400, message: "Invalid request" },
|
|
470
|
+
INTERNAL_SERVER_ERROR: { status: 500, message: "Internal error" },
|
|
471
|
+
CONTROL_PLANE_UNAVAILABLE: { status: 503, message: "Control Plane services are not configured" },
|
|
472
|
+
credential_verification_unsupported: { status: 400, message: "Credential provider does not support verification" },
|
|
473
|
+
credential_shape_invalid: { status: 400, message: "Credential secret has an unsupported shape" },
|
|
474
|
+
credential_provider_unavailable: { status: 502, retryable: true, message: "Credential provider request failed" },
|
|
475
|
+
credential_verification_failed: { status: 502, message: "Credential provider verification failed" },
|
|
476
|
+
credential_redirect_denied: { status: 502, message: "Sandbox provider redirects are not allowed" },
|
|
477
|
+
credential_endpoint_invalid: { status: 400, message: "Invalid credential provider endpoint" },
|
|
478
|
+
host_attestation_denied: { status: 403, message: "host_attestation_denied" },
|
|
479
|
+
signature_replayed: { status: 409, message: "signature_replayed" },
|
|
480
|
+
host_enrollment_not_found: { status: 404, message: "host_enrollment_not_found" },
|
|
481
|
+
enrollment_revoked: { status: 403, message: "enrollment_revoked" },
|
|
482
|
+
enrollment_paused: { status: 403, message: "enrollment_paused" },
|
|
483
|
+
enrollment_owner_ineligible: { status: 403, message: "enrollment_owner_ineligible" },
|
|
484
|
+
enrollment_key_version_mismatch: { status: 403, message: "enrollment_key_version_mismatch" },
|
|
485
|
+
invitation_invalid: { status: 403, message: "invitation_invalid" },
|
|
486
|
+
invitation_expired: { status: 410, message: "invitation_expired" },
|
|
487
|
+
invitation_revoked: { status: 410, message: "invitation_revoked" },
|
|
488
|
+
invitation_redeemed: { status: 409, message: "invitation_redeemed" },
|
|
489
|
+
invitation_host_conflict: { status: 409, message: "invitation_host_conflict" },
|
|
490
|
+
enrollment_generation_superseded: { status: 409, message: "enrollment_generation_superseded" },
|
|
491
|
+
host_assignment_outside_scope: { status: 400, message: "host_assignment_outside_scope" },
|
|
492
|
+
host_sealing_key_undeclared: { status: 409, message: "host_sealing_key_undeclared" },
|
|
493
|
+
host_provider_config_revision_stale: { status: 409, message: "host_provider_config_revision_stale" },
|
|
494
|
+
identity_conflict: { status: 409, message: "identity_conflict" },
|
|
495
|
+
registration_transition_denied: { status: 403, message: "registration_transition_denied" },
|
|
496
|
+
session_reservation_spent: { status: 409, message: "This session's create was undone; reserve it again to retry" },
|
|
497
|
+
actor_authorization_denied: { status: 403, message: "actor_authorization_denied" }
|
|
498
|
+
};
|
|
499
|
+
function encodeApiError(error, defaults = {}) {
|
|
500
|
+
const row = asRecord2(error);
|
|
501
|
+
return {
|
|
502
|
+
error: {
|
|
503
|
+
code: typeof row?.code === "string" && row.code ? row.code : defaults.code ?? "internal_error",
|
|
504
|
+
message: typeof row?.message === "string" && row.message ? row.message : defaults.message ?? "Internal error",
|
|
505
|
+
retryable: row?.retryable === true
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function publicApiErrorShape(code) {
|
|
510
|
+
const entry = PUBLIC_API_ERRORS[code];
|
|
511
|
+
return { status: entry.status, retryable: entry.retryable === true };
|
|
512
|
+
}
|
|
513
|
+
function publicApiFailure(code, message = PUBLIC_API_ERRORS[code].message) {
|
|
514
|
+
return Object.assign(new Error(message), { code, ...publicApiErrorShape(code) });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/host-tunnel-failure.ts
|
|
518
|
+
function hostTunnelFailure(code) {
|
|
519
|
+
return publicApiFailure(code);
|
|
520
|
+
}
|
|
521
|
+
function hostTunnelFailureResponse(error) {
|
|
522
|
+
const code = asRecord(error)?.code;
|
|
523
|
+
const failure = hostTunnelFailure(code === "host_tunnel_timeout" || code === "host_tunnel_response_body_too_large" ? code : "host_tunnel_unavailable");
|
|
524
|
+
return Response.json(encodeApiError(failure), { status: failure.status });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// src/cloudflare.ts
|
|
528
|
+
import { trimToUndefined as trimToUndefined4 } from "@claxedo/helpers/string";
|
|
529
|
+
|
|
530
|
+
// src/http.ts
|
|
531
|
+
import { bearerToken, trimToUndefined } from "@claxedo/helpers/string";
|
|
532
|
+
function errorBody(code, message) {
|
|
533
|
+
return {
|
|
534
|
+
error: {
|
|
535
|
+
code,
|
|
536
|
+
message
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function hostIdFromTunnelPath(pathname) {
|
|
541
|
+
return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
542
|
+
}
|
|
543
|
+
function workspaceIdsFromSearch(url) {
|
|
544
|
+
return [...new Set([
|
|
545
|
+
...url.searchParams.getAll("workspaceId"),
|
|
546
|
+
...url.searchParams.getAll("workspace_id")
|
|
547
|
+
].map(trimToUndefined).filter((item) => !!item))];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// src/cloudflare.ts
|
|
224
551
|
import {
|
|
225
|
-
makeTunnelPing,
|
|
226
552
|
makeTunnelPong,
|
|
227
553
|
TUNNEL_PROTOCOL_VERSION,
|
|
228
554
|
validateTunnelMessage
|
|
229
555
|
} from "@claxedo/workspace-relay-protocol";
|
|
230
556
|
|
|
231
557
|
// src/server.ts
|
|
232
|
-
import {
|
|
233
|
-
import { cors } from "hono/cors";
|
|
234
|
-
import { exportJWK } from "jose";
|
|
558
|
+
import { sessionScopeReaches } from "@claxedo/workspace-relay-protocol";
|
|
235
559
|
|
|
236
|
-
// src/
|
|
237
|
-
|
|
238
|
-
"
|
|
239
|
-
"http://127.0.0.1:*",
|
|
240
|
-
"https://claxedo.com",
|
|
241
|
-
"https://*.claxedo.com"
|
|
242
|
-
];
|
|
243
|
-
function parseAllowedOrigins(raw) {
|
|
244
|
-
const entries = (raw ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
245
|
-
return entries.length ? entries : void 0;
|
|
560
|
+
// src/host-tunnel-forwarding.ts
|
|
561
|
+
function isHostTunnelTarget(target) {
|
|
562
|
+
return target.backing === "local-worktree";
|
|
246
563
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (origin.startsWith("https://")) {
|
|
263
|
-
return httpsSuffixes.some(
|
|
264
|
-
(suffix) => origin.endsWith(suffix) && origin.length > "https://".length + suffix.length
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
if (origin.startsWith("http://")) {
|
|
268
|
-
return httpPortPrefixes.some(
|
|
269
|
-
(prefix) => origin.startsWith(prefix) && /^\d+$/.test(origin.slice(prefix.length))
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
return false;
|
|
564
|
+
|
|
565
|
+
// src/relay-target.ts
|
|
566
|
+
function parseWorkspaceRelayTarget(input) {
|
|
567
|
+
if (!isRecord(input)) return void 0;
|
|
568
|
+
const row = input;
|
|
569
|
+
const { workspaceId, hostId, baseUrl } = row;
|
|
570
|
+
if (typeof workspaceId !== "string" || typeof hostId !== "string" || typeof baseUrl !== "string") return void 0;
|
|
571
|
+
if (row.access !== void 0) return void 0;
|
|
572
|
+
if (!isRelayBacking(row.backing)) return void 0;
|
|
573
|
+
if (!isAllowedRelayTargetBaseUrl({ baseUrl, backing: row.backing })) return void 0;
|
|
574
|
+
return {
|
|
575
|
+
workspaceId,
|
|
576
|
+
hostId,
|
|
577
|
+
baseUrl,
|
|
578
|
+
backing: row.backing
|
|
273
579
|
};
|
|
274
580
|
}
|
|
581
|
+
function parseAbsoluteHttpUrl(baseUrl) {
|
|
582
|
+
let url;
|
|
583
|
+
try {
|
|
584
|
+
url = new URL(baseUrl);
|
|
585
|
+
} catch {
|
|
586
|
+
return void 0;
|
|
587
|
+
}
|
|
588
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url : void 0;
|
|
589
|
+
}
|
|
590
|
+
function isLoopbackTargetHostname(hostname) {
|
|
591
|
+
const host = hostname.toLowerCase();
|
|
592
|
+
if (host === "localhost" || host === "[::1]" || host.endsWith(".localhost")) return true;
|
|
593
|
+
const ipv4 = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
594
|
+
return !!ipv4 && ipv4.slice(1).every((octet) => Number(octet) <= 255);
|
|
595
|
+
}
|
|
596
|
+
function isAllowedRelayTargetBaseUrl(target) {
|
|
597
|
+
if (target.backing === "durable-object") return target.baseUrl === "";
|
|
598
|
+
if (isHostTunnelTarget(target)) {
|
|
599
|
+
if (target.baseUrl === "") return true;
|
|
600
|
+
return parseAbsoluteHttpUrl(target.baseUrl) !== void 0;
|
|
601
|
+
}
|
|
602
|
+
const url = parseAbsoluteHttpUrl(target.baseUrl);
|
|
603
|
+
if (!url) return false;
|
|
604
|
+
return url.protocol === "https:" || isLoopbackTargetHostname(url.hostname);
|
|
605
|
+
}
|
|
275
606
|
|
|
276
|
-
// src/
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
607
|
+
// src/relay-audit.ts
|
|
608
|
+
var auditSamplers = /* @__PURE__ */ new WeakMap();
|
|
609
|
+
function getOrCreateAuditSampler(options) {
|
|
610
|
+
let state = auditSamplers.get(options);
|
|
611
|
+
if (!state) {
|
|
612
|
+
const sampleRate = clampSampleRate(options.auditAcceptSampleRate);
|
|
613
|
+
state = {
|
|
614
|
+
suppressedCount: 0,
|
|
615
|
+
timer: void 0,
|
|
616
|
+
flushIntervalMs: options.auditFlushIntervalMs ?? 6e4,
|
|
617
|
+
random: options.random ?? Math.random,
|
|
618
|
+
sampleRate
|
|
619
|
+
};
|
|
620
|
+
auditSamplers.set(options, state);
|
|
621
|
+
}
|
|
622
|
+
return state;
|
|
281
623
|
}
|
|
282
|
-
function
|
|
283
|
-
return
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
624
|
+
function clampSampleRate(rate) {
|
|
625
|
+
if (rate === void 0) return 1;
|
|
626
|
+
if (!Number.isFinite(rate)) return 1;
|
|
627
|
+
if (rate < 0) return 0;
|
|
628
|
+
if (rate > 1) return 1;
|
|
629
|
+
return rate;
|
|
630
|
+
}
|
|
631
|
+
function ensureFlushTimer(options, state) {
|
|
632
|
+
if (state.timer) return;
|
|
633
|
+
if (state.sampleRate >= 1) return;
|
|
634
|
+
if (state.flushIntervalMs <= 0) return;
|
|
635
|
+
const timer = setInterval(() => {
|
|
636
|
+
flushSuppressedSummary(options, state);
|
|
637
|
+
}, state.flushIntervalMs);
|
|
638
|
+
if (typeof timer.unref === "function") {
|
|
639
|
+
timer.unref();
|
|
640
|
+
}
|
|
641
|
+
state.timer = timer;
|
|
642
|
+
}
|
|
643
|
+
function flushSuppressedSummary(options, state) {
|
|
644
|
+
const count = state.suppressedCount;
|
|
645
|
+
if (count <= 0) return;
|
|
646
|
+
state.suppressedCount = 0;
|
|
647
|
+
void options.audit?.({
|
|
648
|
+
action: "relay.request.suppressed_summary",
|
|
649
|
+
result: "allow",
|
|
650
|
+
method: "INTERNAL",
|
|
651
|
+
path: "/",
|
|
652
|
+
suppressedCount: count
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
async function audit(options, event) {
|
|
656
|
+
if (event.action === "relay.request.accepted") {
|
|
657
|
+
const state = getOrCreateAuditSampler(options);
|
|
658
|
+
if (state.sampleRate < 1) {
|
|
659
|
+
ensureFlushTimer(options, state);
|
|
660
|
+
if (state.random() >= state.sampleRate) {
|
|
661
|
+
state.suppressedCount += 1;
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
287
664
|
}
|
|
288
|
-
}
|
|
665
|
+
}
|
|
666
|
+
await options.audit?.(event);
|
|
289
667
|
}
|
|
290
668
|
|
|
291
669
|
// src/server.ts
|
|
670
|
+
function hostTunnelIncumbentOutranks(incumbentGeneration, candidateGeneration) {
|
|
671
|
+
return incumbentGeneration > candidateGeneration;
|
|
672
|
+
}
|
|
292
673
|
var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
|
|
293
674
|
var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
|
|
294
|
-
var RESOLVER_CACHE_MAX_ENTRIES = 8192;
|
|
295
675
|
var RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 1e4;
|
|
676
|
+
var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
|
|
296
677
|
var relayHostTokenCaches = /* @__PURE__ */ new WeakMap();
|
|
297
678
|
var runtimeAccessTokenCaches = /* @__PURE__ */ new WeakMap();
|
|
298
|
-
function
|
|
299
|
-
|
|
300
|
-
|
|
679
|
+
async function checkHostTunnelGeneration(lookup, claims) {
|
|
680
|
+
if (!lookup) {
|
|
681
|
+
return {
|
|
682
|
+
ok: false,
|
|
683
|
+
retryable: false,
|
|
684
|
+
code: "host_generation_unverifiable",
|
|
685
|
+
reason: "Host tunnel generation cannot be verified by a relay without a host-generation resolver"
|
|
686
|
+
};
|
|
301
687
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
688
|
+
let result;
|
|
689
|
+
try {
|
|
690
|
+
result = await lookup({ enrollmentId: claims.enrollment_id, generation: claims.generation });
|
|
691
|
+
} catch (err) {
|
|
692
|
+
return {
|
|
693
|
+
ok: false,
|
|
694
|
+
retryable: true,
|
|
695
|
+
code: "host_generation_lookup_unavailable",
|
|
696
|
+
reason: `Host generation lookup is unavailable: ${err instanceof Error ? err.message : String(err)}`
|
|
697
|
+
};
|
|
306
698
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
|
|
321
|
-
cache.set(args.jti, { result, expiresAt: now() + ttlMs });
|
|
322
|
-
return result;
|
|
323
|
-
}).catch((err) => {
|
|
324
|
-
cache.delete(args.jti);
|
|
325
|
-
throw err;
|
|
326
|
-
});
|
|
327
|
-
cache.set(args.jti, { promise, expiresAt: at + ttlMs });
|
|
328
|
-
return await promise;
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
function createCachedTargetClient(inner, options = {}) {
|
|
332
|
-
const ttlMs = options.ttlMs ?? 5e3;
|
|
333
|
-
const now = options.now ?? Date.now;
|
|
334
|
-
const cache = /* @__PURE__ */ new Map();
|
|
335
|
-
return async (args) => {
|
|
336
|
-
const key = `${args.workspaceId}\0${args.hostId}`;
|
|
337
|
-
const at = now();
|
|
338
|
-
const entry = cache.get(key);
|
|
339
|
-
if (entry && entry.expiresAt > at) {
|
|
340
|
-
if (entry.result) return entry.result;
|
|
341
|
-
if (entry.promise) return await entry.promise;
|
|
342
|
-
}
|
|
343
|
-
const promise = inner(args).then((result) => {
|
|
344
|
-
pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
|
|
345
|
-
if (result) cache.set(key, { result, expiresAt: now() + ttlMs });
|
|
346
|
-
else cache.delete(key);
|
|
347
|
-
return result;
|
|
348
|
-
}).catch((err) => {
|
|
349
|
-
cache.delete(key);
|
|
350
|
-
throw err;
|
|
351
|
-
});
|
|
352
|
-
cache.set(key, { promise, expiresAt: at + ttlMs });
|
|
353
|
-
return await promise;
|
|
354
|
-
};
|
|
699
|
+
if (!result) {
|
|
700
|
+
return { ok: false, retryable: false, code: "host_enrollment_unknown", reason: "Host enrollment is unknown" };
|
|
701
|
+
}
|
|
702
|
+
if (result.revoked) {
|
|
703
|
+
return { ok: false, retryable: false, code: "host_enrollment_revoked", reason: "Host enrollment was revoked" };
|
|
704
|
+
}
|
|
705
|
+
if (result.generation > claims.generation) {
|
|
706
|
+
return { ok: false, retryable: false, code: "host_generation_superseded", reason: "Host tunnel generation was superseded" };
|
|
707
|
+
}
|
|
708
|
+
if (result.generation < claims.generation) {
|
|
709
|
+
return { ok: false, retryable: false, code: "host_generation_unknown", reason: "Host tunnel generation is ahead of the control plane" };
|
|
710
|
+
}
|
|
711
|
+
return { ok: true };
|
|
355
712
|
}
|
|
356
713
|
function protocolToken(header) {
|
|
357
714
|
return header?.split(",").map((item) => item.trim()).find((item) => item.startsWith("claxedo-rat."))?.slice("claxedo-rat.".length);
|
|
@@ -389,53 +746,38 @@ function isDangerousInboundHeader(name) {
|
|
|
389
746
|
return false;
|
|
390
747
|
}
|
|
391
748
|
function forwardHeaders(input, relayHostToken, workspaceId, options = {}) {
|
|
392
|
-
const
|
|
393
|
-
|
|
749
|
+
const headers = new Headers(input);
|
|
750
|
+
headers.delete("host");
|
|
394
751
|
const inboundNames = /* @__PURE__ */ new Set();
|
|
395
|
-
|
|
752
|
+
headers.forEach((_value, name) => inboundNames.add(name));
|
|
396
753
|
for (const name of inboundNames) {
|
|
397
|
-
if (isDangerousInboundHeader(name))
|
|
398
|
-
}
|
|
399
|
-
if (options.
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
for (const [name, value] of Object.entries(options.upstreamHeaders ?? {})) {
|
|
408
|
-
const trimmed = value.trim();
|
|
409
|
-
if (trimmed) headers2.set(name, trimmed);
|
|
410
|
-
}
|
|
411
|
-
headers2.set("x-forwarded-by", "workspace-relay");
|
|
412
|
-
return headers2;
|
|
754
|
+
if (isDangerousInboundHeader(name)) headers.delete(name);
|
|
755
|
+
}
|
|
756
|
+
if (options.hostTunnel) headers.delete("cookie");
|
|
757
|
+
headers.set("accept-encoding", "identity");
|
|
758
|
+
headers.delete("authorization");
|
|
759
|
+
headers.delete("Authorization");
|
|
760
|
+
headers.set("Authorization", `Bearer ${relayHostToken}`);
|
|
761
|
+
headers.set("x-workspace-id", workspaceId);
|
|
762
|
+
headers.set("x-forwarded-by", "workspace-relay");
|
|
763
|
+
return headers;
|
|
413
764
|
}
|
|
414
765
|
function targetUrl(target, path, search) {
|
|
415
|
-
const
|
|
766
|
+
const base = new URL(target.baseUrl.endsWith("/") ? target.baseUrl : `${target.baseUrl}/`);
|
|
767
|
+
const url = new URL(base);
|
|
768
|
+
url.pathname = `${base.pathname}${path.replace(/^\/+/, "")}`;
|
|
416
769
|
url.search = search;
|
|
770
|
+
url.hash = "";
|
|
417
771
|
return url;
|
|
418
772
|
}
|
|
419
|
-
var RELAY_VIEWER_DENIED_PATH = /^\/api\/wr\/pty(?:\/|$)/;
|
|
420
773
|
function relayRuntimePath(request) {
|
|
421
|
-
return new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/");
|
|
422
|
-
}
|
|
423
|
-
function relayAuthorizationPath(path) {
|
|
424
774
|
try {
|
|
425
|
-
|
|
775
|
+
const decoded = decodeURIComponent(new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/")).replace(/^\/+/, "/");
|
|
776
|
+
return new URL(decoded.replace(/^\/+/, ""), "http://relay.invalid/").pathname;
|
|
426
777
|
} catch {
|
|
427
778
|
return void 0;
|
|
428
779
|
}
|
|
429
780
|
}
|
|
430
|
-
function roleAllowsRelayRequest(role, method, path) {
|
|
431
|
-
if (role === "owner" || role === "admin" || role === "editor") return true;
|
|
432
|
-
if (role === "viewer") {
|
|
433
|
-
const authorizationPath = relayAuthorizationPath(path);
|
|
434
|
-
if (!authorizationPath || RELAY_VIEWER_DENIED_PATH.test(authorizationPath)) return false;
|
|
435
|
-
return method === "GET" || method === "HEAD" || method === "OPTIONS";
|
|
436
|
-
}
|
|
437
|
-
return false;
|
|
438
|
-
}
|
|
439
781
|
function relayHostTokenCacheTtlMs(options, claims) {
|
|
440
782
|
const configured = options.relayHostTokenCacheTtlMs ?? 3e4;
|
|
441
783
|
if (!Number.isFinite(configured) || configured <= 0) return 0;
|
|
@@ -445,12 +787,13 @@ function relayHostTokenCacheTtlMs(options, claims) {
|
|
|
445
787
|
function relayHostTokenCacheKey(options, claims, target) {
|
|
446
788
|
return [
|
|
447
789
|
claims.jti,
|
|
448
|
-
claims.
|
|
790
|
+
claims.principal_kind,
|
|
791
|
+
claims.actor_id,
|
|
792
|
+
claims.actor_kind,
|
|
449
793
|
claims.org_id,
|
|
450
794
|
claims.role,
|
|
451
795
|
claims.workspace_id,
|
|
452
796
|
claims.host_id,
|
|
453
|
-
target.access,
|
|
454
797
|
target.backing,
|
|
455
798
|
options.relayHostMintKid ?? ""
|
|
456
799
|
].join("\0");
|
|
@@ -529,6 +872,13 @@ function pruneRelayHostTokenCache(cache, now) {
|
|
|
529
872
|
cache.delete(key);
|
|
530
873
|
}
|
|
531
874
|
}
|
|
875
|
+
function relayHostMintInput(options, claims, target) {
|
|
876
|
+
return {
|
|
877
|
+
...relayHostInputFromRuntimeClaims(claims),
|
|
878
|
+
...target,
|
|
879
|
+
...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
|
|
880
|
+
};
|
|
881
|
+
}
|
|
532
882
|
async function relayHostTokenFor(options, claims, target, trace) {
|
|
533
883
|
const ttlMs = relayHostTokenCacheTtlMs(options, claims);
|
|
534
884
|
if (ttlMs <= 0) {
|
|
@@ -549,13 +899,11 @@ async function relayHostTokenFor(options, claims, target, trace) {
|
|
|
549
899
|
let promise;
|
|
550
900
|
try {
|
|
551
901
|
const token = await trace.span("rht-mint", async () => {
|
|
552
|
-
promise = mintRelayHostToken(
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
|
|
558
|
-
}, options.relayHostSigningKey, options.relayHostAlgorithm);
|
|
902
|
+
promise = mintRelayHostToken(
|
|
903
|
+
relayHostMintInput(options, claims, target),
|
|
904
|
+
options.relayHostSigningKey,
|
|
905
|
+
options.relayHostAlgorithm
|
|
906
|
+
);
|
|
559
907
|
pruneRelayHostTokenCache(cache, now);
|
|
560
908
|
cache.set(key, { promise, expiresAt: now + ttlMs });
|
|
561
909
|
return await promise;
|
|
@@ -573,13 +921,11 @@ async function relayHostTokenFor(options, claims, target, trace) {
|
|
|
573
921
|
async function uncachedRelayHostTokenFor(options, claims, target, trace) {
|
|
574
922
|
return await trace.span(
|
|
575
923
|
"rht-mint",
|
|
576
|
-
async () => await mintRelayHostToken(
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
|
|
582
|
-
}, options.relayHostSigningKey, options.relayHostAlgorithm)
|
|
924
|
+
async () => await mintRelayHostToken(
|
|
925
|
+
relayHostMintInput(options, claims, target),
|
|
926
|
+
options.relayHostSigningKey,
|
|
927
|
+
options.relayHostAlgorithm
|
|
928
|
+
)
|
|
583
929
|
);
|
|
584
930
|
}
|
|
585
931
|
function workspaceRelayTargetUrl(target, path, search) {
|
|
@@ -600,146 +946,22 @@ function workspaceRelayForwardRequestInit(request, relayHostToken, workspaceId,
|
|
|
600
946
|
if (request.method === "GET" || request.method === "HEAD") delete init.body;
|
|
601
947
|
return init;
|
|
602
948
|
}
|
|
603
|
-
var auditSamplers = /* @__PURE__ */ new WeakMap();
|
|
604
|
-
function getOrCreateAuditSampler(options) {
|
|
605
|
-
let state = auditSamplers.get(options);
|
|
606
|
-
if (!state) {
|
|
607
|
-
const sampleRate = clampSampleRate(options.auditAcceptSampleRate);
|
|
608
|
-
state = {
|
|
609
|
-
suppressedCount: 0,
|
|
610
|
-
timer: void 0,
|
|
611
|
-
flushIntervalMs: options.auditFlushIntervalMs ?? 6e4,
|
|
612
|
-
random: options.random ?? Math.random,
|
|
613
|
-
sampleRate
|
|
614
|
-
};
|
|
615
|
-
auditSamplers.set(options, state);
|
|
616
|
-
}
|
|
617
|
-
return state;
|
|
618
|
-
}
|
|
619
|
-
function clampSampleRate(rate) {
|
|
620
|
-
if (rate === void 0) return 1;
|
|
621
|
-
if (!Number.isFinite(rate)) return 1;
|
|
622
|
-
if (rate < 0) return 0;
|
|
623
|
-
if (rate > 1) return 1;
|
|
624
|
-
return rate;
|
|
625
|
-
}
|
|
626
|
-
function ensureFlushTimer(options, state) {
|
|
627
|
-
if (state.timer) return;
|
|
628
|
-
if (state.sampleRate >= 1) return;
|
|
629
|
-
if (state.flushIntervalMs <= 0) return;
|
|
630
|
-
const timer = setInterval(() => {
|
|
631
|
-
flushSuppressedSummary(options, state);
|
|
632
|
-
}, state.flushIntervalMs);
|
|
633
|
-
if (typeof timer.unref === "function") {
|
|
634
|
-
timer.unref();
|
|
635
|
-
}
|
|
636
|
-
state.timer = timer;
|
|
637
|
-
}
|
|
638
|
-
function flushSuppressedSummary(options, state) {
|
|
639
|
-
const count = state.suppressedCount;
|
|
640
|
-
if (count <= 0) return;
|
|
641
|
-
state.suppressedCount = 0;
|
|
642
|
-
void options.audit?.({
|
|
643
|
-
action: "relay.request.suppressed_summary",
|
|
644
|
-
result: "allow",
|
|
645
|
-
method: "INTERNAL",
|
|
646
|
-
path: "/",
|
|
647
|
-
suppressedCount: count
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
function disposeAuditSampler(options) {
|
|
651
|
-
const state = auditSamplers.get(options);
|
|
652
|
-
if (!state) return;
|
|
653
|
-
if (state.timer) {
|
|
654
|
-
clearInterval(state.timer);
|
|
655
|
-
state.timer = void 0;
|
|
656
|
-
}
|
|
657
|
-
flushSuppressedSummary(options, state);
|
|
658
|
-
auditSamplers.delete(options);
|
|
659
|
-
}
|
|
660
|
-
function createWorkspaceRelayTrace() {
|
|
661
|
-
const phases = [];
|
|
662
|
-
return {
|
|
663
|
-
phases,
|
|
664
|
-
async span(name, run) {
|
|
665
|
-
const startedAt = performance.now();
|
|
666
|
-
try {
|
|
667
|
-
return await run();
|
|
668
|
-
} finally {
|
|
669
|
-
phases.push({ name, ms: performance.now() - startedAt });
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
};
|
|
673
|
-
}
|
|
674
|
-
function roundedMs(value) {
|
|
675
|
-
return Math.round(value * 100) / 100;
|
|
676
|
-
}
|
|
677
|
-
function workspaceRelayServerTiming(trace) {
|
|
678
|
-
return trace.phases.map((phase) => `${phase.name};dur=${roundedMs(phase.ms)}`).join(", ");
|
|
679
|
-
}
|
|
680
|
-
function workspaceRelayTimingResponse(response, trace) {
|
|
681
|
-
const value = workspaceRelayServerTiming(trace);
|
|
682
|
-
if (!value) return response;
|
|
683
|
-
const headers2 = new Headers(response.headers);
|
|
684
|
-
const prev = headers2.get("server-timing");
|
|
685
|
-
headers2.set("server-timing", prev ? `${prev}, ${value}` : value);
|
|
686
|
-
return new Response(response.body, {
|
|
687
|
-
status: response.status,
|
|
688
|
-
statusText: response.statusText,
|
|
689
|
-
headers: headers2
|
|
690
|
-
});
|
|
691
|
-
}
|
|
692
|
-
async function audit(options, event) {
|
|
693
|
-
if (event.action === "relay.request.accepted") {
|
|
694
|
-
const state = getOrCreateAuditSampler(options);
|
|
695
|
-
if (state.sampleRate < 1) {
|
|
696
|
-
ensureFlushTimer(options, state);
|
|
697
|
-
if (state.random() >= state.sampleRate) {
|
|
698
|
-
state.suppressedCount += 1;
|
|
699
|
-
return;
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
await options.audit?.(event);
|
|
704
|
-
}
|
|
705
|
-
var defaultOriginMatcher = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
|
|
706
|
-
var originMatchers = /* @__PURE__ */ new WeakMap();
|
|
707
|
-
function originMatcherFor(options) {
|
|
708
|
-
if (!options.allowedOrigins) return defaultOriginMatcher;
|
|
709
|
-
let matcher = originMatchers.get(options);
|
|
710
|
-
if (!matcher) {
|
|
711
|
-
matcher = createOriginMatcher(options.allowedOrigins);
|
|
712
|
-
originMatchers.set(options, matcher);
|
|
713
|
-
}
|
|
714
|
-
return matcher;
|
|
715
|
-
}
|
|
716
|
-
function denyCorsHeaders(request, originAllowed = defaultOriginMatcher) {
|
|
717
|
-
const origin = request.headers.get("origin");
|
|
718
|
-
if (!origin) return void 0;
|
|
719
|
-
if (!originAllowed(origin)) return void 0;
|
|
720
|
-
return {
|
|
721
|
-
"access-control-allow-origin": origin,
|
|
722
|
-
"access-control-allow-headers": "Accept, Authorization, Content-Type, Last-Event-ID, 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",
|
|
723
|
-
"access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"
|
|
724
|
-
};
|
|
725
|
-
}
|
|
726
949
|
async function deny(options, input) {
|
|
727
950
|
const url = new URL(input.request.url);
|
|
728
951
|
await audit(options, {
|
|
729
952
|
action: "relay.request.denied",
|
|
730
953
|
result: "deny",
|
|
731
954
|
reason: input.code,
|
|
732
|
-
|
|
955
|
+
actorId: input.claims?.actor_id,
|
|
956
|
+
actorKind: input.claims?.actor_kind,
|
|
957
|
+
principalKind: input.claims?.principal_kind,
|
|
733
958
|
...input.claims ? { role: input.claims.role } : {},
|
|
734
959
|
workspaceId: input.claims?.workspace_id,
|
|
735
960
|
hostId: input.claims?.host_id,
|
|
736
961
|
method: input.request.method,
|
|
737
962
|
path: url.pathname
|
|
738
963
|
});
|
|
739
|
-
return Response.json(errorBody(input.code, input.message), {
|
|
740
|
-
status: input.status,
|
|
741
|
-
headers: denyCorsHeaders(input.request, originMatcherFor(options))
|
|
742
|
-
});
|
|
964
|
+
return Response.json(errorBody(input.code, input.message), { status: input.status });
|
|
743
965
|
}
|
|
744
966
|
async function authorizeWorkspaceRelayRequest(options, request, workspaceId, trace) {
|
|
745
967
|
const token = runtimeAccessToken(request);
|
|
@@ -761,6 +983,8 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
761
983
|
"rat-verify",
|
|
762
984
|
async () => await cachedRuntimeAccessTokenClaims(options, token, workspaceId)
|
|
763
985
|
);
|
|
986
|
+
const targetLookup = span("target-resolve", async () => await options.resolveTarget(claims));
|
|
987
|
+
targetLookup.catch(() => void 0);
|
|
764
988
|
const active = await span("rat-active", async () => {
|
|
765
989
|
return await options.isRuntimeAccessTokenActive?.(claims) ?? { active: true };
|
|
766
990
|
});
|
|
@@ -778,21 +1002,21 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
778
1002
|
};
|
|
779
1003
|
}
|
|
780
1004
|
const path = relayRuntimePath(request);
|
|
781
|
-
if (!
|
|
1005
|
+
if (!path || !sessionScopeReaches(claims.session_id, path, new URL(request.url).search)) {
|
|
782
1006
|
return {
|
|
783
1007
|
ok: false,
|
|
784
|
-
code: "
|
|
1008
|
+
code: "relay_scope_denied",
|
|
785
1009
|
response: await deny(options, {
|
|
786
|
-
code: "
|
|
787
|
-
message: "
|
|
1010
|
+
code: "relay_scope_denied",
|
|
1011
|
+
message: "Runtime Access Token scope does not reach this relay request",
|
|
788
1012
|
status: 403,
|
|
789
1013
|
claims,
|
|
790
1014
|
request
|
|
791
1015
|
})
|
|
792
1016
|
};
|
|
793
1017
|
}
|
|
794
|
-
const target = await
|
|
795
|
-
if (!target || target.workspaceId !== claims.workspace_id || target.hostId !== claims.host_id) {
|
|
1018
|
+
const target = await targetLookup;
|
|
1019
|
+
if (!target || target.workspaceId !== claims.workspace_id || target.hostId !== claims.host_id || !isAllowedRelayTargetBaseUrl(target)) {
|
|
796
1020
|
return {
|
|
797
1021
|
ok: false,
|
|
798
1022
|
code: "relay_target_unavailable",
|
|
@@ -805,13 +1029,13 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
805
1029
|
})
|
|
806
1030
|
};
|
|
807
1031
|
}
|
|
808
|
-
if (target
|
|
1032
|
+
if (isHostTunnelTarget(target) && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
|
|
809
1033
|
return {
|
|
810
1034
|
ok: false,
|
|
811
|
-
code: "
|
|
1035
|
+
code: "host_tunnel_offline",
|
|
812
1036
|
response: await deny(options, {
|
|
813
|
-
code: "
|
|
814
|
-
message: "
|
|
1037
|
+
code: "host_tunnel_offline",
|
|
1038
|
+
message: "The machine serving this workspace is offline",
|
|
815
1039
|
status: 503,
|
|
816
1040
|
claims,
|
|
817
1041
|
request
|
|
@@ -821,7 +1045,9 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
821
1045
|
await span("audit", async () => await audit(options, {
|
|
822
1046
|
action: "relay.request.accepted",
|
|
823
1047
|
result: "allow",
|
|
824
|
-
|
|
1048
|
+
actorId: claims.actor_id,
|
|
1049
|
+
actorKind: claims.actor_kind,
|
|
1050
|
+
principalKind: claims.principal_kind,
|
|
825
1051
|
role: claims.role,
|
|
826
1052
|
workspaceId: claims.workspace_id,
|
|
827
1053
|
hostId: claims.host_id,
|
|
@@ -852,1431 +1078,25 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
852
1078
|
};
|
|
853
1079
|
}
|
|
854
1080
|
}
|
|
855
|
-
async function forwardWorkspaceRelayRequest(request, target, relayHostToken, path, requestFetch, timeoutMs, trace, originAllowed = defaultOriginMatcher) {
|
|
856
|
-
const controller = new AbortController();
|
|
857
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
858
|
-
const span = (name, run) => trace ? trace.span(name, run) : run();
|
|
859
|
-
try {
|
|
860
|
-
const upstream = await span("upstream-fetch", async () => await requestFetch(
|
|
861
|
-
targetUrl(target, path, new URL(request.url).search),
|
|
862
|
-
workspaceRelayForwardRequestInit(request, relayHostToken, target.workspaceId, {
|
|
863
|
-
signal: controller.signal,
|
|
864
|
-
upstreamHeaders: target.upstreamHeaders
|
|
865
|
-
})
|
|
866
|
-
));
|
|
867
|
-
const headers2 = new Headers(upstream.headers);
|
|
868
|
-
headers2.delete("access-control-allow-origin");
|
|
869
|
-
headers2.delete("access-control-allow-credentials");
|
|
870
|
-
headers2.delete("access-control-allow-headers");
|
|
871
|
-
headers2.delete("access-control-allow-methods");
|
|
872
|
-
headers2.delete("access-control-expose-headers");
|
|
873
|
-
headers2.delete("access-control-max-age");
|
|
874
|
-
for (const [key, value] of Object.entries(denyCorsHeaders(request, originAllowed) ?? {})) {
|
|
875
|
-
headers2.set(key, value);
|
|
876
|
-
}
|
|
877
|
-
return new Response(upstream.body, {
|
|
878
|
-
status: upstream.status,
|
|
879
|
-
statusText: upstream.statusText,
|
|
880
|
-
headers: headers2
|
|
881
|
-
});
|
|
882
|
-
} catch (err) {
|
|
883
|
-
const aborted = err instanceof Error && err.name === "AbortError";
|
|
884
|
-
return Response.json(
|
|
885
|
-
errorBody(
|
|
886
|
-
aborted ? "upstream_timeout" : "upstream_unavailable",
|
|
887
|
-
aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable"
|
|
888
|
-
),
|
|
889
|
-
{ status: aborted ? 504 : 503, headers: denyCorsHeaders(request, originAllowed) }
|
|
890
|
-
);
|
|
891
|
-
} finally {
|
|
892
|
-
clearTimeout(timer);
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
function getAuditSuppressedCount(options) {
|
|
896
|
-
return auditSamplers.get(options)?.suppressedCount ?? 0;
|
|
897
|
-
}
|
|
898
|
-
var LOOPBACK_REMOTES = /* @__PURE__ */ new Set([
|
|
899
|
-
"127.0.0.1",
|
|
900
|
-
"::1",
|
|
901
|
-
"::ffff:127.0.0.1"
|
|
902
|
-
]);
|
|
903
|
-
function isLoopbackAddress(address) {
|
|
904
|
-
if (!address) return false;
|
|
905
|
-
const trimmed = address.trim();
|
|
906
|
-
if (LOOPBACK_REMOTES.has(trimmed)) return true;
|
|
907
|
-
if (/^127\./.test(trimmed)) return true;
|
|
908
|
-
if (/^::ffff:127\./i.test(trimmed)) return true;
|
|
909
|
-
return false;
|
|
910
|
-
}
|
|
911
|
-
function checkMetricsAuth(options, request) {
|
|
912
|
-
if (options.metricsToken) {
|
|
913
|
-
const presented = bearerToken(request.headers.get("authorization") ?? void 0);
|
|
914
|
-
if (!presented || presented !== options.metricsToken) {
|
|
915
|
-
return {
|
|
916
|
-
ok: false,
|
|
917
|
-
status: 401,
|
|
918
|
-
code: "metrics_unauthorized",
|
|
919
|
-
message: "Metrics endpoint requires a valid bearer token"
|
|
920
|
-
};
|
|
921
|
-
}
|
|
922
|
-
return { ok: true };
|
|
923
|
-
}
|
|
924
|
-
if (!options.metricsRemoteAddress) {
|
|
925
|
-
return {
|
|
926
|
-
ok: false,
|
|
927
|
-
status: 401,
|
|
928
|
-
code: "metrics_unauthorized",
|
|
929
|
-
message: "Metrics endpoint is restricted to loopback or bearer-token clients"
|
|
930
|
-
};
|
|
931
|
-
}
|
|
932
|
-
const address = options.metricsRemoteAddress(request);
|
|
933
|
-
if (isLoopbackAddress(address)) return { ok: true };
|
|
934
|
-
return {
|
|
935
|
-
ok: false,
|
|
936
|
-
status: 401,
|
|
937
|
-
code: "metrics_unauthorized",
|
|
938
|
-
message: "Metrics endpoint is restricted to loopback or bearer-token clients"
|
|
939
|
-
};
|
|
940
|
-
}
|
|
941
|
-
function buildMetricsBody(options) {
|
|
942
|
-
const sources = options.metricsSources ?? {};
|
|
943
|
-
const fragmentation = sources.fragmentation?.() ?? { fragmentsBuffered: 0, oversizedClosed: 0 };
|
|
944
|
-
const slowConsumer = sources.slowConsumer?.() ?? {
|
|
945
|
-
overflowEvents: 0,
|
|
946
|
-
timerFired: 0,
|
|
947
|
-
droppedRequests: 0
|
|
948
|
-
};
|
|
949
|
-
const activeHostCount = options.directory?.size?.() ?? 0;
|
|
950
|
-
const suppressedAccepts = getAuditSuppressedCount(options);
|
|
951
|
-
const body = {
|
|
952
|
-
fragmentation,
|
|
953
|
-
slowConsumer,
|
|
954
|
-
directory: { activeHostCount },
|
|
955
|
-
audit: { suppressedAccepts }
|
|
956
|
-
};
|
|
957
|
-
if (sources.drainPending) {
|
|
958
|
-
body.drain = { pendingCount: sources.drainPending() };
|
|
959
|
-
}
|
|
960
|
-
return body;
|
|
961
|
-
}
|
|
962
|
-
function createWorkspaceRelay(options) {
|
|
963
|
-
const app = new Hono();
|
|
964
|
-
const originAllowed = originMatcherFor(options);
|
|
965
|
-
app.use("*", cors({
|
|
966
|
-
origin: (origin) => {
|
|
967
|
-
if (!origin) return void 0;
|
|
968
|
-
return originAllowed(origin) ? origin : void 0;
|
|
969
|
-
},
|
|
970
|
-
allowHeaders: [
|
|
971
|
-
"Accept",
|
|
972
|
-
"Authorization",
|
|
973
|
-
"Content-Type",
|
|
974
|
-
"X-Daytona-Skip-Preview-Warning",
|
|
975
|
-
"X-Workspace-Id",
|
|
976
|
-
"X-OpenCode-Directory",
|
|
977
|
-
"X-Claxedo-Runner",
|
|
978
|
-
"X-Claxedo-Model",
|
|
979
|
-
"X-Claxedo-Draft-Id",
|
|
980
|
-
"X-Claxedo-Binary"
|
|
981
|
-
],
|
|
982
|
-
allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
983
|
-
}));
|
|
984
|
-
app.get("/health", (c) => {
|
|
985
|
-
if (options.isDraining?.()) {
|
|
986
|
-
return c.json(
|
|
987
|
-
{ ok: false, service: "workspace-relay", draining: true },
|
|
988
|
-
503
|
|
989
|
-
);
|
|
990
|
-
}
|
|
991
|
-
return c.json({ ok: true, service: "workspace-relay" });
|
|
992
|
-
});
|
|
993
|
-
app.get("/.well-known/jwks.json", async (c) => {
|
|
994
|
-
const sources = options.relayHostPublicKeys ?? [];
|
|
995
|
-
if (sources.length === 0) {
|
|
996
|
-
return c.json(
|
|
997
|
-
{
|
|
998
|
-
error: {
|
|
999
|
-
code: "jwks_no_keys_configured",
|
|
1000
|
-
message: "No Relay Host Token public keys are configured for the JWKS endpoint"
|
|
1001
|
-
}
|
|
1002
|
-
},
|
|
1003
|
-
503
|
|
1004
|
-
);
|
|
1005
|
-
}
|
|
1006
|
-
let keys;
|
|
1007
|
-
try {
|
|
1008
|
-
keys = await Promise.all(sources.map(async (source) => {
|
|
1009
|
-
const jwk = await exportJWK(source.publicKey);
|
|
1010
|
-
return {
|
|
1011
|
-
...jwk,
|
|
1012
|
-
kid: source.kid,
|
|
1013
|
-
alg: "EdDSA",
|
|
1014
|
-
use: "sig"
|
|
1015
|
-
};
|
|
1016
|
-
}));
|
|
1017
|
-
} catch (err) {
|
|
1018
|
-
return c.json(
|
|
1019
|
-
{
|
|
1020
|
-
error: {
|
|
1021
|
-
code: "jwks_invalid_key",
|
|
1022
|
-
message: "A configured RHT public key could not be exported as a JWK",
|
|
1023
|
-
detail: err instanceof Error ? err.message : String(err)
|
|
1024
|
-
}
|
|
1025
|
-
},
|
|
1026
|
-
500
|
|
1027
|
-
);
|
|
1028
|
-
}
|
|
1029
|
-
c.header("cache-control", "public, max-age=300");
|
|
1030
|
-
return c.json({ keys });
|
|
1031
|
-
});
|
|
1032
|
-
app.disposeAuditSampler = () => disposeAuditSampler(options);
|
|
1033
|
-
app.get("/metrics", (c) => {
|
|
1034
|
-
const authResult = checkMetricsAuth(options, c.req.raw);
|
|
1035
|
-
if (!authResult.ok) {
|
|
1036
|
-
return c.json(
|
|
1037
|
-
errorBody(authResult.code, authResult.message),
|
|
1038
|
-
authResult.status
|
|
1039
|
-
);
|
|
1040
|
-
}
|
|
1041
|
-
return c.json(buildMetricsBody(options));
|
|
1042
|
-
});
|
|
1043
|
-
app.all("/workspaces/:workspaceId/*", async (c) => {
|
|
1044
|
-
const trace = createWorkspaceRelayTrace();
|
|
1045
|
-
if (options.isDraining?.()) {
|
|
1046
|
-
return Response.json(
|
|
1047
|
-
errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
|
|
1048
|
-
{ status: 503, headers: denyCorsHeaders(c.req.raw, originAllowed) }
|
|
1049
|
-
);
|
|
1050
|
-
}
|
|
1051
|
-
const response = await trace.span("relay-total", async () => {
|
|
1052
|
-
const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"), trace);
|
|
1053
|
-
if (!relay.ok) return relay.response;
|
|
1054
|
-
return await forwardWorkspaceRelayRequest(
|
|
1055
|
-
new Request(c.req.raw),
|
|
1056
|
-
relay.request.target,
|
|
1057
|
-
relay.request.relayHostToken,
|
|
1058
|
-
relay.request.path,
|
|
1059
|
-
options.fetch ?? fetch,
|
|
1060
|
-
options.forwardTimeoutMs ?? 3e4,
|
|
1061
|
-
trace,
|
|
1062
|
-
originAllowed
|
|
1063
|
-
);
|
|
1064
|
-
});
|
|
1065
|
-
return workspaceRelayTimingResponse(response, trace);
|
|
1066
|
-
});
|
|
1067
|
-
return app;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
// src/bun.ts
|
|
1071
|
-
var TUNNEL_PENDING_HTTP_CAP = 32;
|
|
1072
|
-
var TUNNEL_CHANNEL_CAP = 16;
|
|
1073
|
-
var HOST_TUNNEL_REGISTRATION_RECONNECT_CAP = 5;
|
|
1074
|
-
var HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS = 6e4;
|
|
1075
|
-
var WS_MAX_PAYLOAD_LENGTH_BYTES = 16 * 1024 * 1024;
|
|
1076
|
-
var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
|
|
1077
|
-
var TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1078
|
-
var DIRECT_HTTP_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1079
|
-
var UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT = 1e4;
|
|
1080
|
-
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
|
|
1081
|
-
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1082
|
-
var WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1083
|
-
var relayBufferedBytes = (socket) => {
|
|
1084
|
-
const method = socket.getBufferedAmount;
|
|
1085
|
-
if (typeof method === "function") {
|
|
1086
|
-
const measured = method.call(socket);
|
|
1087
|
-
if (typeof measured === "number" && Number.isFinite(measured)) return measured;
|
|
1088
|
-
return void 0;
|
|
1089
|
-
}
|
|
1090
|
-
const property = socket.bufferedAmount;
|
|
1091
|
-
return typeof property === "number" && Number.isFinite(property) ? property : void 0;
|
|
1092
|
-
};
|
|
1093
|
-
var relayOverBackpressureLimit = (socket, limitBytes) => {
|
|
1094
|
-
const queued = relayBufferedBytes(socket);
|
|
1095
|
-
return queued !== void 0 && queued > limitBytes;
|
|
1096
|
-
};
|
|
1097
|
-
var HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT = 2;
|
|
1098
|
-
var TUNNEL_MESSAGE_BUFFER_CAP_BYTES = 4 * 1024 * 1024;
|
|
1099
|
-
var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1100
|
-
var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1101
|
-
var HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT = 250;
|
|
1102
|
-
function createFragmentationStats() {
|
|
1103
|
-
return {
|
|
1104
|
-
fragmentsBuffered: 0,
|
|
1105
|
-
oversizedClosed: 0
|
|
1106
|
-
};
|
|
1107
|
-
}
|
|
1108
|
-
function createSlowConsumerStats() {
|
|
1109
|
-
return {
|
|
1110
|
-
overflowEvents: 0,
|
|
1111
|
-
timerFired: 0,
|
|
1112
|
-
droppedRequests: 0
|
|
1113
|
-
};
|
|
1114
|
-
}
|
|
1115
|
-
function readFragmentationStats(stats) {
|
|
1116
|
-
return {
|
|
1117
|
-
fragmentsBuffered: stats.fragmentsBuffered,
|
|
1118
|
-
oversizedClosed: stats.oversizedClosed
|
|
1119
|
-
};
|
|
1120
|
-
}
|
|
1121
|
-
function resetFragmentationStats(stats) {
|
|
1122
|
-
stats.fragmentsBuffered = 0;
|
|
1123
|
-
stats.oversizedClosed = 0;
|
|
1124
|
-
}
|
|
1125
|
-
function readSlowConsumerStats(stats) {
|
|
1126
|
-
return { ...stats };
|
|
1127
|
-
}
|
|
1128
|
-
function resetSlowConsumerStats(stats) {
|
|
1129
|
-
stats.overflowEvents = 0;
|
|
1130
|
-
stats.timerFired = 0;
|
|
1131
|
-
stats.droppedRequests = 0;
|
|
1132
|
-
}
|
|
1133
|
-
function createDirectHttpLimiter(limit) {
|
|
1134
|
-
if (!Number.isInteger(limit) || !limit || limit <= 0) return void 0;
|
|
1135
|
-
const max = limit;
|
|
1136
|
-
let active = 0;
|
|
1137
|
-
const queue = [];
|
|
1138
|
-
function drain() {
|
|
1139
|
-
if (active >= max) return;
|
|
1140
|
-
const next = queue.shift();
|
|
1141
|
-
if (!next) return;
|
|
1142
|
-
active++;
|
|
1143
|
-
next();
|
|
1144
|
-
}
|
|
1145
|
-
return {
|
|
1146
|
-
async acquire() {
|
|
1147
|
-
if (active < max) {
|
|
1148
|
-
active++;
|
|
1149
|
-
} else {
|
|
1150
|
-
await new Promise((resolve) => queue.push(resolve));
|
|
1151
|
-
}
|
|
1152
|
-
let released = false;
|
|
1153
|
-
return () => {
|
|
1154
|
-
if (released) return;
|
|
1155
|
-
released = true;
|
|
1156
|
-
active = Math.max(0, active - 1);
|
|
1157
|
-
drain();
|
|
1158
|
-
};
|
|
1159
|
-
}
|
|
1160
|
-
};
|
|
1161
|
-
}
|
|
1162
|
-
function jsonError(code, message, status) {
|
|
1163
|
-
return new Response(JSON.stringify({ error: { code, message } }), {
|
|
1164
|
-
status,
|
|
1165
|
-
headers: { "content-type": "application/json" }
|
|
1166
|
-
});
|
|
1167
|
-
}
|
|
1168
|
-
function corsJsonError(request, code, message, status) {
|
|
1169
|
-
const headers2 = relayCorsHeaders(request);
|
|
1170
|
-
headers2.set("content-type", "application/json");
|
|
1171
|
-
return new Response(JSON.stringify({ error: { code, message } }), { status, headers: headers2 });
|
|
1172
|
-
}
|
|
1173
|
-
function preOpenFrameBytes(message) {
|
|
1174
|
-
return typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
1175
|
-
}
|
|
1176
|
-
function safeCloseCode(input, fallback = 1011) {
|
|
1177
|
-
if (!Number.isInteger(input)) return fallback;
|
|
1178
|
-
const code = input;
|
|
1179
|
-
if (code === 1e3) return code;
|
|
1180
|
-
if (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
|
|
1181
|
-
if (code >= 3e3 && code <= 4999) return code;
|
|
1182
|
-
return fallback;
|
|
1183
|
-
}
|
|
1184
|
-
function closeWebSocket(ws, code, reason, fallback = 1011) {
|
|
1185
|
-
try {
|
|
1186
|
-
ws.close(safeCloseCode(code, fallback), reason);
|
|
1187
|
-
} catch {
|
|
1188
|
-
try {
|
|
1189
|
-
ws.close(fallback, reason);
|
|
1190
|
-
} catch {
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
function upstreamCloseReason(event) {
|
|
1195
|
-
if (event.reason.trim()) return event.reason;
|
|
1196
|
-
if (event.code === 1e3) return "";
|
|
1197
|
-
if (event.code === 1005 || event.code === 1006) return "Upstream WebSocket closed abnormally";
|
|
1198
|
-
return `Upstream WebSocket closed unexpectedly with code ${event.code}`;
|
|
1199
|
-
}
|
|
1200
|
-
function workspaceIdFromPath(pathname) {
|
|
1201
|
-
return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
1202
|
-
}
|
|
1203
|
-
function hostIdFromTunnelPath(pathname) {
|
|
1204
|
-
return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
1205
|
-
}
|
|
1206
|
-
function websocketRequest(request) {
|
|
1207
|
-
return request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
1208
|
-
}
|
|
1209
|
-
function roundedMs2(input) {
|
|
1210
|
-
return Math.round(input * 100) / 100;
|
|
1211
|
-
}
|
|
1212
|
-
function relayWebSocketTraceEnabled(request) {
|
|
1213
|
-
return request.headers.get("x-claxedo-relay-ws-trace") === "1";
|
|
1214
|
-
}
|
|
1215
|
-
function sendRelayWebSocketTrace(ws) {
|
|
1216
|
-
const trace = ws.data.trace;
|
|
1217
|
-
if (!trace || trace.emitted) return;
|
|
1218
|
-
trace.emitted = true;
|
|
1219
|
-
ws.send(JSON.stringify({
|
|
1220
|
-
type: "relay.trace",
|
|
1221
|
-
wsUpstreamOpenMs: trace.upstreamOpenMs === void 0 ? void 0 : roundedMs2(trace.upstreamOpenMs),
|
|
1222
|
-
queuedFrames: trace.queuedFrames,
|
|
1223
|
-
maxQueuedDelayMs: roundedMs2(trace.maxQueuedDelayMs)
|
|
1224
|
-
}));
|
|
1225
|
-
}
|
|
1226
|
-
function relayWebSocketPayload(input) {
|
|
1227
|
-
if (typeof input === "string") return input;
|
|
1228
|
-
if (input instanceof ArrayBuffer) return input;
|
|
1229
|
-
if (ArrayBuffer.isView(input)) {
|
|
1230
|
-
const copy = new Uint8Array(input.byteLength);
|
|
1231
|
-
copy.set(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
|
|
1232
|
-
return copy;
|
|
1233
|
-
}
|
|
1234
|
-
}
|
|
1235
|
-
function headersRecord(headers2) {
|
|
1236
|
-
const result = {};
|
|
1237
|
-
headers2.forEach((value, key) => {
|
|
1238
|
-
result[key] = value;
|
|
1239
|
-
});
|
|
1240
|
-
return result;
|
|
1241
|
-
}
|
|
1242
|
-
function headers(input) {
|
|
1243
|
-
const result = new Headers();
|
|
1244
|
-
for (const [key, value] of Object.entries(input)) {
|
|
1245
|
-
result.set(key, value);
|
|
1246
|
-
}
|
|
1247
|
-
return result;
|
|
1248
|
-
}
|
|
1249
|
-
function isEventStream(input) {
|
|
1250
|
-
return Object.entries(input).some(
|
|
1251
|
-
([key, value]) => key.toLowerCase() === "content-type" && value.toLowerCase().includes("text/event-stream")
|
|
1252
|
-
);
|
|
1253
|
-
}
|
|
1254
|
-
var defaultRelayOriginMatcher = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
|
|
1255
|
-
function allowedCorsOrigin(origin) {
|
|
1256
|
-
if (!origin) return;
|
|
1257
|
-
if (defaultRelayOriginMatcher(origin)) return origin;
|
|
1258
|
-
}
|
|
1259
|
-
function requireAllowedOrigin(request) {
|
|
1260
|
-
const origin = request.headers.get("origin");
|
|
1261
|
-
if (allowedCorsOrigin(origin)) return null;
|
|
1262
|
-
return jsonError(
|
|
1263
|
-
"origin_not_allowed",
|
|
1264
|
-
"Origin is not in the allowlist",
|
|
1265
|
-
403
|
|
1266
|
-
);
|
|
1267
|
-
}
|
|
1268
|
-
function relayCorsHeaders(request, input = new Headers()) {
|
|
1269
|
-
const result = new Headers(input);
|
|
1270
|
-
result.delete("access-control-allow-origin");
|
|
1271
|
-
result.delete("access-control-allow-credentials");
|
|
1272
|
-
result.delete("access-control-allow-headers");
|
|
1273
|
-
result.delete("access-control-allow-methods");
|
|
1274
|
-
result.delete("access-control-expose-headers");
|
|
1275
|
-
result.delete("access-control-max-age");
|
|
1276
|
-
const origin = allowedCorsOrigin(request.headers.get("origin"));
|
|
1277
|
-
if (origin) {
|
|
1278
|
-
result.set("access-control-allow-origin", origin);
|
|
1279
|
-
result.set("access-control-allow-headers", "Accept, Authorization, Content-Type, Last-Event-ID, 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");
|
|
1280
|
-
result.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
1281
|
-
}
|
|
1282
|
-
return result;
|
|
1283
|
-
}
|
|
1284
|
-
function encoded(input) {
|
|
1285
|
-
return Buffer.from(input).toString("base64");
|
|
1286
|
-
}
|
|
1287
|
-
function decoded(input) {
|
|
1288
|
-
return new Uint8Array(Buffer.from(input, "base64"));
|
|
1289
|
-
}
|
|
1290
|
-
function encodedFrame(message) {
|
|
1291
|
-
if (typeof message === "string") {
|
|
1292
|
-
return {
|
|
1293
|
-
binary: false,
|
|
1294
|
-
data_base64: Buffer.from(message).toString("base64")
|
|
1295
|
-
};
|
|
1296
|
-
}
|
|
1297
|
-
return {
|
|
1298
|
-
binary: true,
|
|
1299
|
-
data_base64: Buffer.from(message).toString("base64")
|
|
1300
|
-
};
|
|
1301
|
-
}
|
|
1302
|
-
function decodedFrame(frame) {
|
|
1303
|
-
const body = Buffer.from(frame.data_base64, "base64");
|
|
1304
|
-
return frame.binary ? new Uint8Array(body) : body.toString("utf8");
|
|
1305
|
-
}
|
|
1306
|
-
function tunnelMessage(ws, input, stats) {
|
|
1307
|
-
if (typeof input !== "string") return;
|
|
1308
|
-
const combined = ws.data.messageBuffer.length > 0 ? ws.data.messageBuffer + input : input;
|
|
1309
|
-
let parsed;
|
|
1310
|
-
try {
|
|
1311
|
-
parsed = JSON.parse(combined);
|
|
1312
|
-
} catch {
|
|
1313
|
-
if (combined.length > TUNNEL_MESSAGE_BUFFER_CAP_BYTES) {
|
|
1314
|
-
stats.oversizedClosed += 1;
|
|
1315
|
-
ws.data.messageBuffer = "";
|
|
1316
|
-
ws.close(1009, "Tunnel message buffer exceeded 4 MB");
|
|
1317
|
-
return;
|
|
1318
|
-
}
|
|
1319
|
-
stats.fragmentsBuffered += 1;
|
|
1320
|
-
ws.data.messageBuffer = combined;
|
|
1321
|
-
return;
|
|
1322
|
-
}
|
|
1323
|
-
ws.data.messageBuffer = "";
|
|
1324
|
-
const validated = validateTunnelMessage(parsed);
|
|
1325
|
-
if (validated.ok) return validated.message;
|
|
1326
|
-
if (validated.reason === "protocol_mismatch") {
|
|
1327
|
-
ws.close(1002, `Tunnel protocol mismatch: expected ${validated.expected_protocol}`);
|
|
1328
|
-
}
|
|
1329
|
-
return;
|
|
1330
|
-
}
|
|
1331
|
-
function sendTunnelPing(ws) {
|
|
1332
|
-
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1333
|
-
ws.data.missedPongs += 1;
|
|
1334
|
-
ws.send(JSON.stringify(makeTunnelPing()));
|
|
1335
|
-
}
|
|
1336
|
-
function clearPendingTimers(entry) {
|
|
1337
|
-
clearTimeout(entry.timeout);
|
|
1338
|
-
if (entry.slowConsumerTimeout) {
|
|
1339
|
-
clearTimeout(entry.slowConsumerTimeout);
|
|
1340
|
-
entry.slowConsumerTimeout = void 0;
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
function failPendingHttpResponse(input) {
|
|
1344
|
-
clearPendingTimers(input.entry);
|
|
1345
|
-
input.entry.pendingChunks.length = 0;
|
|
1346
|
-
input.entry.bytesQueued = 0;
|
|
1347
|
-
try {
|
|
1348
|
-
input.entry.controller.error(input.error);
|
|
1349
|
-
} catch {
|
|
1350
|
-
}
|
|
1351
|
-
if (!input.entry.responseStarted) {
|
|
1352
|
-
if (input.response) {
|
|
1353
|
-
input.entry.resolve(input.response);
|
|
1354
|
-
return;
|
|
1355
|
-
}
|
|
1356
|
-
input.entry.reject(input.error);
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
function cleanupHostTunnelSocket(input) {
|
|
1360
|
-
if (input.ws.data.heartbeat) {
|
|
1361
|
-
clearInterval(input.ws.data.heartbeat);
|
|
1362
|
-
input.ws.data.heartbeat = void 0;
|
|
1363
|
-
}
|
|
1364
|
-
for (const pending of input.ws.data.pending.values()) {
|
|
1365
|
-
failPendingHttpResponse({
|
|
1366
|
-
entry: pending,
|
|
1367
|
-
response: input.request ? corsJsonError(input.request, "user_hosted_app_offline", "User-hosted workspace is offline", 503) : jsonError("user_hosted_app_offline", "User-hosted workspace is offline", 503),
|
|
1368
|
-
error: new Error("User-hosted tunnel disconnected")
|
|
1369
|
-
});
|
|
1370
|
-
}
|
|
1371
|
-
input.ws.data.pending.clear();
|
|
1372
|
-
for (const channel of input.ws.data.channels.values()) {
|
|
1373
|
-
if (input.closeChannels) closeWebSocket(channel, 1011, "User-hosted tunnel disconnected");
|
|
1374
|
-
}
|
|
1375
|
-
input.ws.data.channels.clear();
|
|
1376
|
-
if (!input.disconnectDirectory) return;
|
|
1377
|
-
if (input.hostTunnels.get(input.ws.data.hostId) === input.ws) {
|
|
1378
|
-
input.hostTunnels.delete(input.ws.data.hostId);
|
|
1379
|
-
input.options.directory?.disconnectHost(input.ws.data.hostId);
|
|
1380
|
-
scheduleHostTunnelStateChange(input.hostTunnelStateDebounce, input.options, {
|
|
1381
|
-
hostId: input.ws.data.hostId,
|
|
1382
|
-
workspaceId: input.ws.data.workspaceIds[0],
|
|
1383
|
-
path: `/host-tunnels/${input.ws.data.hostId}`,
|
|
1384
|
-
state: "disconnected",
|
|
1385
|
-
debounceMs: input.bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
|
|
1386
|
-
});
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
async function audit2(options, input) {
|
|
1390
|
-
await options.audit?.({
|
|
1391
|
-
action: input.action,
|
|
1392
|
-
result: "allow",
|
|
1393
|
-
hostId: input.hostId,
|
|
1394
|
-
workspaceId: input.workspaceId,
|
|
1395
|
-
method: "WEBSOCKET",
|
|
1396
|
-
path: input.path
|
|
1397
|
-
});
|
|
1398
|
-
}
|
|
1399
|
-
function scheduleHostTunnelStateChange(state, options, input) {
|
|
1400
|
-
const existing = state.get(input.hostId);
|
|
1401
|
-
const entry = existing ?? {
|
|
1402
|
-
intendedState: "disconnected",
|
|
1403
|
-
lastWritten: "disconnected",
|
|
1404
|
-
flushTimer: void 0,
|
|
1405
|
-
pending: { workspaceId: input.workspaceId, path: input.path }
|
|
1406
|
-
};
|
|
1407
|
-
entry.intendedState = input.state;
|
|
1408
|
-
entry.pending = { workspaceId: input.workspaceId, path: input.path };
|
|
1409
|
-
if (entry.flushTimer) clearTimeout(entry.flushTimer);
|
|
1410
|
-
entry.flushTimer = setTimeout(() => {
|
|
1411
|
-
entry.flushTimer = void 0;
|
|
1412
|
-
if (entry.intendedState === entry.lastWritten) {
|
|
1413
|
-
if (entry.intendedState === "disconnected") {
|
|
1414
|
-
state.delete(input.hostId);
|
|
1415
|
-
}
|
|
1416
|
-
return;
|
|
1417
|
-
}
|
|
1418
|
-
entry.lastWritten = entry.intendedState;
|
|
1419
|
-
void audit2(options, {
|
|
1420
|
-
action: entry.intendedState === "connected" ? "host_tunnel.connected" : "host_tunnel.disconnected",
|
|
1421
|
-
hostId: input.hostId,
|
|
1422
|
-
workspaceId: entry.pending.workspaceId,
|
|
1423
|
-
path: entry.pending.path
|
|
1424
|
-
});
|
|
1425
|
-
}, input.debounceMs);
|
|
1426
|
-
state.set(input.hostId, entry);
|
|
1427
|
-
}
|
|
1428
|
-
function drainPendingChunks(entry) {
|
|
1429
|
-
while (entry.pendingChunks.length > 0) {
|
|
1430
|
-
const desired = entry.controller.desiredSize;
|
|
1431
|
-
if (desired !== null && desired <= 0) break;
|
|
1432
|
-
const next = entry.pendingChunks.shift();
|
|
1433
|
-
entry.bytesQueued -= next.byteLength;
|
|
1434
|
-
try {
|
|
1435
|
-
entry.controller.enqueue(next);
|
|
1436
|
-
} catch {
|
|
1437
|
-
entry.pendingChunks.length = 0;
|
|
1438
|
-
entry.bytesQueued = 0;
|
|
1439
|
-
break;
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
if (entry.pendingChunks.length === 0 && entry.slowConsumerTimeout) {
|
|
1443
|
-
clearTimeout(entry.slowConsumerTimeout);
|
|
1444
|
-
entry.slowConsumerTimeout = void 0;
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
function enqueueChunkWithBackpressure(input) {
|
|
1448
|
-
const { entry, chunk } = input;
|
|
1449
|
-
const desired = entry.controller.desiredSize;
|
|
1450
|
-
const overflowing = desired !== null && desired <= 0;
|
|
1451
|
-
if (overflowing || entry.pendingChunks.length > 0) {
|
|
1452
|
-
entry.pendingChunks.push(chunk);
|
|
1453
|
-
entry.bytesQueued += chunk.byteLength;
|
|
1454
|
-
if (!entry.slowConsumerTimeout) {
|
|
1455
|
-
input.slowConsumerStats.overflowEvents += 1;
|
|
1456
|
-
entry.slowConsumerTimeout = setTimeout(() => {
|
|
1457
|
-
input.slowConsumerStats.timerFired += 1;
|
|
1458
|
-
const error = new Error("slow_consumer_timeout: downstream consumer did not drain in time");
|
|
1459
|
-
try {
|
|
1460
|
-
entry.controller.error(error);
|
|
1461
|
-
} catch {
|
|
1462
|
-
}
|
|
1463
|
-
clearTimeout(entry.timeout);
|
|
1464
|
-
entry.pendingChunks.length = 0;
|
|
1465
|
-
entry.bytesQueued = 0;
|
|
1466
|
-
if (!entry.responseStarted) {
|
|
1467
|
-
entry.resolve(jsonError("slow_consumer_timeout", "Downstream consumer did not drain in time", 503));
|
|
1468
|
-
}
|
|
1469
|
-
input.ws.data.pending.delete(input.requestId);
|
|
1470
|
-
input.slowConsumerStats.droppedRequests += 1;
|
|
1471
|
-
}, input.slowConsumerTimeoutMs);
|
|
1472
|
-
}
|
|
1473
|
-
return;
|
|
1474
|
-
}
|
|
1475
|
-
try {
|
|
1476
|
-
entry.controller.enqueue(chunk);
|
|
1477
|
-
} catch {
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
var __slowConsumerInternalsForTest = {
|
|
1481
|
-
enqueueChunkWithBackpressure,
|
|
1482
|
-
createSlowConsumerStats
|
|
1483
|
-
};
|
|
1484
|
-
async function tunnelHttpRequest(input) {
|
|
1485
|
-
if (input.ws.readyState !== WebSocket.OPEN) {
|
|
1486
|
-
return new Response("User-hosted workspace is offline", { status: 503 });
|
|
1487
|
-
}
|
|
1488
|
-
if (input.ws.data.pending.size >= TUNNEL_PENDING_HTTP_CAP) {
|
|
1489
|
-
return jsonError(
|
|
1490
|
-
"too_many_in_flight",
|
|
1491
|
-
"User-hosted tunnel has too many in-flight HTTP requests",
|
|
1492
|
-
429
|
|
1493
|
-
);
|
|
1494
|
-
}
|
|
1495
|
-
const requestId = crypto.randomUUID();
|
|
1496
|
-
let controller;
|
|
1497
|
-
const stream = new ReadableStream({
|
|
1498
|
-
start(next) {
|
|
1499
|
-
controller = next;
|
|
1500
|
-
},
|
|
1501
|
-
pull() {
|
|
1502
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1503
|
-
if (!entry) return;
|
|
1504
|
-
drainPendingChunks(entry);
|
|
1505
|
-
},
|
|
1506
|
-
cancel() {
|
|
1507
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1508
|
-
if (!entry) return;
|
|
1509
|
-
input.ws.data.pending.delete(requestId);
|
|
1510
|
-
clearPendingTimers(entry);
|
|
1511
|
-
entry.pendingChunks.length = 0;
|
|
1512
|
-
entry.bytesQueued = 0;
|
|
1513
|
-
if (input.ws.readyState === WebSocket.OPEN) {
|
|
1514
|
-
input.ws.send(JSON.stringify({
|
|
1515
|
-
type: "http.response.flow",
|
|
1516
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
1517
|
-
request_id: requestId,
|
|
1518
|
-
paused: false,
|
|
1519
|
-
reason: "closed"
|
|
1520
|
-
}));
|
|
1521
|
-
}
|
|
1522
|
-
}
|
|
1523
|
-
}, new ByteLengthQueuingStrategy({ highWaterMark: input.slowConsumerHighWaterMarkBytes }));
|
|
1524
|
-
const pending = new Promise((resolve, reject) => {
|
|
1525
|
-
const timeout = setTimeout(() => {
|
|
1526
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1527
|
-
if (!entry) return;
|
|
1528
|
-
input.ws.data.pending.delete(requestId);
|
|
1529
|
-
if (entry.responseStarted) {
|
|
1530
|
-
clearPendingTimers(entry);
|
|
1531
|
-
entry.pendingChunks.length = 0;
|
|
1532
|
-
entry.bytesQueued = 0;
|
|
1533
|
-
try {
|
|
1534
|
-
entry.controller.close();
|
|
1535
|
-
} catch {
|
|
1536
|
-
}
|
|
1537
|
-
return;
|
|
1538
|
-
}
|
|
1539
|
-
const error = new Error("User-hosted tunnel response timed out");
|
|
1540
|
-
failPendingHttpResponse({
|
|
1541
|
-
entry,
|
|
1542
|
-
response: corsJsonError(input.request, "user_hosted_tunnel_timeout", "User-hosted tunnel response timed out", 504),
|
|
1543
|
-
error
|
|
1544
|
-
});
|
|
1545
|
-
}, input.responseTimeoutMs);
|
|
1546
|
-
input.ws.data.pending.set(requestId, {
|
|
1547
|
-
controller,
|
|
1548
|
-
stream,
|
|
1549
|
-
resolve,
|
|
1550
|
-
reject,
|
|
1551
|
-
timeout,
|
|
1552
|
-
pendingChunks: [],
|
|
1553
|
-
bytesQueued: 0,
|
|
1554
|
-
responseStarted: false
|
|
1555
|
-
});
|
|
1556
|
-
});
|
|
1557
|
-
const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : await readBoundedBody(input.request, input.requestBodyMaxBytes);
|
|
1558
|
-
if (body && "response" in body) {
|
|
1559
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1560
|
-
if (entry) clearPendingTimers(entry);
|
|
1561
|
-
input.ws.data.pending.delete(requestId);
|
|
1562
|
-
return body.response;
|
|
1563
|
-
}
|
|
1564
|
-
input.ws.send(JSON.stringify({
|
|
1565
|
-
type: "http.request",
|
|
1566
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
1567
|
-
request_id: requestId,
|
|
1568
|
-
workspace_id: input.workspaceId,
|
|
1569
|
-
method: input.request.method,
|
|
1570
|
-
path: input.path,
|
|
1571
|
-
headers: headersRecord(workspaceRelayForwardHeaders(
|
|
1572
|
-
input.request.headers,
|
|
1573
|
-
input.relayHostToken,
|
|
1574
|
-
input.workspaceId,
|
|
1575
|
-
// T26: user-hosted tunnel — strip Cookie to avoid leaking browser
|
|
1576
|
-
// cookies to the host process running on the user's laptop.
|
|
1577
|
-
{ userHosted: true }
|
|
1578
|
-
)),
|
|
1579
|
-
...body?.bodyBase64 ? { body_base64: body.bodyBase64 } : {},
|
|
1580
|
-
end: true
|
|
1581
|
-
}));
|
|
1582
|
-
return await pending;
|
|
1583
|
-
}
|
|
1584
|
-
async function readBoundedBody(request, maxBytes) {
|
|
1585
|
-
if (!request.body) return void 0;
|
|
1586
|
-
const reader = request.body.getReader();
|
|
1587
|
-
const chunks = [];
|
|
1588
|
-
let total = 0;
|
|
1589
|
-
for (; ; ) {
|
|
1590
|
-
const next = await reader.read();
|
|
1591
|
-
if (next.done) break;
|
|
1592
|
-
total += next.value.byteLength;
|
|
1593
|
-
if (total > maxBytes) {
|
|
1594
|
-
try {
|
|
1595
|
-
await reader.cancel();
|
|
1596
|
-
} catch {
|
|
1597
|
-
}
|
|
1598
|
-
return {
|
|
1599
|
-
response: corsJsonError(request, "request_body_too_large", "Tunnel request body exceeds the relay limit", 413)
|
|
1600
|
-
};
|
|
1601
|
-
}
|
|
1602
|
-
chunks.push(next.value);
|
|
1603
|
-
}
|
|
1604
|
-
const body = new Uint8Array(total);
|
|
1605
|
-
chunks.reduce((offset, chunk) => {
|
|
1606
|
-
body.set(chunk, offset);
|
|
1607
|
-
return offset + chunk.byteLength;
|
|
1608
|
-
}, 0);
|
|
1609
|
-
return { bodyBase64: encoded(body.buffer) };
|
|
1610
|
-
}
|
|
1611
|
-
async function directHttpRequest(input) {
|
|
1612
|
-
const controller = new AbortController();
|
|
1613
|
-
const timer = setTimeout(() => controller.abort(), input.timeoutMs);
|
|
1614
|
-
const span = (name, run) => input.trace ? input.trace.span(name, run) : run();
|
|
1615
|
-
try {
|
|
1616
|
-
const init = workspaceRelayForwardRequestInit(
|
|
1617
|
-
input.request,
|
|
1618
|
-
input.relayHostToken,
|
|
1619
|
-
input.workspaceId,
|
|
1620
|
-
{
|
|
1621
|
-
// T26: cloud-vm direct path. Cookies pass through (default) — workspace
|
|
1622
|
-
// dashboards in the VM may legitimately need session cookies.
|
|
1623
|
-
userHosted: false,
|
|
1624
|
-
upstreamHeaders: input.upstreamHeaders,
|
|
1625
|
-
signal: controller.signal
|
|
1626
|
-
}
|
|
1627
|
-
);
|
|
1628
|
-
if (input.request.method !== "GET" && input.request.method !== "HEAD" && input.request.body) {
|
|
1629
|
-
init.body = await input.request.arrayBuffer();
|
|
1630
|
-
}
|
|
1631
|
-
const limiter = input.limiter;
|
|
1632
|
-
const release = limiter ? await span("direct-http-queue", () => limiter.acquire()) : void 0;
|
|
1633
|
-
try {
|
|
1634
|
-
const upstream = await span("upstream-fetch", async () => await fetch(input.targetUrl, init));
|
|
1635
|
-
return new Response(upstream.body, {
|
|
1636
|
-
status: upstream.status,
|
|
1637
|
-
statusText: upstream.statusText,
|
|
1638
|
-
headers: relayCorsHeaders(input.request, upstream.headers)
|
|
1639
|
-
});
|
|
1640
|
-
} finally {
|
|
1641
|
-
release?.();
|
|
1642
|
-
}
|
|
1643
|
-
} catch (err) {
|
|
1644
|
-
const aborted = err instanceof Error && err.name === "AbortError";
|
|
1645
|
-
return corsJsonError(
|
|
1646
|
-
input.request,
|
|
1647
|
-
aborted ? "upstream_timeout" : "upstream_unavailable",
|
|
1648
|
-
aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable",
|
|
1649
|
-
aborted ? 504 : 503
|
|
1650
|
-
);
|
|
1651
|
-
} finally {
|
|
1652
|
-
clearTimeout(timer);
|
|
1653
|
-
}
|
|
1654
|
-
}
|
|
1655
|
-
function hostTunnel(hostTunnels, hostId) {
|
|
1656
|
-
const tunnel = hostTunnels.get(hostId);
|
|
1657
|
-
if (tunnel?.readyState === WebSocket.OPEN) return tunnel;
|
|
1658
|
-
return void 0;
|
|
1659
|
-
}
|
|
1660
|
-
async function authorizeHostTunnel(options, bunOptions, request, input) {
|
|
1661
|
-
if (bunOptions.authorizeHostTunnel) return await bunOptions.authorizeHostTunnel(request, input);
|
|
1662
|
-
const token = bearerToken(request.headers.get("authorization"));
|
|
1663
|
-
if (!token) return false;
|
|
1664
|
-
try {
|
|
1665
|
-
await verifyHostTunnelToken(token, options.runtimeAccessKey, input);
|
|
1666
|
-
return true;
|
|
1667
|
-
} catch (err) {
|
|
1668
|
-
if (err instanceof WorkspaceRelayAuthError) return false;
|
|
1669
|
-
throw err;
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
1673
|
-
const hostTunnels = /* @__PURE__ */ new Map();
|
|
1674
|
-
const relayClients = /* @__PURE__ */ new Set();
|
|
1675
|
-
const hostTunnelRegistrations = /* @__PURE__ */ new Map();
|
|
1676
|
-
const directHttpLimiter = createDirectHttpLimiter(bunOptions.directHttpConcurrency);
|
|
1677
|
-
const hostTunnelStateDebounce = /* @__PURE__ */ new Map();
|
|
1678
|
-
const fragmentationStats = createFragmentationStats();
|
|
1679
|
-
const slowConsumerStats = createSlowConsumerStats();
|
|
1680
|
-
const telemetry = {
|
|
1681
|
-
getFragmentationStats: () => readFragmentationStats(fragmentationStats),
|
|
1682
|
-
resetFragmentationStats: () => resetFragmentationStats(fragmentationStats),
|
|
1683
|
-
getSlowConsumerStats: () => readSlowConsumerStats(slowConsumerStats),
|
|
1684
|
-
resetSlowConsumerStats: () => resetSlowConsumerStats(slowConsumerStats)
|
|
1685
|
-
};
|
|
1686
|
-
let draining = false;
|
|
1687
|
-
const drainController = {
|
|
1688
|
-
isDraining: () => draining,
|
|
1689
|
-
setDraining: (value) => {
|
|
1690
|
-
draining = value;
|
|
1691
|
-
if (!value) return;
|
|
1692
|
-
for (const tunnel of hostTunnels.values()) {
|
|
1693
|
-
closeWebSocket(tunnel, 1012, "Workspace relay is draining", 1012);
|
|
1694
|
-
}
|
|
1695
|
-
for (const client of relayClients) {
|
|
1696
|
-
closeWebSocket(client, 1012, "Workspace relay is draining", 1012);
|
|
1697
|
-
}
|
|
1698
|
-
},
|
|
1699
|
-
pendingCount: () => {
|
|
1700
|
-
let total = 0;
|
|
1701
|
-
for (const tunnel of hostTunnels.values()) {
|
|
1702
|
-
total += tunnel.data.pending.size;
|
|
1703
|
-
}
|
|
1704
|
-
return total;
|
|
1705
|
-
},
|
|
1706
|
-
waitForDrain: async (timeoutMs) => {
|
|
1707
|
-
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
1708
|
-
const pollMs = 50;
|
|
1709
|
-
while (true) {
|
|
1710
|
-
const remaining = drainController.pendingCount();
|
|
1711
|
-
if (remaining === 0) return { drained: true, remaining: 0 };
|
|
1712
|
-
const now = Date.now();
|
|
1713
|
-
if (now >= deadline) return { drained: false, remaining };
|
|
1714
|
-
const sleep = Math.min(pollMs, Math.max(1, deadline - now));
|
|
1715
|
-
await new Promise((resolve) => setTimeout(resolve, sleep));
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1718
|
-
};
|
|
1719
|
-
const mergedMetricsSources = {
|
|
1720
|
-
fragmentation: options.metricsSources?.fragmentation ?? telemetry.getFragmentationStats,
|
|
1721
|
-
slowConsumer: options.metricsSources?.slowConsumer ?? telemetry.getSlowConsumerStats,
|
|
1722
|
-
drainPending: options.metricsSources?.drainPending ?? drainController.pendingCount
|
|
1723
|
-
};
|
|
1724
|
-
const requestRemoteAddress = /* @__PURE__ */ new WeakMap();
|
|
1725
|
-
const metricsRemoteAddressResolver = options.metricsRemoteAddress ?? ((request) => requestRemoteAddress.get(request));
|
|
1726
|
-
const app = createWorkspaceRelay({
|
|
1727
|
-
...options,
|
|
1728
|
-
isDraining: drainController.isDraining,
|
|
1729
|
-
metricsSources: mergedMetricsSources,
|
|
1730
|
-
metricsRemoteAddress: metricsRemoteAddressResolver
|
|
1731
|
-
});
|
|
1732
|
-
function getRegistrationTracker(hostId) {
|
|
1733
|
-
let tracker = hostTunnelRegistrations.get(hostId);
|
|
1734
|
-
if (!tracker) {
|
|
1735
|
-
tracker = { recent: [] };
|
|
1736
|
-
hostTunnelRegistrations.set(hostId, tracker);
|
|
1737
|
-
}
|
|
1738
|
-
return tracker;
|
|
1739
|
-
}
|
|
1740
|
-
function pruneReconnects(tracker, now) {
|
|
1741
|
-
const cutoff = now - HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS;
|
|
1742
|
-
while (tracker.recent.length && tracker.recent[0] < cutoff) {
|
|
1743
|
-
tracker.recent.shift();
|
|
1744
|
-
}
|
|
1745
|
-
}
|
|
1746
|
-
return {
|
|
1747
|
-
async fetch(request, server) {
|
|
1748
|
-
const url = new URL(request.url);
|
|
1749
|
-
const workspaceId = workspaceIdFromPath(url.pathname);
|
|
1750
|
-
const hostId = hostIdFromTunnelPath(url.pathname);
|
|
1751
|
-
try {
|
|
1752
|
-
const ip = server.requestIP(request)?.address;
|
|
1753
|
-
if (ip) requestRemoteAddress.set(request, ip);
|
|
1754
|
-
} catch {
|
|
1755
|
-
}
|
|
1756
|
-
if (url.pathname === "/health") {
|
|
1757
|
-
return Response.json(
|
|
1758
|
-
draining ? { ok: false, service: "workspace-relay", draining: true } : { ok: true, service: "workspace-relay" },
|
|
1759
|
-
{ status: draining ? 503 : 200 }
|
|
1760
|
-
);
|
|
1761
|
-
}
|
|
1762
|
-
if (draining && websocketRequest(request) && hostId) {
|
|
1763
|
-
return jsonError(
|
|
1764
|
-
"relay_draining",
|
|
1765
|
-
"Workspace relay is shutting down; new tunnel registrations are not accepted",
|
|
1766
|
-
503
|
|
1767
|
-
);
|
|
1768
|
-
}
|
|
1769
|
-
if (draining && workspaceId) {
|
|
1770
|
-
const headers2 = relayCorsHeaders(request);
|
|
1771
|
-
const body = JSON.stringify({
|
|
1772
|
-
error: {
|
|
1773
|
-
code: "relay_draining",
|
|
1774
|
-
message: "Workspace relay is shutting down; try another instance"
|
|
1775
|
-
}
|
|
1776
|
-
});
|
|
1777
|
-
headers2.set("content-type", "application/json");
|
|
1778
|
-
return new Response(body, { status: 503, headers: headers2 });
|
|
1779
|
-
}
|
|
1780
|
-
if (websocketRequest(request) && hostId) {
|
|
1781
|
-
const workspaceIds = url.searchParams.getAll("workspaceId");
|
|
1782
|
-
if (!workspaceIds.length || !options.directory) {
|
|
1783
|
-
return new Response("Host tunnel registration is unavailable", { status: 503 });
|
|
1784
|
-
}
|
|
1785
|
-
if (!await authorizeHostTunnel(options, bunOptions, request, { hostId, workspaceIds })) {
|
|
1786
|
-
return new Response("Host tunnel registration denied", { status: 403 });
|
|
1787
|
-
}
|
|
1788
|
-
const tracker = getRegistrationTracker(hostId);
|
|
1789
|
-
const now = Date.now();
|
|
1790
|
-
pruneReconnects(tracker, now);
|
|
1791
|
-
if (tracker.recent.length >= HOST_TUNNEL_REGISTRATION_RECONNECT_CAP) {
|
|
1792
|
-
return jsonError(
|
|
1793
|
-
"too_many_host_tunnel_reconnects",
|
|
1794
|
-
"Too many host-tunnel reconnects for this host within the last 60 seconds",
|
|
1795
|
-
429
|
|
1796
|
-
);
|
|
1797
|
-
}
|
|
1798
|
-
if (server.upgrade(request, {
|
|
1799
|
-
data: {
|
|
1800
|
-
kind: "host-tunnel",
|
|
1801
|
-
hostId,
|
|
1802
|
-
workspaceIds,
|
|
1803
|
-
pending: /* @__PURE__ */ new Map(),
|
|
1804
|
-
channels: /* @__PURE__ */ new Map(),
|
|
1805
|
-
missedPongs: 0,
|
|
1806
|
-
messageBuffer: ""
|
|
1807
|
-
}
|
|
1808
|
-
})) {
|
|
1809
|
-
tracker.recent.push(now);
|
|
1810
|
-
return;
|
|
1811
|
-
}
|
|
1812
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
1813
|
-
}
|
|
1814
|
-
if (!workspaceId) return app.request(request);
|
|
1815
|
-
if (request.method === "OPTIONS") {
|
|
1816
|
-
return new Response(null, {
|
|
1817
|
-
status: 204,
|
|
1818
|
-
headers: relayCorsHeaders(request)
|
|
1819
|
-
});
|
|
1820
|
-
}
|
|
1821
|
-
if (!websocketRequest(request)) {
|
|
1822
|
-
const trace = createWorkspaceRelayTrace();
|
|
1823
|
-
const response = await trace.span("relay-total", async () => {
|
|
1824
|
-
const relay2 = await authorizeWorkspaceRelayRequest(options, request, workspaceId, trace);
|
|
1825
|
-
if (!relay2.ok) return relay2.response;
|
|
1826
|
-
if (relay2.request.target.access === "user-hosted") {
|
|
1827
|
-
const tunnel = hostTunnel(hostTunnels, relay2.request.target.hostId);
|
|
1828
|
-
return tunnel ? await trace.span("tunnel-http", async () => await tunnelHttpRequest({
|
|
1829
|
-
ws: tunnel,
|
|
1830
|
-
request,
|
|
1831
|
-
workspaceId: relay2.request.target.workspaceId,
|
|
1832
|
-
path: `${relay2.request.path}${url.search}`,
|
|
1833
|
-
relayHostToken: relay2.request.relayHostToken,
|
|
1834
|
-
slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
|
|
1835
|
-
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
|
|
1836
|
-
slowConsumerStats,
|
|
1837
|
-
requestBodyMaxBytes: bunOptions.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT,
|
|
1838
|
-
responseTimeoutMs: bunOptions.tunnelHttpResponseTimeoutMs ?? TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT
|
|
1839
|
-
})) : new Response("User-hosted workspace is offline", { status: 503 });
|
|
1840
|
-
}
|
|
1841
|
-
return await directHttpRequest({
|
|
1842
|
-
request,
|
|
1843
|
-
targetUrl: workspaceRelayTargetUrl(
|
|
1844
|
-
relay2.request.target,
|
|
1845
|
-
relay2.request.path,
|
|
1846
|
-
url.search
|
|
1847
|
-
).toString(),
|
|
1848
|
-
relayHostToken: relay2.request.relayHostToken,
|
|
1849
|
-
workspaceId: relay2.request.target.workspaceId,
|
|
1850
|
-
upstreamHeaders: relay2.request.target.upstreamHeaders,
|
|
1851
|
-
timeoutMs: bunOptions.directHttpTimeoutMs ?? DIRECT_HTTP_TIMEOUT_MS_DEFAULT,
|
|
1852
|
-
limiter: directHttpLimiter,
|
|
1853
|
-
trace
|
|
1854
|
-
});
|
|
1855
|
-
});
|
|
1856
|
-
return workspaceRelayTimingResponse(response, trace);
|
|
1857
|
-
}
|
|
1858
|
-
const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
|
|
1859
|
-
if (!relay.ok) return relay.response;
|
|
1860
|
-
const originDenied = requireAllowedOrigin(request);
|
|
1861
|
-
if (originDenied) return originDenied;
|
|
1862
|
-
if (relay.request.target.access === "user-hosted") {
|
|
1863
|
-
const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId);
|
|
1864
|
-
if (!tunnel) {
|
|
1865
|
-
return new Response("User-hosted workspace is offline", { status: 503 });
|
|
1866
|
-
}
|
|
1867
|
-
if (tunnel.data.channels.size >= TUNNEL_CHANNEL_CAP) {
|
|
1868
|
-
return jsonError(
|
|
1869
|
-
"too_many_channels",
|
|
1870
|
-
"User-hosted tunnel has too many active WebSocket channels",
|
|
1871
|
-
503
|
|
1872
|
-
);
|
|
1873
|
-
}
|
|
1874
|
-
if (server.upgrade(request, {
|
|
1875
|
-
data: {
|
|
1876
|
-
kind: "user-hosted-client",
|
|
1877
|
-
hostId: relay.request.target.hostId,
|
|
1878
|
-
workspaceId: relay.request.target.workspaceId,
|
|
1879
|
-
channelId: crypto.randomUUID(),
|
|
1880
|
-
path: `${relay.request.path}${url.search}`,
|
|
1881
|
-
relayHostToken: relay.request.relayHostToken
|
|
1882
|
-
}
|
|
1883
|
-
})) {
|
|
1884
|
-
return;
|
|
1885
|
-
}
|
|
1886
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
1887
|
-
}
|
|
1888
|
-
if (server.upgrade(request, {
|
|
1889
|
-
data: {
|
|
1890
|
-
kind: "client",
|
|
1891
|
-
upstreamUrl: workspaceRelayTargetUrl(
|
|
1892
|
-
relay.request.target,
|
|
1893
|
-
relay.request.path,
|
|
1894
|
-
url.search
|
|
1895
|
-
).toString().replace(/^http/, "ws"),
|
|
1896
|
-
headers: headersRecord(workspaceRelayForwardHeaders(
|
|
1897
|
-
new Headers(),
|
|
1898
|
-
relay.request.relayHostToken,
|
|
1899
|
-
relay.request.target.workspaceId,
|
|
1900
|
-
// T26: cloud-vm WS upgrade. Cookies pass through (default).
|
|
1901
|
-
{ userHosted: false, upstreamHeaders: relay.request.target.upstreamHeaders }
|
|
1902
|
-
)),
|
|
1903
|
-
queue: [],
|
|
1904
|
-
...relayWebSocketTraceEnabled(request) ? {
|
|
1905
|
-
trace: {
|
|
1906
|
-
acceptedAt: performance.now(),
|
|
1907
|
-
queuedFrames: 0,
|
|
1908
|
-
maxQueuedDelayMs: 0,
|
|
1909
|
-
emitted: false
|
|
1910
|
-
}
|
|
1911
|
-
} : {}
|
|
1912
|
-
}
|
|
1913
|
-
})) {
|
|
1914
|
-
return;
|
|
1915
|
-
}
|
|
1916
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
1917
|
-
},
|
|
1918
|
-
websocket: {
|
|
1919
|
-
maxPayloadLength: WS_MAX_PAYLOAD_LENGTH_BYTES,
|
|
1920
|
-
message(ws, message) {
|
|
1921
|
-
if (ws.data.kind === "host-tunnel") {
|
|
1922
|
-
const parsed = tunnelMessage(
|
|
1923
|
-
ws,
|
|
1924
|
-
message,
|
|
1925
|
-
fragmentationStats
|
|
1926
|
-
);
|
|
1927
|
-
if (parsed?.type === "ping") {
|
|
1928
|
-
options.directory?.recordPong(ws.data.hostId);
|
|
1929
|
-
ws.send(JSON.stringify(makeTunnelPong(parsed)));
|
|
1930
|
-
}
|
|
1931
|
-
if (parsed?.type === "pong") {
|
|
1932
|
-
ws.data.missedPongs = 0;
|
|
1933
|
-
options.directory?.recordPong(ws.data.hostId);
|
|
1934
|
-
}
|
|
1935
|
-
if (parsed?.type === "host.registration.update") {
|
|
1936
|
-
const update = parsed;
|
|
1937
|
-
const workspaceIds = [...new Set(update.workspace_ids)];
|
|
1938
|
-
const hostSocket = ws;
|
|
1939
|
-
const hostId = hostSocket.data.hostId;
|
|
1940
|
-
const authorizationRequest = new Request(
|
|
1941
|
-
`http://relay.local/host-tunnels/${encodeURIComponent(hostId)}`,
|
|
1942
|
-
{ headers: { authorization: `Bearer ${update.token}` } }
|
|
1943
|
-
);
|
|
1944
|
-
void authorizeHostTunnel(options, bunOptions, authorizationRequest, {
|
|
1945
|
-
hostId,
|
|
1946
|
-
workspaceIds
|
|
1947
|
-
}).then((authorized) => {
|
|
1948
|
-
if (!authorized) {
|
|
1949
|
-
closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008);
|
|
1950
|
-
return;
|
|
1951
|
-
}
|
|
1952
|
-
if (hostTunnels.get(hostId) !== hostSocket || hostSocket.readyState !== WebSocket.OPEN) return;
|
|
1953
|
-
hostSocket.data.workspaceIds = workspaceIds;
|
|
1954
|
-
options.directory?.registerHostTunnel({ hostId, workspaceIds });
|
|
1955
|
-
}).catch(() => closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008));
|
|
1956
|
-
return;
|
|
1957
|
-
}
|
|
1958
|
-
if (parsed?.type === "error") {
|
|
1959
|
-
if (parsed.request_id) {
|
|
1960
|
-
const pending = ws.data.pending.get(parsed.request_id);
|
|
1961
|
-
if (pending) {
|
|
1962
|
-
ws.data.pending.delete(parsed.request_id);
|
|
1963
|
-
failPendingHttpResponse({
|
|
1964
|
-
entry: pending,
|
|
1965
|
-
response: jsonError(parsed.code, parsed.message, 502),
|
|
1966
|
-
error: new Error(parsed.message)
|
|
1967
|
-
});
|
|
1968
|
-
}
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
if (parsed?.type === "http.response.start") {
|
|
1972
|
-
const pending = ws.data.pending.get(parsed.request_id);
|
|
1973
|
-
if (pending) {
|
|
1974
|
-
const response = parsed;
|
|
1975
|
-
pending.responseStarted = true;
|
|
1976
|
-
if (isEventStream(response.headers)) clearTimeout(pending.timeout);
|
|
1977
|
-
pending.resolve(new Response(pending.stream, {
|
|
1978
|
-
status: response.status,
|
|
1979
|
-
headers: headers(response.headers)
|
|
1980
|
-
}));
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
if (parsed?.type === "http.response.chunk") {
|
|
1984
|
-
const entry = ws.data.pending.get(parsed.request_id);
|
|
1985
|
-
if (entry) {
|
|
1986
|
-
enqueueChunkWithBackpressure({
|
|
1987
|
-
ws,
|
|
1988
|
-
requestId: parsed.request_id,
|
|
1989
|
-
entry,
|
|
1990
|
-
chunk: decoded(parsed.body_base64),
|
|
1991
|
-
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
|
|
1992
|
-
slowConsumerStats
|
|
1993
|
-
});
|
|
1994
|
-
}
|
|
1995
|
-
}
|
|
1996
|
-
if (parsed?.type === "http.response.end") {
|
|
1997
|
-
const pending = ws.data.pending.get(parsed.request_id);
|
|
1998
|
-
if (pending) {
|
|
1999
|
-
clearTimeout(pending.timeout);
|
|
2000
|
-
if (pending.slowConsumerTimeout) {
|
|
2001
|
-
clearTimeout(pending.slowConsumerTimeout);
|
|
2002
|
-
pending.slowConsumerTimeout = void 0;
|
|
2003
|
-
}
|
|
2004
|
-
drainPendingChunks(pending);
|
|
2005
|
-
if (pending.pendingChunks.length > 0) {
|
|
2006
|
-
for (const chunk of pending.pendingChunks) {
|
|
2007
|
-
try {
|
|
2008
|
-
pending.controller.enqueue(chunk);
|
|
2009
|
-
} catch {
|
|
2010
|
-
break;
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
pending.pendingChunks.length = 0;
|
|
2014
|
-
pending.bytesQueued = 0;
|
|
2015
|
-
}
|
|
2016
|
-
try {
|
|
2017
|
-
pending.controller.close();
|
|
2018
|
-
} catch {
|
|
2019
|
-
}
|
|
2020
|
-
ws.data.pending.delete(parsed.request_id);
|
|
2021
|
-
}
|
|
2022
|
-
}
|
|
2023
|
-
if (parsed?.type === "ws.frame") {
|
|
2024
|
-
const frame = parsed;
|
|
2025
|
-
const channel = ws.data.channels.get(frame.channel_id);
|
|
2026
|
-
if (!channel || channel.readyState !== WebSocket.OPEN) {
|
|
2027
|
-
ws.data.channels.delete(frame.channel_id);
|
|
2028
|
-
return;
|
|
2029
|
-
}
|
|
2030
|
-
if (relayOverBackpressureLimit(channel, bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
|
|
2031
|
-
ws.data.channels.delete(frame.channel_id);
|
|
2032
|
-
closeWebSocket(channel, 1011, "Client WebSocket backpressure limit exceeded");
|
|
2033
|
-
return;
|
|
2034
|
-
}
|
|
2035
|
-
channel.send(decodedFrame(frame));
|
|
2036
|
-
}
|
|
2037
|
-
if (parsed?.type === "ws.close") {
|
|
2038
|
-
const close = parsed;
|
|
2039
|
-
const channel = ws.data.channels.get(close.channel_id);
|
|
2040
|
-
if (channel) {
|
|
2041
|
-
ws.data.channels.delete(close.channel_id);
|
|
2042
|
-
closeWebSocket(channel, close.code, close.reason);
|
|
2043
|
-
}
|
|
2044
|
-
}
|
|
2045
|
-
return;
|
|
2046
|
-
}
|
|
2047
|
-
if (ws.data.kind === "user-hosted-client") {
|
|
2048
|
-
const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
|
|
2049
|
-
if (!tunnel) {
|
|
2050
|
-
ws.close(1011, "User-hosted tunnel disconnected");
|
|
2051
|
-
return;
|
|
2052
|
-
}
|
|
2053
|
-
if (relayOverBackpressureLimit(tunnel, bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
|
|
2054
|
-
closeWebSocket(ws, 1011, "Host tunnel backpressure limit exceeded");
|
|
2055
|
-
return;
|
|
2056
|
-
}
|
|
2057
|
-
tunnel.send(JSON.stringify({
|
|
2058
|
-
type: "ws.frame",
|
|
2059
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2060
|
-
channel_id: ws.data.channelId,
|
|
2061
|
-
...encodedFrame(message)
|
|
2062
|
-
}));
|
|
2063
|
-
return;
|
|
2064
|
-
}
|
|
2065
|
-
if (ws.data.upstream?.readyState === WebSocket.OPEN) {
|
|
2066
|
-
ws.data.upstream.send(message);
|
|
2067
|
-
return;
|
|
2068
|
-
}
|
|
2069
|
-
const queuedBytes = ws.data.queuedBytes ?? 0;
|
|
2070
|
-
const frameBytes = preOpenFrameBytes(message);
|
|
2071
|
-
if (ws.data.queue.length >= (bunOptions.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT) && queuedBytes + frameBytes > (bunOptions.upstreamWebSocketPreOpenQueueMaxBytes ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT)) {
|
|
2072
|
-
closeWebSocket(ws, 1011, "Upstream WebSocket queue limit exceeded");
|
|
2073
|
-
return;
|
|
2074
|
-
}
|
|
2075
|
-
ws.data.queuedBytes = queuedBytes + frameBytes;
|
|
2076
|
-
ws.data.queue.push({ payload: message, queuedAt: performance.now() });
|
|
2077
|
-
},
|
|
2078
|
-
open(ws) {
|
|
2079
|
-
if (ws.data.kind === "host-tunnel") {
|
|
2080
|
-
const previous = hostTunnels.get(ws.data.hostId);
|
|
2081
|
-
if (previous && previous !== ws) {
|
|
2082
|
-
cleanupHostTunnelSocket({
|
|
2083
|
-
ws: previous,
|
|
2084
|
-
hostTunnels,
|
|
2085
|
-
hostTunnelStateDebounce,
|
|
2086
|
-
options,
|
|
2087
|
-
bunOptions,
|
|
2088
|
-
disconnectDirectory: false,
|
|
2089
|
-
closeChannels: true
|
|
2090
|
-
});
|
|
2091
|
-
closeWebSocket(previous, 1012, "Host tunnel replaced by a newer connection", 1012);
|
|
2092
|
-
}
|
|
2093
|
-
hostTunnels.set(ws.data.hostId, ws);
|
|
2094
|
-
options.directory?.registerHostTunnel({
|
|
2095
|
-
hostId: ws.data.hostId,
|
|
2096
|
-
workspaceIds: ws.data.workspaceIds
|
|
2097
|
-
});
|
|
2098
|
-
ws.data.heartbeat = setInterval(() => {
|
|
2099
|
-
const hostWs = ws;
|
|
2100
|
-
if (hostWs.data.missedPongs > (bunOptions.hostTunnelMaxMissedPongs ?? HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT)) {
|
|
2101
|
-
closeWebSocket(ws, 1001, "Host tunnel heartbeat timed out", 1001);
|
|
2102
|
-
return;
|
|
2103
|
-
}
|
|
2104
|
-
sendTunnelPing(hostWs);
|
|
2105
|
-
}, bunOptions.hostTunnelPingIntervalMs ?? 15e3);
|
|
2106
|
-
scheduleHostTunnelStateChange(hostTunnelStateDebounce, options, {
|
|
2107
|
-
hostId: ws.data.hostId,
|
|
2108
|
-
workspaceId: ws.data.workspaceIds[0],
|
|
2109
|
-
path: `/host-tunnels/${ws.data.hostId}`,
|
|
2110
|
-
state: "connected",
|
|
2111
|
-
debounceMs: bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
|
|
2112
|
-
});
|
|
2113
|
-
return;
|
|
2114
|
-
}
|
|
2115
|
-
if (ws.data.kind === "user-hosted-client") {
|
|
2116
|
-
const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
|
|
2117
|
-
if (!tunnel) {
|
|
2118
|
-
ws.close(1011, "User-hosted tunnel disconnected");
|
|
2119
|
-
return;
|
|
2120
|
-
}
|
|
2121
|
-
tunnel.data.channels.set(ws.data.channelId, ws);
|
|
2122
|
-
if (tunnel.readyState !== WebSocket.OPEN) {
|
|
2123
|
-
closeWebSocket(ws, 1011, "User-hosted tunnel disconnected");
|
|
2124
|
-
return;
|
|
2125
|
-
}
|
|
2126
|
-
tunnel.send(JSON.stringify({
|
|
2127
|
-
type: "ws.open",
|
|
2128
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2129
|
-
channel_id: ws.data.channelId,
|
|
2130
|
-
workspace_id: ws.data.workspaceId,
|
|
2131
|
-
path: ws.data.path,
|
|
2132
|
-
headers: headersRecord(workspaceRelayForwardHeaders(
|
|
2133
|
-
new Headers(),
|
|
2134
|
-
ws.data.relayHostToken,
|
|
2135
|
-
ws.data.workspaceId,
|
|
2136
|
-
// T26: user-hosted tunnel WS upgrade — strip Cookie. Headers are
|
|
2137
|
-
// empty here today, but pass the flag for consistency in case
|
|
2138
|
-
// upstream client headers are forwarded in the future.
|
|
2139
|
-
{ userHosted: true }
|
|
2140
|
-
))
|
|
2141
|
-
}));
|
|
2142
|
-
return;
|
|
2143
|
-
}
|
|
2144
|
-
relayClients.add(ws);
|
|
2145
|
-
const data = ws.data;
|
|
2146
|
-
const UpstreamWebSocket = bunOptions.upstreamWebSocket ?? WebSocket;
|
|
2147
|
-
if (data.trace) data.trace.upstreamStartedAt = performance.now();
|
|
2148
|
-
const upstream = new UpstreamWebSocket(data.upstreamUrl, {
|
|
2149
|
-
headers: data.headers
|
|
2150
|
-
});
|
|
2151
|
-
upstream.binaryType = "arraybuffer";
|
|
2152
|
-
data.upstream = upstream;
|
|
2153
|
-
data.upstreamOpenTimer = setTimeout(() => {
|
|
2154
|
-
if (upstream.readyState === WebSocket.OPEN) return;
|
|
2155
|
-
closeWebSocket(upstream, 1001, "Upstream WebSocket open timed out", 1001);
|
|
2156
|
-
closeWebSocket(ws, 1011, "Upstream WebSocket open timed out");
|
|
2157
|
-
}, bunOptions.upstreamWebSocketOpenTimeoutMs ?? UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT);
|
|
2158
|
-
upstream.onopen = () => {
|
|
2159
|
-
const openedAt = performance.now();
|
|
2160
|
-
if (data.upstreamOpenTimer) {
|
|
2161
|
-
clearTimeout(data.upstreamOpenTimer);
|
|
2162
|
-
data.upstreamOpenTimer = void 0;
|
|
2163
|
-
}
|
|
2164
|
-
if (data.trace) {
|
|
2165
|
-
data.trace.upstreamOpenMs = openedAt - (data.trace.upstreamStartedAt ?? data.trace.acceptedAt);
|
|
2166
|
-
data.trace.queuedFrames = data.queue.length;
|
|
2167
|
-
data.trace.maxQueuedDelayMs = data.queue.reduce(
|
|
2168
|
-
(max, item) => Math.max(max, openedAt - item.queuedAt),
|
|
2169
|
-
0
|
|
2170
|
-
);
|
|
2171
|
-
sendRelayWebSocketTrace(ws);
|
|
2172
|
-
}
|
|
2173
|
-
for (const item of data.queue.splice(0)) upstream.send(item.payload);
|
|
2174
|
-
data.queuedBytes = 0;
|
|
2175
|
-
};
|
|
2176
|
-
upstream.onmessage = (event) => {
|
|
2177
|
-
const payload = relayWebSocketPayload(event.data);
|
|
2178
|
-
if (payload !== void 0) ws.send(payload);
|
|
2179
|
-
};
|
|
2180
|
-
upstream.onclose = (event) => {
|
|
2181
|
-
if (data.upstreamOpenTimer) {
|
|
2182
|
-
clearTimeout(data.upstreamOpenTimer);
|
|
2183
|
-
data.upstreamOpenTimer = void 0;
|
|
2184
|
-
}
|
|
2185
|
-
closeWebSocket(ws, event.code, upstreamCloseReason(event));
|
|
2186
|
-
};
|
|
2187
|
-
upstream.onerror = () => {
|
|
2188
|
-
if (data.upstreamOpenTimer) {
|
|
2189
|
-
clearTimeout(data.upstreamOpenTimer);
|
|
2190
|
-
data.upstreamOpenTimer = void 0;
|
|
2191
|
-
}
|
|
2192
|
-
closeWebSocket(ws, 1011, "Upstream WebSocket connection failed");
|
|
2193
|
-
};
|
|
2194
|
-
},
|
|
2195
|
-
close(ws, code, reason) {
|
|
2196
|
-
if (ws.data.kind === "host-tunnel") {
|
|
2197
|
-
cleanupHostTunnelSocket({
|
|
2198
|
-
ws,
|
|
2199
|
-
hostTunnels,
|
|
2200
|
-
hostTunnelStateDebounce,
|
|
2201
|
-
options,
|
|
2202
|
-
bunOptions,
|
|
2203
|
-
disconnectDirectory: true,
|
|
2204
|
-
closeChannels: true
|
|
2205
|
-
});
|
|
2206
|
-
const tracker = hostTunnelRegistrations.get(ws.data.hostId);
|
|
2207
|
-
if (tracker) {
|
|
2208
|
-
pruneReconnects(tracker, Date.now());
|
|
2209
|
-
if (tracker.recent.length === 0) hostTunnelRegistrations.delete(ws.data.hostId);
|
|
2210
|
-
}
|
|
2211
|
-
return;
|
|
2212
|
-
}
|
|
2213
|
-
if (ws.data.kind === "user-hosted-client") {
|
|
2214
|
-
const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
|
|
2215
|
-
tunnel?.data.channels.delete(ws.data.channelId);
|
|
2216
|
-
if (tunnel?.readyState === WebSocket.OPEN) {
|
|
2217
|
-
tunnel.send(JSON.stringify({
|
|
2218
|
-
type: "ws.close",
|
|
2219
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2220
|
-
channel_id: ws.data.channelId,
|
|
2221
|
-
code: safeCloseCode(code, 1e3),
|
|
2222
|
-
reason
|
|
2223
|
-
}));
|
|
2224
|
-
}
|
|
2225
|
-
return;
|
|
2226
|
-
}
|
|
2227
|
-
relayClients.delete(ws);
|
|
2228
|
-
if (ws.data.upstreamOpenTimer) clearTimeout(ws.data.upstreamOpenTimer);
|
|
2229
|
-
if (ws.data.upstream) closeWebSocket(ws.data.upstream, code, reason, 1e3);
|
|
2230
|
-
}
|
|
2231
|
-
},
|
|
2232
|
-
drain: drainController,
|
|
2233
|
-
telemetry
|
|
2234
|
-
};
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
// src/cloudflare.ts
|
|
2238
|
-
import {
|
|
2239
|
-
makeTunnelPong as makeTunnelPong2,
|
|
2240
|
-
TUNNEL_PROTOCOL_VERSION as TUNNEL_PROTOCOL_VERSION2,
|
|
2241
|
-
validateTunnelMessage as validateTunnelMessage2
|
|
2242
|
-
} from "@claxedo/workspace-relay-protocol";
|
|
2243
1081
|
|
|
2244
1082
|
// src/directory.ts
|
|
1083
|
+
function presenceKey(hostId, workspaceId) {
|
|
1084
|
+
return `${hostId}\0${workspaceId}`;
|
|
1085
|
+
}
|
|
2245
1086
|
function createWorkspaceRelayDirectory(options = {}) {
|
|
2246
1087
|
const ttlMs = Math.max(1, options.ttlMs ?? 45e3);
|
|
2247
1088
|
const now = options.now ?? Date.now;
|
|
2248
|
-
const
|
|
2249
|
-
const
|
|
2250
|
-
|
|
2251
|
-
if (!presence) return;
|
|
1089
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1090
|
+
const alive = (key) => {
|
|
1091
|
+
const presence = entries.get(key);
|
|
1092
|
+
if (!presence) return void 0;
|
|
2252
1093
|
if (presence.expiresAt <= now()) {
|
|
2253
|
-
|
|
2254
|
-
return;
|
|
1094
|
+
entries.delete(key);
|
|
1095
|
+
return void 0;
|
|
2255
1096
|
}
|
|
2256
1097
|
return presence;
|
|
2257
1098
|
};
|
|
2258
|
-
const
|
|
2259
|
-
const timestamp = now();
|
|
2260
|
-
const next = {
|
|
2261
|
-
...presence,
|
|
2262
|
-
lastPongAt: timestamp,
|
|
2263
|
-
expiresAt: timestamp + ttlMs
|
|
2264
|
-
};
|
|
2265
|
-
hosts.set(next.hostId, next);
|
|
2266
|
-
return next;
|
|
2267
|
-
};
|
|
2268
|
-
const sweep = () => {
|
|
2269
|
-
const at = now();
|
|
2270
|
-
for (const [hostId, presence] of hosts) {
|
|
2271
|
-
if (presence.expiresAt <= at) {
|
|
2272
|
-
hosts.delete(hostId);
|
|
2273
|
-
}
|
|
2274
|
-
}
|
|
2275
|
-
};
|
|
2276
|
-
let intervalHandle;
|
|
2277
|
-
if (sweepIntervalMs > 0) {
|
|
2278
|
-
intervalHandle = setInterval(sweep, sweepIntervalMs);
|
|
2279
|
-
}
|
|
1099
|
+
const keysOf = (hostId) => [...entries].filter(([, presence]) => presence.hostId === hostId).map(([key]) => key);
|
|
2280
1100
|
return {
|
|
2281
1101
|
registerHostTunnel(input) {
|
|
2282
1102
|
const timestamp = now();
|
|
@@ -2287,46 +1107,160 @@ function createWorkspaceRelayDirectory(options = {}) {
|
|
|
2287
1107
|
lastPongAt: timestamp,
|
|
2288
1108
|
expiresAt: timestamp + ttlMs
|
|
2289
1109
|
};
|
|
2290
|
-
|
|
1110
|
+
for (const workspaceId of next.workspaceIds) entries.set(presenceKey(next.hostId, workspaceId), next);
|
|
2291
1111
|
return next;
|
|
2292
1112
|
},
|
|
2293
1113
|
recordPong(hostId) {
|
|
2294
|
-
const
|
|
2295
|
-
|
|
1114
|
+
const timestamp = now();
|
|
1115
|
+
let touched;
|
|
1116
|
+
for (const key of keysOf(hostId)) {
|
|
1117
|
+
const presence = alive(key);
|
|
1118
|
+
if (!presence) continue;
|
|
1119
|
+
const next = { ...presence, lastPongAt: timestamp, expiresAt: timestamp + ttlMs };
|
|
1120
|
+
entries.set(key, next);
|
|
1121
|
+
touched ??= next;
|
|
1122
|
+
}
|
|
1123
|
+
return touched;
|
|
2296
1124
|
},
|
|
2297
1125
|
disconnectHost(hostId) {
|
|
2298
|
-
|
|
1126
|
+
for (const key of keysOf(hostId)) entries.delete(key);
|
|
2299
1127
|
},
|
|
2300
1128
|
activeHost(input) {
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
1129
|
+
return alive(presenceKey(input.hostId, input.workspaceId));
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// src/cors-origins.ts
|
|
1135
|
+
var DEFAULT_RELAY_APP_ORIGINS = [
|
|
1136
|
+
"https://claxedo.com",
|
|
1137
|
+
"https://*.claxedo.com"
|
|
1138
|
+
];
|
|
1139
|
+
var RELAY_ALLOWED_REQUEST_HEADERS = [
|
|
1140
|
+
"Accept",
|
|
1141
|
+
"Authorization",
|
|
1142
|
+
"Content-Type",
|
|
1143
|
+
"Last-Event-ID",
|
|
1144
|
+
"Traceparent",
|
|
1145
|
+
"Tracestate",
|
|
1146
|
+
"X-Fetch-Bypass-Throttle",
|
|
1147
|
+
"X-Workspace-Id",
|
|
1148
|
+
"X-OpenCode-Directory",
|
|
1149
|
+
"X-Claxedo-Runner",
|
|
1150
|
+
"X-Claxedo-Model",
|
|
1151
|
+
"X-Claxedo-Draft-Id",
|
|
1152
|
+
"X-Claxedo-Session-Registration-Operation",
|
|
1153
|
+
"X-Claxedo-Binary"
|
|
1154
|
+
].join(", ");
|
|
1155
|
+
function parseAllowedOrigins(raw) {
|
|
1156
|
+
const entries = (raw ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
1157
|
+
return entries.length ? entries : void 0;
|
|
1158
|
+
}
|
|
1159
|
+
function createOriginMatcher(patterns) {
|
|
1160
|
+
const exact = /* @__PURE__ */ new Set();
|
|
1161
|
+
const httpsSuffixes = [];
|
|
1162
|
+
const httpPortPrefixes = [];
|
|
1163
|
+
for (const pattern of patterns) {
|
|
1164
|
+
if (pattern.startsWith("https://*.")) {
|
|
1165
|
+
httpsSuffixes.push(pattern.slice("https://*".length));
|
|
1166
|
+
} else if (pattern.startsWith("http://") && pattern.endsWith(":*")) {
|
|
1167
|
+
httpPortPrefixes.push(pattern.slice(0, -1));
|
|
1168
|
+
} else {
|
|
1169
|
+
exact.add(pattern);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return (origin) => {
|
|
1173
|
+
if (exact.has(origin)) return true;
|
|
1174
|
+
if (origin.startsWith("https://")) {
|
|
1175
|
+
return httpsSuffixes.some(
|
|
1176
|
+
(suffix) => origin.endsWith(suffix) && origin.length > "https://".length + suffix.length
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
if (origin.startsWith("http://")) {
|
|
1180
|
+
return httpPortPrefixes.some(
|
|
1181
|
+
(prefix) => origin.startsWith(prefix) && /^\d+$/.test(origin.slice(prefix.length))
|
|
1182
|
+
);
|
|
2319
1183
|
}
|
|
1184
|
+
return false;
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// src/relay-cors.ts
|
|
1189
|
+
var RELAY_EXPOSED_RESPONSE_HEADERS = "X-Event-Cursor, X-Next-Cursor, X-Max-Event-Ordinal, Server-Timing, X-Claxedo-Trace-Id, X-Claxedo-Relay-Location-Hint";
|
|
1190
|
+
var configuredAppOriginAllowed = () => false;
|
|
1191
|
+
function setWorkspaceRelayAppOrigins(raw) {
|
|
1192
|
+
configuredAppOriginAllowed = raw?.trim() ? createOriginMatcher(parseAllowedOrigins(raw) ?? []) : () => false;
|
|
1193
|
+
}
|
|
1194
|
+
var defaultOriginAllowed = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
|
|
1195
|
+
var baseOriginAllowed = defaultOriginAllowed;
|
|
1196
|
+
function setWorkspaceRelayAllowedOrigins(raw) {
|
|
1197
|
+
const patterns = parseAllowedOrigins(raw);
|
|
1198
|
+
baseOriginAllowed = patterns ? createOriginMatcher(patterns) : defaultOriginAllowed;
|
|
1199
|
+
}
|
|
1200
|
+
function allowedCorsOrigin(origin) {
|
|
1201
|
+
if (!origin) return void 0;
|
|
1202
|
+
return baseOriginAllowed(origin) || configuredAppOriginAllowed(origin) ? origin : void 0;
|
|
1203
|
+
}
|
|
1204
|
+
function workspaceUpgradeOriginDenied(request) {
|
|
1205
|
+
const origin = request.headers.get("origin");
|
|
1206
|
+
if (origin === null || allowedCorsOrigin(origin)) return null;
|
|
1207
|
+
return Response.json(errorBody("origin_not_allowed", "Origin is not in the allowlist"), { status: 403 });
|
|
1208
|
+
}
|
|
1209
|
+
function corsHeaders(request) {
|
|
1210
|
+
const origin = allowedCorsOrigin(request.headers.get("origin"));
|
|
1211
|
+
if (!origin) return void 0;
|
|
1212
|
+
return {
|
|
1213
|
+
"access-control-allow-origin": origin,
|
|
1214
|
+
"access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
1215
|
+
"access-control-allow-headers": RELAY_ALLOWED_REQUEST_HEADERS,
|
|
1216
|
+
"access-control-expose-headers": RELAY_EXPOSED_RESPONSE_HEADERS,
|
|
1217
|
+
"access-control-max-age": "86400",
|
|
1218
|
+
"timing-allow-origin": origin,
|
|
1219
|
+
vary: "Origin"
|
|
2320
1220
|
};
|
|
2321
1221
|
}
|
|
2322
|
-
function
|
|
2323
|
-
|
|
1222
|
+
function corsPreflight(request) {
|
|
1223
|
+
const headers = corsHeaders(request);
|
|
1224
|
+
return new Response(null, { status: headers ? 204 : 403, ...headers ? { headers } : {} });
|
|
1225
|
+
}
|
|
1226
|
+
var UPSTREAM_OWNED_RESPONSE_HEADERS = [
|
|
1227
|
+
"set-cookie",
|
|
1228
|
+
"access-control-allow-origin",
|
|
1229
|
+
"access-control-allow-credentials",
|
|
1230
|
+
"access-control-allow-headers",
|
|
1231
|
+
"access-control-allow-methods",
|
|
1232
|
+
"access-control-expose-headers",
|
|
1233
|
+
"access-control-max-age"
|
|
1234
|
+
];
|
|
1235
|
+
var RELAY_DOCUMENT_LOCKDOWN_HEADERS = {
|
|
1236
|
+
"x-content-type-options": "nosniff",
|
|
1237
|
+
"content-security-policy": "sandbox"
|
|
1238
|
+
};
|
|
1239
|
+
function withRelayResponseHeaders(request, response) {
|
|
1240
|
+
if (response.status === 101 || "webSocket" in response && response.webSocket) return response;
|
|
1241
|
+
const next = new Headers(response.headers);
|
|
1242
|
+
for (const name of UPSTREAM_OWNED_RESPONSE_HEADERS) next.delete(name);
|
|
1243
|
+
for (const [key, value] of Object.entries({ ...RELAY_DOCUMENT_LOCKDOWN_HEADERS, ...corsHeaders(request) })) next.set(key, value);
|
|
1244
|
+
return new Response(response.body, {
|
|
1245
|
+
status: response.status,
|
|
1246
|
+
statusText: response.statusText,
|
|
1247
|
+
headers: next
|
|
1248
|
+
});
|
|
2324
1249
|
}
|
|
2325
1250
|
|
|
2326
|
-
// src/
|
|
1251
|
+
// src/session-host-forwarding.ts
|
|
1252
|
+
import { sessionHostRootOf as sessionHostRootOf2 } from "@claxedo/workspace-relay-protocol";
|
|
1253
|
+
|
|
1254
|
+
// src/relay-location.ts
|
|
1255
|
+
import { trimToUndefined as trimToUndefined2 } from "@claxedo/helpers/string";
|
|
1256
|
+
import { sessionHostRootOf } from "@claxedo/workspace-relay-protocol";
|
|
2327
1257
|
var DEFAULT_RELAY_LOCATION_HINT = "apac";
|
|
1258
|
+
var SESSION_HOST_LOCATION_HINT = "enam";
|
|
2328
1259
|
var RELAY_LOCATION_HINTS = ["wnam", "enam", "sam", "weur", "eeur", "apac", "oc", "afr", "me"];
|
|
2329
1260
|
var relayLocationHintSet = new Set(RELAY_LOCATION_HINTS);
|
|
1261
|
+
function isRelayLocationHint(input) {
|
|
1262
|
+
return relayLocationHintSet.has(input);
|
|
1263
|
+
}
|
|
2330
1264
|
var RELAY_REGION_TO_LOCATION_HINT = {
|
|
2331
1265
|
// Canonical Claxedo regions.
|
|
2332
1266
|
"apac-south": "apac",
|
|
@@ -2363,46 +1297,157 @@ function locationHintForCountry(country) {
|
|
|
2363
1297
|
}
|
|
2364
1298
|
var RELAY_REGION_HEADER = "x-claxedo-workspace-region";
|
|
2365
1299
|
function relayLocationHint(input) {
|
|
2366
|
-
const configured =
|
|
2367
|
-
const region =
|
|
1300
|
+
const configured = trimToUndefined2(input.configured) ?? DEFAULT_RELAY_LOCATION_HINT;
|
|
1301
|
+
const region = trimToUndefined2(input.region);
|
|
2368
1302
|
if (region) {
|
|
2369
|
-
const
|
|
2370
|
-
const
|
|
1303
|
+
const lowered = region.toLowerCase();
|
|
1304
|
+
const direct = isRelayLocationHint(lowered) ? lowered : void 0;
|
|
1305
|
+
const mapped = direct ?? RELAY_REGION_TO_LOCATION_HINT[lowered];
|
|
2371
1306
|
if (mapped) return mapped;
|
|
2372
1307
|
}
|
|
2373
|
-
const country =
|
|
1308
|
+
const country = trimToUndefined2(input.country);
|
|
2374
1309
|
if (country) {
|
|
2375
1310
|
const mapped = locationHintForCountry(country);
|
|
2376
1311
|
if (mapped) return mapped;
|
|
2377
1312
|
}
|
|
2378
1313
|
return configured;
|
|
2379
1314
|
}
|
|
1315
|
+
var RELAY_REGION_QUERY_PARAMS = ["region", "homeRegion"];
|
|
2380
1316
|
function relayRequestRegion(request) {
|
|
2381
1317
|
const url = new URL(request.url);
|
|
2382
|
-
return
|
|
1318
|
+
return trimToUndefined2(request.headers.get(RELAY_REGION_HEADER)) ?? trimToUndefined2(url.searchParams.get(RELAY_REGION_QUERY_PARAMS[0])) ?? trimToUndefined2(url.searchParams.get(RELAY_REGION_QUERY_PARAMS[1]));
|
|
1319
|
+
}
|
|
1320
|
+
async function relayRoomPlacement(request, input) {
|
|
1321
|
+
const configured = trimToUndefined2(input.configured);
|
|
1322
|
+
const region = relayRequestRegion(request);
|
|
1323
|
+
const country = relayRequestCountry(request);
|
|
1324
|
+
const floor = relayLocationHint(configured ? { configured } : {});
|
|
1325
|
+
const declared = relayLocationHint({
|
|
1326
|
+
...configured ? { configured } : {},
|
|
1327
|
+
...region ? { region } : {},
|
|
1328
|
+
...country ? { country } : {}
|
|
1329
|
+
});
|
|
1330
|
+
const headers = new Headers(request.headers);
|
|
1331
|
+
headers.delete(RELAY_REGION_HEADER);
|
|
1332
|
+
const credential = await input.credential();
|
|
1333
|
+
const sessionHost = credential !== void 0 && sessionHostRootOf(credential.hostId) !== void 0;
|
|
1334
|
+
return {
|
|
1335
|
+
locationHint: sessionHost ? SESSION_HOST_LOCATION_HINT : credential || declared === floor ? declared : floor,
|
|
1336
|
+
url: withoutQueryParams(request.url, RELAY_REGION_QUERY_PARAMS),
|
|
1337
|
+
headers
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
function withoutQueryParams(url, names) {
|
|
1341
|
+
const target = new URL(url);
|
|
1342
|
+
if (!names.some((name) => target.searchParams.has(name))) return url;
|
|
1343
|
+
const kept = target.search.slice(1).split("&").filter((pair) => !names.includes(pair.split("=")[0] ?? ""));
|
|
1344
|
+
target.search = kept.length ? `?${kept.join("&")}` : "";
|
|
1345
|
+
return target.toString();
|
|
2383
1346
|
}
|
|
2384
1347
|
function relayRequestCountry(request) {
|
|
2385
1348
|
const cf = request.cf;
|
|
2386
1349
|
return typeof cf?.country === "string" ? cf.country : void 0;
|
|
2387
1350
|
}
|
|
1351
|
+
|
|
1352
|
+
// src/session-host-forwarding.ts
|
|
1353
|
+
var SESSION_HOST_LOCATION = { locationHint: SESSION_HOST_LOCATION_HINT };
|
|
1354
|
+
var SESSION_HOST_ORIGIN = "https://session-host.invalid";
|
|
1355
|
+
function unavailable() {
|
|
1356
|
+
return Response.json(errorBody("session_host_unavailable", "The session host is unavailable"), { status: 503 });
|
|
1357
|
+
}
|
|
1358
|
+
function isSessionHostTarget(target) {
|
|
1359
|
+
return target.backing === "durable-object";
|
|
1360
|
+
}
|
|
1361
|
+
async function forwardSessionHostHttp(namespace, request, authorized) {
|
|
1362
|
+
const root = sessionHostRootOf2(authorized.target.hostId);
|
|
1363
|
+
if (!namespace || !root) return unavailable();
|
|
1364
|
+
const url = workspaceRelayTargetUrl({ ...authorized.target, baseUrl: SESSION_HOST_ORIGIN }, authorized.path, new URL(request.url).search);
|
|
1365
|
+
const init = workspaceRelayForwardRequestInit(request, authorized.relayHostToken, authorized.target.workspaceId, { hostTunnel: true });
|
|
1366
|
+
let upstream;
|
|
1367
|
+
try {
|
|
1368
|
+
upstream = await namespace.getByName(root, SESSION_HOST_LOCATION).fetch(url.toString(), init);
|
|
1369
|
+
} catch {
|
|
1370
|
+
return unavailable();
|
|
1371
|
+
}
|
|
1372
|
+
return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: upstream.headers });
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// src/gateway-placement.ts
|
|
1376
|
+
import { createRemoteJWKSet, importSPKI } from "jose";
|
|
1377
|
+
import { trimToUndefined as trimToUndefined3 } from "@claxedo/helpers/string";
|
|
1378
|
+
var placementAuthKeys = /* @__PURE__ */ new Map();
|
|
1379
|
+
function placementAuthKey(env) {
|
|
1380
|
+
const jwksUrl = typeof env.CLAXEDO_CONTROL_PLANE_JWKS_URL === "string" ? trimToUndefined3(env.CLAXEDO_CONTROL_PLANE_JWKS_URL) : void 0;
|
|
1381
|
+
const pem = (typeof env.CLAXEDO_RUNTIME_ACCESS_TOKEN_PUBLIC_KEY_PEM === "string" ? trimToUndefined3(env.CLAXEDO_RUNTIME_ACCESS_TOKEN_PUBLIC_KEY_PEM) : void 0)?.replaceAll("\\n", "\n");
|
|
1382
|
+
const id = jwksUrl ? `jwks:${jwksUrl}` : pem ? `pem:${pem}` : void 0;
|
|
1383
|
+
if (!id) return void 0;
|
|
1384
|
+
const cached = placementAuthKeys.get(id);
|
|
1385
|
+
if (cached) return cached;
|
|
1386
|
+
if (placementAuthKeys.size >= 32) placementAuthKeys.clear();
|
|
1387
|
+
const loaded = (async () => {
|
|
1388
|
+
try {
|
|
1389
|
+
if (jwksUrl) return createRemoteJWKSet(new URL(jwksUrl));
|
|
1390
|
+
if (!pem) return void 0;
|
|
1391
|
+
return await importSPKI(pem, "EdDSA");
|
|
1392
|
+
} catch {
|
|
1393
|
+
return void 0;
|
|
1394
|
+
}
|
|
1395
|
+
})();
|
|
1396
|
+
placementAuthKeys.set(id, loaded);
|
|
1397
|
+
return loaded;
|
|
1398
|
+
}
|
|
1399
|
+
function runtimeAccessTokenFrom(request) {
|
|
1400
|
+
return bearerToken(request.headers.get("authorization") ?? void 0) ?? request.headers.get("sec-websocket-protocol")?.split(",").map((item) => item.trim()).find((item) => item.startsWith("claxedo-rat."))?.slice("claxedo-rat.".length);
|
|
1401
|
+
}
|
|
1402
|
+
async function relayPlacementCredential(request, env, url, workspaceId) {
|
|
1403
|
+
const key = await placementAuthKey(env);
|
|
1404
|
+
if (!key) return void 0;
|
|
1405
|
+
try {
|
|
1406
|
+
if (url.pathname.startsWith("/host-tunnels/")) {
|
|
1407
|
+
const hostId = trimToUndefined3(hostIdFromTunnelPath(url.pathname));
|
|
1408
|
+
const token2 = bearerToken(request.headers.get("authorization"));
|
|
1409
|
+
if (!hostId || !token2) return void 0;
|
|
1410
|
+
await verifyHostTunnelToken(token2, key, { hostId, workspaceIds: workspaceIdsFromSearch(url) });
|
|
1411
|
+
return { hostId };
|
|
1412
|
+
}
|
|
1413
|
+
const token = runtimeAccessTokenFrom(request);
|
|
1414
|
+
if (!token) return void 0;
|
|
1415
|
+
const claims = await verifyRuntimeAccessToken(token, key, { workspaceId });
|
|
1416
|
+
return { hostId: claims.host_id };
|
|
1417
|
+
} catch {
|
|
1418
|
+
return void 0;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
function workspaceRelayRoomPlacement(request, env, workspaceId) {
|
|
1422
|
+
return relayRoomPlacement(request, {
|
|
1423
|
+
...typeof env.CLAXEDO_RELAY_LOCATION_HINT === "string" ? { configured: env.CLAXEDO_RELAY_LOCATION_HINT } : {},
|
|
1424
|
+
credential: () => relayPlacementCredential(request, env, new URL(request.url), workspaceId)
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// src/cloudflare.ts
|
|
2388
1429
|
var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
|
|
2389
1430
|
var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
|
|
2390
1431
|
var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
|
|
2391
|
-
var
|
|
1432
|
+
var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
|
|
2392
1433
|
var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
|
|
2393
|
-
var
|
|
2394
|
-
var
|
|
2395
|
-
var
|
|
2396
|
-
var
|
|
2397
|
-
var
|
|
1434
|
+
var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1435
|
+
var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1436
|
+
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 8192;
|
|
1437
|
+
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1438
|
+
var UPSTREAM_WS_PRE_OPEN_QUEUE_FRAME_OVERHEAD_BYTES = 64;
|
|
2398
1439
|
var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
|
|
2399
1440
|
var RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
|
|
2400
1441
|
var HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT = 3e4;
|
|
1442
|
+
var HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT = 3e4;
|
|
2401
1443
|
var TRACE_ID_HEADER = "x-claxedo-trace-id";
|
|
2402
1444
|
var TRACE_FORCE_HEADER = "x-claxedo-relay-trace";
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
1445
|
+
var TRACE_SAMPLED_RESPONSE_HEADER = "x-claxedo-relay-sampled";
|
|
1446
|
+
function unrefTimer(timer) {
|
|
1447
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
1448
|
+
}
|
|
1449
|
+
function isHostTunnelClient(client) {
|
|
1450
|
+
return "channelId" in client && typeof client.channelId === "string";
|
|
2406
1451
|
}
|
|
2407
1452
|
function clampRate(input) {
|
|
2408
1453
|
if (input === void 0 || !Number.isFinite(input)) return 0;
|
|
@@ -2411,8 +1456,8 @@ function clampRate(input) {
|
|
|
2411
1456
|
return input;
|
|
2412
1457
|
}
|
|
2413
1458
|
function traceRequest(request, routeKind, options) {
|
|
2414
|
-
const id =
|
|
2415
|
-
const forceSecret =
|
|
1459
|
+
const id = trimToUndefined4(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
|
|
1460
|
+
const forceSecret = trimToUndefined4(options.traceForceHeaderSecret);
|
|
2416
1461
|
const forced = !!forceSecret && request.headers.get(TRACE_FORCE_HEADER) === forceSecret;
|
|
2417
1462
|
const rate = clampRate(options.traceSampleRate);
|
|
2418
1463
|
return {
|
|
@@ -2431,37 +1476,38 @@ async function traceSpan(trace, name, fn) {
|
|
|
2431
1476
|
trace?.phases.push({ name, ms: performance.now() - startedAt });
|
|
2432
1477
|
}
|
|
2433
1478
|
}
|
|
2434
|
-
function tracedRequest(request, trace) {
|
|
2435
|
-
const
|
|
2436
|
-
|
|
2437
|
-
if (trace.sampled)
|
|
2438
|
-
else
|
|
2439
|
-
return new Request(
|
|
1479
|
+
function tracedRequest(request, trace, forwarded = request) {
|
|
1480
|
+
const headers = new Headers(forwarded.headers);
|
|
1481
|
+
headers.set(TRACE_ID_HEADER, trace.id);
|
|
1482
|
+
if (trace.sampled) headers.set("x-claxedo-trace-sampled", "1");
|
|
1483
|
+
else headers.delete("x-claxedo-trace-sampled");
|
|
1484
|
+
return new Request(forwarded.url, {
|
|
2440
1485
|
method: request.method,
|
|
2441
|
-
headers
|
|
1486
|
+
headers,
|
|
2442
1487
|
body: request.method === "GET" || request.method === "HEAD" ? void 0 : request.body,
|
|
2443
1488
|
redirect: request.redirect,
|
|
2444
1489
|
duplex: "half"
|
|
2445
1490
|
});
|
|
2446
1491
|
}
|
|
2447
|
-
function
|
|
1492
|
+
function roundedMs(value) {
|
|
2448
1493
|
return Math.round(value * 100) / 100;
|
|
2449
1494
|
}
|
|
2450
1495
|
function serverTiming(trace) {
|
|
2451
|
-
return trace.phases.map((phase) => `${phase.name};dur=${
|
|
1496
|
+
return trace.phases.map((phase) => `${phase.name};dur=${roundedMs(phase.ms)}`).join(", ");
|
|
2452
1497
|
}
|
|
2453
1498
|
function withTraceHeaders(response, trace) {
|
|
2454
|
-
const
|
|
2455
|
-
|
|
1499
|
+
const headers = new Headers(response.headers);
|
|
1500
|
+
headers.set(TRACE_ID_HEADER, trace.id);
|
|
2456
1501
|
const timing = trace.sampled ? serverTiming(trace) : "";
|
|
2457
1502
|
if (timing) {
|
|
2458
|
-
const existing =
|
|
2459
|
-
|
|
1503
|
+
const existing = headers.get("server-timing");
|
|
1504
|
+
headers.set("server-timing", existing ? `${existing}, ${timing}` : timing);
|
|
1505
|
+
headers.set(TRACE_SAMPLED_RESPONSE_HEADER, "1");
|
|
2460
1506
|
}
|
|
2461
1507
|
return new Response(response.body, {
|
|
2462
1508
|
status: response.status,
|
|
2463
1509
|
statusText: response.statusText,
|
|
2464
|
-
headers
|
|
1510
|
+
headers
|
|
2465
1511
|
});
|
|
2466
1512
|
}
|
|
2467
1513
|
function logTrace(options, request, workspaceId, trace, response) {
|
|
@@ -2476,8 +1522,8 @@ function logTrace(options, request, workspaceId, trace, response) {
|
|
|
2476
1522
|
method: request.method,
|
|
2477
1523
|
path: url.pathname,
|
|
2478
1524
|
status: response.status,
|
|
2479
|
-
totalMs:
|
|
2480
|
-
phases: Object.fromEntries(trace.phases.map((phase) => [phase.name,
|
|
1525
|
+
totalMs: roundedMs(totalMs),
|
|
1526
|
+
phases: Object.fromEntries(trace.phases.map((phase) => [phase.name, roundedMs(phase.ms)]))
|
|
2481
1527
|
};
|
|
2482
1528
|
if (options.traceLog) {
|
|
2483
1529
|
options.traceLog(event);
|
|
@@ -2488,57 +1534,45 @@ function logTrace(options, request, workspaceId, trace, response) {
|
|
|
2488
1534
|
function workspaceIdFromRelayPath(pathname) {
|
|
2489
1535
|
return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
2490
1536
|
}
|
|
2491
|
-
function
|
|
2492
|
-
return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
2493
|
-
}
|
|
2494
|
-
function websocketRequest2(request) {
|
|
1537
|
+
function websocketRequest(request) {
|
|
2495
1538
|
return request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
2496
1539
|
}
|
|
2497
|
-
function
|
|
1540
|
+
function relayWebSocketTraceEnabled(request) {
|
|
2498
1541
|
return request.headers.get("x-claxedo-relay-ws-trace") === "1";
|
|
2499
1542
|
}
|
|
2500
|
-
function workspaceIdsFromSearch(url) {
|
|
2501
|
-
return [...new Set([
|
|
2502
|
-
...url.searchParams.getAll("workspaceId"),
|
|
2503
|
-
...url.searchParams.getAll("workspace_id")
|
|
2504
|
-
].map(clean).filter((item) => !!item))];
|
|
2505
|
-
}
|
|
2506
1543
|
function randomToken() {
|
|
2507
1544
|
return crypto.randomUUID();
|
|
2508
1545
|
}
|
|
2509
|
-
function headerRecord(
|
|
2510
|
-
const result =
|
|
2511
|
-
|
|
1546
|
+
function headerRecord(headers) {
|
|
1547
|
+
const result = /* @__PURE__ */ Object.create(null);
|
|
1548
|
+
headers.forEach((value, key) => {
|
|
2512
1549
|
result[key] = value;
|
|
2513
1550
|
});
|
|
2514
1551
|
return result;
|
|
2515
1552
|
}
|
|
2516
1553
|
function headersFromRecord(input) {
|
|
2517
|
-
const
|
|
2518
|
-
for (const [key, value] of Object.entries(input))
|
|
2519
|
-
return
|
|
1554
|
+
const headers = new Headers();
|
|
1555
|
+
for (const [key, value] of Object.entries(input)) headers.set(key, value);
|
|
1556
|
+
return headers;
|
|
2520
1557
|
}
|
|
2521
1558
|
var BASE64_CHUNK_BYTES = 32768;
|
|
2522
1559
|
function bytesToBase64(bytes) {
|
|
2523
1560
|
let binary = "";
|
|
2524
1561
|
for (let i = 0; i < bytes.length; i += BASE64_CHUNK_BYTES) {
|
|
2525
|
-
binary += String.fromCharCode.
|
|
2526
|
-
null,
|
|
2527
|
-
bytes.subarray(i, i + BASE64_CHUNK_BYTES)
|
|
2528
|
-
);
|
|
1562
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_BYTES));
|
|
2529
1563
|
}
|
|
2530
1564
|
return btoa(binary);
|
|
2531
1565
|
}
|
|
2532
1566
|
function base64ToBytes(input) {
|
|
2533
1567
|
return Uint8Array.from(atob(input), (char) => char.charCodeAt(0));
|
|
2534
1568
|
}
|
|
2535
|
-
function
|
|
2536
|
-
|
|
2537
|
-
return
|
|
1569
|
+
function preOpenFrameBytes(input) {
|
|
1570
|
+
const payload = typeof input === "string" ? new TextEncoder().encode(input).byteLength : input.byteLength;
|
|
1571
|
+
return payload + UPSTREAM_WS_PRE_OPEN_QUEUE_FRAME_OVERHEAD_BYTES;
|
|
2538
1572
|
}
|
|
2539
|
-
function
|
|
2540
|
-
if (!input || typeof input !== "object") return
|
|
2541
|
-
return typeof input.arrayBuffer === "function"
|
|
1573
|
+
function isBlobLike(input) {
|
|
1574
|
+
if (!input || typeof input !== "object") return false;
|
|
1575
|
+
return "arrayBuffer" in input && typeof input.arrayBuffer === "function";
|
|
2542
1576
|
}
|
|
2543
1577
|
function viewBytes(input) {
|
|
2544
1578
|
return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
|
@@ -2568,9 +1602,8 @@ function socketFrame(input) {
|
|
|
2568
1602
|
data_base64: bytesToBase64(viewBytes(input))
|
|
2569
1603
|
};
|
|
2570
1604
|
}
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
return blob.arrayBuffer().then((buffer) => ({
|
|
1605
|
+
if (!isBlobLike(input)) return void 0;
|
|
1606
|
+
return input.arrayBuffer().then((buffer) => ({
|
|
2574
1607
|
binary: true,
|
|
2575
1608
|
data_base64: bytesToBase64(new Uint8Array(buffer))
|
|
2576
1609
|
}));
|
|
@@ -2580,9 +1613,8 @@ function socketPayload(input) {
|
|
|
2580
1613
|
if (input instanceof ArrayBuffer) return input;
|
|
2581
1614
|
if (input instanceof Uint8Array) return input;
|
|
2582
1615
|
if (ArrayBuffer.isView(input)) return viewBytes(input);
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
return blob.arrayBuffer();
|
|
1616
|
+
if (!isBlobLike(input)) return void 0;
|
|
1617
|
+
return input.arrayBuffer();
|
|
2586
1618
|
}
|
|
2587
1619
|
function orderedFrameDelivery(deliver) {
|
|
2588
1620
|
let tail;
|
|
@@ -2648,7 +1680,7 @@ async function readRequestBodyCapped(request, maxBytes) {
|
|
|
2648
1680
|
}
|
|
2649
1681
|
return concatBytes(chunks);
|
|
2650
1682
|
}
|
|
2651
|
-
function
|
|
1683
|
+
function safeCloseCode(code, fallback = 1011) {
|
|
2652
1684
|
if (code === void 0) return fallback;
|
|
2653
1685
|
if (code >= 1e3 && code <= 4999 && ![1004, 1005, 1006].includes(code)) return code;
|
|
2654
1686
|
return fallback;
|
|
@@ -2658,63 +1690,25 @@ function workspaceRelayDurableObjectRoomName(workspaceId) {
|
|
|
2658
1690
|
}
|
|
2659
1691
|
function workspaceRelayDurableObjectWorkspaceId(request) {
|
|
2660
1692
|
const url = new URL(request.url);
|
|
2661
|
-
const workspaceId =
|
|
1693
|
+
const workspaceId = trimToUndefined4(workspaceIdFromRelayPath(url.pathname));
|
|
2662
1694
|
if (workspaceId) return decodeURIComponent(workspaceId);
|
|
2663
|
-
if (!
|
|
2664
|
-
return
|
|
1695
|
+
if (!hostIdFromTunnelPath(url.pathname)) return void 0;
|
|
1696
|
+
return trimToUndefined4(url.searchParams.get("workspaceId")) ?? trimToUndefined4(url.searchParams.get("workspace_id"));
|
|
2665
1697
|
}
|
|
2666
1698
|
function namespaceFromEnv(env, options) {
|
|
2667
1699
|
if (options.namespace) return options.namespace;
|
|
2668
1700
|
const binding = env[options.bindingName ?? "WORKSPACE_RELAY_ROOM"];
|
|
2669
|
-
return binding
|
|
1701
|
+
return isDurableObjectNamespace(binding) ? binding : void 0;
|
|
1702
|
+
}
|
|
1703
|
+
function isDurableObjectNamespace(value) {
|
|
1704
|
+
if (!value || typeof value !== "object") return false;
|
|
1705
|
+
return "idFromName" in value && typeof value.idFromName === "function" && "get" in value && typeof value.get === "function";
|
|
2670
1706
|
}
|
|
2671
1707
|
function json(code, message, status) {
|
|
2672
1708
|
return Response.json(errorBody(code, message), { status });
|
|
2673
1709
|
}
|
|
2674
|
-
var configuredAppOriginAllowed = () => false;
|
|
2675
|
-
function setWorkspaceRelayAppOrigins(raw) {
|
|
2676
|
-
configuredAppOriginAllowed = raw?.trim() ? createOriginMatcher(parseAllowedOrigins(raw) ?? []) : () => false;
|
|
2677
|
-
}
|
|
2678
|
-
var defaultOriginAllowed = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
|
|
2679
|
-
var baseOriginAllowed = defaultOriginAllowed;
|
|
2680
|
-
function setWorkspaceRelayAllowedOrigins(raw) {
|
|
2681
|
-
const patterns = parseAllowedOrigins(raw);
|
|
2682
|
-
baseOriginAllowed = patterns ? createOriginMatcher(patterns) : defaultOriginAllowed;
|
|
2683
|
-
}
|
|
2684
|
-
function allowedCorsOrigin2(origin) {
|
|
2685
|
-
if (!origin) return;
|
|
2686
|
-
if (baseOriginAllowed(origin)) return origin;
|
|
2687
|
-
if (configuredAppOriginAllowed(origin)) return origin;
|
|
2688
|
-
}
|
|
2689
|
-
function corsHeaders(request) {
|
|
2690
|
-
const origin = allowedCorsOrigin2(request.headers.get("origin"));
|
|
2691
|
-
if (!origin) return;
|
|
2692
|
-
return {
|
|
2693
|
-
"access-control-allow-origin": origin,
|
|
2694
|
-
"access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
2695
|
-
"access-control-allow-headers": "Accept, Authorization, Content-Type, Last-Event-ID, 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",
|
|
2696
|
-
"access-control-max-age": "86400",
|
|
2697
|
-
vary: "Origin"
|
|
2698
|
-
};
|
|
2699
|
-
}
|
|
2700
|
-
function corsPreflight(request) {
|
|
2701
|
-
const headers2 = corsHeaders(request);
|
|
2702
|
-
return new Response(null, { status: headers2 ? 204 : 403, ...headers2 ? { headers: headers2 } : {} });
|
|
2703
|
-
}
|
|
2704
|
-
function withCors(request, response) {
|
|
2705
|
-
const headers2 = corsHeaders(request);
|
|
2706
|
-
if (!headers2) return response;
|
|
2707
|
-
if (response.status === 101 || response.webSocket) return response;
|
|
2708
|
-
const next = new Headers(response.headers);
|
|
2709
|
-
for (const [key, value] of Object.entries(headers2)) next.set(key, value);
|
|
2710
|
-
return new Response(response.body, {
|
|
2711
|
-
status: response.status,
|
|
2712
|
-
statusText: response.statusText,
|
|
2713
|
-
headers: next
|
|
2714
|
-
});
|
|
2715
|
-
}
|
|
2716
1710
|
function defaultWebSocketPair() {
|
|
2717
|
-
const pair = new
|
|
1711
|
+
const pair = new WebSocketPair();
|
|
2718
1712
|
return {
|
|
2719
1713
|
client: pair[0],
|
|
2720
1714
|
server: pair[1]
|
|
@@ -2722,7 +1716,7 @@ function defaultWebSocketPair() {
|
|
|
2722
1716
|
}
|
|
2723
1717
|
function selectedSubprotocol(request) {
|
|
2724
1718
|
const offered = request.headers.get("sec-websocket-protocol")?.split(",").map((item) => item.trim()).filter(Boolean);
|
|
2725
|
-
if (!offered?.length) return;
|
|
1719
|
+
if (!offered?.length) return void 0;
|
|
2726
1720
|
return offered.find((item) => item.startsWith("claxedo-rat.")) ?? offered[0];
|
|
2727
1721
|
}
|
|
2728
1722
|
function upgradeResponse(socket, request) {
|
|
@@ -2749,7 +1743,7 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
|
|
|
2749
1743
|
return {
|
|
2750
1744
|
async fetch(request, env = {}) {
|
|
2751
1745
|
const gatewayStartedAt = performance.now();
|
|
2752
|
-
const gatewayTraceId =
|
|
1746
|
+
const gatewayTraceId = trimToUndefined4(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
|
|
2753
1747
|
const url = new URL(request.url);
|
|
2754
1748
|
if (request.method === "OPTIONS" && (url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/"))) {
|
|
2755
1749
|
return corsPreflight(request);
|
|
@@ -2757,13 +1751,6 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
|
|
|
2757
1751
|
if (url.pathname === "/health") {
|
|
2758
1752
|
return Response.json({ ok: true, service: "workspace-relay", mode: "cloudflare-durable-object" });
|
|
2759
1753
|
}
|
|
2760
|
-
const requestRegion = relayRequestRegion(request);
|
|
2761
|
-
const requestCountry = relayRequestCountry(request);
|
|
2762
|
-
const locationHint = relayLocationHint({
|
|
2763
|
-
...typeof env.CLAXEDO_RELAY_LOCATION_HINT === "string" ? { configured: env.CLAXEDO_RELAY_LOCATION_HINT } : {},
|
|
2764
|
-
...requestRegion ? { region: requestRegion } : {},
|
|
2765
|
-
...requestCountry ? { country: requestCountry } : {}
|
|
2766
|
-
});
|
|
2767
1754
|
const namespace = namespaceFromEnv(env, options);
|
|
2768
1755
|
if (!namespace) {
|
|
2769
1756
|
return json(
|
|
@@ -2787,6 +1774,8 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
|
|
|
2787
1774
|
url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/") ? 400 : 404
|
|
2788
1775
|
);
|
|
2789
1776
|
}
|
|
1777
|
+
const placement = await workspaceRelayRoomPlacement(request, env, workspaceId);
|
|
1778
|
+
const locationHint = placement.locationHint;
|
|
2790
1779
|
const routeFinishedAt = performance.now();
|
|
2791
1780
|
const response = await namespace.get(namespace.idFromName(workspaceRelayDurableObjectRoomName(workspaceId)), {
|
|
2792
1781
|
locationHint
|
|
@@ -2796,37 +1785,39 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
|
|
|
2796
1785
|
startedAt: gatewayStartedAt,
|
|
2797
1786
|
routeKind: "gateway",
|
|
2798
1787
|
phases: []
|
|
2799
|
-
}));
|
|
1788
|
+
}, placement));
|
|
2800
1789
|
const fetchFinishedAt = performance.now();
|
|
2801
|
-
if (response.status === 101 || response.webSocket) {
|
|
2802
|
-
return url.pathname.startsWith("/workspaces/") ?
|
|
1790
|
+
if (response.status === 101 || "webSocket" in response && response.webSocket) {
|
|
1791
|
+
return url.pathname.startsWith("/workspaces/") ? withRelayResponseHeaders(request, response) : response;
|
|
2803
1792
|
}
|
|
2804
|
-
const
|
|
2805
|
-
|
|
2806
|
-
|
|
1793
|
+
const headers = new Headers(response.headers);
|
|
1794
|
+
headers.set(TRACE_ID_HEADER, headers.get(TRACE_ID_HEADER) ?? gatewayTraceId);
|
|
1795
|
+
headers.set("x-claxedo-relay-location-hint", locationHint);
|
|
2807
1796
|
const gatewayResponseStartedAt = performance.now();
|
|
2808
1797
|
const tracedResponse = new Response(response.body, {
|
|
2809
1798
|
status: response.status,
|
|
2810
1799
|
statusText: response.statusText,
|
|
2811
|
-
headers
|
|
1800
|
+
headers
|
|
2812
1801
|
});
|
|
2813
1802
|
const gatewayFinishedAt = performance.now();
|
|
2814
|
-
const
|
|
2815
|
-
|
|
1803
|
+
const sampled = tracedResponse.headers.get(TRACE_SAMPLED_RESPONSE_HEADER) === "1";
|
|
1804
|
+
tracedResponse.headers.delete(TRACE_SAMPLED_RESPONSE_HEADER);
|
|
1805
|
+
const roomTiming = tracedResponse.headers.get("server-timing") ?? "";
|
|
1806
|
+
if (sampled) {
|
|
2816
1807
|
const gatewayTiming = [
|
|
2817
|
-
`relay-gateway-route;dur=${
|
|
2818
|
-
`relay-gateway-do-fetch;dur=${
|
|
2819
|
-
`relay-gateway-response;dur=${
|
|
2820
|
-
`relay-gateway;dur=${
|
|
1808
|
+
`relay-gateway-route;dur=${roundedMs(routeFinishedAt - gatewayStartedAt)}`,
|
|
1809
|
+
`relay-gateway-do-fetch;dur=${roundedMs(fetchFinishedAt - routeFinishedAt)}`,
|
|
1810
|
+
`relay-gateway-response;dur=${roundedMs(gatewayFinishedAt - gatewayResponseStartedAt)}`,
|
|
1811
|
+
`relay-gateway;dur=${roundedMs(gatewayFinishedAt - gatewayStartedAt)}`
|
|
2821
1812
|
].join(", ");
|
|
2822
1813
|
tracedResponse.headers.set("server-timing", `${roomTiming}, ${gatewayTiming}`);
|
|
2823
1814
|
}
|
|
2824
|
-
return url.pathname.startsWith("/workspaces/") ?
|
|
1815
|
+
return url.pathname.startsWith("/workspaces/") ? withRelayResponseHeaders(request, tracedResponse) : tracedResponse;
|
|
2825
1816
|
}
|
|
2826
1817
|
};
|
|
2827
1818
|
}
|
|
2828
1819
|
function createWorkspaceRelayDurableObjectRoom(options) {
|
|
2829
|
-
const directory = options.directory ?? createWorkspaceRelayDirectory(
|
|
1820
|
+
const directory = options.directory ?? createWorkspaceRelayDirectory();
|
|
2830
1821
|
const relayOptions = { ...options, directory };
|
|
2831
1822
|
const hostTunnels = /* @__PURE__ */ new Map();
|
|
2832
1823
|
const clients = /* @__PURE__ */ new Set();
|
|
@@ -2867,6 +1858,8 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2867
1858
|
}
|
|
2868
1859
|
};
|
|
2869
1860
|
const cleanupTunnelResources = (tunnel, reason) => {
|
|
1861
|
+
if (tunnel.generationWatcher) clearInterval(tunnel.generationWatcher);
|
|
1862
|
+
tunnel.generationWatcher = void 0;
|
|
2870
1863
|
for (const pending of tunnel.pending.values()) {
|
|
2871
1864
|
clearTimeout(pending.timeout);
|
|
2872
1865
|
if (pending.stream?.slowConsumerTimeout) clearTimeout(pending.stream.slowConsumerTimeout);
|
|
@@ -2899,10 +1892,15 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2899
1892
|
const attachment = socketAttachment(socket);
|
|
2900
1893
|
if (!attachment) continue;
|
|
2901
1894
|
if (attachment.kind === "host-tunnel") {
|
|
1895
|
+
const incumbent = hostTunnels.get(attachment.hostId);
|
|
1896
|
+
if (incumbent && incumbent.connectedAt > attachment.connectedAt) continue;
|
|
2902
1897
|
hostTunnels.set(attachment.hostId, {
|
|
2903
1898
|
hostId: attachment.hostId,
|
|
2904
1899
|
workspaceIds: attachment.workspaceIds,
|
|
2905
1900
|
connectedAt: attachment.connectedAt,
|
|
1901
|
+
enrollmentId: attachment.enrollmentId,
|
|
1902
|
+
generation: attachment.generation,
|
|
1903
|
+
generationCheckFailures: attachment.generationCheckFailures ?? 0,
|
|
2906
1904
|
socket,
|
|
2907
1905
|
pending: /* @__PURE__ */ new Map(),
|
|
2908
1906
|
channels: /* @__PURE__ */ new Map()
|
|
@@ -2918,7 +1916,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2918
1916
|
for (const row of clientRows) {
|
|
2919
1917
|
const tunnel = hostTunnels.get(row.attachment.target.hostId);
|
|
2920
1918
|
if (!tunnel) {
|
|
2921
|
-
closeSocket(row.socket, 1011, "
|
|
1919
|
+
closeSocket(row.socket, 1011, "Host tunnel unavailable");
|
|
2922
1920
|
continue;
|
|
2923
1921
|
}
|
|
2924
1922
|
const client = {
|
|
@@ -2953,7 +1951,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2953
1951
|
const resolverUnreachable = (code) => code === "relay_revocation_resolver_unavailable";
|
|
2954
1952
|
const watchRuntimeAccessToken = (client, onInactive) => {
|
|
2955
1953
|
const intervalMs = options.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
|
|
2956
|
-
if (intervalMs <= 0) return;
|
|
1954
|
+
if (intervalMs <= 0) return void 0;
|
|
2957
1955
|
const now = options.now ?? Date.now;
|
|
2958
1956
|
let consecutiveUnreachable = 0;
|
|
2959
1957
|
const timer = setInterval(() => {
|
|
@@ -2969,7 +1967,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2969
1967
|
clearInterval(timer);
|
|
2970
1968
|
onInactive(reason);
|
|
2971
1969
|
};
|
|
2972
|
-
void Promise.resolve(options.isRuntimeAccessTokenActive(client.request.claims)).then((active) => {
|
|
1970
|
+
void Promise.resolve().then(() => options.isRuntimeAccessTokenActive(client.request.claims)).then((active) => {
|
|
2973
1971
|
if (active.active) {
|
|
2974
1972
|
consecutiveUnreachable = 0;
|
|
2975
1973
|
return;
|
|
@@ -2983,25 +1981,23 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2983
1981
|
onInactive(active.reason);
|
|
2984
1982
|
}).catch(() => tolerate("Runtime Access Token active check failed"));
|
|
2985
1983
|
}, intervalMs);
|
|
2986
|
-
|
|
2987
|
-
timer.unref();
|
|
2988
|
-
}
|
|
1984
|
+
unrefTimer(timer);
|
|
2989
1985
|
return timer;
|
|
2990
1986
|
};
|
|
2991
|
-
const
|
|
1987
|
+
const watchHostTunnelTarget = (client, onInactive) => {
|
|
2992
1988
|
const intervalMs = options.workspaceTargetActiveCheckIntervalMs ?? WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
|
|
2993
|
-
if (intervalMs <= 0) return;
|
|
1989
|
+
if (intervalMs <= 0) return void 0;
|
|
2994
1990
|
const target = client.request.target;
|
|
2995
1991
|
let consecutiveUnreachable = 0;
|
|
2996
1992
|
const timer = setInterval(() => {
|
|
2997
1993
|
void Promise.resolve(options.resolveTarget(client.request.claims)).then((activeTarget) => {
|
|
2998
|
-
if (activeTarget?.
|
|
1994
|
+
if (activeTarget?.backing === "local-worktree" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
|
|
2999
1995
|
consecutiveUnreachable = 0;
|
|
3000
1996
|
return;
|
|
3001
1997
|
}
|
|
3002
1998
|
consecutiveUnreachable = 0;
|
|
3003
1999
|
clearInterval(timer);
|
|
3004
|
-
onInactive("
|
|
2000
|
+
onInactive("The machine serving this workspace is offline");
|
|
3005
2001
|
}).catch(() => {
|
|
3006
2002
|
consecutiveUnreachable += 1;
|
|
3007
2003
|
if (consecutiveUnreachable < resolverGraceAttempts) return;
|
|
@@ -3009,11 +2005,53 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3009
2005
|
onInactive("Workspace relay target active check failed");
|
|
3010
2006
|
});
|
|
3011
2007
|
}, intervalMs);
|
|
3012
|
-
|
|
3013
|
-
timer.unref();
|
|
3014
|
-
}
|
|
2008
|
+
unrefTimer(timer);
|
|
3015
2009
|
return timer;
|
|
3016
2010
|
};
|
|
2011
|
+
const hostTunnelAttachment = (tunnel) => ({
|
|
2012
|
+
kind: "host-tunnel",
|
|
2013
|
+
hostId: tunnel.hostId,
|
|
2014
|
+
workspaceIds: tunnel.workspaceIds,
|
|
2015
|
+
connectedAt: tunnel.connectedAt,
|
|
2016
|
+
enrollmentId: tunnel.enrollmentId,
|
|
2017
|
+
generation: tunnel.generation,
|
|
2018
|
+
...tunnel.generationCheckFailures ? { generationCheckFailures: tunnel.generationCheckFailures } : {}
|
|
2019
|
+
});
|
|
2020
|
+
const recheckHostGeneration = async (tunnel) => {
|
|
2021
|
+
if (hostTunnels.get(tunnel.hostId) !== tunnel) return;
|
|
2022
|
+
const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, {
|
|
2023
|
+
enrollment_id: tunnel.enrollmentId,
|
|
2024
|
+
generation: tunnel.generation
|
|
2025
|
+
});
|
|
2026
|
+
if (hostTunnels.get(tunnel.hostId) !== tunnel) return;
|
|
2027
|
+
if (decision.ok) {
|
|
2028
|
+
if (tunnel.generationCheckFailures === 0) return;
|
|
2029
|
+
tunnel.generationCheckFailures = 0;
|
|
2030
|
+
tunnel.socket.serializeAttachment?.(hostTunnelAttachment(tunnel));
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
if (decision.retryable) {
|
|
2034
|
+
tunnel.generationCheckFailures += 1;
|
|
2035
|
+
if (tunnel.generationCheckFailures < resolverGraceAttempts) {
|
|
2036
|
+
tunnel.socket.serializeAttachment?.(hostTunnelAttachment(tunnel));
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
cleanupHostTunnel(tunnel.hostId, tunnel.socket, "Host generation check unavailable");
|
|
2040
|
+
closeSocket(tunnel.socket, 1012, "Host generation check unavailable");
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
cleanupHostTunnel(tunnel.hostId, tunnel.socket, decision.reason);
|
|
2044
|
+
closeSocket(tunnel.socket, 1008, decision.reason);
|
|
2045
|
+
};
|
|
2046
|
+
const watchHostGeneration = (tunnel) => {
|
|
2047
|
+
const intervalMs = options.hostGenerationCheckIntervalMs ?? HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT;
|
|
2048
|
+
if (tunnel.generationWatcher || hibernation || intervalMs <= 0) return;
|
|
2049
|
+
tunnel.generationWatcher = setInterval(() => {
|
|
2050
|
+
void recheckHostGeneration(tunnel).catch(() => {
|
|
2051
|
+
});
|
|
2052
|
+
}, intervalMs);
|
|
2053
|
+
unrefTimer(tunnel.generationWatcher);
|
|
2054
|
+
};
|
|
3017
2055
|
const clearSlowConsumerTimer = (pending) => {
|
|
3018
2056
|
if (!pending.stream?.slowConsumerTimeout) return;
|
|
3019
2057
|
clearTimeout(pending.stream.slowConsumerTimeout);
|
|
@@ -3028,7 +2066,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3028
2066
|
pending.stream.flowPaused = paused;
|
|
3029
2067
|
tunnel.socket.send?.(JSON.stringify({
|
|
3030
2068
|
type: "http.response.flow",
|
|
3031
|
-
protocol:
|
|
2069
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3032
2070
|
request_id: requestId,
|
|
3033
2071
|
paused,
|
|
3034
2072
|
reason
|
|
@@ -3049,7 +2087,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3049
2087
|
if (pending.stream?.slowConsumerTimeout) return;
|
|
3050
2088
|
pending.stream.slowConsumerTimeout = setTimeout(
|
|
3051
2089
|
() => failSlowConsumer(tunnel, requestId, pending),
|
|
3052
|
-
options.slowConsumerTimeoutMs ??
|
|
2090
|
+
options.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
|
|
3053
2091
|
);
|
|
3054
2092
|
};
|
|
3055
2093
|
const drainPendingStreamChunks = (tunnel, requestId, pending) => {
|
|
@@ -3103,7 +2141,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3103
2141
|
failPendingTunnelResponse(tunnel, oldestId, oldest, new Error("stream_evicted: tunnel started-stream cap reached"));
|
|
3104
2142
|
}
|
|
3105
2143
|
};
|
|
3106
|
-
const resolvePendingTunnelStream = (tunnel, requestId, pending, status,
|
|
2144
|
+
const resolvePendingTunnelStream = (tunnel, requestId, pending, status, headers) => {
|
|
3107
2145
|
if (pending.stream) return;
|
|
3108
2146
|
pending.startedSeq = ++startedSeqCounter;
|
|
3109
2147
|
evictOldestStartedStream(tunnel, requestId);
|
|
@@ -3127,18 +2165,18 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3127
2165
|
}
|
|
3128
2166
|
tunnel.socket.send?.(JSON.stringify({
|
|
3129
2167
|
type: "http.response.flow",
|
|
3130
|
-
protocol:
|
|
2168
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3131
2169
|
request_id: requestId,
|
|
3132
2170
|
paused: false,
|
|
3133
2171
|
reason: "closed"
|
|
3134
2172
|
}));
|
|
3135
2173
|
}
|
|
3136
2174
|
}, new ByteLengthQueuingStrategy({
|
|
3137
|
-
highWaterMark: options.slowConsumerHighWaterMarkBytes ??
|
|
2175
|
+
highWaterMark: options.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT
|
|
3138
2176
|
}));
|
|
3139
2177
|
pending.resolve(new Response(stream, {
|
|
3140
2178
|
status,
|
|
3141
|
-
headers
|
|
2179
|
+
headers
|
|
3142
2180
|
}));
|
|
3143
2181
|
};
|
|
3144
2182
|
const failPendingTunnelResponse = (tunnel, requestId, pending, error) => {
|
|
@@ -3184,18 +2222,18 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3184
2222
|
const active = await options.isRuntimeAccessTokenActive(client.request.claims);
|
|
3185
2223
|
return active.active ? { active: true } : { active: false, reason: active.reason };
|
|
3186
2224
|
};
|
|
3187
|
-
const
|
|
2225
|
+
const hostTunnelTargetActive = async (client) => {
|
|
3188
2226
|
const target = client.request.target;
|
|
3189
2227
|
const activeTarget = await options.resolveTarget(client.request.claims);
|
|
3190
|
-
return !!(activeTarget?.
|
|
2228
|
+
return !!(activeTarget?.backing === "local-worktree" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId }));
|
|
3191
2229
|
};
|
|
3192
|
-
const
|
|
2230
|
+
const dropHostTunnelChannel = (tunnel, channelId, client, code, reason) => {
|
|
3193
2231
|
tunnel.channels.delete(channelId);
|
|
3194
2232
|
clients.delete(client);
|
|
3195
2233
|
closeSocket(client.socket, code, reason);
|
|
3196
2234
|
sendSocket(tunnel.socket, JSON.stringify({
|
|
3197
2235
|
type: "ws.close",
|
|
3198
|
-
protocol:
|
|
2236
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3199
2237
|
channel_id: channelId,
|
|
3200
2238
|
code,
|
|
3201
2239
|
reason
|
|
@@ -3210,7 +2248,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3210
2248
|
const scheduleHibernatedRevocationCheck = async () => {
|
|
3211
2249
|
const intervalMs = hibernatedRevocationIntervalMs();
|
|
3212
2250
|
if (!hibernation || !options.alarms || intervalMs <= 0) return;
|
|
3213
|
-
if (clients.size === 0) return;
|
|
2251
|
+
if (clients.size === 0 && hostTunnels.size === 0) return;
|
|
3214
2252
|
const now = options.now ?? Date.now;
|
|
3215
2253
|
const at = now() + intervalMs;
|
|
3216
2254
|
try {
|
|
@@ -3224,16 +2262,17 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3224
2262
|
ensureHibernatedSocketsRebuilt();
|
|
3225
2263
|
const now = options.now ?? Date.now;
|
|
3226
2264
|
const revoke = (client, reason) => {
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
2265
|
+
if (isHostTunnelClient(client)) {
|
|
2266
|
+
for (const tunnel of hostTunnels.values()) {
|
|
2267
|
+
if (tunnel.channels.get(client.channelId) !== client) continue;
|
|
2268
|
+
dropHostTunnelChannel(tunnel, client.channelId, client, 1008, reason);
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
3232
2271
|
}
|
|
3233
2272
|
closeSocket(client.socket, 1008, reason);
|
|
3234
2273
|
clients.delete(client);
|
|
3235
2274
|
};
|
|
3236
|
-
for (const client of
|
|
2275
|
+
for (const client of Array.from(clients)) {
|
|
3237
2276
|
if (client.request.claims.exp * 1e3 <= now()) {
|
|
3238
2277
|
revoke(client, "Runtime Access Token expired");
|
|
3239
2278
|
continue;
|
|
@@ -3248,65 +2287,85 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3248
2287
|
if (active.active || resolverUnreachable(active.code)) continue;
|
|
3249
2288
|
revoke(client, active.reason);
|
|
3250
2289
|
}
|
|
2290
|
+
for (const tunnel of Array.from(hostTunnels.values())) {
|
|
2291
|
+
await recheckHostGeneration(tunnel);
|
|
2292
|
+
}
|
|
3251
2293
|
await scheduleHibernatedRevocationCheck();
|
|
3252
2294
|
};
|
|
3253
|
-
const
|
|
2295
|
+
const activeHostTunnelClient = (channelId, socket) => {
|
|
3254
2296
|
for (const tunnel of hostTunnels.values()) {
|
|
3255
2297
|
const client = tunnel.channels.get(channelId);
|
|
3256
2298
|
if (client?.socket === socket) return { tunnel, client };
|
|
3257
2299
|
}
|
|
2300
|
+
return void 0;
|
|
3258
2301
|
};
|
|
3259
|
-
const
|
|
3260
|
-
const active =
|
|
2302
|
+
const handleHostTunnelClientMessage = async (channelId, socket, data) => {
|
|
2303
|
+
const active = activeHostTunnelClient(channelId, socket);
|
|
3261
2304
|
if (!active) return;
|
|
3262
2305
|
const token = await runtimeAccessTokenActive(active.client);
|
|
3263
2306
|
if (!token.active) {
|
|
3264
2307
|
closeSocket(socket, 1008, token.reason);
|
|
3265
2308
|
return;
|
|
3266
2309
|
}
|
|
3267
|
-
if (!await
|
|
3268
|
-
closeSocket(socket, 1011, "
|
|
2310
|
+
if (!await hostTunnelTargetActive(active.client)) {
|
|
2311
|
+
closeSocket(socket, 1011, "The machine serving this workspace is offline");
|
|
3269
2312
|
return;
|
|
3270
2313
|
}
|
|
3271
2314
|
const frame = await socketFrame(data);
|
|
3272
2315
|
if (!frame) return;
|
|
3273
2316
|
const sent = sendSocket(active.tunnel.socket, JSON.stringify({
|
|
3274
2317
|
type: "ws.frame",
|
|
3275
|
-
protocol:
|
|
2318
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3276
2319
|
channel_id: channelId,
|
|
3277
2320
|
...frame
|
|
3278
2321
|
}));
|
|
3279
|
-
if (!sent) closeSocket(socket, 1011, "
|
|
2322
|
+
if (!sent) closeSocket(socket, 1011, "Host tunnel unavailable");
|
|
2323
|
+
};
|
|
2324
|
+
const applyRegistrationUpdate = async (tunnel, workspaceIds, token) => {
|
|
2325
|
+
const hostId = tunnel.hostId;
|
|
2326
|
+
const refuse = (code, reason) => {
|
|
2327
|
+
cleanupHostTunnel(hostId, tunnel.socket, reason);
|
|
2328
|
+
closeSocket(tunnel.socket, code, reason);
|
|
2329
|
+
};
|
|
2330
|
+
let claims;
|
|
2331
|
+
try {
|
|
2332
|
+
claims = await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
|
|
2333
|
+
} catch {
|
|
2334
|
+
if (hostTunnels.get(hostId) === tunnel) refuse(1008, "Host tunnel registration update denied");
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
if (hostTunnels.get(hostId) !== tunnel) return;
|
|
2338
|
+
if (hostTunnelIncumbentOutranks(tunnel.generation, claims.generation)) {
|
|
2339
|
+
refuse(1008, "Host tunnel registration update superseded");
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, claims);
|
|
2343
|
+
if (hostTunnels.get(hostId) !== tunnel) return;
|
|
2344
|
+
if (!decision.ok) {
|
|
2345
|
+
refuse(decision.retryable ? 1012 : 1008, decision.retryable ? "Host generation check unavailable" : decision.reason);
|
|
2346
|
+
return;
|
|
2347
|
+
}
|
|
2348
|
+
tunnel.workspaceIds = workspaceIds;
|
|
2349
|
+
tunnel.enrollmentId = claims.enrollment_id;
|
|
2350
|
+
tunnel.generation = claims.generation;
|
|
2351
|
+
tunnel.socket.serializeAttachment?.(hostTunnelAttachment(tunnel));
|
|
2352
|
+
directory.registerHostTunnel({ hostId, workspaceIds });
|
|
2353
|
+
watchHostGeneration(tunnel);
|
|
2354
|
+
void scheduleHibernatedRevocationCheck();
|
|
3280
2355
|
};
|
|
3281
|
-
const handleTunnelMessage = async (hostId, event) => {
|
|
2356
|
+
const handleTunnelMessage = async (hostId, socket, event) => {
|
|
3282
2357
|
const row = parseTunnelMessageData(event.data);
|
|
3283
2358
|
if (!row.ok) return;
|
|
3284
|
-
const parsed =
|
|
2359
|
+
const parsed = validateTunnelMessage(row.value);
|
|
3285
2360
|
const tunnel = hostTunnels.get(hostId);
|
|
3286
|
-
if (!parsed.ok || !tunnel) return;
|
|
2361
|
+
if (!parsed.ok || !tunnel || tunnel.socket !== socket) return;
|
|
3287
2362
|
directory.recordPong(tunnel.hostId);
|
|
3288
2363
|
if (parsed.message.type === "ping") {
|
|
3289
|
-
tunnel.socket.send?.(JSON.stringify(
|
|
2364
|
+
tunnel.socket.send?.(JSON.stringify(makeTunnelPong(parsed.message)));
|
|
3290
2365
|
return;
|
|
3291
2366
|
}
|
|
3292
2367
|
if (parsed.message.type === "host.registration.update") {
|
|
3293
|
-
|
|
3294
|
-
const workspaceIds = [...new Set(update.workspace_ids)];
|
|
3295
|
-
try {
|
|
3296
|
-
await verifyHostTunnelToken(update.token, options.runtimeAccessKey, { hostId, workspaceIds });
|
|
3297
|
-
} catch {
|
|
3298
|
-
closeSocket(tunnel.socket, 1008, "Host tunnel registration update denied");
|
|
3299
|
-
return;
|
|
3300
|
-
}
|
|
3301
|
-
if (hostTunnels.get(hostId) !== tunnel) return;
|
|
3302
|
-
tunnel.workspaceIds = workspaceIds;
|
|
3303
|
-
tunnel.socket.serializeAttachment?.({
|
|
3304
|
-
kind: "host-tunnel",
|
|
3305
|
-
hostId,
|
|
3306
|
-
workspaceIds,
|
|
3307
|
-
connectedAt: tunnel.connectedAt
|
|
3308
|
-
});
|
|
3309
|
-
directory.registerHostTunnel({ hostId, workspaceIds });
|
|
2368
|
+
await applyRegistrationUpdate(tunnel, [...new Set(parsed.message.workspace_ids)], parsed.message.token);
|
|
3310
2369
|
return;
|
|
3311
2370
|
}
|
|
3312
2371
|
if (parsed.message.type === "error" && parsed.message.request_id) {
|
|
@@ -3331,7 +2390,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3331
2390
|
if (!pending) return;
|
|
3332
2391
|
const chunk = base64ToBytes(message.body_base64);
|
|
3333
2392
|
if (pending.bytes + chunk.byteLength > (options.tunnelResponseBodyMaxBytes ?? TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT)) {
|
|
3334
|
-
failPendingTunnelResponse(tunnel, message.request_id, pending,
|
|
2393
|
+
failPendingTunnelResponse(tunnel, message.request_id, pending, hostTunnelFailure("host_tunnel_response_body_too_large"));
|
|
3335
2394
|
return;
|
|
3336
2395
|
}
|
|
3337
2396
|
pending.bytes += chunk.byteLength;
|
|
@@ -3377,7 +2436,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3377
2436
|
return;
|
|
3378
2437
|
}
|
|
3379
2438
|
if (!sendSocket(channel.socket, tunnelFrame(message))) {
|
|
3380
|
-
|
|
2439
|
+
dropHostTunnelChannel(tunnel, message.channel_id, channel, 1013, "Client WebSocket delivery failed");
|
|
3381
2440
|
}
|
|
3382
2441
|
return;
|
|
3383
2442
|
}
|
|
@@ -3387,7 +2446,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3387
2446
|
if (!channel) return;
|
|
3388
2447
|
tunnel.channels.delete(message.channel_id);
|
|
3389
2448
|
clients.delete(channel);
|
|
3390
|
-
closeSocket(channel.socket,
|
|
2449
|
+
closeSocket(channel.socket, safeCloseCode(message.code), message.reason ?? "");
|
|
3391
2450
|
return;
|
|
3392
2451
|
}
|
|
3393
2452
|
if (parsed.message.type === "error" && parsed.message.channel_id) {
|
|
@@ -3415,7 +2474,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3415
2474
|
hostTunnels.delete(hostId);
|
|
3416
2475
|
directory.disconnectHost(hostId);
|
|
3417
2476
|
};
|
|
3418
|
-
const
|
|
2477
|
+
const cleanupHostTunnelClient = (channelId, socket, event) => {
|
|
3419
2478
|
for (const tunnel of hostTunnels.values()) {
|
|
3420
2479
|
const active = tunnel.channels.get(channelId);
|
|
3421
2480
|
if (!active || active.socket !== socket) continue;
|
|
@@ -3423,17 +2482,17 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3423
2482
|
clients.delete(active);
|
|
3424
2483
|
sendSocket(tunnel.socket, JSON.stringify({
|
|
3425
2484
|
type: "ws.close",
|
|
3426
|
-
protocol:
|
|
2485
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3427
2486
|
channel_id: channelId,
|
|
3428
|
-
code:
|
|
2487
|
+
code: safeCloseCode(event?.code, 1e3),
|
|
3429
2488
|
reason: event?.reason ?? "Client WebSocket closed"
|
|
3430
2489
|
}));
|
|
3431
2490
|
return;
|
|
3432
2491
|
}
|
|
3433
2492
|
};
|
|
3434
2493
|
const admitHostTunnel = async (request, roomWorkspaceId, url) => {
|
|
3435
|
-
if (!
|
|
3436
|
-
const hostId =
|
|
2494
|
+
if (!websocketRequest(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
|
|
2495
|
+
const hostId = trimToUndefined4(hostIdFromTunnelPath(url.pathname));
|
|
3437
2496
|
if (!hostId) return json("host_tunnel_host_required", "Host tunnel id is required", 400);
|
|
3438
2497
|
const workspaceIds = workspaceIdsFromSearch(url);
|
|
3439
2498
|
if (!workspaceIds.includes(roomWorkspaceId)) {
|
|
@@ -3441,54 +2500,69 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3441
2500
|
}
|
|
3442
2501
|
const token = bearerToken(request.headers.get("authorization"));
|
|
3443
2502
|
if (!token) return json("host_tunnel_token_required", "Host Tunnel Token is required", 401);
|
|
2503
|
+
let claims;
|
|
3444
2504
|
try {
|
|
3445
|
-
await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
|
|
2505
|
+
claims = await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
|
|
3446
2506
|
} catch (err) {
|
|
3447
2507
|
if (err instanceof WorkspaceRelayAuthError) {
|
|
3448
2508
|
return json(err.code, "Host Tunnel Token was denied", 403);
|
|
3449
2509
|
}
|
|
3450
2510
|
throw err;
|
|
3451
2511
|
}
|
|
3452
|
-
const
|
|
3453
|
-
|
|
3454
|
-
kind: "host-tunnel",
|
|
3455
|
-
hostId,
|
|
3456
|
-
workspaceIds,
|
|
3457
|
-
connectedAt
|
|
3458
|
-
});
|
|
2512
|
+
const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, claims);
|
|
2513
|
+
if (!decision.ok) return json(decision.code, decision.reason, decision.retryable ? 503 : 403);
|
|
3459
2514
|
const previous = hostTunnels.get(hostId);
|
|
3460
|
-
if (previous) {
|
|
3461
|
-
|
|
3462
|
-
closeSocket(previous.socket, 1012, "Host tunnel replaced");
|
|
2515
|
+
if (previous && hostTunnelIncumbentOutranks(previous.generation, claims.generation)) {
|
|
2516
|
+
return json("host_generation_superseded", "Host tunnel generation was superseded", 403);
|
|
3463
2517
|
}
|
|
3464
|
-
|
|
2518
|
+
const connectedAt = options.now?.() ?? Date.now();
|
|
2519
|
+
const fence = {
|
|
2520
|
+
enrollmentId: claims.enrollment_id,
|
|
2521
|
+
generation: claims.generation
|
|
2522
|
+
};
|
|
2523
|
+
const pair = acceptSocket({ kind: "host-tunnel", hostId, workspaceIds, connectedAt, ...fence });
|
|
2524
|
+
const tunnel = {
|
|
3465
2525
|
hostId,
|
|
3466
2526
|
workspaceIds,
|
|
3467
2527
|
connectedAt,
|
|
2528
|
+
...fence,
|
|
2529
|
+
generationCheckFailures: 0,
|
|
3468
2530
|
socket: pair.server,
|
|
3469
2531
|
pending: /* @__PURE__ */ new Map(),
|
|
3470
2532
|
channels: /* @__PURE__ */ new Map()
|
|
3471
|
-
}
|
|
2533
|
+
};
|
|
2534
|
+
if (previous) {
|
|
2535
|
+
cleanupTunnelResources(previous, "Host tunnel replaced");
|
|
2536
|
+
closeSocket(previous.socket, 1012, "Host tunnel replaced");
|
|
2537
|
+
}
|
|
2538
|
+
hostTunnels.set(hostId, tunnel);
|
|
3472
2539
|
directory.registerHostTunnel({ hostId, workspaceIds });
|
|
2540
|
+
watchHostGeneration(tunnel);
|
|
2541
|
+
void scheduleHibernatedRevocationCheck();
|
|
3473
2542
|
if (!hibernation) {
|
|
3474
2543
|
pair.server.addEventListener?.("message", (event) => {
|
|
3475
2544
|
if (!event || event.data === void 0) return;
|
|
3476
|
-
void handleTunnelMessage(hostId, { data: event.data }).catch((err) => reportFrameHandlerFailure(pair.server, err));
|
|
2545
|
+
void handleTunnelMessage(hostId, pair.server, { data: event.data }).catch((err) => reportFrameHandlerFailure(pair.server, err));
|
|
3477
2546
|
});
|
|
3478
|
-
pair.server.addEventListener?.("close", () => cleanupHostTunnel(hostId, pair.server, "
|
|
3479
|
-
pair.server.addEventListener?.("error", () => cleanupHostTunnel(hostId, pair.server, "
|
|
2547
|
+
pair.server.addEventListener?.("close", () => cleanupHostTunnel(hostId, pair.server, "Host tunnel disconnected"));
|
|
2548
|
+
pair.server.addEventListener?.("error", () => cleanupHostTunnel(hostId, pair.server, "Host tunnel disconnected"));
|
|
3480
2549
|
}
|
|
3481
2550
|
return upgradeResponse(pair.client, request);
|
|
3482
2551
|
};
|
|
3483
2552
|
const admitClient = async (request, workspaceId) => {
|
|
3484
|
-
if (!
|
|
2553
|
+
if (!websocketRequest(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
|
|
3485
2554
|
const authorized = await authorizeWorkspaceRelayRequest(relayOptions, request, workspaceId);
|
|
3486
2555
|
if (!authorized.ok) return authorized.response;
|
|
3487
|
-
|
|
2556
|
+
const originDenied = workspaceUpgradeOriginDenied(request);
|
|
2557
|
+
if (originDenied) return originDenied;
|
|
2558
|
+
if (isHostTunnelTarget(authorized.request.target)) return admitHostTunnelClient(request, authorized.request);
|
|
2559
|
+
if (isSessionHostTarget(authorized.request.target)) {
|
|
2560
|
+
return json("durable_object_websocket_unsupported", "A session host serves its events over HTTP streams, not WebSockets", 400);
|
|
2561
|
+
}
|
|
3488
2562
|
return await admitCloudClient(request, authorized.request);
|
|
3489
2563
|
};
|
|
3490
2564
|
const admitCloudClient = async (request, authorized) => {
|
|
3491
|
-
const trace =
|
|
2565
|
+
const trace = relayWebSocketTraceEnabled(request) ? { upstreamStartedAt: performance.now(), queueTimes: [], emitted: false } : void 0;
|
|
3492
2566
|
let upstream;
|
|
3493
2567
|
try {
|
|
3494
2568
|
upstream = await connectWebSocket(
|
|
@@ -3502,7 +2576,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3502
2576
|
new Headers(),
|
|
3503
2577
|
authorized.relayHostToken,
|
|
3504
2578
|
authorized.target.workspaceId,
|
|
3505
|
-
{
|
|
2579
|
+
{ hostTunnel: false }
|
|
3506
2580
|
))
|
|
3507
2581
|
}
|
|
3508
2582
|
);
|
|
@@ -3522,9 +2596,9 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3522
2596
|
trace.emitted = true;
|
|
3523
2597
|
sendSocket(pair.server, JSON.stringify({
|
|
3524
2598
|
type: "relay.trace",
|
|
3525
|
-
wsUpstreamOpenMs:
|
|
2599
|
+
wsUpstreamOpenMs: roundedMs(openedAt - trace.upstreamStartedAt),
|
|
3526
2600
|
queuedFrames: trace.queueTimes.length,
|
|
3527
|
-
maxQueuedDelayMs:
|
|
2601
|
+
maxQueuedDelayMs: roundedMs(trace.queueTimes.reduce((max, at) => Math.max(max, openedAt - at), 0))
|
|
3528
2602
|
}));
|
|
3529
2603
|
};
|
|
3530
2604
|
const activeTokenTimer = watchRuntimeAccessToken(client, (reason) => {
|
|
@@ -3535,14 +2609,14 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3535
2609
|
closed = true;
|
|
3536
2610
|
if (activeTokenTimer) clearInterval(activeTokenTimer);
|
|
3537
2611
|
clients.delete(client);
|
|
3538
|
-
closeSocket(upstream,
|
|
2612
|
+
closeSocket(upstream, safeCloseCode(event?.code, 1e3), event?.reason ?? "Client WebSocket closed");
|
|
3539
2613
|
};
|
|
3540
2614
|
const cleanupUpstream = (event) => {
|
|
3541
2615
|
if (closed) return;
|
|
3542
2616
|
closed = true;
|
|
3543
2617
|
if (activeTokenTimer) clearInterval(activeTokenTimer);
|
|
3544
2618
|
clients.delete(client);
|
|
3545
|
-
closeSocket(pair.server,
|
|
2619
|
+
closeSocket(pair.server, safeCloseCode(event?.code), event?.reason ?? "");
|
|
3546
2620
|
};
|
|
3547
2621
|
const flushQueue = () => {
|
|
3548
2622
|
upstreamOpen = true;
|
|
@@ -3557,8 +2631,8 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3557
2631
|
};
|
|
3558
2632
|
const deliverClientFrame = orderedFrameDelivery((payload) => {
|
|
3559
2633
|
if (!upstreamOpen) {
|
|
3560
|
-
const frameBytes =
|
|
3561
|
-
if (queue.length >= (options.upstreamWebSocketPreOpenQueueMaxFrames ??
|
|
2634
|
+
const frameBytes = preOpenFrameBytes(payload);
|
|
2635
|
+
if (queue.length >= (options.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT) || queuedBytes + frameBytes > (options.upstreamWebSocketPreOpenQueueMaxBytes ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT)) {
|
|
3562
2636
|
closeSocket(pair.server, 1011, "Upstream WebSocket queue limit exceeded");
|
|
3563
2637
|
cleanupClient({ code: 1011, reason: "Upstream WebSocket queue limit exceeded" });
|
|
3564
2638
|
return;
|
|
@@ -3585,17 +2659,17 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3585
2659
|
if (trace && upstreamOpen) emitTrace(upstreamConnectedAt);
|
|
3586
2660
|
return upgradeResponse(pair.client, request);
|
|
3587
2661
|
};
|
|
3588
|
-
const
|
|
2662
|
+
const admitHostTunnelClient = (request, authorized) => {
|
|
3589
2663
|
const tunnel = hostTunnels.get(authorized.target.hostId);
|
|
3590
2664
|
if (!tunnel || !directory.activeHost({ hostId: authorized.target.hostId, workspaceId: authorized.target.workspaceId })) {
|
|
3591
|
-
return json("
|
|
2665
|
+
return json("host_tunnel_offline", "The machine serving this workspace is offline", 503);
|
|
3592
2666
|
}
|
|
3593
2667
|
if (tunnel.channels.size >= (options.tunnelChannelCap ?? TUNNEL_CHANNEL_CAP_DEFAULT)) {
|
|
3594
|
-
return json("too_many_channels", "
|
|
2668
|
+
return json("too_many_channels", "Host tunnel has too many active WebSocket channels", 503);
|
|
3595
2669
|
}
|
|
3596
2670
|
const channelId = randomToken();
|
|
3597
2671
|
const pair = acceptSocket({
|
|
3598
|
-
kind: "
|
|
2672
|
+
kind: "host-tunnel-client",
|
|
3599
2673
|
channelId,
|
|
3600
2674
|
claims: authorized.claims,
|
|
3601
2675
|
target: authorized.target,
|
|
@@ -3612,7 +2686,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3612
2686
|
const activeTokenTimer = hibernation ? void 0 : watchRuntimeAccessToken(client, (reason) => {
|
|
3613
2687
|
closeSocket(pair.server, 1008, reason);
|
|
3614
2688
|
});
|
|
3615
|
-
const activeTargetTimer = hibernation ? void 0 :
|
|
2689
|
+
const activeTargetTimer = hibernation ? void 0 : watchHostTunnelTarget(client, (reason) => {
|
|
3616
2690
|
closeSocket(pair.server, 1011, reason);
|
|
3617
2691
|
});
|
|
3618
2692
|
if (hibernation) void scheduleHibernatedRevocationCheck();
|
|
@@ -3620,18 +2694,18 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3620
2694
|
const cleanup = (event) => {
|
|
3621
2695
|
if (activeTokenTimer) clearInterval(activeTokenTimer);
|
|
3622
2696
|
if (activeTargetTimer) clearInterval(activeTargetTimer);
|
|
3623
|
-
|
|
2697
|
+
cleanupHostTunnelClient(channelId, pair.server, event);
|
|
3624
2698
|
};
|
|
3625
2699
|
pair.server.addEventListener?.("message", (event) => {
|
|
3626
2700
|
if (!event) return;
|
|
3627
|
-
void
|
|
2701
|
+
void handleHostTunnelClientMessage(channelId, pair.server, event.data).catch((err) => reportFrameHandlerFailure(pair.server, err));
|
|
3628
2702
|
});
|
|
3629
2703
|
pair.server.addEventListener?.("close", cleanup);
|
|
3630
2704
|
pair.server.addEventListener?.("error", cleanup);
|
|
3631
2705
|
}
|
|
3632
2706
|
const opened = sendSocket(tunnel.socket, JSON.stringify({
|
|
3633
2707
|
type: "ws.open",
|
|
3634
|
-
protocol:
|
|
2708
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3635
2709
|
channel_id: channelId,
|
|
3636
2710
|
workspace_id: authorized.target.workspaceId,
|
|
3637
2711
|
path: authorized.path,
|
|
@@ -3639,14 +2713,14 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3639
2713
|
new Headers(),
|
|
3640
2714
|
authorized.relayHostToken,
|
|
3641
2715
|
authorized.target.workspaceId,
|
|
3642
|
-
{
|
|
2716
|
+
{ hostTunnel: true }
|
|
3643
2717
|
))
|
|
3644
2718
|
}));
|
|
3645
2719
|
if (!opened) {
|
|
3646
2720
|
tunnel.channels.delete(channelId);
|
|
3647
2721
|
clients.delete(client);
|
|
3648
|
-
closeSocket(pair.server, 1011, "
|
|
3649
|
-
return json("
|
|
2722
|
+
closeSocket(pair.server, 1011, "Host tunnel unavailable");
|
|
2723
|
+
return json("host_tunnel_offline", "The machine serving this workspace is offline", 503);
|
|
3650
2724
|
}
|
|
3651
2725
|
return upgradeResponse(pair.client, request);
|
|
3652
2726
|
};
|
|
@@ -3659,9 +2733,13 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3659
2733
|
})
|
|
3660
2734
|
);
|
|
3661
2735
|
if (!authorized.ok) return authorized.response;
|
|
3662
|
-
if (authorized.request.target
|
|
3663
|
-
trace && (trace.routeKind = "
|
|
3664
|
-
return
|
|
2736
|
+
if (isHostTunnelTarget(authorized.request.target)) {
|
|
2737
|
+
trace && (trace.routeKind = "host-tunnel-http");
|
|
2738
|
+
return forwardHostTunnelHttp(request, authorized.request, trace);
|
|
2739
|
+
}
|
|
2740
|
+
if (isSessionHostTarget(authorized.request.target)) {
|
|
2741
|
+
trace && (trace.routeKind = "session-host-http");
|
|
2742
|
+
return traceSpan(trace, "session-host-fetch", () => forwardSessionHostHttp(options.sessionHost, request, authorized.request));
|
|
3665
2743
|
}
|
|
3666
2744
|
trace && (trace.routeKind = "cloud-http");
|
|
3667
2745
|
const controller = new AbortController();
|
|
@@ -3674,8 +2752,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3674
2752
|
new URL(request.url).search
|
|
3675
2753
|
),
|
|
3676
2754
|
workspaceRelayForwardRequestInit(request, authorized.request.relayHostToken, workspaceId, {
|
|
3677
|
-
signal: controller.signal
|
|
3678
|
-
upstreamHeaders: authorized.request.target.upstreamHeaders
|
|
2755
|
+
signal: controller.signal
|
|
3679
2756
|
})
|
|
3680
2757
|
));
|
|
3681
2758
|
return new Response(upstream.body, {
|
|
@@ -3696,27 +2773,27 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3696
2773
|
clearTimeout(timer);
|
|
3697
2774
|
}
|
|
3698
2775
|
};
|
|
3699
|
-
const
|
|
2776
|
+
const forwardHostTunnelHttp = async (request, authorized, trace) => {
|
|
3700
2777
|
const tunnel = hostTunnels.get(authorized.target.hostId);
|
|
3701
2778
|
if (!tunnel || !directory.activeHost({ hostId: authorized.target.hostId, workspaceId: authorized.target.workspaceId })) {
|
|
3702
|
-
return json("
|
|
2779
|
+
return json("host_tunnel_offline", "The machine serving this workspace is offline", 503);
|
|
3703
2780
|
}
|
|
3704
2781
|
const awaitingStart = [...tunnel.pending.values()].filter((entry) => entry.startedSeq === void 0).length;
|
|
3705
2782
|
if (awaitingStart >= (options.tunnelPendingHttpCap ?? TUNNEL_PENDING_HTTP_CAP_DEFAULT)) {
|
|
3706
|
-
return json("too_many_pending_requests", "
|
|
2783
|
+
return json("too_many_pending_requests", "Host tunnel has too many pending HTTP requests", 503);
|
|
3707
2784
|
}
|
|
3708
2785
|
const requestId = randomToken();
|
|
3709
2786
|
const init = workspaceRelayForwardRequestInit(request, authorized.relayHostToken, authorized.target.workspaceId, {
|
|
3710
|
-
|
|
2787
|
+
hostTunnel: true
|
|
3711
2788
|
});
|
|
3712
|
-
const bodyMaxBytes = options.tunnelRequestBodyMaxBytes ??
|
|
2789
|
+
const bodyMaxBytes = options.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT;
|
|
3713
2790
|
const contentLength = Number(request.headers.get("content-length") ?? Number.NaN);
|
|
3714
2791
|
if (Number.isFinite(contentLength) && contentLength > bodyMaxBytes) {
|
|
3715
|
-
return json("request_body_too_large", "
|
|
2792
|
+
return json("request_body_too_large", "Host tunnel request body exceeds the relay limit", 413);
|
|
3716
2793
|
}
|
|
3717
2794
|
const bodyBytes = request.method === "GET" || request.method === "HEAD" ? void 0 : await traceSpan(trace, "request-body", () => readRequestBodyCapped(request, bodyMaxBytes));
|
|
3718
2795
|
if (bodyBytes === "too_large") {
|
|
3719
|
-
return json("request_body_too_large", "
|
|
2796
|
+
return json("request_body_too_large", "Host tunnel request body exceeds the relay limit", 413);
|
|
3720
2797
|
}
|
|
3721
2798
|
const body = bodyBytes ? bytesToBase64(bodyBytes) : void 0;
|
|
3722
2799
|
const pending = new Promise((resolve, reject) => {
|
|
@@ -3724,12 +2801,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3724
2801
|
tunnel.pending.delete(requestId);
|
|
3725
2802
|
tunnel.socket.send?.(JSON.stringify({
|
|
3726
2803
|
type: "http.response.flow",
|
|
3727
|
-
protocol:
|
|
2804
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3728
2805
|
request_id: requestId,
|
|
3729
2806
|
paused: false,
|
|
3730
2807
|
reason: "closed"
|
|
3731
2808
|
}));
|
|
3732
|
-
reject(
|
|
2809
|
+
reject(hostTunnelFailure("host_tunnel_timeout"));
|
|
3733
2810
|
}, options.forwardTimeoutMs ?? 3e4);
|
|
3734
2811
|
tunnel.pending.set(requestId, {
|
|
3735
2812
|
chunks: [],
|
|
@@ -3741,7 +2818,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3741
2818
|
});
|
|
3742
2819
|
tunnel.socket.send?.(JSON.stringify({
|
|
3743
2820
|
type: "http.request",
|
|
3744
|
-
protocol:
|
|
2821
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3745
2822
|
request_id: requestId,
|
|
3746
2823
|
workspace_id: authorized.target.workspaceId,
|
|
3747
2824
|
method: request.method,
|
|
@@ -3753,15 +2830,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3753
2830
|
try {
|
|
3754
2831
|
return await traceSpan(trace, "tunnel-wait", () => pending);
|
|
3755
2832
|
} catch (err) {
|
|
3756
|
-
|
|
3757
|
-
const responseTooLarge = err instanceof Error && err.message.includes("response body exceeds");
|
|
3758
|
-
return Response.json(
|
|
3759
|
-
errorBody(
|
|
3760
|
-
timeout ? "user_hosted_tunnel_timeout" : responseTooLarge ? "user_hosted_response_body_too_large" : "user_hosted_tunnel_unavailable",
|
|
3761
|
-
timeout ? "User-hosted tunnel timed out" : responseTooLarge ? "User-hosted response body exceeds the relay limit" : "User-hosted tunnel is unavailable"
|
|
3762
|
-
),
|
|
3763
|
-
{ status: responseTooLarge ? 413 : 503 }
|
|
3764
|
-
);
|
|
2833
|
+
return hostTunnelFailureResponse(err);
|
|
3765
2834
|
}
|
|
3766
2835
|
};
|
|
3767
2836
|
return {
|
|
@@ -3788,7 +2857,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3788
2857
|
if (!roomWorkspaceId) return json("relay_workspace_required", "Workspace Relay requests must include a workspace id", 400);
|
|
3789
2858
|
if (url.pathname.startsWith("/host-tunnels/")) return admitHostTunnel(request, roomWorkspaceId, url);
|
|
3790
2859
|
if (url.pathname.startsWith("/workspaces/")) {
|
|
3791
|
-
if (
|
|
2860
|
+
if (websocketRequest(request)) return admitClient(request, roomWorkspaceId);
|
|
3792
2861
|
const trace = traceRequest(request, "workspace-http", options);
|
|
3793
2862
|
const roomTotalStartedAt = performance.now();
|
|
3794
2863
|
const nextRequest = tracedRequest(request, trace);
|
|
@@ -3796,7 +2865,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3796
2865
|
trace.phases.push({ name: "relay-room-total", ms: performance.now() - roomTotalStartedAt });
|
|
3797
2866
|
const tracedResponse = withTraceHeaders(response, trace);
|
|
3798
2867
|
logTrace(options, nextRequest, roomWorkspaceId, trace, tracedResponse);
|
|
3799
|
-
return
|
|
2868
|
+
return withRelayResponseHeaders(request, tracedResponse);
|
|
3800
2869
|
}
|
|
3801
2870
|
return json("relay_route_not_found", "Workspace Relay route not found", 404);
|
|
3802
2871
|
},
|
|
@@ -3806,17 +2875,17 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3806
2875
|
const attachment = socketAttachment(socket);
|
|
3807
2876
|
if (!attachment) return;
|
|
3808
2877
|
if (attachment.kind === "host-tunnel") {
|
|
3809
|
-
await handleTunnelMessage(attachment.hostId, { data: message });
|
|
2878
|
+
await handleTunnelMessage(attachment.hostId, socket, { data: message });
|
|
3810
2879
|
return;
|
|
3811
2880
|
}
|
|
3812
|
-
await
|
|
2881
|
+
await handleHostTunnelClientMessage(attachment.channelId, socket, message);
|
|
3813
2882
|
} catch (err) {
|
|
3814
2883
|
reportFrameHandlerFailure(socket, err);
|
|
3815
2884
|
}
|
|
3816
2885
|
},
|
|
3817
2886
|
/**
|
|
3818
2887
|
* Durable Object alarm entry point. Runs the hibernation-safe revocation
|
|
3819
|
-
* sweep
|
|
2888
|
+
* sweep. The Worker must forward `alarm()` here.
|
|
3820
2889
|
*/
|
|
3821
2890
|
async alarm() {
|
|
3822
2891
|
try {
|
|
@@ -3830,20 +2899,20 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3830
2899
|
const attachment = socketAttachment(socket);
|
|
3831
2900
|
if (!attachment) return;
|
|
3832
2901
|
if (attachment.kind === "host-tunnel") {
|
|
3833
|
-
cleanupHostTunnel(attachment.hostId, socket, "
|
|
2902
|
+
cleanupHostTunnel(attachment.hostId, socket, "Host tunnel disconnected");
|
|
3834
2903
|
return;
|
|
3835
2904
|
}
|
|
3836
|
-
|
|
2905
|
+
cleanupHostTunnelClient(attachment.channelId, socket, { code, reason });
|
|
3837
2906
|
},
|
|
3838
2907
|
webSocketError(socket) {
|
|
3839
2908
|
ensureHibernatedSocketsRebuilt();
|
|
3840
2909
|
const attachment = socketAttachment(socket);
|
|
3841
2910
|
if (!attachment) return;
|
|
3842
2911
|
if (attachment.kind === "host-tunnel") {
|
|
3843
|
-
cleanupHostTunnel(attachment.hostId, socket, "
|
|
2912
|
+
cleanupHostTunnel(attachment.hostId, socket, "Host tunnel disconnected");
|
|
3844
2913
|
return;
|
|
3845
2914
|
}
|
|
3846
|
-
|
|
2915
|
+
cleanupHostTunnelClient(attachment.channelId, socket, { code: 1011, reason: "WebSocket error" });
|
|
3847
2916
|
},
|
|
3848
2917
|
state() {
|
|
3849
2918
|
ensureHibernatedSocketsRebuilt();
|
|
@@ -3857,55 +2926,222 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3857
2926
|
drain: drainController
|
|
3858
2927
|
};
|
|
3859
2928
|
}
|
|
2929
|
+
|
|
2930
|
+
// ../claxedo-helpers/dist/deadline.mjs
|
|
2931
|
+
async function withDeadlineSignal(timeoutMs, work, parent) {
|
|
2932
|
+
const deadline = new AbortController();
|
|
2933
|
+
const timer = setTimeout(() => deadline.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
|
|
2934
|
+
try {
|
|
2935
|
+
return await work(parent ? AbortSignal.any([parent, deadline.signal]) : deadline.signal);
|
|
2936
|
+
} finally {
|
|
2937
|
+
clearTimeout(timer);
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
// src/resolver-clients.ts
|
|
2942
|
+
function parseRuntimeAccessTokenActiveResult(input) {
|
|
2943
|
+
if (!isRecord(input)) return void 0;
|
|
2944
|
+
const row = input;
|
|
2945
|
+
if (row.active === true) return { active: true };
|
|
2946
|
+
if (row.active !== false) return void 0;
|
|
2947
|
+
if (typeof row.code !== "string" || typeof row.reason !== "string") return void 0;
|
|
2948
|
+
return { active: false, code: row.code, reason: row.reason };
|
|
2949
|
+
}
|
|
2950
|
+
function parseHostGenerationResult(input) {
|
|
2951
|
+
if (!isRecord(input)) return void 0;
|
|
2952
|
+
const row = input;
|
|
2953
|
+
if (typeof row.enrollmentId !== "string" || !row.enrollmentId.trim()) return void 0;
|
|
2954
|
+
if (!isHostGeneration(row.generation) || typeof row.revoked !== "boolean") return void 0;
|
|
2955
|
+
return { enrollmentId: row.enrollmentId, generation: row.generation, revoked: row.revoked };
|
|
2956
|
+
}
|
|
2957
|
+
var HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT = 5e3;
|
|
2958
|
+
var HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE = "relay_resolver_enrollment_not_found";
|
|
2959
|
+
function createHostGenerationResolverLookup(url, options) {
|
|
2960
|
+
const fetcher = options.fetch ?? ((target, init) => fetch(target, init));
|
|
2961
|
+
const timeoutMs = options.timeoutMs ?? HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT;
|
|
2962
|
+
return async ({ enrollmentId }) => {
|
|
2963
|
+
const target = new URL(url);
|
|
2964
|
+
target.searchParams.set("enrollmentId", enrollmentId);
|
|
2965
|
+
return withDeadlineSignal(timeoutMs, async (signal) => {
|
|
2966
|
+
const res = await fetcher(target, { headers: options.headers, signal });
|
|
2967
|
+
if (res.status === 404) {
|
|
2968
|
+
const body = await res.json().catch(() => void 0);
|
|
2969
|
+
const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
|
|
2970
|
+
if (error?.code === HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE) return void 0;
|
|
2971
|
+
throw new Error(`relay host-generation resolver failed: 404 (no host-generation route at ${url})`);
|
|
2972
|
+
}
|
|
2973
|
+
if (!res.ok) throw new Error(`relay host-generation resolver failed: ${res.status} ${await res.text()}`);
|
|
2974
|
+
const result = parseHostGenerationResult(await res.json());
|
|
2975
|
+
if (!result) throw new Error("relay host-generation resolver returned a malformed result");
|
|
2976
|
+
return result;
|
|
2977
|
+
});
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
var RESOLVER_CACHE_MAX_ENTRIES = 8192;
|
|
2981
|
+
var REVOCATION_CACHE_TTL_MS_DEFAULT = 1e4;
|
|
2982
|
+
var TARGET_CACHE_TTL_MS_DEFAULT = 1e4;
|
|
2983
|
+
var DURABLE_OBJECT_TARGET_CACHE_TTL_MS_DEFAULT = 30 * 6e4;
|
|
2984
|
+
function pruneExpiringCache(cache, now, maxEntries) {
|
|
2985
|
+
for (const [key, entry] of cache) {
|
|
2986
|
+
if (entry.expiresAt <= now) cache.delete(key);
|
|
2987
|
+
}
|
|
2988
|
+
while (cache.size >= maxEntries) {
|
|
2989
|
+
const key = cache.keys().next().value;
|
|
2990
|
+
if (!key) return;
|
|
2991
|
+
cache.delete(key);
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
function createCachedRevocationClient(inner, options = {}) {
|
|
2995
|
+
const ttlMs = options.ttlMs ?? REVOCATION_CACHE_TTL_MS_DEFAULT;
|
|
2996
|
+
const now = options.now ?? Date.now;
|
|
2997
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2998
|
+
return async (args) => {
|
|
2999
|
+
const at = now();
|
|
3000
|
+
const entry = cache.get(args.jti);
|
|
3001
|
+
if (entry && entry.expiresAt > at) {
|
|
3002
|
+
if (entry.result) return entry.result;
|
|
3003
|
+
if (entry.promise) return await entry.promise;
|
|
3004
|
+
}
|
|
3005
|
+
const promise = inner(args).then((result) => {
|
|
3006
|
+
pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
|
|
3007
|
+
cache.set(args.jti, { result, expiresAt: now() + ttlMs });
|
|
3008
|
+
return result;
|
|
3009
|
+
}).catch((err) => {
|
|
3010
|
+
cache.delete(args.jti);
|
|
3011
|
+
throw err;
|
|
3012
|
+
});
|
|
3013
|
+
cache.set(args.jti, { promise, expiresAt: at + ttlMs });
|
|
3014
|
+
return await promise;
|
|
3015
|
+
};
|
|
3016
|
+
}
|
|
3017
|
+
function runtimeAccessTokenRevocationDelayMs(input = {}) {
|
|
3018
|
+
const configuredTtl = input.revocationCacheTtlMs ?? REVOCATION_CACHE_TTL_MS_DEFAULT;
|
|
3019
|
+
const ttlMs = Number.isFinite(configuredTtl) && configuredTtl > 0 ? configuredTtl : 0;
|
|
3020
|
+
if (input.activeCheckIntervalMs === void 0) return ttlMs;
|
|
3021
|
+
if (!Number.isFinite(input.activeCheckIntervalMs) || input.activeCheckIntervalMs <= 0) {
|
|
3022
|
+
return Number.POSITIVE_INFINITY;
|
|
3023
|
+
}
|
|
3024
|
+
return ttlMs + input.activeCheckIntervalMs;
|
|
3025
|
+
}
|
|
3026
|
+
function createCachedHostGenerationClient(inner, options = {}) {
|
|
3027
|
+
const ttlMs = options.ttlMs ?? 1e4;
|
|
3028
|
+
const now = options.now ?? Date.now;
|
|
3029
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3030
|
+
return async (args) => {
|
|
3031
|
+
for (; ; ) {
|
|
3032
|
+
const at = now();
|
|
3033
|
+
const entry = cache.get(args.enrollmentId);
|
|
3034
|
+
if (entry && entry.expiresAt > at) {
|
|
3035
|
+
if (entry.result && entry.result.generation >= args.generation) return entry.result;
|
|
3036
|
+
if (!entry.result && entry.promise) {
|
|
3037
|
+
const shared = await entry.promise;
|
|
3038
|
+
if (!shared || shared.generation >= args.generation) return shared;
|
|
3039
|
+
continue;
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
const promise = inner(args).then((result) => {
|
|
3043
|
+
pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
|
|
3044
|
+
if (result) cache.set(args.enrollmentId, { result, expiresAt: now() + ttlMs });
|
|
3045
|
+
else cache.delete(args.enrollmentId);
|
|
3046
|
+
return result;
|
|
3047
|
+
}).catch((err) => {
|
|
3048
|
+
cache.delete(args.enrollmentId);
|
|
3049
|
+
throw err;
|
|
3050
|
+
});
|
|
3051
|
+
cache.set(args.enrollmentId, { promise, expiresAt: at + ttlMs });
|
|
3052
|
+
return await promise;
|
|
3053
|
+
}
|
|
3054
|
+
};
|
|
3055
|
+
}
|
|
3056
|
+
function createCachedTargetClient(inner, options = {}) {
|
|
3057
|
+
const ttlMs = options.ttlMs ?? TARGET_CACHE_TTL_MS_DEFAULT;
|
|
3058
|
+
const durableObjectTtlMs = options.durableObjectTtlMs ?? DURABLE_OBJECT_TARGET_CACHE_TTL_MS_DEFAULT;
|
|
3059
|
+
const now = options.now ?? Date.now;
|
|
3060
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3061
|
+
return async (args) => {
|
|
3062
|
+
const key = `${args.workspaceId}\0${args.hostId}\0${args.routingId ?? ""}`;
|
|
3063
|
+
const at = now();
|
|
3064
|
+
const entry = cache.get(key);
|
|
3065
|
+
if (entry && entry.expiresAt > at) {
|
|
3066
|
+
if (entry.target) return entry.target;
|
|
3067
|
+
if (entry.promise) return await entry.promise;
|
|
3068
|
+
}
|
|
3069
|
+
const promise = inner(args).then((target) => {
|
|
3070
|
+
pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
|
|
3071
|
+
if (target) cache.set(key, { target, expiresAt: now() + (target.backing === "durable-object" ? durableObjectTtlMs : ttlMs) });
|
|
3072
|
+
else cache.delete(key);
|
|
3073
|
+
return target;
|
|
3074
|
+
}).catch((err) => {
|
|
3075
|
+
cache.delete(key);
|
|
3076
|
+
throw err;
|
|
3077
|
+
});
|
|
3078
|
+
cache.set(key, { promise, expiresAt: at + ttlMs });
|
|
3079
|
+
return await promise;
|
|
3080
|
+
};
|
|
3081
|
+
}
|
|
3860
3082
|
export {
|
|
3861
3083
|
DEFAULT_RELAY_APP_ORIGINS,
|
|
3862
3084
|
DEFAULT_RELAY_LOCATION_HINT,
|
|
3085
|
+
RELAY_ALLOWED_REQUEST_HEADERS,
|
|
3863
3086
|
RELAY_LOCATION_HINTS,
|
|
3864
3087
|
RELAY_REGION_HEADER,
|
|
3088
|
+
RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT,
|
|
3089
|
+
RUNTIME_ACCESS_TOKEN_ISSUED_AT_FLOOR_SECONDS,
|
|
3090
|
+
SESSION_HOST_LOCATION_HINT,
|
|
3865
3091
|
WorkspaceRelayAuthError,
|
|
3866
|
-
__slowConsumerInternalsForTest,
|
|
3867
3092
|
authorizeWorkspaceRelayRequest,
|
|
3868
3093
|
bytesToBase64,
|
|
3094
|
+
checkHostTunnelGeneration,
|
|
3095
|
+
corsPreflight,
|
|
3096
|
+
createCachedHostGenerationClient,
|
|
3869
3097
|
createCachedRevocationClient,
|
|
3870
3098
|
createCachedTargetClient,
|
|
3099
|
+
createHostGenerationResolverLookup,
|
|
3871
3100
|
createOriginMatcher,
|
|
3872
|
-
createWorkspaceRelay,
|
|
3873
|
-
createWorkspaceRelayBun,
|
|
3874
3101
|
createWorkspaceRelayDirectory,
|
|
3875
3102
|
createWorkspaceRelayDurableObjectGateway,
|
|
3876
3103
|
createWorkspaceRelayDurableObjectRoom,
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
forwardWorkspaceRelayRequest,
|
|
3881
|
-
getAuditSuppressedCount,
|
|
3104
|
+
deriveRelayHostKid,
|
|
3105
|
+
deriveRelayHostPublicKey,
|
|
3106
|
+
hostTunnelIncumbentOutranks,
|
|
3882
3107
|
hostTunnelTokenAudience,
|
|
3883
|
-
|
|
3108
|
+
isAllowedRelayTargetBaseUrl,
|
|
3109
|
+
isHostGeneration,
|
|
3110
|
+
isRelayBacking,
|
|
3884
3111
|
mintHostTunnelToken,
|
|
3885
3112
|
mintRelayHostToken,
|
|
3886
3113
|
mintRuntimeAccessToken,
|
|
3887
3114
|
parseAllowedOrigins,
|
|
3888
|
-
|
|
3115
|
+
parseRuntimeAccessTokenActiveResult,
|
|
3116
|
+
parseWorkspaceRelayTarget,
|
|
3117
|
+
pruneExpiringCache,
|
|
3889
3118
|
relayFrameDiagnostics,
|
|
3119
|
+
relayHostInputFromRuntimeClaims,
|
|
3890
3120
|
relayHostTokenAudience,
|
|
3891
3121
|
relayHostTokenIssuer,
|
|
3892
3122
|
relayLocationHint,
|
|
3893
|
-
relayOverBackpressureLimit,
|
|
3894
3123
|
relayRequestCountry,
|
|
3895
3124
|
relayRequestRegion,
|
|
3125
|
+
relayRoomPlacement,
|
|
3896
3126
|
runtimeAccessTokenAudience,
|
|
3897
3127
|
runtimeAccessTokenIssuer,
|
|
3128
|
+
runtimeAccessTokenRevocationDelayMs,
|
|
3898
3129
|
setWorkspaceRelayAllowedOrigins,
|
|
3899
3130
|
setWorkspaceRelayAppOrigins,
|
|
3131
|
+
toChannelIdentityClaim,
|
|
3132
|
+
tokenPurposeClaims,
|
|
3133
|
+
tokenScopeClaims,
|
|
3134
|
+
tokenScopePayload,
|
|
3900
3135
|
validateRuntimeAccessTokenClaims,
|
|
3901
3136
|
verifyHostTunnelToken,
|
|
3902
3137
|
verifyRelayHostToken,
|
|
3903
3138
|
verifyRuntimeAccessToken,
|
|
3139
|
+
withRelayResponseHeaders,
|
|
3904
3140
|
workspaceRelayDurableObjectRoomName,
|
|
3905
3141
|
workspaceRelayDurableObjectWorkspaceId,
|
|
3906
3142
|
workspaceRelayForwardHeaders,
|
|
3907
3143
|
workspaceRelayForwardRequestInit,
|
|
3908
|
-
|
|
3144
|
+
workspaceRelayRoomPlacement,
|
|
3909
3145
|
workspaceRelayTargetUrl,
|
|
3910
|
-
|
|
3146
|
+
workspaceUpgradeOriginDenied
|
|
3911
3147
|
};
|