pw_protobuf_compiler: TS library

The Library class builds from a FileDescriptorSet binary and makes it
easier to find and manage proto modules.

Tested with 'bazel test //pw_protobuf_compiler/ts:proto_lib_test'

Bug: b/194329554
Change-Id: I6bb065a94be17619e3ed5f46708aeeba323dc322
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/59540
Pigweed-Auto-Submit: Jared Weinstein <jaredweinstein@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
Reviewed-by: Paul Mathieu <paulmathieu@google.com>
diff --git a/pw_protobuf_compiler/BUILD.bazel b/pw_protobuf_compiler/BUILD.bazel
index 4115f07..045285e 100644
--- a/pw_protobuf_compiler/BUILD.bazel
+++ b/pw_protobuf_compiler/BUILD.bazel
@@ -12,6 +12,7 @@
 # License for the specific language governing permissions and limitations under
 # the License.
 load("@com_google_protobuf//:protobuf.bzl", "py_proto_library")
+load("@rules_proto_grpc//js:defs.bzl", "js_proto_library")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -30,3 +31,16 @@
         "pw_protobuf_compiler_protos/test.proto",
     ],
 )
+
+proto_library(
+    name = "test_protos",
+    srcs = [
+        "pw_protobuf_compiler_protos/nested/more_nesting/test.proto",
+        "pw_protobuf_compiler_protos/test.proto",
+    ],
+)
+
+js_proto_library(
+    name = "test_protos_tspb",
+    protos = ["//pw_protobuf_compiler:test_protos"],
+)
diff --git a/pw_protobuf_compiler/docs.rst b/pw_protobuf_compiler/docs.rst
index 54e633d..3712df5 100644
--- a/pw_protobuf_compiler/docs.rst
+++ b/pw_protobuf_compiler/docs.rst
@@ -30,6 +30,9 @@
 | Python      | ``python``     | Compiles using the standard Python protobuf   |
 |             |                | plugin, creating a ``pw_python_package``.     |
 +-------------+----------------+-----------------------------------------------+
+| Typescript  | ``typescript`` | Compilation is supported in Bazel via         |
+|             |                | @rules_proto_grpc.                            |
++-------------+----------------+-----------------------------------------------+
 
 GN template
 ===========
diff --git a/pw_protobuf_compiler/ts/BUILD.bazel b/pw_protobuf_compiler/ts/BUILD.bazel
new file mode 100644
index 0000000..48b7a4b
--- /dev/null
+++ b/pw_protobuf_compiler/ts/BUILD.bazel
@@ -0,0 +1,53 @@
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test")
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_library(
+    name = "proto_lib",
+    srcs = ["proto_lib.ts"],
+    module_name = "pigweed/pw_protobuf_compiler/ts",
+    deps = [
+        "//pw_rpc:packet_proto_tspb",
+        "@npm//@types/google-protobuf",
+        "@npm//@types/node",
+    ],
+)
+
+ts_library(
+    name = "proto_lib_test_lib",
+    srcs = [
+        "proto_lib_test.ts",
+    ],
+    data = [
+        "//pw_protobuf_compiler:test_protos",
+    ],
+    deps = [
+        ":proto_lib",
+        "//pw_protobuf_compiler:test_protos_tspb",
+        "//pw_rpc:packet_proto_tspb",
+        "@npm//@types/google-protobuf",
+        "@npm//@types/jasmine",
+    ],
+)
+
+jasmine_node_test(
+    name = "proto_lib_test",
+    srcs = [
+        ":proto_lib_test_lib",
+    ],
+)
diff --git a/pw_protobuf_compiler/ts/proto_lib.ts b/pw_protobuf_compiler/ts/proto_lib.ts
new file mode 100644
index 0000000..8af431e
--- /dev/null
+++ b/pw_protobuf_compiler/ts/proto_lib.ts
@@ -0,0 +1,122 @@
+// Copyright 2021 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+/** Tools for compiling and importing Javascript protos on the fly. */
+
+import * as fs from 'fs';
+import {Message} from 'google-protobuf';
+import {FileDescriptorSet} from 'google-protobuf/google/protobuf/descriptor_pb';
+
+export type MessageCreator = new () => Message;
+class MessageMap extends Map<string, MessageCreator> {}
+class ModuleMap extends Map<string, any> {}
+
+/**
+ * A collection of protocol buffer modules
+ */
+export class Library {
+  private fileDescriptorSet: FileDescriptorSet;
+  private messages: MessageMap;
+
+  /**
+   * Primary way to construct a Library.
+   *
+   * For example,
+   * ```
+   * const path = 'pw_rpc/ts/test_protos-descriptor-set.proto.bin'
+   * const lib = Library.fromFileDescriptorSet(path, 'test_protos_tspb');
+   * ```
+   * The example path is for a proto_library bazel rule named 'test_protos'.
+   *
+   * @param path String location of the FileDescriptorSet binary.
+   * @param protoLibName: String name for the js_proto_library dependency. This
+   * is used to map from fileDescriptor name to compiled proto path.
+   */
+  static async fromFileDescriptorSet(path: string, protoLibName: string):
+      Promise<Library> {
+    const binary = fs.readFileSync(path);
+    const fileDescriptorSet = FileDescriptorSet.deserializeBinary(binary);
+
+    let mods = new ModuleMap();
+    for (const file of fileDescriptorSet.getFileList()) {
+      const moduleName = this.buildModuleName(file.getName()!, protoLibName);
+      let mod = await import(moduleName);
+      const key = file.getName()!;
+      mods.set(key, mod);
+    }
+
+    return new Library(fileDescriptorSet, mods)
+  }
+
+  /**
+   * Maps from a file and js_proto_library name to a module path.
+   *
+   * For example the following rules would correspond with the call
+   * `buildModuleName('dir/test.proto', 'test_proto_tspb')`
+   *
+   *  proto_library(
+   *      name = "test_proto",
+   *      srcs = [
+   *          "dir/test.proto",
+   *      ],
+   *  )
+
+   *  js_proto_library(
+   *      name = "test_proto_tspb",
+   *      protos = [":test_protos"],
+   *  )
+   *
+   * @param {string} fileName Proto file in the form 'root/dir/test.proto'
+   * @param {string} protoLibName Name attribute of the relevant
+   *   js_proto_library rule.
+   */
+  private static buildModuleName(fileName: string, protoLibName: string):
+      string {
+    const name = `${protoLibName}/${protoLibName}_pb/${fileName}`;
+    return name.replace(/\.proto$/, '_pb');
+  }
+
+  constructor(set: FileDescriptorSet, mods: ModuleMap) {
+    this.fileDescriptorSet = set;
+    this.messages = this.mapMessages(set, mods);
+  }
+
+  /**
+   * Creates a map between message identifier "{packageName}.{messageName}"
+   * and the Message class.
+   */
+  private mapMessages(set: FileDescriptorSet, mods: ModuleMap): MessageMap {
+    const messages = new MessageMap();
+    for (const fileDescriptor of set.getFileList()) {
+      const mod = mods.get(fileDescriptor.getName()!)!;
+      for (const messageType of fileDescriptor.getMessageTypeList()) {
+        const fullName =
+            fileDescriptor.getPackage()! + '.' + messageType.getName();
+        const message = mod[messageType.getName()!];
+        messages.set(fullName, message);
+      }
+    }
+    return messages;
+  }
+
+  /**
+   * Finds the Message class referenced by the identifier.
+   *
+   *  @param identifier String identifier of the form
+   *  "{packageName}.{messageName}" i.e: "pw.rpc.test.NewMessage".
+   */
+  getMessageCreator(identifier: string): MessageCreator|undefined {
+    return this.messages.get(identifier);
+  }
+}
diff --git a/pw_protobuf_compiler/ts/proto_lib_test.ts b/pw_protobuf_compiler/ts/proto_lib_test.ts
new file mode 100644
index 0000000..6a917f5
--- /dev/null
+++ b/pw_protobuf_compiler/ts/proto_lib_test.ts
@@ -0,0 +1,52 @@
+// Copyright 2021 The Pigweed Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+/* eslint-env browser, jasmine */
+import 'jasmine';
+
+import {Message} from 'test_protos_tspb/test_protos_tspb_pb/pw_protobuf_compiler/pw_protobuf_compiler_protos/nested/more_nesting/test_pb';
+
+import {Library} from './proto_lib';
+
+const DESCRIPTOR_BINARY_PATH =
+    'pw_protobuf_compiler/test_protos-descriptor-set.proto.bin';
+const TEST_LIB_NAME = 'test_protos_tspb';
+
+describe('Library', () => {
+  it('constructed from invalid paths rejects promise', async () => {
+    await expectAsync(Library.fromFileDescriptorSet('garbage', TEST_LIB_NAME))
+        .toBeRejected();
+    await expectAsync(
+        Library.fromFileDescriptorSet(DESCRIPTOR_BINARY_PATH, 'garbage'))
+        .toBeRejected();
+  });
+
+  it('getMessageType returns message', async () => {
+    const lib = await Library.fromFileDescriptorSet(
+        DESCRIPTOR_BINARY_PATH, TEST_LIB_NAME);
+
+    let fetched = lib.getMessageCreator('pw.protobuf_compiler.test.Message');
+    expect(fetched).toEqual(Message);
+  });
+
+  it('getMessageType for invalid identifier returns undefined', async () => {
+    const lib = await Library.fromFileDescriptorSet(
+        DESCRIPTOR_BINARY_PATH, TEST_LIB_NAME);
+
+    let fetched = lib.getMessageCreator('pw');
+    expect(fetched).toBeUndefined();
+    fetched = lib.getMessageCreator('pw.test1.Garbage');
+    expect(fetched).toBeUndefined();
+  });
+});