vite-plugin-moonbit
v0.5.1
Published
Vite plugin for MoonBit - import MoonBit modules with mbt: prefix
Readme
vite-plugin-moonbit
Vite plugin for MoonBit projects. Supports both JS and WASM-GC backends.
Features
- Import resolution via
mbt:prefix - Auto-starts
moon build --watch - HMR on file changes
- JS and WASM-GC backend support
- MoonBit workspace (
moon.work) / monorepo support - Understands
moon.pkg/moon.workDSL (parsed by a MoonBit-native parser built frommoonbitlang/parser) mbt:imports support?worker,?url,?rawquery suffixes- Source maps forwarded so
.mbtsources are debuggable in browser devtools - Auto-detects
use-js-builtin-stringfrommoon.pkgforwasm-gc - Mix multiple backends in one project (e.g. JS for the main thread,
wasm-gc inside a Web Worker) by instantiating the plugin twice with
distinct
prefixoptions - Optional TypeScript bridge package generation via
mizchi/ts.mbt
Install
pnpm add -D vite-plugin-moonbit// vite.config.ts
import { defineConfig } from 'vite';
import { moonbit } from 'vite-plugin-moonbit';
export default defineConfig({
plugins: [
moonbit({
target: "js",
// run: `moon build --target js --watch` in vite
// If you want to build manually, set `false` and `moon build`
watch: true
})
],
});Quick Start
npx tiged github:mizchi/vite-plugin-moonbit/examples/js_project myapp
cd myapp && pnpm install
moon build && pnpm devUsage
JS Backend (default)
// vite.config.ts
import { defineConfig } from 'vite';
import moonbit from 'vite-plugin-moonbit';
export default defineConfig({
plugins: [moonbit()]
});// main.ts
import { greet } from 'mbt:username/app';Optional: tsconfig.json's paths
{
"compilerOptions": {
// ...
"paths": {
"mbt:internal/app": [
"./_build/js/release/build/app.js"
]
}
}
}Check out: npx tiged mizchi/vite-plugin-moonbit/examples/wasm_project myapp
WASM-GC Backend
// vite.config.ts
import { defineConfig } from 'vite';
import moonbit from 'vite-plugin-moonbit';
export default defineConfig({
plugins: [moonbit({ target: 'wasm-gc' })]
});// main.ts
import init from './_build/wasm-gc/release/build/app.wasm?init';
const instance = await init();
const { add } = instance.exports as { add: (a: number, b: number) => number };
add(1, 2);Check out: npx tiged mizchi/vite-plugin-moonbit/examples/wasm_project myapp
Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| root | string | cwd() | MoonBit project root |
| watch | boolean | true (dev) | Run moon build --watch |
| target | 'js' \| 'wasm' \| 'wasm-gc' | 'js' | Build target |
| showLogs | boolean | true | Show build logs |
| prefix | string | 'mbt:' | Import prefix for this plugin instance |
| tsBridge | MoonbitTsBridgeOptions | undefined | Type-check TS entrypoints and generate MoonBit bridge packages before build |
| npmPackage | MoonbitNpmPackageOptions | undefined | Emit an npm-publishable ESM + .d.ts package from MoonBit |
| normalizedDts | MoonbitNormalizedDtsOptions | undefined | Experimental: rewrite MoonBit-generated _build/.../*.d.ts with clearer TS declarations |
TypeScript bridge packages
Use tsBridge when MoonBit should consume a TypeScript entrypoint through a
generated typed bridge package. Each entry is strictly type-checked before its
MoonBit bindings are generated. If mtsc reports an error, Vite stops before
writing or using a bridge package.
tsBridge is the public integration for TypeScript → MoonBit. Its consumer
contract is the option shape below and generated moon.pkg, bridge.mbti,
bridge.mbt, bridge.js, and package.json. Do not import the checker module
or generated glue directly from application code.
// vite.config.ts
import { defineConfig } from "vite";
import moonbit from "vite-plugin-moonbit";
export default defineConfig({
plugins: [
moonbit({
root: "./moonbit-app",
tsBridge: {
generatorRoot: "../ts.mbt",
entries: ["./src/api/client.ts"],
},
}),
],
});For the string shorthand above, the plugin infers:
moduleSpec:"/src/api/client.ts"outDir:"src/gen/client_bridge"
You can still pass the full object form when you need to override either of those defaults.
To bridge a dependency rather than a project file, use package. The plugin
resolves the package from the Vite root, reads its package.json, and selects
its declaration entrypoint. A subpath such as @types/node/fs resolves the
declaration for that subpath. Keep moduleSpec explicit when the runtime name
differs from the declaration package name.
tsBridge: {
generatorRoot: "../ts.mbt",
entries: [
"./src/api/client.ts",
{
package: "@types/node/fs",
moduleSpec: "node:fs",
outDir: "src/gen/node_fs_bridge",
},
],
}Install the selected package in the consuming project. The generated
node:fs bridge is for Node/SSR MoonBit code, not a browser Vite entrypoint.
Use a non-relative moduleSpec whenever possible, for example
/src/api/client.ts, node:fs, or a bare package name. The generator can emit
leaner MoonBit FFI for non-relative specifiers. Relative specs like
./client.ts still work, but they force more bindings through generated
bridge.js wrappers because MoonBit #module("...") does not currently accept
relative module paths.
For each configured generator root, the plugin builds src/mtsc for the
MoonBit JS target and imports its checkModuleGraph ESM export directly. It
uses Vite's resolver to collect local TS/TSX dependencies and resolved import
edges for every configured entry, then type-checks that complete graph before
generating a bridge. It does not run mtsc as a command or create a temporary
JavaScript output file. The built checker module is reused until its src/ or
Moon manifest inputs change; diagnostic paths are displayed relative to the
Vite root.
Only after that succeeds, it generates the bridge package:
moon -C ../ts.mbt run src/cmd/ts2mbt -- package \
/abs/path/to/src/api/client.ts \
/src/api/client.ts \
/abs/path/to/moonbit-app/src/gen/client_bridgeSet runtimeValidation: true when the generated package must validate
untrusted JS values at an explicit boundary. This selects package-validated
and emits validate<Type>(value : JSValue) -> Type? only for generated
structural types; it does not add hidden checks to normal bridge calls.
tsBridge: {
generatorRoot: "../ts.mbt",
runtimeValidation: true,
entries: ["./src/api/client.ts"],
}The generated package contains:
moon.pkgbridge.mbtibridge.mbtbridge.jspackage.json
Everything inside outDir is generated. The surrounding MoonBit package that
imports that bridge package remains hand-written.
When a MoonBit package imports the generated bridge package, the plugin reads
that package's bridge.js exports and injects the needed bindings into the
compiled MoonBit JS module automatically.
Today bridge.js is still needed for some surfaces even with a non-relative
moduleSpec, especially static class members, value exports, and namespace-like
exports. Plain exported functions, instance members, and class constructors are
already emitted with less wrapper code when direct #module("...") imports are
available.
See examples/ts_bridge_project for a complete example that checks in the generated bridge package and wraps a TypeScript entrypoint from MoonBit.
npm package output
npmPackage makes a MoonBit package publishable as a conventional npm ESM
library. It invokes moon info, uses mbt2ts to generate the public
.d.ts, package.json, and MoonBit facade, then bundles that facade together
with local TypeScript bridge modules. Consumers therefore receive ordinary
JavaScript and declarations; they do not need MoonBit, Vite, or generated
@tsmbt-bridge/* dependencies at runtime.
moonbit({
root: __dirname,
tsBridge: {
generatorRoot: "../ts.mbt",
entries: ["./src/api/client.ts"],
},
npmPackage: {
entry: "internal/app", // MoonBit package name, or pkg.generated.mbti path
outDir: "dist/npm",
name: "@acme/app",
version: "0.1.0",
},
})name and version override the values derived by mbt2ts, so the generated
directory is ready for your registry namespace and release version without a
manual package.json edit.
During vite build, the npm package is generated in closeBundle, after
Vite has cleared its output directory. outDir: "dist/npm" is therefore
safe. The generated directory contains index.js, index.d.ts, source map,
package.json, and AUTOLINK_DIAGNOSTICS.md; verify and publish it with:
cd dist/npm
npm pack --dry-run
npm publishfacade defaults to true, so eligible MoonBit methods and constructors are
made callable from JS. Set strict: true to reject omitted runtime members,
and set bundle: false only when your consumer will provide all external
runtime imports itself.
npmPackage is the public MoonBit → TypeScript integration. Its required
options are entry and outDir; it reuses tsBridge.generatorRoot and
tsBridge.command when present, otherwise provide generatorRoot (and,
optionally, command) directly. name, version, importRewrites, and
diagnostics are passed to the package generator as documented controls.
At this boundary, public MoonBit structs and enums are recursively normalized
to ordinary JavaScript objects (tagged unions use $tag). Values returned by
the package carry an internal opaque brand so they can safely be passed back to
MoonBit APIs, but callers cannot construct a lookalike object themselves.
MoonBit trait methods are intentionally not exposed on these plain values.
An exported function whose parameter or result mentions a MoonBit type from
outside the selected package tree is omitted from both the generated .d.ts
and runtime exports, unless that import has been explicitly mapped to a
publishable TypeScript module. The omission is listed in
AUTOLINK_DIAGNOSTICS.md.
Publish a primitive/JSON boundary package for that API (for example,
mizchi/markdown/api) instead of leaking a cross-package MoonBit value. If
such a type occurs only inside an otherwise public struct or enum, that nested
slot is emitted as unknown so the declaration remains standalone.
Experimental normalized .d.ts
Use normalizedDts when you want the plugin to post-process MoonBit-generated
_build/.../*.d.ts files with mizchi/ts.mbt and replace MoonBit.Double,
MoonBit.String, and similar aliases with clearer TypeScript surface types.
This integration is experimental. It rewrites generated declaration files
in-place after MoonBit build output appears, and the exact normalization rules
may still change. This post-process only runs on the Vite plugin path. If your
workflow calls moon build directly, run the normalizer explicitly after build:
moon -C ../ts.mbt run src/cmd/mbt2ts -- normalize \
/abs/path/to/_build/js/release/build/app.d.ts \
/abs/path/to/_build/js/release/build/app.d.tsmoonbit({
normalizedDts: {
generatorRoot: "../ts.mbt",
},
})If you already use tsBridge, you can reuse its generator settings:
moonbit({
tsBridge: {
generatorRoot: "../ts.mbt",
entries: ["./src/api/client.ts"],
},
normalizedDts: {},
})Today the normalizer is intentionally narrow. It mainly rewrites the
MoonBit-generated namespace alias layer into clearer primitives and named type
imports. Treat it as a readability pass for MoonBit output, not a general
purpose .d.ts pretty-printer.
When normalizedDts is enabled but skipped, the plugin now logs why:
- no generator root was configured
- the MoonBit build directory does not exist yet
- no generated declaration files were found
Path Resolution
The plugin searches upward from root for moon.work / moon.work.json
(workspace) first, then for moon.mod.json (single module). The resolved
project root is where _build/ is expected.
Single module (legacy layout)
| Import | JS | WASM-GC |
|---|---|---|
| mbt:user/pkg | _build/js/release/build/pkg.js | _build/wasm-gc/release/build/pkg.wasm |
| mbt:user/pkg/sub | _build/js/release/build/sub/sub.js | _build/wasm-gc/release/build/sub/sub.wasm |
Workspace (moon.work, multi-root layout)
Module name segments are inserted before the package path. mbt: imports are
matched against every workspace member's moon.mod.json#name by longest
prefix, so all members share one resolver.
| Import | JS |
|---|---|
| mbt:internal/app | _build/js/release/build/internal/app/app.js |
| mbt:internal/shared | _build/js/release/build/internal/shared/shared.js |
| mbt:internal/app/util | _build/js/release/build/internal/app/util/util.js |
See examples/monorepo_project for a full
working workspace with two members (internal/app depends on
internal/shared via a path deps).
// moon.work (at the workspace root)
members = [
"./app",
"./shared",
]When running in workspace mode, moon build --watch is spawned at the
workspace root so all members are built together into a single _build/.
Virtual / implement / overrides
MoonBit's moon.pkg.json supports three fields for swappable implementations:
"virtual": { "has-default": bool }— interface-only package (signatures declared inpkg.mbti)"implement": "user/mod/iface"— a package that implements the above"overrides": ["user/mod/impl_x"]— a main/app package selects which implementation to link
These are resolved entirely by moon's linker; the plugin serves the final
.js output transparently. Virtual and implement-only packages produce only
.mi/.core intermediates (no runtime .js); trying to import 'mbt:<virtual>'
directly prints a hint pointing to the app package that owns the overrides.
See examples/overrides_project.
Multiple backends in one project
Instantiate the plugin per backend with a unique prefix:
// vite.config.ts
const plugins = [
moonbit({ target: 'js' }), // mbt:*
moonbit({ target: 'wasm-gc', prefix: 'mbtw:' }), // mbtw:*
];
export default defineConfig({
plugins,
worker: {
format: 'es',
plugins: () => [
moonbit({ target: 'wasm-gc', prefix: 'mbtw:', watch: false }),
],
},
});// main.ts — runs on JS backend
import { greet } from 'mbt:my';
// worker.ts — runs as a Web Worker using the wasm-gc backend
import init from 'mbtw:my/heavy';See examples/multi_backend_project for a complete setup that offloads CPU-heavy work to a Wasm-GC worker while the main thread stays on the JS backend.
License
MIT
