@apifuse/provider-sdk 2.2.0-beta.27 → 2.2.0-beta.28
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/CHANGELOG.md +4 -0
- package/bin/apifuse-dev.ts +2 -2
- package/bin/apifuse-pack-smoke.ts +1 -1
- package/bin/apifuse-pack-types.ts +2 -1
- package/bin/apifuse-perf.ts +2 -4
- package/bin/apifuse-record.ts +1 -1
- package/dist/auth-turn/index.d.ts +1 -1
- package/dist/auth-turn/index.js +1 -1
- package/dist/ceremonies/index.js +52 -11
- package/dist/config/loader.d.ts +1 -1
- package/dist/config/loader.js +4 -2
- package/dist/index.d.ts +7 -6
- package/dist/index.js +4 -6
- package/dist/provider.d.ts +2 -1
- package/dist/provider.js +1 -1
- package/dist/runtime/auth-flow.js +1 -1
- package/dist/runtime/http.d.ts +1 -0
- package/dist/runtime/http.js +135 -12
- package/dist/runtime/instrumentation.js +1 -1
- package/dist/runtime/native-network-errors.d.ts +33 -0
- package/dist/runtime/native-network-errors.js +69 -0
- package/dist/runtime/native-network.d.ts +2 -33
- package/dist/runtime/native-network.js +2 -68
- package/dist/runtime/redis.d.ts +1 -1
- package/dist/runtime/redis.js +4 -2
- package/dist/runtime/resolver-config.d.ts +6 -0
- package/dist/runtime/resolver-config.js +6 -0
- package/dist/runtime/resolver-shared.d.ts +3 -0
- package/dist/runtime/resolver-shared.js +12 -0
- package/dist/runtime/resolver-vendors/twocaptcha.d.ts +2 -1
- package/dist/runtime/resolver-vendors/twocaptcha.js +80 -43
- package/dist/runtime/resolver-vendors/types.d.ts +1 -1
- package/dist/runtime/resolver.d.ts +6 -10
- package/dist/runtime/resolver.js +19 -18
- package/dist/runtime/state.js +5 -115
- package/dist/runtime/stealth-cookies.d.ts +20 -0
- package/dist/runtime/stealth-cookies.js +111 -0
- package/dist/runtime/stealth.js +7 -130
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/self-test.d.ts +13 -0
- package/dist/server/self-test.js +124 -46
- package/dist/server/serve-implementation.d.ts +199 -0
- package/dist/server/serve-implementation.js +2054 -0
- package/dist/server/serve.d.ts +1 -187
- package/dist/server/serve.js +1 -1827
- package/dist/stateful/errors.d.ts +5 -0
- package/dist/stateful/errors.js +10 -0
- package/dist/stateful/stateful-provider-session-routing.d.ts +1 -5
- package/dist/stateful/stateful-provider-session-routing.js +2 -10
- package/dist/stream.js +7 -1
- package/package.json +27 -2
- package/src/auth-turn/index.ts +1 -1
- package/src/ceremonies/index.ts +68 -18
- package/src/config/loader.ts +5 -2
- package/src/index.ts +18 -23
- package/src/provider.ts +12 -14
- package/src/runtime/auth-flow.ts +1 -1
- package/src/runtime/http.ts +155 -11
- package/src/runtime/instrumentation.ts +1 -1
- package/src/runtime/native-network-errors.ts +99 -0
- package/src/runtime/native-network.ts +16 -97
- package/src/runtime/redis.ts +7 -2
- package/src/runtime/resolver-config.ts +6 -0
- package/src/runtime/resolver-shared.ts +17 -0
- package/src/runtime/resolver-vendors/twocaptcha.ts +100 -49
- package/src/runtime/resolver-vendors/types.ts +1 -0
- package/src/runtime/resolver.ts +47 -22
- package/src/runtime/state.ts +5 -144
- package/src/runtime/stealth-cookies.ts +132 -0
- package/src/runtime/stealth.ts +14 -157
- package/src/serve.ts +6 -1
- package/src/server/index.ts +1 -0
- package/src/server/self-test.ts +184 -59
- package/src/server/serve-implementation.ts +3024 -0
- package/src/server/serve.ts +1 -2661
- package/src/stateful/errors.ts +12 -0
- package/src/stateful/stateful-provider-session-routing.ts +2 -11
- package/src/stream.ts +8 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChallengeSolution, ProviderChallenge } from "../../types.js";
|
|
2
|
+
import type { TraceRecorder } from "../trace.js";
|
|
2
3
|
import { assertResolverHostAllowed } from "./hosts.js";
|
|
3
4
|
import {
|
|
4
5
|
type ResolverIdentity,
|
|
@@ -34,6 +35,7 @@ export interface TwoCaptchaResolverVendorAdapter extends ResolverVendorAdapter {
|
|
|
34
35
|
challenge: ProviderChallenge,
|
|
35
36
|
identity: ResolverIdentity | undefined,
|
|
36
37
|
signal: AbortSignal,
|
|
38
|
+
traceRecorder?: TraceRecorder,
|
|
37
39
|
): Promise<Extract<ChallengeSolution, { readonly form: "token" }>>;
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -231,6 +233,28 @@ function endpoint(baseUrl: string, path: string): string {
|
|
|
231
233
|
return `${baseUrl.replace(/\/+$/u, "")}/${path}`;
|
|
232
234
|
}
|
|
233
235
|
|
|
236
|
+
function spanErrorAttributes(
|
|
237
|
+
error: unknown,
|
|
238
|
+
phase: TwoCaptchaOperationPhase,
|
|
239
|
+
): Record<string, unknown> | undefined {
|
|
240
|
+
if (error instanceof ResolverVendorUnavailableError) {
|
|
241
|
+
return {
|
|
242
|
+
unavailability_reason: error.reason,
|
|
243
|
+
transport_phase: error.phase,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// The solve budget raises TwoCaptchaSolveTimeoutError inside the spanned closure and
|
|
247
|
+
// the outer catch converts it only after the span has been finalized, so the span has
|
|
248
|
+
// to recognize it here or slow solves lose exactly the attribution these spans add.
|
|
249
|
+
if (error instanceof TwoCaptchaSolveTimeoutError) {
|
|
250
|
+
return {
|
|
251
|
+
unavailability_reason: "timeout",
|
|
252
|
+
transport_phase: phase,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
234
258
|
export function createTwoCaptchaResolverVendorAdapter(
|
|
235
259
|
options: TwoCaptchaResolverVendorOptions,
|
|
236
260
|
): TwoCaptchaResolverVendorAdapter {
|
|
@@ -248,7 +272,7 @@ export function createTwoCaptchaResolverVendorAdapter(
|
|
|
248
272
|
return resolverVendorSupports(TWOCAPTCHA_VENDOR_ID, kind);
|
|
249
273
|
},
|
|
250
274
|
|
|
251
|
-
async solve(challenge, identity, callerSignal) {
|
|
275
|
+
async solve(challenge, identity, callerSignal, traceRecorder) {
|
|
252
276
|
const apiKey = options.apiKey?.trim();
|
|
253
277
|
if (!apiKey) {
|
|
254
278
|
throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "missing_credentials", {
|
|
@@ -285,62 +309,89 @@ export function createTwoCaptchaResolverVendorAdapter(
|
|
|
285
309
|
let phase: TwoCaptchaOperationPhase = "create_task";
|
|
286
310
|
|
|
287
311
|
try {
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
endpoint(baseUrl, "createTask"),
|
|
291
|
-
{
|
|
292
|
-
clientKey: apiKey,
|
|
293
|
-
task: {
|
|
294
|
-
type: proxy ? "RecaptchaV2Task" : "RecaptchaV2TaskProxyless",
|
|
295
|
-
websiteURL: challenge.pageUrl,
|
|
296
|
-
websiteKey: challenge.siteKey,
|
|
297
|
-
isInvisible: false,
|
|
298
|
-
...(identity ? { userAgent: identity.userAgent } : {}),
|
|
299
|
-
...(proxy ?? {}),
|
|
300
|
-
},
|
|
301
|
-
},
|
|
302
|
-
solveController.signal,
|
|
303
|
-
phase,
|
|
304
|
-
);
|
|
305
|
-
const taskId = taskIdFrom(createResult.payload);
|
|
306
|
-
if (!createResult.ok || createResult.payload.errorId !== 0 || taskId === undefined) {
|
|
307
|
-
throw unavailableForPayload(createResult.payload, phase);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
phase = "poll_result";
|
|
311
|
-
while (true) {
|
|
312
|
-
callerSignal.throwIfAborted();
|
|
313
|
-
const remainingMs = timeoutMs - (now() - startedAt);
|
|
314
|
-
if (remainingMs <= 0) throw new TwoCaptchaSolveTimeoutError();
|
|
315
|
-
await delay(Math.min(pollIntervalMs, remainingMs), solveController.signal);
|
|
316
|
-
callerSignal.throwIfAborted();
|
|
317
|
-
if (now() - startedAt >= timeoutMs) throw new TwoCaptchaSolveTimeoutError();
|
|
318
|
-
|
|
319
|
-
const pollResult = await postJson(
|
|
312
|
+
const createTask = async () => {
|
|
313
|
+
const createResult = await postJson(
|
|
320
314
|
fetchImpl,
|
|
321
|
-
endpoint(baseUrl, "
|
|
322
|
-
{
|
|
315
|
+
endpoint(baseUrl, "createTask"),
|
|
316
|
+
{
|
|
317
|
+
clientKey: apiKey,
|
|
318
|
+
task: {
|
|
319
|
+
type: proxy ? "RecaptchaV2Task" : "RecaptchaV2TaskProxyless",
|
|
320
|
+
websiteURL: challenge.pageUrl,
|
|
321
|
+
websiteKey: challenge.siteKey,
|
|
322
|
+
isInvisible: false,
|
|
323
|
+
...(identity ? { userAgent: identity.userAgent } : {}),
|
|
324
|
+
...(proxy ?? {}),
|
|
325
|
+
},
|
|
326
|
+
},
|
|
323
327
|
solveController.signal,
|
|
324
328
|
phase,
|
|
325
329
|
);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (pollResult.payload.status === "processing") continue;
|
|
330
|
-
if (pollResult.payload.status !== "ready") {
|
|
331
|
-
throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
|
|
332
|
-
phase,
|
|
333
|
-
});
|
|
330
|
+
const taskId = taskIdFrom(createResult.payload);
|
|
331
|
+
if (!createResult.ok || createResult.payload.errorId !== 0 || taskId === undefined) {
|
|
332
|
+
throw unavailableForPayload(createResult.payload, phase);
|
|
334
333
|
}
|
|
334
|
+
return taskId;
|
|
335
|
+
};
|
|
336
|
+
const taskId = traceRecorder
|
|
337
|
+
? await traceRecorder.runSpan("resolver.vendor.create_task", createTask, {
|
|
338
|
+
attributes: {
|
|
339
|
+
vendor: TWOCAPTCHA_VENDOR_ID,
|
|
340
|
+
challenge_kind: challenge.kind,
|
|
341
|
+
},
|
|
342
|
+
onError: (error) => spanErrorAttributes(error, "create_task"),
|
|
343
|
+
})
|
|
344
|
+
: await createTask();
|
|
335
345
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
346
|
+
phase = "poll_result";
|
|
347
|
+
const pollResult = async () => {
|
|
348
|
+
while (true) {
|
|
349
|
+
callerSignal.throwIfAborted();
|
|
350
|
+
const remainingMs = timeoutMs - (now() - startedAt);
|
|
351
|
+
if (remainingMs <= 0) throw new TwoCaptchaSolveTimeoutError();
|
|
352
|
+
await delay(Math.min(pollIntervalMs, remainingMs), solveController.signal);
|
|
353
|
+
callerSignal.throwIfAborted();
|
|
354
|
+
if (now() - startedAt >= timeoutMs) throw new TwoCaptchaSolveTimeoutError();
|
|
355
|
+
|
|
356
|
+
const pollResult = await postJson(
|
|
357
|
+
fetchImpl,
|
|
358
|
+
endpoint(baseUrl, "getTaskResult"),
|
|
359
|
+
{ clientKey: apiKey, taskId },
|
|
360
|
+
solveController.signal,
|
|
339
361
|
phase,
|
|
340
|
-
|
|
362
|
+
);
|
|
363
|
+
if (!pollResult.ok || pollResult.payload.errorId !== 0) {
|
|
364
|
+
throw unavailableForPayload(pollResult.payload, phase);
|
|
365
|
+
}
|
|
366
|
+
if (pollResult.payload.status === "processing") continue;
|
|
367
|
+
if (pollResult.payload.status !== "ready") {
|
|
368
|
+
throw new ResolverVendorUnavailableError(
|
|
369
|
+
TWOCAPTCHA_VENDOR_ID,
|
|
370
|
+
"transport_failure",
|
|
371
|
+
{ phase },
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const token = tokenFrom(pollResult.payload);
|
|
376
|
+
if (!token?.trim()) {
|
|
377
|
+
throw new ResolverVendorUnavailableError(
|
|
378
|
+
TWOCAPTCHA_VENDOR_ID,
|
|
379
|
+
"transport_failure",
|
|
380
|
+
{ phase },
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return { form: "token" as const, token };
|
|
341
384
|
}
|
|
342
|
-
|
|
343
|
-
|
|
385
|
+
};
|
|
386
|
+
return traceRecorder
|
|
387
|
+
? await traceRecorder.runSpan("resolver.vendor.poll_result", pollResult, {
|
|
388
|
+
attributes: {
|
|
389
|
+
vendor: TWOCAPTCHA_VENDOR_ID,
|
|
390
|
+
challenge_kind: challenge.kind,
|
|
391
|
+
},
|
|
392
|
+
onError: (error) => spanErrorAttributes(error, "poll_result"),
|
|
393
|
+
})
|
|
394
|
+
: await pollResult();
|
|
344
395
|
} catch (error) {
|
|
345
396
|
if (callerSignal.aborted) throw abortReason(callerSignal);
|
|
346
397
|
if (
|
package/src/runtime/resolver.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ProviderCache,
|
|
7
7
|
ProviderChallenge,
|
|
8
8
|
ProviderChallengeKind,
|
|
9
|
+
ProviderProxyMode,
|
|
9
10
|
ProviderResolverConfig,
|
|
10
11
|
ProviderResolverVendor,
|
|
11
12
|
ResolverContext,
|
|
@@ -29,14 +30,32 @@ import {
|
|
|
29
30
|
type ResolverVendorUnavailableReason,
|
|
30
31
|
resolverVendorSupports,
|
|
31
32
|
} from "./resolver-vendors/types.js";
|
|
33
|
+
import {
|
|
34
|
+
createUnsupportedResolverClient,
|
|
35
|
+
RESOLVER_INSTRUMENTATION_METADATA,
|
|
36
|
+
} from "./resolver-shared.js";
|
|
37
|
+
import {
|
|
38
|
+
APIFUSE__CDP_POOL__URL,
|
|
39
|
+
APIFUSE__RESOLVER__2CAPTCHA__API_KEY,
|
|
40
|
+
APIFUSE__RESOLVER__CAPMONSTER__API_KEY,
|
|
41
|
+
APIFUSE__RESOLVER__CAPSOLVER__API_KEY,
|
|
42
|
+
APIFUSE__RESOLVER__TIMEOUT_MS,
|
|
43
|
+
DEFAULT_RESOLVER_TIMEOUT_MS,
|
|
44
|
+
} from "./resolver-config.js";
|
|
32
45
|
import type { TraceRecorder } from "./trace.js";
|
|
33
46
|
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
export
|
|
39
|
-
|
|
47
|
+
export {
|
|
48
|
+
createUnsupportedResolverClient,
|
|
49
|
+
RESOLVER_INSTRUMENTATION_METADATA,
|
|
50
|
+
} from "./resolver-shared.js";
|
|
51
|
+
export {
|
|
52
|
+
APIFUSE__CDP_POOL__URL,
|
|
53
|
+
APIFUSE__RESOLVER__2CAPTCHA__API_KEY,
|
|
54
|
+
APIFUSE__RESOLVER__CAPMONSTER__API_KEY,
|
|
55
|
+
APIFUSE__RESOLVER__CAPSOLVER__API_KEY,
|
|
56
|
+
APIFUSE__RESOLVER__TIMEOUT_MS,
|
|
57
|
+
DEFAULT_RESOLVER_TIMEOUT_MS,
|
|
58
|
+
} from "./resolver-config.js";
|
|
40
59
|
|
|
41
60
|
type EnvLike = Record<string, string | undefined>;
|
|
42
61
|
|
|
@@ -75,6 +94,8 @@ type ResolverChainClient = ResolverContext & {
|
|
|
75
94
|
export interface ResolverRuntimeOptions {
|
|
76
95
|
readonly allowedHosts?: readonly string[];
|
|
77
96
|
readonly cache?: ProviderCache;
|
|
97
|
+
/** Provider-declared proxy intent. The SDK never accepts a caller-built identity. */
|
|
98
|
+
readonly proxyMode?: ProviderProxyMode;
|
|
78
99
|
/** Server-owned context/proxy scope used only for identity-bound cache entries. */
|
|
79
100
|
readonly identityScope?: string;
|
|
80
101
|
/** SDK-owned transport already bound to the resolved proxy lease and client profile. */
|
|
@@ -143,10 +164,6 @@ const SAFE_CAUSE_MESSAGE_WORDS: ReadonlySet<string> = new Set([
|
|
|
143
164
|
"writing",
|
|
144
165
|
]);
|
|
145
166
|
|
|
146
|
-
export const RESOLVER_INSTRUMENTATION_METADATA = Symbol.for(
|
|
147
|
-
"@apifuse/provider-sdk/runtime/resolver-instrumentation-metadata",
|
|
148
|
-
);
|
|
149
|
-
|
|
150
167
|
export type ResolverInstrumentationMetadata = {
|
|
151
168
|
readonly target: ResolverContext;
|
|
152
169
|
readonly traceRecorder: TraceRecorder;
|
|
@@ -340,7 +357,9 @@ function sanitizeCauseMessage(message: string): string {
|
|
|
340
357
|
.map((token) => {
|
|
341
358
|
if (token === "[REDACTED]" || /^[a-z][a-z\d+.-]*:\/\/[^\s]+$/i.test(token)) return token;
|
|
342
359
|
const word = token.replace(/^[^a-z\d]+|[^a-z\d]+$/gi, "");
|
|
343
|
-
return word.length > 0 &&
|
|
360
|
+
return word.length > 0 &&
|
|
361
|
+
word.length <= 32 &&
|
|
362
|
+
SAFE_CAUSE_MESSAGE_WORDS.has(word.toLowerCase())
|
|
344
363
|
? token
|
|
345
364
|
: "[REDACTED]";
|
|
346
365
|
});
|
|
@@ -691,6 +710,7 @@ function createResolverChainClient(options: {
|
|
|
691
710
|
readonly unavailableReason?: string;
|
|
692
711
|
readonly cache?: ProviderCache;
|
|
693
712
|
readonly identity?: ResolverIdentity;
|
|
713
|
+
readonly proxyMode?: ProviderProxyMode;
|
|
694
714
|
readonly identityScope?: string;
|
|
695
715
|
readonly transport?: ResolverVendorTransport;
|
|
696
716
|
readonly createTransport?: ResolverRuntimeOptions["createTransport"];
|
|
@@ -715,6 +735,19 @@ function createResolverChainClient(options: {
|
|
|
715
735
|
const supportingEntries = options.entries.filter((entry) => entry.supports(challenge.kind));
|
|
716
736
|
if (supportingEntries.length === 0) throwUnsupportedKind(challenge.kind);
|
|
717
737
|
signal.throwIfAborted();
|
|
738
|
+
// A required proxy policy is checked before the cache. Solutions minted under a
|
|
739
|
+
// previous release are shared and long-lived, so consulting the cache first would
|
|
740
|
+
// keep reporting success without a proxy identity until every old entry expired.
|
|
741
|
+
const requiredProxyIdentityMissing =
|
|
742
|
+
options.proxyMode === "required" && options.identity === undefined;
|
|
743
|
+
if (requiredProxyIdentityMissing) {
|
|
744
|
+
throwExhausted(
|
|
745
|
+
supportingEntries.map((entry) => ({
|
|
746
|
+
vendor: entry.id,
|
|
747
|
+
reason: "missing_proxy_identity" as const,
|
|
748
|
+
})),
|
|
749
|
+
);
|
|
750
|
+
}
|
|
718
751
|
if (options.cache && resolverChallengeIsCacheable(challenge)) {
|
|
719
752
|
const cached = await findCachedSolution(
|
|
720
753
|
options.cache,
|
|
@@ -804,6 +837,7 @@ export function createResolverClient(options: {
|
|
|
804
837
|
readonly unavailableReason?: string;
|
|
805
838
|
readonly cache?: ProviderCache;
|
|
806
839
|
readonly identity?: ResolverIdentity;
|
|
840
|
+
readonly proxyMode?: ProviderProxyMode;
|
|
807
841
|
readonly transport?: ResolverVendorTransport;
|
|
808
842
|
readonly createTransport?: ResolverRuntimeOptions["createTransport"];
|
|
809
843
|
readonly clientProfile?: string;
|
|
@@ -819,6 +853,7 @@ export function createResolverClient(options: {
|
|
|
819
853
|
unavailableReason: options.unavailableReason,
|
|
820
854
|
cache: options.cache,
|
|
821
855
|
identity: options.identity,
|
|
856
|
+
proxyMode: options.proxyMode,
|
|
822
857
|
transport: options.transport,
|
|
823
858
|
createTransport: options.createTransport,
|
|
824
859
|
clientProfile: options.clientProfile,
|
|
@@ -853,17 +888,6 @@ function resolveVendorAvailability(
|
|
|
853
888
|
: { vendor, available: false, reason: "missing_credentials" };
|
|
854
889
|
}
|
|
855
890
|
|
|
856
|
-
export function createUnsupportedResolverClient(reason?: string): ResolverContext {
|
|
857
|
-
return {
|
|
858
|
-
async solve() {
|
|
859
|
-
throw new ProviderError(reason ?? "Resolver runtime is not configured", {
|
|
860
|
-
code: "RESOLVER_UNAVAILABLE",
|
|
861
|
-
fix: "Declare resolver on the provider definition and configure vendor credentials.",
|
|
862
|
-
});
|
|
863
|
-
},
|
|
864
|
-
};
|
|
865
|
-
}
|
|
866
|
-
|
|
867
891
|
export function bindResolverSignal(
|
|
868
892
|
resolver: ResolverContext,
|
|
869
893
|
defaultSignal: AbortSignal | undefined,
|
|
@@ -921,6 +945,7 @@ function createResolverClientFromEnvInternal(
|
|
|
921
945
|
};
|
|
922
946
|
}),
|
|
923
947
|
cache: options.cache,
|
|
948
|
+
proxyMode: options.proxyMode,
|
|
924
949
|
identityScope: options.identityScope,
|
|
925
950
|
transport: options.transport,
|
|
926
951
|
createTransport: options.createTransport,
|
package/src/runtime/state.ts
CHANGED
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
|
|
19
19
|
const DEFAULT_REDIS_TIMEOUT_MS = 250;
|
|
20
20
|
const REDIS_STATE_PREFIX = "apifuse:provider-state:v2";
|
|
21
|
-
const LEGACY_REDIS_STATE_PREFIX = "apifuse:provider-state:v1";
|
|
22
21
|
const PROVIDER_SCOPE_DISCRIMINATOR = "scope:provider";
|
|
23
22
|
const MISSING_CONNECTION_SCOPE_DISCRIMINATOR = "scope:connection:missing";
|
|
24
23
|
const SET_WITH_QUOTA_SCRIPT = `
|
|
@@ -29,7 +28,6 @@ local index_ttl = tonumber(ARGV[4])
|
|
|
29
28
|
local envelope = ARGV[5]
|
|
30
29
|
|
|
31
30
|
redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
|
|
32
|
-
redis.call("ZREM", KEYS[2], KEYS[3])
|
|
33
31
|
local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
|
|
34
32
|
if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
|
|
35
33
|
return {0, false}
|
|
@@ -38,15 +36,11 @@ end
|
|
|
38
36
|
redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
|
|
39
37
|
redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
|
|
40
38
|
redis.call("PEXPIRE", KEYS[2], index_ttl)
|
|
41
|
-
if ARGV[6] == "1" then
|
|
42
|
-
redis.call("SET", KEYS[3], ARGV[7], "PX", index_ttl)
|
|
43
|
-
end
|
|
44
39
|
return {1, envelope}
|
|
45
40
|
`;
|
|
46
41
|
|
|
47
42
|
const COMPARE_AND_SET_WITH_QUOTA_SCRIPT = `
|
|
48
43
|
local current = redis.call("GET", KEYS[1])
|
|
49
|
-
local scoped_present = current and true or false
|
|
50
44
|
if current then
|
|
51
45
|
local ok, decoded = pcall(cjson.decode, current)
|
|
52
46
|
if ok and type(decoded) == "table" and decoded.deleted == true then
|
|
@@ -59,42 +53,6 @@ local max_entries = tonumber(ARGV[3])
|
|
|
59
53
|
local expires_at = tonumber(ARGV[4])
|
|
60
54
|
local index_ttl = tonumber(ARGV[5])
|
|
61
55
|
local envelope = ARGV[6]
|
|
62
|
-
local allow_legacy_claim = ARGV[7] == "1"
|
|
63
|
-
local legacy_tombstone = ARGV[8]
|
|
64
|
-
|
|
65
|
-
if not scoped_present then
|
|
66
|
-
local legacy = redis.call("GET", KEYS[3])
|
|
67
|
-
if legacy then
|
|
68
|
-
local ok, decoded = pcall(cjson.decode, legacy)
|
|
69
|
-
if ok and type(decoded) == "table" and decoded.deleted == true then
|
|
70
|
-
legacy = false
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
if legacy and not allow_legacy_claim then
|
|
74
|
-
return {-1, false}
|
|
75
|
-
end
|
|
76
|
-
if legacy then
|
|
77
|
-
local legacy_ttl = redis.call("PTTL", KEYS[3])
|
|
78
|
-
if legacy_ttl == -1 then
|
|
79
|
-
legacy_ttl = index_ttl
|
|
80
|
-
elseif legacy_ttl < 1 then
|
|
81
|
-
legacy_ttl = 1
|
|
82
|
-
end
|
|
83
|
-
if legacy_ttl > 0 then
|
|
84
|
-
redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
|
|
85
|
-
redis.call("ZREM", KEYS[2], KEYS[3])
|
|
86
|
-
local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
|
|
87
|
-
if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
|
|
88
|
-
return {0, false}
|
|
89
|
-
end
|
|
90
|
-
redis.call("SET", KEYS[1], legacy, "PX", legacy_ttl)
|
|
91
|
-
redis.call("ZADD", KEYS[2], now + legacy_ttl, KEYS[1])
|
|
92
|
-
redis.call("PEXPIRE", KEYS[2], index_ttl)
|
|
93
|
-
redis.call("SET", KEYS[3], legacy_tombstone, "PX", index_ttl)
|
|
94
|
-
current = legacy
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
56
|
|
|
99
57
|
local current_version = 0
|
|
100
58
|
local current_decoded = false
|
|
@@ -111,7 +69,6 @@ if current_version ~= tonumber(ARGV[1]) then
|
|
|
111
69
|
end
|
|
112
70
|
|
|
113
71
|
redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
|
|
114
|
-
redis.call("ZREM", KEYS[2], KEYS[3])
|
|
115
72
|
local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
|
|
116
73
|
if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
|
|
117
74
|
return {0, false}
|
|
@@ -125,63 +82,15 @@ end
|
|
|
125
82
|
redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
|
|
126
83
|
redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
|
|
127
84
|
redis.call("PEXPIRE", KEYS[2], index_ttl)
|
|
128
|
-
if allow_legacy_claim then
|
|
129
|
-
redis.call("SET", KEYS[3], legacy_tombstone, "PX", index_ttl)
|
|
130
|
-
end
|
|
131
85
|
return {1, envelope}
|
|
132
86
|
`;
|
|
133
87
|
|
|
134
88
|
const DELETE_WITH_INDEX_SCRIPT = `
|
|
135
89
|
redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2])
|
|
136
|
-
|
|
137
|
-
redis.call("SET", KEYS[3], ARGV[1], "PX", ARGV[2])
|
|
138
|
-
end
|
|
139
|
-
redis.call("ZREM", KEYS[2], KEYS[1], KEYS[3])
|
|
90
|
+
redis.call("ZREM", KEYS[2], KEYS[1])
|
|
140
91
|
return 1
|
|
141
92
|
`;
|
|
142
93
|
|
|
143
|
-
// A v1 key has no connection discriminator, so it cannot safely be exposed to
|
|
144
|
-
// every v2 scope. The first concrete connection (or explicit provider scope) to
|
|
145
|
-
// request a key atomically adopts that value and tombstones v1. The missing-
|
|
146
|
-
// connection sentinel never claims legacy state.
|
|
147
|
-
const CLAIM_LEGACY_SCRIPT = `
|
|
148
|
-
local scoped = redis.call("GET", KEYS[1])
|
|
149
|
-
if scoped then
|
|
150
|
-
return {1, scoped}
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
local legacy = redis.call("GET", KEYS[3])
|
|
154
|
-
if not legacy then
|
|
155
|
-
return {1, false}
|
|
156
|
-
end
|
|
157
|
-
local ok, decoded = pcall(cjson.decode, legacy)
|
|
158
|
-
if ok and type(decoded) == "table" and decoded.deleted == true then
|
|
159
|
-
return {1, false}
|
|
160
|
-
end
|
|
161
|
-
local legacy_ttl = redis.call("PTTL", KEYS[3])
|
|
162
|
-
if legacy_ttl == -1 then
|
|
163
|
-
legacy_ttl = tonumber(ARGV[3])
|
|
164
|
-
elseif legacy_ttl < 1 then
|
|
165
|
-
legacy_ttl = 1
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
local now = tonumber(ARGV[1])
|
|
169
|
-
local max_entries = tonumber(ARGV[2])
|
|
170
|
-
local index_ttl = tonumber(ARGV[3])
|
|
171
|
-
redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
|
|
172
|
-
redis.call("ZREM", KEYS[2], KEYS[3])
|
|
173
|
-
local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
|
|
174
|
-
if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
|
|
175
|
-
return {0, false}
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
redis.call("SET", KEYS[1], legacy, "PX", legacy_ttl)
|
|
179
|
-
redis.call("ZADD", KEYS[2], now + legacy_ttl, KEYS[1])
|
|
180
|
-
redis.call("PEXPIRE", KEYS[2], index_ttl)
|
|
181
|
-
redis.call("SET", KEYS[3], ARGV[4], "PX", index_ttl)
|
|
182
|
-
return {1, legacy}
|
|
183
|
-
`;
|
|
184
|
-
|
|
185
94
|
type RedisProviderRuntimeStateOptions = {
|
|
186
95
|
readonly redisUrl: string;
|
|
187
96
|
readonly providerId?: string;
|
|
@@ -268,18 +177,6 @@ function providerStateKey(
|
|
|
268
177
|
return `${providerStatePrefix(providerId, namespace, scopeDiscriminator)}:${key}`;
|
|
269
178
|
}
|
|
270
179
|
|
|
271
|
-
function legacyProviderStatePrefix(providerId: string | undefined, namespace: string): string {
|
|
272
|
-
return `${LEGACY_REDIS_STATE_PREFIX}:${providerId ?? "default"}:${namespace}`;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function legacyProviderStateKey(
|
|
276
|
-
providerId: string | undefined,
|
|
277
|
-
namespace: string,
|
|
278
|
-
key: string,
|
|
279
|
-
): string {
|
|
280
|
-
return `${legacyProviderStatePrefix(providerId, namespace)}:${key}`;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
180
|
function publicStateKey(redisKey: string, prefixes: readonly string[]): string {
|
|
284
181
|
for (const prefix of prefixes) {
|
|
285
182
|
const prefixWithSeparator = `${prefix}:`;
|
|
@@ -391,10 +288,6 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
|
|
|
391
288
|
);
|
|
392
289
|
}
|
|
393
290
|
|
|
394
|
-
private legacyRedisKey(key: string): string {
|
|
395
|
-
return legacyProviderStateKey(this.providerId, this.namespaceName, key);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
291
|
private statePrefix(): string {
|
|
399
292
|
return providerStatePrefix(
|
|
400
293
|
this.providerId,
|
|
@@ -418,10 +311,6 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
|
|
|
418
311
|
return `${REDIS_STATE_PREFIX}:index:${namespaceIdentity}`;
|
|
419
312
|
}
|
|
420
313
|
|
|
421
|
-
private canClaimLegacy(): boolean {
|
|
422
|
-
return this.scopeDiscriminator !== MISSING_CONNECTION_SCOPE_DISCRIMINATOR;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
314
|
private async indexedKeys(limit: number): Promise<string[]> {
|
|
426
315
|
await requireRedisReady(this.backend.redis);
|
|
427
316
|
const now = Date.now();
|
|
@@ -440,27 +329,7 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
|
|
|
440
329
|
|
|
441
330
|
private async readRaw(key: string): Promise<string | null> {
|
|
442
331
|
await requireRedisReady(this.backend.redis);
|
|
443
|
-
|
|
444
|
-
return await withRequiredRedis(() => this.backend.redis.get(this.redisKey(key)));
|
|
445
|
-
}
|
|
446
|
-
const tombstone = JSON.stringify({ deleted: true } satisfies RedisStateTombstone);
|
|
447
|
-
const result = await withRequiredRedis(() =>
|
|
448
|
-
this.backend.redis.eval(
|
|
449
|
-
CLAIM_LEGACY_SCRIPT,
|
|
450
|
-
3,
|
|
451
|
-
this.redisKey(key),
|
|
452
|
-
this.indexKey(),
|
|
453
|
-
this.legacyRedisKey(key),
|
|
454
|
-
String(Date.now()),
|
|
455
|
-
String(this.options.maxEntries),
|
|
456
|
-
String(parseStateDurationMs(this.options.maxTtl)),
|
|
457
|
-
tombstone,
|
|
458
|
-
),
|
|
459
|
-
);
|
|
460
|
-
if (Array.isArray(result) && Number(result[0]) === 0) {
|
|
461
|
-
throw this.quotaExceeded();
|
|
462
|
-
}
|
|
463
|
-
return Array.isArray(result) && typeof result[1] === "string" ? result[1] : null;
|
|
332
|
+
return await withRequiredRedis(() => this.backend.redis.get(this.redisKey(key)));
|
|
464
333
|
}
|
|
465
334
|
|
|
466
335
|
private enforceValueSize(value: unknown): void {
|
|
@@ -534,17 +403,14 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
|
|
|
534
403
|
const result = await withRequiredRedis(() =>
|
|
535
404
|
this.backend.redis.eval(
|
|
536
405
|
SET_WITH_QUOTA_SCRIPT,
|
|
537
|
-
|
|
406
|
+
2,
|
|
538
407
|
this.redisKey(key),
|
|
539
408
|
this.indexKey(),
|
|
540
|
-
this.legacyRedisKey(key),
|
|
541
409
|
String(Date.now()),
|
|
542
410
|
String(this.options.maxEntries),
|
|
543
411
|
String(timing.expiresAtMs),
|
|
544
412
|
String(timing.indexTtlMs),
|
|
545
413
|
JSON.stringify(envelope),
|
|
546
|
-
this.canClaimLegacy() ? "1" : "0",
|
|
547
|
-
JSON.stringify({ deleted: true } satisfies RedisStateTombstone),
|
|
548
414
|
),
|
|
549
415
|
);
|
|
550
416
|
if (!Array.isArray(result) || Number(result[0]) !== 1) {
|
|
@@ -590,18 +456,15 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
|
|
|
590
456
|
const result = await withRequiredRedis(() =>
|
|
591
457
|
this.backend.redis.eval(
|
|
592
458
|
COMPARE_AND_SET_WITH_QUOTA_SCRIPT,
|
|
593
|
-
|
|
459
|
+
2,
|
|
594
460
|
this.redisKey(key),
|
|
595
461
|
this.indexKey(),
|
|
596
|
-
this.legacyRedisKey(key),
|
|
597
462
|
String(expectedVersion),
|
|
598
463
|
String(Date.now()),
|
|
599
464
|
String(this.options.maxEntries),
|
|
600
465
|
String(timing.expiresAtMs),
|
|
601
466
|
String(timing.indexTtlMs),
|
|
602
467
|
JSON.stringify(envelope),
|
|
603
|
-
this.canClaimLegacy() ? "1" : "0",
|
|
604
|
-
JSON.stringify({ deleted: true } satisfies RedisStateTombstone),
|
|
605
468
|
),
|
|
606
469
|
);
|
|
607
470
|
if (Array.isArray(result) && Number(result[0]) === 0) {
|
|
@@ -627,13 +490,11 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
|
|
|
627
490
|
await withRequiredRedis(() =>
|
|
628
491
|
this.backend.redis.eval(
|
|
629
492
|
DELETE_WITH_INDEX_SCRIPT,
|
|
630
|
-
|
|
493
|
+
2,
|
|
631
494
|
this.redisKey(key),
|
|
632
495
|
this.indexKey(),
|
|
633
|
-
this.legacyRedisKey(key),
|
|
634
496
|
tombstone,
|
|
635
497
|
String(parseStateDurationMs(this.options.maxTtl)),
|
|
636
|
-
this.canClaimLegacy() ? "1" : "0",
|
|
637
498
|
),
|
|
638
499
|
);
|
|
639
500
|
}
|