@claxedo/workspace-relay 0.7.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 +90 -35
- package/dist/bun.mjs +2492 -0
- package/dist/cloudflare.d.ts +14 -1
- 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 +625 -1455
- package/dist/main.d.ts +36 -27
- package/dist/main.mjs +765 -271
- 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 +30 -2
- package/dist/workerd-fixture/boot.d.ts +18 -0
- package/package.json +15 -7
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,7 +326,7 @@ 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 = [
|
|
@@ -273,22 +368,76 @@ function createOriginMatcher(patterns) {
|
|
|
273
368
|
};
|
|
274
369
|
}
|
|
275
370
|
|
|
276
|
-
// src/
|
|
277
|
-
function
|
|
278
|
-
|
|
279
|
-
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
280
|
-
return match?.[1]?.trim() || void 0;
|
|
371
|
+
// src/user-hosted-forwarding.ts
|
|
372
|
+
function isUserHostedTarget(target) {
|
|
373
|
+
return target.access === "user-hosted";
|
|
281
374
|
}
|
|
282
|
-
|
|
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;
|
|
283
385
|
return {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
386
|
+
workspaceId,
|
|
387
|
+
hostId,
|
|
388
|
+
baseUrl,
|
|
389
|
+
...upstreamHeaders ? { upstreamHeaders } : {},
|
|
390
|
+
...pair
|
|
288
391
|
};
|
|
289
392
|
}
|
|
290
|
-
|
|
291
|
-
|
|
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
|
+
}
|
|
292
441
|
var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
|
|
293
442
|
var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
|
|
294
443
|
var RESOLVER_CACHE_MAX_ENTRIES = 8192;
|
|
@@ -328,6 +477,71 @@ function createCachedRevocationClient(inner, options = {}) {
|
|
|
328
477
|
return await promise;
|
|
329
478
|
};
|
|
330
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
|
+
}
|
|
331
545
|
function createCachedTargetClient(inner, options = {}) {
|
|
332
546
|
const ttlMs = options.ttlMs ?? 5e3;
|
|
333
547
|
const now = options.now ?? Date.now;
|
|
@@ -389,27 +603,27 @@ function isDangerousInboundHeader(name) {
|
|
|
389
603
|
return false;
|
|
390
604
|
}
|
|
391
605
|
function forwardHeaders(input, relayHostToken, workspaceId, options = {}) {
|
|
392
|
-
const
|
|
393
|
-
|
|
606
|
+
const headers = new Headers(input);
|
|
607
|
+
headers.delete("host");
|
|
394
608
|
const inboundNames = /* @__PURE__ */ new Set();
|
|
395
|
-
|
|
609
|
+
headers.forEach((_value, name) => inboundNames.add(name));
|
|
396
610
|
for (const name of inboundNames) {
|
|
397
|
-
if (isDangerousInboundHeader(name))
|
|
398
|
-
}
|
|
399
|
-
if (options.userHosted)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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");
|
|
407
621
|
for (const [name, value] of Object.entries(options.upstreamHeaders ?? {})) {
|
|
408
622
|
const trimmed = value.trim();
|
|
409
|
-
if (trimmed)
|
|
623
|
+
if (trimmed) headers.set(name, trimmed);
|
|
410
624
|
}
|
|
411
|
-
|
|
412
|
-
return
|
|
625
|
+
headers.set("x-forwarded-by", "workspace-relay");
|
|
626
|
+
return headers;
|
|
413
627
|
}
|
|
414
628
|
function targetUrl(target, path, search) {
|
|
415
629
|
const url = new URL(path.replace(/^\/+/, ""), target.baseUrl.endsWith("/") ? target.baseUrl : `${target.baseUrl}/`);
|
|
@@ -418,11 +632,9 @@ function targetUrl(target, path, search) {
|
|
|
418
632
|
}
|
|
419
633
|
var RELAY_VIEWER_DENIED_PATH = /^\/api\/wr\/pty(?:\/|$)/;
|
|
420
634
|
function relayRuntimePath(request) {
|
|
421
|
-
return new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/");
|
|
422
|
-
}
|
|
423
|
-
function relayAuthorizationPath(path) {
|
|
424
635
|
try {
|
|
425
|
-
|
|
636
|
+
const decoded = decodeURIComponent(new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/")).replace(/^\/+/, "/");
|
|
637
|
+
return new URL(decoded.replace(/^\/+/, ""), "http://relay.invalid/").pathname;
|
|
426
638
|
} catch {
|
|
427
639
|
return void 0;
|
|
428
640
|
}
|
|
@@ -430,8 +642,7 @@ function relayAuthorizationPath(path) {
|
|
|
430
642
|
function roleAllowsRelayRequest(role, method, path) {
|
|
431
643
|
if (role === "owner" || role === "admin" || role === "editor") return true;
|
|
432
644
|
if (role === "viewer") {
|
|
433
|
-
|
|
434
|
-
if (!authorizationPath || RELAY_VIEWER_DENIED_PATH.test(authorizationPath)) return false;
|
|
645
|
+
if (RELAY_VIEWER_DENIED_PATH.test(path)) return false;
|
|
435
646
|
return method === "GET" || method === "HEAD" || method === "OPTIONS";
|
|
436
647
|
}
|
|
437
648
|
return false;
|
|
@@ -445,7 +656,9 @@ function relayHostTokenCacheTtlMs(options, claims) {
|
|
|
445
656
|
function relayHostTokenCacheKey(options, claims, target) {
|
|
446
657
|
return [
|
|
447
658
|
claims.jti,
|
|
448
|
-
claims.
|
|
659
|
+
claims.principal_kind,
|
|
660
|
+
claims.actor_id,
|
|
661
|
+
claims.actor_kind,
|
|
449
662
|
claims.org_id,
|
|
450
663
|
claims.role,
|
|
451
664
|
claims.workspace_id,
|
|
@@ -550,7 +763,15 @@ async function relayHostTokenFor(options, claims, target, trace) {
|
|
|
550
763
|
try {
|
|
551
764
|
const token = await trace.span("rht-mint", async () => {
|
|
552
765
|
promise = mintRelayHostToken({
|
|
553
|
-
|
|
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
|
+
} : {},
|
|
554
775
|
orgId: claims.org_id,
|
|
555
776
|
role: claims.role,
|
|
556
777
|
...target,
|
|
@@ -574,7 +795,15 @@ async function uncachedRelayHostTokenFor(options, claims, target, trace) {
|
|
|
574
795
|
return await trace.span(
|
|
575
796
|
"rht-mint",
|
|
576
797
|
async () => await mintRelayHostToken({
|
|
577
|
-
|
|
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
|
+
} : {},
|
|
578
807
|
orgId: claims.org_id,
|
|
579
808
|
role: claims.role,
|
|
580
809
|
...target,
|
|
@@ -680,13 +909,13 @@ function workspaceRelayServerTiming(trace) {
|
|
|
680
909
|
function workspaceRelayTimingResponse(response, trace) {
|
|
681
910
|
const value = workspaceRelayServerTiming(trace);
|
|
682
911
|
if (!value) return response;
|
|
683
|
-
const
|
|
684
|
-
const prev =
|
|
685
|
-
|
|
912
|
+
const headers = new Headers(response.headers);
|
|
913
|
+
const prev = headers.get("server-timing");
|
|
914
|
+
headers.set("server-timing", prev ? `${prev}, ${value}` : value);
|
|
686
915
|
return new Response(response.body, {
|
|
687
916
|
status: response.status,
|
|
688
917
|
statusText: response.statusText,
|
|
689
|
-
headers
|
|
918
|
+
headers
|
|
690
919
|
});
|
|
691
920
|
}
|
|
692
921
|
async function audit(options, event) {
|
|
@@ -713,13 +942,30 @@ function originMatcherFor(options) {
|
|
|
713
942
|
}
|
|
714
943
|
return matcher;
|
|
715
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(", ");
|
|
716
962
|
function denyCorsHeaders(request, originAllowed = defaultOriginMatcher) {
|
|
717
963
|
const origin = request.headers.get("origin");
|
|
718
964
|
if (!origin) return void 0;
|
|
719
965
|
if (!originAllowed(origin)) return void 0;
|
|
720
966
|
return {
|
|
721
967
|
"access-control-allow-origin": origin,
|
|
722
|
-
"access-control-allow-headers":
|
|
968
|
+
"access-control-allow-headers": RELAY_ALLOWED_REQUEST_HEADERS,
|
|
723
969
|
"access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"
|
|
724
970
|
};
|
|
725
971
|
}
|
|
@@ -729,7 +975,9 @@ async function deny(options, input) {
|
|
|
729
975
|
action: "relay.request.denied",
|
|
730
976
|
result: "deny",
|
|
731
977
|
reason: input.code,
|
|
732
|
-
|
|
978
|
+
actorId: input.claims?.actor_id,
|
|
979
|
+
actorKind: input.claims?.actor_kind,
|
|
980
|
+
principalKind: input.claims?.principal_kind,
|
|
733
981
|
...input.claims ? { role: input.claims.role } : {},
|
|
734
982
|
workspaceId: input.claims?.workspace_id,
|
|
735
983
|
hostId: input.claims?.host_id,
|
|
@@ -778,7 +1026,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
778
1026
|
};
|
|
779
1027
|
}
|
|
780
1028
|
const path = relayRuntimePath(request);
|
|
781
|
-
if (!roleAllowsRelayRequest(claims.role, request.method, path)) {
|
|
1029
|
+
if (!path || !roleAllowsRelayRequest(claims.role, request.method, path)) {
|
|
782
1030
|
return {
|
|
783
1031
|
ok: false,
|
|
784
1032
|
code: "relay_role_denied",
|
|
@@ -805,7 +1053,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
805
1053
|
})
|
|
806
1054
|
};
|
|
807
1055
|
}
|
|
808
|
-
if (target
|
|
1056
|
+
if (isUserHostedTarget(target) && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
|
|
809
1057
|
return {
|
|
810
1058
|
ok: false,
|
|
811
1059
|
code: "user_hosted_app_offline",
|
|
@@ -821,7 +1069,9 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
821
1069
|
await span("audit", async () => await audit(options, {
|
|
822
1070
|
action: "relay.request.accepted",
|
|
823
1071
|
result: "allow",
|
|
824
|
-
|
|
1072
|
+
actorId: claims.actor_id,
|
|
1073
|
+
actorKind: claims.actor_kind,
|
|
1074
|
+
principalKind: claims.principal_kind,
|
|
825
1075
|
role: claims.role,
|
|
826
1076
|
workspaceId: claims.workspace_id,
|
|
827
1077
|
hostId: claims.host_id,
|
|
@@ -864,20 +1114,20 @@ async function forwardWorkspaceRelayRequest(request, target, relayHostToken, pat
|
|
|
864
1114
|
upstreamHeaders: target.upstreamHeaders
|
|
865
1115
|
})
|
|
866
1116
|
));
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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");
|
|
874
1124
|
for (const [key, value] of Object.entries(denyCorsHeaders(request, originAllowed) ?? {})) {
|
|
875
|
-
|
|
1125
|
+
headers.set(key, value);
|
|
876
1126
|
}
|
|
877
1127
|
return new Response(upstream.body, {
|
|
878
1128
|
status: upstream.status,
|
|
879
1129
|
statusText: upstream.statusText,
|
|
880
|
-
headers
|
|
1130
|
+
headers
|
|
881
1131
|
});
|
|
882
1132
|
} catch (err) {
|
|
883
1133
|
const aborted = err instanceof Error && err.name === "AbortError";
|
|
@@ -904,7 +1154,7 @@ function isLoopbackAddress(address) {
|
|
|
904
1154
|
if (!address) return false;
|
|
905
1155
|
const trimmed = address.trim();
|
|
906
1156
|
if (LOOPBACK_REMOTES.has(trimmed)) return true;
|
|
907
|
-
if (
|
|
1157
|
+
if (trimmed.startsWith("127.")) return true;
|
|
908
1158
|
if (/^::ffff:127\./i.test(trimmed)) return true;
|
|
909
1159
|
return false;
|
|
910
1160
|
}
|
|
@@ -960,25 +1210,26 @@ function buildMetricsBody(options) {
|
|
|
960
1210
|
return body;
|
|
961
1211
|
}
|
|
962
1212
|
function createWorkspaceRelay(options) {
|
|
963
|
-
const app = new Hono()
|
|
1213
|
+
const app = Object.assign(new Hono(), {
|
|
1214
|
+
disposeAuditSampler: () => disposeAuditSampler(options)
|
|
1215
|
+
});
|
|
964
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);
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
965
1224
|
app.use("*", cors({
|
|
966
1225
|
origin: (origin) => {
|
|
967
1226
|
if (!origin) return void 0;
|
|
968
1227
|
return originAllowed(origin) ? origin : void 0;
|
|
969
1228
|
},
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
"X-Daytona-Skip-Preview-Warning",
|
|
975
|
-
"X-Workspace-Id",
|
|
976
|
-
"X-OpenCode-Directory",
|
|
977
|
-
"X-Claxedo-Runner",
|
|
978
|
-
"X-Claxedo-Model",
|
|
979
|
-
"X-Claxedo-Draft-Id",
|
|
980
|
-
"X-Claxedo-Binary"
|
|
981
|
-
],
|
|
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],
|
|
982
1233
|
allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
983
1234
|
}));
|
|
984
1235
|
app.get("/health", (c) => {
|
|
@@ -1006,7 +1257,7 @@ function createWorkspaceRelay(options) {
|
|
|
1006
1257
|
let keys;
|
|
1007
1258
|
try {
|
|
1008
1259
|
keys = await Promise.all(sources.map(async (source) => {
|
|
1009
|
-
const jwk = await
|
|
1260
|
+
const jwk = await exportJWK2(source.publicKey);
|
|
1010
1261
|
return {
|
|
1011
1262
|
...jwk,
|
|
1012
1263
|
kid: source.kid,
|
|
@@ -1029,7 +1280,6 @@ function createWorkspaceRelay(options) {
|
|
|
1029
1280
|
c.header("cache-control", "public, max-age=300");
|
|
1030
1281
|
return c.json({ keys });
|
|
1031
1282
|
});
|
|
1032
|
-
app.disposeAuditSampler = () => disposeAuditSampler(options);
|
|
1033
1283
|
app.get("/metrics", (c) => {
|
|
1034
1284
|
const authResult = checkMetricsAuth(options, c.req.raw);
|
|
1035
1285
|
if (!authResult.ok) {
|
|
@@ -1067,1209 +1317,33 @@ function createWorkspaceRelay(options) {
|
|
|
1067
1317
|
return app;
|
|
1068
1318
|
}
|
|
1069
1319
|
|
|
1070
|
-
// src/bun.ts
|
|
1071
|
-
var TUNNEL_PENDING_HTTP_CAP = 32;
|
|
1072
|
-
var TUNNEL_CHANNEL_CAP = 16;
|
|
1073
|
-
var HOST_TUNNEL_REGISTRATION_RECONNECT_CAP = 5;
|
|
1074
|
-
var HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS = 6e4;
|
|
1075
|
-
var WS_MAX_PAYLOAD_LENGTH_BYTES = 16 * 1024 * 1024;
|
|
1076
|
-
var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
|
|
1077
|
-
var TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1078
|
-
var DIRECT_HTTP_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1079
|
-
var UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT = 1e4;
|
|
1080
|
-
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
|
|
1081
|
-
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1082
|
-
var WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1083
|
-
var relayBufferedBytes = (socket) => {
|
|
1084
|
-
const method = socket.getBufferedAmount;
|
|
1085
|
-
if (typeof method === "function") {
|
|
1086
|
-
const measured = method.call(socket);
|
|
1087
|
-
if (typeof measured === "number" && Number.isFinite(measured)) return measured;
|
|
1088
|
-
return void 0;
|
|
1089
|
-
}
|
|
1090
|
-
const property = socket.bufferedAmount;
|
|
1091
|
-
return typeof property === "number" && Number.isFinite(property) ? property : void 0;
|
|
1092
|
-
};
|
|
1093
|
-
var relayOverBackpressureLimit = (socket, limitBytes) => {
|
|
1094
|
-
const queued = relayBufferedBytes(socket);
|
|
1095
|
-
return queued !== void 0 && queued > limitBytes;
|
|
1096
|
-
};
|
|
1097
|
-
var HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT = 2;
|
|
1098
|
-
var TUNNEL_MESSAGE_BUFFER_CAP_BYTES = 4 * 1024 * 1024;
|
|
1099
|
-
var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1100
|
-
var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1101
|
-
var HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT = 250;
|
|
1102
|
-
function createFragmentationStats() {
|
|
1103
|
-
return {
|
|
1104
|
-
fragmentsBuffered: 0,
|
|
1105
|
-
oversizedClosed: 0
|
|
1106
|
-
};
|
|
1107
|
-
}
|
|
1108
|
-
function createSlowConsumerStats() {
|
|
1109
|
-
return {
|
|
1110
|
-
overflowEvents: 0,
|
|
1111
|
-
timerFired: 0,
|
|
1112
|
-
droppedRequests: 0
|
|
1113
|
-
};
|
|
1114
|
-
}
|
|
1115
|
-
function readFragmentationStats(stats) {
|
|
1116
|
-
return {
|
|
1117
|
-
fragmentsBuffered: stats.fragmentsBuffered,
|
|
1118
|
-
oversizedClosed: stats.oversizedClosed
|
|
1119
|
-
};
|
|
1120
|
-
}
|
|
1121
|
-
function resetFragmentationStats(stats) {
|
|
1122
|
-
stats.fragmentsBuffered = 0;
|
|
1123
|
-
stats.oversizedClosed = 0;
|
|
1124
|
-
}
|
|
1125
|
-
function readSlowConsumerStats(stats) {
|
|
1126
|
-
return { ...stats };
|
|
1127
|
-
}
|
|
1128
|
-
function resetSlowConsumerStats(stats) {
|
|
1129
|
-
stats.overflowEvents = 0;
|
|
1130
|
-
stats.timerFired = 0;
|
|
1131
|
-
stats.droppedRequests = 0;
|
|
1132
|
-
}
|
|
1133
|
-
function createDirectHttpLimiter(limit) {
|
|
1134
|
-
if (!Number.isInteger(limit) || !limit || limit <= 0) return void 0;
|
|
1135
|
-
const max = limit;
|
|
1136
|
-
let active = 0;
|
|
1137
|
-
const queue = [];
|
|
1138
|
-
function drain() {
|
|
1139
|
-
if (active >= max) return;
|
|
1140
|
-
const next = queue.shift();
|
|
1141
|
-
if (!next) return;
|
|
1142
|
-
active++;
|
|
1143
|
-
next();
|
|
1144
|
-
}
|
|
1145
|
-
return {
|
|
1146
|
-
async acquire() {
|
|
1147
|
-
if (active < max) {
|
|
1148
|
-
active++;
|
|
1149
|
-
} else {
|
|
1150
|
-
await new Promise((resolve) => queue.push(resolve));
|
|
1151
|
-
}
|
|
1152
|
-
let released = false;
|
|
1153
|
-
return () => {
|
|
1154
|
-
if (released) return;
|
|
1155
|
-
released = true;
|
|
1156
|
-
active = Math.max(0, active - 1);
|
|
1157
|
-
drain();
|
|
1158
|
-
};
|
|
1159
|
-
}
|
|
1160
|
-
};
|
|
1161
|
-
}
|
|
1162
|
-
function jsonError(code, message, status) {
|
|
1163
|
-
return new Response(JSON.stringify({ error: { code, message } }), {
|
|
1164
|
-
status,
|
|
1165
|
-
headers: { "content-type": "application/json" }
|
|
1166
|
-
});
|
|
1167
|
-
}
|
|
1168
|
-
function corsJsonError(request, code, message, status) {
|
|
1169
|
-
const headers2 = relayCorsHeaders(request);
|
|
1170
|
-
headers2.set("content-type", "application/json");
|
|
1171
|
-
return new Response(JSON.stringify({ error: { code, message } }), { status, headers: headers2 });
|
|
1172
|
-
}
|
|
1173
|
-
function preOpenFrameBytes(message) {
|
|
1174
|
-
return typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
1175
|
-
}
|
|
1176
|
-
function safeCloseCode(input, fallback = 1011) {
|
|
1177
|
-
if (!Number.isInteger(input)) return fallback;
|
|
1178
|
-
const code = input;
|
|
1179
|
-
if (code === 1e3) return code;
|
|
1180
|
-
if (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
|
|
1181
|
-
if (code >= 3e3 && code <= 4999) return code;
|
|
1182
|
-
return fallback;
|
|
1183
|
-
}
|
|
1184
|
-
function closeWebSocket(ws, code, reason, fallback = 1011) {
|
|
1185
|
-
try {
|
|
1186
|
-
ws.close(safeCloseCode(code, fallback), reason);
|
|
1187
|
-
} catch {
|
|
1188
|
-
try {
|
|
1189
|
-
ws.close(fallback, reason);
|
|
1190
|
-
} catch {
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
function upstreamCloseReason(event) {
|
|
1195
|
-
if (event.reason.trim()) return event.reason;
|
|
1196
|
-
if (event.code === 1e3) return "";
|
|
1197
|
-
if (event.code === 1005 || event.code === 1006) return "Upstream WebSocket closed abnormally";
|
|
1198
|
-
return `Upstream WebSocket closed unexpectedly with code ${event.code}`;
|
|
1199
|
-
}
|
|
1200
|
-
function workspaceIdFromPath(pathname) {
|
|
1201
|
-
return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
1202
|
-
}
|
|
1203
|
-
function hostIdFromTunnelPath(pathname) {
|
|
1204
|
-
return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
1205
|
-
}
|
|
1206
|
-
function websocketRequest(request) {
|
|
1207
|
-
return request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
1208
|
-
}
|
|
1209
|
-
function roundedMs2(input) {
|
|
1210
|
-
return Math.round(input * 100) / 100;
|
|
1211
|
-
}
|
|
1212
|
-
function relayWebSocketTraceEnabled(request) {
|
|
1213
|
-
return request.headers.get("x-claxedo-relay-ws-trace") === "1";
|
|
1214
|
-
}
|
|
1215
|
-
function sendRelayWebSocketTrace(ws) {
|
|
1216
|
-
const trace = ws.data.trace;
|
|
1217
|
-
if (!trace || trace.emitted) return;
|
|
1218
|
-
trace.emitted = true;
|
|
1219
|
-
ws.send(JSON.stringify({
|
|
1220
|
-
type: "relay.trace",
|
|
1221
|
-
wsUpstreamOpenMs: trace.upstreamOpenMs === void 0 ? void 0 : roundedMs2(trace.upstreamOpenMs),
|
|
1222
|
-
queuedFrames: trace.queuedFrames,
|
|
1223
|
-
maxQueuedDelayMs: roundedMs2(trace.maxQueuedDelayMs)
|
|
1224
|
-
}));
|
|
1225
|
-
}
|
|
1226
|
-
function relayWebSocketPayload(input) {
|
|
1227
|
-
if (typeof input === "string") return input;
|
|
1228
|
-
if (input instanceof ArrayBuffer) return input;
|
|
1229
|
-
if (ArrayBuffer.isView(input)) {
|
|
1230
|
-
const copy = new Uint8Array(input.byteLength);
|
|
1231
|
-
copy.set(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
|
|
1232
|
-
return copy;
|
|
1233
|
-
}
|
|
1234
|
-
}
|
|
1235
|
-
function headersRecord(headers2) {
|
|
1236
|
-
const result = {};
|
|
1237
|
-
headers2.forEach((value, key) => {
|
|
1238
|
-
result[key] = value;
|
|
1239
|
-
});
|
|
1240
|
-
return result;
|
|
1241
|
-
}
|
|
1242
|
-
function headers(input) {
|
|
1243
|
-
const result = new Headers();
|
|
1244
|
-
for (const [key, value] of Object.entries(input)) {
|
|
1245
|
-
result.set(key, value);
|
|
1246
|
-
}
|
|
1247
|
-
return result;
|
|
1248
|
-
}
|
|
1249
|
-
function isEventStream(input) {
|
|
1250
|
-
return Object.entries(input).some(
|
|
1251
|
-
([key, value]) => key.toLowerCase() === "content-type" && value.toLowerCase().includes("text/event-stream")
|
|
1252
|
-
);
|
|
1253
|
-
}
|
|
1254
|
-
var defaultRelayOriginMatcher = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
|
|
1255
|
-
function allowedCorsOrigin(origin) {
|
|
1256
|
-
if (!origin) return;
|
|
1257
|
-
if (defaultRelayOriginMatcher(origin)) return origin;
|
|
1258
|
-
}
|
|
1259
|
-
function requireAllowedOrigin(request) {
|
|
1260
|
-
const origin = request.headers.get("origin");
|
|
1261
|
-
if (allowedCorsOrigin(origin)) return null;
|
|
1262
|
-
return jsonError(
|
|
1263
|
-
"origin_not_allowed",
|
|
1264
|
-
"Origin is not in the allowlist",
|
|
1265
|
-
403
|
|
1266
|
-
);
|
|
1267
|
-
}
|
|
1268
|
-
function relayCorsHeaders(request, input = new Headers()) {
|
|
1269
|
-
const result = new Headers(input);
|
|
1270
|
-
result.delete("access-control-allow-origin");
|
|
1271
|
-
result.delete("access-control-allow-credentials");
|
|
1272
|
-
result.delete("access-control-allow-headers");
|
|
1273
|
-
result.delete("access-control-allow-methods");
|
|
1274
|
-
result.delete("access-control-expose-headers");
|
|
1275
|
-
result.delete("access-control-max-age");
|
|
1276
|
-
const origin = allowedCorsOrigin(request.headers.get("origin"));
|
|
1277
|
-
if (origin) {
|
|
1278
|
-
result.set("access-control-allow-origin", origin);
|
|
1279
|
-
result.set("access-control-allow-headers", "Accept, Authorization, Content-Type, Last-Event-ID, X-Fetch-Bypass-Throttle, X-Daytona-Skip-Preview-Warning, X-Workspace-Id, X-OpenCode-Directory, X-Claxedo-Runner, X-Claxedo-Model, X-Claxedo-Draft-Id, X-Claxedo-Binary");
|
|
1280
|
-
result.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
1281
|
-
}
|
|
1282
|
-
return result;
|
|
1283
|
-
}
|
|
1284
|
-
function encoded(input) {
|
|
1285
|
-
return Buffer.from(input).toString("base64");
|
|
1286
|
-
}
|
|
1287
|
-
function decoded(input) {
|
|
1288
|
-
return new Uint8Array(Buffer.from(input, "base64"));
|
|
1289
|
-
}
|
|
1290
|
-
function encodedFrame(message) {
|
|
1291
|
-
if (typeof message === "string") {
|
|
1292
|
-
return {
|
|
1293
|
-
binary: false,
|
|
1294
|
-
data_base64: Buffer.from(message).toString("base64")
|
|
1295
|
-
};
|
|
1296
|
-
}
|
|
1297
|
-
return {
|
|
1298
|
-
binary: true,
|
|
1299
|
-
data_base64: Buffer.from(message).toString("base64")
|
|
1300
|
-
};
|
|
1301
|
-
}
|
|
1302
|
-
function decodedFrame(frame) {
|
|
1303
|
-
const body = Buffer.from(frame.data_base64, "base64");
|
|
1304
|
-
return frame.binary ? new Uint8Array(body) : body.toString("utf8");
|
|
1305
|
-
}
|
|
1306
|
-
function tunnelMessage(ws, input, stats) {
|
|
1307
|
-
if (typeof input !== "string") return;
|
|
1308
|
-
const combined = ws.data.messageBuffer.length > 0 ? ws.data.messageBuffer + input : input;
|
|
1309
|
-
let parsed;
|
|
1310
|
-
try {
|
|
1311
|
-
parsed = JSON.parse(combined);
|
|
1312
|
-
} catch {
|
|
1313
|
-
if (combined.length > TUNNEL_MESSAGE_BUFFER_CAP_BYTES) {
|
|
1314
|
-
stats.oversizedClosed += 1;
|
|
1315
|
-
ws.data.messageBuffer = "";
|
|
1316
|
-
ws.close(1009, "Tunnel message buffer exceeded 4 MB");
|
|
1317
|
-
return;
|
|
1318
|
-
}
|
|
1319
|
-
stats.fragmentsBuffered += 1;
|
|
1320
|
-
ws.data.messageBuffer = combined;
|
|
1321
|
-
return;
|
|
1322
|
-
}
|
|
1323
|
-
ws.data.messageBuffer = "";
|
|
1324
|
-
const validated = validateTunnelMessage(parsed);
|
|
1325
|
-
if (validated.ok) return validated.message;
|
|
1326
|
-
if (validated.reason === "protocol_mismatch") {
|
|
1327
|
-
ws.close(1002, `Tunnel protocol mismatch: expected ${validated.expected_protocol}`);
|
|
1328
|
-
}
|
|
1329
|
-
return;
|
|
1330
|
-
}
|
|
1331
|
-
function sendTunnelPing(ws) {
|
|
1332
|
-
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1333
|
-
ws.data.missedPongs += 1;
|
|
1334
|
-
ws.send(JSON.stringify(makeTunnelPing()));
|
|
1335
|
-
}
|
|
1336
|
-
function clearPendingTimers(entry) {
|
|
1337
|
-
clearTimeout(entry.timeout);
|
|
1338
|
-
if (entry.slowConsumerTimeout) {
|
|
1339
|
-
clearTimeout(entry.slowConsumerTimeout);
|
|
1340
|
-
entry.slowConsumerTimeout = void 0;
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
function failPendingHttpResponse(input) {
|
|
1344
|
-
clearPendingTimers(input.entry);
|
|
1345
|
-
input.entry.pendingChunks.length = 0;
|
|
1346
|
-
input.entry.bytesQueued = 0;
|
|
1347
|
-
try {
|
|
1348
|
-
input.entry.controller.error(input.error);
|
|
1349
|
-
} catch {
|
|
1350
|
-
}
|
|
1351
|
-
if (!input.entry.responseStarted) {
|
|
1352
|
-
if (input.response) {
|
|
1353
|
-
input.entry.resolve(input.response);
|
|
1354
|
-
return;
|
|
1355
|
-
}
|
|
1356
|
-
input.entry.reject(input.error);
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
function cleanupHostTunnelSocket(input) {
|
|
1360
|
-
if (input.ws.data.heartbeat) {
|
|
1361
|
-
clearInterval(input.ws.data.heartbeat);
|
|
1362
|
-
input.ws.data.heartbeat = void 0;
|
|
1363
|
-
}
|
|
1364
|
-
for (const pending of input.ws.data.pending.values()) {
|
|
1365
|
-
failPendingHttpResponse({
|
|
1366
|
-
entry: pending,
|
|
1367
|
-
response: input.request ? corsJsonError(input.request, "user_hosted_app_offline", "User-hosted workspace is offline", 503) : jsonError("user_hosted_app_offline", "User-hosted workspace is offline", 503),
|
|
1368
|
-
error: new Error("User-hosted tunnel disconnected")
|
|
1369
|
-
});
|
|
1370
|
-
}
|
|
1371
|
-
input.ws.data.pending.clear();
|
|
1372
|
-
for (const channel of input.ws.data.channels.values()) {
|
|
1373
|
-
if (input.closeChannels) closeWebSocket(channel, 1011, "User-hosted tunnel disconnected");
|
|
1374
|
-
}
|
|
1375
|
-
input.ws.data.channels.clear();
|
|
1376
|
-
if (!input.disconnectDirectory) return;
|
|
1377
|
-
if (input.hostTunnels.get(input.ws.data.hostId) === input.ws) {
|
|
1378
|
-
input.hostTunnels.delete(input.ws.data.hostId);
|
|
1379
|
-
input.options.directory?.disconnectHost(input.ws.data.hostId);
|
|
1380
|
-
scheduleHostTunnelStateChange(input.hostTunnelStateDebounce, input.options, {
|
|
1381
|
-
hostId: input.ws.data.hostId,
|
|
1382
|
-
workspaceId: input.ws.data.workspaceIds[0],
|
|
1383
|
-
path: `/host-tunnels/${input.ws.data.hostId}`,
|
|
1384
|
-
state: "disconnected",
|
|
1385
|
-
debounceMs: input.bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
|
|
1386
|
-
});
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
async function audit2(options, input) {
|
|
1390
|
-
await options.audit?.({
|
|
1391
|
-
action: input.action,
|
|
1392
|
-
result: "allow",
|
|
1393
|
-
hostId: input.hostId,
|
|
1394
|
-
workspaceId: input.workspaceId,
|
|
1395
|
-
method: "WEBSOCKET",
|
|
1396
|
-
path: input.path
|
|
1397
|
-
});
|
|
1398
|
-
}
|
|
1399
|
-
function scheduleHostTunnelStateChange(state, options, input) {
|
|
1400
|
-
const existing = state.get(input.hostId);
|
|
1401
|
-
const entry = existing ?? {
|
|
1402
|
-
intendedState: "disconnected",
|
|
1403
|
-
lastWritten: "disconnected",
|
|
1404
|
-
flushTimer: void 0,
|
|
1405
|
-
pending: { workspaceId: input.workspaceId, path: input.path }
|
|
1406
|
-
};
|
|
1407
|
-
entry.intendedState = input.state;
|
|
1408
|
-
entry.pending = { workspaceId: input.workspaceId, path: input.path };
|
|
1409
|
-
if (entry.flushTimer) clearTimeout(entry.flushTimer);
|
|
1410
|
-
entry.flushTimer = setTimeout(() => {
|
|
1411
|
-
entry.flushTimer = void 0;
|
|
1412
|
-
if (entry.intendedState === entry.lastWritten) {
|
|
1413
|
-
if (entry.intendedState === "disconnected") {
|
|
1414
|
-
state.delete(input.hostId);
|
|
1415
|
-
}
|
|
1416
|
-
return;
|
|
1417
|
-
}
|
|
1418
|
-
entry.lastWritten = entry.intendedState;
|
|
1419
|
-
void audit2(options, {
|
|
1420
|
-
action: entry.intendedState === "connected" ? "host_tunnel.connected" : "host_tunnel.disconnected",
|
|
1421
|
-
hostId: input.hostId,
|
|
1422
|
-
workspaceId: entry.pending.workspaceId,
|
|
1423
|
-
path: entry.pending.path
|
|
1424
|
-
});
|
|
1425
|
-
}, input.debounceMs);
|
|
1426
|
-
state.set(input.hostId, entry);
|
|
1427
|
-
}
|
|
1428
|
-
function drainPendingChunks(entry) {
|
|
1429
|
-
while (entry.pendingChunks.length > 0) {
|
|
1430
|
-
const desired = entry.controller.desiredSize;
|
|
1431
|
-
if (desired !== null && desired <= 0) break;
|
|
1432
|
-
const next = entry.pendingChunks.shift();
|
|
1433
|
-
entry.bytesQueued -= next.byteLength;
|
|
1434
|
-
try {
|
|
1435
|
-
entry.controller.enqueue(next);
|
|
1436
|
-
} catch {
|
|
1437
|
-
entry.pendingChunks.length = 0;
|
|
1438
|
-
entry.bytesQueued = 0;
|
|
1439
|
-
break;
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
if (entry.pendingChunks.length === 0 && entry.slowConsumerTimeout) {
|
|
1443
|
-
clearTimeout(entry.slowConsumerTimeout);
|
|
1444
|
-
entry.slowConsumerTimeout = void 0;
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
function enqueueChunkWithBackpressure(input) {
|
|
1448
|
-
const { entry, chunk } = input;
|
|
1449
|
-
const desired = entry.controller.desiredSize;
|
|
1450
|
-
const overflowing = desired !== null && desired <= 0;
|
|
1451
|
-
if (overflowing || entry.pendingChunks.length > 0) {
|
|
1452
|
-
entry.pendingChunks.push(chunk);
|
|
1453
|
-
entry.bytesQueued += chunk.byteLength;
|
|
1454
|
-
if (!entry.slowConsumerTimeout) {
|
|
1455
|
-
input.slowConsumerStats.overflowEvents += 1;
|
|
1456
|
-
entry.slowConsumerTimeout = setTimeout(() => {
|
|
1457
|
-
input.slowConsumerStats.timerFired += 1;
|
|
1458
|
-
const error = new Error("slow_consumer_timeout: downstream consumer did not drain in time");
|
|
1459
|
-
try {
|
|
1460
|
-
entry.controller.error(error);
|
|
1461
|
-
} catch {
|
|
1462
|
-
}
|
|
1463
|
-
clearTimeout(entry.timeout);
|
|
1464
|
-
entry.pendingChunks.length = 0;
|
|
1465
|
-
entry.bytesQueued = 0;
|
|
1466
|
-
if (!entry.responseStarted) {
|
|
1467
|
-
entry.resolve(jsonError("slow_consumer_timeout", "Downstream consumer did not drain in time", 503));
|
|
1468
|
-
}
|
|
1469
|
-
input.ws.data.pending.delete(input.requestId);
|
|
1470
|
-
input.slowConsumerStats.droppedRequests += 1;
|
|
1471
|
-
}, input.slowConsumerTimeoutMs);
|
|
1472
|
-
}
|
|
1473
|
-
return;
|
|
1474
|
-
}
|
|
1475
|
-
try {
|
|
1476
|
-
entry.controller.enqueue(chunk);
|
|
1477
|
-
} catch {
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
var __slowConsumerInternalsForTest = {
|
|
1481
|
-
enqueueChunkWithBackpressure,
|
|
1482
|
-
createSlowConsumerStats
|
|
1483
|
-
};
|
|
1484
|
-
async function tunnelHttpRequest(input) {
|
|
1485
|
-
if (input.ws.readyState !== WebSocket.OPEN) {
|
|
1486
|
-
return new Response("User-hosted workspace is offline", { status: 503 });
|
|
1487
|
-
}
|
|
1488
|
-
if (input.ws.data.pending.size >= TUNNEL_PENDING_HTTP_CAP) {
|
|
1489
|
-
return jsonError(
|
|
1490
|
-
"too_many_in_flight",
|
|
1491
|
-
"User-hosted tunnel has too many in-flight HTTP requests",
|
|
1492
|
-
429
|
|
1493
|
-
);
|
|
1494
|
-
}
|
|
1495
|
-
const requestId = crypto.randomUUID();
|
|
1496
|
-
let controller;
|
|
1497
|
-
const stream = new ReadableStream({
|
|
1498
|
-
start(next) {
|
|
1499
|
-
controller = next;
|
|
1500
|
-
},
|
|
1501
|
-
pull() {
|
|
1502
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1503
|
-
if (!entry) return;
|
|
1504
|
-
drainPendingChunks(entry);
|
|
1505
|
-
},
|
|
1506
|
-
cancel() {
|
|
1507
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1508
|
-
if (!entry) return;
|
|
1509
|
-
input.ws.data.pending.delete(requestId);
|
|
1510
|
-
clearPendingTimers(entry);
|
|
1511
|
-
entry.pendingChunks.length = 0;
|
|
1512
|
-
entry.bytesQueued = 0;
|
|
1513
|
-
if (input.ws.readyState === WebSocket.OPEN) {
|
|
1514
|
-
input.ws.send(JSON.stringify({
|
|
1515
|
-
type: "http.response.flow",
|
|
1516
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
1517
|
-
request_id: requestId,
|
|
1518
|
-
paused: false,
|
|
1519
|
-
reason: "closed"
|
|
1520
|
-
}));
|
|
1521
|
-
}
|
|
1522
|
-
}
|
|
1523
|
-
}, new ByteLengthQueuingStrategy({ highWaterMark: input.slowConsumerHighWaterMarkBytes }));
|
|
1524
|
-
const pending = new Promise((resolve, reject) => {
|
|
1525
|
-
const timeout = setTimeout(() => {
|
|
1526
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1527
|
-
if (!entry) return;
|
|
1528
|
-
input.ws.data.pending.delete(requestId);
|
|
1529
|
-
if (entry.responseStarted) {
|
|
1530
|
-
clearPendingTimers(entry);
|
|
1531
|
-
entry.pendingChunks.length = 0;
|
|
1532
|
-
entry.bytesQueued = 0;
|
|
1533
|
-
try {
|
|
1534
|
-
entry.controller.close();
|
|
1535
|
-
} catch {
|
|
1536
|
-
}
|
|
1537
|
-
return;
|
|
1538
|
-
}
|
|
1539
|
-
const error = new Error("User-hosted tunnel response timed out");
|
|
1540
|
-
failPendingHttpResponse({
|
|
1541
|
-
entry,
|
|
1542
|
-
response: corsJsonError(input.request, "user_hosted_tunnel_timeout", "User-hosted tunnel response timed out", 504),
|
|
1543
|
-
error
|
|
1544
|
-
});
|
|
1545
|
-
}, input.responseTimeoutMs);
|
|
1546
|
-
input.ws.data.pending.set(requestId, {
|
|
1547
|
-
controller,
|
|
1548
|
-
stream,
|
|
1549
|
-
resolve,
|
|
1550
|
-
reject,
|
|
1551
|
-
timeout,
|
|
1552
|
-
pendingChunks: [],
|
|
1553
|
-
bytesQueued: 0,
|
|
1554
|
-
responseStarted: false
|
|
1555
|
-
});
|
|
1556
|
-
});
|
|
1557
|
-
const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : await readBoundedBody(input.request, input.requestBodyMaxBytes);
|
|
1558
|
-
if (body && "response" in body) {
|
|
1559
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1560
|
-
if (entry) clearPendingTimers(entry);
|
|
1561
|
-
input.ws.data.pending.delete(requestId);
|
|
1562
|
-
return body.response;
|
|
1563
|
-
}
|
|
1564
|
-
input.ws.send(JSON.stringify({
|
|
1565
|
-
type: "http.request",
|
|
1566
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
1567
|
-
request_id: requestId,
|
|
1568
|
-
workspace_id: input.workspaceId,
|
|
1569
|
-
method: input.request.method,
|
|
1570
|
-
path: input.path,
|
|
1571
|
-
headers: headersRecord(workspaceRelayForwardHeaders(
|
|
1572
|
-
input.request.headers,
|
|
1573
|
-
input.relayHostToken,
|
|
1574
|
-
input.workspaceId,
|
|
1575
|
-
// T26: user-hosted tunnel — strip Cookie to avoid leaking browser
|
|
1576
|
-
// cookies to the host process running on the user's laptop.
|
|
1577
|
-
{ userHosted: true }
|
|
1578
|
-
)),
|
|
1579
|
-
...body?.bodyBase64 ? { body_base64: body.bodyBase64 } : {},
|
|
1580
|
-
end: true
|
|
1581
|
-
}));
|
|
1582
|
-
return await pending;
|
|
1583
|
-
}
|
|
1584
|
-
async function readBoundedBody(request, maxBytes) {
|
|
1585
|
-
if (!request.body) return void 0;
|
|
1586
|
-
const reader = request.body.getReader();
|
|
1587
|
-
const chunks = [];
|
|
1588
|
-
let total = 0;
|
|
1589
|
-
for (; ; ) {
|
|
1590
|
-
const next = await reader.read();
|
|
1591
|
-
if (next.done) break;
|
|
1592
|
-
total += next.value.byteLength;
|
|
1593
|
-
if (total > maxBytes) {
|
|
1594
|
-
try {
|
|
1595
|
-
await reader.cancel();
|
|
1596
|
-
} catch {
|
|
1597
|
-
}
|
|
1598
|
-
return {
|
|
1599
|
-
response: corsJsonError(request, "request_body_too_large", "Tunnel request body exceeds the relay limit", 413)
|
|
1600
|
-
};
|
|
1601
|
-
}
|
|
1602
|
-
chunks.push(next.value);
|
|
1603
|
-
}
|
|
1604
|
-
const body = new Uint8Array(total);
|
|
1605
|
-
chunks.reduce((offset, chunk) => {
|
|
1606
|
-
body.set(chunk, offset);
|
|
1607
|
-
return offset + chunk.byteLength;
|
|
1608
|
-
}, 0);
|
|
1609
|
-
return { bodyBase64: encoded(body.buffer) };
|
|
1610
|
-
}
|
|
1611
|
-
async function directHttpRequest(input) {
|
|
1612
|
-
const controller = new AbortController();
|
|
1613
|
-
const timer = setTimeout(() => controller.abort(), input.timeoutMs);
|
|
1614
|
-
const span = (name, run) => input.trace ? input.trace.span(name, run) : run();
|
|
1615
|
-
try {
|
|
1616
|
-
const init = workspaceRelayForwardRequestInit(
|
|
1617
|
-
input.request,
|
|
1618
|
-
input.relayHostToken,
|
|
1619
|
-
input.workspaceId,
|
|
1620
|
-
{
|
|
1621
|
-
// T26: cloud-vm direct path. Cookies pass through (default) — workspace
|
|
1622
|
-
// dashboards in the VM may legitimately need session cookies.
|
|
1623
|
-
userHosted: false,
|
|
1624
|
-
upstreamHeaders: input.upstreamHeaders,
|
|
1625
|
-
signal: controller.signal
|
|
1626
|
-
}
|
|
1627
|
-
);
|
|
1628
|
-
if (input.request.method !== "GET" && input.request.method !== "HEAD" && input.request.body) {
|
|
1629
|
-
init.body = await input.request.arrayBuffer();
|
|
1630
|
-
}
|
|
1631
|
-
const limiter = input.limiter;
|
|
1632
|
-
const release = limiter ? await span("direct-http-queue", () => limiter.acquire()) : void 0;
|
|
1633
|
-
try {
|
|
1634
|
-
const upstream = await span("upstream-fetch", async () => await fetch(input.targetUrl, init));
|
|
1635
|
-
return new Response(upstream.body, {
|
|
1636
|
-
status: upstream.status,
|
|
1637
|
-
statusText: upstream.statusText,
|
|
1638
|
-
headers: relayCorsHeaders(input.request, upstream.headers)
|
|
1639
|
-
});
|
|
1640
|
-
} finally {
|
|
1641
|
-
release?.();
|
|
1642
|
-
}
|
|
1643
|
-
} catch (err) {
|
|
1644
|
-
const aborted = err instanceof Error && err.name === "AbortError";
|
|
1645
|
-
return corsJsonError(
|
|
1646
|
-
input.request,
|
|
1647
|
-
aborted ? "upstream_timeout" : "upstream_unavailable",
|
|
1648
|
-
aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable",
|
|
1649
|
-
aborted ? 504 : 503
|
|
1650
|
-
);
|
|
1651
|
-
} finally {
|
|
1652
|
-
clearTimeout(timer);
|
|
1653
|
-
}
|
|
1654
|
-
}
|
|
1655
|
-
function hostTunnel(hostTunnels, hostId) {
|
|
1656
|
-
const tunnel = hostTunnels.get(hostId);
|
|
1657
|
-
if (tunnel?.readyState === WebSocket.OPEN) return tunnel;
|
|
1658
|
-
return void 0;
|
|
1659
|
-
}
|
|
1660
|
-
async function authorizeHostTunnel(options, bunOptions, request, input) {
|
|
1661
|
-
if (bunOptions.authorizeHostTunnel) return await bunOptions.authorizeHostTunnel(request, input);
|
|
1662
|
-
const token = bearerToken(request.headers.get("authorization"));
|
|
1663
|
-
if (!token) return false;
|
|
1664
|
-
try {
|
|
1665
|
-
await verifyHostTunnelToken(token, options.runtimeAccessKey, input);
|
|
1666
|
-
return true;
|
|
1667
|
-
} catch (err) {
|
|
1668
|
-
if (err instanceof WorkspaceRelayAuthError) return false;
|
|
1669
|
-
throw err;
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
1673
|
-
const hostTunnels = /* @__PURE__ */ new Map();
|
|
1674
|
-
const relayClients = /* @__PURE__ */ new Set();
|
|
1675
|
-
const hostTunnelRegistrations = /* @__PURE__ */ new Map();
|
|
1676
|
-
const directHttpLimiter = createDirectHttpLimiter(bunOptions.directHttpConcurrency);
|
|
1677
|
-
const hostTunnelStateDebounce = /* @__PURE__ */ new Map();
|
|
1678
|
-
const fragmentationStats = createFragmentationStats();
|
|
1679
|
-
const slowConsumerStats = createSlowConsumerStats();
|
|
1680
|
-
const telemetry = {
|
|
1681
|
-
getFragmentationStats: () => readFragmentationStats(fragmentationStats),
|
|
1682
|
-
resetFragmentationStats: () => resetFragmentationStats(fragmentationStats),
|
|
1683
|
-
getSlowConsumerStats: () => readSlowConsumerStats(slowConsumerStats),
|
|
1684
|
-
resetSlowConsumerStats: () => resetSlowConsumerStats(slowConsumerStats)
|
|
1685
|
-
};
|
|
1686
|
-
let draining = false;
|
|
1687
|
-
const drainController = {
|
|
1688
|
-
isDraining: () => draining,
|
|
1689
|
-
setDraining: (value) => {
|
|
1690
|
-
draining = value;
|
|
1691
|
-
if (!value) return;
|
|
1692
|
-
for (const tunnel of hostTunnels.values()) {
|
|
1693
|
-
closeWebSocket(tunnel, 1012, "Workspace relay is draining", 1012);
|
|
1694
|
-
}
|
|
1695
|
-
for (const client of relayClients) {
|
|
1696
|
-
closeWebSocket(client, 1012, "Workspace relay is draining", 1012);
|
|
1697
|
-
}
|
|
1698
|
-
},
|
|
1699
|
-
pendingCount: () => {
|
|
1700
|
-
let total = 0;
|
|
1701
|
-
for (const tunnel of hostTunnels.values()) {
|
|
1702
|
-
total += tunnel.data.pending.size;
|
|
1703
|
-
}
|
|
1704
|
-
return total;
|
|
1705
|
-
},
|
|
1706
|
-
waitForDrain: async (timeoutMs) => {
|
|
1707
|
-
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
1708
|
-
const pollMs = 50;
|
|
1709
|
-
while (true) {
|
|
1710
|
-
const remaining = drainController.pendingCount();
|
|
1711
|
-
if (remaining === 0) return { drained: true, remaining: 0 };
|
|
1712
|
-
const now = Date.now();
|
|
1713
|
-
if (now >= deadline) return { drained: false, remaining };
|
|
1714
|
-
const sleep = Math.min(pollMs, Math.max(1, deadline - now));
|
|
1715
|
-
await new Promise((resolve) => setTimeout(resolve, sleep));
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1718
|
-
};
|
|
1719
|
-
const mergedMetricsSources = {
|
|
1720
|
-
fragmentation: options.metricsSources?.fragmentation ?? telemetry.getFragmentationStats,
|
|
1721
|
-
slowConsumer: options.metricsSources?.slowConsumer ?? telemetry.getSlowConsumerStats,
|
|
1722
|
-
drainPending: options.metricsSources?.drainPending ?? drainController.pendingCount
|
|
1723
|
-
};
|
|
1724
|
-
const requestRemoteAddress = /* @__PURE__ */ new WeakMap();
|
|
1725
|
-
const metricsRemoteAddressResolver = options.metricsRemoteAddress ?? ((request) => requestRemoteAddress.get(request));
|
|
1726
|
-
const app = createWorkspaceRelay({
|
|
1727
|
-
...options,
|
|
1728
|
-
isDraining: drainController.isDraining,
|
|
1729
|
-
metricsSources: mergedMetricsSources,
|
|
1730
|
-
metricsRemoteAddress: metricsRemoteAddressResolver
|
|
1731
|
-
});
|
|
1732
|
-
function getRegistrationTracker(hostId) {
|
|
1733
|
-
let tracker = hostTunnelRegistrations.get(hostId);
|
|
1734
|
-
if (!tracker) {
|
|
1735
|
-
tracker = { recent: [] };
|
|
1736
|
-
hostTunnelRegistrations.set(hostId, tracker);
|
|
1737
|
-
}
|
|
1738
|
-
return tracker;
|
|
1739
|
-
}
|
|
1740
|
-
function pruneReconnects(tracker, now) {
|
|
1741
|
-
const cutoff = now - HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS;
|
|
1742
|
-
while (tracker.recent.length && tracker.recent[0] < cutoff) {
|
|
1743
|
-
tracker.recent.shift();
|
|
1744
|
-
}
|
|
1745
|
-
}
|
|
1746
|
-
return {
|
|
1747
|
-
async fetch(request, server) {
|
|
1748
|
-
const url = new URL(request.url);
|
|
1749
|
-
const workspaceId = workspaceIdFromPath(url.pathname);
|
|
1750
|
-
const hostId = hostIdFromTunnelPath(url.pathname);
|
|
1751
|
-
try {
|
|
1752
|
-
const ip = server.requestIP(request)?.address;
|
|
1753
|
-
if (ip) requestRemoteAddress.set(request, ip);
|
|
1754
|
-
} catch {
|
|
1755
|
-
}
|
|
1756
|
-
if (url.pathname === "/health") {
|
|
1757
|
-
return Response.json(
|
|
1758
|
-
draining ? { ok: false, service: "workspace-relay", draining: true } : { ok: true, service: "workspace-relay" },
|
|
1759
|
-
{ status: draining ? 503 : 200 }
|
|
1760
|
-
);
|
|
1761
|
-
}
|
|
1762
|
-
if (draining && websocketRequest(request) && hostId) {
|
|
1763
|
-
return jsonError(
|
|
1764
|
-
"relay_draining",
|
|
1765
|
-
"Workspace relay is shutting down; new tunnel registrations are not accepted",
|
|
1766
|
-
503
|
|
1767
|
-
);
|
|
1768
|
-
}
|
|
1769
|
-
if (draining && workspaceId) {
|
|
1770
|
-
const headers2 = relayCorsHeaders(request);
|
|
1771
|
-
const body = JSON.stringify({
|
|
1772
|
-
error: {
|
|
1773
|
-
code: "relay_draining",
|
|
1774
|
-
message: "Workspace relay is shutting down; try another instance"
|
|
1775
|
-
}
|
|
1776
|
-
});
|
|
1777
|
-
headers2.set("content-type", "application/json");
|
|
1778
|
-
return new Response(body, { status: 503, headers: headers2 });
|
|
1779
|
-
}
|
|
1780
|
-
if (websocketRequest(request) && hostId) {
|
|
1781
|
-
const workspaceIds = url.searchParams.getAll("workspaceId");
|
|
1782
|
-
if (!workspaceIds.length || !options.directory) {
|
|
1783
|
-
return new Response("Host tunnel registration is unavailable", { status: 503 });
|
|
1784
|
-
}
|
|
1785
|
-
if (!await authorizeHostTunnel(options, bunOptions, request, { hostId, workspaceIds })) {
|
|
1786
|
-
return new Response("Host tunnel registration denied", { status: 403 });
|
|
1787
|
-
}
|
|
1788
|
-
const tracker = getRegistrationTracker(hostId);
|
|
1789
|
-
const now = Date.now();
|
|
1790
|
-
pruneReconnects(tracker, now);
|
|
1791
|
-
if (tracker.recent.length >= HOST_TUNNEL_REGISTRATION_RECONNECT_CAP) {
|
|
1792
|
-
return jsonError(
|
|
1793
|
-
"too_many_host_tunnel_reconnects",
|
|
1794
|
-
"Too many host-tunnel reconnects for this host within the last 60 seconds",
|
|
1795
|
-
429
|
|
1796
|
-
);
|
|
1797
|
-
}
|
|
1798
|
-
if (server.upgrade(request, {
|
|
1799
|
-
data: {
|
|
1800
|
-
kind: "host-tunnel",
|
|
1801
|
-
hostId,
|
|
1802
|
-
workspaceIds,
|
|
1803
|
-
pending: /* @__PURE__ */ new Map(),
|
|
1804
|
-
channels: /* @__PURE__ */ new Map(),
|
|
1805
|
-
missedPongs: 0,
|
|
1806
|
-
messageBuffer: ""
|
|
1807
|
-
}
|
|
1808
|
-
})) {
|
|
1809
|
-
tracker.recent.push(now);
|
|
1810
|
-
return;
|
|
1811
|
-
}
|
|
1812
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
1813
|
-
}
|
|
1814
|
-
if (!workspaceId) return app.request(request);
|
|
1815
|
-
if (request.method === "OPTIONS") {
|
|
1816
|
-
return new Response(null, {
|
|
1817
|
-
status: 204,
|
|
1818
|
-
headers: relayCorsHeaders(request)
|
|
1819
|
-
});
|
|
1820
|
-
}
|
|
1821
|
-
if (!websocketRequest(request)) {
|
|
1822
|
-
const trace = createWorkspaceRelayTrace();
|
|
1823
|
-
const response = await trace.span("relay-total", async () => {
|
|
1824
|
-
const relay2 = await authorizeWorkspaceRelayRequest(options, request, workspaceId, trace);
|
|
1825
|
-
if (!relay2.ok) return relay2.response;
|
|
1826
|
-
if (relay2.request.target.access === "user-hosted") {
|
|
1827
|
-
const tunnel = hostTunnel(hostTunnels, relay2.request.target.hostId);
|
|
1828
|
-
return tunnel ? await trace.span("tunnel-http", async () => await tunnelHttpRequest({
|
|
1829
|
-
ws: tunnel,
|
|
1830
|
-
request,
|
|
1831
|
-
workspaceId: relay2.request.target.workspaceId,
|
|
1832
|
-
path: `${relay2.request.path}${url.search}`,
|
|
1833
|
-
relayHostToken: relay2.request.relayHostToken,
|
|
1834
|
-
slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
|
|
1835
|
-
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
|
|
1836
|
-
slowConsumerStats,
|
|
1837
|
-
requestBodyMaxBytes: bunOptions.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT,
|
|
1838
|
-
responseTimeoutMs: bunOptions.tunnelHttpResponseTimeoutMs ?? TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT
|
|
1839
|
-
})) : new Response("User-hosted workspace is offline", { status: 503 });
|
|
1840
|
-
}
|
|
1841
|
-
return await directHttpRequest({
|
|
1842
|
-
request,
|
|
1843
|
-
targetUrl: workspaceRelayTargetUrl(
|
|
1844
|
-
relay2.request.target,
|
|
1845
|
-
relay2.request.path,
|
|
1846
|
-
url.search
|
|
1847
|
-
).toString(),
|
|
1848
|
-
relayHostToken: relay2.request.relayHostToken,
|
|
1849
|
-
workspaceId: relay2.request.target.workspaceId,
|
|
1850
|
-
upstreamHeaders: relay2.request.target.upstreamHeaders,
|
|
1851
|
-
timeoutMs: bunOptions.directHttpTimeoutMs ?? DIRECT_HTTP_TIMEOUT_MS_DEFAULT,
|
|
1852
|
-
limiter: directHttpLimiter,
|
|
1853
|
-
trace
|
|
1854
|
-
});
|
|
1855
|
-
});
|
|
1856
|
-
return workspaceRelayTimingResponse(response, trace);
|
|
1857
|
-
}
|
|
1858
|
-
const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
|
|
1859
|
-
if (!relay.ok) return relay.response;
|
|
1860
|
-
const originDenied = requireAllowedOrigin(request);
|
|
1861
|
-
if (originDenied) return originDenied;
|
|
1862
|
-
if (relay.request.target.access === "user-hosted") {
|
|
1863
|
-
const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId);
|
|
1864
|
-
if (!tunnel) {
|
|
1865
|
-
return new Response("User-hosted workspace is offline", { status: 503 });
|
|
1866
|
-
}
|
|
1867
|
-
if (tunnel.data.channels.size >= TUNNEL_CHANNEL_CAP) {
|
|
1868
|
-
return jsonError(
|
|
1869
|
-
"too_many_channels",
|
|
1870
|
-
"User-hosted tunnel has too many active WebSocket channels",
|
|
1871
|
-
503
|
|
1872
|
-
);
|
|
1873
|
-
}
|
|
1874
|
-
if (server.upgrade(request, {
|
|
1875
|
-
data: {
|
|
1876
|
-
kind: "user-hosted-client",
|
|
1877
|
-
hostId: relay.request.target.hostId,
|
|
1878
|
-
workspaceId: relay.request.target.workspaceId,
|
|
1879
|
-
channelId: crypto.randomUUID(),
|
|
1880
|
-
path: `${relay.request.path}${url.search}`,
|
|
1881
|
-
relayHostToken: relay.request.relayHostToken
|
|
1882
|
-
}
|
|
1883
|
-
})) {
|
|
1884
|
-
return;
|
|
1885
|
-
}
|
|
1886
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
1887
|
-
}
|
|
1888
|
-
if (server.upgrade(request, {
|
|
1889
|
-
data: {
|
|
1890
|
-
kind: "client",
|
|
1891
|
-
upstreamUrl: workspaceRelayTargetUrl(
|
|
1892
|
-
relay.request.target,
|
|
1893
|
-
relay.request.path,
|
|
1894
|
-
url.search
|
|
1895
|
-
).toString().replace(/^http/, "ws"),
|
|
1896
|
-
headers: headersRecord(workspaceRelayForwardHeaders(
|
|
1897
|
-
new Headers(),
|
|
1898
|
-
relay.request.relayHostToken,
|
|
1899
|
-
relay.request.target.workspaceId,
|
|
1900
|
-
// T26: cloud-vm WS upgrade. Cookies pass through (default).
|
|
1901
|
-
{ userHosted: false, upstreamHeaders: relay.request.target.upstreamHeaders }
|
|
1902
|
-
)),
|
|
1903
|
-
queue: [],
|
|
1904
|
-
...relayWebSocketTraceEnabled(request) ? {
|
|
1905
|
-
trace: {
|
|
1906
|
-
acceptedAt: performance.now(),
|
|
1907
|
-
queuedFrames: 0,
|
|
1908
|
-
maxQueuedDelayMs: 0,
|
|
1909
|
-
emitted: false
|
|
1910
|
-
}
|
|
1911
|
-
} : {}
|
|
1912
|
-
}
|
|
1913
|
-
})) {
|
|
1914
|
-
return;
|
|
1915
|
-
}
|
|
1916
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
1917
|
-
},
|
|
1918
|
-
websocket: {
|
|
1919
|
-
maxPayloadLength: WS_MAX_PAYLOAD_LENGTH_BYTES,
|
|
1920
|
-
message(ws, message) {
|
|
1921
|
-
if (ws.data.kind === "host-tunnel") {
|
|
1922
|
-
const parsed = tunnelMessage(
|
|
1923
|
-
ws,
|
|
1924
|
-
message,
|
|
1925
|
-
fragmentationStats
|
|
1926
|
-
);
|
|
1927
|
-
if (parsed?.type === "ping") {
|
|
1928
|
-
options.directory?.recordPong(ws.data.hostId);
|
|
1929
|
-
ws.send(JSON.stringify(makeTunnelPong(parsed)));
|
|
1930
|
-
}
|
|
1931
|
-
if (parsed?.type === "pong") {
|
|
1932
|
-
ws.data.missedPongs = 0;
|
|
1933
|
-
options.directory?.recordPong(ws.data.hostId);
|
|
1934
|
-
}
|
|
1935
|
-
if (parsed?.type === "host.registration.update") {
|
|
1936
|
-
const update = parsed;
|
|
1937
|
-
const workspaceIds = [...new Set(update.workspace_ids)];
|
|
1938
|
-
const hostSocket = ws;
|
|
1939
|
-
const hostId = hostSocket.data.hostId;
|
|
1940
|
-
const authorizationRequest = new Request(
|
|
1941
|
-
`http://relay.local/host-tunnels/${encodeURIComponent(hostId)}`,
|
|
1942
|
-
{ headers: { authorization: `Bearer ${update.token}` } }
|
|
1943
|
-
);
|
|
1944
|
-
void authorizeHostTunnel(options, bunOptions, authorizationRequest, {
|
|
1945
|
-
hostId,
|
|
1946
|
-
workspaceIds
|
|
1947
|
-
}).then((authorized) => {
|
|
1948
|
-
if (!authorized) {
|
|
1949
|
-
closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008);
|
|
1950
|
-
return;
|
|
1951
|
-
}
|
|
1952
|
-
if (hostTunnels.get(hostId) !== hostSocket || hostSocket.readyState !== WebSocket.OPEN) return;
|
|
1953
|
-
hostSocket.data.workspaceIds = workspaceIds;
|
|
1954
|
-
options.directory?.registerHostTunnel({ hostId, workspaceIds });
|
|
1955
|
-
}).catch(() => closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008));
|
|
1956
|
-
return;
|
|
1957
|
-
}
|
|
1958
|
-
if (parsed?.type === "error") {
|
|
1959
|
-
if (parsed.request_id) {
|
|
1960
|
-
const pending = ws.data.pending.get(parsed.request_id);
|
|
1961
|
-
if (pending) {
|
|
1962
|
-
ws.data.pending.delete(parsed.request_id);
|
|
1963
|
-
failPendingHttpResponse({
|
|
1964
|
-
entry: pending,
|
|
1965
|
-
response: jsonError(parsed.code, parsed.message, 502),
|
|
1966
|
-
error: new Error(parsed.message)
|
|
1967
|
-
});
|
|
1968
|
-
}
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
if (parsed?.type === "http.response.start") {
|
|
1972
|
-
const pending = ws.data.pending.get(parsed.request_id);
|
|
1973
|
-
if (pending) {
|
|
1974
|
-
const response = parsed;
|
|
1975
|
-
pending.responseStarted = true;
|
|
1976
|
-
if (isEventStream(response.headers)) clearTimeout(pending.timeout);
|
|
1977
|
-
pending.resolve(new Response(pending.stream, {
|
|
1978
|
-
status: response.status,
|
|
1979
|
-
headers: headers(response.headers)
|
|
1980
|
-
}));
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
if (parsed?.type === "http.response.chunk") {
|
|
1984
|
-
const entry = ws.data.pending.get(parsed.request_id);
|
|
1985
|
-
if (entry) {
|
|
1986
|
-
enqueueChunkWithBackpressure({
|
|
1987
|
-
ws,
|
|
1988
|
-
requestId: parsed.request_id,
|
|
1989
|
-
entry,
|
|
1990
|
-
chunk: decoded(parsed.body_base64),
|
|
1991
|
-
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
|
|
1992
|
-
slowConsumerStats
|
|
1993
|
-
});
|
|
1994
|
-
}
|
|
1995
|
-
}
|
|
1996
|
-
if (parsed?.type === "http.response.end") {
|
|
1997
|
-
const pending = ws.data.pending.get(parsed.request_id);
|
|
1998
|
-
if (pending) {
|
|
1999
|
-
clearTimeout(pending.timeout);
|
|
2000
|
-
if (pending.slowConsumerTimeout) {
|
|
2001
|
-
clearTimeout(pending.slowConsumerTimeout);
|
|
2002
|
-
pending.slowConsumerTimeout = void 0;
|
|
2003
|
-
}
|
|
2004
|
-
drainPendingChunks(pending);
|
|
2005
|
-
if (pending.pendingChunks.length > 0) {
|
|
2006
|
-
for (const chunk of pending.pendingChunks) {
|
|
2007
|
-
try {
|
|
2008
|
-
pending.controller.enqueue(chunk);
|
|
2009
|
-
} catch {
|
|
2010
|
-
break;
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
pending.pendingChunks.length = 0;
|
|
2014
|
-
pending.bytesQueued = 0;
|
|
2015
|
-
}
|
|
2016
|
-
try {
|
|
2017
|
-
pending.controller.close();
|
|
2018
|
-
} catch {
|
|
2019
|
-
}
|
|
2020
|
-
ws.data.pending.delete(parsed.request_id);
|
|
2021
|
-
}
|
|
2022
|
-
}
|
|
2023
|
-
if (parsed?.type === "ws.frame") {
|
|
2024
|
-
const frame = parsed;
|
|
2025
|
-
const channel = ws.data.channels.get(frame.channel_id);
|
|
2026
|
-
if (!channel || channel.readyState !== WebSocket.OPEN) {
|
|
2027
|
-
ws.data.channels.delete(frame.channel_id);
|
|
2028
|
-
return;
|
|
2029
|
-
}
|
|
2030
|
-
if (relayOverBackpressureLimit(channel, bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
|
|
2031
|
-
ws.data.channels.delete(frame.channel_id);
|
|
2032
|
-
closeWebSocket(channel, 1011, "Client WebSocket backpressure limit exceeded");
|
|
2033
|
-
return;
|
|
2034
|
-
}
|
|
2035
|
-
channel.send(decodedFrame(frame));
|
|
2036
|
-
}
|
|
2037
|
-
if (parsed?.type === "ws.close") {
|
|
2038
|
-
const close = parsed;
|
|
2039
|
-
const channel = ws.data.channels.get(close.channel_id);
|
|
2040
|
-
if (channel) {
|
|
2041
|
-
ws.data.channels.delete(close.channel_id);
|
|
2042
|
-
closeWebSocket(channel, close.code, close.reason);
|
|
2043
|
-
}
|
|
2044
|
-
}
|
|
2045
|
-
return;
|
|
2046
|
-
}
|
|
2047
|
-
if (ws.data.kind === "user-hosted-client") {
|
|
2048
|
-
const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
|
|
2049
|
-
if (!tunnel) {
|
|
2050
|
-
ws.close(1011, "User-hosted tunnel disconnected");
|
|
2051
|
-
return;
|
|
2052
|
-
}
|
|
2053
|
-
if (relayOverBackpressureLimit(tunnel, bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
|
|
2054
|
-
closeWebSocket(ws, 1011, "Host tunnel backpressure limit exceeded");
|
|
2055
|
-
return;
|
|
2056
|
-
}
|
|
2057
|
-
tunnel.send(JSON.stringify({
|
|
2058
|
-
type: "ws.frame",
|
|
2059
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2060
|
-
channel_id: ws.data.channelId,
|
|
2061
|
-
...encodedFrame(message)
|
|
2062
|
-
}));
|
|
2063
|
-
return;
|
|
2064
|
-
}
|
|
2065
|
-
if (ws.data.upstream?.readyState === WebSocket.OPEN) {
|
|
2066
|
-
ws.data.upstream.send(message);
|
|
2067
|
-
return;
|
|
2068
|
-
}
|
|
2069
|
-
const queuedBytes = ws.data.queuedBytes ?? 0;
|
|
2070
|
-
const frameBytes = preOpenFrameBytes(message);
|
|
2071
|
-
if (ws.data.queue.length >= (bunOptions.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT) && queuedBytes + frameBytes > (bunOptions.upstreamWebSocketPreOpenQueueMaxBytes ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT)) {
|
|
2072
|
-
closeWebSocket(ws, 1011, "Upstream WebSocket queue limit exceeded");
|
|
2073
|
-
return;
|
|
2074
|
-
}
|
|
2075
|
-
ws.data.queuedBytes = queuedBytes + frameBytes;
|
|
2076
|
-
ws.data.queue.push({ payload: message, queuedAt: performance.now() });
|
|
2077
|
-
},
|
|
2078
|
-
open(ws) {
|
|
2079
|
-
if (ws.data.kind === "host-tunnel") {
|
|
2080
|
-
const previous = hostTunnels.get(ws.data.hostId);
|
|
2081
|
-
if (previous && previous !== ws) {
|
|
2082
|
-
cleanupHostTunnelSocket({
|
|
2083
|
-
ws: previous,
|
|
2084
|
-
hostTunnels,
|
|
2085
|
-
hostTunnelStateDebounce,
|
|
2086
|
-
options,
|
|
2087
|
-
bunOptions,
|
|
2088
|
-
disconnectDirectory: false,
|
|
2089
|
-
closeChannels: true
|
|
2090
|
-
});
|
|
2091
|
-
closeWebSocket(previous, 1012, "Host tunnel replaced by a newer connection", 1012);
|
|
2092
|
-
}
|
|
2093
|
-
hostTunnels.set(ws.data.hostId, ws);
|
|
2094
|
-
options.directory?.registerHostTunnel({
|
|
2095
|
-
hostId: ws.data.hostId,
|
|
2096
|
-
workspaceIds: ws.data.workspaceIds
|
|
2097
|
-
});
|
|
2098
|
-
ws.data.heartbeat = setInterval(() => {
|
|
2099
|
-
const hostWs = ws;
|
|
2100
|
-
if (hostWs.data.missedPongs > (bunOptions.hostTunnelMaxMissedPongs ?? HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT)) {
|
|
2101
|
-
closeWebSocket(ws, 1001, "Host tunnel heartbeat timed out", 1001);
|
|
2102
|
-
return;
|
|
2103
|
-
}
|
|
2104
|
-
sendTunnelPing(hostWs);
|
|
2105
|
-
}, bunOptions.hostTunnelPingIntervalMs ?? 15e3);
|
|
2106
|
-
scheduleHostTunnelStateChange(hostTunnelStateDebounce, options, {
|
|
2107
|
-
hostId: ws.data.hostId,
|
|
2108
|
-
workspaceId: ws.data.workspaceIds[0],
|
|
2109
|
-
path: `/host-tunnels/${ws.data.hostId}`,
|
|
2110
|
-
state: "connected",
|
|
2111
|
-
debounceMs: bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
|
|
2112
|
-
});
|
|
2113
|
-
return;
|
|
2114
|
-
}
|
|
2115
|
-
if (ws.data.kind === "user-hosted-client") {
|
|
2116
|
-
const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
|
|
2117
|
-
if (!tunnel) {
|
|
2118
|
-
ws.close(1011, "User-hosted tunnel disconnected");
|
|
2119
|
-
return;
|
|
2120
|
-
}
|
|
2121
|
-
tunnel.data.channels.set(ws.data.channelId, ws);
|
|
2122
|
-
if (tunnel.readyState !== WebSocket.OPEN) {
|
|
2123
|
-
closeWebSocket(ws, 1011, "User-hosted tunnel disconnected");
|
|
2124
|
-
return;
|
|
2125
|
-
}
|
|
2126
|
-
tunnel.send(JSON.stringify({
|
|
2127
|
-
type: "ws.open",
|
|
2128
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2129
|
-
channel_id: ws.data.channelId,
|
|
2130
|
-
workspace_id: ws.data.workspaceId,
|
|
2131
|
-
path: ws.data.path,
|
|
2132
|
-
headers: headersRecord(workspaceRelayForwardHeaders(
|
|
2133
|
-
new Headers(),
|
|
2134
|
-
ws.data.relayHostToken,
|
|
2135
|
-
ws.data.workspaceId,
|
|
2136
|
-
// T26: user-hosted tunnel WS upgrade — strip Cookie. Headers are
|
|
2137
|
-
// empty here today, but pass the flag for consistency in case
|
|
2138
|
-
// upstream client headers are forwarded in the future.
|
|
2139
|
-
{ userHosted: true }
|
|
2140
|
-
))
|
|
2141
|
-
}));
|
|
2142
|
-
return;
|
|
2143
|
-
}
|
|
2144
|
-
relayClients.add(ws);
|
|
2145
|
-
const data = ws.data;
|
|
2146
|
-
const UpstreamWebSocket = bunOptions.upstreamWebSocket ?? WebSocket;
|
|
2147
|
-
if (data.trace) data.trace.upstreamStartedAt = performance.now();
|
|
2148
|
-
const upstream = new UpstreamWebSocket(data.upstreamUrl, {
|
|
2149
|
-
headers: data.headers
|
|
2150
|
-
});
|
|
2151
|
-
upstream.binaryType = "arraybuffer";
|
|
2152
|
-
data.upstream = upstream;
|
|
2153
|
-
data.upstreamOpenTimer = setTimeout(() => {
|
|
2154
|
-
if (upstream.readyState === WebSocket.OPEN) return;
|
|
2155
|
-
closeWebSocket(upstream, 1001, "Upstream WebSocket open timed out", 1001);
|
|
2156
|
-
closeWebSocket(ws, 1011, "Upstream WebSocket open timed out");
|
|
2157
|
-
}, bunOptions.upstreamWebSocketOpenTimeoutMs ?? UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT);
|
|
2158
|
-
upstream.onopen = () => {
|
|
2159
|
-
const openedAt = performance.now();
|
|
2160
|
-
if (data.upstreamOpenTimer) {
|
|
2161
|
-
clearTimeout(data.upstreamOpenTimer);
|
|
2162
|
-
data.upstreamOpenTimer = void 0;
|
|
2163
|
-
}
|
|
2164
|
-
if (data.trace) {
|
|
2165
|
-
data.trace.upstreamOpenMs = openedAt - (data.trace.upstreamStartedAt ?? data.trace.acceptedAt);
|
|
2166
|
-
data.trace.queuedFrames = data.queue.length;
|
|
2167
|
-
data.trace.maxQueuedDelayMs = data.queue.reduce(
|
|
2168
|
-
(max, item) => Math.max(max, openedAt - item.queuedAt),
|
|
2169
|
-
0
|
|
2170
|
-
);
|
|
2171
|
-
sendRelayWebSocketTrace(ws);
|
|
2172
|
-
}
|
|
2173
|
-
for (const item of data.queue.splice(0)) upstream.send(item.payload);
|
|
2174
|
-
data.queuedBytes = 0;
|
|
2175
|
-
};
|
|
2176
|
-
upstream.onmessage = (event) => {
|
|
2177
|
-
const payload = relayWebSocketPayload(event.data);
|
|
2178
|
-
if (payload !== void 0) ws.send(payload);
|
|
2179
|
-
};
|
|
2180
|
-
upstream.onclose = (event) => {
|
|
2181
|
-
if (data.upstreamOpenTimer) {
|
|
2182
|
-
clearTimeout(data.upstreamOpenTimer);
|
|
2183
|
-
data.upstreamOpenTimer = void 0;
|
|
2184
|
-
}
|
|
2185
|
-
closeWebSocket(ws, event.code, upstreamCloseReason(event));
|
|
2186
|
-
};
|
|
2187
|
-
upstream.onerror = () => {
|
|
2188
|
-
if (data.upstreamOpenTimer) {
|
|
2189
|
-
clearTimeout(data.upstreamOpenTimer);
|
|
2190
|
-
data.upstreamOpenTimer = void 0;
|
|
2191
|
-
}
|
|
2192
|
-
closeWebSocket(ws, 1011, "Upstream WebSocket connection failed");
|
|
2193
|
-
};
|
|
2194
|
-
},
|
|
2195
|
-
close(ws, code, reason) {
|
|
2196
|
-
if (ws.data.kind === "host-tunnel") {
|
|
2197
|
-
cleanupHostTunnelSocket({
|
|
2198
|
-
ws,
|
|
2199
|
-
hostTunnels,
|
|
2200
|
-
hostTunnelStateDebounce,
|
|
2201
|
-
options,
|
|
2202
|
-
bunOptions,
|
|
2203
|
-
disconnectDirectory: true,
|
|
2204
|
-
closeChannels: true
|
|
2205
|
-
});
|
|
2206
|
-
const tracker = hostTunnelRegistrations.get(ws.data.hostId);
|
|
2207
|
-
if (tracker) {
|
|
2208
|
-
pruneReconnects(tracker, Date.now());
|
|
2209
|
-
if (tracker.recent.length === 0) hostTunnelRegistrations.delete(ws.data.hostId);
|
|
2210
|
-
}
|
|
2211
|
-
return;
|
|
2212
|
-
}
|
|
2213
|
-
if (ws.data.kind === "user-hosted-client") {
|
|
2214
|
-
const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
|
|
2215
|
-
tunnel?.data.channels.delete(ws.data.channelId);
|
|
2216
|
-
if (tunnel?.readyState === WebSocket.OPEN) {
|
|
2217
|
-
tunnel.send(JSON.stringify({
|
|
2218
|
-
type: "ws.close",
|
|
2219
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2220
|
-
channel_id: ws.data.channelId,
|
|
2221
|
-
code: safeCloseCode(code, 1e3),
|
|
2222
|
-
reason
|
|
2223
|
-
}));
|
|
2224
|
-
}
|
|
2225
|
-
return;
|
|
2226
|
-
}
|
|
2227
|
-
relayClients.delete(ws);
|
|
2228
|
-
if (ws.data.upstreamOpenTimer) clearTimeout(ws.data.upstreamOpenTimer);
|
|
2229
|
-
if (ws.data.upstream) closeWebSocket(ws.data.upstream, code, reason, 1e3);
|
|
2230
|
-
}
|
|
2231
|
-
},
|
|
2232
|
-
drain: drainController,
|
|
2233
|
-
telemetry
|
|
2234
|
-
};
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
// src/cloudflare.ts
|
|
2238
|
-
import {
|
|
2239
|
-
makeTunnelPong as makeTunnelPong2,
|
|
2240
|
-
TUNNEL_PROTOCOL_VERSION as TUNNEL_PROTOCOL_VERSION2,
|
|
2241
|
-
validateTunnelMessage as validateTunnelMessage2
|
|
2242
|
-
} from "@claxedo/workspace-relay-protocol";
|
|
2243
|
-
|
|
2244
1320
|
// src/directory.ts
|
|
1321
|
+
function presenceKey(hostId, workspaceId) {
|
|
1322
|
+
return `${hostId}\0${workspaceId}`;
|
|
1323
|
+
}
|
|
2245
1324
|
function createWorkspaceRelayDirectory(options = {}) {
|
|
2246
1325
|
const ttlMs = Math.max(1, options.ttlMs ?? 45e3);
|
|
2247
1326
|
const now = options.now ?? Date.now;
|
|
2248
1327
|
const sweepIntervalMs = options.sweepIntervalMs ?? 3e4;
|
|
2249
|
-
const
|
|
2250
|
-
const alive = (
|
|
2251
|
-
|
|
1328
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1329
|
+
const alive = (key) => {
|
|
1330
|
+
const presence = entries.get(key);
|
|
1331
|
+
if (!presence) return void 0;
|
|
2252
1332
|
if (presence.expiresAt <= now()) {
|
|
2253
|
-
|
|
2254
|
-
return;
|
|
1333
|
+
entries.delete(key);
|
|
1334
|
+
return void 0;
|
|
2255
1335
|
}
|
|
2256
1336
|
return presence;
|
|
2257
1337
|
};
|
|
2258
|
-
const
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
...presence,
|
|
2262
|
-
lastPongAt: timestamp,
|
|
2263
|
-
expiresAt: timestamp + ttlMs
|
|
2264
|
-
};
|
|
2265
|
-
hosts.set(next.hostId, next);
|
|
2266
|
-
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);
|
|
2267
1341
|
};
|
|
2268
1342
|
const sweep = () => {
|
|
2269
1343
|
const at = now();
|
|
2270
|
-
for (const [
|
|
1344
|
+
for (const [key, presence] of entries) {
|
|
2271
1345
|
if (presence.expiresAt <= at) {
|
|
2272
|
-
|
|
1346
|
+
entries.delete(key);
|
|
2273
1347
|
}
|
|
2274
1348
|
}
|
|
2275
1349
|
};
|
|
@@ -2287,29 +1361,35 @@ function createWorkspaceRelayDirectory(options = {}) {
|
|
|
2287
1361
|
lastPongAt: timestamp,
|
|
2288
1362
|
expiresAt: timestamp + ttlMs
|
|
2289
1363
|
};
|
|
2290
|
-
|
|
1364
|
+
for (const workspaceId of next.workspaceIds) entries.set(presenceKey(next.hostId, workspaceId), next);
|
|
2291
1365
|
return next;
|
|
2292
1366
|
},
|
|
2293
|
-
recordPong(hostId) {
|
|
2294
|
-
const
|
|
2295
|
-
|
|
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;
|
|
2296
1378
|
},
|
|
2297
|
-
disconnectHost(hostId) {
|
|
2298
|
-
|
|
1379
|
+
disconnectHost(hostId, workspaceIds) {
|
|
1380
|
+
for (const key of keysOf(hostId, workspaceIds)) entries.delete(key);
|
|
2299
1381
|
},
|
|
2300
1382
|
activeHost(input) {
|
|
2301
|
-
|
|
2302
|
-
if (!presence?.workspaceIds.includes(input.workspaceId)) return;
|
|
2303
|
-
return presence;
|
|
1383
|
+
return alive(presenceKey(input.hostId, input.workspaceId));
|
|
2304
1384
|
},
|
|
2305
1385
|
sweep,
|
|
2306
1386
|
size() {
|
|
2307
1387
|
const at = now();
|
|
2308
|
-
|
|
2309
|
-
for (const presence of
|
|
2310
|
-
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);
|
|
2311
1391
|
}
|
|
2312
|
-
return
|
|
1392
|
+
return hosts.size;
|
|
2313
1393
|
},
|
|
2314
1394
|
dispose() {
|
|
2315
1395
|
if (intervalHandle !== void 0) {
|
|
@@ -2327,6 +1407,9 @@ function disposeWorkspaceRelayDirectory(directory) {
|
|
|
2327
1407
|
var DEFAULT_RELAY_LOCATION_HINT = "apac";
|
|
2328
1408
|
var RELAY_LOCATION_HINTS = ["wnam", "enam", "sam", "weur", "eeur", "apac", "oc", "afr", "me"];
|
|
2329
1409
|
var relayLocationHintSet = new Set(RELAY_LOCATION_HINTS);
|
|
1410
|
+
function isRelayLocationHint(input) {
|
|
1411
|
+
return relayLocationHintSet.has(input);
|
|
1412
|
+
}
|
|
2330
1413
|
var RELAY_REGION_TO_LOCATION_HINT = {
|
|
2331
1414
|
// Canonical Claxedo regions.
|
|
2332
1415
|
"apac-south": "apac",
|
|
@@ -2363,14 +1446,15 @@ function locationHintForCountry(country) {
|
|
|
2363
1446
|
}
|
|
2364
1447
|
var RELAY_REGION_HEADER = "x-claxedo-workspace-region";
|
|
2365
1448
|
function relayLocationHint(input) {
|
|
2366
|
-
const configured =
|
|
2367
|
-
const region =
|
|
1449
|
+
const configured = trimToUndefined(input.configured) ?? DEFAULT_RELAY_LOCATION_HINT;
|
|
1450
|
+
const region = trimToUndefined(input.region);
|
|
2368
1451
|
if (region) {
|
|
2369
|
-
const
|
|
2370
|
-
const
|
|
1452
|
+
const lowered = region.toLowerCase();
|
|
1453
|
+
const direct = isRelayLocationHint(lowered) ? lowered : void 0;
|
|
1454
|
+
const mapped = direct ?? RELAY_REGION_TO_LOCATION_HINT[lowered];
|
|
2371
1455
|
if (mapped) return mapped;
|
|
2372
1456
|
}
|
|
2373
|
-
const country =
|
|
1457
|
+
const country = trimToUndefined(input.country);
|
|
2374
1458
|
if (country) {
|
|
2375
1459
|
const mapped = locationHintForCountry(country);
|
|
2376
1460
|
if (mapped) return mapped;
|
|
@@ -2379,7 +1463,7 @@ function relayLocationHint(input) {
|
|
|
2379
1463
|
}
|
|
2380
1464
|
function relayRequestRegion(request) {
|
|
2381
1465
|
const url = new URL(request.url);
|
|
2382
|
-
return
|
|
1466
|
+
return trimToUndefined(request.headers.get(RELAY_REGION_HEADER)) ?? trimToUndefined(url.searchParams.get("region")) ?? trimToUndefined(url.searchParams.get("homeRegion"));
|
|
2383
1467
|
}
|
|
2384
1468
|
function relayRequestCountry(request) {
|
|
2385
1469
|
const cf = request.cf;
|
|
@@ -2388,21 +1472,24 @@ function relayRequestCountry(request) {
|
|
|
2388
1472
|
var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
|
|
2389
1473
|
var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
|
|
2390
1474
|
var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
|
|
2391
|
-
var
|
|
1475
|
+
var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
|
|
2392
1476
|
var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
|
|
2393
|
-
var
|
|
2394
|
-
var
|
|
2395
|
-
var
|
|
2396
|
-
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;
|
|
2397
1481
|
var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
|
|
2398
1482
|
var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
|
|
2399
1483
|
var RESOLVER_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
|
|
2400
1484
|
var HIBERNATED_REVOCATION_ALARM_INTERVAL_MS_DEFAULT = 3e4;
|
|
1485
|
+
var HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT = 3e4;
|
|
2401
1486
|
var TRACE_ID_HEADER = "x-claxedo-trace-id";
|
|
2402
1487
|
var TRACE_FORCE_HEADER = "x-claxedo-relay-trace";
|
|
2403
|
-
function
|
|
2404
|
-
|
|
2405
|
-
|
|
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";
|
|
2406
1493
|
}
|
|
2407
1494
|
function clampRate(input) {
|
|
2408
1495
|
if (input === void 0 || !Number.isFinite(input)) return 0;
|
|
@@ -2411,8 +1498,8 @@ function clampRate(input) {
|
|
|
2411
1498
|
return input;
|
|
2412
1499
|
}
|
|
2413
1500
|
function traceRequest(request, routeKind, options) {
|
|
2414
|
-
const id =
|
|
2415
|
-
const forceSecret =
|
|
1501
|
+
const id = trimToUndefined(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
|
|
1502
|
+
const forceSecret = trimToUndefined(options.traceForceHeaderSecret);
|
|
2416
1503
|
const forced = !!forceSecret && request.headers.get(TRACE_FORCE_HEADER) === forceSecret;
|
|
2417
1504
|
const rate = clampRate(options.traceSampleRate);
|
|
2418
1505
|
return {
|
|
@@ -2432,36 +1519,36 @@ async function traceSpan(trace, name, fn) {
|
|
|
2432
1519
|
}
|
|
2433
1520
|
}
|
|
2434
1521
|
function tracedRequest(request, trace) {
|
|
2435
|
-
const
|
|
2436
|
-
|
|
2437
|
-
if (trace.sampled)
|
|
2438
|
-
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");
|
|
2439
1526
|
return new Request(request.url, {
|
|
2440
1527
|
method: request.method,
|
|
2441
|
-
headers
|
|
1528
|
+
headers,
|
|
2442
1529
|
body: request.method === "GET" || request.method === "HEAD" ? void 0 : request.body,
|
|
2443
1530
|
redirect: request.redirect,
|
|
2444
1531
|
duplex: "half"
|
|
2445
1532
|
});
|
|
2446
1533
|
}
|
|
2447
|
-
function
|
|
1534
|
+
function roundedMs2(value) {
|
|
2448
1535
|
return Math.round(value * 100) / 100;
|
|
2449
1536
|
}
|
|
2450
1537
|
function serverTiming(trace) {
|
|
2451
|
-
return trace.phases.map((phase) => `${phase.name};dur=${
|
|
1538
|
+
return trace.phases.map((phase) => `${phase.name};dur=${roundedMs2(phase.ms)}`).join(", ");
|
|
2452
1539
|
}
|
|
2453
1540
|
function withTraceHeaders(response, trace) {
|
|
2454
|
-
const
|
|
2455
|
-
|
|
1541
|
+
const headers = new Headers(response.headers);
|
|
1542
|
+
headers.set(TRACE_ID_HEADER, trace.id);
|
|
2456
1543
|
const timing = trace.sampled ? serverTiming(trace) : "";
|
|
2457
1544
|
if (timing) {
|
|
2458
|
-
const existing =
|
|
2459
|
-
|
|
1545
|
+
const existing = headers.get("server-timing");
|
|
1546
|
+
headers.set("server-timing", existing ? `${existing}, ${timing}` : timing);
|
|
2460
1547
|
}
|
|
2461
1548
|
return new Response(response.body, {
|
|
2462
1549
|
status: response.status,
|
|
2463
1550
|
statusText: response.statusText,
|
|
2464
|
-
headers
|
|
1551
|
+
headers
|
|
2465
1552
|
});
|
|
2466
1553
|
}
|
|
2467
1554
|
function logTrace(options, request, workspaceId, trace, response) {
|
|
@@ -2476,8 +1563,8 @@ function logTrace(options, request, workspaceId, trace, response) {
|
|
|
2476
1563
|
method: request.method,
|
|
2477
1564
|
path: url.pathname,
|
|
2478
1565
|
status: response.status,
|
|
2479
|
-
totalMs:
|
|
2480
|
-
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)]))
|
|
2481
1568
|
};
|
|
2482
1569
|
if (options.traceLog) {
|
|
2483
1570
|
options.traceLog(event);
|
|
@@ -2488,57 +1575,54 @@ function logTrace(options, request, workspaceId, trace, response) {
|
|
|
2488
1575
|
function workspaceIdFromRelayPath(pathname) {
|
|
2489
1576
|
return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
2490
1577
|
}
|
|
2491
|
-
function
|
|
1578
|
+
function hostIdFromTunnelPath(pathname) {
|
|
2492
1579
|
return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
2493
1580
|
}
|
|
2494
|
-
function
|
|
1581
|
+
function websocketRequest(request) {
|
|
2495
1582
|
return request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
2496
1583
|
}
|
|
2497
|
-
function
|
|
1584
|
+
function relayWebSocketTraceEnabled(request) {
|
|
2498
1585
|
return request.headers.get("x-claxedo-relay-ws-trace") === "1";
|
|
2499
1586
|
}
|
|
2500
1587
|
function workspaceIdsFromSearch(url) {
|
|
2501
1588
|
return [...new Set([
|
|
2502
1589
|
...url.searchParams.getAll("workspaceId"),
|
|
2503
1590
|
...url.searchParams.getAll("workspace_id")
|
|
2504
|
-
].map(
|
|
1591
|
+
].map(trimToUndefined).filter((item) => !!item))];
|
|
2505
1592
|
}
|
|
2506
1593
|
function randomToken() {
|
|
2507
1594
|
return crypto.randomUUID();
|
|
2508
1595
|
}
|
|
2509
|
-
function headerRecord(
|
|
1596
|
+
function headerRecord(headers) {
|
|
2510
1597
|
const result = {};
|
|
2511
|
-
|
|
1598
|
+
headers.forEach((value, key) => {
|
|
2512
1599
|
result[key] = value;
|
|
2513
1600
|
});
|
|
2514
1601
|
return result;
|
|
2515
1602
|
}
|
|
2516
1603
|
function headersFromRecord(input) {
|
|
2517
|
-
const
|
|
2518
|
-
for (const [key, value] of Object.entries(input))
|
|
2519
|
-
return
|
|
1604
|
+
const headers = new Headers();
|
|
1605
|
+
for (const [key, value] of Object.entries(input)) headers.set(key, value);
|
|
1606
|
+
return headers;
|
|
2520
1607
|
}
|
|
2521
1608
|
var BASE64_CHUNK_BYTES = 32768;
|
|
2522
1609
|
function bytesToBase64(bytes) {
|
|
2523
1610
|
let binary = "";
|
|
2524
1611
|
for (let i = 0; i < bytes.length; i += BASE64_CHUNK_BYTES) {
|
|
2525
|
-
binary += String.fromCharCode.
|
|
2526
|
-
null,
|
|
2527
|
-
bytes.subarray(i, i + BASE64_CHUNK_BYTES)
|
|
2528
|
-
);
|
|
1612
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_BYTES));
|
|
2529
1613
|
}
|
|
2530
1614
|
return btoa(binary);
|
|
2531
1615
|
}
|
|
2532
1616
|
function base64ToBytes(input) {
|
|
2533
1617
|
return Uint8Array.from(atob(input), (char) => char.charCodeAt(0));
|
|
2534
1618
|
}
|
|
2535
|
-
function
|
|
1619
|
+
function preOpenFrameBytes(input) {
|
|
2536
1620
|
if (typeof input === "string") return new TextEncoder().encode(input).byteLength;
|
|
2537
1621
|
return input.byteLength;
|
|
2538
1622
|
}
|
|
2539
|
-
function
|
|
2540
|
-
if (!input || typeof input !== "object") return
|
|
2541
|
-
return typeof input.arrayBuffer === "function"
|
|
1623
|
+
function isBlobLike(input) {
|
|
1624
|
+
if (!input || typeof input !== "object") return false;
|
|
1625
|
+
return "arrayBuffer" in input && typeof input.arrayBuffer === "function";
|
|
2542
1626
|
}
|
|
2543
1627
|
function viewBytes(input) {
|
|
2544
1628
|
return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
|
@@ -2568,9 +1652,8 @@ function socketFrame(input) {
|
|
|
2568
1652
|
data_base64: bytesToBase64(viewBytes(input))
|
|
2569
1653
|
};
|
|
2570
1654
|
}
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
return blob.arrayBuffer().then((buffer) => ({
|
|
1655
|
+
if (!isBlobLike(input)) return void 0;
|
|
1656
|
+
return input.arrayBuffer().then((buffer) => ({
|
|
2574
1657
|
binary: true,
|
|
2575
1658
|
data_base64: bytesToBase64(new Uint8Array(buffer))
|
|
2576
1659
|
}));
|
|
@@ -2580,9 +1663,8 @@ function socketPayload(input) {
|
|
|
2580
1663
|
if (input instanceof ArrayBuffer) return input;
|
|
2581
1664
|
if (input instanceof Uint8Array) return input;
|
|
2582
1665
|
if (ArrayBuffer.isView(input)) return viewBytes(input);
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
return blob.arrayBuffer();
|
|
1666
|
+
if (!isBlobLike(input)) return void 0;
|
|
1667
|
+
return input.arrayBuffer();
|
|
2586
1668
|
}
|
|
2587
1669
|
function orderedFrameDelivery(deliver) {
|
|
2588
1670
|
let tail;
|
|
@@ -2648,7 +1730,7 @@ async function readRequestBodyCapped(request, maxBytes) {
|
|
|
2648
1730
|
}
|
|
2649
1731
|
return concatBytes(chunks);
|
|
2650
1732
|
}
|
|
2651
|
-
function
|
|
1733
|
+
function safeCloseCode(code, fallback = 1011) {
|
|
2652
1734
|
if (code === void 0) return fallback;
|
|
2653
1735
|
if (code >= 1e3 && code <= 4999 && ![1004, 1005, 1006].includes(code)) return code;
|
|
2654
1736
|
return fallback;
|
|
@@ -2658,15 +1740,19 @@ function workspaceRelayDurableObjectRoomName(workspaceId) {
|
|
|
2658
1740
|
}
|
|
2659
1741
|
function workspaceRelayDurableObjectWorkspaceId(request) {
|
|
2660
1742
|
const url = new URL(request.url);
|
|
2661
|
-
const workspaceId =
|
|
1743
|
+
const workspaceId = trimToUndefined(workspaceIdFromRelayPath(url.pathname));
|
|
2662
1744
|
if (workspaceId) return decodeURIComponent(workspaceId);
|
|
2663
|
-
if (!
|
|
2664
|
-
return
|
|
1745
|
+
if (!hostIdFromTunnelPath(url.pathname)) return void 0;
|
|
1746
|
+
return trimToUndefined(url.searchParams.get("workspaceId")) ?? trimToUndefined(url.searchParams.get("workspace_id"));
|
|
2665
1747
|
}
|
|
2666
1748
|
function namespaceFromEnv(env, options) {
|
|
2667
1749
|
if (options.namespace) return options.namespace;
|
|
2668
1750
|
const binding = env[options.bindingName ?? "WORKSPACE_RELAY_ROOM"];
|
|
2669
|
-
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";
|
|
2670
1756
|
}
|
|
2671
1757
|
function json(code, message, status) {
|
|
2672
1758
|
return Response.json(errorBody(code, message), { status });
|
|
@@ -2681,32 +1767,31 @@ function setWorkspaceRelayAllowedOrigins(raw) {
|
|
|
2681
1767
|
const patterns = parseAllowedOrigins(raw);
|
|
2682
1768
|
baseOriginAllowed = patterns ? createOriginMatcher(patterns) : defaultOriginAllowed;
|
|
2683
1769
|
}
|
|
2684
|
-
function
|
|
2685
|
-
if (!origin) return;
|
|
2686
|
-
|
|
2687
|
-
if (configuredAppOriginAllowed(origin)) return origin;
|
|
1770
|
+
function allowedCorsOrigin(origin) {
|
|
1771
|
+
if (!origin) return void 0;
|
|
1772
|
+
return baseOriginAllowed(origin) || configuredAppOriginAllowed(origin) ? origin : void 0;
|
|
2688
1773
|
}
|
|
2689
1774
|
function corsHeaders(request) {
|
|
2690
|
-
const origin =
|
|
2691
|
-
if (!origin) return;
|
|
1775
|
+
const origin = allowedCorsOrigin(request.headers.get("origin"));
|
|
1776
|
+
if (!origin) return void 0;
|
|
2692
1777
|
return {
|
|
2693
1778
|
"access-control-allow-origin": origin,
|
|
2694
1779
|
"access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
2695
|
-
"access-control-allow-headers":
|
|
1780
|
+
"access-control-allow-headers": RELAY_ALLOWED_REQUEST_HEADERS,
|
|
2696
1781
|
"access-control-max-age": "86400",
|
|
2697
1782
|
vary: "Origin"
|
|
2698
1783
|
};
|
|
2699
1784
|
}
|
|
2700
1785
|
function corsPreflight(request) {
|
|
2701
|
-
const
|
|
2702
|
-
return new Response(null, { status:
|
|
1786
|
+
const headers = corsHeaders(request);
|
|
1787
|
+
return new Response(null, { status: headers ? 204 : 403, ...headers ? { headers } : {} });
|
|
2703
1788
|
}
|
|
2704
1789
|
function withCors(request, response) {
|
|
2705
|
-
const
|
|
2706
|
-
if (!
|
|
2707
|
-
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;
|
|
2708
1793
|
const next = new Headers(response.headers);
|
|
2709
|
-
for (const [key, value] of Object.entries(
|
|
1794
|
+
for (const [key, value] of Object.entries(headers)) next.set(key, value);
|
|
2710
1795
|
return new Response(response.body, {
|
|
2711
1796
|
status: response.status,
|
|
2712
1797
|
statusText: response.statusText,
|
|
@@ -2714,7 +1799,7 @@ function withCors(request, response) {
|
|
|
2714
1799
|
});
|
|
2715
1800
|
}
|
|
2716
1801
|
function defaultWebSocketPair() {
|
|
2717
|
-
const pair = new
|
|
1802
|
+
const pair = new WebSocketPair();
|
|
2718
1803
|
return {
|
|
2719
1804
|
client: pair[0],
|
|
2720
1805
|
server: pair[1]
|
|
@@ -2722,7 +1807,7 @@ function defaultWebSocketPair() {
|
|
|
2722
1807
|
}
|
|
2723
1808
|
function selectedSubprotocol(request) {
|
|
2724
1809
|
const offered = request.headers.get("sec-websocket-protocol")?.split(",").map((item) => item.trim()).filter(Boolean);
|
|
2725
|
-
if (!offered?.length) return;
|
|
1810
|
+
if (!offered?.length) return void 0;
|
|
2726
1811
|
return offered.find((item) => item.startsWith("claxedo-rat.")) ?? offered[0];
|
|
2727
1812
|
}
|
|
2728
1813
|
function upgradeResponse(socket, request) {
|
|
@@ -2749,7 +1834,7 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
|
|
|
2749
1834
|
return {
|
|
2750
1835
|
async fetch(request, env = {}) {
|
|
2751
1836
|
const gatewayStartedAt = performance.now();
|
|
2752
|
-
const gatewayTraceId =
|
|
1837
|
+
const gatewayTraceId = trimToUndefined(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
|
|
2753
1838
|
const url = new URL(request.url);
|
|
2754
1839
|
if (request.method === "OPTIONS" && (url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/"))) {
|
|
2755
1840
|
return corsPreflight(request);
|
|
@@ -2798,26 +1883,26 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
|
|
|
2798
1883
|
phases: []
|
|
2799
1884
|
}));
|
|
2800
1885
|
const fetchFinishedAt = performance.now();
|
|
2801
|
-
if (response.status === 101 || response.webSocket) {
|
|
1886
|
+
if (response.status === 101 || "webSocket" in response && response.webSocket) {
|
|
2802
1887
|
return url.pathname.startsWith("/workspaces/") ? withCors(request, response) : response;
|
|
2803
1888
|
}
|
|
2804
|
-
const
|
|
2805
|
-
|
|
2806
|
-
|
|
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);
|
|
2807
1892
|
const gatewayResponseStartedAt = performance.now();
|
|
2808
1893
|
const tracedResponse = new Response(response.body, {
|
|
2809
1894
|
status: response.status,
|
|
2810
1895
|
statusText: response.statusText,
|
|
2811
|
-
headers
|
|
1896
|
+
headers
|
|
2812
1897
|
});
|
|
2813
1898
|
const gatewayFinishedAt = performance.now();
|
|
2814
1899
|
const roomTiming = tracedResponse.headers.get("server-timing");
|
|
2815
1900
|
if (roomTiming) {
|
|
2816
1901
|
const gatewayTiming = [
|
|
2817
|
-
`relay-gateway-route;dur=${
|
|
2818
|
-
`relay-gateway-do-fetch;dur=${
|
|
2819
|
-
`relay-gateway-response;dur=${
|
|
2820
|
-
`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)}`
|
|
2821
1906
|
].join(", ");
|
|
2822
1907
|
tracedResponse.headers.set("server-timing", `${roomTiming}, ${gatewayTiming}`);
|
|
2823
1908
|
}
|
|
@@ -2867,6 +1952,8 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2867
1952
|
}
|
|
2868
1953
|
};
|
|
2869
1954
|
const cleanupTunnelResources = (tunnel, reason) => {
|
|
1955
|
+
if (tunnel.generationWatcher) clearInterval(tunnel.generationWatcher);
|
|
1956
|
+
tunnel.generationWatcher = void 0;
|
|
2870
1957
|
for (const pending of tunnel.pending.values()) {
|
|
2871
1958
|
clearTimeout(pending.timeout);
|
|
2872
1959
|
if (pending.stream?.slowConsumerTimeout) clearTimeout(pending.stream.slowConsumerTimeout);
|
|
@@ -2903,6 +1990,9 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2903
1990
|
hostId: attachment.hostId,
|
|
2904
1991
|
workspaceIds: attachment.workspaceIds,
|
|
2905
1992
|
connectedAt: attachment.connectedAt,
|
|
1993
|
+
...attachment.enrollmentId ? { enrollmentId: attachment.enrollmentId } : {},
|
|
1994
|
+
...attachment.generation !== void 0 ? { generation: attachment.generation } : {},
|
|
1995
|
+
generationCheckFailures: attachment.generationCheckFailures ?? 0,
|
|
2906
1996
|
socket,
|
|
2907
1997
|
pending: /* @__PURE__ */ new Map(),
|
|
2908
1998
|
channels: /* @__PURE__ */ new Map()
|
|
@@ -2953,7 +2043,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2953
2043
|
const resolverUnreachable = (code) => code === "relay_revocation_resolver_unavailable";
|
|
2954
2044
|
const watchRuntimeAccessToken = (client, onInactive) => {
|
|
2955
2045
|
const intervalMs = options.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
|
|
2956
|
-
if (intervalMs <= 0) return;
|
|
2046
|
+
if (intervalMs <= 0) return void 0;
|
|
2957
2047
|
const now = options.now ?? Date.now;
|
|
2958
2048
|
let consecutiveUnreachable = 0;
|
|
2959
2049
|
const timer = setInterval(() => {
|
|
@@ -2983,14 +2073,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2983
2073
|
onInactive(active.reason);
|
|
2984
2074
|
}).catch(() => tolerate("Runtime Access Token active check failed"));
|
|
2985
2075
|
}, intervalMs);
|
|
2986
|
-
|
|
2987
|
-
timer.unref();
|
|
2988
|
-
}
|
|
2076
|
+
unrefTimer(timer);
|
|
2989
2077
|
return timer;
|
|
2990
2078
|
};
|
|
2991
2079
|
const watchUserHostedTarget = (client, onInactive) => {
|
|
2992
2080
|
const intervalMs = options.workspaceTargetActiveCheckIntervalMs ?? WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
|
|
2993
|
-
if (intervalMs <= 0) return;
|
|
2081
|
+
if (intervalMs <= 0) return void 0;
|
|
2994
2082
|
const target = client.request.target;
|
|
2995
2083
|
let consecutiveUnreachable = 0;
|
|
2996
2084
|
const timer = setInterval(() => {
|
|
@@ -3009,11 +2097,54 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3009
2097
|
onInactive("Workspace relay target active check failed");
|
|
3010
2098
|
});
|
|
3011
2099
|
}, intervalMs);
|
|
3012
|
-
|
|
3013
|
-
timer.unref();
|
|
3014
|
-
}
|
|
2100
|
+
unrefTimer(timer);
|
|
3015
2101
|
return timer;
|
|
3016
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
|
+
};
|
|
3017
2148
|
const clearSlowConsumerTimer = (pending) => {
|
|
3018
2149
|
if (!pending.stream?.slowConsumerTimeout) return;
|
|
3019
2150
|
clearTimeout(pending.stream.slowConsumerTimeout);
|
|
@@ -3028,7 +2159,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3028
2159
|
pending.stream.flowPaused = paused;
|
|
3029
2160
|
tunnel.socket.send?.(JSON.stringify({
|
|
3030
2161
|
type: "http.response.flow",
|
|
3031
|
-
protocol:
|
|
2162
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3032
2163
|
request_id: requestId,
|
|
3033
2164
|
paused,
|
|
3034
2165
|
reason
|
|
@@ -3049,7 +2180,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3049
2180
|
if (pending.stream?.slowConsumerTimeout) return;
|
|
3050
2181
|
pending.stream.slowConsumerTimeout = setTimeout(
|
|
3051
2182
|
() => failSlowConsumer(tunnel, requestId, pending),
|
|
3052
|
-
options.slowConsumerTimeoutMs ??
|
|
2183
|
+
options.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
|
|
3053
2184
|
);
|
|
3054
2185
|
};
|
|
3055
2186
|
const drainPendingStreamChunks = (tunnel, requestId, pending) => {
|
|
@@ -3103,7 +2234,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3103
2234
|
failPendingTunnelResponse(tunnel, oldestId, oldest, new Error("stream_evicted: tunnel started-stream cap reached"));
|
|
3104
2235
|
}
|
|
3105
2236
|
};
|
|
3106
|
-
const resolvePendingTunnelStream = (tunnel, requestId, pending, status,
|
|
2237
|
+
const resolvePendingTunnelStream = (tunnel, requestId, pending, status, headers) => {
|
|
3107
2238
|
if (pending.stream) return;
|
|
3108
2239
|
pending.startedSeq = ++startedSeqCounter;
|
|
3109
2240
|
evictOldestStartedStream(tunnel, requestId);
|
|
@@ -3127,18 +2258,18 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3127
2258
|
}
|
|
3128
2259
|
tunnel.socket.send?.(JSON.stringify({
|
|
3129
2260
|
type: "http.response.flow",
|
|
3130
|
-
protocol:
|
|
2261
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3131
2262
|
request_id: requestId,
|
|
3132
2263
|
paused: false,
|
|
3133
2264
|
reason: "closed"
|
|
3134
2265
|
}));
|
|
3135
2266
|
}
|
|
3136
2267
|
}, new ByteLengthQueuingStrategy({
|
|
3137
|
-
highWaterMark: options.slowConsumerHighWaterMarkBytes ??
|
|
2268
|
+
highWaterMark: options.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT
|
|
3138
2269
|
}));
|
|
3139
2270
|
pending.resolve(new Response(stream, {
|
|
3140
2271
|
status,
|
|
3141
|
-
headers
|
|
2272
|
+
headers
|
|
3142
2273
|
}));
|
|
3143
2274
|
};
|
|
3144
2275
|
const failPendingTunnelResponse = (tunnel, requestId, pending, error) => {
|
|
@@ -3195,7 +2326,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3195
2326
|
closeSocket(client.socket, code, reason);
|
|
3196
2327
|
sendSocket(tunnel.socket, JSON.stringify({
|
|
3197
2328
|
type: "ws.close",
|
|
3198
|
-
protocol:
|
|
2329
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3199
2330
|
channel_id: channelId,
|
|
3200
2331
|
code,
|
|
3201
2332
|
reason
|
|
@@ -3210,7 +2341,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3210
2341
|
const scheduleHibernatedRevocationCheck = async () => {
|
|
3211
2342
|
const intervalMs = hibernatedRevocationIntervalMs();
|
|
3212
2343
|
if (!hibernation || !options.alarms || intervalMs <= 0) return;
|
|
3213
|
-
if (clients.size === 0) return;
|
|
2344
|
+
if (clients.size === 0 && ![...hostTunnels.values()].some(fenced)) return;
|
|
3214
2345
|
const now = options.now ?? Date.now;
|
|
3215
2346
|
const at = now() + intervalMs;
|
|
3216
2347
|
try {
|
|
@@ -3224,16 +2355,17 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3224
2355
|
ensureHibernatedSocketsRebuilt();
|
|
3225
2356
|
const now = options.now ?? Date.now;
|
|
3226
2357
|
const revoke = (client, reason) => {
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
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
|
+
}
|
|
3232
2364
|
}
|
|
3233
2365
|
closeSocket(client.socket, 1008, reason);
|
|
3234
2366
|
clients.delete(client);
|
|
3235
2367
|
};
|
|
3236
|
-
for (const client of
|
|
2368
|
+
for (const client of Array.from(clients)) {
|
|
3237
2369
|
if (client.request.claims.exp * 1e3 <= now()) {
|
|
3238
2370
|
revoke(client, "Runtime Access Token expired");
|
|
3239
2371
|
continue;
|
|
@@ -3248,6 +2380,9 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3248
2380
|
if (active.active || resolverUnreachable(active.code)) continue;
|
|
3249
2381
|
revoke(client, active.reason);
|
|
3250
2382
|
}
|
|
2383
|
+
for (const tunnel of Array.from(hostTunnels.values())) {
|
|
2384
|
+
await recheckHostGeneration(tunnel);
|
|
2385
|
+
}
|
|
3251
2386
|
await scheduleHibernatedRevocationCheck();
|
|
3252
2387
|
};
|
|
3253
2388
|
const activeUserHostedClient = (channelId, socket) => {
|
|
@@ -3255,6 +2390,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3255
2390
|
const client = tunnel.channels.get(channelId);
|
|
3256
2391
|
if (client?.socket === socket) return { tunnel, client };
|
|
3257
2392
|
}
|
|
2393
|
+
return void 0;
|
|
3258
2394
|
};
|
|
3259
2395
|
const handleUserHostedClientMessage = async (channelId, socket, data) => {
|
|
3260
2396
|
const active = activeUserHostedClient(channelId, socket);
|
|
@@ -3272,41 +2408,57 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3272
2408
|
if (!frame) return;
|
|
3273
2409
|
const sent = sendSocket(active.tunnel.socket, JSON.stringify({
|
|
3274
2410
|
type: "ws.frame",
|
|
3275
|
-
protocol:
|
|
2411
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3276
2412
|
channel_id: channelId,
|
|
3277
2413
|
...frame
|
|
3278
2414
|
}));
|
|
3279
2415
|
if (!sent) closeSocket(socket, 1011, "User-hosted tunnel unavailable");
|
|
3280
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
|
+
};
|
|
3281
2449
|
const handleTunnelMessage = async (hostId, event) => {
|
|
3282
2450
|
const row = parseTunnelMessageData(event.data);
|
|
3283
2451
|
if (!row.ok) return;
|
|
3284
|
-
const parsed =
|
|
2452
|
+
const parsed = validateTunnelMessage(row.value);
|
|
3285
2453
|
const tunnel = hostTunnels.get(hostId);
|
|
3286
2454
|
if (!parsed.ok || !tunnel) return;
|
|
3287
2455
|
directory.recordPong(tunnel.hostId);
|
|
3288
2456
|
if (parsed.message.type === "ping") {
|
|
3289
|
-
tunnel.socket.send?.(JSON.stringify(
|
|
2457
|
+
tunnel.socket.send?.(JSON.stringify(makeTunnelPong(parsed.message)));
|
|
3290
2458
|
return;
|
|
3291
2459
|
}
|
|
3292
2460
|
if (parsed.message.type === "host.registration.update") {
|
|
3293
|
-
|
|
3294
|
-
const workspaceIds = [...new Set(update.workspace_ids)];
|
|
3295
|
-
try {
|
|
3296
|
-
await verifyHostTunnelToken(update.token, options.runtimeAccessKey, { hostId, workspaceIds });
|
|
3297
|
-
} catch {
|
|
3298
|
-
closeSocket(tunnel.socket, 1008, "Host tunnel registration update denied");
|
|
3299
|
-
return;
|
|
3300
|
-
}
|
|
3301
|
-
if (hostTunnels.get(hostId) !== tunnel) return;
|
|
3302
|
-
tunnel.workspaceIds = workspaceIds;
|
|
3303
|
-
tunnel.socket.serializeAttachment?.({
|
|
3304
|
-
kind: "host-tunnel",
|
|
3305
|
-
hostId,
|
|
3306
|
-
workspaceIds,
|
|
3307
|
-
connectedAt: tunnel.connectedAt
|
|
3308
|
-
});
|
|
3309
|
-
directory.registerHostTunnel({ hostId, workspaceIds });
|
|
2461
|
+
await applyRegistrationUpdate(tunnel, [...new Set(parsed.message.workspace_ids)], parsed.message.token);
|
|
3310
2462
|
return;
|
|
3311
2463
|
}
|
|
3312
2464
|
if (parsed.message.type === "error" && parsed.message.request_id) {
|
|
@@ -3387,7 +2539,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3387
2539
|
if (!channel) return;
|
|
3388
2540
|
tunnel.channels.delete(message.channel_id);
|
|
3389
2541
|
clients.delete(channel);
|
|
3390
|
-
closeSocket(channel.socket,
|
|
2542
|
+
closeSocket(channel.socket, safeCloseCode(message.code), message.reason ?? "");
|
|
3391
2543
|
return;
|
|
3392
2544
|
}
|
|
3393
2545
|
if (parsed.message.type === "error" && parsed.message.channel_id) {
|
|
@@ -3423,17 +2575,17 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3423
2575
|
clients.delete(active);
|
|
3424
2576
|
sendSocket(tunnel.socket, JSON.stringify({
|
|
3425
2577
|
type: "ws.close",
|
|
3426
|
-
protocol:
|
|
2578
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3427
2579
|
channel_id: channelId,
|
|
3428
|
-
code:
|
|
2580
|
+
code: safeCloseCode(event?.code, 1e3),
|
|
3429
2581
|
reason: event?.reason ?? "Client WebSocket closed"
|
|
3430
2582
|
}));
|
|
3431
2583
|
return;
|
|
3432
2584
|
}
|
|
3433
2585
|
};
|
|
3434
2586
|
const admitHostTunnel = async (request, roomWorkspaceId, url) => {
|
|
3435
|
-
if (!
|
|
3436
|
-
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));
|
|
3437
2589
|
if (!hostId) return json("host_tunnel_host_required", "Host tunnel id is required", 400);
|
|
3438
2590
|
const workspaceIds = workspaceIdsFromSearch(url);
|
|
3439
2591
|
if (!workspaceIds.includes(roomWorkspaceId)) {
|
|
@@ -3441,35 +2593,45 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3441
2593
|
}
|
|
3442
2594
|
const token = bearerToken(request.headers.get("authorization"));
|
|
3443
2595
|
if (!token) return json("host_tunnel_token_required", "Host Tunnel Token is required", 401);
|
|
2596
|
+
let claims;
|
|
3444
2597
|
try {
|
|
3445
|
-
await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
|
|
2598
|
+
claims = await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
|
|
3446
2599
|
} catch (err) {
|
|
3447
2600
|
if (err instanceof WorkspaceRelayAuthError) {
|
|
3448
2601
|
return json(err.code, "Host Tunnel Token was denied", 403);
|
|
3449
2602
|
}
|
|
3450
2603
|
throw err;
|
|
3451
2604
|
}
|
|
3452
|
-
const
|
|
3453
|
-
|
|
3454
|
-
kind: "host-tunnel",
|
|
3455
|
-
hostId,
|
|
3456
|
-
workspaceIds,
|
|
3457
|
-
connectedAt
|
|
3458
|
-
});
|
|
2605
|
+
const decision = await checkHostTunnelGeneration(options.resolveHostGeneration, claims);
|
|
2606
|
+
if (!decision.ok) return json(decision.code, decision.reason, decision.retryable ? 503 : 403);
|
|
3459
2607
|
const previous = hostTunnels.get(hostId);
|
|
3460
|
-
if (previous) {
|
|
3461
|
-
|
|
3462
|
-
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);
|
|
3463
2610
|
}
|
|
3464
|
-
|
|
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 = {
|
|
3465
2618
|
hostId,
|
|
3466
2619
|
workspaceIds,
|
|
3467
2620
|
connectedAt,
|
|
2621
|
+
...fence,
|
|
2622
|
+
generationCheckFailures: 0,
|
|
3468
2623
|
socket: pair.server,
|
|
3469
2624
|
pending: /* @__PURE__ */ new Map(),
|
|
3470
2625
|
channels: /* @__PURE__ */ new Map()
|
|
3471
|
-
}
|
|
2626
|
+
};
|
|
2627
|
+
if (previous) {
|
|
2628
|
+
cleanupTunnelResources(previous, "Host tunnel replaced");
|
|
2629
|
+
closeSocket(previous.socket, 1012, "Host tunnel replaced");
|
|
2630
|
+
}
|
|
2631
|
+
hostTunnels.set(hostId, tunnel);
|
|
3472
2632
|
directory.registerHostTunnel({ hostId, workspaceIds });
|
|
2633
|
+
watchHostGeneration(tunnel);
|
|
2634
|
+
void scheduleHibernatedRevocationCheck();
|
|
3473
2635
|
if (!hibernation) {
|
|
3474
2636
|
pair.server.addEventListener?.("message", (event) => {
|
|
3475
2637
|
if (!event || event.data === void 0) return;
|
|
@@ -3481,14 +2643,14 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3481
2643
|
return upgradeResponse(pair.client, request);
|
|
3482
2644
|
};
|
|
3483
2645
|
const admitClient = async (request, workspaceId) => {
|
|
3484
|
-
if (!
|
|
2646
|
+
if (!websocketRequest(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
|
|
3485
2647
|
const authorized = await authorizeWorkspaceRelayRequest(relayOptions, request, workspaceId);
|
|
3486
2648
|
if (!authorized.ok) return authorized.response;
|
|
3487
|
-
if (authorized.request.target
|
|
2649
|
+
if (isUserHostedTarget(authorized.request.target)) return admitUserHostedClient(request, authorized.request);
|
|
3488
2650
|
return await admitCloudClient(request, authorized.request);
|
|
3489
2651
|
};
|
|
3490
2652
|
const admitCloudClient = async (request, authorized) => {
|
|
3491
|
-
const trace =
|
|
2653
|
+
const trace = relayWebSocketTraceEnabled(request) ? { upstreamStartedAt: performance.now(), queueTimes: [], emitted: false } : void 0;
|
|
3492
2654
|
let upstream;
|
|
3493
2655
|
try {
|
|
3494
2656
|
upstream = await connectWebSocket(
|
|
@@ -3522,9 +2684,9 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3522
2684
|
trace.emitted = true;
|
|
3523
2685
|
sendSocket(pair.server, JSON.stringify({
|
|
3524
2686
|
type: "relay.trace",
|
|
3525
|
-
wsUpstreamOpenMs:
|
|
2687
|
+
wsUpstreamOpenMs: roundedMs2(openedAt - trace.upstreamStartedAt),
|
|
3526
2688
|
queuedFrames: trace.queueTimes.length,
|
|
3527
|
-
maxQueuedDelayMs:
|
|
2689
|
+
maxQueuedDelayMs: roundedMs2(trace.queueTimes.reduce((max, at) => Math.max(max, openedAt - at), 0))
|
|
3528
2690
|
}));
|
|
3529
2691
|
};
|
|
3530
2692
|
const activeTokenTimer = watchRuntimeAccessToken(client, (reason) => {
|
|
@@ -3535,14 +2697,14 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3535
2697
|
closed = true;
|
|
3536
2698
|
if (activeTokenTimer) clearInterval(activeTokenTimer);
|
|
3537
2699
|
clients.delete(client);
|
|
3538
|
-
closeSocket(upstream,
|
|
2700
|
+
closeSocket(upstream, safeCloseCode(event?.code, 1e3), event?.reason ?? "Client WebSocket closed");
|
|
3539
2701
|
};
|
|
3540
2702
|
const cleanupUpstream = (event) => {
|
|
3541
2703
|
if (closed) return;
|
|
3542
2704
|
closed = true;
|
|
3543
2705
|
if (activeTokenTimer) clearInterval(activeTokenTimer);
|
|
3544
2706
|
clients.delete(client);
|
|
3545
|
-
closeSocket(pair.server,
|
|
2707
|
+
closeSocket(pair.server, safeCloseCode(event?.code), event?.reason ?? "");
|
|
3546
2708
|
};
|
|
3547
2709
|
const flushQueue = () => {
|
|
3548
2710
|
upstreamOpen = true;
|
|
@@ -3557,8 +2719,8 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3557
2719
|
};
|
|
3558
2720
|
const deliverClientFrame = orderedFrameDelivery((payload) => {
|
|
3559
2721
|
if (!upstreamOpen) {
|
|
3560
|
-
const frameBytes =
|
|
3561
|
-
if (queue.length >= (options.upstreamWebSocketPreOpenQueueMaxFrames ??
|
|
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)) {
|
|
3562
2724
|
closeSocket(pair.server, 1011, "Upstream WebSocket queue limit exceeded");
|
|
3563
2725
|
cleanupClient({ code: 1011, reason: "Upstream WebSocket queue limit exceeded" });
|
|
3564
2726
|
return;
|
|
@@ -3631,7 +2793,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3631
2793
|
}
|
|
3632
2794
|
const opened = sendSocket(tunnel.socket, JSON.stringify({
|
|
3633
2795
|
type: "ws.open",
|
|
3634
|
-
protocol:
|
|
2796
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3635
2797
|
channel_id: channelId,
|
|
3636
2798
|
workspace_id: authorized.target.workspaceId,
|
|
3637
2799
|
path: authorized.path,
|
|
@@ -3659,7 +2821,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3659
2821
|
})
|
|
3660
2822
|
);
|
|
3661
2823
|
if (!authorized.ok) return authorized.response;
|
|
3662
|
-
if (authorized.request.target
|
|
2824
|
+
if (isUserHostedTarget(authorized.request.target)) {
|
|
3663
2825
|
trace && (trace.routeKind = "user-hosted-http");
|
|
3664
2826
|
return forwardUserHostedHttp(request, authorized.request, trace);
|
|
3665
2827
|
}
|
|
@@ -3709,7 +2871,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3709
2871
|
const init = workspaceRelayForwardRequestInit(request, authorized.relayHostToken, authorized.target.workspaceId, {
|
|
3710
2872
|
userHosted: true
|
|
3711
2873
|
});
|
|
3712
|
-
const bodyMaxBytes = options.tunnelRequestBodyMaxBytes ??
|
|
2874
|
+
const bodyMaxBytes = options.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT;
|
|
3713
2875
|
const contentLength = Number(request.headers.get("content-length") ?? Number.NaN);
|
|
3714
2876
|
if (Number.isFinite(contentLength) && contentLength > bodyMaxBytes) {
|
|
3715
2877
|
return json("request_body_too_large", "User-hosted request body exceeds the relay limit", 413);
|
|
@@ -3724,7 +2886,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3724
2886
|
tunnel.pending.delete(requestId);
|
|
3725
2887
|
tunnel.socket.send?.(JSON.stringify({
|
|
3726
2888
|
type: "http.response.flow",
|
|
3727
|
-
protocol:
|
|
2889
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3728
2890
|
request_id: requestId,
|
|
3729
2891
|
paused: false,
|
|
3730
2892
|
reason: "closed"
|
|
@@ -3741,7 +2903,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3741
2903
|
});
|
|
3742
2904
|
tunnel.socket.send?.(JSON.stringify({
|
|
3743
2905
|
type: "http.request",
|
|
3744
|
-
protocol:
|
|
2906
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
3745
2907
|
request_id: requestId,
|
|
3746
2908
|
workspace_id: authorized.target.workspaceId,
|
|
3747
2909
|
method: request.method,
|
|
@@ -3788,7 +2950,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3788
2950
|
if (!roomWorkspaceId) return json("relay_workspace_required", "Workspace Relay requests must include a workspace id", 400);
|
|
3789
2951
|
if (url.pathname.startsWith("/host-tunnels/")) return admitHostTunnel(request, roomWorkspaceId, url);
|
|
3790
2952
|
if (url.pathname.startsWith("/workspaces/")) {
|
|
3791
|
-
if (
|
|
2953
|
+
if (websocketRequest(request)) return admitClient(request, roomWorkspaceId);
|
|
3792
2954
|
const trace = traceRequest(request, "workspace-http", options);
|
|
3793
2955
|
const roomTotalStartedAt = performance.now();
|
|
3794
2956
|
const nextRequest = tracedRequest(request, trace);
|
|
@@ -3816,7 +2978,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3816
2978
|
},
|
|
3817
2979
|
/**
|
|
3818
2980
|
* Durable Object alarm entry point. Runs the hibernation-safe revocation
|
|
3819
|
-
* sweep
|
|
2981
|
+
* sweep. The Worker must forward `alarm()` here.
|
|
3820
2982
|
*/
|
|
3821
2983
|
async alarm() {
|
|
3822
2984
|
try {
|
|
@@ -3860,37 +3022,45 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3860
3022
|
export {
|
|
3861
3023
|
DEFAULT_RELAY_APP_ORIGINS,
|
|
3862
3024
|
DEFAULT_RELAY_LOCATION_HINT,
|
|
3025
|
+
RELAY_ALLOWED_REQUEST_HEADERS,
|
|
3026
|
+
RELAY_ALLOWED_REQUEST_HEADER_LIST,
|
|
3863
3027
|
RELAY_LOCATION_HINTS,
|
|
3864
3028
|
RELAY_REGION_HEADER,
|
|
3865
3029
|
WorkspaceRelayAuthError,
|
|
3866
|
-
__slowConsumerInternalsForTest,
|
|
3867
3030
|
authorizeWorkspaceRelayRequest,
|
|
3868
3031
|
bytesToBase64,
|
|
3032
|
+
checkHostTunnelGeneration,
|
|
3033
|
+
createCachedHostGenerationClient,
|
|
3869
3034
|
createCachedRevocationClient,
|
|
3870
3035
|
createCachedTargetClient,
|
|
3036
|
+
createHostGenerationResolverLookup,
|
|
3871
3037
|
createOriginMatcher,
|
|
3872
3038
|
createWorkspaceRelay,
|
|
3873
|
-
createWorkspaceRelayBun,
|
|
3874
3039
|
createWorkspaceRelayDirectory,
|
|
3875
3040
|
createWorkspaceRelayDurableObjectGateway,
|
|
3876
3041
|
createWorkspaceRelayDurableObjectRoom,
|
|
3877
3042
|
createWorkspaceRelayTrace,
|
|
3043
|
+
deriveRelayHostKid,
|
|
3044
|
+
deriveRelayHostPublicKey,
|
|
3878
3045
|
disposeAuditSampler,
|
|
3879
3046
|
disposeWorkspaceRelayDirectory,
|
|
3880
3047
|
forwardWorkspaceRelayRequest,
|
|
3881
3048
|
getAuditSuppressedCount,
|
|
3049
|
+
hostTunnelIncumbentOutranks,
|
|
3882
3050
|
hostTunnelTokenAudience,
|
|
3051
|
+
isHostGeneration,
|
|
3883
3052
|
isRelayClaimPair,
|
|
3884
3053
|
mintHostTunnelToken,
|
|
3885
3054
|
mintRelayHostToken,
|
|
3886
3055
|
mintRuntimeAccessToken,
|
|
3887
3056
|
parseAllowedOrigins,
|
|
3888
|
-
|
|
3057
|
+
parseHostGenerationResult,
|
|
3058
|
+
parseRuntimeAccessTokenActiveResult,
|
|
3059
|
+
parseWorkspaceRelayTarget,
|
|
3889
3060
|
relayFrameDiagnostics,
|
|
3890
3061
|
relayHostTokenAudience,
|
|
3891
3062
|
relayHostTokenIssuer,
|
|
3892
3063
|
relayLocationHint,
|
|
3893
|
-
relayOverBackpressureLimit,
|
|
3894
3064
|
relayRequestCountry,
|
|
3895
3065
|
relayRequestRegion,
|
|
3896
3066
|
runtimeAccessTokenAudience,
|