@claxedo/workspace-relay 0.1.0 → 0.5.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 +143 -9
- package/dist/auth.d.ts +16 -8
- package/dist/bun.d.ts +40 -8
- package/dist/cloudflare.d.ts +116 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +2153 -212
- package/dist/main.d.ts +35 -18
- package/dist/main.mjs +984 -215
- package/dist/server.d.ts +83 -15
- package/dist/worker.d.ts +50 -0
- package/package.json +18 -8
package/dist/index.mjs
CHANGED
|
@@ -38,6 +38,9 @@ function roleClaim(payload) {
|
|
|
38
38
|
const value = stringClaim(payload, "role");
|
|
39
39
|
return value === "viewer" || value === "editor" || value === "admin" || value === "owner" ? value : void 0;
|
|
40
40
|
}
|
|
41
|
+
function isRelayClaimPair(input) {
|
|
42
|
+
return input.access === "cloud" && input.backing === "cloud-vm" || input.access === "user-hosted" && input.backing === "local-worktree";
|
|
43
|
+
}
|
|
41
44
|
function checkHostTunnelTarget(payload, expected) {
|
|
42
45
|
if (stringClaim(payload, "host_id") !== expected.hostId) {
|
|
43
46
|
throw new WorkspaceRelayAuthError("relay_token_host_mismatch", "Relay token host does not match request");
|
|
@@ -97,6 +100,18 @@ async function verifyRuntimeAccessToken(token, key, expected) {
|
|
|
97
100
|
}
|
|
98
101
|
return claims;
|
|
99
102
|
}
|
|
103
|
+
function validateRuntimeAccessTokenClaims(input, expected) {
|
|
104
|
+
const payload = input;
|
|
105
|
+
if (stringClaim(payload, "iss") !== runtimeAccessTokenIssuer || stringClaim(payload, "aud") !== runtimeAccessTokenAudience) {
|
|
106
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token issuer or audience is invalid");
|
|
107
|
+
}
|
|
108
|
+
checkTarget(payload, expected);
|
|
109
|
+
const claims = runtimeClaims(payload);
|
|
110
|
+
if (!claims) {
|
|
111
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token claims are incomplete");
|
|
112
|
+
}
|
|
113
|
+
return claims;
|
|
114
|
+
}
|
|
100
115
|
async function mintHostTunnelToken(input, key, alg) {
|
|
101
116
|
const now = seconds(input.now);
|
|
102
117
|
return await new SignJWT({
|
|
@@ -117,6 +132,9 @@ async function verifyHostTunnelToken(token, key, expected) {
|
|
|
117
132
|
return claims;
|
|
118
133
|
}
|
|
119
134
|
async function mintRelayHostToken(input, key, alg) {
|
|
135
|
+
if (!isRelayClaimPair(input)) {
|
|
136
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Relay Host Token access/backing claims are inconsistent");
|
|
137
|
+
}
|
|
120
138
|
const now = seconds(input.now);
|
|
121
139
|
const protectedHeader = { alg: requireAlgorithm(alg) };
|
|
122
140
|
if (input.kid) protectedHeader.kid = input.kid;
|
|
@@ -172,14 +190,13 @@ function relayHostClaims(payload) {
|
|
|
172
190
|
});
|
|
173
191
|
const access = stringClaim(payload, "access");
|
|
174
192
|
const backing = stringClaim(payload, "backing");
|
|
175
|
-
|
|
176
|
-
if (
|
|
193
|
+
const pair = { access, backing };
|
|
194
|
+
if (!base || !isRelayClaimPair(pair)) return;
|
|
177
195
|
return {
|
|
178
196
|
...base,
|
|
179
197
|
iss: relayHostTokenIssuer,
|
|
180
198
|
aud: relayHostTokenAudience,
|
|
181
|
-
|
|
182
|
-
backing
|
|
199
|
+
...pair
|
|
183
200
|
};
|
|
184
201
|
}
|
|
185
202
|
function hostTunnelClaims(payload) {
|
|
@@ -204,9 +221,9 @@ function hostTunnelClaims(payload) {
|
|
|
204
221
|
|
|
205
222
|
// src/bun.ts
|
|
206
223
|
import {
|
|
207
|
-
isTunnelMessage,
|
|
208
224
|
makeTunnelPong,
|
|
209
|
-
TUNNEL_PROTOCOL_VERSION
|
|
225
|
+
TUNNEL_PROTOCOL_VERSION,
|
|
226
|
+
validateTunnelMessage
|
|
210
227
|
} from "@claxedo/workspace-relay-protocol";
|
|
211
228
|
|
|
212
229
|
// src/server.ts
|
|
@@ -230,6 +247,70 @@ function errorBody(code, message) {
|
|
|
230
247
|
}
|
|
231
248
|
|
|
232
249
|
// src/server.ts
|
|
250
|
+
var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
|
|
251
|
+
var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
|
|
252
|
+
var RESOLVER_CACHE_MAX_ENTRIES = 8192;
|
|
253
|
+
var RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 1e4;
|
|
254
|
+
var relayHostTokenCaches = /* @__PURE__ */ new WeakMap();
|
|
255
|
+
var runtimeAccessTokenCaches = /* @__PURE__ */ new WeakMap();
|
|
256
|
+
function pruneExpiringCache(cache, now, maxEntries) {
|
|
257
|
+
for (const [key, entry] of cache) {
|
|
258
|
+
if (entry.expiresAt <= now) cache.delete(key);
|
|
259
|
+
}
|
|
260
|
+
while (cache.size >= maxEntries) {
|
|
261
|
+
const key = cache.keys().next().value;
|
|
262
|
+
if (!key) return;
|
|
263
|
+
cache.delete(key);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function createCachedRevocationClient(inner, options = {}) {
|
|
267
|
+
const ttlMs = options.ttlMs ?? 1e4;
|
|
268
|
+
const now = options.now ?? Date.now;
|
|
269
|
+
const cache = /* @__PURE__ */ new Map();
|
|
270
|
+
return async (args) => {
|
|
271
|
+
const at = now();
|
|
272
|
+
const entry = cache.get(args.jti);
|
|
273
|
+
if (entry && entry.expiresAt > at) {
|
|
274
|
+
if (entry.result) return entry.result;
|
|
275
|
+
if (entry.promise) return await entry.promise;
|
|
276
|
+
}
|
|
277
|
+
const promise = inner(args).then((result) => {
|
|
278
|
+
pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
|
|
279
|
+
cache.set(args.jti, { result, expiresAt: now() + ttlMs });
|
|
280
|
+
return result;
|
|
281
|
+
}).catch((err) => {
|
|
282
|
+
cache.delete(args.jti);
|
|
283
|
+
throw err;
|
|
284
|
+
});
|
|
285
|
+
cache.set(args.jti, { promise, expiresAt: at + ttlMs });
|
|
286
|
+
return await promise;
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function createCachedTargetClient(inner, options = {}) {
|
|
290
|
+
const ttlMs = options.ttlMs ?? 5e3;
|
|
291
|
+
const now = options.now ?? Date.now;
|
|
292
|
+
const cache = /* @__PURE__ */ new Map();
|
|
293
|
+
return async (args) => {
|
|
294
|
+
const key = `${args.workspaceId}\0${args.hostId}`;
|
|
295
|
+
const at = now();
|
|
296
|
+
const entry = cache.get(key);
|
|
297
|
+
if (entry && entry.expiresAt > at) {
|
|
298
|
+
if (entry.result) return entry.result;
|
|
299
|
+
if (entry.promise) return await entry.promise;
|
|
300
|
+
}
|
|
301
|
+
const promise = inner(args).then((result) => {
|
|
302
|
+
pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
|
|
303
|
+
if (result) cache.set(key, { result, expiresAt: now() + ttlMs });
|
|
304
|
+
else cache.delete(key);
|
|
305
|
+
return result;
|
|
306
|
+
}).catch((err) => {
|
|
307
|
+
cache.delete(key);
|
|
308
|
+
throw err;
|
|
309
|
+
});
|
|
310
|
+
cache.set(key, { promise, expiresAt: at + ttlMs });
|
|
311
|
+
return await promise;
|
|
312
|
+
};
|
|
313
|
+
}
|
|
233
314
|
function protocolToken(header) {
|
|
234
315
|
return header?.split(",").map((item) => item.trim()).find((item) => item.startsWith("claxedo-rat."))?.slice("claxedo-rat.".length);
|
|
235
316
|
}
|
|
@@ -242,6 +323,16 @@ var DANGEROUS_INBOUND_HEADERS = [
|
|
|
242
323
|
"x-forwarded-proto",
|
|
243
324
|
"x-real-ip"
|
|
244
325
|
];
|
|
326
|
+
var HOP_BY_HOP_HEADERS = [
|
|
327
|
+
"connection",
|
|
328
|
+
"keep-alive",
|
|
329
|
+
"proxy-authenticate",
|
|
330
|
+
"proxy-authorization",
|
|
331
|
+
"te",
|
|
332
|
+
"trailer",
|
|
333
|
+
"transfer-encoding",
|
|
334
|
+
"upgrade"
|
|
335
|
+
];
|
|
245
336
|
var DANGEROUS_INBOUND_HEADER_PATTERNS = [
|
|
246
337
|
/^x-claxedo-internal-/i,
|
|
247
338
|
/^x-supervisor-/i
|
|
@@ -249,6 +340,7 @@ var DANGEROUS_INBOUND_HEADER_PATTERNS = [
|
|
|
249
340
|
function isDangerousInboundHeader(name) {
|
|
250
341
|
const lower = name.toLowerCase();
|
|
251
342
|
if (DANGEROUS_INBOUND_HEADERS.includes(lower)) return true;
|
|
343
|
+
if (HOP_BY_HOP_HEADERS.includes(lower)) return true;
|
|
252
344
|
for (const pattern of DANGEROUS_INBOUND_HEADER_PATTERNS) {
|
|
253
345
|
if (pattern.test(lower)) return true;
|
|
254
346
|
}
|
|
@@ -264,9 +356,16 @@ function forwardHeaders(input, relayHostToken, workspaceId, options = {}) {
|
|
|
264
356
|
}
|
|
265
357
|
if (options.userHosted) headers2.delete("cookie");
|
|
266
358
|
headers2.set("accept-encoding", "identity");
|
|
267
|
-
headers2.
|
|
359
|
+
headers2.delete("authorization");
|
|
360
|
+
headers2.delete("Authorization");
|
|
361
|
+
headers2.set("Authorization", `Bearer ${relayHostToken}`);
|
|
268
362
|
headers2.set("x-workspace-id", workspaceId);
|
|
269
363
|
headers2.set("X-Daytona-Skip-Preview-Warning", "true");
|
|
364
|
+
headers2.set("X-Daytona-Skip-Last-Activity-Update", "true");
|
|
365
|
+
for (const [name, value] of Object.entries(options.upstreamHeaders ?? {})) {
|
|
366
|
+
const trimmed = value.trim();
|
|
367
|
+
if (trimmed) headers2.set(name, trimmed);
|
|
368
|
+
}
|
|
270
369
|
headers2.set("x-forwarded-by", "workspace-relay");
|
|
271
370
|
return headers2;
|
|
272
371
|
}
|
|
@@ -276,8 +375,155 @@ function targetUrl(target, path, search) {
|
|
|
276
375
|
return url;
|
|
277
376
|
}
|
|
278
377
|
function roleAllowsRelayRequest(role, method) {
|
|
279
|
-
if (role
|
|
280
|
-
return method === "GET" || method === "HEAD" || method === "OPTIONS";
|
|
378
|
+
if (role === "owner" || role === "admin" || role === "editor") return true;
|
|
379
|
+
if (role === "viewer") return method === "GET" || method === "HEAD" || method === "OPTIONS";
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
function relayHostTokenCacheTtlMs(options, claims) {
|
|
383
|
+
const configured = options.relayHostTokenCacheTtlMs ?? 3e4;
|
|
384
|
+
if (!Number.isFinite(configured) || configured <= 0) return 0;
|
|
385
|
+
const tokenRemaining = claims.exp * 1e3 - Date.now() - 5e3;
|
|
386
|
+
return Math.max(0, Math.min(configured, tokenRemaining));
|
|
387
|
+
}
|
|
388
|
+
function relayHostTokenCacheKey(options, claims, target) {
|
|
389
|
+
return [
|
|
390
|
+
claims.jti,
|
|
391
|
+
claims.sub,
|
|
392
|
+
claims.org_id,
|
|
393
|
+
claims.role,
|
|
394
|
+
claims.workspace_id,
|
|
395
|
+
claims.host_id,
|
|
396
|
+
target.access,
|
|
397
|
+
target.backing,
|
|
398
|
+
options.relayHostMintKid ?? ""
|
|
399
|
+
].join("\0");
|
|
400
|
+
}
|
|
401
|
+
function relayHostTokenCache(options) {
|
|
402
|
+
let cache = relayHostTokenCaches.get(options);
|
|
403
|
+
if (cache) return cache;
|
|
404
|
+
cache = /* @__PURE__ */ new Map();
|
|
405
|
+
relayHostTokenCaches.set(options, cache);
|
|
406
|
+
return cache;
|
|
407
|
+
}
|
|
408
|
+
function runtimeAccessTokenCache(options) {
|
|
409
|
+
let cache = runtimeAccessTokenCaches.get(options);
|
|
410
|
+
if (cache) return cache;
|
|
411
|
+
cache = /* @__PURE__ */ new Map();
|
|
412
|
+
runtimeAccessTokenCaches.set(options, cache);
|
|
413
|
+
return cache;
|
|
414
|
+
}
|
|
415
|
+
function runtimeAccessTokenCacheTtlMs(options, claims, now) {
|
|
416
|
+
const configured = options.runtimeAccessTokenCacheTtlMs ?? RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT;
|
|
417
|
+
if (!Number.isFinite(configured) || configured <= 0) return 0;
|
|
418
|
+
const tokenRemaining = claims.exp * 1e3 - now - 5e3;
|
|
419
|
+
return Math.max(0, Math.min(configured, tokenRemaining));
|
|
420
|
+
}
|
|
421
|
+
function pruneRuntimeAccessTokenCache(cache, now) {
|
|
422
|
+
for (const [key, entry] of cache) {
|
|
423
|
+
if (entry.expiresAt <= now) cache.delete(key);
|
|
424
|
+
}
|
|
425
|
+
while (cache.size >= RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES) {
|
|
426
|
+
const key = cache.keys().next().value;
|
|
427
|
+
if (!key) return;
|
|
428
|
+
cache.delete(key);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
async function verifyRuntimeAccessTokenForRequest(options, token, workspaceId) {
|
|
432
|
+
const verified = options.tokenVerifier ? await options.tokenVerifier.verify(token).then(
|
|
433
|
+
(result) => validateRuntimeAccessTokenClaims(result.claims, { workspaceId })
|
|
434
|
+
) : await verifyRuntimeAccessToken(token, options.runtimeAccessKey, { workspaceId });
|
|
435
|
+
return verified;
|
|
436
|
+
}
|
|
437
|
+
async function cachedRuntimeAccessTokenClaims(options, token, workspaceId) {
|
|
438
|
+
const configured = options.runtimeAccessTokenCacheTtlMs ?? RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT;
|
|
439
|
+
if (!Number.isFinite(configured) || configured <= 0) {
|
|
440
|
+
return await verifyRuntimeAccessTokenForRequest(options, token, workspaceId);
|
|
441
|
+
}
|
|
442
|
+
const now = Date.now();
|
|
443
|
+
const cache = runtimeAccessTokenCache(options);
|
|
444
|
+
const key = `${workspaceId}\0${token}`;
|
|
445
|
+
const cached = cache.get(key);
|
|
446
|
+
if (cached && cached.expiresAt > now) {
|
|
447
|
+
if (cached.claims) return cached.claims;
|
|
448
|
+
if (cached.promise) return await cached.promise;
|
|
449
|
+
}
|
|
450
|
+
let promise;
|
|
451
|
+
try {
|
|
452
|
+
promise = verifyRuntimeAccessTokenForRequest(options, token, workspaceId);
|
|
453
|
+
pruneRuntimeAccessTokenCache(cache, now);
|
|
454
|
+
cache.set(key, { promise, expiresAt: now + configured });
|
|
455
|
+
const claims = await promise;
|
|
456
|
+
const ttlMs = runtimeAccessTokenCacheTtlMs(options, claims, now);
|
|
457
|
+
if (ttlMs > 0) cache.set(key, { claims, expiresAt: now + ttlMs });
|
|
458
|
+
else cache.delete(key);
|
|
459
|
+
return claims;
|
|
460
|
+
} catch (err) {
|
|
461
|
+
if (promise && cache.get(key)?.promise === promise) cache.delete(key);
|
|
462
|
+
throw err;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function pruneRelayHostTokenCache(cache, now) {
|
|
466
|
+
for (const [key, entry] of cache) {
|
|
467
|
+
if (entry.expiresAt <= now) cache.delete(key);
|
|
468
|
+
}
|
|
469
|
+
while (cache.size >= RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES) {
|
|
470
|
+
const key = cache.keys().next().value;
|
|
471
|
+
if (!key) return;
|
|
472
|
+
cache.delete(key);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async function relayHostTokenFor(options, claims, target, trace) {
|
|
476
|
+
const ttlMs = relayHostTokenCacheTtlMs(options, claims);
|
|
477
|
+
if (ttlMs <= 0) {
|
|
478
|
+
return await uncachedRelayHostTokenFor(options, claims, target, trace);
|
|
479
|
+
}
|
|
480
|
+
const key = relayHostTokenCacheKey(options, claims, target);
|
|
481
|
+
const now = Date.now();
|
|
482
|
+
const cache = relayHostTokenCache(options);
|
|
483
|
+
const cached = cache.get(key);
|
|
484
|
+
if (cached && cached.expiresAt > now) {
|
|
485
|
+
if (cached.promise) {
|
|
486
|
+
return await trace.span("rht-cache", async () => await cached.promise);
|
|
487
|
+
}
|
|
488
|
+
await trace.span("rht-cache", async () => {
|
|
489
|
+
});
|
|
490
|
+
return cached.token;
|
|
491
|
+
}
|
|
492
|
+
let promise;
|
|
493
|
+
try {
|
|
494
|
+
const token = await trace.span("rht-mint", async () => {
|
|
495
|
+
promise = mintRelayHostToken({
|
|
496
|
+
subject: claims.sub,
|
|
497
|
+
orgId: claims.org_id,
|
|
498
|
+
role: claims.role,
|
|
499
|
+
...target,
|
|
500
|
+
...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
|
|
501
|
+
}, options.relayHostSigningKey, options.relayHostAlgorithm);
|
|
502
|
+
pruneRelayHostTokenCache(cache, now);
|
|
503
|
+
cache.set(key, { promise, expiresAt: now + ttlMs });
|
|
504
|
+
return await promise;
|
|
505
|
+
});
|
|
506
|
+
cache.set(key, {
|
|
507
|
+
token,
|
|
508
|
+
expiresAt: now + ttlMs
|
|
509
|
+
});
|
|
510
|
+
return token;
|
|
511
|
+
} catch (err) {
|
|
512
|
+
if (promise && cache.get(key)?.promise === promise) cache.delete(key);
|
|
513
|
+
throw err;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
async function uncachedRelayHostTokenFor(options, claims, target, trace) {
|
|
517
|
+
return await trace.span(
|
|
518
|
+
"rht-mint",
|
|
519
|
+
async () => await mintRelayHostToken({
|
|
520
|
+
subject: claims.sub,
|
|
521
|
+
orgId: claims.org_id,
|
|
522
|
+
role: claims.role,
|
|
523
|
+
...target,
|
|
524
|
+
...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
|
|
525
|
+
}, options.relayHostSigningKey, options.relayHostAlgorithm)
|
|
526
|
+
);
|
|
281
527
|
}
|
|
282
528
|
function workspaceRelayTargetUrl(target, path, search) {
|
|
283
529
|
return targetUrl(target, path, search);
|
|
@@ -285,6 +531,18 @@ function workspaceRelayTargetUrl(target, path, search) {
|
|
|
285
531
|
function workspaceRelayForwardHeaders(input, relayHostToken, workspaceId, options = {}) {
|
|
286
532
|
return forwardHeaders(input, relayHostToken, workspaceId, options);
|
|
287
533
|
}
|
|
534
|
+
function workspaceRelayForwardRequestInit(request, relayHostToken, workspaceId, options = {}) {
|
|
535
|
+
const init = {
|
|
536
|
+
method: request.method,
|
|
537
|
+
headers: forwardHeaders(request.headers, relayHostToken, workspaceId, options),
|
|
538
|
+
body: request.body,
|
|
539
|
+
duplex: "half",
|
|
540
|
+
redirect: "manual",
|
|
541
|
+
...options.signal ? { signal: options.signal } : {}
|
|
542
|
+
};
|
|
543
|
+
if (request.method === "GET" || request.method === "HEAD") delete init.body;
|
|
544
|
+
return init;
|
|
545
|
+
}
|
|
288
546
|
var auditSamplers = /* @__PURE__ */ new WeakMap();
|
|
289
547
|
function getOrCreateAuditSampler(options) {
|
|
290
548
|
let state = auditSamplers.get(options);
|
|
@@ -342,6 +600,38 @@ function disposeAuditSampler(options) {
|
|
|
342
600
|
flushSuppressedSummary(options, state);
|
|
343
601
|
auditSamplers.delete(options);
|
|
344
602
|
}
|
|
603
|
+
function createWorkspaceRelayTrace() {
|
|
604
|
+
const phases = [];
|
|
605
|
+
return {
|
|
606
|
+
phases,
|
|
607
|
+
async span(name, run) {
|
|
608
|
+
const startedAt = performance.now();
|
|
609
|
+
try {
|
|
610
|
+
return await run();
|
|
611
|
+
} finally {
|
|
612
|
+
phases.push({ name, ms: performance.now() - startedAt });
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
function roundedMs(value) {
|
|
618
|
+
return Math.round(value * 100) / 100;
|
|
619
|
+
}
|
|
620
|
+
function workspaceRelayServerTiming(trace) {
|
|
621
|
+
return trace.phases.map((phase) => `${phase.name};dur=${roundedMs(phase.ms)}`).join(", ");
|
|
622
|
+
}
|
|
623
|
+
function workspaceRelayTimingResponse(response, trace) {
|
|
624
|
+
const value = workspaceRelayServerTiming(trace);
|
|
625
|
+
if (!value) return response;
|
|
626
|
+
const headers2 = new Headers(response.headers);
|
|
627
|
+
const prev = headers2.get("server-timing");
|
|
628
|
+
headers2.set("server-timing", prev ? `${prev}, ${value}` : value);
|
|
629
|
+
return new Response(response.body, {
|
|
630
|
+
status: response.status,
|
|
631
|
+
statusText: response.statusText,
|
|
632
|
+
headers: headers2
|
|
633
|
+
});
|
|
634
|
+
}
|
|
345
635
|
async function audit(options, event) {
|
|
346
636
|
if (event.action === "relay.request.accepted") {
|
|
347
637
|
const state = getOrCreateAuditSampler(options);
|
|
@@ -362,7 +652,7 @@ function denyCorsHeaders(request) {
|
|
|
362
652
|
if (!allowed) return void 0;
|
|
363
653
|
return {
|
|
364
654
|
"access-control-allow-origin": origin,
|
|
365
|
-
"access-control-allow-headers": "Accept, Authorization, Content-Type, X-Daytona-Skip-Preview-Warning, X-Workspace-Id",
|
|
655
|
+
"access-control-allow-headers": "Accept, Authorization, Content-Type, Last-Event-ID, X-Fetch-Bypass-Throttle, X-Daytona-Skip-Preview-Warning, X-Workspace-Id, X-OpenCode-Directory, X-Claxedo-Runner, X-Claxedo-Model, X-Claxedo-Draft-Id, X-Claxedo-Binary",
|
|
366
656
|
"access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"
|
|
367
657
|
};
|
|
368
658
|
}
|
|
@@ -384,11 +674,12 @@ async function deny(options, input) {
|
|
|
384
674
|
headers: denyCorsHeaders(input.request)
|
|
385
675
|
});
|
|
386
676
|
}
|
|
387
|
-
async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
677
|
+
async function authorizeWorkspaceRelayRequest(options, request, workspaceId, trace) {
|
|
388
678
|
const token = runtimeAccessToken(request);
|
|
389
679
|
if (!token) {
|
|
390
680
|
return {
|
|
391
681
|
ok: false,
|
|
682
|
+
code: "runtime_access_token_required",
|
|
392
683
|
response: await deny(options, {
|
|
393
684
|
code: "runtime_access_token_required",
|
|
394
685
|
message: "Runtime Access Token is required",
|
|
@@ -398,14 +689,18 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
398
689
|
};
|
|
399
690
|
}
|
|
400
691
|
try {
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
692
|
+
const span = (name, run) => trace ? trace.span(name, run) : run();
|
|
693
|
+
const claims = await span(
|
|
694
|
+
"rat-verify",
|
|
695
|
+
async () => await cachedRuntimeAccessTokenClaims(options, token, workspaceId)
|
|
696
|
+
);
|
|
697
|
+
const active = await span("rat-active", async () => {
|
|
698
|
+
return await options.isRuntimeAccessTokenActive?.(claims) ?? { active: true };
|
|
699
|
+
});
|
|
406
700
|
if (!active.active) {
|
|
407
701
|
return {
|
|
408
702
|
ok: false,
|
|
703
|
+
code: active.code,
|
|
409
704
|
response: await deny(options, {
|
|
410
705
|
code: active.code,
|
|
411
706
|
message: active.reason,
|
|
@@ -418,6 +713,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
418
713
|
if (!roleAllowsRelayRequest(claims.role, request.method)) {
|
|
419
714
|
return {
|
|
420
715
|
ok: false,
|
|
716
|
+
code: "relay_role_denied",
|
|
421
717
|
response: await deny(options, {
|
|
422
718
|
code: "relay_role_denied",
|
|
423
719
|
message: "Workspace role does not allow this relay request",
|
|
@@ -427,10 +723,11 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
427
723
|
})
|
|
428
724
|
};
|
|
429
725
|
}
|
|
430
|
-
const target = await options.resolveTarget(claims);
|
|
726
|
+
const target = await span("target-resolve", async () => await options.resolveTarget(claims));
|
|
431
727
|
if (!target || target.workspaceId !== claims.workspace_id || target.hostId !== claims.host_id) {
|
|
432
728
|
return {
|
|
433
729
|
ok: false,
|
|
730
|
+
code: "relay_target_unavailable",
|
|
434
731
|
response: await deny(options, {
|
|
435
732
|
code: "relay_target_unavailable",
|
|
436
733
|
message: "Workspace relay target is unavailable",
|
|
@@ -443,6 +740,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
443
740
|
if (target.access === "user-hosted" && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
|
|
444
741
|
return {
|
|
445
742
|
ok: false,
|
|
743
|
+
code: "user_hosted_app_offline",
|
|
446
744
|
response: await deny(options, {
|
|
447
745
|
code: "user_hosted_app_offline",
|
|
448
746
|
message: "User-hosted workspace is offline",
|
|
@@ -452,7 +750,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
452
750
|
})
|
|
453
751
|
};
|
|
454
752
|
}
|
|
455
|
-
await audit(options, {
|
|
753
|
+
await span("audit", async () => await audit(options, {
|
|
456
754
|
action: "relay.request.accepted",
|
|
457
755
|
result: "allow",
|
|
458
756
|
subject: claims.sub,
|
|
@@ -461,22 +759,14 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
461
759
|
hostId: claims.host_id,
|
|
462
760
|
method: request.method,
|
|
463
761
|
path: new URL(request.url).pathname
|
|
464
|
-
});
|
|
762
|
+
}));
|
|
763
|
+
const relayHostToken = await relayHostTokenFor(options, claims, target, { span });
|
|
465
764
|
return {
|
|
466
765
|
ok: true,
|
|
467
766
|
request: {
|
|
468
767
|
claims,
|
|
469
768
|
target,
|
|
470
|
-
relayHostToken
|
|
471
|
-
subject: claims.sub,
|
|
472
|
-
orgId: claims.org_id,
|
|
473
|
-
workspaceId: claims.workspace_id,
|
|
474
|
-
hostId: claims.host_id,
|
|
475
|
-
role: claims.role,
|
|
476
|
-
access: target.access,
|
|
477
|
-
backing: target.backing,
|
|
478
|
-
...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
|
|
479
|
-
}, options.relayHostSigningKey, options.relayHostAlgorithm),
|
|
769
|
+
relayHostToken,
|
|
480
770
|
path: new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/")
|
|
481
771
|
}
|
|
482
772
|
};
|
|
@@ -484,6 +774,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
484
774
|
const code = err instanceof WorkspaceRelayAuthError ? err.code : "relay_request_failed";
|
|
485
775
|
return {
|
|
486
776
|
ok: false,
|
|
777
|
+
code,
|
|
487
778
|
response: await deny(options, {
|
|
488
779
|
code,
|
|
489
780
|
message: "Workspace relay request was denied",
|
|
@@ -493,28 +784,45 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
493
784
|
};
|
|
494
785
|
}
|
|
495
786
|
}
|
|
496
|
-
async function
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
787
|
+
async function forwardWorkspaceRelayRequest(request, target, relayHostToken, path, requestFetch, timeoutMs, trace) {
|
|
788
|
+
const controller = new AbortController();
|
|
789
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
790
|
+
const span = (name, run) => trace ? trace.span(name, run) : run();
|
|
791
|
+
try {
|
|
792
|
+
const upstream = await span("upstream-fetch", async () => await requestFetch(
|
|
793
|
+
targetUrl(target, path, new URL(request.url).search),
|
|
794
|
+
workspaceRelayForwardRequestInit(request, relayHostToken, target.workspaceId, {
|
|
795
|
+
signal: controller.signal,
|
|
796
|
+
upstreamHeaders: target.upstreamHeaders
|
|
797
|
+
})
|
|
798
|
+
));
|
|
799
|
+
const headers2 = new Headers(upstream.headers);
|
|
800
|
+
headers2.delete("access-control-allow-origin");
|
|
801
|
+
headers2.delete("access-control-allow-credentials");
|
|
802
|
+
headers2.delete("access-control-allow-headers");
|
|
803
|
+
headers2.delete("access-control-allow-methods");
|
|
804
|
+
headers2.delete("access-control-expose-headers");
|
|
805
|
+
headers2.delete("access-control-max-age");
|
|
806
|
+
for (const [key, value] of Object.entries(denyCorsHeaders(request) ?? {})) {
|
|
807
|
+
headers2.set(key, value);
|
|
808
|
+
}
|
|
809
|
+
return new Response(upstream.body, {
|
|
810
|
+
status: upstream.status,
|
|
811
|
+
statusText: upstream.statusText,
|
|
812
|
+
headers: headers2
|
|
813
|
+
});
|
|
814
|
+
} catch (err) {
|
|
815
|
+
const aborted = err instanceof Error && err.name === "AbortError";
|
|
816
|
+
return Response.json(
|
|
817
|
+
errorBody(
|
|
818
|
+
aborted ? "upstream_timeout" : "upstream_unavailable",
|
|
819
|
+
aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable"
|
|
820
|
+
),
|
|
821
|
+
{ status: aborted ? 504 : 503, headers: denyCorsHeaders(request) }
|
|
822
|
+
);
|
|
823
|
+
} finally {
|
|
824
|
+
clearTimeout(timer);
|
|
825
|
+
}
|
|
518
826
|
}
|
|
519
827
|
function getAuditSuppressedCount(options) {
|
|
520
828
|
return auditSamplers.get(options)?.suppressedCount ?? 0;
|
|
@@ -545,7 +853,14 @@ function checkMetricsAuth(options, request) {
|
|
|
545
853
|
}
|
|
546
854
|
return { ok: true };
|
|
547
855
|
}
|
|
548
|
-
if (!options.metricsRemoteAddress)
|
|
856
|
+
if (!options.metricsRemoteAddress) {
|
|
857
|
+
return {
|
|
858
|
+
ok: false,
|
|
859
|
+
status: 401,
|
|
860
|
+
code: "metrics_unauthorized",
|
|
861
|
+
message: "Metrics endpoint is restricted to loopback or bearer-token clients"
|
|
862
|
+
};
|
|
863
|
+
}
|
|
549
864
|
const address = options.metricsRemoteAddress(request);
|
|
550
865
|
if (isLoopbackAddress(address)) return { ok: true };
|
|
551
866
|
return {
|
|
@@ -584,6 +899,7 @@ function createWorkspaceRelay(options) {
|
|
|
584
899
|
if (origin.startsWith("http://localhost:")) return origin;
|
|
585
900
|
if (origin.startsWith("http://127.0.0.1:")) return origin;
|
|
586
901
|
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
|
|
902
|
+
if (/^https:\/\/([a-z0-9-]+\.)*claxedo\.com$/.test(origin)) return origin;
|
|
587
903
|
return void 0;
|
|
588
904
|
},
|
|
589
905
|
allowHeaders: [
|
|
@@ -591,7 +907,12 @@ function createWorkspaceRelay(options) {
|
|
|
591
907
|
"Authorization",
|
|
592
908
|
"Content-Type",
|
|
593
909
|
"X-Daytona-Skip-Preview-Warning",
|
|
594
|
-
"X-Workspace-Id"
|
|
910
|
+
"X-Workspace-Id",
|
|
911
|
+
"X-OpenCode-Directory",
|
|
912
|
+
"X-Claxedo-Runner",
|
|
913
|
+
"X-Claxedo-Model",
|
|
914
|
+
"X-Claxedo-Draft-Id",
|
|
915
|
+
"X-Claxedo-Binary"
|
|
595
916
|
],
|
|
596
917
|
allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
597
918
|
}));
|
|
@@ -655,21 +976,27 @@ function createWorkspaceRelay(options) {
|
|
|
655
976
|
return c.json(buildMetricsBody(options));
|
|
656
977
|
});
|
|
657
978
|
app.all("/workspaces/:workspaceId/*", async (c) => {
|
|
979
|
+
const trace = createWorkspaceRelayTrace();
|
|
658
980
|
if (options.isDraining?.()) {
|
|
659
981
|
return Response.json(
|
|
660
982
|
errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
|
|
661
983
|
{ status: 503, headers: denyCorsHeaders(c.req.raw) }
|
|
662
984
|
);
|
|
663
985
|
}
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
986
|
+
const response = await trace.span("relay-total", async () => {
|
|
987
|
+
const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"), trace);
|
|
988
|
+
if (!relay.ok) return relay.response;
|
|
989
|
+
return await forwardWorkspaceRelayRequest(
|
|
990
|
+
new Request(c.req.raw),
|
|
991
|
+
relay.request.target,
|
|
992
|
+
relay.request.relayHostToken,
|
|
993
|
+
relay.request.path,
|
|
994
|
+
options.fetch ?? fetch,
|
|
995
|
+
options.forwardTimeoutMs ?? 3e4,
|
|
996
|
+
trace
|
|
997
|
+
);
|
|
998
|
+
});
|
|
999
|
+
return workspaceRelayTimingResponse(response, trace);
|
|
673
1000
|
});
|
|
674
1001
|
return app;
|
|
675
1002
|
}
|
|
@@ -677,38 +1004,79 @@ function createWorkspaceRelay(options) {
|
|
|
677
1004
|
// src/bun.ts
|
|
678
1005
|
var TUNNEL_PENDING_HTTP_CAP = 32;
|
|
679
1006
|
var TUNNEL_CHANNEL_CAP = 16;
|
|
680
|
-
var HOST_TUNNEL_REGISTRATION_CONCURRENT_CAP = 3;
|
|
681
1007
|
var HOST_TUNNEL_REGISTRATION_RECONNECT_CAP = 5;
|
|
682
1008
|
var HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS = 6e4;
|
|
683
1009
|
var WS_MAX_PAYLOAD_LENGTH_BYTES = 16 * 1024 * 1024;
|
|
1010
|
+
var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
|
|
1011
|
+
var TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1012
|
+
var DIRECT_HTTP_TIMEOUT_MS_DEFAULT = 3e4;
|
|
1013
|
+
var UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT = 1e4;
|
|
1014
|
+
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
|
|
1015
|
+
var WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
1016
|
+
var HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT = 2;
|
|
684
1017
|
var TUNNEL_MESSAGE_BUFFER_CAP_BYTES = 4 * 1024 * 1024;
|
|
685
1018
|
var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
686
1019
|
var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
|
|
687
1020
|
var HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT = 250;
|
|
688
|
-
|
|
689
|
-
fragmentsBuffered: 0,
|
|
690
|
-
oversizedClosed: 0
|
|
691
|
-
};
|
|
692
|
-
function getFragmentationStats() {
|
|
1021
|
+
function createFragmentationStats() {
|
|
693
1022
|
return {
|
|
694
|
-
fragmentsBuffered:
|
|
695
|
-
oversizedClosed:
|
|
1023
|
+
fragmentsBuffered: 0,
|
|
1024
|
+
oversizedClosed: 0
|
|
696
1025
|
};
|
|
697
1026
|
}
|
|
698
|
-
function
|
|
699
|
-
|
|
700
|
-
|
|
1027
|
+
function createSlowConsumerStats() {
|
|
1028
|
+
return {
|
|
1029
|
+
overflowEvents: 0,
|
|
1030
|
+
timerFired: 0,
|
|
1031
|
+
droppedRequests: 0
|
|
1032
|
+
};
|
|
701
1033
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
};
|
|
707
|
-
function getSlowConsumerStats() {
|
|
708
|
-
return { ...slowConsumerStats };
|
|
1034
|
+
function readFragmentationStats(stats) {
|
|
1035
|
+
return {
|
|
1036
|
+
fragmentsBuffered: stats.fragmentsBuffered,
|
|
1037
|
+
oversizedClosed: stats.oversizedClosed
|
|
1038
|
+
};
|
|
709
1039
|
}
|
|
710
|
-
function
|
|
711
|
-
|
|
1040
|
+
function resetFragmentationStats(stats) {
|
|
1041
|
+
stats.fragmentsBuffered = 0;
|
|
1042
|
+
stats.oversizedClosed = 0;
|
|
1043
|
+
}
|
|
1044
|
+
function readSlowConsumerStats(stats) {
|
|
1045
|
+
return { ...stats };
|
|
1046
|
+
}
|
|
1047
|
+
function resetSlowConsumerStats(stats) {
|
|
1048
|
+
stats.overflowEvents = 0;
|
|
1049
|
+
stats.timerFired = 0;
|
|
1050
|
+
stats.droppedRequests = 0;
|
|
1051
|
+
}
|
|
1052
|
+
function createDirectHttpLimiter(limit) {
|
|
1053
|
+
if (!Number.isInteger(limit) || !limit || limit <= 0) return void 0;
|
|
1054
|
+
const max = limit;
|
|
1055
|
+
let active = 0;
|
|
1056
|
+
const queue = [];
|
|
1057
|
+
function drain() {
|
|
1058
|
+
if (active >= max) return;
|
|
1059
|
+
const next = queue.shift();
|
|
1060
|
+
if (!next) return;
|
|
1061
|
+
active++;
|
|
1062
|
+
next();
|
|
1063
|
+
}
|
|
1064
|
+
return {
|
|
1065
|
+
async acquire() {
|
|
1066
|
+
if (active < max) {
|
|
1067
|
+
active++;
|
|
1068
|
+
} else {
|
|
1069
|
+
await new Promise((resolve) => queue.push(resolve));
|
|
1070
|
+
}
|
|
1071
|
+
let released = false;
|
|
1072
|
+
return () => {
|
|
1073
|
+
if (released) return;
|
|
1074
|
+
released = true;
|
|
1075
|
+
active = Math.max(0, active - 1);
|
|
1076
|
+
drain();
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
712
1080
|
}
|
|
713
1081
|
function jsonError(code, message, status) {
|
|
714
1082
|
return new Response(JSON.stringify({ error: { code, message } }), {
|
|
@@ -716,6 +1084,35 @@ function jsonError(code, message, status) {
|
|
|
716
1084
|
headers: { "content-type": "application/json" }
|
|
717
1085
|
});
|
|
718
1086
|
}
|
|
1087
|
+
function corsJsonError(request, code, message, status) {
|
|
1088
|
+
const headers2 = relayCorsHeaders(request);
|
|
1089
|
+
headers2.set("content-type", "application/json");
|
|
1090
|
+
return new Response(JSON.stringify({ error: { code, message } }), { status, headers: headers2 });
|
|
1091
|
+
}
|
|
1092
|
+
function safeCloseCode(input, fallback = 1011) {
|
|
1093
|
+
if (!Number.isInteger(input)) return fallback;
|
|
1094
|
+
const code = input;
|
|
1095
|
+
if (code === 1e3) return code;
|
|
1096
|
+
if (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
|
|
1097
|
+
if (code >= 3e3 && code <= 4999) return code;
|
|
1098
|
+
return fallback;
|
|
1099
|
+
}
|
|
1100
|
+
function closeWebSocket(ws, code, reason, fallback = 1011) {
|
|
1101
|
+
try {
|
|
1102
|
+
ws.close(safeCloseCode(code, fallback), reason);
|
|
1103
|
+
} catch {
|
|
1104
|
+
try {
|
|
1105
|
+
ws.close(fallback, reason);
|
|
1106
|
+
} catch {
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
function upstreamCloseReason(event) {
|
|
1111
|
+
if (event.reason.trim()) return event.reason;
|
|
1112
|
+
if (event.code === 1e3) return "";
|
|
1113
|
+
if (event.code === 1005 || event.code === 1006) return "Upstream WebSocket closed abnormally";
|
|
1114
|
+
return `Upstream WebSocket closed unexpectedly with code ${event.code}`;
|
|
1115
|
+
}
|
|
719
1116
|
function workspaceIdFromPath(pathname) {
|
|
720
1117
|
return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
721
1118
|
}
|
|
@@ -725,6 +1122,23 @@ function hostIdFromTunnelPath(pathname) {
|
|
|
725
1122
|
function websocketRequest(request) {
|
|
726
1123
|
return request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
727
1124
|
}
|
|
1125
|
+
function roundedMs2(input) {
|
|
1126
|
+
return Math.round(input * 100) / 100;
|
|
1127
|
+
}
|
|
1128
|
+
function relayWebSocketTraceEnabled(request) {
|
|
1129
|
+
return request.headers.get("x-claxedo-relay-ws-trace") === "1";
|
|
1130
|
+
}
|
|
1131
|
+
function sendRelayWebSocketTrace(ws) {
|
|
1132
|
+
const trace = ws.data.trace;
|
|
1133
|
+
if (!trace || trace.emitted) return;
|
|
1134
|
+
trace.emitted = true;
|
|
1135
|
+
ws.send(JSON.stringify({
|
|
1136
|
+
type: "relay.trace",
|
|
1137
|
+
wsUpstreamOpenMs: trace.upstreamOpenMs === void 0 ? void 0 : roundedMs2(trace.upstreamOpenMs),
|
|
1138
|
+
queuedFrames: trace.queuedFrames,
|
|
1139
|
+
maxQueuedDelayMs: roundedMs2(trace.maxQueuedDelayMs)
|
|
1140
|
+
}));
|
|
1141
|
+
}
|
|
728
1142
|
function relayWebSocketPayload(input) {
|
|
729
1143
|
if (typeof input === "string") return input;
|
|
730
1144
|
if (input instanceof ArrayBuffer) return input;
|
|
@@ -748,6 +1162,11 @@ function headers(input) {
|
|
|
748
1162
|
}
|
|
749
1163
|
return result;
|
|
750
1164
|
}
|
|
1165
|
+
function isEventStream(input) {
|
|
1166
|
+
return Object.entries(input).some(
|
|
1167
|
+
([key, value]) => key.toLowerCase() === "content-type" && value.toLowerCase().includes("text/event-stream")
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
751
1170
|
function allowedCorsOrigin(origin) {
|
|
752
1171
|
if (!origin) return;
|
|
753
1172
|
if (origin.startsWith("http://localhost:")) return origin;
|
|
@@ -774,7 +1193,7 @@ function relayCorsHeaders(request, input = new Headers()) {
|
|
|
774
1193
|
const origin = allowedCorsOrigin(request.headers.get("origin"));
|
|
775
1194
|
if (origin) {
|
|
776
1195
|
result.set("access-control-allow-origin", origin);
|
|
777
|
-
result.set("access-control-allow-headers", "Accept, Authorization, Content-Type, X-Daytona-Skip-Preview-Warning, X-Workspace-Id");
|
|
1196
|
+
result.set("access-control-allow-headers", "Accept, Authorization, Content-Type, Last-Event-ID, X-Fetch-Bypass-Throttle, X-Daytona-Skip-Preview-Warning, X-Workspace-Id, X-OpenCode-Directory, X-Claxedo-Runner, X-Claxedo-Model, X-Claxedo-Draft-Id, X-Claxedo-Binary");
|
|
778
1197
|
result.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
779
1198
|
}
|
|
780
1199
|
return result;
|
|
@@ -801,7 +1220,7 @@ function decodedFrame(frame) {
|
|
|
801
1220
|
const body = Buffer.from(frame.data_base64, "base64");
|
|
802
1221
|
return frame.binary ? new Uint8Array(body) : body.toString("utf8");
|
|
803
1222
|
}
|
|
804
|
-
function tunnelMessage(ws, input) {
|
|
1223
|
+
function tunnelMessage(ws, input, stats) {
|
|
805
1224
|
if (typeof input !== "string") return;
|
|
806
1225
|
const combined = ws.data.messageBuffer.length > 0 ? ws.data.messageBuffer + input : input;
|
|
807
1226
|
let parsed;
|
|
@@ -809,20 +1228,26 @@ function tunnelMessage(ws, input) {
|
|
|
809
1228
|
parsed = JSON.parse(combined);
|
|
810
1229
|
} catch {
|
|
811
1230
|
if (combined.length > TUNNEL_MESSAGE_BUFFER_CAP_BYTES) {
|
|
812
|
-
|
|
1231
|
+
stats.oversizedClosed += 1;
|
|
813
1232
|
ws.data.messageBuffer = "";
|
|
814
1233
|
ws.close(1009, "Tunnel message buffer exceeded 4 MB");
|
|
815
1234
|
return;
|
|
816
1235
|
}
|
|
817
|
-
|
|
1236
|
+
stats.fragmentsBuffered += 1;
|
|
818
1237
|
ws.data.messageBuffer = combined;
|
|
819
1238
|
return;
|
|
820
1239
|
}
|
|
821
1240
|
ws.data.messageBuffer = "";
|
|
822
|
-
|
|
1241
|
+
const validated = validateTunnelMessage(parsed);
|
|
1242
|
+
if (validated.ok) return validated.message;
|
|
1243
|
+
if (validated.reason === "protocol_mismatch") {
|
|
1244
|
+
ws.close(1002, `Tunnel protocol mismatch: expected ${validated.expected_protocol}`);
|
|
1245
|
+
}
|
|
1246
|
+
return;
|
|
823
1247
|
}
|
|
824
1248
|
function sendTunnelPing(ws) {
|
|
825
1249
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1250
|
+
ws.data.missedPongs += 1;
|
|
826
1251
|
ws.send(JSON.stringify({
|
|
827
1252
|
type: "ping",
|
|
828
1253
|
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
@@ -830,6 +1255,59 @@ function sendTunnelPing(ws) {
|
|
|
830
1255
|
sent_at: Date.now()
|
|
831
1256
|
}));
|
|
832
1257
|
}
|
|
1258
|
+
function clearPendingTimers(entry) {
|
|
1259
|
+
clearTimeout(entry.timeout);
|
|
1260
|
+
if (entry.slowConsumerTimeout) {
|
|
1261
|
+
clearTimeout(entry.slowConsumerTimeout);
|
|
1262
|
+
entry.slowConsumerTimeout = void 0;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
function failPendingHttpResponse(input) {
|
|
1266
|
+
clearPendingTimers(input.entry);
|
|
1267
|
+
input.entry.pendingChunks.length = 0;
|
|
1268
|
+
input.entry.bytesQueued = 0;
|
|
1269
|
+
try {
|
|
1270
|
+
input.entry.controller.error(input.error);
|
|
1271
|
+
} catch {
|
|
1272
|
+
}
|
|
1273
|
+
if (!input.entry.responseStarted) {
|
|
1274
|
+
if (input.response) {
|
|
1275
|
+
input.entry.resolve(input.response);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
input.entry.reject(input.error);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
function cleanupHostTunnelSocket(input) {
|
|
1282
|
+
if (input.ws.data.heartbeat) {
|
|
1283
|
+
clearInterval(input.ws.data.heartbeat);
|
|
1284
|
+
input.ws.data.heartbeat = void 0;
|
|
1285
|
+
}
|
|
1286
|
+
for (const pending of input.ws.data.pending.values()) {
|
|
1287
|
+
failPendingHttpResponse({
|
|
1288
|
+
entry: pending,
|
|
1289
|
+
response: input.request ? corsJsonError(input.request, "user_hosted_app_offline", "User-hosted workspace is offline", 503) : jsonError("user_hosted_app_offline", "User-hosted workspace is offline", 503),
|
|
1290
|
+
error: new Error("User-hosted tunnel disconnected")
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
input.ws.data.pending.clear();
|
|
1294
|
+
for (const channel of input.ws.data.channels.values()) {
|
|
1295
|
+
if (input.closeChannels) closeWebSocket(channel, 1011, "User-hosted tunnel disconnected");
|
|
1296
|
+
}
|
|
1297
|
+
input.ws.data.channels.clear();
|
|
1298
|
+
if (!input.disconnectDirectory) return;
|
|
1299
|
+
if (input.hostTunnels.get(input.ws.data.hostId) === input.ws) {
|
|
1300
|
+
input.hostTunnels.delete(input.ws.data.hostId);
|
|
1301
|
+
input.options.directory?.disconnectHost(input.ws.data.hostId);
|
|
1302
|
+
scheduleHostTunnelStateChange(input.hostTunnelStateDebounce, input.options, {
|
|
1303
|
+
hostId: input.ws.data.hostId,
|
|
1304
|
+
workspaceId: input.ws.data.workspaceIds[0],
|
|
1305
|
+
path: `/host-tunnels/${input.ws.data.hostId}`,
|
|
1306
|
+
state: "disconnected",
|
|
1307
|
+
debounceMs: input.bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
833
1311
|
async function audit2(options, input) {
|
|
834
1312
|
await options.audit?.({
|
|
835
1313
|
action: input.action,
|
|
@@ -840,9 +1318,8 @@ async function audit2(options, input) {
|
|
|
840
1318
|
path: input.path
|
|
841
1319
|
});
|
|
842
1320
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
const existing = hostTunnelStateDebounce.get(input.hostId);
|
|
1321
|
+
function scheduleHostTunnelStateChange(state, options, input) {
|
|
1322
|
+
const existing = state.get(input.hostId);
|
|
846
1323
|
const entry = existing ?? {
|
|
847
1324
|
intendedState: "disconnected",
|
|
848
1325
|
lastWritten: "disconnected",
|
|
@@ -856,7 +1333,7 @@ function scheduleHostTunnelStateChange(options, input) {
|
|
|
856
1333
|
entry.flushTimer = void 0;
|
|
857
1334
|
if (entry.intendedState === entry.lastWritten) {
|
|
858
1335
|
if (entry.intendedState === "disconnected") {
|
|
859
|
-
|
|
1336
|
+
state.delete(input.hostId);
|
|
860
1337
|
}
|
|
861
1338
|
return;
|
|
862
1339
|
}
|
|
@@ -868,13 +1345,7 @@ function scheduleHostTunnelStateChange(options, input) {
|
|
|
868
1345
|
path: entry.pending.path
|
|
869
1346
|
});
|
|
870
1347
|
}, input.debounceMs);
|
|
871
|
-
|
|
872
|
-
}
|
|
873
|
-
function __resetHostTunnelStateDebounce() {
|
|
874
|
-
for (const entry of hostTunnelStateDebounce.values()) {
|
|
875
|
-
if (entry.flushTimer) clearTimeout(entry.flushTimer);
|
|
876
|
-
}
|
|
877
|
-
hostTunnelStateDebounce.clear();
|
|
1348
|
+
state.set(input.hostId, entry);
|
|
878
1349
|
}
|
|
879
1350
|
function drainPendingChunks(entry) {
|
|
880
1351
|
while (entry.pendingChunks.length > 0) {
|
|
@@ -903,9 +1374,9 @@ function enqueueChunkWithBackpressure(input) {
|
|
|
903
1374
|
entry.pendingChunks.push(chunk);
|
|
904
1375
|
entry.bytesQueued += chunk.byteLength;
|
|
905
1376
|
if (!entry.slowConsumerTimeout) {
|
|
906
|
-
slowConsumerStats.overflowEvents += 1;
|
|
1377
|
+
input.slowConsumerStats.overflowEvents += 1;
|
|
907
1378
|
entry.slowConsumerTimeout = setTimeout(() => {
|
|
908
|
-
slowConsumerStats.timerFired += 1;
|
|
1379
|
+
input.slowConsumerStats.timerFired += 1;
|
|
909
1380
|
const error = new Error("slow_consumer_timeout: downstream consumer did not drain in time");
|
|
910
1381
|
try {
|
|
911
1382
|
entry.controller.error(error);
|
|
@@ -918,7 +1389,7 @@ function enqueueChunkWithBackpressure(input) {
|
|
|
918
1389
|
entry.resolve(jsonError("slow_consumer_timeout", "Downstream consumer did not drain in time", 503));
|
|
919
1390
|
}
|
|
920
1391
|
input.ws.data.pending.delete(input.requestId);
|
|
921
|
-
slowConsumerStats.droppedRequests += 1;
|
|
1392
|
+
input.slowConsumerStats.droppedRequests += 1;
|
|
922
1393
|
}, input.slowConsumerTimeoutMs);
|
|
923
1394
|
}
|
|
924
1395
|
return;
|
|
@@ -929,7 +1400,8 @@ function enqueueChunkWithBackpressure(input) {
|
|
|
929
1400
|
}
|
|
930
1401
|
}
|
|
931
1402
|
var __slowConsumerInternalsForTest = {
|
|
932
|
-
enqueueChunkWithBackpressure
|
|
1403
|
+
enqueueChunkWithBackpressure,
|
|
1404
|
+
createSlowConsumerStats
|
|
933
1405
|
};
|
|
934
1406
|
async function tunnelHttpRequest(input) {
|
|
935
1407
|
if (input.ws.readyState !== WebSocket.OPEN) {
|
|
@@ -952,13 +1424,47 @@ async function tunnelHttpRequest(input) {
|
|
|
952
1424
|
const entry = input.ws.data.pending.get(requestId);
|
|
953
1425
|
if (!entry) return;
|
|
954
1426
|
drainPendingChunks(entry);
|
|
1427
|
+
},
|
|
1428
|
+
cancel() {
|
|
1429
|
+
const entry = input.ws.data.pending.get(requestId);
|
|
1430
|
+
if (!entry) return;
|
|
1431
|
+
input.ws.data.pending.delete(requestId);
|
|
1432
|
+
clearPendingTimers(entry);
|
|
1433
|
+
entry.pendingChunks.length = 0;
|
|
1434
|
+
entry.bytesQueued = 0;
|
|
1435
|
+
if (input.ws.readyState === WebSocket.OPEN) {
|
|
1436
|
+
input.ws.send(JSON.stringify({
|
|
1437
|
+
type: "http.response.flow",
|
|
1438
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
1439
|
+
request_id: requestId,
|
|
1440
|
+
paused: false,
|
|
1441
|
+
reason: "closed"
|
|
1442
|
+
}));
|
|
1443
|
+
}
|
|
955
1444
|
}
|
|
956
1445
|
}, new ByteLengthQueuingStrategy({ highWaterMark: input.slowConsumerHighWaterMarkBytes }));
|
|
957
1446
|
const pending = new Promise((resolve, reject) => {
|
|
958
1447
|
const timeout = setTimeout(() => {
|
|
1448
|
+
const entry = input.ws.data.pending.get(requestId);
|
|
1449
|
+
if (!entry) return;
|
|
959
1450
|
input.ws.data.pending.delete(requestId);
|
|
960
|
-
|
|
961
|
-
|
|
1451
|
+
if (entry.responseStarted) {
|
|
1452
|
+
clearPendingTimers(entry);
|
|
1453
|
+
entry.pendingChunks.length = 0;
|
|
1454
|
+
entry.bytesQueued = 0;
|
|
1455
|
+
try {
|
|
1456
|
+
entry.controller.close();
|
|
1457
|
+
} catch {
|
|
1458
|
+
}
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
const error = new Error("User-hosted tunnel response timed out");
|
|
1462
|
+
failPendingHttpResponse({
|
|
1463
|
+
entry,
|
|
1464
|
+
response: corsJsonError(input.request, "user_hosted_tunnel_timeout", "User-hosted tunnel response timed out", 504),
|
|
1465
|
+
error
|
|
1466
|
+
});
|
|
1467
|
+
}, input.responseTimeoutMs);
|
|
962
1468
|
input.ws.data.pending.set(requestId, {
|
|
963
1469
|
controller,
|
|
964
1470
|
stream,
|
|
@@ -970,7 +1476,13 @@ async function tunnelHttpRequest(input) {
|
|
|
970
1476
|
responseStarted: false
|
|
971
1477
|
});
|
|
972
1478
|
});
|
|
973
|
-
const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 :
|
|
1479
|
+
const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : await readBoundedBody(input.request, input.requestBodyMaxBytes);
|
|
1480
|
+
if (body && "response" in body) {
|
|
1481
|
+
const entry = input.ws.data.pending.get(requestId);
|
|
1482
|
+
if (entry) clearPendingTimers(entry);
|
|
1483
|
+
input.ws.data.pending.delete(requestId);
|
|
1484
|
+
return body.response;
|
|
1485
|
+
}
|
|
974
1486
|
input.ws.send(JSON.stringify({
|
|
975
1487
|
type: "http.request",
|
|
976
1488
|
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
@@ -986,33 +1498,81 @@ async function tunnelHttpRequest(input) {
|
|
|
986
1498
|
// cookies to the host process running on the user's laptop.
|
|
987
1499
|
{ userHosted: true }
|
|
988
1500
|
)),
|
|
989
|
-
...body ? { body_base64: body } : {},
|
|
1501
|
+
...body?.bodyBase64 ? { body_base64: body.bodyBase64 } : {},
|
|
990
1502
|
end: true
|
|
991
1503
|
}));
|
|
992
1504
|
return await pending;
|
|
993
1505
|
}
|
|
1506
|
+
async function readBoundedBody(request, maxBytes) {
|
|
1507
|
+
if (!request.body) return void 0;
|
|
1508
|
+
const reader = request.body.getReader();
|
|
1509
|
+
const chunks = [];
|
|
1510
|
+
let total = 0;
|
|
1511
|
+
for (; ; ) {
|
|
1512
|
+
const next = await reader.read();
|
|
1513
|
+
if (next.done) break;
|
|
1514
|
+
total += next.value.byteLength;
|
|
1515
|
+
if (total > maxBytes) {
|
|
1516
|
+
try {
|
|
1517
|
+
await reader.cancel();
|
|
1518
|
+
} catch {
|
|
1519
|
+
}
|
|
1520
|
+
return {
|
|
1521
|
+
response: corsJsonError(request, "request_body_too_large", "Tunnel request body exceeds the relay limit", 413)
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
chunks.push(next.value);
|
|
1525
|
+
}
|
|
1526
|
+
const body = new Uint8Array(total);
|
|
1527
|
+
chunks.reduce((offset, chunk) => {
|
|
1528
|
+
body.set(chunk, offset);
|
|
1529
|
+
return offset + chunk.byteLength;
|
|
1530
|
+
}, 0);
|
|
1531
|
+
return { bodyBase64: encoded(body.buffer) };
|
|
1532
|
+
}
|
|
994
1533
|
async function directHttpRequest(input) {
|
|
995
|
-
const
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1534
|
+
const controller = new AbortController();
|
|
1535
|
+
const timer = setTimeout(() => controller.abort(), input.timeoutMs);
|
|
1536
|
+
const span = (name, run) => input.trace ? input.trace.span(name, run) : run();
|
|
1537
|
+
try {
|
|
1538
|
+
const init = workspaceRelayForwardRequestInit(
|
|
1539
|
+
input.request,
|
|
999
1540
|
input.relayHostToken,
|
|
1000
1541
|
input.workspaceId,
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1542
|
+
{
|
|
1543
|
+
// T26: cloud-vm direct path. Cookies pass through (default) — workspace
|
|
1544
|
+
// dashboards in the VM may legitimately need session cookies.
|
|
1545
|
+
userHosted: false,
|
|
1546
|
+
upstreamHeaders: input.upstreamHeaders,
|
|
1547
|
+
signal: controller.signal
|
|
1548
|
+
}
|
|
1549
|
+
);
|
|
1550
|
+
if (input.request.method !== "GET" && input.request.method !== "HEAD" && input.request.body) {
|
|
1551
|
+
init.body = await input.request.arrayBuffer();
|
|
1552
|
+
}
|
|
1553
|
+
const limiter = input.limiter;
|
|
1554
|
+
const release = limiter ? await span("direct-http-queue", () => limiter.acquire()) : void 0;
|
|
1555
|
+
try {
|
|
1556
|
+
const upstream = await span("upstream-fetch", async () => await fetch(input.targetUrl, init));
|
|
1557
|
+
return new Response(upstream.body, {
|
|
1558
|
+
status: upstream.status,
|
|
1559
|
+
statusText: upstream.statusText,
|
|
1560
|
+
headers: relayCorsHeaders(input.request, upstream.headers)
|
|
1561
|
+
});
|
|
1562
|
+
} finally {
|
|
1563
|
+
release?.();
|
|
1564
|
+
}
|
|
1565
|
+
} catch (err) {
|
|
1566
|
+
const aborted = err instanceof Error && err.name === "AbortError";
|
|
1567
|
+
return corsJsonError(
|
|
1568
|
+
input.request,
|
|
1569
|
+
aborted ? "upstream_timeout" : "upstream_unavailable",
|
|
1570
|
+
aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable",
|
|
1571
|
+
aborted ? 504 : 503
|
|
1572
|
+
);
|
|
1573
|
+
} finally {
|
|
1574
|
+
clearTimeout(timer);
|
|
1575
|
+
}
|
|
1016
1576
|
}
|
|
1017
1577
|
function hostTunnel(hostTunnels, hostId) {
|
|
1018
1578
|
const tunnel = hostTunnels.get(hostId);
|
|
@@ -1033,12 +1593,30 @@ async function authorizeHostTunnel(options, bunOptions, request, input) {
|
|
|
1033
1593
|
}
|
|
1034
1594
|
function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
1035
1595
|
const hostTunnels = /* @__PURE__ */ new Map();
|
|
1596
|
+
const relayClients = /* @__PURE__ */ new Set();
|
|
1036
1597
|
const hostTunnelRegistrations = /* @__PURE__ */ new Map();
|
|
1598
|
+
const directHttpLimiter = createDirectHttpLimiter(bunOptions.directHttpConcurrency);
|
|
1599
|
+
const hostTunnelStateDebounce = /* @__PURE__ */ new Map();
|
|
1600
|
+
const fragmentationStats = createFragmentationStats();
|
|
1601
|
+
const slowConsumerStats = createSlowConsumerStats();
|
|
1602
|
+
const telemetry = {
|
|
1603
|
+
getFragmentationStats: () => readFragmentationStats(fragmentationStats),
|
|
1604
|
+
resetFragmentationStats: () => resetFragmentationStats(fragmentationStats),
|
|
1605
|
+
getSlowConsumerStats: () => readSlowConsumerStats(slowConsumerStats),
|
|
1606
|
+
resetSlowConsumerStats: () => resetSlowConsumerStats(slowConsumerStats)
|
|
1607
|
+
};
|
|
1037
1608
|
let draining = false;
|
|
1038
1609
|
const drainController = {
|
|
1039
1610
|
isDraining: () => draining,
|
|
1040
1611
|
setDraining: (value) => {
|
|
1041
1612
|
draining = value;
|
|
1613
|
+
if (!value) return;
|
|
1614
|
+
for (const tunnel of hostTunnels.values()) {
|
|
1615
|
+
closeWebSocket(tunnel, 1012, "Workspace relay is draining", 1012);
|
|
1616
|
+
}
|
|
1617
|
+
for (const client of relayClients) {
|
|
1618
|
+
closeWebSocket(client, 1012, "Workspace relay is draining", 1012);
|
|
1619
|
+
}
|
|
1042
1620
|
},
|
|
1043
1621
|
pendingCount: () => {
|
|
1044
1622
|
let total = 0;
|
|
@@ -1061,8 +1639,8 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1061
1639
|
}
|
|
1062
1640
|
};
|
|
1063
1641
|
const mergedMetricsSources = {
|
|
1064
|
-
fragmentation: options.metricsSources?.fragmentation ?? getFragmentationStats,
|
|
1065
|
-
slowConsumer: options.metricsSources?.slowConsumer ?? getSlowConsumerStats,
|
|
1642
|
+
fragmentation: options.metricsSources?.fragmentation ?? telemetry.getFragmentationStats,
|
|
1643
|
+
slowConsumer: options.metricsSources?.slowConsumer ?? telemetry.getSlowConsumerStats,
|
|
1066
1644
|
drainPending: options.metricsSources?.drainPending ?? drainController.pendingCount
|
|
1067
1645
|
};
|
|
1068
1646
|
const requestRemoteAddress = /* @__PURE__ */ new WeakMap();
|
|
@@ -1076,7 +1654,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1076
1654
|
function getRegistrationTracker(hostId) {
|
|
1077
1655
|
let tracker = hostTunnelRegistrations.get(hostId);
|
|
1078
1656
|
if (!tracker) {
|
|
1079
|
-
tracker = {
|
|
1657
|
+
tracker = { recent: [] };
|
|
1080
1658
|
hostTunnelRegistrations.set(hostId, tracker);
|
|
1081
1659
|
}
|
|
1082
1660
|
return tracker;
|
|
@@ -1097,6 +1675,12 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1097
1675
|
if (ip) requestRemoteAddress.set(request, ip);
|
|
1098
1676
|
} catch {
|
|
1099
1677
|
}
|
|
1678
|
+
if (url.pathname === "/health") {
|
|
1679
|
+
return Response.json(
|
|
1680
|
+
draining ? { ok: false, service: "workspace-relay", draining: true } : { ok: true, service: "workspace-relay" },
|
|
1681
|
+
{ status: draining ? 503 : 200 }
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1100
1684
|
if (draining && websocketRequest(request) && hostId) {
|
|
1101
1685
|
return jsonError(
|
|
1102
1686
|
"relay_draining",
|
|
@@ -1126,13 +1710,6 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1126
1710
|
const tracker = getRegistrationTracker(hostId);
|
|
1127
1711
|
const now = Date.now();
|
|
1128
1712
|
pruneReconnects(tracker, now);
|
|
1129
|
-
if (tracker.current >= HOST_TUNNEL_REGISTRATION_CONCURRENT_CAP) {
|
|
1130
|
-
return jsonError(
|
|
1131
|
-
"too_many_concurrent_host_tunnels",
|
|
1132
|
-
"Too many concurrent host-tunnel registrations for this host",
|
|
1133
|
-
429
|
|
1134
|
-
);
|
|
1135
|
-
}
|
|
1136
1713
|
if (tracker.recent.length >= HOST_TUNNEL_REGISTRATION_RECONNECT_CAP) {
|
|
1137
1714
|
return jsonError(
|
|
1138
1715
|
"too_many_host_tunnel_reconnects",
|
|
@@ -1147,10 +1724,10 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1147
1724
|
workspaceIds,
|
|
1148
1725
|
pending: /* @__PURE__ */ new Map(),
|
|
1149
1726
|
channels: /* @__PURE__ */ new Map(),
|
|
1727
|
+
missedPongs: 0,
|
|
1150
1728
|
messageBuffer: ""
|
|
1151
1729
|
}
|
|
1152
1730
|
})) {
|
|
1153
|
-
tracker.current += 1;
|
|
1154
1731
|
tracker.recent.push(now);
|
|
1155
1732
|
return;
|
|
1156
1733
|
}
|
|
@@ -1163,32 +1740,45 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1163
1740
|
headers: relayCorsHeaders(request)
|
|
1164
1741
|
});
|
|
1165
1742
|
}
|
|
1166
|
-
const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
|
|
1167
|
-
if (!relay.ok) return relay.response;
|
|
1168
|
-
if (!websocketRequest(request) && relay.request.target.access === "user-hosted") {
|
|
1169
|
-
const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId);
|
|
1170
|
-
return tunnel ? await tunnelHttpRequest({
|
|
1171
|
-
ws: tunnel,
|
|
1172
|
-
request,
|
|
1173
|
-
workspaceId: relay.request.target.workspaceId,
|
|
1174
|
-
path: `${relay.request.path}${url.search}`,
|
|
1175
|
-
relayHostToken: relay.request.relayHostToken,
|
|
1176
|
-
slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
|
|
1177
|
-
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
|
|
1178
|
-
}) : new Response("User-hosted workspace is offline", { status: 503 });
|
|
1179
|
-
}
|
|
1180
1743
|
if (!websocketRequest(request)) {
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1744
|
+
const trace = createWorkspaceRelayTrace();
|
|
1745
|
+
const response = await trace.span("relay-total", async () => {
|
|
1746
|
+
const relay2 = await authorizeWorkspaceRelayRequest(options, request, workspaceId, trace);
|
|
1747
|
+
if (!relay2.ok) return relay2.response;
|
|
1748
|
+
if (relay2.request.target.access === "user-hosted") {
|
|
1749
|
+
const tunnel = hostTunnel(hostTunnels, relay2.request.target.hostId);
|
|
1750
|
+
return tunnel ? await trace.span("tunnel-http", async () => await tunnelHttpRequest({
|
|
1751
|
+
ws: tunnel,
|
|
1752
|
+
request,
|
|
1753
|
+
workspaceId: relay2.request.target.workspaceId,
|
|
1754
|
+
path: `${relay2.request.path}${url.search}`,
|
|
1755
|
+
relayHostToken: relay2.request.relayHostToken,
|
|
1756
|
+
slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
|
|
1757
|
+
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
|
|
1758
|
+
slowConsumerStats,
|
|
1759
|
+
requestBodyMaxBytes: bunOptions.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT,
|
|
1760
|
+
responseTimeoutMs: bunOptions.tunnelHttpResponseTimeoutMs ?? TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT
|
|
1761
|
+
})) : new Response("User-hosted workspace is offline", { status: 503 });
|
|
1762
|
+
}
|
|
1763
|
+
return await directHttpRequest({
|
|
1764
|
+
request,
|
|
1765
|
+
targetUrl: workspaceRelayTargetUrl(
|
|
1766
|
+
relay2.request.target,
|
|
1767
|
+
relay2.request.path,
|
|
1768
|
+
url.search
|
|
1769
|
+
).toString(),
|
|
1770
|
+
relayHostToken: relay2.request.relayHostToken,
|
|
1771
|
+
workspaceId: relay2.request.target.workspaceId,
|
|
1772
|
+
upstreamHeaders: relay2.request.target.upstreamHeaders,
|
|
1773
|
+
timeoutMs: bunOptions.directHttpTimeoutMs ?? DIRECT_HTTP_TIMEOUT_MS_DEFAULT,
|
|
1774
|
+
limiter: directHttpLimiter,
|
|
1775
|
+
trace
|
|
1776
|
+
});
|
|
1190
1777
|
});
|
|
1778
|
+
return workspaceRelayTimingResponse(response, trace);
|
|
1191
1779
|
}
|
|
1780
|
+
const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
|
|
1781
|
+
if (!relay.ok) return relay.response;
|
|
1192
1782
|
const originDenied = requireAllowedOrigin(request);
|
|
1193
1783
|
if (originDenied) return originDenied;
|
|
1194
1784
|
if (relay.request.target.access === "user-hosted") {
|
|
@@ -1230,9 +1820,17 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1230
1820
|
relay.request.relayHostToken,
|
|
1231
1821
|
relay.request.target.workspaceId,
|
|
1232
1822
|
// T26: cloud-vm WS upgrade. Cookies pass through (default).
|
|
1233
|
-
{ userHosted: false }
|
|
1823
|
+
{ userHosted: false, upstreamHeaders: relay.request.target.upstreamHeaders }
|
|
1234
1824
|
)),
|
|
1235
|
-
queue: []
|
|
1825
|
+
queue: [],
|
|
1826
|
+
...relayWebSocketTraceEnabled(request) ? {
|
|
1827
|
+
trace: {
|
|
1828
|
+
acceptedAt: performance.now(),
|
|
1829
|
+
queuedFrames: 0,
|
|
1830
|
+
maxQueuedDelayMs: 0,
|
|
1831
|
+
emitted: false
|
|
1832
|
+
}
|
|
1833
|
+
} : {}
|
|
1236
1834
|
}
|
|
1237
1835
|
})) {
|
|
1238
1836
|
return;
|
|
@@ -1245,22 +1843,39 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1245
1843
|
if (ws.data.kind === "host-tunnel") {
|
|
1246
1844
|
const parsed = tunnelMessage(
|
|
1247
1845
|
ws,
|
|
1248
|
-
message
|
|
1846
|
+
message,
|
|
1847
|
+
fragmentationStats
|
|
1249
1848
|
);
|
|
1250
1849
|
if (parsed?.type === "ping") {
|
|
1251
1850
|
options.directory?.recordPong(ws.data.hostId);
|
|
1252
1851
|
ws.send(JSON.stringify(makeTunnelPong(parsed)));
|
|
1253
1852
|
}
|
|
1254
1853
|
if (parsed?.type === "pong") {
|
|
1854
|
+
ws.data.missedPongs = 0;
|
|
1255
1855
|
options.directory?.recordPong(ws.data.hostId);
|
|
1256
1856
|
}
|
|
1857
|
+
if (parsed?.type === "error") {
|
|
1858
|
+
if (parsed.request_id) {
|
|
1859
|
+
const pending = ws.data.pending.get(parsed.request_id);
|
|
1860
|
+
if (pending) {
|
|
1861
|
+
ws.data.pending.delete(parsed.request_id);
|
|
1862
|
+
failPendingHttpResponse({
|
|
1863
|
+
entry: pending,
|
|
1864
|
+
response: jsonError(parsed.code, parsed.message, 502),
|
|
1865
|
+
error: new Error(parsed.message)
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1257
1870
|
if (parsed?.type === "http.response.start") {
|
|
1258
1871
|
const pending = ws.data.pending.get(parsed.request_id);
|
|
1259
1872
|
if (pending) {
|
|
1873
|
+
const response = parsed;
|
|
1260
1874
|
pending.responseStarted = true;
|
|
1875
|
+
if (isEventStream(response.headers)) clearTimeout(pending.timeout);
|
|
1261
1876
|
pending.resolve(new Response(pending.stream, {
|
|
1262
|
-
status:
|
|
1263
|
-
headers: headers(
|
|
1877
|
+
status: response.status,
|
|
1878
|
+
headers: headers(response.headers)
|
|
1264
1879
|
}));
|
|
1265
1880
|
}
|
|
1266
1881
|
}
|
|
@@ -1272,7 +1887,8 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1272
1887
|
requestId: parsed.request_id,
|
|
1273
1888
|
entry,
|
|
1274
1889
|
chunk: decoded(parsed.body_base64),
|
|
1275
|
-
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
|
|
1890
|
+
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
|
|
1891
|
+
slowConsumerStats
|
|
1276
1892
|
});
|
|
1277
1893
|
}
|
|
1278
1894
|
}
|
|
@@ -1305,14 +1921,24 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1305
1921
|
}
|
|
1306
1922
|
if (parsed?.type === "ws.frame") {
|
|
1307
1923
|
const frame = parsed;
|
|
1308
|
-
ws.data.channels.get(frame.channel_id)
|
|
1924
|
+
const channel = ws.data.channels.get(frame.channel_id);
|
|
1925
|
+
if (!channel || channel.readyState !== WebSocket.OPEN) {
|
|
1926
|
+
ws.data.channels.delete(frame.channel_id);
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
if (channel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
|
|
1930
|
+
ws.data.channels.delete(frame.channel_id);
|
|
1931
|
+
closeWebSocket(channel, 1011, "Client WebSocket backpressure limit exceeded");
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
channel.send(decodedFrame(frame));
|
|
1309
1935
|
}
|
|
1310
1936
|
if (parsed?.type === "ws.close") {
|
|
1311
1937
|
const close = parsed;
|
|
1312
1938
|
const channel = ws.data.channels.get(close.channel_id);
|
|
1313
1939
|
if (channel) {
|
|
1314
1940
|
ws.data.channels.delete(close.channel_id);
|
|
1315
|
-
channel
|
|
1941
|
+
closeWebSocket(channel, close.code, close.reason);
|
|
1316
1942
|
}
|
|
1317
1943
|
}
|
|
1318
1944
|
return;
|
|
@@ -1323,6 +1949,10 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1323
1949
|
ws.close(1011, "User-hosted tunnel disconnected");
|
|
1324
1950
|
return;
|
|
1325
1951
|
}
|
|
1952
|
+
if (tunnel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
|
|
1953
|
+
closeWebSocket(ws, 1011, "Host tunnel backpressure limit exceeded");
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1326
1956
|
tunnel.send(JSON.stringify({
|
|
1327
1957
|
type: "ws.frame",
|
|
1328
1958
|
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
@@ -1335,19 +1965,41 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1335
1965
|
ws.data.upstream.send(message);
|
|
1336
1966
|
return;
|
|
1337
1967
|
}
|
|
1338
|
-
ws.data.queue.
|
|
1968
|
+
if (ws.data.queue.length >= (bunOptions.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT)) {
|
|
1969
|
+
closeWebSocket(ws, 1011, "Upstream WebSocket queue limit exceeded");
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
ws.data.queue.push({ payload: message, queuedAt: performance.now() });
|
|
1339
1973
|
},
|
|
1340
1974
|
open(ws) {
|
|
1341
1975
|
if (ws.data.kind === "host-tunnel") {
|
|
1976
|
+
const previous = hostTunnels.get(ws.data.hostId);
|
|
1977
|
+
if (previous && previous !== ws) {
|
|
1978
|
+
cleanupHostTunnelSocket({
|
|
1979
|
+
ws: previous,
|
|
1980
|
+
hostTunnels,
|
|
1981
|
+
hostTunnelStateDebounce,
|
|
1982
|
+
options,
|
|
1983
|
+
bunOptions,
|
|
1984
|
+
disconnectDirectory: false,
|
|
1985
|
+
closeChannels: true
|
|
1986
|
+
});
|
|
1987
|
+
closeWebSocket(previous, 1012, "Host tunnel replaced by a newer connection", 1012);
|
|
1988
|
+
}
|
|
1342
1989
|
hostTunnels.set(ws.data.hostId, ws);
|
|
1343
1990
|
options.directory?.registerHostTunnel({
|
|
1344
1991
|
hostId: ws.data.hostId,
|
|
1345
1992
|
workspaceIds: ws.data.workspaceIds
|
|
1346
1993
|
});
|
|
1347
1994
|
ws.data.heartbeat = setInterval(() => {
|
|
1348
|
-
|
|
1995
|
+
const hostWs = ws;
|
|
1996
|
+
if (hostWs.data.missedPongs > (bunOptions.hostTunnelMaxMissedPongs ?? HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT)) {
|
|
1997
|
+
closeWebSocket(ws, 1001, "Host tunnel heartbeat timed out", 1001);
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
sendTunnelPing(hostWs);
|
|
1349
2001
|
}, bunOptions.hostTunnelPingIntervalMs ?? 15e3);
|
|
1350
|
-
scheduleHostTunnelStateChange(options, {
|
|
2002
|
+
scheduleHostTunnelStateChange(hostTunnelStateDebounce, options, {
|
|
1351
2003
|
hostId: ws.data.hostId,
|
|
1352
2004
|
workspaceId: ws.data.workspaceIds[0],
|
|
1353
2005
|
path: `/host-tunnels/${ws.data.hostId}`,
|
|
@@ -1363,6 +2015,10 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1363
2015
|
return;
|
|
1364
2016
|
}
|
|
1365
2017
|
tunnel.data.channels.set(ws.data.channelId, ws);
|
|
2018
|
+
if (tunnel.readyState !== WebSocket.OPEN) {
|
|
2019
|
+
closeWebSocket(ws, 1011, "User-hosted tunnel disconnected");
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
1366
2022
|
tunnel.send(JSON.stringify({
|
|
1367
2023
|
type: "ws.open",
|
|
1368
2024
|
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
@@ -1381,76 +2037,105 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1381
2037
|
}));
|
|
1382
2038
|
return;
|
|
1383
2039
|
}
|
|
2040
|
+
relayClients.add(ws);
|
|
1384
2041
|
const data = ws.data;
|
|
1385
|
-
const
|
|
2042
|
+
const UpstreamWebSocket = bunOptions.upstreamWebSocket ?? WebSocket;
|
|
2043
|
+
if (data.trace) data.trace.upstreamStartedAt = performance.now();
|
|
2044
|
+
const upstream = new UpstreamWebSocket(data.upstreamUrl, {
|
|
1386
2045
|
headers: data.headers
|
|
1387
2046
|
});
|
|
1388
2047
|
upstream.binaryType = "arraybuffer";
|
|
1389
2048
|
data.upstream = upstream;
|
|
2049
|
+
data.upstreamOpenTimer = setTimeout(() => {
|
|
2050
|
+
if (upstream.readyState === WebSocket.OPEN) return;
|
|
2051
|
+
closeWebSocket(upstream, 1001, "Upstream WebSocket open timed out", 1001);
|
|
2052
|
+
closeWebSocket(ws, 1011, "Upstream WebSocket open timed out");
|
|
2053
|
+
}, bunOptions.upstreamWebSocketOpenTimeoutMs ?? UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT);
|
|
1390
2054
|
upstream.onopen = () => {
|
|
1391
|
-
|
|
2055
|
+
const openedAt = performance.now();
|
|
2056
|
+
if (data.upstreamOpenTimer) {
|
|
2057
|
+
clearTimeout(data.upstreamOpenTimer);
|
|
2058
|
+
data.upstreamOpenTimer = void 0;
|
|
2059
|
+
}
|
|
2060
|
+
if (data.trace) {
|
|
2061
|
+
data.trace.upstreamOpenMs = openedAt - (data.trace.upstreamStartedAt ?? data.trace.acceptedAt);
|
|
2062
|
+
data.trace.queuedFrames = data.queue.length;
|
|
2063
|
+
data.trace.maxQueuedDelayMs = data.queue.reduce(
|
|
2064
|
+
(max, item) => Math.max(max, openedAt - item.queuedAt),
|
|
2065
|
+
0
|
|
2066
|
+
);
|
|
2067
|
+
sendRelayWebSocketTrace(ws);
|
|
2068
|
+
}
|
|
2069
|
+
for (const item of data.queue.splice(0)) upstream.send(item.payload);
|
|
1392
2070
|
};
|
|
1393
2071
|
upstream.onmessage = (event) => {
|
|
1394
2072
|
const payload = relayWebSocketPayload(event.data);
|
|
1395
2073
|
if (payload !== void 0) ws.send(payload);
|
|
1396
2074
|
};
|
|
1397
2075
|
upstream.onclose = (event) => {
|
|
1398
|
-
|
|
2076
|
+
if (data.upstreamOpenTimer) {
|
|
2077
|
+
clearTimeout(data.upstreamOpenTimer);
|
|
2078
|
+
data.upstreamOpenTimer = void 0;
|
|
2079
|
+
}
|
|
2080
|
+
closeWebSocket(ws, event.code, upstreamCloseReason(event));
|
|
1399
2081
|
};
|
|
1400
2082
|
upstream.onerror = () => {
|
|
1401
|
-
|
|
2083
|
+
if (data.upstreamOpenTimer) {
|
|
2084
|
+
clearTimeout(data.upstreamOpenTimer);
|
|
2085
|
+
data.upstreamOpenTimer = void 0;
|
|
2086
|
+
}
|
|
2087
|
+
closeWebSocket(ws, 1011, "Upstream WebSocket connection failed");
|
|
1402
2088
|
};
|
|
1403
2089
|
},
|
|
1404
2090
|
close(ws, code, reason) {
|
|
1405
2091
|
if (ws.data.kind === "host-tunnel") {
|
|
1406
|
-
|
|
1407
|
-
|
|
2092
|
+
cleanupHostTunnelSocket({
|
|
2093
|
+
ws,
|
|
2094
|
+
hostTunnels,
|
|
2095
|
+
hostTunnelStateDebounce,
|
|
2096
|
+
options,
|
|
2097
|
+
bunOptions,
|
|
2098
|
+
disconnectDirectory: true,
|
|
2099
|
+
closeChannels: true
|
|
2100
|
+
});
|
|
1408
2101
|
const tracker = hostTunnelRegistrations.get(ws.data.hostId);
|
|
1409
2102
|
if (tracker) {
|
|
1410
|
-
tracker.current = Math.max(0, tracker.current - 1);
|
|
1411
2103
|
pruneReconnects(tracker, Date.now());
|
|
1412
|
-
if (tracker.
|
|
1413
|
-
hostTunnelRegistrations.delete(ws.data.hostId);
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
for (const pending of ws.data.pending.values()) {
|
|
1417
|
-
clearTimeout(pending.timeout);
|
|
1418
|
-
pending.reject(new Error("User-hosted tunnel disconnected"));
|
|
1419
|
-
}
|
|
1420
|
-
ws.data.pending.clear();
|
|
1421
|
-
for (const channel of ws.data.channels.values()) {
|
|
1422
|
-
channel.close(1011, "User-hosted tunnel disconnected");
|
|
2104
|
+
if (tracker.recent.length === 0) hostTunnelRegistrations.delete(ws.data.hostId);
|
|
1423
2105
|
}
|
|
1424
|
-
ws.data.channels.clear();
|
|
1425
|
-
options.directory?.disconnectHost(ws.data.hostId);
|
|
1426
|
-
scheduleHostTunnelStateChange(options, {
|
|
1427
|
-
hostId: ws.data.hostId,
|
|
1428
|
-
workspaceId: ws.data.workspaceIds[0],
|
|
1429
|
-
path: `/host-tunnels/${ws.data.hostId}`,
|
|
1430
|
-
state: "disconnected",
|
|
1431
|
-
debounceMs: bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
|
|
1432
|
-
});
|
|
1433
2106
|
return;
|
|
1434
2107
|
}
|
|
1435
2108
|
if (ws.data.kind === "user-hosted-client") {
|
|
1436
2109
|
const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
|
|
1437
2110
|
tunnel?.data.channels.delete(ws.data.channelId);
|
|
1438
|
-
tunnel?.
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
2111
|
+
if (tunnel?.readyState === WebSocket.OPEN) {
|
|
2112
|
+
tunnel.send(JSON.stringify({
|
|
2113
|
+
type: "ws.close",
|
|
2114
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2115
|
+
channel_id: ws.data.channelId,
|
|
2116
|
+
code: safeCloseCode(code, 1e3),
|
|
2117
|
+
reason
|
|
2118
|
+
}));
|
|
2119
|
+
}
|
|
1445
2120
|
return;
|
|
1446
2121
|
}
|
|
1447
|
-
|
|
2122
|
+
relayClients.delete(ws);
|
|
2123
|
+
if (ws.data.upstreamOpenTimer) clearTimeout(ws.data.upstreamOpenTimer);
|
|
2124
|
+
if (ws.data.upstream) closeWebSocket(ws.data.upstream, code, reason, 1e3);
|
|
1448
2125
|
}
|
|
1449
2126
|
},
|
|
1450
|
-
drain: drainController
|
|
2127
|
+
drain: drainController,
|
|
2128
|
+
telemetry
|
|
1451
2129
|
};
|
|
1452
2130
|
}
|
|
1453
2131
|
|
|
2132
|
+
// src/cloudflare.ts
|
|
2133
|
+
import {
|
|
2134
|
+
makeTunnelPong as makeTunnelPong2,
|
|
2135
|
+
TUNNEL_PROTOCOL_VERSION as TUNNEL_PROTOCOL_VERSION2,
|
|
2136
|
+
validateTunnelMessage as validateTunnelMessage2
|
|
2137
|
+
} from "@claxedo/workspace-relay-protocol";
|
|
2138
|
+
|
|
1454
2139
|
// src/directory.ts
|
|
1455
2140
|
function createWorkspaceRelayDirectory(options = {}) {
|
|
1456
2141
|
const ttlMs = Math.max(1, options.ttlMs ?? 45e3);
|
|
@@ -1532,32 +2217,1288 @@ function createWorkspaceRelayDirectory(options = {}) {
|
|
|
1532
2217
|
function disposeWorkspaceRelayDirectory(directory) {
|
|
1533
2218
|
directory.dispose();
|
|
1534
2219
|
}
|
|
2220
|
+
|
|
2221
|
+
// src/cloudflare.ts
|
|
2222
|
+
var DEFAULT_RELAY_LOCATION_HINT = "apac";
|
|
2223
|
+
var TUNNEL_PENDING_HTTP_CAP_DEFAULT = 32;
|
|
2224
|
+
var TUNNEL_STARTED_STREAM_CAP_DEFAULT = 16;
|
|
2225
|
+
var TUNNEL_CHANNEL_CAP_DEFAULT = 16;
|
|
2226
|
+
var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT2 = 16 * 1024 * 1024;
|
|
2227
|
+
var TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
|
|
2228
|
+
var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT2 = 8 * 1024 * 1024;
|
|
2229
|
+
var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT2 = 3e4;
|
|
2230
|
+
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT2 = 64;
|
|
2231
|
+
var RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
|
|
2232
|
+
var WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT = 3e4;
|
|
2233
|
+
var TRACE_ID_HEADER = "x-claxedo-trace-id";
|
|
2234
|
+
var TRACE_FORCE_HEADER = "x-claxedo-relay-trace";
|
|
2235
|
+
function clean(input) {
|
|
2236
|
+
const value = input?.trim();
|
|
2237
|
+
return value ? value : void 0;
|
|
2238
|
+
}
|
|
2239
|
+
function clampRate(input) {
|
|
2240
|
+
if (input === void 0 || !Number.isFinite(input)) return 0;
|
|
2241
|
+
if (input <= 0) return 0;
|
|
2242
|
+
if (input >= 1) return 1;
|
|
2243
|
+
return input;
|
|
2244
|
+
}
|
|
2245
|
+
function traceRequest(request, routeKind, options) {
|
|
2246
|
+
const id = clean(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
|
|
2247
|
+
const forced = request.headers.get(TRACE_FORCE_HEADER) === "1";
|
|
2248
|
+
const rate = clampRate(options.traceSampleRate);
|
|
2249
|
+
return {
|
|
2250
|
+
id,
|
|
2251
|
+
sampled: forced || rate > 0 && (options.random ?? Math.random)() < rate,
|
|
2252
|
+
startedAt: performance.now(),
|
|
2253
|
+
routeKind,
|
|
2254
|
+
phases: []
|
|
2255
|
+
};
|
|
2256
|
+
}
|
|
2257
|
+
async function traceSpan(trace, name, fn) {
|
|
2258
|
+
const startedAt = performance.now();
|
|
2259
|
+
try {
|
|
2260
|
+
return await fn();
|
|
2261
|
+
} finally {
|
|
2262
|
+
trace?.phases.push({ name, ms: performance.now() - startedAt });
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
function tracedRequest(request, trace) {
|
|
2266
|
+
const headers2 = new Headers(request.headers);
|
|
2267
|
+
headers2.set(TRACE_ID_HEADER, trace.id);
|
|
2268
|
+
if (trace.sampled) headers2.set("x-claxedo-trace-sampled", "1");
|
|
2269
|
+
return new Request(request.url, {
|
|
2270
|
+
method: request.method,
|
|
2271
|
+
headers: headers2,
|
|
2272
|
+
body: request.method === "GET" || request.method === "HEAD" ? void 0 : request.body,
|
|
2273
|
+
redirect: request.redirect,
|
|
2274
|
+
duplex: "half"
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
function roundedMs3(value) {
|
|
2278
|
+
return Math.round(value * 100) / 100;
|
|
2279
|
+
}
|
|
2280
|
+
function serverTiming(trace) {
|
|
2281
|
+
return trace.phases.map((phase) => `${phase.name};dur=${roundedMs3(phase.ms)}`).join(", ");
|
|
2282
|
+
}
|
|
2283
|
+
function withTraceHeaders(response, trace) {
|
|
2284
|
+
const headers2 = new Headers(response.headers);
|
|
2285
|
+
headers2.set(TRACE_ID_HEADER, trace.id);
|
|
2286
|
+
const timing = serverTiming(trace);
|
|
2287
|
+
if (timing) {
|
|
2288
|
+
const existing = headers2.get("server-timing");
|
|
2289
|
+
headers2.set("server-timing", existing ? `${existing}, ${timing}` : timing);
|
|
2290
|
+
}
|
|
2291
|
+
return new Response(response.body, {
|
|
2292
|
+
status: response.status,
|
|
2293
|
+
statusText: response.statusText,
|
|
2294
|
+
headers: headers2
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
function logTrace(options, request, workspaceId, trace, response) {
|
|
2298
|
+
if (!trace.sampled) return;
|
|
2299
|
+
const url = new URL(request.url);
|
|
2300
|
+
const totalMs = performance.now() - trace.startedAt;
|
|
2301
|
+
const event = {
|
|
2302
|
+
event: "workspace_relay_request",
|
|
2303
|
+
traceId: trace.id,
|
|
2304
|
+
workspaceId,
|
|
2305
|
+
routeKind: trace.routeKind,
|
|
2306
|
+
method: request.method,
|
|
2307
|
+
path: url.pathname,
|
|
2308
|
+
status: response.status,
|
|
2309
|
+
totalMs: roundedMs3(totalMs),
|
|
2310
|
+
phases: Object.fromEntries(trace.phases.map((phase) => [phase.name, roundedMs3(phase.ms)]))
|
|
2311
|
+
};
|
|
2312
|
+
if (options.traceLog) {
|
|
2313
|
+
options.traceLog(event);
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
console.log(JSON.stringify(event));
|
|
2317
|
+
}
|
|
2318
|
+
function workspaceIdFromRelayPath(pathname) {
|
|
2319
|
+
return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
2320
|
+
}
|
|
2321
|
+
function hostIdFromTunnelPath2(pathname) {
|
|
2322
|
+
return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
2323
|
+
}
|
|
2324
|
+
function websocketRequest2(request) {
|
|
2325
|
+
return request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
2326
|
+
}
|
|
2327
|
+
function workspaceIdsFromSearch(url) {
|
|
2328
|
+
return [...new Set([
|
|
2329
|
+
...url.searchParams.getAll("workspaceId"),
|
|
2330
|
+
...url.searchParams.getAll("workspace_id")
|
|
2331
|
+
].map(clean).filter((item) => !!item))];
|
|
2332
|
+
}
|
|
2333
|
+
function randomToken() {
|
|
2334
|
+
return crypto.randomUUID();
|
|
2335
|
+
}
|
|
2336
|
+
function headerRecord(headers2) {
|
|
2337
|
+
const result = {};
|
|
2338
|
+
headers2.forEach((value, key) => {
|
|
2339
|
+
result[key] = value;
|
|
2340
|
+
});
|
|
2341
|
+
return result;
|
|
2342
|
+
}
|
|
2343
|
+
function headersFromRecord(input) {
|
|
2344
|
+
const headers2 = new Headers();
|
|
2345
|
+
for (const [key, value] of Object.entries(input)) headers2.set(key, value);
|
|
2346
|
+
return headers2;
|
|
2347
|
+
}
|
|
2348
|
+
function bytesToBase64(bytes) {
|
|
2349
|
+
let binary = "";
|
|
2350
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
2351
|
+
return btoa(binary);
|
|
2352
|
+
}
|
|
2353
|
+
function base64ToBytes(input) {
|
|
2354
|
+
return Uint8Array.from(atob(input), (char) => char.charCodeAt(0));
|
|
2355
|
+
}
|
|
2356
|
+
function socketFrame(input) {
|
|
2357
|
+
if (typeof input === "string") {
|
|
2358
|
+
return {
|
|
2359
|
+
binary: false,
|
|
2360
|
+
data_base64: bytesToBase64(new TextEncoder().encode(input))
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
if (input instanceof ArrayBuffer) {
|
|
2364
|
+
return {
|
|
2365
|
+
binary: true,
|
|
2366
|
+
data_base64: bytesToBase64(new Uint8Array(input))
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
if (input instanceof Uint8Array) {
|
|
2370
|
+
return {
|
|
2371
|
+
binary: true,
|
|
2372
|
+
data_base64: bytesToBase64(input)
|
|
2373
|
+
};
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
function socketPayload(input) {
|
|
2377
|
+
if (typeof input === "string") return input;
|
|
2378
|
+
if (input instanceof ArrayBuffer) return input;
|
|
2379
|
+
if (input instanceof Uint8Array) return input;
|
|
2380
|
+
if (ArrayBuffer.isView(input)) {
|
|
2381
|
+
return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
function tunnelFrame(input) {
|
|
2385
|
+
const bytes = base64ToBytes(input.data_base64);
|
|
2386
|
+
return input.binary ? bytes : new TextDecoder().decode(bytes);
|
|
2387
|
+
}
|
|
2388
|
+
function concatBytes(chunks) {
|
|
2389
|
+
const length = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
2390
|
+
const result = new Uint8Array(length);
|
|
2391
|
+
let offset = 0;
|
|
2392
|
+
for (const chunk of chunks) {
|
|
2393
|
+
result.set(chunk, offset);
|
|
2394
|
+
offset += chunk.byteLength;
|
|
2395
|
+
}
|
|
2396
|
+
return result;
|
|
2397
|
+
}
|
|
2398
|
+
function responseMustNotHaveBody(status) {
|
|
2399
|
+
return status === 204 || status === 205 || status === 304;
|
|
2400
|
+
}
|
|
2401
|
+
async function readRequestBodyCapped(request, maxBytes) {
|
|
2402
|
+
if (!request.body) return new Uint8Array(0);
|
|
2403
|
+
const reader = request.body.getReader();
|
|
2404
|
+
const chunks = [];
|
|
2405
|
+
let total = 0;
|
|
2406
|
+
while (true) {
|
|
2407
|
+
const { done, value } = await reader.read();
|
|
2408
|
+
if (done) break;
|
|
2409
|
+
total += value.byteLength;
|
|
2410
|
+
if (total > maxBytes) {
|
|
2411
|
+
await reader.cancel().catch(() => {
|
|
2412
|
+
});
|
|
2413
|
+
return "too_large";
|
|
2414
|
+
}
|
|
2415
|
+
chunks.push(value);
|
|
2416
|
+
}
|
|
2417
|
+
return concatBytes(chunks);
|
|
2418
|
+
}
|
|
2419
|
+
function safeCloseCode2(code, fallback = 1011) {
|
|
2420
|
+
if (code === void 0) return fallback;
|
|
2421
|
+
if (code >= 1e3 && code <= 4999 && ![1004, 1005, 1006].includes(code)) return code;
|
|
2422
|
+
return fallback;
|
|
2423
|
+
}
|
|
2424
|
+
function workspaceRelayDurableObjectRoomName(workspaceId) {
|
|
2425
|
+
return `workspace:${workspaceId}`;
|
|
2426
|
+
}
|
|
2427
|
+
function workspaceRelayDurableObjectWorkspaceId(request) {
|
|
2428
|
+
const url = new URL(request.url);
|
|
2429
|
+
const workspaceId = clean(workspaceIdFromRelayPath(url.pathname));
|
|
2430
|
+
if (workspaceId) return decodeURIComponent(workspaceId);
|
|
2431
|
+
if (!hostIdFromTunnelPath2(url.pathname)) return;
|
|
2432
|
+
return clean(url.searchParams.get("workspaceId")) ?? clean(url.searchParams.get("workspace_id"));
|
|
2433
|
+
}
|
|
2434
|
+
function namespaceFromEnv(env, options) {
|
|
2435
|
+
if (options.namespace) return options.namespace;
|
|
2436
|
+
const binding = env[options.bindingName ?? "WORKSPACE_RELAY_ROOM"];
|
|
2437
|
+
return binding && typeof binding === "object" ? binding : void 0;
|
|
2438
|
+
}
|
|
2439
|
+
function json(code, message, status) {
|
|
2440
|
+
return Response.json(errorBody(code, message), { status });
|
|
2441
|
+
}
|
|
2442
|
+
var configuredAppOriginAllowed = () => false;
|
|
2443
|
+
function setWorkspaceRelayAppOrigins(raw) {
|
|
2444
|
+
const entries = (raw ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
2445
|
+
const exact = /* @__PURE__ */ new Set();
|
|
2446
|
+
const suffixes = [];
|
|
2447
|
+
for (const entry of entries) {
|
|
2448
|
+
if (entry.startsWith("https://*.")) suffixes.push(entry.slice("https://*".length));
|
|
2449
|
+
else exact.add(entry);
|
|
2450
|
+
}
|
|
2451
|
+
configuredAppOriginAllowed = (origin) => {
|
|
2452
|
+
if (exact.has(origin)) return true;
|
|
2453
|
+
if (!origin.startsWith("https://")) return false;
|
|
2454
|
+
return suffixes.some((suffix) => origin.endsWith(suffix) && origin.length > "https://".length + suffix.length);
|
|
2455
|
+
};
|
|
2456
|
+
}
|
|
2457
|
+
function allowedCorsOrigin2(origin) {
|
|
2458
|
+
if (!origin) return;
|
|
2459
|
+
if (origin.startsWith("http://localhost:")) return origin;
|
|
2460
|
+
if (origin.startsWith("http://127.0.0.1:")) return origin;
|
|
2461
|
+
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
|
|
2462
|
+
if (/^https:\/\/([a-z0-9-]+\.)*claxedo\.com$/.test(origin)) return origin;
|
|
2463
|
+
if (configuredAppOriginAllowed(origin)) return origin;
|
|
2464
|
+
}
|
|
2465
|
+
function corsHeaders(request) {
|
|
2466
|
+
const origin = allowedCorsOrigin2(request.headers.get("origin"));
|
|
2467
|
+
if (!origin) return;
|
|
2468
|
+
return {
|
|
2469
|
+
"access-control-allow-origin": origin,
|
|
2470
|
+
"access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
2471
|
+
"access-control-allow-headers": "Accept, Authorization, Content-Type, Last-Event-ID, X-Fetch-Bypass-Throttle, X-Daytona-Skip-Preview-Warning, X-Workspace-Id, X-OpenCode-Directory, X-Claxedo-Runner, X-Claxedo-Model, X-Claxedo-Draft-Id, X-Claxedo-Binary",
|
|
2472
|
+
"access-control-max-age": "86400",
|
|
2473
|
+
vary: "Origin"
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
function corsPreflight(request) {
|
|
2477
|
+
const headers2 = corsHeaders(request);
|
|
2478
|
+
return new Response(null, { status: headers2 ? 204 : 403, ...headers2 ? { headers: headers2 } : {} });
|
|
2479
|
+
}
|
|
2480
|
+
function withCors(request, response) {
|
|
2481
|
+
const headers2 = corsHeaders(request);
|
|
2482
|
+
if (!headers2) return response;
|
|
2483
|
+
if (response.status === 101 || response.webSocket) return response;
|
|
2484
|
+
const next = new Headers(response.headers);
|
|
2485
|
+
for (const [key, value] of Object.entries(headers2)) next.set(key, value);
|
|
2486
|
+
return new Response(response.body, {
|
|
2487
|
+
status: response.status,
|
|
2488
|
+
statusText: response.statusText,
|
|
2489
|
+
headers: next
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
function defaultWebSocketPair() {
|
|
2493
|
+
const pair = new globalThis.WebSocketPair();
|
|
2494
|
+
return {
|
|
2495
|
+
client: pair[0],
|
|
2496
|
+
server: pair[1]
|
|
2497
|
+
};
|
|
2498
|
+
}
|
|
2499
|
+
function selectedSubprotocol(request) {
|
|
2500
|
+
const offered = request.headers.get("sec-websocket-protocol")?.split(",").map((item) => item.trim()).filter(Boolean);
|
|
2501
|
+
if (!offered?.length) return;
|
|
2502
|
+
return offered.find((item) => item.startsWith("claxedo-rat.")) ?? offered[0];
|
|
2503
|
+
}
|
|
2504
|
+
function upgradeResponse(socket, request) {
|
|
2505
|
+
const protocol = selectedSubprotocol(request);
|
|
2506
|
+
return new Response(null, {
|
|
2507
|
+
status: 101,
|
|
2508
|
+
...protocol ? { headers: { "sec-websocket-protocol": protocol } } : {},
|
|
2509
|
+
webSocket: socket
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
async function defaultConnectWebSocket(url, init) {
|
|
2513
|
+
const res = await fetch(url, {
|
|
2514
|
+
headers: {
|
|
2515
|
+
...init.headers,
|
|
2516
|
+
Upgrade: "websocket"
|
|
2517
|
+
}
|
|
2518
|
+
});
|
|
2519
|
+
const socket = res.webSocket;
|
|
2520
|
+
if (!socket) throw new Error("Upstream WebSocket upgrade was not accepted");
|
|
2521
|
+
socket.accept?.();
|
|
2522
|
+
return socket;
|
|
2523
|
+
}
|
|
2524
|
+
function createWorkspaceRelayDurableObjectGateway(options = {}) {
|
|
2525
|
+
return {
|
|
2526
|
+
async fetch(request, env = {}) {
|
|
2527
|
+
const gatewayStartedAt = performance.now();
|
|
2528
|
+
const gatewayTraceId = clean(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
|
|
2529
|
+
const url = new URL(request.url);
|
|
2530
|
+
if (request.method === "OPTIONS" && (url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/"))) {
|
|
2531
|
+
return corsPreflight(request);
|
|
2532
|
+
}
|
|
2533
|
+
if (url.pathname === "/health") {
|
|
2534
|
+
return Response.json({ ok: true, service: "workspace-relay", mode: "cloudflare-durable-object" });
|
|
2535
|
+
}
|
|
2536
|
+
const relayLocationHint = typeof env.CLAXEDO_RELAY_LOCATION_HINT === "string" && env.CLAXEDO_RELAY_LOCATION_HINT ? env.CLAXEDO_RELAY_LOCATION_HINT : DEFAULT_RELAY_LOCATION_HINT;
|
|
2537
|
+
const namespace = namespaceFromEnv(env, options);
|
|
2538
|
+
if (!namespace) {
|
|
2539
|
+
return json(
|
|
2540
|
+
"relay_durable_object_unconfigured",
|
|
2541
|
+
"Workspace Relay Durable Object binding is not configured",
|
|
2542
|
+
503
|
|
2543
|
+
);
|
|
2544
|
+
}
|
|
2545
|
+
if (url.pathname.startsWith("/host-tunnels/") && workspaceIdsFromSearch(url).length > 1) {
|
|
2546
|
+
return json(
|
|
2547
|
+
"host_tunnel_single_workspace_required",
|
|
2548
|
+
"Durable Object relay rooms are per-workspace; host tunnels must register exactly one workspaceId per connection",
|
|
2549
|
+
400
|
|
2550
|
+
);
|
|
2551
|
+
}
|
|
2552
|
+
const workspaceId = workspaceRelayDurableObjectWorkspaceId(request);
|
|
2553
|
+
if (!workspaceId) {
|
|
2554
|
+
return json(
|
|
2555
|
+
"relay_workspace_required",
|
|
2556
|
+
"Workspace Relay requests must include a workspace id",
|
|
2557
|
+
url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/") ? 400 : 404
|
|
2558
|
+
);
|
|
2559
|
+
}
|
|
2560
|
+
const routeFinishedAt = performance.now();
|
|
2561
|
+
const response = await namespace.get(namespace.idFromName(workspaceRelayDurableObjectRoomName(workspaceId)), {
|
|
2562
|
+
locationHint: relayLocationHint
|
|
2563
|
+
}).fetch(tracedRequest(request, {
|
|
2564
|
+
id: gatewayTraceId,
|
|
2565
|
+
sampled: request.headers.get("x-claxedo-trace-sampled") === "1",
|
|
2566
|
+
startedAt: gatewayStartedAt,
|
|
2567
|
+
routeKind: "gateway",
|
|
2568
|
+
phases: []
|
|
2569
|
+
}));
|
|
2570
|
+
const fetchFinishedAt = performance.now();
|
|
2571
|
+
if (response.status === 101 || response.webSocket) {
|
|
2572
|
+
return url.pathname.startsWith("/workspaces/") ? withCors(request, response) : response;
|
|
2573
|
+
}
|
|
2574
|
+
const headers2 = new Headers(response.headers);
|
|
2575
|
+
headers2.set(TRACE_ID_HEADER, headers2.get(TRACE_ID_HEADER) ?? gatewayTraceId);
|
|
2576
|
+
headers2.set("x-claxedo-relay-location-hint", relayLocationHint);
|
|
2577
|
+
const gatewayResponseStartedAt = performance.now();
|
|
2578
|
+
const tracedResponse = new Response(response.body, {
|
|
2579
|
+
status: response.status,
|
|
2580
|
+
statusText: response.statusText,
|
|
2581
|
+
headers: headers2
|
|
2582
|
+
});
|
|
2583
|
+
const gatewayFinishedAt = performance.now();
|
|
2584
|
+
const gatewayTiming = [
|
|
2585
|
+
`relay-gateway-route;dur=${roundedMs3(routeFinishedAt - gatewayStartedAt)}`,
|
|
2586
|
+
`relay-gateway-do-fetch;dur=${roundedMs3(fetchFinishedAt - routeFinishedAt)}`,
|
|
2587
|
+
`relay-gateway-response;dur=${roundedMs3(gatewayFinishedAt - gatewayResponseStartedAt)}`,
|
|
2588
|
+
`relay-gateway;dur=${roundedMs3(gatewayFinishedAt - gatewayStartedAt)}`
|
|
2589
|
+
].join(", ");
|
|
2590
|
+
tracedResponse.headers.set(
|
|
2591
|
+
"server-timing",
|
|
2592
|
+
tracedResponse.headers.get("server-timing") ? `${tracedResponse.headers.get("server-timing")}, ${gatewayTiming}` : gatewayTiming
|
|
2593
|
+
);
|
|
2594
|
+
return url.pathname.startsWith("/workspaces/") ? withCors(request, tracedResponse) : tracedResponse;
|
|
2595
|
+
}
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
function createWorkspaceRelayDurableObjectRoom(options) {
|
|
2599
|
+
const directory = options.directory ?? createWorkspaceRelayDirectory({ sweepIntervalMs: 0 });
|
|
2600
|
+
const relayOptions = { ...options, directory };
|
|
2601
|
+
const hostTunnels = /* @__PURE__ */ new Map();
|
|
2602
|
+
const clients = /* @__PURE__ */ new Set();
|
|
2603
|
+
const pairFactory = options.createWebSocketPair ?? defaultWebSocketPair;
|
|
2604
|
+
const connectWebSocket = options.connectWebSocket ?? defaultConnectWebSocket;
|
|
2605
|
+
const hibernation = options.hibernation;
|
|
2606
|
+
let draining = false;
|
|
2607
|
+
let hibernatedSocketsRebuilt = false;
|
|
2608
|
+
const socketAttachment = (socket) => {
|
|
2609
|
+
try {
|
|
2610
|
+
return socket.deserializeAttachment?.();
|
|
2611
|
+
} catch {
|
|
2612
|
+
return void 0;
|
|
2613
|
+
}
|
|
2614
|
+
};
|
|
2615
|
+
const acceptSocket = (attachment) => {
|
|
2616
|
+
const pair = pairFactory();
|
|
2617
|
+
if (hibernation && attachment) {
|
|
2618
|
+
hibernation.acceptWebSocket(pair.server);
|
|
2619
|
+
pair.server.serializeAttachment?.(attachment);
|
|
2620
|
+
} else {
|
|
2621
|
+
pair.server.accept?.();
|
|
2622
|
+
}
|
|
2623
|
+
return pair;
|
|
2624
|
+
};
|
|
2625
|
+
const closeSocket = (socket, code, reason) => {
|
|
2626
|
+
try {
|
|
2627
|
+
socket.close?.(code, reason);
|
|
2628
|
+
} catch {
|
|
2629
|
+
}
|
|
2630
|
+
};
|
|
2631
|
+
const sendSocket = (socket, message) => {
|
|
2632
|
+
try {
|
|
2633
|
+
socket.send?.(message);
|
|
2634
|
+
return true;
|
|
2635
|
+
} catch {
|
|
2636
|
+
return false;
|
|
2637
|
+
}
|
|
2638
|
+
};
|
|
2639
|
+
const cleanupTunnelResources = (tunnel, reason) => {
|
|
2640
|
+
for (const pending of tunnel.pending.values()) {
|
|
2641
|
+
clearTimeout(pending.timeout);
|
|
2642
|
+
if (pending.stream?.slowConsumerTimeout) clearTimeout(pending.stream.slowConsumerTimeout);
|
|
2643
|
+
const error = new Error(reason);
|
|
2644
|
+
if (pending.stream && !pending.stream.closed) {
|
|
2645
|
+
pending.stream.closed = true;
|
|
2646
|
+
pending.stream.controller.error(error);
|
|
2647
|
+
} else {
|
|
2648
|
+
pending.reject(error);
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
tunnel.pending.clear();
|
|
2652
|
+
for (const client of tunnel.channels.values()) {
|
|
2653
|
+
clients.delete(client);
|
|
2654
|
+
closeSocket(client.socket, 1011, reason);
|
|
2655
|
+
}
|
|
2656
|
+
tunnel.channels.clear();
|
|
2657
|
+
};
|
|
2658
|
+
const rebuildHibernatedSockets = () => {
|
|
2659
|
+
if (!hibernation) return;
|
|
2660
|
+
hibernatedSocketsRebuilt = true;
|
|
2661
|
+
hostTunnels.clear();
|
|
2662
|
+
clients.clear();
|
|
2663
|
+
const clientRows = [];
|
|
2664
|
+
for (const socket of hibernation.getWebSockets()) {
|
|
2665
|
+
const attachment = socketAttachment(socket);
|
|
2666
|
+
if (!attachment) continue;
|
|
2667
|
+
if (attachment.kind === "host-tunnel") {
|
|
2668
|
+
hostTunnels.set(attachment.hostId, {
|
|
2669
|
+
hostId: attachment.hostId,
|
|
2670
|
+
workspaceIds: attachment.workspaceIds,
|
|
2671
|
+
socket,
|
|
2672
|
+
pending: /* @__PURE__ */ new Map(),
|
|
2673
|
+
channels: /* @__PURE__ */ new Map()
|
|
2674
|
+
});
|
|
2675
|
+
directory.registerHostTunnel({
|
|
2676
|
+
hostId: attachment.hostId,
|
|
2677
|
+
workspaceIds: attachment.workspaceIds
|
|
2678
|
+
});
|
|
2679
|
+
continue;
|
|
2680
|
+
}
|
|
2681
|
+
clientRows.push({ socket, attachment });
|
|
2682
|
+
}
|
|
2683
|
+
for (const row of clientRows) {
|
|
2684
|
+
const tunnel = hostTunnels.get(row.attachment.target.hostId);
|
|
2685
|
+
if (!tunnel) {
|
|
2686
|
+
closeSocket(row.socket, 1011, "User-hosted tunnel unavailable");
|
|
2687
|
+
continue;
|
|
2688
|
+
}
|
|
2689
|
+
const client = {
|
|
2690
|
+
request: {
|
|
2691
|
+
claims: row.attachment.claims,
|
|
2692
|
+
target: row.attachment.target,
|
|
2693
|
+
path: row.attachment.path,
|
|
2694
|
+
relayHostToken: ""
|
|
2695
|
+
},
|
|
2696
|
+
socket: row.socket,
|
|
2697
|
+
channelId: row.attachment.channelId
|
|
2698
|
+
};
|
|
2699
|
+
clients.add(client);
|
|
2700
|
+
tunnel.channels.set(row.attachment.channelId, client);
|
|
2701
|
+
}
|
|
2702
|
+
};
|
|
2703
|
+
const ensureHibernatedSocketsRebuilt = () => {
|
|
2704
|
+
if (!hibernation || hibernatedSocketsRebuilt) return;
|
|
2705
|
+
rebuildHibernatedSockets();
|
|
2706
|
+
};
|
|
2707
|
+
const watchRuntimeAccessToken = (client, onInactive) => {
|
|
2708
|
+
const intervalMs = options.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
|
|
2709
|
+
if (intervalMs <= 0) return;
|
|
2710
|
+
const now = options.now ?? Date.now;
|
|
2711
|
+
const timer = setInterval(() => {
|
|
2712
|
+
if (client.request.claims.exp * 1e3 <= now()) {
|
|
2713
|
+
clearInterval(timer);
|
|
2714
|
+
onInactive("Runtime Access Token expired");
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2717
|
+
if (!options.isRuntimeAccessTokenActive) return;
|
|
2718
|
+
void Promise.resolve(options.isRuntimeAccessTokenActive(client.request.claims)).then((active) => {
|
|
2719
|
+
if (active.active) return;
|
|
2720
|
+
clearInterval(timer);
|
|
2721
|
+
onInactive(active.reason);
|
|
2722
|
+
}).catch(() => {
|
|
2723
|
+
clearInterval(timer);
|
|
2724
|
+
onInactive("Runtime Access Token active check failed");
|
|
2725
|
+
});
|
|
2726
|
+
}, intervalMs);
|
|
2727
|
+
if (typeof timer.unref === "function") {
|
|
2728
|
+
timer.unref();
|
|
2729
|
+
}
|
|
2730
|
+
return timer;
|
|
2731
|
+
};
|
|
2732
|
+
const watchUserHostedTarget = (client, onInactive) => {
|
|
2733
|
+
const intervalMs = options.workspaceTargetActiveCheckIntervalMs ?? WORKSPACE_TARGET_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
|
|
2734
|
+
if (intervalMs <= 0) return;
|
|
2735
|
+
const target = client.request.target;
|
|
2736
|
+
const timer = setInterval(() => {
|
|
2737
|
+
void Promise.resolve(options.resolveTarget(client.request.claims)).then((activeTarget) => {
|
|
2738
|
+
if (activeTarget?.access === "user-hosted" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
clearInterval(timer);
|
|
2742
|
+
onInactive("User-hosted workspace is offline");
|
|
2743
|
+
}).catch(() => {
|
|
2744
|
+
clearInterval(timer);
|
|
2745
|
+
onInactive("Workspace relay target active check failed");
|
|
2746
|
+
});
|
|
2747
|
+
}, intervalMs);
|
|
2748
|
+
if (typeof timer.unref === "function") {
|
|
2749
|
+
timer.unref();
|
|
2750
|
+
}
|
|
2751
|
+
return timer;
|
|
2752
|
+
};
|
|
2753
|
+
const clearSlowConsumerTimer = (pending) => {
|
|
2754
|
+
if (!pending.stream?.slowConsumerTimeout) return;
|
|
2755
|
+
clearTimeout(pending.stream.slowConsumerTimeout);
|
|
2756
|
+
pending.stream.slowConsumerTimeout = void 0;
|
|
2757
|
+
};
|
|
2758
|
+
const sendHttpResponseFlow = (tunnel, requestId, pending, paused, reason) => {
|
|
2759
|
+
if (!pending.stream || pending.stream.flowPaused === paused) return;
|
|
2760
|
+
if (!paused && pending.stream.flowPaused === void 0) {
|
|
2761
|
+
pending.stream.flowPaused = false;
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
pending.stream.flowPaused = paused;
|
|
2765
|
+
tunnel.socket.send?.(JSON.stringify({
|
|
2766
|
+
type: "http.response.flow",
|
|
2767
|
+
protocol: TUNNEL_PROTOCOL_VERSION2,
|
|
2768
|
+
request_id: requestId,
|
|
2769
|
+
paused,
|
|
2770
|
+
reason
|
|
2771
|
+
}));
|
|
2772
|
+
};
|
|
2773
|
+
const failSlowConsumer = (tunnel, requestId, pending) => {
|
|
2774
|
+
sendHttpResponseFlow(tunnel, requestId, pending, false, "closed");
|
|
2775
|
+
tunnel.pending.delete(requestId);
|
|
2776
|
+
clearTimeout(pending.timeout);
|
|
2777
|
+
clearSlowConsumerTimer(pending);
|
|
2778
|
+
pending.chunks.length = 0;
|
|
2779
|
+
if (!pending.stream || pending.stream.closed) return;
|
|
2780
|
+
pending.stream.bytesQueued = 0;
|
|
2781
|
+
pending.stream.closed = true;
|
|
2782
|
+
pending.stream.controller.error(new Error("slow_consumer_timeout: downstream consumer did not drain in time"));
|
|
2783
|
+
};
|
|
2784
|
+
const armSlowConsumerTimer = (tunnel, requestId, pending) => {
|
|
2785
|
+
if (pending.stream?.slowConsumerTimeout) return;
|
|
2786
|
+
pending.stream.slowConsumerTimeout = setTimeout(
|
|
2787
|
+
() => failSlowConsumer(tunnel, requestId, pending),
|
|
2788
|
+
options.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT2
|
|
2789
|
+
);
|
|
2790
|
+
};
|
|
2791
|
+
const drainPendingStreamChunks = (tunnel, requestId, pending) => {
|
|
2792
|
+
if (!pending.stream || pending.stream.closed) return;
|
|
2793
|
+
while (pending.chunks.length > 0) {
|
|
2794
|
+
const desired = pending.stream.controller.desiredSize;
|
|
2795
|
+
if (desired !== null && desired <= 0) {
|
|
2796
|
+
sendHttpResponseFlow(tunnel, requestId, pending, true, "slow_consumer");
|
|
2797
|
+
armSlowConsumerTimer(tunnel, requestId, pending);
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
const chunk = pending.chunks.shift();
|
|
2801
|
+
pending.stream.bytesQueued -= chunk.byteLength;
|
|
2802
|
+
try {
|
|
2803
|
+
pending.stream.controller.enqueue(chunk);
|
|
2804
|
+
} catch {
|
|
2805
|
+
pending.chunks.length = 0;
|
|
2806
|
+
pending.stream.bytesQueued = 0;
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
clearSlowConsumerTimer(pending);
|
|
2811
|
+
sendHttpResponseFlow(tunnel, requestId, pending, false, "drained");
|
|
2812
|
+
};
|
|
2813
|
+
const enqueuePendingStreamChunk = (tunnel, requestId, pending, chunk) => {
|
|
2814
|
+
if (!pending.stream || pending.stream.closed) {
|
|
2815
|
+
pending.chunks.push(chunk);
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
const desired = pending.stream.controller.desiredSize;
|
|
2819
|
+
if (desired !== null && desired <= 0 || pending.chunks.length > 0) {
|
|
2820
|
+
pending.chunks.push(chunk);
|
|
2821
|
+
pending.stream.bytesQueued += chunk.byteLength;
|
|
2822
|
+
sendHttpResponseFlow(tunnel, requestId, pending, true, "slow_consumer");
|
|
2823
|
+
armSlowConsumerTimer(tunnel, requestId, pending);
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
try {
|
|
2827
|
+
pending.stream.controller.enqueue(chunk);
|
|
2828
|
+
} catch {
|
|
2829
|
+
pending.stream.closed = true;
|
|
2830
|
+
}
|
|
2831
|
+
};
|
|
2832
|
+
let startedSeqCounter = 0;
|
|
2833
|
+
const evictOldestStartedStream = (tunnel, keepRequestId) => {
|
|
2834
|
+
const cap = options.tunnelStartedStreamCap ?? TUNNEL_STARTED_STREAM_CAP_DEFAULT;
|
|
2835
|
+
while (true) {
|
|
2836
|
+
const started = [...tunnel.pending.entries()].filter(([id, entry]) => entry.startedSeq !== void 0 && id !== keepRequestId).sort((a, b) => (a[1].startedSeq ?? 0) - (b[1].startedSeq ?? 0));
|
|
2837
|
+
if (started.length + 1 <= cap) return;
|
|
2838
|
+
const [oldestId, oldest] = started[0];
|
|
2839
|
+
failPendingTunnelResponse(tunnel, oldestId, oldest, new Error("stream_evicted: tunnel started-stream cap reached"));
|
|
2840
|
+
}
|
|
2841
|
+
};
|
|
2842
|
+
const resolvePendingTunnelStream = (tunnel, requestId, pending, status, headers2) => {
|
|
2843
|
+
if (pending.stream) return;
|
|
2844
|
+
pending.startedSeq = ++startedSeqCounter;
|
|
2845
|
+
evictOldestStartedStream(tunnel, requestId);
|
|
2846
|
+
clearTimeout(pending.timeout);
|
|
2847
|
+
const stream = new ReadableStream({
|
|
2848
|
+
start(controller) {
|
|
2849
|
+
pending.stream = { controller, closed: false, bytesQueued: 0 };
|
|
2850
|
+
drainPendingStreamChunks(tunnel, requestId, pending);
|
|
2851
|
+
},
|
|
2852
|
+
pull() {
|
|
2853
|
+
drainPendingStreamChunks(tunnel, requestId, pending);
|
|
2854
|
+
},
|
|
2855
|
+
cancel() {
|
|
2856
|
+
tunnel.pending.delete(requestId);
|
|
2857
|
+
clearTimeout(pending.timeout);
|
|
2858
|
+
clearSlowConsumerTimer(pending);
|
|
2859
|
+
pending.chunks.length = 0;
|
|
2860
|
+
if (pending.stream) {
|
|
2861
|
+
pending.stream.closed = true;
|
|
2862
|
+
pending.stream.bytesQueued = 0;
|
|
2863
|
+
}
|
|
2864
|
+
tunnel.socket.send?.(JSON.stringify({
|
|
2865
|
+
type: "http.response.flow",
|
|
2866
|
+
protocol: TUNNEL_PROTOCOL_VERSION2,
|
|
2867
|
+
request_id: requestId,
|
|
2868
|
+
paused: false,
|
|
2869
|
+
reason: "closed"
|
|
2870
|
+
}));
|
|
2871
|
+
}
|
|
2872
|
+
}, new ByteLengthQueuingStrategy({
|
|
2873
|
+
highWaterMark: options.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT2
|
|
2874
|
+
}));
|
|
2875
|
+
pending.resolve(new Response(stream, {
|
|
2876
|
+
status,
|
|
2877
|
+
headers: headers2
|
|
2878
|
+
}));
|
|
2879
|
+
};
|
|
2880
|
+
const failPendingTunnelResponse = (tunnel, requestId, pending, error) => {
|
|
2881
|
+
tunnel.pending.delete(requestId);
|
|
2882
|
+
clearTimeout(pending.timeout);
|
|
2883
|
+
clearSlowConsumerTimer(pending);
|
|
2884
|
+
sendHttpResponseFlow(tunnel, requestId, pending, false, "closed");
|
|
2885
|
+
if (pending.stream && !pending.stream.closed) {
|
|
2886
|
+
pending.stream.closed = true;
|
|
2887
|
+
pending.stream.controller.error(error);
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
pending.reject(error);
|
|
2891
|
+
};
|
|
2892
|
+
const pendingCount = () => [...hostTunnels.values()].reduce((sum, tunnel) => sum + tunnel.pending.size, 0);
|
|
2893
|
+
const closeActiveSocketsForDrain = () => {
|
|
2894
|
+
for (const tunnel of hostTunnels.values()) closeSocket(tunnel.socket, 1012, "Workspace relay is draining");
|
|
2895
|
+
for (const client of clients) closeSocket(client.socket, 1012, "Workspace relay is draining");
|
|
2896
|
+
};
|
|
2897
|
+
const drainController = {
|
|
2898
|
+
isDraining: () => draining,
|
|
2899
|
+
setDraining(value) {
|
|
2900
|
+
draining = value;
|
|
2901
|
+
if (draining) closeActiveSocketsForDrain();
|
|
2902
|
+
},
|
|
2903
|
+
pendingCount,
|
|
2904
|
+
async waitForDrain(timeoutMs) {
|
|
2905
|
+
const deadline = Date.now() + timeoutMs;
|
|
2906
|
+
while (true) {
|
|
2907
|
+
const remaining = pendingCount();
|
|
2908
|
+
if (remaining === 0) return { drained: true, remaining: 0 };
|
|
2909
|
+
if (Date.now() >= deadline) return { drained: false, remaining };
|
|
2910
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
};
|
|
2914
|
+
const runtimeAccessTokenActive = async (client) => {
|
|
2915
|
+
const now = options.now ?? Date.now;
|
|
2916
|
+
if (client.request.claims.exp * 1e3 <= now()) {
|
|
2917
|
+
return { active: false, reason: "Runtime Access Token expired" };
|
|
2918
|
+
}
|
|
2919
|
+
if (!options.isRuntimeAccessTokenActive) return { active: true };
|
|
2920
|
+
const active = await options.isRuntimeAccessTokenActive(client.request.claims);
|
|
2921
|
+
return active.active ? { active: true } : { active: false, reason: active.reason };
|
|
2922
|
+
};
|
|
2923
|
+
const userHostedTargetActive = async (client) => {
|
|
2924
|
+
const target = client.request.target;
|
|
2925
|
+
const activeTarget = await options.resolveTarget(client.request.claims);
|
|
2926
|
+
return !!(activeTarget?.access === "user-hosted" && activeTarget.workspaceId === target.workspaceId && activeTarget.hostId === target.hostId && directory.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId }));
|
|
2927
|
+
};
|
|
2928
|
+
const activeUserHostedClient = (channelId, socket) => {
|
|
2929
|
+
for (const tunnel of hostTunnels.values()) {
|
|
2930
|
+
const client = tunnel.channels.get(channelId);
|
|
2931
|
+
if (client?.socket === socket) return { tunnel, client };
|
|
2932
|
+
}
|
|
2933
|
+
};
|
|
2934
|
+
const handleUserHostedClientMessage = async (channelId, socket, data) => {
|
|
2935
|
+
const active = activeUserHostedClient(channelId, socket);
|
|
2936
|
+
if (!active) return;
|
|
2937
|
+
const token = await runtimeAccessTokenActive(active.client);
|
|
2938
|
+
if (!token.active) {
|
|
2939
|
+
closeSocket(socket, 1008, token.reason);
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
if (!await userHostedTargetActive(active.client)) {
|
|
2943
|
+
closeSocket(socket, 1011, "User-hosted workspace is offline");
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2946
|
+
const frame = socketFrame(data);
|
|
2947
|
+
if (!frame) return;
|
|
2948
|
+
const sent = sendSocket(active.tunnel.socket, JSON.stringify({
|
|
2949
|
+
type: "ws.frame",
|
|
2950
|
+
protocol: TUNNEL_PROTOCOL_VERSION2,
|
|
2951
|
+
channel_id: channelId,
|
|
2952
|
+
...frame
|
|
2953
|
+
}));
|
|
2954
|
+
if (!sent) closeSocket(socket, 1011, "User-hosted tunnel unavailable");
|
|
2955
|
+
};
|
|
2956
|
+
const handleTunnelMessage = async (hostId, event) => {
|
|
2957
|
+
const row = parseTunnelMessageData(event.data);
|
|
2958
|
+
if (!row.ok) return;
|
|
2959
|
+
const parsed = validateTunnelMessage2(row.value);
|
|
2960
|
+
const tunnel = hostTunnels.get(hostId);
|
|
2961
|
+
if (!parsed.ok || !tunnel) return;
|
|
2962
|
+
if (parsed.message.type === "ping") {
|
|
2963
|
+
directory.recordPong(tunnel.hostId);
|
|
2964
|
+
tunnel.socket.send?.(JSON.stringify(makeTunnelPong2(parsed.message)));
|
|
2965
|
+
return;
|
|
2966
|
+
}
|
|
2967
|
+
if (parsed.message.type === "error" && parsed.message.request_id) {
|
|
2968
|
+
const pending = tunnel.pending.get(parsed.message.request_id);
|
|
2969
|
+
if (!pending) return;
|
|
2970
|
+
failPendingTunnelResponse(tunnel, parsed.message.request_id, pending, new Error(parsed.message.message));
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
if (parsed.message.type === "http.response.start") {
|
|
2974
|
+
const message = parsed.message;
|
|
2975
|
+
const pending = tunnel.pending.get(message.request_id);
|
|
2976
|
+
if (!pending) return;
|
|
2977
|
+
pending.status = message.status;
|
|
2978
|
+
pending.headers = headersFromRecord(message.headers);
|
|
2979
|
+
if (responseMustNotHaveBody(pending.status)) return;
|
|
2980
|
+
resolvePendingTunnelStream(tunnel, message.request_id, pending, pending.status, pending.headers);
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
2983
|
+
if (parsed.message.type === "http.response.chunk") {
|
|
2984
|
+
const message = parsed.message;
|
|
2985
|
+
const pending = tunnel.pending.get(message.request_id);
|
|
2986
|
+
if (!pending) return;
|
|
2987
|
+
const chunk = base64ToBytes(message.body_base64);
|
|
2988
|
+
if (pending.bytes + chunk.byteLength > (options.tunnelResponseBodyMaxBytes ?? TUNNEL_RESPONSE_BODY_MAX_BYTES_DEFAULT)) {
|
|
2989
|
+
failPendingTunnelResponse(tunnel, message.request_id, pending, new Error("User-hosted response body exceeds the relay limit"));
|
|
2990
|
+
return;
|
|
2991
|
+
}
|
|
2992
|
+
pending.bytes += chunk.byteLength;
|
|
2993
|
+
enqueuePendingStreamChunk(tunnel, message.request_id, pending, chunk);
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
if (parsed.message.type === "http.response.end") {
|
|
2997
|
+
const message = parsed.message;
|
|
2998
|
+
const pending = tunnel.pending.get(message.request_id);
|
|
2999
|
+
if (!pending) return;
|
|
3000
|
+
tunnel.pending.delete(message.request_id);
|
|
3001
|
+
clearTimeout(pending.timeout);
|
|
3002
|
+
clearSlowConsumerTimer(pending);
|
|
3003
|
+
sendHttpResponseFlow(tunnel, message.request_id, pending, false, "closed");
|
|
3004
|
+
if (pending.stream) {
|
|
3005
|
+
if (!pending.stream.closed) {
|
|
3006
|
+
drainPendingStreamChunks(tunnel, message.request_id, pending);
|
|
3007
|
+
while (pending.chunks.length > 0) {
|
|
3008
|
+
const chunk = pending.chunks.shift();
|
|
3009
|
+
pending.stream.bytesQueued -= chunk.byteLength;
|
|
3010
|
+
pending.stream.controller.enqueue(chunk);
|
|
3011
|
+
}
|
|
3012
|
+
pending.stream.closed = true;
|
|
3013
|
+
pending.stream.controller.close();
|
|
3014
|
+
}
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
pending.resolve(new Response(responseMustNotHaveBody(pending.status ?? 200) ? null : concatBytes(pending.chunks), {
|
|
3018
|
+
status: pending.status ?? 200,
|
|
3019
|
+
headers: pending.headers
|
|
3020
|
+
}));
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
if (parsed.message.type === "ws.frame") {
|
|
3024
|
+
const message = parsed.message;
|
|
3025
|
+
const channel = tunnel.channels.get(message.channel_id);
|
|
3026
|
+
if (!channel) return;
|
|
3027
|
+
const token = await runtimeAccessTokenActive(channel);
|
|
3028
|
+
if (!token.active) {
|
|
3029
|
+
tunnel.channels.delete(message.channel_id);
|
|
3030
|
+
clients.delete(channel);
|
|
3031
|
+
closeSocket(channel.socket, 1008, token.reason);
|
|
3032
|
+
return;
|
|
3033
|
+
}
|
|
3034
|
+
sendSocket(channel.socket, tunnelFrame(message));
|
|
3035
|
+
return;
|
|
3036
|
+
}
|
|
3037
|
+
if (parsed.message.type === "ws.close") {
|
|
3038
|
+
const message = parsed.message;
|
|
3039
|
+
const channel = tunnel.channels.get(message.channel_id);
|
|
3040
|
+
if (!channel) return;
|
|
3041
|
+
tunnel.channels.delete(message.channel_id);
|
|
3042
|
+
clients.delete(channel);
|
|
3043
|
+
closeSocket(channel.socket, safeCloseCode2(message.code), message.reason ?? "");
|
|
3044
|
+
return;
|
|
3045
|
+
}
|
|
3046
|
+
if (parsed.message.type === "error" && parsed.message.channel_id) {
|
|
3047
|
+
const message = parsed.message;
|
|
3048
|
+
const channelId = parsed.message.channel_id;
|
|
3049
|
+
const channel = tunnel.channels.get(channelId);
|
|
3050
|
+
if (!channel) return;
|
|
3051
|
+
tunnel.channels.delete(channelId);
|
|
3052
|
+
clients.delete(channel);
|
|
3053
|
+
closeSocket(channel.socket, 1011, message.message);
|
|
3054
|
+
}
|
|
3055
|
+
};
|
|
3056
|
+
const parseTunnelMessageData = (data) => {
|
|
3057
|
+
if (typeof data !== "string") return { ok: true, value: data };
|
|
3058
|
+
try {
|
|
3059
|
+
return { ok: true, value: JSON.parse(data) };
|
|
3060
|
+
} catch {
|
|
3061
|
+
return { ok: false };
|
|
3062
|
+
}
|
|
3063
|
+
};
|
|
3064
|
+
const cleanupHostTunnel = (hostId, socket, reason) => {
|
|
3065
|
+
const tunnel = hostTunnels.get(hostId);
|
|
3066
|
+
if (tunnel?.socket !== socket) return;
|
|
3067
|
+
cleanupTunnelResources(tunnel, reason);
|
|
3068
|
+
hostTunnels.delete(hostId);
|
|
3069
|
+
directory.disconnectHost(hostId);
|
|
3070
|
+
};
|
|
3071
|
+
const cleanupUserHostedClient = (channelId, socket, event) => {
|
|
3072
|
+
for (const tunnel of hostTunnels.values()) {
|
|
3073
|
+
const active = tunnel.channels.get(channelId);
|
|
3074
|
+
if (!active || active.socket !== socket) continue;
|
|
3075
|
+
tunnel.channels.delete(channelId);
|
|
3076
|
+
clients.delete(active);
|
|
3077
|
+
sendSocket(tunnel.socket, JSON.stringify({
|
|
3078
|
+
type: "ws.close",
|
|
3079
|
+
protocol: TUNNEL_PROTOCOL_VERSION2,
|
|
3080
|
+
channel_id: channelId,
|
|
3081
|
+
code: safeCloseCode2(event?.code, 1e3),
|
|
3082
|
+
reason: event?.reason ?? "Client WebSocket closed"
|
|
3083
|
+
}));
|
|
3084
|
+
return;
|
|
3085
|
+
}
|
|
3086
|
+
};
|
|
3087
|
+
const admitHostTunnel = async (request, roomWorkspaceId, url) => {
|
|
3088
|
+
if (!websocketRequest2(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
|
|
3089
|
+
const hostId = clean(hostIdFromTunnelPath2(url.pathname));
|
|
3090
|
+
if (!hostId) return json("host_tunnel_host_required", "Host tunnel id is required", 400);
|
|
3091
|
+
const workspaceIds = workspaceIdsFromSearch(url);
|
|
3092
|
+
if (!workspaceIds.includes(roomWorkspaceId)) {
|
|
3093
|
+
return json("host_tunnel_workspace_mismatch", "Host tunnel must include the room workspace id", 403);
|
|
3094
|
+
}
|
|
3095
|
+
const token = bearerToken(request.headers.get("authorization"));
|
|
3096
|
+
if (!token) return json("host_tunnel_token_required", "Host Tunnel Token is required", 401);
|
|
3097
|
+
try {
|
|
3098
|
+
await verifyHostTunnelToken(token, options.runtimeAccessKey, { hostId, workspaceIds });
|
|
3099
|
+
} catch (err) {
|
|
3100
|
+
if (err instanceof WorkspaceRelayAuthError) {
|
|
3101
|
+
return json(err.code, "Host Tunnel Token was denied", 403);
|
|
3102
|
+
}
|
|
3103
|
+
throw err;
|
|
3104
|
+
}
|
|
3105
|
+
const pair = acceptSocket({
|
|
3106
|
+
kind: "host-tunnel",
|
|
3107
|
+
hostId,
|
|
3108
|
+
workspaceIds,
|
|
3109
|
+
connectedAt: options.now?.() ?? Date.now()
|
|
3110
|
+
});
|
|
3111
|
+
const previous = hostTunnels.get(hostId);
|
|
3112
|
+
if (previous) {
|
|
3113
|
+
cleanupTunnelResources(previous, "Host tunnel replaced");
|
|
3114
|
+
closeSocket(previous.socket, 1012, "Host tunnel replaced");
|
|
3115
|
+
}
|
|
3116
|
+
hostTunnels.set(hostId, {
|
|
3117
|
+
hostId,
|
|
3118
|
+
workspaceIds,
|
|
3119
|
+
socket: pair.server,
|
|
3120
|
+
pending: /* @__PURE__ */ new Map(),
|
|
3121
|
+
channels: /* @__PURE__ */ new Map()
|
|
3122
|
+
});
|
|
3123
|
+
directory.registerHostTunnel({ hostId, workspaceIds });
|
|
3124
|
+
if (!hibernation) {
|
|
3125
|
+
pair.server.addEventListener?.("message", (event) => {
|
|
3126
|
+
if (event && event.data !== void 0) void handleTunnelMessage(hostId, { data: event.data });
|
|
3127
|
+
});
|
|
3128
|
+
pair.server.addEventListener?.("close", () => cleanupHostTunnel(hostId, pair.server, "User-hosted tunnel disconnected"));
|
|
3129
|
+
pair.server.addEventListener?.("error", () => cleanupHostTunnel(hostId, pair.server, "User-hosted tunnel disconnected"));
|
|
3130
|
+
}
|
|
3131
|
+
return upgradeResponse(pair.client, request);
|
|
3132
|
+
};
|
|
3133
|
+
const admitClient = async (request, workspaceId) => {
|
|
3134
|
+
if (!websocketRequest2(request)) return json("websocket_upgrade_required", "Workspace Relay requires a WebSocket upgrade", 426);
|
|
3135
|
+
const authorized = await authorizeWorkspaceRelayRequest(relayOptions, request, workspaceId);
|
|
3136
|
+
if (!authorized.ok) return authorized.response;
|
|
3137
|
+
if (authorized.request.target.access !== "cloud") return admitUserHostedClient(request, authorized.request);
|
|
3138
|
+
return await admitCloudClient(request, authorized.request);
|
|
3139
|
+
};
|
|
3140
|
+
const admitCloudClient = async (request, authorized) => {
|
|
3141
|
+
let upstream;
|
|
3142
|
+
try {
|
|
3143
|
+
upstream = await connectWebSocket(
|
|
3144
|
+
workspaceRelayTargetUrl(
|
|
3145
|
+
authorized.target,
|
|
3146
|
+
authorized.path,
|
|
3147
|
+
new URL(request.url).search
|
|
3148
|
+
).toString(),
|
|
3149
|
+
{
|
|
3150
|
+
headers: headerRecord(workspaceRelayForwardHeaders(
|
|
3151
|
+
new Headers(),
|
|
3152
|
+
authorized.relayHostToken,
|
|
3153
|
+
authorized.target.workspaceId,
|
|
3154
|
+
{ userHosted: false, upstreamHeaders: authorized.target.upstreamHeaders }
|
|
3155
|
+
))
|
|
3156
|
+
}
|
|
3157
|
+
);
|
|
3158
|
+
} catch {
|
|
3159
|
+
return json("upstream_unavailable", "Workspace upstream is unavailable", 503);
|
|
3160
|
+
}
|
|
3161
|
+
const pair = acceptSocket();
|
|
3162
|
+
const client = { request: authorized, socket: pair.server };
|
|
3163
|
+
clients.add(client);
|
|
3164
|
+
let closed = false;
|
|
3165
|
+
let upstreamOpen = upstream.readyState === void 0 || upstream.readyState === 1;
|
|
3166
|
+
const queue = [];
|
|
3167
|
+
const activeTokenTimer = watchRuntimeAccessToken(client, (reason) => {
|
|
3168
|
+
closeSocket(pair.server, 1008, reason);
|
|
3169
|
+
});
|
|
3170
|
+
const cleanupClient = (event) => {
|
|
3171
|
+
if (closed) return;
|
|
3172
|
+
closed = true;
|
|
3173
|
+
if (activeTokenTimer) clearInterval(activeTokenTimer);
|
|
3174
|
+
clients.delete(client);
|
|
3175
|
+
closeSocket(upstream, safeCloseCode2(event?.code, 1e3), event?.reason ?? "Client WebSocket closed");
|
|
3176
|
+
};
|
|
3177
|
+
const cleanupUpstream = (event) => {
|
|
3178
|
+
if (closed) return;
|
|
3179
|
+
closed = true;
|
|
3180
|
+
if (activeTokenTimer) clearInterval(activeTokenTimer);
|
|
3181
|
+
clients.delete(client);
|
|
3182
|
+
closeSocket(pair.server, safeCloseCode2(event?.code), event?.reason ?? "");
|
|
3183
|
+
};
|
|
3184
|
+
const flushQueue = () => {
|
|
3185
|
+
upstreamOpen = true;
|
|
3186
|
+
for (const item of queue.splice(0)) {
|
|
3187
|
+
if (!sendSocket(upstream, item)) {
|
|
3188
|
+
closeSocket(pair.server, 1011, "Upstream WebSocket connection failed");
|
|
3189
|
+
return;
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
};
|
|
3193
|
+
pair.server.addEventListener?.("message", (event) => {
|
|
3194
|
+
const payload = socketPayload(event?.data);
|
|
3195
|
+
if (payload === void 0) return;
|
|
3196
|
+
if (!upstreamOpen) {
|
|
3197
|
+
if (queue.length >= (options.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT2)) {
|
|
3198
|
+
closeSocket(pair.server, 1011, "Upstream WebSocket queue limit exceeded");
|
|
3199
|
+
cleanupClient({ code: 1011, reason: "Upstream WebSocket queue limit exceeded" });
|
|
3200
|
+
return;
|
|
3201
|
+
}
|
|
3202
|
+
queue.push(payload);
|
|
3203
|
+
return;
|
|
3204
|
+
}
|
|
3205
|
+
if (!sendSocket(upstream, payload)) closeSocket(pair.server, 1011, "Upstream WebSocket connection failed");
|
|
3206
|
+
});
|
|
3207
|
+
pair.server.addEventListener?.("close", cleanupClient);
|
|
3208
|
+
pair.server.addEventListener?.("error", cleanupClient);
|
|
3209
|
+
upstream.addEventListener?.("open", flushQueue);
|
|
3210
|
+
upstream.addEventListener?.("message", (event) => {
|
|
3211
|
+
const payload = socketPayload(event?.data);
|
|
3212
|
+
if (payload !== void 0) sendSocket(pair.server, payload);
|
|
3213
|
+
});
|
|
3214
|
+
upstream.addEventListener?.("close", cleanupUpstream);
|
|
3215
|
+
upstream.addEventListener?.("error", () => cleanupUpstream({ code: 1011, reason: "Upstream WebSocket connection failed" }));
|
|
3216
|
+
return upgradeResponse(pair.client, request);
|
|
3217
|
+
};
|
|
3218
|
+
const admitUserHostedClient = (request, authorized) => {
|
|
3219
|
+
const tunnel = hostTunnels.get(authorized.target.hostId);
|
|
3220
|
+
if (!tunnel || !directory.activeHost({ hostId: authorized.target.hostId, workspaceId: authorized.target.workspaceId })) {
|
|
3221
|
+
return json("user_hosted_app_offline", "User-hosted workspace is offline", 503);
|
|
3222
|
+
}
|
|
3223
|
+
if (tunnel.channels.size >= (options.tunnelChannelCap ?? TUNNEL_CHANNEL_CAP_DEFAULT)) {
|
|
3224
|
+
return json("too_many_channels", "User-hosted tunnel has too many active WebSocket channels", 503);
|
|
3225
|
+
}
|
|
3226
|
+
const channelId = randomToken();
|
|
3227
|
+
const pair = acceptSocket({
|
|
3228
|
+
kind: "user-hosted-client",
|
|
3229
|
+
channelId,
|
|
3230
|
+
claims: authorized.claims,
|
|
3231
|
+
target: authorized.target,
|
|
3232
|
+
path: authorized.path,
|
|
3233
|
+
connectedAt: options.now?.() ?? Date.now()
|
|
3234
|
+
});
|
|
3235
|
+
const client = {
|
|
3236
|
+
request: authorized,
|
|
3237
|
+
socket: pair.server,
|
|
3238
|
+
channelId
|
|
3239
|
+
};
|
|
3240
|
+
clients.add(client);
|
|
3241
|
+
tunnel.channels.set(channelId, client);
|
|
3242
|
+
const activeTokenTimer = hibernation ? void 0 : watchRuntimeAccessToken(client, (reason) => {
|
|
3243
|
+
closeSocket(pair.server, 1008, reason);
|
|
3244
|
+
});
|
|
3245
|
+
const activeTargetTimer = hibernation ? void 0 : watchUserHostedTarget(client, (reason) => {
|
|
3246
|
+
closeSocket(pair.server, 1011, reason);
|
|
3247
|
+
});
|
|
3248
|
+
if (!hibernation) {
|
|
3249
|
+
const cleanup = (event) => {
|
|
3250
|
+
if (activeTokenTimer) clearInterval(activeTokenTimer);
|
|
3251
|
+
if (activeTargetTimer) clearInterval(activeTargetTimer);
|
|
3252
|
+
cleanupUserHostedClient(channelId, pair.server, event);
|
|
3253
|
+
};
|
|
3254
|
+
pair.server.addEventListener?.("message", (event) => {
|
|
3255
|
+
if (!event) return;
|
|
3256
|
+
void handleUserHostedClientMessage(channelId, pair.server, event.data);
|
|
3257
|
+
});
|
|
3258
|
+
pair.server.addEventListener?.("close", cleanup);
|
|
3259
|
+
pair.server.addEventListener?.("error", cleanup);
|
|
3260
|
+
}
|
|
3261
|
+
sendSocket(tunnel.socket, JSON.stringify({
|
|
3262
|
+
type: "ws.open",
|
|
3263
|
+
protocol: TUNNEL_PROTOCOL_VERSION2,
|
|
3264
|
+
channel_id: channelId,
|
|
3265
|
+
workspace_id: authorized.target.workspaceId,
|
|
3266
|
+
path: authorized.path,
|
|
3267
|
+
headers: headerRecord(workspaceRelayForwardHeaders(
|
|
3268
|
+
new Headers(),
|
|
3269
|
+
authorized.relayHostToken,
|
|
3270
|
+
authorized.target.workspaceId,
|
|
3271
|
+
{ userHosted: true }
|
|
3272
|
+
))
|
|
3273
|
+
}));
|
|
3274
|
+
return upgradeResponse(pair.client, request);
|
|
3275
|
+
};
|
|
3276
|
+
const forwardCloudHttp = async (request, workspaceId, trace) => {
|
|
3277
|
+
const authorized = await traceSpan(
|
|
3278
|
+
trace,
|
|
3279
|
+
"relay-auth",
|
|
3280
|
+
() => authorizeWorkspaceRelayRequest(relayOptions, request, workspaceId, {
|
|
3281
|
+
span: (name, run) => traceSpan(trace, name, run)
|
|
3282
|
+
})
|
|
3283
|
+
);
|
|
3284
|
+
if (!authorized.ok) return authorized.response;
|
|
3285
|
+
if (authorized.request.target.access !== "cloud") {
|
|
3286
|
+
trace && (trace.routeKind = "user-hosted-http");
|
|
3287
|
+
return forwardUserHostedHttp(request, authorized.request, trace);
|
|
3288
|
+
}
|
|
3289
|
+
trace && (trace.routeKind = "cloud-http");
|
|
3290
|
+
const controller = new AbortController();
|
|
3291
|
+
const timer = setTimeout(() => controller.abort(), options.forwardTimeoutMs ?? 3e4);
|
|
3292
|
+
try {
|
|
3293
|
+
const upstream = await traceSpan(trace, "upstream-fetch", () => (options.fetch ?? fetch)(
|
|
3294
|
+
workspaceRelayTargetUrl(
|
|
3295
|
+
authorized.request.target,
|
|
3296
|
+
authorized.request.path,
|
|
3297
|
+
new URL(request.url).search
|
|
3298
|
+
),
|
|
3299
|
+
workspaceRelayForwardRequestInit(request, authorized.request.relayHostToken, workspaceId, {
|
|
3300
|
+
signal: controller.signal,
|
|
3301
|
+
upstreamHeaders: authorized.request.target.upstreamHeaders
|
|
3302
|
+
})
|
|
3303
|
+
));
|
|
3304
|
+
return new Response(upstream.body, {
|
|
3305
|
+
status: upstream.status,
|
|
3306
|
+
statusText: upstream.statusText,
|
|
3307
|
+
headers: upstream.headers
|
|
3308
|
+
});
|
|
3309
|
+
} catch (err) {
|
|
3310
|
+
const aborted = err instanceof Error && err.name === "AbortError";
|
|
3311
|
+
return Response.json(
|
|
3312
|
+
errorBody(
|
|
3313
|
+
aborted ? "upstream_timeout" : "upstream_unavailable",
|
|
3314
|
+
aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable"
|
|
3315
|
+
),
|
|
3316
|
+
{ status: aborted ? 504 : 503 }
|
|
3317
|
+
);
|
|
3318
|
+
} finally {
|
|
3319
|
+
clearTimeout(timer);
|
|
3320
|
+
}
|
|
3321
|
+
};
|
|
3322
|
+
const forwardUserHostedHttp = async (request, authorized, trace) => {
|
|
3323
|
+
const tunnel = hostTunnels.get(authorized.target.hostId);
|
|
3324
|
+
if (!tunnel || !directory.activeHost({ hostId: authorized.target.hostId, workspaceId: authorized.target.workspaceId })) {
|
|
3325
|
+
return json("user_hosted_app_offline", "User-hosted workspace is offline", 503);
|
|
3326
|
+
}
|
|
3327
|
+
const awaitingStart = [...tunnel.pending.values()].filter((entry) => entry.startedSeq === void 0).length;
|
|
3328
|
+
if (awaitingStart >= (options.tunnelPendingHttpCap ?? TUNNEL_PENDING_HTTP_CAP_DEFAULT)) {
|
|
3329
|
+
return json("too_many_pending_requests", "User-hosted tunnel has too many pending HTTP requests", 503);
|
|
3330
|
+
}
|
|
3331
|
+
const requestId = randomToken();
|
|
3332
|
+
const init = workspaceRelayForwardRequestInit(request, authorized.relayHostToken, authorized.target.workspaceId, {
|
|
3333
|
+
userHosted: true
|
|
3334
|
+
});
|
|
3335
|
+
const bodyMaxBytes = options.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT2;
|
|
3336
|
+
const contentLength = Number(request.headers.get("content-length") ?? Number.NaN);
|
|
3337
|
+
if (Number.isFinite(contentLength) && contentLength > bodyMaxBytes) {
|
|
3338
|
+
return json("request_body_too_large", "User-hosted request body exceeds the relay limit", 413);
|
|
3339
|
+
}
|
|
3340
|
+
const bodyBytes = request.method === "GET" || request.method === "HEAD" ? void 0 : await traceSpan(trace, "request-body", () => readRequestBodyCapped(request, bodyMaxBytes));
|
|
3341
|
+
if (bodyBytes === "too_large") {
|
|
3342
|
+
return json("request_body_too_large", "User-hosted request body exceeds the relay limit", 413);
|
|
3343
|
+
}
|
|
3344
|
+
const body = bodyBytes ? bytesToBase64(bodyBytes) : void 0;
|
|
3345
|
+
const pending = new Promise((resolve, reject) => {
|
|
3346
|
+
const timeout = setTimeout(() => {
|
|
3347
|
+
tunnel.pending.delete(requestId);
|
|
3348
|
+
tunnel.socket.send?.(JSON.stringify({
|
|
3349
|
+
type: "http.response.flow",
|
|
3350
|
+
protocol: TUNNEL_PROTOCOL_VERSION2,
|
|
3351
|
+
request_id: requestId,
|
|
3352
|
+
paused: false,
|
|
3353
|
+
reason: "closed"
|
|
3354
|
+
}));
|
|
3355
|
+
reject(new Error("User-hosted tunnel timed out"));
|
|
3356
|
+
}, options.forwardTimeoutMs ?? 3e4);
|
|
3357
|
+
tunnel.pending.set(requestId, {
|
|
3358
|
+
chunks: [],
|
|
3359
|
+
bytes: 0,
|
|
3360
|
+
timeout,
|
|
3361
|
+
resolve,
|
|
3362
|
+
reject
|
|
3363
|
+
});
|
|
3364
|
+
});
|
|
3365
|
+
tunnel.socket.send?.(JSON.stringify({
|
|
3366
|
+
type: "http.request",
|
|
3367
|
+
protocol: TUNNEL_PROTOCOL_VERSION2,
|
|
3368
|
+
request_id: requestId,
|
|
3369
|
+
workspace_id: authorized.target.workspaceId,
|
|
3370
|
+
method: request.method,
|
|
3371
|
+
path: `${authorized.path}${new URL(request.url).search}`,
|
|
3372
|
+
headers: headerRecord(init.headers instanceof Headers ? init.headers : new Headers(init.headers)),
|
|
3373
|
+
...body ? { body_base64: body } : {},
|
|
3374
|
+
end: true
|
|
3375
|
+
}));
|
|
3376
|
+
try {
|
|
3377
|
+
return await traceSpan(trace, "tunnel-wait", () => pending);
|
|
3378
|
+
} catch (err) {
|
|
3379
|
+
const timeout = err instanceof Error && err.message.includes("timed out");
|
|
3380
|
+
const responseTooLarge = err instanceof Error && err.message.includes("response body exceeds");
|
|
3381
|
+
return Response.json(
|
|
3382
|
+
errorBody(
|
|
3383
|
+
timeout ? "user_hosted_tunnel_timeout" : responseTooLarge ? "user_hosted_response_body_too_large" : "user_hosted_tunnel_unavailable",
|
|
3384
|
+
timeout ? "User-hosted tunnel timed out" : responseTooLarge ? "User-hosted response body exceeds the relay limit" : "User-hosted tunnel is unavailable"
|
|
3385
|
+
),
|
|
3386
|
+
{ status: responseTooLarge ? 413 : 503 }
|
|
3387
|
+
);
|
|
3388
|
+
}
|
|
3389
|
+
};
|
|
3390
|
+
return {
|
|
3391
|
+
async fetch(request) {
|
|
3392
|
+
ensureHibernatedSocketsRebuilt();
|
|
3393
|
+
const url = new URL(request.url);
|
|
3394
|
+
const roomWorkspaceId = workspaceRelayDurableObjectWorkspaceId(request);
|
|
3395
|
+
if (request.method === "OPTIONS" && (url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/"))) {
|
|
3396
|
+
return corsPreflight(request);
|
|
3397
|
+
}
|
|
3398
|
+
if (url.pathname === "/health") {
|
|
3399
|
+
return Response.json({
|
|
3400
|
+
ok: !draining,
|
|
3401
|
+
service: "workspace-relay-room",
|
|
3402
|
+
mode: "cloudflare-durable-object",
|
|
3403
|
+
workspaceId: roomWorkspaceId,
|
|
3404
|
+
hostTunnelCount: hostTunnels.size,
|
|
3405
|
+
clientCount: clients.size,
|
|
3406
|
+
draining
|
|
3407
|
+
}, { status: draining ? 503 : 200 });
|
|
3408
|
+
}
|
|
3409
|
+
if (draining) return json("relay_draining", "Workspace relay is shutting down; try another instance", 503);
|
|
3410
|
+
if (!roomWorkspaceId) return json("relay_workspace_required", "Workspace Relay requests must include a workspace id", 400);
|
|
3411
|
+
if (url.pathname.startsWith("/host-tunnels/")) return admitHostTunnel(request, roomWorkspaceId, url);
|
|
3412
|
+
if (url.pathname.startsWith("/workspaces/")) {
|
|
3413
|
+
if (websocketRequest2(request)) return admitClient(request, roomWorkspaceId);
|
|
3414
|
+
const trace = traceRequest(request, "workspace-http", options);
|
|
3415
|
+
const roomTotalStartedAt = performance.now();
|
|
3416
|
+
const nextRequest = tracedRequest(request, trace);
|
|
3417
|
+
const response = await traceSpan(trace, "relay-room", () => forwardCloudHttp(nextRequest, roomWorkspaceId, trace));
|
|
3418
|
+
trace.phases.push({ name: "relay-room-total", ms: performance.now() - roomTotalStartedAt });
|
|
3419
|
+
const tracedResponse = withTraceHeaders(response, trace);
|
|
3420
|
+
logTrace(options, nextRequest, roomWorkspaceId, trace, tracedResponse);
|
|
3421
|
+
return withCors(request, tracedResponse);
|
|
3422
|
+
}
|
|
3423
|
+
return json("relay_route_not_found", "Workspace Relay route not found", 404);
|
|
3424
|
+
},
|
|
3425
|
+
async webSocketMessage(socket, message) {
|
|
3426
|
+
ensureHibernatedSocketsRebuilt();
|
|
3427
|
+
const attachment = socketAttachment(socket);
|
|
3428
|
+
if (!attachment) return;
|
|
3429
|
+
if (attachment.kind === "host-tunnel") {
|
|
3430
|
+
await handleTunnelMessage(attachment.hostId, { data: message });
|
|
3431
|
+
return;
|
|
3432
|
+
}
|
|
3433
|
+
await handleUserHostedClientMessage(attachment.channelId, socket, message);
|
|
3434
|
+
},
|
|
3435
|
+
webSocketClose(socket, code, reason) {
|
|
3436
|
+
ensureHibernatedSocketsRebuilt();
|
|
3437
|
+
const attachment = socketAttachment(socket);
|
|
3438
|
+
if (!attachment) return;
|
|
3439
|
+
if (attachment.kind === "host-tunnel") {
|
|
3440
|
+
cleanupHostTunnel(attachment.hostId, socket, "User-hosted tunnel disconnected");
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
3443
|
+
cleanupUserHostedClient(attachment.channelId, socket, { code, reason });
|
|
3444
|
+
},
|
|
3445
|
+
webSocketError(socket) {
|
|
3446
|
+
ensureHibernatedSocketsRebuilt();
|
|
3447
|
+
const attachment = socketAttachment(socket);
|
|
3448
|
+
if (!attachment) return;
|
|
3449
|
+
if (attachment.kind === "host-tunnel") {
|
|
3450
|
+
cleanupHostTunnel(attachment.hostId, socket, "User-hosted tunnel disconnected");
|
|
3451
|
+
return;
|
|
3452
|
+
}
|
|
3453
|
+
cleanupUserHostedClient(attachment.channelId, socket, { code: 1011, reason: "WebSocket error" });
|
|
3454
|
+
},
|
|
3455
|
+
state() {
|
|
3456
|
+
ensureHibernatedSocketsRebuilt();
|
|
3457
|
+
return {
|
|
3458
|
+
hostTunnelCount: hostTunnels.size,
|
|
3459
|
+
clientCount: clients.size,
|
|
3460
|
+
hostIds: [...hostTunnels.keys()]
|
|
3461
|
+
};
|
|
3462
|
+
},
|
|
3463
|
+
drain: drainController
|
|
3464
|
+
};
|
|
3465
|
+
}
|
|
1535
3466
|
export {
|
|
3467
|
+
DEFAULT_RELAY_LOCATION_HINT,
|
|
1536
3468
|
WorkspaceRelayAuthError,
|
|
1537
|
-
__resetHostTunnelStateDebounce,
|
|
1538
3469
|
__slowConsumerInternalsForTest,
|
|
1539
3470
|
authorizeWorkspaceRelayRequest,
|
|
3471
|
+
createCachedRevocationClient,
|
|
3472
|
+
createCachedTargetClient,
|
|
1540
3473
|
createWorkspaceRelay,
|
|
1541
3474
|
createWorkspaceRelayBun,
|
|
1542
3475
|
createWorkspaceRelayDirectory,
|
|
3476
|
+
createWorkspaceRelayDurableObjectGateway,
|
|
3477
|
+
createWorkspaceRelayDurableObjectRoom,
|
|
3478
|
+
createWorkspaceRelayTrace,
|
|
1543
3479
|
disposeAuditSampler,
|
|
1544
3480
|
disposeWorkspaceRelayDirectory,
|
|
3481
|
+
forwardWorkspaceRelayRequest,
|
|
1545
3482
|
getAuditSuppressedCount,
|
|
1546
|
-
getFragmentationStats,
|
|
1547
|
-
getSlowConsumerStats,
|
|
1548
3483
|
hostTunnelTokenAudience,
|
|
3484
|
+
isRelayClaimPair,
|
|
1549
3485
|
mintHostTunnelToken,
|
|
1550
3486
|
mintRelayHostToken,
|
|
1551
3487
|
mintRuntimeAccessToken,
|
|
1552
3488
|
relayHostTokenAudience,
|
|
1553
3489
|
relayHostTokenIssuer,
|
|
1554
|
-
resetFragmentationStats,
|
|
1555
|
-
resetSlowConsumerStats,
|
|
1556
3490
|
runtimeAccessTokenAudience,
|
|
1557
3491
|
runtimeAccessTokenIssuer,
|
|
3492
|
+
setWorkspaceRelayAppOrigins,
|
|
3493
|
+
validateRuntimeAccessTokenClaims,
|
|
1558
3494
|
verifyHostTunnelToken,
|
|
1559
3495
|
verifyRelayHostToken,
|
|
1560
3496
|
verifyRuntimeAccessToken,
|
|
3497
|
+
workspaceRelayDurableObjectRoomName,
|
|
3498
|
+
workspaceRelayDurableObjectWorkspaceId,
|
|
1561
3499
|
workspaceRelayForwardHeaders,
|
|
1562
|
-
|
|
3500
|
+
workspaceRelayForwardRequestInit,
|
|
3501
|
+
workspaceRelayServerTiming,
|
|
3502
|
+
workspaceRelayTargetUrl,
|
|
3503
|
+
workspaceRelayTimingResponse
|
|
1563
3504
|
};
|