Skip to content

Commit d1d3669

Browse files
committed
fix(nodejs): add CJS compatibility for VS Code extensions (#528)
The SDK's getBundledCliPath() used import.meta.resolve() which fails when consumed via CJS bundlers (esbuild format:"cjs", webpack). This breaks VS Code extensions that bundle dependencies with esbuild. Changes: - Replace import.meta.resolve with createRequire + resolution path walking (works in both ESM and CJS contexts) - Add bundled CJS output (dist/cjs/index.cjs) via esbuild - Add conditional exports in package.json (import + require) - Add CJS compatibility test suite The @github/copilot package only exposes ESM exports for ./sdk, so require.resolve cannot resolve it directly. Instead, we walk the module resolution paths and check for the package directory. Fixes #528
1 parent f0909a7 commit d1d3669

File tree

4 files changed

+122
-11
lines changed

4 files changed

+122
-11
lines changed

nodejs/esbuild-copilotsdk-nodejs.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,39 @@ import { execSync } from "child_process";
44

55
const entryPoints = globSync("src/**/*.ts");
66

7+
// ESM build (existing)
78
await esbuild.build({
89
entryPoints,
910
outbase: "src",
10-
outdir: "dist",
11+
outdir: "dist/esm",
1112
format: "esm",
1213
platform: "node",
1314
target: "es2022",
1415
sourcemap: false,
1516
outExtension: { ".js": ".js" },
1617
});
1718

19+
// CJS build — single bundled file for consumption from CJS bundlers (esbuild
20+
// format:"cjs", webpack, VS Code extensions).
21+
// Bundled to avoid cross-file require resolution issues with .cjs extensions.
22+
// See: https://github.com/github/copilot-sdk/issues/528
23+
await esbuild.build({
24+
entryPoints: ["src/index.ts"],
25+
bundle: true,
26+
outfile: "dist/cjs/index.cjs",
27+
format: "cjs",
28+
platform: "node",
29+
target: "es2022",
30+
sourcemap: false,
31+
// Mark dependencies as external — they're resolved at runtime by the consumer
32+
external: [
33+
"vscode-jsonrpc",
34+
"vscode-jsonrpc/*",
35+
"zod",
36+
"@github/copilot",
37+
"@github/copilot/*",
38+
],
39+
});
40+
1841
// Generate .d.ts files
1942
execSync("tsc", { stdio: "inherit" });

nodejs/package.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@
66
},
77
"version": "0.1.8",
88
"description": "TypeScript SDK for programmatic control of GitHub Copilot CLI via JSON-RPC",
9-
"main": "./dist/index.js",
9+
"main": "./dist/esm/index.js",
1010
"types": "./dist/index.d.ts",
1111
"exports": {
1212
".": {
13-
"import": "./dist/index.js",
14-
"types": "./dist/index.d.ts"
13+
"import": {
14+
"types": "./dist/index.d.ts",
15+
"default": "./dist/esm/index.js"
16+
},
17+
"require": {
18+
"types": "./dist/index.d.ts",
19+
"default": "./dist/cjs/index.cjs"
20+
}
1521
}
1622
},
1723
"type": "module",

nodejs/src/client.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313

1414
import { spawn, type ChildProcess } from "node:child_process";
1515
import { existsSync } from "node:fs";
16+
import { createRequire } from "node:module";
1617
import { Socket } from "node:net";
1718
import { dirname, join } from "node:path";
18-
import { fileURLToPath } from "node:url";
19+
import { pathToFileURL } from "node:url";
1920
import {
2021
createMessageConnection,
2122
MessageConnection,
@@ -117,14 +118,29 @@ function getNodeExecPath(): string {
117118
/**
118119
* Gets the path to the bundled CLI from the @github/copilot package.
119120
* Uses index.js directly rather than npm-loader.js (which spawns the native binary).
121+
*
122+
* The @github/copilot package only exposes an ESM-only "./sdk" export,
123+
* which breaks in CJS contexts (e.g., VS Code extensions bundled with esbuild).
124+
* Instead of resolving through the package's exports, we locate the package
125+
* root by walking module resolution paths and checking for its directory.
126+
* See: https://github.com/github/copilot-sdk/issues/528
120127
*/
121128
function getBundledCliPath(): string {
122-
// Find the actual location of the @github/copilot package by resolving its sdk export
123-
const sdkUrl = import.meta.resolve("@github/copilot/sdk");
124-
const sdkPath = fileURLToPath(sdkUrl);
125-
// sdkPath is like .../node_modules/@github/copilot/sdk/index.js
126-
// Go up two levels to get the package root, then append index.js
127-
return join(dirname(dirname(sdkPath)), "index.js");
129+
// import.meta.url is defined in ESM; in CJS bundles (esbuild format:"cjs")
130+
// it's undefined, so we fall back to __filename via pathToFileURL.
131+
const require = createRequire(import.meta.url ?? pathToFileURL(__filename).href);
132+
// The @github/copilot package has strict ESM-only exports, so require.resolve
133+
// cannot resolve it. Instead, walk the module resolution paths to find it.
134+
const searchPaths = require.resolve.paths("@github/copilot") ?? [];
135+
for (const base of searchPaths) {
136+
const candidate = join(base, "@github", "copilot", "index.js");
137+
if (existsSync(candidate)) {
138+
return candidate;
139+
}
140+
}
141+
throw new Error(
142+
"Could not find @github/copilot package. Ensure it is installed."
143+
);
128144
}
129145

130146
export class CopilotClient {

nodejs/test/cjs-compat.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* CJS compatibility test
3+
*
4+
* Verifies that the SDK's CJS build can be loaded via require() and that
5+
* getBundledCliPath() works when import.meta is unavailable (CJS context).
6+
*
7+
* This is the scenario that breaks for VS Code extensions bundled with
8+
* esbuild format:"cjs". See: https://github.com/github/copilot-sdk/issues/528
9+
*/
10+
11+
import { describe, expect, it } from "vitest";
12+
import { existsSync } from "node:fs";
13+
import { execFileSync } from "node:child_process";
14+
import { join } from "node:path";
15+
16+
const cjsEntryPoint = join(import.meta.dirname, "../dist/cjs/index.cjs");
17+
const esmEntryPoint = join(import.meta.dirname, "../dist/esm/index.js");
18+
19+
describe("CJS compatibility (#528)", () => {
20+
it("CJS dist file should exist", () => {
21+
expect(existsSync(cjsEntryPoint)).toBe(true);
22+
});
23+
24+
it("ESM dist file should exist", () => {
25+
expect(existsSync(esmEntryPoint)).toBe(true);
26+
});
27+
28+
it("CJS build should be requireable and export CopilotClient", () => {
29+
// Run in a subprocess to get a genuine CJS context
30+
const script = `
31+
const sdk = require(${JSON.stringify(cjsEntryPoint)});
32+
if (typeof sdk.CopilotClient !== 'function') {
33+
process.exit(1);
34+
}
35+
console.log('CopilotClient exported: OK');
36+
`;
37+
const output = execFileSync(process.execPath, ["--eval", script], {
38+
encoding: "utf-8",
39+
timeout: 10000,
40+
});
41+
expect(output).toContain("CopilotClient exported: OK");
42+
});
43+
44+
it("CJS CopilotClient constructor should resolve bundled CLI path", () => {
45+
// Verify that new CopilotClient({ cliUrl: "8080" }) doesn't throw
46+
// (constructor calls getBundledCliPath() for the default cliPath)
47+
const script = `
48+
const sdk = require(${JSON.stringify(cjsEntryPoint)});
49+
// Use cliUrl to avoid actually spawning the CLI,
50+
// but the constructor still evaluates getBundledCliPath() for cliPath default
51+
try {
52+
const client = new sdk.CopilotClient({ cliUrl: "8080" });
53+
console.log('constructor: OK');
54+
} catch (e) {
55+
console.error('constructor failed:', e.message);
56+
process.exit(1);
57+
}
58+
`;
59+
const output = execFileSync(process.execPath, ["--eval", script], {
60+
encoding: "utf-8",
61+
timeout: 10000,
62+
cwd: join(import.meta.dirname, ".."),
63+
});
64+
expect(output).toContain("constructor: OK");
65+
});
66+
});

0 commit comments

Comments
 (0)