@claxedo/workspace-relay 0.1.0 → 0.5.1
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/main.mjs
CHANGED
|
@@ -13,9 +13,9 @@ import {
|
|
|
13
13
|
|
|
14
14
|
// src/bun.ts
|
|
15
15
|
import {
|
|
16
|
-
isTunnelMessage,
|
|
17
16
|
makeTunnelPong,
|
|
18
|
-
TUNNEL_PROTOCOL_VERSION
|
|
17
|
+
TUNNEL_PROTOCOL_VERSION,
|
|
18
|
+
validateTunnelMessage
|
|
19
19
|
} from "@claxedo/workspace-relay-protocol";
|
|
20
20
|
|
|
21
21
|
// src/server.ts
|
|
@@ -63,6 +63,9 @@ function roleClaim(payload) {
|
|
|
63
63
|
const value = stringClaim(payload, "role");
|
|
64
64
|
return value === "viewer" || value === "editor" || value === "admin" || value === "owner" ? value : void 0;
|
|
65
65
|
}
|
|
66
|
+
function isRelayClaimPair(input) {
|
|
67
|
+
return input.access === "cloud" && input.backing === "cloud-vm" || input.access === "user-hosted" && input.backing === "local-worktree";
|
|
68
|
+
}
|
|
66
69
|
function checkHostTunnelTarget(payload, expected) {
|
|
67
70
|
if (stringClaim(payload, "host_id") !== expected.hostId) {
|
|
68
71
|
throw new WorkspaceRelayAuthError("relay_token_host_mismatch", "Relay token host does not match request");
|
|
@@ -113,6 +116,18 @@ async function verifyRuntimeAccessToken(token, key, expected) {
|
|
|
113
116
|
}
|
|
114
117
|
return claims;
|
|
115
118
|
}
|
|
119
|
+
function validateRuntimeAccessTokenClaims(input, expected) {
|
|
120
|
+
const payload = input;
|
|
121
|
+
if (stringClaim(payload, "iss") !== runtimeAccessTokenIssuer || stringClaim(payload, "aud") !== runtimeAccessTokenAudience) {
|
|
122
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token issuer or audience is invalid");
|
|
123
|
+
}
|
|
124
|
+
checkTarget(payload, expected);
|
|
125
|
+
const claims = runtimeClaims(payload);
|
|
126
|
+
if (!claims) {
|
|
127
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token claims are incomplete");
|
|
128
|
+
}
|
|
129
|
+
return claims;
|
|
130
|
+
}
|
|
116
131
|
async function verifyHostTunnelToken(token, key, expected) {
|
|
117
132
|
const result = await verifyJwt(token, key, {
|
|
118
133
|
issuer: runtimeAccessTokenIssuer,
|
|
@@ -126,6 +141,9 @@ async function verifyHostTunnelToken(token, key, expected) {
|
|
|
126
141
|
return claims;
|
|
127
142
|
}
|
|
128
143
|
async function mintRelayHostToken(input, key, alg) {
|
|
144
|
+
if (!isRelayClaimPair(input)) {
|
|
145
|
+
throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Relay Host Token access/backing claims are inconsistent");
|
|
146
|
+
}
|
|
129
147
|
const now = seconds(input.now);
|
|
130
148
|
const protectedHeader = { alg: requireAlgorithm(alg) };
|
|
131
149
|
if (input.kid) protectedHeader.kid = input.kid;
|
|
@@ -197,6 +215,70 @@ function errorBody(code, message) {
|
|
|
197
215
|
}
|
|
198
216
|
|
|
199
217
|
// src/server.ts
|
|
218
|
+
var RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES = 4096;
|
|
219
|
+
var RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES = 8192;
|
|
220
|
+
var RESOLVER_CACHE_MAX_ENTRIES = 8192;
|
|
221
|
+
var RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 1e4;
|
|
222
|
+
var relayHostTokenCaches = /* @__PURE__ */ new WeakMap();
|
|
223
|
+
var runtimeAccessTokenCaches = /* @__PURE__ */ new WeakMap();
|
|
224
|
+
function pruneExpiringCache(cache, now, maxEntries) {
|
|
225
|
+
for (const [key, entry] of cache) {
|
|
226
|
+
if (entry.expiresAt <= now) cache.delete(key);
|
|
227
|
+
}
|
|
228
|
+
while (cache.size >= maxEntries) {
|
|
229
|
+
const key = cache.keys().next().value;
|
|
230
|
+
if (!key) return;
|
|
231
|
+
cache.delete(key);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function createCachedRevocationClient(inner, options = {}) {
|
|
235
|
+
const ttlMs = options.ttlMs ?? 1e4;
|
|
236
|
+
const now = options.now ?? Date.now;
|
|
237
|
+
const cache = /* @__PURE__ */ new Map();
|
|
238
|
+
return async (args) => {
|
|
239
|
+
const at = now();
|
|
240
|
+
const entry = cache.get(args.jti);
|
|
241
|
+
if (entry && entry.expiresAt > at) {
|
|
242
|
+
if (entry.result) return entry.result;
|
|
243
|
+
if (entry.promise) return await entry.promise;
|
|
244
|
+
}
|
|
245
|
+
const promise = inner(args).then((result) => {
|
|
246
|
+
pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
|
|
247
|
+
cache.set(args.jti, { result, expiresAt: now() + ttlMs });
|
|
248
|
+
return result;
|
|
249
|
+
}).catch((err) => {
|
|
250
|
+
cache.delete(args.jti);
|
|
251
|
+
throw err;
|
|
252
|
+
});
|
|
253
|
+
cache.set(args.jti, { promise, expiresAt: at + ttlMs });
|
|
254
|
+
return await promise;
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function createCachedTargetClient(inner, options = {}) {
|
|
258
|
+
const ttlMs = options.ttlMs ?? 5e3;
|
|
259
|
+
const now = options.now ?? Date.now;
|
|
260
|
+
const cache = /* @__PURE__ */ new Map();
|
|
261
|
+
return async (args) => {
|
|
262
|
+
const key = `${args.workspaceId}\0${args.hostId}`;
|
|
263
|
+
const at = now();
|
|
264
|
+
const entry = cache.get(key);
|
|
265
|
+
if (entry && entry.expiresAt > at) {
|
|
266
|
+
if (entry.result) return entry.result;
|
|
267
|
+
if (entry.promise) return await entry.promise;
|
|
268
|
+
}
|
|
269
|
+
const promise = inner(args).then((result) => {
|
|
270
|
+
pruneExpiringCache(cache, now(), RESOLVER_CACHE_MAX_ENTRIES);
|
|
271
|
+
if (result) cache.set(key, { result, expiresAt: now() + ttlMs });
|
|
272
|
+
else cache.delete(key);
|
|
273
|
+
return result;
|
|
274
|
+
}).catch((err) => {
|
|
275
|
+
cache.delete(key);
|
|
276
|
+
throw err;
|
|
277
|
+
});
|
|
278
|
+
cache.set(key, { promise, expiresAt: at + ttlMs });
|
|
279
|
+
return await promise;
|
|
280
|
+
};
|
|
281
|
+
}
|
|
200
282
|
function protocolToken(header) {
|
|
201
283
|
return header?.split(",").map((item) => item.trim()).find((item) => item.startsWith("claxedo-rat."))?.slice("claxedo-rat.".length);
|
|
202
284
|
}
|
|
@@ -209,6 +291,16 @@ var DANGEROUS_INBOUND_HEADERS = [
|
|
|
209
291
|
"x-forwarded-proto",
|
|
210
292
|
"x-real-ip"
|
|
211
293
|
];
|
|
294
|
+
var HOP_BY_HOP_HEADERS = [
|
|
295
|
+
"connection",
|
|
296
|
+
"keep-alive",
|
|
297
|
+
"proxy-authenticate",
|
|
298
|
+
"proxy-authorization",
|
|
299
|
+
"te",
|
|
300
|
+
"trailer",
|
|
301
|
+
"transfer-encoding",
|
|
302
|
+
"upgrade"
|
|
303
|
+
];
|
|
212
304
|
var DANGEROUS_INBOUND_HEADER_PATTERNS = [
|
|
213
305
|
/^x-claxedo-internal-/i,
|
|
214
306
|
/^x-supervisor-/i
|
|
@@ -216,6 +308,7 @@ var DANGEROUS_INBOUND_HEADER_PATTERNS = [
|
|
|
216
308
|
function isDangerousInboundHeader(name) {
|
|
217
309
|
const lower = name.toLowerCase();
|
|
218
310
|
if (DANGEROUS_INBOUND_HEADERS.includes(lower)) return true;
|
|
311
|
+
if (HOP_BY_HOP_HEADERS.includes(lower)) return true;
|
|
219
312
|
for (const pattern of DANGEROUS_INBOUND_HEADER_PATTERNS) {
|
|
220
313
|
if (pattern.test(lower)) return true;
|
|
221
314
|
}
|
|
@@ -231,9 +324,16 @@ function forwardHeaders(input, relayHostToken, workspaceId, options = {}) {
|
|
|
231
324
|
}
|
|
232
325
|
if (options.userHosted) headers2.delete("cookie");
|
|
233
326
|
headers2.set("accept-encoding", "identity");
|
|
234
|
-
headers2.
|
|
327
|
+
headers2.delete("authorization");
|
|
328
|
+
headers2.delete("Authorization");
|
|
329
|
+
headers2.set("Authorization", `Bearer ${relayHostToken}`);
|
|
235
330
|
headers2.set("x-workspace-id", workspaceId);
|
|
236
331
|
headers2.set("X-Daytona-Skip-Preview-Warning", "true");
|
|
332
|
+
headers2.set("X-Daytona-Skip-Last-Activity-Update", "true");
|
|
333
|
+
for (const [name, value] of Object.entries(options.upstreamHeaders ?? {})) {
|
|
334
|
+
const trimmed = value.trim();
|
|
335
|
+
if (trimmed) headers2.set(name, trimmed);
|
|
336
|
+
}
|
|
237
337
|
headers2.set("x-forwarded-by", "workspace-relay");
|
|
238
338
|
return headers2;
|
|
239
339
|
}
|
|
@@ -243,8 +343,155 @@ function targetUrl(target, path, search) {
|
|
|
243
343
|
return url;
|
|
244
344
|
}
|
|
245
345
|
function roleAllowsRelayRequest(role, method) {
|
|
246
|
-
if (role
|
|
247
|
-
return method === "GET" || method === "HEAD" || method === "OPTIONS";
|
|
346
|
+
if (role === "owner" || role === "admin" || role === "editor") return true;
|
|
347
|
+
if (role === "viewer") return method === "GET" || method === "HEAD" || method === "OPTIONS";
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
function relayHostTokenCacheTtlMs(options, claims) {
|
|
351
|
+
const configured = options.relayHostTokenCacheTtlMs ?? 3e4;
|
|
352
|
+
if (!Number.isFinite(configured) || configured <= 0) return 0;
|
|
353
|
+
const tokenRemaining = claims.exp * 1e3 - Date.now() - 5e3;
|
|
354
|
+
return Math.max(0, Math.min(configured, tokenRemaining));
|
|
355
|
+
}
|
|
356
|
+
function relayHostTokenCacheKey(options, claims, target) {
|
|
357
|
+
return [
|
|
358
|
+
claims.jti,
|
|
359
|
+
claims.sub,
|
|
360
|
+
claims.org_id,
|
|
361
|
+
claims.role,
|
|
362
|
+
claims.workspace_id,
|
|
363
|
+
claims.host_id,
|
|
364
|
+
target.access,
|
|
365
|
+
target.backing,
|
|
366
|
+
options.relayHostMintKid ?? ""
|
|
367
|
+
].join("\0");
|
|
368
|
+
}
|
|
369
|
+
function relayHostTokenCache(options) {
|
|
370
|
+
let cache = relayHostTokenCaches.get(options);
|
|
371
|
+
if (cache) return cache;
|
|
372
|
+
cache = /* @__PURE__ */ new Map();
|
|
373
|
+
relayHostTokenCaches.set(options, cache);
|
|
374
|
+
return cache;
|
|
375
|
+
}
|
|
376
|
+
function runtimeAccessTokenCache(options) {
|
|
377
|
+
let cache = runtimeAccessTokenCaches.get(options);
|
|
378
|
+
if (cache) return cache;
|
|
379
|
+
cache = /* @__PURE__ */ new Map();
|
|
380
|
+
runtimeAccessTokenCaches.set(options, cache);
|
|
381
|
+
return cache;
|
|
382
|
+
}
|
|
383
|
+
function runtimeAccessTokenCacheTtlMs(options, claims, now) {
|
|
384
|
+
const configured = options.runtimeAccessTokenCacheTtlMs ?? RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT;
|
|
385
|
+
if (!Number.isFinite(configured) || configured <= 0) return 0;
|
|
386
|
+
const tokenRemaining = claims.exp * 1e3 - now - 5e3;
|
|
387
|
+
return Math.max(0, Math.min(configured, tokenRemaining));
|
|
388
|
+
}
|
|
389
|
+
function pruneRuntimeAccessTokenCache(cache, now) {
|
|
390
|
+
for (const [key, entry] of cache) {
|
|
391
|
+
if (entry.expiresAt <= now) cache.delete(key);
|
|
392
|
+
}
|
|
393
|
+
while (cache.size >= RUNTIME_ACCESS_TOKEN_CACHE_MAX_ENTRIES) {
|
|
394
|
+
const key = cache.keys().next().value;
|
|
395
|
+
if (!key) return;
|
|
396
|
+
cache.delete(key);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async function verifyRuntimeAccessTokenForRequest(options, token, workspaceId) {
|
|
400
|
+
const verified = options.tokenVerifier ? await options.tokenVerifier.verify(token).then(
|
|
401
|
+
(result) => validateRuntimeAccessTokenClaims(result.claims, { workspaceId })
|
|
402
|
+
) : await verifyRuntimeAccessToken(token, options.runtimeAccessKey, { workspaceId });
|
|
403
|
+
return verified;
|
|
404
|
+
}
|
|
405
|
+
async function cachedRuntimeAccessTokenClaims(options, token, workspaceId) {
|
|
406
|
+
const configured = options.runtimeAccessTokenCacheTtlMs ?? RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT;
|
|
407
|
+
if (!Number.isFinite(configured) || configured <= 0) {
|
|
408
|
+
return await verifyRuntimeAccessTokenForRequest(options, token, workspaceId);
|
|
409
|
+
}
|
|
410
|
+
const now = Date.now();
|
|
411
|
+
const cache = runtimeAccessTokenCache(options);
|
|
412
|
+
const key = `${workspaceId}\0${token}`;
|
|
413
|
+
const cached = cache.get(key);
|
|
414
|
+
if (cached && cached.expiresAt > now) {
|
|
415
|
+
if (cached.claims) return cached.claims;
|
|
416
|
+
if (cached.promise) return await cached.promise;
|
|
417
|
+
}
|
|
418
|
+
let promise;
|
|
419
|
+
try {
|
|
420
|
+
promise = verifyRuntimeAccessTokenForRequest(options, token, workspaceId);
|
|
421
|
+
pruneRuntimeAccessTokenCache(cache, now);
|
|
422
|
+
cache.set(key, { promise, expiresAt: now + configured });
|
|
423
|
+
const claims = await promise;
|
|
424
|
+
const ttlMs = runtimeAccessTokenCacheTtlMs(options, claims, now);
|
|
425
|
+
if (ttlMs > 0) cache.set(key, { claims, expiresAt: now + ttlMs });
|
|
426
|
+
else cache.delete(key);
|
|
427
|
+
return claims;
|
|
428
|
+
} catch (err) {
|
|
429
|
+
if (promise && cache.get(key)?.promise === promise) cache.delete(key);
|
|
430
|
+
throw err;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function pruneRelayHostTokenCache(cache, now) {
|
|
434
|
+
for (const [key, entry] of cache) {
|
|
435
|
+
if (entry.expiresAt <= now) cache.delete(key);
|
|
436
|
+
}
|
|
437
|
+
while (cache.size >= RELAY_HOST_TOKEN_CACHE_MAX_ENTRIES) {
|
|
438
|
+
const key = cache.keys().next().value;
|
|
439
|
+
if (!key) return;
|
|
440
|
+
cache.delete(key);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async function relayHostTokenFor(options, claims, target, trace) {
|
|
444
|
+
const ttlMs = relayHostTokenCacheTtlMs(options, claims);
|
|
445
|
+
if (ttlMs <= 0) {
|
|
446
|
+
return await uncachedRelayHostTokenFor(options, claims, target, trace);
|
|
447
|
+
}
|
|
448
|
+
const key = relayHostTokenCacheKey(options, claims, target);
|
|
449
|
+
const now = Date.now();
|
|
450
|
+
const cache = relayHostTokenCache(options);
|
|
451
|
+
const cached = cache.get(key);
|
|
452
|
+
if (cached && cached.expiresAt > now) {
|
|
453
|
+
if (cached.promise) {
|
|
454
|
+
return await trace.span("rht-cache", async () => await cached.promise);
|
|
455
|
+
}
|
|
456
|
+
await trace.span("rht-cache", async () => {
|
|
457
|
+
});
|
|
458
|
+
return cached.token;
|
|
459
|
+
}
|
|
460
|
+
let promise;
|
|
461
|
+
try {
|
|
462
|
+
const token = await trace.span("rht-mint", async () => {
|
|
463
|
+
promise = mintRelayHostToken({
|
|
464
|
+
subject: claims.sub,
|
|
465
|
+
orgId: claims.org_id,
|
|
466
|
+
role: claims.role,
|
|
467
|
+
...target,
|
|
468
|
+
...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
|
|
469
|
+
}, options.relayHostSigningKey, options.relayHostAlgorithm);
|
|
470
|
+
pruneRelayHostTokenCache(cache, now);
|
|
471
|
+
cache.set(key, { promise, expiresAt: now + ttlMs });
|
|
472
|
+
return await promise;
|
|
473
|
+
});
|
|
474
|
+
cache.set(key, {
|
|
475
|
+
token,
|
|
476
|
+
expiresAt: now + ttlMs
|
|
477
|
+
});
|
|
478
|
+
return token;
|
|
479
|
+
} catch (err) {
|
|
480
|
+
if (promise && cache.get(key)?.promise === promise) cache.delete(key);
|
|
481
|
+
throw err;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
async function uncachedRelayHostTokenFor(options, claims, target, trace) {
|
|
485
|
+
return await trace.span(
|
|
486
|
+
"rht-mint",
|
|
487
|
+
async () => await mintRelayHostToken({
|
|
488
|
+
subject: claims.sub,
|
|
489
|
+
orgId: claims.org_id,
|
|
490
|
+
role: claims.role,
|
|
491
|
+
...target,
|
|
492
|
+
...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
|
|
493
|
+
}, options.relayHostSigningKey, options.relayHostAlgorithm)
|
|
494
|
+
);
|
|
248
495
|
}
|
|
249
496
|
function workspaceRelayTargetUrl(target, path, search) {
|
|
250
497
|
return targetUrl(target, path, search);
|
|
@@ -252,6 +499,18 @@ function workspaceRelayTargetUrl(target, path, search) {
|
|
|
252
499
|
function workspaceRelayForwardHeaders(input, relayHostToken, workspaceId, options = {}) {
|
|
253
500
|
return forwardHeaders(input, relayHostToken, workspaceId, options);
|
|
254
501
|
}
|
|
502
|
+
function workspaceRelayForwardRequestInit(request, relayHostToken, workspaceId, options = {}) {
|
|
503
|
+
const init = {
|
|
504
|
+
method: request.method,
|
|
505
|
+
headers: forwardHeaders(request.headers, relayHostToken, workspaceId, options),
|
|
506
|
+
body: request.body,
|
|
507
|
+
duplex: "half",
|
|
508
|
+
redirect: "manual",
|
|
509
|
+
...options.signal ? { signal: options.signal } : {}
|
|
510
|
+
};
|
|
511
|
+
if (request.method === "GET" || request.method === "HEAD") delete init.body;
|
|
512
|
+
return init;
|
|
513
|
+
}
|
|
255
514
|
var auditSamplers = /* @__PURE__ */ new WeakMap();
|
|
256
515
|
function getOrCreateAuditSampler(options) {
|
|
257
516
|
let state = auditSamplers.get(options);
|
|
@@ -309,6 +568,38 @@ function disposeAuditSampler(options) {
|
|
|
309
568
|
flushSuppressedSummary(options, state);
|
|
310
569
|
auditSamplers.delete(options);
|
|
311
570
|
}
|
|
571
|
+
function createWorkspaceRelayTrace() {
|
|
572
|
+
const phases = [];
|
|
573
|
+
return {
|
|
574
|
+
phases,
|
|
575
|
+
async span(name, run) {
|
|
576
|
+
const startedAt = performance.now();
|
|
577
|
+
try {
|
|
578
|
+
return await run();
|
|
579
|
+
} finally {
|
|
580
|
+
phases.push({ name, ms: performance.now() - startedAt });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
function roundedMs(value) {
|
|
586
|
+
return Math.round(value * 100) / 100;
|
|
587
|
+
}
|
|
588
|
+
function workspaceRelayServerTiming(trace) {
|
|
589
|
+
return trace.phases.map((phase) => `${phase.name};dur=${roundedMs(phase.ms)}`).join(", ");
|
|
590
|
+
}
|
|
591
|
+
function workspaceRelayTimingResponse(response, trace) {
|
|
592
|
+
const value = workspaceRelayServerTiming(trace);
|
|
593
|
+
if (!value) return response;
|
|
594
|
+
const headers2 = new Headers(response.headers);
|
|
595
|
+
const prev = headers2.get("server-timing");
|
|
596
|
+
headers2.set("server-timing", prev ? `${prev}, ${value}` : value);
|
|
597
|
+
return new Response(response.body, {
|
|
598
|
+
status: response.status,
|
|
599
|
+
statusText: response.statusText,
|
|
600
|
+
headers: headers2
|
|
601
|
+
});
|
|
602
|
+
}
|
|
312
603
|
async function audit(options, event) {
|
|
313
604
|
if (event.action === "relay.request.accepted") {
|
|
314
605
|
const state = getOrCreateAuditSampler(options);
|
|
@@ -329,7 +620,7 @@ function denyCorsHeaders(request) {
|
|
|
329
620
|
if (!allowed) return void 0;
|
|
330
621
|
return {
|
|
331
622
|
"access-control-allow-origin": origin,
|
|
332
|
-
"access-control-allow-headers": "Accept, Authorization, Content-Type, X-Daytona-Skip-Preview-Warning, X-Workspace-Id",
|
|
623
|
+
"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",
|
|
333
624
|
"access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"
|
|
334
625
|
};
|
|
335
626
|
}
|
|
@@ -351,11 +642,12 @@ async function deny(options, input) {
|
|
|
351
642
|
headers: denyCorsHeaders(input.request)
|
|
352
643
|
});
|
|
353
644
|
}
|
|
354
|
-
async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
645
|
+
async function authorizeWorkspaceRelayRequest(options, request, workspaceId, trace) {
|
|
355
646
|
const token = runtimeAccessToken(request);
|
|
356
647
|
if (!token) {
|
|
357
648
|
return {
|
|
358
649
|
ok: false,
|
|
650
|
+
code: "runtime_access_token_required",
|
|
359
651
|
response: await deny(options, {
|
|
360
652
|
code: "runtime_access_token_required",
|
|
361
653
|
message: "Runtime Access Token is required",
|
|
@@ -365,14 +657,18 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
365
657
|
};
|
|
366
658
|
}
|
|
367
659
|
try {
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
660
|
+
const span = (name, run) => trace ? trace.span(name, run) : run();
|
|
661
|
+
const claims = await span(
|
|
662
|
+
"rat-verify",
|
|
663
|
+
async () => await cachedRuntimeAccessTokenClaims(options, token, workspaceId)
|
|
664
|
+
);
|
|
665
|
+
const active = await span("rat-active", async () => {
|
|
666
|
+
return await options.isRuntimeAccessTokenActive?.(claims) ?? { active: true };
|
|
667
|
+
});
|
|
373
668
|
if (!active.active) {
|
|
374
669
|
return {
|
|
375
670
|
ok: false,
|
|
671
|
+
code: active.code,
|
|
376
672
|
response: await deny(options, {
|
|
377
673
|
code: active.code,
|
|
378
674
|
message: active.reason,
|
|
@@ -385,6 +681,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
385
681
|
if (!roleAllowsRelayRequest(claims.role, request.method)) {
|
|
386
682
|
return {
|
|
387
683
|
ok: false,
|
|
684
|
+
code: "relay_role_denied",
|
|
388
685
|
response: await deny(options, {
|
|
389
686
|
code: "relay_role_denied",
|
|
390
687
|
message: "Workspace role does not allow this relay request",
|
|
@@ -394,10 +691,11 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
394
691
|
})
|
|
395
692
|
};
|
|
396
693
|
}
|
|
397
|
-
const target = await options.resolveTarget(claims);
|
|
694
|
+
const target = await span("target-resolve", async () => await options.resolveTarget(claims));
|
|
398
695
|
if (!target || target.workspaceId !== claims.workspace_id || target.hostId !== claims.host_id) {
|
|
399
696
|
return {
|
|
400
697
|
ok: false,
|
|
698
|
+
code: "relay_target_unavailable",
|
|
401
699
|
response: await deny(options, {
|
|
402
700
|
code: "relay_target_unavailable",
|
|
403
701
|
message: "Workspace relay target is unavailable",
|
|
@@ -410,6 +708,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
410
708
|
if (target.access === "user-hosted" && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
|
|
411
709
|
return {
|
|
412
710
|
ok: false,
|
|
711
|
+
code: "user_hosted_app_offline",
|
|
413
712
|
response: await deny(options, {
|
|
414
713
|
code: "user_hosted_app_offline",
|
|
415
714
|
message: "User-hosted workspace is offline",
|
|
@@ -419,7 +718,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
419
718
|
})
|
|
420
719
|
};
|
|
421
720
|
}
|
|
422
|
-
await audit(options, {
|
|
721
|
+
await span("audit", async () => await audit(options, {
|
|
423
722
|
action: "relay.request.accepted",
|
|
424
723
|
result: "allow",
|
|
425
724
|
subject: claims.sub,
|
|
@@ -428,22 +727,14 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
428
727
|
hostId: claims.host_id,
|
|
429
728
|
method: request.method,
|
|
430
729
|
path: new URL(request.url).pathname
|
|
431
|
-
});
|
|
730
|
+
}));
|
|
731
|
+
const relayHostToken = await relayHostTokenFor(options, claims, target, { span });
|
|
432
732
|
return {
|
|
433
733
|
ok: true,
|
|
434
734
|
request: {
|
|
435
735
|
claims,
|
|
436
736
|
target,
|
|
437
|
-
relayHostToken
|
|
438
|
-
subject: claims.sub,
|
|
439
|
-
orgId: claims.org_id,
|
|
440
|
-
workspaceId: claims.workspace_id,
|
|
441
|
-
hostId: claims.host_id,
|
|
442
|
-
role: claims.role,
|
|
443
|
-
access: target.access,
|
|
444
|
-
backing: target.backing,
|
|
445
|
-
...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
|
|
446
|
-
}, options.relayHostSigningKey, options.relayHostAlgorithm),
|
|
737
|
+
relayHostToken,
|
|
447
738
|
path: new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/")
|
|
448
739
|
}
|
|
449
740
|
};
|
|
@@ -451,6 +742,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
451
742
|
const code = err instanceof WorkspaceRelayAuthError ? err.code : "relay_request_failed";
|
|
452
743
|
return {
|
|
453
744
|
ok: false,
|
|
745
|
+
code,
|
|
454
746
|
response: await deny(options, {
|
|
455
747
|
code,
|
|
456
748
|
message: "Workspace relay request was denied",
|
|
@@ -460,28 +752,45 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
|
|
|
460
752
|
};
|
|
461
753
|
}
|
|
462
754
|
}
|
|
463
|
-
async function
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
755
|
+
async function forwardWorkspaceRelayRequest(request, target, relayHostToken, path, requestFetch, timeoutMs, trace) {
|
|
756
|
+
const controller = new AbortController();
|
|
757
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
758
|
+
const span = (name, run) => trace ? trace.span(name, run) : run();
|
|
759
|
+
try {
|
|
760
|
+
const upstream = await span("upstream-fetch", async () => await requestFetch(
|
|
761
|
+
targetUrl(target, path, new URL(request.url).search),
|
|
762
|
+
workspaceRelayForwardRequestInit(request, relayHostToken, target.workspaceId, {
|
|
763
|
+
signal: controller.signal,
|
|
764
|
+
upstreamHeaders: target.upstreamHeaders
|
|
765
|
+
})
|
|
766
|
+
));
|
|
767
|
+
const headers2 = new Headers(upstream.headers);
|
|
768
|
+
headers2.delete("access-control-allow-origin");
|
|
769
|
+
headers2.delete("access-control-allow-credentials");
|
|
770
|
+
headers2.delete("access-control-allow-headers");
|
|
771
|
+
headers2.delete("access-control-allow-methods");
|
|
772
|
+
headers2.delete("access-control-expose-headers");
|
|
773
|
+
headers2.delete("access-control-max-age");
|
|
774
|
+
for (const [key, value] of Object.entries(denyCorsHeaders(request) ?? {})) {
|
|
775
|
+
headers2.set(key, value);
|
|
776
|
+
}
|
|
777
|
+
return new Response(upstream.body, {
|
|
778
|
+
status: upstream.status,
|
|
779
|
+
statusText: upstream.statusText,
|
|
780
|
+
headers: headers2
|
|
781
|
+
});
|
|
782
|
+
} catch (err) {
|
|
783
|
+
const aborted = err instanceof Error && err.name === "AbortError";
|
|
784
|
+
return Response.json(
|
|
785
|
+
errorBody(
|
|
786
|
+
aborted ? "upstream_timeout" : "upstream_unavailable",
|
|
787
|
+
aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable"
|
|
788
|
+
),
|
|
789
|
+
{ status: aborted ? 504 : 503, headers: denyCorsHeaders(request) }
|
|
790
|
+
);
|
|
791
|
+
} finally {
|
|
792
|
+
clearTimeout(timer);
|
|
793
|
+
}
|
|
485
794
|
}
|
|
486
795
|
function getAuditSuppressedCount(options) {
|
|
487
796
|
return auditSamplers.get(options)?.suppressedCount ?? 0;
|
|
@@ -512,7 +821,14 @@ function checkMetricsAuth(options, request) {
|
|
|
512
821
|
}
|
|
513
822
|
return { ok: true };
|
|
514
823
|
}
|
|
515
|
-
if (!options.metricsRemoteAddress)
|
|
824
|
+
if (!options.metricsRemoteAddress) {
|
|
825
|
+
return {
|
|
826
|
+
ok: false,
|
|
827
|
+
status: 401,
|
|
828
|
+
code: "metrics_unauthorized",
|
|
829
|
+
message: "Metrics endpoint is restricted to loopback or bearer-token clients"
|
|
830
|
+
};
|
|
831
|
+
}
|
|
516
832
|
const address = options.metricsRemoteAddress(request);
|
|
517
833
|
if (isLoopbackAddress(address)) return { ok: true };
|
|
518
834
|
return {
|
|
@@ -551,6 +867,7 @@ function createWorkspaceRelay(options) {
|
|
|
551
867
|
if (origin.startsWith("http://localhost:")) return origin;
|
|
552
868
|
if (origin.startsWith("http://127.0.0.1:")) return origin;
|
|
553
869
|
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
|
|
870
|
+
if (/^https:\/\/([a-z0-9-]+\.)*claxedo\.com$/.test(origin)) return origin;
|
|
554
871
|
return void 0;
|
|
555
872
|
},
|
|
556
873
|
allowHeaders: [
|
|
@@ -558,7 +875,12 @@ function createWorkspaceRelay(options) {
|
|
|
558
875
|
"Authorization",
|
|
559
876
|
"Content-Type",
|
|
560
877
|
"X-Daytona-Skip-Preview-Warning",
|
|
561
|
-
"X-Workspace-Id"
|
|
878
|
+
"X-Workspace-Id",
|
|
879
|
+
"X-OpenCode-Directory",
|
|
880
|
+
"X-Claxedo-Runner",
|
|
881
|
+
"X-Claxedo-Model",
|
|
882
|
+
"X-Claxedo-Draft-Id",
|
|
883
|
+
"X-Claxedo-Binary"
|
|
562
884
|
],
|
|
563
885
|
allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
564
886
|
}));
|
|
@@ -622,21 +944,27 @@ function createWorkspaceRelay(options) {
|
|
|
622
944
|
return c.json(buildMetricsBody(options));
|
|
623
945
|
});
|
|
624
946
|
app.all("/workspaces/:workspaceId/*", async (c) => {
|
|
947
|
+
const trace = createWorkspaceRelayTrace();
|
|
625
948
|
if (options.isDraining?.()) {
|
|
626
949
|
return Response.json(
|
|
627
950
|
errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
|
|
628
951
|
{ status: 503, headers: denyCorsHeaders(c.req.raw) }
|
|
629
952
|
);
|
|
630
953
|
}
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
954
|
+
const response = await trace.span("relay-total", async () => {
|
|
955
|
+
const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"), trace);
|
|
956
|
+
if (!relay.ok) return relay.response;
|
|
957
|
+
return await forwardWorkspaceRelayRequest(
|
|
958
|
+
new Request(c.req.raw),
|
|
959
|
+
relay.request.target,
|
|
960
|
+
relay.request.relayHostToken,
|
|
961
|
+
relay.request.path,
|
|
962
|
+
options.fetch ?? fetch,
|
|
963
|
+
options.forwardTimeoutMs ?? 3e4,
|
|
964
|
+
trace
|
|
965
|
+
);
|
|
966
|
+
});
|
|
967
|
+
return workspaceRelayTimingResponse(response, trace);
|
|
640
968
|
});
|
|
641
969
|
return app;
|
|
642
970
|
}
|
|
@@ -644,31 +972,79 @@ function createWorkspaceRelay(options) {
|
|
|
644
972
|
// src/bun.ts
|
|
645
973
|
var TUNNEL_PENDING_HTTP_CAP = 32;
|
|
646
974
|
var TUNNEL_CHANNEL_CAP = 16;
|
|
647
|
-
var HOST_TUNNEL_REGISTRATION_CONCURRENT_CAP = 3;
|
|
648
975
|
var HOST_TUNNEL_REGISTRATION_RECONNECT_CAP = 5;
|
|
649
976
|
var HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS = 6e4;
|
|
650
977
|
var WS_MAX_PAYLOAD_LENGTH_BYTES = 16 * 1024 * 1024;
|
|
978
|
+
var TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT = 16 * 1024 * 1024;
|
|
979
|
+
var TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT = 3e4;
|
|
980
|
+
var DIRECT_HTTP_TIMEOUT_MS_DEFAULT = 3e4;
|
|
981
|
+
var UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT = 1e4;
|
|
982
|
+
var UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT = 64;
|
|
983
|
+
var WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
984
|
+
var HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT = 2;
|
|
651
985
|
var TUNNEL_MESSAGE_BUFFER_CAP_BYTES = 4 * 1024 * 1024;
|
|
652
986
|
var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
|
|
653
987
|
var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
|
|
654
988
|
var HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT = 250;
|
|
655
|
-
|
|
656
|
-
fragmentsBuffered: 0,
|
|
657
|
-
oversizedClosed: 0
|
|
658
|
-
};
|
|
659
|
-
function getFragmentationStats() {
|
|
989
|
+
function createFragmentationStats() {
|
|
660
990
|
return {
|
|
661
|
-
fragmentsBuffered:
|
|
662
|
-
oversizedClosed:
|
|
991
|
+
fragmentsBuffered: 0,
|
|
992
|
+
oversizedClosed: 0
|
|
663
993
|
};
|
|
664
994
|
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
995
|
+
function createSlowConsumerStats() {
|
|
996
|
+
return {
|
|
997
|
+
overflowEvents: 0,
|
|
998
|
+
timerFired: 0,
|
|
999
|
+
droppedRequests: 0
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
function readFragmentationStats(stats) {
|
|
1003
|
+
return {
|
|
1004
|
+
fragmentsBuffered: stats.fragmentsBuffered,
|
|
1005
|
+
oversizedClosed: stats.oversizedClosed
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
function resetFragmentationStats(stats) {
|
|
1009
|
+
stats.fragmentsBuffered = 0;
|
|
1010
|
+
stats.oversizedClosed = 0;
|
|
1011
|
+
}
|
|
1012
|
+
function readSlowConsumerStats(stats) {
|
|
1013
|
+
return { ...stats };
|
|
1014
|
+
}
|
|
1015
|
+
function resetSlowConsumerStats(stats) {
|
|
1016
|
+
stats.overflowEvents = 0;
|
|
1017
|
+
stats.timerFired = 0;
|
|
1018
|
+
stats.droppedRequests = 0;
|
|
1019
|
+
}
|
|
1020
|
+
function createDirectHttpLimiter(limit) {
|
|
1021
|
+
if (!Number.isInteger(limit) || !limit || limit <= 0) return void 0;
|
|
1022
|
+
const max = limit;
|
|
1023
|
+
let active = 0;
|
|
1024
|
+
const queue = [];
|
|
1025
|
+
function drain() {
|
|
1026
|
+
if (active >= max) return;
|
|
1027
|
+
const next = queue.shift();
|
|
1028
|
+
if (!next) return;
|
|
1029
|
+
active++;
|
|
1030
|
+
next();
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
async acquire() {
|
|
1034
|
+
if (active < max) {
|
|
1035
|
+
active++;
|
|
1036
|
+
} else {
|
|
1037
|
+
await new Promise((resolve) => queue.push(resolve));
|
|
1038
|
+
}
|
|
1039
|
+
let released = false;
|
|
1040
|
+
return () => {
|
|
1041
|
+
if (released) return;
|
|
1042
|
+
released = true;
|
|
1043
|
+
active = Math.max(0, active - 1);
|
|
1044
|
+
drain();
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
672
1048
|
}
|
|
673
1049
|
function jsonError(code, message, status) {
|
|
674
1050
|
return new Response(JSON.stringify({ error: { code, message } }), {
|
|
@@ -676,6 +1052,35 @@ function jsonError(code, message, status) {
|
|
|
676
1052
|
headers: { "content-type": "application/json" }
|
|
677
1053
|
});
|
|
678
1054
|
}
|
|
1055
|
+
function corsJsonError(request, code, message, status) {
|
|
1056
|
+
const headers2 = relayCorsHeaders(request);
|
|
1057
|
+
headers2.set("content-type", "application/json");
|
|
1058
|
+
return new Response(JSON.stringify({ error: { code, message } }), { status, headers: headers2 });
|
|
1059
|
+
}
|
|
1060
|
+
function safeCloseCode(input, fallback = 1011) {
|
|
1061
|
+
if (!Number.isInteger(input)) return fallback;
|
|
1062
|
+
const code = input;
|
|
1063
|
+
if (code === 1e3) return code;
|
|
1064
|
+
if (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
|
|
1065
|
+
if (code >= 3e3 && code <= 4999) return code;
|
|
1066
|
+
return fallback;
|
|
1067
|
+
}
|
|
1068
|
+
function closeWebSocket(ws, code, reason, fallback = 1011) {
|
|
1069
|
+
try {
|
|
1070
|
+
ws.close(safeCloseCode(code, fallback), reason);
|
|
1071
|
+
} catch {
|
|
1072
|
+
try {
|
|
1073
|
+
ws.close(fallback, reason);
|
|
1074
|
+
} catch {
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
function upstreamCloseReason(event) {
|
|
1079
|
+
if (event.reason.trim()) return event.reason;
|
|
1080
|
+
if (event.code === 1e3) return "";
|
|
1081
|
+
if (event.code === 1005 || event.code === 1006) return "Upstream WebSocket closed abnormally";
|
|
1082
|
+
return `Upstream WebSocket closed unexpectedly with code ${event.code}`;
|
|
1083
|
+
}
|
|
679
1084
|
function workspaceIdFromPath(pathname) {
|
|
680
1085
|
return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
|
|
681
1086
|
}
|
|
@@ -685,6 +1090,23 @@ function hostIdFromTunnelPath(pathname) {
|
|
|
685
1090
|
function websocketRequest(request) {
|
|
686
1091
|
return request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
687
1092
|
}
|
|
1093
|
+
function roundedMs2(input) {
|
|
1094
|
+
return Math.round(input * 100) / 100;
|
|
1095
|
+
}
|
|
1096
|
+
function relayWebSocketTraceEnabled(request) {
|
|
1097
|
+
return request.headers.get("x-claxedo-relay-ws-trace") === "1";
|
|
1098
|
+
}
|
|
1099
|
+
function sendRelayWebSocketTrace(ws) {
|
|
1100
|
+
const trace = ws.data.trace;
|
|
1101
|
+
if (!trace || trace.emitted) return;
|
|
1102
|
+
trace.emitted = true;
|
|
1103
|
+
ws.send(JSON.stringify({
|
|
1104
|
+
type: "relay.trace",
|
|
1105
|
+
wsUpstreamOpenMs: trace.upstreamOpenMs === void 0 ? void 0 : roundedMs2(trace.upstreamOpenMs),
|
|
1106
|
+
queuedFrames: trace.queuedFrames,
|
|
1107
|
+
maxQueuedDelayMs: roundedMs2(trace.maxQueuedDelayMs)
|
|
1108
|
+
}));
|
|
1109
|
+
}
|
|
688
1110
|
function relayWebSocketPayload(input) {
|
|
689
1111
|
if (typeof input === "string") return input;
|
|
690
1112
|
if (input instanceof ArrayBuffer) return input;
|
|
@@ -708,6 +1130,11 @@ function headers(input) {
|
|
|
708
1130
|
}
|
|
709
1131
|
return result;
|
|
710
1132
|
}
|
|
1133
|
+
function isEventStream(input) {
|
|
1134
|
+
return Object.entries(input).some(
|
|
1135
|
+
([key, value]) => key.toLowerCase() === "content-type" && value.toLowerCase().includes("text/event-stream")
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
711
1138
|
function allowedCorsOrigin(origin) {
|
|
712
1139
|
if (!origin) return;
|
|
713
1140
|
if (origin.startsWith("http://localhost:")) return origin;
|
|
@@ -734,7 +1161,7 @@ function relayCorsHeaders(request, input = new Headers()) {
|
|
|
734
1161
|
const origin = allowedCorsOrigin(request.headers.get("origin"));
|
|
735
1162
|
if (origin) {
|
|
736
1163
|
result.set("access-control-allow-origin", origin);
|
|
737
|
-
result.set("access-control-allow-headers", "Accept, Authorization, Content-Type, X-Daytona-Skip-Preview-Warning, X-Workspace-Id");
|
|
1164
|
+
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");
|
|
738
1165
|
result.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
739
1166
|
}
|
|
740
1167
|
return result;
|
|
@@ -761,7 +1188,7 @@ function decodedFrame(frame) {
|
|
|
761
1188
|
const body = Buffer.from(frame.data_base64, "base64");
|
|
762
1189
|
return frame.binary ? new Uint8Array(body) : body.toString("utf8");
|
|
763
1190
|
}
|
|
764
|
-
function tunnelMessage(ws, input) {
|
|
1191
|
+
function tunnelMessage(ws, input, stats) {
|
|
765
1192
|
if (typeof input !== "string") return;
|
|
766
1193
|
const combined = ws.data.messageBuffer.length > 0 ? ws.data.messageBuffer + input : input;
|
|
767
1194
|
let parsed;
|
|
@@ -769,20 +1196,26 @@ function tunnelMessage(ws, input) {
|
|
|
769
1196
|
parsed = JSON.parse(combined);
|
|
770
1197
|
} catch {
|
|
771
1198
|
if (combined.length > TUNNEL_MESSAGE_BUFFER_CAP_BYTES) {
|
|
772
|
-
|
|
1199
|
+
stats.oversizedClosed += 1;
|
|
773
1200
|
ws.data.messageBuffer = "";
|
|
774
1201
|
ws.close(1009, "Tunnel message buffer exceeded 4 MB");
|
|
775
1202
|
return;
|
|
776
1203
|
}
|
|
777
|
-
|
|
1204
|
+
stats.fragmentsBuffered += 1;
|
|
778
1205
|
ws.data.messageBuffer = combined;
|
|
779
1206
|
return;
|
|
780
1207
|
}
|
|
781
1208
|
ws.data.messageBuffer = "";
|
|
782
|
-
|
|
1209
|
+
const validated = validateTunnelMessage(parsed);
|
|
1210
|
+
if (validated.ok) return validated.message;
|
|
1211
|
+
if (validated.reason === "protocol_mismatch") {
|
|
1212
|
+
ws.close(1002, `Tunnel protocol mismatch: expected ${validated.expected_protocol}`);
|
|
1213
|
+
}
|
|
1214
|
+
return;
|
|
783
1215
|
}
|
|
784
1216
|
function sendTunnelPing(ws) {
|
|
785
1217
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1218
|
+
ws.data.missedPongs += 1;
|
|
786
1219
|
ws.send(JSON.stringify({
|
|
787
1220
|
type: "ping",
|
|
788
1221
|
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
@@ -790,6 +1223,59 @@ function sendTunnelPing(ws) {
|
|
|
790
1223
|
sent_at: Date.now()
|
|
791
1224
|
}));
|
|
792
1225
|
}
|
|
1226
|
+
function clearPendingTimers(entry) {
|
|
1227
|
+
clearTimeout(entry.timeout);
|
|
1228
|
+
if (entry.slowConsumerTimeout) {
|
|
1229
|
+
clearTimeout(entry.slowConsumerTimeout);
|
|
1230
|
+
entry.slowConsumerTimeout = void 0;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
function failPendingHttpResponse(input) {
|
|
1234
|
+
clearPendingTimers(input.entry);
|
|
1235
|
+
input.entry.pendingChunks.length = 0;
|
|
1236
|
+
input.entry.bytesQueued = 0;
|
|
1237
|
+
try {
|
|
1238
|
+
input.entry.controller.error(input.error);
|
|
1239
|
+
} catch {
|
|
1240
|
+
}
|
|
1241
|
+
if (!input.entry.responseStarted) {
|
|
1242
|
+
if (input.response) {
|
|
1243
|
+
input.entry.resolve(input.response);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
input.entry.reject(input.error);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
function cleanupHostTunnelSocket(input) {
|
|
1250
|
+
if (input.ws.data.heartbeat) {
|
|
1251
|
+
clearInterval(input.ws.data.heartbeat);
|
|
1252
|
+
input.ws.data.heartbeat = void 0;
|
|
1253
|
+
}
|
|
1254
|
+
for (const pending of input.ws.data.pending.values()) {
|
|
1255
|
+
failPendingHttpResponse({
|
|
1256
|
+
entry: pending,
|
|
1257
|
+
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),
|
|
1258
|
+
error: new Error("User-hosted tunnel disconnected")
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
input.ws.data.pending.clear();
|
|
1262
|
+
for (const channel of input.ws.data.channels.values()) {
|
|
1263
|
+
if (input.closeChannels) closeWebSocket(channel, 1011, "User-hosted tunnel disconnected");
|
|
1264
|
+
}
|
|
1265
|
+
input.ws.data.channels.clear();
|
|
1266
|
+
if (!input.disconnectDirectory) return;
|
|
1267
|
+
if (input.hostTunnels.get(input.ws.data.hostId) === input.ws) {
|
|
1268
|
+
input.hostTunnels.delete(input.ws.data.hostId);
|
|
1269
|
+
input.options.directory?.disconnectHost(input.ws.data.hostId);
|
|
1270
|
+
scheduleHostTunnelStateChange(input.hostTunnelStateDebounce, input.options, {
|
|
1271
|
+
hostId: input.ws.data.hostId,
|
|
1272
|
+
workspaceId: input.ws.data.workspaceIds[0],
|
|
1273
|
+
path: `/host-tunnels/${input.ws.data.hostId}`,
|
|
1274
|
+
state: "disconnected",
|
|
1275
|
+
debounceMs: input.bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
793
1279
|
async function audit2(options, input) {
|
|
794
1280
|
await options.audit?.({
|
|
795
1281
|
action: input.action,
|
|
@@ -800,9 +1286,8 @@ async function audit2(options, input) {
|
|
|
800
1286
|
path: input.path
|
|
801
1287
|
});
|
|
802
1288
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
const existing = hostTunnelStateDebounce.get(input.hostId);
|
|
1289
|
+
function scheduleHostTunnelStateChange(state, options, input) {
|
|
1290
|
+
const existing = state.get(input.hostId);
|
|
806
1291
|
const entry = existing ?? {
|
|
807
1292
|
intendedState: "disconnected",
|
|
808
1293
|
lastWritten: "disconnected",
|
|
@@ -816,7 +1301,7 @@ function scheduleHostTunnelStateChange(options, input) {
|
|
|
816
1301
|
entry.flushTimer = void 0;
|
|
817
1302
|
if (entry.intendedState === entry.lastWritten) {
|
|
818
1303
|
if (entry.intendedState === "disconnected") {
|
|
819
|
-
|
|
1304
|
+
state.delete(input.hostId);
|
|
820
1305
|
}
|
|
821
1306
|
return;
|
|
822
1307
|
}
|
|
@@ -828,7 +1313,7 @@ function scheduleHostTunnelStateChange(options, input) {
|
|
|
828
1313
|
path: entry.pending.path
|
|
829
1314
|
});
|
|
830
1315
|
}, input.debounceMs);
|
|
831
|
-
|
|
1316
|
+
state.set(input.hostId, entry);
|
|
832
1317
|
}
|
|
833
1318
|
function drainPendingChunks(entry) {
|
|
834
1319
|
while (entry.pendingChunks.length > 0) {
|
|
@@ -857,9 +1342,9 @@ function enqueueChunkWithBackpressure(input) {
|
|
|
857
1342
|
entry.pendingChunks.push(chunk);
|
|
858
1343
|
entry.bytesQueued += chunk.byteLength;
|
|
859
1344
|
if (!entry.slowConsumerTimeout) {
|
|
860
|
-
slowConsumerStats.overflowEvents += 1;
|
|
1345
|
+
input.slowConsumerStats.overflowEvents += 1;
|
|
861
1346
|
entry.slowConsumerTimeout = setTimeout(() => {
|
|
862
|
-
slowConsumerStats.timerFired += 1;
|
|
1347
|
+
input.slowConsumerStats.timerFired += 1;
|
|
863
1348
|
const error = new Error("slow_consumer_timeout: downstream consumer did not drain in time");
|
|
864
1349
|
try {
|
|
865
1350
|
entry.controller.error(error);
|
|
@@ -872,7 +1357,7 @@ function enqueueChunkWithBackpressure(input) {
|
|
|
872
1357
|
entry.resolve(jsonError("slow_consumer_timeout", "Downstream consumer did not drain in time", 503));
|
|
873
1358
|
}
|
|
874
1359
|
input.ws.data.pending.delete(input.requestId);
|
|
875
|
-
slowConsumerStats.droppedRequests += 1;
|
|
1360
|
+
input.slowConsumerStats.droppedRequests += 1;
|
|
876
1361
|
}, input.slowConsumerTimeoutMs);
|
|
877
1362
|
}
|
|
878
1363
|
return;
|
|
@@ -903,13 +1388,47 @@ async function tunnelHttpRequest(input) {
|
|
|
903
1388
|
const entry = input.ws.data.pending.get(requestId);
|
|
904
1389
|
if (!entry) return;
|
|
905
1390
|
drainPendingChunks(entry);
|
|
1391
|
+
},
|
|
1392
|
+
cancel() {
|
|
1393
|
+
const entry = input.ws.data.pending.get(requestId);
|
|
1394
|
+
if (!entry) return;
|
|
1395
|
+
input.ws.data.pending.delete(requestId);
|
|
1396
|
+
clearPendingTimers(entry);
|
|
1397
|
+
entry.pendingChunks.length = 0;
|
|
1398
|
+
entry.bytesQueued = 0;
|
|
1399
|
+
if (input.ws.readyState === WebSocket.OPEN) {
|
|
1400
|
+
input.ws.send(JSON.stringify({
|
|
1401
|
+
type: "http.response.flow",
|
|
1402
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
1403
|
+
request_id: requestId,
|
|
1404
|
+
paused: false,
|
|
1405
|
+
reason: "closed"
|
|
1406
|
+
}));
|
|
1407
|
+
}
|
|
906
1408
|
}
|
|
907
1409
|
}, new ByteLengthQueuingStrategy({ highWaterMark: input.slowConsumerHighWaterMarkBytes }));
|
|
908
1410
|
const pending = new Promise((resolve, reject) => {
|
|
909
1411
|
const timeout = setTimeout(() => {
|
|
1412
|
+
const entry = input.ws.data.pending.get(requestId);
|
|
1413
|
+
if (!entry) return;
|
|
910
1414
|
input.ws.data.pending.delete(requestId);
|
|
911
|
-
|
|
912
|
-
|
|
1415
|
+
if (entry.responseStarted) {
|
|
1416
|
+
clearPendingTimers(entry);
|
|
1417
|
+
entry.pendingChunks.length = 0;
|
|
1418
|
+
entry.bytesQueued = 0;
|
|
1419
|
+
try {
|
|
1420
|
+
entry.controller.close();
|
|
1421
|
+
} catch {
|
|
1422
|
+
}
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
const error = new Error("User-hosted tunnel response timed out");
|
|
1426
|
+
failPendingHttpResponse({
|
|
1427
|
+
entry,
|
|
1428
|
+
response: corsJsonError(input.request, "user_hosted_tunnel_timeout", "User-hosted tunnel response timed out", 504),
|
|
1429
|
+
error
|
|
1430
|
+
});
|
|
1431
|
+
}, input.responseTimeoutMs);
|
|
913
1432
|
input.ws.data.pending.set(requestId, {
|
|
914
1433
|
controller,
|
|
915
1434
|
stream,
|
|
@@ -921,7 +1440,13 @@ async function tunnelHttpRequest(input) {
|
|
|
921
1440
|
responseStarted: false
|
|
922
1441
|
});
|
|
923
1442
|
});
|
|
924
|
-
const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 :
|
|
1443
|
+
const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : await readBoundedBody(input.request, input.requestBodyMaxBytes);
|
|
1444
|
+
if (body && "response" in body) {
|
|
1445
|
+
const entry = input.ws.data.pending.get(requestId);
|
|
1446
|
+
if (entry) clearPendingTimers(entry);
|
|
1447
|
+
input.ws.data.pending.delete(requestId);
|
|
1448
|
+
return body.response;
|
|
1449
|
+
}
|
|
925
1450
|
input.ws.send(JSON.stringify({
|
|
926
1451
|
type: "http.request",
|
|
927
1452
|
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
@@ -937,33 +1462,81 @@ async function tunnelHttpRequest(input) {
|
|
|
937
1462
|
// cookies to the host process running on the user's laptop.
|
|
938
1463
|
{ userHosted: true }
|
|
939
1464
|
)),
|
|
940
|
-
...body ? { body_base64: body } : {},
|
|
1465
|
+
...body?.bodyBase64 ? { body_base64: body.bodyBase64 } : {},
|
|
941
1466
|
end: true
|
|
942
1467
|
}));
|
|
943
1468
|
return await pending;
|
|
944
1469
|
}
|
|
1470
|
+
async function readBoundedBody(request, maxBytes) {
|
|
1471
|
+
if (!request.body) return void 0;
|
|
1472
|
+
const reader = request.body.getReader();
|
|
1473
|
+
const chunks = [];
|
|
1474
|
+
let total = 0;
|
|
1475
|
+
for (; ; ) {
|
|
1476
|
+
const next = await reader.read();
|
|
1477
|
+
if (next.done) break;
|
|
1478
|
+
total += next.value.byteLength;
|
|
1479
|
+
if (total > maxBytes) {
|
|
1480
|
+
try {
|
|
1481
|
+
await reader.cancel();
|
|
1482
|
+
} catch {
|
|
1483
|
+
}
|
|
1484
|
+
return {
|
|
1485
|
+
response: corsJsonError(request, "request_body_too_large", "Tunnel request body exceeds the relay limit", 413)
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
chunks.push(next.value);
|
|
1489
|
+
}
|
|
1490
|
+
const body = new Uint8Array(total);
|
|
1491
|
+
chunks.reduce((offset, chunk) => {
|
|
1492
|
+
body.set(chunk, offset);
|
|
1493
|
+
return offset + chunk.byteLength;
|
|
1494
|
+
}, 0);
|
|
1495
|
+
return { bodyBase64: encoded(body.buffer) };
|
|
1496
|
+
}
|
|
945
1497
|
async function directHttpRequest(input) {
|
|
946
|
-
const
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1498
|
+
const controller = new AbortController();
|
|
1499
|
+
const timer = setTimeout(() => controller.abort(), input.timeoutMs);
|
|
1500
|
+
const span = (name, run) => input.trace ? input.trace.span(name, run) : run();
|
|
1501
|
+
try {
|
|
1502
|
+
const init = workspaceRelayForwardRequestInit(
|
|
1503
|
+
input.request,
|
|
950
1504
|
input.relayHostToken,
|
|
951
1505
|
input.workspaceId,
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1506
|
+
{
|
|
1507
|
+
// T26: cloud-vm direct path. Cookies pass through (default) — workspace
|
|
1508
|
+
// dashboards in the VM may legitimately need session cookies.
|
|
1509
|
+
userHosted: false,
|
|
1510
|
+
upstreamHeaders: input.upstreamHeaders,
|
|
1511
|
+
signal: controller.signal
|
|
1512
|
+
}
|
|
1513
|
+
);
|
|
1514
|
+
if (input.request.method !== "GET" && input.request.method !== "HEAD" && input.request.body) {
|
|
1515
|
+
init.body = await input.request.arrayBuffer();
|
|
1516
|
+
}
|
|
1517
|
+
const limiter = input.limiter;
|
|
1518
|
+
const release = limiter ? await span("direct-http-queue", () => limiter.acquire()) : void 0;
|
|
1519
|
+
try {
|
|
1520
|
+
const upstream = await span("upstream-fetch", async () => await fetch(input.targetUrl, init));
|
|
1521
|
+
return new Response(upstream.body, {
|
|
1522
|
+
status: upstream.status,
|
|
1523
|
+
statusText: upstream.statusText,
|
|
1524
|
+
headers: relayCorsHeaders(input.request, upstream.headers)
|
|
1525
|
+
});
|
|
1526
|
+
} finally {
|
|
1527
|
+
release?.();
|
|
1528
|
+
}
|
|
1529
|
+
} catch (err) {
|
|
1530
|
+
const aborted = err instanceof Error && err.name === "AbortError";
|
|
1531
|
+
return corsJsonError(
|
|
1532
|
+
input.request,
|
|
1533
|
+
aborted ? "upstream_timeout" : "upstream_unavailable",
|
|
1534
|
+
aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable",
|
|
1535
|
+
aborted ? 504 : 503
|
|
1536
|
+
);
|
|
1537
|
+
} finally {
|
|
1538
|
+
clearTimeout(timer);
|
|
1539
|
+
}
|
|
967
1540
|
}
|
|
968
1541
|
function hostTunnel(hostTunnels, hostId) {
|
|
969
1542
|
const tunnel = hostTunnels.get(hostId);
|
|
@@ -984,12 +1557,30 @@ async function authorizeHostTunnel(options, bunOptions, request, input) {
|
|
|
984
1557
|
}
|
|
985
1558
|
function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
986
1559
|
const hostTunnels = /* @__PURE__ */ new Map();
|
|
1560
|
+
const relayClients = /* @__PURE__ */ new Set();
|
|
987
1561
|
const hostTunnelRegistrations = /* @__PURE__ */ new Map();
|
|
1562
|
+
const directHttpLimiter = createDirectHttpLimiter(bunOptions.directHttpConcurrency);
|
|
1563
|
+
const hostTunnelStateDebounce = /* @__PURE__ */ new Map();
|
|
1564
|
+
const fragmentationStats = createFragmentationStats();
|
|
1565
|
+
const slowConsumerStats = createSlowConsumerStats();
|
|
1566
|
+
const telemetry = {
|
|
1567
|
+
getFragmentationStats: () => readFragmentationStats(fragmentationStats),
|
|
1568
|
+
resetFragmentationStats: () => resetFragmentationStats(fragmentationStats),
|
|
1569
|
+
getSlowConsumerStats: () => readSlowConsumerStats(slowConsumerStats),
|
|
1570
|
+
resetSlowConsumerStats: () => resetSlowConsumerStats(slowConsumerStats)
|
|
1571
|
+
};
|
|
988
1572
|
let draining = false;
|
|
989
1573
|
const drainController = {
|
|
990
1574
|
isDraining: () => draining,
|
|
991
1575
|
setDraining: (value) => {
|
|
992
1576
|
draining = value;
|
|
1577
|
+
if (!value) return;
|
|
1578
|
+
for (const tunnel of hostTunnels.values()) {
|
|
1579
|
+
closeWebSocket(tunnel, 1012, "Workspace relay is draining", 1012);
|
|
1580
|
+
}
|
|
1581
|
+
for (const client of relayClients) {
|
|
1582
|
+
closeWebSocket(client, 1012, "Workspace relay is draining", 1012);
|
|
1583
|
+
}
|
|
993
1584
|
},
|
|
994
1585
|
pendingCount: () => {
|
|
995
1586
|
let total = 0;
|
|
@@ -1012,8 +1603,8 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1012
1603
|
}
|
|
1013
1604
|
};
|
|
1014
1605
|
const mergedMetricsSources = {
|
|
1015
|
-
fragmentation: options.metricsSources?.fragmentation ?? getFragmentationStats,
|
|
1016
|
-
slowConsumer: options.metricsSources?.slowConsumer ?? getSlowConsumerStats,
|
|
1606
|
+
fragmentation: options.metricsSources?.fragmentation ?? telemetry.getFragmentationStats,
|
|
1607
|
+
slowConsumer: options.metricsSources?.slowConsumer ?? telemetry.getSlowConsumerStats,
|
|
1017
1608
|
drainPending: options.metricsSources?.drainPending ?? drainController.pendingCount
|
|
1018
1609
|
};
|
|
1019
1610
|
const requestRemoteAddress = /* @__PURE__ */ new WeakMap();
|
|
@@ -1027,7 +1618,7 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1027
1618
|
function getRegistrationTracker(hostId) {
|
|
1028
1619
|
let tracker = hostTunnelRegistrations.get(hostId);
|
|
1029
1620
|
if (!tracker) {
|
|
1030
|
-
tracker = {
|
|
1621
|
+
tracker = { recent: [] };
|
|
1031
1622
|
hostTunnelRegistrations.set(hostId, tracker);
|
|
1032
1623
|
}
|
|
1033
1624
|
return tracker;
|
|
@@ -1048,6 +1639,12 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1048
1639
|
if (ip) requestRemoteAddress.set(request, ip);
|
|
1049
1640
|
} catch {
|
|
1050
1641
|
}
|
|
1642
|
+
if (url.pathname === "/health") {
|
|
1643
|
+
return Response.json(
|
|
1644
|
+
draining ? { ok: false, service: "workspace-relay", draining: true } : { ok: true, service: "workspace-relay" },
|
|
1645
|
+
{ status: draining ? 503 : 200 }
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1051
1648
|
if (draining && websocketRequest(request) && hostId) {
|
|
1052
1649
|
return jsonError(
|
|
1053
1650
|
"relay_draining",
|
|
@@ -1077,13 +1674,6 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1077
1674
|
const tracker = getRegistrationTracker(hostId);
|
|
1078
1675
|
const now = Date.now();
|
|
1079
1676
|
pruneReconnects(tracker, now);
|
|
1080
|
-
if (tracker.current >= HOST_TUNNEL_REGISTRATION_CONCURRENT_CAP) {
|
|
1081
|
-
return jsonError(
|
|
1082
|
-
"too_many_concurrent_host_tunnels",
|
|
1083
|
-
"Too many concurrent host-tunnel registrations for this host",
|
|
1084
|
-
429
|
|
1085
|
-
);
|
|
1086
|
-
}
|
|
1087
1677
|
if (tracker.recent.length >= HOST_TUNNEL_REGISTRATION_RECONNECT_CAP) {
|
|
1088
1678
|
return jsonError(
|
|
1089
1679
|
"too_many_host_tunnel_reconnects",
|
|
@@ -1098,10 +1688,10 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1098
1688
|
workspaceIds,
|
|
1099
1689
|
pending: /* @__PURE__ */ new Map(),
|
|
1100
1690
|
channels: /* @__PURE__ */ new Map(),
|
|
1691
|
+
missedPongs: 0,
|
|
1101
1692
|
messageBuffer: ""
|
|
1102
1693
|
}
|
|
1103
1694
|
})) {
|
|
1104
|
-
tracker.current += 1;
|
|
1105
1695
|
tracker.recent.push(now);
|
|
1106
1696
|
return;
|
|
1107
1697
|
}
|
|
@@ -1114,32 +1704,45 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1114
1704
|
headers: relayCorsHeaders(request)
|
|
1115
1705
|
});
|
|
1116
1706
|
}
|
|
1117
|
-
const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
|
|
1118
|
-
if (!relay.ok) return relay.response;
|
|
1119
|
-
if (!websocketRequest(request) && relay.request.target.access === "user-hosted") {
|
|
1120
|
-
const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId);
|
|
1121
|
-
return tunnel ? await tunnelHttpRequest({
|
|
1122
|
-
ws: tunnel,
|
|
1123
|
-
request,
|
|
1124
|
-
workspaceId: relay.request.target.workspaceId,
|
|
1125
|
-
path: `${relay.request.path}${url.search}`,
|
|
1126
|
-
relayHostToken: relay.request.relayHostToken,
|
|
1127
|
-
slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
|
|
1128
|
-
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
|
|
1129
|
-
}) : new Response("User-hosted workspace is offline", { status: 503 });
|
|
1130
|
-
}
|
|
1131
1707
|
if (!websocketRequest(request)) {
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1708
|
+
const trace = createWorkspaceRelayTrace();
|
|
1709
|
+
const response = await trace.span("relay-total", async () => {
|
|
1710
|
+
const relay2 = await authorizeWorkspaceRelayRequest(options, request, workspaceId, trace);
|
|
1711
|
+
if (!relay2.ok) return relay2.response;
|
|
1712
|
+
if (relay2.request.target.access === "user-hosted") {
|
|
1713
|
+
const tunnel = hostTunnel(hostTunnels, relay2.request.target.hostId);
|
|
1714
|
+
return tunnel ? await trace.span("tunnel-http", async () => await tunnelHttpRequest({
|
|
1715
|
+
ws: tunnel,
|
|
1716
|
+
request,
|
|
1717
|
+
workspaceId: relay2.request.target.workspaceId,
|
|
1718
|
+
path: `${relay2.request.path}${url.search}`,
|
|
1719
|
+
relayHostToken: relay2.request.relayHostToken,
|
|
1720
|
+
slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
|
|
1721
|
+
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
|
|
1722
|
+
slowConsumerStats,
|
|
1723
|
+
requestBodyMaxBytes: bunOptions.tunnelRequestBodyMaxBytes ?? TUNNEL_REQUEST_BODY_MAX_BYTES_DEFAULT,
|
|
1724
|
+
responseTimeoutMs: bunOptions.tunnelHttpResponseTimeoutMs ?? TUNNEL_HTTP_RESPONSE_TIMEOUT_MS_DEFAULT
|
|
1725
|
+
})) : new Response("User-hosted workspace is offline", { status: 503 });
|
|
1726
|
+
}
|
|
1727
|
+
return await directHttpRequest({
|
|
1728
|
+
request,
|
|
1729
|
+
targetUrl: workspaceRelayTargetUrl(
|
|
1730
|
+
relay2.request.target,
|
|
1731
|
+
relay2.request.path,
|
|
1732
|
+
url.search
|
|
1733
|
+
).toString(),
|
|
1734
|
+
relayHostToken: relay2.request.relayHostToken,
|
|
1735
|
+
workspaceId: relay2.request.target.workspaceId,
|
|
1736
|
+
upstreamHeaders: relay2.request.target.upstreamHeaders,
|
|
1737
|
+
timeoutMs: bunOptions.directHttpTimeoutMs ?? DIRECT_HTTP_TIMEOUT_MS_DEFAULT,
|
|
1738
|
+
limiter: directHttpLimiter,
|
|
1739
|
+
trace
|
|
1740
|
+
});
|
|
1141
1741
|
});
|
|
1742
|
+
return workspaceRelayTimingResponse(response, trace);
|
|
1142
1743
|
}
|
|
1744
|
+
const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
|
|
1745
|
+
if (!relay.ok) return relay.response;
|
|
1143
1746
|
const originDenied = requireAllowedOrigin(request);
|
|
1144
1747
|
if (originDenied) return originDenied;
|
|
1145
1748
|
if (relay.request.target.access === "user-hosted") {
|
|
@@ -1181,9 +1784,17 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1181
1784
|
relay.request.relayHostToken,
|
|
1182
1785
|
relay.request.target.workspaceId,
|
|
1183
1786
|
// T26: cloud-vm WS upgrade. Cookies pass through (default).
|
|
1184
|
-
{ userHosted: false }
|
|
1787
|
+
{ userHosted: false, upstreamHeaders: relay.request.target.upstreamHeaders }
|
|
1185
1788
|
)),
|
|
1186
|
-
queue: []
|
|
1789
|
+
queue: [],
|
|
1790
|
+
...relayWebSocketTraceEnabled(request) ? {
|
|
1791
|
+
trace: {
|
|
1792
|
+
acceptedAt: performance.now(),
|
|
1793
|
+
queuedFrames: 0,
|
|
1794
|
+
maxQueuedDelayMs: 0,
|
|
1795
|
+
emitted: false
|
|
1796
|
+
}
|
|
1797
|
+
} : {}
|
|
1187
1798
|
}
|
|
1188
1799
|
})) {
|
|
1189
1800
|
return;
|
|
@@ -1196,22 +1807,39 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1196
1807
|
if (ws.data.kind === "host-tunnel") {
|
|
1197
1808
|
const parsed = tunnelMessage(
|
|
1198
1809
|
ws,
|
|
1199
|
-
message
|
|
1810
|
+
message,
|
|
1811
|
+
fragmentationStats
|
|
1200
1812
|
);
|
|
1201
1813
|
if (parsed?.type === "ping") {
|
|
1202
1814
|
options.directory?.recordPong(ws.data.hostId);
|
|
1203
1815
|
ws.send(JSON.stringify(makeTunnelPong(parsed)));
|
|
1204
1816
|
}
|
|
1205
1817
|
if (parsed?.type === "pong") {
|
|
1818
|
+
ws.data.missedPongs = 0;
|
|
1206
1819
|
options.directory?.recordPong(ws.data.hostId);
|
|
1207
1820
|
}
|
|
1821
|
+
if (parsed?.type === "error") {
|
|
1822
|
+
if (parsed.request_id) {
|
|
1823
|
+
const pending = ws.data.pending.get(parsed.request_id);
|
|
1824
|
+
if (pending) {
|
|
1825
|
+
ws.data.pending.delete(parsed.request_id);
|
|
1826
|
+
failPendingHttpResponse({
|
|
1827
|
+
entry: pending,
|
|
1828
|
+
response: jsonError(parsed.code, parsed.message, 502),
|
|
1829
|
+
error: new Error(parsed.message)
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1208
1834
|
if (parsed?.type === "http.response.start") {
|
|
1209
1835
|
const pending = ws.data.pending.get(parsed.request_id);
|
|
1210
1836
|
if (pending) {
|
|
1837
|
+
const response = parsed;
|
|
1211
1838
|
pending.responseStarted = true;
|
|
1839
|
+
if (isEventStream(response.headers)) clearTimeout(pending.timeout);
|
|
1212
1840
|
pending.resolve(new Response(pending.stream, {
|
|
1213
|
-
status:
|
|
1214
|
-
headers: headers(
|
|
1841
|
+
status: response.status,
|
|
1842
|
+
headers: headers(response.headers)
|
|
1215
1843
|
}));
|
|
1216
1844
|
}
|
|
1217
1845
|
}
|
|
@@ -1223,7 +1851,8 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1223
1851
|
requestId: parsed.request_id,
|
|
1224
1852
|
entry,
|
|
1225
1853
|
chunk: decoded(parsed.body_base64),
|
|
1226
|
-
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
|
|
1854
|
+
slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT,
|
|
1855
|
+
slowConsumerStats
|
|
1227
1856
|
});
|
|
1228
1857
|
}
|
|
1229
1858
|
}
|
|
@@ -1256,14 +1885,24 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1256
1885
|
}
|
|
1257
1886
|
if (parsed?.type === "ws.frame") {
|
|
1258
1887
|
const frame = parsed;
|
|
1259
|
-
ws.data.channels.get(frame.channel_id)
|
|
1888
|
+
const channel = ws.data.channels.get(frame.channel_id);
|
|
1889
|
+
if (!channel || channel.readyState !== WebSocket.OPEN) {
|
|
1890
|
+
ws.data.channels.delete(frame.channel_id);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
if (channel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
|
|
1894
|
+
ws.data.channels.delete(frame.channel_id);
|
|
1895
|
+
closeWebSocket(channel, 1011, "Client WebSocket backpressure limit exceeded");
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
channel.send(decodedFrame(frame));
|
|
1260
1899
|
}
|
|
1261
1900
|
if (parsed?.type === "ws.close") {
|
|
1262
1901
|
const close = parsed;
|
|
1263
1902
|
const channel = ws.data.channels.get(close.channel_id);
|
|
1264
1903
|
if (channel) {
|
|
1265
1904
|
ws.data.channels.delete(close.channel_id);
|
|
1266
|
-
channel
|
|
1905
|
+
closeWebSocket(channel, close.code, close.reason);
|
|
1267
1906
|
}
|
|
1268
1907
|
}
|
|
1269
1908
|
return;
|
|
@@ -1274,6 +1913,10 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1274
1913
|
ws.close(1011, "User-hosted tunnel disconnected");
|
|
1275
1914
|
return;
|
|
1276
1915
|
}
|
|
1916
|
+
if (tunnel.bufferedAmount > (bunOptions.webSocketBufferedAmountMaxBytes ?? WS_BUFFERED_AMOUNT_MAX_BYTES_DEFAULT)) {
|
|
1917
|
+
closeWebSocket(ws, 1011, "Host tunnel backpressure limit exceeded");
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1277
1920
|
tunnel.send(JSON.stringify({
|
|
1278
1921
|
type: "ws.frame",
|
|
1279
1922
|
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
@@ -1286,19 +1929,41 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1286
1929
|
ws.data.upstream.send(message);
|
|
1287
1930
|
return;
|
|
1288
1931
|
}
|
|
1289
|
-
ws.data.queue.
|
|
1932
|
+
if (ws.data.queue.length >= (bunOptions.upstreamWebSocketPreOpenQueueMaxFrames ?? UPSTREAM_WS_PRE_OPEN_QUEUE_MAX_FRAMES_DEFAULT)) {
|
|
1933
|
+
closeWebSocket(ws, 1011, "Upstream WebSocket queue limit exceeded");
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
ws.data.queue.push({ payload: message, queuedAt: performance.now() });
|
|
1290
1937
|
},
|
|
1291
1938
|
open(ws) {
|
|
1292
1939
|
if (ws.data.kind === "host-tunnel") {
|
|
1940
|
+
const previous = hostTunnels.get(ws.data.hostId);
|
|
1941
|
+
if (previous && previous !== ws) {
|
|
1942
|
+
cleanupHostTunnelSocket({
|
|
1943
|
+
ws: previous,
|
|
1944
|
+
hostTunnels,
|
|
1945
|
+
hostTunnelStateDebounce,
|
|
1946
|
+
options,
|
|
1947
|
+
bunOptions,
|
|
1948
|
+
disconnectDirectory: false,
|
|
1949
|
+
closeChannels: true
|
|
1950
|
+
});
|
|
1951
|
+
closeWebSocket(previous, 1012, "Host tunnel replaced by a newer connection", 1012);
|
|
1952
|
+
}
|
|
1293
1953
|
hostTunnels.set(ws.data.hostId, ws);
|
|
1294
1954
|
options.directory?.registerHostTunnel({
|
|
1295
1955
|
hostId: ws.data.hostId,
|
|
1296
1956
|
workspaceIds: ws.data.workspaceIds
|
|
1297
1957
|
});
|
|
1298
1958
|
ws.data.heartbeat = setInterval(() => {
|
|
1299
|
-
|
|
1959
|
+
const hostWs = ws;
|
|
1960
|
+
if (hostWs.data.missedPongs > (bunOptions.hostTunnelMaxMissedPongs ?? HOST_TUNNEL_MAX_MISSED_PONGS_DEFAULT)) {
|
|
1961
|
+
closeWebSocket(ws, 1001, "Host tunnel heartbeat timed out", 1001);
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
sendTunnelPing(hostWs);
|
|
1300
1965
|
}, bunOptions.hostTunnelPingIntervalMs ?? 15e3);
|
|
1301
|
-
scheduleHostTunnelStateChange(options, {
|
|
1966
|
+
scheduleHostTunnelStateChange(hostTunnelStateDebounce, options, {
|
|
1302
1967
|
hostId: ws.data.hostId,
|
|
1303
1968
|
workspaceId: ws.data.workspaceIds[0],
|
|
1304
1969
|
path: `/host-tunnels/${ws.data.hostId}`,
|
|
@@ -1314,6 +1979,10 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1314
1979
|
return;
|
|
1315
1980
|
}
|
|
1316
1981
|
tunnel.data.channels.set(ws.data.channelId, ws);
|
|
1982
|
+
if (tunnel.readyState !== WebSocket.OPEN) {
|
|
1983
|
+
closeWebSocket(ws, 1011, "User-hosted tunnel disconnected");
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1317
1986
|
tunnel.send(JSON.stringify({
|
|
1318
1987
|
type: "ws.open",
|
|
1319
1988
|
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
@@ -1332,73 +2001,95 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1332
2001
|
}));
|
|
1333
2002
|
return;
|
|
1334
2003
|
}
|
|
2004
|
+
relayClients.add(ws);
|
|
1335
2005
|
const data = ws.data;
|
|
1336
|
-
const
|
|
2006
|
+
const UpstreamWebSocket = bunOptions.upstreamWebSocket ?? WebSocket;
|
|
2007
|
+
if (data.trace) data.trace.upstreamStartedAt = performance.now();
|
|
2008
|
+
const upstream = new UpstreamWebSocket(data.upstreamUrl, {
|
|
1337
2009
|
headers: data.headers
|
|
1338
2010
|
});
|
|
1339
2011
|
upstream.binaryType = "arraybuffer";
|
|
1340
2012
|
data.upstream = upstream;
|
|
2013
|
+
data.upstreamOpenTimer = setTimeout(() => {
|
|
2014
|
+
if (upstream.readyState === WebSocket.OPEN) return;
|
|
2015
|
+
closeWebSocket(upstream, 1001, "Upstream WebSocket open timed out", 1001);
|
|
2016
|
+
closeWebSocket(ws, 1011, "Upstream WebSocket open timed out");
|
|
2017
|
+
}, bunOptions.upstreamWebSocketOpenTimeoutMs ?? UPSTREAM_WS_OPEN_TIMEOUT_MS_DEFAULT);
|
|
1341
2018
|
upstream.onopen = () => {
|
|
1342
|
-
|
|
2019
|
+
const openedAt = performance.now();
|
|
2020
|
+
if (data.upstreamOpenTimer) {
|
|
2021
|
+
clearTimeout(data.upstreamOpenTimer);
|
|
2022
|
+
data.upstreamOpenTimer = void 0;
|
|
2023
|
+
}
|
|
2024
|
+
if (data.trace) {
|
|
2025
|
+
data.trace.upstreamOpenMs = openedAt - (data.trace.upstreamStartedAt ?? data.trace.acceptedAt);
|
|
2026
|
+
data.trace.queuedFrames = data.queue.length;
|
|
2027
|
+
data.trace.maxQueuedDelayMs = data.queue.reduce(
|
|
2028
|
+
(max, item) => Math.max(max, openedAt - item.queuedAt),
|
|
2029
|
+
0
|
|
2030
|
+
);
|
|
2031
|
+
sendRelayWebSocketTrace(ws);
|
|
2032
|
+
}
|
|
2033
|
+
for (const item of data.queue.splice(0)) upstream.send(item.payload);
|
|
1343
2034
|
};
|
|
1344
2035
|
upstream.onmessage = (event) => {
|
|
1345
2036
|
const payload = relayWebSocketPayload(event.data);
|
|
1346
2037
|
if (payload !== void 0) ws.send(payload);
|
|
1347
2038
|
};
|
|
1348
2039
|
upstream.onclose = (event) => {
|
|
1349
|
-
|
|
2040
|
+
if (data.upstreamOpenTimer) {
|
|
2041
|
+
clearTimeout(data.upstreamOpenTimer);
|
|
2042
|
+
data.upstreamOpenTimer = void 0;
|
|
2043
|
+
}
|
|
2044
|
+
closeWebSocket(ws, event.code, upstreamCloseReason(event));
|
|
1350
2045
|
};
|
|
1351
2046
|
upstream.onerror = () => {
|
|
1352
|
-
|
|
2047
|
+
if (data.upstreamOpenTimer) {
|
|
2048
|
+
clearTimeout(data.upstreamOpenTimer);
|
|
2049
|
+
data.upstreamOpenTimer = void 0;
|
|
2050
|
+
}
|
|
2051
|
+
closeWebSocket(ws, 1011, "Upstream WebSocket connection failed");
|
|
1353
2052
|
};
|
|
1354
2053
|
},
|
|
1355
2054
|
close(ws, code, reason) {
|
|
1356
2055
|
if (ws.data.kind === "host-tunnel") {
|
|
1357
|
-
|
|
1358
|
-
|
|
2056
|
+
cleanupHostTunnelSocket({
|
|
2057
|
+
ws,
|
|
2058
|
+
hostTunnels,
|
|
2059
|
+
hostTunnelStateDebounce,
|
|
2060
|
+
options,
|
|
2061
|
+
bunOptions,
|
|
2062
|
+
disconnectDirectory: true,
|
|
2063
|
+
closeChannels: true
|
|
2064
|
+
});
|
|
1359
2065
|
const tracker = hostTunnelRegistrations.get(ws.data.hostId);
|
|
1360
2066
|
if (tracker) {
|
|
1361
|
-
tracker.current = Math.max(0, tracker.current - 1);
|
|
1362
2067
|
pruneReconnects(tracker, Date.now());
|
|
1363
|
-
if (tracker.
|
|
1364
|
-
hostTunnelRegistrations.delete(ws.data.hostId);
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
for (const pending of ws.data.pending.values()) {
|
|
1368
|
-
clearTimeout(pending.timeout);
|
|
1369
|
-
pending.reject(new Error("User-hosted tunnel disconnected"));
|
|
2068
|
+
if (tracker.recent.length === 0) hostTunnelRegistrations.delete(ws.data.hostId);
|
|
1370
2069
|
}
|
|
1371
|
-
ws.data.pending.clear();
|
|
1372
|
-
for (const channel of ws.data.channels.values()) {
|
|
1373
|
-
channel.close(1011, "User-hosted tunnel disconnected");
|
|
1374
|
-
}
|
|
1375
|
-
ws.data.channels.clear();
|
|
1376
|
-
options.directory?.disconnectHost(ws.data.hostId);
|
|
1377
|
-
scheduleHostTunnelStateChange(options, {
|
|
1378
|
-
hostId: ws.data.hostId,
|
|
1379
|
-
workspaceId: ws.data.workspaceIds[0],
|
|
1380
|
-
path: `/host-tunnels/${ws.data.hostId}`,
|
|
1381
|
-
state: "disconnected",
|
|
1382
|
-
debounceMs: bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
|
|
1383
|
-
});
|
|
1384
2070
|
return;
|
|
1385
2071
|
}
|
|
1386
2072
|
if (ws.data.kind === "user-hosted-client") {
|
|
1387
2073
|
const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
|
|
1388
2074
|
tunnel?.data.channels.delete(ws.data.channelId);
|
|
1389
|
-
tunnel?.
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
2075
|
+
if (tunnel?.readyState === WebSocket.OPEN) {
|
|
2076
|
+
tunnel.send(JSON.stringify({
|
|
2077
|
+
type: "ws.close",
|
|
2078
|
+
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
2079
|
+
channel_id: ws.data.channelId,
|
|
2080
|
+
code: safeCloseCode(code, 1e3),
|
|
2081
|
+
reason
|
|
2082
|
+
}));
|
|
2083
|
+
}
|
|
1396
2084
|
return;
|
|
1397
2085
|
}
|
|
1398
|
-
|
|
2086
|
+
relayClients.delete(ws);
|
|
2087
|
+
if (ws.data.upstreamOpenTimer) clearTimeout(ws.data.upstreamOpenTimer);
|
|
2088
|
+
if (ws.data.upstream) closeWebSocket(ws.data.upstream, code, reason, 1e3);
|
|
1399
2089
|
}
|
|
1400
2090
|
},
|
|
1401
|
-
drain: drainController
|
|
2091
|
+
drain: drainController,
|
|
2092
|
+
telemetry
|
|
1402
2093
|
};
|
|
1403
2094
|
}
|
|
1404
2095
|
|
|
@@ -1600,10 +2291,17 @@ function startSyntheticProbe(options) {
|
|
|
1600
2291
|
}
|
|
1601
2292
|
|
|
1602
2293
|
// src/main.ts
|
|
2294
|
+
var BUN_TARGET_CACHE_TTL_MS_DEFAULT = 3e4;
|
|
2295
|
+
var BUN_REVOCATION_CACHE_TTL_MS_DEFAULT = 1e4;
|
|
2296
|
+
var BUN_RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT = 1e4;
|
|
1603
2297
|
function clean(input) {
|
|
1604
2298
|
const value = input?.trim();
|
|
1605
2299
|
return value ? value : void 0;
|
|
1606
2300
|
}
|
|
2301
|
+
function positiveInteger(input) {
|
|
2302
|
+
const parsed = Number(clean(input));
|
|
2303
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : void 0;
|
|
2304
|
+
}
|
|
1607
2305
|
function pem(input) {
|
|
1608
2306
|
return clean(input)?.replaceAll("\\n", "\n");
|
|
1609
2307
|
}
|
|
@@ -1710,25 +2408,31 @@ function parseAuditAcceptSampleRate(env) {
|
|
|
1710
2408
|
function parseMetricsToken(env) {
|
|
1711
2409
|
return clean(env.CLAXEDO_RELAY_METRICS_TOKEN);
|
|
1712
2410
|
}
|
|
1713
|
-
function
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
return
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
const result = await inner(args);
|
|
1724
|
-
cache.set(args.jti, { result, expiresAt: at + ttlMs });
|
|
1725
|
-
return result;
|
|
2411
|
+
function runtimeAccessTokenCacheTtlMsFromEnv(env) {
|
|
2412
|
+
return positiveInteger(env.CLAXEDO_RELAY_RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS) ?? BUN_RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS_DEFAULT;
|
|
2413
|
+
}
|
|
2414
|
+
function directHttpConcurrencyFromEnv(env) {
|
|
2415
|
+
return positiveInteger(env.CLAXEDO_RELAY_DIRECT_HTTP_CONCURRENCY);
|
|
2416
|
+
}
|
|
2417
|
+
function resolverClientCacheOptionsFromEnv(env) {
|
|
2418
|
+
return {
|
|
2419
|
+
targetCacheTtlMs: positiveInteger(env.CLAXEDO_RELAY_TARGET_CACHE_TTL_MS) ?? BUN_TARGET_CACHE_TTL_MS_DEFAULT,
|
|
2420
|
+
revocationCacheTtlMs: positiveInteger(env.CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS) ?? BUN_REVOCATION_CACHE_TTL_MS_DEFAULT
|
|
1726
2421
|
};
|
|
1727
2422
|
}
|
|
1728
|
-
function createResolverClient(baseUrl, token) {
|
|
2423
|
+
function createResolverClient(baseUrl, token, options = {}) {
|
|
1729
2424
|
const headers2 = { accept: "application/json" };
|
|
1730
2425
|
if (token) headers2.authorization = `Bearer ${token}`;
|
|
1731
2426
|
const root = baseUrl.replace(/\/+$/, "");
|
|
2427
|
+
const targetUncached = async (args) => {
|
|
2428
|
+
const url = new URL(`${root}/target`);
|
|
2429
|
+
url.searchParams.set("workspaceId", args.workspaceId);
|
|
2430
|
+
url.searchParams.set("hostId", args.hostId);
|
|
2431
|
+
const res = await fetch(url, { headers: headers2 });
|
|
2432
|
+
if (res.status === 404) return void 0;
|
|
2433
|
+
if (!res.ok) throw new Error(`relay target resolver failed: ${res.status} ${await res.text()}`);
|
|
2434
|
+
return await res.json();
|
|
2435
|
+
};
|
|
1732
2436
|
const revocationUncached = async (args) => {
|
|
1733
2437
|
const url = new URL(`${root}/revocation`);
|
|
1734
2438
|
url.searchParams.set("jti", args.jti);
|
|
@@ -1744,17 +2448,14 @@ function createResolverClient(baseUrl, token) {
|
|
|
1744
2448
|
}
|
|
1745
2449
|
return await res.json();
|
|
1746
2450
|
};
|
|
1747
|
-
const
|
|
2451
|
+
const target = createCachedTargetClient(targetUncached, {
|
|
2452
|
+
ttlMs: options.targetCacheTtlMs ?? BUN_TARGET_CACHE_TTL_MS_DEFAULT
|
|
2453
|
+
});
|
|
2454
|
+
const revocation = createCachedRevocationClient(revocationUncached, {
|
|
2455
|
+
ttlMs: options.revocationCacheTtlMs ?? BUN_REVOCATION_CACHE_TTL_MS_DEFAULT
|
|
2456
|
+
});
|
|
1748
2457
|
return {
|
|
1749
|
-
|
|
1750
|
-
const url = new URL(`${root}/target`);
|
|
1751
|
-
url.searchParams.set("workspaceId", workspaceId);
|
|
1752
|
-
url.searchParams.set("hostId", hostId);
|
|
1753
|
-
const res = await fetch(url, { headers: headers2 });
|
|
1754
|
-
if (res.status === 404) return void 0;
|
|
1755
|
-
if (!res.ok) throw new Error(`relay target resolver failed: ${res.status} ${await res.text()}`);
|
|
1756
|
-
return await res.json();
|
|
1757
|
-
},
|
|
2458
|
+
target: (workspaceId, hostId) => target({ workspaceId, hostId }),
|
|
1758
2459
|
revocation
|
|
1759
2460
|
};
|
|
1760
2461
|
}
|
|
@@ -1806,6 +2507,47 @@ function installShutdownDrainHandler(options) {
|
|
|
1806
2507
|
isShuttingDown: () => shuttingDown
|
|
1807
2508
|
};
|
|
1808
2509
|
}
|
|
2510
|
+
function installFatalProcessHandlers(options) {
|
|
2511
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
2512
|
+
const log = options.log ?? (() => {
|
|
2513
|
+
});
|
|
2514
|
+
const register = options.register ?? true;
|
|
2515
|
+
let fatal = false;
|
|
2516
|
+
let pending;
|
|
2517
|
+
async function trigger(error, source) {
|
|
2518
|
+
if (pending) return pending;
|
|
2519
|
+
fatal = true;
|
|
2520
|
+
pending = (async () => {
|
|
2521
|
+
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
2522
|
+
log(`[workspace-relay] fatal ${source}: ${message}`);
|
|
2523
|
+
options.drain.setDraining(true);
|
|
2524
|
+
try {
|
|
2525
|
+
await options.stopServer?.();
|
|
2526
|
+
} catch (err) {
|
|
2527
|
+
log(`[workspace-relay] fatal stopServer failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2528
|
+
}
|
|
2529
|
+
try {
|
|
2530
|
+
options.directory.dispose();
|
|
2531
|
+
} catch (err) {
|
|
2532
|
+
log(`[workspace-relay] fatal directory.dispose() failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2533
|
+
}
|
|
2534
|
+
exit(1);
|
|
2535
|
+
})();
|
|
2536
|
+
return pending;
|
|
2537
|
+
}
|
|
2538
|
+
if (register) {
|
|
2539
|
+
process.on("uncaughtException", (err) => {
|
|
2540
|
+
void trigger(err, "uncaughtException");
|
|
2541
|
+
});
|
|
2542
|
+
process.on("unhandledRejection", (err) => {
|
|
2543
|
+
void trigger(err, "unhandledRejection");
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
return {
|
|
2547
|
+
trigger,
|
|
2548
|
+
isFatal: () => fatal
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
1809
2551
|
async function main() {
|
|
1810
2552
|
const validation = validateProductionEnv({
|
|
1811
2553
|
NODE_ENV: process.env.NODE_ENV,
|
|
@@ -1850,7 +2592,10 @@ async function main() {
|
|
|
1850
2592
|
const relayHostSigningKey = relayHostMaterial.privateKey;
|
|
1851
2593
|
const relayHostPublicKeys = [relayHostMaterial.current];
|
|
1852
2594
|
if (relayHostMaterial.next) relayHostPublicKeys.push(relayHostMaterial.next);
|
|
1853
|
-
const resolver = createResolverClient(resolverUrl, resolverToken
|
|
2595
|
+
const resolver = createResolverClient(resolverUrl, resolverToken, resolverClientCacheOptionsFromEnv({
|
|
2596
|
+
CLAXEDO_RELAY_TARGET_CACHE_TTL_MS: process.env.CLAXEDO_RELAY_TARGET_CACHE_TTL_MS,
|
|
2597
|
+
CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS: process.env.CLAXEDO_RELAY_REVOCATION_CACHE_TTL_MS
|
|
2598
|
+
}));
|
|
1854
2599
|
const directory = createWorkspaceRelayDirectory();
|
|
1855
2600
|
const auditAcceptSampleRate = parseAuditAcceptSampleRate({
|
|
1856
2601
|
CLAXEDO_RELAY_AUDIT_ACCEPT_SAMPLE_RATE: process.env.CLAXEDO_RELAY_AUDIT_ACCEPT_SAMPLE_RATE,
|
|
@@ -1859,8 +2604,15 @@ async function main() {
|
|
|
1859
2604
|
const metricsToken = parseMetricsToken({
|
|
1860
2605
|
CLAXEDO_RELAY_METRICS_TOKEN: process.env.CLAXEDO_RELAY_METRICS_TOKEN
|
|
1861
2606
|
});
|
|
2607
|
+
const runtimeAccessTokenCacheTtlMs2 = runtimeAccessTokenCacheTtlMsFromEnv({
|
|
2608
|
+
CLAXEDO_RELAY_RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS: process.env.CLAXEDO_RELAY_RUNTIME_ACCESS_TOKEN_CACHE_TTL_MS
|
|
2609
|
+
});
|
|
2610
|
+
const directHttpConcurrency = directHttpConcurrencyFromEnv({
|
|
2611
|
+
CLAXEDO_RELAY_DIRECT_HTTP_CONCURRENCY: process.env.CLAXEDO_RELAY_DIRECT_HTTP_CONCURRENCY
|
|
2612
|
+
});
|
|
1862
2613
|
const handler = createWorkspaceRelayBun({
|
|
1863
2614
|
runtimeAccessKey,
|
|
2615
|
+
runtimeAccessTokenCacheTtlMs: runtimeAccessTokenCacheTtlMs2,
|
|
1864
2616
|
relayHostSigningKey,
|
|
1865
2617
|
relayHostAlgorithm: "EdDSA",
|
|
1866
2618
|
relayHostPublicKeys,
|
|
@@ -1881,6 +2633,8 @@ async function main() {
|
|
|
1881
2633
|
);
|
|
1882
2634
|
}
|
|
1883
2635
|
}
|
|
2636
|
+
}, {
|
|
2637
|
+
...directHttpConcurrency ? { directHttpConcurrency } : {}
|
|
1884
2638
|
});
|
|
1885
2639
|
const server = Bun.serve({
|
|
1886
2640
|
port,
|
|
@@ -1914,6 +2668,15 @@ async function main() {
|
|
|
1914
2668
|
syntheticProbe?.stop();
|
|
1915
2669
|
}
|
|
1916
2670
|
});
|
|
2671
|
+
installFatalProcessHandlers({
|
|
2672
|
+
drain: handler.drain,
|
|
2673
|
+
directory,
|
|
2674
|
+
log: (message) => console.error(message),
|
|
2675
|
+
stopServer: async () => {
|
|
2676
|
+
server.stop(true);
|
|
2677
|
+
syntheticProbe?.stop();
|
|
2678
|
+
}
|
|
2679
|
+
});
|
|
1917
2680
|
void exportPKCS8;
|
|
1918
2681
|
}
|
|
1919
2682
|
if (import.meta.main) {
|
|
@@ -1924,10 +2687,16 @@ if (import.meta.main) {
|
|
|
1924
2687
|
}
|
|
1925
2688
|
export {
|
|
1926
2689
|
createCachedRevocationClient,
|
|
2690
|
+
createCachedTargetClient,
|
|
2691
|
+
createResolverClient,
|
|
2692
|
+
directHttpConcurrencyFromEnv,
|
|
2693
|
+
installFatalProcessHandlers,
|
|
1927
2694
|
installShutdownDrainHandler,
|
|
1928
2695
|
loadRelayHostKeyMaterial,
|
|
1929
2696
|
loadRuntimeAccessKeyOrJwks,
|
|
1930
2697
|
parseAuditAcceptSampleRate,
|
|
1931
2698
|
parseMetricsToken,
|
|
2699
|
+
resolverClientCacheOptionsFromEnv,
|
|
2700
|
+
runtimeAccessTokenCacheTtlMsFromEnv,
|
|
1932
2701
|
validateProductionEnv
|
|
1933
2702
|
};
|