├── src ├── bench │ ├── scripts │ │ └── noop.ts │ ├── protocol.ts │ ├── json.ts │ ├── bench-child-hooks.ts │ └── bench.ts ├── extra-types.d.ts ├── wds.bin.js ├── hooks │ ├── child-process-esm-hook.ts │ ├── compileInLeaderProcess.cts │ ├── utils.cts │ ├── child-process-cjs-hook.cts │ └── child-process-esm-loader.ts ├── utils.ts ├── Compiler.ts ├── wds-bench.bin.js ├── PathTrie.ts ├── mini-server.ts ├── Project.ts ├── Supervisor.ts ├── ProjectConfig.ts ├── SyncWorker.cts ├── index.ts └── SwcCompiler.ts ├── spec ├── fixtures │ ├── failing │ │ ├── failing.ts │ │ ├── bar.ts │ │ ├── successful.ts │ │ └── package.json │ ├── src │ │ ├── simple.ts │ │ ├── .well-known │ │ │ ├── foo.ts │ │ │ └── run.ts │ │ ├── files_with_swcrc │ │ │ ├── simple.ts │ │ │ ├── nested │ │ │ │ ├── simple.ts │ │ │ │ ├── more_nested │ │ │ │ │ └── simple.ts │ │ │ │ └── .swcrc │ │ │ ├── wds.js │ │ │ ├── .swcrc │ │ │ └── package.json │ │ ├── files_with_config │ │ │ ├── ignored.ts │ │ │ ├── simple.ts │ │ │ ├── package.json │ │ │ └── wds.js │ │ ├── no-ipc.ts │ │ ├── package.json │ │ ├── lazy_import.ts │ │ ├── add.ts │ │ ├── echo.ts │ │ ├── wds.js │ │ ├── grandchild.js │ │ └── signal-order.ts │ ├── configs │ │ ├── empty-config │ │ │ └── wds.js │ │ ├── basic-ignore │ │ │ └── wds.js │ │ ├── with-extensions │ │ │ └── wds.js │ │ └── outside-root │ │ │ └── wds.js │ └── esm │ │ ├── github.com │ │ └── wds │ │ │ └── simple.ts │ │ ├── package.json │ │ └── wds.js ├── PathTrie.spec.ts ├── wds.spec.ts ├── SwcCompiler.test.ts ├── Supervisor.spec.ts └── ProjectConfig.spec.ts ├── .prettierrc.json ├── .envrc ├── integration-test ├── cjs-js-from-ts │ ├── utils.js │ ├── run.ts │ ├── test.sh │ ├── package.json │ └── .swcrc ├── cjs-with-esm-disabled │ ├── wds.js │ ├── utils.ts │ ├── run.ts │ ├── test.sh │ ├── package.json │ └── .swcrc ├── esm-js-from-ts │ ├── utils.js │ ├── run.ts │ ├── test.sh │ └── package.json ├── reload-cross-workspace │ ├── pnpm-workspace.yaml │ ├── side │ │ ├── run.ts │ │ └── package.json │ ├── main │ │ ├── package.json │ │ └── run.ts │ ├── pnpm-lock.yaml │ └── test.sh ├── cjs-ts-from-js │ ├── utils.ts │ ├── run.js │ ├── test.sh │ ├── package.json │ └── .swcrc ├── cjs-ts-from-ts │ ├── utils.ts │ ├── run.ts │ ├── test.sh │ ├── package.json │ └── .swcrc ├── esm-ts-from-js │ ├── utils.ts │ ├── run.js │ ├── test.sh │ └── package.json ├── esm-ts-from-ts │ ├── utils.ts │ ├── run.ts │ ├── test.sh │ └── package.json ├── reload-cross-workspace-lazy │ ├── pnpm-workspace.yaml │ ├── side │ │ ├── run.ts │ │ ├── .swcrc │ │ └── package.json │ ├── main │ │ ├── .swcrc │ │ ├── package.json │ │ └── run.ts │ ├── pnpm-lock.yaml │ └── test.sh ├── simple-cjs │ ├── utils.ts │ ├── run.ts │ ├── test.sh │ ├── package.json │ └── .swcrc ├── swc-helpers │ ├── utils.ts │ ├── run.ts │ ├── test.sh │ ├── package.json │ └── .swcrc ├── cjs-sourcemap │ ├── run.ts │ ├── package.json │ ├── test.sh │ ├── .swcrc │ └── utils.ts ├── esm-sourcemap │ ├── run.ts │ ├── package.json │ ├── test.sh │ └── utils.ts ├── oom │ ├── run.ts │ ├── package.json │ └── test.sh ├── parent-crash │ ├── run.ts │ ├── package.json │ └── test.js ├── cjs-require-cache │ ├── test.sh │ ├── run.ts │ ├── .swcrc │ ├── package.json │ └── utils.ts ├── reload │ ├── package.json │ ├── run.ts │ ├── run-scratch.ts │ └── test.sh ├── server │ ├── package.json │ ├── run.ts │ └── test.sh └── test.js ├── .gitignore ├── tsconfig.test.json ├── vitest.config.ts ├── .swcrc ├── gitpkg.config.js ├── .github ├── workflows │ ├── test.yml │ └── release.yml └── actions │ └── setup-test-env │ └── action.yml ├── tsconfig.json ├── Contributing.md ├── flake.nix ├── LICENSE.txt ├── flake.lock ├── .eslintrc.cjs ├── package.json └── Readme.md /src/bench/scripts/noop.ts: -------------------------------------------------------------------------------- 1 | 1 + 1; 2 | -------------------------------------------------------------------------------- /spec/fixtures/failing/failing.ts: -------------------------------------------------------------------------------- 1 | oops+++ 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | "@gadgetinc/prettier-config" 2 | -------------------------------------------------------------------------------- /spec/fixtures/failing/bar.ts: -------------------------------------------------------------------------------- 1 | export const foo = "foo"; 2 | -------------------------------------------------------------------------------- /spec/fixtures/src/simple.ts: -------------------------------------------------------------------------------- 1 | console.log("success"); 2 | -------------------------------------------------------------------------------- /src/extra-types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "yargs/helpers"; 2 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | 3 | source_env_if_exists .envrc.local 4 | -------------------------------------------------------------------------------- /spec/fixtures/src/.well-known/foo.ts: -------------------------------------------------------------------------------- 1 | export const foo = "foo"; -------------------------------------------------------------------------------- /spec/fixtures/src/files_with_swcrc/simple.ts: -------------------------------------------------------------------------------- 1 | export const a = 1; -------------------------------------------------------------------------------- /spec/fixtures/configs/empty-config/wds.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /spec/fixtures/esm/github.com/wds/simple.ts: -------------------------------------------------------------------------------- 1 | console.log("success"); 2 | -------------------------------------------------------------------------------- /spec/fixtures/src/files_with_config/ignored.ts: -------------------------------------------------------------------------------- 1 | // this is ignored 2 | -------------------------------------------------------------------------------- /spec/fixtures/src/files_with_config/simple.ts: -------------------------------------------------------------------------------- 1 | export const a = 1; 2 | -------------------------------------------------------------------------------- /spec/fixtures/src/files_with_swcrc/nested/simple.ts: -------------------------------------------------------------------------------- 1 | export const a = 1; -------------------------------------------------------------------------------- /spec/fixtures/src/files_with_swcrc/nested/more_nested/simple.ts: -------------------------------------------------------------------------------- 1 | export const a = 1; -------------------------------------------------------------------------------- /spec/fixtures/src/.well-known/run.ts: -------------------------------------------------------------------------------- 1 | import { foo } from "./foo"; 2 | 3 | console.log(foo); -------------------------------------------------------------------------------- /spec/fixtures/src/files_with_swcrc/wds.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "swc": ".swcrc", 3 | } 4 | -------------------------------------------------------------------------------- /integration-test/cjs-js-from-ts/utils.js: -------------------------------------------------------------------------------- 1 | module.exports.utility = (str) => str.toUpperCase(); 2 | -------------------------------------------------------------------------------- /integration-test/cjs-with-esm-disabled/wds.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | esm: false, 3 | }; 4 | -------------------------------------------------------------------------------- /integration-test/esm-js-from-ts/utils.js: -------------------------------------------------------------------------------- 1 | export const utility = (str) => str.toUpperCase(); 2 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "main" 3 | - "side" -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace/side/run.ts: -------------------------------------------------------------------------------- 1 | export const message = "Hello, World!"; 2 | -------------------------------------------------------------------------------- /spec/fixtures/failing/successful.ts: -------------------------------------------------------------------------------- 1 | export function success() { 2 | return !!(1 + 1); 3 | } 4 | -------------------------------------------------------------------------------- /integration-test/cjs-ts-from-js/utils.ts: -------------------------------------------------------------------------------- 1 | export const utility = (str: string) => str.toUpperCase(); 2 | -------------------------------------------------------------------------------- /integration-test/cjs-ts-from-ts/utils.ts: -------------------------------------------------------------------------------- 1 | export const utility = (str: string) => str.toUpperCase(); 2 | -------------------------------------------------------------------------------- /integration-test/esm-ts-from-js/utils.ts: -------------------------------------------------------------------------------- 1 | export const utility = (str: string) => str.toUpperCase(); 2 | -------------------------------------------------------------------------------- /integration-test/esm-ts-from-ts/utils.ts: -------------------------------------------------------------------------------- 1 | export const utility = (str: string) => str.toUpperCase(); 2 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace-lazy/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "main" 3 | - "side" -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace-lazy/side/run.ts: -------------------------------------------------------------------------------- 1 | export const message = "Hello, World!"; 2 | -------------------------------------------------------------------------------- /integration-test/simple-cjs/utils.ts: -------------------------------------------------------------------------------- 1 | export const utility = (str: string) => str.toUpperCase(); 2 | -------------------------------------------------------------------------------- /integration-test/swc-helpers/utils.ts: -------------------------------------------------------------------------------- 1 | export const utility = (str: string) => str.toUpperCase(); 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | pkg 3 | .envrc.local 4 | **/run-scratch.ts 5 | **/run-scratch.ts 6 | .direnv 7 | -------------------------------------------------------------------------------- /integration-test/cjs-with-esm-disabled/utils.ts: -------------------------------------------------------------------------------- 1 | export const utility = (str: string) => str.toUpperCase(); 2 | -------------------------------------------------------------------------------- /integration-test/swc-helpers/run.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | console.log(_.camelCase("It worked!")); 4 | -------------------------------------------------------------------------------- /spec/fixtures/configs/basic-ignore/wds.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignore: ["ignored/", "*.test.ts"] 3 | }; 4 | -------------------------------------------------------------------------------- /integration-test/simple-cjs/run.ts: -------------------------------------------------------------------------------- 1 | import { utility } from "./utils"; 2 | 3 | console.log(utility("It worked!")); 4 | -------------------------------------------------------------------------------- /integration-test/cjs-js-from-ts/run.ts: -------------------------------------------------------------------------------- 1 | import { utility } from "./utils"; 2 | 3 | console.log(utility("It worked!")); 4 | -------------------------------------------------------------------------------- /integration-test/cjs-sourcemap/run.ts: -------------------------------------------------------------------------------- 1 | import { utility } from "./utils"; 2 | 3 | console.log(utility("It worked!")); 4 | -------------------------------------------------------------------------------- /integration-test/cjs-ts-from-ts/run.ts: -------------------------------------------------------------------------------- 1 | import { utility } from "./utils"; 2 | 3 | console.log(utility("It worked!")); 4 | -------------------------------------------------------------------------------- /spec/fixtures/src/no-ipc.ts: -------------------------------------------------------------------------------- 1 | if (process.send) { 2 | // The process was started with IPC 3 | process.exit(1); 4 | } 5 | -------------------------------------------------------------------------------- /integration-test/cjs-ts-from-js/run.js: -------------------------------------------------------------------------------- 1 | const { utility } = require("./utils"); 2 | 3 | console.log(utility("It worked!")); 4 | -------------------------------------------------------------------------------- /integration-test/esm-js-from-ts/run.ts: -------------------------------------------------------------------------------- 1 | import { utility } from "./utils.js"; 2 | 3 | console.log(utility("It worked!")); 4 | -------------------------------------------------------------------------------- /integration-test/esm-sourcemap/run.ts: -------------------------------------------------------------------------------- 1 | import { utility } from "./utils.js"; 2 | 3 | console.log(utility("It worked!")); 4 | -------------------------------------------------------------------------------- /integration-test/esm-ts-from-js/run.js: -------------------------------------------------------------------------------- 1 | import { utility } from "./utils.js"; 2 | 3 | console.log(utility("It worked!")); 4 | -------------------------------------------------------------------------------- /integration-test/esm-ts-from-ts/run.ts: -------------------------------------------------------------------------------- 1 | import { utility } from "./utils.js"; 2 | 3 | console.log(utility("It worked!")); 4 | -------------------------------------------------------------------------------- /integration-test/oom/run.ts: -------------------------------------------------------------------------------- 1 | const leak = []; 2 | while (true) { 3 | leak.push("consuming memory".repeat(10000)); 4 | } 5 | -------------------------------------------------------------------------------- /spec/fixtures/esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wds-test-sources", 3 | "version": "0.0.1", 4 | "license": "MIT" 5 | } 6 | -------------------------------------------------------------------------------- /spec/fixtures/src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wds-test-sources", 3 | "version": "0.0.1", 4 | "license": "MIT" 5 | } 6 | -------------------------------------------------------------------------------- /integration-test/cjs-with-esm-disabled/run.ts: -------------------------------------------------------------------------------- 1 | import { utility } from "./utils"; 2 | 3 | console.log(utility("It worked!")); 4 | -------------------------------------------------------------------------------- /spec/fixtures/src/files_with_swcrc/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "module": { 3 | "type": "commonjs", 4 | "strictMode": false 5 | } 6 | } -------------------------------------------------------------------------------- /spec/fixtures/failing/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wds-test-failing-sources", 3 | "version": "0.0.1", 4 | "license": "MIT" 5 | } 6 | -------------------------------------------------------------------------------- /spec/fixtures/src/files_with_swcrc/nested/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "module": { 3 | "type": "commonjs", 4 | "strictMode": true 5 | } 6 | } -------------------------------------------------------------------------------- /spec/fixtures/src/lazy_import.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "child_process"; 2 | 3 | export function reSpawn() { 4 | return spawn; 5 | } 6 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace-lazy/main/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "module": { 3 | "type": "commonjs", 4 | "lazy": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace-lazy/side/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "module": { 3 | "type": "commonjs", 4 | "lazy": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /spec/fixtures/configs/with-extensions/wds.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extensions: [".ts", ".js"], 3 | ignore: ["**/*.spec.ts"] 4 | }; 5 | -------------------------------------------------------------------------------- /spec/fixtures/src/files_with_config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wds-test-files-with-config", 3 | "version": "0.0.1", 4 | "license": "MIT" 5 | } 6 | -------------------------------------------------------------------------------- /spec/fixtures/src/files_with_swcrc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wds-test-files-with-swcrc", 3 | "version": "0.0.1", 4 | "license": "MIT" 5 | } 6 | -------------------------------------------------------------------------------- /spec/fixtures/configs/outside-root/wds.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignore: ["../../some-external-file.ts", "../../../other-file.tsx", "../../tmp"] 3 | }; 4 | -------------------------------------------------------------------------------- /integration-test/parent-crash/run.ts: -------------------------------------------------------------------------------- 1 | process.stderr.write("child started\n") 2 | setInterval(() => { 3 | process.stderr.write("child still alive\n") 4 | }, 200) 5 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace/side/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "side", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "type": "module" 6 | } 7 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace-lazy/side/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "side", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "type": "commonjs" 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | }, 6 | "include": ["src", "spec"], 7 | "exclude": ["spec/fixtures"] 8 | } 9 | -------------------------------------------------------------------------------- /integration-test/simple-cjs/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -ex 4 | 5 | $DIR/../../pkg/wds.bin.js $@ $DIR/run.ts | grep "IT WORKED" -------------------------------------------------------------------------------- /integration-test/swc-helpers/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -ex 4 | 5 | $DIR/../../pkg/wds.bin.js $@ $DIR/run.ts | grep "itWorked" -------------------------------------------------------------------------------- /integration-test/cjs-js-from-ts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -ex 4 | 5 | $DIR/../../pkg/wds.bin.js $@ $DIR/run.ts | grep "IT WORKED" -------------------------------------------------------------------------------- /integration-test/cjs-sourcemap/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cjs-sourcemap", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "license": "ISC" 8 | } 9 | -------------------------------------------------------------------------------- /integration-test/cjs-ts-from-js/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -ex 4 | 5 | $DIR/../../pkg/wds.bin.js $@ $DIR/run.js | grep "IT WORKED" -------------------------------------------------------------------------------- /integration-test/cjs-ts-from-ts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -ex 4 | 5 | $DIR/../../pkg/wds.bin.js $@ $DIR/run.ts | grep "IT WORKED" -------------------------------------------------------------------------------- /integration-test/esm-js-from-ts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -ex 4 | 5 | $DIR/../../pkg/wds.bin.js $@ $DIR/run.ts | grep "IT WORKED" -------------------------------------------------------------------------------- /integration-test/esm-sourcemap/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esm-sourcemap", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "license": "ISC" 8 | } 9 | -------------------------------------------------------------------------------- /integration-test/esm-ts-from-js/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -ex 4 | 5 | $DIR/../../pkg/wds.bin.js $@ $DIR/run.js | grep "IT WORKED" -------------------------------------------------------------------------------- /integration-test/esm-ts-from-ts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -ex 4 | 5 | $DIR/../../pkg/wds.bin.js $@ $DIR/run.ts | grep "IT WORKED" -------------------------------------------------------------------------------- /integration-test/cjs-require-cache/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -ex 4 | 5 | $DIR/../../pkg/wds.bin.js $@ $DIR/run.ts | grep "IT WORKED" -------------------------------------------------------------------------------- /integration-test/cjs-with-esm-disabled/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -ex 4 | 5 | $DIR/../../pkg/wds.bin.js $@ $DIR/run.ts | grep "IT WORKED" -------------------------------------------------------------------------------- /src/bench/protocol.ts: -------------------------------------------------------------------------------- 1 | export const MARKER = "[wds-bench]:"; 2 | export type ChildProcessResult = { 3 | event: string; 4 | duration: number; 5 | startTime: bigint; 6 | endTime: bigint; 7 | code: number; 8 | }; 9 | -------------------------------------------------------------------------------- /integration-test/cjs-require-cache/run.ts: -------------------------------------------------------------------------------- 1 | import { utility } from "./utils"; 2 | 3 | if (!require.cache) { 4 | throw new Error("require.cache not found in entrypoint file"); 5 | } 6 | console.log(utility("It worked!")); 7 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "node", 6 | include: ["spec/**/*.{test,spec}.?(c|m)[jt]s?(x)"], 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace/main/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "main", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "type": "module", 6 | "dependencies": { 7 | "side": "workspace:*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace-lazy/main/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "main", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "type": "commonjs", 6 | "dependencies": { 7 | "side": "workspace:*" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /integration-test/oom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oom", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "license": "ISC" 10 | } 11 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": true, 6 | "decorators": true, 7 | "dynamicImport": true 8 | }, 9 | "target": "esnext" 10 | }, 11 | "module": { 12 | "type": "es6" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /integration-test/cjs-sourcemap/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -ex 4 | 5 | $DIR/../../pkg/wds.bin.js $DIR/run.ts 2>&1 | tee /dev/stderr | grep "sourcemap/utils.ts:7" 6 | echo "Found correct source location" -------------------------------------------------------------------------------- /integration-test/esm-sourcemap/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -ex 4 | 5 | $DIR/../../pkg/wds.bin.js $DIR/run.ts 2>&1 | tee /dev/stderr | grep "sourcemap/utils.ts:7" 6 | echo "Found correct source location" -------------------------------------------------------------------------------- /src/wds.bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | "use strict"; 3 | import { cli } from "./index.js"; 4 | 5 | try { 6 | await cli(process.argv); 7 | } catch (error) { 8 | console.error(` 9 | ${error.stack || error.message || error} 10 | `); 11 | process.exit(1); 12 | } 13 | -------------------------------------------------------------------------------- /gitpkg.config.js: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | 3 | export default () => ({ 4 | getTagName: (pkg) => 5 | `${pkg.name}-v${pkg.version}-gitpkg-${execSync( 6 | 'git rev-parse --short HEAD', 7 | { encoding: 'utf-8' } 8 | ).trim()}` 9 | }) 10 | -------------------------------------------------------------------------------- /integration-test/parent-crash/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parent-crash", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "license": "ISC" 10 | } 11 | -------------------------------------------------------------------------------- /spec/fixtures/esm/wds.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | esm: true, 3 | swc: { 4 | jsc: { 5 | parser: { 6 | syntax: "typescript", 7 | decorators: true, 8 | dynamicImport: true, 9 | }, 10 | target: "es2020", 11 | }, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /integration-test/reload/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reload", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /integration-test/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /integration-test/oom/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -ex 4 | 5 | $DIR/../../pkg/wds.bin.js $@ --max-old-space-size=50 $DIR/run.ts 2>&1 | grep -E "ReportOOMFailure|JavaScript heap out of memory" 6 | 7 | echo "found OOM failure" -------------------------------------------------------------------------------- /integration-test/simple-cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /integration-test/esm-ts-from-ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-esm", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /integration-test/cjs-js-from-ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cjs-js-from-ts", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /integration-test/cjs-sourcemap/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": true, 6 | "decorators": true, 7 | "dynamicImport": true 8 | }, 9 | "target": "esnext" 10 | }, 11 | "module": { 12 | "type": "commonjs" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /integration-test/cjs-ts-from-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cjs-ts-from-js", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /integration-test/cjs-ts-from-ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cjs-ts-from-ts", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /integration-test/cjs-with-esm-disabled/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /integration-test/esm-js-from-ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esm-js-from-ts", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /integration-test/esm-ts-from-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esm-ts-from-js", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /integration-test/reload/run.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | 3 | const requestListener = function (req, res) { 4 | res.writeHead(200); 5 | res.end("Hello, World!"); 6 | }; 7 | 8 | const server = http.createServer(requestListener); 9 | server.listen(8080); 10 | console.warn("Listening on 8080"); 11 | -------------------------------------------------------------------------------- /integration-test/server/run.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | 3 | const requestListener = function (req, res) { 4 | res.writeHead(200); 5 | res.end("Hello, World!"); 6 | }; 7 | 8 | const server = http.createServer(requestListener); 9 | server.listen(8080); 10 | console.warn("Listening on 8080"); 11 | -------------------------------------------------------------------------------- /integration-test/cjs-js-from-ts/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": true, 6 | "decorators": true, 7 | "dynamicImport": true 8 | }, 9 | "target": "esnext" 10 | }, 11 | "module": { 12 | "type": "commonjs" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /integration-test/cjs-require-cache/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": true, 6 | "decorators": true, 7 | "dynamicImport": true 8 | }, 9 | "target": "esnext" 10 | }, 11 | "module": { 12 | "type": "commonjs" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /integration-test/cjs-require-cache/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cjs-require-cache", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /integration-test/cjs-ts-from-js/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": true, 6 | "decorators": true, 7 | "dynamicImport": true 8 | }, 9 | "target": "esnext" 10 | }, 11 | "module": { 12 | "type": "commonjs" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /integration-test/cjs-ts-from-ts/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": true, 6 | "decorators": true, 7 | "dynamicImport": true 8 | }, 9 | "target": "esnext" 10 | }, 11 | "module": { 12 | "type": "commonjs" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /integration-test/reload/run-scratch.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | 3 | const requestListener = function (req, res) { 4 | res.writeHead(200); 5 | res.end("Hey, Pluto!"); 6 | }; 7 | 8 | const server = http.createServer(requestListener); 9 | server.listen(8080); 10 | console.warn("Listening on 8080"); 11 | -------------------------------------------------------------------------------- /integration-test/cjs-sourcemap/utils.ts: -------------------------------------------------------------------------------- 1 | // add some lines that move the source lines around but not the output lines 2 | export type Whatever = any; 3 | 4 | /** A nice util */ 5 | export const utility = (str: string) => { 6 | // this is on line 7 which we look for in the tests 7 | throw new Error("error in utils"); 8 | }; 9 | -------------------------------------------------------------------------------- /integration-test/esm-sourcemap/utils.ts: -------------------------------------------------------------------------------- 1 | // add some lines that move the source lines around but not the output lines 2 | export type Whatever = any; 3 | 4 | /** A nice util */ 5 | export const utility = (str: string) => { 6 | // this is on line 7 which we look for in the tests 7 | throw new Error("error in utils"); 8 | }; 9 | -------------------------------------------------------------------------------- /spec/fixtures/src/add.ts: -------------------------------------------------------------------------------- 1 | const timeout = setTimeout(() => null, Math.pow(2, 31) - 1); 2 | process.on("message", (message: any) => { 3 | if (message === "exit") { 4 | clearTimeout(timeout); 5 | process.exit(0); 6 | } else { 7 | process.send(message + 1); 8 | } 9 | }); 10 | 11 | process.send("ready"); 12 | -------------------------------------------------------------------------------- /spec/fixtures/src/echo.ts: -------------------------------------------------------------------------------- 1 | process.stdin.resume(); 2 | 3 | async function main() { 4 | if (!process.stdin.readable) { 5 | process.stdout.write("stdin is not readable"); 6 | } 7 | process.stdin.pipe(process.stdout); 8 | process.stdin.on("end", () => { 9 | process.exit(0); 10 | }) 11 | } 12 | 13 | main() -------------------------------------------------------------------------------- /integration-test/simple-cjs/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": true, 6 | "decorators": true, 7 | "dynamicImport": true 8 | }, 9 | "target": "esnext" 10 | }, 11 | "module": { 12 | "type": "commonjs", 13 | "lazy": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '6.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | main: 10 | dependencies: 11 | side: 12 | specifier: workspace:* 13 | version: link:../side 14 | 15 | side: {} 16 | -------------------------------------------------------------------------------- /integration-test/cjs-with-esm-disabled/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": true, 6 | "decorators": true, 7 | "dynamicImport": true 8 | }, 9 | "target": "esnext" 10 | }, 11 | "module": { 12 | "type": "commonjs", 13 | "lazy": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace-lazy/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '6.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | main: 10 | dependencies: 11 | side: 12 | specifier: workspace:* 13 | version: link:../side 14 | 15 | side: {} 16 | -------------------------------------------------------------------------------- /spec/fixtures/src/files_with_config/wds.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "swc": { 3 | "jsc": { 4 | "parser": { 5 | "syntax": "typescript" 6 | }, 7 | "target": "es5" 8 | }, 9 | "module": { 10 | "type": "commonjs", 11 | "strictMode": false 12 | } 13 | }, 14 | ignore: ["ignored.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /integration-test/cjs-require-cache/utils.ts: -------------------------------------------------------------------------------- 1 | if (!require.cache) { 2 | throw new Error("require.cache not found in utils file module scope"); 3 | } 4 | export const utility = (str: string) => { 5 | if (!require.cache) { 6 | throw new Error("require.cache not found in utils file function scope"); 7 | } 8 | return str.toUpperCase(); 9 | }; 10 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace/main/run.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | import { message } from "side/run-scratch"; 3 | 4 | const requestListener = function (req, res) { 5 | res.writeHead(200); 6 | res.end(message); 7 | }; 8 | 9 | const server = http.createServer(requestListener); 10 | server.listen(8080); 11 | console.warn("Listening on 8080"); 12 | -------------------------------------------------------------------------------- /integration-test/swc-helpers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swc-helpers", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "dependencies": { 11 | "@swc/helpers": "*" 12 | }, 13 | "license": "ISC" 14 | } 15 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace-lazy/main/run.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | import { message } from "side/run-scratch"; 3 | 4 | const requestListener = function (req, res) { 5 | res.writeHead(200); 6 | res.end(message); 7 | }; 8 | 9 | const server = http.createServer(requestListener); 10 | server.listen(8080); 11 | console.warn("Listening on 8080"); 12 | -------------------------------------------------------------------------------- /spec/fixtures/src/wds.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | swc: { 3 | jsc: { 4 | parser: { 5 | syntax: "typescript", 6 | decorators: true, 7 | dynamicImport: true, 8 | }, 9 | target: "es2020", 10 | }, 11 | module: { 12 | type: "commonjs", 13 | strictMode: true, 14 | lazy: true, 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/bench/json.ts: -------------------------------------------------------------------------------- 1 | export const json = { 2 | stringify: (data: any): string => { 3 | return JSON.stringify(data, (key, value) => { 4 | return typeof value === "bigint" ? value.toString() + "n" : value; 5 | }); 6 | }, 7 | parse: (str: string): any => { 8 | return JSON.parse(str, (key, value) => { 9 | if (typeof value === "string" && /^\d+n$/.test(value)) { 10 | return BigInt(value.substr(0, value.length - 1)); 11 | } 12 | return value; 13 | }); 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /spec/fixtures/src/grandchild.js: -------------------------------------------------------------------------------- 1 | console.log(`grandchild:ready:${process.pid}`); 2 | 3 | process.on("SIGINT", () => { 4 | // swallow SIGINT and log 5 | // let wds kill this process after a timeout 6 | console.log("grandchild:sigint"); 7 | }); 8 | 9 | process.on("SIGQUIT", () => { 10 | console.log("grandchild:sigquit"); 11 | }); 12 | 13 | process.on("SIGTERM", () => { 14 | console.log("grandchild:sigterm"); 15 | process.exit(0); 16 | }); 17 | 18 | // Keep alive 19 | setInterval(() => {}, 1e9); 20 | 21 | 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | workflow_call: 6 | 7 | jobs: 8 | test: 9 | timeout-minutes: 30 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: ./.github/actions/setup-test-env 15 | - run: pnpm build 16 | - run: pnpm test 17 | - run: pnpm integration-test 18 | 19 | lint: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: ./.github/actions/setup-test-env 24 | - run: pnpm lint 25 | -------------------------------------------------------------------------------- /src/bench/bench-child-hooks.ts: -------------------------------------------------------------------------------- 1 | import { json } from "./json.js"; 2 | import type { ChildProcessResult } from "./protocol.js"; 3 | import { MARKER } from "./protocol.js"; 4 | 5 | const startTime = process.hrtime.bigint(); 6 | 7 | process.on("exit", (code) => { 8 | const endTime = process.hrtime.bigint(); 9 | 10 | const metrics: ChildProcessResult = { 11 | event: "exit", 12 | startTime, 13 | endTime, 14 | code: code, 15 | duration: Number(endTime - startTime), 16 | }; 17 | 18 | process.stdout.write(`${MARKER}${json.stringify(metrics)}`); 19 | }); 20 | -------------------------------------------------------------------------------- /integration-test/swc-helpers/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/swcrc", 3 | "jsc": { 4 | "parser": { 5 | "syntax": "typescript", 6 | "tsx": true, 7 | "decorators": true, 8 | "dynamicImport": true 9 | }, 10 | "target": "es2022", 11 | "externalHelpers": true 12 | }, 13 | "module": { 14 | "type": "commonjs", 15 | // turn on lazy imports for maximum reboot performance 16 | "lazy": true, 17 | // leave dynamic imports as import statements so we can import ESM 18 | "ignoreDynamic": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /integration-test/server/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -e 4 | 5 | # kill the server when this script exits 6 | trap "kill -9 0" INT TERM 7 | trap 'kill $(jobs -p)' EXIT 8 | 9 | $DIR/../../pkg/wds.bin.js $@ $DIR/run.ts & 10 | 11 | max_retry=5 12 | counter=0 13 | 14 | set +e 15 | until curl -s localhost:8080 | grep "Hello" 16 | do 17 | sleep 1 18 | [[ counter -eq $max_retry ]] && echo "Failed!" && exit 1 19 | echo "Trying again. Try #$counter" 20 | ((counter++)) 21 | done 22 | 23 | echo "Made request to server" 24 | 25 | exit 0 -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "esModuleInterop": true, 5 | "allowJs": true, 6 | "noImplicitAny": true, 7 | "sourceMap": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "skipLibCheck": true, 10 | "lib": ["es2020"], 11 | "resolveJsonModule": true, 12 | "moduleResolution": "NodeNext", 13 | "module": "NodeNext", 14 | "target": "es2020", 15 | "types": ["node", "vitest/globals"], 16 | "declaration": true, 17 | "outDir": "./pkg", 18 | "rootDir": "./src" 19 | }, 20 | "include": ["src"], 21 | "exclude": ["spec/fixtures"] 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/child-process-esm-hook.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Entrypoint file passed as --import to all child processes started by wds 3 | */ 4 | import { register } from "node:module"; 5 | import { log } from "./utils.cjs"; 6 | 7 | if (!register) { 8 | throw new Error( 9 | `This version of Node.js (${process.version}) does not support module.register(). Please upgrade to Node v18.19 or v20.6 and above.` 10 | ); 11 | } 12 | 13 | // register the CJS hook to intercept require calls the old way 14 | 15 | log.debug("registering wds ESM loader"); 16 | // register the ESM loader the new way 17 | register("./child-process-esm-loader.js", import.meta.url); 18 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { threadId } from "worker_threads"; 2 | const logPrefix = `[wds pid=${process.pid} thread=${threadId}]`; 3 | export const log = { 4 | debug: (...args: any[]) => process.env["WDS_DEBUG"] && console.warn(logPrefix, ...args), 5 | info: (...args: any[]) => console.warn(logPrefix, ...args), 6 | warn: (...args: any[]) => console.warn(logPrefix, ...args), 7 | error: (...args: any[]) => console.error(logPrefix, ...args), 8 | }; 9 | 10 | export async function time(run: () => Promise) { 11 | const time = process.hrtime(); 12 | await run(); 13 | const diff = process.hrtime(time); 14 | 15 | return (diff[0] + diff[1] / 1e9).toFixed(5); 16 | } 17 | -------------------------------------------------------------------------------- /Contributing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Gotta build the package, which you can do once with `pnpm build`, or watch your local development directory and rebuild when things change with `pnpm watch`. 4 | 5 | Then gotta use it somewhere, which tends to be easiest in a project. I use `pnpm link` for this. 6 | 7 | # Releasing 8 | 9 | Releases are managed automatically by Github Actions. To create a new release, follow these steps: 10 | 11 | 1. Run `npm version minor|major|patch`. This will change the version in the package.json and create a new git commit changing it. 12 | 2. Push this commit to the `main` branch. CI will run the tests, then run the release workflow, which publishes to NPM, create a Github release, and creates a git tag for the version. 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - 'package.json' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | uses: ./.github/workflows/test.yml 13 | release: 14 | needs: test 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: ./.github/actions/setup-test-env 19 | - id: npm-publish 20 | name: Publish wds to npm 21 | uses: JS-DevTools/npm-publish@v1 22 | with: 23 | token: ${{ secrets.NPM_TOKEN }} 24 | access: public 25 | - name: Publish Release to github 26 | uses: softprops/action-gh-release@v1 27 | if: ${{ steps.npm-publish.outputs.type != 'none' }} 28 | with: 29 | tag_name: ${{ steps.npm-publish.outputs.version }} 30 | generate_release_notes: true -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "wds development environment"; 3 | 4 | inputs = { 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 7 | }; 8 | 9 | outputs = { self, flake-utils, nixpkgs }: 10 | (flake-utils.lib.eachSystem [ 11 | "x86_64-linux" 12 | "x86_64-darwin" 13 | "aarch64-darwin" 14 | ] 15 | (system: nixpkgs.lib.fix (flake: 16 | let 17 | pkgs = nixpkgs.legacyPackages.${system}; 18 | in 19 | rec { 20 | 21 | packages = 22 | rec { 23 | bash = pkgs.bash; 24 | nodejs = pkgs.nodejs_23; 25 | corepack = pkgs.corepack_23; 26 | }; 27 | 28 | devShell = pkgs.mkShell { 29 | packages = builtins.attrValues packages; 30 | }; 31 | } 32 | ))); 33 | } 34 | -------------------------------------------------------------------------------- /integration-test/test.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | import path from 'path'; 3 | import 'zx/globals'; 4 | 5 | // Get the directory of the current script 6 | const DIR = path.dirname(new URL(import.meta.url).pathname); 7 | 8 | // Find all test.sh and test.js files in subfolders 9 | const testFiles = await glob('*/test.{sh,js}', { cwd: DIR, ignore: 'node_modules/**' }); 10 | 11 | for (const testFile of testFiles.sort()) { 12 | const fullPath = path.join(DIR, testFile); 13 | const folderName = path.basename(path.dirname(testFile)); 14 | 15 | console.log(`::group::${folderName} test ${argv._}`); 16 | 17 | if (testFile.endsWith('.sh')) { 18 | await $`bash ${fullPath} ${argv._}`.stdio('inherit', 'inherit', 'inherit'); 19 | } else if (testFile.endsWith('.js')) { 20 | await $`zx ${fullPath} ${argv._}`.stdio('inherit', 'inherit', 'inherit'); 21 | } 22 | 23 | console.log('::endgroup::'); 24 | console.log(); 25 | } -------------------------------------------------------------------------------- /src/Compiler.ts: -------------------------------------------------------------------------------- 1 | export type Compiler = { 2 | /** 3 | * When a file operation occurs that requires setting up all the builds again, we run this. 4 | * The operations that should cause an invalidation are: 5 | * - a change in the tsconfig.json 6 | * - any new file being added 7 | * - any existing file being deleted 8 | */ 9 | invalidateBuildSet(): Promise; 10 | 11 | /** 12 | * Compiles a new file at `filename`. 13 | **/ 14 | compile(filename: string): Promise; 15 | 16 | /** 17 | * For a given input filename, return all the destinations of the files compiled alongside it in its compilation group. 18 | **/ 19 | fileGroup(filename: string): Promise>; 20 | 21 | /** 22 | * Invalidates a compiled file, after it changes on disk. 23 | */ 24 | invalidate(filename: string): void; 25 | 26 | /** 27 | * Rebuilds invalidated files 28 | */ 29 | rebuild(): Promise; 30 | }; 31 | -------------------------------------------------------------------------------- /spec/PathTrie.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { PathTrie } from "../src/PathTrie.js"; 3 | 4 | describe("PathTrie", () => { 5 | it("should insert and search for paths", () => { 6 | const trie = new PathTrie(); 7 | trie.insert("foo/bar"); 8 | expect(trie.contains("foo/bar")).toBe(true); 9 | expect(trie.contains("foo/baz")).toBe(false); 10 | }); 11 | 12 | it("should check if any paths in the trie start with a given prefix", () => { 13 | const trie = new PathTrie(); 14 | trie.insert("foo/bar"); 15 | trie.insert("foo/baz"); 16 | trie.insert("foo/qux/corge"); 17 | expect(trie.anyStartsWith("foo")).toBe(true); 18 | expect(trie.anyStartsWith("foo/bar")).toBe(true); 19 | expect(trie.anyStartsWith("foo/corge")).toBe(false); 20 | expect(trie.anyStartsWith("qux")).toBe(false); 21 | expect(trie.anyStartsWith("foo/qux")).toBe(true); 22 | expect(trie.anyStartsWith("foo/qux/corge")).toBe(true); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 Gadget Software Inc 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE -------------------------------------------------------------------------------- /integration-test/reload/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -e 4 | 5 | cp $DIR/run.ts $DIR/run-scratch.ts 6 | 7 | # kill the server when this script exits 8 | trap "kill -9 0" INT TERM 9 | trap 'kill $(jobs -p)' EXIT 10 | 11 | # run a server in the background 12 | $DIR/../../pkg/wds.bin.js $@ --watch --commands $DIR/run-scratch.ts & 13 | 14 | max_retry=5 15 | counter=0 16 | 17 | set +e 18 | until curl -s localhost:8080 | grep "World" 19 | do 20 | sleep 1 21 | [[ counter -eq $max_retry ]] && echo "Failed!" && exit 1 22 | echo "Trying again. Try #$counter" 23 | ((counter++)) 24 | done 25 | 26 | echo "Made initial request to server" 27 | 28 | # modify it and expect it to start serving the new contents 29 | sed -i 's/Hello, World/Hey, Pluto/g' $DIR/run-scratch.ts 30 | 31 | counter=0 32 | until curl -s localhost:8080 | grep "Pluto" 33 | do 34 | sleep 1 35 | [[ counter -eq $max_retry ]] && echo "Failed!" && exit 1 36 | echo "Trying again. Try #$counter" 37 | ((counter++)) 38 | done 39 | 40 | echo "Made new request to reloaded server" 41 | 42 | exit 0 43 | -------------------------------------------------------------------------------- /src/wds-bench.bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node --enable-source-maps 2 | "use strict"; 3 | import yargs from "yargs"; 4 | import { hideBin } from "yargs/helpers"; 5 | import { benchBoot, benchReload } from "./bench/bench.js"; 6 | 7 | export const cli = async () => { 8 | const args = yargs(hideBin(process.argv)) 9 | .option("runs", { 10 | type: "number", 11 | default: 10, 12 | }) 13 | .option("type", { 14 | choices: ["boot", "reload"], 15 | description: `Type of benchmark to run 16 | Select 'boot' to measure the time taken to run a file from cold boot. 17 | Select 'reload' to measure how long it takes to reload once a file is modified. 18 | `, 19 | default: "", 20 | }).argv; 21 | 22 | const benchArgs = { 23 | runs: args.runs, 24 | argv: args._, 25 | }; 26 | 27 | switch (args.type) { 28 | case "boot": 29 | await benchBoot(benchArgs); 30 | break; 31 | case "reload": 32 | await benchReload(benchArgs); 33 | break; 34 | default: 35 | throw new Error(`Unhandled type of benchmark: ${args.type}`); 36 | } 37 | }; 38 | 39 | void cli(); 40 | -------------------------------------------------------------------------------- /spec/fixtures/src/signal-order.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "child_process"; 2 | import * as path from "path"; 3 | 4 | // Spawn a long-lived grandchild that logs when it gets SIGTERM 5 | // Resolve from project root (wds runs child with cwd at project root) 6 | const grandchildPath = path.resolve(process.cwd(), "spec/fixtures/src/grandchild.js"); 7 | const grandchild = spawn("node", [grandchildPath], { 8 | stdio: ["ignore", "inherit", "inherit"], 9 | }); 10 | 11 | console.log(`parent:ready:${process.pid}`); 12 | 13 | const exit = (signal: NodeJS.Signals) => { 14 | console.log(`parent:exit-${signal}`); 15 | 16 | grandchild.once("exit", () => { 17 | console.log("parent:grandchild-exit"); 18 | process.exit(0); 19 | }); 20 | 21 | setTimeout(() => { 22 | console.log("parent:exit-timeout"); 23 | process.exit(0); 24 | }, 2000); 25 | 26 | grandchild.kill(signal); 27 | } 28 | 29 | process.on("SIGTERM", () => exit("SIGTERM")); 30 | process.on("SIGINT", () => exit("SIGINT")); 31 | process.on("SIGQUIT", () => { 32 | console.log("parent:sigquit"); 33 | }); 34 | 35 | // Keep the process alive indefinitely until signaled 36 | setInterval(() => {}, 1e9); 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/hooks/compileInLeaderProcess.cts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import http from "http"; 3 | import { debugLog } from "../SyncWorker.cjs"; 4 | 5 | // async function to ask the leader process to do the compilation and hand us back a list of newly compiled source filenames to compiled filenames 6 | export async function compileInLeaderProcess(filename: string): Promise> { 7 | return await new Promise((resolve, reject) => { 8 | const request = http.request( 9 | { socketPath: process.env["WDS_SOCKET_PATH"]!, path: "/compile", method: "POST", timeout: 200 }, 10 | (resp) => { 11 | let data = ""; 12 | if (resp.statusCode !== 200) { 13 | return reject(`Error compiling ${filename}, parent process responded with status ${resp.statusCode}`); 14 | } 15 | resp.on("data", (chunk: string) => (data += chunk)); 16 | resp.on("end", () => resolve(JSON.parse(data).filenames)); 17 | } 18 | ); 19 | 20 | request.on("error", (error) => { 21 | debugLog?.(`Error compiling file ${filename}:`, error); 22 | reject(error); 23 | }); 24 | request.write(filename); 25 | request.end(); 26 | }); 27 | } 28 | 29 | export default compileInLeaderProcess; 30 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -e 4 | 5 | 6 | # kill the server when this script exits 7 | trap "kill -9 0" INT TERM 8 | trap 'kill $(jobs -p)' EXIT 9 | 10 | # setup the pnpm workspace with multiple packages 11 | cd $DIR 12 | pnpm install 13 | 14 | # make a copy of the run.ts file in the side package for us to modify 15 | cp $DIR/side/run.ts $DIR/side/run-scratch.ts 16 | 17 | # run a server in the main package in the background 18 | $DIR/../../pkg/wds.bin.js $@ --watch --commands $DIR/main/run.ts & 19 | 20 | max_retry=5 21 | counter=0 22 | 23 | set +e 24 | until curl -s localhost:8080 | grep "World" 25 | do 26 | sleep 1 27 | [[ counter -eq $max_retry ]] && echo "Failed!" && exit 1 28 | echo "Trying again. Try #$counter" 29 | ((counter++)) 30 | done 31 | 32 | echo "Made initial request to server" 33 | 34 | # modify the file in the side package and expect the main script to reload 35 | sed -i 's/Hello, World/Hey, Pluto/g' $DIR/side/run-scratch.ts 36 | 37 | echo "Made change to side package" 38 | 39 | counter=0 40 | until curl -s localhost:8080 | grep "Pluto" 41 | do 42 | sleep 1 43 | [[ counter -eq $max_retry ]] && echo "Failed!" && exit 1 44 | echo "Trying again. Try #$counter" 45 | ((counter++)) 46 | done 47 | 48 | echo "Made new request to reloaded server" 49 | 50 | exit 0 51 | -------------------------------------------------------------------------------- /integration-test/reload-cross-workspace-lazy/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | set -e 4 | 5 | 6 | # kill the server when this script exits 7 | trap "kill -9 0" INT TERM 8 | trap 'kill $(jobs -p)' EXIT 9 | 10 | # setup the pnpm workspace with multiple packages 11 | cd $DIR 12 | pnpm install 13 | 14 | # make a copy of the run.ts file in the side package for us to modify 15 | cp $DIR/side/run.ts $DIR/side/run-scratch.ts 16 | 17 | # run a server in the main package in the background 18 | $DIR/../../pkg/wds.bin.js $@ --watch --commands $DIR/main/run.ts & 19 | 20 | max_retry=5 21 | counter=0 22 | 23 | set +e 24 | until curl -s localhost:8080 | grep "World" 25 | do 26 | sleep 1 27 | [[ counter -eq $max_retry ]] && echo "Failed!" && exit 1 28 | echo "Trying again. Try #$counter" 29 | ((counter++)) 30 | done 31 | 32 | echo "Made initial request to server" 33 | 34 | # modify the file in the side package and expect the main script to reload 35 | sed -i 's/Hello, World/Hey, Pluto/g' $DIR/side/run-scratch.ts 36 | 37 | echo "Made change to side package" 38 | 39 | counter=0 40 | until curl -s localhost:8080 | grep "Pluto" 41 | do 42 | sleep 1 43 | [[ counter -eq $max_retry ]] && echo "Failed!" && exit 1 44 | echo "Trying again. Try #$counter" 45 | ((counter++)) 46 | done 47 | 48 | echo "Made new request to reloaded server" 49 | 50 | exit 0 51 | -------------------------------------------------------------------------------- /.github/actions/setup-test-env/action.yml: -------------------------------------------------------------------------------- 1 | name: "Setup test environment" 2 | description: "" 3 | inputs: {} 4 | outputs: {} 5 | runs: 6 | using: "composite" 7 | steps: 8 | - uses: cachix/install-nix-action@v30 9 | - run: | 10 | source <(nix print-dev-env --show-trace) 11 | output_file="nix-env.txt" 12 | 13 | # Clear the output file 14 | > $output_file 15 | 16 | # Loop over each variable in the environment 17 | while IFS='=' read -r -d '' name value; do 18 | # Skip if the variable is a function or read-only or non-alphanumeric 19 | [[ "$(declare -p $name)" =~ "declare -[a-z]*r[a-z]* " ]] && continue 20 | [[ ! $name =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] && continue 21 | 22 | # Check if the variable value contains a newline 23 | if [[ "$value" != *$'\n'* ]]; then 24 | # It doesn't, so write the variable and its value (stripping quotes) to the file 25 | echo "${name}=${value//\"/}" >> $output_file 26 | fi 27 | done < <(env -0) 28 | 29 | # useful for debugging what env is exported 30 | # cat nix-env.txt 31 | shell: bash 32 | - run: cat nix-env.txt >> "$GITHUB_ENV" 33 | shell: bash 34 | - name: Add Gadget npm registry 35 | shell: bash 36 | run: npm config set @gadget-client:registry https://registry.gadget.dev/npm 37 | - name: Install dependencies with pnpm 38 | shell: bash 39 | run: pnpm install -------------------------------------------------------------------------------- /src/hooks/utils.cts: -------------------------------------------------------------------------------- 1 | import type { RequestOptions } from "http"; 2 | import http from "http"; 3 | import _ from "lodash"; 4 | import { threadId } from "worker_threads"; 5 | 6 | const logPrefix = `[wds pid=${process.pid} thread=${threadId}]`; 7 | export const log = { 8 | debug: (...args: any[]) => process.env["WDS_DEBUG"] && console.warn(logPrefix, ...args), 9 | info: (...args: any[]) => console.warn(logPrefix, ...args), 10 | warn: (...args: any[]) => console.warn(logPrefix, ...args), 11 | error: (...args: any[]) => console.error(logPrefix, ...args), 12 | }; 13 | 14 | let pendingRequireNotifications: string[] = []; 15 | const throttledRequireFlush = _.throttle(() => { 16 | try { 17 | const options: RequestOptions = { socketPath: process.env["WDS_SOCKET_PATH"]!, path: "/file-required", method: "POST", timeout: 300 }; 18 | const request = http.request(options, () => { 19 | // don't care if it worked 20 | }); 21 | 22 | request.on("error", (error: any) => { 23 | log.debug(`Unexpected request error while flushing require notifications`, error); 24 | }); 25 | request.write(JSON.stringify(pendingRequireNotifications)); 26 | request.end(); 27 | pendingRequireNotifications = []; 28 | } catch (error) { 29 | // errors sometimes thrown during shutdown process, we don't care 30 | log.debug("error flushing require notifications", error); 31 | } 32 | }); 33 | 34 | export const notifyParentProcessOfRequire = (filename: string) => { 35 | pendingRequireNotifications.push(filename); 36 | void throttledRequireFlush(); 37 | }; 38 | -------------------------------------------------------------------------------- /src/PathTrie.ts: -------------------------------------------------------------------------------- 1 | class TrieNode { 2 | children: Record = {}; 3 | isEndOfWord = false; 4 | } 5 | 6 | /** 7 | * Prefix matching datastructure for holding paths and seeing if any match a given incoming path 8 | **/ 9 | export class PathTrie { 10 | root = new TrieNode(); 11 | seen = new Set(); 12 | 13 | insert(path: string) { 14 | if (this.seen.has(path)) { 15 | return; 16 | } 17 | let node = this.root; 18 | const segments = path.split("/"); 19 | for (const segment of segments) { 20 | if (!node.children[segment]) { 21 | node.children[segment] = new TrieNode(); 22 | } 23 | node = node.children[segment]; 24 | } 25 | this.seen.add(path); 26 | node.isEndOfWord = true; 27 | } 28 | 29 | /** 30 | * Has the incoming path been inserted into the trie? 31 | */ 32 | contains(path: string) { 33 | let node = this.root; 34 | const segments = path.split("/"); 35 | for (const segment of segments) { 36 | if (!node.children[segment]) { 37 | return false; 38 | } 39 | node = node.children[segment]; 40 | } 41 | return node.isEndOfWord; 42 | } 43 | 44 | /** 45 | * Do any paths in the trie start with the given prefix? 46 | */ 47 | anyStartsWith(prefix: string) { 48 | let node = this.root; 49 | const segments = prefix.split("/"); 50 | for (const segment of segments) { 51 | if (!node.children[segment]) { 52 | return false; 53 | } 54 | node = node.children[segment]; 55 | } 56 | return true; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /integration-test/parent-crash/test.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | const assert = require("assert"); 3 | const { exec } = require("child_process"); 4 | 5 | async function getChildPids(parentPid, callback) { 6 | const result = await $`pgrep -P ${parentPid}`; 7 | return result.stdout 8 | .split("\n") 9 | .filter((pid) => pid) 10 | .map((pid) => parseInt(pid, 10)); 11 | } 12 | 13 | function processIsRunning(pid) { 14 | try { 15 | process.kill(pid, 0); 16 | return true; 17 | } catch (e) { 18 | return false; 19 | } 20 | } 21 | 22 | const { setTimeout } = require("timers/promises"); 23 | 24 | const main = async () => { 25 | // launch the wds process 26 | const parent = $`${__dirname}/../../pkg/wds.bin.js --watch ${__dirname}/run.ts`.nothrow(); 27 | 28 | // wait for the wds process to start 29 | await setTimeout(500); 30 | 31 | // get the pid of the child process that the parent wds supervisor will have started 32 | const pids = await getChildPids(parent.child.pid); 33 | assert(pids.length > 0, "no child pids found for supervisor process"); 34 | 35 | // SIGKILL the parent process, as if it OOMed or something like that to simulate a zombie child 36 | console.log(`killing parent (${parent.child.pid})`); 37 | await parent.kill(9); 38 | assert.ok(processIsRunning(pids[0]), "test is broken, child process is not running immediately after parent is dead"); 39 | 40 | // ensure the children are dead too after their monitoring delay 41 | await setTimeout(3000); 42 | 43 | for (const pid of pids) { 44 | assert.ok(!processIsRunning(pid), `child process ${pid} is still running after parent has been killed`); 45 | } 46 | 47 | await parent; 48 | }; 49 | 50 | void main(); 51 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1739138025, 24 | "narHash": "sha256-M4ilIfGxzbBZuURokv24aqJTbdjPA9K+DtKUzrJaES4=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "b2243f41e860ac85c0b446eadc6930359b294e79", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: "@gadgetinc/eslint-config", 3 | parserOptions: { 4 | tsconfigRootDir: __dirname, 5 | project: ["./tsconfig.test.json"], 6 | }, 7 | ignorePatterns: [".eslintrc.cjs", "spec/fixtures/**/*"], 8 | rules: { 9 | "lodash/import-scope": "off", 10 | // Disable all Jest-related ESLint rules 11 | "jest/no-alias-methods": "off", 12 | "jest/no-commented-out-tests": "off", 13 | "jest/no-conditional-expect": "off", 14 | "jest/no-deprecated-functions": "off", 15 | "jest/no-disabled-tests": "off", 16 | "jest/no-done-callback": "off", 17 | "jest/no-duplicate-hooks": "off", 18 | "jest/no-export": "off", 19 | "jest/no-focused-tests": "off", 20 | "jest/no-hooks": "off", 21 | "jest/no-identical-title": "off", 22 | "jest/no-if": "off", 23 | "jest/no-interpolation-in-snapshots": "off", 24 | "jest/no-jasmine-globals": "off", 25 | "jest/no-jest-import": "off", 26 | "jest/no-large-snapshots": "off", 27 | "jest/no-mocks-import": "off", 28 | "jest/no-restricted-matchers": "off", 29 | "jest/no-standalone-expect": "off", 30 | "jest/no-test-prefixes": "off", 31 | "jest/no-test-return-statement": "off", 32 | "jest/prefer-called-with": "off", 33 | "jest/prefer-expect-assertions": "off", 34 | "jest/prefer-hooks-on-top": "off", 35 | "jest/prefer-spy-on": "off", 36 | "jest/prefer-strict-equal": "off", 37 | "jest/prefer-to-be": "off", 38 | "jest/prefer-to-contain": "off", 39 | "jest/prefer-to-have-length": "off", 40 | "jest/prefer-todo": "off", 41 | "jest/require-to-throw-message": "off", 42 | "jest/require-top-level-describe": "off", 43 | "jest/valid-describe-callback": "off", 44 | "jest/valid-expect": "off", 45 | "jest/valid-expect-in-promise": "off", 46 | "jest/valid-title": "off" 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /src/mini-server.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | import { promisify } from "util"; 3 | import { log } from "./utils.js"; 4 | 5 | /** represents a higher level incoming request */ 6 | export class Request { 7 | constructor(readonly raw: http.IncomingMessage, readonly body: string) { 8 | Object.assign(this, raw); 9 | } 10 | 11 | json() { 12 | return JSON.parse(this.body); 13 | } 14 | } 15 | 16 | /** represents a higher level incoming reply */ 17 | export class Reply { 18 | statusCode: number | null = null; 19 | 20 | constructor(readonly raw: http.ServerResponse) {} 21 | 22 | json(value: any) { 23 | this.raw.setHeader("Content-Type", "application/json"); 24 | this.raw.write(JSON.stringify(value)); 25 | } 26 | } 27 | 28 | export type RouteHandler = (request: Request, reply: any) => Promise | void; 29 | 30 | /** A teensy HTTP server with built in support for :gasp: routes :gasp: */ 31 | export class MiniServer { 32 | server?: http.Server; 33 | closed = false; 34 | 35 | constructor(readonly routes: Record) {} 36 | 37 | add(path: string, handler: RouteHandler) { 38 | this.routes[path] = handler; 39 | } 40 | 41 | async dispatch(request: Request, reply: Reply) { 42 | const handler = this.routes[request.raw.url!]; 43 | if (!handler) { 44 | log.error(`404: ${request.raw.url}`); 45 | reply.statusCode = 404; 46 | } else { 47 | try { 48 | await handler(request, reply); 49 | if (reply.statusCode) reply.raw.statusCode = reply.statusCode; 50 | } catch (error) { 51 | if (!this.closed) log.error("Error processing handler", error); 52 | reply.raw.statusCode = 500; 53 | } 54 | } 55 | 56 | reply.raw.end(); 57 | } 58 | 59 | async start(host: string, port?: number) { 60 | this.server = http.createServer((req, res) => { 61 | const chunks: Uint8Array[] = []; 62 | req 63 | .on("error", (err) => log.debug("Error processing request", err)) 64 | .on("data", (chunk) => chunks.push(chunk)) 65 | .on("end", () => { 66 | const request = new Request(req, Buffer.concat(chunks).toString("utf-8")); 67 | const reply = new Reply(res); 68 | void this.dispatch(request, reply); 69 | }); 70 | }); 71 | 72 | await (promisify(this.server.listen.bind(this.server)) as any)(host, port); 73 | 74 | log.debug(`Started HTTP server on ${this.server.address()}`); 75 | } 76 | 77 | close() { 78 | this.closed = true; 79 | this.server?.close(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wds", 3 | "version": "0.24.2", 4 | "author": "Harry Brundage", 5 | "license": "MIT", 6 | "bin": { 7 | "wds": "pkg/wds.bin.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/gadget-inc/wds.git" 12 | }, 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "types": "pkg/index.d.ts", 17 | "type": "module", 18 | "main": "pkg/index.js", 19 | "files": [ 20 | "pkg/*", 21 | "Readme.md", 22 | "Contributing.md", 23 | "LICENSE.txt" 24 | ], 25 | "scripts": { 26 | "build": "rm -rf pkg && tsc && chmod +x pkg/wds.bin.js pkg/wds-bench.bin.js", 27 | "prepublishOnly": "pnpm run build", 28 | "watch": "tsc -w", 29 | "typecheck": "tsc --noEmit", 30 | "lint": "pnpm run lint:prettier && pnpm run lint:eslint", 31 | "lint:prettier": "NODE_OPTIONS=\"--max-old-space-size=4096\" prettier --check \"src/**/*.{js,ts,tsx}\"", 32 | "lint:eslint": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint --quiet --ext ts,tsx src", 33 | "lint:fix": "NODE_OPTIONS=\"--max-old-space-size=4096\" prettier --write --check \"src/**/*.{js,ts,tsx}\" && eslint --ext ts,tsx --fix src", 34 | "prerelease": "gitpkg publish", 35 | "test": "vitest run", 36 | "integration-test": "pnpm run build && zx integration-test/test.js", 37 | "test:watch": "vitest" 38 | }, 39 | "engines": { 40 | "node": ">=16.0.0" 41 | }, 42 | "dependencies": { 43 | "@pnpm/find-workspace-dir": "^1000.0.1", 44 | "@swc/core": "^1.10.9", 45 | "@swc/helpers": "^0.5.15", 46 | "find-root": "^1.1.0", 47 | "find-yarn-workspace-root": "^2.0.0", 48 | "fs-extra": "^11.2.0", 49 | "globby": "^11.1.0", 50 | "lodash": "^4.17.20", 51 | "micromatch": "^4.0.8", 52 | "node-object-hash": "^3.1.1", 53 | "oxc-resolver": "^4.1.0", 54 | "pkg-dir": "^5.0.0", 55 | "watcher": "^2.3.1", 56 | "write-file-atomic": "^6.0.0", 57 | "xxhash-wasm": "^1.1.0", 58 | "yargs": "^16.2.0" 59 | }, 60 | "devDependencies": { 61 | "@gadgetinc/eslint-config": "^0.6.1", 62 | "@gadgetinc/prettier-config": "^0.4.0", 63 | "@types/find-root": "^1.1.4", 64 | "@types/fs-extra": "^11.0.4", 65 | "@types/lodash": "^4.17.13", 66 | "@types/micromatch": "^4.0.9", 67 | "@types/node": "^22.10.1", 68 | "@types/write-file-atomic": "^4.0.3", 69 | "@types/yargs": "^15.0.19", 70 | "eslint": "^8.57.1", 71 | "gitpkg": "github:airhorns/gitpkg#gitpkg-v1.0.0-beta.4-gitpkg-82083c3", 72 | "prettier": "^2.8.8", 73 | "typescript": "^5.7.3", 74 | "vitest": "^2.1.8", 75 | "zx": "^7.2.3" 76 | }, 77 | "packageManager": "pnpm@8.12.0+sha256.553e4eb0e2a2c9abcb419b3262bdc7aee8ae3c42e2301a1807d44575786160c9" 78 | } 79 | -------------------------------------------------------------------------------- /src/Project.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import type { Compiler } from "./Compiler.js"; 3 | import { PathTrie } from "./PathTrie.js"; 4 | import type { ProjectConfig } from "./ProjectConfig.js"; 5 | import type { Supervisor } from "./Supervisor.js"; 6 | import { log } from "./utils.js"; 7 | 8 | interface ReloadBatch { 9 | paths: string[]; 10 | invalidate: boolean; 11 | } 12 | 13 | /** Orchestrates all the other bits to respond to high level commands */ 14 | export class Project { 15 | cleanups: (() => void)[] = []; 16 | currentBatch: ReloadBatch = { paths: [], invalidate: false }; 17 | supervisor!: Supervisor; 18 | watched = new PathTrie(); 19 | 20 | private shuttingDown?: Promise; 21 | 22 | constructor(readonly workspaceRoot: string, readonly config: ProjectConfig, readonly compiler: Compiler) {} 23 | 24 | addShutdownCleanup(cleanup: () => void) { 25 | this.cleanups.push(cleanup); 26 | } 27 | 28 | enqueueReload(path: string, requiresInvalidation = false) { 29 | log.debug({ path }, "watch event"); 30 | if (this.watched.contains(path)) { 31 | this.compiler.invalidate(path); 32 | this.currentBatch.paths.push(path); 33 | this.currentBatch.invalidate = this.currentBatch.invalidate || requiresInvalidation; 34 | this.debouncedReload(); 35 | } 36 | } 37 | 38 | debouncedReload = _.debounce(() => { 39 | void this.reloadNow(); 40 | }, 15); 41 | 42 | async reloadNow() { 43 | log.info( 44 | _.compact([ 45 | this.currentBatch.paths[0].replace(this.workspaceRoot, ""), 46 | this.currentBatch.paths.length > 1 && ` and ${this.currentBatch.paths.length - 1} others`, 47 | " changed, ", 48 | this.currentBatch.invalidate && "reinitializing and ", 49 | "restarting ...", 50 | ]).join("") 51 | ); 52 | const invalidate = this.currentBatch.invalidate; 53 | this.currentBatch = { paths: [], invalidate: false }; 54 | if (invalidate) { 55 | await this.compiler.invalidateBuildSet(); 56 | } 57 | await this.compiler.rebuild(); 58 | this.supervisor.restart(); 59 | } 60 | 61 | async invalidateBuildSetAndReload() { 62 | await this.compiler.invalidateBuildSet(); 63 | this.supervisor.restart(); 64 | } 65 | 66 | async shutdown(code: number, signal: NodeJS.Signals = "SIGTERM") { 67 | if (this.shuttingDown) { 68 | return await this.shuttingDown; 69 | } 70 | 71 | this.shuttingDown = (async () => { 72 | await this.supervisor.stop(signal); 73 | for (const cleanup of this.cleanups) { 74 | cleanup(); 75 | } 76 | process.exit(code); 77 | })(); 78 | 79 | return await this.shuttingDown; 80 | } 81 | 82 | watchFile(path: string) { 83 | this.watched.insert(path); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/hooks/child-process-cjs-hook.cts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { workerData } from "worker_threads"; 4 | import type { SyncWorkerData } from "../SyncWorker.cjs"; 5 | import { SyncWorker } from "../SyncWorker.cjs"; 6 | import { log, notifyParentProcessOfRequire } from "./utils.cjs"; 7 | 8 | if (!workerData || !(workerData as SyncWorkerData).isWDSSyncWorker) { 9 | const worker = new SyncWorker(path.join(__dirname, "compileInLeaderProcess.cjs")); 10 | const paths: Record< 11 | string, 12 | | string 13 | | { 14 | ignored: boolean; 15 | } 16 | > = {}; 17 | 18 | // enable source maps 19 | process.setSourceMapsEnabled(true); 20 | 21 | // Compile a given file by sending it into our async-to-sync wrapper worker js file 22 | // The leader process returns us a list of all the files it just compiled, so that we don't have to pay the IPC boundary cost for each file after this one 23 | // So, we keep a map of all the files it's compiled so far, and check it first. 24 | const compileOffThread = (filename: string) => { 25 | let result = paths[filename]; 26 | if (!result) { 27 | const newPaths = worker.call(filename); 28 | Object.assign(paths, newPaths); 29 | result = paths[filename]; 30 | } 31 | 32 | if (!result) { 33 | throw new Error( 34 | `[wds] Internal error: compiled ${filename} but did not get it returned from the leader process in the list of compiled files` 35 | ); 36 | } 37 | 38 | return result; 39 | }; 40 | 41 | // Register our compiler for typescript files. 42 | // We don't do the best practice of chaining module._compile calls because swc won't know about any of the stuff any of the other extensions might do, so running them wouldn't do anything. wds must then be the first registered extension. 43 | const extensions = process.env["WDS_EXTENSIONS"]!.split(","); 44 | log.debug("registering cjs hook for extensions", extensions); 45 | for (const extension of extensions) { 46 | require.extensions[extension] = (module: any, filename: string) => { 47 | const compiledFilename = compileOffThread(filename); 48 | if (typeof compiledFilename === "string") { 49 | const content = fs.readFileSync(compiledFilename, "utf8"); 50 | notifyParentProcessOfRequire(filename); 51 | module._compile(content, filename); 52 | } 53 | }; 54 | } 55 | 56 | // monitor the parent process' health, if it dies, kill ourselves so we don't end up a zombie 57 | const monitor = setInterval(() => { 58 | try { 59 | process.kill(process.ppid, 0); 60 | // No error means the process exists 61 | } catch (e) { 62 | // An error means the process does not exist 63 | log.error("wds parent process crashed, killing child"); 64 | process.kill(-1 * process.pid, "SIGKILL"); 65 | } 66 | }, 1000); 67 | monitor.unref(); 68 | process.on("beforeExit", () => { 69 | clearInterval(monitor); 70 | }); 71 | } 72 | -------------------------------------------------------------------------------- /spec/wds.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import type { ChildProcess } from "child_process"; 3 | import http from "http"; 4 | import path from "path"; 5 | import { fileURLToPath } from "url"; 6 | import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; 7 | import { Supervisor } from "../src/Supervisor.js"; 8 | import { wds } from "../src/index.js"; 9 | const dirname = fileURLToPath(new URL(".", import.meta.url)); 10 | 11 | describe("wds", () => { 12 | let cwd: any; 13 | let supervisorRestart: any; 14 | let socketPath: string; 15 | 16 | const sendCompileRequest = async (filename: string) => { 17 | assert(socketPath, "socketPath must be set"); 18 | const result = await new Promise((resolve, reject) => { 19 | const request = http.request({ socketPath, path: "/compile", method: "POST", timeout: 200 }, (resp) => { 20 | let data = ""; 21 | if (resp.statusCode !== 200) { 22 | return reject(`Error compiling`); 23 | } 24 | resp.on("data", (chunk: string) => (data += chunk)); 25 | resp.on("end", () => resolve(JSON.parse(data).filenames)); 26 | }); 27 | 28 | request.on("error", (error) => { 29 | reject(error); 30 | }); 31 | request.write(filename); 32 | request.end(); 33 | }); 34 | 35 | return result; 36 | }; 37 | 38 | beforeEach(() => { 39 | cwd = vi.spyOn(process, "cwd").mockImplementation(() => { 40 | return path.resolve(dirname, "fixtures/src/files_with_config"); 41 | }); 42 | 43 | supervisorRestart = vi.spyOn(Supervisor.prototype, "restart").mockImplementation(function () { 44 | const self = this as unknown as Supervisor; 45 | socketPath = self.socketPath; 46 | self.process = { 47 | on: vi.fn(), 48 | } as unknown as ChildProcess; 49 | return self.process; 50 | }); 51 | }); 52 | 53 | afterEach(() => { 54 | cwd.mockRestore(); 55 | supervisorRestart.mockRestore(); 56 | }); 57 | 58 | test("server responds to ignored files", async () => { 59 | const server = await wds({ 60 | argv: [], 61 | terminalCommands: false, 62 | reloadOnChanges: false, 63 | }); 64 | const result = (await sendCompileRequest(path.resolve(dirname, "fixtures/src/files_with_config/ignored.ts"))) as Record< 65 | string, 66 | string | { ignored: boolean } 67 | >; 68 | const compiledKeys = Object.keys(result).filter((k) => /spec\/fixtures\/src\/files_with_config\/ignored\.ts/.test(k)); 69 | expect(compiledKeys).toHaveLength(1); 70 | expect(result[compiledKeys[0]]).toEqual({ 71 | ignored: true, 72 | }); 73 | 74 | server.close(); 75 | }); 76 | 77 | test("server responds to included files", async () => { 78 | const server = await wds({ 79 | argv: [], 80 | terminalCommands: false, 81 | reloadOnChanges: false, 82 | }); 83 | const result = (await sendCompileRequest(path.resolve(dirname, "fixtures/src/files_with_config/simple.ts"))) as Record< 84 | string, 85 | string | { ignored: boolean } 86 | >; 87 | const compiledKeys = Object.keys(result).filter((k) => /spec\/fixtures\/src\/files_with_config\/simple\.ts/.test(k)); 88 | expect(compiledKeys).toHaveLength(1); 89 | expect(typeof result[compiledKeys[0]]).toBe("string"); 90 | 91 | server.close(); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /spec/SwcCompiler.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs/promises"; 2 | import os from "os"; 3 | import path from "path"; 4 | import { fileURLToPath } from "url"; 5 | import { expect, test, vi } from "vitest"; 6 | import { MissingDestinationError, SwcCompiler } from "../src/SwcCompiler.js"; 7 | import { log } from "../src/utils.js"; 8 | 9 | const dirname = fileURLToPath(new URL(".", import.meta.url)); 10 | 11 | const compile = async (filename: string, root = "fixtures/src") => { 12 | const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "wds-test")); 13 | const rootDir = path.join(dirname, root); 14 | const fullPath = path.join(rootDir, filename); 15 | 16 | const compiler = await SwcCompiler.create(rootDir, workDir); 17 | await compiler.compile(fullPath); 18 | const compiledFilePath = (await compiler.fileGroup(fullPath))[fullPath]!; 19 | 20 | return await fs.readFile(compiledFilePath, "utf-8"); 21 | }; 22 | 23 | test("compiles simple files", async () => { 24 | const content = await compile("./simple.ts"); 25 | expect(content).toContain('console.log("success")'); 26 | }); 27 | 28 | test("compiles files in directories named .well-known", async () => { 29 | const content = await compile("./.well-known/run.ts"); 30 | expect(content).toContain('console.log(_foo.foo)'); 31 | }); 32 | 33 | test("throws if the compilation fails", async () => { 34 | await expect(compile("./failing/failing.ts", "fixtures/failing")).rejects.toThrow(MissingDestinationError); 35 | }); 36 | 37 | test("throws if the file is ignored", async () => { 38 | let error: MissingDestinationError | null = null; 39 | try { 40 | await compile("./files_with_config/ignored.ts"); 41 | } catch (e) { 42 | if (e instanceof MissingDestinationError) { 43 | error = e; 44 | } else { 45 | throw e; 46 | } 47 | } 48 | 49 | expect(error).toBeTruthy(); 50 | expect(error?.ignoredFile).toBeTruthy(); 51 | expect(error?.message).toMatch( 52 | /File .+ignored\.ts is imported but not being built because it is explicitly ignored in the wds project config\. It is being ignored by the provided glob pattern '.+ignored\.ts', remove this pattern from the project config or don't import this file to fix./ 53 | ); 54 | }); 55 | 56 | test("logs error when a file in group fails compilation but continues", async () => { 57 | const errorLogs: any[] = []; 58 | 59 | const mock = vi.spyOn(log, "error").mockImplementation((...args: any[]) => { 60 | errorLogs.push(args); 61 | }); 62 | 63 | const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "wds-test")); 64 | const rootDir = path.join(dirname, "fixtures/failing"); 65 | const fullPath = path.join(rootDir, "successful.ts"); 66 | const compiler = await SwcCompiler.create(rootDir, workDir); 67 | await compiler.compile(fullPath); 68 | const group = await compiler.fileGroup(fullPath); 69 | 70 | expect(group[fullPath]).toBeDefined(); 71 | expect(Object.entries(group).filter(([path]) => /.+(bar|successful)\.ts$/.test(path))).toHaveLength(2); 72 | const error = errorLogs[0][0]; 73 | expect(error.code).toBe("GenericFailure"); 74 | expect(error.message).toMatch(/.+failing\.ts/); 75 | expect(error.message).toMatch(/Syntax Error/); 76 | 77 | mock.mockRestore(); 78 | }); 79 | 80 | test("compiles lazy import", async () => { 81 | const content = await compile("./lazy_import.ts"); 82 | expect(content).toContain( 83 | ` 84 | function _child_process() { 85 | const data = require("child_process"); 86 | _child_process = function() { 87 | return data; 88 | }; 89 | return data; 90 | } 91 | `.trim() 92 | ); 93 | }); 94 | 95 | test("uses the swc config file from wds.js", async () => { 96 | const contentWithConfigOverride = await compile("./files_with_config/simple.ts"); 97 | expect(contentWithConfigOverride).not.toContain("strict"); 98 | 99 | const contentWithoutConfigOverride = await compile("./simple.ts"); 100 | expect(contentWithoutConfigOverride).toContain("strict"); 101 | }); 102 | 103 | test("uses the .swcrc file if wds.js uses 'swc': '.swcrc'", async () => { 104 | const contentWithRootSwcrc = await compile("./files_with_swcrc/simple.ts"); 105 | expect(contentWithRootSwcrc).not.toContain("strict"); 106 | 107 | const contentWithNestedSwcrc = await compile("./files_with_swcrc/nested/simple.ts"); 108 | expect(contentWithNestedSwcrc).toContain("strict"); 109 | 110 | const contentWithoutConfigOverride = await compile("./files_with_swcrc/nested/more_nested/simple.ts"); 111 | expect(contentWithoutConfigOverride).toContain("strict"); 112 | }); 113 | -------------------------------------------------------------------------------- /src/Supervisor.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcess, StdioOptions } from "child_process"; 2 | import { spawn } from "child_process"; 3 | import { EventEmitter } from "events"; 4 | import type { Project } from "./Project.js"; 5 | import type { RunOptions } from "./ProjectConfig.js"; 6 | import { log } from "./utils.js"; 7 | 8 | /** */ 9 | export class Supervisor extends EventEmitter { 10 | process!: ChildProcess; 11 | private stopping?: Promise; 12 | 13 | constructor(readonly argv: string[], readonly socketPath: string, readonly options: RunOptions, readonly project: Project) { 14 | super(); 15 | } 16 | 17 | /** 18 | * Stop the process with a given signal, then SIGKILL after a timeout 19 | * First signals only the ref'd process; once it exits, signal the rest of the process group. 20 | * Falls back to SIGKILL on the group if the ref'd process doesn't exit in time. 21 | * See https://azimi.me/2014/12/31/kill-child_process-node-js.html for more information 22 | */ 23 | async stop(signal: NodeJS.Signals = "SIGTERM") { 24 | if (this.stopping) { 25 | return await this.stopping; 26 | } 27 | 28 | this.stopping = (async () => { 29 | // if we never started the child process, we don't need to do anything 30 | if (!this.process || !this.process.pid) return; 31 | 32 | // if the child process has already exited, we don't need to do anything 33 | if (this.process.exitCode !== null) return; 34 | 35 | // signal the child process and give if time to gracefully close, allow the child process 36 | // the chance to attempt a graceful shutdown of any child processes it may have spawned 37 | // once the child process exits, SIGKILL the rest of the process group that haven't exited yet 38 | // if the child process doesn't exit in time, SIGKILL the whole process group 39 | const ref = this.process; 40 | const refPid = ref.pid; 41 | 42 | log.debug(`stopping process ${refPid} with signal ${signal}`); 43 | 44 | this.kill(signal, refPid, false); 45 | 46 | const exited = await Promise.race([ 47 | new Promise((resolve) => ref.once("exit", () => resolve(true))), 48 | new Promise((resolve) => setTimeout(() => resolve(false), 5000)), 49 | ]); 50 | 51 | if (exited) { 52 | log.debug(`process ${refPid} exited successfully, killing process group`); 53 | } else { 54 | log.debug(`process ${refPid} did not exit in time, killing process group`); 55 | } 56 | 57 | this.kill("SIGKILL", refPid, true); 58 | })(); 59 | 60 | return await this.stopping; 61 | } 62 | 63 | restart() { 64 | if (this.process?.pid) { 65 | log.debug(`restarting process group ${this.process.pid} with SIGKILL`); 66 | this.kill(); 67 | } 68 | 69 | const stdio: StdioOptions = [null, "inherit", "inherit"]; 70 | if (!this.options.terminalCommands) { 71 | stdio[0] = "inherit"; 72 | } 73 | if (process.send) { 74 | // WDS was called from a process that has IPC 75 | stdio.push("ipc"); 76 | } 77 | this.process = spawn("node", this.argv, { 78 | cwd: process.cwd(), 79 | env: { 80 | ...process.env, 81 | WDS_SOCKET_PATH: this.socketPath, 82 | WDS_EXTENSIONS: this.project.config.extensions.join(","), 83 | WDS_ESM_ENABLED: this.project.config.esm ? "true" : "false", 84 | }, 85 | stdio: stdio, 86 | detached: true, 87 | }); 88 | 89 | if (this.options.terminalCommands) { 90 | this.process.stdin!.end(); 91 | } 92 | 93 | const onChildProcessMessage = (message: any) => { 94 | if (process.send) process.send(message); 95 | }; 96 | const onParentProcessMessage = (message: any) => { 97 | this.process.send(message); 98 | }; 99 | process.on("message", onParentProcessMessage); 100 | this.process.on("message", onChildProcessMessage); 101 | this.process.on("exit", (code, signal) => { 102 | if (signal !== "SIGKILL") { 103 | let message = `process exited with code ${code}`; 104 | if (signal) message += ` with signal ${signal}`; 105 | log.warn(message); 106 | } 107 | this.process.off("message", onChildProcessMessage); 108 | process.off("message", onParentProcessMessage); 109 | }); 110 | 111 | return this.process; 112 | } 113 | 114 | private kill(signal: NodeJS.Signals = "SIGKILL", pid = this.process?.pid, group = true) { 115 | if (!pid) return; 116 | if (group) { 117 | log.debug(`killing process group ${pid} with signal ${signal}`); 118 | } else { 119 | log.debug(`killing process ${pid} with signal ${signal}`); 120 | } 121 | try { 122 | if (group) { 123 | process.kill(-pid, signal); 124 | } else { 125 | process.kill(pid, signal); 126 | } 127 | } catch (error: any) { 128 | log.debug(`error killing process ${pid} with signal ${signal}: ${error.message}`); 129 | if (error.code !== "ESRCH" && error.code !== "EPERM") throw error; 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/ProjectConfig.ts: -------------------------------------------------------------------------------- 1 | import type { Options as SwcOptions } from "@swc/core"; 2 | import fs from "fs-extra"; 3 | import _ from "lodash"; 4 | import micromatch from "micromatch"; 5 | import path from "path"; 6 | import { log } from "./utils.js"; 7 | 8 | export type SwcConfig = string | SwcOptions; 9 | 10 | export interface RunOptions { 11 | argv: string[]; 12 | terminalCommands: boolean; 13 | reloadOnChanges: boolean; 14 | } 15 | 16 | export interface ProjectConfig { 17 | root: string; 18 | ignore: string[]; 19 | includeGlob: string; 20 | /** 21 | * Checks if a file or directory should be included/watched. 22 | * Only accepts absolute paths. 23 | * - For files with extensions: checks extension match and ignore patterns 24 | * - For directories/extensionless files: checks ignore patterns only 25 | * Files outside the project root are allowed to support monorepo/workspace scenarios. 26 | */ 27 | includedMatcher: (absoluteFilePath: string) => boolean; 28 | swc?: SwcConfig; 29 | esm?: boolean; 30 | extensions: string[]; 31 | cacheDir: string; 32 | } 33 | 34 | export const projectConfig = _.memoize(async (root: string): Promise => { 35 | const location = path.join(root, "wds.js"); 36 | const base: ProjectConfig = { 37 | root, 38 | extensions: [".ts", ".tsx", ".jsx"], 39 | cacheDir: path.join(root, "node_modules/.cache/wds"), 40 | esm: true, 41 | /** The list of globby patterns to use when searching for files to build */ 42 | includeGlob: `**/*`, 43 | /** The list of globby patterns to ignore use when searching for files to build */ 44 | ignore: [], 45 | /** A micromatch matcher for userland checking if a file is included */ 46 | includedMatcher: () => true, 47 | }; 48 | 49 | let exists = false; 50 | try { 51 | await fs.access(location); 52 | exists = true; 53 | } catch (error: any) { 54 | log.debug(`Not loading project config from ${location}`); 55 | } 56 | 57 | let result: ProjectConfig; 58 | if (exists) { 59 | let required = await import(location); 60 | if (required.default) { 61 | required = required.default; 62 | } 63 | log.debug(`Loaded project config from ${location}`); 64 | result = _.defaults(required, base); 65 | } else { 66 | result = base; 67 | } 68 | 69 | const projectRootDir = path.dirname(location); 70 | // absolutize the cacheDir if not already 71 | if (!result.cacheDir.startsWith("/")) { 72 | result.cacheDir = path.resolve(projectRootDir, result.cacheDir); 73 | } 74 | 75 | // build inclusion glob and matcher 76 | // Convert all ignore patterns to absolute paths 77 | const absoluteIgnorePatterns = result.ignore.map((pattern) => { 78 | let absolutePattern: string; 79 | 80 | // Step 1: Determine the base path (prefix handling) 81 | if (pattern.startsWith("/")) { 82 | // Already absolute 83 | absolutePattern = pattern; 84 | } else if (pattern.startsWith("../") || pattern.startsWith("./")) { 85 | // Relative to project root 86 | absolutePattern = path.resolve(projectRootDir, pattern); 87 | } else if (pattern.startsWith("**/")) { 88 | // Glob pattern that should match anywhere under project root 89 | absolutePattern = path.join(projectRootDir, pattern); 90 | } else { 91 | // Relative pattern that should match at any depth under project root 92 | absolutePattern = path.join(projectRootDir, "**", pattern); 93 | } 94 | 95 | // Step 2: Determine if we need to add suffix for directory patterns 96 | // Check the original pattern for these characteristics 97 | if (pattern.endsWith("/")) { 98 | // Ends with slash - match everything inside 99 | // Remove trailing slash if present, then add /** 100 | absolutePattern = absolutePattern.replace(/\/$/, "") + "/**"; 101 | } else if (!path.extname(pattern) && !pattern.includes("*")) { 102 | // No extension and no wildcards - looks like a directory name 103 | if (!absolutePattern.endsWith("/**")) { 104 | absolutePattern = `${absolutePattern}/**`; 105 | } 106 | } 107 | 108 | return absolutePattern; 109 | }); 110 | 111 | const defaultIgnores = [ 112 | path.join(projectRootDir, "**/node_modules/**"), 113 | path.join(projectRootDir, "**/*.d.ts"), 114 | path.join(projectRootDir, "**/.git/**"), 115 | ]; 116 | 117 | result.ignore = _.uniq([...defaultIgnores, ...absoluteIgnorePatterns]); 118 | result.includeGlob = `**/*{${result.extensions.join(",")}}`; 119 | 120 | // Build an absolute include pattern that matches files with the right extensions anywhere in the filesystem 121 | // This allows compilation of files outside the project root (e.g., in monorepo sibling packages) 122 | const absoluteIncludePattern = `/**/*{${result.extensions.join(",")}}`; 123 | 124 | // Pre-compile matchers for performance 125 | const fileMatcher = micromatch.matcher(absoluteIncludePattern, { ignore: result.ignore }); 126 | 127 | // For directories/extensionless files, pre-compile individual matchers for each ignore pattern 128 | // micromatch.matcher is fast because it compiles the pattern to a regex once 129 | const directoryIgnoreMatchers = result.ignore.map((pattern) => micromatch.matcher(pattern)); 130 | 131 | // Single unified matcher that handles both files and directories 132 | result.includedMatcher = (absolutePath: string) => { 133 | const hasExtension = path.extname(absolutePath) !== ""; 134 | 135 | if (hasExtension) { 136 | // Files with extensions: use the full file matcher (checks extension + ignores) 137 | return fileMatcher(absolutePath); 138 | } else { 139 | // Directories/extensionless files: check if they match any ignore pattern 140 | // Return true (include) if they DON'T match any ignore pattern 141 | return !directoryIgnoreMatchers.some((matcher) => matcher(absolutePath)); 142 | } 143 | }; 144 | 145 | return result; 146 | }); 147 | -------------------------------------------------------------------------------- /src/bench/bench.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcessByStdio } from "child_process"; 2 | import { spawn } from "child_process"; 3 | import findRoot from "find-root"; 4 | import * as fs from "fs/promises"; 5 | import path from "path"; 6 | import type { Readable } from "stream"; 7 | import { log } from "../utils.js"; 8 | import { json } from "./json.js"; 9 | import type { ChildProcessResult } from "./protocol.js"; 10 | import { MARKER } from "./protocol.js"; 11 | 12 | type ChildProcess = ChildProcessByStdio; 13 | 14 | function monitorLogs(childProcess: ChildProcess): Promise { 15 | const childStdOut = childProcess.stdout; 16 | return new Promise((resolve) => { 17 | const onEnd = () => { 18 | childStdOut.removeListener("data", onData); 19 | childStdOut.removeListener("end", onEnd); 20 | throw new Error("Failed to find metric output line in child process. Did it terminate correctly?"); 21 | }; 22 | const onData = (data: Buffer) => { 23 | const str = data.toString("utf-8"); 24 | const line = str.split("\n").find((l: string) => l.startsWith(MARKER)); 25 | if (line) { 26 | const metrics = json.parse(line.replace(MARKER, "")); 27 | childStdOut.removeListener("data", onData); 28 | childStdOut.removeListener("end", onEnd); 29 | if (metrics.code === 0) { 30 | return resolve(metrics); 31 | } else { 32 | throw new Error(`Child process completed unsuccessfully, aborting benchmark. Exit code: ${metrics.code}`); 33 | } 34 | } 35 | }; 36 | childStdOut.on("data", onData); 37 | childStdOut.on("end", onEnd); 38 | }); 39 | } 40 | 41 | function spawnOnce(args: { watch?: boolean; filename: string }): ChildProcess { 42 | const extraArgs = []; 43 | 44 | if (args.watch) { 45 | extraArgs.push("--watch"); 46 | } 47 | 48 | const binPath = path.resolve("pkg/wds.bin.js"); 49 | const root = findRoot(args.filename); 50 | const relativeFilePath = path.relative(root, args.filename); 51 | const allArgs = [...extraArgs, "-r", path.resolve("pkg/bench/bench-child-hooks.js"), relativeFilePath]; 52 | 53 | log.debug(binPath, ...allArgs); 54 | 55 | return spawn(binPath, allArgs, { 56 | stdio: ["ignore", "pipe", "ignore"], 57 | cwd: root, 58 | }); 59 | } 60 | 61 | type RunResult = { 62 | startTime: bigint; 63 | endTime: bigint; 64 | duration: number; 65 | metrics: ChildProcessResult; 66 | }; 67 | 68 | // https://stackoverflow.com/questions/48719873/how-to-get-median-and-quartiles-percentiles-of-an-array-in-javascript-or-php 69 | const sum = (values: Array) => values.reduce((a, b) => a + b, 0); 70 | const mean = (values: Array) => Number(values.reduce((sum, t) => sum + t, 0)) / values.length; 71 | const stdDev = (values: Array) => { 72 | if (values.length == 1) { 73 | return 0; 74 | } 75 | const mu = mean(values); 76 | const diffArr = values.map((a) => (Number(a) - mu) ** 2); 77 | return Math.sqrt(sum(diffArr) / (values.length - 1)); 78 | }; 79 | const quantile = (values: Array, q: number) => { 80 | const sorted = values.sort(); 81 | const pos = (sorted.length - 1) * q; 82 | const base = Math.floor(pos); 83 | const rest = pos - base; 84 | if (sorted[base + 1] !== undefined) { 85 | return sorted[base] + rest * (sorted[base + 1] - sorted[base]); 86 | } else { 87 | return sorted[base]; 88 | } 89 | }; 90 | 91 | function report(results: Array): Record> { 92 | const asMs = (number: number): number => Math.round((number * 100) / 1e6) / 100; 93 | const totalDurations = results.map((result) => result.duration); 94 | const childDurations = results.map((result) => result.metrics.duration); 95 | 96 | return { 97 | "Total process duration": { 98 | "mean (ms)": asMs(mean(totalDurations)), 99 | "stdDev (ms)": asMs(stdDev(totalDurations)), 100 | "p95 (ms)": asMs(quantile(totalDurations, 0.95)), 101 | }, 102 | "Child process duration": { 103 | "mean (ms)": asMs(mean(childDurations)), 104 | "stdDev (ms)": asMs(stdDev(childDurations)), 105 | "p95 (ms)": asMs(quantile(childDurations, 0.95)), 106 | }, 107 | }; 108 | } 109 | 110 | export type BenchArgs = { 111 | runs: number; 112 | argv: Array; 113 | }; 114 | 115 | function execPath(args: BenchArgs): string { 116 | let filepath; 117 | if (args.argv.length === 0) { 118 | filepath = path.resolve("src/bench/scripts/noop.ts"); 119 | } else { 120 | filepath = args.argv[0]; 121 | } 122 | 123 | return filepath; 124 | } 125 | 126 | export async function benchBoot(args: BenchArgs): Promise { 127 | const results: Array = []; 128 | 129 | process.stdout.write(`Starting boot benchmark (pid=${process.pid})\n`); 130 | 131 | for (let i = 0; i < args.runs; i++) { 132 | const startTime = process.hrtime.bigint(); 133 | const childProcess = spawnOnce({ filename: execPath(args) }); 134 | const result = await monitorLogs(childProcess); 135 | const endTime = process.hrtime.bigint(); 136 | results.push({ 137 | startTime, 138 | endTime, 139 | duration: Number(endTime - startTime), 140 | metrics: result, 141 | }); 142 | process.stdout.write("."); 143 | } 144 | 145 | process.stdout.write("\n"); 146 | 147 | console.table(report(results)); 148 | } 149 | 150 | export async function benchReload(args: BenchArgs): Promise { 151 | const results: Array = []; 152 | const filepath = execPath(args); 153 | const file = await fs.open(filepath, "r+"); 154 | 155 | process.stdout.write(`Starting reload benchmark (pid=${process.pid})\n`); 156 | 157 | const childProcess = spawnOnce({ watch: true, filename: filepath }); 158 | const _ignoreInitialBoot = await monitorLogs(childProcess); 159 | 160 | for (let i = 0; i < args.runs; i++) { 161 | const now = new Date(); 162 | const startTime = process.hrtime.bigint(); 163 | 164 | await file.utimes(now, now); 165 | const [result] = await Promise.all([monitorLogs(childProcess), file.sync()]); 166 | 167 | const endTime = process.hrtime.bigint(); 168 | results.push({ 169 | startTime, 170 | endTime, 171 | duration: Number(endTime - startTime), 172 | metrics: result, 173 | }); 174 | process.stdout.write("."); 175 | } 176 | 177 | await file.close(); 178 | childProcess.kill(); 179 | 180 | process.stdout.write("\n"); 181 | 182 | console.table(report(results)); 183 | } 184 | -------------------------------------------------------------------------------- /src/SyncWorker.cts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import _ from "lodash"; 3 | import { inspect } from "util"; 4 | import type { MessagePort } from "worker_threads"; 5 | import workerThreads, { isMainThread, MessageChannel, receiveMessageOnPort, threadId, Worker } from "worker_threads"; 6 | 7 | export let debugLog: ((...args: any[]) => void) | undefined = undefined; 8 | 9 | if (process.env["WDS_DEBUG"]) { 10 | // write logs to a file, not a stdout, since stdio is buffered from worker threads by node and messages are lost on process crash :eyeroll: 11 | debugLog = (...args: any[]) => { 12 | const result = 13 | `[wds syncworker ${isMainThread ? "main" : "inner"} thread=${threadId}] ` + 14 | args.map((arg) => (typeof arg === "string" ? arg : inspect(arg))).join(" "); 15 | 16 | console.error(result); 17 | fs.appendFileSync(`/tmp/wds-debug-log-pid-${process.pid}-thread-${threadId}.txt`, result + "\n"); 18 | }; 19 | } 20 | debugLog?.("syncworker file boot", { isMainThread: workerThreads.isMainThread, hasWorkerData: !!workerThreads.workerData }); 21 | 22 | interface SyncWorkerCall { 23 | id: number; 24 | args: any[]; 25 | sharedBuffer: SharedArrayBuffer; 26 | } 27 | 28 | interface SyncWorkerResponse { 29 | id: number; 30 | result: undefined | any; 31 | error: null | any; 32 | } 33 | 34 | export interface SyncWorkerData { 35 | isWDSSyncWorker: true; 36 | scriptPath: string; 37 | port: MessagePort; 38 | } 39 | 40 | /** 41 | * A synchronous wrapper around a worker which can do asynchronous work 42 | * Useful for us because we need to block the main synchronous thread during requiring something to asynchronously ask the parent to compile stuff for us. 43 | * Uses Atomics to block the main thread waiting on a SharedArrayBuffer, and then another worker thread to actually do the async stuff in a different event loop. 44 | * A terrible invention inspired by https://github.com/evanw/esbuild/pull/612/files 45 | * */ 46 | export class SyncWorker { 47 | port: MessagePort; 48 | idCounter = 0; 49 | worker: Worker; 50 | 51 | constructor(scriptPath: string) { 52 | const { port1, port2 } = new MessageChannel(); 53 | this.port = port1; 54 | 55 | const workerData: SyncWorkerData = { 56 | scriptPath, 57 | port: port2, 58 | isWDSSyncWorker: true, 59 | }; 60 | 61 | this.worker = new Worker(__filename, { 62 | argv: [], 63 | execArgv: [], 64 | workerData, 65 | transferList: [port2], 66 | }); 67 | 68 | debugLog?.("booted syncworker worker", { filename: __filename, scriptPath, childWorkerThreadId: this.worker.threadId }); 69 | 70 | this.worker.on("error", (error) => { 71 | debugLog?.("Internal error", error); 72 | process.exit(1); 73 | }); 74 | 75 | this.worker.on("exit", (code) => { 76 | if (code !== 0) { 77 | console.error(`Internal error, compiler worker exited unexpectedly with exit code ${code}`); 78 | process.exit(1); 79 | } 80 | }); 81 | 82 | // Calling unref() on a worker will allow the thread to exit if it's the last only active handle in the event system. This means node will still exit when there are no more event handlers from the main thread. So there's no need to have a "stop()" function. 83 | this.worker.unref(); 84 | } 85 | 86 | call(...args: any[]) { 87 | const id = this.idCounter++; 88 | 89 | const call: SyncWorkerCall = { 90 | id, 91 | args, 92 | // Make a fresh shared buffer for every request. That way we can't have a race where a notification from the previous call overlaps with this call. 93 | sharedBuffer: new SharedArrayBuffer(8), 94 | }; 95 | 96 | const sharedBufferView = new Int32Array(call.sharedBuffer); 97 | 98 | debugLog?.("calling syncworker", { thisThreadId: threadId, childWorkerThreadId: this.worker.threadId, call }); 99 | this.port.postMessage(call); 100 | 101 | // synchronously wait for worker thread to get back to us 102 | const status = Atomics.wait(sharedBufferView, 0, 0, 60000); 103 | if (status === "timed-out") 104 | throw new Error("[wds] Internal error: timed out communicating with wds sync worker thread, likely an wds bug"); 105 | if (status !== "ok" && status !== "not-equal") throw new Error(`[wds] Internal error: Atomics.wait() failed with status ${status}`); 106 | 107 | const message = receiveMessageOnPort(this.port); 108 | 109 | if (!message) throw new Error("[wds] Internal error: no response received from sync worker thread"); 110 | const response: SyncWorkerResponse = message.message; 111 | 112 | if (response.id != id) 113 | throw new Error( 114 | `[wds] Internal error: response received from sync worker thread with incorrect id, sent ${id}, recieved ${response.id}` 115 | ); 116 | 117 | if (response.error) throw response.error; 118 | 119 | return response.result; 120 | } 121 | } 122 | 123 | // This file re-executes itself in the worker thread. Actually run the worker code within the inner thread if we're the inner thread 124 | if (!workerThreads.isMainThread) { 125 | const runWorker = async () => { 126 | const workerData: SyncWorkerData | undefined = workerThreads.workerData; 127 | if (!workerData || !workerData.isWDSSyncWorker) return; 128 | 129 | void debugLog?.("inner sync worker thread booting", { scriptPath: workerData.scriptPath }); 130 | 131 | try { 132 | let implementation = await import(workerData.scriptPath); 133 | 134 | // yes, twice :eyeroll: 135 | if (implementation.default) implementation = implementation.default; 136 | if (implementation.default) implementation = implementation.default; 137 | 138 | if (!_.isFunction(implementation)) 139 | throw new Error( 140 | `[wds] Internal error: sync worker script at ${workerData.scriptPath} did not export a default function, it was a ${inspect( 141 | implementation 142 | )}` 143 | ); 144 | const port: MessagePort = workerData.port; 145 | 146 | const handleCall = async (call: SyncWorkerCall) => { 147 | const sharedBufferView = new Int32Array(call.sharedBuffer); 148 | 149 | try { 150 | const result = await implementation(...call.args); 151 | port.postMessage({ id: call.id, result }); 152 | } catch (error) { 153 | void debugLog?.("error running syncworker", error); 154 | port.postMessage({ id: call.id, error }); 155 | } 156 | 157 | // First, change the shared value. That way if the main thread attempts to wait for us after this point, the wait will fail because the shared value has changed. 158 | Atomics.add(sharedBufferView, 0, 1); 159 | // Then, wake the main thread. This handles the case where the main thread was already waiting for us before the shared value was changed. 160 | Atomics.notify(sharedBufferView, 0, Infinity); 161 | }; 162 | 163 | port.addListener("message", (message) => { 164 | void debugLog?.("got port message", message); 165 | void handleCall(message as SyncWorkerCall); 166 | }); 167 | 168 | port.addListener("messageerror", (error) => { 169 | void debugLog?.("got port message error", error); 170 | console.error("got port message error", error); 171 | }); 172 | 173 | void debugLog?.("sync worker booted\n"); 174 | } catch (error) { 175 | console.error("error booting inner sync worker thread", error); 176 | void debugLog?.("error booting inner sync worker thread", error); 177 | process.exit(1); 178 | } 179 | }; 180 | 181 | void runWorker(); 182 | } 183 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # wds 2 | 3 | A reloading dev server for server side TypeScript projects. Compiles TypeScript _real_ fast, on demand, using ESM loaders and `require.extensions`. Similar to and inspired by `ts-node-dev` and `tsx`. 4 | 5 | wds stands for Whirlwind (or web) Development Server. 6 | 7 | ## Examples 8 | 9 | After installing `wds`, you can use it like you might use the `node` command line program: 10 | 11 | ```shell 12 | # run one script with wds compiling TS to JS 13 | wds some-script.ts 14 | 15 | # run one server with wds `watch` mode, re-running the server on any file changes 16 | wds --watch some-server.ts 17 | 18 | # run one script with node command line arguments that you'd normally pass to `node` 19 | wds --inspect some-test.test.ts 20 | ``` 21 | 22 | ## Features 23 | 24 | - Builds and runs TypeScript really fast using [`swc`](https://github.com/swc-project/swc) 25 | - Incrementally rebuilds only what has changed in `--watch` mode, restarting the process on file changes 26 | - Full support for CommonJS and ESM packages (subject to node's own interoperability rules) 27 | - Great support for really huge projects and monorepos 28 | - Caches transformed files on disk for warm startups on process reload (with expiry when config or source changes) 29 | - Execute commands on demand with the `--commands` mode 30 | - Plays nice with node.js command line flags like `--inspect` or `--prof` 31 | - Supports node.js `ipc` channels between the process starting `wds` and the node.js process started by `wds`. 32 | - Produces sourcemaps which Just Work™️ by default for debugging with many editors (VSCode, IntelliJ, etc) 33 | - Monorepo aware, allowing for different configuration per package and only compiling what is actually required from the monorepo context 34 | 35 | ## Motivation 36 | 37 | You deserve to get stuff done. You deserve a fast iteration loop. If you're writing TypeScript for node, you still deserve to have a fast iteration loop, but with big codebases, `tsc` can get quite slow. Instead, you can use a fast TS => JS transpiler like `swc` to quickly reload your runtime code and get to the point where you know if your code is working as fast as possible. This means a small sacrifice: `tsc` no longer typechecks your code as you run it, and so you must supplement with typechecking in your editor or in CI. 38 | 39 | This tool prioritizes rebooting a node.js TypeScript project as fast as possible. This means it _doesn't_ typecheck. Type checking gets prohibitively slow at scale, so we recommend using this separate typechecker approach that still gives you valuable feedback out of band. That way, you don't have to wait for it to see if your change actually worked. We usually don't run anything other than VSCode's TypeScript integration locally, and then run a full `tsc --noEmit` in CI. 40 | 41 | ## Usage 42 | 43 | ```text 44 | Options: 45 | --help Show help [boolean] 46 | --version Show version number [boolean] 47 | -c, --commands Trigger commands by watching for them on stdin. Prevents 48 | stdin from being forwarded to the process. Only command right 49 | now is `rs` to restart the server. [boolean] [default: false] 50 | -w, --watch Trigger restarts by watching for changes to required files 51 | [boolean] [default: false] 52 | -s, --supervise Supervise and restart the process when it exits indefinitely 53 | [boolean] [default: false] 54 | ``` 55 | 56 | ## Configuration 57 | 58 | Configuration for `wds` is done by adding a `wds.js` file to your pacakge root, and optionally a `.swcrc` file if using `swc` as your compiler backend. 59 | 60 | An `wds.js` file needs to export an object like so: 61 | 62 | ```javascript 63 | module.exports = { 64 | // which file extensions to build, defaults to .js, .jsx, .ts, .tsx extensions 65 | extensions: [".tsx", ".ts", ".mdx"], 66 | 67 | // file paths to explicitly not transform for speed, defaults to [], plus whatever the compiler backend excludes by default, which is `node_modules` for swc 68 | ignore: ["spec/integration/**/node_modules", "spec/**/*.spec.ts", "cypress/", "public/"], 69 | }; 70 | ``` 71 | 72 | ### When using `swc` (the default) 73 | 74 | `swc` is the fastest TypeScript compiler we've found and is the default compiler `wds` uses. `wds` sets up a default `swc` config suitable for compiling to JS for running in Node: 75 | 76 | ```jsonc 77 | { 78 | "jsc": { 79 | "parser": { 80 | "syntax": "typescript", 81 | "decorators": true, 82 | "dynamicImport": true 83 | }, 84 | "target": "es2020" 85 | }, 86 | "module": { 87 | "type": "commonjs", 88 | // turn on lazy imports for maximum reboot performance 89 | "lazy": true 90 | } 91 | } 92 | ``` 93 | 94 | **Note**: the above config is _different_ than the default swc config. It's been honed to give maximum performance for server start time, but can be adjusted by creating your own `.swcrc` file. 95 | 96 | Configuring `swc`'s compiler options with with `wds` can be done using the `wds.js` file. Create a file named `wds.js` in the root of your repository with content like this: 97 | 98 | ```javascript 99 | // wds.js 100 | module.exports = { 101 | swc: { 102 | env: { 103 | targets: { 104 | node: 12, 105 | }, 106 | }, 107 | }, 108 | }; 109 | ``` 110 | 111 | You can also use `swc`'s built in configuration mechanism which is an `.swcrc` file. Using an `.swcrc` file is useful in order to share `swc` configuration between `wds` and other tools that might use `swc` under the hood as well, like `@swc/jest`. To stop using `wds`'s default config and use the config from a `.swcrc` file, you must configure wds to do so using `wds.js` like so: 112 | 113 | ```javascript 114 | // in wds.js 115 | module.exports = { 116 | swc: ".swcrc", 117 | }; 118 | ``` 119 | 120 | And then, you can use `swc`'s standard syntax for the `.swcrc` file 121 | 122 | ```jsonc 123 | // in .swcrc, these are the defaults wds uses 124 | { 125 | "jsc": { 126 | "parser": { 127 | "syntax": "typescript", 128 | "decorators": true, 129 | "dynamicImport": true 130 | }, 131 | "target": "es2022" 132 | }, 133 | "module": { 134 | "type": "commonjs", 135 | // turn on lazy imports for maximum reboot performance 136 | "lazy": true 137 | } 138 | } 139 | ``` 140 | 141 | Refer to [the SWC docs](https://swc.rs/docs/configuration/swcrc) for more info. 142 | 143 | # Comparison to `ts-node-dev` 144 | 145 | `ts-node-dev` (and `ts-node`) accomplish a similar feat but are often 5-10x slower than `wds` in big projects. They are loaded with features and will keep up with new TypeScript features much better as they use the mainline TypeScript compiler sources, and we think they make lots of sense! Because they use TypeScript proper for compilation though, even with `--transpile-only`, they are destined to be slower than `swc`. `wds` is for the times where you care a lot more about performance and are ok with the tradeoffs `swc` makes, like not supporting `const enum` and being a touch behind on supporting new TypeScript releases. 146 | 147 | # Comparison to `tsx` 148 | 149 | `tsx` is a great, low-config TypeScript runner with a similar just-transpile-and-dont-typecheck approach. But, it has slower startup when run for the first time, and fewer fancy optimizations for restarting really large projects that use the `lazy: true` option in SWC to maximize reload speed. `wds` shines brightest when used as Gadget uses it against a many-million line TypeScript monorepo. 150 | -------------------------------------------------------------------------------- /spec/Supervisor.spec.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, spawn } from "child_process"; 2 | import _ from "lodash"; 3 | import * as path from "path"; 4 | import { fileURLToPath } from "url"; 5 | import { expect, test } from "vitest"; 6 | 7 | const dirname = fileURLToPath(new URL(".", import.meta.url)); 8 | 9 | const childExit = (child: ChildProcess) => { 10 | return new Promise((resolve, reject) => { 11 | child.on("error", (err: Error) => { 12 | reject(err); 13 | }); 14 | 15 | child.on("exit", (code: number) => { 16 | if (code == 0) { 17 | resolve(); 18 | } else { 19 | reject(new Error(`Child process exited with code ${code}`)); 20 | } 21 | }); 22 | }); 23 | }; 24 | 25 | test("it proxies ipc messages", async () => { 26 | const binPath = path.join(dirname, "../pkg/wds.bin.js"); 27 | const scriptPath = path.join(dirname, "fixtures/src/add.ts"); 28 | 29 | const child = spawn("node", [binPath, scriptPath], { 30 | stdio: ["inherit", "inherit", "inherit", "ipc"], 31 | env: process.env, 32 | }); 33 | 34 | const childHasBooted = new Promise((resolve) => { 35 | const handler = () => { 36 | resolve(); 37 | child.off("message", handler); 38 | }; 39 | child.on("message", handler); 40 | }); 41 | await childHasBooted; 42 | 43 | const messagesToChild = _.range(0, 3); 44 | const messagesFromChild: Array = []; 45 | 46 | const promise = new Promise((resolve) => { 47 | child.on("message", (message: any) => { 48 | messagesFromChild.push(message); 49 | 50 | if (messagesFromChild.length === messagesToChild.length) { 51 | resolve(); 52 | } 53 | }); 54 | }); 55 | 56 | for (const number of messagesToChild) { 57 | child.send(number); 58 | } 59 | 60 | child.send("exit"); 61 | 62 | await promise; 63 | 64 | expect(messagesFromChild).toEqual([1, 2, 3]); 65 | }, 10000); 66 | 67 | test("it doesn't setup ipc if it wasn't setup with ipc itself", async () => { 68 | const binPath = path.join(dirname, "../pkg/wds.bin.js"); 69 | const scriptPath = path.join(dirname, "fixtures/src/no-ipc.ts"); 70 | 71 | const child = spawn("node", [binPath, scriptPath], { 72 | stdio: ["inherit", "inherit", "inherit"], 73 | env: process.env, 74 | }); 75 | 76 | await childExit(child); 77 | }, 10000); 78 | 79 | test("it inherits stdin if WDS was started without terminal commands", async () => { 80 | const binPath = path.join(dirname, "../pkg/wds.bin.js"); 81 | const scriptPath = path.join(dirname, "fixtures/src/echo.ts"); 82 | 83 | const child = spawn("node", [binPath, scriptPath], { 84 | env: process.env, 85 | }); 86 | 87 | let output = ""; 88 | 89 | child.stdin.write("test"); 90 | child.stdin.end(); 91 | 92 | child.stdout.on("data", (data) => { 93 | output += data; 94 | }); 95 | 96 | await childExit(child); 97 | expect(output).toEqual("test"); 98 | }, 10000); 99 | 100 | test("it doesn't have any stdin if wds is started with terminal commands", async () => { 101 | const binPath = path.join(dirname, "../pkg/wds.bin.js"); 102 | const scriptPath = path.join(dirname, "fixtures/src/echo.ts"); 103 | 104 | const child = spawn("node", [binPath, scriptPath, "--commands"], { 105 | env: process.env, 106 | }); 107 | 108 | let output = ""; 109 | 110 | child.stdin.write("test"); 111 | child.stdin.end(); 112 | 113 | child.stdout.on("data", (data) => { 114 | output += data; 115 | }); 116 | 117 | await childExit(child); 118 | 119 | expect(output).toEqual(""); 120 | }, 10000); 121 | 122 | test("it can load a commonjs module inside a directory that contains a dot when in esm mode", async () => { 123 | const binPath = path.join(dirname, "../pkg/wds.bin.js"); 124 | const scriptPath = path.join(dirname, "fixtures/esm/github.com/wds/simple.ts"); 125 | 126 | const child = spawn("node", [binPath, scriptPath], { 127 | stdio: ["inherit", "inherit", "inherit"], 128 | env: process.env, 129 | }); 130 | 131 | await childExit(child); 132 | }, 10000); 133 | 134 | const runSignalOrderTest = async (signal: NodeJS.Signals) => { 135 | const binPath = path.join(dirname, "../pkg/wds.bin.js"); 136 | const scriptPath = path.join(dirname, "fixtures/src/signal-order.ts"); 137 | 138 | const child = spawn("node", [binPath, scriptPath], { 139 | stdio: ["ignore", "pipe", "pipe"], 140 | env: process.env, 141 | }); 142 | 143 | let stdout = ""; 144 | let isReady = false; 145 | const onData = (data: Buffer) => { 146 | stdout += data.toString(); 147 | if (!isReady && stdout.includes("parent:ready") && stdout.includes("grandchild:ready")) { 148 | isReady = true; 149 | } 150 | }; 151 | child.stdout?.on("data", onData); 152 | 153 | await new Promise((resolve) => { 154 | const checkReady = () => (isReady ? resolve() : setTimeout(checkReady, 50)); 155 | checkReady(); 156 | }); 157 | 158 | const wdsPid = child.pid; 159 | 160 | 161 | child.kill(signal); 162 | 163 | await childExit(child); 164 | 165 | // give any SIGKILLs time to propagate 166 | await new Promise((resolve) => setTimeout(resolve, 250)); 167 | 168 | let parentPid: number | undefined; 169 | let grandchildPid: number | undefined; 170 | const lines = stdout.split(/\r?\n/).filter(Boolean).map((line) => { 171 | if (line.includes("parent:ready")) { 172 | parentPid = parseInt(line.split(":")[2]); 173 | return `parent:ready`; 174 | } else if (line.includes("grandchild:ready")) { 175 | grandchildPid = parseInt(line.split(":")[2]); 176 | return `grandchild:ready`; 177 | } 178 | return line; 179 | }); 180 | 181 | return { lines, wdsPid, parentPid, grandchildPid }; 182 | } 183 | 184 | const isPidAlive = (pid: number) => { 185 | try { 186 | process.kill(pid, 0); 187 | return true; 188 | } catch (e) { 189 | if (e.code === "ESRCH") { 190 | return false; 191 | } 192 | throw e; 193 | } 194 | } 195 | 196 | test("it kills the child first, then the process group on stop", async () => { 197 | const { lines, wdsPid, parentPid, grandchildPid } = await runSignalOrderTest("SIGTERM"); 198 | expect(wdsPid).toBeDefined(); 199 | expect(parentPid).toBeDefined(); 200 | expect(grandchildPid).toBeDefined(); 201 | expect(parentPid).not.toBe(wdsPid); 202 | expect(grandchildPid).not.toBe(wdsPid); 203 | expect(parentPid).not.toBe(grandchildPid); 204 | 205 | expect(isPidAlive(wdsPid!)).toBe(false); 206 | expect(isPidAlive(parentPid!)).toBe(false); 207 | expect(isPidAlive(grandchildPid!)).toBe(false); 208 | 209 | expect(lines).toMatchInlineSnapshot(` 210 | [ 211 | "parent:ready", 212 | "grandchild:ready", 213 | "parent:exit-SIGTERM", 214 | "grandchild:sigterm", 215 | "parent:grandchild-exit", 216 | ] 217 | `); 218 | }, 20000); 219 | 220 | test("it kills grandchildren if they have not shutdown by the time the parent process exits", async () => { 221 | const { lines, wdsPid, parentPid, grandchildPid } = await runSignalOrderTest("SIGINT"); 222 | expect(wdsPid).toBeDefined(); 223 | expect(parentPid).toBeDefined(); 224 | expect(grandchildPid).toBeDefined(); 225 | expect(parentPid).not.toBe(wdsPid); 226 | expect(grandchildPid).not.toBe(wdsPid); 227 | expect(parentPid).not.toBe(grandchildPid); 228 | 229 | expect(isPidAlive(wdsPid!)).toBe(false); 230 | expect(isPidAlive(parentPid!)).toBe(false); 231 | expect(isPidAlive(grandchildPid!)).toBe(false); 232 | 233 | expect(lines).toMatchInlineSnapshot(` 234 | [ 235 | "parent:ready", 236 | "grandchild:ready", 237 | "parent:exit-SIGINT", 238 | "grandchild:sigint", 239 | "parent:exit-timeout", 240 | ] 241 | `); 242 | }, 20000); 243 | 244 | test("it kills the whole process group if the child process doesn't exit before the timeout", async () => { 245 | const { lines, wdsPid, parentPid, grandchildPid } = await runSignalOrderTest("SIGQUIT"); 246 | expect(wdsPid).toBeDefined(); 247 | expect(parentPid).toBeDefined(); 248 | expect(grandchildPid).toBeDefined(); 249 | expect(parentPid).not.toBe(wdsPid); 250 | expect(grandchildPid).not.toBe(wdsPid); 251 | expect(parentPid).not.toBe(grandchildPid); 252 | 253 | expect(isPidAlive(wdsPid!)).toBe(false); 254 | expect(isPidAlive(parentPid!)).toBe(false); 255 | expect(isPidAlive(grandchildPid!)).toBe(false); 256 | 257 | expect(lines).toMatchInlineSnapshot(` 258 | [ 259 | "parent:ready", 260 | "grandchild:ready", 261 | "parent:sigquit", 262 | ] 263 | `); 264 | }, 20000); -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { findWorkspaceDir as findPnpmWorkspaceRoot } from "@pnpm/find-workspace-dir"; 2 | import findRoot from "find-root"; 3 | import findYarnWorkspaceRoot from "find-yarn-workspace-root"; 4 | import fs from "fs-extra"; 5 | import os from "os"; 6 | import path from "path"; 7 | import readline from "readline"; 8 | import { fileURLToPath } from "url"; 9 | import Watcher from "watcher"; 10 | import yargs from "yargs"; 11 | import { hideBin } from "yargs/helpers"; 12 | import { Project } from "./Project.js"; 13 | import { projectConfig, type ProjectConfig, type RunOptions } from "./ProjectConfig.js"; 14 | import { Supervisor } from "./Supervisor.js"; 15 | import { MissingDestinationError, SwcCompiler } from "./SwcCompiler.js"; 16 | import { MiniServer } from "./mini-server.js"; 17 | import { log } from "./utils.js"; 18 | 19 | const dirname = fileURLToPath(new URL(".", import.meta.url)); 20 | 21 | export const cli = async () => { 22 | const args = yargs(hideBin(process.argv)) 23 | .parserConfiguration({ 24 | "unknown-options-as-args": true, 25 | }) 26 | .option("commands", { 27 | alias: "c", 28 | type: "boolean", 29 | description: 30 | "Trigger commands by watching for them on stdin. Prevents stdin from being forwarded to the process. Only command right now is `rs` to restart the server.", 31 | default: false, 32 | }) 33 | .option("watch", { 34 | alias: "w", 35 | type: "boolean", 36 | description: "Trigger restarts by watching for changes to required files", 37 | default: false, 38 | }).argv; 39 | 40 | return await wds({ 41 | argv: args._ as any, 42 | terminalCommands: args.commands, 43 | reloadOnChanges: args.watch, 44 | }); 45 | }; 46 | 47 | const startTerminalCommandListener = (project: Project) => { 48 | const reader = readline.createInterface({ 49 | input: process.stdin, 50 | output: process.stdout, 51 | terminal: false, 52 | }); 53 | 54 | reader.on("line", (line: string) => { 55 | if (line.trim() === "rs") { 56 | log.info("Restart command received, restarting..."); 57 | void project.invalidateBuildSetAndReload(); 58 | } 59 | }); 60 | 61 | project.addShutdownCleanup(() => reader.close()); 62 | 63 | return reader; 64 | }; 65 | 66 | const gitDir = `${path.sep}.git${path.sep}`; 67 | const nodeModulesDir = `${path.sep}node_modules${path.sep}`; 68 | 69 | const startFilesystemWatcher = (project: Project) => { 70 | const watcher = new Watcher([project.workspaceRoot], { 71 | ignoreInitial: true, 72 | recursive: true, 73 | ignore: (filePath: string) => { 74 | if (filePath.includes(nodeModulesDir)) return true; 75 | if (filePath == project.workspaceRoot) return false; 76 | if (filePath == project.config.root) return false; 77 | if (filePath.endsWith(".d.ts")) return true; 78 | if (filePath.endsWith(".map")) return true; 79 | if (filePath.includes(gitDir)) return true; 80 | if (filePath.endsWith(".DS_Store")) return true; 81 | if (filePath.endsWith(".tsbuildinfo")) return true; 82 | 83 | // Don't watch anything outside the workspace root 84 | // This prevents climbing up into parent directories like /Users/home 85 | if (!filePath.startsWith(project.workspaceRoot + path.sep) && filePath !== project.workspaceRoot) { 86 | return true; // ignore paths outside workspace 87 | } 88 | 89 | // check the project's included file matcher 90 | return !project.config.includedMatcher(filePath); 91 | }, 92 | }); 93 | 94 | log.debug("started watcher", { root: project.workspaceRoot }); 95 | 96 | project.supervisor.on("message", (value) => { 97 | if (value.require) { 98 | if (!value.require.includes("node_modules")) { 99 | project.watchFile(value.require); 100 | } 101 | } 102 | }); 103 | 104 | const reload = (path: string) => project.enqueueReload(path, false); 105 | const invalidateAndReload = (path: string) => project.enqueueReload(path, true); 106 | 107 | watcher.on("change", reload); 108 | watcher.on("add", invalidateAndReload); 109 | watcher.on("addDir", invalidateAndReload); 110 | watcher.on("unlink", invalidateAndReload); 111 | watcher.on("unlinkDir", invalidateAndReload); 112 | watcher.on("error", (error) => log.error("watcher error", error)); 113 | 114 | project.addShutdownCleanup(() => void watcher.close()); 115 | 116 | return watcher; 117 | }; 118 | 119 | const startIPCServer = async (socketPath: string, project: Project) => { 120 | const compile = async (filename: string) => { 121 | try { 122 | await project.compiler.compile(filename); 123 | project.watched.insert(filename); 124 | return await project.compiler.fileGroup(filename); 125 | } catch (error) { 126 | log.error(`Error compiling file ${filename}:`, error); 127 | 128 | if (error instanceof MissingDestinationError && error.ignoredFile) { 129 | return { 130 | [filename]: { 131 | ignored: true, 132 | }, 133 | }; 134 | } 135 | } 136 | }; 137 | 138 | const server = new MiniServer({ 139 | "/compile": async (request, reply) => { 140 | const results = await compile(request.body); 141 | reply.json({ filenames: results }); 142 | }, 143 | "/file-required": (request, reply) => { 144 | for (const filename of request.json()) { 145 | project.watchFile(filename); 146 | } 147 | reply.json({ status: "ok" }); 148 | }, 149 | }); 150 | 151 | log.debug(`Starting supervisor server at ${socketPath}`); 152 | await server.start(socketPath); 153 | 154 | project.addShutdownCleanup(() => server.close()); 155 | 156 | return server; 157 | }; 158 | 159 | const childProcessArgs = (config: ProjectConfig) => { 160 | const args = ["--require", path.join(dirname, "hooks", "child-process-cjs-hook.cjs")]; 161 | if (config.esm) { 162 | args.push("--import", path.join(dirname, "hooks", "child-process-esm-hook.js")); 163 | } 164 | return args; 165 | }; 166 | 167 | export const wds = async (options: RunOptions) => { 168 | let workspaceRoot: string; 169 | let projectRoot: string; 170 | const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "wds")); 171 | 172 | const firstNonOptionArg = options.argv.find((arg) => !arg.startsWith("-")); 173 | if (firstNonOptionArg && fs.existsSync(firstNonOptionArg)) { 174 | const absolutePath = path.resolve(firstNonOptionArg); 175 | projectRoot = findRoot(path.dirname(absolutePath)); 176 | workspaceRoot = (await findPnpmWorkspaceRoot(projectRoot)) || findYarnWorkspaceRoot(projectRoot) || projectRoot; 177 | } else { 178 | projectRoot = findRoot(process.cwd()); 179 | workspaceRoot = (await findPnpmWorkspaceRoot(process.cwd())) || findYarnWorkspaceRoot(process.cwd()) || process.cwd(); 180 | } 181 | 182 | let serverSocketPath: string; 183 | if (os.platform() === "win32") { 184 | serverSocketPath = path.join("\\\\?\\pipe", workDir, "ipc.sock"); 185 | } else { 186 | serverSocketPath = path.join(workDir, "ipc.sock"); 187 | } 188 | 189 | const config = await projectConfig(projectRoot); 190 | log.debug(`starting wds for workspace root ${workspaceRoot} and workdir ${workDir}`, config); 191 | 192 | const compiler = await SwcCompiler.create(workspaceRoot, config.cacheDir); 193 | const project = new Project(workspaceRoot, config, compiler); 194 | 195 | project.supervisor = new Supervisor([...childProcessArgs(config), ...options.argv], serverSocketPath, options, project); 196 | 197 | if (options.reloadOnChanges) startFilesystemWatcher(project); 198 | if (options.terminalCommands) startTerminalCommandListener(project); 199 | const server = await startIPCServer(serverSocketPath, project); 200 | 201 | // kickoff the first child process 202 | options.reloadOnChanges && log.info(`Supervision starting for command: node ${options.argv.join(" ")}`); 203 | await project.invalidateBuildSetAndReload(); 204 | 205 | process.on("SIGINT", () => { 206 | log.debug(`process ${process.pid} got SIGINT`); 207 | void project.shutdown(0, "SIGINT"); 208 | }); 209 | process.on("SIGTERM", () => { 210 | log.debug(`process ${process.pid} got SIGTERM`); 211 | void project.shutdown(0, "SIGTERM"); 212 | }); 213 | process.on("SIGQUIT", () => { 214 | log.debug(`process ${process.pid} got SIGQUIT`); 215 | void project.shutdown(0, "SIGQUIT"); 216 | }); 217 | 218 | project.supervisor.process.on("exit", (code, signal) => { 219 | const logShutdown = (explanation: string) => { 220 | log.debug(`child process exited with code=${code} signal=${signal}, ${explanation}`); 221 | }; 222 | if (options.reloadOnChanges) { 223 | logShutdown("not exiting because we're on 'watch' mode"); 224 | return; 225 | } 226 | logShutdown("shutting down project since it's no longer needed..."); 227 | void project.shutdown(code ?? 1); 228 | }); 229 | 230 | return server; 231 | }; 232 | -------------------------------------------------------------------------------- /src/hooks/child-process-esm-loader.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Loader file registered as an ESM module loader 3 | */ 4 | import fs from "node:fs/promises"; 5 | import type { LoadHook, ModuleFormat, ResolveHook } from "node:module"; 6 | import { builtinModules, createRequire } from "node:module"; 7 | import { dirname, join, resolve as resolvePath } from "node:path"; 8 | import { fileURLToPath, pathToFileURL } from "node:url"; 9 | import { ResolverFactory } from "oxc-resolver"; 10 | import { debugLog } from "../SyncWorker.cjs"; 11 | import { compileInLeaderProcess } from "./compileInLeaderProcess.cjs"; 12 | import { notifyParentProcessOfRequire } from "./utils.cjs"; 13 | 14 | const extensions = process.env["WDS_EXTENSIONS"]!.split(","); 15 | const builtin = new Set(builtinModules); 16 | 17 | const esmResolver = new ResolverFactory({ 18 | conditionNames: ["node", "import"], 19 | extensions, 20 | extensionAlias: { 21 | ".js": [".js", ".ts"], 22 | ".cjs": [".cjs", ".cts"], 23 | ".mjs": [".mjs", ".mts"], 24 | ".jsx": [".jsx", ".tsx"], 25 | ".mjsx": [".mjsx", ".mtsx"], 26 | ".cjsx": [".cjsx", ".ctsx"], 27 | }, 28 | }); 29 | 30 | // export a custom require hook that resolves .js imports to .ts files 31 | export const resolve: ResolveHook = async function resolve(specifier, context, nextResolve) { 32 | // import fs from "node:fs" 33 | if (specifier.startsWith("node:")) { 34 | return { 35 | url: specifier, 36 | format: "builtin", 37 | shortCircuit: true, 38 | }; 39 | } 40 | 41 | // import fs from "fs" 42 | if (builtin.has(specifier)) { 43 | return { 44 | url: `node:${specifier}`, 45 | format: "builtin", 46 | shortCircuit: true, 47 | }; 48 | } 49 | 50 | // import from data URLs 51 | if (specifier.startsWith("data:")) { 52 | return { 53 | url: specifier, 54 | shortCircuit: true, 55 | }; 56 | } 57 | 58 | // import attributes present which we're just gonna assume import json, don't touch em 59 | if (context.importAttributes?.type) { 60 | return { 61 | ...(await nextResolve(specifier)), 62 | shortCircuit: true, 63 | }; 64 | } 65 | 66 | // if there's no parentURL, we're resolving an absolute path or an entrypoint. resolve relative to cwd 67 | let parentURL: string; 68 | if (context.parentURL) { 69 | // strip the filename from the parentURL to get the dir to resolve relative to 70 | parentURL = join(fileURLToPath(context.parentURL), ".."); 71 | } else { 72 | parentURL = process.cwd(); 73 | } 74 | 75 | debugLog?.("esm resolver running", { specifier, context, parentURL }); 76 | 77 | const resolved = await esmResolver.async(parentURL, specifier.startsWith("file:") ? fileURLToPath(specifier) : specifier); 78 | debugLog?.("oxc resolver result", { specifier, parentURL, resolved }); 79 | 80 | if (!resolved.error && resolved.path) { 81 | // we were able to resolve with our custom resolver 82 | const targetPath = resolved.path; 83 | 84 | // we resolved to a path that needs compilation, return the specifier with the wds protocol 85 | if (extensions.some((ext) => targetPath.endsWith(ext))) { 86 | const url = new URL(join("file://", targetPath)); 87 | url.search = "wds=true"; 88 | 89 | return { 90 | format: resolved.moduleType as any, 91 | url: url.toString(), 92 | shortCircuit: true, 93 | }; 94 | } 95 | } else if (resolved.error) { 96 | debugLog?.("oxc resolver failed, will try node fallback", { specifier, parentURL, error: resolved.error }); 97 | } 98 | 99 | // we weren't able to resolve with our custom resolver, fallback to node's default resolver 100 | try { 101 | const res = await nextResolve(specifier); 102 | debugLog?.("esm: resolved with node fallback resolver", { specifier, url: res.url, format: res.format }); 103 | return { 104 | ...res, 105 | shortCircuit: true, 106 | }; 107 | } catch (resolveError) { 108 | // fallback to cjs resolver, as the specifier may point to non-esm files that can be required 109 | // stolen from https://github.com/swc-project/swc-node/blob/6f162b495fb1414c16d3d30b61dcfcce6afbb260/packages/register/esm.mts#L209 110 | try { 111 | debugLog?.("oxc and node backup resolve error", { resolveError: (resolveError as Error).message }); 112 | 113 | const resolution = pathToFileURL(createRequire(process.cwd()).resolve(specifier)).toString(); 114 | 115 | debugLog?.("esm: resolved with commonjs require fallback", { specifier, resolution }); 116 | 117 | return { 118 | format: "commonjs", 119 | url: resolution, 120 | shortCircuit: true, 121 | }; 122 | } catch (error) { 123 | debugLog?.("esm: commonjs require fallback error", { specifier, error }); 124 | throw resolveError; 125 | } 126 | } 127 | }; 128 | 129 | const paths: Record< 130 | string, 131 | | string 132 | | { 133 | ignored: boolean; 134 | } 135 | > = {}; 136 | 137 | // Compile a given file by sending it to the leader process 138 | // The leader process returns us a list of all the files it just compiled, so that we don't have to pay the IPC boundary cost for each file after this one 139 | // So, we keep a map of all the files it's compiled so far, and check it first. 140 | const compileOffThread = async (filename: string): Promise => { 141 | let result = paths[filename]; 142 | if (!result) { 143 | const newPaths = await compileInLeaderProcess(filename); 144 | Object.assign(paths, newPaths); 145 | result = paths[filename]; 146 | } 147 | 148 | if (!result) { 149 | throw new Error( 150 | `[wds] Internal error: compiled ${filename} but did not get it returned from the leader process in the list of compiled files` 151 | ); 152 | } 153 | 154 | return result; 155 | }; 156 | 157 | export const load: LoadHook = async function load(url, context, nextLoad) { 158 | if (!url.endsWith("wds=true")) { 159 | return await nextLoad(url, context); 160 | } 161 | url = url.slice(0, -9); 162 | if (!extensions.some((ext) => url.endsWith(ext))) { 163 | return await nextLoad(url, context); 164 | } 165 | 166 | const format: ModuleFormat = context.format ?? (await getPackageType(fileURLToPath(url))) ?? "commonjs"; 167 | if (format == "commonjs") { 168 | // if the package is a commonjs package and we return the source contents explicitly, this loader will process the inner requires, but with a broken/different version of \`require\` internally. 169 | // if we return a nullish source, node falls back to the old, mainline require chain, which has require.cache set properly and whatnot. 170 | // see https://nodejs.org/docs/latest-v22.x/api/module.html#loadurl-context-nextload under "Omitting vs providing a source for 'commonjs' has very different effects:" 171 | debugLog?.("esm loader falling back to node builtin commonjs loader", { url, format }); 172 | 173 | return { 174 | format, 175 | shortCircuit: true, 176 | }; 177 | } 178 | 179 | const sourceFileName = url.startsWith("file:") ? fileURLToPath(url) : url; 180 | const targetFileName = await compileOffThread(sourceFileName); 181 | if (typeof targetFileName !== "string") { 182 | throw new Error(`WDS ESM loader failed because the filename ${sourceFileName} is ignored but still being imported.`); 183 | } 184 | 185 | notifyParentProcessOfRequire(sourceFileName); 186 | const content = fs.readFile(targetFileName, "utf8"); 187 | 188 | debugLog?.("esm load success", { url, context, sourceFileName, targetFileName, format }); 189 | 190 | return { 191 | format: format as any, 192 | shortCircuit: true, 193 | source: await content, 194 | }; 195 | }; 196 | 197 | async function getPackageType(path: string, isFilePath?: boolean): Promise { 198 | try { 199 | isFilePath ??= await fs.readdir(path).then(() => false); 200 | } catch (err: any) { 201 | if (err?.code !== "ENOTDIR") { 202 | throw err; 203 | } 204 | isFilePath = true; 205 | } 206 | 207 | // If it is a file path, get the directory it's in 208 | const dir = isFilePath ? dirname(path) : path; 209 | // Compose a file path to a package.json in the same directory, 210 | // which may or may not exist 211 | const packagePath = resolvePath(dir, "package.json"); 212 | debugLog?.("getPackageType", { path, packagePath }); 213 | 214 | // Try to read the possibly nonexistent package.json 215 | const type = await fs 216 | .readFile(packagePath, { encoding: "utf8" }) 217 | .then((filestring) => { 218 | // As per node's docs, we use the nearest package.json to figure out the package type (see https://nodejs.org/api/packages.html#type) 219 | // If it lacks a "type" key, we assume "commonjs". If we fail to parse, we also choose to assume "commonjs". 220 | try { 221 | return JSON.parse(filestring).type || "commonjs"; 222 | } catch (_err) { 223 | return "commonjs"; 224 | } 225 | }) 226 | .catch((err) => { 227 | if (err?.code !== "ENOENT") console.error(err); 228 | }); 229 | // If package.json existed, we guarantee a type and return it. 230 | if (type) return type; 231 | // Otherwise, (if not at the root) continue checking the next directory up 232 | // If at the root, stop and return false 233 | if (dir.length > 1) return await getPackageType(resolvePath(dir, ".."), false); 234 | 235 | return undefined; 236 | } 237 | -------------------------------------------------------------------------------- /src/SwcCompiler.ts: -------------------------------------------------------------------------------- 1 | import type { Config, Options } from "@swc/core"; 2 | import { transform } from "@swc/core"; 3 | import { createRequire } from "node:module"; 4 | import type { XXHashAPI } from "xxhash-wasm"; 5 | import xxhash from "xxhash-wasm"; 6 | 7 | import findRoot from "find-root"; 8 | import * as fs from "fs/promises"; 9 | import globby from "globby"; 10 | import _ from "lodash"; 11 | import micromatch from "micromatch"; 12 | import { hasher } from "node-object-hash"; 13 | import path from "path"; 14 | import { fileURLToPath } from "url"; 15 | import writeFileAtomic from "write-file-atomic"; 16 | import type { Compiler } from "./Compiler.js"; 17 | import { projectConfig } from "./ProjectConfig.js"; 18 | import { log } from "./utils.js"; 19 | 20 | const __filename = fileURLToPath(import.meta.url); 21 | const require = createRequire(import.meta.url); 22 | 23 | const getPackageVersion = async (packageDir: string) => { 24 | const packageJson = JSON.parse(await fs.readFile(path.join(packageDir, "package.json"), "utf-8")); 25 | return packageJson.version; 26 | }; 27 | 28 | export class MissingDestinationError extends Error { 29 | ignoredFile: boolean; 30 | 31 | constructor(error: { message: string; ignoredFile?: boolean }) { 32 | super(error.message); 33 | this.ignoredFile = !!error.ignoredFile; 34 | } 35 | } 36 | 37 | const SWC_DEFAULTS: Config = { 38 | jsc: { 39 | parser: { 40 | syntax: "typescript", 41 | decorators: true, 42 | dynamicImport: true, 43 | }, 44 | target: "es2022", 45 | }, 46 | module: { 47 | type: "commonjs", 48 | lazy: true, 49 | }, 50 | }; 51 | 52 | export type CompiledFile = { filename: string; root: string; destination: string; config: Config }; 53 | export type Group = { root: string; files: Array }; 54 | 55 | type FileGroup = Map; 56 | class CompiledFiles { 57 | private groups: Map; 58 | 59 | constructor() { 60 | this.groups = new Map(); 61 | } 62 | 63 | removeFile(filename: string) { 64 | for (const [root, files] of this.groups.entries()) { 65 | if (files.get(filename)) { 66 | files.delete(filename); 67 | } 68 | } 69 | } 70 | 71 | addFile(file: CompiledFile) { 72 | let group = this.groups.get(file.root); 73 | if (!group) { 74 | group = new Map(); 75 | this.groups.set(file.root, group); 76 | } 77 | group.set(file.filename, file); 78 | } 79 | 80 | group(filename: string): Group | undefined { 81 | for (const [root, files] of this.groups.entries()) { 82 | if (files.get(filename)) { 83 | return { root, files: Array.from(files.values()) }; 84 | } 85 | } 86 | } 87 | 88 | existingFile(filename: string): CompiledFile | undefined { 89 | for (const [_root, files] of this.groups.entries()) { 90 | const file = files.get(filename); 91 | if (file) { 92 | return file; 93 | } 94 | } 95 | } 96 | } 97 | 98 | /** Implements TypeScript building using swc */ 99 | export class SwcCompiler implements Compiler { 100 | private compiledFiles: CompiledFiles; 101 | private invalidatedFiles: Set; 102 | private knownCacheEntries = new Set(); 103 | 104 | static async create(workspaceRoot: string, outDir: string) { 105 | const compiler = new SwcCompiler(workspaceRoot, outDir); 106 | await compiler.initialize(); 107 | return compiler; 108 | } 109 | 110 | /** @private */ 111 | constructor(readonly workspaceRoot: string, readonly outDir: string) { 112 | this.compiledFiles = new CompiledFiles(); 113 | this.invalidatedFiles = new Set(); 114 | } 115 | 116 | private xxhash!: XXHashAPI; 117 | private cacheEpoch!: string; 118 | 119 | async initialize() { 120 | this.xxhash = await xxhash(); 121 | try { 122 | const files = await globby(path.join(this.outDir, "*", "*"), { onlyFiles: true }); 123 | for (const file of files) { 124 | this.knownCacheEntries.add(path.basename(file)); 125 | } 126 | } catch (error) { 127 | // no complaints if the cache dir doesn't exist yet 128 | } 129 | 130 | // Get package versions for cache keys 131 | const [thisPackageVersion, swcCoreVersion] = await Promise.all([ 132 | getPackageVersion(findRoot(__filename)), 133 | getPackageVersion(findRoot(require.resolve("@swc/core"))), 134 | ]); 135 | 136 | this.cacheEpoch = `${thisPackageVersion}-${swcCoreVersion}`; 137 | } 138 | 139 | async invalidateBuildSet() { 140 | this.invalidatedFiles = new Set(); 141 | this.compiledFiles = new CompiledFiles(); 142 | } 143 | 144 | async compile(filename: string): Promise { 145 | const existingFile = this.compiledFiles.existingFile(filename); 146 | 147 | if (existingFile) { 148 | await this.buildFile(filename, existingFile.root, existingFile.config); 149 | } else { 150 | await this.buildGroup(filename); 151 | } 152 | 153 | return; 154 | } 155 | 156 | async fileGroup(filename: string) { 157 | const contents: Record = {}; 158 | const group = this.compiledFiles.group(filename); 159 | 160 | if (!group) { 161 | throw new MissingDestinationError(await this.missingDestination(filename)); 162 | } 163 | 164 | for (const file of group.files) { 165 | contents[file.filename] = file.destination; 166 | } 167 | 168 | return contents; 169 | } 170 | 171 | private async getModule(filename: string) { 172 | const root = findRoot(path.dirname(filename)); 173 | const config = await projectConfig(root); 174 | 175 | let swcConfig: Options; 176 | 177 | if (!config.swc || typeof config.swc === "string") { 178 | swcConfig = { 179 | swcrc: true, 180 | configFile: config.swc && config.swc !== ".swcrc" ? path.resolve(root, config.swc) : undefined, 181 | }; 182 | } else if (config.swc === undefined) { 183 | swcConfig = SWC_DEFAULTS; 184 | } else { 185 | swcConfig = config.swc; 186 | } 187 | 188 | const ignores = config.ignore 189 | .filter((ignore) => { 190 | return ignore.startsWith(root); 191 | }) 192 | .map((ignore) => { 193 | return ignore.replace(root + "/", ""); 194 | }); 195 | 196 | log.debug("searching for filenames", { filename, config, ignores }); 197 | 198 | let fileNames = await globby(config.includeGlob, { 199 | onlyFiles: true, 200 | cwd: root, 201 | dot: true, 202 | absolute: true, 203 | ignore: ignores, 204 | }); 205 | 206 | if (process.platform === "win32") { 207 | fileNames = fileNames.map((fileName) => fileName.replace(/\//g, "\\")); 208 | } 209 | 210 | return { root, fileNames, swcConfig }; 211 | } 212 | 213 | private async buildFile(filename: string, root: string, config: Config): Promise { 214 | const content = await fs.readFile(filename, "utf8"); 215 | 216 | const contentHash = this.xxhash.h32ToString(this.cacheEpoch + "///" + filename + "///" + content); 217 | const cacheKey = `${path.basename(filename).replace(/[^a-zA-Z0-9]/g, "")}-${contentHash.slice(2)}-${hashConfig(config)}`; 218 | const destination = path.join(this.outDir, contentHash.slice(0, 2), cacheKey); 219 | 220 | if (!this.knownCacheEntries.has(cacheKey)) { 221 | const options: Options = { 222 | cwd: root, 223 | filename: filename, 224 | root: this.workspaceRoot, 225 | rootMode: "root", 226 | sourceMaps: "inline", 227 | swcrc: false, 228 | inlineSourcesContent: true, 229 | ...config, 230 | }; 231 | 232 | const [transformResult, _] = await Promise.all([ 233 | transform(content, options), 234 | fs.mkdir(path.dirname(destination), { recursive: true }), 235 | ]); 236 | 237 | await writeFileAtomic(destination, transformResult.code); 238 | this.knownCacheEntries.add(cacheKey); 239 | } 240 | 241 | const file = { filename, root, destination, config }; 242 | 243 | this.compiledFiles.addFile(file); 244 | this.invalidatedFiles.delete(filename); 245 | 246 | return file; 247 | } 248 | 249 | /** 250 | * Build the group of files at the specified path. 251 | * If the group has already been built, build only the specified file. 252 | */ 253 | private async buildGroup(filename: string): Promise { 254 | // TODO: Use the config 255 | const { root, fileNames, swcConfig } = await this.getModule(filename); 256 | 257 | await this.reportErrors(Promise.allSettled(fileNames.map((filename) => this.buildFile(filename, root, swcConfig)))); 258 | 259 | log.debug("started build", { 260 | root, 261 | promptedBy: filename, 262 | files: fileNames.length, 263 | compiler: "swc", 264 | }); 265 | } 266 | 267 | private async reportErrors(results: Promise[]>) { 268 | for (const result of await results) { 269 | if (result.status === "rejected") { 270 | log.error(result.reason); 271 | } 272 | } 273 | } 274 | 275 | private async missingDestination(filename: string) { 276 | const ignorePattern = await this.isFilenameIgnored(filename); 277 | 278 | // TODO: Understand cases in which the file destination could be missing 279 | if (ignorePattern) { 280 | return { 281 | message: `File ${filename} is imported but not being built because it is explicitly ignored in the wds project config. It is being ignored by the provided glob pattern '${ignorePattern}', remove this pattern from the project config or don't import this file to fix.`, 282 | ignoredFile: true, 283 | }; 284 | } else { 285 | return { 286 | message: `Built output for file ${filename} not found. Is it outside the project directory, or has it failed to build?`, 287 | ignoredFile: false, 288 | }; 289 | } 290 | } 291 | 292 | /** 293 | * Detect if a file is being ignored by the ignore glob patterns for a given project 294 | * 295 | * Returns false if the file isn't being ignored, or the ignore pattern that is ignoring it if it is. 296 | */ 297 | private async isFilenameIgnored(filename: string): Promise { 298 | const root = findRoot(filename); 299 | const config = await projectConfig(root); 300 | 301 | // check if the file is ignored by any of the ignore patterns 302 | const included = config.includedMatcher(filename); 303 | if (!included) { 304 | // figure out which ignore pattern is causing the file to be ignored for a better error message 305 | for (const ignoreGlob of config.ignore) { 306 | if (micromatch.isMatch(filename, ignoreGlob)) { 307 | return ignoreGlob; 308 | } 309 | } 310 | } 311 | 312 | return false; 313 | } 314 | 315 | invalidate(filename: string): void { 316 | this.invalidatedFiles.add(filename); 317 | this.compiledFiles.removeFile(filename); 318 | } 319 | 320 | async rebuild(): Promise { 321 | await Promise.all( 322 | Array.from(this.invalidatedFiles).map((filename) => { 323 | return this.compile(filename); 324 | }) 325 | ); 326 | return; 327 | } 328 | } 329 | 330 | const hashObject = hasher({ sort: true }); 331 | const hashConfig = _.memoize((config: Config) => hashObject.hash(config)); 332 | -------------------------------------------------------------------------------- /spec/ProjectConfig.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import * as path from "path"; 3 | import { fileURLToPath } from "url"; 4 | import { beforeEach, describe, expect, it } from "vitest"; 5 | import { projectConfig } from "../src/ProjectConfig.js"; 6 | 7 | const dirname = fileURLToPath(new URL(".", import.meta.url)); 8 | 9 | describe("ProjectConfig", () => { 10 | beforeEach(() => { 11 | projectConfig.cache.clear?.(); 12 | }); 13 | 14 | describe("default configuration", () => { 15 | it("should load default config when wds.js does not exist", async () => { 16 | const nonExistentRoot = path.join(dirname, "fixtures/configs/non-existent"); 17 | await fs.ensureDir(nonExistentRoot); 18 | 19 | const config = await projectConfig(nonExistentRoot); 20 | 21 | expect(config.root).toBe(nonExistentRoot); 22 | expect(config.extensions).toEqual([".ts", ".tsx", ".jsx"]); 23 | expect(config.esm).toBe(true); 24 | expect(config.includeGlob).toBe("**/*{.ts,.tsx,.jsx}"); 25 | // Default ignores should be applied 26 | expect(config.includedMatcher(path.join(nonExistentRoot, "node_modules/package/index.ts"))).toBe(false); 27 | expect(config.includedMatcher(path.join(nonExistentRoot, "types.d.ts"))).toBe(false); 28 | expect(config.includedMatcher(path.join(nonExistentRoot, ".git/config.ts"))).toBe(false); 29 | 30 | await fs.remove(nonExistentRoot); 31 | }); 32 | 33 | it("should set cacheDir relative to root", async () => { 34 | const nonExistentRoot = path.join(dirname, "fixtures/configs/non-existent2"); 35 | await fs.ensureDir(nonExistentRoot); 36 | 37 | const config = await projectConfig(nonExistentRoot); 38 | 39 | expect(config.cacheDir).toBe(path.join(nonExistentRoot, "node_modules/.cache/wds")); 40 | 41 | await fs.remove(nonExistentRoot); 42 | }); 43 | }); 44 | 45 | describe("config file loading", () => { 46 | it("should load empty config and merge with defaults", async () => { 47 | const configRoot = path.join(dirname, "fixtures/configs/empty-config"); 48 | 49 | const config = await projectConfig(configRoot); 50 | 51 | expect(config.root).toBe(configRoot); 52 | expect(config.extensions).toEqual([".ts", ".tsx", ".jsx"]); 53 | expect(config.esm).toBe(true); 54 | }); 55 | 56 | it("should use config from existing wds.js", async () => { 57 | const configRoot = path.join(dirname, "fixtures/src/files_with_config"); 58 | 59 | const config = await projectConfig(configRoot); 60 | 61 | expect(config.root).toBe(configRoot); 62 | expect(config.swc).toBeDefined(); 63 | expect((config.swc as any).jsc.target).toBe("es5"); 64 | }); 65 | 66 | it("should merge custom extensions with defaults", async () => { 67 | const configRoot = path.join(dirname, "fixtures/configs/with-extensions"); 68 | 69 | const config = await projectConfig(configRoot); 70 | 71 | expect(config.extensions).toEqual([".ts", ".js"]); 72 | expect(config.includeGlob).toBe("**/*{.ts,.js}"); 73 | }); 74 | }); 75 | 76 | describe("file ignores", () => { 77 | it("should work with both absolute and relative paths", async () => { 78 | const configRoot = path.join(dirname, "fixtures/src/files_with_config"); 79 | 80 | const config = await projectConfig(configRoot); 81 | 82 | // Absolute paths within project 83 | expect(config.includedMatcher(path.join(configRoot, "simple.ts"))).toBe(true); 84 | expect(config.includedMatcher(path.join(configRoot, "ignored.ts"))).toBe(false); 85 | 86 | // Absolute paths outside root are allowed (for monorepo/workspace scenarios) 87 | expect(config.includedMatcher("/some/other/path/file.ts")).toBe(true); 88 | expect(config.includedMatcher("/some/other/path/file.tsx")).toBe(true); 89 | }); 90 | 91 | it("should match files with configured extensions", async () => { 92 | const configRoot = path.join(dirname, "fixtures/src/files_with_config"); 93 | 94 | const config = await projectConfig(configRoot); 95 | 96 | expect(config.includedMatcher(path.join(configRoot, "simple.ts"))).toBe(true); 97 | expect(config.includedMatcher(path.join(configRoot, "test.tsx"))).toBe(true); 98 | expect(config.includedMatcher(path.join(configRoot, "test.jsx"))).toBe(true); 99 | }); 100 | 101 | it("should not match files with wrong extensions", async () => { 102 | const configRoot = path.join(dirname, "fixtures/src/files_with_config"); 103 | 104 | const config = await projectConfig(configRoot); 105 | 106 | expect(config.includedMatcher(path.join(configRoot, "test.js"))).toBe(false); 107 | expect(config.includedMatcher(path.join(configRoot, "test.py"))).toBe(false); 108 | expect(config.includedMatcher(path.join(configRoot, "README.md"))).toBe(false); 109 | }); 110 | 111 | it("should not match explicitly ignored files", async () => { 112 | const configRoot = path.join(dirname, "fixtures/src/files_with_config"); 113 | 114 | const config = await projectConfig(configRoot); 115 | 116 | expect(config.includedMatcher(path.join(configRoot, "ignored.ts"))).toBe(false); 117 | expect(config.includedMatcher(path.join(configRoot, "simple.ts"))).toBe(true); 118 | }); 119 | 120 | it("should not match .d.ts files", async () => { 121 | const configRoot = path.join(dirname, "fixtures/configs/empty-config"); 122 | 123 | const config = await projectConfig(configRoot); 124 | 125 | expect(config.includedMatcher(path.join(configRoot, "types.d.ts"))).toBe(false); 126 | expect(config.includedMatcher(path.join(configRoot, "src/types.d.ts"))).toBe(false); 127 | expect(config.includedMatcher(path.join(configRoot, "types.ts"))).toBe(true); 128 | }); 129 | 130 | it("should not match files in node_modules", async () => { 131 | const configRoot = path.join(dirname, "fixtures/configs/empty-config"); 132 | 133 | const config = await projectConfig(configRoot); 134 | 135 | expect(config.includedMatcher(path.join(configRoot, "node_modules/package/index.ts"))).toBe(false); 136 | expect(config.includedMatcher(path.join(configRoot, "src/node_modules/package/index.ts"))).toBe(false); 137 | }); 138 | 139 | it("should not match the .git directory", async () => { 140 | const configRoot = path.join(dirname, "fixtures/configs/empty-config"); 141 | 142 | const config = await projectConfig(configRoot); 143 | 144 | expect(config.includedMatcher(path.join(configRoot, ".git"))).toBe(false); 145 | }); 146 | 147 | it("should not match files in .git directory", async () => { 148 | const configRoot = path.join(dirname, "fixtures/configs/empty-config"); 149 | 150 | const config = await projectConfig(configRoot); 151 | 152 | expect(config.includedMatcher(path.join(configRoot, ".git/config.ts"))).toBe(false); 153 | expect(config.includedMatcher(path.join(configRoot, ".git/hooks/pre-commit.ts"))).toBe(false); 154 | }); 155 | 156 | it("should match files with glob pattern ignores", async () => { 157 | const configRoot = path.join(dirname, "fixtures/configs/basic-ignore"); 158 | 159 | const config = await projectConfig(configRoot); 160 | 161 | expect(config.includedMatcher(path.join(configRoot, "src/file.ts"))).toBe(true); 162 | expect(config.includedMatcher(path.join(configRoot, "src/ignored/file.ts"))).toBe(false); 163 | expect(config.includedMatcher(path.join(configRoot, "file.test.ts"))).toBe(false); 164 | expect(config.includedMatcher(path.join(configRoot, "src/file.test.ts"))).toBe(false); 165 | }); 166 | 167 | it("should match files outside project root to support monorepo/workspace scenarios", async () => { 168 | const configRoot = path.join(dirname, "fixtures/configs/empty-config"); 169 | 170 | const config = await projectConfig(configRoot); 171 | 172 | // These paths are outside the project root 173 | const outsideFile1 = path.resolve(configRoot, "../../outside-file.ts"); 174 | const outsideFile2 = path.resolve(configRoot, "../sibling/file.tsx"); 175 | 176 | // Make paths relative to config root for micromatch 177 | const relativeOutside1 = path.relative(configRoot, outsideFile1); 178 | const relativeOutside2 = path.relative(configRoot, outsideFile2); 179 | 180 | // Files starting with ../ are outside the root 181 | expect(relativeOutside1.startsWith("..")).toBe(true); 182 | expect(relativeOutside2.startsWith("..")).toBe(true); 183 | 184 | // The matcher should match files outside the project root (for workspace scenarios) 185 | expect(config.includedMatcher(outsideFile1)).toBe(true); 186 | expect(config.includedMatcher(outsideFile2)).toBe(true); 187 | }); 188 | 189 | it("should ignore directories outside project root with ../../ patterns", async () => { 190 | const tempRoot = path.join(dirname, "fixtures/configs/temp-parent-ignore"); 191 | await fs.ensureDir(tempRoot); 192 | 193 | // Simulate a monorepo structure: /repo-root/packages/api/wds.js with ignore: ["../../tmp"] 194 | // This should ignore /repo-root/tmp/clickhouse/file.ts 195 | await fs.writeFile( 196 | path.join(tempRoot, "wds.js"), 197 | `module.exports = { 198 | extensions: [".ts", ".tsx"], 199 | ignore: ["../../tmp", "../../.direnv"] 200 | };` 201 | ); 202 | 203 | const config = await projectConfig(tempRoot); 204 | 205 | // Files outside the project root in ../../tmp should be ignored 206 | const repoRoot = path.dirname(path.dirname(tempRoot)); // Go up two levels 207 | const tmpDir = path.join(repoRoot, "tmp"); 208 | const direnvDir = path.join(repoRoot, ".direnv"); 209 | 210 | expect(config.includedMatcher(path.join(tmpDir, "clickhouse", "file.ts"))).toBe(false); 211 | expect(config.includedMatcher(path.join(tmpDir, "file.tsx"))).toBe(false); 212 | expect(config.includedMatcher(path.join(direnvDir, "node", "bin", "node.ts"))).toBe(false); 213 | 214 | // Files inside the project should not be ignored 215 | expect(config.includedMatcher(path.join(tempRoot, "src", "file.ts"))).toBe(true); 216 | 217 | await fs.remove(tempRoot); 218 | }); 219 | 220 | it("should ignore extensionless files and directories in ignored paths", async () => { 221 | const tempRoot = path.join(dirname, "fixtures/configs/temp-extensionless-ignore"); 222 | await fs.ensureDir(tempRoot); 223 | 224 | // Regression test: extensionless files were not being ignored properly 225 | // Some tools create extensionless data files (databases, caches, etc.) 226 | await fs.writeFile( 227 | path.join(tempRoot, "wds.js"), 228 | `module.exports = { 229 | extensions: [".ts", ".tsx", ".mdx"], 230 | ignore: ["../../tmp", "../../.direnv"] 231 | };` 232 | ); 233 | 234 | const config = await projectConfig(tempRoot); 235 | 236 | // Simulate monorepo structure: /repo-root/packages/api/ 237 | const repoRoot = path.dirname(path.dirname(tempRoot)); 238 | const tmpDir = path.join(repoRoot, "tmp"); 239 | const direnvDir = path.join(repoRoot, ".direnv"); 240 | 241 | // Extensionless files deep in ignored directories should be ignored 242 | const deepExtensionlessFile = path.join(tmpDir, "cache", "data", "store", "abc123", "datafile"); 243 | expect(config.includedMatcher(deepExtensionlessFile)).toBe(false); 244 | 245 | // Extensionless files at any depth in ignored paths should be ignored 246 | expect(config.includedMatcher(path.join(tmpDir, "some-cache-file"))).toBe(false); 247 | expect(config.includedMatcher(path.join(direnvDir, "profile"))).toBe(false); 248 | 249 | // Directories in ignored paths should be ignored 250 | expect(config.includedMatcher(path.join(tmpDir, "cache"))).toBe(false); 251 | expect(config.includedMatcher(path.join(tmpDir, "cache", "data"))).toBe(false); 252 | 253 | // But extensionless files outside ignored directories should be allowed (for watching purposes) 254 | expect(config.includedMatcher(path.join(tempRoot, "src", "components"))).toBe(true); 255 | 256 | await fs.remove(tempRoot); 257 | }); 258 | 259 | it("should handle absolute paths in ignore patterns", async () => { 260 | const tempRoot = path.join(dirname, "fixtures/configs/temp-absolute"); 261 | await fs.ensureDir(tempRoot); 262 | const absoluteIgnore = "/some/absolute/path/*.ts"; 263 | await fs.writeFile(path.join(tempRoot, "wds.js"), `module.exports = { ignore: ["${absoluteIgnore}"] };`); 264 | 265 | const config = await projectConfig(tempRoot); 266 | 267 | // Absolute path patterns should be preserved and work 268 | expect(config.includedMatcher("/some/absolute/path/file.ts")).toBe(false); 269 | expect(config.includedMatcher(path.join(tempRoot, "src/file.ts"))).toBe(true); 270 | 271 | await fs.remove(tempRoot); 272 | }); 273 | 274 | it("should handle complex relative patterns outside root", async () => { 275 | const tempRoot = path.join(dirname, "fixtures/configs/temp-complex"); 276 | await fs.ensureDir(tempRoot); 277 | await fs.writeFile(path.join(tempRoot, "wds.js"), `module.exports = { ignore: ["../../../**/*.test.ts", "../../sibling/**"] };`); 278 | 279 | const config = await projectConfig(tempRoot); 280 | 281 | // Test that files matching these patterns are excluded 282 | const outsideTestFile = path.resolve(tempRoot, "../../../some/file.test.ts"); 283 | const siblingFile = path.resolve(tempRoot, "../../sibling/file.ts"); 284 | 285 | expect(config.includedMatcher(outsideTestFile)).toBe(false); 286 | expect(config.includedMatcher(siblingFile)).toBe(false); 287 | expect(config.includedMatcher(path.join(tempRoot, "src/file.ts"))).toBe(true); 288 | 289 | await fs.remove(tempRoot); 290 | }); 291 | }); 292 | 293 | describe("cacheDir resolution", () => { 294 | it("should resolve relative cacheDirs to absolute paths", async () => { 295 | const tempRoot = path.join(dirname, "fixtures/configs/temp-cache"); 296 | await fs.ensureDir(tempRoot); 297 | await fs.writeFile(path.join(tempRoot, "wds.js"), "module.exports = { cacheDir: '.cache/wds' };"); 298 | 299 | const config = await projectConfig(tempRoot); 300 | 301 | expect(config.cacheDir).toBe(path.join(tempRoot, ".cache/wds")); 302 | expect(path.isAbsolute(config.cacheDir)).toBe(true); 303 | 304 | await fs.remove(tempRoot); 305 | }); 306 | 307 | it("should keep absolute cacheDirs as-is", async () => { 308 | const tempRoot = path.join(dirname, "fixtures/configs/temp-cache-abs"); 309 | const absoluteCacheDir = "/tmp/wds-cache"; 310 | await fs.ensureDir(tempRoot); 311 | await fs.writeFile(path.join(tempRoot, "wds.js"), `module.exports = { cacheDir: "${absoluteCacheDir}" };`); 312 | 313 | const config = await projectConfig(tempRoot); 314 | 315 | expect(config.cacheDir).toBe(absoluteCacheDir); 316 | 317 | await fs.remove(tempRoot); 318 | }); 319 | }); 320 | 321 | it("should handle config with default export", async () => { 322 | const tempRoot = path.join(dirname, "fixtures/configs/temp-default"); 323 | await fs.ensureDir(tempRoot); 324 | await fs.writeFile(path.join(tempRoot, "wds.js"), "module.exports.default = { extensions: ['.ts'] };"); 325 | 326 | const config = await projectConfig(tempRoot); 327 | 328 | expect(config.extensions).toEqual([".ts"]); 329 | 330 | await fs.remove(tempRoot); 331 | }); 332 | 333 | it("should handle config with esm: false", async () => { 334 | const tempRoot = path.join(dirname, "fixtures/configs/temp-cjs"); 335 | await fs.ensureDir(tempRoot); 336 | await fs.writeFile(path.join(tempRoot, "wds.js"), "module.exports = { esm: false };"); 337 | 338 | const config = await projectConfig(tempRoot); 339 | 340 | expect(config.esm).toBe(false); 341 | 342 | await fs.remove(tempRoot); 343 | }); 344 | 345 | it("should generate correct includeGlob based on extensions", async () => { 346 | const tempRoot = path.join(dirname, "fixtures/configs/temp-glob"); 347 | await fs.ensureDir(tempRoot); 348 | await fs.writeFile(path.join(tempRoot, "wds.js"), "module.exports = { extensions: ['.ts', '.js', '.mjs'] };"); 349 | 350 | const config = await projectConfig(tempRoot); 351 | 352 | expect(config.includeGlob).toBe("**/*{.ts,.js,.mjs}"); 353 | 354 | await fs.remove(tempRoot); 355 | }); 356 | }); 357 | --------------------------------------------------------------------------------