我が名はなんとか菜である!

主に技術系の記事を書きますが、ポエムも混入します。

Next.js をカスタムサーバーで構築して Websocket サーバーと同居させる

久しぶりに純粋な技術ネタです、備忘録代わり。

今日は秋葉のミスドで書いている。(気が付いたら周りのビルがぽつぽつなくなっていてなんだかちょっと寂しい感じになっていた)

概要

Next.js はお手軽 React.js フレームワークとして使うこともでき、フロントエンドで完結するウェブアプリであれば Next.js のみで作っていくことも可能である。

しかし、ちゃんとしたバックエンドサーバーを置きたい場合、とてもではないが Next.js の /api だけでは大変辛いので、別にサーバーサイド用フレームワークを用いて組む必要がある。

その際、従来通りサーバーサイドアプリケーションは別のディレクトリに構築していくのももちろんアリだが、実は Next.js のSSRサーバーは普通に next パッケージとして import することが可能であり、これを使えば手動で初期化処理を行うことが出来るため、この一連の処理の中で独自のサーバーサイドアプリケーションを同居させることが出来る。

今回紹介する例では Next.js を手動で初期化し、 ws パッケージを用いて WebSocket サーバーを立て、クライアントから ws://~ で接続する。

Next.js を手動で初期化して動かすための main.ts の作成

公式サイトにもカスタムサーバーを作る例 が載っているが、個人的には express を使ってルーティングをすべて丸投げしたかったので、以下のようにした。

import express from "express";
import next from "next";

const dev = process.env.NODE_ENV !== "production";
const nextApp = next({ dev });
const port = parseInt(process.env.PORT || "3000", 10);
const nextHandle = nextApp.getRequestHandler();

const expressApp = express();

nextApp.prepare().then(() => {
  expressApp.all("*", (req, res) => {
    return nextHandle(req, res);
  });
  expressApp.listen(port);
});

express を使ってリクエストを受け、 nextApp.getRequestHandler() を使って取得したハンドラにそのままオブジェクトを渡すような感じのコードになっている。

次に、このコードを動かすために package.json の script を少々いじる必要がある。Next.js が用意してくれている起動コマンドではなく、この main.ts を起動させるようにするためだ。

なお、今回は ts-node を使って実行することにする。 (ビルドはちょっと試したところ tsc オンリーだとなぜか上手く .js が吐き出されなかったので esbuild とか導入するといいのかもしれない、未検証)

...
  "scripts": {
    "dev": "ts-node ./src/server.ts",
...
  },
...
$ yarn dev

環境変数を特に指定していなければ localhost:3000 に Next.js サーバーが建つ。

next dev した時と違いログにURLは出ないので、自力でブラウザに入力するか console.log(`dev server in: http://localhost:${port}`) などとどこかに書いておいていつものようにコンソールからコピーするなりターミナルのURLを開いてくれる機能を使うなり出来るようにしておくと便利かもしれない。

カスタムサーバーで起動した場合であっても、以下のようにちゃんと Next.js はログを吐き出してくれる。
f:id:happo31:20220211173246p:plain

WebSocket サーバーを建てる

まあここまでくれば後はチュートリアルを見たりコピペしたりしててきとーにやっていくだけある。

import express from "express";
import next from "next";
import { WebSocketServer, WebSocket } from "ws";

const dev = process.env.NODE_ENV !== "production";
const nextApp = next({ dev });
const port = parseInt(process.env.PORT || "3000", 10);
const nextHandle = nextApp.getRequestHandler();

const expressApp = express();

nextApp.prepare().then(() => {
  let connections: WebSocket[] = [];
  const wss = new WebSocketServer({ port: port + 1 });

  wss.on("connection", (ws) => {
    connections.push(ws);
    ws.on("message", (message) => {
      console.log("message: %s", message);
      ws.send("pong");
    });
    ws.on("close", (code, reason) => {
      console.log("close: %s", reason.toString("utf-8"));
    });
  });

  expressApp.all("*", (req, res) => {
    return nextHandle(req, res);
  });
  expressApp.listen(port);
});

一つ注意点としては、同じポートでサーバーを建ててパスを指定する方法だと上手くいかなかった。( new WebSocketServer({ noServer: true, path: "/ws" }) みたいにして http://localhost:3000/ws ってする的なやつ)

検証しきれなかったので、もし何かわかったら追記するかもしれない。

面倒になってきたので、クライアントサイドは適当に貼るだけにする。

import type { NextPage } from "next";
import { useEffect, useRef, useState } from "react";
import styles from "../styles/Home.module.css";

const Home: NextPage = () => {
  const wsRef = useRef<WebSocket>();
  const [message, setMessage] = useState("");

  useEffect(() => {
    console.log("initial socket");
    wsRef.current = new WebSocket("ws://localhost:3001");
    const { current: ws } = wsRef;

    ws.addEventListener("open", () => {
      console.log("open socket");
      ws.send("test");
    });
    ws.addEventListener("message", (msg) => {
      console.log({ msg });
      setMessage(msg.data);
    });
  }, []);

  return <div className={styles.container}>hello, world {message}</div>;
};

export default Home;

実行結果

f:id:happo31:20220211174503p:plain

ヤッタネ!

今日の記事はぶっちゃけだいぶ実験途中な感じが否めないので、やっぱりなんか後で追記したい気持ちがあります。(いうだけならタダのため)