Sharing code between Deno and Node where Bun and ts-node failed
- 27 min read - Text OnlyBun, a new JavaScript runtime, promises speed, optimized APIs, and a batteries-included developer experience. I tried it out and I was instantly in love — until it constantly crashed after I had learned and adapted to it.
My experience below is dated between June 15 to June 27, 2023.
Why try out Bun?
I could not care less about the benchmarks. What really got me interested was native TypeScript execution in Bun. No compile and then execute, just bun index.ts
.
Where Bun shined
Bun delivers on its tagline promises:
- Builds faster
- Supports TypeScript as a first-class language
- Provides Rapid feedback with
--watch
- And more is described on Bun's website.
In VS Code, I could quickly edit, save, and observe the results of my changes in the console below.
For a while, Bun maintained the zero-friction dream.
Bun crashes on typical things
Do you believe on unit tests in continuous integration (CI)? I sure do.
What about dependency lock files?
Until I removed that lockfile, which was built on MacOS AArch64, I could not get Bun to run my project on x86_64 Linux on GitHub.
My next issue is that Bun crashed while using SubtleCrypto, in combination with reading files, executing sub processes, and using a JSON canonicalization dependency. When I removed SubtleCrypto, Bun did not crash.
Breaking promises (pun intended) in language provided cryptography is a bad sign to me. It also terminated early when I checked the status of a child process.
My conclusion? Bun is not tested outside of its happy path. It is only reliable at producing incredible benchmarks today.
I began to look elsewhere.
Giving up on bun.
It segfaults on its own lock database.
It exits WITH 0 when waiting on a child process to exit.
It exits WITH 0 on using crypto subtle digest.
It really is still baking in the oven...
Trying out ts-node
I have tried Vite before, and while it delivers a similar TypeScript fast-refresh developer environment, I felt it was too much magic to pull in for my target environments: Cloudflare Workers, Web Workers, and CI jobs. In A Precious Side Project — This Website, I described how Tweet, Toot, and YouTube embeds were changed from iframes to inline HTML. For that, I used Node! With native JavaScript fetch
too!
Tried ts-node and oh my g*d, really? What incantations do I need to have it know node has fetch now? Blah blah work around that and... are you kidding me? Unknown extension .ts??? This is typescript! Something about modules.. try this and that and...
Fine.. deno. See if deno will.. work..
Okay.. I have to use this rando CDN that reads my github repo and.. I have to make all imports use explicit extensions. And.. its cached. Okay Release and tag!
Well, thats working.
Deno is okay.
Well, it turns out, convincing TypeScript that, "Yes, I'm on Node, and yes, actually I have access to fetch
and SubtleCrypto" is unnecessarily difficult. Any Stack Overflow responses I find just say "well tell it you have access to the DOM!". After the swap to Deno, I find out that a terribly named package undici should be used. For more info see Docs for Node's Native fetch (Undici).
I will not be using ts-node
for personal projects in the future. If I need to configure webpack one more time for work, then whatever. This is about my personal happiness. TypeScript's ts-node
failed to deliver a happy development environment.
Looking at Deno
I tried Deno last November. Deno's first-class TypeScript, SubtleCrypto support, unit test tooling, and runtime alignment with Web Workers felt just right.
And yet, it was not love at first sight.
At the time, I was experimenting with parsing binary content in Deno. I paused when I hit a few friction points, primarily around operating with other technologies. For example, everything I write needs to operate in Cloudflare Workers without issue. Additionally, I felt that without access to Node packages, my development options were incredibly limited.
For a start, how was I to share this with non-Deno runtimes like Web Workers and Cloudflare Workers? Was there any not-Webpack packer I could use?
I put Deno back on the shelf and thought little of it for half a year.
Trying Deno again
After my frustration with Bun's immaturity, and after flipping the table at ts-node
for its complexity and hidden knowledge, I took one more look at Deno.
This new project, document-ir
, creates a transformable data structure that can be generated from Markdown, Mendoza markup, limited HTML, and later be used to render HTML for a reader.
It needed to share code between several places:
- An independent place where unit tests could run
- A CI build step using Deno to rewrite the JSON documents before uploading to KV.
- A Cloudflare Worker that transforms JSON documents to HTML for each request.
At first, I used GitHub dependencies to add document-ir
to my Cloudflare worker.
npm i --save "github:cendyne/document-ir#0.0.5"
And for the Deno side, I found out that jsDelivr spplies JS packages reliably from GitHub and NPM using Cloudflare and Fastly. However, to use it, I had to tag new releases every time I wanted to observe my changes in dependent projects.
export {NodeVisitor} from 'https://cdn.jsdelivr.net/gh/cendyne/document-ir@0.0.5/index.ts'
export type {TextNode, FormattedTextNode} from 'https://cdn.jsdelivr.net/gh/cendyne/document-ir@0.0.5/index.ts'
GitHub over jsDeliver delivers exactly the files as committed. This is great if your files use relative imports! It breaks down when you import anything else.
And so, I looked into how to package Deno projects to be compatible with other target environments.
Deno packaging
It was easy to share code between applications — the web worker and post processing task — when it had no external dependencies. Yet, the moment I added text-doc-ir
, the requirements to import document-ir
changed. Not only did the web worker depend on document-ir
, but it also depended upon text-doc-ir
which depended upon the same version of document-ir
.
Here's why that change is so important: text-doc-ir
, being a native Deno package, specifies its dependencies with absolute URLs. Node packages do not do this. While I could try to import text-doc-ir
with a GitHub package import, as I did with document-ir
earlier, it would then fail to resolve the mutual dependency: document-ir
.
Then I found out: Deno to NPM package (dnt
) exists!
Last November, I totally missed that dnt
is available. It is exactly the tool for the job.
The first trick was to build for NPM. I copied some of the sample configuration and tried it out. It worked beautifully.
Though, I was surprised: it also included my unit tests and the code dependencies for the unit tests. I removed unit tests by adding test: false
to the build options.
// ex. scripts/build_npm.ts
import { build, emptyDir } from "https://deno.land/x/dnt@0.37.0/mod.ts";
await emptyDir("./npm");
await build({
// ...
test: false,
// ...
});
What blocked me next? I needed a place to put this package! I looked into Deno's package hosting and signed up for NPM.
Deno third party modules
I moved on from jsDelivr for a few reasons. Deno's third party modules repository is searchable and discoverable. For each package, Deno provides documentation lists the available versions. While there are some big names powering jsDelivr, I don't know it. My brand ignorance here is a risk that I am not interested in taking.
And so, I went to publish on Deno's third party modules repository.
To get a package on Deno's third party modules, I first went to the Adding a module page. It turns out, Deno does not like packages with hyphens in the name, so I had to use document_ir
instead of document-ir
in the URL name.
I followed the instructions on their page and created a webhook in GitHub.
Unfortunately, there was no way to trigger the webhook after the fact for an event in the past. To publish on Deno, I created another version and it published successfully!
NPM repository
Like many, I have used NPM for years without ever creating an account there. It was time to change that.
My first experience was not so positive. I created an account, completed a weird captcha, got an OTP challenge over email, entered the OTP into the next form, and got a 404.
What the heck, NPM???
I tried signing in again, got another OTP, and this time the application did not fail.
Naturally, the first thing I did was add WebAuthn for 2FA. If you haven't, consider doing that!
Then, I attached my GitHub account and...
So… how was I to create a package namespace? Turns out: I do not in their UI; this happens as part of publishing a package for the first time!
To publish a package on NPM, you must log in first.
npm login
The console opens up an OAuth style authorization flow and when successful updates ~/.npmrc
with an authorization token.
Once I ran the build command for dnt
, I had a folder called "npm
" where I could examine and publish a Node-ready module.
deno run -A scripts/build_npm.ts 0.0.5
cd npm
npm publish
My browser immediately opened prompting me for my security key. Oh good! They at least require 2FA for any write activities with an easily-exfiltrated token.
After npm publish
is done, my new package is ready! Hold on, aren't packages supposed to be prefixed by usernames now? Like, maybe @cendyne/document-ir
? How did I get a root-level package?
Sharing document-ir
Now that document-ir
was ready to use on NPM, I could focus on my other package: text-doc-ir
.
Ultimately, the Cloudflare Workers project will use both document-ir
and text-doc-ir
as dependencies. Like document-ir
, text-doc-ir
is made for Deno and runs with Deno unit tests. And yet, both are intended to be used in the Cloudflare Worker's project — wherein dependencies come from NPM.
I replicated my build and publish flow for NPM easily to text-doc-ir, though I was surprised at what I saw. DNT had included the document-ir
dependency inside of the text-doc-ir
NPM build folder.
This is really neat! If I had a Deno only dependency and it was entirely internal, this automatic bundling erases an entire concern from the development experience. However, document-ir
is a shared dependency and it does not make sense to have two copies of the same library being pulled into my own application.
As text-doc-ir
is not intended to be used by itself, inlining document-ir
inside text-doc-ir
would not be beneficial. Further, document-ir
is on NPM, any dependents upon text-doc-ir
should be able to also pull document-ir
. To instruct Deno to NPM package to not inline a Deno dependency and instead use an NPM dependency, specify a package mapping in the build options.
// ex. scripts/build_npm.ts
import { build, emptyDir } from "https://deno.land/x/dnt@0.37.0/mod.ts";
await emptyDir("./npm");
await build({
// ...
mappings: {
"https://deno.land/x/document_ir@0.0.5/index.ts": {
name: "document-ir",
version: "0.0.5",
},
},
// ...
});
After this change, the npm
generated folder no longer has document-ir
included. Instead, package.json
now has a dependencies section populated with the referenced NPM dependency!
{
// ...
"name": "text-doc-ir",
// ...
"dependencies": {
"document-ir": "0.0.5"
}
}
Finally, text-doc-ir
is packaged and available on NPM and text-doc-ir
is available on Deno.
Everything together
Now that these packages (text-doc-ir
and document-ir
) were in public repositories, I could pull them into my Cloudflare Workers application (see A Precious Side Project — This Website.) I also use the same dependencies in my Deno document post processing application in a GitHub Action to massage the output from Mendoza (a static site generator) into an output more suitable for rendering content on the edge.
Overall, I found Deno to consistently deliver technology that "just works." The URL-based and file-extension-included imports felt quirky at first and challenged me to find a delivery method to share code across projects. At first, I used jsDelivr which was recommended on Stack Overflow. Later, when I determined I could share my code on NPM, I switched to using both NPM and Deno third party modules.
Once I figured out denos particular ways, I found it a far far far more pleasant typescript experience than ts-node, webpack, and so on.
While bun was faster to start it also crashed left and right. No bueno.
The deno to node build script is easy to grok and it was sraight forward to swap out deno dependencies for npm packages with an example.
Without swapping anything out, it embeds its dependencies, which is kind of cute.
I can develop with deno and then use cf workers with shared code!
I look forward to using Deno more in the future. Bun is too unstable to use in personal or professional work and I will table that as an option for another year. Bun is still in the oven and ts-node is too inaccessible.