@majkapp/plugin-kit 3.2.1 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/INDEX.md ADDED
@@ -0,0 +1,605 @@
1
+ # @majkapp/plugin-kit
2
+
3
+ Build MAJK plugins with functions, UI screens, and services. Test with `@majkapp/plugin-test`.
4
+
5
+ ## Quick Start
6
+
7
+ ```typescript
8
+ // src/index.ts
9
+ import { definePlugin } from '@majkapp/plugin-kit';
10
+
11
+ const plugin = definePlugin('my-plugin', 'My Plugin', '1.0.0')
12
+ .pluginRoot(__dirname) // REQUIRED FIRST - enables resource loading
13
+
14
+ .function('health', {
15
+ description: 'Check plugin health status',
16
+ input: {
17
+ type: 'object',
18
+ properties: {},
19
+ additionalProperties: false
20
+ },
21
+ output: {
22
+ type: 'object',
23
+ properties: {
24
+ status: { type: 'string', enum: ['ok', 'error'] },
25
+ timestamp: { type: 'string', format: 'date-time' }
26
+ },
27
+ required: ['status', 'timestamp']
28
+ },
29
+ handler: async (input, ctx) => {
30
+ // ctx.logger - scoped logging (debug, info, warn, error)
31
+ ctx.logger.info('Health check requested');
32
+
33
+ return {
34
+ status: 'ok',
35
+ timestamp: new Date().toISOString()
36
+ };
37
+ },
38
+ tags: ['monitoring']
39
+ })
40
+
41
+ .screenReact({
42
+ id: 'my-plugin-main',
43
+ name: 'My Plugin',
44
+ description: 'Main plugin screen',
45
+ route: '/plugin-screens/my-plugin/main',
46
+ pluginPath: '/index.html' // Path to built React app
47
+ })
48
+
49
+ .build();
50
+
51
+ export = plugin;
52
+ ```
53
+
54
+ ## Testing Your Function
55
+
56
+ ```typescript
57
+ // tests/plugin/functions/unit/health.test.js
58
+ import { test } from '@majkapp/plugin-test';
59
+ import { invoke, mock } from '@majkapp/plugin-test';
60
+ import assert from 'assert';
61
+
62
+ test('health check returns ok status', async () => {
63
+ // Create mock context - no real MAJK instance needed
64
+ const context = mock().build();
65
+
66
+ // Invoke function with mock context
67
+ const result = await invoke('health', {}, { context });
68
+
69
+ // Verify output matches schema
70
+ assert.strictEqual(result.status, 'ok');
71
+ assert.ok(result.timestamp);
72
+ assert.ok(new Date(result.timestamp).getTime() > 0);
73
+ });
74
+ ```
75
+
76
+ Run test:
77
+ ```bash
78
+ cd my-plugin
79
+ npx majk-test tests/plugin/functions/unit/health.test.js
80
+ ```
81
+
82
+ Output:
83
+ ```
84
+ ✓ health check returns ok status (2ms)
85
+ ```
86
+
87
+ ## Project Structure
88
+
89
+ ```
90
+ my-plugin/
91
+ ├── src/
92
+ │ ├── index.ts # Plugin definition
93
+ │ └── core/ # Business logic (no plugin deps)
94
+ │ └── analytics.ts # Pure functions, easy to test
95
+ ├── ui/
96
+ │ ├── src/
97
+ │ │ ├── App.tsx # React app
98
+ │ │ ├── components/ # UI components
99
+ │ │ └── generated/ # Auto-generated hooks
100
+ │ │ ├── hooks.ts # useHealth(), etc.
101
+ │ │ └── client.ts # RPC client
102
+ │ └── index.html
103
+ ├── tests/
104
+ │ └── plugin/
105
+ │ ├── functions/unit/ # Function tests
106
+ │ └── ui/unit/ # UI tests with bot
107
+ ├── vite.config.js # EXACT format required (see below)
108
+ ├── package.json # REQUIRED: majk metadata section
109
+ └── tsconfig.json
110
+ ```
111
+
112
+ ## package.json (REQUIRED)
113
+
114
+ **CRITICAL:** Your package.json MUST include the `majk` metadata section and use an npm organization scope:
115
+
116
+ ```json
117
+ {
118
+ "name": "@yourorg/my-plugin",
119
+ "version": "1.0.0",
120
+ "description": "My awesome MAJK plugin",
121
+ "main": "index.js",
122
+ "keywords": ["majk", "plugin"],
123
+ "majk": {
124
+ "type": "in-process",
125
+ "entry": "index.js"
126
+ },
127
+ "scripts": {
128
+ "generate": "npx plugin-kit generate -e ./index.js -o ./ui/src/generated",
129
+ "dev": "vite",
130
+ "build": "tsc && npm run generate && vite build",
131
+ "preview": "vite preview",
132
+ "test": "npm run test:plugin",
133
+ "test:plugin": "npm run test:plugin:functions && npm run test:plugin:ui",
134
+ "test:plugin:functions": "npx majk-test --type functions",
135
+ "test:plugin:ui": "node tests/plugin/ui/unit/basic-navigation.test.js",
136
+ "clean": "rm -rf dist index.js index.d.ts ui/src/generated"
137
+ },
138
+ "dependencies": {
139
+ "@majkapp/plugin-kit": "^3.3.0",
140
+ "@majkapp/plugin-theme": "^1.0.0",
141
+ "@majkapp/plugin-ui": "^1.4.0",
142
+ "react": "^18.3.1",
143
+ "react-dom": "^18.3.1"
144
+ },
145
+ "devDependencies": {
146
+ "@majkapp/plugin-test": "^1.0.2",
147
+ "@types/react": "^18.3.1",
148
+ "@types/react-dom": "^18.3.1",
149
+ "@vitejs/plugin-react": "^4.3.4",
150
+ "typescript": "^5.3.3",
151
+ "vite": "^6.0.3"
152
+ }
153
+ }
154
+ ```
155
+
156
+ ### Package Naming (REQUIRED)
157
+
158
+ **Your plugin name MUST use an npm organization scope** (format: `@org/plugin-name`).
159
+
160
+ Replace `@yourorg` with your organization:
161
+ - If you have an npm organization: `@mycompany/my-plugin`
162
+ - If you don't have one: Use `@majkical/my-plugin`
163
+
164
+ Example: `@majkical/weather-widget`
165
+
166
+ ### majk Metadata Section
167
+
168
+ The `majk` object tells MAJK how to load your plugin:
169
+
170
+ **`type`**: `"in-process"` or `"remote"`
171
+ - `"in-process"` - Plugin runs in MAJK's Node.js process (most common)
172
+ - `"remote"` - Plugin runs in separate process (for isolation)
173
+
174
+ **`entry`**: Path to compiled plugin file (relative to package root)
175
+ - Usually `"index.js"` (compiled from `src/index.ts`)
176
+ - MAJK loads this file to get your plugin definition
177
+
178
+ ### Important Scripts
179
+
180
+ **`generate`** - Generate TypeScript client from plugin
181
+ ```bash
182
+ npm run generate # Regenerate after adding/changing functions
183
+ ```
184
+
185
+ **`build`** - Complete build pipeline
186
+ ```bash
187
+ npm run build # 1. Compile TS → index.js
188
+ # 2. Generate client
189
+ # 3. Build React UI → dist/
190
+ ```
191
+
192
+ **`test:plugin:functions`** - Test plugin functions
193
+ ```bash
194
+ npm run test:plugin:functions # Run all function tests
195
+ ```
196
+
197
+ **`test:plugin:ui`** - Test plugin UI with bot
198
+ ```bash
199
+ npm run test:plugin:ui # Run UI tests with browser automation
200
+ ```
201
+
202
+ ## vite.config.js (REQUIRED FORMAT)
203
+
204
+ **CRITICAL:** Use this EXACT format for your vite.config.js:
205
+
206
+ ```javascript
207
+ import { defineConfig } from 'vite';
208
+ import react from '@vitejs/plugin-react';
209
+
210
+ export default defineConfig({
211
+ plugins: [react()],
212
+ root: 'ui', // React source is in ui/ directory
213
+ base: '', // IMPORTANT: Empty string, NOT './'
214
+ server: {
215
+ port: 3000,
216
+ strictPort: false,
217
+ },
218
+ build: {
219
+ outDir: '../dist', // Build output to dist/ (parent of ui/)
220
+ assetsDir: 'assets',
221
+ emptyOutDir: true,
222
+ },
223
+ });
224
+ ```
225
+
226
+ **Why `base: ''`?**
227
+ - MAJK loads plugins in iframes with dynamic paths
228
+ - Empty base ensures assets load correctly
229
+ - `base: './'` will cause 404 errors for assets
230
+
231
+ ## Best Practice: Decoupled Business Logic
232
+
233
+ **ALWAYS separate business logic from plugin code.** This makes testing easier and allows logic reuse.
234
+
235
+ ```typescript
236
+ // src/core/analytics.ts - Pure business logic, NO plugin dependencies
237
+ export interface AnalyticsData {
238
+ period: '24h' | '7d' | '30d';
239
+ metrics: {
240
+ activeUsers: number;
241
+ totalSessions: number;
242
+ avgDuration: number;
243
+ };
244
+ }
245
+
246
+ export function calculateAnalytics(
247
+ sessions: Array<{ userId: string; duration: number; timestamp: Date }>,
248
+ period: '24h' | '7d' | '30d'
249
+ ): AnalyticsData {
250
+ const now = Date.now();
251
+ const periodMs = period === '24h' ? 86400000 : period === '7d' ? 604800000 : 2592000000;
252
+
253
+ const recentSessions = sessions.filter(s =>
254
+ now - s.timestamp.getTime() < periodMs
255
+ );
256
+
257
+ const uniqueUsers = new Set(recentSessions.map(s => s.userId)).size;
258
+ const totalDuration = recentSessions.reduce((sum, s) => sum + s.duration, 0);
259
+
260
+ return {
261
+ period,
262
+ metrics: {
263
+ activeUsers: uniqueUsers,
264
+ totalSessions: recentSessions.length,
265
+ avgDuration: recentSessions.length > 0
266
+ ? Math.round(totalDuration / recentSessions.length)
267
+ : 0
268
+ }
269
+ };
270
+ }
271
+
272
+ // Test this with pure Jest/Vitest - NO plugin-test needed
273
+ ```
274
+
275
+ ```typescript
276
+ // src/index.ts - Plugin function wraps business logic
277
+ import { calculateAnalytics } from './core/analytics';
278
+
279
+ const plugin = definePlugin('my-plugin', 'My Plugin', '1.0.0')
280
+ .pluginRoot(__dirname)
281
+
282
+ .function('getAnalytics', {
283
+ description: 'Get analytics for specified time period',
284
+ input: {
285
+ type: 'object',
286
+ properties: {
287
+ period: { type: 'string', enum: ['24h', '7d', '30d'] }
288
+ },
289
+ required: ['period'],
290
+ additionalProperties: false
291
+ },
292
+ output: {
293
+ type: 'object',
294
+ properties: {
295
+ period: { type: 'string' },
296
+ metrics: {
297
+ type: 'object',
298
+ properties: {
299
+ activeUsers: { type: 'number' },
300
+ totalSessions: { type: 'number' },
301
+ avgDuration: { type: 'number' }
302
+ },
303
+ required: ['activeUsers', 'totalSessions', 'avgDuration']
304
+ }
305
+ },
306
+ required: ['period', 'metrics']
307
+ },
308
+ handler: async (input, ctx) => {
309
+ // Get data from storage (plugin layer)
310
+ const sessions = await ctx.storage.get('sessions') || [];
311
+
312
+ // Pure business logic (easy to test)
313
+ const analytics = calculateAnalytics(sessions, input.period);
314
+
315
+ ctx.logger.info(`Analytics calculated for ${input.period}`, analytics);
316
+
317
+ return analytics;
318
+ },
319
+ tags: ['analytics']
320
+ });
321
+ ```
322
+
323
+ ## Best Practice: Service Interfaces for External Dependencies
324
+
325
+ When using 3rd party services (AWS, databases, APIs), create interfaces for easy mocking.
326
+
327
+ ```typescript
328
+ // src/core/storage-service.ts - Interface for abstraction
329
+ export interface StorageService {
330
+ save(key: string, data: any): Promise<void>;
331
+ load(key: string): Promise<any>;
332
+ delete(key: string): Promise<void>;
333
+ }
334
+
335
+ // src/core/aws-storage.ts - Real implementation
336
+ import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
337
+
338
+ export class AWSStorageService implements StorageService {
339
+ constructor(private s3: S3Client, private bucket: string) {}
340
+
341
+ async save(key: string, data: any): Promise<void> {
342
+ await this.s3.send(new PutObjectCommand({
343
+ Bucket: this.bucket,
344
+ Key: key,
345
+ Body: JSON.stringify(data)
346
+ }));
347
+ }
348
+
349
+ async load(key: string): Promise<any> {
350
+ const result = await this.s3.send(new GetObjectCommand({
351
+ Bucket: this.bucket,
352
+ Key: key
353
+ }));
354
+ const body = await result.Body?.transformToString();
355
+ return body ? JSON.parse(body) : null;
356
+ }
357
+
358
+ async delete(key: string): Promise<void> {
359
+ // Implementation...
360
+ }
361
+ }
362
+
363
+ // src/core/mock-storage.ts - Mock for testing
364
+ export class MockStorageService implements StorageService {
365
+ private data = new Map<string, any>();
366
+
367
+ async save(key: string, data: any): Promise<void> {
368
+ this.data.set(key, data);
369
+ }
370
+
371
+ async load(key: string): Promise<any> {
372
+ return this.data.get(key);
373
+ }
374
+
375
+ async delete(key: string): Promise<void> {
376
+ this.data.delete(key);
377
+ }
378
+ }
379
+ ```
380
+
381
+ ## React UI with Generated Hooks
382
+
383
+ The plugin-kit auto-generates React hooks from your functions.
384
+
385
+ ```typescript
386
+ // ui/src/DashboardPage.tsx
387
+ import { useHealth, useGetAnalytics, useUiLog } from './generated/hooks';
388
+ import { PluginStatGrid, PluginActionPanel } from '@majkapp/plugin-ui';
389
+ import { useEffect } from 'react';
390
+
391
+ export function DashboardPage() {
392
+ // Auto-generated hooks - query hooks auto-fetch on mount
393
+ const { data: health, loading: healthLoading, refetch: refetchHealth } = useHealth();
394
+ const { data: analytics, loading: analyticsLoading } = useGetAnalytics(
395
+ { period: '7d' },
396
+ { refetchInterval: 30000 } // Auto-refresh every 30s
397
+ );
398
+
399
+ // Mutation hook for logging - call .mutate() manually
400
+ const { mutate: uiLog } = useUiLog();
401
+
402
+ // Log UI lifecycle events for debugging
403
+ useEffect(() => {
404
+ uiLog({
405
+ level: 'info',
406
+ component: 'DashboardPage',
407
+ message: 'Dashboard mounted',
408
+ data: { timestamp: new Date().toISOString() }
409
+ });
410
+ }, [uiLog]);
411
+
412
+ // data-majk-id: Enables bot testing - identifies clickable elements
413
+ // data-majk-state: Tracks UI state for testing - empty/loading/populated
414
+ return (
415
+ <div data-majk-id="dashboardContainer">
416
+ <PluginActionPanel
417
+ actions={[
418
+ {
419
+ label: '🔄 Refresh Health',
420
+ onClick: () => {
421
+ uiLog({
422
+ level: 'info',
423
+ component: 'DashboardPage',
424
+ message: 'Health refresh triggered by user'
425
+ });
426
+ refetchHealth();
427
+ },
428
+ 'data-majk-id': 'refreshHealthButton',
429
+ 'data-majk-state': healthLoading ? 'loading' : 'idle'
430
+ }
431
+ ]}
432
+ />
433
+
434
+ <PluginStatGrid
435
+ stats={[
436
+ {
437
+ label: 'Health Status',
438
+ value: health?.status || 'unknown',
439
+ 'data-majk-id': 'healthStatus',
440
+ 'data-majk-state': healthLoading ? 'loading' : health ? 'populated' : 'empty'
441
+ },
442
+ {
443
+ label: 'Active Users (7d)',
444
+ value: analytics?.metrics.activeUsers || 0,
445
+ 'data-majk-id': 'activeUsersCount',
446
+ 'data-majk-state': analyticsLoading ? 'loading' : analytics ? 'populated' : 'empty'
447
+ },
448
+ {
449
+ label: 'Total Sessions',
450
+ value: analytics?.metrics.totalSessions || 0,
451
+ 'data-majk-id': 'totalSessionsCount'
452
+ }
453
+ ]}
454
+ />
455
+ </div>
456
+ );
457
+ }
458
+ ```
459
+
460
+ ## Testing Your UI
461
+
462
+ ```typescript
463
+ // tests/plugin/ui/unit/dashboard.test.js
464
+ import { test } from '@majkapp/plugin-test';
465
+ import { bot, invoke, mock } from '@majkapp/plugin-test';
466
+ import assert from 'assert';
467
+
468
+ test('dashboard displays health status', async () => {
469
+ // Create mock context with handlers for functions
470
+ const context = mock()
471
+ .withMockHandler('health', async () => ({
472
+ status: 'ok',
473
+ timestamp: new Date().toISOString()
474
+ }))
475
+ .withMockHandler('getAnalytics', async () => ({
476
+ period: '7d',
477
+ metrics: {
478
+ activeUsers: 42,
479
+ totalSessions: 150,
480
+ avgDuration: 320
481
+ }
482
+ }))
483
+ .build();
484
+
485
+ // Start browser bot
486
+ const b = await bot(context);
487
+
488
+ // Navigate to screen
489
+ await b.goto('/plugin-screens/my-plugin/main');
490
+
491
+ // Wait for data to load - check data-majk-state
492
+ await b.waitForSelector('[data-majk-id="healthStatus"][data-majk-state="populated"]');
493
+
494
+ // Verify displayed values
495
+ const healthText = await b.getText('[data-majk-id="healthStatus"]');
496
+ assert.ok(healthText.includes('ok'));
497
+
498
+ const usersText = await b.getText('[data-majk-id="activeUsersCount"]');
499
+ assert.ok(usersText.includes('42'));
500
+
501
+ // Test interaction - click refresh button
502
+ await b.click('[data-majk-id="refreshHealthButton"]');
503
+
504
+ // Verify loading state
505
+ const buttonState = await b.getAttribute('[data-majk-id="refreshHealthButton"]', 'data-majk-state');
506
+ assert.strictEqual(buttonState, 'loading');
507
+
508
+ await b.close();
509
+ });
510
+ ```
511
+
512
+ ## Key Concepts
513
+
514
+ ### Query vs Mutation Hooks
515
+
516
+ Generated hooks follow React Query patterns:
517
+
518
+ **Query Hooks** - Auto-fetch data on mount:
519
+ - `useHealth()` - fetches immediately
520
+ - `useGetAnalytics({ period: '7d' })` - fetches immediately with params
521
+ - Returns: `{ data, error, loading, refetch }`
522
+
523
+ **Mutation Hooks** - Call manually:
524
+ - `useUiLog()` - call `.mutate(input)` when needed
525
+ - `useClearEvents()` - call `.mutate({})` on button click
526
+ - Returns: `{ mutate, data, error, loading }`
527
+
528
+ ### data-majk-* Attributes
529
+
530
+ **Critical for testing.** The bot uses these to find and verify elements.
531
+
532
+ - `data-majk-id="uniqueId"` - Identifies clickable elements, key stats
533
+ - `data-majk-state="loading|empty|populated|error"` - Tracks UI state for assertions
534
+
535
+ ### uiLog Function
536
+
537
+ **Best practice:** Create a `uiLog` function in your plugin for frontend debugging.
538
+
539
+ ```typescript
540
+ .function('uiLog', {
541
+ description: 'Log messages from UI for debugging',
542
+ input: {
543
+ type: 'object',
544
+ properties: {
545
+ level: { type: 'string', enum: ['debug', 'info', 'warn', 'error'] },
546
+ component: { type: 'string' },
547
+ message: { type: 'string' },
548
+ data: { type: 'object' }
549
+ },
550
+ required: ['level', 'component', 'message'],
551
+ additionalProperties: false
552
+ },
553
+ output: {
554
+ type: 'object',
555
+ properties: {
556
+ success: { type: 'boolean' }
557
+ }
558
+ },
559
+ handler: async (input, ctx) => {
560
+ const prefix = `[UI:${input.component}]`;
561
+ ctx.logger[input.level](`${prefix} ${input.message}`, input.data);
562
+ return { success: true };
563
+ },
564
+ tags: ['debugging']
565
+ })
566
+ ```
567
+
568
+ Then use in React:
569
+
570
+ ```typescript
571
+ // Log errors
572
+ try {
573
+ await someAsyncOperation();
574
+ } catch (error) {
575
+ uiLog({
576
+ level: 'error',
577
+ component: 'DashboardPage',
578
+ message: 'Failed to load data',
579
+ data: { error: error.message }
580
+ });
581
+ }
582
+
583
+ // Log user actions
584
+ onClick={() => {
585
+ uiLog({
586
+ level: 'info',
587
+ component: 'SettingsPage',
588
+ message: 'User clicked save button',
589
+ data: { settingsChanged: ['theme', 'notifications'] }
590
+ });
591
+ handleSave();
592
+ }}
593
+ ```
594
+
595
+ ## Next Steps
596
+
597
+ Run `npx @majkapp/plugin-kit --functions` - Deep dive into function patterns
598
+ Run `npx @majkapp/plugin-kit --screens` - Screen configuration details
599
+ Run `npx @majkapp/plugin-kit --hooks` - Generated hooks reference
600
+ Run `npx @majkapp/plugin-kit --context` - ctx.majk and ctx.logger APIs
601
+ Run `npx @majkapp/plugin-kit --services` - Service grouping patterns
602
+ Run `npx @majkapp/plugin-kit --lifecycle` - onReady and cleanup
603
+ Run `npx @majkapp/plugin-kit --testing` - Complete testing guide
604
+ Run `npx @majkapp/plugin-kit --config` - vite.config.js and project setup
605
+ Run `npx @majkapp/plugin-kit --full` - Complete reference