mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-26 10:41:41 +00:00
feat: add wasm build config (#2985)
This commit is contained in:
parent
32e7b632b8
commit
44c596cd14
7 changed files with 131 additions and 218 deletions
|
|
@ -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
15
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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,15 +25,47 @@ 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;
|
||||
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 {
|
||||
return moduleFilePath;
|
||||
// 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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -172,4 +172,7 @@ button {
|
|||
/* VSCode panel uses 100vh instead of 100% */
|
||||
.chat-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue