📡

Next.js + Web WorkerでSupabaseのDatabase Changesを受ける

2024/12/15に公開

概要

今回はNext.js + Web Workerの構成で、SupabaseのDatabase ChangesをWorker側で受けてページ側に反映するという事を試してみました。

Web Worker

https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API

ウェブワーカー (Web Worker) とは、ウェブアプリケーションにおけるスクリプトの処理をメインとは別のスレッドに移し、バックグラウンドでの実行を可能にする仕組みのこと

また、以下のような制限があります。

Supabase Database Changes

https://supabase.com/docs/guides/realtime/subscribing-to-database-changes

SupabaseのDatabase Changesは、PostgreSQLの機能を活用して、データベース内の変更(INSERT、UPDATE、DELETE)をリアルタイムで検知します

プロジェクト作成

早速サンプル用のプロジェクトを作成していきます。今回は以下リポジトリテンプレートを使用してプロジェクトを作成しました。一からNext.jsのプロジェクトを作成しても大丈夫です。
プロジェクト名は nextjs_web_worker_example として作成しました。

https://github.com/Slowhand0309/nextjs-devcontainer-boilerplate

各バージョンは以下になります。

  "dependencies": {
    "@chakra-ui/icons": "^2.1.1",
    "@chakra-ui/react": "^2.8.2",
    "@emotion/react": "^11.13.5",
    "@emotion/styled": "^11.13.0",
    "framer-motion": "^11.11.17",
    "next": "^14.0.2",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
    "devDependencies": {
    "@types/node": "22.7.4",
    "@types/react": "^18.2.37",
    "@types/react-dom": "^18.2.15",
    "eslint": "8.57.0",
    "eslint-config-next": "15.0.3",
    "prettier": "^3.3.3",
    "typescript": "^5.1.6"
  }

簡単なサンプル実装

まずはシンプルに入力された文字を何秒後かに返すWorkerを作成したいと思います。

libs/echo_worker.ts を以下内容で作成します。

const worker = self as unknown as Worker;

// 2秒後にメッセージを返すWorker
worker.addEventListener('message', (e: MessageEvent<string>) => {
  console.log('worker received:', e.data);
  setTimeout(() => {
    worker.postMessage(e.data);
  }, 2000);
});

次に app/(examples)/basic/page.tsx を以下内容に修正します。

'use client';

import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  Button,
  Heading,
  Input,
  Text,
  VStack,
} from '@chakra-ui/react';
import { useState } from 'react';

const BasicPage = () => {
  const [text, setText] = useState<string>();
  const [value, setValue] = useState<string>();
  return (
    <VStack minH="100vh" p={8} alignItems="flex-start">
      <Breadcrumb fontSize="xl">
        <BreadcrumbItem>
          <BreadcrumbLink href="/" color="blue.400">
            Home
          </BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbItem isCurrentPage>
          <BreadcrumbLink>Basic</BreadcrumbLink>
        </BreadcrumbItem>
      </Breadcrumb>
      <Heading as="h2" size="xl">
        Basic
      </Heading>
      <Text>文字を入力して実行すると2秒後にWorkerが同じ内容を返します</Text>
      <Input onChange={(e) => setText(e.target.value)} />
      <Button
        mt={4}
        onClick={(e) => {
          e.preventDefault();
          const worker = new Worker(
            new URL('../../../libs/echo_worker', import.meta.url),
          );
          worker.addEventListener('message', ({ data }) => {
            setValue(data);
          });
          console.log('main thread:', text);
          worker.postMessage(text);
        }}
      >
        実行
      </Button>
      <Text>{value}</Text>
    </VStack>
  );
};

export default BasicPage;

早速試してみると👇の様に2秒後にレスポンスがあるのが分かります。

image1.gif

SharedWorker

次に名前の通り、各ウィンドウやタブ等で共有してアクセスできる SharedWorker を試してみたいと思います。

https://developer.mozilla.org/ja/docs/Web/API/SharedWorker

libs/echo_shared_worker.ts を以下内容で作成します。

const sharedWorker = self as unknown as SharedWorker;

let globalNumber = 0;

const ports: MessagePort[] = [];

sharedWorker.addEventListener('connect', (e) => {
  const port = (e as MessageEvent).ports[0];
  ports.push(port);
  port.start();
});

// 1秒後にグローバルな値をインクリメントして返す
setInterval(() => {
  globalNumber++;
  ports.forEach((port) => port.postMessage(globalNumber));
}, 1000);

次に app/(examples)/shared-worker-basic/page.tsx を以下内容で作成します。

'use client';

import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  Heading,
  Text,
  VStack,
} from '@chakra-ui/react';
import { useEffect, useState } from 'react';

const SharedWorkerBasicPage = () => {
  const [value, setValue] = useState<number>(0);

  useEffect(() => {
    const worker = new SharedWorker(
      new URL('../../../libs/echo_shared_worker', import.meta.url),
    );
    worker.port.addEventListener('message', ({ data }) => {
      setValue(data);
    });
    worker.port.start();
    return () => {
      worker.port.close();
    };
  }, []);
  return (
    <VStack minH="100vh" p={8} alignItems="flex-start">
      <Breadcrumb fontSize="xl">
        <BreadcrumbItem>
          <BreadcrumbLink href="/" color="blue.400">
            Home
          </BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbItem isCurrentPage>
          <BreadcrumbLink>SharedWorker Basic</BreadcrumbLink>
        </BreadcrumbItem>
      </Breadcrumb>
      <Heading as="h2" size="xl">
        SharedWorker Basic
      </Heading>
      <Text>
        ページを開くと1秒間隔でSharedWorkerがグローバルな値をインクリメントして返します
      </Text>
      <Text>{value}</Text>
    </VStack>
  );
};

export default SharedWorkerBasicPage;

http://localhost:3000/shared-worker-basic にアクセスすると、以下の様に複数タブで同じ内容が表示されているのが分かるかと思います。

image2.gif

SharedWorkerに仕込んだログを見たい場合は、chrome://inspect/#workers にアクセスして該当のWorkerの inspect を選択すると見れるようになります。

image3.png

ローカルでのSupabase環境構築

https://supabase.com/docs/guides/local-development

Supabase CLI を使って環境構築していきたいと思います。

$ npx supabase init
Generate VS Code settings for Deno? [y/N] N
Generate IntelliJ Settings for Deno? [y/N] N

Database Changesの監視対象のテーブルを作成します。

npx supabase migration new create_books_table

👆を実行すると supabase/migrations/ 配下にsqlファイルが作成されると思うので、こちらにbooksテーブル作成のSQLを書いていきます。


create table
books (
  id bigint primary key generated always as identity,
  name text,
  author text,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

ここまで用意ができたら npx supabase start でローカルでのSupabase環境を立ち上げます。

立ち上げると Studio URL が表示されるので、アクセスしてみるとダッシュボードが表示されるかと思います。

ダッシュボードのサイドメニューから「Table Editor」を選択します。

image4.png

上手くいってれば、先ほど作成した「books」テーブルが表示されているかと思います。

image5.png

「books」テーブルを選択し、Database Changesを監視する為に、右上の「Realtime off」をクリックし「Enable realtime」を選択します。

image6.png

image7.png

先ほどの「Realtime off」→「Realtime on」に変わっていればOKです。一旦以上で最低限の設定が完了しました。

1件適当にデータも登録しておきます。左上の「Insert」>「Insert row」からデータを登録します。

image8.png

image9.png

シンプルなWeb WorkerでDatabase Changesを受けるサンプル実装

今度はNext.js側で必要になるパッケージをインストールしていきます。

yarn add @supabase/supabase-js

次に libs/supabase.ts を以下内容で作成します。

import { createClient } from '@supabase/supabase-js';

export const supabase = createClient(
  'http://localhost:54321',
  'eyJh...',
);

※ 引数の第二パラメータには npx supabase start 時に表示された anon key を設定します。

次にWorkerの実装を進めていきます。 libs/db_changes_worker.ts を以下内容で作成します。

import { RealtimeChannel } from '@supabase/supabase-js';
import { supabase } from './supabase';

const worker = self as unknown as Worker;

type ReciveMessageType = {
  type: 'start' | 'stop';
};

let channel: RealtimeChannel | null = null;

worker.addEventListener('message', (e) => {
  const message = e.data as ReciveMessageType;
  switch (message.type) {
    case 'start':
      channel = supabase
        .channel('worker-books-db-changes')
        .on(
          'postgres_changes',
          {
            event: 'UPDATE',
            schema: 'public',
            table: 'books',
          },
          (payload) => self.postMessage(payload.new),
        )
        .subscribe();
      break;
    case 'stop':
      channel?.unsubscribe();
      break;
    default:
      break;
  }
});

Page側の実装は👇の内容で app/(examples)/db-change-worker/page.tsx を作成しときます。

'use client';

import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  Button,
  Heading,
  Text,
  VStack,
} from '@chakra-ui/react';
import { useEffect, useRef, useState } from 'react';

const DbChangeWorkerPage = () => {
  const [value, setValue] = useState<object>();
  const workerRef = useRef<Worker | null>(null);
  useEffect(() => {
    const worker = new Worker(
      new URL('../../../libs/db_changes_worker', import.meta.url),
    );
    worker.onmessage = (event) => {
      const data = event.data;
      setValue(data);
    };
    workerRef.current = worker;
    return () => {
      worker.terminate();
    };
  }, []);
  return (
    <VStack minH="100vh" p={8} alignItems="flex-start">
      <Breadcrumb fontSize="xl">
        <BreadcrumbItem>
          <BreadcrumbLink href="/" color="blue.400">
            Home
          </BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbItem isCurrentPage>
          <BreadcrumbLink>Database Changes Worker</BreadcrumbLink>
        </BreadcrumbItem>
      </Breadcrumb>
      <Heading as="h2" size="xl">
        Database Changes Worker
      </Heading>
      <Text>開始をクリックするとbooksテーブルのUpdateを監視します</Text>
      <Button
        mt={4}
        onClick={(e) => workerRef.current?.postMessage({ type: 'start' })}
      >
        開始
      </Button>
      <Button
        mt={4}
        onClick={(e) => workerRef.current?.postMessage({ type: 'stop' })}
      >
        停止
      </Button>
      <Text>{JSON.stringify(value)}</Text>
    </VStack>
  );
};

export default DbChangeWorkerPage;

いざ実行してみると👇の様に、更新があったら更新内容が表示されます。

image10.gif

また、「停止」をクリックすると監視が停止されます。

image11.gif

SharedWorkerでDatabase Changesを受けるサンプル実装

最後にSharedWorkerでDatabase Changesを受けるサンプルを作成して見たいと思います。

libs/db_changes_shared_worker.ts を以下内容で作成します。

import { RealtimeChannel } from '@supabase/supabase-js';
import type { ReciveMessageType } from './db_changes_worker';
import { supabase } from './supabase';

const dbChabgesSharedWorker = self as unknown as SharedWorker;
const workerPorts: MessagePort[] = [];

let channel: RealtimeChannel | null = null;

dbChabgesSharedWorker.addEventListener('connect', (e) => {
  const port = (e as MessageEvent).ports[0];
  port.onmessage = (event: MessageEvent) => {
    const message = event.data as ReciveMessageType;
    switch (message.type) {
      case 'start':
        channel = supabase
          .channel('worker-books-db-changes')
          .on(
            'postgres_changes',
            {
              event: 'UPDATE',
              schema: 'public',
              table: 'books',
            },
            (payload) =>
              workerPorts.forEach((port) => port.postMessage(payload.new)),
          )
          .subscribe();
        break;
      case 'stop':
        channel?.unsubscribe();
        break;
      default:
        break;
    }
  };

  workerPorts.push(port);
  port.start();
});

次に app/(examples)/db-change-shared-worker/page.tsx を以下内容で作成します。

'use client';

import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  Button,
  Heading,
  Text,
  VStack,
} from '@chakra-ui/react';
import { useEffect, useRef, useState } from 'react';

const DbChangeSharedWorkerPage = () => {
  const [value, setValue] = useState<object>();
  const workerRef = useRef<SharedWorker | null>(null);
  useEffect(() => {
    const worker = new SharedWorker(
      new URL('../../../libs/db_changes_shared_worker', import.meta.url),
    );
    worker.port.onmessage = (event) => {
      const data = event.data;
      setValue(data);
    };
    workerRef.current = worker;
    return () => {
      worker.port.close();
    };
  }, []);
  return (
    <VStack minH="100vh" p={8} alignItems="flex-start">
      <Breadcrumb fontSize="xl">
        <BreadcrumbItem>
          <BreadcrumbLink href="/" color="blue.400">
            Home
          </BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbItem isCurrentPage>
          <BreadcrumbLink>Database Changes Shared Worker</BreadcrumbLink>
        </BreadcrumbItem>
      </Breadcrumb>
      <Heading as="h2" size="xl">
        Database Changes Shared Worker
      </Heading>
      <Text>
        開始をクリックするとSharedWorkerがbooksテーブルのUpdateを監視します
      </Text>
      <Button
        mt={4}
        onClick={(e) => workerRef.current?.port.postMessage({ type: 'start' })}
      >
        開始
      </Button>
      <Button
        mt={4}
        onClick={(e) => workerRef.current?.port.postMessage({ type: 'stop' })}
      >
        停止
      </Button>
      <Text>{JSON.stringify(value)}</Text>
    </VStack>
  );
};

export default DbChangeSharedWorkerPage;

ここまで実装できたら、複数タブを立ち上げ、どれか一つのタブで「開始」をクリックすると全部のタブで更新を受けれる様になります。

image12.gif

参考URL

https://medium.com/@ngrato/harnessing-the-power-of-web-workers-with-next-js-350901a99a10

https://zenn.dev/sora_kumo/articles/65420761a0bec2

Discussion