Skip to content

Commit

Permalink
Add ts-node integration as sucrase/ts-node-plugin (#729)
Browse files Browse the repository at this point in the history
Fixes #726

This makes it easy to use Sucrase with all of the nice benefits that ts-node
provides, such as an ESM loader, tsconfig discovery, and a REPL.

The implementation is based off of these docs:
https://typestrong.org/ts-node/docs/transpilers/
and the built-in SWC integration:
https://github.com/TypeStrong/ts-node/blob/main/src/transpilers/swc.ts

Some implementation notes:
* Currently, the plugin is written as a CJS module and written in JS rather than
  TS. This it the easiest approach with the current build system, though it may
  be reasonable to extend the build system to compile a `ts-node-plugin-src`
  directory to `ts-node-plugin` or something like that. The future plan is to add
  an `exports` line to `package.json` in a semver-major release, which will make
  this a bit easier to manage.
* The plugin is included as part of the core `sucrase` package rather than as an
  integration to be installed separately. This should make the usage and version
  management a little easier, and feels reasonable because the integration is
  small and has zero dependencies.
* I rewrote the usage section of the README to put installation instructions at
  the top and to suggest ts-node as the recommended way to use Sucrase with Node.
* I added an `integration-test` directory with various cases that this plugin should
  handle, and it may be useful for future node/tooling integration tests as well.
  • Loading branch information
alangpierce authored Oct 5, 2022
1 parent d8bf165 commit 7b349b1
Show file tree
Hide file tree
Showing 50 changed files with 553 additions and 24 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
benchmark/sample/*
benchmark/node_modules/*
example-runner/example-repos
integration-test/test-cases
spec-compliance-tests/babel-tests/babel-tests-checkout
spec-compliance-tests/test262/test262-checkout
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
- run: yarn build
- run: yarn lint
- run: yarn test-with-coverage && yarn report-coverage
- run: yarn integration-test
- run: yarn test262
- run: yarn check-babel-tests
test-older-node:
Expand Down
1 change: 1 addition & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
!bin/**
!dist/**
!register/**
!ts-node-plugin/**
63 changes: 39 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,23 @@
[![MIT License](https://img.shields.io/npm/l/express.svg?maxAge=2592000)](LICENSE)
[![Join the chat at https://gitter.im/sucrasejs](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/sucrasejs/Lobby)

### [Try it out](https://sucrase.io)
## [Try it out](https://sucrase.io)

## Quick usage

```bash
yarn add --dev sucrase # Or npm install --save-dev sucrase
node -r sucrase/register main.ts
```

Using the [ts-node](https://github.com/TypeStrong/ts-node) integration:

```bash
yarn add --dev sucrase ts-node typescript
./node_modules/.bin/ts-node --transpiler sucrase/ts-node-plugin main.ts
```

## Project overview

Sucrase is an alternative to Babel that allows super-fast development builds.
Instead of compiling a large range of JS features to be able to work in Internet
Expand Down Expand Up @@ -150,41 +166,40 @@ Two legacy modes can be used with the `imports` transform:

## Usage

Installation:
### Tool integrations

```bash
yarn add --dev sucrase # Or npm install --save-dev sucrase
```
* [Webpack](https://github.com/alangpierce/sucrase/tree/main/integrations/webpack-loader)
* [Gulp](https://github.com/alangpierce/sucrase/tree/main/integrations/gulp-plugin)
* [Jest](https://github.com/alangpierce/sucrase/tree/main/integrations/jest-plugin)
* [Rollup](https://github.com/rollup/plugins/tree/master/packages/sucrase)
* [Broccoli](https://github.com/stefanpenner/broccoli-sucrase)

Often, you'll want to use one of the build tool integrations:
[Webpack](https://github.com/alangpierce/sucrase/tree/main/integrations/webpack-loader),
[Gulp](https://github.com/alangpierce/sucrase/tree/main/integrations/gulp-plugin),
[Jest](https://github.com/alangpierce/sucrase/tree/main/integrations/jest-plugin),
[Rollup](https://github.com/rollup/plugins/tree/master/packages/sucrase),
[Broccoli](https://github.com/stefanpenner/broccoli-sucrase).
### Usage in Node

Compile on-the-fly via a require hook with some [reasonable defaults](src/register.ts):

```js
// Register just one extension.
require("sucrase/register/ts");
// Or register all at once.
require("sucrase/register");
The most robust way is to use the Sucrase plugin for [ts-node](https://github.com/TypeStrong/ts-node),
which has various Node integrations and configures Sucrase via `tsconfig.json`:
```bash
ts-node --transpiler sucrase/ts-node-plugin
```

Compile on-the-fly via a drop-in replacement for node:
For projects that don't target ESM, Sucrase also has a require hook with some
reasonable defaults that can be accessed in a few ways:

```bash
sucrase-node index.ts
```
* From code: `require("sucrase/register");`
* When invoking Node: `node -r sucrase/register main.ts`
* As a separate binary: `sucrase-node main.ts`

Run on a directory:
### Compiling a project to JS

For simple use cases, Sucrase comes with a `sucrase` CLI that mirrors your
directory structure to an output directory:
```bash
sucrase ./srcDir -d ./outDir --transforms typescript,imports
```

Call from JS directly:
### Usage from code

For any advanced use cases, Sucrase can be called from JS directly:

```js
import {transform} from "sucrase";
Expand Down
42 changes: 42 additions & 0 deletions integration-test/integration-tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {join} from "path";
import {readdir, stat} from "fs/promises";
import {exec} from "child_process";
import {promisify} from "util";
const execPromise = promisify(exec);

describe("ts-node tests", async () => {
/**
* Find all integration tests in the test-cases directory.
*
* Each test has a file starting with "main" (e.g. main.ts, main.tsx,
* main.mts, etc) that's used as the entry point. The test should be written
* in such a way that the execution throws an exception if the test fails.
*/
async function* discoverTests(dir: string): AsyncIterable<string> {
for (const child of await readdir(dir)) {
const childPath = join(dir, child);
if ((await stat(childPath)).isDirectory()) {
yield* discoverTests(childPath);
} else if (child.startsWith("main")) {
yield childPath;
}
}
}

for await (const testPath of discoverTests("test-cases")) {
it(testPath, async () => {
// To help confirm that the behavior is in sync with the default ts-node
// behavior, first run ts-node without the plugin to make sure it works,
// then run it with the plugin.
await execPromise(`npx ts-node --esm --transpile-only ${testPath}`);
await execPromise(
`npx ts-node --esm --transpiler ${__dirname}/../ts-node-plugin ${testPath}`,
);
});
}

// Currently, mocha needs to be run with --delay to allow async test
// generation like this, and that also requires explicitly invoking this run
// callback.
run();
});
8 changes: 8 additions & 0 deletions integration-test/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "sucrase-integration-tests",
"version": "1.0.0",
"private": true,
"devDependencies": {
"ts-node": "^10.9.1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const x = 4;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {x} from './file';

if (x !== 4) {
throw new Error();
}

const a = 1;
// This snippet confirms that we're running in JS, not TS. In TS, it is parsed
// as a function call, and in JS, it is parsed as comparison operators.
const comparisonResult = a<2>(3);
if (comparisonResult !== false) {
throw new Error();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "CommonJS",
"target": "ESNext",
"esModuleInterop": true,
"allowJs": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const x = 7;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
async function foo() {
let calledFakeRequire = false;
const require = () => {
calledFakeRequire = true;
}
// Import should become require, which will end up calling our shadowed
// declaration of require. This is different behavior from nodenext, where
// import should be a true ESM import.
const OtherFile = await import('./file');
if (!calledFakeRequire) {
throw new Error();
}
}
foo();
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = function twelve() {
return 12;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import * as twelve from './file';

if (twelve() !== 12) {
throw new Error();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "CommonJS",
"target": "ESNext",
"esModuleInterop": false
},
}
3 changes: 3 additions & 0 deletions integration-test/test-cases/commonjs-cases/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
7 changes: 7 additions & 0 deletions integration-test/test-cases/commonjs-cases/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "CommonJS",
"target": "ESNext",
"esModuleInterop": true
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
let wasCalled: boolean = false;
function require(path) {
if (path !== "react/jsx-dev-runtime") {
throw new Error();
}
return {
jsxDEV: () => {
wasCalled = true;
}
};
}

const elem = <div />;
if (!wasCalled) {
throw new Error();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "NodeNext",
"target": "ESNext",
"esModuleInterop": true,
"jsx": "react-jsxdev",
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
let wasCalled: boolean = false;
function require(path) {
if (path !== "react/jsx-runtime") {
throw new Error();
}
return {
jsx: () => {
wasCalled = true;
}
};
}

const elem = <div />;
if (!wasCalled) {
throw new Error();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "NodeNext",
"target": "ESNext",
"esModuleInterop": true,
"jsx": "react-jsx",
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
let hWasCalledWithDiv = false;
let hWasCalledWithFragment = false;
const Fragment = {};

function h(tag) {
if (tag === 'div') {
hWasCalledWithDiv = true;
} else if (tag === Fragment) {
hWasCalledWithFragment = true;
}
}
const elem1 = <div />;
if (!hWasCalledWithDiv) {
throw new Error();
}

const elem2 = <>hello</>;
if (!hWasCalledWithFragment) {
throw new Error();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"module": "NodeNext",
"target": "ESNext",
"esModuleInterop": true,
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
let wasCalled = false;
const React = {
createElement() {
wasCalled = true;
}
}
const elem = <div />;
if (!wasCalled) {
throw new Error();
}

const a = 1;
// This snippet confirms that we're running in JS, not TS. In TS, it is parsed
// as a function call, and in JS, it is parsed as comparison operators.
const comparisonResult = a<2>(3);
if (comparisonResult !== false) {
throw new Error();
}
{}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"module": "NodeNext",
"target": "ESNext",
"esModuleInterop": true,
"jsx": "react",
"allowJs": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
let wasCalled: boolean = false;
function require(path) {
if (path !== "my-library/jsx-runtime") {
throw new Error();
}
return {
jsx: () => {
wasCalled = true;
}
};
}

const elem = <div />;
if (!wasCalled) {
throw new Error();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"module": "NodeNext",
"target": "ESNext",
"esModuleInterop": true,
"jsx": "react-jsx",
"jsxImportSource": "my-library"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
let wasCalled: boolean = false;
const React = {
createElement(): void {
wasCalled = true;
}
}
const elem = <div />;
if (!wasCalled) {
throw new Error();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "NodeNext",
"target": "ESNext",
"esModuleInterop": true,
"jsx": "react",
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Crashes if run as CJS
console.log(import.meta.url);
export const x = 3;
Loading

0 comments on commit 7b349b1

Please sign in to comment.