Supporting Deno with import maps and Node compat

I wrote a static site generator called PRPL with an explicit goal of longevity. As a library of modules compiled to JavaScript, that's pretty much a moving target.

The JavaScript ecosystem is undergoing a runtime renaissance while also dealing with incompatible module definitions. Module authors wrestle with how to support:

Any approach for one of these targets has implications on the other targets. It's not a straightforward situation, which is fair for an ecosystem with over a decade of history.

With PRPL I achieved CommonJS and ESM support, but it has always been a goal to also support Deno. Today I managed to do that partially via import maps and Deno's Node compatibility mode.

Prior art and first approach

I wrote most of PRPL prior to Deno v1.15, which introduced Node compatibility mode. There were discussions on the topic around GitHub back then, but it was impossible to know whether Deno would be able to achieve something like a full fledged polyfill for Node.

At the time, one of the best examples I referred to when thinking about solutions was yargs. yargs was able to support CommonJS, ESM, Deno and the browser by using the factory pattern. It provided unique entry points for each target and passed platform shims to a common yargs-factory.

Initially, I took a similar approach with the higher order function pattern.

Here's yargs' Deno entry point:

import denoPlatformShim from './lib/platform-shims/deno.ts';
import { YargsFactory } from './build/lib/yargs-factory.js';

const Yargs = YargsFactory(denoPlatformShim);

export default Yargs;

Here's PRPL's initial Deno entry point (truncated to show just the interpolate function):

import { interpolate } from './interpolate/interpolate.ts';
import { lib } from './lib/lib.ts';
import { platform } from './platform/platform-deno.ts';

const { cache, cwd, log, ensureDir, generateOrRetrieveFileSystemTree } = lib(platform);
const { path, fs } = platform;
const { resolve } = path;
const { copyFile, writeFile } = fs;

const core: Record<string, any> = {
  interpolate: (args?: { options?: PRPLInterpolateOptions }) =>
    interpolate(args, {
      resolve,
      copyFile,
      generateOrRetrieveFileSystemTree,
      log,
      cwd,
      ensureDir,
      cache
    })
};

export { core };

The problem with this approach is that it is very intrusive. It required changes to nearly all files in the PRPL project, and also made it much less legible to potential contributors. Unsatisfied with this tradeoff, I dug deeper.

Import maps for Node APIs

I was aware of Deno's support for import maps and thought initially that I might be able to give Deno an import map for the short list of Node APIs that PRPL uses. Given how strict I was driving toward (near) zero dependencies and how careful I was to select APIs Deno had standard library implementations of, the scope of what I needed to shim was contained to path and fs/promises.

Deno has a 1:1 path implementation with Node, so that worked fine. The problems were with fs/promises:

// Node
import { copyFile, mkdir, readdir, readFile, stat, writeFile } from 'fs/promises';
// Deno
import { copy } from 'https://deno.land/std@0.110.0/fs/mod.ts';
const { mkdir, readDir, readFile, stat, writeFile } = Deno;

These functions in node are all imported from fs/promises and can't be imported from individual paths (e.g. fs/promises/copy-file.js) as far as I can tell. There's no way to differentiate between them in an import map. I briefly explored re-exporting these imports locally in different files to provide different target paths, but because I'm compiling to a single file the imports get changed back to fs/promises anyway.

Moving on, digging deeper.

Import maps and Deno-Node compatibility

As it turns out, the Node compatibility layer I heard rumblings about earlier this year had since covered a lot of ground. The limited set of Node APIs I was using had support, so I decided to try this approach.

I navigated to the PRPL example project and executed deno run --unstable --compat scripts/build.js, where the build script is the basic one:

// scripts/build.js
import { interpolate } from '@prpl/core';

async function build() {
  await interpolate({ options });
}

build();

And drum roll, Deno said:

error: Requires net access to "deno.land", run again with the --allow-net flag at https://deno.land/std@0.119.0/node/url.ts:844:32`

Ah yes, so I executed deno run --unstable --compat --allow-net scripts/build.js, and Deno said:

error: Uncaught SyntaxError: The requested module 'marked' does not provide an export named 'default' import marked from 'marked';`

Indeed, marked is the single dependency that PRPL core has. I rummaged around to find a CDN url that exports a default import Deno can accept. Now was the time for an import map. I created it locally as scripts/import-map.json to test first:

{
  "imports": {
    "marked": "https://unpkg.com/marked@2.1.0/lib/marked.esm.js"
  }
}

And when I executed deno run --unstable --compat --allow-net --import-map=scripts/import-map.json scripts/build.js, Deno said:

error: Uncaught (in promise) PermissionDenied: Requires read access to <CWD>, run again with the --allow-read flag`

Alright then, I added the argument, executed deno run --unstable --compat --allow-net --allow-read --import-map=scripts/import-map.json scripts/build.js, and Deno said:

error: Uncaught (in promise) PermissionDenied: Requires write access to "/some/path", run again with the --allow-write flag`

Quite a lot of arguments, but at least the error messages are helpful. Okay, one more try. I executed deno run --unstable --compat --allow-net --allow-read --allow-write --import-map=scripts/import-map.json scripts/build.js

This time, Deno had nothing to say. Instead, my terminal said in a familiar, cheerful pink-purple text:

[PRPL] Build complete

Pumps fist.

Shipping Deno support in PRPL core

Happily, import maps and the Deno-Node compatibility layer helped me achieve Deno support in PRPL core. To make the experience a bit better for users, I added the import map to the PRPL core module and included the file in core's package.json:

{
  "name": "@prpl/core",
  "version": "0.3.1",
  "description": "HTML-based static site generator",
  "author": "Ty Hopp (https://tyhopp.com)",
  "bin": {
    "prpl": "bin/prpl.js"
  },
  "exports": {
    "./package.json": "./package.json",
    ".": [
      {
        "require": "./dist/index.cjs",
        "import": "./dist/index.mjs"
      },
      "./dist/index.cjs"
    ],
    "./deno-import-map.json": "./deno-import-map.json"
  },
...

As of @prpl/core v0.3, users can run this command to build PRPL sites with Deno:

deno run --unstable --compat --allow-read --allow-write --allow-net --import-map=node_modules/@prpl/core/deno-import-map.json scripts/build.js

Although only the core module works this way for the time being, it is useful to know that this strategy can be employed for all other PRPL library modules without significant changes.

While it's unclear how much adoption Deno will have in the JavaScript ecosystem 2 or 3 years from now, I'm satisfied that I was able to extend support for Deno in PRPL without making uncomfortable trade-offs.


Thanks for reading! Go home for more notes.