feat(better-ws): added new package (#1989)

---------

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-18 15:11:35 +08:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 391d21b996
commit 47bfe02a87
52 changed files with 8112 additions and 2846 deletions
+3 -1
View File
@@ -41,11 +41,13 @@
"dependencies": {
"@guiiai/logg": "catalog:",
"@moeru/std": "catalog:",
"@proj-airi/better-ws": "workspace:^",
"@proj-airi/server-shared": "workspace:^",
"crossws": "catalog:",
"h3": "catalog:",
"nanoid": "catalog:",
"srvx": "catalog:",
"superjson": "catalog:"
"superjson": "catalog:",
"valibot": "catalog:"
}
}
+6 -2
View File
@@ -1,8 +1,12 @@
import type { WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-shared/types'
import type { ConsumerStickyAssignment } from './server-ws/airi/consumers'
import { describe, expect, it } from 'vitest'
import { heartbeatFrameFrom, resolveEventDelivery, selectConsumerPeerId } from './index'
import { heartbeatFrameFrom } from './server-ws/airi/codec'
import { selectConsumerPeerId } from './server-ws/airi/consumers'
import { resolveEventDelivery } from './server-ws/airi/routing'
function createInputTextEvent(
overrides: Partial<WebSocketBaseEvent<'input:text', WebSocketEvents['input:text']>> = {},
@@ -133,7 +137,7 @@ describe('selectConsumerPeerId', () => {
})
it('keeps sticky delivery on the same consumer when available', () => {
const stickyAssignments = new Map<string, string>()
const stickyAssignments = new Map<string, ConsumerStickyAssignment>()
const firstSelectedPeerId = selectConsumerPeerId({
eventType: 'input:text',
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,16 @@
import type { WebSocketEvent } from '@proj-airi/server-shared/types'
import { stringify } from 'superjson'
import { stringify as stringifySuperJson } from 'superjson'
import { describe, expect, it } from 'vitest'
import {
AiriWebSocketEventFormatError,
createResponses,
heartbeatFrameFrom,
InvalidEventError,
parseEvent,
} from '.'
stringifyEvent,
} from './codec'
describe('airi websocket protocol codec', () => {
describe('airi websocket codec', () => {
it('parses superjson encoded events', () => {
const event: WebSocketEvent = {
type: 'module:authenticate',
@@ -25,7 +25,7 @@ describe('airi websocket protocol codec', () => {
},
}
expect(parseEvent(stringify(event))).toEqual(event)
expect(parseEvent(stringifySuperJson(event))).toEqual(event)
})
it('falls back to plain JSON events', () => {
@@ -45,61 +45,48 @@ describe('airi websocket protocol codec', () => {
expect(parseEvent(JSON.stringify(event))).toEqual(event)
})
it('rejects payloads without event type', () => {
it('rejects invalid event envelopes', () => {
expect(() => parseEvent('null'))
.toThrow(AiriWebSocketEventFormatError)
.toThrow(InvalidEventError)
expect(() => parseEvent(JSON.stringify({ data: {} })))
.toThrow(AiriWebSocketEventFormatError)
})
it('rejects payloads with non-string event type', () => {
.toThrow(InvalidEventError)
expect(() => parseEvent(JSON.stringify({ type: 0, data: {} })))
.toThrow(AiriWebSocketEventFormatError)
})
it('rejects payloads without object event data', () => {
.toThrow(InvalidEventError)
expect(() => parseEvent(JSON.stringify({ type: 'module:authenticate' })))
.toThrow(AiriWebSocketEventFormatError)
.toThrow(InvalidEventError)
expect(() => parseEvent(JSON.stringify({ type: 'module:authenticate', data: null })))
.toThrow(AiriWebSocketEventFormatError)
.toThrow(InvalidEventError)
expect(() => parseEvent(JSON.stringify({ type: 'module:authenticate', data: 'secret' })))
.toThrow(AiriWebSocketEventFormatError)
})
it('rejects payloads with array event data', () => {
.toThrow(InvalidEventError)
expect(() => parseEvent(JSON.stringify({ type: 'module:authenticate', data: [] })))
.toThrow(AiriWebSocketEventFormatError)
.toThrow(InvalidEventError)
})
it('classifies raw ping and pong control frames', () => {
it('keeps validation cause and source on invalid event errors', () => {
const source = { type: 'module:authenticate', data: 'secret' }
try {
parseEvent(JSON.stringify(source))
expect.unreachable('Expected invalid event parsing to throw.')
}
catch (error) {
expect(error).toBeInstanceOf(InvalidEventError)
expect(error).toMatchObject({ source })
expect((error as InvalidEventError).cause).toEqual(expect.arrayContaining([
expect.objectContaining({
message: 'Expected event data to be a non-array object.',
}),
]))
}
})
it('detects raw ping and pong control frames', () => {
expect(heartbeatFrameFrom('ping')).toBe('ping')
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',
},
},
})
it('preserves raw string events when stringifying', () => {
expect(stringifyEvent('raw-payload')).toBe('raw-payload')
})
})
@@ -0,0 +1,81 @@
import type { WebSocketBaseEvent, WebSocketEvent } from '@proj-airi/server-shared/types'
import { MessageHeartbeatKind } from '@proj-airi/server-shared/types'
import { parse, stringify } from 'superjson'
import { check, objectWithRest, pipe, safeParse, string, unknown } from 'valibot'
const invalidAiriWebSocketEventFormatMessage = 'Invalid WebSocket event format.'
const eventDataSchema = pipe(
unknown(),
check(
value => Boolean(value) && typeof value === 'object' && !Array.isArray(value),
'Expected event data to be a non-array object.',
),
)
const eventEnvelopeSchema = objectWithRest({
type: string(),
data: eventDataSchema,
}, unknown())
interface InvalidEventErrorOptions {
cause?: unknown
source?: unknown
}
/** Error thrown when parsed websocket text is not an AIRI event envelope. */
export class InvalidEventError extends Error {
readonly source?: unknown
constructor(options: InvalidEventErrorOptions = {}) {
super(invalidAiriWebSocketEventFormatMessage, { cause: options.cause })
this.name = 'InvalidEventError'
this.source = options.source
}
}
/** Checks whether an error came from AIRI websocket event envelope validation. */
export function isInvalidEventError(error: unknown): error is InvalidEventError {
return error instanceof InvalidEventError
}
/** Detects raw ping/pong text frames that should not enter the event protocol. */
export function heartbeatFrameFrom(text: string): MessageHeartbeatKind | undefined {
if (text === MessageHeartbeatKind.Ping || text === MessageHeartbeatKind.Pong) {
return text
}
}
/** Parses one AIRI websocket protocol event from SuperJSON or plain JSON text. */
export function parseEvent(text: string): WebSocketEvent {
// NOTICE:
// SDK clients send events using superjson.stringify, so websocket runtime code must
// use superjson.parse instead of message.json() or plain JSON.parse first.
// JSON.parse on a superjson-encoded string returns the wrapper object
// `{ json: {...}, meta: {...} }` with no protocol `type`, which breaks routing.
// Keep this until all AIRI websocket clients share one non-wrapper wire format.
let parsed: WebSocketEvent | undefined
try {
parsed = parse<WebSocketEvent>(text)
}
catch {
parsed = undefined
}
const potentialEvent = (parsed && typeof parsed === 'object' && 'type' in parsed)
? parsed
: JSON.parse(text)
const result = safeParse(eventEnvelopeSchema, potentialEvent)
if (!result.success) {
throw new InvalidEventError({ cause: result.issues, source: potentialEvent })
}
return potentialEvent as WebSocketEvent
}
/** Serializes one AIRI websocket protocol event with the existing SuperJSON wire format. */
export function stringifyEvent(event: WebSocketBaseEvent<string, unknown> | string) {
return typeof event === 'string' ? event : stringify(event)
}
@@ -1,13 +1,13 @@
import type { ServerWsStickyAssignment } from '.'
import type { ConsumerStickyAssignment } from './consumers'
import { describe, expect, it } from 'vitest'
import {
createConsumerOrchestrator,
selectConsumerPeerId,
} from '.'
} from './consumers'
describe('server-ws consumer selection', () => {
describe('airi websocket consumer selection', () => {
it('selects highest priority then earliest registration', () => {
expect(selectConsumerPeerId({
eventType: 'event:test',
@@ -36,7 +36,7 @@ describe('server-ws consumer selection', () => {
})
it('preserves sticky assignment for the same sticky key', () => {
const stickyAssignments = new Map<string, ServerWsStickyAssignment>()
const stickyAssignments = new Map<string, ConsumerStickyAssignment>()
const delivery = { mode: 'consumer-group' as const, group: 'workers', selection: 'sticky' as const, stickyKey: 'job-1' }
const candidates = [
{ peerId: 'a', priority: 0, registeredAt: 1, authenticated: true },
@@ -73,7 +73,7 @@ describe('server-ws consumer selection', () => {
})
})
describe('server-ws consumer registry', () => {
describe('airi websocket consumer registry', () => {
it('registers and unregisters consumers', () => {
const registry = createConsumerOrchestrator()
@@ -106,7 +106,7 @@ describe('server-ws consumer registry', () => {
})
it('keeps sticky assignments isolated for delimiter-like event and group names', () => {
const stickyAssignments = new Map<string, ServerWsStickyAssignment>()
const stickyAssignments = new Map<string, ConsumerStickyAssignment>()
const candidates = [
{ peerId: 'event::group-target', priority: 0, registeredAt: 1, authenticated: true },
{ peerId: 'other-target', priority: 0, registeredAt: 2, authenticated: true },
@@ -0,0 +1,301 @@
import type { DeliveryConfig } from '@proj-airi/server-shared/types'
const DEFAULT_CONSUMER_GROUP = 'default'
interface ConsumerRegistryRef {
event: string
group: string
}
/**
* Candidate peer metadata used for AIRI consumer selection.
*/
export interface ConsumerSelectionCandidate {
/** Peer id available to receive the event. */
peerId: string
/** Higher values are selected before lower values. */
priority: number
/** Timestamp captured when the peer registered as a consumer. */
registeredAt: number
/** Whether the peer has completed protocol-level authentication. */
authenticated: boolean
/** Explicit `false` excludes the peer from selection. */
healthy?: boolean
}
/**
* Stored AIRI consumer registration.
*/
export interface ConsumerRegistration {
/** Protocol event type consumed by the peer. */
event: string
/** Normalized consumer group name. */
group: string
/** Peer id that registered for the event/group pair. */
peerId: string
/** Higher values are selected before lower values. */
priority: number
/** Timestamp captured when the peer registered as a consumer. */
registeredAt: number
}
/**
* Sticky AIRI consumer assignment stored by the consumer selector.
*/
export interface ConsumerStickyAssignment {
/** Protocol event type the sticky assignment belongs to. */
event: string
/** Normalized consumer group the sticky assignment belongs to. */
group: string
/** Peer selected for the sticky key. */
peerId: string
}
/**
* Checks whether a delivery mode targets the AIRI consumer registry.
*/
export function isConsumerDeliveryMode(mode: unknown): mode is 'consumer' | 'consumer-group' {
return mode === 'consumer' || mode === 'consumer-group'
}
/**
* Normalizes delivery mode for AIRI consumer registration.
*
* Before:
* - undefined with group "workers"
*
* After:
* - "consumer-group"
*/
export function normalizeConsumerMode(mode: unknown, group?: string): 'consumer' | 'consumer-group' {
if (isConsumerDeliveryMode(mode)) {
return mode
}
return group ? 'consumer-group' : 'consumer'
}
/**
* Normalizes AIRI consumer priority.
*
* Before:
* - NaN
*
* After:
* - 0
*/
export function normalizeConsumerPriority(priority: unknown) {
return typeof priority === 'number' && Number.isFinite(priority)
? priority
: 0
}
function normalizeConsumerGroup(mode: 'consumer' | 'consumer-group', group?: string) {
if (mode === 'consumer') {
return DEFAULT_CONSUMER_GROUP
}
return group || DEFAULT_CONSUMER_GROUP
}
function sortConsumers(entries: Array<Pick<ConsumerSelectionCandidate, 'peerId' | 'priority' | 'registeredAt'>>) {
return [...entries].sort((left, right) => {
if (right.priority !== left.priority) {
return right.priority - left.priority
}
return left.registeredAt - right.registeredAt
})
}
/**
* Selects a concrete peer for AIRI consumer-style delivery modes.
*
* Sticky and round-robin state are keyed with structured JSON tuples so event,
* group, and sticky key values may contain delimiter-like text safely.
*/
export function selectConsumerPeerId(options: {
eventType: string
fromPeerId: string
delivery?: DeliveryConfig
candidates: ConsumerSelectionCandidate[]
roundRobinCursor?: Map<string, number>
stickyAssignments?: Map<string, ConsumerStickyAssignment>
}) {
const { candidates, delivery, eventType, fromPeerId } = options
if (!delivery || !isConsumerDeliveryMode(delivery.mode)) {
return
}
const normalizedGroup = normalizeConsumerGroup(delivery.mode, delivery.group)
const registryKey = JSON.stringify([eventType, normalizedGroup])
const availableEntries = sortConsumers(
candidates
.filter(entry => entry.peerId !== fromPeerId)
.filter(entry => entry.authenticated && entry.healthy !== false),
)
if (availableEntries.length === 0) {
return
}
const selection = delivery.selection ?? 'first'
if (selection === 'sticky' && delivery.stickyKey) {
const stickyRegistryKey = JSON.stringify([eventType, normalizedGroup, delivery.stickyKey])
const stickyAssignment = options.stickyAssignments?.get(stickyRegistryKey)
if (stickyAssignment && stickyAssignment.peerId !== fromPeerId) {
const stickyCandidate = availableEntries.find(entry => entry.peerId === stickyAssignment.peerId)
if (stickyCandidate) {
return stickyAssignment.peerId
}
}
const selected = availableEntries[0]
options.stickyAssignments?.set(stickyRegistryKey, { event: eventType, group: normalizedGroup, peerId: selected.peerId })
return selected.peerId
}
if (selection === 'round-robin') {
const cursor = options.roundRobinCursor?.get(registryKey) ?? 0
const selected = availableEntries[cursor % availableEntries.length]
options.roundRobinCursor?.set(registryKey, (cursor + 1) % availableEntries.length)
return selected.peerId
}
return availableEntries[0].peerId
}
/**
* Creates the AIRI consumer delivery orchestrator for websocket peers.
*
* The orchestrator owns registration, unregister, listing, selection, and
* sticky/round-robin cleanup state for AIRI consumer routing.
*/
export function createConsumerOrchestrator() {
const consumerRegistry = new Map<string, Map<string, Map<string, ConsumerRegistration>>>()
const consumerKeysByPeer = new Map<string, Map<string, ConsumerRegistryRef>>()
const deliveryRoundRobinCursor = new Map<string, number>()
const stickyAssignments = new Map<string, ConsumerStickyAssignment>()
function removeStickyAssignmentsFor(event: string, group: string, peerId?: string) {
for (const [stickyKey, assignment] of stickyAssignments.entries()) {
if (peerId && assignment.peerId !== peerId) {
continue
}
if (assignment.event === event && assignment.group === group) {
stickyAssignments.delete(stickyKey)
}
}
}
return {
register(input: { peerId: string, event: string, mode: 'consumer' | 'consumer-group', group?: string, priority?: number }) {
const normalizedGroup = normalizeConsumerGroup(input.mode, input.group)
const registryKey = JSON.stringify([input.event, normalizedGroup])
let groups = consumerRegistry.get(input.event)
if (!groups) {
groups = new Map()
consumerRegistry.set(input.event, groups)
}
let peersForGroup = groups.get(normalizedGroup)
if (!peersForGroup) {
peersForGroup = new Map()
groups.set(normalizedGroup, peersForGroup)
}
const didGrowMembership = !peersForGroup.has(input.peerId)
peersForGroup.set(input.peerId, {
event: input.event,
group: normalizedGroup,
peerId: input.peerId,
priority: normalizeConsumerPriority(input.priority),
registeredAt: Date.now(),
})
if (didGrowMembership) {
deliveryRoundRobinCursor.delete(registryKey)
}
let registrations = consumerKeysByPeer.get(input.peerId)
if (!registrations) {
registrations = new Map()
consumerKeysByPeer.set(input.peerId, registrations)
}
registrations.set(registryKey, { event: input.event, group: normalizedGroup })
},
unregister(input: { peerId: string, event: string, mode: 'consumer' | 'consumer-group', group?: string }) {
const normalizedGroup = normalizeConsumerGroup(input.mode, input.group)
const registryKey = JSON.stringify([input.event, normalizedGroup])
const groups = consumerRegistry.get(input.event)
const peersForGroup = groups?.get(normalizedGroup)
const didDelete = peersForGroup?.delete(input.peerId) ?? false
if (!didDelete) {
return
}
deliveryRoundRobinCursor.delete(registryKey)
if (peersForGroup?.size === 0) {
groups?.delete(normalizedGroup)
}
if (groups?.size === 0) {
consumerRegistry.delete(input.event)
}
const registrations = consumerKeysByPeer.get(input.peerId)
registrations?.delete(registryKey)
if (registrations?.size === 0) {
consumerKeysByPeer.delete(input.peerId)
}
removeStickyAssignmentsFor(input.event, normalizedGroup, input.peerId)
},
unregisterPeer(peerId: string) {
const registrations = consumerKeysByPeer.get(peerId)
if (!registrations?.size) {
return
}
for (const registration of registrations.values()) {
const { event, group } = registration
const groups = consumerRegistry.get(event)
const peersForGroup = groups?.get(group)
peersForGroup?.delete(peerId)
deliveryRoundRobinCursor.delete(JSON.stringify([event, group]))
if (peersForGroup?.size === 0) {
groups?.delete(group)
}
if (groups?.size === 0) {
consumerRegistry.delete(event)
}
removeStickyAssignmentsFor(event, group, peerId)
}
consumerKeysByPeer.delete(peerId)
},
listFor(input: { event: string, mode: 'consumer' | 'consumer-group', group?: string }) {
const normalizedGroup = normalizeConsumerGroup(input.mode, input.group)
return [...consumerRegistry.get(input.event)?.get(normalizedGroup)?.values() ?? []]
},
select(input: {
eventType: string
fromPeerId: string
delivery?: DeliveryConfig
candidates: ConsumerSelectionCandidate[]
}) {
return selectConsumerPeerId({
...input,
roundRobinCursor: deliveryRoundRobinCursor,
stickyAssignments,
})
},
clear() {
consumerRegistry.clear()
consumerKeysByPeer.clear()
deliveryRoundRobinCursor.clear()
stickyAssignments.clear()
},
}
}
@@ -1,354 +1,27 @@
import type { DeliveryConfig, ExtensionIdentity, MessageHeartbeat, MetadataEventSource, WebSocketBaseEvent, WebSocketEvent } from '@proj-airi/server-shared/types'
import type {
RouteContext,
RouteDecision,
RouteMiddleware,
} from '../../middlewares'
import type { Peer } from '../../types'
import { ServerErrorMessages } from '@proj-airi/server-shared'
import {
getProtocolEventMetadata,
MessageHeartbeatKind,
WebSocketEventSource,
} from '@proj-airi/server-shared/types'
import { nanoid } from 'nanoid'
import { parse, stringify } from 'superjson'
import packageJSON from '../../../package.json'
import { createEventCodec, createGatewayLifecycle } from '../core'
const invalidAiriWebSocketEventFormatMessage = 'Invalid WebSocket event format.'
/**
* Close details surfaced by the websocket runtime for AIRI peer shutdown logging.
*/
export interface AiriServerWsCloseDetails {
/** WebSocket close code when the runtime reports one. */
code?: number
/** WebSocket close reason when the runtime reports one. */
reason?: string
/** Whether the runtime considers the close clean. */
wasClean?: unknown
}
/**
* Error thrown when a websocket message parses as JSON but is not an AIRI event envelope.
*
* Use when:
* - The runtime must distinguish malformed event envelopes from invalid JSON text
*
* Expects:
* - Callers convert this to the protocol `invalidEventFormat` response
*
* Returns:
* - A typed error for invalid AIRI websocket event envelopes
*/
export class AiriWebSocketEventFormatError extends Error {
constructor() {
super(invalidAiriWebSocketEventFormatMessage)
this.name = 'AiriWebSocketEventFormatError'
}
}
/**
* Creates the AIRI websocket gateway wrapper.
*
* Use when:
* - `setupApp(...)` needs a gateway object to mount on `/ws`
*
* Expects:
* - `handler` preserves the existing AIRI websocket lifecycle behavior
*
* Returns:
* - A gateway object compatible with H3 `defineWebSocketHandler(...)`
*/
export function createGateway(input: {
handler: {
open: (peer: Peer) => void
message: (peer: Peer, message: { text: () => string }) => void
error: (peer: Peer, error: unknown) => void
close: (peer: Peer, details?: AiriServerWsCloseDetails) => void
}
dispose?: () => void
}) {
return createGatewayLifecycle({
handler: input.handler,
dispose: input.dispose,
})
}
/**
* Creates metadata for events emitted by the AIRI websocket runtime.
*
* Use when:
* - The server sends protocol events to connected peers
* - Response events should preserve parent event correlation
*
* Expects:
* - `serverInstanceId` identifies the active server runtime instance
*
* Returns:
* - AIRI protocol metadata with server source and event id
*/
export function createEventMetadata(
serverInstanceId: string,
parentId?: string,
): { source: MetadataEventSource, event: { id: string, parentId?: string } } {
return {
event: {
id: nanoid(),
parentId,
},
source: {
kind: 'plugin',
plugin: {
id: WebSocketEventSource.Server,
version: packageJSON.version,
},
id: serverInstanceId,
},
}
}
/**
* Creates AIRI server response event factories.
*
* Use when:
* - WebSocket handlers need stable response event shapes
*
* Expects:
* - `serverInstanceId` identifies the current server runtime
*
* Returns:
* - Factory methods for protocol responses emitted by the server
*/
export function createResponses(serverInstanceId: string) {
return {
authenticated(parentId?: string) {
return {
type: 'module:authenticated',
data: { authenticated: true },
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',
data: { message: ServerErrorMessages.notAuthenticated },
metadata: createEventMetadata(serverInstanceId, parentId),
} satisfies WebSocketEvent<Record<string, unknown>>
},
error(message: string, parentId?: string) {
return {
type: 'error',
data: { message },
metadata: createEventMetadata(serverInstanceId, parentId),
} satisfies WebSocketEvent<Record<string, unknown>>
},
heartbeat(kind: MessageHeartbeatKind, message: MessageHeartbeat | string, parentId?: string) {
return {
type: 'transport:connection:heartbeat',
data: { kind, message, at: Date.now() },
metadata: createEventMetadata(serverInstanceId, parentId),
} satisfies WebSocketEvent<Record<string, unknown>>
},
}
}
/**
* Checks whether an error came from AIRI websocket event envelope validation.
*
* Use when:
* - Message handlers need to map invalid envelopes to protocol errors
*
* Expects:
* - Parser code throws {@link AiriWebSocketEventFormatError} for envelope failures
*
* Returns:
* - `true` when the error should become `ServerErrorMessages.invalidEventFormat`
*/
export function isAiriWebSocketEventFormatError(error: unknown): error is AiriWebSocketEventFormatError {
return error instanceof AiriWebSocketEventFormatError
}
/**
* Detects raw websocket heartbeat control frames surfaced as text payloads.
*
* Use when:
* - A websocket runtime forwards ping/pong frames through the normal message callback
* - The runtime should ignore transport heartbeats instead of treating them as protocol JSON
*
* Expects:
* - Raw text payloads such as `ping` and `pong`
*
* Returns:
* - The heartbeat kind when the text is a control frame, otherwise `undefined`
*/
export function heartbeatFrameFrom(text: string): MessageHeartbeatKind | undefined {
if (text === MessageHeartbeatKind.Ping || text === MessageHeartbeatKind.Pong) {
return text
}
}
/**
* Parses one AIRI websocket protocol event.
*
* Use when:
* - Reading text messages from WebSocket peers
*
* Expects:
* - SDK clients may send `superjson.stringify(...)`
* - External clients may send plain JSON
*
* Returns:
* - A WebSocket event with a string `type`
*/
export function parseEvent(text: string): WebSocketEvent {
// NOTICE:
// SDK clients send events using superjson.stringify, so websocket runtime code must
// use superjson.parse instead of message.json() or plain JSON.parse first.
// JSON.parse on a superjson-encoded string returns the wrapper object
// `{ json: {...}, meta: {...} }` with no protocol `type`, which breaks routing.
// Keep this until all AIRI websocket clients share one non-wrapper wire format.
let parsed: WebSocketEvent | undefined
try {
parsed = parse<WebSocketEvent>(text)
}
catch {
parsed = undefined
}
const potentialEvent = (parsed && typeof parsed === 'object' && 'type' in parsed)
? parsed
: JSON.parse(text)
if (
!potentialEvent
|| typeof potentialEvent !== 'object'
|| !('type' in potentialEvent)
|| typeof potentialEvent.type !== 'string'
|| !('data' in potentialEvent)
|| !potentialEvent.data
|| typeof potentialEvent.data !== 'object'
|| Array.isArray(potentialEvent.data)
) {
throw new AiriWebSocketEventFormatError()
}
return potentialEvent as WebSocketEvent
}
/**
* Serializes one AIRI websocket protocol event.
*
* Use when:
* - Sending AIRI events through WebSocket peers
*
* Expects:
* - `event` is already protocol-shaped
*
* Returns:
* - SuperJSON text payload matching existing runtime behavior
*/
export function stringifyEvent(event: WebSocketBaseEvent<string, unknown> | string) {
return typeof event === 'string' ? event : stringify(event)
}
/**
* Resolves the effective event delivery policy.
*
* Use when:
* - Protocol defaults should be merged with route-level delivery overrides
* - Routing needs to know whether the event should broadcast or target one consumer
*
* Expects:
* - Route delivery to override protocol metadata field-by-field
*
* Returns:
* - The merged broadcast/consumer delivery policy, or `undefined` when unrestricted
*/
export function resolveEventDelivery(event: WebSocketEvent): DeliveryConfig | undefined {
const eventMetadata = getProtocolEventMetadata(event.type)
const defaultDelivery = eventMetadata?.delivery
const routeDelivery = event.route?.delivery
if (!defaultDelivery && !routeDelivery) {
return undefined
}
return {
...defaultDelivery,
...routeDelivery,
}
}
/**
* Creates event serializer hooks used by server websocket adapters.
*
* Use when:
* - A gateway wants protocol-specific parsing, stringifying, and control-frame detection
*
* Expects:
* - Callers route raw control frames before protocol events
*
* Returns:
* - A reusable `server-ws/core` codec configured for AIRI events
*/
export function createEventSerializer() {
return createEventCodec<WebSocketEvent>({
parse: parseEvent,
stringify: stringifyEvent,
detectControlFrame: heartbeatFrameFrom,
})
}
/**
* Iterates event middlewares in declaration order until one returns a decision.
*
* Use when:
* - The websocket runtime needs the first route decision from configured middleware
*
* Expects:
* - Middleware functions are ordered by caller policy
*
* Returns:
* - The first route decision, or `undefined` when no middleware decided
*/
export function forEachEventMiddlewares(input: {
event: WebSocketEvent
fromPeer: RouteContext['fromPeer']
peers: Map<string, RouteContext['fromPeer']>
destinations?: RouteContext['destinations']
middleware: RouteMiddleware[]
}): RouteDecision | undefined {
const context: RouteContext = {
event: input.event,
fromPeer: input.fromPeer,
peers: input.peers,
destinations: input.destinations,
}
for (const middleware of input.middleware) {
const result = middleware(context)
if (result) {
return result
}
}
}
export {
heartbeatFrameFrom,
InvalidEventError,
isInvalidEventError,
parseEvent,
stringifyEvent,
} from './codec'
export {
createConsumerOrchestrator,
isConsumerDeliveryMode,
normalizeConsumerMode,
normalizeConsumerPriority,
selectConsumerPeerId,
} from './consumers'
export type {
ConsumerRegistration,
ConsumerSelectionCandidate,
ConsumerStickyAssignment,
} from './consumers'
export {
resolveHealthCheckIntervalMs,
serverWsDefaultHeartbeatTtlMs,
serverWsHealthCheckIntervalDivisor,
serverWsMinimumHealthCheckIntervalMs,
} from './liveness'
export { createEventMetadata, createResponses } from './responses'
export { forEachEventMiddlewares, resolveEventDelivery } from './routing'
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import {
resolveHealthCheckIntervalMs,
serverWsDefaultHeartbeatTtlMs,
serverWsMinimumHealthCheckIntervalMs,
} from './liveness'
describe('airi websocket liveness policy', () => {
it('uses the AIRI default heartbeat TTL', () => {
expect(serverWsDefaultHeartbeatTtlMs).toBe(60_000)
expect(resolveHealthCheckIntervalMs(serverWsDefaultHeartbeatTtlMs)).toBe(12_000)
})
it('keeps health checks at least five seconds apart', () => {
expect(serverWsMinimumHealthCheckIntervalMs).toBe(5_000)
expect(resolveHealthCheckIntervalMs(1_000)).toBe(serverWsMinimumHealthCheckIntervalMs)
expect(resolveHealthCheckIntervalMs(24_999)).toBe(serverWsMinimumHealthCheckIntervalMs)
expect(resolveHealthCheckIntervalMs(25_000)).toBe(serverWsMinimumHealthCheckIntervalMs)
})
})
@@ -0,0 +1,13 @@
/** Default heartbeat read timeout. */
export const serverWsDefaultHeartbeatTtlMs = 60_000
/** Number of liveness checks scheduled within one heartbeat TTL. */
export const serverWsHealthCheckIntervalDivisor = 5
/** Minimum interval to avoid busy liveness loops. */
export const serverWsMinimumHealthCheckIntervalMs = 5_000
/** Resolves the AIRI heartbeat health-check interval in milliseconds. */
export function resolveHealthCheckIntervalMs(heartbeatTtlMs: number) {
return Math.max(serverWsMinimumHealthCheckIntervalMs, Math.floor(heartbeatTtlMs / serverWsHealthCheckIntervalDivisor))
}
@@ -0,0 +1,70 @@
import { WebSocketEventSource } from '@proj-airi/server-shared/types'
import { describe, expect, it } from 'vitest'
import packageJSON from '../../../package.json'
import { createEventMetadata, createResponses } from './responses'
describe('airi websocket responses', () => {
it('creates server metadata with source and parent event ids', () => {
const metadata = createEventMetadata('server-1', 'parent-event-1')
expect(metadata.source).toEqual({
kind: 'plugin',
plugin: {
id: WebSocketEventSource.Server,
version: packageJSON.version,
},
id: 'server-1',
})
expect(metadata.event).toEqual({
id: expect.any(String),
parentId: 'parent-event-1',
})
})
it('creates peer and extension authentication response shapes', () => {
const responses = createResponses('server-1')
expect(responses.peerAuthenticated('peer-1', 'event-1')).toMatchObject({
type: 'peer:authenticated',
data: {
authenticated: true,
peerId: 'peer-1',
},
metadata: {
source: {
id: 'server-1',
plugin: {
id: WebSocketEventSource.Server,
version: packageJSON.version,
},
},
event: {
parentId: 'event-1',
},
},
})
expect(responses.extensionAuthenticated({ id: 'airi-extension-chess' }, 'event-2')).toMatchObject({
type: 'extension:authenticated',
data: {
authenticated: true,
identity: {
id: 'airi-extension-chess',
},
},
metadata: {
source: {
id: 'server-1',
plugin: {
id: WebSocketEventSource.Server,
version: packageJSON.version,
},
},
event: {
parentId: 'event-2',
},
},
})
})
})
@@ -0,0 +1,76 @@
import type { ExtensionIdentity, MessageHeartbeat, MessageHeartbeatKind, MetadataEventSource, WebSocketEvent } from '@proj-airi/server-shared/types'
import { ServerErrorMessages } from '@proj-airi/server-shared'
import { WebSocketEventSource } from '@proj-airi/server-shared/types'
import { nanoid } from 'nanoid'
import packageJSON from '../../../package.json'
/** Creates AIRI server event metadata and preserves optional parent correlation. */
export function createEventMetadata(
serverInstanceId: string,
parentId?: string,
): { source: MetadataEventSource, event: { id: string, parentId?: string } } {
return {
event: {
id: nanoid(),
parentId,
},
source: {
kind: 'plugin',
plugin: {
id: WebSocketEventSource.Server,
version: packageJSON.version,
},
id: serverInstanceId,
},
}
}
/** Creates AIRI server response event factories. */
export function createResponses(serverInstanceId: string) {
return {
authenticated(parentId?: string) {
return {
type: 'module:authenticated',
data: { authenticated: true },
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',
data: { message: ServerErrorMessages.notAuthenticated },
metadata: createEventMetadata(serverInstanceId, parentId),
} satisfies WebSocketEvent<Record<string, unknown>>
},
error(message: string, parentId?: string) {
return {
type: 'error',
data: { message },
metadata: createEventMetadata(serverInstanceId, parentId),
} satisfies WebSocketEvent<Record<string, unknown>>
},
heartbeat(kind: MessageHeartbeatKind, message: MessageHeartbeat | string, parentId?: string) {
return {
type: 'transport:connection:heartbeat',
data: { kind, message, at: Date.now() },
metadata: createEventMetadata(serverInstanceId, parentId),
} satisfies WebSocketEvent<Record<string, unknown>>
},
}
}
@@ -0,0 +1,67 @@
import type { DeliveryConfig, WebSocketEvent } from '@proj-airi/server-shared/types'
import type { RouteContext, RouteDecision, RouteMiddleware } from '../../middlewares'
import { getProtocolEventMetadata } from '@proj-airi/server-shared/types'
/**
* Resolves the effective event delivery policy.
*
* Use when:
* - Protocol defaults should be merged with route-level delivery overrides
* - Routing needs to know whether the event should broadcast or target one consumer
*
* Expects:
* - Route delivery to override protocol metadata field-by-field
*
* Returns:
* - The merged broadcast/consumer delivery policy, or `undefined` when unrestricted
*/
export function resolveEventDelivery(event: WebSocketEvent): DeliveryConfig | undefined {
const eventMetadata = getProtocolEventMetadata(event.type)
const defaultDelivery = eventMetadata?.delivery
const routeDelivery = event.route?.delivery
if (!defaultDelivery && !routeDelivery) {
return undefined
}
return {
...defaultDelivery,
...routeDelivery,
}
}
/**
* Iterates event middlewares in declaration order until one returns a decision.
*
* Use when:
* - The websocket runtime needs the first route decision from configured middleware
*
* Expects:
* - Middleware functions are ordered by caller policy
*
* Returns:
* - The first route decision, or `undefined` when no middleware decided
*/
export function forEachEventMiddlewares(input: {
event: WebSocketEvent
fromPeer: RouteContext['fromPeer']
peers: Map<string, RouteContext['fromPeer']>
destinations?: RouteContext['destinations']
middleware: RouteMiddleware[]
}): RouteDecision | undefined {
const context: RouteContext = {
event: input.event,
fromPeer: input.fromPeer,
peers: input.peers,
destinations: input.destinations,
}
for (const middleware of input.middleware) {
const result = middleware(context)
if (result) {
return result
}
}
}
@@ -1,543 +0,0 @@
/**
* Delivery settings used by the reusable websocket gateway.
*
* @param TMode - Delivery mode literals accepted by the adapter.
*/
export interface ServerWsDeliveryConfig<TMode extends string = 'broadcast' | 'consumer' | 'consumer-group'> {
/**
* Delivery mode selected by the protocol adapter.
*
* @default undefined
*/
mode?: TMode
/**
* Optional consumer group.
*
* @default "default" for consumer delivery modes.
*/
group?: string
/**
* Selection strategy within the target consumer set.
*
* @default "first"
*/
selection?: 'first' | 'priority' | 'sticky' | 'round-robin'
/**
* Sticky routing key used when `selection` is `sticky`.
*
* @default undefined
*/
stickyKey?: string
/**
* Whether missing consumers should be surfaced as an error by the adapter.
*
* @default false
*/
required?: boolean
}
/**
* Delivery settings accepted by the reusable consumer registry.
*
* @param TMode - Consumer delivery mode literals accepted by the adapter.
*/
export type ServerWsConsumerDeliveryConfig<TMode extends string = 'consumer' | 'consumer-group'> = ServerWsDeliveryConfig<TMode>
/**
* Candidate peer metadata used for consumer selection.
*/
export interface ServerWsConsumerSelectionCandidate {
/** Peer id available to receive the event. */
peerId: string
/** Higher values are selected before lower values. */
priority: number
/** Timestamp captured when the peer registered as a consumer. */
registeredAt: number
/** Whether the peer has completed protocol-level authentication. */
authenticated: boolean
/** Explicit `false` excludes the peer from selection. */
healthy?: boolean
}
/**
* Stored consumer registration.
*/
export interface ServerWsConsumerRegistration {
/** Protocol event type consumed by the peer. */
event: string
/** Normalized consumer group name. */
group: string
/** Peer id that registered for the event/group pair. */
peerId: string
/** Higher values are selected before lower values. */
priority: number
/** Timestamp captured when the peer registered as a consumer. */
registeredAt: number
}
/**
* Describes protocol-agnostic text encoding and decoding for websocket events.
*
* @param TEvent - Event envelope shape owned by the protocol adapter.
*/
export interface ServerWsEventCodec<TEvent> {
/** Parses one text payload into a protocol event. */
parse: (text: string) => TEvent
/** Serializes one protocol event or pre-serialized payload for peer sending. */
stringify: (event: TEvent | string) => string
/** Detects raw transport control payloads that should not enter protocol routing. */
detectControlFrame?: (text: string) => string | undefined
}
/**
* Describes a websocket handler object accepted by H3 `defineWebSocketHandler`.
*
* @param TPeer - Runtime peer object accepted by lifecycle callbacks.
* @param TMessage - Runtime message object accepted by the message callback.
* @param TCloseDetails - Runtime close details object accepted by the close callback.
*/
export interface ServerWsGatewayHandler<TPeer = unknown, TMessage = unknown, TCloseDetails = unknown> {
/** Called when a peer opens a websocket connection. */
open?: (peer: TPeer) => void
/** Called when a peer sends one websocket message. */
message?: (peer: TPeer, message: TMessage) => void
/** Called when the websocket runtime reports an error. */
error?: (peer: TPeer, error: unknown) => void
/** Called when a peer closes a websocket connection. */
close?: (peer: TPeer, details?: TCloseDetails) => void
}
/**
* Minimal websocket peer shape used by the reusable gateway.
*/
export interface ServerWsPeer {
/** Stable peer id assigned by the websocket runtime. */
get id(): string
/** Sends one payload to the peer. */
send: (data: unknown, options?: { compress?: boolean }) => number | void | undefined
/** Closes the peer connection when the runtime exposes an explicit close hook. */
close?: () => void
/** WebSocket ready state when exposed by the runtime. */
readyState?: number
/** Request metadata associated with the websocket upgrade. */
request?: {
/** Request URL associated with the websocket upgrade. */
url?: string
/** Request headers associated with the websocket upgrade. */
headers?: Headers
}
/** Remote peer address when exposed by the runtime. */
remoteAddress?: string
}
/** Default heartbeat read timeout used by the websocket gateway. */
export const serverWsDefaultHeartbeatTtlMs = 60_000
/** Miss count where a peer becomes unhealthy but remains connected. */
export const serverWsHealthCheckMissesUnhealthy = 5
/** Miss count where a peer is considered dead and should be closed. */
export const serverWsHealthCheckMissesDead = serverWsHealthCheckMissesUnhealthy * 2
const DEFAULT_CONSUMER_GROUP = 'default'
interface ServerWsConsumerRegistryRef {
event: string
group: string
}
/**
* Sticky consumer assignment stored by the reusable consumer selector.
*/
export interface ServerWsStickyAssignment {
/** Protocol event type the sticky assignment belongs to. */
event: string
/** Normalized consumer group the sticky assignment belongs to. */
group: string
/** Peer selected for the sticky key. */
peerId: string
}
/**
* Creates a websocket event codec from explicit parser and serializer callbacks.
*
* Use when:
* - A protocol adapter wants to plug its own event envelope into `server-ws/core`
*
* Expects:
* - Parser and serializer preserve the adapter's current wire format
*
* Returns:
* - A protocol-agnostic codec object consumed by gateway code
*/
export function createEventCodec<TEvent>(codec: ServerWsEventCodec<TEvent>) {
return codec
}
/**
* Wraps websocket lifecycle callbacks and disposal as a reusable mount object.
*
* Use when:
* - Adapters need one stable lifecycle shape for server mounting
*
* Expects:
* - `handler` contains already-bound protocol behavior
*
* Returns:
* - A handler plus idempotent disposal hook
*/
export function createGatewayLifecycle<TPeer, TMessage, TCloseDetails = unknown>(input: {
handler: ServerWsGatewayHandler<TPeer, TMessage, TCloseDetails>
dispose?: () => void
}) {
let disposed = false
return {
handler: input.handler,
dispose: () => {
if (disposed) {
return
}
disposed = true
input.dispose?.()
},
}
}
/**
* Resolves the interval used for heartbeat health checks.
*
* Use when:
* - Gateway code needs to convert heartbeat TTL into periodic miss checks
*
* Expects:
* - Very small TTL values should still avoid busy intervals
*
* Returns:
* - Interval in milliseconds
*/
export function resolveServerWsHealthCheckIntervalMs(heartbeatTtlMs: number) {
return Math.max(5_000, Math.floor(heartbeatTtlMs / serverWsHealthCheckMissesUnhealthy))
}
/**
* Creates a typed peer store around websocket peer state.
*
* Use when:
* - A gateway needs stable peer lookup, iteration, and cleanup
*
* Expects:
* - `TState` contains protocol-specific peer state
*
* Returns:
* - A small registry over peers keyed by peer id
*/
export function createServerWsPeerStore<TState extends { peer: ServerWsPeer }>() {
const peers = new Map<string, TState>()
return {
peers,
get(peerId: string) {
return peers.get(peerId)
},
set(peerId: string, state: TState) {
peers.set(peerId, state)
return state
},
delete(peerId: string) {
return peers.delete(peerId)
},
clear() {
peers.clear()
},
values() {
return peers.values()
},
entries() {
return peers.entries()
},
size() {
return peers.size
},
}
}
/**
* Checks whether a delivery mode targets the consumer registry.
*
* Use when:
* - A protocol adapter receives broad delivery modes but must call consumer-only APIs
*
* Expects:
* - Non-consumer modes such as `broadcast` should remain outside the consumer registry
*
* Returns:
* - `true` for `consumer` and `consumer-group`
*/
export function isConsumerDeliveryMode(mode: unknown): mode is ServerWsConsumerDeliveryConfig['mode'] {
return mode === 'consumer' || mode === 'consumer-group'
}
/**
* Normalizes delivery mode for consumer registration.
*
* Before:
* - undefined with group "workers"
*
* After:
* - "consumer-group"
*/
export function normalizeConsumerMode(mode: unknown, group?: string): 'consumer' | 'consumer-group' {
if (isConsumerDeliveryMode(mode)) {
return mode!
}
return group ? 'consumer-group' : 'consumer'
}
/**
* Normalizes consumer priority.
*
* Before:
* - NaN
*
* After:
* - 0
*/
export function normalizeConsumerPriority(priority: unknown) {
return typeof priority === 'number' && Number.isFinite(priority)
? priority
: 0
}
function normalizeConsumerGroup(mode: ServerWsConsumerDeliveryConfig['mode'], group?: string) {
if (mode === 'consumer') {
return DEFAULT_CONSUMER_GROUP
}
return group || DEFAULT_CONSUMER_GROUP
}
function getConsumerRegistryKey(event: string, group: string) {
return JSON.stringify([event, group])
}
function getStickyRegistryKey(event: string, group: string, stickyKey: string) {
return JSON.stringify([event, group, stickyKey])
}
function sortConsumers(entries: Array<Pick<ServerWsConsumerSelectionCandidate, 'peerId' | 'priority' | 'registeredAt'>>) {
return [...entries].sort((left, right) => {
if (right.priority !== left.priority) {
return right.priority - left.priority
}
return left.registeredAt - right.registeredAt
})
}
/**
* Selects a concrete consumer peer for consumer-style delivery modes.
*
* Use when:
* - An event should be sent to exactly one registered consumer
* - Sticky or round-robin routing needs to be resolved against live peer metadata
*
* Expects:
* - Candidates already describe authenticated and health state
*
* Returns:
* - The selected peer id, or `undefined` when no eligible consumer is available
*/
export function selectConsumerPeerId(options: {
eventType: string
fromPeerId: string
delivery?: ServerWsDeliveryConfig
candidates: ServerWsConsumerSelectionCandidate[]
roundRobinCursor?: Map<string, number>
stickyAssignments?: Map<string, ServerWsStickyAssignment>
}) {
const { candidates, delivery, eventType, fromPeerId } = options
if (!delivery || (delivery.mode !== 'consumer' && delivery.mode !== 'consumer-group')) {
return
}
const normalizedGroup = normalizeConsumerGroup(delivery.mode, delivery.group)
const registryKey = getConsumerRegistryKey(eventType, normalizedGroup)
const availableEntries = sortConsumers(
candidates
.filter(entry => entry.peerId !== fromPeerId)
.filter(entry => entry.authenticated && entry.healthy !== false),
)
if (availableEntries.length === 0) {
return
}
const selection = delivery.selection ?? 'first'
if (selection === 'sticky' && delivery.stickyKey) {
const stickyRegistryKey = getStickyRegistryKey(eventType, normalizedGroup, delivery.stickyKey)
const stickyAssignment = options.stickyAssignments?.get(stickyRegistryKey)
if (stickyAssignment && stickyAssignment.peerId !== fromPeerId) {
const stickyCandidate = availableEntries.find(entry => entry.peerId === stickyAssignment.peerId)
if (stickyCandidate) {
return stickyAssignment.peerId
}
}
const selected = availableEntries[0]
options.stickyAssignments?.set(stickyRegistryKey, { event: eventType, group: normalizedGroup, peerId: selected.peerId })
return selected.peerId
}
if (selection === 'round-robin') {
const cursor = options.roundRobinCursor?.get(registryKey) ?? 0
const selected = availableEntries[cursor % availableEntries.length]
options.roundRobinCursor?.set(registryKey, (cursor + 1) % availableEntries.length)
return selected.peerId
}
return availableEntries[0].peerId
}
/**
* Creates a reusable consumer delivery orchestrator for websocket peers.
*
* Use when:
* - A protocol adapter supports one-consumer delivery or consumer groups
*
* Expects:
* - Peer liveness is checked by the caller before delivery
*
* Returns:
* - Registration, unregister, listing, selection, and cleanup helpers
*/
export function createConsumerOrchestrator() {
const consumerRegistry = new Map<string, Map<string, Map<string, ServerWsConsumerRegistration>>>()
const consumerKeysByPeer = new Map<string, Map<string, ServerWsConsumerRegistryRef>>()
const deliveryRoundRobinCursor = new Map<string, number>()
const stickyAssignments = new Map<string, ServerWsStickyAssignment>()
function removeStickyAssignmentsFor(event: string, group: string, peerId?: string) {
for (const [stickyKey, assignment] of stickyAssignments.entries()) {
if (peerId && assignment.peerId !== peerId) {
continue
}
if (assignment.event === event && assignment.group === group) {
stickyAssignments.delete(stickyKey)
}
}
}
return {
register(input: { peerId: string, event: string, mode: ServerWsConsumerDeliveryConfig['mode'], group?: string, priority?: number }) {
const normalizedGroup = normalizeConsumerGroup(input.mode, input.group)
const registryKey = getConsumerRegistryKey(input.event, normalizedGroup)
let groups = consumerRegistry.get(input.event)
if (!groups) {
groups = new Map()
consumerRegistry.set(input.event, groups)
}
let peersForGroup = groups.get(normalizedGroup)
if (!peersForGroup) {
peersForGroup = new Map()
groups.set(normalizedGroup, peersForGroup)
}
const didGrowMembership = !peersForGroup.has(input.peerId)
peersForGroup.set(input.peerId, {
event: input.event,
group: normalizedGroup,
peerId: input.peerId,
priority: normalizeConsumerPriority(input.priority),
registeredAt: Date.now(),
})
if (didGrowMembership) {
deliveryRoundRobinCursor.delete(registryKey)
}
let registrations = consumerKeysByPeer.get(input.peerId)
if (!registrations) {
registrations = new Map()
consumerKeysByPeer.set(input.peerId, registrations)
}
registrations.set(registryKey, { event: input.event, group: normalizedGroup })
},
unregister(input: { peerId: string, event: string, mode: ServerWsConsumerDeliveryConfig['mode'], group?: string }) {
const normalizedGroup = normalizeConsumerGroup(input.mode, input.group)
const registryKey = getConsumerRegistryKey(input.event, normalizedGroup)
const groups = consumerRegistry.get(input.event)
const peersForGroup = groups?.get(normalizedGroup)
const didDelete = peersForGroup?.delete(input.peerId) ?? false
if (!didDelete) {
return
}
deliveryRoundRobinCursor.delete(registryKey)
if (peersForGroup?.size === 0) {
groups?.delete(normalizedGroup)
}
if (groups?.size === 0) {
consumerRegistry.delete(input.event)
}
const registrations = consumerKeysByPeer.get(input.peerId)
registrations?.delete(registryKey)
if (registrations?.size === 0) {
consumerKeysByPeer.delete(input.peerId)
}
removeStickyAssignmentsFor(input.event, normalizedGroup, input.peerId)
},
unregisterPeer(peerId: string) {
const registrations = consumerKeysByPeer.get(peerId)
if (!registrations?.size) {
return
}
for (const registration of registrations.values()) {
const { event, group } = registration
const groups = consumerRegistry.get(event)
const peersForGroup = groups?.get(group)
peersForGroup?.delete(peerId)
deliveryRoundRobinCursor.delete(getConsumerRegistryKey(event, group))
if (peersForGroup?.size === 0) {
groups?.delete(group)
}
if (groups?.size === 0) {
consumerRegistry.delete(event)
}
removeStickyAssignmentsFor(event, group, peerId)
}
consumerKeysByPeer.delete(peerId)
},
listFor(input: { event: string, mode: ServerWsConsumerDeliveryConfig['mode'], group?: string }) {
const normalizedGroup = normalizeConsumerGroup(input.mode, input.group)
return [...consumerRegistry.get(input.event)?.get(normalizedGroup)?.values() ?? []]
},
select(input: {
eventType: string
fromPeerId: string
delivery?: ServerWsDeliveryConfig
candidates: ServerWsConsumerSelectionCandidate[]
}) {
return selectConsumerPeerId({
...input,
roundRobinCursor: deliveryRoundRobinCursor,
stickyAssignments,
})
},
clear() {
consumerRegistry.clear()
consumerKeysByPeer.clear()
deliveryRoundRobinCursor.clear()
stickyAssignments.clear()
},
}
}
+7 -3
View File
@@ -12,6 +12,7 @@ const serveMocks = vi.hoisted(() => {
const closeCall = vi.fn(async () => {})
const disposeCall = vi.fn(() => {})
const createH3CrossWsPluginCall = vi.fn(() => ({ name: 'better-ws-h3-plugin' }))
const setupAppCall = vi.fn(() => ({
app: {
fetch: vi.fn(async () => ({ crossws: {} })),
@@ -22,6 +23,7 @@ const serveMocks = vi.hoisted(() => {
return {
closeCall,
createH3CrossWsPluginCall,
disposeCall,
rejectServe: (error: Error) => rejectServe?.(error),
resolveServe: () => resolveServe?.(),
@@ -34,15 +36,14 @@ vi.mock('h3', () => ({
H3: class {
get = vi.fn()
},
defineWebSocketHandler: vi.fn(handler => handler),
serve: vi.fn(() => ({
serve: serveMocks.serveCall,
close: serveMocks.closeCall,
})),
}))
vi.mock('crossws/server', () => ({
plugin: vi.fn(() => ({})),
vi.mock('@proj-airi/better-ws/server/h3', () => ({
createH3CrossWsPlugin: serveMocks.createH3CrossWsPluginCall,
}))
vi.mock('./index', () => ({
@@ -72,6 +73,9 @@ describe('createServer', async () => {
await Promise.all([firstStart, secondStart])
expect(serveMocks.serveCall).toHaveBeenCalledTimes(1)
expect(serveMocks.createH3CrossWsPluginCall).toHaveBeenCalledWith(expect.objectContaining({
fetch: expect.any(Function),
}))
})
it('clears the single-flight state when start fails', async () => {
+7 -3
View File
@@ -1,3 +1,5 @@
import type { H3CrossWsApp, H3CrossWsResponse } from '@proj-airi/better-ws/server/h3'
import type { AppOptions } from '..'
import { isIP } from 'node:net'
@@ -5,7 +7,7 @@ import { networkInterfaces } from 'node:os'
import { useLogg } from '@guiiai/logg'
import { merge } from '@moeru/std'
import { plugin as ws } from 'crossws/server'
import { createH3CrossWsPlugin } from '@proj-airi/better-ws/server/h3'
import { serve } from 'h3'
import { normalizeLoggerConfig, setupApp } from '..'
@@ -151,13 +153,15 @@ export function createServer(opts?: ServerOptions): Server {
startTask = (async () => {
const secureEnabled = options?.tlsConfig != null
const h3App = setupApp(options)
const crossWsApp = {
fetch: async request => await h3App.app.fetch(request) as H3CrossWsResponse,
} satisfies H3CrossWsApp
const port = options.port
const hostname = options.hostname
const instance = serve(h3App.app, {
// @ts-expect-error - the .crossws property wasn't extended in types
plugins: [ws({ resolve: async req => (await h3App.app.fetch(req)).crossws })],
plugins: [createH3CrossWsPlugin(crossWsApp)],
port,
hostname,
tls: options?.tlsConfig || undefined,
@@ -0,0 +1,235 @@
import type { WebSocketEvent } from '@proj-airi/server-shared/types'
import type { Peer } from './types'
import { parse, stringify } from 'superjson'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setupApp } from './index'
interface TestWebSocketHandler {
open?: (peer: Peer) => void
message?: (peer: Peer, message: { text: () => string }) => void
close?: (peer: Peer, details?: { code?: number, reason?: string, wasClean?: unknown }) => void
}
interface TestWsServer {
accept: (
adapter: { id: string, send: (message: { text: () => string }) => void | number, close?: () => void },
options: { state: { rawPeer: Peer } },
) => void
peers: {
get: (peerId: string) => { receive: (message: { text: () => string }) => void } | undefined
}
remove: (peerId: string, details?: { code?: number, reason?: string, wasClean?: unknown }) => void
}
const h3Mocks = vi.hoisted(() => ({
handlers: new Map<string, unknown>(),
}))
vi.mock('h3', () => ({
H3: class {
get(path: string, handler: unknown) {
h3Mocks.handlers.set(path, handler)
}
},
}))
vi.mock('@proj-airi/better-ws/server/h3', () => ({
toH3Handler: vi.fn((server: TestWsServer, options: { state: (peer: Peer) => { rawPeer: Peer } }) => ({
open(peer: Peer) {
server.accept({
id: peer.id,
send: message => peer.send(message.text()),
close: () => peer.close?.(),
}, {
state: options.state(peer),
})
},
message(peer: Peer, message: { text: () => string }) {
server.peers.get(peer.id)?.receive(message)
},
close(peer: Peer, details?: { code?: number, reason?: string, wasClean?: unknown }) {
server.remove(peer.id, details)
},
})),
}))
function createPeer(id: string) {
const sent: string[] = []
const send: Peer['send'] = (data) => {
sent.push(String(data))
}
return {
peer: {
id,
send: vi.fn(send),
close: vi.fn(),
request: { url: `/ws?id=${id}` },
remoteAddress: '127.0.0.1',
} satisfies Peer,
sent,
}
}
function wsHandler() {
const handler = h3Mocks.handlers.get('/ws') as TestWebSocketHandler | undefined
if (!handler) {
throw new Error('Expected setupApp to register a /ws websocket handler.')
}
return handler
}
function sendEvent(
handler: TestWebSocketHandler,
peer: Peer,
event: WebSocketEvent,
) {
handler.message?.(peer, { text: () => stringify(event) })
}
function decodeEvents(sent: string[]) {
return sent.map(message => parse<WebSocketEvent>(message))
}
function createExtensionModuleAnnounceEvent(): WebSocketEvent {
return {
type: 'extension:module:announce',
data: {
name: 'memory',
possibleEvents: [],
identity: {
id: 'memory-module-1',
extension: {
id: 'extension-1',
},
},
},
metadata: {
source: {
kind: 'plugin',
id: 'extension-1',
plugin: {
id: 'extension-1',
},
},
event: {
id: 'announce-1',
},
},
}
}
describe('setupApp websocket liveness', () => {
beforeEach(() => {
h3Mocks.handlers.clear()
vi.useFakeTimers({ now: 0 })
})
afterEach(() => {
vi.useRealTimers()
})
it('broadcasts extension module unhealthy events from better-ws liveness checks', () => {
const runtime = setupApp({ heartbeat: { readTimeout: 20_000 } })
const handler = wsHandler()
const observer = createPeer('observer')
const modulePeer = createPeer('module-peer')
handler.open?.(observer.peer)
handler.open?.(modulePeer.peer)
sendEvent(handler, modulePeer.peer, createExtensionModuleAnnounceEvent())
observer.sent.length = 0
vi.advanceTimersByTime(25_000)
expect(decodeEvents(observer.sent)).toEqual(expect.arrayContaining([
expect.objectContaining({
type: 'registry:modules:health:unhealthy',
data: {
name: 'memory',
identity: {
id: 'memory-module-1',
extension: {
id: 'extension-1',
},
},
reason: 'heartbeat late',
},
}),
]))
runtime.dispose()
})
it('de-announces expired extension modules when better-ws removes stale peers', () => {
const runtime = setupApp({ heartbeat: { readTimeout: 20_000 } })
const handler = wsHandler()
const observer = createPeer('observer')
const modulePeer = createPeer('module-peer')
handler.open?.(observer.peer)
handler.open?.(modulePeer.peer)
sendEvent(handler, modulePeer.peer, createExtensionModuleAnnounceEvent())
observer.sent.length = 0
vi.advanceTimersByTime(25_000)
handler.message?.(observer.peer, { text: () => 'pong' })
observer.sent.length = 0
vi.advanceTimersByTime(25_000)
expect(modulePeer.peer.close).toHaveBeenCalledOnce()
expect(decodeEvents(observer.sent)).toEqual(expect.arrayContaining([
expect.objectContaining({
type: 'extension:module:de-announced',
data: expect.objectContaining({
name: 'memory',
reason: 'heartbeat expired',
}),
}),
]))
runtime.dispose()
})
it('de-announces extension modules before accepting a same-id reconnect', () => {
const runtime = setupApp({ heartbeat: { readTimeout: 20_000 } })
const handler = wsHandler()
const observer = createPeer('observer')
const firstModulePeer = createPeer('module-peer')
const secondModulePeer = createPeer('module-peer')
handler.open?.(observer.peer)
handler.open?.(firstModulePeer.peer)
sendEvent(handler, firstModulePeer.peer, createExtensionModuleAnnounceEvent())
observer.sent.length = 0
handler.open?.(secondModulePeer.peer)
expect(decodeEvents(observer.sent)).toEqual(expect.arrayContaining([
expect.objectContaining({
type: 'extension:module:de-announced',
data: expect.objectContaining({
name: 'memory',
reason: 'connection closed',
}),
}),
]))
runtime.dispose()
})
it('closes each raw peer once during runtime disposal', () => {
const runtime = setupApp({ heartbeat: { readTimeout: 20_000 } })
const handler = wsHandler()
const peer = createPeer('peer-1')
handler.open?.(peer.peer)
runtime.dispose()
expect(peer.peer.close).toHaveBeenCalledOnce()
})
})
@@ -52,5 +52,10 @@ export interface AuthenticatedPeer extends NamedPeer {
extensionModules?: Map<string, RegisteredExtensionModule>
lastHeartbeatAt?: number
healthy?: boolean
/**
* REVIEW: Legacy field name kept during the better-ws migration.
* The value now stores peer silence duration in milliseconds, not a miss count.
* Rename this with the server-runtime peer state cleanup.
*/
missedHeartbeats?: number
}