Skip to content

Instantly share code, notes, and snippets.

@guest271314
Last active April 7, 2024 22:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save guest271314/e05fd62310b7ceab1a29fb5e6697a21b to your computer and use it in GitHub Desktop.
Save guest271314/e05fd62310b7ceab1a29fb5e6697a21b to your computer and use it in GitHub Desktop.
Intercepting and handling arbitrary static and dynamic Ecmascript import specifiers, protocols and file extensions

An issue filed in Bun I find interesting, Bun runtime plugin onResolve doesn't filter non-file: protocol imports #9863.

The gist is handling Ecmascript static import and dynamic import() that are arbitrary specifiers and setting arbitrary content. Something like

import demo1 from '1.demo';

console.log('demo 1', demo1);

import demo2 from 'demo:2';

console.log('demo 2', demo2);

console.log(await import('demo:alice')); // "Hi there, I am the 'alice' import!"

console.log(await import('demo:bob')); // "Hi there, I am the 'bob' import!"

We'll notice this is similar to node:fs or npm:node-fetch that might be used with deno or bun for disambiguity about which built-in or registry is being used for the import source.

Let's make this happen.

Bun

We'll do this in Bun first, specifically the plugin object Plugins. That's where the question was asked.

plugin2.js

const [, file] = Bun.argv;
const url = new URL(file, import.meta.url);
const script = await (await fetch(url.href)).text();
const regexp = /(?<=['"]|import\s\w+\sfrom(?=\s|)|import\((?=\s|))[\w\d+]+:[\w\d]+(?=['"])/g;
const matches = script.match(regexp);

import { plugin } from "bun";

for (const specifier of matches) {
  await plugin({
    name: specifier,
    async setup(build) {
      console.log("Plugin 2 loaded!", build);
      build.module(
        // The specifier, which can be any string - except a built-in, such as "buffer"
        specifier,
        async () => {
          console.log({ specifier });
          return {
            contents: `export default "Hi there, I am the '${specifier}' import!";`,
            loader: "ts",
          };
        },
      );
    },
  });
}

Now we can do

$ bun run -r ./plugin2.js ./index.js

which prints

{
  specifier: "demo:2",
}
{
  specifier: "x0000:2",
}
demo 1 Hello, 1!
demo 2 Hi there, I am the 'demo:2' import!
{
  specifier: "demo:alice",
}
Module {
  default: "Hi there, I am the 'demo:alice' import!",
}
{
  specifier: "demo:bob",
}
Module {
  default: "Hi there, I am the 'demo:bob' import!",
}
{
  specifier: "0:1",
}
Module {
  default: "Hi there, I am the '0:1' import!",
}
demo 3 Hi there, I am the 'x0000:2' import!

Node.js

Let's do the same thing using node. We might try the loaders first nodejs/loaders: ECMAScript Modules Loaders which I don't see a way to handle both static import and dynamic import(). I see a clue in the documentation referring to vm Node.js module VM (executing JavaScript) | Node.js v21.7.2 Documentation. Let's try with that module

node-vm.js

import vm from "node:vm";
import { readFileSync } from "node:fs";

const contextifiedObject = vm.createContext({
  print: console.log,
});

// Step 1
//
// Create a Module by constructing a new `vm.SourceTextModule` object. This
// parses the provided source text, throwing a `SyntaxError` if anything goes
// wrong. By default, a Module is created in the top context. But here, we
// specify `contextifiedObject` as the context this Module belongs to.
//
// Here, we attempt to obtain the default export from the module "foo", and
// put it into local binding "secret".

const script = readFileSync(
  new URL(import.meta.resolve("./index.js", import.meta.url)),
);
const scriptText = `${script}`.replace(/console.log/g, "print");

const bar = new vm.SourceTextModule(scriptText, {
  context: contextifiedObject,
  importModuleDynamically: async function importModuleDynamically(
    specifier,
    referrer,
    importAttributes,
  ) {
    console.log({ specifier });
    const m = new vm.SyntheticModule(["default"], () => {});
    await m.link(() => {});
    m.setExport("default", `Hi there, I am the '${specifier}' import!`);
    return m;
  },
});

// Step 2
//
// "Link" the imported dependencies of this Module to it.
//
// The provided linking callback (the "linker") accepts two arguments: the
// parent module (`bar` in this case) and the string that is the specifier of
// the imported module. The callback is expected to return a Module that
// corresponds to the provided specifier, with certain requirements documented
// in `module.link()`.
//
// If linking has not started for the returned Module, the same linker
// callback will be called on the returned Module.
//
// Even top-level Modules without dependencies must be explicitly linked. The
// callback provided would never be called, however.
//
// The link() method returns a Promise that will be resolved when all the
// Promises returned by the linker resolve.
//
// Note: This is a contrived example in that the linker function creates a new
// "foo" module every time it is called. In a full-fledged module system, a
// cache would probably be used to avoid duplicated modules.

async function linker(specifier, referencingModule) {
  console.log({ specifier });
  return new vm.SourceTextModule(
    `
      // The "secret" variable refers to the global variable we added to
      // "contextifiedObject" when creating the context.
      export default "Hi there, I am the '${specifier}' import!";
    `,
    {
      context: referencingModule.context,
    },
  );

  // Using `contextifiedObject` instead of `referencingModule.context`
  // here would work as well.
  throw new Error(`Unable to resolve dependency: ${specifier}`);
}
await bar.link(linker);

// Step 3
//
// Evaluate the Module. The evaluate() method returns a promise which will
// resolve after the module has finished evaluating.
await bar.evaluate();

Run with

node --experimental-default-type=module --experimental-vm-modules --no-warnings node-vm.js

which prints

{ specifier: '1.demo' }
{ specifier: 'demo:2' }
{ specifier: 'x0000:2' }
demo 1 Hi there, I am the '1.demo' import!
demo 2 Hi there, I am the 'demo:2' import!
{ specifier: 'demo:alice' }
[Module: null prototype] {
  default: "Hi there, I am the 'demo:alice' import!"
}
{ specifier: 'demo:bob' }
[Module: null prototype] {
  default: "Hi there, I am the 'demo:bob' import!"
}
{ specifier: '0:1' }
[Module: null prototype] {
  default: "Hi there, I am the '0:1' import!"
}
demo 3 Hi there, I am the 'x0000:2' import!

We've just used what is available in the given JavaScript runtime to handle arbitrary file extension and specifier imports. So you can create your own npm: or jsr: if you want to, and/or handle arbitrary protocols in your program, dynamically. This is particularly useful in environments that do not implement Import Maps - which neither bun or node do.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment