@stina/extension-api 0.5.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.
package/src/runtime.ts ADDED
@@ -0,0 +1,567 @@
1
+ /**
2
+ * Extension Runtime - Runs inside the worker
3
+ *
4
+ * This module handles communication with the Extension Host and provides
5
+ * the ExtensionContext to the extension's activate function.
6
+ */
7
+
8
+ import type {
9
+ ExtensionContext,
10
+ ExtensionModule,
11
+ Disposable,
12
+ NetworkAPI,
13
+ SettingsAPI,
14
+ ProvidersAPI,
15
+ ToolsAPI,
16
+ DatabaseAPI,
17
+ StorageAPI,
18
+ LogAPI,
19
+ AIProvider,
20
+ Tool,
21
+ ToolResult,
22
+ ModelInfo,
23
+ ChatMessage,
24
+ ChatOptions,
25
+ GetModelsOptions,
26
+ StreamEvent,
27
+ } from './types.js'
28
+
29
+ import type {
30
+ HostToWorkerMessage,
31
+ WorkerToHostMessage,
32
+ RequestMessage,
33
+ PendingRequest,
34
+ } from './messages.js'
35
+
36
+ import { generateMessageId } from './messages.js'
37
+
38
+ // ============================================================================
39
+ // Environment Detection and Message Port
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Detect if we're in Node.js Worker Thread or Web Worker
44
+ * and get the appropriate message port
45
+ */
46
+ interface MessagePort {
47
+ postMessage(message: WorkerToHostMessage): void
48
+ onMessage(handler: (message: HostToWorkerMessage) => void): void
49
+ }
50
+
51
+ function getMessagePort(): MessagePort {
52
+ // Check if we're in Node.js Worker Thread
53
+ if (typeof process !== 'undefined' && process.versions?.node) {
54
+ // Node.js Worker Thread - import parentPort dynamically
55
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
56
+ const { parentPort } = require('node:worker_threads')
57
+ return {
58
+ postMessage: (message) => parentPort?.postMessage(message),
59
+ onMessage: (handler) => parentPort?.on('message', handler),
60
+ }
61
+ }
62
+
63
+ // Web Worker - use self
64
+ return {
65
+ postMessage: (message) => self.postMessage(message),
66
+ onMessage: (handler) => {
67
+ self.addEventListener('message', (event: MessageEvent<HostToWorkerMessage>) => {
68
+ handler(event.data)
69
+ })
70
+ },
71
+ }
72
+ }
73
+
74
+ const messagePort = getMessagePort()
75
+
76
+ // ============================================================================
77
+ // Global State
78
+ // ============================================================================
79
+
80
+ let extensionModule: ExtensionModule | null = null
81
+ let extensionDisposable: Disposable | null = null
82
+ let extensionContext: ExtensionContext | null = null
83
+
84
+ const pendingRequests = new Map<string, PendingRequest>()
85
+ const registeredProviders = new Map<string, AIProvider>()
86
+ const registeredTools = new Map<string, Tool>()
87
+ const settingsCallbacks: Array<(key: string, value: unknown) => void> = []
88
+
89
+ const REQUEST_TIMEOUT = 30000 // 30 seconds
90
+
91
+ // ============================================================================
92
+ // Message Handling
93
+ // ============================================================================
94
+
95
+ /**
96
+ * Send a message to the host
97
+ */
98
+ function postMessage(message: WorkerToHostMessage): void {
99
+ messagePort.postMessage(message)
100
+ }
101
+
102
+ /**
103
+ * Send a request to the host and wait for response
104
+ */
105
+ async function sendRequest<T>(method: RequestMessage['method'], payload: unknown): Promise<T> {
106
+ const id = generateMessageId()
107
+
108
+ return new Promise<T>((resolve, reject) => {
109
+ const timeout = setTimeout(() => {
110
+ pendingRequests.delete(id)
111
+ reject(new Error(`Request ${method} timed out`))
112
+ }, REQUEST_TIMEOUT)
113
+
114
+ pendingRequests.set(id, { resolve: resolve as (value: unknown) => void, reject, timeout })
115
+
116
+ postMessage({
117
+ type: 'request',
118
+ id,
119
+ method,
120
+ payload,
121
+ })
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Handle messages from the host
127
+ */
128
+ async function handleHostMessage(message: HostToWorkerMessage): Promise<void> {
129
+ switch (message.type) {
130
+ case 'activate':
131
+ await handleActivate(message.payload)
132
+ break
133
+
134
+ case 'deactivate':
135
+ await handleDeactivate()
136
+ break
137
+
138
+ case 'settings-changed':
139
+ handleSettingsChanged(message.payload.key, message.payload.value)
140
+ break
141
+
142
+ case 'provider-chat-request':
143
+ await handleProviderChatRequest(message.id, message.payload)
144
+ break
145
+
146
+ case 'provider-models-request':
147
+ await handleProviderModelsRequest(message.id, message.payload)
148
+ break
149
+
150
+ case 'tool-execute-request':
151
+ await handleToolExecuteRequest(message.id, message.payload)
152
+ break
153
+
154
+ case 'response':
155
+ handleResponse(message.payload)
156
+ break
157
+ }
158
+ }
159
+
160
+ function handleResponse(payload: { requestId: string; success: boolean; data?: unknown; error?: string }): void {
161
+ const pending = pendingRequests.get(payload.requestId)
162
+ if (!pending) return
163
+
164
+ clearTimeout(pending.timeout)
165
+ pendingRequests.delete(payload.requestId)
166
+
167
+ if (payload.success) {
168
+ pending.resolve(payload.data)
169
+ } else {
170
+ pending.reject(new Error(payload.error || 'Unknown error'))
171
+ }
172
+ }
173
+
174
+ // ============================================================================
175
+ // Activation / Deactivation
176
+ // ============================================================================
177
+
178
+ async function handleActivate(payload: {
179
+ extensionId: string
180
+ extensionVersion: string
181
+ storagePath: string
182
+ permissions: string[]
183
+ settings: Record<string, unknown>
184
+ }): Promise<void> {
185
+ const { extensionId, extensionVersion, storagePath, permissions } = payload
186
+
187
+ // Build the context based on permissions
188
+ extensionContext = buildContext(extensionId, extensionVersion, storagePath, permissions)
189
+
190
+ // Import and activate the extension
191
+ try {
192
+ // The actual extension code should be bundled and available
193
+ // This is called after the extension code has been loaded into the worker
194
+ if (extensionModule?.activate) {
195
+ const result = await extensionModule.activate(extensionContext)
196
+ if (result && 'dispose' in result) {
197
+ extensionDisposable = result
198
+ }
199
+ }
200
+ } catch (error) {
201
+ extensionContext.log.error('Failed to activate extension', {
202
+ error: error instanceof Error ? error.message : String(error),
203
+ })
204
+ throw error
205
+ }
206
+ }
207
+
208
+ async function handleDeactivate(): Promise<void> {
209
+ try {
210
+ if (extensionModule?.deactivate) {
211
+ await extensionModule.deactivate()
212
+ }
213
+ if (extensionDisposable) {
214
+ extensionDisposable.dispose()
215
+ }
216
+ } catch (error) {
217
+ console.error('Error during deactivation:', error)
218
+ } finally {
219
+ extensionModule = null
220
+ extensionDisposable = null
221
+ extensionContext = null
222
+ registeredProviders.clear()
223
+ registeredTools.clear()
224
+ settingsCallbacks.length = 0
225
+ }
226
+ }
227
+
228
+ function handleSettingsChanged(key: string, value: unknown): void {
229
+ for (const callback of settingsCallbacks) {
230
+ try {
231
+ callback(key, value)
232
+ } catch (error) {
233
+ console.error('Error in settings change callback:', error)
234
+ }
235
+ }
236
+ }
237
+
238
+ // ============================================================================
239
+ // Provider / Tool Requests
240
+ // ============================================================================
241
+
242
+ async function handleProviderChatRequest(
243
+ requestId: string,
244
+ payload: { providerId: string; messages: ChatMessage[]; options: ChatOptions }
245
+ ): Promise<void> {
246
+ const provider = registeredProviders.get(payload.providerId)
247
+ if (!provider) {
248
+ postMessage({
249
+ type: 'request',
250
+ id: generateMessageId(),
251
+ method: 'network.fetch', // Dummy, we need a proper error response
252
+ payload: { error: `Provider ${payload.providerId} not found` },
253
+ })
254
+ return
255
+ }
256
+
257
+ try {
258
+ const generator = provider.chat(payload.messages, payload.options)
259
+ let sawDone = false
260
+ let sawError = false
261
+
262
+ for await (const event of generator) {
263
+ if (event.type === 'done') {
264
+ sawDone = true
265
+ } else if (event.type === 'error') {
266
+ sawError = true
267
+ }
268
+ postMessage({
269
+ type: 'stream-event',
270
+ payload: { requestId, event },
271
+ })
272
+ }
273
+
274
+ if (!sawDone && !sawError) {
275
+ postMessage({
276
+ type: 'stream-event',
277
+ payload: {
278
+ requestId,
279
+ event: { type: 'done' },
280
+ },
281
+ })
282
+ }
283
+ } catch (error) {
284
+ postMessage({
285
+ type: 'stream-event',
286
+ payload: {
287
+ requestId,
288
+ event: {
289
+ type: 'error',
290
+ message: error instanceof Error ? error.message : String(error),
291
+ },
292
+ },
293
+ })
294
+ }
295
+ }
296
+
297
+ async function handleProviderModelsRequest(
298
+ requestId: string,
299
+ payload: { providerId: string; options?: GetModelsOptions }
300
+ ): Promise<void> {
301
+ const provider = registeredProviders.get(payload.providerId)
302
+ if (!provider) {
303
+ // Send error response
304
+ postMessage({
305
+ type: 'provider-models-response',
306
+ payload: {
307
+ requestId,
308
+ models: [],
309
+ error: `Provider ${payload.providerId} not found`,
310
+ },
311
+ })
312
+ return
313
+ }
314
+
315
+ try {
316
+ // Pass options to getModels so provider can use settings (e.g., URL for Ollama)
317
+ const models = await provider.getModels(payload.options)
318
+ // Send response with models
319
+ postMessage({
320
+ type: 'provider-models-response',
321
+ payload: {
322
+ requestId,
323
+ models,
324
+ },
325
+ })
326
+ } catch (error) {
327
+ postMessage({
328
+ type: 'provider-models-response',
329
+ payload: {
330
+ requestId,
331
+ models: [],
332
+ error: error instanceof Error ? error.message : String(error),
333
+ },
334
+ })
335
+ }
336
+ }
337
+
338
+ async function handleToolExecuteRequest(
339
+ requestId: string,
340
+ payload: { toolId: string; params: Record<string, unknown> }
341
+ ): Promise<void> {
342
+ const tool = registeredTools.get(payload.toolId)
343
+ if (!tool) {
344
+ // Send error response
345
+ postMessage({
346
+ type: 'tool-execute-response',
347
+ payload: {
348
+ requestId,
349
+ result: { success: false, error: `Tool ${payload.toolId} not found` },
350
+ error: `Tool ${payload.toolId} not found`,
351
+ },
352
+ })
353
+ return
354
+ }
355
+
356
+ try {
357
+ const result = await tool.execute(payload.params)
358
+ // Send response with result
359
+ postMessage({
360
+ type: 'tool-execute-response',
361
+ payload: {
362
+ requestId,
363
+ result,
364
+ },
365
+ })
366
+ } catch (error) {
367
+ const errorMessage = error instanceof Error ? error.message : String(error)
368
+ postMessage({
369
+ type: 'tool-execute-response',
370
+ payload: {
371
+ requestId,
372
+ result: { success: false, error: errorMessage },
373
+ error: errorMessage,
374
+ },
375
+ })
376
+ }
377
+ }
378
+
379
+ // ============================================================================
380
+ // Context Building
381
+ // ============================================================================
382
+
383
+ function buildContext(
384
+ extensionId: string,
385
+ extensionVersion: string,
386
+ storagePath: string,
387
+ permissions: string[]
388
+ ): ExtensionContext {
389
+ const hasPermission = (perm: string): boolean => {
390
+ return permissions.some((p) => {
391
+ if (p === perm) return true
392
+ if (p.endsWith(':*') && perm.startsWith(p.slice(0, -1))) return true
393
+ return false
394
+ })
395
+ }
396
+
397
+ const log: LogAPI = {
398
+ debug: (message, data) => postMessage({ type: 'log', payload: { level: 'debug', message, data } }),
399
+ info: (message, data) => postMessage({ type: 'log', payload: { level: 'info', message, data } }),
400
+ warn: (message, data) => postMessage({ type: 'log', payload: { level: 'warn', message, data } }),
401
+ error: (message, data) => postMessage({ type: 'log', payload: { level: 'error', message, data } }),
402
+ }
403
+
404
+ const context: ExtensionContext = {
405
+ extension: {
406
+ id: extensionId,
407
+ version: extensionVersion,
408
+ storagePath,
409
+ },
410
+ log,
411
+ }
412
+
413
+ // Add network API if permitted
414
+ if (permissions.some((p) => p.startsWith('network:'))) {
415
+ const networkApi: NetworkAPI = {
416
+ async fetch(url: string, options?: RequestInit): Promise<Response> {
417
+ const result = await sendRequest<{ status: number; statusText: string; headers: Record<string, string>; body: string }>('network.fetch', { url, options })
418
+ return new Response(result.body, {
419
+ status: result.status,
420
+ statusText: result.statusText,
421
+ headers: result.headers,
422
+ })
423
+ },
424
+ }
425
+ ;(context as { network: NetworkAPI }).network = networkApi
426
+ }
427
+
428
+ // Add settings API if permitted
429
+ if (hasPermission('settings.register')) {
430
+ const settingsApi: SettingsAPI = {
431
+ async getAll<T extends Record<string, unknown>>(): Promise<T> {
432
+ return sendRequest<T>('settings.getAll', {})
433
+ },
434
+ async get<T>(key: string): Promise<T | undefined> {
435
+ return sendRequest<T | undefined>('settings.get', { key })
436
+ },
437
+ async set(key: string, value: unknown): Promise<void> {
438
+ return sendRequest<void>('settings.set', { key, value })
439
+ },
440
+ onChange(callback: (key: string, value: unknown) => void): Disposable {
441
+ settingsCallbacks.push(callback)
442
+ return {
443
+ dispose: () => {
444
+ const index = settingsCallbacks.indexOf(callback)
445
+ if (index >= 0) settingsCallbacks.splice(index, 1)
446
+ },
447
+ }
448
+ },
449
+ }
450
+ ;(context as { settings: SettingsAPI }).settings = settingsApi
451
+ }
452
+
453
+ // Add providers API if permitted
454
+ if (hasPermission('provider.register')) {
455
+ const providersApi: ProvidersAPI = {
456
+ register(provider: AIProvider): Disposable {
457
+ registeredProviders.set(provider.id, provider)
458
+ postMessage({
459
+ type: 'provider-registered',
460
+ payload: { id: provider.id, name: provider.name },
461
+ })
462
+ return {
463
+ dispose: () => {
464
+ registeredProviders.delete(provider.id)
465
+ },
466
+ }
467
+ },
468
+ }
469
+ ;(context as { providers: ProvidersAPI }).providers = providersApi
470
+ }
471
+
472
+ // Add tools API if permitted
473
+ if (hasPermission('tools.register')) {
474
+ const toolsApi: ToolsAPI = {
475
+ register(tool: Tool): Disposable {
476
+ registeredTools.set(tool.id, tool)
477
+ postMessage({
478
+ type: 'tool-registered',
479
+ payload: {
480
+ id: tool.id,
481
+ name: tool.name,
482
+ description: tool.description,
483
+ parameters: tool.parameters,
484
+ },
485
+ })
486
+ return {
487
+ dispose: () => {
488
+ registeredTools.delete(tool.id)
489
+ },
490
+ }
491
+ },
492
+ }
493
+ ;(context as { tools: ToolsAPI }).tools = toolsApi
494
+ }
495
+
496
+ // Add database API if permitted
497
+ if (hasPermission('database.own')) {
498
+ const databaseApi: DatabaseAPI = {
499
+ async execute<T = unknown>(sql: string, params?: unknown[]): Promise<T[]> {
500
+ return sendRequest<T[]>('database.execute', { sql, params })
501
+ },
502
+ }
503
+ ;(context as { database: DatabaseAPI }).database = databaseApi
504
+ }
505
+
506
+ // Add storage API if permitted
507
+ if (hasPermission('storage.local')) {
508
+ const storageApi: StorageAPI = {
509
+ async get<T>(key: string): Promise<T | undefined> {
510
+ return sendRequest<T | undefined>('storage.get', { key })
511
+ },
512
+ async set(key: string, value: unknown): Promise<void> {
513
+ return sendRequest<void>('storage.set', { key, value })
514
+ },
515
+ async delete(key: string): Promise<void> {
516
+ return sendRequest<void>('storage.delete', { key })
517
+ },
518
+ async keys(): Promise<string[]> {
519
+ return sendRequest<string[]>('storage.keys', {})
520
+ },
521
+ }
522
+ ;(context as { storage: StorageAPI }).storage = storageApi
523
+ }
524
+
525
+ return context
526
+ }
527
+
528
+ // ============================================================================
529
+ // Initialization
530
+ // ============================================================================
531
+
532
+ /**
533
+ * Initialize the extension runtime
534
+ * This should be called by the extension's entry point
535
+ */
536
+ export function initializeExtension(module: ExtensionModule): void {
537
+ extensionModule = module
538
+
539
+ // Set up message listener using the appropriate message port
540
+ messagePort.onMessage(async (message: HostToWorkerMessage) => {
541
+ try {
542
+ await handleHostMessage(message)
543
+ } catch (error) {
544
+ console.error('Error handling message:', error)
545
+ }
546
+ })
547
+
548
+ // Signal that we're ready
549
+ postMessage({ type: 'ready' })
550
+ }
551
+
552
+ // Re-export types for extensions to use
553
+ export type {
554
+ ExtensionContext,
555
+ ExtensionModule,
556
+ Disposable,
557
+ AIProvider,
558
+ Tool,
559
+ ToolDefinition,
560
+ ToolResult,
561
+ ToolCall,
562
+ ModelInfo,
563
+ ChatMessage,
564
+ ChatOptions,
565
+ GetModelsOptions,
566
+ StreamEvent,
567
+ } from './types.js'