🙌

Rustで450行くらいで作るViteっぽいもの

2023/07/18に公開

はじめに

みなさんはもうこちらの素晴らしい記事は読まれましたでしょうか。
https://trap.jp/post/1549/

この記事を元にして、RustでViteっぽいものを実装しました(元記事はTSです)。

成果物

https://github.com/Catminusminus/mvite
にあります。mvite devでHMR付きの開発サーバーが立ち上がります。mvite buildでバンドルします。
元記事のものと比較すると、Plugin機構がなかったりファイルが見つからない場合panicしたりするなどするのでタイトルは「っぽいもの」としました。至る所でunwrapしているのはそのうちどうにかします。

実装

サーバはActix Web、コンパイラはSWCを使っています。

TypeScriptコードのコンパイル

RustからSWCを使ってTypeScriptで書かれたコードをコンパイルするのには、以下を参照しました。
https://github.com/swc-project/swc/blob/main/crates/swc_ecma_transforms_typescript/examples/ts_to_js.rs

上記のコードはファイルパスをコマンドライン引数で受け取ってファイルを読み込み、JSに変換して標準出力に出すものです。
in/outを変えればそのまま動きます。
注意点として、swc_commonはfeatures = ["tty-emitter"]もありでインストールしてください。

静的ファイルの配信

普通に静的ファイルを配信するだけなら、
https://actix.rs/docs/static-files
にある通り

    HttpServer::new(|| App::new().route("/{filename:.*}", web::get().to(index)))
        .bind(("127.0.0.1", 8080))?
        .run()
        .await

して

async fn index(req: HttpRequest) -> Result<NamedFile> {
    let path: PathBuf = req.match_info().query("filename").parse().unwrap();
    Ok(NamedFile::open(path)?)
}

とすれば良いです。しかし、Viteではファイルを変換する必要があります。また、ヘッダーフィールドも"application/javascript"に変更する必要があります。
そのため、

  • 拡張子を見る(ない場合はJS/TSをつけて探します)
  • TSの場合はJSにSWCで変換
  • JSを以下のように返す
        // pathは(変換した)JSファイルのパス
        let mut res = NamedFile::open(path).unwrap().into_response(&req);
        res.headers_mut().insert(
            CONTENT_TYPE,
            HeaderValue::from_static("application/javascript"),
        );
        return res;
  • JS/TSでない場合、 型を合わせるためにNamedFile::open(path).unwrap().into_response(&req)を返す

という順序で評価します。

HMR

HMRは元記事にある通りファイルの変更検知+websocketサーバーで実現します。
ファイルの変更検知はnotifyを、websocketサーバーはActix Webのものを使いました。

struct MviteWs;

impl Actor for MviteWs {
    type Context = ws::WebsocketContext<Self>;
    fn started(&mut self, ctx: &mut Self::Context) {
	ctx.run_later(Duration::from_secs(5), |_, ctx| {
            let (sender, receiver) = channel();
	    let mut watcher: RecommendedWatcher = Watcher::new(
                sender,
		notify::Config::default().with_poll_interval(Duration::from_secs(2)),
            )
            .unwrap();
            watcher
                .watch(std::path::Path::new("."), RecursiveMode::Recursive)
                .unwrap();
            loop {
                match receiver.recv() {
                    Ok(event) => {
                        ctx.text("{\"type\": \"reload\"}");
                        break;
                    }
                    Err(e) => println!("watch error: {:?}", e),
                }
            }
    }
}

バンドル

バンドルは
https://github.com/swc-project/swc/blob/3930f77b54d8219feae943d4a27280ec106bdec4/crates/swc_bundler/examples/bundle.rs
を参照しました。しかし、これをそのまま使えば良いわけではありません。
まず、読み込み時にTSとして読み込むようにします。

        let module = parse_file_as_module(
            &fm,
            //Syntax::Es(Default::default()),
            Syntax::Typescript(TsConfig {
                ..Default::default()
            }),
            EsVersion::Es2020,
            None,
            &mut vec![],
        )

次に出力時はJSとして出力するようにします。

                let globals = Globals::default();
                GLOBALS.set(&globals, || {
                    let unresolved_mark = Mark::new();
                    let top_level_mark = Mark::new();

                    let module = bundled.module;
                    let module =
                        module.fold_with(&mut resolver(unresolved_mark, top_level_mark, true));
                    let module = module.fold_with(&mut strip(top_level_mark));
                    let module = module.fold_with(&mut hygiene());
                    emitter.emit_module(&module).unwrap();
                });

おわりに

正直まだ成果物のコードのクオリティは低いですが、動くようにするのも大変だったので、一旦出しました。

Discussion