@majkapp/plugin-kit 3.5.4 → 3.6.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.
@@ -0,0 +1,518 @@
1
+ # RPC Callbacks
2
+
3
+ RPC callbacks allow you to pass callback functions across plugin boundaries. The MAJK RPC system automatically handles callback serialization, invocation, and cleanup.
4
+
5
+ ## Overview
6
+
7
+ When building plugins that communicate via RPC, you can now pass callback functions as arguments. The system automatically:
8
+ - **Wraps callbacks** into serializable handles
9
+ - **Registers them** as temporary RPC services
10
+ - **Invokes them** when called from the remote side
11
+ - **Cleans them up** based on your chosen strategy
12
+
13
+ ## Basic Usage (Inline Callbacks)
14
+
15
+ The simplest way to use callbacks is to pass them directly as function arguments. These have **no automatic cleanup** by default.
16
+
17
+ ```typescript
18
+ // Plugin A: Register a service that accepts callbacks
19
+ const plugin = definePlugin('file-processor', 'File Processor', '1.0.0')
20
+ .pluginRoot(__dirname)
21
+ .onLoad(async (ctx) => {
22
+ // Register service with callback parameter
23
+ await ctx.rpc.registerService('processor', {
24
+ async processFile(
25
+ filePath: string,
26
+ onProgress: (percent: number, message: string) => void
27
+ ): Promise<string> {
28
+ // Call the callback as the file processes
29
+ await onProgress(0, 'Starting...');
30
+ await onProgress(25, 'Reading file...');
31
+ await onProgress(50, 'Processing...');
32
+ await onProgress(75, 'Writing results...');
33
+ await onProgress(100, 'Complete!');
34
+
35
+ return `Processed: ${filePath}`;
36
+ }
37
+ });
38
+ })
39
+ .build();
40
+ ```
41
+
42
+ ```typescript
43
+ // Plugin B: Use the service with inline callback
44
+ const plugin = definePlugin('file-consumer', 'File Consumer', '1.0.0')
45
+ .pluginRoot(__dirname)
46
+ .onLoad(async (ctx) => {
47
+ // Create proxy to the remote service
48
+ const processor = await ctx.rpc.createProxy<{
49
+ processFile(path: string, onProgress: (p: number, m: string) => void): Promise<string>
50
+ }>('processor');
51
+
52
+ // Pass callback directly - it just works!
53
+ const result = await processor.processFile('/data/large-file.txt', (percent, message) => {
54
+ console.log(`Progress: ${percent}% - ${message}`);
55
+ });
56
+
57
+ console.log(result); // "Processed: /data/large-file.txt"
58
+ })
59
+ .build();
60
+ ```
61
+
62
+ **Key Point**: Inline callbacks live forever (no automatic cleanup). This is perfect for:
63
+ - One-time operations (file processing, data fetching)
64
+ - Short-lived interactions
65
+ - Callbacks that will be called a known number of times
66
+
67
+ ## Explicit Callbacks with Cleanup Strategies
68
+
69
+ For long-lived callbacks (subscriptions, event listeners, etc.), use `createCallback()` to manage cleanup explicitly.
70
+
71
+ ### Max Calls Strategy
72
+
73
+ Auto-cleanup after N invocations:
74
+
75
+ ```typescript
76
+ // Plugin A: Event emitter service
77
+ await ctx.rpc.registerService('events', {
78
+ async subscribe(callback: (event: string) => void): Promise<void> {
79
+ // callback will be called multiple times
80
+ setInterval(() => callback('tick'), 1000);
81
+ }
82
+ });
83
+ ```
84
+
85
+ ```typescript
86
+ // Plugin B: Subscribe with maxCalls limit
87
+ const events = await ctx.rpc.createProxy<{
88
+ subscribe(cb: (event: string) => void): Promise<void>
89
+ }>('events');
90
+
91
+ // Create callback that self-destructs after 10 calls
92
+ const callback = await ctx.rpc.createCallback!(
93
+ (event: string) => {
94
+ console.log('Event:', event);
95
+ },
96
+ { maxCalls: 10 } // Auto-cleanup after 10 invocations
97
+ );
98
+
99
+ await events.subscribe(callback);
100
+ // After 10 events, the callback is automatically cleaned up
101
+ ```
102
+
103
+ ### Timeout Strategy
104
+
105
+ Auto-cleanup after time expires:
106
+
107
+ ```typescript
108
+ // Subscribe for only 5 seconds
109
+ const callback = await ctx.rpc.createCallback!(
110
+ (data: string) => {
111
+ console.log('Received:', data);
112
+ },
113
+ { timeout: 5000 } // Auto-cleanup after 5 seconds
114
+ );
115
+
116
+ await dataStream.subscribe(callback);
117
+ // After 5 seconds, callback is cleaned up automatically
118
+ ```
119
+
120
+ ### Manual Cleanup
121
+
122
+ For complete control, clean up manually:
123
+
124
+ ```typescript
125
+ // Create callback without auto-cleanup strategies
126
+ const callback = await ctx.rpc.createCallback!((message: string) => {
127
+ console.log('Message:', message);
128
+ });
129
+
130
+ await messageService.subscribe(callback);
131
+
132
+ // Later... manually clean up when done
133
+ ctx.rpc.cleanupCallback!(callback);
134
+ ```
135
+
136
+ ## Real-World Patterns
137
+
138
+ ### Pattern 1: File Processing with Progress
139
+
140
+ ```typescript
141
+ // Service side
142
+ await ctx.rpc.registerService('fileOps', {
143
+ async batchProcess(
144
+ files: string[],
145
+ onFileComplete: (file: string, result: string) => void,
146
+ onError: (file: string, error: string) => void
147
+ ): Promise<void> {
148
+ for (const file of files) {
149
+ try {
150
+ const result = await processFile(file);
151
+ await onFileComplete(file, result);
152
+ } catch (error) {
153
+ await onError(file, error.message);
154
+ }
155
+ }
156
+ }
157
+ });
158
+
159
+ // Client side
160
+ const fileOps = await ctx.rpc.createProxy<typeof FileOpsService>('fileOps');
161
+
162
+ await fileOps.batchProcess(
163
+ ['/file1.txt', '/file2.txt', '/file3.txt'],
164
+ (file, result) => {
165
+ console.log(`✓ ${file}: ${result}`);
166
+ },
167
+ (file, error) => {
168
+ console.error(`✗ ${file}: ${error}`);
169
+ }
170
+ );
171
+ ```
172
+
173
+ ### Pattern 2: Real-Time Data Subscriptions
174
+
175
+ ```typescript
176
+ // Service side: Data stream
177
+ await ctx.rpc.registerService('dataStream', {
178
+ async subscribe(
179
+ filter: string,
180
+ onData: (data: any) => void
181
+ ): Promise<() => void> {
182
+ const subscription = dataSource.on(filter, onData);
183
+
184
+ // Return unsubscribe function (also a callback!)
185
+ return () => subscription.close();
186
+ }
187
+ });
188
+
189
+ // Client side
190
+ const stream = await ctx.rpc.createProxy<{
191
+ subscribe(filter: string, onData: (data: any) => void): Promise<() => void>
192
+ }>('dataStream');
193
+
194
+ // Subscribe with 1-minute timeout
195
+ const callback = await ctx.rpc.createCallback!(
196
+ (data: any) => {
197
+ console.log('New data:', data);
198
+ },
199
+ { timeout: 60000 }
200
+ );
201
+
202
+ const unsubscribe = await stream.subscribe('temperature > 100', callback);
203
+
204
+ // When done early, clean up both callback and subscription
205
+ unsubscribe(); // Stop receiving data
206
+ ctx.rpc.cleanupCallback!(callback); // Clean up callback
207
+ ```
208
+
209
+ ### Pattern 3: Filtering with Predicates
210
+
211
+ ```typescript
212
+ // Service side
213
+ await ctx.rpc.registerService('dataService', {
214
+ async getFiltered(
215
+ items: any[],
216
+ predicate: (item: any) => boolean | Promise<boolean>
217
+ ): Promise<any[]> {
218
+ const filtered = [];
219
+ for (const item of items) {
220
+ if (await predicate(item)) {
221
+ filtered.push(item);
222
+ }
223
+ }
224
+ return filtered;
225
+ }
226
+ });
227
+
228
+ // Client side
229
+ const dataService = await ctx.rpc.createProxy<typeof DataService>('dataService');
230
+
231
+ const filtered = await dataService.getFiltered(
232
+ allUsers,
233
+ (user) => user.age > 18 && user.active
234
+ );
235
+ ```
236
+
237
+ ### Pattern 4: Event Hooks
238
+
239
+ ```typescript
240
+ // Service side: Task runner with hooks
241
+ await ctx.rpc.registerService('taskRunner', {
242
+ async runTask(
243
+ taskName: string,
244
+ hooks: {
245
+ onStart?: () => void,
246
+ onProgress?: (percent: number) => void,
247
+ onComplete?: (result: any) => void,
248
+ onError?: (error: string) => void
249
+ }
250
+ ): Promise<void> {
251
+ try {
252
+ await hooks.onStart?.();
253
+
254
+ // Execute task with progress updates
255
+ for (let i = 0; i <= 100; i += 10) {
256
+ await hooks.onProgress?.(i);
257
+ await simulateWork();
258
+ }
259
+
260
+ const result = await finalizeTask();
261
+ await hooks.onComplete?.(result);
262
+ } catch (error) {
263
+ await hooks.onError?.(error.message);
264
+ }
265
+ }
266
+ });
267
+
268
+ // Client side
269
+ const taskRunner = await ctx.rpc.createProxy<typeof TaskRunner>('taskRunner');
270
+
271
+ await taskRunner.runTask('compile-project', {
272
+ onStart: () => console.log('Starting compilation...'),
273
+ onProgress: (p) => console.log(`Progress: ${p}%`),
274
+ onComplete: (result) => console.log('Done!', result),
275
+ onError: (error) => console.error('Failed:', error)
276
+ });
277
+ ```
278
+
279
+ ## Error Handling
280
+
281
+ ### Callback Cleaned Up Error
282
+
283
+ If a callback is invoked after cleanup, a `CallbackCleanedUpError` is thrown:
284
+
285
+ ```typescript
286
+ const callback = await ctx.rpc.createCallback!(
287
+ (data) => console.log(data),
288
+ { maxCalls: 1 }
289
+ );
290
+
291
+ await service.subscribe(callback);
292
+ // After 1 call, callback is cleaned up
293
+
294
+ // If service tries to call it again:
295
+ // Error: Callback xyz has been cleaned up and is no longer available.
296
+ // This can happen if: 1) maxCalls limit was reached,
297
+ // 2) timeout expired,
298
+ // 3) cleanupCallback() was called manually.
299
+ ```
300
+
301
+ ### Handling Remote Errors
302
+
303
+ Errors thrown in callbacks propagate back to the caller:
304
+
305
+ ```typescript
306
+ // Service side
307
+ await service.process(
308
+ files,
309
+ async (file) => {
310
+ if (!file.exists()) {
311
+ throw new Error(`File not found: ${file.path}`);
312
+ }
313
+ }
314
+ );
315
+ // Error will be received by service with the message
316
+ ```
317
+
318
+ ### Robust Error Handling Pattern
319
+
320
+ ```typescript
321
+ // Service side
322
+ async processWithCallback(
323
+ data: any[],
324
+ callback: (item: any) => Promise<void>
325
+ ): Promise<{ success: any[], errors: any[] }> {
326
+ const success = [];
327
+ const errors = [];
328
+
329
+ for (const item of data) {
330
+ try {
331
+ await callback(item);
332
+ success.push(item);
333
+ } catch (error) {
334
+ errors.push({ item, error: error.message });
335
+ }
336
+ }
337
+
338
+ return { success, errors };
339
+ }
340
+ ```
341
+
342
+ ## Monitoring and Debugging
343
+
344
+ ### Get Callback Statistics
345
+
346
+ ```typescript
347
+ const stats = ctx.rpc.getCallbackStats!();
348
+ console.log('Active callbacks:', stats.activeCallbacks);
349
+ console.log('Inline callbacks:', stats.inlineCallbacks);
350
+ console.log('Explicit callbacks:', stats.explicitCallbacks);
351
+ console.log('Total created:', stats.totalCreated);
352
+ console.log('Total cleaned:', stats.totalCleaned);
353
+ ```
354
+
355
+ **Statistics breakdown**:
356
+ - `activeCallbacks`: Currently registered callbacks
357
+ - `inlineCallbacks`: Auto-wrapped inline callbacks (no cleanup)
358
+ - `explicitCallbacks`: Explicitly created via `createCallback()`
359
+ - `totalCreated`: Lifetime count of callbacks created
360
+ - `totalCleaned`: Lifetime count of callbacks cleaned up
361
+
362
+ ### Debugging Tips
363
+
364
+ 1. **Use timeouts for testing**: When developing, use short timeouts to test cleanup behavior:
365
+ ```typescript
366
+ const callback = await ctx.rpc.createCallback!(fn, { timeout: 5000 });
367
+ ```
368
+
369
+ 2. **Monitor stats**: Check stats periodically to detect callback leaks:
370
+ ```typescript
371
+ setInterval(() => {
372
+ const stats = ctx.rpc.getCallbackStats!();
373
+ if (stats.activeCallbacks > 100) {
374
+ console.warn('High callback count - possible leak');
375
+ }
376
+ }, 60000);
377
+ ```
378
+
379
+ 3. **Log cleanup**: Add logging to understand cleanup timing:
380
+ ```typescript
381
+ const callback = await ctx.rpc.createCallback!(
382
+ (data) => {
383
+ console.log('Callback invoked:', data);
384
+ },
385
+ { maxCalls: 10 }
386
+ );
387
+ console.log('Created callback:', callback.callbackId);
388
+ ```
389
+
390
+ ## Best Practices
391
+
392
+ ### ✅ DO
393
+
394
+ - **Use inline callbacks for simple, one-time operations**
395
+ ```typescript
396
+ await service.processFile(file, (progress) => console.log(progress));
397
+ ```
398
+
399
+ - **Use `createCallback()` with `maxCalls` for subscriptions**
400
+ ```typescript
401
+ const cb = await ctx.rpc.createCallback!(handler, { maxCalls: 100 });
402
+ ```
403
+
404
+ - **Use `createCallback()` with `timeout` for time-limited operations**
405
+ ```typescript
406
+ const cb = await ctx.rpc.createCallback!(handler, { timeout: 30000 });
407
+ ```
408
+
409
+ - **Clean up manually when you control the lifecycle**
410
+ ```typescript
411
+ const cb = await ctx.rpc.createCallback!(handler);
412
+ // ... use callback ...
413
+ ctx.rpc.cleanupCallback!(cb);
414
+ ```
415
+
416
+ - **Handle errors in callbacks gracefully**
417
+ ```typescript
418
+ (data) => {
419
+ try {
420
+ processData(data);
421
+ } catch (error) {
422
+ console.error('Callback error:', error);
423
+ }
424
+ }
425
+ ```
426
+
427
+ ### ❌ DON'T
428
+
429
+ - **Don't use inline callbacks for long-lived subscriptions**
430
+ ```typescript
431
+ // ❌ Bad: No cleanup, callback lives forever
432
+ await eventStream.subscribe((event) => console.log(event));
433
+
434
+ // ✅ Good: Use timeout or manual cleanup
435
+ const cb = await ctx.rpc.createCallback!(
436
+ (event) => console.log(event),
437
+ { timeout: 60000 }
438
+ );
439
+ await eventStream.subscribe(cb);
440
+ ```
441
+
442
+ - **Don't forget to clean up when lifecycle is known**
443
+ ```typescript
444
+ // ❌ Bad: No cleanup on plugin unload
445
+ await ctx.rpc.createCallback!(handler);
446
+
447
+ // ✅ Good: Track and clean up
448
+ const callbacks = [];
449
+ callbacks.push(await ctx.rpc.createCallback!(handler));
450
+
451
+ // In onUnload:
452
+ callbacks.forEach(cb => ctx.rpc.cleanupCallback!(cb));
453
+ ```
454
+
455
+ - **Don't pass complex objects through callbacks**
456
+ ```typescript
457
+ // ❌ Bad: Large objects, circular references
458
+ callback({ largeData: hugeArray, circular: someObjectWithCircularRefs });
459
+
460
+ // ✅ Good: Pass only necessary data
461
+ callback({ id: item.id, status: item.status });
462
+ ```
463
+
464
+ ## Type Safety
465
+
466
+ All callbacks maintain full TypeScript type safety:
467
+
468
+ ```typescript
469
+ // Service definition with typed callbacks
470
+ interface FileService {
471
+ processFile(
472
+ path: string,
473
+ onProgress: (percent: number, message: string) => void,
474
+ onError: (error: Error) => void
475
+ ): Promise<string>;
476
+ }
477
+
478
+ // Client gets full type checking
479
+ const service = await ctx.rpc.createProxy<FileService>('fileService');
480
+
481
+ await service.processFile(
482
+ '/file.txt',
483
+ (percent, message) => {
484
+ // 'percent' is number, 'message' is string - fully typed!
485
+ },
486
+ (error) => {
487
+ // 'error' is Error - fully typed!
488
+ }
489
+ );
490
+ ```
491
+
492
+ ## Backward Compatibility
493
+
494
+ The callback feature is **100% backward compatible**:
495
+ - All callback methods are optional (`createCallback?`, `cleanupCallback?`, `getCallbackStats?`)
496
+ - Existing plugins work unchanged
497
+ - RPC calls without callbacks work exactly as before
498
+ - No breaking changes to existing APIs
499
+
500
+ ## Limitations
501
+
502
+ 1. **Serialization**: Callbacks must not capture complex objects or closures that reference non-serializable data
503
+ 2. **Network boundaries**: Callbacks work within the same MAJK process (in-process RPC)
504
+ 3. **Performance**: Each callback creates a temporary RPC service - avoid creating thousands at once
505
+ 4. **Memory**: Inline callbacks without cleanup will remain in memory until the bridge is destroyed
506
+
507
+ ## Summary
508
+
509
+ RPC callbacks make inter-plugin communication natural and intuitive:
510
+
511
+ | Use Case | Strategy | Example |
512
+ |----------|----------|---------|
513
+ | One-time operations | Inline callback | `process(file, (progress) => ...)` |
514
+ | N-limited subscriptions | `maxCalls` | `createCallback(fn, { maxCalls: 10 })` |
515
+ | Time-limited operations | `timeout` | `createCallback(fn, { timeout: 5000 })` |
516
+ | Controlled lifecycle | Manual cleanup | `createCallback(fn)` + `cleanupCallback()` |
517
+
518
+ Choose the right strategy for your use case, and let the RPC system handle the rest!
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@majkapp/plugin-kit",
3
- "version": "3.5.4",
3
+ "version": "3.6.0",
4
4
  "description": "Pure plugin definition library for MAJK - outputs plugin definitions, not HTTP servers",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -33,6 +33,7 @@
33
33
  "promptable:hooks": "node ./bin/promptable-cli.js --hooks",
34
34
  "promptable:context": "node ./bin/promptable-cli.js --context",
35
35
  "promptable:services": "node ./bin/promptable-cli.js --services",
36
+ "promptable:rpc": "node ./bin/promptable-cli.js --rpc",
36
37
  "promptable:lifecycle": "node ./bin/promptable-cli.js --lifecycle",
37
38
  "promptable:testing": "node ./bin/promptable-cli.js --testing",
38
39
  "promptable:config": "node ./bin/promptable-cli.js --config",