Home > front end >  Resolve JavaScript imports based on current runtime?
Resolve JavaScript imports based on current runtime?

Time:08-21

Is there a way of importing a JavaScript/TypeScript module based on which JavaScript runtime (Node, Deno, Bun) is being used?

Something like:

if (Bun) {
import MyLib from "./mylib-bun.js";
} else if (Deno) {
import MyLib from "./mylib-deno.js";
} else if (Node) {
import MyLib from "./mylib-node.js";
}

It could be something like this, platform-specific import maps, import runtime=Deno { MyFunction } from "./mylib-deno.js", or anything else. I just need a way to import platform-specific bindings based on which JS runtime is being used.

CodePudding user response:

While it's certainly possible to use "dynamic" import() statements, as described in this answer, there are benefits to avoiding that pattern by restricting your modules to using static import statements. One such benefit, for example, is preserving static analysis of the module graph, which is performed by many tools in the JS ecosystem.

One way you can do that is to write your library code using abstractions on top of the expected runtimes and accessing those runtimes' features conditionally using feature-detection. Here's an example of what I mean by that:

env.mjs:

export function getEnvAsObject () {
  // Bun
  if (typeof globalThis.Bun?.env === 'function') {
    return globalThis.Bun?.env;
  }

  // Deno
  if (typeof globalThis.Deno?.env?.toObject === 'function') {
    return globalThis.Deno?.env?.toObject();
  }

  // Node
  if (typeof globalThis.process.env === 'object') {
    return globalThis.process.env;
  }

  // Handle unexpected runtime
  return {};
}

That way, the runtime detail is abstracted away from the consumer when the time comes to import and use it:

module.mjs:

import {getEnvAsObject} from './env.mjs';

const env = getEnvAsObject();
console.log(env);

# Run using Bun
bun module.mjs

# Run using Node
node module.mjs

# Run using Deno
deno run --allow-env module.mjs

The example env.mjs module above doesn't use other imports as dependencies, so it's pretty straightforward.

In scenarios which require more complex code involving other imports, preserving static analysis might require offering different entrypoints for your library, based on runtime. I'll also provide an example of what I mean by that below.

In the example, let’s say we have a library that provides two functions. Each reads the text content of a file on disk.

  • One returns an uppercase version of the text.
  • One returns a lowercase version of the text.

Both also accept an AbortSignal option to cancel the operation.

In TypeScript, the function signature for each of them might look like this:

(filePath: string, options?: { signal?: AbortSignal }) => Promise<string>

The first step would be to create a separate module for each runtime which requires a different import statement. In that module we create a common abstraction for reading a text file based on the signature above.

One for Node, where we import from Node's fs module:

io.node.mjs:

import {readFile} from 'node:fs/promises';

export function readTextFile (filePath, {signal} = {}) {
  // Ref: https://nodejs.org/docs/latest-v18.x/api/fs.html#fspromisesreadfilepath-options
  return readFile(filePath, {encoding: 'utf-8', signal});
}

One for Bun (this is simple because Bun uses Node's API, so we just re-export from the Node module):

io.bun.mjs:

export * from './io.node.mjs';

And one for Deno, where no import statement is necessary because this functionality is in the Deno namespace:

io.deno.mjs:

export function readTextFile (filePath, {signal} = {}) {
  // Ref: https://doc.deno.land/deno/[email protected]/~/Deno.readTextFile
  return Deno.readTextFile(filePath, {signal});
}

Then, the actual logic for the library functions can be written once in a style that's designed to be curried (which we'll get to in the next step):

io.mjs:

export async function getUpperCaseFileText (readTextFile, filePath, {signal} = {}) {
  const text = await readTextFile(filePath, {signal});
  return text.toUpperCase();
}

export async function getLowerCaseFileText (readTextFile, filePath, {signal} = {}) {
  const text = await readTextFile(filePath, {signal});
  return text.toLowerCase();
}

Finally, we can create one entrypoint to the library for each runtime environment. This is where the core functions are curried and exported:

One for Node:

lib.node.mjs:

import {readTextFile} from './io.node.mjs';

import {
  getLowerCaseFileText as lower,
  getUpperCaseFileText as upper,
} from './io.mjs';

export function getLowerCaseFileText (filePath, {signal} = {}) {
  return lower(readTextFile, filePath, {signal});
}

export function getUpperCaseFileText (filePath, {signal} = {}) {
  return upper(readTextFile, filePath, {signal});
}

One for Bun (again, just re-exporting from the Node module):

lib.bun.mjs:

export * from './lib.node.mjs';

And one for Deno:

lib.deno.mjs:

import {readTextFile} from './io.deno.mjs';

import {
  getLowerCaseFileText as lower,
  getUpperCaseFileText as upper,
} from './io.mjs';

export function getLowerCaseFileText (filePath, {signal} = {}) {
  return lower(readTextFile, filePath, {signal});
}

export function getUpperCaseFileText (filePath, {signal} = {}) {
  return upper(readTextFile, filePath, {signal});
}

I hope it's apparent how the currying is very little extra code, and that the only real difference between these entrypoints is the import specifier that's specific to each runtime. This allows the code to be statically-analyzable, which provides lots of benefits!

If someone wanted to use this contrived library, the only thing they'd need to change is the runtime name in the static import specifier:

module.mjs:

// import {getUpperCaseFileText} from './lib.bun.mjs'; // when using Bun
// import {getUpperCaseFileText} from './lib.node.mjs'; // when using Node
import {getUpperCaseFileText} from './lib.deno.mjs'; // when using Deno

const text = await getUpperCaseFileText('./lib.bun.mjs');
console.log(text); // logs => "EXPORT * FROM './LIB.NODE.MJS';"

and run it in the respective runtime:

bun module.mjs
node module.mjs
deno run --allow-read module.mjs

It might seem like a bit more work, but publishing your library using only static imports allows your consumers to enjoy all the same benefits of static analysis, and — conversely — by publishing a library which uses dynamic import(), you will deprive consumers of those benefits.

CodePudding user response:

You can detect the runtime and then use dynamic imports to load the desired module.

To detect the runtime you can inspect globalThis:

function getRuntime() {
  if ("Bun" in globalThis) return "bun";
  if ("Deno" in globalThis) return "deno";
  if (globalThis.process?.versions?.node) return "node";
}

This might not work for all versions of Bun/Deno/Node but it is a start and illustrates the idea (see also https://github.com/dsherret/which_runtime).

You can then load the module you want:

const { default: MyLib } = await import(`./mylib-${getRuntime()}.js`);
  • Related