@vacbo/opencode-anthropic-fix 0.0.44 → 0.1.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.
Files changed (61) hide show
  1. package/README.md +19 -0
  2. package/dist/bun-proxy.mjs +282 -55
  3. package/dist/opencode-anthropic-auth-cli.mjs +194 -55
  4. package/dist/opencode-anthropic-auth-plugin.js +1816 -594
  5. package/package.json +1 -1
  6. package/src/__tests__/billing-edge-cases.test.ts +84 -0
  7. package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
  8. package/src/__tests__/debug-gating.test.ts +76 -0
  9. package/src/__tests__/decomposition-smoke.test.ts +92 -0
  10. package/src/__tests__/fingerprint-regression.test.ts +1 -1
  11. package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
  12. package/src/__tests__/helpers/conversation-history.ts +376 -0
  13. package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
  14. package/src/__tests__/helpers/deferred.ts +122 -0
  15. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
  16. package/src/__tests__/helpers/in-memory-storage.ts +152 -0
  17. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
  18. package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
  19. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
  20. package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
  21. package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
  22. package/src/__tests__/helpers/sse.ts +288 -0
  23. package/src/__tests__/index.parallel.test.ts +711 -0
  24. package/src/__tests__/sanitization-regex.test.ts +65 -0
  25. package/src/__tests__/state-bounds.test.ts +110 -0
  26. package/src/account-identity.test.ts +213 -0
  27. package/src/account-identity.ts +108 -0
  28. package/src/accounts.dedup.test.ts +696 -0
  29. package/src/accounts.test.ts +2 -1
  30. package/src/accounts.ts +485 -191
  31. package/src/bun-fetch.test.ts +379 -0
  32. package/src/bun-fetch.ts +447 -174
  33. package/src/bun-proxy.ts +289 -57
  34. package/src/circuit-breaker.test.ts +274 -0
  35. package/src/circuit-breaker.ts +235 -0
  36. package/src/cli.test.ts +1 -0
  37. package/src/cli.ts +37 -18
  38. package/src/commands/router.ts +25 -5
  39. package/src/env.ts +1 -0
  40. package/src/headers/billing.ts +31 -13
  41. package/src/index.ts +224 -247
  42. package/src/oauth.ts +7 -1
  43. package/src/parent-pid-watcher.test.ts +219 -0
  44. package/src/parent-pid-watcher.ts +99 -0
  45. package/src/plugin-helpers.ts +112 -0
  46. package/src/refresh-helpers.ts +169 -0
  47. package/src/refresh-lock.test.ts +36 -9
  48. package/src/refresh-lock.ts +2 -2
  49. package/src/request/body.history.test.ts +398 -0
  50. package/src/request/body.ts +200 -13
  51. package/src/request/metadata.ts +6 -2
  52. package/src/response/index.ts +1 -1
  53. package/src/response/mcp.ts +60 -31
  54. package/src/response/streaming.test.ts +382 -0
  55. package/src/response/streaming.ts +403 -76
  56. package/src/storage.test.ts +127 -104
  57. package/src/storage.ts +152 -62
  58. package/src/system-prompt/builder.ts +33 -3
  59. package/src/system-prompt/sanitize.ts +12 -2
  60. package/src/token-refresh.test.ts +84 -1
  61. package/src/token-refresh.ts +14 -8
@@ -0,0 +1,166 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createInMemoryStorage, makeAccountsData, makeStoredAccount } from "./in-memory-storage.js";
3
+
4
+ describe("in-memory-storage smoke tests", () => {
5
+ it("createInMemoryStorage creates storage with initial data", () => {
6
+ const initial = makeAccountsData([{ refreshToken: "tok1" }]);
7
+ const storage = createInMemoryStorage(initial);
8
+
9
+ expect(storage.snapshot()).toEqual(initial);
10
+ });
11
+
12
+ it("createInMemoryStorage creates storage with null initial state", async () => {
13
+ const storage = createInMemoryStorage();
14
+
15
+ // loadAccountsMock should return null
16
+ const loaded = await storage.loadAccountsMock();
17
+ expect(loaded).toBeNull();
18
+
19
+ // snapshot should throw when null
20
+ expect(() => storage.snapshot()).toThrow("Storage snapshot is null");
21
+ });
22
+
23
+ it("setSnapshot updates both disk and memory state", async () => {
24
+ const storage = createInMemoryStorage();
25
+ const data = makeAccountsData([{ refreshToken: "tok1" }]);
26
+
27
+ storage.setSnapshot(data);
28
+
29
+ // Memory should match
30
+ expect(storage.snapshot()).toEqual(data);
31
+
32
+ // Disk should match (via loadAccountsMock)
33
+ const loaded = await storage.loadAccountsMock();
34
+ expect(loaded).toEqual(data);
35
+ });
36
+
37
+ it("snapshot returns deep copy - mutations don't affect storage", () => {
38
+ const initial = makeAccountsData([{ refreshToken: "tok1", enabled: true }]);
39
+ const storage = createInMemoryStorage(initial);
40
+
41
+ const snap = storage.snapshot();
42
+ snap.accounts[0].enabled = false;
43
+ snap.accounts[0].refreshToken = "mutated";
44
+
45
+ // Storage should be unchanged
46
+ const snap2 = storage.snapshot();
47
+ expect(snap2.accounts[0].enabled).toBe(true);
48
+ expect(snap2.accounts[0].refreshToken).toBe("tok1");
49
+ });
50
+
51
+ it("saveAccountsMock writes to disk state", async () => {
52
+ const storage = createInMemoryStorage();
53
+ const data = makeAccountsData([{ refreshToken: "tok1" }]);
54
+
55
+ // Save via mock
56
+ await storage.saveAccountsMock(data);
57
+
58
+ // Should be readable via loadAccountsMock
59
+ const loaded = await storage.loadAccountsMock();
60
+ expect(loaded).toEqual(data);
61
+ });
62
+
63
+ it("mutateDiskOnly changes disk without affecting caller's snapshot", () => {
64
+ const initial = makeAccountsData([{ refreshToken: "tok1", enabled: true }]);
65
+ const storage = createInMemoryStorage(initial);
66
+
67
+ // Get current snapshot (what test subject holds)
68
+ const beforeSnapshot = storage.snapshot();
69
+ expect(beforeSnapshot.accounts[0].enabled).toBe(true);
70
+
71
+ // Simulate another process writing to disk
72
+ storage.mutateDiskOnly((disk) => ({
73
+ ...disk,
74
+ accounts: disk.accounts.map((a) => ({ ...a, enabled: false })),
75
+ }));
76
+
77
+ // Caller's snapshot should be unchanged
78
+ const afterSnapshot = storage.snapshot();
79
+ expect(afterSnapshot.accounts[0].enabled).toBe(true);
80
+
81
+ // But disk state should be changed
82
+ expect(afterSnapshot.accounts[0].enabled).toBe(true); // Still true in memory
83
+ });
84
+
85
+ it("mutateDiskOnly affects subsequent loadAccountsMock calls", async () => {
86
+ const initial = makeAccountsData([{ refreshToken: "tok1", enabled: true }]);
87
+ const storage = createInMemoryStorage(initial);
88
+
89
+ // Mutate disk
90
+ storage.mutateDiskOnly((disk) => ({
91
+ ...disk,
92
+ accounts: disk.accounts.map((a) => ({ ...a, enabled: false })),
93
+ }));
94
+
95
+ // Load should see the mutated state
96
+ const loaded = await storage.loadAccountsMock();
97
+ expect(loaded?.accounts[0].enabled).toBe(false);
98
+ });
99
+
100
+ it("mutateDiskOnly throws when disk state is null", () => {
101
+ const storage = createInMemoryStorage();
102
+
103
+ expect(() =>
104
+ storage.mutateDiskOnly((disk) => ({
105
+ ...disk,
106
+ accounts: [],
107
+ })),
108
+ ).toThrow("Cannot mutate disk - disk state is null");
109
+ });
110
+
111
+ it("makeStoredAccount creates valid account with defaults", () => {
112
+ const account = makeStoredAccount({ refreshToken: "my-token" });
113
+
114
+ expect(account.refreshToken).toBe("my-token");
115
+ expect(account.id).toMatch(/^acct-/);
116
+ expect(account.enabled).toBe(true);
117
+ expect(account.consecutiveFailures).toBe(0);
118
+ expect(account.lastFailureTime).toBeNull();
119
+ expect(account.stats.requests).toBe(0);
120
+ expect(account.addedAt).toBeGreaterThan(0);
121
+ });
122
+
123
+ it("makeStoredAccount applies overrides", () => {
124
+ const account = makeStoredAccount({
125
+ refreshToken: "tok1",
126
+ enabled: false,
127
+ consecutiveFailures: 5,
128
+ email: "test@example.com",
129
+ });
130
+
131
+ expect(account.refreshToken).toBe("tok1");
132
+ expect(account.enabled).toBe(false);
133
+ expect(account.consecutiveFailures).toBe(5);
134
+ expect(account.email).toBe("test@example.com");
135
+ });
136
+
137
+ it("makeAccountsData creates storage with multiple accounts", () => {
138
+ const data = makeAccountsData([
139
+ { refreshToken: "tok1", email: "a@test.com" },
140
+ { refreshToken: "tok2", email: "b@test.com" },
141
+ ]);
142
+
143
+ expect(data.version).toBe(1);
144
+ expect(data.activeIndex).toBe(0);
145
+ expect(data.accounts).toHaveLength(2);
146
+ expect(data.accounts[0].refreshToken).toBe("tok1");
147
+ expect(data.accounts[1].refreshToken).toBe("tok2");
148
+ expect(data.accounts[0].addedAt).toBe(1000);
149
+ expect(data.accounts[1].addedAt).toBe(2000);
150
+ });
151
+
152
+ it("makeAccountsData accepts extra storage fields", () => {
153
+ const data = makeAccountsData([{ refreshToken: "tok1" }], {
154
+ activeIndex: 2,
155
+ });
156
+
157
+ expect(data.activeIndex).toBe(2);
158
+ });
159
+
160
+ it("storage mocks are vi.fn() instances", () => {
161
+ const storage = createInMemoryStorage();
162
+
163
+ expect(storage.loadAccountsMock).toBeDefined();
164
+ expect(storage.saveAccountsMock).toBeDefined();
165
+ });
166
+ });
@@ -0,0 +1,152 @@
1
+ import { vi } from "vitest";
2
+ import type { AccountMetadata, AccountStorage } from "../../storage.js";
3
+
4
+ /**
5
+ * In-memory storage harness for testing account deduplication and
6
+ * concurrent access scenarios without touching the real filesystem.
7
+ */
8
+ export interface InMemoryStorage {
9
+ /** Get the current in-memory snapshot (what the test subject sees) */
10
+ snapshot(): AccountStorage;
11
+
12
+ /** Replace the in-memory snapshot (simulates loading from disk) */
13
+ setSnapshot(data: AccountStorage): void;
14
+
15
+ /**
16
+ * Mutate the "disk" state without affecting the caller's snapshot.
17
+ * This simulates another process writing to the storage file while
18
+ * the test subject holds an in-memory copy.
19
+ */
20
+ mutateDiskOnly(mutator: (disk: AccountStorage) => AccountStorage): void;
21
+
22
+ /** Mock implementation of loadAccounts - returns disk state */
23
+ loadAccountsMock: () => Promise<AccountStorage | null>;
24
+
25
+ /** Mock implementation of saveAccounts - writes to disk */
26
+ saveAccountsMock: (data: AccountStorage) => Promise<void>;
27
+ }
28
+
29
+ /**
30
+ * Create an in-memory storage harness for testing.
31
+ *
32
+ * @param initial - Optional initial storage state. If not provided, returns null on load.
33
+ * @returns Storage harness with snapshot management and mock functions
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * const storage = createInMemoryStorage({
38
+ * version: 1,
39
+ * accounts: [{ refreshToken: "tok1", addedAt: 1000, lastUsed: 0, enabled: true }],
40
+ * activeIndex: 0,
41
+ * });
42
+ *
43
+ * // Wire up mocks
44
+ * vi.mock("../../storage.js", () => ({
45
+ * loadAccounts: storage.loadAccountsMock,
46
+ * saveAccounts: storage.saveAccountsMock,
47
+ * createDefaultStats: vi.fn((now?: number) => ({
48
+ * requests: 0,
49
+ * inputTokens: 0,
50
+ * outputTokens: 0,
51
+ * cacheReadTokens: 0,
52
+ * cacheWriteTokens: 0,
53
+ * lastReset: now ?? Date.now(),
54
+ * })),
55
+ * }));
56
+ * ```
57
+ */
58
+ export function createInMemoryStorage(initial?: AccountStorage): InMemoryStorage {
59
+ // Internal "disk" state - what loadAccounts would read from filesystem
60
+ let diskState: AccountStorage | null = initial ?? null;
61
+
62
+ // Internal "memory" state - what the test subject holds after loading
63
+ let memoryState: AccountStorage | null = diskState;
64
+
65
+ return {
66
+ snapshot(): AccountStorage {
67
+ if (memoryState === null) {
68
+ throw new Error("Storage snapshot is null - did you forget to set initial data or call setSnapshot()?");
69
+ }
70
+ // Return deep copy to prevent accidental mutations via references
71
+ return structuredClone(memoryState);
72
+ },
73
+
74
+ setSnapshot(data: AccountStorage): void {
75
+ // Update both disk and memory to match
76
+ diskState = structuredClone(data);
77
+ memoryState = structuredClone(data);
78
+ },
79
+
80
+ mutateDiskOnly(mutator: (disk: AccountStorage) => AccountStorage): void {
81
+ if (diskState === null) {
82
+ throw new Error("Cannot mutate disk - disk state is null. Set initial data first.");
83
+ }
84
+ // Only mutate disk state, leaving memoryState unchanged
85
+ // This simulates another process writing to the file
86
+ diskState = mutator(structuredClone(diskState));
87
+ },
88
+
89
+ loadAccountsMock: vi.fn(async (): Promise<AccountStorage | null> => {
90
+ // Simulate reading from disk - returns current disk state
91
+ return diskState === null ? null : structuredClone(diskState);
92
+ }),
93
+
94
+ saveAccountsMock: vi.fn(async (data: AccountStorage): Promise<void> => {
95
+ // Simulate writing to disk - updates disk state
96
+ diskState = structuredClone(data);
97
+ }),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Helper to create a minimal valid stored account for testing.
103
+ */
104
+ export function makeStoredAccount(overrides: Partial<AccountMetadata> & { refreshToken: string }): AccountMetadata {
105
+ const now = Date.now();
106
+ return {
107
+ id: overrides.id ?? `acct-${Math.random().toString(36).slice(2, 8)}`,
108
+ email: overrides.email,
109
+ identity: overrides.identity,
110
+ label: overrides.label,
111
+ refreshToken: overrides.refreshToken,
112
+ access: overrides.access ?? "access-token",
113
+ expires: overrides.expires ?? now + 3600_000,
114
+ addedAt: overrides.addedAt ?? now,
115
+ lastUsed: overrides.lastUsed ?? 0,
116
+ enabled: overrides.enabled ?? true,
117
+ rateLimitResetTimes: overrides.rateLimitResetTimes ?? {},
118
+ consecutiveFailures: overrides.consecutiveFailures ?? 0,
119
+ lastFailureTime: overrides.lastFailureTime ?? null,
120
+ lastSwitchReason: overrides.lastSwitchReason,
121
+ token_updated_at: overrides.token_updated_at ?? 0,
122
+ stats: overrides.stats ?? {
123
+ requests: 0,
124
+ inputTokens: 0,
125
+ outputTokens: 0,
126
+ cacheReadTokens: 0,
127
+ cacheWriteTokens: 0,
128
+ lastReset: now,
129
+ },
130
+ source: overrides.source,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Helper to create a valid storage payload from account overrides.
136
+ */
137
+ export function makeAccountsData(
138
+ accountOverrides: Array<Partial<AccountMetadata> & { refreshToken: string }>,
139
+ extra: Partial<AccountStorage> = {},
140
+ ): AccountStorage {
141
+ return {
142
+ version: 1,
143
+ accounts: accountOverrides.map((o, i) =>
144
+ makeStoredAccount({
145
+ addedAt: (i + 1) * 1000,
146
+ ...o,
147
+ }),
148
+ ),
149
+ activeIndex: 0,
150
+ ...extra,
151
+ };
152
+ }
@@ -0,0 +1,92 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { createDeferred } from "./deferred";
4
+ import { createMockBunProxy } from "./mock-bun-proxy";
5
+
6
+ describe("mock bun proxy helper", () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ it("mocks spawn and emits the startup banner", async () => {
17
+ const proxy = createMockBunProxy({ bannerDelay: 25 });
18
+ const child = proxy.mockSpawn("bun", ["run", "./bun-proxy.ts", "48372"], {
19
+ stdio: ["ignore", "pipe", "pipe"],
20
+ });
21
+ const onData = vi.fn();
22
+
23
+ child.stdout?.on("data", onData);
24
+
25
+ proxy.simulateStdoutBanner();
26
+
27
+ expect(onData).not.toHaveBeenCalled();
28
+
29
+ await vi.advanceTimersByTimeAsync(25);
30
+
31
+ expect(proxy.child.pid).toBe(child.pid);
32
+ expect(onData).toHaveBeenCalledOnce();
33
+ expect(onData.mock.calls[0][0].toString()).toContain("BUN_PROXY_PORT=48372");
34
+ });
35
+
36
+ it("fires exit handlers and records kill signals", () => {
37
+ const proxy = createMockBunProxy();
38
+ const child = proxy.mockSpawn("bun", ["run", "./bun-proxy.ts", "48372"], {
39
+ stdio: ["ignore", "pipe", "pipe"],
40
+ });
41
+ const onExit = vi.fn();
42
+
43
+ child.on("exit", onExit);
44
+
45
+ proxy.simulateExit(0);
46
+ child.kill("SIGKILL");
47
+
48
+ expect(onExit).toHaveBeenCalledWith(0, null);
49
+ expect(proxy.child.killSignals).toEqual(["SIGKILL"]);
50
+ });
51
+
52
+ it("rejects async spawn callers when configured to throw", async () => {
53
+ const proxy = createMockBunProxy({ spawnError: new Error("spawn failed") });
54
+
55
+ await expect((async () => proxy.mockSpawn("bun", ["run", "./bun-proxy.ts", "48372"], {}))()).rejects.toThrow(
56
+ "spawn failed",
57
+ );
58
+ });
59
+
60
+ it("tracks in-flight forwarded fetch requests without touching the network", async () => {
61
+ const response = createDeferred<Response>();
62
+ const forwardToMockFetch = vi.fn(() => response.promise);
63
+ const proxy = createMockBunProxy({ forwardToMockFetch });
64
+ const child = proxy.mockSpawn("bun", ["run", "./bun-proxy.ts", "48372"], {
65
+ stdio: ["ignore", "pipe", "pipe"],
66
+ });
67
+
68
+ const fetchPromise = child.forwardFetch("http://127.0.0.1:48372/", {
69
+ method: "POST",
70
+ headers: {
71
+ "x-proxy-url": "https://api.anthropic.com/v1/messages",
72
+ authorization: "Bearer test-token",
73
+ connection: "keep-alive",
74
+ },
75
+ body: JSON.stringify({ hello: "world" }),
76
+ });
77
+
78
+ expect(proxy.getInFlightCount()).toBe(1);
79
+
80
+ response.resolve(new Response("ok", { status: 200 }));
81
+
82
+ await expect(fetchPromise).resolves.toBeInstanceOf(Response);
83
+ expect(proxy.getInFlightCount()).toBe(0);
84
+ expect(forwardToMockFetch).toHaveBeenCalledWith(
85
+ "https://api.anthropic.com/v1/messages",
86
+ expect.objectContaining({
87
+ method: "POST",
88
+ body: JSON.stringify({ hello: "world" }),
89
+ }),
90
+ );
91
+ });
92
+ });
@@ -0,0 +1,229 @@
1
+ import type { ChildProcess, SpawnOptions } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { PassThrough } from "node:stream";
4
+
5
+ import { vi, type Mock } from "vitest";
6
+
7
+ type ForwardFetchInput = string | URL | Request;
8
+ type ForwardFetch = (input: ForwardFetchInput, init?: RequestInit) => Promise<Response>;
9
+
10
+ type MockChildProcess = EventEmitter &
11
+ Omit<
12
+ ChildProcess,
13
+ "pid" | "killed" | "exitCode" | "signalCode" | "stdout" | "stderr" | "spawnfile" | "spawnargs" | "kill"
14
+ > & {
15
+ killSignals: NodeJS.Signals[];
16
+ pid: number;
17
+ killed: boolean;
18
+ exitCode: number | null;
19
+ signalCode: NodeJS.Signals | null;
20
+ stdout: PassThrough;
21
+ stderr: PassThrough;
22
+ spawnfile: string;
23
+ spawnargs: string[];
24
+ kill: (signal?: number | NodeJS.Signals | null) => boolean;
25
+ forwardFetch: ForwardFetch;
26
+ };
27
+
28
+ export interface MockProxyOptions {
29
+ bannerDelay?: number;
30
+ spawnError?: Error;
31
+ forwardToMockFetch?: ForwardFetch;
32
+ parentDeathSimulation?: boolean;
33
+ }
34
+
35
+ export interface MockBunProxy {
36
+ mockSpawn: Mock;
37
+ child: MockChildProcess;
38
+ simulateExit(code?: number | null, signal?: NodeJS.Signals | null): void;
39
+ simulateStdoutBanner(port?: number): void;
40
+ simulateCrash(message?: string): void;
41
+ getInFlightCount(): number;
42
+ }
43
+
44
+ let nextPid = 48_372;
45
+
46
+ function normalizeSignal(signal?: number | NodeJS.Signals | null): NodeJS.Signals {
47
+ return typeof signal === "string" ? signal : "SIGTERM";
48
+ }
49
+
50
+ function inferSpawnPort(args: string[]): number {
51
+ const numericArg = [...args].reverse().find((arg) => /^\d+$/.test(arg));
52
+ return numericArg ? Number.parseInt(numericArg, 10) : 48_372;
53
+ }
54
+
55
+ async function normalizeForwardedRequest(
56
+ input: ForwardFetchInput,
57
+ init?: RequestInit,
58
+ ): Promise<{
59
+ targetUrl: string;
60
+ forwardedInit: RequestInit;
61
+ }> {
62
+ const request =
63
+ input instanceof Request
64
+ ? new Request(input, init)
65
+ : new Request(input instanceof URL ? input.toString() : input, init);
66
+ const headers = new Headers(request.headers);
67
+ const targetUrl = headers.get("x-proxy-url") ?? request.url;
68
+
69
+ headers.delete("x-proxy-url");
70
+ headers.delete("host");
71
+ headers.delete("connection");
72
+
73
+ const method = request.method || "GET";
74
+ let body: RequestInit["body"] | undefined;
75
+
76
+ if (method !== "GET" && method !== "HEAD") {
77
+ if (init?.body !== undefined) {
78
+ body = init.body;
79
+ } else {
80
+ const textBody = await request.clone().text();
81
+ body = textBody.length > 0 ? textBody : undefined;
82
+ }
83
+ }
84
+
85
+ return {
86
+ targetUrl,
87
+ forwardedInit: {
88
+ method,
89
+ headers,
90
+ body,
91
+ },
92
+ };
93
+ }
94
+
95
+ export function createMockBunProxy(options: MockProxyOptions = {}): MockBunProxy {
96
+ let inFlightCount = 0;
97
+ let exited = false;
98
+ let lastSpawnArgs: string[] = [];
99
+ const pendingTimers = new Set<NodeJS.Timeout>();
100
+ const forwardToMockFetch: ForwardFetch =
101
+ options.forwardToMockFetch ?? (async () => new Response(null, { status: 204 }));
102
+
103
+ const stdout = new PassThrough();
104
+ const stderr = new PassThrough();
105
+ const child = new EventEmitter() as MockChildProcess;
106
+
107
+ child.pid = nextPid++;
108
+ child.killed = false;
109
+ child.exitCode = null;
110
+ child.signalCode = null;
111
+ child.stdout = stdout;
112
+ child.stderr = stderr;
113
+ child.killSignals = [];
114
+ child.spawnfile = "";
115
+ child.spawnargs = [];
116
+ child.kill = (signal?: number | NodeJS.Signals | null) => {
117
+ const normalizedSignal = normalizeSignal(signal);
118
+ child.killSignals.push(normalizedSignal);
119
+ child.killed = true;
120
+
121
+ if (options.parentDeathSimulation) {
122
+ queueMicrotask(() => {
123
+ emitExit(null, normalizedSignal);
124
+ });
125
+ }
126
+
127
+ return true;
128
+ };
129
+ child.forwardFetch = async (input: ForwardFetchInput, init?: RequestInit): Promise<Response> => {
130
+ inFlightCount += 1;
131
+
132
+ try {
133
+ const { targetUrl, forwardedInit } = await normalizeForwardedRequest(input, init);
134
+ return await forwardToMockFetch(targetUrl, forwardedInit);
135
+ } finally {
136
+ inFlightCount -= 1;
137
+ }
138
+ };
139
+
140
+ const clearPendingTimers = (): void => {
141
+ for (const timer of pendingTimers) {
142
+ clearTimeout(timer);
143
+ }
144
+ pendingTimers.clear();
145
+ };
146
+
147
+ const emitExit = (code: number | null = 0, signal: NodeJS.Signals | null = null): void => {
148
+ if (exited) {
149
+ return;
150
+ }
151
+
152
+ exited = true;
153
+ clearPendingTimers();
154
+ child.exitCode = code;
155
+ child.signalCode = signal;
156
+
157
+ child.emit("exit", code, signal);
158
+ child.emit("close", code, signal);
159
+ stdout.end();
160
+ stderr.end();
161
+ };
162
+
163
+ const mockSpawn = vi.fn(
164
+ (
165
+ command: string,
166
+ argsOrOptions?: readonly string[] | SpawnOptions,
167
+ maybeOptions?: SpawnOptions,
168
+ ): MockChildProcess => {
169
+ if (options.spawnError) {
170
+ throw options.spawnError;
171
+ }
172
+
173
+ const args = Array.isArray(argsOrOptions) ? [...argsOrOptions] : [];
174
+ const spawnOptions = Array.isArray(argsOrOptions) ? maybeOptions : argsOrOptions;
175
+
176
+ lastSpawnArgs = args;
177
+ child.spawnfile = command;
178
+ child.spawnargs = [command, ...args];
179
+ void spawnOptions;
180
+
181
+ return child;
182
+ },
183
+ );
184
+
185
+ const simulateStdoutBanner = (port = inferSpawnPort(lastSpawnArgs)): void => {
186
+ if (exited) {
187
+ return;
188
+ }
189
+
190
+ const writeBanner = (): void => {
191
+ if (!exited) {
192
+ stdout.write(`BUN_PROXY_PORT=${port}\n`);
193
+ }
194
+ };
195
+
196
+ if (!options.bannerDelay || options.bannerDelay <= 0) {
197
+ writeBanner();
198
+ return;
199
+ }
200
+
201
+ const timer = setTimeout(() => {
202
+ pendingTimers.delete(timer);
203
+ writeBanner();
204
+ }, options.bannerDelay);
205
+
206
+ timer.unref?.();
207
+ pendingTimers.add(timer);
208
+ };
209
+
210
+ return {
211
+ mockSpawn,
212
+ child,
213
+ simulateExit(code = 0, signal = null): void {
214
+ emitExit(code, signal);
215
+ },
216
+ simulateStdoutBanner,
217
+ simulateCrash(message = "mock bun proxy crashed"): void {
218
+ if (exited) {
219
+ return;
220
+ }
221
+
222
+ stderr.write(`${message}\n`);
223
+ emitExit(1, null);
224
+ },
225
+ getInFlightCount(): number {
226
+ return inFlightCount;
227
+ },
228
+ };
229
+ }