feat(plugin-sdk): many more initial impl
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# {title}
|
||||
|
||||
{toc}
|
||||
|
||||
## Summary
|
||||
|
||||
{summary}
|
||||
|
||||
## Background
|
||||
|
||||
{background}
|
||||
|
||||
## Goals
|
||||
|
||||
{goals}
|
||||
|
||||
## Non-goals
|
||||
|
||||
{non-goals}
|
||||
|
||||
## Proposal
|
||||
|
||||
{proposal}
|
||||
|
||||
## Design Detials
|
||||
|
||||
{tagline of design details}
|
||||
|
||||
### {optional sub-sections}
|
||||
|
||||
{sub-sections}
|
||||
|
||||
### {optional sub-sections}
|
||||
|
||||
{sub-sections}
|
||||
|
||||
## Verify & Test
|
||||
|
||||
### Criteria
|
||||
|
||||
{how it can be seens as completed, or could be moved to next phase}
|
||||
|
||||
### Test & QA
|
||||
|
||||
{how to test}
|
||||
|
||||
## Progress
|
||||
|
||||
{multiple sections of progress}
|
||||
|
||||
## Reviews
|
||||
|
||||
### Q&A
|
||||
|
||||
{multiple Q&A for explaining what possible questions or challenges could have or being asked}
|
||||
|
||||
### Related Documentations
|
||||
|
||||
- [{title of the related documentation}]({related path to the related documentation})
|
||||
@@ -0,0 +1,230 @@
|
||||
---
|
||||
title: AIRI Plugin Platform
|
||||
description: Architecture for plugins, bridges, and multi-device orchestration
|
||||
---
|
||||
|
||||
# AIRI Plugin Platform
|
||||
|
||||
- [Summary](#summary)
|
||||
- [Background](#background)
|
||||
- [Goals](#goals)
|
||||
- [Non-goals](#non-goals)
|
||||
- [Proposal](#proposal)
|
||||
- [Design Detials](#design-detials)
|
||||
- [Control And Data Planes](#control-and-data-planes)
|
||||
- [Plugin Host And Viewers](#plugin-host-and-viewers)
|
||||
- [Plugin Lifecycle Overview](#plugin-lifecycle-overview)
|
||||
- [Bridges And Remote Plugins](#bridges-and-remote-plugins)
|
||||
- [Transport Abstraction](#transport-abstraction)
|
||||
- [Capability Model](#capability-model)
|
||||
- [Deployment Modes](#deployment-modes)
|
||||
- [Manifest And Entrypoints](#manifest-and-entrypoints)
|
||||
- [Verify & Test](#verify--test)
|
||||
- [Criteria](#criteria)
|
||||
- [Test & QA](#test--qa)
|
||||
- [Progress](#progress)
|
||||
- [Status](#status)
|
||||
- [Next Steps](#next-steps)
|
||||
- [Reviews](#reviews)
|
||||
- [Q&A](#qa)
|
||||
- [Related Documentations](#related-documentations)
|
||||
|
||||
## Summary
|
||||
|
||||
AIRI is a multi-node system where plugins, bridges, and viewers communicate over Eventa transports. A Plugin Host loads plugins, provides a single API surface, and routes control and data across devices. The platform separates the control plane from the data plane, supports local and remote plugins, and enables multi-device orchestration without changing plugin APIs.
|
||||
|
||||
## Background
|
||||
|
||||
AIRI needs to run across desktop, web, and mobile while keeping one clean API surface. Plugins must be able to register UI, declare capabilities, and exchange data with device-specific bridges. To keep the system scalable, high-rate streams must be separated from lifecycle and configuration traffic.
|
||||
|
||||
## Goals
|
||||
|
||||
- Provide a single plugin API surface across runtimes.
|
||||
- Separate lifecycle and configuration traffic from high-rate streams.
|
||||
- Support local plugins and remote plugins with the same protocol.
|
||||
- Allow multiple viewers and bridges to coordinate through a shared control plane.
|
||||
- Keep deployment flexible: embedded, external, or remote Plugin Host.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Defining the full plugin lifecycle state machine in this document.
|
||||
- Specifying detailed UI layouts or viewer implementations.
|
||||
- Implementing a new transport beyond Eventa adapters.
|
||||
|
||||
## Proposal
|
||||
|
||||
- Use Eventa for all control and data traffic.
|
||||
- Use a dedicated control plane for configuration, permissions, UI, and routing.
|
||||
- Use a data plane for high-volume streams like audio, vision, and telemetry.
|
||||
- Run plugins inside a Plugin Host that loads plugin entrypoints and exposes the SDK.
|
||||
- Treat bridges as device-specific integrations that only provide data and actions.
|
||||
|
||||
## Design Detials
|
||||
|
||||
The platform design focuses on consistent APIs, transport-agnostic integration, and multi-device orchestration.
|
||||
|
||||
### Control And Data Planes
|
||||
|
||||
Control plane purpose: lifecycle, configuration, routing policy, permissions, and UI contributions.
|
||||
|
||||
Typical control messages:
|
||||
|
||||
- control:hello
|
||||
- control:announce
|
||||
- control:plugin:register
|
||||
- control:plugin:config:get
|
||||
- control:plugin:config:set
|
||||
- control:capability:grant
|
||||
- control:capability:revoke
|
||||
- control:ui:register
|
||||
|
||||
Data plane purpose: real-time and high-throughput streams.
|
||||
|
||||
Typical data messages:
|
||||
|
||||
- data:context:update
|
||||
- data:vision:frame
|
||||
- data:audio:stream
|
||||
- data:transcript
|
||||
- data:character:output
|
||||
|
||||
Both planes use Eventa messages. Transport options:
|
||||
|
||||
- Two WebSocket endpoints
|
||||
- One multiplexed connection with namespaces
|
||||
|
||||
### Plugin Host And Viewers
|
||||
|
||||
The Plugin Host is a Node process that:
|
||||
|
||||
- Loads plugin entrypoints.
|
||||
- Exposes the AIRI SDK.
|
||||
- Registers UI contributions.
|
||||
- Negotiates capabilities.
|
||||
- Connects to control and data planes.
|
||||
|
||||
Viewers render UI and character output. Examples:
|
||||
|
||||
- Electron Stage with Configurator features.
|
||||
- Web Configurator client.
|
||||
- Pocket Stage client.
|
||||
|
||||
### Plugin Lifecycle Overview
|
||||
|
||||
The lifecycle below mirrors the detailed lifecycle comment in
|
||||
`packages/plugin-sdk/src/plugin-host/index.ts` and focuses on the module
|
||||
announcement, configuration, and capability phases.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Connect to control plane] --> B[Authenticate]
|
||||
B --> C[Host sends registry:modules:sync]
|
||||
C --> D[Module emits module:announce]
|
||||
D --> E[Module declares deps + initial config]
|
||||
E --> F{Dependencies resolved?}
|
||||
F -- no --> G[module:status emitted]
|
||||
F -- yes --> H[module:prepared]
|
||||
H --> I[module:configuration:needed]
|
||||
I --> J[validate/plan/commit config]
|
||||
J --> K[module:configuration:configured]
|
||||
K --> L[Offer capabilities]
|
||||
L --> M[Capability configuration phase]
|
||||
M --> N[module:status ready]
|
||||
```
|
||||
|
||||
### Bridges And Remote Plugins
|
||||
|
||||
Bridges connect external devices and services to AIRI. They do not own UI; they only provide data and actions. Examples:
|
||||
|
||||
- VS Code extension for editor context and commands.
|
||||
- Browser extension for page context.
|
||||
- Minecraft service for game events and commands.
|
||||
|
||||
Remote plugins are services in any language that connect over Eventa and register capabilities. They are preferred for server integrations and non-JS/TS stacks.
|
||||
|
||||
### Transport Abstraction
|
||||
|
||||
All SDK calls are transport-agnostic. The host controls whether communication is local IPC or remote RPC without changing plugin APIs.
|
||||
|
||||
### Capability Model
|
||||
|
||||
Each node announces capabilities on registration. The control plane grants or denies permissions and routes requests based on policy.
|
||||
|
||||
Example capabilities:
|
||||
|
||||
- context.read
|
||||
- context.write
|
||||
- ui.panel
|
||||
- ui.widget
|
||||
- vision.capture
|
||||
- vision.stream
|
||||
- device.mobile.sensors
|
||||
|
||||
### Deployment Modes
|
||||
|
||||
The Plugin Host can run in three modes:
|
||||
|
||||
1. Embedded in Electron main for install-and-go.
|
||||
2. External Node process for hot reload and isolation.
|
||||
3. Remote server for cross-device continuity.
|
||||
|
||||
### Manifest And Entrypoints
|
||||
|
||||
Plugins declare metadata in a manifest file and provide runtime entrypoints.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "airi.vscode",
|
||||
"name": "AIRI VS Code",
|
||||
"version": "1.0.0",
|
||||
"capabilities": ["context.read", "ui.panel", "commands"],
|
||||
"entrypoints": {
|
||||
"node": "./dist/node/index.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Verify & Test
|
||||
|
||||
### Criteria
|
||||
|
||||
- Plugins can be loaded by the host and register UI and capabilities.
|
||||
- Bridges can connect and be discovered by the control plane.
|
||||
- Data plane streams stay isolated from control plane traffic.
|
||||
- The same plugin API surface works across desktop, web, and mobile.
|
||||
|
||||
### Test & QA
|
||||
|
||||
- Integration test: host + viewer + bridge with control plane routing.
|
||||
- Integration test: data plane streaming with a high-rate source.
|
||||
- Compatibility test: same plugin entrypoints across multiple runtimes.
|
||||
|
||||
## Progress
|
||||
|
||||
### Status
|
||||
|
||||
Active design.
|
||||
|
||||
### Next Steps
|
||||
|
||||
- Align runtime docs with updated plugin context and transport strategy.
|
||||
- Expand remote plugin examples by language.
|
||||
|
||||
## Reviews
|
||||
|
||||
### Q&A
|
||||
|
||||
- Q: Why split control and data planes?
|
||||
A: Lifecycle traffic and high-rate streams have different reliability and QoS needs.
|
||||
|
||||
- Q: Do bridges render UI?
|
||||
A: No. UI is contributed to viewers through the control plane.
|
||||
|
||||
- Q: Can remote plugins be written without JS or npm?
|
||||
A: Yes. They only need to speak the Eventa protocol over WebSocket.
|
||||
|
||||
### Related Documentations
|
||||
|
||||
- [Multi-Transport Plugin Contexts](../../../../packages/plugin-sdk/docs/design/multi-transport.md)
|
||||
@@ -0,0 +1,176 @@
|
||||
# Multi-Transport Plugin Contexts
|
||||
|
||||
- [Summary](#summary)
|
||||
- [Background](#background)
|
||||
- [Goals](#goals)
|
||||
- [Non-goals](#non-goals)
|
||||
- [Proposal](#proposal)
|
||||
- [Design Detials](#design-detials)
|
||||
- [Context And Transport Model](#context-and-transport-model)
|
||||
- [Lifecycle Placement](#lifecycle-placement)
|
||||
- [Host Runtime Layout](#host-runtime-layout)
|
||||
- [API Binding Strategy](#api-binding-strategy)
|
||||
- [Local Vs Remote Plugins](#local-vs-remote-plugins)
|
||||
- [Multi-Plugin Isolation](#multi-plugin-isolation)
|
||||
- [Verify & Test](#verify--test)
|
||||
- [Criteria](#criteria)
|
||||
- [Test & QA](#test--qa)
|
||||
- [Progress](#progress)
|
||||
- [Status](#status)
|
||||
- [Next Steps](#next-steps)
|
||||
- [Reviews](#reviews)
|
||||
- [Q&A](#qa)
|
||||
- [Related Documentations](#related-documentations)
|
||||
|
||||
## Summary
|
||||
|
||||
Introduce a host-side transport-aware context factory that provides one Eventa context per plugin instance. Plugin SDK APIs become context-bound factories, allowing local (in-memory/worker) and remote (WebSocket) plugins to share the same API surface while using different transports. This enables multiple plugins within a single Plugin Host without cross-talk or global channel coupling.
|
||||
|
||||
## Background
|
||||
|
||||
`plugin-sdk` currently exposes APIs (for example `providers.listProviders`) that call `defineInvoke` on a globally imported channel. This couples plugins to a single shared context and prevents the host from isolating multiple plugins or using different transports per plugin. We also need a path to support local plugins (in-process or worker) and remote plugins (WebSocket) with consistent ergonomics.
|
||||
|
||||
Eventa is context-oriented: contexts are created per transport (in-memory, WebSocket, worker, electron) and the invoke/handler APIs attach to that context. Multiple contexts can co-exist in the same process.
|
||||
|
||||
## Goals
|
||||
|
||||
- Provide one context per plugin instance, scoped by transport.
|
||||
- Allow the same API surface to work for local and remote plugins.
|
||||
- Keep transport selection under Plugin Host control, not plugin control.
|
||||
- Support multiple plugins within one host without channel conflicts.
|
||||
- Keep the API ergonomics for plugin authors simple and explicit.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Designing the full plugin lifecycle orchestration (phase transitions, capability config, etc.).
|
||||
- Implementing a new transport stack beyond Eventa adapters (unless required by runtime gaps).
|
||||
- Defining plugin packaging or distribution formats beyond `ManifestV1` entrypoints.
|
||||
|
||||
## Proposal
|
||||
|
||||
1. Introduce a host-side `createPluginContext(transport)` factory that returns an Eventa context bound to the plugin's transport.
|
||||
2. Convert plugin SDK APIs to context-bound factories (`createApis(ctx)`), replacing global channel usage.
|
||||
3. Resolve transport per plugin instance during host setup and pass the created context into plugin `init()`.
|
||||
4. Add runtime-specific implementations under `plugin-host/runtimes/node` and `plugin-host/runtimes/web` to handle different transport adapters.
|
||||
5. Optional: introduce shared reliable WebSocket helpers if needed, but prefer Eventa adapters first.
|
||||
|
||||
## Design Detials
|
||||
|
||||
Transport-aware contexts for isolated multi-plugin hosts.
|
||||
|
||||
### Context And Transport Model
|
||||
|
||||
Define a small transport config type owned by the Plugin Host:
|
||||
|
||||
```ts
|
||||
export type PluginTransport
|
||||
= | { kind: 'in-memory' }
|
||||
| { kind: 'websocket', url: string, protocols?: string[] }
|
||||
| { kind: 'web-worker', worker: Worker }
|
||||
| { kind: 'node-worker', worker: import('node:worker_threads').Worker }
|
||||
| { kind: 'electron', target: 'main' | 'renderer', webContentsId?: number }
|
||||
```
|
||||
|
||||
`createPluginContext(transport)` creates and returns an Eventa context based on the transport adapter (in-memory, WebSocket, worker, electron).
|
||||
|
||||
### Lifecycle Placement
|
||||
|
||||
Context creation happens during host setup, before any plugin lifecycle method is called.
|
||||
|
||||
1. Load plugin module (FileSystemLoader / UrlLoader).
|
||||
2. Resolve transport for the plugin (manifest + host config).
|
||||
3. Create context via `createPluginContext(transport)`.
|
||||
4. Bind APIs with `createApis(ctx)`.
|
||||
5. Call `plugin.init({ host: ctx, apis })`.
|
||||
|
||||
### Host Runtime Layout
|
||||
|
||||
- `packages/plugin-sdk/src/plugin-host/transports/`:
|
||||
- transport type definitions and helpers
|
||||
- `packages/plugin-sdk/src/plugin-host/runtimes/node/`:
|
||||
- in-memory, node-worker, websocket implementations
|
||||
- `packages/plugin-sdk/src/plugin-host/runtimes/web/`:
|
||||
- web-worker, websocket implementations
|
||||
- `packages/plugin-sdk/src/plugin-host/index.ts`:
|
||||
- exports the runtime-appropriate `createPluginContext` via conditional exports
|
||||
|
||||
### API Binding Strategy
|
||||
|
||||
Replace direct channel usage with context-bound factories:
|
||||
|
||||
```ts
|
||||
export function createProviders(ctx: EventaContext) {
|
||||
return {
|
||||
listProviders() {
|
||||
return defineInvoke(ctx, protocolListProviders)()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createApis(ctx: EventaContext) {
|
||||
return { providers: createProviders(ctx) }
|
||||
}
|
||||
```
|
||||
|
||||
Plugins call `createApis(ctx)` provided by the host instead of importing global singletons.
|
||||
|
||||
### Local Vs Remote Plugins
|
||||
|
||||
- Local plugins:
|
||||
- `in-memory` for simplest case
|
||||
- `node-worker` or `web-worker` for isolation
|
||||
- Remote plugins:
|
||||
- `websocket` transport bound to a specific URL or connection
|
||||
|
||||
Transport selection is a host concern; plugins are transport-agnostic.
|
||||
|
||||
### Multi-Plugin Isolation
|
||||
|
||||
Each plugin has its own context and transport. APIs are bound to that context, preventing cross-talk. The host keeps a registry mapping plugin ID to its context, transport, and loaded module for lifecycle management.
|
||||
|
||||
## Verify & Test
|
||||
|
||||
### Criteria
|
||||
|
||||
- Multiple plugins can be loaded in one host without shared global channels.
|
||||
- Local plugin calls use in-memory or worker contexts without manual wiring in plugin code.
|
||||
- Remote plugin calls use WebSocket contexts and do not affect local plugins.
|
||||
- Existing plugin tests can be updated to pass by injecting a context into APIs.
|
||||
|
||||
### Test & QA
|
||||
|
||||
- Unit test: create two plugin contexts in the same process, verify isolated invoke/handler pairs.
|
||||
- Unit test: FileSystemLoader + in-memory context binds correctly to `createApis(ctx)`.
|
||||
- Integration test (optional): WebSocket adapter roundtrip using a stub server.
|
||||
|
||||
## Progress
|
||||
|
||||
### Status
|
||||
|
||||
Planned.
|
||||
|
||||
### Next Steps
|
||||
|
||||
- Implement `createApis(ctx)` and migrate current API modules.
|
||||
- Implement `createPluginContext` in node runtime (in-memory + websocket).
|
||||
- Update tests to construct APIs with a provided context.
|
||||
|
||||
## Reviews
|
||||
|
||||
### Q&A
|
||||
|
||||
- Q: Why not keep global channels and just switch the active channel?
|
||||
A: Global channels make multi-plugin isolation impossible and require global state mutation. Context-per-plugin avoids cross-talk and matches Eventa's design.
|
||||
|
||||
- Q: Do plugins need to know about transports?
|
||||
A: No. The host injects the context and APIs; plugins remain transport-agnostic.
|
||||
|
||||
- Q: Should we build a shared reliable WebSocket package?
|
||||
A: Only if we need custom reconnection/heartbeat logic across multiple packages. Start with Eventa adapters; factor out shared logic later if required.
|
||||
|
||||
- Q: Can workers be used for plugin isolation?
|
||||
A: Yes. Use Eventa web-worker or node-worker adapters to bridge a per-plugin context to the worker.
|
||||
|
||||
### Related Documentations
|
||||
|
||||
- [Plugin Lifecycle](./plugin-lifecycle.md)
|
||||
@@ -33,6 +33,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@moeru/eventa": "catalog:"
|
||||
"@moeru/eventa": "catalog:",
|
||||
"@proj-airi/server-shared": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { EventContext } from '@moeru/eventa'
|
||||
|
||||
import { createContext } from '@moeru/eventa'
|
||||
|
||||
export const channels = {
|
||||
/**
|
||||
* Channel for talking to Plugin Host.
|
||||
* Can be seen as Control plane.
|
||||
*
|
||||
* createContext() here is for fallback internal channel preventing undefined access.
|
||||
* In real usage, either local/* or remote/* channel implementation should be set as active channel.
|
||||
*/
|
||||
host: createContext(),
|
||||
/**
|
||||
* Channel for initialized plugin to transmit events to each other, includes plugins, and stage, configurator, etc.
|
||||
* Can be seen as Data plane.
|
||||
*
|
||||
* createContext() here is for fallback internal channel preventing undefined access.
|
||||
* In real usage, either local/* or remote/* channel implementation should be set as active channel.
|
||||
*/
|
||||
data: createContext(),
|
||||
}
|
||||
|
||||
export function setActiveHostChannel(context: EventContext<any, any>) {
|
||||
channels.host = context
|
||||
}
|
||||
|
||||
export function setActiveDataChannel(context: EventContext<any, any>) {
|
||||
channels.data = context
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createContext } from '@moeru/eventa/adapters/event-target'
|
||||
|
||||
export function createEventTargetHostChannel(eventTarget: EventTarget) {
|
||||
// TODO: implement actual event target based host channel
|
||||
return createContext(eventTarget)
|
||||
}
|
||||
|
||||
export function createEventTargetDataChannel(eventTarget: EventTarget) {
|
||||
// TODO: implement actual event target based data channel
|
||||
return createContext(eventTarget)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createContext } from '@moeru/eventa/adapters/websocket/native'
|
||||
|
||||
export function createWebSocketHostChannel(webSocket: WebSocket) {
|
||||
// TODO: make sure to setup proper event handling on the webSocket
|
||||
return createContext(webSocket)
|
||||
}
|
||||
|
||||
export function createWebSocketDataChannel(webSocket: WebSocket) {
|
||||
// TODO: make sure to setup proper event handling on the webSocket
|
||||
return createContext(webSocket)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { createContext } from '@moeru/eventa'
|
||||
|
||||
export type ChannelControlPlane = ReturnType<typeof createContext>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { createContext, defineEventa, defineInvokeHandler } from '@moeru/eventa'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { FileSystemLoader } from '.'
|
||||
import { channels } from '../channels'
|
||||
import { protocolProviders } from '../plugin/apis/protocol'
|
||||
|
||||
describe('for FileSystemPluginHost', () => {
|
||||
it('should load test-normal-plugin from manifest', async () => {
|
||||
const host = new FileSystemLoader()
|
||||
|
||||
const pluginDef = await host.loadPluginFor({
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.plugin.airi.moeru.ai',
|
||||
name: 'test-plugin',
|
||||
entrypoints: {
|
||||
electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'),
|
||||
},
|
||||
}, { cwd: '' })
|
||||
|
||||
const ctx = createContext()
|
||||
const onVitestCall = vi.fn()
|
||||
ctx.on(defineEventa('vitest-call:init'), onVitestCall)
|
||||
|
||||
await expect(pluginDef.init({ host: ctx })).resolves.not.toThrow()
|
||||
expect(onVitestCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should be able to handle test-error-plugin from manifest', async () => {
|
||||
const host = new FileSystemLoader()
|
||||
|
||||
await expect(host.loadPluginFor({
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.plugin.airi.moeru.ai',
|
||||
name: 'test-plugin',
|
||||
entrypoints: {
|
||||
electron: join(import.meta.dirname, 'testdata', 'test-error-plugin.ts'),
|
||||
},
|
||||
}, { cwd: '' })).rejects.toThrow('Test error plugin always throws an error during loading.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for PluginHost', () => {
|
||||
it('should be able to expose setupModules', async () => {
|
||||
const host = new FileSystemLoader()
|
||||
|
||||
const pluginDef = await host.loadPluginFor({
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.plugin.airi.moeru.ai',
|
||||
name: 'test-plugin',
|
||||
entrypoints: {
|
||||
electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'),
|
||||
},
|
||||
}, { cwd: '' })
|
||||
|
||||
const ctx = createContext()
|
||||
const onVitestCall = vi.fn()
|
||||
ctx.on(defineEventa('vitest-call:init'), onVitestCall)
|
||||
|
||||
await expect(pluginDef.init({ host: ctx })).resolves.not.toThrow()
|
||||
expect(onVitestCall).toHaveBeenCalledTimes(1)
|
||||
|
||||
defineInvokeHandler(channels.data, protocolProviders.listProviders, async () => {
|
||||
return [
|
||||
{ name: 'provider1' },
|
||||
]
|
||||
})
|
||||
|
||||
const onProviderListCall = vi.fn()
|
||||
ctx.on(protocolProviders.listProviders.sendEvent, onProviderListCall)
|
||||
await expect(pluginDef.setupModules?.()).resolves.not.toThrow()
|
||||
expect(onProviderListCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { definePlugin } from '../plugin'
|
||||
import type { Plugin } from '../plugin/shared'
|
||||
|
||||
import { join } from 'node:path'
|
||||
import { cwd } from 'node:process'
|
||||
|
||||
/**
|
||||
* Plugin Host lifecycle overview (transport-aware):
|
||||
*
|
||||
* - The host loads a plugin entrypoint (local or remote).
|
||||
* - The host resolves a per-plugin transport (in-memory, worker, WebSocket, electron).
|
||||
* - The host creates an Eventa context bound to that transport.
|
||||
* - The host binds SDK APIs to the context and passes them into plugin.init.
|
||||
*
|
||||
* This design allows multiple plugins in one host without shared global channels.
|
||||
* Each plugin instance has its own context and transport, so local and remote
|
||||
* plugins share the same API surface while remaining isolated.
|
||||
*/
|
||||
/**
|
||||
* One plugin could contribute multiple modules.
|
||||
*
|
||||
* For plugin itself, there are two ways to implement it, either local plugin, or remote plugin.
|
||||
* Since we have @moeru/eventa as underlying event transmission, we can drive everything in event.
|
||||
*
|
||||
* It's ok that local plugin doesn't implement the remote protocol to handle the remote plugin
|
||||
* RPC if doesn't wish for. Purely local UI manipulation or local resource registration is normal.
|
||||
*
|
||||
* In another word, we could implement the plugin in same eventa definition, while switching
|
||||
* between two different transport.
|
||||
*
|
||||
* For local plugin, local context for in-memory transport will be used.
|
||||
* For remote plugin, server-runtime for WebSocket based transport will be used.
|
||||
*
|
||||
*
|
||||
* The procedure looks like this (regardless to the underlying transport since we will implement
|
||||
* in both):
|
||||
*
|
||||
* 0. Channel Gateway sits on top of all channels
|
||||
* 1. Connect to control plane channel (from plugin-sdk, or any language implementation will impl)
|
||||
* 2. Authenticate with module:authenticate
|
||||
* 3. Plugin Host will send registry:modules:sync, this ensures the auto plugin / dependency discovery
|
||||
* 4. Module will now announce itself to the entire system through module:announce
|
||||
* 5. Module will now sync to Plugin Host that module now preparing, declaring its:
|
||||
* 1. Dependencies to other plugins / modules
|
||||
* 2. Initial Configuration (doesn't relate to capabilities)
|
||||
* Note that for capabilities requires Database configuration, and perhaps Memory manipulation,
|
||||
* plugin should orchestrate itself to contribute many capabilities / features, and the needed
|
||||
* configurations and credentials should be requested and configured for each capabilities
|
||||
* instead.
|
||||
* 6. During this phase, if module failed to find the needed dependency, module:status will be emitted
|
||||
* to allow the Plugin Host to surface errors or notice up to Configurator layer, to display the
|
||||
* needed warning and status.
|
||||
*
|
||||
* It's ok for module to stay online / connected to channels. In this phase, module:announce
|
||||
* could happen multiple times. Module is ok to listen to the sync events and decide whether to enter
|
||||
* the next phases if needed.
|
||||
* 7. During this phase, if plugin successfully configured itself and calculated / computed the possible
|
||||
* contributing capabilities / features, it will emit module:prepared.
|
||||
* 8. During this phase, if module requires more configuration to fill and enable in order to go next
|
||||
* phase, it's ok, it will emit module:configuration:needed.
|
||||
* 8. Module should now emit module:prepared.
|
||||
* 9. Module should now emit module:configuration:needed, for telling the shape to Configurator.
|
||||
* In between, for user side / Configurator side:
|
||||
* - module:configuration:validate:request (static check, zod/valibot or programmatic checks)
|
||||
* - module:configuration:validate:status (with parent event id)
|
||||
* - module:configuration:validate:response
|
||||
* - module:configuration:plan:request (actually dry-run, ensures anything during runtime works)
|
||||
* - module:configuration:plan:status (with parent event id)
|
||||
* - module:configuration:plan:response
|
||||
* - module:configuration:commit
|
||||
* - module:configuration:commit:status (with parent event id)
|
||||
* 9. Module previously configured will get validate, plan, and commit automatically, if failed, status
|
||||
* will surface to the Configurator side for further noticing to user.
|
||||
* 10. Module should now emit module:configuration:configured.
|
||||
* 11. Module should now be able to calculate / compute possible capabilities / features to be able to
|
||||
* contribute to the system / Plugin Host, once calculated, module:contribute:capability:offer will
|
||||
* be emitted in (length of) capabilities times.
|
||||
*
|
||||
* This means for 1 module that offers 5 capabilities, 5 * module:contribute:capability:offer will
|
||||
* be emitted.
|
||||
* 12. Next, module will now enter the capability / feature fill-in phase, during this phase, it's ok
|
||||
* to say that the plugin is running but nothing gets contributed if none of them were configured.
|
||||
*
|
||||
* For any capabilities without further configuration and fill-in from Configurator and User side,
|
||||
* it can be automatically activated now (which is next phase for module:contribute:capability:*
|
||||
* events), module:contribute:capability:configuration:configured,
|
||||
* module:contribute:capability:activated will be emitted.
|
||||
*
|
||||
* If further configuration and actions needed, module:contribute:capability:configuration:needed
|
||||
* will be emitted.
|
||||
*
|
||||
* To configure the capabilities in sequence and correct order,
|
||||
* - module:contribute:capability:configuration:validate:request (static check, zod/valibot or programmatic checks)
|
||||
* - module:contribute:capability:configuration:validate:status (with parent event id)
|
||||
* - module:contribute:capability:configuration:validate:response
|
||||
* - module:contribute:capability:configuration:plan:request (actually dry-run, ensures anything during runtime works)
|
||||
* - module:contribute:capability:configuration:plan:status (with parent event id)
|
||||
* - module:contribute:capability:configuration:plan:response
|
||||
* - module:contribute:capability:configuration:commit
|
||||
* - module:contribute:capability:configuration:commit:status (with parent event id)
|
||||
* similar to module:configuration are accepted.
|
||||
*
|
||||
* 13. No matter what happens, the module:status should emit with ready status now.
|
||||
* 14. Any time the module need to re-calculate / re-compute, or wish to be re-configured, it's ok to
|
||||
* emit module:status:change with needed phase to update, if need to rollback to announced phase,
|
||||
* Plugin Host should treat the Module to be un-prepared status, the needed procedure will be called.
|
||||
*/
|
||||
|
||||
export class PluginHost {
|
||||
constructor() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export interface ManifestV1 {
|
||||
apiVersion: 'v1'
|
||||
kind: 'manifest.plugin.airi.moeru.ai'
|
||||
name: string
|
||||
entrypoints: {
|
||||
electron?: string
|
||||
}
|
||||
}
|
||||
|
||||
export class FileSystemLoader {
|
||||
constructor() {
|
||||
|
||||
}
|
||||
|
||||
async loadLazyPluginFor(manifest: ManifestV1, options?: { cwd?: string }) {
|
||||
const root = options?.cwd ?? cwd()
|
||||
if (!manifest.entrypoints.electron) {
|
||||
throw new Error(''
|
||||
+ 'For locally installed, defined plugin, electron entrypoint is required.'
|
||||
+ 'The value of `entrypoints.electron` should be the relative path to the '
|
||||
+ 'root of app.getPath(\'userData\').',
|
||||
)
|
||||
}
|
||||
|
||||
const entrypoint = join(root, manifest.entrypoints.electron)
|
||||
const pluginModule = await import(entrypoint) as { default: ReturnType<typeof definePlugin> }
|
||||
return pluginModule.default
|
||||
}
|
||||
|
||||
async loadPluginFor(manifest: ManifestV1, options?: { cwd?: string }) {
|
||||
const root = options?.cwd ?? cwd()
|
||||
if (!manifest.entrypoints.electron) {
|
||||
throw new Error(''
|
||||
+ 'For locally installed, defined plugin, electron entrypoint is required.'
|
||||
+ 'The value of `entrypoints.electron` should be the relative path to the '
|
||||
+ 'root of app.getPath(\'userData\').',
|
||||
)
|
||||
}
|
||||
|
||||
const entrypoint = join(root, manifest.entrypoints.electron)
|
||||
const pluginModule = await import(entrypoint) as Plugin
|
||||
return pluginModule
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
throw new Error('Test error plugin always throws an error during loading.')
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ContextInit } from '../../apis/plugin/shared'
|
||||
|
||||
export async function init(_initContext: ContextInit) {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ContextInit } from '../../plugin/shared'
|
||||
|
||||
import { defineEventa } from '@moeru/eventa'
|
||||
|
||||
import { channels, providers } from '../../plugin'
|
||||
|
||||
export async function init(initContext: ContextInit): Promise<void | false> {
|
||||
initContext.host.emit(defineEventa('vitest-call:init'), undefined)
|
||||
}
|
||||
|
||||
export async function configure(): Promise<void> {
|
||||
|
||||
}
|
||||
|
||||
export async function setupModules(): Promise<void> {
|
||||
channels.host.emit(defineEventa('vitest-call:setup-modules'), await providers.listProviders())
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { channels } from '../../../channels'
|
||||
export * from './resources'
|
||||
@@ -0,0 +1 @@
|
||||
export { providers } from './providers'
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineInvoke } from '@moeru/eventa'
|
||||
|
||||
import { channels } from '../../../../../channels'
|
||||
import { protocolListProviders } from '../../../protocol/resources/providers'
|
||||
|
||||
export async function listProviders() {
|
||||
const func = defineInvoke(channels.data, protocolListProviders)
|
||||
return func()
|
||||
}
|
||||
|
||||
export const providers = {
|
||||
listProviders,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './client'
|
||||
export * from './protocol'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './resources'
|
||||
@@ -0,0 +1 @@
|
||||
export { protocolProviders } from './providers'
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineInvokeEventa } from '@moeru/eventa'
|
||||
|
||||
export const protocolListProviders = defineInvokeEventa<{ name: string }[]>('proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers')
|
||||
|
||||
export const protocolProviders = {
|
||||
listProviders: protocolListProviders,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Plugin } from './shared'
|
||||
|
||||
export function definePlugin(name: string, version: string, setup: () => Promise<Plugin> | Plugin): {
|
||||
name: string
|
||||
version: string
|
||||
setup: () => Promise<Plugin> | Plugin
|
||||
} {
|
||||
return {
|
||||
name,
|
||||
version,
|
||||
setup,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './apis'
|
||||
export * from './define'
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Setup the local plugin scope.
|
||||
*
|
||||
* TODO: now sure how this should be implemented, but perhaps it should call packages/plugin-sdk/src/channels to setup local channels
|
||||
* first? Then probably some other initialization steps.
|
||||
*/
|
||||
export async function setupLocalPluginScope() {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Setup the remote plugin scope.
|
||||
*
|
||||
* TODO: now sure how this should be implemented, but perhaps it should call packages/plugin-sdk/src/channels to setup remote channels
|
||||
* first? Then probably some other initialization steps.
|
||||
*/
|
||||
export async function setupRemotePluginScope() {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ChannelControlPlane } from '../channels/shared'
|
||||
|
||||
export interface ContextInit {
|
||||
host: ChannelControlPlane
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
/**
|
||||
*
|
||||
*/
|
||||
init?: (initContext: ContextInit) => Promise<void | undefined | false>
|
||||
/**
|
||||
*
|
||||
*/
|
||||
setupModules?: () => Promise<void | undefined>
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
MetadataEventSource,
|
||||
ModuleConfigSchema,
|
||||
ModuleDependency,
|
||||
WebSocketBaseEvent,
|
||||
WebSocketEvent,
|
||||
WebSocketEventOptionalSource,
|
||||
@@ -21,6 +23,8 @@ export interface ClientOptions<C = undefined> {
|
||||
possibleEvents?: Array<keyof WebSocketEvents<C>>
|
||||
token?: string
|
||||
identity?: MetadataEventSource
|
||||
dependencies?: ModuleDependency[]
|
||||
configSchema?: ModuleConfigSchema
|
||||
heartbeat?: {
|
||||
readTimeout?: number
|
||||
message?: MessageHeartbeat | string
|
||||
@@ -60,8 +64,9 @@ export class Client<C = undefined> {
|
||||
|
||||
constructor(options: ClientOptions<C>) {
|
||||
const identity = options.identity ?? {
|
||||
plugin: options.name,
|
||||
instanceId: createInstanceId(),
|
||||
kind: 'plugin',
|
||||
plugin: { id: options.name },
|
||||
id: createInstanceId(),
|
||||
}
|
||||
|
||||
this.opts = {
|
||||
@@ -233,6 +238,8 @@ export class Client<C = undefined> {
|
||||
name: this.opts.name,
|
||||
identity: this.identity,
|
||||
possibleEvents: this.opts.possibleEvents,
|
||||
dependencies: this.opts.dependencies,
|
||||
configSchema: this.opts.configSchema,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Generated
+3
@@ -1975,6 +1975,9 @@ importers:
|
||||
'@moeru/eventa':
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.0-alpha.14(electron@40.0.0)
|
||||
'@proj-airi/server-shared':
|
||||
specifier: workspace:*
|
||||
version: link:../server-shared
|
||||
|
||||
packages/server-runtime:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user