diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..619bd2e --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,23 @@ +name: Run tests +on: + pull_request: + branches: + - main +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 + + - name: Set up Node.js + uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + cache-dependency-path: './package-lock.json' + + - name: Install Node.js dependencies + run: npm ci + + - run: npm run test \ No newline at end of file diff --git a/examples/osc-message.cjs b/examples/osc-message.cjs deleted file mode 100644 index fe73a3a..0000000 --- a/examples/osc-message.cjs +++ /dev/null @@ -1,5 +0,0 @@ -const osc = require('../dist/index.js') - -const buffer = osc.messageToBuffer({address:'/hello',args:[{type: 's', value: 'arg1'}]}) - -console.log(buffer) \ No newline at end of file diff --git a/package.json b/package.json index 3d2bc42..33700c4 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "scripts": { "prebuild": "rimraf dist", "build": "tsc", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "pretest": "npm run build", + "test": "node --test --experimental-test-coverage" }, "files": [ "dist" diff --git a/src/models.ts b/src/models.ts index d58c04f..eb8909b 100644 --- a/src/models.ts +++ b/src/models.ts @@ -12,4 +12,5 @@ export type OSCMessage = { export type OSCTypeConverter = { toBuffer: (value: string | number | Buffer | boolean) => Buffer | undefined; fromString: (string: string) => string | number | Buffer | boolean; + fromBuffer: (buffer: Buffer) => [string | number | Buffer | boolean | undefined, Buffer]; }; diff --git a/src/osc.ts b/src/osc.ts index 60f741a..081c98e 100644 --- a/src/osc.ts +++ b/src/osc.ts @@ -1,6 +1,6 @@ import { OSCType, OSCArg, OSCMessage, OSCTypeConverter } from './models'; -const oscTypeConverterMap: { [key in OSCType]: OSCTypeConverter } = { +const oscTypeConverterMap: { [key: string]: OSCTypeConverter } = { s: { toBuffer: (string) => { if (typeof string === 'string') { @@ -11,9 +11,27 @@ const oscTypeConverterMap: { [key in OSCType]: OSCTypeConverter } = { } return Buffer.from(oscString, 'ascii'); } - throw new Error('osc type s toBuffer called with non string value'); + throw new TypeError('osc type s toBuffer called with non string value'); }, fromString: (string: string) => string, + fromBuffer: (bytes: Buffer) => { + let stringEnd = 0; + let stringPaddingEnd = 0; + for (let index = 0; index < bytes.length; index++) { + if (bytes[index] === 0) { + stringEnd = index; + stringPaddingEnd = index + 1; + const stringPadding = 4 - ((stringEnd + 1) % 4); + + if (stringPadding < 4) { + stringPaddingEnd += stringPadding; + } + break; + } + } + + return [bytes.toString('ascii', 0, stringEnd), bytes.subarray(stringPaddingEnd)]; + }, }, f: { toBuffer: (number) => { @@ -22,9 +40,17 @@ const oscTypeConverterMap: { [key in OSCType]: OSCTypeConverter } = { buffer.writeFloatBE(number); return buffer; } - throw new Error('osc type f toBuffer called with non number value'); + throw new TypeError('osc type f toBuffer called with non number value'); }, fromString: (string: string) => Number.parseFloat(string), + fromBuffer: (buffer) => { + if (buffer.length < 4) { + throw new Error('not enough bytes to read a osc float'); + } + + const value = buffer.readFloatBE(); + return [value, buffer.subarray(4)]; + }, }, i: { toBuffer: (number) => { @@ -33,9 +59,17 @@ const oscTypeConverterMap: { [key in OSCType]: OSCTypeConverter } = { buffer.writeInt32BE(number); return buffer; } - throw new Error('osc type i toBuffer called with non number value'); + throw new TypeError('osc type i toBuffer called with non number value'); }, fromString: (string: string) => Number.parseInt(string, 10), + fromBuffer: (buffer) => { + if (buffer.length < 4) { + throw new Error('not enough bytes to read a osc integer'); + } + + const value = buffer.readInt32BE(); + return [value, buffer.subarray(4)]; + }, }, b: { toBuffer: (data) => { @@ -43,26 +77,46 @@ const oscTypeConverterMap: { [key in OSCType]: OSCTypeConverter } = { const sizeBuffer = oscTypeConverterMap.i.toBuffer(data.length); if (sizeBuffer) { const padSize = 4 - (data.length % 4); - const padBuffer = Buffer.from(Array(padSize).fill(0)); + const padBuffer = padSize < 4 ? Buffer.from(Array(padSize).fill(0)) : Buffer.from([]); return Buffer.concat([sizeBuffer, data, padBuffer]); } } - throw new Error('osc type b toBuffer called with non Buffer value'); + throw new TypeError('osc type b toBuffer called with non Buffer value'); }, fromString: (string: string) => Buffer.from(string, 'hex'), + fromBuffer: (buffer) => { + const [blobLength, blobBytes] = oscTypeConverterMap.i.fromBuffer(buffer); + if (typeof blobLength === 'number') { + if (blobBytes.length < blobLength) { + throw new Error('not enough bytes left for blob length specified'); + } + const value = blobBytes.subarray(0, blobLength); + const blobPadding = 4 - (blobLength % 4); + const blobEnd = blobPadding < 4 ? blobLength + blobPadding : blobLength; + return [value, blobBytes.subarray(blobEnd)]; + } else { + throw new Error('unexpected value for blob length'); + } + }, }, T: { toBuffer: () => { - return Buffer.alloc(0) + return Buffer.alloc(0); + }, + fromString: (string: string) => true, + fromBuffer: (buffer) => { + return [true, buffer]; }, - fromString: (string: string) => true }, F: { toBuffer: () => { - return Buffer.alloc(0) + return Buffer.alloc(0); }, - fromString: (string: string) => false - } + fromString: (string: string) => false, + fromBuffer: (buffer) => { + return [false, buffer]; + }, + }, }; function argsToBuffer(args: OSCArg[]) { @@ -72,11 +126,7 @@ function argsToBuffer(args: OSCArg[]) { const arg = args[index]; const typeConverter = oscTypeConverterMap[arg.type]; if (typeConverter === undefined) { - throw new Error('osc type error: unknown type '.concat(arg.type)); - } - - if (typeConverter.fromString === undefined) { - throw new Error('osc type error: no string converter for type '.concat(arg.type)); + throw new TypeError('unknown type '.concat(arg.type)); } const buffer = typeConverter.toBuffer(arg.value); @@ -88,7 +138,6 @@ function argsToBuffer(args: OSCArg[]) { } export function messageToBuffer(message: OSCMessage) { - const addressBuffer = oscTypeConverterMap.s.toBuffer(message.address); if (addressBuffer === undefined) { throw new Error('problem encoding address'); @@ -101,4 +150,45 @@ export function messageToBuffer(message: OSCMessage) { } const argsBuffer = argsToBuffer(message.args); return Buffer.concat([addressBuffer, typesBuffer, argsBuffer]); -} \ No newline at end of file +} + +export function messageFromBuffer(bytes: Buffer): OSCMessage | undefined { + if (bytes[0] !== 47) { + throw new Error('osc message must start with a /'); + } + + const oscArgs: OSCArg[] = []; + + const [address, bytesAfterAddress] = oscTypeConverterMap.s.fromBuffer(bytes); + if (typeof address === 'string') { + let [typeString, bytesAfterType] = oscTypeConverterMap.s.fromBuffer(bytesAfterAddress); + if (typeof typeString === 'string') { + if (!typeString.startsWith(',')) { + throw new Error('osc type string must start with a ,'); + } + let argsBuffer = bytesAfterType; + for (let index = 1; index < typeString.length; index++) { + const argType = typeString.charAt(index) as OSCType; + + const oscTypeConverter = oscTypeConverterMap[argType]; + if (oscTypeConverter === undefined) { + throw new Error('unknown OSC type'); + } + const [value, remainingBytes] = oscTypeConverter.fromBuffer(argsBuffer); + if (value !== undefined) { + const arg: OSCArg = { + type: argType, + value: value, + }; + oscArgs.push(arg); + } + argsBuffer = remainingBytes; + } + + return { + address, + args: oscArgs, + }; + } + } +} diff --git a/tests/message-decode.test.js b/tests/message-decode.test.js new file mode 100644 index 0000000..f1910fb --- /dev/null +++ b/tests/message-decode.test.js @@ -0,0 +1,104 @@ +const { deepEqual, throws } = require('assert'); +const { describe, it } = require('node:test'); +const osc = require('../dist/index'); + +const tests = [ + { + description: 'simple address no args', + bytes: Buffer.from('2f68656c6c6f00002c000000', 'hex'), + expected: { address: '/hello', args: [] }, + }, + { + description: 'simple address string arg', + bytes: Buffer.from('2f68656c6c6f00002c7300006172673100000000', 'hex'), + expected: { address: '/hello', args: [{ type: 's', value: 'arg1' }] }, + }, + { + description: 'simple address integer arg', + bytes: Buffer.from('2f68656c6c6f00002c69000000000023', 'hex'), + expected: { address: '/hello', args: [{ type: 'i', value: 35 }] }, + }, + { + description: 'simple address float arg', + bytes: Buffer.from('2f68656c6c6f00002c660000420a0000', 'hex'), + expected: { address: '/hello', args: [{ type: 'f', value: 34.5 }] }, + }, + { + description: 'simple address blob arg', + bytes: Buffer.from('2f68656c6c6f00002c62000000000004626c6f62', 'hex'), + expected: { address: '/hello', args: [{ type: 'b', value: Buffer.from('blob') }] }, + }, + { + description: 'simple address True arg', + bytes: Buffer.from('2f68656c6c6f00002c540000', 'hex'), + expected: { address: '/hello', args: [{ type: 'T', value: true }] }, + }, + { + description: 'simple address False arg', + bytes: Buffer.from('2f68656c6c6f00002c460000', 'hex'), + expected: { address: '/hello', args: [{ type: 'F', value: false }] }, + }, +]; + +describe('OSC Message Decoding', () => { + tests.forEach((messageTest) => { + it(messageTest.description, () => { + const decoded = osc.messageFromBuffer(messageTest.bytes); + deepEqual(decoded, messageTest.expected); + }); + }); + + it('bad address', () => { + throws( + () => { + osc.messageFromBuffer(Buffer.from('68656c6c6f00002c660000420a0000', 'hex')); + }, + { name: /^Error$/, message:/must start with/ } + ); + }); + + it('bad type string', () => { + throws( + () => { + osc.messageFromBuffer(Buffer.from('2f68656c6c6f000066000000420a00', 'hex')); + }, + { name: /^Error$/, message:/type string must start with/ } + ); + }); + + it('unknown type', () => { + throws( + () => { + osc.messageFromBuffer(Buffer.from('2f68656c6c6f00002c7a0000420a0000', 'hex')); + }, + { name: /^Error$/, message:/unknown/ } + ); + }); + + it('float arg missing bytes', () => { + throws( + () => { + osc.messageFromBuffer(Buffer.from('2f68656c6c6f00002c660000420a00', 'hex')); + }, + { name: /^Error$/, message:/not enough bytes/ } + ); + }); + + it('int arg missing bytes', () => { + throws( + () => { + osc.messageFromBuffer(Buffer.from('2f68656c6c6f00002c690000000000', 'hex')); + }, + { name: /^Error$/, message:/not enough bytes/ } + ); + }); + + it('blob bytes too small', () => { + throws( + () => { + osc.messageFromBuffer(Buffer.from('2f68656c6c6f00002c62000000000004626c6f', 'hex')); + }, + { name: /^Error$/, message:/not enough bytes/ } + ); + }); +}); diff --git a/tests/message-encode.test.js b/tests/message-encode.test.js new file mode 100644 index 0000000..70efb19 --- /dev/null +++ b/tests/message-encode.test.js @@ -0,0 +1,94 @@ +const { deepEqual, throws } = require('assert'); +const { describe, it } = require('node:test'); +const osc = require('../dist/index'); + +const tests = [ + { + description: 'simple address no args', + message: { address: '/hello', args: [] }, + expected: Buffer.from('2f68656c6c6f00002c000000', 'hex'), + }, + { + description: 'simple address string arg', + message: { address: '/hello', args: [{ type: 's', value: 'arg1' }] }, + expected: Buffer.from('2f68656c6c6f00002c7300006172673100000000', 'hex'), + }, + { + description: 'simple address integer arg', + message: { address: '/hello', args: [{ type: 'i', value: 35 }] }, + expected: Buffer.from('2f68656c6c6f00002c69000000000023', 'hex'), + }, + { + description: 'simple address float arg', + message: { address: '/hello', args: [{ type: 'f', value: 34.5 }] }, + expected: Buffer.from('2f68656c6c6f00002c660000420a0000', 'hex'), + }, + { + description: 'simple address blob arg', + message: { address: '/hello', args: [{ type: 'b', value: Buffer.from('blob') }] }, + expected: Buffer.from('2f68656c6c6f00002c62000000000004626c6f62', 'hex'), + }, + { + description: 'simple address True arg', + message: { address: '/hello', args: [{ type: 'T', value: true }] }, + expected: Buffer.from('2f68656c6c6f00002c540000', 'hex'), + }, + { + description: 'simple address False arg', + message: { address: '/hello', args: [{ type: 'F', value: false }] }, + expected: Buffer.from('2f68656c6c6f00002c460000', 'hex'), + }, +]; + +describe('OSC Message Encoding', () => { + tests.forEach((messageTest) => { + it(messageTest.description, () => { + const encoded = osc.messageToBuffer(messageTest.message); + deepEqual(encoded, messageTest.expected); + }); + }); + it('bad string arg', () => { + throws( + () => { + osc.messageToBuffer({ address: '/address', args: [{ type: 's', value: 123 }] }); + }, + { name: /^TypeError$/ } + ); + }); + + it('bad integer arg', () => { + throws( + () => { + osc.messageToBuffer({ address: '/address', args: [{ type: 'i', value: 'hi' }] }); + }, + { name: /^TypeError$/ } + ); + }); + + it('bad float arg', () => { + throws( + () => { + osc.messageToBuffer({ address: '/address', args: [{ type: 'f', value: 'hi' }] }); + }, + { name: /^TypeError$/ } + ); + }); + + it('bad blob arg', () => { + throws( + () => { + osc.messageToBuffer({ address: '/address', args: [{ type: 'b', value: 123 }] }); + }, + { name: /^TypeError$/ } + ); + }); + + it('unknown arg type', () => { + throws( + () => { + osc.messageToBuffer({ address: '/address', args: [{ type: 'z', value: 123 }] }); + }, + { name: /^TypeError$/ } + ); + }); +});