refactor(server-runtime): split modules

This commit is contained in:
Neko Ayaka
2026-05-05 02:30:45 +08:00
parent 40284514d4
commit 8a88d233f2
8 changed files with 1695 additions and 710 deletions
+4
View File
@@ -145,6 +145,10 @@ Concise but detailed reference for contributors working across the `moeru-ai/air
## Naming & Comments
- File names: kebab-case.
- Prefer names that rely on the module boundary for context instead of repeating package, product, protocol, or transport prefixes inside every symbol. A well-named module should let exported functions use short action-first names; repeat the larger context only when the symbol crosses a boundary where that context is no longer obvious.
- Name functions after the domain operation they perform, not after the implementation layer that happens to contain them. This keeps call sites readable after refactors and avoids names becoming stale when code moves between files.
- Avoid names that encode multiple layers of ownership into one symbol. If a name needs several qualifiers to be understandable, reconsider the module boundary or introduce a clearer local concept.
- Use nouns for resolved domain concepts and verbs for transformations or side effects. When a function derives a policy/configuration from an event or request, name the domain result explicitly so callers understand what decision is being made.
- Avoid classes unless extending runtime/browser APIs; FP + DI is easier to test/mock.
- Add clear, concise comments for utils, math, OS-interaction, algorithm, shared, and architectural functions that explain what the function does.
- When using a workaround, add a `// NOTICE:` comment explaining why, the root cause, and any source context. If validated via `node_modules` inspection or external sources (e.g., GitHub), include relevant line references and links in code-formatted text.
+11 -11
View File
@@ -2,7 +2,7 @@ import type { WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-shar
import { describe, expect, it } from 'vitest'
import { detectHeartbeatControlFrame, resolveDeliveryConfig, selectConsumerPeerId } from './index'
import { heartbeatFrameFrom, resolveEventDelivery, selectConsumerPeerId } from './index'
function createInputTextEvent(
overrides: Partial<WebSocketBaseEvent<'input:text', WebSocketEvents['input:text']>> = {},
@@ -27,9 +27,9 @@ function createInputTextEvent(
}
}
describe('resolveDeliveryConfig', () => {
describe('resolveEventDelivery', () => {
it('uses protocol event metadata defaults for input:text', () => {
const delivery = resolveDeliveryConfig(createInputTextEvent())
const delivery = resolveEventDelivery(createInputTextEvent())
expect(delivery).toEqual({
mode: 'consumer-group',
@@ -39,7 +39,7 @@ describe('resolveDeliveryConfig', () => {
})
it('allows route delivery to override protocol defaults', () => {
const delivery = resolveDeliveryConfig(createInputTextEvent({
const delivery = resolveEventDelivery(createInputTextEvent({
route: {
delivery: {
required: true,
@@ -59,7 +59,7 @@ describe('resolveDeliveryConfig', () => {
})
it('returns explicit route delivery for events without protocol defaults', () => {
const delivery = resolveDeliveryConfig({
const delivery = resolveEventDelivery({
type: 'spark:notify',
data: {
id: 'spark-1',
@@ -196,15 +196,15 @@ describe('selectConsumerPeerId', () => {
})
})
describe('detectHeartbeatControlFrame', () => {
describe('heartbeatFrameFrom', () => {
it('recognizes raw websocket control frame text without treating it as protocol JSON', () => {
expect(detectHeartbeatControlFrame('ping')).toBe('ping')
expect(detectHeartbeatControlFrame('pong')).toBe('pong')
expect(heartbeatFrameFrom('ping')).toBe('ping')
expect(heartbeatFrameFrom('pong')).toBe('pong')
})
it('ignores non-control payloads', () => {
expect(detectHeartbeatControlFrame('')).toBeUndefined()
expect(detectHeartbeatControlFrame('🩵')).toBeUndefined()
expect(detectHeartbeatControlFrame('{"type":"transport:connection:heartbeat"}')).toBeUndefined()
expect(heartbeatFrameFrom('')).toBeUndefined()
expect(heartbeatFrameFrom('🩵')).toBeUndefined()
expect(heartbeatFrameFrom('{"type":"transport:connection:heartbeat"}')).toBeUndefined()
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
import type { WebSocketEvent } from '@proj-airi/server-shared/types'
import { stringify } from 'superjson'
import { describe, expect, it } from 'vitest'
import {
AiriWebSocketEventFormatError,
heartbeatFrameFrom,
parseEvent,
} from '.'
describe('airi websocket protocol codec', () => {
it('parses superjson encoded events', () => {
const event: WebSocketEvent = {
type: 'module:authenticate',
data: { token: 'secret' },
metadata: {
source: {
kind: 'plugin',
id: 'test-plugin-1',
plugin: { id: 'test-plugin' },
},
event: { id: 'event-1' },
},
}
expect(parseEvent(stringify(event))).toEqual(event)
})
it('falls back to plain JSON events', () => {
const event: WebSocketEvent = {
type: 'module:authenticate',
data: { token: 'secret' },
metadata: {
source: {
kind: 'plugin',
id: 'test-plugin-1',
plugin: { id: 'test-plugin' },
},
event: { id: 'event-1' },
},
}
expect(parseEvent(JSON.stringify(event))).toEqual(event)
})
it('rejects payloads without event type', () => {
expect(() => parseEvent('null'))
.toThrow(AiriWebSocketEventFormatError)
expect(() => parseEvent(JSON.stringify({ data: {} })))
.toThrow(AiriWebSocketEventFormatError)
})
it('rejects payloads with non-string event type', () => {
expect(() => parseEvent(JSON.stringify({ type: 0, data: {} })))
.toThrow(AiriWebSocketEventFormatError)
})
it('rejects payloads without object event data', () => {
expect(() => parseEvent(JSON.stringify({ type: 'module:authenticate' })))
.toThrow(AiriWebSocketEventFormatError)
expect(() => parseEvent(JSON.stringify({ type: 'module:authenticate', data: null })))
.toThrow(AiriWebSocketEventFormatError)
expect(() => parseEvent(JSON.stringify({ type: 'module:authenticate', data: 'secret' })))
.toThrow(AiriWebSocketEventFormatError)
})
it('rejects payloads with array event data', () => {
expect(() => parseEvent(JSON.stringify({ type: 'module:authenticate', data: [] })))
.toThrow(AiriWebSocketEventFormatError)
})
it('classifies raw ping and pong control frames', () => {
expect(heartbeatFrameFrom('ping')).toBe('ping')
expect(heartbeatFrameFrom('pong')).toBe('pong')
expect(heartbeatFrameFrom('{"type":"ping"}')).toBeUndefined()
})
})
@@ -0,0 +1,340 @@
import type { DeliveryConfig, 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>>
},
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
}
}
}
@@ -0,0 +1,229 @@
import type { ServerWsStickyAssignment } from '.'
import { describe, expect, it } from 'vitest'
import {
createConsumerOrchestrator,
selectConsumerPeerId,
} from '.'
describe('server-ws consumer selection', () => {
it('selects highest priority then earliest registration', () => {
expect(selectConsumerPeerId({
eventType: 'event:test',
fromPeerId: 'sender',
delivery: { mode: 'consumer', selection: 'first' },
candidates: [
{ peerId: 'late', priority: 1, registeredAt: 2, authenticated: true },
{ peerId: 'early', priority: 1, registeredAt: 1, authenticated: true },
{ peerId: 'low', priority: 0, registeredAt: 0, authenticated: true },
],
})).toBe('early')
})
it('skips sender, unauthenticated, and unhealthy candidates', () => {
expect(selectConsumerPeerId({
eventType: 'event:test',
fromPeerId: 'sender',
delivery: { mode: 'consumer', selection: 'first' },
candidates: [
{ peerId: 'sender', priority: 3, registeredAt: 1, authenticated: true },
{ peerId: 'unauthenticated', priority: 2, registeredAt: 1, authenticated: false },
{ peerId: 'unhealthy', priority: 1, registeredAt: 1, authenticated: true, healthy: false },
{ peerId: 'target', priority: 0, registeredAt: 1, authenticated: true },
],
})).toBe('target')
})
it('preserves sticky assignment for the same sticky key', () => {
const stickyAssignments = new Map<string, ServerWsStickyAssignment>()
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 },
{ peerId: 'b', priority: 0, registeredAt: 2, authenticated: true },
]
expect(selectConsumerPeerId({
eventType: 'event:test',
fromPeerId: 'sender',
delivery,
candidates,
stickyAssignments,
})).toBe('a')
expect(selectConsumerPeerId({
eventType: 'event:test',
fromPeerId: 'sender',
delivery,
candidates: [...candidates].reverse(),
stickyAssignments,
})).toBe('a')
})
it('preserves round-robin cursor per event and group', () => {
const roundRobinCursor = new Map<string, number>()
const delivery = { mode: 'consumer-group' as const, group: 'workers', selection: 'round-robin' as const }
const candidates = [
{ peerId: 'a', priority: 0, registeredAt: 1, authenticated: true },
{ peerId: 'b', priority: 0, registeredAt: 2, authenticated: true },
]
expect(selectConsumerPeerId({ eventType: 'event:test', fromPeerId: 'sender', delivery, candidates, roundRobinCursor })).toBe('a')
expect(selectConsumerPeerId({ eventType: 'event:test', fromPeerId: 'sender', delivery, candidates, roundRobinCursor })).toBe('b')
expect(selectConsumerPeerId({ eventType: 'event:test', fromPeerId: 'sender', delivery, candidates, roundRobinCursor })).toBe('a')
})
})
describe('server-ws consumer registry', () => {
it('registers and unregisters consumers', () => {
const registry = createConsumerOrchestrator()
registry.register({ peerId: 'peer-1', event: 'event:test', mode: 'consumer-group', group: 'workers', priority: 2 })
expect(registry.listFor({ event: 'event:test', mode: 'consumer-group', group: 'workers' })).toEqual([
expect.objectContaining({ peerId: 'peer-1', event: 'event:test', group: 'workers', priority: 2 }),
])
registry.unregister({ peerId: 'peer-1', event: 'event:test', mode: 'consumer-group', group: 'workers' })
expect(registry.listFor({ event: 'event:test', mode: 'consumer-group', group: 'workers' })).toEqual([])
})
it('unregisters peer consumers when event and group names contain registry delimiters', () => {
const registry = createConsumerOrchestrator()
// ROOT CAUSE:
//
// If event or group names contain the previous string key delimiter, peer cleanup
// can fail because unregisterPeer reconstructs registry coordinates from a split key.
//
// Before:
// `${event}::${group}` was split back into event/group and missed the original entry.
//
// After:
// Peer cleanup stores structured event/group refs and never decodes registry keys.
registry.register({ peerId: 'peer-1', event: 'event::test', mode: 'consumer-group', group: 'group::workers' })
registry.unregisterPeer('peer-1')
expect(registry.listFor({ event: 'event::test', mode: 'consumer-group', group: 'group::workers' })).toEqual([])
})
it('keeps sticky assignments isolated for delimiter-like event and group names', () => {
const stickyAssignments = new Map<string, ServerWsStickyAssignment>()
const candidates = [
{ peerId: 'event::group-target', priority: 0, registeredAt: 1, authenticated: true },
{ peerId: 'other-target', priority: 0, registeredAt: 2, authenticated: true },
]
expect(selectConsumerPeerId({
eventType: 'event::group',
fromPeerId: 'sender',
delivery: { mode: 'consumer-group', group: 'target', selection: 'sticky', stickyKey: 'job' },
candidates,
stickyAssignments,
})).toBe('event::group-target')
expect(selectConsumerPeerId({
eventType: 'event',
fromPeerId: 'sender',
delivery: { mode: 'consumer-group', group: 'group::target', selection: 'sticky', stickyKey: 'job' },
candidates: [
{ peerId: 'other-target', priority: 1, registeredAt: 1, authenticated: true },
{ peerId: 'event::group-target', priority: 0, registeredAt: 2, authenticated: true },
],
stickyAssignments,
})).toBe('other-target')
})
it('resets round-robin cursor when group membership changes', () => {
const registry = createConsumerOrchestrator()
registry.register({ peerId: 'a', event: 'event:test', mode: 'consumer-group', group: 'workers' })
registry.register({ peerId: 'b', event: 'event:test', mode: 'consumer-group', group: 'workers' })
expect(registry.select({
eventType: 'event:test',
fromPeerId: 'sender',
delivery: { mode: 'consumer-group', group: 'workers', selection: 'round-robin' },
candidates: registry.listFor({ event: 'event:test', mode: 'consumer-group', group: 'workers' }).map(entry => ({
peerId: entry.peerId,
priority: entry.priority,
registeredAt: entry.registeredAt,
authenticated: true,
})),
})).toBe('a')
registry.unregister({ peerId: 'a', event: 'event:test', mode: 'consumer-group', group: 'workers' })
expect(registry.select({
eventType: 'event:test',
fromPeerId: 'sender',
delivery: { mode: 'consumer-group', group: 'workers', selection: 'round-robin' },
candidates: registry.listFor({ event: 'event:test', mode: 'consumer-group', group: 'workers' }).map(entry => ({
peerId: entry.peerId,
priority: entry.priority,
registeredAt: entry.registeredAt,
authenticated: true,
})),
})).toBe('b')
})
it('keeps round-robin cursor when unregister does not change group membership', () => {
const registry = createConsumerOrchestrator()
registry.register({ peerId: 'a', event: 'event:test', mode: 'consumer-group', group: 'workers' })
registry.register({ peerId: 'b', event: 'event:test', mode: 'consumer-group', group: 'workers' })
expect(registry.select({
eventType: 'event:test',
fromPeerId: 'sender',
delivery: { mode: 'consumer-group', group: 'workers', selection: 'round-robin' },
candidates: registry.listFor({ event: 'event:test', mode: 'consumer-group', group: 'workers' }).map(entry => ({
peerId: entry.peerId,
priority: entry.priority,
registeredAt: entry.registeredAt,
authenticated: true,
})),
})).toBe('a')
registry.unregister({ peerId: 'missing', event: 'event:test', mode: 'consumer-group', group: 'workers' })
expect(registry.select({
eventType: 'event:test',
fromPeerId: 'sender',
delivery: { mode: 'consumer-group', group: 'workers', selection: 'round-robin' },
candidates: registry.listFor({ event: 'event:test', mode: 'consumer-group', group: 'workers' }).map(entry => ({
peerId: entry.peerId,
priority: entry.priority,
registeredAt: entry.registeredAt,
authenticated: true,
})),
})).toBe('b')
})
it('resets round-robin cursor when group membership grows', () => {
const registry = createConsumerOrchestrator()
registry.register({ peerId: 'a', event: 'event:test', mode: 'consumer-group', group: 'workers' })
registry.register({ peerId: 'b', event: 'event:test', mode: 'consumer-group', group: 'workers' })
expect(registry.select({
eventType: 'event:test',
fromPeerId: 'sender',
delivery: { mode: 'consumer-group', group: 'workers', selection: 'round-robin' },
candidates: registry.listFor({ event: 'event:test', mode: 'consumer-group', group: 'workers' }).map(entry => ({
peerId: entry.peerId,
priority: entry.priority,
registeredAt: entry.registeredAt,
authenticated: true,
})),
})).toBe('a')
registry.register({ peerId: 'c', event: 'event:test', mode: 'consumer-group', group: 'workers' })
expect(registry.select({
eventType: 'event:test',
fromPeerId: 'sender',
delivery: { mode: 'consumer-group', group: 'workers', selection: 'round-robin' },
candidates: registry.listFor({ event: 'event:test', mode: 'consumer-group', group: 'workers' }).map(entry => ({
peerId: entry.peerId,
priority: entry.priority,
registeredAt: entry.registeredAt,
authenticated: true,
})),
})).toBe('a')
})
})
@@ -0,0 +1,543 @@
/**
* 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()
},
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ vi.mock('crossws/server', () => ({
plugin: vi.fn(() => ({})),
}))
vi.mock('..', () => ({
vi.mock('./index', () => ({
normalizeLoggerConfig: () => ({
appLogFormat: 'pretty',
appLogLevel: 'log',