feat(plugin-sdk): version negotiation with compatibility checks
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user