Skip to content

build(js): replace tsup with custom tsc-build for dual CJS/ESM output#5719

Draft
pavelgj wants to merge 2 commits into
mainfrom
pj/ts7
Draft

build(js): replace tsup with custom tsc-build for dual CJS/ESM output#5719
pavelgj wants to merge 2 commits into
mainfrom
pj/ts7

Conversation

@pavelgj

@pavelgj pavelgj commented Jul 9, 2026

Copy link
Copy Markdown
Member

Migrate the JS library build from tsup to a custom tsc-build.mjs script that produces dual CJS/ESM bundles, and upgrade TypeScript to ^7.0.2 across packages.

  • Swap compile/build:watch scripts to invoke tsc-build.mjs
  • Remove tsup from devDependencies
  • Fix ./async export types path (.ts -> .d.ts)
  • Add explicit type args to action in background-action
  • Add rootDir: src to testapp tsconfigs
  • Ignore tsc-build temp artifacts in .gitignore

Migrate the JS library build from tsup to a custom tsc-build.mjs
script that produces dual CJS/ESM bundles, and upgrade TypeScript
to ^7.0.2 across packages.

- Swap `compile`/`build:watch` scripts to invoke `tsc-build.mjs`
- Remove `tsup` from devDependencies
- Fix `./async` export types path (`.ts` -> `.d.ts`)
- Add explicit type args to `action` in background-action
- Add `rootDir: src` to testapp tsconfigs
- Ignore tsc-build temp artifacts in .gitignore
@pavelgj pavelgj changed the title build: replace tsup with custom tsc-build for dual CJS/ESM output build(js): replace tsup with custom tsc-build for dual CJS/ESM output Jul 9, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request replaces tsup with a custom dual CJS/ESM build driver script (tsc-build.mjs) based on TypeScript 7, updating package scripts, dependencies, and lockfiles accordingly. It also adjusts tsconfig.json files in testapps and refines types in background-action.ts. The review feedback highlights several critical improvements for the build script: throwing errors instead of calling process.exit directly to preserve watch mode and ensure cleanup, resolving tsc robustly in monorepos with fallbacks, fixing a bug where parent directory imports are skipped during ESM rewrites, and adding an error listener to FSWatcher to prevent crashes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread js/scripts/tsc-build.mjs
stdio: 'inherit',
cwd: pkgDir,
});
if (res.status !== 0) process.exit(res.status ?? 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Calling process.exit directly inside runTsc will immediately terminate the entire process on any compilation error. In --watch mode, this defeats the purpose of watching for changes, as any typo or syntax error will kill the watch process. Additionally, terminating the process immediately bypasses the finally block in build(), leaving temporary build artifacts behind in the repository.

Instead, throw an error with the exit status so that the finally block can clean up the temporary files, and the watch mode can catch the error and continue running.

  if (res.status !== 0) {
    const err = new Error(`tsc exited with status ${res.status}`);
    err.status = res.status;
    throw err;
  }

Comment thread js/scripts/tsc-build.mjs
Comment on lines +81 to +86
const tscBin = resolve(
pkgDir,
'node_modules',
'.bin',
process.platform === 'win32' ? 'tsc.cmd' : 'tsc'
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Manually resolving tscBin relative to pkgDir can fail in monorepos where typescript is hoisted to the workspace root node_modules (or if the package manager uses a different layout).

Adding a fallback to the workspace root's node_modules and a final fallback to the system PATH makes the build script much more robust across different environments and package managers.

const localTsc = resolve(
  pkgDir,
  'node_modules',
  '.bin',
  process.platform === 'win32' ? 'tsc.cmd' : 'tsc'
);
const rootTsc = resolve(
  jsRoot,
  'node_modules',
  '.bin',
  process.platform === 'win32' ? 'tsc.cmd' : 'tsc'
);
const tscBin = existsSync(localTsc)
  ? localTsc
  : existsSync(rootTsc)
    ? rootTsc
    : 'tsc';

Comment thread js/scripts/tsc-build.mjs
Comment on lines +50 to +59
import {
cpSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
watch,
writeFileSync,
} from 'node:fs';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import existsSync from node:fs to support checking for the existence of the tsc binary in the package or workspace root.

Suggested change
import {
cpSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
watch,
writeFileSync,
} from 'node:fs';
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
watch,
writeFileSync,
} from 'node:fs';

Comment thread js/scripts/tsc-build.mjs
Comment on lines +124 to +125
const lastSegment = importPath.split('/').pop() || '';
if (lastSegment.includes('.')) return match;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If importPath is a parent directory import like .. or ../.., lastSegment will be . or ... Since these contain a dot, lastSegment.includes('.') will evaluate to true, causing the script to skip rewriting them. This leaves a bare directory import in the ESM output, which is invalid in standard ESM runtimes.

Updating the check to exclude . and .. ensures parent directory imports are correctly resolved and rewritten to point to their /index.mjs files.

Suggested change
const lastSegment = importPath.split('/').pop() || '';
if (lastSegment.includes('.')) return match;
const lastSegment = importPath.split('/').pop() || '';
if (lastSegment.includes('.') && lastSegment !== '.' && lastSegment !== '..') return match;

Comment thread js/scripts/tsc-build.mjs
Comment on lines +236 to +246
if (args.includes('--watch')) {
safeBuild();
console.log(`tsc-build: watching ${rootDir}/ for changes...`);
let timer = null;
watch(join(pkgDir, rootDir), { recursive: true }, () => {
clearTimeout(timer);
timer = setTimeout(safeBuild, 100);
});
} else {
build();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Node.js, if an error event is emitted on an FSWatcher (e.g., due to permission issues or directory deletion) and there are no listeners, the process will crash with an unhandled exception. Adding an error listener to the watcher prevents unexpected crashes.

Additionally, update the non-watch execution block to catch the thrown error and exit with the correct status code.

Suggested change
if (args.includes('--watch')) {
safeBuild();
console.log(`tsc-build: watching ${rootDir}/ for changes...`);
let timer = null;
watch(join(pkgDir, rootDir), { recursive: true }, () => {
clearTimeout(timer);
timer = setTimeout(safeBuild, 100);
});
} else {
build();
}
if (args.includes('--watch')) {
safeBuild();
console.log(`tsc-build: watching ${rootDir}/ for changes...`);
let timer = null;
const watcher = watch(join(pkgDir, rootDir), { recursive: true }, () => {
clearTimeout(timer);
timer = setTimeout(safeBuild, 100);
});
watcher.on('error', (err) => {
console.error(`tsc-build watch error: ${err.message}`);
});
} else {
try {
build();
} catch (err) {
process.exit(err.status ?? 1);
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant