Sharing code between Deno and Node where Bun and ts-node failed

- 27 min read - Text Only

Bun, 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.

A typical benchmark chart where one product, Bun in this case, has some outperforming metric to its competitors: Deno and Node.

What's so bad about a build step?
standard
laptop-thinking
In A Precious Side Project — This Website, I put a lot of work into minimizing the save-and-refresh cycle time. In TypeScript, I can express what I intend with predictable and consistent ease. And so, I write in TypeScript. However, Node does not run TypeScript — it has to be transpiled to JavaScript to run there. The delay between my expressing my ideas and seeing the results can destroy my interest, focus, and desire to continue.
oopsie
These projects are for my personal pleasure on my personal time. I value my time. I value my focus. I am simply not happy when my focus can drift and then an hour later, instead of getting things done, I apparently watched a bunch of forgettable videos. That sucks.

Where Bun shined

buns-oven

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.

A screenshot of VS Code. The code requests robots.txt from this website and it prints out in the console below. Bun is used to execute the script file index dot ts.

For a while, Bun maintained the zero-friction dream.

nightmare
And then I woke up from that dream.

Bun crashes on typical things

Do you believe on unit tests in continuous integration (CI)? I sure do.

What about dependency lock files?

Docker build failed in GitHub because of a segmentation fault

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.

dying-at-computer
Unfortunately, I do not have the exact source to reproduce this weeks later. If I use the same version of Bun and use SubtleCrypto to digest the fetched robots.txt response, it works just fine.

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.

buns-burned

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).

Why not stick with ts-node after configuring it enough to get by?
hmm
sigh
Node's batteries-included APIs deviated enough that its JavaScript is another dialect entirely from what browsers expose to Web Workers. I have limited head space and I am not interested in keeping both standardized browser-provided APIs and very similar ones for Node separated in my head. The exposed functions for node-fetch are so similar to web fetch that I will and have mixed them up elsewhere when it matters.
then-perish
That, and tsconfig.json will inevitably need yet another highly specific change that requires scouring documentation and Stack Overflow for a suitable answer. I would rather just use TypeScript and Web Worker APIs, no further configuration necessary.

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.

heartburn
I have to wonder how many developer-days are burned each week on configuring tools like ts-node and webpack to accurately represent the execution 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.

Deno has a benchmark showing it is over twice as fast as Node.

Deno, like Bun, emphasizes execution speed and developer productivity.

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.

aight-im-outta-here

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.

Deno landing page where it says: Meet Deno - The easiest, most secure JavaScript runtime.

looking

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:

  1. An independent place where unit tests could run
  2. A CI build step using Deno to rewrite the JSON documents before uploading to KV.
  3. 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'
cupcake
It was going really well!
face-mash-keyboard
Until I started my next dependent project: text-doc-ir.

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.

A diagram of how document-ir is used by the web worker and by text-doc-ir, and how text-doc-ir is used by the web worker.

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.

laptop-thinking

And so, I began thinking.

What if there was a way to have my package work on Deno first and also on Node with NPM second?

Then I found out: Deno to NPM package (dnt) exists!

A diagram of the Deno logo pointing to the Node js logo

Last November, I totally missed that dnt is available. It is exactly the tool for the job.

point-right
The documentation on denoland / dnt's README is sufficient. For another account of how to use dnt, see Create a Deno-first dual module with dnt (archived.)

The first trick was to build for NPM. I copied some of the sample configuration and tried it out. It worked beautifully.

Wait! Configuration that works out of the box?
shock
crying-heart
Right? Unlike ts-node, which flops out of the box, Deno just works. This is a great sign!

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.

whoa
Whoa! That was the easiest onboarding process I had ever experienced!

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.

Why do you use hyphens all the time?
chinese
typing
Hyphens are easier on my hands. It does not need me to press shift, unlike underscore. Also, lisp languages and URLs love kebab case (archived.)

Deno's add module page where it asks for a package name and gives a webhook URL. It then says waiting with instructions to GitHub.

I followed the instructions on their page and created a webhook in GitHub.

A webhook was successfully created on GitHub which activates on create tags, releases, and push events.

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.

NPM shows a 404 page with login slash email-otp in the URL

server-percussive-maintenance

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...

On NPM there's a link accounts section. The options are GitHub and Twitter.

snake-shrug
Who in their right mind would trust linking Twitter to their NPM account today?

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.

snake-knotted
Bare exposed tokens on the file system? It is 2023, use the system provided secret store.
peek
GitHub did mitigate this concern, as you will shortly see.

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.

An NPM package listing, where document-ir is public and shows a brief README.

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?

sneeze
I think that should become the default for new packages going forward. It would likely deter some ChatGPT package squatting (archived,) typosquatting attacks (archived,) and any other shared namespace threats.

Sharing document-ir

Now that document-ir was ready to use on NPM, I could focus on my other package: text-doc-ir.

Before you continue, what does text-doc-ir do?
reading
old-man
It is a format for old ribbon printers! Though, more seriously, it is something I did for fun to replace the Mendoza-generated txt I did earlier. You can view any page here by replacing .html with .txt.

A fixed width textual representation of the beginning of this post with 'Why try out Bun?' The image demonstrates headers, image alt text, the sticker messages in the blog post, and an unordered list.

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.

A view in VS Code where the npm folder has several such as esm, node_modules, script, and src. Inside src is the TypeScript for this project and there is a deps folder too. The deps folder has a package labeled document_ir at version 0.0.9. The full contents of that package have been inlined.

mind-blown

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.

A brief digression
Rust applications can safely include two versions of the same nested dependency. However, like TypeScript, if the developer attempts to share a structure from that dependency across versions, it will likely fail. This occurs when symbols or structures from the dependency are re-exported or exported through its own functions and traits.
scratch
One such example is the qrcode crate. It requires a three-year-old version of the image crate. This may lead to painful workarounds such as encoding the image with the old library and decoding it with the new library, or being forced to use the old image library for the entire project due to qrcode.
uhh
Thankfully, the qrencode crate forked the former and is compatible with the modern image dependency. In a pinch, fork and use a github dependency in cargo.toml without publishing to crates.io.
wink

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.

The text-doc-ir package was published a few weeks ago. It depends upon document-ir in NPM.

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.

A flow graph where Mendoza (the static site generator) produces JSON documents which are processed by a post processing application. That application is a deno kind and uses document-ir. The results are labeled production ready and go into Cloudflare KV. Another application node labeled cloudflare worker is an NPM dependency type and pulls those json documents from cloudflare KV. There are two dependencies going into the latest application, document-ir and text-doc-ir.

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.

standard
A description of the build process for this website and these dependencies will come in the next article. Look forward to learning about Secrets, in-repository GitHub Actions, Check Runs, and publishing to NPM from GitHub Actions in: Custom GitHub Actions, Check Runs, GitHub Applications, NPM Publishing!