moeru-ai/airi · error · InvalidEventError

Invalid WebSocket event format.

Error message

Invalid WebSocket event format.

What it means

This is the decode path of the AIRI WebSocket server (server-runtime/src/server-ws/airi/codec.ts). Incoming text is parsed with SuperJSON; if that yields an object with a `type` field it is used directly, otherwise the text is re-parsed as plain JSON. The result must then pass safeParse against eventEnvelopeSchema; InvalidEventError ('Invalid WebSocket event format.') is thrown when parsing succeeded but the object does not satisfy the event envelope (typically a missing/ill-typed `type` or malformed payload).

Solutions

  1. Send events through stringifyEvent / the shared codec so the SuperJSON envelope is correct
  2. Inspect result.issues (attached as `cause`) to see exactly which envelope field failed
  3. Align client and server on the same @proj-airi/server-shared / server-sdk versions and rebuild both
  4. If testing manually, send at minimum `{ type: '<known event type>', payload: ... }` matching the schema

Example fix

// before
ws.send(JSON.stringify({ hello: 'world' }))
// after
import { stringifyEvent } from '@proj-airi/server-sdk'
ws.send(stringifyEvent({ type: 'player-heartbeat-set', payload: { beatsPerMinute: 60 }, source: 'test' }))
Defensive patterns

Strategy: try-catch

Validate before calling

import { safeParse } from 'valibot'
import { eventEnvelopeSchema } from '@proj-airi/server-shared'

function isWireEventCandidate(text: string): boolean {
  try {
    const parsed: unknown = JSON.parse(text)
    return safeParse(eventEnvelopeSchema, parsed).success
  }
  catch {
    return false
  }
}

Type guard

function looksLikeWebSocketEvent(value: unknown): value is { type: string } {
  return typeof value === 'object' && value !== null && 'type' in value
    && typeof (value as { type: unknown }).type === 'string'
}

Try / catch

try {
  const event = parseEvent(text)
}
catch (error) {
  if (error instanceof InvalidEventError) {
    // error.cause holds valibot issues, error.source the rejected payload
    logger.warn('dropping malformed event', { issues: error.cause, source: error.source })
    return // skip the frame; do not crash the socket handler
  }
  throw error
}

Prevention

When it happens

Trigger: A client sending raw JSON that lacks the required envelope fields (e.g. `{ data: ... }` without `type`), a payload whose `type` is not a string, a SuperJSON-decoded object with unexpected extra shape, or a client/server version skew where eventEnvelopeSchema changed.

Common situations: Hand-rolled clients or test scripts that JSON.stringify arbitrary objects instead of using stringifyEvent, protocol drift after upgrading server-shared without rebuilding the client, or middleware/proxies rewriting frames.

Related errors


AI-assisted analysis of moeru-ai/airi@677329427f (2026-08-18). Data as JSON: /api/errors/606be38246248543. Report an issue: GitHub.

Appendix: source

Thrown at packages/server-runtime/src/server-ws/airi/codec.ts:72

  // 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)
}

View on GitHub (pinned to 677329427f)