feat: add wasm build config (#2985)

This commit is contained in:
顾盼 2026-04-09 14:21:00 +08:00 committed by GitHub
parent 32e7b632b8
commit 44c596cd14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 131 additions and 218 deletions

View file

@ -7,7 +7,8 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { writeFileSync, rmSync } from 'node:fs';
import { writeFileSync, rmSync, readFileSync } from 'node:fs';
import { wasmLoader } from 'esbuild-plugin-wasm';
let esbuild;
try {
@ -25,6 +26,35 @@ const pkg = require(path.resolve(__dirname, 'package.json'));
// Clean dist directory (cross-platform)
rmSync(path.resolve(__dirname, 'dist'), { recursive: true, force: true });
/**
* Resolve `import X from '*.wasm?binary'` imports to an inline Uint8Array.
*
* The `?binary` suffix is a build-time hint: at bundle time (esbuild) the WASM
* bytes are embedded as base64 and exported as a default Uint8Array, so no
* external vendor files are needed at runtime. In source / transpiled mode
* the dynamic import throws and the caller falls back to reading from
* node_modules via `require.resolve`.
*/
const wasmBinaryPlugin = {
name: 'wasm-binary',
setup(build) {
build.onResolve({ filter: /\.wasm\?binary$/ }, (args) => {
const specifier = args.path.replace(/\?binary$/, '');
const localRequire = createRequire(
path.resolve(args.resolveDir || __dirname, '_dummy_.js'),
);
return {
path: localRequire.resolve(specifier),
namespace: 'wasm-binary',
};
});
build.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, (args) => {
const contents = readFileSync(args.path);
return { contents, loader: 'binary' };
});
},
};
const external = [
'@lydell/node-pty',
'node-pty',
@ -75,6 +105,7 @@ esbuild
global: 'globalThis',
},
loader: { '.node': 'file' },
plugins: [wasmBinaryPlugin, wasmLoader({ mode: 'embedded' })],
metafile: true,
write: true,
keepNames: true,

15
package-lock.json generated
View file

@ -35,6 +35,7 @@
"@xterm/xterm": "^6.0.0",
"cross-env": "^7.0.3",
"esbuild": "^0.25.0",
"esbuild-plugin-wasm": "^1.1.0",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-import": "^2.31.0",
@ -7499,6 +7500,20 @@
"@esbuild/win32-x64": "0.25.6"
}
},
"node_modules/esbuild-plugin-wasm": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/esbuild-plugin-wasm/-/esbuild-plugin-wasm-1.1.0.tgz",
"integrity": "sha512-0bQ6+1tUbySSnxzn5jnXHMDvYnT0cN/Wd4Syk8g/sqAIJUg7buTIi22svS3Qz6ssx895NT+TgLPb33xi1OkZig==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "individual",
"url": "https://ko-fi.com/tschrock"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",

View file

@ -95,6 +95,7 @@
"@xterm/xterm": "^6.0.0",
"cross-env": "^7.0.3",
"esbuild": "^0.25.0",
"esbuild-plugin-wasm": "^1.1.0",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-import": "^2.31.0",

View file

@ -4,14 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import {
initParser,
isShellCommandReadOnlyAST,
extractCommandRules,
_resetParser,
_resolveWasmPathForTesting,
_setParserFailedForTesting,
} from './shellAstParser.js';
import { isShellCommandReadOnly } from './shellReadOnlyChecker.js';
@ -24,79 +22,6 @@ afterAll(() => {
_resetParser();
});
describe('WASM path resolution', () => {
it('resolveWasmPathForModule: computes correct path when resolvePath returns real CLI path', () => {
const symlinkedCliPath = path.join('/usr', 'bin', 'qwen');
const realCliPath = path.join(
'/opt',
'homebrew',
'lib',
'node_modules',
'@qwen-code',
'qwen-code',
'dist',
'cli.js',
);
const result = _resolveWasmPathForTesting(
'tree-sitter.wasm',
symlinkedCliPath,
() => realCliPath,
);
expect(result).toBe(
path.join(
'/opt',
'homebrew',
'lib',
'node_modules',
'@qwen-code',
'qwen-code',
'dist',
'vendor',
'tree-sitter',
'tree-sitter.wasm',
),
);
expect(result).not.toContain(path.join('/usr', 'bin', 'vendor'));
});
it('resolveWasmPathForModule: correctly resolves path when realpathSync returns symlink target in same dir as vendor', () => {
// Simulate: /usr/bin/qwen (symlink) → /usr/lib/node_modules/.../cli.js (real)
// Vendor files live next to cli.js (levelsUp = 0 for bundle case)
const symlinkedCliPath = path.join('/usr', 'bin', 'qwen');
const realCliPath = path.join(
'/usr',
'lib',
'node_modules',
'@qwen-code',
'qwen-code',
'cli.js',
);
const result = _resolveWasmPathForTesting(
'tree-sitter.wasm',
symlinkedCliPath,
() => realCliPath,
);
expect(result).toBe(
path.join(
'/usr',
'lib',
'node_modules',
'@qwen-code',
'qwen-code',
'vendor',
'tree-sitter',
'tree-sitter.wasm',
),
);
// Must NOT use the symlink dir (/usr/bin/vendor/...)
expect(result).not.toContain(path.join('/usr', 'bin', 'vendor'));
});
});
// =========================================================================
// isShellCommandReadOnlyAST — mirror all tests from shellReadOnlyChecker.test.ts
// =========================================================================

View file

@ -16,6 +16,7 @@
import Parser from 'web-tree-sitter';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isShellCommandReadOnly } from './shellReadOnlyChecker.js';
@ -24,16 +25,48 @@ import { isShellCommandReadOnly } from './shellReadOnlyChecker.js';
// Constants
// ---------------------------------------------------------------------------
function resolveModuleFilePath(moduleFilePath: string): string {
/**
* Load a WASM file as a Uint8Array.
*
* In bundle mode (esbuild with wasmBinaryPlugin), the `?binary` import is
* transformed at build-time to embed the WASM bytes inline, so `dynamicImport`
* succeeds and returns the bytes immediately no external vendor files needed.
*
* In source / transpiled mode (Vitest, tsx, etc.), the `?binary` specifier is
* unknown to Node's module resolver and the import throws. The catch block
* falls back to reading the file directly from node_modules.
*/
async function loadWasmBinary(
dynamicImport: () => Promise<unknown>,
fallbackSpecifier: string,
): Promise<Uint8Array> {
const nativeFs =
(process.getBuiltinModule?.('fs') as
| typeof import('node:fs')
| undefined) ?? fs;
const moduleFilePath = fileURLToPath(import.meta.url);
const isBundleMode =
!moduleFilePath.includes(path.join('src', '')) &&
!moduleFilePath.includes(path.join('dist', 'src', ''));
try {
const resolved = fs.realpathSync(moduleFilePath);
// Guard against test environments where `fs` is mocked and realpathSync
// returns undefined rather than throwing.
return typeof resolved === 'string' ? resolved : moduleFilePath;
} catch {
return moduleFilePath;
if (isBundleMode) {
// Bundle mode: esbuild replaces `?binary` imports with inline Uint8Array.
const mod = await dynamicImport();
const wasmBinary = (mod as { default?: unknown }).default;
if (wasmBinary instanceof Uint8Array && wasmBinary.byteLength > 0) {
return wasmBinary;
}
}
} catch {
// Fall through to node_modules lookup below.
}
// Source / dev mode: read the file directly from node_modules.
const require = createRequire(import.meta.url);
const filePath = require.resolve(fallbackSpecifier);
return new Uint8Array(nativeFs.readFileSync(filePath));
}
/**
* Root commands considered read-only by default (no sub-command analysis needed
@ -573,123 +606,6 @@ let initPromise: Promise<void> | null = null;
/** Set to true permanently once WASM initialisation fails. */
let parserInitFailed = false;
/**
* Resolve the path to a WASM file inside vendor/tree-sitter/.
* Handles three deployment scenarios:
* - Source (src/utils/*.ts): 2 levels up to package root
* - Transpiled (dist/src/utils/*.js): 3 levels up
* - Bundle (dist/cli.js): vendor at same level (0 levels)
*
* For the bundle scenario the vendor directory must be located next to
* the cli.js bundle file. The challenge is that `import.meta.url` may
* point to a symlink (e.g. `/usr/bin/qwen`) rather than the real file
* (`/usr/lib/node_modules/@qwen-code/qwen-code/cli.js`), and whether
* Node.js automatically resolves symlinks for `import.meta.url` depends
* on the version and OS. We therefore probe several candidate directories
* and return the first path where the vendor file actually exists.
*/
function resolveWasmPath(filename: string): string {
const rawPath = fileURLToPath(import.meta.url);
// Source / transpiled case: vendor is at a fixed relative depth.
if (rawPath.includes(path.join('src', 'utils'))) {
const levelsUp = rawPath.endsWith('.ts') ? 2 : 3;
return path.join(
path.dirname(rawPath),
...Array<string>(levelsUp).fill('..'),
'vendor',
'tree-sitter',
filename,
);
}
// Bundle case: probe candidate directories so that symlinked installations
// and non-standard installs (e.g. direct binary copy) are handled robustly.
const candidateDirs = getWasmCandidateDirs(rawPath);
for (const dir of candidateDirs) {
const candidate = path.join(dir, 'vendor', 'tree-sitter', filename);
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
/* ignore */
}
}
// Fallback: first candidate (caller will receive ENOENT if file is missing).
return path.join(
candidateDirs[0] ?? path.dirname(rawPath),
'vendor',
'tree-sitter',
filename,
);
}
/**
* Return an ordered, deduplicated list of directories where the bundled
* vendor directory might live. We try multiple sources because:
*
* 1. `import.meta.url` may already be the real path (Node.js 18+ resolves
* symlinks for the entry module on most platforms).
* 2. On some Linux configurations the symlink is NOT resolved, so we
* additionally try `fs.realpathSync` on the raw path.
* 3. `process.argv[1]` is the path Node.js was actually invoked with and
* can differ from `import.meta.url` in certain execution environments.
*/
function getWasmCandidateDirs(rawModulePath: string): string[] {
const unique = new Set<string>();
const add = (p: string | null | undefined) => {
if (p) unique.add(p);
};
// Candidate 1: directory of import.meta.url as-is.
add(path.dirname(rawModulePath));
// Candidate 2: realpath of import.meta.url (resolves symlinks when
// Node.js has not already done so).
try {
const real = fs.realpathSync(rawModulePath);
if (typeof real === 'string') add(path.dirname(real));
} catch {
/* ignore */
}
// Candidate 3 & 4: same two approaches but using process.argv[1], which
// may point to the real file even when import.meta.url does not.
if (process.argv[1]) {
add(path.dirname(process.argv[1]));
try {
const real = fs.realpathSync(process.argv[1]);
if (typeof real === 'string') add(path.dirname(real));
} catch {
/* ignore */
}
}
return [...unique];
}
function resolveWasmPathForModule(
filename: string,
moduleFilePath: string,
resolvePath: (moduleFilePath: string) => string = resolveModuleFilePath,
): string {
const resolvedModuleFilePath = resolvePath(moduleFilePath);
const moduleDir = path.dirname(resolvedModuleFilePath);
const inSrcUtils = resolvedModuleFilePath.includes(path.join('src', 'utils'));
const levelsUp = !inSrcUtils
? 0
: resolvedModuleFilePath.endsWith('.ts')
? 2
: 3;
return path.join(
moduleDir,
...Array<string>(levelsUp).fill('..'),
'vendor',
'tree-sitter',
filename,
);
}
/**
* Initialise the tree-sitter Parser singleton.
* Safe to call multiple times only the first call does real work.
@ -704,14 +620,18 @@ export async function initParser(): Promise<void> {
if (initPromise) return initPromise;
initPromise = (async () => {
const treeSitterWasm = resolveWasmPath('tree-sitter.wasm');
await Parser.init({
locateFile: () => treeSitterWasm,
});
parserInstance = new Parser();
bashLanguage = await Parser.Language.load(
resolveWasmPath('tree-sitter-bash.wasm'),
const treeSitterWasm = await loadWasmBinary(
() => import('web-tree-sitter/tree-sitter.wasm?binary' as string),
'web-tree-sitter/tree-sitter.wasm',
);
await Parser.init({ wasmBinary: treeSitterWasm });
parserInstance = new Parser();
const bashWasm = await loadWasmBinary(
() =>
import('tree-sitter-wasms/out/tree-sitter-bash.wasm?binary' as string),
'tree-sitter-wasms/out/tree-sitter-bash.wasm',
);
bashLanguage = await Parser.Language.load(bashWasm);
parserInstance.setLanguage(bashLanguage);
})().catch((err: unknown) => {
// Mark as permanently failed so callers can use the regex fallback
@ -1234,15 +1154,3 @@ export function _setParserFailedForTesting(): void {
}
bashLanguage = null;
}
/**
* Internal helper exposed for tests.
* @internal
*/
export function _resolveWasmPathForTesting(
filename: string,
moduleFilePath: string,
resolvePath?: (moduleFilePath: string) => string,
): string {
return resolveWasmPathForModule(filename, moduleFilePath, resolvePath);
}

View file

@ -6,8 +6,10 @@
import esbuild from 'esbuild';
import { createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { wasmLoader } from 'esbuild-plugin-wasm';
const production = process.argv.includes('--production');
const watch = process.argv.includes('--watch');
@ -79,6 +81,32 @@ const reactDedupPlugin = {
},
};
/**
* Resolve `*.wasm?binary` imports to embedded Uint8Array content.
* This keeps the companion bundle compatible with core's inline-WASM loader.
* @type {import('esbuild').Plugin}
*/
const wasmBinaryPlugin = {
name: 'wasm-binary',
setup(build) {
build.onResolve({ filter: /\.wasm\?binary$/ }, (args) => {
const specifier = args.path.replace(/\?binary$/, '');
const localRequire = createRequire(
resolve(args.resolveDir || repoRoot, '_dummy_.js'),
);
return {
path: localRequire.resolve(specifier),
namespace: 'wasm-binary',
};
});
build.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, (args) => ({
contents: readFileSync(args.path),
loader: 'binary',
}));
},
};
/**
* @type {import('esbuild').Plugin}
*/
@ -159,6 +187,8 @@ async function main() {
'import.meta.url': 'import_meta.url',
},
plugins: [
wasmBinaryPlugin,
wasmLoader({ mode: 'embedded' }),
/* add to the end of plugins array */
esbuildProblemMatcherPlugin,
],

View file

@ -172,4 +172,7 @@ button {
/* VSCode panel uses 100vh instead of 100% */
.chat-container {
height: 100vh;
display: flex;
flex-direction: column;
min-height: 100vh;
}