@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/README.md +135 -0
- package/dist/auth.d.ts +107 -0
- package/dist/bun.d.ts +107 -0
- package/dist/directory.d.ts +55 -0
- package/dist/http.d.ts +7 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.mjs +1563 -0
- package/dist/main.d.ts +160 -0
- package/dist/main.mjs +1933 -0
- package/dist/server.d.ts +226 -0
- package/dist/synthetic.d.ts +60 -0
- package/package.json +36 -0
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
|
+
};
|