@apifuse/provider-sdk 2.2.0-beta.11 → 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.
Files changed (59) hide show
  1. package/AUTHORING.md +238 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +44 -2
  4. package/bin/apifuse-pack-smoke.ts +14 -0
  5. package/bin/apifuse-pack-types.ts +40 -1
  6. package/bin/apifuse-record.ts +622 -57
  7. package/bin/apifuse-submit-check.ts +43 -10
  8. package/dist/config/loader.d.ts +9 -1
  9. package/dist/config/loader.js +9 -0
  10. package/dist/define.d.ts +2 -1
  11. package/dist/define.js +61 -3
  12. package/dist/errors.d.ts +5 -0
  13. package/dist/errors.js +15 -0
  14. package/dist/fixture-sanitization.d.ts +26 -0
  15. package/dist/fixture-sanitization.js +216 -0
  16. package/dist/index.d.ts +4 -3
  17. package/dist/index.js +2 -1
  18. package/dist/provider.d.ts +2 -1
  19. package/dist/provider.js +1 -0
  20. package/dist/runtime/http.js +86 -32
  21. package/dist/runtime/instrumentation.js +295 -9
  22. package/dist/runtime/native-network.d.ts +53 -0
  23. package/dist/runtime/native-network.js +477 -0
  24. package/dist/runtime/proxy-nodemaven.d.ts +14 -0
  25. package/dist/runtime/proxy-nodemaven.js +20 -2
  26. package/dist/runtime/request-options.d.ts +68 -1
  27. package/dist/runtime/request-options.js +548 -0
  28. package/dist/runtime/stealth.d.ts +3 -1
  29. package/dist/runtime/stealth.js +352 -86
  30. package/dist/server/index.d.ts +1 -1
  31. package/dist/server/index.js +1 -1
  32. package/dist/server/self-test-input-tokens.d.ts +2 -1
  33. package/dist/server/self-test-input-tokens.js +18 -14
  34. package/dist/stream-evidence.d.ts +74 -0
  35. package/dist/stream-evidence.js +785 -0
  36. package/dist/testing/index.d.ts +1 -1
  37. package/dist/testing/index.js +1 -1
  38. package/dist/testing/run.d.ts +32 -2
  39. package/dist/testing/run.js +451 -19
  40. package/dist/types.d.ts +201 -7
  41. package/package.json +3 -1
  42. package/src/config/loader.ts +22 -1
  43. package/src/define.ts +81 -3
  44. package/src/errors.ts +15 -0
  45. package/src/fixture-sanitization.ts +247 -0
  46. package/src/index.ts +45 -1
  47. package/src/provider.ts +37 -0
  48. package/src/runtime/http.ts +144 -38
  49. package/src/runtime/instrumentation.ts +424 -8
  50. package/src/runtime/native-network.ts +600 -0
  51. package/src/runtime/proxy-nodemaven.ts +37 -2
  52. package/src/runtime/request-options.ts +680 -1
  53. package/src/runtime/stealth.ts +420 -88
  54. package/src/server/index.ts +4 -1
  55. package/src/server/self-test-input-tokens.ts +29 -14
  56. package/src/stream-evidence.ts +988 -0
  57. package/src/testing/index.ts +9 -1
  58. package/src/testing/run.ts +608 -12
  59. package/src/types.ts +235 -7
@@ -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: isStickyAffinity(input.policy),
207
+ sticky,
173
208
  filter,
174
209
  lifetimeMinutes,
175
210
  ...(country ? { country } : {}),