@claxedo/workspace-relay 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -223
- package/dist/auth.d.ts +94 -35
- package/dist/cloudflare.d.ts +15 -45
- package/dist/cors-origins.d.ts +2 -0
- package/dist/directory.d.ts +5 -38
- package/dist/gateway-placement.d.ts +7 -0
- package/dist/host-tunnel-failure.d.ts +7 -0
- package/dist/host-tunnel-forwarding.d.ts +29 -0
- package/dist/http.d.ts +4 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.mjs +901 -835
- package/dist/relay-audit.d.ts +4 -0
- package/dist/relay-cors.d.ts +12 -0
- package/dist/relay-location.d.ts +93 -0
- package/dist/relay-target.d.ts +25 -0
- package/dist/resolver-clients.d.ts +116 -0
- package/dist/server.d.ts +27 -286
- package/dist/session-host-forwarding.d.ts +22 -0
- package/dist/test-support/unfenced-host-token.d.ts +6 -0
- package/dist/worker.d.ts +3 -3
- package/package.json +6 -25
- package/dist/bun.d.ts +0 -225
- package/dist/bun.mjs +0 -2492
- package/dist/main.d.ts +0 -255
- package/dist/main.mjs +0 -23713
- package/dist/synthetic.d.ts +0 -60
- package/dist/upstream-websocket.d.ts +0 -34
- package/dist/user-hosted-forwarding.d.ts +0 -40
package/dist/bun.mjs
DELETED
|
@@ -1,2492 +0,0 @@
|
|
|
1
|
-
// src/bun.ts
|
|
2
|
-
import {
|
|
3
|
-
makeTunnelPing,
|
|
4
|
-
makeTunnelPong,
|
|
5
|
-
TUNNEL_PROTOCOL_VERSION,
|
|
6
|
-
validateTunnelMessage
|
|
7
|
-
} from "@claxedo/workspace-relay-protocol";
|
|
8
|
-
|
|
9
|
-
// src/server.ts
|
|
10
|
-
import { Hono } from "hono";
|
|
11
|
-
import { cors } from "hono/cors";
|
|
12
|
-
import { exportJWK as exportJWK2 } from "jose";
|
|
13
|
-
|
|
14
|
-
// ../claxedo-helpers/dist/guards.mjs
|
|
15
|
-
function isFiniteNumber(value) {
|
|
16
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
17
|
-
}
|
|
18
|
-
function asFiniteNumber(value) {
|
|
19
|
-
return isFiniteNumber(value) ? value : void 0;
|
|
20
|
-
}
|
|
21
|
-
function numberClaim(payload, key) {
|
|
22
|
-
return asFiniteNumber(payload[key]);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// src/auth.ts
|
|
26
|
-
import { SignJWT, errors, exportJWK, importJWK, jwtVerify } from "jose";
|
|
27
|
-
var algorithms = ["EdDSA", "ES256", "RS256"];
|
|
28
|
-
var runtimeAccessTokenIssuer = "claxedo-control-plane";
|
|
29
|
-
var runtimeAccessTokenAudience = "workspace-relay";
|
|
30
|
-
var hostTunnelTokenAudience = "workspace-relay-host-tunnel";
|
|
31
|
-
var relayHostTokenIssuer = "workspace-relay";
|
|
32
|
-
var relayHostTokenAudience = "workspace-host-service";
|
|
33
|
-
var WorkspaceRelayAuthError = class extends Error {
|
|
34
|
-
constructor(code, message) {
|
|
35
|
-
super(message);
|
|
36
|
-
this.code = code;
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
function jti() {
|
|
40
|
-
return crypto.randomUUID();
|
|
41
|
-
}
|
|
42
|
-
function seconds(input = Date.now()) {
|
|
43
|
-
return Math.floor(input / 1e3);
|
|
44
|
-
}
|
|
45
|
-
function requireAlgorithm(input) {
|
|
46
|
-
const algorithm = algorithms.find((candidate) => candidate === input);
|
|
47
|
-
if (algorithm) return algorithm;
|
|
48
|
-
throw new WorkspaceRelayAuthError("invalid_relay_token", "Unsupported relay token algorithm");
|
|
49
|
-
}
|
|
50
|
-
function stringClaim(payload, key) {
|
|
51
|
-
const value = payload[key];
|
|
52
|
-
return typeof value === "string" && value.trim() ? value : void 0;
|
|
53
|
-
}
|
|
54
|
-
function stringArrayClaim(payload, key) {
|
|
55
|
-
const value = payload[key];
|
|
56
|
-
return Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim()) ? value : void 0;
|
|
57
|
-
}
|
|
58
|
-
function isHostGeneration(value) {
|
|
59
|
-
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
60
|
-
}
|
|
61
|
-
function hostGenerationClaims(payload) {
|
|
62
|
-
const enrollment_id = stringClaim(payload, "enrollment_id");
|
|
63
|
-
const generation = payload.generation;
|
|
64
|
-
if (generation === void 0) return enrollment_id ? { enrollment_id } : {};
|
|
65
|
-
if (!isHostGeneration(generation) || !enrollment_id) return null;
|
|
66
|
-
return { enrollment_id, generation };
|
|
67
|
-
}
|
|
68
|
-
function roleClaim(payload) {
|
|
69
|
-
const value = stringClaim(payload, "role");
|
|
70
|
-
return value === "viewer" || value === "editor" || value === "admin" || value === "owner" ? value : void 0;
|
|
71
|
-
}
|
|
72
|
-
function actorProfileClaims(payload) {
|
|
73
|
-
const actor_public_id = stringClaim(payload, "actor_public_id");
|
|
74
|
-
const actor_name = stringClaim(payload, "actor_name");
|
|
75
|
-
const actor_avatar_url = stringClaim(payload, "actor_avatar_url");
|
|
76
|
-
if (!actor_public_id && !actor_name && !actor_avatar_url) return {};
|
|
77
|
-
if (!actor_public_id || !actor_name) return void 0;
|
|
78
|
-
return { actor_public_id, actor_name, ...actor_avatar_url ? { actor_avatar_url } : {} };
|
|
79
|
-
}
|
|
80
|
-
function actorProfilePayload(input) {
|
|
81
|
-
if (!input.actorPublicId && !input.actorName && !input.actorAvatarUrl) return {};
|
|
82
|
-
if (!input.actorPublicId || !input.actorName) {
|
|
83
|
-
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Actor display profile claims are incomplete");
|
|
84
|
-
}
|
|
85
|
-
return {
|
|
86
|
-
actor_public_id: input.actorPublicId,
|
|
87
|
-
actor_name: input.actorName,
|
|
88
|
-
...input.actorAvatarUrl ? { actor_avatar_url: input.actorAvatarUrl } : {}
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
function isRelayClaimPair(input) {
|
|
92
|
-
return input.access === "cloud" && input.backing === "cloud-vm" || input.access === "user-hosted" && input.backing === "local-worktree";
|
|
93
|
-
}
|
|
94
|
-
function checkHostTunnelTarget(payload, expected) {
|
|
95
|
-
if (stringClaim(payload, "host_id") !== expected.hostId) {
|
|
96
|
-
throw new WorkspaceRelayAuthError("relay_token_host_mismatch", "Relay token host does not match request");
|
|
97
|
-
}
|
|
98
|
-
const workspaceIds = stringArrayClaim(payload, "workspace_ids") ?? [];
|
|
99
|
-
if (expected.workspaceIds.some((workspaceId) => !workspaceIds.includes(workspaceId))) {
|
|
100
|
-
throw new WorkspaceRelayAuthError("relay_token_workspace_mismatch", "Relay token workspace does not match request");
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
function checkTarget(payload, expected) {
|
|
104
|
-
if (stringClaim(payload, "workspace_id") !== expected.workspaceId) {
|
|
105
|
-
throw new WorkspaceRelayAuthError("relay_token_workspace_mismatch", "Relay token workspace does not match request");
|
|
106
|
-
}
|
|
107
|
-
if (expected.hostId && stringClaim(payload, "host_id") !== expected.hostId) {
|
|
108
|
-
throw new WorkspaceRelayAuthError("relay_token_host_mismatch", "Relay token host does not match request");
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
async function verifyJwt(token, key, input) {
|
|
112
|
-
try {
|
|
113
|
-
if (typeof key === "function") {
|
|
114
|
-
return await jwtVerify(token, key, {
|
|
115
|
-
issuer: input.issuer,
|
|
116
|
-
audience: input.audience,
|
|
117
|
-
algorithms: [...algorithms]
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
return await jwtVerify(token, key, {
|
|
121
|
-
issuer: input.issuer,
|
|
122
|
-
audience: input.audience,
|
|
123
|
-
algorithms: [...algorithms]
|
|
124
|
-
});
|
|
125
|
-
} catch (err) {
|
|
126
|
-
if (err instanceof errors.JWKSTimeout) throw err;
|
|
127
|
-
if (err instanceof errors.JOSEError) {
|
|
128
|
-
throw new WorkspaceRelayAuthError("invalid_relay_token", "Relay token is invalid");
|
|
129
|
-
}
|
|
130
|
-
throw err;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
async function verifyRuntimeAccessToken(token, key, expected) {
|
|
134
|
-
const result = await verifyJwt(token, key, {
|
|
135
|
-
issuer: runtimeAccessTokenIssuer,
|
|
136
|
-
audience: runtimeAccessTokenAudience
|
|
137
|
-
});
|
|
138
|
-
checkTarget(result.payload, expected);
|
|
139
|
-
const claims = runtimeClaims(result.payload);
|
|
140
|
-
if (!claims) {
|
|
141
|
-
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token claims are incomplete");
|
|
142
|
-
}
|
|
143
|
-
return claims;
|
|
144
|
-
}
|
|
145
|
-
function validateRuntimeAccessTokenClaims(input, expected) {
|
|
146
|
-
const payload = input;
|
|
147
|
-
if (stringClaim(payload, "iss") !== runtimeAccessTokenIssuer || stringClaim(payload, "aud") !== runtimeAccessTokenAudience) {
|
|
148
|
-
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token issuer or audience is invalid");
|
|
149
|
-
}
|
|
150
|
-
checkTarget(payload, expected);
|
|
151
|
-
const claims = runtimeClaims(payload);
|
|
152
|
-
if (!claims) {
|
|
153
|
-
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token claims are incomplete");
|
|
154
|
-
}
|
|
155
|
-
return claims;
|
|
156
|
-
}
|
|
157
|
-
async function verifyHostTunnelToken(token, key, expected) {
|
|
158
|
-
const result = await verifyJwt(token, key, {
|
|
159
|
-
issuer: runtimeAccessTokenIssuer,
|
|
160
|
-
audience: hostTunnelTokenAudience
|
|
161
|
-
});
|
|
162
|
-
checkHostTunnelTarget(result.payload, expected);
|
|
163
|
-
const claims = hostTunnelClaims(result.payload);
|
|
164
|
-
if (!claims) {
|
|
165
|
-
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Host Tunnel Token claims are incomplete");
|
|
166
|
-
}
|
|
167
|
-
return claims;
|
|
168
|
-
}
|
|
169
|
-
async function mintRelayHostToken(input, key, alg) {
|
|
170
|
-
if (!isRelayClaimPair(input)) {
|
|
171
|
-
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Relay Host Token access/backing claims are inconsistent");
|
|
172
|
-
}
|
|
173
|
-
const now = seconds(input.now);
|
|
174
|
-
const protectedHeader = { alg: requireAlgorithm(alg) };
|
|
175
|
-
if (input.kid) protectedHeader.kid = input.kid;
|
|
176
|
-
return await new SignJWT({
|
|
177
|
-
principal_kind: input.principalKind,
|
|
178
|
-
actor_id: input.actorId,
|
|
179
|
-
actor_kind: input.actorKind,
|
|
180
|
-
...actorProfilePayload(input),
|
|
181
|
-
org_id: input.orgId,
|
|
182
|
-
workspace_id: input.workspaceId,
|
|
183
|
-
host_id: input.hostId,
|
|
184
|
-
role: input.role,
|
|
185
|
-
access: input.access,
|
|
186
|
-
backing: input.backing,
|
|
187
|
-
parent_jti: input.parentJti
|
|
188
|
-
}).setProtectedHeader(protectedHeader).setIssuer(relayHostTokenIssuer).setAudience(relayHostTokenAudience).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 60)).setJti(input.jti ?? jti()).sign(key);
|
|
189
|
-
}
|
|
190
|
-
function runtimeClaims(payload) {
|
|
191
|
-
const exp = numberClaim(payload, "exp");
|
|
192
|
-
const iat = numberClaim(payload, "iat");
|
|
193
|
-
const principal_kind = stringClaim(payload, "principal_kind");
|
|
194
|
-
const actor_id = stringClaim(payload, "actor_id");
|
|
195
|
-
const actor_kind = stringClaim(payload, "actor_kind");
|
|
196
|
-
const jti2 = stringClaim(payload, "jti");
|
|
197
|
-
const org_id = stringClaim(payload, "org_id");
|
|
198
|
-
const workspace_id = stringClaim(payload, "workspace_id");
|
|
199
|
-
const host_id = stringClaim(payload, "host_id");
|
|
200
|
-
const role = roleClaim(payload);
|
|
201
|
-
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;
|
|
202
|
-
const actorProfile = actorProfileClaims(payload);
|
|
203
|
-
if (!actorProfile) return void 0;
|
|
204
|
-
return {
|
|
205
|
-
iss: runtimeAccessTokenIssuer,
|
|
206
|
-
aud: runtimeAccessTokenAudience,
|
|
207
|
-
principal_kind,
|
|
208
|
-
actor_id,
|
|
209
|
-
actor_kind,
|
|
210
|
-
...actorProfile,
|
|
211
|
-
org_id,
|
|
212
|
-
workspace_id,
|
|
213
|
-
host_id,
|
|
214
|
-
role,
|
|
215
|
-
exp,
|
|
216
|
-
iat,
|
|
217
|
-
jti: jti2
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
function hostTunnelClaims(payload) {
|
|
221
|
-
const exp = numberClaim(payload, "exp");
|
|
222
|
-
const iat = numberClaim(payload, "iat");
|
|
223
|
-
const sub = stringClaim(payload, "sub");
|
|
224
|
-
const jti2 = stringClaim(payload, "jti");
|
|
225
|
-
const host_id = stringClaim(payload, "host_id");
|
|
226
|
-
const workspace_ids = stringArrayClaim(payload, "workspace_ids");
|
|
227
|
-
const fence = hostGenerationClaims(payload);
|
|
228
|
-
if (!exp || !iat || !sub || !jti2 || !host_id || !workspace_ids?.length || fence === null) return void 0;
|
|
229
|
-
return {
|
|
230
|
-
iss: runtimeAccessTokenIssuer,
|
|
231
|
-
aud: hostTunnelTokenAudience,
|
|
232
|
-
sub,
|
|
233
|
-
host_id,
|
|
234
|
-
workspace_ids,
|
|
235
|
-
...fence,
|
|
236
|
-
exp,
|
|
237
|
-
iat,
|
|
238
|
-
jti: jti2
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// src/cors-origins.ts
|
|
243
|
-
var DEFAULT_RELAY_APP_ORIGINS = [
|
|
244
|
-
"http://localhost:*",
|
|
245
|
-
"http://127.0.0.1:*",
|
|
246
|
-
"https://claxedo.com",
|
|
247
|
-
"https://*.claxedo.com"
|
|
248
|
-
];
|
|
249
|
-
function createOriginMatcher(patterns) {
|
|
250
|
-
const exact = /* @__PURE__ */ new Set();
|
|
251
|
-
const httpsSuffixes = [];
|
|
252
|
-
const httpPortPrefixes = [];
|
|
253
|
-
for (const pattern of patterns) {
|
|
254
|
-
if (pattern.startsWith("https://*.")) {
|
|
255
|
-
httpsSuffixes.push(pattern.slice("https://*".length));
|
|
256
|
-
} else if (pattern.startsWith("http://") && pattern.endsWith(":*")) {
|
|
257
|
-
httpPortPrefixes.push(pattern.slice(0, -1));
|
|
258
|
-
} else {
|
|
259
|
-
exact.add(pattern);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
return (origin) => {
|
|
263
|
-
if (exact.has(origin)) return true;
|
|
264
|
-
if (origin.startsWith("https://")) {
|
|
265
|
-
return httpsSuffixes.some(
|
|
266
|
-
(suffix) => origin.endsWith(suffix) && origin.length > "https://".length + suffix.length
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
if (origin.startsWith("http://")) {
|
|
270
|
-
return httpPortPrefixes.some(
|
|
271
|
-
(prefix) => origin.startsWith(prefix) && /^\d+$/.test(origin.slice(prefix.length))
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
return false;
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// src/http.ts
|
|
279
|
-
import { bearerToken } from "@claxedo/helpers/string";
|
|
280
|
-
function errorBody(code, message) {
|
|
281
|
-
return {
|
|
282
|
-
error: {
|
|
283
|
-
code,
|
|
284
|
-
message
|
|
285
|
-
}
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// src/user-hosted-forwarding.ts
|
|
290
|
-
function isUserHostedTarget(target) {
|
|
291
|
-
return target.access === "user-hosted";
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// src/server.ts
|
|
295
|
-
function hostTunnelIncumbentOutranks(incumbentGeneration, candidateGeneration) {
|
|
296
|
-
if (incumbentGeneration === void 0) return false;
|
|
297
|
-
return candidateGeneration === void 0 || incumbentGeneration > candidateGeneration;
|
|
298
|
-
}
|
|
299
|
-
var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
|
|
300
|
-
var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
|
|
301
|
-
var RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 1e4;
|
|
302
|
-
var relayHostTokenCaches = /* @__PURE__ */ new WeakMap();
|
|
303
|
-
var runtimeAccessTokenCaches = /* @__PURE__ */ new WeakMap();
|
|
304
|
-
async function checkHostTunnelGeneration(lookup, claims) {
|
|
305
|
-
if (claims.generation === void 0 || !claims.enrollment_id) return { ok: true };
|
|
306
|
-
if (!lookup) {
|
|
307
|
-
return {
|
|
308
|
-
ok: false,
|
|
309
|
-
retryable: false,
|
|
310
|
-
code: "host_generation_unverifiable",
|
|
311
|
-
reason: "Host tunnel generation cannot be verified by a relay without a host-generation resolver"
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
let result;
|
|
315
|
-
try {
|
|
316
|
-
result = await lookup({ enrollmentId: claims.enrollment_id, generation: claims.generation });
|
|
317
|
-
} catch (err) {
|
|
318
|
-
return {
|
|
319
|
-
ok: false,
|
|
320
|
-
retryable: true,
|
|
321
|
-
code: "host_generation_lookup_unavailable",
|
|
322
|
-
reason: `Host generation lookup is unavailable: ${err instanceof Error ? err.message : String(err)}`
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
if (!result) {
|
|
326
|
-
return { ok: false, retryable: false, code: "host_enrollment_unknown", reason: "Host enrollment is unknown" };
|
|
327
|
-
}
|
|
328
|
-
if (result.revoked) {
|
|
329
|
-
return { ok: false, retryable: false, code: "host_enrollment_revoked", reason: "Host enrollment was revoked" };
|
|
330
|
-
}
|
|
331
|
-
if (result.generation > claims.generation) {
|
|
332
|
-
return { ok: false, retryable: false, code: "host_generation_superseded", reason: "Host tunnel generation was superseded" };
|
|
333
|
-
}
|
|
334
|
-
if (result.generation < claims.generation) {
|
|
335
|
-
return { ok: false, retryable: false, code: "host_generation_unknown", reason: "Host tunnel generation is ahead of the control plane" };
|
|
336
|
-
}
|
|
337
|
-
return { ok: true };
|
|
338
|
-
}
|
|
339
|
-
function protocolToken(header) {
|
|
340
|
-
return header?.split(",").map((item) => item.trim()).find((item) => item.startsWith("claxedo-rat."))?.slice("claxedo-rat.".length);
|
|
341
|
-
}
|
|
342
|
-
function runtimeAccessToken(request) {
|
|
343
|
-
return bearerToken(request.headers.get("authorization") ?? void 0) ?? protocolToken(request.headers.get("sec-websocket-protocol") ?? void 0);
|
|
344
|
-
}
|
|
345
|
-
var DANGEROUS_INBOUND_HEADERS = [
|
|
346
|
-
"x-forwarded-for",
|
|
347
|
-
"x-forwarded-host",
|
|
348
|
-
"x-forwarded-proto",
|
|
349
|
-
"x-real-ip"
|
|
350
|
-
];
|
|
351
|
-
var HOP_BY_HOP_HEADERS = [
|
|
352
|
-
"connection",
|
|
353
|
-
"keep-alive",
|
|
354
|
-
"proxy-authenticate",
|
|
355
|
-
"proxy-authorization",
|
|
356
|
-
"te",
|
|
357
|
-
"trailer",
|
|
358
|
-
"transfer-encoding",
|
|
359
|
-
"upgrade"
|
|
360
|
-
];
|
|
361
|
-
var DANGEROUS_INBOUND_HEADER_PATTERNS = [
|
|
362
|
-
/^x-claxedo-internal-/i,
|
|
363
|
-
/^x-supervisor-/i
|
|
364
|
-
];
|
|
365
|
-
function isDangerousInboundHeader(name) {
|
|
366
|
-
const lower = name.toLowerCase();
|
|
367
|
-
if (DANGEROUS_INBOUND_HEADERS.includes(lower)) return true;
|
|
368
|
-
if (HOP_BY_HOP_HEADERS.includes(lower)) return true;
|
|
369
|
-
for (const pattern of DANGEROUS_INBOUND_HEADER_PATTERNS) {
|
|
370
|
-
if (pattern.test(lower)) return true;
|
|
371
|
-
}
|
|
372
|
-
return false;
|
|
373
|
-
}
|
|
374
|
-
function forwardHeaders(input, relayHostToken, workspaceId, options = {}) {
|
|
375
|
-
const headers2 = new Headers(input);
|
|
376
|
-
headers2.delete("host");
|
|
377
|
-
const inboundNames = /* @__PURE__ */ new Set();
|
|
378
|
-
headers2.forEach((_value, name) => inboundNames.add(name));
|
|
379
|
-
for (const name of inboundNames) {
|
|
380
|
-
if (isDangerousInboundHeader(name)) headers2.delete(name);
|
|
381
|
-
}
|
|
382
|
-
if (options.userHosted) headers2.delete("cookie");
|
|
383
|
-
headers2.set("accept-encoding", "identity");
|
|
384
|
-
headers2.delete("authorization");
|
|
385
|
-
headers2.delete("Authorization");
|
|
386
|
-
headers2.set("Authorization", `Bearer ${relayHostToken}`);
|
|
387
|
-
headers2.set("x-workspace-id", workspaceId);
|
|
388
|
-
headers2.set("X-Daytona-Skip-Preview-Warning", "true");
|
|
389
|
-
headers2.set("X-Daytona-Skip-Last-Activity-Update", "true");
|
|
390
|
-
for (const [name, value] of Object.entries(options.upstreamHeaders ?? {})) {
|
|
391
|
-
const trimmed = value.trim();
|
|
392
|
-
if (trimmed) headers2.set(name, trimmed);
|
|
393
|
-
}
|
|
394
|
-
headers2.set("x-forwarded-by", "workspace-relay");
|
|
395
|
-
return headers2;
|
|
396
|
-
}
|
|
397
|
-
function targetUrl(target, path, search) {
|
|
398
|
-
const url = new URL(path.replace(/^\/+/, ""), target.baseUrl.endsWith("/") ? target.baseUrl : `${target.baseUrl}/`);
|
|
399
|
-
url.search = search;
|
|
400
|
-
return url;
|
|
401
|
-
}
|
|
402
|
-
var RELAY_VIEWER_DENIED_PATH = /^\/api\/wr\/pty(?:\/|$)/;
|
|
403
|
-
function relayRuntimePath(request) {
|
|
404
|
-
try {
|
|
405
|
-
const decoded2 = decodeURIComponent(new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/")).replace(/^\/+/, "/");
|
|
406
|
-
return new URL(decoded2.replace(/^\/+/, ""), "http://relay.invalid/").pathname;
|
|
407
|
-
} catch {
|
|
408
|
-
return void 0;
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
function roleAllowsRelayRequest(role, method, path) {
|
|
412
|
-
if (role === "owner" || role === "admin" || role === "editor") return true;
|
|
413
|
-
if (role === "viewer") {
|
|
414
|
-
if (RELAY_VIEWER_DENIED_PATH.test(path)) return false;
|
|
415
|
-
return method === "GET" || method === "HEAD" || method === "OPTIONS";
|
|
416
|
-
}
|
|
417
|
-
return false;
|
|
418
|
-
}
|
|
419
|
-
function relayHostTokenCacheTtlMs(options, claims) {
|
|
420
|
-
const configured = options.relayHostTokenCacheTtlMs ?? 3e4;
|
|
421
|
-
if (!Number.isFinite(configured) || configured <= 0) return 0;
|
|
422
|
-
const tokenRemaining = claims.exp * 1e3 - Date.now() - 5e3;
|
|
423
|
-
return Math.max(0, Math.min(configured, tokenRemaining));
|
|
424
|
-
}
|
|
425
|
-
function relayHostTokenCacheKey(options, claims, target) {
|
|
426
|
-
return [
|
|
427
|
-
claims.jti,
|
|
428
|
-
claims.principal_kind,
|
|
429
|
-
claims.actor_id,
|
|
430
|
-
claims.actor_kind,
|
|
431
|
-
claims.org_id,
|
|
432
|
-
claims.role,
|
|
433
|
-
claims.workspace_id,
|
|
434
|
-
claims.host_id,
|
|
435
|
-
target.access,
|
|
436
|
-
target.backing,
|
|
437
|
-
options.relayHostMintKid ?? ""
|
|
438
|
-
].join("\0");
|
|
439
|
-
}
|
|
440
|
-
function relayHostTokenCache(options) {
|
|
441
|
-
let cache = relayHostTokenCaches.get(options);
|
|
442
|
-
if (cache) return cache;
|
|
443
|
-
cache = /* @__PURE__ */ new Map();
|
|
444
|
-
relayHostTokenCaches.set(options, cache);
|
|
445
|
-
return cache;
|
|
446
|
-
}
|
|
447
|
-
function runtimeAccessTokenCache(options) {
|
|
448
|
-
let cache = runtimeAccessTokenCaches.get(options);
|
|
449
|
-
if (cache) return cache;
|
|
450
|
-
cache = /* @__PURE__ */ new Map();
|
|
451
|
-
runtimeAccessTokenCaches.set(options, cache);
|
|
452
|
-
return cache;
|
|
453
|
-
}
|
|
454
|
-
function runtimeAccessTokenCacheTtlMs(options, claims, now) {
|
|
455
|
-
const configured = options.runtimeAccessTokenCacheTtlMs ?? RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT;
|
|
456
|
-
if (!Number.isFinite(configured) || configured <= 0) return 0;
|
|
457
|
-
const tokenRemaining = claims.exp * 1e3 - now - 5e3;
|
|
458
|
-
return Math.max(0, Math.min(configured, tokenRemaining));
|
|
459
|
-
}
|
|
460
|
-
function pruneRuntimeAccessTokenCache(cache, now) {
|
|
461
|
-
for (const [key, entry] of cache) {
|
|
462
|
-
if (entry.expiresAt <= now) cache.delete(key);
|
|
463
|
-
}
|
|
464
|
-
while (cache.size >= RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES) {
|
|
465
|
-
const key = cache.keys().next().value;
|
|
466
|
-
if (!key) return;
|
|
467
|
-
cache.delete(key);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
async function verifyRuntimeAccessTokenForRequest(options, token, workspaceId) {
|
|
471
|
-
const verified = options.tokenVerifier ? await options.tokenVerifier.verify(token).then(
|
|
472
|
-
(result) => validateRuntimeAccessTokenClaims(result.claims, { workspaceId })
|
|
473
|
-
) : await verifyRuntimeAccessToken(token, options.runtimeAccessKey, { workspaceId });
|
|
474
|
-
return verified;
|
|
475
|
-
}
|
|
476
|
-
async function cachedRuntimeAccessTokenClaims(options, token, workspaceId) {
|
|
477
|
-
const configured = options.runtimeAccessTokenCacheTtlMs ?? RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT;
|
|
478
|
-
if (!Number.isFinite(configured) || configured <= 0) {
|
|
479
|
-
return await verifyRuntimeAccessTokenForRequest(options, token, workspaceId);
|
|
480
|
-
}
|
|
481
|
-
const now = Date.now();
|
|
482
|
-
const cache = runtimeAccessTokenCache(options);
|
|
483
|
-
const key = `${workspaceId}\0${token}`;
|
|
484
|
-
const cached = cache.get(key);
|
|
485
|
-
if (cached && cached.expiresAt > now) {
|
|
486
|
-
if (cached.claims) return cached.claims;
|
|
487
|
-
if (cached.promise) return await cached.promise;
|
|
488
|
-
}
|
|
489
|
-
let promise;
|
|
490
|
-
try {
|
|
491
|
-
promise = verifyRuntimeAccessTokenForRequest(options, token, workspaceId);
|
|
492
|
-
pruneRuntimeAccessTokenCache(cache, now);
|
|
493
|
-
cache.set(key, { promise, expiresAt: now + configured });
|
|
494
|
-
const claims = await promise;
|
|
495
|
-
const ttlMs = runtimeAccessTokenCacheTtlMs(options, claims, now);
|
|
496
|
-
if (ttlMs > 0) cache.set(key, { claims, expiresAt: now + ttlMs });
|
|
497
|
-
else cache.delete(key);
|
|
498
|
-
return claims;
|
|
499
|
-
} catch (err) {
|
|
500
|
-
if (promise && cache.get(key)?.promise === promise) cache.delete(key);
|
|
501
|
-
throw err;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
function pruneRelayHostTokenCache(cache, now) {
|
|
505
|
-
for (const [key, entry] of cache) {
|
|
506
|
-
if (entry.expiresAt <= now) cache.delete(key);
|
|
507
|
-
}
|
|
508
|
-
while (cache.size >= RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES) {
|
|
509
|
-
const key = cache.keys().next().value;
|
|
510
|
-
if (!key) return;
|
|
511
|
-
cache.delete(key);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
async function relayHostTokenFor(options, claims, target, trace) {
|
|
515
|
-
const ttlMs = relayHostTokenCacheTtlMs(options, claims);
|
|
516
|
-
if (ttlMs <= 0) {
|
|
517
|
-
return await uncachedRelayHostTokenFor(options, claims, target, trace);
|
|
518
|
-
}
|
|
519
|
-
const key = relayHostTokenCacheKey(options, claims, target);
|
|
520
|
-
const now = Date.now();
|
|
521
|
-
const cache = relayHostTokenCache(options);
|
|
522
|
-
const cached = cache.get(key);
|
|
523
|
-
if (cached && cached.expiresAt > now) {
|
|
524
|
-
if (cached.promise) {
|
|
525
|
-
return await trace.span("rht-cache", async () => await cached.promise);
|
|
526
|
-
}
|
|
527
|
-
await trace.span("rht-cache", async () => {
|
|
528
|
-
});
|
|
529
|
-
return cached.token;
|
|
530
|
-
}
|
|
531
|
-
let promise;
|
|
532
|
-
try {
|
|
533
|
-
const token = await trace.span("rht-mint", async () => {
|
|
534
|
-
promise = mintRelayHostToken({
|
|
535
|
-
principalKind: claims.principal_kind,
|
|
536
|
-
actorId: claims.actor_id,
|
|
537
|
-
actorKind: claims.actor_kind,
|
|
538
|
-
parentJti: claims.jti,
|
|
539
|
-
...claims.actor_public_id && claims.actor_name ? {
|
|
540
|
-
actorPublicId: claims.actor_public_id,
|
|
541
|
-
actorName: claims.actor_name,
|
|
542
|
-
...claims.actor_avatar_url ? { actorAvatarUrl: claims.actor_avatar_url } : {}
|
|
543
|
-
} : {},
|
|
544
|
-
orgId: claims.org_id,
|
|
545
|
-
role: claims.role,
|
|
546
|
-
...target,
|
|
547
|
-
...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
|
|
548
|
-
}, options.relayHostSigningKey, options.relayHostAlgorithm);
|
|
549
|
-
pruneRelayHostTokenCache(cache, now);
|
|
550
|
-
cache.set(key, { promise, expiresAt: now + ttlMs });
|
|
551
|
-
return await promise;
|
|
552
|
-
});
|
|
553
|
-
cache.set(key, {
|
|
554
|
-
token,
|
|
555
|
-
expiresAt: now + ttlMs
|
|
556
|
-
});
|
|
557
|
-
return token;
|
|
558
|
-
} catch (err) {
|
|
559
|
-
if (promise && cache.get(key)?.promise === promise) cache.delete(key);
|
|
560
|
-
throw err;
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
async function uncachedRelayHostTokenFor(options, claims, target, trace) {
|
|
564
|
-
return await trace.span(
|
|
565
|
-
"rht-mint",
|
|
566
|
-
async () => await mintRelayHostToken({
|
|
567
|
-
principalKind: claims.principal_kind,
|
|
568
|
-
actorId: claims.actor_id,
|
|
569
|
-
actorKind: claims.actor_kind,
|
|
570
|
-
parentJti: claims.jti,
|
|
571
|
-
...claims.actor_public_id && claims.actor_name ? {
|
|
572
|
-
actorPublicId: claims.actor_public_id,
|
|
573
|
-
actorName: claims.actor_name,
|
|
574
|
-
...claims.actor_avatar_url ? { actorAvatarUrl: claims.actor_avatar_url } : {}
|
|
575
|
-
} : {},
|
|
576
|
-
orgId: claims.org_id,
|
|
577
|
-
role: claims.role,
|
|
578
|
-
...target,
|
|
579
|
-
...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
|
|
580
|
-
}, options.relayHostSigningKey, options.relayHostAlgorithm)
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
|
-
function workspaceRelayTargetUrl(target, path, search) {
|
|
584
|
-
return targetUrl(target, path, search);
|
|
585
|
-
}
|
|
586
|
-
function workspaceRelayForwardHeaders(input, relayHostToken, workspaceId, options = {}) {
|
|
587
|
-
return forwardHeaders(input, relayHostToken, workspaceId, options);
|
|
588
|
-
}
|
|
589
|
-
function workspaceRelayForwardRequestInit(request, relayHostToken, workspaceId, options = {}) {
|
|
590
|
-
const init = {
|
|
591
|
-
method: request.method,
|
|
592
|
-
headers: forwardHeaders(request.headers, relayHostToken, workspaceId, options),
|
|
593
|
-
body: request.body,
|
|
594
|
-
duplex: "half",
|
|
595
|
-
redirect: "manual",
|
|
596
|
-
...options.signal ? { signal: options.signal } : {}
|
|
597
|
-
};
|
|
598
|
-
if (request.method === "GET" || request.method === "HEAD") delete init.body;
|
|
599
|
-
return init;
|
|
600
|
-
}
|
|
601
|
-
var auditSamplers = /* @__PURE__ */ new WeakMap();
|
|
602
|
-
function getOrCreateAuditSampler(options) {
|
|
603
|
-
let state = auditSamplers.get(options);
|
|
604
|
-
if (!state) {
|
|
605
|
-
const sampleRate = clampSampleRate(options.auditAcceptSampleRate);
|
|
606
|
-
state = {
|
|
607
|
-
suppressedCount: 0,
|
|
608
|
-
timer: void 0,
|
|
609
|
-
flushIntervalMs: options.auditFlushIntervalMs ?? 6e4,
|
|
610
|
-
random: options.random ?? Math.random,
|
|
611
|
-
sampleRate
|
|
612
|
-
};
|
|
613
|
-
auditSamplers.set(options, state);
|
|
614
|
-
}
|
|
615
|
-
return state;
|
|
616
|
-
}
|
|
617
|
-
function clampSampleRate(rate) {
|
|
618
|
-
if (rate === void 0) return 1;
|
|
619
|
-
if (!Number.isFinite(rate)) return 1;
|
|
620
|
-
if (rate < 0) return 0;
|
|
621
|
-
if (rate > 1) return 1;
|
|
622
|
-
return rate;
|
|
623
|
-
}
|
|
624
|
-
function ensureFlushTimer(options, state) {
|
|
625
|
-
if (state.timer) return;
|
|
626
|
-
if (state.sampleRate >= 1) return;
|
|
627
|
-
if (state.flushIntervalMs <= 0) return;
|
|
628
|
-
const timer = setInterval(() => {
|
|
629
|
-
flushSuppressedSummary(options, state);
|
|
630
|
-
}, state.flushIntervalMs);
|
|
631
|
-
if (typeof timer.unref === "function") {
|
|
632
|
-
timer.unref();
|
|
633
|
-
}
|
|
634
|
-
state.timer = timer;
|
|
635
|
-
}
|
|
636
|
-
function flushSuppressedSummary(options, state) {
|
|
637
|
-
const count = state.suppressedCount;
|
|
638
|
-
if (count <= 0) return;
|
|
639
|
-
state.suppressedCount = 0;
|
|
640
|
-
void options.audit?.({
|
|
641
|
-
action: "relay.request.suppressed_summary",
|
|
642
|
-
result: "allow",
|
|
643
|
-
method: "INTERNAL",
|
|
644
|
-
path: "/",
|
|
645
|
-
suppressedCount: count
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
function disposeAuditSampler(options) {
|
|
649
|
-
const state = auditSamplers.get(options);
|
|
650
|
-
if (!state) return;
|
|
651
|
-
if (state.timer) {
|
|
652
|
-
clearInterval(state.timer);
|
|
653
|
-
state.timer = void 0;
|
|
654
|
-
}
|
|
655
|
-
flushSuppressedSummary(options, state);
|
|
656
|
-
auditSamplers.delete(options);
|
|
657
|
-
}
|
|
658
|
-
function createWorkspaceRelayTrace() {
|
|
659
|
-
const phases = [];
|
|
660
|
-
return {
|
|
661
|
-
phases,
|
|
662
|
-
async span(name, run) {
|
|
663
|
-
const startedAt = performance.now();
|
|
664
|
-
try {
|
|
665
|
-
return await run();
|
|
666
|
-
} finally {
|
|
667
|
-
phases.push({ name, ms: performance.now() - startedAt });
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
};
|
|
671
|
-
}
|
|
672
|
-
function roundedMs(value) {
|
|
673
|
-
return Math.round(value * 100) / 100;
|
|
674
|
-
}
|
|
675
|
-
function workspaceRelayServerTiming(trace) {
|
|
676
|
-
return trace.phases.map((phase) => `${phase.name};dur=${roundedMs(phase.ms)}`).join(", ");
|
|
677
|
-
}
|
|
678
|
-
function workspaceRelayTimingResponse(response, trace) {
|
|
679
|
-
const value = workspaceRelayServerTiming(trace);
|
|
680
|
-
if (!value) return response;
|
|
681
|
-
const headers2 = new Headers(response.headers);
|
|
682
|
-
const prev = headers2.get("server-timing");
|
|
683
|
-
headers2.set("server-timing", prev ? `${prev}, ${value}` : value);
|
|
684
|
-
return new Response(response.body, {
|
|
685
|
-
status: response.status,
|
|
686
|
-
statusText: response.statusText,
|
|
687
|
-
headers: headers2
|
|
688
|
-
});
|
|
689
|
-
}
|
|
690
|
-
async function audit(options, event) {
|
|
691
|
-
if (event.action === "relay.request.accepted") {
|
|
692
|
-
const state = getOrCreateAuditSampler(options);
|
|
693
|
-
if (state.sampleRate < 1) {
|
|
694
|
-
ensureFlushTimer(options, state);
|
|
695
|
-
if (state.random() >= state.sampleRate) {
|
|
696
|
-
state.suppressedCount += 1;
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
await options.audit?.(event);
|
|
702
|
-
}
|
|
703
|
-
var defaultOriginMatcher = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
|
|
704
|
-
var originMatchers = /* @__PURE__ */ new WeakMap();
|
|
705
|
-
function originMatcherFor(options) {
|
|
706
|
-
if (!options.allowedOrigins) return defaultOriginMatcher;
|
|
707
|
-
let matcher = originMatchers.get(options);
|
|
708
|
-
if (!matcher) {
|
|
709
|
-
matcher = createOriginMatcher(options.allowedOrigins);
|
|
710
|
-
originMatchers.set(options, matcher);
|
|
711
|
-
}
|
|
712
|
-
return matcher;
|
|
713
|
-
}
|
|
714
|
-
var RELAY_ALLOWED_REQUEST_HEADER_LIST = [
|
|
715
|
-
"Accept",
|
|
716
|
-
"Authorization",
|
|
717
|
-
"Content-Type",
|
|
718
|
-
"Last-Event-ID",
|
|
719
|
-
"Traceparent",
|
|
720
|
-
"Tracestate",
|
|
721
|
-
"X-Fetch-Bypass-Throttle",
|
|
722
|
-
"X-Daytona-Skip-Preview-Warning",
|
|
723
|
-
"X-Workspace-Id",
|
|
724
|
-
"X-OpenCode-Directory",
|
|
725
|
-
"X-Claxedo-Runner",
|
|
726
|
-
"X-Claxedo-Model",
|
|
727
|
-
"X-Claxedo-Draft-Id",
|
|
728
|
-
"X-Claxedo-Binary"
|
|
729
|
-
];
|
|
730
|
-
var RELAY_ALLOWED_REQUEST_HEADERS = RELAY_ALLOWED_REQUEST_HEADER_LIST.join(", ");
|
|
731
|
-
function denyCorsHeaders(request, originAllowed = defaultOriginMatcher) {
|
|
732
|
-
const origin = request.headers.get("origin");
|
|
733
|
-
if (!origin) return void 0;
|
|
734
|
-
if (!originAllowed(origin)) return void 0;
|
|
735
|
-
return {
|
|
736
|
-
"access-control-allow-origin": origin,
|
|
737
|
-
"access-control-allow-headers": RELAY_ALLOWED_REQUEST_HEADERS,
|
|
738
|
-
"access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"
|
|
739
|
-
};
|
|
740
|
-
}
|
|
741
|
-
async function deny(options, input) {
|
|
742
|
-
const url = new URL(input.request.url);
|
|
743
|
-
await audit(options, {
|
|
744
|
-
action: "relay.request.denied",
|
|
745
|
-
result: "deny",
|
|
746
|
-
reason: input.code,
|
|
747
|
-
actorId: input.claims?.actor_id,
|
|
748
|
-
actorKind: input.claims?.actor_kind,
|
|
749
|
-
principalKind: input.claims?.principal_kind,
|
|
750
|
-
...input.claims ? { role: input.claims.role } : {},
|
|
751
|
-
workspaceId: input.claims?.workspace_id,
|
|
752
|
-
hostId: input.claims?.host_id,
|
|
753
|
-
method: input.request.method,
|
|
754
|
-
path: url.pathname
|
|
755
|
-
});
|
|
756
|
-
return Response.json(errorBody(input.code, input.message), {
|
|
757
|
-
status: input.status,
|
|
758
|
-
headers: denyCorsHeaders(input.request, originMatcherFor(options))
|
|
759
|
-
});
|
|
760
|
-
}
|
|
761
|
-
async function authorizeWorkspaceRelayRequest(options, request, workspaceId, trace) {
|
|
762
|
-
const token = runtimeAccessToken(request);
|
|
763
|
-
if (!token) {
|
|
764
|
-
return {
|
|
765
|
-
ok: false,
|
|
766
|
-
code: "runtime_access_token_required",
|
|
767
|
-
response: await deny(options, {
|
|
768
|
-
code: "runtime_access_token_required",
|
|
769
|
-
message: "Runtime Access Token is required",
|
|
770
|
-
status: 401,
|
|
771
|
-
request
|
|
772
|
-
})
|
|
773
|
-
};
|
|
774
|
-
}
|
|
775
|
-
try {
|
|
776
|
-
const span = (name, run) => trace ? trace.span(name, run) : run();
|
|
777
|
-
const claims = await span(
|
|
778
|
-
"rat-verify",
|
|
779
|
-
async () => await cachedRuntimeAccessTokenClaims(options, token, workspaceId)
|
|
780
|
-
);
|
|
781
|
-
const active = await span("rat-active", async () => {
|
|
782
|
-
return await options.isRuntimeAccessTokenActive?.(claims) ?? { active: true };
|
|
783
|
-
});
|
|
784
|
-
if (!active.active) {
|
|
785
|
-
return {
|
|
786
|
-
ok: false,
|
|
787
|
-
code: active.code,
|
|
788
|
-
response: await deny(options, {
|
|
789
|
-
code: active.code,
|
|
790
|
-
message: active.reason,
|
|
791
|
-
status: 401,
|
|
792
|
-
claims,
|
|
793
|
-
request
|
|
794
|
-
})
|
|
795
|
-
};
|
|
796
|
-
}
|
|
797
|
-
const path = relayRuntimePath(request);
|
|
798
|
-
if (!path || !roleAllowsRelayRequest(claims.role, request.method, path)) {
|
|
799
|
-
return {
|
|
800
|
-
ok: false,
|
|
801
|
-
code: "relay_role_denied",
|
|
802
|
-
response: await deny(options, {
|
|
803
|
-
code: "relay_role_denied",
|
|
804
|
-
message: "Workspace role does not allow this relay request",
|
|
805
|
-
status: 403,
|
|
806
|
-
claims,
|
|
807
|
-
request
|
|
808
|
-
})
|
|
809
|
-
};
|
|
810
|
-
}
|
|
811
|
-
const target = await span("target-resolve", async () => await options.resolveTarget(claims));
|
|
812
|
-
if (!target || target.workspaceId !== claims.workspace_id || target.hostId !== claims.host_id) {
|
|
813
|
-
return {
|
|
814
|
-
ok: false,
|
|
815
|
-
code: "relay_target_unavailable",
|
|
816
|
-
response: await deny(options, {
|
|
817
|
-
code: "relay_target_unavailable",
|
|
818
|
-
message: "Workspace relay target is unavailable",
|
|
819
|
-
status: 403,
|
|
820
|
-
claims,
|
|
821
|
-
request
|
|
822
|
-
})
|
|
823
|
-
};
|
|
824
|
-
}
|
|
825
|
-
if (isUserHostedTarget(target) && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
|
|
826
|
-
return {
|
|
827
|
-
ok: false,
|
|
828
|
-
code: "user_hosted_app_offline",
|
|
829
|
-
response: await deny(options, {
|
|
830
|
-
code: "user_hosted_app_offline",
|
|
831
|
-
message: "User-hosted workspace is offline",
|
|
832
|
-
status: 503,
|
|
833
|
-
claims,
|
|
834
|
-
request
|
|
835
|
-
})
|
|
836
|
-
};
|
|
837
|
-
}
|
|
838
|
-
await span("audit", async () => await audit(options, {
|
|
839
|
-
action: "relay.request.accepted",
|
|
840
|
-
result: "allow",
|
|
841
|
-
actorId: claims.actor_id,
|
|
842
|
-
actorKind: claims.actor_kind,
|
|
843
|
-
principalKind: claims.principal_kind,
|
|
844
|
-
role: claims.role,
|
|
845
|
-
workspaceId: claims.workspace_id,
|
|
846
|
-
hostId: claims.host_id,
|
|
847
|
-
method: request.method,
|
|
848
|
-
path: new URL(request.url).pathname
|
|
849
|
-
}));
|
|
850
|
-
const relayHostToken = await relayHostTokenFor(options, claims, target, { span });
|
|
851
|
-
return {
|
|
852
|
-
ok: true,
|
|
853
|
-
request: {
|
|
854
|
-
claims,
|
|
855
|
-
target,
|
|
856
|
-
relayHostToken,
|
|
857
|
-
path
|
|
858
|
-
}
|
|
859
|
-
};
|
|
860
|
-
} catch (err) {
|
|
861
|
-
const code = err instanceof WorkspaceRelayAuthError ? err.code : "relay_request_failed";
|
|
862
|
-
return {
|
|
863
|
-
ok: false,
|
|
864
|
-
code,
|
|
865
|
-
response: await deny(options, {
|
|
866
|
-
code,
|
|
867
|
-
message: "Workspace relay request was denied",
|
|
868
|
-
status: code.includes("mismatch") ? 403 : 401,
|
|
869
|
-
request
|
|
870
|
-
})
|
|
871
|
-
};
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
async function forwardWorkspaceRelayRequest(request, target, relayHostToken, path, requestFetch, timeoutMs, trace, originAllowed = defaultOriginMatcher) {
|
|
875
|
-
const controller = new AbortController();
|
|
876
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
877
|
-
const span = (name, run) => trace ? trace.span(name, run) : run();
|
|
878
|
-
try {
|
|
879
|
-
const upstream = await span("upstream-fetch", async () => await requestFetch(
|
|
880
|
-
targetUrl(target, path, new URL(request.url).search),
|
|
881
|
-
workspaceRelayForwardRequestInit(request, relayHostToken, target.workspaceId, {
|
|
882
|
-
signal: controller.signal,
|
|
883
|
-
upstreamHeaders: target.upstreamHeaders
|
|
884
|
-
})
|
|
885
|
-
));
|
|
886
|
-
const headers2 = new Headers(upstream.headers);
|
|
887
|
-
headers2.delete("access-control-allow-origin");
|
|
888
|
-
headers2.delete("access-control-allow-credentials");
|
|
889
|
-
headers2.delete("access-control-allow-headers");
|
|
890
|
-
headers2.delete("access-control-allow-methods");
|
|
891
|
-
headers2.delete("access-control-expose-headers");
|
|
892
|
-
headers2.delete("access-control-max-age");
|
|
893
|
-
for (const [key, value] of Object.entries(denyCorsHeaders(request, originAllowed) ?? {})) {
|
|
894
|
-
headers2.set(key, value);
|
|
895
|
-
}
|
|
896
|
-
return new Response(upstream.body, {
|
|
897
|
-
status: upstream.status,
|
|
898
|
-
statusText: upstream.statusText,
|
|
899
|
-
headers: headers2
|
|
900
|
-
});
|
|
901
|
-
} catch (err) {
|
|
902
|
-
const aborted = err instanceof Error && err.name === "AbortError";
|
|
903
|
-
return Response.json(
|
|
904
|
-
errorBody(
|
|
905
|
-
aborted ? "upstream_timeout" : "upstream_unavailable",
|
|
906
|
-
aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable"
|
|
907
|
-
),
|
|
908
|
-
{ status: aborted ? 504 : 503, headers: denyCorsHeaders(request, originAllowed) }
|
|
909
|
-
);
|
|
910
|
-
} finally {
|
|
911
|
-
clearTimeout(timer);
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
function getAuditSuppressedCount(options) {
|
|
915
|
-
return auditSamplers.get(options)?.suppressedCount ?? 0;
|
|
916
|
-
}
|
|
917
|
-
var LOOPBACK_REMOTES = /* @__PURE__ */ new Set([
|
|
918
|
-
"127.0.0.1",
|
|
919
|
-
"::1",
|
|
920
|
-
"::ffff:127.0.0.1"
|
|
921
|
-
]);
|
|
922
|
-
function isLoopbackAddress(address) {
|
|
923
|
-
if (!address) return false;
|
|
924
|
-
const trimmed = address.trim();
|
|
925
|
-
if (LOOPBACK_REMOTES.has(trimmed)) return true;
|
|
926
|
-
if (trimmed.startsWith("127.")) return true;
|
|
927
|
-
if (/^::ffff:127\./i.test(trimmed)) return true;
|
|
928
|
-
return false;
|
|
929
|
-
}
|
|
930
|
-
function checkMetricsAuth(options, request) {
|
|
931
|
-
if (options.metricsToken) {
|
|
932
|
-
const presented = bearerToken(request.headers.get("authorization") ?? void 0);
|
|
933
|
-
if (!presented || presented !== options.metricsToken) {
|
|
934
|
-
return {
|
|
935
|
-
ok: false,
|
|
936
|
-
status: 401,
|
|
937
|
-
code: "metrics_unauthorized",
|
|
938
|
-
message: "Metrics endpoint requires a valid bearer token"
|
|
939
|
-
};
|
|
940
|
-
}
|
|
941
|
-
return { ok: true };
|
|
942
|
-
}
|
|
943
|
-
if (!options.metricsRemoteAddress) {
|
|
944
|
-
return {
|
|
945
|
-
ok: false,
|
|
946
|
-
status: 401,
|
|
947
|
-
code: "metrics_unauthorized",
|
|
948
|
-
message: "Metrics endpoint is restricted to loopback or bearer-token clients"
|
|
949
|
-
};
|
|
950
|
-
}
|
|
951
|
-
const address = options.metricsRemoteAddress(request);
|
|
952
|
-
if (isLoopbackAddress(address)) return { ok: true };
|
|
953
|
-
return {
|
|
954
|
-
ok: false,
|
|
955
|
-
status: 401,
|
|
956
|
-
code: "metrics_unauthorized",
|
|
957
|
-
message: "Metrics endpoint is restricted to loopback or bearer-token clients"
|
|
958
|
-
};
|
|
959
|
-
}
|
|
960
|
-
function buildMetricsBody(options) {
|
|
961
|
-
const sources = options.metricsSources ?? {};
|
|
962
|
-
const fragmentation = sources.fragmentation?.() ?? { fragmentsBuffered: 0, oversizedClosed: 0 };
|
|
963
|
-
const slowConsumer = sources.slowConsumer?.() ?? {
|
|
964
|
-
overflowEvents: 0,
|
|
965
|
-
timerFired: 0,
|
|
966
|
-
droppedRequests: 0
|
|
967
|
-
};
|
|
968
|
-
const activeHostCount = options.directory?.size?.() ?? 0;
|
|
969
|
-
const suppressedAccepts = getAuditSuppressedCount(options);
|
|
970
|
-
const body = {
|
|
971
|
-
fragmentation,
|
|
972
|
-
slowConsumer,
|
|
973
|
-
directory: { activeHostCount },
|
|
974
|
-
audit: { suppressedAccepts }
|
|
975
|
-
};
|
|
976
|
-
if (sources.drainPending) {
|
|
977
|
-
body.drain = { pendingCount: sources.drainPending() };
|
|
978
|
-
}
|
|
979
|
-
return body;
|
|
980
|
-
}
|
|
981
|
-
function createWorkspaceRelay(options) {
|
|
982
|
-
const app = Object.assign(new Hono(), {
|
|
983
|
-
disposeAuditSampler: () => disposeAuditSampler(options)
|
|
984
|
-
});
|
|
985
|
-
const originAllowed = originMatcherFor(options);
|
|
986
|
-
app.use("*", async (c, next) => {
|
|
987
|
-
await next();
|
|
988
|
-
const origin = c.res.headers.get("access-control-allow-origin");
|
|
989
|
-
if (origin && origin !== "*" && !c.res.headers.has("timing-allow-origin")) {
|
|
990
|
-
c.res.headers.set("timing-allow-origin", origin);
|
|
991
|
-
}
|
|
992
|
-
});
|
|
993
|
-
app.use("*", cors({
|
|
994
|
-
origin: (origin) => {
|
|
995
|
-
if (!origin) return void 0;
|
|
996
|
-
return originAllowed(origin) ? origin : void 0;
|
|
997
|
-
},
|
|
998
|
-
// Spread from the shared list, not hand-maintained here: a duplicate
|
|
999
|
-
// array drifts out of sync and silently drops headers like
|
|
1000
|
-
// `Last-Event-ID` or `X-Fetch-Bypass-Throttle`.
|
|
1001
|
-
allowHeaders: [...RELAY_ALLOWED_REQUEST_HEADER_LIST],
|
|
1002
|
-
allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
1003
|
-
}));
|
|
1004
|
-
app.get("/health", (c) => {
|
|
1005
|
-
if (options.isDraining?.()) {
|
|
1006
|
-
return c.json(
|
|
1007
|
-
{ ok: false, service: "workspace-relay", draining: true },
|
|
1008
|
-
503
|
|
1009
|
-
);
|
|
1010
|
-
}
|
|
1011
|
-
return c.json({ ok: true, service: "workspace-relay" });
|
|
1012
|
-
});
|
|
1013
|
-
app.get("/.well-known/jwks.json", async (c) => {
|
|
1014
|
-
const sources = options.relayHostPublicKeys ?? [];
|
|
1015
|
-
if (sources.length === 0) {
|
|
1016
|
-
return c.json(
|
|
1017
|
-
{
|
|
1018
|
-
error: {
|
|
1019
|
-
code: "jwks_no_keys_configured",
|
|
1020
|
-
message: "No Relay Host Token public keys are configured for the JWKS endpoint"
|
|
1021
|
-
}
|
|
1022
|
-
},
|
|
1023
|
-
503
|
|
1024
|
-
);
|
|
1025
|
-
}
|
|
1026
|
-
let keys;
|
|
1027
|
-
try {
|
|
1028
|
-
keys = await Promise.all(sources.map(async (source) => {
|
|
1029
|
-
const jwk = await exportJWK2(source.publicKey);
|
|
1030
|
-
return {
|
|
1031
|
-
...jwk,
|
|
1032
|
-
kid: source.kid,
|
|
1033
|
-
alg: "EdDSA",
|
|
1034
|
-
use: "sig"
|
|
1035
|
-
};
|
|
1036
|
-
}));
|
|
1037
|
-
} catch (err) {
|
|
1038
|
-
return c.json(
|
|
1039
|
-
{
|
|
1040
|
-
error: {
|
|
1041
|
-
code: "jwks_invalid_key",
|
|
1042
|
-
message: "A configured RHT public key could not be exported as a JWK",
|
|
1043
|
-
detail: err instanceof Error ? err.message : String(err)
|
|
1044
|
-
}
|
|
1045
|
-
},
|
|
1046
|
-
500
|
|
1047
|
-
);
|
|
1048
|
-
}
|
|
1049
|
-
c.header("cache-control", "public, max-age=300");
|
|
1050
|
-
return c.json({ keys });
|
|
1051
|
-
});
|
|
1052
|
-
app.get("/metrics", (c) => {
|
|
1053
|
-
const authResult = checkMetricsAuth(options, c.req.raw);
|
|
1054
|
-
if (!authResult.ok) {
|
|
1055
|
-
return c.json(
|
|
1056
|
-
errorBody(authResult.code, authResult.message),
|
|
1057
|
-
authResult.status
|
|
1058
|
-
);
|
|
1059
|
-
}
|
|
1060
|
-
return c.json(buildMetricsBody(options));
|
|
1061
|
-
});
|
|
1062
|
-
app.all("/workspaces/:workspaceId/*", async (c) => {
|
|
1063
|
-
const trace = createWorkspaceRelayTrace();
|
|
1064
|
-
if (options.isDraining?.()) {
|
|
1065
|
-
return Response.json(
|
|
1066
|
-
errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
|
|
1067
|
-
{ status: 503, headers: denyCorsHeaders(c.req.raw, originAllowed) }
|
|
1068
|
-
);
|
|
1069
|
-
}
|
|
1070
|
-
const response = await trace.span("relay-total", async () => {
|
|
1071
|
-
const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"), trace);
|
|
1072
|
-
if (!relay.ok) return relay.response;
|
|
1073
|
-
return await forwardWorkspaceRelayRequest(
|
|
1074
|
-
new Request(c.req.raw),
|
|
1075
|
-
relay.request.target,
|
|
1076
|
-
relay.request.relayHostToken,
|
|
1077
|
-
relay.request.path,
|
|
1078
|
-
options.fetch ?? fetch,
|
|
1079
|
-
options.forwardTimeoutMs ?? 3e4,
|
|
1080
|
-
trace,
|
|
1081
|
-
originAllowed
|
|
1082
|
-
);
|
|
1083
|
-
});
|
|
1084
|
-
return workspaceRelayTimingResponse(response, trace);
|
|
1085
|
-
});
|
|
1086
|
-
return app;
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
// src/upstream-websocket.ts
|
|
1090
|
-
function resolveUpstreamWebSocket(override) {
|
|
1091
|
-
return override ?? WebSocket;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
// src/bun.ts
|
|
1095
|
-
var WORKSPACE_RELAY_IDLE_TIMEOUT_SECONDS = 45;
|
|
1096
|
-
var isRelayClientSocket = (ws) => ws.data.kind === "client";
|
|
1097
|
-
var isHostTunnelSocket = (ws) => ws.data.kind === "host-tunnel";
|
|
1098
|
-
var isUserHostedClientSocket = (ws) => ws.data.kind === "user-hosted-client";
|
|
1099
|
-
var TUNNEL_PENDING_HTTP_CAP = 32;
|
|
1100
|
-
var TUNNEL_CHANNEL_CAP = 16;
|
|
1101
|
-
var HOST_TUNNEL_REGISTRATION_RECONNECT_CAP = 5;
|
|
1102
|
-
var HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS = 6e4;
|
|
1103
|
-
var WS_MAX_PAYLOAD_LENGTH_BYTES = 16 * 1024 * 1024;
|
|
1104
|
-
var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
|
|
1105
|
-
var TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1106
|
-
var DIRECT_HTTP_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1107
|
-
var UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT = 1e4;
|
|
1108
|
-
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
|
|
1109
|
-
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1110
|
-
var WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1111
|
-
var relayBufferedBytes = (socket) => {
|
|
1112
|
-
if (typeof socket !== "object" || socket === null) return void 0;
|
|
1113
|
-
if ("getBufferedAmount" in socket && typeof socket.getBufferedAmount === "function") {
|
|
1114
|
-
const measured = socket.getBufferedAmount();
|
|
1115
|
-
return typeof measured === "number" && Number.isFinite(measured) ? measured : void 0;
|
|
1116
|
-
}
|
|
1117
|
-
if (!("bufferedAmount" in socket)) return void 0;
|
|
1118
|
-
const property = socket.bufferedAmount;
|
|
1119
|
-
return typeof property === "number" && Number.isFinite(property) ? property : void 0;
|
|
1120
|
-
};
|
|
1121
|
-
var relayOverBackpressureLimit = (socket, limitBytes) => {
|
|
1122
|
-
const queued = relayBufferedBytes(socket);
|
|
1123
|
-
return queued !== void 0 && queued > limitBytes;
|
|
1124
|
-
};
|
|
1125
|
-
var HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT = 2;
|
|
1126
|
-
var HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT = 3e4;
|
|
1127
|
-
var HOST_GENERATION_OUTAGE_GRACE_ATTEMPTS_DEFAULT = 3;
|
|
1128
|
-
var TUNNEL_MESSAGE_BUFFER_CAP_BYTES = 4 * 1024 * 1024;
|
|
1129
|
-
var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1130
|
-
var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1131
|
-
var HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT = 250;
|
|
1132
|
-
function createFragmentationStats() {
|
|
1133
|
-
return {
|
|
1134
|
-
fragmentsBuffered: 0,
|
|
1135
|
-
oversizedClosed: 0
|
|
1136
|
-
};
|
|
1137
|
-
}
|
|
1138
|
-
function createSlowConsumerStats() {
|
|
1139
|
-
return {
|
|
1140
|
-
overflowEvents: 0,
|
|
1141
|
-
timerFired: 0,
|
|
1142
|
-
droppedRequests: 0
|
|
1143
|
-
};
|
|
1144
|
-
}
|
|
1145
|
-
function readFragmentationStats(stats) {
|
|
1146
|
-
return {
|
|
1147
|
-
fragmentsBuffered: stats.fragmentsBuffered,
|
|
1148
|
-
oversizedClosed: stats.oversizedClosed
|
|
1149
|
-
};
|
|
1150
|
-
}
|
|
1151
|
-
function resetFragmentationStats(stats) {
|
|
1152
|
-
stats.fragmentsBuffered = 0;
|
|
1153
|
-
stats.oversizedClosed = 0;
|
|
1154
|
-
}
|
|
1155
|
-
function readSlowConsumerStats(stats) {
|
|
1156
|
-
return { ...stats };
|
|
1157
|
-
}
|
|
1158
|
-
function resetSlowConsumerStats(stats) {
|
|
1159
|
-
stats.overflowEvents = 0;
|
|
1160
|
-
stats.timerFired = 0;
|
|
1161
|
-
stats.droppedRequests = 0;
|
|
1162
|
-
}
|
|
1163
|
-
function createDirectHttpLimiter(limit) {
|
|
1164
|
-
if (!Number.isInteger(limit) || !limit || limit <= 0) return void 0;
|
|
1165
|
-
const max = limit;
|
|
1166
|
-
let active = 0;
|
|
1167
|
-
const queue = [];
|
|
1168
|
-
function drain() {
|
|
1169
|
-
if (active >= max) return;
|
|
1170
|
-
const next = queue.shift();
|
|
1171
|
-
if (!next) return;
|
|
1172
|
-
active++;
|
|
1173
|
-
next();
|
|
1174
|
-
}
|
|
1175
|
-
return {
|
|
1176
|
-
async acquire() {
|
|
1177
|
-
if (active < max) {
|
|
1178
|
-
active++;
|
|
1179
|
-
} else {
|
|
1180
|
-
await new Promise((resolve) => queue.push(resolve));
|
|
1181
|
-
}
|
|
1182
|
-
let released = false;
|
|
1183
|
-
return () => {
|
|
1184
|
-
if (released) return;
|
|
1185
|
-
released = true;
|
|
1186
|
-
active = Math.max(0, active - 1);
|
|
1187
|
-
drain();
|
|
1188
|
-
};
|
|
1189
|
-
}
|
|
1190
|
-
};
|
|
1191
|
-
}
|
|
1192
|
-
function jsonError(code, message, status) {
|
|
1193
|
-
return new Response(JSON.stringify({ error: { code, message } }), {
|
|
1194
|
-
status,
|
|
1195
|
-
headers: { "content-type": "application/json" }
|
|
1196
|
-
});
|
|
1197
|
-
}
|
|
1198
|
-
function corsJsonError(request, originAllowed, code, message, status) {
|
|
1199
|
-
const headers2 = relayCorsHeaders(request, originAllowed);
|
|
1200
|
-
headers2.set("content-type", "application/json");
|
|
1201
|
-
return new Response(JSON.stringify({ error: { code, message } }), { status, headers: headers2 });
|
|
1202
|
-
}
|
|
1203
|
-
function preOpenFrameBytes(message) {
|
|
1204
|
-
return typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
1205
|
-
}
|
|
1206
|
-
function safeCloseCode(input, fallback = 1011) {
|
|
1207
|
-
if (input === void 0 || !Number.isInteger(input)) return fallback;
|
|
1208
|
-
const code = input;
|
|
1209
|
-
if (code === 1e3) return code;
|
|
1210
|
-
if (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
|
|
1211
|
-
if (code >= 3e3 && code <= 4999) return code;
|
|
1212
|
-
return fallback;
|
|
1213
|
-
}
|
|
1214
|
-
function closeWebSocket(ws, code, reason, fallback = 1011) {
|
|
1215
|
-
try {
|
|
1216
|
-
ws.close(safeCloseCode(code, fallback), reason);
|
|
1217
|
-
} catch {
|
|
1218
|
-
try {
|
|
1219
|
-
ws.close(fallback, reason);
|
|
1220
|
-
} catch {
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
function upstreamCloseReason(event) {
|
|
1225
|
-
if (event.reason.trim()) return event.reason;
|
|
1226
|
-
if (event.code === 1e3) return "";
|
|
1227
|
-
if (event.code === 1005 || event.code === 1006) return "Upstream WebSocket closed abnormally";
|
|
1228
|
-
return `Upstream WebSocket closed unexpectedly with code ${event.code}`;
|
|
1229
|
-
}
|
|
1230
|
-
function workspaceIdFromPath(pathname) {
|
|
1231
|
-
return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
1232
|
-
}
|
|
1233
|
-
function hostIdFromTunnelPath(pathname) {
|
|
1234
|
-
return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
1235
|
-
}
|
|
1236
|
-
function tunnelKey(hostId, workspaceId) {
|
|
1237
|
-
return `${hostId}\0${workspaceId}`;
|
|
1238
|
-
}
|
|
1239
|
-
function tunnelIdentity(hostId, workspaceIds) {
|
|
1240
|
-
return `${hostId}\0${[...new Set(workspaceIds)].sort().join(",")}`;
|
|
1241
|
-
}
|
|
1242
|
-
function ownedWorkspaceIds(hostTunnels, ws) {
|
|
1243
|
-
return ws.data.workspaceIds.filter((workspaceId) => hostTunnels.get(tunnelKey(ws.data.hostId, workspaceId)) === ws);
|
|
1244
|
-
}
|
|
1245
|
-
function outranks(incumbent, candidateGeneration) {
|
|
1246
|
-
return hostTunnelIncumbentOutranks(incumbent.data.generation, candidateGeneration);
|
|
1247
|
-
}
|
|
1248
|
-
function websocketRequest(request) {
|
|
1249
|
-
return request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
1250
|
-
}
|
|
1251
|
-
function roundedMs2(input) {
|
|
1252
|
-
return Math.round(input * 100) / 100;
|
|
1253
|
-
}
|
|
1254
|
-
function relayWebSocketTraceEnabled(request) {
|
|
1255
|
-
return request.headers.get("x-claxedo-relay-ws-trace") === "1";
|
|
1256
|
-
}
|
|
1257
|
-
function sendRelayWebSocketTrace(ws) {
|
|
1258
|
-
const trace = ws.data.trace;
|
|
1259
|
-
if (!trace || trace.emitted) return;
|
|
1260
|
-
trace.emitted = true;
|
|
1261
|
-
ws.send(JSON.stringify({
|
|
1262
|
-
type: "relay.trace",
|
|
1263
|
-
wsUpstreamOpenMs: trace.upstreamOpenMs === void 0 ? void 0 : roundedMs2(trace.upstreamOpenMs),
|
|
1264
|
-
queuedFrames: trace.queuedFrames,
|
|
1265
|
-
maxQueuedDelayMs: roundedMs2(trace.maxQueuedDelayMs)
|
|
1266
|
-
}));
|
|
1267
|
-
}
|
|
1268
|
-
function relayWebSocketPayload(input) {
|
|
1269
|
-
if (typeof input === "string") return input;
|
|
1270
|
-
if (input instanceof ArrayBuffer) return input;
|
|
1271
|
-
if (ArrayBuffer.isView(input)) {
|
|
1272
|
-
const copy = new Uint8Array(input.byteLength);
|
|
1273
|
-
copy.set(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
|
|
1274
|
-
return copy;
|
|
1275
|
-
}
|
|
1276
|
-
return void 0;
|
|
1277
|
-
}
|
|
1278
|
-
function headersRecord(headers2) {
|
|
1279
|
-
const result = {};
|
|
1280
|
-
headers2.forEach((value, key) => {
|
|
1281
|
-
result[key] = value;
|
|
1282
|
-
});
|
|
1283
|
-
return result;
|
|
1284
|
-
}
|
|
1285
|
-
function headers(input) {
|
|
1286
|
-
const result = new Headers();
|
|
1287
|
-
for (const [key, value] of Object.entries(input)) {
|
|
1288
|
-
result.set(key, value);
|
|
1289
|
-
}
|
|
1290
|
-
return result;
|
|
1291
|
-
}
|
|
1292
|
-
function isEventStream(input) {
|
|
1293
|
-
return Object.entries(input).some(
|
|
1294
|
-
([key, value]) => key.toLowerCase() === "content-type" && value.toLowerCase().includes("text/event-stream")
|
|
1295
|
-
);
|
|
1296
|
-
}
|
|
1297
|
-
var defaultRelayOriginMatcher = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
|
|
1298
|
-
function allowedCorsOrigin(origin, matcher) {
|
|
1299
|
-
if (!origin) return void 0;
|
|
1300
|
-
return matcher(origin) ? origin : void 0;
|
|
1301
|
-
}
|
|
1302
|
-
function requireAllowedOrigin(request, matcher) {
|
|
1303
|
-
const origin = request.headers.get("origin");
|
|
1304
|
-
if (allowedCorsOrigin(origin, matcher)) return null;
|
|
1305
|
-
return jsonError(
|
|
1306
|
-
"origin_not_allowed",
|
|
1307
|
-
"Origin is not in the allowlist",
|
|
1308
|
-
403
|
|
1309
|
-
);
|
|
1310
|
-
}
|
|
1311
|
-
function relayCorsHeaders(request, originAllowed, input = new Headers()) {
|
|
1312
|
-
const result = new Headers(input);
|
|
1313
|
-
result.delete("access-control-allow-origin");
|
|
1314
|
-
result.delete("access-control-allow-credentials");
|
|
1315
|
-
result.delete("access-control-allow-headers");
|
|
1316
|
-
result.delete("access-control-allow-methods");
|
|
1317
|
-
result.delete("access-control-expose-headers");
|
|
1318
|
-
result.delete("access-control-max-age");
|
|
1319
|
-
const origin = allowedCorsOrigin(request.headers.get("origin"), originAllowed);
|
|
1320
|
-
if (origin) {
|
|
1321
|
-
result.set("access-control-allow-origin", origin);
|
|
1322
|
-
result.set("access-control-allow-headers", RELAY_ALLOWED_REQUEST_HEADERS);
|
|
1323
|
-
result.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
1324
|
-
}
|
|
1325
|
-
return result;
|
|
1326
|
-
}
|
|
1327
|
-
function encoded(input) {
|
|
1328
|
-
return Buffer.from(input).toString("base64");
|
|
1329
|
-
}
|
|
1330
|
-
function decoded(input) {
|
|
1331
|
-
return new Uint8Array(Buffer.from(input, "base64"));
|
|
1332
|
-
}
|
|
1333
|
-
function encodedFrame(message) {
|
|
1334
|
-
if (typeof message === "string") {
|
|
1335
|
-
return {
|
|
1336
|
-
binary: false,
|
|
1337
|
-
data_base64: Buffer.from(message).toString("base64")
|
|
1338
|
-
};
|
|
1339
|
-
}
|
|
1340
|
-
return {
|
|
1341
|
-
binary: true,
|
|
1342
|
-
data_base64: Buffer.from(message).toString("base64")
|
|
1343
|
-
};
|
|
1344
|
-
}
|
|
1345
|
-
function decodedFrame(frame) {
|
|
1346
|
-
const body = Buffer.from(frame.data_base64, "base64");
|
|
1347
|
-
return frame.binary ? new Uint8Array(body) : body.toString("utf8");
|
|
1348
|
-
}
|
|
1349
|
-
function tunnelMessage(ws, input, stats) {
|
|
1350
|
-
if (typeof input !== "string") return void 0;
|
|
1351
|
-
const combined = ws.data.messageBuffer.length > 0 ? ws.data.messageBuffer + input : input;
|
|
1352
|
-
let parsed;
|
|
1353
|
-
try {
|
|
1354
|
-
parsed = JSON.parse(combined);
|
|
1355
|
-
} catch {
|
|
1356
|
-
if (combined.length > TUNNEL_MESSAGE_BUFFER_CAP_BYTES) {
|
|
1357
|
-
stats.oversizedClosed += 1;
|
|
1358
|
-
ws.data.messageBuffer = "";
|
|
1359
|
-
ws.close(1009, "Tunnel message buffer exceeded 4 MB");
|
|
1360
|
-
return void 0;
|
|
1361
|
-
}
|
|
1362
|
-
stats.fragmentsBuffered += 1;
|
|
1363
|
-
ws.data.messageBuffer = combined;
|
|
1364
|
-
return void 0;
|
|
1365
|
-
}
|
|
1366
|
-
ws.data.messageBuffer = "";
|
|
1367
|
-
const validated = validateTunnelMessage(parsed);
|
|
1368
|
-
if (validated.ok) return validated.message;
|
|
1369
|
-
if (validated.reason === "protocol_mismatch") {
|
|
1370
|
-
ws.close(1002, `Tunnel protocol mismatch: expected ${validated.expected_protocol}`);
|
|
1371
|
-
}
|
|
1372
|
-
return void 0;
|
|
1373
|
-
}
|
|
1374
|
-
function sendTunnelPing(ws) {
|
|
1375
|
-
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1376
|
-
ws.data.missedPongs += 1;
|
|
1377
|
-
ws.send(JSON.stringify(makeTunnelPing()));
|
|
1378
|
-
}
|
|
1379
|
-
function clearPendingTimers(entry) {
|
|
1380
|
-
clearTimeout(entry.timeout);
|
|
1381
|
-
if (entry.slowConsumerTimeout) {
|
|
1382
|
-
clearTimeout(entry.slowConsumerTimeout);
|
|
1383
|
-
entry.slowConsumerTimeout = void 0;
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
function failPendingHttpResponse(input) {
|
|
1387
|
-
clearPendingTimers(input.entry);
|
|
1388
|
-
input.entry.pendingChunks.length = 0;
|
|
1389
|
-
input.entry.bytesQueued = 0;
|
|
1390
|
-
try {
|
|
1391
|
-
input.entry.controller.error(input.error);
|
|
1392
|
-
} catch {
|
|
1393
|
-
}
|
|
1394
|
-
if (!input.entry.responseStarted) {
|
|
1395
|
-
if (input.response) {
|
|
1396
|
-
input.entry.resolve(input.response);
|
|
1397
|
-
return;
|
|
1398
|
-
}
|
|
1399
|
-
input.entry.reject(input.error);
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
function cleanupHostTunnelSocket(input) {
|
|
1403
|
-
if (input.ws.data.heartbeat) {
|
|
1404
|
-
clearInterval(input.ws.data.heartbeat);
|
|
1405
|
-
input.ws.data.heartbeat = void 0;
|
|
1406
|
-
}
|
|
1407
|
-
for (const pending of input.ws.data.pending.values()) {
|
|
1408
|
-
failPendingHttpResponse({
|
|
1409
|
-
entry: pending,
|
|
1410
|
-
response: input.request ? corsJsonError(input.request, input.originAllowed, "user_hosted_app_offline", "User-hosted workspace is offline", 503) : jsonError("user_hosted_app_offline", "User-hosted workspace is offline", 503),
|
|
1411
|
-
error: new Error("User-hosted tunnel disconnected")
|
|
1412
|
-
});
|
|
1413
|
-
}
|
|
1414
|
-
input.ws.data.pending.clear();
|
|
1415
|
-
for (const channel of input.ws.data.channels.values()) {
|
|
1416
|
-
if (input.closeChannels) closeWebSocket(channel, 1011, "User-hosted tunnel disconnected");
|
|
1417
|
-
}
|
|
1418
|
-
input.ws.data.channels.clear();
|
|
1419
|
-
if (!input.disconnectDirectory) return;
|
|
1420
|
-
const owned = ownedWorkspaceIds(input.hostTunnels, input.ws);
|
|
1421
|
-
if (owned.length === 0) return;
|
|
1422
|
-
for (const workspaceId of owned) input.hostTunnels.delete(tunnelKey(input.ws.data.hostId, workspaceId));
|
|
1423
|
-
input.options.directory?.disconnectHost(input.ws.data.hostId, owned);
|
|
1424
|
-
scheduleHostTunnelStateChange(input.hostTunnelStateDebounce, input.options, {
|
|
1425
|
-
hostId: input.ws.data.hostId,
|
|
1426
|
-
workspaceIds: input.ws.data.workspaceIds,
|
|
1427
|
-
path: `/host-tunnels/${input.ws.data.hostId}`,
|
|
1428
|
-
state: "disconnected",
|
|
1429
|
-
debounceMs: input.bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
|
|
1430
|
-
});
|
|
1431
|
-
}
|
|
1432
|
-
async function audit2(options, input) {
|
|
1433
|
-
await options.audit?.({
|
|
1434
|
-
action: input.action,
|
|
1435
|
-
result: "allow",
|
|
1436
|
-
hostId: input.hostId,
|
|
1437
|
-
workspaceId: input.workspaceId,
|
|
1438
|
-
method: "WEBSOCKET",
|
|
1439
|
-
path: input.path
|
|
1440
|
-
});
|
|
1441
|
-
}
|
|
1442
|
-
async function auditHostTunnelDenial(options, input) {
|
|
1443
|
-
await options.audit?.({
|
|
1444
|
-
action: "host_tunnel.denied",
|
|
1445
|
-
result: "deny",
|
|
1446
|
-
reason: input.code,
|
|
1447
|
-
hostId: input.hostId,
|
|
1448
|
-
...input.workspaceId ? { workspaceId: input.workspaceId } : {},
|
|
1449
|
-
method: "WEBSOCKET",
|
|
1450
|
-
path: `/host-tunnels/${input.hostId}`
|
|
1451
|
-
});
|
|
1452
|
-
}
|
|
1453
|
-
async function denyHostTunnel(options, input) {
|
|
1454
|
-
await auditHostTunnelDenial(options, input);
|
|
1455
|
-
return jsonError(input.code, input.message, input.status);
|
|
1456
|
-
}
|
|
1457
|
-
async function refuseHostTunnelSocket(options, ws, input) {
|
|
1458
|
-
await auditHostTunnelDenial(options, { code: input.code, hostId: ws.data.hostId, ...input.workspaceId ? { workspaceId: input.workspaceId } : {} });
|
|
1459
|
-
closeWebSocket(ws, input.close, input.reason, input.close);
|
|
1460
|
-
}
|
|
1461
|
-
function scheduleHostTunnelStateChange(state, options, input) {
|
|
1462
|
-
const identity = tunnelIdentity(input.hostId, input.workspaceIds);
|
|
1463
|
-
const workspaceId = input.workspaceIds[0];
|
|
1464
|
-
const existing = state.get(identity);
|
|
1465
|
-
const entry = existing ?? {
|
|
1466
|
-
intendedState: "disconnected",
|
|
1467
|
-
lastWritten: "disconnected",
|
|
1468
|
-
flushTimer: void 0,
|
|
1469
|
-
pending: { workspaceId, path: input.path }
|
|
1470
|
-
};
|
|
1471
|
-
entry.intendedState = input.state;
|
|
1472
|
-
entry.pending = { workspaceId, path: input.path };
|
|
1473
|
-
if (entry.flushTimer) clearTimeout(entry.flushTimer);
|
|
1474
|
-
entry.flushTimer = setTimeout(() => {
|
|
1475
|
-
entry.flushTimer = void 0;
|
|
1476
|
-
if (entry.intendedState === entry.lastWritten) {
|
|
1477
|
-
if (entry.intendedState === "disconnected") {
|
|
1478
|
-
state.delete(identity);
|
|
1479
|
-
}
|
|
1480
|
-
return;
|
|
1481
|
-
}
|
|
1482
|
-
entry.lastWritten = entry.intendedState;
|
|
1483
|
-
void audit2(options, {
|
|
1484
|
-
action: entry.intendedState === "connected" ? "host_tunnel.connected" : "host_tunnel.disconnected",
|
|
1485
|
-
hostId: input.hostId,
|
|
1486
|
-
workspaceId: entry.pending.workspaceId,
|
|
1487
|
-
path: entry.pending.path
|
|
1488
|
-
});
|
|
1489
|
-
}, input.debounceMs);
|
|
1490
|
-
state.set(identity, entry);
|
|
1491
|
-
}
|
|
1492
|
-
function drainPendingChunks(entry) {
|
|
1493
|
-
while (entry.pendingChunks.length > 0) {
|
|
1494
|
-
const desired = entry.controller.desiredSize;
|
|
1495
|
-
if (desired !== null && desired <= 0) break;
|
|
1496
|
-
const next = entry.pendingChunks.shift();
|
|
1497
|
-
entry.bytesQueued -= next.byteLength;
|
|
1498
|
-
try {
|
|
1499
|
-
entry.controller.enqueue(next);
|
|
1500
|
-
} catch {
|
|
1501
|
-
entry.pendingChunks.length = 0;
|
|
1502
|
-
entry.bytesQueued = 0;
|
|
1503
|
-
break;
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
if (entry.pendingChunks.length === 0 && entry.slowConsumerTimeout) {
|
|
1507
|
-
clearTimeout(entry.slowConsumerTimeout);
|
|
1508
|
-
entry.slowConsumerTimeout = void 0;
|
|
1509
|
-
}
|
|
1510
|
-
}
|
|
1511
|
-
function enqueueChunkWithBackpressure(input) {
|
|
1512
|
-
const { entry, chunk } = input;
|
|
1513
|
-
const desired = entry.controller.desiredSize;
|
|
1514
|
-
const overflowing = desired !== null && desired <= 0;
|
|
1515
|
-
if (overflowing || entry.pendingChunks.length > 0) {
|
|
1516
|
-
entry.pendingChunks.push(chunk);
|
|
1517
|
-
entry.bytesQueued += chunk.byteLength;
|
|
1518
|
-
if (!entry.slowConsumerTimeout) {
|
|
1519
|
-
input.slowConsumerStats.overflowEvents += 1;
|
|
1520
|
-
entry.slowConsumerTimeout = setTimeout(() => {
|
|
1521
|
-
input.slowConsumerStats.timerFired += 1;
|
|
1522
|
-
const error = new Error("slow_consumer_timeout: downstream consumer did not drain in time");
|
|
1523
|
-
try {
|
|
1524
|
-
entry.controller.error(error);
|
|
1525
|
-
} catch {
|
|
1526
|
-
}
|
|
1527
|
-
clearTimeout(entry.timeout);
|
|
1528
|
-
entry.pendingChunks.length = 0;
|
|
1529
|
-
entry.bytesQueued = 0;
|
|
1530
|
-
if (!entry.responseStarted) {
|
|
1531
|
-
entry.resolve(jsonError("slow_consumer_timeout", "Downstream consumer did not drain in time", 503));
|
|
1532
|
-
}
|
|
1533
|
-
input.ws.data.pending.delete(input.requestId);
|
|
1534
|
-
input.slowConsumerStats.droppedRequests += 1;
|
|
1535
|
-
}, input.slowConsumerTimeoutMs);
|
|
1536
|
-
}
|
|
1537
|
-
return;
|
|
1538
|
-
}
|
|
1539
|
-
try {
|
|
1540
|
-
entry.controller.enqueue(chunk);
|
|
1541
|
-
} catch {
|
|
1542
|
-
}
|
|
1543
|
-
}
|
|
1544
|
-
var __slowConsumerInternalsForTest = {
|
|
1545
|
-
enqueueChunkWithBackpressure,
|
|
1546
|
-
createSlowConsumerStats
|
|
1547
|
-
};
|
|
1548
|
-
async function tunnelHttpRequest(input) {
|
|
1549
|
-
if (input.ws.readyState !== WebSocket.OPEN) {
|
|
1550
|
-
return new Response("User-hosted workspace is offline", { status: 503 });
|
|
1551
|
-
}
|
|
1552
|
-
if (input.ws.data.pending.size >= TUNNEL_PENDING_HTTP_CAP) {
|
|
1553
|
-
return jsonError(
|
|
1554
|
-
"too_many_in_flight",
|
|
1555
|
-
"User-hosted tunnel has too many in-flight HTTP requests",
|
|
1556
|
-
429
|
|
1557
|
-
);
|
|
1558
|
-
}
|
|
1559
|
-
const requestId = crypto.randomUUID();
|
|
1560
|
-
let controller;
|
|
1561
|
-
const stream = new ReadableStream({
|
|
1562
|
-
start(next) {
|
|
1563
|
-
controller = next;
|
|
1564
|
-
},
|
|
1565
|
-
pull() {
|
|
1566
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1567
|
-
if (!entry) return;
|
|
1568
|
-
drainPendingChunks(entry);
|
|
1569
|
-
},
|
|
1570
|
-
cancel() {
|
|
1571
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1572
|
-
if (!entry) return;
|
|
1573
|
-
input.ws.data.pending.delete(requestId);
|
|
1574
|
-
clearPendingTimers(entry);
|
|
1575
|
-
entry.pendingChunks.length = 0;
|
|
1576
|
-
entry.bytesQueued = 0;
|
|
1577
|
-
if (input.ws.readyState === WebSocket.OPEN) {
|
|
1578
|
-
input.ws.send(JSON.stringify({
|
|
1579
|
-
type: "http.response.flow",
|
|
1580
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
1581
|
-
request_id: requestId,
|
|
1582
|
-
paused: false,
|
|
1583
|
-
reason: "closed"
|
|
1584
|
-
}));
|
|
1585
|
-
}
|
|
1586
|
-
}
|
|
1587
|
-
}, new ByteLengthQueuingStrategy({ highWaterMark: input.slowConsumerHighWaterMarkBytes }));
|
|
1588
|
-
const pending = new Promise((resolve, reject) => {
|
|
1589
|
-
const timeout = setTimeout(() => {
|
|
1590
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1591
|
-
if (!entry) return;
|
|
1592
|
-
input.ws.data.pending.delete(requestId);
|
|
1593
|
-
if (entry.responseStarted) {
|
|
1594
|
-
clearPendingTimers(entry);
|
|
1595
|
-
entry.pendingChunks.length = 0;
|
|
1596
|
-
entry.bytesQueued = 0;
|
|
1597
|
-
try {
|
|
1598
|
-
entry.controller.close();
|
|
1599
|
-
} catch {
|
|
1600
|
-
}
|
|
1601
|
-
return;
|
|
1602
|
-
}
|
|
1603
|
-
const error = new Error("User-hosted tunnel response timed out");
|
|
1604
|
-
failPendingHttpResponse({
|
|
1605
|
-
entry,
|
|
1606
|
-
response: corsJsonError(input.request, input.originAllowed, "user_hosted_tunnel_timeout", "User-hosted tunnel response timed out", 504),
|
|
1607
|
-
error
|
|
1608
|
-
});
|
|
1609
|
-
}, input.responseTimeoutMs);
|
|
1610
|
-
input.ws.data.pending.set(requestId, {
|
|
1611
|
-
controller,
|
|
1612
|
-
stream,
|
|
1613
|
-
resolve,
|
|
1614
|
-
reject,
|
|
1615
|
-
timeout,
|
|
1616
|
-
pendingChunks: [],
|
|
1617
|
-
bytesQueued: 0,
|
|
1618
|
-
responseStarted: false,
|
|
1619
|
-
corsHeaders: (upstream) => relayCorsHeaders(input.request, input.originAllowed, upstream)
|
|
1620
|
-
});
|
|
1621
|
-
});
|
|
1622
|
-
const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : await readBoundedBody(input.request, input.originAllowed, input.requestBodyMaxBytes);
|
|
1623
|
-
if (body && "response" in body) {
|
|
1624
|
-
const entry = input.ws.data.pending.get(requestId);
|
|
1625
|
-
if (entry) clearPendingTimers(entry);
|
|
1626
|
-
input.ws.data.pending.delete(requestId);
|
|
1627
|
-
return body.response;
|
|
1628
|
-
}
|
|
1629
|
-
input.ws.send(JSON.stringify({
|
|
1630
|
-
type: "http.request",
|
|
1631
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
1632
|
-
request_id: requestId,
|
|
1633
|
-
workspace_id: input.workspaceId,
|
|
1634
|
-
method: input.request.method,
|
|
1635
|
-
path: input.path,
|
|
1636
|
-
headers: headersRecord(workspaceRelayForwardHeaders(
|
|
1637
|
-
input.request.headers,
|
|
1638
|
-
input.relayHostToken,
|
|
1639
|
-
input.workspaceId,
|
|
1640
|
-
// User-hosted tunnel: strip Cookie so browser cookies never reach the
|
|
1641
|
-
// host process on the user's laptop.
|
|
1642
|
-
{ userHosted: true }
|
|
1643
|
-
)),
|
|
1644
|
-
...body?.bodyBase64 ? { body_base64: body.bodyBase64 } : {},
|
|
1645
|
-
end: true
|
|
1646
|
-
}));
|
|
1647
|
-
return await pending;
|
|
1648
|
-
}
|
|
1649
|
-
async function readBoundedBody(request, originAllowed, maxBytes) {
|
|
1650
|
-
if (!request.body) return void 0;
|
|
1651
|
-
const reader = request.body.getReader();
|
|
1652
|
-
const chunks = [];
|
|
1653
|
-
let total = 0;
|
|
1654
|
-
for (; ; ) {
|
|
1655
|
-
const next = await reader.read();
|
|
1656
|
-
if (next.done) break;
|
|
1657
|
-
total += next.value.byteLength;
|
|
1658
|
-
if (total > maxBytes) {
|
|
1659
|
-
try {
|
|
1660
|
-
await reader.cancel();
|
|
1661
|
-
} catch {
|
|
1662
|
-
}
|
|
1663
|
-
return {
|
|
1664
|
-
response: corsJsonError(request, originAllowed, "request_body_too_large", "Tunnel request body exceeds the relay limit", 413)
|
|
1665
|
-
};
|
|
1666
|
-
}
|
|
1667
|
-
chunks.push(next.value);
|
|
1668
|
-
}
|
|
1669
|
-
const body = new Uint8Array(total);
|
|
1670
|
-
chunks.reduce((offset, chunk) => {
|
|
1671
|
-
body.set(chunk, offset);
|
|
1672
|
-
return offset + chunk.byteLength;
|
|
1673
|
-
}, 0);
|
|
1674
|
-
return { bodyBase64: encoded(body.buffer) };
|
|
1675
|
-
}
|
|
1676
|
-
async function directHttpRequest(input) {
|
|
1677
|
-
const controller = new AbortController();
|
|
1678
|
-
const timer = setTimeout(() => controller.abort(), input.timeoutMs);
|
|
1679
|
-
const span = (name, run) => input.trace ? input.trace.span(name, run) : run();
|
|
1680
|
-
try {
|
|
1681
|
-
const init = workspaceRelayForwardRequestInit(
|
|
1682
|
-
input.request,
|
|
1683
|
-
input.relayHostToken,
|
|
1684
|
-
input.workspaceId,
|
|
1685
|
-
{
|
|
1686
|
-
// Cloud-vm direct path: cookies pass through — workspace dashboards in
|
|
1687
|
-
// the VM may legitimately need session cookies.
|
|
1688
|
-
userHosted: false,
|
|
1689
|
-
upstreamHeaders: input.upstreamHeaders,
|
|
1690
|
-
signal: controller.signal
|
|
1691
|
-
}
|
|
1692
|
-
);
|
|
1693
|
-
if (input.request.method !== "GET" && input.request.method !== "HEAD" && input.request.body) {
|
|
1694
|
-
init.body = await input.request.arrayBuffer();
|
|
1695
|
-
}
|
|
1696
|
-
const limiter = input.limiter;
|
|
1697
|
-
const release = limiter ? await span("direct-http-queue", () => limiter.acquire()) : void 0;
|
|
1698
|
-
try {
|
|
1699
|
-
const upstream = await span("upstream-fetch", async () => await fetch(input.targetUrl, init));
|
|
1700
|
-
const headers2 = relayCorsHeaders(input.request, input.originAllowed, upstream.headers);
|
|
1701
|
-
const contentType = upstream.headers.get("content-type") ?? "";
|
|
1702
|
-
const streamResponse = contentType.includes("text/event-stream") || contentType.includes("application/octet-stream");
|
|
1703
|
-
if (streamResponse) {
|
|
1704
|
-
return new Response(upstream.body, {
|
|
1705
|
-
status: upstream.status,
|
|
1706
|
-
statusText: upstream.statusText,
|
|
1707
|
-
headers: headers2
|
|
1708
|
-
});
|
|
1709
|
-
}
|
|
1710
|
-
const body = await upstream.arrayBuffer();
|
|
1711
|
-
return new Response(body, {
|
|
1712
|
-
status: upstream.status,
|
|
1713
|
-
statusText: upstream.statusText,
|
|
1714
|
-
headers: headers2
|
|
1715
|
-
});
|
|
1716
|
-
} finally {
|
|
1717
|
-
release?.();
|
|
1718
|
-
}
|
|
1719
|
-
} catch (err) {
|
|
1720
|
-
const aborted = err instanceof Error && err.name === "AbortError";
|
|
1721
|
-
return corsJsonError(
|
|
1722
|
-
input.request,
|
|
1723
|
-
input.originAllowed,
|
|
1724
|
-
aborted ? "upstream_timeout" : "upstream_unavailable",
|
|
1725
|
-
aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable",
|
|
1726
|
-
aborted ? 504 : 503
|
|
1727
|
-
);
|
|
1728
|
-
} finally {
|
|
1729
|
-
clearTimeout(timer);
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
function hostTunnel(hostTunnels, hostId, workspaceId) {
|
|
1733
|
-
const tunnel = hostTunnels.get(tunnelKey(hostId, workspaceId));
|
|
1734
|
-
if (tunnel?.readyState === WebSocket.OPEN) return tunnel;
|
|
1735
|
-
return void 0;
|
|
1736
|
-
}
|
|
1737
|
-
async function authorizeHostTunnel(options, bunOptions, request, input) {
|
|
1738
|
-
if (bunOptions.authorizeHostTunnel) {
|
|
1739
|
-
return { authorized: await bunOptions.authorizeHostTunnel(request, input) };
|
|
1740
|
-
}
|
|
1741
|
-
const token = bearerToken(request.headers.get("authorization"));
|
|
1742
|
-
if (!token) return { authorized: false };
|
|
1743
|
-
try {
|
|
1744
|
-
return { authorized: true, claims: await verifyHostTunnelToken(token, options.runtimeAccessKey, input) };
|
|
1745
|
-
} catch (err) {
|
|
1746
|
-
if (err instanceof WorkspaceRelayAuthError) return { authorized: false };
|
|
1747
|
-
throw err;
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
function clearHostGenerationWatcher(data) {
|
|
1751
|
-
if (data.generationCheckTimer) clearInterval(data.generationCheckTimer);
|
|
1752
|
-
data.generationCheckTimer = void 0;
|
|
1753
|
-
}
|
|
1754
|
-
function watchHostGeneration(ws, options, bunOptions) {
|
|
1755
|
-
const intervalMs = bunOptions.hostGenerationCheckIntervalMs ?? HOST_GENERATION_CHECK_INTERVAL_MS_DEFAULT;
|
|
1756
|
-
if (ws.data.generationCheckTimer || !options.resolveHostGeneration || ws.data.generation === void 0 || intervalMs <= 0) return;
|
|
1757
|
-
const graceAttempts = bunOptions.hostGenerationOutageGraceAttempts ?? HOST_GENERATION_OUTAGE_GRACE_ATTEMPTS_DEFAULT;
|
|
1758
|
-
ws.data.generationCheckTimer = setInterval(() => {
|
|
1759
|
-
void checkHostTunnelGeneration(options.resolveHostGeneration, {
|
|
1760
|
-
enrollment_id: ws.data.enrollmentId,
|
|
1761
|
-
generation: ws.data.generation
|
|
1762
|
-
}).then((decision) => {
|
|
1763
|
-
if (decision.ok) {
|
|
1764
|
-
ws.data.generationCheckFailures = 0;
|
|
1765
|
-
return;
|
|
1766
|
-
}
|
|
1767
|
-
if (!decision.retryable) {
|
|
1768
|
-
clearHostGenerationWatcher(ws.data);
|
|
1769
|
-
closeWebSocket(ws, 1008, decision.reason, 1008);
|
|
1770
|
-
return;
|
|
1771
|
-
}
|
|
1772
|
-
ws.data.generationCheckFailures += 1;
|
|
1773
|
-
if (ws.data.generationCheckFailures < graceAttempts) return;
|
|
1774
|
-
clearHostGenerationWatcher(ws.data);
|
|
1775
|
-
closeWebSocket(ws, 1012, "Host generation check unavailable", 1012);
|
|
1776
|
-
});
|
|
1777
|
-
}, intervalMs);
|
|
1778
|
-
if (typeof ws.data.generationCheckTimer.unref === "function") ws.data.generationCheckTimer.unref();
|
|
1779
|
-
}
|
|
1780
|
-
var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
|
|
1781
|
-
function clearClientAccessWatchers(data) {
|
|
1782
|
-
if (data.accessCheckTimer) clearInterval(data.accessCheckTimer);
|
|
1783
|
-
if (data.expiryTimer) clearTimeout(data.expiryTimer);
|
|
1784
|
-
data.accessCheckTimer = void 0;
|
|
1785
|
-
data.expiryTimer = void 0;
|
|
1786
|
-
}
|
|
1787
|
-
function watchClientAccess(ws, options, bunOptions) {
|
|
1788
|
-
const now = bunOptions.now ?? Date.now;
|
|
1789
|
-
const close = (reason) => {
|
|
1790
|
-
clearClientAccessWatchers(ws.data);
|
|
1791
|
-
closeWebSocket(ws, 1008, reason, 1008);
|
|
1792
|
-
};
|
|
1793
|
-
ws.data.expiryTimer = setTimeout(
|
|
1794
|
-
() => close("Runtime Access Token expired"),
|
|
1795
|
-
Math.max(0, ws.data.claims.exp * 1e3 - now())
|
|
1796
|
-
);
|
|
1797
|
-
if (typeof ws.data.expiryTimer.unref === "function") ws.data.expiryTimer.unref();
|
|
1798
|
-
const intervalMs = bunOptions.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
|
|
1799
|
-
if (!options.isRuntimeAccessTokenActive || intervalMs <= 0) return;
|
|
1800
|
-
ws.data.accessCheckTimer = setInterval(() => {
|
|
1801
|
-
void Promise.resolve(options.isRuntimeAccessTokenActive(ws.data.claims)).then((active) => {
|
|
1802
|
-
if (!active.active) close(active.reason);
|
|
1803
|
-
}).catch(() => {
|
|
1804
|
-
});
|
|
1805
|
-
}, intervalMs);
|
|
1806
|
-
if (typeof ws.data.accessCheckTimer.unref === "function") ws.data.accessCheckTimer.unref();
|
|
1807
|
-
}
|
|
1808
|
-
function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
1809
|
-
const hostTunnels = /* @__PURE__ */ new Map();
|
|
1810
|
-
const relayClients = /* @__PURE__ */ new Set();
|
|
1811
|
-
const hostTunnelRegistrations = /* @__PURE__ */ new Map();
|
|
1812
|
-
const directHttpLimiter = createDirectHttpLimiter(bunOptions.directHttpConcurrency);
|
|
1813
|
-
const hostTunnelStateDebounce = /* @__PURE__ */ new Map();
|
|
1814
|
-
const fragmentationStats = createFragmentationStats();
|
|
1815
|
-
const slowConsumerStats = createSlowConsumerStats();
|
|
1816
|
-
const relayOriginMatcher = options.allowedOrigins ? createOriginMatcher(options.allowedOrigins) : defaultRelayOriginMatcher;
|
|
1817
|
-
const telemetry = {
|
|
1818
|
-
getFragmentationStats: () => readFragmentationStats(fragmentationStats),
|
|
1819
|
-
resetFragmentationStats: () => resetFragmentationStats(fragmentationStats),
|
|
1820
|
-
getSlowConsumerStats: () => readSlowConsumerStats(slowConsumerStats),
|
|
1821
|
-
resetSlowConsumerStats: () => resetSlowConsumerStats(slowConsumerStats)
|
|
1822
|
-
};
|
|
1823
|
-
let draining = false;
|
|
1824
|
-
const drainController = {
|
|
1825
|
-
isDraining: () => draining,
|
|
1826
|
-
setDraining: (value) => {
|
|
1827
|
-
draining = value;
|
|
1828
|
-
if (!value) return;
|
|
1829
|
-
for (const tunnel of new Set(hostTunnels.values())) {
|
|
1830
|
-
closeWebSocket(tunnel, 1012, "Workspace relay is draining", 1012);
|
|
1831
|
-
}
|
|
1832
|
-
for (const client of relayClients) {
|
|
1833
|
-
closeWebSocket(client, 1012, "Workspace relay is draining", 1012);
|
|
1834
|
-
}
|
|
1835
|
-
},
|
|
1836
|
-
pendingCount: () => {
|
|
1837
|
-
let total = 0;
|
|
1838
|
-
for (const tunnel of new Set(hostTunnels.values())) {
|
|
1839
|
-
total += tunnel.data.pending.size;
|
|
1840
|
-
}
|
|
1841
|
-
return total;
|
|
1842
|
-
},
|
|
1843
|
-
waitForDrain: async (timeoutMs) => {
|
|
1844
|
-
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
1845
|
-
const pollMs = 50;
|
|
1846
|
-
while (true) {
|
|
1847
|
-
const remaining = drainController.pendingCount();
|
|
1848
|
-
if (remaining === 0) return { drained: true, remaining: 0 };
|
|
1849
|
-
const now = Date.now();
|
|
1850
|
-
if (now >= deadline) return { drained: false, remaining };
|
|
1851
|
-
const sleep = Math.min(pollMs, Math.max(1, deadline - now));
|
|
1852
|
-
await new Promise((resolve) => setTimeout(resolve, sleep));
|
|
1853
|
-
}
|
|
1854
|
-
}
|
|
1855
|
-
};
|
|
1856
|
-
const mergedMetricsSources = {
|
|
1857
|
-
fragmentation: options.metricsSources?.fragmentation ?? telemetry.getFragmentationStats,
|
|
1858
|
-
slowConsumer: options.metricsSources?.slowConsumer ?? telemetry.getSlowConsumerStats,
|
|
1859
|
-
drainPending: options.metricsSources?.drainPending ?? drainController.pendingCount
|
|
1860
|
-
};
|
|
1861
|
-
const requestRemoteAddress = /* @__PURE__ */ new WeakMap();
|
|
1862
|
-
const metricsRemoteAddressResolver = options.metricsRemoteAddress ?? ((request) => requestRemoteAddress.get(request));
|
|
1863
|
-
const app = createWorkspaceRelay({
|
|
1864
|
-
...options,
|
|
1865
|
-
isDraining: drainController.isDraining,
|
|
1866
|
-
metricsSources: mergedMetricsSources,
|
|
1867
|
-
metricsRemoteAddress: metricsRemoteAddressResolver
|
|
1868
|
-
});
|
|
1869
|
-
function getRegistrationTracker(hostId) {
|
|
1870
|
-
let tracker = hostTunnelRegistrations.get(hostId);
|
|
1871
|
-
if (!tracker) {
|
|
1872
|
-
tracker = { recent: [] };
|
|
1873
|
-
hostTunnelRegistrations.set(hostId, tracker);
|
|
1874
|
-
}
|
|
1875
|
-
return tracker;
|
|
1876
|
-
}
|
|
1877
|
-
function claimTunnelIdentities(ws, workspaceIds) {
|
|
1878
|
-
const hostId = ws.data.hostId;
|
|
1879
|
-
const displaced = /* @__PURE__ */ new Set();
|
|
1880
|
-
for (const workspaceId of workspaceIds) {
|
|
1881
|
-
const previous = hostTunnels.get(tunnelKey(hostId, workspaceId));
|
|
1882
|
-
if (!previous || previous === ws) continue;
|
|
1883
|
-
if (outranks(previous, ws.data.generation)) return workspaceId;
|
|
1884
|
-
displaced.add(previous);
|
|
1885
|
-
}
|
|
1886
|
-
for (const workspaceId of workspaceIds) hostTunnels.set(tunnelKey(hostId, workspaceId), ws);
|
|
1887
|
-
for (const previous of displaced) {
|
|
1888
|
-
if (ownedWorkspaceIds(hostTunnels, previous).length > 0) continue;
|
|
1889
|
-
cleanupHostTunnelSocket({
|
|
1890
|
-
ws: previous,
|
|
1891
|
-
hostTunnels,
|
|
1892
|
-
hostTunnelStateDebounce,
|
|
1893
|
-
options,
|
|
1894
|
-
bunOptions,
|
|
1895
|
-
originAllowed: relayOriginMatcher,
|
|
1896
|
-
disconnectDirectory: false,
|
|
1897
|
-
closeChannels: true
|
|
1898
|
-
});
|
|
1899
|
-
clearHostGenerationWatcher(previous.data);
|
|
1900
|
-
closeWebSocket(previous, 1012, "Host tunnel replaced by a newer connection", 1012);
|
|
1901
|
-
}
|
|
1902
|
-
return void 0;
|
|
1903
|
-
}
|
|
1904
|
-
async function applyRegistrationUpdate(hostSocket, workspaceIds, token) {
|
|
1905
|
-
const hostId = hostSocket.data.hostId;
|
|
1906
|
-
const authorizationRequest = new Request(
|
|
1907
|
-
`http://relay.local/host-tunnels/${encodeURIComponent(hostId)}`,
|
|
1908
|
-
{ headers: { authorization: `Bearer ${token}` } }
|
|
1909
|
-
);
|
|
1910
|
-
const authorization = await authorizeHostTunnel(options, bunOptions, authorizationRequest, { hostId, workspaceIds });
|
|
1911
|
-
if (!authorization.authorized) {
|
|
1912
|
-
await refuseHostTunnelSocket(options, hostSocket, {
|
|
1913
|
-
code: "host_tunnel_token_invalid",
|
|
1914
|
-
close: 1008,
|
|
1915
|
-
reason: "Host tunnel registration update denied"
|
|
1916
|
-
});
|
|
1917
|
-
return;
|
|
1918
|
-
}
|
|
1919
|
-
const claims = authorization.claims;
|
|
1920
|
-
if (hostTunnelIncumbentOutranks(hostSocket.data.generation, claims?.generation)) {
|
|
1921
|
-
await refuseHostTunnelSocket(options, hostSocket, {
|
|
1922
|
-
code: "host_generation_superseded",
|
|
1923
|
-
close: 1008,
|
|
1924
|
-
reason: "Host tunnel registration update superseded"
|
|
1925
|
-
});
|
|
1926
|
-
return;
|
|
1927
|
-
}
|
|
1928
|
-
const generation = await checkHostTunnelGeneration(options.resolveHostGeneration, {
|
|
1929
|
-
enrollment_id: claims?.enrollment_id,
|
|
1930
|
-
generation: claims?.generation
|
|
1931
|
-
});
|
|
1932
|
-
if (!generation.ok) {
|
|
1933
|
-
await refuseHostTunnelSocket(options, hostSocket, {
|
|
1934
|
-
code: generation.code,
|
|
1935
|
-
close: generation.retryable ? 1012 : 1008,
|
|
1936
|
-
reason: generation.retryable ? "Host generation check unavailable" : generation.reason
|
|
1937
|
-
});
|
|
1938
|
-
return;
|
|
1939
|
-
}
|
|
1940
|
-
const owned = ownedWorkspaceIds(hostTunnels, hostSocket);
|
|
1941
|
-
if (owned.length === 0 || hostSocket.readyState !== WebSocket.OPEN) return;
|
|
1942
|
-
const released = owned.filter((workspaceId) => !workspaceIds.includes(workspaceId));
|
|
1943
|
-
for (const workspaceId of released) hostTunnels.delete(tunnelKey(hostId, workspaceId));
|
|
1944
|
-
if (released.length) options.directory?.disconnectHost(hostId, released);
|
|
1945
|
-
hostSocket.data.enrollmentId = claims?.enrollment_id;
|
|
1946
|
-
hostSocket.data.generation = claims?.generation;
|
|
1947
|
-
const outranked = claimTunnelIdentities(hostSocket, workspaceIds);
|
|
1948
|
-
if (outranked) {
|
|
1949
|
-
await refuseHostTunnelSocket(options, hostSocket, {
|
|
1950
|
-
code: "host_generation_superseded",
|
|
1951
|
-
close: 1008,
|
|
1952
|
-
reason: "Host tunnel generation was superseded",
|
|
1953
|
-
workspaceId: outranked
|
|
1954
|
-
});
|
|
1955
|
-
return;
|
|
1956
|
-
}
|
|
1957
|
-
hostSocket.data.workspaceIds = workspaceIds;
|
|
1958
|
-
options.directory?.registerHostTunnel({ hostId, workspaceIds });
|
|
1959
|
-
watchHostGeneration(hostSocket, options, bunOptions);
|
|
1960
|
-
}
|
|
1961
|
-
function pruneReconnects(tracker, now) {
|
|
1962
|
-
const cutoff = now - HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS;
|
|
1963
|
-
while (tracker.recent.length && tracker.recent[0] < cutoff) {
|
|
1964
|
-
tracker.recent.shift();
|
|
1965
|
-
}
|
|
1966
|
-
}
|
|
1967
|
-
return {
|
|
1968
|
-
// A function-valued property rather than a method: `main.ts` hands it
|
|
1969
|
-
// straight to `Bun.serve({ fetch: handler.fetch })`, detached from this
|
|
1970
|
-
// object, and it closes over the adapter's state rather than reading `this`.
|
|
1971
|
-
//
|
|
1972
|
-
// `undefined` is Bun's contract for "this request became a WebSocket":
|
|
1973
|
-
// `server.upgrade()` has already taken ownership of the socket, so there is
|
|
1974
|
-
// no HTTP response left to return.
|
|
1975
|
-
fetch: async (request, server) => {
|
|
1976
|
-
const url = new URL(request.url);
|
|
1977
|
-
const workspaceId = workspaceIdFromPath(url.pathname);
|
|
1978
|
-
const hostId = hostIdFromTunnelPath(url.pathname);
|
|
1979
|
-
try {
|
|
1980
|
-
const ip = server.requestIP(request)?.address;
|
|
1981
|
-
if (ip) requestRemoteAddress.set(request, ip);
|
|
1982
|
-
} catch {
|
|
1983
|
-
}
|
|
1984
|
-
if (url.pathname === "/health") {
|
|
1985
|
-
return Response.json(
|
|
1986
|
-
draining ? { ok: false, service: "workspace-relay", draining: true } : { ok: true, service: "workspace-relay" },
|
|
1987
|
-
{ status: draining ? 503 : 200 }
|
|
1988
|
-
);
|
|
1989
|
-
}
|
|
1990
|
-
if (draining && websocketRequest(request) && hostId) {
|
|
1991
|
-
return jsonError(
|
|
1992
|
-
"relay_draining",
|
|
1993
|
-
"Workspace relay is shutting down; new tunnel registrations are not accepted",
|
|
1994
|
-
503
|
|
1995
|
-
);
|
|
1996
|
-
}
|
|
1997
|
-
if (draining && workspaceId) {
|
|
1998
|
-
const headers2 = relayCorsHeaders(request, relayOriginMatcher);
|
|
1999
|
-
const body = JSON.stringify({
|
|
2000
|
-
error: {
|
|
2001
|
-
code: "relay_draining",
|
|
2002
|
-
message: "Workspace relay is shutting down; try another instance"
|
|
2003
|
-
}
|
|
2004
|
-
});
|
|
2005
|
-
headers2.set("content-type", "application/json");
|
|
2006
|
-
return new Response(body, { status: 503, headers: headers2 });
|
|
2007
|
-
}
|
|
2008
|
-
if (websocketRequest(request) && hostId) {
|
|
2009
|
-
const workspaceIds = url.searchParams.getAll("workspaceId");
|
|
2010
|
-
if (!workspaceIds.length || !options.directory) {
|
|
2011
|
-
return new Response("Host tunnel registration is unavailable", { status: 503 });
|
|
2012
|
-
}
|
|
2013
|
-
const authorization = await authorizeHostTunnel(options, bunOptions, request, { hostId, workspaceIds });
|
|
2014
|
-
if (!authorization.authorized) {
|
|
2015
|
-
await denyHostTunnel(options, { code: "host_tunnel_token_invalid", message: "Host tunnel registration denied", status: 403, hostId });
|
|
2016
|
-
return new Response("Host tunnel registration denied", { status: 403 });
|
|
2017
|
-
}
|
|
2018
|
-
const claims = authorization.claims;
|
|
2019
|
-
const generation = await checkHostTunnelGeneration(options.resolveHostGeneration, {
|
|
2020
|
-
enrollment_id: claims?.enrollment_id,
|
|
2021
|
-
generation: claims?.generation
|
|
2022
|
-
});
|
|
2023
|
-
if (!generation.ok) {
|
|
2024
|
-
return denyHostTunnel(options, {
|
|
2025
|
-
code: generation.code,
|
|
2026
|
-
message: generation.reason,
|
|
2027
|
-
status: generation.retryable ? 503 : 403,
|
|
2028
|
-
hostId
|
|
2029
|
-
});
|
|
2030
|
-
}
|
|
2031
|
-
for (const workspaceId2 of workspaceIds) {
|
|
2032
|
-
const incumbent = hostTunnels.get(tunnelKey(hostId, workspaceId2));
|
|
2033
|
-
if (incumbent && outranks(incumbent, claims?.generation)) {
|
|
2034
|
-
return denyHostTunnel(options, {
|
|
2035
|
-
code: "host_generation_superseded",
|
|
2036
|
-
message: "Host tunnel generation was superseded",
|
|
2037
|
-
status: 403,
|
|
2038
|
-
hostId,
|
|
2039
|
-
workspaceId: workspaceId2
|
|
2040
|
-
});
|
|
2041
|
-
}
|
|
2042
|
-
}
|
|
2043
|
-
const tracker = getRegistrationTracker(hostId);
|
|
2044
|
-
const now = Date.now();
|
|
2045
|
-
pruneReconnects(tracker, now);
|
|
2046
|
-
if (tracker.recent.length >= HOST_TUNNEL_REGISTRATION_RECONNECT_CAP) {
|
|
2047
|
-
return denyHostTunnel(options, {
|
|
2048
|
-
code: "too_many_host_tunnel_reconnects",
|
|
2049
|
-
message: "Too many host-tunnel reconnects for this host within the last 60 seconds",
|
|
2050
|
-
status: 429,
|
|
2051
|
-
hostId
|
|
2052
|
-
});
|
|
2053
|
-
}
|
|
2054
|
-
if (server.upgrade(request, {
|
|
2055
|
-
data: {
|
|
2056
|
-
kind: "host-tunnel",
|
|
2057
|
-
hostId,
|
|
2058
|
-
workspaceIds,
|
|
2059
|
-
...claims?.enrollment_id ? { enrollmentId: claims.enrollment_id } : {},
|
|
2060
|
-
...claims?.generation !== void 0 ? { generation: claims.generation } : {},
|
|
2061
|
-
pending: /* @__PURE__ */ new Map(),
|
|
2062
|
-
channels: /* @__PURE__ */ new Map(),
|
|
2063
|
-
missedPongs: 0,
|
|
2064
|
-
messageBuffer: "",
|
|
2065
|
-
generationCheckFailures: 0
|
|
2066
|
-
}
|
|
2067
|
-
})) {
|
|
2068
|
-
tracker.recent.push(now);
|
|
2069
|
-
return void 0;
|
|
2070
|
-
}
|
|
2071
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
2072
|
-
}
|
|
2073
|
-
if (!workspaceId) return app.request(request);
|
|
2074
|
-
if (request.method === "OPTIONS") {
|
|
2075
|
-
return new Response(null, {
|
|
2076
|
-
status: 204,
|
|
2077
|
-
headers: relayCorsHeaders(request, relayOriginMatcher)
|
|
2078
|
-
});
|
|
2079
|
-
}
|
|
2080
|
-
if (!websocketRequest(request)) {
|
|
2081
|
-
const trace = createWorkspaceRelayTrace();
|
|
2082
|
-
const response = await trace.span("relay-total", async () => {
|
|
2083
|
-
const relay2 = await authorizeWorkspaceRelayRequest(options, request, workspaceId, trace);
|
|
2084
|
-
if (!relay2.ok) return relay2.response;
|
|
2085
|
-
if (isUserHostedTarget(relay2.request.target)) {
|
|
2086
|
-
const tunnel = hostTunnel(hostTunnels, relay2.request.target.hostId, relay2.request.target.workspaceId);
|
|
2087
|
-
return tunnel ? await trace.span("tunnel-http", async () => await tunnelHttpRequest({
|
|
2088
|
-
ws: tunnel,
|
|
2089
|
-
request,
|
|
2090
|
-
originAllowed: relayOriginMatcher,
|
|
2091
|
-
workspaceId: relay2.request.target.workspaceId,
|
|
2092
|
-
path: `${relay2.request.path}${url.search}`,
|
|
2093
|
-
relayHostToken: relay2.request.relayHostToken,
|
|
2094
|
-
slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
|
|
2095
|
-
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
|
|
2096
|
-
slowConsumerStats,
|
|
2097
|
-
requestBodyMaxBytes: bunOptions.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT,
|
|
2098
|
-
responseTimeoutMs: bunOptions.tunnelHttpResponseTimeoutMs ?? TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT
|
|
2099
|
-
})) : new Response("User-hosted workspace is offline", { status: 503 });
|
|
2100
|
-
}
|
|
2101
|
-
return await directHttpRequest({
|
|
2102
|
-
request,
|
|
2103
|
-
originAllowed: relayOriginMatcher,
|
|
2104
|
-
targetUrl: workspaceRelayTargetUrl(
|
|
2105
|
-
relay2.request.target,
|
|
2106
|
-
relay2.request.path,
|
|
2107
|
-
url.search
|
|
2108
|
-
).toString(),
|
|
2109
|
-
relayHostToken: relay2.request.relayHostToken,
|
|
2110
|
-
workspaceId: relay2.request.target.workspaceId,
|
|
2111
|
-
upstreamHeaders: relay2.request.target.upstreamHeaders,
|
|
2112
|
-
timeoutMs: bunOptions.directHttpTimeoutMs ?? DIRECT_HTTP_TIMEOUT_MS_DEFAULT,
|
|
2113
|
-
limiter: directHttpLimiter,
|
|
2114
|
-
trace
|
|
2115
|
-
});
|
|
2116
|
-
});
|
|
2117
|
-
return workspaceRelayTimingResponse(response, trace);
|
|
2118
|
-
}
|
|
2119
|
-
const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
|
|
2120
|
-
if (!relay.ok) return relay.response;
|
|
2121
|
-
const originDenied = requireAllowedOrigin(request, relayOriginMatcher);
|
|
2122
|
-
if (originDenied) return originDenied;
|
|
2123
|
-
if (isUserHostedTarget(relay.request.target)) {
|
|
2124
|
-
const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId, relay.request.target.workspaceId);
|
|
2125
|
-
if (!tunnel) {
|
|
2126
|
-
return new Response("User-hosted workspace is offline", { status: 503 });
|
|
2127
|
-
}
|
|
2128
|
-
if (tunnel.data.channels.size >= TUNNEL_CHANNEL_CAP) {
|
|
2129
|
-
return jsonError(
|
|
2130
|
-
"too_many_channels",
|
|
2131
|
-
"User-hosted tunnel has too many active WebSocket channels",
|
|
2132
|
-
503
|
|
2133
|
-
);
|
|
2134
|
-
}
|
|
2135
|
-
if (server.upgrade(request, {
|
|
2136
|
-
data: {
|
|
2137
|
-
kind: "user-hosted-client",
|
|
2138
|
-
claims: relay.request.claims,
|
|
2139
|
-
hostId: relay.request.target.hostId,
|
|
2140
|
-
workspaceId: relay.request.target.workspaceId,
|
|
2141
|
-
channelId: crypto.randomUUID(),
|
|
2142
|
-
path: `${relay.request.path}${url.search}`,
|
|
2143
|
-
relayHostToken: relay.request.relayHostToken
|
|
2144
|
-
}
|
|
2145
|
-
})) {
|
|
2146
|
-
return void 0;
|
|
2147
|
-
}
|
|
2148
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
2149
|
-
}
|
|
2150
|
-
if (server.upgrade(request, {
|
|
2151
|
-
data: {
|
|
2152
|
-
kind: "client",
|
|
2153
|
-
claims: relay.request.claims,
|
|
2154
|
-
upstreamUrl: workspaceRelayTargetUrl(
|
|
2155
|
-
relay.request.target,
|
|
2156
|
-
relay.request.path,
|
|
2157
|
-
url.search
|
|
2158
|
-
).toString().replace(/^http/, "ws"),
|
|
2159
|
-
headers: headersRecord(workspaceRelayForwardHeaders(
|
|
2160
|
-
new Headers(),
|
|
2161
|
-
relay.request.relayHostToken,
|
|
2162
|
-
relay.request.target.workspaceId,
|
|
2163
|
-
// Cloud-vm WS upgrade: cookies pass through.
|
|
2164
|
-
{ userHosted: false, upstreamHeaders: relay.request.target.upstreamHeaders }
|
|
2165
|
-
)),
|
|
2166
|
-
queue: [],
|
|
2167
|
-
...relayWebSocketTraceEnabled(request) ? {
|
|
2168
|
-
trace: {
|
|
2169
|
-
acceptedAt: performance.now(),
|
|
2170
|
-
queuedFrames: 0,
|
|
2171
|
-
maxQueuedDelayMs: 0,
|
|
2172
|
-
emitted: false
|
|
2173
|
-
}
|
|
2174
|
-
} : {}
|
|
2175
|
-
}
|
|
2176
|
-
})) {
|
|
2177
|
-
return void 0;
|
|
2178
|
-
}
|
|
2179
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
2180
|
-
},
|
|
2181
|
-
websocket: {
|
|
2182
|
-
maxPayloadLength: WS_MAX_PAYLOAD_LENGTH_BYTES,
|
|
2183
|
-
message(ws, message) {
|
|
2184
|
-
if (isHostTunnelSocket(ws)) {
|
|
2185
|
-
const parsed = tunnelMessage(
|
|
2186
|
-
ws,
|
|
2187
|
-
message,
|
|
2188
|
-
fragmentationStats
|
|
2189
|
-
);
|
|
2190
|
-
if (parsed?.type === "ping") {
|
|
2191
|
-
options.directory?.recordPong(ws.data.hostId, ownedWorkspaceIds(hostTunnels, ws));
|
|
2192
|
-
ws.send(JSON.stringify(makeTunnelPong(parsed)));
|
|
2193
|
-
}
|
|
2194
|
-
if (parsed?.type === "pong") {
|
|
2195
|
-
ws.data.missedPongs = 0;
|
|
2196
|
-
options.directory?.recordPong(ws.data.hostId, ownedWorkspaceIds(hostTunnels, ws));
|
|
2197
|
-
}
|
|
2198
|
-
if (parsed?.type === "host.registration.update") {
|
|
2199
|
-
const hostSocket = ws;
|
|
2200
|
-
void applyRegistrationUpdate(hostSocket, [...new Set(parsed.workspace_ids)], parsed.token).catch(() => closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008));
|
|
2201
|
-
return;
|
|
2202
|
-
}
|
|
2203
|
-
if (parsed?.type === "error") {
|
|
2204
|
-
if (parsed.request_id) {
|
|
2205
|
-
const pending = ws.data.pending.get(parsed.request_id);
|
|
2206
|
-
if (pending) {
|
|
2207
|
-
ws.data.pending.delete(parsed.request_id);
|
|
2208
|
-
failPendingHttpResponse({
|
|
2209
|
-
entry: pending,
|
|
2210
|
-
response: jsonError(parsed.code, parsed.message, 502),
|
|
2211
|
-
error: new Error(parsed.message)
|
|
2212
|
-
});
|
|
2213
|
-
}
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
if (parsed?.type === "http.response.start") {
|
|
2217
|
-
const pending = ws.data.pending.get(parsed.request_id);
|
|
2218
|
-
if (pending) {
|
|
2219
|
-
const response = parsed;
|
|
2220
|
-
pending.responseStarted = true;
|
|
2221
|
-
if (isEventStream(response.headers)) clearTimeout(pending.timeout);
|
|
2222
|
-
pending.resolve(new Response(pending.stream, {
|
|
2223
|
-
status: response.status,
|
|
2224
|
-
headers: pending.corsHeaders(headers(response.headers))
|
|
2225
|
-
}));
|
|
2226
|
-
}
|
|
2227
|
-
}
|
|
2228
|
-
if (parsed?.type === "http.response.chunk") {
|
|
2229
|
-
const entry = ws.data.pending.get(parsed.request_id);
|
|
2230
|
-
if (entry) {
|
|
2231
|
-
enqueueChunkWithBackpressure({
|
|
2232
|
-
ws,
|
|
2233
|
-
requestId: parsed.request_id,
|
|
2234
|
-
entry,
|
|
2235
|
-
chunk: decoded(parsed.body_base64),
|
|
2236
|
-
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
|
|
2237
|
-
slowConsumerStats
|
|
2238
|
-
});
|
|
2239
|
-
}
|
|
2240
|
-
}
|
|
2241
|
-
if (parsed?.type === "http.response.end") {
|
|
2242
|
-
const pending = ws.data.pending.get(parsed.request_id);
|
|
2243
|
-
if (pending) {
|
|
2244
|
-
clearTimeout(pending.timeout);
|
|
2245
|
-
if (pending.slowConsumerTimeout) {
|
|
2246
|
-
clearTimeout(pending.slowConsumerTimeout);
|
|
2247
|
-
pending.slowConsumerTimeout = void 0;
|
|
2248
|
-
}
|
|
2249
|
-
drainPendingChunks(pending);
|
|
2250
|
-
if (pending.pendingChunks.length > 0) {
|
|
2251
|
-
for (const chunk of pending.pendingChunks) {
|
|
2252
|
-
try {
|
|
2253
|
-
pending.controller.enqueue(chunk);
|
|
2254
|
-
} catch {
|
|
2255
|
-
break;
|
|
2256
|
-
}
|
|
2257
|
-
}
|
|
2258
|
-
pending.pendingChunks.length = 0;
|
|
2259
|
-
pending.bytesQueued = 0;
|
|
2260
|
-
}
|
|
2261
|
-
try {
|
|
2262
|
-
pending.controller.close();
|
|
2263
|
-
} catch {
|
|
2264
|
-
}
|
|
2265
|
-
ws.data.pending.delete(parsed.request_id);
|
|
2266
|
-
}
|
|
2267
|
-
}
|
|
2268
|
-
if (parsed?.type === "ws.frame") {
|
|
2269
|
-
const frame = parsed;
|
|
2270
|
-
const channel = ws.data.channels.get(frame.channel_id);
|
|
2271
|
-
if (!channel || channel.readyState !== WebSocket.OPEN) {
|
|
2272
|
-
ws.data.channels.delete(frame.channel_id);
|
|
2273
|
-
return;
|
|
2274
|
-
}
|
|
2275
|
-
if (relayOverBackpressureLimit(channel, bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
|
|
2276
|
-
ws.data.channels.delete(frame.channel_id);
|
|
2277
|
-
closeWebSocket(channel, 1011, "Client WebSocket backpressure limit exceeded");
|
|
2278
|
-
return;
|
|
2279
|
-
}
|
|
2280
|
-
channel.send(decodedFrame(frame));
|
|
2281
|
-
}
|
|
2282
|
-
if (parsed?.type === "ws.close") {
|
|
2283
|
-
const close = parsed;
|
|
2284
|
-
const channel = ws.data.channels.get(close.channel_id);
|
|
2285
|
-
if (channel) {
|
|
2286
|
-
ws.data.channels.delete(close.channel_id);
|
|
2287
|
-
closeWebSocket(channel, close.code, close.reason);
|
|
2288
|
-
}
|
|
2289
|
-
}
|
|
2290
|
-
return;
|
|
2291
|
-
}
|
|
2292
|
-
if (isUserHostedClientSocket(ws)) {
|
|
2293
|
-
const tunnel = hostTunnel(hostTunnels, ws.data.hostId, ws.data.workspaceId);
|
|
2294
|
-
if (!tunnel) {
|
|
2295
|
-
ws.close(1011, "User-hosted tunnel disconnected");
|
|
2296
|
-
return;
|
|
2297
|
-
}
|
|
2298
|
-
if (relayOverBackpressureLimit(tunnel, bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
|
|
2299
|
-
closeWebSocket(ws, 1011, "Host tunnel backpressure limit exceeded");
|
|
2300
|
-
return;
|
|
2301
|
-
}
|
|
2302
|
-
tunnel.send(JSON.stringify({
|
|
2303
|
-
type: "ws.frame",
|
|
2304
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2305
|
-
channel_id: ws.data.channelId,
|
|
2306
|
-
...encodedFrame(message)
|
|
2307
|
-
}));
|
|
2308
|
-
return;
|
|
2309
|
-
}
|
|
2310
|
-
if (!isRelayClientSocket(ws)) return;
|
|
2311
|
-
if (ws.data.upstream?.readyState === WebSocket.OPEN) {
|
|
2312
|
-
ws.data.upstream.send(message);
|
|
2313
|
-
return;
|
|
2314
|
-
}
|
|
2315
|
-
const queuedBytes = ws.data.queuedBytes ?? 0;
|
|
2316
|
-
const frameBytes = preOpenFrameBytes(message);
|
|
2317
|
-
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)) {
|
|
2318
|
-
closeWebSocket(ws, 1011, "Upstream WebSocket queue limit exceeded");
|
|
2319
|
-
return;
|
|
2320
|
-
}
|
|
2321
|
-
ws.data.queuedBytes = queuedBytes + frameBytes;
|
|
2322
|
-
ws.data.queue.push({ payload: message, queuedAt: performance.now() });
|
|
2323
|
-
},
|
|
2324
|
-
open(ws) {
|
|
2325
|
-
if (isHostTunnelSocket(ws)) {
|
|
2326
|
-
const outranked = claimTunnelIdentities(ws, ws.data.workspaceIds);
|
|
2327
|
-
if (outranked) {
|
|
2328
|
-
void refuseHostTunnelSocket(options, ws, {
|
|
2329
|
-
code: "host_generation_superseded",
|
|
2330
|
-
close: 1008,
|
|
2331
|
-
reason: "Host tunnel generation was superseded",
|
|
2332
|
-
workspaceId: outranked
|
|
2333
|
-
});
|
|
2334
|
-
return;
|
|
2335
|
-
}
|
|
2336
|
-
options.directory?.registerHostTunnel({
|
|
2337
|
-
hostId: ws.data.hostId,
|
|
2338
|
-
workspaceIds: ws.data.workspaceIds
|
|
2339
|
-
});
|
|
2340
|
-
ws.data.heartbeat = setInterval(() => {
|
|
2341
|
-
const hostWs = ws;
|
|
2342
|
-
if (hostWs.data.missedPongs > (bunOptions.hostTunnelMaxMissedPongs ?? HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT)) {
|
|
2343
|
-
closeWebSocket(ws, 1001, "Host tunnel heartbeat timed out", 1001);
|
|
2344
|
-
return;
|
|
2345
|
-
}
|
|
2346
|
-
sendTunnelPing(hostWs);
|
|
2347
|
-
}, bunOptions.hostTunnelPingIntervalMs ?? 15e3);
|
|
2348
|
-
watchHostGeneration(ws, options, bunOptions);
|
|
2349
|
-
scheduleHostTunnelStateChange(hostTunnelStateDebounce, options, {
|
|
2350
|
-
hostId: ws.data.hostId,
|
|
2351
|
-
workspaceIds: ws.data.workspaceIds,
|
|
2352
|
-
path: `/host-tunnels/${ws.data.hostId}`,
|
|
2353
|
-
state: "connected",
|
|
2354
|
-
debounceMs: bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
|
|
2355
|
-
});
|
|
2356
|
-
return;
|
|
2357
|
-
}
|
|
2358
|
-
if (isUserHostedClientSocket(ws)) {
|
|
2359
|
-
watchClientAccess(ws, options, bunOptions);
|
|
2360
|
-
const tunnel = hostTunnel(hostTunnels, ws.data.hostId, ws.data.workspaceId);
|
|
2361
|
-
if (!tunnel) {
|
|
2362
|
-
ws.close(1011, "User-hosted tunnel disconnected");
|
|
2363
|
-
return;
|
|
2364
|
-
}
|
|
2365
|
-
tunnel.data.channels.set(ws.data.channelId, ws);
|
|
2366
|
-
if (tunnel.readyState !== WebSocket.OPEN) {
|
|
2367
|
-
closeWebSocket(ws, 1011, "User-hosted tunnel disconnected");
|
|
2368
|
-
return;
|
|
2369
|
-
}
|
|
2370
|
-
tunnel.send(JSON.stringify({
|
|
2371
|
-
type: "ws.open",
|
|
2372
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2373
|
-
channel_id: ws.data.channelId,
|
|
2374
|
-
workspace_id: ws.data.workspaceId,
|
|
2375
|
-
path: ws.data.path,
|
|
2376
|
-
headers: headersRecord(workspaceRelayForwardHeaders(
|
|
2377
|
-
new Headers(),
|
|
2378
|
-
ws.data.relayHostToken,
|
|
2379
|
-
ws.data.workspaceId,
|
|
2380
|
-
// User-hosted tunnel WS upgrade: strip Cookie. Headers are empty
|
|
2381
|
-
// here today; the flag keeps the rule uniform.
|
|
2382
|
-
{ userHosted: true }
|
|
2383
|
-
))
|
|
2384
|
-
}));
|
|
2385
|
-
return;
|
|
2386
|
-
}
|
|
2387
|
-
if (!isRelayClientSocket(ws)) return;
|
|
2388
|
-
relayClients.add(ws);
|
|
2389
|
-
watchClientAccess(ws, options, bunOptions);
|
|
2390
|
-
const data = ws.data;
|
|
2391
|
-
const UpstreamWebSocket = resolveUpstreamWebSocket(bunOptions.upstreamWebSocket);
|
|
2392
|
-
if (data.trace) data.trace.upstreamStartedAt = performance.now();
|
|
2393
|
-
const upstream = new UpstreamWebSocket(data.upstreamUrl, {
|
|
2394
|
-
headers: data.headers
|
|
2395
|
-
});
|
|
2396
|
-
upstream.binaryType = "arraybuffer";
|
|
2397
|
-
data.upstream = upstream;
|
|
2398
|
-
data.upstreamOpenTimer = setTimeout(() => {
|
|
2399
|
-
if (upstream.readyState === WebSocket.OPEN) return;
|
|
2400
|
-
closeWebSocket(upstream, 1001, "Upstream WebSocket open timed out", 1001);
|
|
2401
|
-
closeWebSocket(ws, 1011, "Upstream WebSocket open timed out");
|
|
2402
|
-
}, bunOptions.upstreamWebSocketOpenTimeoutMs ?? UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT);
|
|
2403
|
-
upstream.onopen = () => {
|
|
2404
|
-
const openedAt = performance.now();
|
|
2405
|
-
if (data.upstreamOpenTimer) {
|
|
2406
|
-
clearTimeout(data.upstreamOpenTimer);
|
|
2407
|
-
data.upstreamOpenTimer = void 0;
|
|
2408
|
-
}
|
|
2409
|
-
if (data.trace) {
|
|
2410
|
-
data.trace.upstreamOpenMs = openedAt - (data.trace.upstreamStartedAt ?? data.trace.acceptedAt);
|
|
2411
|
-
data.trace.queuedFrames = data.queue.length;
|
|
2412
|
-
data.trace.maxQueuedDelayMs = data.queue.reduce(
|
|
2413
|
-
(max, item) => Math.max(max, openedAt - item.queuedAt),
|
|
2414
|
-
0
|
|
2415
|
-
);
|
|
2416
|
-
sendRelayWebSocketTrace(ws);
|
|
2417
|
-
}
|
|
2418
|
-
for (const item of data.queue.splice(0)) upstream.send(item.payload);
|
|
2419
|
-
data.queuedBytes = 0;
|
|
2420
|
-
};
|
|
2421
|
-
upstream.onmessage = (event) => {
|
|
2422
|
-
const payload = relayWebSocketPayload(event.data);
|
|
2423
|
-
if (payload !== void 0) ws.send(payload);
|
|
2424
|
-
};
|
|
2425
|
-
upstream.onclose = (event) => {
|
|
2426
|
-
if (data.upstreamOpenTimer) {
|
|
2427
|
-
clearTimeout(data.upstreamOpenTimer);
|
|
2428
|
-
data.upstreamOpenTimer = void 0;
|
|
2429
|
-
}
|
|
2430
|
-
closeWebSocket(ws, event.code, upstreamCloseReason(event));
|
|
2431
|
-
};
|
|
2432
|
-
upstream.onerror = () => {
|
|
2433
|
-
if (data.upstreamOpenTimer) {
|
|
2434
|
-
clearTimeout(data.upstreamOpenTimer);
|
|
2435
|
-
data.upstreamOpenTimer = void 0;
|
|
2436
|
-
}
|
|
2437
|
-
closeWebSocket(ws, 1011, "Upstream WebSocket connection failed");
|
|
2438
|
-
};
|
|
2439
|
-
},
|
|
2440
|
-
close(ws, code, reason) {
|
|
2441
|
-
if (isHostTunnelSocket(ws)) {
|
|
2442
|
-
clearHostGenerationWatcher(ws.data);
|
|
2443
|
-
cleanupHostTunnelSocket({
|
|
2444
|
-
ws,
|
|
2445
|
-
hostTunnels,
|
|
2446
|
-
hostTunnelStateDebounce,
|
|
2447
|
-
options,
|
|
2448
|
-
bunOptions,
|
|
2449
|
-
originAllowed: relayOriginMatcher,
|
|
2450
|
-
disconnectDirectory: true,
|
|
2451
|
-
closeChannels: true
|
|
2452
|
-
});
|
|
2453
|
-
const tracker = hostTunnelRegistrations.get(ws.data.hostId);
|
|
2454
|
-
if (tracker) {
|
|
2455
|
-
pruneReconnects(tracker, Date.now());
|
|
2456
|
-
if (tracker.recent.length === 0) hostTunnelRegistrations.delete(ws.data.hostId);
|
|
2457
|
-
}
|
|
2458
|
-
return;
|
|
2459
|
-
}
|
|
2460
|
-
if (isUserHostedClientSocket(ws)) {
|
|
2461
|
-
clearClientAccessWatchers(ws.data);
|
|
2462
|
-
const tunnel = hostTunnel(hostTunnels, ws.data.hostId, ws.data.workspaceId);
|
|
2463
|
-
tunnel?.data.channels.delete(ws.data.channelId);
|
|
2464
|
-
if (tunnel?.readyState === WebSocket.OPEN) {
|
|
2465
|
-
tunnel.send(JSON.stringify({
|
|
2466
|
-
type: "ws.close",
|
|
2467
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2468
|
-
channel_id: ws.data.channelId,
|
|
2469
|
-
code: safeCloseCode(code, 1e3),
|
|
2470
|
-
reason
|
|
2471
|
-
}));
|
|
2472
|
-
}
|
|
2473
|
-
return;
|
|
2474
|
-
}
|
|
2475
|
-
if (!isRelayClientSocket(ws)) return;
|
|
2476
|
-
relayClients.delete(ws);
|
|
2477
|
-
clearClientAccessWatchers(ws.data);
|
|
2478
|
-
if (ws.data.upstreamOpenTimer) clearTimeout(ws.data.upstreamOpenTimer);
|
|
2479
|
-
if (ws.data.upstream) closeWebSocket(ws.data.upstream, code, reason, 1e3);
|
|
2480
|
-
}
|
|
2481
|
-
},
|
|
2482
|
-
drain: drainController,
|
|
2483
|
-
telemetry
|
|
2484
|
-
};
|
|
2485
|
-
}
|
|
2486
|
-
export {
|
|
2487
|
-
WORKSPACE_RELAY_IDLE_TIMEOUT_SECONDS,
|
|
2488
|
-
__slowConsumerInternalsForTest,
|
|
2489
|
-
createWorkspaceRelayBun,
|
|
2490
|
-
relayBufferedBytes,
|
|
2491
|
-
relayOverBackpressureLimit
|
|
2492
|
-
};
|