stablyai/orca · error

expected plaintext relay hello

Error message

expected plaintext relay hello

What it means

Thrown by `acceptHello` in `MobileRelayE2eeLink` when the very first WebSocket message from the relay (the relay phone hello) is not a string. The hello is plaintext JSON because no crypto keys exist yet; a binary payload means the relay framed the hello as binary, which the link cannot interpret at this stage.

Source

Thrown at mobile/src/transport/mobile-relay-e2ee-link.ts:108

        .then(async () => {
          if (this.closed) {
            return
          }
          if (!this.outerReady) {
            this.acceptHello(event.data)
          } else {
            await this.channel.handleMessage(event.data)
          }
        })
        .catch((error: unknown) => this.fail(asError(error)))
    }
    this.socket.onerror = () => this.fail(new Error('relay transport error'))
    this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006))
  }

  private acceptHello(raw: unknown): void {
    if (typeof raw !== 'string') {
      throw new Error('expected plaintext relay hello')
    }
    let value: unknown
    try {
      value = JSON.parse(raw)
    } catch {
      throw new Error('invalid relay hello JSON')
    }
    const parsed = RelayPhoneHelloSchema.safeParse(value)
    if (!parsed.success) {
      throw new Error('invalid relay hello')
    }
    if (!parsed.data.ok) {
      throw new RelayOuterError(parsed.data.code)
    }
    if (parsed.data.credentialKind !== this.options.expectedCredentialKind) {
      throw new Error('relay credential resolved as an unexpected credential kind')
    }
    this.outerReady = true

View on GitHub (pinned to 1136503c6a)

Solutions

  1. Ensure the relay sends the hello as a text frame per the protocol.
  2. Coerce the first inbound frame to a string before `acceptHello` if the transport may deliver binary.
  3. In tests, pass a JSON string for the hello.

Example fix

// before
this.socket.onmessage = (event) => { if (!outerReady) this.acceptHello(event.data) } // throws on ArrayBuffer

// after
this.socket.onmessage = (event) => {
  const raw = event.data instanceof ArrayBuffer ? new TextDecoder().decode(event.data) : event.data
  if (!this.outerReady) this.acceptHello(raw)
  else this.channel.handleMessage(event.data)
}
Defensive patterns

Strategy: validation

Validate before calling

function isTextHello(raw: unknown): raw is string { return typeof raw === 'string' }

Type guard

function isRelayText(raw: unknown): raw is string { return typeof raw === 'string' }

Try / catch

try { this.acceptHello(firstFrame) } catch (e) { if (e.message === 'expected plaintext relay hello') { /* coerce or reconnect with text framing */ } else throw e }

Prevention

When it happens

Trigger: Relay WebSocket delivered the hello as an `ArrayBuffer`/`Blob`; relay implementation that always sends binary frames; `socket.binaryType` configured such that text frames arrive as non-string; a test feeding a buffer instead of JSON.

Common situations: Relay version that switched to binary framing; React Native WebSocket defaulting to arraybuffer; proxy that re-encodes text frames as binary.

Related errors


AI-assisted analysis of stablyai/orca@1136503c6a (2026-08-12). Data as JSON: /api/errors/512a04ec69f11ddc. Report an issue: GitHub.