Yuto Blog
speaker-deck-iconyoutube-iconrss-iconx-icongithub-icon

renderToPipeableStreamとHonoを組み合わせてSSRをする

目次

Intro

renderToPipeableStreamを使ったSSRを行う際のサーバーにHonoを使いました。あんまり調べても記事出てこなくて、epicweb-dev/react-server-componentsをみてました。 果たしてこんな実装を今時する方がどれくらいいるかは知りませんがメモ程度に残しておきます。

https://github.com/yossydev/renderToPipeableStream-with-honoというリポジトリに実装コードはあります。

renderToPipeableStream

ReactのコンポーネントをStreamで返すようにできるAPIです。 Node.jsに依存しているので、Denoなどの他のランタイムで動かしたい場合はrenderToReadableStreamを使用します。

内容

自分が実装したサーバー側のコードは以下のようになっています。これを元にどのように実装したかを見ていきます。 renderToPipeableStreamの第一引数に渡しているのは本当にただのコンポーネントなのでここら辺の説明は省きます。

import { Hono } from "hono";
import React from "react";
import ReactDOMServer from "react-dom/server";
import App from "../client/App";
import Layout from "../client/HTML";
import { serve } from "@hono/node-server";
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
import { serveStatic } from "@hono/node-server/serve-static";

const app = new Hono();

app.use("/*", serveStatic({ root: "./dist", index: "" }));

app.get("/*", (c) => {
	const { pipe } = ReactDOMServer.renderToPipeableStream(
		<Layout>
			<App />
		</Layout>,
	);
	pipe(c.env.outgoing);
	return RESPONSE_ALREADY_SENT;
});

serve({ fetch: app.fetch, port: 3002 }, (info) => {
	console.log(`Listening on ${info.address}:${info.port}`);
});

export default app;

serveStatic

my-hono-project/
  src/
    index.ts
  static/
    index.html

↑ アプリケーションを実行する際に動かすのはindex.tsではなくindex.htmlになるので、それを使用するように設定します。

PipeableStreamの型を見る

renderToPipeableStreamの返り値の型としては以下のようになっています。

type PipeableStream = {
  // Cancel any pending I/O and put anything remaining into
  // client rendered mode.
  abort(reason: mixed): void,
  pipe<T: Writable>(destination: T): T,
};

pipeにはWritableが型定義されています。個人的にはなんでジェネリクスでわざわざ定義しているのかがわかってなくて、型を拡張させるユースケースが果たしてあるのかという気持ちと、この関数はNode.js上でしか動かないと思っているのでそのまま型をつけてあげればいいのでは思ったりしています👀

少し話が逸れたので戻すと、pipeの引数にはnode.jsのstreamを渡してあげれば動く気配がしています。

Honoでnode.jsのapiにアクセスする

pipe関数の引数にはnode.jsのstreamを渡してあげれば動く

これを実現するためにはnode.jsのapiにアクセスする必要があります。Honoではc.env.outgoingで実現することができます。 ref: https://hono.dev/docs/getting-started/nodejs#access-the-raw-node-js-apis

pipe(c.env.outgoing);

つまりこんな感じになります。

RESPONSE_ALREADY_SENT

最後にRESPONSE_ALREADY_SENTをreturnします。

import { X_ALREADY_SENT } from './response/constants'
export const RESPONSE_ALREADY_SENT = new Response(null, {
  headers: { [X_ALREADY_SENT]: 'true' },
})

実装はシンプルで、何かしらreturnしないといけないのでheadersに[X_ALREADY_SENT]: 'true'を追加してレスポンスしているだけのようですね。 ref: https://github.com/honojs/node-server?tab=readme-ov-file#direct-response-from-nodejs-api

ちなみにこれは

export const createAdaptorServer = (options: Options): ServerType => {
  const fetchCallback = options.fetch
  const requestListener = getRequestListener(fetchCallback, {
    hostname: options.hostname,
    overrideGlobalObjects: options.overrideGlobalObjects,
  })
  // ts will complain about createServerHTTP and createServerHTTP2 not being callable, which works just fine
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const createServer: any = options.createServer || createServerHTTP
  const server: ServerType = createServer(options.serverOptions || {}, requestListener)
  return server
}

export const serve = (
  options: Options,
  listeningListener?: (info: AddressInfo) => void
): ServerType => {
  const server = createAdaptorServer(options)
  server.listen(options?.port ?? 3000, options.hostname ?? '0.0.0.0', () => {
    const serverInfo = server.address() as AddressInfo
    listeningListener && listeningListener(serverInfo)
  })
  return server
}

↑ このserve関数を呼び出した際にcreateAdaptorServergetRequestListenerが実行されて、そしてその中に

} else if (resHeaderRecord[X_ALREADY_SENT]) {
  // do nothing, the response has already been sent

という処理があって、「X_ALREADY_SENTが付与されていれば何も実行しない」という実装になっているみたいですね。

まとめ

大体こんな感じです。自分のStreamに関する知識が乏しいのでもしかしたら間違っているかもしれないですが、その時はぜひご指定いただけますと幸いです。

関連

0