フューチャー技術ブログ

Go 1.16のgo:embedとNext.jsの相性が悪い問題と戦う

前エントリーのGo 1.16のembedとchiとSingle Page Applicationでは、Vue.jsで生成したファイルをバンドルしました。Vue.jsや、Parcel V2でビルドしたコンテンツを配信するにはこれで問題ありません。しかし、React(Next.js)は要注意です。

フロントエンドの環境整備をどうやって行うかはいつも悩みの種ですが、そんな中、僕が3年ほど前から他の人にお勧めしていたのがNext.jsでした。ほとんどの最低限必要なツール群は整備済みで、最近のバージョンであればTypeScriptを使うのも簡単。Linter(npm exec eslint –init)とFormatter(npm install prettierと環境整備)ぐらいでコードを書き始められます。

しかし、環境構築が簡単なNext.js製のウェブアプリケーションのビルド済みのフロントエンドのファイル群をGoアプリにバンドルしようとしたらうまく動かず、それの追試をしました。

試した環境

go:embedはフォルダ指定するときは現在地よりも親のフォルダは指定できません。ディレクトリトラバーサルの脆弱性を生み出さないための制約かと思われます。こんな感じのフォルダ構成にしてました。

├── cmd
│ └── single-go-web
│ └── main.go
├── embed.go : go:embedディレクティブコメントを書いたファイル
├── frontend : npm exec create-next-appで生成
│ ├── README.md
│ ├── next-env.d.ts
│ ├── out/ : npm exec next exportが生成する成果物のフォルダ
│ ├── package-lock.json
│ ├── package.json
│ ├── pages/
│ └── tsconfig.json
├── go.mod
├── go.sum
├── handler.go
└── notfound.go

embed.goはこんな感じです。

embed.go
package goweb

import (
"embed"
)

//go:embed frontend/out/*
var assets embed.FS

何が起きたのか

作ったNext.jsのファイルを取り込んでウェブサーバーとして起動するコードを書いたのですが、トップページの静的なタグは表示されるものの、具体的にはファイルがいくつか取得できないようでうまく動きませんでした。こんな感じです。

2021/03/17 08:52:23 not found /_next/static/chunks/pages/_app-e86e439f5882a1d9aed3.js
2021/03/17 08:52:23 not found /_next/static/ZBndKz8ZARrIJBK8V3vpd/_buildManifest.js
2021/03/17 08:52:23 not found /_next/static/ZBndKz8ZARrIJBK8V3vpd/_ssgManifest.js

エラーになったファイル以外のindex.htmlとか他のファイルは読み込めていました。go embedの説明によると、ディレクトリを自動で探索する場合にアンダースコアとピリオドスタートのファイルは無視されるとのこと。ただし、明示的に指定すれば良いみたいです。

If a pattern names a directory, all files in the subtree rooted at that directory are embedded (recursively), except that files with names beginning with ‘.’ or ‘_’ are excluded. So the variable in the above example is almost equivalent to:

実験してみました。

├── _test
│ ├── a.txt
│ ├── _b.txt
│ └── dir
│ ├── c.txt
│ └── _d.txt
├── embed-test
├── go.mod
├── main.go
└── test
├── _f.txt
└── e.txt

それぞれ、go:embedディレクティブに書いたセレクターと選択されるファイルの相関は次の通りです。

ファイル _test, test _test/*, test/* _test/*, _test/*/*, test/*
_test/a.txt ✔︎ ✔︎ ✔︎
_test/_b.txt ✔︎ ✔︎
_test/dir/c.txt ✔︎ ✔︎ ✔︎
_test/dir/_d.txt ✔︎
test/e.txt ✔︎ ✔︎ ✔︎
text/_f.txt ✔︎ ✔︎

深いフォルダでもアスタリスクを駆使すればなんとかなりそうです。 ということで、改めてNext.jsのファイルのバンドルに挑戦。

新たな敵、空フォルダ

というわけでバシバシ追加していったのですが、次のようなエラーがビルド時に出るようになりました。

$ go build
../../embed.go:8:12: pattern frontend/out/*/*: cannot embed directory frontend/out/_next/qi68kQOpQjkJ0HbA6IoFl: contains no embeddable files

Next.jsがビルド時にこのフォルダを作るのですが、ファイルが一個もなく、それを処理できないようです。選択可能なファイルがない場合にエラーになるので、.keepみたいなファイルをおいてもダメです(選択がアスタリスクとアンダーバーは無視されるので)。一番簡単なのはこういうフォルダを削除しておくことです。空フォルダが絶対必要です、というシステムがあるとダメなので、その場合は別の方法が必要ですね。

完成

最終的にアセットをバンドルするディレクティブコメントはこのようになりました。これで無事、実行に必要なファイルを全てバンドルできました。きちんとGo製のサーバーも動きました。

少なくともNext.js 10.0.9ではこれで動きそうです。まあとてもシンプルな画面しか作っていないのでもっといろんな要素を入れていったり、Next.jsのバージョンが上がると動かなくなる可能性もありますが・・・

asset.go
//go:embed frontend/out/*
//go:embed frontend/out/_next/static/*/*
//go:embed frontend/out/_next/static/chunks/pages/*.js
//go:embed frontend/out/_next/static/chunks/pages/next/dist/pages/*
var assets embed.FS

前回とちょっと違うところが1つあります。Next.jsの静的サイト生成の場合、pages/page2.tsxファイルはpage2.htmlファイルとなります。しかし、他のページから遷移すると/page2というパスがアドレスバーに表示されます。ここでリロードしたりすると、最初に/page2という拡張子なしのファイルを読み込もうとしますが、それではうまく動作しません。index.htmlにフォールバックしてもダメでした。

この場合は、/page2にリクエストがきたら、/page2.htmlを返してあげれば良いので、失敗時のフォールバックをもう一つ増やして、拡張子つきで再リトライしてみるようにしてあげる必要があります。

notfound.go

func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
// まずはリクエストされた通りにファイルを探索
err := tryRead(assets, "frontend/out", r.URL.Path, w)
if err == nil {
return
}
// SSGでは.htmlファイルが生成されるが、リクエストされるページは拡張子なし
// かもしれない
err = tryRead(assets, "frontend/out", r.URL.Path+".html", w)
if err == nil {
return
}
// 見つからなければindex.htmlを返す
err = tryRead(assets, "frontend/out", "index.html", w)
if err != nil {
panic(err)
}
}

これでNext.jsで作った静的サイトも、Go 1.16にバンドルできるようになります。