概要
KuzuをGithubPagesで動かそうとしたところ、追加設定が必要になったのでメモ。SharedArrayBufferのエラーが発生する。
ffmpeg.wasmをgithub pagesで動かすよ を参考に、fetch時にヘッダにcross-origin設定を追加する設定を行った。
fetchにヘッダを付与する設定
coi-serviceworkerのライブラリを使用する
publicフォルダにcoi-serviceworker.jsを配置し、index.htmlで読み込むようにする
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TRPG Scenario Maker</title>
</head>
<body>
+ <script src="/coi-serviceworker.js"></script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
今回のアーキテクチャ
WebWorkerを間に挟んでブロックを防いでみた。
┌──────────────────────────────────────────────────────────────────────────────┐
│ Frontend (Main Thread) │
│ ┌─────────────┐ ┌──────────────────────┐ │
│ │ React UI │◄─────►│ Scene Graph │ │
│ └─────────────┘ └──────────┬───────────┘ │
│ │ │
│ ┌────────────▼───────────┐ ┌────────────────────┐ │
│ │ graphdbWorkerClient │◄─────►│ LocalStorage │ │
│ └────┬───────────────────┘ │ (CSV形式保存/復元) │ │
│ │ └────────────────────┘ │
│ │ │
└───────────────────────────┼──────────────────────────────────────────────────┘
│ postMessage
┌────────▼────────┐
│ Web Worker │
│(graphdb.worker) │
└────────┬────────┘
│
┌────────▼────────┐ ┌────────────────────────────┐
│ Kuzu-Wasm │◄─────►│ VFS (Virtual File System) │
│ (Graph Query) │ │ (CSV形式保存/復元) │
└─────────────────┘ └────────────────────────────┘
ソースコード
前回、GraphDB Kuzu(WebAssembly)でデータをlocalStorageに読み書きしたメモを行ったときよりもviteの設定が楽になっていた。
import { defineConfig } from 'vite';
export default defineConfig({
+ optimizeDeps: {
+ exclude: ['@electric-sql/pglite', '@kuzu/kuzu-wasm'],
+ },
resolve: {
alias: {
'@': path.join(__dirname, './src'),
},
},
+ worker: {
+ format: 'es',
+ },
});
// eslint-disable-next-line camelcase
import kuzu_wasm from '@kuzu/kuzu-wasm';
import type { Database, Connection, KuzuModule } from '@kuzu/kuzu-wasm';
let kuzu: KuzuModule | null = null;
let db: Database | null = null;
let connection: Connection | null = null;
/**
* Kùzuデータベースを初期化し、スキーマを作成
*/
export async function initializeDatabase(): Promise<void> {
// Kùzu WASMモジュールを初期化
kuzu = await kuzu_wasm();
// インメモリデータベースを作成
db = await kuzu.Database();
// 接続を確立
connection = await kuzu.Connection(db);
}
/**
* データベース接続を取得
*/
export function getConnection(): Connection {
if (!connection) {
throw new Error(
'Database connection not initialized. Call initializeDatabase first.',
);
}
return connection;
}
/**
* クエリを実行してJSONデータを返す
*/
export async function executeQuery(query: string): Promise<unknown> {
if (!connection) {
throw new Error(
'Database connection not initialized. Call initializeDatabase first.',
);
}
const result = await connection.execute(query);
// result.tableが存在しない場合は空配列を返す
if (!result.table) {
return [];
}
return JSON.parse(result.table.toString());
}
export function readFSVFile(path: string): string {
if (!kuzu) {
throw new Error('Kùzu FS is not initialized');
}
return kuzu.FS.readFile(path, { encoding: 'utf8' });
}
export function writeFSVFile(path: string, content: string): void {
if (!kuzu) {
throw new Error('Kùzu FS is not initialized');
}
kuzu.FS.writeFile(path, content);
}
/**
* データベースを閉じる
*/
export async function closeDatabase(): Promise<void> {
if (connection) {
connection.close();
connection = null;
}
if (db) {
db.close();
db = null;
}
}
型は相変わらず自前。
declare module '@kuzu/kuzu-wasm' {
export default function kuzu_wasm(): Promise<KuzuModule>;
export interface KuzuModule {
Database: () => Promise<Database>;
Connection: (db: Database) => Promise<Connection>;
FS: {
writeFile: (path: string, content: string) => void;
readFile: <E extends 'binary' | 'utf8'>(
path: string,
options: { encoding: E },
) => E extends 'utf8' ? string : Uint8Array;
};
}
export class Database {
close(): void;
}
export class Connection {
execute(query: string): Promise<QueryResult>;
close(): void;
}
export interface QueryResult {
table: {
toString(): string;
};
}
}
/// <reference types="vite/client" />
// kuzu-wasm の型定義を参照
/// <reference types="../../../packages/graphdb/src/global.d.ts" />
スキーマは定義ファイルを作って、初期化時に作成するようにしてみた。
export const graphDbSchemas = {
nodes: [
{
name: 'Scenario',
query: `
CREATE NODE TABLE Scenario (
id STRING,
title STRING,
PRIMARY KEY (id)
)`,
},
{
name: 'Scene',
query: `
CREATE NODE TABLE Scene (
id STRING,
title STRING,
isMasterScene BOOL,
description STRING,
PRIMARY KEY (id)
)`,
},
],
relationships: [
{
name: 'HAS_SCENE',
query: `
CREATE REL TABLE HAS_SCENE (
FROM Scenario TO Scene
)`,
},
{
name: 'NEXT_SCENE',
query: `
CREATE REL TABLE NEXT_SCENE (
FROM Scene TO Scene
)`,
},
],
};
import { graphDbSchemas } from '@trpg-scenario-maker/graphdb';
import { BaseWorkerClient } from './BaseWorkerClient';
import type {
GraphDBWorkerRequest,
GraphDBWorkerResponse,
} from './graphdb.worker';
import DBWorker from './graphdb.worker?worker';
const { nodes, relationships } = graphDbSchemas;
const schemas = [...nodes, ...relationships];
class GraphDBWorkerClient extends BaseWorkerClient<
GraphDBWorkerRequest,
GraphDBWorkerResponse
> {
// eslint-disable-next-line class-methods-use-this
protected getWorkerUrl(): URL | (new () => Worker) {
if (import.meta.env.DEV) {
return new URL('./graphdb.worker.ts', import.meta.url);
}
return DBWorker;
}
/**
* 初期化時にデータベースをセットアップ
*/
protected async onInitialize(): Promise<void> {
await this.sendRequest({ type: 'init' });
await this.createSchema();
await this.load();
}
async close(): Promise<void> {
await this.sendRequest({ type: 'close' });
}
async execute<T = unknown>(query: string): Promise<T> {
const response = await this.sendRequest<
GraphDBWorkerResponse & { data: T }
>({
type: 'execute',
payload: { query },
});
return response.data as T;
}
async save(): Promise<void> {
await Promise.all(nodes.map((schema) => this.saveNode(schema.name)));
await Promise.all(
relationships.map((schema) => this.saveEdge(schema.name)),
);
}
async load(): Promise<void> {
await Promise.all(schemas.map((schema) => this.loadTable(schema.name)));
}
private async createSchema(): Promise<void> {
await Promise.all(schemas.map((schema) => this.execute(schema.query)));
}
private async saveNode(tableName: string): Promise<void> {
const nodeFilename = `/${tableName}.csv`;
const nodeResponse = await this.sendRequest<GraphDBWorkerResponse>({
type: 'save',
payload: {
path: nodeFilename,
query: `COPY (MATCH (n:${tableName}) RETURN n.*) TO '${nodeFilename}' (header=false);`,
},
});
localStorage.setItem(nodeFilename, nodeResponse.data as string);
}
private async saveEdge(tableName: string): Promise<void> {
const edgeFilename = `/${tableName}.csv`;
const edgeResponse = await this.sendRequest<GraphDBWorkerResponse>({
type: 'save',
payload: {
path: edgeFilename,
query: `COPY (MATCH (a)-[f:${tableName}]->(b) RETURN a.id, b.id) TO '${edgeFilename}' (header=false, delim='|');`,
},
});
localStorage.setItem(edgeFilename, edgeResponse.data as string);
}
private async loadTable(tableName: string): Promise<void> {
const path = `/${tableName}.csv`;
await this.sendRequest<GraphDBWorkerResponse>({
type: 'load',
payload: {
path,
query: `COPY ${tableName} FROM '${path}'`,
content: localStorage.getItem(path) ?? '',
},
});
}
}
export const graphdbWorkerClient = new GraphDBWorkerClient();
import {
initializeDatabase,
executeQuery,
closeDatabase,
readFSVFile,
writeFSVFile,
} from '@trpg-scenario-maker/graphdb';
export interface GraphDBWorkerRequest {
type: string;
payload?: unknown;
}
export interface GraphDBWorkerResponse {
type: string;
data?: unknown;
success?: boolean;
error?: string;
originalType?: string;
}
type HandlerFunction = (
payload: unknown,
) => Promise<Omit<GraphDBWorkerResponse, 'type'>>;
const handlers = new Map<string, HandlerFunction>();
handlers.set('init', async () => {
await initializeDatabase();
return { success: true, data: { message: 'Database initialized' } };
});
handlers.set('execute', async (payload: unknown) => {
const { query } = payload as { query: string };
if (!query) {
throw new Error('Query is required');
}
const data = await executeQuery(query);
return { success: true, data };
});
handlers.set('save', async (payload: unknown) => {
const { query, path } = payload as { query: string; path: string };
if (!query || !path) {
throw new Error('Query and path are required');
}
await executeQuery(query);
return { success: true, data: readFSVFile(path) };
});
handlers.set('load', async (payload: unknown) => {
const { query, path, content } = payload as {
query: string;
path: string;
content: string;
};
if (!content) return { success: true, data: { message: 'Data loaded skip' } };
if (!query || !path) {
throw new Error('Content and path are required');
}
await writeFSVFile(path, content);
await executeQuery(query);
return { success: true, data: { message: 'Data loaded successfully' } };
});
handlers.set('close', async () => {
await closeDatabase();
return { success: true, data: { message: 'Database closed' } };
});
const { self } = globalThis;
self.addEventListener(
'message',
async (event: MessageEvent<GraphDBWorkerRequest & { id: number }>) => {
const { type, id, payload } = event.data;
try {
const handler = handlers.get(type);
if (!handler) {
throw new Error(`No handler registered for message type: ${type}`);
}
const result = await handler(payload);
const response: GraphDBWorkerResponse = { type, ...result };
self.postMessage({ id, ...response });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
self.postMessage({
id,
type: 'error',
error: errorMessage,
originalType: type,
} satisfies GraphDBWorkerResponse & { id: number });
}
},
);
初期化は最初に行っている。少し読み込みに時間がかかる。
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './app/App';
import { dbWorkerClient } from './workers/dbWorkerClient';
import { graphdbWorkerClient } from './workers/graphdbWorkerClient';
import './index.css';
// DBWorkerを初期化(マイグレーション自動実行)
await dbWorkerClient.initialize();
+ // GraphDBWorkerを初期化 ローカルストレージからデータを読み込み
+ await graphdbWorkerClient.initialize();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
CypherクエリはFSDのentitiesに今は書いているけど、ここでいいのかは検討中。
2025.10.26 追記 → やはり、RDBと同じ構成に変更。変更後のソース
import type { Scene, SceneConnection } from '@trpg-scenario-maker/ui';
import { graphdbWorkerClient } from '../../../workers/graphdbWorkerClient';
/**
* シーンAPIクラス
*/
export class SceneApi {
/**
* シナリオに属するシーンを取得
*/
static async getScenesByScenarioId(scenarioId: string): Promise<Scene[]> {
const query = `
MATCH (s:Scenario {id: '${scenarioId}'})-[:HAS_SCENE]->(scene:Scene)
RETURN scene.id AS id, scene.title AS title, scene.description AS description, scene.isMasterScene AS isMasterScene
`;
const result = await graphdbWorkerClient.execute<
Array<{
id: string;
title: string;
description: string;
isMasterScene: boolean;
}>
>(query);
return result;
}
/**
* シーン間の接続を取得
*/
static async getConnectionsByScenarioId(
scenarioId: string,
): Promise<SceneConnection[]> {
const query = `
MATCH (s:Scenario {id: '${scenarioId}'})-[:HAS_SCENE]->(scene1:Scene)-[r:NEXT_SCENE]->(scene2:Scene)
RETURN scene1.id AS source, scene2.id AS target
`;
const result = await graphdbWorkerClient.execute<
Array<{
source: string;
target: string;
}>
>(query);
return result.map((item) => ({
id: `${item.source}-${item.target}`,
source: item.source,
target: item.target,
}));
}
/**
* シーンを作成
*/
static async createScene(
scenarioId: string,
scene: Omit<Scene, 'id'>,
): Promise<Scene> {
const id = crypto.randomUUID();
const escapedTitle = scene.title.replace(/'/g, "\\'");
const escapedDescription = scene.description.replace(/'/g, "\\'");
const query = `
MATCH (s:Scenario {id: '${scenarioId}'})
CREATE (scene:Scene {
id: '${id}',
title: '${escapedTitle}',
description: '${escapedDescription}',
isMasterScene: ${scene.isMasterScene}
})
CREATE (s)-[:HAS_SCENE]->(scene)
RETURN scene.id AS id, scene.title AS title, scene.description AS description, scene.isMasterScene AS isMasterScene
`;
const result = await graphdbWorkerClient.execute<
Array<{
id: string;
title: string;
description: string;
isMasterScene: boolean;
}>
>(query);
console.log('Scene created:', result);
if (!result || !Array.isArray(result) || result.length === 0) {
throw new Error(
'Failed to create scene: No result returned from database',
);
}
return result[0];
}
/**
* シーンを更新
*/
// eslint-disable-next-line complexity
static async updateScene(
id: string,
updates: Partial<Scene>,
): Promise<Scene> {
const setClauses: string[] = [];
if (updates.title !== undefined) {
setClauses.push(`scene.title = '${updates.title.replace(/'/g, "\\'")}'`);
}
if (updates.description !== undefined) {
setClauses.push(
`scene.description = '${updates.description.replace(/'/g, "\\'")}'`,
);
}
if (updates.isMasterScene !== undefined) {
setClauses.push(`scene.isMasterScene = ${updates.isMasterScene}`);
}
if (setClauses.length === 0) {
throw new Error('No fields to update');
}
const query = `
MATCH (scene:Scene {id: '${id}'})
SET ${setClauses.join(', ')}
RETURN scene.id AS id, scene.title AS title, scene.description AS description, scene.isMasterScene AS isMasterScene
`;
const result = await graphdbWorkerClient.execute<
Array<{
id: string;
title: string;
description: string;
isMasterScene: boolean;
}>
>(query);
if (!result || !Array.isArray(result) || result.length === 0) {
throw new Error(
'Failed to update scene: Scene not found or no result returned',
);
}
return result[0];
}
/**
* シーンを削除
*/
static async deleteScene(id: string): Promise<void> {
const query = `
MATCH (scene:Scene {id: '${id}'})
DETACH DELETE scene
`;
await graphdbWorkerClient.execute(query);
}
/**
* シーン間の接続を作成
*/
static async createConnection(
connection: Omit<SceneConnection, 'id'>,
): Promise<SceneConnection> {
const query = `
MATCH (source:Scene {id: '${connection.source}'}), (target:Scene {id: '${connection.target}'})
CREATE (source)-[r:NEXT_SCENE]->(target)
RETURN '${connection.source}-${connection.target}' AS id, '${connection.source}' AS source, '${connection.target}' AS target
`;
const result = await graphdbWorkerClient.execute<
Array<{
id: string;
source: string;
target: string;
}>
>(query);
if (!result || !Array.isArray(result) || result.length === 0) {
throw new Error(
'Failed to create connection: No result returned from database',
);
}
return result[0];
}
/**
* シーン間の接続を削除
*/
static async deleteConnection(id: string): Promise<void> {
const [source, target] = id.split('-');
const query = `
MATCH (source:Scene {id: '${source}'})-[r:NEXT_SCENE]->(target:Scene {id: '${target}'})
DELETE r
`;
await graphdbWorkerClient.execute(query);
}
}
参考
ffmpeg.wasmをgithub pagesで動かすよ
GitHub PagesでもSharedArrayBufferを使いたい
GraphDB KuzuのWebAssemblyをブラウザ上で動かしneo4j-nvlで可視化してみたメモ
GraphDB Kuzu(WebAssembly)でデータをlocalStorageに読み書きしたメモ
GraphDB Kuzu(WebAssembly)をvitestで動かしたメモ
Typescript + WebWorker + Vite でビルドすると、 data:video/mp2t;base64 で期待しないビルド結果となったことに対応したメモ
GitHub Pages で React Router を使った SPA サイトを動かす方法
gh-pages-react-apps