diff --git a/.gitignore b/.gitignore index 71991ede0..e2dc7341d 100644 --- a/.gitignore +++ b/.gitignore @@ -165,6 +165,9 @@ Cargo.lock # idea .idea/ +# Serena +.serena + *.node lib artifacts diff --git a/bench/cli.ts b/bench/cli.ts new file mode 100644 index 000000000..812d2afbf --- /dev/null +++ b/bench/cli.ts @@ -0,0 +1,138 @@ +import fs from 'node:fs' +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' + +import { bench, boxplot, run } from 'mitata' + +const rootDir = resolve(__dirname, '..') +const fixtureDir = resolve(__dirname, 'fixture') +const fixtureTsconfig = resolve(fixtureDir, 'tsconfig.json') + +const tsxCli = require.resolve('tsx/cli') +const swcNodeCli = resolve(rootDir, 'packages', 'cli', 'index.js') +const cacheRoot = resolve(rootDir, 'bench', '.cache') +const swcCacheDir = resolve(cacheRoot, 'swc-node') +const tsxTmpDir = resolve(cacheRoot, 'tmp') + +const workloads = [ + { + name: 'rxjs', + entrypoint: resolve(fixtureDir, 'rxjs.ts'), + }, + { + name: 'typescript', + entrypoint: resolve(fixtureDir, 'ts.ts'), + }, +] as const + +type RunMode = 'cached' | 'uncached' + +type Runner = { + name: 'tsx' | 'swc-node' | 'node' + cliFile: string + envForMode?: (mode: RunMode) => NodeJS.ProcessEnv +} + +const runners: Runner[] = [ + { + name: 'node', + cliFile: '', // Use node directly without a CLI wrapper + }, + { + name: 'tsx', + cliFile: tsxCli, + envForMode: (mode) => ({ + ...process.env, + TMPDIR: tsxTmpDir, + TSX_DISABLE_CACHE: mode === 'uncached' ? '1' : '0', + }), + }, + { + name: 'swc-node', + cliFile: swcNodeCli, + envForMode: (mode) => ({ + ...process.env, + SWC_NODE_PROJECT: fixtureTsconfig, + SWC_NODE_CACHE_DIR: swcCacheDir, + SWC_NODE_CACHE: mode === 'uncached' ? '0' : '1', + }), + }, +] + +function runCli(name: string, cliFile: string, entrypoint: string, env: NodeJS.ProcessEnv) { + const result = spawnSync(process.execPath, [cliFile, entrypoint].filter(Boolean), { + cwd: fixtureDir, + env, + stdio: 'pipe', + encoding: 'utf8', + }) + + if (result.status !== 0) { + const stderr = result.stderr?.trim() || '(empty stderr)' + throw new Error(`${name} failed with exit code ${result.status}: ${stderr}`) + } +} + +function resetCacheDirectories() { + fs.rmSync(cacheRoot, { recursive: true, force: true }) + fs.mkdirSync(swcCacheDir, { recursive: true }) + fs.mkdirSync(tsxTmpDir, { recursive: true }) +} + +resetCacheDirectories() + +for (const workload of workloads) { + for (const runner of runners) { + if (runner.envForMode) { + runCli( + `${runner.name} (${workload.name}, cached prewarm)`, + runner.cliFile, + workload.entrypoint, + runner.envForMode('cached'), + ) + runCli( + `${runner.name} (${workload.name}, uncached preflight)`, + runner.cliFile, + workload.entrypoint, + runner.envForMode('uncached'), + ) + } else { + runCli(`${runner.name} (${workload.name},preflight)`, runner.cliFile, workload.entrypoint, process.env) + } + } +} + +boxplot(() => { + for (const workload of workloads) { + for (const runner of runners) { + if (runner.envForMode) { + bench(`${runner.name} uncached (${workload.name})`, () => + runCli( + `${runner.name} uncached (${workload.name})`, + runner.cliFile, + workload.entrypoint, + runner.envForMode!('uncached'), + ), + ) + + bench(`${runner.name} cached (${workload.name})`, () => + runCli( + `${runner.name} cached (${workload.name})`, + runner.cliFile, + workload.entrypoint, + runner.envForMode!('cached'), + ), + ).baseline(runner.name === 'node') + } else { + bench(`${runner.name} (${workload.name})`, () => + runCli(`${runner.name} (${workload.name})`, runner.cliFile, workload.entrypoint, process.env), + ).baseline(runner.name === 'node') + } + } + } +}) + +run({ throw: true }).catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/bench/fixture/common.ts b/bench/fixture/common.ts new file mode 100644 index 000000000..9b93242f0 --- /dev/null +++ b/bench/fixture/common.ts @@ -0,0 +1,234 @@ +/* --------------------------------------------------- + Basic Types and Interfaces +--------------------------------------------------- */ + +export type ID = string | number + +export interface Timestamped { + createdAt: Date + updatedAt?: Date +} + +export interface User extends Timestamped { + id: ID + name: string + email?: string + role: UserRole + metadata?: Record +} + +export type PartialUser = Partial +export type ReadonlyUser = Readonly + +/* --------------------------------------------------- + Enums +--------------------------------------------------- */ + +export type UserRole = (typeof UserRole)[keyof typeof UserRole] +export const UserRole = { + ADMIN: 'admin', + EDITOR: 'editor', + VIEWER: 'viewer', +} + +export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel] +export const LogLevel = { + INFO: 'INFO', + WARN: 'WARN', + ERROR: 'ERROR', +} + +/* --------------------------------------------------- + Constants +--------------------------------------------------- */ + +export const APP_NAME = 'MockTSModule' +export const VERSION = '2.3.1' + +export const DEFAULT_USER: ReadonlyUser = { + id: '0', + name: 'Guest', + role: UserRole.VIEWER, + createdAt: new Date(), +} + +/* --------------------------------------------------- + Utility Types +--------------------------------------------------- */ + +export type Nullable = T | null +export type AsyncResult = Promise<{ ok: true; value: T } | { ok: false; error: Error }> + +export type ExtractArrayType = T extends (infer U)[] ? U : never + +export type DeepPartial = { + [K in keyof T]?: T[K] extends object ? DeepPartial : T[K] +} + +/* --------------------------------------------------- + Generics + Functions +--------------------------------------------------- */ + +export function identity(value: T): T { + return value +} + +export function assertNever(x: never): never { + throw new Error(`Unexpected value: ${x}`) +} + +export async function simulateAsync(value: T, delay = 50): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(value), delay) + }) +} + +export function mergeObjects(a: A, b: B): A & B { + return { ...a, ...b } +} + +export function greet(user: User): string { + return `Hello ${user.name} (${user.role})` +} + +/* --------------------------------------------------- + Class with Generics +--------------------------------------------------- */ + +export class Repository { + private items = new Map() + + add(item: T): void { + this.items.set(item.id, item) + } + + get(id: ID): T | undefined { + return this.items.get(id) + } + + remove(id: ID): boolean { + return this.items.delete(id) + } + + list(): T[] { + return [...this.items.values()] + } + + clear(): void { + this.items.clear() + } + + get size(): number { + return this.items.size + } +} + +/* --------------------------------------------------- + Abstract Classes +--------------------------------------------------- */ + +export abstract class Logger { + abstract log(level: LogLevel, message: string): void + + info(msg: string) { + this.log(LogLevel.INFO, msg) + } + + warn(msg: string) { + this.log(LogLevel.WARN, msg) + } + + error(msg: string) { + this.log(LogLevel.ERROR, msg) + } +} + +export class ConsoleLogger extends Logger { + log(level: LogLevel, message: string) { + console.log(`[${LogLevel[level]}] ${message}`) + } +} + +/* --------------------------------------------------- + Class +--------------------------------------------------- */ + +export class UserService { + private repo = new Repository() + + create(user: User) { + this.repo.add(user) + } + + find(id: ID) { + return this.repo.get(id) + } + + list() { + return this.repo.list() + } +} + +/* --------------------------------------------------- + Function Overloads +--------------------------------------------------- */ + +export function format(value: number): string +export function format(value: Date): string +export function format(value: string): string +export function format(value: number | Date | string): string { + if (typeof value === 'number') return value.toFixed(2) + if (value instanceof Date) return value.toISOString() + return value.trim() +} + +/* --------------------------------------------------- + Symbol Usage +--------------------------------------------------- */ + +export const INTERNAL_TOKEN = Symbol('internal') + +/* --------------------------------------------------- + Tuple + Advanced Types +--------------------------------------------------- */ + +export type Point = readonly [number, number] + +export function distance([x1, y1]: Point, [x2, y2]: Point): number { + return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) +} + +/* --------------------------------------------------- + Async Generator +--------------------------------------------------- */ + +export async function* asyncCounter(limit: number) { + for (let i = 0; i < limit; i++) { + await new Promise((r) => setTimeout(r, 10)) + yield i + } +} + +/* --------------------------------------------------- + Module Augmentation Example +--------------------------------------------------- */ + +declare global { + interface Window { + __MOCK_TS_MODULE__?: boolean + } +} + +/* --------------------------------------------------- + Default Export +--------------------------------------------------- */ + +export default function initialize(): UserService { + return new UserService() +} + +/* --------------------------------------------------- + Re-exports +--------------------------------------------------- */ + +export { randomUUID as generateId } from 'crypto' diff --git a/bench/fixture/package.json b/bench/fixture/package.json new file mode 100644 index 000000000..78cf64cde --- /dev/null +++ b/bench/fixture/package.json @@ -0,0 +1,9 @@ +{ + "name": "swc-node-bench-rxjs-fixture", + "private": true, + "type": "module", + "dependencies": { + "rxjs": "^7.8.2", + "typescript": "^5.9.3" + } +} diff --git a/bench/fixture/rxjs.ts b/bench/fixture/rxjs.ts new file mode 100644 index 000000000..e2f5710a3 --- /dev/null +++ b/bench/fixture/rxjs.ts @@ -0,0 +1,23 @@ +import { Observable } from 'rxjs' +import { greet, UserService } from './common.ts' + +if (!Observable) { + throw new Error('rxjs Observable export was not loaded') +} + +const service = new UserService() + +service.create({ + createdAt: new Date(), + id: '1', + name: 'Alice', + role: 'admin', +}) + +const user = service.find('1') + +if (!user) { + throw new Error('User not found') +} + +greet(user) diff --git a/bench/fixture/ts.ts b/bench/fixture/ts.ts new file mode 100644 index 000000000..c688cb9f2 --- /dev/null +++ b/bench/fixture/ts.ts @@ -0,0 +1,23 @@ +import * as ts from 'typescript' +import { greet, UserService } from './common.ts' + +if (!ts.ScriptTarget.ESNext) { + throw new Error('typescript ScriptTarget export was not loaded') +} + +const service = new UserService() + +service.create({ + createdAt: new Date(), + id: '1', + name: 'Alice', + role: 'admin', +}) + +const user = service.find('1') + +if (!user) { + throw new Error('User not found') +} + +greet(user) diff --git a/bench/fixture/tsconfig.json b/bench/fixture/tsconfig.json new file mode 100644 index 000000000..8ea586fdb --- /dev/null +++ b/bench/fixture/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "module": "ESNext", + "moduleResolution": "Node" + } +} diff --git a/package.json b/package.json index 602500056..95246ae3c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "license": "MIT", "scripts": { "bench": "node -r @swc-node/register ./bench/index.ts", + "bench:cli": "node -r @swc-node/register ./bench/cli.ts", "build": "tsc -b tsconfig.json", "format": "prettier --config ./package.json . -w", "lint": "oxlint .", @@ -58,12 +59,14 @@ "lerna": "9.0.6", "lint-staged": "^16.1.5", "lodash": "^4.17.21", + "mitata": "^1.0.34", "oxlint": "^1.12.0", "prettier": "^3.6.2", "react": "^19.1.1", "rxjs": "^7.8.2", - "sinon": "^21.0.0", + "sinon": "^21.0.2", "tslib": "^2.8.1", + "tsx": "^4.21.0", "typescript": "^5.9.2" }, "lint-staged": { diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 000000000..9930437ba --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,114 @@ +# `@swc-node/cli` + +Downloads + +`@swc-node/cli` is a tiny Node-first CLI powered by `@swc-node/register`. + +## Features + +- TypeScript in both CommonJS and ESM projects. +- Hybrid module graphs (CJS importing ESM and ESM importing CJS). +- `compilerOptions.paths` / `baseUrl` path alias resolution from tsconfig. +- Node watch mode support (`--watch`, `--watch-path`, `--watch-preserve-output`). +- Node test runner support (`--test`). +- Decorator metadata support via tsconfig (`experimentalDecorators` + `emitDecoratorMetadata`). +- No extra swc dependencies required. + +## Usage + +### CLI + +```bash +swc-node index.ts +swc-node --env-file=.env src/index.ts --port=3000 +``` + +### Inline code (`-e` / `-p`) + +```bash +swc-node -e "const n: number = 1; console.log(n + 1)" +swc-node -p "const n: number = 41; n + 1" +``` + +### Node test runner + +```bash +swc-node --test ./math.test.ts +``` + +Without an explicit path, `swc-node --test` uses Node's default test discovery patterns, including TypeScript variants like: + +```text +**/*.test.?[cm][jt]s +**/*-test.?[cm][jt]s +**/*_test.?[cm][jt]s +**/test-*.?[cm][jt]s +**/test.?[cm][jt]s +**/test/**/*.?[cm][jt]s +``` + +### Shebang scripts + +For portable scripts, prefer putting options in `tsconfig.json` and keeping the shebang minimal: + +```ts +#!/usr/bin/env swc-node + +console.log('Hello, world!') +``` + +Then run the script directly: + +```bash +./script.ts +``` + +### CLI helpers + +```bash +swc-node --help +swc-node --version +``` + +## Read `tsconfig.json` + +Set `SWC_NODE_PROJECT` via CLI flag: + +```bash +swc-node --tsconfig ./tsconfig.dev.json src/index.ts +swc-node --tsconfig ./tsconfig.dev.json -e "console.log('hello')" +``` + +or via environment variable: + +```bash +SWC_NODE_PROJECT=./tsconfig.dev.json swc-node src/index.ts +``` + +`swc-node` also supports `compilerOptions.paths`/`baseUrl` path aliases from the selected tsconfig. + +```bash +swc-node --tsconfig ./tsconfig.dev.json src/index.ts +# imports like `@utils/foo` resolve from tsconfig paths +``` + +## Need lower-level APIs? + +If you need a programmable API or direct preload hooks (`-r` / `--import`), use [`@swc-node/register`](https://www.npmjs.com/package/@swc-node/register). + +## Notes + +- Watch mode is delegated to Node (`--watch`) rather than custom orchestration. +- Running `swc-node` without an entrypoint is blocked (REPL mode is not supported). +- If you need a richer REPL/workflow tool, use [`tsx`](https://github.com/privatenumber/tsx). + +## Thanks + +Thanks to these projects for reference and preceding work: + +- [`swrun`](https://www.npmjs.com/package/swrun) +- [`swcx`](https://www.npmjs.com/package/swcx) +- [`swc-node`](https://www.npmjs.com/package/swc-node) +- [`oxc-node`](https://www.npmjs.com/package/oxc-node) +- [`tsx`](https://www.npmjs.com/package/tsx) +- [`ts-node`](https://www.npmjs.com/package/ts-node) diff --git a/packages/cli/__test__/cli.spec.ts b/packages/cli/__test__/cli.spec.ts new file mode 100644 index 000000000..08a9183d2 --- /dev/null +++ b/packages/cli/__test__/cli.spec.ts @@ -0,0 +1,66 @@ +import test from 'ava' + +import { runCli } from './helpers/run-cli' + +const { version } = require('../package.json') as { version: string } + +test('prints version', (t) => { + const result = runCli(['--version']) + + t.is(result.status, 0) + t.is(result.stdout.trim(), version) +}) + +test('prints help with cli custom flags', (t) => { + const result = runCli(['--help']) + + t.is(result.status, 0) + t.true(result.stdout.includes('--tsconfig ')) + t.false(result.stdout.includes('-P, --project')) +}) + +test('supports TypeScript eval', (t) => { + const result = runCli(['-e', 'const n: number = 1; console.log(n + 1)']) + + t.is(result.status, 0) + t.is(result.stdout.trim(), '2') +}) + +test('supports TypeScript print expression', (t) => { + const result = runCli(['-p', 'const n: number = 41; n + 1']) + + t.is(result.status, 0) + t.is(result.stdout.trim(), '42') +}) + +test('supports inline --eval= and --print= forms', (t) => { + const evalResult = runCli(['--eval=const n: number = 1; console.log(n + 2)']) + t.is(evalResult.status, 0) + t.is(evalResult.stdout.trim(), '3') + + const printResult = runCli(['--print=const n: number = 41; n + 1']) + t.is(printResult.status, 0) + t.is(printResult.stdout.trim(), '42') +}) + +test('supports --tsconfig and SWC_NODE_PROJECT for project selection', (t) => { + const configPath = './packages/register/tsconfig.json' + + const fromFlag = runCli(['--tsconfig', configPath, '-e', 'console.log(process.env.SWC_NODE_PROJECT)']) + t.is(fromFlag.status, 0) + t.is(fromFlag.stdout.trim(), configPath) + + const fromEnv = runCli(['-e', 'console.log(process.env.SWC_NODE_PROJECT)'], { + env: { SWC_NODE_PROJECT: configPath }, + }) + t.is(fromEnv.status, 0) + t.is(fromEnv.stdout.trim(), configPath) +}) + +test('blocks REPL mode when no entrypoint is provided', (t) => { + const result = runCli([]) + + t.is(result.status, 1) + t.true(result.stderr.includes('REPL mode is not supported')) + t.true(result.stdout.includes('Usage:')) +}) diff --git a/packages/cli/__test__/fixtures/cjs-imports-esm/esm-dep.mts b/packages/cli/__test__/fixtures/cjs-imports-esm/esm-dep.mts new file mode 100644 index 000000000..9297169b6 --- /dev/null +++ b/packages/cli/__test__/fixtures/cjs-imports-esm/esm-dep.mts @@ -0,0 +1 @@ +export const value = 'cjs-imports-esm-ok' diff --git a/packages/cli/__test__/fixtures/cjs-imports-esm/index.cts b/packages/cli/__test__/fixtures/cjs-imports-esm/index.cts new file mode 100644 index 000000000..576b89efc --- /dev/null +++ b/packages/cli/__test__/fixtures/cjs-imports-esm/index.cts @@ -0,0 +1,7 @@ +;(async () => { + const esmDep = await (0, eval)('import("./esm-dep.mts")') + console.log(esmDep.value) +})().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/packages/cli/__test__/fixtures/cjs-imports-esm/package.json b/packages/cli/__test__/fixtures/cjs-imports-esm/package.json new file mode 100644 index 000000000..0fef86e33 --- /dev/null +++ b/packages/cli/__test__/fixtures/cjs-imports-esm/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "commonjs" +} diff --git a/packages/cli/__test__/fixtures/cjs-imports-esm/tsconfig.json b/packages/cli/__test__/fixtures/cjs-imports-esm/tsconfig.json new file mode 100644 index 000000000..fbaf87d7e --- /dev/null +++ b/packages/cli/__test__/fixtures/cjs-imports-esm/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "Node" + } +} diff --git a/packages/cli/__test__/fixtures/cjs-only/dep.ts b/packages/cli/__test__/fixtures/cjs-only/dep.ts new file mode 100644 index 000000000..e449a1e97 --- /dev/null +++ b/packages/cli/__test__/fixtures/cjs-only/dep.ts @@ -0,0 +1 @@ +export const value = 'cjs-ok' diff --git a/packages/cli/__test__/fixtures/cjs-only/index.cts b/packages/cli/__test__/fixtures/cjs-only/index.cts new file mode 100644 index 000000000..de16ca037 --- /dev/null +++ b/packages/cli/__test__/fixtures/cjs-only/index.cts @@ -0,0 +1,3 @@ +const dep = require('./dep.ts') + +console.log(dep.value) diff --git a/packages/cli/__test__/fixtures/cjs-only/package.json b/packages/cli/__test__/fixtures/cjs-only/package.json new file mode 100644 index 000000000..0fef86e33 --- /dev/null +++ b/packages/cli/__test__/fixtures/cjs-only/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "commonjs" +} diff --git a/packages/cli/__test__/fixtures/cjs-only/tsconfig.json b/packages/cli/__test__/fixtures/cjs-only/tsconfig.json new file mode 100644 index 000000000..fbaf87d7e --- /dev/null +++ b/packages/cli/__test__/fixtures/cjs-only/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "Node" + } +} diff --git a/packages/cli/__test__/fixtures/decorator-metadata/index.ts b/packages/cli/__test__/fixtures/decorator-metadata/index.ts new file mode 100644 index 000000000..96fcfb771 --- /dev/null +++ b/packages/cli/__test__/fixtures/decorator-metadata/index.ts @@ -0,0 +1,20 @@ +const metadataCalls: Array<[string, unknown]> = [] + +;(Reflect as { metadata?: (key: string, value: unknown) => () => void }).metadata = (key, value) => { + metadataCalls.push([key, value]) + return () => undefined +} + +function prop(): PropertyDecorator { + return () => undefined +} + +class Example { + @prop() + value!: string +} + +void Example + +const typeMetadata = metadataCalls.find(([key]) => key === 'design:type') +console.log(typeMetadata?.[1] === String ? 'metadata-ok' : 'metadata-missing') diff --git a/packages/cli/__test__/fixtures/decorator-metadata/package.json b/packages/cli/__test__/fixtures/decorator-metadata/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/packages/cli/__test__/fixtures/decorator-metadata/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/packages/cli/__test__/fixtures/decorator-metadata/tsconfig.json b/packages/cli/__test__/fixtures/decorator-metadata/tsconfig.json new file mode 100644 index 000000000..c1a50c54d --- /dev/null +++ b/packages/cli/__test__/fixtures/decorator-metadata/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + } +} diff --git a/packages/cli/__test__/fixtures/esm-only/dep.ts b/packages/cli/__test__/fixtures/esm-only/dep.ts new file mode 100644 index 000000000..0dfb48b04 --- /dev/null +++ b/packages/cli/__test__/fixtures/esm-only/dep.ts @@ -0,0 +1 @@ +export const value = 'esm-ok' diff --git a/packages/cli/__test__/fixtures/esm-only/index.ts b/packages/cli/__test__/fixtures/esm-only/index.ts new file mode 100644 index 000000000..c598a2cfd --- /dev/null +++ b/packages/cli/__test__/fixtures/esm-only/index.ts @@ -0,0 +1,3 @@ +import { value } from './dep.ts' + +console.log(value) diff --git a/packages/cli/__test__/fixtures/esm-only/package.json b/packages/cli/__test__/fixtures/esm-only/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/packages/cli/__test__/fixtures/esm-only/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/packages/cli/__test__/fixtures/esm-only/tsconfig.json b/packages/cli/__test__/fixtures/esm-only/tsconfig.json new file mode 100644 index 000000000..f90ad9fd1 --- /dev/null +++ b/packages/cli/__test__/fixtures/esm-only/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} diff --git a/packages/cli/__test__/fixtures/mixed/cjs-dep.cts b/packages/cli/__test__/fixtures/mixed/cjs-dep.cts new file mode 100644 index 000000000..b4b43fac6 --- /dev/null +++ b/packages/cli/__test__/fixtures/mixed/cjs-dep.cts @@ -0,0 +1,3 @@ +module.exports = { + value: 'mixed-ok', +} diff --git a/packages/cli/__test__/fixtures/mixed/index.mts b/packages/cli/__test__/fixtures/mixed/index.mts new file mode 100644 index 000000000..19fbc7d04 --- /dev/null +++ b/packages/cli/__test__/fixtures/mixed/index.mts @@ -0,0 +1,4 @@ +import * as dep from './cjs-dep.cts' + +const cjsDep = (dep as { default?: { value?: string }; value?: string }).default ?? dep +console.log((cjsDep as { value?: string }).value) diff --git a/packages/cli/__test__/fixtures/mixed/package.json b/packages/cli/__test__/fixtures/mixed/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/packages/cli/__test__/fixtures/mixed/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/packages/cli/__test__/fixtures/mixed/tsconfig.json b/packages/cli/__test__/fixtures/mixed/tsconfig.json new file mode 100644 index 000000000..f90ad9fd1 --- /dev/null +++ b/packages/cli/__test__/fixtures/mixed/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} diff --git a/packages/cli/__test__/fixtures/node-modules/.gitignore b/packages/cli/__test__/fixtures/node-modules/.gitignore new file mode 100644 index 000000000..736e8ae58 --- /dev/null +++ b/packages/cli/__test__/fixtures/node-modules/.gitignore @@ -0,0 +1 @@ +!node_modules \ No newline at end of file diff --git a/packages/cli/__test__/fixtures/node-modules/index.ts b/packages/cli/__test__/fixtures/node-modules/index.ts new file mode 100644 index 000000000..7df3ddee9 --- /dev/null +++ b/packages/cli/__test__/fixtures/node-modules/index.ts @@ -0,0 +1,5 @@ +import { value as depTsValue } from 'dep-ts' +import depCjs from 'dep-cjs' +import depCts from 'dep-cts' + +console.log([depTsValue, depCts.value, depCjs.value].join(',')) diff --git a/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cjs/index.cjs b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cjs/index.cjs new file mode 100644 index 000000000..68225702e --- /dev/null +++ b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cjs/index.cjs @@ -0,0 +1,3 @@ +module.exports = { + value: 'dep-cjs-ok', +} diff --git a/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cjs/index.d.ts b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cjs/index.d.ts new file mode 100644 index 000000000..a34ecd5c2 --- /dev/null +++ b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cjs/index.d.ts @@ -0,0 +1,5 @@ +declare const value: { + value: string +} + +export = value diff --git a/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cjs/package.json b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cjs/package.json new file mode 100644 index 000000000..a2815f628 --- /dev/null +++ b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cjs/package.json @@ -0,0 +1,10 @@ +{ + "name": "dep-cjs", + "version": "1.0.0", + "type": "commonjs", + "types": "./index.d.ts", + "main": "./index.cjs", + "exports": { + ".": "./index.cjs" + } +} diff --git a/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cts/index.cjs b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cts/index.cjs new file mode 100644 index 000000000..76d2ee840 --- /dev/null +++ b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cts/index.cjs @@ -0,0 +1,3 @@ +module.exports = { + value: 'dep-cts-ok', +} diff --git a/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cts/index.d.ts b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cts/index.d.ts new file mode 100644 index 000000000..a34ecd5c2 --- /dev/null +++ b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cts/index.d.ts @@ -0,0 +1,5 @@ +declare const value: { + value: string +} + +export = value diff --git a/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cts/package.json b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cts/package.json new file mode 100644 index 000000000..0b7481d87 --- /dev/null +++ b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-cts/package.json @@ -0,0 +1,10 @@ +{ + "name": "dep-cts", + "version": "1.0.0", + "type": "commonjs", + "types": "./index.d.ts", + "main": "./index.cjs", + "exports": { + ".": "./index.cjs" + } +} diff --git a/packages/cli/__test__/fixtures/node-modules/node_modules/dep-ts/index.d.ts b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-ts/index.d.ts new file mode 100644 index 000000000..5bbcc5ec7 --- /dev/null +++ b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-ts/index.d.ts @@ -0,0 +1 @@ +export declare const value: string diff --git a/packages/cli/__test__/fixtures/node-modules/node_modules/dep-ts/index.js b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-ts/index.js new file mode 100644 index 000000000..a9000ca98 --- /dev/null +++ b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-ts/index.js @@ -0,0 +1 @@ +export const value = 'dep-ts-ok' diff --git a/packages/cli/__test__/fixtures/node-modules/node_modules/dep-ts/package.json b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-ts/package.json new file mode 100644 index 000000000..4553abda9 --- /dev/null +++ b/packages/cli/__test__/fixtures/node-modules/node_modules/dep-ts/package.json @@ -0,0 +1,9 @@ +{ + "name": "dep-ts", + "version": "1.0.0", + "type": "module", + "types": "./index.d.ts", + "exports": { + ".": "./index.js" + } +} diff --git a/packages/cli/__test__/fixtures/node-modules/package.json b/packages/cli/__test__/fixtures/node-modules/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/packages/cli/__test__/fixtures/node-modules/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/packages/cli/__test__/fixtures/node-modules/tsconfig.json b/packages/cli/__test__/fixtures/node-modules/tsconfig.json new file mode 100644 index 000000000..f90ad9fd1 --- /dev/null +++ b/packages/cli/__test__/fixtures/node-modules/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} diff --git a/packages/cli/__test__/fixtures/non-erasable/index.ts b/packages/cli/__test__/fixtures/non-erasable/index.ts new file mode 100644 index 000000000..9d63f1fbf --- /dev/null +++ b/packages/cli/__test__/fixtures/non-erasable/index.ts @@ -0,0 +1,9 @@ +enum Status { + Ok = 40, +} + +namespace Plus { + export const two = 2 +} + +console.log(Status.Ok + Plus.two) diff --git a/packages/cli/__test__/fixtures/non-erasable/package.json b/packages/cli/__test__/fixtures/non-erasable/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/packages/cli/__test__/fixtures/non-erasable/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/packages/cli/__test__/fixtures/non-erasable/tsconfig.json b/packages/cli/__test__/fixtures/non-erasable/tsconfig.json new file mode 100644 index 000000000..f90ad9fd1 --- /dev/null +++ b/packages/cli/__test__/fixtures/non-erasable/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} diff --git a/packages/cli/__test__/fixtures/test-discovery/alpha.test.ts b/packages/cli/__test__/fixtures/test-discovery/alpha.test.ts new file mode 100644 index 000000000..d9e27b441 --- /dev/null +++ b/packages/cli/__test__/fixtures/test-discovery/alpha.test.ts @@ -0,0 +1,6 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +test('pattern-alpha.test.ts', () => { + assert.equal(1, 1) +}) diff --git a/packages/cli/__test__/fixtures/test-discovery/beta-test.cts b/packages/cli/__test__/fixtures/test-discovery/beta-test.cts new file mode 100644 index 000000000..2b139bf10 --- /dev/null +++ b/packages/cli/__test__/fixtures/test-discovery/beta-test.cts @@ -0,0 +1,6 @@ +const assert = require('node:assert/strict') +const test = require('node:test') + +test('pattern-beta-test.cts', () => { + assert.equal(1, 1) +}) diff --git a/packages/cli/__test__/fixtures/test-discovery/gamma_test.mts b/packages/cli/__test__/fixtures/test-discovery/gamma_test.mts new file mode 100644 index 000000000..4bd20e160 --- /dev/null +++ b/packages/cli/__test__/fixtures/test-discovery/gamma_test.mts @@ -0,0 +1,6 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +test('pattern-gamma_test.mts', () => { + assert.equal(1, 1) +}) diff --git a/packages/cli/__test__/fixtures/test-discovery/nonmatch.ts b/packages/cli/__test__/fixtures/test-discovery/nonmatch.ts new file mode 100644 index 000000000..6fb148252 --- /dev/null +++ b/packages/cli/__test__/fixtures/test-discovery/nonmatch.ts @@ -0,0 +1 @@ +throw new Error('nonmatch.ts should not be discovered by --test auto patterns') diff --git a/packages/cli/__test__/fixtures/test-discovery/package.json b/packages/cli/__test__/fixtures/test-discovery/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/packages/cli/__test__/fixtures/test-discovery/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/packages/cli/__test__/fixtures/test-discovery/test-delta.ts b/packages/cli/__test__/fixtures/test-discovery/test-delta.ts new file mode 100644 index 000000000..93b0ed538 --- /dev/null +++ b/packages/cli/__test__/fixtures/test-discovery/test-delta.ts @@ -0,0 +1,6 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +test('pattern-test-delta.ts', () => { + assert.equal(1, 1) +}) diff --git a/packages/cli/__test__/fixtures/test-discovery/test.ts b/packages/cli/__test__/fixtures/test-discovery/test.ts new file mode 100644 index 000000000..32b9a57ec --- /dev/null +++ b/packages/cli/__test__/fixtures/test-discovery/test.ts @@ -0,0 +1,6 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +test('pattern-test.ts', () => { + assert.equal(1, 1) +}) diff --git a/packages/cli/__test__/fixtures/test-discovery/test/epsilon.ts b/packages/cli/__test__/fixtures/test-discovery/test/epsilon.ts new file mode 100644 index 000000000..502b2b2f8 --- /dev/null +++ b/packages/cli/__test__/fixtures/test-discovery/test/epsilon.ts @@ -0,0 +1,6 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +test('pattern-test/epsilon.ts', () => { + assert.equal(1, 1) +}) diff --git a/packages/cli/__test__/fixtures/test-discovery/tsconfig.json b/packages/cli/__test__/fixtures/test-discovery/tsconfig.json new file mode 100644 index 000000000..f90ad9fd1 --- /dev/null +++ b/packages/cli/__test__/fixtures/test-discovery/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} diff --git a/packages/cli/__test__/fixtures/test-runner/math.test.ts b/packages/cli/__test__/fixtures/test-runner/math.test.ts new file mode 100644 index 000000000..7f695a63a --- /dev/null +++ b/packages/cli/__test__/fixtures/test-runner/math.test.ts @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { add } from './math.ts' + +test('adds numbers in TypeScript test file', () => { + assert.equal(add(20, 22), 42) +}) diff --git a/packages/cli/__test__/fixtures/test-runner/math.ts b/packages/cli/__test__/fixtures/test-runner/math.ts new file mode 100644 index 000000000..a39d4ef19 --- /dev/null +++ b/packages/cli/__test__/fixtures/test-runner/math.ts @@ -0,0 +1,3 @@ +export function add(a: number, b: number): number { + return a + b +} diff --git a/packages/cli/__test__/fixtures/test-runner/package.json b/packages/cli/__test__/fixtures/test-runner/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/packages/cli/__test__/fixtures/test-runner/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/packages/cli/__test__/fixtures/test-runner/tsconfig.json b/packages/cli/__test__/fixtures/test-runner/tsconfig.json new file mode 100644 index 000000000..f90ad9fd1 --- /dev/null +++ b/packages/cli/__test__/fixtures/test-runner/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} diff --git a/packages/cli/__test__/fixtures/tsconfig-paths/index.ts b/packages/cli/__test__/fixtures/tsconfig-paths/index.ts new file mode 100644 index 000000000..29b99e77d --- /dev/null +++ b/packages/cli/__test__/fixtures/tsconfig-paths/index.ts @@ -0,0 +1,4 @@ +// @ts-expect-error runtime path alias resolution is validated in fixture test +import { value } from '@utils/message' + +console.log(value) diff --git a/packages/cli/__test__/fixtures/tsconfig-paths/package.json b/packages/cli/__test__/fixtures/tsconfig-paths/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/packages/cli/__test__/fixtures/tsconfig-paths/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/packages/cli/__test__/fixtures/tsconfig-paths/src/utils/message.ts b/packages/cli/__test__/fixtures/tsconfig-paths/src/utils/message.ts new file mode 100644 index 000000000..6098ac17c --- /dev/null +++ b/packages/cli/__test__/fixtures/tsconfig-paths/src/utils/message.ts @@ -0,0 +1 @@ +export const value = 'paths-ok' diff --git a/packages/cli/__test__/fixtures/tsconfig-paths/tsconfig.json b/packages/cli/__test__/fixtures/tsconfig-paths/tsconfig.json new file mode 100644 index 000000000..c8f4da7dd --- /dev/null +++ b/packages/cli/__test__/fixtures/tsconfig-paths/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "baseUrl": ".", + "paths": { + "@utils/*": ["./src/utils/*"] + } + } +} diff --git a/packages/cli/__test__/fixtures/watch/index.ts b/packages/cli/__test__/fixtures/watch/index.ts new file mode 100644 index 000000000..d27135669 --- /dev/null +++ b/packages/cli/__test__/fixtures/watch/index.ts @@ -0,0 +1 @@ +console.log('watch-run') diff --git a/packages/cli/__test__/fixtures/watch/package.json b/packages/cli/__test__/fixtures/watch/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/packages/cli/__test__/fixtures/watch/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/packages/cli/__test__/fixtures/watch/trigger.txt b/packages/cli/__test__/fixtures/watch/trigger.txt new file mode 100644 index 000000000..626799f0f --- /dev/null +++ b/packages/cli/__test__/fixtures/watch/trigger.txt @@ -0,0 +1 @@ +v1 diff --git a/packages/cli/__test__/fixtures/watch/tsconfig.json b/packages/cli/__test__/fixtures/watch/tsconfig.json new file mode 100644 index 000000000..f90ad9fd1 --- /dev/null +++ b/packages/cli/__test__/fixtures/watch/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} diff --git a/packages/cli/__test__/fixtures/watch/value.ts b/packages/cli/__test__/fixtures/watch/value.ts new file mode 100644 index 000000000..14f9160d2 --- /dev/null +++ b/packages/cli/__test__/fixtures/watch/value.ts @@ -0,0 +1 @@ +export const value = 'watch-v1' diff --git a/packages/cli/__test__/helpers/run-cli.ts b/packages/cli/__test__/helpers/run-cli.ts new file mode 100644 index 000000000..d4c6278cd --- /dev/null +++ b/packages/cli/__test__/helpers/run-cli.ts @@ -0,0 +1,50 @@ +import { spawn, spawnSync, type ChildProcess, type SpawnOptions, type SpawnSyncReturns } from 'node:child_process' +import { resolve } from 'node:path' + +interface RunCliOptions { + cwd?: string + env?: NodeJS.ProcessEnv +} + +interface SpawnCliOptions extends RunCliOptions { + stdio?: SpawnOptions['stdio'] + detached?: boolean +} + +const entry = resolve(__dirname, '..', '..', 'src', 'index.ts') +const registerPath = require.resolve('@swc-node/register') + +export function runCli(args: string[], options: RunCliOptions = {}): SpawnSyncReturns { + const env = createCleanEnv(options.env) + + return spawnSync(process.execPath, ['-r', registerPath, entry, ...args], { + cwd: options.cwd, + env, + encoding: 'utf8', + }) +} + +export function spawnCli(args: string[], options: SpawnCliOptions = {}): ChildProcess { + const env = createCleanEnv(options.env) + + return spawn(process.execPath, ['-r', registerPath, entry, ...args], { + cwd: options.cwd, + env, + stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'], + detached: options.detached, + }) +} + +function createCleanEnv(overrides?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env = { + ...process.env, + } + + delete env.SWC_NODE_PROJECT + delete env.TS_NODE_PROJECT + + return { + ...env, + ...overrides, + } +} diff --git a/packages/cli/__test__/runtime-fixtures.spec.ts b/packages/cli/__test__/runtime-fixtures.spec.ts new file mode 100644 index 000000000..79af64561 --- /dev/null +++ b/packages/cli/__test__/runtime-fixtures.spec.ts @@ -0,0 +1,133 @@ +import { cp, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import test from 'ava' + +import { runCli } from './helpers/run-cli' + +const fixturesDir = join(__dirname, 'fixtures') + +test('runs TypeScript entrypoint in esm-only project', (t) => { + const result = runCli(['./index.ts'], { + cwd: join(fixturesDir, 'esm-only'), + }) + + t.is(result.status, 0) + t.is(result.stdout.trim(), 'esm-ok') +}) + +test('runs TypeScript entrypoint in cjs-only project', (t) => { + const result = runCli(['./index.cts'], { + cwd: join(fixturesDir, 'cjs-only'), + }) + + t.is(result.status, 0) + t.is(result.stdout.trim(), 'cjs-ok') +}) + +test('runs mixed module graph project', (t) => { + const result = runCli(['./index.mts'], { + cwd: join(fixturesDir, 'mixed'), + }) + + t.is(result.status, 0) + t.is(result.stdout.trim(), 'mixed-ok') +}) + +test('supports Node test runner with --test', (t) => { + const result = runCli(['--test', './math.test.ts'], { + cwd: join(fixturesDir, 'test-runner'), + }) + + t.is(result.status, 0) + t.true(result.stderr.trim() === '' || !result.stderr.includes('REPL mode is not supported')) + t.true(result.stdout.includes('adds numbers in TypeScript test file')) +}) + +test('runs non-erasable TypeScript syntax (enum + namespace)', (t) => { + const result = runCli(['./index.ts'], { + cwd: join(fixturesDir, 'non-erasable'), + }) + + t.is(result.status, 0) + t.is(result.stdout.trim(), '42') +}) + +test('supports emitDecoratorMetadata from tsconfig', (t) => { + const result = runCli(['./index.ts'], { + cwd: join(fixturesDir, 'decorator-metadata'), + }) + + t.is(result.status, 0) + t.is(result.stdout.trim(), 'metadata-ok') +}) + +test('works with regular node_modules packages', (t) => { + const result = runCli(['./index.ts'], { + cwd: join(fixturesDir, 'node-modules'), + }) + + t.is(result.status, 0) + t.is(result.stdout.trim(), 'dep-ts-ok,dep-cts-ok,dep-cjs-ok') +}) + +test('supports mixed graph where cjs imports esm', (t) => { + const result = runCli(['./index.cts'], { + cwd: join(fixturesDir, 'cjs-imports-esm'), + }) + + t.is(result.status, 0) + t.is(result.stdout.trim(), 'cjs-imports-esm-ok') +}) + +test('supports tsconfig paths from cwd tsconfig by default', (t) => { + const result = runCli(['./index.ts'], { + cwd: join(fixturesDir, 'tsconfig-paths'), + }) + + t.is(result.status, 0) + t.is(result.stdout.trim(), 'paths-ok') +}) + +test('supports tsconfig paths with explicit --tsconfig', (t) => { + const result = runCli(['--tsconfig', './tsconfig.json', './index.ts'], { + cwd: join(fixturesDir, 'tsconfig-paths'), + }) + + t.is(result.status, 0) + t.is(result.stdout.trim(), 'paths-ok') +}) + +test('auto-discovers TypeScript test file patterns with --test', (t) => { + const result = runCli(['--test'], { + cwd: join(fixturesDir, 'test-discovery'), + }) + + t.is(result.status, 0) + t.true(result.stdout.includes('pattern-alpha.test.ts')) + t.true(result.stdout.includes('pattern-beta-test.cts')) + t.true(result.stdout.includes('pattern-gamma_test.mts')) + t.true(result.stdout.includes('pattern-test-delta.ts')) + t.true(result.stdout.includes('pattern-test.ts')) + t.true(result.stdout.includes('pattern-test/epsilon.ts')) + t.false(result.stdout.includes('nonmatch.ts should not be discovered')) +}) + +test.serial('works when cwd does not have @swc-node/register installed', async (t) => { + const sourceDir = join(fixturesDir, 'esm-only') + const tempDir = await mkdtemp(join(tmpdir(), 'swc-node-cli-no-register-')) + + try { + await cp(sourceDir, tempDir, { recursive: true }) + + const result = runCli(['./index.ts'], { + cwd: tempDir, + }) + + t.is(result.status, 0) + t.is(result.stdout.trim(), 'esm-ok') + } finally { + await rm(tempDir, { recursive: true, force: true }) + } +}) diff --git a/packages/cli/__test__/watch.spec.ts b/packages/cli/__test__/watch.spec.ts new file mode 100644 index 000000000..8c0bd0216 --- /dev/null +++ b/packages/cli/__test__/watch.spec.ts @@ -0,0 +1,106 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +import test from 'ava' + +import { spawnCli } from './helpers/run-cli' +import { ChildProcess } from 'node:child_process' + +const fixturesDir = join(__dirname, 'fixtures') + +test.serial('watch mode restarts on change and exits cleanly', async (t) => { + t.timeout(30000) + + const fixtureDir = join(fixturesDir, 'watch') + const triggerFile = join(fixtureDir, 'trigger.txt') + const originalTrigger = await readFile(triggerFile, 'utf8') + + const child = spawnCli(['--watch-preserve-output', '--watch-path=./trigger.txt', './index.ts'], { + cwd: fixtureDir, + detached: true, + }) + + let output = '' + child.stdout?.on('data', (chunk: Buffer | string) => { + output += chunk.toString() + }) + child.stderr?.on('data', (chunk: Buffer | string) => { + output += chunk.toString() + }) + + try { + await waitFor(() => countRuns(output) >= 1, 12000) + + await writeFile(triggerFile, `v2-${Date.now()}\n`) + + await waitFor(() => countRuns(output) >= 2, 12000) + + if (child.pid) { + try { + process.kill(-child.pid, 'SIGTERM') + } catch { + child.kill('SIGTERM') + } + } else { + child.kill('SIGTERM') + } + + const closed = await waitForClose(child, 8000) + t.true(closed.signal === 'SIGTERM' || closed.code !== null) + } finally { + if (child.pid && isProcessAlive(child.pid)) { + try { + process.kill(-child.pid, 'SIGKILL') + } catch { + child.kill('SIGKILL') + } + } + await writeFile(triggerFile, originalTrigger) + } +}) + +async function waitFor(check: () => boolean, timeoutMs: number): Promise { + const start = Date.now() + + for (;;) { + if (check()) { + return + } + + if (Date.now() - start >= timeoutMs) { + throw new Error('Timed out while waiting for condition') + } + + await new Promise((resolve) => setTimeout(resolve, 50)) + } +} + +function waitForClose( + child: ChildProcess, + timeoutMs: number, +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Timed out waiting for watched process to close')) + }, timeoutMs) + + //@ts-ignore - TODO: Fix type errors + child.on('close', (code, signal) => { + clearTimeout(timeout) + resolve({ code, signal }) + }) + }) +} + +function countRuns(output: string): number { + return output.split('watch-run').length - 1 +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} diff --git a/packages/cli/index.js b/packages/cli/index.js new file mode 100755 index 000000000..79764de90 --- /dev/null +++ b/packages/cli/index.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require('./lib/index.js') diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 000000000..b94d92fa4 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,57 @@ +{ + "name": "@swc-node/cli", + "version": "1.0.0", + "description": "CLI tool intended to be a drop-in replacement for node, powered by swc.", + "keywords": [ + "swc", + "babel", + "ts-node", + "napi-rs", + "uglify", + "node-rs", + "napi-rs", + "napi", + "n-api", + "esbuild", + "tsc", + "webpack", + "node" + ], + "author": "Arthur Fiorette ", + "homepage": "https://github.com/swc-project/swc-node", + "license": "MIT", + "bin": { + "swc-node": "./index.js" + }, + "files": [ + "lib", + "LICENSE", + "index.js", + "register-cjs.js", + "register-esm.js" + ], + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "dependencies": { + "@swc/core": "^1.15.18", + "@swc-node/register": "^1.11.1", + "cross-spawn": "^7.0.6" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/swc-project/swc-node.git" + }, + "bugs": { + "url": "https://github.com/swc-project/swc-node/issues" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/arthurfiorette" + }, + "devDependencies": { + "@types/cross-spawn": "^6.0.6", + "typescript": "^5.9.3" + } +} diff --git a/packages/cli/register-cjs.js b/packages/cli/register-cjs.js new file mode 100644 index 000000000..6ca173900 --- /dev/null +++ b/packages/cli/register-cjs.js @@ -0,0 +1,3 @@ +// Preload CJS hook from this package's own dependency tree. +// Resolution is relative to this file, not the user's project cwd. +require('@swc-node/register') diff --git a/packages/cli/register-esm.js b/packages/cli/register-esm.js new file mode 100644 index 000000000..cfd963dbf --- /dev/null +++ b/packages/cli/register-esm.js @@ -0,0 +1,13 @@ +// Preload ESM hook from this package's own dependency tree. +// We avoid @swc-node/register/esm-register because that bootstrap resolves +// from process.cwd(), which can fail when the user app does not install +// @swc-node/register directly. +const { register } = require('node:module') +const { dirname, resolve } = require('node:path') +const { pathToFileURL } = require('node:url') + +const registerPath = require.resolve('@swc-node/register') +const registerDirectory = dirname(registerPath) +const esmLoaderPath = resolve(registerDirectory, 'esm/esm.mjs') + +register(pathToFileURL(esmLoaderPath).toString(), pathToFileURL(__filename).toString()) diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts new file mode 100644 index 000000000..7ec091f9c --- /dev/null +++ b/packages/cli/src/cli-args.ts @@ -0,0 +1,120 @@ +import { parseArgs } from 'node:util' + +type OptionToken = { + kind: string + name?: string + value?: unknown + rawName?: string + inlineValue?: boolean + index: number +} + +export interface ParsedCliArgs { + help: boolean + version: boolean + repl: boolean + tsconfigPath?: string + argv: string[] +} + +export function parseCliArgs(rawArgs: string[]): ParsedCliArgs { + const { values, tokens, positionals } = parseArgs({ + args: rawArgs, + options: { + // swc-node flags + help: { type: 'boolean', short: 'h' }, + version: { type: 'boolean', short: 'v' }, + tsconfig: { type: 'string' }, + + // node overrides + eval: { type: 'string', short: 'e' }, + print: { type: 'string', short: 'p' }, + }, + allowPositionals: true, + strict: false, + tokens: true, + }) + + const tsconfigPath = typeof values.tsconfig === 'string' ? values.tsconfig : undefined + const hasInlineCode = typeof values.eval === 'string' || typeof values.print === 'string' + const transformedArgs = hasInlineCode ? transformInlineCodeArgs(rawArgs, tokens, tsconfigPath) : rawArgs + const repl = shouldOpenRepl(positionals, values, rawArgs) + + return { + help: Boolean(values.help), + version: Boolean(values.version), + repl, + tsconfigPath, + argv: stripCliArgs(transformedArgs, tokens), + } +} + +function shouldOpenRepl(positionals: string[], values: Record, rawArgs: string[]): boolean { + const hasEntrypoint = positionals.length > 0 + const hasInlineCode = typeof values.eval === 'string' || typeof values.print === 'string' + + // Keep Node modes that intentionally run without entrypoints. + const hasNoEntrypointMode = rawArgs.some((arg) => arg === '--test' || arg === '--run' || arg.startsWith('--run=')) + + return !hasEntrypoint && !hasInlineCode && !hasNoEntrypointMode +} + +function transformInlineCodeArgs(args: string[], tokens: OptionToken[], tsconfigPath?: string): string[] { + // Only import the register and readDefaultTsConfig when needed to avoid unnecessary dependencies for users who don't use --eval or --print. + const { readDefaultTsConfig } = + require('@swc-node/register/read-default-tsconfig') as typeof import('@swc-node/register/read-default-tsconfig') + const { compile } = require('@swc-node/register/register') as typeof import('@swc-node/register/register') + + // readDefaultTsConfig caches by resolved tsconfig path internally, so calling + // this here and later in register bootstrap remains cheap for repeated runs. + const compilerOptions = readDefaultTsConfig(tsconfigPath) + const transformedArgs = [...args] + + for (const token of [...tokens].reverse()) { + if (token.kind !== 'option') { + continue + } + + if (token.name !== 'eval' && token.name !== 'print') { + continue + } + + if (typeof token.value !== 'string') { + continue + } + + const filename = token.name === 'eval' ? '[swc-node-eval].ts' : '[swc-node-print].ts' + const compiled = compile(token.value, filename, { ...compilerOptions }) + + if (token.inlineValue && token.rawName) { + // Convert `--eval=` / `--print=` to split form to preserve + // Node evaluation semantics when transpiled code contains line breaks. + transformedArgs.splice(token.index, 1, token.rawName, compiled) + } else { + transformedArgs[token.index + 1] = compiled + } + } + + return transformedArgs +} + +function stripCliArgs(args: string[], tokens: OptionToken[]): string[] { + const removeIndices = new Set() + + for (const token of tokens) { + if (token.kind !== 'option') { + continue + } + if (token.name !== 'help' && token.name !== 'version' && token.name !== 'tsconfig') { + continue + } + + removeIndices.add(token.index) + + if (!token.inlineValue && token.name === 'tsconfig' && args[token.index + 1] != null) { + removeIndices.add(token.index + 1) + } + } + + return args.filter((_, index) => !removeIndices.has(index)) +} diff --git a/packages/cli/src/cli-output.ts b/packages/cli/src/cli-output.ts new file mode 100644 index 000000000..b3276bc8d --- /dev/null +++ b/packages/cli/src/cli-output.ts @@ -0,0 +1,36 @@ +export function printVersion(version: string) { + process.stdout.write(`${version}\n`) +} + +export function printReplNotSupported() { + process.stderr.write('swc-node: REPL mode is not supported. Pass a file, --eval/--print, or use --test/--run.\n\n') +} + +export function printHelp(version: string) { + const helpMsg = ` + +@swc-node/cli ${version} is a tiny Node-first CLI powered by @swc-node/register + +Usage: + swc-node [swc-node flags] [node flags] [script args] + swc-node --watch + +Swc-node Flags: + -h, --help Show this help message + -v, --version Show swc-node version + --tsconfig Use a specific tsconfig (or set SWC_NODE_PROJECT environment variable) + +Node Flags With TS Support: + -e, --eval Evaluate TypeScript code + -p, --print Evaluate and print TypeScript expression + +Examples: + swc-node index.ts + swc-node -e "const n: number = 1; console.log(n)" + swc-node -p "const n: number = 41; n + 1" + swc-node --tsconfig ./tsconfig.dev.json src/index.ts + +`.trim() + + process.stdout.write(`${helpMsg}\n`) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 000000000..0daf8d047 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,59 @@ +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const spawn = require('cross-spawn') as typeof import('cross-spawn') + +import { parseCliArgs } from './cli-args' +import { printHelp, printReplNotSupported, printVersion } from './cli-output' + +const pkgJson = require('../package.json') + +const localCjsRegisterPath = resolve(__dirname, '..', 'register-cjs.js') +const localEsmRegisterPath = resolve(__dirname, '..', 'register-esm.js') +const localEsmRegisterUrl = pathToFileURL(localEsmRegisterPath).toString() + +const swcArgs = parseCliArgs(process.argv.slice(2)) + +if (swcArgs.version) { + printVersion(pkgJson.version) + process.exit(0) +} + +if (swcArgs.help) { + printHelp(pkgJson.version) + process.exit(0) +} + +if (swcArgs.repl) { + printReplNotSupported() + printHelp(pkgJson.version) + process.exit(1) +} + +// Install both hooks at process boot so swc-node works in mixed module graphs +// (ESM entrypoints importing CJS, or CJS requiring TS) without brittle mode detection. +const result = spawn.sync( + process.execPath, + ['--enable-source-maps', '-r', localCjsRegisterPath, '--import', localEsmRegisterUrl, ...swcArgs.argv], + { + stdio: 'inherit', + env: swcArgs.tsconfigPath + ? { + ...process.env, + SWC_NODE_PROJECT: swcArgs.tsconfigPath, + } + : process.env, + }, +) + +if (result.error) { + throw result.error +} + +if (result.signal) { + // Mirror child signal termination in the wrapper process. This preserves + // expected shell/CI behavior for SIGINT/SIGTERM instead of masking as exit code. + process.kill(process.pid, result.signal) +} else { + process.exit(result.status ?? 1) +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 000000000..a197aac2f --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./lib" + }, + "include": ["src"], + "exclude": ["lib", "__test__"], + "references": [{ "path": "../core" }, { "path": "../register" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a792cb76..e5ef70152 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.23 + mitata: + specifier: ^1.0.34 + version: 1.0.34 oxlint: specifier: ^1.12.0 version: 1.51.0 @@ -87,15 +90,68 @@ importers: specifier: ^7.8.2 version: 7.8.2 sinon: - specifier: ^21.0.0 + specifier: ^21.0.2 version: 21.0.2 tslib: specifier: ^2.8.1 version: 2.8.1 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.9.2 version: 5.9.3 + bench/fixture: + dependencies: + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + packages/cli: + dependencies: + '@swc-node/register': + specifier: ^1.11.1 + version: link:../register + '@swc/core': + specifier: ^1.15.18 + version: 1.15.18(@swc/helpers@0.5.19) + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 + devDependencies: + '@types/cross-spawn': + specifier: ^6.0.6 + version: 6.0.6 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + packages/cli/__test__/fixtures/cjs-imports-esm: {} + + packages/cli/__test__/fixtures/cjs-only: {} + + packages/cli/__test__/fixtures/decorator-metadata: {} + + packages/cli/__test__/fixtures/esm-only: {} + + packages/cli/__test__/fixtures/mixed: {} + + packages/cli/__test__/fixtures/node-modules: {} + + packages/cli/__test__/fixtures/non-erasable: {} + + packages/cli/__test__/fixtures/test-discovery: {} + + packages/cli/__test__/fixtures/test-runner: {} + + packages/cli/__test__/fixtures/tsconfig-paths: {} + + packages/cli/__test__/fixtures/watch: {} + packages/conditions: {} packages/core: @@ -2146,6 +2202,9 @@ packages: '@types/benchmark@2.1.5': resolution: {integrity: sha512-cKio2eFB3v7qmKcvIHLUMw/dIx/8bhWPuzpzRT4unCPRTD8VdA9Zb0afxpcxOqR4PixRS7yT42FqGS8BYL8g1w==} + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -3332,6 +3391,9 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + git-raw-commits@3.0.0: resolution: {integrity: sha512-b5OHmZ3vAgGrDn/X0kS+9qCfNKWe4K/jFnhwzVWWg0/k5eLa3060tZShrRg8Dja5kPc+YjS0Gc6y7cRr44Lpjw==} engines: {node: '>=14'} @@ -4123,6 +4185,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mitata@1.0.34: + resolution: {integrity: sha512-Mc3zrtNBKIMeHSCQ0XqRLo1vbdIx1wvFV9c8NJAiyho6AjNfMY8bVhbS12bwciUdd1t4rj8099CH3N3NFahaUA==} + modify-values@1.0.1: resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} engines: {node: '>=0.10.0'} @@ -4644,6 +4709,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve.exports@2.0.3: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} @@ -5026,6 +5094,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tuf-js@4.1.0: resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==} engines: {node: ^20.17.0 || >=22.9.0} @@ -7225,6 +7298,10 @@ snapshots: '@types/benchmark@2.1.5': {} + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 24.12.0 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -8415,6 +8492,10 @@ snapshots: get-stream@6.0.1: {} + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + git-raw-commits@3.0.0: dependencies: dargs: 7.0.0 @@ -9482,6 +9563,8 @@ snapshots: dependencies: minipass: 7.1.3 + mitata@1.0.34: {} + modify-values@1.0.1: {} ms@2.1.3: {} @@ -10090,6 +10173,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve.exports@2.0.3: {} resolve@1.22.11: @@ -10468,6 +10553,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + tuf-js@4.1.0: dependencies: '@tufjs/models': 4.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dc6ae9320..d5af0f302 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1 +1 @@ -packages: ['packages/**'] +packages: ['packages/**', 'bench/fixture']