はじめに
スマホのホーム画面アイコンをタップして数秒でGitHub Issueを起票する、という用途だけに絞ったPWAを作りました。Vite 8 + React 19のSPAをCloudflare Workers上のHonoが配信する構成で、機能としては「リポジトリを選んでタイトルを書いて送る」だけです。
ところが、その素朴な機能をAndroidで成立させるために、最終的に ブラウザ挙動の回避策が4箇所 残りました。しかもそのうち1つは、公式に用意された設定項目(globPatterns / globIgnores / manifestTransforms)ではどうやっても解決できず、ビルド成果物を後から書き換えるVite プラグインを自作して潰しています。
この記事では、その4箇所を実際のコードで説明します。1件目については、記事を書くにあたって手元でビルドし直し、回避策が効いていることを実測しました。
作ったのはkai-kou/github-issue-shortcut(MITライセンス)です。以降のコード参照はすべてコミット fa6b026 時点のものです。
この記事で学べること
-
vite-plugin-pwaがmanifestをService Workerのprecacheへ強制追加し、設定では除外できない理由と回避策 - WorkboxのRegExpRouteが
url.hrefに対して評価されるという、パスだけ見ていると気づけない仕様 - Android のWebAPKが既存アプリを再利用起動するときにクエリを落とす問題を、Launch Handler APIで復元する実装
- Web Share Targetで共有URLが
urlではなくtextに入ってくる前提の処理
対象読者
PWAをAndroid向けに配信していて、Service Workerやmanifestまわりで想定外の挙動に当たったことがある方、あるいはこれから vite-plugin-pwa でPWA化しようとしている方を対象にしています。
前提環境
- リポジトリ:
kai-kou/github-issue-shortcut(コミットfa6b026) - ビルド: Vite 8 +
vite-plugin-pwa(生成されたSWのヘッダはPWA v1.3.0/generateSW) - 検証環境: Linux コンテナ(Node.js・npm が使えるクラウド実行環境)
- 検証日: 2026-07-29 JST
TL;DR
-
vite-plugin-pwaはmanifest.webmanifestをworkbox.additionalManifestEntriesへ 強制追加 する。この追加はWorkboxのmanifestTransformsより後段で適用されるため、設定側からは除外できない - 回避策として「生成後の
sw.jsから該当エントリを正規表現で削除するVite プラグイン」を書いた。npm run buildしてgrep -c 'manifest\.webmanifest' dist/client/sw.jsを実行すると0になる - WorkboxのRegExpRouteは正規表現をURL全体(
url.href)に対して評価する。/^\/api\/issues$/はhttps://host/api/issuesに一致しない - Android のWebAPKが
start_urlで既存アプリを再利用起動するとクエリが落ちる。launch_handler: { client_mode: "navigate-existing" }とwindow.launchQueueで起動URLを復元した - Android の共有シートは共有URLを
urlではなくtextに入れてくることが多い。manifestのshare_targetでtextをbodyにマッピングし、urlが別途来た場合だけ末尾に追記している
罠1: vite-plugin-pwaがmanifestをprecacheへ強制追加する
一番厄介だったのがこれです。生成された sw.js から manifest.webmanifest のprecacheエントリを削除するためだけのVite プラグインを書くはめになりました。
vite.config.ts#L25-L54 を引用します。
function stripManifestFromSwPrecache(): Plugin {
const MANIFEST_ENTRY = /\{url:"manifest\.webmanifest",revision:(?:"[^"]*"|null)\},?/;
return {
name: "strip-manifest-from-sw-precache",
apply: "build",
enforce: "post",
closeBundle: {
order: "post",
handler() {
if (this.environment && this.environment.name !== "client") return;
const swPath = resolve(process.cwd(), "dist/client/sw.js");
if (!existsSync(swPath)) return;
const src = readFileSync(swPath, "utf8");
if (!MANIFEST_ENTRY.test(src)) {
throw new Error(
"[strip-manifest-from-sw-precache] sw.js に manifest.webmanifest の precache エントリが見つからず除外できませんでした。...",
);
}
const stripped = src.replace(MANIFEST_ENTRY, "").replace(/,\]/g, "]");
writeFileSync(swPath, stripped);
},
},
};
}
なぜ設定で除外できないのか
設定で除外できない理由はこうです。vite-plugin-pwa はmanifestを workbox.additionalManifestEntries へ追加し(configureStaticAssets)、この追加はWorkboxの manifestTransforms より後段(additionalManifestEntriesTransform が最後)に適用されます。つまり globPatterns で拾わないようにしても、globIgnores で弾いても、manifestTransforms で落としても、その後から差し込まれます。
何が壊れるのか
manifestがprecacheされると、ブラウザのmanifest取得リクエスト(destination: "manifest" でSWのfetchハンドラを経由する)をprecacheルートが横取りし、ビルド時の静的なmanifestを返し続けます。
一時期、ログインユーザーごとにshortcutsを差し替えた動的manifestをWorkerから返す実装を試していたのですが、これが「動的manifestが永遠にブラウザへ届かない」原因でした。SWがprecacheから返している以上、Worker側にはリクエストすら到達しません。
実測
回避策が効いているか、記事のためにビルドし直して確かめました。
$ npm ci --no-audit --no-fund
added 440 packages in 10s
$ npm run build
✓ built in 182ms
dist/client/assets/index-zlMbnGVq.js 266.90 kB │ gzip: 83.07 kB
dist/client/assets/index-B9ldCHiV.css 13.04 kB │ gzip: 2.94 kB
PWA v1.3.0
mode generateSW
precache 8 entries (274.24 KiB)
$ grep -c 'manifest\.webmanifest' dist/client/sw.js
0
precacheは8エントリ残っていて、そのなかに manifest.webmanifest はいません。プラグインが後処理で消しているためです。
この方式の要点は、削除に失敗したらビルドを止める ようにしたことです。vite-plugin-pwa やWorkboxの出力フォーマットが変わって正規表現が当たらなくなった場合、黙って元の壊れた状態に戻るのではなく throw します。生成物を正規表現で書き換えるという本質的に脆い手段を取るなら、この番人はセットで必要でした。
なお、SWの設定側では /auth/* /setup /api/* をナビゲーションフォールバックの対象外にしています(navigateFallbackDenylist)。OAuthのコールバックがキャッシュ応答で壊れるのを防ぐためで、こちらは設定で素直に書けます。
罠2: WorkboxのRegExpRouteはurl.hrefに対して評価される
このアプリには、オフライン時に起票をキューして復帰後に再送する機能があります。当初はWorkboxのBackground Syncで実装していましたが、現在は撤去してページ側の経路に一本化しました。
撤去理由は vite.config.ts の workbox セクションにコメントとして残してあります。
- 1 度も発火していなかった: Workbox の RegExpRoute は正規表現を URL 全体(
url.href)に対して評価する。/^\/api\/issues$/はhttps://host/api/issuesに一致しないため、起票 POST はキューへ積まれていなかった。
パスだけを見て /^\/api\/issues$/ と書くのは自然な発想ですが、評価対象が url.href である以上、先頭の ^\/ はスキーム https: に当たって外れます。エラーも警告も出ないまま、キューが1度も動かない状態が続いていました。
厄介だったのは、パターンを直すだけでは直らない ことです。同じコメントの2点目に書いたのがこれです。
- パターンを直すだけでは二重起票する: サーバー側の重複判定を廃止し、照合を端末内(
client_request_idの予約)へ移した。Workbox の BackgroundSyncPlugin は自動でキューを再送するが、その再送はこの予約を参照しないため、ページ側の再送と合わせて同じ起票が 2 件作られうる。
重複防止の責務をサーバーから端末内へ移した結果、SWの自動再送が「重複判定を知らない再送経路」になってしまった、という構図です。SWから予約を参照するには injectManifest 化してSWを自前で書く必要があり、個人開発のこのアプリでそこまでやる価値はないと判断して、Background Syncごと撤去しました。
「バグを直す」ではなく「その機構をやめる」を選んだので、判断の理由はコードに残しておかないと後の自分が復活させてしまいます。長いコメントになりましたが、消さずに置いています。
罠3: WebAPKの再利用起動でクエリが落ちる
ここからの2件はAndroid のプラットフォーム挙動が前提です。今回の執筆環境(Linuxコンテナ)では実機の再現ができないので、実装と、その実装に至った理由を中心に書きます。
manifestには次の1行を入れています。
launch_handler: { client_mode: "navigate-existing" },
WebAPK(Android にインストールされたPWA)が既存のアプリインスタンスを再利用して起動すると、location が start_url(このアプリでは /)のままになり、ホーム画面に手動追加した /new?repo=... のようなクエリ付きショートカットのクエリが失われます。ホーム画面タップで特定リポジトリのIssue作成画面へ直行する、というこのアプリの中心機能が、そこで壊れます。作った意味がなくなる部類の不具合でした。
対策として、src/App.tsx#L208-L224 でLaunch Handler APIから実際の起動URLを受け取り、履歴を差し替えています。
useEffect(() => {
const launchQueue = window.launchQueue;
if (!launchQueue) return;
launchQueue.setConsumer((launchParams) => {
if (!launchParams.targetURL) return;
const target = parseLaunchTargetUrl(launchParams.targetURL, window.location.origin);
if (!target) return;
if (target.path === window.location.pathname && target.search === window.location.search) return;
window.history.replaceState(null, "", `${target.path}${target.search}`);
setPath(target.path);
setSearch(target.search);
});
}, []);
window.launchQueue が無い環境(未対応ブラウザ・通常のタブ)では何もせず抜けるので、機能検出だけで安全に足せます。parseLaunchTargetUrl 側では new URL(targetURL, origin) の結果が同一オリジンでなければ null を返し、外部から渡されたURLで内部遷移させないようにしました。
start_url は / のままにしておいて、クエリの復元だけを別レイヤーで行う。この分離にしたおかげで、Launch Handler API非対応の環境でも「トップページが開く」という劣化で済んでいます。
罠4: 共有シートのURLはtextに入ってくる
manifestの share_target はこうしました。
share_target: {
action: "/new",
method: "GET",
params: { title: "title", text: "body", url: "url" },
},
method: "GET" なので、ブラウザは action にクエリ文字列を付けて遷移するだけです。専用のPOSTエンドポイントもmultipart解析も要りません。既存の /new?title=&body= のプレフィル解析にそのまま合流できるので、共有シート対応のために増えたサーバー側コードは実質ゼロでした。
ポイントは text を body にマッピングしている点です。共有元アプリは共有URLを url ではなく text に入れてくることが多いためです。ただし両方埋まっているケースもあるので、src/issues/prefillParams.ts#L8-L14 で合成しています。
function mergeBodyAndUrl(body: string | null, url: string | null): string | null {
if (!url) return body;
if (body && body.includes(url)) return body;
return body ? `${body}\n\n${url}` : url;
}
body がすでに url を含んでいれば追記しない、という重複防止を入れています。「text にURLが入っていて、かつ url にも同じURLが入っている」共有元に当たったときに、本文へ同じURLが2回並ぶのを防ぐためです。
manifestの shortcuts は3件だけにしました。「Android Chrome 最大 3 個・WebAPK 反映は約 24h 周期」という制約があるので、ユーザーごとのリポジトリを埋め込むのは諦め、汎用の「新しいIssueを作成」「バグを報告」「改善案を起票」に留めています。反映が24時間周期では、ユーザー設定に追従する用途には最初から向きません。個別プリセットは、URLベースのショートカット作成ヘルパーという別機能に分けました。
配信中のmanifestが本当にこうなっているかは、誰でも確認できます。
$ curl -s https://github-issue-shortcut.kinamocchi-tech.workers.dev/manifest.webmanifest | head -c 200
実行したところ、share_target(text → body)と shortcuts3件、launch_handler.client_mode = "navigate-existing" がそのまま返ってきました。
バンドルサイズはどうなったか
回避策を4件も抱えて肥大化していないかが気になったので、リポジトリに設定してある size-limit を実行しました。
$ npx size-limit
クライアントバンドル(brotli)
Size limit: 80 kB
Size: 70.52 kB brotlied
Worker バンドル(brotli)
Size limit: 35 kB
Size: 26.15 kB brotlied
依存を hono / react / react-dom だけに絞っているので、React 19込みで70.52 kB(brotli)に収まっています。予算80 kBに対して約12%の余裕でした。ユニットテストも通ることを確認しています(npx vitest run で19ファイル・263テストがpass、13.64秒)。
まとめ
作ったのは「Issueを1件作るだけ」のアプリですが、Androidで実用に耐えさせるために入れた回避策は次の4件でした。
| # | 罠 | 対処 | 根拠 |
|---|---|---|---|
| 1 |
vite-plugin-pwa がmanifestをprecacheへ強制追加する |
生成後の sw.js を書き換える自作プラグイン(失敗時はビルドを止める) |
ビルドして grep で確認 |
| 2 | WorkboxのRegExpRouteが url.href で評価される |
Background Syncを撤去しページ側経路へ一本化 | 実装とコード内コメント |
| 3 | WebAPKの再利用起動でクエリが落ちる |
launch_handler + window.launchQueue で起動URLを復元 |
実装とコード内コメント |
| 4 | 共有URLが url ではなく text に入る |
share_target で text→body、重複を避けて url を追記 |
実装と配信中のmanifest |
1件目は今回の執筆環境でもビルドして再確認できました。3件目と4件目はAndroid実機の挙動が前提なので、この記事では実装と判断理由の説明に留めています。
PWAをAndroid向けに配信するなら、少なくとも罠1と罠2は設定を眺めているだけでは絶対に気づけない種類のものでした。どちらもエラーが出ないまま静かに壊れます。manifestが更新されないとき、オフラインキューが動いていないとき、疑うポイントとして頭の片隅に置いておくと早いと思います。
コードはMITライセンスで公開しています。Issue・PRは歓迎です。