Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@rushstack/hashed-folder-copy-plugin",
"comment": "",
"type": "none"
}
],
"packageName": "@rushstack/hashed-folder-copy-plugin"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"comment": "Add `evaluateConstantEstreeExpression` for statically evaluating constant ESTree expression nodes.",
"type": "minor",
"packageName": "@rushstack/webpack-plugin-utilities"
}
],
"packageName": "@rushstack/webpack-plugin-utilities",
"email": "[email protected]"
}
6 changes: 6 additions & 0 deletions common/config/subspaces/default/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions common/reviews/api/webpack-plugin-utilities.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
```ts

import type { Configuration } from 'webpack';
import type { Expression } from 'estree';
import { IFs } from 'memfs';
import type { MultiStats } from 'webpack';
import type { SpreadElement } from 'estree';
import type { Stats } from 'webpack';
import type * as Webpack from 'webpack';

// @beta
export function evaluateConstantEstreeExpression<TNode>(node: Expression | SpreadElement): TNode;

// @public
function getTestingWebpackCompilerAsync(entry: string, additionalConfig?: Configuration, memFs?: IFs): Promise<(Stats | MultiStats) | undefined>;

Expand Down
1 change: 1 addition & 0 deletions webpack/hashed-folder-copy-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
},
"dependencies": {
"@rushstack/node-core-library": "workspace:*",
"@rushstack/webpack-plugin-utilities": "workspace:*",
"fast-glob": "~3.3.1"
},
"devDependencies": {
Expand Down
49 changes: 3 additions & 46 deletions webpack/hashed-folder-copy-plugin/src/HashedFolderCopyPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type webpack from 'webpack';
import type glob from 'fast-glob';

import { Async } from '@rushstack/node-core-library';
import { evaluateConstantEstreeExpression } from '@rushstack/webpack-plugin-utilities';

import {
type IHashedFolderDependency,
Expand All @@ -25,16 +26,6 @@ const PLUGIN_NAME: 'hashed-folder-copy-plugin' = 'hashed-folder-copy-plugin';

const EXPRESSION_NAME: 'requireFolder' = 'requireFolder';

interface IAcornNode<TExpression> {
computed: boolean | undefined;
elements: IAcornNode<unknown>[];
key: IAcornNode<unknown> | undefined;
name: string | undefined;
properties: IAcornNode<unknown>[] | undefined;
type: 'Literal' | 'ObjectExpression' | 'Identifier' | 'ArrayExpression' | unknown;
value: TExpression;
}

export function renderError(errorMessage: string): string {
return `(function () { throw new Error(${JSON.stringify(errorMessage)}); })()`;
}
Expand Down Expand Up @@ -92,10 +83,9 @@ export class HashedFolderCopyPlugin implements webpack.WebpackPluginInstance {
if (expression.arguments.length !== 1) {
errorMessage = `Exactly one argument is required to be passed to "${EXPRESSION_NAME}"`;
} else {
const argument: IAcornNode<IRequireFolderOptions> = expression
.arguments[0] as IAcornNode<IRequireFolderOptions>;
const argument: Expression = expression.arguments[0] as Expression;
try {
requireFolderOptions = this._evaluateAcornNode(argument) as IRequireFolderOptions;
requireFolderOptions = evaluateConstantEstreeExpression(argument);
} catch (e) {
errorMessage = (e as Error).message;
}
Expand Down Expand Up @@ -164,37 +154,4 @@ export class HashedFolderCopyPlugin implements webpack.WebpackPluginInstance {
}
);
}

private _evaluateAcornNode(node: IAcornNode<unknown>): unknown {
switch (node.type) {
case 'Literal': {
return node.value;
}

case 'ObjectExpression': {
const result: Record<string, unknown> = {};

for (const property of node.properties!) {
const keyNode: IAcornNode<unknown> = property.key!;
if (keyNode.type !== 'Identifier' || keyNode.computed) {
throw new Error('Property keys must be non-computed identifiers');
}

const key: string = keyNode.name!;
const value: unknown = this._evaluateAcornNode(property.value as IAcornNode<unknown>);
result[key] = value;
}

return result;
}

case 'ArrayExpression': {
return node.elements.map((element) => this._evaluateAcornNode(element));
}

default: {
throw new Error(`Unsupported node type: "${node.type}"`);
}
}
}
}
11 changes: 7 additions & 4 deletions webpack/webpack-plugin-utilities/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,14 @@
},
"scripts": {
"build": "heft build --clean",
"_phase:build": "heft run --only build -- --clean"
"test": "heft run --only test",
"_phase:build": "heft run --only build -- --clean",
"_phase:test": "heft run --only test -- --clean"
},
"dependencies": {
"webpack-merge": "~5.8.0",
"memfs": "4.12.0"
"@types/estree": "1.0.8",
"memfs": "4.12.0",
"webpack-merge": "~5.8.0"
},
"peerDependencies": {
"@types/webpack": "^4.39.8",
Expand All @@ -55,9 +58,9 @@
},
"devDependencies": {
"@rushstack/heft": "workspace:*",
"@types/tapable": "1.0.6",
"eslint": "~9.37.0",
"local-node-rig": "workspace:*",
"@types/tapable": "1.0.6",
"webpack": "~5.105.2"
},
"sideEffects": false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type { Expression, PrivateIdentifier, SpreadElement } from 'estree';

/**
* Statically evaluates an ESTree (acorn) expression node into its corresponding
* runtime JavaScript value.
*
* @remarks
* This is intended to be used inside webpack plugins and loaders that hook into
* the parser (for example `parser.hooks.call`) and need to read the literal
* arguments passed to a function call at build time, without actually executing
* the user's code. A common use case is extracting a plain options object that
* was passed to a custom `require()`-style expression.
*
* Only a small subset of expression types is supported, namely those needed to
* express JSON-like constant values:
*
* - `Literal` (strings, numbers, booleans, `null`, etc.)
* - `UnaryExpression` (the `-`, `+`, `!`, and `~` operators applied to a constant
* argument, e.g. the `-1` in `{ count: -1 }`)
* - `TemplateLiteral` (only when it has no `${...}` substitutions)
* - `ObjectExpression` (with non-computed identifier keys)
* - `ArrayExpression` (including sparse holes, which evaluate to `null`)
*
* @example
* ```ts
* // Given source: requireFolder({ outputFolder: 'assets', sources: [] })
* const options: IRequireFolderOptions = evaluateConstantEstreeExpression(callExpression.arguments[0]);
* ```
*
* @remarks Limitations
* Because the node is evaluated statically rather than executed, anything that
* is not a compile-time constant is unsupported and will cause an `Error` to be
* thrown. This includes:
*
* - Identifiers and variable references (e.g. `someVariable`)
* - Computed property keys (e.g. `{ [key]: value }`)
* - Spread elements in object expressions (e.g. `{ ...other }`)
* - Function calls, template literals, and any other expression type not listed above
*
* @param node - The ESTree expression node to evaluate.
* @returns The evaluated value, cast to the caller-specified type `TNode`. Note
* that the cast is unchecked; the caller is responsible for validating that the
* returned shape matches `TNode`.
* @throws An `Error` if the node (or any nested node) uses an unsupported
* expression type or syntax.
* @beta
*/
export function evaluateConstantEstreeExpression<TNode>(node: Expression | SpreadElement): TNode {
switch (node.type) {
case 'Literal': {
return node.value as TNode;
}

case 'UnaryExpression': {
const argumentValue: unknown = evaluateConstantEstreeExpression(node.argument);
switch (node.operator) {
case '-': {
return -(argumentValue as number) as TNode;
}
case '+': {
return +(argumentValue as number) as TNode;
}
case '!': {
return !argumentValue as TNode;
}
case '~': {
return ~(argumentValue as number) as TNode;
}
default: {
throw new Error(`Unsupported unary operator: "${node.operator}"`);
}
}
}

case 'TemplateLiteral': {
if (node.expressions.length > 0) {
throw new Error('Template literals with substitutions are not supported');
}

return node.quasis[0].value.cooked as TNode;
}

case 'ObjectExpression': {
const result: Record<string, unknown> = {};

for (const property of node.properties) {
if (property.type === 'SpreadElement') {
throw new Error('Spread elements are not supported in object expressions');
}

const keyNode: Expression | PrivateIdentifier = property.key;
if (keyNode.type !== 'Identifier' || property.computed) {
throw new Error('Property keys must be non-computed identifiers');
}

const key: string = keyNode.name;
const value: unknown = evaluateConstantEstreeExpression(property.value as Expression);
result[key] = value;
}

return result as TNode;
}

case 'ArrayExpression': {
return node.elements.map((element) =>
element === null ? null : evaluateConstantEstreeExpression(element)
) as TNode;
}

default: {
throw new Error(`Unsupported node type: "${node.type}"`);
}
}
}
2 changes: 2 additions & 0 deletions webpack/webpack-plugin-utilities/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@
import * as VersionDetection from './DetectWebpackVersion';
import * as Testing from './Testing';
export { VersionDetection, Testing };

export { evaluateConstantEstreeExpression } from './evaluateConstantEstreeExpression';
Loading