@cortexkit/anthropic-auth-core 1.8.0 → 1.9.1
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 +14 -0
- package/dist/accounts.js +10 -0
- package/dist/models.d.ts +4 -0
- package/dist/models.js +4 -0
- package/dist/relay.d.ts +1 -1
- package/dist/relay.js +82 -5
- package/package.json +1 -1
package/dist/accounts.d.ts
CHANGED
|
@@ -94,6 +94,14 @@ export type AccountStorage = {
|
|
|
94
94
|
claudeFast?: {
|
|
95
95
|
enabled?: boolean;
|
|
96
96
|
};
|
|
97
|
+
/**
|
|
98
|
+
* Zero out Anthropic OAuth model costs in the provider hook. Default: enabled
|
|
99
|
+
* (OAuth usage is quota-based, not per-token billed, so costs show as $0).
|
|
100
|
+
* Set `enabled: false` to opt out and display the provider's real model costs.
|
|
101
|
+
*/
|
|
102
|
+
costZeroing?: {
|
|
103
|
+
enabled?: boolean;
|
|
104
|
+
};
|
|
97
105
|
cacheKeep?: {
|
|
98
106
|
enabled?: boolean;
|
|
99
107
|
startHour?: number;
|
|
@@ -109,6 +117,12 @@ export type AccountStorage = {
|
|
|
109
117
|
killswitch?: KillswitchConfig;
|
|
110
118
|
accounts: FallbackAccount[];
|
|
111
119
|
};
|
|
120
|
+
/**
|
|
121
|
+
* Whether Anthropic OAuth model costs should be zeroed in the provider hook.
|
|
122
|
+
* Defaults to enabled; only an explicit `costZeroing.enabled === false` opts out
|
|
123
|
+
* (to display the provider's real model costs).
|
|
124
|
+
*/
|
|
125
|
+
export declare function isCostZeroingEnabled(storage: Pick<AccountStorage, 'costZeroing'>): boolean;
|
|
112
126
|
export type AccountRuntimeEntry = Partial<Pick<OAuthAccount, 'access' | 'refresh' | 'expires' | 'lastUsed' | 'lastRefreshedAt' | 'lastRefreshError' | 'lastQuotaRefreshError' | 'quota'> & Pick<ApiKeyAccount, 'apiKey' | 'lastUsed'>>;
|
|
113
127
|
export type AccountRuntimeState = {
|
|
114
128
|
version: 1;
|
package/dist/accounts.js
CHANGED
|
@@ -30,6 +30,14 @@ export function isValidApiBaseURL(value) {
|
|
|
30
30
|
return false;
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Whether Anthropic OAuth model costs should be zeroed in the provider hook.
|
|
35
|
+
* Defaults to enabled; only an explicit `costZeroing.enabled === false` opts out
|
|
36
|
+
* (to display the provider's real model costs).
|
|
37
|
+
*/
|
|
38
|
+
export function isCostZeroingEnabled(storage) {
|
|
39
|
+
return storage.costZeroing?.enabled !== false;
|
|
40
|
+
}
|
|
33
41
|
const DEFAULT_FALLBACK_ON = [401, 403, 429];
|
|
34
42
|
const MIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240;
|
|
35
43
|
const DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_REFRESH_BEFORE_EXPIRY_MINUTES;
|
|
@@ -176,6 +184,7 @@ function normalizeStorage(value) {
|
|
|
176
184
|
claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined,
|
|
177
185
|
dump: isRecord(value.dump) ? value.dump : undefined,
|
|
178
186
|
claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
|
|
187
|
+
costZeroing: isRecord(value.costZeroing) ? value.costZeroing : undefined,
|
|
179
188
|
cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
|
|
180
189
|
relay: isRecord(value.relay) ? value.relay : undefined,
|
|
181
190
|
killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
|
|
@@ -308,6 +317,7 @@ function configFromStorage(storage) {
|
|
|
308
317
|
claudeCache: storage.claudeCache,
|
|
309
318
|
dump: storage.dump,
|
|
310
319
|
claudeFast: storage.claudeFast,
|
|
320
|
+
costZeroing: storage.costZeroing,
|
|
311
321
|
cacheKeep: storage.cacheKeep,
|
|
312
322
|
relay: storage.relay,
|
|
313
323
|
killswitch: storage.killswitch,
|
package/dist/models.d.ts
CHANGED
|
@@ -2,6 +2,10 @@ export declare const CLAUDE_FABLE_5_MODEL_ID = "claude-fable-5";
|
|
|
2
2
|
export declare const CLAUDE_MYTHOS_5_MODEL_ID = "claude-mythos-5";
|
|
3
3
|
export declare const CLAUDE_FABLE_MYTHOS_5_MODEL_IDS: readonly ["claude-fable-5", "claude-mythos-5"];
|
|
4
4
|
export type ClaudeFableMythos5ModelId = (typeof CLAUDE_FABLE_MYTHOS_5_MODEL_IDS)[number];
|
|
5
|
+
export declare const CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING: {
|
|
6
|
+
readonly type: "adaptive";
|
|
7
|
+
readonly display: "summarized";
|
|
8
|
+
};
|
|
5
9
|
export declare const CLAUDE_FABLE_MYTHOS_5_PRICING: {
|
|
6
10
|
readonly input: 10;
|
|
7
11
|
readonly output: 50;
|
package/dist/models.js
CHANGED
|
@@ -4,6 +4,10 @@ export const CLAUDE_FABLE_MYTHOS_5_MODEL_IDS = [
|
|
|
4
4
|
CLAUDE_FABLE_5_MODEL_ID,
|
|
5
5
|
CLAUDE_MYTHOS_5_MODEL_ID,
|
|
6
6
|
];
|
|
7
|
+
export const CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING = {
|
|
8
|
+
type: 'adaptive',
|
|
9
|
+
display: 'summarized',
|
|
10
|
+
};
|
|
7
11
|
export const CLAUDE_FABLE_MYTHOS_5_PRICING = {
|
|
8
12
|
input: 10,
|
|
9
13
|
output: 50,
|
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 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";
|
|
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\nconst WEBSOCKET_STREAM_FLUSH_BYTES = 1024\nconst WEBSOCKET_STREAM_FLUSH_MS = 20\n\nfunction createWebSocketStreamCoalescer(socket, options = {}) {\n const flushBytes = options.flushBytes || WEBSOCKET_STREAM_FLUSH_BYTES\n const flushMs = options.flushMs ?? WEBSOCKET_STREAM_FLUSH_MS\n let chunks = []\n let byteLength = 0\n let flushTimer = null\n const stats = {\n upstreamChunks: 0,\n upstreamBytes: 0,\n frames: 0,\n frameBytes: 0,\n }\n\n const clearFlushTimer = () => {\n if (flushTimer === null) return\n clearTimeout(flushTimer)\n flushTimer = null\n }\n\n const mergeChunks = () => {\n if (chunks.length === 1) return chunks[0]\n const frame = new Uint8Array(byteLength)\n let offset = 0\n for (const chunk of chunks) {\n frame.set(chunk, offset)\n offset += chunk.byteLength\n }\n return frame\n }\n\n const flush = () => {\n clearFlushTimer()\n if (byteLength === 0) return\n const frame = mergeChunks()\n chunks = []\n byteLength = 0\n socket.send(frame)\n stats.frames += 1\n stats.frameBytes += frame.byteLength\n }\n\n const scheduleFlush = () => {\n if (flushTimer !== null) return\n flushTimer = setTimeout(flush, flushMs)\n }\n\n return {\n push(value) {\n const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)\n if (chunk.byteLength === 0) return\n stats.upstreamChunks += 1\n stats.upstreamBytes += chunk.byteLength\n chunks.push(chunk)\n byteLength += chunk.byteLength\n if (byteLength >= flushBytes) flush()\n else scheduleFlush()\n },\n flush,\n stats() {\n return { ...stats, pendingBytes: byteLength }\n },\n }\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 let streamCoalescer = null\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 streamCoalescer = createWebSocketStreamCoalescer(socket)\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n streamCoalescer.push(value)\n }\n streamCoalescer.flush()\n }\n const stats = streamCoalescer?.stats?.() || {}\n socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id, ...stats }))\n } catch (error) {\n try {\n streamCoalescer?.flush?.()\n } catch {}\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
|
@@ -625,7 +625,7 @@ class PersistentRelaySession {
|
|
|
625
625
|
return;
|
|
626
626
|
}
|
|
627
627
|
if (message.type === 'done') {
|
|
628
|
-
this.finishPending();
|
|
628
|
+
this.finishPending(message);
|
|
629
629
|
return;
|
|
630
630
|
}
|
|
631
631
|
if (message.type === 'error') {
|
|
@@ -650,12 +650,15 @@ class PersistentRelaySession {
|
|
|
650
650
|
this.failPending(error);
|
|
651
651
|
}
|
|
652
652
|
}
|
|
653
|
-
finishPending() {
|
|
653
|
+
finishPending(done) {
|
|
654
654
|
const pending = this.pending;
|
|
655
655
|
if (!pending)
|
|
656
656
|
return;
|
|
657
657
|
const finishedAt = perfNowMs();
|
|
658
|
-
|
|
658
|
+
const workerStats = done && 'frames' in done
|
|
659
|
+
? ` upstreamChunks=${done.upstreamChunks ?? 'unknown'} upstreamBytes=${done.upstreamBytes ?? 'unknown'} frames=${done.frames} frameBytes=${done.frameBytes ?? 'unknown'}`
|
|
660
|
+
: '';
|
|
661
|
+
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}`);
|
|
659
662
|
clearTimeout(pending.timeout);
|
|
660
663
|
if (!pending.streamDone) {
|
|
661
664
|
pending.streamDone = true;
|
|
@@ -926,6 +929,73 @@ function deferWorkerTask(task) {
|
|
|
926
929
|
return new Promise((resolve) => setTimeout(resolve, 0)).then(task)
|
|
927
930
|
}
|
|
928
931
|
|
|
932
|
+
const WEBSOCKET_STREAM_FLUSH_BYTES = 1024
|
|
933
|
+
const WEBSOCKET_STREAM_FLUSH_MS = 20
|
|
934
|
+
|
|
935
|
+
function createWebSocketStreamCoalescer(socket, options = {}) {
|
|
936
|
+
const flushBytes = options.flushBytes || WEBSOCKET_STREAM_FLUSH_BYTES
|
|
937
|
+
const flushMs = options.flushMs ?? WEBSOCKET_STREAM_FLUSH_MS
|
|
938
|
+
let chunks = []
|
|
939
|
+
let byteLength = 0
|
|
940
|
+
let flushTimer = null
|
|
941
|
+
const stats = {
|
|
942
|
+
upstreamChunks: 0,
|
|
943
|
+
upstreamBytes: 0,
|
|
944
|
+
frames: 0,
|
|
945
|
+
frameBytes: 0,
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const clearFlushTimer = () => {
|
|
949
|
+
if (flushTimer === null) return
|
|
950
|
+
clearTimeout(flushTimer)
|
|
951
|
+
flushTimer = null
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const mergeChunks = () => {
|
|
955
|
+
if (chunks.length === 1) return chunks[0]
|
|
956
|
+
const frame = new Uint8Array(byteLength)
|
|
957
|
+
let offset = 0
|
|
958
|
+
for (const chunk of chunks) {
|
|
959
|
+
frame.set(chunk, offset)
|
|
960
|
+
offset += chunk.byteLength
|
|
961
|
+
}
|
|
962
|
+
return frame
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const flush = () => {
|
|
966
|
+
clearFlushTimer()
|
|
967
|
+
if (byteLength === 0) return
|
|
968
|
+
const frame = mergeChunks()
|
|
969
|
+
chunks = []
|
|
970
|
+
byteLength = 0
|
|
971
|
+
socket.send(frame)
|
|
972
|
+
stats.frames += 1
|
|
973
|
+
stats.frameBytes += frame.byteLength
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const scheduleFlush = () => {
|
|
977
|
+
if (flushTimer !== null) return
|
|
978
|
+
flushTimer = setTimeout(flush, flushMs)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
return {
|
|
982
|
+
push(value) {
|
|
983
|
+
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)
|
|
984
|
+
if (chunk.byteLength === 0) return
|
|
985
|
+
stats.upstreamChunks += 1
|
|
986
|
+
stats.upstreamBytes += chunk.byteLength
|
|
987
|
+
chunks.push(chunk)
|
|
988
|
+
byteLength += chunk.byteLength
|
|
989
|
+
if (byteLength >= flushBytes) flush()
|
|
990
|
+
else scheduleFlush()
|
|
991
|
+
},
|
|
992
|
+
flush,
|
|
993
|
+
stats() {
|
|
994
|
+
return { ...stats, pendingBytes: byteLength }
|
|
995
|
+
},
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
929
999
|
function checkpointWebSocketState(env, payload, body, nextState) {
|
|
930
1000
|
return deferWorkerTask(async () => {
|
|
931
1001
|
try {
|
|
@@ -996,6 +1066,7 @@ async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
|
|
|
996
1066
|
clearInterval(heartbeat)
|
|
997
1067
|
}
|
|
998
1068
|
}, 15000)
|
|
1069
|
+
let streamCoalescer = null
|
|
999
1070
|
try {
|
|
1000
1071
|
const result = await prepareWebSocketUpstream(env, getState(), payload)
|
|
1001
1072
|
if (result.error) {
|
|
@@ -1025,14 +1096,20 @@ async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
|
|
|
1025
1096
|
|
|
1026
1097
|
const reader = upstream.body?.getReader()
|
|
1027
1098
|
if (reader) {
|
|
1099
|
+
streamCoalescer = createWebSocketStreamCoalescer(socket)
|
|
1028
1100
|
while (true) {
|
|
1029
1101
|
const { done, value } = await reader.read()
|
|
1030
1102
|
if (done) break
|
|
1031
|
-
|
|
1103
|
+
streamCoalescer.push(value)
|
|
1032
1104
|
}
|
|
1105
|
+
streamCoalescer.flush()
|
|
1033
1106
|
}
|
|
1034
|
-
|
|
1107
|
+
const stats = streamCoalescer?.stats?.() || {}
|
|
1108
|
+
socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id, ...stats }))
|
|
1035
1109
|
} catch (error) {
|
|
1110
|
+
try {
|
|
1111
|
+
streamCoalescer?.flush?.()
|
|
1112
|
+
} catch {}
|
|
1036
1113
|
socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: 500, message: error instanceof Error ? error.message : String(error) }))
|
|
1037
1114
|
} finally {
|
|
1038
1115
|
clearInterval(heartbeat)
|