Next.jsのランタイム継承
前回、ミドルウェアのEdgeランタイム問題について触れました。EdgeランタイムはNode.jsとは異なるため、互換性のないデータベースアダプターを使用していると依存関係のエラーが起こるというものです。
ランタイムを意識するようになり、気になったことがあるため、その検証を行います。
当記事は単独で読めますが、前回の記事を読んでいると文脈が理解しやすいかもしれません。
サーバアクションのランタイム
Next.js v14はサーバ上で実行できる非同期関数にサーバアクション(安定版)が導入されています。サーバアクションはデータベースクライアントを使用しても問題ありません。
以下はサーバアクション内でMongoDBの操作を行う簡単なコードです。
"use server";
import mongoClientPromise from "@/lib/database/mongoClientPromise";
import { ObjectId, Collection } from "mongodb";
type userCollection = {
_id: ObjectId;
name: string;
email: string;
image: string;
emailVerified: string | null;
};
export default async function testAction(formData: FormData) {
const client = await mongoClientPromise;
const db = client.db(process.env.ENVIROMENT);
const collection: Collection<userCollection> = db.collection("users");
const user = await collection.findOne();
console.log(user);
}
以下のようなクライアントコンポーネントから呼び出すことができます。
"use client";
import testAction from "@/app/actions/testAction";
export default function TestForm() {
return (
<form action={testAction}>
<button type="submit">Add Message</button>
</form>
);
}
アプリを実行し、ボタンを押すとMongoDB内のドキュメントがコンソールに出力されます。
{
_id: new ObjectId('669acdbafca40bfcb1522794'),
name: 'maeda',
email: 'maeda@email.com',
image: 'https://avatars.githubusercontent.com/u/11928107?v=4',
emailVerified: null
}
本題とは異なりますが、上記のコードおよび以降のコードにおける「実行」は、デプロイ後ではなくローカル開発での"実行"を指します。
Edgeランタイムの変更
Next.jsではpage、layout、ルートハンドラのランタイムはNode.jsですが、以下を記述するとEdgeランタイムに変更することができます。以降、便宜上、このワンライナーをランタイムコードと記述します。
// change runtime
export const runtime = 'edge';
疑問
ここまでの前提を踏まえて…
- サーバアクションはランタイムを変更できないのか?
- pageのランタイムをEdgeにしてサーバアクションを呼び出すとどうなるか?
- pageのランタイムをEdgeにしてルートハンドラを呼び出すとどうなるか?
これらが気になったため、確認してみます。
確認1: サーバアクションのランタイムを変更
まず、サーバアクションはランタイムを変更できないのか?これを確認します。
先ほどのサーバアクションファイル内にランタイムコードを記述してみます。
"use server";
// add
export const runtime = 'edge';
これはサーバアクションの呼び出し時にエラーが出ました。
Error:
× Only async functions are allowed to be exported in a "use server" file.
╭─[/example/src/app/actions/testAction.ts:1:1]
1 │ "use server";
2 │
3 │ export const runtime = "edge";
· ──────────────────────────────
4 │
5 │ import mongoClientPromise from "@/lib/database/mongoClientPromise";
6 │ import { ObjectId, Collection } from "mongodb";
╰────
サーバアクション内からエクスポートできるのは非同期関数のみと記載されているため、サーバアクションはランタイムを変更できないようです。また、割合しますが、呼び出すクライアントコンポーネント(TestForm)にランタイムコードを書いた場合は、記述は無視されます。
確認2: Pageのランタイムを変更
次にpageのランタイムコードをEdgeに変更して、サーバアクションを実行するとどうなるか?を確認します。
確認1のサーバアクションのランタイムコードを削除し、pageは以下のようにします。
import TestForm from "@/components/TestForm";
// add
export const runtime = "edge";
export default async function Page() {
return <TestForm />;
}
これを実行してサーバアクションを呼び出します。
Server Error
Error: Module not found: Can't resolve 'crypto'
https://nextjs.org/docs/messages/module-not-found
サーバアクションを呼び出す以前に実行が落ちます。なぜ動作しないのでしょう?
サーバアクションを以下のように変更してみます。データベース操作関連のコードを削除し、サーバアクションの呼び出し確認だけを行っています。
"use server";
export default async function testAction(formData: FormData) {
console.log("server action!");
}
この実行は落ちず、ボタンを押すとサーバアクションが呼び出せます。
server action!
つまり、現在のランタイムは以下になっていることがわかります。
- ページのランタイム: Edge(明示的に宣言)
- サーバアクションのランタイム: Edge(暗黙的)
なぜサーバアクションはEdgeランタイムになっているのでしょうか?
あとでドキュメントにそれらしいことが記載されていることを知りました。
You can also define
runtime
on a layout level, which will make all routes under the layout run on the edge runtime:レイアウトレベルでランタイムを定義することもできます。これにより、レイアウトの下のすべてのルートがエッジランタイムで実行されます。
pageもlayoutと同様のルールが適用され、今回の作例のようなことが起こるようです。つまり、Next.jsはランタイムをセグメントレベルで指定ができますが、サーバアクションは使用されるページおよびレイアウトからランタイムを継承するということです。
最後にルートハンドラも確認してみます。
確認3: Edgにおけるルートハンドラ
ページのランタイムをEdgeに変更し、ルートハンドラの呼び出しの挙動を確認します。
まずルートハンドラを定義します。先ほどのサーバアクションと似たものにしておきます。
import { NextApiRequest, NextApiResponse } from "next";
import mongoClientPromise from "@/lib/database/mongoClientPromise";
import { ObjectId, Collection } from "mongodb";
import { NextResponse } from "next/server";
type userCollection = {
_id: ObjectId;
name: string;
email: string;
image: string;
emailVerified: string | null;
};
export async function GET(req: NextApiRequest, res: NextApiResponse) {
const client = await mongoClientPromise;
const db = client.db(process.env.ENVIROMENT);
const collection: Collection<userCollection> = db.collection("users");
const user = await collection.findOne();
return NextResponse.json(
{ data: user },
{ status: 200 }
);
}
次にこのルートハンドラをクライアントから呼び出すために、TestFormを修正します。
"use client";
export default function TestForm() {
const clickHandler = async () => {
const res = await fetch("/api/edge");
const json = res.json();
console.log(json);
};
return <button onClick={clickHandler}>Button</button>;
}
ここで実行するとアプリはクラッシュしないのが確認できます。ボタンを押すとDBをクエリした結果も正しく取得できています。
Promise {
<state>: "fulfilled"
<value>: data: Object {
_id: "669acdbafca40bfcb1522794"
email: "maeda@email.com"
emailVerified: null
image: "https://avatars.githubusercontent.com/u/11928107?v=4"
name: "maeda"
}
}
では、ここでpageにランタイムコードを追加します。
import TestForm from "@/components/TestForm";
// add
export const runtime = "edge";
export default async function Page() {
return <TestForm />;
}
実行すると、サーバアクションのときと異なり実行は落ちません。そして、ボタンを押すと普通にルートハンドラ関数の呼び出しが成功しました。
ランタイムは継承されると思っていたので、ちょっと意外です。これは前記事のミドルウェアの挙動とも異なります。ランタイムの継承面においては、ミドルウェアとサーバアクションの挙動は同じでした。
ちなみにルートハンドラにランタイムコードを記述した場合は、勿論エラーになります。これは実行は落ちず、呼び出し時にエラーになります。
まとめ
前回、今回の記事をまとめると以下になりました。
環境 | Page & Layoutのランタイム |
---|---|
サーバアクション | 継承される |
ミドルウェア | 継承される |
ルートハンドラ | 継承されない |
明示的にランタイムを記述した場合は上書き可能です。