るちtechブログ

とあるサイトを作っています

Cloudflare WorkerとPinoを組み合わせた時に発生するエラーを修正する

備考

  • 文章は人間が一生懸命タイピングしてます。
  • コードは一部Claude Sonnet 4.5が書いています

経緯

Cloudflare Wroker × Pino (× Next.JS)を使っていると毎回500エラーが発生してしまい、デプロイすると正しくページが表示されなくなる問題が発生しました。 Cloudflare上でObservabilityからログをみるとCloudflare Worker "Failed to publish diagnostics channel message" Errorと表示されていました。

結論としては、PinoなどでDiagnostic Channelsの処理に不整合が発生してるためで、console.logで出力する際にJSON.stringify()を使ってあげれば解決しました。

解決策

import type { LogFn } from "pino";
import pino from "pino";

const isDevelopment = process.env.NODE_ENV !== "production";

const pinoConfig = {
    level: isDevelopment ? "debug" : "info",
    browser: {
        asObject: false,
        write: (o: object) => {
            console.log(JSON.stringify(o));
        },
    },
    hooks: {
        logMethod(
           this: unknown,
           args: Parameters<LogFn>,
           _method: LogFn,
           level: number,
       ) {
            const consoleFn =
                level >= 50
                    ? console.error
                    : level >= 40
                        ? console.warn
                        : level >= 20
                            ? console.log
                            : console.debug;

            consoleFn(...args);
        },
    },
};

export const logger = pino({
    ...pinoConfig,
});

短いですが、以上です。

参考

github.com

PlaywrightでTurnstileの出現を待機する

備考

  • 文章は人間が一生懸命タイピングしてます。

経緯

PlaywightでTurnstileを使った認証ページをテストする時に、Turnstileのiframe取得がうまくいかないことがありました。結果として、適当に数秒待つというコードを使ってテストの実行時間が無駄に伸びている時がありました。そこで、TurnstileがSuccesになるまで待機するというコードを書いたので共有します。

コード

/**
 * Cloudflare Turnstileのiframeが出現するまで待機する関数
 */
async function waitForTurnstileFrame(
   page: Page,
   timeout = 20000,
): Promise<Frame> {
    console.log("Waiting for Turnstile iframe to appear (polling)...");
    const startTime = Date.now();
    while (Date.now() - startTime < timeout) {
        const frame = page.frames().find((f) => f.url().includes("challenges.cloudflare.com"));
        if (frame) {
            console.log("Found Turnstile frame via polling.");
            return frame;
        }
        await page.waitForTimeout(500); // 500ms待機
    }
    throw new Error(`Turnstile frame not found within ${timeout}ms`);
}

/**
 * Turnstileのトークンが生成され、inputに格納されるまで待機して値を返す関数
 */
async function waitForTurnstileToken(
   page: Page,
   timeout = 30000,
): Promise<string> {
    console.log("Waiting for Turnstile token to be generated...");
    const tokenInput = page.locator('input[id^="cf-chl-widget-"][id$="_response"]');

    // 要素がDOMに出現するまで待機
    await tokenInput.waitFor({ state: "attached", timeout });

    // 値が空でなくなるまで待機
    await page.waitForFunction(
        (el) => (el as HTMLInputElement).value !== "",
        await tokenInput.elementHandle(),
        { timeout },
    );

    const token = await tokenInput.inputValue();
    return token;
}

/**
 * Turnstileが出てきてからトークン取得までをまとめて行う関数
 */
export async function getTurnstileToken(page: Page, timeout = 30000): Promise<string> {
    // 1. iframeが出現するまで待機
    await waitForTurnstileFrame(page, timeout);
    // 2. トークンが生成されるまで待機して取得
    return await waitForTurnstileToken(page, timeout);
}

短いですが以上です。

Macのおすすめ設定やアプリ

備考

  • 文章は人間が一生懸命タイピングまたはWillowを使った音声入力(後述)をして友人に送ったDiscordの会話文を一番ChatGPTに修正させた表現を含みます。
  • 一部リンクにリファラルリンクを含みます。

あいさつ

あけましておめでとうございました。 さて、皆さんはAppleの初売りでMacを買い、3万円分のギフトカードをゲットしました。

はじめに

私は新しくパソコンを買った時に絶対に移行をしません。理由は単純で、整理しきれてない古いフォルダ、ファイルを引き引き継がないで心機一転†俺の考えた最強のフォルダ構成†を作り直す方が手っ取り早いからです。

アプリ編

Raycast

Raycastは便利なアプリランチャーをはじめとした拡張機能がモリモリの多機能ツールです。 ⌘ Spaceに割り当てると便利なのですが、Spotlightと競合してるのでそこから直していきます。

システム設定 → キーボード → キーボードショートカット → Spotlightにて「Spotlight検索を表示」「Finder検索ウインドウを表示」のチェックを外します。 次に、Raycastの起動するためのホットキーを⌘ Spaceに設定します。

RaycastのSettings→Extensions→Window Managementにて、一旦全てのチェックボックスを外します。以下の機能だけで十分です。Windowsライクのウィンドウ操作ができて便利です。 | 機能 | ショートカット | | --------------- | -------- | | Left Half | ⌘ ← | | Right Half | ⌘ → | | Maximize | ⌘ ↑ | | Reasonable Size | ⌘ ↓ |

Lunar

Lunarはディスプレイの明るさを変更するツールです。何が便利かというと、Mac本体から外部ディスプレイの明るさも変更することができます。さらに、Macの内蔵ディスプレイでXDRを有効にすると、デフォルトの一番暗い状態からもう一段階暗くすることができます。暗い部屋でYouTube見る時にディスプレイが明るすぎるんだよなぁという時にすごく便利です。

Willow Voice

Willow Voice は音声入力のアプリです。右シフトキーに割り当てると、バイブコーディングの時などに簡単に2ヶ国語以上で音声入力ができるようになります。エディタ内蔵の音声入力では英語音声しか入力できないことが多々あります。また、一つの言語しか認識できないため英語で音声入力をしたい時に一旦設定言語を変えないといけないなどの手間があります。しかしWillowを使うとこの問題を簡単に解決できます。

  • Mac 起動音をオフ
  • 日本語入力のライブ変換をオフ
  • ディスプレイ設定を More Space に変更
  • ホットコーナー左上を Mission Control に設定
  • バッテリー表示を パーセンテージ表示 に変更
  • 時計表示を 秒まで表示
  • トラックパッドの押し込み強さを Light に変更
  • 右ファンクションキーに 絵文字ピッカー を割り当て(Tahoeだとデフォルトの挙動かも)

  • ブラウザ拡張 G App Launcher を入れる

好み

Supabaseで制限付き公開リンクを実装する方法

備考

  • 文章は人間が一生懸命タイピングしてます。

経緯

同じグループ内ではお互いのファイルが観れるけど、他のグループからはファイルが見れないということを実装しようとした時にpublicUrlを使うと実装が難しそうでした。 どうにか実装できないかな、というのがこの記事です。

やりたいこと

  1. 条件付きのユーザーのみ(Policyにて設定)がファイルを閲覧できる
  2. URLは固定する

方針

一旦APIを噛ませて、そいつに期限付きのsignedUrlを返させることで固定URLかつ、Policyによるアクセス制限を実装します。

APIのコード

import { NextResponse } from "next/server";
import { logger } from "@/libs/logger";
import { supabaseServerClient } from "@/supabase/supabaseServer";

export async function GET(
   _request: Request,
   { params }: { params: Promise<{ userId: string; filePath: string }> },
) {
    try {
        // Supabaseクライアントを初期化し、認証を確認
        const supabase = await supabaseServerClient();
        const { data: user, error: authError } = await supabase.auth.getUser();

        // 認証されていない場合はエラーを返す
        if (authError || !user) {
            logger.error(
                authError || new Error("Authentication failed"),
                "認証エラー",
            );
            return new Response(null, { status: 401 });
        }

        const { userId, filePath } = await params;

        // signed URLを取得(バケット名はプロジェクトに合わせて調整してください)
        const { data, error } = await supabase.storage
            .from("user_icons") // バケット名を適切に設定
            .createSignedUrl(`${userId}/${filePath}`, 3600); // 1時間有効

        if (error) {
            logger.error(error, "signed URLの取得中にエラーが発生しました");
            return new Response(null, { status: 500 });
        }

        // 成功レスポンスを返す
        return NextResponse.redirect(data.signedUrl);
    } catch (error) {
        logger.error(error, "signed URL取得中にエラーが発生しました");
        return new Response(null, { status: 500 });
    }
}

ポイントはNextResponse.redirectでリダイレクトさせることです。

多分、ブラウザ側のキャッシュが切れるたびに毎回1時間の期限付きURLを生成したり、ブラウザのキャッシュが残ってる状態でURLの方の期限が切れてしまったら表示されないなどの問題があると思いますがとりあえず動いたのでよしとします。

SupabaseのメールをReact風に書く方法

備考

  • 文章は人間が一生懸命タイピングしてます。
  • この手法は既存のプロジェクトに途中から導入できます。

経緯

Supabaseのパスワードを変更した時などに使うメールを作成するときに素のHTMLやCSSを頑張って書く必要があります。 しかし、メインのページはNextJSだとかVueだとかモダンでイケてるフレームワークでゴリゴリ書いているのにメールのテンプレートは素のHTMLを書けなんて無理があります。 特にMUIなどのUIライブラリを使ってる場合CSSを今更書けなんてかったるいです。

便利ツールの紹介

他の素のHTMLを書きたくない有志がメールビルダーサイトを作成してくれています。ただしいちいち完成したHTMLファイルをダウンロードする必要があったり、メールテンプレートの変更をgitでトラックできないなどの問題もあります。しかしこれで十分という場合もあると思います。

https://www.reddit.com/r/Supabase/comments/1o10a7a/supabase_emails_are_ugly_so_heres_an_open_source/

React Email

今回使用するのはReact Emailというライブラリです。ReactとTypeScriptでコンポーネントを作成しHTMLで書き出せます。本来はメール配信ライブラリと組み合わせて使いますが、今回はReactで書いたものをHTMLに変換する機能を使ってSupabaseのメールを書き出せるようにしていきます。 react.email

やってみる

まずは必要なコンポーネントをインストールします。

npm install @react-email/components @react-email/render @react-email/tailwind

これで準備はOKなので実際にコードを書いて行きましょう。 今回は例としてconfirmation.htmlを作っていきます。

プロジェクトのルートにemails/confirmation.tsxを作成してください。 次に以下のコードをconfirmation.tsxにコピペしてください。

import {
    Body,
    Button,
    Container,
    Font,
    Head,
    Heading,
    Hr,
    Html,
    Img,
    pixelBasedPreset,
    Section,
    Tailwind,
    Text,
} from "@react-email/components";

const palette = {
    primary: {
        main: "#2563eb", // 青
        light: "#60a5fa",
        dark: "#1e40af",
        contrastText: "#FFFFFF",
    },
    secondary: {
        main: "#facc15", // 黄色
        light: "#fde047",
        dark: "#ca8a04",
        contrastText: "#1f2937",
    },
    error: {
        main: "#BA1A1A",
        light: "#C74747",
        dark: "#821212",
    },
    warning: {
        main: "#FF9800",
    },
};

export default function Page() {
    return (
        <Html>
            <Head>
                <Font
                    fontFamily="Roboto"
                    fallbackFontFamily="Verdana"
                    webFont={{
                        url: "https://fonts.gstatic.com/s/roboto/v27/KFOmCnqEu92Fr1Mu4mxKKTU1Kg.woff2",
                        format: "woff2",
                    }}
                    fontWeight={400}
                    fontStyle="normal"
                />
            </Head>
            <Body className="bg-[#f4f5fb] font-sans text-[#1f2937]">
                <Tailwind
                    config={{
                        presets: [pixelBasedPreset],
                        theme: {
                            extend: {
                                colors: {
                                    accent: palette.primary.main,
                                    accentContrast: palette.primary.contrastText,
                                    background: "#f4f5fb",
                                    body: "#0f172a",
                                    muted: "#6b7280",
                                },
                            },
                        },
                    }}
                >
                    <Section className="px-4 py-12">
                        <div className="mx-auto w-full max-w-[600px] text-center">
                            <Img
                                src={"https://example.com/logo"}
                                alt="App Name"
                                className="mx-auto inline-block h-auto w-[132px]"
                                style={{
                                    display: "block",
                                    margin: "0 auto",
                                    maxWidth: "132px",
                                }}
                            />
                        </div>
                        <Container className="mx-auto w-full max-w-[600px] rounded-2xl bg-white px-12 py-5 text-left shadow-[0_16px_40px_rgba(15,23,42,0.06)]">
                            <Heading>App Nameへようこそ!</Heading>
                            <Text className="mt-5">
                                以下のボタンからアカウント登録を完了すると、App
                                Nameの機能をすぐにご利用いただけます。
                            </Text>
                            <div className="mt-8">
                                <Button
                                    href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&next={{ .RedirectTo }}&type=signup"
                                    className="block rounded-xl bg-accent px-6 py-4 text-center font-semibold text-accentContrast"
                                >
                                    登録を完了する
                                </Button>
                            </div>
                            <Text className="mt-8">App Nameチーム</Text>
                            <Hr />
                            <Text className="m-0 mt-6 text-xs text-muted">
                                © 2025 App Name. All rights reserved.
                            </Text>
                        </Container>
                    </Section>
                </Tailwind>
            </Body>
        </Html>
    );
}

これが正しく表示されるか確かめるためには以下のコマンドを実行します。

npx react-email dev

そしてlocalhost:3000を開きサイドバーからconfirmation.tsxを選択するとプレビューが表示されます。

HTMLを書き出す

Reactでメールのテンプレートを作成し、プレビューの確認もできたのでHTMLを書き出し、Supabaseへアップロードしてみましょう。 HTMLを書き出すにはrender関数を使います。コマンド一発で書き出せるようにしたいので、スクリプトを書いていきましょう。 emails/cli/renderer.tsxを作成し以下の内容をコピペしてください。

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { render } from "@react-email/render";
import { program } from "commander";
import * as React from "react";

global.React = React;

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const emailsDir = path.join(__dirname, "../../emails");

// Configure command-line arguments
program.option(
    "-o, --output <path>",
    "Output directory",
    path.join(__dirname, "../../supabase/templates"),
);

program.parse(process.argv);
const options = program.opts();
const outputDir = options.output;

// Check if the emails directory exists
if (!fs.existsSync(emailsDir)) {
    console.error(
        "エラー: 'emails' ディレクトリが存在しません。正しいパスを確認してください。",
    );
    process.exit(1);
}

const files = fs.readdirSync(emailsDir).filter((f) => f.endsWith(".tsx"));

async function renderEmails() {
    for (const file of files) {
        const name = path.basename(file, path.extname(file));
        const modulePath = path.join(emailsDir, file);

        try {
            // Dynamically import the component
            const { default: Component } = await import(modulePath);

            // Render the component to HTML
            const html = await render(Component());
            const outPath = path.join(outputDir, `${name}.html`);
            fs.mkdirSync(path.dirname(outPath), { recursive: true });
            fs.writeFileSync(outPath, html);
            console.log(`Rendered: ${name}.html`);
        } catch (error) {
            console.error(`Failed to render: ${name}. Error:`, error);
        }
    }
}

renderEmails();

そしてpackage.jsonにコマンドを追加します。

  "scripts": {
    "build:email": "node --import tsx ./emails/cli/renderer.ts"
  }

ここまで出来たらターミナルでnpm run build:emailを実行するとsupabase/templatesフォルダにHTMLが書き出されているはずです。

テンプレートのアップロード

Supabaseのテンプレート設定するためにプロジェクトのページに移動し、サイドバーからAuthentication/Emailsへ移動しそれぞれのHTMLをコピペしていきます。現在テンプレートのアップデートを自動で行う機能は提供されていないため、ここだけは手動でコピペする必要があります。GitHub Actionsとかでアップロードできたら便利でいいんですけどね...。

これでおしまいです。良きSupabaseライフを!

追記

テンプレートのロゴやフッターをテンプレートコンポーネントとし、メール本文のみを各ファイルでかき分けるようにするとコード量が減って良いです。

Supabaseで特定のユーザーになりきったテストを書く

備考

文章は人間が一生懸命タイピングしてます。

経緯

SupabaseのRLSのテストを書いてた時に、特定のユーザーに成り切る必要があったのですが、ChatGPTに聞いた時に出てくるコードが正しく動かなかったので備忘録です。 ここで特定のユーザーになり切るというのはauth.uid()が特定のユーザーのuuidを返すことを指しています。

問題のあるコード

ChatGPTにどうすればいいのか聞くと以下のようなコードが返ってきます。

SELECT set_config('request.jwt.claim.sub', '<特定のユーザーのuuid>', true);
SELECT set_config('request.jwt.claim.role', 'authenticated', true);

一見よさそうに見えますがこれだとなぜか動かないです。

正しく動くコード

以下のコードだと正しくauth.uid()が帰ってくるようになります

set local role authenticated;
set local request.jwt.claims to '{"role":"authenticated", "sub":"<特定のユーザーのuuid>"}';

これで特定のユーザーになりきってテストを実行できます!良きSupabaseライフを!

参考

supabase.com

GitHubにpushすると自動でpull&再起動させる方法(Webhook)

Raspberry PiGitHub の push を検知し、自動で git pull & コマンド実行する方法(Webhook)

1. 必要なパッケージをインストール

sudo apt update
sudo apt install -y git python3-flask

2. Webhook用のPythonスクリプトを作成

nano /home/pi/webhook.py

以下の内容を記述:

from flask import Flask, request
import os
import subprocess

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def webhook():
    repo_path = "/home/pi/your-repo"  # クローンしたリポジトリのパス
    os.chdir(repo_path)

    # `git pull` を実行
    subprocess.run(["git", "pull"], check=True)

    # 追加で実行するコマンド(例: デプロイスクリプト)
    subprocess.run(["bash", "deploy.sh"], check=True)
    # 非同期で実行する場合
    subprocess.Popen(["npm", "run", "start"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    return "Success", 200

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

3. Webhookサーバーを systemd で自動起動

sudo nano /etc/systemd/system/webhook.service

以下を記述:

[Unit]
Description=GitHub Webhook Listener
After=network.target

[Service]
ExecStart=/usr/bin/python3 /home/pi/webhook.py
WorkingDirectory=/home/pi
Restart=always
User=pi

[Install]
WantedBy=multi-user.target

設定を適用:

sudo systemctl daemon-reload
sudo systemctl enable webhook
sudo systemctl start webhook

4. GitHub Webhook を設定

  • GitHubリポジトリ → Settings → Webhooks → Add webhook
  • Payload URL: http://<ラズパイのIP>:5000/webhook
  • Content type: application/json
  • Just the push event を選択
  • Add webhook

5. 動作確認

ターミナルで Webhook が動いているか確認:

curl -X POST http://localhost:5000/webhook

成功すると "Success" が返る。

補足

  • deploy.sh に追加のコマンドを記述
  • Webhook を HTTPS 化するなら Nginx + Let's Encrypt を導入
  • GitHub の Webhook Secret を使ってセキュリティ強化も可能

これで GitHub に push するたびに git pull & コマンド実行が自動化できます! 🚀