diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index bddd13d..ee556de 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,9 +5,9 @@ name: test on: push: - branches: [ dev ] + branches: [ master, dev ] pull_request: - branches: [ dev ] + branches: [ master, dev ] workflow_dispatch: jobs: @@ -17,7 +17,7 @@ jobs: strategy: matrix: - node-version: [6.x, 8.x, 10.x, 12.x, 14.x, 16.x, 17.x] + node-version: [6.x, 8.x, 10.x, 12.x, 14.x, 16.x, 17.x, 18.x] fail-fast: false steps: @@ -32,21 +32,27 @@ jobs: - run: npm run testonly test-esm: - # CJS + ESM tests for Node 18+ - runs-on: ubuntu-latest + # CJS + ESM tests for Node 20.19+ (mocha 2.x loads .mjs via require(), + # which needs require(esm) support - not available on Node 18) + runs-on: ${{ matrix.os }} strategy: matrix: - node-version: [18.x, 20.x, 22.x] + os: [ubuntu-latest, windows-latest] + node-version: [20.x, 22.x] fail-fast: false steps: - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js ${{ matrix.node-version }} on ${{ matrix.os }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: node -v - run: npm -v - run: npm install - - run: npm run test:all + # Two CJS config-loading tests fail on Windows for reasons unrelated to + # the ESM loader (they predate it), so the CJS suite stays Linux-only + - run: npm run testonly + if: runner.os == 'Linux' + - run: npm run test:esm diff --git a/esm-loader.mjs b/esm-loader.mjs index 798cf02..dbbd4a4 100644 --- a/esm-loader.mjs +++ b/esm-loader.mjs @@ -2,7 +2,7 @@ // Provides resolve hooks for ES modules import { readFileSync, existsSync, statSync } from 'node:fs' -import { join, resolve as pathResolve, dirname } from 'node:path' +import { join, resolve as pathResolve, dirname, isAbsolute } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' let aliases = {} @@ -39,7 +39,7 @@ export function init (options = {}) { const pkgAliases = pkg._moduleAliases || {} for (const alias in pkgAliases) { const target = pkgAliases[alias] - aliases[alias] = target.startsWith('/') ? target : join(base, target) + aliases[alias] = isAbsolute(target) ? target : join(base, target) } // Load _moduleDirectories @@ -138,7 +138,7 @@ export async function resolve (specifier, context, nextResolve) { const resolved = resolveAlias(specifier, context.parentURL) if (resolved) { // If absolute path, convert to file URL - if (resolved.startsWith('/')) { + if (isAbsolute(resolved)) { return { url: pathToFileURL(resolved).href, shortCircuit: true } } // Otherwise let Node resolve it (could be npm package) diff --git a/package.json b/package.json index 5349efa..168dfb5 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ }, "scripts": { "test": "npm run lint && npm run testonly", - "testonly": "NODE_ENV=test mocha test/specs.js", - "testonly-watch": "NODE_ENV=test mocha -w test/specs.js", - "test:esm": "NODE_ENV=test mocha test/esm/unit.mjs test/esm/integration.js", + "testonly": "mocha test/specs.js", + "testonly-watch": "mocha -w test/specs.js", + "test:esm": "mocha test/esm/unit.mjs test/esm/integration.js", "test:all": "npm run testonly && npm run test:esm", "lint": "standard" }, diff --git a/register.mjs b/register.mjs index cc44aa3..3801c37 100644 --- a/register.mjs +++ b/register.mjs @@ -2,6 +2,8 @@ // Usage: node --import module-alias/register ./app.mjs import { register } from 'node:module' +import { isAbsolute } from 'node:path' +import { pathToFileURL } from 'node:url' // Check Node version for registerHooks support (22.15+) const [major, minor] = process.versions.node.split('.').map(Number) @@ -18,6 +20,10 @@ if (hasRegisterHooks) { resolve (specifier, context, nextResolve) { const resolved = resolveAlias(specifier, context.parentURL) if (resolved) { + // If absolute path, convert to file URL + if (isAbsolute(resolved)) { + return { url: pathToFileURL(resolved).href, shortCircuit: true } + } return nextResolve(resolved, context) } return nextResolve(specifier, context) diff --git a/test/esm/integration.js b/test/esm/integration.js index 4dbb13b..e5afc56 100644 --- a/test/esm/integration.js +++ b/test/esm/integration.js @@ -3,6 +3,7 @@ var expect = require('chai').expect var execSync = require('child_process').execSync var path = require('path') var semver = require('semver') +var pathToFileURL = require('url').pathToFileURL describe('ESM Integration', function () { // Skip all ESM tests on Node < 18.19.0 @@ -17,10 +18,10 @@ describe('ESM Integration', function () { describe('basic alias', function () { it('should resolve alias in ESM with --import flag', function () { var fixture = path.join(fixturesDir, 'basic') - var registerPath = path.join(moduleAliasRoot, 'register.mjs') + var registerUrl = pathToFileURL(path.join(moduleAliasRoot, 'register.mjs')).href var result = execSync( - 'node --import ' + registerPath + ' app.mjs', + 'node --import ' + registerUrl + ' app.mjs', { cwd: fixture, encoding: 'utf8' } ) @@ -31,10 +32,10 @@ describe('ESM Integration', function () { describe('moduleDirectories', function () { it('should resolve modules from custom directories', function () { var fixture = path.join(fixturesDir, 'module-dirs') - var registerPath = path.join(moduleAliasRoot, 'register.mjs') + var registerUrl = pathToFileURL(path.join(moduleAliasRoot, 'register.mjs')).href var result = execSync( - 'node --import ' + registerPath + ' app.mjs', + 'node --import ' + registerUrl + ' app.mjs', { cwd: fixture, encoding: 'utf8' } ) diff --git a/test/esm/unit.mjs b/test/esm/unit.mjs index e9a8540..83d393d 100644 --- a/test/esm/unit.mjs +++ b/test/esm/unit.mjs @@ -1,8 +1,8 @@ /* eslint-env mocha */ import chai from 'chai' import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { isPathMatchesAlias, resolveAlias, reset, addAlias, addAliases } from '../../esm-loader.mjs' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { isPathMatchesAlias, resolveAlias, reset, addAlias, addAliases, resolve } from '../../esm-loader.mjs' const { expect } = chai const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -76,7 +76,7 @@ describe('ESM Loader', function () { }) const result = resolveAlias('react-dom/server') - expect(result).to.equal('/path/to/server') + expect(result).to.equal(path.join('/path/to/server')) }) it('should handle multiple aliases', function () { @@ -85,8 +85,44 @@ describe('ESM Loader', function () { '@utils': '/path/to/utils' }) - expect(resolveAlias('@lib/foo')).to.equal('/path/to/lib/foo') - expect(resolveAlias('@utils/bar')).to.equal('/path/to/utils/bar') + expect(resolveAlias('@lib/foo')).to.equal(path.join('/path/to/lib', 'foo')) + expect(resolveAlias('@utils/bar')).to.equal(path.join('/path/to/utils', 'bar')) + }) + }) + + describe('resolve hook', function () { + afterEach(function () { + reset() + }) + + it('should convert absolute paths to file URLs', async function () { + // Platform-native absolute path (C:\... on Windows, /... on POSIX) + const targetPath = path.join(__dirname, '../src') + addAlias('@src', targetPath) + + const result = await resolve('@src/foo', {}, () => { + throw new Error('nextResolve should not be called for absolute paths') + }) + expect(result.url).to.equal(pathToFileURL(path.join(targetPath, 'foo')).href) + expect(result.shortCircuit).to.equal(true) + }) + + it('should pass non-absolute resolved specifiers to nextResolve', async function () { + addAlias('lodash-alias', 'lodash') + + const result = await resolve('lodash-alias/fp', {}, (specifier) => { + return { url: 'resolved:' + specifier } + }) + expect(result.url).to.equal('resolved:' + path.join('lodash', 'fp')) + }) + + it('should pass non-aliased specifiers through unchanged', async function () { + addAlias('@src', '/some/path') + + const result = await resolve('other-module', {}, (specifier) => { + return { url: 'resolved:' + specifier } + }) + expect(result.url).to.equal('resolved:other-module') }) }) })