Skip to content
Merged
89 changes: 63 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,37 @@ await exec(['app.js', '--target', 'host', '--output', 'app.exe']);
// do something with app.exe, run, test, upload, deploy, etc
```

## ECMAScript Modules (ESM) Support

Starting from version **6.13.0**, pkg has improved support for ECMAScript Modules (ESM). Most ESM features are now automatically transformed to CommonJS during the packaging process.

### Supported ESM Features

The following ESM features are now supported and will work in your packaged executables:

- **`import` and `export` statements** - Automatically transformed to `require()` and `module.exports`
- **Top-level `await`** - Wrapped in an async IIFE to work in CommonJS context
- **Top-level `for await...of`** - Wrapped in an async IIFE to work in CommonJS context
- **`import.meta.url`** - Polyfilled to provide the file URL of the current module
- **`import.meta.dirname`** - Polyfilled to provide the directory path (Node.js 20.11+ property)
- **`import.meta.filename`** - Polyfilled to provide the file path (Node.js 20.11+ property)

### Known Limitations

While most ESM features work, there are some limitations to be aware of:

1. **Modules with both top-level await and exports**: Modules that use `export` statements alongside top-level `await` cannot be wrapped in an async IIFE and will not be transformed to bytecode. These modules will be included as source code instead.

2. **`import.meta.main`** and other custom properties: Only the standard `import.meta` properties listed above are polyfilled. Custom properties added by your code or other tools may not work as expected.

3. **Dynamic imports**: `import()` expressions work but may have limitations depending on the module being imported.

### Best Practices

- For entry point scripts (the main file you're packaging), feel free to use top-level await
- For library modules that will be imported by other code, avoid using both exports and top-level await together
- Test your packaged executable to ensure all ESM features work as expected in your specific use case

## Use custom Node.js binary

In case you want to use custom node binary, you can set `PKG_NODE_PATH` environment variable to the path of the node binary you want to use and `pkg` will use it instead of the default one.
Expand Down Expand Up @@ -535,43 +566,45 @@ or
Note: make sure not to use --debug flag in production.

### Injecting Windows Executable Metadata After Packaging
Executables created with `pkg` are based on a Node.js binary and, by default,
inherit its embedded metadata – such as version number, product name, company
name, icon, and description. This can be misleading or unpolished in

Executables created with `pkg` are based on a Node.js binary and, by default,
inherit its embedded metadata – such as version number, product name, company
name, icon, and description. This can be misleading or unpolished in
distributed applications.

There are two ways to customize the metadata of the resulting `.exe`:

1. **Use a custom Node.js binary** with your own metadata already embedded.
See: [Use Custom Node.js Binary](#use-custom-nodejs-binary)

2. **Post-process the generated executable** using
[`resedit`](https://www.npmjs.com/package/resedit), a Node.js-compatible
tool for modifying Windows executable resources. This allows injecting
2. **Post-process the generated executable** using
[`resedit`](https://www.npmjs.com/package/resedit), a Node.js-compatible
tool for modifying Windows executable resources. This allows injecting
correct version info, icons, copyright,
and more.

This section focuses on the second approach: post-processing the packaged
binary using [`resedit`](https://www.npmjs.com/package/resedit).
binary using [`resedit`](https://www.npmjs.com/package/resedit).

> ⚠️ Other tools may corrupt the executable, resulting in runtime errors such as
> `Pkg: Error reading from file.` –
> `Pkg: Error reading from file.` –
> [`resedit`](https://www.npmjs.com/package/resedit) has proven to work reliably
> with `pkg`-generated binaries.

Below is a working example for post-processing an `.exe` file using the Node.js API of [`resedit`](https://www.npmjs.com/package/resedit):

```ts
import * as ResEdit from "resedit";
import * as fs from "fs";
import * as path from "path";
import * as ResEdit from 'resedit';
import * as fs from 'fs';
import * as path from 'path';

// Set your inputs:
const exePath = "dist/my-tool.exe"; // Path to the generated executable
const outputPath = exePath; // Overwrite or use a different path
const version = "1.2.3"; // Your application version
const exePath = 'dist/my-tool.exe'; // Path to the generated executable
const outputPath = exePath; // Overwrite or use a different path
const version = '1.2.3'; // Your application version

const lang = 1033; // en-US
const codepage = 1200; // Unicode
const lang = 1033; // en-US
const codepage = 1200; // Unicode

const exeData = fs.readFileSync(exePath);
const exe = ResEdit.NtExecutable.from(exeData);
Expand All @@ -580,19 +613,22 @@ const res = ResEdit.NtExecutableResource.from(exe);
const viList = ResEdit.Resource.VersionInfo.fromEntries(res.entries);
const vi = viList[0];

const [major, minor, patch] = version.split(".");
const [major, minor, patch] = version.split('.');
vi.setFileVersion(Number(major), Number(minor), Number(patch), 0, lang);
vi.setProductVersion(Number(major), Number(minor), Number(patch), 0, lang);

vi.setStringValues({ lang, codepage }, {
FileDescription: "ACME CLI Tool",
ProductName: "ACME Application",
CompanyName: "ACME Corporation",
ProductVersion: version,
FileVersion: version,
OriginalFilename: path.basename(exePath),
LegalCopyright: `© ${new Date().getFullYear()} ACME Corporation`
});
vi.setStringValues(
{ lang, codepage },
{
FileDescription: 'ACME CLI Tool',
ProductName: 'ACME Application',
CompanyName: 'ACME Corporation',
ProductVersion: version,
FileVersion: version,
OriginalFilename: path.basename(exePath),
LegalCopyright: `© ${new Date().getFullYear()} ACME Corporation`,
},
);

vi.outputToResourceEntries(res.entries);
res.outputResource(exe);
Expand All @@ -610,6 +646,7 @@ The following command examples inject an icon and metadata into the executable
`dist/bin/app.exe`.

- **Example (PowerShell on Windows)**

```powershell
npx resedit dist/bin/app.exe dist/bin/app_with_metadata.exe `
--icon 1,dist/favicon.ico `
Expand Down
159 changes: 141 additions & 18 deletions lib/esm-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ interface UnsupportedFeature {
column: number | null;
}

/**
* Wrapper for top-level await support
* Wraps code in an async IIFE to allow top-level await in CommonJS
*/
const ASYNC_IIFE_WRAPPER = {
prefix: '(async () => {\n',
suffix: '\n})()',
};

/**
* Check if code contains import.meta usage
*
Expand Down Expand Up @@ -57,20 +66,23 @@ function hasImportMeta(code: string): boolean {
}

/**
* Detect ESM features that cannot be safely transformed to CommonJS
* Detect ESM features that require special handling or cannot be transformed
* These include:
* - Top-level await (no CJS equivalent)
* - Top-level await (can be handled with async IIFE wrapper)
*
* Note: import.meta is now supported via polyfills and is no longer unsupported
* Note: import.meta is now supported via polyfills and is no longer in the unsupported list
*
* @param code - The ESM source code to check
* @param filename - The filename for error reporting
* @returns Array of unsupported features found, or null if parse fails
* @returns Object with arrays of features requiring special handling
*/
function detectUnsupportedESMFeatures(
function detectESMFeatures(
code: string,
filename: string,
): UnsupportedFeature[] | null {
): {
topLevelAwait: UnsupportedFeature[];
unsupportedFeatures: UnsupportedFeature[];
} | null {
try {
const ast = babel.parse(code, {
sourceType: 'module',
Expand All @@ -81,11 +93,12 @@ function detectUnsupportedESMFeatures(
return null;
}

const topLevelAwait: UnsupportedFeature[] = [];
const unsupportedFeatures: UnsupportedFeature[] = [];

// @ts-expect-error Type mismatch due to @babel/types version in @types/babel__traverse
traverse(ast as t.File, {
// Detect top-level await
// Detect top-level await - can be handled with async IIFE wrapper
AwaitExpression(path: NodePath<t.AwaitExpression>) {
// Check if await is at top level (not inside a function)
let parent: NodePath | null = path.parentPath;
Expand All @@ -106,15 +119,15 @@ function detectUnsupportedESMFeatures(
}

if (isTopLevel) {
unsupportedFeatures.push({
topLevelAwait.push({
feature: 'top-level await',
line: path.node.loc?.start.line ?? null,
column: path.node.loc?.start.column ?? null,
});
}
},

// Detect for-await-of at top level
// Detect for-await-of at top level - can be handled with async IIFE wrapper
ForOfStatement(path: NodePath<t.ForOfStatement>) {
if (path.node.await) {
let parent: NodePath | null = path.parentPath;
Expand All @@ -135,7 +148,7 @@ function detectUnsupportedESMFeatures(
}

if (isTopLevel) {
unsupportedFeatures.push({
topLevelAwait.push({
feature: 'top-level for-await-of',
line: path.node.loc?.start.line ?? null,
column: path.node.loc?.start.column ?? null,
Expand All @@ -145,11 +158,11 @@ function detectUnsupportedESMFeatures(
},
});

return unsupportedFeatures;
return { topLevelAwait, unsupportedFeatures };
} catch (error) {
// If we can't parse, return null to let the transform attempt proceed
log.debug(
`Could not parse ${filename} to detect unsupported ESM features: ${
`Could not parse ${filename} to detect ESM features: ${
error instanceof Error ? error.message : String(error)
}`,
);
Expand Down Expand Up @@ -218,11 +231,16 @@ export function transformESMtoCJS(
};
}

// First, check for unsupported ESM features that can't be safely transformed
const unsupportedFeatures = detectUnsupportedESMFeatures(code, filename);
// First, check for ESM features that need special handling
const esmFeatures = detectESMFeatures(code, filename);

if (unsupportedFeatures && unsupportedFeatures.length > 0) {
const featureList = unsupportedFeatures
// Handle truly unsupported features (import.meta)
if (
esmFeatures &&
esmFeatures.unsupportedFeatures &&
esmFeatures.unsupportedFeatures.length > 0
) {
const featureList = esmFeatures.unsupportedFeatures
.map((f) => {
const location = f.line !== null ? ` at line ${f.line}` : '';
return ` - ${f.feature}${location}`;
Expand Down Expand Up @@ -251,18 +269,123 @@ export function transformESMtoCJS(
};
}

// Check if we need to wrap in async IIFE for top-level await
const hasTopLevelAwait =
esmFeatures &&
esmFeatures.topLevelAwait &&
esmFeatures.topLevelAwait.length > 0;

let codeToTransform = code;

// If top-level await is detected, we need to wrap in async IIFE
// But we must handle imports and exports specially
if (hasTopLevelAwait) {
try {
// Parse the code to check for exports and collect imports
const ast = babel.parse(code, {
sourceType: 'module',
plugins: [],
});

let hasExports = false;
const codeLines = code.split('\n');
const importLineIndices = new Set<number>();

// @ts-expect-error Type mismatch due to @babel/types version
traverse(ast as t.File, {
ExportNamedDeclaration() {
hasExports = true;
},
ExportDefaultDeclaration() {
hasExports = true;
},
ExportAllDeclaration() {
hasExports = true;
},
ImportDeclaration(path: NodePath<t.ImportDeclaration>) {
// Track import statements by line number
const { loc } = path.node;
if (loc) {
const { start, end } = loc;
for (let i = start.line; i <= end.line; i += 1) {
importLineIndices.add(i - 1); // Convert to 0-based index
}
}
},
});

if (hasExports) {
// If the file has exports, we can't wrap it in an IIFE
// because exports need to be synchronous and at the top level.
log.warn(
`Module ${filename} has both top-level await and export statements. ` +
`This combination cannot be safely transformed to CommonJS in pkg's ESM transformer. ` +
`The original source code will be used as-is; depending on the package visibility and build configuration, ` +
`bytecode compilation may fail and the module may need to be loaded from source or be skipped.`,
);
return {
code,
isTransformed: false,
};
}

// If there are imports, extract them to keep outside the async IIFE
if (importLineIndices.size > 0) {
const imports: string[] = [];
const rest: string[] = [];

codeLines.forEach((line, index) => {
if (importLineIndices.has(index)) {
imports.push(line);
} else {
rest.push(line);
}
});

// Reconstruct: imports at top, then async IIFE wrapping the rest
codeToTransform = `${imports.join('\n')}\n${ASYNC_IIFE_WRAPPER.prefix}${rest.join('\n')}${ASYNC_IIFE_WRAPPER.suffix}`;

log.debug(
`Wrapping ${filename} in async IIFE with imports extracted to top level`,
);
} else {
// No imports, wrap everything
codeToTransform =
ASYNC_IIFE_WRAPPER.prefix + code + ASYNC_IIFE_WRAPPER.suffix;

log.debug(
`Wrapping ${filename} in async IIFE to support top-level await`,
);
}
} catch (parseError) {
// If we can't parse, wrap everything and hope for the best
codeToTransform =
ASYNC_IIFE_WRAPPER.prefix + code + ASYNC_IIFE_WRAPPER.suffix;

log.warn(
`Could not parse ${filename} to detect exports/imports (${
parseError instanceof Error ? parseError.message : String(parseError)
}). ` +
`Wrapping entire code in async IIFE - this may fail if the module has export or import statements.`,
);
}
}

// Check if code uses import.meta before transformation
const usesImportMeta = hasImportMeta(code);

try {
const result = esbuild.transformSync(code, {
// Build esbuild options
const esbuildOptions: esbuild.TransformOptions = {
loader: 'js',
format: 'cjs',
target: 'node18',
sourcemap: false,
minify: false,
keepNames: true,
});
};

const result = esbuild.transformSync(codeToTransform, esbuildOptions);

if (!result || !result.code) {
log.warn(`esbuild transform returned no code for ${filename}`);
Expand Down
Loading