🧊

`use server`ディレクティブの正体

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とか厳密に指定したいが

って割り切るなら、便利に付き合っていけそうかなと。Form actionsに懐疑的なスタンスだった身としては。