@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,1248 @@
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from 'bun:test';
2
+ import { createRPClient, extractRouteTypes, type RPCClient } from '../../src/rpc';
3
+ import { Router } from '../../src/router';
4
+ import { Application } from '../../src/modules';
5
+ import { Context } from '../../src/context';
6
+
7
+ const createMockApp = () => {
8
+ const router = new Router();
9
+ router.get('/users', (ctx) => ctx.json([{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }]));
10
+ router.get('/users/:id', (ctx) => ctx.json({ id: parseInt(ctx.params.id), name: 'User' }));
11
+ router.get('/slow', async (ctx) => {
12
+ await new Promise(resolve => setTimeout(resolve, 100));
13
+ return ctx.json({ timestamp: Date.now() });
14
+ });
15
+ router.post('/users', (ctx) => ctx.json({ created: true }));
16
+ router.put('/users/:id', (ctx) => ctx.json({ updated: true }));
17
+ router.delete('/users/:id', (ctx) => ctx.json({ deleted: true }));
18
+
19
+ return router;
20
+ };
21
+
22
+ describe('RPC Client', () => {
23
+ let client: RPCClient;
24
+ let server: ReturnType<typeof Bun.serve>;
25
+ let router: Router;
26
+
27
+ beforeEach(async () => {
28
+ router = createMockApp();
29
+
30
+ server = Bun.serve({
31
+ port: 3999,
32
+ fetch: async (request: Request) => {
33
+ const url = new URL(request.url);
34
+ const match = router.match(request.method as 'GET', url.pathname);
35
+
36
+ if (!match) {
37
+ return new Response('Not Found', { status: 404 });
38
+ }
39
+
40
+ const context = new Context(request, match.params);
41
+ return match.handler(context);
42
+ },
43
+ });
44
+
45
+ client = createRPClient({
46
+ baseUrl: 'http://localhost:3999',
47
+ });
48
+ });
49
+
50
+ afterEach(() => {
51
+ server.stop();
52
+ });
53
+
54
+ describe('HTTP Methods', () => {
55
+ test('should make GET request', async () => {
56
+ const response = await client.get('/users');
57
+ const data = await response.json();
58
+
59
+ expect(response.status).toBe(200);
60
+ expect(data).toEqual([{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }]);
61
+ });
62
+
63
+ test('should make GET request with path params', async () => {
64
+ const response = await client.get('/users/123');
65
+ const data = await response.json();
66
+
67
+ expect(data).toEqual({ id: 123, name: 'User' });
68
+ });
69
+
70
+ test('should make POST request with body', async () => {
71
+ const response = await client.post('/users', { name: 'New User' });
72
+ const data = await response.json();
73
+
74
+ expect(data).toEqual({ created: true });
75
+ });
76
+
77
+ test('should make PUT request', async () => {
78
+ const response = await client.put('/users/123', { name: 'Updated' });
79
+ const data = await response.json();
80
+
81
+ expect(data).toEqual({ updated: true });
82
+ });
83
+
84
+ test('should make DELETE request', async () => {
85
+ const response = await client.delete('/users/123');
86
+ const data = await response.json();
87
+
88
+ expect(data).toEqual({ deleted: true });
89
+ });
90
+ });
91
+
92
+ describe('Request Options', () => {
93
+ test('should include custom headers', async () => {
94
+ const response = await client.get('/users', {
95
+ headers: { 'X-Custom': 'value' },
96
+ });
97
+
98
+ expect(response.status).toBe(200);
99
+ });
100
+
101
+ test('should include query parameters', async () => {
102
+ const response = await client.get('/users', {
103
+ query: { page: '1', limit: '10' },
104
+ });
105
+
106
+ expect(response.status).toBe(200);
107
+ });
108
+ });
109
+
110
+ describe('Error Handling', () => {
111
+ test('should handle 404', async () => {
112
+ const response = await client.get('/nonexistent');
113
+ expect(response.status).toBe(404);
114
+ });
115
+ });
116
+ });
117
+
118
+ describe('Request Deduplication', () => {
119
+ let client: RPCClient;
120
+ let server: ReturnType<typeof Bun.serve>;
121
+ let router: Router;
122
+ let requestCount: number;
123
+
124
+ beforeEach(async () => {
125
+ requestCount = 0;
126
+ router = createMockApp();
127
+
128
+ server = Bun.serve({
129
+ port: 4000,
130
+ fetch: async (request: Request) => {
131
+ requestCount++;
132
+ const url = new URL(request.url);
133
+ const match = router.match(request.method as 'GET', url.pathname);
134
+
135
+ if (!match) {
136
+ return new Response('Not Found', { status: 404 });
137
+ }
138
+
139
+ const context = new Context(request, match.params);
140
+ return match.handler(context);
141
+ },
142
+ });
143
+
144
+ client = createRPClient({
145
+ baseUrl: 'http://localhost:4000',
146
+ deduplication: {
147
+ enabled: true,
148
+ ttl: 1000,
149
+ },
150
+ });
151
+ });
152
+
153
+ afterEach(() => {
154
+ server.stop();
155
+ });
156
+
157
+ describe('Concurrent Request Deduplication', () => {
158
+ test('should deduplicate concurrent identical requests', async () => {
159
+ const promises = [
160
+ client.get('/slow'),
161
+ client.get('/slow'),
162
+ client.get('/slow'),
163
+ ];
164
+
165
+ const responses = await Promise.all(promises);
166
+
167
+ expect(responses.length).toBe(3);
168
+ expect(requestCount).toBe(1);
169
+ });
170
+
171
+ test('should not deduplicate requests with skipDeduplication', async () => {
172
+ const promises = [
173
+ client.get('/slow'),
174
+ client.get('/slow', { skipDeduplication: true }),
175
+ ];
176
+
177
+ const responses = await Promise.all(promises);
178
+
179
+ expect(responses.length).toBe(2);
180
+ expect(requestCount).toBe(2);
181
+ });
182
+
183
+ test('should deduplicate POST requests with same body', async () => {
184
+ const body = { name: 'Test' };
185
+ const promises = [
186
+ client.post('/users', body),
187
+ client.post('/users', body),
188
+ ];
189
+
190
+ const responses = await Promise.all(promises);
191
+
192
+ expect(responses.length).toBe(2);
193
+ expect(requestCount).toBe(1);
194
+ });
195
+
196
+ test('should NOT deduplicate POST requests with different bodies', async () => {
197
+ const promises = [
198
+ client.post('/users', { name: 'Test1' }),
199
+ client.post('/users', { name: 'Test2' }),
200
+ ];
201
+
202
+ const responses = await Promise.all(promises);
203
+
204
+ expect(responses.length).toBe(2);
205
+ expect(requestCount).toBe(2);
206
+ });
207
+ });
208
+
209
+ describe('Cache for GET Requests', () => {
210
+ test('should cache successful GET responses', async () => {
211
+ const response1 = await client.get('/users');
212
+ const data1 = await response1.json();
213
+
214
+ const response2 = await client.get('/users');
215
+ const data2 = await response2.json();
216
+
217
+ expect(data1).toEqual(data2);
218
+ expect(requestCount).toBe(1);
219
+ });
220
+
221
+ test('should respect TTL for cached responses', async () => {
222
+ const response1 = await client.get('/users');
223
+ await response1.json();
224
+
225
+ expect(requestCount).toBe(1);
226
+
227
+ await new Promise(resolve => setTimeout(resolve, 1100));
228
+
229
+ const response2 = await client.get('/users');
230
+ await response2.json();
231
+
232
+ expect(requestCount).toBe(2);
233
+ });
234
+
235
+ test('should not cache failed responses', async () => {
236
+ await client.get('/nonexistent');
237
+ await client.get('/nonexistent');
238
+
239
+ expect(requestCount).toBe(2);
240
+ });
241
+
242
+ test('should clear cache', async () => {
243
+ await client.get('/users');
244
+ expect(requestCount).toBe(1);
245
+
246
+ client.clearCache();
247
+
248
+ await client.get('/users');
249
+ expect(requestCount).toBe(2);
250
+ });
251
+ });
252
+
253
+ describe('Deduplication Configuration', () => {
254
+ test('should disable deduplication when configured', async () => {
255
+ const noDedupeClient = createRPClient({
256
+ baseUrl: 'http://localhost:4000',
257
+ deduplication: { enabled: false },
258
+ });
259
+
260
+ const promises = [
261
+ noDedupeClient.get('/users'),
262
+ noDedupeClient.get('/users'),
263
+ ];
264
+
265
+ await Promise.all(promises);
266
+
267
+ expect(requestCount).toBe(2);
268
+ });
269
+
270
+ test('should use custom key generator', async () => {
271
+ let customKeyCalled = false;
272
+
273
+ const customClient = createRPClient({
274
+ baseUrl: 'http://localhost:4000',
275
+ deduplication: {
276
+ keyGenerator: (method, url, body) => {
277
+ customKeyCalled = true;
278
+ return `custom-${method}-${url}`;
279
+ },
280
+ },
281
+ });
282
+
283
+ await customClient.get('/users');
284
+
285
+ expect(customKeyCalled).toBe(true);
286
+ });
287
+
288
+ test('should expose deduplication config', () => {
289
+ expect(client.isDeduplicationEnabled()).toBe(true);
290
+ expect(client.getDeduplicationTTL()).toBe(1000);
291
+ });
292
+
293
+ test('should expose deduplication stats', async () => {
294
+ await client.get('/users');
295
+
296
+ const stats = client.getDeduplicationStats();
297
+ expect(stats.cached).toBe(1);
298
+ expect(stats.pending).toBe(0);
299
+ });
300
+ });
301
+
302
+ describe('withBaseUrl and withHeaders', () => {
303
+ test('should preserve deduplication config in withBaseUrl', async () => {
304
+ const newClient = client.withBaseUrl('http://localhost:4000');
305
+
306
+ await newClient.get('/users');
307
+ await newClient.get('/users');
308
+
309
+ expect(requestCount).toBe(1);
310
+ });
311
+
312
+ test('should preserve deduplication config in withHeaders', async () => {
313
+ const newClient = client.withHeaders({ 'X-Test': 'value' });
314
+
315
+ await newClient.get('/users');
316
+ await newClient.get('/users');
317
+
318
+ expect(requestCount).toBe(1);
319
+ });
320
+ });
321
+ });
322
+
323
+ describe('extractRouteTypes', () => {
324
+ test('should extract route information', () => {
325
+ const router = createMockApp();
326
+ const routes = extractRouteTypes(router);
327
+
328
+ expect(routes.length).toBeGreaterThan(0);
329
+ expect(routes[0]).toHaveProperty('method');
330
+ expect(routes[0]).toHaveProperty('path');
331
+ });
332
+ });
333
+
334
+ describe('Optimistic Updates', () => {
335
+ let client: RPCClient;
336
+ let server: ReturnType<typeof Bun.serve>;
337
+ let router: Router;
338
+
339
+ beforeEach(async () => {
340
+ router = new Router();
341
+ router.get('/users', (ctx) => ctx.json([{ id: 1, name: 'John' }]));
342
+ router.get('/users/:id', (ctx) => ctx.json({ id: parseInt(ctx.params.id), name: 'User' }));
343
+ router.post('/users', async (ctx) => {
344
+ const body = await ctx.req.json();
345
+ return ctx.json({ id: 3, ...body, created: true });
346
+ });
347
+ router.put('/users/:id', async (ctx) => {
348
+ const body = await ctx.req.json();
349
+ return ctx.json({ id: parseInt(ctx.params.id), ...body, updated: true });
350
+ });
351
+ router.patch('/users/:id', async (ctx) => {
352
+ const body = await ctx.req.json();
353
+ return ctx.json({ id: parseInt(ctx.params.id), ...body, patched: true });
354
+ });
355
+ router.delete('/users/:id', (ctx) => ctx.json({ deleted: true, id: parseInt(ctx.params.id) }));
356
+ router.post('/fail', (ctx) => new Response('Error', { status: 500 }));
357
+
358
+ server = Bun.serve({
359
+ port: 4001,
360
+ fetch: async (request: Request) => {
361
+ const url = new URL(request.url);
362
+ const match = router.match(request.method as 'GET', url.pathname);
363
+
364
+ if (!match) {
365
+ return new Response('Not Found', { status: 404 });
366
+ }
367
+
368
+ const context = new Context(request, match.params);
369
+ return match.handler(context);
370
+ },
371
+ });
372
+
373
+ client = createRPClient({
374
+ baseUrl: 'http://localhost:4001',
375
+ optimisticUpdates: {
376
+ enabled: true,
377
+ autoRollback: true,
378
+ },
379
+ });
380
+ });
381
+
382
+ afterEach(() => {
383
+ server.stop();
384
+ });
385
+
386
+ describe('Optimistic POST', () => {
387
+ test('should update cache optimistically on POST', async () => {
388
+ const optimisticData = { id: 3, name: 'New User' };
389
+
390
+ const { response, rollbackId } = await client.optimisticPost(
391
+ '/users',
392
+ { name: 'New User' },
393
+ { optimisticData, cacheKey: '/users' }
394
+ );
395
+
396
+ expect(response.ok).toBe(true);
397
+ expect(rollbackId).toBeDefined();
398
+
399
+ const data = await response.json();
400
+ expect(data.created).toBe(true);
401
+ });
402
+
403
+ test('should call onConfirm callback on success', async () => {
404
+ let confirmCalled = false;
405
+ const onConfirm = () => { confirmCalled = true; };
406
+
407
+ await client.optimisticPost(
408
+ '/users',
409
+ { name: 'New User' },
410
+ {
411
+ optimisticData: { id: 3, name: 'New User' },
412
+ cacheKey: '/users',
413
+ onConfirm
414
+ }
415
+ );
416
+
417
+ expect(confirmCalled).toBe(true);
418
+ });
419
+
420
+ test('should rollback on failure', async () => {
421
+ let rollbackCalled = false;
422
+ const onRollback = () => { rollbackCalled = true; };
423
+
424
+ const { response } = await client.optimisticPost(
425
+ '/fail',
426
+ { name: 'New User' },
427
+ {
428
+ optimisticData: { id: 3, name: 'New User' },
429
+ cacheKey: '/users',
430
+ onRollback
431
+ }
432
+ );
433
+
434
+ expect(response.ok).toBe(false);
435
+ expect(rollbackCalled).toBe(true);
436
+ });
437
+ });
438
+
439
+ describe('Optimistic PUT', () => {
440
+ test('should update cache optimistically on PUT', async () => {
441
+ const optimisticData = { id: 1, name: 'Updated User' };
442
+
443
+ const { response, rollbackId } = await client.optimisticPut(
444
+ '/users/1',
445
+ { name: 'Updated User' },
446
+ { optimisticData, cacheKey: '/users/1' }
447
+ );
448
+
449
+ expect(response.ok).toBe(true);
450
+ expect(rollbackId).toBeDefined();
451
+
452
+ const data = await response.json();
453
+ expect(data.updated).toBe(true);
454
+ });
455
+ });
456
+
457
+ describe('Optimistic PATCH', () => {
458
+ test('should update cache optimistically on PATCH', async () => {
459
+ const optimisticData = { id: 1, name: 'Patched User', updated: true };
460
+
461
+ const { response } = await client.optimisticPatch(
462
+ '/users/1',
463
+ { name: 'Patched User' },
464
+ { optimisticData, cacheKey: '/users/1' }
465
+ );
466
+
467
+ expect(response.ok).toBe(true);
468
+ });
469
+ });
470
+
471
+ describe('Optimistic DELETE', () => {
472
+ test('should update cache optimistically on DELETE', async () => {
473
+ const { response } = await client.optimisticDelete('/users/1', {
474
+ optimisticData: { deleted: true },
475
+ cacheKey: '/users/1',
476
+ });
477
+
478
+ expect(response.ok).toBe(true);
479
+
480
+ const data = await response.json();
481
+ expect(data.deleted).toBe(true);
482
+ });
483
+ });
484
+
485
+ describe('Manual Rollback', () => {
486
+ test('should manually rollback when autoRollback is disabled', async () => {
487
+ const manualClient = createRPClient({
488
+ baseUrl: 'http://localhost:4001',
489
+ optimisticUpdates: {
490
+ enabled: true,
491
+ autoRollback: false,
492
+ },
493
+ });
494
+
495
+ const { response, rollbackId } = await manualClient.optimisticPost(
496
+ '/fail',
497
+ { name: 'New User' },
498
+ { optimisticData: { id: 3, name: 'New User' }, cacheKey: '/users' }
499
+ );
500
+
501
+ expect(response.ok).toBe(false);
502
+ expect(rollbackId).toBeDefined();
503
+
504
+ const previousData = manualClient.rollback(rollbackId!);
505
+ expect(previousData).toBeUndefined();
506
+ });
507
+ });
508
+
509
+ describe('Pending Optimistic Updates', () => {
510
+ test('should check for pending optimistic updates', async () => {
511
+ expect(client.hasPendingOptimisticUpdate('/users')).toBe(false);
512
+
513
+ await client.optimisticPost(
514
+ '/users',
515
+ { name: 'New User' },
516
+ { optimisticData: { id: 3, name: 'New User' }, cacheKey: '/users' }
517
+ );
518
+
519
+ expect(client.hasPendingOptimisticUpdate('/users')).toBe(false);
520
+ });
521
+
522
+ test('should get optimistic data for pending update', () => {
523
+ const optimisticData = { id: 3, name: 'New User' };
524
+ expect(client.getOptimisticData('/users')).toBeUndefined();
525
+ });
526
+
527
+ test('should count pending optimistic updates', async () => {
528
+ expect(client.getPendingOptimisticCount()).toBe(0);
529
+ });
530
+
531
+ test('should clear all optimistic updates', async () => {
532
+ client.clearOptimisticUpdates();
533
+ expect(client.getPendingOptimisticCount()).toBe(0);
534
+ });
535
+ });
536
+
537
+ describe('Configuration', () => {
538
+ test('should check if optimistic updates are enabled', () => {
539
+ expect(client.isOptimisticUpdatesEnabled()).toBe(true);
540
+ });
541
+
542
+ test('should disable optimistic updates when configured', async () => {
543
+ const disabledClient = createRPClient({
544
+ baseUrl: 'http://localhost:4001',
545
+ optimisticUpdates: { enabled: false },
546
+ });
547
+
548
+ const { rollbackId } = await disabledClient.optimisticPost(
549
+ '/users',
550
+ { name: 'New User' },
551
+ { optimisticData: { id: 3, name: 'New User' }, cacheKey: '/users' }
552
+ );
553
+
554
+ expect(rollbackId).toBeUndefined();
555
+ });
556
+ });
557
+
558
+ describe('Cache Management', () => {
559
+ test('should clear all caches', () => {
560
+ client.clearAllCaches();
561
+
562
+ const stats = client.getDeduplicationStats();
563
+ expect(stats.cached).toBe(0);
564
+ expect(stats.pending).toBe(0);
565
+ });
566
+
567
+ test('should invalidate specific cache key', async () => {
568
+ await client.get('/users');
569
+
570
+ const statsBefore = client.getDeduplicationStats();
571
+ expect(statsBefore.cached).toBe(1);
572
+
573
+ client.clearCache();
574
+
575
+ const statsAfter = client.getDeduplicationStats();
576
+ expect(statsAfter.cached).toBe(0);
577
+ });
578
+ });
579
+ });
580
+
581
+ describe('Retry Logic', () => {
582
+ let client: RPCClient;
583
+ let server: ReturnType<typeof Bun.serve>;
584
+ let router: Router;
585
+ let requestCount: number;
586
+ let retryAttempts: number;
587
+
588
+ beforeEach(async () => {
589
+ requestCount = 0;
590
+ retryAttempts = 0;
591
+ router = new Router();
592
+ router.get('/users', (ctx) => {
593
+ requestCount++;
594
+ return ctx.json([{ id: 1, name: 'John' }]);
595
+ });
596
+ router.get('/flaky', (ctx) => {
597
+ requestCount++;
598
+ if (requestCount < 3) {
599
+ return new Response('Service Unavailable', { status: 503 });
600
+ }
601
+ return ctx.json({ success: true, attempt: requestCount });
602
+ });
603
+ router.get('/timeout', async (ctx) => {
604
+ requestCount++;
605
+ await new Promise(resolve => setTimeout(resolve, 200));
606
+ return ctx.json({ success: true });
607
+ });
608
+ router.get('/error', (ctx) => {
609
+ requestCount++;
610
+ return new Response('Internal Server Error', { status: 500 });
611
+ });
612
+ router.get('/always-fail', (ctx) => {
613
+ requestCount++;
614
+ return new Response('Always Fails', { status: 503 });
615
+ });
616
+ router.get('/nonexistent', (ctx) => {
617
+ requestCount++;
618
+ return new Response('Not Found', { status: 404 });
619
+ });
620
+
621
+ server = Bun.serve({
622
+ port: 4002,
623
+ fetch: async (request: Request) => {
624
+ const url = new URL(request.url);
625
+ const match = router.match(request.method as 'GET', url.pathname);
626
+
627
+ if (!match) {
628
+ return new Response('Not Found', { status: 404 });
629
+ }
630
+
631
+ const context = new Context(request, match.params);
632
+ return match.handler(context);
633
+ },
634
+ });
635
+
636
+ client = createRPClient({
637
+ baseUrl: 'http://localhost:4002',
638
+ retry: {
639
+ enabled: true,
640
+ maxAttempts: 3,
641
+ initialDelay: 50,
642
+ maxDelay: 500,
643
+ backoffMultiplier: 2,
644
+ retryableStatusCodes: [500, 502, 503, 504],
645
+ onRetry: (attempt, error, delay) => {
646
+ retryAttempts = attempt;
647
+ },
648
+ },
649
+ });
650
+ });
651
+
652
+ afterEach(() => {
653
+ server.stop();
654
+ });
655
+
656
+ describe('Basic Retry', () => {
657
+ test('should retry on retryable status codes', async () => {
658
+ const response = await client.get('/flaky');
659
+
660
+ expect(response.ok).toBe(true);
661
+ expect(requestCount).toBe(3);
662
+ expect(retryAttempts).toBe(2);
663
+
664
+ const data = await response.json();
665
+ expect(data.success).toBe(true);
666
+ });
667
+
668
+ test('should not retry on success', async () => {
669
+ const response = await client.get('/users');
670
+
671
+ expect(response.ok).toBe(true);
672
+ expect(requestCount).toBe(1);
673
+ });
674
+
675
+ test('should not retry on non-retryable status', async () => {
676
+ const response = await client.get('/nonexistent');
677
+
678
+ expect(response.status).toBe(404);
679
+ expect(requestCount).toBe(1);
680
+ });
681
+
682
+ test('should respect max attempts', async () => {
683
+ const response = await client.get('/always-fail');
684
+
685
+ expect(response.status).toBe(503);
686
+ expect(requestCount).toBe(3);
687
+ });
688
+ });
689
+
690
+ describe('Retry Configuration', () => {
691
+ test('should disable retry when configured', async () => {
692
+ const noRetryClient = createRPClient({
693
+ baseUrl: 'http://localhost:4002',
694
+ retry: { enabled: false },
695
+ });
696
+
697
+ const response = await noRetryClient.get('/always-fail');
698
+
699
+ expect(response.status).toBe(503);
700
+ expect(requestCount).toBe(1);
701
+ });
702
+
703
+ test('should use custom max attempts via per-request options', async () => {
704
+ const response = await client.get('/always-fail', {
705
+ retry: {
706
+ enabled: true,
707
+ maxAttempts: 2,
708
+ initialDelay: 10,
709
+ },
710
+ });
711
+
712
+ expect(response.status).toBe(503);
713
+ expect(requestCount).toBe(2);
714
+ });
715
+
716
+ test('should skip retry per-request', async () => {
717
+ const response = await client.get('/always-fail', {
718
+ retry: { skipRetry: true },
719
+ });
720
+
721
+ expect(response.status).toBe(503);
722
+ expect(requestCount).toBe(1);
723
+ });
724
+
725
+ test('should override retry options per-request', async () => {
726
+ const response = await client.get('/flaky', {
727
+ retry: {
728
+ enabled: true,
729
+ maxAttempts: 1,
730
+ },
731
+ });
732
+
733
+ expect(response.status).toBe(503);
734
+ expect(requestCount).toBe(1);
735
+ });
736
+ });
737
+
738
+ describe('Exponential Backoff', () => {
739
+ test('should calculate backoff delays correctly', async () => {
740
+ const delays: number[] = [];
741
+ let startTime = Date.now();
742
+
743
+ const backoffClient = createRPClient({
744
+ baseUrl: 'http://localhost:4002',
745
+ retry: {
746
+ enabled: true,
747
+ maxAttempts: 4,
748
+ initialDelay: 50,
749
+ maxDelay: 1000,
750
+ backoffMultiplier: 2,
751
+ onRetry: (attempt, error, delay) => {
752
+ delays.push(Date.now() - startTime);
753
+ startTime = Date.now();
754
+ },
755
+ },
756
+ });
757
+
758
+ requestCount = 0;
759
+ router.get('/backoff-test', (ctx) => {
760
+ requestCount++;
761
+ return new Response('Error', { status: 503 });
762
+ });
763
+
764
+ await backoffClient.get('/backoff-test');
765
+
766
+ expect(delays.length).toBeGreaterThanOrEqual(2);
767
+ });
768
+ });
769
+
770
+ describe('Retry Callbacks', () => {
771
+ test('should call onRetry callback', async () => {
772
+ let retryCount = 0;
773
+
774
+ const callbackClient = createRPClient({
775
+ baseUrl: 'http://localhost:4002',
776
+ retry: {
777
+ enabled: true,
778
+ maxAttempts: 3,
779
+ initialDelay: 10,
780
+ onRetry: (attempt, error, delay) => {
781
+ retryCount++;
782
+ },
783
+ },
784
+ });
785
+
786
+ await callbackClient.get('/always-fail');
787
+
788
+ expect(retryCount).toBe(2);
789
+ });
790
+
791
+ test('should call onRetry with error info', async () => {
792
+ let lastError: Error | null = null;
793
+
794
+ const callbackClient = createRPClient({
795
+ baseUrl: 'http://localhost:4002',
796
+ retry: {
797
+ enabled: true,
798
+ maxAttempts: 2,
799
+ initialDelay: 10,
800
+ onRetry: (attempt, error, delay) => {
801
+ lastError = error;
802
+ },
803
+ },
804
+ });
805
+
806
+ await callbackClient.get('/always-fail');
807
+
808
+ expect(lastError).toBeNull();
809
+ });
810
+ });
811
+
812
+ describe('Retry Utilities', () => {
813
+ test('should expose retry config', () => {
814
+ expect(client.isRetryEnabled()).toBe(true);
815
+ expect(client.getMaxRetryAttempts()).toBe(3);
816
+
817
+ const config = client.getRetryConfig();
818
+ expect(config.initialDelay).toBe(50);
819
+ expect(config.backoffMultiplier).toBe(2);
820
+ });
821
+
822
+ test('should use withRetry method', async () => {
823
+ const response = await client.withRetry('GET', '/flaky', undefined, {
824
+ maxAttempts: 2,
825
+ });
826
+
827
+ expect(response.status).toBe(503);
828
+ expect(requestCount).toBe(2);
829
+ });
830
+ });
831
+
832
+ describe('Custom Should Retry', () => {
833
+ test('should use custom shouldRetry function', async () => {
834
+ let customRetryCalled = false;
835
+
836
+ const customClient = createRPClient({
837
+ baseUrl: 'http://localhost:4002',
838
+ retry: {
839
+ enabled: true,
840
+ maxAttempts: 3,
841
+ initialDelay: 10,
842
+ shouldRetry: (response, error, attempt) => {
843
+ customRetryCalled = true;
844
+ return response?.status === 429;
845
+ },
846
+ },
847
+ });
848
+
849
+ const response = await customClient.get('/always-fail');
850
+
851
+ expect(customRetryCalled).toBe(true);
852
+ expect(requestCount).toBe(1);
853
+ });
854
+ });
855
+
856
+ describe('Client Preservation', () => {
857
+ test('should preserve retry config in withBaseUrl', async () => {
858
+ const newClient = client.withBaseUrl('http://localhost:4002');
859
+
860
+ const response = await newClient.get('/always-fail');
861
+
862
+ expect(response.status).toBe(503);
863
+ expect(requestCount).toBe(3);
864
+ });
865
+
866
+ test('should preserve retry config in withHeaders', async () => {
867
+ const newClient = client.withHeaders({ 'X-Test': 'value' });
868
+
869
+ const response = await newClient.get('/always-fail');
870
+
871
+ expect(response.status).toBe(503);
872
+ expect(requestCount).toBe(3);
873
+ });
874
+ });
875
+ });
876
+
877
+ describe('Interceptors', () => {
878
+ let client: RPCClient;
879
+ let server: ReturnType<typeof Bun.serve>;
880
+ let router: Router;
881
+
882
+ beforeEach(async () => {
883
+ router = new Router();
884
+ router.get('/users', (ctx) => ctx.json([{ id: 1, name: 'John' }]));
885
+ router.get('/error', (ctx) => new Response('Server Error', { status: 500 }));
886
+ router.post('/users', async (ctx) => {
887
+ const body = await ctx.req.json();
888
+ return ctx.json({ created: true, ...body });
889
+ });
890
+
891
+ server = Bun.serve({
892
+ port: 4003,
893
+ fetch: async (request: Request) => {
894
+ const url = new URL(request.url);
895
+ const match = router.match(request.method as 'GET', url.pathname);
896
+
897
+ if (!match) {
898
+ return new Response('Not Found', { status: 404 });
899
+ }
900
+
901
+ const context = new Context(request, match.params);
902
+ return match.handler(context);
903
+ },
904
+ });
905
+ });
906
+
907
+ afterEach(() => {
908
+ server.stop();
909
+ });
910
+
911
+ describe('Request Interceptors', () => {
912
+ test('should apply single request interceptor', async () => {
913
+ let interceptedUrl = '';
914
+
915
+ client = createRPClient({
916
+ baseUrl: 'http://localhost:4003',
917
+ interceptors: {
918
+ request: (config) => {
919
+ interceptedUrl = config.url;
920
+ return config;
921
+ },
922
+ },
923
+ });
924
+
925
+ await client.get('/users');
926
+
927
+ expect(interceptedUrl).toBe('http://localhost:4003/users');
928
+ });
929
+
930
+ test('should apply multiple request interceptors in order', async () => {
931
+ const order: number[] = [];
932
+
933
+ client = createRPClient({
934
+ baseUrl: 'http://localhost:4003',
935
+ interceptors: {
936
+ request: [
937
+ (config) => {
938
+ order.push(1);
939
+ return config;
940
+ },
941
+ (config) => {
942
+ order.push(2);
943
+ return config;
944
+ },
945
+ ],
946
+ },
947
+ });
948
+
949
+ await client.get('/users');
950
+
951
+ expect(order).toEqual([1, 2]);
952
+ });
953
+
954
+ test('should modify request config', async () => {
955
+ client = createRPClient({
956
+ baseUrl: 'http://localhost:4003',
957
+ interceptors: {
958
+ request: (config) => {
959
+ config.headers = { ...config.headers, 'X-Custom-Header': 'test-value' };
960
+ return config;
961
+ },
962
+ },
963
+ });
964
+
965
+ const response = await client.get('/users');
966
+ expect(response.ok).toBe(true);
967
+ });
968
+
969
+ test('should add request interceptor dynamically', async () => {
970
+ let interceptorCalled = false;
971
+
972
+ client = createRPClient({ baseUrl: 'http://localhost:4003' });
973
+ client.addRequestInterceptor((config) => {
974
+ interceptorCalled = true;
975
+ return config;
976
+ });
977
+
978
+ await client.get('/users');
979
+
980
+ expect(interceptorCalled).toBe(true);
981
+ });
982
+
983
+ test('should remove request interceptor', async () => {
984
+ let callCount = 0;
985
+
986
+ const interceptor = (config: any) => {
987
+ callCount++;
988
+ return config;
989
+ };
990
+
991
+ client = createRPClient({ baseUrl: 'http://localhost:4003' });
992
+ client.addRequestInterceptor(interceptor);
993
+
994
+ await client.get('/users');
995
+ expect(callCount).toBe(1);
996
+
997
+ client.removeRequestInterceptor(interceptor);
998
+
999
+ await client.get('/users');
1000
+ expect(callCount).toBe(1);
1001
+ });
1002
+ });
1003
+
1004
+ describe('Response Interceptors', () => {
1005
+ test('should apply single response interceptor', async () => {
1006
+ let interceptedStatus = 0;
1007
+
1008
+ client = createRPClient({
1009
+ baseUrl: 'http://localhost:4003',
1010
+ interceptors: {
1011
+ response: (response) => {
1012
+ interceptedStatus = response.status;
1013
+ return response;
1014
+ },
1015
+ },
1016
+ });
1017
+
1018
+ await client.get('/users');
1019
+
1020
+ expect(interceptedStatus).toBe(200);
1021
+ });
1022
+
1023
+ test('should apply multiple response interceptors', async () => {
1024
+ const order: number[] = [];
1025
+
1026
+ client = createRPClient({
1027
+ baseUrl: 'http://localhost:4003',
1028
+ interceptors: {
1029
+ response: [
1030
+ (response) => {
1031
+ order.push(1);
1032
+ return response;
1033
+ },
1034
+ (response) => {
1035
+ order.push(2);
1036
+ return response;
1037
+ },
1038
+ ],
1039
+ },
1040
+ });
1041
+
1042
+ await client.get('/users');
1043
+
1044
+ expect(order).toEqual([1, 2]);
1045
+ });
1046
+
1047
+ test('should transform response', async () => {
1048
+ client = createRPClient({
1049
+ baseUrl: 'http://localhost:4003',
1050
+ interceptors: {
1051
+ response: (response) => {
1052
+ const headers = new Headers(response.headers);
1053
+ headers.set('X-Intercepted', 'true');
1054
+ return new Response(response.body, {
1055
+ status: response.status,
1056
+ headers,
1057
+ });
1058
+ },
1059
+ },
1060
+ });
1061
+
1062
+ const response = await client.get('/users');
1063
+ expect(response.headers.get('X-Intercepted')).toBe('true');
1064
+ });
1065
+
1066
+ test('should add response interceptor dynamically', async () => {
1067
+ let interceptorCalled = false;
1068
+
1069
+ client = createRPClient({ baseUrl: 'http://localhost:4003' });
1070
+ client.addResponseInterceptor((response) => {
1071
+ interceptorCalled = true;
1072
+ return response;
1073
+ });
1074
+
1075
+ await client.get('/users');
1076
+
1077
+ expect(interceptorCalled).toBe(true);
1078
+ });
1079
+ });
1080
+
1081
+ describe('Error Interceptors', () => {
1082
+ test('should handle network errors', async () => {
1083
+ let errorInterceptorCalled = false;
1084
+
1085
+ client = createRPClient({
1086
+ baseUrl: 'http://localhost:9999',
1087
+ timeout: 100,
1088
+ retry: { enabled: false },
1089
+ interceptors: {
1090
+ error: (error) => {
1091
+ errorInterceptorCalled = true;
1092
+ },
1093
+ },
1094
+ });
1095
+
1096
+ try {
1097
+ await client.get('/users');
1098
+ } catch {
1099
+ }
1100
+
1101
+ expect(errorInterceptorCalled).toBe(true);
1102
+ });
1103
+
1104
+ test('should allow error interceptor to return fallback response', async () => {
1105
+ client = createRPClient({
1106
+ baseUrl: 'http://localhost:9999',
1107
+ timeout: 100,
1108
+ retry: { enabled: false },
1109
+ interceptors: {
1110
+ error: () => {
1111
+ return new Response(JSON.stringify({ fallback: true }), {
1112
+ status: 200,
1113
+ headers: { 'Content-Type': 'application/json' },
1114
+ });
1115
+ },
1116
+ },
1117
+ });
1118
+
1119
+ const response = await client.get('/users');
1120
+ const data = await response.json();
1121
+
1122
+ expect(data.fallback).toBe(true);
1123
+ });
1124
+
1125
+ test('should add error interceptor dynamically', async () => {
1126
+ let interceptorCalled = false;
1127
+
1128
+ client = createRPClient({
1129
+ baseUrl: 'http://localhost:9999',
1130
+ timeout: 100,
1131
+ retry: { enabled: false },
1132
+ });
1133
+ client.addErrorInterceptor(() => {
1134
+ interceptorCalled = true;
1135
+ });
1136
+
1137
+ try {
1138
+ await client.get('/users');
1139
+ } catch {
1140
+ }
1141
+
1142
+ expect(interceptorCalled).toBe(true);
1143
+ });
1144
+ });
1145
+
1146
+ describe('Interceptor Management', () => {
1147
+ test('should clear all interceptors', async () => {
1148
+ let requestCalled = false;
1149
+ let responseCalled = false;
1150
+ let errorCalled = false;
1151
+
1152
+ client = createRPClient({
1153
+ baseUrl: 'http://localhost:4003',
1154
+ interceptors: {
1155
+ request: (config) => {
1156
+ requestCalled = true;
1157
+ return config;
1158
+ },
1159
+ response: (response) => {
1160
+ responseCalled = true;
1161
+ return response;
1162
+ },
1163
+ },
1164
+ });
1165
+ client.addErrorInterceptor(() => {
1166
+ errorCalled = true;
1167
+ });
1168
+
1169
+ client.clearInterceptors();
1170
+
1171
+ await client.get('/users');
1172
+
1173
+ expect(requestCalled).toBe(false);
1174
+ expect(responseCalled).toBe(false);
1175
+ });
1176
+
1177
+ test('should get interceptor stats', async () => {
1178
+ client = createRPClient({
1179
+ baseUrl: 'http://localhost:4003',
1180
+ interceptors: {
1181
+ request: [(config) => config, (config) => config],
1182
+ response: (response) => response,
1183
+ },
1184
+ });
1185
+ client.addErrorInterceptor(() => {});
1186
+
1187
+ const stats = client.getInterceptorStats();
1188
+
1189
+ expect(stats.request).toBe(2);
1190
+ expect(stats.response).toBe(1);
1191
+ expect(stats.error).toBe(1);
1192
+ });
1193
+
1194
+ test('should create client with interceptors', async () => {
1195
+ client = createRPClient({ baseUrl: 'http://localhost:4003' });
1196
+
1197
+ const newClient = client.withInterceptors({
1198
+ request: (config) => {
1199
+ config.headers = { ...config.headers, 'X-New': 'value' };
1200
+ return config;
1201
+ },
1202
+ });
1203
+
1204
+ const response = await newClient.get('/users');
1205
+ expect(response.ok).toBe(true);
1206
+ });
1207
+ });
1208
+
1209
+ describe('Interceptor Preservation', () => {
1210
+ test('should preserve interceptors in withBaseUrl', async () => {
1211
+ let interceptorCalled = false;
1212
+
1213
+ client = createRPClient({
1214
+ baseUrl: 'http://localhost:4003',
1215
+ interceptors: {
1216
+ request: (config) => {
1217
+ interceptorCalled = true;
1218
+ return config;
1219
+ },
1220
+ },
1221
+ });
1222
+
1223
+ const newClient = client.withBaseUrl('http://localhost:4003');
1224
+ await newClient.get('/users');
1225
+
1226
+ expect(interceptorCalled).toBe(true);
1227
+ });
1228
+
1229
+ test('should preserve interceptors in withHeaders', async () => {
1230
+ let interceptorCalled = false;
1231
+
1232
+ client = createRPClient({
1233
+ baseUrl: 'http://localhost:4003',
1234
+ interceptors: {
1235
+ request: (config) => {
1236
+ interceptorCalled = true;
1237
+ return config;
1238
+ },
1239
+ },
1240
+ });
1241
+
1242
+ const newClient = client.withHeaders({ 'X-Test': 'value' });
1243
+ await newClient.get('/users');
1244
+
1245
+ expect(interceptorCalled).toBe(true);
1246
+ });
1247
+ });
1248
+ });