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:
Neko
2026-06-12 02:03:42 +08:00
committed by GitHub
co-authored by autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent 8518c65aa4
commit 668440a732
112 changed files with 7299 additions and 6330 deletions
+2 -2
View File
@@ -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())
@@ -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.
@@ -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() })
},
})
@@ -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": [
{
@@ -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)
},
@@ -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) => {
@@ -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
@@ -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()
})
})
+6
View File
@@ -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')
})
})
+158 -6
View File
@@ -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
},
}
}
},
})
+686 -181
View File
@@ -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,
}
+98 -140
View File
@@ -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,
+64 -1
View File
@@ -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
+1 -3
View File
@@ -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)
})
})
+63 -46
View File
@@ -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`.
*
+1 -1
View File
@@ -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,
}
}
+106
View File
@@ -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
}
+2 -13
View File
@@ -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'
+12
View File
@@ -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'
}
}
+44
View File
@@ -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')
})
})
+97
View File
@@ -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
@@ -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',
})
})
})
@@ -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'
@@ -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',
@@ -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
}
@@ -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' })
},
})
@@ -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()
},
})
@@ -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() {},
})
@@ -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>
-25
View File
@@ -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
View File
@@ -1,2 +1 @@
export * from './apis'
export * from './define'
-64
View File
@@ -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'
@@ -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 })
},
})
@@ -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'))
}
},
})
@@ -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 })
},
})
+238 -57
View File
@@ -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',
+16 -2
View File
@@ -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
+69 -11
View File
@@ -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
}
+261
View File
@@ -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
View File
@@ -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'
+160 -50
View File
@@ -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