mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
refactor(extension-*,stage-tamagotchi,stage-ui,server-*): rename to extension, improve DX (#1892)
--------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by-agent: Codex
This commit is contained in:
co-authored by
autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent
8518c65aa4
commit
668440a732
@@ -33,7 +33,7 @@ import { setupServerChannel } from './services/airi/channel-server'
|
||||
import { setupGodotStageManager } from './services/airi/godot-stage'
|
||||
import { setupBuiltInServer } from './services/airi/http-server'
|
||||
import { setupMcpStdioManager } from './services/airi/mcp-servers'
|
||||
import { setupPluginHost } from './services/airi/plugins'
|
||||
import { setupExtensionHost } from './services/airi/plugins'
|
||||
import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
|
||||
import { setupAutoUpdater } from './services/electron/auto-updater'
|
||||
import { setupGlobalShortcutService } from './services/electron/global-shortcut'
|
||||
@@ -171,7 +171,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
const pluginHost = injeca.provide('modules:plugin-host', {
|
||||
dependsOn: { serverChannel, widgetsManager },
|
||||
build: ({ dependsOn }) => setupPluginHost(dependsOn),
|
||||
build: ({ dependsOn }) => setupExtensionHost(dependsOn),
|
||||
})
|
||||
|
||||
const windowAuthManager = injeca.provide('services:window-auth-manager', () => createWindowAuthManagerService())
|
||||
|
||||
+8
-9
@@ -1,32 +1,31 @@
|
||||
# Devtools Sample Plugin
|
||||
|
||||
This sample plugin is for validating plugin host behavior in the **Plugin Host Inspector** page.
|
||||
This sample extension is for validating extension host behavior in the **Extension Host Inspector** page.
|
||||
|
||||
## Files
|
||||
|
||||
- `plugin.airi.json`: plugin manifest (`ManifestV1`)
|
||||
- `devtools-sample-plugin.mjs`: plugin implementation
|
||||
- `extension.airi.json`: extension manifest (`ExtensionManifestV1`)
|
||||
- `devtools-sample-plugin.mjs`: extension implementation
|
||||
|
||||
The manifest declares the protocol permissions required by `apis.providers.listProviders()`: invoke `capabilities:wait`, invoke `resources:providers:list-providers`, read the provider resource, and wait for the provider-list capability.
|
||||
The manifest declares the extension entrypoint used by the host inspector sample.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Open `/devtools/plugin-host` in Stage Tamagotchi.
|
||||
2. Note the `registry.root` path from the page.
|
||||
3. Copy both files into that `registry.root` directory.
|
||||
4. In Plugin Host Inspector:
|
||||
4. In Extension Host Inspector:
|
||||
- click `Refresh`
|
||||
- find `devtools-sample-plugin`
|
||||
- click `Enable`
|
||||
- click `Load` (or `Load Enabled`)
|
||||
5. Confirm:
|
||||
- plugin appears as `loaded`
|
||||
- extension appears as `loaded`
|
||||
- session phase becomes `ready`
|
||||
- capability list is visible
|
||||
|
||||
## What this plugin does
|
||||
## What this extension does
|
||||
|
||||
- `init`: logs startup in renderer/main console.
|
||||
- `setupModules`: calls `apis.providers.listProviders()` and logs provider names.
|
||||
- `setup`: logs startup in renderer/main console.
|
||||
|
||||
It does not mutate app state; it is safe for lifecycle verification.
|
||||
|
||||
+10
-14
@@ -1,3 +1,5 @@
|
||||
import { defineExtension } from '@proj-airi/plugin-sdk'
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
@@ -5,18 +7,12 @@ function nowIso() {
|
||||
/**
|
||||
* Example plugin for verifying plugin-host lifecycle in devtools.
|
||||
*
|
||||
* This module intentionally avoids external package imports so it can run
|
||||
* from the userData plugins folder without additional dependency setup.
|
||||
* This module uses the public extension authoring API so it matches the
|
||||
* package shape expected by the current host loader.
|
||||
*/
|
||||
export async function init(_context) {
|
||||
console.info('[devtools-sample-plugin] init', { at: nowIso() })
|
||||
}
|
||||
|
||||
export async function setupModules({ apis }) {
|
||||
const providers = await apis.providers.listProviders()
|
||||
console.info('[devtools-sample-plugin] setupModules', {
|
||||
at: nowIso(),
|
||||
providerCount: providers.length,
|
||||
providerNames: providers.map(provider => provider.name),
|
||||
})
|
||||
}
|
||||
export default defineExtension({
|
||||
id: 'devtools-sample-plugin',
|
||||
setup() {
|
||||
console.info('[devtools-sample-plugin] setup', { at: nowIso() })
|
||||
},
|
||||
})
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "manifest.plugin.airi.moeru.ai",
|
||||
"name": "devtools-sample-plugin",
|
||||
"kind": "manifest.extension.airi.moeru.ai",
|
||||
"id": "devtools-sample-plugin",
|
||||
"permissions": {
|
||||
"apis": [
|
||||
{
|
||||
+21
-19
@@ -2,28 +2,30 @@ import type { FSWatcher } from 'node:fs'
|
||||
|
||||
import type { useLogg } from '@guiiai/logg'
|
||||
|
||||
import type { ManifestEntry, PluginConfig } from '../../types'
|
||||
import type { ExtensionConfig, ManifestEntry } from '../../types'
|
||||
|
||||
import { watch as watchFile } from 'node:fs'
|
||||
|
||||
import { manifestIdOf } from '../../host/registry'
|
||||
|
||||
/**
|
||||
* Declares the host-owned callbacks needed by the plugin auto-reload feature.
|
||||
* Declares the host-owned callbacks needed by the extension auto-reload feature.
|
||||
*
|
||||
* Use when:
|
||||
* - Installing the optional auto-reload feature into the Electron plugin host
|
||||
* - Installing the optional auto-reload feature into the Electron extension host
|
||||
* - Keeping file-watcher ownership outside the core host bootstrap
|
||||
*
|
||||
* Expects:
|
||||
* - `reload` unloads, refreshes, and loads the named plugin
|
||||
* - `resolveWatchPaths` returns stable absolute file paths for the plugin
|
||||
* - `reload` unloads, refreshes, and loads the named extension
|
||||
* - `resolveWatchPaths` returns stable absolute file paths for the extension
|
||||
* - `getConfig`, `listEntries`, and `isLoaded` always reflect current host state
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginAutoReloadFeatureOptions {
|
||||
export interface ExtensionAutoReloadFeatureOptions {
|
||||
log: ReturnType<typeof useLogg>
|
||||
getConfig: () => PluginConfig
|
||||
getConfig: () => ExtensionConfig
|
||||
listEntries: () => ManifestEntry[]
|
||||
isLoaded: (name: string) => boolean
|
||||
resolveWatchPaths: (name: string) => string[]
|
||||
@@ -31,21 +33,21 @@ export interface PluginAutoReloadFeatureOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages optional plugin auto-reload watchers and debounce timers.
|
||||
* Manages optional extension auto-reload watchers and debounce timers.
|
||||
*
|
||||
* Use when:
|
||||
* - The Electron plugin host wants manifest and entrypoint file watching as an installable feature
|
||||
* - The Electron extension host wants manifest and entrypoint file watching as an installable feature
|
||||
* - Host bootstrap should delegate watcher lifecycle and reload scheduling out of `host/index.ts`
|
||||
*
|
||||
* Expects:
|
||||
* - Call `sync()` after registry/config/load-state changes
|
||||
* - Call `clearPlugin(name)` before unloading or disabling a plugin
|
||||
* - Call `clearExtension(name)` before unloading or disabling a plugin
|
||||
* - Call `dispose()` during host shutdown
|
||||
*
|
||||
* Returns:
|
||||
* - The installed auto-reload feature controller
|
||||
*/
|
||||
export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOptions) {
|
||||
export function createExtensionAutoReloadFeature(options: ExtensionAutoReloadFeatureOptions) {
|
||||
const autoReloadInFlight = new Set<string>()
|
||||
const autoReloadTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const autoReloadWatchers = new Map<string, FSWatcher[]>()
|
||||
@@ -73,7 +75,7 @@ export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOp
|
||||
autoReloadWatchers.delete(name)
|
||||
}
|
||||
|
||||
const reloadPluginByName = async (name: string, changedPath: string) => {
|
||||
const reloadExtensionById = async (name: string, changedPath: string) => {
|
||||
if (autoReloadInFlight.has(name)) {
|
||||
return
|
||||
}
|
||||
@@ -81,10 +83,10 @@ export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOp
|
||||
autoReloadInFlight.add(name)
|
||||
try {
|
||||
await options.reload(name, changedPath)
|
||||
options.log.log('plugin auto-reloaded after file change', { plugin: name, path: changedPath })
|
||||
options.log.log('extension auto-reloaded after file change', { extension: name, path: changedPath })
|
||||
}
|
||||
catch (error) {
|
||||
options.log.withError(error).withFields({ plugin: name, path: changedPath }).error('plugin auto-reload failed')
|
||||
options.log.withError(error).withFields({ extension: name, path: changedPath }).error('extension auto-reload failed')
|
||||
}
|
||||
finally {
|
||||
autoReloadInFlight.delete(name)
|
||||
@@ -95,7 +97,7 @@ export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOp
|
||||
clearTimer(name)
|
||||
autoReloadTimers.set(name, setTimeout(() => {
|
||||
autoReloadTimers.delete(name)
|
||||
void reloadPluginByName(name, changedPath)
|
||||
void reloadExtensionById(name, changedPath)
|
||||
}, 180))
|
||||
}
|
||||
|
||||
@@ -103,7 +105,7 @@ export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOp
|
||||
sync() {
|
||||
const enabledNames = new Set(options.getConfig().autoReload)
|
||||
const desiredNames = new Set(options.listEntries()
|
||||
.map(entry => entry.manifest.name)
|
||||
.map(entry => manifestIdOf(entry.manifest))
|
||||
.filter(name => enabledNames.has(name) && options.isLoaded(name)))
|
||||
|
||||
for (const name of autoReloadWatchers.keys()) {
|
||||
@@ -128,12 +130,12 @@ export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOp
|
||||
try {
|
||||
const watcher = watchFile(watchPath, { persistent: false }, () => scheduleReload(name, watchPath))
|
||||
watcher.on('error', (error) => {
|
||||
options.log.withError(error).withFields({ plugin: name, path: watchPath }).warn('plugin auto-reload watcher error')
|
||||
options.log.withError(error).withFields({ extension: name, path: watchPath }).warn('extension auto-reload watcher error')
|
||||
})
|
||||
watchers.push(watcher)
|
||||
}
|
||||
catch (error) {
|
||||
options.log.withError(error).withFields({ plugin: name, path: watchPath }).warn('failed to watch plugin file for auto-reload')
|
||||
options.log.withError(error).withFields({ extension: name, path: watchPath }).warn('failed to watch extension file for auto-reload')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +144,7 @@ export function createPluginAutoReloadFeature(options: PluginAutoReloadFeatureOp
|
||||
}
|
||||
}
|
||||
},
|
||||
clearPlugin(name: string) {
|
||||
clearExtension(name: string) {
|
||||
clearTimer(name)
|
||||
closeWatchers(name)
|
||||
},
|
||||
|
||||
+5
-5
@@ -9,7 +9,7 @@ import { buildMountedStaticAssetPath } from '../../../http-server/static-assets/
|
||||
* Describes one plugin asset session creation request.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin-owned asset URL must be mounted behind the local loopback server with cookie auth
|
||||
* - A extension-owned asset URL must be mounted behind the local loopback server with cookie auth
|
||||
* - Snapshot builders need a transport-agnostic way to authorize one plugin asset route before iframe load
|
||||
*
|
||||
* Expects:
|
||||
@@ -25,7 +25,7 @@ export interface PluginAssetSessionInput {
|
||||
pluginId: string
|
||||
/** Plugin version expected by the server-side session validator. */
|
||||
version: string
|
||||
/** Parent plugin session id used for owner-scoped revocation. */
|
||||
/** Parent extension session id used for owner-scoped revocation. */
|
||||
ownerSessionId: string
|
||||
/** Asset path to mount in the returned renderer-facing URL. */
|
||||
routeAssetPath: string
|
||||
@@ -125,7 +125,7 @@ export interface PluginAssetSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the plugin-owned asset hosting service used by the plugin host.
|
||||
* Defines the extension-owned asset hosting service used by the extension host.
|
||||
*
|
||||
* Use when:
|
||||
* - Plugin snapshots need mounted asset URLs without depending on the H3 server shape
|
||||
@@ -172,11 +172,11 @@ async function removeCookies(cookieAdapter: PluginAssetCookieAdapter, baseUrl: s
|
||||
* Creates the plugin asset host service backed by the extension static asset server.
|
||||
*
|
||||
* Use when:
|
||||
* - The plugin host needs to expose mounted asset URLs to renderer snapshots
|
||||
* - The extension host needs to expose mounted asset URLs to renderer snapshots
|
||||
* - Asset session lifecycle should stay inside the plugin domain instead of the HTTP server layer
|
||||
*
|
||||
* Expects:
|
||||
* - `getManifestEntryByName` returns the latest plugin root/version map
|
||||
* - `getManifestEntryByName` returns the latest extension root/version map
|
||||
* - `cookieAdapter` writes and removes cookies in the Electron host session used by plugin iframes
|
||||
*
|
||||
* Returns:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { PluginConfig } from '../types'
|
||||
import type { ExtensionConfig } from '../types'
|
||||
|
||||
import { array, object, record, string } from 'valibot'
|
||||
|
||||
import { createConfig } from '../../../../libs/electron/persistence'
|
||||
|
||||
const pluginConfigSchema = object({
|
||||
const extensionConfigSchema = object({
|
||||
enabled: array(string()),
|
||||
autoReload: array(string()),
|
||||
known: record(string(), object({
|
||||
@@ -12,7 +12,7 @@ const pluginConfigSchema = object({
|
||||
})),
|
||||
})
|
||||
|
||||
function createDefaultPluginConfig(): PluginConfig {
|
||||
function createDefaultExtensionConfig(): ExtensionConfig {
|
||||
return {
|
||||
enabled: [],
|
||||
autoReload: [],
|
||||
@@ -21,27 +21,27 @@ function createDefaultPluginConfig(): PluginConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists plugin host enablement and discovery metadata.
|
||||
* Persists extension host enablement and discovery metadata.
|
||||
*
|
||||
* Use when:
|
||||
* - Bootstrapping the Electron plugin host
|
||||
* - Reading or updating `plugins-v1.json` state
|
||||
* - Bootstrapping the Electron extension host
|
||||
* - Reading or updating `extensions-v1.json` state
|
||||
*
|
||||
* Expects:
|
||||
* - `setup()` runs before `get()` or `update()`
|
||||
* - Consumers write complete `PluginConfig` snapshots
|
||||
* - Consumers write complete `ExtensionConfig` snapshots
|
||||
*
|
||||
* Returns:
|
||||
* - Accessors around the persisted plugin config document
|
||||
* - Accessors around the persisted extension config document
|
||||
*/
|
||||
export interface PluginHostConfigStore {
|
||||
export interface ExtensionHostConfigStore {
|
||||
setup: () => void
|
||||
get: () => PluginConfig
|
||||
update: (config: PluginConfig) => void
|
||||
get: () => ExtensionConfig
|
||||
update: (config: ExtensionConfig) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the persisted config store used by the plugin host bootstrap.
|
||||
* Creates the persisted config store used by the extension host bootstrap.
|
||||
*
|
||||
* Use when:
|
||||
* - Host bootstrap modules need config persistence without inlining schema setup
|
||||
@@ -50,23 +50,23 @@ export interface PluginHostConfigStore {
|
||||
* - Electron `app.getPath('userData')` is available through the persistence layer
|
||||
*
|
||||
* Returns:
|
||||
* - A small config store that always falls back to the default plugin config
|
||||
* - A small config store that always falls back to the default extension config
|
||||
*/
|
||||
export function createPluginHostConfigStore(): PluginHostConfigStore {
|
||||
const pluginConfig = createConfig('plugins', 'v1.json', pluginConfigSchema, {
|
||||
default: createDefaultPluginConfig(),
|
||||
export function createExtensionHostConfigStore(): ExtensionHostConfigStore {
|
||||
const extensionConfig = createConfig('extensions', 'v1.json', extensionConfigSchema, {
|
||||
default: createDefaultExtensionConfig(),
|
||||
autoHeal: true,
|
||||
})
|
||||
|
||||
return {
|
||||
setup() {
|
||||
pluginConfig.setup()
|
||||
extensionConfig.setup()
|
||||
},
|
||||
get() {
|
||||
return pluginConfig.get() ?? createDefaultPluginConfig()
|
||||
return extensionConfig.get() ?? createDefaultExtensionConfig()
|
||||
},
|
||||
update(config) {
|
||||
pluginConfig.update(config)
|
||||
extensionConfig.update(config)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import type { PluginHost } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import type { ExtensionHost } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import type {
|
||||
PluginHostDebugSnapshot,
|
||||
PluginHostModuleSummary,
|
||||
} from '../../../../../shared/eventa/plugin/host'
|
||||
import type { PluginAssetSnapshotService } from '../features/static-assets'
|
||||
import type { ManifestEntry, PluginConfig } from '../types'
|
||||
import type { ExtensionConfig, ManifestEntry } from '../types'
|
||||
|
||||
import { rewriteWidgetModuleAssetUrl } from '../kits/widget'
|
||||
import { buildPluginRegistrySnapshot } from './registry'
|
||||
|
||||
/**
|
||||
* Builds the debug snapshot exposed by the Electron plugin host inspector.
|
||||
* Builds the debug snapshot exposed by the Electron extension host inspector.
|
||||
*
|
||||
* Use when:
|
||||
* - Renderer devtools need sessions, kits, modules, and capability state
|
||||
* - Widget iframe asset URLs must be rewritten to mounted plugin asset URLs
|
||||
*
|
||||
* Expects:
|
||||
* - `host` is the initialized plugin host instance
|
||||
* - `manifestEntryByName` contains entries for any plugin-owned modules being inspected
|
||||
* - `host` is the initialized extension host instance
|
||||
* - `manifestEntryByName` contains entries for any extension-owned modules being inspected
|
||||
* - `pluginAssetService` owns plugin asset URL/session lifecycle when mounted asset URLs are needed
|
||||
*
|
||||
* Returns:
|
||||
* - A full debug snapshot with registry, sessions, kits, modules, and capabilities
|
||||
*/
|
||||
export function buildPluginHostDebugSnapshot(options: {
|
||||
host: PluginHost
|
||||
pluginsRoot: string
|
||||
host: ExtensionHost
|
||||
extensionsRoot: string
|
||||
entries: ManifestEntry[]
|
||||
config: PluginConfig
|
||||
config: ExtensionConfig
|
||||
loaded: Set<string>
|
||||
manifestEntryByName: Map<string, ManifestEntry>
|
||||
pluginAssetService?: PluginAssetSnapshotService
|
||||
@@ -66,17 +66,17 @@ export function buildPluginHostDebugSnapshot(options: {
|
||||
|
||||
return modules.then(resolvedModules => ({
|
||||
registry: buildPluginRegistrySnapshot({
|
||||
pluginsRoot: options.pluginsRoot,
|
||||
extensionsRoot: options.extensionsRoot,
|
||||
entries: options.entries,
|
||||
config: options.config,
|
||||
loaded: options.loaded,
|
||||
}),
|
||||
sessions: options.host.listSessions().map(session => ({
|
||||
id: session.id,
|
||||
manifestName: session.manifest.name,
|
||||
manifestName: session.manifest.id,
|
||||
phase: session.phase,
|
||||
runtime: session.runtime,
|
||||
moduleId: session.identity.id,
|
||||
runtime: session.runtime ?? 'electron',
|
||||
moduleId: session.extension.id,
|
||||
})),
|
||||
kits: options.host.listKits(),
|
||||
modules: resolvedModules as PluginHostDebugSnapshot['modules'],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TamagotchiToolRegistry } from '@proj-airi/plugin-sdk-tamagotchi/tools'
|
||||
|
||||
import type {
|
||||
PluginHostDebugSnapshot,
|
||||
PluginRegistrySnapshot,
|
||||
@@ -7,32 +9,30 @@ import type {
|
||||
PluginAssetSession,
|
||||
PluginAssetSnapshotService,
|
||||
} from '../features/static-assets'
|
||||
import type {
|
||||
PluginHostService,
|
||||
SetupPluginHostOptions,
|
||||
} from '../types'
|
||||
import type { ExtensionHostService, SetupExtensionHostOptions } from '../types'
|
||||
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
import { PluginHost } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import { ExtensionHost } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import { app, session as electronSession } from 'electron'
|
||||
|
||||
import { createPluginAutoReloadFeature } from '../features/auto-reload'
|
||||
import { createExtensionAutoReloadFeature } from '../features/auto-reload'
|
||||
import { createPluginAssetService } from '../features/static-assets'
|
||||
import { createBuiltInPluginKitRuntime } from '../kits'
|
||||
import { createPluginHostConfigStore } from './config'
|
||||
import { createBuiltInExtensionKitRuntime } from '../kits'
|
||||
import { createExtensionHostConfigStore } from './config'
|
||||
import { buildPluginHostDebugSnapshot } from './debug'
|
||||
import {
|
||||
buildPluginRegistrySnapshot,
|
||||
createExtensionHostRegistry,
|
||||
createManifestForLoad,
|
||||
createPluginHostRegistry,
|
||||
manifestIdOf,
|
||||
resolvePluginRuntimeEntrypointPath,
|
||||
} from './registry'
|
||||
|
||||
const extensionAssetSessionTtlMs = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
function createElectronPluginAssetCookieAdapter() {
|
||||
function createElectronExtensionAssetCookieAdapter() {
|
||||
return {
|
||||
async setCookie(cookie: PluginAssetCookie) {
|
||||
await electronSession.defaultSession.cookies.set({
|
||||
@@ -53,7 +53,7 @@ function createElectronPluginAssetCookieAdapter() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal plugin host bootstrap service used by the public `setupPluginHost(...)` facade.
|
||||
* Internal extension host bootstrap service used by the public `setupExtensionHost(...)` facade.
|
||||
*
|
||||
* Use when:
|
||||
* - `plugins/index.ts` needs a smaller orchestration layer with the same caller-facing API
|
||||
@@ -64,11 +64,14 @@ function createElectronPluginAssetCookieAdapter() {
|
||||
* - `widgetsManager` is ready before startup begins
|
||||
*
|
||||
* Returns:
|
||||
* - The plain `PluginHostService` fields plus internal helpers for list/load/unload/inspect/dispose
|
||||
* - The plain `ExtensionHostService` fields plus internal helpers for list/load/unload/inspect/dispose
|
||||
*/
|
||||
export interface PluginHostHostService extends PluginHostService {
|
||||
export interface ExtensionHostServiceInternal extends ExtensionHostService {
|
||||
/** Tamagotchi-owned extension tool registry used by IPC tool bridges. */
|
||||
tools: TamagotchiToolRegistry
|
||||
|
||||
/**
|
||||
* Lists the current plugin registry snapshot.
|
||||
* Lists the current extension registry snapshot.
|
||||
*
|
||||
* Use when:
|
||||
* - IPC callers need the latest discovered plugin entries and enablement state
|
||||
@@ -78,7 +81,7 @@ export interface PluginHostHostService extends PluginHostService {
|
||||
* - Manifest discovery can be refreshed before the snapshot is built
|
||||
*
|
||||
* Returns:
|
||||
* - The latest plugin registry snapshot for renderer consumption
|
||||
* - The latest extension registry snapshot for renderer consumption
|
||||
*/
|
||||
list: () => Promise<PluginRegistrySnapshot>
|
||||
|
||||
@@ -94,7 +97,7 @@ export interface PluginHostHostService extends PluginHostService {
|
||||
* - `payload.path` is only needed when the manifest is not currently discoverable
|
||||
*
|
||||
* Returns:
|
||||
* - The updated plugin registry snapshot after persistence
|
||||
* - The updated extension registry snapshot after persistence
|
||||
*/
|
||||
setEnabled: (payload: { name: string, enabled: boolean, path?: string }) => Promise<PluginRegistrySnapshot>
|
||||
|
||||
@@ -109,7 +112,7 @@ export interface PluginHostHostService extends PluginHostService {
|
||||
* - `payload.name` matches one plugin entry in config or discovery state
|
||||
*
|
||||
* Returns:
|
||||
* - The updated plugin registry snapshot after persistence
|
||||
* - The updated extension registry snapshot after persistence
|
||||
*/
|
||||
setAutoReload: (payload: { name: string, enabled: boolean }) => Promise<PluginRegistrySnapshot>
|
||||
|
||||
@@ -124,7 +127,7 @@ export interface PluginHostHostService extends PluginHostService {
|
||||
* - Discovery state is current before load begins
|
||||
*
|
||||
* Returns:
|
||||
* - The plugin registry snapshot after load attempts finish
|
||||
* - The extension registry snapshot after load attempts finish
|
||||
*/
|
||||
loadEnabled: () => Promise<PluginRegistrySnapshot>
|
||||
|
||||
@@ -139,7 +142,7 @@ export interface PluginHostHostService extends PluginHostService {
|
||||
* - `name` resolves to a manifest entry in the current registry
|
||||
*
|
||||
* Returns:
|
||||
* - The plugin registry snapshot after the load completes
|
||||
* - The extension registry snapshot after the load completes
|
||||
*/
|
||||
load: (name: string) => Promise<PluginRegistrySnapshot>
|
||||
|
||||
@@ -154,12 +157,12 @@ export interface PluginHostHostService extends PluginHostService {
|
||||
* - `name` identifies a plugin that may or may not currently be loaded
|
||||
*
|
||||
* Returns:
|
||||
* - The plugin registry snapshot after unload bookkeeping completes
|
||||
* - The extension registry snapshot after unload bookkeeping completes
|
||||
*/
|
||||
unload: (name: string) => Promise<PluginRegistrySnapshot>
|
||||
|
||||
/**
|
||||
* Builds the full plugin host debug snapshot.
|
||||
* Builds the full extension host debug snapshot.
|
||||
*
|
||||
* Use when:
|
||||
* - Devtools need sessions, kits, bindings, capabilities, and rewritten asset URLs
|
||||
@@ -192,7 +195,7 @@ export interface PluginHostHostService extends PluginHostService {
|
||||
* Disposes optional host features and asset hosting resources.
|
||||
*
|
||||
* Use when:
|
||||
* - Electron shutdown needs to stop plugin-owned background work
|
||||
* - Electron shutdown needs to stop extension-owned background work
|
||||
* - Tests need to release watchers and local asset servers deterministically
|
||||
*
|
||||
* Expects:
|
||||
@@ -205,50 +208,50 @@ export interface PluginHostHostService extends PluginHostService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the extracted Electron plugin host bootstrap used by the public facade.
|
||||
* Builds the extracted Electron extension host bootstrap used by the public facade.
|
||||
*
|
||||
* Use when:
|
||||
* - The public plugin service wants one internal bootstrap entrypoint
|
||||
* - The public extension service wants one internal bootstrap entrypoint
|
||||
* - Tests need direct access to the internal host bootstrap helper
|
||||
*
|
||||
* Expects:
|
||||
* - Electron `app.getPath('userData')` is available
|
||||
* - Plugin manifests live under `<userData>/plugins/v1`
|
||||
* - Extension manifests live under `<userData>/extensions/v1`
|
||||
*
|
||||
* Returns:
|
||||
* - The internal bootstrap service that powers the public plugin-host IPC facade
|
||||
* - The internal bootstrap service that powers the public extension-host IPC facade
|
||||
*/
|
||||
export async function setupPluginHostHostService(
|
||||
options: SetupPluginHostOptions,
|
||||
): Promise<PluginHostHostService> {
|
||||
const log = useLogg('main/plugin-host').useGlobalConfig()
|
||||
const pluginsRoot = join(app.getPath('userData'), 'plugins', 'v1')
|
||||
export async function setupExtensionHostServiceInternal(
|
||||
options: SetupExtensionHostOptions,
|
||||
): Promise<ExtensionHostServiceInternal> {
|
||||
const log = useLogg('main/extension-host').useGlobalConfig()
|
||||
const extensionsRoot = join(app.getPath('userData'), 'extensions', 'v1')
|
||||
|
||||
// Config
|
||||
const pluginConfig = createPluginHostConfigStore()
|
||||
pluginConfig.setup()
|
||||
const extensionConfig = createExtensionHostConfigStore()
|
||||
extensionConfig.setup()
|
||||
|
||||
// Kit API, Host
|
||||
const builtInKitRuntime = createBuiltInPluginKitRuntime(options)
|
||||
const host = new PluginHost({ runtime: 'electron', contributions: builtInKitRuntime.contributions })
|
||||
const builtInKitRuntime = createBuiltInExtensionKitRuntime(options)
|
||||
const host = new ExtensionHost({ runtime: 'electron', contributions: builtInKitRuntime.contributions })
|
||||
builtInKitRuntime.attachHost(host) // reverse dependency injection
|
||||
log.withFields({ pluginsRoot }).log('loading plugin manifests')
|
||||
log.withFields({ extensionsRoot }).log('loading extension manifests')
|
||||
// Once kit injected the host, then apply kits
|
||||
builtInKitRuntime.registerHostKits(host)
|
||||
|
||||
// plugin registry
|
||||
const pluginRegistry = createPluginHostRegistry({ pluginsRoot, log })
|
||||
// extension registry
|
||||
const extensionRegistry = createExtensionHostRegistry({ extensionsRoot, log })
|
||||
|
||||
await pluginRegistry.refresh()
|
||||
log.withFields({ count: pluginRegistry.listEntries().length }).log('plugin manifests loaded')
|
||||
for (const entry of pluginRegistry.listEntries()) {
|
||||
log.withFields({ name: entry.manifest.name, path: entry.path }).log('plugin manifest found')
|
||||
await extensionRegistry.refresh()
|
||||
log.withFields({ count: extensionRegistry.listEntries().length }).log('extension manifests loaded')
|
||||
for (const entry of extensionRegistry.listEntries()) {
|
||||
log.withFields({ name: manifestIdOf(entry.manifest), path: entry.path }).log('extension manifest found')
|
||||
}
|
||||
|
||||
// Plugin feature: Static Assets serving
|
||||
// Extension feature: Static Assets serving
|
||||
const pluginAssetService = createPluginAssetService({
|
||||
getManifestEntryByName: () => pluginRegistry.getManifestEntryByName(),
|
||||
cookieAdapter: createElectronPluginAssetCookieAdapter(),
|
||||
getManifestEntryByName: () => extensionRegistry.getManifestEntryByName(),
|
||||
cookieAdapter: createElectronExtensionAssetCookieAdapter(),
|
||||
})
|
||||
await pluginAssetService.start()
|
||||
|
||||
@@ -256,7 +259,7 @@ export async function setupPluginHostHostService(
|
||||
const loadedSessionIds = new Map<string, string>()
|
||||
const moduleAssetSessionCache = new Map<string, PluginAssetSession>()
|
||||
|
||||
const clearModuleAssetSessionCacheByPluginId = (pluginId: string) => {
|
||||
const clearModuleAssetSessionCacheByExtensionId = (pluginId: string) => {
|
||||
for (const key of moduleAssetSessionCache.keys()) {
|
||||
if (key.startsWith(`${pluginId}:`)) {
|
||||
moduleAssetSessionCache.delete(key)
|
||||
@@ -274,15 +277,15 @@ export async function setupPluginHostHostService(
|
||||
}
|
||||
|
||||
const refreshManifests = async () => {
|
||||
await pluginRegistry.refresh()
|
||||
await extensionRegistry.refresh()
|
||||
}
|
||||
|
||||
const getConfig = () => pluginConfig.get()
|
||||
const getConfig = () => extensionConfig.get()
|
||||
|
||||
const listSnapshot = (): PluginRegistrySnapshot => {
|
||||
return buildPluginRegistrySnapshot({
|
||||
pluginsRoot,
|
||||
entries: pluginRegistry.listEntries(),
|
||||
extensionsRoot,
|
||||
entries: extensionRegistry.listEntries(),
|
||||
config: getConfig(),
|
||||
loaded,
|
||||
})
|
||||
@@ -330,16 +333,16 @@ export async function setupPluginHostHostService(
|
||||
const inspectSnapshot = async (): Promise<PluginHostDebugSnapshot> => {
|
||||
return await buildPluginHostDebugSnapshot({
|
||||
host,
|
||||
pluginsRoot,
|
||||
entries: pluginRegistry.listEntries(),
|
||||
extensionsRoot,
|
||||
entries: extensionRegistry.listEntries(),
|
||||
config: getConfig(),
|
||||
loaded,
|
||||
manifestEntryByName: pluginRegistry.getManifestEntryByName(),
|
||||
manifestEntryByName: extensionRegistry.getManifestEntryByName(),
|
||||
pluginAssetService: pluginAssetSnapshotService,
|
||||
})
|
||||
}
|
||||
|
||||
const loadPluginByName = async (
|
||||
const loadExtensionById = async (
|
||||
name: string,
|
||||
loadOptions: { cacheBustKey?: string } = {},
|
||||
) => {
|
||||
@@ -347,37 +350,37 @@ export async function setupPluginHostHostService(
|
||||
return
|
||||
}
|
||||
|
||||
const entry = pluginRegistry.findManifestEntry(name)
|
||||
const entry = extensionRegistry.findManifestEntry(name)
|
||||
if (!entry) {
|
||||
throw new Error(`Plugin manifest not found: ${name}`)
|
||||
throw new Error(`Extension manifest not found: ${name}`)
|
||||
}
|
||||
|
||||
const manifestForLoad = createManifestForLoad(entry, loadOptions)
|
||||
const session = await host.start(manifestForLoad, { cwd: dirname(entry.path) })
|
||||
loaded.add(name)
|
||||
loadedSessionIds.set(name, session.id)
|
||||
log.log('plugin loaded', { plugin: name, sessionId: session.id })
|
||||
log.log('extension loaded', { extension: name, sessionId: session.id })
|
||||
}
|
||||
|
||||
const stopLoadedPluginByName = async (name: string) => {
|
||||
const stopLoadedExtensionById = async (name: string) => {
|
||||
const sessionId = loadedSessionIds.get(name)
|
||||
if (!sessionId) {
|
||||
loaded.delete(name)
|
||||
return
|
||||
}
|
||||
|
||||
host.stop(sessionId)
|
||||
await host.stop(sessionId)
|
||||
loadedSessionIds.delete(name)
|
||||
loaded.delete(name)
|
||||
|
||||
clearModuleAssetSessionCacheByOwnerSessionId(sessionId)
|
||||
await pluginAssetService.revokeByOwnerSessionId(sessionId)
|
||||
|
||||
log.log('plugin unloaded', { plugin: name, sessionId })
|
||||
log.log('extension unloaded', { extension: name, sessionId })
|
||||
}
|
||||
|
||||
const resolveAutoReloadWatchPaths = (name: string) => {
|
||||
const entry = pluginRegistry.findManifestEntry(name)
|
||||
const entry = extensionRegistry.findManifestEntry(name)
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
@@ -386,29 +389,29 @@ export async function setupPluginHostHostService(
|
||||
return [...new Set([entry.path, entrypointPath].filter((path): path is string => Boolean(path)))]
|
||||
}
|
||||
|
||||
// Plugin feature: Auto-reload for plugins
|
||||
const autoReloadFeature = createPluginAutoReloadFeature({
|
||||
// Extension feature: Auto-reload for plugins
|
||||
const autoReloadFeature = createExtensionAutoReloadFeature({
|
||||
log,
|
||||
getConfig,
|
||||
listEntries: () => pluginRegistry.listEntries(),
|
||||
listEntries: () => extensionRegistry.listEntries(),
|
||||
isLoaded: name => loaded.has(name),
|
||||
resolveWatchPaths: resolveAutoReloadWatchPaths,
|
||||
reload: async (name) => {
|
||||
await stopLoadedPluginByName(name)
|
||||
await stopLoadedExtensionById(name)
|
||||
await refreshManifests()
|
||||
await loadPluginByName(name, { cacheBustKey: `auto-reload-${Date.now()}` })
|
||||
await loadExtensionById(name, { cacheBustKey: `auto-reload-${Date.now()}` })
|
||||
},
|
||||
})
|
||||
|
||||
const unloadPluginByName = async (name: string) => {
|
||||
autoReloadFeature.clearPlugin(name)
|
||||
await stopLoadedPluginByName(name)
|
||||
const unloadExtensionById = async (name: string) => {
|
||||
autoReloadFeature.clearExtension(name)
|
||||
await stopLoadedExtensionById(name)
|
||||
}
|
||||
|
||||
const loadEnabledPlugins = async () => {
|
||||
const loadEnabledExtensions = async () => {
|
||||
const config = getConfig()
|
||||
for (const entry of pluginRegistry.listEntries()) {
|
||||
const name = entry.manifest.name
|
||||
for (const entry of extensionRegistry.listEntries()) {
|
||||
const name = manifestIdOf(entry.manifest)
|
||||
if (!config.enabled.includes(name)) {
|
||||
continue
|
||||
}
|
||||
@@ -417,10 +420,10 @@ export async function setupPluginHostHostService(
|
||||
}
|
||||
|
||||
try {
|
||||
await loadPluginByName(name)
|
||||
await loadExtensionById(name)
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).withFields({ plugin: name }).error('plugin failed to start')
|
||||
log.withError(error).withFields({ extension: name }).error('extension failed to start')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,12 +431,13 @@ export async function setupPluginHostHostService(
|
||||
}
|
||||
|
||||
await refreshManifests()
|
||||
await loadEnabledPlugins()
|
||||
await loadEnabledExtensions()
|
||||
autoReloadFeature.sync()
|
||||
|
||||
return {
|
||||
host,
|
||||
manifests: pluginRegistry.listManifests(),
|
||||
tools: builtInKitRuntime.tools,
|
||||
manifests: extensionRegistry.listManifests(),
|
||||
async list() {
|
||||
await refreshManifests()
|
||||
autoReloadFeature.sync()
|
||||
@@ -449,13 +453,13 @@ export async function setupPluginHostHostService(
|
||||
}
|
||||
else {
|
||||
enabled.delete(payload.name)
|
||||
clearModuleAssetSessionCacheByPluginId(payload.name)
|
||||
clearModuleAssetSessionCacheByExtensionId(payload.name)
|
||||
await pluginAssetService.revokeByPluginId(payload.name)
|
||||
}
|
||||
|
||||
const entry = pluginRegistry.findManifestEntry(payload.name)
|
||||
const entry = extensionRegistry.findManifestEntry(payload.name)
|
||||
const manifestPath = entry?.path ?? payload.path ?? ''
|
||||
pluginConfig.update({
|
||||
extensionConfig.update({
|
||||
enabled: [...enabled],
|
||||
autoReload: config.autoReload,
|
||||
known: {
|
||||
@@ -479,7 +483,7 @@ export async function setupPluginHostHostService(
|
||||
autoReload.delete(payload.name)
|
||||
}
|
||||
|
||||
pluginConfig.update({
|
||||
extensionConfig.update({
|
||||
...config,
|
||||
autoReload: [...autoReload],
|
||||
})
|
||||
@@ -489,18 +493,18 @@ export async function setupPluginHostHostService(
|
||||
},
|
||||
async loadEnabled() {
|
||||
await refreshManifests()
|
||||
await loadEnabledPlugins()
|
||||
await loadEnabledExtensions()
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
},
|
||||
async load(name) {
|
||||
await refreshManifests()
|
||||
await loadPluginByName(name)
|
||||
await loadExtensionById(name)
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
},
|
||||
async unload(name) {
|
||||
await unloadPluginByName(name)
|
||||
await unloadExtensionById(name)
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
},
|
||||
@@ -514,6 +518,7 @@ export async function setupPluginHostHostService(
|
||||
},
|
||||
async dispose() {
|
||||
autoReloadFeature.dispose()
|
||||
builtInKitRuntime.dispose()
|
||||
|
||||
moduleAssetSessionCache.clear()
|
||||
await pluginAssetService.revokeAll()
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import type { Dirent } from 'node:fs'
|
||||
|
||||
import type { useLogg } from '@guiiai/logg'
|
||||
import type { ManifestV1 } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import type { ExtensionManifestV1 } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import type {
|
||||
PluginManifestSummary,
|
||||
PluginRegistrySnapshot,
|
||||
} from '../../../../../shared/eventa/plugin/host'
|
||||
import type { ManifestEntry, PluginConfig } from '../types'
|
||||
import type { ExtensionConfig, ManifestEntry } from '../types'
|
||||
|
||||
import { mkdir, readdir, readFile, realpath, stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
|
||||
import { manifestV1Schema } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import { extensionManifestV1Schema } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import { safeParse } from 'valibot'
|
||||
|
||||
export const pluginManifestFileName = 'plugin.airi.json'
|
||||
export const extensionManifestFileName = 'extension.airi.json'
|
||||
|
||||
function isManifestV1(value: unknown): value is ManifestV1 {
|
||||
return safeParse(manifestV1Schema, value).success
|
||||
function isExtensionManifestV1(value: unknown): value is ExtensionManifestV1 {
|
||||
return safeParse(extensionManifestV1Schema, value).success
|
||||
}
|
||||
|
||||
export function manifestIdOf(manifest: ExtensionManifestV1) {
|
||||
return manifest.id
|
||||
}
|
||||
|
||||
async function realPathOf(entry: Dirent<string>, options?: { cwd?: string }): Promise<{ resolved: false, path?: string, error?: unknown } | { resolved: true, path: string, error?: unknown }> {
|
||||
@@ -41,16 +45,16 @@ async function realPathOf(entry: Dirent<string>, options?: { cwd?: string }): Pr
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads plugin manifests from plugin subdirectories under the configured root.
|
||||
* Loads extension manifests from plugin subdirectories under the configured root.
|
||||
*
|
||||
* Use when:
|
||||
* - Refreshing the plugin registry state from disk
|
||||
* - Refreshing the extension registry state from disk
|
||||
* - Resolving symlink-backed plugin directories before manifest parsing
|
||||
*
|
||||
* Expects:
|
||||
* - Root directory may not exist yet
|
||||
* - Each plugin is nested under its own child directory
|
||||
* - Each plugin directory may include `plugin.airi.json` and optional `package.json`
|
||||
* - Each extension directory may include `extension.airi.json` and optional `package.json`
|
||||
*
|
||||
* Returns:
|
||||
* - Array of validated manifest entries with resolved paths and version metadata
|
||||
@@ -69,7 +73,7 @@ export async function loadManifestsFrom(
|
||||
if (entry.isSymbolicLink()) {
|
||||
const { resolved, error } = await realPathOf(entry, { cwd: dir })
|
||||
if (error) {
|
||||
log.withError(error).withFields({ name: entry.name }).warn('failed to resolve plugin manifest path, skipping')
|
||||
log.withError(error).withFields({ name: entry.name }).warn('failed to resolve extension manifest path, skipping')
|
||||
continue
|
||||
}
|
||||
if (!resolved) {
|
||||
@@ -82,11 +86,11 @@ export async function loadManifestsFrom(
|
||||
}
|
||||
}
|
||||
|
||||
let pluginDir = join(dir, entry.name)
|
||||
let extensionDir = join(dir, entry.name)
|
||||
if (entry.isSymbolicLink()) {
|
||||
const { path, resolved } = await realPathOf(entry, { cwd: dir })
|
||||
if (resolved) {
|
||||
pluginDir = path
|
||||
extensionDir = path
|
||||
}
|
||||
else {
|
||||
log.withFields({ name: entry.name }).warn('found symlink that does not resolve to a file, skipping')
|
||||
@@ -94,15 +98,15 @@ export async function loadManifestsFrom(
|
||||
}
|
||||
}
|
||||
|
||||
const pluginEntries = await readdir(pluginDir, { withFileTypes: true })
|
||||
const manifestEntry = pluginEntries.find(candidate => candidate.name === pluginManifestFileName)
|
||||
const extensionEntries = await readdir(extensionDir, { withFileTypes: true })
|
||||
const manifestEntry = extensionEntries.find(candidate => candidate.name === extensionManifestFileName)
|
||||
if (!manifestEntry) {
|
||||
continue
|
||||
}
|
||||
|
||||
const manifestPath = join(pluginDir, pluginManifestFileName)
|
||||
const manifestPath = join(extensionDir, extensionManifestFileName)
|
||||
if (manifestEntry.isFile()) {
|
||||
manifestPaths.push({ path: manifestPath, rootDir: pluginDir })
|
||||
manifestPaths.push({ path: manifestPath, rootDir: extensionDir })
|
||||
continue
|
||||
}
|
||||
if (!manifestEntry.isSymbolicLink()) {
|
||||
@@ -115,7 +119,7 @@ export async function loadManifestsFrom(
|
||||
if (!stats.isFile()) {
|
||||
continue
|
||||
}
|
||||
manifestPaths.push({ path: manifestPath, rootDir: pluginDir })
|
||||
manifestPaths.push({ path: manifestPath, rootDir: extensionDir })
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).withFields({ name: manifestEntry.name }).warn('failed to resolve symlink, skipping')
|
||||
@@ -126,8 +130,8 @@ export async function loadManifestsFrom(
|
||||
try {
|
||||
const raw = await readFile(manifestPath.path, 'utf-8')
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!isManifestV1(parsed)) {
|
||||
log.warn('invalid plugin manifest schema', { path: manifestPath.path })
|
||||
if (!isExtensionManifestV1(parsed)) {
|
||||
log.warn('invalid extension manifest schema', { path: manifestPath.path })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -140,7 +144,7 @@ export async function loadManifestsFrom(
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Ignore package.json read failures; plugin manifests without package metadata
|
||||
// Ignore package.json read failures; extension manifests without package metadata
|
||||
// still load with a deterministic fallback version.
|
||||
}
|
||||
|
||||
@@ -152,7 +156,7 @@ export async function loadManifestsFrom(
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).withFields({ path: manifestPath.path }).error('failed to read plugin manifest')
|
||||
log.withError(error).withFields({ path: manifestPath.path }).error('failed to read extension manifest')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +171,7 @@ export async function loadManifestsFrom(
|
||||
*
|
||||
* Expects:
|
||||
* - `entry` corresponds to a currently discovered manifest
|
||||
* - `config` is the latest persisted plugin config
|
||||
* - `config` is the latest persisted extension config
|
||||
* - `loaded` tracks currently running plugin names
|
||||
*
|
||||
* Returns:
|
||||
@@ -175,10 +179,10 @@ export async function loadManifestsFrom(
|
||||
*/
|
||||
export function createPluginSummary(
|
||||
entry: ManifestEntry,
|
||||
config: PluginConfig,
|
||||
config: ExtensionConfig,
|
||||
loaded: Set<string>,
|
||||
): PluginManifestSummary {
|
||||
const name = entry.manifest.name
|
||||
const name = manifestIdOf(entry.manifest)
|
||||
return {
|
||||
name,
|
||||
entrypoints: entry.manifest.entrypoints,
|
||||
@@ -191,7 +195,7 @@ export function createPluginSummary(
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the renderer-facing plugin registry snapshot.
|
||||
* Builds the renderer-facing extension registry snapshot.
|
||||
*
|
||||
* Use when:
|
||||
* - IPC clients request the plugin list
|
||||
@@ -204,13 +208,13 @@ export function createPluginSummary(
|
||||
* - A stable registry snapshot for renderer consumption
|
||||
*/
|
||||
export function buildPluginRegistrySnapshot(options: {
|
||||
pluginsRoot: string
|
||||
extensionsRoot: string
|
||||
entries: ManifestEntry[]
|
||||
config: PluginConfig
|
||||
config: ExtensionConfig
|
||||
loaded: Set<string>
|
||||
}): PluginRegistrySnapshot {
|
||||
return {
|
||||
root: options.pluginsRoot,
|
||||
root: options.extensionsRoot,
|
||||
plugins: options.entries.map(entry => createPluginSummary(entry, options.config, options.loaded)),
|
||||
}
|
||||
}
|
||||
@@ -260,12 +264,13 @@ function appendCacheBustKey(entrypoint: string, cacheBustKey: string): string {
|
||||
export function createManifestForLoad(
|
||||
entry: ManifestEntry,
|
||||
options: { cacheBustKey?: string },
|
||||
): ManifestV1 {
|
||||
): ExtensionManifestV1 {
|
||||
const loadManifest = entry.manifest
|
||||
if (!options.cacheBustKey) {
|
||||
return entry.manifest
|
||||
return loadManifest
|
||||
}
|
||||
|
||||
const manifest = structuredClone(entry.manifest)
|
||||
const manifest = structuredClone(loadManifest)
|
||||
if (manifest.entrypoints.electron) {
|
||||
manifest.entrypoints.electron = appendCacheBustKey(manifest.entrypoints.electron, options.cacheBustKey)
|
||||
}
|
||||
@@ -276,30 +281,30 @@ export function createManifestForLoad(
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the manifest registry state used by the Electron plugin host.
|
||||
* Tracks the manifest registry state used by the Electron extension host.
|
||||
*
|
||||
* Use when:
|
||||
* - Refreshing plugin manifests from disk
|
||||
* - Refreshing extension manifests from disk
|
||||
* - Looking up manifests by plugin name during load or inspect operations
|
||||
*
|
||||
* Expects:
|
||||
* - `refresh()` is called before consumers read entries or manifests
|
||||
* - `pluginsRoot` points at the plugin manifest root under user data
|
||||
* - `extensionsRoot` points at the extension manifest root under user data
|
||||
*
|
||||
* Returns:
|
||||
* - Read access to the current manifest entries, manifest list, and lookup map
|
||||
*/
|
||||
export interface PluginHostRegistry {
|
||||
export interface ExtensionHostRegistry {
|
||||
getRoot: () => string
|
||||
refresh: () => Promise<ManifestEntry[]>
|
||||
listEntries: () => ManifestEntry[]
|
||||
listManifests: () => ManifestV1[]
|
||||
listManifests: () => ExtensionManifestV1[]
|
||||
findManifestEntry: (name: string) => ManifestEntry | undefined
|
||||
getManifestEntryByName: () => Map<string, ManifestEntry>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the manifest registry store used by the plugin host bootstrap.
|
||||
* Creates the manifest registry store used by the extension host bootstrap.
|
||||
*
|
||||
* Use when:
|
||||
* - Host bootstrap needs in-memory manifest lookup and refresh operations
|
||||
@@ -310,24 +315,25 @@ export interface PluginHostRegistry {
|
||||
* Returns:
|
||||
* - A registry wrapper around the current manifest entry array and lookup map
|
||||
*/
|
||||
export function createPluginHostRegistry(options: {
|
||||
pluginsRoot: string
|
||||
export function createExtensionHostRegistry(options: {
|
||||
extensionsRoot: string
|
||||
log: ReturnType<typeof useLogg>
|
||||
}): PluginHostRegistry {
|
||||
}): ExtensionHostRegistry {
|
||||
let entries: ManifestEntry[] = []
|
||||
let manifests: ManifestV1[] = []
|
||||
let manifests: ExtensionManifestV1[] = []
|
||||
let manifestEntryByName = new Map<string, ManifestEntry>()
|
||||
|
||||
return {
|
||||
getRoot() {
|
||||
return options.pluginsRoot
|
||||
return options.extensionsRoot
|
||||
},
|
||||
async refresh() {
|
||||
entries = await loadManifestsFrom(options.pluginsRoot, options.log)
|
||||
entries = await loadManifestsFrom(options.extensionsRoot, options.log)
|
||||
manifestEntryByName = new Map()
|
||||
for (const entry of entries) {
|
||||
if (!manifestEntryByName.has(entry.manifest.name)) {
|
||||
manifestEntryByName.set(entry.manifest.name, entry)
|
||||
const id = manifestIdOf(entry.manifest)
|
||||
if (!manifestEntryByName.has(id)) {
|
||||
manifestEntryByName.set(id, entry)
|
||||
}
|
||||
}
|
||||
manifests = entries.map(entry => entry.manifest)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
PluginHostService,
|
||||
SetupPluginHostOptions,
|
||||
ExtensionHostService,
|
||||
SetupExtensionHostOptions,
|
||||
} from './types'
|
||||
|
||||
import {
|
||||
@@ -35,27 +35,27 @@ import {
|
||||
electronPluginListAgentTools,
|
||||
electronPluginListXsaiTools,
|
||||
} from '../../../../shared/eventa/plugin/tools'
|
||||
import { setupPluginHostHostService } from './host'
|
||||
import { setupExtensionHostServiceInternal } from './host'
|
||||
|
||||
/**
|
||||
* Initializes the Electron plugin host and wires IPC handlers.
|
||||
* Initializes the Electron extension host and wires IPC handlers.
|
||||
* Call once during app startup; it loads manifests, returns the host instance,
|
||||
* and registers Eventa handlers for listing, enabling, and loading plugins.
|
||||
*
|
||||
* Loads plugin manifests from the app config directory under `plugins/v1`.
|
||||
* Loads extension manifests from the app config directory under `extensions/v1`.
|
||||
*
|
||||
* - Windows: %APPDATA%\${appId}\plugins\v1
|
||||
* - Linux: $XDG_CONFIG_HOME/${appId}/plugins/v1 or ~/.config/${appId}/plugins/v1
|
||||
* - macOS: ~/Library/Application Support/${appId}/plugins/v1
|
||||
* - Windows: %APPDATA%\${appId}\extensions\v1
|
||||
* - Linux: $XDG_CONFIG_HOME/${appId}/extensions/v1 or ~/.config/${appId}/extensions/v1
|
||||
* - macOS: ~/Library/Application Support/${appId}/extensions/v1
|
||||
*
|
||||
* Persists enablement/known state to `plugins-v1.json` alongside config data.
|
||||
* Persists enablement/known state to `extensions-v1.json` alongside config data.
|
||||
*
|
||||
* - Windows: %APPDATA%\${appId}/plugins-v1.json
|
||||
* - Linux: $XDG_CONFIG_HOME/${appId}/plugins-v1.json or ~/.config/${appId}/plugins-v1.json
|
||||
* - macOS: ~/Library/Application Support/${appId}/plugins-v1.json
|
||||
* - Windows: %APPDATA%\${appId}/extensions-v1.json
|
||||
* - Linux: $XDG_CONFIG_HOME/${appId}/extensions-v1.json or ~/.config/${appId}/extensions-v1.json
|
||||
* - macOS: ~/Library/Application Support/${appId}/extensions-v1.json
|
||||
*/
|
||||
export async function setupPluginHost(options: SetupPluginHostOptions): Promise<PluginHostService> {
|
||||
const hostService = await setupPluginHostHostService(options)
|
||||
export async function setupExtensionHost(options: SetupExtensionHostOptions): Promise<ExtensionHostService> {
|
||||
const hostService = await setupExtensionHostServiceInternal(options)
|
||||
const { context } = createContext(ipcMain)
|
||||
const invokePluginProtocolListProviders = defineInvoke(context, pluginProtocolListProviders)
|
||||
|
||||
@@ -92,15 +92,15 @@ export async function setupPluginHost(options: SetupPluginHostOptions): Promise<
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronPluginListAgentTools, async () => {
|
||||
return await hostService.host.listAvailableToolDescriptors()
|
||||
return await hostService.tools.listAvailableDescriptors()
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronPluginListXsaiTools, async () => {
|
||||
return await hostService.host.listSerializedXsaiTools()
|
||||
return await hostService.tools.listSerializedXsaiTools()
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronPluginInvokeTool, async (payload) => {
|
||||
return await hostService.host.invokeTool(payload.ownerPluginId, payload.name, payload.input)
|
||||
return await hostService.tools.invoke(payload.ownerPluginId, payload.name, payload.input)
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronPluginUpdateCapability, async (payload) => {
|
||||
|
||||
-207
@@ -1,207 +0,0 @@
|
||||
import type {
|
||||
BindingRecord,
|
||||
HostDataRecord,
|
||||
PluginHost,
|
||||
} from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import type { WidgetWindowSize } from '../../../../../../shared/eventa'
|
||||
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
|
||||
function cloneRecord<TValue>(value: TValue): TValue {
|
||||
return structuredClone(value)
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isPlainObject(value) ? cloneRecord(value as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
function toHostDataRecord(value: unknown): HostDataRecord | undefined {
|
||||
return isPlainObject(value) ? cloneRecord(value as HostDataRecord) : undefined
|
||||
}
|
||||
|
||||
function toWindowSize(value: unknown): WidgetWindowSize | undefined {
|
||||
if (!isPlainObject(value)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof value.width !== 'number' || typeof value.height !== 'number') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return cloneRecord(value as WidgetWindowSize)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one owned gamelet binding and rejects mismatched ownership.
|
||||
*
|
||||
* Use when:
|
||||
* - Plugin sessions invoke `session.apis.gamelets.*`
|
||||
* - The gamelet kit must enforce plugin and session ownership before touching widget state
|
||||
*
|
||||
* Expects:
|
||||
* - `host` is the active plugin host instance
|
||||
* - `moduleId` refers to a binding announced through `kit.gamelet`
|
||||
*
|
||||
* Returns:
|
||||
* - The owned gamelet binding record when ownership and kit checks pass
|
||||
*/
|
||||
export function getOwnedGameletBindingOrThrow(params: {
|
||||
host: PluginHost
|
||||
ownerPluginId: string
|
||||
ownerSessionId: string
|
||||
moduleId: string
|
||||
}): BindingRecord<HostDataRecord> {
|
||||
const binding = params.host.getBinding(params.moduleId)
|
||||
if (!binding) {
|
||||
throw new Error(`Gamelet module not found: ${params.moduleId}`)
|
||||
}
|
||||
|
||||
if (binding.ownerPluginId !== params.ownerPluginId) {
|
||||
throw new Error(`Gamelet module \`${params.moduleId}\` is not owned by plugin \`${params.ownerPluginId}\`.`)
|
||||
}
|
||||
|
||||
if (binding.ownerSessionId !== params.ownerSessionId) {
|
||||
throw new Error(`Gamelet module \`${params.moduleId}\` is not owned by session \`${params.ownerSessionId}\`.`)
|
||||
}
|
||||
|
||||
if (binding.kitId !== 'kit.gamelet') {
|
||||
throw new Error(`Module \`${params.moduleId}\` is not a gamelet binding.`)
|
||||
}
|
||||
|
||||
return binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the widget window size for one gamelet binding.
|
||||
*
|
||||
* Use when:
|
||||
* - Opening or reconfiguring a gamelet-backed widget
|
||||
* - Preferring module config while preserving current window size when the config omits it
|
||||
*
|
||||
* Expects:
|
||||
* - `moduleConfig` is the binding config stored on the host
|
||||
*
|
||||
* Returns:
|
||||
* - The configured window size or the current widget snapshot size
|
||||
*/
|
||||
export function getGameletWidgetWindowSize(params: {
|
||||
moduleConfig: HostDataRecord
|
||||
existingSnapshot?: { windowSize?: unknown }
|
||||
}): WidgetWindowSize | undefined {
|
||||
const widgetConfig = toRecord(params.moduleConfig.widget)
|
||||
const windowSize = toWindowSize(widgetConfig?.windowSize)
|
||||
return windowSize ?? toWindowSize(params.existingSnapshot?.windowSize)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the current display title for a gamelet widget.
|
||||
*
|
||||
* Use when:
|
||||
* - Opening or updating a widget-backed gamelet
|
||||
* - Preserving a current title when the binding config does not provide one
|
||||
*
|
||||
* Expects:
|
||||
* - `moduleId` is the stable fallback title when neither config nor widget props define one
|
||||
*
|
||||
* Returns:
|
||||
* - The best available title for the widget shell
|
||||
*/
|
||||
export function getGameletTitle(params: {
|
||||
moduleId: string
|
||||
moduleConfig: HostDataRecord
|
||||
existingComponentProps?: Record<string, unknown>
|
||||
}): string {
|
||||
const configuredTitle = typeof params.moduleConfig.title === 'string' && params.moduleConfig.title.trim()
|
||||
? params.moduleConfig.title
|
||||
: undefined
|
||||
const currentTitle = typeof params.existingComponentProps?.title === 'string' && params.existingComponentProps.title.trim()
|
||||
? params.existingComponentProps.title
|
||||
: undefined
|
||||
|
||||
return configuredTitle ?? currentTitle ?? params.moduleId
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the persisted gamelet config payload stored under `config.current`.
|
||||
*
|
||||
* Use when:
|
||||
* - Hydrating widget payload state from the host binding config
|
||||
* - Merging `gamelets.configure(...)` patches into the stored config
|
||||
*
|
||||
* Expects:
|
||||
* - `moduleConfig` is the full binding config record for one gamelet
|
||||
*
|
||||
* Returns:
|
||||
* - The stored config payload, or an empty record when it has not been set yet
|
||||
*/
|
||||
export function getStoredGameletConfig(moduleConfig: HostDataRecord): HostDataRecord {
|
||||
const configSection = toHostDataRecord(moduleConfig.config)
|
||||
return toHostDataRecord(configSection?.current) ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges one `gamelets.configure(...)` patch into the stored binding config.
|
||||
*
|
||||
* Use when:
|
||||
* - Updating the host binding config and the mirrored widget payload together
|
||||
*
|
||||
* Expects:
|
||||
* - `patch` is a JSON-compatible config patch
|
||||
*
|
||||
* Returns:
|
||||
* - The merged `current` payload and the next full binding config record
|
||||
*/
|
||||
export function mergeGameletConfigPatch(params: {
|
||||
moduleConfig: HostDataRecord
|
||||
patch: HostDataRecord
|
||||
}): {
|
||||
nextCurrentConfig: HostDataRecord
|
||||
nextConfig: HostDataRecord
|
||||
} {
|
||||
const nextCurrentConfig: HostDataRecord = {
|
||||
...getStoredGameletConfig(params.moduleConfig),
|
||||
...cloneRecord(params.patch),
|
||||
}
|
||||
const nextConfig: HostDataRecord = {
|
||||
...cloneRecord(params.moduleConfig),
|
||||
config: {
|
||||
...toHostDataRecord(params.moduleConfig.config),
|
||||
current: nextCurrentConfig,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
nextCurrentConfig,
|
||||
nextConfig,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the extension-ui component props used for one gamelet widget.
|
||||
*
|
||||
* Use when:
|
||||
* - Opening or updating a gamelet-backed extension-ui widget
|
||||
* - Preserving unrelated existing component props while replacing payload-specific fields
|
||||
*
|
||||
* Expects:
|
||||
* - `moduleId` and `title` are already resolved for the current binding state
|
||||
*
|
||||
* Returns:
|
||||
* - The next component props payload sent to the widgets manager
|
||||
*/
|
||||
export function createGameletWidgetProps(params: {
|
||||
moduleId: string
|
||||
title: string
|
||||
payload?: Record<string, unknown>
|
||||
windowSize?: WidgetWindowSize
|
||||
existingComponentProps?: Record<string, unknown>
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
...params.existingComponentProps,
|
||||
moduleId: params.moduleId,
|
||||
title: params.title,
|
||||
...(params.windowSize ? { windowSize: params.windowSize } : {}),
|
||||
...(params.payload ? { payload: params.payload } : {}),
|
||||
}
|
||||
}
|
||||
@@ -1,140 +1,10 @@
|
||||
import type {
|
||||
HostDataRecord,
|
||||
KitDescriptor,
|
||||
PluginHost,
|
||||
PluginHostContribution,
|
||||
} from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import type { PluginHostGameletWidgetsManager } from '../../types'
|
||||
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
|
||||
import {
|
||||
createGameletWidgetProps,
|
||||
getGameletTitle,
|
||||
getGameletWidgetWindowSize,
|
||||
getOwnedGameletBindingOrThrow,
|
||||
getStoredGameletConfig,
|
||||
mergeGameletConfigPatch,
|
||||
} from './gamelet-widget-state'
|
||||
|
||||
/**
|
||||
* Identifies the stage-tamagotchi permission key used to open a host-backed gamelet surface.
|
||||
*
|
||||
* Use when:
|
||||
* - Declaring or asserting permission for `session.apis.gamelets.open(...)`
|
||||
* - Reusing the stable gamelet event key in stage-owned tests
|
||||
*
|
||||
* Expects:
|
||||
* - The gamelet kit contribution and its tests share this stage-owned constant
|
||||
*
|
||||
* Returns:
|
||||
* - The permission/event key string for opening gamelets
|
||||
*/
|
||||
export const pluginGameletApiOpenEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:open'
|
||||
|
||||
/**
|
||||
* Identifies the stage-tamagotchi permission key used to update a host-backed gamelet surface.
|
||||
*
|
||||
* Use when:
|
||||
* - Declaring or asserting permission for `session.apis.gamelets.configure(...)`
|
||||
* - Reusing the stable gamelet event key in stage-owned tests
|
||||
*
|
||||
* Expects:
|
||||
* - The gamelet kit contribution and its tests share this stage-owned constant
|
||||
*
|
||||
* Returns:
|
||||
* - The permission/event key string for configuring gamelets
|
||||
*/
|
||||
export const pluginGameletApiConfigureEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:configure'
|
||||
|
||||
/**
|
||||
* Identifies the stage-tamagotchi permission key used to request data from a host-backed gamelet surface.
|
||||
*
|
||||
* Use when:
|
||||
* - Declaring or asserting permission for `session.apis.gamelets.request(...)`
|
||||
* - Waiting for an iframe gamelet to process a host command and publish a response
|
||||
*
|
||||
* Expects:
|
||||
* - The gamelet kit contribution and its tests share this stage-owned constant
|
||||
*
|
||||
* Returns:
|
||||
* - The permission/event key string for request-response gamelet commands
|
||||
*/
|
||||
export const pluginGameletApiRequestEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:request'
|
||||
|
||||
/**
|
||||
* Identifies the stage-tamagotchi permission key used to close a host-backed gamelet surface.
|
||||
*
|
||||
* Use when:
|
||||
* - Declaring or asserting permission for `session.apis.gamelets.close(...)`
|
||||
* - Reusing the stable gamelet event key in stage-owned tests
|
||||
*
|
||||
* Expects:
|
||||
* - The gamelet kit contribution and its tests share this stage-owned constant
|
||||
*
|
||||
* Returns:
|
||||
* - The permission/event key string for closing gamelets
|
||||
*/
|
||||
export const pluginGameletApiCloseEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:close'
|
||||
|
||||
/**
|
||||
* Identifies the stage-tamagotchi permission key used to query whether a gamelet is open.
|
||||
*
|
||||
* Use when:
|
||||
* - Declaring or asserting permission for `session.apis.gamelets.isOpen(...)`
|
||||
* - Reusing the stable gamelet event key in stage-owned tests
|
||||
*
|
||||
* Expects:
|
||||
* - The gamelet kit contribution and its tests share this stage-owned constant
|
||||
*
|
||||
* Returns:
|
||||
* - The permission/event key string for querying gamelets
|
||||
*/
|
||||
export const pluginGameletApiIsOpenEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:is-open'
|
||||
|
||||
function cloneRecord<TValue>(value: TValue): TValue {
|
||||
return structuredClone(value)
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isPlainObject(value) ? cloneRecord(value as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an opaque request id for correlating one iframe command response.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin tool needs a one-shot reply from a gamelet iframe
|
||||
*
|
||||
* Expects:
|
||||
* - Request ids only need to be unique within one live Electron process
|
||||
*
|
||||
* Returns:
|
||||
* - A stable string safe to pass through JSON-like host data records
|
||||
*/
|
||||
function createGameletRequestId(): string {
|
||||
const random = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 12)
|
||||
return `gamelet:${Date.now()}:${random}`
|
||||
}
|
||||
|
||||
function getEventPayload(event: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
return toRecord(event.payload)
|
||||
}
|
||||
|
||||
function getPositiveTimeoutMs(timeoutMs: number | undefined): number {
|
||||
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
return 15_000
|
||||
}
|
||||
|
||||
return timeoutMs
|
||||
}
|
||||
import type { ExtensionHost, KitDescriptor } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
/**
|
||||
* Declares the built-in gamelet kit exposed by `stage-tamagotchi`.
|
||||
*
|
||||
* Use when:
|
||||
* - Bootstrapping the Electron plugin host with gamelet support
|
||||
* - Bootstrapping the Electron extension host with gamelet support
|
||||
* - Reading the stable built-in gamelet kit descriptor in tests or snapshots
|
||||
*
|
||||
* Expects:
|
||||
@@ -156,315 +26,15 @@ export const gameletPluginKitDescriptor = {
|
||||
* Registers the built-in gamelet kit on one host instance.
|
||||
*
|
||||
* Use when:
|
||||
* - Bootstrapping the Electron plugin host with gamelet kit support
|
||||
* - Bootstrapping the Electron extension host with gamelet kit support
|
||||
* - Keeping gamelet descriptor registration inside the gamelet kit module
|
||||
*
|
||||
* Expects:
|
||||
* - `host` is the initialized plugin host instance
|
||||
* - `host` is the initialized extension host instance
|
||||
*
|
||||
* Returns:
|
||||
* - The registered gamelet kit descriptor
|
||||
*/
|
||||
export function registerGameletPluginKit(host: PluginHost): KitDescriptor {
|
||||
export function registerGameletPluginKit(host: ExtensionHost): KitDescriptor {
|
||||
return host.registerKit(gameletPluginKitDescriptor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the installable gamelet host contribution for `session.apis.gamelets`.
|
||||
*
|
||||
* Use when:
|
||||
* - `stage-tamagotchi` needs plugin sessions to open, configure, close, or inspect gamelet widgets
|
||||
* - The root plugin host bootstrap should consume a kit-owned contribution instead of embedding gamelet logic
|
||||
*
|
||||
* Expects:
|
||||
* - `attachHost(...)` is called immediately after constructing `PluginHost`
|
||||
* - `widgetsManager` already manages extension-ui widget state
|
||||
*
|
||||
* Returns:
|
||||
* - A contribution plus an attach step that binds it to the constructed host instance
|
||||
*/
|
||||
export function createGameletHostContribution(options: {
|
||||
widgetsManager: PluginHostGameletWidgetsManager
|
||||
}): {
|
||||
attachHost: (host: PluginHost) => void
|
||||
contribution: PluginHostContribution
|
||||
} {
|
||||
let host: PluginHost | undefined
|
||||
const openWidgetIdsBySession = new Map<string, Set<string>>()
|
||||
const cleanupPromisesBySession = new Map<string, Promise<void>>()
|
||||
|
||||
const requireHost = () => {
|
||||
if (!host) {
|
||||
throw new Error('Gamelet host contribution has not been attached to a PluginHost instance.')
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
const trackOpenWidget = (sessionId: string, moduleId: string) => {
|
||||
const widgetIds = openWidgetIdsBySession.get(sessionId) ?? new Set<string>()
|
||||
widgetIds.add(moduleId)
|
||||
openWidgetIdsBySession.set(sessionId, widgetIds)
|
||||
}
|
||||
|
||||
const untrackOpenWidget = (sessionId: string, moduleId: string) => {
|
||||
const widgetIds = openWidgetIdsBySession.get(sessionId)
|
||||
if (!widgetIds) {
|
||||
return
|
||||
}
|
||||
|
||||
widgetIds.delete(moduleId)
|
||||
if (widgetIds.size === 0) {
|
||||
openWidgetIdsBySession.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
attachHost(instance) {
|
||||
host = instance
|
||||
},
|
||||
contribution: {
|
||||
install(context) {
|
||||
context.registerLifecycleHook('session-stopped', ({ session }) => {
|
||||
const widgetIds = openWidgetIdsBySession.get(session.sessionId)
|
||||
if (!widgetIds) {
|
||||
return
|
||||
}
|
||||
|
||||
const widgetIdsToRemove = [...widgetIds]
|
||||
const cleanupPromise = Promise
|
||||
.allSettled(widgetIdsToRemove.map(widgetId => options.widgetsManager.removeWidget(widgetId)))
|
||||
.then(() => {
|
||||
openWidgetIdsBySession.delete(session.sessionId)
|
||||
cleanupPromisesBySession.delete(session.sessionId)
|
||||
})
|
||||
|
||||
cleanupPromisesBySession.set(session.sessionId, cleanupPromise)
|
||||
void cleanupPromise.catch(() => {})
|
||||
})
|
||||
|
||||
context.registerSessionApi('gamelets', ({ session, assertPermission }) => ({
|
||||
async open(id: string, params?: HostDataRecord) {
|
||||
assertPermission({
|
||||
area: 'apis',
|
||||
action: 'invoke',
|
||||
key: pluginGameletApiOpenEventName,
|
||||
})
|
||||
|
||||
const module = getOwnedGameletBindingOrThrow({
|
||||
host: requireHost(),
|
||||
ownerPluginId: session.ownerPluginId,
|
||||
ownerSessionId: session.sessionId,
|
||||
moduleId: id,
|
||||
})
|
||||
const existingSnapshot = options.widgetsManager.getWidgetSnapshot(id)
|
||||
const existingComponentProps = toRecord(existingSnapshot?.componentProps)
|
||||
const payload = params
|
||||
? cloneRecord(params)
|
||||
: (toRecord(existingComponentProps?.payload) ?? getStoredGameletConfig(module.config))
|
||||
const windowSize = getGameletWidgetWindowSize({
|
||||
moduleConfig: module.config,
|
||||
existingSnapshot,
|
||||
})
|
||||
const componentProps = createGameletWidgetProps({
|
||||
moduleId: id,
|
||||
title: getGameletTitle({
|
||||
moduleId: id,
|
||||
moduleConfig: module.config,
|
||||
existingComponentProps,
|
||||
}),
|
||||
payload,
|
||||
windowSize,
|
||||
existingComponentProps,
|
||||
})
|
||||
|
||||
if (existingSnapshot) {
|
||||
await options.widgetsManager.updateWidget({
|
||||
id,
|
||||
componentProps,
|
||||
windowSize,
|
||||
})
|
||||
await options.widgetsManager.openWindow({ id })
|
||||
trackOpenWidget(session.sessionId, id)
|
||||
return
|
||||
}
|
||||
|
||||
await options.widgetsManager.pushWidget({
|
||||
id,
|
||||
componentName: 'extension-ui',
|
||||
componentProps,
|
||||
size: 'm',
|
||||
ttlMs: 0,
|
||||
windowSize,
|
||||
})
|
||||
trackOpenWidget(session.sessionId, id)
|
||||
},
|
||||
async configure(id: string, patch: HostDataRecord) {
|
||||
assertPermission({
|
||||
area: 'apis',
|
||||
action: 'invoke',
|
||||
key: pluginGameletApiConfigureEventName,
|
||||
})
|
||||
|
||||
const module = getOwnedGameletBindingOrThrow({
|
||||
host: requireHost(),
|
||||
ownerPluginId: session.ownerPluginId,
|
||||
ownerSessionId: session.sessionId,
|
||||
moduleId: id,
|
||||
})
|
||||
const { nextConfig } = mergeGameletConfigPatch({
|
||||
moduleConfig: module.config,
|
||||
patch,
|
||||
})
|
||||
|
||||
requireHost().updateBinding(module.ownerSessionId, id, { config: nextConfig })
|
||||
|
||||
const existingSnapshot = options.widgetsManager.getWidgetSnapshot(id)
|
||||
if (!existingSnapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingComponentProps = toRecord(existingSnapshot.componentProps)
|
||||
const existingPayload = toRecord(existingComponentProps?.payload) ?? {}
|
||||
const windowSize = getGameletWidgetWindowSize({
|
||||
moduleConfig: nextConfig,
|
||||
existingSnapshot,
|
||||
})
|
||||
|
||||
await options.widgetsManager.updateWidget({
|
||||
id,
|
||||
componentProps: createGameletWidgetProps({
|
||||
moduleId: id,
|
||||
title: getGameletTitle({
|
||||
moduleId: id,
|
||||
moduleConfig: nextConfig,
|
||||
existingComponentProps,
|
||||
}),
|
||||
payload: {
|
||||
...existingPayload,
|
||||
...cloneRecord(patch),
|
||||
},
|
||||
windowSize,
|
||||
existingComponentProps,
|
||||
}),
|
||||
windowSize,
|
||||
})
|
||||
},
|
||||
async request(id: string, payload: HostDataRecord, requestOptions?: { timeoutMs?: number }) {
|
||||
assertPermission({
|
||||
area: 'apis',
|
||||
action: 'invoke',
|
||||
key: pluginGameletApiRequestEventName,
|
||||
})
|
||||
|
||||
const module = getOwnedGameletBindingOrThrow({
|
||||
host: requireHost(),
|
||||
ownerPluginId: session.ownerPluginId,
|
||||
ownerSessionId: session.sessionId,
|
||||
moduleId: id,
|
||||
})
|
||||
const existingSnapshot = options.widgetsManager.getWidgetSnapshot(id)
|
||||
if (!existingSnapshot) {
|
||||
throw new Error(`Gamelet widget \`${id}\` is not open.`)
|
||||
}
|
||||
|
||||
const requestId = createGameletRequestId()
|
||||
const command = {
|
||||
...cloneRecord(payload),
|
||||
requestId,
|
||||
}
|
||||
const timeoutMs = getPositiveTimeoutMs(requestOptions?.timeoutMs)
|
||||
const responsePromise = new Promise<HostDataRecord>((resolve, reject) => {
|
||||
let isSettled = false
|
||||
let dispose: (() => void) | undefined
|
||||
const timer = setTimeout(() => {
|
||||
if (isSettled) {
|
||||
return
|
||||
}
|
||||
|
||||
isSettled = true
|
||||
dispose?.()
|
||||
reject(new Error(`Gamelet request \`${requestId}\` timed out for widget \`${id}\`.`))
|
||||
}, timeoutMs)
|
||||
|
||||
dispose = options.widgetsManager.onWidgetEvent((event) => {
|
||||
if (event.id !== id || isSettled) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = getEventPayload(event.event)
|
||||
if (response?.requestId !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
isSettled = true
|
||||
clearTimeout(timer)
|
||||
dispose?.()
|
||||
resolve(response as HostDataRecord)
|
||||
})
|
||||
})
|
||||
|
||||
const existingComponentProps = toRecord(existingSnapshot.componentProps)
|
||||
const existingPayload = toRecord(existingComponentProps?.payload) ?? getStoredGameletConfig(module.config)
|
||||
const windowSize = getGameletWidgetWindowSize({
|
||||
moduleConfig: module.config,
|
||||
existingSnapshot,
|
||||
})
|
||||
|
||||
await options.widgetsManager.updateWidget({
|
||||
id,
|
||||
componentProps: createGameletWidgetProps({
|
||||
moduleId: id,
|
||||
title: getGameletTitle({
|
||||
moduleId: id,
|
||||
moduleConfig: module.config,
|
||||
existingComponentProps,
|
||||
}),
|
||||
payload: {
|
||||
...existingPayload,
|
||||
command,
|
||||
},
|
||||
windowSize,
|
||||
existingComponentProps,
|
||||
}),
|
||||
windowSize,
|
||||
})
|
||||
|
||||
return await responsePromise
|
||||
},
|
||||
async close(id: string) {
|
||||
assertPermission({
|
||||
area: 'apis',
|
||||
action: 'invoke',
|
||||
key: pluginGameletApiCloseEventName,
|
||||
})
|
||||
|
||||
getOwnedGameletBindingOrThrow({
|
||||
host: requireHost(),
|
||||
ownerPluginId: session.ownerPluginId,
|
||||
ownerSessionId: session.sessionId,
|
||||
moduleId: id,
|
||||
})
|
||||
await options.widgetsManager.removeWidget(id)
|
||||
untrackOpenWidget(session.sessionId, id)
|
||||
},
|
||||
async isOpen(id: string) {
|
||||
assertPermission({
|
||||
area: 'apis',
|
||||
action: 'invoke',
|
||||
key: pluginGameletApiIsOpenEventName,
|
||||
})
|
||||
|
||||
getOwnedGameletBindingOrThrow({
|
||||
host: requireHost(),
|
||||
ownerPluginId: session.ownerPluginId,
|
||||
ownerSessionId: session.sessionId,
|
||||
moduleId: id,
|
||||
})
|
||||
return options.widgetsManager.getWidgetSnapshot(id) !== undefined
|
||||
},
|
||||
}))
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { GameletKitRuntime } from '@proj-airi/plugin-sdk-tamagotchi/gamelet'
|
||||
import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import type { ExtensionHostGameletWidgetsManager } from '../../types'
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 30000
|
||||
const GAMELET_ROUTE_NAMESPACE = 'airi.plugin.gamelet'
|
||||
|
||||
export interface GameletOrchestrationRuntime extends NonNullable<GameletKitRuntime['gamelets']> {
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
bindingId: string
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Electron host implementation for gamelet lifecycle and request calls.
|
||||
*
|
||||
* Use when:
|
||||
* - Built-in `kit.gamelet` clients need to open iframe-backed extension UI widgets
|
||||
* - Extension-side gamelet handles need request/response orchestration through widget events
|
||||
*
|
||||
* Expects:
|
||||
* - Widget ids are the same values as gamelet binding ids
|
||||
* - Widget-side response events echo the original `requestId` at the top level or under `payload`
|
||||
*
|
||||
* Returns:
|
||||
* - A gamelet orchestration runtime backed by the stage widget manager
|
||||
*/
|
||||
export function createGameletOrchestrationRuntime(
|
||||
widgetsManager: ExtensionHostGameletWidgetsManager,
|
||||
): GameletOrchestrationRuntime {
|
||||
const pendingRequests = new Map<string, PendingRequest>()
|
||||
|
||||
const rejectPendingForBinding = (bindingId: string, message: string) => {
|
||||
for (const [requestId, pending] of pendingRequests.entries()) {
|
||||
if (pending.bindingId !== bindingId) {
|
||||
continue
|
||||
}
|
||||
|
||||
pendingRequests.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
pending.reject(new Error(message))
|
||||
}
|
||||
}
|
||||
|
||||
const rejectAllPending = (message: string) => {
|
||||
for (const [requestId, pending] of pendingRequests.entries()) {
|
||||
pendingRequests.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
pending.reject(new Error(message))
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = widgetsManager.onWidgetEvent(({ id, event }) => {
|
||||
const response = readRequestResponse(event)
|
||||
if (!response) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingRequests.get(response.requestId)
|
||||
if (!pending || pending.bindingId !== id) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingRequests.delete(response.requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
|
||||
if (response.ok === false) {
|
||||
pending.reject(new Error(readResponseErrorMessage(response.value)))
|
||||
return
|
||||
}
|
||||
|
||||
pending.resolve(response.value)
|
||||
})
|
||||
|
||||
return {
|
||||
async open(bindingId, payload) {
|
||||
const componentProps = createComponentProps(bindingId, payload ?? {})
|
||||
|
||||
if (widgetsManager.getWidgetSnapshot(bindingId)) {
|
||||
await widgetsManager.updateWidget({
|
||||
id: bindingId,
|
||||
componentProps,
|
||||
size: 'l',
|
||||
})
|
||||
}
|
||||
else {
|
||||
await widgetsManager.pushWidget({
|
||||
id: bindingId,
|
||||
componentName: 'extension-ui',
|
||||
componentProps,
|
||||
size: 'l',
|
||||
})
|
||||
}
|
||||
|
||||
await widgetsManager.openWindow({ id: bindingId })
|
||||
},
|
||||
async configure(bindingId, payload) {
|
||||
await widgetsManager.updateWidget({
|
||||
id: bindingId,
|
||||
componentProps: createComponentProps(bindingId, payload),
|
||||
})
|
||||
},
|
||||
async request<TResponse = HostDataRecord>(bindingId: string, payload: HostDataRecord, options?: { timeoutMs?: number }): Promise<TResponse> {
|
||||
if (!widgetsManager.getWidgetSnapshot(bindingId)) {
|
||||
throw new Error(`Gamelet \`${bindingId}\` is not open.`)
|
||||
}
|
||||
|
||||
const requestId = randomUUID()
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
|
||||
|
||||
const response = new Promise<TResponse>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRequests.delete(requestId)
|
||||
reject(new Error(`Gamelet request timed out after ${timeoutMs}ms.`))
|
||||
}, timeoutMs)
|
||||
|
||||
pendingRequests.set(requestId, {
|
||||
bindingId,
|
||||
resolve: value => resolve(value as TResponse),
|
||||
reject,
|
||||
timeout,
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await widgetsManager.updateWidget({
|
||||
id: bindingId,
|
||||
componentProps: createComponentProps(bindingId, {
|
||||
request: {
|
||||
route: {
|
||||
namespace: GAMELET_ROUTE_NAMESPACE,
|
||||
name: 'request',
|
||||
},
|
||||
responseRoute: {
|
||||
namespace: GAMELET_ROUTE_NAMESPACE,
|
||||
name: 'response',
|
||||
},
|
||||
requestId,
|
||||
payload,
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
const pending = pendingRequests.get(requestId)
|
||||
if (pending) {
|
||||
pendingRequests.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
pending.reject(new Error(errorMessageFrom(error) ?? 'Failed to publish gamelet request.'))
|
||||
}
|
||||
}
|
||||
|
||||
return await response
|
||||
},
|
||||
async close(bindingId) {
|
||||
rejectPendingForBinding(bindingId, 'Gamelet was closed before the request completed.')
|
||||
await widgetsManager.removeWidget(bindingId)
|
||||
},
|
||||
async isOpen(bindingId) {
|
||||
return Boolean(widgetsManager.getWidgetSnapshot(bindingId))
|
||||
},
|
||||
dispose() {
|
||||
unsubscribe()
|
||||
rejectAllPending('Gamelet orchestration runtime was disposed before the request completed.')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createComponentProps(bindingId: string, payload: HostDataRecord): HostDataRecord {
|
||||
return {
|
||||
moduleId: bindingId,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
function readRequestResponse(event: Record<string, unknown>): { requestId: string, ok?: boolean, value: unknown } | undefined {
|
||||
const route = event.route
|
||||
if (!route || typeof route !== 'object' || Array.isArray(route)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const routeRecord = route as Record<string, unknown>
|
||||
if (routeRecord.namespace !== GAMELET_ROUTE_NAMESPACE || routeRecord.name !== 'response') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payload = event.payload
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadRecord = payload as Record<string, unknown>
|
||||
if (typeof payloadRecord.requestId !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: payloadRecord.requestId,
|
||||
ok: typeof payloadRecord.ok === 'boolean' ? payloadRecord.ok : undefined,
|
||||
value: readResponseValue(payloadRecord),
|
||||
}
|
||||
}
|
||||
|
||||
function readResponseValue(response: Record<string, unknown>): unknown {
|
||||
if ('result' in response) {
|
||||
return response.result
|
||||
}
|
||||
|
||||
const { type: _type, requestId: _requestId, ...value } = response
|
||||
return value
|
||||
}
|
||||
|
||||
function readResponseErrorMessage(response: unknown): string {
|
||||
if (!response || typeof response !== 'object' || Array.isArray(response)) {
|
||||
return 'Gamelet request failed.'
|
||||
}
|
||||
|
||||
const responseRecord = response as Record<string, unknown>
|
||||
if (typeof responseRecord.error === 'string') {
|
||||
return responseRecord.error
|
||||
}
|
||||
if (typeof responseRecord.message === 'string') {
|
||||
return responseRecord.message
|
||||
}
|
||||
|
||||
return 'Gamelet request failed.'
|
||||
}
|
||||
@@ -1,15 +1,86 @@
|
||||
import type { PluginHost } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import type { KitRef } from '@proj-airi/plugin-sdk'
|
||||
import type { ToolKitRuntime } from '@proj-airi/plugin-sdk-tamagotchi/tools'
|
||||
import type { ExtensionHost } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import type { SetupPluginHostOptions } from '../types'
|
||||
import type { SetupExtensionHostOptions } from '../types'
|
||||
import type { GameletOrchestrationRuntime } from './gamelet/orchestration'
|
||||
|
||||
import {
|
||||
createGameletHostContribution,
|
||||
registerGameletPluginKit,
|
||||
} from './gamelet'
|
||||
import { gameletKit, toolKit } from '@proj-airi/plugin-sdk-tamagotchi'
|
||||
import { TamagotchiToolRegistry } from '@proj-airi/plugin-sdk-tamagotchi/tools'
|
||||
|
||||
import { registerGameletPluginKit } from './gamelet'
|
||||
import { createGameletOrchestrationRuntime } from './gamelet/orchestration'
|
||||
import { registerWidgetPluginKit } from './widget'
|
||||
|
||||
type GameletKitClient = ReturnType<typeof gameletKit.createClient>
|
||||
type ToolKitClient = ReturnType<typeof toolKit.createClient>
|
||||
|
||||
function createHostGameletKit(options: { host: ExtensionHost, gamelets: GameletOrchestrationRuntime }): KitRef<GameletKitClient> {
|
||||
return {
|
||||
...gameletKit,
|
||||
createClient(runtime) {
|
||||
const hostRuntime = {
|
||||
...runtime,
|
||||
bindings: {
|
||||
bind: (input: Parameters<ExtensionHost['bindExtensionKitModule']>[1]) => options.host.bindExtensionKitModule(runtime.sessionId, input, runtime.moduleId),
|
||||
},
|
||||
gamelets: options.gamelets,
|
||||
}
|
||||
|
||||
return gameletKit.createClient(hostRuntime)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createHostToolKit(options: { tools: TamagotchiToolRegistry }): KitRef<ToolKitClient> {
|
||||
return {
|
||||
...toolKit,
|
||||
createClient(runtime) {
|
||||
let cleanupRegistered = false
|
||||
const ensureCleanup = () => {
|
||||
if (cleanupRegistered) {
|
||||
return
|
||||
}
|
||||
|
||||
cleanupRegistered = true
|
||||
runtime.subscriptions.add({
|
||||
dispose: () => {
|
||||
options.tools.unregisterOwnerScope(runtime.sessionId, runtime.moduleId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const hostRuntime: ToolKitRuntime = {
|
||||
...runtime,
|
||||
tools: {
|
||||
register: (input) => {
|
||||
ensureCleanup()
|
||||
options.tools.register({
|
||||
ownerSessionId: runtime.sessionId,
|
||||
ownerPluginId: runtime.extensionId,
|
||||
ownerModuleId: runtime.moduleId,
|
||||
...input,
|
||||
})
|
||||
},
|
||||
registerToolsetPrompt: (input) => {
|
||||
ensureCleanup()
|
||||
options.tools.registerToolsetPrompt({
|
||||
ownerSessionId: runtime.sessionId,
|
||||
ownerPluginId: runtime.extensionId,
|
||||
ownerModuleId: runtime.moduleId,
|
||||
toolset: input,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return toolKit.createClient(hostRuntime)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the built-in kit runtime installed by the Electron plugin host.
|
||||
* Creates the built-in kit runtime installed by the Electron extension host.
|
||||
*
|
||||
* Use when:
|
||||
* - Host bootstrap should depend on a kit-layer API instead of wiring widget/gamelet details inline
|
||||
@@ -21,23 +92,31 @@ import { registerWidgetPluginKit } from './widget'
|
||||
* Returns:
|
||||
* - Helpers to attach contributions and register built-in kits on the host
|
||||
*/
|
||||
export function createBuiltInPluginKitRuntime(options: SetupPluginHostOptions): {
|
||||
contributions: ReturnType<typeof createGameletHostContribution>['contribution'][]
|
||||
attachHost: (host: PluginHost) => void
|
||||
registerHostKits: (host: PluginHost) => void
|
||||
export function createBuiltInExtensionKitRuntime(options: SetupExtensionHostOptions): {
|
||||
contributions: []
|
||||
attachHost: (host: ExtensionHost) => void
|
||||
registerHostKits: (host: ExtensionHost) => void
|
||||
tools: TamagotchiToolRegistry
|
||||
dispose: () => void
|
||||
} {
|
||||
const gameletContribution = createGameletHostContribution({
|
||||
widgetsManager: options.widgetsManager,
|
||||
})
|
||||
const gamelets = createGameletOrchestrationRuntime(options.widgetsManager)
|
||||
const tools = new TamagotchiToolRegistry()
|
||||
|
||||
return {
|
||||
contributions: [gameletContribution.contribution],
|
||||
attachHost(host) {
|
||||
gameletContribution.attachHost(host)
|
||||
contributions: [],
|
||||
attachHost(_host) {
|
||||
void options.widgetsManager
|
||||
},
|
||||
registerHostKits(host) {
|
||||
registerWidgetPluginKit(host)
|
||||
registerGameletPluginKit(host)
|
||||
host.registerKitApi(createHostGameletKit({ host, gamelets }))
|
||||
host.registerKitApi(createHostToolKit({ tools }))
|
||||
},
|
||||
tools,
|
||||
dispose() {
|
||||
gamelets.dispose()
|
||||
tools.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
* Describes one widget iframe asset as seen from the mounted `/ui` route.
|
||||
*
|
||||
* Use when:
|
||||
* - Converting plugin config asset paths into mounted extension asset URLs
|
||||
* - Converting extension config asset paths into mounted extension asset URLs
|
||||
* - Creating sessions that must validate against route-relative asset paths
|
||||
*
|
||||
* Expects:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
ExtensionHost,
|
||||
KitDescriptor,
|
||||
PluginHost,
|
||||
} from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
export {
|
||||
@@ -12,7 +12,7 @@ export {
|
||||
* Declares the built-in widget kit exposed by `stage-tamagotchi`.
|
||||
*
|
||||
* Use when:
|
||||
* - Bootstrapping the Electron plugin host with widget support
|
||||
* - Bootstrapping the Electron extension host with widget support
|
||||
* - Reading the stable built-in widget kit descriptor in tests or snapshots
|
||||
*
|
||||
* Expects:
|
||||
@@ -34,15 +34,15 @@ export const widgetPluginKitDescriptor = {
|
||||
* Registers the built-in widget kit on one host instance.
|
||||
*
|
||||
* Use when:
|
||||
* - Bootstrapping the Electron plugin host with widget kit support
|
||||
* - Bootstrapping the Electron extension host with widget kit support
|
||||
* - Keeping widget descriptor registration inside the widget kit module
|
||||
*
|
||||
* Expects:
|
||||
* - `host` is the initialized plugin host instance
|
||||
* - `host` is the initialized extension host instance
|
||||
*
|
||||
* Returns:
|
||||
* - The registered widget kit descriptor
|
||||
*/
|
||||
export function registerWidgetPluginKit(host: PluginHost): KitDescriptor {
|
||||
export function registerWidgetPluginKit(host: ExtensionHost): KitDescriptor {
|
||||
return host.registerKit(widgetPluginKitDescriptor)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ManifestV1, PluginHost } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import type { ExtensionHost, ExtensionManifestV1 } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import type {
|
||||
WidgetsAddPayload,
|
||||
@@ -7,37 +7,37 @@ import type {
|
||||
} from '../../../../shared/eventa'
|
||||
|
||||
/**
|
||||
* Runtime-facing plugin host service bundle returned by setup.
|
||||
* Runtime-facing extension host service bundle returned by setup.
|
||||
*
|
||||
* Use when:
|
||||
* - Bootstrapping plugin infrastructure during Electron startup
|
||||
* - Bootstrapping extension infrastructure during Electron startup
|
||||
* - Accessing loaded manifests after host initialization
|
||||
*
|
||||
* Expects:
|
||||
* - `host` is an initialized Electron runtime plugin host
|
||||
* - `host` is an initialized Electron runtime extension host
|
||||
* - `manifests` reflect the latest loaded manifest snapshot at setup time
|
||||
*
|
||||
* Returns:
|
||||
* - A stable object containing host instance and manifest list
|
||||
*/
|
||||
export interface PluginHostService {
|
||||
host: PluginHost
|
||||
manifests: ManifestV1[]
|
||||
export interface ExtensionHostService {
|
||||
host: ExtensionHost
|
||||
manifests: ExtensionManifestV1[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the widget manager surface required by plugin-driven gamelet APIs.
|
||||
* Describes the widget manager surface required by extension-driven gamelet APIs.
|
||||
*
|
||||
* Use when:
|
||||
* - `setupPluginHost(...)` needs to open, update, or close extension-ui widgets
|
||||
* - `setupExtensionHost(...)` needs to open, update, or close extension-ui widgets
|
||||
*
|
||||
* Expects:
|
||||
* - Widget ids remain stable and may be reused for the same module id
|
||||
*
|
||||
* Returns:
|
||||
* - The minimal widget-manager contract consumed by the plugin host service
|
||||
* - The minimal widget-manager contract consumed by the extension host service
|
||||
*/
|
||||
export interface PluginHostGameletWidgetsManager {
|
||||
export interface ExtensionHostGameletWidgetsManager {
|
||||
openWindow: (params?: { id?: string }) => Promise<void>
|
||||
pushWidget: (payload: WidgetsAddPayload) => Promise<string>
|
||||
updateWidget: (payload: WidgetsUpdatePayload) => Promise<void>
|
||||
@@ -48,11 +48,11 @@ export interface PluginHostGameletWidgetsManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the runtime dependencies required by `setupPluginHost(...)`.
|
||||
* Configures the runtime dependencies required by `setupExtensionHost(...)`.
|
||||
*
|
||||
* Use when:
|
||||
* - Wiring the plugin host during Electron startup
|
||||
* - Providing test doubles for plugin-driven gamelet orchestration
|
||||
* - Wiring the extension host during Electron startup
|
||||
* - Providing test doubles for extension-driven gamelet orchestration
|
||||
*
|
||||
* Expects:
|
||||
* - `widgetsManager` is already initialized and ready to manage overlay widgets
|
||||
@@ -60,12 +60,12 @@ export interface PluginHostGameletWidgetsManager {
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface SetupPluginHostOptions {
|
||||
widgetsManager: PluginHostGameletWidgetsManager
|
||||
export interface SetupExtensionHostOptions {
|
||||
widgetsManager: ExtensionHostGameletWidgetsManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Binding announcement payload used by plugin-side runtime registration.
|
||||
* Binding announcement payload used by extension-side runtime registration.
|
||||
*
|
||||
* Use when:
|
||||
* - Announcing a new module for a registered kit
|
||||
@@ -79,7 +79,7 @@ export interface SetupPluginHostOptions {
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginHostBindingAnnounceInput {
|
||||
export interface ExtensionHostBindingAnnounceInput {
|
||||
moduleId: string
|
||||
kitId: string
|
||||
kitModuleType: string
|
||||
@@ -99,26 +99,26 @@ export interface PluginHostBindingAnnounceInput {
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginHostBindingListOptions {
|
||||
export interface ExtensionHostBindingListOptions {
|
||||
ownerSessionId?: string
|
||||
kitId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted plugin configuration snapshot.
|
||||
* Persisted extension configuration snapshot.
|
||||
*
|
||||
* Use when:
|
||||
* - Reading/writing enabled and auto-reload plugin state
|
||||
* - Keeping known plugin manifest path metadata
|
||||
* - Keeping known extension manifest path metadata
|
||||
*
|
||||
* Expects:
|
||||
* - Arrays contain plugin manifest names
|
||||
* - Arrays contain extension manifest names
|
||||
* - `known` maps plugin names to canonical manifest paths
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface PluginConfig {
|
||||
export interface ExtensionConfig {
|
||||
enabled: string[]
|
||||
autoReload: string[]
|
||||
known: Record<string, { path: string }>
|
||||
@@ -128,20 +128,20 @@ export interface PluginConfig {
|
||||
* Internal manifest record with resolved location and package version.
|
||||
*
|
||||
* Use when:
|
||||
* - Loading plugin manifests from disk
|
||||
* - Loading extension manifests from disk
|
||||
* - Resolving runtime entrypoints and extension asset metadata
|
||||
*
|
||||
* Expects:
|
||||
* - `manifest` is schema-validated
|
||||
* - `path` points to `plugin.airi.json`
|
||||
* - `rootDir` is the plugin root directory
|
||||
* - `path` points to `extension.airi.json`
|
||||
* - `rootDir` is the extension root directory
|
||||
* - `version` is discovered from package metadata or fallback
|
||||
*
|
||||
* Returns:
|
||||
* - N/A
|
||||
*/
|
||||
export interface ManifestEntry {
|
||||
manifest: ManifestV1
|
||||
manifest: ExtensionManifestV1
|
||||
path: string
|
||||
rootDir: string
|
||||
version: string
|
||||
|
||||
+108
@@ -127,4 +127,112 @@ describe('createContext', () => {
|
||||
host.dispose()
|
||||
iframe.dispose()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(initPayload.props.request.responseRoute).toEqual({ namespace: 'airi.plugin.gamelet', name: 'response' })
|
||||
* expect(publishedPayload.route).toEqual({ namespace: 'airi.plugin.gamelet', name: 'response' })
|
||||
*/
|
||||
it('relays gamelet request props and iframe response envelopes over the extension UI bridge', async () => {
|
||||
const parentWindow = new MockWindow()
|
||||
const iframeWindow = new MockWindow()
|
||||
parentWindow.peer = iframeWindow
|
||||
iframeWindow.peer = parentWindow
|
||||
|
||||
const host = createContext({
|
||||
channel: 'test:extension-ui',
|
||||
currentWindow: parentWindow as unknown as Window,
|
||||
expectedSource: () => iframeWindow as unknown as Window,
|
||||
targetWindow: () => iframeWindow as unknown as Window,
|
||||
})
|
||||
const iframe = createContext({
|
||||
channel: 'test:extension-ui',
|
||||
currentWindow: iframeWindow as unknown as Window,
|
||||
expectedSource: () => parentWindow as unknown as Window,
|
||||
targetWindow: () => parentWindow as unknown as Window,
|
||||
})
|
||||
|
||||
const initPayload = new Promise<Record<string, unknown>>((resolve) => {
|
||||
iframe.context.on(widgetsIframeInitEvent, (event) => {
|
||||
const request = event.body?.props?.request
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
return
|
||||
}
|
||||
|
||||
resolve(request as Record<string, unknown>)
|
||||
})
|
||||
})
|
||||
|
||||
host.context.emit(widgetsIframeInitEvent, {
|
||||
moduleId: 'chess:board',
|
||||
config: {},
|
||||
module: undefined,
|
||||
props: {
|
||||
request: {
|
||||
route: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'request',
|
||||
},
|
||||
responseRoute: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'response',
|
||||
},
|
||||
requestId: 'req-1',
|
||||
payload: {
|
||||
action: 'snapshot',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await expect(initPayload).resolves.toEqual({
|
||||
route: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'request',
|
||||
},
|
||||
responseRoute: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'response',
|
||||
},
|
||||
requestId: 'req-1',
|
||||
payload: {
|
||||
action: 'snapshot',
|
||||
},
|
||||
})
|
||||
|
||||
const publishedPayload = new Promise<Record<string, unknown>>((resolve) => {
|
||||
host.context.on(widgetsIframePublishEvent, (event) => {
|
||||
if (!event.body) {
|
||||
return
|
||||
}
|
||||
|
||||
resolve(event.body)
|
||||
})
|
||||
})
|
||||
|
||||
iframe.context.emit(widgetsIframePublishEvent, {
|
||||
route: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'response',
|
||||
},
|
||||
payload: {
|
||||
requestId: 'req-1',
|
||||
fen: 'fen-after-request',
|
||||
},
|
||||
})
|
||||
|
||||
await expect(publishedPayload).resolves.toEqual({
|
||||
route: {
|
||||
namespace: 'airi.plugin.gamelet',
|
||||
name: 'response',
|
||||
},
|
||||
payload: {
|
||||
requestId: 'req-1',
|
||||
fen: 'fen-after-request',
|
||||
},
|
||||
})
|
||||
|
||||
host.dispose()
|
||||
iframe.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
],
|
||||
"@proj-airi/stage-layouts/*": [
|
||||
"../../packages/stage-layouts/src/*"
|
||||
],
|
||||
"@proj-airi/electron-vueuse": [
|
||||
"../../packages/electron-vueuse/src/index.ts"
|
||||
],
|
||||
"@proj-airi/electron-vueuse/*": [
|
||||
"../../packages/electron-vueuse/src/*"
|
||||
]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
|
||||
@@ -6,13 +6,14 @@ interface EventSourcePayload {
|
||||
}
|
||||
|
||||
function formatMetadataSource(source?: MetadataEventSource) {
|
||||
if (!source?.plugin)
|
||||
if (!source)
|
||||
return undefined
|
||||
|
||||
const pluginId = source.plugin.id
|
||||
const instanceId = source.id
|
||||
if ('extension' in source) {
|
||||
return `${source.extension.id}:${source.id}`
|
||||
}
|
||||
|
||||
return instanceId ? `${pluginId}:${instanceId}` : pluginId
|
||||
return source.id
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +21,7 @@ function formatMetadataSource(source?: MetadataEventSource) {
|
||||
*
|
||||
* Before:
|
||||
* - `{ source: "minecraft" }`
|
||||
* - `{ metadata: { source: { plugin: { id: "p" }, id: "i" } } }`
|
||||
* - `{ metadata: { source: { extension: { id: "p" }, id: "i" } } }`
|
||||
*
|
||||
* After:
|
||||
* - `"minecraft"`
|
||||
|
||||
@@ -7,13 +7,12 @@ import { createContextRegistry } from './context-registry'
|
||||
|
||||
type TestContextMessage = ContextMessage & { source?: string }
|
||||
|
||||
function createMetadata(pluginId: string, instanceId: string): NonNullable<ContextMessage['metadata']> {
|
||||
function createMetadata(extensionId: string, moduleId: string): NonNullable<ContextMessage['metadata']> {
|
||||
return {
|
||||
source: {
|
||||
id: instanceId,
|
||||
kind: 'plugin',
|
||||
plugin: {
|
||||
id: pluginId,
|
||||
id: moduleId,
|
||||
extension: {
|
||||
id: extensionId,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -105,20 +104,16 @@ describe('createContextRegistry', () => {
|
||||
|
||||
/**
|
||||
* @example
|
||||
* metadata.source.plugin.id + metadata.source.id becomes "plugin:instance".
|
||||
* metadata.source.extension.id + metadata.source.id becomes "extension:module".
|
||||
*/
|
||||
it('resolves metadata source keys before source fallback and unknown fallback', () => {
|
||||
const registry = createContextRegistry()
|
||||
|
||||
const pluginInstanceResult = registry.ingest(createContextMessage({
|
||||
const extensionModuleResult = registry.ingest(createContextMessage({
|
||||
id: 'with-instance',
|
||||
source: 'fallback-source',
|
||||
metadata: createMetadata('weather', 'station-1'),
|
||||
}))
|
||||
const pluginOnlyResult = registry.ingest(createContextMessage({
|
||||
id: 'plugin-only',
|
||||
metadata: createMetadata('weather', ''),
|
||||
}))
|
||||
const sourceResult = registry.ingest(createContextMessage({
|
||||
id: 'source-only',
|
||||
source: 'legacy-source',
|
||||
@@ -127,13 +122,11 @@ describe('createContextRegistry', () => {
|
||||
id: 'unknown-source',
|
||||
}))
|
||||
|
||||
expect(pluginInstanceResult?.sourceKey).toBe('weather:station-1')
|
||||
expect(pluginOnlyResult?.sourceKey).toBe('weather')
|
||||
expect(extensionModuleResult?.sourceKey).toBe('weather:station-1')
|
||||
expect(sourceResult?.sourceKey).toBe('legacy-source')
|
||||
expect(unknownResult?.sourceKey).toBe('unknown')
|
||||
expect(Object.keys(registry.snapshot())).toEqual([
|
||||
'weather:station-1',
|
||||
'weather',
|
||||
'legacy-source',
|
||||
'unknown',
|
||||
])
|
||||
|
||||
@@ -56,19 +56,20 @@ interface CreateContextRegistryOptions {
|
||||
/**
|
||||
* Resolves a context message into a stable source bucket key.
|
||||
*
|
||||
* @default metadata plugin/instance key, then event source, then "unknown"
|
||||
* @default metadata extension/module key, then event source, then "unknown"
|
||||
*/
|
||||
getSourceKey?: (event: EventSourcePayload, fallback?: string) => string
|
||||
}
|
||||
|
||||
function formatMetadataSource(source?: MetadataEventSource) {
|
||||
if (!source?.plugin)
|
||||
if (!source)
|
||||
return undefined
|
||||
|
||||
const pluginId = source.plugin.id
|
||||
const instanceId = source.id
|
||||
if ('extension' in source) {
|
||||
return `${source.extension.id}:${source.id}`
|
||||
}
|
||||
|
||||
return instanceId ? `${pluginId}:${instanceId}` : pluginId
|
||||
return source.id
|
||||
}
|
||||
|
||||
function defaultGetSourceKey(event: EventSourcePayload, fallback = 'unknown') {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { InvokeEventa } from '@moeru/eventa'
|
||||
import type { ShallowRef } from 'vue'
|
||||
|
||||
import { defineInvoke } from '@moeru/eventa'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
|
||||
@@ -27,7 +28,7 @@ export function getElectronEventaContext(ipcRenderer?: IpcRendererLike): EventaC
|
||||
return sharedContext
|
||||
}
|
||||
|
||||
export function useElectronEventaContext(ipcRenderer?: IpcRendererLike) {
|
||||
export function useElectronEventaContext(ipcRenderer?: IpcRendererLike): ShallowRef<EventaContext> {
|
||||
return shallowRef(getElectronEventaContext(ipcRenderer))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
extensionAnnounce,
|
||||
extensionKitAnnounce,
|
||||
extensionModuleAnnounce,
|
||||
peerAuthenticate,
|
||||
} from './events'
|
||||
|
||||
describe('extension runtime protocol events', () => {
|
||||
it('defines peer transport authentication separately from extension authentication', () => {
|
||||
expect(peerAuthenticate.id).toBe('peer:authenticate')
|
||||
})
|
||||
|
||||
it('defines extension session announcement separately from module announcement', () => {
|
||||
expect(extensionAnnounce.id).toBe('extension:announce')
|
||||
expect(extensionModuleAnnounce.id).toBe('extension:module:announce')
|
||||
})
|
||||
|
||||
it('defines kit availability events under extension kit namespace', () => {
|
||||
expect(extensionKitAnnounce.id).toBe('extension:kit:announce')
|
||||
})
|
||||
})
|
||||
@@ -28,7 +28,7 @@ export interface PluginIdentity {
|
||||
*/
|
||||
version?: string
|
||||
/**
|
||||
* Optional labels attached to the plugin manifest.
|
||||
* Optional labels attached to the extension manifest.
|
||||
* Example: { env: "prod", app: "telegram", devtools: "true" }.
|
||||
*/
|
||||
labels?: Record<string, string>
|
||||
@@ -55,7 +55,72 @@ export interface ModuleIdentity {
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
export type MetadataEventSource = ModuleIdentity
|
||||
/**
|
||||
* Identifies an extension package/session that is loaded by an extension host.
|
||||
*
|
||||
* Extension identity is the package/session-level scope. Modules registered by
|
||||
* the extension get their own {@link ExtensionModuleIdentity}.
|
||||
*/
|
||||
export interface ExtensionIdentity {
|
||||
/**
|
||||
* Stable extension identifier from `extension.airi.json`.
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* Optional semantic version for the extension package.
|
||||
*/
|
||||
version?: string
|
||||
/**
|
||||
* Optional runtime session id assigned by the host for this loaded extension.
|
||||
*/
|
||||
sessionId?: string
|
||||
/**
|
||||
* Optional labels used for routing, inspection, and policy selectors.
|
||||
*/
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies one runtime module registered by an extension setup function.
|
||||
*/
|
||||
export interface ExtensionModuleIdentity {
|
||||
/**
|
||||
* Stable module id within one extension session.
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* Owning extension session identity.
|
||||
*/
|
||||
extension: ExtensionIdentity
|
||||
/**
|
||||
* Optional labels used for routing, inspection, and policy selectors.
|
||||
*/
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies a kit API surface that can be used by extension modules.
|
||||
*/
|
||||
export interface ExtensionKitIdentity {
|
||||
/**
|
||||
* Stable kit id.
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* Optional semantic version for compatibility checks.
|
||||
*/
|
||||
version?: string
|
||||
/**
|
||||
* Optional owner for future extension-provided kits. Host-provided kits omit this field.
|
||||
*/
|
||||
ownerExtension?: ExtensionIdentity
|
||||
/**
|
||||
* Optional labels used for routing, inspection, and policy selectors.
|
||||
*/
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
export type MetadataEventSource = ModuleIdentity | ExtensionIdentity | ExtensionModuleIdentity | ExtensionKitIdentity
|
||||
|
||||
/**
|
||||
* Static schema metadata for module configuration.
|
||||
@@ -588,6 +653,57 @@ export type WithOutputSource<Source extends keyof OutputSource> = {
|
||||
// 10) module:status (ready)
|
||||
// 11) module:status:change (to re-run phases)
|
||||
|
||||
interface PeerAuthenticateEvent {
|
||||
token?: string
|
||||
peerId?: string
|
||||
}
|
||||
|
||||
interface PeerAuthenticatedEvent {
|
||||
authenticated: boolean
|
||||
peerId: string
|
||||
}
|
||||
|
||||
interface PeerStatusEvent {
|
||||
peerId: string
|
||||
phase: 'connected' | 'authenticated' | 'de-authenticated' | 'closed' | 'failed'
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface PeerDeAuthenticatedEvent {
|
||||
peerId: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface ExtensionAuthenticateEvent {
|
||||
identity: ExtensionIdentity
|
||||
token?: string
|
||||
}
|
||||
|
||||
interface ExtensionAuthenticatedEvent {
|
||||
identity: ExtensionIdentity
|
||||
authenticated: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface ExtensionAnnounceEvent {
|
||||
identity: ExtensionIdentity
|
||||
permissions?: ModulePermissionDeclaration
|
||||
}
|
||||
|
||||
interface ExtensionModuleAnnounceEvent<C = undefined> {
|
||||
name: string
|
||||
identity: ExtensionModuleIdentity
|
||||
possibleEvents: Array<(keyof ProtocolEvents<C>)>
|
||||
permissions?: ModulePermissionDeclaration
|
||||
configSchema?: ModuleConfigSchema
|
||||
dependencies?: ModuleDependency[]
|
||||
}
|
||||
|
||||
interface ExtensionKitAnnounceEvent {
|
||||
identity: ExtensionKitIdentity
|
||||
capabilities?: ModuleCapability[]
|
||||
}
|
||||
|
||||
interface ModuleAuthenticateEvent {
|
||||
token: string
|
||||
}
|
||||
@@ -614,7 +730,7 @@ export interface RegistryModulesSyncEvent {
|
||||
modules: Array<{
|
||||
name: string
|
||||
index?: number
|
||||
identity: ModuleIdentity
|
||||
identity: MetadataEventSource
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -651,14 +767,14 @@ interface ModuleDeAnnouncedEvent {
|
||||
interface RegistryModulesHealthUnhealthyEvent {
|
||||
name: string
|
||||
index?: number
|
||||
identity: ModuleIdentity
|
||||
identity: MetadataEventSource
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface RegistryModulesHealthHealthyEvent {
|
||||
name: string
|
||||
index?: number
|
||||
identity: ModuleIdentity
|
||||
identity: MetadataEventSource
|
||||
|
||||
}
|
||||
|
||||
@@ -743,7 +859,7 @@ interface ModulePermissionsDeniedEvent {
|
||||
* Emitted with the module's reconciled current permission snapshot.
|
||||
*
|
||||
* Typical use cases:
|
||||
* - bootstrapping plugin runtime state after startup or reload
|
||||
* - bootstrapping extension runtime state after startup or reload
|
||||
* - synchronizing UI/debug tools with the final requested vs granted view
|
||||
*
|
||||
* Protocol expectations:
|
||||
@@ -1042,6 +1158,25 @@ interface TransportConnectionHeartbeatEvent {
|
||||
|
||||
type ContextUpdateEvent = ContextUpdate
|
||||
|
||||
export const peerAuthenticate = defineEventa<PeerAuthenticateEvent>('peer:authenticate')
|
||||
export const peerAuthenticated = defineEventa<PeerAuthenticatedEvent>('peer:authenticated')
|
||||
export const peerStatus = defineEventa<PeerStatusEvent>('peer:status')
|
||||
export const peerDeAuthenticated = defineEventa<PeerDeAuthenticatedEvent>('peer:de-authenticated')
|
||||
|
||||
export const extensionAuthenticate = defineEventa<ExtensionAuthenticateEvent>('extension:authenticate')
|
||||
export const extensionAuthenticated = defineEventa<ExtensionAuthenticatedEvent>('extension:authenticated')
|
||||
export const extensionAnnounce = defineEventa<ExtensionAnnounceEvent>('extension:announce')
|
||||
export const extensionAnnounced = defineEventa<ExtensionAnnounceEvent>('extension:announced')
|
||||
export const extensionDeAnnounced = defineEventa<ExtensionAnnounceEvent & { reason?: string }>('extension:de-announced')
|
||||
|
||||
export const extensionModuleAnnounce = defineEventa<ExtensionModuleAnnounceEvent>('extension:module:announce')
|
||||
export const extensionModuleAnnounced = defineEventa<ExtensionModuleAnnounceEvent>('extension:module:announced')
|
||||
export const extensionModuleDeAnnounced = defineEventa<ExtensionModuleAnnounceEvent & { reason?: string }>('extension:module:de-announced')
|
||||
|
||||
export const extensionKitAnnounce = defineEventa<ExtensionKitAnnounceEvent>('extension:kit:announce')
|
||||
export const extensionKitAnnounced = defineEventa<ExtensionKitAnnounceEvent>('extension:kit:announced')
|
||||
export const extensionKitDeAnnounced = defineEventa<ExtensionKitAnnounceEvent & { reason?: string }>('extension:kit:de-announced')
|
||||
|
||||
export const moduleAuthenticate = defineEventa<ModuleAuthenticateEvent>('module:authenticate')
|
||||
export const moduleAuthenticated = defineEventa<ModuleAuthenticatedEvent>('module:authenticated')
|
||||
export const moduleCompatibilityRequest = defineEventa<ModuleCompatibilityRequestEvent>('module:compatibility:request')
|
||||
@@ -1161,6 +1296,23 @@ export interface ProtocolEvents<C = undefined> {
|
||||
'error': ErrorEvent
|
||||
'error:permission': ErrorPermissionEvent
|
||||
|
||||
'peer:authenticate': PeerAuthenticateEvent
|
||||
'peer:authenticated': PeerAuthenticatedEvent
|
||||
'peer:status': PeerStatusEvent
|
||||
'peer:de-authenticated': PeerDeAuthenticatedEvent
|
||||
|
||||
'extension:authenticate': ExtensionAuthenticateEvent
|
||||
'extension:authenticated': ExtensionAuthenticatedEvent
|
||||
'extension:announce': ExtensionAnnounceEvent
|
||||
'extension:announced': ExtensionAnnounceEvent
|
||||
'extension:de-announced': ExtensionAnnounceEvent & { reason?: string }
|
||||
'extension:module:announce': ExtensionModuleAnnounceEvent<C>
|
||||
'extension:module:announced': ExtensionModuleAnnounceEvent<C>
|
||||
'extension:module:de-announced': ExtensionModuleAnnounceEvent<C> & { reason?: string }
|
||||
'extension:kit:announce': ExtensionKitAnnounceEvent
|
||||
'extension:kit:announced': ExtensionKitAnnounceEvent
|
||||
'extension:kit:de-announced': ExtensionKitAnnounceEvent & { reason?: string }
|
||||
|
||||
'module:authenticate': ModuleAuthenticateEvent
|
||||
'module:authenticated': ModuleAuthenticatedEvent
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
@@ -28,6 +28,14 @@
|
||||
"types": "./dist/gamelet/index.d.mts",
|
||||
"default": "./dist/gamelet/index.mjs"
|
||||
},
|
||||
"./kits/gamelet": {
|
||||
"types": "./dist/kits/gamelet/index.d.mts",
|
||||
"default": "./dist/kits/gamelet/index.mjs"
|
||||
},
|
||||
"./kits/tool": {
|
||||
"types": "./dist/kits/tool/index.d.mts",
|
||||
"default": "./dist/kits/tool/index.mjs"
|
||||
},
|
||||
"./tools": {
|
||||
"types": "./dist/tools/index.d.mts",
|
||||
"default": "./dist/tools/index.mjs"
|
||||
@@ -48,6 +56,7 @@
|
||||
"dependencies": {
|
||||
"@moeru/eventa": "catalog:",
|
||||
"@proj-airi/plugin-sdk": "workspace:*",
|
||||
"nanoid": "catalog:",
|
||||
"valibot": "catalog:",
|
||||
"xsschema": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,184 +1,92 @@
|
||||
import type { ContextInit } from '@proj-airi/plugin-sdk'
|
||||
import type { KitClientRuntime } from '@proj-airi/plugin-sdk'
|
||||
import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
/**
|
||||
* Describes a widget hint contributed by a gamelet to the tamagotchi host.
|
||||
*
|
||||
* Use when:
|
||||
* - A gamelet should expose one or more mountable widget surfaces
|
||||
*
|
||||
* Expects:
|
||||
* - `id` is stable within the gamelet
|
||||
*
|
||||
* Returns:
|
||||
* - A serializable host hint for widget registration
|
||||
*/
|
||||
export interface GameletWidgetDefinition {
|
||||
id: string
|
||||
kind: string
|
||||
import { defineKit } from '@proj-airi/plugin-sdk'
|
||||
|
||||
export interface GameletKitClient {
|
||||
iframe: (input: { assetPath?: string, src?: string, sandbox?: string }) => HostDataRecord
|
||||
mount: (definition: {
|
||||
/** Fully qualified host binding id used as the host-side module id. */
|
||||
bindingId?: string
|
||||
title: string
|
||||
ui: HostDataRecord
|
||||
init?: HostDataRecord
|
||||
}) => Promise<unknown>
|
||||
orchestration?: GameletKitRuntime['gamelets']
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes host-managed configuration defaults declared by a gamelet.
|
||||
*
|
||||
* Use when:
|
||||
* - A gamelet wants the host to persist validated defaults
|
||||
*
|
||||
* Expects:
|
||||
* - `defaults` is JSON-compatible
|
||||
*
|
||||
* Returns:
|
||||
* - The configuration declaration stored in the gamelet module config
|
||||
*/
|
||||
export interface GameletConfigDefinition<TDefaults extends HostDataRecord = HostDataRecord> {
|
||||
defaults?: TDefaults
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the friendly tamagotchi authoring shape for a gamelet.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin wants to register one UI-driven gamelet without raw kit/module calls
|
||||
*
|
||||
* Expects:
|
||||
* - `entrypoint` points at the plugin-provided UI asset entry
|
||||
*
|
||||
* Returns:
|
||||
* - A declarative gamelet definition consumed by {@link defineGamelet}
|
||||
*/
|
||||
export interface GameletDefinition<TDefaults extends HostDataRecord = HostDataRecord> {
|
||||
id: string
|
||||
title: string
|
||||
entrypoint: string
|
||||
widgets?: GameletWidgetDefinition[]
|
||||
config?: GameletConfigDefinition<TDefaults>
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents one registered tamagotchi gamelet.
|
||||
*
|
||||
* Use when:
|
||||
* - Tools or plugin bootstrap code need to check whether host registration succeeded
|
||||
*
|
||||
* Expects:
|
||||
* - Returned values come from a previously completed {@link defineGamelet} call
|
||||
*
|
||||
* Returns:
|
||||
* - A minimal handle that keeps host lifecycle concerns internal
|
||||
*/
|
||||
export interface DefinedGamelet {
|
||||
id: string
|
||||
isSupported: () => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes one author-facing gamelet widget into host-safe binding config data.
|
||||
*
|
||||
* Before:
|
||||
* - `{ id: 'main-board', kind: 'primary' }`
|
||||
*
|
||||
* After:
|
||||
* - `{ id: 'main-board', kind: 'primary' }`
|
||||
*/
|
||||
function createWidgetHintRecord(definition: GameletWidgetDefinition): HostDataRecord {
|
||||
return {
|
||||
id: definition.id,
|
||||
kind: definition.kind,
|
||||
export interface GameletKitRuntime extends KitClientRuntime {
|
||||
bindings?: {
|
||||
bind: (input: {
|
||||
moduleId: string
|
||||
kitId: string
|
||||
kitModuleType: string
|
||||
runtime?: string
|
||||
config: HostDataRecord
|
||||
}) => Promise<unknown> | unknown
|
||||
}
|
||||
gamelets?: {
|
||||
open: (bindingId: string, payload?: HostDataRecord) => Promise<void> | void
|
||||
configure: (bindingId: string, payload: HostDataRecord) => Promise<void> | void
|
||||
request: <TResponse = HostDataRecord>(
|
||||
bindingId: string,
|
||||
payload: HostDataRecord,
|
||||
options?: { timeoutMs?: number },
|
||||
) => Promise<TResponse> | TResponse
|
||||
close: (bindingId: string) => Promise<void> | void
|
||||
isOpen: (bindingId: string) => Promise<boolean> | boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes one gamelet definition into binding config stored in `kit.gamelet`.
|
||||
* Derives the host binding id used by the gamelet kit client.
|
||||
*
|
||||
* Before:
|
||||
* - Friendly authoring fields that may include optional properties and typed helper objects
|
||||
* - `{ sessionId: "session-1", moduleId: undefined }`
|
||||
*
|
||||
* After:
|
||||
* - A plain `HostDataRecord` with only host-safe values and no `undefined` properties
|
||||
* - `"session-1:gamelet"`
|
||||
*/
|
||||
function buildModuleConfig<TDefaults extends HostDataRecord>(definition: GameletDefinition<TDefaults>): HostDataRecord {
|
||||
return {
|
||||
title: definition.title,
|
||||
entrypoint: definition.entrypoint,
|
||||
widgets: (definition.widgets ?? []).map(createWidgetHintRecord),
|
||||
widget: {
|
||||
mount: 'iframe',
|
||||
iframe: {
|
||||
assetPath: definition.entrypoint,
|
||||
sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups',
|
||||
},
|
||||
windowSize: {
|
||||
width: 980,
|
||||
height: 840,
|
||||
minWidth: 640,
|
||||
minHeight: 640,
|
||||
},
|
||||
},
|
||||
...(definition.config
|
||||
? {
|
||||
config: {
|
||||
defaults: definition.config.defaults ?? {},
|
||||
function createGameletBindingId(runtime: KitClientRuntime): string {
|
||||
return `${runtime.moduleId ?? runtime.sessionId}:gamelet`
|
||||
}
|
||||
|
||||
export const gameletKit = defineKit<GameletKitClient>({
|
||||
id: 'kit.gamelet',
|
||||
version: '1.0.0',
|
||||
allowedExposePolicies: ['local-only', 'remote-observable'],
|
||||
defaultExposePolicy: 'local-only',
|
||||
createClient(runtime) {
|
||||
const gameletRuntime = runtime as GameletKitRuntime
|
||||
return {
|
||||
iframe(input) {
|
||||
return {
|
||||
mount: 'iframe',
|
||||
iframe: {
|
||||
...input,
|
||||
sandbox: input.sandbox ?? 'allow-scripts allow-same-origin allow-forms allow-popups',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a tamagotchi gamelet through the low-level kit/binding APIs.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin targets stage-tamagotchi and wants one-step gamelet registration
|
||||
*
|
||||
* Expects:
|
||||
* - The host exposes the `kit.gamelet` kit through `ctx.apis.kits`
|
||||
*
|
||||
* Returns:
|
||||
* - A handle that reports whether the host supports the gamelet kit
|
||||
*/
|
||||
export async function defineGamelet<TDefaults extends HostDataRecord = HostDataRecord>(
|
||||
ctx: Pick<ContextInit, 'apis'>,
|
||||
definition: GameletDefinition<TDefaults>,
|
||||
): Promise<DefinedGamelet> {
|
||||
const kits = await ctx.apis.kits.list()
|
||||
const supported = kits.some(kit => kit.kitId === 'kit.gamelet')
|
||||
|
||||
if (!supported) {
|
||||
return {
|
||||
id: definition.id,
|
||||
async isSupported() {
|
||||
return false
|
||||
},
|
||||
async mount(definition) {
|
||||
if (!gameletRuntime.bindings) {
|
||||
throw new Error('gameletKit requires a host binding runtime.')
|
||||
}
|
||||
|
||||
return await gameletRuntime.bindings.bind({
|
||||
moduleId: definition.bindingId ?? createGameletBindingId(runtime),
|
||||
kitId: 'kit.gamelet',
|
||||
kitModuleType: 'gamelet',
|
||||
config: {
|
||||
title: definition.title,
|
||||
widget: definition.ui,
|
||||
config: {
|
||||
init: definition.init ?? {},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
orchestration: gameletRuntime.gamelets,
|
||||
}
|
||||
}
|
||||
|
||||
const existingModules = await ctx.apis.bindings.list()
|
||||
const existingModule = existingModules.find(module => module.moduleId === definition.id)
|
||||
const config = buildModuleConfig(definition)
|
||||
|
||||
if (!existingModule) {
|
||||
await ctx.apis.bindings.announce({
|
||||
moduleId: definition.id,
|
||||
kitId: 'kit.gamelet',
|
||||
kitModuleType: 'gamelet',
|
||||
config,
|
||||
})
|
||||
}
|
||||
else {
|
||||
await ctx.apis.bindings.update({
|
||||
moduleId: definition.id,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.apis.bindings.activate({
|
||||
moduleId: definition.id,
|
||||
})
|
||||
|
||||
return {
|
||||
id: definition.id,
|
||||
async isSupported() {
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,101 +1,414 @@
|
||||
import type { ContextInit } from '@proj-airi/plugin-sdk'
|
||||
import type {
|
||||
ExtensionModuleRef,
|
||||
KitAvailability,
|
||||
KitClientRuntime,
|
||||
KitRef,
|
||||
KitUseResult,
|
||||
} from '@proj-airi/plugin-sdk'
|
||||
import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import type { TamagotchiToolContext } from './index'
|
||||
import type { ToolKitRuntime } from './tools'
|
||||
|
||||
import { DisposableStore } from '@proj-airi/plugin-sdk'
|
||||
import { object, optional, string } from 'valibot'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { defineGamelet, defineToolset } from './index'
|
||||
import { gameletKit, TamagotchiToolRegistry, toolKit } from './index'
|
||||
import { createGamelet } from './kits/gamelet'
|
||||
import { registerTools } from './kits/tool'
|
||||
|
||||
type ToolRuntimeServices = NonNullable<ToolKitRuntime['tools']>
|
||||
type GameletOrchestrationRuntime = NonNullable<ReturnType<typeof gameletKit.createClient>['orchestration']>
|
||||
|
||||
function createGameletRuntime(input: {
|
||||
extensionId: string
|
||||
sessionId: string
|
||||
moduleId?: string
|
||||
bind: (input: unknown) => Promise<unknown> | unknown
|
||||
gamelets?: GameletOrchestrationRuntime
|
||||
}): KitClientRuntime & {
|
||||
bindings: {
|
||||
bind: (input: unknown) => Promise<unknown> | unknown
|
||||
}
|
||||
gamelets?: GameletOrchestrationRuntime
|
||||
} {
|
||||
return {
|
||||
extensionId: input.extensionId,
|
||||
sessionId: input.sessionId,
|
||||
moduleId: input.moduleId,
|
||||
subscriptions: new DisposableStore(),
|
||||
bindings: {
|
||||
bind: input.bind,
|
||||
},
|
||||
gamelets: input.gamelets,
|
||||
}
|
||||
}
|
||||
|
||||
function createToolRuntime(input: {
|
||||
extensionId: string
|
||||
sessionId: string
|
||||
moduleId?: string
|
||||
register: ToolRuntimeServices['register']
|
||||
registerToolsetPrompt: ToolRuntimeServices['registerToolsetPrompt']
|
||||
}): ToolKitRuntime {
|
||||
return {
|
||||
extensionId: input.extensionId,
|
||||
sessionId: input.sessionId,
|
||||
moduleId: input.moduleId,
|
||||
subscriptions: new DisposableStore(),
|
||||
tools: {
|
||||
register: input.register,
|
||||
registerToolsetPrompt: input.registerToolsetPrompt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createGameletModuleRef(input: {
|
||||
id: string
|
||||
extensionId: string
|
||||
sessionId: string
|
||||
bind: (input: unknown) => Promise<unknown> | unknown
|
||||
gamelets?: GameletOrchestrationRuntime
|
||||
}): { module: ExtensionModuleRef, useKit: ReturnType<typeof vi.fn> } {
|
||||
const useKit = vi.fn()
|
||||
|
||||
const module: ExtensionModuleRef = {
|
||||
id: input.id,
|
||||
kits: {
|
||||
async use<TClient>(kit: KitRef<TClient>): Promise<TClient> {
|
||||
useKit(kit)
|
||||
if (kit !== gameletKit) {
|
||||
throw new Error(`Unexpected kit requested: ${kit.id}`)
|
||||
}
|
||||
|
||||
return gameletKit.createClient(createGameletRuntime({
|
||||
extensionId: input.extensionId,
|
||||
sessionId: input.sessionId,
|
||||
moduleId: input.id,
|
||||
bind: input.bind,
|
||||
gamelets: input.gamelets,
|
||||
})) as TClient
|
||||
},
|
||||
async tryUse<TClient>(kit: KitRef<TClient>): Promise<KitUseResult<TClient>> {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing-kit',
|
||||
error: new Error(`Unused test kit lookup: ${kit.id}`),
|
||||
}
|
||||
},
|
||||
watch<TClient>(
|
||||
_kit: KitRef<TClient>,
|
||||
_callback: (availability: KitAvailability<TClient>) => void | Promise<void>,
|
||||
) {
|
||||
return { dispose: vi.fn() }
|
||||
},
|
||||
},
|
||||
subscriptions: new DisposableStore(),
|
||||
dispose: vi.fn(async () => {}),
|
||||
}
|
||||
|
||||
return { module, useKit }
|
||||
}
|
||||
|
||||
function createToolModuleRef(input: {
|
||||
id: string
|
||||
extensionId: string
|
||||
sessionId: string
|
||||
register: ToolRuntimeServices['register']
|
||||
registerToolsetPrompt: ToolRuntimeServices['registerToolsetPrompt']
|
||||
}): { module: ExtensionModuleRef, useKit: ReturnType<typeof vi.fn> } {
|
||||
const useKit = vi.fn()
|
||||
|
||||
const module: ExtensionModuleRef = {
|
||||
id: input.id,
|
||||
kits: {
|
||||
async use<TClient>(kit: KitRef<TClient>): Promise<TClient> {
|
||||
useKit(kit)
|
||||
if (kit !== toolKit) {
|
||||
throw new Error(`Unexpected kit requested: ${kit.id}`)
|
||||
}
|
||||
|
||||
return toolKit.createClient(createToolRuntime({
|
||||
extensionId: input.extensionId,
|
||||
sessionId: input.sessionId,
|
||||
moduleId: input.id,
|
||||
register: input.register,
|
||||
registerToolsetPrompt: input.registerToolsetPrompt,
|
||||
})) as TClient
|
||||
},
|
||||
async tryUse<TClient>(kit: KitRef<TClient>): Promise<KitUseResult<TClient>> {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing-kit',
|
||||
error: new Error(`Unused test kit lookup: ${kit.id}`),
|
||||
}
|
||||
},
|
||||
watch<TClient>(
|
||||
_kit: KitRef<TClient>,
|
||||
_callback: (availability: KitAvailability<TClient>) => void | Promise<void>,
|
||||
) {
|
||||
return { dispose: vi.fn() }
|
||||
},
|
||||
},
|
||||
subscriptions: new DisposableStore(),
|
||||
dispose: vi.fn(async () => {}),
|
||||
}
|
||||
|
||||
return { module, useKit }
|
||||
}
|
||||
|
||||
describe('plugin-sdk-tamagotchi', () => {
|
||||
/**
|
||||
* @example
|
||||
* expect(registerBinding).toHaveBeenCalledWith(expect.objectContaining({ kitId: 'kit.gamelet' }))
|
||||
* expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ tool: expect.any(Object) }))
|
||||
*/
|
||||
it('should allow a plugin to define a gamelet and toolset without raw kit or module calls', async () => {
|
||||
const registerBinding = vi.fn()
|
||||
const registerTool = vi.fn()
|
||||
const openGamelet = vi.fn()
|
||||
const configureGamelet = vi.fn()
|
||||
const closeGamelet = vi.fn()
|
||||
const isGameletOpen = vi.fn(() => true)
|
||||
|
||||
const ctx: Pick<ContextInit, 'apis'> & TamagotchiToolContext = {
|
||||
apis: {
|
||||
gamelets: {
|
||||
open: openGamelet,
|
||||
configure: configureGamelet,
|
||||
request: vi.fn(async () => ({})),
|
||||
close: closeGamelet,
|
||||
isOpen: isGameletOpen,
|
||||
},
|
||||
tools: {
|
||||
register: registerTool,
|
||||
registerToolsetPrompt: vi.fn(),
|
||||
},
|
||||
kits: {
|
||||
list: async () => [
|
||||
{
|
||||
kitId: 'kit.gamelet',
|
||||
version: '1.0.0',
|
||||
runtimes: ['electron'],
|
||||
capabilities: [],
|
||||
},
|
||||
],
|
||||
getCapabilities: async () => [
|
||||
{
|
||||
key: 'kit.gamelet.runtime',
|
||||
actions: ['announce', 'activate', 'update'],
|
||||
},
|
||||
],
|
||||
},
|
||||
bindings: {
|
||||
list: async () => [],
|
||||
announce: registerBinding,
|
||||
update: registerBinding,
|
||||
activate: registerBinding,
|
||||
withdraw: registerBinding,
|
||||
},
|
||||
providers: {
|
||||
listProviders: async () => [],
|
||||
},
|
||||
it('exposes gameletKit as a module-scoped kit client', async () => {
|
||||
const bindings: unknown[] = []
|
||||
const client = gameletKit.createClient(createGameletRuntime({
|
||||
extensionId: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
moduleId: 'chess',
|
||||
bind: async (input: unknown) => {
|
||||
bindings.push(input)
|
||||
return { moduleId: 'chess:gamelet', state: 'active' }
|
||||
},
|
||||
}
|
||||
}))
|
||||
|
||||
const gamelet = await defineGamelet(ctx, {
|
||||
id: 'chess',
|
||||
await client.mount({
|
||||
title: 'Chess',
|
||||
entrypoint: './ui/index.html',
|
||||
widgets: [
|
||||
{
|
||||
id: 'main-board',
|
||||
kind: 'primary',
|
||||
},
|
||||
],
|
||||
ui: client.iframe({ assetPath: 'ui/index.html' }),
|
||||
init: { airiSide: 'black' },
|
||||
})
|
||||
|
||||
await defineToolset(ctx, {
|
||||
id: 'chess-tools',
|
||||
expect(bindings).toHaveLength(1)
|
||||
expect(bindings[0]).toMatchObject({
|
||||
moduleId: 'chess:gamelet',
|
||||
kitId: 'kit.gamelet',
|
||||
kitModuleType: 'gamelet',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(bindings[0]).toMatchObject({ moduleId: 'session-1:gamelet' })
|
||||
*/
|
||||
it('derives a stable gameletKit binding id for extension-scoped clients', async () => {
|
||||
const bindings: unknown[] = []
|
||||
const client = gameletKit.createClient(createGameletRuntime({
|
||||
extensionId: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
bind: async (input: unknown) => {
|
||||
bindings.push(input)
|
||||
return { moduleId: 'session-1:gamelet', state: 'active' }
|
||||
},
|
||||
}))
|
||||
|
||||
await client.mount({
|
||||
title: 'Chess',
|
||||
ui: client.iframe({ assetPath: 'ui/index.html' }),
|
||||
})
|
||||
|
||||
expect(bindings).toHaveLength(1)
|
||||
expect(bindings[0]).toMatchObject({
|
||||
moduleId: 'session-1:gamelet',
|
||||
kitId: 'kit.gamelet',
|
||||
kitModuleType: 'gamelet',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(open).toHaveBeenCalledWith('chess:board', { mode: 'new' })
|
||||
* expect(isOpen).toHaveBeenCalledWith('chess:board')
|
||||
*/
|
||||
it('routes createGamelet handle orchestration calls through the host gamelet runtime', async () => {
|
||||
const open = vi.fn(async (_bindingId: string, _payload?: HostDataRecord) => {})
|
||||
const configure = vi.fn(async (_bindingId: string, _payload: HostDataRecord) => {})
|
||||
const requestCalls: [string, HostDataRecord, { timeoutMs?: number } | undefined][] = []
|
||||
const request: GameletOrchestrationRuntime['request'] = async <TResponse = HostDataRecord>(
|
||||
bindingId: string,
|
||||
payload: HostDataRecord,
|
||||
options?: { timeoutMs?: number },
|
||||
): Promise<TResponse> => {
|
||||
requestCalls.push([bindingId, payload, options])
|
||||
return { ok: true } as TResponse
|
||||
}
|
||||
const close = vi.fn(async (_bindingId: string) => {})
|
||||
const isOpen = vi.fn(async (_bindingId: string) => true)
|
||||
const { module } = createGameletModuleRef({
|
||||
id: 'chess',
|
||||
extensionId: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
bind: async () => ({ moduleId: 'chess:board', state: 'active' }),
|
||||
gamelets: {
|
||||
open,
|
||||
configure,
|
||||
request,
|
||||
close,
|
||||
isOpen,
|
||||
},
|
||||
})
|
||||
|
||||
const handle = await createGamelet(module, {
|
||||
id: 'board',
|
||||
title: 'Chess Board',
|
||||
indexPath: 'ui/index.html',
|
||||
})
|
||||
|
||||
await handle.open({ mode: 'new' })
|
||||
await handle.configure({ airiSide: 'black' })
|
||||
await handle.request({ action: 'snapshot' })
|
||||
await handle.close()
|
||||
await expect(handle.isOpen()).resolves.toBe(true)
|
||||
|
||||
expect(open).toHaveBeenCalledWith('chess:board', { mode: 'new' })
|
||||
expect(configure).toHaveBeenCalledWith('chess:board', { airiSide: 'black' })
|
||||
expect(requestCalls).toEqual([['chess:board', { action: 'snapshot' }, undefined]])
|
||||
expect(close).toHaveBeenCalledWith('chess:board')
|
||||
expect(isOpen).toHaveBeenCalledWith('chess:board')
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* await expect(handle.open()).rejects.toThrow('gameletKit requires a host gamelet orchestration runtime.')
|
||||
*/
|
||||
it('reports a clear error when createGamelet orchestration methods run without a host runtime', async () => {
|
||||
const { module } = createGameletModuleRef({
|
||||
id: 'chess',
|
||||
extensionId: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
bind: async () => ({ moduleId: 'chess:board', state: 'active' }),
|
||||
})
|
||||
|
||||
const handle = await createGamelet(module, {
|
||||
id: 'board',
|
||||
title: 'Chess Board',
|
||||
indexPath: 'ui/index.html',
|
||||
})
|
||||
|
||||
await expect(handle.open()).rejects.toThrow('gameletKit requires a host gamelet orchestration runtime.')
|
||||
await expect(handle.configure({ airiSide: 'black' })).rejects.toThrow('gameletKit requires a host gamelet orchestration runtime.')
|
||||
await expect(handle.request({ action: 'snapshot' })).rejects.toThrow('gameletKit requires a host gamelet orchestration runtime.')
|
||||
await expect(handle.close()).rejects.toThrow('gameletKit requires a host gamelet orchestration runtime.')
|
||||
await expect(handle.isOpen()).rejects.toThrow('gameletKit requires a host gamelet orchestration runtime.')
|
||||
await expect(module.subscriptions.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* await module.subscriptions.dispose()
|
||||
* expect(close).toHaveBeenCalledWith('chess:board')
|
||||
*/
|
||||
it('registers gamelet close cleanup with the module subscription scope', async () => {
|
||||
const close = vi.fn(async (_bindingId: string) => {})
|
||||
const { module } = createGameletModuleRef({
|
||||
id: 'chess',
|
||||
extensionId: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
bind: async () => ({ moduleId: 'chess:board', state: 'active' }),
|
||||
gamelets: {
|
||||
open: vi.fn(),
|
||||
configure: vi.fn(),
|
||||
request: vi.fn(),
|
||||
close,
|
||||
isOpen: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
await createGamelet(module, {
|
||||
id: 'board',
|
||||
title: 'Chess Board',
|
||||
indexPath: 'ui/index.html',
|
||||
})
|
||||
await module.subscriptions.dispose()
|
||||
|
||||
expect(close).toHaveBeenCalledWith('chess:board')
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ tool: expect.objectContaining({ id: 'play_chess' }) }))
|
||||
* expect(registerPrompt).toHaveBeenCalledWith(expect.objectContaining({ id: 'chess-tools' }))
|
||||
*/
|
||||
it('exposes toolKit as a module-scoped kit client without a gamelet runtime', async () => {
|
||||
const registerTool = vi.fn()
|
||||
const registerPrompt = vi.fn()
|
||||
|
||||
const client = toolKit.createClient(createToolRuntime({
|
||||
extensionId: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
moduleId: 'chess',
|
||||
register: registerTool,
|
||||
registerToolsetPrompt: registerPrompt,
|
||||
}))
|
||||
|
||||
await client.registerToolsetPrompt({
|
||||
id: 'chess-toolset',
|
||||
prompt: {
|
||||
id: 'airi-plugin-game-chess.prompt',
|
||||
title: 'Chess Plugin Guidance',
|
||||
content: 'Do not pass fen or pgn when mode is "new".',
|
||||
},
|
||||
})
|
||||
await client.registerTool({
|
||||
id: 'play_chess',
|
||||
title: 'Play Chess',
|
||||
description: 'Open chess.',
|
||||
inputSchema: object({}),
|
||||
execute: async () => ({ ok: true }),
|
||||
})
|
||||
|
||||
expect(registerPrompt).toHaveBeenCalledWith({
|
||||
id: 'chess-toolset',
|
||||
prompt: {
|
||||
id: 'airi-plugin-game-chess.prompt',
|
||||
title: 'Chess Plugin Guidance',
|
||||
content: 'Do not pass fen or pgn when mode is "new".',
|
||||
},
|
||||
})
|
||||
expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tool: expect.objectContaining({
|
||||
id: 'play_chess',
|
||||
}),
|
||||
}))
|
||||
|
||||
await expect(registerTool.mock.calls[0]?.[0].execute({})).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(useKit).toHaveBeenCalledWith(toolKit)
|
||||
* expect(registerToolsetPrompt).toHaveBeenCalledBefore(registerTool)
|
||||
*/
|
||||
it('registers a toolset prompt before module-scoped tools through the tool helper', async () => {
|
||||
const registerTool = vi.fn()
|
||||
const registerToolsetPrompt = vi.fn()
|
||||
const { module, useKit } = createToolModuleRef({
|
||||
id: 'chess',
|
||||
extensionId: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
register: registerTool,
|
||||
registerToolsetPrompt,
|
||||
})
|
||||
|
||||
await registerTools(module, {
|
||||
prompt: {
|
||||
id: 'chess-tools',
|
||||
prompt: {
|
||||
id: 'airi-plugin-game-chess.prompt',
|
||||
title: 'Chess Plugin Guidance',
|
||||
content: 'Do not pass fen or pgn when mode is "new".',
|
||||
},
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
id: 'play_chess',
|
||||
title: 'Play Chess',
|
||||
description: 'Open chess.',
|
||||
inputSchema: object({
|
||||
opening: optional(string()),
|
||||
}),
|
||||
inputSchema: object({}),
|
||||
execute: async () => ({ ok: true }),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(ctx.apis.tools.registerToolsetPrompt).toHaveBeenCalledWith({
|
||||
expect(useKit).toHaveBeenCalledWith(toolKit)
|
||||
expect(registerToolsetPrompt).toHaveBeenCalledWith({
|
||||
id: 'chess-tools',
|
||||
prompt: {
|
||||
id: 'airi-plugin-game-chess.prompt',
|
||||
@@ -103,39 +416,201 @@ describe('plugin-sdk-tamagotchi', () => {
|
||||
content: 'Do not pass fen or pgn when mode is "new".',
|
||||
},
|
||||
})
|
||||
expect(gamelet).toBeDefined()
|
||||
expect(registerBinding).toHaveBeenCalledWith({
|
||||
expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tool: expect.objectContaining({
|
||||
id: 'play_chess',
|
||||
}),
|
||||
}))
|
||||
|
||||
expect(registerToolsetPrompt.mock.invocationCallOrder[0]).toBeLessThan(registerTool.mock.invocationCallOrder[0])
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(registerToolsetPrompt).toHaveBeenCalledWith({ id: 'airi-plugin-game-chess.prompt', prompt: expect.any(Object) })
|
||||
* expect(registerTool).not.toHaveBeenCalled()
|
||||
*/
|
||||
it('normalizes shorthand toolset prompts before registration', async () => {
|
||||
const registerTool = vi.fn()
|
||||
const registerToolsetPrompt = vi.fn()
|
||||
const { module } = createToolModuleRef({
|
||||
id: 'chess',
|
||||
extensionId: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
register: registerTool,
|
||||
registerToolsetPrompt,
|
||||
})
|
||||
|
||||
await registerTools(module, {
|
||||
prompt: {
|
||||
id: 'airi-plugin-game-chess.prompt',
|
||||
title: 'Chess Plugin Guidance',
|
||||
content: 'Start chess directly.',
|
||||
},
|
||||
tools: [],
|
||||
})
|
||||
|
||||
expect(registerToolsetPrompt).toHaveBeenCalledWith({
|
||||
id: 'airi-plugin-game-chess.prompt',
|
||||
prompt: {
|
||||
id: 'airi-plugin-game-chess.prompt',
|
||||
title: 'Chess Plugin Guidance',
|
||||
content: 'Start chess directly.',
|
||||
},
|
||||
})
|
||||
expect(registerTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores, invokes, and removes module-scoped Tamagotchi tools', async () => {
|
||||
const registry = new TamagotchiToolRegistry()
|
||||
const execute = vi.fn(async () => ({ ok: true }))
|
||||
|
||||
registry.register({
|
||||
ownerSessionId: 'session-1',
|
||||
ownerPluginId: 'airi-extension-chess',
|
||||
ownerModuleId: 'chess',
|
||||
tool: {
|
||||
id: 'play_chess',
|
||||
title: 'Play Chess',
|
||||
description: 'Open chess.',
|
||||
activation: {
|
||||
keywords: ['chess'],
|
||||
patterns: ['chess'],
|
||||
},
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
execute,
|
||||
})
|
||||
registry.registerToolsetPrompt({
|
||||
ownerSessionId: 'session-1',
|
||||
ownerPluginId: 'airi-extension-chess',
|
||||
ownerModuleId: 'chess',
|
||||
toolset: {
|
||||
id: 'chess-tools',
|
||||
prompt: {
|
||||
id: 'airi-plugin-game-chess.prompt',
|
||||
content: 'Prefer legal chess moves.',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await expect(registry.listAvailableDescriptors()).resolves.toEqual([{
|
||||
id: 'play_chess',
|
||||
title: 'Play Chess',
|
||||
description: 'Open chess.',
|
||||
activation: {
|
||||
keywords: ['chess'],
|
||||
patterns: ['chess'],
|
||||
},
|
||||
}])
|
||||
await expect(registry.listSerializedXsaiTools()).resolves.toEqual({
|
||||
prompts: [{
|
||||
ownerPluginId: 'airi-extension-chess',
|
||||
id: 'chess-tools',
|
||||
prompt: {
|
||||
id: 'airi-plugin-game-chess.prompt',
|
||||
content: 'Prefer legal chess moves.',
|
||||
},
|
||||
}],
|
||||
tools: [{
|
||||
ownerPluginId: 'airi-extension-chess',
|
||||
name: 'play_chess',
|
||||
description: 'Open chess.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
}],
|
||||
})
|
||||
await expect(registry.invoke('airi-extension-chess', 'play_chess', { move: 'e4' })).resolves.toEqual({ ok: true })
|
||||
expect(execute).toHaveBeenCalledWith({ move: 'e4' })
|
||||
|
||||
registry.unregisterOwnerScope('session-1', 'chess')
|
||||
|
||||
await expect(registry.listSerializedXsaiTools()).resolves.toEqual({
|
||||
prompts: [],
|
||||
tools: [],
|
||||
})
|
||||
await expect(registry.invoke('airi-extension-chess', 'play_chess', {})).rejects.toThrow(
|
||||
'Tamagotchi extension tool not found: airi-extension-chess:play_chess',
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(registerBinding).toHaveBeenCalledWith(expect.objectContaining({ kitId: 'kit.gamelet' }))
|
||||
* expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ tool: expect.any(Object) }))
|
||||
*/
|
||||
it('allows gamelet and tool kits to be composed without coupling tool registration to gamelets', async () => {
|
||||
const registerBinding = vi.fn()
|
||||
const registerTool = vi.fn()
|
||||
const registerToolsetPrompt = vi.fn()
|
||||
const gamelets = gameletKit.createClient(createGameletRuntime({
|
||||
extensionId: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
moduleId: 'chess',
|
||||
bind: registerBinding,
|
||||
}))
|
||||
const tools = toolKit.createClient(createToolRuntime({
|
||||
extensionId: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
moduleId: 'chess',
|
||||
register: registerTool,
|
||||
registerToolsetPrompt,
|
||||
}))
|
||||
|
||||
await gamelets.mount({
|
||||
title: 'Chess',
|
||||
ui: gamelets.iframe({ assetPath: './ui/index.html' }),
|
||||
})
|
||||
|
||||
await tools.registerToolsetPrompt({
|
||||
id: 'chess-tools',
|
||||
prompt: {
|
||||
id: 'airi-plugin-game-chess.prompt',
|
||||
title: 'Chess Plugin Guidance',
|
||||
content: 'Do not pass fen or pgn when mode is "new".',
|
||||
},
|
||||
})
|
||||
await tools.registerTool({
|
||||
id: 'play_chess',
|
||||
title: 'Play Chess',
|
||||
description: 'Open chess.',
|
||||
inputSchema: object({
|
||||
opening: optional(string()),
|
||||
}),
|
||||
execute: async () => ({ ok: true }),
|
||||
})
|
||||
|
||||
expect(registerToolsetPrompt).toHaveBeenCalledWith({
|
||||
id: 'chess-tools',
|
||||
prompt: {
|
||||
id: 'airi-plugin-game-chess.prompt',
|
||||
title: 'Chess Plugin Guidance',
|
||||
content: 'Do not pass fen or pgn when mode is "new".',
|
||||
},
|
||||
})
|
||||
expect(registerBinding).toHaveBeenCalledWith({
|
||||
moduleId: 'chess:gamelet',
|
||||
kitId: 'kit.gamelet',
|
||||
kitModuleType: 'gamelet',
|
||||
config: {
|
||||
title: 'Chess',
|
||||
entrypoint: './ui/index.html',
|
||||
widgets: [
|
||||
{
|
||||
id: 'main-board',
|
||||
kind: 'primary',
|
||||
},
|
||||
],
|
||||
widget: {
|
||||
mount: 'iframe',
|
||||
iframe: {
|
||||
assetPath: './ui/index.html',
|
||||
sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups',
|
||||
},
|
||||
windowSize: {
|
||||
width: 980,
|
||||
height: 840,
|
||||
minWidth: 640,
|
||||
minHeight: 640,
|
||||
},
|
||||
},
|
||||
config: {
|
||||
init: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(registerBinding).toHaveBeenCalledWith({
|
||||
moduleId: 'chess',
|
||||
})
|
||||
expect(registerTool).toHaveBeenCalled()
|
||||
expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tool: expect.objectContaining({
|
||||
id: 'play_chess',
|
||||
@@ -151,12 +626,7 @@ describe('plugin-sdk-tamagotchi', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
await registerTool.mock.calls[0]?.[0].execute({})
|
||||
|
||||
expect(openGamelet).not.toHaveBeenCalled()
|
||||
expect(configureGamelet).not.toHaveBeenCalled()
|
||||
expect(closeGamelet).not.toHaveBeenCalled()
|
||||
expect(isGameletOpen).not.toHaveBeenCalled()
|
||||
await expect(registerTool.mock.calls[0]?.[0].execute({})).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -164,49 +634,42 @@ describe('plugin-sdk-tamagotchi', () => {
|
||||
* expect(openGamelet).toHaveBeenCalledWith('chess', { opening: 'sicilian' })
|
||||
* expect(configureGamelet).toHaveBeenCalledWith('chess', { side: 'black' })
|
||||
*/
|
||||
it('passes host-backed gamelet operations through defineToolset execution context', async () => {
|
||||
it('lets extension authors compose gamelet handles inside tool execution closures', async () => {
|
||||
const registerTool = vi.fn()
|
||||
const openGamelet = vi.fn()
|
||||
const configureGamelet = vi.fn()
|
||||
const closeGamelet = vi.fn()
|
||||
const isGameletOpen = vi.fn(() => true)
|
||||
const isGameletOpen = vi.fn<(id: string) => boolean>(() => true)
|
||||
|
||||
const ctx: TamagotchiToolContext = {
|
||||
apis: {
|
||||
gamelets: {
|
||||
open: openGamelet,
|
||||
configure: configureGamelet,
|
||||
request: vi.fn(async () => ({ ready: true })),
|
||||
close: closeGamelet,
|
||||
isOpen: isGameletOpen,
|
||||
},
|
||||
tools: {
|
||||
register: registerTool,
|
||||
registerToolsetPrompt: vi.fn(),
|
||||
},
|
||||
},
|
||||
const gamelets = {
|
||||
open: openGamelet,
|
||||
configure: configureGamelet,
|
||||
request: vi.fn<(id: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>>>(async () => ({ ready: true })),
|
||||
close: closeGamelet,
|
||||
isOpen: isGameletOpen,
|
||||
}
|
||||
const tools = toolKit.createClient(createToolRuntime({
|
||||
extensionId: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
moduleId: 'chess',
|
||||
register: registerTool,
|
||||
registerToolsetPrompt: vi.fn(),
|
||||
}))
|
||||
|
||||
await defineToolset(ctx, {
|
||||
tools: [
|
||||
{
|
||||
id: 'drive_chess',
|
||||
title: 'Drive Chess',
|
||||
description: 'Drive a host-backed chess gamelet.',
|
||||
inputSchema: object({}),
|
||||
async isAvailable(context) {
|
||||
return await context.gamelets.isOpen('chess')
|
||||
},
|
||||
async execute(_input, context) {
|
||||
await context.gamelets.open('chess', { opening: 'sicilian' })
|
||||
await context.gamelets.configure('chess', { side: 'black' })
|
||||
await context.gamelets.request('chess', { action: 'snapshot' })
|
||||
await context.gamelets.close('chess')
|
||||
await tools.registerTool({
|
||||
id: 'drive_chess',
|
||||
title: 'Drive Chess',
|
||||
description: 'Drive a host-backed chess gamelet.',
|
||||
inputSchema: object({}),
|
||||
isAvailable: async () => await gamelets.isOpen('chess'),
|
||||
async execute() {
|
||||
await gamelets.open('chess', { opening: 'sicilian' })
|
||||
await gamelets.configure('chess', { side: 'black' })
|
||||
await gamelets.request('chess', { action: 'snapshot' })
|
||||
await gamelets.close('chess')
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
},
|
||||
],
|
||||
return { ok: true }
|
||||
},
|
||||
})
|
||||
|
||||
const registration = registerTool.mock.calls[0]?.[0]
|
||||
@@ -218,7 +681,7 @@ describe('plugin-sdk-tamagotchi', () => {
|
||||
expect(registration.availability).toBeTypeOf('function')
|
||||
expect(openGamelet).toHaveBeenCalledWith('chess', { opening: 'sicilian' })
|
||||
expect(configureGamelet).toHaveBeenCalledWith('chess', { side: 'black' })
|
||||
expect(ctx.apis.gamelets.request).toHaveBeenCalledWith('chess', { action: 'snapshot' })
|
||||
expect(gamelets.request).toHaveBeenCalledWith('chess', { action: 'snapshot' })
|
||||
expect(closeGamelet).toHaveBeenCalledWith('chess')
|
||||
})
|
||||
|
||||
@@ -228,35 +691,23 @@ describe('plugin-sdk-tamagotchi', () => {
|
||||
*/
|
||||
it('serializes optional tool fields as required nullable properties for strict OpenAI-compatible schemas', async () => {
|
||||
const registerTool = vi.fn()
|
||||
const ctx: TamagotchiToolContext = {
|
||||
apis: {
|
||||
gamelets: {
|
||||
open: vi.fn(),
|
||||
configure: vi.fn(),
|
||||
request: vi.fn(async () => ({})),
|
||||
close: vi.fn(),
|
||||
isOpen: vi.fn(() => true),
|
||||
},
|
||||
tools: {
|
||||
register: registerTool,
|
||||
registerToolsetPrompt: vi.fn(),
|
||||
},
|
||||
},
|
||||
}
|
||||
const tools = toolKit.createClient(createToolRuntime({
|
||||
extensionId: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
moduleId: 'chess',
|
||||
register: registerTool,
|
||||
registerToolsetPrompt: vi.fn(),
|
||||
}))
|
||||
|
||||
await defineToolset(ctx, {
|
||||
tools: [
|
||||
{
|
||||
id: 'play_chess',
|
||||
title: 'Play Chess',
|
||||
description: 'Open chess.',
|
||||
inputSchema: object({
|
||||
mode: string(),
|
||||
opening: optional(string()),
|
||||
}),
|
||||
execute: async () => ({ ok: true }),
|
||||
},
|
||||
],
|
||||
await tools.registerTool({
|
||||
id: 'play_chess',
|
||||
title: 'Play Chess',
|
||||
description: 'Open chess.',
|
||||
inputSchema: object({
|
||||
mode: string(),
|
||||
opening: optional(string()),
|
||||
}),
|
||||
execute: async () => ({ ok: true }),
|
||||
})
|
||||
|
||||
const parameters = registerTool.mock.calls[0]?.[0].tool.parameters
|
||||
@@ -267,31 +718,85 @@ describe('plugin-sdk-tamagotchi', () => {
|
||||
|
||||
/**
|
||||
* @example
|
||||
* await expect(defineToolset({ apis: { tools: { register: registerTool } } } as never, options)).rejects.toThrow(/gamelet API/i)
|
||||
* expect(registerBinding).toHaveBeenCalledWith(expect.objectContaining({ moduleId: 'chess:board' }))
|
||||
* expect(gamelet.bindingId).toBe('chess:board')
|
||||
*/
|
||||
it('fails with a clear error when the tamagotchi gamelet API is not available', async () => {
|
||||
const registerTool = vi.fn()
|
||||
it('creates a gamelet helper with an explicit module-scoped binding id', async () => {
|
||||
const registerBinding = vi.fn()
|
||||
const { module, useKit } = createGameletModuleRef({
|
||||
id: 'chess',
|
||||
extensionId: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
bind: registerBinding,
|
||||
})
|
||||
|
||||
await expect(defineToolset({
|
||||
apis: {
|
||||
tools: {
|
||||
register: registerTool,
|
||||
},
|
||||
},
|
||||
} as never, {
|
||||
tools: [
|
||||
{
|
||||
id: 'drive_chess',
|
||||
title: 'Drive Chess',
|
||||
description: 'Drive a host-backed chess gamelet.',
|
||||
inputSchema: object({}),
|
||||
async execute() {
|
||||
return { ok: true }
|
||||
const gamelet = await createGamelet(module, {
|
||||
id: 'board',
|
||||
title: 'Chess',
|
||||
indexPath: './ui/index.html',
|
||||
init: { airiSide: 'black' },
|
||||
})
|
||||
|
||||
expect(gamelet.id).toBe('board')
|
||||
expect(gamelet.bindingId).toBe('chess:board')
|
||||
expect(useKit).toHaveBeenCalledWith(gameletKit)
|
||||
expect(registerBinding).toHaveBeenCalledWith({
|
||||
moduleId: 'chess:board',
|
||||
kitId: 'kit.gamelet',
|
||||
kitModuleType: 'gamelet',
|
||||
config: {
|
||||
title: 'Chess',
|
||||
widget: {
|
||||
mount: 'iframe',
|
||||
iframe: {
|
||||
assetPath: './ui/index.html',
|
||||
sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups',
|
||||
},
|
||||
},
|
||||
],
|
||||
})).rejects.toThrow(/gamelet API/i)
|
||||
config: {
|
||||
init: { airiSide: 'black' },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(registerTool).not.toHaveBeenCalled()
|
||||
/**
|
||||
* @example
|
||||
* expect(gamelet.bindingId).toBe(`feature:${gamelet.id}`)
|
||||
*/
|
||||
it('creates a gamelet helper with a generated id when omitted', async () => {
|
||||
const registerBinding = vi.fn()
|
||||
const { module } = createGameletModuleRef({
|
||||
id: 'feature',
|
||||
extensionId: 'airi-extension-feature',
|
||||
sessionId: 'session-1',
|
||||
bind: registerBinding,
|
||||
})
|
||||
|
||||
const gamelet = await createGamelet(module, {
|
||||
title: 'Feature',
|
||||
indexPath: './ui/index.html',
|
||||
})
|
||||
|
||||
expect(gamelet.id).not.toBe('')
|
||||
expect(gamelet.bindingId).toBe(`feature:${gamelet.id}`)
|
||||
expect(registerBinding).toHaveBeenCalledWith({
|
||||
moduleId: gamelet.bindingId,
|
||||
kitId: 'kit.gamelet',
|
||||
kitModuleType: 'gamelet',
|
||||
config: {
|
||||
title: 'Feature',
|
||||
widget: {
|
||||
mount: 'iframe',
|
||||
iframe: {
|
||||
assetPath: './ui/index.html',
|
||||
sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups',
|
||||
},
|
||||
},
|
||||
config: {
|
||||
init: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { ExtensionModuleRef } from '@proj-airi/plugin-sdk'
|
||||
import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import { nanoid } from 'nanoid/non-secure'
|
||||
|
||||
import { gameletKit } from '../../gamelet'
|
||||
|
||||
const GAMELET_RUNTIME_UNAVAILABLE_MESSAGE = 'gameletKit requires a host gamelet orchestration runtime.'
|
||||
|
||||
/**
|
||||
* Options used to declare and mount one Tamagotchi gamelet UI contribution.
|
||||
*
|
||||
* @param TInit Initial host-safe configuration passed to the mounted gamelet.
|
||||
*/
|
||||
export interface CreateGameletOptions<TInit extends HostDataRecord = HostDataRecord> {
|
||||
/** Stable gamelet id within the extension module. Generated when omitted. */
|
||||
id?: string
|
||||
/** Human-readable title shown by the host around the gamelet surface. */
|
||||
title: string
|
||||
/** Plugin asset path for the iframe HTML entrypoint. */
|
||||
indexPath: string
|
||||
/** Initial host-safe configuration delivered to the iframe widget. */
|
||||
init?: TInit
|
||||
/** iframe sandbox policy. Defaults to the low-level gamelet kit sandbox. */
|
||||
sandbox?: string
|
||||
/** Development server URL used by hosts that can load an iframe from a live dev server. */
|
||||
devServerUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime handle returned after a gamelet is mounted.
|
||||
*
|
||||
* @param TInit Initial host-safe configuration type associated with the gamelet.
|
||||
*/
|
||||
export interface GameletHandle<TInit extends HostDataRecord = HostDataRecord> {
|
||||
/** Stable gamelet id within the extension module. */
|
||||
id: string
|
||||
/** Fully qualified host binding id, formatted as `<moduleId>:<gameletId>`. */
|
||||
bindingId: string
|
||||
/** Initial host-safe configuration delivered to the iframe widget. */
|
||||
init?: TInit
|
||||
/** Opens the gamelet through the host orchestration runtime. */
|
||||
open: (payload?: HostDataRecord) => Promise<void>
|
||||
/** Reconfigures the gamelet through the host orchestration runtime. */
|
||||
configure: (payload: HostDataRecord) => Promise<void>
|
||||
/** Sends a request to the gamelet through the host orchestration runtime. */
|
||||
request: <TResponse = HostDataRecord>(payload: HostDataRecord, options?: { timeoutMs?: number }) => Promise<TResponse>
|
||||
/** Closes the gamelet through the host orchestration runtime. */
|
||||
close: () => Promise<void>
|
||||
/** Reports whether the gamelet is open through the host orchestration runtime. */
|
||||
isOpen: () => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and mounts a module-scoped Tamagotchi gamelet.
|
||||
*
|
||||
* Use when:
|
||||
* - Extension authors need an iframe-backed gamelet with a stable module binding id
|
||||
* - Tool handlers need to retain a handle for future gamelet orchestration calls
|
||||
*
|
||||
* Expects:
|
||||
* - `module` is an {@link ExtensionModuleRef} with access to {@link gameletKit}
|
||||
* - `options.indexPath` points at the gamelet iframe HTML asset
|
||||
*
|
||||
* Returns:
|
||||
* - A handle containing the local id, host binding id, initial config, and orchestration methods
|
||||
*/
|
||||
export async function createGamelet<TInit extends HostDataRecord = HostDataRecord>(
|
||||
module: ExtensionModuleRef,
|
||||
options: CreateGameletOptions<TInit>,
|
||||
): Promise<GameletHandle<TInit>> {
|
||||
const id = options.id ?? nanoid()
|
||||
const bindingId = `${module.id}:${id}`
|
||||
const gamelets = await module.kits.use(gameletKit)
|
||||
|
||||
await gamelets.mount({
|
||||
bindingId,
|
||||
title: options.title,
|
||||
ui: gamelets.iframe({
|
||||
assetPath: options.devServerUrl === undefined ? options.indexPath : undefined,
|
||||
src: options.devServerUrl,
|
||||
sandbox: options.sandbox,
|
||||
}),
|
||||
init: options.init,
|
||||
})
|
||||
|
||||
const handle: GameletHandle<TInit> = {
|
||||
id,
|
||||
bindingId,
|
||||
open: async (payload?: HostDataRecord) => {
|
||||
await requireOrchestration(gamelets).open(bindingId, payload)
|
||||
},
|
||||
configure: async (payload: HostDataRecord) => {
|
||||
await requireOrchestration(gamelets).configure(bindingId, payload)
|
||||
},
|
||||
request: async <TResponse = HostDataRecord>(
|
||||
payload: HostDataRecord,
|
||||
options?: { timeoutMs?: number },
|
||||
): Promise<TResponse> => {
|
||||
return await requireOrchestration(gamelets).request<TResponse>(bindingId, payload, options)
|
||||
},
|
||||
close: async () => {
|
||||
await requireOrchestration(gamelets).close(bindingId)
|
||||
},
|
||||
isOpen: async () => await requireOrchestration(gamelets).isOpen(bindingId),
|
||||
}
|
||||
|
||||
module.subscriptions.add({
|
||||
async dispose() {
|
||||
await gamelets.orchestration?.close(bindingId)
|
||||
},
|
||||
})
|
||||
|
||||
if (options.init === undefined) {
|
||||
return handle
|
||||
}
|
||||
|
||||
return {
|
||||
...handle,
|
||||
init: options.init,
|
||||
}
|
||||
}
|
||||
|
||||
export { gameletKit }
|
||||
|
||||
function requireOrchestration(gamelets: Awaited<ReturnType<typeof gameletKit.createClient>>): NonNullable<typeof gamelets.orchestration> {
|
||||
if (!gamelets.orchestration) {
|
||||
throw new Error(GAMELET_RUNTIME_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
|
||||
return gamelets.orchestration
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ExtensionModuleRef } from '@proj-airi/plugin-sdk'
|
||||
|
||||
import type {
|
||||
PluginToolDefinition,
|
||||
PluginToolsetPromptRegistration,
|
||||
ToolsetPromptManifest,
|
||||
} from '../../tools'
|
||||
|
||||
import { toolKit } from '../../tools'
|
||||
|
||||
/**
|
||||
* Options used to register one Tamagotchi module toolset.
|
||||
*
|
||||
* @param TInputSchema Schema implementation accepted by each tool definition.
|
||||
*/
|
||||
export interface RegisterToolsOptions<TInputSchema = unknown> {
|
||||
/** Optional shared toolset prompt registered before any tools. */
|
||||
prompt?: PluginToolsetPromptRegistration | ToolsetPromptManifest
|
||||
/** Tool declarations registered in order through {@link toolKit}. */
|
||||
tools: Array<PluginToolDefinition<TInputSchema>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes shorthand toolset prompts into host registration input.
|
||||
*
|
||||
* Before:
|
||||
* - `{ id: "chess.prompt", content: "Prefer legal chess moves." }`
|
||||
*
|
||||
* After:
|
||||
* - `{ id: "chess.prompt", prompt: { id: "chess.prompt", content: "Prefer legal chess moves." } }`
|
||||
*/
|
||||
export function normalizePrompt(
|
||||
prompt: PluginToolsetPromptRegistration | ToolsetPromptManifest,
|
||||
): PluginToolsetPromptRegistration {
|
||||
if ('prompt' in prompt) {
|
||||
return prompt
|
||||
}
|
||||
|
||||
return {
|
||||
id: prompt.id,
|
||||
prompt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a module-scoped Tamagotchi toolset through the host tool kit.
|
||||
*
|
||||
* Use when:
|
||||
* - Extension modules need one helper to register a shared prompt and tools
|
||||
* - Tool authors want prompt registration to happen before tool registration
|
||||
*
|
||||
* Expects:
|
||||
* - `module` has access to {@link toolKit}
|
||||
* - `options.tools` contains schema-backed plugin tool definitions
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves after the optional prompt and all tools are registered
|
||||
*/
|
||||
export async function registerTools<TInputSchema = unknown>(
|
||||
module: ExtensionModuleRef,
|
||||
options: RegisterToolsOptions<TInputSchema>,
|
||||
): Promise<void> {
|
||||
const tools = await module.kits.use(toolKit)
|
||||
|
||||
if (options.prompt) {
|
||||
await tools.registerToolsetPrompt(normalizePrompt(options.prompt))
|
||||
}
|
||||
|
||||
for (const tool of options.tools) {
|
||||
await tools.registerTool(tool)
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
type PluginToolDefinition,
|
||||
type PluginToolsetPromptRegistration,
|
||||
toolKit,
|
||||
}
|
||||
@@ -1,70 +1,30 @@
|
||||
import type { ContextInit } from '@proj-airi/plugin-sdk'
|
||||
import type { HostDataRecord, ToolsetPromptManifest } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import type { KitClientRuntime } from '@proj-airi/plugin-sdk'
|
||||
import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import type { JsonSchema, Schema as StandardSchemaV1 } from 'xsschema'
|
||||
|
||||
import type {
|
||||
PluginToolDefinitionRecord,
|
||||
PluginToolsetPromptDefinitionRecord,
|
||||
ToolsetPromptManifest,
|
||||
} from './registry'
|
||||
|
||||
import { defineKit } from '@proj-airi/plugin-sdk'
|
||||
import { hostDataRecordSchema } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import { parse } from 'valibot'
|
||||
import { toJsonSchema } from 'xsschema'
|
||||
|
||||
/**
|
||||
* Describes the stage-tamagotchi gamelet API expected on `ctx.apis`.
|
||||
*
|
||||
* Use when:
|
||||
* - Tool execution wants to open, configure, close, or inspect host-managed gamelet surfaces
|
||||
* - Runtime validation needs a structural contract independent from `@proj-airi/plugin-sdk`
|
||||
*
|
||||
* Expects:
|
||||
* - The stage-tamagotchi host contribution installs `gamelets` on the plugin session API object
|
||||
*
|
||||
* Returns:
|
||||
* - The host-backed gamelet control surface exposed to tool callbacks
|
||||
*/
|
||||
export interface ToolExecutionGameletApi {
|
||||
open: (id: string, params?: HostDataRecord) => Promise<void>
|
||||
configure: (id: string, patch: HostDataRecord) => Promise<void>
|
||||
request: (id: string, payload: HostDataRecord, options?: { timeoutMs?: number }) => Promise<HostDataRecord>
|
||||
close: (id: string) => Promise<void>
|
||||
isOpen: (id: string) => Promise<boolean> | boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the tamagotchi-flavored plugin context accepted by {@link defineToolset}.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin host exposes tool registration plus the stage-owned `gamelets` surface
|
||||
* - Tests want to model the runtime shape without relying on baked-in SDK typing
|
||||
*
|
||||
* Expects:
|
||||
* - `apis.tools.register` is available
|
||||
* - `apis.gamelets` is installed by the stage-tamagotchi host contribution
|
||||
*
|
||||
* Returns:
|
||||
* - A context shape compatible with the tamagotchi tool helper
|
||||
*/
|
||||
export interface TamagotchiToolContext {
|
||||
apis: Pick<ContextInit['apis'], 'tools'> & {
|
||||
gamelets: ToolExecutionGameletApi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the host services available while checking or executing a plugin tool.
|
||||
*
|
||||
* Use when:
|
||||
* - Tool logic needs to orchestrate gamelet surfaces
|
||||
*
|
||||
* Expects:
|
||||
* - All methods are provided by the host runtime, not the plugin
|
||||
*
|
||||
* Returns:
|
||||
* - A runtime capability surface for tool execution
|
||||
*/
|
||||
export interface ToolExecutionContext {
|
||||
gamelets: ToolExecutionGameletApi
|
||||
|
||||
// TODO:
|
||||
// Add character/runtime orchestration APIs after the gamelet/tool path is stable.
|
||||
}
|
||||
export type {
|
||||
PluginToolDefinitionRecord,
|
||||
PluginToolsetPromptDefinitionRecord,
|
||||
RegisteredPluginToolDescriptor,
|
||||
SerializedToolsetPromptDefinition,
|
||||
SerializedXsaiToolDefinition,
|
||||
SerializedXsaiToolsetDefinition,
|
||||
ToolRegistryRecord,
|
||||
ToolsetPromptManifest,
|
||||
ToolsetPromptRegistryRecord,
|
||||
} from './registry'
|
||||
export { TamagotchiToolRegistry } from './registry'
|
||||
|
||||
/**
|
||||
* Describes renderer-side discovery hints for a plugin tool.
|
||||
@@ -93,7 +53,7 @@ export interface PluginToolActivationDefinition {
|
||||
* - `inputSchema` is either an xsschema-compatible schema or a prebuilt JSON Schema object
|
||||
*
|
||||
* Returns:
|
||||
* - A friendly authoring record consumed by {@link defineToolset}
|
||||
* - A friendly authoring record consumed by {@link ToolKitClient.registerTool}
|
||||
*/
|
||||
export interface PluginToolDefinition<TInputSchema = unknown> {
|
||||
id: string
|
||||
@@ -101,59 +61,49 @@ export interface PluginToolDefinition<TInputSchema = unknown> {
|
||||
description: string
|
||||
activation?: PluginToolActivationDefinition
|
||||
inputSchema: TInputSchema
|
||||
isAvailable?: (context: ToolExecutionContext) => Promise<boolean> | boolean
|
||||
execute: (input: unknown, context: ToolExecutionContext) => Promise<unknown> | unknown
|
||||
isAvailable?: () => Promise<boolean> | boolean
|
||||
execute: (input: unknown) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a set of plugin tools in one call.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin registers all of its tools during bootstrap
|
||||
*
|
||||
* Expects:
|
||||
* - `ctx.apis.tools.register` is available from the host
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves once every tool has been registered with the host
|
||||
* Describes one toolset prompt registration.
|
||||
*/
|
||||
export interface DefineToolsetOptions<TInputSchema = unknown> {
|
||||
id?: string
|
||||
prompt?: ToolsetPromptManifest
|
||||
tools: Array<PluginToolDefinition<TInputSchema>>
|
||||
export interface PluginToolsetPromptRegistration {
|
||||
id: string
|
||||
prompt: ToolsetPromptManifest
|
||||
}
|
||||
|
||||
function isToolExecutionGameletApi(value: unknown): value is ToolExecutionGameletApi {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
/**
|
||||
* Describes the module-scoped tool authoring client exposed by {@link toolKit}.
|
||||
*
|
||||
* @param TInputSchema - Schema implementation accepted by each tool definition.
|
||||
*/
|
||||
export interface ToolKitClient<TInputSchema = unknown> {
|
||||
/**
|
||||
* Registers one tool through the host-owned tool registry.
|
||||
*/
|
||||
registerTool: (definition: PluginToolDefinition<TInputSchema>) => Promise<void>
|
||||
|
||||
const candidate = value as Partial<Record<keyof ToolExecutionGameletApi, unknown>>
|
||||
|
||||
return typeof candidate.open === 'function'
|
||||
&& typeof candidate.configure === 'function'
|
||||
&& typeof candidate.request === 'function'
|
||||
&& typeof candidate.close === 'function'
|
||||
&& typeof candidate.isOpen === 'function'
|
||||
/**
|
||||
* Registers one toolset prompt through the host-owned tool registry.
|
||||
*/
|
||||
registerToolsetPrompt: (registration: PluginToolsetPromptRegistration) => Promise<void>
|
||||
}
|
||||
|
||||
function getToolExecutionGameletApi(
|
||||
ctx: Pick<ContextInit, 'apis'> | TamagotchiToolContext,
|
||||
): ToolExecutionGameletApi {
|
||||
const gamelets = (ctx.apis as Record<string, unknown>).gamelets
|
||||
|
||||
if (!isToolExecutionGameletApi(gamelets)) {
|
||||
throw new Error('stage-tamagotchi gamelet API is not available on `ctx.apis.gamelets`.')
|
||||
}
|
||||
|
||||
return gamelets
|
||||
}
|
||||
|
||||
function createToolExecutionContext(
|
||||
ctx: Pick<ContextInit, 'apis'> | TamagotchiToolContext,
|
||||
): ToolExecutionContext {
|
||||
return {
|
||||
gamelets: getToolExecutionGameletApi(ctx),
|
||||
/**
|
||||
* Describes host services required by the tool kit client.
|
||||
*/
|
||||
export interface ToolKitRuntime extends KitClientRuntime {
|
||||
/**
|
||||
* Host-owned tool registry operations.
|
||||
*/
|
||||
tools?: {
|
||||
register: (input: {
|
||||
tool: PluginToolDefinitionRecord
|
||||
availability?: () => Promise<boolean> | boolean
|
||||
execute: (input: unknown) => Promise<unknown> | unknown
|
||||
}) => Promise<void> | void
|
||||
registerToolsetPrompt: (input: PluginToolsetPromptDefinitionRecord) => Promise<void> | void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,48 +266,56 @@ async function serializeToolParameters(inputSchema: unknown): Promise<HostDataRe
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers one or more plugin tools with the tamagotchi host wrapper.
|
||||
* Exposes tamagotchi tool registration as a module-scoped extension kit.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin wants to declare xsai-compatible tools without low-level host records
|
||||
* - An extension module wants to register tools through `module.kits.use(toolKit)`
|
||||
* - The host should keep tool transport, permission, and binding details outside authoring code
|
||||
*
|
||||
* Expects:
|
||||
* - The caller supplies stable tool ids and schemas
|
||||
* - The host provides tool registry APIs when creating the kit client
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves after all tool registrations complete
|
||||
* - A client that registers LLM tools without depending on domain-specific kits
|
||||
*/
|
||||
export async function defineToolset(
|
||||
ctx: Pick<ContextInit, 'apis'> | TamagotchiToolContext,
|
||||
options: DefineToolsetOptions,
|
||||
): Promise<void> {
|
||||
const executionContext = createToolExecutionContext(ctx)
|
||||
export const toolKit = defineKit<ToolKitClient>({
|
||||
id: 'kit.tool',
|
||||
version: '1.0.0',
|
||||
allowedExposePolicies: ['local-only', 'remote-observable'],
|
||||
defaultExposePolicy: 'local-only',
|
||||
createClient(runtime) {
|
||||
const toolRuntime = runtime as ToolKitRuntime
|
||||
|
||||
if (options.prompt) {
|
||||
await ctx.apis.tools.registerToolsetPrompt({
|
||||
id: options.id ?? options.prompt.id,
|
||||
prompt: options.prompt,
|
||||
})
|
||||
}
|
||||
return {
|
||||
async registerTool(definition) {
|
||||
if (!toolRuntime.tools) {
|
||||
throw new Error('toolKit requires a host tool registry runtime.')
|
||||
}
|
||||
|
||||
for (const definition of options.tools) {
|
||||
const isAvailable = definition.isAvailable
|
||||
const isAvailable = definition.isAvailable
|
||||
|
||||
await ctx.apis.tools.register({
|
||||
tool: {
|
||||
id: definition.id,
|
||||
title: definition.title,
|
||||
description: definition.description,
|
||||
activation: {
|
||||
keywords: definition.activation?.keywords ?? [],
|
||||
patterns: (definition.activation?.patterns ?? []).map(pattern => pattern.source),
|
||||
},
|
||||
parameters: await serializeToolParameters(definition.inputSchema),
|
||||
await toolRuntime.tools.register({
|
||||
tool: {
|
||||
id: definition.id,
|
||||
title: definition.title,
|
||||
description: definition.description,
|
||||
activation: {
|
||||
keywords: definition.activation?.keywords ?? [],
|
||||
patterns: (definition.activation?.patterns ?? []).map(pattern => pattern.source),
|
||||
},
|
||||
parameters: await serializeToolParameters(definition.inputSchema),
|
||||
},
|
||||
availability: isAvailable,
|
||||
execute: definition.execute,
|
||||
})
|
||||
},
|
||||
availability: isAvailable
|
||||
? () => isAvailable(executionContext)
|
||||
: undefined,
|
||||
execute: input => definition.execute(input, executionContext),
|
||||
})
|
||||
}
|
||||
}
|
||||
async registerToolsetPrompt(registration) {
|
||||
if (!toolRuntime.tools) {
|
||||
throw new Error('toolKit requires a host tool registry runtime.')
|
||||
}
|
||||
|
||||
await toolRuntime.tools.registerToolsetPrompt(registration)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
/**
|
||||
* Describes the user-facing metadata for a Tamagotchi extension tool.
|
||||
*/
|
||||
export interface RegisteredPluginToolDescriptor {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
activation: {
|
||||
keywords: string[]
|
||||
patterns: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the JSON-schema side of an xsai-compatible Tamagotchi extension tool.
|
||||
*/
|
||||
export interface SerializedXsaiToolDefinition {
|
||||
ownerPluginId: string
|
||||
name: string
|
||||
description: string
|
||||
parameters: HostDataRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes model-facing guidance shared by every tool in one toolset.
|
||||
*/
|
||||
export interface ToolsetPromptManifest {
|
||||
id: string
|
||||
title?: string
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures one registered toolset prompt with extension ownership metadata.
|
||||
*/
|
||||
export interface SerializedToolsetPromptDefinition {
|
||||
ownerPluginId: string
|
||||
id: string
|
||||
prompt: ToolsetPromptManifest
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundles xsai tools with their shared toolset prompt contributions.
|
||||
*/
|
||||
export interface SerializedXsaiToolsetDefinition {
|
||||
tools: SerializedXsaiToolDefinition[]
|
||||
prompts: SerializedToolsetPromptDefinition[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the single source-of-truth definition submitted by a Tamagotchi extension.
|
||||
*/
|
||||
export interface PluginToolDefinitionRecord {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
activation: {
|
||||
keywords: string[]
|
||||
patterns: string[]
|
||||
}
|
||||
parameters: HostDataRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures an extension-owned prompt shared by a toolset.
|
||||
*/
|
||||
export interface PluginToolsetPromptDefinitionRecord {
|
||||
id: string
|
||||
prompt: ToolsetPromptManifest
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores one Tamagotchi extension tool registration inside the host runtime.
|
||||
*/
|
||||
export interface ToolRegistryRecord {
|
||||
ownerSessionId: string
|
||||
ownerPluginId: string
|
||||
ownerModuleId?: string
|
||||
tool: PluginToolDefinitionRecord
|
||||
availability?: () => Promise<boolean> | boolean
|
||||
execute: (input: unknown) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores one Tamagotchi extension toolset prompt registration inside the host runtime.
|
||||
*/
|
||||
export interface ToolsetPromptRegistryRecord {
|
||||
ownerSessionId: string
|
||||
ownerPluginId: string
|
||||
ownerModuleId?: string
|
||||
toolset: PluginToolsetPromptDefinitionRecord
|
||||
availability?: () => Promise<boolean> | boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory registry for Tamagotchi extension tools.
|
||||
*
|
||||
* Use when:
|
||||
* - A Tamagotchi host needs to list extension tools for UI and xsai consumers
|
||||
* - A Tamagotchi host needs to dispatch a tool invocation back to its owning extension
|
||||
*
|
||||
* Expects:
|
||||
* - Callers provide extension session and optional module ownership during registration
|
||||
*
|
||||
* Returns:
|
||||
* - Serializable metadata views and invoke routing
|
||||
*/
|
||||
export class TamagotchiToolRegistry {
|
||||
private readonly tools = new Map<string, ToolRegistryRecord>()
|
||||
private readonly toolsetPrompts = new Map<string, ToolsetPromptRegistryRecord>()
|
||||
|
||||
register(record: ToolRegistryRecord) {
|
||||
const key = `${record.ownerPluginId}:${record.tool.id}`
|
||||
this.tools.set(key, record)
|
||||
return record
|
||||
}
|
||||
|
||||
registerToolsetPrompt(record: ToolsetPromptRegistryRecord) {
|
||||
const key = `${record.ownerPluginId}:${record.toolset.id}`
|
||||
this.toolsetPrompts.set(key, record)
|
||||
return record
|
||||
}
|
||||
|
||||
unregister(ownerPluginId: string, toolId: string) {
|
||||
return this.tools.delete(`${ownerPluginId}:${toolId}`)
|
||||
}
|
||||
|
||||
unregisterToolsetPrompt(ownerPluginId: string, toolsetId: string) {
|
||||
return this.toolsetPrompts.delete(`${ownerPluginId}:${toolsetId}`)
|
||||
}
|
||||
|
||||
unregisterOwnerSession(ownerSessionId: string) {
|
||||
for (const [key, record] of this.tools) {
|
||||
if (record.ownerSessionId === ownerSessionId) {
|
||||
this.tools.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, record] of this.toolsetPrompts) {
|
||||
if (record.ownerSessionId === ownerSessionId) {
|
||||
this.toolsetPrompts.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unregisterOwnerScope(ownerSessionId: string, ownerModuleId?: string) {
|
||||
for (const [key, record] of this.tools) {
|
||||
if (record.ownerSessionId === ownerSessionId && record.ownerModuleId === ownerModuleId) {
|
||||
this.tools.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, record] of this.toolsetPrompts) {
|
||||
if (record.ownerSessionId === ownerSessionId && record.ownerModuleId === ownerModuleId) {
|
||||
this.toolsetPrompts.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.tools.clear()
|
||||
this.toolsetPrompts.clear()
|
||||
}
|
||||
|
||||
async listAvailableDescriptors() {
|
||||
const items: RegisteredPluginToolDescriptor[] = []
|
||||
|
||||
for (const record of this.tools.values()) {
|
||||
if (await record.availability?.() === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: record.tool.id,
|
||||
title: record.tool.title,
|
||||
description: record.tool.description,
|
||||
activation: {
|
||||
keywords: [...record.tool.activation.keywords],
|
||||
patterns: [...record.tool.activation.patterns],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
async listToolsetPrompts() {
|
||||
const prompts: SerializedToolsetPromptDefinition[] = []
|
||||
|
||||
for (const record of this.toolsetPrompts.values()) {
|
||||
if (await record.availability?.() === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
prompts.push({
|
||||
ownerPluginId: record.ownerPluginId,
|
||||
id: record.toolset.id,
|
||||
prompt: structuredClone(record.toolset.prompt),
|
||||
})
|
||||
}
|
||||
|
||||
return prompts
|
||||
}
|
||||
|
||||
async listSerializedXsaiTools(): Promise<SerializedXsaiToolsetDefinition> {
|
||||
const items: SerializedXsaiToolDefinition[] = []
|
||||
|
||||
for (const record of this.tools.values()) {
|
||||
if (await record.availability?.() === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
items.push({
|
||||
ownerPluginId: record.ownerPluginId,
|
||||
name: record.tool.id,
|
||||
description: record.tool.description,
|
||||
parameters: structuredClone(record.tool.parameters),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
prompts: await this.listToolsetPrompts(),
|
||||
tools: items,
|
||||
}
|
||||
}
|
||||
|
||||
async invoke(ownerPluginId: string, toolId: string, input: unknown) {
|
||||
const key = `${ownerPluginId}:${toolId}`
|
||||
const record = this.tools.get(key)
|
||||
if (!record) {
|
||||
throw new Error(`Tamagotchi extension tool not found: ${key}`)
|
||||
}
|
||||
|
||||
return await record.execute(input)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export default defineConfig({
|
||||
'src/index.ts',
|
||||
'src/widgets/index.ts',
|
||||
'src/gamelet/index.ts',
|
||||
'src/kits/gamelet/index.ts',
|
||||
'src/kits/tool/index.ts',
|
||||
'src/tools/index.ts',
|
||||
],
|
||||
dts: true,
|
||||
|
||||
@@ -1,3 +1,66 @@
|
||||
# @proj-airi/plugin-sdk
|
||||
|
||||
Runtime-agnostic SDK for AIRI plugins.
|
||||
Runtime-agnostic SDK for AIRI extensions.
|
||||
|
||||
## Kit API Naming
|
||||
|
||||
Kits should hide transport details from extension authors. A normal extension should use a kit as a normal API object directly from setup:
|
||||
|
||||
```ts
|
||||
const gamelets = await ctx.kits.use(gameletKit)
|
||||
await gamelets.mount(input)
|
||||
```
|
||||
|
||||
Explicit module scopes are an advanced lifecycle and attribution API. Use `module.kits.use(...)` only when the host needs a contribution to be associated with a sub-scope that may later be inspected, disposed, or restarted independently.
|
||||
|
||||
When a kit needs to work across process or network boundaries, expose shared Eventa invoke contracts from the kit package and build the client from those contracts. Do not introduce kit-specific transport method names such as `invokeGamelet`, `gameletRpc`, or `gameletRuntime`.
|
||||
|
||||
Use these names consistently:
|
||||
|
||||
| Name | Meaning |
|
||||
| --- | --- |
|
||||
| `gameletKitApis` | Shared Eventa API contract exported by the kit package. This is usually a map of `defineInvokeEventa(...)` entries. |
|
||||
| `gameletKitService` | Host-side implementation of the kit behavior. It owns real side effects such as mounting, updating, and cleaning up UI. |
|
||||
| `gameletKit` | The kit definition consumed by `ctx.kits.use(...)` or an optional module scope. It owns identity, version, availability policy, and client creation. |
|
||||
| `gamelets` | The client instance returned to extension authors. Prefer a plural namespace when the client exposes multiple operations. |
|
||||
| `createGameletKit(...)` | Factory that wires dependencies into `gameletKit`, including local client creation and remote Eventa-backed client creation. |
|
||||
|
||||
Example shape:
|
||||
|
||||
```ts
|
||||
export const gameletKitApis = {
|
||||
mount: defineInvokeEventa<GameletMountResult, GameletMountInput>(
|
||||
'airi:kit:gamelet:mount',
|
||||
),
|
||||
}
|
||||
|
||||
export interface GameletKitService {
|
||||
mount: (input: GameletMountInput, scope: KitCallScope) => Promise<GameletMountResult>
|
||||
}
|
||||
|
||||
export function createGameletKit(options: { service: GameletKitService }) {
|
||||
return defineKit<GameletClient>({
|
||||
id: 'kit.gamelet',
|
||||
version: '1.0.0',
|
||||
createClient(runtime) {
|
||||
return {
|
||||
mount: input => options.service.mount(input, runtime),
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Remote clients should reuse Eventa invoke instead of defining a parallel RPC protocol. Use a lazy context callback when the underlying transport can reconnect or be created after the client object:
|
||||
|
||||
```ts
|
||||
const mount = defineInvoke(getContext, gameletKitApis.mount)
|
||||
|
||||
const gamelets = {
|
||||
mount(input: GameletMountInput) {
|
||||
return mount(input, scope)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The shared artifact is the Eventa API contract, not the implementation function. Local clients may call `gameletKitService` directly; remote clients call the same API through Eventa. Both should expose the same authoring shape.
|
||||
|
||||
@@ -44,7 +44,7 @@ Eventa is context-oriented: contexts are created per transport (in-memory, WebSo
|
||||
|
||||
- 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.
|
||||
- Defining extension packaging or distribution formats beyond `ExtensionManifestV1` entrypoints.
|
||||
|
||||
## Proposal
|
||||
|
||||
|
||||
@@ -43,10 +43,8 @@
|
||||
"@moeru/eventa": "catalog:",
|
||||
"@moeru/std": "catalog:",
|
||||
"@proj-airi/plugin-protocol": "workspace:*",
|
||||
"@proj-airi/server-shared": "workspace:*",
|
||||
"nanoid": "catalog:",
|
||||
"valibot": "catalog:",
|
||||
"xstate": "catalog:"
|
||||
"valibot": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"es-toolkit": "catalog:"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createExtensionChannelScope, createModuleChannelScope } from './index'
|
||||
|
||||
describe('scoped extension channels', () => {
|
||||
it('creates independent extension and module scopes over the same context', () => {
|
||||
const extension = createExtensionChannelScope({
|
||||
extensionId: 'airi-extension-test',
|
||||
sessionId: 'session-1',
|
||||
})
|
||||
const module = createModuleChannelScope(extension, {
|
||||
moduleId: 'module-a',
|
||||
})
|
||||
|
||||
expect(extension.identity.id).toBe('airi-extension-test')
|
||||
expect(extension.identity.sessionId).toBe('session-1')
|
||||
expect(module.identity.id).toBe('module-a')
|
||||
expect(module.identity.extension).toEqual(extension.identity)
|
||||
expect(module.context).toBe(extension.context)
|
||||
})
|
||||
})
|
||||
@@ -1,67 +1,84 @@
|
||||
import type { EventContext } from '@moeru/eventa'
|
||||
import type {
|
||||
ExtensionIdentity,
|
||||
ExtensionModuleIdentity,
|
||||
} from '@proj-airi/plugin-protocol/types'
|
||||
|
||||
import { createContext } from '@moeru/eventa'
|
||||
|
||||
/**
|
||||
* Holds the active plugin-sdk channel contexts for the current process.
|
||||
*
|
||||
* Use when:
|
||||
* - Bootstrapping local or remote plugin transports
|
||||
* - Reading the current control-plane or data-plane Eventa context
|
||||
*
|
||||
* Expects:
|
||||
* - Callers replace the fallback contexts with a concrete transport during startup
|
||||
*
|
||||
* Returns:
|
||||
* - Mutable host and data channel references shared by the SDK runtime
|
||||
* Describes one extension-scoped Eventa channel context.
|
||||
*/
|
||||
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 interface ExtensionChannelScope {
|
||||
/** Extension session identity associated with this scope. */
|
||||
identity: ExtensionIdentity
|
||||
/** Eventa context that carries scoped extension/module traffic. */
|
||||
context: EventContext<any, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the active control-plane channel used to talk to Plugin Host.
|
||||
*
|
||||
* Use when:
|
||||
* - A runtime has created its concrete host transport context
|
||||
*
|
||||
* Expects:
|
||||
* - `context` is compatible with the current plugin transport implementation
|
||||
*
|
||||
* Returns:
|
||||
* - Nothing. Future reads from {@link channels}.host use the provided context.
|
||||
* Describes one module-scoped Eventa channel context.
|
||||
*/
|
||||
export function setActiveHostChannel(context: EventContext<any, any>) {
|
||||
channels.host = context
|
||||
export interface ModuleChannelScope {
|
||||
/** Module identity associated with this scope. */
|
||||
identity: ExtensionModuleIdentity
|
||||
/** Eventa context shared with the owning extension scope. */
|
||||
context: EventContext<any, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the active data-plane channel used for plugin-to-plugin or stage messaging.
|
||||
* Creates an extension-scoped channel context.
|
||||
*
|
||||
* Use when:
|
||||
* - A runtime has created its concrete data transport context
|
||||
* - A host or transport adapter starts one extension session
|
||||
* - Code needs identity metadata attached beside the Eventa context
|
||||
*
|
||||
* Expects:
|
||||
* - `context` is compatible with the current plugin transport implementation
|
||||
* - `extensionId` is the stable extension id
|
||||
* - `context` is already bound to the desired transport when provided
|
||||
*
|
||||
* Returns:
|
||||
* - Nothing. Future reads from {@link channels}.data use the provided context.
|
||||
* - Extension identity plus the Eventa context used by child module scopes
|
||||
*/
|
||||
export function setActiveDataChannel(context: EventContext<any, any>) {
|
||||
channels.data = context
|
||||
export function createExtensionChannelScope(input: {
|
||||
extensionId: string
|
||||
sessionId?: string
|
||||
version?: string
|
||||
context?: EventContext<any, any>
|
||||
}): ExtensionChannelScope {
|
||||
return {
|
||||
identity: {
|
||||
id: input.extensionId,
|
||||
sessionId: input.sessionId,
|
||||
version: input.version,
|
||||
},
|
||||
context: input.context ?? createContext(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a module-scoped channel context from an extension scope.
|
||||
*
|
||||
* Use when:
|
||||
* - An extension registers a module that needs scoped protocol identity
|
||||
*
|
||||
* Expects:
|
||||
* - `extension` is the owning extension channel scope
|
||||
* - `moduleId` is stable within that extension session
|
||||
*
|
||||
* Returns:
|
||||
* - Module identity plus the same Eventa context used by the extension
|
||||
*/
|
||||
export function createModuleChannelScope(
|
||||
extension: ExtensionChannelScope,
|
||||
input: { moduleId: string, labels?: Record<string, string> },
|
||||
): ModuleChannelScope {
|
||||
return {
|
||||
identity: {
|
||||
id: input.moduleId,
|
||||
extension: extension.identity,
|
||||
labels: input.labels,
|
||||
},
|
||||
context: extension.context,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,22 @@ export function createEventTargetHostChannel(eventTarget: EventTarget) {
|
||||
return createContext(eventTarget)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an extension Eventa transport backed by a local `EventTarget`.
|
||||
*
|
||||
* Use when:
|
||||
* - A web-like host bridges extension traffic through an in-process event target
|
||||
*
|
||||
* Expects:
|
||||
* - `eventTarget` dispatches and listens for the Eventa adapter event format
|
||||
*
|
||||
* Returns:
|
||||
* - An Eventa context ready to pass into `createExtensionChannelScope`
|
||||
*/
|
||||
export function createEventTargetExtensionTransport(eventTarget: EventTarget) {
|
||||
return createContext(eventTarget)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a data-plane Eventa context backed by a local `EventTarget`.
|
||||
*
|
||||
|
||||
@@ -17,6 +17,22 @@ export function createWebSocketHostChannel(webSocket: WebSocket) {
|
||||
return createContext(webSocket)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an extension Eventa transport backed by a native `WebSocket`.
|
||||
*
|
||||
* Use when:
|
||||
* - A peer transport carries extension protocol and invoke traffic over websocket
|
||||
*
|
||||
* Expects:
|
||||
* - `webSocket` is already connected and managed by the caller
|
||||
*
|
||||
* Returns:
|
||||
* - An Eventa context ready to pass into `createExtensionChannelScope`
|
||||
*/
|
||||
export function createWebSocketExtensionTransport(webSocket: WebSocket) {
|
||||
return createContext(webSocket)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a data-plane Eventa context backed by a native `WebSocket`.
|
||||
*
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { EventContext } from '@moeru/eventa'
|
||||
* Describes the control-plane Eventa context used between a plugin and its host.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing `ContextInit.channels.host`
|
||||
* - Typing host-backed extension channels
|
||||
* - Passing a host-backed Eventa context through plugin bootstrap code
|
||||
*
|
||||
* Expects:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Extension } from './shared'
|
||||
|
||||
/**
|
||||
* Defines an AIRI extension entrypoint.
|
||||
*
|
||||
* Use when:
|
||||
* - Authoring an extension package that runs setup code and may use host-provided kits
|
||||
* - Keeping extension metadata and setup logic in one explicit export
|
||||
*
|
||||
* Expects:
|
||||
* - `id` matches `extension.airi.json`
|
||||
* - `setup` uses `ctx.kits` for the common kit authoring path
|
||||
*
|
||||
* Returns:
|
||||
* - The extension definition consumed by an extension host loader
|
||||
*/
|
||||
export function defineExtension(extension: Extension): Extension {
|
||||
return extension
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Describes a disposable runtime resource owned by an extension or module.
|
||||
*/
|
||||
export interface Disposable {
|
||||
/** Releases the resource. */
|
||||
dispose: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores disposable resources and releases them in reverse registration order.
|
||||
*
|
||||
* Use when:
|
||||
* - Extension setup needs to collect session-level cleanup callbacks
|
||||
* - Module registration needs scoped cleanup for watches, subscriptions, and bindings
|
||||
*
|
||||
* Expects:
|
||||
* - Disposables are independent or tolerate reverse-order teardown
|
||||
*
|
||||
* Returns:
|
||||
* - A disposable store that can be awaited during host cleanup
|
||||
*/
|
||||
export class DisposableStore implements Disposable {
|
||||
private readonly disposables: Disposable[] = []
|
||||
private disposed = false
|
||||
|
||||
add(disposable: Disposable) {
|
||||
if (this.disposed) {
|
||||
void disposable.dispose()
|
||||
return disposable
|
||||
}
|
||||
|
||||
this.disposables.push(disposable)
|
||||
return disposable
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.disposed = true
|
||||
for (const disposable of [...this.disposables].reverse()) {
|
||||
await disposable.dispose()
|
||||
}
|
||||
this.disposables.length = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { ExtensionModuleContext, ExtensionSetupContext } from './index'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createModule, defineExtension, DisposableStore } from './index'
|
||||
|
||||
function createTestExtensionContext(register: ExtensionSetupContext['modules']['register']): ExtensionSetupContext {
|
||||
return {
|
||||
extension: { id: 'extension-test', sessionId: 'session-1', version: '1.0.0' },
|
||||
subscriptions: new DisposableStore(),
|
||||
kits: { use: vi.fn(), tryUse: vi.fn(), watch: vi.fn() },
|
||||
modules: { register },
|
||||
}
|
||||
}
|
||||
|
||||
function createTestModule(id: string, dispose = vi.fn(async () => {})): ExtensionModuleContext {
|
||||
return {
|
||||
id,
|
||||
identity: {
|
||||
id,
|
||||
extension: {
|
||||
id: 'extension-test',
|
||||
sessionId: 'session-1',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
permissions: {},
|
||||
kits: { use: vi.fn(), tryUse: vi.fn(), watch: vi.fn() },
|
||||
subscriptions: new DisposableStore(),
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
describe('defineExtension', () => {
|
||||
it('defines an extension with setup and module registration context', async () => {
|
||||
const setup = vi.fn(async () => {})
|
||||
const extension = defineExtension({
|
||||
id: 'airi-extension-test',
|
||||
version: '1.0.0',
|
||||
setup,
|
||||
})
|
||||
|
||||
expect(extension.id).toBe('airi-extension-test')
|
||||
expect(extension.version).toBe('1.0.0')
|
||||
|
||||
const subscriptions = new DisposableStore()
|
||||
await extension.setup({
|
||||
extension: {
|
||||
id: extension.id,
|
||||
version: extension.version,
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
subscriptions,
|
||||
kits: {
|
||||
use: vi.fn(),
|
||||
tryUse: vi.fn(),
|
||||
watch: vi.fn(),
|
||||
},
|
||||
modules: {
|
||||
register: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
expect(setup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createModule', () => {
|
||||
it('registers a module with an explicit id and returns a narrow module ref', async () => {
|
||||
const dispose = vi.fn(async () => {})
|
||||
const module = createTestModule('module-explicit', dispose)
|
||||
const register = vi.fn(async () => module)
|
||||
const ctx = createTestExtensionContext(register)
|
||||
|
||||
const ref = await createModule(ctx, { id: 'module-explicit' })
|
||||
|
||||
expect(register).toHaveBeenCalledWith({ id: 'module-explicit' })
|
||||
expect(ref).toStrictEqual({
|
||||
id: 'module-explicit',
|
||||
kits: module.kits,
|
||||
subscriptions: module.subscriptions,
|
||||
dispose: expect.any(Function),
|
||||
})
|
||||
expect(ref).not.toHaveProperty('identity')
|
||||
expect(ref).not.toHaveProperty('permissions')
|
||||
expect(ref.dispose).not.toBe(module.dispose)
|
||||
|
||||
await ctx.subscriptions.dispose()
|
||||
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('disposes the underlying module once when the returned ref and setup context are both disposed', async () => {
|
||||
const dispose = vi.fn(async () => {})
|
||||
const module = createTestModule('module-idempotent', dispose)
|
||||
const register = vi.fn(async () => module)
|
||||
const ctx = createTestExtensionContext(register)
|
||||
|
||||
const ref = await createModule(ctx, { id: 'module-idempotent' })
|
||||
|
||||
await ref.dispose()
|
||||
await ctx.subscriptions.dispose()
|
||||
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('waits for the in-flight module dispose when the returned ref and setup context dispose concurrently', async () => {
|
||||
let resolveDispose!: () => void
|
||||
const controlledDispose = new Promise<void>((resolve) => {
|
||||
resolveDispose = resolve
|
||||
})
|
||||
const dispose = vi.fn(() => controlledDispose)
|
||||
const module = createTestModule('module-concurrent', dispose)
|
||||
const register = vi.fn(async () => module)
|
||||
const ctx = createTestExtensionContext(register)
|
||||
|
||||
const ref = await createModule(ctx, { id: 'module-concurrent' })
|
||||
|
||||
const refDispose = ref.dispose()
|
||||
let ctxDisposeCompleted = false
|
||||
const ctxDispose = ctx.subscriptions.dispose().then(() => {
|
||||
ctxDisposeCompleted = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
expect(ctxDisposeCompleted).toBe(false)
|
||||
|
||||
resolveDispose()
|
||||
await Promise.all([refDispose, ctxDispose])
|
||||
|
||||
expect(ctxDisposeCompleted).toBe(true)
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('registers a module with a generated id when no explicit id is provided', async () => {
|
||||
const register = vi.fn(async ({ id }) => createTestModule(id))
|
||||
const ctx = createTestExtensionContext(register)
|
||||
|
||||
const ref = await createModule(ctx)
|
||||
|
||||
expect(register).toHaveBeenCalledTimes(1)
|
||||
expect(register.mock.calls[0]?.[0].id).toEqual(expect.any(String))
|
||||
expect(register.mock.calls[0]?.[0].id).not.toBe('')
|
||||
expect(ref.id).toBe(register.mock.calls[0]?.[0].id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ExtensionModuleRef, ExtensionSetupContext } from './shared'
|
||||
|
||||
import { nanoid } from 'nanoid/non-secure'
|
||||
|
||||
export * from './define'
|
||||
export * from './disposable'
|
||||
export type * from './shared'
|
||||
|
||||
/**
|
||||
* Creates a module scope owned by the current extension setup.
|
||||
*
|
||||
* Use when:
|
||||
* - A contribution needs module-scoped kits or cleanup
|
||||
* - The extension should dispose the module with the setup session
|
||||
*
|
||||
* Expects:
|
||||
* - `ctx` comes from the active extension setup call
|
||||
* - `options.id`, when provided, is stable within the extension session
|
||||
*
|
||||
* Returns:
|
||||
* - A narrow module reference that hides host identity and permission internals
|
||||
*/
|
||||
export async function createModule(
|
||||
ctx: ExtensionSetupContext,
|
||||
options: { id?: string } = {},
|
||||
): Promise<ExtensionModuleRef> {
|
||||
const id = options.id ?? nanoid()
|
||||
const module = await ctx.modules.register({ id })
|
||||
let disposePromise: Promise<void> | undefined
|
||||
const dispose = () => {
|
||||
disposePromise ??= module.dispose()
|
||||
return disposePromise
|
||||
}
|
||||
|
||||
ctx.subscriptions.add({ dispose })
|
||||
|
||||
return {
|
||||
id: module.id,
|
||||
kits: module.kits,
|
||||
subscriptions: module.subscriptions,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
ExtensionIdentity,
|
||||
ExtensionModuleIdentity,
|
||||
ModulePermissionDeclaration,
|
||||
ModulePermissionGrant,
|
||||
} from '@proj-airi/plugin-protocol/types'
|
||||
|
||||
import type { KitAvailability, KitRef, KitUseResult } from '../kit'
|
||||
import type { Disposable, DisposableStore } from './disposable'
|
||||
|
||||
/**
|
||||
* Describes an optional advanced lifecycle/attribution scope inside an extension session.
|
||||
*/
|
||||
export interface RegisterExtensionModuleInput {
|
||||
/** Stable module id within the current extension session. */
|
||||
id: string
|
||||
/**
|
||||
* Runtime permissions this module actually intends to use.
|
||||
*
|
||||
* The host intersects these requests with the extension manifest grant, so a
|
||||
* module can never widen access beyond the package/session-level ceiling.
|
||||
*/
|
||||
permissions?: ModulePermissionDeclaration
|
||||
/** Optional labels used for routing, policy, and inspection. */
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal kit client registry exposed to extension setup and optional module scopes.
|
||||
*/
|
||||
export interface ExtensionKitRegistry {
|
||||
use: <TClient>(kit: KitRef<TClient>) => Promise<TClient>
|
||||
tryUse: <TClient>(kit: KitRef<TClient>) => Promise<KitUseResult<TClient>>
|
||||
watch: <TClient>(
|
||||
kit: KitRef<TClient>,
|
||||
callback: (availability: KitAvailability<TClient>) => void | Promise<void>,
|
||||
) => Disposable
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime context returned from module registration.
|
||||
*/
|
||||
export interface ExtensionModuleContext {
|
||||
/** Stable module id within the current extension session. */
|
||||
id: string
|
||||
/** Protocol identity for this module. */
|
||||
identity: ExtensionModuleIdentity
|
||||
/** Effective grant after applying the extension-level permission ceiling. */
|
||||
permissions: ModulePermissionGrant
|
||||
/** Module-scoped kit access for attribution and optional lifecycle cleanup. */
|
||||
kits: ExtensionKitRegistry
|
||||
/** Cleanup callbacks owned by this module. */
|
||||
subscriptions: DisposableStore
|
||||
/** Disposes module-owned resources. */
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow module reference exposed to extension authors.
|
||||
*/
|
||||
export interface ExtensionModuleRef {
|
||||
/** Stable module id within the current extension session. */
|
||||
id: string
|
||||
/** Module-scoped kit access for attribution and optional lifecycle cleanup. */
|
||||
kits: ExtensionKitRegistry
|
||||
/** Cleanup callbacks owned by this module. */
|
||||
subscriptions: DisposableStore
|
||||
/** Disposes module-owned resources. */
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional module scope API exposed during extension setup.
|
||||
*
|
||||
* Modules are not required for basic kit usage. Use them when a host needs a
|
||||
* contribution to have its own cleanup, inspection, or future restart boundary.
|
||||
*/
|
||||
export interface ExtensionModuleRegistry {
|
||||
register: (input: RegisterExtensionModuleInput) => Promise<ExtensionModuleContext>
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-provided setup context for one extension session.
|
||||
*/
|
||||
export interface ExtensionSetupContext {
|
||||
/** Current extension session identity. */
|
||||
extension: ExtensionIdentity
|
||||
/** Extension-session cleanup callbacks. */
|
||||
subscriptions: DisposableStore
|
||||
/** Extension-scoped kit access for the common authoring path. */
|
||||
kits: ExtensionKitRegistry
|
||||
/** Optional advanced lifecycle/attribution scopes. */
|
||||
modules: ExtensionModuleRegistry
|
||||
}
|
||||
|
||||
/**
|
||||
* Public extension authoring contract returned by `defineExtension`.
|
||||
*/
|
||||
export interface Extension {
|
||||
/** Stable extension id from the manifest/package. */
|
||||
id: string
|
||||
/** Optional extension package version. */
|
||||
version?: string
|
||||
/** Runs extension initialization. */
|
||||
setup: (ctx: ExtensionSetupContext) => Promise<void> | void
|
||||
}
|
||||
@@ -1,16 +1,5 @@
|
||||
console.warn('@proj-airi/plugin-sdk is currently working in progress. APIs may change without warning.')
|
||||
|
||||
export * from './extension'
|
||||
export * from './kit'
|
||||
export * from './plugin'
|
||||
/**
|
||||
* Re-exports the plugin bootstrap contracts from the package root.
|
||||
*
|
||||
* Use when:
|
||||
* - Consumers want the high-level plugin authoring types from `@proj-airi/plugin-sdk`
|
||||
*
|
||||
* Expects:
|
||||
* - Downstream code imports from the package root instead of the internal path
|
||||
*
|
||||
* Returns:
|
||||
* - The `ContextInit` and `Plugin` types from `./plugin/shared`
|
||||
*/
|
||||
export type { ContextInit, Plugin } from './plugin/shared'
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Error raised when a module cannot use a requested kit.
|
||||
*/
|
||||
export class KitUnavailableError extends Error {
|
||||
constructor(
|
||||
readonly kitId: string,
|
||||
readonly reason: 'missing-kit' | 'permission-denied' | 'incompatible-version' | 'not-ready',
|
||||
) {
|
||||
super(`Kit \`${kitId}\` is unavailable: ${reason}.`)
|
||||
this.name = 'KitUnavailableError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { DisposableStore } from '../extension/disposable'
|
||||
import { defineKit, kitUseFailure } from './index'
|
||||
|
||||
describe('defineKit', () => {
|
||||
it('defines a typed kit reference with expose policy metadata', () => {
|
||||
const kit = defineKit({
|
||||
id: 'kit.test',
|
||||
version: '1.0.0',
|
||||
allowedExposePolicies: ['local-only', 'remote-observable'],
|
||||
defaultExposePolicy: 'local-only',
|
||||
createClient: runtime => ({
|
||||
identity: `${runtime.extensionId}:${runtime.moduleId}`,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(kit.id).toBe('kit.test')
|
||||
expect(kit.defaultExposePolicy).toBe('local-only')
|
||||
expect(kit.createClient({
|
||||
extensionId: 'extension-a',
|
||||
sessionId: 'session-a',
|
||||
moduleId: 'module-a',
|
||||
subscriptions: new DisposableStore(),
|
||||
}).identity).toBe('extension-a:module-a')
|
||||
})
|
||||
|
||||
it('creates typed kit use failures', () => {
|
||||
const kit = defineKit({
|
||||
id: 'kit.missing',
|
||||
version: '1.0.0',
|
||||
createClient: () => ({}),
|
||||
})
|
||||
|
||||
const result = kitUseFailure(kit, 'missing-kit')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) {
|
||||
throw new Error('Expected kit use to fail.')
|
||||
}
|
||||
expect(result.reason).toBe('missing-kit')
|
||||
expect(result.error.message).toContain('kit.missing')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Disposable, DisposableStore } from '../extension/disposable'
|
||||
|
||||
import { KitUnavailableError } from './errors'
|
||||
|
||||
export type ExposePolicy = 'local-only' | 'remote-observable' | 'remote-callable'
|
||||
|
||||
/**
|
||||
* Host-provided runtime values used to create a scope-aware kit client.
|
||||
*/
|
||||
export interface KitClientRuntime {
|
||||
/** Stable extension id. */
|
||||
extensionId: string
|
||||
/** Host-assigned extension session id. */
|
||||
sessionId: string
|
||||
/** Stable module id when the kit client is created for an explicit module scope. */
|
||||
moduleId?: string
|
||||
/** Cleanup store for the current extension or module scope. */
|
||||
subscriptions: DisposableStore
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines one kit API surface available to extension setup and optional module scopes.
|
||||
*
|
||||
* Kits that support remote use should expose kit-owned Eventa API contracts
|
||||
* such as `gameletKitApis`, then build local and remote clients with the same
|
||||
* authoring shape. Keep transport/RPC words out of the author-facing client:
|
||||
* authors should use `gamelets.mount(...)`, not `invokeGameletMount(...)`.
|
||||
*
|
||||
* @param TClient Kit client type returned to extension authors.
|
||||
*/
|
||||
export interface KitRef<TClient> {
|
||||
/** Stable kit id. */
|
||||
id: string
|
||||
/** Kit API version used for compatibility checks. */
|
||||
version: string
|
||||
/** Exposure policies this kit can support across host/peer boundaries. */
|
||||
allowedExposePolicies?: ExposePolicy[]
|
||||
/** Default exposure policy when module/host policy does not override it. */
|
||||
defaultExposePolicy?: ExposePolicy
|
||||
/** Creates a scope-aware client for this kit. */
|
||||
createClient: (runtime: KitClientRuntime) => TClient
|
||||
}
|
||||
|
||||
export type KitUnavailableReason = 'missing-kit' | 'permission-denied' | 'incompatible-version' | 'not-ready'
|
||||
|
||||
export type KitUseResult<TClient>
|
||||
= | { ok: true, client: TClient }
|
||||
| { ok: false, reason: KitUnavailableReason, error: Error }
|
||||
|
||||
export type KitAvailability<TClient>
|
||||
= | { available: true, kit: KitRef<TClient>, client: TClient }
|
||||
| { available: false, kit: KitRef<TClient>, reason: KitUnavailableReason, error: Error }
|
||||
|
||||
/**
|
||||
* Defines a kit reference.
|
||||
*
|
||||
* Use when:
|
||||
* - Implementing a host-provided or extension-provided kit API surface
|
||||
* - Publishing a typed kit that extensions can pass to `ctx.kits.use(...)`
|
||||
*
|
||||
* Expects:
|
||||
* - `id` is stable across versions
|
||||
* - `createClient` returns an extension- or module-scoped API object
|
||||
*
|
||||
* Returns:
|
||||
* - The kit reference consumed by host kit registries, extension setup, and optional module scopes
|
||||
*/
|
||||
export function defineKit<TClient>(kit: KitRef<TClient>): KitRef<TClient> {
|
||||
return kit
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standard failed result for optional kit usage.
|
||||
*
|
||||
* Use when:
|
||||
* - Implementing `ctx.kits.tryUse(...)` or optional module-scoped kit usage
|
||||
* - Returning a typed reason without throwing
|
||||
*
|
||||
* Expects:
|
||||
* - `reason` describes the host-side availability decision
|
||||
*
|
||||
* Returns:
|
||||
* - A discriminated failure result with `KitUnavailableError`
|
||||
*/
|
||||
export function kitUseFailure<TClient>(
|
||||
kit: KitRef<TClient>,
|
||||
reason: KitUnavailableReason,
|
||||
): Extract<KitUseResult<TClient>, { ok: false }> {
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
error: new KitUnavailableError(kit.id, reason),
|
||||
}
|
||||
}
|
||||
|
||||
export type { Disposable }
|
||||
export * from './errors'
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -10,10 +10,10 @@ export * from '../../transports'
|
||||
export * from './loaders'
|
||||
|
||||
/**
|
||||
* Creates the Eventa context used by node-side plugin host sessions.
|
||||
* Creates the Eventa context used by node-side extension host sessions.
|
||||
*
|
||||
* Use when:
|
||||
* - Bootstrapping a node runtime plugin session
|
||||
* - Bootstrapping a node runtime extension session
|
||||
*
|
||||
* Expects:
|
||||
* - `transport` describes a transport supported by the node runtime
|
||||
|
||||
@@ -1,55 +1,45 @@
|
||||
import type { definePlugin } from '../../../../plugin'
|
||||
import type { Plugin } from '../../../../plugin/shared'
|
||||
import type { ManifestV1, PluginLoadOptions } from '../../../shared/types'
|
||||
import type { Extension } from '../../../../extension'
|
||||
import type { ExtensionLoadOptions, ExtensionManifestV1 } from '../../../shared/types'
|
||||
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
import { cwd } from 'node:process'
|
||||
|
||||
function isPluginDefinition(value: unknown): value is ReturnType<typeof definePlugin> {
|
||||
function isExtensionDefinition(value: unknown): value is Extension {
|
||||
return typeof value === 'object'
|
||||
&& value !== null
|
||||
&& 'id' in value
|
||||
&& typeof (value as { id?: unknown }).id === 'string'
|
||||
&& 'setup' in value
|
||||
&& typeof (value as { setup?: unknown }).setup === 'function'
|
||||
}
|
||||
|
||||
async function coercePluginFromModule(moduleValue: unknown): Promise<Plugin> {
|
||||
if (isPluginDefinition(moduleValue)) {
|
||||
return await moduleValue.setup()
|
||||
function coerceExtensionFromModule(moduleValue: unknown): Extension {
|
||||
if (isExtensionDefinition(moduleValue)) {
|
||||
return moduleValue
|
||||
}
|
||||
|
||||
if (typeof moduleValue === 'object' && moduleValue !== null) {
|
||||
if ('default' in moduleValue && isPluginDefinition((moduleValue as { default?: unknown }).default)) {
|
||||
return await (moduleValue as { default: ReturnType<typeof definePlugin> }).default.setup()
|
||||
}
|
||||
|
||||
if ('default' in moduleValue && typeof (moduleValue as { default?: unknown }).default === 'object') {
|
||||
const defaultPlugin = (moduleValue as { default: Plugin }).default
|
||||
if (typeof defaultPlugin.init === 'function' || typeof defaultPlugin.setupModules === 'function') {
|
||||
return defaultPlugin
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = moduleValue as Plugin
|
||||
if (typeof plugin.init === 'function' || typeof plugin.setupModules === 'function') {
|
||||
return plugin
|
||||
const defaultExport = (moduleValue as { default?: unknown }).default
|
||||
if (isExtensionDefinition(defaultExport)) {
|
||||
return defaultExport
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to resolve plugin module. The entrypoint must export either definePlugin(...) or Plugin hooks.')
|
||||
throw new Error('Failed to resolve extension module. The entrypoint must export defineExtension(...).')
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads plugin entrypoints from the local filesystem for the current runtime.
|
||||
* Loads extension entrypoints from the local filesystem for the current runtime.
|
||||
*
|
||||
* Use when:
|
||||
* - The host needs to resolve a manifest entrypoint path
|
||||
* - The host needs to import either a lazy `definePlugin(...)` export or a concrete plugin module
|
||||
* - The host needs to import a `defineExtension(...)` export
|
||||
*
|
||||
* Expects:
|
||||
* - Entry points are valid importable module paths for the active runtime
|
||||
*
|
||||
* Returns:
|
||||
* - Filesystem-backed helpers for resolving and loading plugin entrypoints
|
||||
* - Filesystem-backed helpers for resolving and loading extension entrypoints
|
||||
*/
|
||||
export class FileSystemLoader {
|
||||
/**
|
||||
@@ -58,9 +48,9 @@ export class FileSystemLoader {
|
||||
* Resolution order:
|
||||
* 1) `entrypoints.<runtime>`
|
||||
* 2) `entrypoints.default`
|
||||
* 3) `entrypoints.electron` (legacy fallback for current local plugin manifests)
|
||||
* 3) `entrypoints.electron` (legacy fallback for current local extension manifests)
|
||||
*/
|
||||
resolveEntrypointFor(manifest: ManifestV1, options?: PluginLoadOptions) {
|
||||
resolveEntrypointFor(manifest: ExtensionManifestV1, options?: ExtensionLoadOptions) {
|
||||
const runtime = options?.runtime ?? 'electron'
|
||||
const root = options?.cwd ?? cwd()
|
||||
const entrypoint
|
||||
@@ -70,36 +60,18 @@ export class FileSystemLoader {
|
||||
|
||||
if (!entrypoint) {
|
||||
throw new Error(''
|
||||
+ `Plugin entrypoint is required for runtime \`${runtime}\`. `
|
||||
+ `Extension entrypoint is required for runtime \`${runtime}\`. `
|
||||
+ 'Define one of `entrypoints.<runtime>`, `entrypoints.default`, '
|
||||
+ 'or `entrypoints.electron` in the plugin manifest.',
|
||||
+ 'or `entrypoints.electron` in the extension manifest.',
|
||||
)
|
||||
}
|
||||
|
||||
return isAbsolute(entrypoint) ? entrypoint : join(root, entrypoint)
|
||||
}
|
||||
|
||||
async loadLazyPluginFor(manifest: ManifestV1, options?: PluginLoadOptions) {
|
||||
async loadExtensionFor(manifest: ExtensionManifestV1, options?: ExtensionLoadOptions) {
|
||||
const entrypoint = this.resolveEntrypointFor(manifest, options)
|
||||
const pluginModule = await import(entrypoint)
|
||||
|
||||
if (isPluginDefinition(pluginModule)) {
|
||||
return pluginModule
|
||||
}
|
||||
|
||||
if (typeof pluginModule === 'object' && pluginModule !== null) {
|
||||
const defaultExport = (pluginModule as { default?: unknown }).default
|
||||
if (isPluginDefinition(defaultExport)) {
|
||||
return defaultExport
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Plugin lazy loader expects a definePlugin(...) export.')
|
||||
}
|
||||
|
||||
async loadPluginFor(manifest: ManifestV1, options?: PluginLoadOptions) {
|
||||
const entrypoint = this.resolveEntrypointFor(manifest, options)
|
||||
const pluginModule = await import(entrypoint)
|
||||
return coercePluginFromModule(pluginModule)
|
||||
const extensionModule = await import(entrypoint)
|
||||
return coerceExtensionFromModule(extensionModule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CapabilityDescriptor } from '../../../../plugin/apis/protocol'
|
||||
|
||||
/**
|
||||
* Tracks capability lifecycle state and waits for readiness across plugin sessions.
|
||||
* Tracks capability lifecycle state and waits for readiness across extension sessions.
|
||||
*
|
||||
* Use when:
|
||||
* - The host needs to announce, ready, degrade, or withdraw named capabilities
|
||||
|
||||
+9
-23
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PluginSessionService } from './sessions'
|
||||
import { ExtensionSessionService } from './extension-sessions'
|
||||
|
||||
vi.mock('nanoid/non-secure', () => ({
|
||||
nanoid: vi
|
||||
@@ -14,9 +14,9 @@ interface TestSession {
|
||||
state: 'active' | 'closed'
|
||||
}
|
||||
|
||||
describe('pluginSessionService', () => {
|
||||
describe('extensionSessionService', () => {
|
||||
it('registers, lists, gets, and removes sessions by id', () => {
|
||||
const service = new PluginSessionService<TestSession>()
|
||||
const service = new ExtensionSessionService<TestSession>()
|
||||
const firstSession: TestSession = { id: 'session-1', state: 'active' }
|
||||
const secondSession: TestSession = { id: 'session-2', state: 'closed' }
|
||||
|
||||
@@ -35,31 +35,17 @@ describe('pluginSessionService', () => {
|
||||
expect(service.remove('session-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('generates random session ids and incrementing module identities with sanitized plugin names', () => {
|
||||
const service = new PluginSessionService<TestSession>()
|
||||
it('generates random session ids with incrementing indexes', () => {
|
||||
const service = new ExtensionSessionService<TestSession>()
|
||||
|
||||
expect(service.nextSessionIdentity(' demo-plugin ')).toEqual({
|
||||
expect(service.nextSessionIdentity()).toEqual({
|
||||
index: 0,
|
||||
sessionId: 'plugin-session-session-a',
|
||||
moduleIdentity: {
|
||||
id: 'demo-plugin-0',
|
||||
kind: 'plugin',
|
||||
plugin: {
|
||||
id: 'demo-plugin',
|
||||
},
|
||||
},
|
||||
sessionId: 'extension-session-session-a',
|
||||
})
|
||||
|
||||
expect(service.nextSessionIdentity(' ')).toEqual({
|
||||
expect(service.nextSessionIdentity()).toEqual({
|
||||
index: 1,
|
||||
sessionId: 'plugin-session-session-b',
|
||||
moduleIdentity: {
|
||||
id: 'plugin-1',
|
||||
kind: 'plugin',
|
||||
plugin: {
|
||||
id: 'plugin',
|
||||
},
|
||||
},
|
||||
sessionId: 'extension-session-session-b',
|
||||
})
|
||||
})
|
||||
})
|
||||
+6
-21
@@ -1,25 +1,11 @@
|
||||
import type { ModuleIdentity } from '../../../shared/types'
|
||||
|
||||
import { nanoid } from 'nanoid/non-secure'
|
||||
|
||||
function createModuleIdentity(name: string, index: number): ModuleIdentity {
|
||||
const sanitizedName = name.trim() || 'plugin'
|
||||
|
||||
return {
|
||||
id: `${sanitizedName}-${index}`,
|
||||
kind: 'plugin',
|
||||
plugin: {
|
||||
id: sanitizedName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores plugin sessions and generates deterministic session identities.
|
||||
* Stores extension host sessions and generates deterministic session identities.
|
||||
*
|
||||
* Use when:
|
||||
* - The host needs to track loaded plugin sessions by id
|
||||
* - New plugin sessions need a generated session id and module identity
|
||||
* - The host needs to track loaded extension sessions by id
|
||||
* - New extension sessions need a generated session id and module identity
|
||||
*
|
||||
* Expects:
|
||||
* - `TSession` has a stable `id` field used as the registry key
|
||||
@@ -27,7 +13,7 @@ function createModuleIdentity(name: string, index: number): ModuleIdentity {
|
||||
* Returns:
|
||||
* - An in-memory session registry with identity generation helpers
|
||||
*/
|
||||
export class PluginSessionService<TSession extends { id: string }> {
|
||||
export class ExtensionSessionService<TSession extends { id: string }> {
|
||||
private readonly sessions = new Map<string, TSession>()
|
||||
private sessionCounter = 0
|
||||
|
||||
@@ -54,14 +40,13 @@ export class PluginSessionService<TSession extends { id: string }> {
|
||||
return session
|
||||
}
|
||||
|
||||
nextSessionIdentity(name: string) {
|
||||
nextSessionIdentity() {
|
||||
const index = this.sessionCounter
|
||||
this.sessionCounter += 1
|
||||
|
||||
return {
|
||||
index,
|
||||
sessionId: `plugin-session-${nanoid()}`,
|
||||
moduleIdentity: createModuleIdentity(name, index),
|
||||
sessionId: `extension-session-${nanoid()}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
export * from './bindings'
|
||||
export * from './dependencies'
|
||||
export * from './extension-sessions'
|
||||
export * from './kit-api-bindings'
|
||||
export * from './kits'
|
||||
export * from './permissions'
|
||||
export * from './resources'
|
||||
export * from './sessions'
|
||||
export * from './tools'
|
||||
|
||||
+26
-9
@@ -1,10 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { BindingsRegistryService } from './bindings'
|
||||
import { KitApiBindingRegistryService } from './kit-api-bindings'
|
||||
|
||||
describe('kitApiBindingRegistryService', () => {
|
||||
it('stores kit API bindings by owning extension session and module', () => {
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
const binding = service.bind({
|
||||
moduleId: 'chess-gamelet',
|
||||
ownerSessionId: 'session-1',
|
||||
ownerPluginId: 'airi-extension-chess',
|
||||
kitId: 'kit.gamelet',
|
||||
kitModuleType: 'gamelet',
|
||||
config: { title: 'Chess' },
|
||||
runtime: 'electron',
|
||||
})
|
||||
|
||||
expect(binding.state).toBe('announced')
|
||||
expect(service.listByModule('session-1', 'chess-gamelet')).toEqual([binding])
|
||||
})
|
||||
|
||||
describe('bindingsRegistryService', () => {
|
||||
it('rejects ownership violations when updating a module from another session', () => {
|
||||
const service = new BindingsRegistryService()
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
service.bind({
|
||||
moduleId: 'm1',
|
||||
@@ -20,7 +37,7 @@ describe('bindingsRegistryService', () => {
|
||||
})
|
||||
|
||||
it('tracks lifecycle transitions with revision bumps and preserved ownership', () => {
|
||||
const service = new BindingsRegistryService()
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
const announced = service.bind({
|
||||
moduleId: 'm2',
|
||||
@@ -45,7 +62,7 @@ describe('bindingsRegistryService', () => {
|
||||
})
|
||||
|
||||
it('rejects invalid lifecycle transitions after withdrawal', () => {
|
||||
const service = new BindingsRegistryService()
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
service.bind({
|
||||
moduleId: 'm3',
|
||||
@@ -63,7 +80,7 @@ describe('bindingsRegistryService', () => {
|
||||
})
|
||||
|
||||
it('rejects duplicate module ids from a different owner session', () => {
|
||||
const service = new BindingsRegistryService()
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
service.bind({
|
||||
moduleId: 'm4',
|
||||
@@ -89,7 +106,7 @@ describe('bindingsRegistryService', () => {
|
||||
})
|
||||
|
||||
it('returns the existing record for an idempotent duplicate bind from the same owner', () => {
|
||||
const service = new BindingsRegistryService()
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
const original = service.bind({
|
||||
moduleId: 'm5',
|
||||
@@ -118,7 +135,7 @@ describe('bindingsRegistryService', () => {
|
||||
})
|
||||
|
||||
it('rejects module reuse with the same session but a different owner plugin', () => {
|
||||
const service = new BindingsRegistryService()
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
service.bind({
|
||||
moduleId: 'm6',
|
||||
@@ -144,7 +161,7 @@ describe('bindingsRegistryService', () => {
|
||||
})
|
||||
|
||||
it('removes a withdrawn binding with unbind for teardown flows', () => {
|
||||
const service = new BindingsRegistryService()
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
service.bind({
|
||||
moduleId: 'm7',
|
||||
+32
-12
@@ -5,17 +5,17 @@ import type { HostDataRecord, PluginRuntime } from '../../../shared/types'
|
||||
* Declares the host-owned data needed to create one binding record.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin session contributes a concrete runtime instance through a kit
|
||||
* - A extension session contributes a concrete runtime instance through a kit
|
||||
* - Higher-level kit helpers need to persist their low-level binding into the host registry
|
||||
*
|
||||
* Expects:
|
||||
* - `moduleId` is stable within the owning plugin session
|
||||
* - `moduleId` is stable within the owning extension session
|
||||
* - `kitId` points at a host-registered kit that defines the binding family
|
||||
* - `kitModuleType` is a kit-defined subtype key, not a host-wide enum
|
||||
* - `config` is transport-safe and already normalized by the caller
|
||||
*
|
||||
* Returns:
|
||||
* - A serializable payload that {@link BindingsRegistryService.bind} stores as canonical binding state
|
||||
* - A serializable payload that {@link KitApiBindingRegistryService.bind} stores as canonical binding state
|
||||
*/
|
||||
export interface BindingInput<C extends HostDataRecord = HostDataRecord> {
|
||||
moduleId: string
|
||||
@@ -39,7 +39,7 @@ export interface BindingInput<C extends HostDataRecord = HostDataRecord> {
|
||||
* - `config` only contains fields that should be shallow-merged into the current config
|
||||
*
|
||||
* Returns:
|
||||
* - A partial mutation applied by {@link BindingsRegistryService.update} or {@link BindingsRegistryService.transition}
|
||||
* - A partial mutation applied by {@link KitApiBindingRegistryService.update} or {@link KitApiBindingRegistryService.transition}
|
||||
*/
|
||||
export interface BindingUpdatePatch<C extends HostDataRecord = HostDataRecord> {
|
||||
state?: BindingState
|
||||
@@ -47,10 +47,10 @@ export interface BindingUpdatePatch<C extends HostDataRecord = HostDataRecord> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies the plugin session that owns a binding record.
|
||||
* Identifies the extension session that owns a binding record.
|
||||
*
|
||||
* Use when:
|
||||
* - Enforcing that only the original plugin session mutates or removes a binding
|
||||
* - Enforcing that only the original extension session mutates or removes a binding
|
||||
* - Comparing current callers against stored binding ownership
|
||||
*
|
||||
* Expects:
|
||||
@@ -112,12 +112,12 @@ function createInvalidTransitionError(moduleId: string, from: BindingState, to:
|
||||
* Returns:
|
||||
* - Stable {@link BindingRecord} snapshots representing bound runtime contributions
|
||||
*
|
||||
* A binding is the concrete link between a plugin-owned runtime instance and a host-registered kit.
|
||||
* A binding is the concrete link between a extension-owned runtime instance and a host-registered kit.
|
||||
* The host keeps kits generic: a kit only describes capabilities, supported runtimes, and allowed
|
||||
* operations. That is not enough to render UI, route lifecycle, or enforce ownership for a specific
|
||||
* plugin contribution. The missing piece is a binding record saying:
|
||||
*
|
||||
* - plugin session `X` owns runtime instance `moduleId`
|
||||
* - extension session `X` owns runtime instance `moduleId`
|
||||
* - that instance is attached to kit `kitId`
|
||||
* - within that kit it behaves as subtype `kitModuleType`
|
||||
* - here is its current generic config payload and lifecycle state
|
||||
@@ -170,11 +170,11 @@ function createInvalidTransitionError(moduleId: string, from: BindingState, to:
|
||||
* - `widget-main` under `kit.widget`
|
||||
* - after: one registry, multiple kit families, each record still resolved by the same ownership rules
|
||||
*/
|
||||
export class BindingsRegistryService<C extends HostDataRecord = HostDataRecord> {
|
||||
export class KitApiBindingRegistryService<C extends HostDataRecord = HostDataRecord> {
|
||||
private readonly bindings = new Map<string, BindingRecord<C>>()
|
||||
|
||||
/**
|
||||
* Creates or reuses one binding record for a plugin-owned runtime instance.
|
||||
* Creates or reuses one binding record for a extension-owned runtime instance.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin or kit helper needs to declare that a concrete instance now exists
|
||||
@@ -276,7 +276,7 @@ export class BindingsRegistryService<C extends HostDataRecord = HostDataRecord>
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists bindings owned by one plugin session.
|
||||
* Lists bindings owned by one extension session.
|
||||
*
|
||||
* Use when:
|
||||
* - Stopping or reloading a session
|
||||
@@ -292,6 +292,26 @@ export class BindingsRegistryService<C extends HostDataRecord = HostDataRecord>
|
||||
return this.list().filter(binding => binding.ownerSessionId === ownerSessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists bindings owned by one module in an extension/extension session.
|
||||
*
|
||||
* Use when:
|
||||
* - Module-scoped cleanup or devtools need the bindings for one registered module
|
||||
*
|
||||
* Expects:
|
||||
* - `ownerSessionId` is the extension/extension session id
|
||||
* - `moduleId` is the concrete kit API binding id
|
||||
*
|
||||
* Returns:
|
||||
* - All matching binding records
|
||||
*/
|
||||
listByModule(ownerSessionId: string, moduleId: string) {
|
||||
return this.list().filter(binding =>
|
||||
binding.ownerSessionId === ownerSessionId
|
||||
&& binding.moduleId === moduleId,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists bindings attached to one kit family.
|
||||
*
|
||||
@@ -434,7 +454,7 @@ export class BindingsRegistryService<C extends HostDataRecord = HostDataRecord>
|
||||
* Physically removes a withdrawn-or-obsolete binding record from the registry.
|
||||
*
|
||||
* Use when:
|
||||
* - Stopping or reloading a plugin session after lifecycle cleanup
|
||||
* - Stopping or reloading a extension session after lifecycle cleanup
|
||||
* - The host wants to forget a binding entirely, not merely mark it withdrawn
|
||||
*
|
||||
* Expects:
|
||||
@@ -208,4 +208,23 @@ describe('permissionService', () => {
|
||||
expect(service.isAllowed('plugin-d', 'apis', 'emit', 'plugin.api.users')).toBe(false)
|
||||
expect(service.isAllowed('plugin-d', 'apis', 'invoke', 'plugin.api.billing')).toBe(false)
|
||||
})
|
||||
|
||||
it('caps module grants by the extension permission ceiling', () => {
|
||||
const service = new PermissionService()
|
||||
const extension = service.initialize('extension-session', {
|
||||
apis: [{ key: 'kit.tools.register', actions: ['invoke'] }],
|
||||
})
|
||||
const module = service.initialize('module-session', {
|
||||
apis: [
|
||||
{ key: 'kit.tools.register', actions: ['invoke'] },
|
||||
{ key: 'kit.gamelet.open', actions: ['invoke'] },
|
||||
],
|
||||
})
|
||||
|
||||
const effective = service.intersectGrant(extension.granted, module.requested)
|
||||
|
||||
expect(effective.apis).toEqual([
|
||||
{ key: 'kit.tools.register', actions: ['invoke'] },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -253,7 +253,7 @@ function mergePermissionDeclarations(
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks requested and granted permissions for plugin sessions.
|
||||
* Tracks requested and granted permissions for extension sessions.
|
||||
*
|
||||
* Use when:
|
||||
* - The host needs to initialize permission state for a session
|
||||
@@ -269,6 +269,29 @@ function mergePermissionDeclarations(
|
||||
export class PermissionService {
|
||||
private readonly store = new Map<string, PermissionSnapshot>()
|
||||
|
||||
/**
|
||||
* Computes the effective permission boundary for one module.
|
||||
*
|
||||
* Use when:
|
||||
* - Extension permissions define the install/session-level ceiling
|
||||
* - Module permissions describe actual runtime usage
|
||||
*
|
||||
* Expects:
|
||||
* - `extensionGrant` is the already granted extension-level ceiling
|
||||
* - `moduleRequest` is the module-level requested usage
|
||||
*
|
||||
* Returns:
|
||||
* - The intersection that stays within both extension and module boundaries
|
||||
*/
|
||||
intersectGrant(
|
||||
extensionGrant: ModulePermissionGrant,
|
||||
moduleRequest: ModulePermissionDeclaration,
|
||||
): ModulePermissionGrant {
|
||||
// Extension grants are the package/session ceiling; module requests are
|
||||
// actual runtime usage. Effective access must stay inside both boundaries.
|
||||
return intersectPermissions(normalizeDeclaration(moduleRequest), normalizeDeclaration(extensionGrant))
|
||||
}
|
||||
|
||||
initialize(
|
||||
pluginId: string,
|
||||
requestedDeclaration: ModulePermissionDeclaration,
|
||||
@@ -342,4 +365,12 @@ export class PermissionService {
|
||||
&& hasAction(scope.actions, action),
|
||||
)
|
||||
}
|
||||
|
||||
grantAllows(grant: ModulePermissionGrant, area: ModulePermissionArea, action: string, key: string) {
|
||||
const scopes = grant[area] ?? []
|
||||
return scopes.some(scope =>
|
||||
matchKey(scope.key, key)
|
||||
&& hasAction(scope.actions, action),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import type {
|
||||
PluginToolDefinitionRecord,
|
||||
PluginToolsetPromptDefinitionRecord,
|
||||
RegisteredPluginToolDescriptor,
|
||||
SerializedToolsetPromptDefinition,
|
||||
SerializedXsaiToolDefinition,
|
||||
SerializedXsaiToolsetDefinition,
|
||||
} from '../../../shared'
|
||||
|
||||
/**
|
||||
* Stores one plugin tool registration inside the in-memory host runtime.
|
||||
*
|
||||
* Use when:
|
||||
* - Tracking tool ownership and availability per plugin session
|
||||
*
|
||||
* Expects:
|
||||
* - `ownerPluginId` and `tool.id` together are unique
|
||||
*
|
||||
* Returns:
|
||||
* - A host-managed record used for listing and invocation
|
||||
*/
|
||||
export interface ToolRegistryRecord {
|
||||
ownerSessionId: string
|
||||
ownerPluginId: string
|
||||
tool: PluginToolDefinitionRecord
|
||||
availability?: () => Promise<boolean> | boolean
|
||||
execute: (input: unknown) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores one plugin toolset prompt registration inside the in-memory host runtime.
|
||||
*
|
||||
* Use when:
|
||||
* - Tracking prompt ownership and lifecycle for a plugin-owned toolset
|
||||
*
|
||||
* Expects:
|
||||
* - `ownerPluginId` and `toolset.id` together are unique
|
||||
*
|
||||
* Returns:
|
||||
* - A host-managed record used for prompt serialization
|
||||
*/
|
||||
export interface ToolsetPromptRegistryRecord {
|
||||
ownerSessionId: string
|
||||
ownerPluginId: string
|
||||
toolset: PluginToolsetPromptDefinitionRecord
|
||||
availability?: () => Promise<boolean> | boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory registry for plugin-contributed tools.
|
||||
*
|
||||
* Use when:
|
||||
* - The host needs to list plugin tools for UI and xsai consumers
|
||||
* - The host needs to dispatch a tool invocation back to its owning plugin
|
||||
*
|
||||
* Expects:
|
||||
* - Callers filter by ownership through `ownerPluginId`
|
||||
*
|
||||
* Returns:
|
||||
* - Serialisable metadata views and invoke routing
|
||||
*/
|
||||
export class ToolRegistryService {
|
||||
private readonly tools = new Map<string, ToolRegistryRecord>()
|
||||
private readonly toolsetPrompts = new Map<string, ToolsetPromptRegistryRecord>()
|
||||
|
||||
register(record: ToolRegistryRecord) {
|
||||
const key = `${record.ownerPluginId}:${record.tool.id}`
|
||||
this.tools.set(key, record)
|
||||
return record
|
||||
}
|
||||
|
||||
registerToolsetPrompt(record: ToolsetPromptRegistryRecord) {
|
||||
const key = `${record.ownerPluginId}:${record.toolset.id}`
|
||||
this.toolsetPrompts.set(key, record)
|
||||
return record
|
||||
}
|
||||
|
||||
async listAvailableDescriptors() {
|
||||
const items: RegisteredPluginToolDescriptor[] = []
|
||||
|
||||
for (const record of this.tools.values()) {
|
||||
if (await record.availability?.() === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: record.tool.id,
|
||||
title: record.tool.title,
|
||||
description: record.tool.description,
|
||||
activation: {
|
||||
keywords: [...record.tool.activation.keywords],
|
||||
patterns: [...record.tool.activation.patterns],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
async listToolsetPrompts() {
|
||||
const prompts: SerializedToolsetPromptDefinition[] = []
|
||||
|
||||
for (const record of this.toolsetPrompts.values()) {
|
||||
if (await record.availability?.() === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
prompts.push({
|
||||
ownerPluginId: record.ownerPluginId,
|
||||
id: record.toolset.id,
|
||||
prompt: structuredClone(record.toolset.prompt),
|
||||
})
|
||||
}
|
||||
|
||||
return prompts
|
||||
}
|
||||
|
||||
async listSerializedXsaiTools(): Promise<SerializedXsaiToolsetDefinition> {
|
||||
const items: SerializedXsaiToolDefinition[] = []
|
||||
|
||||
for (const record of this.tools.values()) {
|
||||
if (await record.availability?.() === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
items.push({
|
||||
ownerPluginId: record.ownerPluginId,
|
||||
name: record.tool.id,
|
||||
description: record.tool.description,
|
||||
parameters: structuredClone(record.tool.parameters),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
prompts: await this.listToolsetPrompts(),
|
||||
tools: items,
|
||||
}
|
||||
}
|
||||
|
||||
async invoke(ownerPluginId: string, toolId: string, input: unknown) {
|
||||
const key = `${ownerPluginId}:${toolId}`
|
||||
const record = this.tools.get(key)
|
||||
if (!record) {
|
||||
throw new Error(`Plugin tool not found: ${key}`)
|
||||
}
|
||||
|
||||
return await record.execute(input)
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,10 @@ export * from '../../shared'
|
||||
export * from '../../transports'
|
||||
|
||||
/**
|
||||
* Creates the Eventa context used by web-side plugin host sessions.
|
||||
* Creates the Eventa context used by web-side extension host sessions.
|
||||
*
|
||||
* Use when:
|
||||
* - Bootstrapping a web runtime plugin session
|
||||
* - Bootstrapping a web runtime extension session
|
||||
*
|
||||
* Expects:
|
||||
* - `transport` describes a transport supported by the web runtime
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('bindingRecordSchema', () => {
|
||||
it('accepts generic host-level module record without business coupling', () => {
|
||||
const parsed = parse(bindingRecordSchema, {
|
||||
moduleId: 'board-main',
|
||||
ownerSessionId: 'plugin-session-1',
|
||||
ownerSessionId: 'extension-session-1',
|
||||
ownerPluginId: 'demo-plugin',
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
@@ -26,7 +26,7 @@ describe('bindingRecordSchema', () => {
|
||||
expect(() =>
|
||||
parse(bindingRecordSchema, {
|
||||
moduleId: 'board-main',
|
||||
ownerSessionId: 'plugin-session-1',
|
||||
ownerSessionId: 'extension-session-1',
|
||||
ownerPluginId: 'demo-plugin',
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
@@ -43,7 +43,7 @@ describe('bindingRecordSchema', () => {
|
||||
expect(() =>
|
||||
parse(bindingRecordSchema, {
|
||||
moduleId: 'board-main',
|
||||
ownerSessionId: 'plugin-session-1',
|
||||
ownerSessionId: 'extension-session-1',
|
||||
ownerPluginId: 'demo-plugin',
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
@@ -64,7 +64,7 @@ describe('bindingRecordSchema', () => {
|
||||
expect(() =>
|
||||
parse(bindingRecordSchema, {
|
||||
moduleId: 'board-main',
|
||||
ownerSessionId: 'plugin-session-1',
|
||||
ownerSessionId: 'extension-session-1',
|
||||
ownerPluginId: 'demo-plugin',
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './bindings'
|
||||
export * from './kits'
|
||||
export * from './tools'
|
||||
export * from './types'
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { HostDataRecord } from './types'
|
||||
|
||||
/**
|
||||
* Describes the user-facing metadata for a plugin-contributed tool.
|
||||
*
|
||||
* Use when:
|
||||
* - Listing plugin tools in renderer or devtools surfaces
|
||||
* - Exposing activation hints without the execution handler
|
||||
*
|
||||
* Expects:
|
||||
* - `id` is stable and unique within the owning plugin
|
||||
*
|
||||
* Returns:
|
||||
* - A serializable descriptor suitable for host and renderer registries
|
||||
*/
|
||||
export interface RegisteredPluginToolDescriptor {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
activation: {
|
||||
keywords: string[]
|
||||
patterns: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the JSON-schema side of an xsai-compatible tool.
|
||||
*
|
||||
* Use when:
|
||||
* - Serializing plugin tools across Electron boundaries
|
||||
* - Reconstructing proxy `rawTool(...)` instances in the renderer
|
||||
*
|
||||
* Expects:
|
||||
* - `parameters` is a provider-safe JSON Schema object
|
||||
*
|
||||
* Returns:
|
||||
* - A serializable tool contract without executable callbacks
|
||||
*/
|
||||
export interface SerializedXsaiToolDefinition {
|
||||
ownerPluginId: string
|
||||
name: string
|
||||
description: string
|
||||
parameters: HostDataRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes model-facing guidance shared by every tool in one plugin toolset.
|
||||
*
|
||||
* Use when:
|
||||
* - A toolset needs shared usage policy without duplicating prompt text on each tool
|
||||
*
|
||||
* Expects:
|
||||
* - `content` is ready to append into a runtime system prompt
|
||||
*
|
||||
* Returns:
|
||||
* - A serializable manifest that the host can pass to renderer prompt stores
|
||||
*/
|
||||
export interface ToolsetPromptManifest {
|
||||
id: string
|
||||
title?: string
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures one registered toolset prompt with plugin ownership metadata.
|
||||
*
|
||||
* Use when:
|
||||
* - Serializing plugin-contributed toolset guidance across host boundaries
|
||||
*
|
||||
* Expects:
|
||||
* - `id` is stable within the owning plugin session
|
||||
*
|
||||
* Returns:
|
||||
* - A prompt contribution suitable for renderer LLM prompt injection
|
||||
*/
|
||||
export interface SerializedToolsetPromptDefinition {
|
||||
ownerPluginId: string
|
||||
id: string
|
||||
prompt: ToolsetPromptManifest
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundles plugin xsai tools with their shared toolset prompt contributions.
|
||||
*
|
||||
* Use when:
|
||||
* - The renderer refreshes plugin-backed tools and model prompt guidance together
|
||||
*
|
||||
* Expects:
|
||||
* - Tools and prompts have already been filtered for active sessions
|
||||
*
|
||||
* Returns:
|
||||
* - A serializable snapshot for renderer tool and prompt stores
|
||||
*/
|
||||
export interface SerializedXsaiToolsetDefinition {
|
||||
tools: SerializedXsaiToolDefinition[]
|
||||
prompts: SerializedToolsetPromptDefinition[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the single source-of-truth definition submitted by a plugin.
|
||||
*
|
||||
* Use when:
|
||||
* - Registering tools from plugin runtimes into the host
|
||||
*
|
||||
* Expects:
|
||||
* - `parameters` already contains a serialized input schema
|
||||
*
|
||||
* Returns:
|
||||
* - A host-owned record that can be derived into UI metadata and xsai schemas
|
||||
*/
|
||||
export interface PluginToolDefinitionRecord {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
activation: {
|
||||
keywords: string[]
|
||||
patterns: string[]
|
||||
}
|
||||
parameters: HostDataRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a plugin-owned prompt shared by a toolset.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin registers model-facing guidance for a group of related tools
|
||||
*
|
||||
* Expects:
|
||||
* - `prompt` content is validated by the authoring helper or caller
|
||||
*
|
||||
* Returns:
|
||||
* - A host-owned record that can be filtered by session lifecycle
|
||||
*/
|
||||
export interface PluginToolsetPromptDefinitionRecord {
|
||||
id: string
|
||||
prompt: ToolsetPromptManifest
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import type {
|
||||
ProtocolEvents,
|
||||
ModuleConfigEnvelope as ProtocolModuleConfigEnvelope,
|
||||
ModuleIdentity as ProtocolModuleIdentity,
|
||||
ExtensionIdentity as ProtocolExtensionIdentity,
|
||||
ModulePermissionDeclaration as ProtocolModulePermissionDeclaration,
|
||||
ModulePermissionGrant as ProtocolModulePermissionGrant,
|
||||
ModulePhase as ProtocolModulePhase,
|
||||
PluginIdentity as ProtocolPluginIdentity,
|
||||
} from '@proj-airi/plugin-protocol/types'
|
||||
|
||||
import type { PluginTransport } from '../transports'
|
||||
import type { KitDescriptor } from './kits'
|
||||
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
@@ -32,7 +27,7 @@ import {
|
||||
} from 'valibot'
|
||||
|
||||
/**
|
||||
* Lists the supported plugin runtimes recognized by the host.
|
||||
* Lists the supported extension runtimes recognized by the host.
|
||||
*
|
||||
* Use when:
|
||||
* - Validating manifest entrypoints or host runtime configuration
|
||||
@@ -46,7 +41,7 @@ import {
|
||||
*/
|
||||
export const pluginRuntimeValues = ['electron', 'node', 'web'] as const
|
||||
/**
|
||||
* Describes one supported plugin runtime.
|
||||
* Describes one supported extension runtime.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing host runtime configuration and manifest runtime selection
|
||||
@@ -68,7 +63,7 @@ export type PluginRuntime = typeof pluginRuntimeValues[number]
|
||||
* - Inputs are runtime strings such as `electron`, `node`, or `web`
|
||||
*
|
||||
* Returns:
|
||||
* - A Valibot schema for one plugin runtime literal
|
||||
* - A Valibot schema for one extension runtime literal
|
||||
*/
|
||||
export const pluginRuntimeSchema = picklist(pluginRuntimeValues)
|
||||
|
||||
@@ -188,116 +183,24 @@ export const hostDataRecordSchema = pipe(record(string(), lazy(createHostDataVal
|
||||
export const nonNegativeIntegerSchema = pipe(number(), safeInteger(), minValue(0))
|
||||
|
||||
/**
|
||||
* Re-exports the protocol module phase literals used by the host.
|
||||
* Re-exports the protocol extension identity model used by the host.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing module lifecycle phases shared with `@proj-airi/plugin-protocol`
|
||||
* - Typing package/session-level extension authorization callbacks
|
||||
*
|
||||
* Expects:
|
||||
* - Values follow the protocol package lifecycle model
|
||||
* - Values originate from extension manifests and host session identity generation
|
||||
*
|
||||
* Returns:
|
||||
* - The protocol-defined module phase union
|
||||
* - The protocol-defined extension identity type
|
||||
*/
|
||||
export type ModulePhase = ProtocolModulePhase
|
||||
|
||||
/**
|
||||
* Describes all phases a plugin session can occupy inside `PluginHost`.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing `PluginHostSession.phase`
|
||||
* - Checking host lifecycle transitions
|
||||
*
|
||||
* Expects:
|
||||
* - Protocol phases are extended with host-only bootstrap and shutdown phases
|
||||
*
|
||||
* Returns:
|
||||
* - The full plugin-session lifecycle union
|
||||
*/
|
||||
export type PluginSessionPhase
|
||||
= | 'loading'
|
||||
| 'loaded'
|
||||
| 'authenticating'
|
||||
| 'authenticated'
|
||||
| 'waiting-deps'
|
||||
| ModulePhase
|
||||
| 'stopped'
|
||||
|
||||
/**
|
||||
* Re-exports the protocol plugin identity model used by the host.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing per-plugin identity values stored on sessions and events
|
||||
*
|
||||
* Expects:
|
||||
* - Values originate from the protocol identity generator or host session service
|
||||
*
|
||||
* Returns:
|
||||
* - The protocol-defined plugin identity type
|
||||
*/
|
||||
export type PluginIdentity = ProtocolPluginIdentity
|
||||
|
||||
/**
|
||||
* Re-exports the protocol module identity model used by the host.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing plugin session identities and protocol event payloads
|
||||
*
|
||||
* Expects:
|
||||
* - Values originate from the protocol identity generator or host session service
|
||||
*
|
||||
* Returns:
|
||||
* - The protocol-defined module identity type
|
||||
*/
|
||||
export type ModuleIdentity = ProtocolModuleIdentity
|
||||
|
||||
/**
|
||||
* Re-exports the protocol configuration envelope used for plugin configuration state.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing configuration payloads stored or emitted by the host
|
||||
*
|
||||
* Expects:
|
||||
* - `C` describes the full configuration object carried in the envelope
|
||||
*
|
||||
* Returns:
|
||||
* - The protocol-defined configuration envelope type
|
||||
*/
|
||||
export type ModuleConfigEnvelope<C = Record<string, unknown>> = ProtocolModuleConfigEnvelope<C>
|
||||
|
||||
/**
|
||||
* Re-exports the protocol compatibility request payload type.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing compatibility negotiation messages in the host
|
||||
*
|
||||
* Expects:
|
||||
* - Values conform to the protocol event payload
|
||||
*
|
||||
* Returns:
|
||||
* - The protocol-defined compatibility request type
|
||||
*/
|
||||
export type ModuleCompatibilityRequest = ProtocolEvents['module:compatibility:request']
|
||||
|
||||
/**
|
||||
* Re-exports the protocol compatibility result payload type.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing compatibility negotiation responses in the host
|
||||
*
|
||||
* Expects:
|
||||
* - Values conform to the protocol event payload
|
||||
*
|
||||
* Returns:
|
||||
* - The protocol-defined compatibility result type
|
||||
*/
|
||||
export type ModuleCompatibilityResult = ProtocolEvents['module:compatibility:result']
|
||||
export type ExtensionIdentity = ProtocolExtensionIdentity
|
||||
|
||||
/**
|
||||
* Re-exports the protocol permission declaration model used by manifests and runtime permission flow.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing requested permissions in plugin manifests and host sessions
|
||||
* - Typing requested permissions in extension manifests and host sessions
|
||||
*
|
||||
* Expects:
|
||||
* - Values conform to the protocol permission declaration model
|
||||
@@ -322,28 +225,21 @@ export type ModulePermissionDeclaration = ProtocolModulePermissionDeclaration
|
||||
export type ModulePermissionGrant = ProtocolModulePermissionGrant
|
||||
|
||||
/**
|
||||
* Describes a version-1 plugin manifest consumed by `PluginHost`.
|
||||
* Describes a version-1 extension manifest consumed by `ExtensionHost`.
|
||||
*
|
||||
* Use when:
|
||||
* - Loading a plugin from disk or another runtime
|
||||
* - Typing manifest values in tests and host options
|
||||
*
|
||||
* Expects:
|
||||
* - `kind` and `apiVersion` match the current manifest format
|
||||
*
|
||||
* Returns:
|
||||
* - The structured plugin manifest contract understood by the host
|
||||
* Extension manifests are the install/session-level package description. Module
|
||||
* registration happens later during `defineExtension({ setup })`.
|
||||
*/
|
||||
export interface ManifestV1 {
|
||||
export interface ExtensionManifestV1 {
|
||||
/** Manifest schema version expected by the current host implementation. */
|
||||
apiVersion: 'v1'
|
||||
/** Manifest kind discriminator used to identify AIRI plugin manifests. */
|
||||
kind: 'manifest.plugin.airi.moeru.ai'
|
||||
/** Stable plugin name used for identity generation and display. */
|
||||
name: string
|
||||
/** Requested permissions that the host will evaluate and grant. */
|
||||
/** Manifest kind discriminator used to identify AIRI extension manifests. */
|
||||
kind: 'manifest.extension.airi.moeru.ai'
|
||||
/** Stable extension id used for identity generation and display. */
|
||||
id: string
|
||||
/** Package/session permission ceiling that module permissions are capped by. */
|
||||
permissions: ModulePermissionDeclaration
|
||||
/** Runtime-specific module entrypoints that the host can resolve and import. */
|
||||
/** Runtime-specific extension entrypoints that the host can resolve and import. */
|
||||
entrypoints: {
|
||||
/** Fallback entrypoint used when no runtime-specific path is provided. */
|
||||
default?: string
|
||||
@@ -365,72 +261,77 @@ const localizableSchema = union([
|
||||
}),
|
||||
])
|
||||
|
||||
/**
|
||||
* Validates a version-1 plugin manifest.
|
||||
*
|
||||
* Use when:
|
||||
* - Parsing plugin manifests before loading them into the host
|
||||
*
|
||||
* Expects:
|
||||
* - Inputs follow the `ManifestV1` shape including permission declarations and entrypoints
|
||||
*
|
||||
* Returns:
|
||||
* - A Valibot schema for the AIRI plugin manifest format
|
||||
*/
|
||||
export const manifestV1Schema = object({
|
||||
apiVersion: literal('v1'),
|
||||
kind: literal('manifest.plugin.airi.moeru.ai'),
|
||||
name: string(),
|
||||
permissions: object({
|
||||
apis: optional(array(object({
|
||||
key: string(),
|
||||
actions: array(picklist(['invoke', 'emit'])),
|
||||
reason: optional(localizableSchema),
|
||||
label: optional(localizableSchema),
|
||||
required: optional(boolean()),
|
||||
}))),
|
||||
resources: optional(array(object({
|
||||
key: string(),
|
||||
actions: array(picklist(['read', 'write', 'subscribe'])),
|
||||
reason: optional(localizableSchema),
|
||||
label: optional(localizableSchema),
|
||||
required: optional(boolean()),
|
||||
}))),
|
||||
capabilities: optional(array(object({
|
||||
key: string(),
|
||||
actions: array(picklist(['wait', 'snapshot'])),
|
||||
reason: optional(localizableSchema),
|
||||
label: optional(localizableSchema),
|
||||
required: optional(boolean()),
|
||||
}))),
|
||||
processors: optional(array(object({
|
||||
key: string(),
|
||||
actions: array(picklist(['register', 'execute', 'manage'])),
|
||||
reason: optional(localizableSchema),
|
||||
label: optional(localizableSchema),
|
||||
required: optional(boolean()),
|
||||
}))),
|
||||
pipelines: optional(array(object({
|
||||
key: string(),
|
||||
actions: array(picklist(['hook', 'process', 'emit', 'manage'])),
|
||||
reason: optional(localizableSchema),
|
||||
label: optional(localizableSchema),
|
||||
required: optional(boolean()),
|
||||
}))),
|
||||
}),
|
||||
entrypoints: object({
|
||||
default: optional(string()),
|
||||
electron: optional(string()),
|
||||
node: optional(string()),
|
||||
web: optional(string()),
|
||||
}),
|
||||
const permissionDeclarationSchema = object({
|
||||
apis: optional(array(object({
|
||||
key: string(),
|
||||
actions: array(picklist(['invoke', 'emit'])),
|
||||
reason: optional(localizableSchema),
|
||||
label: optional(localizableSchema),
|
||||
required: optional(boolean()),
|
||||
}))),
|
||||
resources: optional(array(object({
|
||||
key: string(),
|
||||
actions: array(picklist(['read', 'write', 'subscribe'])),
|
||||
reason: optional(localizableSchema),
|
||||
label: optional(localizableSchema),
|
||||
required: optional(boolean()),
|
||||
}))),
|
||||
capabilities: optional(array(object({
|
||||
key: string(),
|
||||
actions: array(picklist(['wait', 'snapshot'])),
|
||||
reason: optional(localizableSchema),
|
||||
label: optional(localizableSchema),
|
||||
required: optional(boolean()),
|
||||
}))),
|
||||
processors: optional(array(object({
|
||||
key: string(),
|
||||
actions: array(picklist(['register', 'execute', 'manage'])),
|
||||
reason: optional(localizableSchema),
|
||||
label: optional(localizableSchema),
|
||||
required: optional(boolean()),
|
||||
}))),
|
||||
pipelines: optional(array(object({
|
||||
key: string(),
|
||||
actions: array(picklist(['hook', 'process', 'emit', 'manage'])),
|
||||
reason: optional(localizableSchema),
|
||||
label: optional(localizableSchema),
|
||||
required: optional(boolean()),
|
||||
}))),
|
||||
})
|
||||
|
||||
const manifestEntrypointsSchema = object({
|
||||
default: optional(string()),
|
||||
electron: optional(string()),
|
||||
node: optional(string()),
|
||||
web: optional(string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* Configures how the host resolves and loads a plugin entrypoint.
|
||||
* Validates a version-1 extension manifest.
|
||||
*
|
||||
* Use when:
|
||||
* - Calling `PluginHost.load(...)` or loader helpers directly
|
||||
* - Parsing `extension.airi.json` before loading an extension into the host
|
||||
*
|
||||
* Expects:
|
||||
* - Inputs use `id`, not legacy plugin `name`
|
||||
* - `permissions` describes the extension-level install/session ceiling
|
||||
*
|
||||
* Returns:
|
||||
* - A Valibot schema for the AIRI extension manifest format
|
||||
*/
|
||||
export const extensionManifestV1Schema = object({
|
||||
apiVersion: literal('v1'),
|
||||
kind: literal('manifest.extension.airi.moeru.ai'),
|
||||
id: string(),
|
||||
permissions: permissionDeclarationSchema,
|
||||
entrypoints: manifestEntrypointsSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
* Configures how the host resolves and loads an extension entrypoint.
|
||||
*
|
||||
* Use when:
|
||||
* - Calling loader helpers directly
|
||||
*
|
||||
* Expects:
|
||||
* - Omitted fields fall back to host defaults
|
||||
@@ -438,7 +339,7 @@ export const manifestV1Schema = object({
|
||||
* Returns:
|
||||
* - Runtime and working-directory overrides for one load operation
|
||||
*/
|
||||
export interface PluginLoadOptions {
|
||||
export interface ExtensionLoadOptions {
|
||||
/** Working directory used to resolve relative manifest entrypoints. */
|
||||
cwd?: string
|
||||
/** Runtime used when selecting a manifest entrypoint. */
|
||||
@@ -446,58 +347,29 @@ export interface PluginLoadOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures one `PluginHost` instance.
|
||||
* Configures one `ExtensionHost` instance.
|
||||
*
|
||||
* Use when:
|
||||
* - Constructing a host with specific runtime, transport, or permission behavior
|
||||
* - Constructing a host with specific runtime, permission, or contribution behavior
|
||||
*
|
||||
* Expects:
|
||||
* - Omitted fields fall back to the host defaults documented below
|
||||
*
|
||||
* Returns:
|
||||
* - The host bootstrap options consumed by {@link import('../core').PluginHost}
|
||||
* - The host bootstrap options consumed by {@link import('../core').ExtensionHost}
|
||||
*/
|
||||
export interface PluginHostOptions {
|
||||
export interface ExtensionHostOptions {
|
||||
/** Runtime used when callers do not override it per load/start call. @default 'electron' */
|
||||
runtime?: PluginRuntime
|
||||
/** Transport used when callers do not override it per load/start call. @default { kind: 'in-memory' } */
|
||||
transport?: PluginTransport
|
||||
/** Protocol version advertised during compatibility negotiation. @default 'v1' */
|
||||
protocolVersion?: string
|
||||
/** Plugin SDK API version advertised during compatibility negotiation. @default 'v1' */
|
||||
apiVersion?: string
|
||||
/** Additional protocol versions the host is willing to negotiate. @default [] */
|
||||
supportedProtocolVersions?: string[]
|
||||
/** Additional API versions the host is willing to negotiate. @default [] */
|
||||
supportedApiVersions?: string[]
|
||||
/** Callback that decides the granted permission set for one plugin session. */
|
||||
/** Callback that decides the granted permission set for one extension session. */
|
||||
permissionResolver?: (payload: {
|
||||
identity: ModuleIdentity
|
||||
manifest: ManifestV1
|
||||
identity: ExtensionIdentity
|
||||
manifest: ExtensionManifestV1
|
||||
requested: ModulePermissionDeclaration
|
||||
persisted?: ModulePermissionGrant
|
||||
}) => ModulePermissionGrant | Promise<ModulePermissionGrant>
|
||||
/** Installable host features that can extend session APIs and register host behavior. @default [] */
|
||||
contributions?: PluginHostContribution[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the stable session metadata exposed to host-installed contributions.
|
||||
*
|
||||
* Use when:
|
||||
* - Building contribution-owned session APIs
|
||||
* - Hooking plugin-session lifecycle work outside the core `PluginHost`
|
||||
*
|
||||
* Expects:
|
||||
* - Values come from the currently executing plugin session
|
||||
*
|
||||
* Returns:
|
||||
* - A minimal session context safe to pass outside `PluginHost`
|
||||
*/
|
||||
export interface PluginHostSessionContext {
|
||||
sessionId: string
|
||||
ownerPluginId: string
|
||||
runtime: PluginRuntime
|
||||
/** Installable host features that can register kits, resources, and capabilities. @default [] */
|
||||
contributions?: ExtensionHostContribution[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -510,9 +382,9 @@ export interface PluginHostSessionContext {
|
||||
* - The permission key/action pair matches the manifest permission contract
|
||||
*
|
||||
* Returns:
|
||||
* - The permission request consumed by `PluginHost.assertPermission(...)`
|
||||
* - The permission request consumed by `ExtensionHost.assertPermission(...)`
|
||||
*/
|
||||
export interface PluginHostPermissionRequest {
|
||||
export interface ExtensionHostPermissionRequest {
|
||||
area: 'apis' | 'resources' | 'capabilities' | 'processors' | 'pipelines'
|
||||
action: string
|
||||
key: string
|
||||
@@ -523,19 +395,16 @@ export interface PluginHostPermissionRequest {
|
||||
* Provides the host-owned registration surface that contributions can use during installation.
|
||||
*
|
||||
* Use when:
|
||||
* - Installing a host feature into `PluginHost`
|
||||
* - Registering session API namespaces, kits, resources, capabilities, or lifecycle hooks
|
||||
* - Installing a host feature into `ExtensionHost`
|
||||
* - Registering kits, resources, or capabilities
|
||||
*
|
||||
* Expects:
|
||||
* - Installation happens during `PluginHost` construction
|
||||
* - Session API namespace names are unique across all contributions and built-in namespaces
|
||||
* - Installation happens during `ExtensionHost` construction
|
||||
*
|
||||
* Returns:
|
||||
* - Registration helpers that keep `PluginHost` generic while allowing extensions
|
||||
* - Registration helpers that keep `ExtensionHost` generic while allowing host-specific features
|
||||
*/
|
||||
export interface PluginHostInstallContext {
|
||||
registerSessionApi: (namespace: string, factory: PluginSessionApiFactory) => void
|
||||
registerLifecycleHook: (event: PluginHostLifecycleEvent, hook: PluginHostLifecycleHook) => void
|
||||
export interface ExtensionHostInstallContext {
|
||||
registerKit: (kit: KitDescriptor) => KitDescriptor
|
||||
unregisterKit: (kitId: string) => KitDescriptor | undefined
|
||||
setResourceResolver: <T>(key: string, resolver: () => Promise<T> | T) => void
|
||||
@@ -547,89 +416,10 @@ export interface PluginHostInstallContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the context passed into one contribution-owned session API factory.
|
||||
* Installs one generic host feature into `ExtensionHost`.
|
||||
*
|
||||
* Use when:
|
||||
* - Creating a custom namespace that will be attached to `session.apis`
|
||||
*
|
||||
* Expects:
|
||||
* - `session` refers to the plugin session currently being assembled
|
||||
* - `assertPermission` is called inside contribution methods before privileged work
|
||||
*
|
||||
* Returns:
|
||||
* - The context needed to build one session API namespace
|
||||
*/
|
||||
export interface PluginSessionApiFactoryContext {
|
||||
host: PluginHostInstallContext
|
||||
session: PluginHostSessionContext
|
||||
assertPermission: (input: PluginHostPermissionRequest) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one custom session API namespace installed by a host contribution.
|
||||
*
|
||||
* Use when:
|
||||
* - Extending `session.apis` with a plugin-host-specific namespace
|
||||
*
|
||||
* Expects:
|
||||
* - The returned value is an object-like namespace safe to expose to plugin code
|
||||
*
|
||||
* Returns:
|
||||
* - The namespace object attached to `session.apis[namespace]`
|
||||
*/
|
||||
export type PluginSessionApiFactory<TNamespace = unknown> = (context: PluginSessionApiFactoryContext) => TNamespace
|
||||
|
||||
/**
|
||||
* Enumerates the host lifecycle moments contributions may observe.
|
||||
*
|
||||
* Use when:
|
||||
* - Registering contribution hooks tied to session load, readiness, or stop events
|
||||
*
|
||||
* Expects:
|
||||
* - Hooks are synchronous and should stay lightweight
|
||||
*
|
||||
* Returns:
|
||||
* - The supported lifecycle event names for `registerLifecycleHook(...)`
|
||||
*/
|
||||
export type PluginHostLifecycleEvent = 'session-loaded' | 'session-ready' | 'session-stopped'
|
||||
|
||||
/**
|
||||
* Describes the context passed into one contribution lifecycle hook.
|
||||
*
|
||||
* Use when:
|
||||
* - Reacting to a plugin session lifecycle event outside the generic host core
|
||||
*
|
||||
* Expects:
|
||||
* - `session` and `manifest` refer to the active session at the time of the hook
|
||||
*
|
||||
* Returns:
|
||||
* - The snapshot available to contribution lifecycle hooks
|
||||
*/
|
||||
export interface PluginHostLifecycleHookContext {
|
||||
host: PluginHostInstallContext
|
||||
session: PluginHostSessionContext
|
||||
manifest: ManifestV1
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles one contribution-owned lifecycle event emitted by `PluginHost`.
|
||||
*
|
||||
* Use when:
|
||||
* - A contribution needs to observe session loading, readiness, or teardown
|
||||
*
|
||||
* Expects:
|
||||
* - Hooks are synchronous and should throw only for deterministic setup failures
|
||||
*
|
||||
* Returns:
|
||||
* - No value; side effects are owned by the contribution
|
||||
*/
|
||||
export type PluginHostLifecycleHook = (context: PluginHostLifecycleHookContext) => void
|
||||
|
||||
/**
|
||||
* Installs one generic host feature into `PluginHost`.
|
||||
*
|
||||
* Use when:
|
||||
* - The host should register extra session APIs or bootstrap runtime-specific behavior
|
||||
* - The host should register kits, resources, capabilities, or runtime-specific behavior
|
||||
*
|
||||
* Expects:
|
||||
* - Installation is idempotent for one host instance
|
||||
@@ -638,15 +428,15 @@ export type PluginHostLifecycleHook = (context: PluginHostLifecycleHookContext)
|
||||
* Returns:
|
||||
* - No value; the contribution mutates the provided install context
|
||||
*/
|
||||
export interface PluginHostContribution {
|
||||
install: (context: PluginHostInstallContext) => void
|
||||
export interface ExtensionHostContribution {
|
||||
install: (context: ExtensionHostInstallContext) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures one `PluginHost.start(...)` or `PluginHost.init(...)` call.
|
||||
* Configures one `ExtensionHost.start(...)` call.
|
||||
*
|
||||
* Use when:
|
||||
* - Starting a session with runtime, compatibility, or capability-wait overrides
|
||||
* - Starting a session with runtime or working-directory overrides
|
||||
*
|
||||
* Expects:
|
||||
* - Omitted fields fall back to host defaults or method-local defaults
|
||||
@@ -654,17 +444,9 @@ export interface PluginHostContribution {
|
||||
* Returns:
|
||||
* - Per-start overrides for initialization behavior
|
||||
*/
|
||||
export interface PluginStartOptions {
|
||||
export interface ExtensionStartOptions {
|
||||
/** Working directory used to resolve relative manifest entrypoints. */
|
||||
cwd?: string
|
||||
/** Runtime override used for this specific start operation. */
|
||||
runtime?: PluginRuntime
|
||||
/** Whether initialization should stop in configuration-needed instead of auto-readying. */
|
||||
requireConfiguration?: boolean
|
||||
/** Compatibility ranges sent during protocol negotiation. */
|
||||
compatibility?: Omit<ModuleCompatibilityRequest, 'protocolVersion' | 'apiVersion'>
|
||||
/** Capability keys that must become ready before the session can proceed. */
|
||||
requiredCapabilities?: string[]
|
||||
/** Wait timeout applied to each required capability. @default 15000 */
|
||||
capabilityWaitTimeoutMs?: number
|
||||
}
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { defineExtension } from '../../extension'
|
||||
|
||||
export default defineExtension({
|
||||
id: 'test-define-extension-entrypoint',
|
||||
async setup(ctx) {
|
||||
await ctx.modules.register({ id: 'defined-extension-module' })
|
||||
},
|
||||
})
|
||||
+36
-32
@@ -1,42 +1,46 @@
|
||||
import type { ContextInit } from '../../plugin/shared'
|
||||
import type { KitRef } from '../../kit'
|
||||
|
||||
import { defineExtension } from '../../extension'
|
||||
|
||||
/**
|
||||
* Exercises host-injected plugin APIs during initialization.
|
||||
* Exercises host-injected extension kit APIs during setup.
|
||||
*
|
||||
* Use when:
|
||||
* - Verifying that a plugin can consume injected kit and binding APIs
|
||||
* - Testing end-to-end plugin host bindings from a real plugin entrypoint
|
||||
* - Verifying that an extension can consume injected kit APIs
|
||||
* - Testing end-to-end extension host bindings from a real extension entrypoint
|
||||
*
|
||||
* Expects:
|
||||
* - The host exposes `kit.widget` to the plugin runtime
|
||||
* - The manifest grants the plugin read and write permissions for the relevant resources
|
||||
* - The host exposes `kit.widget.test` to the extension runtime
|
||||
* - The manifest grants the extension permission to use the kit
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves after persisting the observed host state into a dynamic module config
|
||||
* - Resolves after persisting the observed host state into a dynamic binding config
|
||||
*/
|
||||
export async function init({ apis }: ContextInit): Promise<void> {
|
||||
const kits = await apis.kits.list()
|
||||
const widgetCapabilities = await apis.kits.getCapabilities('kit.widget')
|
||||
|
||||
await apis.bindings.announce({
|
||||
moduleId: 'test-injected-host-apis-module',
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'window',
|
||||
config: {
|
||||
route: '/widgets/injected-host-apis',
|
||||
},
|
||||
})
|
||||
|
||||
await apis.bindings.activate({
|
||||
moduleId: 'test-injected-host-apis-module',
|
||||
})
|
||||
|
||||
await apis.bindings.update({
|
||||
moduleId: 'test-injected-host-apis-module',
|
||||
config: {
|
||||
route: '/widgets/injected-host-apis',
|
||||
observedKitIds: kits.map(kit => kit.kitId),
|
||||
observedCapabilityKeys: widgetCapabilities.map(capability => capability.key).sort(),
|
||||
},
|
||||
})
|
||||
interface TestWidgetKitClient {
|
||||
mount: () => void
|
||||
}
|
||||
|
||||
export const testWidgetKit = {
|
||||
id: 'kit.widget.test',
|
||||
version: '1.0.0',
|
||||
createClient() {
|
||||
return {
|
||||
mount() {},
|
||||
}
|
||||
},
|
||||
} satisfies KitRef<TestWidgetKitClient>
|
||||
|
||||
export default defineExtension({
|
||||
id: 'test-plugin-injected-host-apis',
|
||||
async setup(ctx) {
|
||||
const module = await ctx.modules.register({
|
||||
id: 'test-injected-host-apis-module',
|
||||
permissions: {
|
||||
apis: [{ key: testWidgetKit.id, actions: ['invoke'] }],
|
||||
},
|
||||
})
|
||||
|
||||
const widgets = await module.kits.use(testWidgetKit)
|
||||
widgets.mount()
|
||||
},
|
||||
})
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const notAnExtension = true
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ContextInit } from '../../plugin/shared'
|
||||
import { defineExtension } from '../../extension'
|
||||
|
||||
export async function init(_initContext: ContextInit) {
|
||||
return false
|
||||
}
|
||||
export default defineExtension({
|
||||
id: 'test-plugin-no-connect',
|
||||
setup() {
|
||||
throw new Error('Plugin initialization aborted by plugin: test-plugin-no-connect')
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import type { ContextInit } from '../../plugin/shared'
|
||||
import { defineExtension } from '../../extension'
|
||||
|
||||
import { defineEventa } from '@moeru/eventa'
|
||||
|
||||
export async function init({ channels }: ContextInit): Promise<void | false> {
|
||||
channels.host.emit(defineEventa('vitest-call:init'), undefined)
|
||||
}
|
||||
|
||||
export async function configure(): Promise<void> {
|
||||
|
||||
}
|
||||
|
||||
export async function setupModules({ apis, channels }: ContextInit): Promise<void> {
|
||||
const providerList = await apis.providers.listProviders()
|
||||
channels.host.emit(defineEventa('vitest-call:setup-modules'), providerList)
|
||||
}
|
||||
export default defineExtension({
|
||||
id: 'test-plugin',
|
||||
setup() {},
|
||||
})
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { defineExtension } from '../../extension'
|
||||
|
||||
export const disposedSessionIds: string[] = []
|
||||
|
||||
export default defineExtension({
|
||||
id: 'test-stoppable-extension-entrypoint',
|
||||
async setup(ctx) {
|
||||
ctx.subscriptions.add({
|
||||
dispose() {
|
||||
disposedSessionIds.push(ctx.extension.sessionId)
|
||||
},
|
||||
})
|
||||
|
||||
await ctx.modules.register({ id: 'stoppable-extension-module' })
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Describes the transport selected for one plugin host session.
|
||||
* Describes the transport selected for one extension host session.
|
||||
*
|
||||
* Use when:
|
||||
* - Creating a plugin context for a specific runtime
|
||||
|
||||
@@ -2,12 +2,10 @@ import type { EventContext } from '@moeru/eventa'
|
||||
|
||||
import type { BindingClientBindings } from './bindings'
|
||||
import type { KitClientBindings } from './kits'
|
||||
import type { ToolClientBindings } from './tools'
|
||||
|
||||
import { createBindings } from './bindings'
|
||||
import { createKits } from './kits'
|
||||
import { createResources } from './resources'
|
||||
import { createTools } from './tools'
|
||||
|
||||
/**
|
||||
* Collects the host-provided callbacks that back the plugin client API surface.
|
||||
@@ -24,28 +22,26 @@ import { createTools } from './tools'
|
||||
export interface PluginApiBindings {
|
||||
kits?: KitClientBindings
|
||||
bindings?: BindingClientBindings
|
||||
tools?: ToolClientBindings
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the low-level plugin API surface exposed to plugin code.
|
||||
*
|
||||
* Use when:
|
||||
* - Building `ContextInit.apis` for a plugin session
|
||||
* - Building host-backed client APIs for kit runtimes
|
||||
*
|
||||
* Expects:
|
||||
* - `ctx` is the Eventa context for the current plugin session
|
||||
* - `ctx` is the Eventa context for the current extension session
|
||||
* - `bindings` contains the host-backed callbacks for each enabled API group
|
||||
*
|
||||
* Returns:
|
||||
* - The composed built-in plugin client APIs for resources, kits, bindings, and tools
|
||||
* - The composed built-in plugin client APIs for resources, kits, and bindings
|
||||
*/
|
||||
export function createApis(ctx: EventContext<any, any>, bindings: PluginApiBindings = {}) {
|
||||
return {
|
||||
...createResources(ctx),
|
||||
kits: createKits(ctx, bindings.kits),
|
||||
bindings: createBindings(ctx, bindings.bindings),
|
||||
tools: createTools(ctx, bindings.tools),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +49,7 @@ export function createApis(ctx: EventContext<any, any>, bindings: PluginApiBindi
|
||||
* Describes the concrete API object returned by {@link createApis}.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing `ContextInit.apis`
|
||||
* - Typing host-backed client APIs for kit runtimes
|
||||
*
|
||||
* Expects:
|
||||
* - The caller uses the same shape as the runtime-created API object
|
||||
@@ -65,4 +61,3 @@ export type PluginApis = ReturnType<typeof createApis>
|
||||
export * from './bindings'
|
||||
export * from './kits'
|
||||
export * from './resources'
|
||||
export * from './tools'
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createProviders } from './providers'
|
||||
* - Building the plugin SDK API object for a specific session
|
||||
*
|
||||
* Expects:
|
||||
* - `ctx` is the Eventa context for the current plugin session
|
||||
* - `ctx` is the Eventa context for the current extension session
|
||||
*
|
||||
* Returns:
|
||||
* - The resource client groups currently supported by the SDK
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { EventContext } from '@moeru/eventa'
|
||||
|
||||
import type { PluginToolDefinitionRecord, PluginToolsetPromptDefinitionRecord } from '../../../../plugin-host/shared'
|
||||
|
||||
/**
|
||||
* Identifies the bound API call used to register plugin tools.
|
||||
*
|
||||
* Use when:
|
||||
* - Declaring permissions for `apis.tools.register()`
|
||||
*
|
||||
* Expects:
|
||||
* - Host and plugin agree on this event name
|
||||
*
|
||||
* Returns:
|
||||
* - The permission/event key string for tool registration
|
||||
*/
|
||||
export const pluginToolApiRegisterEventName = 'proj-airi:plugin-sdk:apis:client:tools:register'
|
||||
/**
|
||||
* Identifies the shared resource namespace that stores plugin tool records.
|
||||
*
|
||||
* Use when:
|
||||
* - Declaring write permissions for tool registration
|
||||
*
|
||||
* Expects:
|
||||
* - The host stores tool definitions under this resource key
|
||||
*
|
||||
* Returns:
|
||||
* - The resource key string for the tool registry
|
||||
*/
|
||||
export const pluginToolRegistryResourceKey = 'proj-airi:plugin-sdk:resources:tools'
|
||||
|
||||
/**
|
||||
* Carries a low-level plugin tool registration request into the host.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin has already normalized its tool metadata and JSON Schema
|
||||
*
|
||||
* Expects:
|
||||
* - `tool` is serializable and validated by the caller
|
||||
* - `execute` accepts JSON-compatible input from the host
|
||||
*
|
||||
* Returns:
|
||||
* - A registration payload consumed by the bound host implementation
|
||||
*/
|
||||
export interface RegisterToolInput {
|
||||
tool: PluginToolDefinitionRecord
|
||||
availability?: () => Promise<boolean> | boolean
|
||||
execute: (input: unknown) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries one low-level toolset prompt registration request into the host.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin wants shared model-facing guidance for a group of tools
|
||||
*
|
||||
* Expects:
|
||||
* - `id` is stable within the owning plugin
|
||||
*
|
||||
* Returns:
|
||||
* - A registration payload consumed by the bound host implementation
|
||||
*/
|
||||
export type RegisterToolsetPromptInput = PluginToolsetPromptDefinitionRecord
|
||||
|
||||
/**
|
||||
* Defines the host-side callbacks needed by the low-level plugin tool client.
|
||||
*
|
||||
* Use when:
|
||||
* - Wiring plugin session APIs to host-owned registries
|
||||
*
|
||||
* Expects:
|
||||
* - `register` stores or forwards the tool definition in the host
|
||||
*
|
||||
* Returns:
|
||||
* - The bound client methods used by {@link createTools}
|
||||
*/
|
||||
export interface ToolClientBindings {
|
||||
register: (input: RegisterToolInput) => Promise<void> | void
|
||||
registerToolsetPrompt: (input: RegisterToolsetPromptInput) => Promise<void> | void
|
||||
}
|
||||
|
||||
function createMissingBindingError(method: string) {
|
||||
return new Error(`Plugin tool API binding missing for \`${method}\`.`)
|
||||
}
|
||||
|
||||
function requireBinding<TBinding>(binding: TBinding | undefined, method: string): TBinding {
|
||||
if (!binding) {
|
||||
throw createMissingBindingError(method)
|
||||
}
|
||||
|
||||
return binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the low-level plugin tool client surface exposed on `session.apis`.
|
||||
*
|
||||
* Use when:
|
||||
* - Building the plugin SDK API object for a specific session
|
||||
*
|
||||
* Expects:
|
||||
* - `bindings` comes from a host that knows how to store tool registrations
|
||||
*
|
||||
* Returns:
|
||||
* - A minimal `tools.register(...)` client
|
||||
*/
|
||||
export function createTools(_ctx: EventContext<any, any>, bindings?: ToolClientBindings) {
|
||||
return {
|
||||
async register(input: RegisterToolInput) {
|
||||
return await requireBinding(bindings, 'tools.register').register(input)
|
||||
},
|
||||
async registerToolsetPrompt(input: RegisterToolsetPromptInput) {
|
||||
return await requireBinding(bindings, 'tools.registerToolsetPrompt').registerToolsetPrompt(input)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the concrete client object returned by {@link createTools}.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing `apis.tools`
|
||||
*
|
||||
* Expects:
|
||||
* - The caller uses the same method set as the runtime-created tools client
|
||||
*
|
||||
* Returns:
|
||||
* - The inferred tools client surface
|
||||
*/
|
||||
export type ToolClient = ReturnType<typeof createTools>
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Plugin } from './shared'
|
||||
|
||||
/**
|
||||
* Declares a lazily constructed plugin definition with stable metadata.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin entrypoint wants to expose metadata and deferred setup together
|
||||
*
|
||||
* Expects:
|
||||
* - `setup` returns a {@link Plugin} object when the host loads the entrypoint
|
||||
*
|
||||
* Returns:
|
||||
* - A serializable plugin definition that loaders can recognize and execute
|
||||
*/
|
||||
export function definePlugin(name: string, version: string, setup: () => Promise<Plugin> | Plugin): {
|
||||
name: string
|
||||
version: string
|
||||
setup: () => Promise<Plugin> | Plugin
|
||||
} {
|
||||
return {
|
||||
name,
|
||||
version,
|
||||
setup,
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export * from './apis'
|
||||
export * from './define'
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { ChannelHost } from '../channels/shared'
|
||||
import type { PluginApis } from './apis/client'
|
||||
|
||||
/**
|
||||
* Describes the host-provided context injected into plugin hooks.
|
||||
*
|
||||
* Use when:
|
||||
* - Implementing `Plugin.init`
|
||||
* - Implementing `Plugin.setupModules`
|
||||
*
|
||||
* Expects:
|
||||
* - `channels.host` is the control-plane Eventa context for the session
|
||||
* - `apis` contains the host-bound plugin API surface for that session
|
||||
*
|
||||
* Returns:
|
||||
* - A stable bootstrap object shared across plugin lifecycle hooks
|
||||
*/
|
||||
export interface ContextInit {
|
||||
channels: {
|
||||
host: ChannelHost
|
||||
}
|
||||
apis: PluginApis
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the hook surface implemented by a plugin module.
|
||||
*
|
||||
* Use when:
|
||||
* - Exporting plugin behavior from a runtime entrypoint
|
||||
*
|
||||
* Expects:
|
||||
* - Hooks are optional, but at least one meaningful hook should be provided by a real plugin
|
||||
*
|
||||
* Returns:
|
||||
* - A plugin lifecycle object consumed by the plugin host loader
|
||||
*/
|
||||
export interface Plugin {
|
||||
/**
|
||||
* Performs plugin initialization against the injected host context.
|
||||
*
|
||||
* Use when:
|
||||
* - The plugin needs to announce state, wait for capabilities, or register resources during boot
|
||||
*
|
||||
* Expects:
|
||||
* - The host has already created the plugin session and bound `initContext`
|
||||
*
|
||||
* Returns:
|
||||
* - `false` to abort startup, or nothing to continue initialization
|
||||
*/
|
||||
init?: (initContext: ContextInit) => Promise<void | undefined | false>
|
||||
/**
|
||||
* Declares additional modules or bindings after basic initialization.
|
||||
*
|
||||
* Use when:
|
||||
* - The plugin wants to expose dynamic bindings after its initial boot logic
|
||||
*
|
||||
* Expects:
|
||||
* - The host has already created the plugin session and bound `initContext`
|
||||
*
|
||||
* Returns:
|
||||
* - Nothing. The host observes any side effects performed through `initContext.apis`.
|
||||
*/
|
||||
setupModules?: (initContext: ContextInit) => Promise<void | undefined>
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export { default as demoControlsSettingsChatWebsocketScenario } from './scenarios/demo-controls-settings-chat-websocket'
|
||||
export { default as demoDismissSurfacesScenario } from './scenarios/demo-dismiss-surfaces'
|
||||
export { default as demoHearingDialogScenario } from './scenarios/demo-hearing-dialog'
|
||||
export { default as pluginChessWidgetFlowScenario } from './scenarios/plugin-chess-widget-flow'
|
||||
export { default as pluginChessWorkerSmokeScenario } from './scenarios/plugin-chess-worker-smoke'
|
||||
export { default as pluginWidgetStaticAssetsLocalAddressScenario } from './scenarios/plugin-widget-static-assets-local-address'
|
||||
export { default as settingsConnectionScenario } from './scenarios/settings-connection'
|
||||
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
import type { ScenarioContext } from '@proj-airi/vishot-runner-electron'
|
||||
|
||||
import { defineScenario } from '@proj-airi/vishot-runner-electron'
|
||||
|
||||
type ElectronApplication = ScenarioContext['electronApp']
|
||||
type Page = Parameters<ScenarioContext['capture']>[1]
|
||||
type Frame = ReturnType<Page['frame']>
|
||||
|
||||
const pluginName = 'airi-plugin-game-chess'
|
||||
const pluginModuleId = 'chess-like-main'
|
||||
const chessExtensionUiProps = JSON.stringify({ moduleId: pluginModuleId }, null, 2)
|
||||
const spawnedWidgetPattern = /Spawned widget/i
|
||||
const whitespacePattern = /\s+/g
|
||||
const pluginExtensionFramePath = `/_airi/extensions/${pluginName}/sessions/`
|
||||
|
||||
function inferRouteFromUrl(url: string): string {
|
||||
const hashIndex = url.indexOf('#')
|
||||
if (hashIndex === -1) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const hash = url.slice(hashIndex + 1)
|
||||
if (!hash) {
|
||||
return '/'
|
||||
}
|
||||
|
||||
return hash.startsWith('/') ? hash : `/${hash}`
|
||||
}
|
||||
|
||||
function normalizeRoutePath(route: string): string {
|
||||
if (!route) {
|
||||
return ''
|
||||
}
|
||||
const queryIndex = route.indexOf('?')
|
||||
if (queryIndex >= 0) {
|
||||
return route.slice(0, queryIndex)
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
async function waitForWidgetsWindowPage(electronApp: ElectronApplication, timeoutMs = 30_000): Promise<Page> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let lastSeenWindows = ''
|
||||
while (Date.now() < deadline) {
|
||||
for (const page of electronApp.windows()) {
|
||||
const title = await page.title().catch(() => '')
|
||||
const url = page.url()
|
||||
const route = inferRouteFromUrl(url)
|
||||
const routePath = normalizeRoutePath(route)
|
||||
// NOTICE: `/settings/devtools/widgets-calling` also contains `"/widgets"`,
|
||||
// so this must match the exact widgets route/title to avoid selecting
|
||||
// the devtools settings page by mistake.
|
||||
if (title === 'Widgets' || routePath === '/widgets') {
|
||||
return page
|
||||
}
|
||||
}
|
||||
const snapshots = await Promise.all(
|
||||
electronApp.windows().map(async (page) => {
|
||||
const title = await page.title().catch(() => '')
|
||||
const url = page.url()
|
||||
const route = inferRouteFromUrl(url)
|
||||
return `${title || '(untitled)'} :: ${route || '(no-route)'} :: ${url}`
|
||||
}),
|
||||
)
|
||||
lastSeenWindows = snapshots.join('\n')
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for Widgets window.\nSeen windows:\n${lastSeenWindows}`)
|
||||
}
|
||||
|
||||
async function getPageText(page: Page) {
|
||||
return await page.locator('body').textContent().catch(() => '') ?? ''
|
||||
}
|
||||
|
||||
function excerpt(text: string, maxLength = 1800) {
|
||||
const normalized = text.replaceAll(whitespacePattern, ' ').trim()
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized
|
||||
}
|
||||
return `${normalized.slice(0, maxLength)}...`
|
||||
}
|
||||
|
||||
function normalizeWhitespace(text: string) {
|
||||
return text.replaceAll(whitespacePattern, ' ').trim()
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
check: () => Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
failureMessage: () => Promise<string> | string,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (await check()) {
|
||||
return
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
const message = typeof failureMessage === 'string'
|
||||
? failureMessage
|
||||
: await failureMessage()
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
async function waitForPluginReady(pluginHostPage: Page) {
|
||||
await pluginHostPage.getByPlaceholder('Filter discovered plugins...').fill(pluginName)
|
||||
await pluginHostPage.getByRole('button', { name: 'Refresh' }).click()
|
||||
|
||||
await waitForCondition(
|
||||
async () => (await getPageText(pluginHostPage)).includes(pluginName),
|
||||
15_000,
|
||||
async () => {
|
||||
const text = await getPageText(pluginHostPage)
|
||||
return [
|
||||
`Plugin not discovered in Plugin Host: ${pluginName}`,
|
||||
`Page excerpt: ${excerpt(text)}`,
|
||||
].join('\n')
|
||||
},
|
||||
)
|
||||
|
||||
await pluginHostPage.getByPlaceholder('Load discovered plugin by exact name...').fill(pluginName)
|
||||
await pluginHostPage.getByRole('button', { name: 'Load Plugin' }).click()
|
||||
await pluginHostPage.getByRole('button', { name: 'Refresh' }).click()
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const text = await getPageText(pluginHostPage)
|
||||
return text.includes('Loaded Plugins')
|
||||
&& text.includes(pluginName)
|
||||
&& text.includes('phase:ready')
|
||||
},
|
||||
20_000,
|
||||
async () => {
|
||||
const text = await getPageText(pluginHostPage)
|
||||
return [
|
||||
`Plugin did not reach ready phase. Expected plugin=${pluginName}`,
|
||||
`Page excerpt: ${excerpt(text)}`,
|
||||
].join('\n')
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForSpawnedWidget(widgetsCallingPage: Page) {
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const text = await getPageText(widgetsCallingPage)
|
||||
return spawnedWidgetPattern.test(text)
|
||||
},
|
||||
15_000,
|
||||
async () => {
|
||||
const text = await getPageText(widgetsCallingPage)
|
||||
if (text.includes(`Plugin manifest not found: ${pluginName}`)) {
|
||||
return `Widget spawn failed: Plugin manifest not found: ${pluginName}\nPage excerpt: ${excerpt(text)}`
|
||||
}
|
||||
if (text.includes(`Plugin module "${pluginModuleId}" is not registered.`)) {
|
||||
return `Widget spawn failed: Plugin module "${pluginModuleId}" is not registered.\nPage excerpt: ${excerpt(text)}`
|
||||
}
|
||||
return `Widget spawn timed out. Page excerpt: ${excerpt(text)}`
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function getFrameText(frame: Frame | null | undefined) {
|
||||
if (!frame) {
|
||||
return ''
|
||||
}
|
||||
return await frame.locator('body').textContent().catch(() => '') ?? ''
|
||||
}
|
||||
|
||||
async function waitForChessFrameContent(widgetsPage: Page) {
|
||||
await widgetsPage.locator('iframe').first().waitFor({ state: 'visible', timeout: 20_000 })
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const frame = widgetsPage.frames().find(candidate =>
|
||||
candidate.url().includes(pluginExtensionFramePath)
|
||||
|| candidate.url().startsWith('airi-plugin://'),
|
||||
)
|
||||
const text = await getFrameText(frame)
|
||||
if (text.includes('Not Found')) {
|
||||
throw new Error(`Plugin iframe returned Not Found. frameUrl=${frame?.url() ?? 'unknown'} text=${excerpt(text)}`)
|
||||
}
|
||||
return text.includes('Match Setup')
|
||||
},
|
||||
20_000,
|
||||
async () => {
|
||||
const iframeSrc = await widgetsPage.locator('iframe').first().getAttribute('src').catch(() => null)
|
||||
const frameUrls = widgetsPage.frames().map(frame => frame.url()).join('\n')
|
||||
const candidateFrame = widgetsPage.frames().find(frame =>
|
||||
frame.url().includes(pluginExtensionFramePath)
|
||||
|| frame.url().startsWith('airi-plugin://'),
|
||||
)
|
||||
const frameText = await getFrameText(candidateFrame)
|
||||
return [
|
||||
'Timed out waiting for chess iframe content.',
|
||||
`iframe src: ${iframeSrc ?? '(none)'}`,
|
||||
`frame urls:\n${frameUrls}`,
|
||||
`frame text excerpt: ${excerpt(frameText)}`,
|
||||
].join('\n')
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default defineScenario({
|
||||
id: 'plugin-chess-widget-flow',
|
||||
async run({ capture, controlsIsland, electronApp, settingsWindow, stageWindows }) {
|
||||
const mainWindow = await stageWindows.waitFor('main')
|
||||
await controlsIsland.waitForReady(mainWindow.page)
|
||||
|
||||
await controlsIsland.expand(mainWindow.page)
|
||||
const settings = await controlsIsland.openSettings(mainWindow.page)
|
||||
|
||||
const pluginHostPage = await settingsWindow.goToRoute(settings.page, '/devtools/plugin-host')
|
||||
await waitForPluginReady(pluginHostPage)
|
||||
|
||||
const widgetsCallingPage = await settingsWindow.goToRoute(settings.page, '/devtools/widgets-calling')
|
||||
await widgetsCallingPage.getByRole('button', { name: 'Extension UI Preset' }).click()
|
||||
await widgetsCallingPage.getByLabel('Component Props (JSON)').fill(chessExtensionUiProps)
|
||||
await widgetsCallingPage.getByRole('button', { name: 'Spawn / Replace' }).click()
|
||||
await waitForSpawnedWidget(widgetsCallingPage)
|
||||
await capture('plugin-chess-widget-settings-flow', widgetsCallingPage)
|
||||
|
||||
const widgetsPage = await waitForWidgetsWindowPage(electronApp, 45_000)
|
||||
await capture('plugin-chess-widget-window-before-iframe', widgetsPage, { fullPage: true })
|
||||
await waitForChessFrameContent(widgetsPage)
|
||||
await capture('plugin-chess-widget-window-setup', widgetsPage, { fullPage: true })
|
||||
const moduleFrame = widgetsPage.frameLocator('iframe')
|
||||
|
||||
const gridMetrics = await moduleFrame.locator('button[title="a1"]').evaluate((node) => {
|
||||
const rect = node.getBoundingClientRect()
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
delta: Math.abs(rect.width - rect.height),
|
||||
}
|
||||
})
|
||||
if (gridMetrics.delta > 1) {
|
||||
throw new Error(
|
||||
`Chess grid square is not square enough (a1): width=${gridMetrics.width.toFixed(2)} height=${gridMetrics.height.toFixed(2)} delta=${gridMetrics.delta.toFixed(2)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Normalize mode for deterministic scenario playback.
|
||||
await moduleFrame.getByRole('radio', { name: 'Vs AIRI' }).click().catch(() => undefined)
|
||||
await moduleFrame.getByRole('button', { name: 'Start Game' }).click()
|
||||
|
||||
// Verify gameplay is interactive and AI responds.
|
||||
await moduleFrame.getByTitle('e2').click()
|
||||
await moduleFrame.getByTitle('e4').click()
|
||||
await widgetsPage.waitForTimeout(1800)
|
||||
|
||||
const afterMoveText = normalizeWhitespace(await moduleFrame.locator('body').textContent().catch(() => '') ?? '')
|
||||
if (afterMoveText.includes('AI Engine Minimax fallback') || afterMoveText.includes('Minimax fallback')) {
|
||||
throw new Error(`AIRI engine fell back to minimax during scenario. Frame text excerpt: ${excerpt(afterMoveText)}`)
|
||||
}
|
||||
|
||||
await widgetsPage.waitForTimeout(500)
|
||||
await capture('plugin-chess-widget-window', widgetsPage, { fullPage: true })
|
||||
},
|
||||
})
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
import type { ScenarioContext } from '@proj-airi/vishot-runner-electron'
|
||||
|
||||
import { defineScenario } from '@proj-airi/vishot-runner-electron'
|
||||
|
||||
type ElectronApplication = ScenarioContext['electronApp']
|
||||
type Page = Parameters<ScenarioContext['capture']>[1]
|
||||
type Frame = NonNullable<ReturnType<Page['frame']>>
|
||||
|
||||
const pluginName = 'airi-plugin-game-chess'
|
||||
const pluginModuleId = 'chess-like-main'
|
||||
const chessExtensionUiProps = JSON.stringify({ moduleId: pluginModuleId }, null, 2)
|
||||
const whitespacePattern = /\s+/g
|
||||
const stockfishJsPattern = /stockfish-18-lite-single-[\w-]+\.js/
|
||||
const stockfishWasmPattern = /stockfish-18-lite-single-[\w-]+\.wasm/
|
||||
const pluginExtensionFramePath = `/_airi/extensions/${pluginName}/sessions/`
|
||||
|
||||
function inferRouteFromUrl(url: string): string {
|
||||
const hashIndex = url.indexOf('#')
|
||||
if (hashIndex === -1) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const hash = url.slice(hashIndex + 1)
|
||||
if (!hash) {
|
||||
return '/'
|
||||
}
|
||||
|
||||
return hash.startsWith('/') ? hash : `/${hash}`
|
||||
}
|
||||
|
||||
function normalizeRoutePath(route: string): string {
|
||||
if (!route) {
|
||||
return ''
|
||||
}
|
||||
const queryIndex = route.indexOf('?')
|
||||
if (queryIndex >= 0) {
|
||||
return route.slice(0, queryIndex)
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
function excerpt(text: string, maxLength = 1800) {
|
||||
const normalized = text.replaceAll(whitespacePattern, ' ').trim()
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized
|
||||
}
|
||||
return `${normalized.slice(0, maxLength)}...`
|
||||
}
|
||||
|
||||
async function getPageText(page: Page) {
|
||||
return await page.locator('body').textContent().catch(() => '') ?? ''
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
check: () => Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
failureMessage: () => Promise<string> | string,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (await check()) {
|
||||
return
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
const message = typeof failureMessage === 'string'
|
||||
? failureMessage
|
||||
: await failureMessage()
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
async function waitForWidgetsWindowPage(electronApp: ElectronApplication, timeoutMs = 30_000): Promise<Page> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let lastSeenWindows = ''
|
||||
while (Date.now() < deadline) {
|
||||
for (const page of electronApp.windows()) {
|
||||
const title = await page.title().catch(() => '')
|
||||
const url = page.url()
|
||||
const route = inferRouteFromUrl(url)
|
||||
const routePath = normalizeRoutePath(route)
|
||||
if (title === 'Widgets' || routePath === '/widgets') {
|
||||
return page
|
||||
}
|
||||
}
|
||||
|
||||
const snapshots = await Promise.all(
|
||||
electronApp.windows().map(async (page) => {
|
||||
const title = await page.title().catch(() => '')
|
||||
const url = page.url()
|
||||
const route = inferRouteFromUrl(url)
|
||||
return `${title || '(untitled)'} :: ${route || '(no-route)'} :: ${url}`
|
||||
}),
|
||||
)
|
||||
lastSeenWindows = snapshots.join('\n')
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for Widgets window.\nSeen windows:\n${lastSeenWindows}`)
|
||||
}
|
||||
|
||||
async function waitForPluginReady(pluginHostPage: Page) {
|
||||
await pluginHostPage.getByPlaceholder('Filter discovered plugins...').fill(pluginName)
|
||||
await pluginHostPage.getByRole('button', { name: 'Refresh' }).click()
|
||||
|
||||
await waitForCondition(
|
||||
async () => (await getPageText(pluginHostPage)).includes(pluginName),
|
||||
15_000,
|
||||
async () => {
|
||||
const text = await getPageText(pluginHostPage)
|
||||
return [
|
||||
`Plugin not discovered in Plugin Host: ${pluginName}`,
|
||||
`Page excerpt: ${excerpt(text)}`,
|
||||
].join('\n')
|
||||
},
|
||||
)
|
||||
|
||||
await pluginHostPage.getByPlaceholder('Load discovered plugin by exact name...').fill(pluginName)
|
||||
await pluginHostPage.getByRole('button', { name: 'Load Plugin' }).click()
|
||||
await pluginHostPage.getByRole('button', { name: 'Refresh' }).click()
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const text = await getPageText(pluginHostPage)
|
||||
return text.includes('Loaded Plugins')
|
||||
&& text.includes(pluginName)
|
||||
&& text.includes('phase:ready')
|
||||
},
|
||||
20_000,
|
||||
async () => {
|
||||
const text = await getPageText(pluginHostPage)
|
||||
return [
|
||||
`Plugin did not reach ready phase. Expected plugin=${pluginName}`,
|
||||
`Page excerpt: ${excerpt(text)}`,
|
||||
].join('\n')
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function findChessFrame(widgetsPage: Page) {
|
||||
return widgetsPage.frames().find(candidate =>
|
||||
candidate.url().includes(pluginExtensionFramePath)
|
||||
|| candidate.url().startsWith('airi-plugin://'),
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForChessFrameContent(widgetsPage: Page): Promise<Frame> {
|
||||
await widgetsPage.locator('iframe').first().waitFor({ state: 'visible', timeout: 20_000 })
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const frame = findChessFrame(widgetsPage)
|
||||
const text = await frame?.locator('body').textContent().catch(() => '') ?? ''
|
||||
return text.includes('Match Setup')
|
||||
},
|
||||
20_000,
|
||||
async () => {
|
||||
const iframeSrc = await widgetsPage.locator('iframe').first().getAttribute('src').catch(() => null)
|
||||
const frameUrls = widgetsPage.frames().map(frame => frame.url()).join('\n')
|
||||
return [
|
||||
'Timed out waiting for chess iframe content.',
|
||||
`iframe src: ${iframeSrc ?? '(none)'}`,
|
||||
`frame urls:\n${frameUrls}`,
|
||||
].join('\n')
|
||||
},
|
||||
)
|
||||
|
||||
const frame = findChessFrame(widgetsPage)
|
||||
if (!frame) {
|
||||
throw new Error('Chess iframe frame not found after content wait.')
|
||||
}
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
async function runWorkerSmoke(frame: Frame) {
|
||||
return await frame.evaluate(async ({ stockfishJsPatternSource, stockfishWasmPatternSource }) => {
|
||||
const stockfishJsPattern = new RegExp(stockfishJsPatternSource)
|
||||
const stockfishWasmPattern = new RegExp(stockfishWasmPatternSource)
|
||||
const moduleScript = document.querySelector('script[type="module"][src]') as HTMLScriptElement | null
|
||||
if (!moduleScript?.src) {
|
||||
return { ok: false, reason: 'Missing module script in iframe document' } as const
|
||||
}
|
||||
|
||||
const indexSource = await fetch(moduleScript.src).then(response => response.text())
|
||||
const jsMatch = indexSource.match(stockfishJsPattern)?.[0]
|
||||
const wasmMatch = indexSource.match(stockfishWasmPattern)?.[0]
|
||||
if (!jsMatch || !wasmMatch) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Failed to locate stockfish asset names in index chunk',
|
||||
moduleScriptSrc: moduleScript.src,
|
||||
} as const
|
||||
}
|
||||
|
||||
const workerScriptUrl = new URL(jsMatch, moduleScript.src).toString()
|
||||
const wasmUrl = new URL(wasmMatch, moduleScript.src).toString()
|
||||
const workerUrl = `${workerScriptUrl}#${encodeURIComponent(wasmUrl)}`
|
||||
const workerFetch = await fetch(workerScriptUrl).then((response) => {
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type') ?? '',
|
||||
}
|
||||
}).catch((error) => {
|
||||
return {
|
||||
ok: false,
|
||||
status: -1,
|
||||
contentType: String(error),
|
||||
}
|
||||
})
|
||||
const wasmFetch = await fetch(wasmUrl).then((response) => {
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type') ?? '',
|
||||
}
|
||||
}).catch((error) => {
|
||||
return {
|
||||
ok: false,
|
||||
status: -1,
|
||||
contentType: String(error),
|
||||
}
|
||||
})
|
||||
|
||||
let genericWorkerOk = false
|
||||
let genericWorkerError = ''
|
||||
try {
|
||||
const genericUrl = URL.createObjectURL(new Blob(['onmessage=()=>postMessage("pong")'], { type: 'text/javascript' }))
|
||||
const genericWorker = new Worker(genericUrl)
|
||||
const genericResult = await new Promise<'ok' | 'timeout' | 'error'>((resolve) => {
|
||||
const timer = setTimeout(resolve, 3_000, 'timeout')
|
||||
genericWorker.addEventListener('message', () => {
|
||||
clearTimeout(timer)
|
||||
resolve('ok')
|
||||
})
|
||||
genericWorker.addEventListener('error', () => {
|
||||
clearTimeout(timer)
|
||||
resolve('error')
|
||||
})
|
||||
genericWorker.postMessage('ping')
|
||||
})
|
||||
genericWorker.terminate()
|
||||
URL.revokeObjectURL(genericUrl)
|
||||
genericWorkerOk = genericResult === 'ok'
|
||||
if (genericResult !== 'ok') {
|
||||
genericWorkerError = `generic-worker-${genericResult}`
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
genericWorkerError = String(error)
|
||||
}
|
||||
|
||||
const logs: string[] = []
|
||||
let workerError = ''
|
||||
let ready = false
|
||||
let done = false
|
||||
|
||||
const worker = new Worker(workerUrl)
|
||||
const timeout = setTimeout(() => {
|
||||
done = true
|
||||
}, 20_000)
|
||||
|
||||
worker.addEventListener('message', (event) => {
|
||||
const text = typeof event.data === 'string' ? event.data : String(event.data)
|
||||
logs.push(text)
|
||||
if (text.includes('uciok')) {
|
||||
ready = true
|
||||
done = true
|
||||
}
|
||||
})
|
||||
worker.addEventListener('error', (event) => {
|
||||
workerError = event.message || 'worker error event'
|
||||
done = true
|
||||
})
|
||||
worker.addEventListener('messageerror', () => {
|
||||
workerError = 'worker messageerror event'
|
||||
done = true
|
||||
})
|
||||
|
||||
const pingTimer = setInterval(() => {
|
||||
try {
|
||||
worker.postMessage('uci')
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
}, 700)
|
||||
|
||||
const deadline = Date.now() + 20_000
|
||||
for (;;) {
|
||||
if (done || Date.now() >= deadline) {
|
||||
break
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 120))
|
||||
}
|
||||
|
||||
clearInterval(pingTimer)
|
||||
clearTimeout(timeout)
|
||||
worker.terminate()
|
||||
|
||||
return {
|
||||
ok: ready,
|
||||
workerUrl,
|
||||
wasmUrl,
|
||||
workerFetch,
|
||||
wasmFetch,
|
||||
genericWorkerOk,
|
||||
genericWorkerError,
|
||||
workerError,
|
||||
logs,
|
||||
} as const
|
||||
}, {
|
||||
stockfishJsPatternSource: stockfishJsPattern.source,
|
||||
stockfishWasmPatternSource: stockfishWasmPattern.source,
|
||||
})
|
||||
}
|
||||
|
||||
export default defineScenario({
|
||||
id: 'plugin-chess-worker-smoke',
|
||||
async run({ capture, controlsIsland, electronApp, settingsWindow, stageWindows }) {
|
||||
const mainWindow = await stageWindows.waitFor('main')
|
||||
await controlsIsland.waitForReady(mainWindow.page)
|
||||
|
||||
await controlsIsland.expand(mainWindow.page)
|
||||
const settings = await controlsIsland.openSettings(mainWindow.page)
|
||||
|
||||
const pluginHostPage = await settingsWindow.goToRoute(settings.page, '/devtools/plugin-host')
|
||||
await waitForPluginReady(pluginHostPage)
|
||||
|
||||
const widgetsCallingPage = await settingsWindow.goToRoute(settings.page, '/devtools/widgets-calling')
|
||||
await widgetsCallingPage.getByRole('button', { name: 'Extension UI Preset' }).click()
|
||||
await widgetsCallingPage.getByLabel('Component Props (JSON)').fill(chessExtensionUiProps)
|
||||
await widgetsCallingPage.getByRole('button', { name: 'Spawn / Replace' }).click()
|
||||
|
||||
const widgetsPage = await waitForWidgetsWindowPage(electronApp, 45_000)
|
||||
const moduleFrame = await waitForChessFrameContent(widgetsPage)
|
||||
const smoke = await runWorkerSmoke(moduleFrame)
|
||||
await capture('plugin-chess-worker-smoke', widgetsPage, { fullPage: true })
|
||||
|
||||
if (!smoke.ok) {
|
||||
throw new Error([
|
||||
'Stockfish worker smoke test failed.',
|
||||
`workerUrl: ${'workerUrl' in smoke ? smoke.workerUrl : '(missing)'}`,
|
||||
`wasmUrl: ${'wasmUrl' in smoke ? smoke.wasmUrl : '(missing)'}`,
|
||||
`workerFetch: ${'workerFetch' in smoke ? JSON.stringify(smoke.workerFetch) : '(missing)'}`,
|
||||
`wasmFetch: ${'wasmFetch' in smoke ? JSON.stringify(smoke.wasmFetch) : '(missing)'}`,
|
||||
`genericWorkerOk: ${'genericWorkerOk' in smoke ? String(smoke.genericWorkerOk) : '(missing)'}`,
|
||||
`genericWorkerError: ${'genericWorkerError' in smoke ? smoke.genericWorkerError || '(none)' : '(missing)'}`,
|
||||
`workerError: ${'workerError' in smoke ? smoke.workerError || '(none)' : '(missing)'}`,
|
||||
`logs: ${'logs' in smoke ? excerpt((smoke.logs ?? []).join(' | ')) : '(none)'}`,
|
||||
`reason: ${'reason' in smoke ? smoke.reason : '(none)'}`,
|
||||
].join('\n'))
|
||||
}
|
||||
},
|
||||
})
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
import type { ScenarioContext } from '@proj-airi/vishot-runner-electron'
|
||||
|
||||
import { defineScenario } from '@proj-airi/vishot-runner-electron'
|
||||
|
||||
type ElectronApplication = ScenarioContext['electronApp']
|
||||
type Page = Parameters<ScenarioContext['capture']>[1]
|
||||
type Frame = ReturnType<Page['frame']>
|
||||
|
||||
const extensionId = 'airi-plugin-game-chess'
|
||||
const pluginModuleId = 'chess-like-main'
|
||||
const chessExtensionUiProps = JSON.stringify({ moduleId: pluginModuleId }, null, 2)
|
||||
const spawnedWidgetPattern = /Spawned widget/i
|
||||
const whitespacePattern = /\s+/g
|
||||
const extensionRoutePathPrefix = `/_airi/extensions/${extensionId}/sessions/`
|
||||
|
||||
function inferRouteFromUrl(url: string): string {
|
||||
const hashIndex = url.indexOf('#')
|
||||
if (hashIndex === -1) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const hash = url.slice(hashIndex + 1)
|
||||
if (!hash) {
|
||||
return '/'
|
||||
}
|
||||
|
||||
return hash.startsWith('/') ? hash : `/${hash}`
|
||||
}
|
||||
|
||||
function normalizeRoutePath(route: string): string {
|
||||
if (!route) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const queryIndex = route.indexOf('?')
|
||||
if (queryIndex >= 0) {
|
||||
return route.slice(0, queryIndex)
|
||||
}
|
||||
|
||||
return route
|
||||
}
|
||||
|
||||
function excerpt(text: string, maxLength = 1800) {
|
||||
const normalized = text.replaceAll(whitespacePattern, ' ').trim()
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized
|
||||
}
|
||||
return `${normalized.slice(0, maxLength)}...`
|
||||
}
|
||||
|
||||
async function getPageText(page: Page) {
|
||||
return await page.locator('body').textContent().catch(() => '') ?? ''
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
check: () => Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
failureMessage: () => Promise<string> | string,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (await check()) {
|
||||
return
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
const message = typeof failureMessage === 'string'
|
||||
? failureMessage
|
||||
: await failureMessage()
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
async function waitForWidgetsWindowPage(electronApp: ElectronApplication, timeoutMs = 30_000): Promise<Page> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let lastSeenWindows = ''
|
||||
while (Date.now() < deadline) {
|
||||
for (const page of electronApp.windows()) {
|
||||
const title = await page.title().catch(() => '')
|
||||
const url = page.url()
|
||||
const route = inferRouteFromUrl(url)
|
||||
const routePath = normalizeRoutePath(route)
|
||||
if (title === 'Widgets' || routePath === '/widgets') {
|
||||
return page
|
||||
}
|
||||
}
|
||||
|
||||
const snapshots = await Promise.all(
|
||||
electronApp.windows().map(async (page) => {
|
||||
const title = await page.title().catch(() => '')
|
||||
const url = page.url()
|
||||
const route = inferRouteFromUrl(url)
|
||||
return `${title || '(untitled)'} :: ${route || '(no-route)'} :: ${url}`
|
||||
}),
|
||||
)
|
||||
lastSeenWindows = snapshots.join('\n')
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for Widgets window.\nSeen windows:\n${lastSeenWindows}`)
|
||||
}
|
||||
|
||||
async function ensurePluginEnabledAndLoaded(pluginHostPage: Page) {
|
||||
await pluginHostPage.getByPlaceholder('Filter discovered plugins...').fill(extensionId)
|
||||
await pluginHostPage.getByRole('button', { name: 'Refresh' }).click()
|
||||
|
||||
await waitForCondition(
|
||||
async () => (await getPageText(pluginHostPage)).includes(extensionId),
|
||||
15_000,
|
||||
async () => {
|
||||
const text = await getPageText(pluginHostPage)
|
||||
return [
|
||||
`Plugin not discovered in Plugin Host: ${extensionId}`,
|
||||
`Page excerpt: ${excerpt(text)}`,
|
||||
].join('\n')
|
||||
},
|
||||
)
|
||||
|
||||
const pluginTitle = pluginHostPage.locator('div.font-semibold', { hasText: extensionId }).first()
|
||||
const pluginCard = pluginTitle.locator('xpath=ancestor::div[contains(@class, "rounded-xl")][1]')
|
||||
|
||||
const hasDisabledChip = await pluginCard.getByText('disabled').first().isVisible().catch(() => false)
|
||||
if (hasDisabledChip) {
|
||||
await pluginCard.getByRole('button', { name: 'Enable' }).click()
|
||||
await pluginHostPage.getByRole('button', { name: 'Refresh' }).click()
|
||||
}
|
||||
|
||||
await pluginHostPage.getByPlaceholder('Load discovered plugin by exact name...').fill(extensionId)
|
||||
await pluginHostPage.getByRole('button', { name: 'Load Plugin' }).click()
|
||||
await pluginHostPage.getByRole('button', { name: 'Refresh' }).click()
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const text = await getPageText(pluginHostPage)
|
||||
return text.includes('Loaded Plugins')
|
||||
&& text.includes(extensionId)
|
||||
&& text.includes('phase:ready')
|
||||
},
|
||||
20_000,
|
||||
async () => {
|
||||
const text = await getPageText(pluginHostPage)
|
||||
return [
|
||||
`Plugin did not reach ready phase. Expected extension=${extensionId}`,
|
||||
`Page excerpt: ${excerpt(text)}`,
|
||||
].join('\n')
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForSpawnedWidget(widgetsCallingPage: Page) {
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const text = await getPageText(widgetsCallingPage)
|
||||
return spawnedWidgetPattern.test(text)
|
||||
},
|
||||
15_000,
|
||||
async () => {
|
||||
const text = await getPageText(widgetsCallingPage)
|
||||
if (text.includes(`Plugin manifest not found: ${extensionId}`)) {
|
||||
return `Widget spawn failed: Plugin manifest not found: ${extensionId}\nPage excerpt: ${excerpt(text)}`
|
||||
}
|
||||
if (text.includes(`Plugin module "${pluginModuleId}" is not registered.`)) {
|
||||
return `Widget spawn failed: Plugin module "${pluginModuleId}" is not registered.\nPage excerpt: ${excerpt(text)}`
|
||||
}
|
||||
return `Widget spawn timed out. Page excerpt: ${excerpt(text)}`
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function getFrameText(frame: Frame | null | undefined) {
|
||||
if (!frame) {
|
||||
return ''
|
||||
}
|
||||
return await frame.locator('body').textContent().catch(() => '') ?? ''
|
||||
}
|
||||
|
||||
async function waitForExtensionFrame(widgetsPage: Page) {
|
||||
await widgetsPage.locator('iframe').first().waitFor({ state: 'visible', timeout: 20_000 })
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const frame = widgetsPage.frames().find(candidate =>
|
||||
candidate.url().includes(extensionRoutePathPrefix),
|
||||
)
|
||||
return Boolean(frame)
|
||||
},
|
||||
20_000,
|
||||
async () => {
|
||||
const iframeSrc = await widgetsPage.locator('iframe').first().getAttribute('src').catch(() => null)
|
||||
const frameUrls = widgetsPage.frames().map(frame => frame.url()).join('\n')
|
||||
return [
|
||||
'Timed out waiting for extension iframe content.',
|
||||
`iframe src: ${iframeSrc ?? '(none)'}`,
|
||||
`frame urls:\n${frameUrls}`,
|
||||
].join('\n')
|
||||
},
|
||||
)
|
||||
|
||||
const frame = widgetsPage.frames().find(candidate =>
|
||||
candidate.url().includes(extensionRoutePathPrefix),
|
||||
)
|
||||
if (!frame) {
|
||||
throw new Error(`Extension iframe frame not found with path prefix ${extensionRoutePathPrefix}.`)
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
function assertExtensionAssetUrl(url: string, label: string) {
|
||||
if (!url) {
|
||||
throw new Error(`${label} is empty.`)
|
||||
}
|
||||
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'http:') {
|
||||
throw new Error(`${label} must use http protocol, got ${parsed.protocol} (${url}).`)
|
||||
}
|
||||
if (parsed.hostname !== '127.0.0.1') {
|
||||
throw new Error(`${label} must use 127.0.0.1, got ${parsed.hostname} (${url}).`)
|
||||
}
|
||||
if (!parsed.pathname.startsWith(extensionRoutePathPrefix) || !parsed.pathname.includes('/ui/')) {
|
||||
throw new Error(`${label} must use ${extensionRoutePathPrefix}:assetSessionId/ui/... path, got ${parsed.pathname} (${url}).`)
|
||||
}
|
||||
if (parsed.searchParams.has('t')) {
|
||||
throw new Error(`${label} must not contain legacy auth token query param "t", got ${url}.`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures and verifies plugin widget static asset loading through local extension HTTP endpoint.
|
||||
*
|
||||
* Use when:
|
||||
* - Validating plugin-host + widget-calling devtools integration end-to-end
|
||||
* - Verifying iframe static assets are served from local auth-protected extension routes
|
||||
*
|
||||
* Expects:
|
||||
* - Chess plugin is discoverable by plugin host (`airi-plugin-game-chess`)
|
||||
* - Widget module `chess-like-main` is available after plugin load
|
||||
*
|
||||
* Returns:
|
||||
* - Visual captures for plugin host, widgets-calling, and widget iframe window
|
||||
* - Runtime assertions that iframe URL/frame URL are `http://127.0.0.1:<port>/_airi/extensions/.../sessions/.../ui/...`
|
||||
*/
|
||||
export default defineScenario({
|
||||
id: 'plugin-widget-static-assets-local-address',
|
||||
async run({ capture, controlsIsland, electronApp, settingsWindow, stageWindows }) {
|
||||
const mainWindow = await stageWindows.waitFor('main')
|
||||
await controlsIsland.waitForReady(mainWindow.page)
|
||||
|
||||
await controlsIsland.expand(mainWindow.page)
|
||||
const settings = await controlsIsland.openSettings(mainWindow.page)
|
||||
|
||||
const pluginHostPage = await settingsWindow.goToRoute(settings.page, '/devtools/plugin-host')
|
||||
await ensurePluginEnabledAndLoaded(pluginHostPage)
|
||||
|
||||
const widgetsCallingPage = await settingsWindow.goToRoute(settings.page, '/devtools/widgets-calling')
|
||||
await widgetsCallingPage.getByRole('button', { name: 'Extension UI Preset' }).click()
|
||||
await widgetsCallingPage.getByLabel('Component Props (JSON)').fill(chessExtensionUiProps)
|
||||
await widgetsCallingPage.getByRole('button', { name: 'Spawn / Replace' }).click()
|
||||
await waitForSpawnedWidget(widgetsCallingPage)
|
||||
|
||||
const widgetsPage = await waitForWidgetsWindowPage(electronApp, 45_000)
|
||||
await widgetsPage.locator('iframe').first().waitFor({ state: 'visible', timeout: 20_000 })
|
||||
|
||||
const iframeSrc = await widgetsPage.locator('iframe').first().getAttribute('src')
|
||||
if (!iframeSrc) {
|
||||
throw new Error('Widget iframe src is missing.')
|
||||
}
|
||||
assertExtensionAssetUrl(iframeSrc, 'Widget iframe src')
|
||||
|
||||
const extensionFrame = await waitForExtensionFrame(widgetsPage)
|
||||
assertExtensionAssetUrl(extensionFrame.url(), 'Widget iframe frame URL')
|
||||
const extensionFrameText = await getFrameText(extensionFrame)
|
||||
if (extensionFrameText.includes('Not Found') || extensionFrameText.includes('Unauthorized')) {
|
||||
throw new Error(`Extension iframe served an error document: ${excerpt(extensionFrameText)}`)
|
||||
}
|
||||
|
||||
await capture('plugin-widget-static-assets-widget-window', widgetsPage, { fullPage: true })
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
DeliveryConfig,
|
||||
ExtensionIdentity,
|
||||
ExtensionModuleIdentity,
|
||||
MetadataEventSource,
|
||||
WebSocketBaseEvent,
|
||||
WebSocketEvent,
|
||||
@@ -10,7 +12,7 @@ import type {
|
||||
RoutingPolicy,
|
||||
} from './middlewares'
|
||||
import type { ServerWsConsumerSelectionCandidate, ServerWsStickyAssignment } from './server-ws/core'
|
||||
import type { AuthenticatedPeer, Peer } from './types'
|
||||
import type { AuthenticatedPeer, Peer, RegisteredExtensionModule } from './types'
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
@@ -284,7 +286,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
// === Registries & Orchestrators ===
|
||||
const peerStore = createServerWsPeerStore<AuthenticatedPeer>()
|
||||
const peers = peerStore.peers
|
||||
const peersByModule = new Map<string, Map<number | undefined, AuthenticatedPeer>>()
|
||||
const peersByModule = new Map<string, Map<number | string | undefined, AuthenticatedPeer>>()
|
||||
const consumers = createConsumerOrchestrator()
|
||||
const heartbeatTtlMs = options?.heartbeat?.readTimeout ?? serverWsDefaultHeartbeatTtlMs
|
||||
const heartbeatMessage = options?.heartbeat?.message ?? MessageHeartbeat.Pong
|
||||
@@ -359,15 +361,26 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
peers.delete(id)
|
||||
unregisterModulePeer(peerInfo, 'heartbeat expired')
|
||||
}
|
||||
else if (peerInfo.missedHeartbeats >= serverWsHealthCheckMissesUnhealthy && peerInfo.healthy !== false && peerInfo.name && peerInfo.identity) {
|
||||
else if (peerInfo.missedHeartbeats >= serverWsHealthCheckMissesUnhealthy && peerInfo.healthy !== false) {
|
||||
// 5 consecutive misses — mark unhealthy
|
||||
peerInfo.healthy = false
|
||||
logger.withFields({ peer: id, peerName: peerInfo.name, missedHeartbeats: peerInfo.missedHeartbeats }).debug('heartbeat late, marking unhealthy')
|
||||
broadcastToAuthenticated({
|
||||
type: 'registry:modules:health:unhealthy',
|
||||
data: { name: peerInfo.name, index: peerInfo.index, identity: peerInfo.identity, reason: 'heartbeat late' },
|
||||
metadata: createEventMetadata(instanceId),
|
||||
})
|
||||
|
||||
if (peerInfo.name && peerInfo.identity) {
|
||||
broadcastToAuthenticated({
|
||||
type: 'registry:modules:health:unhealthy',
|
||||
data: { name: peerInfo.name, index: peerInfo.index, identity: peerInfo.identity, reason: 'heartbeat late' },
|
||||
metadata: createEventMetadata(instanceId),
|
||||
})
|
||||
}
|
||||
|
||||
for (const module of peerInfo.extensionModules?.values() ?? []) {
|
||||
broadcastToAuthenticated({
|
||||
type: 'registry:modules:health:unhealthy',
|
||||
data: { name: module.name, identity: module.identity, reason: 'heartbeat late' },
|
||||
metadata: createEventMetadata(instanceId),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, healthCheckIntervalMs)
|
||||
@@ -376,22 +389,49 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
}
|
||||
|
||||
// === Module Registry & Consumer Management ===
|
||||
function registerModulePeer(p: AuthenticatedPeer, name: string, index?: number) {
|
||||
if (!peersByModule.has(name)) {
|
||||
peersByModule.set(name, new Map())
|
||||
function registerExtensionModulePeer(p: AuthenticatedPeer, module: RegisteredExtensionModule) {
|
||||
p.extensionModules ??= new Map()
|
||||
const previous = p.extensionModules.get(module.identity.id)
|
||||
if (previous && previous.name !== module.name) {
|
||||
unregisterExtensionModuleRegistration(p, previous, 'reannounced')
|
||||
}
|
||||
|
||||
const group = peersByModule.get(name)!
|
||||
if (group.has(index)) {
|
||||
// log instead of silent overwrite
|
||||
logger.withFields({ name, index }).debug('peer replaced for module')
|
||||
p.extensionModules.set(module.identity.id, module)
|
||||
|
||||
if (!peersByModule.has(module.name)) {
|
||||
peersByModule.set(module.name, new Map())
|
||||
}
|
||||
|
||||
peersByModule.get(module.name)!.set(module.identity.id, p)
|
||||
p.healthy = true
|
||||
group.set(index, p)
|
||||
broadcastRegistrySync()
|
||||
}
|
||||
|
||||
function findModulePeer(moduleName: string, moduleIndex: number | undefined, identity?: MetadataEventSource) {
|
||||
if (isExtensionModuleIdentity(identity)) {
|
||||
return peersByModule.get(moduleName)?.get(identity.id)
|
||||
}
|
||||
|
||||
// REVIEW: This keeps legacy indexed websocket module routing while extension modules move to identity keys.
|
||||
if (typeof moduleIndex !== 'undefined') {
|
||||
return peersByModule.get(moduleName)?.get(moduleIndex)
|
||||
}
|
||||
|
||||
const group = peersByModule.get(moduleName)
|
||||
if (!group) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// REVIEW: This preserves the old unindexed module bucket until server module routing is fully identity-based.
|
||||
const legacyPeer = group.get(undefined)
|
||||
if (legacyPeer) {
|
||||
return legacyPeer
|
||||
}
|
||||
|
||||
const peers = [...group.values()]
|
||||
return peers.length === 1 ? peers[0] : undefined
|
||||
}
|
||||
|
||||
function registerConsumer(peerId: string, event: string, mode: ReturnType<typeof normalizeConsumerMode>, group?: string, priority?: number) {
|
||||
consumers.register({ peerId, event, mode, group, priority })
|
||||
}
|
||||
@@ -453,11 +493,11 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
}
|
||||
}
|
||||
|
||||
// broadcast module:de-announced to all authenticated peers
|
||||
// broadcast extension:module:de-announced to all authenticated peers
|
||||
if (peerInfo.identity) {
|
||||
broadcastToAuthenticated({
|
||||
type: 'module:de-announced',
|
||||
data: { name: peerInfo.name, index: peerInfo.index, identity: peerInfo.identity, reason: options?.reason },
|
||||
type: 'extension:module:de-announced',
|
||||
data: { name: peerInfo.name, identity: peerInfo.identity, possibleEvents: [], reason: options?.reason },
|
||||
metadata: createEventMetadata(instanceId),
|
||||
})
|
||||
}
|
||||
@@ -468,18 +508,80 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
broadcastRegistrySync()
|
||||
}
|
||||
|
||||
function unregisterExtensionModuleRegistration(
|
||||
peerInfo: AuthenticatedPeer,
|
||||
module: RegisteredExtensionModule,
|
||||
reason?: string,
|
||||
) {
|
||||
const group = peersByModule.get(module.name)
|
||||
if (group?.get(module.identity.id) === peerInfo) {
|
||||
group.delete(module.identity.id)
|
||||
|
||||
if (group.size === 0) {
|
||||
peersByModule.delete(module.name)
|
||||
}
|
||||
}
|
||||
|
||||
peerInfo.extensionModules?.delete(module.identity.id)
|
||||
broadcastToAuthenticated({
|
||||
type: 'extension:module:de-announced',
|
||||
data: { name: module.name, identity: module.identity, possibleEvents: [], reason },
|
||||
metadata: createEventMetadata(instanceId),
|
||||
})
|
||||
}
|
||||
|
||||
function unregisterExtensionModuleRegistrations(peerInfo: AuthenticatedPeer, reason?: string) {
|
||||
if (!peerInfo.extensionModules?.size) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const module of Array.from(peerInfo.extensionModules.values())) {
|
||||
unregisterExtensionModuleRegistration(peerInfo, module, reason)
|
||||
}
|
||||
|
||||
peerInfo.extensionModules.clear()
|
||||
broadcastRegistrySync()
|
||||
}
|
||||
|
||||
function unregisterModulePeer(peerInfo: AuthenticatedPeer, reason?: string) {
|
||||
unregisterModuleRegistration(peerInfo, { reason })
|
||||
unregisterExtensionModuleRegistrations(peerInfo, reason)
|
||||
}
|
||||
|
||||
function listKnownModules() {
|
||||
return Array.from(peers.values())
|
||||
const legacyModules = Array.from(peers.values())
|
||||
.filter(peerInfo => peerInfo.name && peerInfo.identity)
|
||||
.map(peerInfo => ({
|
||||
name: peerInfo.name,
|
||||
index: peerInfo.index,
|
||||
identity: peerInfo.identity!,
|
||||
}))
|
||||
|
||||
const extensionModules = Array.from(peers.values()).flatMap(peerInfo =>
|
||||
Array.from(peerInfo.extensionModules?.values() ?? []).map(module => ({
|
||||
name: module.name,
|
||||
identity: module.identity,
|
||||
})),
|
||||
)
|
||||
|
||||
return [...legacyModules, ...extensionModules]
|
||||
}
|
||||
|
||||
function isExtensionIdentity(value: unknown): value is ExtensionIdentity {
|
||||
return Boolean(
|
||||
value
|
||||
&& typeof value === 'object'
|
||||
&& typeof (value as Partial<ExtensionIdentity>).id === 'string',
|
||||
)
|
||||
}
|
||||
|
||||
function isExtensionModuleIdentity(value: unknown): value is ExtensionModuleIdentity {
|
||||
return Boolean(
|
||||
value
|
||||
&& typeof value === 'object'
|
||||
&& typeof (value as Partial<ExtensionModuleIdentity>).id === 'string'
|
||||
&& isExtensionIdentity((value as Partial<ExtensionModuleIdentity>).extension),
|
||||
)
|
||||
}
|
||||
|
||||
// === Broadcasting & Registry Synchronization ===
|
||||
@@ -566,7 +668,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
if (authenticatedPeer) {
|
||||
markPeerAlive(authenticatedPeer, { parentId: event.metadata?.event.id })
|
||||
|
||||
if (authenticatedPeer.authenticated && event.metadata?.source) {
|
||||
if (authenticatedPeer.authenticated && isExtensionModuleIdentity(event.metadata?.source)) {
|
||||
authenticatedPeer.identity = event.metadata.source
|
||||
}
|
||||
}
|
||||
@@ -610,56 +712,135 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
return
|
||||
}
|
||||
|
||||
case 'module:announce': {
|
||||
case 'peer:authenticate': {
|
||||
const clientToken = typeof event.data.token === 'string' ? event.data.token : ''
|
||||
if (authToken && !timingSafeCompare(clientToken, authToken)) {
|
||||
logger.withFields({ peer: peer.id, peerRemote: peer.remoteAddress, peerRequest: peer.request?.url }).log('peer authentication failed')
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.invalidToken, event.metadata?.event.id))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const authenticatedPeerId = event.data.peerId ?? peer.id
|
||||
send(peer, RESPONSES.peerAuthenticated(authenticatedPeerId, event.metadata?.event.id))
|
||||
const p = peers.get(peer.id)
|
||||
if (p) {
|
||||
p.authenticated = true
|
||||
p.peerIds ??= new Set()
|
||||
p.peerIds.add(peer.id)
|
||||
p.peerIds.add(authenticatedPeerId)
|
||||
}
|
||||
|
||||
sendRegistrySync(peer, event.metadata?.event.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'extension:authenticate': {
|
||||
const clientToken = typeof event.data.token === 'string' ? event.data.token : ''
|
||||
if (authToken && !timingSafeCompare(clientToken, authToken)) {
|
||||
logger.withFields({ peer: peer.id, peerRemote: peer.remoteAddress, peerRequest: peer.request?.url }).log('extension authentication failed')
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.invalidToken, event.metadata?.event.id))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const p = peers.get(peer.id)
|
||||
if (p) {
|
||||
p.authenticated = true
|
||||
p.extensionIdentity = event.data.identity
|
||||
}
|
||||
|
||||
send(peer, RESPONSES.extensionAuthenticated(event.data.identity, event.metadata?.event.id))
|
||||
sendRegistrySync(peer, event.metadata?.event.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'extension:announce': {
|
||||
const p = peers.get(peer.id)
|
||||
if (!p) {
|
||||
return
|
||||
}
|
||||
|
||||
const { name, index, identity } = event.data as { name: string, index?: number, identity?: MetadataEventSource }
|
||||
if (!name || typeof name !== 'string') {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceNameInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
if (typeof index !== 'undefined') {
|
||||
if (!Number.isInteger(index) || index < 0) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIndexInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!identity || identity.kind !== 'plugin' || !identity.plugin?.id) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIdentityInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
if (authToken && !p.authenticated) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.mustAuthenticateBeforeAnnouncing))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
unregisterModuleRegistration(p, {
|
||||
reason: 're-announcing',
|
||||
unregisterConsumers: false,
|
||||
if (!isExtensionIdentity(event.data.identity)) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIdentityInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
p.extensionIdentity = event.data.identity
|
||||
|
||||
send(peer, {
|
||||
type: 'extension:announced',
|
||||
data: event.data,
|
||||
metadata: createEventMetadata(instanceId, event.metadata?.event.id),
|
||||
})
|
||||
|
||||
p.name = name
|
||||
p.index = index
|
||||
p.identity = identity
|
||||
|
||||
registerModulePeer(p, name, index)
|
||||
|
||||
// broadcast module:announced to all authenticated peers
|
||||
for (const other of peers.values()) {
|
||||
// only send to
|
||||
// 1. authenticated peers
|
||||
// 2. other peers except the announcing peer itself
|
||||
if (other.authenticated && !(other.peer.id === peer.id)) {
|
||||
send(other.peer, {
|
||||
type: 'module:announced',
|
||||
data: { name, index, identity },
|
||||
type: 'extension:announced',
|
||||
data: event.data,
|
||||
metadata: createEventMetadata(instanceId, event.metadata?.event.id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'extension:module:announce': {
|
||||
const p = peers.get(peer.id)
|
||||
if (!p) {
|
||||
return
|
||||
}
|
||||
|
||||
if (authToken && !p.authenticated) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.mustAuthenticateBeforeAnnouncing))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const { name, identity } = event.data
|
||||
if (!name || typeof name !== 'string') {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceNameInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!isExtensionModuleIdentity(identity)) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIdentityInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (p.extensionIdentity && identity.extension.id !== p.extensionIdentity.id) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIdentityInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
p.extensionIdentity = identity.extension
|
||||
registerExtensionModulePeer(p, { name, identity })
|
||||
|
||||
send(peer, {
|
||||
type: 'extension:module:announced',
|
||||
data: event.data,
|
||||
metadata: createEventMetadata(instanceId, event.metadata?.event.id),
|
||||
})
|
||||
|
||||
for (const other of peers.values()) {
|
||||
if (other.authenticated && !(other.peer.id === peer.id)) {
|
||||
send(other.peer, {
|
||||
type: 'extension:module:announced',
|
||||
data: event.data,
|
||||
metadata: createEventMetadata(instanceId, event.metadata?.event.id),
|
||||
})
|
||||
}
|
||||
@@ -675,7 +856,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
identity?: MetadataEventSource
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
const moduleName = data.moduleName ?? data.identity?.plugin?.id ?? ''
|
||||
const moduleName = data.moduleName ?? (isExtensionModuleIdentity(data.identity) ? data.identity.id : '') ?? ''
|
||||
const moduleIndex = data.moduleIndex
|
||||
const config = data.config
|
||||
|
||||
@@ -692,7 +873,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
}
|
||||
}
|
||||
|
||||
const target = peersByModule.get(moduleName)?.get(moduleIndex)
|
||||
const target = findModulePeer(moduleName, moduleIndex, data.identity)
|
||||
if (target) {
|
||||
send(target.peer, {
|
||||
type: 'module:configure',
|
||||
|
||||
@@ -10,7 +10,9 @@ import { matchesLabelSelector, matchesLabelSelectors, matchesRouteExpression } f
|
||||
function createPeer(options: {
|
||||
id: string
|
||||
name: string
|
||||
plugin?: string
|
||||
peerIds?: string[]
|
||||
extensionLabels?: Record<string, string>
|
||||
extension?: string
|
||||
instanceId?: string
|
||||
labels?: Record<string, string>
|
||||
authenticated?: boolean
|
||||
@@ -23,13 +25,41 @@ function createPeer(options: {
|
||||
remoteAddress: '127.0.0.1',
|
||||
},
|
||||
authenticated: options.authenticated ?? true,
|
||||
peerIds: options.peerIds ? new Set(options.peerIds) : undefined,
|
||||
name: options.name,
|
||||
identity: options.plugin && options.instanceId
|
||||
? { kind: 'plugin', plugin: { id: options.plugin }, id: options.instanceId, labels: options.labels }
|
||||
identity: options.extension && options.instanceId
|
||||
? { id: options.instanceId, extension: { id: options.extension }, labels: options.labels }
|
||||
: undefined,
|
||||
extensionIdentity: options.extensionLabels
|
||||
? { id: options.name, sessionId: `${options.id}-session`, labels: options.extensionLabels }
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function createExtensionModulePeer(): AuthenticatedPeer {
|
||||
const peer = createPeer({
|
||||
id: 'peer-extension',
|
||||
name: 'airi-extension-chess',
|
||||
extension: 'airi-extension-chess',
|
||||
instanceId: 'extension-session-1',
|
||||
})
|
||||
|
||||
peer.extensionModules = new Map([
|
||||
['chess-gamelet', {
|
||||
name: 'character',
|
||||
identity: {
|
||||
id: 'chess-gamelet',
|
||||
extension: {
|
||||
id: 'airi-extension-chess',
|
||||
sessionId: 'extension-session-1',
|
||||
},
|
||||
},
|
||||
}],
|
||||
])
|
||||
|
||||
return peer
|
||||
}
|
||||
|
||||
function createSparkNotifyEvent(overrides: Partial<WebSocketEventOf<'spark:notify'>> = {}): WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify'], any> {
|
||||
const data: WebSocketEvents['spark:notify'] = {
|
||||
id: 'evt-1',
|
||||
@@ -45,7 +75,7 @@ function createSparkNotifyEvent(overrides: Partial<WebSocketEventOf<'spark:notif
|
||||
type: 'spark:notify',
|
||||
data,
|
||||
metadata: overrides.metadata ?? {
|
||||
source: { kind: 'plugin', plugin: { id: 'server-runtime' }, id: 'test' },
|
||||
source: { id: 'test', extension: { id: 'server-runtime' } },
|
||||
event: { id: data.id },
|
||||
},
|
||||
route: overrides.route,
|
||||
@@ -70,7 +100,7 @@ describe('match-expression', () => {
|
||||
const peer = createPeer({
|
||||
id: 'peer-1',
|
||||
name: 'stage-ui',
|
||||
plugin: 'stage-ui',
|
||||
extension: 'stage-ui',
|
||||
instanceId: 'stage-ui-1',
|
||||
labels: { env: 'prod' },
|
||||
})
|
||||
@@ -136,7 +166,7 @@ describe('route middleware', () => {
|
||||
type: 'spark:notify',
|
||||
data: 'not-an-object',
|
||||
metadata: {
|
||||
source: { kind: 'plugin', plugin: { id: 'server-runtime' }, id: 'test' },
|
||||
source: { id: 'test', extension: { id: 'server-runtime' } },
|
||||
event: { id: 'evt-primitive' },
|
||||
},
|
||||
route: undefined,
|
||||
@@ -149,7 +179,7 @@ describe('route middleware', () => {
|
||||
const peer = createPeer({
|
||||
id: 'peer-2',
|
||||
name: 'telegram-bot',
|
||||
plugin: 'telegram-bot',
|
||||
extension: 'telegram-bot',
|
||||
instanceId: 'telegram-1',
|
||||
labels: { app: 'telegram', env: 'prod' },
|
||||
})
|
||||
@@ -158,10 +188,56 @@ describe('route middleware', () => {
|
||||
expect(matchesDestinations(['label:env=dev'], peer)).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(matchesDestinations(['label:surface=websocket-extension'], peer)).toBe(true)
|
||||
*/
|
||||
it('matches destinations by extension identity labels', () => {
|
||||
const peer = createPeer({
|
||||
id: 'peer-extension-labels',
|
||||
name: 'airi-extension',
|
||||
extensionLabels: { surface: 'websocket-extension' },
|
||||
})
|
||||
|
||||
expect(matchesDestinations(['label:surface=websocket-extension'], peer)).toBe(true)
|
||||
expect(matchesRouteExpression({ type: 'label', selectors: ['surface=websocket-extension'] }, peer)).toBe(true)
|
||||
expect(matchesDestinations(['label:surface=legacy-plugin'], peer)).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(matchesDestinations(['peer:stage-window'], peer)).toBe(true)
|
||||
*/
|
||||
it('matches destinations by acknowledged peer id aliases', () => {
|
||||
const peer = createPeer({
|
||||
id: 'runtime-peer-1',
|
||||
name: 'stage-window',
|
||||
peerIds: ['runtime-peer-1', 'stage-window'],
|
||||
})
|
||||
|
||||
expect(matchesDestinations(['peer:stage-window'], peer)).toBe(true)
|
||||
expect(matchesDestinations([{ type: 'ids', ids: ['stage-window'] }], peer)).toBe(true)
|
||||
expect(matchesDestinations(['peer:missing'], peer)).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(matchesDestinations(['module:character'], peer)).toBe(true)
|
||||
*/
|
||||
it('matches destinations by announced extension module name', () => {
|
||||
const peer = createExtensionModulePeer()
|
||||
|
||||
expect(matchesDestinations(['module:character'], peer)).toBe(true)
|
||||
expect(matchesDestinations(['character'], peer)).toBe(true)
|
||||
expect(matchesDestinations(['chess-*'], peer)).toBe(true)
|
||||
expect(matchesDestinations(['module:missing'], peer)).toBe(false)
|
||||
expect(matchesDestinations(['missing'], peer)).toBe(false)
|
||||
})
|
||||
|
||||
it('policy middleware filters targets', () => {
|
||||
const peers = new Map<string, AuthenticatedPeer>([
|
||||
['peer-1', createPeer({ id: 'peer-1', name: 'telegram', plugin: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })],
|
||||
['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', plugin: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'dev' } })],
|
||||
['peer-1', createPeer({ id: 'peer-1', name: 'telegram', extension: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })],
|
||||
['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', extension: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'dev' } })],
|
||||
])
|
||||
|
||||
const policy = createPolicyMiddleware({ allowLabels: ['env=prod'] })
|
||||
@@ -185,8 +261,8 @@ describe('route middleware', () => {
|
||||
|
||||
it('policy middleware excludes unauthenticated peers', () => {
|
||||
const peers = new Map<string, AuthenticatedPeer>([
|
||||
['peer-1', createPeer({ id: 'peer-1', name: 'telegram', plugin: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })],
|
||||
['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', plugin: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'prod' }, authenticated: false })],
|
||||
['peer-1', createPeer({ id: 'peer-1', name: 'telegram', extension: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })],
|
||||
['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', extension: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'prod' }, authenticated: false })],
|
||||
])
|
||||
|
||||
const policy = createPolicyMiddleware({ allowLabels: ['env=prod'] })
|
||||
@@ -206,8 +282,8 @@ describe('route middleware', () => {
|
||||
|
||||
it('policy middleware does not authorize bypass by itself', () => {
|
||||
const peers = new Map<string, AuthenticatedPeer>([
|
||||
['peer-1', createPeer({ id: 'peer-1', name: 'telegram', plugin: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })],
|
||||
['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', plugin: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'dev' } })],
|
||||
['peer-1', createPeer({ id: 'peer-1', name: 'telegram', extension: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })],
|
||||
['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', extension: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'dev' } })],
|
||||
])
|
||||
|
||||
const policy = createPolicyMiddleware({ allowLabels: ['env=prod'] })
|
||||
@@ -229,7 +305,7 @@ describe('route middleware', () => {
|
||||
const peer = createPeer({
|
||||
id: 'peer-3',
|
||||
name: 'debug-ui',
|
||||
plugin: 'debug-ui',
|
||||
extension: 'debug-ui',
|
||||
instanceId: 'debug-ui-1',
|
||||
labels: { devtools: 'true' },
|
||||
})
|
||||
|
||||
@@ -10,8 +10,8 @@ export type RouteDecision
|
||||
| { type: 'targets', targetIds: Set<string> }
|
||||
|
||||
export interface RoutingPolicy {
|
||||
allowPlugins?: string[]
|
||||
denyPlugins?: string[]
|
||||
allowExtensions?: string[]
|
||||
denyExtensions?: string[]
|
||||
allowLabels?: string[]
|
||||
denyLabels?: string[]
|
||||
}
|
||||
@@ -29,7 +29,7 @@ type DestinationList = Array<string | RouteTargetExpression>
|
||||
|
||||
function getPeerLabels(peer: AuthenticatedPeer) {
|
||||
return {
|
||||
...peer.identity?.plugin?.labels,
|
||||
...peer.extensionIdentity?.labels,
|
||||
...peer.identity?.labels,
|
||||
}
|
||||
}
|
||||
@@ -71,13 +71,13 @@ export function peerMatchesPolicy(peer: AuthenticatedPeer, policy: RoutingPolicy
|
||||
return false
|
||||
}
|
||||
|
||||
const pluginId = peer.identity?.plugin?.id ?? ''
|
||||
const extensionId = peer.identity?.extension.id ?? peer.extensionIdentity?.id ?? ''
|
||||
|
||||
if (policy.allowPlugins?.length && !policy.allowPlugins.includes(pluginId)) {
|
||||
if (policy.allowExtensions?.length && !policy.allowExtensions.includes(extensionId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (policy.denyPlugins?.length && policy.denyPlugins.includes(pluginId)) {
|
||||
if (policy.denyExtensions?.length && policy.denyExtensions.includes(extensionId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -39,11 +39,30 @@ export function matchesLabelSelectors(selectors: string[], labels: Record<string
|
||||
|
||||
function getPeerLabels(peer: AuthenticatedPeer) {
|
||||
return {
|
||||
...peer.identity?.plugin?.labels,
|
||||
...peer.extensionIdentity?.labels,
|
||||
...peer.identity?.extension.labels,
|
||||
...peer.identity?.labels,
|
||||
}
|
||||
}
|
||||
|
||||
function getPeerExtensionId(peer: AuthenticatedPeer) {
|
||||
return peer.identity?.extension.id ?? peer.extensionIdentity?.id
|
||||
}
|
||||
|
||||
function matchesExtensionModule(peer: AuthenticatedPeer, moduleName: string) {
|
||||
return [...peer.extensionModules?.values() ?? []]
|
||||
.some(module => module.name === moduleName || module.identity.id === moduleName)
|
||||
}
|
||||
|
||||
function matchesExtensionModuleGlob(peer: AuthenticatedPeer, glob: string) {
|
||||
return [...peer.extensionModules?.values() ?? []]
|
||||
.some(module => matchesGlob(glob, module.name) || matchesGlob(glob, module.identity.id))
|
||||
}
|
||||
|
||||
function matchesPeerId(peer: AuthenticatedPeer, peerId: string) {
|
||||
return peer.peer.id === peerId || Boolean(peer.peerIds?.has(peerId))
|
||||
}
|
||||
|
||||
export function matchesRouteExpression(expression: RouteTargetExpression, peer: AuthenticatedPeer): boolean {
|
||||
switch (expression.type) {
|
||||
case 'and':
|
||||
@@ -51,19 +70,19 @@ export function matchesRouteExpression(expression: RouteTargetExpression, peer:
|
||||
case 'or':
|
||||
return expression.any.some(expr => matchesRouteExpression(expr, peer))
|
||||
case 'glob': {
|
||||
const pluginId = peer.identity?.plugin?.id
|
||||
const extensionId = getPeerExtensionId(peer)
|
||||
const matched = matchesGlob(expression.glob, peer.name)
|
||||
|| matchesGlob(expression.glob, pluginId)
|
||||
|| matchesGlob(expression.glob, extensionId)
|
||||
|| matchesGlob(expression.glob, peer.identity?.id)
|
||||
|
||||
return expression.inverted ? !matched : matched
|
||||
}
|
||||
case 'ids': {
|
||||
const matched = expression.ids.includes(peer.peer.id)
|
||||
const matched = expression.ids.some(peerId => matchesPeerId(peer, peerId))
|
||||
return expression.inverted ? !matched : matched
|
||||
}
|
||||
case 'plugin': {
|
||||
const matched = expression.plugins.includes(peer.identity?.plugin?.id ?? '')
|
||||
const matched = expression.plugins.includes(getPeerExtensionId(peer) ?? '')
|
||||
return expression.inverted ? !matched : matched
|
||||
}
|
||||
case 'instance': {
|
||||
@@ -75,7 +94,7 @@ export function matchesRouteExpression(expression: RouteTargetExpression, peer:
|
||||
return expression.inverted ? !matched : matched
|
||||
}
|
||||
case 'module': {
|
||||
const matched = expression.modules.includes(peer.name)
|
||||
const matched = expression.modules.some(module => peer.name === module || matchesExtensionModule(peer, module))
|
||||
return expression.inverted ? !matched : matched
|
||||
}
|
||||
case 'source': {
|
||||
@@ -101,22 +120,24 @@ export function matchesDestination(destination: string | RouteTargetExpression,
|
||||
|
||||
switch (prefix) {
|
||||
case 'plugin':
|
||||
return peer.identity?.plugin?.id === value
|
||||
return getPeerExtensionId(peer) === value
|
||||
case 'instance':
|
||||
return peer.identity?.id === value
|
||||
case 'label':
|
||||
return matchesLabelSelectors([value], getPeerLabels(peer))
|
||||
case 'peer':
|
||||
return peer.peer.id === value
|
||||
return matchesPeerId(peer, value)
|
||||
case 'module':
|
||||
return peer.name === value
|
||||
return peer.name === value || matchesExtensionModule(peer, value)
|
||||
case 'source':
|
||||
return peer.name === value
|
||||
default: {
|
||||
const pluginId = peer.identity?.plugin?.id
|
||||
const extensionId = getPeerExtensionId(peer)
|
||||
// REVIEW: Bare/glob destination matching is kept for existing event payloads that do not use module:<name>.
|
||||
return matchesGlob(destination, peer.name)
|
||||
|| matchesGlob(destination, pluginId)
|
||||
|| matchesGlob(destination, extensionId)
|
||||
|| matchesGlob(destination, peer.identity?.id)
|
||||
|| matchesExtensionModuleGlob(peer, destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AiriWebSocketEventFormatError,
|
||||
createResponses,
|
||||
heartbeatFrameFrom,
|
||||
parseEvent,
|
||||
} from '.'
|
||||
@@ -75,4 +76,30 @@ describe('airi websocket protocol codec', () => {
|
||||
expect(heartbeatFrameFrom('pong')).toBe('pong')
|
||||
expect(heartbeatFrameFrom('{"type":"ping"}')).toBeUndefined()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(responses.peerAuthenticated('peer-1').type).toBe('peer:authenticated')
|
||||
* expect(responses.extensionAuthenticated({ id: 'airi-extension-chess' }).type).toBe('extension:authenticated')
|
||||
*/
|
||||
it('creates peer and extension authentication responses separately', () => {
|
||||
const responses = createResponses('server-1')
|
||||
|
||||
expect(responses.peerAuthenticated('peer-1')).toMatchObject({
|
||||
type: 'peer:authenticated',
|
||||
data: {
|
||||
authenticated: true,
|
||||
peerId: 'peer-1',
|
||||
},
|
||||
})
|
||||
expect(responses.extensionAuthenticated({ id: 'airi-extension-chess' })).toMatchObject({
|
||||
type: 'extension:authenticated',
|
||||
data: {
|
||||
authenticated: true,
|
||||
identity: {
|
||||
id: 'airi-extension-chess',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DeliveryConfig, MessageHeartbeat, MetadataEventSource, WebSocketBaseEvent, WebSocketEvent } from '@proj-airi/server-shared/types'
|
||||
import type { DeliveryConfig, ExtensionIdentity, MessageHeartbeat, MetadataEventSource, WebSocketBaseEvent, WebSocketEvent } from '@proj-airi/server-shared/types'
|
||||
|
||||
import type {
|
||||
RouteContext,
|
||||
@@ -134,6 +134,20 @@ export function createResponses(serverInstanceId: string) {
|
||||
metadata: createEventMetadata(serverInstanceId, parentId),
|
||||
} satisfies WebSocketEvent<Record<string, unknown>>
|
||||
},
|
||||
peerAuthenticated(peerId: string, parentId?: string) {
|
||||
return {
|
||||
type: 'peer:authenticated',
|
||||
data: { authenticated: true, peerId },
|
||||
metadata: createEventMetadata(serverInstanceId, parentId),
|
||||
} satisfies WebSocketEvent<Record<string, unknown>>
|
||||
},
|
||||
extensionAuthenticated(identity: ExtensionIdentity, parentId?: string) {
|
||||
return {
|
||||
type: 'extension:authenticated',
|
||||
data: { identity, authenticated: true },
|
||||
metadata: createEventMetadata(serverInstanceId, parentId),
|
||||
} satisfies WebSocketEvent<Record<string, unknown>>
|
||||
},
|
||||
notAuthenticated(parentId?: string) {
|
||||
return {
|
||||
type: 'error',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MetadataEventSource } from '@proj-airi/server-shared/types'
|
||||
import type { ExtensionIdentity, ExtensionModuleIdentity } from '@proj-airi/server-shared/types'
|
||||
|
||||
export interface Peer {
|
||||
/**
|
||||
@@ -26,6 +26,16 @@ export interface NamedPeer {
|
||||
peer: Peer
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks one module announced by an extension over a websocket peer.
|
||||
*/
|
||||
export interface RegisteredExtensionModule {
|
||||
/** Human-readable module name used by registry sync and legacy routing lookup. */
|
||||
name: string
|
||||
/** Module identity scoped to the owning extension session. */
|
||||
identity: ExtensionModuleIdentity
|
||||
}
|
||||
|
||||
export enum WebSocketReadyState {
|
||||
CONNECTING = 0,
|
||||
OPEN = 1,
|
||||
@@ -35,7 +45,11 @@ export enum WebSocketReadyState {
|
||||
|
||||
export interface AuthenticatedPeer extends NamedPeer {
|
||||
authenticated: boolean
|
||||
identity?: MetadataEventSource
|
||||
/** Caller-supplied peer ids acknowledged during manual peer authentication. */
|
||||
peerIds?: Set<string>
|
||||
identity?: ExtensionModuleIdentity
|
||||
extensionIdentity?: ExtensionIdentity
|
||||
extensionModules?: Map<string, RegisteredExtensionModule>
|
||||
lastHeartbeatAt?: number
|
||||
healthy?: boolean
|
||||
missedHeartbeats?: number
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
MetadataEventSource,
|
||||
ExtensionIdentity,
|
||||
ExtensionModuleIdentity,
|
||||
ModuleConfigSchema,
|
||||
ModuleDependency,
|
||||
WebSocketBaseEvent,
|
||||
@@ -49,10 +50,17 @@ export interface ClientOptions<C = undefined> {
|
||||
name: string
|
||||
token?: string
|
||||
websocketConstructor?: WebSocketLikeConstructor
|
||||
/**
|
||||
* Selects the connection handshake owned by this client.
|
||||
*
|
||||
* @default 'module'
|
||||
*/
|
||||
handshake?: 'module' | 'manual'
|
||||
|
||||
connectTimeoutMs?: number
|
||||
possibleEvents?: Array<keyof WebSocketEvents<C>>
|
||||
identity?: MetadataEventSource
|
||||
extension?: ExtensionIdentity
|
||||
identity?: ExtensionModuleIdentity
|
||||
dependencies?: ModuleDependency[]
|
||||
configSchema?: ModuleConfigSchema
|
||||
heartbeat?: ClientHeartbeatOptions
|
||||
@@ -122,7 +130,7 @@ export class Client<C = undefined> {
|
||||
private connectionAttempt?: ConnectionAttempt
|
||||
private failureReason?: Error
|
||||
private status: ClientStatus = 'idle'
|
||||
private readonly identity: MetadataEventSource
|
||||
private readonly identity: ExtensionModuleIdentity
|
||||
private readonly heartbeat: Required<ClientHeartbeatOptions>
|
||||
private readonly websocketConstructor: WebSocketLikeConstructor
|
||||
|
||||
@@ -139,10 +147,12 @@ export class Client<C = undefined> {
|
||||
|
||||
constructor(options: ClientOptions<C>) {
|
||||
const { websocketConstructor, ...clientOptions } = options
|
||||
const extension = options.extension ?? {
|
||||
id: options.name,
|
||||
}
|
||||
const identity = options.identity ?? {
|
||||
kind: 'plugin',
|
||||
plugin: { id: options.name },
|
||||
id: createInstanceId(),
|
||||
extension,
|
||||
}
|
||||
|
||||
const heartbeat = normalizeHeartbeatOptions(options.heartbeat)
|
||||
@@ -162,7 +172,9 @@ export class Client<C = undefined> {
|
||||
autoConnect: true,
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: -1,
|
||||
handshake: 'module',
|
||||
...clientOptions,
|
||||
extension,
|
||||
heartbeat,
|
||||
identity,
|
||||
}
|
||||
@@ -310,6 +322,7 @@ export class Client<C = undefined> {
|
||||
}
|
||||
|
||||
private async runConnectLoop() {
|
||||
const reconnectingFromReady = this.pendingReconnect
|
||||
this.pendingReconnect = false
|
||||
|
||||
while (!this.shouldClose) {
|
||||
@@ -317,7 +330,7 @@ export class Client<C = undefined> {
|
||||
this.transitionTo(reconnecting ? 'reconnecting' : 'connecting')
|
||||
|
||||
try {
|
||||
await this.connectOnce()
|
||||
await this.connectOnce({ reconnectingFromReady })
|
||||
this.reconnectAttempts = 0
|
||||
return
|
||||
}
|
||||
@@ -354,7 +367,7 @@ export class Client<C = undefined> {
|
||||
throw new Error('Client is closed')
|
||||
}
|
||||
|
||||
private connectOnce(): Promise<void> {
|
||||
private connectOnce(options: { reconnectingFromReady?: boolean } = {}): Promise<void> {
|
||||
const WebSocketConstructor = this.websocketConstructor
|
||||
const ws = new WebSocketConstructor(this.opts.url)
|
||||
this.websocket = ws
|
||||
@@ -450,6 +463,24 @@ export class Client<C = undefined> {
|
||||
|
||||
this.startHeartbeat()
|
||||
|
||||
if (this.opts.handshake === 'manual') {
|
||||
if (options.reconnectingFromReady) {
|
||||
attempt.authenticated = false
|
||||
attempt.announced = false
|
||||
this.reconnectAttempts = 0
|
||||
this.transitionTo('authenticating')
|
||||
return
|
||||
}
|
||||
|
||||
attempt.authenticated = true
|
||||
attempt.announced = true
|
||||
this.reconnectAttempts = 0
|
||||
this.transitionTo('ready')
|
||||
this.resolveAttempt()
|
||||
this.opts.onReady?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.opts.token) {
|
||||
attempt.authenticated = false
|
||||
this.transitionTo('authenticating')
|
||||
@@ -586,7 +617,7 @@ export class Client<C = undefined> {
|
||||
|
||||
private tryAnnounce() {
|
||||
this.sendOrThrow({
|
||||
type: 'module:announce',
|
||||
type: 'extension:module:announce',
|
||||
data: {
|
||||
name: this.opts.name,
|
||||
identity: this.identity,
|
||||
@@ -691,7 +722,34 @@ export class Client<C = undefined> {
|
||||
throw new Error('Authentication failed')
|
||||
}
|
||||
|
||||
case 'module:announced': {
|
||||
case 'peer:authenticated': {
|
||||
if (this.opts.handshake !== 'manual' || this.status !== 'authenticating' || !this.connectionAttempt) {
|
||||
return
|
||||
}
|
||||
|
||||
if (data.data.authenticated) {
|
||||
this.connectionAttempt.authenticated = true
|
||||
this.transitionTo('announcing')
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Peer authentication failed')
|
||||
}
|
||||
|
||||
case 'extension:announced': {
|
||||
if (this.opts.handshake !== 'manual' || this.status !== 'announcing' || !this.connectionAttempt) {
|
||||
return
|
||||
}
|
||||
|
||||
this.connectionAttempt.announced = true
|
||||
this.reconnectAttempts = 0
|
||||
this.transitionTo('ready')
|
||||
this.resolveAttempt()
|
||||
this.opts.onReady?.()
|
||||
return
|
||||
}
|
||||
|
||||
case 'extension:module:announced': {
|
||||
if (!this.isSelfAnnouncement(data)) {
|
||||
return
|
||||
}
|
||||
@@ -713,7 +771,7 @@ export class Client<C = undefined> {
|
||||
|
||||
case 'registry:modules:sync': {
|
||||
// Fallback: If the status is stuck at 'announcing' but the sync already contains this module,
|
||||
// it means the announce succeeded; the server simply didn't send back 'module:announced'
|
||||
// it means the announce succeeded; the server simply didn't send back 'extension:module:announced'
|
||||
if (this.status !== 'announcing' || !this.connectionAttempt) {
|
||||
return
|
||||
}
|
||||
@@ -754,7 +812,7 @@ export class Client<C = undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
private isSelfAnnouncement(event: WebSocketBaseEvent<'module:announced', WebSocketEvents<C>['module:announced']>) {
|
||||
private isSelfAnnouncement(event: WebSocketBaseEvent<'extension:module:announced', WebSocketEvents<C>['extension:module:announced']>) {
|
||||
return event.data.name === this.opts.name && event.data.identity?.id === this.identity.id
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import type {
|
||||
ExtensionIdentity,
|
||||
ModuleConfigSchema,
|
||||
ModuleDependency,
|
||||
ModulePermissionDeclaration,
|
||||
ProtocolEvents,
|
||||
WebSocketBaseEvent,
|
||||
WebSocketEventOptionalSource,
|
||||
WebSocketEvents,
|
||||
} from '@proj-airi/server-shared/types'
|
||||
|
||||
import type { Client, ClientOptions, ConnectOptions } from './client'
|
||||
|
||||
import { Client as WebSocketClient } from './client'
|
||||
|
||||
/**
|
||||
* Describes the client operations required by {@link WebSocketExtensionPeer}.
|
||||
*
|
||||
* @param C - Optional custom protocol event map carried by the websocket client.
|
||||
*/
|
||||
export interface ExtensionPeerClient<C = undefined> {
|
||||
/** Opens the underlying websocket client connection. */
|
||||
connect: (options?: ConnectOptions) => Promise<void>
|
||||
/** Sends one typed websocket event and reports whether it was accepted by the transport. */
|
||||
send: (data: WebSocketEventOptionalSource<C>) => boolean
|
||||
/** Sends one typed websocket event or throws when the transport is unavailable. */
|
||||
sendOrThrow: (data: WebSocketEventOptionalSource<C>) => void
|
||||
/** Closes the underlying websocket client connection. */
|
||||
close: () => void
|
||||
/** Registers a typed event listener when backed by the standard server-sdk Client. */
|
||||
onEvent?: <E extends keyof WebSocketEvents<C>>(
|
||||
event: E,
|
||||
callback: (data: WebSocketBaseEvent<E, WebSocketEvents<C>[E]>) => void | Promise<void>,
|
||||
) => () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes one module announcement emitted through a websocket extension peer.
|
||||
*
|
||||
* @param C - Optional custom protocol event map used for possible event declarations.
|
||||
*/
|
||||
export interface AnnounceExtensionModuleInput<C = undefined> {
|
||||
/** Stable module id within the owning extension session. */
|
||||
id: string
|
||||
/** Human-readable module name used by registry and diagnostics. */
|
||||
name: string
|
||||
/** Protocol events this module may emit or handle. */
|
||||
possibleEvents?: Array<keyof ProtocolEvents<C>>
|
||||
/** Runtime permissions requested by this module. */
|
||||
permissions?: ModulePermissionDeclaration
|
||||
/** Optional configuration schema understood by the module. */
|
||||
configSchema?: ModuleConfigSchema
|
||||
/** Other modules or capabilities this module expects to exist. */
|
||||
dependencies?: ModuleDependency[]
|
||||
/** Optional labels for routing, diagnostics, or inspector views. */
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a websocket-backed extension peer.
|
||||
*
|
||||
* @param C - Optional custom protocol event map carried by the websocket client.
|
||||
*/
|
||||
export interface WebSocketExtensionPeerOptions<C = undefined> {
|
||||
/** Extension session identity announced after peer authentication. */
|
||||
extension: ExtensionIdentity
|
||||
/** Optional prebuilt client used by tests or embedding runtimes. */
|
||||
client?: ExtensionPeerClient<C>
|
||||
/** Standard server-sdk Client options used when `client` is not supplied. */
|
||||
clientOptions?: Omit<ClientOptions<C>, 'name' | 'identity'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides extension-level protocol helpers over the existing websocket Client.
|
||||
*
|
||||
* Use when:
|
||||
* - A remote extension talks to an AIRI host over websocket transport
|
||||
* - Authoring/runtime code should say peer/extension/module explicitly instead of sending raw websocket events
|
||||
*
|
||||
* Expects:
|
||||
* - The underlying client owns websocket lifecycle and serialization
|
||||
* - The host interprets `peer:*`, `extension:*`, and `extension:module:*` protocol events
|
||||
*
|
||||
* Returns:
|
||||
* - A thin transport peer that delegates connection and event sending to server-sdk Client
|
||||
*/
|
||||
export class WebSocketExtensionPeer<C = undefined> {
|
||||
private readonly client: ExtensionPeerClient<C>
|
||||
private readonly extension: ExtensionIdentity
|
||||
|
||||
constructor(options: WebSocketExtensionPeerOptions<C>) {
|
||||
this.extension = options.extension
|
||||
this.client = options.client ?? new WebSocketClient<C>({
|
||||
...options.clientOptions,
|
||||
name: options.extension.id,
|
||||
handshake: 'manual',
|
||||
autoConnect: options.clientOptions?.autoConnect ?? false,
|
||||
autoReconnect: options.clientOptions?.autoReconnect ?? false,
|
||||
}) as Client<C>
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the underlying websocket connection.
|
||||
*
|
||||
* Use when:
|
||||
* - The extension transport should begin peer authentication or announcement
|
||||
*
|
||||
* Expects:
|
||||
* - The wrapped client can reach the configured websocket URL
|
||||
*
|
||||
* Returns:
|
||||
* - Resolves when the wrapped client reports readiness
|
||||
*/
|
||||
connect(options?: ConnectOptions): Promise<void> {
|
||||
return this.client.connect(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends transport-level peer authentication.
|
||||
*
|
||||
* Use when:
|
||||
* - A websocket connection needs to authenticate before extension session grant
|
||||
*
|
||||
* Expects:
|
||||
* - The websocket connection is already open or the client can queue/send immediately
|
||||
*
|
||||
* Returns:
|
||||
* - Nothing; send failures are surfaced by the wrapped client
|
||||
*/
|
||||
authenticatePeer(input: { token?: string, peerId?: string } = {}): void {
|
||||
this.client.sendOrThrow({
|
||||
type: 'peer:authenticate',
|
||||
data: input,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces the extension session after peer authentication.
|
||||
*
|
||||
* Use when:
|
||||
* - The remote peer has permission to enter extension setup
|
||||
*
|
||||
* Expects:
|
||||
* - Permissions represent the extension-level ceiling grant or declaration snapshot
|
||||
*
|
||||
* Returns:
|
||||
* - Nothing; send failures are surfaced by the wrapped client
|
||||
*/
|
||||
announceExtension(input: { permissions?: ModulePermissionDeclaration } = {}): void {
|
||||
this.client.sendOrThrow({
|
||||
type: 'extension:announce',
|
||||
data: {
|
||||
identity: this.extension,
|
||||
permissions: input.permissions,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces one module registered by the current extension.
|
||||
*
|
||||
* Use when:
|
||||
* - A websocket extension dynamically registers module capabilities
|
||||
*
|
||||
* Expects:
|
||||
* - `id` is stable within this extension session
|
||||
*
|
||||
* Returns:
|
||||
* - Nothing; send failures are surfaced by the wrapped client
|
||||
*/
|
||||
announceModule(input: AnnounceExtensionModuleInput<C>): void {
|
||||
this.client.sendOrThrow({
|
||||
type: 'extension:module:announce',
|
||||
data: {
|
||||
name: input.name,
|
||||
identity: {
|
||||
id: input.id,
|
||||
extension: this.extension,
|
||||
labels: input.labels,
|
||||
},
|
||||
possibleEvents: input.possibleEvents ?? [],
|
||||
permissions: input.permissions,
|
||||
configSchema: input.configSchema,
|
||||
dependencies: input.dependencies,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a typed websocket event through the wrapped client.
|
||||
*
|
||||
* Use when:
|
||||
* - Runtime code has a protocol event not covered by helper methods
|
||||
*
|
||||
* Expects:
|
||||
* - Callers pass a server-shared websocket event
|
||||
*
|
||||
* Returns:
|
||||
* - Whether the event was accepted by the underlying transport
|
||||
*/
|
||||
send(data: WebSocketEventOptionalSource<C>): boolean {
|
||||
return this.client.send(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers one event listener when the wrapped client supports typed listeners.
|
||||
*
|
||||
* Use when:
|
||||
* - The remote extension needs to observe host protocol events
|
||||
*
|
||||
* Expects:
|
||||
* - Test doubles may omit listener support
|
||||
*
|
||||
* Returns:
|
||||
* - A disposer that removes the listener
|
||||
*/
|
||||
onEvent<E extends keyof WebSocketEvents<C>>(
|
||||
event: E,
|
||||
callback: (data: WebSocketBaseEvent<E, WebSocketEvents<C>[E]>) => void | Promise<void>,
|
||||
): () => void {
|
||||
if (!this.client.onEvent) {
|
||||
throw new Error('Wrapped extension peer client does not support event listeners.')
|
||||
}
|
||||
|
||||
return this.client.onEvent(event, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the underlying websocket client.
|
||||
*
|
||||
* Use when:
|
||||
* - The extension transport is disposed
|
||||
*
|
||||
* Expects:
|
||||
* - Close is idempotent in the wrapped client
|
||||
*
|
||||
* Returns:
|
||||
* - Nothing
|
||||
*/
|
||||
close(): void {
|
||||
this.client.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a websocket extension peer over server-sdk Client.
|
||||
*
|
||||
* Use when:
|
||||
* - Code prefers a function factory over direct class construction
|
||||
*
|
||||
* Expects:
|
||||
* - `extension.id` is the stable extension id
|
||||
*
|
||||
* Returns:
|
||||
* - A {@link WebSocketExtensionPeer} ready to connect and announce
|
||||
*/
|
||||
export function createWebSocketExtensionPeer<C = undefined>(
|
||||
options: WebSocketExtensionPeerOptions<C>,
|
||||
): WebSocketExtensionPeer<C> {
|
||||
return new WebSocketExtensionPeer(options)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './client'
|
||||
export * from './extension-peer'
|
||||
export type * from './websocket-like'
|
||||
export type * from '@proj-airi/server-shared/types'
|
||||
export { ContextUpdateStrategy, WebSocketEventSource } from '@proj-airi/server-shared/types'
|
||||
|
||||
@@ -4,53 +4,61 @@ import superjson from 'superjson'
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0
|
||||
static readonly OPEN = 1
|
||||
static readonly CLOSING = 2
|
||||
static readonly CLOSED = 3
|
||||
import { Client } from '../src/client'
|
||||
import { createWebSocketExtensionPeer } from '../src/extension-peer'
|
||||
|
||||
static instances: MockWebSocket[] = []
|
||||
const { InjectedMockWebSocket, MockWebSocket } = vi.hoisted(() => {
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0
|
||||
static readonly OPEN = 1
|
||||
static readonly CLOSING = 2
|
||||
static readonly CLOSED = 3
|
||||
|
||||
readonly sent: Array<string | ArrayBufferLike | ArrayBufferView<ArrayBufferLike>> = []
|
||||
readyState = MockWebSocket.CONNECTING
|
||||
onclose?: () => void
|
||||
onerror?: (event: { error?: Error } | unknown) => void
|
||||
onmessage?: (event: { data: string | ArrayBufferLike | ArrayBufferView<ArrayBufferLike> }) => void
|
||||
onopen?: () => void
|
||||
static instances: MockWebSocket[] = []
|
||||
|
||||
constructor(public readonly url: string) {
|
||||
MockWebSocket.instances.push(this)
|
||||
readonly sent: Array<string | ArrayBufferLike | ArrayBufferView<ArrayBufferLike>> = []
|
||||
readyState = MockWebSocket.CONNECTING
|
||||
onclose?: () => void
|
||||
onerror?: (event: { error?: Error } | unknown) => void
|
||||
onmessage?: (event: { data: string | ArrayBufferLike | ArrayBufferView<ArrayBufferLike> }) => void
|
||||
onopen?: () => void
|
||||
|
||||
constructor(public readonly url: string) {
|
||||
MockWebSocket.instances.push(this)
|
||||
}
|
||||
|
||||
send(data: string | ArrayBufferLike | ArrayBufferView<ArrayBufferLike>) {
|
||||
this.sent.push(data)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = MockWebSocket.CLOSED
|
||||
this.onclose?.()
|
||||
}
|
||||
|
||||
ping() {}
|
||||
pong() {}
|
||||
}
|
||||
|
||||
send(data: string | ArrayBufferLike | ArrayBufferView<ArrayBufferLike>) {
|
||||
this.sent.push(data)
|
||||
class InjectedMockWebSocket extends MockWebSocket {
|
||||
static instances: InjectedMockWebSocket[] = []
|
||||
|
||||
constructor(url: string) {
|
||||
super(url)
|
||||
InjectedMockWebSocket.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = MockWebSocket.CLOSED
|
||||
this.onclose?.()
|
||||
return {
|
||||
InjectedMockWebSocket,
|
||||
MockWebSocket,
|
||||
}
|
||||
|
||||
ping() {}
|
||||
pong() {}
|
||||
}
|
||||
|
||||
class InjectedMockWebSocket extends MockWebSocket {
|
||||
static instances: InjectedMockWebSocket[] = []
|
||||
|
||||
constructor(url: string) {
|
||||
super(url)
|
||||
InjectedMockWebSocket.instances.push(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('crossws/websocket', () => ({
|
||||
default: MockWebSocket,
|
||||
}))
|
||||
|
||||
const { Client } = await import('../src/client')
|
||||
|
||||
function lastSocket() {
|
||||
const socket = MockWebSocket.instances.at(-1)
|
||||
if (!socket) {
|
||||
@@ -60,7 +68,7 @@ function lastSocket() {
|
||||
return socket
|
||||
}
|
||||
|
||||
function parseSent(socket: MockWebSocket, index = -1) {
|
||||
function parseSent(socket: InstanceType<typeof MockWebSocket>, index = -1) {
|
||||
const payload = socket.sent.at(index)
|
||||
if (!payload) {
|
||||
throw new Error(`No sent payload at index ${index}`)
|
||||
@@ -75,12 +83,12 @@ function parseSent(socket: MockWebSocket, index = -1) {
|
||||
return superjson.parse<WebSocketEvent>(decoded)
|
||||
}
|
||||
|
||||
function emitOpen(socket: MockWebSocket) {
|
||||
function emitOpen(socket: InstanceType<typeof MockWebSocket>) {
|
||||
socket.readyState = MockWebSocket.OPEN
|
||||
socket.onopen?.()
|
||||
}
|
||||
|
||||
function emitMessage(socket: MockWebSocket, event: WebSocketEvent) {
|
||||
function emitMessage(socket: InstanceType<typeof MockWebSocket>, event: WebSocketEvent) {
|
||||
socket.onmessage?.({
|
||||
data: superjson.stringify(event),
|
||||
})
|
||||
@@ -120,15 +128,15 @@ describe('client', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const announceEvent = parseSent(socket) as WebSocketEventOf<'module:announced'>
|
||||
const announceEvent = parseSent(socket) as WebSocketEventOf<'extension:module:announce'>
|
||||
|
||||
expect(announceEvent).toMatchObject({
|
||||
type: 'module:announce',
|
||||
type: 'extension:module:announce',
|
||||
data: { name: 'test-plugin' },
|
||||
})
|
||||
|
||||
emitMessage(socket, {
|
||||
type: 'module:announced',
|
||||
type: 'extension:module:announced',
|
||||
data: {
|
||||
name: 'test-plugin',
|
||||
identity: announceEvent.data.identity,
|
||||
@@ -202,10 +210,10 @@ describe('client', () => {
|
||||
}
|
||||
|
||||
emitOpen(socket)
|
||||
const announceEvent = parseSent(socket) as WebSocketEventOf<'module:announced'>
|
||||
const announceEvent = parseSent(socket) as WebSocketEventOf<'extension:module:announce'>
|
||||
|
||||
emitMessage(socket, {
|
||||
type: 'module:announced',
|
||||
type: 'extension:module:announced',
|
||||
data: {
|
||||
name: 'test-plugin',
|
||||
identity: announceEvent.data.identity,
|
||||
@@ -219,6 +227,108 @@ describe('client', () => {
|
||||
await expect(connected).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('supports manual handshake for extension peers without legacy module announce', async () => {
|
||||
const client = new Client({
|
||||
autoConnect: false,
|
||||
autoReconnect: false,
|
||||
handshake: 'manual',
|
||||
name: 'test-extension',
|
||||
})
|
||||
|
||||
const connected = client.connect()
|
||||
const socket = lastSocket()
|
||||
|
||||
emitOpen(socket)
|
||||
|
||||
await expect(connected).resolves.toBeUndefined()
|
||||
expect(client.connectionStatus).toBe('ready')
|
||||
expect(socket.sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps manual reconnects non-ready until the peer reauthenticates and reannounces', async () => {
|
||||
const onReady = vi.fn()
|
||||
const client = new Client({
|
||||
autoConnect: false,
|
||||
autoReconnect: true,
|
||||
handshake: 'manual',
|
||||
name: 'test-extension',
|
||||
onReady,
|
||||
})
|
||||
|
||||
const connected = client.connect()
|
||||
const firstSocket = lastSocket()
|
||||
|
||||
emitOpen(firstSocket)
|
||||
|
||||
await expect(connected).resolves.toBeUndefined()
|
||||
expect(client.connectionStatus).toBe('ready')
|
||||
expect(onReady).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstSocket.close()
|
||||
const secondSocket = lastSocket()
|
||||
|
||||
emitOpen(secondSocket)
|
||||
|
||||
expect(client.connectionStatus).toBe('authenticating')
|
||||
expect(onReady).toHaveBeenCalledTimes(1)
|
||||
|
||||
emitMessage(secondSocket, {
|
||||
type: 'peer:authenticated',
|
||||
data: { authenticated: true },
|
||||
metadata: {
|
||||
source: { kind: 'plugin', plugin: { id: 'server' }, id: 'server-1' },
|
||||
event: { id: 'peer-auth-1' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(client.connectionStatus).toBe('announcing')
|
||||
expect(onReady).toHaveBeenCalledTimes(1)
|
||||
|
||||
emitMessage(secondSocket, {
|
||||
type: 'extension:announced',
|
||||
data: {
|
||||
identity: { id: 'test-extension' },
|
||||
},
|
||||
metadata: {
|
||||
source: { kind: 'plugin', plugin: { id: 'server' }, id: 'server-1' },
|
||||
event: { id: 'extension-announce-1' },
|
||||
},
|
||||
})
|
||||
|
||||
await expect(client.ensureConnected()).resolves.toBeUndefined()
|
||||
expect(client.connectionStatus).toBe('ready')
|
||||
expect(onReady).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('uses manual handshake when creating websocket extension peers', async () => {
|
||||
const peer = createWebSocketExtensionPeer({
|
||||
extension: {
|
||||
id: 'test-extension',
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
clientOptions: {
|
||||
autoReconnect: false,
|
||||
},
|
||||
})
|
||||
|
||||
const connected = peer.connect()
|
||||
const socket = lastSocket()
|
||||
|
||||
emitOpen(socket)
|
||||
await expect(connected).resolves.toBeUndefined()
|
||||
|
||||
expect(socket.sent).toHaveLength(0)
|
||||
|
||||
peer.authenticatePeer({ token: 'secret', peerId: 'peer-1' })
|
||||
expect(parseSent(socket)).toMatchObject({
|
||||
type: 'peer:authenticate',
|
||||
data: {
|
||||
token: 'secret',
|
||||
peerId: 'peer-1',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('supports timeout-aware ensureConnected without cancelling the shared connect task', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -236,10 +346,10 @@ describe('client', () => {
|
||||
await timedOutAssertion
|
||||
|
||||
emitOpen(socket)
|
||||
const announceEvent = parseSent(socket) as WebSocketEventOf<'module:announced'>
|
||||
const announceEvent = parseSent(socket) as WebSocketEventOf<'extension:module:announce'>
|
||||
|
||||
emitMessage(socket, {
|
||||
type: 'module:announced',
|
||||
type: 'extension:module:announced',
|
||||
data: {
|
||||
name: 'test-plugin',
|
||||
identity: announceEvent.data.identity,
|
||||
@@ -285,10 +395,10 @@ describe('client', () => {
|
||||
|
||||
emitOpen(socket)
|
||||
|
||||
const announceEvent = parseSent(socket) as WebSocketEventOf<'module:announced'>
|
||||
const announceEvent = parseSent(socket) as WebSocketEventOf<'extension:module:announce'>
|
||||
|
||||
emitMessage(socket, {
|
||||
type: 'module:announced',
|
||||
type: 'extension:module:announced',
|
||||
data: {
|
||||
name: 'test-plugin',
|
||||
identity: announceEvent.data.identity,
|
||||
@@ -331,10 +441,10 @@ describe('client', () => {
|
||||
const secondSocket = lastSocket()
|
||||
emitOpen(secondSocket)
|
||||
|
||||
const announceEvent = parseSent(secondSocket) as WebSocketEventOf<'module:announced'>
|
||||
const announceEvent = parseSent(secondSocket) as WebSocketEventOf<'extension:module:announce'>
|
||||
|
||||
emitMessage(secondSocket, {
|
||||
type: 'module:announced',
|
||||
type: 'extension:module:announced',
|
||||
data: {
|
||||
name: 'test-plugin',
|
||||
identity: announceEvent.data.identity,
|
||||
@@ -362,7 +472,7 @@ describe('client', () => {
|
||||
const socket = lastSocket()
|
||||
emitOpen(socket)
|
||||
|
||||
const announceEvent = parseSent(socket) as WebSocketEventOf<'module:announced'>
|
||||
const announceEvent = parseSent(socket) as WebSocketEventOf<'extension:module:announce'>
|
||||
|
||||
const selfIdentity = announceEvent.data.identity
|
||||
|
||||
@@ -378,7 +488,7 @@ describe('client', () => {
|
||||
})
|
||||
|
||||
emitMessage(socket, {
|
||||
type: 'module:announced',
|
||||
type: 'extension:module:announced',
|
||||
data: {
|
||||
name: 'test-plugin',
|
||||
identity: selfIdentity,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { WebSocketEventOptionalSource } from '@proj-airi/server-shared/types'
|
||||
|
||||
import type { ExtensionPeerClient } from '../src/extension-peer'
|
||||
import type { WebSocketLike } from '../src/websocket-like'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createWebSocketExtensionPeer } from '../src/extension-peer'
|
||||
|
||||
class FakeClient implements ExtensionPeerClient {
|
||||
readonly sent: WebSocketEventOptionalSource[] = []
|
||||
readonly connect = vi.fn(async () => {})
|
||||
readonly close = vi.fn(() => {})
|
||||
|
||||
send(data: WebSocketEventOptionalSource): boolean {
|
||||
this.sent.push(data)
|
||||
return true
|
||||
}
|
||||
|
||||
sendOrThrow(data: WebSocketEventOptionalSource): void {
|
||||
this.sent.push(data)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSocket implements WebSocketLike {
|
||||
static readonly CONNECTING = 0
|
||||
static readonly OPEN = 1
|
||||
static readonly CLOSING = 2
|
||||
static readonly CLOSED = 3
|
||||
|
||||
onopen?: () => void
|
||||
onclose?: () => void
|
||||
onmessage?: (event: { data: string }) => void
|
||||
onerror?: (event: unknown) => void
|
||||
readyState = FakeSocket.CONNECTING
|
||||
readonly sent: string[] = []
|
||||
|
||||
constructor(readonly url: string) {}
|
||||
|
||||
open() {
|
||||
this.readyState = FakeSocket.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
|
||||
close(_code?: number, _reason?: string) {
|
||||
this.readyState = FakeSocket.CLOSED
|
||||
this.onclose?.()
|
||||
}
|
||||
|
||||
send(data: string | ArrayBufferLike | ArrayBufferView) {
|
||||
this.sent.push(typeof data === 'string' ? data : new TextDecoder().decode(data))
|
||||
}
|
||||
}
|
||||
|
||||
describe('websocket extension peer', () => {
|
||||
/**
|
||||
* @example
|
||||
* expect(fakeClient.sent.map(event => event.type)).toEqual(['peer:authenticate', 'extension:announce'])
|
||||
*/
|
||||
it('authenticates the websocket peer separately from the extension session', async () => {
|
||||
const fakeClient = new FakeClient()
|
||||
const peer = createWebSocketExtensionPeer({
|
||||
extension: {
|
||||
id: 'airi-extension-chess',
|
||||
version: '1.0.0',
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
client: fakeClient,
|
||||
})
|
||||
|
||||
await peer.connect()
|
||||
peer.authenticatePeer({ token: 'secret', peerId: 'peer-1' })
|
||||
peer.announceExtension()
|
||||
|
||||
expect(fakeClient.connect).toHaveBeenCalled()
|
||||
expect(fakeClient.sent.map(event => event.type)).toEqual([
|
||||
'peer:authenticate',
|
||||
'extension:announce',
|
||||
])
|
||||
expect(fakeClient.sent[0]).toMatchObject({
|
||||
type: 'peer:authenticate',
|
||||
data: {
|
||||
token: 'secret',
|
||||
peerId: 'peer-1',
|
||||
},
|
||||
})
|
||||
expect(fakeClient.sent[1]).toMatchObject({
|
||||
type: 'extension:announce',
|
||||
data: {
|
||||
identity: {
|
||||
id: 'airi-extension-chess',
|
||||
version: '1.0.0',
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(fakeClient.sent[0].type).toBe('extension:module:announce')
|
||||
*/
|
||||
it('announces extension modules under the owning extension identity', () => {
|
||||
const fakeClient = new FakeClient()
|
||||
const peer = createWebSocketExtensionPeer({
|
||||
extension: {
|
||||
id: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
client: fakeClient,
|
||||
})
|
||||
|
||||
peer.announceModule({
|
||||
id: 'chess-gamelet',
|
||||
name: 'Chess Gamelet',
|
||||
possibleEvents: [],
|
||||
})
|
||||
|
||||
expect(fakeClient.sent[0]).toMatchObject({
|
||||
type: 'extension:module:announce',
|
||||
data: {
|
||||
name: 'Chess Gamelet',
|
||||
identity: {
|
||||
id: 'chess-gamelet',
|
||||
extension: {
|
||||
id: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
},
|
||||
possibleEvents: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(sockets).toHaveLength(1)
|
||||
*/
|
||||
it('does not reconnect by default because manual extension handshakes are one-shot', async () => {
|
||||
const sockets: FakeSocket[] = []
|
||||
const peer = createWebSocketExtensionPeer({
|
||||
extension: {
|
||||
id: 'airi-extension-chess',
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
clientOptions: {
|
||||
websocketConstructor: class extends FakeSocket {
|
||||
constructor(url: string) {
|
||||
super(url)
|
||||
sockets.push(this)
|
||||
}
|
||||
},
|
||||
connectTimeoutMs: 10,
|
||||
},
|
||||
})
|
||||
|
||||
const connectPromise = peer.connect()
|
||||
expect(sockets).toHaveLength(1)
|
||||
sockets[0]!.open()
|
||||
await connectPromise
|
||||
|
||||
sockets[0]!.close()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(sockets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user