@cortexkit/opencode-openai-auth 0.7.2 → 0.8.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.
Files changed (114) hide show
  1. package/README.md +9 -5
  2. package/dist/{index-9jpc8yct.js → index-02h7g5f9.js} +1 -1
  3. package/dist/index-2xppkvkx.js +2 -0
  4. package/dist/index-3w4vmckx.js +2 -0
  5. package/dist/{index-zxqb5ydj.js → index-507jh8bv.js} +1 -1
  6. package/dist/{index-zg1x8sty.js → index-944gsj1s.js} +1 -1
  7. package/dist/index-nshbgkz1.js +198 -0
  8. package/dist/{index-87145jkw.js → index-xyg86jgt.js} +1 -1
  9. package/dist/index.d.ts +381 -35
  10. package/dist/index.js +66 -161
  11. package/dist/protocol-Cv9ZmU0p.d.ts +24 -0
  12. package/dist/quota-normalize-wap0rne8.js +1 -0
  13. package/dist/rpc/port-file.d.ts +11 -9
  14. package/dist/rpc/port-file.js +1 -1
  15. package/dist/rpc/protocol.d.ts +2 -21
  16. package/dist/rpc/rpc-client.d.ts +6 -4
  17. package/dist/rpc/rpc-client.js +1 -1
  18. package/dist/rpc/rpc-dir.d.ts +7 -5
  19. package/dist/rpc/rpc-dir.js +1 -1
  20. package/dist/sidebar-state.d.ts +84 -83
  21. package/dist/sidebar-state.js +1 -1
  22. package/dist/{token-bvp0y166.js → token-jdvpwk60.js} +1 -1
  23. package/dist/token-util-zzm7paz9.js +1 -0
  24. package/dist/tui-preferences.d.ts +27 -26
  25. package/dist/tui-preferences.js +1 -1
  26. package/dist/tui.d.ts +18 -17
  27. package/package.json +7 -14
  28. package/src/tui/entry.mjs +2 -2
  29. package/src/tui-compiled/{core/account-paths.ts → raw/core-account-paths.ts} +29 -18
  30. package/src/tui-compiled/raw/logger.ts +53 -0
  31. package/src/tui-compiled/{rpc/port-file.ts → raw/rpc-port-file.ts} +1 -1
  32. package/src/tui-compiled/raw/rpc-protocol.ts +12 -0
  33. package/src/{rpc/rpc-client.ts → tui-compiled/raw/rpc-rpc-client.ts} +2 -2
  34. package/src/{sidebar-state.ts → tui-compiled/raw/sidebar-state.ts} +2 -2
  35. package/src/{tui/command-dialogs.tsx → tui-compiled/raw/tui-command-dialogs.tsx} +5 -5
  36. package/src/{tui.tsx → tui-compiled/raw/tui.tsx} +11 -10
  37. package/src/{core/account-paths.ts → tui-compiled/runtime/core-account-paths.ts} +29 -18
  38. package/src/tui-compiled/runtime/logger.ts +53 -0
  39. package/src/{rpc/port-file.ts → tui-compiled/runtime/rpc-port-file.ts} +1 -1
  40. package/src/tui-compiled/runtime/rpc-protocol.ts +12 -0
  41. package/src/tui-compiled/{rpc/rpc-client.ts → runtime/rpc-rpc-client.ts} +2 -2
  42. package/src/tui-compiled/{sidebar-state.ts → runtime/sidebar-state.ts} +2 -2
  43. package/src/tui-compiled/{tui/command-dialogs.tsx → runtime/tui-command-dialogs.tsx} +3 -4
  44. package/src/tui-compiled/{tui.tsx → runtime/tui.tsx} +10 -10
  45. package/src/tui-compiled/shared/internal.ts +6 -0
  46. package/src/{logger.ts → tui-compiled/shared/logger.ts} +82 -21
  47. package/src/tui-compiled/shared/paths.ts +33 -0
  48. package/src/{core → tui-compiled/shared}/refresh-file-lock.ts +7 -4
  49. package/dist/cli.d.ts +0 -2
  50. package/dist/cli.js +0 -15
  51. package/dist/codex-http.d.ts +0 -3
  52. package/dist/commands.d.ts +0 -88
  53. package/dist/config.d.ts +0 -47
  54. package/dist/core/account-paths.d.ts +0 -25
  55. package/dist/core/accounts.d.ts +0 -330
  56. package/dist/core/atomic-write.d.ts +0 -1
  57. package/dist/core/background-quota-refresh.d.ts +0 -46
  58. package/dist/core/backoff.d.ts +0 -53
  59. package/dist/core/cachekeep.d.ts +0 -118
  60. package/dist/core/oauth.d.ts +0 -138
  61. package/dist/core/provider.d.ts +0 -70
  62. package/dist/core/quota-manager.d.ts +0 -192
  63. package/dist/core/refresh-all-quota.d.ts +0 -86
  64. package/dist/core/refresh-file-lock.d.ts +0 -12
  65. package/dist/core/reset-credits.d.ts +0 -116
  66. package/dist/core/sticky-routing.d.ts +0 -43
  67. package/dist/dump.d.ts +0 -20
  68. package/dist/hosted-web-search.d.ts +0 -5
  69. package/dist/index-8f1200qx.js +0 -6
  70. package/dist/index-cfjed1st.js +0 -89
  71. package/dist/index-nrvjr97q.js +0 -3
  72. package/dist/index-qaggaze1.js +0 -2
  73. package/dist/index-sdcpzbnk.js +0 -2
  74. package/dist/logger.d.ts +0 -22
  75. package/dist/model-costs.d.ts +0 -29
  76. package/dist/prompt-context.d.ts +0 -19
  77. package/dist/quota-normalize-dvwtmy7f.js +0 -1
  78. package/dist/quota-normalize.d.ts +0 -43
  79. package/dist/raw-ws-bun.d.ts +0 -40
  80. package/dist/raw-ws-node.d.ts +0 -37
  81. package/dist/raw-ws-upgrade.d.ts +0 -2
  82. package/dist/raw-ws.d.ts +0 -3
  83. package/dist/response-stream-error.d.ts +0 -12
  84. package/dist/rpc/notifications.d.ts +0 -5
  85. package/dist/rpc/rpc-server.d.ts +0 -17
  86. package/dist/token-util-jcj7k7pa.js +0 -1
  87. package/dist/tui/command-dialogs.d.ts +0 -21
  88. package/dist/util/error.d.ts +0 -1
  89. package/dist/util/open-url.d.ts +0 -6
  90. package/dist/util/proxy-env.d.ts +0 -4
  91. package/dist/util/record.d.ts +0 -1
  92. package/dist/util/stable-json.d.ts +0 -1
  93. package/dist/util/uuid-v7.d.ts +0 -1
  94. package/dist/version.d.ts +0 -1
  95. package/dist/ws-pool.d.ts +0 -57
  96. package/dist/ws.d.ts +0 -71
  97. package/src/rpc/notifications.ts +0 -54
  98. package/src/rpc/protocol.ts +0 -33
  99. package/src/rpc/rpc-server.ts +0 -172
  100. package/src/tui-compiled/core/refresh-file-lock.ts +0 -381
  101. package/src/tui-compiled/logger.ts +0 -196
  102. package/src/tui-compiled/rpc/protocol.ts +0 -33
  103. package/src/util/error.ts +0 -9
  104. package/src/util/open-url.ts +0 -30
  105. package/src/util/proxy-env.ts +0 -42
  106. package/src/util/record.ts +0 -3
  107. package/src/util/stable-json.ts +0 -15
  108. package/src/util/uuid-v7.ts +0 -18
  109. /package/src/{rpc/rpc-dir.ts → tui-compiled/raw/rpc-rpc-dir.ts} +0 -0
  110. /package/src/tui-compiled/{tui-preferences.ts → raw/tui-preferences.ts} +0 -0
  111. /package/src/tui-compiled/{rpc/rpc-dir.ts → runtime/rpc-rpc-dir.ts} +0 -0
  112. /package/src/{tui-preferences.ts → tui-compiled/runtime/tui-preferences.ts} +0 -0
  113. /package/src/tui-compiled/{util → shared/util}/error.ts +0 -0
  114. /package/src/tui-compiled/{util → shared/util}/open-url.ts +0 -0
@@ -1,37 +0,0 @@
1
- type Listener = (event: unknown) => void;
2
- export declare class RawWebSocket {
3
- url: string;
4
- readyState: number;
5
- private socket;
6
- private readonly sessionID;
7
- private listeners;
8
- private rxBuffer;
9
- private handshakeDone;
10
- private upgradeFailureEmitted;
11
- private expectedAccept;
12
- private fragOpcode;
13
- private fragChunks;
14
- constructor(url: string, headers: Record<string, string>, options?: {
15
- sessionID?: string;
16
- });
17
- addEventListener(type: string, fn: Listener, _opts?: {
18
- once?: boolean;
19
- }): void;
20
- removeEventListener(type: string, fn: Listener): void;
21
- /** Close code and reason from the peer's close frame, if one arrived. */
22
- private peerClose;
23
- private closeEmitted;
24
- /** Emit at most one close event, so the first reported code is the one kept. */
25
- private emitClose;
26
- private emit;
27
- private connect;
28
- private onData;
29
- private emitRejectedUpgrade;
30
- private drainFrames;
31
- private handleFrame;
32
- send(data: string): void;
33
- private writeFrame;
34
- close(): void;
35
- private log;
36
- }
37
- export {};
@@ -1,2 +0,0 @@
1
- export declare function rejectedUpgradeStatus(statusLine: string): number | undefined;
2
- export declare function rejectedUpgradeEvent(buffer: Uint8Array, headerEnd: number, finalizePartial?: boolean): Record<string, unknown> | undefined;
package/dist/raw-ws.d.ts DELETED
@@ -1,3 +0,0 @@
1
- import { RawWebSocket as BunRawWebSocket } from './raw-ws-bun';
2
- import { RawWebSocket as NodeRawWebSocket } from './raw-ws-node';
3
- export declare const RawWebSocket: typeof BunRawWebSocket | typeof NodeRawWebSocket;
@@ -1,12 +0,0 @@
1
- import { APICallError } from 'ai';
2
- export declare class ResponseStreamError extends APICallError {
3
- readonly name = "ProviderResponseStreamError";
4
- /**
5
- * `retryable` defaults to true because most stream failures are transient.
6
- * Pass false for a failure the same request will hit again — resending then
7
- * only repeats the cost and the wait.
8
- */
9
- constructor(message: string, options?: ErrorOptions & {
10
- retryable?: boolean;
11
- });
12
- }
@@ -1,5 +0,0 @@
1
- import type { OpenDialogPayload, RpcNotification } from './protocol';
2
- export declare function pushNotification(payload: OpenDialogPayload, sessionId?: string): void;
3
- export declare function drainNotifications(lastReceivedId?: number, sessionId?: string): RpcNotification[];
4
- export declare function isTuiConnected(sessionId?: string): boolean;
5
- export declare function resetNotificationsForTest(): void;
@@ -1,17 +0,0 @@
1
- import type { drainNotifications } from './notifications';
2
- import type { ApplyRequest, ApplyResult } from './protocol';
3
- export interface RpcServerHandle {
4
- port: number;
5
- token: string;
6
- stop: () => Promise<void>;
7
- }
8
- export interface RpcServerOptions {
9
- dir: string;
10
- secureDir?: boolean;
11
- sweepRoot?: string;
12
- drain: typeof drainNotifications;
13
- apply: (request: ApplyRequest) => Promise<ApplyResult>;
14
- timeoutMs?: number;
15
- receiptTimeoutMs?: number;
16
- }
17
- export declare function startRpcServer(options: RpcServerOptions): Promise<RpcServerHandle>;
@@ -1 +0,0 @@
1
- import{b as a}from"./index-9jpc8yct.js";import"./index-zxqb5ydj.js";export default a();
@@ -1,21 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
- import type { TuiPluginApi } from '@opencode-ai/plugin/tui';
3
- import type { OpenDialogPayload } from '../rpc/protocol.js';
4
- import { type AccountQuota, type SidebarState } from '../sidebar-state.js';
5
- type ApplyFn = (command: OpenDialogPayload['command'], args: string) => Promise<{
6
- text: string;
7
- knobs: Record<string, unknown>;
8
- }>;
9
- export declare function buildCachekeepDialogOptions(payload: OpenDialogPayload): {
10
- title: string;
11
- value: string;
12
- description: string;
13
- }[];
14
- export declare function openCommandDialog(api: TuiPluginApi, payload: OpenDialogPayload, apply: ApplyFn, sessionId?: string): void;
15
- export declare function formatQuotaWindows(quota: AccountQuota | null | undefined): string;
16
- export declare function buildAccountDialogRows(state: SidebarState, sessionId?: string): {
17
- title: string;
18
- value: string;
19
- description: string;
20
- }[];
21
- export {};
@@ -1 +0,0 @@
1
- export declare function errorMessage(error: unknown): string;
@@ -1,6 +0,0 @@
1
- type BrowserExec = (file: string, args: string[], options: {
2
- stdio: 'ignore';
3
- timeout: number;
4
- }) => unknown;
5
- export declare function openUrl(url: string, platform?: NodeJS.Platform, execFileSync?: BrowserExec): void;
6
- export {};
@@ -1,4 +0,0 @@
1
- export declare function getProxyForUrl(url: string): string | undefined;
2
- export declare const ProxyEnv: {
3
- getProxyForUrl: typeof getProxyForUrl;
4
- };
@@ -1 +0,0 @@
1
- export declare function isRecord(input: unknown): input is Record<string, unknown>;
@@ -1 +0,0 @@
1
- export declare function stableStringify(value: unknown): string;
@@ -1 +0,0 @@
1
- export declare function uuidV7(): string;
package/dist/version.d.ts DELETED
@@ -1 +0,0 @@
1
- export declare const PackageVersion = "0.7.2";
package/dist/ws-pool.d.ts DELETED
@@ -1,57 +0,0 @@
1
- export declare const TITLE_HEADER = "x-opencode-title";
2
- export declare const QUOTA_ACCOUNT_HEADER = "x-openai-auth-quota-account";
3
- export interface CreateWebSocketFetchOptions {
4
- httpFetch?: typeof globalThis.fetch;
5
- url?: string;
6
- connectTimeout?: number;
7
- idleTimeout?: number;
8
- maxConnectionAge?: number;
9
- streamRetries?: number;
10
- /** Milliseconds to wait for an immediate provider-side WS error before returning response headers. */
11
- firstEventGraceMs?: number;
12
- /** Use the hand-rolled raw TCP/TLS WebSocket client instead of native WebSocket. */
13
- rawWebSocket?: boolean;
14
- /**
15
- * Push per-turn quota from a codex.rate_limits in-band frame.
16
- *
17
- * Receives the snapshot plus the per-request account identity (access token,
18
- * internal quota account key, and served ChatGPT account id) captured at send
19
- * time so the frame is attributed to the connection's own account, not a
20
- * shared mutable global.
21
- */
22
- onQuota?: (s: Record<string, unknown>, accessToken: string, accountId: string | undefined, servedChatgptAccountId: string | undefined) => void;
23
- /** Receives quota exhaustion plus the account identity captured at send time. */
24
- onRateLimitReached?: (window: string, accountId: string | undefined, resetAt?: number) => void;
25
- }
26
- interface PoolEntry {
27
- socket?: WebSocket;
28
- connectedAt?: number;
29
- lastUsedAt: number;
30
- busy: boolean;
31
- fallback: boolean;
32
- streamFailures: number;
33
- continuation?: ContinuationState;
34
- turnID?: string;
35
- turnStartedAt?: number;
36
- turnInput?: unknown[];
37
- turnSignature?: string;
38
- }
39
- interface ContinuationState {
40
- responseID: string;
41
- input: unknown[];
42
- signature: string;
43
- finalizedCallIds: Set<string>;
44
- }
45
- export declare function createWebSocketFetch(options?: CreateWebSocketFetchOptions): ((input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) & {
46
- close: () => void;
47
- remove: (sessionID: string) => void;
48
- };
49
- export declare const CODEX_BODY_KEY_ORDER: string[];
50
- export declare function orderCodexBody(body: Record<string, unknown>): Record<string, unknown>;
51
- export declare function advanceTurn(entry: PoolEntry, body: Record<string, unknown>, fullBody?: Record<string, unknown>): void;
52
- export declare function applyTurnId(entry: PoolEntry, body: Record<string, unknown>, fullBody?: Record<string, unknown>): Record<string, unknown>;
53
- export { hasWebSocketResponsesLiteMetadata, sanitizeHttpFallbackBody, } from './codex-http';
54
- export declare function withoutInternalHeaders<T extends {
55
- headers?: HeadersInit;
56
- }>(init: T | undefined): T | undefined;
57
- export * as OpenAIWebSocketPool from './ws-pool';
package/dist/ws.d.ts DELETED
@@ -1,71 +0,0 @@
1
- import { ResponseStreamError } from './response-stream-error';
2
- export declare const PROTOCOL_HEADER = "responses_websockets=2026-02-06";
3
- export interface ConnectResponsesWebSocketOptions {
4
- url: string;
5
- headers: Record<string, string>;
6
- timeout?: number;
7
- /** Use the hand-rolled raw TCP/TLS WebSocket client instead of native WebSocket. */
8
- rawWebSocket?: boolean;
9
- signal?: AbortSignal;
10
- }
11
- export interface StreamResponsesWebSocketOptions {
12
- socket: WebSocket;
13
- body: Record<string, unknown>;
14
- sessionID?: string;
15
- idleTimeout?: number;
16
- signal?: AbortSignal;
17
- onFirstEvent?: (error?: WrappedError) => void;
18
- /**
19
- * Fires on response.completed/response.done. `finalizedFunctionCallIds` is the
20
- * set of function/custom tool call ids the response actually finalized (emitted
21
- * a response.output_item.done for). The pool uses it to decide which suffix
22
- * function_call items are safe to trim from a continuation: only those present
23
- * in the chained response may be dropped — an unfinalized call (e.g. an aborted
24
- * partial) must be kept inline or its function_call_output orphans → 400.
25
- */
26
- onComplete?: (event: Record<string, unknown>, finalizedFunctionCallIds: Set<string>) => void;
27
- onTerminal?: (event: Record<string, unknown>) => void;
28
- onRetryableTerminal?: (event: Record<string, unknown>) => Promise<WebSocket | undefined>;
29
- onConnectionInvalid?: (error: ResponseStreamError) => void;
30
- onAbort?: (error: Error) => void;
31
- /** Push per-turn quota from a codex.rate_limits in-band frame. */
32
- onQuota?: (s: Record<string, unknown>) => void;
33
- /** Called when the transport reports quota exhaustion for the current connection. */
34
- onRateLimitReached?: (window: string, resetAt?: number) => void;
35
- }
36
- export interface WrappedError {
37
- status: number;
38
- headers?: Record<string, string>;
39
- body: string;
40
- }
41
- export declare function toWebSocketUrl(url: string): string;
42
- export declare function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string>;
43
- export declare function isAbortError(error: unknown): error is DOMException;
44
- export declare function isUpgradeFailure(error: unknown): boolean;
45
- export declare function isOversizedFrame(error: unknown): boolean;
46
- export declare function markOversizedFrame<T extends object>(error: T): T;
47
- export declare function connectResponsesWebSocket(options: ConnectResponsesWebSocketOptions): Promise<WebSocket>;
48
- export declare function streamResponsesWebSocket(options: StreamResponsesWebSocketOptions): Response;
49
- export declare function parseRateLimitSignal(value: unknown): {
50
- window: string;
51
- resetAt?: number;
52
- } | undefined;
53
- /**
54
- * Surfaced when a stream dies after output has reached the reader.
55
- *
56
- * Must not contain any substring the host reads as retryable — no provider
57
- * wording, no response ids, no byte counts. `ws-pool.test.ts` pins it against
58
- * the host's pattern set.
59
- */
60
- export declare const TERMINAL_AFTER_OUTPUT_MESSAGE = "The response ended early after part of it had already been shown. It was not sent again, because repeating it would duplicate that output and re-run any tools it had started. The transport log records what ended it.";
61
- /**
62
- * Surfaced when the peer refuses the request frame as too large (close 1009).
63
- *
64
- * Fixed text for the same reason as {@link TERMINAL_AFTER_OUTPUT_MESSAGE}: the
65
- * host can override this error's non-retryable flag by matching the message,
66
- * and resending a frame the peer has already measured and refused is the one
67
- * outcome this path exists to prevent. The size and the peer's reason are
68
- * logged instead.
69
- */
70
- export declare const OVERSIZED_FRAME_MESSAGE = "The request was larger than the websocket would carry, so it was sent over HTTP instead. Resending it unchanged over the same socket cannot succeed. The transport log records the size.";
71
- export * as OpenAIWebSocket from './ws';
@@ -1,54 +0,0 @@
1
- import type { OpenDialogPayload, RpcNotification } from './protocol'
2
-
3
- const QUEUE_CAP = 100
4
- const TUI_CONNECTED_WINDOW_MS = 3_000
5
-
6
- let queue: RpcNotification[] = []
7
- let nextId = 1
8
- let lastDrainAtAny = 0
9
- const lastDrainAtBySession = new Map<string, number>()
10
-
11
- export function pushNotification(
12
- payload: OpenDialogPayload,
13
- sessionId?: string,
14
- ): void {
15
- queue.push({ id: nextId++, type: 'open-dialog', payload, sessionId })
16
- if (queue.length > QUEUE_CAP) queue = queue.slice(queue.length - QUEUE_CAP)
17
- }
18
-
19
- export function drainNotifications(
20
- lastReceivedId = 0,
21
- sessionId?: string,
22
- ): RpcNotification[] {
23
- const now = Date.now()
24
- lastDrainAtAny = now
25
- if (sessionId !== undefined) lastDrainAtBySession.set(sessionId, now)
26
- const matches = (n: RpcNotification) =>
27
- sessionId === undefined ||
28
- n.sessionId === undefined ||
29
- n.sessionId === sessionId
30
- if (lastReceivedId > 0) {
31
- queue = queue.filter((n) => {
32
- if (n.id > lastReceivedId) return true
33
- if (sessionId === undefined) return false
34
- return n.sessionId !== sessionId
35
- })
36
- }
37
- return queue.filter((n) => n.id > lastReceivedId && matches(n))
38
- }
39
-
40
- export function isTuiConnected(sessionId?: string): boolean {
41
- const now = Date.now()
42
- if (sessionId !== undefined) {
43
- const at = lastDrainAtBySession.get(sessionId) ?? 0
44
- return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS
45
- }
46
- return lastDrainAtAny > 0 && now - lastDrainAtAny < TUI_CONNECTED_WINDOW_MS
47
- }
48
-
49
- export function resetNotificationsForTest(): void {
50
- queue = []
51
- nextId = 1
52
- lastDrainAtAny = 0
53
- lastDrainAtBySession.clear()
54
- }
@@ -1,33 +0,0 @@
1
- export type CommandModalName =
2
- | 'openai-quota'
3
- | 'openai-account'
4
- | 'openai-routing'
5
- | 'openai-killswitch'
6
- | 'openai-dump'
7
- | 'openai-logging'
8
- | 'openai-cachekeep'
9
- | 'openai-reset'
10
-
11
- export interface OpenDialogPayload {
12
- command: CommandModalName
13
- text: string
14
- knobs: Record<string, unknown>
15
- }
16
-
17
- export interface RpcNotification {
18
- id: number
19
- type: 'open-dialog'
20
- payload: OpenDialogPayload
21
- sessionId?: string
22
- }
23
-
24
- export interface ApplyRequest {
25
- command: CommandModalName
26
- arguments: string
27
- sessionId?: string
28
- }
29
-
30
- export interface ApplyResult {
31
- text: string
32
- knobs: Record<string, unknown>
33
- }
@@ -1,172 +0,0 @@
1
- import { randomBytes, timingSafeEqual } from 'node:crypto'
2
- import { unlink } from 'node:fs/promises'
3
- import {
4
- createServer,
5
- type IncomingMessage,
6
- type ServerResponse,
7
- } from 'node:http'
8
- import { join } from 'node:path'
9
- import { createLogger } from '../logger'
10
- import type { drainNotifications } from './notifications'
11
- import { sweepRpcState, writePortFile } from './port-file'
12
- import type { ApplyRequest, ApplyResult } from './protocol'
13
-
14
- const log = createLogger('rpc')
15
-
16
- export interface RpcServerHandle {
17
- port: number
18
- token: string
19
- stop: () => Promise<void>
20
- }
21
-
22
- export interface RpcServerOptions {
23
- dir: string
24
- secureDir?: boolean
25
- sweepRoot?: string
26
- drain: typeof drainNotifications
27
- apply: (request: ApplyRequest) => Promise<ApplyResult>
28
- // Bounds handler execution via the socket inactivity timer.
29
- timeoutMs?: number
30
- // Bounds request delivery only (requestTimeout/headersTimeout).
31
- receiptTimeoutMs?: number
32
- }
33
-
34
- function readBody(req: IncomingMessage): Promise<string> {
35
- return new Promise((resolve, reject) => {
36
- const chunks: Buffer[] = []
37
- let size = 0
38
- req.on('data', (chunk: Buffer) => {
39
- size += chunk.length
40
- if (size > 1_000_000) {
41
- req.destroy()
42
- reject(new Error('body too large'))
43
- return
44
- }
45
- chunks.push(chunk)
46
- })
47
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
48
- req.on('error', reject)
49
- })
50
- }
51
-
52
- function tokenOk(header: string | undefined, token: string): boolean {
53
- if (!header?.startsWith('Bearer ')) return false
54
- const got = Buffer.from(header.slice(7))
55
- const want = Buffer.from(token)
56
- return got.length === want.length && timingSafeEqual(got, want)
57
- }
58
-
59
- export async function startRpcServer(
60
- options: RpcServerOptions,
61
- ): Promise<RpcServerHandle> {
62
- const token = randomBytes(32).toString('hex')
63
- // Two different bounds, deliberately not one value.
64
- //
65
- // requestTimeout/headersTimeout bound how long a client may take to DELIVER
66
- // a request; they do not bound handler execution. A receipt bound of 500ms
67
- // still returns 200 for a 3s handler. Over loopback with a 1 MiB body cap,
68
- // seconds is already generous.
69
- //
70
- // The inactivity timer is the one that can kill a working handler: it
71
- // destroys the socket out from under it. It must therefore outlast the
72
- // slowest legitimate apply — a reset redemption is bounded by a 60s consume
73
- // call — or the response is lost server-side no matter what deadline the
74
- // client passed. Fast commands respond in milliseconds regardless.
75
- //
76
- // Collapsing these back into a single value reintroduces one of two bugs:
77
- // a receipt bound that aborts a slow reset, or a socket timer that leaves
78
- // every endpoint holding a dead connection for 90s.
79
- const handlerTimeoutMs = options.timeoutMs ?? 90_000
80
- const receiptTimeoutMs = options.receiptTimeoutMs ?? 2_000
81
- const server = createServer((req, res) => {
82
- req.setTimeout(handlerTimeoutMs, () => {
83
- req.socket.destroy()
84
- })
85
- void dispatch(req, res)
86
- })
87
- server.requestTimeout = receiptTimeoutMs
88
- server.headersTimeout = receiptTimeoutMs
89
-
90
- async function dispatch(req: IncomingMessage, res: ServerResponse) {
91
- const json = (status: number, value: unknown) => {
92
- // Guard against writing to a socket that was destroyed (e.g. when
93
- // readBody rejected after req.destroy() on an oversized body).
94
- if (res.headersSent || res.writableEnded || res.destroyed) return
95
- res.writeHead(status, { 'content-type': 'application/json' })
96
- res.end(JSON.stringify(value))
97
- }
98
- try {
99
- const url = req.url ?? ''
100
- if (req.method === 'GET' && url === '/health')
101
- return json(200, { ok: true })
102
- if (req.method !== 'POST' || !url.startsWith('/rpc/'))
103
- return json(404, { error: 'not found' })
104
- if (!tokenOk(req.headers.authorization, token))
105
- return json(401, { error: 'unauthorized' })
106
- const method = url.slice('/rpc/'.length)
107
- const body = await readBody(req)
108
- const params = JSON.parse(body || '{}') as Record<string, unknown>
109
- if (method === 'pending-notifications') {
110
- const messages = options.drain(
111
- Number(params.lastReceivedId ?? 0),
112
- typeof params.sessionId === 'string' ? params.sessionId : undefined,
113
- )
114
- return json(200, { messages })
115
- }
116
- if (method === 'apply') {
117
- const result = await options.apply(params as unknown as ApplyRequest)
118
- return json(200, result)
119
- }
120
- return json(404, { error: 'unknown method' })
121
- } catch (error) {
122
- json(500, {
123
- error: error instanceof Error ? error.message : String(error),
124
- })
125
- }
126
- }
127
-
128
- const port = await new Promise<number>((resolve, reject) => {
129
- server.on('error', reject)
130
- server.listen(0, '127.0.0.1', () => {
131
- const addr = server.address()
132
- if (addr && typeof addr === 'object') resolve(addr.port)
133
- else reject(new Error('no port'))
134
- })
135
- })
136
- server.unref()
137
- if (options.sweepRoot) {
138
- try {
139
- await sweepRpcState(options.sweepRoot, options.dir)
140
- } catch (error) {
141
- log.warn('rpc state sweep failed', {
142
- pid: process.pid,
143
- error: error instanceof Error ? error.message : String(error),
144
- })
145
- }
146
- }
147
- try {
148
- await writePortFile(
149
- options.dir,
150
- { port, token, pid: process.pid },
151
- { secureDir: options.secureDir },
152
- )
153
- log.debug('rpc server pid', {
154
- pid: process.pid,
155
- rpcPort: port,
156
- })
157
- } catch (error) {
158
- await new Promise<void>((resolve) => server.close(() => resolve()))
159
- throw error
160
- }
161
-
162
- return {
163
- port,
164
- token,
165
- async stop() {
166
- await new Promise<void>((resolve) => server.close(() => resolve()))
167
- await unlink(join(options.dir, `port-${process.pid}.json`)).catch(
168
- () => {},
169
- )
170
- },
171
- }
172
- }