# 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.work` DSL (parsed by a MoonBit-native
  parser built from `moonbitlang/parser`)
- `mbt:` imports support `?worker`, `?url`, `?raw` query suffixes
- Source maps forwarded so `.mbt` sources are debuggable in browser devtools
- Auto-detects `use-js-builtin-string` from `moon.pkg` for `wasm-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 `prefix` options
- Optional TypeScript bridge package generation via `mizchi/ts.mbt`

## Install

```bash
pnpm add -D vite-plugin-moonbit
```

```js
// 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

```bash
npx tiged github:mizchi/vite-plugin-moonbit/examples/js_project myapp
cd myapp && pnpm install
moon build && pnpm dev
```

## Usage

### JS Backend (default)

```typescript
// vite.config.ts
import { defineConfig } from 'vite';
import moonbit from 'vite-plugin-moonbit';

export default defineConfig({
  plugins: [moonbit()]
});
```

```typescript
// main.ts
import { greet } from 'mbt:username/app';
```

Optional: `tsconfig.json`'s paths

```json
{
  "compilerOptions": {
    // ...
    "paths": {
      "mbt:internal/app": [
        "./_build/js/release/build/app.js"
      ]
    }
  }
}
```

See [examples/js_project](./examples/js_project)

Check out: `npx tiged mizchi/vite-plugin-moonbit/examples/wasm_project myapp`

### WASM-GC Backend

```typescript
// vite.config.ts
import { defineConfig } from 'vite';
import moonbit from 'vite-plugin-moonbit';

export default defineConfig({
  plugins: [moonbit({ target: 'wasm-gc' })]
});
```

```typescript
// 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);
```

See [examples/wasm_project](./examples/wasm_project)

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.

```ts
// 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.

```ts
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:

```bash
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_bridge
```

Set `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.

```ts
tsBridge: {
  generatorRoot: "../ts.mbt",
  runtimeValidation: true,
  entries: ["./src/api/client.ts"],
}
```

The generated package contains:

- `moon.pkg`
- `bridge.mbti`
- `bridge.mbt`
- `bridge.js`
- `package.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](./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.

```ts
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:

```bash
cd dist/npm
npm pack --dry-run
npm publish
```

`facade` 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:

```bash
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.ts
```

```ts
moonbit({
  normalizedDts: {
    generatorRoot: "../ts.mbt",
  },
})
```

If you already use `tsBridge`, you can reuse its generator settings:

```ts
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](./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 in `pkg.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](./examples/overrides_project).

### Multiple backends in one project

Instantiate the plugin per backend with a unique `prefix`:

```ts
// 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 }),
    ],
  },
});
```

```ts
// 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](./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
