e-bon
e-bon.ro
Realtime

Schemas

The wire-protocol frames carried by the realtime channel — seven server→device messages, three device→server messages, and the clientMessageId boundary.

Schemas

The realtime channel carries two different shapes of message:

  1. Partner-facing event envelopes — { type, data, timestamp } — documented on Events. This is what most integrators care about.
  2. Wire-protocol frames — typed, validated, no data wrapper — used between the e-bon gateway and the E-BON Android app — see Architecture overview.

You only need this page if you are building a custom client that talks the wire protocol directly. If you are using @e-bon/realtime, the schemas below are validated for you and dispatched through the same on / off / once API.

All schemas are zod schemas exported by the @e-bon/realtime package. Schemas are the source of truth and the TypeScript types are inferred via z.infer.

Server → device messages

The seven message types the server can send to a device:

Type literalPayload
device.updated{ deviceId, patch: Partial<Device>, version: number }
location.updated{ locationId, patch: Partial<Location>, version: number }
location.deleted{ locationId }
receipt.created{ receiptId, receipt: Partial<Receipt> }
receipt.voided{ receiptId, voidedAt: ISO8601, voidedBy }
command.dispatch{ commandId, command: Partial<FiscalCommand> }
sync.resume{ since: ISO8601 }

The full discriminated union is exported as serverToClientMessageSchema and ServerToClientMessage:

import {
  serverToClientMessageSchema,
  type ServerToClientMessage,
  SERVER_TO_CLIENT_TYPES,
  isServerToClientType,
} from '@e-bon/realtime';

const result = serverToClientMessageSchema.safeParse(raw);
if (!result.success) {
  // Drop the frame — never trust a partial parse.
  return;
}
const message: ServerToClientMessage = result.data;

SERVER_TO_CLIENT_TYPES is the readonly tuple of the seven literals; isServerToClientType(value) is the runtime type guard.

::callout

icon: i-lucide-info

The patch, receipt and command bodies are typed as Record<string, unknown> (jsonObjectSchema) today — @e-bon/types does not yet ship zod mirrors for the core domain models. A tighter typing pass is tracked under E-1c+. ::

Device → server messages

The three message types a device can send back to the server. Every device→server frame must carry a clientMessageId so the server can de-duplicate on at-least-once delivery.

Type literalPayload
command.result{ clientMessageId, commandId, result: Record<string, unknown> }
command.error{ clientMessageId, commandId, error: Record<string, unknown> }
status{ clientMessageId, deviceStatus: Record<string, unknown> }

The discriminated union is exported as clientToServerMessageSchema and ClientToServerMessage:

import {
  clientToServerMessageSchema,
  type ClientToServerMessage,
  CLIENT_TO_SERVER_TYPES,
  isClientToServerType,
} from '@e-bon/realtime';

CLIENT_TO_SERVER_TYPES is the readonly tuple of the three literals; isClientToServerType(value) is the runtime type guard.

The clientMessageId boundary

clientMessageId is a non-empty string chosen by the device and is the only field the server uses to de-duplicate frames. The server treats two frames with the same clientMessageId (within the dedup window) as a single frame, so:

  • Generate a fresh clientMessageId per logical message (UUID v4 is fine).
  • Reuse the same clientMessageId when retrying the same logical message after a reconnect — never mint a new one for a retry.

Sending frames from a device

@e-bon/realtime ships a public device→server API. Call it from your client once connected has fired; the alpha behaviour is deliberately minimal — there is no in-memory queue and no retry, so frames sent while the socket is not open are dropped with a console.warn.

// Low-level: build the envelope yourself.
realtime.send({
  type: 'command.result',
  clientMessageId: 'b4a4f2c7-…', // UUID v4, see the boundary rule above
  commandId: 'cmd_01H…',
  result: { receiptNumber: 4711 },
});

// Convenience helpers — same wire shape, fewer keystrokes.
realtime.sendCommandResult({
  commandId: 'cmd_01H…',
  clientMessageId: 'b4a4f2c7-…',
  result: { receiptNumber: 4711 },
});

realtime.sendCommandError({
  commandId: 'cmd_01H…',
  clientMessageId: 'b4a4f2c7-…',
  error: { code: 'PRINTER_OFFLINE', message: 'no answer from device' },
});

Behaviour the helpers guarantee:

  • Every frame is validated against clientToServerMessageSchema before it touches the wire. Invalid payloads are dropped and logged — a malformed call site can never put garbage on the protocol.
  • If readyState !== OPEN (socket closing, suspended, or mid-reconnect), the frame is dropped with a console.warn rather than buffered. Callers that need at-least-once semantics must persist the intent themselves and re-send after the next connected event, reusing the same clientMessageId so the server de-duplicates the retry.
  • No automatic batching, no compression, no implicit clientMessageId generation — every field on the wire is one you handed in.

This section also documents the wire shape so middleware authors and custom-client builders (Go, Java, Swift, …) can speak the same protocol without depending on @e-bon/realtime.

Shared helpers

  • iso8601Schema — z.iso.datetime({ offset: true }), used for every timestamp and since field.
  • jsonObjectSchema — z.record(z.string(), z.unknown()), the placeholder for Device / Location / Receipt / FiscalCommand bodies until the core types ship zod mirrors.

Both are exported from @e-bon/realtime.

Where to next