@claxedo/workspace-relay 0.1.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/dist/index.mjs ADDED
@@ -0,0 +1,1563 @@
1
+ // src/auth.ts
2
+ import { SignJWT, errors, jwtVerify } from "jose";
3
+ var algorithms = ["EdDSA", "ES256", "RS256"];
4
+ var runtimeAccessTokenIssuer = "claxedo-control-plane";
5
+ var runtimeAccessTokenAudience = "workspace-relay";
6
+ var hostTunnelTokenAudience = "workspace-relay-host-tunnel";
7
+ var relayHostTokenIssuer = "workspace-relay";
8
+ var relayHostTokenAudience = "workspace-host-service";
9
+ var WorkspaceRelayAuthError = class extends Error {
10
+ constructor(code, message) {
11
+ super(message);
12
+ this.code = code;
13
+ }
14
+ };
15
+ function jti() {
16
+ return crypto.randomUUID();
17
+ }
18
+ function seconds(input = Date.now()) {
19
+ return Math.floor(input / 1e3);
20
+ }
21
+ function requireAlgorithm(input) {
22
+ if (algorithms.includes(input)) return input;
23
+ throw new WorkspaceRelayAuthError("invalid_relay_token", "Unsupported relay token algorithm");
24
+ }
25
+ function stringClaim(payload, key) {
26
+ const value = payload[key];
27
+ return typeof value === "string" && value.trim() ? value : void 0;
28
+ }
29
+ function numberClaim(payload, key) {
30
+ const value = payload[key];
31
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
32
+ }
33
+ function stringArrayClaim(payload, key) {
34
+ const value = payload[key];
35
+ return Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim()) ? value : void 0;
36
+ }
37
+ function roleClaim(payload) {
38
+ const value = stringClaim(payload, "role");
39
+ return value === "viewer" || value === "editor" || value === "admin" || value === "owner" ? value : void 0;
40
+ }
41
+ function checkHostTunnelTarget(payload, expected) {
42
+ if (stringClaim(payload, "host_id") !== expected.hostId) {
43
+ throw new WorkspaceRelayAuthError("relay_token_host_mismatch", "Relay token host does not match request");
44
+ }
45
+ const workspaceIds = stringArrayClaim(payload, "workspace_ids") ?? [];
46
+ if (expected.workspaceIds.some((workspaceId) => !workspaceIds.includes(workspaceId))) {
47
+ throw new WorkspaceRelayAuthError("relay_token_workspace_mismatch", "Relay token workspace does not match request");
48
+ }
49
+ }
50
+ function checkTarget(payload, expected) {
51
+ if (stringClaim(payload, "workspace_id") !== expected.workspaceId) {
52
+ throw new WorkspaceRelayAuthError("relay_token_workspace_mismatch", "Relay token workspace does not match request");
53
+ }
54
+ if (expected.hostId && stringClaim(payload, "host_id") !== expected.hostId) {
55
+ throw new WorkspaceRelayAuthError("relay_token_host_mismatch", "Relay token host does not match request");
56
+ }
57
+ }
58
+ async function verifyJwt(token, key, input) {
59
+ try {
60
+ if (typeof key === "function") {
61
+ return await jwtVerify(token, key, {
62
+ issuer: input.issuer,
63
+ audience: input.audience,
64
+ algorithms: [...algorithms]
65
+ });
66
+ }
67
+ return await jwtVerify(token, key, {
68
+ issuer: input.issuer,
69
+ audience: input.audience,
70
+ algorithms: [...algorithms]
71
+ });
72
+ } catch (err) {
73
+ if (err instanceof errors.JOSEError) {
74
+ throw new WorkspaceRelayAuthError("invalid_relay_token", "Relay token is invalid");
75
+ }
76
+ throw err;
77
+ }
78
+ }
79
+ async function mintRuntimeAccessToken(input, key, alg) {
80
+ const now = seconds(input.now);
81
+ return await new SignJWT({
82
+ org_id: input.orgId,
83
+ workspace_id: input.workspaceId,
84
+ host_id: input.hostId,
85
+ role: input.role
86
+ }).setProtectedHeader({ alg: requireAlgorithm(alg) }).setIssuer(runtimeAccessTokenIssuer).setAudience(runtimeAccessTokenAudience).setSubject(input.subject).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 30 * 60)).setJti(input.jti ?? jti()).sign(key);
87
+ }
88
+ async function verifyRuntimeAccessToken(token, key, expected) {
89
+ const result = await verifyJwt(token, key, {
90
+ issuer: runtimeAccessTokenIssuer,
91
+ audience: runtimeAccessTokenAudience
92
+ });
93
+ checkTarget(result.payload, expected);
94
+ const claims = runtimeClaims(result.payload);
95
+ if (!claims) {
96
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Runtime Access Token claims are incomplete");
97
+ }
98
+ return claims;
99
+ }
100
+ async function mintHostTunnelToken(input, key, alg) {
101
+ const now = seconds(input.now);
102
+ return await new SignJWT({
103
+ host_id: input.hostId,
104
+ workspace_ids: input.workspaceIds
105
+ }).setProtectedHeader({ alg: requireAlgorithm(alg) }).setIssuer(runtimeAccessTokenIssuer).setAudience(hostTunnelTokenAudience).setSubject(input.subject).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 5 * 60)).setJti(input.jti ?? jti()).sign(key);
106
+ }
107
+ async function verifyHostTunnelToken(token, key, expected) {
108
+ const result = await verifyJwt(token, key, {
109
+ issuer: runtimeAccessTokenIssuer,
110
+ audience: hostTunnelTokenAudience
111
+ });
112
+ checkHostTunnelTarget(result.payload, expected);
113
+ const claims = hostTunnelClaims(result.payload);
114
+ if (!claims) {
115
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Host Tunnel Token claims are incomplete");
116
+ }
117
+ return claims;
118
+ }
119
+ async function mintRelayHostToken(input, key, alg) {
120
+ const now = seconds(input.now);
121
+ const protectedHeader = { alg: requireAlgorithm(alg) };
122
+ if (input.kid) protectedHeader.kid = input.kid;
123
+ return await new SignJWT({
124
+ org_id: input.orgId,
125
+ workspace_id: input.workspaceId,
126
+ host_id: input.hostId,
127
+ role: input.role,
128
+ access: input.access,
129
+ backing: input.backing
130
+ }).setProtectedHeader(protectedHeader).setIssuer(relayHostTokenIssuer).setAudience(relayHostTokenAudience).setSubject(input.subject).setIssuedAt(now).setExpirationTime(now + (input.ttlSeconds ?? 60)).setJti(input.jti ?? jti()).sign(key);
131
+ }
132
+ async function verifyRelayHostToken(token, key, expected) {
133
+ const result = await verifyJwt(token, key, {
134
+ issuer: relayHostTokenIssuer,
135
+ audience: relayHostTokenAudience
136
+ });
137
+ checkTarget(result.payload, expected);
138
+ const claims = relayHostClaims(result.payload);
139
+ if (!claims) {
140
+ throw new WorkspaceRelayAuthError("relay_token_claims_invalid", "Relay Host Token claims are incomplete");
141
+ }
142
+ return claims;
143
+ }
144
+ function runtimeClaims(payload) {
145
+ const exp = numberClaim(payload, "exp");
146
+ const iat = numberClaim(payload, "iat");
147
+ const sub = stringClaim(payload, "sub");
148
+ const jti2 = stringClaim(payload, "jti");
149
+ const org_id = stringClaim(payload, "org_id");
150
+ const workspace_id = stringClaim(payload, "workspace_id");
151
+ const host_id = stringClaim(payload, "host_id");
152
+ const role = roleClaim(payload);
153
+ if (!exp || !iat || !sub || !jti2 || !org_id || !workspace_id || !host_id || !role) return;
154
+ return {
155
+ iss: runtimeAccessTokenIssuer,
156
+ aud: runtimeAccessTokenAudience,
157
+ sub,
158
+ org_id,
159
+ workspace_id,
160
+ host_id,
161
+ role,
162
+ exp,
163
+ iat,
164
+ jti: jti2
165
+ };
166
+ }
167
+ function relayHostClaims(payload) {
168
+ const base = runtimeClaims({
169
+ ...payload,
170
+ iss: runtimeAccessTokenIssuer,
171
+ aud: runtimeAccessTokenAudience
172
+ });
173
+ const access = stringClaim(payload, "access");
174
+ const backing = stringClaim(payload, "backing");
175
+ if (!base || access !== "cloud" && access !== "user-hosted") return;
176
+ if (backing !== "local-worktree" && backing !== "cloud-vm") return;
177
+ return {
178
+ ...base,
179
+ iss: relayHostTokenIssuer,
180
+ aud: relayHostTokenAudience,
181
+ access,
182
+ backing
183
+ };
184
+ }
185
+ function hostTunnelClaims(payload) {
186
+ const exp = numberClaim(payload, "exp");
187
+ const iat = numberClaim(payload, "iat");
188
+ const sub = stringClaim(payload, "sub");
189
+ const jti2 = stringClaim(payload, "jti");
190
+ const host_id = stringClaim(payload, "host_id");
191
+ const workspace_ids = stringArrayClaim(payload, "workspace_ids");
192
+ if (!exp || !iat || !sub || !jti2 || !host_id || !workspace_ids?.length) return;
193
+ return {
194
+ iss: runtimeAccessTokenIssuer,
195
+ aud: hostTunnelTokenAudience,
196
+ sub,
197
+ host_id,
198
+ workspace_ids,
199
+ exp,
200
+ iat,
201
+ jti: jti2
202
+ };
203
+ }
204
+
205
+ // src/bun.ts
206
+ import {
207
+ isTunnelMessage,
208
+ makeTunnelPong,
209
+ TUNNEL_PROTOCOL_VERSION
210
+ } from "@claxedo/workspace-relay-protocol";
211
+
212
+ // src/server.ts
213
+ import { Hono } from "hono";
214
+ import { cors } from "hono/cors";
215
+ import { exportJWK } from "jose";
216
+
217
+ // src/http.ts
218
+ function bearerToken(header) {
219
+ if (!header) return;
220
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
221
+ return match?.[1]?.trim() || void 0;
222
+ }
223
+ function errorBody(code, message) {
224
+ return {
225
+ error: {
226
+ code,
227
+ message
228
+ }
229
+ };
230
+ }
231
+
232
+ // src/server.ts
233
+ function protocolToken(header) {
234
+ return header?.split(",").map((item) => item.trim()).find((item) => item.startsWith("claxedo-rat."))?.slice("claxedo-rat.".length);
235
+ }
236
+ function runtimeAccessToken(request) {
237
+ return bearerToken(request.headers.get("authorization") ?? void 0) ?? protocolToken(request.headers.get("sec-websocket-protocol") ?? void 0);
238
+ }
239
+ var DANGEROUS_INBOUND_HEADERS = [
240
+ "x-forwarded-for",
241
+ "x-forwarded-host",
242
+ "x-forwarded-proto",
243
+ "x-real-ip"
244
+ ];
245
+ var DANGEROUS_INBOUND_HEADER_PATTERNS = [
246
+ /^x-claxedo-internal-/i,
247
+ /^x-supervisor-/i
248
+ ];
249
+ function isDangerousInboundHeader(name) {
250
+ const lower = name.toLowerCase();
251
+ if (DANGEROUS_INBOUND_HEADERS.includes(lower)) return true;
252
+ for (const pattern of DANGEROUS_INBOUND_HEADER_PATTERNS) {
253
+ if (pattern.test(lower)) return true;
254
+ }
255
+ return false;
256
+ }
257
+ function forwardHeaders(input, relayHostToken, workspaceId, options = {}) {
258
+ const headers2 = new Headers(input);
259
+ headers2.delete("host");
260
+ const inboundNames = /* @__PURE__ */ new Set();
261
+ headers2.forEach((_value, name) => inboundNames.add(name));
262
+ for (const name of inboundNames) {
263
+ if (isDangerousInboundHeader(name)) headers2.delete(name);
264
+ }
265
+ if (options.userHosted) headers2.delete("cookie");
266
+ headers2.set("accept-encoding", "identity");
267
+ headers2.set("authorization", `Bearer ${relayHostToken}`);
268
+ headers2.set("x-workspace-id", workspaceId);
269
+ headers2.set("X-Daytona-Skip-Preview-Warning", "true");
270
+ headers2.set("x-forwarded-by", "workspace-relay");
271
+ return headers2;
272
+ }
273
+ function targetUrl(target, path, search) {
274
+ const url = new URL(path.replace(/^\/+/, ""), target.baseUrl.endsWith("/") ? target.baseUrl : `${target.baseUrl}/`);
275
+ url.search = search;
276
+ return url;
277
+ }
278
+ function roleAllowsRelayRequest(role, method) {
279
+ if (role !== "viewer") return true;
280
+ return method === "GET" || method === "HEAD" || method === "OPTIONS";
281
+ }
282
+ function workspaceRelayTargetUrl(target, path, search) {
283
+ return targetUrl(target, path, search);
284
+ }
285
+ function workspaceRelayForwardHeaders(input, relayHostToken, workspaceId, options = {}) {
286
+ return forwardHeaders(input, relayHostToken, workspaceId, options);
287
+ }
288
+ var auditSamplers = /* @__PURE__ */ new WeakMap();
289
+ function getOrCreateAuditSampler(options) {
290
+ let state = auditSamplers.get(options);
291
+ if (!state) {
292
+ const sampleRate = clampSampleRate(options.auditAcceptSampleRate);
293
+ state = {
294
+ suppressedCount: 0,
295
+ timer: void 0,
296
+ flushIntervalMs: options.auditFlushIntervalMs ?? 6e4,
297
+ random: options.random ?? Math.random,
298
+ sampleRate
299
+ };
300
+ auditSamplers.set(options, state);
301
+ }
302
+ return state;
303
+ }
304
+ function clampSampleRate(rate) {
305
+ if (rate === void 0) return 1;
306
+ if (!Number.isFinite(rate)) return 1;
307
+ if (rate < 0) return 0;
308
+ if (rate > 1) return 1;
309
+ return rate;
310
+ }
311
+ function ensureFlushTimer(options, state) {
312
+ if (state.timer) return;
313
+ if (state.sampleRate >= 1) return;
314
+ if (state.flushIntervalMs <= 0) return;
315
+ const timer = setInterval(() => {
316
+ flushSuppressedSummary(options, state);
317
+ }, state.flushIntervalMs);
318
+ if (typeof timer.unref === "function") {
319
+ timer.unref();
320
+ }
321
+ state.timer = timer;
322
+ }
323
+ function flushSuppressedSummary(options, state) {
324
+ const count = state.suppressedCount;
325
+ if (count <= 0) return;
326
+ state.suppressedCount = 0;
327
+ void options.audit?.({
328
+ action: "relay.request.suppressed_summary",
329
+ result: "allow",
330
+ method: "INTERNAL",
331
+ path: "/",
332
+ suppressedCount: count
333
+ });
334
+ }
335
+ function disposeAuditSampler(options) {
336
+ const state = auditSamplers.get(options);
337
+ if (!state) return;
338
+ if (state.timer) {
339
+ clearInterval(state.timer);
340
+ state.timer = void 0;
341
+ }
342
+ flushSuppressedSummary(options, state);
343
+ auditSamplers.delete(options);
344
+ }
345
+ async function audit(options, event) {
346
+ if (event.action === "relay.request.accepted") {
347
+ const state = getOrCreateAuditSampler(options);
348
+ if (state.sampleRate < 1) {
349
+ ensureFlushTimer(options, state);
350
+ if (state.random() >= state.sampleRate) {
351
+ state.suppressedCount += 1;
352
+ return;
353
+ }
354
+ }
355
+ }
356
+ await options.audit?.(event);
357
+ }
358
+ function denyCorsHeaders(request) {
359
+ const origin = request.headers.get("origin");
360
+ if (!origin) return void 0;
361
+ const allowed = origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:") || /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin);
362
+ if (!allowed) return void 0;
363
+ return {
364
+ "access-control-allow-origin": origin,
365
+ "access-control-allow-headers": "Accept, Authorization, Content-Type, X-Daytona-Skip-Preview-Warning, X-Workspace-Id",
366
+ "access-control-allow-methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"
367
+ };
368
+ }
369
+ async function deny(options, input) {
370
+ const url = new URL(input.request.url);
371
+ await audit(options, {
372
+ action: "relay.request.denied",
373
+ result: "deny",
374
+ reason: input.code,
375
+ subject: input.claims?.sub,
376
+ ...input.claims ? { role: input.claims.role } : {},
377
+ workspaceId: input.claims?.workspace_id,
378
+ hostId: input.claims?.host_id,
379
+ method: input.request.method,
380
+ path: url.pathname
381
+ });
382
+ return Response.json(errorBody(input.code, input.message), {
383
+ status: input.status,
384
+ headers: denyCorsHeaders(input.request)
385
+ });
386
+ }
387
+ async function authorizeWorkspaceRelayRequest(options, request, workspaceId) {
388
+ const token = runtimeAccessToken(request);
389
+ if (!token) {
390
+ return {
391
+ ok: false,
392
+ response: await deny(options, {
393
+ code: "runtime_access_token_required",
394
+ message: "Runtime Access Token is required",
395
+ status: 401,
396
+ request
397
+ })
398
+ };
399
+ }
400
+ try {
401
+ const claims = options.tokenVerifier ? await (async () => {
402
+ const verified = await options.tokenVerifier.verify(token);
403
+ return verified.claims;
404
+ })() : await verifyRuntimeAccessToken(token, options.runtimeAccessKey, { workspaceId });
405
+ const active = await options.isRuntimeAccessTokenActive?.(claims) ?? { active: true };
406
+ if (!active.active) {
407
+ return {
408
+ ok: false,
409
+ response: await deny(options, {
410
+ code: active.code,
411
+ message: active.reason,
412
+ status: 401,
413
+ claims,
414
+ request
415
+ })
416
+ };
417
+ }
418
+ if (!roleAllowsRelayRequest(claims.role, request.method)) {
419
+ return {
420
+ ok: false,
421
+ response: await deny(options, {
422
+ code: "relay_role_denied",
423
+ message: "Workspace role does not allow this relay request",
424
+ status: 403,
425
+ claims,
426
+ request
427
+ })
428
+ };
429
+ }
430
+ const target = await options.resolveTarget(claims);
431
+ if (!target || target.workspaceId !== claims.workspace_id || target.hostId !== claims.host_id) {
432
+ return {
433
+ ok: false,
434
+ response: await deny(options, {
435
+ code: "relay_target_unavailable",
436
+ message: "Workspace relay target is unavailable",
437
+ status: 403,
438
+ claims,
439
+ request
440
+ })
441
+ };
442
+ }
443
+ if (target.access === "user-hosted" && !options.directory?.activeHost({ hostId: target.hostId, workspaceId: target.workspaceId })) {
444
+ return {
445
+ ok: false,
446
+ response: await deny(options, {
447
+ code: "user_hosted_app_offline",
448
+ message: "User-hosted workspace is offline",
449
+ status: 503,
450
+ claims,
451
+ request
452
+ })
453
+ };
454
+ }
455
+ await audit(options, {
456
+ action: "relay.request.accepted",
457
+ result: "allow",
458
+ subject: claims.sub,
459
+ role: claims.role,
460
+ workspaceId: claims.workspace_id,
461
+ hostId: claims.host_id,
462
+ method: request.method,
463
+ path: new URL(request.url).pathname
464
+ });
465
+ return {
466
+ ok: true,
467
+ request: {
468
+ claims,
469
+ target,
470
+ relayHostToken: await mintRelayHostToken({
471
+ subject: claims.sub,
472
+ orgId: claims.org_id,
473
+ workspaceId: claims.workspace_id,
474
+ hostId: claims.host_id,
475
+ role: claims.role,
476
+ access: target.access,
477
+ backing: target.backing,
478
+ ...options.relayHostMintKid ? { kid: options.relayHostMintKid } : {}
479
+ }, options.relayHostSigningKey, options.relayHostAlgorithm),
480
+ path: new URL(request.url).pathname.replace(/^\/workspaces\/[^/]+\/?/, "/")
481
+ }
482
+ };
483
+ } catch (err) {
484
+ const code = err instanceof WorkspaceRelayAuthError ? err.code : "relay_request_failed";
485
+ return {
486
+ ok: false,
487
+ response: await deny(options, {
488
+ code,
489
+ message: "Workspace relay request was denied",
490
+ status: code.includes("mismatch") ? 403 : 401,
491
+ request
492
+ })
493
+ };
494
+ }
495
+ }
496
+ async function forwardRequest(request, target, relayHostToken, path, requestFetch) {
497
+ const init = {
498
+ method: request.method,
499
+ headers: forwardHeaders(request.headers, relayHostToken, target.workspaceId),
500
+ body: request.body,
501
+ duplex: "half",
502
+ redirect: "manual"
503
+ };
504
+ if (request.method === "GET" || request.method === "HEAD") delete init.body;
505
+ const upstream = await requestFetch(targetUrl(target, path, new URL(request.url).search), init);
506
+ const headers2 = new Headers(upstream.headers);
507
+ headers2.delete("access-control-allow-origin");
508
+ headers2.delete("access-control-allow-credentials");
509
+ headers2.delete("access-control-allow-headers");
510
+ headers2.delete("access-control-allow-methods");
511
+ headers2.delete("access-control-expose-headers");
512
+ headers2.delete("access-control-max-age");
513
+ return new Response(upstream.body, {
514
+ status: upstream.status,
515
+ statusText: upstream.statusText,
516
+ headers: headers2
517
+ });
518
+ }
519
+ function getAuditSuppressedCount(options) {
520
+ return auditSamplers.get(options)?.suppressedCount ?? 0;
521
+ }
522
+ var LOOPBACK_REMOTES = /* @__PURE__ */ new Set([
523
+ "127.0.0.1",
524
+ "::1",
525
+ "::ffff:127.0.0.1"
526
+ ]);
527
+ function isLoopbackAddress(address) {
528
+ if (!address) return false;
529
+ const trimmed = address.trim();
530
+ if (LOOPBACK_REMOTES.has(trimmed)) return true;
531
+ if (/^127\./.test(trimmed)) return true;
532
+ if (/^::ffff:127\./i.test(trimmed)) return true;
533
+ return false;
534
+ }
535
+ function checkMetricsAuth(options, request) {
536
+ if (options.metricsToken) {
537
+ const presented = bearerToken(request.headers.get("authorization") ?? void 0);
538
+ if (!presented || presented !== options.metricsToken) {
539
+ return {
540
+ ok: false,
541
+ status: 401,
542
+ code: "metrics_unauthorized",
543
+ message: "Metrics endpoint requires a valid bearer token"
544
+ };
545
+ }
546
+ return { ok: true };
547
+ }
548
+ if (!options.metricsRemoteAddress) return { ok: true };
549
+ const address = options.metricsRemoteAddress(request);
550
+ if (isLoopbackAddress(address)) return { ok: true };
551
+ return {
552
+ ok: false,
553
+ status: 401,
554
+ code: "metrics_unauthorized",
555
+ message: "Metrics endpoint is restricted to loopback or bearer-token clients"
556
+ };
557
+ }
558
+ function buildMetricsBody(options) {
559
+ const sources = options.metricsSources ?? {};
560
+ const fragmentation = sources.fragmentation?.() ?? { fragmentsBuffered: 0, oversizedClosed: 0 };
561
+ const slowConsumer = sources.slowConsumer?.() ?? {
562
+ overflowEvents: 0,
563
+ timerFired: 0,
564
+ droppedRequests: 0
565
+ };
566
+ const activeHostCount = options.directory?.size?.() ?? 0;
567
+ const suppressedAccepts = getAuditSuppressedCount(options);
568
+ const body = {
569
+ fragmentation,
570
+ slowConsumer,
571
+ directory: { activeHostCount },
572
+ audit: { suppressedAccepts }
573
+ };
574
+ if (sources.drainPending) {
575
+ body.drain = { pendingCount: sources.drainPending() };
576
+ }
577
+ return body;
578
+ }
579
+ function createWorkspaceRelay(options) {
580
+ const app = new Hono();
581
+ app.use("*", cors({
582
+ origin: (origin) => {
583
+ if (!origin) return void 0;
584
+ if (origin.startsWith("http://localhost:")) return origin;
585
+ if (origin.startsWith("http://127.0.0.1:")) return origin;
586
+ if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
587
+ return void 0;
588
+ },
589
+ allowHeaders: [
590
+ "Accept",
591
+ "Authorization",
592
+ "Content-Type",
593
+ "X-Daytona-Skip-Preview-Warning",
594
+ "X-Workspace-Id"
595
+ ],
596
+ allowMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
597
+ }));
598
+ app.get("/health", (c) => {
599
+ if (options.isDraining?.()) {
600
+ return c.json(
601
+ { ok: false, service: "workspace-relay", draining: true },
602
+ 503
603
+ );
604
+ }
605
+ return c.json({ ok: true, service: "workspace-relay" });
606
+ });
607
+ app.get("/.well-known/jwks.json", async (c) => {
608
+ const sources = options.relayHostPublicKeys ?? [];
609
+ if (sources.length === 0) {
610
+ return c.json(
611
+ {
612
+ error: {
613
+ code: "jwks_no_keys_configured",
614
+ message: "No Relay Host Token public keys are configured for the JWKS endpoint"
615
+ }
616
+ },
617
+ 503
618
+ );
619
+ }
620
+ let keys;
621
+ try {
622
+ keys = await Promise.all(sources.map(async (source) => {
623
+ const jwk = await exportJWK(source.publicKey);
624
+ return {
625
+ ...jwk,
626
+ kid: source.kid,
627
+ alg: "EdDSA",
628
+ use: "sig"
629
+ };
630
+ }));
631
+ } catch (err) {
632
+ return c.json(
633
+ {
634
+ error: {
635
+ code: "jwks_invalid_key",
636
+ message: "A configured RHT public key could not be exported as a JWK",
637
+ detail: err instanceof Error ? err.message : String(err)
638
+ }
639
+ },
640
+ 500
641
+ );
642
+ }
643
+ c.header("cache-control", "public, max-age=300");
644
+ return c.json({ keys });
645
+ });
646
+ app.disposeAuditSampler = () => disposeAuditSampler(options);
647
+ app.get("/metrics", (c) => {
648
+ const authResult = checkMetricsAuth(options, c.req.raw);
649
+ if (!authResult.ok) {
650
+ return c.json(
651
+ errorBody(authResult.code, authResult.message),
652
+ authResult.status
653
+ );
654
+ }
655
+ return c.json(buildMetricsBody(options));
656
+ });
657
+ app.all("/workspaces/:workspaceId/*", async (c) => {
658
+ if (options.isDraining?.()) {
659
+ return Response.json(
660
+ errorBody("relay_draining", "Workspace relay is shutting down; try another instance"),
661
+ { status: 503, headers: denyCorsHeaders(c.req.raw) }
662
+ );
663
+ }
664
+ const relay = await authorizeWorkspaceRelayRequest(options, c.req.raw, c.req.param("workspaceId"));
665
+ if (!relay.ok) return relay.response;
666
+ return await forwardRequest(
667
+ new Request(c.req.raw),
668
+ relay.request.target,
669
+ relay.request.relayHostToken,
670
+ relay.request.path,
671
+ options.fetch ?? fetch
672
+ );
673
+ });
674
+ return app;
675
+ }
676
+
677
+ // src/bun.ts
678
+ var TUNNEL_PENDING_HTTP_CAP = 32;
679
+ var TUNNEL_CHANNEL_CAP = 16;
680
+ var HOST_TUNNEL_REGISTRATION_CONCURRENT_CAP = 3;
681
+ var HOST_TUNNEL_REGISTRATION_RECONNECT_CAP = 5;
682
+ var HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS = 6e4;
683
+ var WS_MAX_PAYLOAD_LENGTH_BYTES = 16 * 1024 * 1024;
684
+ var TUNNEL_MESSAGE_BUFFER_CAP_BYTES = 4 * 1024 * 1024;
685
+ var SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT = 8 * 1024 * 1024;
686
+ var SLOW_CONSUMER_TIMEOUT_MS_DEFAULT = 3e4;
687
+ var HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT = 250;
688
+ var fragmentationStats = {
689
+ fragmentsBuffered: 0,
690
+ oversizedClosed: 0
691
+ };
692
+ function getFragmentationStats() {
693
+ return {
694
+ fragmentsBuffered: fragmentationStats.fragmentsBuffered,
695
+ oversizedClosed: fragmentationStats.oversizedClosed
696
+ };
697
+ }
698
+ function resetFragmentationStats() {
699
+ fragmentationStats.fragmentsBuffered = 0;
700
+ fragmentationStats.oversizedClosed = 0;
701
+ }
702
+ var slowConsumerStats = {
703
+ overflowEvents: 0,
704
+ timerFired: 0,
705
+ droppedRequests: 0
706
+ };
707
+ function getSlowConsumerStats() {
708
+ return { ...slowConsumerStats };
709
+ }
710
+ function resetSlowConsumerStats() {
711
+ slowConsumerStats = { overflowEvents: 0, timerFired: 0, droppedRequests: 0 };
712
+ }
713
+ function jsonError(code, message, status) {
714
+ return new Response(JSON.stringify({ error: { code, message } }), {
715
+ status,
716
+ headers: { "content-type": "application/json" }
717
+ });
718
+ }
719
+ function workspaceIdFromPath(pathname) {
720
+ return /^\/workspaces\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
721
+ }
722
+ function hostIdFromTunnelPath(pathname) {
723
+ return /^\/host-tunnels\/([^/]+)(?:\/|$)/.exec(pathname)?.[1];
724
+ }
725
+ function websocketRequest(request) {
726
+ return request.headers.get("upgrade")?.toLowerCase() === "websocket";
727
+ }
728
+ function relayWebSocketPayload(input) {
729
+ if (typeof input === "string") return input;
730
+ if (input instanceof ArrayBuffer) return input;
731
+ if (ArrayBuffer.isView(input)) {
732
+ const copy = new Uint8Array(input.byteLength);
733
+ copy.set(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
734
+ return copy;
735
+ }
736
+ }
737
+ function headersRecord(headers2) {
738
+ const result = {};
739
+ headers2.forEach((value, key) => {
740
+ result[key] = value;
741
+ });
742
+ return result;
743
+ }
744
+ function headers(input) {
745
+ const result = new Headers();
746
+ for (const [key, value] of Object.entries(input)) {
747
+ result.set(key, value);
748
+ }
749
+ return result;
750
+ }
751
+ function allowedCorsOrigin(origin) {
752
+ if (!origin) return;
753
+ if (origin.startsWith("http://localhost:")) return origin;
754
+ if (origin.startsWith("http://127.0.0.1:")) return origin;
755
+ if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) return origin;
756
+ }
757
+ function requireAllowedOrigin(request) {
758
+ const origin = request.headers.get("origin");
759
+ if (allowedCorsOrigin(origin)) return null;
760
+ return jsonError(
761
+ "origin_not_allowed",
762
+ "Origin is not in the allowlist",
763
+ 403
764
+ );
765
+ }
766
+ function relayCorsHeaders(request, input = new Headers()) {
767
+ const result = new Headers(input);
768
+ result.delete("access-control-allow-origin");
769
+ result.delete("access-control-allow-credentials");
770
+ result.delete("access-control-allow-headers");
771
+ result.delete("access-control-allow-methods");
772
+ result.delete("access-control-expose-headers");
773
+ result.delete("access-control-max-age");
774
+ const origin = allowedCorsOrigin(request.headers.get("origin"));
775
+ if (origin) {
776
+ result.set("access-control-allow-origin", origin);
777
+ result.set("access-control-allow-headers", "Accept, Authorization, Content-Type, X-Daytona-Skip-Preview-Warning, X-Workspace-Id");
778
+ result.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
779
+ }
780
+ return result;
781
+ }
782
+ function encoded(input) {
783
+ return Buffer.from(input).toString("base64");
784
+ }
785
+ function decoded(input) {
786
+ return new Uint8Array(Buffer.from(input, "base64"));
787
+ }
788
+ function encodedFrame(message) {
789
+ if (typeof message === "string") {
790
+ return {
791
+ binary: false,
792
+ data_base64: Buffer.from(message).toString("base64")
793
+ };
794
+ }
795
+ return {
796
+ binary: true,
797
+ data_base64: Buffer.from(message).toString("base64")
798
+ };
799
+ }
800
+ function decodedFrame(frame) {
801
+ const body = Buffer.from(frame.data_base64, "base64");
802
+ return frame.binary ? new Uint8Array(body) : body.toString("utf8");
803
+ }
804
+ function tunnelMessage(ws, input) {
805
+ if (typeof input !== "string") return;
806
+ const combined = ws.data.messageBuffer.length > 0 ? ws.data.messageBuffer + input : input;
807
+ let parsed;
808
+ try {
809
+ parsed = JSON.parse(combined);
810
+ } catch {
811
+ if (combined.length > TUNNEL_MESSAGE_BUFFER_CAP_BYTES) {
812
+ fragmentationStats.oversizedClosed += 1;
813
+ ws.data.messageBuffer = "";
814
+ ws.close(1009, "Tunnel message buffer exceeded 4 MB");
815
+ return;
816
+ }
817
+ fragmentationStats.fragmentsBuffered += 1;
818
+ ws.data.messageBuffer = combined;
819
+ return;
820
+ }
821
+ ws.data.messageBuffer = "";
822
+ return isTunnelMessage(parsed) ? parsed : void 0;
823
+ }
824
+ function sendTunnelPing(ws) {
825
+ if (ws.readyState !== WebSocket.OPEN) return;
826
+ ws.send(JSON.stringify({
827
+ type: "ping",
828
+ protocol: TUNNEL_PROTOCOL_VERSION,
829
+ id: crypto.randomUUID(),
830
+ sent_at: Date.now()
831
+ }));
832
+ }
833
+ async function audit2(options, input) {
834
+ await options.audit?.({
835
+ action: input.action,
836
+ result: "allow",
837
+ hostId: input.hostId,
838
+ workspaceId: input.workspaceId,
839
+ method: "WEBSOCKET",
840
+ path: input.path
841
+ });
842
+ }
843
+ var hostTunnelStateDebounce = /* @__PURE__ */ new Map();
844
+ function scheduleHostTunnelStateChange(options, input) {
845
+ const existing = hostTunnelStateDebounce.get(input.hostId);
846
+ const entry = existing ?? {
847
+ intendedState: "disconnected",
848
+ lastWritten: "disconnected",
849
+ flushTimer: void 0,
850
+ pending: { workspaceId: input.workspaceId, path: input.path }
851
+ };
852
+ entry.intendedState = input.state;
853
+ entry.pending = { workspaceId: input.workspaceId, path: input.path };
854
+ if (entry.flushTimer) clearTimeout(entry.flushTimer);
855
+ entry.flushTimer = setTimeout(() => {
856
+ entry.flushTimer = void 0;
857
+ if (entry.intendedState === entry.lastWritten) {
858
+ if (entry.intendedState === "disconnected") {
859
+ hostTunnelStateDebounce.delete(input.hostId);
860
+ }
861
+ return;
862
+ }
863
+ entry.lastWritten = entry.intendedState;
864
+ void audit2(options, {
865
+ action: entry.intendedState === "connected" ? "host_tunnel.connected" : "host_tunnel.disconnected",
866
+ hostId: input.hostId,
867
+ workspaceId: entry.pending.workspaceId,
868
+ path: entry.pending.path
869
+ });
870
+ }, input.debounceMs);
871
+ hostTunnelStateDebounce.set(input.hostId, entry);
872
+ }
873
+ function __resetHostTunnelStateDebounce() {
874
+ for (const entry of hostTunnelStateDebounce.values()) {
875
+ if (entry.flushTimer) clearTimeout(entry.flushTimer);
876
+ }
877
+ hostTunnelStateDebounce.clear();
878
+ }
879
+ function drainPendingChunks(entry) {
880
+ while (entry.pendingChunks.length > 0) {
881
+ const desired = entry.controller.desiredSize;
882
+ if (desired !== null && desired <= 0) break;
883
+ const next = entry.pendingChunks.shift();
884
+ entry.bytesQueued -= next.byteLength;
885
+ try {
886
+ entry.controller.enqueue(next);
887
+ } catch {
888
+ entry.pendingChunks.length = 0;
889
+ entry.bytesQueued = 0;
890
+ break;
891
+ }
892
+ }
893
+ if (entry.pendingChunks.length === 0 && entry.slowConsumerTimeout) {
894
+ clearTimeout(entry.slowConsumerTimeout);
895
+ entry.slowConsumerTimeout = void 0;
896
+ }
897
+ }
898
+ function enqueueChunkWithBackpressure(input) {
899
+ const { entry, chunk } = input;
900
+ const desired = entry.controller.desiredSize;
901
+ const overflowing = desired !== null && desired <= 0;
902
+ if (overflowing || entry.pendingChunks.length > 0) {
903
+ entry.pendingChunks.push(chunk);
904
+ entry.bytesQueued += chunk.byteLength;
905
+ if (!entry.slowConsumerTimeout) {
906
+ slowConsumerStats.overflowEvents += 1;
907
+ entry.slowConsumerTimeout = setTimeout(() => {
908
+ slowConsumerStats.timerFired += 1;
909
+ const error = new Error("slow_consumer_timeout: downstream consumer did not drain in time");
910
+ try {
911
+ entry.controller.error(error);
912
+ } catch {
913
+ }
914
+ clearTimeout(entry.timeout);
915
+ entry.pendingChunks.length = 0;
916
+ entry.bytesQueued = 0;
917
+ if (!entry.responseStarted) {
918
+ entry.resolve(jsonError("slow_consumer_timeout", "Downstream consumer did not drain in time", 503));
919
+ }
920
+ input.ws.data.pending.delete(input.requestId);
921
+ slowConsumerStats.droppedRequests += 1;
922
+ }, input.slowConsumerTimeoutMs);
923
+ }
924
+ return;
925
+ }
926
+ try {
927
+ entry.controller.enqueue(chunk);
928
+ } catch {
929
+ }
930
+ }
931
+ var __slowConsumerInternalsForTest = {
932
+ enqueueChunkWithBackpressure
933
+ };
934
+ async function tunnelHttpRequest(input) {
935
+ if (input.ws.readyState !== WebSocket.OPEN) {
936
+ return new Response("User-hosted workspace is offline", { status: 503 });
937
+ }
938
+ if (input.ws.data.pending.size >= TUNNEL_PENDING_HTTP_CAP) {
939
+ return jsonError(
940
+ "too_many_in_flight",
941
+ "User-hosted tunnel has too many in-flight HTTP requests",
942
+ 429
943
+ );
944
+ }
945
+ const requestId = crypto.randomUUID();
946
+ let controller;
947
+ const stream = new ReadableStream({
948
+ start(next) {
949
+ controller = next;
950
+ },
951
+ pull() {
952
+ const entry = input.ws.data.pending.get(requestId);
953
+ if (!entry) return;
954
+ drainPendingChunks(entry);
955
+ }
956
+ }, new ByteLengthQueuingStrategy({ highWaterMark: input.slowConsumerHighWaterMarkBytes }));
957
+ const pending = new Promise((resolve, reject) => {
958
+ const timeout = setTimeout(() => {
959
+ input.ws.data.pending.delete(requestId);
960
+ reject(new Error("User-hosted tunnel response timed out"));
961
+ }, 3e4);
962
+ input.ws.data.pending.set(requestId, {
963
+ controller,
964
+ stream,
965
+ resolve,
966
+ reject,
967
+ timeout,
968
+ pendingChunks: [],
969
+ bytesQueued: 0,
970
+ responseStarted: false
971
+ });
972
+ });
973
+ const body = input.request.method === "GET" || input.request.method === "HEAD" ? void 0 : encoded(await input.request.arrayBuffer());
974
+ input.ws.send(JSON.stringify({
975
+ type: "http.request",
976
+ protocol: TUNNEL_PROTOCOL_VERSION,
977
+ request_id: requestId,
978
+ workspace_id: input.workspaceId,
979
+ method: input.request.method,
980
+ path: input.path,
981
+ headers: headersRecord(workspaceRelayForwardHeaders(
982
+ input.request.headers,
983
+ input.relayHostToken,
984
+ input.workspaceId,
985
+ // T26: user-hosted tunnel — strip Cookie to avoid leaking browser
986
+ // cookies to the host process running on the user's laptop.
987
+ { userHosted: true }
988
+ )),
989
+ ...body ? { body_base64: body } : {},
990
+ end: true
991
+ }));
992
+ return await pending;
993
+ }
994
+ async function directHttpRequest(input) {
995
+ const init = {
996
+ method: input.request.method,
997
+ headers: workspaceRelayForwardHeaders(
998
+ input.request.headers,
999
+ input.relayHostToken,
1000
+ input.workspaceId,
1001
+ // T26: cloud-vm direct path. Cookies pass through (default) — workspace
1002
+ // dashboards in the VM may legitimately need session cookies.
1003
+ { userHosted: false }
1004
+ ),
1005
+ body: input.request.body,
1006
+ duplex: "half",
1007
+ redirect: "manual"
1008
+ };
1009
+ if (input.request.method === "GET" || input.request.method === "HEAD") delete init.body;
1010
+ const upstream = await fetch(input.targetUrl, init);
1011
+ return new Response(upstream.body, {
1012
+ status: upstream.status,
1013
+ statusText: upstream.statusText,
1014
+ headers: relayCorsHeaders(input.request, upstream.headers)
1015
+ });
1016
+ }
1017
+ function hostTunnel(hostTunnels, hostId) {
1018
+ const tunnel = hostTunnels.get(hostId);
1019
+ if (tunnel?.readyState === WebSocket.OPEN) return tunnel;
1020
+ return void 0;
1021
+ }
1022
+ async function authorizeHostTunnel(options, bunOptions, request, input) {
1023
+ if (bunOptions.authorizeHostTunnel) return await bunOptions.authorizeHostTunnel(request, input);
1024
+ const token = bearerToken(request.headers.get("authorization"));
1025
+ if (!token) return false;
1026
+ try {
1027
+ await verifyHostTunnelToken(token, options.runtimeAccessKey, input);
1028
+ return true;
1029
+ } catch (err) {
1030
+ if (err instanceof WorkspaceRelayAuthError) return false;
1031
+ throw err;
1032
+ }
1033
+ }
1034
+ function createWorkspaceRelayBun(options, bunOptions = {}) {
1035
+ const hostTunnels = /* @__PURE__ */ new Map();
1036
+ const hostTunnelRegistrations = /* @__PURE__ */ new Map();
1037
+ let draining = false;
1038
+ const drainController = {
1039
+ isDraining: () => draining,
1040
+ setDraining: (value) => {
1041
+ draining = value;
1042
+ },
1043
+ pendingCount: () => {
1044
+ let total = 0;
1045
+ for (const tunnel of hostTunnels.values()) {
1046
+ total += tunnel.data.pending.size;
1047
+ }
1048
+ return total;
1049
+ },
1050
+ waitForDrain: async (timeoutMs) => {
1051
+ const deadline = Date.now() + Math.max(0, timeoutMs);
1052
+ const pollMs = 50;
1053
+ while (true) {
1054
+ const remaining = drainController.pendingCount();
1055
+ if (remaining === 0) return { drained: true, remaining: 0 };
1056
+ const now = Date.now();
1057
+ if (now >= deadline) return { drained: false, remaining };
1058
+ const sleep = Math.min(pollMs, Math.max(1, deadline - now));
1059
+ await new Promise((resolve) => setTimeout(resolve, sleep));
1060
+ }
1061
+ }
1062
+ };
1063
+ const mergedMetricsSources = {
1064
+ fragmentation: options.metricsSources?.fragmentation ?? getFragmentationStats,
1065
+ slowConsumer: options.metricsSources?.slowConsumer ?? getSlowConsumerStats,
1066
+ drainPending: options.metricsSources?.drainPending ?? drainController.pendingCount
1067
+ };
1068
+ const requestRemoteAddress = /* @__PURE__ */ new WeakMap();
1069
+ const metricsRemoteAddressResolver = options.metricsRemoteAddress ?? ((request) => requestRemoteAddress.get(request));
1070
+ const app = createWorkspaceRelay({
1071
+ ...options,
1072
+ isDraining: drainController.isDraining,
1073
+ metricsSources: mergedMetricsSources,
1074
+ metricsRemoteAddress: metricsRemoteAddressResolver
1075
+ });
1076
+ function getRegistrationTracker(hostId) {
1077
+ let tracker = hostTunnelRegistrations.get(hostId);
1078
+ if (!tracker) {
1079
+ tracker = { current: 0, recent: [] };
1080
+ hostTunnelRegistrations.set(hostId, tracker);
1081
+ }
1082
+ return tracker;
1083
+ }
1084
+ function pruneReconnects(tracker, now) {
1085
+ const cutoff = now - HOST_TUNNEL_REGISTRATION_RECONNECT_WINDOW_MS;
1086
+ while (tracker.recent.length && tracker.recent[0] < cutoff) {
1087
+ tracker.recent.shift();
1088
+ }
1089
+ }
1090
+ return {
1091
+ async fetch(request, server) {
1092
+ const url = new URL(request.url);
1093
+ const workspaceId = workspaceIdFromPath(url.pathname);
1094
+ const hostId = hostIdFromTunnelPath(url.pathname);
1095
+ try {
1096
+ const ip = server.requestIP(request)?.address;
1097
+ if (ip) requestRemoteAddress.set(request, ip);
1098
+ } catch {
1099
+ }
1100
+ if (draining && websocketRequest(request) && hostId) {
1101
+ return jsonError(
1102
+ "relay_draining",
1103
+ "Workspace relay is shutting down; new tunnel registrations are not accepted",
1104
+ 503
1105
+ );
1106
+ }
1107
+ if (draining && workspaceId) {
1108
+ const headers2 = relayCorsHeaders(request);
1109
+ const body = JSON.stringify({
1110
+ error: {
1111
+ code: "relay_draining",
1112
+ message: "Workspace relay is shutting down; try another instance"
1113
+ }
1114
+ });
1115
+ headers2.set("content-type", "application/json");
1116
+ return new Response(body, { status: 503, headers: headers2 });
1117
+ }
1118
+ if (websocketRequest(request) && hostId) {
1119
+ const workspaceIds = url.searchParams.getAll("workspaceId");
1120
+ if (!workspaceIds.length || !options.directory) {
1121
+ return new Response("Host tunnel registration is unavailable", { status: 503 });
1122
+ }
1123
+ if (!await authorizeHostTunnel(options, bunOptions, request, { hostId, workspaceIds })) {
1124
+ return new Response("Host tunnel registration denied", { status: 403 });
1125
+ }
1126
+ const tracker = getRegistrationTracker(hostId);
1127
+ const now = Date.now();
1128
+ pruneReconnects(tracker, now);
1129
+ if (tracker.current >= HOST_TUNNEL_REGISTRATION_CONCURRENT_CAP) {
1130
+ return jsonError(
1131
+ "too_many_concurrent_host_tunnels",
1132
+ "Too many concurrent host-tunnel registrations for this host",
1133
+ 429
1134
+ );
1135
+ }
1136
+ if (tracker.recent.length >= HOST_TUNNEL_REGISTRATION_RECONNECT_CAP) {
1137
+ return jsonError(
1138
+ "too_many_host_tunnel_reconnects",
1139
+ "Too many host-tunnel reconnects for this host within the last 60 seconds",
1140
+ 429
1141
+ );
1142
+ }
1143
+ if (server.upgrade(request, {
1144
+ data: {
1145
+ kind: "host-tunnel",
1146
+ hostId,
1147
+ workspaceIds,
1148
+ pending: /* @__PURE__ */ new Map(),
1149
+ channels: /* @__PURE__ */ new Map(),
1150
+ messageBuffer: ""
1151
+ }
1152
+ })) {
1153
+ tracker.current += 1;
1154
+ tracker.recent.push(now);
1155
+ return;
1156
+ }
1157
+ return new Response("WebSocket upgrade failed", { status: 400 });
1158
+ }
1159
+ if (!workspaceId) return app.request(request);
1160
+ if (request.method === "OPTIONS") {
1161
+ return new Response(null, {
1162
+ status: 204,
1163
+ headers: relayCorsHeaders(request)
1164
+ });
1165
+ }
1166
+ const relay = await authorizeWorkspaceRelayRequest(options, request, workspaceId);
1167
+ if (!relay.ok) return relay.response;
1168
+ if (!websocketRequest(request) && relay.request.target.access === "user-hosted") {
1169
+ const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId);
1170
+ return tunnel ? await tunnelHttpRequest({
1171
+ ws: tunnel,
1172
+ request,
1173
+ workspaceId: relay.request.target.workspaceId,
1174
+ path: `${relay.request.path}${url.search}`,
1175
+ relayHostToken: relay.request.relayHostToken,
1176
+ slowConsumerHighWaterMarkBytes: bunOptions.slowConsumerHighWaterMarkBytes ?? SLOW_CONSUMER_HIGH_WATER_MARK_BYTES_DEFAULT,
1177
+ slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
1178
+ }) : new Response("User-hosted workspace is offline", { status: 503 });
1179
+ }
1180
+ if (!websocketRequest(request)) {
1181
+ return await directHttpRequest({
1182
+ request,
1183
+ targetUrl: workspaceRelayTargetUrl(
1184
+ relay.request.target,
1185
+ relay.request.path,
1186
+ url.search
1187
+ ).toString(),
1188
+ relayHostToken: relay.request.relayHostToken,
1189
+ workspaceId: relay.request.target.workspaceId
1190
+ });
1191
+ }
1192
+ const originDenied = requireAllowedOrigin(request);
1193
+ if (originDenied) return originDenied;
1194
+ if (relay.request.target.access === "user-hosted") {
1195
+ const tunnel = hostTunnel(hostTunnels, relay.request.target.hostId);
1196
+ if (!tunnel) {
1197
+ return new Response("User-hosted workspace is offline", { status: 503 });
1198
+ }
1199
+ if (tunnel.data.channels.size >= TUNNEL_CHANNEL_CAP) {
1200
+ return jsonError(
1201
+ "too_many_channels",
1202
+ "User-hosted tunnel has too many active WebSocket channels",
1203
+ 503
1204
+ );
1205
+ }
1206
+ if (server.upgrade(request, {
1207
+ data: {
1208
+ kind: "user-hosted-client",
1209
+ hostId: relay.request.target.hostId,
1210
+ workspaceId: relay.request.target.workspaceId,
1211
+ channelId: crypto.randomUUID(),
1212
+ path: `${relay.request.path}${url.search}`,
1213
+ relayHostToken: relay.request.relayHostToken
1214
+ }
1215
+ })) {
1216
+ return;
1217
+ }
1218
+ return new Response("WebSocket upgrade failed", { status: 400 });
1219
+ }
1220
+ if (server.upgrade(request, {
1221
+ data: {
1222
+ kind: "client",
1223
+ upstreamUrl: workspaceRelayTargetUrl(
1224
+ relay.request.target,
1225
+ relay.request.path,
1226
+ url.search
1227
+ ).toString().replace(/^http/, "ws"),
1228
+ headers: headersRecord(workspaceRelayForwardHeaders(
1229
+ new Headers(),
1230
+ relay.request.relayHostToken,
1231
+ relay.request.target.workspaceId,
1232
+ // T26: cloud-vm WS upgrade. Cookies pass through (default).
1233
+ { userHosted: false }
1234
+ )),
1235
+ queue: []
1236
+ }
1237
+ })) {
1238
+ return;
1239
+ }
1240
+ return new Response("WebSocket upgrade failed", { status: 400 });
1241
+ },
1242
+ websocket: {
1243
+ maxPayloadLength: WS_MAX_PAYLOAD_LENGTH_BYTES,
1244
+ message(ws, message) {
1245
+ if (ws.data.kind === "host-tunnel") {
1246
+ const parsed = tunnelMessage(
1247
+ ws,
1248
+ message
1249
+ );
1250
+ if (parsed?.type === "ping") {
1251
+ options.directory?.recordPong(ws.data.hostId);
1252
+ ws.send(JSON.stringify(makeTunnelPong(parsed)));
1253
+ }
1254
+ if (parsed?.type === "pong") {
1255
+ options.directory?.recordPong(ws.data.hostId);
1256
+ }
1257
+ if (parsed?.type === "http.response.start") {
1258
+ const pending = ws.data.pending.get(parsed.request_id);
1259
+ if (pending) {
1260
+ pending.responseStarted = true;
1261
+ pending.resolve(new Response(pending.stream, {
1262
+ status: parsed.status,
1263
+ headers: headers(parsed.headers)
1264
+ }));
1265
+ }
1266
+ }
1267
+ if (parsed?.type === "http.response.chunk") {
1268
+ const entry = ws.data.pending.get(parsed.request_id);
1269
+ if (entry) {
1270
+ enqueueChunkWithBackpressure({
1271
+ ws,
1272
+ requestId: parsed.request_id,
1273
+ entry,
1274
+ chunk: decoded(parsed.body_base64),
1275
+ slowConsumerTimeoutMs: bunOptions.slowConsumerTimeoutMs ?? SLOW_CONSUMER_TIMEOUT_MS_DEFAULT
1276
+ });
1277
+ }
1278
+ }
1279
+ if (parsed?.type === "http.response.end") {
1280
+ const pending = ws.data.pending.get(parsed.request_id);
1281
+ if (pending) {
1282
+ clearTimeout(pending.timeout);
1283
+ if (pending.slowConsumerTimeout) {
1284
+ clearTimeout(pending.slowConsumerTimeout);
1285
+ pending.slowConsumerTimeout = void 0;
1286
+ }
1287
+ drainPendingChunks(pending);
1288
+ if (pending.pendingChunks.length > 0) {
1289
+ for (const chunk of pending.pendingChunks) {
1290
+ try {
1291
+ pending.controller.enqueue(chunk);
1292
+ } catch {
1293
+ break;
1294
+ }
1295
+ }
1296
+ pending.pendingChunks.length = 0;
1297
+ pending.bytesQueued = 0;
1298
+ }
1299
+ try {
1300
+ pending.controller.close();
1301
+ } catch {
1302
+ }
1303
+ ws.data.pending.delete(parsed.request_id);
1304
+ }
1305
+ }
1306
+ if (parsed?.type === "ws.frame") {
1307
+ const frame = parsed;
1308
+ ws.data.channels.get(frame.channel_id)?.send(decodedFrame(frame));
1309
+ }
1310
+ if (parsed?.type === "ws.close") {
1311
+ const close = parsed;
1312
+ const channel = ws.data.channels.get(close.channel_id);
1313
+ if (channel) {
1314
+ ws.data.channels.delete(close.channel_id);
1315
+ channel.close(close.code, close.reason);
1316
+ }
1317
+ }
1318
+ return;
1319
+ }
1320
+ if (ws.data.kind === "user-hosted-client") {
1321
+ const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
1322
+ if (!tunnel) {
1323
+ ws.close(1011, "User-hosted tunnel disconnected");
1324
+ return;
1325
+ }
1326
+ tunnel.send(JSON.stringify({
1327
+ type: "ws.frame",
1328
+ protocol: TUNNEL_PROTOCOL_VERSION,
1329
+ channel_id: ws.data.channelId,
1330
+ ...encodedFrame(message)
1331
+ }));
1332
+ return;
1333
+ }
1334
+ if (ws.data.upstream?.readyState === WebSocket.OPEN) {
1335
+ ws.data.upstream.send(message);
1336
+ return;
1337
+ }
1338
+ ws.data.queue.push(message);
1339
+ },
1340
+ open(ws) {
1341
+ if (ws.data.kind === "host-tunnel") {
1342
+ hostTunnels.set(ws.data.hostId, ws);
1343
+ options.directory?.registerHostTunnel({
1344
+ hostId: ws.data.hostId,
1345
+ workspaceIds: ws.data.workspaceIds
1346
+ });
1347
+ ws.data.heartbeat = setInterval(() => {
1348
+ sendTunnelPing(ws);
1349
+ }, bunOptions.hostTunnelPingIntervalMs ?? 15e3);
1350
+ scheduleHostTunnelStateChange(options, {
1351
+ hostId: ws.data.hostId,
1352
+ workspaceId: ws.data.workspaceIds[0],
1353
+ path: `/host-tunnels/${ws.data.hostId}`,
1354
+ state: "connected",
1355
+ debounceMs: bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
1356
+ });
1357
+ return;
1358
+ }
1359
+ if (ws.data.kind === "user-hosted-client") {
1360
+ const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
1361
+ if (!tunnel) {
1362
+ ws.close(1011, "User-hosted tunnel disconnected");
1363
+ return;
1364
+ }
1365
+ tunnel.data.channels.set(ws.data.channelId, ws);
1366
+ tunnel.send(JSON.stringify({
1367
+ type: "ws.open",
1368
+ protocol: TUNNEL_PROTOCOL_VERSION,
1369
+ channel_id: ws.data.channelId,
1370
+ workspace_id: ws.data.workspaceId,
1371
+ path: ws.data.path,
1372
+ headers: headersRecord(workspaceRelayForwardHeaders(
1373
+ new Headers(),
1374
+ ws.data.relayHostToken,
1375
+ ws.data.workspaceId,
1376
+ // T26: user-hosted tunnel WS upgrade — strip Cookie. Headers are
1377
+ // empty here today, but pass the flag for consistency in case
1378
+ // upstream client headers are forwarded in the future.
1379
+ { userHosted: true }
1380
+ ))
1381
+ }));
1382
+ return;
1383
+ }
1384
+ const data = ws.data;
1385
+ const upstream = new WebSocket(data.upstreamUrl, {
1386
+ headers: data.headers
1387
+ });
1388
+ upstream.binaryType = "arraybuffer";
1389
+ data.upstream = upstream;
1390
+ upstream.onopen = () => {
1391
+ for (const item of data.queue.splice(0)) upstream.send(item);
1392
+ };
1393
+ upstream.onmessage = (event) => {
1394
+ const payload = relayWebSocketPayload(event.data);
1395
+ if (payload !== void 0) ws.send(payload);
1396
+ };
1397
+ upstream.onclose = (event) => {
1398
+ ws.close(event.code, event.reason);
1399
+ };
1400
+ upstream.onerror = () => {
1401
+ ws.close(1011, "Upstream WebSocket connection failed");
1402
+ };
1403
+ },
1404
+ close(ws, code, reason) {
1405
+ if (ws.data.kind === "host-tunnel") {
1406
+ if (ws.data.heartbeat) clearInterval(ws.data.heartbeat);
1407
+ hostTunnels.delete(ws.data.hostId);
1408
+ const tracker = hostTunnelRegistrations.get(ws.data.hostId);
1409
+ if (tracker) {
1410
+ tracker.current = Math.max(0, tracker.current - 1);
1411
+ pruneReconnects(tracker, Date.now());
1412
+ if (tracker.current === 0 && tracker.recent.length === 0) {
1413
+ hostTunnelRegistrations.delete(ws.data.hostId);
1414
+ }
1415
+ }
1416
+ for (const pending of ws.data.pending.values()) {
1417
+ clearTimeout(pending.timeout);
1418
+ pending.reject(new Error("User-hosted tunnel disconnected"));
1419
+ }
1420
+ ws.data.pending.clear();
1421
+ for (const channel of ws.data.channels.values()) {
1422
+ channel.close(1011, "User-hosted tunnel disconnected");
1423
+ }
1424
+ ws.data.channels.clear();
1425
+ options.directory?.disconnectHost(ws.data.hostId);
1426
+ scheduleHostTunnelStateChange(options, {
1427
+ hostId: ws.data.hostId,
1428
+ workspaceId: ws.data.workspaceIds[0],
1429
+ path: `/host-tunnels/${ws.data.hostId}`,
1430
+ state: "disconnected",
1431
+ debounceMs: bunOptions.hostTunnelStateDebounceMs ?? HOST_TUNNEL_STATE_DEBOUNCE_MS_DEFAULT
1432
+ });
1433
+ return;
1434
+ }
1435
+ if (ws.data.kind === "user-hosted-client") {
1436
+ const tunnel = hostTunnel(hostTunnels, ws.data.hostId);
1437
+ tunnel?.data.channels.delete(ws.data.channelId);
1438
+ tunnel?.send(JSON.stringify({
1439
+ type: "ws.close",
1440
+ protocol: TUNNEL_PROTOCOL_VERSION,
1441
+ channel_id: ws.data.channelId,
1442
+ code,
1443
+ reason
1444
+ }));
1445
+ return;
1446
+ }
1447
+ ws.data.upstream?.close(code, reason);
1448
+ }
1449
+ },
1450
+ drain: drainController
1451
+ };
1452
+ }
1453
+
1454
+ // src/directory.ts
1455
+ function createWorkspaceRelayDirectory(options = {}) {
1456
+ const ttlMs = Math.max(1, options.ttlMs ?? 45e3);
1457
+ const now = options.now ?? Date.now;
1458
+ const sweepIntervalMs = options.sweepIntervalMs ?? 3e4;
1459
+ const hosts = /* @__PURE__ */ new Map();
1460
+ const alive = (presence) => {
1461
+ if (!presence) return;
1462
+ if (presence.expiresAt <= now()) {
1463
+ hosts.delete(presence.hostId);
1464
+ return;
1465
+ }
1466
+ return presence;
1467
+ };
1468
+ const touch = (presence) => {
1469
+ const timestamp = now();
1470
+ const next = {
1471
+ ...presence,
1472
+ lastPongAt: timestamp,
1473
+ expiresAt: timestamp + ttlMs
1474
+ };
1475
+ hosts.set(next.hostId, next);
1476
+ return next;
1477
+ };
1478
+ const sweep = () => {
1479
+ const at = now();
1480
+ for (const [hostId, presence] of hosts) {
1481
+ if (presence.expiresAt <= at) {
1482
+ hosts.delete(hostId);
1483
+ }
1484
+ }
1485
+ };
1486
+ let intervalHandle;
1487
+ if (sweepIntervalMs > 0) {
1488
+ intervalHandle = setInterval(sweep, sweepIntervalMs);
1489
+ }
1490
+ return {
1491
+ registerHostTunnel(input) {
1492
+ const timestamp = now();
1493
+ const next = {
1494
+ hostId: input.hostId,
1495
+ workspaceIds: [...new Set(input.workspaceIds)],
1496
+ connectedAt: timestamp,
1497
+ lastPongAt: timestamp,
1498
+ expiresAt: timestamp + ttlMs
1499
+ };
1500
+ hosts.set(next.hostId, next);
1501
+ return next;
1502
+ },
1503
+ recordPong(hostId) {
1504
+ const presence = alive(hosts.get(hostId));
1505
+ return presence ? touch(presence) : void 0;
1506
+ },
1507
+ disconnectHost(hostId) {
1508
+ hosts.delete(hostId);
1509
+ },
1510
+ activeHost(input) {
1511
+ const presence = alive(hosts.get(input.hostId));
1512
+ if (!presence?.workspaceIds.includes(input.workspaceId)) return;
1513
+ return presence;
1514
+ },
1515
+ sweep,
1516
+ size() {
1517
+ const at = now();
1518
+ let count = 0;
1519
+ for (const presence of hosts.values()) {
1520
+ if (presence.expiresAt > at) count += 1;
1521
+ }
1522
+ return count;
1523
+ },
1524
+ dispose() {
1525
+ if (intervalHandle !== void 0) {
1526
+ clearInterval(intervalHandle);
1527
+ intervalHandle = void 0;
1528
+ }
1529
+ }
1530
+ };
1531
+ }
1532
+ function disposeWorkspaceRelayDirectory(directory) {
1533
+ directory.dispose();
1534
+ }
1535
+ export {
1536
+ WorkspaceRelayAuthError,
1537
+ __resetHostTunnelStateDebounce,
1538
+ __slowConsumerInternalsForTest,
1539
+ authorizeWorkspaceRelayRequest,
1540
+ createWorkspaceRelay,
1541
+ createWorkspaceRelayBun,
1542
+ createWorkspaceRelayDirectory,
1543
+ disposeAuditSampler,
1544
+ disposeWorkspaceRelayDirectory,
1545
+ getAuditSuppressedCount,
1546
+ getFragmentationStats,
1547
+ getSlowConsumerStats,
1548
+ hostTunnelTokenAudience,
1549
+ mintHostTunnelToken,
1550
+ mintRelayHostToken,
1551
+ mintRuntimeAccessToken,
1552
+ relayHostTokenAudience,
1553
+ relayHostTokenIssuer,
1554
+ resetFragmentationStats,
1555
+ resetSlowConsumerStats,
1556
+ runtimeAccessTokenAudience,
1557
+ runtimeAccessTokenIssuer,
1558
+ verifyHostTunnelToken,
1559
+ verifyRelayHostToken,
1560
+ verifyRuntimeAccessToken,
1561
+ workspaceRelayForwardHeaders,
1562
+ workspaceRelayTargetUrl
1563
+ };