@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.
Files changed (120) hide show
  1. package/.env.example +109 -0
  2. package/.github/workflows/ci.yml +31 -0
  3. package/LICENSE +21 -0
  4. package/README.md +892 -0
  5. package/architecture.md +652 -0
  6. package/bun.lock +70 -0
  7. package/dist/cli/index.js +3233 -0
  8. package/dist/index.js +9014 -0
  9. package/package.json +77 -0
  10. package/src/cache/index.ts +795 -0
  11. package/src/cli/ARCHITECTURE.md +837 -0
  12. package/src/cli/bin.ts +10 -0
  13. package/src/cli/commands/build.ts +425 -0
  14. package/src/cli/commands/dev.ts +248 -0
  15. package/src/cli/commands/generate.ts +541 -0
  16. package/src/cli/commands/help.ts +55 -0
  17. package/src/cli/commands/index.ts +112 -0
  18. package/src/cli/commands/migration.ts +355 -0
  19. package/src/cli/commands/new.ts +804 -0
  20. package/src/cli/commands/start.ts +208 -0
  21. package/src/cli/core/args.ts +283 -0
  22. package/src/cli/core/console.ts +349 -0
  23. package/src/cli/core/index.ts +60 -0
  24. package/src/cli/core/prompt.ts +424 -0
  25. package/src/cli/core/spinner.ts +265 -0
  26. package/src/cli/index.ts +135 -0
  27. package/src/cli/templates/deploy.ts +295 -0
  28. package/src/cli/templates/docker.ts +307 -0
  29. package/src/cli/templates/index.ts +24 -0
  30. package/src/cli/utils/fs.ts +428 -0
  31. package/src/cli/utils/index.ts +8 -0
  32. package/src/cli/utils/strings.ts +197 -0
  33. package/src/config/env.ts +408 -0
  34. package/src/config/index.ts +506 -0
  35. package/src/config/loader.ts +329 -0
  36. package/src/config/merge.ts +285 -0
  37. package/src/config/types.ts +320 -0
  38. package/src/config/validation.ts +441 -0
  39. package/src/container/forward-ref.ts +143 -0
  40. package/src/container/index.ts +386 -0
  41. package/src/context/index.ts +360 -0
  42. package/src/database/index.ts +1142 -0
  43. package/src/database/migrations/index.ts +371 -0
  44. package/src/database/schema/index.ts +619 -0
  45. package/src/frontend/api-routes.ts +640 -0
  46. package/src/frontend/bundler.ts +643 -0
  47. package/src/frontend/console-client.ts +419 -0
  48. package/src/frontend/console-stream.ts +587 -0
  49. package/src/frontend/dev-server.ts +846 -0
  50. package/src/frontend/file-router.ts +611 -0
  51. package/src/frontend/frameworks/index.ts +106 -0
  52. package/src/frontend/frameworks/react.ts +85 -0
  53. package/src/frontend/frameworks/solid.ts +104 -0
  54. package/src/frontend/frameworks/svelte.ts +110 -0
  55. package/src/frontend/frameworks/vue.ts +92 -0
  56. package/src/frontend/hmr-client.ts +663 -0
  57. package/src/frontend/hmr.ts +728 -0
  58. package/src/frontend/index.ts +342 -0
  59. package/src/frontend/islands.ts +552 -0
  60. package/src/frontend/isr.ts +555 -0
  61. package/src/frontend/layout.ts +475 -0
  62. package/src/frontend/ssr/react.ts +446 -0
  63. package/src/frontend/ssr/solid.ts +523 -0
  64. package/src/frontend/ssr/svelte.ts +546 -0
  65. package/src/frontend/ssr/vue.ts +504 -0
  66. package/src/frontend/ssr.ts +699 -0
  67. package/src/frontend/types.ts +2274 -0
  68. package/src/health/index.ts +604 -0
  69. package/src/index.ts +410 -0
  70. package/src/lock/index.ts +587 -0
  71. package/src/logger/index.ts +444 -0
  72. package/src/logger/transports/index.ts +969 -0
  73. package/src/metrics/index.ts +494 -0
  74. package/src/middleware/built-in.ts +360 -0
  75. package/src/middleware/index.ts +94 -0
  76. package/src/modules/filters.ts +458 -0
  77. package/src/modules/guards.ts +405 -0
  78. package/src/modules/index.ts +1256 -0
  79. package/src/modules/interceptors.ts +574 -0
  80. package/src/modules/lazy.ts +418 -0
  81. package/src/modules/lifecycle.ts +478 -0
  82. package/src/modules/metadata.ts +90 -0
  83. package/src/modules/pipes.ts +626 -0
  84. package/src/router/index.ts +339 -0
  85. package/src/router/linear.ts +371 -0
  86. package/src/router/regex.ts +292 -0
  87. package/src/router/tree.ts +562 -0
  88. package/src/rpc/index.ts +1263 -0
  89. package/src/security/index.ts +436 -0
  90. package/src/ssg/index.ts +631 -0
  91. package/src/storage/index.ts +456 -0
  92. package/src/telemetry/index.ts +1097 -0
  93. package/src/testing/index.ts +1586 -0
  94. package/src/types/index.ts +236 -0
  95. package/src/types/optional-deps.d.ts +219 -0
  96. package/src/validation/index.ts +276 -0
  97. package/src/websocket/index.ts +1004 -0
  98. package/tests/integration/cli.test.ts +1016 -0
  99. package/tests/integration/fullstack.test.ts +234 -0
  100. package/tests/unit/cache.test.ts +174 -0
  101. package/tests/unit/cli-commands.test.ts +892 -0
  102. package/tests/unit/cli.test.ts +1258 -0
  103. package/tests/unit/container.test.ts +279 -0
  104. package/tests/unit/context.test.ts +221 -0
  105. package/tests/unit/database.test.ts +183 -0
  106. package/tests/unit/linear-router.test.ts +280 -0
  107. package/tests/unit/lock.test.ts +336 -0
  108. package/tests/unit/middleware.test.ts +184 -0
  109. package/tests/unit/modules.test.ts +142 -0
  110. package/tests/unit/pubsub.test.ts +257 -0
  111. package/tests/unit/regex-router.test.ts +265 -0
  112. package/tests/unit/router.test.ts +373 -0
  113. package/tests/unit/rpc.test.ts +1248 -0
  114. package/tests/unit/security.test.ts +174 -0
  115. package/tests/unit/telemetry.test.ts +371 -0
  116. package/tests/unit/test-cache.test.ts +110 -0
  117. package/tests/unit/test-database.test.ts +282 -0
  118. package/tests/unit/tree-router.test.ts +325 -0
  119. package/tests/unit/validation.test.ts +794 -0
  120. package/tsconfig.json +27 -0
@@ -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
+ }