@apifuse/provider-sdk 2.2.0-beta.12 → 2.2.0-beta.13
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/AUTHORING.md +201 -0
- package/CHANGELOG.md +10 -0
- package/README.md +26 -2
- package/bin/apifuse-pack-types.ts +30 -1
- package/bin/apifuse-record.ts +622 -57
- package/bin/apifuse-submit-check.ts +43 -10
- package/dist/define.d.ts +2 -1
- package/dist/define.js +61 -3
- package/dist/fixture-sanitization.d.ts +26 -0
- package/dist/fixture-sanitization.js +216 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/provider.d.ts +2 -1
- package/dist/provider.js +1 -0
- package/dist/runtime/http.js +86 -32
- package/dist/runtime/instrumentation.js +295 -9
- package/dist/runtime/native-network.d.ts +53 -0
- package/dist/runtime/native-network.js +477 -0
- package/dist/runtime/proxy-nodemaven.d.ts +14 -0
- package/dist/runtime/proxy-nodemaven.js +20 -2
- package/dist/runtime/request-options.d.ts +68 -1
- package/dist/runtime/request-options.js +548 -0
- package/dist/runtime/stealth.d.ts +3 -1
- package/dist/runtime/stealth.js +239 -39
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/self-test-input-tokens.d.ts +2 -1
- package/dist/server/self-test-input-tokens.js +18 -14
- package/dist/stream-evidence.d.ts +74 -0
- package/dist/stream-evidence.js +785 -0
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/run.d.ts +32 -2
- package/dist/testing/run.js +451 -19
- package/dist/types.d.ts +162 -0
- package/package.json +2 -1
- package/src/define.ts +81 -3
- package/src/fixture-sanitization.ts +247 -0
- package/src/index.ts +37 -0
- package/src/provider.ts +37 -0
- package/src/runtime/http.ts +144 -38
- package/src/runtime/instrumentation.ts +424 -8
- package/src/runtime/native-network.ts +600 -0
- package/src/runtime/proxy-nodemaven.ts +37 -2
- package/src/runtime/request-options.ts +680 -1
- package/src/runtime/stealth.ts +293 -40
- package/src/server/index.ts +4 -1
- package/src/server/self-test-input-tokens.ts +29 -14
- package/src/stream-evidence.ts +988 -0
- package/src/testing/index.ts +9 -1
- package/src/testing/run.ts +608 -12
- package/src/types.ts +194 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Socket } from "node:net";
|
|
3
|
+
import { connect as connectTlsSocket, type TLSSocket } from "node:tls";
|
|
4
|
+
|
|
5
|
+
import { SocksClient } from "socks";
|
|
6
|
+
|
|
7
|
+
import { ProxyResolutionError } from "../config/loader.js";
|
|
8
|
+
import { TransportError } from "../errors.js";
|
|
9
|
+
import type {
|
|
10
|
+
NativeNetworkClient,
|
|
11
|
+
NativeNetworkConnection,
|
|
12
|
+
NativeNetworkConnectInput,
|
|
13
|
+
NativeNetworkDynamicGrantOptions,
|
|
14
|
+
NativeNetworkEgressGrant,
|
|
15
|
+
NativeProxyDrainHandler,
|
|
16
|
+
NativeProxyEgressInfo,
|
|
17
|
+
NativeProxyExpiringEvent,
|
|
18
|
+
ProviderProxyPolicy,
|
|
19
|
+
ProviderProxyProvider,
|
|
20
|
+
} from "../types.js";
|
|
21
|
+
import {
|
|
22
|
+
hasNodemavenCredentials,
|
|
23
|
+
nodemavenSessionWindow,
|
|
24
|
+
synthesizeNodemavenProxy,
|
|
25
|
+
} from "./proxy-nodemaven.js";
|
|
26
|
+
|
|
27
|
+
export type NativeNetworkErrorCode =
|
|
28
|
+
| "native_connection_aborted"
|
|
29
|
+
| "native_connection_closed"
|
|
30
|
+
| "native_connection_failed"
|
|
31
|
+
| "native_connection_idle_timeout"
|
|
32
|
+
| "native_connection_timeout"
|
|
33
|
+
| "native_dynamic_egress_unsupported"
|
|
34
|
+
| "native_proxy_expired"
|
|
35
|
+
| "native_proxy_invalid";
|
|
36
|
+
|
|
37
|
+
export class NativeNetworkError extends TransportError {
|
|
38
|
+
constructor(message: string, code: NativeNetworkErrorCode) {
|
|
39
|
+
super(message, { code, status: 0 });
|
|
40
|
+
this.name = "NativeNetworkError";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
override get code(): NativeNetworkErrorCode {
|
|
44
|
+
return super.code as NativeNetworkErrorCode;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class NativeProxyExpiredError extends NativeNetworkError {
|
|
49
|
+
constructor(readonly expiresAt: string) {
|
|
50
|
+
super("Native connection closed at sticky proxy expiry", "native_proxy_expired");
|
|
51
|
+
this.name = "NativeProxyExpiredError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Raised when an established connection exceeds its opt-in read-idle window. */
|
|
56
|
+
export class NativeIdleTimeoutError extends NativeNetworkError {
|
|
57
|
+
constructor() {
|
|
58
|
+
super(
|
|
59
|
+
"Native network socket timed out while reading.",
|
|
60
|
+
"native_connection_idle_timeout",
|
|
61
|
+
);
|
|
62
|
+
this.name = "NativeIdleTimeoutError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type NativeGatewayProxy = NativeProxyEgressInfo & {
|
|
67
|
+
readonly url: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type NativeGatewayProxySynthesisInput = {
|
|
71
|
+
readonly vendor: ProviderProxyProvider;
|
|
72
|
+
readonly policy: ProviderProxyPolicy;
|
|
73
|
+
readonly affinityKey?: string;
|
|
74
|
+
readonly now: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** A vendor adapter in the ordered native gateway resolution chain. */
|
|
78
|
+
export type NativeGatewayProxySynthesizer = (
|
|
79
|
+
input: NativeGatewayProxySynthesisInput,
|
|
80
|
+
) => NativeGatewayProxy | undefined;
|
|
81
|
+
|
|
82
|
+
export type NativeGatewayProxyResolutionInput = {
|
|
83
|
+
readonly policy: ProviderProxyPolicy;
|
|
84
|
+
readonly affinityKey?: string;
|
|
85
|
+
readonly now?: number;
|
|
86
|
+
readonly gatewaySynthesizers?: readonly NativeGatewayProxySynthesizer[];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type NativeNetworkClientOptions = {
|
|
90
|
+
readonly proxyPolicy?: ProviderProxyPolicy;
|
|
91
|
+
readonly affinityKey?: string;
|
|
92
|
+
/** Stable credential/account identity; hashed before vendor synthesis. */
|
|
93
|
+
readonly credentialIdentity?: string;
|
|
94
|
+
/** Vendor adapters in priority order within each policy vendor slot. */
|
|
95
|
+
readonly gatewaySynthesizers?: readonly NativeGatewayProxySynthesizer[];
|
|
96
|
+
/** Warning-level lifecycle diagnostic sink. */
|
|
97
|
+
readonly warn?: (message: string) => void;
|
|
98
|
+
/** Delegate to the deployment's native egress authorization layer. */
|
|
99
|
+
readonly grantTcpEgress?: (input: NativeNetworkDynamicGrantOptions) => NativeNetworkEgressGrant;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function synthesizeNodemavenGateway(
|
|
103
|
+
input: NativeGatewayProxySynthesisInput,
|
|
104
|
+
): NativeGatewayProxy | undefined {
|
|
105
|
+
if (input.vendor !== "nodemaven" || !hasNodemavenCredentials()) return undefined;
|
|
106
|
+
|
|
107
|
+
// NodeMaven defaults ctx.http to HTTP CONNECT because SOCKS5 adds ~500ms per
|
|
108
|
+
// request. Native LOCO pays this once per long-lived connection and must reach
|
|
109
|
+
// arbitrary destination ports, so SOCKS5 is mandatory here.
|
|
110
|
+
const sessionWindow = nodemavenSessionWindow(input.policy, input.now);
|
|
111
|
+
const synthesized = synthesizeNodemavenProxy({
|
|
112
|
+
policy: input.policy,
|
|
113
|
+
affinityKey: input.affinityKey,
|
|
114
|
+
protocol: "socks5",
|
|
115
|
+
poolIndex: 0,
|
|
116
|
+
refreshEpoch: sessionWindow.refreshEpoch,
|
|
117
|
+
now: input.now,
|
|
118
|
+
expiresAt: sessionWindow.expiresAt,
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
url: synthesized.url,
|
|
122
|
+
vendor: "nodemaven",
|
|
123
|
+
sticky: synthesized.sticky,
|
|
124
|
+
sessionId: synthesized.sessionId,
|
|
125
|
+
expiresAt: synthesized.expiresAt,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const DEFAULT_GATEWAY_SYNTHESIZERS: readonly NativeGatewayProxySynthesizer[] = [
|
|
130
|
+
synthesizeNodemavenGateway,
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
/** Domain-separated, process-independent affinity derived from credential identity. */
|
|
134
|
+
export function deriveNativeCredentialAffinityKey(credentialIdentity: string): string {
|
|
135
|
+
return createHash("sha256")
|
|
136
|
+
.update("apifuse-native-proxy-affinity:v1\0")
|
|
137
|
+
.update(credentialIdentity)
|
|
138
|
+
.digest("hex");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isStickyPolicy(policy: ProviderProxyPolicy): boolean {
|
|
142
|
+
return (policy.session?.affinity ?? "request") !== "request";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveNativeVendorChain(policy: ProviderProxyPolicy): ProviderProxyProvider[] {
|
|
146
|
+
const declared = policy.providers?.length
|
|
147
|
+
? policy.providers
|
|
148
|
+
: policy.provider
|
|
149
|
+
? [policy.provider]
|
|
150
|
+
: (["nodemaven"] as const);
|
|
151
|
+
const chain: ProviderProxyProvider[] = [];
|
|
152
|
+
for (const vendor of declared) {
|
|
153
|
+
if (!chain.includes(vendor)) chain.push(vendor);
|
|
154
|
+
}
|
|
155
|
+
return chain;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Resolve the first configured native gateway without invoking an allocator API. */
|
|
159
|
+
export function resolveNativeGatewayProxy(
|
|
160
|
+
input: NativeGatewayProxyResolutionInput,
|
|
161
|
+
): NativeGatewayProxy | undefined {
|
|
162
|
+
if (input.policy.mode === "disabled") return undefined;
|
|
163
|
+
const synthesizers = input.gatewaySynthesizers ?? DEFAULT_GATEWAY_SYNTHESIZERS;
|
|
164
|
+
const now = input.now ?? Date.now();
|
|
165
|
+
for (const vendor of resolveNativeVendorChain(input.policy)) {
|
|
166
|
+
for (const synthesize of synthesizers) {
|
|
167
|
+
const resolved = synthesize({
|
|
168
|
+
vendor,
|
|
169
|
+
policy: input.policy,
|
|
170
|
+
affinityKey: input.affinityKey,
|
|
171
|
+
now,
|
|
172
|
+
});
|
|
173
|
+
if (resolved && resolved.vendor === vendor) return resolved;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function proxyRequiredError(policy: ProviderProxyPolicy): ProxyResolutionError {
|
|
180
|
+
const chain = resolveNativeVendorChain(policy).filter(
|
|
181
|
+
(vendor): vendor is "smartproxy" | "nodemaven" =>
|
|
182
|
+
vendor === "smartproxy" || vendor === "nodemaven",
|
|
183
|
+
);
|
|
184
|
+
return new ProxyResolutionError(
|
|
185
|
+
"PROXY_REQUIRED",
|
|
186
|
+
`Native proxy egress is required but no gateway vendor in [${chain.join(", ") || "none"}] resolved.`,
|
|
187
|
+
{ vendorChain: chain },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type Deadline = number | undefined;
|
|
192
|
+
|
|
193
|
+
function deadlineFrom(timeoutMs: number | undefined): Deadline {
|
|
194
|
+
if (timeoutMs === undefined) return undefined;
|
|
195
|
+
return Date.now() + Math.max(0, timeoutMs);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function remainingMs(deadline: Deadline): number | undefined {
|
|
199
|
+
return deadline === undefined ? undefined : Math.max(0, deadline - Date.now());
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function abortError(): NativeNetworkError {
|
|
203
|
+
return new NativeNetworkError("Native connection was aborted", "native_connection_aborted");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function timeoutError(): NativeNetworkError {
|
|
207
|
+
return new NativeNetworkError("Native connection timed out", "native_connection_timeout");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function failedError(): NativeNetworkError {
|
|
211
|
+
return new NativeNetworkError("Native connection failed", "native_connection_failed");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function assertCanStart(signal: AbortSignal | undefined, deadline: Deadline): void {
|
|
215
|
+
if (signal?.aborted) throw abortError();
|
|
216
|
+
if (deadline !== undefined && deadline <= Date.now()) throw timeoutError();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function waitForSocketEvent(
|
|
220
|
+
socket: Socket | TLSSocket,
|
|
221
|
+
event: "connect" | "secureConnect",
|
|
222
|
+
signal: AbortSignal | undefined,
|
|
223
|
+
deadline: Deadline,
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
assertCanStart(signal, deadline);
|
|
226
|
+
await new Promise<void>((resolve, reject) => {
|
|
227
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
228
|
+
const cleanup = () => {
|
|
229
|
+
if (timer) clearTimeout(timer);
|
|
230
|
+
socket.off(event, onReady);
|
|
231
|
+
socket.off("error", onError);
|
|
232
|
+
socket.off("close", onClose);
|
|
233
|
+
signal?.removeEventListener("abort", onAbort);
|
|
234
|
+
};
|
|
235
|
+
const finish = (error?: Error) => {
|
|
236
|
+
cleanup();
|
|
237
|
+
if (error) {
|
|
238
|
+
socket.on("error", () => undefined);
|
|
239
|
+
socket.destroy();
|
|
240
|
+
reject(error);
|
|
241
|
+
} else resolve();
|
|
242
|
+
};
|
|
243
|
+
const onReady = () => finish();
|
|
244
|
+
const onError = () => finish(failedError());
|
|
245
|
+
const onClose = () => finish(failedError());
|
|
246
|
+
const onAbort = () => finish(abortError());
|
|
247
|
+
|
|
248
|
+
socket.once(event, onReady);
|
|
249
|
+
socket.once("error", onError);
|
|
250
|
+
socket.once("close", onClose);
|
|
251
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
252
|
+
const remaining = remainingMs(deadline);
|
|
253
|
+
if (remaining !== undefined) timer = setTimeout(() => finish(timeoutError()), remaining);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function connectPlainSocket(
|
|
258
|
+
host: string,
|
|
259
|
+
port: number,
|
|
260
|
+
signal: AbortSignal | undefined,
|
|
261
|
+
deadline: Deadline,
|
|
262
|
+
): Promise<Socket> {
|
|
263
|
+
assertCanStart(signal, deadline);
|
|
264
|
+
const socket = new Socket();
|
|
265
|
+
const ready = waitForSocketEvent(socket, "connect", signal, deadline);
|
|
266
|
+
socket.connect({ host, port });
|
|
267
|
+
await ready;
|
|
268
|
+
return socket;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function parseSocks5Proxy(proxyUrl: string): {
|
|
272
|
+
host: string;
|
|
273
|
+
port: number;
|
|
274
|
+
userId?: string;
|
|
275
|
+
password?: string;
|
|
276
|
+
} {
|
|
277
|
+
let parsed: URL;
|
|
278
|
+
try {
|
|
279
|
+
parsed = new URL(proxyUrl);
|
|
280
|
+
} catch {
|
|
281
|
+
throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
|
|
282
|
+
}
|
|
283
|
+
const port = Number(parsed.port);
|
|
284
|
+
if (parsed.protocol !== "socks5:" || !parsed.hostname || !Number.isInteger(port) || port <= 0) {
|
|
285
|
+
throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
return {
|
|
289
|
+
host: parsed.hostname,
|
|
290
|
+
port,
|
|
291
|
+
...(parsed.username ? { userId: decodeURIComponent(parsed.username) } : {}),
|
|
292
|
+
...(parsed.password ? { password: decodeURIComponent(parsed.password) } : {}),
|
|
293
|
+
};
|
|
294
|
+
} catch {
|
|
295
|
+
throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function waitForSocksHandshake(
|
|
300
|
+
proxySocket: Socket,
|
|
301
|
+
promise: ReturnType<typeof SocksClient.createConnection>,
|
|
302
|
+
signal: AbortSignal | undefined,
|
|
303
|
+
deadline: Deadline,
|
|
304
|
+
): Promise<Socket> {
|
|
305
|
+
assertCanStart(signal, deadline);
|
|
306
|
+
return await new Promise<Socket>((resolve, reject) => {
|
|
307
|
+
let settled = false;
|
|
308
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
309
|
+
const cleanup = () => {
|
|
310
|
+
if (timer) clearTimeout(timer);
|
|
311
|
+
signal?.removeEventListener("abort", onAbort);
|
|
312
|
+
};
|
|
313
|
+
const finish = (error: Error | undefined, socket?: Socket) => {
|
|
314
|
+
if (settled) return;
|
|
315
|
+
settled = true;
|
|
316
|
+
cleanup();
|
|
317
|
+
if (error) {
|
|
318
|
+
proxySocket.destroy();
|
|
319
|
+
reject(error);
|
|
320
|
+
} else if (socket) resolve(socket);
|
|
321
|
+
else reject(failedError());
|
|
322
|
+
};
|
|
323
|
+
const onAbort = () => finish(abortError());
|
|
324
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
325
|
+
const remaining = remainingMs(deadline);
|
|
326
|
+
if (remaining !== undefined) timer = setTimeout(() => finish(timeoutError()), remaining);
|
|
327
|
+
void promise.then(
|
|
328
|
+
(result) => finish(undefined, result.socket),
|
|
329
|
+
(error) =>
|
|
330
|
+
finish(
|
|
331
|
+
error instanceof Error && /\b(?:timed out|timeout)\b/i.test(error.message)
|
|
332
|
+
? timeoutError()
|
|
333
|
+
: failedError(),
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function connectSocksTunnel(
|
|
340
|
+
proxy: NativeGatewayProxy,
|
|
341
|
+
input: NativeNetworkConnectInput,
|
|
342
|
+
deadline: Deadline,
|
|
343
|
+
): Promise<Socket> {
|
|
344
|
+
const parsed = parseSocks5Proxy(proxy.url);
|
|
345
|
+
const proxySocket = await connectPlainSocket(parsed.host, parsed.port, input.signal, deadline);
|
|
346
|
+
// `socks` removes its internal listeners as the handshake settles. Keep a
|
|
347
|
+
// credential-free sink on the owned socket so an abort/close race cannot emit
|
|
348
|
+
// an unhandled late network error between library cleanup and our wrapper.
|
|
349
|
+
proxySocket.on("error", () => undefined);
|
|
350
|
+
const remaining = remainingMs(deadline);
|
|
351
|
+
const handshake = SocksClient.createConnection({
|
|
352
|
+
command: "connect",
|
|
353
|
+
destination: { host: input.host, port: input.port },
|
|
354
|
+
proxy: { type: 5, ...parsed },
|
|
355
|
+
existing_socket: proxySocket,
|
|
356
|
+
...(remaining === undefined ? {} : { timeout: Math.max(1, remaining) }),
|
|
357
|
+
});
|
|
358
|
+
return await waitForSocksHandshake(proxySocket, handshake, input.signal, deadline);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function upgradeTls(
|
|
362
|
+
socket: Socket | undefined,
|
|
363
|
+
input: NativeNetworkConnectInput,
|
|
364
|
+
deadline: Deadline,
|
|
365
|
+
): Promise<TLSSocket> {
|
|
366
|
+
assertCanStart(input.signal, deadline);
|
|
367
|
+
const tlsSocket = socket
|
|
368
|
+
? connectTlsSocket({
|
|
369
|
+
socket,
|
|
370
|
+
servername: input.serverName ?? input.host,
|
|
371
|
+
rejectUnauthorized: input.rejectUnauthorized,
|
|
372
|
+
})
|
|
373
|
+
: connectTlsSocket({
|
|
374
|
+
host: input.host,
|
|
375
|
+
port: input.port,
|
|
376
|
+
servername: input.serverName ?? input.host,
|
|
377
|
+
rejectUnauthorized: input.rejectUnauthorized,
|
|
378
|
+
});
|
|
379
|
+
await waitForSocketEvent(tlsSocket, "secureConnect", input.signal, deadline);
|
|
380
|
+
return tlsSocket;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function createNativeNetworkConnection(
|
|
384
|
+
socket: Socket | TLSSocket,
|
|
385
|
+
proxy: NativeGatewayProxy | undefined,
|
|
386
|
+
options: NativeNetworkClientOptions,
|
|
387
|
+
idleTimeoutMs?: number,
|
|
388
|
+
): NativeNetworkConnection {
|
|
389
|
+
const warn = options.warn ?? console.warn;
|
|
390
|
+
let terminalError: Error | undefined;
|
|
391
|
+
let closeReason: NativeNetworkError | undefined;
|
|
392
|
+
let drainHandler: NativeProxyDrainHandler | undefined;
|
|
393
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
394
|
+
let expiringTimer: ReturnType<typeof setTimeout> | undefined;
|
|
395
|
+
let hardExpiryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
396
|
+
let lifecycleSettled = false;
|
|
397
|
+
let settleLifecycle: () => void = () => undefined;
|
|
398
|
+
const lifecycleCutoff = new Promise<void>((resolve) => {
|
|
399
|
+
settleLifecycle = resolve;
|
|
400
|
+
});
|
|
401
|
+
const clearIdleTimer = () => {
|
|
402
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
403
|
+
idleTimer = undefined;
|
|
404
|
+
};
|
|
405
|
+
const resetIdleTimer = () => {
|
|
406
|
+
clearIdleTimer();
|
|
407
|
+
if (idleTimeoutMs === undefined || socket.readableEnded || socket.destroyed) return;
|
|
408
|
+
idleTimer = setTimeout(() => {
|
|
409
|
+
idleTimer = undefined;
|
|
410
|
+
if (socket.readableEnded || socket.destroyed) return;
|
|
411
|
+
closeReason = new NativeIdleTimeoutError();
|
|
412
|
+
socket.destroy(closeReason);
|
|
413
|
+
}, Math.max(0, idleTimeoutMs));
|
|
414
|
+
idleTimer.unref?.();
|
|
415
|
+
};
|
|
416
|
+
const clearLifecycle = () => {
|
|
417
|
+
clearIdleTimer();
|
|
418
|
+
if (lifecycleSettled) return;
|
|
419
|
+
lifecycleSettled = true;
|
|
420
|
+
if (expiringTimer) clearTimeout(expiringTimer);
|
|
421
|
+
if (hardExpiryTimer) clearTimeout(hardExpiryTimer);
|
|
422
|
+
settleLifecycle();
|
|
423
|
+
};
|
|
424
|
+
socket.on("error", (error) => {
|
|
425
|
+
terminalError = error;
|
|
426
|
+
});
|
|
427
|
+
socket.once("end", clearIdleTimer);
|
|
428
|
+
socket.once("close", clearLifecycle);
|
|
429
|
+
resetIdleTimer();
|
|
430
|
+
|
|
431
|
+
const expiresAt = proxy?.sticky ? proxy.expiresAt : undefined;
|
|
432
|
+
const leadSeconds = options.proxyPolicy?.session?.drainLeadSeconds;
|
|
433
|
+
const expiresAtMs = expiresAt ? Date.parse(expiresAt) : Number.NaN;
|
|
434
|
+
if (
|
|
435
|
+
expiresAt &&
|
|
436
|
+
Number.isFinite(expiresAtMs) &&
|
|
437
|
+
typeof leadSeconds === "number" &&
|
|
438
|
+
Number.isFinite(leadSeconds) &&
|
|
439
|
+
leadSeconds >= 0
|
|
440
|
+
) {
|
|
441
|
+
const event: NativeProxyExpiringEvent = {
|
|
442
|
+
expiresAt,
|
|
443
|
+
leadSeconds,
|
|
444
|
+
reason: "sticky_expiry",
|
|
445
|
+
};
|
|
446
|
+
const remaining = Math.max(0, expiresAtMs - Date.now());
|
|
447
|
+
const leadDelay = Math.max(0, remaining - leadSeconds * 1_000);
|
|
448
|
+
expiringTimer = setTimeout(() => {
|
|
449
|
+
expiringTimer = undefined;
|
|
450
|
+
const handler = drainHandler;
|
|
451
|
+
if (!handler) {
|
|
452
|
+
warn("Native sticky proxy is expiring without a drain handler");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
void (async () => {
|
|
456
|
+
try {
|
|
457
|
+
await Promise.race([Promise.resolve(handler(event)), lifecycleCutoff]);
|
|
458
|
+
} catch {
|
|
459
|
+
// Drain failures do not bypass the hard-expiry fail-safe.
|
|
460
|
+
}
|
|
461
|
+
})();
|
|
462
|
+
}, leadDelay);
|
|
463
|
+
expiringTimer.unref?.();
|
|
464
|
+
hardExpiryTimer = setTimeout(() => {
|
|
465
|
+
hardExpiryTimer = undefined;
|
|
466
|
+
if (socket.destroyed) return;
|
|
467
|
+
closeReason = new NativeProxyExpiredError(expiresAt);
|
|
468
|
+
settleLifecycle();
|
|
469
|
+
socket.destroy();
|
|
470
|
+
}, remaining);
|
|
471
|
+
hardExpiryTimer.unref?.();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const read = async (): Promise<Uint8Array | null> => {
|
|
475
|
+
if (closeReason) throw closeReason;
|
|
476
|
+
if (terminalError) throw failedError();
|
|
477
|
+
const chunk = socket.read() as Buffer | null;
|
|
478
|
+
if (chunk) {
|
|
479
|
+
resetIdleTimer();
|
|
480
|
+
return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
481
|
+
}
|
|
482
|
+
if (socket.readableEnded || socket.destroyed) return null;
|
|
483
|
+
await new Promise<void>((resolve) => {
|
|
484
|
+
const cleanup = () => {
|
|
485
|
+
socket.off("readable", onReadable);
|
|
486
|
+
socket.off("end", onDone);
|
|
487
|
+
socket.off("close", onDone);
|
|
488
|
+
socket.off("error", onDone);
|
|
489
|
+
};
|
|
490
|
+
const onReadable = () => {
|
|
491
|
+
cleanup();
|
|
492
|
+
resolve();
|
|
493
|
+
};
|
|
494
|
+
const onDone = () => {
|
|
495
|
+
cleanup();
|
|
496
|
+
resolve();
|
|
497
|
+
};
|
|
498
|
+
socket.once("readable", onReadable);
|
|
499
|
+
socket.once("end", onDone);
|
|
500
|
+
socket.once("close", onDone);
|
|
501
|
+
socket.once("error", onDone);
|
|
502
|
+
});
|
|
503
|
+
return await read();
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
get closeReason() {
|
|
508
|
+
return closeReason;
|
|
509
|
+
},
|
|
510
|
+
...(proxy
|
|
511
|
+
? {
|
|
512
|
+
proxy: {
|
|
513
|
+
vendor: proxy.vendor,
|
|
514
|
+
sticky: proxy.sticky,
|
|
515
|
+
sessionId: proxy.sessionId,
|
|
516
|
+
expiresAt: proxy.expiresAt,
|
|
517
|
+
},
|
|
518
|
+
}
|
|
519
|
+
: {}),
|
|
520
|
+
read,
|
|
521
|
+
onExpiring: (handler) => {
|
|
522
|
+
drainHandler = handler;
|
|
523
|
+
},
|
|
524
|
+
write: async (data) => {
|
|
525
|
+
if (closeReason) throw closeReason;
|
|
526
|
+
if (socket.destroyed) {
|
|
527
|
+
throw new NativeNetworkError("Native connection is closed", "native_connection_closed");
|
|
528
|
+
}
|
|
529
|
+
await new Promise<void>((resolve, reject) => {
|
|
530
|
+
socket.write(data, (error) => {
|
|
531
|
+
if (error) reject(failedError());
|
|
532
|
+
else resolve();
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
},
|
|
536
|
+
close: async () => {
|
|
537
|
+
if (socket.closed || socket.destroyed) {
|
|
538
|
+
clearLifecycle();
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
await new Promise<void>((resolve) => {
|
|
542
|
+
socket.once("close", () => resolve());
|
|
543
|
+
socket.destroy();
|
|
544
|
+
});
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function resolveConnectionProxy(
|
|
550
|
+
options: NativeNetworkClientOptions,
|
|
551
|
+
input: NativeNetworkConnectInput,
|
|
552
|
+
): Promise<NativeGatewayProxy | undefined> {
|
|
553
|
+
const policy = options.proxyPolicy;
|
|
554
|
+
if (!policy || policy.mode === "disabled") return undefined;
|
|
555
|
+
const explicitAffinityKey = input.affinityKey ?? options.affinityKey;
|
|
556
|
+
const affinityKey =
|
|
557
|
+
explicitAffinityKey ??
|
|
558
|
+
(isStickyPolicy(policy) && options.credentialIdentity !== undefined
|
|
559
|
+
? deriveNativeCredentialAffinityKey(options.credentialIdentity)
|
|
560
|
+
: undefined);
|
|
561
|
+
const resolved = resolveNativeGatewayProxy({
|
|
562
|
+
policy,
|
|
563
|
+
affinityKey,
|
|
564
|
+
gatewaySynthesizers: options.gatewaySynthesizers,
|
|
565
|
+
});
|
|
566
|
+
if (!resolved && policy.mode === "required") throw proxyRequiredError(policy);
|
|
567
|
+
return resolved;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Create the SDK byte-stream runtime; deployment egress authorization stays delegated. */
|
|
571
|
+
export function createNativeNetworkClient(
|
|
572
|
+
options: NativeNetworkClientOptions = {},
|
|
573
|
+
): NativeNetworkClient {
|
|
574
|
+
return {
|
|
575
|
+
connectTcp: async (input) => {
|
|
576
|
+
const deadline = deadlineFrom(input.timeoutMs);
|
|
577
|
+
assertCanStart(input.signal, deadline);
|
|
578
|
+
const proxy = await resolveConnectionProxy(options, input);
|
|
579
|
+
const socket = proxy
|
|
580
|
+
? await connectSocksTunnel(proxy, input, deadline)
|
|
581
|
+
: await connectPlainSocket(input.host, input.port, input.signal, deadline);
|
|
582
|
+
return createNativeNetworkConnection(socket, proxy, options, input.idleTimeoutMs);
|
|
583
|
+
},
|
|
584
|
+
connectTls: async (input) => {
|
|
585
|
+
const deadline = deadlineFrom(input.timeoutMs);
|
|
586
|
+
assertCanStart(input.signal, deadline);
|
|
587
|
+
const proxy = await resolveConnectionProxy(options, input);
|
|
588
|
+
const tunnel = proxy ? await connectSocksTunnel(proxy, input, deadline) : undefined;
|
|
589
|
+
const socket = await upgradeTls(tunnel, input, deadline);
|
|
590
|
+
return createNativeNetworkConnection(socket, proxy, options, input.idleTimeoutMs);
|
|
591
|
+
},
|
|
592
|
+
grantTcpEgress: (input) => {
|
|
593
|
+
if (options.grantTcpEgress) return options.grantTcpEgress(input);
|
|
594
|
+
throw new NativeNetworkError(
|
|
595
|
+
"Dynamic native egress authorization is not configured",
|
|
596
|
+
"native_dynamic_egress_unsupported",
|
|
597
|
+
);
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
}
|
|
@@ -60,7 +60,7 @@ export function nodemavenPoolSize(policy: ProviderProxyPolicy): number {
|
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
function nodemavenLifetimeMinutes(policy: ProviderProxyPolicy): number {
|
|
63
|
+
export function nodemavenLifetimeMinutes(policy: ProviderProxyPolicy): number {
|
|
64
64
|
const configured = policy.session?.lifetimeMinutes;
|
|
65
65
|
if (typeof configured !== "number" || !Number.isFinite(configured) || configured <= 0) {
|
|
66
66
|
return NODEMAVEN_MAX_LIFETIME_MINUTES;
|
|
@@ -68,6 +68,24 @@ function nodemavenLifetimeMinutes(policy: ProviderProxyPolicy): number {
|
|
|
68
68
|
return Math.min(NODEMAVEN_MAX_LIFETIME_MINUTES, Math.max(1, Math.floor(configured)));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
export type NodemavenSessionWindow = {
|
|
72
|
+
refreshEpoch: number;
|
|
73
|
+
expiresAt: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Stable wall-clock window shared by every process deriving the same sticky sid. */
|
|
77
|
+
export function nodemavenSessionWindow(
|
|
78
|
+
policy: ProviderProxyPolicy,
|
|
79
|
+
now = Date.now(),
|
|
80
|
+
): NodemavenSessionWindow {
|
|
81
|
+
const lifetimeMs = nodemavenLifetimeMinutes(policy) * 60_000;
|
|
82
|
+
const refreshEpoch = Math.floor(now / lifetimeMs);
|
|
83
|
+
return {
|
|
84
|
+
refreshEpoch,
|
|
85
|
+
expiresAt: new Date((refreshEpoch + 1) * lifetimeMs).toISOString(),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
71
89
|
/** NodeMaven username tokens accept `[a-z0-9]`; slugify geo values to that set. */
|
|
72
90
|
function slugifyGeo(value: string | undefined): string | undefined {
|
|
73
91
|
if (!value) return undefined;
|
|
@@ -121,11 +139,18 @@ export type NodemavenSynthesisInput = {
|
|
|
121
139
|
refreshEpoch: number;
|
|
122
140
|
/** ISO 3166-1 alpha-2, already resolved by the caller (falls back to env). */
|
|
123
141
|
country?: string;
|
|
142
|
+
/** Clock injection used by native sticky-expiry lifecycle tests. */
|
|
143
|
+
now?: number;
|
|
144
|
+
/** Stable caller-owned hard expiry when the refresh epoch is wall-clock-derived. */
|
|
145
|
+
expiresAt?: string;
|
|
124
146
|
};
|
|
125
147
|
|
|
126
148
|
export type NodemavenSynthesis = {
|
|
127
149
|
url: string;
|
|
128
150
|
protocol: ProxyProtocol;
|
|
151
|
+
sticky: boolean;
|
|
152
|
+
sessionId: string;
|
|
153
|
+
expiresAt?: string;
|
|
129
154
|
diagnostics: Record<string, string | number | boolean>;
|
|
130
155
|
};
|
|
131
156
|
|
|
@@ -146,6 +171,7 @@ export function synthesizeNodemavenProxy(input: NodemavenSynthesisInput): Nodema
|
|
|
146
171
|
const sid = deriveSid(input.policy, input.affinityKey, input.poolIndex, input.refreshEpoch);
|
|
147
172
|
const port = selectPort(input.protocol, sid, input.poolIndex);
|
|
148
173
|
const lifetimeMinutes = nodemavenLifetimeMinutes(input.policy);
|
|
174
|
+
const sticky = isStickyAffinity(input.policy);
|
|
149
175
|
|
|
150
176
|
const country = slugifyGeo(input.country ?? input.policy.geo?.country);
|
|
151
177
|
const region = slugifyGeo(input.policy.geo?.subdivision);
|
|
@@ -166,10 +192,19 @@ export function synthesizeNodemavenProxy(input: NodemavenSynthesisInput): Nodema
|
|
|
166
192
|
return {
|
|
167
193
|
url,
|
|
168
194
|
protocol: input.protocol,
|
|
195
|
+
sticky,
|
|
196
|
+
sessionId: sid,
|
|
197
|
+
...(sticky
|
|
198
|
+
? {
|
|
199
|
+
expiresAt:
|
|
200
|
+
input.expiresAt ??
|
|
201
|
+
new Date((input.now ?? Date.now()) + lifetimeMinutes * 60_000).toISOString(),
|
|
202
|
+
}
|
|
203
|
+
: {}),
|
|
169
204
|
diagnostics: {
|
|
170
205
|
vendor: "nodemaven",
|
|
171
206
|
protocol: input.protocol,
|
|
172
|
-
sticky
|
|
207
|
+
sticky,
|
|
173
208
|
filter,
|
|
174
209
|
lifetimeMinutes,
|
|
175
210
|
...(country ? { country } : {}),
|