Skip to content

Commit 18e613d

Browse files
authored
Merge pull request #164 from gadget-inc/optional-esm
optional esm
2 parents 06b2b5c + caff11e commit 18e613d

22 files changed

+189
-33
lines changed

.github/workflows/test.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ jobs:
1313
- uses: actions/checkout@v2
1414
- uses: ./.github/actions/setup-test-env
1515
- run: pnpm build
16-
- run: pnpm zx integration-test/test.js
1716
- run: pnpm test
17+
- run: pnpm integration-test
1818

1919
lint:
2020
runs-on: ubuntu-latest

Readme.md

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ wds --inspect some-test.test.ts
2424
- Builds and runs TypeScript really fast using [`swc`](https://github.com/swc-project/swc)
2525
- Incrementally rebuilds only what has changed in `--watch` mode, restarting the process on file changes
2626
- Full support for CommonJS and ESM packages (subject to node's own interoperability rules)
27+
- Caches transformed files on disk for warm startups on process reload (with expiry when config or source changes)
2728
- Execute commands on demand with the `--commands` mode
2829
- Plays nice with node.js command line flags like `--inspect` or `--prof`
2930
- Supports node.js `ipc` channels between the process starting `wds` and the node.js process started by `wds`.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { utility } from "./utils";
2+
3+
console.log(utility("It worked!"));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
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"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const utility = (str: string) => str.toUpperCase();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = {
2+
esm: false,
3+
};

integration-test/oom/test.sh

100755100644
File mode changed.

integration-test/parent-crash/test.js

100755100644
File mode changed.

integration-test/reload/test.sh

100755100644
File mode changed.

integration-test/server/test.sh

100755100644
File mode changed.

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"lint:fix": "NODE_OPTIONS=\"--max-old-space-size=4096\" prettier --write --check \"src/**/*.{js,ts,tsx}\" && eslint --ext ts,tsx --fix src",
3434
"prerelease": "gitpkg publish",
3535
"test": "vitest run",
36+
"integration-test": "pnpm run build && zx integration-test/test.js",
3637
"test:watch": "vitest"
3738
},
3839
"engines": {
@@ -47,9 +48,11 @@
4748
"globby": "^11.1.0",
4849
"lodash": "^4.17.20",
4950
"oxc-resolver": "^1.12.0",
51+
"node-object-hash": "^3.0.0",
5052
"pkg-dir": "^5.0.0",
5153
"watcher": "^2.3.1",
5254
"write-file-atomic": "^6.0.0",
55+
"xxhash-wasm": "^1.0.2",
5356
"yargs": "^16.2.0"
5457
},
5558
"devDependencies": {

pnpm-lock.yaml

+15
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/SwcCompiler.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const compile = async (filename: string, root = "fixtures/src") => {
1313
const rootDir = path.join(dirname, root);
1414
const fullPath = path.join(rootDir, filename);
1515

16-
const compiler = new SwcCompiler(rootDir, workDir);
16+
const compiler = await SwcCompiler.create(rootDir, workDir);
1717
await compiler.compile(fullPath);
1818
const compiledFilePath = (await compiler.fileGroup(fullPath))[fullPath]!;
1919

@@ -58,7 +58,7 @@ test("logs error when a file in group fails compilation but continues", async ()
5858
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "wds-test"));
5959
const rootDir = path.join(dirname, "fixtures/failing");
6060
const fullPath = path.join(rootDir, "successful.ts");
61-
const compiler = new SwcCompiler(rootDir, workDir);
61+
const compiler = await SwcCompiler.create(rootDir, workDir);
6262
await compiler.compile(fullPath);
6363
const group = await compiler.fileGroup(fullPath);
6464

src/Options.ts

+2
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,7 @@ export interface RunOptions {
1111
export interface ProjectConfig {
1212
ignore: string[];
1313
swc?: SwcConfig;
14+
esm?: boolean;
1415
extensions: string[];
16+
cacheDir: string;
1517
}

src/Supervisor.ts

+1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export class Supervisor extends EventEmitter {
6666
...process.env,
6767
WDS_SOCKET_PATH: this.socketPath,
6868
WDS_EXTENSIONS: this.project.config.extensions.join(","),
69+
WDS_ESM_ENABLED: this.project.config.esm ? "true" : "false",
6970
},
7071
stdio: stdio,
7172
detached: true,

src/SwcCompiler.ts

+77-14
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
import type { Config, Options } from "@swc/core";
2-
import { transformFile } 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+
37
import findRoot from "find-root";
48
import * as fs from "fs/promises";
59
import globby from "globby";
10+
import _ from "lodash";
11+
import { hasher } from "node-object-hash";
612
import path from "path";
13+
import { fileURLToPath } from "url";
714
import writeFileAtomic from "write-file-atomic";
815
import type { Compiler } from "./Compiler.js";
916
import type { ProjectConfig } from "./Options.js";
1017
import { log, projectConfig } from "./utils.js";
1118

19+
const __filename = fileURLToPath(import.meta.url);
20+
const require = createRequire(import.meta.url);
21+
22+
const getPackageVersion = async (packageDir: string) => {
23+
const packageJson = JSON.parse(await fs.readFile(path.join(packageDir, "package.json"), "utf-8"));
24+
return packageJson.version;
25+
};
26+
1227
export class MissingDestinationError extends Error {
1328
ignoredFile: boolean;
1429

@@ -83,11 +98,43 @@ class CompiledFiles {
8398
export class SwcCompiler implements Compiler {
8499
private compiledFiles: CompiledFiles;
85100
private invalidatedFiles: Set<string>;
101+
private knownCacheEntries = new Set<string>();
102+
103+
static async create(workspaceRoot: string, outDir: string) {
104+
const compiler = new SwcCompiler(workspaceRoot, outDir);
105+
await compiler.initialize();
106+
return compiler;
107+
}
108+
109+
/** @private */
86110
constructor(readonly workspaceRoot: string, readonly outDir: string) {
87111
this.compiledFiles = new CompiledFiles();
88112
this.invalidatedFiles = new Set();
89113
}
90114

115+
private xxhash!: XXHashAPI;
116+
private cacheEpoch!: string;
117+
118+
async initialize() {
119+
this.xxhash = await xxhash();
120+
try {
121+
const files = await globby(path.join(this.outDir, "*", "*"), { onlyFiles: true });
122+
for (const file of files) {
123+
this.knownCacheEntries.add(path.basename(file));
124+
}
125+
} catch (error) {
126+
// no complaints if the cache dir doesn't exist yet
127+
}
128+
129+
// Get package versions for cache keys
130+
const [thisPackageVersion, swcCoreVersion] = await Promise.all([
131+
getPackageVersion(findRoot(__filename)),
132+
getPackageVersion(findRoot(require.resolve("@swc/core"))),
133+
]);
134+
135+
this.cacheEpoch = `${thisPackageVersion}-${swcCoreVersion}`;
136+
}
137+
91138
async invalidateBuildSet() {
92139
this.invalidatedFiles = new Set();
93140
this.compiledFiles = new CompiledFiles();
@@ -151,20 +198,33 @@ export class SwcCompiler implements Compiler {
151198
}
152199

153200
private async buildFile(filename: string, root: string, config: Config): Promise<CompiledFile> {
154-
const output = await transformFile(filename, {
155-
cwd: root,
156-
filename: filename,
157-
root: this.workspaceRoot,
158-
rootMode: "root",
159-
sourceMaps: "inline",
160-
swcrc: false,
161-
inlineSourcesContent: true,
162-
...config,
163-
});
201+
const content = await fs.readFile(filename, "utf8");
202+
203+
const contentHash = this.xxhash.h32ToString(this.cacheEpoch + "///" + filename + "///" + content);
204+
const cacheKey = `${path.basename(filename).replace(/[^a-zA-Z0-9]/g, "")}-${contentHash.slice(2)}-${hashConfig(config)}`;
205+
const destination = path.join(this.outDir, contentHash.slice(0, 2), cacheKey);
206+
207+
if (!this.knownCacheEntries.has(cacheKey)) {
208+
const options: Options = {
209+
cwd: root,
210+
filename: filename,
211+
root: this.workspaceRoot,
212+
rootMode: "root",
213+
sourceMaps: "inline",
214+
swcrc: false,
215+
inlineSourcesContent: true,
216+
...config,
217+
};
218+
219+
const [transformResult, _] = await Promise.all([
220+
transform(content, options),
221+
fs.mkdir(path.dirname(destination), { recursive: true }),
222+
]);
223+
224+
await writeFileAtomic(destination, transformResult.code);
225+
this.knownCacheEntries.add(cacheKey);
226+
}
164227

165-
const destination = path.join(this.outDir, filename).replace(this.workspaceRoot, "");
166-
await fs.mkdir(path.dirname(destination), { recursive: true });
167-
await writeFileAtomic(destination, output.code);
168228
const file = { filename, root, destination, config };
169229

170230
this.compiledFiles.addFile(file);
@@ -267,3 +327,6 @@ export class SwcCompiler implements Compiler {
267327
return;
268328
}
269329
}
330+
331+
const hashObject = hasher({ sort: true });
332+
const hashConfig = _.memoize((config: Config) => hashObject.hash(config));

src/hooks/child-process-cjs-hook.cts

+6-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ if (!workerData || !(workerData as SyncWorkerData).isWDSSyncWorker) {
1515
}
1616
> = {};
1717

18+
// enable source maps
19+
process.setSourceMapsEnabled(true);
20+
1821
// Compile a given file by sending it into our async-to-sync wrapper worker js file
1922
// 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
2023
// So, we keep a map of all the files it's compiled so far, and check it first.
@@ -37,7 +40,9 @@ if (!workerData || !(workerData as SyncWorkerData).isWDSSyncWorker) {
3740

3841
// Register our compiler for typescript files.
3942
// We don't do the best practice of chaining module._compile calls because esbuild 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.
40-
for (const extension of process.env["WDS_EXTENSIONS"]!.split(",")) {
43+
const extensions = process.env["WDS_EXTENSIONS"]!.split(",");
44+
log.debug("registering cjs hook for extensions", extensions);
45+
for (const extension of extensions) {
4146
require.extensions[extension] = (module: any, filename: string) => {
4247
const compiledFilename = compileOffThread(filename);
4348
if (typeof compiledFilename === "string") {

src/hooks/child-process-register.ts src/hooks/child-process-esm-hook.ts

+2-4
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,16 @@
22
* Entrypoint file passed as --import to all child processes started by wds
33
*/
44
import { register } from "node:module";
5+
import { log } from "./utils.cjs";
56

67
if (!register) {
78
throw new Error(
89
`This version of Node.js (${process.version}) does not support module.register(). Please upgrade to Node v18.19 or v20.6 and above.`
910
);
1011
}
1112

12-
// enable source maps
13-
process.setSourceMapsEnabled(true);
14-
1513
// register the CJS hook to intercept require calls the old way
16-
import "./child-process-cjs-hook.cjs";
1714

15+
log.debug("registering wds ESM loader");
1816
// register the ESM loader the new way
1917
register("./child-process-esm-loader.js", import.meta.url);

src/index.ts

+25-8
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { fileURLToPath } from "url";
88
import Watcher from "watcher";
99
import yargs from "yargs";
1010
import { hideBin } from "yargs/helpers";
11-
import type { RunOptions } from "./Options.js";
11+
import type { ProjectConfig, RunOptions } from "./Options.js";
1212
import { Project } from "./Project.js";
1313
import { Supervisor } from "./Supervisor.js";
1414
import { MissingDestinationError, SwcCompiler } from "./SwcCompiler.js";
@@ -142,14 +142,28 @@ const startIPCServer = async (socketPath: string, project: Project) => {
142142
return server;
143143
};
144144

145-
const childProcessArgs = () => {
146-
return ["--import", path.join(dirname, "hooks", "child-process-register.js")];
145+
const childProcessArgs = (config: ProjectConfig) => {
146+
const args = ["--require", path.join(dirname, "hooks", "child-process-cjs-hook.cjs")];
147+
if (config.esm) {
148+
args.push("--import", path.join(dirname, "hooks", "child-process-esm-hook.js"));
149+
}
150+
return args;
147151
};
148152

149153
export const wds = async (options: RunOptions) => {
150-
const workspaceRoot = findWorkspaceRoot(process.cwd()) || process.cwd();
154+
let workspaceRoot: string;
155+
let projectRoot: string;
151156
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "wds"));
152-
log.debug(`starting wds for workspace root ${workspaceRoot} and workdir ${workDir}`);
157+
158+
const firstNonOptionArg = options.argv.find((arg) => !arg.startsWith("-"));
159+
if (firstNonOptionArg && fs.existsSync(firstNonOptionArg)) {
160+
const absolutePath = path.resolve(firstNonOptionArg);
161+
projectRoot = findRoot(path.dirname(absolutePath));
162+
workspaceRoot = findWorkspaceRoot(projectRoot) || projectRoot;
163+
} else {
164+
projectRoot = findRoot(process.cwd());
165+
workspaceRoot = findWorkspaceRoot(process.cwd()) || process.cwd();
166+
}
153167

154168
let serverSocketPath: string;
155169
if (os.platform() === "win32") {
@@ -158,10 +172,13 @@ export const wds = async (options: RunOptions) => {
158172
serverSocketPath = path.join(workDir, "ipc.sock");
159173
}
160174

161-
const compiler = new SwcCompiler(workspaceRoot, workDir);
175+
const config = await projectConfig(projectRoot);
176+
log.debug(`starting wds for workspace root ${workspaceRoot} and workdir ${workDir}`, config);
177+
178+
const compiler = await SwcCompiler.create(workspaceRoot, config.cacheDir);
179+
const project = new Project(workspaceRoot, config, compiler);
162180

163-
const project = new Project(workspaceRoot, await projectConfig(findRoot(process.cwd())), compiler);
164-
project.supervisor = new Supervisor([...childProcessArgs(), ...options.argv], serverSocketPath, options, project);
181+
project.supervisor = new Supervisor([...childProcessArgs(config), ...options.argv], serverSocketPath, options, project);
165182

166183
if (options.reloadOnChanges) startFilesystemWatcher(project);
167184
if (options.terminalCommands) startTerminalCommandListener(project);

0 commit comments

Comments
 (0)