DEV Community

Kelvin
Kelvin

Posted on

Implementing Glob Imports in Node.js

Vite supports glob imports, which let you import many modules at once. But if you don’t want to use Vite, can you achieve something similar using only built-in Node.js capabilities?

Node.js Custom Hooks

Recently I noticed that tools like oxc-node and swc-node are implemented using Node.js custom hooks. For example, with oxc-node, you can run TypeScript directly by using the --import flag:

node --import @oxc-node/core/register ./path/to/entry.ts
Enter fullscreen mode Exit fullscreen mode

With --import, Node.js will first load the script specified after the flag to register custom hooks, and then use those hooks to transform code at runtime. In oxc-node’s case, it uses Oxc to compile TypeScript into JavaScript before execution.

So what exactly are custom hooks?

You can think of Node.js module loading as having two phases. Take this line for example:

import a from './a.js'
Enter fullscreen mode Exit fullscreen mode

Phase 1: Resolve

Node.js resolves the module specifier and locates the module. Whether it’s a relative path, an absolute path, a directory, or something from node_modules, Node ultimately turns it into an absolute reference pointing to a specific file.

Phase 2: Load

Node.js loads the module using the resolved absolute file reference, reads the source code, and returns it.

This is Node’s default behavior. Node also exposes two hooks so we can intercept these phases and add our own logic. That’s how swc-node and oxc-node work: during the load phase, instead of returning the original source, they transpile it first (e.g. TypeScript → JavaScript) and then return the transformed source.

We can use the same mechanism to implement Vite-style glob imports.

Implementing the resolve Hook

Vite uses import.meta.glob() to bulk import modules. Static import statements can’t call functions, but they can include a query string. If we append ?glob to a module specifier, we can treat it as a “glob import”:

import type { ResolveHook } from 'node:module'

export const resolve: ResolveHook = (specifier, context, nextResolve) => {
  const url = new URL(specifier, context.parentURL)

  if (!url?.searchParams.has('glob')) return nextResolve(specifier, context)

  return {
    shortCircuit: true,
    url: url.toString(),
    importAttributes: context.importAttributes,
  }
}
Enter fullscreen mode Exit fullscreen mode

Here:

  • specifier is the module specifier. Suppose we import b.js from a.js:
// a.js
import b from './b.js'
Enter fullscreen mode Exit fullscreen mode

Then when resolving b.js, specifier is ./b.js. If the module is the entry file passed directly to Node (e.g. node ./a.js), then specifier is an absolute File URL like: file:///Users/root/workspace/globload/a.js.

  • context is the context object. The key field is parentURL, which points to the module that imported the current module. In the example above, it would be file:///Users/root/workspace/globload/a.js. For the entry module, parentURL is undefined.

First, we turn the specifier into an absolute URL using URL:

const url = new URL(specifier, context.parentURL)
Enter fullscreen mode Exit fullscreen mode

After parsing, ./b.js becomes a File URL like file:///Users/root/workspace/globload/b.js. We then check whether the URL contains the glob query parameter. If it doesn’t, we call nextResolve to pass control to the next hook (or Node itself).

If it does contain glob, we return an object. Returning this object means we want to handle it ourselves and proceed to the load phase (Node will call our load hook next).

  • shortCircuit indicates whether to stop further resolve hooks. We must set it to true, otherwise we could end up in an infinite loop.
  • url is the absolute File URL we resolved.
  • importAttributes is a new feature from ES2025. It provides metadata about the import, for example importing JSON modules:
import json from './foo.json' with { type: 'json' }
Enter fullscreen mode Exit fullscreen mode

In this case, importAttributes would be { type: "json" }. We can simply forward it.

At this point, the resolve hook is done. It’s intentionally minimal: detect glob imports and forward the resolved URL to the load hook.

Implementing the load Hook

The load hook is a bit more complex, because most of the logic lives here. Let’s start with a minimal example:

import type { LoadHook } from 'node:module'
import fs from 'node:fs/promises'
import { fileURLToPath } from 'node:url'

export const load: LoadHook = async (url, context, nextLoad) => {
  const source = await fs.readFile(fileURLToPath(url.toString()), 'utf-8')

  return {
    shortCircuit: true,
    format: 'module',
    source,
  }
}
Enter fullscreen mode Exit fullscreen mode

This is essentially Node’s default behavior: read the module source from disk and return it. format indicates the module format; since we always return ESM here, we hardcode it to 'module'.

Note that most fs APIs accept file paths, not File URLs, so we need fileURLToPath() to convert the File URL into a path.

Now let’s implement the actual glob import logic. Our goal is to parse the glob pattern in the module specifier, find all matched files, and generate a module that imports them all at once.

According to the Vite docs, we want to turn:

import modules from './dir/*.js'
Enter fullscreen mode Exit fullscreen mode

into something like:

export default {
  './dir/bar.js': () => import('./dir/bar.js'),
  './dir/foo.js': () => import('./dir/foo.js'),
}
Enter fullscreen mode Exit fullscreen mode

Just like in resolve, we first check if the query includes glob. If not, we call nextLoad to pass control to the next hook:

const absoluteUrl = new URL(url)

if (!absoluteUrl?.searchParams.has('glob')) return nextLoad(url, context)
Enter fullscreen mode Exit fullscreen mode

Next is the most important part: turn a glob pattern like ./dir/*.js into an array of absolute file paths:

const files = [
  '/Users/root/workspace/globload/dir/bar.js',
  '/Users/root/workspace/globload/dir/foo.js',
]
Enter fullscreen mode Exit fullscreen mode

We can do this with tinyglobby, similar to Vite:

const absoluteGlobPattern = fileURLToPath(absoluteUrl).replace(/\\/g, '/')
const files = await glob(absoluteGlobPattern, {
  absolute: true,
})
Enter fullscreen mode Exit fullscreen mode

replace(/\\/g, '/') normalizes Windows path separators to match other platforms.

Now we need to build the module object:

{
  './dir/bar.js': () => import('./dir/bar.js'),
  './dir/foo.js': () => import('./dir/foo.js'),
}
Enter fullscreen mode Exit fullscreen mode

We loop through files:

const modules: string[] = []

for (const absoluteFilePath of files) {
  const moduleFileUrl = pathToFileURL(absoluteFilePath).toString()
  const relativePathKey = path
    .relative(process.cwd(), absoluteFilePath)
    .replace(/\\/g, '/')

  modules.push(`'${relativePathKey}': () => import('${moduleFileUrl}')`)
}
Enter fullscreen mode Exit fullscreen mode

Inside the loop:

  • We convert the file path into a File URL, because import() accepts a URL-like specifier.
  • For the object key, we use a path relative to process.cwd().

We also need to forward the importAttributes from the resolve hook into each child import:

const modules: string[] = []
const importAttributesJson = JSON.stringify(context.importAttributes)
const hasImportAttributes = Object.keys(context.importAttributes).length > 0

for (const absoluteFilePath of files) {
  const moduleFileUrl = pathToFileURL(absoluteFilePath).toString()
  const relativePathKey = path
    .relative(process.cwd(), absoluteFilePath)
    .replace(/\\/g, '/')

  const importAttributesArg = hasImportAttributes
    ? `, { with: ${importAttributesJson} }`
    : ''

  modules.push(
    `'${relativePathKey}': () => import('${moduleFileUrl}'${importAttributesArg})`,
  )
}
Enter fullscreen mode Exit fullscreen mode

Finally, we replace source with the generated module source:

const source = `
  export default {
    ${modules.join(',\n')}
  };`
Enter fullscreen mode Exit fullscreen mode

Registering the Hooks

After implementing the resolve and load hooks, we need to register them. Save the hook implementation as loader.ts, then create register.ts:

// register.ts
import { register } from 'node:module'

register(new URL('./loader.ts', import.meta.url))
Enter fullscreen mode Exit fullscreen mode
// loader.ts
import type { LoadHook, ResolveHook } from 'node:module'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { glob } from 'tinyglobby'

export const resolve: ResolveHook = (specifier, context, nextResolve) => {
  const url = new URL(specifier, context.parentURL)

  if (!url?.searchParams.has('glob')) return nextResolve(specifier, context)

  return {
    shortCircuit: true,
    url: url.toString(),
    importAttributes: context.importAttributes,
  }
}

export const load: LoadHook = async (url, context, nextLoad) => {
  const absoluteUrl = new URL(url)

  if (!absoluteUrl?.searchParams.has('glob')) return nextLoad(url, context)

  const absoluteGlobPattern = fileURLToPath(absoluteUrl).replace(/\\/g, '/')
  const files = await glob(absoluteGlobPattern, {
    absolute: true,
  })

  const modules: string[] = []
  const importAttributesJson = JSON.stringify(context.importAttributes)
  const hasImportAttributes = Object.keys(context.importAttributes).length > 0

  for (const absoluteFilePath of files) {
    const moduleFileUrl = pathToFileURL(absoluteFilePath).toString()
    const relativePathKey = path
      .relative(process.cwd(), absoluteFilePath)
      .replace(/\\/g, '/')

    const importAttributesArg = hasImportAttributes
      ? `, { with: ${importAttributesJson} }`
      : ''
    modules.push(
      `'${relativePathKey}': () => import('${moduleFileUrl}'${
        hasImportAttributes ? `, { with: ${importAttributesJson} }` : ''
      })`,
    )
  }

  const source = `
    export default {
      ${modules.join(',\n')}
    };`

  return {
    shortCircuit: true,
    format: 'module',
    source,
  }
}
Enter fullscreen mode Exit fullscreen mode

:::

Now let’s test it. Create a dir folder and test.js:

// test.js
import modules from './dir/*.js?glob'

for (const pathKey in modules) {
  const module = await modules[pathKey]()
  console.log(module)
}
Enter fullscreen mode Exit fullscreen mode
// dir/a.js
export const a = 'a'
Enter fullscreen mode Exit fullscreen mode
// dir/b.js
export const b = 'b'
Enter fullscreen mode Exit fullscreen mode

Run node --import ./register.ts ./test.js, and you should see the expected output:

Adding eager Mode

So far we’ve implemented the simplest form of glob imports: the returned object values are always functions, and the real import() only happens when you call them.

But in Vite, import.meta.glob supports an eager option, meaning all modules are imported immediately, and you get the module namespace objects directly:

const modules = import.meta.glob('./dir/*.js', { eager: true })
Enter fullscreen mode Exit fullscreen mode

In Vite, this is transformed into:

import * as __vite_glob_0_0 from './dir/bar.js'
import * as __vite_glob_0_1 from './dir/foo.js'
const modules = {
  './dir/bar.js': __vite_glob_0_0,
  './dir/foo.js': __vite_glob_0_1,
}
Enter fullscreen mode Exit fullscreen mode

We can follow the same idea by adding an eager query parameter:

const mode = absoluteUrl.searchParams.has('eager') ? 'eager' : 'lazy'
Enter fullscreen mode Exit fullscreen mode

To implement eager, we need to do two things:

  1. Add static import statements at the top of the generated module source.
  2. Replace the values in the returned object from dynamic import() functions to the statically imported bindings.
export const load: LoadHook = async (url, context, nextLoad) => {
  const absoluteUrl = new URL(url)

  if (!absoluteUrl?.searchParams.has('glob')) return nextLoad(url, context)

  const absoluteGlobPattern = fileURLToPath(absoluteUrl).replace(/\\/g, '/')
  const mode = absoluteUrl.searchParams.has('eager') ? 'eager' : 'lazy'
  const files = await glob(absoluteGlobPattern, {
    absolute: true,
  })

  let importCounter = 0
  const importStatements: string[] = []
  const modules: string[] = []
  const importAttributesJson = JSON.stringify(context.importAttributes)
  const hasImportAttributes = Object.keys(context.importAttributes).length > 0

  for (const absoluteFilePath of files) {
    const moduleFileUrl = pathToFileURL(absoluteFilePath).toString()
    const relativePathKey = path
      .relative(process.cwd(), absoluteFilePath)
      .replace(/\\/g, '/')

    if (mode === 'eager') {
      const importName = `__globbed_eager_${importCounter++}`
      importStatements.push(
        `import * as ${importName} from '${moduleFileUrl}' ${hasImportAttributes ? `with ${importAttributesJson}` : ''};`,
      )
      modules.push(`'${relativePathKey}': ${importName}`)
    } else {
      const importAttributesArg = hasImportAttributes
        ? `, { with: ${importAttributesJson} }`
        : ''
      modules.push(
        `'${relativePathKey}': () => import('${moduleFileUrl}'${importAttributesArg})`,
      )
    }
  }

  const source = `
    ${importStatements.join('\n')}
    export default {
      ${modules.join(',\n')}
    };`

  return {
    shortCircuit: true,
    format: 'module',
    source,
  }
}
Enter fullscreen mode Exit fullscreen mode

A quick test:

// test.js
import modules from './dir/*.js?glob&eager'

for (const pathKey in modules) {
  const module = modules[pathKey]
  console.log(module)
}
Enter fullscreen mode Exit fullscreen mode

Run node --import ./register.ts ./test.js, and you should see the expected output:

Named Imports

Vite also supports selecting a specific export via the import option:

const modules = import.meta.glob('./dir/*.js', { import: 'setup' })
Enter fullscreen mode Exit fullscreen mode

Which becomes:

const modules = {
  './dir/bar.js': () => import('./dir/bar.js').then((m) => m.setup),
  './dir/foo.js': () => import('./dir/foo.js').then((m) => m.setup),
}
Enter fullscreen mode Exit fullscreen mode

And in eager mode:

import { setup as __vite_glob_0_0 } from './dir/bar.js'
import { setup as __vite_glob_0_1 } from './dir/foo.js'
const modules = {
  './dir/bar.js': __vite_glob_0_0,
  './dir/foo.js': __vite_glob_0_1,
}
Enter fullscreen mode Exit fullscreen mode

We can implement the same behavior by adding another query parameter:

const importKey = absoluteUrl.searchParams.get('import')
Enter fullscreen mode Exit fullscreen mode

Then we handle both modes:

  • In lazy mode, we can map the imported module namespace via .then(m => m.xxx)
  • In eager mode, we generate static imports using import { xxx as local } or import { default as local } instead of import * as ns
export const load: LoadHook = async (url, context, nextLoad) => {
  const absoluteUrl = new URL(url)

  if (!absoluteUrl?.searchParams.has('glob')) return nextLoad(url, context)

  const absoluteGlobPattern = fileURLToPath(absoluteUrl).replace(/\\/g, '/')
  const mode = absoluteUrl.searchParams.has('eager') ? 'eager' : 'lazy'
  const importKey = absoluteUrl.searchParams.get('import')
  const files = await glob(absoluteGlobPattern, {
    absolute: true,
  })

  let importCounter = 0
  const importStatements: string[] = []
  const modules: string[] = []
  const importAttributesJson = JSON.stringify(context.importAttributes)
  const hasImportAttributes = Object.keys(context.importAttributes).length > 0

  for (const absoluteFilePath of files) {
    const moduleFileUrl = pathToFileURL(absoluteFilePath).toString()
    const relativePathKey = path
      .relative(process.cwd(), absoluteFilePath)
      .replace(/\\/g, '/')

    if (mode === 'eager') {
      const importName = `__globbed_eager_${importCounter++}`

      let importStatement: string
      if (importKey) {
        if (importKey === 'default') {
          importStatement = `import { default as ${importName} } from '${moduleFileUrl}' ${hasImportAttributes ? `with ${importAttributesJson}` : ''};`
        } else {
          importStatement = `import { ${importKey} as ${importName} } from '${moduleFileUrl}' ${hasImportAttributes ? `with ${importAttributesJson}` : ''};`
        }
      } else {
        importStatement = `import * as ${importName} from '${moduleFileUrl}' ${hasImportAttributes ? `with ${importAttributesJson}` : ''};`
      }

      importStatements.push(importStatement)
      modules.push(`'${relativePathKey}': ${importName}`)
    } else {
      const importAttributesArg = hasImportAttributes
        ? `, { with: ${importAttributesJson} }`
        : ''

      let importAccess = ''
      if (importKey) {
        if (importKey === 'default') {
          importAccess = '.then(m => m.default)'
        } else {
          importAccess = `.then(m => m.${importKey})`
        }
      }

      modules.push(
        `'${relativePathKey}': () => import('${moduleFileUrl}'${importAttributesArg})${importAccess}`,
      )
    }
  }

  const source = `
    // Dynamically generated by globload
    ${importStatements.join('\n')}

    export default {
      ${modules.join(',\n')}
    };`

  return {
    shortCircuit: true,
    format: 'module',
    source,
  }
}
Enter fullscreen mode Exit fullscreen mode

A quick test:

// test.js
import modules from './dir/*.js?glob&eager&import=default'

for (const pathKey in modules) {
  const module = modules[pathKey]
  console.log(module)
}
Enter fullscreen mode Exit fullscreen mode
// dir/a.js
const a = 'a'

export { a as default }
Enter fullscreen mode Exit fullscreen mode
// dir/b.js
export const b = 'b'

export { b as default }
Enter fullscreen mode Exit fullscreen mode

Run node --import ./register.ts ./test.js, and you should see the expected output:

Summary

At this point, we’ve used Node.js custom hooks to implement import.meta.glob-style lazy mode, eager mode, and named export selection. In real Node.js development (for example in NestJS), this can be handy for bulk importing modules, plugins, or env/config files—without needing a bundler.

The full source code is open on GitHub: globload. Feel free to check it out.


Enter fullscreen mode Exit fullscreen mode

Top comments (0)