Skip to content

Commit

Permalink
fix(vMix): handling XML messages with multi-byte characters
Browse files Browse the repository at this point in the history
the data length that vMix provides is in bytes, not chars;
the line remainder has to be stored as a buffer in case the arriving data is fragmented mid-character
  • Loading branch information
ianshade committed Mar 21, 2024
1 parent d2565c1 commit e811ef0
Show file tree
Hide file tree
Showing 2 changed files with 78 additions and 10 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { VMixResponseStreamReader } from '../vMixResponseStreamReader'

describe('VMixResponseStreamReader', () => {
test('the helper uses byte length of strings', () => {
// this is a meta-test for the helper used in unit tests below, to assert that the data length is in bytes (utf-8), not characters
expect(makeXmlMessage('<vmix>abc</vmix>')).toBe('XML 18\r\n<vmix>abc</vmix>\r\n')
expect(makeXmlMessage('<vmix>abc¾</vmix>')).toBe('XML 20\r\n<vmix>abc¾</vmix>\r\n')
expect(makeXmlMessage('<vmix>abc🚀🚀</vmix>')).toBe('XML 26\r\n<vmix>abc🚀🚀</vmix>\r\n')
})

it('processes a complete message', async () => {
const reader = new VMixResponseStreamReader()

Expand Down Expand Up @@ -231,6 +238,57 @@ describe('VMixResponseStreamReader', () => {
)
})

it('processes a message with data containing multi-byte characters', async () => {
const reader = new VMixResponseStreamReader()

const onMessage = jest.fn()
reader.on('response', onMessage)

const xmlString =
'<vmix><version>27.0.0.49</version><edition>HD</edition><preset>C:\\🚀\\preset3¾.vmix</preset><inputs><inputs></vmix>'
reader.processIncomingData(Buffer.from(makeXmlMessage(xmlString)))

expect(onMessage).toHaveBeenCalledTimes(1)
expect(onMessage).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
command: 'XML',
response: 'OK',
body: xmlString,
})
)
})

it('processes a fragmented message with data containing multi-byte characters, split mid-character', async () => {
const reader = new VMixResponseStreamReader()

const onMessage = jest.fn()
reader.on('response', onMessage)

const xmlString = '<vmix>🚀🚀</vmix>'
const xmlMessage = `XML 23\r\n${xmlString}\r\n`
const fullBuffer = Buffer.from(xmlMessage)

const firstPart = fullBuffer.slice(0, 16)
const secondPart = fullBuffer.slice(16)

// sanity check that we did actually split mid-character
expect(firstPart.toString('utf-8') + secondPart.toString('utf-8')).not.toBe(xmlMessage)

reader.processIncomingData(firstPart)
reader.processIncomingData(secondPart)

expect(onMessage).toHaveBeenCalledTimes(1)
expect(onMessage).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
command: 'XML',
response: 'OK',
body: xmlString,
})
)
})

it('processes a multiline message with data', async () => {
// note: I don't know if those can actually be encountered

Expand Down Expand Up @@ -466,7 +524,8 @@ describe('VMixResponseStreamReader', () => {
})

function makeXmlMessage(xmlString: string): string {
return `XML ${xmlString.length + 2}\r\n${xmlString}\r\n`
// the length of the data is in bytes, not characters!
return `XML ${Buffer.byteLength(xmlString, 'utf-8') + 2}\r\n${xmlString}\r\n`
}

function splitAtIndices(text: string, indices: number[]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,27 @@ const RESPONSE_REGEX = /^(?<command>\w+)\s+(?<response>OK|ER|\d+)(\s+(?<response
*/
export class VMixResponseStreamReader extends EventEmitter<ResponseStreamReaderEvents> {
private _unprocessedLines: string[] = []
private _lineRemainder = ''
private _lineRemainder = Buffer.alloc(0)

reset() {
this._unprocessedLines = []
this._lineRemainder = ''
this._lineRemainder = Buffer.alloc(0)
}

processIncomingData(data: Buffer) {
const string = this._lineRemainder + data.toString('utf-8')
this._lineRemainder = ''
const lines = string.split('\r\n')
const lastChunk = lines.pop()
const remainingData = Buffer.concat([this._lineRemainder, data])
const stringData = remainingData.toString('utf-8')
const incomingLines = stringData.split('\r\n')
const lastChunk = incomingLines.pop()

if (lastChunk != null && lastChunk !== '') {
// Incomplete line found at the end - keep it
this._lineRemainder = lastChunk
const linesByteLength = this.calculatePreSplitByteLength(incomingLines)
this._lineRemainder = remainingData.slice(linesByteLength)
} else {
this._lineRemainder = Buffer.alloc(0)
}
this._unprocessedLines.push(...lines)
this._unprocessedLines.push(...incomingLines)

let lineToProcess: string | undefined

Expand Down Expand Up @@ -81,6 +84,12 @@ export class VMixResponseStreamReader extends EventEmitter<ResponseStreamReaderE
}
}

private calculatePreSplitByteLength(arrayOfStrings: string[]) {
const totalByteLength = arrayOfStrings.reduce((acc, str) => acc + Buffer.byteLength(str, 'utf-8'), 0)
const additionalBytes = arrayOfStrings.length * 2
return totalByteLength + additionalBytes
}

private processPayloadData(responseLen: number): string | null {
const processedLines: string[] = []

Expand All @@ -92,7 +101,7 @@ export class VMixResponseStreamReader extends EventEmitter<ResponseStreamReaderE
}

processedLines.push(line)
responseLen -= line.length + 2
responseLen -= Buffer.byteLength(line, 'utf-8') + 2
}
this._unprocessedLines.splice(0, processedLines.length)
return processedLines.join('\r\n')
Expand Down

0 comments on commit e811ef0

Please sign in to comment.