🐁

Supabase Edge Functions も Go で実装したい

2024/12/15に公開

はじめに

https://qiita.com/advent-calendar/2024/go

Supabase Edge Functions というサービスをご存知でしょうか?
Supabase という Firebase に替わるオープンソースのアプリケーションです。
その中のひとつのプロダクトとして提供されているのが Edge Functions です。

https://supabase.com/docs/guides/functions

Edge Functions are server-side TypeScript functions, distributed globally at the edge—close to your users.

Edge Functions は TypeScript ( Deno ) で実行されますが、Wasm もサポートしています。
ということは、Go 言語で実装することができるということです!(?)
本記事において Supabase Edge Functions を Go で実装する方法についてご紹介します!

Supabase

https://supabase.com/

前述した通りオープンソースの Firebase 代替として利用できるフルスタックなバックエンドサービスです。
Database, Auth, Edge Functions, Storage, Realtime, Vector とさまざまなサービスを利用することが可能です。

OSS なので以下のリポジトリにて公開されています。

https://github.com/supabase/supabase

Deno

https://deno.com/

Deno は V8 JavaScriptエンジンをベースに実装された JavaScript/TypeScript ランタイムです。

本記事では Deno を使いこなすという感じではないので深く言及はしません。
Deno については以下を参考すると理解が深まるかと思います。

https://zenn.dev/uki00a/books/effective-deno

WebAssembly ( Wasm )

https://developer.mozilla.org/ja/docs/WebAssembly

WebAssembly は現代のウェブブラウザーで実行できる新しい種類のコードです。ネイティブに近いパフォーマンスで動作する、コンパクトなバイナリー形式の低レベルなアセンブリー風言語です。さらに、 C/C++、C# や Rust などの言語のコンパイル先となり、それらの言語をウェブ上で実行することができます。 WebAssembly は JavaScript と並行して動作するように設計されているため、両方を連携させることができます。

WebAssembly モジュールはウェブ (あるいは Node.js) アプリにインポートすることができ、 WebAssembly の機能は JavaScript を経由して他の領域から利用できる状態になります。

Go では Go 1.11 にて Wasm がサポートされています。

https://go.dev/doc/go1.11#wasm

Go Wiki: WebAssembly を参照することで Go × Wasm に入門することができます。

https://go.dev/wiki/WebAssembly

ざっくりと Go で Wasm を実装するためには以下の手順が必要となります。

  1. Go を実装する
  2. Wasm としてビルドする
  3. JavaScript サポートファイルをコピーする
  4. JavaScript を実装する

バージョン

今回の記事で扱った各種言語や CLI のバージョンは以下の通りです。

go version
go version go1.23.4 darwin/arm64
deno --version
deno 2.1.4 (stable, release, aarch64-apple-darwin)
v8 13.0.245.12-rusty
typescript 5.6.2
supabase --version
2.0.0

Deno × Go ( Wasm )

Deno × Go ( Wasm ) で Hello World をして Deno で Go を動かす手順をご説明します。
コメントにて解説を記載しています。
まずは Go の実装です。

package main

import (
	"syscall/js"
)

// main 関数を待機させるためのチャンネル
var done = make(chan struct{})

func init() {
	// JavaScript のグローバルオブジェクトに関数を登録
	js.Global().Set("golog", js.FuncOf(golog))
}

// JavaScript から呼び出される関数
func golog(this js.Value, args []js.Value) any {
	defer func() {
		// 終了時にチャンネルを閉じる
		done <- struct{}{}
	}()

	// 引数を出力
	println(args[0].String())

	return nil
}

func main() {
	// メイン関数を待機
	<-done
}

以下のコマンドにて Wasm としてビルドします。

GOOS=js GOARCH=wasm go build -o main.wasm main.go

続いて Deno ( TypeScript ) の実装です。

// wasm_exwc.js の型を利用するための参照
// ref: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/golang-wasm-exec/index.d.ts
/// <reference path="./wasm_exec.d.ts" />

// コピーした wasm_exec.js を読み込む
import "./wasm_exec.js";

// ビルドした wasm ファイルを読み込む
const module = await Deno.readFile("./main.wasm");

// Go のインスタンスを生成
const go = new Go();

// ref: https://developer.mozilla.org/ja/docs/WebAssembly/JavaScript_interface/instantiate_static
const { instance } = await WebAssembly.instantiate(module, go.importObject);

// wasm を実行
go.run(instance);

// wasm で設定した関数を呼び出すために宣言
// deno-lint-ignore no-explicit-any
const golog = (globalThis as any).golog;

// wasm で設定した関数を呼び出す
golog("Hello, World!");

TypeScript に疎いためとりあえず動かせればいいやといった実装になっていることご了承ください。
以下のコマンドにて実行します。
このとき main.wasm を読み込むために権限を設定する必要があります。

deno run --allow-read index.ts

実行すると以下のような出力が得られます。

Hello, World!

こちらの実装は以下のリポジトリに置いておきます。

https://github.com/otakakot/sample-deno-wasm-go

Supabase × Go ( Wasm )

続いて Supabase で Go を実装する方法についてご紹介します。

Supabase は Supabase CLI を利用して開発環境をローカルPCで構築することも可能です。

https://supabase.com/docs/guides/local-development/cli/getting-started

初期化およびローカル環境の立ち上げを下記コマンドにて実施します。

supabase init
supabase start

ローカル環境の準備ができたら Edge Function の開発環境を整えていきます。

https://supabase.com/docs/guides/functions/quickstart

下記コマンドにて Edge Function 用のディレクトリを作成します。

supabase functions new ${FUNCTION_NAME}

supabase/functions/${FUNCTION_NAME} ディレクトリに index.ts ファイルが作成されるので修正していきます。
動作は変えず処理を Go へと移行していきます。

index.ts
// Follow this setup guide to integrate the Deno language server with your editor:
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.

// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts"

console.log("Hello from Functions!")

Deno.serve(async (req) => {
  const { name } = await req.json()
  const data = {
    message: `Hello ${name}!`,
  }

  return new Response(
    JSON.stringify(data),
    { headers: { "Content-Type": "application/json" } },
  )
})

/* To invoke locally:

  1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start)
  2. Make an HTTP request:

  curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/hello-world' \
    --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' \
    --header 'Content-Type: application/json' \
    --data '{"name":"Functions"}'
*/

Go での実装 および TypeScript の実装は以下のようになります。

// Deno で読み込むために Wasm でビルドしたあと base64 でエンコードするコマンド
//
//go:generate sh -c "GOOS=js GOARCH=wasm go build -o main.wasm main.go && cat main.wasm | deno run https://denopkg.com/syumai/binpack/mod.ts > mainwasm.ts"
package main

import (
	"encoding/json"
	"log/slog"
	"net/http"
	"strings"
	"syscall/js"
)

// main 関数を待機させるためのチャンネル
var done = make(chan struct{})

func init() {
	// JavaScript のグローバルオブジェクトに関数を登録
	js.Global().Set("handle", js.FuncOf(handle))
}

// JavaScript から呼び出される関数
func handle(_ js.Value, args []js.Value) any {
	defer func() {
		done <- struct{}{}
	}()

	// 引数を JSON 文字列に変換
	str := js.Global().Get("JSON").Call("stringify", args[0]).String()

	var body struct {
		Name string `json:"name"`
	}

	if err := json.NewDecoder(strings.NewReader(str)).Decode(&body); err != nil {
		slog.Error(err.Error())

		return nil
	}

	return ToJSResponse(
		http.StatusOK,
		http.Header{"Content-Type": []string{"application/json"}},
		[]byte(`{"message":"Hello `+body.Name+`"}`),
	)
}

func main() {
	<-done
}

func ToJSResponse(
	statusCode int,
	headers http.Header,
	data []byte,
) js.Value {
	respInit := js.Global().Get("Object")

	respInit.Set("status", statusCode)

	respInit.Set("statusText", http.StatusText(statusCode))

	respInit.Set("headers", ToJSHeader(headers))

	dataJs := js.Global().Get("Uint8Array").New(len(data))

	js.CopyBytesToJS(dataJs, data)

	return js.Global().Get("Response").New(dataJs, respInit)
}

// ToJSHeader converts http.Header to JavaScript sides Headers.
//   - Headers: https://developer.mozilla.org/docs/Web/API/Headers
func ToJSHeader(header http.Header) js.Value {
	h := js.Global().Get("Headers").New()
	for key, values := range header {
		for _, value := range values {
			h.Call("append", key, value)
		}
	}
	return h
}
/// <reference path="./wasm_exec.d.ts" />

import "./wasm_exec.js";

// Supabase 上の Deno は --allow-read と --allow-net が設定されていないため
// base64 でエンコードした wasm ファイルを ts として用意して import する
import mainwasm from "./mainwasm.ts";

import "jsr:@supabase/functions-js/edge-runtime.d.ts";

import { decodeBase64 } from "https://deno.land/std@0.224.0/encoding/base64.ts";

const go = new Go();

// base64 でデコードする
const module = decodeBase64(mainwasm);

const { instance } = await WebAssembly.instantiate(module, go.importObject);

// wasm を実行
go.run(instance);

// wasm で設定した関数を呼び出すために宣言
// deno-lint-ignore no-explicit-any
const handle = (globalThis as any).handle;

Deno.serve(async (req) => {
  // リクエストの body を取得
  const body = await req.json()
  // wasm で設定した関数を呼び出す
  const res = handle(body);
  return res;
});

下記コマンドにてローカル環境の Edge Function にて起動します。

supabase functions serve

初期実装に記載があった cURL コマンドにて動作確認を行います。

  curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/hello-world' \
    --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' \
    --header 'Content-Type: application/json' \
    --data '{"name":"Functions"}'

以下のようなレスポンスを取得することができます。

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 29
Connection: keep-alive
vary: Accept-Encoding
date: Sat, 14 Dec 2024 09:10:25 GMT
X-Kong-Upstream-Latency: 341
X-Kong-Proxy-Latency: 5
Via: kong/2.8.1

{"message":"Hello Functions"}

準備が完了したらクラウド環境へとデプロイしていきます。
こちらも以下の公式ドキュメントを参考にコマンドを実行していきます。

supabase login
supabase functions deploy ${FUNCTION_NAME} --project-ref ${PROJECT_ID}

デプロイが完了したら再び cURL にて動作確認を行います。

curl -i --request POST 'https://<project_id>.supabase.co/functions/v1/<function_name>' \
  --header 'Authorization: Bearer ANON_KEY' \
  --header 'Content-Type: application/json' \
  --data '{ "name":"Functions" }'

こちらもローカル環境同様に以下のようなレスポンスを取得できます。

HTTP/2 200 
date: Sat, 14 Dec 2024 09:15:35 GMT
content-type: application/json
cf-cache-status: DYNAMIC
strict-transport-security: max-age=31536000; includeSubDomains
vary: Accept-Encoding
sb-project-ref: <project_id>
x-deno-execution-id: ************************************
x-sb-edge-region: <region>
x-served-by: supabase-edge-runtime
server: cloudflare
alt-svc: h3=":443"; ma=86400

{"message":"Hello Functions"}

これにて Supabase Edge Function を Go で動かすことができます。

こちらの実装は以下のリポジトリに置いておきます。

https://github.com/otakakot/sample-supabase-wasm-go

おわりに

Supabase はローカル環境での動作確認もしやすく開発体験がとても良いと感じます。
ほんとは syumai さんの workers のようなエレガントな実装をしたかったのですが、蓋を開けてみると「JavaScriptでよくね?」って実装になってしまいました。。。

https://github.com/syumai/workers

リクエストの Header 情報だったりもっと Go に寄せた実装をしたかったり、まだまだ課題は山積みです。

Discussion