Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .changelog/01.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
breaking
Rewrite to use new initialization customization hook. Update documentation.

No longer assume `node.importmap` file path, but instead require explicitly setting the importMap or importMapUrl via [options.data](https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options).
11 changes: 4 additions & 7 deletions .github/workflows/build_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,11 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "16.12"
- uses: pnpm/action-setup@v2.0.1
with:
version: 6.20.3
node-version: "latest"
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm run check-format
- run: pnpm run lint
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pretty-quick --staged
63 changes: 46 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,48 +1,77 @@
# @node-loader/import-maps

A [nodejs loader](https://nodejs.org/dist/latest-v13.x/docs/api/esm.html#esm_experimental_loaders) for [import maps](https://github.com/WICG/import-maps). This allows you to customize module resolution by creating a `node.importmap` file.
[NodeJS customization hooks](https://nodejs.org/api/module.html#customization-hooks) to add support for [import maps](https://github.com/WICG/import-maps) to NodeJS. This allows you to customize module resolution by creating a `node.importmap` file.

## Installation

```sh
npm install --save @node-loader/import-maps

# Or, if you prefer Yarn
yarn add --save @node-loader/import-maps
pnpm install --save @node-loader/import-maps
```

## Usage

Create a file `node.importmap` in the current working directory:
Create a file (e.g. `node.importmap`) in the current working directory:

```json
{
"imports": {
"my-module": "file:///Users/name/code/my-module.js"
"my-module": "file:///home/name/code/my-module.js"
}
}
```

Now create a file that imports the mapped module:
Now create a file `main.js` that imports the mapped module:

```js
import "my-module";
```

Now run node with the `--experimental-loader` flag:
Now create a [startup module](https://nodejs.org/api/cli.html#--importmodule) `register-hooks.js`:

```sh
node --experimental-loader @node-loader/import-maps file.js
```js
import { register } from "node:module";
import { MessageChannel } from "node:worker_threads";

const messageChannel = new MessageChannel();

global.importMapPort = messageChannel.port1;

register("@node-loader/import-maps", {
data: {
// optional, provides a way to update the import map later on
port: messageChannel.port2,

// optional, import map object
importMap: {
imports: {},
scopes: {},
},

// optional, file url resolved relative to current working directory
// This option is ignored if importMap is provided
importMapUrl: "./node.importmap",
},
transferList: [messageChannel.port2],
});
```

## Configuration
Now run main.js with the [`--import`](https://nodejs.org/api/cli.html#--importmodule) NodeJS flag:

By default, node-loader import maps looks for a configuration file called `node.importmap` in the current working directory. To specify the file path to the configuration file, provide the `IMPORT_MAP_PATH` environment variable:
To dynamically change the import map after startup, do the following:

```sh
IMPORT_MAP_PATH=/Users/name/some/dir/node.importmap node --experimental-loader @node-loader/import-maps file.js
```js
global.importMapPort.postMessage({
// Provide either importMap or importMapUrl, but not both
importMap: {
imports: {},
scopes: {},
},

importMapUrl: "./node.importmap",
});
```

## Composition

If you wish to combine import maps with other NodeJS loaders, you may do so by using [node-loader-core](https://github.com/node-loader/node-loader-core).
```sh
node --import ./register-hooks.js main.js
```
26 changes: 13 additions & 13 deletions lib/import-map-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ export function resolveSpecifier(importMap, specifier, parentURL) {
) {
const scopeImportsMatch = resolveImportsMatch(
normalizedSpecifier,
importMap.scopes[scopePrefix]
importMap.scopes[scopePrefix],
);
if (scopeImportsMatch) {
return scopeImportsMatch;
}
} else {
const topLevelImportsMatch = resolveImportsMatch(
normalizedSpecifier,
importMap.imports
importMap.imports,
);
if (topLevelImportsMatch) {
return topLevelImportsMatch;
Expand All @@ -48,7 +48,7 @@ function resolveImportsMatch(normalizedSpecifier, specifierMap) {
if (specifierKey === normalizedSpecifier) {
if (resolutionResult === null) {
throw TypeError(
`The import map resolution of ${specifierKey} failed due to a null entry`
`The import map resolution of ${specifierKey} failed due to a null entry`,
);
}
return resolutionResult;
Expand All @@ -58,15 +58,15 @@ function resolveImportsMatch(normalizedSpecifier, specifierMap) {
) {
if (resolutionResult === null) {
throw TypeError(
`The import map resolution of ${specifierKey} failed due to a null entry`
`The import map resolution of ${specifierKey} failed due to a null entry`,
);
}
const afterPrefix = normalizedSpecifier.slice(specifierKey.length);
try {
return new URL(afterPrefix, resolutionResult).href;
} catch {
throw TypeError(
`The import map resolution of ${specifierKey} failed due to URL parse failure`
`The import map resolution of ${specifierKey} failed due to URL parse failure`,
);
}
}
Expand Down Expand Up @@ -95,7 +95,7 @@ export function resolveAndComposeImportMap(parsed) {
// Step 4.2
sortedAndNormalizedImports = sortAndNormalizeSpecifierMap(
parsed.imports,
baseURL
baseURL,
);
}

Expand All @@ -115,13 +115,13 @@ export function resolveAndComposeImportMap(parsed) {

// Step 7
const invalidKeys = Object.keys(parsed).filter(
(key) => key !== "imports" && key !== "scopes"
(key) => key !== "imports" && key !== "scopes",
);
if (invalidKeys.length > 0) {
console.warn(
`Invalid top-level key${
invalidKeys.length > 0 ? "s" : ""
} in import map - ${invalidKeys.join(", ")}`
} in import map - ${invalidKeys.join(", ")}`,
);
}

Expand All @@ -147,15 +147,15 @@ function sortAndNormalizeSpecifierMap(map, baseURL) {
let addressURL = parseURLLikeSpecifier(value, baseURL);
if (addressURL === null) {
console.warn(
`Invalid URL address for import map specifier '${specifierKey}'`
`Invalid URL address for import map specifier '${specifierKey}'`,
);
normalized[normalizedSpecifierKey] = null;
continue;
}

if (specifierKey.endsWith("/") && !addressURL.endsWith("/")) {
console.warn(
`Invalid URL address for import map specifier '${specifierKey}' - since the specifier ends in slash, so must the address`
`Invalid URL address for import map specifier '${specifierKey}' - since the specifier ends in slash, so must the address`,
);
normalized[normalizedSpecifierKey] = null;
continue;
Expand Down Expand Up @@ -199,7 +199,7 @@ function sortAndNormalizeScopes(map, baseURL) {
const potentialSpecifierMap = map[scopePrefix];
if (!isPlainObject(potentialSpecifierMap)) {
throw TypeError(
`The value of scope ${scopePrefix} must be a JSON object`
`The value of scope ${scopePrefix} must be a JSON object`,
);
}

Expand All @@ -208,14 +208,14 @@ function sortAndNormalizeScopes(map, baseURL) {
scopePrefixURL = new URL(scopePrefix, baseURL).href;
} catch {
console.warn(
`Scope prefix URL '${scopePrefix}' was not parseable in import map`
`Scope prefix URL '${scopePrefix}' was not parseable in import map`,
);
continue;
}

normalized[scopePrefixURL] = sortAndNormalizeSpecifierMap(
potentialSpecifierMap,
baseURL
baseURL,
);
}

Expand Down
79 changes: 46 additions & 33 deletions lib/node-import-map-loader.js
Original file line number Diff line number Diff line change
@@ -1,51 +1,64 @@
import path from "path";
import { promises as fs } from "fs";
import { promises as fs } from "node:fs";
import { URL } from "node:url";
import {
resolveAndComposeImportMap,
resolveSpecifier,
} from "./import-map-utils.js";

let importMapPromise = getImportMapPromise();
let importMapPromise;

export async function resolve(specifier, context, defaultResolve) {
const { parentURL = null } = context;
const importMap = await importMapPromise;
const importMapUrl = resolveSpecifier(importMap, specifier, parentURL);
export async function initialize(registerData) {
importMapPromise = processRegisterData(registerData);

return defaultResolve(importMapUrl ?? specifier, context, defaultResolve);
if (registerData.port) {
registerData.port.on("message", (data) => {
importMapPromise = processRegisterData(data);
});
}
}

async function getImportMapPromise() {
const relativePath = process.env.IMPORT_MAP_PATH || "node.importmap";
const importMapPath = path.resolve(process.cwd(), relativePath);
async function processRegisterData(registerData) {
let importMap;

let str;
try {
str = await fs.readFile(importMapPath);
} catch (err) {
return emptyMap();
}
if (registerData.importMap) {
importMap = resolveAndComposeImportMap(registerData.importMap);
} else if (registerData.importMapUrl) {
let str;
try {
str = await fs.readFile(new URL(registerData.importMapUrl), "utf-8");
} catch (err) {
console.error(
`node-loader-import-maps: error reading import map from path '${registerData.importMapUrl}'`,
);
console.error(err);
throw err;
}

let json;
try {
json = await JSON.parse(str);
} catch (err) {
console.error(err);
throw Error(
`Import map at ${registerData.importMapUrl} contains invalid json: ${err.message}`,
);
}

let json;
try {
json = await JSON.parse(str);
} catch (err) {
throw Error(
`Import map at ${importMapPath} contains invalid json: ${err.message}`
);
importMap = resolveAndComposeImportMap(json);
}

return resolveAndComposeImportMap(json);
return importMap;
}

global.nodeLoader = global.nodeLoader || {};
export async function resolve(specifier, context, defaultResolve) {
const importMap = await importMapPromise;

global.nodeLoader.setImportMapPromise = function setImportMapPromise(promise) {
importMapPromise = promise.then((map) => {
return resolveAndComposeImportMap(map);
});
};
let importMapUrl;

if (importMap) {
const { parentURL = null } = context;
importMapUrl = resolveSpecifier(importMap, specifier, parentURL);
}

function emptyMap() {
return { imports: {}, scopes: {} };
return defaultResolve(importMapUrl ?? specifier, context);
}
26 changes: 10 additions & 16 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,18 @@
"type": "module",
"main": "lib/node-import-map-loader.js",
"scripts": {
"test": "cross-env node --experimental-loader ./lib/node-import-map-loader.js ./test/run-tests.js",
"lint": "eslint lib",
"test": "node --import ./test/register-hooks.js ./test/run-tests.js",
"prepare": "husky",
"check-format": "prettier --check ."
},
"husky": {
"hooks": {
"pre-commit": "pretty-quick --staged"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/node-loader/node-loader-import-maps.git"
},
"engines": {
"node": ">=14"
"node": ">=20"
},
"author": "",
"author": "Jolyn",
"license": "MIT",
"bugs": {
"url": "https://github.com/node-loader/node-loader-import-maps/issues"
Expand All @@ -30,14 +25,13 @@
"publishConfig": {
"access": "public"
},
"packageManager": "pnpm@10.15.1",
"devDependencies": {
"cross-env": "^7.0.2",
"eslint": "^8.2.0",
"eslint-config-node-important-stuff": "^2.0.0",
"husky": "^4.3.0",
"heeler": "^4.0.0",
"husky": "^9.1.7",
"left-pad": "^1.3.0",
"mocha": "^8.1.3",
"prettier": "^2.1.2",
"pretty-quick": "^3.0.2"
"mocha": "^11.7.2",
"prettier": "^3.6.2",
"pretty-quick": "^4.2.2"
}
}
Loading