cloudflare-maintenance-worker 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Second Fiddle Co., Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.ja.md ADDED
@@ -0,0 +1,238 @@
1
+ # cloudflare-maintenance-worker
2
+
3
+ [English](README.md)
4
+
5
+ 重い migration や DB 作業のあいだ、利用者からのアクセスに `503` とメンテナンスページを返す Cloudflare Worker と、メンテナンスの開始と終了を切り替えるスクリプトです。
6
+
7
+ Worker は普段リクエストを受けません。対象ホストの Workers Route をこの Worker に付けるとメンテナンスに入り、外すと抜けます。付け外しは `maintenance-worker on` と `off` が Cloudflare API で行います。`wrangler.jsonc` にルートを書かないのは、書くと `wrangler deploy` のたびにルートが付き、デプロイしただけでサイトが止まるためです。デプロイとメンテナンスの開始は、意図して別の操作にしています。
8
+
9
+ ## 振る舞い
10
+
11
+ - 通常のリクエストには、メンテナンスページを `503` で返します。`Retry-After`(既定は 3600 秒)と `Cache-Control: no-store` を付け、Cloudflare にもブラウザにもページを残させません。
12
+ - API クライアントへの JSON のエラーや、Inertia.js の画面遷移への `409` のように、リクエストによって応答を変えたいときは、関数を差し込めます。「[独自の応答](#独自の応答)」を読んでください。
13
+ - 作業者は、合言葉か許可 IP で通れます。任意の URL に `?maintenance_bypass=<合言葉>` を付けて開くと、パラメーターを外した同じ URL へリダイレクトし、12 時間有効の Cookie を発行します。リダイレクトするのは、合言葉がアドレスバーに残らないようにするためです。
14
+
15
+ パラメーター名の `maintenance_bypass` は秘密ではありません。名前を知られても、合言葉が無ければ誰も通れません。合言葉は定数時間で比べ、Cookie には合言葉から導いたハッシュだけを入れ、合言葉そのものは入れません。Cookie の値は合言葉から導いていて、サーバー側に有効期限の一覧を持たないので、合言葉を変えればそれまでに発行した Cookie はすべて無効になります。合言葉が未設定なら、合言葉でも Cookie でも誰も通しません(fail-closed)。
16
+
17
+ 同じルートのパターンを別の Worker がすでに持っていると、`on` は何も変更せずに止まります。対象ホストに別の Worker のもっと狭いルート(たとえば `app.example.com/api/*`)があると、そのパスはメンテナンス中も止まらないので、`on` と `status` が警告を出します。
18
+
19
+ 呼び出しログは `wrangler.jsonc` で切っています。リクエスト URL を残すので、作業者が合言葉を使うと平文で入ってしまうためです。有効に戻さないでください。
20
+
21
+ ## セットアップ
22
+
23
+ おすすめの持ち方は、自分のリポジトリには設定だけを置き、コードは依存として取り込む形です。Node 22 以降と pnpm が要ります。npm でも動きますが、npm は `--dry-run` などのオプションを自分で受け取ってしまうので、environment 名の前に `--` を入れます(`npm run deploy -- develop --dry-run`)。
24
+
25
+ ディレクトリを作り、次の 4 つのファイルを置きます。
26
+
27
+ `package.json`:
28
+
29
+ ```json
30
+ {
31
+ "name": "my-maintenance",
32
+ "private": true,
33
+ "type": "module",
34
+ "scripts": {
35
+ "dev": "wrangler dev --env develop",
36
+ "deploy": "maintenance-worker deploy",
37
+ "on": "maintenance-worker on",
38
+ "off": "maintenance-worker off",
39
+ "status": "maintenance-worker status"
40
+ },
41
+ "devDependencies": {
42
+ "cloudflare-maintenance-worker": "github:second-fiddle-jp/cloudflare-maintenance-worker#v0.2.0",
43
+ "wrangler": "4.123.0"
44
+ }
45
+ }
46
+ ```
47
+
48
+ `src/index.js` は Worker を再エクスポートするだけです(一部のリクエストに独自の応答を返すときは「[独自の応答](#独自の応答)」)。
49
+
50
+ ```js
51
+ export { default } from 'cloudflare-maintenance-worker'
52
+ ```
53
+
54
+ `wrangler.jsonc` と `maintenance.config.json` は、このリポジトリの直下にあるサンプルをコピーし、名前とホストを書き換えます。中身は「[設定](#設定)」で説明します。
55
+
56
+ インストールして、wrangler にログインします。
57
+
58
+ ```bash
59
+ pnpm install
60
+ pnpm exec wrangler login
61
+ ```
62
+
63
+ wrangler は自分の `devDependencies` でバージョンを固定してください。スクリプトはインストールされた wrangler を使います。
64
+
65
+ このリポジトリを clone して、サンプルをそのまま書き換えて使うこともできます。clone した側の `package.json` にも同じスクリプトがあるので、以下のコマンドはどちらでも同じです。
66
+
67
+ ## 設定
68
+
69
+ サイト固有の値は、`wrangler.jsonc` の vars と `maintenance.config.json` の 2 か所に置きます。コードにはサイト固有の値がありません。
70
+
71
+ ### Worker の vars と secret
72
+
73
+ vars は `wrangler.jsonc` の `env.<environment>.vars` に書きます。wrangler は top-level の `vars` を environment に継承しないので、同じ値でも environment ごとに全部書きます。値は文字列で書きます。数値や真偽値で書いても文字列として扱うので、`"RETRY_AFTER": 120` も `"120"` と同じく有効です。
74
+
75
+ | 名前 | 意味 | 未設定のとき |
76
+ |---|---|---|
77
+ | `RETRY_AFTER` | `Retry-After` に入れる秒数 | `3600` |
78
+ | `BYPASS_COOKIE` | バイパス Cookie の名前。Cookie の Domain を共有する environment どうしでは別の名前にする | `maintenance_bypass` |
79
+ | `COOKIE_DOMAIN` | バイパス Cookie の `Domain`。`.example.com` のように書くと、1 つの Cookie がサブドメインをまたいで効く | `Domain` を付けず、合言葉を開いたホストだけで効く |
80
+ | `BRAND` | ページに出すブランド名。タイトルの末尾にも ` \| <BRAND>` として付く | ブランドを出さない |
81
+ | `PAGE_LANG` | ページの `lang` 属性 | `en` |
82
+ | `PAGE_TITLE` | ページのタイトル | `Under maintenance` |
83
+ | `PAGE_HEADING` | ページの見出し | `We are currently down for maintenance` |
84
+ | `PAGE_MESSAGE` | 見出しの下の本文。改行は `<br>` になる | お詫びと、時間をおいてアクセスし直すよう促す 2 行の英文 |
85
+ | `BYPASS_SECRET`(secret) | 合言葉 | 合言葉でも Cookie でも通さない |
86
+ | `ALLOW_IPS`(secret) | メンテナンス中も通す IP(カンマ区切り) | IP では通さない |
87
+
88
+ 既定の文言は英語です。日本語のページにするなら、`PAGE_LANG` を `ja` にし、`PAGE_TITLE`・`PAGE_HEADING`・`PAGE_MESSAGE` を environment ごとに書いてください。ページに差し込む値は HTML エスケープするので、vars に `<` を書いてもタグにはなりません。文言より大きく見た目を変えたいときは、clone して `src/page.js` の HTML と CSS を編集します。HTML を別ファイルにしていないのは、`.html` を Worker のモジュールとして読み込むと `node --test` から import できなくなるためです。
89
+
90
+ ### 独自の応答
91
+
92
+ メンテナンス中の応答をリクエストによって変えたいときは、`src/index.js` で `createWorker` に関数を渡します。関数は `(request, env, { headers })` を受け取ります。`Response` を返すとそれが応答になり、`undefined` を返すと次の関数へ、最後はメンテナンスページへ進みます。関数は配列の順に呼びます。`headers` には、メンテナンスページと同じ `Cache-Control` と `Retry-After` が入っています。Cookie や許可 IP で通る作業者のリクエストは、関数に届きません。
93
+
94
+ 次の例は、Inertia.js の画面遷移にページ全体を読み込み直させるものと、API のホストに JSON-RPC のエラーを返すものです。
95
+
96
+ ```js
97
+ import { createWorker } from 'cloudflare-maintenance-worker'
98
+
99
+ // Inertia の XHR に 503 の HTML を返すとモーダルの中にページが出るので、409 でページ全体を読み込み直させる
100
+ const inertia = (request) =>
101
+ request.headers.has('X-Inertia')
102
+ ? new Response(null, { status: 409, headers: { 'X-Inertia-Location': request.url, 'Cache-Control': 'no-store' } })
103
+ : undefined
104
+
105
+ // ページを読めない API クライアントには、JSON-RPC のエラーを返す
106
+ const jsonRpc = (request, env, { headers }) =>
107
+ new URL(request.url).hostname === 'api.example.com'
108
+ ? Response.json({ jsonrpc: '2.0', error: { code: -32000, message: 'Service under maintenance' }, id: null }, { status: 503, headers })
109
+ : undefined
110
+
111
+ export default createWorker({ respond: [inertia, jsonRpc] })
112
+ ```
113
+
114
+ ### maintenance.config.json
115
+
116
+ ルートを付け外しするスクリプトが読む設定で、ゾーンと、environment ごとに付けるルートのパターンを書きます。
117
+
118
+ ```json
119
+ {
120
+ "zone": "example.com",
121
+ "environments": {
122
+ "develop": { "patterns": ["dev.example.com/*"] },
123
+ "production": { "patterns": ["example.com/*", "www.example.com/*"] }
124
+ }
125
+ }
126
+ ```
127
+
128
+ パターンは上から順に付けます。`*.example.com/*` のようなワイルドカードにせず、ホストごとに書いてください。ワイルドカードにすると、公開ストレージのバケットのように止めるつもりの無いホストまで止まります。
129
+
130
+ environment の名前は、`wrangler.jsonc` の `env` と同じ組にそろえます。各 environment の Worker 名は `wrangler.jsonc` の `name` に environment 名を付けた `<name>-<env>`(env で `name` を上書きしていればその名前)で、スクリプトも同じ規則で導きます。`maintenance.config.json` にだけある environment や、`patterns` が無い environment は、その environment を操作しようとしたときだけ理由を示して止まります。`wrangler.jsonc` にだけある environment は警告を出すだけです。メンテナンスの最中に無関係な environment の設定を誤っても、`off` と `status` を使えるようにするためです。
131
+
132
+ スクリプトは `wrangler.jsonc` を wrangler を使わずに読みます。行コメント、ブロックコメント、末尾カンマのどれも書けます。
133
+
134
+ ## デプロイと secret の登録
135
+
136
+ Worker のデプロイは、`deploy` スクリプトに environment 名を付けて行います。素の `wrangler deploy` は top-level の `name` で vars を持たない Worker を作ってしまうので、`deploy` は environment 名が無いときや空のときは止まります。`pnpm deploy` は pnpm の組み込みコマンドなので、必ず `pnpm run deploy` と書きます。`--dry-run` など `wrangler deploy` のオプションは、environment 名のあとに続けます(`pnpm run deploy develop --dry-run`。npm なら `npm run deploy -- develop --dry-run`)。
137
+
138
+ ```bash
139
+ pnpm run deploy develop
140
+ pnpm run deploy production
141
+ ```
142
+
143
+ デプロイしてもルートは付かないので、サイトは通常どおり動き続けます。
144
+
145
+ 合言葉と許可 IP は、最初のデプロイのあとに Worker の secret として登録します。Worker が無い状態だと、`wrangler secret put` は Worker を作ってよいかを対話で尋ねてきます。
146
+
147
+ ```bash
148
+ pnpm exec wrangler secret put BYPASS_SECRET --env production
149
+ pnpm exec wrangler secret put ALLOW_IPS --env production
150
+ ```
151
+
152
+ 合言葉は environment ごとに別の値にし、`openssl rand -hex 32` で作ります。16 進数だけなので URL エンコードが要りません。メンテナンスのたびに合言葉を変えてください。前回配った Cookie は、そうすることで無効になります。
153
+
154
+ ## ルートを付け外しする API トークン
155
+
156
+ ルートの付け外しは Cloudflare API を直接呼ぶので、専用の API トークンを作ります。権限は Zone → Workers Routes: Edit と Zone → Zone: Read の 2 つで、対象は自分のゾーンだけにします。トークンは `CLOUDFLARE_API_TOKEN` 環境変数で渡します。コマンドラインに直接書くとシェルの履歴に残るので、次のように読み込みます。
157
+
158
+ ```bash
159
+ read -s CLOUDFLARE_API_TOKEN && export CLOUDFLARE_API_TOKEN
160
+ ```
161
+
162
+ wrangler も `CLOUDFLARE_API_TOKEN` があればそれを使い、`wrangler login` のログインより優先します。このトークンには Account の権限が無いので、export したまま `deploy` や `wrangler secret put` を実行すると「Failed to automatically retrieve account IDs」で止まります。wrangler のコマンドは、環境変数を外して実行してください。
163
+
164
+ ```bash
165
+ env -u CLOUDFLARE_API_TOKEN pnpm run deploy production
166
+ ```
167
+
168
+ ## メンテナンスに入る・抜ける
169
+
170
+ まず、その environment に Worker がデプロイ済みであること、今回の合言葉に差し替えたこと、いまが通常稼働であることを確かめます。`status` が「メンテナンスのルートは無い」と返せば通常稼働です。
171
+
172
+ ```bash
173
+ pnpm run status production
174
+ ```
175
+
176
+ メンテナンスに入ります。ここから利用者には `503` が返ります。
177
+
178
+ ```bash
179
+ pnpm run on production
180
+ ```
181
+
182
+ 別の Worker が同じパターンを持っていれば、`on` は何も変更せずに止まります。途中で失敗したときは、ルートを数え直して、1 本も付いていないのか、一部だけ付いたのか、全部付いたのかを伝えます。一部だけ付いたときは `status` で確かめ、必要なら `off` を実行してください。Worker がまだデプロイされていなければ、ルートは 1 本も付かず、先にデプロイするよう案内が出ます。
183
+
184
+ 作業者は `https://<ホスト>/?maintenance_bypass=<合言葉>` を開いて Cookie を受け取るか、`ALLOW_IPS` に登録した IP から作業します。IPv6 はアドレスが変わりやすいので、IPv4 で登録するか Cookie を使うほうが確実です。
185
+
186
+ Worker が止めるのは外からの HTTP だけです。サーバー内のキューワーカー、cron、スケジューラーは動き続けるので、作業に応じて自分で止めてください。
187
+
188
+ 作業が終わったら、メンテナンスを抜けて確かめます。
189
+
190
+ ```bash
191
+ pnpm run off production
192
+ pnpm run status production
193
+ ```
194
+
195
+ `status` が再び「メンテナンスのルートは無い」と返すことを確かめ、Cookie を持たないブラウザ(シークレットウィンドウなど)でも開けることを確認します。
196
+
197
+ Webhook の送信元や外形監視にも、メンテナンス中は `503` が返ります。多くの送信元は再送しますが、メンテナンスが長くなるときは再送の上限を確かめておいてください。
198
+
199
+ ### 一部のホストだけをメンテナンスにする
200
+
201
+ environment のうち一部のサイトだけを止めるときは、`on` に `--host` でホストを渡します。`maintenance.config.json` のパターンのうち、そのホストのものだけが付きます。ホストはパターンの最初の `/` より前と、大文字と小文字も区別して完全一致で比べるので、`--host example.com` で `app.example.com` は選ばれません。`*.example.com/*` のようなワイルドカードのパターンは、同じ文字列をシェルでクォートして渡します(`--host '*.example.com'`)。複数のホストは `--host` を繰り返して渡し、あとから別のホストを `on --host` で足すこともできます。
202
+
203
+ ```bash
204
+ pnpm run on production --host app.example.com
205
+ pnpm run status production
206
+ ```
207
+
208
+ `status` は、たとえば `In maintenance: 1 / 3 routes` と出し、付いているルートを並べます。`off` は `--host` を受け付けず、いつも Worker のルートを全部外すので、一部だけのメンテナンスも `off production` 一つで抜けられます。environment のパターンに無いホストを渡すと、`on` は何も変えずに止まります。
209
+
210
+ 一部のホストだけを止めてよいのは、作業がそのサイトに閉じているときだけです。サイトどうしが DB を共有していれば、止めていないサイトは作業中も DB を使い続けます。
211
+
212
+ ## ローカルで確かめる
213
+
214
+ `wrangler.jsonc` と同じディレクトリの `.dev.vars` に secret を書き、Worker を起動します。
215
+
216
+ ```
217
+ BYPASS_SECRET=<合言葉>
218
+ ALLOW_IPS=<許可する IP>
219
+ ```
220
+
221
+ ```bash
222
+ pnpm run dev
223
+ curl -i http://localhost:8787/
224
+ ```
225
+
226
+ `dev` は `develop` environment の vars で動きます。独自の応答を確かめるときは、`-H 'X-Inertia: true'` や `Host` ヘッダーを付けるなど、その条件に合うリクエストを送ります。
227
+
228
+ ## テスト
229
+
230
+ ```bash
231
+ node --test
232
+ ```
233
+
234
+ テストは Node の標準 API だけを使うので、インストールは要りません。
235
+
236
+ ## ライセンス
237
+
238
+ [MIT](LICENSE)
package/README.md CHANGED
@@ -1,3 +1,238 @@
1
- # Temporary Holding Version
1
+ # cloudflare-maintenance-worker
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [日本語](README.ja.md)
4
+
5
+ A Cloudflare Worker that answers with `503` and a maintenance page while you run heavy migrations or other database work, plus the scripts that switch maintenance on and off.
6
+
7
+ The Worker normally receives no traffic at all. Maintenance starts when Workers Routes for your hosts are attached to it, and ends when they are detached. The `maintenance-worker on` and `off` commands do this through the Cloudflare API. Routes are deliberately left out of `wrangler.jsonc`: if they were there, every `wrangler deploy` would attach them and a deploy alone would take the site down. Deploying the Worker and entering maintenance are separate actions on purpose.
8
+
9
+ ## What it does
10
+
11
+ - It answers ordinary requests with the maintenance page as `503`, with `Retry-After` (3600 seconds by default) and `Cache-Control: no-store`, so neither Cloudflare nor browsers keep the page.
12
+ - When some requests need a different answer, such as a JSON error for API clients or a `409` for Inertia.js visits, you can plug in functions that give it. See [Custom responses](#custom-responses).
13
+ - Operators get through with a passphrase or from an allowed IP. Opening any URL with `?maintenance_bypass=<passphrase>` redirects to the same URL without the parameter and sets a cookie that is valid for 12 hours. The redirect keeps the passphrase from staying in the address bar.
14
+
15
+ The name of the parameter, `maintenance_bypass`, is not a secret. Anyone may know it and it lets nobody in without the passphrase. The passphrase is compared in constant time, and the cookie holds a hash derived from it, never the passphrase itself. Because the cookie value is derived from the passphrase and the server keeps no expiry list, changing the passphrase invalidates every cookie issued so far. If no passphrase is set, nobody gets through by passphrase or cookie: the Worker fails closed.
16
+
17
+ `on` refuses to start if another Worker already holds one of the same route patterns, and changes nothing. If another Worker holds a narrower route on one of your hosts (for example `app.example.com/api/*`), those paths keep serving during maintenance, so `on` and `status` print a warning about them.
18
+
19
+ Invocation logs are turned off in `wrangler.jsonc` because they record the request URL, which contains the passphrase in plain text when an operator uses it. Do not turn them back on.
20
+
21
+ ## Setting up
22
+
23
+ The recommended setup keeps only your configuration in your repository and installs the code as a dependency. You need Node 22 or later and pnpm. npm works too, but it takes options such as `--dry-run` for itself, so put `--` before the environment name: `npm run deploy -- develop --dry-run`.
24
+
25
+ Create a directory with these four files.
26
+
27
+ `package.json`:
28
+
29
+ ```json
30
+ {
31
+ "name": "my-maintenance",
32
+ "private": true,
33
+ "type": "module",
34
+ "scripts": {
35
+ "dev": "wrangler dev --env develop",
36
+ "deploy": "maintenance-worker deploy",
37
+ "on": "maintenance-worker on",
38
+ "off": "maintenance-worker off",
39
+ "status": "maintenance-worker status"
40
+ },
41
+ "devDependencies": {
42
+ "cloudflare-maintenance-worker": "github:second-fiddle-jp/cloudflare-maintenance-worker#v0.2.0",
43
+ "wrangler": "4.123.0"
44
+ }
45
+ }
46
+ ```
47
+
48
+ `src/index.js`, which only re-exports the Worker (to answer some requests your own way, see [Custom responses](#custom-responses)):
49
+
50
+ ```js
51
+ export { default } from 'cloudflare-maintenance-worker'
52
+ ```
53
+
54
+ `wrangler.jsonc` and `maintenance.config.json`: copy the samples at the root of this repository and replace the names and hosts. They are described under [Configuration](#configuration).
55
+
56
+ Then install and log in to wrangler:
57
+
58
+ ```bash
59
+ pnpm install
60
+ pnpm exec wrangler login
61
+ ```
62
+
63
+ Pin wrangler to an exact version in your own `devDependencies`. The scripts run the wrangler you installed.
64
+
65
+ You can also clone this repository and edit the samples in place. All commands below work the same in the clone, because its own `package.json` defines the same scripts.
66
+
67
+ ## Configuration
68
+
69
+ Values that belong to your site live in two files: the vars in `wrangler.jsonc` and `maintenance.config.json`. The code has no site-specific values.
70
+
71
+ ### Worker vars and secrets
72
+
73
+ Write vars under `env.<environment>.vars` in `wrangler.jsonc`. wrangler does not inherit top-level `vars` into environments, so write every var in every environment even when the value is the same. Write values as strings; numbers and booleans are converted to strings, so `"RETRY_AFTER": 120` works the same as `"120"`.
74
+
75
+ | Name | Meaning | When unset |
76
+ |---|---|---|
77
+ | `RETRY_AFTER` | Seconds to put in `Retry-After` | `3600` |
78
+ | `BYPASS_COOKIE` | Name of the bypass cookie. Environments that share a cookie domain need different names | `maintenance_bypass` |
79
+ | `COOKIE_DOMAIN` | `Domain` of the bypass cookie, such as `.example.com`, so one cookie works across subdomains | No `Domain`: the cookie works only on the host where the passphrase was used |
80
+ | `BRAND` | Brand name shown on the page and appended to the title as ` \| <BRAND>` | No brand is shown |
81
+ | `PAGE_LANG` | The page's `lang` attribute | `en` |
82
+ | `PAGE_TITLE` | The page title | `Under maintenance` |
83
+ | `PAGE_HEADING` | The page heading | `We are currently down for maintenance` |
84
+ | `PAGE_MESSAGE` | The text under the heading. Newlines become `<br>` | A two-line apology asking visitors to try again later |
85
+ | `BYPASS_SECRET` (secret) | The passphrase | No passphrase or cookie gets through |
86
+ | `ALLOW_IPS` (secret) | IPs let through during maintenance (comma-separated) | No IP is let through |
87
+
88
+ Values inserted into the page are HTML-escaped, so a `<` in a var never becomes a tag. To change more than the text, edit the HTML and CSS in `src/page.js` (in a clone) or render your own page. The HTML is not a separate file because importing `.html` as a Worker module would break `node --test`.
89
+
90
+ ### Custom responses
91
+
92
+ To answer some requests differently during maintenance, pass functions to `createWorker` in `src/index.js`. Each function receives `(request, env, { headers })`. If it returns a `Response`, that is the answer; if it returns `undefined`, the request goes on to the next function and finally to the maintenance page. The functions are called in order. `headers` holds the `Cache-Control` and `Retry-After` the maintenance page uses. Requests from operators, who get through with the cookie or an allowed IP, never reach the functions.
93
+
94
+ The example below makes Inertia.js visits reload the whole page, and gives a JSON-RPC error to an API host.
95
+
96
+ ```js
97
+ import { createWorker } from 'cloudflare-maintenance-worker'
98
+
99
+ // A 503 HTML page returned to an Inertia XHR shows up inside a modal, so make the client reload the whole page with 409
100
+ const inertia = (request) =>
101
+ request.headers.has('X-Inertia')
102
+ ? new Response(null, { status: 409, headers: { 'X-Inertia-Location': request.url, 'Cache-Control': 'no-store' } })
103
+ : undefined
104
+
105
+ // API clients cannot read a page, so give them a JSON-RPC error
106
+ const jsonRpc = (request, env, { headers }) =>
107
+ new URL(request.url).hostname === 'api.example.com'
108
+ ? Response.json({ jsonrpc: '2.0', error: { code: -32000, message: 'Service under maintenance' }, id: null }, { status: 503, headers })
109
+ : undefined
110
+
111
+ export default createWorker({ respond: [inertia, jsonRpc] })
112
+ ```
113
+
114
+ ### maintenance.config.json
115
+
116
+ This file tells the route scripts which zone to use and which route patterns to attach for each environment.
117
+
118
+ ```json
119
+ {
120
+ "zone": "example.com",
121
+ "environments": {
122
+ "develop": { "patterns": ["dev.example.com/*"] },
123
+ "production": { "patterns": ["example.com/*", "www.example.com/*"] }
124
+ }
125
+ }
126
+ ```
127
+
128
+ Patterns are attached in order. List each host explicitly rather than using a wildcard such as `*.example.com/*`, which would also stop hosts you did not mean to stop, such as a public storage bucket.
129
+
130
+ Keep the environment names the same as the `env` keys in `wrangler.jsonc`. The Worker name for each environment is `<name>-<env>` from `wrangler.jsonc` (or the `name` an env overrides), and the scripts derive it the same way. An environment that exists only in `maintenance.config.json`, or has no patterns, stops the scripts with a reason, but only when you operate on that environment. An environment that exists only in `wrangler.jsonc` produces a warning and nothing else. This way a mistake in an unrelated environment never blocks `off` or `status` in the middle of maintenance.
131
+
132
+ The scripts read `wrangler.jsonc` without wrangler. Line comments, block comments and trailing commas are all accepted.
133
+
134
+ ## Deploying and registering secrets
135
+
136
+ Deploy with the `deploy` script and an environment name. Plain `wrangler deploy` would create a Worker named after the top-level `name` with no vars, so `deploy` refuses to run without an environment name, or with an empty one. `pnpm deploy` is a built-in pnpm command, so always write `pnpm run deploy`. Options for `wrangler deploy`, such as `--dry-run`, go after the environment name (`pnpm run deploy develop --dry-run`; with npm, `npm run deploy -- develop --dry-run`).
137
+
138
+ ```bash
139
+ pnpm run deploy develop
140
+ pnpm run deploy production
141
+ ```
142
+
143
+ Deploying attaches no routes, so the site keeps serving normally.
144
+
145
+ Register the passphrase and the allowed IPs as Worker secrets after the first deploy. If the Worker does not exist yet, `wrangler secret put` asks interactively whether to create it.
146
+
147
+ ```bash
148
+ pnpm exec wrangler secret put BYPASS_SECRET --env production
149
+ pnpm exec wrangler secret put ALLOW_IPS --env production
150
+ ```
151
+
152
+ Use a different passphrase for each environment, generated with `openssl rand -hex 32`. Hex characters need no URL encoding. Change the passphrase before every maintenance window; that is what invalidates the cookies handed out last time.
153
+
154
+ ## The API token for routes
155
+
156
+ Attaching and detaching routes calls the Cloudflare API directly, so create a dedicated API token with two permissions, Zone → Workers Routes: Edit and Zone → Zone: Read, limited to your zone. Pass it in the `CLOUDFLARE_API_TOKEN` environment variable. To keep it out of your shell history, read it instead of typing it on the command line:
157
+
158
+ ```bash
159
+ read -s CLOUDFLARE_API_TOKEN && export CLOUDFLARE_API_TOKEN
160
+ ```
161
+
162
+ wrangler also uses `CLOUDFLARE_API_TOKEN` when it is set, ahead of your `wrangler login` session. This token has no account permissions, so with it exported, `deploy` and `wrangler secret put` stop with "Failed to automatically retrieve account IDs". Run wrangler commands without the variable:
163
+
164
+ ```bash
165
+ env -u CLOUDFLARE_API_TOKEN pnpm run deploy production
166
+ ```
167
+
168
+ ## Entering and leaving maintenance
169
+
170
+ First check that the Worker is deployed in that environment, that the passphrase has been changed for this window, and that the site is currently serving normally. `status` should report that there are no maintenance routes.
171
+
172
+ ```bash
173
+ pnpm run status production
174
+ ```
175
+
176
+ Enter maintenance. From here on, visitors get `503`.
177
+
178
+ ```bash
179
+ pnpm run on production
180
+ ```
181
+
182
+ If another Worker holds the same pattern, `on` stops without changing anything. If it fails part way, it counts the routes again and tells you whether none, some or all of them were attached. When only some are attached, check with `status` and run `off` if needed. If the Worker has not been deployed yet, no route is attached and `on` tells you to deploy first.
183
+
184
+ Operators then open `https://<host>/?maintenance_bypass=<passphrase>` to get the cookie, or work from an IP listed in `ALLOW_IPS`. IPv6 addresses change often, so register IPv4 addresses or use the cookie.
185
+
186
+ Remember that the Worker only stops HTTP traffic from outside. Queue workers, cron jobs and schedulers on your servers keep running, so stop them yourself if your work needs it.
187
+
188
+ When the work is done, leave maintenance and confirm:
189
+
190
+ ```bash
191
+ pnpm run off production
192
+ pnpm run status production
193
+ ```
194
+
195
+ `status` should again report no maintenance routes. Open the site in a browser without the cookie, such as a private window, to confirm it serves normally.
196
+
197
+ Webhook senders and uptime monitors also receive `503` during maintenance. Most webhook senders retry, but check their retry limits if the window is long.
198
+
199
+ ### Putting only some hosts into maintenance
200
+
201
+ To stop only some sites of an environment, give their hosts to `on` with `--host`. Only the patterns of those hosts in `maintenance.config.json` are attached. The host is compared with the part of each pattern before the first `/`, exactly and case-sensitively, so `--host example.com` does not select `app.example.com`. For a wildcard pattern such as `*.example.com/*`, pass the same text and quote it in the shell (`--host '*.example.com'`). Repeat `--host` for several hosts, and run `on --host` again later to add another host.
202
+
203
+ ```bash
204
+ pnpm run on production --host app.example.com
205
+ pnpm run status production
206
+ ```
207
+
208
+ `status` then reports, for example, `In maintenance: 1 / 3 routes` and lists the attached routes. `off` takes no `--host` and always detaches every route of the Worker, so a single `off production` ends a partial maintenance too. A host that is not in the patterns of the environment stops `on` before anything is changed.
209
+
210
+ Stopping only some hosts is safe only for work that is confined to those sites. If the sites share a database, the sites left running keep using it during the work.
211
+
212
+ ## Trying it locally
213
+
214
+ Put the secrets in `.dev.vars` next to `wrangler.jsonc` and start the Worker:
215
+
216
+ ```
217
+ BYPASS_SECRET=<passphrase>
218
+ ALLOW_IPS=<allowed IP>
219
+ ```
220
+
221
+ ```bash
222
+ pnpm run dev
223
+ curl -i http://localhost:8787/
224
+ ```
225
+
226
+ `dev` runs with the vars of the `develop` environment. To check a custom response, send a request that matches its condition, for example with `-H 'X-Inertia: true'` or a `Host` header.
227
+
228
+ ## Tests
229
+
230
+ ```bash
231
+ node --test
232
+ ```
233
+
234
+ The tests use Node's standard API only and need no install.
235
+
236
+ ## License
237
+
238
+ [MIT](LICENSE)
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ // maintenance-worker <deploy|on|off|status> <env> [...]
3
+ // Run it in the project directory that holds wrangler.jsonc and maintenance.config.json
4
+ // (pnpm run sets the current directory to the package that defines the script).
5
+
6
+ import { deploy } from '../scripts/deploy.mjs'
7
+ import { routes } from '../scripts/routes.mjs'
8
+
9
+ const USAGE = 'usage: maintenance-worker deploy <env> [wrangler deploy options]\n maintenance-worker <on|off|status> <env> [--host <host> ...]'
10
+
11
+ // pnpm run on -- <env> passes the -- through, so it may come before the command name too
12
+ const argv = process.argv.slice(2)
13
+ const [command, ...rest] = argv[0] === '--' ? argv.slice(1) : argv
14
+
15
+ // Set exitCode instead of calling process.exit, so output piped to another command is flushed before the process ends.
16
+ if (command === 'deploy') {
17
+ process.exitCode = deploy(rest)
18
+ } else if (['on', 'off', 'status'].includes(command)) {
19
+ process.exitCode = await routes([command, ...rest])
20
+ } else {
21
+ console.error(USAGE)
22
+ process.exitCode = 1
23
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "cloudflare-maintenance-worker",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "A Cloudflare Worker that serves a maintenance page, switched on and off by attaching and detaching its routes",
5
+ "license": "MIT",
6
+ "repository": "github:second-fiddle-jp/cloudflare-maintenance-worker",
7
+ "type": "module",
8
+ "main": "src/index.js",
9
+ "bin": {
10
+ "maintenance-worker": "bin/maintenance-worker.mjs"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "scripts",
15
+ "src"
16
+ ],
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "scripts": {
21
+ "test": "node --test",
22
+ "dev": "wrangler dev --env develop",
23
+ "deploy": "node bin/maintenance-worker.mjs deploy",
24
+ "on": "node bin/maintenance-worker.mjs on",
25
+ "off": "node bin/maintenance-worker.mjs off",
26
+ "status": "node bin/maintenance-worker.mjs status"
27
+ },
28
+ "peerDependencies": {
29
+ "wrangler": ">=4"
30
+ },
31
+ "devDependencies": {
32
+ "wrangler": "4.123.0"
33
+ }
34
+ }
@@ -0,0 +1,52 @@
1
+ // Attaches routes one by one for on, and decides what to tell the operator on failure. Does not talk to Cloudflare (the caller passes post).
2
+
3
+ // The Cloudflare API error code returned when attaching a route to a Worker that does not exist.
4
+ export const WORKER_NOT_FOUND = 10019
5
+
6
+ /** The Cloudflare API returned a failure. errors is the API's errors array (empty when the body was not JSON). */
7
+ export class ApiError extends Error {
8
+ constructor(message, errors = []) {
9
+ super(message)
10
+ this.name = 'ApiError'
11
+ this.errors = errors
12
+ }
13
+ }
14
+
15
+ /** Posts each pattern in order. Stops at the first failure and returns the patterns attached so far and the error. */
16
+ export const applyOn = async ({ create, post }) => {
17
+ const created = []
18
+ for (const pattern of create) {
19
+ try {
20
+ await post(pattern)
21
+ } catch (error) {
22
+ return { created, error }
23
+ }
24
+ created.push(pattern)
25
+ }
26
+ return { created, error: undefined }
27
+ }
28
+
29
+ /**
30
+ * The guidance after on fails part way. attached is the number of this Worker's routes counted from a fresh
31
+ * route list after the failure, or undefined when the list could not be fetched. total is the number of target routes.
32
+ * Both count only the target patterns. elsewhere is the number of this Worker's other routes, attached earlier by on --host.
33
+ * A failed POST does not prove the route was not attached (a 502 from a proxy, for example), so the count decides.
34
+ */
35
+ export const describeOnFailure = ({ envName, script, error, attached, total, elsewhere = 0 }) => {
36
+ if (attached === undefined) {
37
+ return `Some hosts may be in maintenance. Check with status ${envName} and run off ${envName} if needed`
38
+ }
39
+ if (attached >= total) {
40
+ return `A failure was returned, but all ${total} routes are attached. Check with status ${envName}`
41
+ }
42
+ if (attached > 0) {
43
+ return `Only some hosts are in maintenance. Check with status ${envName} and run off ${envName} if needed`
44
+ }
45
+ if (error instanceof ApiError && error.errors.some((e) => e.code === WORKER_NOT_FOUND)) {
46
+ return `Worker ${script} has not been deployed yet. No routes were attached (still serving normally). Run deploy ${envName} first, then run on ${envName} again`
47
+ }
48
+ if (elsewhere > 0) {
49
+ return `No routes were attached for the selected hosts. Routes attached earlier are still attached (${elsewhere}), so those hosts stay in maintenance. Check with status ${envName} and run off ${envName} if needed`
50
+ }
51
+ return 'No routes were attached (still serving normally)'
52
+ }
@@ -0,0 +1,15 @@
1
+ // Builds the wrangler arguments from the arguments given to deploy. Does not run wrangler.
2
+
3
+ export const DEPLOY_USAGE = 'usage: deploy <env> [wrangler deploy options]'
4
+
5
+ /**
6
+ * Passes the first argument to --env as the environment name and appends the rest as is.
7
+ * A leading -- is skipped (pnpm passes it through for pnpm run deploy -- <env>).
8
+ * Throws when the environment name is missing, empty or an option: plain wrangler deploy and --env ""
9
+ * would create a Worker without vars. wrangler itself checks that the name exists in wrangler.jsonc.
10
+ */
11
+ export const buildDeployArgs = (argv) => {
12
+ const [envName, ...rest] = argv[0] === '--' ? argv.slice(1) : argv
13
+ if (envName === undefined || envName === '' || envName.startsWith('-')) throw new Error(DEPLOY_USAGE)
14
+ return ['deploy', '--env', envName, ...rest]
15
+ }
@@ -0,0 +1,43 @@
1
+ // Deploys the Worker. Requires an environment name and runs wrangler deploy.
2
+ // Uses Node's standard API only.
3
+
4
+ import { spawnSync } from 'node:child_process'
5
+ import { readFileSync } from 'node:fs'
6
+ import { createRequire } from 'node:module'
7
+ import { dirname, join } from 'node:path'
8
+ import { buildDeployArgs } from './deploy-args.mjs'
9
+
10
+ /** Finds wrangler's bin from the project's own dependencies first, then from this package's. */
11
+ const resolveWrangler = (dir) => {
12
+ let manifestPath
13
+ for (const from of [join(dir, 'package.json'), import.meta.url]) {
14
+ try {
15
+ manifestPath = createRequire(from).resolve('wrangler/package.json')
16
+ break
17
+ } catch {}
18
+ }
19
+ if (manifestPath === undefined) throw new Error('wrangler is not installed. Add it to devDependencies')
20
+ const { bin } = JSON.parse(readFileSync(manifestPath, 'utf8'))
21
+ return join(dirname(manifestPath), bin.wrangler)
22
+ }
23
+
24
+ /** Runs deploy and returns the exit code. dir is the project directory (where wrangler.jsonc is). */
25
+ export const deploy = (argv, dir = process.cwd()) => {
26
+ let args
27
+ let wrangler
28
+ try {
29
+ args = buildDeployArgs(argv)
30
+ wrangler = resolveWrangler(dir)
31
+ } catch (error) {
32
+ console.error(error.message)
33
+ return 1
34
+ }
35
+
36
+ // Run wrangler's bin with node directly. No shell is involved, so arguments pass through unchanged on Windows too.
37
+ const result = spawnSync(process.execPath, [wrangler, ...args], { stdio: 'inherit', cwd: dir })
38
+ if (result.error) {
39
+ console.error(result.error.message)
40
+ return 1
41
+ }
42
+ return result.status ?? 1
43
+ }
@@ -0,0 +1,42 @@
1
+ // The Worker name and the routes to attach during maintenance, per environment.
2
+ // The zone and hosts live in maintenance.config.json. The Worker name is wrangler.jsonc's name plus the environment name.
3
+ import { readFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { readWranglerConfig, stripBom } from './wrangler-config.mjs'
6
+
7
+ const isNonEmptyString = (value) => typeof value === 'string' && value !== ''
8
+
9
+ /**
10
+ * Builds the environments from the config and wrangler.jsonc.
11
+ * Mismatches and mistakes are returned per environment as problems / warnings instead of throwing,
12
+ * so a mistake in an unrelated environment cannot block off or status in the middle of maintenance.
13
+ */
14
+ export const buildEnvironments = (config, wrangler) => {
15
+ if (!isNonEmptyString(config.zone)) throw new Error('zone is not set in maintenance.config.json')
16
+
17
+ const deployed = wrangler.env ?? {}
18
+ const environments = {}
19
+ const problems = {}
20
+ for (const [name, { patterns } = {}] of Object.entries(config.environments ?? {})) {
21
+ if (!(name in deployed)) {
22
+ problems[name] = `${name}: in maintenance.config.json but not in the env of wrangler.jsonc`
23
+ } else if (!Array.isArray(patterns) || patterns.length === 0 || !patterns.every(isNonEmptyString)) {
24
+ problems[name] = `${name}: patterns in maintenance.config.json must be an array of one or more patterns`
25
+ } else {
26
+ // When an env overrides name, wrangler deploys under that name.
27
+ environments[name] = { script: deployed[name].name ?? `${wrangler.name}-${name}`, patterns }
28
+ }
29
+ }
30
+
31
+ const warnings = Object.keys(deployed)
32
+ .filter((name) => !(name in (config.environments ?? {})))
33
+ .map((name) => `${name}: in the env of wrangler.jsonc but not in maintenance.config.json (its routes cannot be managed)`)
34
+
35
+ return { zone: config.zone, environments, problems, warnings }
36
+ }
37
+
38
+ /** Reads maintenance.config.json and wrangler.jsonc in dir (the project directory; defaults to the current directory). */
39
+ export const loadEnvironments = (dir = process.cwd()) => {
40
+ const config = JSON.parse(stripBom(readFileSync(join(dir, 'maintenance.config.json'), 'utf8')))
41
+ return buildEnvironments(config, readWranglerConfig(dir))
42
+ }
@@ -0,0 +1,25 @@
1
+ // Decides which routes on / off create and delete. Does not talk to Cloudflare.
2
+
3
+ /** Creates only the patterns that do not exist yet. A pattern held by another Worker is returned in conflicts. */
4
+ export const planOn = (existing, { script, patterns }) => {
5
+ const conflicts = existing.filter((route) => patterns.includes(route.pattern) && route.script !== script)
6
+ const taken = new Set(existing.filter((route) => patterns.includes(route.pattern)).map((route) => route.pattern))
7
+ return { create: patterns.filter((pattern) => !taken.has(pattern)), conflicts }
8
+ }
9
+
10
+ /** Returns only this maintenance Worker's routes. Routes of other Workers are never touched. */
11
+ export const planOff = (existing, { script }) => existing.filter((route) => route.script === script)
12
+
13
+ /** Routes of other Workers on the target hosts. Those paths keep serving during maintenance, so they are warned about. */
14
+ export const findShadowingRoutes = (existing, { script, patterns }) => {
15
+ const hosts = new Set(patterns.map(hostOf))
16
+ return existing.filter((route) => route.script !== script && hosts.has(hostOf(route.pattern)))
17
+ }
18
+
19
+ const hostOf = (pattern) => pattern.split('/')[0]
20
+
21
+ /** Keeps the patterns whose host is one of hosts, in the order of the config. Hosts that no pattern has are returned in unknown. */
22
+ export const selectHosts = (patterns, hosts) => ({
23
+ patterns: patterns.filter((pattern) => hosts.includes(hostOf(pattern))),
24
+ unknown: hosts.filter((host) => !patterns.some((pattern) => hostOf(pattern) === host)),
25
+ })
@@ -0,0 +1,166 @@
1
+ // Attaches and detaches the maintenance Worker's routes. The site is in maintenance while the routes are attached.
2
+ // The API token comes from the CLOUDFLARE_API_TOKEN environment variable (Zone: Workers Routes Edit / Zone Read).
3
+ // Uses Node's standard API only.
4
+
5
+ import { loadEnvironments } from './environments.mjs'
6
+ import { ApiError, applyOn, describeOnFailure } from './apply-on.mjs'
7
+ import { findShadowingRoutes, planOff, planOn, selectHosts } from './route-plan.mjs'
8
+
9
+ const API = 'https://api.cloudflare.com/client/v4'
10
+
11
+ class Failure extends Error {}
12
+
13
+ const fail = (message) => {
14
+ throw new Failure(message)
15
+ }
16
+
17
+ /**
18
+ * Splits the options after the environment name. Only --host <host> / --host=<host> exists, and it may be repeated.
19
+ * Returns the hosts, or the reason the options cannot be used.
20
+ */
21
+ export const parseOptions = (options) => {
22
+ const hosts = []
23
+ for (let i = 0; i < options.length; i++) {
24
+ const option = options[i]
25
+ if (option === '--host') {
26
+ const host = options[++i]
27
+ if (host === undefined || host === '' || host.startsWith('-')) return { error: '--host needs a host, such as --host app.example.com' }
28
+ hosts.push(host)
29
+ } else if (option.startsWith('--host=')) {
30
+ const host = option.slice('--host='.length)
31
+ if (host === '') return { error: '--host needs a host, such as --host app.example.com' }
32
+ hosts.push(host)
33
+ } else {
34
+ return { error: option.startsWith('-') ? `Unknown option: ${option}` : `Unexpected argument: ${option}` }
35
+ }
36
+ }
37
+ return { hosts }
38
+ }
39
+
40
+ /**
41
+ * Runs on / off / status and returns the exit code.
42
+ * argv is [command, env, ...options]; a -- anywhere is skipped (pnpm passes it through for pnpm run on -- <env>).
43
+ * on --host <host> attaches only the patterns of that host; off always detaches every route of this Worker.
44
+ * dir is the project directory (where maintenance.config.json and wrangler.jsonc are).
45
+ */
46
+ export const routes = async (argv, dir = process.cwd()) => {
47
+ try {
48
+ await run(argv, dir)
49
+ return 0
50
+ } catch (error) {
51
+ console.error(error.message)
52
+ return 1
53
+ }
54
+ }
55
+
56
+ const run = async (argv, dir) => {
57
+ const { zone: zoneName, environments, problems, warnings } = loadEnvironments(dir)
58
+ for (const warning of warnings) console.error(`[warn] ${warning}`)
59
+
60
+ const [command, envName, ...options] = argv.filter((arg) => arg !== '--')
61
+ if (problems[envName] !== undefined) fail(`${envName} cannot be operated on because of a configuration error:\n ${problems[envName]}`)
62
+ const target = environments[envName]
63
+ const usage = `usage: <on|off|status> <${Object.keys(environments).join('|')}> [--host <host> ...]`
64
+ if (envName?.startsWith('-')) fail(`Options go after the environment name.\n${usage}`)
65
+ if (!['on', 'off', 'status'].includes(command) || target === undefined) fail(usage)
66
+
67
+ const { hosts, error: optionError } = parseOptions(options)
68
+ if (optionError !== undefined) fail(`${optionError}\n${usage}`)
69
+ if (hosts.length > 0 && command !== 'on') {
70
+ const offNote = command === 'off' ? ` off always detaches every route of ${target.script}.` : ''
71
+ fail(`--host can only be used with on, not ${command}.${offNote}\n${usage}`)
72
+ }
73
+ const selected = selectHosts(target.patterns, hosts)
74
+ if (selected.unknown.length > 0) {
75
+ const verb = selected.unknown.length === 1 ? 'is' : 'are'
76
+ fail(`${selected.unknown.join(', ')} ${verb} not in the patterns of ${envName} in maintenance.config.json:\n${target.patterns.map((p) => ` ${p}`).join('\n')}`)
77
+ }
78
+ // The routes on attaches: every pattern, or only those of the hosts given with --host.
79
+ const onTarget = hosts.length > 0 ? { ...target, patterns: selected.patterns } : target
80
+
81
+ const token = process.env.CLOUDFLARE_API_TOKEN
82
+ if (!token) fail('CLOUDFLARE_API_TOKEN is not set (see the README)')
83
+
84
+ const api = async (method, path, body) => {
85
+ const res = await fetch(`${API}${path}`, {
86
+ method,
87
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
88
+ body: body === undefined ? undefined : JSON.stringify(body),
89
+ })
90
+ const isJson = (res.headers.get('content-type') ?? '').includes('json')
91
+ const json = isJson ? await res.json().catch(() => undefined) : undefined
92
+ if (!res.ok || json === undefined) {
93
+ const detail = json === undefined ? '' : ` ${JSON.stringify(json.errors)}`
94
+ throw new ApiError(`${method} ${path} failed: HTTP ${res.status}${detail}`, json?.errors ?? [])
95
+ }
96
+ if (!json.success) throw new ApiError(`${method} ${path} failed: ${JSON.stringify(json.errors)}`, json.errors ?? [])
97
+ return json.result
98
+ }
99
+
100
+ const zones = await api('GET', `/zones?name=${encodeURIComponent(zoneName)}`)
101
+ if (zones.length === 0) fail(`Zone ${zoneName} not found (check the token's Zone Read permission)`)
102
+ const zoneId = zones[0].id
103
+ const listRoutes = () => api('GET', `/zones/${zoneId}/workers/routes`)
104
+
105
+ const printStatus = async () => {
106
+ const all = await listRoutes()
107
+ const ours = planOff(all, target)
108
+ // While in maintenance, narrower routes matter only on the hosts that are in it. While serving normally, check every host as a preflight.
109
+ const watched = ours.length > 0 ? { ...target, patterns: ours.map((route) => route.pattern) } : target
110
+ const shadowing = findShadowingRoutes(all, watched)
111
+ if (shadowing.length > 0) {
112
+ console.log('[warn] These routes belong to other Workers and keep serving during maintenance:')
113
+ for (const route of shadowing) console.log(` ${route.pattern} -> ${route.script}`)
114
+ }
115
+ if (ours.length === 0) {
116
+ console.log(`[${envName}] No maintenance routes (serving normally)`)
117
+ return
118
+ }
119
+ // Count only the patterns of the config, so routes left from an older config do not give counts like 4 / 3.
120
+ const configured = ours.filter((route) => target.patterns.includes(route.pattern))
121
+ const leftover = ours.filter((route) => !target.patterns.includes(route.pattern))
122
+ console.log(`[${envName}] In maintenance: ${configured.length} / ${target.patterns.length} routes`)
123
+ for (const route of configured) console.log(` ${route.pattern} -> ${route.script}`)
124
+ if (leftover.length > 0) {
125
+ console.log(`[warn] Routes of this Worker that are not in maintenance.config.json (off detaches them too):`)
126
+ for (const route of leftover) console.log(` ${route.pattern} -> ${route.script}`)
127
+ }
128
+ }
129
+
130
+ if (command === 'on') {
131
+ const existing = await listRoutes()
132
+ const { create, conflicts } = planOn(existing, onTarget)
133
+ if (conflicts.length > 0) {
134
+ fail(`Another Worker holds the same routes. Nothing was changed:\n${conflicts.map((r) => ` ${r.pattern} -> ${r.script}`).join('\n')}`)
135
+ }
136
+ const { error } = await applyOn({
137
+ create,
138
+ post: async (pattern) => {
139
+ await api('POST', `/zones/${zoneId}/workers/routes`, { pattern, script: onTarget.script })
140
+ console.log(`[add] ${pattern}`)
141
+ },
142
+ })
143
+ if (error !== undefined) {
144
+ // A failure response may still have attached the route, so count from a fresh list. If that fails too, treat it as unknown.
145
+ // With --host, count only the patterns this on tried to attach and report routes attached earlier separately.
146
+ // Without --host, count every route of this Worker, as before.
147
+ const counts = await listRoutes().then(
148
+ (all) => {
149
+ const ours = planOff(all, onTarget)
150
+ if (hosts.length === 0) return { attached: ours.length }
151
+ const attached = ours.filter((route) => onTarget.patterns.includes(route.pattern)).length
152
+ return { attached, elsewhere: ours.length - attached }
153
+ },
154
+ () => ({ attached: undefined }),
155
+ )
156
+ fail(`${error.message}\n${describeOnFailure({ envName, script: onTarget.script, error, total: onTarget.patterns.length, ...counts })}`)
157
+ }
158
+ } else if (command === 'off') {
159
+ for (const route of planOff(await listRoutes(), target)) {
160
+ await api('DELETE', `/zones/${zoneId}/workers/routes/${route.id}`)
161
+ console.log(`[remove] ${route.pattern}`)
162
+ }
163
+ }
164
+
165
+ await printStatus()
166
+ }
@@ -0,0 +1,63 @@
1
+ // Reads wrangler.jsonc into plain JSON with Node's standard API only (no dependency on wrangler).
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ /** Walks text, calling onOutside for each character outside a string; string contents are copied as is. */
6
+ const scan = (text, onOutside) => {
7
+ let out = ''
8
+ let inString = false
9
+ for (let i = 0; i < text.length; i++) {
10
+ const ch = text[i]
11
+ if (inString) {
12
+ out += ch
13
+ if (ch === '\\') out += text[++i] ?? ''
14
+ else if (ch === '"') inString = false
15
+ } else if (ch === '"') {
16
+ inString = true
17
+ out += ch
18
+ } else {
19
+ const result = onOutside(text, i)
20
+ out += result.text
21
+ i = result.next - 1
22
+ }
23
+ }
24
+ return out
25
+ }
26
+
27
+ /** Drops // line comments and /* *\/ block comments outside strings. */
28
+ export const stripComments = (text) =>
29
+ scan(text, (s, i) => {
30
+ if (s[i] === '/' && s[i + 1] === '/') {
31
+ const end = s.indexOf('\n', i)
32
+ return { text: '\n', next: end === -1 ? s.length : end + 1 }
33
+ }
34
+ if (s[i] === '/' && s[i + 1] === '*') {
35
+ const end = s.indexOf('*/', i + 2)
36
+ if (end === -1) throw new Error('unterminated block comment')
37
+ // Keep the newlines so JSON.parse still reports useful line numbers.
38
+ return { text: s.slice(i, end + 2).replace(/[^\n]/g, ''), next: end + 2 }
39
+ }
40
+ return { text: s[i], next: i + 1 }
41
+ })
42
+
43
+ /** Drops a comma that is followed only by whitespace and a closing } or ]. Run after stripComments. */
44
+ export const stripTrailingCommas = (text) =>
45
+ scan(text, (s, i) => {
46
+ if (s[i] === ',' && /^\s*[}\]]/.test(s.slice(i + 1))) return { text: '', next: i + 1 }
47
+ return { text: s[i], next: i + 1 }
48
+ })
49
+
50
+ /** Drops a leading byte order mark, which some editors on Windows write and JSON.parse rejects. */
51
+ export const stripBom = (text) => text.replace(/^\uFEFF/, '')
52
+
53
+ /** Parses the contents of wrangler.jsonc. Accepts // and /* *\/ comments and trailing commas, as wrangler does. */
54
+ export const parseWranglerConfig = (text) => {
55
+ try {
56
+ return JSON.parse(stripTrailingCommas(stripComments(stripBom(text))))
57
+ } catch (error) {
58
+ throw new Error(`Cannot read wrangler.jsonc: ${error.message}`)
59
+ }
60
+ }
61
+
62
+ /** Reads wrangler.jsonc in dir (the project directory; defaults to the current directory). */
63
+ export const readWranglerConfig = (dir = process.cwd()) => parseWranglerConfig(readFileSync(join(dir, 'wrangler.jsonc'), 'utf8'))
package/src/bypass.js ADDED
@@ -0,0 +1,77 @@
1
+ // Decides which requests are let through to the origin during maintenance.
2
+ // The passphrase is the Worker secret BYPASS_SECRET; allowed IPs are the secret ALLOW_IPS (comma-separated).
3
+ // With COOKIE_DOMAIN empty the cookie is host-only and does not work across subdomains.
4
+
5
+ const BYPASS_PARAM = 'maintenance_bypass'
6
+ const COOKIE_MAX_AGE = 60 * 60 * 12
7
+ const DEFAULT_COOKIE_NAME = 'maintenance_bypass'
8
+
9
+ const digest = async (value) =>
10
+ new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)))
11
+
12
+ const toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
13
+
14
+ /** Hashes both sides with SHA-256 so the lengths match, then compares in constant time. */
15
+ const safeEqual = async (a, b) => {
16
+ const [x, y] = await Promise.all([digest(a), digest(b)])
17
+ let diff = 0
18
+ for (let i = 0; i < x.length; i += 1) diff |= x[i] ^ y[i]
19
+ return diff === 0
20
+ }
21
+
22
+ const parseList = (value) =>
23
+ String(value ?? '')
24
+ .split(',')
25
+ .map((item) => item.trim())
26
+ .filter((item) => item !== '')
27
+
28
+ /** The same name is used to issue and to check the cookie. Falls back to the default when BYPASS_COOKIE is empty. */
29
+ const cookieName = (env) => String(env.BYPASS_COOKIE || DEFAULT_COOKIE_NAME)
30
+
31
+ const readCookie = (header, name) => {
32
+ for (const part of (header ?? '').split(';')) {
33
+ const index = part.indexOf('=')
34
+ if (index === -1) continue
35
+ if (part.slice(0, index).trim() === name) return part.slice(index + 1).trim()
36
+ }
37
+ return null
38
+ }
39
+
40
+ /** The cookie value. It never contains the passphrase itself. */
41
+ export const bypassCookieValue = async (secret) => toHex(await digest(`cookie:${secret}`))
42
+
43
+ /** For a URL carrying the correct passphrase, returns a redirect to the URL without it, plus the cookie. Otherwise null. */
44
+ export const bypassRedirect = async (request, env) => {
45
+ const secret = env.BYPASS_SECRET ?? ''
46
+ const url = new URL(request.url)
47
+ const given = url.searchParams.get(BYPASS_PARAM)
48
+ if (secret === '' || given === null || !(await safeEqual(given, secret))) return null
49
+
50
+ url.searchParams.delete(BYPASS_PARAM)
51
+ const domain = String(env.COOKIE_DOMAIN ?? '')
52
+ const cookie = [
53
+ `${cookieName(env)}=${await bypassCookieValue(secret)}`,
54
+ ...(domain === '' ? [] : [`Domain=${domain}`]),
55
+ 'Path=/',
56
+ `Max-Age=${COOKIE_MAX_AGE}`,
57
+ 'HttpOnly',
58
+ 'Secure',
59
+ 'SameSite=Lax',
60
+ ].join('; ')
61
+
62
+ return new Response(null, {
63
+ status: 302,
64
+ headers: { Location: url.toString(), 'Set-Cookie': cookie, 'Cache-Control': 'no-store' },
65
+ })
66
+ }
67
+
68
+ /** True for an allowed IP or a request carrying the issued cookie. */
69
+ export const shouldPassThrough = async (request, env) => {
70
+ const ip = request.headers.get('CF-Connecting-IP')
71
+ if (ip !== null && parseList(env.ALLOW_IPS).includes(ip)) return true
72
+
73
+ const secret = env.BYPASS_SECRET ?? ''
74
+ if (secret === '') return false
75
+ const cookie = readCookie(request.headers.get('Cookie'), cookieName(env))
76
+ return cookie !== null && (await safeEqual(cookie, await bypassCookieValue(secret)))
77
+ }
package/src/handler.js ADDED
@@ -0,0 +1,30 @@
1
+ import { bypassRedirect, shouldPassThrough } from './bypass.js'
2
+ import { renderMaintenancePage } from './page.js'
3
+
4
+ const DEFAULT_RETRY_AFTER = '3600'
5
+
6
+ /** The headers every maintenance response carries. Neither Cloudflare nor the browser may cache it. */
7
+ const maintenanceHeaders = (env) => ({ 'Cache-Control': 'no-store', 'Retry-After': String(env.RETRY_AFTER || DEFAULT_RETRY_AFTER) })
8
+
9
+ /**
10
+ * Answers a request: the passphrase redirect, operators to the origin, everyone else the maintenance page.
11
+ * respond holds functions that may answer instead of the page, such as a JSON error for API clients.
12
+ * Each gets (request, env, { headers }) and returns a Response, or undefined / null to leave the request to the next one.
13
+ * They are consulted only for requests that would get the page, never for operators.
14
+ */
15
+ export const handle = async (request, env, upstream = fetch, respond = []) => {
16
+ const redirect = await bypassRedirect(request, env)
17
+ if (redirect !== null) return redirect
18
+
19
+ if (await shouldPassThrough(request, env)) return upstream(request)
20
+
21
+ for (const answer of respond) {
22
+ const response = await answer(request, env, { headers: maintenanceHeaders(env) })
23
+ if (response != null) return response
24
+ }
25
+
26
+ return new Response(renderMaintenancePage(env), {
27
+ status: 503,
28
+ headers: { ...maintenanceHeaders(env), 'Content-Type': 'text/html; charset=utf-8' },
29
+ })
30
+ }
package/src/index.js ADDED
@@ -0,0 +1,14 @@
1
+ import { handle } from './handler.js'
2
+
3
+ /**
4
+ * Builds the Worker. Pass respond to answer some requests your own way during maintenance
5
+ * (for example a JSON error for API clients); see handle in handler.js for how the functions are called.
6
+ */
7
+ export const createWorker = ({ respond = [] } = {}) => {
8
+ if (!Array.isArray(respond) || !respond.every((answer) => typeof answer === 'function')) {
9
+ throw new TypeError('respond must be an array of functions')
10
+ }
11
+ return { fetch: (request, env) => handle(request, env, undefined, respond) }
12
+ }
13
+
14
+ export default createWorker()
package/src/page.js ADDED
@@ -0,0 +1,53 @@
1
+ // The maintenance page. CSS is inlined so the page renders even while the origin is down.
2
+ // The text comes from vars (PAGE_LANG, BRAND, PAGE_TITLE, PAGE_HEADING, PAGE_MESSAGE). Edit this file to change the look.
3
+
4
+ const DEFAULT_LANG = 'en'
5
+ const DEFAULT_TITLE = 'Under maintenance'
6
+ const DEFAULT_HEADING = 'We are currently down for maintenance'
7
+ const DEFAULT_MESSAGE = 'We apologize for the inconvenience.\nPlease try again in a little while.'
8
+
9
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
10
+
11
+ const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => ESCAPES[char])
12
+
13
+ // An empty string counts as unset, so leaving a var empty still shows the default.
14
+ // wrangler can pass numbers and booleans as vars, so values are turned into strings first.
15
+ const pick = (value, fallback) => (String(value ?? '') === '' ? fallback : String(value))
16
+
17
+ /** Builds the maintenance page HTML from the env vars. */
18
+ export const renderMaintenancePage = (env) => {
19
+ const brand = String(env.BRAND ?? '')
20
+ const lang = escapeHtml(pick(env.PAGE_LANG, DEFAULT_LANG))
21
+ const baseTitle = pick(env.PAGE_TITLE, DEFAULT_TITLE)
22
+ const title = escapeHtml(brand === '' ? baseTitle : `${baseTitle} | ${brand}`)
23
+ const brandLine = brand === '' ? '' : ` <div class="brand">${escapeHtml(brand)}</div>\n`
24
+ const heading = escapeHtml(pick(env.PAGE_HEADING, DEFAULT_HEADING))
25
+ // Escape first, then turn newlines into <br>. The other way round would escape the <br> too.
26
+ const message = escapeHtml(pick(env.PAGE_MESSAGE, DEFAULT_MESSAGE)).replace(/\r?\n/g, '<br>')
27
+
28
+ return `<!doctype html>
29
+ <html lang="${lang}">
30
+ <head>
31
+ <meta charset="utf-8">
32
+ <meta name="viewport" content="width=device-width, initial-scale=1">
33
+ <title>${title}</title>
34
+ <style>
35
+ body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
36
+ background: #f5f5f5; color: #1f2937; padding: 24px; box-sizing: border-box;
37
+ font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", "Yu Gothic", sans-serif; }
38
+ main { max-width: 560px; width: 100%; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
39
+ padding: 40px 32px; text-align: center; }
40
+ h1 { font-size: 22px; margin: 0 0 16px; }
41
+ p { margin: 0; line-height: 1.8; color: #6b7280; font-size: 15px; }
42
+ .brand { font-weight: 700; letter-spacing: 0.04em; margin-bottom: 24px; }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <main>
47
+ ${brandLine} <h1>${heading}</h1>
48
+ <p>${message}</p>
49
+ </main>
50
+ </body>
51
+ </html>
52
+ `
53
+ }