learnings, nerdisms, bicycles
Suppose you are building applications for browsers. Suppose that you have a design in mind for your project, warranting many modules. You want to:
A monorepo is often the solution for these needs.
With the advent of deno
and esm
, this is an increasingly common pattern.
Defining "monorepo" is out of scope, but for simplicity sake I will make an
assertion that a monorepo hosts independent projects that may or may not
reference one another. Consider
deno_std. deno_std
has http
and fs
concerns baked into the same repository, and is thus considered a monorepo from
this point forward.
deno
1Alternatively,
deno
oriented monorepos1 Assuming no bundlers are applied
import
s work in development using deno
import
s work in production using browserssourceMaps
work in production using browsersWhat options do we have to to publish TypeScript ESM libraries using deno
? So
much of the conversation in Deno is around getting pre-existing JS resources
into deno
. What we want is the inverse--to get great work from a deno
starting project into browsers!
deno bundle
import()
TypeScript nativelyesm.sh
does offer partial support for this, but has macro inhibitors:
npm
as it's module entrypoint, versus github or an arbitrary
static file servertsc
and upload the artifacts
deno
, not tsc
importMap
s do not workDeno.emit(...)
, and rewire imports
deno esm browser (deb)
& Deno.emit(...)
To jump directly to the code solution, you can see the
build
function in the deb
module, checked into the rad repository.
Consider that using deno
often implies use of the following features:
importMap
sTypeScript
These are powerful tools, and are both commonly used in the deno
space. They
are not required for use in deno
, they are simply common, and thus should
be supported. If we are building browser-ready modules, we want to support these
features.
How could it work? If you'd like to follow along interactively, you can see a fully featured demo here, for those who want to try it out.
Let's consider some input source:
// foo/mod.ts
import { bar } from "bar/mod.ts";
export const foobar = () => `foo${bar()}`;
Assume that bar/*
is resolved via an import map, e.g.
importMap.development.json
during dev. I may run
deno --import-map importMap.development.json test foo/mod.test.ts
to test
foo/mod.ts
. Deno reads and tests this browser module just fine!
Getting this TypeScript ESM to JavaScript ESM is easy. Call Deno.emit(...)
on
this module, and you will get:
// foo/mod.ts.js
import { bar } from "bar/mod.ts";
export const foobar = () => `foo${bar()}`;
# sourceMap=...
Not bad. What problems exist?
bar/mod.ts
will not be resolved by a browser.
bar/mod.ts
is not a relative sibling to foo/mod.ts
bar/mod.ts
is still a TS file :/
bar/mod.ts.js
was generated, as it is part of
foo/mod.ts
's import graph!How can we solve these problems? Let's focus on module resolution first. Suppose
we know what our HTTP server or CDN base URL is going to be upfront. For
instance, I host static assets on static.cdaringe.com
. If I want to publish to
my own static file server, I know my hostname and pathname for where I want to
upload these assets!
Just like I am using importMap.development.json
to resolve bar/*
in
development, I can author a importMap.production.json
, and simply re-write
imports post-compile. Let me update importMap.production.json
to map
{ "bar/": "https://static.cdaringe.com/esm/dbl/bar/" }
In other words Demo.emit(...) |> rewriteImports("importMap.production.json")
yields something like:
// foo/mod.ts.js
import { bar } from "https://static.cdaringe.com/esm/dbl/bar/mod.ts";
export const foobar = () => `foo${bar()}`;
# sourceMap=...
Excellent! Pop on a .js
extension during import re-writes, and we will have
solved all outstanding problems:
// foo/mod.ts.js
import { bar } from "https://static.cdaringe.com/esm/dbl/bar/mod.ts.js";
export const foobar = () => `foo${bar()}`;
# sourceMap=...
After pushing the build artifacts up, we can now try importing the ESM JS assets.
So how did we actually compile it? Rather than repeat documentation in other places, instead allow me to guide you via links, end to end:
build
function,
that accepts ESM module filenames to compile
Deno.emit
finds each file in the import graph, and emits an ESM module &
.map file next to the associated .ts
file
outDir
option, to move all of these ESM assets to a
directory of your choice, versus polluting your source code directories.build
directory.I simply the rsync
'd the ESM to my static file server, and the ObservableHQ
demo above works!
Why use Deno at all here?
Deno is the superior toolkit & runtime for developing browser libraries.
Deno.test(...)
is all you need. It has browser
runtime primitives baked in, versus needing to polyfill/shim in a virtual
browser env, a la JSDom/happy-dom.What if my TypeScript imports external Deno modules
Right--this project only converts your Deno source code to ESM--not the whole world's! If there are external, browser friendly, TypeScript ESM modules you want to tap into, you could:
Option 1: Add git submodule
s for your dependencies.
import your dependencies via local importMap references, the
n
publish the compiled 3rd party modules in the same structure as referenced in development
This option isn't great, as git submodule
adds moderate complexity to all
workflows. However, conceptually, it's quite simple if you're willing to add
submodule
functionality to your project.
Option 2: An ESM server, such as emit_esm_server
In this strategy, you must deploy a server to produce ESM. However, it does have a huge benefit--you can import TS files directly from your JavaScript, and it just works. Slow, probably needs loads of optimizations contingent on use case, but works.
# docker-compose.yaml
deno_emit_esm_server:
image: cdaringe/deno_emit_server
deploy:
resources:
limits:
memory: 120M