@buenojs/bueno 0.8.0
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/.env.example +109 -0
- package/.github/workflows/ci.yml +31 -0
- package/LICENSE +21 -0
- package/README.md +892 -0
- package/architecture.md +652 -0
- package/bun.lock +70 -0
- package/dist/cli/index.js +3233 -0
- package/dist/index.js +9014 -0
- package/package.json +77 -0
- package/src/cache/index.ts +795 -0
- package/src/cli/ARCHITECTURE.md +837 -0
- package/src/cli/bin.ts +10 -0
- package/src/cli/commands/build.ts +425 -0
- package/src/cli/commands/dev.ts +248 -0
- package/src/cli/commands/generate.ts +541 -0
- package/src/cli/commands/help.ts +55 -0
- package/src/cli/commands/index.ts +112 -0
- package/src/cli/commands/migration.ts +355 -0
- package/src/cli/commands/new.ts +804 -0
- package/src/cli/commands/start.ts +208 -0
- package/src/cli/core/args.ts +283 -0
- package/src/cli/core/console.ts +349 -0
- package/src/cli/core/index.ts +60 -0
- package/src/cli/core/prompt.ts +424 -0
- package/src/cli/core/spinner.ts +265 -0
- package/src/cli/index.ts +135 -0
- package/src/cli/templates/deploy.ts +295 -0
- package/src/cli/templates/docker.ts +307 -0
- package/src/cli/templates/index.ts +24 -0
- package/src/cli/utils/fs.ts +428 -0
- package/src/cli/utils/index.ts +8 -0
- package/src/cli/utils/strings.ts +197 -0
- package/src/config/env.ts +408 -0
- package/src/config/index.ts +506 -0
- package/src/config/loader.ts +329 -0
- package/src/config/merge.ts +285 -0
- package/src/config/types.ts +320 -0
- package/src/config/validation.ts +441 -0
- package/src/container/forward-ref.ts +143 -0
- package/src/container/index.ts +386 -0
- package/src/context/index.ts +360 -0
- package/src/database/index.ts +1142 -0
- package/src/database/migrations/index.ts +371 -0
- package/src/database/schema/index.ts +619 -0
- package/src/frontend/api-routes.ts +640 -0
- package/src/frontend/bundler.ts +643 -0
- package/src/frontend/console-client.ts +419 -0
- package/src/frontend/console-stream.ts +587 -0
- package/src/frontend/dev-server.ts +846 -0
- package/src/frontend/file-router.ts +611 -0
- package/src/frontend/frameworks/index.ts +106 -0
- package/src/frontend/frameworks/react.ts +85 -0
- package/src/frontend/frameworks/solid.ts +104 -0
- package/src/frontend/frameworks/svelte.ts +110 -0
- package/src/frontend/frameworks/vue.ts +92 -0
- package/src/frontend/hmr-client.ts +663 -0
- package/src/frontend/hmr.ts +728 -0
- package/src/frontend/index.ts +342 -0
- package/src/frontend/islands.ts +552 -0
- package/src/frontend/isr.ts +555 -0
- package/src/frontend/layout.ts +475 -0
- package/src/frontend/ssr/react.ts +446 -0
- package/src/frontend/ssr/solid.ts +523 -0
- package/src/frontend/ssr/svelte.ts +546 -0
- package/src/frontend/ssr/vue.ts +504 -0
- package/src/frontend/ssr.ts +699 -0
- package/src/frontend/types.ts +2274 -0
- package/src/health/index.ts +604 -0
- package/src/index.ts +410 -0
- package/src/lock/index.ts +587 -0
- package/src/logger/index.ts +444 -0
- package/src/logger/transports/index.ts +969 -0
- package/src/metrics/index.ts +494 -0
- package/src/middleware/built-in.ts +360 -0
- package/src/middleware/index.ts +94 -0
- package/src/modules/filters.ts +458 -0
- package/src/modules/guards.ts +405 -0
- package/src/modules/index.ts +1256 -0
- package/src/modules/interceptors.ts +574 -0
- package/src/modules/lazy.ts +418 -0
- package/src/modules/lifecycle.ts +478 -0
- package/src/modules/metadata.ts +90 -0
- package/src/modules/pipes.ts +626 -0
- package/src/router/index.ts +339 -0
- package/src/router/linear.ts +371 -0
- package/src/router/regex.ts +292 -0
- package/src/router/tree.ts +562 -0
- package/src/rpc/index.ts +1263 -0
- package/src/security/index.ts +436 -0
- package/src/ssg/index.ts +631 -0
- package/src/storage/index.ts +456 -0
- package/src/telemetry/index.ts +1097 -0
- package/src/testing/index.ts +1586 -0
- package/src/types/index.ts +236 -0
- package/src/types/optional-deps.d.ts +219 -0
- package/src/validation/index.ts +276 -0
- package/src/websocket/index.ts +1004 -0
- package/tests/integration/cli.test.ts +1016 -0
- package/tests/integration/fullstack.test.ts +234 -0
- package/tests/unit/cache.test.ts +174 -0
- package/tests/unit/cli-commands.test.ts +892 -0
- package/tests/unit/cli.test.ts +1258 -0
- package/tests/unit/container.test.ts +279 -0
- package/tests/unit/context.test.ts +221 -0
- package/tests/unit/database.test.ts +183 -0
- package/tests/unit/linear-router.test.ts +280 -0
- package/tests/unit/lock.test.ts +336 -0
- package/tests/unit/middleware.test.ts +184 -0
- package/tests/unit/modules.test.ts +142 -0
- package/tests/unit/pubsub.test.ts +257 -0
- package/tests/unit/regex-router.test.ts +265 -0
- package/tests/unit/router.test.ts +373 -0
- package/tests/unit/rpc.test.ts +1248 -0
- package/tests/unit/security.test.ts +174 -0
- package/tests/unit/telemetry.test.ts +371 -0
- package/tests/unit/test-cache.test.ts +110 -0
- package/tests/unit/test-database.test.ts +282 -0
- package/tests/unit/tree-router.test.ts +325 -0
- package/tests/unit/validation.test.ts +794 -0
- package/tsconfig.json +27 -0
package/src/rpc/index.ts
ADDED
|
@@ -0,0 +1,1263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC Client
|
|
3
|
+
*
|
|
4
|
+
* Type-safe HTTP client for making requests to Bueno servers.
|
|
5
|
+
* Provides method inference, automatic serialization, request deduplication,
|
|
6
|
+
* optimistic updates, and retry logic support.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Router } from "../router";
|
|
10
|
+
|
|
11
|
+
// ============= Types =============
|
|
12
|
+
|
|
13
|
+
export interface RPCClientOptions {
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
timeout?: number;
|
|
17
|
+
deduplication?: DeduplicationConfig;
|
|
18
|
+
optimisticUpdates?: OptimisticUpdatesConfig;
|
|
19
|
+
retry?: RetryConfig;
|
|
20
|
+
interceptors?: InterceptorsConfig;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface InterceptorsConfig {
|
|
24
|
+
request?: RequestInterceptor | RequestInterceptor[];
|
|
25
|
+
response?: ResponseInterceptor | ResponseInterceptor[];
|
|
26
|
+
error?: ErrorInterceptor | ErrorInterceptor[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type RequestInterceptor = (
|
|
30
|
+
config: RequestInterceptorContext,
|
|
31
|
+
) => RequestInterceptorContext | Promise<RequestInterceptorContext>;
|
|
32
|
+
|
|
33
|
+
export type ResponseInterceptor = (
|
|
34
|
+
response: Response,
|
|
35
|
+
context: InterceptorContext,
|
|
36
|
+
) => Response | Promise<Response>;
|
|
37
|
+
|
|
38
|
+
export type ErrorInterceptor = (
|
|
39
|
+
error: Error,
|
|
40
|
+
context: InterceptorContext,
|
|
41
|
+
) => undefined | Response | Promise<undefined | Response>;
|
|
42
|
+
|
|
43
|
+
export interface RequestInterceptorContext extends RequestInit {
|
|
44
|
+
url: string;
|
|
45
|
+
method: HTTPMethod;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface InterceptorContext {
|
|
49
|
+
url: string;
|
|
50
|
+
method: HTTPMethod;
|
|
51
|
+
requestInit: RequestInit;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DeduplicationConfig {
|
|
55
|
+
enabled?: boolean;
|
|
56
|
+
ttl?: number;
|
|
57
|
+
keyGenerator?: (method: HTTPMethod, url: string, body?: unknown) => string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface OptimisticUpdatesConfig {
|
|
61
|
+
enabled?: boolean;
|
|
62
|
+
autoRollback?: boolean;
|
|
63
|
+
onConflict?: "rollback" | "overwrite" | "merge";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RetryConfig {
|
|
67
|
+
enabled?: boolean;
|
|
68
|
+
maxAttempts?: number;
|
|
69
|
+
initialDelay?: number;
|
|
70
|
+
maxDelay?: number;
|
|
71
|
+
backoffMultiplier?: number;
|
|
72
|
+
retryableStatusCodes?: number[];
|
|
73
|
+
retryableErrors?: string[];
|
|
74
|
+
onRetry?: (attempt: number, error: Error | null, delay: number) => void;
|
|
75
|
+
shouldRetry?: (
|
|
76
|
+
response: Response,
|
|
77
|
+
error: Error | null,
|
|
78
|
+
attempt: number,
|
|
79
|
+
) => boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface RequestOptions {
|
|
83
|
+
headers?: Record<string, string>;
|
|
84
|
+
query?: Record<string, string>;
|
|
85
|
+
timeout?: number;
|
|
86
|
+
skipDeduplication?: boolean;
|
|
87
|
+
retry?: RetryOptions;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface RetryOptions {
|
|
91
|
+
enabled?: boolean;
|
|
92
|
+
maxAttempts?: number;
|
|
93
|
+
initialDelay?: number;
|
|
94
|
+
skipRetry?: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface OptimisticOptions<T = unknown> {
|
|
98
|
+
optimisticData?: T;
|
|
99
|
+
rollbackData?: T;
|
|
100
|
+
cacheKey?: string;
|
|
101
|
+
onRollback?: (previousData: T | undefined) => void;
|
|
102
|
+
onConfirm?: (data: T) => void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type HTTPMethod =
|
|
106
|
+
| "GET"
|
|
107
|
+
| "POST"
|
|
108
|
+
| "PUT"
|
|
109
|
+
| "PATCH"
|
|
110
|
+
| "DELETE"
|
|
111
|
+
| "HEAD"
|
|
112
|
+
| "OPTIONS";
|
|
113
|
+
|
|
114
|
+
// ============= Optimistic Update Types =============
|
|
115
|
+
|
|
116
|
+
interface PendingOptimisticUpdate<T = unknown> {
|
|
117
|
+
id: string;
|
|
118
|
+
cacheKey: string;
|
|
119
|
+
optimisticData: T;
|
|
120
|
+
previousData: T | undefined;
|
|
121
|
+
timestamp: number;
|
|
122
|
+
status: "pending" | "confirmed" | "rolled_back";
|
|
123
|
+
onRollback?: (previousData: T | undefined) => void;
|
|
124
|
+
onConfirm?: (data: T) => void;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ============= Retry State =============
|
|
128
|
+
|
|
129
|
+
interface RetryState {
|
|
130
|
+
attempt: number;
|
|
131
|
+
lastError: Error | null;
|
|
132
|
+
totalDelay: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ============= Deduplication Store =============
|
|
136
|
+
|
|
137
|
+
interface PendingRequest {
|
|
138
|
+
promise: Promise<Response>;
|
|
139
|
+
timestamp: number;
|
|
140
|
+
body?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface CachedResponse {
|
|
144
|
+
response: Response;
|
|
145
|
+
timestamp: number;
|
|
146
|
+
ttl: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
class DeduplicationStore {
|
|
150
|
+
private pending: Map<string, PendingRequest> = new Map();
|
|
151
|
+
private cache: Map<string, CachedResponse> = new Map();
|
|
152
|
+
private cleanupInterval?: Timer;
|
|
153
|
+
|
|
154
|
+
constructor(private defaultTTL = 5000) {
|
|
155
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 10000);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private cleanup(): void {
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
|
|
161
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
162
|
+
if (now - entry.timestamp > entry.ttl) {
|
|
163
|
+
this.cache.delete(key);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const [key, entry] of this.pending.entries()) {
|
|
168
|
+
if (now - entry.timestamp > 60000) {
|
|
169
|
+
this.pending.delete(key);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
getPending(key: string, body?: string): PendingRequest | undefined {
|
|
175
|
+
const pending = this.pending.get(key);
|
|
176
|
+
if (pending && (body === undefined || pending.body === body)) {
|
|
177
|
+
return pending;
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
setPending(key: string, promise: Promise<Response>, body?: string): void {
|
|
183
|
+
this.pending.set(key, { promise, timestamp: Date.now(), body });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
removePending(key: string): void {
|
|
187
|
+
this.pending.delete(key);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
getCached(key: string, ttl: number): Response | undefined {
|
|
191
|
+
const cached = this.cache.get(key);
|
|
192
|
+
if (cached && Date.now() - cached.timestamp < ttl) {
|
|
193
|
+
return cached.response.clone() as Response;
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
setCached(key: string, response: Response, ttl: number): void {
|
|
199
|
+
this.cache.set(key, {
|
|
200
|
+
response: response.clone() as Response,
|
|
201
|
+
timestamp: Date.now(),
|
|
202
|
+
ttl,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
getCachedData<T>(key: string): T | undefined {
|
|
207
|
+
return (this.cache.get(key) as unknown as { data?: T })?.data;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
setCachedData<T>(key: string, data: T, ttl: number): void {
|
|
211
|
+
this.cache.set(key, {
|
|
212
|
+
response: new Response(JSON.stringify(data)),
|
|
213
|
+
timestamp: Date.now(),
|
|
214
|
+
ttl,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
invalidate(key: string): void {
|
|
219
|
+
this.cache.delete(key);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
clear(): void {
|
|
223
|
+
this.pending.clear();
|
|
224
|
+
this.cache.clear();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
destroy(): void {
|
|
228
|
+
if (this.cleanupInterval) {
|
|
229
|
+
clearInterval(this.cleanupInterval);
|
|
230
|
+
}
|
|
231
|
+
this.clear();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
getStats(): { pending: number; cached: number } {
|
|
235
|
+
return {
|
|
236
|
+
pending: this.pending.size,
|
|
237
|
+
cached: this.cache.size,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ============= Optimistic Update Store =============
|
|
243
|
+
|
|
244
|
+
class OptimisticStore {
|
|
245
|
+
private pending: Map<string, PendingOptimisticUpdate> = new Map();
|
|
246
|
+
private idCounter = 0;
|
|
247
|
+
|
|
248
|
+
create<T>(
|
|
249
|
+
cacheKey: string,
|
|
250
|
+
optimisticData: T,
|
|
251
|
+
previousData: T | undefined,
|
|
252
|
+
callbacks?: {
|
|
253
|
+
onRollback?: (prev: T | undefined) => void;
|
|
254
|
+
onConfirm?: (data: T) => void;
|
|
255
|
+
},
|
|
256
|
+
): string {
|
|
257
|
+
const id = `optimistic-${++this.idCounter}`;
|
|
258
|
+
this.pending.set(id, {
|
|
259
|
+
id,
|
|
260
|
+
cacheKey,
|
|
261
|
+
optimisticData,
|
|
262
|
+
previousData,
|
|
263
|
+
timestamp: Date.now(),
|
|
264
|
+
status: "pending",
|
|
265
|
+
onRollback: callbacks?.onRollback as ((previousData: unknown) => void) | undefined,
|
|
266
|
+
onConfirm: callbacks?.onConfirm as ((data: unknown) => void) | undefined,
|
|
267
|
+
});
|
|
268
|
+
return id;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
confirm(id: string, serverData?: unknown): void {
|
|
272
|
+
const update = this.pending.get(id);
|
|
273
|
+
if (update) {
|
|
274
|
+
update.status = "confirmed";
|
|
275
|
+
if (update.onConfirm && serverData !== undefined) {
|
|
276
|
+
update.onConfirm(serverData as typeof update.optimisticData);
|
|
277
|
+
}
|
|
278
|
+
this.pending.delete(id);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
rollback<T>(id: string): T | undefined {
|
|
283
|
+
const update = this.pending.get(id);
|
|
284
|
+
if (update) {
|
|
285
|
+
update.status = "rolled_back";
|
|
286
|
+
const previousData = update.previousData as T | undefined;
|
|
287
|
+
if (update.onRollback) {
|
|
288
|
+
update.onRollback(previousData);
|
|
289
|
+
}
|
|
290
|
+
this.pending.delete(id);
|
|
291
|
+
return previousData;
|
|
292
|
+
}
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
get(id: string): PendingOptimisticUpdate | undefined {
|
|
297
|
+
return this.pending.get(id);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
getByCacheKey(cacheKey: string): PendingOptimisticUpdate | undefined {
|
|
301
|
+
for (const update of this.pending.values()) {
|
|
302
|
+
if (update.cacheKey === cacheKey) {
|
|
303
|
+
return update;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
hasPending(cacheKey: string): boolean {
|
|
310
|
+
for (const update of this.pending.values()) {
|
|
311
|
+
if (update.cacheKey === cacheKey && update.status === "pending") {
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
getOptimisticData<T>(cacheKey: string): T | undefined {
|
|
319
|
+
const update = this.getByCacheKey(cacheKey);
|
|
320
|
+
if (update && update.status === "pending") {
|
|
321
|
+
return update.optimisticData as T;
|
|
322
|
+
}
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
clear(): void {
|
|
327
|
+
this.pending.clear();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
getStats(): { pending: number } {
|
|
331
|
+
return { pending: this.pending.size };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ============= Default Key Generator =============
|
|
336
|
+
|
|
337
|
+
function defaultKeyGenerator(
|
|
338
|
+
method: HTTPMethod,
|
|
339
|
+
url: string,
|
|
340
|
+
body?: unknown,
|
|
341
|
+
): string {
|
|
342
|
+
const bodyHash = body ? JSON.stringify(body) : "";
|
|
343
|
+
return `${method}:${url}:${bodyHash}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ============= Default Retry Decision =============
|
|
347
|
+
|
|
348
|
+
const DEFAULT_RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504];
|
|
349
|
+
const DEFAULT_RETRYABLE_ERRORS = [
|
|
350
|
+
"ECONNRESET",
|
|
351
|
+
"ETIMEDOUT",
|
|
352
|
+
"ENOTFOUND",
|
|
353
|
+
"EAI_AGAIN",
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
function defaultShouldRetry(
|
|
357
|
+
response: Response | null,
|
|
358
|
+
error: Error | null,
|
|
359
|
+
attempt: number,
|
|
360
|
+
config?: Required<RetryConfig>,
|
|
361
|
+
): boolean {
|
|
362
|
+
const maxAttempts = config?.maxAttempts ?? 3;
|
|
363
|
+
const retryableStatusCodes =
|
|
364
|
+
config?.retryableStatusCodes ?? DEFAULT_RETRYABLE_STATUS_CODES;
|
|
365
|
+
const retryableErrors = config?.retryableErrors ?? DEFAULT_RETRYABLE_ERRORS;
|
|
366
|
+
|
|
367
|
+
if (attempt >= maxAttempts) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (error) {
|
|
372
|
+
if (retryableErrors.length > 0) {
|
|
373
|
+
return retryableErrors.some(
|
|
374
|
+
(code) => error.message.includes(code) || error.name === code,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
return DEFAULT_RETRYABLE_ERRORS.some(
|
|
378
|
+
(code) => error.message.includes(code) || error.name === code,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (response) {
|
|
383
|
+
if (retryableStatusCodes.length > 0) {
|
|
384
|
+
return retryableStatusCodes.includes(response.status);
|
|
385
|
+
}
|
|
386
|
+
return DEFAULT_RETRYABLE_STATUS_CODES.includes(response.status);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ============= Calculate Delay =============
|
|
393
|
+
|
|
394
|
+
function calculateDelay(
|
|
395
|
+
attempt: number,
|
|
396
|
+
config: Required<RetryConfig>,
|
|
397
|
+
): number {
|
|
398
|
+
const delay = config.initialDelay * config.backoffMultiplier ** (attempt - 1);
|
|
399
|
+
return Math.min(delay, config.maxDelay);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ============= Sleep Utility =============
|
|
403
|
+
|
|
404
|
+
function sleep(ms: number): Promise<void> {
|
|
405
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ============= RPC Client =============
|
|
409
|
+
|
|
410
|
+
export class RPCClient {
|
|
411
|
+
private baseUrl: string;
|
|
412
|
+
private defaultHeaders: Record<string, string>;
|
|
413
|
+
private defaultTimeout: number;
|
|
414
|
+
private requestInterceptors: RequestInterceptor[] = [];
|
|
415
|
+
private responseInterceptors: ResponseInterceptor[] = [];
|
|
416
|
+
private errorInterceptors: ErrorInterceptor[] = [];
|
|
417
|
+
private deduplicationConfig: Required<DeduplicationConfig>;
|
|
418
|
+
private optimisticConfig: Required<OptimisticUpdatesConfig>;
|
|
419
|
+
private retryConfig: Required<RetryConfig>;
|
|
420
|
+
private deduplicationStore: DeduplicationStore;
|
|
421
|
+
private optimisticStore: OptimisticStore;
|
|
422
|
+
|
|
423
|
+
constructor(options: RPCClientOptions) {
|
|
424
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
425
|
+
this.defaultHeaders = {
|
|
426
|
+
"Content-Type": "application/json",
|
|
427
|
+
...options.headers,
|
|
428
|
+
};
|
|
429
|
+
this.defaultTimeout = options.timeout ?? 30000;
|
|
430
|
+
|
|
431
|
+
if (options.interceptors?.request) {
|
|
432
|
+
this.requestInterceptors = Array.isArray(options.interceptors.request)
|
|
433
|
+
? options.interceptors.request
|
|
434
|
+
: [options.interceptors.request];
|
|
435
|
+
}
|
|
436
|
+
if (options.interceptors?.response) {
|
|
437
|
+
this.responseInterceptors = Array.isArray(options.interceptors.response)
|
|
438
|
+
? options.interceptors.response
|
|
439
|
+
: [options.interceptors.response];
|
|
440
|
+
}
|
|
441
|
+
if (options.interceptors?.error) {
|
|
442
|
+
this.errorInterceptors = Array.isArray(options.interceptors.error)
|
|
443
|
+
? options.interceptors.error
|
|
444
|
+
: [options.interceptors.error];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
this.deduplicationConfig = {
|
|
448
|
+
enabled: options.deduplication?.enabled ?? true,
|
|
449
|
+
ttl: options.deduplication?.ttl ?? 5000,
|
|
450
|
+
keyGenerator: options.deduplication?.keyGenerator ?? defaultKeyGenerator,
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
this.optimisticConfig = {
|
|
454
|
+
enabled: options.optimisticUpdates?.enabled ?? true,
|
|
455
|
+
autoRollback: options.optimisticUpdates?.autoRollback ?? true,
|
|
456
|
+
onConflict: options.optimisticUpdates?.onConflict ?? "rollback",
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
this.retryConfig = {
|
|
460
|
+
enabled: options.retry?.enabled ?? true,
|
|
461
|
+
maxAttempts: options.retry?.maxAttempts ?? 3,
|
|
462
|
+
initialDelay: options.retry?.initialDelay ?? 1000,
|
|
463
|
+
maxDelay: options.retry?.maxDelay ?? 30000,
|
|
464
|
+
backoffMultiplier: options.retry?.backoffMultiplier ?? 2,
|
|
465
|
+
retryableStatusCodes: options.retry?.retryableStatusCodes ?? [
|
|
466
|
+
...DEFAULT_RETRYABLE_STATUS_CODES,
|
|
467
|
+
],
|
|
468
|
+
retryableErrors: options.retry?.retryableErrors ?? [
|
|
469
|
+
...DEFAULT_RETRYABLE_ERRORS,
|
|
470
|
+
],
|
|
471
|
+
onRetry: options.retry?.onRetry ?? (() => {}),
|
|
472
|
+
shouldRetry:
|
|
473
|
+
options.retry?.shouldRetry ??
|
|
474
|
+
((response: Response, error: Error | null, attempt: number) =>
|
|
475
|
+
defaultShouldRetry(response, error, attempt, undefined)),
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
this.deduplicationStore = new DeduplicationStore(
|
|
479
|
+
this.deduplicationConfig.ttl,
|
|
480
|
+
);
|
|
481
|
+
this.optimisticStore = new OptimisticStore();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Make a GET request
|
|
486
|
+
*/
|
|
487
|
+
async get(path: string, options?: RequestOptions): Promise<Response> {
|
|
488
|
+
return this.request("GET", path, undefined, options);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Make a POST request
|
|
493
|
+
*/
|
|
494
|
+
async post<T>(
|
|
495
|
+
path: string,
|
|
496
|
+
body?: T,
|
|
497
|
+
options?: RequestOptions,
|
|
498
|
+
): Promise<Response> {
|
|
499
|
+
return this.request("POST", path, body, options);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Make a PUT request
|
|
504
|
+
*/
|
|
505
|
+
async put<T>(
|
|
506
|
+
path: string,
|
|
507
|
+
body?: T,
|
|
508
|
+
options?: RequestOptions,
|
|
509
|
+
): Promise<Response> {
|
|
510
|
+
return this.request("PUT", path, body, options);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Make a PATCH request
|
|
515
|
+
*/
|
|
516
|
+
async patch<T>(
|
|
517
|
+
path: string,
|
|
518
|
+
body?: T,
|
|
519
|
+
options?: RequestOptions,
|
|
520
|
+
): Promise<Response> {
|
|
521
|
+
return this.request("PATCH", path, body, options);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Make a DELETE request
|
|
526
|
+
*/
|
|
527
|
+
async delete(path: string, options?: RequestOptions): Promise<Response> {
|
|
528
|
+
return this.request("DELETE", path, undefined, options);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Make a HEAD request
|
|
533
|
+
*/
|
|
534
|
+
async head(path: string, options?: RequestOptions): Promise<Response> {
|
|
535
|
+
return this.request("HEAD", path, undefined, options);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Make an OPTIONS request
|
|
540
|
+
*/
|
|
541
|
+
async options(path: string, options?: RequestOptions): Promise<Response> {
|
|
542
|
+
return this.request("OPTIONS", path, undefined, options);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Make a generic request with deduplication and retry support
|
|
547
|
+
*/
|
|
548
|
+
private async request<T>(
|
|
549
|
+
method: HTTPMethod,
|
|
550
|
+
path: string,
|
|
551
|
+
body?: T,
|
|
552
|
+
options?: RequestOptions,
|
|
553
|
+
): Promise<Response> {
|
|
554
|
+
let url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
|
555
|
+
|
|
556
|
+
if (options?.query) {
|
|
557
|
+
const searchParams = new URLSearchParams(options.query);
|
|
558
|
+
url += `?${searchParams.toString()}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const skipDeduplication =
|
|
562
|
+
options?.skipDeduplication || options?.retry?.skipRetry;
|
|
563
|
+
|
|
564
|
+
if (
|
|
565
|
+
this.deduplicationConfig.enabled &&
|
|
566
|
+
method === "GET" &&
|
|
567
|
+
!skipDeduplication
|
|
568
|
+
) {
|
|
569
|
+
const cacheKey = this.deduplicationConfig.keyGenerator(method, url);
|
|
570
|
+
const cached = this.deduplicationStore.getCached(
|
|
571
|
+
cacheKey,
|
|
572
|
+
this.deduplicationConfig.ttl,
|
|
573
|
+
);
|
|
574
|
+
if (cached) {
|
|
575
|
+
return cached;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (this.deduplicationConfig.enabled && !skipDeduplication) {
|
|
580
|
+
const bodyStr = body ? JSON.stringify(body) : undefined;
|
|
581
|
+
const dedupeKey = this.deduplicationConfig.keyGenerator(
|
|
582
|
+
method,
|
|
583
|
+
url,
|
|
584
|
+
body,
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
const pending = this.deduplicationStore.getPending(dedupeKey, bodyStr);
|
|
588
|
+
if (pending) {
|
|
589
|
+
return pending.promise;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const requestPromise = this.executeWithRetry<T>(
|
|
593
|
+
method,
|
|
594
|
+
url,
|
|
595
|
+
body,
|
|
596
|
+
options,
|
|
597
|
+
dedupeKey,
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
this.deduplicationStore.setPending(dedupeKey, requestPromise, bodyStr);
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
const response = await requestPromise;
|
|
604
|
+
return response;
|
|
605
|
+
} finally {
|
|
606
|
+
this.deduplicationStore.removePending(dedupeKey);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return this.executeWithRetry<T>(method, url, body, options);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Execute request with retry logic
|
|
615
|
+
*/
|
|
616
|
+
private async executeWithRetry<T>(
|
|
617
|
+
method: HTTPMethod,
|
|
618
|
+
url: string,
|
|
619
|
+
body?: T,
|
|
620
|
+
options?: RequestOptions,
|
|
621
|
+
cacheKey?: string,
|
|
622
|
+
): Promise<Response> {
|
|
623
|
+
const retryOptions = options?.retry;
|
|
624
|
+
const retryEnabled = retryOptions?.enabled ?? this.retryConfig.enabled;
|
|
625
|
+
const skipRetry = retryOptions?.skipRetry ?? false;
|
|
626
|
+
|
|
627
|
+
if (!retryEnabled || skipRetry) {
|
|
628
|
+
return this.executeRequest<T>(method, url, body, options, cacheKey);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const maxAttempts =
|
|
632
|
+
retryOptions?.maxAttempts ?? this.retryConfig.maxAttempts;
|
|
633
|
+
const initialDelay =
|
|
634
|
+
retryOptions?.initialDelay ?? this.retryConfig.initialDelay;
|
|
635
|
+
|
|
636
|
+
const state: RetryState = {
|
|
637
|
+
attempt: 0,
|
|
638
|
+
lastError: null,
|
|
639
|
+
totalDelay: 0,
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
while (state.attempt < maxAttempts) {
|
|
643
|
+
state.attempt++;
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
const response = await this.executeRequest<T>(
|
|
647
|
+
method,
|
|
648
|
+
url,
|
|
649
|
+
body,
|
|
650
|
+
options,
|
|
651
|
+
cacheKey,
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
if (
|
|
655
|
+
response.ok ||
|
|
656
|
+
!this.shouldRetryResponse(response, null, state.attempt, maxAttempts)
|
|
657
|
+
) {
|
|
658
|
+
return response;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (state.attempt < maxAttempts) {
|
|
662
|
+
const delay = calculateDelay(state.attempt, {
|
|
663
|
+
...this.retryConfig,
|
|
664
|
+
initialDelay,
|
|
665
|
+
maxAttempts,
|
|
666
|
+
});
|
|
667
|
+
this.retryConfig.onRetry(state.attempt, null, delay);
|
|
668
|
+
await sleep(delay);
|
|
669
|
+
state.totalDelay += delay;
|
|
670
|
+
} else {
|
|
671
|
+
return response;
|
|
672
|
+
}
|
|
673
|
+
} catch (error) {
|
|
674
|
+
state.lastError =
|
|
675
|
+
error instanceof Error ? error : new Error(String(error));
|
|
676
|
+
|
|
677
|
+
if (
|
|
678
|
+
state.attempt < maxAttempts &&
|
|
679
|
+
this.shouldRetryError(state.lastError, state.attempt, maxAttempts)
|
|
680
|
+
) {
|
|
681
|
+
const delay = calculateDelay(state.attempt, {
|
|
682
|
+
...this.retryConfig,
|
|
683
|
+
initialDelay,
|
|
684
|
+
maxAttempts,
|
|
685
|
+
});
|
|
686
|
+
this.retryConfig.onRetry(state.attempt, state.lastError, delay);
|
|
687
|
+
await sleep(delay);
|
|
688
|
+
state.totalDelay += delay;
|
|
689
|
+
} else {
|
|
690
|
+
throw state.lastError;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
throw state.lastError || new Error("Max retry attempts exceeded");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Check if response should trigger a retry
|
|
700
|
+
*/
|
|
701
|
+
private shouldRetryResponse(
|
|
702
|
+
response: Response,
|
|
703
|
+
error: Error | null,
|
|
704
|
+
attempt: number,
|
|
705
|
+
maxAttempts: number,
|
|
706
|
+
): boolean {
|
|
707
|
+
if (attempt >= maxAttempts) {
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
return this.retryConfig.shouldRetry(response, error, attempt);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Check if error should trigger a retry
|
|
715
|
+
*/
|
|
716
|
+
private shouldRetryError(
|
|
717
|
+
error: Error,
|
|
718
|
+
attempt: number,
|
|
719
|
+
maxAttempts: number,
|
|
720
|
+
): boolean {
|
|
721
|
+
if (attempt >= maxAttempts) {
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
return this.retryConfig.shouldRetry(
|
|
725
|
+
null as unknown as Response,
|
|
726
|
+
error,
|
|
727
|
+
attempt,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Execute the actual HTTP request
|
|
733
|
+
*/
|
|
734
|
+
private async executeRequest<T>(
|
|
735
|
+
method: HTTPMethod,
|
|
736
|
+
url: string,
|
|
737
|
+
body?: T,
|
|
738
|
+
options?: RequestOptions,
|
|
739
|
+
cacheKey?: string,
|
|
740
|
+
): Promise<Response> {
|
|
741
|
+
let config: RequestInit = {
|
|
742
|
+
method,
|
|
743
|
+
headers: {
|
|
744
|
+
...this.defaultHeaders,
|
|
745
|
+
...options?.headers,
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
if (body && !["GET", "HEAD", "OPTIONS"].includes(method)) {
|
|
750
|
+
config.body = JSON.stringify(body);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const context: InterceptorContext = { url, method, requestInit: config };
|
|
754
|
+
|
|
755
|
+
for (const interceptor of this.requestInterceptors) {
|
|
756
|
+
const interceptorContext: RequestInterceptorContext = {
|
|
757
|
+
...config,
|
|
758
|
+
url,
|
|
759
|
+
method,
|
|
760
|
+
};
|
|
761
|
+
const result = await interceptor(interceptorContext);
|
|
762
|
+
config = result;
|
|
763
|
+
context.requestInit = config;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const controller = new AbortController();
|
|
767
|
+
const timeout = options?.timeout ?? this.defaultTimeout;
|
|
768
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
769
|
+
config.signal = controller.signal;
|
|
770
|
+
|
|
771
|
+
try {
|
|
772
|
+
let response = (await fetch(url, config)) as Response;
|
|
773
|
+
|
|
774
|
+
if (cacheKey && method === "GET" && response.ok) {
|
|
775
|
+
this.deduplicationStore.setCached(
|
|
776
|
+
cacheKey,
|
|
777
|
+
response,
|
|
778
|
+
this.deduplicationConfig.ttl,
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
for (const interceptor of this.responseInterceptors) {
|
|
783
|
+
response = await interceptor(response, context);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return response;
|
|
787
|
+
} catch (error) {
|
|
788
|
+
const processedError =
|
|
789
|
+
error instanceof Error ? error : new Error(String(error));
|
|
790
|
+
|
|
791
|
+
for (const interceptor of this.errorInterceptors) {
|
|
792
|
+
const result = await interceptor(processedError, context);
|
|
793
|
+
if (result instanceof Response) {
|
|
794
|
+
return result;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (processedError.name === "AbortError") {
|
|
799
|
+
throw new Error(`Request timeout after ${timeout}ms`);
|
|
800
|
+
}
|
|
801
|
+
throw processedError;
|
|
802
|
+
} finally {
|
|
803
|
+
clearTimeout(timeoutId);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ============= Optimistic Updates =============
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Perform an optimistic update
|
|
811
|
+
*/
|
|
812
|
+
async optimistic<T = unknown, R = unknown>(
|
|
813
|
+
method: "POST" | "PUT" | "PATCH" | "DELETE",
|
|
814
|
+
path: string,
|
|
815
|
+
options?: {
|
|
816
|
+
body?: T;
|
|
817
|
+
query?: Record<string, string>;
|
|
818
|
+
headers?: Record<string, string>;
|
|
819
|
+
} & OptimisticOptions<R>,
|
|
820
|
+
): Promise<{ response: Response; rollbackId?: string }> {
|
|
821
|
+
if (!this.optimisticConfig.enabled) {
|
|
822
|
+
const response = await this.request(method, path, options?.body, options);
|
|
823
|
+
return { response };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const cacheKey = options?.cacheKey ?? path;
|
|
827
|
+
const previousData = this.deduplicationStore.getCachedData<R>(cacheKey);
|
|
828
|
+
const optimisticData = options?.optimisticData;
|
|
829
|
+
|
|
830
|
+
let rollbackId: string | undefined;
|
|
831
|
+
|
|
832
|
+
if (optimisticData !== undefined) {
|
|
833
|
+
rollbackId = this.optimisticStore.create<R>(
|
|
834
|
+
cacheKey,
|
|
835
|
+
optimisticData,
|
|
836
|
+
previousData,
|
|
837
|
+
{ onRollback: options?.onRollback, onConfirm: options?.onConfirm },
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
this.deduplicationStore.setCachedData(
|
|
841
|
+
cacheKey,
|
|
842
|
+
optimisticData,
|
|
843
|
+
this.deduplicationConfig.ttl,
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
try {
|
|
848
|
+
const response = await this.request(method, path, options?.body, {
|
|
849
|
+
query: options?.query,
|
|
850
|
+
headers: options?.headers,
|
|
851
|
+
skipDeduplication: true,
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
if (response.ok) {
|
|
855
|
+
if (rollbackId) {
|
|
856
|
+
const responseData = await response
|
|
857
|
+
.clone()
|
|
858
|
+
.json()
|
|
859
|
+
.catch(() => undefined);
|
|
860
|
+
this.optimisticStore.confirm(rollbackId, responseData);
|
|
861
|
+
}
|
|
862
|
+
return { response, rollbackId };
|
|
863
|
+
}
|
|
864
|
+
if (rollbackId && this.optimisticConfig.autoRollback) {
|
|
865
|
+
this.rollback(rollbackId);
|
|
866
|
+
}
|
|
867
|
+
return { response, rollbackId };
|
|
868
|
+
} catch (error) {
|
|
869
|
+
if (rollbackId && this.optimisticConfig.autoRollback) {
|
|
870
|
+
this.rollback(rollbackId);
|
|
871
|
+
}
|
|
872
|
+
throw error;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Optimistic POST - create a resource optimistically
|
|
878
|
+
*/
|
|
879
|
+
async optimisticPost<T = unknown, R = unknown>(
|
|
880
|
+
path: string,
|
|
881
|
+
body: T,
|
|
882
|
+
options?: OptimisticOptions<R> & {
|
|
883
|
+
query?: Record<string, string>;
|
|
884
|
+
headers?: Record<string, string>;
|
|
885
|
+
},
|
|
886
|
+
): Promise<{ response: Response; rollbackId?: string }> {
|
|
887
|
+
return this.optimistic<T, R>("POST", path, { body, ...options });
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Optimistic PUT - update a resource optimistically
|
|
892
|
+
*/
|
|
893
|
+
async optimisticPut<T = unknown, R = unknown>(
|
|
894
|
+
path: string,
|
|
895
|
+
body: T,
|
|
896
|
+
options?: OptimisticOptions<R> & {
|
|
897
|
+
query?: Record<string, string>;
|
|
898
|
+
headers?: Record<string, string>;
|
|
899
|
+
},
|
|
900
|
+
): Promise<{ response: Response; rollbackId?: string }> {
|
|
901
|
+
return this.optimistic<T, R>("PUT", path, { body, ...options });
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Optimistic PATCH - partially update a resource optimistically
|
|
906
|
+
*/
|
|
907
|
+
async optimisticPatch<T = unknown, R = unknown>(
|
|
908
|
+
path: string,
|
|
909
|
+
body: T,
|
|
910
|
+
options?: OptimisticOptions<R> & {
|
|
911
|
+
query?: Record<string, string>;
|
|
912
|
+
headers?: Record<string, string>;
|
|
913
|
+
},
|
|
914
|
+
): Promise<{ response: Response; rollbackId?: string }> {
|
|
915
|
+
return this.optimistic<T, R>("PATCH", path, { body, ...options });
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Optimistic DELETE - delete a resource optimistically
|
|
920
|
+
*/
|
|
921
|
+
async optimisticDelete<R = unknown>(
|
|
922
|
+
path: string,
|
|
923
|
+
options?: OptimisticOptions<R> & {
|
|
924
|
+
query?: Record<string, string>;
|
|
925
|
+
headers?: Record<string, string>;
|
|
926
|
+
},
|
|
927
|
+
): Promise<{ response: Response; rollbackId?: string }> {
|
|
928
|
+
return this.optimistic<never, R>("DELETE", path, options);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Rollback an optimistic update
|
|
933
|
+
*/
|
|
934
|
+
rollback<T>(rollbackId: string): T | undefined {
|
|
935
|
+
const previousData = this.optimisticStore.rollback<T>(rollbackId);
|
|
936
|
+
|
|
937
|
+
if (previousData !== undefined) {
|
|
938
|
+
const update = this.optimisticStore.get(rollbackId);
|
|
939
|
+
if (update) {
|
|
940
|
+
this.deduplicationStore.setCachedData(
|
|
941
|
+
update.cacheKey,
|
|
942
|
+
previousData,
|
|
943
|
+
this.deduplicationConfig.ttl,
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
return previousData;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Confirm an optimistic update
|
|
953
|
+
*/
|
|
954
|
+
confirm(rollbackId: string, serverData?: unknown): void {
|
|
955
|
+
this.optimisticStore.confirm(rollbackId, serverData);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Check if there's a pending optimistic update for a cache key
|
|
960
|
+
*/
|
|
961
|
+
hasPendingOptimisticUpdate(cacheKey: string): boolean {
|
|
962
|
+
return this.optimisticStore.hasPending(cacheKey);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Get optimistic data for a cache key
|
|
967
|
+
*/
|
|
968
|
+
getOptimisticData<T>(cacheKey: string): T | undefined {
|
|
969
|
+
return this.optimisticStore.getOptimisticData<T>(cacheKey);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Get all pending optimistic updates count
|
|
974
|
+
*/
|
|
975
|
+
getPendingOptimisticCount(): number {
|
|
976
|
+
return this.optimisticStore.getStats().pending;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Clear all pending optimistic updates
|
|
981
|
+
*/
|
|
982
|
+
clearOptimisticUpdates(): void {
|
|
983
|
+
this.optimisticStore.clear();
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ============= Retry Utilities =============
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Make a request with explicit retry options
|
|
990
|
+
*/
|
|
991
|
+
async withRetry<T>(
|
|
992
|
+
method: HTTPMethod,
|
|
993
|
+
path: string,
|
|
994
|
+
body?: T,
|
|
995
|
+
retryOptions?: RetryOptions,
|
|
996
|
+
): Promise<Response> {
|
|
997
|
+
return this.request(method, path, body, { retry: retryOptions });
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Check if retry is enabled
|
|
1002
|
+
*/
|
|
1003
|
+
isRetryEnabled(): boolean {
|
|
1004
|
+
return this.retryConfig.enabled;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Get max retry attempts
|
|
1009
|
+
*/
|
|
1010
|
+
getMaxRetryAttempts(): number {
|
|
1011
|
+
return this.retryConfig.maxAttempts;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Get retry configuration
|
|
1016
|
+
*/
|
|
1017
|
+
getRetryConfig(): Required<RetryConfig> {
|
|
1018
|
+
return { ...this.retryConfig };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ============= Interceptor Management =============
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Add a request interceptor
|
|
1025
|
+
*/
|
|
1026
|
+
addRequestInterceptor(interceptor: RequestInterceptor): void {
|
|
1027
|
+
this.requestInterceptors.push(interceptor);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Add a response interceptor
|
|
1032
|
+
*/
|
|
1033
|
+
addResponseInterceptor(interceptor: ResponseInterceptor): void {
|
|
1034
|
+
this.responseInterceptors.push(interceptor);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Add an error interceptor
|
|
1039
|
+
*/
|
|
1040
|
+
addErrorInterceptor(interceptor: ErrorInterceptor): void {
|
|
1041
|
+
this.errorInterceptors.push(interceptor);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Remove a request interceptor
|
|
1046
|
+
*/
|
|
1047
|
+
removeRequestInterceptor(interceptor: RequestInterceptor): boolean {
|
|
1048
|
+
const index = this.requestInterceptors.indexOf(interceptor);
|
|
1049
|
+
if (index > -1) {
|
|
1050
|
+
this.requestInterceptors.splice(index, 1);
|
|
1051
|
+
return true;
|
|
1052
|
+
}
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Remove a response interceptor
|
|
1058
|
+
*/
|
|
1059
|
+
removeResponseInterceptor(interceptor: ResponseInterceptor): boolean {
|
|
1060
|
+
const index = this.responseInterceptors.indexOf(interceptor);
|
|
1061
|
+
if (index > -1) {
|
|
1062
|
+
this.responseInterceptors.splice(index, 1);
|
|
1063
|
+
return true;
|
|
1064
|
+
}
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Remove an error interceptor
|
|
1070
|
+
*/
|
|
1071
|
+
removeErrorInterceptor(interceptor: ErrorInterceptor): boolean {
|
|
1072
|
+
const index = this.errorInterceptors.indexOf(interceptor);
|
|
1073
|
+
if (index > -1) {
|
|
1074
|
+
this.errorInterceptors.splice(index, 1);
|
|
1075
|
+
return true;
|
|
1076
|
+
}
|
|
1077
|
+
return false;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Clear all interceptors
|
|
1082
|
+
*/
|
|
1083
|
+
clearInterceptors(): void {
|
|
1084
|
+
this.requestInterceptors = [];
|
|
1085
|
+
this.responseInterceptors = [];
|
|
1086
|
+
this.errorInterceptors = [];
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Get interceptor counts
|
|
1091
|
+
*/
|
|
1092
|
+
getInterceptorStats(): { request: number; response: number; error: number } {
|
|
1093
|
+
return {
|
|
1094
|
+
request: this.requestInterceptors.length,
|
|
1095
|
+
response: this.responseInterceptors.length,
|
|
1096
|
+
error: this.errorInterceptors.length,
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Create client with interceptors
|
|
1102
|
+
*/
|
|
1103
|
+
withInterceptors(interceptors: InterceptorsConfig): RPCClient {
|
|
1104
|
+
return new RPCClient({
|
|
1105
|
+
baseUrl: this.baseUrl,
|
|
1106
|
+
headers: this.defaultHeaders,
|
|
1107
|
+
timeout: this.defaultTimeout,
|
|
1108
|
+
deduplication: this.deduplicationConfig,
|
|
1109
|
+
optimisticUpdates: this.optimisticConfig,
|
|
1110
|
+
retry: this.retryConfig,
|
|
1111
|
+
interceptors,
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ============= Client Utilities =============
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Create a new client with different base URL
|
|
1119
|
+
*/
|
|
1120
|
+
withBaseUrl(baseUrl: string): RPCClient {
|
|
1121
|
+
const interceptors: InterceptorsConfig = {};
|
|
1122
|
+
if (this.requestInterceptors.length > 0)
|
|
1123
|
+
interceptors.request = this.requestInterceptors;
|
|
1124
|
+
if (this.responseInterceptors.length > 0)
|
|
1125
|
+
interceptors.response = this.responseInterceptors;
|
|
1126
|
+
if (this.errorInterceptors.length > 0)
|
|
1127
|
+
interceptors.error = this.errorInterceptors;
|
|
1128
|
+
|
|
1129
|
+
return new RPCClient({
|
|
1130
|
+
baseUrl,
|
|
1131
|
+
headers: this.defaultHeaders,
|
|
1132
|
+
timeout: this.defaultTimeout,
|
|
1133
|
+
deduplication: this.deduplicationConfig,
|
|
1134
|
+
optimisticUpdates: this.optimisticConfig,
|
|
1135
|
+
retry: this.retryConfig,
|
|
1136
|
+
interceptors,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Create a new client with additional headers
|
|
1142
|
+
*/
|
|
1143
|
+
withHeaders(headers: Record<string, string>): RPCClient {
|
|
1144
|
+
const interceptors: InterceptorsConfig = {};
|
|
1145
|
+
if (this.requestInterceptors.length > 0)
|
|
1146
|
+
interceptors.request = this.requestInterceptors;
|
|
1147
|
+
if (this.responseInterceptors.length > 0)
|
|
1148
|
+
interceptors.response = this.responseInterceptors;
|
|
1149
|
+
if (this.errorInterceptors.length > 0)
|
|
1150
|
+
interceptors.error = this.errorInterceptors;
|
|
1151
|
+
|
|
1152
|
+
return new RPCClient({
|
|
1153
|
+
baseUrl: this.baseUrl,
|
|
1154
|
+
headers: { ...this.defaultHeaders, ...headers },
|
|
1155
|
+
timeout: this.defaultTimeout,
|
|
1156
|
+
deduplication: this.deduplicationConfig,
|
|
1157
|
+
optimisticUpdates: this.optimisticConfig,
|
|
1158
|
+
retry: this.retryConfig,
|
|
1159
|
+
interceptors,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Clear the deduplication cache
|
|
1165
|
+
*/
|
|
1166
|
+
clearCache(): void {
|
|
1167
|
+
this.deduplicationStore.clear();
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Clear all caches
|
|
1172
|
+
*/
|
|
1173
|
+
clearAllCaches(): void {
|
|
1174
|
+
this.deduplicationStore.clear();
|
|
1175
|
+
this.optimisticStore.clear();
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Invalidate a specific cache key
|
|
1180
|
+
*/
|
|
1181
|
+
invalidateCache(cacheKey: string): void {
|
|
1182
|
+
this.deduplicationStore.invalidate(cacheKey);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Get deduplication statistics
|
|
1187
|
+
*/
|
|
1188
|
+
getDeduplicationStats(): { pending: number; cached: number } {
|
|
1189
|
+
return this.deduplicationStore.getStats();
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Check if deduplication is enabled
|
|
1194
|
+
*/
|
|
1195
|
+
isDeduplicationEnabled(): boolean {
|
|
1196
|
+
return this.deduplicationConfig.enabled;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Get deduplication TTL
|
|
1201
|
+
*/
|
|
1202
|
+
getDeduplicationTTL(): number {
|
|
1203
|
+
return this.deduplicationConfig.ttl;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Check if optimistic updates are enabled
|
|
1208
|
+
*/
|
|
1209
|
+
isOptimisticUpdatesEnabled(): boolean {
|
|
1210
|
+
return this.optimisticConfig.enabled;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// ============= Client Factory =============
|
|
1215
|
+
|
|
1216
|
+
export function createRPClient(options: RPCClientOptions): RPCClient {
|
|
1217
|
+
return new RPCClient(options);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
export function bc<T>(options: RPCClientOptions): RPCClient {
|
|
1221
|
+
return createRPClient(options);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// ============= Route Type Extraction =============
|
|
1225
|
+
|
|
1226
|
+
export interface RouteTypeInfo {
|
|
1227
|
+
method: HTTPMethod;
|
|
1228
|
+
path: string;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
export function extractRouteTypes(router: Router): RouteTypeInfo[] {
|
|
1232
|
+
const routes = router.getRoutes();
|
|
1233
|
+
return routes.map((r) => ({
|
|
1234
|
+
method: r.method as HTTPMethod,
|
|
1235
|
+
path: r.pattern,
|
|
1236
|
+
}));
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// ============= Response Helpers =============
|
|
1240
|
+
|
|
1241
|
+
export async function parseJSON<T>(response: Response): Promise<T> {
|
|
1242
|
+
return response.json() as Promise<T>;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
export async function parseText(response: Response): Promise<string> {
|
|
1246
|
+
return response.text();
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
export function isOK(response: Response): boolean {
|
|
1250
|
+
return response.ok;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
export function isStatus(response: Response, status: number): boolean {
|
|
1254
|
+
return response.status === status;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
export async function throwIfNotOK(response: Response): Promise<Response> {
|
|
1258
|
+
if (!response.ok) {
|
|
1259
|
+
const error = await response.text();
|
|
1260
|
+
throw new Error(`HTTP ${response.status}: ${error}`);
|
|
1261
|
+
}
|
|
1262
|
+
return response;
|
|
1263
|
+
}
|