├── test ├── stdin │ ├── .gitignore │ ├── build.js │ └── test.sh ├── cmd │ ├── .gitignore │ ├── build.js │ ├── test.sh │ ├── program1.js │ └── test-cmd.ts ├── jsx │ ├── .gitignore │ ├── build.js │ ├── main.jsx │ ├── main.tsx │ ├── package.json │ └── test.sh ├── tempfile-output │ ├── main.js │ ├── test.sh │ └── build.js ├── issue-22-esbuildmeta │ ├── lib.js │ ├── main.js │ └── test.sh ├── cli-direct │ ├── ts-diag │ │ ├── main.ts │ │ └── tsconfig.json │ ├── ts-files │ │ ├── main.ts │ │ └── tsconfig.json │ ├── ts-include │ │ ├── main.ts │ │ └── tsconfig.json │ ├── main.ts │ └── test.sh ├── perf │ ├── test.sh │ └── test-startup-perf.js ├── types │ ├── tsconfig.json │ ├── main.ts │ └── test.sh ├── watch │ ├── test.sh │ ├── test-cli.js │ ├── test-rename.js │ ├── test-api.js │ └── test-api-callbacks.js ├── test.sh └── npm │ └── test.sh ├── examples ├── minimal-js │ ├── main.js │ └── build.js ├── minimal │ ├── main.ts │ └── build.js ├── default │ ├── src │ │ ├── c.ts │ │ ├── foo.ts │ │ └── main.ts │ ├── package.json │ ├── tsconfig.json │ └── build.js ├── custom-cli-options │ ├── main.ts │ └── build.js ├── code-generation │ ├── say.coffee │ ├── package.json │ ├── main.coffee │ ├── README.md │ └── build.js ├── typedef-generation │ ├── test.sh │ ├── README.md │ ├── main.ts │ ├── tsconfig.json │ └── build.js ├── init.sh ├── web-livereload │ ├── package.json │ ├── src │ │ └── app.ts │ ├── docs │ │ └── index.html │ ├── README.md │ ├── tsconfig.json │ └── build.js └── run │ ├── esbuild-meta.json │ ├── main.ts │ └── build.js ├── misc ├── estrella-logo.png ├── rescue.sh └── dist.sh ├── .gitignore ├── src ├── timeout.ts ├── hash.ts ├── memoize.js ├── screen.js ├── extra.ts ├── global.ts ├── textparse.ts ├── error.ts ├── signal.ts ├── typeinfo.ts ├── log.ts ├── termstyle.ts ├── config.ts ├── tsutil.ts ├── util.js ├── watch │ ├── watch.ts │ └── fswatch.ts ├── run.ts ├── debug │ └── debug.ts ├── chmod.ts ├── file.ts ├── io.ts ├── tsapi.ts ├── cli.ts └── tslint.js ├── LICENSE.txt ├── tsconfig.json ├── package.json └── BUILDING.md /test/stdin/.gitignore: -------------------------------------------------------------------------------- 1 | out/ 2 | -------------------------------------------------------------------------------- /test/cmd/.gitignore: -------------------------------------------------------------------------------- 1 | /test-cmd.js 2 | -------------------------------------------------------------------------------- /test/jsx/.gitignore: -------------------------------------------------------------------------------- 1 | /out 2 | /package-lock.json 3 | -------------------------------------------------------------------------------- /test/tempfile-output/main.js: -------------------------------------------------------------------------------- 1 | console.log("a") 2 | -------------------------------------------------------------------------------- /examples/minimal-js/main.js: -------------------------------------------------------------------------------- 1 | console.log(`Hello world`) 2 | -------------------------------------------------------------------------------- /examples/minimal/main.ts: -------------------------------------------------------------------------------- 1 | console.log(`Hello world`) 2 | -------------------------------------------------------------------------------- /test/issue-22-esbuildmeta/lib.js: -------------------------------------------------------------------------------- 1 | export const a = 1 2 | -------------------------------------------------------------------------------- /examples/default/src/c.ts: -------------------------------------------------------------------------------- 1 | export const C = "Ceasar9000" 2 | -------------------------------------------------------------------------------- /test/cli-direct/ts-diag/main.ts: -------------------------------------------------------------------------------- 1 | not_exist("Hello world") 2 | -------------------------------------------------------------------------------- /test/cli-direct/ts-files/main.ts: -------------------------------------------------------------------------------- 1 | console.log("Hello world") 2 | -------------------------------------------------------------------------------- /examples/custom-cli-options/main.ts: -------------------------------------------------------------------------------- 1 | console.log(`Hello world`) 2 | -------------------------------------------------------------------------------- /test/cli-direct/ts-include/main.ts: -------------------------------------------------------------------------------- 1 | console.log("Hello world") 2 | -------------------------------------------------------------------------------- /test/cli-direct/ts-diag/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": ["main.ts"] 3 | } 4 | -------------------------------------------------------------------------------- /test/cli-direct/ts-files/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": ["main.ts"] 3 | } 4 | -------------------------------------------------------------------------------- /test/cli-direct/ts-include/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["*.ts"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/code-generation/say.coffee: -------------------------------------------------------------------------------- 1 | export say = (text) -> 2 | console.log text 3 | -------------------------------------------------------------------------------- /misc/estrella-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsms/estrella/HEAD/misc/estrella-logo.png -------------------------------------------------------------------------------- /test/issue-22-esbuildmeta/main.js: -------------------------------------------------------------------------------- 1 | import { nonexisting } from "./lib" 2 | console.log(nonexisting) 3 | -------------------------------------------------------------------------------- /examples/typedef-generation/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | rm -rf out 4 | ./build.js -no-diag 5 | head -n3 out/main.d.ts 6 | -------------------------------------------------------------------------------- /test/perf/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd "$(dirname "$0")" 3 | 4 | node --unhandled-rejections=strict test-startup-perf.js 5 | -------------------------------------------------------------------------------- /examples/minimal/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { build } = require("estrella") 3 | build({ 4 | entry: "main.ts", 5 | outfile: "out/main.js", 6 | }) 7 | -------------------------------------------------------------------------------- /test/cli-direct/main.ts: -------------------------------------------------------------------------------- 1 | console.log("Hello world") 2 | 3 | if (process.env["SET_EXIT_CODE"]) { 4 | process.exit(parseInt(process.env["SET_EXIT_CODE"])) 5 | } 6 | -------------------------------------------------------------------------------- /examples/minimal-js/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { build } = require("estrella") 3 | build({ 4 | entry: "main.js", 5 | outfile: "out/main.js", 6 | tslint: { format: "short" } 7 | }) 8 | -------------------------------------------------------------------------------- /test/cmd/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { build } = require("estrella") 3 | build({ 4 | entry: "test-cmd.ts", 5 | outfile: "test-cmd.js", 6 | bundle: true, 7 | platform: "node", 8 | }) 9 | -------------------------------------------------------------------------------- /test/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions":{ 3 | "target": "esnext", 4 | "moduleResolution": "node", 5 | // "allowSyntheticDefaultImports": true, 6 | "noEmit": true 7 | }, 8 | "files":["main.ts"] 9 | } -------------------------------------------------------------------------------- /examples/typedef-generation/README.md: -------------------------------------------------------------------------------- 1 | This example shows how to generate TypeScript ".d.ts" files from .ts source files 2 | 3 | ``` 4 | $ ./build.js 5 | ``` 6 | 7 | Compiled .js bundle can now be found in `./out/` together with `main.d.ts` 8 | -------------------------------------------------------------------------------- /examples/default/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "out/foo.js", 5 | "scripts": { 6 | "build": "node build.js" 7 | }, 8 | "devDependencies": { 9 | "estrella": "*" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/jsx/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { build } = require("estrella") 3 | 4 | build({ 5 | entry: "main.tsx", 6 | outfile: "out/app.ts.js", 7 | }) 8 | 9 | build({ 10 | entry: "main.jsx", 11 | outfile: "out/app.js.js", 12 | }) 13 | -------------------------------------------------------------------------------- /test/types/main.ts: -------------------------------------------------------------------------------- 1 | import { build, BuildConfig, CancellablePromise } from "estrella" 2 | function mybuild(config :BuildConfig) { 3 | const r :CancellablePromise = build(config) 4 | r.cancel() 5 | } 6 | mybuild({ entryPoints:["main.ts"] }) 7 | -------------------------------------------------------------------------------- /examples/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | for d in *; do 3 | if [ -f "$d/build.js" ]; then 4 | pushd "$d" >/dev/null 5 | mkdir -p node_modules/estrella 6 | ln -s ../../../dist/estrella.js node_modules/estrella/index.js 7 | popd >/dev/null 8 | fi 9 | done 10 | -------------------------------------------------------------------------------- /examples/code-generation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "out/main.js", 5 | "scripts": { 6 | "build": "node build.js" 7 | }, 8 | "devDependencies": { 9 | "coffeescript": "^2.5.1", 10 | "estrella": "*" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/web-livereload/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build": "node build.js", 6 | "start": "node build.js -w" 7 | }, 8 | "devDependencies": { 9 | "estrella": "*", 10 | "serve-http": "^1.0.6" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/web-livereload/src/app.ts: -------------------------------------------------------------------------------- 1 | 2 | function updateClock() { 3 | const el = document.querySelector("#clock") as HTMLElement 4 | const now = new Date() 5 | el.innerText = `Time is now ${now.toLocaleTimeString()}` 6 | } 7 | 8 | setInterval(updateClock, 1000) 9 | updateClock() 10 | -------------------------------------------------------------------------------- /test/jsx/main.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ReactDOM from "react-dom" 3 | 4 | class App extends React.Component { 5 | render() { 6 | return

Hello, world!

7 | } 8 | } 9 | 10 | ReactDOM.render( 11 | , 12 | document.getElementById('root') 13 | ) 14 | -------------------------------------------------------------------------------- /examples/default/src/foo.ts: -------------------------------------------------------------------------------- 1 | export function A(text :string, repetitions :number) :string[] { 2 | let unused_variable_warning_expected = 4 3 | const a = new Array(repetitions) 4 | for (let i = 0; i < repetitions; i++) { 5 | a[i] = text 6 | } 7 | return a 8 | } 9 | 10 | export const B = "Hello" 11 | -------------------------------------------------------------------------------- /examples/typedef-generation/main.ts: -------------------------------------------------------------------------------- 1 | export interface Named { 2 | readonly name :string 3 | } 4 | 5 | export class Foo implements Named { 6 | readonly name :string 7 | } 8 | 9 | export class Bar extends Foo { 10 | readonly id :number 11 | getName() :string { 12 | return this.name 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/stdin/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs') 4 | const { build } = require('estrella') 5 | 6 | const incoming = fs.readFileSync(0, 'utf-8') 7 | 8 | build({ 9 | stdin: { 10 | contents: incoming, 11 | sourcefile: 'stdin' 12 | }, 13 | outfile: 'out/main.js', 14 | }) 15 | -------------------------------------------------------------------------------- /test/jsx/main.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ReactDOM from "react-dom" 3 | 4 | class App extends React.Component { 5 | render(): React.ReactNode { 6 | return

Hello, world!

7 | } 8 | } 9 | 10 | ReactDOM.render( 11 | , 12 | document.getElementById('root') 13 | ) 14 | -------------------------------------------------------------------------------- /examples/code-generation/main.coffee: -------------------------------------------------------------------------------- 1 | import { say } from "./say.coffee" 2 | 3 | sleep = (ms) -> 4 | new Promise (resolve) -> 5 | setTimeout resolve, ms 6 | 7 | countdown = (seconds) -> 8 | for i in [seconds..1] 9 | say i 10 | await sleep 100 # wait for a short while 11 | say "Blastoff!" 12 | 13 | countdown 3 14 | -------------------------------------------------------------------------------- /examples/web-livereload/docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | clock 5 | 6 | 7 | 8 |
00:00
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/default/src/main.ts: -------------------------------------------------------------------------------- 1 | import { A, B } from "./foo" 2 | import { C } from "./c" 3 | console.log(C) 4 | 5 | // DEBUG is true with -debug set in estrella, otherwise false. 6 | declare const DEBUG :boolean 7 | 8 | async function main() { 9 | console.log(A(B, 4)) 10 | if (DEBUG) { 11 | console.log("Running in debug mode") 12 | } 13 | } 14 | 15 | main() 16 | -------------------------------------------------------------------------------- /examples/run/esbuild-meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "inputs": { 3 | "main.ts": { 4 | "bytes": 128, 5 | "imports": [] 6 | } 7 | }, 8 | "outputs": { 9 | "out/main.js": { 10 | "imports": [], 11 | "inputs": { 12 | "main.ts": { 13 | "bytesInOutput": 119 14 | } 15 | }, 16 | "bytes": 120 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/jsx/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "estrella-test-jsx", 3 | "version": "1.0.0", 4 | "description": "test", 5 | "repository": "https://example.com", 6 | "main": "main.js", 7 | "keywords": [], 8 | "author": "estrella", 9 | "license": "ISC", 10 | "devDependencies": { 11 | "@types/react": "^16.9.49", 12 | "@types/react-dom": "^16.9.8" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.g.* 3 | *.sublime* 4 | 5 | node_modules 6 | /examples/**/out 7 | /examples/**/package-lock.json 8 | /examples/**/node_modules 9 | /examples/**/docs/app.js* 10 | /examples/**/tmp* 11 | /_local 12 | /dist/*.g.* 13 | /dist/*.mark 14 | /.test 15 | /test/**/tmp-* 16 | 17 | # typedefs copied from /estrella.d.ts to support development 18 | /dist/estrella.d.ts 19 | -------------------------------------------------------------------------------- /test/types/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd "$(dirname "$0")" 3 | 4 | rm -rf node_modules 5 | mkdir -p node_modules 6 | ln -s ../../.. node_modules/estrella 7 | ln -s ../../../node_modules/esbuild node_modules/esbuild 8 | if ! (which tsc >/dev/null); then 9 | echo "tsc not found in PATH -- installing temporarily in $PWD" 10 | npm install --no-save typescript 2>/dev/null 11 | PATH=$PWD/node_modules/.bin:$PATH 12 | fi 13 | tsc -p . 14 | echo "OK" 15 | -------------------------------------------------------------------------------- /examples/web-livereload/README.md: -------------------------------------------------------------------------------- 1 | Example of a livereload website project 2 | 3 | ``` 4 | npm install 5 | ./build.js -w 6 | ``` 7 | 8 | Open the URL printed when you run `npm start` in a web browser. 9 | Try making changes to the `src/app.ts` file and watch the results in the web browser. 10 | 11 | - `./build.js -w` — build in release mode with web server and watch mode 12 | - `./build.js` — build in release mode and exit 13 | - `./build.js -g` — build in debug mode and exit 14 | -------------------------------------------------------------------------------- /test/tempfile-output/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | cd "$(dirname "$0")" 4 | 5 | if [ -z "$ESTRELLA_PROGAM" ]; then ESTRELLA_PROGAM=../../dist/estrella.js; fi 6 | export ESTRELLA_PROGAM 7 | 8 | # test 1/2 -- API output 9 | node build.js 10 | 11 | # test 2/2 -- stout output 12 | OUTPUT=$(TEST_OUTPUT_TO_STDOUT=1 node build.js) 13 | if [ "$OUTPUT" != 'console.log("a");' ]; then 14 | echo "unexpected output on stdout: $OUTPUT" >&2 15 | exit 1 16 | fi 17 | 18 | echo "PASS OK" 19 | -------------------------------------------------------------------------------- /test/watch/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd "$(dirname "$0")" 3 | 4 | rm -rf node_modules 5 | mkdir -p node_modules 6 | ln -s ../../.. node_modules/estrella 7 | ln -s ../../../node_modules/esbuild node_modules/esbuild 8 | 9 | export ESTRELLA_TEST_VERBOSE=$ESTRELLA_TEST_VERBOSE 10 | if [ "$1" == "-verbose" ]; then 11 | export ESTRELLA_TEST_VERBOSE=1 12 | fi 13 | 14 | for f in test-*.js; do 15 | echo "$f" ; node --unhandled-rejections=strict "$f" 16 | done 17 | -------------------------------------------------------------------------------- /src/timeout.ts: -------------------------------------------------------------------------------- 1 | export function createTimeout( 2 | promise :Promise, 3 | timeout :number, 4 | rejectOnTimeout :(e:Error)=>void, 5 | ) :Promise { 6 | const timeoutTimer = setTimeout(() => { 7 | const e = new Error("timeout") 8 | e.name = "Timeout" 9 | rejectOnTimeout(e) 10 | }, timeout) 11 | return promise.then(r => { 12 | clearTimeout(timeoutTimer) 13 | return r 14 | }, e => { 15 | clearTimeout(timeoutTimer) 16 | throw e 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /test/cmd/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd "$(dirname "$0")" 3 | 4 | rm -rf node_modules 5 | mkdir -p node_modules 6 | ln -s ../../.. node_modules/estrella 7 | ln -s ../../../node_modules/esbuild node_modules/esbuild 8 | 9 | export ESTRELLA_TEST_VERBOSE=$ESTRELLA_TEST_VERBOSE 10 | if [ "$1" == "-verbose" ]; then 11 | export ESTRELLA_TEST_VERBOSE=1 12 | fi 13 | 14 | node build.js 15 | 16 | for f in test-*.js; do 17 | echo "running $f" 18 | node --unhandled-rejections=strict "$f" 19 | done 20 | -------------------------------------------------------------------------------- /src/hash.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from "crypto" 2 | 3 | export type StringEncoding = crypto.BinaryToTextEncoding 4 | export type InputData = string | NodeJS.ArrayBufferView 5 | 6 | export function sha1(input :InputData) :Buffer 7 | export function sha1(input :InputData, outputEncoding :StringEncoding) :string 8 | 9 | export function sha1(input :InputData, outputEncoding? :StringEncoding) :Buffer|string { 10 | const h = crypto.createHash('sha1').update(input) 11 | return outputEncoding ? h.digest(outputEncoding) : h.digest() 12 | } 13 | -------------------------------------------------------------------------------- /src/memoize.js: -------------------------------------------------------------------------------- 1 | import { json } from "./util" 2 | 3 | const memoizeMap = new Map() 4 | 5 | export const isMemoized = Symbol("isMemoized") 6 | 7 | export function memoize(fn) { 8 | return function memoizedCall(...args) { 9 | let k = args.map(json).join("\0") 10 | if (!memoizeMap.has(k)) { 11 | const result = fn(...args) 12 | memoizeMap.set(k, result) 13 | return result 14 | } 15 | let v = memoizeMap.get(k) 16 | if (v && typeof v == "object") { 17 | v[isMemoized] = true 18 | } 19 | return v 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/code-generation/README.md: -------------------------------------------------------------------------------- 1 | Example of using a code generator, 2 | which is [CoffeeScript](https://coffeescript.org/) in this example. 3 | 4 | ``` 5 | npm install 6 | ./build.js -watch -run 7 | ``` 8 | 9 | Now try editing the `.coffee` files and see your program being rebuilt and run in response. 10 | 11 | The generated program is a single JavaScript file at `out/main.js`. 12 | Source mapping is also generated at `out/main.js.map` which correctly maps to your coffee files 13 | (rather than the intermediate JS files generated by the CoffeeScript compiler.) 14 | -------------------------------------------------------------------------------- /examples/web-livereload/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "strictFunctionTypes": true, 5 | "strictBindCallApply": true, 6 | "noImplicitThis": true, 7 | "alwaysStrict": true, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitReturns": true, 12 | // "strictNullChecks": true, "strictPropertyInitialization": true, 13 | "moduleResolution": "node", 14 | "target": "es2017", 15 | "baseUrl": "src" 16 | }, 17 | "files": [ "src/app.ts" ] 18 | } 19 | -------------------------------------------------------------------------------- /examples/default/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "strictFunctionTypes": true, 5 | "strictBindCallApply": true, 6 | "noImplicitThis": true, 7 | "alwaysStrict": true, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitReturns": true, 12 | // "strictNullChecks": true, "strictPropertyInitialization": true, 13 | "moduleResolution": "node", 14 | "target": "es2017", 15 | "baseUrl": "src" 16 | }, 17 | "include": [ 18 | "src/main.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /examples/typedef-generation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "strictFunctionTypes": true, 5 | "strictBindCallApply": true, 6 | "noImplicitThis": true, 7 | "alwaysStrict": true, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitReturns": true, 12 | // "strictNullChecks": true, "strictPropertyInitialization": true, 13 | "moduleResolution": "node", 14 | "target": "es2017", 15 | "baseUrl": "src" 16 | }, 17 | "include": [ 18 | "main.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /examples/run/main.ts: -------------------------------------------------------------------------------- 1 | // set RUN_SLEEP=seconds to make this process sleep before exiting 2 | const log = console.log.bind(console) 3 | const env = process.env 4 | 5 | log(`Hello from a running program ${process.argv[1]}`) 6 | log(`env["ESTRELLA_PATH"] = ${env["ESTRELLA_PATH"]}`) 7 | log(`env["ESTRELLA_VERSION"] = ${env["ESTRELLA_VERSION"]}`) 8 | log(`Hello...`) 9 | console.error(`Hello on stderr`) 10 | setTimeout(() => { 11 | log(`...world!`) 12 | }, 200) 13 | 14 | const sleepsecs = parseFloat(env["RUN_SLEEP"]) 15 | if (sleepsecs > 0 && !isNaN(sleepsecs)) { 16 | log(`waiting ${sleepsecs}s until exiting`) 17 | setTimeout(() => {}, sleepsecs * 1000) 18 | } 19 | -------------------------------------------------------------------------------- /examples/web-livereload/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // 3 | // This example builds a module in both debug and release mode. 4 | // See estrella.d.ts for documentation of available options. 5 | // You can also pass any options for esbuild (as defined in esbuild/lib/main.d.ts). 6 | // 7 | const { build, cliopts } = require("estrella") 8 | const Path = require("path") 9 | 10 | build({ 11 | outfile: "docs/app.js", 12 | bundle: true, 13 | sourcemap: true, 14 | }) 15 | 16 | // Run a local web server with livereload when -watch is set 17 | cliopts.watch && require("serve-http").createServer({ 18 | port: 8181, 19 | pubdir: Path.join(__dirname, "docs"), 20 | }) 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2020, Rasmus Andersson 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "strictFunctionTypes": true, 5 | "strictBindCallApply": true, 6 | "noImplicitThis": true, 7 | "alwaysStrict": true, 8 | // "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitReturns": true, 12 | "strictNullChecks": true, 13 | "strictPropertyInitialization": true, 14 | "allowSyntheticDefaultImports": true, 15 | "moduleResolution": "node", 16 | "allowJs": true, 17 | "module": "esnext", 18 | "target": "es2017", 19 | "baseUrl": "src", 20 | "rootDirs": ["src"], 21 | "outDir": "dist", 22 | }, 23 | "files": [ 24 | "src/global.ts", 25 | "src/estrella.js", 26 | "src/debug/debug.ts", 27 | "src/watch/watch.ts", 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /examples/default/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // 3 | // This example builds a module in both debug and release mode. 4 | // See estrella.d.ts for documentation of available options. 5 | // You can also pass any options for esbuild (as defined in esbuild/lib/main.d.ts). 6 | // 7 | const { build, cliopts } = require("estrella") 8 | 9 | // config shared by products 10 | const baseConfig = { 11 | entry: "src/main.ts", 12 | bundle: true, 13 | 14 | // Examples of TypeScript diagnostic code rules: 15 | // tsrules: { 6133: "IGNORE" }, // uncomment this to silence the TS6133 warning 16 | // tsrules: { 6133: "ERROR" }, // uncomment this to cause build to fail with an error 17 | } 18 | 19 | build({ ...baseConfig, 20 | outfile: "out/foo.js", 21 | sourcemap: true, 22 | }) 23 | 24 | build({ ...baseConfig, 25 | outfile: "out/foo.debug.js", 26 | sourcemap: "inline", 27 | debug: true, 28 | }) 29 | -------------------------------------------------------------------------------- /examples/run/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { build } = require("estrella") 3 | 4 | const p = build({ 5 | entry: "main.ts", 6 | outfile: "out/main.js", 7 | platform: "node", 8 | bundle: true, 9 | 10 | // The following demonstrates that the onEnd function is run before the 11 | // program or command is executed. This allows you to do things like move 12 | // files around before running a program. 13 | async onEnd() { 14 | console.log("user onEnd begin") 15 | await new Promise(r => setTimeout(r, 200)) 16 | console.log("user onEnd done") 17 | }, 18 | 19 | // value of run can be... 20 | // - true to run outfile in node (or whatever js vm runs estrella) 21 | // - a shell command as a string (must be properly escaped for shell) 22 | // - direct program & arguments as an array of strings 23 | // This can also be specified on the command line (-run) 24 | run: true, 25 | }) 26 | -------------------------------------------------------------------------------- /test/jsx/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd "$(dirname "$0")" 3 | 4 | function fail { 5 | msg=$1 ; shift 6 | echo "FAIL $msg" >&2 7 | for line in "$@"; do 8 | echo "$line" >&2 9 | done 10 | exit 1 11 | } 12 | 13 | rm -rf node_modules 14 | mkdir -p node_modules 15 | ln -s ../../.. node_modules/estrella 16 | ln -s ../../../node_modules/esbuild node_modules/esbuild 17 | 18 | # Note: no need to run "npm install" as only type defs are declared. 19 | # When editing main.tsx it may be helpful to manuall run "npm install" to install react type defs. 20 | 21 | export ESTRELLA_TEST_VERBOSE=$ESTRELLA_TEST_VERBOSE 22 | if [ "$1" == "-verbose" ]; then 23 | export ESTRELLA_TEST_VERBOSE=1 24 | fi 25 | 26 | node build.js -quiet 27 | 28 | for f in out/*.js; do 29 | # createElement("h1" 30 | if [[ "$(cat "$f")" != *'createElement("h1"'* ]]; then 31 | fail "$f does not contain createElement(\"h1\"" 32 | fi 33 | done 34 | -------------------------------------------------------------------------------- /test/stdin/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd "$(dirname "$0")" 3 | 4 | _fail() { 5 | msg=$1 ; shift 6 | echo "FAIL $msg" >&2 7 | for line in "$@"; do 8 | echo "$line" >&2 9 | done 10 | exit 1 11 | } 12 | 13 | rm -rf node_modules 14 | mkdir -p node_modules 15 | ln -s ../../.. node_modules/estrella 16 | ln -s ../../../node_modules/esbuild node_modules/esbuild 17 | 18 | export ESTRELLA_TEST_VERBOSE=$ESTRELLA_TEST_VERBOSE 19 | if [ "$1" == "-verbose" ]; then 20 | export ESTRELLA_TEST_VERBOSE=1 21 | fi 22 | 23 | echo "testing stdin via build.js" 24 | ./build.js -quiet <<< 'export function working() { return "works" }' 25 | 26 | for f in out/*.js; do 27 | # working() 28 | if [[ "$(cat "$f")" != *'working()'* ]]; then 29 | _fail "$f does not contain working()" 30 | fi 31 | done 32 | 33 | # test cli 34 | echo "testing stdin via CLI" 35 | EXPECTED='console.log("hello");' 36 | ACTUAL="$(../../dist/estrella.js <<< 'console.log("hello")')" 37 | [ "$ACTUAL" == "$EXPECTED" ] || 38 | _fail "cli stdin: expected $EXPECTED but got $ACTUAL" 39 | -------------------------------------------------------------------------------- /test/issue-22-esbuildmeta/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This test attempts to build an invalid input in watch mode. 4 | # Build should fail and report the error and the estrella process should stay alive 5 | # and keep watching for changes. 6 | # 7 | set -e 8 | cd "$(dirname "$0")" 9 | 10 | if [ -z "$ESTRELLA_PROGAM" ]; then ESTRELLA_PROGAM=../../dist/estrella.js; fi 11 | 12 | rm -f out.* 13 | bash -c " 14 | '$ESTRELLA_PROGAM' -w -quiet -bundle -o=out.js main.js > out.log 2>&1; 15 | echo \$? > out.status 16 | " & 17 | pid=$! 18 | 19 | # give it 5 seconds to complete 20 | for i in {1..25}; do 21 | if sleep 0.01 2>/dev/null; then 22 | sleep 0.2 23 | else 24 | sleep 1 25 | fi 26 | if [ -f out.log ] && grep -q "main.js:1:9: error: No matching export" out.log; then 27 | rm -f out.* 28 | kill $pid && exit 0 29 | echo "FAIL: estrella process exited prematurely" >&2 30 | exit 1 31 | fi 32 | done 33 | 34 | # if it hasn't finished in 5 seconds, kill it and consider this test a failure 35 | kill $pid 2>/dev/null 36 | cat out.log >&2 37 | echo "FAIL (timeout)" >&2 38 | rm -f out.* 39 | exit 1 40 | -------------------------------------------------------------------------------- /misc/rescue.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | cd "$(dirname "$0")/.." 4 | 5 | export PATH=$PWD/node_modules/.bin:$PATH 6 | 7 | esbuild \ 8 | --platform=node \ 9 | --minify-whitespace \ 10 | --bundle \ 11 | --sourcemap \ 12 | --outfile=dist/estrella.js \ 13 | --external:esbuild \ 14 | --external:fsevents \ 15 | --external:typescript \ 16 | --define:DEBUG=1 \ 17 | --define:VERSION='"0.0.0-rescue"' \ 18 | src/estrella.js 19 | 20 | esbuild \ 21 | --platform=node \ 22 | --minify-whitespace \ 23 | --bundle \ 24 | --sourcemap \ 25 | --outfile=dist/debug.js \ 26 | --external:esbuild \ 27 | --external:fsevents \ 28 | --external:typescript \ 29 | --define:DEBUG=1 \ 30 | --define:VERSION='"0.0.0-rescue"' \ 31 | src/debug/debug.ts 32 | 33 | esbuild \ 34 | --platform=node \ 35 | --minify-whitespace \ 36 | --bundle \ 37 | --sourcemap \ 38 | --outfile=dist/watch.js \ 39 | --external:esbuild \ 40 | --external:fsevents \ 41 | --external:typescript \ 42 | --define:DEBUG=1 \ 43 | --define:VERSION='"0.0.0-rescue"' \ 44 | src/watch/watch.ts 45 | 46 | chmod +x dist/estrella.js 47 | echo 'Wrote to dist/' 48 | -------------------------------------------------------------------------------- /src/screen.js: -------------------------------------------------------------------------------- 1 | const stdoutIsTTY = !!process.stdout.isTTY 2 | , stderrIsTTY = !!process.stderr.isTTY 3 | 4 | export const screen = { 5 | width: 60, 6 | height: 20, 7 | clear() {}, 8 | banner(ch) { 9 | if (!ch) { ch = "-" } 10 | return ch.repeat(Math.floor((screen.width - 1) / ch.length)) 11 | }, 12 | } 13 | 14 | if (stdoutIsTTY || stderrIsTTY) { 15 | const ws = (stdoutIsTTY && process.stdout) || process.stderr 16 | const updateScreenSize = () => { 17 | screen.width = ws.columns 18 | screen.height = ws.rows 19 | } 20 | ws.on("resize", updateScreenSize) 21 | updateScreenSize() 22 | screen.clear = () => { 23 | // Note: \ec is reported to not work on the KDE console Konsole. 24 | // TODO: detect KDE Konsole and use \e[2J instead 25 | // Clear display: "\x1bc" 26 | // Clear Screen: \x1b[{n}J clears the screen 27 | // n=0 clears from cursor until end of screen 28 | // n=1 clears from cursor to beginning of screen 29 | // n=2 clears entire screen 30 | ws.write("\x1bc") 31 | } 32 | // Note: we can clear past rows relatively using these two functions: 33 | // ws.moveCursor(0, -4) 34 | // ws.clearScreenDown() 35 | } 36 | -------------------------------------------------------------------------------- /examples/typedef-generation/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { build, ts, tsconfig, dirname, glob, log } = require("estrella") 3 | 4 | build({ 5 | entry: "main.ts", 6 | outfile: "out/foo.js", 7 | onEnd(config) { 8 | const dtsFilesOutdir = dirname(config.outfile) 9 | generateTypeDefs(tsconfig(config), config.entry, dtsFilesOutdir) 10 | }, 11 | }) 12 | 13 | function generateTypeDefs(tsconfig, entryfiles, outdir) { 14 | const filenames = Array.from( 15 | new Set( 16 | (Array.isArray(entryfiles) ? entryfiles : [entryfiles]) 17 | .concat(tsconfig.include || []))).filter(v => v) 18 | log.info("Generating type declaration files for", filenames.join(", ")) 19 | const compilerOptions = { 20 | ...tsconfig.compilerOptions, 21 | moduleResolution: undefined, 22 | declaration: true, 23 | outDir: outdir, 24 | } 25 | const program = ts.ts.createProgram(filenames, compilerOptions) 26 | const targetSourceFile = undefined 27 | const writeFile = undefined 28 | const cancellationToken = undefined 29 | const emitOnlyDtsFiles = true 30 | program.emit(targetSourceFile, writeFile, cancellationToken, emitOnlyDtsFiles) 31 | log.info("Wrote", glob(outdir + "/*.d.ts").join(", ")) 32 | } 33 | -------------------------------------------------------------------------------- /src/extra.ts: -------------------------------------------------------------------------------- 1 | import * as Path from "path" 2 | 3 | import { runtimeRequire } from "./util" 4 | import { log, LogLevel } from "./log" 5 | import * as file from "./file" 6 | import * as debugModule from "./debug/debug" 7 | import * as watchModule from "./watch/watch" 8 | 9 | export type DebugModule = typeof debugModule 10 | export type WatchModule = typeof watchModule 11 | 12 | type FileModule = typeof file 13 | 14 | interface AuxModule { 15 | initModule(logLevel :LogLevel, file :FileModule) :void 16 | } 17 | 18 | // used by tests 19 | let estrellaDir = __dirname 20 | export function setEstrellaDir(dir :string) { 21 | estrellaDir = dir 22 | } 23 | 24 | 25 | function createLazyModuleAccessor(filename :string) :()=>T { 26 | let m : T | null = null 27 | return function getLazyModule() :T { 28 | if (!m) { 29 | log.debug(`loading ${filename} module`) 30 | m = runtimeRequire(Path.join(estrellaDir, filename)) 31 | m!.initModule(log.level, file) 32 | } 33 | return m! 34 | } 35 | } 36 | 37 | export const debug = createLazyModuleAccessor(DEBUG ? "debug.g.js" : "debug.js") 38 | export const watch = createLazyModuleAccessor(DEBUG ? "watch.g.js" : "watch.js") 39 | -------------------------------------------------------------------------------- /src/global.ts: -------------------------------------------------------------------------------- 1 | // defined by esbuild, configured in build.js 2 | declare const DEBUG :boolean 3 | declare const VERSION :string 4 | declare function _runtimeRequire(id :string) :any 5 | 6 | // Mutable yields a derivative of T with readonly attributes erased 7 | type Mutable = { 8 | -readonly [P in keyof T]: T[P]; 9 | } 10 | 11 | // assert checks the condition for truth, and if false, prints an optional 12 | // message, stack trace and exits the process. assert is no-op in release builds. 13 | function assert(cond :any, msg? :string, cons? :Function) :void { 14 | if (DEBUG) { 15 | if (cond) { 16 | return 17 | } 18 | const message = 'assertion failure: ' + (msg || cond) 19 | const e = new Error(message) 20 | e.name = "AssertionError" 21 | const obj :any = {} 22 | Error.captureStackTrace(obj, cons || assert) 23 | if (obj.stack) { 24 | e.stack = message + "\n" + obj.stack.split("\n").slice(1).join("\n") 25 | } 26 | if (assert.throws) { 27 | throw e 28 | } 29 | require("error").printErrorAndExit(e, "assert") 30 | } 31 | } 32 | 33 | // throws can be set to true to cause assertions to be thrown as exceptions instead 34 | // of printing the error and exiting the process. 35 | assert.throws = false 36 | 37 | ;(global as any)["assert"] = assert 38 | -------------------------------------------------------------------------------- /test/perf/test-startup-perf.js: -------------------------------------------------------------------------------- 1 | const { spawnSync } = require("child_process") 2 | const Path = require("path") 3 | const asserteq = require("assert").strictEqual 4 | const { performance } = require("perf_hooks") 5 | 6 | process.chdir(__dirname) 7 | 8 | const verbose = !!parseInt(process.env["ESTRELLA_TEST_VERBOSE"]) 9 | const clock = () => performance.now() 10 | const estrellajs = ( 11 | process.env["$ESTRELLA_PROGAM"] || 12 | Path.resolve(__dirname, "..", "..", "dist", "estrella.js") 13 | ) 14 | const args = [ 15 | estrellajs, 16 | "-help", 17 | !verbose && "-quiet", 18 | ].filter(v => v) 19 | 20 | 21 | let maxTime = 1000 22 | let minSamples = 20 23 | let startTime = clock() 24 | let samples = [] 25 | 26 | while (true) { 27 | let t = clock() 28 | const { status } = spawnSync(process.execPath, args, {}) 29 | asserteq(status, 0) 30 | samples.push(clock() - t) 31 | 32 | if (samples.length >= minSamples && clock() - startTime >= maxTime) { 33 | break 34 | } 35 | } 36 | 37 | const avg = samples.reduce((a, v) => (a + v)/2) 38 | console.log(`avg startup time: ${fmtDuration(avg)} sampled from ${samples.length} runs`) 39 | 40 | function fmtDuration(ms) { // from src/util 41 | return ( 42 | ms >= 59500 ? (ms/60000).toFixed(0) + "min" : 43 | ms >= 999.5 ? (ms/1000).toFixed(1) + "s" : 44 | ms.toFixed(2) + "ms" 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "estrella", 3 | "version": "1.4.1", 4 | "description": "Versatile build tool based on the esbuild compiler", 5 | "main": "dist/estrella.js", 6 | "bin": { 7 | "estrella": "dist/estrella.js" 8 | }, 9 | "types": "estrella.d.ts", 10 | "directories": { 11 | "example": "examples" 12 | }, 13 | "files": [ 14 | "LICENSE.txt", 15 | "README.md", 16 | "estrella.d.ts", 17 | "dist/estrella.js", 18 | "dist/estrella.js.map", 19 | "dist/debug.js", 20 | "dist/debug.js.map", 21 | "dist/watch.js", 22 | "dist/watch.js.map", 23 | ".gitignore" 24 | ], 25 | "scripts": { 26 | "build": "node build.js", 27 | "build-rescue": "bash misc/rescue.sh", 28 | "test": "bash test/test.sh" 29 | }, 30 | "author": "Rasmus Andersson ", 31 | "license": "ISC", 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/rsms/estrella.git" 35 | }, 36 | "engines": { 37 | "node": ">=12.0.0" 38 | }, 39 | "dependencies": { 40 | "esbuild": "^0.11.0" 41 | }, 42 | "optionalDependencies": { 43 | "fsevents": "~2.3.1" 44 | }, 45 | "devDependencies": { 46 | "@types/node": "^14.14.14", 47 | "@types/source-map-support": "^0.5.3", 48 | "chokidar": "^3.5.1", 49 | "miniglob": "^0.1.2", 50 | "source-map-support": "^0.5.19", 51 | "typescript": "^4.2.3" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/textparse.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface LineParserState { 3 | readonly line :string // current line contents (excluding line break) 4 | readonly startoffs :number // current line start offset 5 | readonly endoffs :number // current line end offset (including line break) 6 | 7 | // lineno is the current line number, starting at 1 for the first line. 8 | // Change this to alter line numbers (e.g. to emulate M4/C's "#line" macro.) 9 | lineno :number 10 | } 11 | 12 | // LineParser reads and yields each line of the input, including line number 13 | // and character range. 14 | // Returns true if at least one line was parsed. 15 | export function* LineParser(input :string) :Generator { 16 | const state :LineParserState = { line: "", lineno: 0, startoffs: 0, endoffs: 0 } 17 | let re = /(.*)\r?\n/g, m = null 18 | while (m = re.exec(input)) { 19 | ;(state as any).line = m[1] 20 | ;(state as any).startoffs = m.index 21 | ;(state as any).endoffs = re.lastIndex 22 | state.lineno++ 23 | yield state 24 | } 25 | if (state.endoffs < input.length) { 26 | // inclue last line which does not end with a line break 27 | ;(state as any).line = input.substr(state.endoffs) 28 | ;(state as any).startoffs = state.endoffs 29 | ;(state as any).endoffs = input.length 30 | state.lineno++ 31 | yield state 32 | } 33 | return state.lineno > 0 34 | } 35 | -------------------------------------------------------------------------------- /BUILDING.md: -------------------------------------------------------------------------------- 1 | # Developing and building Estrella itself 2 | 3 | TL;DR 4 | 5 | ```txt 6 | git clone https://github.com/rsms/estrella.git 7 | cd estrella 8 | npm install 9 | ./build.js -gw 10 | ``` 11 | 12 | Requirements: 13 | 14 | - Posix system (i.e. macOS, Linux, Windows WSL or Cygwin) 15 | It might build fine in other environments. 16 | If you try and it works, please let me know. 17 | - NodeJS version 10.10.0 or later (10.10.0 introduces mkdir -p, used in tests) 18 | - Bash version 2 or later (for testing) 19 | 20 | Files: 21 | 22 | - [`src`](src) contains estrella source files. 23 | - [`test`](test) contains directories which each is one unit test. 24 | - [`examples`](examples) contains directories of various example setups with estrella. 25 | These are also built as part of the test suite. 26 | - [`dist`](dist) is the output directory of builds. 27 | - [`misc`](misc) houses scripts and dev tools. You can ignore this. 28 | 29 | Testing: 30 | 31 | - Run all tests with `./test/test.sh` 32 | - Run one or more specific tests with `./test/test.sh test/watchdir examples/minimal` 33 | 34 | Notes: 35 | 36 | - After cloning or pulling in changes from git, run `npm install`. 37 | - Estrella builds itself: `./build.js` runs AND produces `dist/estrella.js`. 38 | - If you break `dist/estrella.js`, just `git checkout dist/estrella.js` and 39 | run `./build.js` again. Alternatively `npm run build-rescue` which uses vanilla esbuild. 40 | - Debug builds are built with `./build.js -g` producing `dist/estrella.g.js`. 41 | -------------------------------------------------------------------------------- /examples/custom-cli-options/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { build, cliopts, file, stdoutStyle } = require("estrella") 3 | 4 | // (re)parse command line options, including custom flags 5 | const [ opts, args ] = cliopts.parse( 6 | ["c, cat" , "Prints a nice cat"], 7 | ["file" , "Show contents of after building", ""], 8 | ) 9 | 10 | // Try invoking this build script with the -help flag to see the documentation 11 | // generated, which includes both build-in CLI options as well as your own. 12 | 13 | // Estrella's option parser connects aliases so we don't have to look for 14 | // "lolcat" here in addition to "cat". 15 | opts.cat && console.log(stdoutStyle.pink(` 16 | ─────────────────────────────────────── 17 | ───▐▀▄───────▄▀▌───▄▄▄▄▄▄▄───────────── 18 | ───▌▒▒▀▄▄▄▄▄▀▒▒▐▄▀▀▒██▒██▒▀▀▄────────── 19 | ──▐▒▒▒▒▀▒▀▒▀▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▀▄──────── 20 | ──▌▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▄▒▒▒▒▒▒▒▒▒▒▒▒▀▄────── 21 | ▀█▒▒▒• ▒▒█▒▒• ▒▒▒▀▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▌───── 22 | ▀▌▒▒▒▒▒▒▀▒▀▒▒▒▒▒▒▀▀▒▒▒▒▒▒▒▒▒▒▒▒▒▒▐───▄▄ 23 | ▐▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▌▄█▒█ 24 | ▐▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒█▒█▀─ 25 | ▐▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒█▀─── 26 | ─▌▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▐───── 27 | ──▌▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▐────── 28 | ──▐▄▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▄▌────── 29 | ────▀▄▄▀▀▀▀▀▄▄▀▀▀▀▀▀▀▄▄▀▀▀▀▀▄▄▀────────`.trim())) 30 | 31 | build({ 32 | entry: "main.ts", 33 | outfile: "out/main.js", 34 | 35 | async onEnd(config, buildResult) { 36 | if (opts.file) { 37 | console.log(`contents of file ${opts.file}:`) 38 | console.log(await file.read(opts.file, "utf8")) 39 | } 40 | } 41 | }) 42 | -------------------------------------------------------------------------------- /test/tempfile-output/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { build } = require(process.env.ESTRELLA_PROGAM || "../../dist/estrella") 3 | 4 | function fail(...msg) { 5 | console.error(...msg) 6 | process.exit(1) 7 | } 8 | 9 | if ("TEST_OUTPUT_TO_STDOUT" in process.env) { 10 | // test output to stdout 11 | build({ 12 | entry: "main.js", 13 | outfile: "-", 14 | }) 15 | } else { 16 | // test output to API (onEnd) 17 | setTimeout(()=>{ fail("timeout") }, 10000) 18 | build({ 19 | entry: "main.js", 20 | sourcemap: true, 21 | 22 | // Examples of output configuration: (just use ONE OF THESE) 23 | // outdir: "out", // write to directory 24 | // outfile: "out.js", // write to file 25 | // outfile: "-", // write to stdout 26 | // outfile: "", // (any falsy value) output to onEnd 27 | 28 | // Setting write:false makes esbuild skip generating code; no output. 29 | // write: false, 30 | 31 | onEnd(config, result) { 32 | if (!result.js) { 33 | // outfile or outdir was set; don't run test 34 | console.warn("SKIP test since outfile or outdir is set") 35 | process.exit(0) 36 | return 37 | } 38 | // console.log("result.map:", JSON.parse(result.map)) 39 | if (result.js.trim() != `console.log("a");`) { 40 | fail("unexpected result.js:", result.js) 41 | } 42 | try { 43 | const map = JSON.parse(result.map) 44 | if (!map || !map.sources || map.sources.length != 1 || map.sources[0] != "main.js") { 45 | fail("unexpected sourcemap:", map) 46 | } 47 | } catch (err) { 48 | fail("failed to parse sourcemap as JSON", err, result.map) 49 | } 50 | // ok 51 | process.exit(0) 52 | } 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /examples/code-generation/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { build, watch, cliopts, glob, file, log } = require("estrella") 3 | const fs = require("fs") 4 | const pjoin = require("path").join 5 | const CoffeeScript = require("coffeescript") 6 | 7 | // directory where we will write js files generated by CoffeeScript 8 | const jsdir = "tmp" 9 | 10 | // 1. Compile all .coffee files to .js files. 11 | // 2. Using esbuild, compile .js files into a bundle. 12 | // 3. In watch mode, recompile as coffee and js files change. 13 | compileCoffeScripts(glob("*.coffee")).then(() => { 14 | const { rebuild } = build({ 15 | entry: pjoin(jsdir, "main.coffee.js"), 16 | outfile: pjoin("out", "main.js"), 17 | sourcemap: true, 18 | bundle: true, // generate a single JS file 19 | clear: false, // do not clear the terminal when running interactively 20 | }) 21 | if (cliopts.watch) { 22 | watch(".", {filter:/\.coffee$/i}, changes => 23 | compileCoffeScripts(changes.map(c => c.name)).then(rebuild)) 24 | } 25 | }) 26 | 27 | // compileCoffeScripts compiles all provided .coffee files. 28 | // Returns true if all succeeded. 29 | function compileCoffeScripts(files) { 30 | return Promise.all(files.map(compileCoffeeScript)).then(v => { 31 | return v.every(ok => ok) 32 | }) 33 | } 34 | 35 | // compileCoffeeScript compiles one .coffee file to a .js file. E.g. 36 | // foo/bar.coffee -> {jsdir}/foo/bar.coffee.js 37 | // Returns true on success. On error, message is logged and false is returned. 38 | async function compileCoffeeScript(filename) { 39 | const jsfile = pjoin(jsdir, filename + ".js") 40 | log.info("compiling", filename, "->", jsfile) 41 | const code = await file.read(filename, "utf8") 42 | try { 43 | const { js } = CoffeeScript.compile(code, { 44 | sourceMap: true, 45 | inlineMap: true, 46 | bare: true, 47 | filename, 48 | }) 49 | // we use file.write instead of fs.writeFile since all file.* modification 50 | // functions coordinate with file watchers to make sure we don't "react" to 51 | // file modifications we did outselves; this avoids cycles. 52 | await file.write(jsfile, js, "utf8") 53 | } catch (err) { 54 | // CoffeeScript error objects contain a nicely formatted message 55 | console.error(err.toString()) 56 | return false 57 | } 58 | return true 59 | } 60 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | import * as extra from "./extra" 2 | import { stderrStyle } from "./termstyle" 3 | import { getModulePackageJSON } from "./util" 4 | import * as typeinfo from "./typeinfo" 5 | 6 | 7 | export class UserError extends Error { 8 | constructor(msg :string) { 9 | super(msg) 10 | this.name = "UserError" 11 | } 12 | } 13 | 14 | 15 | // captureStackTrace captures a stack trace, returning the formatted stack. 16 | // If sourcemap is true, then translate locations via source map (loads debug module.) 17 | export function captureStackTrace(cons? :Function, sourcemap? :boolean) :string { 18 | const Error_prepareStackTrace = Error.prepareStackTrace 19 | if (!sourcemap) { 20 | Error.prepareStackTrace = undefined 21 | } 22 | let stack = "" 23 | try { 24 | const e :any = {} 25 | Error.captureStackTrace(e, cons) 26 | // note: accessing e.stack invokes Error.prepareStackTrace so this must be done 27 | // before restoring Error.prepareStackTrace 28 | stack = e.stack as string 29 | } finally { 30 | Error.prepareStackTrace = Error_prepareStackTrace 31 | } 32 | return stack 33 | } 34 | 35 | 36 | export function bugReportMessage(mode :"confident"|"guess", reportContextField? :string) { 37 | return extra.debug().bugReportMessage(mode, reportContextField) 38 | } 39 | 40 | 41 | export function printErrorAndExit(err :any, origin? :string) { 42 | return extra.debug().printErrorAndExit(err, origin) 43 | } 44 | 45 | 46 | // attempt to install source-map-support just-in-time when an error occurs to avoid 47 | // taking the startup cost of 10-20ms for loading the source-map-support module. 48 | function Error_prepareStackTrace(error: Error, stack: NodeJS.CallSite[]) { 49 | Error.prepareStackTrace = undefined 50 | try { 51 | extra.debug().installSourceMapSupport() 52 | if (Error.prepareStackTrace !== Error_prepareStackTrace) { 53 | return Error.prepareStackTrace!(error, stack) 54 | } 55 | } catch(_) {} 56 | return error.stack || String(error) 57 | } 58 | 59 | 60 | // install process-level exception and rejection handlers 61 | Error.prepareStackTrace = Error_prepareStackTrace 62 | process.on("uncaughtException", printErrorAndExit) 63 | process.on("unhandledRejection", (reason :{} | null | undefined, _promise :Promise) => { 64 | printErrorAndExit(reason||"PromiseRejection", "unhandledRejection") 65 | }) 66 | -------------------------------------------------------------------------------- /test/cmd/program1.js: -------------------------------------------------------------------------------- 1 | const { spawn, ChildProcess } = require("child_process") 2 | const fs = require("fs") 3 | const { inspect } = require("util") 4 | 5 | const subprocID = process.env["TEST_SUBPROCESS_ID"] || "" 6 | const procID = subprocID ? ` subproc${subprocID}[${process.pid}]` : `mainproc[${process.pid}]` 7 | const log = (...v) => { 8 | let msg = [procID].concat(v).join(" ") 9 | // fs.writeSync so that multiple processes' output is not intertwined 10 | try { 11 | fs.writeSync(process.stdout.fd, msg + "\n") 12 | } catch (err) { 13 | // EPIPE happens when the parent process (the test program) closes its stdio pipes. 14 | if (err.code != "EPIPE") { 15 | throw err 16 | } 17 | } 18 | } 19 | const subprocs = [] 20 | 21 | log(`start`) 22 | 23 | if (process.env["IGNORE_SIGTERM"]) { 24 | log("enabled ignore SIGTERM") 25 | process.on("SIGTERM", () => { 26 | log("received and ignoring SIGTERM") 27 | if (process.env["FORWARD_SIGTERM"] && subprocs.length) { 28 | log(`forwarding SIGTERM to ${subprocs.length} subprocesses`) 29 | for (let id = 0; id < subprocs.length; id++) { 30 | const p = subprocs[id] 31 | if (p.exitCode !== null) { 32 | log(`subproc#${id}[${p.pid}] not running`) 33 | } else { 34 | p.kill("SIGTERM") 35 | } 36 | } 37 | } 38 | }) 39 | } 40 | 41 | if (!subprocID && process.env["SPAWN_SUBPROCESSES"]) { 42 | let count = parseInt(process.env["SPAWN_SUBPROCESSES"]) 43 | if (isNaN(count) || count == 0) { 44 | count = 1 45 | } 46 | log(`spawning ${count} subprocesses`) 47 | 48 | for (let i = 0; i < count; i++) { 49 | const env = {...process.env} 50 | delete env["SPAWN_SUBPROCESSES"] // avoid subprocesses spawning subprocesses spawning... 51 | env["TEST_SUBPROCESS_ID"] = `${i}` 52 | const p = spawn(process.execPath, [ __filename ], { 53 | env, 54 | stdio: "inherit", 55 | }) 56 | if (p.pid === undefined) { 57 | throw new Error(`failed to spawn subprocess`) 58 | } 59 | log(`spawned subprocess with pid ${p.pid}`) 60 | p.on("exit", (code, signal) => { 61 | log(`subproc#${i}[${p.pid}] exited code=${code} signal=${signal}`) 62 | }) 63 | subprocs.push(p) 64 | } 65 | } 66 | 67 | 68 | const idleTime = 2000 69 | log(`waiting ${idleTime/1000}s until exiting cleanly`) 70 | setTimeout(() => {}, idleTime) 71 | -------------------------------------------------------------------------------- /src/signal.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import * as os from "os" 3 | 4 | export type Signal = NodeJS.Signals 5 | export type SignalsListener = NodeJS.SignalsListener 6 | 7 | 8 | interface ListenerEntry { 9 | listeners :Set 10 | rootListener :(sig :Signal)=>void 11 | } 12 | 13 | const _listenermap = new Map() 14 | 15 | // addListener registers f to be called upon receiving signal sig. 16 | // 17 | // The semantics of this function is different than process.on(sig, f): The process is always 18 | // terminated after all handlers have been invoked. 19 | // 20 | export function addListener(sig :Signal, f :SignalsListener) { 21 | // any log messages must be sync since process is about to terminate 22 | const logerr = (msg :string) => fs.writeSync((process.stderr as any).fd, msg + "\n") 23 | 24 | let ent = _listenermap.get(sig) 25 | if (ent) { 26 | ent.listeners.add(f) 27 | } else { 28 | const listeners = new Set([f]) 29 | const rootListener = (sig :Signal) => { 30 | // output linebreak after sigint as it is most likely from user pressing ^C in terminal 31 | if (sig == "SIGINT") { 32 | fs.writeSync(/*STDOUT*/1, "\n") 33 | } 34 | 35 | // invoke all listeners 36 | DEBUG && logerr(`[signal.ts] calling ${listeners.size} registered listeners`) 37 | try { 38 | for (let f of listeners) { 39 | f(sig) 40 | } 41 | } catch (err) { 42 | logerr(`error in signal listener: ${err.stack||err}`) 43 | } 44 | 45 | // exit process 46 | process.exit(-(os.constants.signals[sig] || 1)) 47 | 48 | // // remove all listeners from process 49 | // for (let [sig, ent] of _listenermap.entries()) { 50 | // process.removeListener(sig, ent.rootListener) 51 | // } 52 | // // Signal process again, which will cause a proper "signal" termination. 53 | // // This may be important for a parent program running estrella. 54 | // process.kill(process.pid, sig) 55 | } 56 | process.on(sig, rootListener) 57 | _listenermap.set(sig, { rootListener, listeners }) 58 | } 59 | } 60 | 61 | export function removeListener(sig :Signal, f :SignalsListener) { 62 | const ent = _listenermap.get(sig) 63 | if (ent) { 64 | ent.listeners.delete(f) 65 | if (ent.listeners.size == 0) { 66 | _listenermap.delete(sig) 67 | process.removeListener(sig, ent.rootListener) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /misc/dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd "$(dirname "$0")/.." 3 | 4 | _step() { 5 | echo "———————————————————————————————————————————————————————————————————————————" 6 | echo ">>> $@" 7 | } 8 | 9 | # Check version 10 | _step "Checking version in package.json vs NPM" 11 | ESTRELLA_VERSION=$(node -e 'process.stdout.write(require("./package.json").version)') 12 | ESTRELLA_NPM_VERSION=$(npm show estrella version) 13 | if [ "$ESTRELLA_NPM_VERSION" == "$ESTRELLA_VERSION" ]; then 14 | echo "version in package.json needs to be updated ($ESTRELLA_VERSION is already published on NPM)" >&2 15 | exit 1 16 | fi 17 | 18 | # Check fsevents dependency which must match that in chokidar. 19 | # Chokidar is embedded/bundled with estrella but fsevents must be loaded at runtime as it 20 | # contains platform-native code. 21 | _step "Checking fsevents version in package.json" 22 | CHOKIDAR_FSEVENTS_VERSION=$(node -e \ 23 | 'process.stdout.write(require("./node_modules/chokidar/package.json").optionalDependencies["fsevents"])') 24 | ESTRELLA_FSEVENTS_VERSION=$(node -e \ 25 | 'process.stdout.write(require("./package.json").optionalDependencies["fsevents"])') 26 | if [ "$CHOKIDAR_FSEVENTS_VERSION" != "$ESTRELLA_FSEVENTS_VERSION" ]; then 27 | echo "The version of fsevents needs to be updated in package.json" >&2 28 | echo "to match that required by chokidar. Change it to this:" >&2 29 | echo " \"fsevents\": \"$CHOKIDAR_FSEVENTS_VERSION\"" >&2 30 | echo >&2 31 | exit 1 32 | fi 33 | 34 | # checkout products so that npm version doesn't fail. 35 | # These are regenerated later anyways. 36 | # TODO: exception for changes to dist/npm-postinstall.js which is not generated 37 | _step "Resetting ./dist/ and checking for uncommitted changes" 38 | git checkout -- dist 39 | if ! (git diff-index --quiet HEAD --); then 40 | echo "There are uncommitted changes:" >&2 41 | git status -s --untracked-files=no --ignored=no 42 | exit 1 43 | fi 44 | 45 | GIT_TREE_HASH=$(git rev-parse HEAD) 46 | CLEAN_EXIT=false 47 | 48 | _onexit() { 49 | if $CLEAN_EXIT; then 50 | exit 51 | fi 52 | if [ "$(git rev-parse HEAD)" != "$GIT_TREE_HASH" ]; then 53 | echo "Rolling back git (to $GIT_TREE_HASH)" 54 | git reset --hard "$GIT_TREE_HASH" 55 | fi 56 | } 57 | trap _onexit EXIT 58 | 59 | # build 60 | _step "./build.js" 61 | ./build.js 62 | 63 | # test 64 | _step "./test/test.sh" 65 | ./test/test.sh 66 | 67 | # publish to npm (fails and stops this script if the version is already published) 68 | _step "npm publish" 69 | npm publish 70 | 71 | # commit, tag and push git 72 | _step "git commit" 73 | git commit -m "release v${ESTRELLA_VERSION}" dist package.json package-lock.json 74 | git tag "v${ESTRELLA_VERSION}" 75 | git push origin master "v${ESTRELLA_VERSION}" 76 | 77 | CLEAN_EXIT=true 78 | -------------------------------------------------------------------------------- /test/watch/test-cli.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require("child_process") 2 | const fs = require("fs") 3 | const asserteq = require("assert").strictEqual 4 | 5 | process.chdir(__dirname) 6 | 7 | const verbose = !!parseInt(process.env["ESTRELLA_TEST_VERBOSE"]) 8 | const log = verbose ? console.log.bind(console) : ()=>{} 9 | const testInFile = "tmp-in.js" 10 | const testOutFile = "tmp-out.js" 11 | 12 | function writeInFile(content) { 13 | fs.writeFileSync(testInFile, content, "utf8") 14 | } 15 | function readOutFile() { 16 | return fs.readFileSync(testOutFile, "utf8") 17 | } 18 | 19 | writeInFile("console.log(1);\n") 20 | 21 | const command = process.env["$ESTRELLA_PROGAM"] || "./node_modules/estrella/dist/estrella.js" 22 | const args = ["-watch", "-no-clear", "-o", testOutFile, testInFile] 23 | log("spawn", command, args.join(" ")) 24 | 25 | const p = spawn(command, args, { 26 | cwd: __dirname, 27 | stdio: ['inherit', 'pipe', 'inherit'], 28 | }) 29 | 30 | let step = 1 31 | let expectedExit = false 32 | 33 | setTimeout(() => { 34 | fail("timeout") 35 | }, 5000) 36 | 37 | p.stdout.on('data', (data) => { 38 | if (verbose) { 39 | process.stdout.write(">>") 40 | process.stdout.write(data) 41 | } 42 | const s = data.toString("utf8") 43 | 44 | function assertStdout(re) { 45 | if (re.test(s)) { 46 | step++ 47 | } else { 48 | return fail("unexpected stdout data:", {s}) 49 | } 50 | } 51 | 52 | switch (step) { 53 | 54 | case 1: 55 | assertStdout(/^Wrote /i) 56 | break 57 | 58 | case 2: 59 | assertStdout(/(?:^|\n)Watching files for changes/i) 60 | // note: nodejs fs.watch has some amount real-time delay between invoking fs.watch and the 61 | // file system actually being watched. 62 | // To work around this, we sleep for a long enough time so that it is very likely to work. 63 | setTimeout(()=>{ 64 | assertOutFileContent(/^console\.log\(1\)/) 65 | writeInFile("console.log(2)") 66 | },20) 67 | break 68 | 69 | case 3: 70 | assertStdout(/file changed/i) 71 | break 72 | 73 | case 4: 74 | assertStdout(/^Wrote /i) 75 | assertOutFileContent(/^console\.log\(2\)/) 76 | expectedExit = true 77 | process.exit(0) 78 | break 79 | 80 | default: 81 | fail(`test step ${step} ??`) 82 | } 83 | }) 84 | 85 | p.on('exit', code => { 86 | if (!expectedExit) { 87 | console.log(`estrella subprocess exited prematurely with code ${code}`) 88 | process.exit(1) 89 | } 90 | }) 91 | 92 | process.on("exit", code => { 93 | console.log(code == 0 ? "PASS" : "FAIL") 94 | try { fs.unlinkSync(testInFile) } catch(_) {} 95 | try { fs.unlinkSync(testOutFile) } catch(_) {} 96 | try { p.kill() } catch(_) {} 97 | }) 98 | 99 | function fail(...msg) { 100 | console.error(...msg) 101 | process.exit(1) 102 | } 103 | 104 | function assertOutFileContent(regexp) { 105 | if (!regexp.test(readOutFile())) { 106 | fail("unexpected outfile contents:", {content:readOutFile(), regexp}) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /test/watch/test-rename.js: -------------------------------------------------------------------------------- 1 | // 2 | // This tests the file watcher's ability to track files as they are renamed, 3 | // including rewiring renamed entryPoint entries. 4 | // 5 | process.chdir(__dirname) 6 | const { build } = require("estrella") 7 | const fs = require("fs") 8 | const asserteq = require("assert").strictEqual 9 | 10 | const verbose = !!parseInt(process.env["ESTRELLA_TEST_VERBOSE"]) 11 | const log = verbose ? console.log.bind(console) : ()=>{} 12 | const repr = require("util").inspect 13 | const testInFile1 = "tmp-in.ts" 14 | const testInFile2 = "tmp-in-renamed.ts" 15 | const testOutFile = "tmp-out.js" 16 | 17 | function readOutFile() { 18 | return fs.readFileSync(testOutFile, "utf8") 19 | } 20 | 21 | function fail(...msg) { 22 | console.error(...msg) 23 | process.exit(1) 24 | } 25 | 26 | function assertOutFileContent(regexp) { 27 | if (!regexp.test(readOutFile())) { 28 | fail("unexpected outfile contents:", {content:readOutFile(), regexp}) 29 | } 30 | } 31 | 32 | process.on("exit", code => { 33 | console.log(code == 0 ? "PASS" : "FAIL") 34 | try { fs.unlinkSync(testInFile1) } catch(_) {} 35 | try { fs.unlinkSync(testInFile2) } catch(_) {} 36 | try { fs.unlinkSync(testOutFile) } catch(_) {} 37 | }) 38 | 39 | // ------------------------------------------------------------------------------- 40 | 41 | // Give estrella a short amount of time to do this before we consider it stalled 42 | const stallTimeout = 1000 43 | let resolutionExpected = false 44 | 45 | fs.writeFileSync(testInFile1, "console.log(1);\n", "utf8") 46 | 47 | let step = 1 48 | let timeoutTimer = null 49 | 50 | const buildProcess = build({ 51 | entry: testInFile1, 52 | outfile: testOutFile, 53 | clear: false, 54 | watch: true, 55 | quiet: !verbose, 56 | onEnd(config, buildResult) { 57 | //assertOutFileContent(/^console\.log\(1\)/) 58 | setTimeout(() => { 59 | log(`~~~~~~~~~~~ step ${step} ~~~~~~~~~~~`) 60 | switch (step++) { 61 | case 1: 62 | log(`moving file ${repr(testInFile1)} -> ${repr(testInFile2)}`) 63 | fs.renameSync(testInFile1, testInFile2) 64 | break 65 | case 2: 66 | log(`writing to ${repr(testInFile2)}`) 67 | fs.writeFileSync(testInFile2, "console.log(2);\n", "utf8") 68 | break 69 | default: // DONE 70 | clearTimeout(timeoutTimer) 71 | resolutionExpected = true 72 | buildProcess.cancel() 73 | break 74 | } 75 | // (re)set stall timer 76 | clearTimeout(timeoutTimer) 77 | timeoutTimer = setTimeout(() => { 78 | fail(`stalled -- no progress during the past ${stallTimeout/1000}s`) 79 | }, stallTimeout) 80 | timeoutTimer.unref() // this takes it out of the "stop from exit" list of node's runloop 81 | },100) 82 | } 83 | }) 84 | 85 | buildProcess.then(ok => { 86 | log(`buildProcess ended`, {ok}) 87 | if (!resolutionExpected) { 88 | console.error("buildProcess ended prematurely") 89 | process.exit(1) 90 | } else { 91 | process.exit(ok ? 0 : 1) 92 | } 93 | }) 94 | 95 | buildProcess.catch(fail) 96 | -------------------------------------------------------------------------------- /test/watch/test-api.js: -------------------------------------------------------------------------------- 1 | process.chdir(__dirname) 2 | const { build } = require("estrella") 3 | const fs = require("fs") 4 | const asserteq = require("assert").strictEqual 5 | 6 | const verbose = !!parseInt(process.env["ESTRELLA_TEST_VERBOSE"]) 7 | const log = verbose ? console.log.bind(console) : ()=>{} 8 | const testInFile = "tmp-in.ts" 9 | const testTSFile = "tsconfig.json" 10 | const testOutFile = "tmp-out.js" 11 | 12 | function writeInFile(content) { 13 | fs.writeFileSync(testInFile, content, "utf8") 14 | } 15 | function readOutFile() { 16 | return fs.readFileSync(testOutFile, "utf8") 17 | } 18 | 19 | function fail(...msg) { 20 | console.error(...msg) 21 | process.exit(1) 22 | } 23 | 24 | function assertOutFileContent(regexp) { 25 | if (!regexp.test(readOutFile())) { 26 | fail("unexpected outfile contents:", {content:readOutFile(), regexp}) 27 | } 28 | } 29 | 30 | process.on("exit", code => { 31 | console.log(code == 0 ? "PASS" : "FAIL") 32 | try { fs.unlinkSync(testInFile) } catch(_) {} 33 | try { fs.unlinkSync(testOutFile) } catch(_) {} 34 | try { fs.unlinkSync(testTSFile) } catch(_) {} 35 | }) 36 | 37 | let resolutionExpected = false 38 | 39 | writeInFile("console.log(1);\n") 40 | fs.writeFileSync(testTSFile, `{ 41 | "files":["${testInFile}"], 42 | "compilerOptions": {} 43 | }`, "utf8") 44 | 45 | const buildProcess = build({ 46 | entry: testInFile, 47 | outfile: testOutFile, 48 | clear: false, 49 | watch: true, 50 | quiet: !verbose, 51 | 52 | onEnd(config, buildResult) { 53 | asserteq(buildResult.warnings.length, 0) 54 | asserteq(buildResult.errors.length, 0, JSON.stringify(buildResult.errors)) 55 | assertOutFileContent(/^console\.log\(1\)/) 56 | setTimeout(() => { 57 | try { 58 | writeInFile("console.log(2);\n") 59 | 60 | // stop the build process 61 | log("calling buildProcess.cancel()") 62 | resolutionExpected = true 63 | buildProcess.cancel() 64 | 65 | // at this point all subprocesses should be cancelled by estrella 66 | // and the node process should exit when the runloop is empty. 67 | // 68 | // Give estrella a short amount of time to do this before we consider it 69 | // stalled: 70 | const timeout = 2000 71 | const timer = setTimeout(() => { 72 | fail(`stalled -- has not exited after being canceled ${timeout/1000}s ago`) 73 | }, timeout) 74 | timer.unref() // this takes it out of the "stop from exit" list of node's runloop 75 | // 76 | // Note: To verify this test is sound, empty out the cancel() function inside 77 | // the build() function in src/estrella.js 78 | // 79 | } catch (err) { 80 | fail(err.stack||String(err)) 81 | } 82 | },100) 83 | } 84 | }) 85 | 86 | // console.log({"buildProcess.cancel": buildProcess.cancel.toString()}) 87 | 88 | // Note: intentionally avoiding using onStart and onEnd to reduce bug surface area 89 | 90 | buildProcess.then(result => { 91 | log(`buildProcess ended with`, {result}) 92 | if (!resolutionExpected) { 93 | console.error("build() resolved prematurely") 94 | process.exit(1) 95 | } 96 | }) 97 | 98 | buildProcess.catch(fail) 99 | 100 | 101 | // process.nextTick(() => { 102 | // writeInFile("console.log(2);\n") 103 | // }) 104 | -------------------------------------------------------------------------------- /src/typeinfo.ts: -------------------------------------------------------------------------------- 1 | // Do not edit. Generated by build.js 2 | 3 | export const esbuild = { 4 | version: "0.11.20", 5 | BuildOptions: new Set([ 6 | "sourcemap" , // boolean | 'inline' | 'external' | 'both' 7 | "legalComments" , // 'none' | 'inline' | 'eof' | 'linked' | 'external' 8 | "sourceRoot" , // string 9 | "sourcesContent" , // boolean 10 | "format" , // Format 11 | "globalName" , // string 12 | "target" , // string | string[] 13 | "minify" , // boolean 14 | "minifyWhitespace" , // boolean 15 | "minifyIdentifiers" , // boolean 16 | "minifySyntax" , // boolean 17 | "charset" , // Charset 18 | "treeShaking" , // TreeShaking 19 | "jsxFactory" , // string 20 | "jsxFragment" , // string 21 | "define" , // { [key: string]: string; } 22 | "pure" , // string[] 23 | "keepNames" , // boolean 24 | "color" , // boolean 25 | "logLevel" , // LogLevel 26 | "logLimit" , // number 27 | "bundle" , // boolean 28 | "splitting" , // boolean 29 | "preserveSymlinks" , // boolean 30 | "outfile" , // string 31 | "metafile" , // boolean 32 | "outdir" , // string 33 | "outbase" , // string 34 | "platform" , // Platform 35 | "external" , // string[] 36 | "loader" , // { [ext: string]: Loader; } 37 | "resolveExtensions" , // string[] 38 | "mainFields" , // string[] 39 | "conditions" , // string[] 40 | "write" , // boolean 41 | "allowOverwrite" , // boolean 42 | "tsconfig" , // string 43 | "outExtension" , // { [ext: string]: string; } 44 | "publicPath" , // string 45 | "entryNames" , // string 46 | "chunkNames" , // string 47 | "assetNames" , // string 48 | "inject" , // string[] 49 | "banner" , // { [type: string]: string; } 50 | "footer" , // { [type: string]: string; } 51 | "incremental" , // boolean 52 | "entryPoints" , // string[] | Record 53 | "stdin" , // StdinOptions 54 | "plugins" , // Plugin[] 55 | "absWorkingDir" , // string 56 | "nodePaths" , // string[] 57 | "watch" , // boolean | WatchMode 58 | ]), // BuildOptions 59 | } 60 | 61 | export const estrella = { 62 | BuildConfig: new Set([ 63 | "entry" , // string | string[] | Record 64 | "debug" , // boolean 65 | "watch" , // boolean | WatchOptions 66 | "cwd" , // string 67 | "quiet" , // boolean 68 | "silent" , // boolean 69 | "clear" , // boolean 70 | "tslint" , // boolean | "auto" | "on" | "off" | TSLintBasicOptions 71 | "onStart" , // (config: Readonly, changedFiles: string[], ctx: BuildContext) => Promise | any 72 | "onEnd" , // (config: Readonly, buildResult: BuildResult, ctx: BuildContext) => Promise | any 73 | "outfileMode" , // number | string | string[] 74 | "run" , // boolean | string | string[] 75 | "tsc" , // boolean | "auto" | "on" | "off" 76 | "tsrules" , // TSRules 77 | "title" , // string 78 | ]), // BuildConfig 79 | } -------------------------------------------------------------------------------- /test/watch/test-api-callbacks.js: -------------------------------------------------------------------------------- 1 | const { build } = require("estrella") 2 | const fs = require("fs") 3 | const assert = require("assert") 4 | 5 | process.chdir(__dirname) 6 | 7 | const verbose = !!parseInt(process.env["ESTRELLA_TEST_VERBOSE"]) 8 | const log = verbose ? console.log.bind(console) : ()=>{} 9 | const testInFile = "tmp-in.js" 10 | 11 | function writeInFile(content) { 12 | fs.writeFileSync(testInFile, content, "utf8") 13 | } 14 | 15 | function fail(...msg) { 16 | console.error(...msg) 17 | process.exit(1) 18 | } 19 | 20 | process.on("exit", code => { 21 | console.log(code == 0 ? "PASS" : "FAIL") 22 | try { fs.unlinkSync(testInFile) } catch(_) {} 23 | }) 24 | 25 | 26 | writeInFile("console.log(1);\n") 27 | 28 | let expected_changedFiles = [] 29 | let testIsDone = false 30 | let onEndCounter = 0 31 | 32 | 33 | ;(async () => { 34 | // ----------------------------------------------------------------------------- 35 | 36 | 37 | // -------------------------------------- 38 | // first, verify that error handling in callbacks works 39 | 40 | const onStartPromise = build({ 41 | entry: testInFile, 42 | quiet: !verbose, 43 | onStart(config, changedFiles) { throw new Error("onStart") }, 44 | }).catch(err => err) 45 | 46 | const onEndPromise = build({ 47 | entry: testInFile, 48 | quiet: !verbose, 49 | onEnd(config, result) { throw new Error("onEnd") }, 50 | }).catch(err => err) 51 | 52 | // TODO add timeout? 53 | const [ startErr, endErr ] = await Promise.all([ onStartPromise, onEndPromise ]) 54 | if (!startErr || startErr.message != "onStart") { 55 | assert.fail(`did not get expected error onStart`) 56 | } 57 | if (!endErr || endErr.message != "onEnd") { 58 | assert.fail(`did not get expected error onEnd`) 59 | } 60 | log(`test errors in callbacks: OK`) 61 | 62 | // -------------------------------------- 63 | // next, verify that onStart receives the expected input in watch mode 64 | // and that a change to a source file triggers rebuild. 65 | 66 | const buildProcess = build({ 67 | entry: testInFile, 68 | clear: false, 69 | watch: true, 70 | quiet: !verbose, 71 | 72 | onStart(config, changedFiles) { 73 | assert.deepStrictEqual(expected_changedFiles, changedFiles.sort()) 74 | log("onStart gets expected input: OK") 75 | if (testIsDone) { 76 | assert.equal(onEndCounter, 1, "onEnd called once") 77 | log("onEnd called once: OK") 78 | process.exit(0) 79 | } 80 | }, 81 | 82 | onEnd(config, result) { 83 | if (onEndCounter == 0) { 84 | // give fswatch a decent time window to complete its initial scan 85 | setTimeout(() => { 86 | log(`watch test writing edit to ${testInFile}`) 87 | writeInFile("console.log(2);\n") 88 | expected_changedFiles = [ testInFile ] 89 | testIsDone = true 90 | }, 100) 91 | } 92 | onEndCounter++ 93 | }, 94 | }) 95 | 96 | buildProcess.catch(err => { 97 | console.error(`Got error ${err.stack||err}`) 98 | process.exit(1) 99 | }) 100 | 101 | buildProcess.then(result => { 102 | // we should never get here; our onStart handler should call process.exit before. 103 | console.error("build() resolved prematurely") 104 | process.exit(1) 105 | }) 106 | 107 | // ----------------------------------------------------------------------------- 108 | })().catch(err => { 109 | console.error(`${err.stack||err}`) 110 | process.exit(1) 111 | }) 112 | -------------------------------------------------------------------------------- /test/cli-direct/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | cd "$(dirname "$0")" 4 | 5 | estrella=$PWD/node_modules/estrella/dist/estrella.js 6 | if [ -n "$ESTRELLA_PROGAM" ]; then 7 | estrella=$ESTRELLA_PROGAM 8 | fi 9 | outfile=.out.js 10 | 11 | rm -rf node_modules 12 | mkdir -p node_modules 13 | ln -s ../../.. node_modules/estrella 14 | ln -s ../../../node_modules/esbuild node_modules/esbuild 15 | 16 | _fail() { 17 | echo "FAIL $1" >&2 18 | exit 1 19 | } 20 | 21 | _test() { 22 | echo "run test $1" 23 | } 24 | 25 | _clean() { 26 | rm -f "$outfile" 27 | } 28 | trap _clean EXIT 29 | trap exit SIGINT # make sure we can ctrl-c in loops 30 | 31 | _clean 32 | 33 | 34 | _test "should write to outfile" 35 | "$estrella" "-outfile=$outfile" main.ts -quiet 36 | expect='console.log("Hello world")' 37 | actual=$(cat "$outfile") 38 | if [[ "$("$estrella" main.ts)" != "$expect"* ]]; then 39 | _fail "Unexpected output to $outfile. Expected [${expect}*] but got [$actual]" 40 | fi 41 | echo "PASS" 42 | 43 | _test "should run outfile when -run is set" 44 | expect='Hello world' 45 | actual=$("$estrella" "-outfile=$outfile" main.ts -quiet -run) 46 | if [[ "$actual" != "$expect" ]]; then 47 | _fail "Unexpected output (-run). Expected [$expect] but got [$actual]" 48 | fi 49 | echo "PASS" 50 | 51 | _test "Should print to stdout when no outfile is given." 52 | # Also there should be no "Wrote" log message. 53 | expect='console.log("Hello world")' 54 | actual=$("$estrella" main.ts) 55 | if [[ "$actual" != "$expect"* ]]; then 56 | _fail "Unexpected output to stdout. Expected [${expect}*] but got [$actual]" 57 | fi 58 | echo "PASS" 59 | 60 | _test "Should run rather than print to stdout when no outfile is given and -run is set" 61 | # Also there should be no "Wrote" log message. 62 | expect='Hello world' 63 | actual=$("$estrella" main.ts -run) 64 | if [[ "$actual" != "$expect" ]]; then 65 | _fail "Unexpected stdout (-run). Expected [$expect] but got [$actual]" 66 | fi 67 | echo "PASS" 68 | 69 | # ------------------------ 70 | # infile inference 71 | 72 | _test "should fail to infer input files (no tsconfig.json)" 73 | set +e 74 | actual=$("$estrella" 2>&1) 75 | set -e 76 | if [[ "$actual" != *"missing "* ]]; then 77 | _fail "Should have failed with no inputs, with '... missing ...'. Got $actual" 78 | fi 79 | echo "PASS" 80 | 81 | 82 | pushd ts-files >/dev/null 83 | _test "(ts-files) infile inference from tsconfig.json:files; -run" 84 | expect='Hello world' 85 | actual=$("$estrella" "-outfile=../$outfile" -quiet -run -no-diag) 86 | if [[ "$actual" != "$expect" ]]; then 87 | _fail "Unexpected output (-run). Expected [$expect] but got [$actual]" 88 | fi 89 | echo "PASS" 90 | popd >/dev/null 91 | 92 | 93 | pushd ts-include >/dev/null 94 | _test "(ts-include) infile inference from tsconfig.json:include (glob); -run" 95 | expect='Hello world' 96 | actual=$("$estrella" "-outfile=../$outfile" -quiet -run -no-diag) 97 | if [[ "$actual" != "$expect" ]]; then 98 | _fail "Unexpected output (-run). Expected [$expect] but got [$actual]" 99 | fi 100 | echo "PASS" 101 | popd >/dev/null 102 | 103 | 104 | pushd ts-diag >/dev/null 105 | 106 | _test "(ts-diag) infile inference from tsconfig.json:files; -diag should report an error" 107 | expect="TS2304: Cannot find name 'not_exist'" 108 | actual=$("$estrella" -diag 2>&1) 109 | if [[ "$actual" != *"$expect"* ]]; then 110 | _fail "Unexpected output (-run). Expected [*${expect}*] but got [$actual]" 111 | fi 112 | echo "PASS" 113 | popd >/dev/null 114 | 115 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | import { Console } from "console" 2 | import { stdoutStyle, stderrStyle } from "./termstyle" 3 | import { memoize } from "./memoize" 4 | import { prog } from "./cli" 5 | import { captureStackTrace } from "./error" 6 | 7 | import { Log as LogAPI } from "../estrella" 8 | 9 | declare const DEBUG :boolean 10 | 11 | export interface Env { 12 | log :typeof log 13 | } 14 | 15 | export enum LogLevel { 16 | Silent = -1,// log nothing 17 | Error = 0, // only log errors 18 | Warn, // log errors and warnings 19 | Info, // log errors, warnings and info 20 | Debug, // log everything 21 | } 22 | 23 | let log_console = console 24 | let log_colorMode :boolean|undefined = undefined 25 | 26 | export const log = new class Log implements LogAPI { 27 | readonly SILENT = LogLevel.Silent // = -1 28 | readonly ERROR = LogLevel.Error // = 0 29 | readonly WARN = LogLevel.Warn // = 1 30 | readonly INFO = LogLevel.Info // = 2 31 | readonly DEBUG = LogLevel.Debug // = 3 32 | 33 | level = LogLevel.Info 34 | 35 | error(...v :any[]) :void { 36 | if (log.level >= LogLevel.Error) { 37 | evalFunctionInArgs(v) 38 | log_console.error(stderrStyle.red(`${prog}:`), ...v) 39 | } 40 | } 41 | warn(...v :any[]) :void { 42 | if (log.level >= LogLevel.Warn) { 43 | evalFunctionInArgs(v) 44 | log_console.error(stderrStyle.magenta(`${prog}:`), ...v) 45 | } 46 | } 47 | info(...v :any[]) :void { 48 | if (log.level >= LogLevel.Info) { 49 | evalFunctionInArgs(v) 50 | log_console.log(...v) 51 | } 52 | } 53 | 54 | // DEPRECATED in Estrella 1.2.2 55 | readonly infoOnce = this.info 56 | 57 | readonly debug = log_debug 58 | 59 | get colorMode() :boolean|undefined { 60 | return log_colorMode 61 | } 62 | set colorMode(colorMode :boolean|undefined) { 63 | if (log_colorMode === colorMode) { 64 | return 65 | } 66 | log_colorMode = colorMode 67 | if (colorMode === undefined) { // auto 68 | log_console = console 69 | } else { 70 | log_console = new Console({ 71 | stdout: process.stdout, 72 | stderr: process.stderr, 73 | colorMode 74 | }) 75 | } 76 | } 77 | } 78 | 79 | export default log 80 | 81 | function evalFunctionInArgs(args :any[]) { 82 | // evaluate first function argument 83 | if (typeof args[0] == "function") { 84 | args[0] = args[0]() 85 | } 86 | } 87 | 88 | function log_debug(...v :any[]) { 89 | if (log.level >= LogLevel.Debug) { 90 | let meta = "" 91 | 92 | if (DEBUG) { 93 | // stack traces are only useful in debug builds (not mangled) 94 | const stack = captureStackTrace(log_debug) 95 | const frames = stack.split("\n", 5) 96 | const f = frames[1] // stack frame 97 | let m = f && /at (\w+)/.exec(f) 98 | if (m) { 99 | meta = " " + m[1] 100 | } else if (!m && frames[2]) { 101 | if (m = frames[2] && /at (\w+)/.exec(frames[2])) { 102 | meta = ` ${m[1]} → ${stdoutStyle.italic("f")}` 103 | } 104 | } 105 | } 106 | 107 | evalFunctionInArgs(v) 108 | 109 | if (v.length == 0 || (v.length == 1 && (v[0] === "" || v[0] === undefined))) { 110 | // Nothing to be logged. 111 | // This is sometimes useful when logging something complex conditionally, for example: 112 | // log.debug(() => { 113 | // if (expensiveComputation()) { 114 | // return "redirecting foobar to fuzlol" 115 | // } 116 | // }) 117 | return 118 | } 119 | 120 | log_console.log(stdoutStyle.bold(stdoutStyle.blue(`[DEBUG${meta}]`)), ...v) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/termstyle.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TermStyle as TermStyleAPI, 3 | TermStyleFun, 4 | TTYStream, 5 | NoTTYStream, 6 | } from "../estrella" 7 | 8 | 9 | export interface TermStyle extends TermStyleAPI { 10 | _hint :boolean|undefined // original hint 11 | 12 | // Like calling termStyle but instead of returning a new TermStyle object, 13 | // the receiver (this) is updated/mutated. 14 | reconfigure(w :TTYStream|NoTTYStream, hint? :boolean) :TermStyle 15 | } 16 | 17 | 18 | function numColors(w :TTYStream|NoTTYStream, hint? :boolean) { 19 | let ncolors = 0 20 | if (hint === true) { 21 | // use colors regardless of TTY or not 22 | let t = process.env.TERM || "" 23 | ncolors = ( 24 | t && ['xterm','screen','vt100'].some(s => t.indexOf(s) != -1) ? ( 25 | t.indexOf('256color') != -1 ? 8 : 4 26 | ) : 2 27 | ) 28 | } else if (hint !== false && w.isTTY) { 29 | // unless hint is explicitly false, use colors if stdout is a TTY 30 | ncolors = w.getColorDepth() 31 | } 32 | return ncolors 33 | } 34 | 35 | type TermStyleFunCons = (open16 :string, open256 :string, close :string) => TermStyleFun 36 | 37 | 38 | export function termStyle(w :TTYStream|NoTTYStream, hint? :boolean) :TermStyle { 39 | return createTermStyle(numColors(w, hint), hint) 40 | } 41 | 42 | 43 | export function createTermStyle(ncolors :number, hint? :boolean) :TermStyle { 44 | const CODE = (s :string) => `\x1b[${s}m` 45 | 46 | const effect :(open :string, close :string)=>TermStyleFun = ( 47 | ncolors > 0 || hint ? (open, close) => { 48 | const a = CODE(open), b = CODE(close) 49 | return s => a + s + b 50 | } : 51 | (_) => s => s 52 | ) 53 | 54 | const color :TermStyleFunCons = ( 55 | 56 | // 256 colors support 57 | ncolors >= 8 ? (_open16, open256, close) => { 58 | // const open = CODE(code), close = CODE('2' + code) 59 | let a = '\x1b[' + open256 + 'm', b = '\x1b[' + close + 'm' 60 | return s => a + s + b 61 | } : 62 | 63 | // 16 colors support 64 | ncolors > 0 ? (open16, _open256, close) => { 65 | let a = '\x1b[' + open16 + 'm', b = '\x1b[' + close + 'm' 66 | return s => a + s + b 67 | } : 68 | 69 | // no colors 70 | (_open16, _open256, _close) => s => s 71 | ) 72 | 73 | return { 74 | _hint: hint, 75 | ncolors, 76 | 77 | reset : hint || ncolors > 0 ? "\e[0m" : "", 78 | 79 | bold : effect('1', '22'), 80 | italic : effect('3', '23'), 81 | underline : effect('4', '24'), 82 | inverse : effect('7', '27'), 83 | 84 | // name 16c 256c close 85 | white : color('37', '38;2;255;255;255', '39'), 86 | grey : color('90', '38;5;244', '39'), 87 | black : color('30', '38;5;16', '39'), 88 | blue : color('34', '38;5;75', '39'), 89 | cyan : color('36', '38;5;87', '39'), 90 | green : color('32', '38;5;84', '39'), 91 | magenta : color('35', '38;5;213', '39'), 92 | purple : color('35', '38;5;141', '39'), 93 | pink : color('35', '38;5;211', '39'), 94 | red : color('31', '38;2;255;110;80', '39'), 95 | yellow : color('33', '38;5;227', '39'), 96 | lightyellow : color('93', '38;5;229', '39'), 97 | orange : color('33', '38;5;215', '39'), 98 | 99 | reconfigure(w :TTYStream|NoTTYStream, hint? :boolean) :TermStyle { 100 | const ncolors = numColors(w, hint) 101 | if (ncolors != this.ncolors && hint != this._hint) { 102 | Object.assign(this, createTermStyle(ncolors, hint)) 103 | } 104 | return this 105 | }, 106 | 107 | } 108 | } 109 | 110 | export const stdoutStyle = termStyle(process.stdout) 111 | export const stderrStyle = termStyle(process.stderr) 112 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as filepath from "path" 2 | import { sha1 } from "./hash" 3 | import { isCLI } from "./util" 4 | import { 5 | BuildConfig as UserBuildConfig, 6 | BuildContext as UserBuildContext, 7 | } from "../estrella.d" 8 | 9 | 10 | export interface BuildContext extends UserBuildContext { 11 | addCancelCallback(f :()=>void) :void 12 | } 13 | 14 | 15 | export interface BuildConfig extends UserBuildConfig { 16 | cwd :string // never undefined 17 | 18 | // unique but stable ID of the build, used for temp files and caching 19 | readonly projectID :string 20 | 21 | // absolute path to outfile (empty if outfile is empty) 22 | readonly outfileAbs :string 23 | 24 | setOutfile(outfile :string) :void 25 | 26 | // Computes projectID based on current configuration and updates value of this.projectID. 27 | // Depends on the following config properties: 28 | // - cwd 29 | // - outfile 30 | // - entryPoints 31 | // 32 | updateProjectID() :string 33 | 34 | // true if the build is cancelled (BuildProcess.cancel() was called) 35 | buildIsCancelled :boolean 36 | 37 | // true if outfile is a temporary file 38 | outfileIsTemporary :boolean 39 | 40 | // if true, copy outfile to stdout when it has changes 41 | outfileCopyToStdout :boolean 42 | 43 | // true if metafile is a temporary file 44 | metafileIsTemporary :boolean 45 | } 46 | 47 | // entryPointsMapToList {"out1.js":"in1.js","b.js":"b.js"} => ["out1.js:in1.js","b.js:b.js"] 48 | function entryPointsMapToList(entryPointsMap :Record) :string[] { 49 | return Object.keys(entryPointsMap).map(k => k + ":" + entryPointsMap[k]) 50 | } 51 | 52 | export function createBuildConfig(userConfig :UserBuildConfig, defaultCwd :string) :BuildConfig { 53 | let buildIsCancelled = false 54 | let outfileIsTemporary = false 55 | let outfileCopyToStdout = false 56 | let metafileIsTemporary = false 57 | let outfileAbs = "" 58 | 59 | function computeProjectID(config :UserBuildConfig) :string { 60 | const projectKey = [config.cwd, config.outfile||"", ...( 61 | Array.isArray(config.entryPoints) ? config.entryPoints : 62 | typeof config.entryPoints == "object" ? entryPointsMapToList(config.entryPoints) : 63 | config.entryPoints ? [config.entryPoints] : 64 | [] 65 | )].join(filepath.delimiter) 66 | return base36EncodeBuf(sha1(Buffer.from(projectKey, "utf8"))) 67 | } 68 | 69 | let projectID = "" 70 | 71 | const config :BuildConfig = Object.create({ 72 | get outfileAbs() :string { return outfileAbs }, 73 | 74 | setOutfile(outfile :string) :void { 75 | config.outfile = outfile 76 | outfileAbs = ( 77 | outfile && outfile != "-" ? filepath.resolve(config.cwd, outfile) : 78 | "" 79 | ) 80 | }, 81 | 82 | get projectID() :string { return projectID }, 83 | 84 | updateProjectID() :string { 85 | projectID = computeProjectID(config) 86 | return projectID 87 | }, 88 | 89 | get buildIsCancelled() :boolean { return buildIsCancelled }, 90 | set buildIsCancelled(y :boolean) { buildIsCancelled = y }, 91 | 92 | get outfileIsTemporary() :boolean { return outfileIsTemporary }, 93 | set outfileIsTemporary(y :boolean) { outfileIsTemporary = y }, 94 | 95 | get outfileCopyToStdout() :boolean { return outfileCopyToStdout }, 96 | set outfileCopyToStdout(y :boolean) { outfileCopyToStdout = y }, 97 | 98 | get metafileIsTemporary() :boolean { return metafileIsTemporary }, 99 | set metafileIsTemporary(y :boolean) { metafileIsTemporary = y }, 100 | }) 101 | 102 | Object.assign(config, userConfig) 103 | 104 | config.cwd = ( 105 | userConfig.cwd ? filepath.resolve(userConfig.cwd) : 106 | (!isCLI && process.mainModule) ? process.mainModule.path : 107 | defaultCwd 108 | ) 109 | config.setOutfile(userConfig.outfile || "") 110 | config.updateProjectID() 111 | 112 | return config 113 | } 114 | 115 | 116 | function base36EncodeBuf(buf :Buffer) { 117 | let s = "" 118 | for (let i = 0; i < buf.length; i += 4) { 119 | s += buf.readUInt32LE(i).toString(36) 120 | } 121 | return s 122 | } 123 | -------------------------------------------------------------------------------- /src/tsutil.ts: -------------------------------------------------------------------------------- 1 | import * as Path from "path" 2 | import * as fs from "fs" 3 | import { CompilerOptions } from "typescript" 4 | 5 | import { jsonparseFile, isWindows } from "./util" 6 | import { BuildConfig as BuildConfigPub } from "../estrella.d" 7 | import log from "./log" 8 | 9 | const TS_CONFIG_FILE = Symbol("TS_CONFIG_FILE") 10 | const TS_CONFIG = Symbol("TS_CONFIG") 11 | 12 | type BuildConfig = BuildConfigPub & { 13 | [TS_CONFIG]? :CompilerOptions|null 14 | [TS_CONFIG_FILE]? :string|null 15 | } 16 | 17 | const { dirname, basename } = Path 18 | 19 | 20 | export function findTSC(cwd :string) :string { 21 | let npmPath = "" 22 | let tmpcwd = process.cwd() 23 | const exe = isWindows ? "tsc.cmd" : "tsc" 24 | if (cwd) { 25 | process.chdir(cwd) 26 | } 27 | try { 28 | npmPath = require.resolve("typescript") 29 | } catch (_) {} 30 | if (cwd) { 31 | process.chdir(tmpcwd) 32 | } 33 | if (npmPath) { 34 | const find = Path.sep + "node_modules" + Path.sep 35 | let i = npmPath.indexOf(find) 36 | if (i != -1) { 37 | return Path.join(npmPath.substr(0, i + find.length - Path.sep.length), ".bin", exe) 38 | } 39 | } 40 | // not found in node_modules 41 | return exe 42 | } 43 | 44 | 45 | export function findTSConfigFile(dir :string, maxParentDir? :string) :string|null { 46 | for (let path of searchTSConfigFile(dir, maxParentDir)) { 47 | try { 48 | const st = fs.statSync(path) 49 | if (st.isFile()) { 50 | return path 51 | } 52 | } catch(_) {} 53 | } 54 | return null 55 | } 56 | 57 | 58 | export function* searchTSConfigFile(dir :string, maxParentDir? :string) :Generator { 59 | // start at dir and search for dir + tsconfig.json, 60 | // moving to the parent dir until found or until parent dir is the root dir. 61 | // If maxParentDir is set, then stop when reaching directory maxParentDir. 62 | dir = Path.resolve(dir) 63 | const root = Path.parse(dir).root 64 | maxParentDir = maxParentDir ? Path.resolve(maxParentDir) : root 65 | while (true) { 66 | yield Path.join(dir, "tsconfig.json") 67 | if (dir == maxParentDir) { 68 | // stop. this was the last dir we were asked to search 69 | break 70 | } 71 | dir = dirname(dir) 72 | if (dir == root) { 73 | // don't search "/" 74 | break 75 | } 76 | } 77 | } 78 | 79 | 80 | export function tsConfigFileSearchDirForConfig(config :BuildConfig) :string { 81 | let dir = config.cwd || process.cwd() 82 | if (config.entryPoints && Object.keys(config.entryPoints).length > 0) { 83 | // TODO: pick the most specific common denominator dir path of all entryPoints 84 | let firstEntryPoint = "" 85 | if (Array.isArray(config.entryPoints)) { 86 | firstEntryPoint = config.entryPoints[0] 87 | } else { // entryPoints is an object {outfile:infile} 88 | for (let outfile of Object.keys(config.entryPoints)) { 89 | firstEntryPoint = config.entryPoints[outfile] 90 | break 91 | } 92 | } 93 | dir = Path.resolve(dir, Path.dirname(firstEntryPoint)) 94 | } 95 | return dir 96 | } 97 | 98 | 99 | export function getTSConfigFileForConfig(config :BuildConfig) :string|null { 100 | let file = config[TS_CONFIG_FILE] 101 | if (file === undefined) { 102 | if ( 103 | config.tslint === "off" || config.tslint === false || 104 | config.tsc === "off" || config.tsc === false 105 | ) { 106 | file = null 107 | } else { 108 | let dir = tsConfigFileSearchDirForConfig(config) 109 | file = findTSConfigFile(dir, config.cwd) 110 | } 111 | Object.defineProperty(config, TS_CONFIG_FILE, { value: file }) 112 | } 113 | return file 114 | } 115 | 116 | 117 | export function getTSConfigForConfig(config :BuildConfig) :CompilerOptions|null { 118 | let tsconfig = config[TS_CONFIG] 119 | if (tsconfig === undefined) { 120 | const file = getTSConfigFileForConfig(config) 121 | if (file) try { 122 | tsconfig = jsonparseFile(file) 123 | } catch(err) { 124 | log.warn(()=> `failed to parse ${file}: ${err.stack||err}`) 125 | } 126 | if (!tsconfig) { 127 | tsconfig = null 128 | } 129 | Object.defineProperty(config, TS_CONFIG, { value: tsconfig }) 130 | } 131 | return tsconfig 132 | } 133 | 134 | -------------------------------------------------------------------------------- /test/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # usage: test.sh [-debug] [ ...] 4 | # 5 | # environment variables: 6 | # ESTRELLA_TEST_VERBOSE 7 | # If set to any value, some tests will log details 8 | # 9 | cd "$(dirname "$0")/.." 10 | set -e 11 | 12 | DEBUG=false 13 | ESTRELLA_BUILD_ARGS=() 14 | ESTRELLA_PROG=estrella.js 15 | if [ "$1" == "-debug" ]; then 16 | shift 17 | DEBUG=true 18 | ESTRELLA_PROG=estrella.g.js 19 | ESTRELLA_BUILD_ARGS+=( -estrella-debug ) 20 | fi 21 | export ESTRELLA_PROGAM=$PWD/dist/$ESTRELLA_PROG 22 | 23 | 24 | # first build estrella 25 | BUILD_OK=false 26 | if $DEBUG; then 27 | echo "Building estrella in debug mode" 28 | if ./build.js -g ; then 29 | BUILD_OK=true 30 | fi 31 | else 32 | echo "Building estrella in release mode" 33 | if ./build.js ; then 34 | BUILD_OK=true 35 | fi 36 | fi 37 | if ! $BUILD_OK; then 38 | echo "building with dist/estrella.js failed." 39 | echo "Attempt rescue build?" 40 | echo " y = attempt resuce build" 41 | echo " r = revert to last git version of dist/estrella.js" 42 | echo " * = cancel & exit" 43 | echo -n "[Y/r/n] " 44 | read ANSWER 45 | if [[ "$ANSWER" == "" ]] || [[ "$ANSWER" == "y"* ]]; then 46 | echo "Running bash misc/rescue.sh" 47 | bash misc/rescue.sh 48 | elif [[ "$ANSWER" == "r"* ]]; then 49 | echo "Running git checkout -- dist/estrella.js dist/estrella.g.js" 50 | git checkout -- dist/estrella.js dist/estrella.g.js 51 | else 52 | exit 1 53 | fi 54 | echo "Retrying with new build and argument -estrella-debug" 55 | if $DEBUG; then 56 | ./build.js -g -estrella-debug 57 | else 58 | ./build.js -estrella-debug 59 | fi 60 | fi 61 | 62 | 63 | fn_test_example() { 64 | local d=$1 65 | echo "———————————————————————————————————————————————————————————————————————" 66 | echo "$d" 67 | if [ -f "$d/NO_TEST" ]; then 68 | echo "SKIP (found a NO_TEST file)" 69 | return 0 70 | fi 71 | pushd "$d" >/dev/null 72 | 73 | # link local debug version of estrella into node_modules 74 | rm -rf node_modules 75 | if [ -f package.json ]; then 76 | npm install >/dev/null 2>&1 77 | fi 78 | mkdir -p node_modules 79 | rm -rf node_modules/estrella 80 | pushd node_modules >/dev/null 81 | ln -s ../../../dist/$ESTRELLA_PROG estrella 82 | popd >/dev/null 83 | 84 | rm -rf out 85 | 86 | # if there's a test.sh script, run that, else build & run 87 | if [ -f test.sh ]; then 88 | if ! bash test.sh; then 89 | echo "FAIL $PWD/test.sh ${ESTRELLA_BUILD_ARGS[@]}" >&2 90 | return 1 91 | fi 92 | else 93 | # build example, assuming ./out is the product output directory 94 | if ! ./build.js "${ESTRELLA_BUILD_ARGS[@]}"; then 95 | echo "FAIL $PWD/build.js ${ESTRELLA_BUILD_ARGS[@]}" >&2 96 | return 1 97 | fi 98 | 99 | # assume first js file in out/*.js is the build product 100 | for f in out/*.js; do 101 | if [ -f "$f" ]; then 102 | if ! node "$f"; then 103 | echo "FAIL node $PWD/$f" >&2 104 | return 1 105 | fi 106 | fi 107 | break 108 | done 109 | # # extract outfile from build script 110 | # outfile=$(node -p 'const m = /\boutfile:\s*("[^"]+"|'"'"'[^\'"'"']+\'"'"')/.exec(require("fs").readFileSync("build.js", "utf8")) ; (m ? JSON.parse(m[1] || m[1]) : "")') 111 | # if [ "$outfile" != "" ]; then 112 | # node "$outfile" 113 | # else 114 | # echo "Can not find outfile for example $PWD" >&2 115 | # fi 116 | fi 117 | 118 | echo "PASS" 119 | popd >/dev/null 120 | } 121 | 122 | # ^C to stop rather than break current loop 123 | trap exit SIGINT 124 | 125 | if [ $# -gt 0 ]; then 126 | # only run tests provided as dirnames to argv 127 | for d in "$@"; do 128 | if ! [ -d "$d" ]; then 129 | echo "$0: '$d' is not a directory" >&2 130 | exit 1 131 | fi 132 | if [[ "$d" == "examples/"* ]]; then 133 | fn_test_example "$d" 134 | else 135 | echo "———————————————————————————————————————————————————————————————————————" 136 | echo "$d" 137 | "$d/test.sh" 138 | fi 139 | done 140 | else 141 | # run all tests and examples 142 | for d in test/*; do 143 | if [ -d "$d" ] && [[ "$d" != "."* ]]; then 144 | echo "———————————————————————————————————————————————————————————————————————" 145 | echo "$d" 146 | if [ -f "$d/test.sh" ]; then 147 | if "$d/test.sh"; then 148 | echo "PASS" 149 | else 150 | echo "FAIL $d" >&2 151 | exit 1 152 | fi 153 | else 154 | echo "SKIP (no test.sh file)" 155 | fi 156 | fi 157 | done 158 | for d in examples/*; do 159 | if [ -d "$d" ] && [[ "$d" != "."* ]]; then 160 | fn_test_example "$d" 161 | fi 162 | done 163 | fi 164 | 165 | echo "ALL PASS OK" 166 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import * as Path from "path" 3 | import * as os from "os" 4 | import { performance } from "perf_hooks" 5 | import { stdoutStyle } from "./termstyle" 6 | import { inspect } from "util" 7 | 8 | export const json = (val, pretty, showHidden) => JSON.stringify(val, showHidden, pretty) 9 | export const clock = () => performance.now() 10 | 11 | // running on Windows? 12 | export const isWindows = process.platform.startsWith("win") 13 | 14 | // generic symbols 15 | export const TYPE = Symbol("TYPE") 16 | 17 | // runtimeRequire(id :string) :any 18 | export function runtimeRequire(id) { 19 | // _runtimeRequire is defined at compile time by build.js (== require) 20 | try { return _runtimeRequire(id) } catch { return null } 21 | } 22 | runtimeRequire.resolve = id => { 23 | try { return _runtimeRequire.resolve(id) } catch { return "" } 24 | } 25 | 26 | // isCLI is true if estrella is invoked directly and not imported as a module 27 | export const isCLI = module.id == "." || process.mainModule.filename == __filename 28 | 29 | 30 | export function repr(val, prettyOrOptions) { 31 | let options = { 32 | colors: stdoutStyle.ncolors > 0, 33 | } 34 | if (typeof prettyOrOptions == "object") { 35 | options = { ...prettyOrOptions } 36 | } else if (prettyOrOptions !== undefined) { 37 | options.compact = !prettyOrOptions 38 | } 39 | return inspect(val, options) 40 | } 41 | 42 | 43 | export function resolveModulePackageFile(moduleSpec) { 44 | const mainfile = runtimeRequire.resolve(moduleSpec) 45 | let dir = Path.dirname(Path.resolve(mainfile)) 46 | let lastdir = Path.sep // lastdir approach to support Windows (not just check for "/") 47 | while (dir != lastdir) { 48 | let pfile = Path.join(dir, "package.json") 49 | if (fs.existsSync(pfile)) { 50 | return pfile 51 | } 52 | dir = Path.dirname(dir) 53 | } 54 | throw new Error(`package.json not found for module ${moduleSpec}`) 55 | } 56 | 57 | 58 | export function getModulePackageJSON(moduleSpec) { 59 | const pfile = resolveModulePackageFile(moduleSpec) 60 | return jsonparseFile(pfile) 61 | } 62 | 63 | 64 | let _tmpdir = "" 65 | 66 | export function tmpdir() { 67 | if (!_tmpdir) { 68 | // Some systems return paths with symlinks. 69 | // esbuild does "realpath" on some pathnames and thus reporting with esbuild's metafile 70 | // may be incorrect if this is not canonical. 71 | _tmpdir = fs.realpathSync.native(os.tmpdir()) 72 | } 73 | return _tmpdir 74 | } 75 | 76 | 77 | export function fmtDuration(ms) { 78 | return ( 79 | ms >= 59500 ? (ms/60000).toFixed(0) + "min" : 80 | ms >= 999.5 ? (ms/1000).toFixed(1) + "s" : 81 | ms.toFixed(2) + "ms" 82 | ) 83 | } 84 | 85 | export function fmtByteSize(bytes) { 86 | return ( 87 | bytes >= 1024*1000 ? (bytes/(1024*1000)).toFixed(1) + "MB" : 88 | bytes >= 1000 ? (bytes/1024).toFixed(1) + "kB" : 89 | bytes + "B" 90 | ) 91 | } 92 | 93 | export function findInPATH(executableName) { 94 | const exeFileMode = isWindows ? 0xFFFFFFFF : fs.constants.X_OK 95 | const PATH = new Set((process.env.PATH || "").split(Path.delimiter)) 96 | 97 | for (let dir of PATH) { 98 | let path = Path.join(Path.resolve(dir), executableName) 99 | if (isWindows) { 100 | path += ".cmd" 101 | } 102 | while (true) { 103 | try { 104 | let st = fs.statSync(path) 105 | if (st.isSymbolicLink()) { 106 | path = fs.realpathSync.native(path) 107 | continue // try again 108 | } else if (st.isFile() && (st.mode & exeFileMode)) { 109 | return path 110 | } 111 | } catch (_) { 112 | if (isWindows && path.endsWith(".cmd")) { 113 | path = Path.join(Path.resolve(dir), executableName) + ".exe" 114 | continue // try with .exe extension 115 | } 116 | } 117 | break 118 | } 119 | } 120 | return null 121 | } 122 | 123 | 124 | // jsonparse parses "relaxed" JSON which can be in JavaScript format 125 | export function jsonparse(jsonText, filename /*optional*/) { 126 | try { 127 | return JSON.parse(json) 128 | } catch (err) { 129 | return require("vm").runInNewContext( 130 | '(' + jsonText + ')', 131 | { /* sandbox */ }, 132 | { filename, displayErrors: true } 133 | ) 134 | } 135 | } 136 | 137 | export function jsonparseFile(filename) { 138 | const json = fs.readFileSync(filename, "utf8") 139 | try { 140 | return jsonparse(json) 141 | } catch (err) { 142 | throw new Error(`failed to parse ${filename}: ${err.message || err}`) 143 | } 144 | } 145 | 146 | 147 | // ~/hello => /home/user/hello 148 | export function expandTildePath(path) { 149 | const homedir = os.homedir() 150 | if (path == "~") { 151 | return homedir 152 | } 153 | if (path.startsWith("~" + Path.sep)) { 154 | return homedir + path.substr(1) 155 | } 156 | return path 157 | } 158 | 159 | // /home/user/hello => ~/hello 160 | export function tildePath(path) { 161 | const s = Path.resolve(path) 162 | const homedir = os.homedir() 163 | if (s.startsWith(homedir)) { 164 | return "~" + s.substr(homedir.length) 165 | } 166 | return s 167 | } 168 | -------------------------------------------------------------------------------- /test/npm/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | # 3 | # This test... 4 | # 1. Packages the estrella npm module (using npm pack => ARCHIVE_FILE) 5 | # 2. Creates a temporary directory outside the estrella src dir 6 | # 3. Creates a new npm project and installs estrella (using npm install ARCHIVE_FILE) 7 | # 4. Generates some source code an run a bunch of tests 8 | # 9 | # This verifies that the npm distribution of estrella works as intended. 10 | # 11 | cd "$(dirname "$0")" 12 | 13 | KEEP_TEMP_DIR=false 14 | VERBOSE=false 15 | 16 | export ESTRELLA_TEST_VERBOSE=$ESTRELLA_TEST_VERBOSE 17 | if [ "$1" == "-verbose" ]; then 18 | export ESTRELLA_TEST_VERBOSE=1 19 | fi 20 | if [[ "$ESTRELLA_TEST_VERBOSE" != "" ]]; then 21 | VERBOSE=true 22 | fi 23 | 24 | while [[ $# -gt 0 ]]; do 25 | case "$1" in 26 | -keep-tempdir|--keep-tempdir) 27 | KEEP_TEMP_DIR=true ; shift 28 | ;; 29 | -verbose|--verbose) 30 | VERBOSE=true ; shift 31 | ;; 32 | -o|-output|--output) 33 | if [[ "$2" == "-"* ]]; then 34 | echo "Missing value for option $1" >&2 35 | _usage 36 | fi 37 | OUTFILE=$2 38 | shift ; shift 39 | ;; 40 | -h|-help|--help) 41 | echo "usage: $0 [options]" 42 | echo "options:" 43 | echo "-keep-tempdir Don't remove the temporary working directory after finishing." 44 | echo "-verbose Verbose logging." 45 | echo "-h, -help Print help to stdout and exit." 46 | exit 0 47 | ;; 48 | *) 49 | echo "$0: Unknown command or option $1 (see $0 -help)" >&2 50 | exit 1 51 | ;; 52 | esac 53 | done 54 | 55 | 56 | function fail { 57 | msg=$1 ; shift 58 | echo "FAIL $msg" >&2 59 | for line in "$@"; do 60 | echo "$line" >&2 61 | done 62 | exit 1 63 | } 64 | 65 | function assertEq { 66 | actual=$1 67 | expect=$2 68 | message=$3 69 | if [[ "$actual" != "$expect" ]]; then 70 | if [ -z "$message" ]; then 71 | message="Assertion error" 72 | fi 73 | fail "$message; expected result:" \ 74 | "'$expect'" \ 75 | "-----------------------------------------" \ 76 | "actual result:" \ 77 | "'$actual'" \ 78 | "-----------------------------------------" 79 | fi 80 | } 81 | 82 | rmfilesAtExit=() 83 | pidsToSigtermAtExit=() 84 | function _atexit { 85 | for f in "${rmfilesAtExit[@]}"; do 86 | # echo "rm -rf '$f'" 87 | rm -rf "$f" 88 | done 89 | for pid in "${pidsToSigtermAtExit[@]}"; do 90 | # echo "kill -s TERM $pid" 91 | kill -s TERM "$pid" 92 | done 93 | } 94 | trap _atexit EXIT 95 | trap exit SIGINT # make sure we can ctrl-c in loops 96 | 97 | pushd ../.. >/dev/null 98 | ESTRELLA_ROOTDIR=$PWD 99 | popd >/dev/null 100 | 101 | echo "$ npm pack '$ESTRELLA_ROOTDIR'" 102 | PACKAGE_ARCHIVE_FILE=$PWD/$(npm --quiet pack "$ESTRELLA_ROOTDIR") 103 | echo "$PACKAGE_ARCHIVE_FILE" 104 | rmfilesAtExit+=( "$PACKAGE_ARCHIVE_FILE" ) 105 | # Note: tar -xzf estrella-1.2.5.tgz # => "package" dir 106 | 107 | TEMP_DIR=$(realpath "$TMPDIR")/estrella-test-npm 108 | rm -rf "$TEMP_DIR" 109 | mkdir -p "$TEMP_DIR" 110 | echo "using tempdir $TEMP_DIR" 111 | if ! $KEEP_TEMP_DIR; then 112 | rmfilesAtExit+=( "$TEMP_DIR" ) 113 | fi 114 | pushd "$TEMP_DIR" >/dev/null 115 | 116 | # Rather than 'npm init --yes' write package.json manually so we can include 117 | # fields which npm install otherwise complains about to stderr. 118 | cat <<_JSON_ > package.json 119 | { 120 | "name": "estrella-test-npm", 121 | "version": "1.0.0", 122 | "description": "test", 123 | "repository": "https://example.com", 124 | "main": "main.js", 125 | "keywords": [], 126 | "author": "estrella", 127 | "license": "ISC" 128 | } 129 | _JSON_ 130 | 131 | echo "$ npm install -D '$PACKAGE_ARCHIVE_FILE'" 132 | if $VERBOSE; then 133 | npm install -D "$PACKAGE_ARCHIVE_FILE" 134 | else 135 | npm --quiet install -D "$PACKAGE_ARCHIVE_FILE" >/dev/null 136 | fi 137 | 138 | cat <<_JS_ > build.js 139 | const { build } = require("estrella") 140 | build({ 141 | entry: "main.ts", 142 | outfile: "main.js", 143 | }) 144 | _JS_ 145 | 146 | cat <<_JS_ > main.ts 147 | console.log("hello world") 148 | _JS_ 149 | 150 | echo "$ node build.js" 151 | node build.js 152 | 153 | echo "$ node main.js" 154 | assertEq "$(node main.js)" "hello world" 155 | 156 | 157 | # test watch, which loads a separate module 158 | echo "console.log('hello1')" > main.ts 159 | echo "$ node build.js -w -no-clear" 160 | node build.js -w -no-clear & 161 | watch_pid=$! 162 | pidsToSigtermAtExit+=( $watch_pid ) 163 | 164 | sleep 0.5 165 | echo "$ node main.js" 166 | assertEq "$(node main.js)" "hello1" 167 | 168 | echo "console.log('hello2')" > main.ts 169 | sleep 0.5 170 | echo "$ node main.js" 171 | assertEq "$(node main.js)" "hello2" 172 | 173 | 174 | # test run 175 | echo "$ node build.js -quiet -run" 176 | assertEq "$(node build.js -quiet -run)" "hello2" 177 | 178 | 179 | # test typescript 180 | echo "$ npm install -D 'typescript'" 181 | if $VERBOSE; then 182 | npm install -D "typescript" 183 | else 184 | npm --quiet install -D "typescript" >/dev/null 185 | fi 186 | cat <<_JSON_ > tsconfig.json 187 | { 188 | "compilerOptions": { 189 | "noUnusedLocals": true, 190 | "target": "es2017", 191 | }, 192 | "files": [ "main.ts" ] 193 | } 194 | _JSON_ 195 | 196 | 197 | # typecript should detect & warn about unused variable 198 | echo "function a() { let unused = 1 } a()" > main.ts 199 | echo "$ node build.js -diag" 200 | output=$(node build.js -diag) 201 | if [[ "$output" != *"TS6133:"* ]]; then 202 | fail "expected *TS6133:* but got:" \ 203 | "$output" 204 | fi 205 | 206 | 207 | echo "OK" 208 | -------------------------------------------------------------------------------- /src/watch/watch.ts: -------------------------------------------------------------------------------- 1 | import * as filepath from "path" 2 | import { Metafile as ESBuildMetafile } from "esbuild" 3 | import { WatchOptions, WatchCallback, CancellablePromise, FileEvent } from "../../estrella.d" 4 | import { BuildConfig, BuildContext } from "../config" 5 | import * as _file from "../file" 6 | import { log, LogLevel } from "../log" 7 | import { repr } from "../util" 8 | 9 | import { FSWatcher, FSWatcherOptions } from "./fswatch" 10 | 11 | 12 | type FileModule = typeof _file 13 | let file :FileModule 14 | 15 | 16 | export function initModule(logLevel :LogLevel, filem :FileModule) { 17 | log.level = logLevel 18 | file = filem 19 | } 20 | 21 | function makeFSWatcherOptions(options :WatchOptions) :FSWatcherOptions { 22 | return { 23 | ...options, 24 | isChangeSelfOriginating(filename :string) :boolean { 25 | return file.fileWasModifiedRecentlyByUser(filename) 26 | }, 27 | } 28 | } 29 | 30 | 31 | let fswatcherMap = new Map() // projectID => FSWatcher 32 | 33 | 34 | // used by estrella itself, when config.watch is enabled 35 | export async function watchFiles( 36 | config :BuildConfig, 37 | getESBuildMeta :()=>ESBuildMetafile|null, 38 | ctx :BuildContext, 39 | callback :(changes :FileEvent[]) => Promise, 40 | ) :Promise { 41 | const projectID = config.projectID 42 | let fswatcher = fswatcherMap.get(projectID) 43 | 44 | if (!fswatcher) { 45 | const watchOptions = config.watch && typeof config.watch == "object" ? config.watch : {} 46 | fswatcher = new FSWatcher(makeFSWatcherOptions(watchOptions)) 47 | fswatcherMap.set(projectID, fswatcher) 48 | fswatcher.basedir = config.cwd || process.cwd() 49 | fswatcher.onChange = (changes) => { 50 | // invoke the callback, which in turn rebuilds the project and writes a fresh 51 | // esbuild metafile which we then read in refreshFiles. 52 | callback(changes).then(refreshFiles) 53 | } 54 | ctx.addCancelCallback(() => { 55 | fswatcher!.promise.cancel() 56 | }) 57 | log.debug(`fswatch started for project#${projectID}`) 58 | } 59 | 60 | function refreshFiles() { 61 | // Read metadata produced by esbuild, describing source files and product files. 62 | // The metadata may be null or have a missing inputs prop in case esbuild failed. 63 | const esbuildMeta = getESBuildMeta() 64 | log.debug("fswatch refreshFiles with esbuildMeta", esbuildMeta) 65 | if (!esbuildMeta || !esbuildMeta.inputs) { 66 | // esbuild failed -- don't change what files are being watched 67 | return 68 | } 69 | 70 | // vars 71 | const srcfiles = Object.keys(esbuildMeta.inputs) // {[filename:string]:{}} => string[] 72 | , outfiles = esbuildMeta.outputs || {} // {[filename:string]:{}} 73 | 74 | if (srcfiles.length == 0) { 75 | // esbuild failed -- don't change what files are being watched 76 | return 77 | } 78 | 79 | // path substrings for filtering out nodejs files 80 | const nodeModulesPathPrefix = "node_modules" + filepath.sep 81 | const nodeModulesPathSubstr = filepath.sep + nodeModulesPathPrefix 82 | const isNodeModuleFile = (fn :string) => { 83 | return fn.startsWith(nodeModulesPathPrefix) || fn.includes(nodeModulesPathSubstr) 84 | } 85 | 86 | // log 87 | if (log.level >= log.DEBUG) { 88 | const xs = srcfiles.filter(fn => !isNodeModuleFile(fn)).slice(0,10) 89 | log.debug( 90 | `fswatch updating source files: esbuild reported` + 91 | ` ${srcfiles.length} inputs:` + 92 | xs.map(fn => `\n ${fn}`).join("") + 93 | (xs.length < srcfiles.length ? `\n ... ${srcfiles.length-xs.length} more` : "") 94 | ) 95 | } 96 | 97 | // append output files to self-originating mod log 98 | for (let fn of Object.keys(outfiles)) { 99 | file.fileModificationLogAppend(fn) 100 | } 101 | 102 | // create list of source files 103 | const sourceFiles = [] 104 | for (let fn of srcfiles) { 105 | // exclude output files to avoid a loop 106 | if (fn in outfiles) { 107 | continue 108 | } 109 | 110 | // exclude files from libraries. Some projects may include hundreds or thousands of library 111 | // files which would slow things down unncessarily. 112 | if (srcfiles.length > 100 && isNodeModuleFile(fn)) { // "/node_modules/" 113 | continue 114 | } 115 | sourceFiles.push(fn) 116 | } 117 | fswatcher!.setFiles(sourceFiles) 118 | } 119 | 120 | refreshFiles() 121 | 122 | return fswatcher.promise 123 | } 124 | 125 | 126 | // watch is a utility function exported in the estrella API 127 | export function watch( 128 | path :string|ReadonlyArray, 129 | cb :WatchCallback, 130 | ) :CancellablePromise 131 | 132 | export function watch( 133 | path :string|ReadonlyArray, 134 | options :WatchOptions|null|undefined, 135 | cb :WatchCallback, 136 | ) :CancellablePromise 137 | 138 | export function watch( 139 | path :string|ReadonlyArray, 140 | options :WatchOptions|null|undefined | WatchCallback, 141 | cb? :WatchCallback, 142 | ) :CancellablePromise { 143 | if (!cb) { // call form: watch(path, cb) 144 | cb = options as WatchCallback 145 | options = {} 146 | } 147 | 148 | const w = new FSWatcher(makeFSWatcherOptions({ 149 | // Defaults 150 | persistent: true, 151 | ignoreInitial: true, 152 | ignored: /(^|[\/\\])\../, // ignore dotfiles 153 | disableGlobbing: true, 154 | followSymlinks: false, 155 | 156 | // user override 157 | ...(options || {}) 158 | })) 159 | w.basedir = process.cwd() 160 | w.onChange = cb! 161 | w.setFiles(typeof path == "string" ? [path] : path) 162 | 163 | return w.promise 164 | } 165 | -------------------------------------------------------------------------------- /src/run.ts: -------------------------------------------------------------------------------- 1 | import * as filepath from "path" 2 | import * as fs from "fs" 3 | 4 | import { BuildResult } from "../estrella" 5 | import { BuildConfig } from "./config" 6 | import log from "./log" 7 | import { repr } from "./util" 8 | import { Cmd, startCmd } from "./exec" 9 | import { stdoutStyle } from "./termstyle" 10 | import * as io from "./io" 11 | import * as signal from "./signal" 12 | import { UserError } from "./error" 13 | 14 | 15 | let _initialized = false 16 | let _deinitialized = false 17 | let _runContexts = new Set() 18 | 19 | 20 | function init() { 21 | if (_initialized) { return } 22 | _initialized = true 23 | process.on("beforeExit", exitCode => atexit(DEBUG && `process.on beforeExit ${exitCode}`)) 24 | process.on("exit", exitCode => atexit(DEBUG && `process.on exit ${exitCode}`)) 25 | const onsignal = (sig: NodeJS.Signals) => atexit(DEBUG && `process.on signal ${sig}`) 26 | signal.addListener("SIGINT", onsignal) 27 | signal.addListener("SIGHUP", onsignal) 28 | signal.addListener("SIGTERM", onsignal) 29 | signal.addListener("SIGPIPE", onsignal) 30 | } 31 | 32 | 33 | function atexit(cause :string|false) { 34 | if (_deinitialized) { return } 35 | _deinitialized = true 36 | 37 | // any log messages must be sync since process is about to terminate 38 | const logerr = (msg :string) => fs.writeSync((process.stderr as any).fd, msg + "\n") 39 | 40 | try { 41 | // log in debug mode 42 | if (DEBUG) { 43 | let runningCount = 0 44 | for (let ctx of _runContexts) { 45 | if (ctx.cmd.running) { 46 | runningCount++ 47 | } 48 | } 49 | if (runningCount > 0) { 50 | logerr(`[DEBUG run.atexit] run.atexit (${cause})`) 51 | } 52 | } 53 | 54 | // Send SIGTERM to any running processes. 55 | // It's better to send SIGTERM than SIGKILL in this case since in almost all scenarios 56 | // processes are well-behaved and won't ignore SIGTERM (forever.) On the flipside, sending 57 | // SIGKILL may cause some processes to miss out on important atexit code 58 | for (let ctx of _runContexts) { 59 | if (ctx.cmd.running) { 60 | DEBUG && logerr(`[DEBUG run.atexit] sending SIGTERM to ${ctx.cmd}`) 61 | try { 62 | ctx.cmd.signal("SIGTERM") 63 | } catch(_) {} 64 | } 65 | } 66 | 67 | _runContexts.clear() 68 | } catch (err) { 69 | logerr(`ignoring error in run.atexit: ${err.stack||err}`) 70 | } 71 | } 72 | 73 | 74 | // run.configure is called by build1 with a mutable copy of config. 75 | // If config.run is not falsy, this function sets up onStart and onEnd handlers on config 76 | // to manage execution of the build product. 77 | export function configure(config :BuildConfig) { 78 | if (!config.run) { 79 | return 80 | } 81 | 82 | log.debug(()=> `run.configure run=${repr(config.run)}`) 83 | 84 | const ctx = new RunContext(config) 85 | _runContexts.add(ctx) 86 | 87 | // const onStartNext = config.onStart 88 | // config.onStart = async (config, changedFiles, bctx) => { 89 | // if (typeof onStartNext == "function") { 90 | // await onStartNext(config, changedFiles, bctx) 91 | // } 92 | // return ctx.onStartBuild(changedFiles) 93 | // } 94 | 95 | const onEndNext = config.onEnd 96 | config.onEnd = async (config, buildResult, bctx) => { 97 | // make sure we run user's onEnd function before we spawn a process 98 | let returnValue = undefined 99 | if (typeof onEndNext == "function") { 100 | returnValue = onEndNext(config, buildResult, bctx) 101 | if (returnValue instanceof Promise) { 102 | returnValue = await returnValue 103 | } 104 | } 105 | await ctx.onEndBuild(buildResult) 106 | return returnValue 107 | } 108 | 109 | init() 110 | } 111 | 112 | 113 | // waitAll waits for all running commands to exit. 114 | // Returns the largest exit code (i.e. 0 if all processes exited cleanly.) 115 | export function waitAll() :Promise { 116 | return Promise.all( 117 | Array.from(_runContexts).map(ctx => ctx.cmd.promise) 118 | ).then(exitCodes => exitCodes.reduce((a,c) => Math.max(a,c), 0)) 119 | } 120 | 121 | 122 | class RunContext { 123 | readonly config :Readonly 124 | readonly cmd :Cmd 125 | readonly cmdname :string // shown in logs 126 | 127 | _logOnExit = true // state used by onEndBuild to decide if exit is logged or not 128 | 129 | constructor(config :Readonly) { 130 | this.config = config 131 | 132 | // Create a command object with stdout and stderr forwarding (/dev/null for stdin) 133 | this.cmd = new Cmd("") 134 | this.cmd.stdout = "inherit" 135 | this.cmd.stderr = "inherit" 136 | this.cmd.env["ESTRELLA_PATH"] = __filename 137 | this.cmd.env["ESTRELLA_VERSION"] = VERSION 138 | 139 | if (typeof config.run == "string") { 140 | this.cmd.command = config.run 141 | this.cmd.shell = true 142 | this.cmdname = config.run 143 | 144 | } else if (typeof config.run == "boolean") { 145 | if (!config.outfile) { 146 | throw new UserError(`please set config.outfile= or config.run=`) 147 | } 148 | this.cmd.command = process.execPath // node 149 | this.cmd.args = [ config.outfileAbs ] 150 | this.cmdname = config.outfile 151 | 152 | } else { 153 | if (!config.run || config.run.length == 0) { 154 | throw new UserError("config.run is an empty list") 155 | } 156 | this.cmd.command = config.run[0] 157 | this.cmd.args = config.run.slice(1) 158 | this.cmdname = config.run.join(" ") 159 | if (this.cmdname.length > 60) { 160 | this.cmdname = this.cmdname.substr(0,57) + "..." 161 | } 162 | } 163 | } 164 | 165 | async onEndBuild(buildResult :BuildResult) { 166 | if (buildResult.errors.length > 0) { 167 | // don't start or restart a process if the build failed 168 | return 169 | } 170 | 171 | // okay, let's start or restart this.cmd 172 | const cmd = this.cmd 173 | const style = stdoutStyle.pink 174 | 175 | // if the program is still running, stop it first 176 | const restart = cmd.running 177 | if (cmd.running) { 178 | this._logOnExit = false 179 | log.debug(() => `Stopping ${this.cmdname} [${cmd.pid}] ...`) 180 | await cmd.kill() 181 | } 182 | 183 | // start new process 184 | log.debug(() => `Starting command ${repr([cmd.command, ...cmd.args])}`) 185 | cmd.start() 186 | 187 | // log info about the process starting and existing in watch mode 188 | if (this.config.watch) { 189 | log.info(() => style(`${restart ? "Restarted" : "Running"} ${this.cmdname} [${cmd.pid}]`)) 190 | this._logOnExit = true 191 | cmd.promise.then(exitCode => { 192 | this._logOnExit && log.info(() => style(`${this.cmdname} exited (${exitCode})`)) 193 | }) 194 | } 195 | } 196 | } 197 | 198 | -------------------------------------------------------------------------------- /test/cmd/test-cmd.ts: -------------------------------------------------------------------------------- 1 | import { strictEqual as asserteq } from "assert" 2 | import * as os from "os" 3 | import * as Path from "path" 4 | const child_process = require("child_process") 5 | 6 | // NOTE: This test embeds source files from estrella. 7 | // 8 | // It's important to do the following three things: 9 | // 1. Import "global" first. This includes global things like assert and types. 10 | // 2. import { setEstrellaDir } from "extra" 11 | // 3. call setEstrellaDir with the absolute directory of estrella.js 12 | // 13 | import "../../src/global" 14 | import { setEstrellaDir } from "../../src/extra" 15 | import { startCmd, SignalMode, Signal } from "../../src/exec" 16 | import { repr } from "../../src/util" 17 | import { readlines } from "../../src/io" 18 | 19 | setEstrellaDir(__dirname + "/../../dist") 20 | process.chdir(__dirname) 21 | 22 | const verbose = !!parseInt(process.env["ESTRELLA_TEST_VERBOSE"]) 23 | const log = verbose ? console.log.bind(console) : ()=>{} 24 | function fail(...msg) { console.error("FAIL", ...msg) ; process.exit(1) } 25 | 26 | 27 | // this tests Cmd, running a subprocess which in turn runs its own subprocesses 28 | // 29 | async function test1(signalMode :SignalMode, numSubprocs: number) { 30 | const env = {...process.env, 31 | "IGNORE_SIGTERM": "true", 32 | "SPAWN_SUBPROCESSES": String(numSubprocs), 33 | } 34 | if (signalMode == "standard") { 35 | // ask the process to forward SIGTERM to subprocesses since we can't use pg signalling 36 | env["FORWARD_SIGTERM"] = "true" 37 | } 38 | 39 | const [cmd,{stdout}] = startCmd(process.execPath, ["program1.js"], { 40 | stdout: "pipe", 41 | stderr: "inherit", 42 | env, 43 | }) 44 | 45 | log(`startCmd ${repr(cmd.command)} ${repr(cmd.args)}`) 46 | 47 | // Note that order of output is non-deterministic beyond the message 48 | // "mainproc[PID] spawning N subprocesses" 49 | // Therefore we count and test instead of simply verifying a certain specific output. 50 | 51 | let mainprocStarted = false 52 | let subprocReadyCount = 0 53 | let subprocPIDs :number[] = [] 54 | let killPromise :Promise|null = null 55 | let mainprocRecvSIGTERM = 0 56 | let subprocRecvSIGTERMCount = 0 57 | let killTimer :any = null 58 | 59 | const finalSignal = "SIGINT" 60 | 61 | const signalAll = (signal :Signal) => { 62 | // kill (signal 9) since we disabled timeout in kill() 63 | cmd.signal(signal, signalMode) 64 | 65 | // send the signal manually in standard signalling mode 66 | if (signalMode == "standard") { 67 | for (let pid of subprocPIDs) { 68 | process.kill(pid, signal) 69 | } 70 | } 71 | } 72 | 73 | for await (const line of readlines(stdout, "utf8")) { 74 | log("stdout>>", line.trimEnd()) 75 | 76 | // step 1: main process confirmed as launched 77 | if (!mainprocStarted) { 78 | if (/mainproc\[\d+\] start/.test(line)) { 79 | mainprocStarted = true 80 | } 81 | } 82 | 83 | // step 2: sub-processes confirmed launched & waiting 84 | else if (subprocReadyCount < numSubprocs) { 85 | const m = line.match(/subproc\d+\[(\d+)\] waiting/) 86 | if (m) { 87 | // step 1: register subprocess as launched 88 | subprocPIDs.push(parseInt(m[1])) 89 | subprocReadyCount++ 90 | } 91 | if (subprocReadyCount == numSubprocs) { 92 | log(`all subprocs launched. PIDs: ${subprocPIDs.join(",")} OK`) 93 | 94 | // Now send SIGTERM to the process. 95 | // Disable timeout so that the test does not depend on timing/race. 96 | log("sending SIGTERM") 97 | killPromise = cmd.kill("SIGTERM", /*timeout*/0, signalMode) 98 | 99 | // setup a long manual timeout for the test 100 | killTimer = setTimeout(() => { 101 | signalAll("SIGKILL") 102 | fail("did not finish within 1s") 103 | },1000) 104 | } 105 | } 106 | 107 | // step 3: main process confirms receiving SIGTERM 108 | else if (!mainprocRecvSIGTERM || subprocRecvSIGTERMCount < numSubprocs) { 109 | if (/mainproc\[\d+\] received and ignoring SIGTERM/.test(line)) { 110 | mainprocStarted = true 111 | } 112 | if (/subproc\d+\[\d+\] received and ignoring SIGTERM/.test(line)) { 113 | subprocRecvSIGTERMCount++ 114 | } 115 | if (mainprocStarted && subprocRecvSIGTERMCount == numSubprocs) { 116 | log("all subprocs acknowledge receiving SIGTERM. OK") 117 | 118 | // check that subprocesses are still running 119 | if (processInfoCommand) for (let pid of subprocPIDs) { 120 | const cmdinfo = await getProcessInfoForPID(pid) 121 | if (cmdinfo.indexOf(Path.basename(process.execPath)) == -1) { 122 | console.warn( 123 | `subproc [${pid}] not found.`+ 124 | `\ngetProcessInfoForPID returned:\n${cmdinfo}` 125 | ) 126 | } 127 | } 128 | 129 | // send SIGINT to cmd and its subprocesses (SIGINT since they ignore SIGTERM) 130 | signalAll(finalSignal) 131 | // note that if this does not cause the process to terminate, killTimer will expire 132 | // and send SIGKILL 133 | break 134 | } 135 | } 136 | 137 | else { 138 | fail("received unexpected command output:", [line]) 139 | signalAll("SIGKILL") 140 | } 141 | } 142 | 143 | // wait for process to exit 144 | const killReturnValue = await killPromise 145 | clearTimeout(killTimer) 146 | 147 | // check for subprocess zombies 148 | if (processInfoCommand) for (let pid of subprocPIDs) { 149 | const cmdinfo = await getProcessInfoForPID(pid) 150 | if (cmdinfo.indexOf(Path.basename(process.execPath)) != -1) { 151 | console.warn( 152 | `subproc [${pid}] still running.`+ 153 | `\ngetProcessInfoForPID returned:\n${cmdinfo}` 154 | ) 155 | } 156 | } 157 | 158 | // check state 159 | const finalSignal_code = os.constants.signals[finalSignal] 160 | asserteq(cmd.exitCode, -finalSignal_code, 161 | `Should exit from signal ${finalSignal} (-${finalSignal_code}) but got ${cmd.exitCode}`) 162 | asserteq(killReturnValue, cmd.exitCode) 163 | } 164 | 165 | 166 | async function main() { 167 | // how many subprocesses the process should spawn "underneath" itself. 168 | // 2 is enough for the test to make sense. 169 | const numSubprocs = 2 170 | 171 | // log note about skipping liveness checks 172 | if (!processInfoCommand) { 173 | log("skipping OS-level process liveness checks (unsupported)") 174 | } 175 | 176 | await test1("standard", numSubprocs) 177 | if (!os.platform().startsWith("win")) { 178 | // test process group signalling 179 | await test1("group", numSubprocs) 180 | } 181 | } 182 | 183 | 184 | // this is an OS-dependent shell command that given $PID returns the process filename 185 | const processInfoCommand = (()=>{ 186 | switch (os.platform()) { 187 | case "darwin": 188 | case "freebsd": 189 | case "linux": 190 | case "openbsd": 191 | return "ps ax | awk '$1 == $PID { print $0 }'" 192 | 193 | case "aix": 194 | case "sunos": 195 | case "win32": 196 | return "" 197 | 198 | default: 199 | return "" 200 | } 201 | })() 202 | 203 | 204 | function getProcessInfoForPID(pid :number) :Promise { 205 | return new Promise((resolve, reject) => { 206 | const options = {} 207 | const command = processInfoCommand.replace(/\$PID/, pid) 208 | child_process.exec(command, options, (error, stdout, _stderr) => { 209 | // if (error) { 210 | // reject(error) 211 | // } else { 212 | resolve(stdout || "") 213 | // } 214 | }) 215 | }) 216 | } 217 | 218 | 219 | main().catch(err => { 220 | console.error(err.stack||String(err)) 221 | process.exit(2) 222 | }) 223 | 224 | 225 | -------------------------------------------------------------------------------- /src/debug/debug.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import * as os from "os" 3 | import * as Path from "path" 4 | import { install, getErrorSource } from "source-map-support" 5 | import { stderrStyle } from "../termstyle" 6 | import { resolveModulePackageFile, tildePath } from "../util" 7 | import * as typeinfo from "../typeinfo" 8 | import { log, LogLevel } from "../log" 9 | import * as _file from "../file" 10 | 11 | export { install as installSourceMapSupport, getErrorSource } 12 | 13 | type FileModule = typeof _file 14 | 15 | export function initModule(logLevel :LogLevel, _ :FileModule) { 16 | log.level = logLevel 17 | } 18 | 19 | 20 | export function bugReportMessage(mode :"confident"|"guess", reportContextField? :string) { 21 | const props :{[name:string]:any} = { 22 | "platform": `${os.platform()}; ${os.arch()}; v${os.release()}`, 23 | "time": (new Date).toISOString(), 24 | "estrella": `v${VERSION} (${tildePath(__dirname)}) for esbuild v${typeinfo.esbuild.version}`, 25 | "esbuild": `(not found)`, 26 | } 27 | 28 | for (let modid of ["esbuild", "chokidar", "typescript"]) { 29 | try { 30 | const packageFile = resolveModulePackageFile(modid) 31 | const pkg = JSON.parse(fs.readFileSync(packageFile, "utf8")) 32 | props[modid] = `v${pkg.version} (${Path.dirname(tildePath(packageFile))})` 33 | } catch (_) {} 34 | } 35 | 36 | if (reportContextField) { 37 | props["context"] = reportContextField 38 | } 39 | 40 | let msg = ( 41 | mode == "guess" ? "If you think this is a bug in Estrella, please" : 42 | stderrStyle.yellow("Looks like you found a bug in Estrella!") + "\nPlease" 43 | ) + ` file an issue at:\n` + 44 | ` https://github.com/rsms/estrella/issues\n` + 45 | `Include the following information in the report along with the stack trace:` 46 | 47 | const propKeyMaxlen = Object.keys(props).reduce((a, v) => Math.max(a, v.length), 0) 48 | for (let k of Object.keys(props)) { 49 | msg += `\n ${(k + ":").padEnd(propKeyMaxlen + 1, " ")} ${props[k]}` 50 | } 51 | 52 | return msg 53 | } 54 | 55 | 56 | export function printErrorAndExit(err :any, origin? :string) { 57 | // origin : "uncaughtException" | "unhandledRejection" 58 | let message = "" 59 | let stack = "" 60 | if (!err || typeof err != "object") { 61 | err = String(err) 62 | } 63 | const isUserError = err.name == "UserError" 64 | 65 | const m = (err.stack||"").match(/\n\s{2,}at /) 66 | if (m) { 67 | message = err.stack.substr(0, m.index) 68 | stack = err.stack.substr(m.index + 1) 69 | } else { 70 | message = err.message || String(err) 71 | } 72 | 73 | let kind = origin == "unhandledRejection" ? "promise rejection" : "exception" 74 | let msg = stderrStyle.red( 75 | isUserError ? `error: ${err.message || message}` : 76 | `Unhandled ${kind}: ${message}` 77 | ) 78 | 79 | if (stack && (!isUserError || DEBUG)) { 80 | // Note: no stack for UserError in release builds 81 | const sourceSnippet = getErrorSource(err) 82 | if (sourceSnippet) { 83 | msg += `\n${sourceSnippet}` 84 | } 85 | msg += "\n" + stack 86 | } 87 | 88 | // did the error originate in estrella rather than a user script? 89 | if (!DEBUG && stack && !isUserError) { 90 | const frame1 = stack.split("\n",2)[0] 91 | const filename = findFilenameInStackFrame(frame1) 92 | if (filename.includes("") || Path.basename(filename).startsWith("estrella")) { 93 | // Note: estrella's build.js script adds "" to the sourcemap when 94 | // build in release mode 95 | msg += "\n" + bugReportMessage("confident") 96 | } 97 | } 98 | 99 | fs.writeSync((process.stderr as any).fd, msg + "\n") 100 | process.exit(2) 101 | } 102 | 103 | 104 | function findFilenameInStackFrame(frame :string) :string { 105 | const m = frame.match(/at\s+(?:.+\s+\(([^\:]+)\:\d+(?:\:\d+)\)$|([^\:]+)\:\d)/) 106 | if (!m) { 107 | return "" 108 | } 109 | return m[1] || m[2] 110 | } 111 | 112 | 113 | // libuv error codes from http://docs.libuv.org/en/v1.x/errors.html 114 | export const libuv_errors = { 115 | "E2BIG": "argument list too long", 116 | "EACCES": "permission denied", 117 | "EADDRINUSE": "address already in use", 118 | "EADDRNOTAVAIL": "address not available", 119 | "EAFNOSUPPORT": "address family not supported", 120 | "EAGAIN": "resource temporarily unavailable", 121 | "EAI_ADDRFAMILY": "address family not supported", 122 | "EAI_AGAIN": "temporary failure", 123 | "EAI_BADFLAGS": "bad ai_flags value", 124 | "EAI_BADHINTS": "invalid value for hints", 125 | "EAI_CANCELED": "request canceled", 126 | "EAI_FAIL": "permanent failure", 127 | "EAI_FAMILY": "ai_family not supported", 128 | "EAI_MEMORY": "out of memory", 129 | "EAI_NODATA": "no address", 130 | "EAI_NONAME": "unknown node or service", 131 | "EAI_OVERFLOW": "argument buffer overflow", 132 | "EAI_PROTOCOL": "resolved protocol is unknown", 133 | "EAI_SERVICE": "service not available for socket type", 134 | "EAI_SOCKTYPE": "socket type not supported", 135 | "EALREADY": "connection already in progress", 136 | "EBADF": "bad file descriptor", 137 | "EBUSY": "resource busy or locked", 138 | "ECANCELED": "operation canceled", 139 | "ECHARSET": "invalid Unicode character", 140 | "ECONNABORTED": "software caused connection abort", 141 | "ECONNREFUSED": "connection refused", 142 | "ECONNRESET": "connection reset by peer", 143 | "EDESTADDRREQ": "destination address required", 144 | "EEXIST": "file already exists", 145 | "EFAULT": "bad address in system call argument", 146 | "EFBIG": "file too large", 147 | "EHOSTUNREACH": "host is unreachable", 148 | "EINTR": "interrupted system call", 149 | "EINVAL": "invalid argument", 150 | "EIO": "i/o error", 151 | "EISCONN": "socket is already connected", 152 | "EISDIR": "illegal operation on a directory", 153 | "ELOOP": "too many symbolic links encountered", 154 | "EMFILE": "too many open files", 155 | "EMSGSIZE": "message too long", 156 | "ENAMETOOLONG": "name too long", 157 | "ENETDOWN": "network is down", 158 | "ENETUNREACH": "network is unreachable", 159 | "ENFILE": "file table overflow", 160 | "ENOBUFS": "no buffer space available", 161 | "ENODEV": "no such device", 162 | "ENOENT": "no such file or directory", 163 | "ENOMEM": "not enough memory", 164 | "ENONET": "machine is not on the network", 165 | "ENOPROTOOPT": "protocol not available", 166 | "ENOSPC": "no space left on device", 167 | "ENOSYS": "function not implemented", 168 | "ENOTCONN": "socket is not connected", 169 | "ENOTDIR": "not a directory", 170 | "ENOTEMPTY": "directory not empty", 171 | "ENOTSOCK": "socket operation on non-socket", 172 | "ENOTSUP": "operation not supported on socket", 173 | "EPERM": "operation not permitted", 174 | "EPIPE": "broken pipe", 175 | "EPROTO": "protocol error", 176 | "EPROTONOSUPPORT": "protocol not supported", 177 | "EPROTOTYPE": "protocol wrong type for socket", 178 | "ERANGE": "result too large", 179 | "EROFS": "read-only file system", 180 | "ESHUTDOWN": "cannot send after transport endpoint shutdown", 181 | "ESPIPE": "invalid seek", 182 | "ESRCH": "no such process", 183 | "ETIMEDOUT": "connection timed out", 184 | "ETXTBSY": "text file is busy", 185 | "EXDEV": "cross-device link not permitted", 186 | "UNKNOWN": "unknown error", 187 | "EOF": "end of file", 188 | "ENXIO": "no such device or address", 189 | "EMLINK": "too many links", 190 | "ENOTTY": "inappropriate ioctl for device", 191 | "EFTYPE": "inappropriate file type or format", 192 | "EILSEQ": "illegal byte sequence", 193 | } 194 | -------------------------------------------------------------------------------- /src/chmod.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import { json } from "./util" 3 | 4 | const chr = String.fromCharCode 5 | const ord = (s :string, offs :number) => s.charCodeAt(offs || 0) 6 | 7 | 8 | export type Modifier = number 9 | | string 10 | | string[] 11 | 12 | // chmod edits mode of a file (synchronous) 13 | // If m is a number, the mode is simply set to m. 14 | // If m is a string or list of strings, the mode is updated using editFileMode. 15 | // Returns the new mode set on file. 16 | export function chmod(file :fs.PathLike, modifier :Modifier) :number { 17 | if (typeof modifier == "number") { 18 | fs.chmodSync(file, modifier) 19 | return modifier 20 | } 21 | let mode = fs.statSync(file).mode 22 | let newMode = editFileMode(mode, modifier) 23 | if (mode != newMode) { 24 | fs.chmodSync(file, newMode) 25 | } 26 | return newMode 27 | } 28 | 29 | // async version of chmod 30 | export function chmodp(file :fs.PathLike, modifier :Modifier) :Promise { 31 | return new Promise((resolve, reject) => { 32 | if (typeof modifier == "number") { 33 | return fs.chmod(file, modifier, err => { 34 | err ? reject(err) : resolve(modifier) 35 | }) 36 | } 37 | fs.stat(file, (err, st) => { 38 | if (err) return reject(err) 39 | let newMode = editFileMode(st.mode, modifier) 40 | if (st.mode == newMode) { 41 | return resolve(newMode) 42 | } 43 | fs.chmod(file, newMode, err => { 44 | err ? reject(err) : resolve(newMode) 45 | }) 46 | }) 47 | }) 48 | } 49 | 50 | 51 | // editFileMode takes a file mode (e.g. 0o764), applies modifiers and returns the resulting mode. 52 | // It accepts the same format as the Posix chmod program. 53 | // If multiple modifiers are provided, they are applied to mode in order. 54 | // 55 | // Grammar of modifier format: 56 | // 57 | // mode := clause [, clause ...] 58 | // clause := [who ...] [action ...] action 59 | // action := op [perm ...] 60 | // who := a | u | g | o 61 | // op := + | - | = 62 | // perm := r | w | x 63 | // 64 | // Examples: 65 | // 66 | // // Set execute bit for user and group 67 | // newMode = editFileMode(0o444, "ug+x") // => 0o554 68 | // 69 | // // Set execute bit for user, write bit for group and remove all access for others 70 | // newMode = editFileMode(0o444, "+x,g+w,o-") // => 0o560 71 | // 72 | export function editFileMode(mode :number, modifier :string|string[]) :number { 73 | const expectedFormat = `Expected format: [ugoa]*[+-=][rwx]+` 74 | 75 | const err = (msg :string, m :any) => 76 | new Error(`${msg} in modifier ${json(m)}. ${expectedFormat}`) 77 | 78 | let mods :string[] = [] 79 | for (let m of Array.isArray(modifier) ? modifier : [ modifier ]) { 80 | mods = mods.concat(m.trim().split(/\s*,+\s*/)) 81 | } 82 | 83 | for (let m of mods) { 84 | let who :number[] = [] 85 | let all = false 86 | let op = 0 87 | let perm = 0 88 | 89 | for (let i = 0; i < m.length; i++) { 90 | let c = ord(m, i) 91 | if (op == 0) { 92 | switch (c) { 93 | case 0x75: // u 94 | case 0x67: // g 95 | case 0x6F: // o 96 | if (!all) { 97 | who.push(c) 98 | } 99 | break 100 | case 0x61: // a 101 | who = [ 0x75, 0x67, 0x6F ] 102 | all = true 103 | break 104 | case 0x2B: // + 105 | case 0x2D: // - 106 | case 0x3D: // = 107 | op = c 108 | break 109 | default: 110 | if (op == 0) { 111 | throw err(`Invalid target or operation ${json(chr(c))}`, m) 112 | } 113 | break 114 | } 115 | } else { 116 | switch (c) { 117 | case 0x72: perm |= 0o4 ; break // r 118 | case 0x77: perm |= 0o2 ; break // w 119 | case 0x78: perm |= 0o1 ; break // x 120 | default: throw err(`Invalid permission ${json(chr(c))}`, m) 121 | } 122 | } 123 | } 124 | if (op == 0) { 125 | throw err(`Missing operation`, m) 126 | } 127 | if (who.length == 0) { 128 | who = [ 0x75 ] // u 129 | } 130 | if (perm == 0) { 131 | perm = 0o4 | 0o2 | 0o1 132 | } 133 | 134 | let mode2 = 0 135 | for (let w of who) { 136 | switch (w) { 137 | case 0x75: mode2 |= (perm << 6) ; break // u 138 | case 0x67: mode2 |= (perm << 3) ; break // g 139 | case 0x6F: mode2 |= perm ; break // o 140 | } 141 | } 142 | switch (op) { 143 | case 0x2B: mode |= mode2 ; break // + 144 | case 0x2D: mode &= ~mode2 ; break // - 145 | case 0x3D: mode = mode2 ; break // = 146 | } 147 | // For debugging: 148 | // console.log({ 149 | // who: who.map(n => '0o' + n.toString(8)), 150 | // op: String.fromCharCode(op), 151 | // perm: '0o' + perm.toString(8), 152 | // }) 153 | } // for each m in modifier 154 | return mode 155 | } 156 | 157 | 158 | declare const DEBUG :boolean 159 | 160 | // lil' unit test for editFileMode 161 | if (DEBUG) { 162 | const asserteq = require("assert").strictEqual 163 | const oct = (v :number) => "0o" + v.toString(8).padStart(3, '0') 164 | // input, modifiers, expected 165 | const samples : 166 | [ number, string[], number ][] = [ 167 | [ 0o444, ["u+r"], 0o444 ], 168 | [ 0o444, ["u+x"], 0o544 ], 169 | [ 0o444, ["u+w"], 0o644 ], 170 | [ 0o444, ["u+wx"], 0o744 ], 171 | [ 0o444, ["u+rwx"], 0o744 ], 172 | [ 0o444, ["u+r,u+w,u+x"], 0o744 ], 173 | [ 0o444, ["u+r", "u+w,u+x"], 0o744 ], 174 | [ 0o444, ["u+"], 0o744 ], // no perm spec = all 175 | 176 | [ 0o777, ["u-r"], 0o377 ], 177 | [ 0o777, ["u-wx"], 0o477 ], 178 | [ 0o777, ["u-w"], 0o577 ], 179 | [ 0o777, ["u-x"], 0o677 ], 180 | [ 0o777, ["u-"], 0o077 ], 181 | [ 0o777, ["u-rwx"], 0o077 ], 182 | 183 | [ 0o444, ["g+r"], 0o444 ], 184 | [ 0o444, ["g+x"], 0o454 ], 185 | [ 0o444, ["g+w"], 0o464 ], 186 | [ 0o444, ["g+wx"], 0o474 ], 187 | [ 0o444, ["g+rwx"], 0o474 ], 188 | [ 0o444, ["g+"], 0o474 ], 189 | 190 | [ 0o777, ["g-r"], 0o737 ], 191 | [ 0o777, ["g-wx"], 0o747 ], 192 | [ 0o777, ["g-w"], 0o757 ], 193 | [ 0o777, ["g-x"], 0o767 ], 194 | [ 0o777, ["g-"], 0o707 ], 195 | [ 0o777, ["g-rwx"], 0o707 ], 196 | 197 | [ 0o444, ["o+r"], 0o444 ], 198 | [ 0o444, ["o+x"], 0o445 ], 199 | [ 0o444, ["o+w"], 0o446 ], 200 | [ 0o444, ["o+wx"], 0o447 ], 201 | [ 0o444, ["o+rwx"], 0o447 ], 202 | [ 0o444, ["o+"], 0o447 ], 203 | 204 | [ 0o777, ["o-r"], 0o773 ], 205 | [ 0o777, ["o-wx"], 0o774 ], 206 | [ 0o777, ["o-w"], 0o775 ], 207 | [ 0o777, ["o-x"], 0o776 ], 208 | [ 0o777, ["o-"], 0o770 ], 209 | [ 0o777, ["o-rwx"], 0o770 ], 210 | 211 | 212 | [ 0o444, ["ug+r"], 0o444 ], 213 | [ 0o444, ["ug+x"], 0o554 ], 214 | [ 0o444, ["ug+w"], 0o664 ], 215 | [ 0o444, ["ug+wx"], 0o774 ], 216 | [ 0o444, ["ug+rwx"], 0o774 ], 217 | [ 0o444, ["ug+"], 0o774 ], 218 | 219 | [ 0o444, ["ugo+r"], 0o444 ], [ 0o444, ["a+r"], 0o444 ], 220 | [ 0o444, ["ugo+x"], 0o555 ], [ 0o444, ["a+x"], 0o555 ], 221 | [ 0o444, ["ugo+w"], 0o666 ], [ 0o444, ["a+w"], 0o666 ], 222 | [ 0o444, ["ugo+wx"], 0o777 ], [ 0o444, ["a+wx"], 0o777 ], 223 | [ 0o444, ["ugo+rwx"], 0o777 ], [ 0o444, ["a+rwx"], 0o777 ], 224 | [ 0o444, ["ugo+"], 0o777 ], [ 0o444, ["a+"], 0o777 ], 225 | 226 | [ 0o777, ["ug-r"], 0o337 ], 227 | [ 0o777, ["ug-wx"], 0o447 ], 228 | [ 0o777, ["ug-w"], 0o557 ], 229 | [ 0o777, ["ug-x"], 0o667 ], 230 | [ 0o777, ["ug-"], 0o007 ], 231 | [ 0o777, ["ug-rwx"], 0o007 ], 232 | 233 | [ 0o777, ["ugo-r"], 0o333 ], [ 0o777, ["a-r"], 0o333 ], 234 | [ 0o777, ["ugo-wx"], 0o444 ], [ 0o777, ["a-wx"], 0o444 ], 235 | [ 0o777, ["ugo-w"], 0o555 ], [ 0o777, ["a-w"], 0o555 ], 236 | [ 0o777, ["ugo-x"], 0o666 ], [ 0o777, ["a-x"], 0o666 ], 237 | [ 0o777, ["ugo-"], 0o000 ], [ 0o777, ["a-"], 0o000 ], 238 | [ 0o777, ["ugo-rwx"], 0o000 ], [ 0o777, ["a-rwx"], 0o000 ], 239 | ] // samples 240 | 241 | samples.map(([input, mods, expect]) => { 242 | let actual = editFileMode(input, mods) 243 | asserteq(actual, expect, 244 | `editFileMode(${oct(input)}, ${json(mods)}) => ` + 245 | `${oct(actual)} != expected ${oct(expect)}` 246 | ) 247 | }) 248 | } // end of editFileMode tests 249 | -------------------------------------------------------------------------------- /src/file.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import { PathLike } from "fs" 3 | import * as Path from "path" 4 | import * as crypto from "crypto" 5 | import { chmodp, Modifier as ChModModifier, editFileMode } from "./chmod" 6 | import { clock, tildePath } from "./util" 7 | import { stdoutStyle } from "./termstyle" 8 | import log from "./log" 9 | import { UserError } from "./error" 10 | 11 | import { WatchOptions, file as filedecl, FileWriteOptions } from "../estrella.d" 12 | 13 | 14 | const fsp = fs.promises 15 | 16 | // fileModificationLog contains a list of [filename,Date.now()] of files that where 17 | // modified through the API. This data is used by watch. 18 | export const fileModificationLog :{[filename:string]:number} = {} 19 | 20 | export function fileModificationLogAppend(filename :PathLike) { 21 | // TODO figure out a way to make it not grow unbounded with variable file names 22 | fileModificationLog[Path.resolve(String(filename))] = clock() 23 | } 24 | 25 | export function fileWasModifiedRecentlyByUser(filename :string) { 26 | const ageThreshold = 30000 27 | const time = fileModificationLog[Path.resolve(filename)] 28 | return time !== undefined && clock() - time <= ageThreshold 29 | } 30 | 31 | // trick to make TypeScript type check our definitions here against those in estrella.d.ts 32 | export const _ts_check_file :typeof filedecl = file 33 | 34 | 35 | // file() reads all contents of a file (same as file.read) 36 | export function file(filename :PathLike, options :{encoding:string,flag?:string}|string) :Promise 37 | export function file(filename :PathLike, options :{encoding?:null,flag?:string}) :Promise 38 | export function file(filename :PathLike) :Promise 39 | export function file( 40 | filename: PathLike, 41 | options? :{encoding?:string|null,flag?:string}|string, 42 | ) :Promise { 43 | return fsp.readFile(filename, options as any) 44 | } 45 | 46 | file.editMode = editFileMode 47 | 48 | 49 | file.chmod = (filename :PathLike, modifier :ChModModifier) => { 50 | fileModificationLogAppend(filename) 51 | return chmodp(filename, modifier) 52 | } 53 | 54 | 55 | type ReadOptions = fs.BaseEncodingOptions & { flag?: string | number; } 56 | | BufferEncoding 57 | | null 58 | 59 | function read( 60 | filename :PathLike, 61 | options :{encoding:BufferEncoding, flag?:fs.OpenMode} | BufferEncoding 62 | ) :Promise 63 | function read(filename :PathLike, 64 | options :{encoding?:null, flag?:fs.OpenMode} | null 65 | ) :Promise 66 | function read(filename :PathLike) :Promise 67 | function read(filename :PathLike, options? :ReadOptions) :Promise { 68 | return fsp.readFile(filename, options) 69 | } 70 | file.read = read 71 | 72 | 73 | function readSync( 74 | filename :PathLike, 75 | options :{encoding:BufferEncoding,flag?:fs.OpenMode} | BufferEncoding 76 | ) :string 77 | function readSync(filename :PathLike, options :{encoding?:null,flag?:fs.OpenMode} | null) :Buffer 78 | function readSync(filename :PathLike) :Buffer 79 | function readSync(filename :PathLike, options? :ReadOptions) :string|Buffer { 80 | // Note: typecast of options since fs type defs for node12 are incorrect: type of flags 81 | // do not list number, even though the official nodejs documentation does. 82 | // https://nodejs.org/docs/latest-v12.x/api/fs.html#fs_file_system_flags 83 | return fs.readFileSync(filename, options as ReadOptions&{flag?: string}) 84 | } 85 | file.readSync = readSync 86 | 87 | 88 | file.stat = fsp.stat 89 | 90 | 91 | function mtime(filename :PathLike) :Promise 92 | function mtime(...filenames :PathLike[]) :Promise<(number|null)[]> 93 | function mtime(...filenames :PathLike[]) :Promise { 94 | return Promise.all(filenames.map(filename => 95 | fsp.stat(filename).then(st => st.mtimeMs).catch(_ => null) 96 | )).then(r => r.length == 1 ? r[0] : r) 97 | } 98 | file.mtime = mtime 99 | 100 | file.readall = (...filenames :PathLike[]) => 101 | Promise.all(filenames.map(fn => fsp.readFile(fn))) 102 | 103 | file.readallText = (encoding :string|null|undefined, ...filenames :PathLike[]) => 104 | Promise.all(filenames.map(fn => fsp.readFile(fn, { 105 | encoding: (encoding||"utf8") as BufferEncoding 106 | }))) 107 | 108 | file.write = async (filename :PathLike, data :string|Uint8Array, options? :FileWriteOptions) => { 109 | fileModificationLogAppend(filename) 110 | const opt = options && typeof options == "object" ? options : {} 111 | try { 112 | await fsp.writeFile(filename, data, options) 113 | } catch (err) { 114 | if (!opt.mkdirOff && err.code == "ENOENT") { 115 | await file.mkdirs(Path.dirname(String(filename)), opt.mkdirMode) 116 | await fsp.writeFile(filename, data, options) 117 | } else { 118 | throw err 119 | } 120 | } 121 | if (opt.log) { 122 | let relpath = Path.relative(process.cwd(), String(filename)) 123 | if (relpath.startsWith(".." + Path.sep)) { 124 | relpath = tildePath(filename) 125 | } 126 | log.info(stdoutStyle.green(`Wrote ${relpath}`)) 127 | } 128 | } 129 | 130 | file.writeSync = (filename :PathLike, data :string|Uint8Array, options? :FileWriteOptions) => { 131 | // See note in readSync regarding the typecast 132 | fileModificationLogAppend(filename) 133 | fs.writeFileSync(filename, data, options as fs.WriteFileOptions) 134 | } 135 | 136 | function sha1(filename :PathLike) :Promise 137 | function sha1(filename :PathLike, outputEncoding :crypto.BinaryToTextEncoding) :Promise 138 | 139 | function sha1( 140 | filename :PathLike, 141 | outputEncoding? :crypto.BinaryToTextEncoding, 142 | ) :Promise { 143 | return new Promise((resolve, reject) => { 144 | const reader = fs.createReadStream(filename) 145 | const h = crypto.createHash('sha1') 146 | reader.on('error', reject) 147 | reader.on('end', () => { 148 | h.end() 149 | resolve(outputEncoding ? h.digest(outputEncoding) : h.digest()) 150 | }) 151 | reader.pipe(h) 152 | }) 153 | } 154 | 155 | file.sha1 = sha1 156 | 157 | file.copy = (srcfile :PathLike, dstfile :PathLike, failIfExist? :boolean) => { 158 | let mode = fs.constants.COPYFILE_FICLONE // copy-on-write (only used if OS supports it) 159 | if (failIfExist) { 160 | mode |= fs.constants.COPYFILE_EXCL 161 | } 162 | fileModificationLogAppend(dstfile) 163 | return fsp.copyFile(srcfile, dstfile, mode) 164 | } 165 | 166 | file.move = (oldfile :PathLike, newfile :PathLike) => { 167 | fileModificationLogAppend(newfile) 168 | return fsp.rename(oldfile, newfile) 169 | } 170 | 171 | file.mkdirs = (dir :PathLike, mode? :fs.Mode) :Promise => { 172 | return fsp.mkdir(dir, {recursive:true, mode}).then(s => !!s && s.length > 0) 173 | } 174 | 175 | 176 | type LegacyWatchOptions = { 177 | recursive? :boolean 178 | } 179 | 180 | 181 | export async function scandir( 182 | dir :string|string[], 183 | filter? :RegExp|null, 184 | options? :(WatchOptions & LegacyWatchOptions)|null, 185 | ) :Promise { 186 | if (!options) { options = {} } 187 | if (!fs.promises || !fs.promises.opendir) { 188 | // opendir was added in node 12.12.0 189 | throw new Error(`scandir not implemented for nodejs <12.12.0`) // TODO 190 | } 191 | const files :string[] = [] 192 | const visited = new Set() 193 | 194 | const maxdepth = ( 195 | options.recursive !== undefined ? // legacy option from estrella <=1.1 196 | options.recursive ? Infinity : 0 : 197 | options.depth !== undefined ? options.depth : 198 | Infinity 199 | ) 200 | 201 | async function visit(dir :string, reldir :string, depth :number) { 202 | if (visited.has(dir)) { 203 | // cycle 204 | return 205 | } 206 | visited.add(dir) 207 | const d = await fs.promises.opendir(dir) 208 | // Note: d.close() is called implicitly by the iterator/generator 209 | for await (const ent of d) { 210 | let name = ent.name 211 | if (ent.isDirectory()) { 212 | if (maxdepth < depth) { 213 | await visit(Path.join(dir, name), Path.join(reldir, name), depth + 1) 214 | } 215 | } else if (ent.isFile() || ent.isSymbolicLink()) { 216 | if (filter && filter.test(name)) { 217 | files.push(Path.join(reldir, name)) 218 | } 219 | } 220 | } 221 | } 222 | 223 | const dirs = Array.isArray(dir) ? dir : [dir] 224 | 225 | return Promise.all(dirs.map(dir => 226 | visit(Path.resolve(dir), ".", 0) 227 | )).then(() => files.sort()) 228 | } 229 | -------------------------------------------------------------------------------- /src/watch/fswatch.ts: -------------------------------------------------------------------------------- 1 | import * as Path from "path" 2 | import * as chokidar from "chokidar" 3 | 4 | import { WatchOptions, WatchCallback, FileEvent, FileEvent1, FileEvent2 } from "../../estrella.d" 5 | 6 | import { repr, clock, fmtDuration } from "../util" 7 | import log from "../log" 8 | 9 | type ChangeEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir' 10 | type CancellablePromise = Promise&{ cancel(reason?:any):void } 11 | 12 | export interface FSWatcherOptions extends WatchOptions { 13 | isChangeSelfOriginating(filename :string) :boolean 14 | } 15 | 16 | 17 | export class FSWatcher { 18 | options :FSWatcherOptions 19 | promise :CancellablePromise 20 | basedir :string = "" 21 | 22 | onStart? :()=>{} 23 | onChange? :WatchCallback 24 | 25 | _resolve = (_?:void|PromiseLike|undefined, _rejectReason?:any)=>{} 26 | _cancelled :boolean = false 27 | _watcher :chokidar.FSWatcher | null = null 28 | _fileset = new Set() // observed files 29 | 30 | 31 | constructor(options :FSWatcherOptions) { 32 | this.options = options 33 | this.promise = new Promise(r => { 34 | this._resolve = r 35 | }) as CancellablePromise 36 | this.promise.cancel = () => { 37 | this._cancelled = true 38 | } 39 | } 40 | 41 | 42 | setFiles(files :Iterable) { 43 | const newfileset = new Set(files) 44 | 45 | if (!this._watcher) { 46 | this._fileset = newfileset 47 | this._start() 48 | return 49 | } 50 | 51 | // find files being added and removed 52 | let gonefiles :string[] = [] 53 | for (let f of this._fileset) { 54 | if (!newfileset.has(f)) { 55 | gonefiles.push(f) 56 | } 57 | } 58 | let addfiles :string[] = [] 59 | for (let f of newfileset) { 60 | if (!this._fileset.has(f)) { 61 | addfiles.push(f) 62 | } 63 | } 64 | 65 | this._fileset = newfileset 66 | 67 | if (gonefiles.length > 0) { 68 | log.debug(()=> `fswatch stop watching files ${this._relnames(gonefiles)}`) 69 | this._watcher.unwatch(gonefiles) 70 | } 71 | 72 | if (addfiles.length > 0) { 73 | log.debug(()=> `fswatch start watching files ${this._relnames(addfiles)}`) 74 | this._watcher.add(addfiles) 75 | } 76 | } 77 | 78 | 79 | close() :Promise { 80 | if (!this._watcher) { 81 | return Promise.resolve() 82 | } 83 | log.debug(()=> `fswatch closing`) 84 | this._watcher.close() 85 | .then(() => this._resolve()) 86 | .catch(err => this._resolve(undefined, err)) 87 | this._watcher = null 88 | return this.promise 89 | } 90 | 91 | 92 | _relnames(filenames :string[]) :string { 93 | if (filenames.length == 1) { 94 | return this._relname(filenames[0]) 95 | } 96 | return filenames.map(fn => "\n " + this._relname(fn)).join("") 97 | } 98 | 99 | 100 | _relname(fn :string) :string { 101 | if (this.basedir && fn.startsWith(this.basedir)) { 102 | return Path.relative(this.basedir, fn) 103 | } 104 | return Path.relative(process.cwd(), fn) 105 | } 106 | 107 | 108 | _start() { 109 | if (this._cancelled) { 110 | return 111 | } 112 | 113 | const initialFiles = Array.from(this._fileset) 114 | if (initialFiles.length == 0) { 115 | // chokidar has some odd behavior (bug?) where starting a watcher in "persistent" mode 116 | // without initial files to watch causes it to not prevent the program runloop from ending. 117 | return 118 | } 119 | 120 | if (this.basedir) { 121 | this.basedir = Path.resolve(this.basedir) 122 | } 123 | 124 | let flushLatency = 50 125 | let filter :RegExp|null = null 126 | 127 | // copy user options and extract non-chokidar options 128 | const options :WatchOptions = {...this.options} 129 | if (typeof options.latency == "number") { 130 | flushLatency = options.latency 131 | delete options.latency 132 | } 133 | if (options.filter) { 134 | filter = options.filter 135 | delete options.filter 136 | } 137 | 138 | // build chokidar options from default options + user options + required options 139 | const chokidarOptions :chokidar.WatchOptions = { 140 | disableGlobbing: true, 141 | followSymlinks: false, 142 | 143 | // ups the reliability of change events 144 | awaitWriteFinish: { 145 | stabilityThreshold: 20, 146 | pollInterval: 100, 147 | }, 148 | 149 | // user options may override any options listed above 150 | ...options, 151 | 152 | // required options; for guaranteeing the promised semantics of FSWatcher 153 | persistent: true, 154 | ignoreInitial: true, 155 | } 156 | 157 | let changedFiles = new Map() // changed files (to be flushed to callback) 158 | let timer :any = null 159 | 160 | const flush = () => { 161 | timer = null 162 | const p = this.onChange ? this.onChange(Array.from(changedFiles.values())) : null 163 | changedFiles.clear() 164 | if (p instanceof Promise) { 165 | // pause dispatch (just enqueue) until resolved 166 | p.then(() => { 167 | timer = null 168 | if (changedFiles.size > 0) { 169 | // changes recorded while waiting for promise; flush again 170 | flush() 171 | } 172 | }).catch(err => { 173 | this.promise.cancel(err) 174 | }) 175 | timer = 1 // this prevents flushing 176 | } 177 | } 178 | 179 | const scheduleFlush = () => { 180 | if (timer === null) { 181 | timer = setTimeout(flush, flushLatency) 182 | } 183 | } 184 | 185 | const maybeFilter = (file :string) => { 186 | if (filter && !filter.test(file)) { 187 | log.debug(()=>`fswatch ignoring ${file} (filter)`) 188 | return true 189 | } 190 | return false 191 | } 192 | 193 | // macOS issues two consecutive "move" events when a file is renamed 194 | // This contains state of a previous move event 195 | const renameState = { 196 | time: 0 as number, // clock() when oldname event was recorded 197 | oldname: "", 198 | newname: "INIT", 199 | } 200 | 201 | const onchange = (ev :ChangeEvent, file :string) => { 202 | if (this.options.isChangeSelfOriginating(file)) { 203 | log.debug(()=> `fswatch ignoring self-originating event ${ev} ${file}`) 204 | return 205 | } 206 | if (maybeFilter(file)) { 207 | return 208 | } 209 | log.debug(()=> `fsevent ${repr(ev)} ${repr(file)}`) 210 | const evmap :{[k:string]:FileEvent["type"]} = { // map chokidar event name to our event names 211 | 'addDir':"add", 212 | 'unlink':"delete", 213 | 'unlinkDir':"delete", 214 | } 215 | if (ev != "unlink" || !changedFiles.has(file) || changedFiles.get(file)!.type != "move") { 216 | changedFiles.set(file, { 217 | type: evmap[ev] || ev, 218 | name: file, 219 | } as FileEvent1) 220 | } 221 | scheduleFlush() 222 | } 223 | 224 | const onRawEvent = (ev: string, file: string, details: any) => { 225 | if (ev != "moved") { 226 | // note: we only care about "moved" here; other events are handled by onchange 227 | return 228 | } 229 | file = this._relname(file) 230 | log.debug(()=> `fsevent (raw) ${repr(ev)} ${repr(file)} ${repr(details)}`) 231 | if (maybeFilter(file)) { 232 | return 233 | } 234 | const time = clock() 235 | const timeWindow = 100 // ms 236 | if (renameState.newname != "") { 237 | // start of a new pair of "moved" events 238 | renameState.oldname = file 239 | renameState.newname = "" 240 | renameState.time = time 241 | } else { 242 | // end of a pair of "moved" events 243 | renameState.newname = file 244 | renameState.time = time 245 | if (time - renameState.time <= timeWindow) { 246 | // this is the second of two move events 247 | log.debug(`fsevent (derived) move ${renameState.oldname} -> ${file}`) 248 | if (this._watcher) { 249 | this._watcher.add(file) 250 | this._fileset.add(file) 251 | this._watcher.unwatch(renameState.oldname) 252 | this._fileset.delete(renameState.oldname) 253 | } 254 | changedFiles.delete(renameState.oldname) 255 | changedFiles.set(renameState.oldname, { 256 | type: "move", 257 | name: renameState.oldname, 258 | newname: file, 259 | } as FileEvent2) 260 | scheduleFlush() 261 | } 262 | } 263 | } 264 | 265 | this.promise.cancel = (reason? :any) => { 266 | log.debug(`fswatcher is being cancelled`) 267 | clearTimeout(timer) 268 | if (!this._cancelled) { 269 | this._cancelled = true 270 | this.close() 271 | } 272 | if (reason) { 273 | this._resolve(undefined, reason) 274 | } 275 | } 276 | 277 | const time = clock() 278 | 279 | this._watcher = chokidar.watch(initialFiles, chokidarOptions) 280 | .on('all', onchange) 281 | .on('raw', onRawEvent) 282 | .on('error', error => log.warn(`fswatch ${error}`)) 283 | .on('ready', () => { 284 | log.debug(()=>`fswatch initial scan complete (${fmtDuration(clock() - time)})`) 285 | this.onStart && this.onStart() 286 | }) 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /src/io.ts: -------------------------------------------------------------------------------- 1 | import { Writable, Readable } from "stream" 2 | import * as fs from "fs" 3 | 4 | import { TYPE } from "./util" 5 | import * as extra from "./extra" 6 | 7 | 8 | export function isReadableStream(s :Readable|Writable|null|undefined) :s is Readable { 9 | return s && (s as any).read 10 | } 11 | 12 | export function isWritableStream(s :Readable|Writable|null|undefined) :s is Writable { 13 | return s && (s as any).write 14 | } 15 | 16 | 17 | export interface Reader extends AsyncIterable { 18 | // read data with optional size limit. 19 | // 20 | // size -- max number of bytes to read. 21 | // If size is not given or negative, read everything. 22 | // If size is given and the returned buffer's length is smaller than size, then 23 | // the stream has ended (EOF.) 24 | // 25 | // encoding -- how to decode the data into a string. 26 | // If provided, decode the read bytes as `encoding`. 27 | // Note that the size parameter always denotes bytes to read, not characters. 28 | // 29 | read(size? :number) :Promise 30 | read(size :number|undefined|null, encoding :BufferEncoding) :Promise 31 | read(encoding :BufferEncoding) :Promise 32 | 33 | // read chunks as they arrive into the underlying buffer. 34 | // 35 | // Example: 36 | // for await (const chunk of r) { 37 | // console.log(chunk) // Buffer<48 65 6c 6c 6f> 38 | // } 39 | // 40 | [Symbol.asyncIterator](): AsyncIterableIterator 41 | 42 | // underlying nodejs stream object 43 | readonly stream :Readable 44 | 45 | readonly [TYPE] :"Reader" 46 | } 47 | 48 | export interface Writer { 49 | readonly stream :Writable 50 | readonly [TYPE] :"Writer" 51 | } 52 | 53 | export const emptyBuffer = Buffer.allocUnsafe(0) 54 | 55 | 56 | export function isReader(value :any) :value is Reader { 57 | return value && typeof value == "object" && value[TYPE] == "Reader" 58 | } 59 | 60 | export function isWriter(value :any) :value is Writer { 61 | return value && typeof value == "object" && value[TYPE] == "Writer" 62 | } 63 | 64 | export function createReader(stream? :Readable|null) :Reader { 65 | return stream ? new StreamReader(stream) : InvalidReader 66 | } 67 | 68 | export function createWriter(stream? :Writable|null) :Writer { 69 | // TODO 70 | return stream ? { 71 | [TYPE]: "Writer", 72 | stream, 73 | } : InvalidWriter 74 | } 75 | 76 | export function createFileReader(filename :string) :Reader { 77 | return new FileReader(filename) 78 | } 79 | 80 | 81 | export const InvalidReader = new class implements Reader { 82 | readonly [TYPE] = "Reader" 83 | _E() { return new Error("stream not readable") } 84 | get stream() :Readable { throw this._E() } 85 | [Symbol.asyncIterator]() :AsyncIterableIterator { throw this._E() } 86 | read() { return Promise.reject(this._E()) } 87 | } 88 | 89 | export const InvalidWriter = new class implements Writer { 90 | readonly [TYPE] = "Writer" 91 | _E() { return new Error("stream not writable") } 92 | get stream() :Writable { throw this._E() } 93 | } 94 | 95 | // ------------------------------------------------------------------------------------ 96 | // Reader 97 | 98 | export class StreamReader implements Reader { 99 | readonly [TYPE] = "Reader" 100 | readonly stream :Readable 101 | 102 | _ended = false 103 | 104 | constructor(stream :Readable) { 105 | this.stream = stream 106 | stream.pause() // makes it possible to use read() 107 | stream.once("end", () => { 108 | this._ended = true 109 | }) 110 | } 111 | 112 | [Symbol.asyncIterator]() :AsyncIterableIterator { 113 | return this.stream[Symbol.asyncIterator]() 114 | } 115 | 116 | async read(size? :number) :Promise 117 | async read(size :number|undefined|null, encoding :BufferEncoding) :Promise 118 | async read(encoding :BufferEncoding) :Promise 119 | async read(size? :number|null|BufferEncoding, encoding? :BufferEncoding) :Promise { 120 | const stream = this.stream 121 | 122 | // stream must be paused in order to call stream.read() 123 | stream.pause() 124 | 125 | // stream.read(size) semantics: 126 | // if size is undefined: 127 | // return any data in the internal buffer 128 | // returns null if the internal buffer is empty 129 | // else 130 | // if size bytes are available 131 | // return buffer of that length 132 | // else if EOF 133 | // return whatever is in the internal buffer 134 | // else 135 | // return null 136 | // 137 | 138 | if (typeof size == "string") { 139 | encoding = size 140 | size = Number.MAX_SAFE_INTEGER 141 | } else if (size === undefined || size === null || size < 0) { 142 | size = Number.MAX_SAFE_INTEGER 143 | } else if (size == 0) { 144 | return encoding ? "" : emptyBuffer 145 | } 146 | 147 | if (stream.readable) { 148 | // if we are lucky, the requested amount of data is already in the stream's buffer. 149 | // in the case the stream ended, pass undefined for size which causes this call to return 150 | // whatever remains in the buffer. 151 | let buf = stream.read(this._ended ? undefined : size) 152 | if (buf) { 153 | return encoding ? buf.toString(encoding) : buf 154 | } 155 | } 156 | 157 | // stream ended and there is nothing else to read. 158 | // Return an empty buffer 159 | if (this._ended) { 160 | return encoding ? "" : emptyBuffer 161 | } 162 | 163 | // data not yet available 164 | const buffers :Buffer[] = [] 165 | let buffersLen = 0 // accumulative length of `buffers` 166 | 167 | if (stream.readable) { 168 | const buf = stream.read() // read what is in the buffer 169 | if (buf) { 170 | buffers.push(buf) 171 | buffersLen += buf.length 172 | } 173 | } 174 | 175 | // console.log( 176 | // `READ 2 awaiting more data`+ 177 | // ` (has ${buffersLen}, want ${size == Number.MAX_SAFE_INTEGER ? "ALL" : size} bytes)`) 178 | 179 | while (buffersLen < size && !this._ended) { 180 | await new Promise((resolve, reject) => { 181 | stream.once('error', reject) 182 | stream.once('end', resolve) 183 | stream.once('readable', resolve) 184 | }) 185 | 186 | // read no more than what we need 187 | let buf = stream.read(size - buffersLen) 188 | if (!buf) { 189 | // if that fails it means that the stream's buffer is smaller. 190 | // retrieve whatever is in the buffer 191 | buf = stream.read() 192 | } 193 | if (buf) { 194 | buffers.push(buf) 195 | buffersLen += buf.length 196 | } 197 | } 198 | 199 | const buf = joinbufs(buffers) 200 | 201 | return encoding ? buf.toString(encoding) : buf 202 | } 203 | } 204 | 205 | 206 | export class FileReader extends StreamReader { 207 | constructor(filename :string) { 208 | super(fs.createReadStream(filename)) 209 | } 210 | } 211 | 212 | 213 | export function joinbufs(bufs :Buffer[], totalLength? :number) :Buffer { 214 | return ( 215 | bufs.length == 0 ? emptyBuffer : 216 | bufs.length == 1 ? bufs[0] : 217 | Buffer.concat(bufs, totalLength) 218 | ) 219 | } 220 | 221 | 222 | export type WBuf = Buffer[] & _WBuf 223 | interface _WBuf { 224 | buffer() :Buffer // returns everything added so far as one contiguous byte array 225 | } 226 | 227 | export function createWriteBuffer() :WBuf { 228 | const w = [] as any as WBuf 229 | let totalLength = 0 230 | const push = w.push 231 | w.push = (b :Buffer) => { 232 | totalLength += b.length 233 | return push.call(w, b) 234 | } 235 | w.buffer = () => { 236 | return joinbufs(w, totalLength) 237 | } 238 | return w 239 | } 240 | 241 | // readlines yields line by line while reading from source 242 | export function readlines(source :AsyncIterable) :AsyncGenerator 243 | // 244 | export function readlines( 245 | source :AsyncIterable, 246 | encoding :BufferEncoding, 247 | ) :AsyncGenerator 248 | // 249 | export async function* readlines( 250 | source :AsyncIterable, 251 | encoding? :BufferEncoding, 252 | ) :AsyncGenerator { 253 | let bufs :Buffer[] = [] 254 | let bufz = 0 255 | 256 | for await (const data of source) { 257 | let offs = 0 258 | while (true) { 259 | let i = data.indexOf(0x0A, offs) 260 | if (i == -1) { 261 | if (offs < data.length - 1) { 262 | const chunk = data.subarray(offs) 263 | bufs.push(chunk) 264 | bufz += chunk.length 265 | } 266 | break 267 | } 268 | i++ 269 | let buf = data.subarray(offs, i) 270 | if (bufz > 0) { 271 | buf = Buffer.concat(bufs.concat(buf), bufz + buf.length) 272 | bufs.length = 0 273 | bufz = 0 274 | } 275 | yield encoding ? buf.toString(encoding) : buf 276 | offs = i 277 | } 278 | } 279 | 280 | if (bufs.length > 0) { 281 | // last line does not end with a line break 282 | const buf = Buffer.concat(bufs, bufz) 283 | yield encoding ? buf.toString(encoding) : buf 284 | } 285 | } 286 | 287 | 288 | 289 | // ------------------------------------------------------------------------------- 290 | 291 | type LibUVErrors = extra.DebugModule["libuv_errors"] 292 | 293 | export function errorCodeMsg(errorCode :string) :string { 294 | const libuv_errors = extra.debug().libuv_errors 295 | return libuv_errors[errorCode as keyof LibUVErrors] || "" 296 | } 297 | -------------------------------------------------------------------------------- /src/tsapi.ts: -------------------------------------------------------------------------------- 1 | import { json, tildePath } from "./util" 2 | import log from "./log" 3 | import { TypeScriptAPI, TSInterface, TSTypeProp } from "../estrella" 4 | 5 | // hack to make tsc work vanilla with our weird srcdir-based tsconfig (needed for examples to work) 6 | import * as TS from "../node_modules/typescript/lib/typescript.d" 7 | 8 | // type Program = TS.Program 9 | type CompilerOptions = TS.CompilerOptions 10 | type InterfaceDeclaration = TS.InterfaceDeclaration 11 | type SourceFile = TS.SourceFile 12 | 13 | export function createTSAPI(tsapi? :typeof TS) :TypeScriptAPI | null { 14 | let ts = tsapi as typeof TS 15 | if (!ts) { 16 | // load typescript module if available, or return null 17 | log.debug("typescript API requested; attempting to load typescript module") 18 | try { 19 | const X = require // work around an issue in esbuild with require.X() 20 | ts = X("typescript") as typeof TS 21 | if (parseFloat(ts.versionMajorMinor) < 3.5) { 22 | // typescript too old 23 | log.warn( 24 | `typescript ${ts.version} is too old; disabling "ts" API.\n` + 25 | ` You are seeing this message because you are importing the ts API.\n` + 26 | ` Either install a more recent version of typescript or remove the ts import.` 27 | ) 28 | return null 29 | } 30 | log.debug(() => 31 | `loaded typescript ${ts.version} from ${tildePath(X.resolve("typescript"))}`) 32 | } catch (_) { 33 | // API unavailable 34 | log.debug(() => `failed to load typescript; module unavailable`) 35 | return null 36 | } 37 | } 38 | 39 | const compilerHostCache = new Map() 40 | 41 | function getCompilerHost(options: CompilerOptions) :[TS.CompilerHost,CompilerOptions] { 42 | const cacheKey = json(Object.keys(options).sort().map(k => [k,options[k]])) 43 | const cacheEntry = compilerHostCache.get(cacheKey) 44 | if (cacheEntry) { 45 | log.debug("ts.getCompilerHost cache hit") 46 | return cacheEntry 47 | } 48 | options = { 49 | newLine: ts.NewLineKind.LineFeed, // TS 4.0.3 crashes if not set 50 | ...options 51 | } 52 | const host = ts.createCompilerHost(options, /*setParentNodes*/true) 53 | const result :[TS.CompilerHost,CompilerOptions] = [host, options] 54 | compilerHostCache.set(cacheKey, result) 55 | log.debug("ts.getCompilerHost cache miss") 56 | return result 57 | } 58 | 59 | 60 | async function parse(source :string, options?: CompilerOptions) :Promise 61 | 62 | async function parse( 63 | source :{[filename:string]:string}, 64 | options?: CompilerOptions, 65 | ) :Promise<{[filename:string]:SourceFile}> 66 | 67 | async function parse( 68 | source :string | {[filename:string]:string}, 69 | options?: CompilerOptions, 70 | ) :Promise { 71 | const sources = typeof source == "string" ? {"//a.ts":source} : source 72 | const filenames = Object.keys(sources) 73 | 74 | const [host, compilerOptions] = getCompilerHost(options||{}) 75 | 76 | const readFile = host.readFile 77 | host.readFile = (filename: string) => { 78 | // console.log("readFile", filename) 79 | if (filename in sources) { 80 | return sources[filename] 81 | } 82 | return readFile(filename) 83 | } 84 | 85 | // This is SLOW. Usually around 500ms for even a single empty file 86 | const prog = ts.createProgram(filenames, compilerOptions, host) 87 | 88 | if (typeof source == "string") { 89 | return prog.getSourceFile(filenames[0])! 90 | } 91 | const nodes :{[filename:string]:SourceFile} = {} 92 | for (let fn of filenames) { 93 | nodes[fn] = prog.getSourceFile(fn)! 94 | } 95 | return nodes 96 | } 97 | 98 | 99 | async function parseFile(srcfile :string, options?: CompilerOptions) :Promise { 100 | // TODO worker 101 | return _parsefile(srcfile, options) 102 | } 103 | 104 | 105 | function _parsefile(srcfile :string, options?: CompilerOptions) :SourceFile { 106 | const [host, compilerOptions] = getCompilerHost(options || {}) 107 | const prog = ts.createProgram([srcfile], compilerOptions, host) 108 | const file = prog.getSourceFile(srcfile) 109 | if (!file) { 110 | throw new Error(`${srcfile}: file not found`) 111 | } 112 | return file 113 | } 114 | 115 | 116 | function interfaceInfo( 117 | srcfile :string, 118 | interfaceName :string, 119 | options?: CompilerOptions, 120 | ) :Promise { 121 | return interfacesInfo(srcfile, [interfaceName], options).then(v => v[0]) 122 | } 123 | 124 | async function interfacesInfo( 125 | srcfile :string, 126 | interfaceNames :string[] | null, 127 | options?: CompilerOptions, 128 | ) :Promise<(TSInterface|null)[]> { 129 | // TODO move ts to subprocess/worker 130 | const file = _parsefile(srcfile, options) 131 | return interfacesInfoAST(file, interfaceNames) 132 | } 133 | 134 | 135 | function interfacesInfoAST( 136 | file :SourceFile, 137 | interfaceNames :string[] | null, 138 | ) :(TSInterface|null)[] { 139 | const ifdecls = topLevelInterfaceDeclarations(file) 140 | 141 | const shortCircuit = new Map() 142 | const infov :(TSInterface|null)[] = [] 143 | 144 | for (let name of (interfaceNames || ifdecls.keys())) { 145 | const node = ifdecls.get(name) 146 | if (!node) { 147 | infov.push(null) 148 | continue 149 | } 150 | infov.push(createTSInterface(file, node, ifdecls, shortCircuit)) 151 | } 152 | 153 | return infov 154 | } 155 | 156 | 157 | function createTSInterface( 158 | file :SourceFile, 159 | ifnode :InterfaceDeclaration, 160 | ifdecls :Map, 161 | shortCircuit :Map, 162 | ) :TSInterface { 163 | const info1 = shortCircuit.get(ifnode) 164 | if (info1) { 165 | return info1 166 | } 167 | 168 | const info :TSInterface = { 169 | heritage: [], 170 | name: ifnode.name.escapedText as string, 171 | props :{}, 172 | computedProps() { 173 | const props :{[name:string]:TSTypeProp} = {} 174 | for (let h of info.heritage) { 175 | Object.assign(props, h.props) 176 | } 177 | Object.assign(props, info.props) 178 | return props 179 | }, 180 | lookupProp(name :string) :TSTypeProp|null { 181 | let p :TSTypeProp|null = info.props[name] 182 | if (!p) { 183 | for (let h of info.heritage) { 184 | if (p = h.lookupProp(name)) { 185 | break 186 | } 187 | } 188 | } 189 | return p 190 | }, 191 | } 192 | 193 | shortCircuit.set(ifnode, info) 194 | 195 | // heritage types (i.e. from "Bar" in "interface Foo extends Bar") 196 | if (ifnode.heritageClauses) for (let hc of ifnode.heritageClauses) { // hc :HeritageClause 197 | for (let t of hc.types) { // t :ExpressionWithTypeArguments 198 | const expr = t.expression 199 | if (ts.isIdentifier(expr)) { 200 | const heritageNode = ifdecls.get(expr.escapedText as string) 201 | if (heritageNode) { 202 | info.heritage.push(createTSInterface(file, heritageNode, ifdecls, shortCircuit)) 203 | } // else just ignore it 204 | } 205 | } 206 | } 207 | 208 | // build info.props 209 | ifnode.forEachChild(n => { 210 | if (ts.isPropertySignature(n)) { 211 | const prop = createTSTypeProp(n, file, info) 212 | info.props[prop.name] = prop 213 | } 214 | }) 215 | 216 | return info 217 | } 218 | 219 | 220 | function createTSTypeProp( 221 | n :TS.PropertySignature, 222 | file :SourceFile, 223 | parent :TSInterface, 224 | ) :TSTypeProp { 225 | // console.log("PropertySignature", n.name.escapedText, n) 226 | const pos = ts.getLineAndCharacterOfPosition(file, n.pos) 227 | 228 | let _typestr :string|null = null 229 | const _type = n.type 230 | const name = propName(n.name) 231 | 232 | const typeprop = { 233 | name, 234 | type: _type, 235 | get typestr() :string { 236 | if (_typestr === null) { 237 | _typestr = _type ? fmt(_type, file) : "any" 238 | } 239 | Object.defineProperty(typeprop, "typestr", {enumerable:true, value:_typestr}) 240 | return _typestr 241 | }, 242 | srcfile: file.fileName, 243 | srcline: pos.line, 244 | srccol: pos.character, 245 | parent, 246 | } 247 | return typeprop 248 | } 249 | 250 | 251 | function propName(n :TS.PropertyName) :string { 252 | switch (n.kind) { 253 | 254 | case ts.SyntaxKind.Identifier: 255 | case ts.SyntaxKind.PrivateIdentifier: 256 | return n.escapedText as string 257 | 258 | case ts.SyntaxKind.StringLiteral: 259 | case ts.SyntaxKind.NumericLiteral: 260 | return n.text 261 | 262 | case ts.SyntaxKind.ComputedPropertyName: 263 | // TODO printer 264 | return "[computed]" 265 | 266 | default: 267 | return "?" 268 | } 269 | } 270 | 271 | 272 | // returns all top-level interface declarations in file 273 | function topLevelInterfaceDeclarations(file :SourceFile) :Map { 274 | const m = new Map() 275 | ts.forEachChild(file, n => { 276 | if (n.kind == ts.SyntaxKind.InterfaceDeclaration) { 277 | m.set( 278 | (n as InterfaceDeclaration).name.escapedText as string, 279 | n as InterfaceDeclaration, 280 | ) 281 | } else { 282 | // console.log("unhandled n in switch:", ts.SyntaxKind[n.kind]) 283 | } 284 | }) 285 | return m 286 | } 287 | 288 | 289 | const basicPrinter = ts.createPrinter({ 290 | removeComments: true, 291 | newLine: ts.NewLineKind.LineFeed, 292 | omitTrailingSemicolon: true, 293 | noEmitHelpers: true, 294 | }) 295 | 296 | 297 | /*EXPORT*/ function fmt(node :TS.Node, file? :SourceFile) :string { 298 | if (!file) { 299 | // find source file by walking up the AST 300 | let n = node 301 | while (n.kind != ts.SyntaxKind.SourceFile) { 302 | n = n.parent 303 | if (!n) { 304 | throw new Error("node without SourceFile parent (provide file to ts.fmt)") 305 | } 306 | } 307 | file = n as TS.SourceFile 308 | } 309 | return basicPrinter.printNode(ts.EmitHint.Unspecified, node, file) 310 | } 311 | 312 | return { 313 | ts, 314 | getCompilerHost, 315 | parse, 316 | parseFile, 317 | interfaceInfo, 318 | interfacesInfo, 319 | interfacesInfoAST, 320 | fmt, 321 | } 322 | 323 | } 324 | 325 | // const programCache = new Map() // {srcfile:{options:program}} 326 | // function getProgram(srcfiles :string[], options: CompilerOptions) { 327 | // const cacheKey = srcfiles.map(f => Path.resolve(f)).join(":") + "\n" + ( 328 | // Object.keys(options).sort().map(k => `${options[k]}\n`) 329 | // ) 330 | // let prog = programCache.get(cacheKey) 331 | // if (!prog) { 332 | // prog = ts.createProgram(srcfiles, options) 333 | // programCache.set(cacheKey, prog) 334 | // } 335 | // return prog 336 | // } 337 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import * as Path from "path" 2 | import { json } from "./util" 3 | 4 | 5 | // parse CLI program name (as invoked) 6 | export const prog = (() :string => { 7 | const $_ = process.env["_"] 8 | const scriptfile = process.argv[1] 9 | if (!scriptfile) { 10 | // unlikely 11 | return $_ || process.argv[0] 12 | } 13 | if ($_ && !Path.isAbsolute($_)) { 14 | // accurate in some shells (like bash, but not in zsh) 15 | return $_ 16 | } 17 | let prefix = "" 18 | if ($_) { 19 | const nodeExecName = Path.basename(process.execPath) 20 | if ($_.endsWith(Path.sep + nodeExecName)) { 21 | // the script was invoked by explicitly calling node. 22 | // e.g. "node build.js" 23 | prefix = nodeExecName + " " 24 | } 25 | } 26 | if (scriptfile.startsWith(process.cwd())) { 27 | let rel = Path.relative(process.cwd(), scriptfile) 28 | if (!rel.startsWith("node_modules"+Path.sep) && 29 | rel.indexOf(Path.sep+"node_modules"+Path.sep) == -1 30 | ) { 31 | if (Path.sep == "/") { 32 | // on posix systems, this is needed to avoid PATH resolution 33 | rel = "./" + rel 34 | } 35 | return rel 36 | } 37 | } 38 | return prefix + Path.basename(scriptfile) 39 | })() 40 | 41 | 42 | export function printUsageAndExit(usage :string, errmsg? :string|null) { 43 | const msg = usage.trim().replace(/\$0\b/g, prog) 44 | if (errmsg) { 45 | console.error(`${prog}: ${errmsg}\n` + msg) 46 | process.exit(1) 47 | } else { 48 | console.log(msg) 49 | process.exit(0) 50 | } 51 | } 52 | 53 | // parseopt types 54 | export interface Doc { 55 | usage? :Usage|null 56 | flags :Flags[] 57 | trailer? :string 58 | 59 | // if true, treat an unknown flag as an argument (no error) 60 | unknownFlagAsArg? :boolean 61 | 62 | // help is a function which is invoked INSTEAD OF printing help and exiting the process. 63 | // The function receives three values: 64 | // flags -- available flags 65 | // options -- flag values parsed so far 66 | // args -- remaining, unprocessed input arguments 67 | // options and args are the same values returned by parseopt() 68 | // 69 | help? :( (flags: FlagInfo[], options :Options, args :string[]) => void ) | null 70 | } 71 | export type Usage = string | (()=>string) 72 | export type Flags = (Flag | null | undefined | false)[] // falsy elements are ignored 73 | export type Flag = string | [ string|string[] , string?, string? ] 74 | export interface FlagInfo { 75 | names :string[] 76 | description? :string 77 | valueName? :string 78 | valueType? :string 79 | valueParser? :(v:string)=>any 80 | } 81 | export type Options = { [k :string] :any } 82 | 83 | // parseopt parses command-line arguments. 84 | // Returns options and unparsed remaining arguments. 85 | // 86 | // flag format: 87 | // 88 | // flag = flagname | flagspec 89 | // flagname = "-"* 90 | // flagnames = Array< flagname+ > 91 | // flagspec = Tuple< flagnames | flagname > 92 | // 93 | // flag format examples: 94 | // 95 | // "verbose" 96 | // Simple boolean flag that can be set with -verbose or --verbose. 97 | // 98 | // [ "v", "Show version" ] 99 | // Boolean flag "v" with description text shown in program usage. 100 | // 101 | // [ "v, version", "Show version" ] 102 | // [ ["v", "version"], "Show version" ] 103 | // Boolean flag "v" with alternate name "version" with description. 104 | // 105 | // [ ["v", "version"] ] 106 | // Boolean flag "v" with alternate name "version" without description. 107 | // 108 | // [ "o", "Output file", "" ] 109 | // Value flag with description. Value type defaults to string. 110 | // Can be invoked as -o=path, --o=path, -o path, and --o path. 111 | // 112 | // [ "o", "", "" ] 113 | // Value flag without description. 114 | // 115 | // [ "limit", "Show no more than items", "" ] 116 | // Value flag with type constraint. Passing a value that is not a JS number 117 | // causes an error message. 118 | // 119 | // [ "with-openssl", "", "enable:bool" ] 120 | // Boolean flag 121 | // 122 | export function parseopt(argv :string[], doc :Doc) :[Options, string[]] { 123 | let [flagmap, opts] = parseFlags(doc.flags.filter(f => f) as Flag[]) 124 | let options :Options = {} 125 | let help = false 126 | let args :string[] = [] 127 | let i = 0 128 | 129 | const eatArg = () => { 130 | args.push(argv.splice(i, 1)[0]) 131 | i-- 132 | } 133 | 134 | for (; i < argv.length; i++) { 135 | // read argument 136 | let arg = argv[i] 137 | if (arg == '--') { 138 | i++ 139 | break 140 | } 141 | if (arg[0] != '-' || arg == '-') { 142 | eatArg() 143 | continue 144 | } 145 | arg = arg.replace(/^\-+/, '') 146 | let eqp = arg.indexOf('=') 147 | let argval :string|undefined = undefined 148 | if (eqp != -1) { 149 | // e.g. -name=value 150 | argval = arg.substr(eqp + 1) 151 | arg = arg.substr(0, eqp) 152 | } 153 | 154 | // lookup flag 155 | let opt = flagmap.get(arg) 156 | if (!opt) { 157 | if (arg == "h" || arg == "help") { 158 | help = true 159 | if (!doc.help) { 160 | console.log(fmtUsage(opts, doc.usage, doc.trailer)) 161 | process.exit(0) 162 | } 163 | } else if (doc.unknownFlagAsArg) { 164 | eatArg() 165 | continue 166 | } else { 167 | printUnknownOptionsAndExit([argv[i]]) 168 | } 169 | break 170 | } 171 | 172 | // save option 173 | let value :any = true 174 | if (opt.valueName) { 175 | if (argval === undefined) { 176 | // -k v 177 | argval = argv[i + 1] 178 | if (argval !== undefined && argval[0] != "-") { 179 | i++ 180 | // } else if (opt.valueType == "boolean") { 181 | // argval = "true" 182 | } else { 183 | console.error(`missing value for option -${arg} (see ${prog} -help)`) 184 | process.exit(1) 185 | break 186 | } 187 | } // else -k=v 188 | try { 189 | value = opt.valueParser ? opt.valueParser(argval) : argval 190 | } catch (err) { 191 | console.error(`invalid value for option -${arg} (${err.message})`) 192 | } 193 | } else if (argval !== undefined) { 194 | console.error(`unexpected value provided for flag -${arg}`) 195 | process.exit(1) 196 | } // else: e.g. -k 197 | 198 | options[arg] = value 199 | 200 | // alias spread 201 | for (let alias of opt.names) { 202 | if (alias == arg) { 203 | continue 204 | } 205 | options[alias] = value 206 | } 207 | 208 | } // for (; i < argv.length; i++) 209 | 210 | if (i < argv.length) { 211 | args = args.concat(argv.slice(i)) 212 | } 213 | 214 | if (help && doc.help) { 215 | doc.help(opts, options, args) 216 | } 217 | 218 | return [options, args] 219 | } 220 | 221 | 222 | export function printUnknownOptionsAndExit(args :string[]) { 223 | console.error( 224 | `unknown option${args.length > 1 ? "s" : ""} ${args.join(", ")} (see ${prog} -help)`) 225 | process.exit(1) 226 | } 227 | 228 | 229 | // parseFlags parses falgs and returns normalized structured options. 230 | // Returns: 231 | // [0] Mapping of argument name (e.g. "help") to options. 232 | // [1] Unique set of options (e.g. {flags:["h","help"],...}). 233 | // 234 | export function parseFlags(flags :Flag[]) :[ Map, FlagInfo[] ] { 235 | let fimap = new Map() 236 | let fiv :FlagInfo[] = [] 237 | for (let f of flags) { 238 | let fi = parseFlag(f) 239 | fiv.push(fi) 240 | for (let k of fi.names) { 241 | if (fimap.has(k)) { 242 | throw new Error(`duplicate CLI flag ${json(k)} in definition ${json(f)}`) 243 | } 244 | fimap.set(k, fi) 245 | } 246 | } 247 | return [fimap, fiv] 248 | } 249 | 250 | 251 | function parseFlag(f :Flag) :FlagInfo { 252 | const cleanFlag = (s :string) => s.replace(/(?:^|[\s,])\-+/g, '') 253 | const splitComma = (s :string) => s.split(/\s*,\s*/) 254 | 255 | if (typeof f == "string") { 256 | return { names: splitComma(cleanFlag(f)) } 257 | } 258 | 259 | let o :FlagInfo = { 260 | names: ( 261 | typeof f[0] == "string" ? splitComma(cleanFlag(f[0])) : 262 | f[0].map(cleanFlag) 263 | ), 264 | description: f[1] || undefined 265 | } 266 | 267 | if (f[2]) { 268 | let [name, type] = f[2].replace(/^[<>]+|[<>]+$/g, '').split(/:/, 2) 269 | if (type) { 270 | switch (type.toLowerCase()) { 271 | 272 | case 'string': 273 | case 'str': 274 | type = 'string' 275 | break 276 | 277 | case 'bool': 278 | case 'boolean': 279 | type = 'boolean' 280 | o.valueParser = s => { 281 | s = s.toLowerCase() 282 | return s != "false" && s != "0" && s != "no" && s != "off" 283 | } 284 | break 285 | 286 | case 'number': 287 | case 'num': 288 | case 'float': 289 | case 'int': 290 | type = 'number' 291 | o.valueParser = s => { 292 | let n = Number(s) 293 | if (isNaN(n)) { 294 | throw new Error(`${json(s)} is not a number`) 295 | } 296 | return n 297 | } 298 | break 299 | 300 | default: 301 | throw new Error(`invalid argument type "${type}"`) 302 | } 303 | } else { 304 | type = "string" 305 | } 306 | o.valueName = name || type 307 | o.valueType = type 308 | } 309 | return o 310 | } 311 | 312 | 313 | export function fmtUsage(opts :FlagInfo[], usage? :Usage|null, trailer? :string) :string { 314 | // s/$name/value/ 315 | let vars :{[k:string]:any} = { 316 | prog: prog, 317 | "0": prog, 318 | } 319 | const subvars = (s :string) :string => s.replace(/\$(\w+)/g, (_, v) => { 320 | let sub = vars[v] 321 | if (!sub) { 322 | throw new Error(`unknown variable $${v} (to print a dollar sign, use '\\$')`) 323 | } 324 | return sub 325 | }) 326 | 327 | // start with usage 328 | let s = subvars( 329 | usage ? 330 | typeof usage == 'function' ? usage() : 331 | String(usage) : 332 | opts.length > 0 ? 333 | `Usage: $prog [options]` : 334 | `Usage: $prog` 335 | ) 336 | 337 | if (opts.length > 0) { 338 | s += '\noptions:\n' 339 | let longestFlagName = 0 340 | let flagNames :string[] = [] 341 | 342 | for (let f of opts) { 343 | let flagName = " -" + ( 344 | // -f=,-file= 345 | f.valueName ? 346 | f.names.join("=,-") + "=" + ( 347 | f.valueType == "boolean" ? 'on|off' : 348 | '<' + f.valueName + '>' 349 | ) : 350 | // -f, -file 351 | f.names.join(", -") 352 | ) 353 | longestFlagName = Math.max(longestFlagName, flagName.length) 354 | flagNames.push(flagName) 355 | } 356 | 357 | for (let i = 0; i < opts.length; i++) { 358 | let f = opts[i] 359 | let names = flagNames[i] 360 | let descr = f.description 361 | if (!f.description) { 362 | // default to "Set flagname" ("Enable flagname" for bool flags) 363 | descr = f.valueType ? "Set " : "Enable " + f.names.reduce( 364 | (a,s) => (s.length > a.length ? s : a), // pick longest name 365 | "" 366 | ) 367 | } 368 | s += `${names.padEnd(longestFlagName, " ")} ${descr}` 369 | if (i + 1 < opts.length) { 370 | s += "\n" 371 | } 372 | } 373 | } 374 | 375 | // end with trailer 376 | if (trailer) { 377 | s += "\n" + subvars(trailer.replace(/[\n\s]+$/, "")) 378 | } 379 | 380 | return s 381 | } 382 | 383 | -------------------------------------------------------------------------------- /src/tslint.js: -------------------------------------------------------------------------------- 1 | import * as Path from "path" 2 | import * as fs from "fs" 3 | import { spawn } from "child_process" 4 | 5 | import { json, jsonparseFile, findInPATH } from "./util" 6 | import { stdoutStyle, stderrStyle } from "./termstyle" 7 | import { screen } from "./screen" 8 | import { findTSC, findTSConfigFile } from "./tsutil" 9 | import { UserError } from "./error" 10 | import log from "./log" 11 | 12 | const { dirname, basename } = Path 13 | 14 | 15 | // defaultTSRules maps TS diagnostics codes to severity levels. 16 | // The special value IGNORE can be used to completely silence a diagnostic. 17 | // For diagnostic codes not listed, the default DiagnosticCategory for a 18 | // certain diagnostic is used. 19 | export const defaultTSRules = { 20 | 6031: "IGNORE", // starting compilation 21 | 6194: "IGNORE", // Found N errors. Watching for file changes. 22 | 6133: "WARNING", // unused variable, parameter or import 23 | 2531: "WARNING", // Object is possibly 'null' 24 | 7006: "WARNING", // Parameter 'x' implicitly has an 'any' type. 25 | 7015: "WARNING", // Element implicitly has an 'any' type because index expression is not ... 26 | 7053: "WARNING", // Element implicitly has an 'any' type because expression of type can't be ... 27 | } 28 | 29 | 30 | const IGNORE = 0 31 | , INFO = 1 32 | , WARNING = 2 33 | , ERROR = 3 34 | 35 | 36 | const severities = {IGNORE,INFO,WARNING,ERROR} 37 | 38 | 39 | function addTSRules(dst, src) { 40 | for (let k of Object.keys(src)) { 41 | let v = severities[String(src[k]).toUpperCase()] 42 | if (v === undefined) { 43 | throw new UserError( 44 | `Invalid value for TS rule ${k}: ${json(v)} -- expected value to be one of: `+ 45 | Object.keys(severities).map(json).join(", ") 46 | ) 47 | } 48 | dst[k] = v 49 | } 50 | } 51 | 52 | 53 | // returns a promise which resolves to a boolean "no errors", when the TSC process ends. 54 | // Note that in watch mode, the promise only resolves after explicitly calling cancel. 55 | // The returned promise is cancellable. I.e. p.cancel() 56 | // 57 | export function tslint(options /*:TSLintOptions*/) { 58 | if (!options) { options = {} } 59 | let cancellation = { 60 | cancelled: false, 61 | cancel(){}, 62 | } 63 | let p = new Promise((resolve, reject) => { 64 | 65 | if (options.mode == "off") { 66 | return resolve(true) 67 | } 68 | 69 | const cwd = options.cwd || process.cwd() 70 | 71 | // find tsconfig.json file 72 | let tsconfigFile = options.tsconfigFile 73 | if (tsconfigFile === undefined) { 74 | // Note: options.tsconfigFile=null|"" means "explicitly no ts config file" 75 | tsconfigFile = findTSConfigFile(options.srcdir ? Path.resolve(cwd, options.srcdir) : cwd) 76 | } 77 | if (options.mode != "on" && !tsconfigFile) { 78 | // no tsconfig file found -- in auto mode, we consider this "not a TypeScript project". 79 | return resolve(true) 80 | } 81 | 82 | const options_format = options.format ? options.format.toLowerCase() : "" 83 | const logShortInfo = options_format.startsWith("short") 84 | const logShortWarning = options_format.startsWith("short") 85 | const logShortError = options_format == "short-all" 86 | 87 | // find tsc program 88 | let tscprog = findTSC(options.cwd /* ok if undefined */) 89 | if (tscprog == "tsc" && options.mode != "on") { 90 | // look up tsc in PATH 91 | if (!(tscprog = findInPATH(tscprog))) { 92 | // we found a tsconfig.json file but not tsc 93 | log.warn( 94 | `tsc not found in node_modules or PATH. However a tsconfig.json file was found in ` + 95 | Path.relative(process.cwd(), dirname(tsconfigFile)) + `.` + 96 | ` Set tslint options.tslint="off" or pass -no-diag on the command line to disable tsc.` 97 | ) 98 | return resolve(true) 99 | } 100 | } 101 | 102 | // rules 103 | const tsrules = {} 104 | addTSRules(tsrules, defaultTSRules) 105 | if (options.rules) { 106 | addTSRules(tsrules, options.rules) 107 | } 108 | 109 | // CLI arguments 110 | let args = [ 111 | "--noEmit", 112 | options.colors && "--pretty", 113 | options.watch && "--watch", 114 | tsconfigFile && "--project", tsconfigFile, 115 | ].concat(options.args || []).filter(a => a) 116 | 117 | log.debug(() => `spawning process ${tscprog} ${json(args,2)}`) 118 | 119 | // spawn tsc process 120 | const p = spawn(tscprog, args, { 121 | stdio: ['inherit', 'pipe', 'inherit'], 122 | cwd, 123 | }) 124 | 125 | // kill process on exit to avoid EPIPE errors 126 | const onProcessExitHandler = () => { 127 | try { p.kill() } catch (_) {} 128 | } 129 | process.on('exit', onProcessExitHandler) 130 | 131 | // cancellation handler 132 | cancellation.cancel = () => { 133 | // called just once (guarded by user cancel function) 134 | p.kill() 135 | } 136 | 137 | const infoStyle = s => s 138 | , warnStyle = stdoutStyle.orange 139 | , errorStyle = stdoutStyle.red 140 | , okStyle = stdoutStyle.green 141 | 142 | const _TS_buf = Buffer.from(" TS") 143 | const Found__buf = Buffer.from("Found ") 144 | const ANSI_clear_buf = Buffer.from("\x1bc") 145 | const Starting_compilation_buf = Buffer.from("tarting compilation") 146 | const Starting_incremental_compilation_buf = Buffer.from("tarting incremental compilation") 147 | 148 | const tsmsgbuf = [] 149 | let tscode = 0 150 | let lastRunHadErrors = false 151 | let stats = { 152 | errors: 0, 153 | warnings: 0, 154 | other: 0, 155 | reset() { 156 | this.errors = 0 157 | this.warnings = 0 158 | this.other = 0 159 | }, 160 | } 161 | 162 | let isIdle = false 163 | 164 | 165 | function onSessionEnd() { 166 | if (!options.quiet || stats.errors >= 0) { 167 | options.watch && console.log(screen.banner("—")) 168 | let summary = [] 169 | if (stats.errors > 0) { 170 | summary.push(errorStyle("TS: " + plural(`$ error`, `$ errors`, stats.errors))) 171 | } else { 172 | summary.push(okStyle("TS: OK")) 173 | } 174 | if (stats.warnings > 0) { 175 | summary.push(warnStyle(plural(`$ warning`, `$ warnings`, stats.warnings))) 176 | } 177 | if (stats.other > 0) { 178 | summary.push(plural(`$ message`, `$ messages`, stats.other)) 179 | } 180 | console.log(summary.join(" ")) 181 | options.watch && console.log(screen.banner("—")) 182 | } 183 | lastRunHadErrors = stats.errors > 0 184 | options.onEnd && options.onEnd(stats) 185 | stats.reset() 186 | isIdle = true 187 | } 188 | 189 | 190 | // called when tsmsgbuf contains one or more lines of one TypeScript message. 191 | function flushTSMessage(compilationPassCompleted) { 192 | // console.log(`------------------- TS${tscode} ------------------`) 193 | // console.log({ tsmsgbuf: tsmsgbuf.map(b => b.toString("utf8")) }) 194 | 195 | // reset buffer 196 | let lines = tsmsgbuf.slice() 197 | tsmsgbuf.length = 0 198 | 199 | if (tscode == 0) { 200 | 201 | // pick the first non-empty line 202 | let i = 0 203 | let line0 = lines[i++] 204 | while (line0.length == 0 || line0[0] == 0x0A && i < lines.length) { 205 | line0 = lines[i++] 206 | } 207 | 208 | // check if the line is the "starting" message 209 | if (line0.includes(Starting_compilation_buf) || 210 | line0.includes(Starting_incremental_compilation_buf) 211 | ) { 212 | stats.reset() 213 | // ignore "Starting compilation [in watch mode...]" message 214 | // alt spelling in more recent typescript versions: 215 | // "Starting incremental compilation..." 216 | return compilationPassCompleted && onSessionEnd() 217 | } 218 | 219 | if (lines.every(line => line.length <= 1)) { 220 | // ignore empty message 221 | return compilationPassCompleted && onSessionEnd() 222 | } 223 | } else { 224 | const errorRe = /(?:\x1b\[\d+m|)error(?:\x1b\[\d+m|)/g 225 | let line0 = lines.shift().toString("utf8") 226 | // console.log("TSLINT", {line0, tscode, sev: tsrules[tscode]}) 227 | 228 | switch (tsrules[tscode]) { 229 | case IGNORE: return compilationPassCompleted && onSessionEnd() 230 | 231 | case INFO: 232 | // rewrite potentially ANSI-colored first line "error" 233 | line0 = line0.replace(errorRe, infoStyle("info")) 234 | if (logShortInfo) { 235 | lines = [] 236 | } else { 237 | restyleSrcLineWaves(lines, infoStyle) 238 | } 239 | stats.other++ 240 | break 241 | 242 | case WARNING: 243 | // rewrite potentially ANSI-colored first line "error" 244 | line0 = line0.replace(errorRe, warnStyle("warning")) 245 | if (logShortWarning) { 246 | lines = [] 247 | } else { 248 | restyleSrcLineWaves(lines, warnStyle) 249 | } 250 | stats.warnings++ 251 | break 252 | 253 | default: // ERROR or other 254 | if (logShortError) { 255 | lines = [] 256 | } 257 | if (errorRe.test(line0)) { 258 | stats.errors++ 259 | } else { 260 | stats.other++ 261 | } 262 | break 263 | } 264 | process.stdout.write(line0) 265 | } 266 | 267 | // write lines to stdout 268 | lines.forEach(v => process.stdout.write(v)) 269 | 270 | compilationPassCompleted && onSessionEnd() 271 | } 272 | 273 | 274 | function restyleSrcLineWaves(lines, stylefn) { 275 | for (let i = 1; i < lines.length; i++) { 276 | let line = lines[i] 277 | if (line.includes(0x7e)) { // ~ 278 | let s = line.toString("utf8") // "\x1b[91m" 279 | s = s.replace(/\x1b\[\d+m(\s*~+)/g, stylefn("$1")) 280 | lines[i] = s // ok to set string instead of Buffer 281 | } 282 | } 283 | } 284 | 285 | 286 | function plural(singular, plural, n) { 287 | return (n == 1 ? singular : plural).replace(/\$/g, n) 288 | } 289 | 290 | lineReader(p.stdout, (line, flush) => { 291 | if (!options.clearScreen) { 292 | line = stripANSIClearCode(line) 293 | } 294 | if (flush) { 295 | if (line.length > 0) { 296 | tsmsgbuf.push(line) 297 | } 298 | if (tsmsgbuf.length > 0) { 299 | flushTSMessage() 300 | } 301 | return 302 | } 303 | 304 | if (isIdle && line.length > 1) { 305 | // first non-empty line after isIdle state has been entered marks the start of 306 | // a new session. 307 | isIdle = false 308 | options.onRestart && options.onRestart() 309 | } 310 | 311 | if (line.includes(Found__buf)) { 312 | let s = stripANSICodesStr(line.toString("utf8")) 313 | if (/^(?:\[[^\]]+\] |[\d\:PAM \-]+|)Found \d+ error/.test(s)) { 314 | // TypeScript has completed a compilation pass 315 | flushTSMessage(true) 316 | tscode = 0 317 | return // don't add this line to line buffer 318 | } else { 319 | flushTSMessage(false) 320 | } 321 | tscode = 0 322 | } else { 323 | // console.log("--> " + line.subarray(0, line.length-1).toString("utf8")) 324 | if (line.includes(_TS_buf)) { 325 | const s = line.toString("utf8") 326 | const m = /(?:\x1b\[\d+m|)error(?:\x1b\[\d+m\x1b\[\d+m|) TS(\d+)\:/.exec(s) 327 | // const m = /(?:\x1b\[\d+m|)error(?:\x1b\[\d+m|) TS(\d+)\:/.exec(s) 328 | let tscode2 = m ? parseInt(m[1]) : 0 329 | if (tscode2 > 0 && !isNaN(tscode2)) { 330 | if (tsmsgbuf.length > 0) { 331 | flushTSMessage() 332 | } 333 | tscode = tscode2 334 | } 335 | } 336 | } 337 | tsmsgbuf.push(line) 338 | }) 339 | 340 | // lineReader(p.stderr, line => { 341 | // process.stderr.write(line) 342 | // }) 343 | 344 | p.on('close', code => { 345 | // console.log(`tsc exited with code ${code}`) 346 | process.removeListener('exit', onProcessExitHandler) 347 | resolve(!lastRunHadErrors) 348 | }) 349 | 350 | function stripANSICodesStr(s) { 351 | return s.replace(/\x1b\[\d+m/g, "") 352 | } 353 | 354 | function stripANSIClearCode(buf) { 355 | // strip "clear" ANSI code is present in buf 356 | let i = buf.indexOf(ANSI_clear_buf) 357 | return ( 358 | i == -1 ? buf : 359 | i == 0 ? buf.subarray(3) : 360 | Buffer.concat([buf.subarray(0,i), buf.subarray(i+3)], buf.length - 3) 361 | ) 362 | } 363 | }) // Promise 364 | p.cancel = () => { 365 | if (!cancellation.cancelled) { 366 | cancellation.cancelled = true 367 | cancellation.cancel() 368 | } 369 | return p 370 | } 371 | return p 372 | } // end function tslint 373 | 374 | const emptyBuffer = Buffer.allocUnsafe(0) 375 | 376 | // TODO replace this with io.readlines 377 | function lineReader(r, onLine) { 378 | let bufs = [], bufz = 0 379 | const readbuf = data => { 380 | let offs = 0 381 | while (true) { 382 | let i = data.indexOf(0x0A, offs) 383 | if (i == -1) { 384 | if (offs < data.length - 1) { 385 | const chunk = data.subarray(offs) 386 | bufs.push(chunk) 387 | bufz += chunk.length 388 | } 389 | break 390 | } 391 | i++ 392 | let buf = data.subarray(offs, i) 393 | if (bufz > 0) { 394 | buf = Buffer.concat(bufs.concat(buf), bufz + buf.length) 395 | bufs.length = 0 396 | bufz = 0 397 | } 398 | onLine(buf, false) 399 | offs = i 400 | } 401 | } 402 | const flush = () => { 403 | if (bufs.length > 0) { 404 | onLine(Buffer.concat(bufs, bufz), true) 405 | } else { 406 | onLine(emptyBuffer, true) 407 | } 408 | } 409 | 410 | // TEST 411 | // readbuf(Buffer.from("hello")) 412 | // readbuf(Buffer.from(" world\n")) 413 | // readbuf(Buffer.from("How")) 414 | // readbuf(Buffer.from("'s ")) 415 | // readbuf(Buffer.from("it go")) 416 | // readbuf(Buffer.from("ing?\n")) 417 | // readbuf(Buffer.from("quite well\nI hope!\nBye\n")) 418 | // readbuf(Buffer.from("bye.")) 419 | // flush() 420 | // lineReader(0, line => { 421 | // console.log({line:line.toString("utf8")}) 422 | // }) 423 | 424 | r.on("data", readbuf) 425 | r.on("close", flush) 426 | r.on("end", flush) 427 | } 428 | --------------------------------------------------------------------------------