protodef
Version:
A simple yet powerful way to define binary protocols
441 lines (394 loc) • 14.3 kB
JavaScript
const numeric = require('./datatypes/numeric')
const utils = require('./datatypes/utils')
const conditionalDatatypes = require('./datatypes/compiler-conditional')
const structuresDatatypes = require('./datatypes/compiler-structures')
const utilsDatatypes = require('./datatypes/compiler-utils')
const { tryCatch } = require('./utils')
class ProtoDefCompiler {
constructor () {
this.readCompiler = new ReadCompiler()
this.writeCompiler = new WriteCompiler()
this.sizeOfCompiler = new SizeOfCompiler()
}
addTypes (types) {
this.readCompiler.addTypes(types.Read)
this.writeCompiler.addTypes(types.Write)
this.sizeOfCompiler.addTypes(types.SizeOf)
}
addTypesToCompile (types) {
this.readCompiler.addTypesToCompile(types)
this.writeCompiler.addTypesToCompile(types)
this.sizeOfCompiler.addTypesToCompile(types)
}
addProtocol (protocolData, path) {
this.readCompiler.addProtocol(protocolData, path)
this.writeCompiler.addProtocol(protocolData, path)
this.sizeOfCompiler.addProtocol(protocolData, path)
}
addVariable (key, val) {
this.readCompiler.addContextType(key, val)
this.writeCompiler.addContextType(key, val)
this.sizeOfCompiler.addContextType(key, val)
}
compileProtoDefSync (options = { printCode: false }) {
const sizeOfCode = this.sizeOfCompiler.generate()
const writeCode = this.writeCompiler.generate()
const readCode = this.readCompiler.generate()
if (options.printCode) {
console.log('// SizeOf:')
console.log(sizeOfCode)
console.log('// Write:')
console.log(writeCode)
console.log('// Read:')
console.log(readCode)
}
const sizeOfCtx = this.sizeOfCompiler.compile(sizeOfCode)
const writeCtx = this.writeCompiler.compile(writeCode)
const readCtx = this.readCompiler.compile(readCode)
return new CompiledProtodef(sizeOfCtx, writeCtx, readCtx)
}
}
class CompiledProtodef {
constructor (sizeOfCtx, writeCtx, readCtx) {
this.sizeOfCtx = sizeOfCtx
this.writeCtx = writeCtx
this.readCtx = readCtx
}
read (buffer, cursor, type) {
const readFn = this.readCtx[type]
if (!readFn) { throw new Error('missing data type: ' + type) }
return readFn(buffer, cursor)
}
write (value, buffer, cursor, type) {
const writeFn = this.writeCtx[type]
if (!writeFn) { throw new Error('missing data type: ' + type) }
return writeFn(value, buffer, cursor)
}
setVariable (key, val) {
this.sizeOfCtx[key] = val
this.readCtx[key] = val
this.writeCtx[key] = val
}
sizeOf (value, type) {
const sizeFn = this.sizeOfCtx[type]
if (!sizeFn) { throw new Error('missing data type: ' + type) }
if (typeof sizeFn === 'function') {
return sizeFn(value)
} else {
return sizeFn
}
}
createPacketBuffer (type, packet) {
const length = tryCatch(() => this.sizeOf(packet, type),
(e) => {
e.message = `SizeOf error for ${e.field} : ${e.message}`
throw e
})
const buffer = Buffer.allocUnsafe(length)
tryCatch(() => this.write(packet, buffer, 0, type),
(e) => {
e.message = `Write error for ${e.field} : ${e.message}`
throw e
})
return buffer
}
parsePacketBuffer (type, buffer, offset = 0) {
const { value, size } = tryCatch(() => this.read(buffer, offset, type),
(e) => {
e.message = `Read error for ${e.field} : ${e.message}`
throw e
})
return {
data: value,
metadata: { size },
buffer: buffer.slice(0, size),
fullBuffer: buffer
}
}
}
class Compiler {
constructor () {
this.primitiveTypes = {}
this.native = {}
this.context = {}
this.types = {}
this.scopeStack = []
this.parameterizableTypes = {}
}
/**
* A native type is a type read or written by a function that will be called in it's
* original context.
* @param {*} type
* @param {*} fn
*/
addNativeType (type, fn) {
this.primitiveTypes[type] = `native.${type}`
this.native[type] = fn
this.types[type] = 'native'
}
/**
* A context type is a type that will be called in the protocol's context. It can refer to
* registred native types using native.{type}() or context type (provided and generated)
* using ctx.{type}(), but cannot access it's original context.
* @param {*} type
* @param {*} fn
*/
addContextType (type, fn) {
this.primitiveTypes[type] = `ctx.${type}`
this.context[type] = fn.toString()
}
/**
* A parametrizable type is a function that will be generated at compile time using the
* provided maker function
* @param {*} type
* @param {*} maker
*/
addParametrizableType (type, maker) {
this.parameterizableTypes[type] = maker
}
addTypes (types) {
for (const [type, [kind, fn]] of Object.entries(types)) {
if (kind === 'native') this.addNativeType(type, fn)
else if (kind === 'context') this.addContextType(type, fn)
else if (kind === 'parametrizable') this.addParametrizableType(type, fn)
}
}
addTypesToCompile (types) {
for (const [type, json] of Object.entries(types)) {
// Replace native type, otherwise first in wins
if (!this.types[type] || this.types[type] === 'native') this.types[type] = json
}
}
addProtocol (protocolData, path) {
const self = this
function recursiveAddTypes (protocolData, path) {
if (protocolData === undefined) { return }
if (protocolData.types) { self.addTypesToCompile(protocolData.types) }
recursiveAddTypes(protocolData[path.shift()], path)
}
recursiveAddTypes(protocolData, path.slice(0))
}
indent (code, indent = ' ') {
return code.split('\n').map((line) => indent + line).join('\n')
}
getField (name, noAssign) {
const path = name.split('/')
let i = this.scopeStack.length - 1
const reserved = ['value', 'enum', 'default', 'size', 'offset']
while (path.length) {
const scope = this.scopeStack[i]
const field = path.shift()
if (field === '..') {
i--
continue
}
// We are at the right level
if (scope[field]) return scope[field] + (path.length ? ('.' + path.join('.')) : '')
if (path.length !== 0) {
throw new Error('Cannot access properties of undefined field')
}
// Count how many collision occured in the scope
let count = 0
if (reserved.includes(field)) count++
for (let j = 0; j < i; j++) {
if (this.scopeStack[j][field]) count++
}
if (noAssign) { // referencing a variable, inherit from parent scope
scope[field] = field
} else { // creating a new variable in this scope
scope[field] = field + (count || '') // If the name is already used, add a number
}
return scope[field]
}
throw new Error('Unknown field ' + path)
}
generate () {
this.scopeStack = [{}]
const functions = []
for (const type in this.context) {
functions[type] = this.context[type]
}
for (const type in this.types) {
if (!functions[type]) {
if (this.types[type] !== 'native') {
functions[type] = this.compileType(this.types[type])
if (functions[type].startsWith('ctx')) {
functions[type] = 'function () { return ' + functions[type] + '(...arguments) }'
}
if (!isNaN(functions[type])) { functions[type] = this.wrapCode(' return ' + functions[type]) }
} else {
functions[type] = `native.${type}`
}
}
}
return '() => {\n' + this.indent('const ctx = {\n' + this.indent(Object.keys(functions).map((type) => {
return type + ': ' + functions[type]
}).join(',\n')) + '\n}\nreturn ctx') + '\n}'
}
/**
* Compile the given js code, providing native.{type} to the context, return the compiled types
* @param {*} code
*/
compile (code) {
// Local variable to provide some context to eval()
const native = this.native // eslint-disable-line
const { PartialReadError } = require('./utils') // eslint-disable-line
return eval(code)() // eslint-disable-line
}
}
class ReadCompiler extends Compiler {
constructor () {
super()
this.addTypes(conditionalDatatypes.Read)
this.addTypes(structuresDatatypes.Read)
this.addTypes(utilsDatatypes.Read)
// Add default types
for (const key in numeric) {
this.addNativeType(key, numeric[key][0])
}
for (const key in utils) {
this.addNativeType(key, utils[key][0])
}
}
compileType (type) {
if (type instanceof Array) {
if (this.parameterizableTypes[type[0]]) { return this.parameterizableTypes[type[0]](this, type[1]) }
if (this.types[type[0]] && this.types[type[0]] !== 'native') {
return this.wrapCode('return ' + this.callType(type[0], 'offset', Object.values(type[1])))
}
throw new Error('Unknown parametrizable type: ' + JSON.stringify(type[0]))
} else { // Primitive type
if (type === 'native') return 'null'
if (this.types[type]) { return 'ctx.' + type }
return this.primitiveTypes[type]
}
}
wrapCode (code, args = []) {
if (args.length > 0) return '(buffer, offset, ' + args.join(', ') + ') => {\n' + this.indent(code) + '\n}'
return '(buffer, offset) => {\n' + this.indent(code) + '\n}'
}
callType (type, offsetExpr = 'offset', args = []) {
if (type instanceof Array) {
if (this.types[type[0]] && this.types[type[0]] !== 'native') {
return this.callType(type[0], offsetExpr, Object.values(type[1]))
}
}
if (type instanceof Array && type[0] === 'container') this.scopeStack.push({})
const code = this.compileType(type)
if (type instanceof Array && type[0] === 'container') this.scopeStack.pop()
if (args.length > 0) return '(' + code + `)(buffer, ${offsetExpr}, ` + args.map(name => this.getField(name)).join(', ') + ')'
return '(' + code + `)(buffer, ${offsetExpr})`
}
}
class WriteCompiler extends Compiler {
constructor () {
super()
this.addTypes(conditionalDatatypes.Write)
this.addTypes(structuresDatatypes.Write)
this.addTypes(utilsDatatypes.Write)
// Add default types
for (const key in numeric) {
this.addNativeType(key, numeric[key][1])
}
for (const key in utils) {
this.addNativeType(key, utils[key][1])
}
}
compileType (type) {
if (type instanceof Array) {
if (this.parameterizableTypes[type[0]]) { return this.parameterizableTypes[type[0]](this, type[1]) }
if (this.types[type[0]] && this.types[type[0]] !== 'native') {
return this.wrapCode('return ' + this.callType('value', type[0], 'offset', Object.values(type[1])))
}
throw new Error('Unknown parametrizable type: ' + type[0])
} else { // Primitive type
if (type === 'native') return 'null'
if (this.types[type]) { return 'ctx.' + type }
return this.primitiveTypes[type]
}
}
wrapCode (code, args = []) {
if (args.length > 0) return '(value, buffer, offset, ' + args.join(', ') + ') => {\n' + this.indent(code) + '\n}'
return '(value, buffer, offset) => {\n' + this.indent(code) + '\n}'
}
callType (value, type, offsetExpr = 'offset', args = []) {
if (type instanceof Array) {
if (this.types[type[0]] && this.types[type[0]] !== 'native') {
return this.callType(value, type[0], offsetExpr, Object.values(type[1]))
}
}
if (type instanceof Array && type[0] === 'container') this.scopeStack.push({})
const code = this.compileType(type)
if (type instanceof Array && type[0] === 'container') this.scopeStack.pop()
if (args.length > 0) return '(' + code + `)(${value}, buffer, ${offsetExpr}, ` + args.map(name => this.getField(name)).join(', ') + ')'
return '(' + code + `)(${value}, buffer, ${offsetExpr})`
}
}
class SizeOfCompiler extends Compiler {
constructor () {
super()
this.addTypes(conditionalDatatypes.SizeOf)
this.addTypes(structuresDatatypes.SizeOf)
this.addTypes(utilsDatatypes.SizeOf)
// Add default types
for (const key in numeric) {
this.addNativeType(key, numeric[key][2])
}
for (const key in utils) {
this.addNativeType(key, utils[key][2])
}
}
/**
* A native type is a type read or written by a function that will be called in it's
* original context.
* @param {*} type
* @param {*} fn
*/
addNativeType (type, fn) {
this.primitiveTypes[type] = `native.${type}`
if (!isNaN(fn)) {
this.native[type] = (value) => { return fn }
} else {
this.native[type] = fn
}
this.types[type] = 'native'
}
compileType (type) {
if (type instanceof Array) {
if (this.parameterizableTypes[type[0]]) { return this.parameterizableTypes[type[0]](this, type[1]) }
if (this.types[type[0]] && this.types[type[0]] !== 'native') {
return this.wrapCode('return ' + this.callType('value', type[0], Object.values(type[1])))
}
throw new Error('Unknown parametrizable type: ' + type[0])
} else { // Primitive type
if (type === 'native') return 'null'
if (!isNaN(this.primitiveTypes[type])) return this.primitiveTypes[type]
if (this.types[type]) { return 'ctx.' + type }
return this.primitiveTypes[type]
}
}
wrapCode (code, args = []) {
if (args.length > 0) return '(value, ' + args.join(', ') + ') => {\n' + this.indent(code) + '\n}'
return '(value) => {\n' + this.indent(code) + '\n}'
}
callType (value, type, args = []) {
if (type instanceof Array) {
if (this.types[type[0]] && this.types[type[0]] !== 'native') {
return this.callType(value, type[0], Object.values(type[1]))
}
}
if (type instanceof Array && type[0] === 'container') this.scopeStack.push({})
const code = this.compileType(type)
if (type instanceof Array && type[0] === 'container') this.scopeStack.pop()
if (!isNaN(code)) return code
if (args.length > 0) return '(' + code + `)(${value}, ` + args.map(name => this.getField(name)).join(', ') + ')'
return '(' + code + `)(${value})`
}
}
module.exports = {
ReadCompiler,
WriteCompiler,
SizeOfCompiler,
ProtoDefCompiler,
CompiledProtodef
}