@cortexkit/anthropic-auth-core 1.1.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accounts.d.ts +1 -1
- package/dist/accounts.js +2 -2
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +55 -23
- package/dist/relay.d.ts +1 -1
- package/dist/relay.js +36 -14
- package/package.json +1 -1
package/dist/accounts.d.ts
CHANGED
|
@@ -141,7 +141,7 @@ export declare class FallbackAccountManager {
|
|
|
141
141
|
save(storage: AccountStorage): Promise<void>;
|
|
142
142
|
startBackgroundRefresh(): void;
|
|
143
143
|
stopBackgroundRefresh(): void;
|
|
144
|
-
getUsableFallbackAccounts(): Promise<OAuthAccount[]>;
|
|
144
|
+
getUsableFallbackAccounts(existingStorage?: AccountStorage | null): Promise<OAuthAccount[]>;
|
|
145
145
|
markUsed(account: OAuthAccount): Promise<void>;
|
|
146
146
|
accountPassesQuotaPolicy(account: OAuthAccount, storage: AccountStorage | null): boolean;
|
|
147
147
|
refreshDueAccounts(): Promise<void>;
|
package/dist/accounts.js
CHANGED
|
@@ -544,8 +544,8 @@ export class FallbackAccountManager {
|
|
|
544
544
|
this.refreshTimer = null;
|
|
545
545
|
this.quotaTimer = null;
|
|
546
546
|
}
|
|
547
|
-
async getUsableFallbackAccounts() {
|
|
548
|
-
const storage = await this.load();
|
|
547
|
+
async getUsableFallbackAccounts(existingStorage) {
|
|
548
|
+
const storage = existingStorage !== undefined ? existingStorage : await this.load();
|
|
549
549
|
if (!storage)
|
|
550
550
|
return [];
|
|
551
551
|
const usable = [];
|
package/dist/auth.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export declare function refreshClaudeOAuthToken(input: {
|
|
|
13
13
|
refreshToken: string;
|
|
14
14
|
fetchImpl?: typeof fetch;
|
|
15
15
|
now?: () => number;
|
|
16
|
+
maxRetries?: number;
|
|
17
|
+
baseDelayMs?: number;
|
|
16
18
|
}): Promise<ClaudeOAuthRefreshResult>;
|
|
17
19
|
export type AuthorizationResult = {
|
|
18
20
|
url: string;
|
package/dist/auth.js
CHANGED
|
@@ -10,32 +10,64 @@ export class ClaudeOAuthRefreshError extends Error {
|
|
|
10
10
|
this.name = 'ClaudeOAuthRefreshError';
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
function isTransientNetworkError(error) {
|
|
14
|
+
if (!(error instanceof Error))
|
|
15
|
+
return false;
|
|
16
|
+
const code = error.code;
|
|
17
|
+
return (error.message.includes('fetch failed') ||
|
|
18
|
+
code === 'ECONNRESET' ||
|
|
19
|
+
code === 'ECONNREFUSED' ||
|
|
20
|
+
code === 'ETIMEDOUT' ||
|
|
21
|
+
code === 'UND_ERR_CONNECT_TIMEOUT');
|
|
22
|
+
}
|
|
13
23
|
export async function refreshClaudeOAuthToken(input) {
|
|
14
24
|
const fetchImpl = input.fetchImpl ?? fetch;
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
const maxRetries = input.maxRetries ?? 2;
|
|
26
|
+
const baseDelayMs = input.baseDelayMs ?? 500;
|
|
27
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
28
|
+
try {
|
|
29
|
+
if (attempt > 0) {
|
|
30
|
+
const delay = baseDelayMs * 2 ** (attempt - 1);
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
32
|
+
}
|
|
33
|
+
const response = await fetchImpl(TOKEN_URL, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: {
|
|
36
|
+
'Content-Type': 'application/json',
|
|
37
|
+
Accept: 'application/json',
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
grant_type: 'refresh_token',
|
|
41
|
+
refresh_token: input.refreshToken,
|
|
42
|
+
client_id: CLIENT_ID,
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
if (response.status >= 500 && attempt < maxRetries) {
|
|
47
|
+
await response.body?.cancel().catch(() => { });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const body = await response.text().catch(() => '');
|
|
51
|
+
throw new ClaudeOAuthRefreshError(response.status, body);
|
|
52
|
+
}
|
|
53
|
+
const json = (await response.json());
|
|
54
|
+
const refreshedAt = input.now?.() ?? Date.now();
|
|
55
|
+
return {
|
|
56
|
+
access: json.access_token,
|
|
57
|
+
refresh: json.refresh_token ?? input.refreshToken,
|
|
58
|
+
expires: refreshedAt + json.expires_in * 1000,
|
|
59
|
+
expiresIn: json.expires_in,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error instanceof ClaudeOAuthRefreshError)
|
|
64
|
+
throw error;
|
|
65
|
+
if (attempt < maxRetries && isTransientNetworkError(error))
|
|
66
|
+
continue;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
30
69
|
}
|
|
31
|
-
|
|
32
|
-
const refreshedAt = input.now?.() ?? Date.now();
|
|
33
|
-
return {
|
|
34
|
-
access: json.access_token,
|
|
35
|
-
refresh: json.refresh_token ?? input.refreshToken,
|
|
36
|
-
expires: refreshedAt + json.expires_in * 1000,
|
|
37
|
-
expiresIn: json.expires_in,
|
|
38
|
-
};
|
|
70
|
+
throw new Error('Token refresh exhausted all retries');
|
|
39
71
|
}
|
|
40
72
|
function generateState() {
|
|
41
73
|
return crypto.randomUUID().replace(/-/g, '');
|
package/dist/relay.d.ts
CHANGED
|
@@ -25,4 +25,4 @@ export declare function sendViaRelay(options: {
|
|
|
25
25
|
affinity?: string | null;
|
|
26
26
|
optimisticResponse?: boolean;
|
|
27
27
|
}): Promise<Response>;
|
|
28
|
-
export declare const WORKER_SCRIPT = "\nasync function hashBody(body) {\n const bytes = new TextEncoder().encode(body)\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')\n return 'sha256:' + hex\n}\n\nfunction applyPatch(base, patch) {\n return base.slice(0, patch.start) + patch.insert + base.slice(patch.start + patch.deleteCount)\n}\n\nfunction applyPatchSet(base, patch) {\n if (!Array.isArray(patch)) return applyPatch(base, patch)\n if (patch.length === 0) return base\n\n let result = ''\n let cursor = 0\n for (const item of patch) {\n if (item.start < cursor) return { error: 'overlapping patch', status: 400 }\n result += base.slice(cursor, item.start)\n result += item.insert\n cursor = item.start + item.deleteCount\n }\n return result + base.slice(cursor)\n}\n\nasync function readState(env, affinity) {\n const raw = await env.RELAY_STATE.get('session:' + affinity)\n return raw ? JSON.parse(raw) : null\n}\n\nasync function writeState(env, affinity, state) {\n await env.RELAY_STATE.put('session:' + affinity, JSON.stringify(state), { expirationTtl: 86400 })\n}\n\nasync function resolveBody(env, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n const state = await readState(env, payload.affinity)\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nasync function prepareUpstream(env, payload) {\n if ((payload.protocol !== 1 && payload.protocol !== 2) || payload.type !== 'request' || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = await resolveBody(env, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n\n if (typeof body !== 'string' || (await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n await writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision })\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'relay',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body }\n}\n\nfunction resolveBodyFromState(state, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nfunction deferWorkerTask(task) {\n return new Promise((resolve) => setTimeout(resolve, 0)).then(task)\n}\n\nfunction checkpointWebSocketState(env, payload, body, nextState) {\n return deferWorkerTask(async () => {\n try {\n if ((await hashBody(body)) !== payload.next_hash) {\n throw new Error('hash mismatch')\n }\n await writeState(env, payload.affinity, nextState)\n } catch (error) {\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n event: 'checkpoint_write_failed',\n affinity: String(payload.affinity).slice(0, 12),\n message: error instanceof Error ? error.message : String(error),\n }))\n }\n })\n}\n\nasync function prepareWebSocketUpstream(env, state, payload) {\n if (payload.protocol !== 2 || payload.type !== 'request' || !payload.id || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = resolveBodyFromState(state, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n if (typeof body !== 'string') return { error: 'invalid body', status: 400 }\n if ((await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const nextState = { body, hash: payload.next_hash, revision: payload.revision }\n const checkpoint = checkpointWebSocketState(env, payload, body, nextState)\n const logAccepted = () => console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, state: nextState, checkpoint, logAccepted }\n}\n\nfunction headersToObject(headers) {\n const result = {}\n for (const [key, value] of headers.entries()) result[key] = value\n return result\n}\n\nasync function handleRelayPayload(env, payload) {\n const prepared = await prepareUpstream(env, payload)\n if (prepared.error) return prepared\n const upstream = await fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: prepared.body,\n })\n return { upstream }\n}\n\nasync function handleWebSocket(socket, env, ctx, payload, getState, setState) {\n const heartbeat = setInterval(() => {\n try {\n socket.send(JSON.stringify({ protocol: 2, type: 'keepalive' }))\n } catch {\n clearInterval(heartbeat)\n }\n }, 5000)\n try {\n const result = await prepareWebSocketUpstream(env, getState(), payload)\n if (result.error) {\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: result.status, message: result.error }))\n return\n }\n\n setState(result.state)\n socket.send(JSON.stringify({ protocol: 2, type: 'accepted', id: payload.id, hash: result.state.hash, revision: result.state.revision }))\n ctx?.waitUntil?.(deferWorkerTask(result.logAccepted))\n\n const upstreamPromise = fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: result.body,\n })\n ctx?.waitUntil?.(result.checkpoint)\n const upstream = await upstreamPromise\n socket.send(JSON.stringify({\n protocol: 2,\n type: 'response_start',\n id: payload.id,\n status: upstream.status,\n statusText: upstream.statusText,\n headers: headersToObject(upstream.headers),\n }))\n\n const reader = upstream.body?.getReader()\n if (reader) {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n socket.send(value)\n }\n }\n socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id }))\n } catch (error) {\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: 500, message: error instanceof Error ? error.message : String(error) }))\n } finally {\n clearInterval(heartbeat)\n }\n}\n\nexport default {\n async fetch(request, env, ctx) {\n if (request.headers.get('Upgrade') === 'websocket') {\n const url = new URL(request.url)\n const token = url.searchParams.get('token')\n const affinity = url.searchParams.get('affinity')\n if (token !== env.RELAY_TOKEN) return new Response('unauthorized', { status: 401 })\n if (!affinity) return new Response('missing affinity', { status: 400 })\n const pair = new WebSocketPair()\n const client = pair[0]\n const server = pair[1]\n server.binaryType = 'arraybuffer'\n let state = null\n let ready = false\n server.accept()\n const loadState = readState(env, affinity).then((loadedState) => {\n state = loadedState\n ready = true\n server.send(JSON.stringify({\n protocol: 2,\n type: 'ready',\n state: state ? { hash: state.hash, revision: state.revision } : null,\n }))\n }).catch((error) => {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 500, message: error instanceof Error ? error.message : String(error) }))\n server.close(1011, 'state load failed')\n })\n ctx?.waitUntil?.(loadState)\n if (!ctx?.waitUntil) void loadState\n let busy = false\n server.addEventListener('message', (event) => {\n if (!ready) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 425, message: 'relay state is not ready' }))\n return\n }\n if (busy) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 429, message: 'request already in flight' }))\n return\n }\n let payload\n try {\n payload = JSON.parse(event.data)\n } catch {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 400, message: 'invalid JSON payload' }))\n return\n }\n payload.affinity = affinity\n busy = true\n const run = handleWebSocket(server, env, ctx, payload, () => state, (nextState) => { state = nextState }).finally(() => { busy = false })\n ctx?.waitUntil?.(run)\n if (!ctx?.waitUntil) void run\n })\n return new Response(null, { status: 101, webSocket: client })\n }\n\n if (request.method === 'GET') return new Response('ok')\n if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })\n if (request.headers.get('x-relay-token') !== env.RELAY_TOKEN) {\n return new Response('unauthorized', { status: 401 })\n }\n\n const payload = await request.json()\n const result = await handleRelayPayload(env, payload)\n if (result.error) return Response.json({ error: result.error }, { status: result.status })\n\n const upstream = result.upstream\n return new Response(upstream.body, {\n status: upstream.status,\n statusText: upstream.statusText,\n headers: upstream.headers,\n })\n },\n}\n";
|
|
28
|
+
export declare const WORKER_SCRIPT = "\nasync function hashBody(body) {\n const bytes = new TextEncoder().encode(body)\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')\n return 'sha256:' + hex\n}\n\nfunction applyPatch(base, patch) {\n return base.slice(0, patch.start) + patch.insert + base.slice(patch.start + patch.deleteCount)\n}\n\nfunction applyPatchSet(base, patch) {\n if (!Array.isArray(patch)) return applyPatch(base, patch)\n if (patch.length === 0) return base\n\n let result = ''\n let cursor = 0\n for (const item of patch) {\n if (item.start < cursor) return { error: 'overlapping patch', status: 400 }\n result += base.slice(cursor, item.start)\n result += item.insert\n cursor = item.start + item.deleteCount\n }\n return result + base.slice(cursor)\n}\n\nasync function readState(env, affinity) {\n const raw = await env.RELAY_STATE.get('session:' + affinity)\n return raw ? JSON.parse(raw) : null\n}\n\nasync function writeState(env, affinity, state) {\n await env.RELAY_STATE.put('session:' + affinity, JSON.stringify(state), { expirationTtl: 86400 })\n}\n\nasync function resolveBody(env, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n const state = await readState(env, payload.affinity)\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nasync function prepareUpstream(env, payload) {\n if ((payload.protocol !== 1 && payload.protocol !== 2) || payload.type !== 'request' || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = await resolveBody(env, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n\n if (typeof body !== 'string' || (await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const stateWrite = writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision }).catch(() => {})\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'relay',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, stateWrite }\n}\n\nfunction resolveBodyFromState(state, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nfunction deferWorkerTask(task) {\n return new Promise((resolve) => setTimeout(resolve, 0)).then(task)\n}\n\nfunction checkpointWebSocketState(env, payload, body, nextState) {\n return deferWorkerTask(async () => {\n try {\n if ((await hashBody(body)) !== payload.next_hash) {\n throw new Error('hash mismatch')\n }\n await writeState(env, payload.affinity, nextState)\n } catch (error) {\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n event: 'checkpoint_write_failed',\n affinity: String(payload.affinity).slice(0, 12),\n message: error instanceof Error ? error.message : String(error),\n }))\n }\n })\n}\n\nasync function prepareWebSocketUpstream(env, state, payload) {\n if (payload.protocol !== 2 || payload.type !== 'request' || !payload.id || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = resolveBodyFromState(state, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n if (typeof body !== 'string') return { error: 'invalid body', status: 400 }\n if ((await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const nextState = { body, hash: payload.next_hash, revision: payload.revision }\n const checkpoint = checkpointWebSocketState(env, payload, body, nextState)\n const logAccepted = () => console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, state: nextState, checkpoint, logAccepted }\n}\n\nfunction headersToObject(headers) {\n const result = {}\n for (const [key, value] of headers.entries()) result[key] = value\n return result\n}\n\nasync function handleRelayPayload(env, payload) {\n const prepared = await prepareUpstream(env, payload)\n if (prepared.error) return prepared\n const upstream = await fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: prepared.body,\n })\n return { upstream, stateWrite: prepared.stateWrite }\n}\n\nasync function handleWebSocket(socket, env, ctx, payload, getState, setState) {\n const heartbeat = setInterval(() => {\n try {\n socket.send(JSON.stringify({ protocol: 2, type: 'keepalive' }))\n } catch {\n clearInterval(heartbeat)\n }\n }, 15000)\n try {\n const result = await prepareWebSocketUpstream(env, getState(), payload)\n if (result.error) {\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: result.status, message: result.error }))\n return\n }\n\n setState(result.state)\n socket.send(JSON.stringify({ protocol: 2, type: 'accepted', id: payload.id, hash: result.state.hash, revision: result.state.revision }))\n ctx?.waitUntil?.(deferWorkerTask(result.logAccepted))\n\n const upstreamPromise = fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: result.body,\n })\n ctx?.waitUntil?.(result.checkpoint)\n const upstream = await upstreamPromise\n socket.send(JSON.stringify({\n protocol: 2,\n type: 'response_start',\n id: payload.id,\n status: upstream.status,\n statusText: upstream.statusText,\n headers: headersToObject(upstream.headers),\n }))\n\n const reader = upstream.body?.getReader()\n if (reader) {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n socket.send(value)\n }\n }\n socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id }))\n } catch (error) {\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: 500, message: error instanceof Error ? error.message : String(error) }))\n } finally {\n clearInterval(heartbeat)\n }\n}\n\nexport default {\n async fetch(request, env, ctx) {\n if (request.headers.get('Upgrade') === 'websocket') {\n const url = new URL(request.url)\n const token = url.searchParams.get('token')\n const affinity = url.searchParams.get('affinity')\n if (token !== env.RELAY_TOKEN) return new Response('unauthorized', { status: 401 })\n if (!affinity) return new Response('missing affinity', { status: 400 })\n const pair = new WebSocketPair()\n const client = pair[0]\n const server = pair[1]\n server.binaryType = 'arraybuffer'\n let state = null\n let ready = false\n server.accept()\n const loadState = readState(env, affinity).then((loadedState) => {\n state = loadedState\n ready = true\n server.send(JSON.stringify({\n protocol: 2,\n type: 'ready',\n state: state ? { hash: state.hash, revision: state.revision } : null,\n }))\n }).catch((error) => {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 500, message: error instanceof Error ? error.message : String(error) }))\n server.close(1011, 'state load failed')\n })\n ctx?.waitUntil?.(loadState)\n if (!ctx?.waitUntil) void loadState\n let busy = false\n server.addEventListener('message', (event) => {\n if (!ready) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 425, message: 'relay state is not ready' }))\n return\n }\n if (busy) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 429, message: 'request already in flight' }))\n return\n }\n let payload\n try {\n payload = JSON.parse(event.data)\n } catch {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 400, message: 'invalid JSON payload' }))\n return\n }\n payload.affinity = affinity\n busy = true\n const run = handleWebSocket(server, env, ctx, payload, () => state, (nextState) => { state = nextState }).finally(() => { busy = false })\n ctx?.waitUntil?.(run)\n if (!ctx?.waitUntil) void run\n })\n return new Response(null, { status: 101, webSocket: client })\n }\n\n if (request.method === 'GET') {\n return Response.json({ status: 'ok', transports: ['http', 'websocket'] })\n }\n if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })\n if (request.headers.get('x-relay-token') !== env.RELAY_TOKEN) {\n return new Response('unauthorized', { status: 401 })\n }\n\n try {\n const payload = await request.json()\n const result = await handleRelayPayload(env, payload)\n if (result.error) return Response.json({ error: result.error }, { status: result.status })\n\n if (result.stateWrite) ctx.waitUntil(result.stateWrite)\n\n const upstream = result.upstream\n return new Response(upstream.body, {\n status: upstream.status,\n statusText: upstream.statusText,\n headers: upstream.headers,\n })\n } catch (error) {\n return Response.json(\n { error: error instanceof Error ? error.message : 'internal relay error' },\n { status: 502 },\n )\n }\n },\n}\n";
|
package/dist/relay.js
CHANGED
|
@@ -544,6 +544,17 @@ class PersistentRelaySession {
|
|
|
544
544
|
return;
|
|
545
545
|
}
|
|
546
546
|
if (message.type === 'error') {
|
|
547
|
+
// If an optimistic response is already streaming (responseStartedAt is
|
|
548
|
+
// set), the pending promise was already resolved — calling failPending
|
|
549
|
+
// would reject a settled promise and leave the stream hanging. Instead,
|
|
550
|
+
// inject an SSE error event and close cleanly.
|
|
551
|
+
if (pending.optimisticResponse && pending.responseStartedAt != null) {
|
|
552
|
+
relayLog(`websocket relay error during optimistic response session=${shortAffinity(this.affinity)} request=${pending.payload.id} status=${message.status} message=${message.message || 'unknown'}`);
|
|
553
|
+
pending.streamController?.enqueue(new TextEncoder().encode(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'relay_error', message: message.message || 'relay error' } })}\n\n`));
|
|
554
|
+
this.finishPending();
|
|
555
|
+
this.socket?.close();
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
547
558
|
if (message.status === 409 &&
|
|
548
559
|
this.retryPendingBeforeResponse(pending, message.message || 'state mismatch')) {
|
|
549
560
|
return;
|
|
@@ -718,7 +729,7 @@ async function prepareUpstream(env, payload) {
|
|
|
718
729
|
return { error: 'hash mismatch', status: 409 }
|
|
719
730
|
}
|
|
720
731
|
|
|
721
|
-
|
|
732
|
+
const stateWrite = writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision }).catch(() => {})
|
|
722
733
|
console.log(JSON.stringify({
|
|
723
734
|
relay: 'opencode-anthropic-auth',
|
|
724
735
|
transport: 'relay',
|
|
@@ -728,7 +739,7 @@ async function prepareUpstream(env, payload) {
|
|
|
728
739
|
bodyBytes: body.length,
|
|
729
740
|
}))
|
|
730
741
|
|
|
731
|
-
return { body }
|
|
742
|
+
return { body, stateWrite }
|
|
732
743
|
}
|
|
733
744
|
|
|
734
745
|
function resolveBodyFromState(state, payload) {
|
|
@@ -805,7 +816,7 @@ async function handleRelayPayload(env, payload) {
|
|
|
805
816
|
headers: payload.upstream.headers,
|
|
806
817
|
body: prepared.body,
|
|
807
818
|
})
|
|
808
|
-
return { upstream }
|
|
819
|
+
return { upstream, stateWrite: prepared.stateWrite }
|
|
809
820
|
}
|
|
810
821
|
|
|
811
822
|
async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
|
|
@@ -815,7 +826,7 @@ async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
|
|
|
815
826
|
} catch {
|
|
816
827
|
clearInterval(heartbeat)
|
|
817
828
|
}
|
|
818
|
-
},
|
|
829
|
+
}, 15000)
|
|
819
830
|
try {
|
|
820
831
|
const result = await prepareWebSocketUpstream(env, getState(), payload)
|
|
821
832
|
if (result.error) {
|
|
@@ -914,22 +925,33 @@ export default {
|
|
|
914
925
|
return new Response(null, { status: 101, webSocket: client })
|
|
915
926
|
}
|
|
916
927
|
|
|
917
|
-
if (request.method === 'GET')
|
|
928
|
+
if (request.method === 'GET') {
|
|
929
|
+
return Response.json({ status: 'ok', transports: ['http', 'websocket'] })
|
|
930
|
+
}
|
|
918
931
|
if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })
|
|
919
932
|
if (request.headers.get('x-relay-token') !== env.RELAY_TOKEN) {
|
|
920
933
|
return new Response('unauthorized', { status: 401 })
|
|
921
934
|
}
|
|
922
935
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
936
|
+
try {
|
|
937
|
+
const payload = await request.json()
|
|
938
|
+
const result = await handleRelayPayload(env, payload)
|
|
939
|
+
if (result.error) return Response.json({ error: result.error }, { status: result.status })
|
|
926
940
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
941
|
+
if (result.stateWrite) ctx.waitUntil(result.stateWrite)
|
|
942
|
+
|
|
943
|
+
const upstream = result.upstream
|
|
944
|
+
return new Response(upstream.body, {
|
|
945
|
+
status: upstream.status,
|
|
946
|
+
statusText: upstream.statusText,
|
|
947
|
+
headers: upstream.headers,
|
|
948
|
+
})
|
|
949
|
+
} catch (error) {
|
|
950
|
+
return Response.json(
|
|
951
|
+
{ error: error instanceof Error ? error.message : 'internal relay error' },
|
|
952
|
+
{ status: 502 },
|
|
953
|
+
)
|
|
954
|
+
}
|
|
933
955
|
},
|
|
934
956
|
}
|
|
935
957
|
`;
|