[Rust]  N-APIアドオンを作成してTypeScriptコードを変換する [Bun 1.2]

[Rust] N-APIアドオンを作成してTypeScriptコードを変換する [Bun 1.2]

Clock Icon2025.01.27

Introduction

先日js/tsツールキットであるBunの1.2がリリースされました。
S3 API が組み込まれてデフォルトでS3アクセス可能になったり
Bun.sqlでPostgresqlにアクセスできたり(MySQLは近日対応予定)、
テキストベースのロックファイルが導入されたり、HTTP/2やExpressが高速化されたりと
盛りだくさんの内容です。

その中の1つ、Plugin APIの新しいフック、「onBeforeParse」が使えるようになりました。
これはRustやZig などでN-API Addonとして実装します。

これを使うとBunがtsをパースする前にコードをフックし、
中身を変更したりとかができます。

今回はRustでBunのpluginをつくってみます。

Environment

  • MacBook Pro (14-inch, M3, 2023)
  • OS : MacOS 14.5
  • Bun : 1.2
  • Rust : 1.83.0

Setup

まずは必要なソフトウェアのインストールです。
Bunをインストールしましょう。

% curl -fsSL https://bun.sh/install | bash # for macOS, Linux, and WSL

# すでにインストールしている場合はupgrade
% bun upgrade

Rustでpluginのプロジェクト生成&ビルドするため、napiのcliとかyarnが必要なので
これらもインストールします。

% bun add -g @napi-rs/cli
% bun add -g yarn

適当なディレクトリをつくってnapiコマンドでプロジェクトの雛形を作成します。
Package name、Dir nameは適当に指定して、github actionsは使いません。

% cd my-bun-plugin
% napi new

? Package name: (The name filed in your package.json) napi-example
? Dir name: napi-example
? Choose targets you want to support x86_64-apple-darwin, x86_64-pc-windows-msvc, x86_64-unknown-linux-gnu
? Enable github actions? No
・・・
➤ YN0000: · Done in 2s 813ms

そしてbun-native-pluginをcargo addします。

% cargo add bun-native-plugin

    Updating crates.io index
      Adding bun-native-plugin v0.1.2 to dependencies
             Features:
             + napi
    Updating crates.io index
     Locking 34 packages to latest compatible versions

ここにある、コード中のfooをbarに変えるサンプルをlib.rsに記述。

use bun_native_plugin::{define_bun_plugin, OnBeforeParse, bun, Result, anyhow, BunLoader};
use napi_derive::napi;

/// Define the plugin and its name
define_bun_plugin!("replace-foo-with-bar");

/// Here we'll implement `onBeforeParse` with code that replaces all occurrences of
/// `foo` with `bar`.
///
/// We use the #[bun] macro to generate some of the boilerplate code.
///
/// The argument of the function (`handle: &mut OnBeforeParse`) tells
/// the macro that this function implements the `onBeforeParse` hook.
#[bun]
pub fn replace_foo_with_bar(handle: &mut OnBeforeParse) -> Result<()> {
  // Fetch the input source code.
  let input_source_code = handle.input_source_code()?;

  // Get the Loader for the file
  let loader = handle.output_loader();

  let output_source_code = input_source_code.replace("foo", "bar");

  handle.set_output_source_code(output_source_code, BunLoader::BUN_LOADER_JSX);

  Ok(())
}

次にnapiモジュールをコンパイルします。
napi-rsがビルドスクリプトまで用意してくれてるので、それを使います。

% bun run build

$ napi build --platform --release

・・・・

warning: `napi-example` (lib) generated 3 warnings (run `cargo fix --lib -p napi-example` to apply 1 suggestion)
    Finished `release` profile [optimized] target(s) in 9.56s

ビルドが完了すると、「napi-example.darwin-arm64.node」ができているはずです。
pluginができたので、次は対象となる適当なtsファイルを作成します。
とりあえずtest.tsという名前で以下のファイルを記述。

// test.ts
console.log("foo is here");
const message = "foo foo foo";

bunで実行してみる。

% bun run test.ts 
foo is here

そしてさきほどのpluginをつかってビルドするためのスクリプト、
build.jsを作成します。
ビルド対象のファイル、さきほど作成したプラグインファイルを指定します。

//build.js
const path = require("path");

const result = await Bun.build({
  entrypoints: ["./test.ts"],
  outdir: "./dist",
  plugins: [
    {
      name: "replace-foo-with-bar",
      setup(build) {
        // プラグインファイルへの絶対パス
        const napiModule = require(path.resolve("./napi-example.darwin-arm64.node"));

        build.onBeforeParse(
          {
            filter: /\.tsx?$/,  // .ts または .tsx ファイルに適用
            namespace: "file"
          },
          {
            napiModule,
            symbol: "replace_foo_with_bar"
          }
        );

        build.onStart(() => {
          console.log("Build started!");
        });
      }
    }
  ],
  // ソースマップを生成して確認しやすくする
  sourcemap: "external",
});

console.log("Build completed!");
console.log("Outputs:", result.outputs.map(o => o.path));

bunでビルドスクリプトを実行しましょう。

% bun run build.js
Build started!
Build completed!
Outputs: [ "/・・・/dist/test.js", "/・・・/dist/test.js.map" ]

distにjsファイルが生成されてます。
pluginが適用され、fooがbarになってます。

% bun run dist/test.js

bar is here

ちなみに、tsファイルで↓みたいにするとスクリプト実行時にエラーになりますが、

let x:string = "foo";

pluginのBunLoader部分を↓に変更したら動きました。

BunLoader::BUN_LOADER_JSXBunLoader::BUN_LOADER_TSX

コードのパースをする

一応コードの変更ができたのですが、pluginでは単純な文字列を取得して置換してるだけです。
他になにかできないかとおもってたら、swc系のcrateが良さそうなのでためしてみました。

これらのcrateはSwc (Speedy Web Compiler) プロジェクトの一部で、js/tsのパースや変換などで使うコンポーネントとのことです。

  • swc_common - 基本的なユーティリティと共通機能を提供
  • swc_ecma_parser - JavaScriptとTypeScriptのパーサー
  • swc_ecma_ast - ECMAScript/JavaScriptのAST定義

これらのcrateを追加してpluginを少し書き換えます。

% cargo add swc_common swc_ecma_parser swc_ecma_ast

パース処理を途中でいれてみました。
※出力はさっきと同じです

//lib.rs
use bun_native_plugin::{define_bun_plugin, bun, Result, BunLoader};
use napi_derive::napi;
use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax};
use swc_common::{SourceMap, FileName};
use std::sync::Arc;

define_bun_plugin!("replace-foo-with-bar");

#[bun]
pub fn replace_foo_with_bar(handle: &mut OnBeforeParse) -> Result<()> {
    // 入力ソースコードを取得
    let input_source_code = handle.input_source_code()?;

    // パース処理の準備
    let source_map = SourceMap::default();
    let source_file = source_map.new_source_file(
        FileName::Anon.into(), 
        input_source_code.to_string(), 
    );

    // setting lexer&parser
    let lexer = Lexer::new(
        Syntax::Es(Default::default()),
        Default::default(),
        StringInput::from(&*source_file),
        None,
    );
    let mut parser = Parser::new_from(lexer);

    match parser.parse_module() {
        Ok(module) => {
            println!("=== Parsed Module Structure ===");
            println!("Module: {:#?}", module);

            // モジュール内の各要素を解析
            for item in module.body {
                match item {
                    swc_ecma_ast::ModuleItem::Stmt(stmt) => {
                        println!("Statement: {:#?}", stmt);
                    }
                    swc_ecma_ast::ModuleItem::ModuleDecl(decl) => {
                        println!("Module Declaration: {:#?}", decl);
                    }
                }
            }
        }
        Err(err) => {
            println!("Parse error: {:?}", err);
        }
    }

    let output_source_code = input_source_code.replace("foo", "bar");
    handle.set_output_source_code(output_source_code, BunLoader::BUN_LOADER_JSX);

    Ok(())
}

pluginをビルドして実行してみると、以下のようにAST情報がコンソールに出力されます。

% bun run build.js
Build started!
=== Parsed Module Structure ===
Module: Module {
    span: 13..104,
    body: [
        Stmt(
            Decl(
                Var(
                    VarDecl {
                        span: 13..31,
                        ctxt: #0,
                        kind: "const",
                        declare: false,
                        decls: [
・・・
                callee: Expr(
                    Ident(
                        Ident {
                            span: 90..101,
                            ctxt: #0,
                            sym: "my_function",
                            optional: false,
                        },
                    ),
                ),
                args: [],
                type_args: None,
            },
        ),
    },
)
Build completed!

これを元に内容を変更すれば、関数の前後に自動で処理を入れたり
自動でコード生成したりなど、いろいろな使い方ができそうです。

References

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.