Skip to content

Commit

Permalink
feat: correct module systems by file extension. addresses microsoft/T…
Browse files Browse the repository at this point in the history
  • Loading branch information
knightedcodemonkey committed Jul 30, 2023
1 parent bfd59a9 commit 6f91a89
Show file tree
Hide file tree
Showing 22 changed files with 276 additions and 31 deletions.
6 changes: 3 additions & 3 deletions README.md
Expand Up @@ -6,7 +6,7 @@

Node.js tool for creating a TypeScript dual package.

Early stages of development. Inspired by https://github.com/microsoft/TypeScript/issues/49462.
Inspired by https://github.com/microsoft/TypeScript/issues/49462.

## Requirements

Expand Down Expand Up @@ -79,8 +79,8 @@ Options:

## Gotchas

* Unfortunately, TypeScript doesn't really build [dual packages](https://nodejs.org/api/packages.html#dual-commonjses-module-packages) very well in regards to preserving module system by file extension. For instance, it will **always** create CJS exports when `--module commonjs` is used, _even on files with an `.mts` extension_, which is contrary to [how Node determines module systems](https://nodejs.org/api/packages.html#determining-module-system). The `tsc` compiler is fundamentally broken in this regard. One reference issue is https://github.com/microsoft/TypeScript/issues/54573. If you use `.mts` extensions to enforce an ESM module system, this will break in the corresponding dual CJS build. There is no way to fix this until TypeScript fixes their compiler.
These are definitely edge cases, and would only really come up if your project mixes file extensions. For example, if you have `.ts` files combined with `.mts`, and/or `.cts`. For most project, things should just work as expected.

* If targeting a dual CJS build, and you are using [top level `await`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await#top_level_await), you will most likely encounter the compilation error `error TS1378: Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022', 'esnext', 'system', 'node16', or 'nodenext', and the 'target' option is set to 'es2017' or higher.` during the CJS build. This is because `duel` creates a temporary `tsconfig.json` from your original and necessarily overwrites the `--module` and `--moduleResolution` based on the provided `--target-ext`. There is no workaround other than to **not** use top level await if you want a dual build.
* Unfortunately, TypeScript doesn't really build [dual packages](https://nodejs.org/api/packages.html#dual-commonjses-module-packages) very well in regards to preserving module system by file extension. For instance, there doesn't appear to be a way to convert an arbitrary `.ts` file into another module system, _while also preserving the module system of `.mts` and `.cts` files_. In my opinion, the `tsc` compiler is fundamentally broken in this regard, and at best is enforcing usage patterns it shouldn't. If you want to see one of my extended rants on this, check out this [comment](https://github.com/microsoft/TypeScript/pull/50985#issuecomment-1656991606). This is only mentioned for transparency, `duel` will correct for this and produce files with the module system you would expect based on the files extension, so that it works with [how Node.js determines module systems](https://nodejs.org/api/packages.html#determining-module-system).

* If doing an `import type` across module systems, i.e. from `.mts` into `.cts`, or vice versa, you might encounter the compilation error ``error TS1452: 'resolution-mode' assertions are only supported when `moduleResolution` is `node16` or `nodenext`.``. This is a [known issue](https://github.com/microsoft/TypeScript/issues/49055) and TypeScript currently suggests installing the nightly build, i.e. `npm i typescript@next`.
6 changes: 3 additions & 3 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
@@ -1,6 +1,6 @@
{
"name": "@knighted/duel",
"version": "1.0.0-alpha.3",
"version": "1.0.0-alpha.4",
"description": "TypeScript dual packages.",
"type": "module",
"main": "dist",
Expand Down Expand Up @@ -44,7 +44,7 @@
"url": "https://github.com/knightedcodemonkey/duel/issues"
},
"peerDependencies": {
"typescript": "^5.0.0"
"typescript": ">=4.0.0"
},
"devDependencies": {
"babel-dual-package": "^1.0.0-rc.5",
Expand Down
65 changes: 58 additions & 7 deletions src/duel.js
@@ -1,9 +1,9 @@
#!/usr/bin/env node

import { argv, cwd } from 'node:process'
import { join } from 'node:path'
import { join, relative } from 'node:path'
import { spawnSync } from 'node:child_process'
import { writeFile, rm } from 'node:fs/promises'
import { writeFile, copyFile, rm } from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
import { performance } from 'node:perf_hooks'

Expand All @@ -13,7 +13,6 @@ import { specifier } from '@knighted/specifier'
import { init } from './init.js'
import { getRealPathAsFileUrl, logError, log } from './util.js'

// TypeScript is defined as a peer dependency.
const tsc = join(cwd(), 'node_modules', '.bin', 'tsc')
const runBuild = project => {
const { status, error } = spawnSync(tsc, ['-p', project], { stdio: 'inherit' })
Expand Down Expand Up @@ -42,7 +41,7 @@ const duel = async args => {
const ctx = await init(args)

if (ctx) {
const { projectDir, tsconfig, targetExt, configPath } = ctx
const { projectDir, tsconfig, targetExt, configPath, absoluteOutDir } = ctx
const startTime = performance.now()

log('Starting primary build...\n')
Expand All @@ -55,13 +54,15 @@ const duel = async args => {
const { outDir } = tsconfig.compilerOptions
const dualConfigPath = join(projectDir, `tsconfig.${hex}.json`)
const dualOutDir = isCjsBuild ? join(outDir, 'cjs') : join(outDir, 'mjs')
// Using structuredClone() would require node >= 17.0.0
const tsconfigDual = {
...tsconfig,
compilerOptions: {
...tsconfig.compilerOptions,
outDir: dualOutDir,
module: isCjsBuild ? 'CommonJS' : 'NodeNext',
moduleResolution: isCjsBuild ? 'Node' : 'NodeNext',
module: isCjsBuild ? 'CommonJS' : 'ESNext',
// Best way to make this work given how tsc works
moduleResolution: 'Node',
},
}

Expand All @@ -71,7 +72,8 @@ const duel = async args => {
await rm(dualConfigPath, { force: true })

if (success) {
const filenames = await glob(`${join(projectDir, dualOutDir)}/**/*{.js,.d.ts}`, {
const absoluteDualOutDir = join(projectDir, dualOutDir)
const filenames = await glob(`${absoluteDualOutDir}/**/*{.js,.d.ts}`, {
ignore: 'node_modules/**',
})

Expand All @@ -95,6 +97,55 @@ const duel = async args => {
await rm(filename, { force: true })
}

/**
* This is a fix for tsc compiler which doesn't seem to support
* converting an arbitrary `.ts` file, into another module system,
* while also preserving the module systems of `.mts` and `.cts` files.
*
* Hopefully it can be removed when TS updates their supported options,
* or at least how the combination of `--module` and `--moduleResolution`
* currently work.
*
* @see https://github.com/microsoft/TypeScript/pull/50985#issuecomment-1656991606
*/
if (isCjsBuild) {
const mjsFiles = await glob(`${absoluteOutDir}/**/*.mjs`, {
ignore: ['node_modules/**', `${absoluteDualOutDir}/**`],
})

for (const filename of mjsFiles) {
const relativeFn = relative(absoluteOutDir, filename)

await copyFile(filename, join(absoluteDualOutDir, relativeFn))
}
} else {
const cjsFiles = await glob(`${absoluteOutDir}/**/*.cjs`, {
ignore: ['node_modules/**', `${absoluteDualOutDir}/**`],
})

for (const filename of cjsFiles) {
const relativeFn = relative(absoluteOutDir, filename)

await copyFile(filename, join(absoluteDualOutDir, relativeFn))
}

/**
* Now copy the good .mjs files from the dual out dir
* to the original out dir, but build the file path
* from the original out dir to distinguish from the
* dual build .mjs files.
*/
const mjsFiles = await glob(`${absoluteOutDir}/**/*.mjs`, {
ignore: ['node_modules/**', `${absoluteDualOutDir}/**`],
})

for (const filename of mjsFiles) {
const relativeFn = relative(absoluteOutDir, filename)

await copyFile(join(absoluteDualOutDir, relativeFn), filename)
}
}

log(
`Successfully created a dual ${targetExt
.replace('.', '')
Expand Down
18 changes: 18 additions & 0 deletions test/__fixtures__/cjsProject/package.json
@@ -0,0 +1,18 @@
{
"version": "0.0.0",
"type": "commonjs",
"exports": {
".": {
"import": {
"types": "./dist/mjs/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
},
"default": "./dist/index.cjs"
},
"./package.json": "./package.json"
}
}
File renamed without changes.
File renamed without changes.
9 changes: 9 additions & 0 deletions test/__fixtures__/cjsProject/src/folder/another.mts
@@ -0,0 +1,9 @@
interface ESM {
esm: boolean;
}

export const esm: ESM = {
esm: true
}

export type { ESM }
Expand Up @@ -16,5 +16,5 @@ const mod: Mod = {
}
}

export { mod }
export { mod, cjs }
export type { Mod }
39 changes: 39 additions & 0 deletions test/__fixtures__/cjsProject/src/index.ts
@@ -0,0 +1,39 @@
import { mod } from "./folder/module.js"
import { cjs } from './cjs.cjs'

import type { Mod } from "./folder/module.js"
import type { CJS } from "./cjs.cjs"

interface User {
name: string;
id: number;
mod: Mod;
esm: any;
cjs: CJS;
}

class UserAccount {
name: string;
id: number;
mod: Mod;
esm: any;
cjs: CJS;

constructor(name: string, id: number, mod: Mod, esm: any, cjs: CJS) {
this.name = name;
this.id = id;
this.mod = mod;
this.esm = esm;
this.cjs = cjs;
}
}

const getUser = async () => {
const { esm } = await import('./esm.mjs')

return new UserAccount("Murphy", 1, mod, esm, cjs)
}

export type { User }

export { getUser }
12 changes: 12 additions & 0 deletions test/__fixtures__/cjsProject/tsconfig.json
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"moduleResolution": "Node",
"declaration": true,
"strict": false,
"outDir": "dist",
"lib": ["ES2015"]
},
"include": ["src"]
}
File renamed without changes.
3 changes: 3 additions & 0 deletions test/__fixtures__/esmProject/script.js
@@ -0,0 +1,3 @@
//import * as cjs from './dist/cjs/cjs.cjs'

require('./dist/cjs/cjs.cjs')
39 changes: 39 additions & 0 deletions test/__fixtures__/esmProject/src/cjs.cts
@@ -0,0 +1,39 @@
/*
import type { ESM } from './esm.mjs' assert { 'resolution-mode': 'import' };
interface CJS {
cjs: boolean,
esm: ESM;
}
const func = async () => {
const { esm } = await import('./esm.mjs')
const cjs: CJS = {
cjs: true,
esm
}
return cjs
}
export { func }
export type { CJS }
*/

import MagicString from "magic-string"

interface CJS {
cjs: boolean;
magic: MagicString;
}

const cjs: CJS = {
cjs: true,
magic: new MagicString('magic')
}

export { cjs }

export type { CJS }
9 changes: 9 additions & 0 deletions test/__fixtures__/esmProject/src/esm.mts
@@ -0,0 +1,9 @@
interface ESM {
esm: boolean;
}

export const esm: ESM = {
esm: true
}

export type { ESM }
9 changes: 9 additions & 0 deletions test/__fixtures__/esmProject/src/folder/another.mts
@@ -0,0 +1,9 @@
interface ESM {
esm: boolean;
}

export const esm: ESM = {
esm: true
}

export type { ESM }
20 changes: 20 additions & 0 deletions test/__fixtures__/esmProject/src/folder/module.ts
@@ -0,0 +1,20 @@
import MagicString from "magic-string";
import { cjs } from "../cjs.cjs";

import type { CJS } from "../cjs.cjs";

interface Mod {
prop: string;
cjs: CJS
}

const mod: Mod = {
prop: 'foobar',
cjs: {
cjs: true,
magic: new MagicString('module')
}
}

export { mod, cjs }
export type { Mod }
File renamed without changes.
Expand Up @@ -8,5 +8,5 @@
"outDir": "dist",
"lib": ["ES2015"]
},
"include": ["src/*.ts"]
"include": ["src"]
}
File renamed without changes.

0 comments on commit 6f91a89

Please sign in to comment.