@vacbo/opencode-anthropic-fix 0.0.45 → 0.1.2
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/README.md +19 -0
- package/dist/bun-proxy.mjs +282 -55
- package/dist/opencode-anthropic-auth-cli.mjs +194 -55
- package/dist/opencode-anthropic-auth-plugin.js +1801 -613
- package/package.json +4 -4
- package/src/__tests__/billing-edge-cases.test.ts +84 -0
- package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
- package/src/__tests__/debug-gating.test.ts +76 -0
- package/src/__tests__/decomposition-smoke.test.ts +92 -0
- package/src/__tests__/fingerprint-regression.test.ts +1 -1
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
- package/src/__tests__/helpers/conversation-history.ts +376 -0
- package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
- package/src/__tests__/helpers/deferred.ts +122 -0
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
- package/src/__tests__/helpers/in-memory-storage.ts +152 -0
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
- package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
- package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
- package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
- package/src/__tests__/helpers/sse.ts +288 -0
- package/src/__tests__/index.parallel.test.ts +711 -0
- package/src/__tests__/sanitization-regex.test.ts +65 -0
- package/src/__tests__/state-bounds.test.ts +110 -0
- package/src/account-identity.test.ts +213 -0
- package/src/account-identity.ts +108 -0
- package/src/accounts.dedup.test.ts +696 -0
- package/src/accounts.test.ts +2 -1
- package/src/accounts.ts +485 -191
- package/src/bun-fetch.test.ts +379 -0
- package/src/bun-fetch.ts +447 -191
- package/src/bun-proxy.ts +289 -57
- package/src/circuit-breaker.test.ts +274 -0
- package/src/circuit-breaker.ts +235 -0
- package/src/cli.test.ts +1 -0
- package/src/cli.ts +37 -18
- package/src/commands/router.ts +25 -5
- package/src/env.ts +1 -0
- package/src/headers/billing.ts +11 -5
- package/src/index.ts +224 -247
- package/src/oauth.ts +7 -1
- package/src/parent-pid-watcher.test.ts +219 -0
- package/src/parent-pid-watcher.ts +99 -0
- package/src/plugin-helpers.ts +112 -0
- package/src/refresh-helpers.ts +169 -0
- package/src/refresh-lock.test.ts +36 -9
- package/src/refresh-lock.ts +2 -2
- package/src/request/body.history.test.ts +398 -0
- package/src/request/body.ts +200 -13
- package/src/request/metadata.ts +6 -2
- package/src/response/index.ts +1 -1
- package/src/response/mcp.ts +60 -31
- package/src/response/streaming.test.ts +382 -0
- package/src/response/streaming.ts +403 -76
- package/src/storage.test.ts +127 -104
- package/src/storage.ts +152 -62
- package/src/system-prompt/builder.ts +33 -3
- package/src/system-prompt/sanitize.ts +12 -2
- package/src/token-refresh.test.ts +84 -1
- 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
|
+
}
|