@claxedo/workspace-relay 0.5.1 → 0.6.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/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/auth.d.ts +1 -0
- package/dist/cloudflare.d.ts +5 -0
- package/dist/cors-origins.d.ts +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +192 -55
- package/dist/main.d.ts +49 -0
- package/dist/main.mjs +40181 -173
- package/dist/server.d.ts +2 -1
- package/dist/worker-h2.d.ts +9 -0
- package/dist/worker.d.ts +2 -0
- package/package.json +19 -3
package/dist/index.mjs
CHANGED
|
@@ -73,6 +73,7 @@ async function verifyJwt(token, key, input) {
|
|
|
73
73
|
algorithms: [...algorithms]
|
|
74
74
|
});
|
|
75
75
|
} catch (err) {
|
|
76
|
+
if (err instanceof errors.JWKSTimeout) throw err;
|
|
76
77
|
if (err instanceof errors.JOSEError) {
|
|
77
78
|
throw new WorkspaceRelayAuthError("invalid_relay_token", "Relay token is invalid");
|
|
78
79
|
}
|
|
@@ -221,6 +222,7 @@ function hostTunnelClaims(payload) {
|
|
|
221
222
|
|
|
222
223
|
// src/bun.ts
|
|
223
224
|
import {
|
|
225
|
+
makeTunnelPing,
|
|
224
226
|
makeTunnelPong,
|
|
225
227
|
TUNNEL_PROTOCOL_VERSION,
|
|
226
228
|
validateTunnelMessage
|
|
@@ -231,6 +233,48 @@ import { Hono } from "hono";
|
|
|
231
233
|
import { cors } from "hono/cors";
|
|
232
234
|
import { exportJWK } from "jose";
|
|
233
235
|
|
|
236
|
+
// src/cors-origins.ts
|
|
237
|
+
var DEFAULT_RELAY_APP_ORIGINS = [
|
|
238
|
+
"http://localhost:*",
|
|
239
|
+
"http://127.0.0.1:*",
|
|
240
|
+
"https://opencode.ai",
|
|
241
|
+
"https://*.opencode.ai",
|
|
242
|
+
"https://claxedo.com",
|
|
243
|
+
"https://*.claxedo.com"
|
|
244
|
+
];
|
|
245
|
+
function parseAllowedOrigins(raw) {
|
|
246
|
+
const entries = (raw ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
247
|
+
return entries.length ? entries : void 0;
|
|
248
|
+
}
|
|
249
|
+
function createOriginMatcher(patterns) {
|
|
250
|
+
const exact = /* @__PURE__ */ new Set();
|
|
251
|
+
const httpsSuffixes = [];
|
|
252
|
+
const httpPortPrefixes = [];
|
|
253
|
+
for (const pattern of patterns) {
|
|
254
|
+
if (pattern.startsWith("https://*.")) {
|
|
255
|
+
httpsSuffixes.push(pattern.slice("https://*".length));
|
|
256
|
+
} else if (pattern.startsWith("http://") && pattern.endsWith(":*")) {
|
|
257
|
+
httpPortPrefixes.push(pattern.slice(0, -1));
|
|
258
|
+
} else {
|
|
259
|
+
exact.add(pattern);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return (origin) => {
|
|
263
|
+
if (exact.has(origin)) return true;
|
|
264
|
+
if (origin.startsWith("https://")) {
|
|
265
|
+
return httpsSuffixes.some(
|
|
266
|
+
(suffix) => origin.endsWith(suffix) && origin.length > "https://".length + suffix.length
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (origin.startsWith("http://")) {
|
|
270
|
+
return httpPortPrefixes.some(
|
|
271
|
+
(prefix) => origin.startsWith(prefix) && /^\d+$/.test(origin.slice(prefix.length))
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
234
278
|
// src/http.ts
|
|
235
279
|
function bearerToken(header) {
|
|
236
280
|
if (!header) return;
|
|
@@ -374,9 +418,24 @@ function targetUrl(target, path, search) {
|
|
|
374
418
|
url.search = search;
|
|
375
419
|
return url;
|
|
376
420
|
}
|
|
377
|
-
|
|
421
|
+
var RELAY_VIEWER_DENIED_PATH = /^\/api\/wr\/pty(?:\/|$)/;
|
|
422
|
+
function relayRuntimePath(request) {
|
|
423
|
+
return new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/");
|
|
424
|
+
}
|
|
425
|
+
function relayAuthorizationPath(path) {
|
|
426
|
+
try {
|
|
427
|
+
return decodeURIComponent(path);
|
|
428
|
+
} catch {
|
|
429
|
+
return void 0;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function roleAllowsRelayRequest(role, method, path) {
|
|
378
433
|
if (role === "owner" || role === "admin" || role === "editor") return true;
|
|
379
|
-
if (role === "viewer")
|
|
434
|
+
if (role === "viewer") {
|
|
435
|
+
const authorizationPath = relayAuthorizationPath(path);
|
|
436
|
+
if (!authorizationPath || RELAY_VIEWER_DENIED_PATH.test(authorizationPath)) return false;
|
|
437
|
+
return method === "GET" || method === "HEAD" || method === "OPTIONS";
|
|
438
|
+
}
|
|
380
439
|
return false;
|
|
381
440
|
}
|
|
382
441
|
function relayHostTokenCacheTtlMs(options, claims) {
|
|
@@ -645,11 +704,21 @@ async function audit(options, event) {
|
|
|
645
704
|
}
|
|
646
705
|
await options.audit?.(event);
|
|
647
706
|
}
|
|
648
|
-
|
|
707
|
+
var defaultOriginMatcher = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
|
|
708
|
+
var originMatchers = /* @__PURE__ */ new WeakMap();
|
|
709
|
+
function originMatcherFor(options) {
|
|
710
|
+
if (!options.allowedOrigins) return defaultOriginMatcher;
|
|
711
|
+
let matcher = originMatchers.get(options);
|
|
712
|
+
if (!matcher) {
|
|
713
|
+
matcher = createOriginMatcher(options.allowedOrigins);
|
|
714
|
+
originMatchers.set(options, matcher);
|
|
715
|
+
}
|
|
716
|
+
return matcher;
|
|
717
|
+
}
|
|
718
|
+
function denyCorsHeaders(request, originAllowed = defaultOriginMatcher) {
|
|
649
719
|
const origin = request.headers.get("origin");
|
|
650
720
|
if (!origin) return void 0;
|
|
651
|
-
|
|
652
|
-
if (!allowed) return void 0;
|
|
721
|
+
if (!originAllowed(origin)) return void 0;
|
|
653
722
|
return {
|
|
654
723
|
"access-control-allow-origin": origin,
|
|
655
724
|
"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",
|
|
@@ -671,7 +740,7 @@ async function deny(options, input) {
|
|
|
671
740
|
});
|
|
672
741
|
return Response.json(errorBody(input.code, input.message), {
|
|
673
742
|
status: input.status,
|
|
674
|
-
headers: denyCorsHeaders(input.request)
|
|
743
|
+
headers: denyCorsHeaders(input.request, originMatcherFor(options))
|
|
675
744
|
});
|
|
676
745
|
}
|
|
677
746
|
async function authorizeWorkspaceRelayRequest(options, request, workspaceId, trace) {
|
|
@@ -710,7 +779,8 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
710
779
|
})
|
|
711
780
|
};
|
|
712
781
|
}
|
|
713
|
-
|
|
782
|
+
const path = relayRuntimePath(request);
|
|
783
|
+
if (!roleAllowsRelayRequest(claims.role, request.method, path)) {
|
|
714
784
|
return {
|
|
715
785
|
ok: false,
|
|
716
786
|
code: "relay_role_denied",
|
|
@@ -767,7 +837,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
767
837
|
claims,
|
|
768
838
|
target,
|
|
769
839
|
relayHostToken,
|
|
770
|
-
path
|
|
840
|
+
path
|
|
771
841
|
}
|
|
772
842
|
};
|
|
773
843
|
} catch (err) {
|
|
@@ -784,7 +854,7 @@ async function authorizeWorkspaceRelayRequest(options, request, workspaceId, tra
|
|
|
784
854
|
};
|
|
785
855
|
}
|
|
786
856
|
}
|
|
787
|
-
async function forwardWorkspaceRelayRequest(request, target, relayHostToken, path, requestFetch, timeoutMs, trace) {
|
|
857
|
+
async function forwardWorkspaceRelayRequest(request, target, relayHostToken, path, requestFetch, timeoutMs, trace, originAllowed = defaultOriginMatcher) {
|
|
788
858
|
const controller = new AbortController();
|
|
789
859
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
790
860
|
const span = (name, run) => trace ? trace.span(name, run) : run();
|
|
@@ -803,7 +873,7 @@ async function forwardWorkspaceRelayRequest(request, target, relayHostToken, pat
|
|
|
803
873
|
headers2.delete("access-control-allow-methods");
|
|
804
874
|
headers2.delete("access-control-expose-headers");
|
|
805
875
|
headers2.delete("access-control-max-age");
|
|
806
|
-
for (const [key, value] of Object.entries(denyCorsHeaders(request) ?? {})) {
|
|
876
|
+
for (const [key, value] of Object.entries(denyCorsHeaders(request, originAllowed) ?? {})) {
|
|
807
877
|
headers2.set(key, value);
|
|
808
878
|
}
|
|
809
879
|
return new Response(upstream.body, {
|
|
@@ -818,7 +888,7 @@ async function forwardWorkspaceRelayRequest(request, target, relayHostToken, pat
|
|
|
818
888
|
aborted ? "upstream_timeout" : "upstream_unavailable",
|
|
819
889
|
aborted ? "Workspace upstream timed out" : "Workspace upstream is unavailable"
|
|
820
890
|
),
|
|
821
|
-
{ status: aborted ? 504 : 503, headers: denyCorsHeaders(request) }
|
|
891
|
+
{ status: aborted ? 504 : 503, headers: denyCorsHeaders(request, originAllowed) }
|
|
822
892
|
);
|
|
823
893
|
} finally {
|
|
824
894
|
clearTimeout(timer);
|
|
@@ -893,14 +963,11 @@ function buildMetricsBody(options) {
|
|
|
893
963
|
}
|
|
894
964
|
function createWorkspaceRelay(options) {
|
|
895
965
|
const app = new Hono();
|
|
966
|
+
const originAllowed = originMatcherFor(options);
|
|
896
967
|
app.use("*", cors({
|
|
897
968
|
origin: (origin) => {
|
|
898
969
|
if (!origin) return void 0;
|
|
899
|
-
|
|
900
|
-
if (origin.startsWith("http://127.0.0.1:")) return origin;
|
|
901
|
-
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
|
|
902
|
-
if (/^https:\/\/([a-z0-9-]+\.)*claxedo\.com$/.test(origin)) return origin;
|
|
903
|
-
return void 0;
|
|
970
|
+
return originAllowed(origin) ? origin : void 0;
|
|
904
971
|
},
|
|
905
972
|
allowHeaders: [
|
|
906
973
|
"Accept",
|
|
@@ -980,7 +1047,7 @@ function createWorkspaceRelay(options) {
|
|
|
980
1047
|
if (options.isDraining?.()) {
|
|
981
1048
|
return Response.json(
|
|
982
1049
|
errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
|
|
983
|
-
{ status: 503, headers: denyCorsHeaders(c.req.raw) }
|
|
1050
|
+
{ status: 503, headers: denyCorsHeaders(c.req.raw, originAllowed) }
|
|
984
1051
|
);
|
|
985
1052
|
}
|
|
986
1053
|
const response = await trace.span("relay-total", async () => {
|
|
@@ -993,7 +1060,8 @@ function createWorkspaceRelay(options) {
|
|
|
993
1060
|
relay.request.path,
|
|
994
1061
|
options.fetch ?? fetch,
|
|
995
1062
|
options.forwardTimeoutMs ?? 3e4,
|
|
996
|
-
trace
|
|
1063
|
+
trace,
|
|
1064
|
+
originAllowed
|
|
997
1065
|
);
|
|
998
1066
|
});
|
|
999
1067
|
return workspaceRelayTimingResponse(response, trace);
|
|
@@ -1248,12 +1316,7 @@ function tunnelMessage(ws, input, stats) {
|
|
|
1248
1316
|
function sendTunnelPing(ws) {
|
|
1249
1317
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1250
1318
|
ws.data.missedPongs += 1;
|
|
1251
|
-
ws.send(JSON.stringify(
|
|
1252
|
-
type: "ping",
|
|
1253
|
-
protocol: TUNNEL_PROTOCOL_VERSION,
|
|
1254
|
-
id: crypto.randomUUID(),
|
|
1255
|
-
sent_at: Date.now()
|
|
1256
|
-
}));
|
|
1319
|
+
ws.send(JSON.stringify(makeTunnelPing()));
|
|
1257
1320
|
}
|
|
1258
1321
|
function clearPendingTimers(entry) {
|
|
1259
1322
|
clearTimeout(entry.timeout);
|
|
@@ -1854,6 +1917,29 @@ function createWorkspaceRelayBun(options, bunOptions = {}) {
|
|
|
1854
1917
|
ws.data.missedPongs = 0;
|
|
1855
1918
|
options.directory?.recordPong(ws.data.hostId);
|
|
1856
1919
|
}
|
|
1920
|
+
if (parsed?.type === "host.registration.update") {
|
|
1921
|
+
const update = parsed;
|
|
1922
|
+
const workspaceIds = [...new Set(update.workspace_ids)];
|
|
1923
|
+
const hostSocket = ws;
|
|
1924
|
+
const hostId = hostSocket.data.hostId;
|
|
1925
|
+
const authorizationRequest = new Request(
|
|
1926
|
+
`http://relay.local/host-tunnels/${encodeURIComponent(hostId)}`,
|
|
1927
|
+
{ headers: { authorization: `Bearer ${update.token}` } }
|
|
1928
|
+
);
|
|
1929
|
+
void authorizeHostTunnel(options, bunOptions, authorizationRequest, {
|
|
1930
|
+
hostId,
|
|
1931
|
+
workspaceIds
|
|
1932
|
+
}).then((authorized) => {
|
|
1933
|
+
if (!authorized) {
|
|
1934
|
+
closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008);
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
if (hostTunnels.get(hostId) !== hostSocket || hostSocket.readyState !== WebSocket.OPEN) return;
|
|
1938
|
+
hostSocket.data.workspaceIds = workspaceIds;
|
|
1939
|
+
options.directory?.registerHostTunnel({ hostId, workspaceIds });
|
|
1940
|
+
}).catch(() => closeWebSocket(hostSocket, 1008, "Host tunnel registration update denied", 1008));
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1857
1943
|
if (parsed?.type === "error") {
|
|
1858
1944
|
if (parsed.request_id) {
|
|
1859
1945
|
const pending = ws.data.pending.get(parsed.request_id);
|
|
@@ -2244,7 +2330,8 @@ function clampRate(input) {
|
|
|
2244
2330
|
}
|
|
2245
2331
|
function traceRequest(request, routeKind, options) {
|
|
2246
2332
|
const id = clean(request.headers.get(TRACE_ID_HEADER)) ?? crypto.randomUUID();
|
|
2247
|
-
const
|
|
2333
|
+
const forceSecret = clean(options.traceForceHeaderSecret);
|
|
2334
|
+
const forced = !!forceSecret && request.headers.get(TRACE_FORCE_HEADER) === forceSecret;
|
|
2248
2335
|
const rate = clampRate(options.traceSampleRate);
|
|
2249
2336
|
return {
|
|
2250
2337
|
id,
|
|
@@ -2266,6 +2353,7 @@ function tracedRequest(request, trace) {
|
|
|
2266
2353
|
const headers2 = new Headers(request.headers);
|
|
2267
2354
|
headers2.set(TRACE_ID_HEADER, trace.id);
|
|
2268
2355
|
if (trace.sampled) headers2.set("x-claxedo-trace-sampled", "1");
|
|
2356
|
+
else headers2.delete("x-claxedo-trace-sampled");
|
|
2269
2357
|
return new Request(request.url, {
|
|
2270
2358
|
method: request.method,
|
|
2271
2359
|
headers: headers2,
|
|
@@ -2283,7 +2371,7 @@ function serverTiming(trace) {
|
|
|
2283
2371
|
function withTraceHeaders(response, trace) {
|
|
2284
2372
|
const headers2 = new Headers(response.headers);
|
|
2285
2373
|
headers2.set(TRACE_ID_HEADER, trace.id);
|
|
2286
|
-
const timing = serverTiming(trace);
|
|
2374
|
+
const timing = trace.sampled ? serverTiming(trace) : "";
|
|
2287
2375
|
if (timing) {
|
|
2288
2376
|
const existing = headers2.get("server-timing");
|
|
2289
2377
|
headers2.set("server-timing", existing ? `${existing}, ${timing}` : timing);
|
|
@@ -2324,6 +2412,9 @@ function hostIdFromTunnelPath2(pathname) {
|
|
|
2324
2412
|
function websocketRequest2(request) {
|
|
2325
2413
|
return request.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
2326
2414
|
}
|
|
2415
|
+
function relayWebSocketTraceEnabled2(request) {
|
|
2416
|
+
return request.headers.get("x-claxedo-relay-ws-trace") === "1";
|
|
2417
|
+
}
|
|
2327
2418
|
function workspaceIdsFromSearch(url) {
|
|
2328
2419
|
return [...new Set([
|
|
2329
2420
|
...url.searchParams.getAll("workspaceId"),
|
|
@@ -2441,25 +2532,17 @@ function json(code, message, status) {
|
|
|
2441
2532
|
}
|
|
2442
2533
|
var configuredAppOriginAllowed = () => false;
|
|
2443
2534
|
function setWorkspaceRelayAppOrigins(raw) {
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
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
|
-
};
|
|
2535
|
+
configuredAppOriginAllowed = raw?.trim() ? createOriginMatcher(parseAllowedOrigins(raw) ?? []) : () => false;
|
|
2536
|
+
}
|
|
2537
|
+
var defaultOriginAllowed = createOriginMatcher(DEFAULT_RELAY_APP_ORIGINS);
|
|
2538
|
+
var baseOriginAllowed = defaultOriginAllowed;
|
|
2539
|
+
function setWorkspaceRelayAllowedOrigins(raw) {
|
|
2540
|
+
const patterns = parseAllowedOrigins(raw);
|
|
2541
|
+
baseOriginAllowed = patterns ? createOriginMatcher(patterns) : defaultOriginAllowed;
|
|
2456
2542
|
}
|
|
2457
2543
|
function allowedCorsOrigin2(origin) {
|
|
2458
2544
|
if (!origin) return;
|
|
2459
|
-
if (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;
|
|
2545
|
+
if (baseOriginAllowed(origin)) return origin;
|
|
2463
2546
|
if (configuredAppOriginAllowed(origin)) return origin;
|
|
2464
2547
|
}
|
|
2465
2548
|
function corsHeaders(request) {
|
|
@@ -2581,16 +2664,16 @@ function createWorkspaceRelayDurableObjectGateway(options = {}) {
|
|
|
2581
2664
|
headers: headers2
|
|
2582
2665
|
});
|
|
2583
2666
|
const gatewayFinishedAt = performance.now();
|
|
2584
|
-
const
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
"
|
|
2592
|
-
tracedResponse.headers.
|
|
2593
|
-
|
|
2667
|
+
const roomTiming = tracedResponse.headers.get("server-timing");
|
|
2668
|
+
if (roomTiming) {
|
|
2669
|
+
const gatewayTiming = [
|
|
2670
|
+
`relay-gateway-route;dur=${roundedMs3(routeFinishedAt - gatewayStartedAt)}`,
|
|
2671
|
+
`relay-gateway-do-fetch;dur=${roundedMs3(fetchFinishedAt - routeFinishedAt)}`,
|
|
2672
|
+
`relay-gateway-response;dur=${roundedMs3(gatewayFinishedAt - gatewayResponseStartedAt)}`,
|
|
2673
|
+
`relay-gateway;dur=${roundedMs3(gatewayFinishedAt - gatewayStartedAt)}`
|
|
2674
|
+
].join(", ");
|
|
2675
|
+
tracedResponse.headers.set("server-timing", `${roomTiming}, ${gatewayTiming}`);
|
|
2676
|
+
}
|
|
2594
2677
|
return url.pathname.startsWith("/workspaces/") ? withCors(request, tracedResponse) : tracedResponse;
|
|
2595
2678
|
}
|
|
2596
2679
|
};
|
|
@@ -2668,6 +2751,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2668
2751
|
hostTunnels.set(attachment.hostId, {
|
|
2669
2752
|
hostId: attachment.hostId,
|
|
2670
2753
|
workspaceIds: attachment.workspaceIds,
|
|
2754
|
+
connectedAt: attachment.connectedAt,
|
|
2671
2755
|
socket,
|
|
2672
2756
|
pending: /* @__PURE__ */ new Map(),
|
|
2673
2757
|
channels: /* @__PURE__ */ new Map()
|
|
@@ -2704,6 +2788,16 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2704
2788
|
if (!hibernation || hibernatedSocketsRebuilt) return;
|
|
2705
2789
|
rebuildHibernatedSockets();
|
|
2706
2790
|
};
|
|
2791
|
+
const refreshHostPresenceFromSockets = () => {
|
|
2792
|
+
if (!hibernation) return;
|
|
2793
|
+
for (const socket of hibernation.getWebSockets()) {
|
|
2794
|
+
const attachment = socketAttachment(socket);
|
|
2795
|
+
if (attachment?.kind !== "host-tunnel") continue;
|
|
2796
|
+
if (!directory.recordPong(attachment.hostId)) {
|
|
2797
|
+
directory.registerHostTunnel({ hostId: attachment.hostId, workspaceIds: attachment.workspaceIds });
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
};
|
|
2707
2801
|
const watchRuntimeAccessToken = (client, onInactive) => {
|
|
2708
2802
|
const intervalMs = options.runtimeAccessTokenActiveCheckIntervalMs ?? RUNTIME_ACCESS_TOKEN_ACTIVE_CHECK_INTERVAL_MS_DEFAULT;
|
|
2709
2803
|
if (intervalMs <= 0) return;
|
|
@@ -2959,11 +3053,31 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
2959
3053
|
const parsed = validateTunnelMessage2(row.value);
|
|
2960
3054
|
const tunnel = hostTunnels.get(hostId);
|
|
2961
3055
|
if (!parsed.ok || !tunnel) return;
|
|
3056
|
+
directory.recordPong(tunnel.hostId);
|
|
2962
3057
|
if (parsed.message.type === "ping") {
|
|
2963
|
-
directory.recordPong(tunnel.hostId);
|
|
2964
3058
|
tunnel.socket.send?.(JSON.stringify(makeTunnelPong2(parsed.message)));
|
|
2965
3059
|
return;
|
|
2966
3060
|
}
|
|
3061
|
+
if (parsed.message.type === "host.registration.update") {
|
|
3062
|
+
const update = parsed.message;
|
|
3063
|
+
const workspaceIds = [...new Set(update.workspace_ids)];
|
|
3064
|
+
try {
|
|
3065
|
+
await verifyHostTunnelToken(update.token, options.runtimeAccessKey, { hostId, workspaceIds });
|
|
3066
|
+
} catch {
|
|
3067
|
+
closeSocket(tunnel.socket, 1008, "Host tunnel registration update denied");
|
|
3068
|
+
return;
|
|
3069
|
+
}
|
|
3070
|
+
if (hostTunnels.get(hostId) !== tunnel) return;
|
|
3071
|
+
tunnel.workspaceIds = workspaceIds;
|
|
3072
|
+
tunnel.socket.serializeAttachment?.({
|
|
3073
|
+
kind: "host-tunnel",
|
|
3074
|
+
hostId,
|
|
3075
|
+
workspaceIds,
|
|
3076
|
+
connectedAt: tunnel.connectedAt
|
|
3077
|
+
});
|
|
3078
|
+
directory.registerHostTunnel({ hostId, workspaceIds });
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
2967
3081
|
if (parsed.message.type === "error" && parsed.message.request_id) {
|
|
2968
3082
|
const pending = tunnel.pending.get(parsed.message.request_id);
|
|
2969
3083
|
if (!pending) return;
|
|
@@ -3102,11 +3216,12 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3102
3216
|
}
|
|
3103
3217
|
throw err;
|
|
3104
3218
|
}
|
|
3219
|
+
const connectedAt = options.now?.() ?? Date.now();
|
|
3105
3220
|
const pair = acceptSocket({
|
|
3106
3221
|
kind: "host-tunnel",
|
|
3107
3222
|
hostId,
|
|
3108
3223
|
workspaceIds,
|
|
3109
|
-
connectedAt
|
|
3224
|
+
connectedAt
|
|
3110
3225
|
});
|
|
3111
3226
|
const previous = hostTunnels.get(hostId);
|
|
3112
3227
|
if (previous) {
|
|
@@ -3116,6 +3231,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3116
3231
|
hostTunnels.set(hostId, {
|
|
3117
3232
|
hostId,
|
|
3118
3233
|
workspaceIds,
|
|
3234
|
+
connectedAt,
|
|
3119
3235
|
socket: pair.server,
|
|
3120
3236
|
pending: /* @__PURE__ */ new Map(),
|
|
3121
3237
|
channels: /* @__PURE__ */ new Map()
|
|
@@ -3138,6 +3254,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3138
3254
|
return await admitCloudClient(request, authorized.request);
|
|
3139
3255
|
};
|
|
3140
3256
|
const admitCloudClient = async (request, authorized) => {
|
|
3257
|
+
const trace = relayWebSocketTraceEnabled2(request) ? { upstreamStartedAt: performance.now(), queueTimes: [], emitted: false } : void 0;
|
|
3141
3258
|
let upstream;
|
|
3142
3259
|
try {
|
|
3143
3260
|
upstream = await connectWebSocket(
|
|
@@ -3158,12 +3275,23 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3158
3275
|
} catch {
|
|
3159
3276
|
return json("upstream_unavailable", "Workspace upstream is unavailable", 503);
|
|
3160
3277
|
}
|
|
3278
|
+
const upstreamConnectedAt = trace ? performance.now() : 0;
|
|
3161
3279
|
const pair = acceptSocket();
|
|
3162
3280
|
const client = { request: authorized, socket: pair.server };
|
|
3163
3281
|
clients.add(client);
|
|
3164
3282
|
let closed = false;
|
|
3165
3283
|
let upstreamOpen = upstream.readyState === void 0 || upstream.readyState === 1;
|
|
3166
3284
|
const queue = [];
|
|
3285
|
+
const emitTrace = (openedAt) => {
|
|
3286
|
+
if (!trace || trace.emitted) return;
|
|
3287
|
+
trace.emitted = true;
|
|
3288
|
+
sendSocket(pair.server, JSON.stringify({
|
|
3289
|
+
type: "relay.trace",
|
|
3290
|
+
wsUpstreamOpenMs: roundedMs3(openedAt - trace.upstreamStartedAt),
|
|
3291
|
+
queuedFrames: trace.queueTimes.length,
|
|
3292
|
+
maxQueuedDelayMs: roundedMs3(trace.queueTimes.reduce((max, at) => Math.max(max, openedAt - at), 0))
|
|
3293
|
+
}));
|
|
3294
|
+
};
|
|
3167
3295
|
const activeTokenTimer = watchRuntimeAccessToken(client, (reason) => {
|
|
3168
3296
|
closeSocket(pair.server, 1008, reason);
|
|
3169
3297
|
});
|
|
@@ -3183,6 +3311,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3183
3311
|
};
|
|
3184
3312
|
const flushQueue = () => {
|
|
3185
3313
|
upstreamOpen = true;
|
|
3314
|
+
if (trace) emitTrace(performance.now());
|
|
3186
3315
|
for (const item of queue.splice(0)) {
|
|
3187
3316
|
if (!sendSocket(upstream, item)) {
|
|
3188
3317
|
closeSocket(pair.server, 1011, "Upstream WebSocket connection failed");
|
|
@@ -3199,6 +3328,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3199
3328
|
cleanupClient({ code: 1011, reason: "Upstream WebSocket queue limit exceeded" });
|
|
3200
3329
|
return;
|
|
3201
3330
|
}
|
|
3331
|
+
if (trace) trace.queueTimes.push(performance.now());
|
|
3202
3332
|
queue.push(payload);
|
|
3203
3333
|
return;
|
|
3204
3334
|
}
|
|
@@ -3213,6 +3343,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3213
3343
|
});
|
|
3214
3344
|
upstream.addEventListener?.("close", cleanupUpstream);
|
|
3215
3345
|
upstream.addEventListener?.("error", () => cleanupUpstream({ code: 1011, reason: "Upstream WebSocket connection failed" }));
|
|
3346
|
+
if (trace && upstreamOpen) emitTrace(upstreamConnectedAt);
|
|
3216
3347
|
return upgradeResponse(pair.client, request);
|
|
3217
3348
|
};
|
|
3218
3349
|
const admitUserHostedClient = (request, authorized) => {
|
|
@@ -3390,6 +3521,7 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3390
3521
|
return {
|
|
3391
3522
|
async fetch(request) {
|
|
3392
3523
|
ensureHibernatedSocketsRebuilt();
|
|
3524
|
+
refreshHostPresenceFromSockets();
|
|
3393
3525
|
const url = new URL(request.url);
|
|
3394
3526
|
const roomWorkspaceId = workspaceRelayDurableObjectWorkspaceId(request);
|
|
3395
3527
|
if (request.method === "OPTIONS" && (url.pathname.startsWith("/workspaces/") || url.pathname.startsWith("/host-tunnels/"))) {
|
|
@@ -3457,19 +3589,22 @@ function createWorkspaceRelayDurableObjectRoom(options) {
|
|
|
3457
3589
|
return {
|
|
3458
3590
|
hostTunnelCount: hostTunnels.size,
|
|
3459
3591
|
clientCount: clients.size,
|
|
3460
|
-
hostIds: [...hostTunnels.keys()]
|
|
3592
|
+
hostIds: [...hostTunnels.keys()],
|
|
3593
|
+
hostWorkspaceIds: Object.fromEntries([...hostTunnels].map(([hostId, tunnel]) => [hostId, tunnel.workspaceIds]))
|
|
3461
3594
|
};
|
|
3462
3595
|
},
|
|
3463
3596
|
drain: drainController
|
|
3464
3597
|
};
|
|
3465
3598
|
}
|
|
3466
3599
|
export {
|
|
3600
|
+
DEFAULT_RELAY_APP_ORIGINS,
|
|
3467
3601
|
DEFAULT_RELAY_LOCATION_HINT,
|
|
3468
3602
|
WorkspaceRelayAuthError,
|
|
3469
3603
|
__slowConsumerInternalsForTest,
|
|
3470
3604
|
authorizeWorkspaceRelayRequest,
|
|
3471
3605
|
createCachedRevocationClient,
|
|
3472
3606
|
createCachedTargetClient,
|
|
3607
|
+
createOriginMatcher,
|
|
3473
3608
|
createWorkspaceRelay,
|
|
3474
3609
|
createWorkspaceRelayBun,
|
|
3475
3610
|
createWorkspaceRelayDirectory,
|
|
@@ -3485,10 +3620,12 @@ export {
|
|
|
3485
3620
|
mintHostTunnelToken,
|
|
3486
3621
|
mintRelayHostToken,
|
|
3487
3622
|
mintRuntimeAccessToken,
|
|
3623
|
+
parseAllowedOrigins,
|
|
3488
3624
|
relayHostTokenAudience,
|
|
3489
3625
|
relayHostTokenIssuer,
|
|
3490
3626
|
runtimeAccessTokenAudience,
|
|
3491
3627
|
runtimeAccessTokenIssuer,
|
|
3628
|
+
setWorkspaceRelayAllowedOrigins,
|
|
3492
3629
|
setWorkspaceRelayAppOrigins,
|
|
3493
3630
|
validateRuntimeAccessTokenClaims,
|
|
3494
3631
|
verifyHostTunnelToken,
|
package/dist/main.d.ts
CHANGED
|
@@ -3,6 +3,49 @@ import { type WorkspaceRelayDirectory } from "./directory";
|
|
|
3
3
|
import { type RelayHostPublicKey, type RevocationLookup, type WorkspaceRelayTarget } from "./server";
|
|
4
4
|
import type { RelayKey } from "./auth";
|
|
5
5
|
export { createCachedRevocationClient, createCachedTargetClient } from "./server";
|
|
6
|
+
/**
|
|
7
|
+
* D12 observability: Sentry on the relay (ops floor ADR
|
|
8
|
+
* docs/plans/2026-07-11-016-wp-ops-floor-design.md §4).
|
|
9
|
+
*
|
|
10
|
+
* Gated on `CLAXEDO_RELAY_SENTRY_DSN` (the relay's OWN DSN env — separate
|
|
11
|
+
* Sentry project from the control plane). Absent DSN → `Sentry.init` is never
|
|
12
|
+
* called: zero SDK overhead, zero network — the documented no-op posture, safe
|
|
13
|
+
* before the Sentry account exists. Release = git SHA passed by the D11
|
|
14
|
+
* deploy workflow (`CLAXEDO_RELEASE`; `GIT_SHA` accepted as alias); events are
|
|
15
|
+
* tagged unit=relay + deployment_mode (absent → "self-host", mirroring D9's
|
|
16
|
+
* default). Tracing stays off (ADR: it is where Sentry gets expensive).
|
|
17
|
+
*
|
|
18
|
+
* The relay is a Bun process (`Bun.serve`), so this uses `@sentry/bun` —
|
|
19
|
+
* Sentry's Bun-native SDK with the same init-gated pattern as @sentry/node
|
|
20
|
+
* (https://docs.sentry.io/platforms/javascript/guides/bun/).
|
|
21
|
+
*/
|
|
22
|
+
export type RelayObservabilityEnv = {
|
|
23
|
+
CLAXEDO_RELAY_SENTRY_DSN?: string | undefined;
|
|
24
|
+
CLAXEDO_RELEASE?: string | undefined;
|
|
25
|
+
GIT_SHA?: string | undefined;
|
|
26
|
+
CLAXEDO_DEPLOYMENT_MODE?: string | undefined;
|
|
27
|
+
/** Accept process.env verbatim (extra keys are ignored). */
|
|
28
|
+
[key: string]: string | undefined;
|
|
29
|
+
};
|
|
30
|
+
export type RelaySentryOptions = {
|
|
31
|
+
dsn: string;
|
|
32
|
+
release?: string;
|
|
33
|
+
tracesSampleRate: 0;
|
|
34
|
+
initialScope: {
|
|
35
|
+
tags: {
|
|
36
|
+
unit: "relay";
|
|
37
|
+
deployment_mode: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Pure env → Sentry options resolver. `undefined` = DSN absent = do NOT init
|
|
43
|
+
* (no client, no network). Exported for tests.
|
|
44
|
+
*/
|
|
45
|
+
export declare function relaySentryOptions(env: RelayObservabilityEnv): RelaySentryOptions | undefined;
|
|
46
|
+
export declare function initRelayObservability(env?: RelayObservabilityEnv): {
|
|
47
|
+
enabled: boolean;
|
|
48
|
+
};
|
|
6
49
|
/**
|
|
7
50
|
* Production fail-closed gate for the relay → claxedo-server resolver channel.
|
|
8
51
|
*
|
|
@@ -168,6 +211,12 @@ export type FatalProcessHandlerOptions = {
|
|
|
168
211
|
exit?: (code?: number) => void;
|
|
169
212
|
log?: (message: string) => void;
|
|
170
213
|
register?: boolean;
|
|
214
|
+
/**
|
|
215
|
+
* D12: error-tracker hook, invoked (and awaited) before teardown so a fatal
|
|
216
|
+
* crash reaches Sentry before the process exits. Failures are logged and
|
|
217
|
+
* never block the exit path. Defaults to none (tests, DSN-absent runs).
|
|
218
|
+
*/
|
|
219
|
+
report?: (error: unknown, source: "uncaughtException" | "unhandledRejection") => void | Promise<void>;
|
|
171
220
|
stopServer?: () => Promise<void> | void;
|
|
172
221
|
};
|
|
173
222
|
export type FatalProcessHandle = {
|