├── .github ├── CODE_OF_CONDUCT.md └── workflows │ ├── npm-publish.yml │ └── test.yml ├── .gitignore ├── .swcrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── jest.config.js ├── just.tsconfig.json ├── package-lock.json ├── package.json ├── register.js ├── src ├── commands │ ├── build.ts │ ├── dev.ts │ └── run.ts ├── just.ts ├── libs │ ├── config.ts │ ├── register.ts │ ├── server.ts │ ├── swc.ts │ └── typescript.ts └── utils │ ├── file.ts │ └── logger.ts ├── tests ├── libs │ ├── config.test.ts │ ├── server.test.ts │ ├── swc.test.ts │ └── typescript.test.ts └── utils │ ├── file.test.ts │ └── logger.test.ts └── tsconfig.json /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of the level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: '20.x' 16 | registry-url: 'https://registry.npmjs.org' 17 | env: 18 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Build 24 | run: npm run build 25 | 26 | - name: Test 27 | run: npm test 28 | 29 | - name: Publish 30 | run: npm publish 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: '20.x' 20 | registry-url: 'https://registry.npmjs.org' 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Test 26 | run: npm test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | .turbo -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "sourceMaps": "inline", 3 | "minify": false, 4 | "exclude": [ 5 | ".*\\.d\\.ts$" 6 | ], 7 | "module": { 8 | "type": "commonjs", 9 | "noInterop": false 10 | }, 11 | "jsc": { 12 | "baseUrl": "./src", 13 | "target": "es2017", 14 | "loose": false, 15 | "keepClassNames": true, 16 | "parser": { 17 | "syntax": "typescript", 18 | "tsx": false, 19 | "decorators": true, 20 | "dynamicImport": true 21 | }, 22 | "transform": { 23 | "legacyDecorator": true, 24 | "decoratorMetadata": true 25 | }, 26 | "minify": { 27 | "compress": false, 28 | "mangle": false 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "vscode.typescript-language-features", 4 | "[json]": { 5 | "editor.defaultFormatter": "vscode.json-language-features" 6 | } 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sonny T. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Just ![GitHub tag (latest SemVer pre-release)](https://img.shields.io/github/v/tag/sonnyt/just?include_prereleases) ![NPM Version](https://img.shields.io/npm/v/%40sonnyt%2Fjust) ![GitHub](https://img.shields.io/github/license/sonnyt/just) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/sonnyt/just/test.yml) 2 | Zero config TypeScript build and development toolkit. 3 | 4 | ## Features 5 | - Fast [SWC](https://swc.rs/) compiler 6 | - TypeScript type check support 7 | - Live reload support 8 | - `.env` file support 9 | - Path alias support 10 | - Typescript script runner 11 | - REPL support 12 | 13 | ## Install 14 | ```shell 15 | # Locally in your project. 16 | npm install -D @sonnyt/just 17 | 18 | # Or globally 19 | npm install -g @sonnyt/just 20 | ``` 21 | 22 | ## Usage 23 | To start a dev server in the root of your project just (😉) run: 24 | ```shell 25 | just dev 26 | ``` 27 | 28 | To build: 29 | ```shell 30 | just build 31 | ``` 32 | 33 | ## Commands 34 | 35 | ### Build 36 | `just build [options] [files]` 37 | 38 | Compiles the application for production deployment. 39 | 40 | **Arguments** 41 | - `files` - glob file path to compile. If not present, `includes` from config.json is used. 42 | 43 | **Options** 44 | |Option|Default|Description| 45 | |:--|:--|:--| 46 | |`--transpile-only`|off|disables type checking| 47 | |`--out-dir `|`compilerOptions.outDir`|output folder for all emitted files| 48 | |`--no-color`|off|disables output color| 49 | |`--debug`|false|enables debug logging| 50 | |`-c, --config `|[default](#default-typescript-config)|path to typescript configuration file| 51 | 52 | ### Dev 53 | 54 | `just dev [options] [entry]` 55 | 56 | Starts the application in development mode. Watches for any file changes and live reloads the server. 57 | 58 | **Arguments** 59 | - `entry` - server entry path to start. If not present, `main` from package.json is used. 60 | 61 | **Options** 62 | |Option|Default|Description| 63 | |:--|:--|:--| 64 | |`-p, --port `|null|server port used in `process.env.PORT`| 65 | |`--type-check`|false|enables type checking| 66 | |`--no-color`|off|disables output color| 67 | |`--debug`|false|enables debug logging| 68 | |`-c, --config `|[default](#default-typescript-config)|path to typescript configuration file| 69 | 70 | ### Run 71 | `just run [options] [args...]` 72 | 73 | Runs `.ts` file scripts. 74 | 75 | **Arguments** 76 | - `` - script/command to run. 77 | - `[args...]` - arguments passed to the script/command. 78 | 79 | **Options** 80 | |Option|Default|Description| 81 | |:--|:--|:--| 82 | |`--no-color`|off|disables output color| 83 | |`--debug`|false|enables debug logging| 84 | |`-c, --config `|[default](#default-typescript-config)|path to typescript configuration file| 85 | 86 | ## Programmatic 87 | You can require Just runner programmatically two ways: 88 | 89 | Import Just as early as possible in your application code. 90 | ```JS 91 | require('@sonnyt/just/register'); 92 | ``` 93 | 94 | Or you can use the `--require` (`-r`) [command line option](https://nodejs.org/api/cli.html#-r---require-module) to preload Just. By doing this, you do not need to require and load Just in your application code. 95 | 96 | ```shell 97 | node -r @sonnyt/just/register myscript.ts 98 | ``` 99 | 100 | Please note that runner does not support type checking. 101 | 102 | ## Default Config File 103 | Just automatically finds and loads `tsconfig.json` or `jsconfig.json`. By default, this search is performed relative to the entrypoint script. If neither file is found nor the file is not provided as an argument. Just falls back to using default settings shown below. 104 | 105 | ```JSON 106 | { 107 | "compilerOptions": { 108 | "module": "CommonJS", 109 | "target": "ES2021", 110 | "moduleResolution": "Node", 111 | "inlineSourceMap": true, 112 | "strict": true, 113 | "baseUrl": "./", 114 | "experimentalDecorators": true, 115 | "emitDecoratorMetadata": true, 116 | "esModuleInterop": true, 117 | "skipLibCheck": true, 118 | "importHelpers": true, 119 | "outDir": "dist", 120 | "paths": {} 121 | }, 122 | "include": [ 123 | "./" 124 | ], 125 | "exclude": [ 126 | "node_modules" 127 | ] 128 | } 129 | ``` 130 | 131 | ## Environment Variables 132 | When using the [dev](#dev) or [run](#run) commands. Just automatically finds and loads environment variables from a `.env` file into `process.env`. By default, this search is performed relative to the entrypoint script. 133 | 134 | ## Path Alias 135 | Based on the `paths` [configuration](https://www.typescriptlang.org/tsconfig#paths), Just replaces all alias paths with relative paths after typescript compilation. 136 | 137 | ## FAQ 138 | ### Does Just work with ES Modules? 139 | Currently, Just only supports building ES Module files. 140 | 141 | ### What's the REPL use case? 142 | Just REPL enables you to execute TypeScript files on Node.js directly without precompiling. It serves as a replacement for [ts-node](https://www.npmjs.com/package/ts-node). 143 | 144 | ### Does Just compile alias paths? 145 | Out of the box, Just supports build and runtime path aliases. All output file alias imports are replaced with relative paths. 146 | 147 | ### What happens to my non-JavaScript/TypeScript files? 148 | If your source directory includes non-compilable files (i.e., JSON, SVG, etc.), Just automatically copies them into your output directory. 149 | 150 | ### How can I help? 151 | If you would like to contribute, please see the issues labeled as [help-wanted](https://github.com/sonnyt/just/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22). 152 | 153 | ## Roadmap 154 | - Build watch option [#7](https://github.com/sonnyt/just/issues/7) 155 | - Init option - copy over the default config file to the working directory [#5](https://github.com/sonnyt/just/issues/5) 156 | - [TypeScript ESLint](https://typescript-eslint.io/) support [#6](https://github.com/sonnyt/just/issues/6) 157 | - [Prettier](https://www.npmjs.com/package/prettier-eslint) support 158 | - REPL ES module support 159 | - ~~`jsconfig.json` support~~ 160 | - `.swcrc` support 161 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; -------------------------------------------------------------------------------- /just.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2021", 5 | "moduleResolution": "Node", 6 | "inlineSourceMap": true, 7 | "strict": true, 8 | "baseUrl": "./", 9 | "experimentalDecorators": true, 10 | "emitDecoratorMetadata": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "importHelpers": true, 14 | "outDir": "dist", 15 | "paths": {} 16 | }, 17 | "include": [ 18 | "./" 19 | ], 20 | "exclude": [ 21 | "node_modules" 22 | ] 23 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sonnyt/just", 3 | "version": "0.0.6", 4 | "description": "Zero config TypeScript build and development toolkit.", 5 | "files": [ 6 | "README.md", 7 | "LICENSE", 8 | "dist/**/*", 9 | "register.js", 10 | "just.tsconfig.json" 11 | ], 12 | "bin": { 13 | "just": "./dist/just.js" 14 | }, 15 | "scripts": { 16 | "build": "npx swc ./src -d dist", 17 | "watch": "npx swc ./src -d dist -w", 18 | "test": "jest" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/sonnyt/just.git" 23 | }, 24 | "keywords": [ 25 | "typescript", 26 | "watch", 27 | "build", 28 | "development" 29 | ], 30 | "author": { 31 | "name": "Sonny T.", 32 | "email": "mail@sonnyt.com", 33 | "url": "https://sonnyt.com" 34 | }, 35 | "license": "ISC", 36 | "bugs": { 37 | "url": "https://github.com/sonnyt/just/issues" 38 | }, 39 | "homepage": "https://github.com/sonnyt/just#readme", 40 | "dependencies": { 41 | "@swc/core": "1.2.205", 42 | "chokidar": "^3.5.3", 43 | "colors": "^1.4.0", 44 | "commander": "^11.1.0", 45 | "dir-glob": "^3.0.1", 46 | "dotenv": "^16.3.1", 47 | "get-port": "^5.1.1", 48 | "glob": "^10.3.10", 49 | "typescript": "^5.3.3", 50 | "which": "^4.0.0" 51 | }, 52 | "devDependencies": { 53 | "@swc/cli": "^0.1.63", 54 | "@types/dir-glob": "^2.0.3", 55 | "@types/eslint": "^8.44.9", 56 | "@types/glob": "^8.1.0", 57 | "@types/jest": "^29.5.11", 58 | "@types/node": "^20.10.4", 59 | "@types/which": "^3.0.3", 60 | "jest": "^29.7.0", 61 | "ts-jest": "^29.1.1", 62 | "tslib": "^2.6.2" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /register.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | module.exports = require('./dist/libs/register'); 3 | -------------------------------------------------------------------------------- /src/commands/build.ts: -------------------------------------------------------------------------------- 1 | import color from 'colors/safe'; 2 | 3 | import * as log from '../utils/logger'; 4 | import { resolveConfigPath, loadConfig } from '../libs/config'; 5 | import { cleanOutDir, compileFiles, copyStaticFiles } from '../libs/swc'; 6 | import { checkFiles } from '../libs/typescript'; 7 | 8 | interface Options { 9 | transpileOnly: boolean; 10 | outDir?: string; 11 | config: string; 12 | color: boolean; 13 | debug: boolean; 14 | } 15 | 16 | export default async function (filePath: string, options: Options) { 17 | if (options.debug) { 18 | process.env.JUST_DEBUG = 'TRUE'; 19 | log.info('debugger is on'); 20 | } 21 | 22 | if (!options.color) { 23 | color.disable(); 24 | } 25 | 26 | const configPath = resolveConfigPath(options.config); 27 | const config = loadConfig(configPath); 28 | const compilablePaths = filePath ? [filePath] : config.compileFiles; 29 | const copyablePaths = config.staticFiles; 30 | 31 | if (!options.transpileOnly) { 32 | const time = log.timer(); 33 | time.start('type checking...'); 34 | 35 | const typeCheckError = checkFiles(compilablePaths, config.compilerOptions); 36 | 37 | time.end('type check'); 38 | 39 | if (typeCheckError) { 40 | return; 41 | } 42 | } 43 | 44 | const time = log.timer(); 45 | time.start('building...'); 46 | 47 | await cleanOutDir(config.outDir); 48 | 49 | await Promise.all([ 50 | compileFiles(compilablePaths, config.outDir, config.swc), 51 | copyStaticFiles(copyablePaths, config.outDir), 52 | ]); 53 | 54 | time.end('build'); 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/dev.ts: -------------------------------------------------------------------------------- 1 | import color from 'colors/safe'; 2 | 3 | import * as log from '../utils/logger'; 4 | import { watchFiles } from '../utils/file'; 5 | import { loadConfig, resolveConfigPath } from '../libs/config'; 6 | import { checkFile, checkFiles } from '../libs/typescript'; 7 | import { createServer, resolveEntryPath, resolvePort } from '../libs/server'; 8 | 9 | interface Options { 10 | typeCheck: boolean; 11 | port?: string; 12 | config: string; 13 | color: boolean; 14 | debug: boolean; 15 | } 16 | 17 | export default async function (entryFile: string, options: Options) { 18 | if (options.debug) { 19 | process.env.JUST_DEBUG = 'TRUE'; 20 | log.info('debugger is on'); 21 | } 22 | 23 | if (!options.color) { 24 | color.disable(); 25 | } 26 | 27 | const configPath = resolveConfigPath(options.config); 28 | const config = loadConfig(configPath); 29 | 30 | const entryFilePath = resolveEntryPath(entryFile); 31 | 32 | if (!entryFilePath) { 33 | return; 34 | } 35 | 36 | if (options.typeCheck) { 37 | const time = log.timer(); 38 | time.start('type checking...'); 39 | 40 | const typeCheckError = checkFiles(config.compileFiles, config.compilerOptions); 41 | 42 | time.end('type check'); 43 | 44 | if (typeCheckError) { 45 | return; 46 | } 47 | } 48 | 49 | const portNumber = await resolvePort(options.port); 50 | 51 | log.wait('starting server...'); 52 | 53 | const server = createServer(entryFilePath, portNumber, configPath); 54 | 55 | log.event('server started on port: ' + portNumber); 56 | 57 | const watcher = await watchFiles(config.include, config.exclude); 58 | 59 | server.onExit((code) => { 60 | if (code === 0) { 61 | log.event('server stopped'); 62 | 63 | server.stop(); 64 | watcher.stop(); 65 | process.exit(0); 66 | } else { 67 | log.error('server crashed'); 68 | } 69 | }); 70 | 71 | watcher.onChange(async (fileName) => { 72 | if (options.typeCheck) { 73 | const time = log.timer(); 74 | time.start('type checking...'); 75 | 76 | const typeCheckError = checkFile(fileName, config.compilerOptions); 77 | 78 | time.end('type check'); 79 | 80 | if (typeCheckError) { 81 | return; 82 | } 83 | } 84 | 85 | const time = log.timer(); 86 | time.start('restarting server...'); 87 | 88 | server.restart(); 89 | 90 | time.end('restarted server'); 91 | }); 92 | 93 | process.on('SIGINT', () => { 94 | log.wait('shutting down...'); 95 | 96 | watcher.stop(); 97 | server.stop(); 98 | process.exit(process.exitCode); 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /src/commands/run.ts: -------------------------------------------------------------------------------- 1 | import color from 'colors/safe'; 2 | 3 | import * as log from '../utils/logger'; 4 | import { resolveConfigPath } from '../libs/config'; 5 | import { runCommand, runFile } from '../libs/server'; 6 | import { isFile } from '../utils/file'; 7 | 8 | interface Options { 9 | config: string; 10 | color: boolean; 11 | debug: boolean; 12 | } 13 | 14 | export default async function (cmd: string, args: string[], options: Options) { 15 | if (options.debug) { 16 | process.env.JUST_DEBUG = 'TRUE'; 17 | log.info('debugger is on'); 18 | } 19 | 20 | if (!options.color) { 21 | color.disable(); 22 | } 23 | 24 | const configPath = resolveConfigPath(options.config); 25 | 26 | const time = log.timer(); 27 | 28 | if (isFile(cmd)) { 29 | time.start('running file...'); 30 | 31 | runFile(cmd, configPath); 32 | } else { 33 | time.start('running command...'); 34 | 35 | runCommand(cmd, args, configPath); 36 | } 37 | 38 | time.end(); 39 | 40 | process.on('SIGINT', () => { 41 | log.wait('shutting down...'); 42 | 43 | process.exit(process.exitCode); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/just.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import { Command } from 'commander'; 3 | 4 | import { version } from '../package.json'; 5 | import runAction from './commands/run'; 6 | import buildAction from './commands/build'; 7 | import devAction from './commands/dev'; 8 | 9 | const program = new Command(); 10 | 11 | program.name('just'); 12 | program.version(version); 13 | program.enablePositionalOptions(); 14 | 15 | const run = program.command('run'); 16 | run.description('runs typescript scripts'); 17 | run.argument('', 'command to run'); 18 | run.argument('[args...]'); 19 | run.optsWithGlobals(); 20 | run.passThroughOptions(); 21 | run.action(runAction); 22 | 23 | const dev = program.command('dev'); 24 | dev.description('starts the application in development mode'); 25 | dev.argument('[entry]', 'server entry file'); 26 | dev.option('-p, --port ', 'server port'); 27 | dev.option('--type-check', 'enable type checking'); 28 | dev.action(devAction); 29 | 30 | const build = program.command('build'); 31 | build.description('compiles the application for production deployment'); 32 | build.argument('[files]', 'files to compile'); 33 | build.option('--transpile-only', 'disable type checking'); 34 | build.option('--out-dir ', 'output folder for all emitted files'); 35 | build.action(buildAction); 36 | 37 | // global options 38 | program.commands.forEach((cmd) => { 39 | cmd.option('--no-color', 'disable output color'); 40 | cmd.option('--debug', 'log error messages', process.env.JUST_DEBUG ?? false); 41 | cmd.option( 42 | '-c, --config ', 43 | 'tsconfig.json or jsconfig.json configuration file' 44 | ); 45 | }); 46 | 47 | program.parse(process.argv); 48 | -------------------------------------------------------------------------------- /src/libs/config.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import { existsSync } from 'fs'; 3 | import { resolve } from 'path'; 4 | import type { Options } from '@swc/core'; 5 | 6 | import * as log from '../utils/logger'; 7 | import { createDirGlob, createFileGlob } from '../utils/file'; 8 | import { loadTSConfig } from './typescript'; 9 | 10 | /** 11 | * Configuration file names. 12 | */ 13 | const CONFIG_FILES = ['tsconfig.json', 'jsconfig.json'] as const; 14 | 15 | /** 16 | * Resolves the path to the configuration file. 17 | * If a path is provided, it will be used. 18 | * Otherwise, it will check for the environment variable JUST_TSCONFIG. 19 | * If neither a path nor an environment variable is found, it will search for a default configuration file. 20 | * If no configuration file is found, it will fallback to the default configuration file. 21 | * @param path - Optional path to the configuration file. 22 | * @returns The resolved path to the configuration file. 23 | */ 24 | export function resolveConfigPath(path = process.env.JUST_TSCONFIG ?? process.env.TS_NODE_PROJECT) { 25 | if (path) { 26 | log.debug(`using config file: ${path}`); 27 | return resolve(process.cwd(), path); 28 | } 29 | 30 | const filePath = CONFIG_FILES.find((file) => { 31 | const resolvedPath = resolve(process.cwd(), file); 32 | return existsSync(resolvedPath); 33 | }); 34 | 35 | if (filePath) { 36 | log.debug(`using config file: ${filePath}`); 37 | return filePath; 38 | } 39 | 40 | log.debug( 41 | `config file is missing, falling back to default configuration.` 42 | ); 43 | 44 | return resolve(__dirname, '..', '..', 'just.tsconfig.json'); 45 | } 46 | 47 | /** 48 | * Converts a TypeScript script target to a string representation. 49 | * @param target - The TypeScript script target. 50 | * @returns The string representation of the script target. 51 | */ 52 | export function toTsTarget(target: ts.ScriptTarget) { 53 | switch (target) { 54 | case ts.ScriptTarget.ES3: 55 | return 'es3' 56 | case ts.ScriptTarget.ES5: 57 | return 'es5' 58 | case ts.ScriptTarget.ES2015: 59 | return 'es2015' 60 | case ts.ScriptTarget.ES2016: 61 | return 'es2016' 62 | case ts.ScriptTarget.ES2017: 63 | return 'es2017' 64 | case ts.ScriptTarget.ES2018: 65 | return 'es2018' 66 | case ts.ScriptTarget.ES2019: 67 | return 'es2019' 68 | case ts.ScriptTarget.ES2020: 69 | return 'es2020' 70 | case ts.ScriptTarget.ES2021: 71 | return 'es2021' 72 | case ts.ScriptTarget.ES2022: 73 | case ts.ScriptTarget.ESNext: 74 | case ts.ScriptTarget.Latest: 75 | return 'es2022' 76 | case ts.ScriptTarget.JSON: 77 | return 'es5' 78 | } 79 | } 80 | 81 | /** 82 | * Converts a TypeScript module kind to a string representation. 83 | * @param moduleKind - The TypeScript module kind. 84 | * @returns The string representation of the module kind. 85 | */ 86 | export function toModule(moduleKind: ts.ModuleKind) { 87 | switch (moduleKind) { 88 | case ts.ModuleKind.CommonJS: 89 | return 'commonjs' 90 | case ts.ModuleKind.UMD: 91 | return 'umd' 92 | case ts.ModuleKind.AMD: 93 | return 'amd' 94 | case ts.ModuleKind.ES2015: 95 | case ts.ModuleKind.ES2020: 96 | case ts.ModuleKind.ES2022: 97 | case ts.ModuleKind.ESNext: 98 | case ts.ModuleKind.Node16: 99 | case ts.ModuleKind.NodeNext: 100 | case ts.ModuleKind.None: 101 | return 'es6' 102 | case ts.ModuleKind.System: 103 | throw new TypeError('Do not support system kind module') 104 | } 105 | } 106 | 107 | /** 108 | * Formats the paths object by resolving each path relative to the base URL. 109 | * @param paths - The paths object to be formatted. 110 | * @param baseUrl - The base URL to resolve the paths against. 111 | * @returns The formatted paths object with resolved paths. 112 | */ 113 | export function formatPaths(paths = {}, baseUrl: string) { 114 | return Object 115 | .entries(paths) 116 | .reduce((paths, [key, value]) => { 117 | paths![key] = (value as string[] ?? []).map((path) => resolve(baseUrl, path)); 118 | return paths; 119 | }, {} as Record); 120 | } 121 | 122 | /** 123 | * Converts TypeScript compiler options to SWC configuration options. 124 | * 125 | * @param options - TypeScript compiler options. 126 | * @returns SWC configuration options. 127 | */ 128 | export function convertSWCConfig(options: ts.CompilerOptions): Options { 129 | const target = options.target ?? ts.ScriptTarget.ES2018; 130 | 131 | return { 132 | swcrc: false, 133 | minify: false, 134 | isModule: true, 135 | configFile: false, 136 | cwd: process.cwd(), 137 | sourceMaps: options.sourceMap && options.inlineSourceMap ? 'inline' : Boolean(options.sourceMap), 138 | module: { 139 | noInterop: !options.esModuleInterop, 140 | type: toModule(options.module ?? ts.ModuleKind.ES2015)!, 141 | strictMode: options.strict || options.alwaysStrict || false, 142 | }, 143 | jsc: { 144 | keepClassNames: true, 145 | externalHelpers: false, 146 | target: toTsTarget(target), 147 | baseUrl: resolve(options.baseUrl ?? './'), 148 | paths: formatPaths(options.paths, options.baseUrl ?? './') as any, 149 | parser: { 150 | tsx: !!options.jsx, 151 | dynamicImport: true, 152 | syntax: 'typescript', 153 | decorators: options.experimentalDecorators ?? false, 154 | }, 155 | transform: { 156 | legacyDecorator: true, 157 | decoratorMetadata: options.emitDecoratorMetadata ?? false, 158 | }, 159 | minify: { 160 | compress: false, 161 | mangle: false 162 | }, 163 | }, 164 | }; 165 | } 166 | 167 | /** 168 | * Loads the configuration from the specified path. 169 | * @param path - The path to the configuration file. 170 | * @returns The loaded configuration object. 171 | */ 172 | export function loadConfig(path: string) { 173 | const { compilerOptions, config, fileNames: compileFiles } = loadTSConfig(path); 174 | const swc = convertSWCConfig(compilerOptions); 175 | 176 | const include = createDirGlob(config.include ?? ['./']); 177 | const exclude = createDirGlob(config.exclude ?? ['node_modules']); 178 | const outDir = compilerOptions.outDir ?? 'dist'; 179 | const staticFiles = createFileGlob(include, compileFiles); 180 | 181 | return { swc, outDir, include, exclude, staticFiles, compilerOptions, compileFiles }; 182 | } 183 | -------------------------------------------------------------------------------- /src/libs/register.ts: -------------------------------------------------------------------------------- 1 | import InternalModule from 'module'; 2 | 3 | import { loadConfig, resolveConfigPath } from './config'; 4 | import { compileCode } from './swc'; 5 | 6 | const EXTENSIONS = ['.ts', '.tsx', '.cts', '.mts'] as const; 7 | 8 | type ModuleType = InternalModule & { 9 | _extensions: Record void>; 10 | _compile: (code: string, fileName: string) => unknown; 11 | }; 12 | 13 | const Module = InternalModule as unknown as ModuleType; 14 | 15 | export function register() { 16 | const filePath = resolveConfigPath(); 17 | const config = loadConfig(filePath); 18 | 19 | const jsLoader = Module._extensions['.js']; 20 | 21 | EXTENSIONS.forEach((ext) => { 22 | Module._extensions[ext] = (module: any, fileName: string) => { 23 | const compile = module._compile; 24 | 25 | module._compile = (jsCode: string) => { 26 | const { code } = compileCode(jsCode, fileName, config.swc); 27 | return compile.call(module, code, fileName); 28 | }; 29 | 30 | jsLoader(module, fileName); 31 | }; 32 | }); 33 | } 34 | 35 | export default register(); 36 | -------------------------------------------------------------------------------- /src/libs/server.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { fork, spawnSync } from 'child_process'; 3 | import getPort, { makeRange } from 'get-port'; 4 | import { sync as whichSync } from 'which'; 5 | 6 | import * as log from '../utils/logger'; 7 | import { existsSync } from 'fs'; 8 | 9 | /** 10 | * Retrieves the options for the server. 11 | * 12 | * @param JUST_TSCONFIG - The path to the JUST_TSCONFIG file. 13 | * @param port - The port number for the server (optional). 14 | * @returns The options object for the server. 15 | */ 16 | export function getOptions(JUST_TSCONFIG: string, port?: string | number) { 17 | const flags = [ 18 | process.env['NODE_OPTIONS'], 19 | `-r ${require.resolve('dotenv/config')}`, 20 | `-r ${__dirname}/register.js`, 21 | '--no-warnings', 22 | ]; 23 | 24 | const NODE_OPTIONS = flags.filter((option) => !!option).join(' '); 25 | 26 | log.debug(`using NODE_OPTIONS: ${NODE_OPTIONS}`); 27 | log.debug(`using PORT: ${port}`); 28 | log.debug(`using JUST_TSCONFIG: ${JUST_TSCONFIG}`); 29 | 30 | const options = { 31 | stdio: 'inherit', 32 | windowsHide: true, 33 | env: { ...process.env, NODE_OPTIONS, JUST_TSCONFIG } 34 | } as any; 35 | 36 | if (port) { 37 | options.env.PORT = Number(port); 38 | } 39 | 40 | return options; 41 | } 42 | 43 | /** 44 | * Loads the package.json file from the current working directory. 45 | * @returns The content of the package.json file, or undefined if it cannot be loaded. 46 | */ 47 | export function loadPackageJson() { 48 | const path = resolve(process.cwd(), 'package.json'); 49 | 50 | let content; 51 | 52 | try { 53 | content = require(path); 54 | } catch (error) { } 55 | 56 | return content; 57 | } 58 | 59 | /** 60 | * Resolves the entry path for the server. 61 | * 62 | * @param path - Optional path to the entry file. 63 | * @returns The resolved entry path or undefined if not provided. 64 | * @throws Error if entry path is not provided and JUST_DEBUG environment variable is set. 65 | */ 66 | export function resolveEntryPath(path?: string) { 67 | if (path) { 68 | log.debug(`using entry file: ${path}`); 69 | return resolve(process.cwd(), path); 70 | } 71 | 72 | const packageJson = loadPackageJson(); 73 | 74 | if (packageJson?.main) { 75 | log.debug(`using main entry file from package.json: ${packageJson.main}`); 76 | return resolve(process.cwd(), packageJson.main); 77 | } 78 | 79 | log.error('entry path is not provided'); 80 | 81 | if (process.env.JUST_DEBUG) { 82 | throw new Error('entry path is not provided'); 83 | } 84 | 85 | return undefined; 86 | } 87 | 88 | /** 89 | * Resolves the port number to be used by the server. 90 | * If a specific port is provided, it will be used. 91 | * Otherwise, it checks for the PORT environment variable. 92 | * If not found, it checks for the port specified in package.json. 93 | * If still not found, it uses a random port within the range 3000-3100. 94 | * 95 | * @param port - Optional port number to be used. 96 | * @returns The resolved port number. 97 | */ 98 | export async function resolvePort(port = process.env.PORT) { 99 | if (port) { 100 | log.debug(`using PORT: ${port}`); 101 | return Number(port); 102 | } 103 | 104 | if (process.env.npm_package_config_port) { 105 | log.debug(`using port from package.json: ${process.env.npm_package_config_port}`); 106 | return Number(process.env.npm_package_config_port); 107 | } 108 | 109 | const randomPort = await getPort({ port: makeRange(3000, 3100) }); 110 | 111 | log.debug('using PORT: ' + randomPort); 112 | 113 | return randomPort; 114 | } 115 | 116 | /** 117 | * Creates a server by forking a child process with the specified entry path, port, and config path. 118 | * @param entryPath - The path to the entry file for the server. 119 | * @param port - The port number on which the server should listen. 120 | * @param configPath - The path to the configuration file for the server. 121 | * @returns An object with methods to control the server: 122 | * - `childProcess`: The child process created by forking. 123 | * - `stop()`: Stops the server by killing the child process. 124 | * - `restart()`: Restarts the server by killing the current child process and forking a new one. 125 | * - `onExit(callback)`: Registers a callback function to be called when the child process exits. 126 | */ 127 | export function createServer(entryPath: string, port: number, configPath: string) { 128 | const entry = resolve(process.cwd(), entryPath); 129 | const options = getOptions(configPath, port); 130 | 131 | let childProcess = fork(entry, options); 132 | 133 | return { 134 | childProcess, 135 | stop() { 136 | childProcess.kill(); 137 | }, 138 | restart() { 139 | childProcess.kill(); 140 | childProcess = fork(entry, options); 141 | }, 142 | onExit(callback: (code: number) => void) { 143 | childProcess.on('exit', callback); 144 | } 145 | }; 146 | } 147 | 148 | /** 149 | * Checks if a command exists in the system. 150 | * @param command - The command to check. 151 | * @returns True if the command exists, false otherwise. 152 | */ 153 | export function isCommand(command: string) { 154 | return whichSync(command, { nothrow: true }); 155 | } 156 | 157 | /** 158 | * Runs a command with the specified arguments and configuration path. 159 | * @param command - The command to run. 160 | * @param args - The arguments to pass to the command. 161 | * @param configPath - The path to the configuration file. 162 | * @returns The result of the command execution. 163 | */ 164 | export function runCommand(command: string, args: string[], configPath: string) { 165 | if (!isCommand(command)) { 166 | log.error(`command ${command} does not exist`); 167 | return; 168 | } 169 | 170 | const options = getOptions(configPath); 171 | return spawnSync(command, args, options); 172 | } 173 | 174 | /** 175 | * Runs a file with the specified configuration. 176 | * 177 | * @param filePath - The path of the file to run. 178 | * @param configPath - The path of the configuration file. 179 | * @returns A forked process. 180 | */ 181 | export function runFile(filePath: string, configPath: string) { 182 | if (!existsSync(filePath)) { 183 | log.error(`file ${filePath} does not exist`); 184 | return; 185 | } 186 | 187 | const options = getOptions(configPath); 188 | return fork(filePath, options); 189 | } 190 | -------------------------------------------------------------------------------- /src/libs/swc.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, promises as fs } from "fs"; 2 | import { basename, dirname, extname, join, relative, resolve } from "path"; 3 | import { type Options, DEFAULT_EXTENSIONS, transformFile, transformSync } from "@swc/core"; 4 | 5 | import { copyFile } from "../utils/file"; 6 | import * as log from "../utils/logger"; 7 | 8 | /** 9 | * Checks if a file is compilable based on its extension. 10 | * @param fileName - The name of the file. 11 | * @returns A boolean indicating if the file is compilable. 12 | */ 13 | export function isCompilable(fileName: string) { 14 | const extension = extname(fileName); 15 | return DEFAULT_EXTENSIONS.includes(extension); 16 | } 17 | 18 | /** 19 | * Resolves the output path for a given file name and output directory. 20 | * 21 | * @param fileName - The name of the file. 22 | * @param outDir - The output directory. 23 | * @returns The resolved output path. 24 | */ 25 | export function resolveOutPath(fileName: string, outDir: string, extension?: string) { 26 | const relativePath = relative(process.cwd(), fileName); 27 | const [, ...components] = relativePath.split('/'); 28 | 29 | if (!components.length) { 30 | return join(outDir, fileName); 31 | } 32 | 33 | while (components[0] === '..') { 34 | components.shift(); 35 | } 36 | 37 | const outPath = join(outDir, ...components); 38 | 39 | if (extension) { 40 | return outPath.replace(/\.\w*$/, `.${extension}`); 41 | } 42 | 43 | return outPath; 44 | } 45 | 46 | /** 47 | * Resolves the source file path relative to the output path. 48 | * 49 | * @param outputPath - The output path. 50 | * @param fileName - The file name. 51 | * @returns The resolved source file path. 52 | */ 53 | export function resolveSourceFilePath(outputPath: string, fileName: string) { 54 | return relative(dirname(outputPath), fileName); 55 | } 56 | 57 | /** 58 | * Writes the content to a file with the specified file name. 59 | * If a source map is provided, it will be appended to the content and saved as well. 60 | * @param fileName - The name of the file to write. 61 | * @param content - The content to write to the file. 62 | * @param map - The source map to append to the content (optional). 63 | */ 64 | export async function writeOutputFile(fileName: string, content: string, map?: string) { 65 | const outDir = dirname(fileName); 66 | 67 | await fs.mkdir(outDir, { recursive: true }); 68 | 69 | const writes = []; 70 | 71 | if (map) { 72 | const outFile = basename(fileName); 73 | content += `\n//# sourceMappingURL=${outFile}.map`; 74 | 75 | writes.push(fs.writeFile(`${fileName}.map`, map)); 76 | } 77 | 78 | writes.push(fs.writeFile(fileName, content)); 79 | 80 | return Promise.all(writes); 81 | } 82 | 83 | /** 84 | * Cleans the specified output directory by removing all files and directories inside it. 85 | * If the directory does not exist, nothing happens. 86 | * 87 | * @param outDir - The path to the output directory. 88 | */ 89 | export function cleanOutDir(outDir: string) { 90 | const path = resolve(process.cwd(), outDir); 91 | 92 | if (!existsSync(path)) { 93 | return; 94 | } 95 | 96 | log.debug(`cleaning outDir: ${outDir}`); 97 | return fs.rm(path, { recursive: true, force: true }); 98 | } 99 | 100 | /** 101 | * Copies file to the specified output directory. 102 | * 103 | * @param fileName - The name of the file to be copied. 104 | * @param outDir - The output directory where the file will be copied to. 105 | * @returns A promise that resolves when the file is successfully copied. 106 | */ 107 | export async function copyStaticFile(fileName: string, outDir: string) { 108 | const outputPath = resolveOutPath(fileName, outDir); 109 | 110 | log.debug(`copying ${fileName} to ${outDir}`); 111 | 112 | return copyFile(fileName, outputPath); 113 | } 114 | 115 | /** 116 | * Copies files to the specified output directory. 117 | * @param fileNames - An array of file names to be copied. 118 | * @param outDir - The output directory where the files will be copied to. 119 | * @returns A promise that resolves when all files have been copied. 120 | */ 121 | export async function copyStaticFiles(fileNames: string[], outDir: string) { 122 | return Promise.all(fileNames.map((fileName) => copyStaticFile(fileName, outDir))); 123 | } 124 | 125 | /** 126 | * Compiles a file using SWC (a JavaScript/TypeScript compiler). 127 | * @param fileName - The name of the file to compile. 128 | * @param outDir - The output directory for the compiled file. 129 | * @param options - Additional options for the compilation process. 130 | * @returns A promise that resolves when the file is successfully compiled. 131 | */ 132 | export async function compileFile(fileName: string, outDir: string, options: Options) { 133 | if (fileName.endsWith('.d.ts')) { 134 | return; 135 | } 136 | 137 | const outputPath = resolveOutPath(fileName, outDir, 'js'); 138 | const sourceFileName = resolveSourceFilePath(outputPath, fileName); 139 | 140 | log.debug(`compiling ${fileName} to ${outputPath}`); 141 | 142 | try { 143 | const { map, code } = await transformFile(fileName, { 144 | filename: fileName, 145 | sourceFileName, 146 | outputPath, 147 | ...options, 148 | }); 149 | 150 | const mapContent = options.sourceMaps === true ? map : undefined; 151 | await writeOutputFile(outputPath, code, mapContent); 152 | } catch (err) { 153 | log.error(`failed to compile ${fileName}`); 154 | 155 | if (process.env.JUST_DEBUG) { 156 | throw err; 157 | } 158 | } 159 | } 160 | 161 | /** 162 | * Compiles an array of files using the specified options and outputs the result to the specified directory. 163 | * @param fileNames - An array of file names to compile. 164 | * @param outDir - The output directory for the compiled files. 165 | * @param options - The options to use for compilation. 166 | * @returns A promise that resolves when all files have been compiled. 167 | */ 168 | export function compileFiles(fileNames: string[], outDir: string, options: Options) { 169 | return Promise.all(fileNames.map((fileName) => compileFile(fileName, outDir, options))); 170 | } 171 | 172 | /** 173 | * Compiles the given code using SWC. 174 | * 175 | * @param code - The code to compile. 176 | * @param filename - The name of the file being compiled. 177 | * @param options - The options for the compilation. 178 | * @returns The transformed code. 179 | */ 180 | export function compileCode(code: string, filename: string, options: Options) { 181 | return transformSync(code, { filename, ...options }); 182 | } -------------------------------------------------------------------------------- /src/libs/typescript.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | 3 | import * as log from '../utils/logger'; 4 | 5 | /** 6 | * Loads the TypeScript configuration from the specified path. 7 | * 8 | * @param path - The path to the TypeScript configuration file. 9 | * @returns An object containing the loaded configuration, file names, and compiler options. 10 | */ 11 | export function loadTSConfig(path: string) { 12 | const { config } = ts.readConfigFile(path, ts.sys.readFile); 13 | const { options: compilerOptions, fileNames, errors } = ts.parseJsonConfigFileContent(config, ts.sys, process.cwd()); 14 | 15 | if (errors.length) { 16 | log.error('failed to load tsconfig.json'); 17 | 18 | if (process.env.JUST_DEBUG) { 19 | throw errors; 20 | } 21 | } 22 | 23 | compilerOptions.importHelpers = false; 24 | compilerOptions.files = fileNames; 25 | 26 | return { config, fileNames, compilerOptions }; 27 | } 28 | 29 | /** 30 | * Checks the specified files for TypeScript errors using the provided compiler options. 31 | * 32 | * @param fileNames - An array of file names to check. 33 | * @param options - The TypeScript compiler options. 34 | * @returns Returns `false` if there are no errors, otherwise returns `true`. 35 | */ 36 | export function checkFiles(fileNames: string[] = [], options: ts.CompilerOptions = {}) { 37 | const program = ts.createProgram(fileNames, options); 38 | const errors = ts.getPreEmitDiagnostics(program); 39 | 40 | if (!errors.length) { 41 | return false; 42 | } 43 | 44 | const formatedError = ts.formatDiagnosticsWithColorAndContext(errors, { 45 | getCanonicalFileName: (fileName: any) => fileName, 46 | getCurrentDirectory: () => process.cwd(), 47 | getNewLine: () => ts.sys.newLine, 48 | }); 49 | 50 | log.error('type error \n\n' + formatedError); 51 | 52 | return true; 53 | } 54 | 55 | /** 56 | * Checks a single TypeScript file using the specified compiler options. 57 | * @param fileName - The path of the TypeScript file to check. 58 | * @param options - The compiler options to use for checking the file. 59 | * @returns A result indicating whether the file passed the check or not. 60 | */ 61 | export function checkFile(fileName: string, options: ts.CompilerOptions = {}) { 62 | return checkFiles([fileName], options); 63 | } -------------------------------------------------------------------------------- /src/utils/file.ts: -------------------------------------------------------------------------------- 1 | import { watch } from 'chokidar'; 2 | import dirGlob from 'dir-glob'; 3 | import { IgnoreLike, globSync } from 'glob'; 4 | import { dirname, extname } from 'path'; 5 | import { promises as fs } from "fs"; 6 | 7 | /** 8 | * Checks if the given path represents a file. 9 | * @param path - The path to check. 10 | * @returns True if the path represents a file, false otherwise. 11 | */ 12 | export function isFile(path: string) { 13 | return extname(path) !== ''; 14 | } 15 | 16 | /** 17 | * Creates a directory glob pattern and returns matching file paths. 18 | * @param paths - The path or paths to search for files. 19 | * @param extensions - Optional array of file extensions to filter the search. 20 | * @returns An array of file paths that match the directory glob pattern. 21 | */ 22 | export function createDirGlob(paths: string | string[], extensions?: string[]) { 23 | return dirGlob.sync(paths, { 24 | extensions, 25 | cwd: process.cwd(), 26 | }); 27 | } 28 | 29 | /** 30 | * Creates a file glob by matching the specified paths against the file system. 31 | * 32 | * @param paths - An array of paths to match against the file system. 33 | * @param ignore - An array of patterns to ignore during the matching process. 34 | * @returns An array of matched file paths. 35 | */ 36 | export function createFileGlob(paths: string[] = [], ignore: string | string[] | IgnoreLike = []) { 37 | const options = { 38 | ignore, 39 | dot: false, 40 | nodir: true, 41 | cwd: process.cwd(), 42 | }; 43 | 44 | return paths.flatMap((path) => globSync(path, options)); 45 | } 46 | 47 | /** 48 | * Copies a file to the specified output path. 49 | * @param fileName - The name of the file to be copied. 50 | * @param outputPath - The path where the file should be copied to. 51 | * @returns A promise that resolves when the file is successfully copied. 52 | */ 53 | export async function copyFile(fileName: string, outputPath: string) { 54 | const dirName = dirname(outputPath); 55 | await fs.mkdir(dirName, { recursive: true }); 56 | return fs.copyFile(fileName, outputPath); 57 | } 58 | 59 | /** 60 | * Creates a debounced version of a function. 61 | * @param fn The function to debounce. 62 | * @param timeout The debounce timeout in milliseconds. Default is 500ms. 63 | * @returns The debounced function. 64 | */ 65 | export function debounce(fn: Function, timeout = 500) { 66 | let timer: NodeJS.Timeout; 67 | return (...args: any[]) => { 68 | clearTimeout(timer); 69 | timer = setTimeout(() => fn.apply(null, args), timeout); 70 | }; 71 | } 72 | 73 | /** 74 | * Watches the specified files and directories for changes. 75 | * 76 | * @param paths - An array of file or directory paths to watch. 77 | * @param ignored - An array of file or directory paths to ignore. 78 | * @returns A promise that resolves to an object containing the watcher and utility functions. 79 | */ 80 | export function watchFiles(paths: string[], ignored: string[]) { 81 | const watcher = watch(paths, { 82 | ignored, 83 | persistent: true, 84 | ignoreInitial: true, 85 | usePolling: true, 86 | useFsEvents: true, 87 | awaitWriteFinish: { 88 | stabilityThreshold: 50, 89 | pollInterval: 10, 90 | }, 91 | cwd: process.cwd(), 92 | }); 93 | 94 | const response = { 95 | watcher, 96 | stop: () => watcher.close(), 97 | onChange(callback: (...args: any) => Promise | void) { 98 | ['add', 'change'].forEach((type) => watcher.on(type, debounce(callback))); 99 | }, 100 | onRemove(callback: (...args: any) => Promise | void) { 101 | watcher.on('unlink', debounce(callback)); 102 | }, 103 | }; 104 | 105 | return new Promise((resolve, reject) => { 106 | watcher.on('ready', () => resolve(response)); 107 | watcher.on('error', (err) => reject(err)); 108 | }); 109 | } -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import colors from 'colors/safe'; 2 | 3 | /** 4 | * The prefix to use for all log messages. 5 | */ 6 | const PREFIX = '[Just]' as const; 7 | 8 | /** 9 | * Creates a timer object that can be used to measure the execution time of a code block. 10 | * @returns An object with `start` and `end` methods. 11 | */ 12 | export function timer() { 13 | let start: [number, number]; 14 | 15 | return { 16 | /** 17 | * Starts the timer and executes the specified function. 18 | * @param args - Optional arguments to pass to the function. 19 | */ 20 | start(...args: unknown[]) { 21 | start = process.hrtime(); 22 | wait(...args); 23 | }, 24 | /** 25 | * Ends the timer and logs the elapsed time. 26 | * @param args - Optional arguments to pass to the log event. 27 | */ 28 | end(...args: unknown[]) { 29 | const end = process.hrtime(start); 30 | event(...args, (end[1] / 1000000).toFixed(2), 'in', 'ms'); 31 | }, 32 | }; 33 | } 34 | 35 | /** 36 | * Logs an wait message. 37 | * @param args - The arguments to be logged. 38 | */ 39 | export function wait(...args: unknown[]) { 40 | log(colors.bold(colors.magenta('wait')), '-', ...args); 41 | } 42 | 43 | /** 44 | * Logs an event message. 45 | * @param args - The arguments to be logged. 46 | */ 47 | export function event(...args: unknown[]) { 48 | log(colors.bold(colors.green('event')), '-', ...args); 49 | } 50 | 51 | /** 52 | * Logs an error message. 53 | * @param args - The error message or additional arguments to log. 54 | */ 55 | export function error(...args: unknown[]) { 56 | log(colors.bold(colors.red('error')), '-', ...args); 57 | } 58 | 59 | /** 60 | * Logs a warning message. 61 | * @param args - The arguments to be logged. 62 | */ 63 | export function warning(...args: unknown[]) { 64 | log(colors.bold(colors.yellow('warning')), '-', ...args); 65 | } 66 | 67 | /** 68 | * Logs an information message. 69 | * @param args - The arguments to be logged. 70 | */ 71 | export function info(...args: unknown[]) { 72 | log(colors.bold(colors.cyan('info')), '-', ...args); 73 | } 74 | 75 | /** 76 | * Logs debug information if the environment variable JUST_DEBUG is set. 77 | * @param args - The arguments to be logged. 78 | */ 79 | export function debug(...args: unknown[]) { 80 | if (process.env.JUST_DEBUG) { 81 | log(colors.bold(colors.gray('DEBUG')), '-', ...args); 82 | } 83 | } 84 | 85 | /** 86 | * Logs the provided arguments to the console. 87 | * @param args - The arguments to be logged. 88 | */ 89 | export function log(...args: unknown[]) { 90 | console.log(colors.blue(PREFIX), ...args); 91 | } 92 | -------------------------------------------------------------------------------- /tests/libs/config.test.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | import { resolveConfigPath, toTsTarget, toModule, formatPaths, convertSWCConfig } from '../../src/libs/config'; 5 | 6 | describe('Config', () => { 7 | describe('resolveConfigPath', () => { 8 | it('should resolve the config path correctly', () => { 9 | const path = resolveConfigPath('/path/to/config/tsjson'); 10 | expect(path).toBe('/path/to/config/tsjson'); 11 | }); 12 | 13 | it('should resolve the config path from the environment variable', () => { 14 | process.env.JUST_TSCONFIG = '/path/to/config/tsjson'; 15 | 16 | const path = resolveConfigPath(); 17 | expect(path).toBe('/path/to/config/tsjson'); 18 | delete process.env.JUST_TSCONFIG; 19 | }); 20 | 21 | it('should resolve the root tsconfig.json path', () => { 22 | const existsSync = jest 23 | .spyOn(fs, 'existsSync') 24 | .mockReturnValueOnce(true); 25 | 26 | const path = resolveConfigPath(); 27 | expect(path).toBe('tsconfig.json'); 28 | 29 | existsSync.mockRestore(); 30 | }); 31 | 32 | it('should resolve the root jsconfig.json path', () => { 33 | const existsSync = jest 34 | .spyOn(fs, 'existsSync') 35 | .mockReturnValueOnce(false) 36 | .mockReturnValueOnce(true); 37 | 38 | const path = resolveConfigPath(); 39 | expect(path).toBe('jsconfig.json'); 40 | 41 | existsSync.mockRestore(); 42 | }); 43 | 44 | it('should resolve the default config file path', () => { 45 | const existsSync = jest 46 | .spyOn(fs, 'existsSync') 47 | .mockReturnValue(false); 48 | 49 | const path = resolveConfigPath(); 50 | expect(path).toContain('just.tsconfig.json'); 51 | 52 | existsSync.mockRestore(); 53 | }); 54 | }); 55 | 56 | describe('toTsTarget', () => { 57 | it('should convert the target to a string', () => { 58 | const target = toTsTarget(ts.ScriptTarget.ES2015); 59 | expect(target).toBe('es2015'); 60 | 61 | const target2 = toTsTarget(ts.ScriptTarget.ES2016); 62 | expect(target2).toBe('es2016'); 63 | 64 | const target3 = toTsTarget(ts.ScriptTarget.ES2017); 65 | expect(target3).toBe('es2017'); 66 | }); 67 | }); 68 | 69 | describe('toModule', () => { 70 | it('should convert the module to a string', () => { 71 | const module = toModule(ts.ModuleKind.CommonJS); 72 | expect(module).toBe('commonjs'); 73 | 74 | const module2 = toModule(ts.ModuleKind.ES2015); 75 | expect(module2).toBe('es6'); 76 | 77 | const module3 = toModule(ts.ModuleKind.ES2020); 78 | expect(module3).toBe('es6'); 79 | }); 80 | }); 81 | 82 | describe('formatPaths', () => { 83 | it('should format the paths correctly', () => { 84 | const resolve = jest 85 | .spyOn(path, 'resolve') 86 | .mockImplementation((...args) => args.join('/')); 87 | 88 | const paths = formatPaths({ 89 | '@/*': ['src/*'], 90 | 'test/*': ['test/*'], 91 | }, '/path/to/project'); 92 | 93 | expect(paths).toEqual({ 94 | '@/*': ['/path/to/project/src/*'], 95 | 'test/*': ['/path/to/project/test/*'], 96 | }); 97 | 98 | resolve.mockRestore(); 99 | }); 100 | }); 101 | 102 | describe('convertSWCConfig', () => { 103 | it('should convert the SWC config correctly', () => { 104 | const resolve = jest 105 | .spyOn(path, 'resolve') 106 | .mockImplementation((...args) => args.join('/')); 107 | 108 | const cwd = jest 109 | .spyOn(process, 'cwd') 110 | .mockReturnValue('/path/to/project'); 111 | 112 | const options = { 113 | target: ts.ScriptTarget.ES2018, 114 | module: ts.ModuleKind.ES2015, 115 | sourceMap: true, 116 | inlineSourceMap: true, 117 | esModuleInterop: true, 118 | emitDecoratorMetadata: true, 119 | experimentalDecorators: true, 120 | strict: true, 121 | alwaysStrict: true, 122 | baseUrl: './', 123 | paths: { 124 | '@/*': ['src/*'], 125 | 'test/*': ['test/*'], 126 | }, 127 | }; 128 | 129 | const result = convertSWCConfig(options); 130 | 131 | expect(result).toEqual({ 132 | swcrc: false, 133 | minify: false, 134 | isModule: true, 135 | configFile: false, 136 | cwd: '/path/to/project', 137 | sourceMaps: 'inline', 138 | module: { 139 | noInterop: false, 140 | type: 'es6', 141 | strictMode: true, 142 | }, 143 | jsc: { 144 | keepClassNames: true, 145 | externalHelpers: false, 146 | target: 'es2018', 147 | baseUrl: './', 148 | paths: { 149 | '@/*': ['.//src/*'], 150 | 'test/*': ['.//test/*'], 151 | }, 152 | parser: { 153 | tsx: false, 154 | dynamicImport: true, 155 | syntax: 'typescript', 156 | decorators: true, 157 | }, 158 | transform: { 159 | legacyDecorator: true, 160 | decoratorMetadata: true, 161 | }, 162 | minify: { 163 | compress: false, 164 | mangle: false, 165 | }, 166 | }, 167 | }); 168 | 169 | resolve.mockRestore(); 170 | cwd.mockRestore(); 171 | }); 172 | }); 173 | 174 | describe('loadConfig', () => { 175 | // TODO: Add tests for loadConfig 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /tests/libs/server.test.ts: -------------------------------------------------------------------------------- 1 | import { getOptions, resolveEntryPath, resolvePort } from '../../src/libs/server'; 2 | import * as log from '../../src/utils/logger'; 3 | 4 | describe('server', () => { 5 | describe('getOptions', () => { 6 | it('returns the options object for the server', () => { 7 | const result = getOptions('path/to/config.js'); 8 | expect(result.env.JUST_TSCONFIG).toEqual('path/to/config.js'); 9 | }); 10 | 11 | it('returns the options object for the server with the specified port', () => { 12 | const result = getOptions('path/to/config.js', 8080); 13 | expect(result.env.PORT).toEqual(8080); 14 | }); 15 | 16 | it('returns the options object for the server with the specified port as a number', () => { 17 | const result = getOptions('path/to/config.js', '8080'); 18 | expect(result.env.PORT).toEqual(8080); 19 | }); 20 | }); 21 | 22 | describe('resolveEntryPath', () => { 23 | let cwd: jest.SpyInstance; 24 | 25 | beforeAll(() => { 26 | cwd = jest 27 | .spyOn(process, 'cwd') 28 | .mockReturnValue('/path/to/project'); 29 | }); 30 | 31 | afterAll(() => { 32 | cwd.mockRestore(); 33 | }); 34 | 35 | it('returns the resolved entry path when provided', () => { 36 | const result = resolveEntryPath('path/to/entry.js'); 37 | expect(result).toEqual('/path/to/project/path/to/entry.js'); 38 | }); 39 | 40 | it('throws an error when entry path is not provided and JUST_DEBUG environment variable is set', () => { 41 | process.env.JUST_DEBUG = 'TRUE'; 42 | 43 | const error = jest 44 | .spyOn(log, 'error') 45 | .mockReturnValueOnce(undefined); 46 | 47 | expect(() => { 48 | resolveEntryPath(); 49 | }).toThrow('entry path is not provided'); 50 | 51 | expect(error).toHaveBeenCalledWith('entry path is not provided'); 52 | 53 | delete process.env.JUST_DEBUG; 54 | error.mockRestore(); 55 | }); 56 | 57 | it('returns undefined when entry path is not provided and JUST_DEBUG environment variable is not set', () => { 58 | delete process.env.JUST_DEBUG; 59 | 60 | const error = jest 61 | .spyOn(log, 'error') 62 | .mockReturnValueOnce(undefined); 63 | 64 | const result = resolveEntryPath(); 65 | expect(result).toBeUndefined(); 66 | expect(error).toHaveBeenCalledWith('entry path is not provided'); 67 | 68 | error.mockRestore(); 69 | }); 70 | }); 71 | 72 | describe('resolvePort', () => { 73 | it('returns the provided port number when it is specified', async () => { 74 | const result = await resolvePort('8080'); 75 | expect(result).toEqual(8080); 76 | }); 77 | 78 | it('returns the PORT environment variable when it is set', async () => { 79 | process.env.PORT = '3000'; 80 | const result = await resolvePort(); 81 | expect(result).toEqual(3000); 82 | delete process.env.PORT; 83 | }); 84 | 85 | it('returns the port specified in package.json when PORT environment variable is not set', async () => { 86 | process.env.npm_package_config_port = '4000'; 87 | const result = await resolvePort(); 88 | expect(result).toEqual(4000); 89 | delete process.env.npm_package_config_port; 90 | }); 91 | 92 | it('returns a random port within the range 3000-3100 when neither port nor PORT environment variable is set', async () => { 93 | const result = await resolvePort(); 94 | expect(result).toBeGreaterThanOrEqual(3000); 95 | expect(result).toBeLessThanOrEqual(3100); 96 | }); 97 | }); 98 | }); -------------------------------------------------------------------------------- /tests/libs/swc.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import process from 'process'; 3 | 4 | import { isCompilable, resolveOutPath, resolveSourceFilePath, writeOutputFile, cleanOutDir, copyStaticFile, copyStaticFiles } from '../../src/libs/swc'; 5 | import * as file from '../../src/utils/file'; 6 | 7 | describe('swc', () => { 8 | describe('isCompilable', () => { 9 | it('returns true for a compilable file', () => { 10 | const result = isCompilable('path/to/file.ts'); 11 | expect(result).toBe(true); 12 | }); 13 | 14 | it('returns false for a non-compilable file', () => { 15 | const result = isCompilable('path/to/file.json'); 16 | expect(result).toBe(false); 17 | }); 18 | }); 19 | 20 | describe('resolveOutPath', () => { 21 | it('returns the resolved output path', () => { 22 | const result = resolveOutPath('path/to/output.ts', 'path/to/dist'); 23 | expect(result).toEqual('path/to/dist/to/output.ts'); 24 | }); 25 | 26 | it('returns the resolved output path with the specified extension', () => { 27 | const result = resolveOutPath('path/to/output.ts', 'path/to/dist', 'js'); 28 | expect(result).toEqual('path/to/dist/to/output.js'); 29 | }); 30 | 31 | it('returns the resolved output path when the output directory is the current directory', () => { 32 | const result = resolveOutPath('path/to/output.ts', '.'); 33 | expect(result).toEqual('to/output.ts'); 34 | }); 35 | 36 | it('returns the resolved output path when the output directory is the parent directory', () => { 37 | const result = resolveOutPath('path/to/output.ts', '..'); 38 | expect(result).toEqual('../to/output.ts'); 39 | }); 40 | }); 41 | 42 | describe('resolveSourceFilePath', () => { 43 | it('returns the resolved source file path', () => { 44 | const result = resolveSourceFilePath('path/to/output.ts', 'path/to/output.ts'); 45 | expect(result).toEqual('output.ts'); 46 | }); 47 | }); 48 | 49 | describe('writeOutputFile', () => { 50 | it('writes the content to the output file', async () => { 51 | const mkdir = jest 52 | .spyOn(fs.promises, 'mkdir') 53 | .mockResolvedValue(undefined); 54 | 55 | const writeFile = jest 56 | .spyOn(fs.promises, 'writeFile') 57 | .mockResolvedValue(undefined); 58 | 59 | await writeOutputFile('path/to/output.ts', 'console.log("Hello World!")'); 60 | 61 | expect(mkdir).toHaveBeenCalledWith('path/to', { 62 | recursive: true, 63 | }); 64 | 65 | expect(writeFile).toHaveBeenCalledWith('path/to/output.ts', 'console.log("Hello World!")'); 66 | 67 | mkdir.mockRestore(); 68 | writeFile.mockRestore(); 69 | }); 70 | 71 | it('writes the content and source map to the output file', async () => { 72 | const mkdir = jest 73 | .spyOn(fs.promises, 'mkdir') 74 | .mockResolvedValue(undefined); 75 | 76 | const writeFile = jest 77 | .spyOn(fs.promises, 'writeFile') 78 | .mockResolvedValue(undefined); 79 | 80 | await writeOutputFile('path/to/output.js', 'console.log("Hello World!")', '{"version":3,"file":"output.js","sourceRoot":"","sources":["output.ts"],"names":[],"mappings":"AAAA,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC"}'); 81 | 82 | expect(mkdir).toHaveBeenCalledWith('path/to', { 83 | recursive: true, 84 | }); 85 | 86 | expect(writeFile).toHaveBeenNthCalledWith(1, 'path/to/output.js.map', '{"version":3,"file":"output.js","sourceRoot":"","sources":["output.ts"],"names":[],"mappings":"AAAA,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC"}'); 87 | expect(writeFile).toHaveBeenNthCalledWith(2, 'path/to/output.js', 'console.log("Hello World!")\n//# sourceMappingURL=output.js.map'); 88 | 89 | mkdir.mockRestore(); 90 | writeFile.mockRestore(); 91 | }); 92 | }); 93 | 94 | describe('cleanOutDir', () => { 95 | it('removes the output directory', async () => { 96 | const cwd = jest 97 | .spyOn(process, 'cwd') 98 | .mockReturnValue('/path/to/project'); 99 | 100 | const existsSync = jest 101 | .spyOn(fs, 'existsSync') 102 | .mockReturnValue(true); 103 | 104 | const rm = jest 105 | .spyOn(fs.promises, 'rm') 106 | .mockResolvedValue(undefined); 107 | 108 | await cleanOutDir('path/to/dist'); 109 | 110 | expect(rm).toHaveBeenCalledWith('/path/to/project/path/to/dist', { 111 | force: true, 112 | recursive: true, 113 | }); 114 | 115 | cwd.mockRestore(); 116 | rm.mockRestore(); 117 | existsSync.mockRestore(); 118 | }); 119 | 120 | it('does not remove the output directory when it does not exist', async () => { 121 | const cwd = jest 122 | .spyOn(process, 'cwd') 123 | .mockReturnValue('/path/to/project'); 124 | 125 | const existsSync = jest 126 | .spyOn(fs, 'existsSync') 127 | .mockReturnValue(false); 128 | 129 | const rm = jest 130 | .spyOn(fs.promises, 'rm') 131 | .mockResolvedValue(undefined); 132 | 133 | await cleanOutDir('path/to/dist'); 134 | 135 | expect(rm).not.toHaveBeenCalled(); 136 | 137 | cwd.mockRestore(); 138 | rm.mockRestore(); 139 | existsSync.mockRestore(); 140 | }); 141 | }); 142 | 143 | describe('copyStaticFile', () => { 144 | it('copies the file to the specified output directory', async () => { 145 | const copyFile = jest 146 | .spyOn(file, 'copyFile') 147 | .mockResolvedValue(undefined); 148 | 149 | await copyStaticFile('path/to/file.json', 'path/to/dist'); 150 | 151 | expect(copyFile).toHaveBeenCalledWith('path/to/file.json', 'path/to/dist/to/file.json'); 152 | 153 | copyFile.mockRestore(); 154 | }); 155 | }); 156 | 157 | describe('copyStaticFiles', () => { 158 | it('copies the files to the specified output directory', async () => { 159 | const copyFile = jest 160 | .spyOn(file, 'copyFile') 161 | .mockResolvedValue(undefined); 162 | 163 | await copyStaticFiles([ 164 | 'path/to/file1.json', 165 | 'path/to/file2.json', 166 | ], 'path/to/dist'); 167 | 168 | expect(copyFile).toHaveBeenCalledWith('path/to/file1.json', 'path/to/dist/to/file1.json'); 169 | expect(copyFile).toHaveBeenCalledWith('path/to/file2.json', 'path/to/dist/to/file2.json'); 170 | 171 | copyFile.mockRestore(); 172 | }); 173 | }); 174 | }); -------------------------------------------------------------------------------- /tests/libs/typescript.test.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | 3 | import { loadTSConfig, checkFiles } from '../../src/libs/typescript'; 4 | import * as log from '../../src/utils/logger'; 5 | 6 | jest.mock('typescript', () => ({ 7 | readConfigFile: jest.fn(), 8 | parseJsonConfigFileContent: jest.fn(), 9 | createProgram: jest.fn(), 10 | getPreEmitDiagnostics: jest.fn(), 11 | formatDiagnosticsWithColorAndContext: jest.fn(), 12 | sys: { 13 | readFile: jest.fn(), 14 | }, 15 | })); 16 | 17 | describe('typescript', () => { 18 | describe('loadTSConfig', () => { 19 | it('should load the TypeScript configuration from the specified path', () => { 20 | (ts.readConfigFile as jest.Mock).mockReturnValueOnce({ config: {}, error: undefined }); 21 | (ts.parseJsonConfigFileContent as jest.Mock).mockReturnValueOnce({ options: {}, fileNames: [], errors: [] }); 22 | const result = loadTSConfig('tsconfig.json'); 23 | 24 | expect(ts.readConfigFile).toHaveBeenCalledWith('tsconfig.json', expect.any(Function)); 25 | expect(ts.parseJsonConfigFileContent).toHaveBeenCalledWith({}, expect.anything(), expect.any(String)); 26 | expect(result).toEqual({ 27 | config: {}, 28 | fileNames: [], 29 | compilerOptions: { 30 | importHelpers: false, 31 | files: [], 32 | } 33 | }); 34 | }); 35 | 36 | it('should throw an error if the configuration file contains errors', () => { 37 | process.env.JUST_DEBUG = 'TRUE'; 38 | 39 | const error = jest 40 | .spyOn(log, 'error') 41 | .mockReturnValueOnce(undefined); 42 | 43 | (ts.readConfigFile as jest.Mock).mockReturnValueOnce({ config: {}, error: undefined }); 44 | (ts.parseJsonConfigFileContent as jest.Mock).mockReturnValueOnce({ options: {}, fileNames: [], errors: ['error'] }); 45 | 46 | expect(() => loadTSConfig('tsconfig.json')).toThrow('error'); 47 | expect(error).toHaveBeenCalledWith('failed to load tsconfig.json'); 48 | 49 | delete process.env.JUST_DEBUG; 50 | error.mockRestore(); 51 | }); 52 | }); 53 | 54 | describe('checkFiles', () => { 55 | it('should return false if there are no errors', () => { 56 | (ts.getPreEmitDiagnostics as jest.Mock).mockReturnValueOnce([]); 57 | 58 | expect(checkFiles()).toBe(false); 59 | expect(ts.createProgram).toHaveBeenCalledWith([], {}); 60 | expect(ts.getPreEmitDiagnostics).toHaveBeenCalled(); 61 | }); 62 | 63 | it('should return true if there are errors', () => { 64 | const error = jest 65 | .spyOn(log, 'error') 66 | .mockReturnValueOnce(undefined); 67 | 68 | (ts.getPreEmitDiagnostics as jest.Mock).mockReturnValueOnce(['error']); 69 | (ts.formatDiagnosticsWithColorAndContext as jest.Mock).mockReturnValueOnce('error'); 70 | 71 | expect(checkFiles()).toBe(true); 72 | expect(ts.createProgram).toHaveBeenCalledWith([], {}); 73 | expect(ts.getPreEmitDiagnostics).toHaveBeenCalled(); 74 | expect(error).toHaveBeenCalledWith('type error \n\nerror'); 75 | 76 | error.mockRestore(); 77 | }); 78 | }); 79 | }); -------------------------------------------------------------------------------- /tests/utils/file.test.ts: -------------------------------------------------------------------------------- 1 | import dirGlob from 'dir-glob'; 2 | import * as glob from 'glob'; 3 | import fs from 'fs'; 4 | import chokidar from 'chokidar'; 5 | import { EventEmitter } from 'events'; 6 | 7 | import * as file from '../../src/utils/file'; 8 | 9 | describe('file', () => { 10 | let cwd: jest.SpyInstance; 11 | 12 | beforeEach(() => { 13 | cwd = jest 14 | .spyOn(process, 'cwd') 15 | .mockReturnValue('/path/to/project'); 16 | }); 17 | 18 | afterEach(() => { 19 | cwd.mockRestore(); 20 | }); 21 | 22 | describe('createDirGlob', () => { 23 | it('returns an array of matching files without extensions', () => { 24 | const sync = jest 25 | .spyOn(dirGlob, 'sync') 26 | .mockReturnValue([ 27 | 'path/to/files/file1', 28 | 'path/to/files/file2', 29 | ]); 30 | 31 | const result = file.createDirGlob('path/to/files/*'); 32 | 33 | expect(sync).toHaveBeenCalledWith('path/to/files/*', { 34 | extensions: undefined, 35 | cwd: '/path/to/project', 36 | }); 37 | 38 | expect(result).toEqual([ 39 | 'path/to/files/file1', 40 | 'path/to/files/file2', 41 | ]); 42 | 43 | sync.mockRestore(); 44 | }); 45 | 46 | it('returns an array of matching files with extensions', () => { 47 | const sync = jest 48 | .spyOn(dirGlob, 'sync') 49 | .mockReturnValue([ 50 | 'path/to/files/*.ts', 51 | 'path/to/files/*.tsx', 52 | ]); 53 | 54 | const result = file.createDirGlob('path/to/files/*'); 55 | 56 | expect(sync).toHaveBeenCalledWith('path/to/files/*', { 57 | cwd: '/path/to/project', 58 | }); 59 | 60 | expect(result).toEqual([ 61 | 'path/to/files/*.ts', 62 | 'path/to/files/*.tsx', 63 | ]); 64 | 65 | sync.mockRestore(); 66 | }); 67 | }); 68 | 69 | describe('createFileGlob', () => { 70 | it('returns an array of matching files', () => { 71 | const globSync = jest 72 | .spyOn(glob, 'globSync') 73 | .mockReturnValue([ 74 | 'path/to/files/*.ts', 75 | 'path/to/files/*.tsx', 76 | ]); 77 | 78 | const result = file.createFileGlob(['path/to/files/*'], ['path/to/ignore/*']); 79 | 80 | expect(globSync).toHaveBeenCalledWith('path/to/files/*', { 81 | ignore: ['path/to/ignore/*'], 82 | nodir: true, 83 | dot: false, 84 | cwd: '/path/to/project', 85 | }); 86 | 87 | expect(result).toEqual([ 88 | 'path/to/files/*.ts', 89 | 'path/to/files/*.tsx', 90 | ]); 91 | 92 | globSync.mockRestore(); 93 | }); 94 | }); 95 | 96 | describe('copyFile', () => { 97 | it('copies the file to the output path', async () => { 98 | const mkdir = jest 99 | .spyOn(fs.promises, 'mkdir') 100 | .mockResolvedValue(undefined); 101 | 102 | const copyFile = jest 103 | .spyOn(fs.promises, 'copyFile') 104 | .mockResolvedValue(undefined); 105 | 106 | await file.copyFile('path/to/file', 'path/to/output'); 107 | 108 | expect(mkdir).toHaveBeenCalledWith('path/to', { recursive: true }); 109 | expect(copyFile).toHaveBeenCalledWith('path/to/file', 'path/to/output'); 110 | 111 | mkdir.mockRestore(); 112 | copyFile.mockRestore(); 113 | }); 114 | }); 115 | 116 | describe('debounce', () => { 117 | it('returns a function that debounces the callback', () => { 118 | jest.useFakeTimers(); 119 | 120 | const callback = jest.fn(); 121 | const debounced = file.debounce(callback, 100); 122 | 123 | debounced(); 124 | debounced(); 125 | debounced(); 126 | 127 | jest.runAllTimers(); 128 | 129 | expect(callback).toHaveBeenCalledTimes(1); 130 | }); 131 | }); 132 | 133 | describe('watchFiles', () => { 134 | let watch: jest.SpyInstance; 135 | const emitter = new EventEmitter(); 136 | jest.useFakeTimers(); 137 | 138 | beforeAll(() => { 139 | watch = jest 140 | .spyOn(chokidar, 'watch') 141 | .mockImplementation(() => { 142 | (emitter as any).close = jest.fn(); 143 | return emitter as any; 144 | }); 145 | }); 146 | 147 | afterAll(() => { 148 | watch.mockRestore(); 149 | emitter.removeAllListeners(); 150 | }); 151 | 152 | it('returns a watcher and utility functions', async () => { 153 | const watchFiles = file.watchFiles(['path/to/files/*'], ['path/to/ignore/*']); 154 | 155 | emitter.emit('ready'); 156 | 157 | const result = await watchFiles; 158 | 159 | expect(result).toHaveProperty('watcher'); 160 | expect(result).toHaveProperty('stop'); 161 | expect(result).toHaveProperty('onChange'); 162 | }); 163 | 164 | it('stops the watcher', async () => { 165 | const watchFiles = file.watchFiles(['path/to/files/*'], ['path/to/ignore/*']); 166 | 167 | emitter.emit('ready'); 168 | 169 | const result = await watchFiles; 170 | 171 | result.stop(); 172 | 173 | expect((emitter as any).close).toHaveBeenCalled(); 174 | }); 175 | 176 | it('calls the onChange callback when a file is added or changed', async () => { 177 | const watchFiles = file.watchFiles(['path/to/files/*'], ['path/to/ignore/*']); 178 | 179 | emitter.emit('ready'); 180 | 181 | const result = await watchFiles; 182 | 183 | const onChange = jest.fn(); 184 | result.onChange(onChange); 185 | 186 | emitter.emit('add', 'path/to/file'); 187 | emitter.emit('change', 'path/to/file'); 188 | jest.runAllTimers(); 189 | 190 | expect(onChange).toHaveBeenCalledTimes(2); 191 | }); 192 | 193 | it('calls the onRemove callback when a file is removed', async () => { 194 | const watchFiles = file.watchFiles(['path/to/files/*'], ['path/to/ignore/*']); 195 | 196 | emitter.emit('ready'); 197 | 198 | const result = await watchFiles; 199 | 200 | const onRemove = jest.fn(); 201 | result.onRemove(onRemove); 202 | 203 | emitter.emit('unlink', 'path/to/file'); 204 | jest.runAllTimers(); 205 | 206 | expect(onRemove).toHaveBeenCalledTimes(1); 207 | }); 208 | 209 | it('rejects the promise if the watcher emits an error', async () => { 210 | const watchFiles = file.watchFiles(['path/to/files/*'], ['path/to/ignore/*']); 211 | 212 | const error = new Error('Watcher error'); 213 | emitter.emit('error', error); 214 | 215 | await expect(watchFiles).rejects.toThrow(error); 216 | }); 217 | }); 218 | }); -------------------------------------------------------------------------------- /tests/utils/logger.test.ts: -------------------------------------------------------------------------------- 1 | import colors from 'colors/safe'; 2 | import * as logger from '../../src/utils/logger'; 3 | 4 | describe('logger', () => { 5 | let consoleMock: jest.SpyInstance; 6 | 7 | beforeEach(() => { 8 | consoleMock = jest.spyOn(console, 'log').mockImplementation(); 9 | colors.disable(); 10 | }); 11 | 12 | afterEach(() => { 13 | consoleMock.mockRestore(); 14 | }); 15 | 16 | it('adds prefix', () => { 17 | logger.log('testing'); 18 | expect(consoleMock).toHaveBeenCalledTimes(1); 19 | expect(consoleMock).toHaveBeenCalledWith('[Just]', 'testing'); 20 | }); 21 | 22 | it('logs timer', () => { 23 | const timer = logger.timer(); 24 | timer.start('start testing'); 25 | timer.end('end testing'); 26 | 27 | expect(consoleMock).toHaveBeenCalledTimes(2); 28 | expect(consoleMock).toHaveBeenNthCalledWith( 29 | 1, 30 | '[Just]', 31 | 'wait', 32 | '-', 33 | 'start testing' 34 | ); 35 | expect(consoleMock).toHaveBeenNthCalledWith( 36 | 2, 37 | '[Just]', 38 | 'event', 39 | '-', 40 | 'end testing', 41 | expect.any(String), 42 | 'in', 43 | 'ms' 44 | ); 45 | }); 46 | 47 | it('logs wait', () => { 48 | logger.wait('testing'); 49 | expect(consoleMock).toHaveBeenCalledTimes(1); 50 | expect(consoleMock).toHaveBeenCalledWith('[Just]', 'wait', '-', 'testing'); 51 | }); 52 | 53 | it('logs event', () => { 54 | logger.event('testing'); 55 | expect(consoleMock).toHaveBeenCalledTimes(1); 56 | expect(consoleMock).toHaveBeenCalledWith('[Just]', 'event', '-', 'testing'); 57 | }); 58 | 59 | it('logs error', () => { 60 | logger.error('testing'); 61 | expect(consoleMock).toHaveBeenCalledTimes(1); 62 | expect(consoleMock).toHaveBeenCalledWith('[Just]', 'error', '-', 'testing'); 63 | }); 64 | 65 | it('logs warning', () => { 66 | logger.warning('testing'); 67 | expect(consoleMock).toHaveBeenCalledTimes(1); 68 | expect(consoleMock).toHaveBeenCalledWith('[Just]', 'warning', '-', 'testing'); 69 | }); 70 | 71 | it('logs debug when JUST_DEBUG is set', () => { 72 | process.env.JUST_DEBUG = 'TRUE'; 73 | logger.debug('testing'); 74 | expect(consoleMock).toHaveBeenCalledTimes(1); 75 | expect(consoleMock).toHaveBeenCalledWith('[Just]', 'DEBUG', '-', 'testing'); 76 | delete process.env.JUST_DEBUG; 77 | }); 78 | 79 | it('does not log debug when JUST_DEBUG is not set', () => { 80 | delete process.env.JUST_DEBUG; 81 | logger.debug('testing'); 82 | expect(consoleMock).toHaveBeenCalledTimes(0); 83 | }); 84 | 85 | it('logs info', () => { 86 | logger.info('testing'); 87 | expect(consoleMock).toHaveBeenCalledTimes(1); 88 | expect(consoleMock).toHaveBeenCalledWith('[Just]', 'info', '-', 'testing'); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2017", 5 | "esModuleInterop": true, 6 | "allowSyntheticDefaultImports": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "moduleResolution": "Node", 10 | "resolveJsonModule": true, 11 | "baseUrl": "src", 12 | "skipLibCheck": true, 13 | "importHelpers": true, 14 | "strict": true 15 | }, 16 | "include": [ 17 | "src" 18 | ], 19 | "exclude": [ 20 | "node_modules", 21 | "tests" 22 | ] 23 | } 24 | --------------------------------------------------------------------------------