はじめに
LIFULL Advent Calendar 2025 8日目の記事です。
普段は LIFULL HOME'S不動産査定 と ホームズマンション売却 の開発に携わっています。
本記事では、HTML + Cloudflare Pagesで作成した予定通知機能付き「旅のしおり」を紹介します。
最近、SNSを中心に「旅のしおり」が流行しています。知人との旅行前に、手作りのしおりを作って予定を共有する、というスタイルをよく見かけるようになりました。
同期エンジニアで開発合宿を開催するにあたり、「旅のしおりを作成したい!」となったのですが、紙のしおりは素敵な一方で、
- 予定変更のたびに修正・再配布が必要
- 人数分印刷して配る手間がかかる
- 当日うっかり忘れてしまう可能性もある
といった運用上の課題が想定されました。
そこで今回は、旅のしおりをHTMLで作成して、Cloudflare Pagesで無料公開するという方法を試してみました。この方法であれば、
- いつでも簡単に修正できる
- URLを送るだけで全員に共有できる
- スマートフォンからいつでも確認できる
- 完全無料でホスティング可能
という、紙のしおりの弱点をほぼすべて解消できます。
さらにこの記事の後半(第2章)では、設定した予定時間に自動で通知を送る機能も追加していきます。
この記事で作成するもの
第1章:HTMLで旅のしおりを作成してCloudflare Pagesで公開
旅のしおり
- スケジュール(日付・時間・内容)
- 宿泊先の情報
- 持ち物リスト
- 近隣の店舗情報
- メンバー
など
第2章:Web Push通知機能を追加
予定の時間に自動で通知
例:
- 13時 → 「集合時間👥」
- 19時 → 「夕食🍚」
第1章:HTMLで旅のしおりを作る
完成イメージ
スマートフォン
デスクトップ
必要なもの
- GitHubアカウント(無料)
- Cloudflareアカウント(無料)
- テキストエディタ(VS Codeなど)
基本的な旅のしおりHTML
HTMLで旅のしおりを作成します。以下は最低限のファイル構成の例です。
your-project/
├── public/
│ └── index.html # メインページ
└── README.md
index.htmlには以下のような内容を記述します。
- スケジュール(日付・時間・内容)
- 宿泊先の情報
- 持ち物リスト
- 近隣の店舗情報
- メンバー
Cloudflare Pagesにデプロイ
- GitHubにリポジトリを作成してプッシュ
- Cloudflare Dashboard → Workers & Pages → Create application
- Pagesタブ → Connect to Git
- リポジトリを選択してデプロイ
詳しい手順はCloudflare公式ドキュメントを参照してください。
これで、GitHubリポジトリ名に基づいた次の形式のURLで旅のしおりが公開されます。
https://<リポジトリ名>.pages.dev
第2章:予定時間に通知を送る機能を追加
旅のしおりを作ったら、「予定の時間に通知が来たら便利だな」と思いませんか?
この章では、Web Push APIを使って、設定した時間に自動で通知を送る機能を追加します。
完成イメージ
- サイトで「通知を有効にする」ボタンをクリック
- 設定した日時になると自動でブラウザ通知が届く
iOS
Android
Mac
技術スタック
- Cloudflare Pages Functions:サブスクリプション登録API
- Cloudflare Workers:通知送信処理
- Cloudflare KV:スケジュールとサブスクリプション情報の保存
- Web Push API:ブラウザへのプッシュ通知
- Service Worker:クライアント側の通知制御
追加で必要なもの
- Node.js環境(ローカル開発用)
- 少しのJavaScriptの知識
システム構成
動作の流れ
- ユーザーがブラウザで通知を許可
- Service Workerが登録され、サブスクリプション情報をPages Functionsに送信
- スケジュールとサブスクリプション情報をCloudflare KVに保存
- Cron Workerが毎分KVをチェック
- 現在時刻とスケジュールが一致したら、ユーザーに通知を送信
実装手順
ステップ1:VAPID鍵の生成
Web Pushには認証用のVAPID鍵が必要です。
npx web-push generate-vapid-keys
出力例:
Public Key: BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LQ...
Private Key: UUxI4O8DDsznPBCWGE7hMCi5Y2f9oXLsK-KJQgRNq...
この2つのキーを安全に保存しておきます。
ステップ2:Cloudflare KVの作成
- Cloudflareダッシュボード → Workers & Pages → KV
- Create a namespaceをクリック
- Namespace Name:
SUBSCRIPTIONS - 作成後、Namespace IDをメモ
ステップ3:Pagesプロジェクトの準備
プロジェクト構造
your-project/
├── functions/
│ └── api/
│ └── subscribe.js # サブスクリプション登録API
├── public/
│ ├── manifest.json # PWA用マニフェスト
│ ├── icon-192.png # アプリアイコン 192x192
│ ├── sw.js # Service Worker
│ └── index.html # メインページ
└── README.md
public/manifest.json
iPhoneでホーム画面に追加してPWAとして動作させるために必須です。
{
"name": "開発合宿の旅のしおり",
"short_name": "旅のしおり",
"description": "開発合宿用に作成した予定確認と通知ができる旅のしおりWebアプリ",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4285f4",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
}
]
}
public/icon-192.png
アイコン用の192x192pxのPNG画像を用意してください。
functions/api/subscribe.js
// サブスクリプション登録用API
export async function onRequestPost(context) {
const { request, env } = context;
try {
const { subscription, schedules } = await request.json();
const subscriptionId = crypto.randomUUID();
await env.SUBSCRIPTIONS.put(subscriptionId, JSON.stringify({
subscription,
schedules,
createdAt: new Date().toISOString()
}));
return new Response(JSON.stringify({
success: true,
id: subscriptionId
}), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
} catch (error) {
return new Response(JSON.stringify({
success: false,
error: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
export async function onRequestOptions(context) {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
}
});
}
public/sw.js
// Service Worker
self.addEventListener('push', function(event) {
const data = event.data ? event.data.json() : {};
const title = data.title || '旅のしおり';
const options = {
body: data.body || '予定の時間です',
icon: data.icon || '/icon-192.png',
badge: data.badge || '/badge-72.png',
vibrate: [200, 100, 200],
tag: 'itinerary-notification',
data: {
url: data.url || '/'
}
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
self.addEventListener('install', function(event) {
self.skipWaiting();
});
self.addEventListener('activate', function(event) {
event.waitUntil(clients.claim());
});
public/index.html
<head>タグにmanifest.jsonとアイコンのリンクを追加してください。
<head>
<!-- 既存のmeta tagなど -->
<!-- PWA用メタタグ -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#4285f4">
<!-- iOS用 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="旅のしおり">
<link rel="apple-touch-icon" href="/icon-192.png">
<!-- その他のアイコン -->
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
</head>
<body>
<!-- 通知登録ボタン -->
<button id="enable-notifications">通知を有効にする</button>
<div id="notification-status"></div>
<script>
const VAPID_PUBLIC_KEY = 'YOUR_PUBLIC_VAPID_KEY_HERE'; // ステップ1のPublic Key
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
alert('このブラウザはService Workerに対応していません');
return null;
}
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('Service Worker registered');
return registration;
} catch (error) {
console.error('Service Worker registration failed:', error);
return null;
}
}
async function subscribeToPushNotifications() {
const statusDiv = document.getElementById('notification-status');
statusDiv.textContent = '設定中...';
const registration = await registerServiceWorker();
if (!registration) return;
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
statusDiv.textContent = '通知が許可されませんでした';
return;
}
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
const response = await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subscription: subscription,
schedules: [
{ date: '2025-11-24', time: '13:00', message: '13:00 集合時間👥' },
{ date: '2025-11-24', time: '15:00', message: '15:00 開発セッション1💻' },
{ date: '2025-11-24', time: '19:00', message: '19:00 夕食🍚' }
]
})
});
const result = await response.json();
if (result.success) {
statusDiv.textContent = '通知が有効になりました!';
localStorage.setItem('subscriptionId', result.id);
} else {
statusDiv.textContent = '登録に失敗しました';
}
} catch (error) {
console.error('Subscription failed:', error);
statusDiv.textContent = 'エラーが発生しました';
}
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('enable-notifications')
.addEventListener('click', subscribeToPushNotifications);
});
</script>
</body>
ステップ4:追加ファイルをデプロイ
ステップ3で作成したファイルをGitHubに反映させます。
Cloudflare Pagesが自動的に再デプロイを開始します。
ステップ5:PagesのKVバインディング設定
- デプロイ完了後、Settings → Functions
-
KV namespace bindings → Add binding
- Variable name:
SUBSCRIPTIONS - KV namespace:ステップ2で作成したものを選択
- Variable name:
- Save
ステップ6:Cron Worker(通知送信用)の作成
ローカルでWorkerプロジェクトを作成
mkdir travel-notifications-worker
cd travel-notifications-worker
npm init -y
npm install wrangler @block65/webcrypto-web-push
wrangler.toml
name = "travel-notifications-cron"
main = "src/index.js"
compatibility_date = "2025-11-23"
kv_namespaces = [
{ binding = "SUBSCRIPTIONS", id = "{KVのNamespace ID}" }
]
[triggers]
crons = ["* * * * *"] # 1分ごと(テスト用)
[vars]
VAPID_PUBLIC_KEY = "BEl62iU..." # ステップ1のPublic Key
src/index.js
import { buildPushPayload } from '@block65/webcrypto-web-push';
export default {
async scheduled(event, env, ctx) {
// 日本時間に変換(UTC+9)
const now = new Date();
const jstOffset = 9 * 60 * 60 * 1000;
const jstDate = new Date(now.getTime() + jstOffset);
const currentDate = jstDate.toISOString().split('T')[0];
const currentTime = `${jstDate.getHours().toString().padStart(2, '0')}:${jstDate.getMinutes().toString().padStart(2, '0')}`;
console.log(`Cron triggered at JST: ${currentDate} ${currentTime}`);
// VAPID設定
// subjectには管理者の連絡先を指定します(Web Push仕様で必須)
const vapid = {
subject: 'mailto:your-email@example.com', // 自分のメールアドレス or サイトURLに変更
publicKey: env.VAPID_PUBLIC_KEY,
privateKey: env.VAPID_PRIVATE_KEY
};
try {
// KVから全てのサブスクリプションを取得
const list = await env.SUBSCRIPTIONS.list();
console.log(`Found ${list.keys.length} subscriptions`);
for (const key of list.keys) {
const data = await env.SUBSCRIPTIONS.get(key.name);
if (!data) continue;
const { subscription, schedules } = JSON.parse(data);
// 現在の日時に一致するスケジュールを探す
const matchingSchedule = schedules.find(s =>
s.date === currentDate && s.time === currentTime
);
if (matchingSchedule) {
console.log(`Sending: ${matchingSchedule.message}`);
try {
const message = {
data: JSON.stringify({
title: '旅のしおり',
body: matchingSchedule.message,
icon: '/icon-192.png',
url: '/'
}),
options: {
ttl: 86400,
urgency: 'high'
}
};
const payload = await buildPushPayload(message, subscription, vapid);
const response = await fetch(subscription.endpoint, payload);
if (response.ok) {
console.log('Notification sent!');
} else {
console.error(`Failed: ${response.status}`);
// 410 = サブスクリプションが無効
if (response.status === 410) {
await env.SUBSCRIPTIONS.delete(key.name);
}
}
} catch (error) {
console.error('Send failed:', error.message);
}
}
}
} catch (error) {
console.error('Cron error:', error);
}
}
};
デプロイ
# VAPID Private Keyをセキュアに設定
npx wrangler secret put VAPID_PRIVATE_KEY
# プロンプトが出たらPrivate Keyを貼り付け
# デプロイ
npx wrangler deploy
ステップ7:動作確認
通知の有効化
- デプロイしたPagesサイトにアクセス
- 「通知を有効にする」ボタンをクリック
- 通知を許可
- 「通知が有効になりました!」と表示されればOK
iPhone(iOS)の場合
iPhoneでWeb Push通知を受け取るには、iOS 16.4以降が必要です。
設定手順:
- ブラウザで旅のしおりサイトを開く
- 共有ボタン→ ホーム画面に追加をタップ
- PWA(Progressive Web App)としてインストール
- ホーム画面から起動したアプリで「通知を有効にする」をタップ
- 通知の許可ダイアログが表示されるので「許可」をタップ
注意点:
- ブラウザでは通知を受け取れません
- ホーム画面に追加したPWAアプリからのみ通知を受け取れます
- アプリを完全に終了していても通知は届きます
実際の通知:
設定時刻になると、ロック画面や通知センターに通知が表示されます。
Androidの場合
設定手順:
- ブラウザで旅のしおりサイトを開く
- 「通知を有効にする」ボタンをタップ
- 通知の許可ダイアログが表示されるので「許可する」をタップ
注意点:
- ブラウザを閉じていても通知は届きます
実際の通知:
設定時刻になると、ロック画面や通知バーに通知が表示されます。
Macの場合
設定手順:
- ブラウザで旅のしおりサイトを開く
- 「通知を有効にする」ボタンをクリック
- 通知の許可ダイアログが表示されるので「許可する」をタップ
注意点:
- ブラウザが起動している必要があります
- ブラウザを完全に終了すると通知は届きません
実際の通知:
設定時刻になると、デスクトップ右上に通知バナーが表示されます。
料金について
すべてCloudflareの無料プランで運用可能です。
- Pages:無料
- Workers:100,000リクエスト/日まで無料
- KV:100,000 read、1,000 write/日まで無料
- Cron Triggers:追加料金なし
実際の使用量
テスト用の設定(1分ごと実行)の場合
- 1,440リクエスト/日(60回/時間 × 24時間)
- Workersの無料枠に対して1.44%の使用量
十分に余裕があるため、無料枠内で安心して運用できます。
本番運用時の最適化
テスト完了後は、必要な時刻だけ実行するように変更することをお勧めします。
# wrangler.toml
[triggers]
crons = ["0 4,5,6,10,12 * * *"] # UTC 04:00, 05:00, 06:00, 10:00, 12:00
# = JST 13:00, 14:00, 15:00, 19:00, 21:00
この設定では
- 1日5リクエストのみ(予定の時刻のみ実行)
- Workersの無料枠に対して0.005%の使用量
さらにリクエスト数を抑えつつ、必要な通知はすべて送信できます。
まとめ
この記事では、HTML + Cloudflare Pagesで作る「旅のしおり」に、Web Push APIを使った予定通知機能を追加する方法を紹介しました。
