@ateam-ai-mobile/plugin-runtime 0.1.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,268 @@
1
+ /**
2
+ * Native Bridge SDK Types
3
+ *
4
+ * TypeScript interfaces for the bridge API exposed to dynamic connectors.
5
+ * Connectors use these types to interact with device capabilities.
6
+ */
7
+
8
+ // ═══════════════════════════════════════════════════════════════════════════
9
+ // CALENDAR
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+
12
+ export interface CalendarEvent {
13
+ id: string;
14
+ title: string;
15
+ date: string; // YYYY-MM-DD
16
+ start: string; // HH:MM
17
+ end: string; // HH:MM
18
+ location: string;
19
+ attendees: string[];
20
+ notes: string;
21
+ }
22
+
23
+ export interface CalendarTodayResult {
24
+ date: string; // YYYY-MM-DD
25
+ day_of_week: string; // "Monday"
26
+ events: CalendarEvent[];
27
+ count: number;
28
+ }
29
+
30
+ export interface CalendarUpcomingResult {
31
+ from: string;
32
+ days_ahead: number;
33
+ events: CalendarEvent[];
34
+ count: number;
35
+ }
36
+
37
+ export interface CalendarCreateParams {
38
+ title: string;
39
+ date: string; // YYYY-MM-DD
40
+ start_time: string; // HH:MM
41
+ end_time?: string; // HH:MM
42
+ location?: string;
43
+ attendees?: string[];
44
+ notes?: string;
45
+ }
46
+
47
+ export interface CalendarCreateResult {
48
+ event_id: string;
49
+ }
50
+
51
+ export interface CalendarRescheduleParams {
52
+ event_id: string;
53
+ new_date?: string;
54
+ new_start_time?: string;
55
+ new_end_time?: string;
56
+ }
57
+
58
+ // ═══════════════════════════════════════════════════════════════════════════
59
+ // CONTACTS
60
+ // ═══════════════════════════════════════════════════════════════════════════
61
+
62
+ export interface Contact {
63
+ id: string;
64
+ name: string;
65
+ phone: string | null;
66
+ email: string | null;
67
+ relationship: string;
68
+ birthday: string | null; // YYYY-MM-DD
69
+ notes: string;
70
+ }
71
+
72
+ export interface ContactSearchResult {
73
+ contacts: Contact[];
74
+ count: number;
75
+ }
76
+
77
+ export interface Birthday {
78
+ name: string;
79
+ relationship: string;
80
+ birthday: string;
81
+ days_until: number;
82
+ is_today: boolean;
83
+ }
84
+
85
+ export interface BirthdayResult {
86
+ birthdays: Birthday[];
87
+ count: number;
88
+ }
89
+
90
+ // ═══════════════════════════════════════════════════════════════════════════
91
+ // LOCATION
92
+ // ═══════════════════════════════════════════════════════════════════════════
93
+
94
+ export interface LocationResult {
95
+ lat: number;
96
+ lng: number;
97
+ place: string;
98
+ address: string;
99
+ type: string; // home, work, school, fitness, medical, other
100
+ timestamp: string;
101
+ }
102
+
103
+ // ═══════════════════════════════════════════════════════════════════════════
104
+ // BATTERY
105
+ // ═══════════════════════════════════════════════════════════════════════════
106
+
107
+ export interface BatteryStatus {
108
+ ok: true;
109
+ timestamp: string;
110
+ battery: {
111
+ level: number; // 0-100
112
+ state: 'charging' | 'unplugged' | 'full';
113
+ low_power_mode: boolean;
114
+ };
115
+ }
116
+
117
+ // ═══════════════════════════════════════════════════════════════════════════
118
+ // NETWORK
119
+ // ═══════════════════════════════════════════════════════════════════════════
120
+
121
+ export interface NetworkStatus {
122
+ type: 'wifi' | 'cellular' | 'none';
123
+ is_connected: boolean;
124
+ wifi_name?: string;
125
+ timestamp: string;
126
+ }
127
+
128
+ // ═══════════════════════════════════════════════════════════════════════════
129
+ // NOTIFICATIONS
130
+ // ═══════════════════════════════════════════════════════════════════════════
131
+
132
+ export interface Notification {
133
+ id: string;
134
+ app: string;
135
+ title: string;
136
+ body: string;
137
+ timestamp: string;
138
+ sender: string | null;
139
+ priority: 'high' | 'medium' | 'low';
140
+ read: boolean;
141
+ }
142
+
143
+ export interface NotificationListResult {
144
+ notifications: Notification[];
145
+ total: number;
146
+ unread: number;
147
+ }
148
+
149
+ // ═══════════════════════════════════════════════════════════════════════════
150
+ // SMS
151
+ // ═══════════════════════════════════════════════════════════════════════════
152
+
153
+ export interface SMSSendResult {
154
+ ok: boolean;
155
+ status: 'sent' | 'unknown' | 'cancelled';
156
+ }
157
+
158
+ // ═══════════════════════════════════════════════════════════════════════════
159
+ // MAPS
160
+ // ═══════════════════════════════════════════════════════════════════════════
161
+
162
+ export interface MapsNavigateResult {
163
+ ok: boolean;
164
+ status: 'navigating' | 'error';
165
+ }
166
+
167
+ // ═══════════════════════════════════════════════════════════════════════════
168
+ // HTTP
169
+ // ═══════════════════════════════════════════════════════════════════════════
170
+
171
+ export interface HttpResponse {
172
+ ok: boolean;
173
+ status: number;
174
+ data: any;
175
+ headers: Record<string, string>;
176
+ }
177
+
178
+ // ═══════════════════════════════════════════════════════════════════════════
179
+ // PERMISSIONS
180
+ // ═══════════════════════════════════════════════════════════════════════════
181
+
182
+ export type PermissionState = 'granted' | 'denied' | 'not_requested';
183
+
184
+ // ═══════════════════════════════════════════════════════════════════════════
185
+ // DEVICE
186
+ // ═══════════════════════════════════════════════════════════════════════════
187
+
188
+ export interface DeviceInfo {
189
+ os: 'ios' | 'android';
190
+ version: string;
191
+ }
192
+
193
+ // ═══════════════════════════════════════════════════════════════════════════
194
+ // BRIDGE API
195
+ // ═══════════════════════════════════════════════════════════════════════════
196
+
197
+ export interface Bridge {
198
+ calendar: {
199
+ getToday(): Promise<CalendarTodayResult>;
200
+ getUpcoming(days?: number): Promise<CalendarUpcomingResult>;
201
+ create(event: CalendarCreateParams): Promise<CalendarCreateResult>;
202
+ reschedule(params: CalendarRescheduleParams): Promise<{ ok: boolean }>;
203
+ cancel(event_id: string): Promise<{ ok: boolean }>;
204
+ };
205
+
206
+ contacts: {
207
+ search(query: string): Promise<ContactSearchResult>;
208
+ get(idOrName: string): Promise<Contact | null>;
209
+ getAll(): Promise<ContactSearchResult>;
210
+ birthdays(days?: number): Promise<BirthdayResult>;
211
+ };
212
+
213
+ location: {
214
+ getCurrent(): Promise<LocationResult>;
215
+ };
216
+
217
+ battery: {
218
+ getStatus(): Promise<BatteryStatus>;
219
+ };
220
+
221
+ network: {
222
+ getStatus(): Promise<NetworkStatus>;
223
+ };
224
+
225
+ notifications: {
226
+ list(): Promise<NotificationListResult>;
227
+ };
228
+
229
+ sms: {
230
+ isAvailable(): Promise<boolean>;
231
+ send(phone: string, message: string): Promise<SMSSendResult>;
232
+ };
233
+
234
+ maps: {
235
+ navigate(destination: string, mode?: 'driving' | 'transit' | 'walking'): Promise<MapsNavigateResult>;
236
+ };
237
+
238
+ http: {
239
+ get(url: string, headers?: Record<string, string>): Promise<HttpResponse>;
240
+ post(url: string, body: any, headers?: Record<string, string>): Promise<HttpResponse>;
241
+ put(url: string, body: any, headers?: Record<string, string>): Promise<HttpResponse>;
242
+ delete(url: string, body?: any, headers?: Record<string, string>): Promise<HttpResponse>;
243
+ };
244
+
245
+ storage: {
246
+ get(key: string): Promise<string | null>;
247
+ set(key: string, value: string): Promise<void>;
248
+ delete(key: string): Promise<void>;
249
+ keys(): Promise<string[]>;
250
+ };
251
+
252
+ permissions: {
253
+ check(capability: string): Promise<PermissionState>;
254
+ request(capability: string): Promise<boolean>;
255
+ getAll(): Promise<Record<string, PermissionState>>;
256
+ };
257
+
258
+ device: {
259
+ getDeviceId(): string;
260
+ getPlatform(): DeviceInfo;
261
+ };
262
+
263
+ log: {
264
+ info(message: string, data?: any): void;
265
+ warn(message: string, data?: any): void;
266
+ error(message: string, data?: any): void;
267
+ };
268
+ }
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Global Plugin Bundle Manager — singleton.
3
+ *
4
+ * ONE download per bundleUrl (deduplicates multi-plugin bundles); all plugin
5
+ * renderer instances share the same loaded modules. Persistence goes through
6
+ * the BundleStorage adapter (in-memory by default; @ateam-ai-mobile/expo provides disk).
7
+ *
8
+ * Usage:
9
+ * const component = await bundleManager.load(pluginId, bundleUrl, version, hash);
10
+ * // component = { id, type, Component, ... } (RegisteredUIPlugin)
11
+ */
12
+ import React from 'react';
13
+ import {
14
+ useState, useEffect, useCallback, useMemo, useRef,
15
+ useContext, useReducer, useLayoutEffect, useImperativeHandle, useDebugValue,
16
+ } from 'react';
17
+ import * as ReactNative from 'react-native';
18
+ import { apiFetchRaw } from '@ateam-ai-mobile/core';
19
+ import * as PluginSDKModule from '../sdk';
20
+ import { resolveBundleModule } from '../host';
21
+ import { sha256Hex } from '../utils/sha256';
22
+ import { getBundleStorage, type BundleCacheEntry } from './storage';
23
+ import type { RegisteredUIPlugin } from '../sdk/types';
24
+
25
+ // ── Require shim ─────────────────────────────────────────────
26
+ // Bundles are built with external: ['react', 'react-native', '@adas/plugin-sdk']
27
+ // so they emit require() calls that this shim resolves at runtime. Anything
28
+ // beyond the built-ins (react-native-webview, expo-linking, …) must be
29
+ // registered by the host via registerBundleModules() — the host is the one
30
+ // that actually has those modules installed.
31
+
32
+ const ReactShim: any = {
33
+ __esModule: true,
34
+ default: React,
35
+ ...React,
36
+ // Explicitly include hooks (React 19 may not have them as own enumerable props)
37
+ useState, useEffect, useCallback, useMemo, useRef, useContext,
38
+ useReducer, useLayoutEffect, useImperativeHandle, useDebugValue,
39
+ createElement: React.createElement,
40
+ Fragment: React.Fragment,
41
+ Component: React.Component,
42
+ PureComponent: React.PureComponent,
43
+ createContext: React.createContext,
44
+ forwardRef: React.forwardRef,
45
+ memo: React.memo,
46
+ Children: React.Children,
47
+ cloneElement: React.cloneElement,
48
+ isValidElement: React.isValidElement,
49
+ createRef: React.createRef,
50
+ lazy: React.lazy,
51
+ Suspense: React.Suspense,
52
+ };
53
+
54
+ // Freeze PluginSDK shim to prevent bundle monkey-patching of register()
55
+ const PluginSDKShim = Object.freeze({
56
+ ...PluginSDKModule,
57
+ PluginSDK: Object.freeze({
58
+ ...PluginSDKModule.PluginSDK,
59
+ register: PluginSDKModule.PluginSDK.register,
60
+ }),
61
+ register: PluginSDKModule.register,
62
+ });
63
+
64
+ const requireShim = (name: string) => {
65
+ if (name === 'react') return ReactShim;
66
+ if (name === 'react-native') return ReactNative;
67
+ if (name === '@adas/plugin-sdk') return PluginSDKShim;
68
+ const hostModule = resolveBundleModule(name);
69
+ if (hostModule !== undefined) return hostModule;
70
+ console.warn(`[BundleManager] Unknown module: ${name} — register it via registerBundleModules()`);
71
+ return {};
72
+ };
73
+
74
+ // ── Cache constants ──────────────────────────────────────────
75
+
76
+ const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
77
+
78
+ // ── BundleManager singleton ──────────────────────────────────
79
+
80
+ class BundleManager {
81
+ /** In-memory module cache: bundleUrl → evaluated module.exports */
82
+ private modules = new Map<string, any>();
83
+
84
+ /** In-flight download deduplication: bundleUrl → Promise */
85
+ private inflight = new Map<string, Promise<any>>();
86
+
87
+ // ── Public API ──────────────────────────────────────────────
88
+
89
+ /**
90
+ * Load a plugin component. Returns the RegisteredUIPlugin object.
91
+ * Handles: in-memory cache → disk cache → download → evaluate → extract.
92
+ *
93
+ * `expectedHash` (SHA-256 hex from cp.getContextPlugin manifest) MUST be
94
+ * provided for any bundle the user has not previously verified — without
95
+ * it, a tampered bundle is `eval`'d as JS with full RN runtime + every
96
+ * native capability the manifest declares.
97
+ */
98
+ async load(
99
+ pluginId: string,
100
+ bundleUrl: string,
101
+ version: string,
102
+ expectedHash?: string,
103
+ ): Promise<RegisteredUIPlugin> {
104
+ // 1. In-memory cache — instant
105
+ const cached = this.getComponentFromMemory(pluginId, bundleUrl);
106
+ if (cached) return cached;
107
+
108
+ // 2. Deduplicate in-flight downloads for same bundleUrl
109
+ let modulePromise = this.inflight.get(bundleUrl);
110
+ if (!modulePromise) {
111
+ modulePromise = this.loadModule(bundleUrl, version, expectedHash);
112
+ this.inflight.set(bundleUrl, modulePromise);
113
+ // Swallow rejection on the cleanup chain — the original `await` below
114
+ // still surfaces the error to the caller.
115
+ modulePromise.catch(() => {}).finally(() => this.inflight.delete(bundleUrl));
116
+ }
117
+
118
+ await modulePromise;
119
+
120
+ // 3. Extract component for this specific pluginId
121
+ const component = this.getComponentFromMemory(pluginId, bundleUrl);
122
+ if (!component) {
123
+ throw new Error(
124
+ `Bundle loaded from ${bundleUrl} but no component found for "${pluginId}". ` +
125
+ `Available exports: ${this.describeModule(this.modules.get(bundleUrl))}`
126
+ );
127
+ }
128
+
129
+ return component;
130
+ }
131
+
132
+ /**
133
+ * Prewarm: load every plugin bundle in the background after app boot, so a
134
+ * later open is instant (in-memory hit) instead of round-tripping the
135
+ * network. Errors are swallowed per-plugin — one bad bundle shouldn't
136
+ * block others. Concurrency capped at 3.
137
+ */
138
+ async prewarmAll(items: Array<{
139
+ pluginId: string;
140
+ bundleUrl: string;
141
+ version: string;
142
+ bundleHash?: string;
143
+ }>): Promise<void> {
144
+ const queue = [...items];
145
+ const concurrency = Math.min(3, queue.length);
146
+ const start = Date.now();
147
+ let okCount = 0;
148
+ let failCount = 0;
149
+
150
+ const worker = async () => {
151
+ while (queue.length > 0) {
152
+ const it = queue.shift();
153
+ if (!it) return;
154
+ try {
155
+ await this.load(it.pluginId, it.bundleUrl, it.version, it.bundleHash);
156
+ okCount++;
157
+ } catch (err) {
158
+ failCount++;
159
+ // Silent — preload is best-effort. Log for diagnostics only.
160
+ console.log(`[prewarm] ${it.pluginId}: ${err instanceof Error ? err.message : String(err)}`);
161
+ }
162
+ }
163
+ };
164
+
165
+ await Promise.all(Array.from({ length: concurrency }, worker));
166
+ console.log(`[prewarm] ${okCount} loaded, ${failCount} failed in ${Date.now() - start}ms`);
167
+ }
168
+
169
+ /**
170
+ * Clear all caches (in-memory + storage).
171
+ */
172
+ async clearAll(): Promise<void> {
173
+ this.modules.clear();
174
+ try {
175
+ await getBundleStorage().clearAll();
176
+ } catch (err) {
177
+ console.warn('[BundleManager] clearAll error:', err);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Clear cache for a specific plugin/bundle.
183
+ */
184
+ async clearPlugin(pluginId: string): Promise<void> {
185
+ const storage = getBundleStorage();
186
+ const index = await storage.loadIndex();
187
+ const entry = index[pluginId];
188
+ if (entry) {
189
+ this.modules.delete(entry.bundleUrl);
190
+ delete index[pluginId];
191
+ await storage.saveIndex(index);
192
+ try {
193
+ await storage.deleteBundle(entry.localPath);
194
+ } catch { /* ignore */ }
195
+ }
196
+ }
197
+
198
+ // ── Private: module loading pipeline ────────────────────────
199
+
200
+ private async loadModule(bundleUrl: string, version: string, expectedHash?: string): Promise<void> {
201
+ // Already in memory?
202
+ if (this.modules.has(bundleUrl)) return;
203
+
204
+ const storage = getBundleStorage();
205
+ const index = await storage.loadIndex();
206
+ const expectedLc = expectedHash ? expectedHash.toLowerCase() : null;
207
+
208
+ // Check disk cache. A cache hit is only valid when the cached SHA-256
209
+ // matches the manifest hash exactly — never trust a cached bundle
210
+ // without proof.
211
+ let content: string | null = null;
212
+ for (const entry of Object.values(index)) {
213
+ if (entry.bundleUrl === bundleUrl && entry.version === version) {
214
+ const age = Date.now() - entry.timestamp;
215
+ const hashOk = expectedLc && entry.bundleHash && entry.bundleHash.toLowerCase() === expectedLc;
216
+ if (age < CACHE_TTL && hashOk) {
217
+ content = await storage.readBundle(entry.localPath);
218
+ if (content) break;
219
+ }
220
+ }
221
+ }
222
+
223
+ // Download if not cached
224
+ if (!content) {
225
+ const downloaded = await this.download(bundleUrl, expectedLc);
226
+ content = downloaded.content;
227
+ // Save with verified hash
228
+ const safeName = bundleUrl.replace(/[^a-zA-Z0-9]/g, '_').slice(-80);
229
+ try {
230
+ const localPath = await storage.writeBundle(`${safeName}@${version}.js`, content);
231
+ index[bundleUrl] = {
232
+ localPath,
233
+ version,
234
+ timestamp: Date.now(),
235
+ bundleUrl,
236
+ bundleHash: downloaded.sha256,
237
+ } satisfies BundleCacheEntry;
238
+ await storage.saveIndex(index);
239
+ } catch (err) {
240
+ console.warn('[BundleManager] Cache write error:', err);
241
+ }
242
+ }
243
+
244
+ // Evaluate
245
+ const mod = this.evaluate(content, bundleUrl);
246
+ this.modules.set(bundleUrl, mod);
247
+ }
248
+
249
+ /**
250
+ * Download + integrity-verify a bundle. Refuses to return when:
251
+ * - manifest hash and computed hash disagree
252
+ * - server's `X-Bundle-SHA256` disagrees with manifest hash
253
+ * - no expected hash AND no server hash header (cannot prove the bundle
254
+ * wasn't tampered with — fail closed rather than trust the wire).
255
+ */
256
+ private async download(bundleUrl: string, expectedHash: string | null): Promise<{ content: string; sha256: string }> {
257
+ const cacheBust = `${bundleUrl}${bundleUrl.includes('?') ? '&' : '?'}t=${Date.now()}`;
258
+ const response = await apiFetchRaw(cacheBust);
259
+ if (!response.ok) {
260
+ const body = await response.text().catch(() => '');
261
+ throw new Error(`Download failed: ${response.status} ${response.statusText} — ${body.substring(0, 200)}`);
262
+ }
263
+
264
+ const content = await response.text();
265
+ if (content.length < 50 || content.trimStart().startsWith('<!')) {
266
+ throw new Error('Bundle content is empty or HTML — server may have returned an error page');
267
+ }
268
+
269
+ const headerHash = (response.headers.get('x-bundle-sha256') || '').toLowerCase();
270
+ const computed = sha256Hex(content);
271
+
272
+ if (expectedHash) {
273
+ if (computed !== expectedHash) {
274
+ throw new Error(
275
+ `Bundle integrity FAIL: expected sha256=${expectedHash.slice(0, 12)}... got ${computed.slice(0, 12)}.... Refusing to evaluate.`
276
+ );
277
+ }
278
+ if (headerHash && headerHash !== expectedHash) {
279
+ throw new Error(
280
+ `Bundle hash mismatch: manifest=${expectedHash.slice(0, 12)}... server=${headerHash.slice(0, 12)}.... Refusing to evaluate.`
281
+ );
282
+ }
283
+ } else if (headerHash) {
284
+ if (computed !== headerHash) {
285
+ throw new Error(
286
+ `Bundle body sha256 (${computed.slice(0, 12)}...) does not match server's X-Bundle-SHA256 (${headerHash.slice(0, 12)}...).`
287
+ );
288
+ }
289
+ console.warn(`[BundleManager] No manifest bundleHash — verified via X-Bundle-SHA256 header only`);
290
+ } else {
291
+ throw new Error(
292
+ `No bundleHash in manifest and no X-Bundle-SHA256 header from server. Refusing to evaluate untrusted bundle.`
293
+ );
294
+ }
295
+
296
+ return { content, sha256: computed };
297
+ }
298
+
299
+ private evaluate(content: string, bundleUrl: string): any {
300
+ // Strip __toESM wrapping — our shim already provides correct ESM objects.
301
+ // esbuild's __toESM + isNodeMode=1 double-wraps and breaks React 19 hooks.
302
+ let cleaned = content.replace(
303
+ /__toESM\(require\(("[^"]+")\)(?:,\s*\d+)?\)/g,
304
+ 'require($1)',
305
+ );
306
+
307
+ // Strip __toCommonJS wrapper — it creates a getter-based Proxy that
308
+ // collapses all named exports to the same object in Hermes/JSC.
309
+ cleaned = cleaned.replace(
310
+ /module\.exports\s*=\s*__toCommonJS\((\w+)\)/g,
311
+ 'module.exports = $1',
312
+ );
313
+
314
+ const mod: { exports: any } = { exports: {} };
315
+ try {
316
+ const factory = new Function(
317
+ 'module', 'exports', 'require', '__filename', '__dirname',
318
+ cleaned,
319
+ );
320
+ factory(mod, mod.exports, requireShim, bundleUrl, 'adas-plugin-bundles');
321
+ } catch (err) {
322
+ throw new Error(`Bundle evaluation failed: ${err instanceof Error ? err.message : String(err)}`);
323
+ }
324
+
325
+ // Materialize getter-based exports into plain values.
326
+ // esbuild's __export uses Object.defineProperty getters which can
327
+ // misbehave in Hermes/JSC — force-resolve them now.
328
+ const raw = mod.exports;
329
+ const materialized: Record<string, any> = {};
330
+ for (const key of Object.getOwnPropertyNames(raw)) {
331
+ try {
332
+ materialized[key] = raw[key];
333
+ } catch { /* skip broken getters */ }
334
+ }
335
+ return materialized;
336
+ }
337
+
338
+ // ── Private: component extraction ───────────────────────────
339
+
340
+ /**
341
+ * Find a plugin component in a loaded module.
342
+ * Handles: default export, direct key match, multi-plugin .id match.
343
+ */
344
+ private getComponentFromMemory(pluginId: string, bundleUrl: string): RegisteredUIPlugin | null {
345
+ const mod = this.modules.get(bundleUrl);
346
+ if (!mod) return null;
347
+
348
+ const shortName = pluginId.includes(':') ? pluginId.split(':').pop()! : pluginId;
349
+
350
+ // 1. Direct key match (full pluginId as key)
351
+ if (mod[pluginId] && typeof mod[pluginId].Component === 'function') {
352
+ return mod[pluginId] as RegisteredUIPlugin;
353
+ }
354
+
355
+ // 2. PascalCase match from shortName (most reliable for combined bundles)
356
+ // e.g., "memories-panel" → "MemoriesPanel"
357
+ const pascal = shortName.replace(/(^|-)([a-z])/g, (_: string, __: string, c: string) => c.toUpperCase());
358
+ if (mod[pascal] && typeof mod[pascal] === 'object' && typeof mod[pascal].Component === 'function') {
359
+ return mod[pascal] as RegisteredUIPlugin;
360
+ }
361
+
362
+ // 3. Default export (single-plugin bundles)
363
+ if (mod.default && typeof mod.default === 'object' && typeof mod.default.Component === 'function') {
364
+ return mod.default as RegisteredUIPlugin;
365
+ }
366
+
367
+ // 4. Match by .id field (last resort — can be unreliable in combined bundles)
368
+ for (const key of Object.keys(mod)) {
369
+ const val = mod[key];
370
+ if (
371
+ val &&
372
+ typeof val === 'object' &&
373
+ val.id === shortName &&
374
+ typeof val.Component === 'function'
375
+ ) {
376
+ return val as RegisteredUIPlugin;
377
+ }
378
+ }
379
+
380
+ return null;
381
+ }
382
+
383
+ private describeModule(mod: any): string {
384
+ if (!mod) return '(empty)';
385
+ return Object.keys(mod)
386
+ .map(k => {
387
+ const v = mod[k];
388
+ if (v && typeof v === 'object' && v.id) return `${k}(id="${v.id}")`;
389
+ return k;
390
+ })
391
+ .join(', ');
392
+ }
393
+ }
394
+
395
+ /** Singleton — shared across all plugin renderers */
396
+ export const bundleManager = new BundleManager();