ReactというかNext.jsでおなじみのアレが、この度SolidStartでも使えるようになった。
async function serverFn(id: number) {
"use server"; // 👈
const res = await DB.findSomething({ where: { id } });
return res.data;
}
return (
<button onClick={() => serverFn(42).then((d) => console.log(d))}>
Server function!
</button>
);
ちなみに、元々のSolidStartはQwikCityと同じserver$
方式を採用してたけど、この度use server
方式になったという経緯。
server$ | Qwik City 📚 Qwik Documentation https://qwik.builder.io/docs/server$/
で、Next.jsの実装を追う気にはならんかったけど、SolidStartくらいなら追ってもいいかなと思って、その仕組みを調べてみた。
いきなり結論
use server
もとい、Server functionsと呼ばれるものは、
- コンパイラありきの機能
- コンパイル時に、
- クライアント側の生成コードで
use server
が書かれた関数を見つけ、fetch()
を使ったRPCに置換する - サーバー側の生成コードにその関数を移設し、RPCを処理して返すエンドポイントを増やす
- クライアント側の生成コードで
というだけ。(少なくとも、SolidStartの実装および一般的なアイデアとしては)
たとえば、こういうコードがあったとして、
// client
const serverFn = async (msg) => {
"use server";
return `${msg} from server!`;
};
これをコンパイルすると、
// client
const serverFn = async (msg) =>
fetch("/_server", {
method: "POST",
body: JSON.stringify(["serverFn", msg]),
})
.then((r) => r.json());
// server
server.post("/_server", async (req) => {
const [name, ...args] = await req.json();
const handler = HANDLERS.get(name); // async (msg) => `${msg} from server!`;
const res = await handler(...args);
return Response.json(res);
});
となるイメージ。
use server
は関数レベルで定義することもできるし、モジュールレベルで定義することもでき、この場合はそこで定義されたすべてがサーバーで実行される。
蓋を開けてみるとそこまで複雑なことはしてない。
それを判別するのにuse server
という文字列である必要もないし、わかりやすければなんだっていい。
ただディレクティブが書かれてるだけでは無害であり、コンパイラが必要という点で、メタフレームワーク側の機能として位置づけられてるところがポイントか。
実装としての勘所
DXとして、書いてるコードとしては、今まで通りのJavaScriptであり、ただの非同期関数というコンテキスト。 ただし実行時には、クライアントからサーバーというコンテキストをまたぐ必要があるし、そのやりとりもHTTPを経由する。
ココをいい感じにするために、単に変換するだけでなく、ちょっとした実装が必要になってる。
端的には、
- その関数が依存してる変数やモジュールの抽出、クロージャやスコーピングの対応
- 意図して叩かれないように独自ヘッダでの検証
- あらゆるJS界の型を、(de)serializeして透過的に扱う
Map
とかFormData
とかArrayBuffer
とかFile
とか
- レスポンスをまとめてではなく、ストリーミングで返す
というあたりに、ひと手間かかってる。
SolidStartの場合は、Vinxiがuse server
の基盤を持ってて、その上でseroval
を使ってI/Oのよしな化を行ってる形。
やろうと思えば
アイデア自体はただのCompiler Magicなので、たとえばSvelteKitなんかでもuse server
を適用できる。
せっかくなので、めちゃめちゃシンプルなケースでも実装して遊んでみようかと思ったけど、先人がもうやってた。
lxsmnsyc/use-server-directive: Universal “use server” functions https://github.com/lxsmnsyc/use-server-directive
さすがだぜ。
昨今の動向を見るに、SvelteKitにすぐ導入されることはないと思うけど、これを使うとこんなコードも書けちゃう。
<script>
const prefix = "Server Count";
async function serverCount(value) {
"use server";
const immediate = `${prefix}: ${value}`;
return {
immediate,
delayed: new Promise((res) => {
setTimeout(res, 1000, immediate);
}),
};
}
let state = 0;
$: data = serverCount(state);
function increment() {
state += 1;
}
</script>
<button on:click={increment}>
{`Client Count: ${state}`}
</button>
<div>
{#await data}
<h1>Loading</h1>
{:then value}
<h1>{value.immediate}</h1>
{#await value.delayed}
<h1>Loading</h1>
{:then delayed}
<h1>Delayed: {delayed}</h1>
{/await}
{/await}
</div>
Promise
をレスポンスとして扱えるのは、SvelteKitの元々の機能。
自問自答コーナー
Q. 素直にAPIルートにしたらええやん?tRPCとかもあるやろ?わざわざ新しい仕組みいる?
その気持ちもわかる。
けど、JSON以外もシームレスにやり取りできるのは楽でいいし、TSの型がそのまま使えるのも楽。
外部にURLとして公開する必要がないならなおさら。
まぁ全てがバグなく動作する前提ではある。ある日突然落とし穴にハマりたくないし。
Q. クライアントのコードに混ざるのはやっぱ気持ち悪い
それはそう思う。
import clientOnlyModule from "...";
// ...
import os from "node:os";
こういう並びになるの気持ち悪い。
別ファイルに切り出して、関数レベルではなくモジュールレベルでuse server
するという手はあるかも・・・?
(そうなると今度はファイル名やディレクトリ名にserver
って入れたくなるな・・・)
Q. どんどんコンパイラありきになってる?
なってそう。
ただJSXが許容された世界では、もはや程度の問題にこだわっても仕方ないのかも。これが令和のJavaScript 2ってことで。
Q. RSCの仕組みは?
またちょっと話が違ってそう。
というか、なんでReact本体のDocsにuse server
の記載があるのかがよくわからない。
(コンパイラありき・メタフレームワークありきならば)
というわけで
個人的には、
- 公開しないAPIルートの代わりとして使う
- ファイルは必ず分け、モジュールレベルでのみ
use server
する- できればTSの
lib
とか厳密に指定したいが
- できればTSの
って割り切るなら、便利に付き合っていけそうかなと。Form actionsに懐疑的なスタンスだった身としては。