Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,6 @@ jobs:
- 21.x
- 22.x
- 24.x
os:
- ubuntu-latest
- macos-latest
- windows-latest

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Use Node.js ${{ matrix.node-version }}
Expand All @@ -50,19 +45,41 @@ jobs:
- run: npm test
- name: Rename coverage file
run: >
mv coverage/lcov.info coverage/${{ matrix.node-version }}_${{ matrix.os }}_lcov.info
mv coverage/lcov.info coverage/${{ matrix.node-version }}_lcov.info
- name: Archive code coverage results
if: success()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage_${{ matrix.os }}_${{ matrix.node-version}}
name: coverage_${{ matrix.node-version}}
if-no-files-found: ignore
path: coverage/${{ matrix.node-version }}_${{ matrix.os }}_lcov.info
path: coverage/${{ matrix.node-version }}_lcov.info

# This will clobber any coverage generated by the previous `npm test`.
# We are opting to omit TS coverage and stick to pass or fail only for TS.
- run: npm run test:ts

bundlers:
runs-on: ${{ matrix.os }}

strategy:
matrix:
node-version:
- 18.5.0
- 24.x
os:
- ubuntu-latest
- macos-latest
- windows-latest

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npx imhotap --files test/low-level/bundler.mjs test/other/v18-bundlers.mjs


coverage:
runs-on: ubuntu-latest
Expand Down
95 changes: 90 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# import-in-the-middle

**`import-in-the-middle`** is a module loading interceptor inspired by
[`require-in-the-middle`](https://npm.im/require-in-the-middle), but
specifically for ESM modules. In fact, it can even modify modules after loading
time.
[`require-in-the-middle`](https://npm.im/require-in-the-middle). It intercepts
ESM modules and can opt into CommonJS when synchronous hooks are available.

## Usage

Expand Down Expand Up @@ -115,6 +114,88 @@ fs.readFileSync('file.txt')
node --import=./instrument.mjs ./my-app.mjs
```

## Bundler integrations

> **Note:** The bundler integration API is experimental. It may change in minor versions.

Bundlers can generate ESM and CommonJS wrappers with
`createWrapperModule`:

```js
import { createWrapperModule } from 'import-in-the-middle/bundler.mjs'

const wrapper = await createWrapperModule({
module: { url, format, source, specifier, data, passthroughExports },
resolve,
load
})
```

`format` is optional. When omitted, IITM detects ESM or CommonJS from the source before it creates the wrapper.

CommonJS integrations can use the lazy-loading facade. Both entry points expose
`getPackageDetails`, which finds the nearest named package for a resolved file.
The CommonJS facade also exposes the module format detection used by the Node
loader:

```js
const {
createWrapperModule,
getNodeModuleFormat,
getPackageDetails
} = require('import-in-the-middle/bundler')

const packageDetails = getPackageDetails(url)
const format = getNodeModuleFormat(url, packageDetails?.packageJsonUrl, packageDetails?.type)
```

`getPackageDetails` accepts a resolved `file:` URL. It returns the package
`name`, optional `version` and `type`, package and `package.json` URLs, and the
slash-separated module `path`. It walks past unnamed package scopes, which lets
bundlers identify linked workspace packages without relying on a `node_modules`
path. It returns `undefined` when no named package owns the URL.

`url` is the canonical `file:` or `node:` URL reported to hooks. `resolve` and
`load` adapt the bundler's resolver and source loader to the same URL-based
module graph. `resolve` always receives the declaring module's `parentURL`.
Both callbacks can be omitted when an explicitly formatted CommonJS module
supplies its source. Only `load` is required when that source is omitted.

`getNodeModuleFormat` returns `undefined` for typeless `.js` and `.ts` files.
The bundler must determine their format from the source.

The package helpers read current metadata on each call. A bundler can cache
their results for one build and discard that cache before a watch rebuild.

The optional `data` value must be JSON-serializable. It is embedded in the
wrapper and passed as the fourth argument to `Hook` callbacks, allowing package
metadata needed by instrumentation to reach the bundled runtime.

`passthroughExports` identifies ESM exports that must keep their original live
bindings. It accepts an iterable of names or a selector that receives all
resolved exports after IITM loads the module graph. Each resolved export has
the public `name`, the defining module `url`, and its `localName` when it has a
local ESM binding. IITM emits direct re-exports for selected names and exposes
their current values to `Hook` callbacks. Assignments from a callback do not
replace these bindings. Other exports remain patchable. This option has no
effect on CommonJS modules, and names that the module does not export are
ignored.

The result contains generated `code`, an `imports` manifest, `watchFiles`, and
`sideEffects: true`. The code imports only relative placeholder specifiers. The
bundler adapter provides it as a virtual module and maps each placeholder using
the manifest, so filesystem paths, virtual IDs, external modules, and cache
invalidation remain owned by the bundler. `watchFiles` are file URLs that the
adapter converts to its native watch-dependency format.

CommonJS results also contain `sourceLineOffset`. It specifies the number of
generated lines before the original source. The source starts at column zero,
so an adapter can shift an existing source map without parsing the wrapper.

The runtime import in the manifest must be bundled with the wrapper. Keeping it
external can create a second hook registry at runtime. It is CommonJS and must
go through the bundler's normal CommonJS transform.

## Synchronous loader hooks

On Node.js versions that support
Expand Down Expand Up @@ -150,7 +231,7 @@ if (supportsSyncHooks()) {
import { register } from 'import-in-the-middle/register-hooks.mjs'
import { Hook } from 'import-in-the-middle'

register({ include: ['package-i-want-to-include'] })
register({ include: ['package-i-want-to-include'], commonjs: true })

Hook(['package-i-want-to-include'], (exported, name, baseDir) => {
// Instrument the module
Expand All @@ -163,6 +244,9 @@ node --import=./instrument.mjs ./my-app.mjs

`register()` accepts the same `include` / `exclude` options as the asynchronous
loader and throws on a Node.js version where `supportsSyncHooks()` is `false`.
Set `commonjs: true` to intercept both `require()` and ESM loaded through
`require()`. This is opt-in so consumers can continue using
`require-in-the-middle` for CommonJS without double instrumentation.

### Custom matching with `shouldInclude`

Expand Down Expand Up @@ -254,7 +338,8 @@ On Node.js versions where type stripping is not enabled by default, run with
* You cannot add new exports to a module. You can only modify existing ones.
* While bindings to module exports end up being "re-bound" when modified in a
hook, dynamically imported modules cannot be altered after they're loaded.
* Modules loaded via `require` are not affected at all.
* Modules loaded via `require` are only affected by synchronous registration
with `commonjs: true`.
* A module's set of export *names* is assumed to be stable for the lifetime of
the process. `import-in-the-middle` reads a module's source once to lex its
exports and reuses that export set on later loads of the same URL. An upstream
Expand Down
140 changes: 140 additions & 0 deletions bundler.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
export type WrapperSource = string | ArrayBuffer | ArrayBufferView

export type PackageDetails = {
name: string
packageJsonUrl: string
packageUrl: string
path: string
type?: string
version?: string
}

export type JsonValue =
| boolean
| null
| number
| string
| JsonValue[]
| { [key: string]: JsonValue }

export type JsonCompatible<Value> =
Value extends boolean | null | number | string
? Value
: Value extends readonly unknown[]
? { [Key in keyof Value]: JsonCompatible<Value[Key]> }
: Value extends object
? { [Key in keyof Value]: JsonCompatible<Value[Key]> }
: never

export type WrapperExport = {
name: string
url: string
localName?: string
}

export type PassthroughExports =
| Iterable<string>
| ((exports: readonly WrapperExport[]) => Iterable<string>)

export type BundlerModule<Data = JsonValue> = {
url: string
format?: string
specifier: string
source?: WrapperSource
data?: JsonCompatible<Data>
passthroughExports?: PassthroughExports
}

export type ModuleContext = {
format?: string
parentURL?: string
}

export type ResolveContext = ModuleContext & {
parentURL: string
}

export type ModuleTarget = {
url: string
format?: string
}

export type ResolveResult = ModuleTarget & {
watchFiles?: Iterable<string>
}

export type LoadResult = {
source?: WrapperSource
format?: string
watchFiles?: Iterable<string>
}

export type WrapperImport = {
specifier: string
kind: 'module' | 'runtime'
target: ModuleTarget
external: boolean
}

export type WrapperModule = {
code: string
imports: WrapperImport[]
watchFiles: string[]
sideEffects: true
sourceLineOffset?: number
}

type CommonJSFormat = 'commonjs' | 'commonjs-typescript'

type Resolve = (
specifier: string,
context: ResolveContext
) => ResolveResult | Promise<ResolveResult>

type Load = (
url: string,
context: ModuleContext
) => LoadResult | Promise<LoadResult>

type InlineCommonJSOptions<Data> = {
module: BundlerModule<Data> & {
format: CommonJSFormat
source: WrapperSource
}
resolve?: Resolve
load?: Load
}

type LoadedCommonJSOptions<Data> = {
module: BundlerModule<Data> & {
format: CommonJSFormat
source?: WrapperSource
}
resolve?: Resolve
load: Load
}

type AdapterBackedOptions<Data> = {
module: BundlerModule<Data>
resolve: Resolve
load: Load
}

export type CreateWrapperModuleOptions<Data = JsonValue> =
| InlineCommonJSOptions<Data>
| LoadedCommonJSOptions<Data>
| AdapterBackedOptions<Data>

/**
* EXPERIMENTAL
* This API is experimental and may change in minor versions.
*/
export declare function createWrapperModule<Data = JsonValue>(
options: CreateWrapperModuleOptions<Data>
): Promise<WrapperModule>

/**
* EXPERIMENTAL
* This API is experimental and may change in minor versions.
*/
export declare function getPackageDetails(url: string): PackageDetails | undefined
11 changes: 11 additions & 0 deletions bundler.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export * from './bundler.mjs'

/**
* EXPERIMENTAL
* This API is experimental and may change in minor versions.
*/
export declare function getNodeModuleFormat(
url: string,
packageJsonUrl?: string,
packageType?: string
): 'builtin' | 'module' | 'module-typescript' | 'commonjs' | 'commonjs-typescript' | undefined
37 changes: 37 additions & 0 deletions bundler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use strict'

const { readFileSync } = require('node:fs')

const createGetNodeModuleFormat = require('./lib/get-node-module-format.js')
const createGetPackageDetails = require('./lib/get-package-details.js')

const getNodeModuleFormat = createGetNodeModuleFormat(readFileSync, false)
const getPackageDetails = createGetPackageDetails(readFileSync)

/** @type {typeof import('./bundler.mjs').createWrapperModule|undefined} */
let createWrapperModuleImplementation

/**
* EXPERIMENTAL
* This API is experimental and may change in minor versions.
*
* @param {Parameters<typeof import('./bundler.mjs').createWrapperModule>[0]} options
*/
async function createWrapperModule (options) {
createWrapperModuleImplementation ??= (await import('./bundler.mjs')).createWrapperModule
return createWrapperModuleImplementation(options)
}

exports.createWrapperModule = createWrapperModule

/**
* EXPERIMENTAL
* This API is experimental and may change in minor versions.
*/
exports.getPackageDetails = getPackageDetails

/**
* EXPERIMENTAL
* This API is experimental and may change in minor versions.
*/
exports.getNodeModuleFormat = getNodeModuleFormat
Loading