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]
parent 8518c65aa4
commit 668440a732
112 changed files with 7299 additions and 6330 deletions
+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