48 views
## TL; DR If you have a local Deno project Bar that needs to import another local Deno project `Foo`, make sure you have this in Foo's `deno.json`: ```json { "exports": { ".": "./src/lib.ts" } } ``` Then add this to Bar's: ```json { "imports": { "foo": "jsr:Foo" }, "links": ["/path/to/foo"] } ``` Now, you can ```typescript import * as foo from "foo" ``` in Bar, and `foo` will be the exports from `/path/to/foo/src/lib.ts`. ## Full Story I am using [Deno](https://deno.com) to bootstrap a new project, and it has been a very pleasant experience. Deno is a Typescript runtime that claims to be more performant, secure and easy to use than Node. It includes a formatter, a linter, a language server and pretty much everything you would possibly need in the Typescript ecosystem. It also handles all the build system heavylifting for me, allowing me to focus more on the actual code. After I finished drafting the first part of my project (let's call it *Foo*), I went on to create a new repo for the next part (let's call this one *Bar*), which I intend to write also using Deno. Bar would include Foo as a dependency, and use certain functions from Foo. To get this to work, I simply added Foo to Bar's import map: ```json { "imports": { "$/": "./src/", "$djson": "./deno.json", "foo": "/path/to/foo/src/lib.ts" } } ``` This allowed me to import stuff by writing ```typescript import * as foo from "foo" ``` but things start to break down when I run Bar: ``` error: Relative import path "yaml" not prefixed with / or ./ or ../ and not in import map from "file:///path/to/foo/src/lib.ts" hint: If you want to use the npm package, try running `deno add npm:yaml` at file:///path/to/foo/src/lib.ts:1:23 ``` `yaml` is a npmjs library Foo uses. The issue here is Bar does not list `yaml` in its import map (in `deno.json` or its standalone import map file). Deno has no idea how to resolve it. The import map entry `foo` is merely an alias, so the import is equivalent to ```typescript import * as foo from "/path/to/foo/src/lib.ts" ``` Foo's import map, containing the information Deno needs, is not involved in this entire process at all. I did some research around this issue and got the following solutions / workarounds: - Use Deno's workspaces feature. This needs a top-level `deno.json` file, thus implicitly requiring a monorepo setup. I am not a big fan of monorepos, as I prefer to keep separate things, well, separate. - Use a common import map file for both projects. There even is a dedicated [issue](https://github.com/denoland/deno/issues/5522) and corresponding PR to make this easier. This carries three drawbacks: - more complicated setup (now I have to host the import map *somewhere*) - more coupling (now if I ever want to use different versions of the same library in Foo and Bar, I have to give them different names) - more hassle (Deno [extends](https://github.com/denoland/deno/issues/5522) the import map standard to make your life easier, and you lose that convenience if you choose to use a standalone `import_map.json`) - Publish Foo. Not to mention I do not want to publish a half-finished mess, even uploading it privately means loss of some flexibility when I want to develop the two projects hand-in-hand. :::info And here is a little piece of advice I got: > Write a script to help merge Foo's import map into bar's manually. > [name=ChatGPT] Super duper smart. ::: None of these solutions look optimal to me. However, something caught my attention as I read Deno's [documentation](https://docs.deno.com/runtime/fundamentals/configuration/#overriding-packages): > The links field in deno.json allows you to override dependencies with local packages stored on disk. This is similar to npm link. > >This capability addresses several common development challenges: >- Dependency bug fixes >- Private local libraries >- Compatibility issues > >The package being referenced doesn't need to be published at all. It just needs to have the proper package name and metadata in deno.json or package.json, so that Deno knows what package it's dealing with. This provides greater flexibility and modularity, maintaining clean separation between your main code and external packages. Great, but the documentation is not very clear about how to use this feature. I spent a couple hours to understand how it works, and here I present my findings. In our case, add this to Bar's `deno.json`: ```json { "links": ["/path/to/foo"] } ``` `/path/to/foo` should contain `deno.json`, or Deno will report an error, citing it is not a valid "link member". Internally, Deno reads `/path/to/foo` and looks for a `deno.json` (or `package.json`). It then reads the `name` and `version` of the linked package and creates a *link* for `jsr:[email protected]` (assuming Foo has `version` set to `0.0.1`). This link can be seen in `deno.lock`, the `workspace.links` field: ```json { "workspace": { "links": { "jsr:[email protected]": { "dependencies": [ "npm:yaml@^2.8.1", ... ] } } } } ``` When the Deno runtime needs to resolve an import, its resolver factory creates a `WorkspaceResolver`, which implements the function `from_workspace` used to do the actual resolution. It in turn calls `resolver_deno_jsons` to get all `deno.json` files relevant to the resolver: ```rust pub fn resolver_deno_jsons(&self) -> impl Iterator<Item = &ConfigFileRc> { self .deno_jsons() .chain(self.links.values().filter_map(|f| f.deno_json.as_ref())) } ``` At this very point, `deno.json` files from links are added to the resolver. That means all dependencies we declared in Foo are now visible to Bar's runtime. To conclude, when we add the path to Foo to `links` in Bar's `deno.json`, we include Foo's dependencies in Bar's runtime. To make things even better, we can now get rid of the `"foo": "/path/to/foo/src/lib.ts"` import mapping. Remember what `links` was initially designed for? It is meant to provide a way to override a remote package with the contents of a local directory. In this case, the link created by Deno reads `jsr:[email protected]` - and that means any reference to `jsr:Foo` would now be redirected to `/path/to/foo`, so we can write this instead in our `deno.json`: ```json { "imports": { "$/": "./src/", "$djson": "./deno.json", "foo": "jsr:Foo" } } ``` Note that for this to work, Foo will need to explicitly declare its export for `.`, because `jsr:Foo` resolves to `.` from Foo's perspective, and Deno cannot map it to a concrete `.ts` file if you don't tell it this info: ```json { "exports": { ".": "./src/lib.ts" } } ```