📝

Nest.jsのSSEでMINEタイプが違うエラー

2024/12/09に公開

初めに

Nest.js の SSE(Server Sent Events)を使っている時にクライアントサイドで以下のエラーが発生。

EventSource's response has a MIME type ("text/plain") that is not "text/event-stream". Aborting the connection.

どうやらクライアントのEventSourceで受け取ったデータの形式が正しくないとのこと。
SSE 周りは情報が少ないのでググっても解決しなく、自力で解決したので久々に記事を書いていきます。

フレームワーク バージョン
Next.js(App Router) 13.4.2
Nest.js 9.0.0

問題の再現

バックエンド(Nest.js)のプロジェクト作成

$ npm i -g @nestjs/cli
$ nest new api

? Which package manager would you ❤️  to use? … yarn

$ cd api
$ yarn

main.tscorsの設定とポートを 8000 番に変更します。

src/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
+  app.enableCors({
+    origin: 'http://localhost:3000',
+  });
-  await app.listen(3000);
+  await app.listen(8000);
}
bootstrap();

ローカルサーバーを立ち上げます

yarn start:dev

フロント(Next.js)のプロジェクト作成

今回は Next.js13.4 から安定版となった App Router を使います。

$ yarn create next-app

? What is your project named? … front
? Would you like to use TypeScript with this project? … Yes
? Would you like to use ESLint with this project? … Yes
? Would you like to use Tailwind CSS with this project? … Yes
? Would you like to use `src/` directory with this project? … Yes
? Use App Router (recommended)? … Yes
? Would you like to customize the default import alias? … No

$ cd front
$ yarn dev

src/app/page.tsxで不要な記述を削除しておきます。

page.tsx
export default function Home() {
  return (
    <></>
  )
}

SSE を使ってみる

App.Controllerに SSE のエンドポイントを追加します。
内容は公式のものをお借りします。

App.Controller
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

+  @Sse('sse')
+  sse(): Observable<MessageEvent> {
+    return interval(1000).pipe(
+      map((_) => ({ data: { hello: 'world' } } as MessageEvent)),
+    );
+  }

  @Get()
  getHello() {
    return this.appService.getHello();
  }
}

次にフロントで受信してみます。

SSE のデータ受信にはEventSourceを使います。EventSourceはクライアント側のコードでしか使えません。
Next.js の Server Component で使うとエラーを吐くので'use client'で page.tsx を Client Component にします。

page.tsx
+'use client'
+import { useEffect } from "react"

export default function Home() {
+  useEffect(() => {
+  const eventSource = new EventSource(`http://localhost:8000/sse`)
+  eventSource.onmessage = ({ data }: MessageEvent) => {
+     console.log(data)
+  };
+    return () => {
+   eventSource.close();
+  };
+  },[])

  return (
    <></>
  )
}

メッセージを受信してログに出すだけの処理を追加しました

クライアントのコンソールを見てみると一定間隔でデータが受信できているようです。
この時点ではきちんと接続ができていることが確認できます。

問題のコード

ここから開発が進んでいき、controllerの中にエンドポイントが増えていくとします。

App.Controller
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

+  @Post()
+  create() {}

+  @Get()
+  findAll() {}

+  @Get(':id')
+  findOne() {}

+  @Patch(':id')
+  update() {}

+  @Delete(':id')
+  remove() {}

  @Sse('sse')
  sse(): Observable<MessageEvent> {
    return interval(1000).pipe(
      map((_) => ({ data: { hello: 'world' } } as MessageEvent)),
    );
  }

  @Get()
  getHello() {
    return this.appService.getHello();
  }
}

この状態でクライアントの localhost:3000 のコンソールを見てみます。

@Sse のメソッドは触っていないはずですが、送信されるデータの形式が変わってしまったようです。

原因調査のため公式をgoogle翻訳で和訳してみると

これを配置すると、EventSourceクライアント側アプリケーションでクラスのインスタンスを作成し、/sseルート (上記のデコレータに渡したエンドポイントと一致する@Sse()) をコンストラクター引数として渡すことができます。
EventSourceインスタンスは HTTP サーバーへの永続的な接続を開き、イベントをtext/event-stream形式で送信します。接続は、EventSource.close() を呼び出して閉じるまで開いたままになります。

とある。ここで言っていることはこんな感じ(微妙に違ってたらごめんなさい)

  1. クライアントでEventSourceインスタンスを生成する
  2. クライアントでインスタンスが作られると@Sse()メソッドからの受信を開始する
  3. @Sse()メソッドはイベントをtext/event-stream形式で送信する

つまりこのエンドポイントから送られるデータ形式はtext/event-stream以外ありえないはず。
であれば何故クライアントはtext/plain形式でデータを受け取ってしまうのだろうか。

原因

原因は@Patch()メソッドが@Sse()メソッドより先に記述されている事が原因でした。

@Patch()メソッドはクライアントのEventSourceから送られてくる接続リクエストを検知してしまう。そして@Patch()text/plain形式を返すため、データ形式が正しくない事象が発生していた。
全てのメソッドを試してみたが、どうやら@Patch()だけが反応している。

解決策

解決方法は@Patch()メソッドは@Sse()メソッドより下に記述する。
が、実際のところ@Patchだけが下部にあるのは気持ち悪いので@Sse()メソッドは上に置くようにする、が解決策。

App.Controller
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

+  @Sse('sse')
+  sse(): Observable<MessageEvent> {
+    return interval(1000).pipe(
+      map((_) => ({ data: { hello: 'world' } } as MessageEvent)),
+    );
+  }

  @Post()
  create() {}

  @Get()
  findAll() {}

  @Get(':id')
  findOne() {}

  @Patch(':id')
  update() {}

  @Delete(':id')
  remove() {}

-  @Sse('sse')
-  sse(): Observable<MessageEvent> {
-    return interval(1000).pipe(
-      map((_) => ({ data: { hello: 'world' } } as MessageEvent)),
-    );
-  }

  @Get()
  getHello() {
    return this.appService.getHello();
  }
}

順番を変えたら、正しくデータが受け取れるようになりました。

まとめ

@Sse()メソッドはtext/event-stream以外の形式は送信しないはずなので Nest.js と SSE を使っててこのエラーを見たら、順番が違うか接続先間違えてるのどちらかと捉えていいと思う。

EventSource's response has a MIME type ("text/plain") that is not "text/event-stream". Aborting the connection.

こんなニッチな記事が誰かの役に立てば幸いです。

Discussion