feat(plugin-sdk): version negotiation with compatibility checks

This commit is contained in:
Makito
2026-03-01 01:19:47 +07:00
parent 256f34f7d5
commit c6f9db7cc3
2 changed files with 140 additions and 4 deletions
@@ -1,7 +1,7 @@
import { join } from 'node:path'
import { createContext, defineEventa, defineInvokeHandler } from '@moeru/eventa'
import { moduleStatus } from '@proj-airi/plugin-protocol/types'
import { moduleCompatibilityResult, moduleStatus } from '@proj-airi/plugin-protocol/types'
import { describe, expect, it, vi } from 'vitest'
import { FileSystemLoader, PluginHost } from '.'
@@ -408,4 +408,64 @@ describe('for PluginHost', () => {
const reloaded = await host.reload(session.id)
expect(reloaded.phase).toBe('ready')
})
it('should emit downgraded compatibility result when fallback versions overlap', async () => {
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
protocolVersion: 'v2',
apiVersion: 'v2',
supportedProtocolVersions: ['v1'],
supportedApiVersions: ['v1'],
})
reportPluginCapability(host, {
key: providersCapability,
state: 'ready',
metadata: { source: 'test' },
})
const session = await host.load(testManifest, { cwd: '' })
const compatibilityEvents: Array<{ body?: Record<string, unknown> }> = []
session.channels.host.on(moduleCompatibilityResult, (payload) => {
compatibilityEvents.push(payload as unknown as { body?: Record<string, unknown> })
})
const initialized = await host.init(session.id, {
compatibility: {
supportedProtocolVersions: ['v1'],
supportedApiVersions: ['v1'],
},
})
expect(initialized.phase).toBe('ready')
expect(compatibilityEvents).toEqual(expect.arrayContaining([
expect.objectContaining({
body: expect.objectContaining({
protocolVersion: 'v1',
apiVersion: 'v1',
mode: 'downgraded',
}),
}),
]))
})
it('should reject initialization when compatibility has no overlap', async () => {
const host = new PluginHost({
runtime: 'electron',
transport: { kind: 'in-memory' },
protocolVersion: 'v2',
apiVersion: 'v2',
})
const session = await host.load(testManifest, { cwd: '' })
await expect(host.init(session.id, {
compatibility: {
supportedProtocolVersions: ['v9'],
supportedApiVersions: ['v9'],
},
})).rejects.toThrow('Negotiation rejected:')
expect(host.getSession(session.id)?.phase).toBe('failed')
})
})
+79 -3
View File
@@ -349,6 +349,49 @@ function createModuleIdentity(name: string, index: number): ModuleIdentity {
}
}
// TODO: Maybe support more complex version formats.
function resolveSupportedVersions(preferredVersion: string, supportedVersions?: string[]) {
const list = [preferredVersion, ...(supportedVersions ?? [])]
return [...new Set(list)]
}
function resolveNegotiatedVersion(preferredVersion: string, hostSupportedVersions: string[], peerSupportedVersions?: string[]) {
if (!peerSupportedVersions?.length) {
if (hostSupportedVersions.includes(preferredVersion)) {
return {
acceptedVersion: preferredVersion,
exact: true,
}
}
return {
exact: false,
reason: `Host does not support preferred version "${preferredVersion}".`,
}
}
if (peerSupportedVersions.includes(preferredVersion) && hostSupportedVersions.includes(preferredVersion)) {
return {
acceptedVersion: preferredVersion,
exact: true,
}
}
for (const version of hostSupportedVersions) {
if (peerSupportedVersions.includes(version)) {
return {
acceptedVersion: version,
exact: false,
}
}
}
return {
exact: false,
reason: `No overlapping supported versions. host=[${hostSupportedVersions.join(', ')}]; peer=[${peerSupportedVersions.join(', ')}].`,
}
}
export type PluginRuntime = 'electron' | 'node' | 'web'
export type ModulePhase = ProtocolModulePhase
@@ -406,6 +449,8 @@ export interface PluginHostOptions {
transport?: PluginTransport
protocolVersion?: string
apiVersion?: string
supportedProtocolVersions?: string[]
supportedApiVersions?: string[]
}
export interface PluginStartOptions {
@@ -458,6 +503,8 @@ export class PluginHost {
private readonly transport: PluginTransport
private readonly protocolVersion: string
private readonly apiVersion: string
private readonly supportedProtocolVersions: string[]
private readonly supportedApiVersions: string[]
private readonly capabilities = new Map<string, CapabilityDescriptor>()
private readonly capabilityWaiters = new Map<string, Set<(descriptor: CapabilityDescriptor) => void>>()
private providersListResolver: () => Promise<Array<{ name: string }>> | Array<{ name: string }> = () => []
@@ -469,6 +516,8 @@ export class PluginHost {
this.transport = options.transport ?? { kind: 'in-memory' }
this.protocolVersion = options.protocolVersion ?? 'v1'
this.apiVersion = options.apiVersion ?? 'v1'
this.supportedProtocolVersions = resolveSupportedVersions(this.protocolVersion, options.supportedProtocolVersions)
this.supportedApiVersions = resolveSupportedVersions(this.apiVersion, options.supportedApiVersions)
this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host' })
}
@@ -597,10 +646,37 @@ export class PluginHost {
}
session.channels.host.emit(moduleCompatibilityRequest, compatibilityRequest)
const protocolNegotiation = resolveNegotiatedVersion(
compatibilityRequest.protocolVersion,
this.supportedProtocolVersions,
compatibilityRequest.supportedProtocolVersions,
)
const apiNegotiation = resolveNegotiatedVersion(
compatibilityRequest.apiVersion,
this.supportedApiVersions,
compatibilityRequest.supportedApiVersions,
)
const rejectionReasons = [
...protocolNegotiation.acceptedVersion ? [] : [`protocol: ${protocolNegotiation.reason}`],
...apiNegotiation.acceptedVersion ? [] : [`api: ${apiNegotiation.reason}`],
]
if (rejectionReasons.length > 0) {
const reason = `Negotiation rejected: ${rejectionReasons.join('; ')}`
session.channels.host.emit(moduleCompatibilityResult, {
protocolVersion: compatibilityRequest.protocolVersion,
apiVersion: compatibilityRequest.apiVersion,
mode: 'rejected',
reason,
})
throw new Error(reason)
}
session.channels.host.emit(moduleCompatibilityResult, {
protocolVersion: compatibilityRequest.protocolVersion,
apiVersion: compatibilityRequest.apiVersion,
mode: 'exact',
protocolVersion: protocolNegotiation.acceptedVersion!,
apiVersion: apiNegotiation.acceptedVersion!,
mode: protocolNegotiation.exact && apiNegotiation.exact ? 'exact' : 'downgraded',
})
// Step 4: broadcast currently known modules for dependency discovery/bootstrap.