@claxedo/workspace-relay 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -9
- package/dist/auth.d.ts +54 -3
- package/dist/bun.d.ts +105 -19
- package/dist/bun.mjs +2492 -0
- package/dist/cloudflare.d.ts +111 -0
- package/dist/directory.d.ts +17 -6
- package/dist/http.d.ts +2 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.mjs +957 -1517
- package/dist/main.d.ts +72 -43
- package/dist/main.mjs +20454 -39451
- package/dist/server.d.ts +139 -17
- package/dist/upstream-websocket.d.ts +34 -0
- package/dist/user-hosted-forwarding.d.ts +40 -0
- package/dist/worker.d.ts +42 -2
- package/dist/workerd-fixture/binary-frame-worker.d.ts +33 -0
- package/dist/workerd-fixture/boot.d.ts +18 -0
- package/package.json +24 -9
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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 isFiniteNumber(value) {
|
|
9
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
10
|
+
}
|
|
11
|
+
function asFiniteNumber(value) {
|
|
12
|
+
return isFiniteNumber(value) ? value : void 0;
|
|
13
|
+
}
|
|
14
|
+
function numberClaim(payload, key) {
|
|
15
|
+
return asFiniteNumber(payload[key]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/auth.ts
|
|
3
19
|
var algorithms = ["EdDSA", "ES256", "RS256"];
|
|
4
20
|
var runtimeAccessTokenIssuer = "claxedo-control-plane";
|
|
5
21
|
var runtimeAccessTokenAudience = "workspace-relay";
|
|
@@ -19,25 +35,51 @@ function seconds(input = Date.now()) {
|
|
|
19
35
|
return Math.floor(input / 1e3);
|
|
20
36
|
}
|
|
21
37
|
function requireAlgorithm(input) {
|
|
22
|
-
|
|
38
|
+
const algorithm = algorithms.find((candidate) => candidate === input);
|
|
39
|
+
if (algorithm) return algorithm;
|
|
23
40
|
throw new WorkspaceRelayAuthError("invalid_relay_token", "Unsupported relay token algorithm");
|
|
24
41
|
}
|
|
25
42
|
function stringClaim(payload, key) {
|
|
26
43
|
const value = payload[key];
|
|
27
44
|
return typeof value === "string" && value.trim() ? value : void 0;
|
|
28
45
|
}
|
|
29
|
-
function numberClaim(payload, key) {
|
|
30
|
-
const value = payload[key];
|
|
31
|
-
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
32
|
-
}
|
|
33
46
|
function stringArrayClaim(payload, key) {
|
|
34
47
|
const value = payload[key];
|
|
35
48
|
return Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim()) ? value : void 0;
|
|
36
49
|
}
|
|
50
|
+
function isHostGeneration(value) {
|
|
51
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
52
|
+
}
|
|
53
|
+
function hostGenerationClaims(payload) {
|
|
54
|
+
const enrollment_id = stringClaim(payload, "enrollment_id");
|
|
55
|
+
const generation = payload.generation;
|
|
56
|
+
if (generation === void 0) return enrollment_id ? { enrollment_id } : {};
|
|
57
|
+
if (!isHostGeneration(generation) || !enrollment_id) return null;
|
|
58
|
+
return { enrollment_id, generation };
|
|
59
|
+
}
|
|
37
60
|
function roleClaim(payload) {
|
|
38
61
|
const value = stringClaim(payload, "role");
|
|
39
62
|
return value === "viewer" || value === "editor" || value === "admin" || value === "owner" ? value : void 0;
|
|
40
63
|
}
|
|
64
|
+
function actorProfileClaims(payload) {
|
|
65
|
+
const actor_public_id = stringClaim(payload, "actor_public_id");
|
|
66
|
+
const actor_name = stringClaim(payload, "actor_name");
|
|
67
|
+
const actor_avatar_url = stringClaim(payload, "actor_avatar_url");
|
|
68
|
+
if (!actor_public_id && !actor_name && !actor_avatar_url) return {};
|
|
69
|
+
if (!actor_public_id || !actor_name) return void 0;
|
|
70
|
+
return { actor_public_id, actor_name, ...actor_avatar_url ? { actor_avatar_url } : {} };
|
|
71
|
+
}
|
|
72
|
+
function actorProfilePayload(input) {
|
|
73
|
+
if (!input.actorPublicId && !input.actorName && !input.actorAvatarUrl) return {};
|
|
74
|
+
if (!input.actorPublicId || !input.actorName) {
|
|
75
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Actor display profile claims are incomplete");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
actor_public_id: input.actorPublicId,
|
|
79
|
+
actor_name: input.actorName,
|
|
80
|
+
...input.actorAvatarUrl ? { actor_avatar_url: input.actorAvatarUrl } : {}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
41
83
|
function isRelayClaimPair(input) {
|
|
42
84
|
return input.access === "cloud" && input.backing === "cloud-vm" || input.access === "user-hosted" && input.backing === "local-worktree";
|
|
43
85
|
}
|
|
@@ -58,6 +100,21 @@ function checkTarget(payload, expected) {
|
|
|
58
100
|
throw new WorkspaceRelayAuthError("relay_token_host_mismatch", "Relay token host does not match request");
|
|
59
101
|
}
|
|
60
102
|
}
|
|
103
|
+
async function deriveRelayHostPublicKey(privateKey) {
|
|
104
|
+
const jwk = await exportJWK(privateKey);
|
|
105
|
+
const imported = await importJWK({ kty: jwk.kty, crv: jwk.crv, x: jwk.x }, "EdDSA", { extractable: true });
|
|
106
|
+
if (imported instanceof Uint8Array) {
|
|
107
|
+
throw new Error("Relay host public key imported as raw bytes; expected an EdDSA public key");
|
|
108
|
+
}
|
|
109
|
+
return imported;
|
|
110
|
+
}
|
|
111
|
+
async function deriveRelayHostKid(publicKey) {
|
|
112
|
+
const jwk = await exportJWK(publicKey);
|
|
113
|
+
const material = jwk.x ?? jwk.n ?? "";
|
|
114
|
+
if (!material) throw new Error("Cannot derive kid: public key has no public component");
|
|
115
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
|
|
116
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 16);
|
|
117
|
+
}
|
|
61
118
|
async function verifyJwt(token, key, input) {
|
|
62
119
|
try {
|
|
63
120
|
if (typeof key === "function") {
|
|
@@ -83,11 +140,15 @@ async function verifyJwt(token, key, input) {
|
|
|
83
140
|
async function mintRuntimeAccessToken(input, key, alg) {
|
|
84
141
|
const now = seconds(input.now);
|
|
85
142
|
return await new SignJWT({
|
|
143
|
+
principal_kind: input.principalKind,
|
|
144
|
+
actor_id: input.actorId,
|
|
145
|
+
actor_kind: input.actorKind,
|
|
146
|
+
...actorProfilePayload(input),
|
|
86
147
|
org_id: input.orgId,
|
|
87
148
|
workspace_id: input.workspaceId,
|
|
88
149
|
host_id: input.hostId,
|
|
89
150
|
role: input.role
|
|
90
|
-
}).setProtectedHeader({ alg: requireAlgorithm(alg) }).setIssuer(runtimeAccessTokenIssuer).setAudience(runtimeAccessTokenAudience).
|
|
151
|
+
}).setProtectedHeader({ alg: requireAlgorithm(alg) }).setIssuer(runtimeAccessTokenIssuer).setAudience(runtimeAccessTokenAudience).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 30 * 60)).setJti(input.jti ?? jti()).sign(key);
|
|
91
152
|
}
|
|
92
153
|
async function verifyRuntimeAccessToken(token, key, expected) {
|
|
93
154
|
const result = await verifyJwt(token, key, {
|
|
@@ -115,9 +176,14 @@ function validateRuntimeAccessTokenClaims(input, expected) {
|
|
|
115
176
|
}
|
|
116
177
|
async function mintHostTunnelToken(input, key, alg) {
|
|
117
178
|
const now = seconds(input.now);
|
|
179
|
+
if (input.generation !== void 0 && (!isHostGeneration(input.generation) || !input.enrollmentId)) {
|
|
180
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Host Tunnel Token generation requires a non-negative integer and an enrollment id");
|
|
181
|
+
}
|
|
118
182
|
return await new SignJWT({
|
|
119
183
|
host_id: input.hostId,
|
|
120
|
-
workspace_ids: input.workspaceIds
|
|
184
|
+
workspace_ids: input.workspaceIds,
|
|
185
|
+
...input.enrollmentId ? { enrollment_id: input.enrollmentId } : {},
|
|
186
|
+
...input.generation !== void 0 ? { generation: input.generation } : {}
|
|
121
187
|
}).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
188
|
}
|
|
123
189
|
async function verifyHostTunnelToken(token, key, expected) {
|
|
@@ -140,13 +206,18 @@ async function mintRelayHostToken(input, key, alg) {
|
|
|
140
206
|
const protectedHeader = { alg: requireAlgorithm(alg) };
|
|
141
207
|
if (input.kid) protectedHeader.kid = input.kid;
|
|
142
208
|
return await new SignJWT({
|
|
209
|
+
principal_kind: input.principalKind,
|
|
210
|
+
actor_id: input.actorId,
|
|
211
|
+
actor_kind: input.actorKind,
|
|
212
|
+
...actorProfilePayload(input),
|
|
143
213
|
org_id: input.orgId,
|
|
144
214
|
workspace_id: input.workspaceId,
|
|
145
215
|
host_id: input.hostId,
|
|
146
216
|
role: input.role,
|
|
147
217
|
access: input.access,
|
|
148
|
-
backing: input.backing
|
|
149
|
-
|
|
218
|
+
backing: input.backing,
|
|
219
|
+
parent_jti: input.parentJti
|
|
220
|
+
}).setProtectedHeader(protectedHeader).setIssuer(relayHostTokenIssuer).setAudience(relayHostTokenAudience).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 60)).setJti(input.jti ?? jti()).sign(key);
|
|
150
221
|
}
|
|
151
222
|
async function verifyRelayHostToken(token, key, expected) {
|
|
152
223
|
const result = await verifyJwt(token, key, {
|
|
@@ -163,17 +234,24 @@ async function verifyRelayHostToken(token, key, expected) {
|
|
|
163
234
|
function runtimeClaims(payload) {
|
|
164
235
|
const exp = numberClaim(payload, "exp");
|
|
165
236
|
const iat = numberClaim(payload, "iat");
|
|
166
|
-
const
|
|
237
|
+
const principal_kind = stringClaim(payload, "principal_kind");
|
|
238
|
+
const actor_id = stringClaim(payload, "actor_id");
|
|
239
|
+
const actor_kind = stringClaim(payload, "actor_kind");
|
|
167
240
|
const jti2 = stringClaim(payload, "jti");
|
|
168
241
|
const org_id = stringClaim(payload, "org_id");
|
|
169
242
|
const workspace_id = stringClaim(payload, "workspace_id");
|
|
170
243
|
const host_id = stringClaim(payload, "host_id");
|
|
171
244
|
const role = roleClaim(payload);
|
|
172
|
-
if (!exp || !iat || !
|
|
245
|
+
if (!exp || !iat || !jti2 || !org_id || !workspace_id || !host_id || !role || !actor_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;
|
|
246
|
+
const actorProfile = actorProfileClaims(payload);
|
|
247
|
+
if (!actorProfile) return void 0;
|
|
173
248
|
return {
|
|
174
249
|
iss: runtimeAccessTokenIssuer,
|
|
175
250
|
aud: runtimeAccessTokenAudience,
|
|
176
|
-
|
|
251
|
+
principal_kind,
|
|
252
|
+
actor_id,
|
|
253
|
+
actor_kind,
|
|
254
|
+
...actorProfile,
|
|
177
255
|
org_id,
|
|
178
256
|
workspace_id,
|
|
179
257
|
host_id,
|
|
@@ -191,12 +269,14 @@ function relayHostClaims(payload) {
|
|
|
191
269
|
});
|
|
192
270
|
const access = stringClaim(payload, "access");
|
|
193
271
|
const backing = stringClaim(payload, "backing");
|
|
272
|
+
const parent_jti = stringClaim(payload, "parent_jti");
|
|
194
273
|
const pair = { access, backing };
|
|
195
|
-
if (!base || !isRelayClaimPair(pair)) return;
|
|
274
|
+
if (!base || !parent_jti || !isRelayClaimPair(pair)) return void 0;
|
|
196
275
|
return {
|
|
197
276
|
...base,
|
|
198
277
|
iss: relayHostTokenIssuer,
|
|
199
278
|
aud: relayHostTokenAudience,
|
|
279
|
+
parent_jti,
|
|
200
280
|
...pair
|
|
201
281
|
};
|
|
202
282
|
}
|
|
@@ -207,22 +287,37 @@ function hostTunnelClaims(payload) {
|
|
|
207
287
|
const jti2 = stringClaim(payload, "jti");
|
|
208
288
|
const host_id = stringClaim(payload, "host_id");
|
|
209
289
|
const workspace_ids = stringArrayClaim(payload, "workspace_ids");
|
|
210
|
-
|
|
290
|
+
const fence = hostGenerationClaims(payload);
|
|
291
|
+
if (!exp || !iat || !sub || !jti2 || !host_id || !workspace_ids?.length || fence === null) return void 0;
|
|
211
292
|
return {
|
|
212
293
|
iss: runtimeAccessTokenIssuer,
|
|
213
294
|
aud: hostTunnelTokenAudience,
|
|
214
295
|
sub,
|
|
215
296
|
host_id,
|
|
216
297
|
workspace_ids,
|
|
298
|
+
...fence,
|
|
217
299
|
exp,
|
|
218
300
|
iat,
|
|
219
301
|
jti: jti2
|
|
220
302
|
};
|
|
221
303
|
}
|
|
222
304
|
|
|
223
|
-
// src/
|
|
305
|
+
// src/cloudflare.ts
|
|
306
|
+
import { trimToUndefined } from "@claxedo/helpers/string";
|
|
307
|
+
|
|
308
|
+
// src/http.ts
|
|
309
|
+
import { bearerToken } from "@claxedo/helpers/string";
|
|
310
|
+
function errorBody(code, message) {
|
|
311
|
+
return {
|
|
312
|
+
error: {
|
|
313
|
+
code,
|
|
314
|
+
message
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/cloudflare.ts
|
|
224
320
|
import {
|
|
225
|
-
makeTunnelPing,
|
|
226
321
|
makeTunnelPong,
|
|
227
322
|
TUNNEL_PROTOCOL_VERSION,
|
|
228
323
|
validateTunnelMessage
|
|
@@ -231,14 +326,12 @@ import {
|
|
|
231
326
|
// src/server.ts
|
|
232
327
|
import { Hono } from "hono";
|
|
233
328
|
import { cors } from "hono/cors";
|
|
234
|
-
import { exportJWK } from "jose";
|
|
329
|
+
import { exportJWK as exportJWK2 } from "jose";
|
|
235
330
|
|
|
236
331
|
// src/cors-origins.ts
|
|
237
332
|
var DEFAULT_RELAY_APP_ORIGINS = [
|
|
238
333
|
"http://localhost:*",
|
|
239
334
|
"http://127.0.0.1:*",
|
|
240
|
-
"https://opencode.ai",
|
|
241
|
-
"https://*.opencode.ai",
|
|
242
335
|
"https://claxedo.com",
|
|
243
336
|
"https://*.claxedo.com"
|
|
244
337
|
];
|
|
@@ -275,22 +368,76 @@ function createOriginMatcher(patterns) {
|
|
|
275
368
|
};
|
|
276
369
|
}
|
|
277
370
|
|
|
278
|
-
// src/
|
|
279
|
-
function
|
|
280
|
-
|
|
281
|
-
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
282
|
-
return match?.[1]?.trim() || void 0;
|
|
371
|
+
// src/user-hosted-forwarding.ts
|
|
372
|
+
function isUserHostedTarget(target) {
|
|
373
|
+
return target.access === "user-hosted";
|
|
283
374
|
}
|
|
284
|
-
|
|
375
|
+
|
|
376
|
+
// src/server.ts
|
|
377
|
+
function parseWorkspaceRelayTarget(input) {
|
|
378
|
+
if (!isRecord(input)) return void 0;
|
|
379
|
+
const row = input;
|
|
380
|
+
const { workspaceId, hostId, baseUrl, upstreamHeaders } = row;
|
|
381
|
+
if (typeof workspaceId !== "string" || typeof hostId !== "string" || typeof baseUrl !== "string") return void 0;
|
|
382
|
+
if (upstreamHeaders !== void 0 && !isStringRecord(upstreamHeaders)) return void 0;
|
|
383
|
+
const pair = relayClaimPair(row.access, row.backing);
|
|
384
|
+
if (!pair) return void 0;
|
|
285
385
|
return {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
386
|
+
workspaceId,
|
|
387
|
+
hostId,
|
|
388
|
+
baseUrl,
|
|
389
|
+
...upstreamHeaders ? { upstreamHeaders } : {},
|
|
390
|
+
...pair
|
|
290
391
|
};
|
|
291
392
|
}
|
|
292
|
-
|
|
293
|
-
|
|
393
|
+
function parseRuntimeAccessTokenActiveResult(input) {
|
|
394
|
+
if (!isRecord(input)) return void 0;
|
|
395
|
+
const row = input;
|
|
396
|
+
if (row.active === true) return { active: true };
|
|
397
|
+
if (row.active !== false) return void 0;
|
|
398
|
+
if (typeof row.code !== "string" || typeof row.reason !== "string") return void 0;
|
|
399
|
+
return { active: false, code: row.code, reason: row.reason };
|
|
400
|
+
}
|
|
401
|
+
function parseHostGenerationResult(input) {
|
|
402
|
+
if (!isRecord(input)) return void 0;
|
|
403
|
+
const row = input;
|
|
404
|
+
if (typeof row.enrollmentId !== "string" || !row.enrollmentId.trim()) return void 0;
|
|
405
|
+
if (!isHostGeneration(row.generation) || typeof row.revoked !== "boolean") return void 0;
|
|
406
|
+
return { enrollmentId: row.enrollmentId, generation: row.generation, revoked: row.revoked };
|
|
407
|
+
}
|
|
408
|
+
var HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT = 5e3;
|
|
409
|
+
var HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE = "relay_resolver_enrollment_not_found";
|
|
410
|
+
function createHostGenerationResolverLookup(url, options) {
|
|
411
|
+
const fetcher = options.fetch ?? ((target, init) => fetch(target, init));
|
|
412
|
+
const timeoutMs = options.timeoutMs ?? HOST_GENERATION_LOOKUP_TIMEOUT_MS_DEFAULT;
|
|
413
|
+
return async ({ enrollmentId }) => {
|
|
414
|
+
const target = new URL(url);
|
|
415
|
+
target.searchParams.set("enrollmentId", enrollmentId);
|
|
416
|
+
const res = await fetcher(target, { headers: options.headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
417
|
+
if (res.status === 404) {
|
|
418
|
+
const body = await res.json().catch(() => void 0);
|
|
419
|
+
const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
|
|
420
|
+
if (error?.code === HOST_GENERATION_ENROLLMENT_NOT_FOUND_CODE) return void 0;
|
|
421
|
+
throw new Error(`relay host-generation resolver failed: 404 (no host-generation route at ${url})`);
|
|
422
|
+
}
|
|
423
|
+
if (!res.ok) throw new Error(`relay host-generation resolver failed: ${res.status} ${await res.text()}`);
|
|
424
|
+
const result = parseHostGenerationResult(await res.json());
|
|
425
|
+
if (!result) throw new Error("relay host-generation resolver returned a malformed result");
|
|
426
|
+
return result;
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function hostTunnelIncumbentOutranks(incumbentGeneration, candidateGeneration) {
|
|
430
|
+
if (incumbentGeneration === void 0) return false;
|
|
431
|
+
return candidateGeneration === void 0 || incumbentGeneration > candidateGeneration;
|
|
432
|
+
}
|
|
433
|
+
function relayClaimPair(access, backing) {
|
|
434
|
+
if (access === "cloud" && backing === "cloud-vm") return { access, backing };
|
|
435
|
+
if (access === "user-hosted" && backing === "local-worktree") return { access, backing };
|
|
436
|
+
return void 0;
|
|
437
|
+
}
|
|
438
|
+
function isStringRecord(input) {
|
|
439
|
+
return isRecord(input) && Object.values(input).every((value) => typeof value === "string");
|
|
440
|
+
}
|
|
294
441
|
var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
|
|
295
442
|
var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
|
|
296
443
|
var RESOLVER_CACHE_MAX_ENTRIES = 8192;
|
|
@@ -330,6 +477,71 @@ function createCachedRevocationClient(inner, options = {}) {
|
|
|
330
477
|
return await promise;
|
|
331
478
|
};
|
|
332
479
|
}
|
|
480
|
+
function createCachedHostGenerationClient(inner, options = {}) {
|
|
481
|
+
const ttlMs = options.ttlMs ?? 1e4;
|
|
482
|
+
const now = options.now ?? Date.now;
|
|
483
|
+
const cache = /* @__PURE__ */ new Map();
|
|
484
|
+
return async (args) => {
|
|
485
|
+
for (; ; ) {
|
|
486
|
+
const at = now();
|
|
487
|
+
const entry = cache.get(args.enrollmentId);
|
|
488
|
+
if (entry && entry.expiresAt > at) {
|
|
489
|
+
if (entry.result && entry.result.generation >= args.generation) return entry.result;
|
|
490
|
+
if (!entry.result && entry.promise) {
|
|
491
|
+
const shared = await entry.promise;
|
|
492
|
+
if (!shared || shared.generation >= args.generation) return shared;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const promise = inner(args).then((result) => {
|
|
497
|
+
pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
|
|
498
|
+
if (result) cache.set(args.enrollmentId, { result, expiresAt: now() + ttlMs });
|
|
499
|
+
else cache.delete(args.enrollmentId);
|
|
500
|
+
return result;
|
|
501
|
+
}).catch((err) => {
|
|
502
|
+
cache.delete(args.enrollmentId);
|
|
503
|
+
throw err;
|
|
504
|
+
});
|
|
505
|
+
cache.set(args.enrollmentId, { promise, expiresAt: at + ttlMs });
|
|
506
|
+
return await promise;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
async function checkHostTunnelGeneration(lookup, claims) {
|
|
511
|
+
if (claims.generation === void 0 || !claims.enrollment_id) return { ok: true };
|
|
512
|
+
if (!lookup) {
|
|
513
|
+
return {
|
|
514
|
+
ok: false,
|
|
515
|
+
retryable: false,
|
|
516
|
+
code: "host_generation_unverifiable",
|
|
517
|
+
reason: "Host tunnel generation cannot be verified by a relay without a host-generation resolver"
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
let result;
|
|
521
|
+
try {
|
|
522
|
+
result = await lookup({ enrollmentId: claims.enrollment_id, generation: claims.generation });
|
|
523
|
+
} catch (err) {
|
|
524
|
+
return {
|
|
525
|
+
ok: false,
|
|
526
|
+
retryable: true,
|
|
527
|
+
code: "host_generation_lookup_unavailable",
|
|
528
|
+
reason: `Host generation lookup is unavailable: ${err instanceof Error ? err.message : String(err)}`
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
if (!result) {
|
|
532
|
+
return { ok: false, retryable: false, code: "host_enrollment_unknown", reason: "Host enrollment is unknown" };
|
|
533
|
+
}
|
|
534
|
+
if (result.revoked) {
|
|
535
|
+
return { ok: false, retryable: false, code: "host_enrollment_revoked", reason: "Host enrollment was revoked" };
|
|
536
|
+
}
|
|
537
|
+
if (result.generation > claims.generation) {
|
|
538
|
+
return { ok: false, retryable: false, code: "host_generation_superseded", reason: "Host tunnel generation was superseded" };
|
|
539
|
+
}
|
|
540
|
+
if (result.generation < claims.generation) {
|
|
541
|
+
return { ok: false, retryable: false, code: "host_generation_unknown", reason: "Host tunnel generation is ahead of the control plane" };
|
|
542
|
+
}
|
|
543
|
+
return { ok: true };
|
|
544
|
+
}
|
|
333
545
|
function createCachedTargetClient(inner, options = {}) {
|
|
334
546
|
const ttlMs = options.ttlMs ?? 5e3;
|
|
335
547
|
const now = options.now ?? Date.now;
|
|
@@ -391,27 +603,27 @@ function isDangerousInboundHeader(name) {
|
|
|
391
603
|
return false;
|
|
392
604
|
}
|
|
393
605
|
function forwardHeaders(input, relayHostToken, workspaceId, options = {}) {
|
|
394
|
-
const
|
|
395
|
-
|
|
606
|
+
const headers = new Headers(input);
|
|
607
|
+
headers.delete("host");
|
|
396
608
|
const inboundNames = /* @__PURE__ */ new Set();
|
|
397
|
-
|
|
609
|
+
headers.forEach((_value, name) => inboundNames.add(name));
|
|
398
610
|
for (const name of inboundNames) {
|
|
399
|
-
if (isDangerousInboundHeader(name))
|
|
400
|
-
}
|
|
401
|
-
if (options.userHosted)
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
611
|
+
if (isDangerousInboundHeader(name)) headers.delete(name);
|
|
612
|
+
}
|
|
613
|
+
if (options.userHosted) headers.delete("cookie");
|
|
614
|
+
headers.set("accept-encoding", "identity");
|
|
615
|
+
headers.delete("authorization");
|
|
616
|
+
headers.delete("Authorization");
|
|
617
|
+
headers.set("Authorization", `Bearer ${relayHostToken}`);
|
|
618
|
+
headers.set("x-workspace-id", workspaceId);
|
|
619
|
+
headers.set("X-Daytona-Skip-Preview-Warning", "true");
|
|
620
|
+
headers.set("X-Daytona-Skip-Last-Activity-Update", "true");
|
|
409
621
|
for (const [name, value] of Object.entries(options.upstreamHeaders ?? {})) {
|
|
410
622
|
const trimmed = value.trim();
|
|
411
|
-
if (trimmed)
|
|
623
|
+
if (trimmed) headers.set(name, trimmed);
|
|
412
624
|
}
|
|
413
|
-
|
|
414
|
-
return
|
|
625
|
+
headers.set("x-forwarded-by", "workspace-relay");
|
|
626
|
+
return headers;
|
|
415
627
|
}
|
|
416
628
|
function targetUrl(target, path, search) {
|
|
417
629
|
const url = new URL(path.replace(/^\/+/, ""), target.baseUrl.endsWith("/") ? target.baseUrl : `${target.baseUrl}/`);
|
|
@@ -420,11 +632,9 @@ function targetUrl(target, path, search) {
|
|
|
420
632
|
}
|
|
421
633
|
var RELAY_VIEWER_DENIED_PATH = /^\/api\/wr\/pty(?:\/|$)/;
|
|
422
634
|
function relayRuntimePath(request) {
|
|
423
|
-
return new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/");
|
|
424
|
-
}
|
|
425
|
-
function relayAuthorizationPath(path) {
|
|
426
635
|
try {
|
|
427
|
-
|
|
636
|
+
const decoded = decodeURIComponent(new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/")).replace(/^\/+/, "/");
|
|
637
|
+
return new URL(decoded.replace(/^\/+/, ""), "http://relay.invalid/").pathname;
|
|
428
638
|
} catch {
|
|
429
639
|
return void 0;
|
|
430
640
|
}
|
|
@@ -432,8 +642,7 @@ function relayAuthorizationPath(path) {
|
|
|
432
642
|
function roleAllowsRelayRequest(role, method, path) {
|
|
433
643
|
if (role === "owner" || role === "admin" || role === "editor") return true;
|
|
434
644
|
if (role === "viewer") {
|
|
435
|
-
|
|
436
|
-
if (!authorizationPath || RELAY_VIEWER_DENIED_PATH.test(authorizationPath)) return false;
|
|
645
|
+
if (RELAY_VIEWER_DENIED_PATH.test(path)) return false;
|
|
437
646
|
return method === "GET" || method === "HEAD" || method === "OPTIONS";
|
|
438
647
|
}
|
|
439
648
|
return false;
|
|
@@ -447,7 +656,9 @@ function relayHostTokenCacheTtlMs(options, claims) {
|
|
|
447
656
|
function relayHostTokenCacheKey(options, claims, target) {
|
|
448
657
|
return [
|
|
449
658
|
claims.jti,
|
|
450
|
-
claims.
|
|
659
|
+
claims.principal_kind,
|
|
660
|
+
claims.actor_id,
|
|
661
|
+
claims.actor_kind,
|
|
451
662
|
claims.org_id,
|
|
452
663
|
claims.role,
|
|
453
664
|
claims.workspace_id,
|
|
@@ -552,7 +763,15 @@ async function relayHostTokenFor(options, claims, target, trace) {
|
|
|
552
763
|
try {
|
|
553
764
|
const token = await trace.span("rht-mint", async () => {
|
|
554
765
|
promise = mintRelayHostToken({
|
|
555
|
-
|
|
766
|
+
principalKind: claims.principal_kind,
|
|
767
|
+
actorId: claims.actor_id,
|
|
768
|
+
actorKind: claims.actor_kind,
|
|
769
|
+
parentJti: claims.jti,
|
|
770
|
+
...claims.actor_public_id && claims.actor_name ? {
|
|
771
|
+
actorPublicId: claims.actor_public_id,
|
|
772
|
+
actorName: claims.actor_name,
|
|
773
|
+
...claims.actor_avatar_url ? { actorAvatarUrl: claims.actor_avatar_url } : {}
|
|
774
|
+
} : {},
|
|
556
775
|
orgId: claims.org_id,
|
|
557
776
|
role: claims.role,
|
|
558
777
|
...target,
|
|
@@ -576,7 +795,15 @@ async function uncachedRelayHostTokenFor(options, claims, target, trace) {
|
|
|
576
795
|
return await trace.span(
|
|
577
796
|
"rht-mint",
|
|
578
797
|
async () => await mintRelayHostToken({
|
|
579
|
-
|
|
798
|
+
principalKind: claims.principal_kind,
|
|
799
|
+
actorId: claims.actor_id,
|
|
800
|
+
actorKind: claims.actor_kind,
|
|
801
|
+
parentJti: claims.jti,
|
|
802
|
+
...claims.actor_public_id && claims.actor_name ? {
|
|
803
|
+
actorPublicId: claims.actor_public_id,
|
|
804
|
+
actorName: claims.actor_name,
|
|
805
|
+
...claims.actor_avatar_url ? { actorAvatarUrl: claims.actor_avatar_url } : {}
|
|
806
|
+
} : {},
|
|
580
807
|
orgId: claims.org_id,
|
|
581
808
|
role: claims.role,
|
|
582
809
|
...target,
|
|
@@ -682,13 +909,13 @@ function workspaceRelayServerTiming(trace) {
|
|
|
682
909
|
function workspaceRelayTimingResponse(response, trace) {
|
|
683
910
|
const value = workspaceRelayServerTiming(trace);
|
|
684
911
|
if (!value) return response;
|
|
685
|
-
const
|
|
686
|
-
const prev =
|
|
687
|
-
|
|
912
|
+
const headers = new Headers(response.headers);
|
|
913
|
+
const prev = headers.get("server-timing");
|
|
914
|
+
headers.set("server-timing", prev ? `${prev}, ${value}` : value);
|
|
688
915
|
return new Response(response.body, {
|
|
689
916
|
status: response.status,
|
|
690
917
|
statusText: response.statusText,
|
|
691
|
-
headers
|
|
918
|
+
headers
|
|
692
919
|
});
|
|
693
920
|
}
|
|
694
921
|
async function audit(options, event) {
|
|
@@ -715,13 +942,30 @@ function originMatcherFor(options) {
|
|
|
715
942
|
}
|
|
716
943
|
return matcher;
|
|
717
944
|
}
|
|
945
|
+
var RELAY_ALLOWED_REQUEST_HEADER_LIST = [
|
|
946
|
+
"Accept",
|
|
947
|
+
"Authorization",
|
|
948
|
+
"Content-Type",
|
|
949
|
+
"Last-Event-ID",
|
|
950
|
+
"Traceparent",
|
|
951
|
+
"Tracestate",
|
|
952
|
+
"X-Fetch-Bypass-Throttle",
|
|
953
|
+
"X-Daytona-Skip-Preview-Warning",
|
|
954
|
+
"X-Workspace-Id",
|
|
955
|
+
"X-OpenCode-Directory",
|
|
956
|
+
"X-Claxedo-Runner",
|
|
957
|
+
"X-Claxedo-Model",
|
|
958
|
+
"X-Claxedo-Draft-Id",
|
|
959
|
+
"X-Claxedo-Binary"
|
|
960
|
+
];
|
|
961
|
+
var RELAY_ALLOWED_REQUEST_HEADERS = RELAY_ALLOWED_REQUEST_HEADER_LIST.join(", ");
|
|
718
962
|
function denyCorsHeaders(request, originAllowed = defaultOriginMatcher) {
|
|
719
963
|
const origin = request.headers.get("origin");
|
|
720
964
|
if (!origin) return void 0;
|
|
721
965
|
if (!originAllowed(origin)) return void 0;
|
|
722
966
|
return {
|
|
723
967
|
"access-control-allow-origin": origin,
|
|
724
|
-
"access-control-allow-headers":
|
|
968
|
+
"access-control-allow-headers": RELAY_ALLOWED_REQUEST_HEADERS,
|
|
725
969
|
"access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"
|
|
726
970
|
};
|
|
727
971
|
}
|
|
@@ -731,7 +975,9 @@ async function deny(options, input) {
|
|
|
731
975
|
action: "relay.request.denied",
|
|
732
976
|
result: "deny",
|
|
733
977
|
reason: input.code,
|
|
734
|
-
|
|
978
|
+
actorId: input.claims?.actor_id,
|
|
979
|
+
actorKind: input.claims?.actor_kind,
|
|
980
|
+
principalKind: input.claims?.principal_kind,
|
|
735
981
|
...input.claims ? { role: input.claims.role } : {},
|
|
736
982
|
workspaceId: input.claims?.workspace_id,
|
|
737
983
|
hostId: input.claims?.host_id,
|
|
@@ -780,7 +1026,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
780
1026
|
};
|
|
781
1027
|
}
|
|
782
1028
|
const path = relayRuntimePath(request);
|
|
783
|
-
if (!roleAllowsRelayRequest(claims.role, request.method, path)) {
|
|
1029
|
+
if (!path || !roleAllowsRelayRequest(claims.role, request.method, path)) {
|
|
784
1030
|
return {
|
|
785
1031
|
ok: false,
|
|
786
1032
|
code: "relay_role_denied",
|
|
@@ -807,7 +1053,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
807
1053
|
})
|
|
808
1054
|
};
|
|
809
1055
|
}
|
|
810
|
-
if (target
|
|
1056
|
+
if (isUserHostedTarget(target) && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
|
|
811
1057
|
return {
|
|
812
1058
|
ok: false,
|
|
813
1059
|
code: "user_hosted_app_offline",
|
|
@@ -823,7 +1069,9 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
823
1069
|
await span("audit", async () => await audit(options, {
|
|
824
1070
|
action: "relay.request.accepted",
|
|
825
1071
|
result: "allow",
|
|
826
|
-
|
|
1072
|
+
actorId: claims.actor_id,
|
|
1073
|
+
actorKind: claims.actor_kind,
|
|
1074
|
+
principalKind: claims.principal_kind,
|
|
827
1075
|
role: claims.role,
|
|
828
1076
|
workspaceId: claims.workspace_id,
|
|
829
1077
|
hostId: claims.host_id,
|
|
@@ -866,20 +1114,20 @@ async function forwardWorkspaceRelayRequest(request, target, relayHostToken, pat
|
|
|
866
1114
|
upstreamHeaders: target.upstreamHeaders
|
|
867
1115
|
})
|
|
868
1116
|
));
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1117
|
+
const headers = new Headers(upstream.headers);
|
|
1118
|
+
headers.delete("access-control-allow-origin");
|
|
1119
|
+
headers.delete("access-control-allow-credentials");
|
|
1120
|
+
headers.delete("access-control-allow-headers");
|
|
1121
|
+
headers.delete("access-control-allow-methods");
|
|
1122
|
+
headers.delete("access-control-expose-headers");
|
|
1123
|
+
headers.delete("access-control-max-age");
|
|
876
1124
|
for (const [key, value] of Object.entries(denyCorsHeaders(request, originAllowed) ?? {})) {
|
|
877
|
-
|
|
1125
|
+
headers.set(key, value);
|
|
878
1126
|
}
|
|
879
1127
|
return new Response(upstream.body, {
|
|
880
1128
|
status: upstream.status,
|
|
881
1129
|
statusText: upstream.statusText,
|
|
882
|
-
headers
|
|
1130
|
+
headers
|
|
883
1131
|
});
|
|
884
1132
|
} catch (err) {
|
|
885
1133
|
const aborted = err instanceof Error && err.name === "AbortError";
|
|
@@ -906,7 +1154,7 @@ function isLoopbackAddress(address) {
|
|
|
906
1154
|
if (!address) return false;
|
|
907
1155
|
const trimmed = address.trim();
|
|
908
1156
|
if (LOOPBACK_REMOTES.has(trimmed)) return true;
|
|
909
|
-
if (
|
|
1157
|
+
if (trimmed.startsWith("127.")) return true;
|
|
910
1158
|
if (/^::ffff:127\./i.test(trimmed)) return true;
|
|
911
1159
|
return false;
|
|
912
1160
|
}
|
|
@@ -962,1295 +1210,140 @@ function buildMetricsBody(options) {
|
|
|
962
1210
|
return body;
|
|
963
1211
|
}
|
|
964
1212
|
function createWorkspaceRelay(options) {
|
|
965
|
-
const app = new Hono()
|
|
966
|
-
|
|
967
|
-
app.use("*", cors({
|
|
968
|
-
origin: (origin) => {
|
|
969
|
-
if (!origin) return void 0;
|
|
970
|
-
return originAllowed(origin) ? origin : void 0;
|
|
971
|
-
},
|
|
972
|
-
allowHeaders: [
|
|
973
|
-
"Accept",
|
|
974
|
-
"Authorization",
|
|
975
|
-
"Content-Type",
|
|
976
|
-
"X-Daytona-Skip-Preview-Warning",
|
|
977
|
-
"X-Workspace-Id",
|
|
978
|
-
"X-OpenCode-Directory",
|
|
979
|
-
"X-Claxedo-Runner",
|
|
980
|
-
"X-Claxedo-Model",
|
|
981
|
-
"X-Claxedo-Draft-Id",
|
|
982
|
-
"X-Claxedo-Binary"
|
|
983
|
-
],
|
|
984
|
-
allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
985
|
-
}));
|
|
986
|
-
app.get("/health", (c) => {
|
|
987
|
-
if (options.isDraining?.()) {
|
|
988
|
-
return c.json(
|
|
989
|
-
{ ok: false, service: "workspace-relay", draining: true },
|
|
990
|
-
503
|
|
991
|
-
);
|
|
992
|
-
}
|
|
993
|
-
return c.json({ ok: true, service: "workspace-relay" });
|
|
1213
|
+
const app = Object.assign(new Hono(), {
|
|
1214
|
+
disposeAuditSampler: () => disposeAuditSampler(options)
|
|
994
1215
|
});
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
code: "jwks_no_keys_configured",
|
|
1002
|
-
message: "No Relay Host Token public keys are configured for the JWKS endpoint"
|
|
1003
|
-
}
|
|
1004
|
-
},
|
|
1005
|
-
503
|
|
1006
|
-
);
|
|
1007
|
-
}
|
|
1008
|
-
let keys;
|
|
1009
|
-
try {
|
|
1010
|
-
keys = await Promise.all(sources.map(async (source) => {
|
|
1011
|
-
const jwk = await exportJWK(source.publicKey);
|
|
1012
|
-
return {
|
|
1013
|
-
...jwk,
|
|
1014
|
-
kid: source.kid,
|
|
1015
|
-
alg: "EdDSA",
|
|
1016
|
-
use: "sig"
|
|
1017
|
-
};
|
|
1018
|
-
}));
|
|
1019
|
-
} catch (err) {
|
|
1020
|
-
return c.json(
|
|
1021
|
-
{
|
|
1022
|
-
error: {
|
|
1023
|
-
code: "jwks_invalid_key",
|
|
1024
|
-
message: "A configured RHT public key could not be exported as a JWK",
|
|
1025
|
-
detail: err instanceof Error ? err.message : String(err)
|
|
1026
|
-
}
|
|
1027
|
-
},
|
|
1028
|
-
500
|
|
1029
|
-
);
|
|
1030
|
-
}
|
|
1031
|
-
c.header("cache-control", "public, max-age=300");
|
|
1032
|
-
return c.json({ keys });
|
|
1033
|
-
});
|
|
1034
|
-
app.disposeAuditSampler = () => disposeAuditSampler(options);
|
|
1035
|
-
app.get("/metrics", (c) => {
|
|
1036
|
-
const authResult = checkMetricsAuth(options, c.req.raw);
|
|
1037
|
-
if (!authResult.ok) {
|
|
1038
|
-
return c.json(
|
|
1039
|
-
errorBody(authResult.code, authResult.message),
|
|
1040
|
-
authResult.status
|
|
1041
|
-
);
|
|
1042
|
-
}
|
|
1043
|
-
return c.json(buildMetricsBody(options));
|
|
1044
|
-
});
|
|
1045
|
-
app.all("/workspaces/:workspaceId/*", async (c) => {
|
|
1046
|
-
const trace = createWorkspaceRelayTrace();
|
|
1047
|
-
if (options.isDraining?.()) {
|
|
1048
|
-
return Response.json(
|
|
1049
|
-
errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
|
|
1050
|
-
{ status: 503, headers: denyCorsHeaders(c.req.raw, originAllowed) }
|
|
1051
|
-
);
|
|
1052
|
-
}
|
|
1053
|
-
const response = await trace.span("relay-total", async () => {
|
|
1054
|
-
const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"), trace);
|
|
1055
|
-
if (!relay.ok) return relay.response;
|
|
1056
|
-
return await forwardWorkspaceRelayRequest(
|
|
1057
|
-
new Request(c.req.raw),
|
|
1058
|
-
relay.request.target,
|
|
1059
|
-
relay.request.relayHostToken,
|
|
1060
|
-
relay.request.path,
|
|
1061
|
-
options.fetch ?? fetch,
|
|
1062
|
-
options.forwardTimeoutMs ?? 3e4,
|
|
1063
|
-
trace,
|
|
1064
|
-
originAllowed
|
|
1065
|
-
);
|
|
1066
|
-
});
|
|
1067
|
-
return workspaceRelayTimingResponse(response, trace);
|
|
1068
|
-
});
|
|
1069
|
-
return app;
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
// src/bun.ts
|
|
1073
|
-
var TUNNEL_PENDING_HTTP_CAP = 32;
|
|
1074
|
-
var TUNNEL_CHANNEL_CAP = 16;
|
|
1075
|
-
var HOST_TUNNEL_REGISTRATION_RECONNECT_CAP = 5;
|
|
1076
|
-
var HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS = 6e4;
|
|
1077
|
-
var WS_MAX_PAYLOAD_LENGTH_BYTES = 16 * 1024 * 1024;
|
|
1078
|
-
var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
|
|
1079
|
-
var TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1080
|
-
var DIRECT_HTTP_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1081
|
-
var UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT = 1e4;
|
|
1082
|
-
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
|
|
1083
|
-
var WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1084
|
-
var HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT = 2;
|
|
1085
|
-
var TUNNEL_MESSAGE_BUFFER_CAP_BYTES = 4 * 1024 * 1024;
|
|
1086
|
-
var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1087
|
-
var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1088
|
-
var HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT = 250;
|
|
1089
|
-
function createFragmentationStats() {
|
|
1090
|
-
return {
|
|
1091
|
-
fragmentsBuffered: 0,
|
|
1092
|
-
oversizedClosed: 0
|
|
1093
|
-
};
|
|
1094
|
-
}
|
|
1095
|
-
function createSlowConsumerStats() {
|
|
1096
|
-
return {
|
|
1097
|
-
overflowEvents: 0,
|
|
1098
|
-
timerFired: 0,
|
|
1099
|
-
droppedRequests: 0
|
|
1100
|
-
};
|
|
1101
|
-
}
|
|
1102
|
-
function readFragmentationStats(stats) {
|
|
1103
|
-
return {
|
|
1104
|
-
fragmentsBuffered: stats.fragmentsBuffered,
|
|
1105
|
-
oversizedClosed: stats.oversizedClosed
|
|
1106
|
-
};
|
|
1107
|
-
}
|
|
1108
|
-
function resetFragmentationStats(stats) {
|
|
1109
|
-
stats.fragmentsBuffered = 0;
|
|
1110
|
-
stats.oversizedClosed = 0;
|
|
1111
|
-
}
|
|
1112
|
-
function readSlowConsumerStats(stats) {
|
|
1113
|
-
return { ...stats };
|
|
1114
|
-
}
|
|
1115
|
-
function resetSlowConsumerStats(stats) {
|
|
1116
|
-
stats.overflowEvents = 0;
|
|
1117
|
-
stats.timerFired = 0;
|
|
1118
|
-
stats.droppedRequests = 0;
|
|
1119
|
-
}
|
|
1120
|
-
function createDirectHttpLimiter(limit) {
|
|
1121
|
-
if (!Number.isInteger(limit) || !limit || limit <= 0) return void 0;
|
|
1122
|
-
const max = limit;
|
|
1123
|
-
let active = 0;
|
|
1124
|
-
const queue = [];
|
|
1125
|
-
function drain() {
|
|
1126
|
-
if (active >= max) return;
|
|
1127
|
-
const next = queue.shift();
|
|
1128
|
-
if (!next) return;
|
|
1129
|
-
active++;
|
|
1130
|
-
next();
|
|
1131
|
-
}
|
|
1132
|
-
return {
|
|
1133
|
-
async acquire() {
|
|
1134
|
-
if (active < max) {
|
|
1135
|
-
active++;
|
|
1136
|
-
} else {
|
|
1137
|
-
await new Promise((resolve) => queue.push(resolve));
|
|
1138
|
-
}
|
|
1139
|
-
let released = false;
|
|
1140
|
-
return () => {
|
|
1141
|
-
if (released) return;
|
|
1142
|
-
released = true;
|
|
1143
|
-
active = Math.max(0, active - 1);
|
|
1144
|
-
drain();
|
|
1145
|
-
};
|
|
1146
|
-
}
|
|
1147
|
-
};
|
|
1148
|
-
}
|
|
1149
|
-
function jsonError(code, message, status) {
|
|
1150
|
-
return new Response(JSON.stringify({ error: { code, message } }), {
|
|
1151
|
-
status,
|
|
1152
|
-
headers: { "content-type": "application/json" }
|
|
1153
|
-
});
|
|
1154
|
-
}
|
|
1155
|
-
function corsJsonError(request, code, message, status) {
|
|
1156
|
-
const headers2 = relayCorsHeaders(request);
|
|
1157
|
-
headers2.set("content-type", "application/json");
|
|
1158
|
-
return new Response(JSON.stringify({ error: { code, message } }), { status, headers: headers2 });
|
|
1159
|
-
}
|
|
1160
|
-
function safeCloseCode(input, fallback = 1011) {
|
|
1161
|
-
if (!Number.isInteger(input)) return fallback;
|
|
1162
|
-
const code = input;
|
|
1163
|
-
if (code === 1e3) return code;
|
|
1164
|
-
if (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
|
|
1165
|
-
if (code >= 3e3 && code <= 4999) return code;
|
|
1166
|
-
return fallback;
|
|
1167
|
-
}
|
|
1168
|
-
function closeWebSocket(ws, code, reason, fallback = 1011) {
|
|
1169
|
-
try {
|
|
1170
|
-
ws.close(safeCloseCode(code, fallback), reason);
|
|
1171
|
-
} catch {
|
|
1172
|
-
try {
|
|
1173
|
-
ws.close(fallback, reason);
|
|
1174
|
-
} catch {
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
function upstreamCloseReason(event) {
|
|
1179
|
-
if (event.reason.trim()) return event.reason;
|
|
1180
|
-
if (event.code === 1e3) return "";
|
|
1181
|
-
if (event.code === 1005 || event.code === 1006) return "Upstream WebSocket closed abnormally";
|
|
1182
|
-
return `Upstream WebSocket closed unexpectedly with code ${event.code}`;
|
|
1183
|
-
}
|
|
1184
|
-
function workspaceIdFromPath(pathname) {
|
|
1185
|
-
return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
1186
|
-
}
|
|
1187
|
-
function hostIdFromTunnelPath(pathname) {
|
|
1188
|
-
return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
1189
|
-
}
|
|
1190
|
-
function websocketRequest(request) {
|
|
1191
|
-
return request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
1192
|
-
}
|
|
1193
|
-
function roundedMs2(input) {
|
|
1194
|
-
return Math.round(input * 100) / 100;
|
|
1195
|
-
}
|
|
1196
|
-
function relayWebSocketTraceEnabled(request) {
|
|
1197
|
-
return request.headers.get("x-claxedo-relay-ws-trace") === "1";
|
|
1198
|
-
}
|
|
1199
|
-
function sendRelayWebSocketTrace(ws) {
|
|
1200
|
-
const trace = ws.data.trace;
|
|
1201
|
-
if (!trace || trace.emitted) return;
|
|
1202
|
-
trace.emitted = true;
|
|
1203
|
-
ws.send(JSON.stringify({
|
|
1204
|
-
type: "relay.trace",
|
|
1205
|
-
wsUpstreamOpenMs: trace.upstreamOpenMs === void 0 ? void 0 : roundedMs2(trace.upstreamOpenMs),
|
|
1206
|
-
queuedFrames: trace.queuedFrames,
|
|
1207
|
-
maxQueuedDelayMs: roundedMs2(trace.maxQueuedDelayMs)
|
|
1208
|
-
}));
|
|
1209
|
-
}
|
|
1210
|
-
function relayWebSocketPayload(input) {
|
|
1211
|
-
if (typeof input === "string") return input;
|
|
1212
|
-
if (input instanceof ArrayBuffer) return input;
|
|
1213
|
-
if (ArrayBuffer.isView(input)) {
|
|
1214
|
-
const copy = new Uint8Array(input.byteLength);
|
|
1215
|
-
copy.set(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
|
|
1216
|
-
return copy;
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
function headersRecord(headers2) {
|
|
1220
|
-
const result = {};
|
|
1221
|
-
headers2.forEach((value, key) => {
|
|
1222
|
-
result[key] = value;
|
|
1223
|
-
});
|
|
1224
|
-
return result;
|
|
1225
|
-
}
|
|
1226
|
-
function headers(input) {
|
|
1227
|
-
const result = new Headers();
|
|
1228
|
-
for (const [key, value] of Object.entries(input)) {
|
|
1229
|
-
result.set(key, value);
|
|
1230
|
-
}
|
|
1231
|
-
return result;
|
|
1232
|
-
}
|
|
1233
|
-
function isEventStream(input) {
|
|
1234
|
-
return Object.entries(input).some(
|
|
1235
|
-
([key, value]) => key.toLowerCase() === "content-type" && value.toLowerCase().includes("text/event-stream")
|
|
1236
|
-
);
|
|
1237
|
-
}
|
|
1238
|
-
function allowedCorsOrigin(origin) {
|
|
1239
|
-
if (!origin) return;
|
|
1240
|
-
if (origin.startsWith("http://localhost:")) return origin;
|
|
1241
|
-
if (origin.startsWith("http://127.0.0.1:")) return origin;
|
|
1242
|
-
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
|
|
1243
|
-
}
|
|
1244
|
-
function requireAllowedOrigin(request) {
|
|
1245
|
-
const origin = request.headers.get("origin");
|
|
1246
|
-
if (allowedCorsOrigin(origin)) return null;
|
|
1247
|
-
return jsonError(
|
|
1248
|
-
"origin_not_allowed",
|
|
1249
|
-
"Origin is not in the allowlist",
|
|
1250
|
-
403
|
|
1251
|
-
);
|
|
1252
|
-
}
|
|
1253
|
-
function relayCorsHeaders(request, input = new Headers()) {
|
|
1254
|
-
const result = new Headers(input);
|
|
1255
|
-
result.delete("access-control-allow-origin");
|
|
1256
|
-
result.delete("access-control-allow-credentials");
|
|
1257
|
-
result.delete("access-control-allow-headers");
|
|
1258
|
-
result.delete("access-control-allow-methods");
|
|
1259
|
-
result.delete("access-control-expose-headers");
|
|
1260
|
-
result.delete("access-control-max-age");
|
|
1261
|
-
const origin = allowedCorsOrigin(request.headers.get("origin"));
|
|
1262
|
-
if (origin) {
|
|
1263
|
-
result.set("access-control-allow-origin", origin);
|
|
1264
|
-
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");
|
|
1265
|
-
result.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
1266
|
-
}
|
|
1267
|
-
return result;
|
|
1268
|
-
}
|
|
1269
|
-
function encoded(input) {
|
|
1270
|
-
return Buffer.from(input).toString("base64");
|
|
1271
|
-
}
|
|
1272
|
-
function decoded(input) {
|
|
1273
|
-
return new Uint8Array(Buffer.from(input, "base64"));
|
|
1274
|
-
}
|
|
1275
|
-
function encodedFrame(message) {
|
|
1276
|
-
if (typeof message === "string") {
|
|
1277
|
-
return {
|
|
1278
|
-
binary: false,
|
|
1279
|
-
data_base64: Buffer.from(message).toString("base64")
|
|
1280
|
-
};
|
|
1281
|
-
}
|
|
1282
|
-
return {
|
|
1283
|
-
binary: true,
|
|
1284
|
-
data_base64: Buffer.from(message).toString("base64")
|
|
1285
|
-
};
|
|
1286
|
-
}
|
|
1287
|
-
function decodedFrame(frame) {
|
|
1288
|
-
const body = Buffer.from(frame.data_base64, "base64");
|
|
1289
|
-
return frame.binary ? new Uint8Array(body) : body.toString("utf8");
|
|
1290
|
-
}
|
|
1291
|
-
function tunnelMessage(ws, input, stats) {
|
|
1292
|
-
if (typeof input !== "string") return;
|
|
1293
|
-
const combined = ws.data.messageBuffer.length > 0 ? ws.data.messageBuffer + input : input;
|
|
1294
|
-
let parsed;
|
|
1295
|
-
try {
|
|
1296
|
-
parsed = JSON.parse(combined);
|
|
1297
|
-
} catch {
|
|
1298
|
-
if (combined.length > TUNNEL_MESSAGE_BUFFER_CAP_BYTES) {
|
|
1299
|
-
stats.oversizedClosed += 1;
|
|
1300
|
-
ws.data.messageBuffer = "";
|
|
1301
|
-
ws.close(1009, "Tunnel message buffer exceeded 4 MB");
|
|
1302
|
-
return;
|
|
1303
|
-
}
|
|
1304
|
-
stats.fragmentsBuffered += 1;
|
|
1305
|
-
ws.data.messageBuffer = combined;
|
|
1306
|
-
return;
|
|
1307
|
-
}
|
|
1308
|
-
ws.data.messageBuffer = "";
|
|
1309
|
-
const validated = validateTunnelMessage(parsed);
|
|
1310
|
-
if (validated.ok) return validated.message;
|
|
1311
|
-
if (validated.reason === "protocol_mismatch") {
|
|
1312
|
-
ws.close(1002, `Tunnel protocol mismatch: expected ${validated.expected_protocol}`);
|
|
1313
|
-
}
|
|
1314
|
-
return;
|
|
1315
|
-
}
|
|
1316
|
-
function sendTunnelPing(ws) {
|
|
1317
|
-
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1318
|
-
ws.data.missedPongs += 1;
|
|
1319
|
-
ws.send(JSON.stringify(makeTunnelPing()));
|
|
1320
|
-
}
|
|
1321
|
-
function clearPendingTimers(entry) {
|
|
1322
|
-
clearTimeout(entry.timeout);
|
|
1323
|
-
if (entry.slowConsumerTimeout) {
|
|
1324
|
-
clearTimeout(entry.slowConsumerTimeout);
|
|
1325
|
-
entry.slowConsumerTimeout = void 0;
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
function failPendingHttpResponse(input) {
|
|
1329
|
-
clearPendingTimers(input.entry);
|
|
1330
|
-
input.entry.pendingChunks.length = 0;
|
|
1331
|
-
input.entry.bytesQueued = 0;
|
|
1332
|
-
try {
|
|
1333
|
-
input.entry.controller.error(input.error);
|
|
1334
|
-
} catch {
|
|
1335
|
-
}
|
|
1336
|
-
if (!input.entry.responseStarted) {
|
|
1337
|
-
if (input.response) {
|
|
1338
|
-
input.entry.resolve(input.response);
|
|
1339
|
-
return;
|
|
1340
|
-
}
|
|
1341
|
-
input.entry.reject(input.error);
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
function cleanupHostTunnelSocket(input) {
|
|
1345
|
-
if (input.ws.data.heartbeat) {
|
|
1346
|
-
clearInterval(input.ws.data.heartbeat);
|
|
1347
|
-
input.ws.data.heartbeat = void 0;
|
|
1348
|
-
}
|
|
1349
|
-
for (const pending of input.ws.data.pending.values()) {
|
|
1350
|
-
failPendingHttpResponse({
|
|
1351
|
-
entry: pending,
|
|
1352
|
-
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),
|
|
1353
|
-
error: new Error("User-hosted tunnel disconnected")
|
|
1354
|
-
});
|
|
1355
|
-
}
|
|
1356
|
-
input.ws.data.pending.clear();
|
|
1357
|
-
for (const channel of input.ws.data.channels.values()) {
|
|
1358
|
-
if (input.closeChannels) closeWebSocket(channel, 1011, "User-hosted tunnel disconnected");
|
|
1359
|
-
}
|
|
1360
|
-
input.ws.data.channels.clear();
|
|
1361
|
-
if (!input.disconnectDirectory) return;
|
|
1362
|
-
if (input.hostTunnels.get(input.ws.data.hostId) === input.ws) {
|
|
1363
|
-
input.hostTunnels.delete(input.ws.data.hostId);
|
|
1364
|
-
input.options.directory?.disconnectHost(input.ws.data.hostId);
|
|
1365
|
-
scheduleHostTunnelStateChange(input.hostTunnelStateDebounce, input.options, {
|
|
1366
|
-
hostId: input.ws.data.hostId,
|
|
1367
|
-
workspaceId: input.ws.data.workspaceIds[0],
|
|
1368
|
-
path: `/host-tunnels/${input.ws.data.hostId}`,
|
|
1369
|
-
state: "disconnected",
|
|
1370
|
-
debounceMs: input.bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
|
|
1371
|
-
});
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
async function audit2(options, input) {
|
|
1375
|
-
await options.audit?.({
|
|
1376
|
-
action: input.action,
|
|
1377
|
-
result: "allow",
|
|
1378
|
-
hostId: input.hostId,
|
|
1379
|
-
workspaceId: input.workspaceId,
|
|
1380
|
-
method: "WEBSOCKET",
|
|
1381
|
-
path: input.path
|
|
1382
|
-
});
|
|
1383
|
-
}
|
|
1384
|
-
function scheduleHostTunnelStateChange(state, options, input) {
|
|
1385
|
-
const existing = state.get(input.hostId);
|
|
1386
|
-
const entry = existing ?? {
|
|
1387
|
-
intendedState: "disconnected",
|
|
1388
|
-
lastWritten: "disconnected",
|
|
1389
|
-
flushTimer: void 0,
|
|
1390
|
-
pending: { workspaceId: input.workspaceId, path: input.path }
|
|
1391
|
-
};
|
|
1392
|
-
entry.intendedState = input.state;
|
|
1393
|
-
entry.pending = { workspaceId: input.workspaceId, path: input.path };
|
|
1394
|
-
if (entry.flushTimer) clearTimeout(entry.flushTimer);
|
|
1395
|
-
entry.flushTimer = setTimeout(() => {
|
|
1396
|
-
entry.flushTimer = void 0;
|
|
1397
|
-
if (entry.intendedState === entry.lastWritten) {
|
|
1398
|
-
if (entry.intendedState === "disconnected") {
|
|
1399
|
-
state.delete(input.hostId);
|
|
1400
|
-
}
|
|
1401
|
-
return;
|
|
1402
|
-
}
|
|
1403
|
-
entry.lastWritten = entry.intendedState;
|
|
1404
|
-
void audit2(options, {
|
|
1405
|
-
action: entry.intendedState === "connected" ? "host_tunnel.connected" : "host_tunnel.disconnected",
|
|
1406
|
-
hostId: input.hostId,
|
|
1407
|
-
workspaceId: entry.pending.workspaceId,
|
|
1408
|
-
path: entry.pending.path
|
|
1409
|
-
});
|
|
1410
|
-
}, input.debounceMs);
|
|
1411
|
-
state.set(input.hostId, entry);
|
|
1412
|
-
}
|
|
1413
|
-
function drainPendingChunks(entry) {
|
|
1414
|
-
while (entry.pendingChunks.length > 0) {
|
|
1415
|
-
const desired = entry.controller.desiredSize;
|
|
1416
|
-
if (desired !== null && desired <= 0) break;
|
|
1417
|
-
const next = entry.pendingChunks.shift();
|
|
1418
|
-
entry.bytesQueued -= next.byteLength;
|
|
1419
|
-
try {
|
|
1420
|
-
entry.controller.enqueue(next);
|
|
1421
|
-
} catch {
|
|
1422
|
-
entry.pendingChunks.length = 0;
|
|
1423
|
-
entry.bytesQueued = 0;
|
|
1424
|
-
break;
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
if (entry.pendingChunks.length === 0 && entry.slowConsumerTimeout) {
|
|
1428
|
-
clearTimeout(entry.slowConsumerTimeout);
|
|
1429
|
-
entry.slowConsumerTimeout = void 0;
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
function enqueueChunkWithBackpressure(input) {
|
|
1433
|
-
const { entry, chunk } = input;
|
|
1434
|
-
const desired = entry.controller.desiredSize;
|
|
1435
|
-
const overflowing = desired !== null && desired <= 0;
|
|
1436
|
-
if (overflowing || entry.pendingChunks.length > 0) {
|
|
1437
|
-
entry.pendingChunks.push(chunk);
|
|
1438
|
-
entry.bytesQueued += chunk.byteLength;
|
|
1439
|
-
if (!entry.slowConsumerTimeout) {
|
|
1440
|
-
input.slowConsumerStats.overflowEvents += 1;
|
|
1441
|
-
entry.slowConsumerTimeout = setTimeout(() => {
|
|
1442
|
-
input.slowConsumerStats.timerFired += 1;
|
|
1443
|
-
const error = new Error("slow_consumer_timeout: downstream consumer did not drain in time");
|
|
1444
|
-
try {
|
|
1445
|
-
entry.controller.error(error);
|
|
1446
|
-
} catch {
|
|
1447
|
-
}
|
|
1448
|
-
clearTimeout(entry.timeout);
|
|
1449
|
-
entry.pendingChunks.length = 0;
|
|
1450
|
-
entry.bytesQueued = 0;
|
|
1451
|
-
if (!entry.responseStarted) {
|
|
1452
|
-
entry.resolve(jsonError("slow_consumer_timeout", "Downstream consumer did not drain in time", 503));
|
|
1453
|
-
}
|
|
1454
|
-
input.ws.data.pending.delete(input.requestId);
|
|
1455
|
-
input.slowConsumerStats.droppedRequests += 1;
|
|
1456
|
-
}, input.slowConsumerTimeoutMs);
|
|
1457
|
-
}
|
|
1458
|
-
return;
|
|
1459
|
-
}
|
|
1460
|
-
try {
|
|
1461
|
-
entry.controller.enqueue(chunk);
|
|
1462
|
-
} catch {
|
|
1463
|
-
}
|
|
1464
|
-
}
|
|
1465
|
-
var __slowConsumerInternalsForTest = {
|
|
1466
|
-
enqueueChunkWithBackpressure,
|
|
1467
|
-
createSlowConsumerStats
|
|
1468
|
-
};
|
|
1469
|
-
async function tunnelHttpRequest(input) {
|
|
1470
|
-
if (input.ws.readyState !== WebSocket.OPEN) {
|
|
1471
|
-
return new Response("User-hosted workspace is offline", { status: 503 });
|
|
1472
|
-
}
|
|
1473
|
-
if (input.ws.data.pending.size >= TUNNEL_PENDING_HTTP_CAP) {
|
|
1474
|
-
return jsonError(
|
|
1475
|
-
"too_many_in_flight",
|
|
1476
|
-
"User-hosted tunnel has too many in-flight HTTP requests",
|
|
1477
|
-
429
|
|
1478
|
-
);
|
|
1479
|
-
}
|
|
1480
|
-
const requestId = crypto.randomUUID();
|
|
1481
|
-
let controller;
|
|
1482
|
-
const stream = new ReadableStream({
|
|
1483
|
-
start(next) {
|
|
1484
|
-
controller = next;
|
|
1485
|
-
},
|
|
1486
|
-
pull() {
|
|
1487
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1488
|
-
if (!entry) return;
|
|
1489
|
-
drainPendingChunks(entry);
|
|
1490
|
-
},
|
|
1491
|
-
cancel() {
|
|
1492
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1493
|
-
if (!entry) return;
|
|
1494
|
-
input.ws.data.pending.delete(requestId);
|
|
1495
|
-
clearPendingTimers(entry);
|
|
1496
|
-
entry.pendingChunks.length = 0;
|
|
1497
|
-
entry.bytesQueued = 0;
|
|
1498
|
-
if (input.ws.readyState === WebSocket.OPEN) {
|
|
1499
|
-
input.ws.send(JSON.stringify({
|
|
1500
|
-
type: "http.response.flow",
|
|
1501
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
1502
|
-
request_id: requestId,
|
|
1503
|
-
paused: false,
|
|
1504
|
-
reason: "closed"
|
|
1505
|
-
}));
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
}, new ByteLengthQueuingStrategy({ highWaterMark: input.slowConsumerHighWaterMarkBytes }));
|
|
1509
|
-
const pending = new Promise((resolve, reject) => {
|
|
1510
|
-
const timeout = setTimeout(() => {
|
|
1511
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1512
|
-
if (!entry) return;
|
|
1513
|
-
input.ws.data.pending.delete(requestId);
|
|
1514
|
-
if (entry.responseStarted) {
|
|
1515
|
-
clearPendingTimers(entry);
|
|
1516
|
-
entry.pendingChunks.length = 0;
|
|
1517
|
-
entry.bytesQueued = 0;
|
|
1518
|
-
try {
|
|
1519
|
-
entry.controller.close();
|
|
1520
|
-
} catch {
|
|
1521
|
-
}
|
|
1522
|
-
return;
|
|
1523
|
-
}
|
|
1524
|
-
const error = new Error("User-hosted tunnel response timed out");
|
|
1525
|
-
failPendingHttpResponse({
|
|
1526
|
-
entry,
|
|
1527
|
-
response: corsJsonError(input.request, "user_hosted_tunnel_timeout", "User-hosted tunnel response timed out", 504),
|
|
1528
|
-
error
|
|
1529
|
-
});
|
|
1530
|
-
}, input.responseTimeoutMs);
|
|
1531
|
-
input.ws.data.pending.set(requestId, {
|
|
1532
|
-
controller,
|
|
1533
|
-
stream,
|
|
1534
|
-
resolve,
|
|
1535
|
-
reject,
|
|
1536
|
-
timeout,
|
|
1537
|
-
pendingChunks: [],
|
|
1538
|
-
bytesQueued: 0,
|
|
1539
|
-
responseStarted: false
|
|
1540
|
-
});
|
|
1541
|
-
});
|
|
1542
|
-
const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : await readBoundedBody(input.request, input.requestBodyMaxBytes);
|
|
1543
|
-
if (body && "response" in body) {
|
|
1544
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1545
|
-
if (entry) clearPendingTimers(entry);
|
|
1546
|
-
input.ws.data.pending.delete(requestId);
|
|
1547
|
-
return body.response;
|
|
1548
|
-
}
|
|
1549
|
-
input.ws.send(JSON.stringify({
|
|
1550
|
-
type: "http.request",
|
|
1551
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
1552
|
-
request_id: requestId,
|
|
1553
|
-
workspace_id: input.workspaceId,
|
|
1554
|
-
method: input.request.method,
|
|
1555
|
-
path: input.path,
|
|
1556
|
-
headers: headersRecord(workspaceRelayForwardHeaders(
|
|
1557
|
-
input.request.headers,
|
|
1558
|
-
input.relayHostToken,
|
|
1559
|
-
input.workspaceId,
|
|
1560
|
-
// T26: user-hosted tunnel — strip Cookie to avoid leaking browser
|
|
1561
|
-
// cookies to the host process running on the user's laptop.
|
|
1562
|
-
{ userHosted: true }
|
|
1563
|
-
)),
|
|
1564
|
-
...body?.bodyBase64 ? { body_base64: body.bodyBase64 } : {},
|
|
1565
|
-
end: true
|
|
1566
|
-
}));
|
|
1567
|
-
return await pending;
|
|
1568
|
-
}
|
|
1569
|
-
async function readBoundedBody(request, maxBytes) {
|
|
1570
|
-
if (!request.body) return void 0;
|
|
1571
|
-
const reader = request.body.getReader();
|
|
1572
|
-
const chunks = [];
|
|
1573
|
-
let total = 0;
|
|
1574
|
-
for (; ; ) {
|
|
1575
|
-
const next = await reader.read();
|
|
1576
|
-
if (next.done) break;
|
|
1577
|
-
total += next.value.byteLength;
|
|
1578
|
-
if (total > maxBytes) {
|
|
1579
|
-
try {
|
|
1580
|
-
await reader.cancel();
|
|
1581
|
-
} catch {
|
|
1582
|
-
}
|
|
1583
|
-
return {
|
|
1584
|
-
response: corsJsonError(request, "request_body_too_large", "Tunnel request body exceeds the relay limit", 413)
|
|
1585
|
-
};
|
|
1586
|
-
}
|
|
1587
|
-
chunks.push(next.value);
|
|
1588
|
-
}
|
|
1589
|
-
const body = new Uint8Array(total);
|
|
1590
|
-
chunks.reduce((offset, chunk) => {
|
|
1591
|
-
body.set(chunk, offset);
|
|
1592
|
-
return offset + chunk.byteLength;
|
|
1593
|
-
}, 0);
|
|
1594
|
-
return { bodyBase64: encoded(body.buffer) };
|
|
1595
|
-
}
|
|
1596
|
-
async function directHttpRequest(input) {
|
|
1597
|
-
const controller = new AbortController();
|
|
1598
|
-
const timer = setTimeout(() => controller.abort(), input.timeoutMs);
|
|
1599
|
-
const span = (name, run) => input.trace ? input.trace.span(name, run) : run();
|
|
1600
|
-
try {
|
|
1601
|
-
const init = workspaceRelayForwardRequestInit(
|
|
1602
|
-
input.request,
|
|
1603
|
-
input.relayHostToken,
|
|
1604
|
-
input.workspaceId,
|
|
1605
|
-
{
|
|
1606
|
-
// T26: cloud-vm direct path. Cookies pass through (default) — workspace
|
|
1607
|
-
// dashboards in the VM may legitimately need session cookies.
|
|
1608
|
-
userHosted: false,
|
|
1609
|
-
upstreamHeaders: input.upstreamHeaders,
|
|
1610
|
-
signal: controller.signal
|
|
1611
|
-
}
|
|
1612
|
-
);
|
|
1613
|
-
if (input.request.method !== "GET" && input.request.method !== "HEAD" && input.request.body) {
|
|
1614
|
-
init.body = await input.request.arrayBuffer();
|
|
1615
|
-
}
|
|
1616
|
-
const limiter = input.limiter;
|
|
1617
|
-
const release = limiter ? await span("direct-http-queue", () => limiter.acquire()) : void 0;
|
|
1618
|
-
try {
|
|
1619
|
-
const upstream = await span("upstream-fetch", async () => await fetch(input.targetUrl, init));
|
|
1620
|
-
return new Response(upstream.body, {
|
|
1621
|
-
status: upstream.status,
|
|
1622
|
-
statusText: upstream.statusText,
|
|
1623
|
-
headers: relayCorsHeaders(input.request, upstream.headers)
|
|
1624
|
-
});
|
|
1625
|
-
} finally {
|
|
1626
|
-
release?.();
|
|
1627
|
-
}
|
|
1628
|
-
} catch (err) {
|
|
1629
|
-
const aborted = err instanceof Error && err.name === "AbortError";
|
|
1630
|
-
return corsJsonError(
|
|
1631
|
-
input.request,
|
|
1632
|
-
aborted ? "upstream_timeout" : "upstream_unavailable",
|
|
1633
|
-
aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable",
|
|
1634
|
-
aborted ? 504 : 503
|
|
1635
|
-
);
|
|
1636
|
-
} finally {
|
|
1637
|
-
clearTimeout(timer);
|
|
1638
|
-
}
|
|
1639
|
-
}
|
|
1640
|
-
function hostTunnel(hostTunnels, hostId) {
|
|
1641
|
-
const tunnel = hostTunnels.get(hostId);
|
|
1642
|
-
if (tunnel?.readyState === WebSocket.OPEN) return tunnel;
|
|
1643
|
-
return void 0;
|
|
1644
|
-
}
|
|
1645
|
-
async function authorizeHostTunnel(options, bunOptions, request, input) {
|
|
1646
|
-
if (bunOptions.authorizeHostTunnel) return await bunOptions.authorizeHostTunnel(request, input);
|
|
1647
|
-
const token = bearerToken(request.headers.get("authorization"));
|
|
1648
|
-
if (!token) return false;
|
|
1649
|
-
try {
|
|
1650
|
-
await verifyHostTunnelToken(token, options.runtimeAccessKey, input);
|
|
1651
|
-
return true;
|
|
1652
|
-
} catch (err) {
|
|
1653
|
-
if (err instanceof WorkspaceRelayAuthError) return false;
|
|
1654
|
-
throw err;
|
|
1655
|
-
}
|
|
1656
|
-
}
|
|
1657
|
-
function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
1658
|
-
const hostTunnels = /* @__PURE__ */ new Map();
|
|
1659
|
-
const relayClients = /* @__PURE__ */ new Set();
|
|
1660
|
-
const hostTunnelRegistrations = /* @__PURE__ */ new Map();
|
|
1661
|
-
const directHttpLimiter = createDirectHttpLimiter(bunOptions.directHttpConcurrency);
|
|
1662
|
-
const hostTunnelStateDebounce = /* @__PURE__ */ new Map();
|
|
1663
|
-
const fragmentationStats = createFragmentationStats();
|
|
1664
|
-
const slowConsumerStats = createSlowConsumerStats();
|
|
1665
|
-
const telemetry = {
|
|
1666
|
-
getFragmentationStats: () => readFragmentationStats(fragmentationStats),
|
|
1667
|
-
resetFragmentationStats: () => resetFragmentationStats(fragmentationStats),
|
|
1668
|
-
getSlowConsumerStats: () => readSlowConsumerStats(slowConsumerStats),
|
|
1669
|
-
resetSlowConsumerStats: () => resetSlowConsumerStats(slowConsumerStats)
|
|
1670
|
-
};
|
|
1671
|
-
let draining = false;
|
|
1672
|
-
const drainController = {
|
|
1673
|
-
isDraining: () => draining,
|
|
1674
|
-
setDraining: (value) => {
|
|
1675
|
-
draining = value;
|
|
1676
|
-
if (!value) return;
|
|
1677
|
-
for (const tunnel of hostTunnels.values()) {
|
|
1678
|
-
closeWebSocket(tunnel, 1012, "Workspace relay is draining", 1012);
|
|
1679
|
-
}
|
|
1680
|
-
for (const client of relayClients) {
|
|
1681
|
-
closeWebSocket(client, 1012, "Workspace relay is draining", 1012);
|
|
1682
|
-
}
|
|
1683
|
-
},
|
|
1684
|
-
pendingCount: () => {
|
|
1685
|
-
let total = 0;
|
|
1686
|
-
for (const tunnel of hostTunnels.values()) {
|
|
1687
|
-
total += tunnel.data.pending.size;
|
|
1688
|
-
}
|
|
1689
|
-
return total;
|
|
1690
|
-
},
|
|
1691
|
-
waitForDrain: async (timeoutMs) => {
|
|
1692
|
-
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
1693
|
-
const pollMs = 50;
|
|
1694
|
-
while (true) {
|
|
1695
|
-
const remaining = drainController.pendingCount();
|
|
1696
|
-
if (remaining === 0) return { drained: true, remaining: 0 };
|
|
1697
|
-
const now = Date.now();
|
|
1698
|
-
if (now >= deadline) return { drained: false, remaining };
|
|
1699
|
-
const sleep = Math.min(pollMs, Math.max(1, deadline - now));
|
|
1700
|
-
await new Promise((resolve) => setTimeout(resolve, sleep));
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
};
|
|
1704
|
-
const mergedMetricsSources = {
|
|
1705
|
-
fragmentation: options.metricsSources?.fragmentation ?? telemetry.getFragmentationStats,
|
|
1706
|
-
slowConsumer: options.metricsSources?.slowConsumer ?? telemetry.getSlowConsumerStats,
|
|
1707
|
-
drainPending: options.metricsSources?.drainPending ?? drainController.pendingCount
|
|
1708
|
-
};
|
|
1709
|
-
const requestRemoteAddress = /* @__PURE__ */ new WeakMap();
|
|
1710
|
-
const metricsRemoteAddressResolver = options.metricsRemoteAddress ?? ((request) => requestRemoteAddress.get(request));
|
|
1711
|
-
const app = createWorkspaceRelay({
|
|
1712
|
-
...options,
|
|
1713
|
-
isDraining: drainController.isDraining,
|
|
1714
|
-
metricsSources: mergedMetricsSources,
|
|
1715
|
-
metricsRemoteAddress: metricsRemoteAddressResolver
|
|
1716
|
-
});
|
|
1717
|
-
function getRegistrationTracker(hostId) {
|
|
1718
|
-
let tracker = hostTunnelRegistrations.get(hostId);
|
|
1719
|
-
if (!tracker) {
|
|
1720
|
-
tracker = { recent: [] };
|
|
1721
|
-
hostTunnelRegistrations.set(hostId, tracker);
|
|
1722
|
-
}
|
|
1723
|
-
return tracker;
|
|
1724
|
-
}
|
|
1725
|
-
function pruneReconnects(tracker, now) {
|
|
1726
|
-
const cutoff = now - HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS;
|
|
1727
|
-
while (tracker.recent.length && tracker.recent[0] < cutoff) {
|
|
1728
|
-
tracker.recent.shift();
|
|
1216
|
+
const originAllowed = originMatcherFor(options);
|
|
1217
|
+
app.use("*", async (c, next) => {
|
|
1218
|
+
await next();
|
|
1219
|
+
const origin = c.res.headers.get("access-control-allow-origin");
|
|
1220
|
+
if (origin && origin !== "*" && !c.res.headers.has("timing-allow-origin")) {
|
|
1221
|
+
c.res.headers.set("timing-allow-origin", origin);
|
|
1729
1222
|
}
|
|
1730
|
-
}
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
message: "Workspace relay is shutting down; try another instance"
|
|
1760
|
-
}
|
|
1761
|
-
});
|
|
1762
|
-
headers2.set("content-type", "application/json");
|
|
1763
|
-
return new Response(body, { status: 503, headers: headers2 });
|
|
1764
|
-
}
|
|
1765
|
-
if (websocketRequest(request) && hostId) {
|
|
1766
|
-
const workspaceIds = url.searchParams.getAll("workspaceId");
|
|
1767
|
-
if (!workspaceIds.length || !options.directory) {
|
|
1768
|
-
return new Response("Host tunnel registration is unavailable", { status: 503 });
|
|
1769
|
-
}
|
|
1770
|
-
if (!await authorizeHostTunnel(options, bunOptions, request, { hostId, workspaceIds })) {
|
|
1771
|
-
return new Response("Host tunnel registration denied", { status: 403 });
|
|
1772
|
-
}
|
|
1773
|
-
const tracker = getRegistrationTracker(hostId);
|
|
1774
|
-
const now = Date.now();
|
|
1775
|
-
pruneReconnects(tracker, now);
|
|
1776
|
-
if (tracker.recent.length >= HOST_TUNNEL_REGISTRATION_RECONNECT_CAP) {
|
|
1777
|
-
return jsonError(
|
|
1778
|
-
"too_many_host_tunnel_reconnects",
|
|
1779
|
-
"Too many host-tunnel reconnects for this host within the last 60 seconds",
|
|
1780
|
-
429
|
|
1781
|
-
);
|
|
1782
|
-
}
|
|
1783
|
-
if (server.upgrade(request, {
|
|
1784
|
-
data: {
|
|
1785
|
-
kind: "host-tunnel",
|
|
1786
|
-
hostId,
|
|
1787
|
-
workspaceIds,
|
|
1788
|
-
pending: /* @__PURE__ */ new Map(),
|
|
1789
|
-
channels: /* @__PURE__ */ new Map(),
|
|
1790
|
-
missedPongs: 0,
|
|
1791
|
-
messageBuffer: ""
|
|
1792
|
-
}
|
|
1793
|
-
})) {
|
|
1794
|
-
tracker.recent.push(now);
|
|
1795
|
-
return;
|
|
1796
|
-
}
|
|
1797
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
1798
|
-
}
|
|
1799
|
-
if (!workspaceId) return app.request(request);
|
|
1800
|
-
if (request.method === "OPTIONS") {
|
|
1801
|
-
return new Response(null, {
|
|
1802
|
-
status: 204,
|
|
1803
|
-
headers: relayCorsHeaders(request)
|
|
1804
|
-
});
|
|
1805
|
-
}
|
|
1806
|
-
if (!websocketRequest(request)) {
|
|
1807
|
-
const trace = createWorkspaceRelayTrace();
|
|
1808
|
-
const response = await trace.span("relay-total", async () => {
|
|
1809
|
-
const relay2 = await authorizeWorkspaceRelayRequest(options, request, workspaceId, trace);
|
|
1810
|
-
if (!relay2.ok) return relay2.response;
|
|
1811
|
-
if (relay2.request.target.access === "user-hosted") {
|
|
1812
|
-
const tunnel = hostTunnel(hostTunnels, relay2.request.target.hostId);
|
|
1813
|
-
return tunnel ? await trace.span("tunnel-http", async () => await tunnelHttpRequest({
|
|
1814
|
-
ws: tunnel,
|
|
1815
|
-
request,
|
|
1816
|
-
workspaceId: relay2.request.target.workspaceId,
|
|
1817
|
-
path: `${relay2.request.path}${url.search}`,
|
|
1818
|
-
relayHostToken: relay2.request.relayHostToken,
|
|
1819
|
-
slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
|
|
1820
|
-
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
|
|
1821
|
-
slowConsumerStats,
|
|
1822
|
-
requestBodyMaxBytes: bunOptions.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT,
|
|
1823
|
-
responseTimeoutMs: bunOptions.tunnelHttpResponseTimeoutMs ?? TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT
|
|
1824
|
-
})) : new Response("User-hosted workspace is offline", { status: 503 });
|
|
1825
|
-
}
|
|
1826
|
-
return await directHttpRequest({
|
|
1827
|
-
request,
|
|
1828
|
-
targetUrl: workspaceRelayTargetUrl(
|
|
1829
|
-
relay2.request.target,
|
|
1830
|
-
relay2.request.path,
|
|
1831
|
-
url.search
|
|
1832
|
-
).toString(),
|
|
1833
|
-
relayHostToken: relay2.request.relayHostToken,
|
|
1834
|
-
workspaceId: relay2.request.target.workspaceId,
|
|
1835
|
-
upstreamHeaders: relay2.request.target.upstreamHeaders,
|
|
1836
|
-
timeoutMs: bunOptions.directHttpTimeoutMs ?? DIRECT_HTTP_TIMEOUT_MS_DEFAULT,
|
|
1837
|
-
limiter: directHttpLimiter,
|
|
1838
|
-
trace
|
|
1839
|
-
});
|
|
1840
|
-
});
|
|
1841
|
-
return workspaceRelayTimingResponse(response, trace);
|
|
1842
|
-
}
|
|
1843
|
-
const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
|
|
1844
|
-
if (!relay.ok) return relay.response;
|
|
1845
|
-
const originDenied = requireAllowedOrigin(request);
|
|
1846
|
-
if (originDenied) return originDenied;
|
|
1847
|
-
if (relay.request.target.access === "user-hosted") {
|
|
1848
|
-
const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId);
|
|
1849
|
-
if (!tunnel) {
|
|
1850
|
-
return new Response("User-hosted workspace is offline", { status: 503 });
|
|
1851
|
-
}
|
|
1852
|
-
if (tunnel.data.channels.size >= TUNNEL_CHANNEL_CAP) {
|
|
1853
|
-
return jsonError(
|
|
1854
|
-
"too_many_channels",
|
|
1855
|
-
"User-hosted tunnel has too many active WebSocket channels",
|
|
1856
|
-
503
|
|
1857
|
-
);
|
|
1858
|
-
}
|
|
1859
|
-
if (server.upgrade(request, {
|
|
1860
|
-
data: {
|
|
1861
|
-
kind: "user-hosted-client",
|
|
1862
|
-
hostId: relay.request.target.hostId,
|
|
1863
|
-
workspaceId: relay.request.target.workspaceId,
|
|
1864
|
-
channelId: crypto.randomUUID(),
|
|
1865
|
-
path: `${relay.request.path}${url.search}`,
|
|
1866
|
-
relayHostToken: relay.request.relayHostToken
|
|
1867
|
-
}
|
|
1868
|
-
})) {
|
|
1869
|
-
return;
|
|
1870
|
-
}
|
|
1871
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
1872
|
-
}
|
|
1873
|
-
if (server.upgrade(request, {
|
|
1874
|
-
data: {
|
|
1875
|
-
kind: "client",
|
|
1876
|
-
upstreamUrl: workspaceRelayTargetUrl(
|
|
1877
|
-
relay.request.target,
|
|
1878
|
-
relay.request.path,
|
|
1879
|
-
url.search
|
|
1880
|
-
).toString().replace(/^http/, "ws"),
|
|
1881
|
-
headers: headersRecord(workspaceRelayForwardHeaders(
|
|
1882
|
-
new Headers(),
|
|
1883
|
-
relay.request.relayHostToken,
|
|
1884
|
-
relay.request.target.workspaceId,
|
|
1885
|
-
// T26: cloud-vm WS upgrade. Cookies pass through (default).
|
|
1886
|
-
{ userHosted: false, upstreamHeaders: relay.request.target.upstreamHeaders }
|
|
1887
|
-
)),
|
|
1888
|
-
queue: [],
|
|
1889
|
-
...relayWebSocketTraceEnabled(request) ? {
|
|
1890
|
-
trace: {
|
|
1891
|
-
acceptedAt: performance.now(),
|
|
1892
|
-
queuedFrames: 0,
|
|
1893
|
-
maxQueuedDelayMs: 0,
|
|
1894
|
-
emitted: false
|
|
1895
|
-
}
|
|
1896
|
-
} : {}
|
|
1897
|
-
}
|
|
1898
|
-
})) {
|
|
1899
|
-
return;
|
|
1900
|
-
}
|
|
1901
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
1902
|
-
},
|
|
1903
|
-
websocket: {
|
|
1904
|
-
maxPayloadLength: WS_MAX_PAYLOAD_LENGTH_BYTES,
|
|
1905
|
-
message(ws, message) {
|
|
1906
|
-
if (ws.data.kind === "host-tunnel") {
|
|
1907
|
-
const parsed = tunnelMessage(
|
|
1908
|
-
ws,
|
|
1909
|
-
message,
|
|
1910
|
-
fragmentationStats
|
|
1911
|
-
);
|
|
1912
|
-
if (parsed?.type === "ping") {
|
|
1913
|
-
options.directory?.recordPong(ws.data.hostId);
|
|
1914
|
-
ws.send(JSON.stringify(makeTunnelPong(parsed)));
|
|
1915
|
-
}
|
|
1916
|
-
if (parsed?.type === "pong") {
|
|
1917
|
-
ws.data.missedPongs = 0;
|
|
1918
|
-
options.directory?.recordPong(ws.data.hostId);
|
|
1919
|
-
}
|
|
1920
|
-
if (parsed?.type === "host.registration.update") {
|
|
1921
|
-
const update = parsed;
|
|
1922
|
-
const workspaceIds = [...new Set(update.workspace_ids)];
|
|
1923
|
-
const hostSocket = ws;
|
|
1924
|
-
const hostId = hostSocket.data.hostId;
|
|
1925
|
-
const authorizationRequest = new Request(
|
|
1926
|
-
`http://relay.local/host-tunnels/${encodeURIComponent(hostId)}`,
|
|
1927
|
-
{ headers: { authorization: `Bearer ${update.token}` } }
|
|
1928
|
-
);
|
|
1929
|
-
void authorizeHostTunnel(options, bunOptions, authorizationRequest, {
|
|
1930
|
-
hostId,
|
|
1931
|
-
workspaceIds
|
|
1932
|
-
}).then((authorized) => {
|
|
1933
|
-
if (!authorized) {
|
|
1934
|
-
closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008);
|
|
1935
|
-
return;
|
|
1936
|
-
}
|
|
1937
|
-
if (hostTunnels.get(hostId) !== hostSocket || hostSocket.readyState !== WebSocket.OPEN) return;
|
|
1938
|
-
hostSocket.data.workspaceIds = workspaceIds;
|
|
1939
|
-
options.directory?.registerHostTunnel({ hostId, workspaceIds });
|
|
1940
|
-
}).catch(() => closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008));
|
|
1941
|
-
return;
|
|
1942
|
-
}
|
|
1943
|
-
if (parsed?.type === "error") {
|
|
1944
|
-
if (parsed.request_id) {
|
|
1945
|
-
const pending = ws.data.pending.get(parsed.request_id);
|
|
1946
|
-
if (pending) {
|
|
1947
|
-
ws.data.pending.delete(parsed.request_id);
|
|
1948
|
-
failPendingHttpResponse({
|
|
1949
|
-
entry: pending,
|
|
1950
|
-
response: jsonError(parsed.code, parsed.message, 502),
|
|
1951
|
-
error: new Error(parsed.message)
|
|
1952
|
-
});
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
if (parsed?.type === "http.response.start") {
|
|
1957
|
-
const pending = ws.data.pending.get(parsed.request_id);
|
|
1958
|
-
if (pending) {
|
|
1959
|
-
const response = parsed;
|
|
1960
|
-
pending.responseStarted = true;
|
|
1961
|
-
if (isEventStream(response.headers)) clearTimeout(pending.timeout);
|
|
1962
|
-
pending.resolve(new Response(pending.stream, {
|
|
1963
|
-
status: response.status,
|
|
1964
|
-
headers: headers(response.headers)
|
|
1965
|
-
}));
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
if (parsed?.type === "http.response.chunk") {
|
|
1969
|
-
const entry = ws.data.pending.get(parsed.request_id);
|
|
1970
|
-
if (entry) {
|
|
1971
|
-
enqueueChunkWithBackpressure({
|
|
1972
|
-
ws,
|
|
1973
|
-
requestId: parsed.request_id,
|
|
1974
|
-
entry,
|
|
1975
|
-
chunk: decoded(parsed.body_base64),
|
|
1976
|
-
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
|
|
1977
|
-
slowConsumerStats
|
|
1978
|
-
});
|
|
1979
|
-
}
|
|
1980
|
-
}
|
|
1981
|
-
if (parsed?.type === "http.response.end") {
|
|
1982
|
-
const pending = ws.data.pending.get(parsed.request_id);
|
|
1983
|
-
if (pending) {
|
|
1984
|
-
clearTimeout(pending.timeout);
|
|
1985
|
-
if (pending.slowConsumerTimeout) {
|
|
1986
|
-
clearTimeout(pending.slowConsumerTimeout);
|
|
1987
|
-
pending.slowConsumerTimeout = void 0;
|
|
1988
|
-
}
|
|
1989
|
-
drainPendingChunks(pending);
|
|
1990
|
-
if (pending.pendingChunks.length > 0) {
|
|
1991
|
-
for (const chunk of pending.pendingChunks) {
|
|
1992
|
-
try {
|
|
1993
|
-
pending.controller.enqueue(chunk);
|
|
1994
|
-
} catch {
|
|
1995
|
-
break;
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
pending.pendingChunks.length = 0;
|
|
1999
|
-
pending.bytesQueued = 0;
|
|
2000
|
-
}
|
|
2001
|
-
try {
|
|
2002
|
-
pending.controller.close();
|
|
2003
|
-
} catch {
|
|
2004
|
-
}
|
|
2005
|
-
ws.data.pending.delete(parsed.request_id);
|
|
2006
|
-
}
|
|
2007
|
-
}
|
|
2008
|
-
if (parsed?.type === "ws.frame") {
|
|
2009
|
-
const frame = parsed;
|
|
2010
|
-
const channel = ws.data.channels.get(frame.channel_id);
|
|
2011
|
-
if (!channel || channel.readyState !== WebSocket.OPEN) {
|
|
2012
|
-
ws.data.channels.delete(frame.channel_id);
|
|
2013
|
-
return;
|
|
2014
|
-
}
|
|
2015
|
-
if (channel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
|
|
2016
|
-
ws.data.channels.delete(frame.channel_id);
|
|
2017
|
-
closeWebSocket(channel, 1011, "Client WebSocket backpressure limit exceeded");
|
|
2018
|
-
return;
|
|
2019
|
-
}
|
|
2020
|
-
channel.send(decodedFrame(frame));
|
|
2021
|
-
}
|
|
2022
|
-
if (parsed?.type === "ws.close") {
|
|
2023
|
-
const close = parsed;
|
|
2024
|
-
const channel = ws.data.channels.get(close.channel_id);
|
|
2025
|
-
if (channel) {
|
|
2026
|
-
ws.data.channels.delete(close.channel_id);
|
|
2027
|
-
closeWebSocket(channel, close.code, close.reason);
|
|
2028
|
-
}
|
|
2029
|
-
}
|
|
2030
|
-
return;
|
|
2031
|
-
}
|
|
2032
|
-
if (ws.data.kind === "user-hosted-client") {
|
|
2033
|
-
const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
|
|
2034
|
-
if (!tunnel) {
|
|
2035
|
-
ws.close(1011, "User-hosted tunnel disconnected");
|
|
2036
|
-
return;
|
|
2037
|
-
}
|
|
2038
|
-
if (tunnel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
|
|
2039
|
-
closeWebSocket(ws, 1011, "Host tunnel backpressure limit exceeded");
|
|
2040
|
-
return;
|
|
2041
|
-
}
|
|
2042
|
-
tunnel.send(JSON.stringify({
|
|
2043
|
-
type: "ws.frame",
|
|
2044
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2045
|
-
channel_id: ws.data.channelId,
|
|
2046
|
-
...encodedFrame(message)
|
|
2047
|
-
}));
|
|
2048
|
-
return;
|
|
2049
|
-
}
|
|
2050
|
-
if (ws.data.upstream?.readyState === WebSocket.OPEN) {
|
|
2051
|
-
ws.data.upstream.send(message);
|
|
2052
|
-
return;
|
|
2053
|
-
}
|
|
2054
|
-
if (ws.data.queue.length >= (bunOptions.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT)) {
|
|
2055
|
-
closeWebSocket(ws, 1011, "Upstream WebSocket queue limit exceeded");
|
|
2056
|
-
return;
|
|
2057
|
-
}
|
|
2058
|
-
ws.data.queue.push({ payload: message, queuedAt: performance.now() });
|
|
2059
|
-
},
|
|
2060
|
-
open(ws) {
|
|
2061
|
-
if (ws.data.kind === "host-tunnel") {
|
|
2062
|
-
const previous = hostTunnels.get(ws.data.hostId);
|
|
2063
|
-
if (previous && previous !== ws) {
|
|
2064
|
-
cleanupHostTunnelSocket({
|
|
2065
|
-
ws: previous,
|
|
2066
|
-
hostTunnels,
|
|
2067
|
-
hostTunnelStateDebounce,
|
|
2068
|
-
options,
|
|
2069
|
-
bunOptions,
|
|
2070
|
-
disconnectDirectory: false,
|
|
2071
|
-
closeChannels: true
|
|
2072
|
-
});
|
|
2073
|
-
closeWebSocket(previous, 1012, "Host tunnel replaced by a newer connection", 1012);
|
|
2074
|
-
}
|
|
2075
|
-
hostTunnels.set(ws.data.hostId, ws);
|
|
2076
|
-
options.directory?.registerHostTunnel({
|
|
2077
|
-
hostId: ws.data.hostId,
|
|
2078
|
-
workspaceIds: ws.data.workspaceIds
|
|
2079
|
-
});
|
|
2080
|
-
ws.data.heartbeat = setInterval(() => {
|
|
2081
|
-
const hostWs = ws;
|
|
2082
|
-
if (hostWs.data.missedPongs > (bunOptions.hostTunnelMaxMissedPongs ?? HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT)) {
|
|
2083
|
-
closeWebSocket(ws, 1001, "Host tunnel heartbeat timed out", 1001);
|
|
2084
|
-
return;
|
|
2085
|
-
}
|
|
2086
|
-
sendTunnelPing(hostWs);
|
|
2087
|
-
}, bunOptions.hostTunnelPingIntervalMs ?? 15e3);
|
|
2088
|
-
scheduleHostTunnelStateChange(hostTunnelStateDebounce, options, {
|
|
2089
|
-
hostId: ws.data.hostId,
|
|
2090
|
-
workspaceId: ws.data.workspaceIds[0],
|
|
2091
|
-
path: `/host-tunnels/${ws.data.hostId}`,
|
|
2092
|
-
state: "connected",
|
|
2093
|
-
debounceMs: bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
|
|
2094
|
-
});
|
|
2095
|
-
return;
|
|
2096
|
-
}
|
|
2097
|
-
if (ws.data.kind === "user-hosted-client") {
|
|
2098
|
-
const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
|
|
2099
|
-
if (!tunnel) {
|
|
2100
|
-
ws.close(1011, "User-hosted tunnel disconnected");
|
|
2101
|
-
return;
|
|
2102
|
-
}
|
|
2103
|
-
tunnel.data.channels.set(ws.data.channelId, ws);
|
|
2104
|
-
if (tunnel.readyState !== WebSocket.OPEN) {
|
|
2105
|
-
closeWebSocket(ws, 1011, "User-hosted tunnel disconnected");
|
|
2106
|
-
return;
|
|
2107
|
-
}
|
|
2108
|
-
tunnel.send(JSON.stringify({
|
|
2109
|
-
type: "ws.open",
|
|
2110
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2111
|
-
channel_id: ws.data.channelId,
|
|
2112
|
-
workspace_id: ws.data.workspaceId,
|
|
2113
|
-
path: ws.data.path,
|
|
2114
|
-
headers: headersRecord(workspaceRelayForwardHeaders(
|
|
2115
|
-
new Headers(),
|
|
2116
|
-
ws.data.relayHostToken,
|
|
2117
|
-
ws.data.workspaceId,
|
|
2118
|
-
// T26: user-hosted tunnel WS upgrade — strip Cookie. Headers are
|
|
2119
|
-
// empty here today, but pass the flag for consistency in case
|
|
2120
|
-
// upstream client headers are forwarded in the future.
|
|
2121
|
-
{ userHosted: true }
|
|
2122
|
-
))
|
|
2123
|
-
}));
|
|
2124
|
-
return;
|
|
2125
|
-
}
|
|
2126
|
-
relayClients.add(ws);
|
|
2127
|
-
const data = ws.data;
|
|
2128
|
-
const UpstreamWebSocket = bunOptions.upstreamWebSocket ?? WebSocket;
|
|
2129
|
-
if (data.trace) data.trace.upstreamStartedAt = performance.now();
|
|
2130
|
-
const upstream = new UpstreamWebSocket(data.upstreamUrl, {
|
|
2131
|
-
headers: data.headers
|
|
2132
|
-
});
|
|
2133
|
-
upstream.binaryType = "arraybuffer";
|
|
2134
|
-
data.upstream = upstream;
|
|
2135
|
-
data.upstreamOpenTimer = setTimeout(() => {
|
|
2136
|
-
if (upstream.readyState === WebSocket.OPEN) return;
|
|
2137
|
-
closeWebSocket(upstream, 1001, "Upstream WebSocket open timed out", 1001);
|
|
2138
|
-
closeWebSocket(ws, 1011, "Upstream WebSocket open timed out");
|
|
2139
|
-
}, bunOptions.upstreamWebSocketOpenTimeoutMs ?? UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT);
|
|
2140
|
-
upstream.onopen = () => {
|
|
2141
|
-
const openedAt = performance.now();
|
|
2142
|
-
if (data.upstreamOpenTimer) {
|
|
2143
|
-
clearTimeout(data.upstreamOpenTimer);
|
|
2144
|
-
data.upstreamOpenTimer = void 0;
|
|
2145
|
-
}
|
|
2146
|
-
if (data.trace) {
|
|
2147
|
-
data.trace.upstreamOpenMs = openedAt - (data.trace.upstreamStartedAt ?? data.trace.acceptedAt);
|
|
2148
|
-
data.trace.queuedFrames = data.queue.length;
|
|
2149
|
-
data.trace.maxQueuedDelayMs = data.queue.reduce(
|
|
2150
|
-
(max, item) => Math.max(max, openedAt - item.queuedAt),
|
|
2151
|
-
0
|
|
2152
|
-
);
|
|
2153
|
-
sendRelayWebSocketTrace(ws);
|
|
2154
|
-
}
|
|
2155
|
-
for (const item of data.queue.splice(0)) upstream.send(item.payload);
|
|
2156
|
-
};
|
|
2157
|
-
upstream.onmessage = (event) => {
|
|
2158
|
-
const payload = relayWebSocketPayload(event.data);
|
|
2159
|
-
if (payload !== void 0) ws.send(payload);
|
|
2160
|
-
};
|
|
2161
|
-
upstream.onclose = (event) => {
|
|
2162
|
-
if (data.upstreamOpenTimer) {
|
|
2163
|
-
clearTimeout(data.upstreamOpenTimer);
|
|
2164
|
-
data.upstreamOpenTimer = void 0;
|
|
2165
|
-
}
|
|
2166
|
-
closeWebSocket(ws, event.code, upstreamCloseReason(event));
|
|
2167
|
-
};
|
|
2168
|
-
upstream.onerror = () => {
|
|
2169
|
-
if (data.upstreamOpenTimer) {
|
|
2170
|
-
clearTimeout(data.upstreamOpenTimer);
|
|
2171
|
-
data.upstreamOpenTimer = void 0;
|
|
1223
|
+
});
|
|
1224
|
+
app.use("*", cors({
|
|
1225
|
+
origin: (origin) => {
|
|
1226
|
+
if (!origin) return void 0;
|
|
1227
|
+
return originAllowed(origin) ? origin : void 0;
|
|
1228
|
+
},
|
|
1229
|
+
// Spread from the shared list, not hand-maintained here: a duplicate
|
|
1230
|
+
// array drifts out of sync and silently drops headers like
|
|
1231
|
+
// `Last-Event-ID` or `X-Fetch-Bypass-Throttle`.
|
|
1232
|
+
allowHeaders: [...RELAY_ALLOWED_REQUEST_HEADER_LIST],
|
|
1233
|
+
allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
1234
|
+
}));
|
|
1235
|
+
app.get("/health", (c) => {
|
|
1236
|
+
if (options.isDraining?.()) {
|
|
1237
|
+
return c.json(
|
|
1238
|
+
{ ok: false, service: "workspace-relay", draining: true },
|
|
1239
|
+
503
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
return c.json({ ok: true, service: "workspace-relay" });
|
|
1243
|
+
});
|
|
1244
|
+
app.get("/.well-known/jwks.json", async (c) => {
|
|
1245
|
+
const sources = options.relayHostPublicKeys ?? [];
|
|
1246
|
+
if (sources.length === 0) {
|
|
1247
|
+
return c.json(
|
|
1248
|
+
{
|
|
1249
|
+
error: {
|
|
1250
|
+
code: "jwks_no_keys_configured",
|
|
1251
|
+
message: "No Relay Host Token public keys are configured for the JWKS endpoint"
|
|
2172
1252
|
}
|
|
2173
|
-
|
|
1253
|
+
},
|
|
1254
|
+
503
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
let keys;
|
|
1258
|
+
try {
|
|
1259
|
+
keys = await Promise.all(sources.map(async (source) => {
|
|
1260
|
+
const jwk = await exportJWK2(source.publicKey);
|
|
1261
|
+
return {
|
|
1262
|
+
...jwk,
|
|
1263
|
+
kid: source.kid,
|
|
1264
|
+
alg: "EdDSA",
|
|
1265
|
+
use: "sig"
|
|
2174
1266
|
};
|
|
2175
|
-
}
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
bunOptions,
|
|
2184
|
-
disconnectDirectory: true,
|
|
2185
|
-
closeChannels: true
|
|
2186
|
-
});
|
|
2187
|
-
const tracker = hostTunnelRegistrations.get(ws.data.hostId);
|
|
2188
|
-
if (tracker) {
|
|
2189
|
-
pruneReconnects(tracker, Date.now());
|
|
2190
|
-
if (tracker.recent.length === 0) hostTunnelRegistrations.delete(ws.data.hostId);
|
|
2191
|
-
}
|
|
2192
|
-
return;
|
|
2193
|
-
}
|
|
2194
|
-
if (ws.data.kind === "user-hosted-client") {
|
|
2195
|
-
const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
|
|
2196
|
-
tunnel?.data.channels.delete(ws.data.channelId);
|
|
2197
|
-
if (tunnel?.readyState === WebSocket.OPEN) {
|
|
2198
|
-
tunnel.send(JSON.stringify({
|
|
2199
|
-
type: "ws.close",
|
|
2200
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2201
|
-
channel_id: ws.data.channelId,
|
|
2202
|
-
code: safeCloseCode(code, 1e3),
|
|
2203
|
-
reason
|
|
2204
|
-
}));
|
|
1267
|
+
}));
|
|
1268
|
+
} catch (err) {
|
|
1269
|
+
return c.json(
|
|
1270
|
+
{
|
|
1271
|
+
error: {
|
|
1272
|
+
code: "jwks_invalid_key",
|
|
1273
|
+
message: "A configured RHT public key could not be exported as a JWK",
|
|
1274
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
2205
1275
|
}
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
1276
|
+
},
|
|
1277
|
+
500
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
c.header("cache-control", "public, max-age=300");
|
|
1281
|
+
return c.json({ keys });
|
|
1282
|
+
});
|
|
1283
|
+
app.get("/metrics", (c) => {
|
|
1284
|
+
const authResult = checkMetricsAuth(options, c.req.raw);
|
|
1285
|
+
if (!authResult.ok) {
|
|
1286
|
+
return c.json(
|
|
1287
|
+
errorBody(authResult.code, authResult.message),
|
|
1288
|
+
authResult.status
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
return c.json(buildMetricsBody(options));
|
|
1292
|
+
});
|
|
1293
|
+
app.all("/workspaces/:workspaceId/*", async (c) => {
|
|
1294
|
+
const trace = createWorkspaceRelayTrace();
|
|
1295
|
+
if (options.isDraining?.()) {
|
|
1296
|
+
return Response.json(
|
|
1297
|
+
errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
|
|
1298
|
+
{ status: 503, headers: denyCorsHeaders(c.req.raw, originAllowed) }
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
const response = await trace.span("relay-total", async () => {
|
|
1302
|
+
const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"), trace);
|
|
1303
|
+
if (!relay.ok) return relay.response;
|
|
1304
|
+
return await forwardWorkspaceRelayRequest(
|
|
1305
|
+
new Request(c.req.raw),
|
|
1306
|
+
relay.request.target,
|
|
1307
|
+
relay.request.relayHostToken,
|
|
1308
|
+
relay.request.path,
|
|
1309
|
+
options.fetch ?? fetch,
|
|
1310
|
+
options.forwardTimeoutMs ?? 3e4,
|
|
1311
|
+
trace,
|
|
1312
|
+
originAllowed
|
|
1313
|
+
);
|
|
1314
|
+
});
|
|
1315
|
+
return workspaceRelayTimingResponse(response, trace);
|
|
1316
|
+
});
|
|
1317
|
+
return app;
|
|
2216
1318
|
}
|
|
2217
1319
|
|
|
2218
|
-
// src/cloudflare.ts
|
|
2219
|
-
import {
|
|
2220
|
-
makeTunnelPong as makeTunnelPong2,
|
|
2221
|
-
TUNNEL_PROTOCOL_VERSION as TUNNEL_PROTOCOL_VERSION2,
|
|
2222
|
-
validateTunnelMessage as validateTunnelMessage2
|
|
2223
|
-
} from "@claxedo/workspace-relay-protocol";
|
|
2224
|
-
|
|
2225
1320
|
// src/directory.ts
|
|
1321
|
+
function presenceKey(hostId, workspaceId) {
|
|
1322
|
+
return `${hostId}\0${workspaceId}`;
|
|
1323
|
+
}
|
|
2226
1324
|
function createWorkspaceRelayDirectory(options = {}) {
|
|
2227
1325
|
const ttlMs = Math.max(1, options.ttlMs ?? 45e3);
|
|
2228
1326
|
const now = options.now ?? Date.now;
|
|
2229
1327
|
const sweepIntervalMs = options.sweepIntervalMs ?? 3e4;
|
|
2230
|
-
const
|
|
2231
|
-
const alive = (
|
|
2232
|
-
|
|
1328
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1329
|
+
const alive = (key) => {
|
|
1330
|
+
const presence = entries.get(key);
|
|
1331
|
+
if (!presence) return void 0;
|
|
2233
1332
|
if (presence.expiresAt <= now()) {
|
|
2234
|
-
|
|
2235
|
-
return;
|
|
1333
|
+
entries.delete(key);
|
|
1334
|
+
return void 0;
|
|
2236
1335
|
}
|
|
2237
1336
|
return presence;
|
|
2238
1337
|
};
|
|
2239
|
-
const
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
...presence,
|
|
2243
|
-
lastPongAt: timestamp,
|
|
2244
|
-
expiresAt: timestamp + ttlMs
|
|
2245
|
-
};
|
|
2246
|
-
hosts.set(next.hostId, next);
|
|
2247
|
-
return next;
|
|
1338
|
+
const keysOf = (hostId, workspaceIds) => {
|
|
1339
|
+
if (workspaceIds) return [...new Set(workspaceIds)].map((workspaceId) => presenceKey(hostId, workspaceId));
|
|
1340
|
+
return [...entries].filter(([, presence]) => presence.hostId === hostId).map(([key]) => key);
|
|
2248
1341
|
};
|
|
2249
1342
|
const sweep = () => {
|
|
2250
1343
|
const at = now();
|
|
2251
|
-
for (const [
|
|
1344
|
+
for (const [key, presence] of entries) {
|
|
2252
1345
|
if (presence.expiresAt <= at) {
|
|
2253
|
-
|
|
1346
|
+
entries.delete(key);
|
|
2254
1347
|
}
|
|
2255
1348
|
}
|
|
2256
1349
|
};
|
|
@@ -2268,29 +1361,35 @@ function createWorkspaceRelayDirectory(options = {}) {
|
|
|
2268
1361
|
lastPongAt: timestamp,
|
|
2269
1362
|
expiresAt: timestamp + ttlMs
|
|
2270
1363
|
};
|
|
2271
|
-
|
|
1364
|
+
for (const workspaceId of next.workspaceIds) entries.set(presenceKey(next.hostId, workspaceId), next);
|
|
2272
1365
|
return next;
|
|
2273
1366
|
},
|
|
2274
|
-
recordPong(hostId) {
|
|
2275
|
-
const
|
|
2276
|
-
|
|
1367
|
+
recordPong(hostId, workspaceIds) {
|
|
1368
|
+
const timestamp = now();
|
|
1369
|
+
let touched;
|
|
1370
|
+
for (const key of keysOf(hostId, workspaceIds)) {
|
|
1371
|
+
const presence = alive(key);
|
|
1372
|
+
if (!presence) continue;
|
|
1373
|
+
const next = { ...presence, lastPongAt: timestamp, expiresAt: timestamp + ttlMs };
|
|
1374
|
+
entries.set(key, next);
|
|
1375
|
+
touched ??= next;
|
|
1376
|
+
}
|
|
1377
|
+
return touched;
|
|
2277
1378
|
},
|
|
2278
|
-
disconnectHost(hostId) {
|
|
2279
|
-
|
|
1379
|
+
disconnectHost(hostId, workspaceIds) {
|
|
1380
|
+
for (const key of keysOf(hostId, workspaceIds)) entries.delete(key);
|
|
2280
1381
|
},
|
|
2281
1382
|
activeHost(input) {
|
|
2282
|
-
|
|
2283
|
-
if (!presence?.workspaceIds.includes(input.workspaceId)) return;
|
|
2284
|
-
return presence;
|
|
1383
|
+
return alive(presenceKey(input.hostId, input.workspaceId));
|
|
2285
1384
|
},
|
|
2286
1385
|
sweep,
|
|
2287
1386
|
size() {
|
|
2288
1387
|
const at = now();
|
|
2289
|
-
|
|
2290
|
-
for (const presence of
|
|
2291
|
-
if (presence.expiresAt > at)
|
|
1388
|
+
const hosts = /* @__PURE__ */ new Set();
|
|
1389
|
+
for (const presence of entries.values()) {
|
|
1390
|
+
if (presence.expiresAt > at) hosts.add(presence.hostId);
|
|
2292
1391
|
}
|
|
2293
|
-
return
|
|
1392
|
+
return hosts.size;
|
|
2294
1393
|
},
|
|
2295
1394
|
dispose() {
|
|
2296
1395
|
if (intervalHandle !== void 0) {
|
|
@@ -2306,21 +1405,91 @@ function disposeWorkspaceRelayDirectory(directory) {
|
|
|
2306
1405
|
|
|
2307
1406
|
// src/cloudflare.ts
|
|
2308
1407
|
var DEFAULT_RELAY_LOCATION_HINT = "apac";
|
|
1408
|
+
var RELAY_LOCATION_HINTS = ["wnam", "enam", "sam", "weur", "eeur", "apac", "oc", "afr", "me"];
|
|
1409
|
+
var relayLocationHintSet = new Set(RELAY_LOCATION_HINTS);
|
|
1410
|
+
function isRelayLocationHint(input) {
|
|
1411
|
+
return relayLocationHintSet.has(input);
|
|
1412
|
+
}
|
|
1413
|
+
var RELAY_REGION_TO_LOCATION_HINT = {
|
|
1414
|
+
// Canonical Claxedo regions.
|
|
1415
|
+
"apac-south": "apac",
|
|
1416
|
+
"apac-east": "apac",
|
|
1417
|
+
"eu-west": "weur",
|
|
1418
|
+
"us-east": "enam",
|
|
1419
|
+
"us-west": "wnam",
|
|
1420
|
+
// Provider spellings.
|
|
1421
|
+
"us-central": "wnam",
|
|
1422
|
+
"eu-central": "weur",
|
|
1423
|
+
"eu-east": "eeur",
|
|
1424
|
+
"ap-south": "apac",
|
|
1425
|
+
"ap-southeast": "apac",
|
|
1426
|
+
"ap-northeast": "apac",
|
|
1427
|
+
"sa-east": "sam",
|
|
1428
|
+
"af-south": "afr",
|
|
1429
|
+
"me-central": "me",
|
|
1430
|
+
"oceania": "oc",
|
|
1431
|
+
"au-southeast": "oc"
|
|
1432
|
+
};
|
|
1433
|
+
function locationHintForCountry(country) {
|
|
1434
|
+
const code = country.toUpperCase();
|
|
1435
|
+
if (["US", "CA", "MX"].includes(code)) return code === "US" || code === "CA" ? "enam" : "wnam";
|
|
1436
|
+
if (["BR", "AR", "CL", "CO", "PE", "UY", "PY", "BO", "EC", "VE"].includes(code)) return "sam";
|
|
1437
|
+
if (["GB", "IE", "FR", "ES", "PT", "NL", "BE", "DE", "CH", "AT", "IT", "DK", "NO", "SE", "FI", "IS", "LU"].includes(code)) return "weur";
|
|
1438
|
+
if (["PL", "CZ", "SK", "HU", "RO", "BG", "GR", "UA", "RS", "HR", "SI", "EE", "LV", "LT", "TR", "RU"].includes(code)) return "eeur";
|
|
1439
|
+
if (["IN", "PK", "BD", "LK", "NP", "SG", "MY", "TH", "VN", "PH", "ID", "CN", "HK", "TW", "JP", "KR"].includes(code)) return "apac";
|
|
1440
|
+
if (["AU", "NZ", "FJ", "PG"].includes(code)) return "oc";
|
|
1441
|
+
if (["ZA", "NG", "KE", "GH", "ET", "TZ", "UG", "EG", "MA", "DZ", "TN"].includes(code)) {
|
|
1442
|
+
return ["EG", "MA", "DZ", "TN"].includes(code) ? "me" : "afr";
|
|
1443
|
+
}
|
|
1444
|
+
if (["AE", "SA", "IL", "QA", "KW", "BH", "OM", "JO", "LB", "IQ", "IR"].includes(code)) return "me";
|
|
1445
|
+
return void 0;
|
|
1446
|
+
}
|
|
1447
|
+
var RELAY_REGION_HEADER = "x-claxedo-workspace-region";
|
|
1448
|
+
function relayLocationHint(input) {
|
|
1449
|
+
const configured = trimToUndefined(input.configured) ?? DEFAULT_RELAY_LOCATION_HINT;
|
|
1450
|
+
const region = trimToUndefined(input.region);
|
|
1451
|
+
if (region) {
|
|
1452
|
+
const lowered = region.toLowerCase();
|
|
1453
|
+
const direct = isRelayLocationHint(lowered) ? lowered : void 0;
|
|
1454
|
+
const mapped = direct ?? RELAY_REGION_TO_LOCATION_HINT[lowered];
|
|
1455
|
+
if (mapped) return mapped;
|
|
1456
|
+
}
|
|
1457
|
+
const country = trimToUndefined(input.country);
|
|
1458
|
+
if (country) {
|
|
1459
|
+
const mapped = locationHintForCountry(country);
|
|
1460
|
+
if (mapped) return mapped;
|
|
1461
|
+
}
|
|
1462
|
+
return configured;
|
|
1463
|
+
}
|
|
1464
|
+
function relayRequestRegion(request) {
|
|
1465
|
+
const url = new URL(request.url);
|
|
1466
|
+
return trimToUndefined(request.headers.get(RELAY_REGION_HEADER)) ?? trimToUndefined(url.searchParams.get("region")) ?? trimToUndefined(url.searchParams.get("homeRegion"));
|
|
1467
|
+
}
|
|
1468
|
+
function relayRequestCountry(request) {
|
|
1469
|
+
const cf = request.cf;
|
|
1470
|
+
return typeof cf?.country === "string" ? cf.country : void 0;
|
|
1471
|
+
}
|
|
2309
1472
|
var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
|
|
2310
1473
|
var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
|
|
2311
1474
|
var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
|
|
2312
|
-
var
|
|
1475
|
+
var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
|
|
2313
1476
|
var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
|
|
2314
|
-
var
|
|
2315
|
-
var
|
|
2316
|
-
var
|
|
1477
|
+
var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1478
|
+
var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1479
|
+
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
|
|
1480
|
+
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
2317
1481
|
var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
|
|
2318
1482
|
var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
|
|
1483
|
+
var RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
|
|
1484
|
+
var HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT = 3e4;
|
|
1485
|
+
var HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT = 3e4;
|
|
2319
1486
|
var TRACE_ID_HEADER = "x-claxedo-trace-id";
|
|
2320
1487
|
var TRACE_FORCE_HEADER = "x-claxedo-relay-trace";
|
|
2321
|
-
function
|
|
2322
|
-
|
|
2323
|
-
|
|
1488
|
+
function unrefTimer(timer) {
|
|
1489
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
1490
|
+
}
|
|
1491
|
+
function isUserHostedClient(client) {
|
|
1492
|
+
return "channelId" in client && typeof client.channelId === "string";
|
|
2324
1493
|
}
|
|
2325
1494
|
function clampRate(input) {
|
|
2326
1495
|
if (input === void 0 || !Number.isFinite(input)) return 0;
|
|
@@ -2329,8 +1498,8 @@ function clampRate(input) {
|
|
|
2329
1498
|
return input;
|
|
2330
1499
|
}
|
|
2331
1500
|
function traceRequest(request, routeKind, options) {
|
|
2332
|
-
const id =
|
|
2333
|
-
const forceSecret =
|
|
1501
|
+
const id = trimToUndefined(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
|
|
1502
|
+
const forceSecret = trimToUndefined(options.traceForceHeaderSecret);
|
|
2334
1503
|
const forced = !!forceSecret && request.headers.get(TRACE_FORCE_HEADER) === forceSecret;
|
|
2335
1504
|
const rate = clampRate(options.traceSampleRate);
|
|
2336
1505
|
return {
|
|
@@ -2350,36 +1519,36 @@ async function traceSpan(trace, name, fn) {
|
|
|
2350
1519
|
}
|
|
2351
1520
|
}
|
|
2352
1521
|
function tracedRequest(request, trace) {
|
|
2353
|
-
const
|
|
2354
|
-
|
|
2355
|
-
if (trace.sampled)
|
|
2356
|
-
else
|
|
1522
|
+
const headers = new Headers(request.headers);
|
|
1523
|
+
headers.set(TRACE_ID_HEADER, trace.id);
|
|
1524
|
+
if (trace.sampled) headers.set("x-claxedo-trace-sampled", "1");
|
|
1525
|
+
else headers.delete("x-claxedo-trace-sampled");
|
|
2357
1526
|
return new Request(request.url, {
|
|
2358
1527
|
method: request.method,
|
|
2359
|
-
headers
|
|
1528
|
+
headers,
|
|
2360
1529
|
body: request.method === "GET" || request.method === "HEAD" ? void 0 : request.body,
|
|
2361
1530
|
redirect: request.redirect,
|
|
2362
1531
|
duplex: "half"
|
|
2363
1532
|
});
|
|
2364
1533
|
}
|
|
2365
|
-
function
|
|
1534
|
+
function roundedMs2(value) {
|
|
2366
1535
|
return Math.round(value * 100) / 100;
|
|
2367
1536
|
}
|
|
2368
1537
|
function serverTiming(trace) {
|
|
2369
|
-
return trace.phases.map((phase) => `${phase.name};dur=${
|
|
1538
|
+
return trace.phases.map((phase) => `${phase.name};dur=${roundedMs2(phase.ms)}`).join(", ");
|
|
2370
1539
|
}
|
|
2371
1540
|
function withTraceHeaders(response, trace) {
|
|
2372
|
-
const
|
|
2373
|
-
|
|
1541
|
+
const headers = new Headers(response.headers);
|
|
1542
|
+
headers.set(TRACE_ID_HEADER, trace.id);
|
|
2374
1543
|
const timing = trace.sampled ? serverTiming(trace) : "";
|
|
2375
1544
|
if (timing) {
|
|
2376
|
-
const existing =
|
|
2377
|
-
|
|
1545
|
+
const existing = headers.get("server-timing");
|
|
1546
|
+
headers.set("server-timing", existing ? `${existing}, ${timing}` : timing);
|
|
2378
1547
|
}
|
|
2379
1548
|
return new Response(response.body, {
|
|
2380
1549
|
status: response.status,
|
|
2381
1550
|
statusText: response.statusText,
|
|
2382
|
-
headers
|
|
1551
|
+
headers
|
|
2383
1552
|
});
|
|
2384
1553
|
}
|
|
2385
1554
|
function logTrace(options, request, workspaceId, trace, response) {
|
|
@@ -2394,8 +1563,8 @@ function logTrace(options, request, workspaceId, trace, response) {
|
|
|
2394
1563
|
method: request.method,
|
|
2395
1564
|
path: url.pathname,
|
|
2396
1565
|
status: response.status,
|
|
2397
|
-
totalMs:
|
|
2398
|
-
phases: Object.fromEntries(trace.phases.map((phase) => [phase.name,
|
|
1566
|
+
totalMs: roundedMs2(totalMs),
|
|
1567
|
+
phases: Object.fromEntries(trace.phases.map((phase) => [phase.name, roundedMs2(phase.ms)]))
|
|
2399
1568
|
};
|
|
2400
1569
|
if (options.traceLog) {
|
|
2401
1570
|
options.traceLog(event);
|
|
@@ -2406,44 +1575,58 @@ function logTrace(options, request, workspaceId, trace, response) {
|
|
|
2406
1575
|
function workspaceIdFromRelayPath(pathname) {
|
|
2407
1576
|
return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
2408
1577
|
}
|
|
2409
|
-
function
|
|
1578
|
+
function hostIdFromTunnelPath(pathname) {
|
|
2410
1579
|
return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
2411
1580
|
}
|
|
2412
|
-
function
|
|
1581
|
+
function websocketRequest(request) {
|
|
2413
1582
|
return request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
2414
1583
|
}
|
|
2415
|
-
function
|
|
1584
|
+
function relayWebSocketTraceEnabled(request) {
|
|
2416
1585
|
return request.headers.get("x-claxedo-relay-ws-trace") === "1";
|
|
2417
1586
|
}
|
|
2418
1587
|
function workspaceIdsFromSearch(url) {
|
|
2419
1588
|
return [...new Set([
|
|
2420
1589
|
...url.searchParams.getAll("workspaceId"),
|
|
2421
1590
|
...url.searchParams.getAll("workspace_id")
|
|
2422
|
-
].map(
|
|
1591
|
+
].map(trimToUndefined).filter((item) => !!item))];
|
|
2423
1592
|
}
|
|
2424
1593
|
function randomToken() {
|
|
2425
1594
|
return crypto.randomUUID();
|
|
2426
1595
|
}
|
|
2427
|
-
function headerRecord(
|
|
1596
|
+
function headerRecord(headers) {
|
|
2428
1597
|
const result = {};
|
|
2429
|
-
|
|
1598
|
+
headers.forEach((value, key) => {
|
|
2430
1599
|
result[key] = value;
|
|
2431
1600
|
});
|
|
2432
1601
|
return result;
|
|
2433
1602
|
}
|
|
2434
1603
|
function headersFromRecord(input) {
|
|
2435
|
-
const
|
|
2436
|
-
for (const [key, value] of Object.entries(input))
|
|
2437
|
-
return
|
|
1604
|
+
const headers = new Headers();
|
|
1605
|
+
for (const [key, value] of Object.entries(input)) headers.set(key, value);
|
|
1606
|
+
return headers;
|
|
2438
1607
|
}
|
|
1608
|
+
var BASE64_CHUNK_BYTES = 32768;
|
|
2439
1609
|
function bytesToBase64(bytes) {
|
|
2440
1610
|
let binary = "";
|
|
2441
|
-
for (
|
|
1611
|
+
for (let i = 0; i < bytes.length; i += BASE64_CHUNK_BYTES) {
|
|
1612
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_BYTES));
|
|
1613
|
+
}
|
|
2442
1614
|
return btoa(binary);
|
|
2443
1615
|
}
|
|
2444
1616
|
function base64ToBytes(input) {
|
|
2445
1617
|
return Uint8Array.from(atob(input), (char) => char.charCodeAt(0));
|
|
2446
1618
|
}
|
|
1619
|
+
function preOpenFrameBytes(input) {
|
|
1620
|
+
if (typeof input === "string") return new TextEncoder().encode(input).byteLength;
|
|
1621
|
+
return input.byteLength;
|
|
1622
|
+
}
|
|
1623
|
+
function isBlobLike(input) {
|
|
1624
|
+
if (!input || typeof input !== "object") return false;
|
|
1625
|
+
return "arrayBuffer" in input && typeof input.arrayBuffer === "function";
|
|
1626
|
+
}
|
|
1627
|
+
function viewBytes(input) {
|
|
1628
|
+
return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
|
1629
|
+
}
|
|
2447
1630
|
function socketFrame(input) {
|
|
2448
1631
|
if (typeof input === "string") {
|
|
2449
1632
|
return {
|
|
@@ -2463,14 +1646,54 @@ function socketFrame(input) {
|
|
|
2463
1646
|
data_base64: bytesToBase64(input)
|
|
2464
1647
|
};
|
|
2465
1648
|
}
|
|
1649
|
+
if (ArrayBuffer.isView(input)) {
|
|
1650
|
+
return {
|
|
1651
|
+
binary: true,
|
|
1652
|
+
data_base64: bytesToBase64(viewBytes(input))
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
if (!isBlobLike(input)) return void 0;
|
|
1656
|
+
return input.arrayBuffer().then((buffer) => ({
|
|
1657
|
+
binary: true,
|
|
1658
|
+
data_base64: bytesToBase64(new Uint8Array(buffer))
|
|
1659
|
+
}));
|
|
2466
1660
|
}
|
|
2467
1661
|
function socketPayload(input) {
|
|
2468
1662
|
if (typeof input === "string") return input;
|
|
2469
1663
|
if (input instanceof ArrayBuffer) return input;
|
|
2470
1664
|
if (input instanceof Uint8Array) return input;
|
|
2471
|
-
if (ArrayBuffer.isView(input))
|
|
2472
|
-
|
|
2473
|
-
|
|
1665
|
+
if (ArrayBuffer.isView(input)) return viewBytes(input);
|
|
1666
|
+
if (!isBlobLike(input)) return void 0;
|
|
1667
|
+
return input.arrayBuffer();
|
|
1668
|
+
}
|
|
1669
|
+
function orderedFrameDelivery(deliver) {
|
|
1670
|
+
let tail;
|
|
1671
|
+
return (data) => {
|
|
1672
|
+
const payload = socketPayload(data);
|
|
1673
|
+
if (payload === void 0) return;
|
|
1674
|
+
if (tail === void 0 && !(payload instanceof Promise)) {
|
|
1675
|
+
deliver(payload);
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
const queued = (tail ?? Promise.resolve()).then(async () => deliver(await payload)).catch(() => {
|
|
1679
|
+
});
|
|
1680
|
+
tail = queued;
|
|
1681
|
+
void queued.then(() => {
|
|
1682
|
+
if (tail === queued) tail = void 0;
|
|
1683
|
+
});
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
async function relayFrameDiagnostics(data) {
|
|
1687
|
+
const frame = await socketFrame(data);
|
|
1688
|
+
const payload = await socketPayload(data);
|
|
1689
|
+
return {
|
|
1690
|
+
// `undefined` here is exactly the silent drop: every caller treats it as
|
|
1691
|
+
// "nothing to forward" and returns without a throw or a log.
|
|
1692
|
+
frameDropped: frame === void 0,
|
|
1693
|
+
payloadDropped: payload === void 0,
|
|
1694
|
+
...frame ? { binary: frame.binary, dataBase64: frame.data_base64 } : {},
|
|
1695
|
+
payloadBase64: payload === void 0 ? void 0 : typeof payload === "string" ? bytesToBase64(new TextEncoder().encode(payload)) : bytesToBase64(payload instanceof ArrayBuffer ? new Uint8Array(payload) : payload)
|
|
1696
|
+
};
|
|
2474
1697
|
}
|
|
2475
1698
|
function tunnelFrame(input) {
|
|
2476
1699
|
const bytes = base64ToBytes(input.data_base64);
|
|
@@ -2507,7 +1730,7 @@ async function readRequestBodyCapped(request, maxBytes) {
|
|
|
2507
1730
|
}
|
|
2508
1731
|
return concatBytes(chunks);
|
|
2509
1732
|
}
|
|
2510
|
-
function
|
|
1733
|
+
function safeCloseCode(code, fallback = 1011) {
|
|
2511
1734
|
if (code === void 0) return fallback;
|
|
2512
1735
|
if (code >= 1e3 && code <= 4999 && ![1004, 1005, 1006].includes(code)) return code;
|
|
2513
1736
|
return fallback;
|
|
@@ -2517,15 +1740,19 @@ function workspaceRelayDurableObjectRoomName(workspaceId) {
|
|
|
2517
1740
|
}
|
|
2518
1741
|
function workspaceRelayDurableObjectWorkspaceId(request) {
|
|
2519
1742
|
const url = new URL(request.url);
|
|
2520
|
-
const workspaceId =
|
|
1743
|
+
const workspaceId = trimToUndefined(workspaceIdFromRelayPath(url.pathname));
|
|
2521
1744
|
if (workspaceId) return decodeURIComponent(workspaceId);
|
|
2522
|
-
if (!
|
|
2523
|
-
return
|
|
1745
|
+
if (!hostIdFromTunnelPath(url.pathname)) return void 0;
|
|
1746
|
+
return trimToUndefined(url.searchParams.get("workspaceId")) ?? trimToUndefined(url.searchParams.get("workspace_id"));
|
|
2524
1747
|
}
|
|
2525
1748
|
function namespaceFromEnv(env, options) {
|
|
2526
1749
|
if (options.namespace) return options.namespace;
|
|
2527
1750
|
const binding = env[options.bindingName ?? "WORKSPACE_RELAY_ROOM"];
|
|
2528
|
-
return binding
|
|
1751
|
+
return isDurableObjectNamespace(binding) ? binding : void 0;
|
|
1752
|
+
}
|
|
1753
|
+
function isDurableObjectNamespace(value) {
|
|
1754
|
+
if (!value || typeof value !== "object") return false;
|
|
1755
|
+
return "idFromName" in value && typeof value.idFromName === "function" && "get" in value && typeof value.get === "function";
|
|
2529
1756
|
}
|
|
2530
1757
|
function json(code, message, status) {
|
|
2531
1758
|
return Response.json(errorBody(code, message), { status });
|
|
@@ -2540,32 +1767,31 @@ function setWorkspaceRelayAllowedOrigins(raw) {
|
|
|
2540
1767
|
const patterns = parseAllowedOrigins(raw);
|
|
2541
1768
|
baseOriginAllowed = patterns ? createOriginMatcher(patterns) : defaultOriginAllowed;
|
|
2542
1769
|
}
|
|
2543
|
-
function
|
|
2544
|
-
if (!origin) return;
|
|
2545
|
-
|
|
2546
|
-
if (configuredAppOriginAllowed(origin)) return origin;
|
|
1770
|
+
function allowedCorsOrigin(origin) {
|
|
1771
|
+
if (!origin) return void 0;
|
|
1772
|
+
return baseOriginAllowed(origin) || configuredAppOriginAllowed(origin) ? origin : void 0;
|
|
2547
1773
|
}
|
|
2548
1774
|
function corsHeaders(request) {
|
|
2549
|
-
const origin =
|
|
2550
|
-
if (!origin) return;
|
|
1775
|
+
const origin = allowedCorsOrigin(request.headers.get("origin"));
|
|
1776
|
+
if (!origin) return void 0;
|
|
2551
1777
|
return {
|
|
2552
1778
|
"access-control-allow-origin": origin,
|
|
2553
1779
|
"access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
2554
|
-
"access-control-allow-headers":
|
|
1780
|
+
"access-control-allow-headers": RELAY_ALLOWED_REQUEST_HEADERS,
|
|
2555
1781
|
"access-control-max-age": "86400",
|
|
2556
1782
|
vary: "Origin"
|
|
2557
1783
|
};
|
|
2558
1784
|
}
|
|
2559
1785
|
function corsPreflight(request) {
|
|
2560
|
-
const
|
|
2561
|
-
return new Response(null, { status:
|
|
1786
|
+
const headers = corsHeaders(request);
|
|
1787
|
+
return new Response(null, { status: headers ? 204 : 403, ...headers ? { headers } : {} });
|
|
2562
1788
|
}
|
|
2563
1789
|
function withCors(request, response) {
|
|
2564
|
-
const
|
|
2565
|
-
if (!
|
|
2566
|
-
if (response.status === 101 || response.webSocket) return response;
|
|
1790
|
+
const headers = corsHeaders(request);
|
|
1791
|
+
if (!headers) return response;
|
|
1792
|
+
if (response.status === 101 || "webSocket" in response && response.webSocket) return response;
|
|
2567
1793
|
const next = new Headers(response.headers);
|
|
2568
|
-
for (const [key, value] of Object.entries(
|
|
1794
|
+
for (const [key, value] of Object.entries(headers)) next.set(key, value);
|
|
2569
1795
|
return new Response(response.body, {
|
|
2570
1796
|
status: response.status,
|
|
2571
1797
|
statusText: response.statusText,
|
|
@@ -2573,7 +1799,7 @@ function withCors(request, response) {
|
|
|
2573
1799
|
});
|
|
2574
1800
|
}
|
|
2575
1801
|
function defaultWebSocketPair() {
|
|
2576
|
-
const pair = new
|
|
1802
|
+
const pair = new WebSocketPair();
|
|
2577
1803
|
return {
|
|
2578
1804
|
client: pair[0],
|
|
2579
1805
|
server: pair[1]
|
|
@@ -2581,7 +1807,7 @@ function defaultWebSocketPair() {
|
|
|
2581
1807
|
}
|
|
2582
1808
|
function selectedSubprotocol(request) {
|
|
2583
1809
|
const offered = request.headers.get("sec-websocket-protocol")?.split(",").map((item) => item.trim()).filter(Boolean);
|
|
2584
|
-
if (!offered?.length) return;
|
|
1810
|
+
if (!offered?.length) return void 0;
|
|
2585
1811
|
return offered.find((item) => item.startsWith("claxedo-rat.")) ?? offered[0];
|
|
2586
1812
|
}
|
|
2587
1813
|
function upgradeResponse(socket, request) {
|
|
@@ -2608,7 +1834,7 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
|
|
|
2608
1834
|
return {
|
|
2609
1835
|
async fetch(request, env = {}) {
|
|
2610
1836
|
const gatewayStartedAt = performance.now();
|
|
2611
|
-
const gatewayTraceId =
|
|
1837
|
+
const gatewayTraceId = trimToUndefined(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
|
|
2612
1838
|
const url = new URL(request.url);
|
|
2613
1839
|
if (request.method === "OPTIONS" && (url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/"))) {
|
|
2614
1840
|
return corsPreflight(request);
|
|
@@ -2616,7 +1842,13 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
|
|
|
2616
1842
|
if (url.pathname === "/health") {
|
|
2617
1843
|
return Response.json({ ok: true, service: "workspace-relay", mode: "cloudflare-durable-object" });
|
|
2618
1844
|
}
|
|
2619
|
-
const
|
|
1845
|
+
const requestRegion = relayRequestRegion(request);
|
|
1846
|
+
const requestCountry = relayRequestCountry(request);
|
|
1847
|
+
const locationHint = relayLocationHint({
|
|
1848
|
+
...typeof env.CLAXEDO_RELAY_LOCATION_HINT === "string" ? { configured: env.CLAXEDO_RELAY_LOCATION_HINT } : {},
|
|
1849
|
+
...requestRegion ? { region: requestRegion } : {},
|
|
1850
|
+
...requestCountry ? { country: requestCountry } : {}
|
|
1851
|
+
});
|
|
2620
1852
|
const namespace = namespaceFromEnv(env, options);
|
|
2621
1853
|
if (!namespace) {
|
|
2622
1854
|
return json(
|
|
@@ -2642,7 +1874,7 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
|
|
|
2642
1874
|
}
|
|
2643
1875
|
const routeFinishedAt = performance.now();
|
|
2644
1876
|
const response = await namespace.get(namespace.idFromName(workspaceRelayDurableObjectRoomName(workspaceId)), {
|
|
2645
|
-
locationHint
|
|
1877
|
+
locationHint
|
|
2646
1878
|
}).fetch(tracedRequest(request, {
|
|
2647
1879
|
id: gatewayTraceId,
|
|
2648
1880
|
sampled: request.headers.get("x-claxedo-trace-sampled") === "1",
|
|
@@ -2651,26 +1883,26 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
|
|
|
2651
1883
|
phases: []
|
|
2652
1884
|
}));
|
|
2653
1885
|
const fetchFinishedAt = performance.now();
|
|
2654
|
-
if (response.status === 101 || response.webSocket) {
|
|
1886
|
+
if (response.status === 101 || "webSocket" in response && response.webSocket) {
|
|
2655
1887
|
return url.pathname.startsWith("/workspaces/") ? withCors(request, response) : response;
|
|
2656
1888
|
}
|
|
2657
|
-
const
|
|
2658
|
-
|
|
2659
|
-
|
|
1889
|
+
const headers = new Headers(response.headers);
|
|
1890
|
+
headers.set(TRACE_ID_HEADER, headers.get(TRACE_ID_HEADER) ?? gatewayTraceId);
|
|
1891
|
+
headers.set("x-claxedo-relay-location-hint", locationHint);
|
|
2660
1892
|
const gatewayResponseStartedAt = performance.now();
|
|
2661
1893
|
const tracedResponse = new Response(response.body, {
|
|
2662
1894
|
status: response.status,
|
|
2663
1895
|
statusText: response.statusText,
|
|
2664
|
-
headers
|
|
1896
|
+
headers
|
|
2665
1897
|
});
|
|
2666
1898
|
const gatewayFinishedAt = performance.now();
|
|
2667
1899
|
const roomTiming = tracedResponse.headers.get("server-timing");
|
|
2668
1900
|
if (roomTiming) {
|
|
2669
1901
|
const gatewayTiming = [
|
|
2670
|
-
`relay-gateway-route;dur=${
|
|
2671
|
-
`relay-gateway-do-fetch;dur=${
|
|
2672
|
-
`relay-gateway-response;dur=${
|
|
2673
|
-
`relay-gateway;dur=${
|
|
1902
|
+
`relay-gateway-route;dur=${roundedMs2(routeFinishedAt - gatewayStartedAt)}`,
|
|
1903
|
+
`relay-gateway-do-fetch;dur=${roundedMs2(fetchFinishedAt - routeFinishedAt)}`,
|
|
1904
|
+
`relay-gateway-response;dur=${roundedMs2(gatewayFinishedAt - gatewayResponseStartedAt)}`,
|
|
1905
|
+
`relay-gateway;dur=${roundedMs2(gatewayFinishedAt - gatewayStartedAt)}`
|
|
2674
1906
|
].join(", ");
|
|
2675
1907
|
tracedResponse.headers.set("server-timing", `${roomTiming}, ${gatewayTiming}`);
|
|
2676
1908
|
}
|
|
@@ -2720,6 +1952,8 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2720
1952
|
}
|
|
2721
1953
|
};
|
|
2722
1954
|
const cleanupTunnelResources = (tunnel, reason) => {
|
|
1955
|
+
if (tunnel.generationWatcher) clearInterval(tunnel.generationWatcher);
|
|
1956
|
+
tunnel.generationWatcher = void 0;
|
|
2723
1957
|
for (const pending of tunnel.pending.values()) {
|
|
2724
1958
|
clearTimeout(pending.timeout);
|
|
2725
1959
|
if (pending.stream?.slowConsumerTimeout) clearTimeout(pending.stream.slowConsumerTimeout);
|
|
@@ -2741,6 +1975,10 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2741
1975
|
const rebuildHibernatedSockets = () => {
|
|
2742
1976
|
if (!hibernation) return;
|
|
2743
1977
|
hibernatedSocketsRebuilt = true;
|
|
1978
|
+
for (const tunnel of hostTunnels.values()) {
|
|
1979
|
+
if (tunnel.pending.size === 0) continue;
|
|
1980
|
+
cleanupTunnelResources(tunnel, "Workspace relay restarted while the request was in flight");
|
|
1981
|
+
}
|
|
2744
1982
|
hostTunnels.clear();
|
|
2745
1983
|
clients.clear();
|
|
2746
1984
|
const clientRows = [];
|
|
@@ -2752,6 +1990,9 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2752
1990
|
hostId: attachment.hostId,
|
|
2753
1991
|
workspaceIds: attachment.workspaceIds,
|
|
2754
1992
|
connectedAt: attachment.connectedAt,
|
|
1993
|
+
...attachment.enrollmentId ? { enrollmentId: attachment.enrollmentId } : {},
|
|
1994
|
+
...attachment.generation !== void 0 ? { generation: attachment.generation } : {},
|
|
1995
|
+
generationCheckFailures: attachment.generationCheckFailures ?? 0,
|
|
2755
1996
|
socket,
|
|
2756
1997
|
pending: /* @__PURE__ */ new Map(),
|
|
2757
1998
|
channels: /* @__PURE__ */ new Map()
|
|
@@ -2798,10 +2039,13 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2798
2039
|
}
|
|
2799
2040
|
}
|
|
2800
2041
|
};
|
|
2042
|
+
const resolverGraceAttempts = options.resolverOutageGraceAttempts ?? RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT;
|
|
2043
|
+
const resolverUnreachable = (code) => code === "relay_revocation_resolver_unavailable";
|
|
2801
2044
|
const watchRuntimeAccessToken = (client, onInactive) => {
|
|
2802
2045
|
const intervalMs = options.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
|
|
2803
|
-
if (intervalMs <= 0) return;
|
|
2046
|
+
if (intervalMs <= 0) return void 0;
|
|
2804
2047
|
const now = options.now ?? Date.now;
|
|
2048
|
+
let consecutiveUnreachable = 0;
|
|
2805
2049
|
const timer = setInterval(() => {
|
|
2806
2050
|
if (client.request.claims.exp * 1e3 <= now()) {
|
|
2807
2051
|
clearInterval(timer);
|
|
@@ -2809,41 +2053,98 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2809
2053
|
return;
|
|
2810
2054
|
}
|
|
2811
2055
|
if (!options.isRuntimeAccessTokenActive) return;
|
|
2056
|
+
const tolerate = (reason) => {
|
|
2057
|
+
consecutiveUnreachable += 1;
|
|
2058
|
+
if (consecutiveUnreachable < resolverGraceAttempts) return;
|
|
2059
|
+
clearInterval(timer);
|
|
2060
|
+
onInactive(reason);
|
|
2061
|
+
};
|
|
2812
2062
|
void Promise.resolve(options.isRuntimeAccessTokenActive(client.request.claims)).then((active) => {
|
|
2813
|
-
if (active.active)
|
|
2063
|
+
if (active.active) {
|
|
2064
|
+
consecutiveUnreachable = 0;
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
if (resolverUnreachable(active.code)) {
|
|
2068
|
+
tolerate(`Runtime Access Token active check unavailable: ${active.reason}`);
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
consecutiveUnreachable = 0;
|
|
2814
2072
|
clearInterval(timer);
|
|
2815
2073
|
onInactive(active.reason);
|
|
2816
|
-
}).catch(() =>
|
|
2817
|
-
clearInterval(timer);
|
|
2818
|
-
onInactive("Runtime Access Token active check failed");
|
|
2819
|
-
});
|
|
2074
|
+
}).catch(() => tolerate("Runtime Access Token active check failed"));
|
|
2820
2075
|
}, intervalMs);
|
|
2821
|
-
|
|
2822
|
-
timer.unref();
|
|
2823
|
-
}
|
|
2076
|
+
unrefTimer(timer);
|
|
2824
2077
|
return timer;
|
|
2825
2078
|
};
|
|
2826
2079
|
const watchUserHostedTarget = (client, onInactive) => {
|
|
2827
2080
|
const intervalMs = options.workspaceTargetActiveCheckIntervalMs ?? WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
|
|
2828
|
-
if (intervalMs <= 0) return;
|
|
2081
|
+
if (intervalMs <= 0) return void 0;
|
|
2829
2082
|
const target = client.request.target;
|
|
2083
|
+
let consecutiveUnreachable = 0;
|
|
2830
2084
|
const timer = setInterval(() => {
|
|
2831
2085
|
void Promise.resolve(options.resolveTarget(client.request.claims)).then((activeTarget) => {
|
|
2832
2086
|
if (activeTarget?.access === "user-hosted" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
|
|
2087
|
+
consecutiveUnreachable = 0;
|
|
2833
2088
|
return;
|
|
2834
2089
|
}
|
|
2090
|
+
consecutiveUnreachable = 0;
|
|
2835
2091
|
clearInterval(timer);
|
|
2836
2092
|
onInactive("User-hosted workspace is offline");
|
|
2837
2093
|
}).catch(() => {
|
|
2094
|
+
consecutiveUnreachable += 1;
|
|
2095
|
+
if (consecutiveUnreachable < resolverGraceAttempts) return;
|
|
2838
2096
|
clearInterval(timer);
|
|
2839
2097
|
onInactive("Workspace relay target active check failed");
|
|
2840
2098
|
});
|
|
2841
2099
|
}, intervalMs);
|
|
2842
|
-
|
|
2843
|
-
timer.unref();
|
|
2844
|
-
}
|
|
2100
|
+
unrefTimer(timer);
|
|
2845
2101
|
return timer;
|
|
2846
2102
|
};
|
|
2103
|
+
const hostTunnelAttachment = (tunnel) => ({
|
|
2104
|
+
kind: "host-tunnel",
|
|
2105
|
+
hostId: tunnel.hostId,
|
|
2106
|
+
workspaceIds: tunnel.workspaceIds,
|
|
2107
|
+
connectedAt: tunnel.connectedAt,
|
|
2108
|
+
...tunnel.enrollmentId ? { enrollmentId: tunnel.enrollmentId } : {},
|
|
2109
|
+
...tunnel.generation !== void 0 ? { generation: tunnel.generation } : {},
|
|
2110
|
+
...tunnel.generationCheckFailures ? { generationCheckFailures: tunnel.generationCheckFailures } : {}
|
|
2111
|
+
});
|
|
2112
|
+
const fenced = (tunnel) => Boolean(options.resolveHostGeneration) && tunnel.generation !== void 0;
|
|
2113
|
+
const recheckHostGeneration = async (tunnel) => {
|
|
2114
|
+
if (!fenced(tunnel) || hostTunnels.get(tunnel.hostId) !== tunnel) return;
|
|
2115
|
+
const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, {
|
|
2116
|
+
enrollment_id: tunnel.enrollmentId,
|
|
2117
|
+
generation: tunnel.generation
|
|
2118
|
+
});
|
|
2119
|
+
if (hostTunnels.get(tunnel.hostId) !== tunnel) return;
|
|
2120
|
+
if (decision.ok) {
|
|
2121
|
+
if (tunnel.generationCheckFailures === 0) return;
|
|
2122
|
+
tunnel.generationCheckFailures = 0;
|
|
2123
|
+
tunnel.socket.serializeAttachment?.(hostTunnelAttachment(tunnel));
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
if (decision.retryable) {
|
|
2127
|
+
tunnel.generationCheckFailures += 1;
|
|
2128
|
+
if (tunnel.generationCheckFailures < resolverGraceAttempts) {
|
|
2129
|
+
tunnel.socket.serializeAttachment?.(hostTunnelAttachment(tunnel));
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
cleanupHostTunnel(tunnel.hostId, tunnel.socket, "Host generation check unavailable");
|
|
2133
|
+
closeSocket(tunnel.socket, 1012, "Host generation check unavailable");
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
cleanupHostTunnel(tunnel.hostId, tunnel.socket, decision.reason);
|
|
2137
|
+
closeSocket(tunnel.socket, 1008, decision.reason);
|
|
2138
|
+
};
|
|
2139
|
+
const watchHostGeneration = (tunnel) => {
|
|
2140
|
+
const intervalMs = options.hostGenerationCheckIntervalMs ?? HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT;
|
|
2141
|
+
if (tunnel.generationWatcher || hibernation || !fenced(tunnel) || intervalMs <= 0) return;
|
|
2142
|
+
tunnel.generationWatcher = setInterval(() => {
|
|
2143
|
+
void recheckHostGeneration(tunnel).catch(() => {
|
|
2144
|
+
});
|
|
2145
|
+
}, intervalMs);
|
|
2146
|
+
unrefTimer(tunnel.generationWatcher);
|
|
2147
|
+
};
|
|
2847
2148
|
const clearSlowConsumerTimer = (pending) => {
|
|
2848
2149
|
if (!pending.stream?.slowConsumerTimeout) return;
|
|
2849
2150
|
clearTimeout(pending.stream.slowConsumerTimeout);
|
|
@@ -2858,7 +2159,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2858
2159
|
pending.stream.flowPaused = paused;
|
|
2859
2160
|
tunnel.socket.send?.(JSON.stringify({
|
|
2860
2161
|
type: "http.response.flow",
|
|
2861
|
-
protocol:
|
|
2162
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2862
2163
|
request_id: requestId,
|
|
2863
2164
|
paused,
|
|
2864
2165
|
reason
|
|
@@ -2879,7 +2180,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2879
2180
|
if (pending.stream?.slowConsumerTimeout) return;
|
|
2880
2181
|
pending.stream.slowConsumerTimeout = setTimeout(
|
|
2881
2182
|
() => failSlowConsumer(tunnel, requestId, pending),
|
|
2882
|
-
options.slowConsumerTimeoutMs ??
|
|
2183
|
+
options.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
|
|
2883
2184
|
);
|
|
2884
2185
|
};
|
|
2885
2186
|
const drainPendingStreamChunks = (tunnel, requestId, pending) => {
|
|
@@ -2933,7 +2234,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2933
2234
|
failPendingTunnelResponse(tunnel, oldestId, oldest, new Error("stream_evicted: tunnel started-stream cap reached"));
|
|
2934
2235
|
}
|
|
2935
2236
|
};
|
|
2936
|
-
const resolvePendingTunnelStream = (tunnel, requestId, pending, status,
|
|
2237
|
+
const resolvePendingTunnelStream = (tunnel, requestId, pending, status, headers) => {
|
|
2937
2238
|
if (pending.stream) return;
|
|
2938
2239
|
pending.startedSeq = ++startedSeqCounter;
|
|
2939
2240
|
evictOldestStartedStream(tunnel, requestId);
|
|
@@ -2957,18 +2258,18 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2957
2258
|
}
|
|
2958
2259
|
tunnel.socket.send?.(JSON.stringify({
|
|
2959
2260
|
type: "http.response.flow",
|
|
2960
|
-
protocol:
|
|
2261
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2961
2262
|
request_id: requestId,
|
|
2962
2263
|
paused: false,
|
|
2963
2264
|
reason: "closed"
|
|
2964
2265
|
}));
|
|
2965
2266
|
}
|
|
2966
2267
|
}, new ByteLengthQueuingStrategy({
|
|
2967
|
-
highWaterMark: options.slowConsumerHighWaterMarkBytes ??
|
|
2268
|
+
highWaterMark: options.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT
|
|
2968
2269
|
}));
|
|
2969
2270
|
pending.resolve(new Response(stream, {
|
|
2970
2271
|
status,
|
|
2971
|
-
headers
|
|
2272
|
+
headers
|
|
2972
2273
|
}));
|
|
2973
2274
|
};
|
|
2974
2275
|
const failPendingTunnelResponse = (tunnel, requestId, pending, error) => {
|
|
@@ -3019,11 +2320,77 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3019
2320
|
const activeTarget = await options.resolveTarget(client.request.claims);
|
|
3020
2321
|
return !!(activeTarget?.access === "user-hosted" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId }));
|
|
3021
2322
|
};
|
|
2323
|
+
const dropUserHostedChannel = (tunnel, channelId, client, code, reason) => {
|
|
2324
|
+
tunnel.channels.delete(channelId);
|
|
2325
|
+
clients.delete(client);
|
|
2326
|
+
closeSocket(client.socket, code, reason);
|
|
2327
|
+
sendSocket(tunnel.socket, JSON.stringify({
|
|
2328
|
+
type: "ws.close",
|
|
2329
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2330
|
+
channel_id: channelId,
|
|
2331
|
+
code,
|
|
2332
|
+
reason
|
|
2333
|
+
}));
|
|
2334
|
+
};
|
|
2335
|
+
const reportFrameHandlerFailure = (socket, err) => {
|
|
2336
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
2337
|
+
console.error(`[workspace-relay] frame handler failed: ${reason}`);
|
|
2338
|
+
closeSocket(socket, 1011, "Workspace relay failed to process a frame");
|
|
2339
|
+
};
|
|
2340
|
+
const hibernatedRevocationIntervalMs = () => options.hibernatedRevocationCheckIntervalMs ?? HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT;
|
|
2341
|
+
const scheduleHibernatedRevocationCheck = async () => {
|
|
2342
|
+
const intervalMs = hibernatedRevocationIntervalMs();
|
|
2343
|
+
if (!hibernation || !options.alarms || intervalMs <= 0) return;
|
|
2344
|
+
if (clients.size === 0 && ![...hostTunnels.values()].some(fenced)) return;
|
|
2345
|
+
const now = options.now ?? Date.now;
|
|
2346
|
+
const at = now() + intervalMs;
|
|
2347
|
+
try {
|
|
2348
|
+
const existing = await options.alarms.getAlarm();
|
|
2349
|
+
if (typeof existing === "number" && existing > 0 && existing <= at) return;
|
|
2350
|
+
await options.alarms.setAlarm(at);
|
|
2351
|
+
} catch {
|
|
2352
|
+
}
|
|
2353
|
+
};
|
|
2354
|
+
const runHibernatedRevocationCheck = async () => {
|
|
2355
|
+
ensureHibernatedSocketsRebuilt();
|
|
2356
|
+
const now = options.now ?? Date.now;
|
|
2357
|
+
const revoke = (client, reason) => {
|
|
2358
|
+
if (isUserHostedClient(client)) {
|
|
2359
|
+
for (const tunnel of hostTunnels.values()) {
|
|
2360
|
+
if (tunnel.channels.get(client.channelId) !== client) continue;
|
|
2361
|
+
dropUserHostedChannel(tunnel, client.channelId, client, 1008, reason);
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
closeSocket(client.socket, 1008, reason);
|
|
2366
|
+
clients.delete(client);
|
|
2367
|
+
};
|
|
2368
|
+
for (const client of Array.from(clients)) {
|
|
2369
|
+
if (client.request.claims.exp * 1e3 <= now()) {
|
|
2370
|
+
revoke(client, "Runtime Access Token expired");
|
|
2371
|
+
continue;
|
|
2372
|
+
}
|
|
2373
|
+
if (!options.isRuntimeAccessTokenActive) continue;
|
|
2374
|
+
let active;
|
|
2375
|
+
try {
|
|
2376
|
+
active = await options.isRuntimeAccessTokenActive(client.request.claims);
|
|
2377
|
+
} catch {
|
|
2378
|
+
continue;
|
|
2379
|
+
}
|
|
2380
|
+
if (active.active || resolverUnreachable(active.code)) continue;
|
|
2381
|
+
revoke(client, active.reason);
|
|
2382
|
+
}
|
|
2383
|
+
for (const tunnel of Array.from(hostTunnels.values())) {
|
|
2384
|
+
await recheckHostGeneration(tunnel);
|
|
2385
|
+
}
|
|
2386
|
+
await scheduleHibernatedRevocationCheck();
|
|
2387
|
+
};
|
|
3022
2388
|
const activeUserHostedClient = (channelId, socket) => {
|
|
3023
2389
|
for (const tunnel of hostTunnels.values()) {
|
|
3024
2390
|
const client = tunnel.channels.get(channelId);
|
|
3025
2391
|
if (client?.socket === socket) return { tunnel, client };
|
|
3026
2392
|
}
|
|
2393
|
+
return void 0;
|
|
3027
2394
|
};
|
|
3028
2395
|
const handleUserHostedClientMessage = async (channelId, socket, data) => {
|
|
3029
2396
|
const active = activeUserHostedClient(channelId, socket);
|
|
@@ -3037,45 +2404,61 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3037
2404
|
closeSocket(socket, 1011, "User-hosted workspace is offline");
|
|
3038
2405
|
return;
|
|
3039
2406
|
}
|
|
3040
|
-
const frame = socketFrame(data);
|
|
2407
|
+
const frame = await socketFrame(data);
|
|
3041
2408
|
if (!frame) return;
|
|
3042
2409
|
const sent = sendSocket(active.tunnel.socket, JSON.stringify({
|
|
3043
2410
|
type: "ws.frame",
|
|
3044
|
-
protocol:
|
|
2411
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3045
2412
|
channel_id: channelId,
|
|
3046
2413
|
...frame
|
|
3047
2414
|
}));
|
|
3048
2415
|
if (!sent) closeSocket(socket, 1011, "User-hosted tunnel unavailable");
|
|
3049
2416
|
};
|
|
2417
|
+
const applyRegistrationUpdate = async (tunnel, workspaceIds, token) => {
|
|
2418
|
+
const hostId = tunnel.hostId;
|
|
2419
|
+
const refuse = (code, reason) => {
|
|
2420
|
+
cleanupHostTunnel(hostId, tunnel.socket, reason);
|
|
2421
|
+
closeSocket(tunnel.socket, code, reason);
|
|
2422
|
+
};
|
|
2423
|
+
let claims;
|
|
2424
|
+
try {
|
|
2425
|
+
claims = await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
|
|
2426
|
+
} catch {
|
|
2427
|
+
if (hostTunnels.get(hostId) === tunnel) refuse(1008, "Host tunnel registration update denied");
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
if (hostTunnels.get(hostId) !== tunnel) return;
|
|
2431
|
+
if (hostTunnelIncumbentOutranks(tunnel.generation, claims.generation)) {
|
|
2432
|
+
refuse(1008, "Host tunnel registration update superseded");
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, claims);
|
|
2436
|
+
if (hostTunnels.get(hostId) !== tunnel) return;
|
|
2437
|
+
if (!decision.ok) {
|
|
2438
|
+
refuse(decision.retryable ? 1012 : 1008, decision.retryable ? "Host generation check unavailable" : decision.reason);
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
tunnel.workspaceIds = workspaceIds;
|
|
2442
|
+
tunnel.enrollmentId = claims.enrollment_id;
|
|
2443
|
+
tunnel.generation = claims.generation;
|
|
2444
|
+
tunnel.socket.serializeAttachment?.(hostTunnelAttachment(tunnel));
|
|
2445
|
+
directory.registerHostTunnel({ hostId, workspaceIds });
|
|
2446
|
+
watchHostGeneration(tunnel);
|
|
2447
|
+
void scheduleHibernatedRevocationCheck();
|
|
2448
|
+
};
|
|
3050
2449
|
const handleTunnelMessage = async (hostId, event) => {
|
|
3051
2450
|
const row = parseTunnelMessageData(event.data);
|
|
3052
2451
|
if (!row.ok) return;
|
|
3053
|
-
const parsed =
|
|
2452
|
+
const parsed = validateTunnelMessage(row.value);
|
|
3054
2453
|
const tunnel = hostTunnels.get(hostId);
|
|
3055
2454
|
if (!parsed.ok || !tunnel) return;
|
|
3056
2455
|
directory.recordPong(tunnel.hostId);
|
|
3057
2456
|
if (parsed.message.type === "ping") {
|
|
3058
|
-
tunnel.socket.send?.(JSON.stringify(
|
|
2457
|
+
tunnel.socket.send?.(JSON.stringify(makeTunnelPong(parsed.message)));
|
|
3059
2458
|
return;
|
|
3060
2459
|
}
|
|
3061
2460
|
if (parsed.message.type === "host.registration.update") {
|
|
3062
|
-
|
|
3063
|
-
const workspaceIds = [...new Set(update.workspace_ids)];
|
|
3064
|
-
try {
|
|
3065
|
-
await verifyHostTunnelToken(update.token, options.runtimeAccessKey, { hostId, workspaceIds });
|
|
3066
|
-
} catch {
|
|
3067
|
-
closeSocket(tunnel.socket, 1008, "Host tunnel registration update denied");
|
|
3068
|
-
return;
|
|
3069
|
-
}
|
|
3070
|
-
if (hostTunnels.get(hostId) !== tunnel) return;
|
|
3071
|
-
tunnel.workspaceIds = workspaceIds;
|
|
3072
|
-
tunnel.socket.serializeAttachment?.({
|
|
3073
|
-
kind: "host-tunnel",
|
|
3074
|
-
hostId,
|
|
3075
|
-
workspaceIds,
|
|
3076
|
-
connectedAt: tunnel.connectedAt
|
|
3077
|
-
});
|
|
3078
|
-
directory.registerHostTunnel({ hostId, workspaceIds });
|
|
2461
|
+
await applyRegistrationUpdate(tunnel, [...new Set(parsed.message.workspace_ids)], parsed.message.token);
|
|
3079
2462
|
return;
|
|
3080
2463
|
}
|
|
3081
2464
|
if (parsed.message.type === "error" && parsed.message.request_id) {
|
|
@@ -3145,7 +2528,9 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3145
2528
|
closeSocket(channel.socket, 1008, token.reason);
|
|
3146
2529
|
return;
|
|
3147
2530
|
}
|
|
3148
|
-
sendSocket(channel.socket, tunnelFrame(message))
|
|
2531
|
+
if (!sendSocket(channel.socket, tunnelFrame(message))) {
|
|
2532
|
+
dropUserHostedChannel(tunnel, message.channel_id, channel, 1013, "Client WebSocket delivery failed");
|
|
2533
|
+
}
|
|
3149
2534
|
return;
|
|
3150
2535
|
}
|
|
3151
2536
|
if (parsed.message.type === "ws.close") {
|
|
@@ -3154,7 +2539,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3154
2539
|
if (!channel) return;
|
|
3155
2540
|
tunnel.channels.delete(message.channel_id);
|
|
3156
2541
|
clients.delete(channel);
|
|
3157
|
-
closeSocket(channel.socket,
|
|
2542
|
+
closeSocket(channel.socket, safeCloseCode(message.code), message.reason ?? "");
|
|
3158
2543
|
return;
|
|
3159
2544
|
}
|
|
3160
2545
|
if (parsed.message.type === "error" && parsed.message.channel_id) {
|
|
@@ -3190,17 +2575,17 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3190
2575
|
clients.delete(active);
|
|
3191
2576
|
sendSocket(tunnel.socket, JSON.stringify({
|
|
3192
2577
|
type: "ws.close",
|
|
3193
|
-
protocol:
|
|
2578
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3194
2579
|
channel_id: channelId,
|
|
3195
|
-
code:
|
|
2580
|
+
code: safeCloseCode(event?.code, 1e3),
|
|
3196
2581
|
reason: event?.reason ?? "Client WebSocket closed"
|
|
3197
2582
|
}));
|
|
3198
2583
|
return;
|
|
3199
2584
|
}
|
|
3200
2585
|
};
|
|
3201
2586
|
const admitHostTunnel = async (request, roomWorkspaceId, url) => {
|
|
3202
|
-
if (!
|
|
3203
|
-
const hostId =
|
|
2587
|
+
if (!websocketRequest(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
|
|
2588
|
+
const hostId = trimToUndefined(hostIdFromTunnelPath(url.pathname));
|
|
3204
2589
|
if (!hostId) return json("host_tunnel_host_required", "Host tunnel id is required", 400);
|
|
3205
2590
|
const workspaceIds = workspaceIdsFromSearch(url);
|
|
3206
2591
|
if (!workspaceIds.includes(roomWorkspaceId)) {
|
|
@@ -3208,38 +2593,49 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3208
2593
|
}
|
|
3209
2594
|
const token = bearerToken(request.headers.get("authorization"));
|
|
3210
2595
|
if (!token) return json("host_tunnel_token_required", "Host Tunnel Token is required", 401);
|
|
2596
|
+
let claims;
|
|
3211
2597
|
try {
|
|
3212
|
-
await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
|
|
2598
|
+
claims = await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
|
|
3213
2599
|
} catch (err) {
|
|
3214
2600
|
if (err instanceof WorkspaceRelayAuthError) {
|
|
3215
2601
|
return json(err.code, "Host Tunnel Token was denied", 403);
|
|
3216
2602
|
}
|
|
3217
2603
|
throw err;
|
|
3218
2604
|
}
|
|
3219
|
-
const
|
|
3220
|
-
|
|
3221
|
-
kind: "host-tunnel",
|
|
3222
|
-
hostId,
|
|
3223
|
-
workspaceIds,
|
|
3224
|
-
connectedAt
|
|
3225
|
-
});
|
|
2605
|
+
const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, claims);
|
|
2606
|
+
if (!decision.ok) return json(decision.code, decision.reason, decision.retryable ? 503 : 403);
|
|
3226
2607
|
const previous = hostTunnels.get(hostId);
|
|
3227
|
-
if (previous) {
|
|
3228
|
-
|
|
3229
|
-
closeSocket(previous.socket, 1012, "Host tunnel replaced");
|
|
2608
|
+
if (previous && hostTunnelIncumbentOutranks(previous.generation, claims.generation)) {
|
|
2609
|
+
return json("host_generation_superseded", "Host tunnel generation was superseded", 403);
|
|
3230
2610
|
}
|
|
3231
|
-
|
|
2611
|
+
const connectedAt = options.now?.() ?? Date.now();
|
|
2612
|
+
const fence = {
|
|
2613
|
+
...claims.enrollment_id ? { enrollmentId: claims.enrollment_id } : {},
|
|
2614
|
+
...claims.generation !== void 0 ? { generation: claims.generation } : {}
|
|
2615
|
+
};
|
|
2616
|
+
const pair = acceptSocket({ kind: "host-tunnel", hostId, workspaceIds, connectedAt, ...fence });
|
|
2617
|
+
const tunnel = {
|
|
3232
2618
|
hostId,
|
|
3233
2619
|
workspaceIds,
|
|
3234
2620
|
connectedAt,
|
|
2621
|
+
...fence,
|
|
2622
|
+
generationCheckFailures: 0,
|
|
3235
2623
|
socket: pair.server,
|
|
3236
2624
|
pending: /* @__PURE__ */ new Map(),
|
|
3237
2625
|
channels: /* @__PURE__ */ new Map()
|
|
3238
|
-
}
|
|
2626
|
+
};
|
|
2627
|
+
if (previous) {
|
|
2628
|
+
cleanupTunnelResources(previous, "Host tunnel replaced");
|
|
2629
|
+
closeSocket(previous.socket, 1012, "Host tunnel replaced");
|
|
2630
|
+
}
|
|
2631
|
+
hostTunnels.set(hostId, tunnel);
|
|
3239
2632
|
directory.registerHostTunnel({ hostId, workspaceIds });
|
|
2633
|
+
watchHostGeneration(tunnel);
|
|
2634
|
+
void scheduleHibernatedRevocationCheck();
|
|
3240
2635
|
if (!hibernation) {
|
|
3241
2636
|
pair.server.addEventListener?.("message", (event) => {
|
|
3242
|
-
if (event
|
|
2637
|
+
if (!event || event.data === void 0) return;
|
|
2638
|
+
void handleTunnelMessage(hostId, { data: event.data }).catch((err) => reportFrameHandlerFailure(pair.server, err));
|
|
3243
2639
|
});
|
|
3244
2640
|
pair.server.addEventListener?.("close", () => cleanupHostTunnel(hostId, pair.server, "User-hosted tunnel disconnected"));
|
|
3245
2641
|
pair.server.addEventListener?.("error", () => cleanupHostTunnel(hostId, pair.server, "User-hosted tunnel disconnected"));
|
|
@@ -3247,14 +2643,14 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3247
2643
|
return upgradeResponse(pair.client, request);
|
|
3248
2644
|
};
|
|
3249
2645
|
const admitClient = async (request, workspaceId) => {
|
|
3250
|
-
if (!
|
|
2646
|
+
if (!websocketRequest(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
|
|
3251
2647
|
const authorized = await authorizeWorkspaceRelayRequest(relayOptions, request, workspaceId);
|
|
3252
2648
|
if (!authorized.ok) return authorized.response;
|
|
3253
|
-
if (authorized.request.target
|
|
2649
|
+
if (isUserHostedTarget(authorized.request.target)) return admitUserHostedClient(request, authorized.request);
|
|
3254
2650
|
return await admitCloudClient(request, authorized.request);
|
|
3255
2651
|
};
|
|
3256
2652
|
const admitCloudClient = async (request, authorized) => {
|
|
3257
|
-
const trace =
|
|
2653
|
+
const trace = relayWebSocketTraceEnabled(request) ? { upstreamStartedAt: performance.now(), queueTimes: [], emitted: false } : void 0;
|
|
3258
2654
|
let upstream;
|
|
3259
2655
|
try {
|
|
3260
2656
|
upstream = await connectWebSocket(
|
|
@@ -3282,14 +2678,15 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3282
2678
|
let closed = false;
|
|
3283
2679
|
let upstreamOpen = upstream.readyState === void 0 || upstream.readyState === 1;
|
|
3284
2680
|
const queue = [];
|
|
2681
|
+
let queuedBytes = 0;
|
|
3285
2682
|
const emitTrace = (openedAt) => {
|
|
3286
2683
|
if (!trace || trace.emitted) return;
|
|
3287
2684
|
trace.emitted = true;
|
|
3288
2685
|
sendSocket(pair.server, JSON.stringify({
|
|
3289
2686
|
type: "relay.trace",
|
|
3290
|
-
wsUpstreamOpenMs:
|
|
2687
|
+
wsUpstreamOpenMs: roundedMs2(openedAt - trace.upstreamStartedAt),
|
|
3291
2688
|
queuedFrames: trace.queueTimes.length,
|
|
3292
|
-
maxQueuedDelayMs:
|
|
2689
|
+
maxQueuedDelayMs: roundedMs2(trace.queueTimes.reduce((max, at) => Math.max(max, openedAt - at), 0))
|
|
3293
2690
|
}));
|
|
3294
2691
|
};
|
|
3295
2692
|
const activeTokenTimer = watchRuntimeAccessToken(client, (reason) => {
|
|
@@ -3300,18 +2697,19 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3300
2697
|
closed = true;
|
|
3301
2698
|
if (activeTokenTimer) clearInterval(activeTokenTimer);
|
|
3302
2699
|
clients.delete(client);
|
|
3303
|
-
closeSocket(upstream,
|
|
2700
|
+
closeSocket(upstream, safeCloseCode(event?.code, 1e3), event?.reason ?? "Client WebSocket closed");
|
|
3304
2701
|
};
|
|
3305
2702
|
const cleanupUpstream = (event) => {
|
|
3306
2703
|
if (closed) return;
|
|
3307
2704
|
closed = true;
|
|
3308
2705
|
if (activeTokenTimer) clearInterval(activeTokenTimer);
|
|
3309
2706
|
clients.delete(client);
|
|
3310
|
-
closeSocket(pair.server,
|
|
2707
|
+
closeSocket(pair.server, safeCloseCode(event?.code), event?.reason ?? "");
|
|
3311
2708
|
};
|
|
3312
2709
|
const flushQueue = () => {
|
|
3313
2710
|
upstreamOpen = true;
|
|
3314
2711
|
if (trace) emitTrace(performance.now());
|
|
2712
|
+
queuedBytes = 0;
|
|
3315
2713
|
for (const item of queue.splice(0)) {
|
|
3316
2714
|
if (!sendSocket(upstream, item)) {
|
|
3317
2715
|
closeSocket(pair.server, 1011, "Upstream WebSocket connection failed");
|
|
@@ -3319,28 +2717,31 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3319
2717
|
}
|
|
3320
2718
|
}
|
|
3321
2719
|
};
|
|
3322
|
-
|
|
3323
|
-
const payload = socketPayload(event?.data);
|
|
3324
|
-
if (payload === void 0) return;
|
|
2720
|
+
const deliverClientFrame = orderedFrameDelivery((payload) => {
|
|
3325
2721
|
if (!upstreamOpen) {
|
|
3326
|
-
|
|
2722
|
+
const frameBytes = preOpenFrameBytes(payload);
|
|
2723
|
+
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)) {
|
|
3327
2724
|
closeSocket(pair.server, 1011, "Upstream WebSocket queue limit exceeded");
|
|
3328
2725
|
cleanupClient({ code: 1011, reason: "Upstream WebSocket queue limit exceeded" });
|
|
3329
2726
|
return;
|
|
3330
2727
|
}
|
|
2728
|
+
queuedBytes += frameBytes;
|
|
3331
2729
|
if (trace) trace.queueTimes.push(performance.now());
|
|
3332
2730
|
queue.push(payload);
|
|
3333
2731
|
return;
|
|
3334
2732
|
}
|
|
3335
2733
|
if (!sendSocket(upstream, payload)) closeSocket(pair.server, 1011, "Upstream WebSocket connection failed");
|
|
3336
2734
|
});
|
|
2735
|
+
pair.server.addEventListener?.("message", (event) => deliverClientFrame(event?.data));
|
|
3337
2736
|
pair.server.addEventListener?.("close", cleanupClient);
|
|
3338
2737
|
pair.server.addEventListener?.("error", cleanupClient);
|
|
3339
2738
|
upstream.addEventListener?.("open", flushQueue);
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
2739
|
+
const deliverUpstreamFrame = orderedFrameDelivery((payload) => {
|
|
2740
|
+
if (!sendSocket(pair.server, payload)) {
|
|
2741
|
+
closeSocket(pair.server, 1011, "Client WebSocket send failed");
|
|
2742
|
+
}
|
|
3343
2743
|
});
|
|
2744
|
+
upstream.addEventListener?.("message", (event) => deliverUpstreamFrame(event?.data));
|
|
3344
2745
|
upstream.addEventListener?.("close", cleanupUpstream);
|
|
3345
2746
|
upstream.addEventListener?.("error", () => cleanupUpstream({ code: 1011, reason: "Upstream WebSocket connection failed" }));
|
|
3346
2747
|
if (trace && upstreamOpen) emitTrace(upstreamConnectedAt);
|
|
@@ -3376,6 +2777,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3376
2777
|
const activeTargetTimer = hibernation ? void 0 : watchUserHostedTarget(client, (reason) => {
|
|
3377
2778
|
closeSocket(pair.server, 1011, reason);
|
|
3378
2779
|
});
|
|
2780
|
+
if (hibernation) void scheduleHibernatedRevocationCheck();
|
|
3379
2781
|
if (!hibernation) {
|
|
3380
2782
|
const cleanup = (event) => {
|
|
3381
2783
|
if (activeTokenTimer) clearInterval(activeTokenTimer);
|
|
@@ -3384,14 +2786,14 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3384
2786
|
};
|
|
3385
2787
|
pair.server.addEventListener?.("message", (event) => {
|
|
3386
2788
|
if (!event) return;
|
|
3387
|
-
void handleUserHostedClientMessage(channelId, pair.server, event.data);
|
|
2789
|
+
void handleUserHostedClientMessage(channelId, pair.server, event.data).catch((err) => reportFrameHandlerFailure(pair.server, err));
|
|
3388
2790
|
});
|
|
3389
2791
|
pair.server.addEventListener?.("close", cleanup);
|
|
3390
2792
|
pair.server.addEventListener?.("error", cleanup);
|
|
3391
2793
|
}
|
|
3392
|
-
sendSocket(tunnel.socket, JSON.stringify({
|
|
2794
|
+
const opened = sendSocket(tunnel.socket, JSON.stringify({
|
|
3393
2795
|
type: "ws.open",
|
|
3394
|
-
protocol:
|
|
2796
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3395
2797
|
channel_id: channelId,
|
|
3396
2798
|
workspace_id: authorized.target.workspaceId,
|
|
3397
2799
|
path: authorized.path,
|
|
@@ -3402,6 +2804,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3402
2804
|
{ userHosted: true }
|
|
3403
2805
|
))
|
|
3404
2806
|
}));
|
|
2807
|
+
if (!opened) {
|
|
2808
|
+
tunnel.channels.delete(channelId);
|
|
2809
|
+
clients.delete(client);
|
|
2810
|
+
closeSocket(pair.server, 1011, "User-hosted tunnel unavailable");
|
|
2811
|
+
return json("user_hosted_app_offline", "User-hosted workspace is offline", 503);
|
|
2812
|
+
}
|
|
3405
2813
|
return upgradeResponse(pair.client, request);
|
|
3406
2814
|
};
|
|
3407
2815
|
const forwardCloudHttp = async (request, workspaceId, trace) => {
|
|
@@ -3413,7 +2821,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3413
2821
|
})
|
|
3414
2822
|
);
|
|
3415
2823
|
if (!authorized.ok) return authorized.response;
|
|
3416
|
-
if (authorized.request.target
|
|
2824
|
+
if (isUserHostedTarget(authorized.request.target)) {
|
|
3417
2825
|
trace && (trace.routeKind = "user-hosted-http");
|
|
3418
2826
|
return forwardUserHostedHttp(request, authorized.request, trace);
|
|
3419
2827
|
}
|
|
@@ -3463,7 +2871,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3463
2871
|
const init = workspaceRelayForwardRequestInit(request, authorized.relayHostToken, authorized.target.workspaceId, {
|
|
3464
2872
|
userHosted: true
|
|
3465
2873
|
});
|
|
3466
|
-
const bodyMaxBytes = options.tunnelRequestBodyMaxBytes ??
|
|
2874
|
+
const bodyMaxBytes = options.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT;
|
|
3467
2875
|
const contentLength = Number(request.headers.get("content-length") ?? Number.NaN);
|
|
3468
2876
|
if (Number.isFinite(contentLength) && contentLength > bodyMaxBytes) {
|
|
3469
2877
|
return json("request_body_too_large", "User-hosted request body exceeds the relay limit", 413);
|
|
@@ -3478,7 +2886,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3478
2886
|
tunnel.pending.delete(requestId);
|
|
3479
2887
|
tunnel.socket.send?.(JSON.stringify({
|
|
3480
2888
|
type: "http.response.flow",
|
|
3481
|
-
protocol:
|
|
2889
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3482
2890
|
request_id: requestId,
|
|
3483
2891
|
paused: false,
|
|
3484
2892
|
reason: "closed"
|
|
@@ -3495,7 +2903,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3495
2903
|
});
|
|
3496
2904
|
tunnel.socket.send?.(JSON.stringify({
|
|
3497
2905
|
type: "http.request",
|
|
3498
|
-
protocol:
|
|
2906
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3499
2907
|
request_id: requestId,
|
|
3500
2908
|
workspace_id: authorized.target.workspaceId,
|
|
3501
2909
|
method: request.method,
|
|
@@ -3542,7 +2950,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3542
2950
|
if (!roomWorkspaceId) return json("relay_workspace_required", "Workspace Relay requests must include a workspace id", 400);
|
|
3543
2951
|
if (url.pathname.startsWith("/host-tunnels/")) return admitHostTunnel(request, roomWorkspaceId, url);
|
|
3544
2952
|
if (url.pathname.startsWith("/workspaces/")) {
|
|
3545
|
-
if (
|
|
2953
|
+
if (websocketRequest(request)) return admitClient(request, roomWorkspaceId);
|
|
3546
2954
|
const trace = traceRequest(request, "workspace-http", options);
|
|
3547
2955
|
const roomTotalStartedAt = performance.now();
|
|
3548
2956
|
const nextRequest = tracedRequest(request, trace);
|
|
@@ -3555,14 +2963,29 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3555
2963
|
return json("relay_route_not_found", "Workspace Relay route not found", 404);
|
|
3556
2964
|
},
|
|
3557
2965
|
async webSocketMessage(socket, message) {
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
2966
|
+
try {
|
|
2967
|
+
ensureHibernatedSocketsRebuilt();
|
|
2968
|
+
const attachment = socketAttachment(socket);
|
|
2969
|
+
if (!attachment) return;
|
|
2970
|
+
if (attachment.kind === "host-tunnel") {
|
|
2971
|
+
await handleTunnelMessage(attachment.hostId, { data: message });
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
await handleUserHostedClientMessage(attachment.channelId, socket, message);
|
|
2975
|
+
} catch (err) {
|
|
2976
|
+
reportFrameHandlerFailure(socket, err);
|
|
2977
|
+
}
|
|
2978
|
+
},
|
|
2979
|
+
/**
|
|
2980
|
+
* Durable Object alarm entry point. Runs the hibernation-safe revocation
|
|
2981
|
+
* sweep. The Worker must forward `alarm()` here.
|
|
2982
|
+
*/
|
|
2983
|
+
async alarm() {
|
|
2984
|
+
try {
|
|
2985
|
+
await runHibernatedRevocationCheck();
|
|
2986
|
+
} catch (err) {
|
|
2987
|
+
console.error(`[workspace-relay] revocation alarm failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3564
2988
|
}
|
|
3565
|
-
await handleUserHostedClientMessage(attachment.channelId, socket, message);
|
|
3566
2989
|
},
|
|
3567
2990
|
webSocketClose(socket, code, reason) {
|
|
3568
2991
|
ensureHibernatedSocketsRebuilt();
|
|
@@ -3599,30 +3022,47 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3599
3022
|
export {
|
|
3600
3023
|
DEFAULT_RELAY_APP_ORIGINS,
|
|
3601
3024
|
DEFAULT_RELAY_LOCATION_HINT,
|
|
3025
|
+
RELAY_ALLOWED_REQUEST_HEADERS,
|
|
3026
|
+
RELAY_ALLOWED_REQUEST_HEADER_LIST,
|
|
3027
|
+
RELAY_LOCATION_HINTS,
|
|
3028
|
+
RELAY_REGION_HEADER,
|
|
3602
3029
|
WorkspaceRelayAuthError,
|
|
3603
|
-
__slowConsumerInternalsForTest,
|
|
3604
3030
|
authorizeWorkspaceRelayRequest,
|
|
3031
|
+
bytesToBase64,
|
|
3032
|
+
checkHostTunnelGeneration,
|
|
3033
|
+
createCachedHostGenerationClient,
|
|
3605
3034
|
createCachedRevocationClient,
|
|
3606
3035
|
createCachedTargetClient,
|
|
3036
|
+
createHostGenerationResolverLookup,
|
|
3607
3037
|
createOriginMatcher,
|
|
3608
3038
|
createWorkspaceRelay,
|
|
3609
|
-
createWorkspaceRelayBun,
|
|
3610
3039
|
createWorkspaceRelayDirectory,
|
|
3611
3040
|
createWorkspaceRelayDurableObjectGateway,
|
|
3612
3041
|
createWorkspaceRelayDurableObjectRoom,
|
|
3613
3042
|
createWorkspaceRelayTrace,
|
|
3043
|
+
deriveRelayHostKid,
|
|
3044
|
+
deriveRelayHostPublicKey,
|
|
3614
3045
|
disposeAuditSampler,
|
|
3615
3046
|
disposeWorkspaceRelayDirectory,
|
|
3616
3047
|
forwardWorkspaceRelayRequest,
|
|
3617
3048
|
getAuditSuppressedCount,
|
|
3049
|
+
hostTunnelIncumbentOutranks,
|
|
3618
3050
|
hostTunnelTokenAudience,
|
|
3051
|
+
isHostGeneration,
|
|
3619
3052
|
isRelayClaimPair,
|
|
3620
3053
|
mintHostTunnelToken,
|
|
3621
3054
|
mintRelayHostToken,
|
|
3622
3055
|
mintRuntimeAccessToken,
|
|
3623
3056
|
parseAllowedOrigins,
|
|
3057
|
+
parseHostGenerationResult,
|
|
3058
|
+
parseRuntimeAccessTokenActiveResult,
|
|
3059
|
+
parseWorkspaceRelayTarget,
|
|
3060
|
+
relayFrameDiagnostics,
|
|
3624
3061
|
relayHostTokenAudience,
|
|
3625
3062
|
relayHostTokenIssuer,
|
|
3063
|
+
relayLocationHint,
|
|
3064
|
+
relayRequestCountry,
|
|
3065
|
+
relayRequestRegion,
|
|
3626
3066
|
runtimeAccessTokenAudience,
|
|
3627
3067
|
runtimeAccessTokenIssuer,
|
|
3628
3068
|
setWorkspaceRelayAllowedOrigins,
|