Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FR]: Standardized support for non-relative import paths of libraries #706

Open
1 task
gonzojive opened this issue Dec 10, 2022 · 22 comments
Open
1 task
Labels
enhancement New feature or request

Comments

@gonzojive
Copy link
Contributor

gonzojive commented Dec 10, 2022

What is the current behavior?

As far as I know, JavaScript doesn't standardize a way to associate a fully qualified import path with a module. (Unlike Go and Java.) However, having a canonical, non-relative import path for libraries is useful, and JS-related tools find a way to support this.

Different tools (node, TypeScript, browsers?) seem to have different ways of being configured to handle "absolute" imports. For example, tsconfig.json has a paths option that can be used. esbuild I believe supports the tsconfig.json paths.

It is currently up to the rules_js user to manipulate the configuration objects passed to bazel-run tools (esbuild, tsc, node, ...) and the IDE such that global imports work correctly.

Describe the feature

Should associating an absolute import path with a module or set of modules be given some sort of standardized support by rules_js?

I'm looking for something like rules_go's import_path arg. I want to have a TypeScript file in a project called "lib.ts" (with corresponding js file "lib.js"). I would like to import "lib.js" using an import path of my choosing, such as "@proj/foo/lib". @proj/foo may have no relationship to my workspace name.

My actual use case is so I can generate protobuf code that is imported like import {Timestamp} from "@protos/google/type/timetamp_pb.js" using a build rule like

ts_proto_library(
  name = "timestamp",
  proto = "@com_google_protobuf//:timestamp_proto",
  import_path = "@protos/google/type/timestamp_pb",
)

ts_proto_library would be either a rule or a macro. One of the outputs of the rule would be something that keeps track of this global "@protos/google/type/timestamp_pb" module name and its association with some generated JS/TS files. When tools are called, they would be made aware of the import mappings within all transitive dependencies.

I'm not sure the recommended way to do this is, if it is not recommended, or what. Perhaps there is a way to do this using npm_package.

Fund our work

@gonzojive gonzojive added the enhancement New feature or request label Dec 10, 2022
@gregmagolan
Copy link
Member

gregmagolan commented Dec 11, 2022

The difficultly with a standardized absolute import path in the Node.js ecosystem is that if it is not something handled by Node.js, then each tool would have to be taught how to understand it which is not scalable. Even if some support could be added to Node.js for a standard import mapping, the JavaScript ecosystem is already starting to migrate to tools build with golang (esbuild) & rust (swc, turbopack, rome) for better build times so those tools would also have to be taught how to understand the mapping as well.

The simplest way to make standardized imports that all tools understand is to link 1p packages into node_modules with pnpm workspaces. You eluded to this with your reference to npm_package. We have a few examples of this approach in rules_js and in our bazel examples.

Here, for example, first party packages under the packages/* directory are configured to be imported as @bazel-poc/*.

@alexeagle
Copy link
Member

@gonzojive does that make sense as being out-of-scope for rules_js? We don't want to invent a new semantic that's Bazel-specific.

@gonzojive
Copy link
Contributor Author

The difficultly with a standardized absolute import path in the Node.js ecosystem is that if it is not something handled by Node.js, then each tool would have to be taught how to understand it which is not scalable. Even if some support could be added to Node.js for a standard import mapping, the JavaScript ecosystem is already starting to migrate to tools build with golang (esbuild) & rust (swc, turbopack, rome) for better build times so those tools would also have to be taught how to understand the mapping as well.

The support I'm after already appears to be part of tsconfig.json through the "paths" attribute, which is respected by esbuild and tsc. rules_js would probably need to modify the tsconfig.json fed to those tools to populate paths appropriately.

@gonzojive does that make sense as being out-of-scope for rules_js? We don't want to invent a new semantic that's Bazel-specific.

It's understandable. I will probably end up writing wrappers around the rules to make this easier, though. Also, I'm not sure of a good strategy for how to write proto rules on top of vanilla rules_ts. What would you do?

To control the import path of the protos, it seems each js_proto_library would need to create an NPM package, and clients would depend on that NPM package somehow.

Here, for example, first party packages under the packages/* directory are configured to be imported as @bazel-poc/*.

Thanks for the link. I will try this out more. I find it pretty confusing. Some notes:

  1. Without any explicit reference to //packages/one:one_ts (a ts_library) or //packages/one:one (an npm_package), it is hard to figure out what depends on the 'one' library and how. I think the bazel dependencies are coming from the pnpm-workspace.yaml contents, which includes a glob pattern.
  2. When depending on ts_project d from another ts_project, app, I simply add d to the deps list of app. If I want to import d with an absolute import style, however, the process is radically different: setting up pnpm_workspaces and understanding the syntax, declaring an npm_package for d, adding d to the packages.json file in the root, and depending on //:node_modules/@path/to/d instead of //path/to/d directly.

@gonzojive
Copy link
Contributor Author

gonzojive commented Dec 31, 2022

Some notes to myself on how to implement the ts_proto_library case:

It seems the way the linked example works relies on explicit package.json files in the subdirectories. When certain pnpm subcommands are run (TODO: which?) on the file system as normal (without bazel awareness), pnpm observes the package.json files and updates the lock file according to the workspace semantics of pnpm.

In my ts_proto_library example, I would need the macro/rule for ts_proto_library to generate the NPM package. Presumably this would prevent pnpm from picking up on the package and updating the lockfile accordingly. Which means the below starlark code needs to operate a bit differently from the example with the direct package.json files:

ts_proto_library(
  name = "clock_proto_ts",
  proto = "clock_proto",
  import_path = "@protos/my_app/clock_pb",
  deps = [
    # Dependency on some other ts_proto_library that
    # carries information about how protocol buffer language
    # imports corresponds to TypeScript language imports.
    "//x/y/z:timestamp_proto_ts"
  ],
)

Presumably an npm_package needs to be generated, and then some other stuff needs to happen to use that npm package elsewhere, probably using some of the npm_link* rules described here.


Edit 1:

I'm guessing I'll need to generate both an npm_package and an npm_link_package_store rule for the ts_proto_library call. It seems this would be done by calling the npm_link_package macro. This is based on reading the docs and inspecting

$ bazel query --output label_kind "//:node_modules/inspirational-quotes"

output:

Loading: 0 packages loaded
npm_link_package_store rule //:node_modules/inspirational-quotes

So clock_proto_ts will turn into something like:

_ts_proto_library_rule(
  name = "clock_proto_ts",
  proto = ":clock_proto",
  ts_import_path = "@protos/my_app/clock_pb",
  output = "clock_proto_ts_generated.ts",
)

npm_package(
  name = "clock_proto_ts_npm_package",
  package = "@protos/my_app/clock_pb",
  srcs = [
        "clock_proto_ts_generated_package.json",
        "clock_proto_ts_generated.ts",
    ],
  deps = [
    # deps extracted from "//x/y/z:timestamp_proto_ts",
  ]
)

npm_link_package(
  # Should name be "node_modules/@protos/my_app/clock_pb"?
  # https://github.com/search?q=npm_link_package&type=code
  name = "WHAT GOES HERE??",
  src = "clock_proto_ts_npm_package",
  auto_manual = False,
)

Users of the library would then depend on ":WHAT GOES HERE??" presumably, which would be an npm_link_package_store target just like //:node_modules/inspirational-quotes.


Edit 3: Except the npm_link_package above doesn't work when it's not in the root of workspace. So there would be no way for the ts_proto_library macro to generate an appropriate call to npm_link_package.

Aside: the documentation for the root_package attribute of npm_link_package is confusing. It is a bazel package, not an NPM package. It also doesn't explain the importance of the attribute. The npm_link_package docs has other jargon that makes it hard to understand like "virtual store target." The examples section is empty, so searching github was necessary to figure out how to use it. The explanation, "the root package where the node_modules virtual store is linked to" doesn't make much sense to me. What does "linked" mean here?

gonzojive added a commit to gonzojive/rules_js_examples that referenced this issue Dec 31, 2022
@gonzojive
Copy link
Contributor Author

gonzojive commented Dec 31, 2022

See pnpm-workspaces/apps/alpha/src/main.ts in my attempt to get a prototype of the above to work. It seems pretty standard to put all the npm_link_package calls in the root of the repository. Why is that? I would prefer for a library just depend directly on an npm package declared in some random directory. (This is how dependencies work in most languages for Bazel.) If every npm package needs to be declared in the root, it doesn't scale well for a big monorepo and requires editing two BUILD files for every new library.

@alexeagle
Copy link
Member

@gregmagolan and I are discussing what feels like a missing documentation page on "linking". By the time you've gotten to the API doc for npm_link_package I agree it's down the deep end of terminology and complexity.

"Linking" is an npm concept https://docs.npmjs.com/cli/v8/commands/npm-link
which is used by other JS monorepo tools (e.g. lerna https://github.com/lerna/lerna/tree/main/commands/link#readme rush https://rushjs.io/pages/commands/rush_link/)
It just means there has to be a node_modules tree into which the dependency can be symlinked. Thus it has to be at the root of one of your npm packages (next to a package.json file) but not necessarily in the repo root.

rules_js doesn't require that you link dependencies, as you observed you can also just use TS pathmapping support (which was originally added to the language for google3 !). I'm not sure what you mean

rules_js would probably need to modify the tsconfig.json fed to those tools

since the tsconfig.json file is written by the user and not generated.

We plan to add a TypeScript + Proto example and possibly a rule, and have discussed design, but so far don't have any funding to work on that. Instead of writing your own, maybe you could donate a feature bounty so we could provide one for everyone?

@gonzojive
Copy link
Contributor Author

gonzojive commented Dec 31, 2022

We plan to add a TypeScript + Proto example and possibly a rule, and have discussed design, but so far don't have any funding to work on that. Instead of writing your own, maybe you could donate a feature bounty so we could provide one for everyone?

Working on it.

It just means there has to be a node_modules tree into which the dependency can be symlinked. Thus it has to be at the root of one of your npm packages (next to a package.json file) but not necessarily in the repo root.

What is stopping the node_modules trees from being constructed based on the deps of any particular target? Could something like this be supported?

# Suppose this is at <repo-root>/a/b/BUILD.bazel
npm_package(
  name = "bazely_lib",
  package = "@foo/bar",
  srcs = [
    "main.js",
  ],
  package_json = "my_package.json",
)

js_library(
  name = "script",
  srcs = ["script.js"],
  deps = [
    ":bazely_lib",
  ],
)

When invoked on bazely_lib, the esbuild rule (or tsc rule) would want a directory structure like

a/b/
  main.js
node_modules/@foo/bar/ # or a/b/node_modules/@foo/bar/
  main.js
  package.json

Which it seems like it could construct by analyzing the deps of "script".

@gregmagolan
Copy link
Member

gregmagolan commented Dec 31, 2022

The node_modules tree is made up of output artifacts (tree artifacts & symlinks) that make up the symlinked node_modules trees. There is quite a bit of complication in the npm link rules to construct these trees, but in theory the syntax sugar you have in your example is possible and the links could be automatically added to the same package as the target (rules cannot output to other bazel packages) for any npm_package targets in the deps with the caveat that bazely_lib also needs to be linked into the virtual store which is typically in the package that the pnpm lock file is. See https://pnpm.io/symlinked-node-modules-structure for more info on how the virtual store relates to node_modules links.

The virtual store requirement does make the magic of this syntax sugar less attractive since it doesn't stand on its own. /a/b/node_modules/@foo/bar is just a symlink to the package's location in the /node_modules/.aspect_rules_js/... virtual store which means that there also needs to be a target in the root package to create the virtual store output for the @foo/bar package. The inconsistency of how to dep on 3rd party npm package and first party npm packages may not be ideal either since

  deps = [
    ":bazely_lib",
    ":node_modules/some_package",
  ],

may be less clear than

  deps = [
    ":node_modules/@foo/bar",
    ":node_modules/some_package",
  ],

in terms of what you're actually depending on, which is node_modules links.

@gonzojive
Copy link
Contributor Author

gonzojive commented Dec 31, 2022

Thanks for all the explanations. Hopefully this helps others, too.

To be clear, to make the snippet from #706 (comment) work today, something like the following npm_link_package is also needed either in the bazely_lib package or a parent directory:

npm_package(
  name = "bazely_lib",
  package = "@foo/bar",
  srcs = [
    "main.js",
  ],
  package_json = "my_package.json",
)

npm_link_package(
    name = "node_modules/@foo/bar",
    src = ":bazely_lib",
    root_package = package_name(),
)

js_library(
  name = "script",
  srcs = ["script.js"],
  deps = [
    ":node_modules/@foo/bar",
  ],
)

I suppose what I don't see is why the npm_link_package is really necessary or desired. I suppose the current approach helps when you want the bazel target names to match the directory structure seen by the tools? (e.g., the target name "//a/b:node_modules/@foo/bar" matches the directory bazel-bin/a/b/node_modules/@foo/bar?)

Could rules_js be modified to support depending directly on npm_packages? Rules like the esbuild one could parse dependencies of type "npm_package" while preparing the input files for the tool action. Presumably that would involve changes to js_lib_helpers.gather_files_from_js_providers:

    # excerpt from esbuild rule...
    input_sources = depset(
        copy_files_to_bin_actions(ctx, [
            file
            for file in ctx.files.srcs + filter_files(entry_points)
            if not (file.path.endswith(".d.ts") or file.path.endswith(".tsbuildinfo"))
        ]) + other_inputs + node_toolinfo.tool_files + esbuild_toolinfo.tool_files,
        transitive = [js_lib_helpers.gather_files_from_js_providers(
            targets = ctx.attr.srcs + ctx.attr.deps,
            include_transitive_sources = True,
            include_declarations = False,
            include_npm_linked_packages = True,
        )],
    )

    launcher = ctx.executable.launcher or esbuild_toolinfo.launcher.files_to_run
    ctx.actions.run(
        inputs = input_sources,
        outputs = output_sources,
        arguments = [launcher_args],
        progress_message = "%s Javascript %s [esbuild]" % ("Bundling" if not ctx.attr.output_dir else "Splitting", " ".join([entry_point.short_path for entry_point in entry_points])),
        execution_requirements = execution_requirements,
        mnemonic = "esbuild",
        env = env,
        executable = launcher,
    )

@gregmagolan
Copy link
Member

gregmagolan commented Jan 1, 2023

Setting the root_package = package_name() will create a virtual store in that Bazel package since root_package sets where the virtual store is. If you do this for each 1p package you'll end up with many virtual stores. Having multiple virtual stores throughout a monorepo can cause problems. If a 1st party package has transitive deps then it likely has to be in the same virtual store as where the transitives are or else it won't find them. This may work in simple cases where a 1p package has no transitive deps but in cases where 1p packages have any deps on other 1p packages or any 3p packages this pattern likely won't work.

I'm not sure what the failure mode would be as I've never tried multiple virtual stores. It is not something that you can do with pnpm itself so to go down that route will require some care. Supporting it might be possible but it would likely require some non-trivial refactoring of the code and lots of testing & new test cases & e2e tests.

@gregmagolan
Copy link
Member

The other side-effect that the "auto-link" approach would hit is when targets depend directly on npm_packages in other packages. The only choice is to link to the package of the target and make another virtual store there since you have no way to know if the package is linked to a virtual store elsewhere. This will end up with duplicate 1p packages in multiple virtual stores. This might be fine but it could be surprising to some users and come with unexpected side-effects.

@gregmagolan
Copy link
Member

gregmagolan commented Jan 1, 2023

I do see a possibility of this simplification working if deps could be shared between multiple virtual stores (this is a pre-req since otherwise deps have to be duplicated in each one) and if npm_package always linked a virtual store in the package the target is in. If possible, it would be a few days of exploratory work and if it there were no blockers a few days to a week to implement and the auto-link could work.

@gonzojive
Copy link
Contributor Author

I'm trying to follow the comments. Understanding the answers to some of the questions below might help clarify.

When running a tool like esbuild, it seems the working directory for the tool is the BINDIR root. Does this impose constraints to keep the BINDIR consistent between different targets? That is, does the rule implementing target //a/b:script need to avoid copying a file to $BINDIR/node_modules/@foo that is inconsistent with a file the rule implementing //a/c:script2 copies to $BINDIR/node_modules/@foo? Or can the implementations of //a/b:script and //a/c:script2 write conflicting files to $BINDIR/node_modules/@foo?

Is there any benefit to copying into a directory specific to the target being built?

For example, when compiling //a/b:script, instead of outputting to

$BINDIR/a/b/
  main.js
$BINDIR/node_modules/@foo/bar/ # or a/b/node_modules/@foo/bar/
  main.js
  package.json

The custom could be to output to

$BINDIR/a/b/script_rules_js_root/
  a/b/
    main.js
  node_modules/@foo/bar/ # or a/b/node_modules/@foo/bar/
    main.js
    package.json

and then execute esbuild with working directory $BINDIR/a/b/script_rules_js_root rather than $BINDIR? If this approach is taken, it seems each rule instantiation that invokes a JavaScript/TypeScript tool would have its own directory tree to manipulate as necessary.

@gregmagolan
Copy link
Member

Bazel restricts a target such as //a/b:script from creating outputs in other Bazel packages so all of its outputs must be in the //a/b Bazel package. It is not possible for that target to create outputs in the root // package. Thus a target //a/b:script could not create the output //:node_modules/@foo/bar. It can only create //a/b:node_modules/@foo/bar.

The pattern of re-rooting the entire output tree for a target into an output folder such as
$BINDIR/a/b/script_rules_js_root/node_modules/@foo/bar or $BINDIR/a/b/script_rules_js_root/a/b/node_modules/@foo/bar is generally bad as it would result in all targets getting their own node_modules trees which doesn't scale well. No two targets could then share node_modules outputs.

With regards to the BINDIR, the cwd for rules_js actions is set to the BINDIR of the target platform. Bazel expects a build target is expected to produce its outputs in that bazel-out tree. The tool being run & its runfiles can be in another platform output tree altogether such as the exec platform and this works as expected. I haven't seen any issues with different bindirs as Bazel handles putting all input files to a target in the bindir for the target platform being built and expects its outputs to go into that tree as well.

Hopefully that helps clarify some of the constraints rule sets have to work with.

@gonzojive
Copy link
Contributor Author

The pattern of re-rooting the entire output tree for a target into an output folder such as
$BINDIR/a/b/script_rules_js_root/node_modules/@foo/bar or $BINDIR/a/b/script_rules_js_root/a/b/node_modules/@foo/bar is generally bad as it would result in all targets getting their own node_modules trees which doesn't scale well. No two targets could then share node_modules outputs.

Is it the copying of files that doesn't scale or something else? It seems like tools already go over the entire list of input files, e.g.,

    input_sources = depset(
        copy_files_to_bin_actions(ctx, [
            file
            for file in ctx.files.srcs + filter_files(entry_points)
            if not (file.path.endswith(".d.ts") or file.path.endswith(".tsbuildinfo"))
        ]) + other_inputs + node_toolinfo.tool_files + esbuild_toolinfo.tool_files,
        transitive = [js_lib_helpers.gather_files_from_js_providers(
            targets = ctx.attr.srcs + ctx.attr.deps,
            include_transitive_sources = True,
            include_declarations = False,
            include_npm_linked_packages = True,
        )],
    )

That function is already O(direct_deps + transitive_deps). There are currently O(direct_deps) copy_files_to_bin_actions, while in the re-rooted version there would be O(direct_deps + transitive_deps) actions.

I would expect shared node_modules directories could still be created. Symlinks to those directories would need to be created in the re-rooted version of the output tree.


In any case, it sounds the current system is sufficient based on this comment:

I do see a possibility of this simplification working if deps could be shared between multiple virtual stores (this is a pre-req since otherwise deps have to be duplicated in each one) and if npm_package always linked a virtual store in the package the target is in. If possible, it would be a few days of exploratory work and if it there were no blockers a few days to a week to implement and the auto-link could work.

For this example

# liba/BUILD.bazel
npm_package(
  name = "pkga",
  package = "@myapp/a",
  deps = [
    "//libc:pkgc",
  ]
)

# libb/BUILD.bazel
npm_package(
  name = "pkgb",
  package = "@myapp/b",
  deps = [
    "//libc:pkgc",
  ]
)

# liba/BUILD.bazel
npm_package(
  name = "pkgc",
  package = "@myapp/c",
)

# cmd/BUILD.bazel

js_library(
  name = "script",
  srcs = "script.js",
  deps = [
    "//liba:pkga",
    "//liba:pkgb",
  ]
)

Would you generate something like this?

$BINDIR/
  liba/node_modules/
    @myapp/a/                                    # package.json, etc. for package a
    @myapp/c                                     # symlink to $BINDIR/libc/node_modules/@myapp/c
  libb/node_modules/
    @myapp/b                                     # package.json, etc. for package b
    @myapp/c                                     # symlink to $BINDIR/libc/node_modules/@myapp/c
  libc/node_modules/
    @myapp/c                                     # package.json, etc. for package c
  cmd/
    script.js
    node_modules/
      @myapp/a                                  # symlink to $BINDIR/libc/node_modules/@myapp/a
      @myapp/b                                  # symlink to $BINDIR/libc/node_modules/@myapp/b
      @myapp/c                                  # symlink to $BINDIR/libc/node_modules/@myapp/c (unsure if transitive dep symlinks are needed here or not)

@gregmagolan
Copy link
Member

gregmagolan commented Jan 2, 2023

Is it the copying of files that doesn't scale or something else? It seems like tools already go over the entire list of input files, e.g.,

We do already copy all source files to the output tree so that node tools only have to deal with a single file tree instead of separate source & output file trees (also touched on here). This makes rules_js much more compatible with node tools at the expense of some overhead; although even in this case multiple targets that reference the same source file can share the same source file output artifact so this ends up as O(source files).

If each target had its own unique tree in the output tree, that tree would have to include copies of source files & copies of all other inputs & npm packages so that node_modules resolution would work within that unique tree all the relative paths were valid in there as well. This is more of O(targets * (source files + direct deps + transitive deps)); granted every target only uses some subset of source files & deps in the graph, but the additional overhead & bloat in the output tree is not desirable in comparison to the simpler approach.

Would you generate something like this?

Close. The virtual store is put into a node_modules/.aspect_rules_js folder (this is equivalent to pnpm's node_modules/.pnpm https://pnpm.io/symlinked-node-modules-structure).

You'd get something like this:

$BINDIR/
  liba/node_modules/.aspect_rules_js/@[email protected]/node_modules/
    @myapp/a/        # package.json, etc. for package a
    @myapp/c         # symlink to $BINDIR/libc/node_modules/.aspect_rules_js/@[email protected]/node_modules/@myapp/c
    other            # symlink to virtual store location of other transitive dep of @myapp/a
  libb/node_modules/.aspect_rules_js/@[email protected]/node_modules/
    @myapp/b/        # package.json, etc. for package b
    @myapp/c         # symlink to $BINDIR/libc/node_modules/.aspect_rules_js/@[email protected]/node_modules/@myapp/c
    other            # symlink to virtual store location of other transitive dep of @myapp/b
  libc/node_modules/.aspect_rules_js/@[email protected]/node_modules/
    @myapp/c         # package.json, etc. for package c
    other            # symlink to virtual store location of other transitive dep of @myapp/c
  cmd/
    script.js
    node_modules/
      @myapp/a       # symlink to $BINDIR/liba/node_modules/.aspect_rules_js/@[email protected]/node_modules/@myapp/a
      @myapp/b       # symlink to $BINDIR/libb/node_modules/.aspect_rules_js/@[email protected]/node_modules/@myapp/b

with the symlinked node_modules virtual store tree handling transitive deps

@gonzojive
Copy link
Contributor Author

gonzojive commented Feb 4, 2023

I'm running into a similar annoyance writing a macro that calls ts_project. The ts_project needs to depend on NPM packages 'google-proto' and '@types/google-proto'.

def ts_proto_library(name, proto, visibility = None, deps = [], tsconfig = None):
    """A rule for compiling protobufs into a ts_project.

    Args:
        name: Name of the ts_project to produce.
        proto: proto_library rule to compile.
        tsconfig: The tsconfig to be passed to ts_project rules.
    """
   # ...

   # This is how implicit dependencies on npm packages would work if rules_js
   # operated the way Go does. However, as written this doesn't work because
   # if ts_proto_library(name = "foo") is called in bazel package @bar//baz,
   # the @com_github_gonzojive_rules_ts_proto//:node_modules directory
   # is not in the set of directories searched by nodejs to resolve "google-proto".
    implicit_deps = [
        "@com_github_gonzojive_rules_ts_proto//:node_modules/@types/google-protobuf",
        "@com_github_gonzojive_rules_ts_proto//:node_modules/google-protobuf",
    ]
    deps = [x for x in deps]
    for want_dep in implicit_deps:
        if want_dep not in deps:
            deps.append(want_dep)
    ts_project(
        name = name,
        srcs = [
            ts_files,
        ],
        assets = [
            non_ts_files,
        ],
        deps = deps,
        tsconfig = tsconfig,
    )

How would you deal with these npm dependencies? Some options I see

  1. Rely on the user to supply all dependencies for each invocation of ts_proto_library:
ts_proto_library(
    name = "polyline_ts_proto",
    proto = ":polyline_proto",
    visibility = ["//visibility:public"],
    deps = [
        "//location:location_ts_proto",
        "//:node_modules/google-protobuf",
        "//:node_modules/@types/google-protobuf",
    ],
)
  1. When setting up the ts_proto_library macros/rules in WORKSPACE.bazel, specify how each npm dependency should be resolved.
workspace(
    name = "myorg",
)

load("@com_github_gonzojive_rules_ts_proto//ts_proto:workspace_deps.bzl", "install_rules_ts_proto")

install_rules_ts_proto(
  npm_deps = {
    "myorg": {
      "google-protobuf": "//:node_modules/google-protobuf",
      "@types/google-protobuf": "//:node_modules/@types/google-protobuf",
    },
    "dep1": {
      "google-protobuf": "@dep1//:node_modules/google-protobuf",
      "@types/google-protobuf": "@dep1//:node_modules/@types/google-protobuf",
    },
  }
)

I'm not sure if the npm_deps needs to be per-repository or not (looking into it now). I'm under the impression that each repository needs its own node_modules directory, but I need to verify this. If all repositories can share a single node_modules at the root, this solution is less verbose.

gonzojive added a commit to gonzojive/rules_ts_proto that referenced this issue Feb 4, 2023
install_rules_ts_proto now takes a dictionary mapping npm package name to bazel
targets required to satisfy that package dependency. Example:

```starlark
install_rules_ts_proto(
    dep_targets = {
        "google-protobuf": "//:node_modules/google-protobuf",
        "@types/google-protobuf": "//:node_modules/@types/google-protobuf",
    },
)
```

This is required because ts_project and similar targets cannot depend directly
on NPMs; they must instead depend on "linked" npm targets within the target's
bazel package or one of its parent packages. This is discussed in
aspect-build/rules_js#706 (comment).
@gregmagolan
Copy link
Member

Latest release of rules_ts shouldn't depend on google-protobuf anymore after @thesayyn refactoring of the worker code

@gregmagolan gregmagolan moved this to 📋 Backlog in Open Source Feb 4, 2023
@gonzojive
Copy link
Contributor Author

gonzojive commented Feb 4, 2023 via email

@gregmagolan
Copy link
Member

gregmagolan commented Feb 5, 2023

Gotcha. I think I have to read through the threads above to catch up on the context. Generally speaking, each npm_translate_lock will result in a unique virtual store (equivalent of the .pnpm folder in pnpm) of npm packages. There is no way right now to share deps between npm_translate_lock instances. Something like that may be possible to do in the future and I have been thinking about it as a means to simplify first-party dependency linking, but I have to read through the thread above to see if what I'm thinking would apply to this case.

@gonzojive
Copy link
Contributor Author

gonzojive commented Mar 5, 2023

I wonder if import maps are relevant to this discussion. They are mentioned in the nodejs docs.

There is some discussion of this in issues filed against the TypeScript and nodejs projects: microsoft/TypeScript#43326

@gregmagolan
Copy link
Member

gregmagolan commented Mar 6, 2023

Interesting. Looks like node already supports a variation of that it calls "subpath imports", https://blog.spencersnyder.io/solving-the-long-relative-paths-problem-natively-in-node-js-6aeebc3a81bd... but I don't see how to make that play nicely with type checking or bundling.

It's a difficult problem space because there is such a wide array of tools in the javascript ecosystem. Even if rules_js could populate the "paths" of a tsconfig, that wouldn't help if you were bundling with rollup or esbuild. Whatever bundler you're using would also need to be aware of the paths as will node or the browser at runtime if you're not bundling away the paths.

Consuming through pnpm workspaces adds overhead and boilerplate but at least most tools agree on standard node_modules resolution. If you use pnpm workspaces you can avoid the additional npm_link_package target and just have an npm_package target per 1p package. I've helped a client make this work at scale with 1000+ first party packages and 10,000+ npm packages.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
No open projects
Status: 📋 Backlog
Development

No branches or pull requests

3 participants