@cortexkit/anthropic-auth-core 1.1.2 → 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 +9 -1
- package/dist/accounts.js +51 -5
- 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
|
@@ -89,6 +89,14 @@ export type AccountRefreshError = {
|
|
|
89
89
|
export declare function getAccountStoragePath(): string;
|
|
90
90
|
export declare function loadAccounts(path?: string): Promise<AccountStorage | null>;
|
|
91
91
|
export declare function saveAccounts(storage: AccountStorage, path?: string): Promise<void>;
|
|
92
|
+
export declare function acquireRefreshFileLock(options: {
|
|
93
|
+
name: string;
|
|
94
|
+
ttlMs: number;
|
|
95
|
+
path?: string;
|
|
96
|
+
now?: () => number;
|
|
97
|
+
}): Promise<{
|
|
98
|
+
release: () => Promise<void>;
|
|
99
|
+
} | null>;
|
|
92
100
|
export declare function isCache1hPersistentlyEnabled(storage: AccountStorage | null): boolean;
|
|
93
101
|
export declare function getCache1hPersistentMode(storage: AccountStorage | null): Cache1hMode;
|
|
94
102
|
export declare function setCache1hPersistentEnabled(enabled: boolean, mode?: Cache1hMode, path?: string): Promise<AccountStorage>;
|
|
@@ -133,7 +141,7 @@ export declare class FallbackAccountManager {
|
|
|
133
141
|
save(storage: AccountStorage): Promise<void>;
|
|
134
142
|
startBackgroundRefresh(): void;
|
|
135
143
|
stopBackgroundRefresh(): void;
|
|
136
|
-
getUsableFallbackAccounts(): Promise<OAuthAccount[]>;
|
|
144
|
+
getUsableFallbackAccounts(existingStorage?: AccountStorage | null): Promise<OAuthAccount[]>;
|
|
137
145
|
markUsed(account: OAuthAccount): Promise<void>;
|
|
138
146
|
accountPassesQuotaPolicy(account: OAuthAccount, storage: AccountStorage | null): boolean;
|
|
139
147
|
refreshDueAccounts(): Promise<void>;
|
package/dist/accounts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { ClaudeOAuthRefreshError, refreshClaudeOAuthToken } from "./auth.js";
|
|
@@ -8,7 +8,8 @@ import { log } from "./logger.js";
|
|
|
8
8
|
export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
|
|
9
9
|
export const QUOTA_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
10
10
|
const DEFAULT_FALLBACK_ON = [401, 403, 429];
|
|
11
|
-
const
|
|
11
|
+
const MIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240;
|
|
12
|
+
const DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_REFRESH_BEFORE_EXPIRY_MINUTES;
|
|
12
13
|
const DEFAULT_REFRESH_INTERVAL_MINUTES = 10;
|
|
13
14
|
const MIN_REFRESH_RETRY_DELAY_MS = 5 * 60_000;
|
|
14
15
|
const MAX_REFRESH_RETRY_DELAY_MS = 60 * 60_000;
|
|
@@ -159,6 +160,51 @@ export async function saveAccounts(storage, path = getAccountStoragePath()) {
|
|
|
159
160
|
});
|
|
160
161
|
await rename(tempPath, path);
|
|
161
162
|
}
|
|
163
|
+
export async function acquireRefreshFileLock(options) {
|
|
164
|
+
const accountPath = options.path ?? getAccountStoragePath();
|
|
165
|
+
const lockDir = `${accountPath}.${options.name}.lock`;
|
|
166
|
+
const ownerPath = join(lockDir, 'owner.json');
|
|
167
|
+
const ownerId = randomUUID();
|
|
168
|
+
const now = options.now ?? Date.now;
|
|
169
|
+
async function tryAcquire() {
|
|
170
|
+
try {
|
|
171
|
+
await mkdir(lockDir, { mode: 0o700 });
|
|
172
|
+
await writeFile(ownerPath, `${JSON.stringify({ ownerId, expiresAt: now() + options.ttlMs })}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (error.code !== 'EEXIST')
|
|
177
|
+
throw error;
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (!(await tryAcquire())) {
|
|
182
|
+
try {
|
|
183
|
+
const owner = JSON.parse(await readFile(ownerPath, 'utf8'));
|
|
184
|
+
if (Number(owner?.expiresAt) > now())
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Broken owner metadata is treated as stale.
|
|
189
|
+
}
|
|
190
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => { });
|
|
191
|
+
if (!(await tryAcquire()))
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
release: async () => {
|
|
196
|
+
try {
|
|
197
|
+
const owner = JSON.parse(await readFile(ownerPath, 'utf8'));
|
|
198
|
+
if (owner?.ownerId !== ownerId)
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
await rm(lockDir, { recursive: true, force: true }).catch(() => { });
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
162
208
|
export function isCache1hPersistentlyEnabled(storage) {
|
|
163
209
|
return storage?.claudeCache?.enabled === true;
|
|
164
210
|
}
|
|
@@ -284,7 +330,7 @@ function refreshEnabled(storage) {
|
|
|
284
330
|
function refreshBeforeExpiryMs(storage) {
|
|
285
331
|
const minutes = storage?.refresh?.refreshBeforeExpiryMinutes ??
|
|
286
332
|
DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES;
|
|
287
|
-
return Math.max(
|
|
333
|
+
return Math.max(MIN_REFRESH_BEFORE_EXPIRY_MINUTES, minutes) * 60_000;
|
|
288
334
|
}
|
|
289
335
|
export function getRefreshIntervalMs(storage) {
|
|
290
336
|
const minutes = storage?.refresh?.intervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
|
|
@@ -498,8 +544,8 @@ export class FallbackAccountManager {
|
|
|
498
544
|
this.refreshTimer = null;
|
|
499
545
|
this.quotaTimer = null;
|
|
500
546
|
}
|
|
501
|
-
async getUsableFallbackAccounts() {
|
|
502
|
-
const storage = await this.load();
|
|
547
|
+
async getUsableFallbackAccounts(existingStorage) {
|
|
548
|
+
const storage = existingStorage !== undefined ? existingStorage : await this.load();
|
|
503
549
|
if (!storage)
|
|
504
550
|
return [];
|
|
505
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
|
`;
|