Skip to content

Commit

Permalink
fix(common): update TS module resolution flow
Browse files Browse the repository at this point in the history
This commit updates the implementation for resolving `.ts` files.
Instead of registering the `ts-node` project only once, we now refrain from
doing so since there might be multiple projects with different configurations.
The current approach involves dynamically switching the implementation for
registering and unregistering the project after the `.ts` file has been transpiled
and resolved. This change addresses an issue where warnings were encountered when
`ts-node` attempted to register with different configurations. The number of configurations
is no longer a concern, as each time we need to read a `.ts` file, a new TS project is
registered. This adjustment does not impact performance or other attributes because `ts-node`
allows native project disabling. Part of the implementation has been adapted from what Nrwl Nx
already has; we can find their implementation here:
https://github.com/nrwl/nx/blob/master/packages/nx/src/plugins/js/utils/register.ts
It's worth noting that their implementation is somewhat versatile, as it also supports SWC.

Closes: #1197
Closes: #1213
  • Loading branch information
arturovt committed Apr 3, 2024
1 parent 55a7ca5 commit 0f6ef0d
Show file tree
Hide file tree
Showing 19 changed files with 975 additions and 1,151 deletions.
@@ -1,7 +1,14 @@
import type { Configuration } from 'webpack';

import { WebpackEsmPlugin } from 'webpack-esm-plugin';

export default async (cfg: Configuration) => {
const { default: configFromEsm } = await import('./custom-webpack.config.js');

// This is used to ensure we fixed the following issue:
// https://github.com/just-jeb/angular-builders/issues/1213
cfg.plugins!.push(new WebpackEsmPlugin());

// Do some stuff with config and configFromEsm
return { ...cfg, ...configFromEsm };
};
3 changes: 2 additions & 1 deletion examples/custom-webpack/sanity-app-esm/package.json
Expand Up @@ -44,6 +44,7 @@
"karma-jasmine": "5.1.0",
"karma-jasmine-html-reporter": "2.1.0",
"puppeteer": "21.11.0",
"typescript": "5.4.3"
"typescript": "5.4.3",
"webpack-esm-plugin": "file:./webpack-esm-plugin"
}
}
@@ -0,0 +1,17 @@
{
"name": "webpack-esm-plugin",
"version": "0.0.1",
"module": "./webpack-esm-plugin.mjs",
"typings": "./webpack-esm-plugin.d.ts",
"exports": {
"./package.json": {
"default": "./package.json"
},
".": {
"types": "./webpack-esm-plugin.d.ts",
"node": "./webpack-esm-plugin.mjs",
"default": "./webpack-esm-plugin.mjs"
}
},
"sideEffects": false
}
@@ -0,0 +1,5 @@
import * as webpack from 'webpack';

export declare class WebpackEsmPlugin {
apply(compiler: webpack.Compiler): void;
}
@@ -0,0 +1,7 @@
class WebpackEsmPlugin {
apply(compiler) {
console.error('hello from the WebpackEsmPlugin');
}
}

export { WebpackEsmPlugin };
5 changes: 4 additions & 1 deletion package.json
Expand Up @@ -27,7 +27,10 @@
"node": "^14.20.0 || ^16.13.0 || >=18.10.0"
},
"scripts": {
"build:packages": "yarn workspaces foreach -vip --include '@angular-builders/*' run build",
"// - BUILD": "The `common` must be built first because other packages are being built in parallel, which means the `common` package may not be ready yet.",
"build:common": "yarn workspace @angular-builders/common run build",
"build:packages:except:common": "yarn workspaces foreach -vip --exclude '@angular-builders/common' --include '@angular-builders/*' run build",
"build:packages": "yarn build:common && yarn build:packages:except:common",
"ci": "yarn build:packages && ./scripts/run-ci.sh",
"lerna": "lerna",
"graduate": "lerna publish --conventional-commits --conventional-graduate",
Expand Down
1 change: 0 additions & 1 deletion packages/common/package.json
Expand Up @@ -20,7 +20,6 @@
"clean": "rimraf dist"
},
"dependencies": {
"@angular-devkit/core": "^17.1.0",
"ts-node": "^10.0.0",
"tsconfig-paths": "^4.1.0"
},
Expand Down
105 changes: 13 additions & 92 deletions packages/common/src/load-module.ts
@@ -1,93 +1,27 @@
import * as path from 'node:path';
import * as url from 'node:url';
import type { logging } from '@angular-devkit/core';

const _tsNodeRegister = (() => {
let lastTsConfig: string | undefined;
return (tsConfig: string, logger: logging.LoggerApi) => {
// Check if the function was previously called with the same tsconfig
if (lastTsConfig && lastTsConfig !== tsConfig) {
logger.warn(`Trying to register ts-node again with a different tsconfig - skipping the registration.
tsconfig 1: ${lastTsConfig}
tsconfig 2: ${tsConfig}`);
}

if (lastTsConfig) {
return;
}

lastTsConfig = tsConfig;

loadTsNode().register({
project: tsConfig,
compilerOptions: {
module: 'CommonJS',
types: [
'node', // NOTE: `node` is added because users scripts can also use pure node's packages as webpack or others
],
},
});

const tsConfigPaths = loadTsConfigPaths();
const result = tsConfigPaths.loadConfig(tsConfig);
// The `loadConfig` returns a `ConfigLoaderResult` which must be guarded with
// the `resultType` check.
if (result.resultType === 'success') {
const { absoluteBaseUrl: baseUrl, paths } = result;
if (baseUrl && paths) {
tsConfigPaths.register({ baseUrl, paths });
}
}
};
})();

/**
* check for TS node registration
* @param file: file name or file directory are allowed
* @todo tsNodeRegistration: require ts-node if file extension is TypeScript
*/
function tsNodeRegister(file: string = '', tsConfig: string, logger: logging.LoggerApi) {
if (file?.endsWith('.ts')) {
// Register TS compiler lazily
_tsNodeRegister(tsConfig, logger);
}
}

/**
* This uses a dynamic import to load a module which may be ESM.
* CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript
* will currently, unconditionally downlevel dynamic import into a require call.
* require calls cannot load ESM code and will result in a runtime error. To workaround
* this, a Function constructor is used to prevent TypeScript from changing the dynamic import.
* Once TypeScript provides support for keeping the dynamic import this workaround can
* be dropped.
*
* @param modulePath The path of the module to load.
* @returns A Promise that resolves to the dynamically imported module.
*/
function loadEsmModule<T>(modulePath: string | URL): Promise<T> {
return new Function('modulePath', `return import(modulePath);`)(modulePath) as Promise<T>;
}
import { registerTsProject } from './register-ts-project';

/**
* Loads CJS and ESM modules based on extension
*/
export async function loadModule<T>(
modulePath: string,
tsConfig: string,
logger: logging.LoggerApi
): Promise<T> {
tsNodeRegister(modulePath, tsConfig, logger);
export async function loadModule<T>(modulePath: string, tsConfig: string): Promise<T> {
const absoluteModulePath = url.pathToFileURL(modulePath).toString();

switch (path.extname(modulePath)) {
case '.mjs':
// Load the ESM configuration file using the TypeScript dynamic import workaround.
// Once TypeScript provides support for keeping the dynamic import this workaround can be
// changed to a direct dynamic import.
return (await loadEsmModule<{ default: T }>(url.pathToFileURL(modulePath))).default;
return await import(absoluteModulePath).then(m => m.default);

case '.cjs':
return require(modulePath);

case '.ts':
const unregisterTsProject = registerTsProject(tsConfig);

try {
// If it's a TS file then there are 2 cases for exporing an object.
// The first one is `export blah`, transpiled into `module.exports = { blah} `.
Expand All @@ -98,10 +32,13 @@ export async function loadModule<T>(
// Load the ESM configuration file using the TypeScript dynamic import workaround.
// Once TypeScript provides support for keeping the dynamic import this workaround can be
// changed to a direct dynamic import.
return (await loadEsmModule<{ default: T }>(url.pathToFileURL(modulePath))).default;
return await import(absoluteModulePath).then(m => m.default);
}
throw e;
} finally {
unregisterTsProject();
}

//.js
default:
// The file could be either CommonJS or ESM.
Expand All @@ -113,26 +50,10 @@ export async function loadModule<T>(
// Load the ESM configuration file using the TypeScript dynamic import workaround.
// Once TypeScript provides support for keeping the dynamic import this workaround can be
// changed to a direct dynamic import.
return (await loadEsmModule<{ default: T }>(url.pathToFileURL(modulePath))).default;
return await import(absoluteModulePath).then(m => m.default);
}

throw e;
}
}
}

/**
* Loads `ts-node` lazily. Moved to a separate function to declare
* a return type, more readable than an inline variant.
*/
function loadTsNode(): typeof import('ts-node') {
return require('ts-node');
}

/**
* Loads `tsconfig-paths` lazily. Moved to a separate function to declare
* a return type, more readable than an inline variant.
*/
function loadTsConfigPaths(): typeof import('tsconfig-paths') {
return require('tsconfig-paths');
}
68 changes: 68 additions & 0 deletions packages/common/src/register-ts-project.ts
@@ -0,0 +1,68 @@
let isTsEsmLoaderRegistered = false;

export function registerTsProject(tsConfig: string) {
const cleanupFunctions = [registerTsConfigPaths(tsConfig), registerTsNodeService(tsConfig)];

// Add ESM support for `.ts` files.
// NOTE: There is no cleanup function for this, as it's not possible to unregister the loader.
// Based on limited testing, it doesn't seem to matter if we register it multiple times, but just in
// case let's keep a flag to prevent it.
if (!isTsEsmLoaderRegistered) {
const module = require('node:module');
if (module.register && packageIsInstalled('ts-node/esm')) {
const url = require('node:url');
module.register(url.pathToFileURL(require.resolve('ts-node/esm')));
}
isTsEsmLoaderRegistered = true;
}

return () => {
cleanupFunctions.forEach(fn => fn());
};
}

function registerTsNodeService(tsConfig: string): VoidFunction {
const { register } = require('ts-node') as typeof import('ts-node');

const service = register({
transpileOnly: true,
project: tsConfig,
compilerOptions: {
module: 'CommonJS',
types: [
'node', // NOTE: `node` is added because users scripts can also use pure node's packages as webpack or others
],
},
});

return () => {
service.enabled(false);
};
}

function registerTsConfigPaths(tsConfig: string): VoidFunction {
const tsConfigPaths = require('tsconfig-paths') as typeof import('tsconfig-paths');
const result = tsConfigPaths.loadConfig(tsConfig);
if (result.resultType === 'success') {
const { absoluteBaseUrl: baseUrl, paths } = result;
if (baseUrl && paths) {
// Returns a function to undo paths registration.
return tsConfigPaths.register({ baseUrl, paths });
}
}

// We cannot return anything here if paths failed to be registered.
// Additionally, I don't think we should perform any logging in this
// context, considering that this is internal information not exposed
// to the end user
return () => {};
}

function packageIsInstalled(m: string): boolean {
try {
require.resolve(m);
return true;
} catch {
return false;
}
}
4 changes: 3 additions & 1 deletion packages/common/tsconfig.json
@@ -1,7 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist"
"outDir": "dist",
"module": "node16",
"moduleResolution": "node16"
},
"files": [
"src/index.ts"
Expand Down
8 changes: 2 additions & 6 deletions packages/custom-esbuild/src/application/index.ts
Expand Up @@ -17,14 +17,10 @@ export function buildCustomEsbuildApplication(
const tsConfig = path.join(workspaceRoot, options.tsConfig);

return defer(async () => {
const codePlugins = await loadPlugins(options.plugins, workspaceRoot, tsConfig, context.logger);
const codePlugins = await loadPlugins(options.plugins, workspaceRoot, tsConfig);

const indexHtmlTransformer = options.indexHtmlTransformer
? await loadModule(
path.join(workspaceRoot, options.indexHtmlTransformer),
tsConfig,
context.logger
)
? await loadModule(path.join(workspaceRoot, options.indexHtmlTransformer), tsConfig)
: undefined;

return { codePlugins, indexHtmlTransformer } as ApplicationBuilderExtensions;
Expand Down
19 changes: 3 additions & 16 deletions packages/custom-esbuild/src/dev-server/index.ts
Expand Up @@ -42,27 +42,14 @@ export function executeCustomDevServerBuilder(
const middleware = await Promise.all(
(options.middlewares || []).map(middlewarePath =>
// https://github.com/angular/angular-cli/pull/26212/files#diff-a99020cbdb97d20b2bc686bcb64b31942107d56db06fd880171b0a86f7859e6eR52
loadModule<Connect.NextHandleFunction>(
path.join(workspaceRoot, middlewarePath),
tsConfig,
context.logger
)
loadModule<Connect.NextHandleFunction>(path.join(workspaceRoot, middlewarePath), tsConfig)
)
);

const buildPlugins = await loadPlugins(
buildOptions.plugins,
workspaceRoot,
tsConfig,
context.logger
);
const buildPlugins = await loadPlugins(buildOptions.plugins, workspaceRoot, tsConfig);

const indexHtmlTransformer: IndexHtmlTransform = buildOptions.indexHtmlTransformer
? await loadModule(
path.join(workspaceRoot, buildOptions.indexHtmlTransformer),
tsConfig,
context.logger
)
? await loadModule(path.join(workspaceRoot, buildOptions.indexHtmlTransformer), tsConfig)
: undefined;

patchBuilderContext(context, buildTarget);
Expand Down
6 changes: 2 additions & 4 deletions packages/custom-esbuild/src/load-plugins.ts
@@ -1,17 +1,15 @@
import * as path from 'node:path';
import type { Plugin } from 'esbuild';
import type { logging } from '@angular-devkit/core';
import { loadModule } from '@angular-builders/common';

export async function loadPlugins(
paths: string[] | undefined,
workspaceRoot: string,
tsConfig: string,
logger: logging.LoggerApi
tsConfig: string
): Promise<Plugin[]> {
const plugins = await Promise.all(
(paths || []).map(pluginPath =>
loadModule<Plugin | Plugin[]>(path.join(workspaceRoot, pluginPath), tsConfig, logger)
loadModule<Plugin | Plugin[]>(path.join(workspaceRoot, pluginPath), tsConfig)
)
);

Expand Down
19 changes: 13 additions & 6 deletions packages/custom-esbuild/src/schemes.ts
@@ -1,13 +1,20 @@
// Base schemes from build-angular@17.3.2

import * as path from 'node:path';

module.exports = [
{
originalSchemaPath: '@angular-devkit/build-angular/src/builders/application/schema.json',
schemaExtensionPaths: [`${__dirname}/application/schema.ext.json`],
newSchemaPath: `${__dirname}/../dist/application/schema.json`,
originalSchemaPath: require.resolve(
'@angular-devkit/build-angular/src/builders/application/schema.json'
),
schemaExtensionPaths: [path.join(__dirname, 'application/schema.ext.json')],
newSchemaPath: path.resolve(__dirname, '../dist/application/schema.json'),
},
{
originalSchemaPath: '@angular-devkit/build-angular/src/builders/dev-server/schema.json',
schemaExtensionPaths: [`${__dirname}/dev-server/schema.ext.json`],
newSchemaPath: `${__dirname}/../dist/dev-server/schema.json`,
originalSchemaPath: require.resolve(
'@angular-devkit/build-angular/src/builders/dev-server/schema.json'
),
schemaExtensionPaths: [path.join(__dirname, 'dev-server/schema.ext.json')],
newSchemaPath: path.resolve(__dirname, '../dist/dev-server/schema.json'),
},
];

0 comments on commit 0f6ef0d

Please sign in to comment.