diff --git a/README.md b/README.md index c5c2f84..5c17c41 100644 --- a/README.md +++ b/README.md @@ -278,11 +278,12 @@ For an example of a real-world usage of the library, see [vector-tile-js](https: If installed globally, `pbf` provides a binary that compiles `proto` files into JavaScript modules. Usage: ```bash -$ pbf [--no-write] [--no-read] [--legacy] +$ pbf [--no-write] [--no-read] [--legacy] [--ts-declarations] ``` The `--no-write` and `--no-read` switches remove corresponding code in the output. The `--legacy` switch makes it generate a CommonJS module instead of ESM. +The `--ts-declarations` switch makes it generate TypeScript declarations (corresponding to the ESM output). `Pbf` will generate `read` and `write` functions for every message in the schema. For nested messages, their names will be concatenated — e.g. `Message` inside `Test` will produce `readTestMessage` and `writeTestMessage` functions. diff --git a/bin/pbf b/bin/pbf index 7981f06..e79cb1c 100755 --- a/bin/pbf +++ b/bin/pbf @@ -4,14 +4,21 @@ import resolve from 'resolve-protobuf-schema'; import {compileRaw} from '../compile.js'; if (process.argv.length < 3) { - console.error('Usage: pbf [file.proto] [--no-read] [--no-write] [--legacy]'); + console.error('Usage: pbf [file.proto] [--no-read] [--no-write] [--legacy] [--ts-declarations]'); process.exit(0); } -const code = compileRaw(resolve.sync(process.argv[2]), { +const options = { noRead: process.argv.indexOf('--no-read') >= 0, noWrite: process.argv.indexOf('--no-write') >= 0, - legacy: process.argv.indexOf('--legacy') >= 0 -}); + legacy: process.argv.indexOf('--legacy') >= 0, + tsDeclarations: process.argv.indexOf('--ts-declarations') >= 0 +}; +if (options.legacy && options.tsDeclarations) { + console.error('--legacy cannot be used with --ts-declarations'); + process.exit(1); +} + +const code = compileRaw(resolve.sync(process.argv[2]), options); process.stdout.write(code); diff --git a/compile.js b/compile.js index 220de6d..2422923 100644 --- a/compile.js +++ b/compile.js @@ -9,6 +9,9 @@ export function compile(proto) { export function compileRaw(proto, options = {}) { const context = buildDefaults(buildContext(proto, null), proto.syntax); + if (options.tsDeclarations) { + return `${options.dev ? '' : `// TypeScript declarations generated by pbf v${version}\n`}${writeTSContext(context, options)}`; + } return `${options.dev ? '' : `// code generated by pbf v${version}\n`}${writeContext(context, options)}`; } @@ -415,3 +418,140 @@ function getDefaultWriteTest(ctx, field) { function isPacked(field) { return field.options.packed === 'true'; } + +function writeTSContext(ctx, options) { + let code = ''; + if (ctx._proto.fields) code += writeTSMessage(ctx, options); + if (ctx._proto.values) code += writeTSEnum(ctx, options); + + for (let i = 0; i < ctx._children.length; i++) { + code += writeTSContext(ctx._children[i], options); + } + return code; +} + +function writeTSMessage(ctx, options) { + const fields = ctx._proto.fields; + const name = ctx._name; + + let code = '\n'; + + // Generate interface for the message + code += `export interface ${name} {\n`; + + // Track oneof groups to handle them properly + const oneofGroups = new Map(); + for (const field of fields) { + if (field.oneof) { + if (!oneofGroups.has(field.oneof)) { + oneofGroups.set(field.oneof, []); + } + oneofGroups.get(field.oneof).push(field); + } + } + + for (const field of fields) { + const tsType = getTSType(ctx, field); + + if (field.oneof) { + // For oneof fields, make them optional and add the oneof indicator + code += ` ${field.name}?: ${tsType} | null;\n`; + } else if (field.repeated) { + // Repeated fields are required arrays + code += ` ${field.name}: ${tsType};\n`; + } else { + // Regular optional fields + code += ` ${field.name}?: ${tsType} | null;\n`; + } + } + + // Add oneof indicator properties + for (const [oneofName, oneofFields] of oneofGroups) { + const fieldNames = oneofFields.map(f => `"${f.name}"`).join(' | '); + code += ` ${oneofName}?: ${fieldNames};\n`; + } + + code += '}\n\n'; + + // Generate read function declaration + if (!options.noRead) { + const readName = `read${name}`; + code += `export function ${readName}(pbf: any, end?: number): ${name};\n`; + } + + // Generate write function declaration + if (!options.noWrite) { + const writeName = `write${name}`; + code += `export function ${writeName}(obj: ${name}, pbf: any): void;\n`; + } + + return code; +} + +function writeTSEnum(ctx) { + const name = ctx._name; + const values = getEnumValues(ctx); + + let code = `export const ${name}: {\n`; + for (const [key, value] of Object.entries(values)) { + code += ` readonly ${key}: ${value};\n`; + } + code += '};\n\n'; + + return code; +} + +function getTSType(ctx, field) { + if (field.type === 'map') { + const keyType = getTSTypeForFieldType(field.map.from, null, null); + const valueType = getTSTypeForFieldType(field.map.to, null, null); + return `{ [key: ${keyType}]: ${valueType} }`; + } + + if (field.repeated) { + const elementType = getTSTypeForFieldType(field.type, ctx, field); + return `${elementType}[]`; + } + + return getTSTypeForFieldType(field.type, ctx, field); +} + +function getTSTypeForFieldType(type, ctx, field) { + if (ctx) { + const resolvedType = getType(ctx, {type}); + + if (resolvedType) { + if (resolvedType._proto.fields) { + return resolvedType._name; + } + if (resolvedType._proto.values) { + // For enums, return the type of the enum values (number) + return 'number'; + } + } + } + + // Check if field should use string as number (jstype=JS_STRING) + if (field && fieldShouldUseStringAsNumber(field)) { + return 'string'; + } + + switch (type) { + case 'string': return 'string'; + case 'float': + case 'double': return 'number'; + case 'bool': return 'boolean'; + case 'uint32': + case 'uint64': + case 'int32': + case 'int64': + case 'sint32': + case 'sint64': + case 'fixed32': + case 'fixed64': + case 'sfixed32': + case 'sfixed64': return 'number'; + case 'bytes': return 'Uint8Array'; + default: return 'any'; + } +} diff --git a/package.json b/package.json index 499b0f6..e57d69f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "bench": "node bench/bench.js", "pretest": "eslint *.js compile.js test/*.js test/fixtures/*.js bin/pbf", - "test": "tsc && node --test", + "test": "tsc && node --test test/*.js", "cov": "node --test --experimental-test-covetage", "build": "rollup -c", "prepublishOnly": "npm run test && npm run build" diff --git a/test/compile.test.js b/test/compile.test.js index d2e5157..a39319b 100644 --- a/test/compile.test.js +++ b/test/compile.test.js @@ -6,21 +6,26 @@ import {sync as resolve} from 'resolve-protobuf-schema'; import Pbf from '../index.js'; import {compile, compileRaw} from '../compile.js'; -test('compiles all proto files to proper js', () => { - const files = fs.readdirSync(new URL('fixtures', import.meta.url)); +function testProtoCompilation(options, extension, description) { + test(description, () => { + const files = fs.readdirSync(new URL('fixtures', import.meta.url)); - for (const path of files) { - if (!path.endsWith('.proto')) continue; - const proto = resolve(new URL(`fixtures/${path}`, import.meta.url)); - const js = compileRaw(proto, {dev: true}); + for (const path of files) { + if (!path.endsWith('.proto')) continue; + const proto = resolve(new URL(`fixtures/${path}`, import.meta.url)); + const code = compileRaw(proto, options); - // uncomment to update the fixtures - // fs.writeFileSync(new URL(`fixtures/${path}`.replace('.proto', '.js'), import.meta.url), js); + // uncomment to update the fixtures + // fs.writeFileSync(new URL(`fixtures/${path}`.replace('.proto', extension), import.meta.url), code); - const expectedJS = fs.readFileSync(new URL(`fixtures/${path}`.replace('.proto', '.js'), import.meta.url), 'utf8'); - assert.equal(js, expectedJS); - } -}); + const expectedCode = fs.readFileSync(new URL(`fixtures/${path}`.replace('.proto', extension), import.meta.url), 'utf8'); + assert.equal(code, expectedCode); + } + }); +} + +testProtoCompilation({dev: true}, '.js', 'compiles all proto files to proper js'); +testProtoCompilation({dev: true, tsDeclarations: true}, '.d.ts', 'compiles all proto files to TypeScript declarations'); test('compiles vector tile proto', () => { const proto = resolve(new URL('fixtures/vector_tile.proto', import.meta.url)); diff --git a/test/fixtures/defaults.d.ts b/test/fixtures/defaults.d.ts new file mode 100644 index 0000000..9707b78 --- /dev/null +++ b/test/fixtures/defaults.d.ts @@ -0,0 +1,16 @@ +export const MessageType: { + readonly UNKNOWN: 0; + readonly GREETING: 1; +}; + + +export interface Envelope { + type?: number | null; + name?: string | null; + flag?: boolean | null; + weight?: number | null; + id?: number | null; +} + +export function readEnvelope(pbf: any, end?: number): Envelope; +export function writeEnvelope(obj: Envelope, pbf: any): void; diff --git a/test/fixtures/defaults_implicit.d.ts b/test/fixtures/defaults_implicit.d.ts new file mode 100644 index 0000000..1c03472 --- /dev/null +++ b/test/fixtures/defaults_implicit.d.ts @@ -0,0 +1,27 @@ +export const MessageType: { + readonly UNKNOWN: 0; + readonly GREETING: 1; +}; + + +export interface CustomType { +} + +export function readCustomType(pbf: any, end?: number): CustomType; +export function writeCustomType(obj: CustomType, pbf: any): void; + +export interface Envelope { + type?: number | null; + name?: string | null; + flag?: boolean | null; + weight?: number | null; + id?: number | null; + tags: string[]; + numbers: number[]; + bytes?: Uint8Array | null; + custom?: CustomType | null; + types: number[]; +} + +export function readEnvelope(pbf: any, end?: number): Envelope; +export function writeEnvelope(obj: Envelope, pbf: any): void; diff --git a/test/fixtures/defaults_proto3.d.ts b/test/fixtures/defaults_proto3.d.ts new file mode 100644 index 0000000..9707b78 --- /dev/null +++ b/test/fixtures/defaults_proto3.d.ts @@ -0,0 +1,16 @@ +export const MessageType: { + readonly UNKNOWN: 0; + readonly GREETING: 1; +}; + + +export interface Envelope { + type?: number | null; + name?: string | null; + flag?: boolean | null; + weight?: number | null; + id?: number | null; +} + +export function readEnvelope(pbf: any, end?: number): Envelope; +export function writeEnvelope(obj: Envelope, pbf: any): void; diff --git a/test/fixtures/embedded_type.d.ts b/test/fixtures/embedded_type.d.ts new file mode 100644 index 0000000..1d64244 --- /dev/null +++ b/test/fixtures/embedded_type.d.ts @@ -0,0 +1,23 @@ + +export interface EmbeddedType { + value?: string | null; + sub_field?: EmbeddedTypeContainer | null; + sub_sub_field?: EmbeddedTypeContainerInner | null; +} + +export function readEmbeddedType(pbf: any, end?: number): EmbeddedType; +export function writeEmbeddedType(obj: EmbeddedType, pbf: any): void; + +export interface EmbeddedTypeContainer { + values: EmbeddedTypeContainerInner[]; +} + +export function readEmbeddedTypeContainer(pbf: any, end?: number): EmbeddedTypeContainer; +export function writeEmbeddedTypeContainer(obj: EmbeddedTypeContainer, pbf: any): void; + +export interface EmbeddedTypeContainerInner { + value?: string | null; +} + +export function readEmbeddedTypeContainerInner(pbf: any, end?: number): EmbeddedTypeContainerInner; +export function writeEmbeddedTypeContainerInner(obj: EmbeddedTypeContainerInner, pbf: any): void; diff --git a/test/fixtures/map.d.ts b/test/fixtures/map.d.ts new file mode 100644 index 0000000..9642df1 --- /dev/null +++ b/test/fixtures/map.d.ts @@ -0,0 +1,24 @@ + +export interface Envelope { + kv?: { [key: string]: string } | null; + kn?: { [key: string]: number } | null; +} + +export function readEnvelope(pbf: any, end?: number): Envelope; +export function writeEnvelope(obj: Envelope, pbf: any): void; + +export interface Envelope_FieldEntry1 { + key?: string | null; + value?: string | null; +} + +export function readEnvelope_FieldEntry1(pbf: any, end?: number): Envelope_FieldEntry1; +export function writeEnvelope_FieldEntry1(obj: Envelope_FieldEntry1, pbf: any): void; + +export interface Envelope_FieldEntry2 { + key?: string | null; + value?: number | null; +} + +export function readEnvelope_FieldEntry2(pbf: any, end?: number): Envelope_FieldEntry2; +export function writeEnvelope_FieldEntry2(obj: Envelope_FieldEntry2, pbf: any): void; diff --git a/test/fixtures/oneof.d.ts b/test/fixtures/oneof.d.ts new file mode 100644 index 0000000..9b4a155 --- /dev/null +++ b/test/fixtures/oneof.d.ts @@ -0,0 +1,11 @@ + +export interface Envelope { + id?: number | null; + int?: number | null; + float?: number | null; + string?: string | null; + value?: "int" | "float" | "string"; +} + +export function readEnvelope(pbf: any, end?: number): Envelope; +export function writeEnvelope(obj: Envelope, pbf: any): void; diff --git a/test/fixtures/packed.d.ts b/test/fixtures/packed.d.ts new file mode 100644 index 0000000..412fcf9 --- /dev/null +++ b/test/fixtures/packed.d.ts @@ -0,0 +1,24 @@ + +export interface NotPacked { + value: number[]; + types: number[]; +} + +export function readNotPacked(pbf: any, end?: number): NotPacked; +export function writeNotPacked(obj: NotPacked, pbf: any): void; + +export interface FalsePacked { + value: number[]; + types: number[]; +} + +export function readFalsePacked(pbf: any, end?: number): FalsePacked; +export function writeFalsePacked(obj: FalsePacked, pbf: any): void; + +export interface Packed { + value: number[]; + types: number[]; +} + +export function readPacked(pbf: any, end?: number): Packed; +export function writePacked(obj: Packed, pbf: any): void; diff --git a/test/fixtures/packed_proto3.d.ts b/test/fixtures/packed_proto3.d.ts new file mode 100644 index 0000000..58c170c --- /dev/null +++ b/test/fixtures/packed_proto3.d.ts @@ -0,0 +1,28 @@ +export const MessageType: { + readonly UNKNOWN: 0; + readonly GREETING: 1; +}; + + +export interface NotPacked { + value: number[]; + types: number[]; +} + +export function readNotPacked(pbf: any, end?: number): NotPacked; +export function writeNotPacked(obj: NotPacked, pbf: any): void; + +export interface FalsePacked { + value: number[]; + types: number[]; +} + +export function readFalsePacked(pbf: any, end?: number): FalsePacked; +export function writeFalsePacked(obj: FalsePacked, pbf: any): void; + +export interface Packed { + value: number[]; +} + +export function readPacked(pbf: any, end?: number): Packed; +export function writePacked(obj: Packed, pbf: any): void; diff --git a/test/fixtures/type_string.d.ts b/test/fixtures/type_string.d.ts new file mode 100644 index 0000000..8db5c07 --- /dev/null +++ b/test/fixtures/type_string.d.ts @@ -0,0 +1,22 @@ + +export interface TypeString { + int?: string | null; + long?: string | null; + boolVal?: boolean | null; + float?: string | null; + default_implicit?: string | null; + default_explicit?: string | null; +} + +export function readTypeString(pbf: any, end?: number): TypeString; +export function writeTypeString(obj: TypeString, pbf: any): void; + +export interface TypeNotString { + int?: number | null; + long?: number | null; + boolVal?: boolean | null; + float?: number | null; +} + +export function readTypeNotString(pbf: any, end?: number): TypeNotString; +export function writeTypeNotString(obj: TypeNotString, pbf: any): void; diff --git a/test/fixtures/varint.d.ts b/test/fixtures/varint.d.ts new file mode 100644 index 0000000..a622faa --- /dev/null +++ b/test/fixtures/varint.d.ts @@ -0,0 +1,10 @@ + +export interface Envelope { + int?: number | null; + uint?: number | null; + long?: number | null; + ulong?: number | null; +} + +export function readEnvelope(pbf: any, end?: number): Envelope; +export function writeEnvelope(obj: Envelope, pbf: any): void; diff --git a/test/fixtures/vector_tile.d.ts b/test/fixtures/vector_tile.d.ts new file mode 100644 index 0000000..27356ae --- /dev/null +++ b/test/fixtures/vector_tile.d.ts @@ -0,0 +1,49 @@ + +export interface Tile { + layers: TileLayer[]; +} + +export function readTile(pbf: any, end?: number): Tile; +export function writeTile(obj: Tile, pbf: any): void; +export const TileGeomType: { + readonly UNKNOWN: 0; + readonly POINT: 1; + readonly LINESTRING: 2; + readonly POLYGON: 3; +}; + + +export interface TileValue { + string_value?: string | null; + float_value?: number | null; + double_value?: number | null; + int_value?: number | null; + uint_value?: number | null; + sint_value?: number | null; + bool_value?: boolean | null; +} + +export function readTileValue(pbf: any, end?: number): TileValue; +export function writeTileValue(obj: TileValue, pbf: any): void; + +export interface TileFeature { + id?: number | null; + tags: number[]; + type?: number | null; + geometry: number[]; +} + +export function readTileFeature(pbf: any, end?: number): TileFeature; +export function writeTileFeature(obj: TileFeature, pbf: any): void; + +export interface TileLayer { + version?: number | null; + name?: string | null; + features: TileFeature[]; + keys: string[]; + values: TileValue[]; + extent?: number | null; +} + +export function readTileLayer(pbf: any, end?: number): TileLayer; +export function writeTileLayer(obj: TileLayer, pbf: any): void;