A Vite plugin that automatically builds WASM C/C++ source code using the Emscripten SDK.
(Japanese language is here/日本語はこちら)
This is a Vite plugin that automatically downloads and manages the Emscripten SDK, and makes it possible to automatically build WASM C/C++ code in your project. With this plugin, you can easily set up a WASM C/C++ development environment in your Vite project.
Usage is simple. Just add this Vite plugin package to your project and initialize the plugin in vite.config like this:
// `vite.config.ts`
import { defineConfig } from 'vite';
// Refer to the emsdk-env Vite plugin
import emsdkEnv from 'emsdk-env/vite';
export default defineConfig({
plugins: [
// Add as a plugin
emsdkEnv({
// Generate a runtime loader code
generatedLoader: { enable: true },
// Build targets
targets: {
// Generate "add.wasm"
add: {
// Compiler options
options: ['-O3', '-std=c99'],
// Linker options
linkOptions: ['--no-entry'],
// Linker directives
linkDirectives: { STANDALONE_WASM: 1 },
// Exported symbols
exports: ['_add'],
},
},
}),
],
});If the source code changes, it will automatically rebuild and reload the page. You can focus on writing C/C++ code just like you would TypeScript/JavaScript code!
- Automatic setup and caching of the Emscripten SDK
- HMR support via Vite plugin (Note: C/C++ code requires a full build)
- Support for parallel builds
- Simplified specification of export symbols
- Ability to generate multiple target WASM binaries
- Customizable directory paths, compile options, and linker options
- Archive libraries (
*.a) can be built and referenced - WASM libraries can be distributed and referenced via NPM packages
Add to devDependencies (emsdk-env itself does not require runtime code):
$ npm install -D emsdk-env- emsdk-env automatically downloads and caches the Emscripten SDK (located under
~/.cache/emsdk-env/). Therefore, you do not need to manually set up the Emscripten SDK.
By default, C/C++ source code is placed in the wasm/ directory under your project,
and the built WASM binaries are placed in the src/wasm/ directory.
A typical directory structure looks like this:
project/
├── package.json
├── vite.config.ts
├── src/
│ ├── generated/
│ │ └── wasm-loader.ts // (Generate automatically)
│ └── wasm/
│ └── add.wasm // (Built WASM binary)
└── wasm/
└── add.c
wasm-loader.tsis helper code that loads and makes WASM binaries usable.- In addition to the above, a temporary build directory is created under the OS temp directory.
The default location is
${TMPDIR}/emsdk-env(typically/tmp/emsdk-envon Unix). This directory is used during the build process and is typically deleted after the build completes. If you overridebuildDirto point inside the project, add it to.gitignore.
Of course, you can change these. Specify them in the Vite plugin options.
You might find it odd that the built binary is placed in src/wasm/,
but this is because the Vite server defaults to a path where it can easily access WASM binaries.
If generatedLoader.enable is set to true, emsdk-env also generates a WASM loader helper code by default at src/generated/wasm-loader.ts.
That loader can call the final WASM exports directly:
import { loadAddWasm } from './generated/wasm-loader';
// WASM exported function declaration (You need to define it)
interface AddExports {
add?: (a: number, b: number) => number;
}
// Load WASM binary and instantiates it
const wasm = await loadAddWasm<AddExports>();
// Get `add()` function entry point
const add = wasm.exports.add;
if (typeof add !== 'function') {
throw new Error('add function not found in wasm exports.');
}
// Then use it now
const result = add(1, 2);- You need to define WASM export functions yourself.
When doing so, the symbol name for the exported function is the same as the C/C++ function name in TypeScript,
but the symbol name specified in
exports: [...]typically requires an underscore prefix (add()-->_add).
If the WASM binary is located in a non-default directory, you can also explicitly specify the source:
// Load WASM binary with target source
const wasm = await loadAddWasm<AddExports>({
source: new URL('./alternate/add.wasm', import.meta.url),
});If you plan to operate with the default settings, there is essentially no configuration work required.
Other topics include features such as explicitly specifying source files, applying multiple compile options separately, handling multiple target outputs, generating and referencing archive library files, and compilation and referencing NPM packages.
We will cover these starting in the next chapter.
When compiling C/C++ sources, you can define preprocessor macros with command-line options like -DOPT=1.
In emsdk-env, you can do this via options, but defines is supported as a dedicated field.
Likewise, you can pass additional linker directives to the Emscripten SDK at link time, for example -s STANDALONE_WASM=1.
In emsdk-env, you can do this via linkOptions, but linkDirectives is supported as a dedicated field.
Here is an example:
export default defineConfig({
plugins: [
emsdkEnv({
targets: {
add: {
// Define preprocessor macros `-DOPT=1`
defines: { OPT: 1 },
// Define linker directives `-s STANDALONE_WASM=1`
linkDirectives: {
STANDALONE_WASM: 1,
EXPORTED_RUNTIME_METHODS: ['wasmMemory'],
},
// :
// :
},
},
}),
],
});definesandlinkDirectivescan be provided as objects as shown above, and they also acceptMaporstring[].- For
string[], separate key and value with=as in'OPT=1'. - To omit a value (e.g.
-DOPT), set the value toundefinedornull. For string entries, a definition without=is treated as a key without a value. definesvalues are scalar-only (string | number | boolean | null | undefined).linkDirectivesvalues also acceptstring[], which is emitted as JSON list syntax like-s EXPORTED_RUNTIME_METHODS=["wasmMemory"].
By default, files matching wasm/**/*.c and wasm/**/*.cpp are treated as source files and built.
The leading wasm/ directory is the "source root directory", and any source file under it becomes a compile target.
To change this, explicitly set srcDir and/or sources:
export default defineConfig({
plugins: [
emsdkEnv({
// Explicitly set the source root directory
srcDir: 'wasm',
targets: {
add: {
// Explicitly specify source file patterns
sources: ['**/*.c++', '**/*.cpp'],
// :
// :
},
},
}),
],
});srcDiris also used as the root directory that the Vite plugin watches for source changes. Files outsidesrcDirwill not trigger rebuilds during the Vite dev server.
Building a single WASM binary may require compiling different sources with different options. In that case, use "source groups" to split source files into groups:
export default defineConfig({
plugins: [
emsdkEnv({
targets: {
add: {
// Compile options (common)
options: ['-O3', '-std=c99'],
// Define source groups
sourceGroups: [
{
sources: ['opt/**/*.c'],
defines: { OPT: 1 }, // -DOPT=1
},
{
sources: ['opt/**/*.c'],
defines: { OPT: 2 }, // -DOPT=2
},
],
// :
// :
},
},
}),
],
});In the above case, compilation runs as follows:
- Sources under
opt/are compiled withOPT=1. - Sources under
opt/are compiled withOPT=2. - All other sources are compiled without additional defines. (Sources under
wasm/are still targets, except those covered above.)
All of these object files are linked together to produce add.wasm.
Therefore, take care to avoid symbol collisions for sources under opt/.
If you compile unrelated source sets with different options, there is no problem.
You may want to generate multiple WASM binaries in a single project.
In that case, add multiple entries under targets:
export default defineConfig({
plugins: [
emsdkEnv({
targets: {
// Build "add.wasm"
add: {
options: ['-O3', '-std=c99'],
defines: { OPERATOR: 'ADD' },
// :
// :
},
// Build "mul.wasm"
mul: {
options: ['-O3', '-std=c99'],
defines: { OPERATOR: 'MUL' },
// :
// :
},
},
}),
],
});You can split targets as above, but when the same options repeat, use common to share them:
export default defineConfig({
plugins: [
emsdkEnv({
common: {
// Common compile options
options: ['-O3', '-std=c99'],
},
targets: {
// Build "add.wasm"
add: {
defines: { OPERATOR: 'ADD' },
// :
// :
},
// Build "mul.wasm"
mul: {
defines: { OPERATOR: 'MUL' },
// :
// :
},
},
}),
],
});emsdk-env also supports building and using archive libraries (*.a).
To build one, set type: 'archive' on the target:
export default defineConfig({
plugins: [
emsdkEnv({
targets: {
libcalc: {
// Generate "libcalc.a"
type: 'archive',
// :
// :
},
},
}),
],
});Archive libraries are placed under lib/ by default (unlike WASM binaries).
You can change this directory with libDir.
lib/ is also included in the default linker options as -Llib.
That means you can reference the archive with just -lcalc at link time:
export default defineConfig({
plugins: [
emsdkEnv({
// Explicitly set the archive base directory
libDir: 'wasm-lib',
targets: {
// "libcalc.a"
libcalc: {
type: 'archive',
// :
// :
},
// "offload.wasm"
offload: {
// Reference "libcalc.a"
linkOptions: ['-lcalc'],
// :
// :
},
},
}),
],
});Note: The Emscripten SDK uses archive library filenames with the lib... prefix, such as libcalc.a, as a convention.
Therefore, apply the prefix similarly to the target name, such as libcalc: { ... }.
While builds are possible without the prefix and can generate archive library files
like calc.a, linking with -lcalc will not work without the prefix.
With emsdk-env, you can package and distribute your header files and WASM archives. The key point is to place include files and archive libraries under fixed directories:
project/
├── package.json
├── vite.config.ts
├── wasm/
│ └── add.c
├── include/
│ └── calc.h // Include files
└── lib/
└── libcalc.a // Archive libraries
With this structure, just add include and lib to files in package.json:
{
"files": ["include", "lib"]
// :
// :
}If you changed includeDir or libDir, add an emsdk-env key to package.json:
{
"files": ["inc", "wasm-lib"],
"emsdk-env": {
"include": "inc",
"lib": "wasm-lib"
}
// :
// :
}Note: The imports explained in the next section resolve packages using Node's module resolution (equivalent to require.resolve).
Therefore, the package must have resolvable entries such as main, exports, or index.js.
When distributing only headers and .a files, include an empty dummy.js or similar.
For example, define package.json as follows to include an empty dummy.js:
{
"name": "wasm-calc-lib",
"version": "1.0.0",
"main": "dummy.js",
"files": ["dummy.js", "include", "lib"]
}Packages built as above can be installed like any other NPM package.
Normally, install them in devDependencies, because once the WASM binary is built,
the package's include files and archive libraries are no longer needed:
$ npm install -D wasm-calc-libOn the emsdk-env side, specify which packages to use via imports:
export default defineConfig({
plugins: [
emsdkEnv({
// Use the library from "wasm-calc-lib"
imports: ['wasm-calc-lib'],
targets: {
// "offload.wasm"
offload: {
// Reference "libcalc.a" from "wasm-calc-lib"
linkOptions: ['-lcalc'],
// :
// :
},
},
}),
],
});Each target can now reference include files and archive libraries from the specified packages.
Others:
- The search order follows the order in
imports. If you get symbol collisions, adjust the package order. - If you installed a referenced package in
devDependencies, resolution fails in these cases:npm ci --omit=dev;npm installwithNODE_ENV=production; or when build and runtime environments are split and runtime omits devDependencies. In those cases, install it as a normal dependency and make sure include/archive files are not shipped in the final artifact. - When using yarn, files inside the package may not be accessible by default.
Use
nodeLinker: node-modulesor mark the target package as unplugged so it is materialized.
“wasm-opt” is a utility included in the Emscripten SDK that performs additional optimizations. It is implemented by Binaryen.
It takes a WASM binary as input and outputs a new, optimized WASM binary.
emsdk-env can use wasm-opt to perform additional optimizations on the link result:
export default defineConfig({
plugins: [
emsdkEnv({
targets: {
// “offload.wasm”
offload: {
// Execute additional optimizations using wasm-opt
wasmOpt: {
enable: true,
options: ['-Oz', '--enable-simd'],
},
// :
// :
},
},
}),
],
});wasmOpt.enable is false by default. Therefore, explicitly set it to true when using wasm-opt.
Using this, you can specify only wasmOpt.options in common and control whether to apply wasm-opt for each target via wasmOpt.enable.
The options accepted by emsdk-env/vite (EmsdkVitePluginOptions) are:
| Key | Type | Default | Description |
|---|---|---|---|
emsdk |
PrepareEmsdkOptions |
{ targetVersion: 'latest' } |
Emscripten SDK setup. |
srcDir |
string |
'wasm' |
Root directory for C/C++ sources (project-root relative). |
includeDir |
string |
'include' |
Default include directory (project-root relative). |
outDir |
string |
'src/wasm' |
WASM output directory (project-root relative). |
libDir |
string |
'lib' |
Archive output directory (project-root relative). |
buildDir |
string |
<OS temp>/emsdk-env |
Temporary build directory. |
cleanupBuildDir |
boolean |
true |
Whether to delete the temp directory after build. |
imports |
string[] |
[] |
NPM packages to reference. Auto-detects include/lib and adds -I/-L accordingly. |
generatedLoader |
{ enable?: boolean; outFile?: string } |
undefined |
Generate a zero-dependency TypeScript WASM loader into the target project when enable is true. |
common |
WasmBuildCommonOptions |
undefined |
Common settings applied to all targets. |
targets |
Record<string, WasmBuildTarget> |
Required | Target definitions, keyed by target name. |
When generatedLoader.enable is true, the default output path is src/generated/wasm-loader.ts.
The generated file contains loadWasm<T>(), WasmInstance<T>, and target wrappers such as loadAddWasm<T>().
Do not place generatedLoader.outFile under watched source/include directories such as srcDir or includeDirs.
The main keys available under common and targets are:
| Key | Type | Default | Description |
|---|---|---|---|
type |
'wasm' | 'archive' |
'wasm' |
Output type. archive produces .a. |
outFile |
string |
<target>.wasm / <target>.a |
Output file name (relative to outDir / libDir). |
sources |
string[] |
['**/*.c', '**/*.cpp'] |
Source globs (relative to srcDir). |
sourceGroups |
WasmBuildSourceGroup[] |
[] |
Source groups with additional options. |
options |
string[] |
[] |
Extra options passed to emcc -c. |
linkOptions |
string[] |
[] |
Extra linker options. Not available for archive. |
linkDirectives |
Record<string, DefineValue | readonly string[]> | Readonly<Map<string, DefineValue | readonly string[]>> | string[] |
{} |
Linker directives mapped to -s KEY=VALUE. Object/Map values accept both scalar values and string[]; array values are emitted as JSON list syntax such as EXPORTED_RUNTIME_METHODS=["wasmMemory"]. string[] entries are parsed as KEY=VALUE (value is string), or KEY (treated as KEY=undefined). Use null/undefined to emit -s KEY. Not available for archive. |
exports |
string[] |
[] |
Exports passed via -s EXPORTED_FUNCTIONS=.... Not available for archive. |
wasmOpt |
WasmOptOptions |
undefined |
wasm-opt options (enable defaults to false; common options default to ['-Oz']). If the output is not .wasm (e.g. .js/.mjs/.html), wasm-opt runs on the associated .wasm (uses WASM_BINARY_FILE if set, otherwise <basename>.wasm). Not available for archive. |
includeDirs |
string[] |
[] |
Additional include directories (-I). |
defines |
Record<string, DefineValue> | Readonly<Map<string, DefineValue>> | string[] |
{} |
Macro definitions (-D). Values are scalar-only. string[] entries are parsed as KEY=VALUE (value is string), or KEY (treated as KEY=undefined). Use null/undefined to emit -DKEY. |
PrepareEmsdkOptions supports:
| Key | Type | Default | Description |
|---|---|---|---|
targetVersion |
string |
'latest' |
Emscripten SDK version to install. |
cacheDir |
string |
~/.cache/emsdk-env |
SDK cache location. |
repoUrl |
string |
'https://github.com/emscripten-core/emsdk' |
emsdk repository URL. |
gitPath |
string |
'git' |
git executable to use. |
signal |
AbortSignal |
undefined |
Abort signal for cancellation. |
Under MIT.
