@apifuse/provider-sdk 2.1.0-beta.5 → 2.1.0-beta.6
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 +6 -0
- package/README.md +2 -2
- package/SUBMISSION.md +2 -1
- package/bin/apifuse-check.ts +60 -6
- package/bin/apifuse-dev.ts +48 -5
- package/bin/apifuse-perf.ts +50 -11
- package/bin/apifuse-record.ts +35 -11
- package/bin/apifuse-submit-check.ts +1425 -3
- package/package.json +107 -92
- package/src/ceremonies/index.ts +8 -2
- package/src/choice-token.ts +1 -0
- package/src/cli/commands.ts +8 -5
- package/src/cli/create.ts +28 -0
- package/src/cli/templates/provider/operations/ping.ts.tpl +3 -2
- package/src/cli/templates/provider/schemas/ping.ts.tpl +8 -0
- package/src/config/loader.ts +19 -1
- package/src/contract-json.ts +75 -0
- package/src/contract-serialization.ts +89 -0
- package/src/contract-types.ts +52 -0
- package/src/contract.ts +215 -0
- package/src/define.ts +37 -2
- package/src/errors.ts +15 -0
- package/src/i18n/catalog.ts +156 -0
- package/src/index.ts +22 -1
- package/src/lint.ts +256 -37
- package/src/provider.ts +45 -2
- package/src/runtime/browser.ts +685 -30
- package/src/runtime/cache.ts +35 -89
- package/src/runtime/choice.ts +760 -0
- package/src/runtime/executor.ts +19 -2
- package/src/runtime/redis.ts +116 -0
- package/src/runtime/state.ts +487 -0
- package/src/runtime/stealth.ts +8 -1
- package/src/server/serve.ts +361 -46
- package/src/server/types.ts +2 -0
- package/src/testing/run.ts +16 -3
- package/src/types.ts +209 -6
package/src/runtime/executor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ProviderError } from "../errors";
|
|
1
|
+
import { ProviderError, SessionExpiredError } from "../errors";
|
|
2
2
|
import { parseSchema } from "../schema";
|
|
3
3
|
import type { ProviderContext, ProviderDefinition } from "../types";
|
|
4
4
|
|
|
@@ -51,7 +51,24 @@ export async function executeOperation(
|
|
|
51
51
|
Promise.resolve(operation.handler(ctx, validatedInput)),
|
|
52
52
|
);
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
let result: unknown;
|
|
55
|
+
try {
|
|
56
|
+
result = await execute();
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// Session expiry is renewed by Credential Service via the /auth/refresh
|
|
59
|
+
// route, NOT in-process here: this executor cannot mutate ctx.credential,
|
|
60
|
+
// so an in-process retry would just repeat the call with the same stale
|
|
61
|
+
// credential (and risk repeating partial side-effects). Instead we surface
|
|
62
|
+
// the expiry so Credential Service refreshes and re-drives the operation
|
|
63
|
+
// with a fresh credential. `retryOnAuthRefresh` declares that this
|
|
64
|
+
// operation is safe to re-drive after refresh, which we signal by marking
|
|
65
|
+
// the surfaced error retryable; non-idempotent operations (the default)
|
|
66
|
+
// stay non-retryable so they are not auto-re-driven. See design.md §4.3 D3.
|
|
67
|
+
if (error instanceof SessionExpiredError && operation.retryOnAuthRefresh) {
|
|
68
|
+
throw new SessionExpiredError(error.message, { retryable: true });
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
55
72
|
|
|
56
73
|
if (isStreamingOperation(provider, operationId)) {
|
|
57
74
|
return result;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import Redis from "ioredis";
|
|
2
|
+
|
|
3
|
+
export type ProviderRedisClient = Redis;
|
|
4
|
+
|
|
5
|
+
export type ProviderRedisClientOptions = {
|
|
6
|
+
readonly redisUrl: string;
|
|
7
|
+
readonly timeoutMs: number;
|
|
8
|
+
readonly onError: () => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type RedisTimeoutOptions<T> = {
|
|
12
|
+
readonly timeoutMs: number;
|
|
13
|
+
readonly onTimeout: () => T;
|
|
14
|
+
readonly onError?: (error: unknown) => T;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function createProviderRedisClient(
|
|
18
|
+
options: ProviderRedisClientOptions,
|
|
19
|
+
): ProviderRedisClient {
|
|
20
|
+
const redis = new Redis(options.redisUrl, {
|
|
21
|
+
connectTimeout: options.timeoutMs,
|
|
22
|
+
enableOfflineQueue: false,
|
|
23
|
+
lazyConnect: true,
|
|
24
|
+
maxRetriesPerRequest: 0,
|
|
25
|
+
retryStrategy: () => null,
|
|
26
|
+
});
|
|
27
|
+
redis.on("error", options.onError);
|
|
28
|
+
return redis;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function withRedisTimeout<T>(
|
|
32
|
+
operation: () => Promise<T>,
|
|
33
|
+
options: RedisTimeoutOptions<T>,
|
|
34
|
+
): Promise<T> {
|
|
35
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
36
|
+
try {
|
|
37
|
+
const timeout = new Promise<T>((resolve, reject) => {
|
|
38
|
+
timeoutId = setTimeout(() => {
|
|
39
|
+
try {
|
|
40
|
+
resolve(options.onTimeout());
|
|
41
|
+
} catch (error) {
|
|
42
|
+
reject(error);
|
|
43
|
+
}
|
|
44
|
+
}, options.timeoutMs);
|
|
45
|
+
});
|
|
46
|
+
const operationResult = options.onError
|
|
47
|
+
? operation().catch(options.onError)
|
|
48
|
+
: operation();
|
|
49
|
+
return await Promise.race([operationResult, timeout]);
|
|
50
|
+
} finally {
|
|
51
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function redisStatus(redis: ProviderRedisClient): string {
|
|
56
|
+
return redis.status;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function waitForRedisReady(
|
|
60
|
+
redis: ProviderRedisClient,
|
|
61
|
+
timeoutMs: number,
|
|
62
|
+
): Promise<boolean> {
|
|
63
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
64
|
+
let settled = false;
|
|
65
|
+
|
|
66
|
+
return await new Promise<boolean>((resolve) => {
|
|
67
|
+
const cleanup = () => {
|
|
68
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
69
|
+
redis.off("ready", onReady);
|
|
70
|
+
redis.off("close", onUnavailable);
|
|
71
|
+
redis.off("end", onUnavailable);
|
|
72
|
+
redis.off("error", onUnavailable);
|
|
73
|
+
};
|
|
74
|
+
const finish = (ready: boolean) => {
|
|
75
|
+
if (settled) return;
|
|
76
|
+
settled = true;
|
|
77
|
+
cleanup();
|
|
78
|
+
resolve(ready);
|
|
79
|
+
};
|
|
80
|
+
const onReady = () => finish(true);
|
|
81
|
+
const onUnavailable = () => finish(false);
|
|
82
|
+
|
|
83
|
+
timeoutId = setTimeout(
|
|
84
|
+
() => finish(redisStatus(redis) === "ready"),
|
|
85
|
+
timeoutMs,
|
|
86
|
+
);
|
|
87
|
+
redis.once("ready", onReady);
|
|
88
|
+
redis.once("close", onUnavailable);
|
|
89
|
+
redis.once("end", onUnavailable);
|
|
90
|
+
redis.once("error", onUnavailable);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function ensureRedisReady(
|
|
95
|
+
redis: ProviderRedisClient,
|
|
96
|
+
timeoutMs: number,
|
|
97
|
+
): Promise<boolean> {
|
|
98
|
+
if (redisStatus(redis) === "ready") return true;
|
|
99
|
+
|
|
100
|
+
if (redisStatus(redis) === "wait" || redisStatus(redis) === "end") {
|
|
101
|
+
const connected = await withRedisTimeout(
|
|
102
|
+
async () => {
|
|
103
|
+
await redis.connect();
|
|
104
|
+
return true;
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
timeoutMs,
|
|
108
|
+
onTimeout: () => false,
|
|
109
|
+
onError: () => false,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
return connected && redisStatus(redis) === "ready";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return await waitForRedisReady(redis, timeoutMs);
|
|
116
|
+
}
|
package/src/runtime/state.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { providerStateRedisUrlFromEnv } from "../config/loader";
|
|
1
2
|
import { ProviderError } from "../errors";
|
|
2
3
|
import type {
|
|
3
4
|
ProviderRuntimeState,
|
|
@@ -7,6 +8,340 @@ import type {
|
|
|
7
8
|
StateValue,
|
|
8
9
|
StateWriteOptions,
|
|
9
10
|
} from "../types";
|
|
11
|
+
import {
|
|
12
|
+
createProviderRedisClient,
|
|
13
|
+
ensureRedisReady,
|
|
14
|
+
type ProviderRedisClient,
|
|
15
|
+
withRedisTimeout,
|
|
16
|
+
} from "./redis";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_REDIS_TIMEOUT_MS = 250;
|
|
19
|
+
const REDIS_STATE_PREFIX = "apifuse:provider-state:v1";
|
|
20
|
+
|
|
21
|
+
type RedisProviderRuntimeStateOptions = {
|
|
22
|
+
readonly redisUrl: string;
|
|
23
|
+
readonly providerId?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type RedisStateEnvelope = {
|
|
27
|
+
readonly value: unknown;
|
|
28
|
+
readonly version: number;
|
|
29
|
+
readonly expiresAt: string;
|
|
30
|
+
readonly createdAt: string;
|
|
31
|
+
readonly updatedAt: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type RedisBackend = {
|
|
35
|
+
readonly redis: ProviderRedisClient;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const redisBackends = new Map<string, RedisBackend>();
|
|
39
|
+
|
|
40
|
+
function getRedisBackend(redisUrl: string): RedisBackend {
|
|
41
|
+
const existing = redisBackends.get(redisUrl);
|
|
42
|
+
if (existing) return existing;
|
|
43
|
+
const redis = createProviderRedisClient({
|
|
44
|
+
redisUrl,
|
|
45
|
+
timeoutMs: DEFAULT_REDIS_TIMEOUT_MS,
|
|
46
|
+
onError: () => {
|
|
47
|
+
// Runtime state operations fail closed at their call sites. Avoid noisy
|
|
48
|
+
// unhandled Redis errors from background reconnect attempts.
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const backend = { redis };
|
|
52
|
+
redisBackends.set(redisUrl, backend);
|
|
53
|
+
return backend;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function withRequiredRedis<T>(operation: () => Promise<T>): Promise<T> {
|
|
57
|
+
return await withRedisTimeout(operation, {
|
|
58
|
+
timeoutMs: DEFAULT_REDIS_TIMEOUT_MS,
|
|
59
|
+
onTimeout: () => {
|
|
60
|
+
throw new UnsupportedProviderStateError(
|
|
61
|
+
"Provider runtime state Redis timed out",
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
onError: () => {
|
|
65
|
+
throw new UnsupportedProviderStateError(
|
|
66
|
+
"Provider runtime state Redis is unavailable",
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function requireRedisReady(redis: ProviderRedisClient): Promise<void> {
|
|
73
|
+
if (await ensureRedisReady(redis, DEFAULT_REDIS_TIMEOUT_MS)) return;
|
|
74
|
+
throw new UnsupportedProviderStateError(
|
|
75
|
+
"Provider runtime state Redis is unavailable",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function providerStatePrefix(
|
|
80
|
+
providerId: string | undefined,
|
|
81
|
+
namespace: string,
|
|
82
|
+
): string {
|
|
83
|
+
return `${REDIS_STATE_PREFIX}:${providerId ?? "default"}:${namespace}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function providerStateKey(
|
|
87
|
+
providerId: string | undefined,
|
|
88
|
+
namespace: string,
|
|
89
|
+
key: string,
|
|
90
|
+
): string {
|
|
91
|
+
return `${providerStatePrefix(providerId, namespace)}:${key}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function publicStateKey(
|
|
95
|
+
providerId: string | undefined,
|
|
96
|
+
namespace: string,
|
|
97
|
+
redisKey: string,
|
|
98
|
+
): string {
|
|
99
|
+
const prefix = `${providerStatePrefix(providerId, namespace)}:`;
|
|
100
|
+
return redisKey.startsWith(prefix) ? redisKey.slice(prefix.length) : redisKey;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseStateDurationMs(ttl: StateWriteOptions["ttl"]): number {
|
|
104
|
+
const match = /^(\d+)(ms|s|m|h|d)$/.exec(ttl ?? "1h");
|
|
105
|
+
if (!match) return 3_600_000;
|
|
106
|
+
const amount = Number(match[1]);
|
|
107
|
+
const unit = match[2];
|
|
108
|
+
const multiplier =
|
|
109
|
+
unit === "ms"
|
|
110
|
+
? 1
|
|
111
|
+
: unit === "s"
|
|
112
|
+
? 1_000
|
|
113
|
+
: unit === "m"
|
|
114
|
+
? 60_000
|
|
115
|
+
: unit === "h"
|
|
116
|
+
? 3_600_000
|
|
117
|
+
: 86_400_000;
|
|
118
|
+
return Math.max(1, amount * multiplier);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolveExpiresAt(ttl: StateWriteOptions["ttl"]): string {
|
|
122
|
+
return new Date(Date.now() + parseStateDurationMs(ttl)).toISOString();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function envelopeFromJson(
|
|
126
|
+
key: string,
|
|
127
|
+
raw: string | null,
|
|
128
|
+
// biome-ignore lint/suspicious/noExplicitAny: state envelopes deserialize caller-owned generic values.
|
|
129
|
+
): StateValue<any> | null {
|
|
130
|
+
if (!raw) return null;
|
|
131
|
+
const parsed: unknown = JSON.parse(raw);
|
|
132
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const record: Record<string, unknown> = Object.fromEntries(
|
|
136
|
+
Object.entries(parsed),
|
|
137
|
+
);
|
|
138
|
+
if (
|
|
139
|
+
typeof record.version !== "number" ||
|
|
140
|
+
typeof record.expiresAt !== "string" ||
|
|
141
|
+
typeof record.createdAt !== "string" ||
|
|
142
|
+
typeof record.updatedAt !== "string" ||
|
|
143
|
+
!("value" in record)
|
|
144
|
+
) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
key,
|
|
149
|
+
value: record.value,
|
|
150
|
+
version: record.version,
|
|
151
|
+
expiresAt: record.expiresAt,
|
|
152
|
+
createdAt: record.createdAt,
|
|
153
|
+
updatedAt: record.updatedAt,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function redisEnvelope(
|
|
158
|
+
value: unknown,
|
|
159
|
+
version: number,
|
|
160
|
+
createdAt: string,
|
|
161
|
+
expiresAt: string,
|
|
162
|
+
): RedisStateEnvelope {
|
|
163
|
+
const updatedAt = new Date().toISOString();
|
|
164
|
+
return { value, version, expiresAt, createdAt, updatedAt };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
class RedisProviderStateNamespace implements ProviderStateNamespace {
|
|
168
|
+
constructor(
|
|
169
|
+
private readonly backend: RedisBackend,
|
|
170
|
+
private readonly providerId: string | undefined,
|
|
171
|
+
private readonly namespaceName: string,
|
|
172
|
+
private readonly options: StateNamespaceOptions,
|
|
173
|
+
) {}
|
|
174
|
+
|
|
175
|
+
private redisKey(key: string): string {
|
|
176
|
+
return providerStateKey(this.providerId, this.namespaceName, key);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private prefix(): string {
|
|
180
|
+
return `${providerStatePrefix(this.providerId, this.namespaceName)}:`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async activeKeys(): Promise<string[]> {
|
|
184
|
+
await requireRedisReady(this.backend.redis);
|
|
185
|
+
return await withRequiredRedis(() =>
|
|
186
|
+
this.backend.redis.keys(`${this.prefix()}*`),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private enforceValueSize(value: unknown): void {
|
|
191
|
+
const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
192
|
+
if (bytes > this.options.maxValueBytes) {
|
|
193
|
+
throw new UnsupportedProviderStateError(
|
|
194
|
+
`Provider runtime state value exceeds maxValueBytes (${bytes} > ${this.options.maxValueBytes})`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private async enforceMaxEntries(key: string): Promise<void> {
|
|
200
|
+
const keys = await this.activeKeys();
|
|
201
|
+
const redisKey = this.redisKey(key);
|
|
202
|
+
const otherKeys = keys.filter((candidate) => candidate !== redisKey);
|
|
203
|
+
if (otherKeys.length >= this.options.maxEntries) {
|
|
204
|
+
throw new UnsupportedProviderStateError(
|
|
205
|
+
`Provider runtime state namespace quota exceeded (${otherKeys.length + 1} > ${this.options.maxEntries})`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async list<T>(options?: {
|
|
211
|
+
limit?: number;
|
|
212
|
+
prefix?: string;
|
|
213
|
+
}): Promise<StateValue<T>[]> {
|
|
214
|
+
const keys = (await this.activeKeys()).filter((key) => {
|
|
215
|
+
const publicKey = publicStateKey(
|
|
216
|
+
this.providerId,
|
|
217
|
+
this.namespaceName,
|
|
218
|
+
key,
|
|
219
|
+
);
|
|
220
|
+
return options?.prefix ? publicKey.startsWith(options.prefix) : true;
|
|
221
|
+
});
|
|
222
|
+
const limited = keys.slice(0, Math.max(0, options?.limit ?? keys.length));
|
|
223
|
+
if (limited.length === 0) return [];
|
|
224
|
+
const values = await withRequiredRedis(() =>
|
|
225
|
+
this.backend.redis.mget(limited),
|
|
226
|
+
);
|
|
227
|
+
return values.flatMap((raw, index) => {
|
|
228
|
+
const key = limited[index];
|
|
229
|
+
if (!key) return [];
|
|
230
|
+
const value = envelopeFromJson(
|
|
231
|
+
publicStateKey(this.providerId, this.namespaceName, key),
|
|
232
|
+
raw,
|
|
233
|
+
);
|
|
234
|
+
return value ? [value] : [];
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async get<T>(key: string): Promise<StateValue<T> | null> {
|
|
239
|
+
await requireRedisReady(this.backend.redis);
|
|
240
|
+
const raw = await withRequiredRedis(() =>
|
|
241
|
+
this.backend.redis.get(this.redisKey(key)),
|
|
242
|
+
);
|
|
243
|
+
return envelopeFromJson(key, raw);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async set<T>(
|
|
247
|
+
key: string,
|
|
248
|
+
value: T,
|
|
249
|
+
options?: StateWriteOptions,
|
|
250
|
+
): Promise<StateValue<T>> {
|
|
251
|
+
this.enforceValueSize(value);
|
|
252
|
+
await this.enforceMaxEntries(key);
|
|
253
|
+
const current = await this.get<T>(key);
|
|
254
|
+
const createdAt = current?.createdAt ?? new Date().toISOString();
|
|
255
|
+
const version = (current?.version ?? 0) + 1;
|
|
256
|
+
const ttl = options?.ttl ?? this.options.defaultTtl;
|
|
257
|
+
const ttlMs = parseStateDurationMs(ttl);
|
|
258
|
+
const expiresAt = resolveExpiresAt(ttl);
|
|
259
|
+
const envelope = redisEnvelope(value, version, createdAt, expiresAt);
|
|
260
|
+
await withRequiredRedis(() =>
|
|
261
|
+
this.backend.redis.set(
|
|
262
|
+
this.redisKey(key),
|
|
263
|
+
JSON.stringify(envelope),
|
|
264
|
+
"PX",
|
|
265
|
+
ttlMs,
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
return {
|
|
269
|
+
key,
|
|
270
|
+
value,
|
|
271
|
+
version,
|
|
272
|
+
expiresAt,
|
|
273
|
+
createdAt,
|
|
274
|
+
updatedAt: envelope.updatedAt,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async patch<T extends Record<string, unknown>>(
|
|
279
|
+
key: string,
|
|
280
|
+
partial: Partial<T>,
|
|
281
|
+
options?: StateWriteOptions,
|
|
282
|
+
): Promise<StateValue<T>> {
|
|
283
|
+
const current = (await this.get<Record<string, unknown>>(key))?.value ?? {};
|
|
284
|
+
// biome-ignore lint/suspicious/noExplicitAny: patch preserves the caller-provided generic state shape.
|
|
285
|
+
const merged: any = { ...current, ...partial };
|
|
286
|
+
return await this.set<T>(key, merged, options);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async compareAndSet<T>(
|
|
290
|
+
key: string,
|
|
291
|
+
expectedVersion: number,
|
|
292
|
+
value: T,
|
|
293
|
+
options?: StateWriteOptions,
|
|
294
|
+
): Promise<StateCasResult<T>> {
|
|
295
|
+
this.enforceValueSize(value);
|
|
296
|
+
const current = await this.get<T>(key);
|
|
297
|
+
if ((current?.version ?? 0) !== expectedVersion) {
|
|
298
|
+
return { ok: false, current };
|
|
299
|
+
}
|
|
300
|
+
return { ok: true, value: await this.set(key, value, options) };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async delete(key: string): Promise<void> {
|
|
304
|
+
await requireRedisReady(this.backend.redis);
|
|
305
|
+
await withRequiredRedis(() => this.backend.redis.del(this.redisKey(key)));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async increment(
|
|
309
|
+
key: string,
|
|
310
|
+
field: string,
|
|
311
|
+
delta = 1,
|
|
312
|
+
options?: StateWriteOptions,
|
|
313
|
+
): Promise<StateValue<Record<string, unknown>>> {
|
|
314
|
+
const current = (await this.get<Record<string, unknown>>(key))?.value ?? {};
|
|
315
|
+
const previous = typeof current[field] === "number" ? current[field] : 0;
|
|
316
|
+
return await this.set(
|
|
317
|
+
key,
|
|
318
|
+
{ ...current, [field]: previous + delta },
|
|
319
|
+
options,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
class RedisProviderRuntimeState implements ProviderRuntimeState {
|
|
325
|
+
readonly backend: RedisBackend;
|
|
326
|
+
readonly providerId?: string;
|
|
327
|
+
|
|
328
|
+
constructor(options: RedisProviderRuntimeStateOptions) {
|
|
329
|
+
this.backend = getRedisBackend(options.redisUrl);
|
|
330
|
+
this.providerId = options.providerId;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
namespace(
|
|
334
|
+
name: string,
|
|
335
|
+
options: StateNamespaceOptions,
|
|
336
|
+
): ProviderStateNamespace {
|
|
337
|
+
return new RedisProviderStateNamespace(
|
|
338
|
+
this.backend,
|
|
339
|
+
this.providerId,
|
|
340
|
+
name,
|
|
341
|
+
options,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
10
345
|
|
|
11
346
|
export class UnsupportedProviderStateError extends ProviderError {
|
|
12
347
|
constructor(
|
|
@@ -71,6 +406,158 @@ class UnsupportedProviderRuntimeState implements ProviderRuntimeState {
|
|
|
71
406
|
}
|
|
72
407
|
}
|
|
73
408
|
|
|
409
|
+
class MemoryProviderStateNamespace implements ProviderStateNamespace {
|
|
410
|
+
// biome-ignore lint/suspicious/noExplicitAny: in-memory state stores heterogeneous generic values by key.
|
|
411
|
+
readonly values = new Map<string, StateValue<any>>();
|
|
412
|
+
|
|
413
|
+
constructor(private readonly options: StateNamespaceOptions) {}
|
|
414
|
+
|
|
415
|
+
private pruneExpired(nowMs = Date.now()): void {
|
|
416
|
+
for (const [key, row] of this.values.entries()) {
|
|
417
|
+
if (row.expiresAt && Date.parse(row.expiresAt) <= nowMs) {
|
|
418
|
+
this.values.delete(key);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async list<T>(_options?: {
|
|
424
|
+
limit?: number;
|
|
425
|
+
prefix?: string;
|
|
426
|
+
}): Promise<StateValue<T>[]> {
|
|
427
|
+
this.pruneExpired();
|
|
428
|
+
const rows = Array.from(this.values.values()).filter((value) =>
|
|
429
|
+
_options?.prefix ? value.key.startsWith(_options.prefix) : true,
|
|
430
|
+
);
|
|
431
|
+
return rows.slice(0, _options?.limit);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async get<T>(key: string): Promise<StateValue<T> | null> {
|
|
435
|
+
this.pruneExpired();
|
|
436
|
+
return this.values.get(key) ?? null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async set<T>(
|
|
440
|
+
key: string,
|
|
441
|
+
value: T,
|
|
442
|
+
options?: StateWriteOptions,
|
|
443
|
+
): Promise<StateValue<T>> {
|
|
444
|
+
this.pruneExpired();
|
|
445
|
+
const now = new Date().toISOString();
|
|
446
|
+
const current = this.values.get(key);
|
|
447
|
+
const expiresAt = resolveMemoryStateExpiresAt(
|
|
448
|
+
options?.ttl ?? this.options.defaultTtl,
|
|
449
|
+
);
|
|
450
|
+
const row = {
|
|
451
|
+
key,
|
|
452
|
+
value,
|
|
453
|
+
version: (current?.version ?? 0) + 1,
|
|
454
|
+
expiresAt,
|
|
455
|
+
createdAt: current?.createdAt ?? now,
|
|
456
|
+
updatedAt: now,
|
|
457
|
+
} satisfies StateValue<T>;
|
|
458
|
+
this.values.set(key, row);
|
|
459
|
+
return row;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async patch<T extends Record<string, unknown>>(
|
|
463
|
+
_key: string,
|
|
464
|
+
_partial: Partial<T>,
|
|
465
|
+
_options?: StateWriteOptions,
|
|
466
|
+
): Promise<StateValue<T>> {
|
|
467
|
+
throw new UnsupportedProviderStateError(
|
|
468
|
+
"In-memory provider runtime state does not support patch",
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async compareAndSet<T>(
|
|
473
|
+
_key: string,
|
|
474
|
+
_expectedVersion: number,
|
|
475
|
+
_value: T,
|
|
476
|
+
_options?: StateWriteOptions,
|
|
477
|
+
): Promise<StateCasResult<T>> {
|
|
478
|
+
throw new UnsupportedProviderStateError(
|
|
479
|
+
"In-memory provider runtime state does not support compareAndSet",
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async delete(key: string): Promise<void> {
|
|
484
|
+
this.values.delete(key);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async increment(
|
|
488
|
+
_key: string,
|
|
489
|
+
_field: string,
|
|
490
|
+
_delta = 1,
|
|
491
|
+
_options?: StateWriteOptions,
|
|
492
|
+
): Promise<StateValue<Record<string, unknown>>> {
|
|
493
|
+
throw new UnsupportedProviderStateError(
|
|
494
|
+
"In-memory provider runtime state does not support increment",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
class MemoryProviderRuntimeState implements ProviderRuntimeState {
|
|
500
|
+
readonly namespaces = new Map<string, MemoryProviderStateNamespace>();
|
|
501
|
+
|
|
502
|
+
namespace(
|
|
503
|
+
name: string,
|
|
504
|
+
_options: StateNamespaceOptions,
|
|
505
|
+
): ProviderStateNamespace {
|
|
506
|
+
const existing = this.namespaces.get(name);
|
|
507
|
+
if (existing) return existing;
|
|
508
|
+
const created = new MemoryProviderStateNamespace(_options);
|
|
509
|
+
this.namespaces.set(name, created);
|
|
510
|
+
return created;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function resolveMemoryStateExpiresAt(ttl: StateWriteOptions["ttl"]): string {
|
|
515
|
+
const match = /^(\d+)(ms|s|m|h|d)$/.exec(ttl ?? "1h");
|
|
516
|
+
if (!match) return new Date(Date.now() + 3_600_000).toISOString();
|
|
517
|
+
const amount = Number(match[1]);
|
|
518
|
+
const unit = match[2];
|
|
519
|
+
const multiplier =
|
|
520
|
+
unit === "ms"
|
|
521
|
+
? 1
|
|
522
|
+
: unit === "s"
|
|
523
|
+
? 1_000
|
|
524
|
+
: unit === "m"
|
|
525
|
+
? 60_000
|
|
526
|
+
: unit === "h"
|
|
527
|
+
? 3_600_000
|
|
528
|
+
: 86_400_000;
|
|
529
|
+
return new Date(Date.now() + amount * multiplier).toISOString();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export function createRedisProviderRuntimeState(
|
|
533
|
+
options: RedisProviderRuntimeStateOptions,
|
|
534
|
+
): ProviderRuntimeState {
|
|
535
|
+
return new RedisProviderRuntimeState(options);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function createProviderRuntimeStateFromEnv(
|
|
539
|
+
options: {
|
|
540
|
+
readonly providerId?: string;
|
|
541
|
+
readonly allowMemoryFallback?: boolean;
|
|
542
|
+
} = {},
|
|
543
|
+
): ProviderRuntimeState {
|
|
544
|
+
const redisUrl = providerStateRedisUrlFromEnv();
|
|
545
|
+
if (redisUrl) {
|
|
546
|
+
return createRedisProviderRuntimeState({
|
|
547
|
+
redisUrl,
|
|
548
|
+
providerId: options.providerId,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
if (options.allowMemoryFallback === true) {
|
|
552
|
+
return new MemoryProviderRuntimeState();
|
|
553
|
+
}
|
|
554
|
+
return createUnsupportedProviderRuntimeState();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function createMemoryProviderRuntimeState(): ProviderRuntimeState {
|
|
558
|
+
return new MemoryProviderRuntimeState();
|
|
559
|
+
}
|
|
560
|
+
|
|
74
561
|
export function createUnsupportedProviderRuntimeState(): ProviderRuntimeState {
|
|
75
562
|
return new UnsupportedProviderRuntimeState();
|
|
76
563
|
}
|
package/src/runtime/stealth.ts
CHANGED
|
@@ -776,12 +776,19 @@ function createSessionFetcher(
|
|
|
776
776
|
options?: StealthFetchOptions,
|
|
777
777
|
proxyAttempt?: number,
|
|
778
778
|
): Promise<ResolvedAttemptProxy> {
|
|
779
|
+
const rawProxyAttemptOffset = options?.proxyAttemptOffset ?? 0;
|
|
780
|
+
const proxyAttemptOffset = Number.isFinite(rawProxyAttemptOffset)
|
|
781
|
+
? Math.max(0, Math.floor(rawProxyAttemptOffset))
|
|
782
|
+
: 0;
|
|
779
783
|
const resolvedProxy = await resolveProxyConfigAsync({
|
|
780
784
|
proxy: options?.proxy ?? clientOptions.proxy,
|
|
781
785
|
upstream: clientOptions.upstream,
|
|
782
786
|
apifuseConfig: clientOptions.apifuseConfig,
|
|
783
787
|
affinityKey: clientOptions.affinityKey,
|
|
784
|
-
proxyAttempt
|
|
788
|
+
proxyAttempt:
|
|
789
|
+
proxyAttempt === undefined
|
|
790
|
+
? proxyAttemptOffset
|
|
791
|
+
: proxyAttemptOffset + proxyAttempt,
|
|
785
792
|
telemetry: clientOptions.telemetry,
|
|
786
793
|
});
|
|
787
794
|
|