@cortexkit/anthropic-auth-core 1.23.0 → 2.0.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 +2 -0
- package/dist/accounts.d.ts +3 -16
- package/dist/cachekeep.d.ts +9 -0
- package/dist/claude-code.d.ts +16 -0
- package/dist/claustrum-enrollment.d.ts +2 -0
- package/dist/claustrum-scoped-roster.d.ts +4 -0
- package/dist/claustrum-scoped-runtime.d.ts +3 -0
- package/dist/claustrum-scoped.d.ts +14 -0
- package/dist/claustrum.d.ts +0 -209
- package/dist/commands/account.d.ts +2 -3
- package/dist/index.d.ts +44 -1
- package/dist/index.js +29700 -38
- package/dist/internal-types/LICENSE +21 -0
- package/dist/internal-types/commands/builtins.d.ts +71 -0
- package/dist/internal-types/commands/index.d.ts +10 -0
- package/dist/internal-types/commands/menu.d.ts +61 -0
- package/dist/internal-types/commands/model.d.ts +238 -0
- package/dist/internal-types/commands/pi.d.ts +23 -0
- package/dist/internal-types/commands/seam.d.ts +72 -0
- package/dist/internal-types/fs/refresh-file-lock.d.ts +33 -0
- package/dist/internal-types/logger/capture-sink.d.ts +14 -0
- package/dist/internal-types/logger/engine.d.ts +100 -0
- package/dist/internal-types/logger/index.d.ts +6 -0
- package/dist/internal-types/logger/redact.d.ts +11 -0
- package/dist/internal-types/pi-slot/index.d.ts +3 -0
- package/dist/internal-types/pi-slot/slot.d.ts +49 -0
- package/dist/internal-types/quota/codec.d.ts +11 -0
- package/dist/internal-types/quota/format.d.ts +46 -0
- package/dist/internal-types/quota/index.d.ts +10 -0
- package/dist/internal-types/quota/map.d.ts +63 -0
- package/dist/internal-types/quota/merge.d.ts +48 -0
- package/dist/internal-types/quota/projection.d.ts +64 -0
- package/dist/internal-types/store/attribution.d.ts +28 -0
- package/dist/internal-types/store/errors.d.ts +55 -0
- package/dist/internal-types/store/hooks.d.ts +12 -0
- package/dist/internal-types/store/identity.d.ts +39 -0
- package/dist/internal-types/store/index.d.ts +21 -0
- package/dist/internal-types/store/mutate.d.ts +189 -0
- package/dist/internal-types/store/pool.d.ts +196 -0
- package/dist/internal-types/store/provider-state.d.ts +131 -0
- package/dist/internal-types/store/publication.d.ts +47 -0
- package/dist/internal-types/store/pull.d.ts +36 -0
- package/dist/internal-types/store/refresh-lock.d.ts +63 -0
- package/dist/internal-types/store/refresh.d.ts +72 -0
- package/dist/internal-types/store/rows.d.ts +313 -0
- package/dist/internal-types/store/runtime.d.ts +36 -0
- package/dist/internal-types/store/schema.d.ts +443 -0
- package/dist/internal-types/store/settings.d.ts +63 -0
- package/dist/internal-types/store/torn.d.ts +78 -0
- package/dist/migration-source.d.ts +33 -0
- package/dist/models.d.ts +41 -0
- package/dist/native-account-runtime.d.ts +121 -0
- package/dist/native-account-view.d.ts +126 -0
- package/dist/native-command-effects.d.ts +59 -0
- package/dist/native-credential-validation.d.ts +42 -0
- package/dist/native-custody-activation.d.ts +45 -0
- package/dist/native-custody.d.ts +114 -0
- package/dist/native-enrollment-authority.d.ts +2 -0
- package/dist/native-host-auth-read.d.ts +3 -0
- package/dist/native-host-auth-write.d.ts +51 -0
- package/dist/native-host-auth.d.ts +12 -0
- package/dist/native-local-credential-service.d.ts +76 -0
- package/dist/native-local-runtime-readers.d.ts +22 -0
- package/dist/native-lock-observer.d.ts +12 -0
- package/dist/native-menu-executor.d.ts +243 -0
- package/dist/native-menu-model.d.ts +142 -0
- package/dist/native-migration-proof.d.ts +28 -0
- package/dist/native-migration-source.d.ts +47 -0
- package/dist/native-migration.d.ts +66 -0
- package/dist/native-quota-codec.d.ts +32 -0
- package/dist/native-refresh-coordinator.d.ts +240 -0
- package/dist/native-roster-seed.d.ts +115 -0
- package/dist/native-runtime-registry.d.ts +19 -0
- package/dist/native-runtime.d.ts +204 -0
- package/dist/native-transient-account.d.ts +38 -0
- package/dist/native-ui.d.ts +45 -0
- package/dist/native-usage-publication.d.ts +11 -0
- package/dist/native-vault-admission.d.ts +8 -0
- package/dist/native-vault-connection.d.ts +33 -0
- package/dist/native-vault-paths.d.ts +13 -0
- package/dist/native-vault-runtime.d.ts +102 -0
- package/dist/native-vault-seed.d.ts +16 -0
- package/dist/pool-authority.d.ts +159 -0
- package/dist/pool-binding.d.ts +32 -0
- package/dist/pool-paths.d.ts +21 -0
- package/dist/pool-store.d.ts +32 -0
- package/dist/provider-error.d.ts +139 -0
- package/dist/quota-manager.d.ts +18 -7
- package/dist/relay.d.ts +14 -1
- package/dist/request-history.d.ts +34 -0
- package/dist/sse.d.ts +81 -0
- package/dist/sticky-routing.d.ts +27 -0
- package/package.json +9 -4
- package/dist/accounts.js +0 -3881
- package/dist/auth.js +0 -185
- package/dist/cache1h.js +0 -97
- package/dist/cachekeep-registry.js +0 -124
- package/dist/cachekeep.js +0 -604
- package/dist/cch.js +0 -172
- package/dist/claude-code.js +0 -403
- package/dist/claustrum-enrollment-registry.d.ts +0 -17
- package/dist/claustrum-enrollment-registry.js +0 -194
- package/dist/claustrum-enrollment.js +0 -485
- package/dist/claustrum-scoped-roster.js +0 -194
- package/dist/claustrum-scoped-runtime.js +0 -230
- package/dist/claustrum-scoped.js +0 -193
- package/dist/claustrum.js +0 -1471
- package/dist/commands/account.js +0 -368
- package/dist/constants.js +0 -83
- package/dist/custom-headers.js +0 -102
- package/dist/dump.js +0 -616
- package/dist/fast.js +0 -66
- package/dist/json.js +0 -10
- package/dist/killswitch.js +0 -132
- package/dist/logger.js +0 -288
- package/dist/logging.js +0 -59
- package/dist/mid-conversation-output-config.js +0 -89
- package/dist/model-remap.js +0 -72
- package/dist/models.js +0 -177
- package/dist/network-errors.js +0 -27
- package/dist/oauth-profile.js +0 -60
- package/dist/pkce.js +0 -17
- package/dist/prime.js +0 -841
- package/dist/provider.js +0 -0
- package/dist/quota-header-feed.js +0 -331
- package/dist/quota-headers.js +0 -121
- package/dist/quota-manager.js +0 -856
- package/dist/quotas.js +0 -141
- package/dist/relay.js +0 -1303
- package/dist/routing.js +0 -94
- package/dist/start.js +0 -29
- package/dist/sticky-routing.js +0 -541
- package/dist/thinking-binding.js +0 -54
- package/dist/token-fingerprint.js +0 -8
package/dist/relay.js
DELETED
|
@@ -1,1303 +0,0 @@
|
|
|
1
|
-
import { Buffer } from 'node:buffer';
|
|
2
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
-
import { dumpRelayRequest } from './dump.js';
|
|
4
|
-
import { relayLog } from './logger.js';
|
|
5
|
-
class RelayAuthorizationError extends Error {
|
|
6
|
-
failure;
|
|
7
|
-
constructor(failure) {
|
|
8
|
-
super('Relay request authorization failed');
|
|
9
|
-
this.failure = failure;
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
async function authorizeRelayPayload(payload, authorize) {
|
|
13
|
-
if (!authorize)
|
|
14
|
-
return undefined;
|
|
15
|
-
try {
|
|
16
|
-
const attempt = await authorize();
|
|
17
|
-
payload.upstream = {
|
|
18
|
-
...payload.upstream,
|
|
19
|
-
headers: jsonHeaders(attempt.headers),
|
|
20
|
-
};
|
|
21
|
-
return attempt;
|
|
22
|
-
}
|
|
23
|
-
catch (error) {
|
|
24
|
-
throw new RelayAuthorizationError(error);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
class RelayStateMismatchError extends Error {
|
|
28
|
-
}
|
|
29
|
-
class RelayWebSocketConnectionResetError extends Error {
|
|
30
|
-
code = 'ECONNRESET';
|
|
31
|
-
syscall = 'websocket';
|
|
32
|
-
closeCode;
|
|
33
|
-
closeReason;
|
|
34
|
-
wasClean;
|
|
35
|
-
constructor(message, event) {
|
|
36
|
-
super(message);
|
|
37
|
-
this.name = 'RelayWebSocketConnectionResetError';
|
|
38
|
-
this.closeCode = event?.code;
|
|
39
|
-
this.closeReason = event?.reason;
|
|
40
|
-
this.wasClean = event?.wasClean;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
const sessionState = new Map();
|
|
44
|
-
const websocketSessions = new Map();
|
|
45
|
-
const loggedRelayConfigMessages = new Set();
|
|
46
|
-
const RELAY_SESSION_STATE_TTL_MS = 2 * 60 * 60_000;
|
|
47
|
-
const RELAY_WEBSOCKET_IDLE_TTL_MS = 30 * 60_000;
|
|
48
|
-
const MAX_RELAY_SESSION_STATES = 128;
|
|
49
|
-
const MAX_RELAY_WEBSOCKET_SESSIONS = 32;
|
|
50
|
-
let nextRequestId = 0;
|
|
51
|
-
function createRequestId() {
|
|
52
|
-
nextRequestId += 1;
|
|
53
|
-
return `req_${Date.now().toString(36)}_${nextRequestId.toString(36)}`;
|
|
54
|
-
}
|
|
55
|
-
function logRelayConfigOnce(message) {
|
|
56
|
-
if (loggedRelayConfigMessages.has(message))
|
|
57
|
-
return;
|
|
58
|
-
loggedRelayConfigMessages.add(message);
|
|
59
|
-
relayLog(message);
|
|
60
|
-
}
|
|
61
|
-
export function getRelayConfig(storage) {
|
|
62
|
-
const relay = storage?.relay;
|
|
63
|
-
if (!relay) {
|
|
64
|
-
logRelayConfigOnce('disabled: no relay config found');
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
if (relay.enabled !== true) {
|
|
68
|
-
logRelayConfigOnce('disabled: relay.enabled is not true');
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
if (!relay.url?.trim() || !relay.token?.trim()) {
|
|
72
|
-
logRelayConfigOnce('disabled: missing relay url or token');
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
const transport = relay.transport === 'http' ? 'http' : 'websocket';
|
|
76
|
-
logRelayConfigOnce(`configured transport=${transport} protocol=${transport === 'websocket' ? 2 : 1} url=${relay.url.trim()} fallbackToDirect=${relay.fallbackToDirect !== false}`);
|
|
77
|
-
return {
|
|
78
|
-
enabled: true,
|
|
79
|
-
url: relay.url.trim(),
|
|
80
|
-
token: relay.token.trim(),
|
|
81
|
-
fallbackToDirect: relay.fallbackToDirect !== false,
|
|
82
|
-
transport,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
export function generateRelayToken() {
|
|
86
|
-
return randomBytes(32).toString('base64url');
|
|
87
|
-
}
|
|
88
|
-
export function hashBody(body) {
|
|
89
|
-
return `sha256:${createHash('sha256').update(body).digest('hex')}`;
|
|
90
|
-
}
|
|
91
|
-
export function createStringPatch(previous, next) {
|
|
92
|
-
let start = 0;
|
|
93
|
-
const maxPrefix = Math.min(previous.length, next.length);
|
|
94
|
-
while (start < maxPrefix &&
|
|
95
|
-
previous.charCodeAt(start) === next.charCodeAt(start)) {
|
|
96
|
-
start++;
|
|
97
|
-
}
|
|
98
|
-
let previousEnd = previous.length;
|
|
99
|
-
let nextEnd = next.length;
|
|
100
|
-
while (previousEnd > start &&
|
|
101
|
-
nextEnd > start &&
|
|
102
|
-
previous.charCodeAt(previousEnd - 1) === next.charCodeAt(nextEnd - 1)) {
|
|
103
|
-
previousEnd--;
|
|
104
|
-
nextEnd--;
|
|
105
|
-
}
|
|
106
|
-
return {
|
|
107
|
-
start,
|
|
108
|
-
deleteCount: previousEnd - start,
|
|
109
|
-
insert: next.slice(start, nextEnd),
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
function isNoopPatch(patch) {
|
|
113
|
-
return patch.deleteCount === 0 && patch.insert.length === 0;
|
|
114
|
-
}
|
|
115
|
-
function findCchTokenRange(body) {
|
|
116
|
-
const match = body.match(/\bcch=([0-9a-f]{5});/);
|
|
117
|
-
const token = match?.[1];
|
|
118
|
-
if (!token || match.index == null)
|
|
119
|
-
return null;
|
|
120
|
-
const start = match.index + 'cch='.length;
|
|
121
|
-
return { start, end: start + token.length, token };
|
|
122
|
-
}
|
|
123
|
-
function replaceRange(text, start, end, replacement) {
|
|
124
|
-
return `${text.slice(0, start)}${replacement}${text.slice(end)}`;
|
|
125
|
-
}
|
|
126
|
-
function createRelayPatch(previous, next) {
|
|
127
|
-
const previousCch = findCchTokenRange(previous);
|
|
128
|
-
const nextCch = findCchTokenRange(next);
|
|
129
|
-
if (previousCch &&
|
|
130
|
-
nextCch &&
|
|
131
|
-
previousCch.start === nextCch.start &&
|
|
132
|
-
previousCch.end - previousCch.start === nextCch.end - nextCch.start &&
|
|
133
|
-
previousCch.token !== nextCch.token) {
|
|
134
|
-
const cchPatch = {
|
|
135
|
-
start: previousCch.start,
|
|
136
|
-
deleteCount: previousCch.end - previousCch.start,
|
|
137
|
-
insert: nextCch.token,
|
|
138
|
-
};
|
|
139
|
-
const previousWithNextCch = replaceRange(previous, previousCch.start, previousCch.end, nextCch.token);
|
|
140
|
-
const tailPatch = createStringPatch(previousWithNextCch, next);
|
|
141
|
-
return isNoopPatch(tailPatch) ? cchPatch : [cchPatch, tailPatch];
|
|
142
|
-
}
|
|
143
|
-
return createStringPatch(previous, next);
|
|
144
|
-
}
|
|
145
|
-
function isRelayableAnthropicRequest(input, body) {
|
|
146
|
-
if (typeof body !== 'string')
|
|
147
|
-
return false;
|
|
148
|
-
try {
|
|
149
|
-
const url = input instanceof Request ? new URL(input.url) : new URL(input.toString());
|
|
150
|
-
return url.pathname === '/v1/messages' || url.pathname === '/messages';
|
|
151
|
-
}
|
|
152
|
-
catch {
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
function jsonHeaders(headers) {
|
|
157
|
-
const result = {};
|
|
158
|
-
for (const [key, value] of headers.entries()) {
|
|
159
|
-
if (key === 'x-session-affinity' || key === 'x-opencode-session')
|
|
160
|
-
continue;
|
|
161
|
-
result[key] = value;
|
|
162
|
-
}
|
|
163
|
-
return result;
|
|
164
|
-
}
|
|
165
|
-
async function postRelay(config, payload, signal) {
|
|
166
|
-
const stringifyStart = perfNowMs();
|
|
167
|
-
const body = JSON.stringify(payload);
|
|
168
|
-
relayPerfLog('http_payload_stringify', {
|
|
169
|
-
session: shortAffinity(payload.affinity),
|
|
170
|
-
ms: formatMs(perfNowMs() - stringifyStart),
|
|
171
|
-
relayBytes: body.length,
|
|
172
|
-
mode: payload.mode,
|
|
173
|
-
});
|
|
174
|
-
return fetch(config.url, {
|
|
175
|
-
method: 'POST',
|
|
176
|
-
headers: {
|
|
177
|
-
'content-type': 'application/json',
|
|
178
|
-
'x-relay-token': config.token,
|
|
179
|
-
},
|
|
180
|
-
body,
|
|
181
|
-
signal: signal ?? undefined,
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
function shortAffinity(affinity) {
|
|
185
|
-
return affinity.length > 12 ? `${affinity.slice(0, 12)}…` : affinity;
|
|
186
|
-
}
|
|
187
|
-
function perfNowMs() {
|
|
188
|
-
return performance.now();
|
|
189
|
-
}
|
|
190
|
-
function formatMs(value) {
|
|
191
|
-
return Math.round(value * 10) / 10;
|
|
192
|
-
}
|
|
193
|
-
function relayPerfLoggingEnabled() {
|
|
194
|
-
return process.env.OPENCODE_ANTHROPIC_AUTH_PERF === '1';
|
|
195
|
-
}
|
|
196
|
-
function relayPerfLog(stage, data) {
|
|
197
|
-
if (!relayPerfLoggingEnabled())
|
|
198
|
-
return;
|
|
199
|
-
relayLog(`[perf] relay ${stage} ${JSON.stringify(data)}`);
|
|
200
|
-
}
|
|
201
|
-
function createRelayPayload(options) {
|
|
202
|
-
const { input, init, headers, bodyText, affinity, previous, nextHash } = options;
|
|
203
|
-
const rewrittenUrl = input instanceof Request ? input.url : input.toString();
|
|
204
|
-
const basePayload = {
|
|
205
|
-
protocol: 1,
|
|
206
|
-
type: 'request',
|
|
207
|
-
affinity,
|
|
208
|
-
upstream: {
|
|
209
|
-
url: rewrittenUrl,
|
|
210
|
-
method: init?.method || (input instanceof Request ? input.method : 'POST'),
|
|
211
|
-
headers: jsonHeaders(headers),
|
|
212
|
-
},
|
|
213
|
-
next_hash: nextHash,
|
|
214
|
-
};
|
|
215
|
-
return previous
|
|
216
|
-
? {
|
|
217
|
-
...basePayload,
|
|
218
|
-
mode: 'patch',
|
|
219
|
-
base_hash: previous.hash,
|
|
220
|
-
revision: previous.revision + 1,
|
|
221
|
-
patch: createRelayPatch(previous.body, bodyText),
|
|
222
|
-
}
|
|
223
|
-
: {
|
|
224
|
-
...basePayload,
|
|
225
|
-
mode: 'full_sync',
|
|
226
|
-
revision: 1,
|
|
227
|
-
body: bodyText,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
function createFullSyncPayload(payload, bodyText) {
|
|
231
|
-
return {
|
|
232
|
-
protocol: payload.protocol,
|
|
233
|
-
type: 'request',
|
|
234
|
-
affinity: payload.affinity,
|
|
235
|
-
upstream: payload.upstream,
|
|
236
|
-
next_hash: payload.next_hash,
|
|
237
|
-
mode: 'full_sync',
|
|
238
|
-
revision: 1,
|
|
239
|
-
body: bodyText,
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
function updateLocalRelayState(affinity, bodyText, nextHash, revision) {
|
|
243
|
-
const now = Date.now();
|
|
244
|
-
sessionState.delete(affinity);
|
|
245
|
-
sessionState.set(affinity, {
|
|
246
|
-
body: bodyText,
|
|
247
|
-
hash: nextHash,
|
|
248
|
-
revision,
|
|
249
|
-
updatedAt: now,
|
|
250
|
-
});
|
|
251
|
-
cleanupRelaySessionCaches(now);
|
|
252
|
-
}
|
|
253
|
-
async function sendRelayHttp(options) {
|
|
254
|
-
const { config, payload, bodyText, fallback, signal, authorizeAttempt } = options;
|
|
255
|
-
let actualPayload = payload;
|
|
256
|
-
let authorization = authorizeAttempt
|
|
257
|
-
? await authorizeRelayPayload(actualPayload, authorizeAttempt)
|
|
258
|
-
: undefined;
|
|
259
|
-
let response = await postRelay(config, actualPayload, signal);
|
|
260
|
-
if (response.status === 409 && actualPayload.mode === 'patch') {
|
|
261
|
-
relayLog(`state mismatch from relay session=${shortAffinity(actualPayload.affinity)}; retrying full_sync`);
|
|
262
|
-
await response.body?.cancel().catch(() => { });
|
|
263
|
-
actualPayload = createFullSyncPayload(actualPayload, bodyText);
|
|
264
|
-
authorization = authorizeAttempt
|
|
265
|
-
? await authorizeRelayPayload(actualPayload, authorizeAttempt)
|
|
266
|
-
: undefined;
|
|
267
|
-
response = await postRelay(config, actualPayload, signal);
|
|
268
|
-
}
|
|
269
|
-
// Old workers already forward Anthropic request IDs. Without upstream
|
|
270
|
-
// provenance a relay's own 401 must never invalidate an OAuth credential.
|
|
271
|
-
if (/^req_[A-Za-z0-9_-]+$/.test(response.headers.get('request-id') ?? '')) {
|
|
272
|
-
try {
|
|
273
|
-
authorization?.onUpstreamStatus?.(response.status);
|
|
274
|
-
}
|
|
275
|
-
catch { }
|
|
276
|
-
}
|
|
277
|
-
if (!response.ok && response.status >= 500 && config.fallbackToDirect) {
|
|
278
|
-
relayLog(`relay returned ${response.status}; falling back direct session=${shortAffinity(actualPayload.affinity)}`);
|
|
279
|
-
await response.body?.cancel().catch(() => { });
|
|
280
|
-
return {
|
|
281
|
-
response: await fallback(),
|
|
282
|
-
payload: actualPayload,
|
|
283
|
-
transport: 'http',
|
|
284
|
-
protocol: actualPayload.protocol,
|
|
285
|
-
usedRelay: false,
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
return {
|
|
289
|
-
response,
|
|
290
|
-
payload: actualPayload,
|
|
291
|
-
transport: 'http',
|
|
292
|
-
protocol: actualPayload.protocol,
|
|
293
|
-
usedRelay: true,
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
function toWebSocketUrl(url, token) {
|
|
297
|
-
const parsed = new URL(url);
|
|
298
|
-
parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:';
|
|
299
|
-
parsed.pathname = '/ws';
|
|
300
|
-
parsed.searchParams.set('token', token);
|
|
301
|
-
return parsed.toString();
|
|
302
|
-
}
|
|
303
|
-
function decodeRelayChunk(base64) {
|
|
304
|
-
return new Uint8Array(Buffer.from(base64, 'base64'));
|
|
305
|
-
}
|
|
306
|
-
function toBinaryChunk(data) {
|
|
307
|
-
if (data instanceof Uint8Array)
|
|
308
|
-
return data;
|
|
309
|
-
if (data instanceof ArrayBuffer)
|
|
310
|
-
return new Uint8Array(data);
|
|
311
|
-
if (ArrayBuffer.isView(data)) {
|
|
312
|
-
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
313
|
-
}
|
|
314
|
-
return new Uint8Array(Buffer.from(String(data)));
|
|
315
|
-
}
|
|
316
|
-
function parseRelayControlMessage(data) {
|
|
317
|
-
return JSON.parse(data);
|
|
318
|
-
}
|
|
319
|
-
function formatCloseEvent(event) {
|
|
320
|
-
const close = event;
|
|
321
|
-
const parts = [
|
|
322
|
-
`code=${close.code ?? 'unknown'}`,
|
|
323
|
-
`wasClean=${close.wasClean ?? 'unknown'}`,
|
|
324
|
-
];
|
|
325
|
-
if (close.reason)
|
|
326
|
-
parts.push(`reason=${JSON.stringify(close.reason)}`);
|
|
327
|
-
return parts.join(' ');
|
|
328
|
-
}
|
|
329
|
-
function relayControlErrorMessage(message) {
|
|
330
|
-
const detail = message.message || 'relay websocket error';
|
|
331
|
-
return message.status
|
|
332
|
-
? `relay websocket error ${message.status}: ${detail}`
|
|
333
|
-
: detail;
|
|
334
|
-
}
|
|
335
|
-
class PersistentRelaySession {
|
|
336
|
-
config;
|
|
337
|
-
affinity;
|
|
338
|
-
timers;
|
|
339
|
-
socket;
|
|
340
|
-
connecting;
|
|
341
|
-
queue = Promise.resolve();
|
|
342
|
-
pending;
|
|
343
|
-
serverState;
|
|
344
|
-
lastUsedAt = Date.now();
|
|
345
|
-
constructor(config, affinity, timers) {
|
|
346
|
-
this.config = config;
|
|
347
|
-
this.affinity = affinity;
|
|
348
|
-
this.timers = timers;
|
|
349
|
-
}
|
|
350
|
-
touch(now = Date.now()) {
|
|
351
|
-
this.lastUsedAt = now;
|
|
352
|
-
}
|
|
353
|
-
canEvict(now = Date.now()) {
|
|
354
|
-
return (!this.pending &&
|
|
355
|
-
!this.connecting &&
|
|
356
|
-
now - this.lastUsedAt > RELAY_WEBSOCKET_IDLE_TTL_MS);
|
|
357
|
-
}
|
|
358
|
-
dispose() {
|
|
359
|
-
this.socket?.close();
|
|
360
|
-
this.socket = undefined;
|
|
361
|
-
this.serverState = undefined;
|
|
362
|
-
this.connecting = undefined;
|
|
363
|
-
}
|
|
364
|
-
send(payload, bodyText, optimisticResponse = false, onResponseHeaders, authorizeAttempt) {
|
|
365
|
-
this.touch();
|
|
366
|
-
const enqueuedAt = perfNowMs();
|
|
367
|
-
const start = this.queue
|
|
368
|
-
.catch(() => { })
|
|
369
|
-
.then(() => this.startQueued(payload, bodyText, enqueuedAt, optimisticResponse, onResponseHeaders, authorizeAttempt));
|
|
370
|
-
const result = start.then(async ({ response, getPayload }) => ({
|
|
371
|
-
response: await response,
|
|
372
|
-
payload: getPayload(),
|
|
373
|
-
transport: 'websocket',
|
|
374
|
-
protocol: 2,
|
|
375
|
-
usedRelay: true,
|
|
376
|
-
}));
|
|
377
|
-
this.queue = start.then(({ done }) => done.catch(() => { }), () => { });
|
|
378
|
-
return result;
|
|
379
|
-
}
|
|
380
|
-
async startQueued(payload, bodyText, enqueuedAt, optimisticResponse, onResponseHeaders, authorizeAttempt) {
|
|
381
|
-
const connectStart = perfNowMs();
|
|
382
|
-
await this.ensureConnected();
|
|
383
|
-
const connectedAt = perfNowMs();
|
|
384
|
-
const localState = sessionState.get(this.affinity);
|
|
385
|
-
const serverHash = this.serverState?.hash;
|
|
386
|
-
const requestPayload = {
|
|
387
|
-
...payload,
|
|
388
|
-
protocol: 2,
|
|
389
|
-
id: createRequestId(),
|
|
390
|
-
};
|
|
391
|
-
if (requestPayload.mode === 'patch' &&
|
|
392
|
-
(!serverHash || serverHash !== requestPayload.base_hash)) {
|
|
393
|
-
requestPayload.mode = 'full_sync';
|
|
394
|
-
requestPayload.revision = (this.serverState?.revision ?? 0) + 1;
|
|
395
|
-
delete requestPayload.base_hash;
|
|
396
|
-
delete requestPayload.patch;
|
|
397
|
-
requestPayload.body = bodyText;
|
|
398
|
-
}
|
|
399
|
-
if (requestPayload.mode === 'patch' && localState) {
|
|
400
|
-
requestPayload.patch = createRelayPatch(localState.body, bodyText);
|
|
401
|
-
}
|
|
402
|
-
relayLog(`perf websocket send_start session=${shortAffinity(this.affinity)} request=${requestPayload.id} mode=${requestPayload.mode} queueMs=${formatMs(connectedAt - enqueuedAt)} connectMs=${formatMs(connectedAt - connectStart)} bodyBytes=${bodyText.length} relayBytes=${JSON.stringify(requestPayload).length}`);
|
|
403
|
-
const authorization = authorizeAttempt
|
|
404
|
-
? await authorizeRelayPayload(requestPayload, authorizeAttempt)
|
|
405
|
-
: undefined;
|
|
406
|
-
let activePayload = requestPayload;
|
|
407
|
-
const first = this.sendPayload(requestPayload, bodyText, optimisticResponse, onResponseHeaders, authorizeAttempt, authorization);
|
|
408
|
-
void first.done.catch(() => { });
|
|
409
|
-
let activeDone = first.done;
|
|
410
|
-
const response = first.response.catch(async (error) => {
|
|
411
|
-
if (!(error instanceof RelayStateMismatchError) ||
|
|
412
|
-
requestPayload.mode !== 'patch') {
|
|
413
|
-
throw error;
|
|
414
|
-
}
|
|
415
|
-
const fullSync = createFullSyncPayload(requestPayload, bodyText);
|
|
416
|
-
fullSync.protocol = 2;
|
|
417
|
-
fullSync.id = createRequestId();
|
|
418
|
-
fullSync.revision = (this.serverState?.revision ?? 0) + 1;
|
|
419
|
-
const retryAuthorization = authorizeAttempt
|
|
420
|
-
? await authorizeRelayPayload(fullSync, authorizeAttempt)
|
|
421
|
-
: undefined;
|
|
422
|
-
activePayload = fullSync;
|
|
423
|
-
const retry = this.sendPayload(fullSync, bodyText, optimisticResponse, onResponseHeaders, authorizeAttempt, retryAuthorization);
|
|
424
|
-
void retry.done.catch(() => { });
|
|
425
|
-
activeDone = retry.done;
|
|
426
|
-
return retry.response;
|
|
427
|
-
});
|
|
428
|
-
const done = response.then(() => activeDone, () => activeDone.catch(() => { }));
|
|
429
|
-
return { response, done, getPayload: () => activePayload };
|
|
430
|
-
}
|
|
431
|
-
async ensureConnected() {
|
|
432
|
-
if (this.socket?.readyState === WebSocket.OPEN &&
|
|
433
|
-
this.serverState !== undefined)
|
|
434
|
-
return;
|
|
435
|
-
if (this.connecting)
|
|
436
|
-
return await this.connecting;
|
|
437
|
-
this.connecting = new Promise((resolve, reject) => {
|
|
438
|
-
const url = toWebSocketUrl(this.config.url, this.config.token);
|
|
439
|
-
const parsed = new URL(url);
|
|
440
|
-
parsed.searchParams.set('affinity', this.affinity);
|
|
441
|
-
const socket = new WebSocket(parsed.toString());
|
|
442
|
-
this.socket = socket;
|
|
443
|
-
this.serverState = undefined;
|
|
444
|
-
const timeout = this.timers.setTimeout(() => {
|
|
445
|
-
socket.close();
|
|
446
|
-
reject(new Error('relay websocket ready timed out'));
|
|
447
|
-
}, 15_000);
|
|
448
|
-
socket.addEventListener('message', (event) => {
|
|
449
|
-
try {
|
|
450
|
-
if (typeof event.data !== 'string') {
|
|
451
|
-
this.handleBinaryChunk(event.data);
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
const message = parseRelayControlMessage(event.data);
|
|
455
|
-
if (message.type === 'ready') {
|
|
456
|
-
this.timers.clearTimeout(timeout);
|
|
457
|
-
this.serverState = message.state;
|
|
458
|
-
resolve();
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
461
|
-
this.handleMessage(message);
|
|
462
|
-
}
|
|
463
|
-
catch (error) {
|
|
464
|
-
this.timers.clearTimeout(timeout);
|
|
465
|
-
reject(error);
|
|
466
|
-
this.failPending(error);
|
|
467
|
-
socket.close();
|
|
468
|
-
}
|
|
469
|
-
});
|
|
470
|
-
socket.addEventListener('error', () => {
|
|
471
|
-
this.timers.clearTimeout(timeout);
|
|
472
|
-
const error = new RelayWebSocketConnectionResetError('relay websocket error');
|
|
473
|
-
reject(error);
|
|
474
|
-
this.failPending(error);
|
|
475
|
-
});
|
|
476
|
-
socket.addEventListener('close', (event) => {
|
|
477
|
-
this.timers.clearTimeout(timeout);
|
|
478
|
-
if (this.socket === socket) {
|
|
479
|
-
this.socket = undefined;
|
|
480
|
-
this.serverState = undefined;
|
|
481
|
-
this.connecting = undefined;
|
|
482
|
-
}
|
|
483
|
-
if (this.pending) {
|
|
484
|
-
if (this.pending.retryingBeforeResponse)
|
|
485
|
-
return;
|
|
486
|
-
const closedAfterResponseStart = this.pending.responseStartedAt != null;
|
|
487
|
-
const reason = closedAfterResponseStart
|
|
488
|
-
? 'closed after response_start before stream bytes'
|
|
489
|
-
: 'closed before response';
|
|
490
|
-
if (this.retryPendingBeforeResponse(this.pending, reason, event))
|
|
491
|
-
return;
|
|
492
|
-
const error = closedAfterResponseStart
|
|
493
|
-
? new RelayWebSocketConnectionResetError('relay websocket closed during response stream', event)
|
|
494
|
-
: new Error('relay websocket closed before response');
|
|
495
|
-
relayLog(`websocket ${closedAfterResponseStart ? 'closed during response stream' : 'closed before response without retry'} session=${shortAffinity(this.affinity)} request=${this.pending.payload.id} chunks=${this.pending.streamChunkCount} bytes=${this.pending.streamByteCount} ${formatCloseEvent(event)}`);
|
|
496
|
-
this.failPending(error);
|
|
497
|
-
}
|
|
498
|
-
});
|
|
499
|
-
}).finally(() => {
|
|
500
|
-
this.connecting = undefined;
|
|
501
|
-
});
|
|
502
|
-
await this.connecting;
|
|
503
|
-
}
|
|
504
|
-
sendPayload(payload, bodyText, optimisticResponse, onResponseHeaders, authorizeAttempt, authorization) {
|
|
505
|
-
const socket = this.socket;
|
|
506
|
-
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
507
|
-
throw new Error('relay websocket is not connected');
|
|
508
|
-
}
|
|
509
|
-
const sentAt = perfNowMs();
|
|
510
|
-
let resolveDone;
|
|
511
|
-
let rejectDone;
|
|
512
|
-
const done = new Promise((resolve, reject) => {
|
|
513
|
-
resolveDone = resolve;
|
|
514
|
-
rejectDone = reject;
|
|
515
|
-
});
|
|
516
|
-
const response = new Promise((resolve, reject) => {
|
|
517
|
-
const pending = {
|
|
518
|
-
payload,
|
|
519
|
-
bodyText,
|
|
520
|
-
resolve,
|
|
521
|
-
reject,
|
|
522
|
-
streamDone: false,
|
|
523
|
-
accepted: false,
|
|
524
|
-
responseStarted: false,
|
|
525
|
-
resolveDone,
|
|
526
|
-
rejectDone,
|
|
527
|
-
sentAt,
|
|
528
|
-
optimisticResponse,
|
|
529
|
-
streamChunkCount: 0,
|
|
530
|
-
streamByteCount: 0,
|
|
531
|
-
retryAttempts: 0,
|
|
532
|
-
retryingBeforeResponse: false,
|
|
533
|
-
onResponseHeaders,
|
|
534
|
-
authorizeAttempt,
|
|
535
|
-
authorization,
|
|
536
|
-
stagedResponseHeaders: undefined,
|
|
537
|
-
responseHeadersDelivered: false,
|
|
538
|
-
};
|
|
539
|
-
this.pending = pending;
|
|
540
|
-
this.resetPendingTimeout(pending);
|
|
541
|
-
socket.send(JSON.stringify(payload));
|
|
542
|
-
if (optimisticResponse) {
|
|
543
|
-
this.resolvePendingResponse(pending, 200, 'OK', {
|
|
544
|
-
'content-type': 'text/event-stream',
|
|
545
|
-
'x-cortexkit-relay-optimistic': 'true',
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
});
|
|
549
|
-
return { response, done };
|
|
550
|
-
}
|
|
551
|
-
resetPendingTimeout(pending) {
|
|
552
|
-
this.timers.clearTimeout(pending.timeout);
|
|
553
|
-
pending.timeout = this.timers.setTimeout(() => {
|
|
554
|
-
if (this.pending !== pending)
|
|
555
|
-
return;
|
|
556
|
-
if (this.retryPendingBeforeResponse(pending, 'response timed out'))
|
|
557
|
-
return;
|
|
558
|
-
this.failPending(new Error('relay websocket response timed out'));
|
|
559
|
-
this.socket?.close();
|
|
560
|
-
}, 45_000);
|
|
561
|
-
}
|
|
562
|
-
retryPendingBeforeResponse(pending, reason, closeEvent) {
|
|
563
|
-
if (pending.responseStartedAt != null && pending.streamByteCount > 0)
|
|
564
|
-
return false;
|
|
565
|
-
if (pending.streamDone)
|
|
566
|
-
return false;
|
|
567
|
-
if (pending.retryAttempts >= 1)
|
|
568
|
-
return false;
|
|
569
|
-
pending.retryAttempts += 1;
|
|
570
|
-
pending.retryingBeforeResponse = true;
|
|
571
|
-
this.timers.clearTimeout(pending.timeout);
|
|
572
|
-
const attempt = pending.retryAttempts;
|
|
573
|
-
const originalId = pending.payload.id;
|
|
574
|
-
relayLog(`websocket ${reason}; reconnecting and retrying full_sync session=${shortAffinity(this.affinity)} request=${originalId} retry=${attempt} chunks=${pending.streamChunkCount} bytes=${pending.streamByteCount}${closeEvent ? ` ${formatCloseEvent(closeEvent)}` : ''}`);
|
|
575
|
-
this.socket?.close();
|
|
576
|
-
this.socket = undefined;
|
|
577
|
-
this.serverState = undefined;
|
|
578
|
-
this.connecting = undefined;
|
|
579
|
-
void (async () => {
|
|
580
|
-
try {
|
|
581
|
-
await this.ensureConnected();
|
|
582
|
-
if (this.pending !== pending)
|
|
583
|
-
return;
|
|
584
|
-
const retryPayload = createFullSyncPayload(pending.payload, pending.bodyText);
|
|
585
|
-
retryPayload.protocol = 2;
|
|
586
|
-
retryPayload.id = createRequestId();
|
|
587
|
-
retryPayload.revision = (this.serverState?.revision ?? 0) + 1;
|
|
588
|
-
const authorization = pending.authorizeAttempt
|
|
589
|
-
? await authorizeRelayPayload(retryPayload, pending.authorizeAttempt)
|
|
590
|
-
: undefined;
|
|
591
|
-
if (this.pending !== pending || pending.streamDone)
|
|
592
|
-
return;
|
|
593
|
-
pending.authorization = authorization;
|
|
594
|
-
pending.payload = retryPayload;
|
|
595
|
-
pending.accepted = false;
|
|
596
|
-
pending.acceptedAt = undefined;
|
|
597
|
-
pending.responseStartedAt = undefined;
|
|
598
|
-
pending.stagedResponseHeaders = undefined;
|
|
599
|
-
pending.responseHeadersDelivered = false;
|
|
600
|
-
pending.sentAt = perfNowMs();
|
|
601
|
-
pending.retryingBeforeResponse = false;
|
|
602
|
-
const socket = this.socket;
|
|
603
|
-
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
604
|
-
throw new Error('relay websocket is not connected');
|
|
605
|
-
}
|
|
606
|
-
this.resetPendingTimeout(pending);
|
|
607
|
-
relayLog(`perf websocket retry_send session=${shortAffinity(this.affinity)} request=${retryPayload.id} previous=${originalId} mode=full_sync retry=${attempt} bodyBytes=${pending.bodyText.length} relayBytes=${JSON.stringify(retryPayload).length}`);
|
|
608
|
-
socket.send(JSON.stringify(retryPayload));
|
|
609
|
-
}
|
|
610
|
-
catch (error) {
|
|
611
|
-
pending.retryingBeforeResponse = false;
|
|
612
|
-
if (this.pending === pending)
|
|
613
|
-
this.failPending(error instanceof RelayAuthorizationError ? error.failure : error);
|
|
614
|
-
}
|
|
615
|
-
})();
|
|
616
|
-
return true;
|
|
617
|
-
}
|
|
618
|
-
enqueueStreamChunk(pending, chunk) {
|
|
619
|
-
this.deliverResponseHeaders(pending);
|
|
620
|
-
pending.streamChunkCount += 1;
|
|
621
|
-
pending.streamByteCount += chunk.byteLength;
|
|
622
|
-
pending.streamController?.enqueue(chunk);
|
|
623
|
-
}
|
|
624
|
-
deliverResponseHeaders(pending) {
|
|
625
|
-
if (pending.responseHeadersDelivered ||
|
|
626
|
-
pending.stagedResponseHeaders == null)
|
|
627
|
-
return;
|
|
628
|
-
pending.responseHeadersDelivered = true;
|
|
629
|
-
try {
|
|
630
|
-
pending.onResponseHeaders?.(pending.stagedResponseHeaders);
|
|
631
|
-
}
|
|
632
|
-
catch { }
|
|
633
|
-
}
|
|
634
|
-
handleBinaryChunk(data) {
|
|
635
|
-
const pending = this.pending;
|
|
636
|
-
if (!pending?.responseStarted)
|
|
637
|
-
return;
|
|
638
|
-
this.enqueueStreamChunk(pending, toBinaryChunk(data));
|
|
639
|
-
}
|
|
640
|
-
resolvePendingResponse(pending, status, statusText, headers) {
|
|
641
|
-
if (pending.responseStarted)
|
|
642
|
-
return;
|
|
643
|
-
pending.responseStarted = true;
|
|
644
|
-
const stream = new ReadableStream({
|
|
645
|
-
start: (controller) => {
|
|
646
|
-
pending.streamController = controller;
|
|
647
|
-
},
|
|
648
|
-
cancel: () => {
|
|
649
|
-
this.socket?.close();
|
|
650
|
-
},
|
|
651
|
-
});
|
|
652
|
-
pending.resolve(new Response(stream, {
|
|
653
|
-
status,
|
|
654
|
-
statusText,
|
|
655
|
-
headers,
|
|
656
|
-
}));
|
|
657
|
-
}
|
|
658
|
-
handleMessage(message) {
|
|
659
|
-
if (message.type === 'keepalive') {
|
|
660
|
-
if (this.pending)
|
|
661
|
-
this.resetPendingTimeout(this.pending);
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
const pending = this.pending;
|
|
665
|
-
if (!pending)
|
|
666
|
-
return;
|
|
667
|
-
if ('id' in message && message.id && message.id !== pending.payload.id)
|
|
668
|
-
return;
|
|
669
|
-
if (message.type === 'accepted') {
|
|
670
|
-
const acceptedAt = perfNowMs();
|
|
671
|
-
pending.accepted = true;
|
|
672
|
-
pending.acceptedAt = acceptedAt;
|
|
673
|
-
relayLog(`perf websocket accepted session=${shortAffinity(this.affinity)} request=${pending.payload.id} sentMs=${formatMs(acceptedAt - pending.sentAt)} mode=${pending.payload.mode}`);
|
|
674
|
-
this.serverState = { hash: message.hash, revision: message.revision };
|
|
675
|
-
updateLocalRelayState(this.affinity, pending.bodyText, message.hash, message.revision);
|
|
676
|
-
this.resetPendingTimeout(pending);
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
if (message.type === 'response_start') {
|
|
680
|
-
if (pending.responseStartedAt != null)
|
|
681
|
-
return;
|
|
682
|
-
const responseStartedAt = perfNowMs();
|
|
683
|
-
pending.responseStartedAt = responseStartedAt;
|
|
684
|
-
try {
|
|
685
|
-
pending.authorization?.onUpstreamStatus?.(message.status);
|
|
686
|
-
}
|
|
687
|
-
catch { }
|
|
688
|
-
relayLog(`perf websocket response_start session=${shortAffinity(this.affinity)} request=${pending.payload.id} sentMs=${formatMs(responseStartedAt - pending.sentAt)} upstreamMs=${pending.acceptedAt == null ? 'unknown' : formatMs(responseStartedAt - pending.acceptedAt)} status=${message.status}`);
|
|
689
|
-
this.timers.clearTimeout(pending.timeout);
|
|
690
|
-
if (message.headers)
|
|
691
|
-
pending.stagedResponseHeaders = new Headers(message.headers);
|
|
692
|
-
this.resolvePendingResponse(pending, message.status, message.statusText, message.headers);
|
|
693
|
-
if (pending.optimisticResponse && message.status >= 400) {
|
|
694
|
-
this.deliverResponseHeaders(pending);
|
|
695
|
-
pending.streamController?.enqueue(new TextEncoder().encode(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'relay_upstream_error', message: `Relay upstream returned HTTP ${message.status}` } })}\n\n`));
|
|
696
|
-
this.finishPending();
|
|
697
|
-
}
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
if (message.type === 'chunk') {
|
|
701
|
-
this.enqueueStreamChunk(pending, decodeRelayChunk(message.base64));
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
if (message.type === 'done') {
|
|
705
|
-
this.finishPending(message);
|
|
706
|
-
return;
|
|
707
|
-
}
|
|
708
|
-
if (message.type === 'error') {
|
|
709
|
-
// If an optimistic response is already streaming (responseStartedAt is
|
|
710
|
-
// set), the pending promise was already resolved — calling failPending
|
|
711
|
-
// would reject a settled promise and leave the stream hanging. Instead,
|
|
712
|
-
// inject an SSE error event and close cleanly.
|
|
713
|
-
if (pending.optimisticResponse && pending.responseStartedAt != null) {
|
|
714
|
-
relayLog(`websocket relay error during optimistic response session=${shortAffinity(this.affinity)} request=${pending.payload.id} status=${message.status} message=${message.message || 'unknown'} chunks=${pending.streamChunkCount} bytes=${pending.streamByteCount}`);
|
|
715
|
-
this.deliverResponseHeaders(pending);
|
|
716
|
-
pending.streamController?.enqueue(new TextEncoder().encode(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'relay_error', message: message.message || 'relay error' } })}\n\n`));
|
|
717
|
-
this.finishPending();
|
|
718
|
-
this.socket?.close();
|
|
719
|
-
return;
|
|
720
|
-
}
|
|
721
|
-
if (message.status === 409 &&
|
|
722
|
-
this.retryPendingBeforeResponse(pending, message.message || 'state mismatch')) {
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
const error = message.status === 409
|
|
726
|
-
? new RelayStateMismatchError(message.message || 'state mismatch')
|
|
727
|
-
: new Error(relayControlErrorMessage(message));
|
|
728
|
-
this.failPending(error);
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
finishPending(done) {
|
|
732
|
-
const pending = this.pending;
|
|
733
|
-
if (!pending)
|
|
734
|
-
return;
|
|
735
|
-
const finishedAt = perfNowMs();
|
|
736
|
-
const workerStats = done && 'frames' in done
|
|
737
|
-
? ` upstreamChunks=${done.upstreamChunks ?? 'unknown'} upstreamBytes=${done.upstreamBytes ?? 'unknown'} frames=${done.frames} frameBytes=${done.frameBytes ?? 'unknown'}`
|
|
738
|
-
: '';
|
|
739
|
-
relayLog(`perf websocket done session=${shortAffinity(this.affinity)} request=${pending.payload.id} sentMs=${formatMs(finishedAt - pending.sentAt)} streamMs=${pending.responseStartedAt == null ? 'unknown' : formatMs(finishedAt - pending.responseStartedAt)} chunks=${pending.streamChunkCount} bytes=${pending.streamByteCount}${workerStats}`);
|
|
740
|
-
this.timers.clearTimeout(pending.timeout);
|
|
741
|
-
if (!pending.streamDone) {
|
|
742
|
-
this.deliverResponseHeaders(pending);
|
|
743
|
-
pending.streamDone = true;
|
|
744
|
-
pending.streamController?.close();
|
|
745
|
-
}
|
|
746
|
-
pending.resolveDone();
|
|
747
|
-
this.pending = undefined;
|
|
748
|
-
}
|
|
749
|
-
failPending(error) {
|
|
750
|
-
const pending = this.pending;
|
|
751
|
-
if (!pending)
|
|
752
|
-
return;
|
|
753
|
-
this.timers.clearTimeout(pending.timeout);
|
|
754
|
-
this.pending = undefined;
|
|
755
|
-
if (pending.responseStarted) {
|
|
756
|
-
pending.streamController?.error(error);
|
|
757
|
-
}
|
|
758
|
-
else {
|
|
759
|
-
pending.reject(error);
|
|
760
|
-
}
|
|
761
|
-
pending.rejectDone(error);
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
function cleanupRelaySessionCaches(now = Date.now()) {
|
|
765
|
-
for (const [affinity, state] of sessionState) {
|
|
766
|
-
if (now - state.updatedAt > RELAY_SESSION_STATE_TTL_MS) {
|
|
767
|
-
sessionState.delete(affinity);
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
while (sessionState.size > MAX_RELAY_SESSION_STATES) {
|
|
771
|
-
const oldest = sessionState.keys().next().value;
|
|
772
|
-
if (oldest === undefined)
|
|
773
|
-
break;
|
|
774
|
-
sessionState.delete(oldest);
|
|
775
|
-
}
|
|
776
|
-
for (const [affinity, session] of websocketSessions) {
|
|
777
|
-
if (session.canEvict(now)) {
|
|
778
|
-
session.dispose();
|
|
779
|
-
websocketSessions.delete(affinity);
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
while (websocketSessions.size > MAX_RELAY_WEBSOCKET_SESSIONS) {
|
|
783
|
-
let evicted = false;
|
|
784
|
-
for (const [affinity, session] of websocketSessions) {
|
|
785
|
-
if (!session.canEvict(now))
|
|
786
|
-
continue;
|
|
787
|
-
session.dispose();
|
|
788
|
-
websocketSessions.delete(affinity);
|
|
789
|
-
evicted = true;
|
|
790
|
-
break;
|
|
791
|
-
}
|
|
792
|
-
if (!evicted)
|
|
793
|
-
break;
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
function getPersistentRelaySession(config, affinity, timers) {
|
|
797
|
-
cleanupRelaySessionCaches();
|
|
798
|
-
const existing = websocketSessions.get(affinity);
|
|
799
|
-
if (existing) {
|
|
800
|
-
existing.touch();
|
|
801
|
-
return existing;
|
|
802
|
-
}
|
|
803
|
-
const session = new PersistentRelaySession(config, affinity, timers);
|
|
804
|
-
websocketSessions.set(affinity, session);
|
|
805
|
-
cleanupRelaySessionCaches();
|
|
806
|
-
return session;
|
|
807
|
-
}
|
|
808
|
-
export async function sendViaRelay(options) {
|
|
809
|
-
const { config, input, init, headers, body, fallback, affinity: explicitAffinity, optimisticResponse, authorizeAttempt, onResponseHeaders, dumpTag, onDumpCreated, setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout, } = options;
|
|
810
|
-
if (!config || !isRelayableAnthropicRequest(input, body))
|
|
811
|
-
return fallback();
|
|
812
|
-
const affinity = explicitAffinity ||
|
|
813
|
-
headers.get('x-session-affinity') ||
|
|
814
|
-
headers.get('x-opencode-session');
|
|
815
|
-
if (!affinity) {
|
|
816
|
-
relayLog('skipping relay: missing x-session-affinity header');
|
|
817
|
-
return fallback();
|
|
818
|
-
}
|
|
819
|
-
const bodyText = body;
|
|
820
|
-
const hashStart = perfNowMs();
|
|
821
|
-
const nextHash = hashBody(bodyText);
|
|
822
|
-
relayPerfLog('hash_body', {
|
|
823
|
-
session: shortAffinity(affinity),
|
|
824
|
-
ms: formatMs(perfNowMs() - hashStart),
|
|
825
|
-
bodyBytes: bodyText.length,
|
|
826
|
-
});
|
|
827
|
-
const previous = sessionState.get(affinity);
|
|
828
|
-
const payloadStart = perfNowMs();
|
|
829
|
-
const payload = createRelayPayload({
|
|
830
|
-
input,
|
|
831
|
-
init,
|
|
832
|
-
headers,
|
|
833
|
-
bodyText,
|
|
834
|
-
affinity,
|
|
835
|
-
previous,
|
|
836
|
-
nextHash,
|
|
837
|
-
});
|
|
838
|
-
relayPerfLog('create_payload', {
|
|
839
|
-
session: shortAffinity(affinity),
|
|
840
|
-
ms: formatMs(perfNowMs() - payloadStart),
|
|
841
|
-
mode: payload.mode,
|
|
842
|
-
bodyBytes: bodyText.length,
|
|
843
|
-
previousBytes: previous?.body.length,
|
|
844
|
-
});
|
|
845
|
-
try {
|
|
846
|
-
let result;
|
|
847
|
-
const sendStart = perfNowMs();
|
|
848
|
-
if (config.transport === 'websocket') {
|
|
849
|
-
try {
|
|
850
|
-
const session = getPersistentRelaySession(config, affinity, {
|
|
851
|
-
setTimeout: setTimeoutImpl,
|
|
852
|
-
clearTimeout: clearTimeoutImpl,
|
|
853
|
-
});
|
|
854
|
-
result = await session.send(payload, bodyText, optimisticResponse === true, onResponseHeaders, authorizeAttempt);
|
|
855
|
-
}
|
|
856
|
-
catch (error) {
|
|
857
|
-
if (error instanceof RelayAuthorizationError)
|
|
858
|
-
throw error;
|
|
859
|
-
relayLog(`websocket relay failed session=${shortAffinity(affinity)}; trying http relay: ${error instanceof Error ? error.message : String(error)}`);
|
|
860
|
-
result = await sendRelayHttp({
|
|
861
|
-
config,
|
|
862
|
-
payload,
|
|
863
|
-
bodyText,
|
|
864
|
-
fallback,
|
|
865
|
-
signal: init?.signal,
|
|
866
|
-
authorizeAttempt,
|
|
867
|
-
});
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
else {
|
|
871
|
-
result = await sendRelayHttp({
|
|
872
|
-
config,
|
|
873
|
-
payload,
|
|
874
|
-
bodyText,
|
|
875
|
-
fallback,
|
|
876
|
-
signal: init?.signal,
|
|
877
|
-
authorizeAttempt,
|
|
878
|
-
});
|
|
879
|
-
}
|
|
880
|
-
relayPerfLog('send', {
|
|
881
|
-
session: shortAffinity(affinity),
|
|
882
|
-
ms: formatMs(perfNowMs() - sendStart),
|
|
883
|
-
transport: result.transport,
|
|
884
|
-
protocol: result.protocol,
|
|
885
|
-
mode: result.payload.mode,
|
|
886
|
-
status: result.response.status,
|
|
887
|
-
usedRelay: result.usedRelay,
|
|
888
|
-
});
|
|
889
|
-
if (!result.usedRelay)
|
|
890
|
-
return result.response;
|
|
891
|
-
if (result.transport === 'http') {
|
|
892
|
-
try {
|
|
893
|
-
onResponseHeaders?.(result.response.headers);
|
|
894
|
-
}
|
|
895
|
-
catch { }
|
|
896
|
-
}
|
|
897
|
-
if (result.transport === 'http') {
|
|
898
|
-
updateLocalRelayState(affinity, bodyText, result.payload.next_hash, result.payload.revision);
|
|
899
|
-
}
|
|
900
|
-
const payloadStringifyStart = perfNowMs();
|
|
901
|
-
const actualPayloadBytes = JSON.stringify(result.payload).length;
|
|
902
|
-
relayPerfLog('payload_stringify', {
|
|
903
|
-
session: shortAffinity(affinity),
|
|
904
|
-
ms: formatMs(perfNowMs() - payloadStringifyStart),
|
|
905
|
-
relayBytes: actualPayloadBytes,
|
|
906
|
-
});
|
|
907
|
-
relayLog(`used relay transport=${result.transport} protocol=${result.protocol} mode=${result.payload.mode} status=${result.response.status} session=${shortAffinity(affinity)} bodyBytes=${bodyText.length} relayBytes=${actualPayloadBytes}`);
|
|
908
|
-
const dumpStart = perfNowMs();
|
|
909
|
-
const dumpHandle = await dumpRelayRequest({
|
|
910
|
-
affinity,
|
|
911
|
-
transport: result.transport,
|
|
912
|
-
protocol: result.protocol,
|
|
913
|
-
mode: result.payload.mode,
|
|
914
|
-
status: result.response.status,
|
|
915
|
-
bodyText,
|
|
916
|
-
previousBodyText: previous?.body,
|
|
917
|
-
payload: result.payload,
|
|
918
|
-
relayBytes: actualPayloadBytes,
|
|
919
|
-
tag: dumpTag,
|
|
920
|
-
});
|
|
921
|
-
try {
|
|
922
|
-
if (dumpHandle)
|
|
923
|
-
onDumpCreated?.(dumpHandle);
|
|
924
|
-
}
|
|
925
|
-
catch { }
|
|
926
|
-
relayPerfLog('dump', {
|
|
927
|
-
session: shortAffinity(affinity),
|
|
928
|
-
ms: formatMs(perfNowMs() - dumpStart),
|
|
929
|
-
enabled: true,
|
|
930
|
-
});
|
|
931
|
-
return result.response;
|
|
932
|
-
}
|
|
933
|
-
catch (error) {
|
|
934
|
-
if (error instanceof RelayAuthorizationError)
|
|
935
|
-
throw error.failure;
|
|
936
|
-
if (!config.fallbackToDirect) {
|
|
937
|
-
relayLog(`relay failed session=${shortAffinity(affinity)} and fallbackToDirect=false: ${error instanceof Error ? error.message : String(error)}`);
|
|
938
|
-
throw error;
|
|
939
|
-
}
|
|
940
|
-
relayLog(`relay failed; falling back direct session=${shortAffinity(affinity)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
941
|
-
return fallback();
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
export const WORKER_SCRIPT = `
|
|
945
|
-
async function hashBody(body) {
|
|
946
|
-
const bytes = new TextEncoder().encode(body)
|
|
947
|
-
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
|
948
|
-
const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
949
|
-
return 'sha256:' + hex
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
function applyPatch(base, patch) {
|
|
953
|
-
return base.slice(0, patch.start) + patch.insert + base.slice(patch.start + patch.deleteCount)
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
function applyPatchSet(base, patch) {
|
|
957
|
-
if (!Array.isArray(patch)) return applyPatch(base, patch)
|
|
958
|
-
if (patch.length === 0) return base
|
|
959
|
-
|
|
960
|
-
let result = ''
|
|
961
|
-
let cursor = 0
|
|
962
|
-
for (const item of patch) {
|
|
963
|
-
if (item.start < cursor) return { error: 'overlapping patch', status: 400 }
|
|
964
|
-
result += base.slice(cursor, item.start)
|
|
965
|
-
result += item.insert
|
|
966
|
-
cursor = item.start + item.deleteCount
|
|
967
|
-
}
|
|
968
|
-
return result + base.slice(cursor)
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
async function readState(env, affinity) {
|
|
972
|
-
const raw = await env.RELAY_STATE.get('session:' + affinity)
|
|
973
|
-
return raw ? JSON.parse(raw) : null
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
async function writeState(env, affinity, state) {
|
|
977
|
-
await env.RELAY_STATE.put('session:' + affinity, JSON.stringify(state), { expirationTtl: 86400 })
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
async function resolveBody(env, payload) {
|
|
981
|
-
if (payload.mode === 'full_sync') return payload.body
|
|
982
|
-
if (payload.mode === 'patch') {
|
|
983
|
-
const state = await readState(env, payload.affinity)
|
|
984
|
-
if (!state || state.hash !== payload.base_hash) {
|
|
985
|
-
return { error: 'state mismatch', status: 409 }
|
|
986
|
-
}
|
|
987
|
-
return applyPatchSet(state.body, payload.patch)
|
|
988
|
-
}
|
|
989
|
-
return { error: 'unknown mode', status: 400 }
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
async function prepareUpstream(env, payload) {
|
|
993
|
-
if ((payload.protocol !== 1 && payload.protocol !== 2) || payload.type !== 'request' || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {
|
|
994
|
-
return { error: 'invalid payload', status: 400 }
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
const body = await resolveBody(env, payload)
|
|
998
|
-
if (body && typeof body === 'object' && 'error' in body) return body
|
|
999
|
-
|
|
1000
|
-
if (typeof body !== 'string' || (await hashBody(body)) !== payload.next_hash) {
|
|
1001
|
-
return { error: 'hash mismatch', status: 409 }
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
const stateWrite = writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision }).catch(() => {})
|
|
1005
|
-
console.log(JSON.stringify({
|
|
1006
|
-
relay: 'opencode-anthropic-auth',
|
|
1007
|
-
transport: 'relay',
|
|
1008
|
-
mode: payload.mode,
|
|
1009
|
-
revision: payload.revision,
|
|
1010
|
-
affinity: String(payload.affinity).slice(0, 12),
|
|
1011
|
-
bodyBytes: body.length,
|
|
1012
|
-
}))
|
|
1013
|
-
|
|
1014
|
-
return { body, stateWrite }
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
function resolveBodyFromState(state, payload) {
|
|
1018
|
-
if (payload.mode === 'full_sync') return payload.body
|
|
1019
|
-
if (payload.mode === 'patch') {
|
|
1020
|
-
if (!state || state.hash !== payload.base_hash) {
|
|
1021
|
-
return { error: 'state mismatch', status: 409 }
|
|
1022
|
-
}
|
|
1023
|
-
return applyPatchSet(state.body, payload.patch)
|
|
1024
|
-
}
|
|
1025
|
-
return { error: 'unknown mode', status: 400 }
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
function deferWorkerTask(task) {
|
|
1029
|
-
return new Promise((resolve) => setTimeout(resolve, 0)).then(task)
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
const WEBSOCKET_STREAM_FLUSH_BYTES = 1024
|
|
1033
|
-
const WEBSOCKET_STREAM_FLUSH_MS = 20
|
|
1034
|
-
|
|
1035
|
-
function createWebSocketStreamCoalescer(socket, options = {}) {
|
|
1036
|
-
const flushBytes = options.flushBytes || WEBSOCKET_STREAM_FLUSH_BYTES
|
|
1037
|
-
const flushMs = options.flushMs ?? WEBSOCKET_STREAM_FLUSH_MS
|
|
1038
|
-
let chunks = []
|
|
1039
|
-
let byteLength = 0
|
|
1040
|
-
let flushTimer = null
|
|
1041
|
-
const stats = {
|
|
1042
|
-
upstreamChunks: 0,
|
|
1043
|
-
upstreamBytes: 0,
|
|
1044
|
-
frames: 0,
|
|
1045
|
-
frameBytes: 0,
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
const clearFlushTimer = () => {
|
|
1049
|
-
if (flushTimer === null) return
|
|
1050
|
-
clearTimeout(flushTimer)
|
|
1051
|
-
flushTimer = null
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
const mergeChunks = () => {
|
|
1055
|
-
if (chunks.length === 1) return chunks[0]
|
|
1056
|
-
const frame = new Uint8Array(byteLength)
|
|
1057
|
-
let offset = 0
|
|
1058
|
-
for (const chunk of chunks) {
|
|
1059
|
-
frame.set(chunk, offset)
|
|
1060
|
-
offset += chunk.byteLength
|
|
1061
|
-
}
|
|
1062
|
-
return frame
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
const flush = () => {
|
|
1066
|
-
clearFlushTimer()
|
|
1067
|
-
if (byteLength === 0) return
|
|
1068
|
-
const frame = mergeChunks()
|
|
1069
|
-
chunks = []
|
|
1070
|
-
byteLength = 0
|
|
1071
|
-
socket.send(frame)
|
|
1072
|
-
stats.frames += 1
|
|
1073
|
-
stats.frameBytes += frame.byteLength
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
const scheduleFlush = () => {
|
|
1077
|
-
if (flushTimer !== null) return
|
|
1078
|
-
flushTimer = setTimeout(flush, flushMs)
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
return {
|
|
1082
|
-
push(value) {
|
|
1083
|
-
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)
|
|
1084
|
-
if (chunk.byteLength === 0) return
|
|
1085
|
-
stats.upstreamChunks += 1
|
|
1086
|
-
stats.upstreamBytes += chunk.byteLength
|
|
1087
|
-
chunks.push(chunk)
|
|
1088
|
-
byteLength += chunk.byteLength
|
|
1089
|
-
if (byteLength >= flushBytes) flush()
|
|
1090
|
-
else scheduleFlush()
|
|
1091
|
-
},
|
|
1092
|
-
flush,
|
|
1093
|
-
stats() {
|
|
1094
|
-
return { ...stats, pendingBytes: byteLength }
|
|
1095
|
-
},
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
function checkpointWebSocketState(env, payload, body, nextState) {
|
|
1100
|
-
return deferWorkerTask(async () => {
|
|
1101
|
-
try {
|
|
1102
|
-
if ((await hashBody(body)) !== payload.next_hash) {
|
|
1103
|
-
throw new Error('hash mismatch')
|
|
1104
|
-
}
|
|
1105
|
-
await writeState(env, payload.affinity, nextState)
|
|
1106
|
-
} catch (error) {
|
|
1107
|
-
console.log(JSON.stringify({
|
|
1108
|
-
relay: 'opencode-anthropic-auth',
|
|
1109
|
-
transport: 'websocket',
|
|
1110
|
-
event: 'checkpoint_write_failed',
|
|
1111
|
-
affinity: String(payload.affinity).slice(0, 12),
|
|
1112
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1113
|
-
}))
|
|
1114
|
-
}
|
|
1115
|
-
})
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
async function prepareWebSocketUpstream(env, state, payload) {
|
|
1119
|
-
if (payload.protocol !== 2 || payload.type !== 'request' || !payload.id || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {
|
|
1120
|
-
return { error: 'invalid payload', status: 400 }
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
const body = resolveBodyFromState(state, payload)
|
|
1124
|
-
if (body && typeof body === 'object' && 'error' in body) return body
|
|
1125
|
-
if (typeof body !== 'string') return { error: 'invalid body', status: 400 }
|
|
1126
|
-
if ((await hashBody(body)) !== payload.next_hash) {
|
|
1127
|
-
return { error: 'hash mismatch', status: 409 }
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
const nextState = { body, hash: payload.next_hash, revision: payload.revision }
|
|
1131
|
-
const checkpoint = checkpointWebSocketState(env, payload, body, nextState)
|
|
1132
|
-
const logAccepted = () => console.log(JSON.stringify({
|
|
1133
|
-
relay: 'opencode-anthropic-auth',
|
|
1134
|
-
transport: 'websocket',
|
|
1135
|
-
mode: payload.mode,
|
|
1136
|
-
revision: payload.revision,
|
|
1137
|
-
affinity: String(payload.affinity).slice(0, 12),
|
|
1138
|
-
bodyBytes: body.length,
|
|
1139
|
-
}))
|
|
1140
|
-
|
|
1141
|
-
return { body, state: nextState, checkpoint, logAccepted }
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
function headersToObject(headers) {
|
|
1145
|
-
const result = {}
|
|
1146
|
-
for (const [key, value] of headers.entries()) result[key] = value
|
|
1147
|
-
return result
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
async function handleRelayPayload(env, payload) {
|
|
1151
|
-
const prepared = await prepareUpstream(env, payload)
|
|
1152
|
-
if (prepared.error) return prepared
|
|
1153
|
-
const upstream = await fetch(payload.upstream.url, {
|
|
1154
|
-
method: payload.upstream.method || 'POST',
|
|
1155
|
-
headers: payload.upstream.headers,
|
|
1156
|
-
body: prepared.body,
|
|
1157
|
-
})
|
|
1158
|
-
return { upstream, stateWrite: prepared.stateWrite }
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
|
|
1162
|
-
const heartbeat = setInterval(() => {
|
|
1163
|
-
try {
|
|
1164
|
-
socket.send(JSON.stringify({ protocol: 2, type: 'keepalive' }))
|
|
1165
|
-
} catch {
|
|
1166
|
-
clearInterval(heartbeat)
|
|
1167
|
-
}
|
|
1168
|
-
}, 15000)
|
|
1169
|
-
let streamCoalescer = null
|
|
1170
|
-
try {
|
|
1171
|
-
const result = await prepareWebSocketUpstream(env, getState(), payload)
|
|
1172
|
-
if (result.error) {
|
|
1173
|
-
socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: result.status, message: result.error }))
|
|
1174
|
-
return
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
setState(result.state)
|
|
1178
|
-
socket.send(JSON.stringify({ protocol: 2, type: 'accepted', id: payload.id, hash: result.state.hash, revision: result.state.revision }))
|
|
1179
|
-
ctx?.waitUntil?.(deferWorkerTask(result.logAccepted))
|
|
1180
|
-
|
|
1181
|
-
const upstreamPromise = fetch(payload.upstream.url, {
|
|
1182
|
-
method: payload.upstream.method || 'POST',
|
|
1183
|
-
headers: payload.upstream.headers,
|
|
1184
|
-
body: result.body,
|
|
1185
|
-
})
|
|
1186
|
-
ctx?.waitUntil?.(result.checkpoint)
|
|
1187
|
-
const upstream = await upstreamPromise
|
|
1188
|
-
socket.send(JSON.stringify({
|
|
1189
|
-
protocol: 2,
|
|
1190
|
-
type: 'response_start',
|
|
1191
|
-
id: payload.id,
|
|
1192
|
-
status: upstream.status,
|
|
1193
|
-
statusText: upstream.statusText,
|
|
1194
|
-
headers: headersToObject(upstream.headers),
|
|
1195
|
-
}))
|
|
1196
|
-
|
|
1197
|
-
const reader = upstream.body?.getReader()
|
|
1198
|
-
if (reader) {
|
|
1199
|
-
streamCoalescer = createWebSocketStreamCoalescer(socket)
|
|
1200
|
-
while (true) {
|
|
1201
|
-
const { done, value } = await reader.read()
|
|
1202
|
-
if (done) break
|
|
1203
|
-
streamCoalescer.push(value)
|
|
1204
|
-
}
|
|
1205
|
-
streamCoalescer.flush()
|
|
1206
|
-
}
|
|
1207
|
-
const stats = streamCoalescer?.stats?.() || {}
|
|
1208
|
-
socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id, ...stats }))
|
|
1209
|
-
} catch (error) {
|
|
1210
|
-
try {
|
|
1211
|
-
streamCoalescer?.flush?.()
|
|
1212
|
-
} catch {}
|
|
1213
|
-
socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: 500, message: error instanceof Error ? error.message : String(error) }))
|
|
1214
|
-
} finally {
|
|
1215
|
-
clearInterval(heartbeat)
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
export default {
|
|
1220
|
-
async fetch(request, env, ctx) {
|
|
1221
|
-
if (request.headers.get('Upgrade') === 'websocket') {
|
|
1222
|
-
const url = new URL(request.url)
|
|
1223
|
-
const token = url.searchParams.get('token')
|
|
1224
|
-
const affinity = url.searchParams.get('affinity')
|
|
1225
|
-
if (token !== env.RELAY_TOKEN) return new Response('unauthorized', { status: 401 })
|
|
1226
|
-
if (!affinity) return new Response('missing affinity', { status: 400 })
|
|
1227
|
-
const pair = new WebSocketPair()
|
|
1228
|
-
const client = pair[0]
|
|
1229
|
-
const server = pair[1]
|
|
1230
|
-
server.binaryType = 'arraybuffer'
|
|
1231
|
-
let state = null
|
|
1232
|
-
let ready = false
|
|
1233
|
-
server.accept()
|
|
1234
|
-
const loadState = readState(env, affinity).then((loadedState) => {
|
|
1235
|
-
state = loadedState
|
|
1236
|
-
ready = true
|
|
1237
|
-
server.send(JSON.stringify({
|
|
1238
|
-
protocol: 2,
|
|
1239
|
-
type: 'ready',
|
|
1240
|
-
state: state ? { hash: state.hash, revision: state.revision } : null,
|
|
1241
|
-
}))
|
|
1242
|
-
}).catch((error) => {
|
|
1243
|
-
server.send(JSON.stringify({ protocol: 2, type: 'error', status: 500, message: error instanceof Error ? error.message : String(error) }))
|
|
1244
|
-
server.close(1011, 'state load failed')
|
|
1245
|
-
})
|
|
1246
|
-
ctx?.waitUntil?.(loadState)
|
|
1247
|
-
if (!ctx?.waitUntil) void loadState
|
|
1248
|
-
let busy = false
|
|
1249
|
-
server.addEventListener('message', (event) => {
|
|
1250
|
-
if (!ready) {
|
|
1251
|
-
server.send(JSON.stringify({ protocol: 2, type: 'error', status: 425, message: 'relay state is not ready' }))
|
|
1252
|
-
return
|
|
1253
|
-
}
|
|
1254
|
-
if (busy) {
|
|
1255
|
-
server.send(JSON.stringify({ protocol: 2, type: 'error', status: 429, message: 'request already in flight' }))
|
|
1256
|
-
return
|
|
1257
|
-
}
|
|
1258
|
-
let payload
|
|
1259
|
-
try {
|
|
1260
|
-
payload = JSON.parse(event.data)
|
|
1261
|
-
} catch {
|
|
1262
|
-
server.send(JSON.stringify({ protocol: 2, type: 'error', status: 400, message: 'invalid JSON payload' }))
|
|
1263
|
-
return
|
|
1264
|
-
}
|
|
1265
|
-
payload.affinity = affinity
|
|
1266
|
-
busy = true
|
|
1267
|
-
const run = handleWebSocket(server, env, ctx, payload, () => state, (nextState) => { state = nextState }).finally(() => { busy = false })
|
|
1268
|
-
ctx?.waitUntil?.(run)
|
|
1269
|
-
if (!ctx?.waitUntil) void run
|
|
1270
|
-
})
|
|
1271
|
-
return new Response(null, { status: 101, webSocket: client })
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
if (request.method === 'GET') {
|
|
1275
|
-
return Response.json({ status: 'ok', transports: ['http', 'websocket'] })
|
|
1276
|
-
}
|
|
1277
|
-
if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })
|
|
1278
|
-
if (request.headers.get('x-relay-token') !== env.RELAY_TOKEN) {
|
|
1279
|
-
return new Response('unauthorized', { status: 401 })
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
try {
|
|
1283
|
-
const payload = await request.json()
|
|
1284
|
-
const result = await handleRelayPayload(env, payload)
|
|
1285
|
-
if (result.error) return Response.json({ error: result.error }, { status: result.status })
|
|
1286
|
-
|
|
1287
|
-
if (result.stateWrite) ctx.waitUntil(result.stateWrite)
|
|
1288
|
-
|
|
1289
|
-
const upstream = result.upstream
|
|
1290
|
-
return new Response(upstream.body, {
|
|
1291
|
-
status: upstream.status,
|
|
1292
|
-
statusText: upstream.statusText,
|
|
1293
|
-
headers: upstream.headers,
|
|
1294
|
-
})
|
|
1295
|
-
} catch (error) {
|
|
1296
|
-
return Response.json(
|
|
1297
|
-
{ error: error instanceof Error ? error.message : 'internal relay error' },
|
|
1298
|
-
{ status: 502 },
|
|
1299
|
-
)
|
|
1300
|
-
}
|
|
1301
|
-
},
|
|
1302
|
-
}
|
|
1303
|
-
`;
|