Next.js + Web WorkerでSupabaseのDatabase Changesを受ける
概要
今回はNext.js + Web Workerの構成で、SupabaseのDatabase ChangesをWorker側で受けてページ側に反映するという事を試してみました。
Web Worker
ウェブワーカー (Web Worker) とは、ウェブアプリケーションにおけるスクリプトの処理をメインとは別のスレッドに移し、バックグラウンドでの実行を可能にする仕組みのこと
また、以下のような制限があります。
- ワーカー内から直接 DOM を操作することはできない
-
window
オブジェクトの既定のメソッドやプロパティには使用できないものがある
Supabase Database Changes
SupabaseのDatabase Changesは、PostgreSQLの機能を活用して、データベース内の変更(INSERT、UPDATE、DELETE)をリアルタイムで検知します
プロジェクト作成
早速サンプル用のプロジェクトを作成していきます。今回は以下リポジトリテンプレートを使用してプロジェクトを作成しました。一からNext.jsのプロジェクトを作成しても大丈夫です。
プロジェクト名は nextjs_web_worker_example
として作成しました。
各バージョンは以下になります。
"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秒後にレスポンスがあるのが分かります。
SharedWorker
次に名前の通り、各ウィンドウやタブ等で共有してアクセスできる 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 にアクセスすると、以下の様に複数タブで同じ内容が表示されているのが分かるかと思います。
SharedWorkerに仕込んだログを見たい場合は、chrome://inspect/#workers
にアクセスして該当のWorkerの inspect
を選択すると見れるようになります。
ローカルでのSupabase環境構築
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」を選択します。
上手くいってれば、先ほど作成した「books」テーブルが表示されているかと思います。
「books」テーブルを選択し、Database Changesを監視する為に、右上の「Realtime off」をクリックし「Enable realtime」を選択します。
先ほどの「Realtime off」→「Realtime on」に変わっていればOKです。一旦以上で最低限の設定が完了しました。
1件適当にデータも登録しておきます。左上の「Insert」>「Insert row」からデータを登録します。
シンプルな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;
いざ実行してみると👇の様に、更新があったら更新内容が表示されます。
また、「停止」をクリックすると監視が停止されます。
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;
ここまで実装できたら、複数タブを立ち上げ、どれか一つのタブで「開始」をクリックすると全部のタブで更新を受けれる様になります。
参考URL
Discussion