🍖

【初学者向け】Go製WASMをNext.js App Routerのサーバーサイドで動かすまで

に公開

はじめに 👋

どうも、Cloud Creator Labの中の人です。Next.jsやGo言語でサーバーサイドの処理を実装しているときに、Go製のWasmをサーバーサイドで動かしたいという要望があったので、その方法をまとめてみました。
WebAssembly (Wasm) は、ブラウザだけでなくサーバーサイドでもそのパフォーマンスとポータビリティで注目を集めています。Go言語は、比較的容易にWasmを生成でき、既存のGoコード資産をNode.js環境などでも活用できる可能性があります。

この記事では、簡単な足し算を行うGoの関数をWebAssemblyにコンパイルし、Next.js 13+ (App Router) アプリケーションのサーバーサイド、具体的にはRoute Handlerから呼び出す方法をステップバイステップで解説します。

対象読者

  • Next.js (App Router) でサーバーサイド処理を実装している開発者
  • Go言語で書いたロジックをNode.js/Next.js環境で利用したいと考えている開発者
  • WebAssemblyの基本的なコンセプトに関心がある方

この記事で学べること

  • Go言語のコードをWebAssembly (.wasm) にコンパイルする方法
  • Next.jsプロジェクトでWasmモジュールを実行するために必要なセットアップ
  • App RouterのRoute HandlerからWasm関数を呼び出し、結果をAPIレスポンスとして返す方法
  • Node.js環境でGo Wasmを実行する際の注意点と基本的なエラーハンドリング

前提条件 🛠️

この記事で解説する手順やコードは、以下の環境で動作確認を行っています。

ツール バージョン 確認コマンド / 備考
Go言語 1.24.3 (1.11以降必須) go version
Node.js v22.15.0 (LTS版推奨) node -v
npm (Node.jsに同梱) npm -v
Next.js v15 以降 (執筆時点の@latest) npx create-next-app@latest でセットアップ後、package.json内のnextのバージョンを確認してください。

フェーズ1: GoによるWebAssemblyモジュールの準備 📦

まずは、Next.jsから呼び出すためのWasmモジュールをGoで作成します。

1. Goの作業ディレクトリ作成 (任意)

Next.jsプロジェクトとは別の場所でGoのコードを管理する場合、作業ディレクトリを作成します。

mkdir go-wasm-module
cd go-wasm-module

2. Goモジュールの初期化

go mod init example.com/go-wasm-module

example.com/go-wasm-module の部分は適宜ご自身のモジュール名に置き換えてください。

3. Wasm用のGoコード作成 (main.go)

main.go というファイル名で以下のコードを作成します。このコードは、JavaScriptから呼び出せる add 関数を定義します。

// main.go
package main

import (
	"fmt"
	"syscall/js" // JavaScriptとの連携に必要
)

// JavaScriptから呼び出される add 関数
// js.Value はJavaScriptの値を表す型
func add(this js.Value, args []js.Value) interface{} {
	// 引数の数をチェック
	if len(args) != 2 {
		// エラーメッセージをJavaScriptの文字列として返す
		return js.ValueOf("Invalid number of arguments")
	}

	// 1つ目の引数を整数に変換
	arg1, ok1 := safeConvertToInt(args[0])
	if !ok1 {
		return js.ValueOf("Argument 1 is not a valid integer")
	}

	// 2つ目の引数を整数に変換
	arg2, ok2 := safeConvertToInt(args[1])
	if !ok2 {
		return js.ValueOf("Argument 2 is not a valid integer")
	}

	// 加算結果をJavaScriptの数値として返す
	return js.ValueOf(arg1 + arg2)
}

// js.Valueを安全にintに変換するヘルパー関数
func safeConvertToInt(val js.Value) (int, bool) {
	// 型が数値でない場合はエラー
	if val.Type() != js.TypeNumber {
		return 0, false
	}
	num := val.Int() // JavaScriptのNumber型からGoのint型へ変換
	// 注意: JavaScriptのNumberはfloat64なので、非常に大きな数値や精度が重要な場合は別途対応が必要
	return num, true
}

// JavaScript側に関数を公開(登録)する関数
func registerCallbacks() {
	// "goAdd" という名前でグローバルスコープにadd関数を登録
	js.Global().Set("goAdd", js.FuncOf(add))

	// (オプション) Go Wasmの準備完了をJavaScriptに通知するコールバックを設定する例
	// js.Global().Set("goWasmReady", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
	// 	fmt.Println("Go Wasm is ready to be called from JS!")
	// 	return nil
	// }))
}

func main() {
	// プログラムが即座に終了しないようにチャネルを作成して待機
	c := make(chan struct{}, 0)

	fmt.Println("Go WebAssembly Initialized (from Go)") // サーバーログに出力される
	registerCallbacks() // JavaScriptに関数を登録

	// (オプション) Goの初期化完了をJavaScript側に通知する例
	// if js.Global().Get("onGoWasmReady").Type() == js.TypeFunction {
	// 	js.Global().Call("onGoWasmReady")
	// }

	<-c // このチャネル受信待機によりmain関数が終了せず、Wasmインスタンスがアクティブな状態を保つ
}

コード解説 (main.go):

  • syscall/js パッケージ: GoとJavaScript間でデータをやり取りするための基本的な機能を提供します。
  • add 関数: JavaScript側から goAdd(a, b) として呼び出せるようにします。引数は js.Value のスライスとして渡され、戻り値も js.Value でラップされた値(またはエラーメッセージ文字列)です。
  • safeConvertToInt: JavaScriptからの入力が本当に数値であるかをチェックし、安全にGoの int 型に変換します。js.Value.Type() で型を確認し、js.Value.Int() で数値を取得します。
  • registerCallbacks: js.Global().Set() を使って、Goの関数 (add) をJavaScriptのグローバルスコープに登録します。js.FuncOf() はGoの関数をJavaScriptから呼び出し可能な形式にラップします。
  • main 関数: registerCallbacks() を呼び出した後、チャネル (c) を使ってプログラムをブロックします。これにより、WasmインスタンスがGoの main 関数終了と同時に破棄されるのを防ぎ、登録したコールバック関数がJavaScriptから呼ばれるのを待ち受けます。

4. GoコードをWebAssemblyにコンパイル

作成したGoコードを .wasm ファイルにコンパイルします。

共通の注意点:

  • GOOS=jsGOARCH=wasm: JavaScript環境で動作するWebAssemblyバイナリを生成するための環境変数を設定します。
  • -o main.go.wasm: 出力ファイル名を main.go.wasm とします。このファイルがWasmモジュール本体です。

コマンド:

Linux/macOS (bashなど)
GOOS=js GOARCH=wasm go build -o main.go.wasm main.go
Windows (PowerShell)
$env:GOOS="js"; $env:GOARCH="wasm"; go build -o main.go.wasm main.go

コンパイルに成功すると、カレントディレクトリに main.go.wasm が生成されます。

5. wasm_exec.js の準備

GoでコンパイルしたWasmを実行するには、GoのSDKに含まれる wasm_exec.js というJavaScriptファイルが必須です。このファイルは、Goのランタイム機能を提供し、WasmモジュールとJavaScript環境(この場合はNode.js)との間のブリッジとして機能します。

wasm_exec.js は、Goのインストールパスの misc/wasm/wasm_exec.js にあります。 (バージョンによっては ${GOROOT}/lib/wasm/wasm_exec.js にある場合もあります。後述の注意点を参照してください。)

Goのインストールパス確認方法:

go env GOROOT

表示されたパス(例: /usr/local/goC:\Go)を元に、wasm_exec.js を見つけます。
後でこのファイルをNext.jsプロジェクトの public ディレクトリにコピーします。

wasm_exec.js をカレントディレクトリにコピーする例:

Linux/macOS (bashなど)
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./wasm_exec.js
# または、lib ディレクトリの場合
# cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" ./wasm_exec.js
Windows (PowerShell)
# PowerShellでは `go env GOROOT` の結果を直接コマンド内で展開するのが少し複雑なので、
# まずGOROOTのパスを取得してからコピーするのが確実です。
$goroot = go env GOROOT
Copy-Item "$goroot/misc/wasm/wasm_exec.js" -Destination "./wasm_exec.js"
# または、lib ディレクトリの場合
# Copy-Item "$goroot/lib/wasm/wasm_exec.js" -Destination "./wasm_exec.js"

# もし1行で実行したい場合 (ただし、GOROOTにスペースが含まれない前提)
# Copy-Item "$(go env GOROOT)/misc/wasm/wasm_exec.js" -Destination "./wasm_exec.js"

${GOROOT}/misc/wasm/wasm_exec.js をコピーしてください。

最終的に public ディレクトリは以下のようになっているはずです:

my-go-wasm-app/
├── public/
│   ├── main.go.wasm   # GoからコンパイルされたWasmモジュール
│   └── wasm_exec.js # Go WasmランタイムサポートJS
│   └── (その他 Next.js のデフォルトファイル like next.svg, vercel.svg)
└── ... (その他のプロジェクトファイル)

フェーズ2: Next.js (App Router) プロジェクトのセットアップと統合 🚀

次に、Next.jsプロジェクトを作成し、先ほど準備したWasmモジュールを組み込みます。

1. Next.jsプロジェクトの作成

npx を使って新しいNext.jsプロジェクトを作成します。プロジェクト名は my-go-wasm-app とします。

npx create-next-app@latest my-go-wasm-app

プロジェクト作成時の質問には適宜答えてください(TypeScriptの使用を推奨します。この例ではTypeScriptを前提とします)。

2. プロジェクトディレクトリへ移動

cd my-go-wasm-app

3. Wasm関連ファイルの配置

a. main.go.wasm の配置

フェーズ1でコンパイルした main.go.wasm ファイルを、Next.jsプロジェクトの public ディレクトリにコピーまたは移動します。

# public ディレクトリが存在しない場合は作成 (通常は最初からあります)
# mkdir -p public

# (go-wasm-module ディレクトリから main.go.wasm をコピーする場合の例)
# cp ../go-wasm-module/main.go.wasm ./public/

Goプロジェクトのルートでビルドした場合、生成された main.go.wasmmy-go-wasm-app/public/ にコピーしてください。

b. wasm_exec.js の配置

フェーズ1で準備した wasm_exec.js ファイルを、Next.jsプロジェクトの public ディレクトリにコピーします。

# GOROOT/misc/wasm からコピーする場合
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./public/

# GOROOT/lib/wasm からコピーする場合 (Goのバージョンによってはこちら)
# cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" ./public/

もしフェーズ1でカレントディレクトリに wasm_exec.js をコピー済みの場合は、それを使用できます。

# cp ./wasm_exec.js ./public/

${GOROOT}/misc/wasm/wasm_exec.js をコピーしてください。

最終的に public ディレクトリは以下のようになっているはずです:

my-go-wasm-app/
├── public/
│   ├── main.go.wasm   # GoからコンパイルされたWasmモジュール
│   └── wasm_exec.js # Go WasmランタイムサポートJS
│   └── (その他 Next.js のデフォルトファイル like next.svg, vercel.svg)
└── ... (その他のプロジェクトファイル)

4. Route Handler の作成 (app/api/calculate/route.ts)

App Routerを使用してAPIエンドポイントを作成します。app/api/calculate/route.ts というファイルを作成し、以下の内容を記述します。

// app/api/calculate/route.ts
import { NextResponse } from 'next/server';
import fs from 'fs/promises'; // Node.jsのファイルシステムモジュール (Promise版)
import path from 'path';       // Node.jsのパス操作モジュール
import { webcrypto } from 'crypto'; // Node.js の crypto API (Web Crypto API互換)
import { performance as nodePerformance } from 'perf_hooks'; // Node.js の performance API

// Wasm実行環境に必要なグローバルプロパティの型定義
interface GoWasmGlobal extends Window { // ブラウザ環境のWindowと互換性を持たせつつ拡張
  Go: {
    new (): {
      importObject: WebAssembly.Imports;
      run(instance: WebAssembly.Instance): Promise<void>;
      exited: boolean;
      exit: (code: number) => void;
    };
  };
  goAdd: (a: number, b: number) => number | string; // Goからエクスポートされる関数
  // Node.js環境では、crypto と performance は globalThis に既に存在しうるが、
  // Wasm実行や特定のライブラリが期待する型と異なる場合があるため、明示的に定義・上書きする。
  crypto: typeof webcrypto;
  performance: typeof nodePerformance;
}

// Node.js環境でGo Wasmを実行するために必要なグローバルオブジェクトをセットアップ
// globalThis が GoWasmGlobal の形状を持つことを TypeScript に伝える
const g = globalThis as unknown as GoWasmGlobal;

// `wasm_exec.js` は `globalThis.crypto` と `globalThis.performance` を期待するため、
// Node.js環境でこれらを提供します。
if (typeof g.crypto === 'undefined' || g.crypto !== webcrypto) {
  g.crypto = webcrypto;
}
// Node.jsのperf_hooks.performanceはWeb APIのPerformanceと完全互換ではない場合があるため、
// Goが期待するであろう最低限の機能を提供しているか確認が必要なケースもある。
// ここでは、wasm_exec.jsが主にperformance.now()を利用することを想定。
if (typeof g.performance === 'undefined' || typeof g.performance.now !== 'function') {
  g.performance = nodePerformance as unknown as GoWasmGlobal['performance'];
}


// WasmモジュールのインスタンスとGoのランタイムインスタンスを保持する変数
// これらは一度初期化されたら、後続のリクエストで再利用される(シングルトンパターン)
let goRuntimeInstance: InstanceType<GoWasmGlobal['Go']>; // Goランタイムのインスタンス (go.runなどを呼び出すため)
let wasmInstance: WebAssembly.Instance | null = null; // WebAssemblyのインスタンス (実際のWasmモジュール)
let wasmInitializationPromise: Promise<void> | null = null; // 初期化処理の重複実行を防ぐためのPromise

/**
 * WebAssemblyモジュールを非同期で初期化します。
 * 既に初期化中または初期化済みの場合は何もしません。
 * この関数は、Wasmモジュール内の 'goAdd' 関数が利用可能になるまで待機します。
 */
async function initializeWasm(): Promise<void> {
  // 既に初期化済み、または初期化処理が進行中の場合は、その完了を待つ
  if (wasmInstance && goRuntimeInstance && !goRuntimeInstance.exited) {
    console.log('Wasmは既に初期化されています。');
    // goAddが利用可能か再確認 (稀なケースだが、何らかの理由で消えた場合など)
    if (typeof g.goAdd === 'function') return;
    console.warn("'goAdd'関数が利用できなくなっています。再初期化を試みます。");
    // リセット処理(より堅牢にするなら、古いインスタンスのクリーンアップも考慮)
    wasmInstance = null;
    goRuntimeInstance = null as any; // 型エラーを避けるためanyを使用
    wasmInitializationPromise = null;
  }

  if (wasmInitializationPromise) {
    console.log('Wasmの初期化処理が既に進行中です。完了を待ちます。');
    return wasmInitializationPromise;
  }

  console.log('Wasmモジュールを初期化しています...');

  wasmInitializationPromise = (async () => {
    try {
      // 1. wasm_exec.js のパス解決と読み込み・実行
      // このスクリプトはGoのWasmをブラウザやNode.jsで実行するためのランタイムを提供し、
      // `globalThis.Go` コンストラクタを定義します。
      const wasmExecPath = path.resolve('./public/wasm_exec.js');
      const wasmExecContent = await fs.readFile(wasmExecPath, 'utf-8');
      // `new Function` を使ってグローバルスコープで wasm_exec.js を実行
      // これにより、`globalThis.Go` が利用可能になります。
      new Function(wasmExecContent)();

      // Goコンストラクタの存在確認
      if (typeof g.Go === 'undefined') {
        throw new Error("GoコンストラクタがglobalThis上で見つかりません。wasm_exec.jsの実行に失敗した可能性があります。");
      }
      goRuntimeInstance = new g.Go(); // Goランタイムの新しいインスタンスを作成

      // 2. Wasmバイナリファイル (.wasm) のパス解決と読み込み
      const wasmFilePath = path.resolve('./public/main.go.wasm');
      const wasmBytes = await fs.readFile(wasmFilePath);

      // 3. Wasmモジュールのインスタント化
      // WasmバイナリとGoランタイムのインポートオブジェクトを関連付けます。
      const result = await WebAssembly.instantiate(wasmBytes, goRuntimeInstance.importObject);
      wasmInstance = result.instance; // インスタント化されたWasmモジュールを保持

      console.log('Wasmモジュールがインスタント化されました。');

      // 4. Goランタイムを開始し、Wasmモジュールを実行 (Goのmain関数が実行される)
      // これは非同期処理です。完了を待たずに次の処理に進むことがあります。
      // Wasm内でのエラー (panicなど) はここでキャッチされます。
      // run()が完了する前にgoAddが使えるようになるので、runのPromiseはここでは待たない。
      // run()がrejectした場合に備えてエラーハンドリングは行う。
      goRuntimeInstance.run(wasmInstance).catch((err: unknown) => {
        const errorMessage = err instanceof Error ? err.message : String(err);
        console.error("Go Wasmの実行時エラー (goRuntimeInstance.run):", errorMessage);
        // goRuntimeInstance.exited が true になるはず
        // エラー発生時はインスタンスを無効化し、再初期化を促す
        wasmInstance = null;
        wasmInitializationPromise = null; // 次回のリクエストで再初期化試行できるようにする
      });

      // 5. 'goAdd'関数がグローバルスコープで利用可能になるまで待機 (ポーリング)
      // Wasmモジュールの初期化が完了し、Go側でエクスポートされた関数が使えるようになるのを待ちます。
      await new Promise<void>((resolve, reject) => {
        const timeout = 10000; // タイムアウト時間を10秒に設定 (環境により調整)
        const checkIntervalMs = 50;
        let elapsedTime = 0;

        const checkInterval = setInterval(() => {
          if (goRuntimeInstance && goRuntimeInstance.exited) {
            clearInterval(checkInterval);
            console.error("'goAdd' 関数の準備待機中にGoランタイムが終了しました。");
            reject(new Error("Goランタイムが予期せず終了しました。Wasmモジュールの初期化に失敗した可能性があります。"));
            return;
          }
          if (typeof g.goAdd === 'function') {
            clearInterval(checkInterval);
            console.log("'goAdd'関数が利用可能です。");
            resolve();
          } else {
            elapsedTime += checkIntervalMs;
            if (elapsedTime >= timeout) {
              clearInterval(checkInterval);
              console.error(`タイムアウト(${timeout/1000}秒): 'goAdd'関数が利用可能になりませんでした。`);
              reject(new Error("タイムアウト: 'goAdd'関数の準備待機中にエラーが発生しました。Wasmモジュールの初期化に失敗した可能性があります。"));
            }
          }
        }, checkIntervalMs);
      });

      console.log('Go WasmがAPIルート用に初期化されました。');

    } catch (error: unknown) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      console.error('Wasm初期化中の致命的なエラー:', errorMessage);
      wasmInstance = null; // 初期化失敗時はインスタンスを無効化
      wasmInitializationPromise = null; // 次回のリクエストで再初期化試行できるようにする
      // エラーを再スローして、呼び出し元で処理できるようにする
      throw new Error(`Wasm初期化失敗: ${errorMessage}`);
    }
  })();
  return wasmInitializationPromise;
}

// APIリクエストを処理するPOSTハンドラ
export async function POST(request: Request): Promise<NextResponse> {
  try {
    // Wasmモジュールが初期化されているか確認し、されていなければ初期化
    // initializeWasm() はPromiseを返すので、awaitで完了を待つ
    await initializeWasm();

    // 初期化後、再度 'goAdd' 関数の存在とランタイムの状態を確認
    if (!wasmInstance || typeof g.goAdd !== 'function' || (goRuntimeInstance && goRuntimeInstance.exited)) {
      console.error("Wasmモジュールの準備ができていないか、'goAdd'関数が見つからないか、またはGoランタイムが終了しています(初期化試行後)。");
      return NextResponse.json(
        { error: "Wasmモジュールが利用できません。サーバーのログを確認してください。" },
        { status: 503 } // Service Unavailable
      );
    }

    // リクエストボディから計算する数値を取得
    const body = await request.json();
    const { a, b } = body;

    // 入力値の型チェック (基本的なバリデーション)
    if (typeof a !== 'number' || typeof b !== 'number') {
      return NextResponse.json(
        { error: '無効な入力です。"a"と"b"は数値である必要があります。' },
        { status: 400 } // Bad Request
      );
    }

    // Wasmモジュール内の 'goAdd' 関数を呼び出し
    const result = g.goAdd(a, b);

    // Go側で不正な入力として文字列が返された場合の対応
    if (typeof result === 'string' && result.startsWith("Invalid")) {
        return NextResponse.json({ error: result }, { status: 400 }); // Bad Request
    }
    if (typeof result !== 'number') {
        // 予期せぬ戻り値の型
        console.error(`'goAdd' から予期せぬ型の結果が返されました: ${typeof result}, 値: ${result}`);
        return NextResponse.json({ error: "Wasm関数から予期せぬ結果が返されました。" }, { status: 500 });
    }


    // 計算結果をJSON形式でレスポンス
    return NextResponse.json({ result });

  } catch (error: unknown) {
    // API処理中に発生した予期せぬエラーのハンドリング
    const errorMessage = error instanceof Error ? error.message : "不明なAPIエラーが発生しました。";
    console.error('/api/calculate でのエラー:', errorMessage);
    // Wasm初期化エラーの場合、より具体的なメッセージを返す
    if (errorMessage.startsWith("Wasm初期化失敗:")) {
        return NextResponse.json(
            { error: 'Wasmモジュールの初期化に失敗しました。詳細はサーバーログをご確認ください。', details: errorMessage },
            { status: 500 } // Internal Server Error
        );
    }
    return NextResponse.json(
      { error: '内部サーバーエラーが発生しました。処理中に予期せぬ問題が発生しました。', details: errorMessage },
      { status: 500 } // Internal Server Error
    );
  }
}

// 開発環境でのホットリロード時にログを出力する例
// 注意: ホットリロードによりWasmモジュールの状態がリセットされることはないため、
// 複数回の初期化試行や状態の不整合に注意が必要です。
// 上記の initializeWasm 実装では、この点をある程度考慮しています。
if (process.env.NODE_ENV === 'development') {
    console.log("APIルートモジュール (app/api/calculate/route.ts) が開発モードでリロードされました。Wasmの状態は維持されます。");
}

コード解説 (app/api/calculate/route.ts):

このTypeScriptコードは、Next.js App RouterのRoute Handlerとして機能し、POSTリクエストを受け取ってGoで書かれたWasm関数を呼び出し、その結果を返します。

  • グローバルスコープのセットアップ (g, g.crypto, g.performance):

    • wasm_exec.js は、実行されるJavaScript環境のグローバルスコープに特定のオブジェクト(crypto, performanceなど)が存在することを期待します。ブラウザ環境ではこれらは標準で提供されていますが、Node.js環境では明示的にセットアップする必要があります。
    • globalThis (Node.jsでは global と同等) を g としてキャストし、必要なプロパティ (Goコンストラクタや goAdd関数など) を持つ GoWasmGlobal 型として扱います。
    • Node.jsの crypto.webcryptoperf_hooks.performance をそれぞれ g.cryptog.performance に設定し、Wasmランタイムが必要とするAPIを提供します。
  • Wasmインスタンスの保持 (goRuntimeInstance, wasmInstance, wasmInitializationPromise):

    • これらの変数はモジュールスコープ(つまり、このファイルがロードされた時点で一度だけ初期化されるスコープ)で定義されます。
    • wasmInstance: WebAssembly.instantiate によって生成されたWasmモジュールの実際のインスタンスです。
    • goRuntimeInstance: new g.Go() によって作成されるGoランタイムのインスタンスで、WasmモジュールとJavaScript間の通信やライフサイクル管理を行います。
    • wasmInitializationPromise: Wasmの初期化処理は非同期で行われ、時間がかかる可能性があります。このPromiseは、複数のリクエストが同時に発生した場合でも初期化処理が重複して実行されるのを防ぎ、全ての処理が最初の初期化完了を待つようにします。
  • initializeWasm() 関数:

    • 重複実行防止: wasmInstance が既に存在し、かつGoランタイムが終了していない (!goRuntimeInstance.exited) 場合は、初期化済みとみなし即座にリターンします。また、wasmInitializationPromise を使用して、初期化処理が進行中の場合はそのPromiseを返すことで、重複実行を避けます。
    • wasm_exec.jsの読み込みと実行: fs.readFilepublic/wasm_exec.js を読み込み、new Function(wasmExecContent)() で実行します。これにより、globalThis.Go が定義されます。
    • Goランタイムのインスタンス化: goRuntimeInstance = new g.Go() でGoランタイムのインスタンスを作成します。
    • Wasmモジュールの読み込みとインスタンス化: fs.readFilepublic/main.go.wasm バイナリを読み込み、WebAssembly.instantiate(wasmBytes, goRuntimeInstance.importObject) を呼び出します。goRuntimeInstance.importObject は、GoのコードがJavaScript側の機能を呼び出すために必要な関数群(例: syscall/js.Value.Call の実装など)を含んでいます。
    • Goランタイムの開始: goRuntimeInstance.run(wasmInstance) を呼び出すと、Wasmモジュール内でGoの main() 関数が実行されます。この呼び出しは非同期であり、Promiseを返しますが、main() 関数内で js.Global().Set() によって関数が登録されるのは run() の実行途中であるため、run() の完了を待たずに次のステップ(goAdd のポーリング)に進みます。run() がエラーで終了した場合(例: Goのコード内でpanicが発生)のエラーハンドリングも行います。
    • goAdd 関数のポーリング: Goの main() 関数が実行され、registerCallbacks() が呼び出されて js.Global().Set("goAdd", ...) が完了するまでにはわずかな時間がかかります。そのため、g.goAddfunction 型として定義されるまで、短い間隔でチェックを繰り返します(ポーリング)。タイムアウト処理も設けて、無限ループを防ぎます。また、ポーリング中にGoランタイムが終了 (goRuntimeInstance.exited === true) した場合もエラーとして扱います。
  • POST(request: Request) 関数:

    • 初期化処理の呼び出し: まず await initializeWasm() を呼び出し、Wasmモジュールが確実に初期化され、利用可能な状態になるのを待ちます。
    • 利用可能性の再確認: 初期化後も、wasmInstanceg.goAdd、および goRuntimeInstance.exited をチェックし、Wasmモジュールが本当に利用可能かを確認します。
    • リクエスト処理: リクエストボディから ab をJSONとして取得し、数値型であるかバリデーションを行います。
    • Wasm関数呼び出し: g.goAdd(a, b) を使って、Goで実装された足し算関数を呼び出します。
    • 結果のハンドリング: goAdd 関数は計算結果(数値)またはエラーメッセージ(文字列)を返すようにGo側で実装されているため、戻り値の型をチェックし、適切に応答を返します。
    • エラーハンドリング: try...catch ブロックで、初期化処理中のエラーやAPI処理中の予期せぬエラーを捕捉し、適切なHTTPステータスコードと共にエラーレスポンスを返します。
  • 開発モード時のログ: process.env.NODE_ENV === 'development' の場合、モジュールがリロードされたことを示すログを出力します。上記の initializeWasm の実装により、Wasmインスタンスは可能な限り再利用され、ホットリロード時にも状態が維持されることを目指しています。

このRoute Handlerは、サーバーレス環境 (Vercelなど) でデプロイされた場合、リクエストごとに起動される可能性があります。その際、initializeWasm 内のシングルトンパターンにより、Wasmの初期化コストが高い処理は最初の呼び出し時のみ(またはインスタンスが破棄された後の最初の呼び出し時)に実行されるようになっています。

フェーズ3: 動作確認と発展 🧪

1. 開発サーバーの起動

Next.jsの開発サーバーを起動します。

npm run dev
# または
yarn dev

サーバーがデフォルトで http://localhost:3000 で起動します。

コンソールには、Go側からの "Go WebAssembly Initialized (from Go)" や、route.ts からの "Wasmモジュールを初期化しています..."、"'goAdd'関数が利用可能です。" といったログが表示されるはずです。

2. API Routeのテスト

curl や Postman、またはブラウザの開発者ツールなどを使って、作成したAPIエンドポイントにPOSTリクエストを送信します。

成功例:

curl -X POST -H "Content-Type: application/json" -d '{"a": 15, "b": 7}' http://localhost:3000/api/calculate

期待されるレスポンス:

{"result":22}

入力値が数値でない場合 (Go側のバリデーション):

curl -X POST -H "Content-Type: application/json" -d '{"a": "hello", "b": 7}' http://localhost:3000/api/calculate

期待されるレスポンス (main.gosafeConvertToInt または add でエラーを返す場合):

{"error":"Argument 1 is not a valid integer"}

または (Next.js側のバリデーションで先に捕捉される場合):

{"error":"無効な入力です。\"a\"と\"b\"は数値である必要があります。"}

入力値のキーが不足している場合 (Next.js側のバリデーション):

curl -X POST -H "Content-Type: application/json" -d '{"a": 10}' http://localhost:3000/api/calculate

期待されるレスポンス:

{"error":"無効な入力です。\"a\"と\"b\"は数値である必要があります。"}

発展的な考慮事項 🤔

この例は基本的なものですが、実際のアプリケーションではさらに多くのことを考慮する必要があります。

  • エラーハンドリングの強化:

    • GoのWasm関数内で発生したエラーを、より構造化された形でJavaScript側に伝え、APIレスポンスとして返す仕組みを強化します。Go側では panic するのではなく、エラー情報を含むオブジェクトや複数の戻り値を返すように設計すると、JavaScript側で扱いやすくなります。
    • route.ts でのタイムアウト処理、Wasmモジュールの初期化失敗時のリトライ戦略なども検討します。
  • 複雑なデータ型の扱い:

    • 文字列、配列、構造体(オブジェクト)などをGoとJavaScript間でやり取りする場合、syscall/js の機能をより深く理解し、適切に型変換を行う必要があります。
    • 大きなデータや複雑な構造を持つデータは、JSON文字列にシリアライズ/デシリアライズして交換する方法が一般的で、メモリ管理の観点からも有利な場合があります。js.ValueOf(string)js.Value.String() を活用します。
  • パフォーマンス最適化:

    • 初期化コスト: initializeWasm は、サーバーレス環境ではコールドスタート時に影響します。アプリケーション起動時に一度だけ実行されるように設計するのが理想的です(例: Route Handlerのトップレベルで呼び出し、Promiseを保持する)。現在の実装では、リクエストごとに初期化チェックが入りますが、実際の初期化処理は一度だけです。
    • メモリ管理: GoのWasmは独自のガベージコレクションを持ちますが、JavaScriptとの間で大きなデータを頻繁にやり取りする場合はメモリコピーのオーバーヘッドに注意が必要です。js.CopyBytesToGojs.CopyBytesToJS といった関数を効率的に使用するか、前述のJSONシリアライズを検討します。
    • Wasmモジュールのサイズ: Goで生成されるWasmバイナリは、他の言語で生成されるものと比較して小さくない場合があります。不要なパッケージのインポートを避けたり、TinyGoのような代替コンパイラを検討することで、ファイルサイズを削減できる場合があります(ただし、TinyGoは syscall/js の互換性に制限がある場合があります)。
  • wasm_exec.js の代替/改良:

    • wasm_exec.js はブラウザ環境を主眼に置いている部分もあるため、Node.js専用のより軽量なローダーや、Wasmモジュール自体に最低限のランタイムを組み込むアプローチ (TinyGoなど) も長期的には検討の余地があります。標準のGoコンパイラを使用する場合、wasm_exec.js が基本的な選択肢となります。
  • セキュリティ:

    • 前述の通り、new Function(wasmExecContent)() の使用は wasm_exec.js の内容が改ざんされていないことを前提としています。
    • Wasmモジュールに渡す入力値の検証は、JavaScript側 (Route Handler) とGo側の両方で行うことが望ましいです(二重検証)。
  • デプロイ:

    • VercelやAWS Lambdaなどのプラットフォームにデプロイする際、public ディレクトリ内の .wasm ファイルと wasm_exec.js が正しくデプロイパッケージに含まれ、サーバー環境で読み取り可能であることを確認してください。
    • サーバーレス環境では、コールドスタート時のWasm初期化時間がAPIの応答時間に影響を与える可能性があります。初期化済みのインスタンスを維持する戦略(プロビジョンドコンカレンシーなど、プラットフォームが提供する場合)や、Wasmモジュールのサイズ削減、初期化処理の高速化が重要になります。
  • GoからJavaScriptへのより高度な連携:

    • Goから非同期にJavaScript関数を呼び出したり、JavaScript側でPromiseを待機したりすることも可能です。
    • Goのgoroutineとチャネルを活用して、バックグラウンドタスクを実行し、結果をJavaScriptに通知するような複雑な処理も実装できます。

まとめ ✨

この記事では、Goで作成したWebAssemblyモジュールをNext.js App RouterのRoute Handlerから呼び出す基本的な手順を解説しました。
Goの計算ロジックをサーバーサイドのNode.js環境で再利用する一つの方法として、Wasmは強力な選択肢となり得ます。

重要なポイントは以下の通りです:

  1. Goのコードを GOOS=js GOARCH=wasm でコンパイルする。
  2. wasm_exec.js をGo SDKから取得し、Wasmファイルと共にNext.jsの public ディレクトリに配置する。
  3. Route Handler内で wasm_exec.js を実行してGoランタイムをセットアップし、Wasmモジュールをインスタンス化する。
  4. Go側で syscall/js を使ってエクスポートした関数をJavaScript側から呼び出す。
  5. Node.js環境特有のグローバルオブジェクト (crypto, performance) の設定に注意する。
  6. Wasmの初期化はコストがかかるため、インスタンスを再利用する仕組みを導入する。

今回の例はシンプルな足し算でしたが、より複雑なGoのライブラリやビジネスロジックをNext.jsバックエンドに組み込む際の第一歩として役立てば幸いです。
画像処理や複雑な計算などが発生する時はぜひ、ご自身のプロジェクトで試してみてください!🚀

Discussion