1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

`.pb.js` で自由にルーティング!PocketBase × JSフック 拡張術

Last updated at Posted at 2025-05-31

はじめに

PocketBase は、Go製の軽量バックエンド。単体バイナリで API + 管理画面 + DB(SQLite)が起動でき、フロントエンド開発者にとって理想的な開発環境を提供してくれます。

本記事では、PocketBase公式SDKを使わずに、REST APIを直接叩く構成での実装方法を紹介します。TypeScript + axios を用いて、起動チェックからAPI呼び出し、JSフックによる拡張までを一気通貫で解説します。

❓ なぜSDKを使わないのか

PocketBaseには便利なSDKがありますが、TypeScriptプロジェクトで使用した際に型エラーや動作不良が頻発しました。そこで、本記事では APIを直接叩く構成を採用しています。

PocketBaseのセットアップ

📦 PocketBaseをダウンロードする

公式サイトからOSに応じたPocketBaseのバイナリをダウンロードします。
プロジェクト内に pocketbase/ フォルダを作成し、バイナリ(pocketbase.exepocketbase)を格納します。

🌐 管理画面にアクセス

以下のコマンドでサーバーを起動します。

pocketbase/pocketbase serve

⚠️ セキュリティソフトにブロックされた場合は、許可してください。

image.png

立ち上がったらhttp://127.0.0.1:8090/_/にアクセスします。
アカウント作成を行います。

⚠️認証メール来ないので、Emailは適当でいいで実在する必要はありません。

image.png

「Create and login」ボタンを押して、ログインします。
image.png

必要に応じてテーブルを作成します。(公式より)
image.png

1. PocketBase の起動処理(クラス外)

ここで実装する内容はクラスの外です。

🌐 起動状態の確認

PocketBase がすでに起動済みかどうかをチェックする関数です。

class/Pocketbase.ts
export async function checkRunning(): Promise<boolean> {
  return new Promise((resolve) => {
    const options = {
      hostname: "localhost",
      port: 8090,
      path: "/_/",
      method: "GET",
    };

    const req = http.request(options, (res) => {
      resolve(res.statusCode === 200);
    });

    req.on("error", () => {
      resolve(false);
    });

    req.end();
  });
}

🔁 起動済みでなければ初期化

class/Pocketbase.ts
async function checkConnection(): Promise<void> {
  const isRunning = await checkRunning();
  if (isRunning) return;

  await initPocketBase();
}

🔧 PocketBase の起動処理

まずは OS 判定をして、適切なパスから PocketBase を起動します。

class/Pocketbase.ts

export function initPocketBase(): Promise<void> {
  const pocketbasePath =
    environment.CURRENT_OS === "win32"
      ? paths.POCKETBASE_PATH_WIN
      : paths.POCKETBASE_PATH_MAC;

  return new Promise<void>((resolve, reject) => {
    let pocketBaseProcess;

    try {
      // macOS: 実行権限付与とquarantine解除
      if (environment.CURRENT_OS === "darwin") {
        execSync(`chmod +x "${paths.POCKETBASE_PATH_MAC}"`);
        execSync(
          `xattr -d com.apple.quarantine "${paths.POCKETBASE_PATH_MAC}"`
        );
      }

      pocketBaseProcess = spawn(pocketbasePath, ["serve"]);
    } catch (error) {
      Logger.error(`exec error: ${error}`);
      return reject(error);
    }

    if (!pocketBaseProcess) return;

    pocketBaseProcess.stdout.on("data", (chunk) => {
      const message = chunk.toString();

      if (message.includes("Server started at http://127.0.0.1:8090/")) {
        Logger.success("PocketBase is running!");
        resolve();
      }
    });

    pocketBaseProcess.stderr.on("data", (chunk) => {
      Logger.error(chunk.toString());
    });

    pocketBaseProcess.on("close", (code) => {
      if (code !== 0) {
        reject(new Error(`PocketBase process exited with code ${code}`));
      }
    });
  });
}

🎁 デコレータで自動実行を仕込む

関数実行前に PocketBase が起動していることを保証するデコレータを作成。

class/Pocketbase.ts
function pocketbase() {
  return function (
    _target: object,
    _key: string | symbol,
    descriptor: PropertyDescriptor
  ) {
    const original = descriptor.value;

    descriptor.value = async function (...args: any[]) {
      await checkConnection();
      return await original.apply(this, args);
    };

    return descriptor;
  };
}

2. 標準機能で実装する

以下は、「クラスによるサービス層の設計と活用法」を中心に据えた内容になります。

今回は、PocketBase API を効率的に操作するためのクラス構成を紹介します。
認証・一覧取得・重複チェック・ログ削除など、API 呼び出しを抽象化し、再利用性の高い設計にしています。

  • @pocketbase() デコレータ:メソッド呼び出し前に PocketBase の起動確認を実行
  • getAuthToken():管理者認証を内部で取得し、Bearer トークンを返す
  • 各メソッドは PocketBase の API に対応し、axios 経由でアクセス

🧬 認証

class/Pocketbase.ts
class PocketBase {
  private auth = {
    identity: credentials.POCKETBASE_USERNAME,
    password: credentials.POCKETBASE_PASSWORD,
  };

  @pocketbase()
  protected async getAuthToken() {
    const url = `${apis.POCKETBASE_BASE_URL}/api/admins/auth-with-password`;
    const authResponse = await axios.post(url, this.auth);
    return authResponse.data.token;
  }
}

export default new Pocketbase(); // singleton

📌 よく使う機能まとめ

class/Pocketbase.ts
class PocketBase {
  // ...前略
  // コレクションを表示する
  @pocketbase()
  public async listCollections() {
    const url = `${apis.POCKETBASE_BASE_URL}/api/collections`;
    const token = await this.getAuthToken();
    const headers = { headers: { Authorization: `Bearer ${token}` } };

    const response = await axios.get(url, headers).catch((err) => {
      throw new Error(err);
    });

    console.log(response.data);
  }

  // 重複チェック
  @pocketbase()
  private async checkForDuplicate(id: string): Promise<boolean> {
    const url = `${apis.POCKETBASE_BASE_URL}/api/collections/foo/records?filter=id='${id}'`;
    const token = await this.getAuthToken();
    const headers = {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
    };

    const response = await axios.get(url, headers).catch((err) => {
      console.log(err);
      throw new Error(err);
    });

    return response.data.items.length > 0;
  }

  // 登録処理(重複スキップ付き)
  @pocketbase()
  public async registerEntries(entries: FooProgress[]) {
    const url = `${apis.POCKETBASE_BASE_URL}/api/collections/foo/records`;
    const token = await this.getAuthToken();
    const headers = {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
    };

    for (const entry of entries) {
      const isDuplicate = await this.checkForDuplicate(entry.title);
      if (isDuplicate) {
        Logger.error(`Duplicate found for ${entry.title}`);
        continue;
      }

      await axios.post(url, entry, headers).catch((err) => {
        console.log(err);
        throw new Error(err);
      });

      Logger.success(`Registered ${entry.title} to foo waitlist`);
    }
  }

  // ログ一覧
  @pocketbase()
  public async getAllApiLogs(): Promise<FooProgress[]> {
    const url = `${apis.POCKETBASE_BASE_URL}/api/collections/fooLogs/records`;
    const token = await this.getAuthToken();
    const headers = {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
    };

    const response = await axios.get(url, headers);
    return response.data.items;
  }

  // 削除・アーカイブなど
  @pocketbase()
  public async deleteApiLogs(entries: object[]): Promise<void> {
    const token = await this.getAuthToken();
    const headers = {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
    };

    const records = entries as [{ id: string }];
    for (const record of records) {
      const url = `${apis.POCKETBASE_BASE_URL}/api/collections/fooLogs/records/${record.id}`;
      await axios.delete(url, headers);
    }
  }
}

export default new PocketBase(); // singleton

3. JS拡張機能で実装する

PocketBase は標準で REST API を備えていますが、もっと柔軟なロジック(複雑なSQLや独自ルート処理)が欲しい場面もありますよね?

PocketBaseのJS拡張機能はPocketbaseのカスタマイズのルーターを定義することでより柔軟にクエリすることが可能です。PocketBase に *.pb.js ファイルを追加し、Node.js で実行できる拡張APIを追加する方法を紹介します。

🧠 何ができるの?

  • 任意のルートを追加(routerAdd("GET", "/api/xxx", handler)
  • SQLを直接発行($app.dao().db().newQuery(...)
  • POSTパラメータを処理
  • データ構造を柔軟に扱う(newDynamicModel()

セットアップ

📁 ディレクトリ構成

追加前:

.
├── pocketbase          ← バイナリ (macOS)
├── pocketbase.exe      ← バイナリ (Windows)
├── pb_data             ← DBなどが入る
└── pb_migrations       ← マイグレーション用フォルダ

追加後:

.
├── pocketbase          ← バイナリ (macOS)
├── pocketbase.exe      ← バイナリ (Windows)
├── pb_data             ← DBなどが入る
├── pb_migrations       ← マイグレーション用フォルダ
└── pb_hooks            ← 🔥 JSフックファイルを入れるフォルダ(今回追加)
    └── custom.pb.js    ← ここに独自 API を記述

pb_hooks フォルダは PocketBase 起動時に自動読み込みされ、JSファイル内の routerAdd() などのAPI拡張が実行されます。

✅ 拡張 API の使い方

PocketBase 起動時、pb_hooks/*.pb.js に記述されたコードは自動で読み込まれます。以下のような構文で、任意のルートを追加できます。

📘 拡張 API の実装例(抽象化済み)

custom.pb.js
// 🔍 条件付きフィルタ取得
routerAdd("POST", "api/collections/Items/filterByStatus", (c) => {
  const { status, type, limit, afterDays, now } = $apis.requestInfo(c).data;

  const records = arrayOf(newDynamicModel({
    itemId: "",
    label: "",
    status: "",
    createdAt: "",
    updatedAt: "",
  }));

  const query = [
    `SELECT *`,
    `FROM Items`,
    `WHERE status = '${status}'`,
    `AND type = '${type}'`,
    `AND retryCount < 3`,
    afterDays === 0 ? "" : `AND '${now}' >= availableFrom`,
    afterDays === 0 ? "" : `AND '${now}' > lastChecked`,
    afterDays === 0 ? "" : `AND '${now}' >= datetime(registeredAt, '+${afterDays} days')`,
    `ORDER BY createdAt ASC`,
    `LIMIT ${limit}`,
  ].join(" ");

  $app.dao().db().newQuery(query).all(records);

  return c.json(200, {
    message: "Query executed successfully.",
    items: records,
  });
});

// 📋 全件取得
routerAdd("GET", "api/collections/Items/getAll", (c) => {
  const records = arrayOf(newDynamicModel({
    itemId: "",
    label: "",
    status: "",
    createdAt: "",
  }));

  const query = `SELECT * FROM Items`;

  $app.dao().db().newQuery(query).all(records);

  return c.json(200, {
    message: "All items retrieved successfully.",
    items: records,
  });
});

// ❌ データ削除
routerAdd("POST", "api/collections/Items/deleteById", (c) => {
  const itemId = $apis.requestInfo(c).data.itemId;
  const query = `DELETE FROM Items WHERE itemId = '${itemId}'`;

  $app.dao().db().newQuery(query).execute();

  return c.json(200, {
    message: "Item deleted successfully.",
  });
});

// 🔄 ステータス更新
routerAdd("POST", "api/collections/Items/updateStatus", (c) => {
  const itemId = $apis.requestInfo(c).data.itemId;
  const newStatus = $apis.requestInfo(c).data.status;

  const query = [
    `UPDATE Items`,
    `SET status = '${newStatus}'`,
    `WHERE itemId = '${itemId}'`,
  ].join(" ");

  $app.dao().db().newQuery(query).execute();

  return c.json(200, {
    message: "Status updated successfully.",
  });
});

使い方

🏁 Pocketbaseクラスに実装する

  1. pb_hooks/custom.pb.js に上記コードを保存
  2. クラスPocketbase.tsに実装する
  3. PocketBase を再起動(./pocketbase serve
class/Pocketbase.ts
class PocketBase {
  // 前略...
  // 🔍 条件付きフィルタ取得
  @pocketbase()
  public async filterItemsByStatus(params: {
    status: string;
    type: string;
    limit: number;
    afterDays: number;
    now: string;
  }): Promise<FooItem[]> {
    const url = `${apis.POCKETBASE_BASE_URL}/api/collections/Items/filterByStatus`;
    const token = await this.getAuthToken();

    const headers = {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
    };

    const response = await axios.post(url, params, headers);
    return response.data.items;
  }

  // 📋 全件取得
  @pocketbase()
  public async getAllItems(): Promise<FooItem[]> {
    const url = `${apis.POCKETBASE_BASE_URL}/api/collections/Items/getAll`;
    const token = await this.getAuthToken();

    const headers = {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
    };

    const response = await axios.get(url, headers);
    return response.data.items;
  }

  // ❌ レコード削除
  @pocketbase()
  public async deleteItemById(itemId: string): Promise<void> {
    const url = `${apis.POCKETBASE_BASE_URL}/api/collections/Items/deleteById`;
    const token = await this.getAuthToken();

    const headers = {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
    };

    await axios.post(url, { itemId }, headers);
  }

  // 🔄 ステータス更新
  @pocketbase()
  public async updateItemStatus(itemId: string, status: string): Promise<void> {
    const url = `${apis.POCKETBASE_BASE_URL}/api/collections/Items/updateStatus`;
    const token = await this.getAuthToken();

    const headers = {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
    };

    await axios.post(url, { itemId, status }, headers);
  }
}

export default new PocketBase(); // singleton

📝 注意点

  • routerAdd() は GET/POST 両方に対応
  • $app.dao().db().newQuery() による SQL 実行は SQLインジェクションに注意
  • 型はゆるいので、整合性やバリデーションは自前で行う必要あり
  • ルート名・クエリ構造はなるべく抽象化 or 簡素化しておく
  • 本番環境では .pb.js の配置や公開範囲に制限を設けることを推奨

📚 おすすめの活用シーン

  • 複雑なフィルター・JOINが必要な集計処理
  • 特定の業務ロジックをAPIにカプセル化
  • 外部APIとの中継処理を PocketBase 側で行いたいとき
  • 標準 GUI では扱いにくいロジックも、サーバーサイドに移譲可能
  • SQL を直接実行できるため、パフォーマンス最適化も柔軟に対応

おわりに

PocketBaseは、軽量ながらもAPIや管理画面、SQLiteベースのDBを一括で提供してくれる非常に便利なバックエンドツールです。
今回紹介したように、公式SDKを使わずともTypeScriptからAPIを直接呼び出すことで、柔軟かつシンプルな構成で扱うことができます。

また、.pb.js を用いた拡張機能を活用することで、業務に合わせた独自のAPI設計や複雑なクエリ処理も可能になり、PocketBaseの可能性はさらに広がります。

今後もプロジェクトに応じて、PocketBaseを軽量なBFF(Backend For Frontend)として活用していければと思います。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?