UNPKG

protons

Version:
1,328 lines (1,126 loc) 37.9 kB
/* eslint-disable max-depth */ /** * @packageDocumentation * * `protons` is a high performance implementation of [Protocol Buffers v3](https://protobuf.dev/programming-guides/proto3/). * * It transpiles code to TypeScript and supports BigInts for 64 bit types. * * The `protons` module contains the code to compile `.proto` files to `.ts` files and `protons-runtime` contains the code to do serialization/deserialization to `Uint8Array`s during application execution. * * Please ensure you declare them as the correct type of dependencies: * * ```console * $ npm install --save-dev protons * $ npm install --save protons-runtime * ``` * * ## Usage * * First generate your `.ts` files: * * ```console * $ protons ./path/to/foo.proto ./path/to/output.ts * ``` * * Then run tsc over them as normal: * * ```console * $ tsc * ``` * * In your code import the generated classes and use them to transform to/from bytes: * * ```js * import { Foo } from './foo.js' * * const foo = { * message: 'hello world' * } * * const encoded = Foo.encode(foo) * const decoded = Foo.decode(encoded) * * console.info(decoded.message) * // 'hello world' * ``` * * ## Differences from protobuf.js * * This module uses the internal reader/writer from `protobuf.js` as it is highly optimised and there's no point reinventing the wheel. * * It does have one or two differences: * * 1. Supports `proto3` semantics only * 2. All 64 bit values are represented as `BigInt`s and not `Long`s (e.g. `int64`, `uint64`, `sint64` etc) * 3. Unset `optional` fields are set on the deserialized object forms as `undefined` instead of the default values * 4. `singular` fields set to default values are not serialized and are set to default values when deserialized if not set - protobuf.js [diverges from the language guide](https://github.com/protobufjs/protobuf.js/issues/1468#issuecomment-745177012) around this feature * 5. `map` fields can have keys of any type - protobufs.js [only supports strings](https://github.com/protobufjs/protobuf.js/issues/1203#issuecomment-488637338) * 6. `map` fields are deserialized as ES6 `Map`s - protobuf.js uses `Object`s * * ## Extra features * * ### Limiting the size of repeated/map elements * * To protect decoders from malicious payloads, it's possible to limit the maximum size of repeated/map elements. * * You can either do this at compile time by using the [protons.options](https://github.com/protocolbuffers/protobuf/blob/6f1d88107f268b8ebdad6690d116e74c403e366e/docs/options.md?plain=1#L490-L493) extension: * * ```protobuf * message MyMessage { * // repeatedField cannot have more than 10 entries * repeated uint32 repeatedField = 1 [(protons.options).limit = 10]; * * // stringMap cannot have more than 10 keys * map<string, string> stringMap = 2 [(protons.options).limit = 10]; * } * ``` * * Or at runtime by passing objects to the `.decode` function of your message: * * ```TypeScript * const message = MyMessage.decode(buf, { * limits: { * repeatedField: 10, * stringMap: 10 * } * }) * ``` * * #### Limiting repeating fields of nested messages at runtime * * Sub messages with repeating elements can be limited in a similar way: * * ```protobuf * message SubMessage { * repeated uint32 repeatedField = 1; * } * * message MyMessage { * SubMessage message = 1; * } * ``` * * ```TypeScript * const message = MyMessage.decode(buf, { * limits: { * messages: { * repeatedField: 5 // the SubMessage can not have more than 5 repeatedField entries * } * } * }) * ``` * * #### Limiting repeating fields of repeating messages at runtime * * Sub messages defined in repeating elements can be limited by appending `$` to the field name in the runtime limit options: * * ```protobuf * message SubMessage { * repeated uint32 repeatedField = 1; * } * * message MyMessage { * repeated SubMessage messages = 1; * } * ``` * * ```TypeScript * const message = MyMessage.decode(buf, { * limits: { * messages: 5 // max 5x SubMessages * messages$: { * repeatedField: 5 // no SubMessage can have more than 5 repeatedField entries * } * } * }) * ``` * * #### Limiting repeating fields of map entries at runtime * * Repeating fields in map entries can be limited by appending `$value` to the field name in the runtime limit options: * * ```protobuf * message SubMessage { * repeated uint32 repeatedField = 1; * } * * message MyMessage { * map<string, SubMessage> messages = 1; * } * ``` * * ```TypeScript * const message = MyMessage.decode(buf, { * limits: { * messages: 5 // max 5x SubMessages in the map * messages$value: { * repeatedField: 5 // no SubMessage in the map can have more than 5 repeatedField entries * } * } * }) * ``` * * ### Overriding 64 bit types * * By default 64 bit types are implemented as [BigInt](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt)s. * * Sometimes this is undesirable due to [performance issues](https://betterprogramming.pub/the-downsides-of-bigints-in-javascript-6350fd807d) or code legibility. * * It's possible to override the JavaScript type 64 bit fields will deserialize to: * * ```protobuf * message MyMessage { * repeated int64 bigintField = 1; * repeated int64 numberField = 2 [jstype = JS_NUMBER]; * repeated int64 stringField = 3 [jstype = JS_STRING]; * } * ``` * * ```TypeScript * const message = MyMessage.decode(buf) * * console.info(typeof message.bigintField) // bigint * console.info(typeof message.numberField) // number * console.info(typeof message.stringField) // string * ``` * * ## Missing features * * Some features are missing `OneOf`s, etc due to them not being needed so far in ipfs/libp2p. If these features are important to you, please open PRs implementing them along with tests comparing the generated bytes to `protobuf.js` and `pbjs`. */ import fs from 'fs/promises' import path from 'path' import { promisify } from 'util' import { main as pbjs } from 'protobufjs-cli/pbjs.js' import { NoMessagesFoundError, ParseError } from 'protons-runtime' export enum CODEC_TYPES { VARINT = 0, BIT64, LENGTH_DELIMITED, START_GROUP, END_GROUP, BIT32 } function pathWithExtension (input: string, extension: string, outputDir?: string): string { const output = outputDir ?? path.dirname(input) return path.join(output, path.basename(input).split('.').slice(0, -1).join('.') + extension) } /** * This will be removed in a future release * * @deprecated */ export class CodeError extends Error { public code: string constructor (message: string, code: string, options?: ErrorOptions) { super(message, options) this.code = code } } const types: Record<string, string> = { bool: 'boolean', bytes: 'Uint8Array', double: 'number', fixed32: 'number', fixed64: 'bigint', float: 'number', int32: 'number', int64: 'bigint', sfixed32: 'number', sfixed64: 'bigint', sint32: 'number', sint64: 'bigint', string: 'string', uint32: 'number', uint64: 'bigint' } const jsTypeOverrides: Record<string, 'number' | 'string'> = { JS_NUMBER: 'number', JS_STRING: 'string' } const encoderGenerators: Record<string, (val: string, jsTypeOverride?: 'number' | 'string') => string> = { bool: (val) => `w.bool(${val})`, bytes: (val) => `w.bytes(${val})`, double: (val) => `w.double(${val})`, fixed32: (val) => `w.fixed32(${val})`, fixed64: (val, jsTypeOverride) => { if (jsTypeOverride === 'number') { return `w.fixed64Number(${val})` } if (jsTypeOverride === 'string') { return `w.fixed64String(${val})` } return `w.fixed64(${val})` }, float: (val) => `w.float(${val})`, int32: (val) => `w.int32(${val})`, int64: (val, jsTypeOverride) => { if (jsTypeOverride === 'number') { return `w.int64Number(${val})` } if (jsTypeOverride === 'string') { return `w.int64String(${val})` } return `w.int64(${val})` }, sfixed32: (val) => `w.sfixed32(${val})`, sfixed64: (val, jsTypeOverride) => { if (jsTypeOverride === 'number') { return `w.sfixed64Number(${val})` } if (jsTypeOverride === 'string') { return `w.sfixed64String(${val})` } return `w.sfixed64(${val})` }, sint32: (val) => `w.sint32(${val})`, sint64: (val, jsTypeOverride) => { if (jsTypeOverride === 'number') { return `w.sint64Number(${val})` } if (jsTypeOverride === 'string') { return `w.sint64String(${val})` } return `w.sint64(${val})` }, string: (val) => `w.string(${val})`, uint32: (val) => `w.uint32(${val})`, uint64: (val, jsTypeOverride) => { if (jsTypeOverride === 'number') { return `w.uint64Number(${val})` } if (jsTypeOverride === 'string') { return `w.uint64String(${val})` } return `w.uint64(${val})` } } const decoderGenerators: Record<string, (jsTypeOverride?: 'number' | 'string') => string> = { bool: () => 'reader.bool()', bytes: () => 'reader.bytes()', double: () => 'reader.double()', fixed32: () => 'reader.fixed32()', fixed64: (jsTypeOverride) => { if (jsTypeOverride === 'number') { return 'reader.fixed64Number()' } if (jsTypeOverride === 'string') { return 'reader.fixed64String()' } return 'reader.fixed64()' }, float: () => 'reader.float()', int32: () => 'reader.int32()', int64: (jsTypeOverride) => { if (jsTypeOverride === 'number') { return 'reader.int64Number()' } if (jsTypeOverride === 'string') { return 'reader.int64String()' } return 'reader.int64()' }, sfixed32: () => 'reader.sfixed32()', sfixed64: (jsTypeOverride) => { if (jsTypeOverride === 'number') { return 'reader.sfixed64Number()' } if (jsTypeOverride === 'string') { return 'reader.sfixed64String()' } return 'reader.sfixed64()' }, sint32: () => 'reader.sint32()', sint64: (jsTypeOverride) => { if (jsTypeOverride === 'number') { return 'reader.sint64Number()' } if (jsTypeOverride === 'string') { return 'reader.sint64String()' } return 'reader.sint64()' }, string: () => 'reader.string()', uint32: () => 'reader.uint32()', uint64: (jsTypeOverride) => { if (jsTypeOverride === 'number') { return 'reader.uint64Number()' } if (jsTypeOverride === 'string') { return 'reader.uint64String()' } return 'reader.uint64()' } } const defaultValueGenerators: Record<string, () => string> = { bool: () => 'false', bytes: () => 'uint8ArrayAlloc(0)', double: () => '0', fixed32: () => '0', fixed64: () => '0n', float: () => '0', int32: () => '0', int64: () => '0n', sfixed32: () => '0', sfixed64: () => '0n', sint32: () => '0', sint64: () => '0n', string: () => "''", uint32: () => '0', uint64: () => '0n' } const defaultValueGeneratorsJsTypeOverrides: Record<string, () => string> = { number: () => '0', string: () => "''" } const defaultValueTestGenerators: Record<string, (field: string) => string> = { bool: (field) => `(${field} != null && ${field} !== false)`, bytes: (field) => `(${field} != null && ${field}.byteLength > 0)`, double: (field) => `(${field} != null && ${field} !== 0)`, fixed32: (field) => `(${field} != null && ${field} !== 0)`, fixed64: (field) => `(${field} != null && ${field} !== 0n)`, float: (field) => `(${field} != null && ${field} !== 0)`, int32: (field) => `(${field} != null && ${field} !== 0)`, int64: (field) => `(${field} != null && ${field} !== 0n)`, sfixed32: (field) => `(${field} != null && ${field} !== 0)`, sfixed64: (field) => `(${field} != null && ${field} !== 0n)`, sint32: (field) => `(${field} != null && ${field} !== 0)`, sint64: (field) => `(${field} != null && ${field} !== 0n)`, string: (field) => `(${field} != null && ${field} !== '')`, uint32: (field) => `(${field} != null && ${field} !== 0)`, uint64: (field) => `(${field} != null && ${field} !== 0n)` } const defaultValueTestGeneratorsJsTypeOverrides: Record<string, (field: string) => string> = { number: (field) => `(${field} != null && ${field} !== 0)`, string: (field) => `(${field} != null && ${field} !== '')` } function findJsTypeOverride (defaultType: string, fieldDef: FieldDef): 'number' | 'string' | undefined { if (fieldDef.options?.jstype != null && jsTypeOverrides[fieldDef.options?.jstype] != null) { if (!['int64', 'uint64', 'sint64', 'fixed64', 'sfixed64'].includes(defaultType)) { throw new Error(`jstype is only allowed on int64, uint64, sint64, fixed64 or sfixed64 fields - got "${defaultType}"`) } return jsTypeOverrides[fieldDef.options?.jstype] } } function findJsTypeName (typeName: string, classDef: MessageDef, moduleDef: ModuleDef, fieldDef: FieldDef): string { const override = findJsTypeOverride(typeName, fieldDef) if (override != null) { return override } if (types[typeName] != null) { return types[typeName] } if (isEnumDef(classDef)) { throw new Error('Could not find type in enum') } if (classDef.nested?.[typeName] != null) { return `${classDef.fullName}.${typeName}` } if (classDef.parent != null) { return findJsTypeName(typeName, classDef.parent, moduleDef, fieldDef) } if (moduleDef.globals[typeName] != null) { return typeName } throw new Error(`Could not resolve type name "${typeName}"`) } function findDef (typeName: string, classDef: MessageDef, moduleDef: ModuleDef): MessageDef { if (isEnumDef(classDef)) { throw new Error('Could not find type in enum') } if (classDef.nested?.[typeName] != null) { return classDef.nested?.[typeName] } if (classDef.parent != null) { return findDef(typeName, classDef.parent, moduleDef) } if (moduleDef.globals[typeName] != null) { return moduleDef.globals[typeName] } throw new Error(`Could not resolve type name "${typeName}"`) } function createDefaultObject (fields: Record<string, FieldDef>, messageDef: MessageDef, moduleDef: ModuleDef): string { const output = Object.entries(fields) .map(([name, fieldDef]) => { if (fieldDef.map) { return `${name}: new Map<${types[fieldDef.keyType ?? 'string']}, ${types[fieldDef.valueType]}>()` } if (fieldDef.repeated) { return `${name}: []` } if (fieldDef.optional) { return '' } const type: string = fieldDef.type let defaultValue let defaultValueGenerator = defaultValueGenerators[type] if (defaultValueGenerator != null) { const jsTypeOverride = findJsTypeOverride(type, fieldDef) if (jsTypeOverride != null && defaultValueGeneratorsJsTypeOverrides[jsTypeOverride] != null) { defaultValueGenerator = defaultValueGeneratorsJsTypeOverrides[jsTypeOverride] } if (type === 'bytes') { moduleDef.addImport('uint8arrays/alloc', 'alloc', 'uint8ArrayAlloc') } defaultValue = defaultValueGenerator() } else { const def = findDef(fieldDef.type, messageDef, moduleDef) if (isEnumDef(def)) { // select lowest-value enum - should be 0 but it's not guaranteed const val = Object.entries(def.values) .sort((a, b) => { if (a[1] < b[1]) { return 1 } if (a[1] > b[1]) { return -1 } return 0 }) .pop() if (val == null) { throw new Error(`Could not find default enum value for ${def.fullName}`) } defaultValue = `${def.name}.${val[0]}` } else { defaultValue = 'undefined' } } return `${name}: ${defaultValue}` }) .filter(Boolean) .join(',\n ') if (output !== '') { return ` ${output} ` } return '' } const encoders: Record<string, string> = { bool: 'bool', bytes: 'bytes', double: 'double', fixed32: 'fixed32', fixed64: 'fixed64', float: 'float', int32: 'int32', int64: 'int64', sfixed32: 'sfixed32', sfixed64: 'sfixed64', sint32: 'sint32', sint64: 'sint64', string: 'string', uint32: 'uint32', uint64: 'uint64' } const codecTypes: Record<string, CODEC_TYPES> = { bool: CODEC_TYPES.VARINT, bytes: CODEC_TYPES.LENGTH_DELIMITED, double: CODEC_TYPES.BIT64, enum: CODEC_TYPES.VARINT, fixed32: CODEC_TYPES.BIT32, fixed64: CODEC_TYPES.BIT64, float: CODEC_TYPES.BIT32, int32: CODEC_TYPES.VARINT, int64: CODEC_TYPES.VARINT, message: CODEC_TYPES.LENGTH_DELIMITED, sfixed32: CODEC_TYPES.BIT32, sfixed64: CODEC_TYPES.BIT64, sint32: CODEC_TYPES.VARINT, sint64: CODEC_TYPES.VARINT, string: CODEC_TYPES.LENGTH_DELIMITED, uint32: CODEC_TYPES.VARINT, uint64: CODEC_TYPES.VARINT } interface ClassDef { name: string fullName: string parent?: ClassDef fields?: Record<string, FieldDef> nested?: Record<string, ClassDef> } interface EnumDef { name: string fullName: string parent?: ClassDef values: Record<string, number> } type MessageDef = ClassDef | EnumDef function isEnumDef (obj: any): obj is EnumDef { return obj.values != null } interface FieldDef { type: string id: number options?: Record<string, any> rule: string optional: boolean repeated: boolean lengthLimit?: number message: boolean enum: boolean map: boolean valueType: string keyType: string /** * Support proto2 required field. This field means a value should always be * in the serialized buffer, any message without it should be considered * invalid. It was removed for proto3. */ proto2Required: boolean } function defineFields (fields: Record<string, FieldDef>, messageDef: MessageDef, moduleDef: ModuleDef): string[] { return Object.entries(fields).map(([fieldName, fieldDef]) => { if (fieldDef.map) { return `${fieldName}: Map<${findJsTypeName(fieldDef.keyType ?? 'string', messageDef, moduleDef, fieldDef)}, ${findJsTypeName(fieldDef.valueType, messageDef, moduleDef, fieldDef)}>` } return `${fieldName}${fieldDef.optional ? '?' : ''}: ${findJsTypeName(fieldDef.type, messageDef, moduleDef, fieldDef)}${fieldDef.repeated ? '[]' : ''}` }) } function compileMessage (messageDef: MessageDef, moduleDef: ModuleDef, flags?: Flags): string { if (isEnumDef(messageDef)) { moduleDef.addImport('protons-runtime', 'enumeration') // check that the enum def values start from 0 if (Object.values(messageDef.values)[0] !== 0) { const message = `enum ${messageDef.name} does not contain a value that maps to zero as it's first element, this is required in proto3 - see https://protobuf.dev/programming-guides/proto3/#enum` if (flags?.strict === true) { throw new ParseError(message) } else { // eslint-disable-next-line no-console console.info(`[WARN] ${message}`) } } return ` export enum ${messageDef.name} { ${ Object.keys(messageDef.values).map(name => { return `${name} = '${name}'` }).join(',\n ').trim() } } enum __${messageDef.name}Values { ${ Object.entries(messageDef.values).map(([name, value]) => { return `${name} = ${value}` }).join(',\n ').trim() } } export namespace ${messageDef.name} { export const codec = (): Codec<${messageDef.name}> => { return enumeration<${messageDef.name}>(__${messageDef.name}Values) } }`.trim() } let nested = '' if (messageDef.nested != null) { nested = '\n' nested += Object.values(messageDef.nested) .map(def => compileMessage(def, moduleDef, flags).trim()) .join('\n\n') .split('\n') .map(line => line.trim() === '' ? '' : ` ${line}`) .join('\n') } const fields = messageDef.fields ?? {} // import relevant modules moduleDef.addImport('protons-runtime', 'encodeMessage') moduleDef.addImport('protons-runtime', 'decodeMessage') moduleDef.addImport('protons-runtime', 'message') moduleDef.addTypeImport('protons-runtime', 'Codec') moduleDef.addTypeImport('protons-runtime', 'DecodeOptions') moduleDef.addTypeImport('uint8arraylist', 'Uint8ArrayList') const interfaceFields = defineFields(fields, messageDef, moduleDef) .join('\n ') .trim() let interfaceDef = '' let interfaceCodecDef = '' if (interfaceFields === '') { interfaceDef = ` export interface ${messageDef.name} {}` } else { interfaceDef = ` export interface ${messageDef.name} { ${ defineFields(fields, messageDef, moduleDef) .join('\n ') .trim() } }` } const encodeFields = Object.entries(fields) .map(([name, fieldDef]) => { let codec: string = encoders[fieldDef.type] let type: string = fieldDef.map ? 'message' : fieldDef.type let typeName: string = '' if (codec == null) { if (fieldDef.enum) { moduleDef.addImport('protons-runtime', 'enumeration') type = 'enum' } else { moduleDef.addImport('protons-runtime', 'message') type = 'message' } typeName = findJsTypeName(fieldDef.type, messageDef, moduleDef, fieldDef) codec = `${typeName}.codec()` } let valueTest = `obj.${name} != null` if (fieldDef.map) { valueTest = `obj.${name} != null && obj.${name}.size !== 0` } else if (!fieldDef.optional && !fieldDef.repeated && !fieldDef.proto2Required) { let defaultValueTestGenerator = defaultValueTestGenerators[type] // proto3 singular fields should only be written out if they are not the default value if (defaultValueTestGenerator != null) { const jsTypeOverride = findJsTypeOverride(type, fieldDef) if (jsTypeOverride != null && defaultValueTestGeneratorsJsTypeOverrides[jsTypeOverride] != null) { defaultValueTestGenerator = defaultValueTestGeneratorsJsTypeOverrides[jsTypeOverride] } valueTest = `${defaultValueTestGenerator(`obj.${name}`)}` } else if (type === 'enum') { // handle enums const def = findDef(fieldDef.type, messageDef, moduleDef) if (!isEnumDef(def)) { throw new Error(`${fieldDef.type} was not enum def`) } valueTest = `obj.${name} != null` // singular enums default to 0, but enums can be defined without a 0 // value which is against the proto3 spec but is tolerated if (Object.values(def.values)[0] === 0) { valueTest += ` && __${fieldDef.type}Values[obj.${name}] !== 0` } } } function createWriteField (valueVar: string): () => string { const id = (fieldDef.id << 3) | codecTypes[type] if (fieldDef.enum) { const def = findDef(fieldDef.type, messageDef, moduleDef) if (!isEnumDef(def)) { throw new Error(`${fieldDef.type} was not enum def`) } } let writeField = (): string => { const encoderGenerator = encoderGenerators[type] const jsTypeOverride = findJsTypeOverride(type, fieldDef) return `w.uint32(${id}) ${encoderGenerator == null ? `${codec}.encode(${valueVar}, w)` : encoderGenerator(valueVar, jsTypeOverride)}` } if (type === 'message') { // message fields are only written if they have values. But if a message // is part of a repeated field, and consists of only default values it // won't be written, so write a zero-length buffer if that's the case writeField = () => `w.uint32(${id}) ${typeName}.codec().encode(${valueVar}, w)` } return writeField } let writeField = createWriteField(`obj.${name}`) if (fieldDef.repeated) { if (fieldDef.map) { writeField = () => ` for (const [key, value] of obj.${name}.entries()) { ${ createWriteField('{ key, value }')() .split('\n') .map(s => { const trimmed = s.trim() return trimmed === '' ? trimmed : ` ${s}` }) .join('\n') } } `.trim() } else { writeField = () => ` for (const value of obj.${name}) { ${ createWriteField('value')() .split('\n') .map(s => { const trimmed = s.trim() return trimmed === '' ? trimmed : ` ${s}` }) .join('\n') } } `.trim() } } return ` if (${valueTest}) { ${writeField()} }` }).join('\n') const decodeFields = Object.entries(fields) .map(([fieldName, fieldDef]) => { function createReadField (fieldName: string, fieldDef: FieldDef): string { let codec: string = encoders[fieldDef.type] let type: string = fieldDef.type if (codec == null) { if (fieldDef.enum) { moduleDef.addImport('protons-runtime', 'enumeration') type = 'enum' } else { moduleDef.addImport('protons-runtime', 'message') type = 'message' } const typeName = findJsTypeName(fieldDef.type, messageDef, moduleDef, fieldDef) codec = `${typeName}.codec()` } // override setting type on js object const jsTypeOverride = findJsTypeOverride(fieldDef.type, fieldDef) let fieldOpts = '' if (fieldDef.message) { let suffix = '' if (fieldDef.repeated) { suffix = '$' } fieldOpts = `, { limits: opts.limits?.${fieldName}${suffix} }` } if (fieldDef.map) { fieldOpts = `, { limits: { value: opts.limits?.${fieldName}$value } }` // do not pass limit opts to map value types that are enums or // primitives - only support messages if (types[fieldDef.valueType] != null) { // primmitive type fieldOpts = '' } else { const valueType = findDef(fieldDef.valueType, messageDef, moduleDef) if (isEnumDef(valueType)) { // enum type fieldOpts = '' } } } const parseValue = `${decoderGenerators[type] == null ? `${codec}.decode(reader${type === 'message' ? `, reader.uint32()${fieldOpts}` : ''})` : decoderGenerators[type](jsTypeOverride)}` if (fieldDef.map) { moduleDef.addImport('protons-runtime', 'MaxSizeError') let limit = ` if (opts.limits?.${fieldName} != null && obj.${fieldName}.size === opts.limits.${fieldName}) { throw new MaxSizeError('Decode error - map field "${fieldName}" had too many elements') } ` if (fieldDef.lengthLimit != null) { limit += ` if (obj.${fieldName}.size === ${fieldDef.lengthLimit}) { throw new MaxSizeError('Decode error - map field "${fieldName}" had too many elements') } ` } return `case ${fieldDef.id}: {${limit} const entry = ${parseValue} obj.${fieldName}.set(entry.key, entry.value) break }` } else if (fieldDef.repeated) { moduleDef.addImport('protons-runtime', 'MaxLengthError') let limit = ` if (opts.limits?.${fieldName} != null && obj.${fieldName}.length === opts.limits.${fieldName}) { throw new MaxLengthError('Decode error - map field "${fieldName}" had too many elements') } ` if (fieldDef.lengthLimit != null) { limit += ` if (obj.${fieldName}.length === ${fieldDef.lengthLimit}) { throw new MaxLengthError('Decode error - repeated field "${fieldName}" had too many elements') } ` } return `case ${fieldDef.id}: {${limit} obj.${fieldName}.push(${parseValue}) break }` } return `case ${fieldDef.id}: { obj.${fieldName} = ${parseValue} break }` } return createReadField(fieldName, fieldDef) }) .join('\n ') interfaceCodecDef = ` let _codec: Codec<${messageDef.name}> export const codec = (): Codec<${messageDef.name}> => { if (_codec == null) { _codec = message<${messageDef.name}>((obj, w, opts = {}) => { if (opts.lengthDelimited !== false) { w.fork() } ${encodeFields === '' ? '' : `${encodeFields}\n`} if (opts.lengthDelimited !== false) { w.ldelim() } }, (reader, length, opts = {}) => { const obj: any = {${createDefaultObject(fields, messageDef, moduleDef)}} const end = length == null ? reader.len : reader.pos + length while (reader.pos < end) { const tag = reader.uint32() switch (tag >>> 3) {${decodeFields === '' ? '' : `\n ${decodeFields}`} default: { reader.skipType(tag & 7) break } } } return obj }) } return _codec } export const encode = (obj: Partial<${messageDef.name}>): Uint8Array => { return encodeMessage(obj, ${messageDef.name}.codec()) } export const decode = (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions<${messageDef.name}>): ${messageDef.name} => { return decodeMessage(buf, ${messageDef.name}.codec(), opts) }` return ` ${interfaceDef} export namespace ${messageDef.name} { ${`${nested}${nested !== '' && interfaceCodecDef !== '' ? '\n' : ''}${interfaceCodecDef}`.trim()} } `.trimStart() } interface Import { symbol: string alias?: string type: boolean } class ModuleDef { imports: Map<string, Import[]> types: Set<string> compiled: string[] globals: Record<string, ClassDef> constructor () { this.imports = new Map() this.types = new Set() this.compiled = [] this.globals = {} } addImport (module: string, symbol: string, alias?: string): void { const defs = this._findDefs(module) for (const def of defs) { // check if we already have a definition for this symbol if (def.symbol === symbol) { if (alias !== def.alias) { throw new Error(`Type symbol ${symbol} imported from ${module} with alias ${def.alias} does not match alias ${alias}`) } // if it was a type before it's not now def.type = false return } } defs.push({ symbol, alias, type: false }) } addTypeImport (module: string, symbol: string, alias?: string): void { const defs = this._findDefs(module) for (const def of defs) { // check if we already have a definition for this symbol if (def.symbol === symbol) { if (alias !== def.alias) { throw new Error(`Type symbol ${symbol} imported from ${module} with alias ${def.alias} does not match alias ${alias}`) } return } } defs.push({ symbol, alias, type: true }) } _findDefs (module: string): Import[] { let defs = this.imports.get(module) if (defs == null) { defs = [] this.imports.set(module, defs) } return defs } } function defineModule (def: ClassDef, flags: Flags): ModuleDef { const moduleDef = new ModuleDef() const defs = def.nested if (defs == null) { throw new NoMessagesFoundError('No top-level messages found in protobuf') } function defineMessage (defs: Record<string, ClassDef>, parent?: ClassDef, flags?: Flags): void { for (const className of Object.keys(defs)) { const classDef = defs[className] classDef.name = className classDef.parent = parent classDef.fullName = parent == null ? className : `${parent.fullName}.${className}` if (classDef.nested != null) { defineMessage(classDef.nested, classDef) } if (classDef.fields != null) { for (const name of Object.keys(classDef.fields)) { const fieldDef = classDef.fields[name] fieldDef.repeated = fieldDef.rule === 'repeated' fieldDef.optional = !fieldDef.repeated && fieldDef.options?.proto3_optional === true fieldDef.map = fieldDef.keyType != null fieldDef.lengthLimit = fieldDef.options?.['(protons.options).limit'] fieldDef.proto2Required = false if (fieldDef.rule === 'required') { const message = `field "${name}" is required, this is not allowed in proto3. Please convert your proto2 definitions to proto3 - see https://github.com/ipfs/protons/wiki/Required-fields-and-protobuf-3` if (flags?.strict === true) { throw new ParseError(message) } else { fieldDef.proto2Required = true // eslint-disable-next-line no-console console.info(`[WARN] ${message}`) } } } } if (parent == null) { moduleDef.globals[className] = classDef } } } function updateTypes (defs: Record<string, ClassDef>, parent?: ClassDef): void { for (const className of Object.keys(defs)) { const classDef = defs[className] if (classDef.nested != null) { updateTypes(classDef.nested, classDef) } if (classDef.fields != null) { for (const name of Object.keys(classDef.fields)) { const fieldDef = classDef.fields[name] if (types[fieldDef.type] == null) { const def = findDef(fieldDef.type, classDef, moduleDef) fieldDef.enum = isEnumDef(def) fieldDef.message = !fieldDef.enum if (fieldDef.message && !fieldDef.repeated) { // the default type for a message is unset so they are always optional // https://developers.google.com/protocol-buffers/docs/proto3#default fieldDef.optional = true } } } } } } defineMessage(defs, undefined, flags) // set enum/message fields now all messages have been defined updateTypes(defs) for (const className of Object.keys(defs)) { const classDef = defs[className] moduleDef.compiled.push(compileMessage(classDef, moduleDef, flags)) } return moduleDef } interface Flags { /** * Specifies an output directory */ output?: string /** * If true, warnings will be thrown as errors */ strict?: boolean /** * A list of directories to add to the include path */ path?: string[] } export async function generate (source: string, flags: Flags): Promise<void> { // convert .protobuf to .json const json = await promisify(pbjs)([ '-t', 'json', ...(flags.path ?? []).map(p => ['--path', path.isAbsolute(p) ? p : path.resolve(process.cwd(), p)]).flat(), source ]) if (json == null) { throw new Error(`Could not convert ${source} to intermediate JSON format`) } const def = JSON.parse(json) for (const [className, classDef] of Object.entries<any>(def.nested ?? {})) { for (const [fieldName, fieldDef] of Object.entries<any>(classDef.fields ?? {})) { if (fieldDef.keyType == null) { continue } // https://developers.google.com/protocol-buffers/docs/proto3#backwards_compatibility const mapEntryType = `${className}$${fieldName}Entry` classDef.nested = classDef.nested ?? {} classDef.nested[mapEntryType] = { fields: { key: { type: fieldDef.keyType, id: 1 }, value: { type: fieldDef.type, id: 2 } } } fieldDef.valueType = fieldDef.type fieldDef.type = mapEntryType fieldDef.rule = 'repeated' } } const moduleDef = defineModule(def, flags) const ignores = [ '/* eslint-disable import/export */', '/* eslint-disable complexity */', '/* eslint-disable @typescript-eslint/no-namespace */', '/* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare */', '/* eslint-disable @typescript-eslint/no-empty-interface */' ] const imports = [] const importedModules = Array.from([...moduleDef.imports.entries()]) .sort((a, b) => { return a[0].localeCompare(b[0]) }) .sort((a, b) => { const aAllTypes = a[1].reduce((acc, curr) => { return acc && curr.type }, true) const bAllTypes = b[1].reduce((acc, curr) => { return acc && curr.type }, true) if (aAllTypes && !bAllTypes) { return 1 } if (!aAllTypes && bAllTypes) { return -1 } return 0 }) for (const imp of importedModules) { const allTypes = imp[1].reduce((acc, curr) => { return acc && curr.type }, true) const symbols = imp[1].sort((a, b) => { return a.symbol.localeCompare(b.symbol) }).map(imp => { return `${!allTypes && imp.type ? 'type ' : ''}${imp.symbol}${imp.alias != null ? ` as ${imp.alias}` : ''}` }).join(', ') imports.push(`import ${allTypes ? 'type ' : ''}{ ${symbols} } from '${imp[0]}'`) } const lines = [ ...ignores, '', ...imports, '', ...moduleDef.compiled ] const content = lines.join('\n').trim() const outputPath = pathWithExtension(source, '.ts', flags.output) await fs.writeFile(outputPath, content + '\n') }