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?

Next.jsで決まった時刻にWeb Push通知を送る ─ Vercel Cron + QStashで作るサーバーレスな定時通知

1
Posted at

はじめに

前回の記事で、アプリを閉じていてもPush通知が届く仕組みを作りました。
次にやりたくなったのが、「毎日18時・20時・22時」のように、あらかじめ設定した時刻に通知を送ることです。

この記事では、Web Pushそのものの実装ではなく、「1日に複数回、決まった時刻に通知を送るにはどう設計するか」に絞って書いています。

Web Push通知の基本的な実装(VAPIDキー・Service Worker・購読情報の保存・通知の送信)は、前回の記事で解説しています。この記事はその続きとして、前回の構成が動いている前提で進めます。

今回作ったアプリのソースコード

この記事の対象読者

  • Next.js + Vercel でPWAを作っている
  • 「毎日18時、20時、22時に通知」のような定時通知を複数回実装したい
  • サーバーレス環境でのスケジューリングに悩んでいる

課題:サーバーレスでは「時計を見張る」ことができない

「18時・20時・22時に通知を送りたい」と考えたとき、最初に思いつくのは、動き続けるプロセスで時刻を見張る方法です。

// こういうことがしたい(でもVercelではできない)
setInterval(() => {
  if (今が18時 || 今が20時 || 今が22時) {
    通知を送る();
  }
}, 60000);

しかしVercelのようなサーバーレス環境では、長時間動き続けるプロセスを持てません。関数はリクエストが来たときだけ起動し、処理が終わるとすぐに停止するためです。

ではVercel Cronで全部やればいいのでは?

Vercel Cronを使えば定時実行はできますが、今回の用途には次の2つの問題がありました。

  1. 実行時刻が正確ではない
    Hobbyプラン(無料)では、1つのCronは1日1回までしか実行できず、実行時刻も指定した時刻から最大59分ずれます。18時・20時・22時の3つのCronを登録することはできますが、どれも「18:00ちょうど」のように時刻どおりには届きません。
  2. 通知時刻をアプリから変更できない
    Cronの時刻は vercel.json という設定ファイルに書くため、アプリから書き換えることができません。ユーザーが通知時刻を自由に変えられるようにしたくても対応できません。

Vercel Cronの制限はプランによって異なり、変更されることもあります。最新の情報は公式ドキュメントを確認してください。

解決策:0時に「今日の予約」をまとめて入れる

そこで発想を転換しました。

深夜0時に1回だけ起動して、その日に送る予定の通知をすべて事前に予約してしまう

予約した時刻に実際にAPIを呼び出す役割は、QStash という外部のスケジューラーに任せます。QStashは「指定した時刻に、このURLにリクエストを送って」という予約を受け付けてくれるサービスです。

この設計には次のメリットがあります。

  • Vercel Cronの役割は「1日1回、予約係を起動するだけ」で済む(実行時刻が多少ずれても問題ない)
  • 通知を送るタイミングの正確さはQStashが担保してくれる
  • ユーザーが通知時刻を変更しても、翌日から自動で反映される

全体の流れ

使用技術

使用技術 役割
Vercel Cron 毎日深夜0時に、予約係のAPIを起動する
Upstash Redis 通知設定と購読情報を保存する
Upstash QStash 指定した時刻にAPIを呼び出すスケジューラー
web-push 実際にPush通知を送るライブラリ

事前準備

前回の記事で web-push・@upstash/redis のインストールと、VAPIDキー・Upstash Redisの環境変数の設定は済んでいる前提です。この記事では、追加で必要なものだけを準備します。

1. ライブラリのインストール

npm install @upstash/qstash
  • @upstash/qstash:QStashに予約を登録するためのJavaScriptクライアント

2. QStashのトークンを取得する

  1. Upstash Console を開く(前回作ったアカウントでOK)
  2. 上部メニューから QStash を開く
  3. 表示されている QSTASH_URL と QSTASH_TOKEN をコピーする

3. 環境変数の追加

.env.local に以下を追加します。

.env.local
# デプロイ先のURL(末尾のスラッシュは付けない)
BASE_URL=https://your-app.vercel.app

# Upstash QStash(Upstash Consoleからコピー)
QSTASH_URL=
QSTASH_TOKEN=

QSTASH_URL と QSTASH_TOKEN は両方必要です。QSTASH_URL を設定しないと、not found in this region というエラーになることがあります。詳しくは以下の記事で解説しています。

Vercelにデプロイする場合は、Vercelダッシュボードの Settings → Environment Variables にも同じ値を設定してください。

実装

この記事のコードは 1ユーザー・1端末 を前提にしています。Redisのキーを push-subscription や notification-settings という固定の値にしているためです。複数ユーザーに対応する場合は、ユーザーIDをキーに含める形に変更してください。

Step 1:通知設定の型を定義する

まず「いつ通知するか」の設定を型で定義します。平日と休日で時刻を分けられるようにしました。

src/lib/types.ts
// 1つの区分(平日 or 休日)の通知設定
export interface NotificationTimeSettings {
  enabled: boolean; // 通知のON/OFF
  times: string[];  // "HH:MM" 形式の文字列(例: ["18:00", "20:00"])
}
// 通知設定全体
export interface NotificationSettings {
  weekday: NotificationTimeSettings; // 月〜金
  holiday: NotificationTimeSettings; // 土・日
}

Step 2:通知設定をRedisに保存する

Cronが「今日は何時に通知するか」を読めるように、通知設定をRedisに保存しておきます。設定画面から、このAPIに設定を送ります。

src/app/api/notification-settings/route.ts
import { Redis } from "@upstash/redis";
import { NextResponse } from "next/server";
import { NotificationSettings } from "@/lib/types";

// Redisクライアントを作って返す関数
function getRedis() {
  return new Redis({
    url: process.env.UPSTASH_REDIS_REST_URL!,
    token: process.env.UPSTASH_REDIS_REST_TOKEN!,
  });
}

// Redisに保存するときのキー
const REDIS_KEY = "notification-settings";

// GET /api/notification-settings
// 保存されている通知設定を返す
export async function GET() {
  const redis = getRedis();
  const settings = await redis.get<NotificationSettings>(REDIS_KEY);
  return NextResponse.json(settings ?? null);
}

// POST /api/notification-settings
// 設定画面から送られてきた通知設定を保存する
export async function POST(req: Request) {
  const redis = getRedis();
  const settings: NotificationSettings = await req.json();
  await redis.set(REDIS_KEY, JSON.stringify(settings));
  return NextResponse.json({ ok: true });
}

Step 3:Vercel Cronで深夜0時に予約係を起動する

vercel.json にCronの設定を書くだけで、Vercelが指定した時刻にAPIを呼び出してくれます。

vercel.json
{
  "crons": [
    {
      "path": "/api/cron/schedule",
      "schedule": "0 15 * * *"
    }
  ]
}

Vercel Cronのスケジュールは常にUTCです。
JSTの0時に実行したいので 15時UTC = 0時JST を指定しています。

Hobbyプランでは、実際には0時〜0時59分のどこかで実行されます。そのため、0時台に通知時刻を設定すると、予約が間に合わないことがあります。

Vercel CronはGETリクエストを送ります。APIを POST で書いていると、Cronから呼ばれても何も起きないので注意してください。

Step 4:QStashに今日の通知を予約する

Cronから呼ばれる /api/cron/schedule で、次の処理を行います。

  1. Redisから通知設定を読む
  2. 今日が平日か休日かを判定して、今日送る時刻の一覧を決める
  3. 時刻ごとに、QStashへ予約を1件ずつ登録する
src/app/api/cron/schedule/route.ts
import { Client } from "@upstash/qstash";
import { Redis } from "@upstash/redis";
import { NextResponse } from "next/server";
import { NotificationSettings } from "@/lib/types";

// QStashとRedisのクライアントを作って返す関数
// 関数の中で作ることで、ビルド時ではなくリクエスト時に初期化される
function getClients() {
  const qstash = new Client({
    token: process.env.QSTASH_TOKEN!,
    baseUrl: process.env.QSTASH_URL, // 省略するとリージョンのエラーになることがある
  });
  const redis = new Redis({
    url: process.env.UPSTASH_REDIS_REST_URL!,
    token: process.env.UPSTASH_REDIS_REST_TOKEN!,
  });
  return { qstash, redis };
}

// 土曜(6)・日曜(0)なら true を返す
function isHoliday(date: Date): boolean {
  const day = date.getDay();
  return day === 0 || day === 6;
}

// 通知設定から、今日送る時刻の一覧を返す(例: ["18:00", "22:00"])
function getActiveTimes(settings: NotificationSettings, today: Date): string[] {
  // 休日なら休日の設定、平日なら平日の設定を使う
  const target = isHoliday(today) ? settings.holiday : settings.weekday;

  // 通知がOFFなら空の配列を返す
  return target.enabled ? target.times : [];
}

// GET /api/cron/schedule
// Vercel CronはGETで呼ぶので、GETで受け取る
export async function GET() {
  const { qstash, redis } = getClients();

  // 購読情報がなければ通知を送れないので、ここで終了する
  const subscription = await redis.get("push-subscription");
  if (!subscription) {
    return NextResponse.json({ ok: false, reason: "no subscription" });
  }

  // Redisから通知設定を読む
  const settings = await redis.get<NotificationSettings>("notification-settings");
  if (!settings) {
    return NextResponse.json({ ok: false, reason: "no notification settings" });
  }

  // 今日の日付(JST)を "YYYY-MM-DD" の形式で取得する
  const todayJST = new Date().toLocaleDateString("en-CA", {
    timeZone: "Asia/Tokyo",
  });

  // 曜日を判定するためのDate
  // UTCの正午で作ると、サーバーのタイムゾーンに関係なく日付がずれない
  const today = new Date(`${todayJST}T12:00:00Z`);

  // 今日送る時刻の一覧(例: ["18:00", "20:00"])
  const times = getActiveTimes(settings, today);
  if (times.length === 0) {
    return NextResponse.json({ ok: true, scheduled: [] });
  }

  // QStashに呼び出してもらうURL
  // BASE_URL の末尾に "/" があると "//api" になり、通知が二重に届く原因になるので取り除く
  const notificationUrl = `${process.env.BASE_URL?.replace(/\/$/, "")}/api/send-notification`;

  for (const time of times) {
    const [hour, minute] = time.split(":");

    // "+09:00" を付けて「JSTの何時何分か」を明示する
    const notifyAt = new Date(
      `${todayJST}T${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00+09:00`,
    );

    // QStashには「1970年1月1日からの秒数(Unix時間)」で時刻を渡す
    const notBefore = Math.floor(notifyAt.getTime() / 1000);

    // QStashに予約を登録する(notBefore の時刻になったら notificationUrl をPOSTで呼ぶ)
    await qstash.publishJSON({
      url: notificationUrl,
      body: {},
      notBefore,
    });
  }

  return NextResponse.json({ ok: true, scheduled: times });
}

ポイントは publishJSON の notBefore です。ここにUnix時間(秒)を渡すだけで、「この時刻になったら1回だけURLを呼ぶ」という予約を登録できます。APIが成功(200番台)以外のステータスを返したときは、QStashが自動でリトライしてくれます(初期設定では3回)。

日付と時刻の作り方を2通りに分けている理由

  • 曜日の判定用:UTCの正午(T12:00:00Z)で作ると、日本時間(+9時間)で見てもUTCで見ても同じ日付になるため、曜日がずれません。
  • 通知の時刻用:+09:00 を付けて「日本時間の18時」と明示することで、正しくUTCに変換されます。

予約した時刻になると、QStashが /api/send-notification をPOSTで呼び出します。このAPIは前回の記事で作ったものをそのまま使えるので、追加の実装は必要ありません。

動作確認

1. 通知設定を保存する

設定画面から保存するか、curlで直接保存します。確認しやすいように、少し先の時刻を入れておきます。

curl -X POST https://your-app.vercel.app/api/notification-settings \
  -H "Content-Type: application/json" \
  -d '{"weekday":{"enabled":true,"times":["18:00","20:00"]},"holiday":{"enabled":true,"times":["18:00","20:00"]}}'

2. 予約係のAPIを手動で呼ぶ

0時を待たずに確認したいときは、Cronの代わりに手動で呼び出します。

curl https://your-app.vercel.app/api/cron/schedule

{"ok":true,"scheduled":["18:00","20:00"]} のように、予約した時刻が返ってくれば成功です。

3. QStashの予約を確認する

Upstash Consoleの QStash 画面で、登録した予約と、実行予定の時刻を確認できます。予約した時刻にスマホへ通知が届けば完成です。

手動で実行するときの注意

  • 同じ日に2回実行すると、予約が2件ずつ登録されて通知が二重に届きます。
  • すでに過ぎた時刻は、QStashがすぐに実行するため、その場で通知が届きます。

まとめ

サーバーレス環境で「1日に複数回、決まった時刻に通知する」ために、次の3つの役割に分けました。

担当 役割
Vercel Cron 1日1回、予約係を起動する
Upstash QStash 決まった時刻にAPIを呼ぶ
Upstash Redis 通知設定・購読情報を保存する

「常に時計を見張る」のではなく、「0時にその日の予約をまとめて入れておく」と発想を変えることで、常駐プロセスを持てないサーバーレス環境でも定時通知を実現できました。

終わりに

今回、Claude Codeを使って実装を進めました。
実現したいことを質問したところ、QStashという知らなかったサービスを教えてもらい、実装に組み込むことができました。
これまでは、使えるライブラリを調べることに多くの時間を使っていましたが、その時間を大幅に減らすことができました。

一方で、コードをAIに任せることで、自分のコーディング能力が落ちてしまうのではという不安もあります。だからこそ、今回のように実装した内容を自分の言葉で記事にまとめ、理解を確かめるようにしています。

AIの発展でエンジニアの役割は変わりつつあります。実装はAIが手伝ってくれるようになっても、「何を実現したいか」を考えるのは自分自身であり、そこを考え続けることが大事だと感じました。

関連記事

参考

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?