Skip to content

Provide support for top-level exports #455

Open
@smaye81

Description

This is to open discussion about the implementation of top-level exports when generating JS/TS code via the plugin framework.

Problem

Specifically, what we're exploring is that given the following:

package foo;

message Foo {
  string foo_field = 1;
}
package bar;

message Bar {
  int32 bar_field = 1;
}

We would provide the option of generating:

.
├── index.js
├── package.json
└── proto
    ├── foo
    │   ├── foo_pb.d.ts
    │   └── foo_pb.js
    └── bar
        ├── bar_pb.d.ts
        └── bar_pb.js

where index.js looks something like:

export { Foo } from "./proto/foo/foo_pb.js";
export { Bar } from "./proto/bar/bar_pb.js";

This functionality would ostensibly be opt-in through a plugin option of something like generateIndex or something similar.

Benefits

This would provide a few benefits (note that below benefits would also require associated changes to / creation of a package.json file.

  • When using remote packages, it would allow users to write the following import:

    import { Foo } from "@buf/bufbuild_foo.bufbuild_es";

    instead of

    import { Foo } from "@buf/bufbuild_foo.bufbuild_es/proto/foo/foo_pb.js"
  • It could help facilitate auto-imports in IDEs like VSCode.

  • It would make it a lot easier for users to use tools like Intellisense to see exports.

Difficulties

There are difficulties associated with this however. The biggest being how we handle name clashes with exported symbols.

Since Protobuf has namespaces, it is perfectly acceptable to have two messages (or services or enums) with the same name in different namespaces. For example, consider:

package foo.v1;

message Foo {
  string foo_field = 1;
}
package foo.v2;

message Foo {
  int32 bar_field = 1;
}

Now the generation of the index.js file becomes much more complicated because this wouldn't work:

export { Foo } from "./proto/foo/v1/foo_pb.js";
export { Foo } from "./proto/foo/v2/foo_pb.js";

So we would have to somehow alias one of these exports to something like:

export { Foo } from "./proto/foo/v1/foo_pb.js";
export { Foo as v2_Foo } from "./proto/foo/v2/foo_pb.js";

Even this solution is not straightforward and is further complicated by the following:

  • The order is mostly predictable (at least with Buf) that files are stable-sorted and passed to the plugin in the same order. However, it is not guaranteed across all codebases and compilers and is not part of the plugin contract. This means that, while probably rare, the potential is there to alias a completely different export between compilation runs.
  • Even with Buf's predictability, this is only effective using the strategy option during generation. This means if we provide this option it would have to be with the caveat to always make sure strategy is set to all, which is not a great developer experience.
  • Users would potentially have no way of knowing after generation that symbols have been aliased and further, which ones were aliased. So, simply importing Foo from their package may not give them the intended Foo.
  • If this option is allowed, then it would probably also have to be allowed for other plugins, such as Connect-ES so that service definitions would also be exported. This means that both plugins will generate an index.js file and preventing the conflicts / collisions that would ensue will not be easy.
  • Alias conventions could become very ugly. Ideally, we would have to use the fully-qualified path to properly disambiguate across packages, but this could snowball for deeply nested packages. Consider package proto.orders.clients.foo.bar.v1;. This could cause the alias to be something like proto_orders_client_foo_bar_v1_FooClient.
  • As mentioned above, these changes also require an accompanying package.json to declare types and main/exports.

Potential Solutions

Note that all of these solutions only apply when the option to generate top-level exports is true.

  1. Alias all the things all the time, using the fully-qualified package name as the alias.

  2. Only alias when a conflict occurs and further, only alias the conflict using the smallest possible package path (see example above with v2_Foo.

  3. Throw an error at generation time if a conflict is detected, alerting the user which types conflict. (this is probably not a great idea, but listed here for visibility).

  4. Only generate top-level exports within a package. This would eliminate the conflict issue altogether as exported symbols within a package are guaranteed not to clash. However, as one might have guessed, this also has its problems:

    • This would need subpath exports defined in package.json, not just a plain exports field since we would end up with multiple entrypoints per path. We would also most likely have to specify types fields as well in this subpath export map.
    • Subpath patterns may or may not be possible here, we would have to test different versions of TypeScript, Node.js, and popular bundlers to get some confidence that it works well enough.
    • This will only work for ESM generated code, not for CJS code that the protocolbuffers/js and grpc/web plugins generate.
    • Since npm artifacts are expected to be stable (i.e. not change without a version bump), we have to introduce this feature in remote packages by bumping the version number in the registry URL (https://buf.build/gen/npm/v1/). If we discover an incompatibility later, we have to bump the registry URL version again for a fix or rollback.
    • All this really provides is that users would no longer need the filename in the import path, which seems like a small ROI. So
      import { Foo } from "@buf/bufbuild_foo.bufbuild_es/proto/foo"
      instead of
      import { Foo } from "@buf/bufbuild_foo.bufbuild_es/proto/foo/foo_pb.js"

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions