shabbat-gate 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -3,7 +3,73 @@
3
3
  All notable changes to this package are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/).
5
5
 
6
- ## [0.3.0] - Unreleased
6
+ ## [0.4.0] - Unreleased
7
+
8
+ ### Fixed
9
+
10
+ - **A *slow* Hebcal took a consumer's site down with 504s - the exact opposite of this
11
+ package's fail-open design.** Found in production on 2026-07-28 on `primestack.co.il`: 24h of
12
+ Cloudflare zone analytics showed 14 homepage `504`s, clustered in the same hours as 53 `504`s
13
+ on the internal cache key `/shabbat-gate-windows-v1`. Nothing else on the site 504'd.
14
+
15
+ Root cause: `fetchWindows` did a plain `await fetch(url)` with no timeout and no
16
+ `AbortSignal`. The gate wraps everything in a `try/catch` that fails open - **but a
17
+ `try/catch` only rescues a rejection, never a hang.** When Hebcal was slow rather than down,
18
+ the fetch never settled, the Worker invocation ran past its limit, and Cloudflare returned
19
+ 504 to the visitor; the catch never ran, so "fail open" never happened. And because it was a
20
+ cache miss, *every* concurrent visitor started their own fetch - a thundering herd, which is
21
+ why the 504s arrived in clusters rather than one at a time.
22
+
23
+ Four changes, so that a slow or broken upstream can never again reach the visitor:
24
+ - **`fetchWindows` now aborts** via `AbortController` after `timeoutMs` (new
25
+ `FetchWindowsOptions` field, default 3000; exported as `DEFAULT_HEBCAL_TIMEOUT_MS`),
26
+ rejecting with a distinct `HebcalTimeoutError` so the failure mode is greppable in logs. A
27
+ real abort rather than a `Promise.race`, which would leave the underlying request dangling.
28
+ The signal stays armed across `res.json()` too - a stalled body stream hangs an invocation
29
+ just as effectively as a stalled connection.
30
+ - **Stale-while-revalidate.** A cached window list is now served past its 24h freshness
31
+ window (up to 7 days) while it refreshes in the background via `waitUntil`, so once the
32
+ cache is warm no visitor ever waits on Hebcal at all. Windows are fetched 45 days ahead, so
33
+ a week-old list is still correct for the next ~38 days.
34
+ - **Failures are cached for 60s**, so a struggling upstream gets one retry per minute instead
35
+ of one per visitor.
36
+ - **Concurrent fetches are deduped** per cache key within an isolate, closing the cold-start
37
+ herd the Cache API alone can't (it only helps once a fetch has *finished*).
38
+
39
+ Regression tests stub `fetch` with a promise that never resolves and assert the gate returns
40
+ the real site rather than hanging - both tests hang and fail against the old code.
41
+
42
+ ### Added
43
+
44
+ - `ShabbatGateConfig.hebcalTimeoutMs` (default 3000) - how long to wait for Hebcal before
45
+ giving up and failing open. Only ever paid on a cold cache.
46
+ - `createShabbatGateForWorker`'s handler now takes an optional second argument, the Worker's
47
+ `ctx`: `gate(request, ctx)`. Passing it lets a stale window list refresh *after* the response
48
+ is sent instead of the refresh being cancelled when the invocation ends. Backward compatible -
49
+ existing `gate(request)` calls keep working, they just lose background revalidation.
50
+ `createShabbatGate` (Pages) wires this up automatically from its own `context`.
51
+
52
+ ### Changed
53
+
54
+ - **The internal cache key moved to `...-windows-v2`** (and `...-visitor-v2`), because cache
55
+ entries now carry their fetch timestamp and failure state rather than being a bare window
56
+ array. Consumers reading `INTERNAL_CACHE_KEY_URL` are unaffected; anyone who hardcoded the
57
+ `v1` string will now be reading a key nothing writes to.
58
+
59
+ ## [0.3.1] - Unreleased (never published separately; folded into 0.4.0)
60
+
61
+ ### Documentation
62
+
63
+ - Documented middleware **chaining** for Cloudflare Pages sites that already have a root
64
+ `functions/_middleware`. Cloudflare runs only one root middleware file - two (`_middleware.js`
65
+ **and** `_middleware.ts`) means one is silently ignored. Since `gate` already calls
66
+ `context.next()`, it composes as-is: export the root `onRequest` as an array
67
+ (`export const onRequest = [noindex, (context) => gate(context)]`) and Cloudflare runs them in
68
+ order. No code change - the gate was already a composable middleware primitive; this just adds
69
+ the README section (both languages) so consumers don't add a clashing second file. Found live
70
+ installing on eyalmeshulam.com (which already had a preview-noindex middleware).
71
+
72
+ ## [0.3.0] - 2026-07-19
7
73
 
8
74
  ### Added
9
75
 
package/README.he.md CHANGED
@@ -37,6 +37,35 @@ const gate = createShabbatGate({ siteName: 'שם האתר שלי' });
37
37
  export const onRequest: PagesFunction = (context) => gate(context);
38
38
  ```
39
39
 
40
+ ### כשכבר קיים `functions/_middleware` באתר
41
+
42
+ Cloudflare Pages מריץ **קובץ middleware אחד בלבד** בשורש `functions/`. אם קיימים שניים
43
+ (`_middleware.js` **וגם** `_middleware.ts`), Cloudflare בוחר אחד בשקט והשני פשוט לא רץ - בלי
44
+ שגיאה, בלי אזהרה. לכן **אין ליצור קובץ שני**. `gate` קורא בעצמו ל-`context.next()`, כלומר הוא
45
+ כבר primitive שמשתלב בשרשרת: מייצאים את ה-`onRequest` בשורש כ**מערך** של handlers,
46
+ ו-Cloudflare מריץ אותם לפי הסדר כשכל אחד קורא `next()`. לדוגמה, שרשור שומר-noindex לפריוויו
47
+ לפני השער:
48
+
49
+ ```js
50
+ import { createShabbatGate } from 'shabbat-gate';
51
+
52
+ const PROD_HOSTS = new Set(['example.com', 'www.example.com']);
53
+
54
+ const noindex = async ({ request, next }) => {
55
+ const res = await next();
56
+ if (PROD_HOSTS.has(new URL(request.url).hostname)) return res;
57
+ const tagged = new Response(res.body, res);
58
+ tagged.headers.set('X-Robots-Tag', 'noindex, nofollow');
59
+ return tagged;
60
+ };
61
+
62
+ const gate = createShabbatGate({ siteName: 'שם האתר שלי' });
63
+
64
+ export const onRequest = [noindex, (context) => gate(context)];
65
+ ```
66
+
67
+ אין צורך לעטוף `next()` ידנית - השער משתלב בשרשרת כמו שהוא.
68
+
40
69
  ### שימוש עם Worker רגיל + assets binding (לא Pages)
41
70
 
42
71
  `createShabbatGate` מחזירה handler בצורה של Pages Functions (`(context) => Response`), וזה
@@ -50,13 +79,17 @@ import { createShabbatGateForWorker } from 'shabbat-gate';
50
79
  const gate = createShabbatGateForWorker({ siteName: 'שם האתר שלי' });
51
80
 
52
81
  export default {
53
- async fetch(request: Request, env: { ASSETS: Fetcher }) {
54
- const blocked = await gate(request);
82
+ async fetch(request: Request, env: { ASSETS: Fetcher }, ctx: ExecutionContext) {
83
+ const blocked = await gate(request, ctx);
55
84
  return blocked ?? env.ASSETS.fetch(request);
56
85
  },
57
86
  };
58
87
  ```
59
88
 
89
+ העברת `ctx` היא אופציונלית אבל מומלצת: זה מה שמאפשר לרשימת חלונות שפג תוקפה להתרענן **אחרי**
90
+ ששלחנו את התשובה (ראו [עמידות](#עמידות-timeout-ו-stale-while-revalidate)) במקום שהרענון יבוטל
91
+ כשההרצה מסתיימת. `createShabbatGate` (ב-Pages) מקבל את זה אוטומטית מה-`context` שלו.
92
+
60
93
  **מוקש שמבטל את כל השער בשקט:** Cloudflare Worker עם `assets` binding מגיש כל בקשה שתואמת
61
94
  קובץ בתיקיית ה-assets **ישירות**, בלי להריץ בכלל את ה-`fetch` handler של ה-Worker - אלא אם
62
95
  מגדירים `run_worker_first: true`. בלי זה, קוד השער רץ ונראה מחובר נכון, הבדיקות עוברות, אבל
@@ -127,6 +160,11 @@ export interface ShabbatGateConfig {
127
160
  * מיקום לבקשה (למשל `wrangler dev` מקומי, או IP ש-Cloudflare לא ממקם) - אותה בקשה
128
161
  * נופלת חזרה להחלטה לפי ישראל בלבד. */
129
162
  enforceVisitorLocation?: boolean;
163
+
164
+ /** כמה זמן לחכות ל-Hebcal לפני שמוותרים ונופלים פתוח (ברירת מחדל: 3000 מילישניות).
165
+ * משולם רק כשהקאש קר - ברגע שהוא חם, רשימת חלונות שפג תוקפה מוגשת מיד ומתרעננת
166
+ * ברקע, כך שה-timeout הזה אף פעם לא נוחת על בקשה של גולש. */
167
+ hebcalTimeoutMs?: number;
130
168
  }
131
169
  ```
132
170
 
@@ -181,13 +219,31 @@ export const onRequest: PagesFunction = (context) => gate(context);
181
219
  6. אם הזמן הנוכחי נופל בתוך חלון, מוצג דף ה"סגור" (HTTP 200). אחרת, האתר האמיתי עובר.
182
220
  7. כל שגיאה בדרך גורמת למעבר לאתר האמיתי.
183
221
 
222
+ ## עמידות: timeout ו-stale-while-revalidate
223
+
224
+ השער תלוי ב-API של צד שלישי (Hebcal) על הנתיב הקריטי של כל טעינת עמוד, ולכן הוא בנוי כך
225
+ ש-Hebcal שלא זמין - או, מסוכן יותר, **איטי** - לא יוכל להפיל את האתר יחד איתו.
226
+
227
+ - **הקריאה ל-Hebcal מבוטלת אחרי `hebcalTimeoutMs`** (ברירת מחדל 3 שניות). upstream איטי גרוע
228
+ יותר מכזה שנופל: `fetch` בלי abort לא נסגר לעולם, ההרצה של ה-Worker עוברת את מגבלת הזמן,
229
+ וקלאודפלייר מחזירה **504** לגולש - ה-`try/catch` שאמור "ליפול פתוח" אף פעם לא רץ, כי
230
+ `try/catch` מציל מ-rejection, לא מתקיעה. ה-abort הופך את התקיעה ל-rejection שהשער יודע
231
+ ליפול עליו פתוח. (זה לא תרחיש תיאורטי - ראו את רשומת 0.4.0 ביומן השינויים.)
232
+ - **stale-while-revalidate.** גולש מחכה ל-Hebcal רק כשאין בכלל מידע שמיש בקאש. החלונות נשלפים
233
+ 45 יום קדימה, ולכן ברגע שהקאש חם, רשימה שפג תוקפה מוגשת **מיד** ומתרעננת ברקע דרך
234
+ `waitUntil` - upstream איטי אף פעם לא יושב על בקשה של גולש. חלונות ישנים מוגשים עד 7 ימים.
235
+ - **כישלון נשמר בקאש ל-60 שניות.** אחרת כל גולש מקביל פותח בקשה משלו מול upstream שכבר מתקשה,
236
+ וזה מה שהופך תשובה איטית אחת לאשכול של 504.
237
+ - **קריאות מקבילות ממוזגות** לפי מפתח קאש בתוך אותו isolate, כך שהתנעה קרה פותחת חיבור אחד
238
+ ולא אחד לכל בקשה שנמצאת באוויר.
239
+
184
240
  ## מפתח קאש פנימי
185
241
 
186
242
  רשימת החלונות המאוחדת נשמרת בקאש תחת מפתח פנימי קבוע
187
- (`https://internal.cache/shabbat-gate-windows-v1`, מיוצא בשם `INTERNAL_CACHE_KEY_URL`) לכ-24
188
- שעות דרך Workers Cache API. אם הקוד שלכם עושה caching משלו לנתונים נגזרים (למשל חלונות עם
189
- buffer משלכם), כדאי להשתמש במפתח אחר - שימוש חוזר במפתח הזה יגרום בשקט להחזרת נתונים ישנים
190
- ולא-מעובדים למשך עד 24 שעות.
243
+ (`https://internal.cache/shabbat-gate-windows-v2`, מיוצא בשם `INTERNAL_CACHE_KEY_URL`) דרך
244
+ Workers Cache API - טרייה לכ-24 שעות, ואחר כך מוגשת ישנה תוך כדי רענון. אם הקוד שלכם עושה
245
+ caching משלו לנתונים נגזרים (למשל חלונות עם buffer משלכם), כדאי להשתמש במפתח אחר - שימוש חוזר
246
+ במפתח הזה יגרום בשקט להחזרת נתונים ישנים ולא-מעובדים.
191
247
 
192
248
  ## היסטוריית שינויים
193
249
 
package/README.md CHANGED
@@ -37,6 +37,35 @@ const gate = createShabbatGate({ siteName: 'My Site' });
37
37
  export const onRequest: PagesFunction = (context) => gate(context);
38
38
  ```
39
39
 
40
+ ### When the site already has a `functions/_middleware`
41
+
42
+ Cloudflare Pages runs **only one** middleware file at the root of `functions/`. If two exist
43
+ (`_middleware.js` **and** `_middleware.ts`), Cloudflare silently picks one and the other never
44
+ runs - no error, no warning. So **do not add a second file**. `gate` calls `context.next()`
45
+ itself, which means it is already a composable middleware primitive: export the root
46
+ `onRequest` as an **array** of handlers and Cloudflare runs them in order, each calling
47
+ `next()`. For example, chaining a preview-noindex guard before the gate:
48
+
49
+ ```js
50
+ import { createShabbatGate } from 'shabbat-gate';
51
+
52
+ const PROD_HOSTS = new Set(['example.com', 'www.example.com']);
53
+
54
+ const noindex = async ({ request, next }) => {
55
+ const res = await next();
56
+ if (PROD_HOSTS.has(new URL(request.url).hostname)) return res;
57
+ const tagged = new Response(res.body, res);
58
+ tagged.headers.set('X-Robots-Tag', 'noindex, nofollow');
59
+ return tagged;
60
+ };
61
+
62
+ const gate = createShabbatGate({ siteName: 'My Site' });
63
+
64
+ export const onRequest = [noindex, (context) => gate(context)];
65
+ ```
66
+
67
+ No manual `next()` wrapping is needed - the gate participates in the chain as-is.
68
+
40
69
  ### Using with a plain Worker + Assets binding (not Pages)
41
70
 
42
71
  `createShabbatGate` returns a Pages-Functions-shaped handler (`(context) => Response`), which
@@ -50,13 +79,18 @@ import { createShabbatGateForWorker } from 'shabbat-gate';
50
79
  const gate = createShabbatGateForWorker({ siteName: 'My Site' });
51
80
 
52
81
  export default {
53
- async fetch(request: Request, env: { ASSETS: Fetcher }) {
54
- const blocked = await gate(request);
82
+ async fetch(request: Request, env: { ASSETS: Fetcher }, ctx: ExecutionContext) {
83
+ const blocked = await gate(request, ctx);
55
84
  return blocked ?? env.ASSETS.fetch(request);
56
85
  },
57
86
  };
58
87
  ```
59
88
 
89
+ Passing `ctx` is optional but recommended: it's what lets a stale window list refresh *after*
90
+ the response is sent (see [Resilience](#resilience-timeouts-and-stale-while-revalidate)) instead
91
+ of the refresh being cancelled when the invocation ends. `createShabbatGate` (Pages) gets this
92
+ automatically from its own `context`.
93
+
60
94
  **Gotcha that silently defeats the whole gate:** a Cloudflare Worker with an `assets` binding
61
95
  serves any request matching a file in the assets directory *directly*, without invoking the
62
96
  Worker's `fetch` handler at all - unless `run_worker_first: true` is set. Without it, the gate
@@ -127,6 +161,12 @@ export interface ShabbatGateConfig {
127
161
  * the Israel-only decision when a request has no geolocation (local dev,
128
162
  * unplaceable IP). */
129
163
  enforceVisitorLocation?: boolean;
164
+
165
+ /** How long to wait for Hebcal before giving up and failing open (default
166
+ * 3000ms). Only ever paid on a cold cache - once warm, an expired window list
167
+ * is served immediately and refreshed in the background, so this timeout never
168
+ * lands on a visitor's request. */
169
+ hebcalTimeoutMs?: number;
130
170
  }
131
171
  ```
132
172
 
@@ -184,13 +224,35 @@ export const onRequest: PagesFunction = (context) => gate(context);
184
224
  the real site through.
185
225
  7. Any error along the way falls through to the real site.
186
226
 
227
+ ## Resilience: timeouts and stale-while-revalidate
228
+
229
+ The gate depends on a third-party API (Hebcal) on the critical path of every page load, so it
230
+ is built so that Hebcal being unavailable - or, more dangerously, being *slow* - cannot take
231
+ the site down with it.
232
+
233
+ - **The Hebcal request is aborted after `hebcalTimeoutMs` (default 3s).** A slow upstream is
234
+ worse than a failing one: a `fetch` with no abort never settles, the Worker invocation runs
235
+ past its limit, and Cloudflare returns **504** to the visitor - the fail-open `try/catch`
236
+ never runs, because a `try/catch` rescues a rejection, never a hang. Aborting turns the hang
237
+ into a rejection the gate can fail open on. (This is not hypothetical; see the 0.4.0 entry in
238
+ the changelog.)
239
+ - **Stale-while-revalidate.** A visitor only ever waits on Hebcal when there is nothing usable
240
+ cached at all. Windows are fetched 45 days ahead, so once the cache is warm an expired list
241
+ is served *immediately* and refreshed in the background via `waitUntil` - a slow upstream
242
+ never sits on a visitor's request. Stale windows are served for up to 7 days.
243
+ - **Failures are cached for 60s.** Otherwise every concurrent visitor starts their own request
244
+ against an upstream that is already struggling, which is what turns one slow response into a
245
+ cluster of 504s.
246
+ - **Concurrent fetches are deduped** per cache key within an isolate, so a cold start opens one
247
+ connection rather than one per in-flight request.
248
+
187
249
  ## Internal cache key
188
250
 
189
251
  The merged window list is cached under a fixed internal key
190
- (`https://internal.cache/shabbat-gate-windows-v1`, exported as `INTERNAL_CACHE_KEY_URL`) for
191
- ~24h via the Workers Cache API. If your own code also caches derived data (e.g. windows with
192
- your own buffer applied) via `caches.default`, use a different key - reusing this one will
193
- silently serve stale, unprocessed data for up to 24h.
252
+ (`https://internal.cache/shabbat-gate-windows-v2`, exported as `INTERNAL_CACHE_KEY_URL`) via
253
+ the Workers Cache API - fresh for ~24h, then served stale while it refreshes. If your own code
254
+ also caches derived data (e.g. windows with your own buffer applied) via `caches.default`, use
255
+ a different key - reusing this one will silently serve stale, unprocessed data.
194
256
 
195
257
  ## Changelog
196
258
 
package/dist/hebcal.d.ts CHANGED
@@ -71,6 +71,25 @@ export interface FetchWindowsOptions {
71
71
  * `latitude`/`longitude` are ignored when it is present. Jerusalem=281184,
72
72
  * Haifa=294801, Tel Aviv=293397, Beer Sheva=295530. */
73
73
  geonameid?: number;
74
+ /** How long to wait for Hebcal before aborting the request (default 3000ms).
75
+ *
76
+ * A *slow* upstream is more dangerous here than a failing one: without an
77
+ * abort the fetch never settles, the Worker invocation runs past its limit,
78
+ * and Cloudflare returns 504 to the visitor - the gate's fail-open catch
79
+ * never runs, because a try/catch rescues a rejection, never a hang. This
80
+ * is not hypothetical: it took a site's homepage down in production on
81
+ * 2026-07-28. Aborting converts the hang into a rejection the caller can
82
+ * fail open on. */
83
+ timeoutMs?: number;
84
+ }
85
+ /** Conservative default: Hebcal normally answers in well under a second, and
86
+ * the answer only has to be waited for on a cold cache anyway. */
87
+ export declare const DEFAULT_HEBCAL_TIMEOUT_MS = 3000;
88
+ /** Thrown when the Hebcal request is aborted by {@link FetchWindowsOptions.timeoutMs}.
89
+ * A distinct class (and a `shabbat-gate: hebcal timeout` message) so the next
90
+ * occurrence is greppable in Workers logs, separately from other failures. */
91
+ export declare class HebcalTimeoutError extends Error {
92
+ constructor(timeoutMs: number);
74
93
  }
75
94
  /**
76
95
  * Fetches and merges Shabbat + major-holiday windows for the next ~45 days from
package/dist/hebcal.js CHANGED
@@ -60,6 +60,18 @@ export function pairWindows(items, defaults) {
60
60
  function toISODate(date) {
61
61
  return date.toISOString().slice(0, 10);
62
62
  }
63
+ /** Conservative default: Hebcal normally answers in well under a second, and
64
+ * the answer only has to be waited for on a cold cache anyway. */
65
+ export const DEFAULT_HEBCAL_TIMEOUT_MS = 3000;
66
+ /** Thrown when the Hebcal request is aborted by {@link FetchWindowsOptions.timeoutMs}.
67
+ * A distinct class (and a `shabbat-gate: hebcal timeout` message) so the next
68
+ * occurrence is greppable in Workers logs, separately from other failures. */
69
+ export class HebcalTimeoutError extends Error {
70
+ constructor(timeoutMs) {
71
+ super(`shabbat-gate: hebcal timeout after ${timeoutMs}ms`);
72
+ this.name = 'HebcalTimeoutError';
73
+ }
74
+ }
63
75
  /**
64
76
  * Fetches and merges Shabbat + major-holiday windows for the next ~45 days from
65
77
  * Hebcal's free public JSON API. Defaults to Israel single-day Yom Tov mode at
@@ -98,13 +110,32 @@ export async function fetchWindows(latitude, longitude, options = {}) {
98
110
  const url = `https://www.hebcal.com/hebcal?cfg=json&v=1&maj=on&min=off&mod=off&nx=off&mf=off&ss=on` +
99
111
  `&c=on&i=${iParam}&${locationParam}&tzid=${encodeURIComponent(tzid)}` +
100
112
  `&start=${startParam}&end=${endParam}`;
101
- const res = await fetch(url);
102
- if (!res.ok) {
103
- throw new Error(`hebcal fetch failed: ${res.status}`);
113
+ // The abort has to stay armed across `res.json()` too, not just the initial
114
+ // response - a stalled body stream hangs the invocation just as effectively
115
+ // as a stalled connection.
116
+ const timeoutMs = options.timeoutMs ?? DEFAULT_HEBCAL_TIMEOUT_MS;
117
+ const controller = new AbortController();
118
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
119
+ try {
120
+ const res = await fetch(url, { signal: controller.signal });
121
+ if (!res.ok) {
122
+ throw new Error(`hebcal fetch failed: ${res.status}`);
123
+ }
124
+ const data = (await res.json());
125
+ const windows = pairWindows(data.items ?? [], { label: SHABBAT_LABEL, closingLabel: SHABBAT_CLOSING_LABEL });
126
+ return windows.sort((a, b) => a.start - b.start);
127
+ }
128
+ catch (error) {
129
+ // Any rejection after the abort fired is the abort, whatever shape the
130
+ // runtime's error takes (DOMException in Workers, TypeError elsewhere).
131
+ if (controller.signal.aborted) {
132
+ throw new HebcalTimeoutError(timeoutMs);
133
+ }
134
+ throw error;
135
+ }
136
+ finally {
137
+ clearTimeout(timer);
104
138
  }
105
- const data = (await res.json());
106
- const windows = pairWindows(data.items ?? [], { label: SHABBAT_LABEL, closingLabel: SHABBAT_CLOSING_LABEL });
107
- return windows.sort((a, b) => a.start - b.start);
108
139
  }
109
140
  /**
110
141
  * Coalesces overlapping/touching windows into continuous ones. Needed when two
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type HoldingPageContext } from './holdingPage.js';
2
2
  export type { Window, FetchWindowsOptions } from './hebcal.js';
3
3
  export type { HoldingPageContext, SecondaryMessage } from './holdingPage.js';
4
4
  export type { SupportedLanguage } from './translations.js';
5
- export { isBlocked, findActiveWindow, mergeWindows, pairWindows, fetchWindows } from './hebcal.js';
5
+ export { isBlocked, findActiveWindow, mergeWindows, pairWindows, fetchWindows, HebcalTimeoutError, DEFAULT_HEBCAL_TIMEOUT_MS, } from './hebcal.js';
6
6
  export { SUPPORTED_LANGUAGES, resolveVisitorLanguage } from './translations.js';
7
7
  export { isBot, BOT_PATTERN } from './botPattern.js';
8
8
  export { defaultRenderHoldingPage } from './holdingPage.js';
@@ -46,13 +46,19 @@ export interface ShabbatGateConfig {
46
46
  * unavailable for a request (e.g. local `wrangler dev`, or an IP Cloudflare
47
47
  * can't place), that request falls back to the Israel-only decision. */
48
48
  enforceVisitorLocation?: boolean;
49
+ /** How long to wait for Hebcal before giving up and failing open (default
50
+ * 3000ms). Only ever paid on a cold cache - once warm, an expired window
51
+ * list is served immediately and refreshed in the background, so this
52
+ * timeout never lands on a visitor's request. Raise it only if you'd rather
53
+ * a cold visitor wait than risk a missed block. */
54
+ hebcalTimeoutMs?: number;
49
55
  }
50
56
  /** Internal cache key for the merged window list (~24h TTL via the Workers
51
57
  * Cache API). Exported so consumers that do their own caching of
52
58
  * derived/post-processed window data (e.g. after applying their own buffer)
53
59
  * can pick a different key and avoid accidentally colliding with this one -
54
60
  * which would silently serve stale, unprocessed windows for up to 24h. */
55
- export declare const INTERNAL_CACHE_KEY_URL = "https://internal.cache/shabbat-gate-windows-v1";
61
+ export declare const INTERNAL_CACHE_KEY_URL = "https://internal.cache/shabbat-gate-windows-v2";
56
62
  /**
57
63
  * Returns a Cloudflare Pages Functions-compatible handler that closes the
58
64
  * site to human visitors during Shabbat and major Jewish holidays, while
@@ -71,13 +77,19 @@ export declare function createShabbatGate(config: ShabbatGateConfig): PagesFunct
71
77
  * const gate = createShabbatGateForWorker({ siteName: 'My Site' });
72
78
  * export default {
73
79
  * async fetch(request, env, ctx) {
74
- * const blocked = await gate(request);
80
+ * const blocked = await gate(request, ctx);
75
81
  * return blocked ?? env.ASSETS.fetch(request);
76
82
  * },
77
83
  * };
78
84
  *
85
+ * Passing `ctx` is optional but recommended: it's what lets a stale window
86
+ * list refresh in the background after the response is sent, instead of the
87
+ * refresh being cancelled when the invocation ends.
88
+ *
79
89
  * Note: a Worker with an `assets` binding skips the `fetch` handler entirely
80
90
  * for requests matching a static asset unless `assets.run_worker_first: true`
81
91
  * is set in `wrangler.jsonc` - without it, this gate never runs.
82
92
  */
83
- export declare function createShabbatGateForWorker(config: ShabbatGateConfig): (request: Request) => Promise<Response | null>;
93
+ export declare function createShabbatGateForWorker(config: ShabbatGateConfig): (request: Request, ctx?: {
94
+ waitUntil(promise: Promise<unknown>): void;
95
+ }) => Promise<Response | null>;
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { isBot } from './botPattern.js';
2
2
  import { fetchWindows, findActiveWindow, mergeWindows, SHABBAT_LABEL } from './hebcal.js';
3
3
  import { defaultRenderHoldingPage } from './holdingPage.js';
4
4
  import { buildSecondaryMessage, resolveVisitorLanguage } from './translations.js';
5
- export { isBlocked, findActiveWindow, mergeWindows, pairWindows, fetchWindows } from './hebcal.js';
5
+ export { isBlocked, findActiveWindow, mergeWindows, pairWindows, fetchWindows, HebcalTimeoutError, DEFAULT_HEBCAL_TIMEOUT_MS, } from './hebcal.js';
6
6
  export { SUPPORTED_LANGUAGES, resolveVisitorLanguage } from './translations.js';
7
7
  export { isBot, BOT_PATTERN } from './botPattern.js';
8
8
  export { defaultRenderHoldingPage } from './holdingPage.js';
@@ -13,30 +13,140 @@ const JERUSALEM_LONGITUDE = 35.2137;
13
13
  * derived/post-processed window data (e.g. after applying their own buffer)
14
14
  * can pick a different key and avoid accidentally colliding with this one -
15
15
  * which would silently serve stale, unprocessed windows for up to 24h. */
16
- export const INTERNAL_CACHE_KEY_URL = 'https://internal.cache/shabbat-gate-windows-v1';
16
+ export const INTERNAL_CACHE_KEY_URL = 'https://internal.cache/shabbat-gate-windows-v2';
17
17
  /** Cache-key prefix for per-visitor-location window lists. Keyed by rounded
18
18
  * coordinates + timezone + reckoning so all visitors within ~1° of each other
19
19
  * share one cached fetch (sunset differs by only a few minutes across a cell -
20
20
  * immaterial at "block the whole site or not" granularity). */
21
- const VISITOR_CACHE_KEY_PREFIX = 'https://internal.cache/shabbat-gate-visitor-v1';
21
+ const VISITOR_CACHE_KEY_PREFIX = 'https://internal.cache/shabbat-gate-visitor-v2';
22
+ /** How long a cached window list counts as fresh. Past this it is still served
23
+ * (see stale-while-revalidate below) while a refresh runs out of band. */
22
24
  const CACHE_TTL_SECONDS = 24 * 60 * 60;
23
- /** Fetch a window list through the Workers Cache API under a fixed key. */
24
- async function getCachedWindows(cacheKeyUrl, fetcher) {
25
- const cache = caches.default;
26
- const cacheRequest = new Request(cacheKeyUrl);
27
- const cached = await cache.match(cacheRequest);
28
- if (cached) {
29
- return (await cached.json());
25
+ /** How long a *stale* list may still be served. Windows are fetched 45 days
26
+ * ahead, so a week-old list is still correct for the next ~38 days - serving
27
+ * it is strictly better than making a visitor wait on a slow upstream. Also
28
+ * the Cache API `max-age`, so anything older stops matching entirely and the
29
+ * next request refetches synchronously. */
30
+ const CACHE_MAX_STALE_SECONDS = 7 * 24 * 60 * 60;
31
+ /** After a failed or timed-out fetch, don't try again for this long. Without
32
+ * it, every concurrent visitor starts their own request against an upstream
33
+ * that is already struggling - the thundering herd that turned one slow
34
+ * Hebcal response into a cluster of 504s in production on 2026-07-28. */
35
+ const CACHE_FAILURE_TTL_SECONDS = 60;
36
+ /** In-flight fetches per cache key, deduped within this isolate. The Cache API
37
+ * only helps once a fetch has *finished*; on a cold start every concurrent
38
+ * request would otherwise open its own connection. */
39
+ const inFlightFetches = new Map();
40
+ async function readCacheRecord(cacheKeyUrl) {
41
+ try {
42
+ const cached = await caches.default.match(new Request(cacheKeyUrl));
43
+ if (!cached) {
44
+ return null;
45
+ }
46
+ const body = (await cached.json());
47
+ if (!body || typeof body !== 'object' || !('fetchedAt' in body)) {
48
+ return null;
49
+ }
50
+ return {
51
+ windows: Array.isArray(body.windows) ? body.windows : null,
52
+ fetchedAt: typeof body.fetchedAt === 'number' ? body.fetchedAt : 0,
53
+ failedAt: typeof body.failedAt === 'number' ? body.failedAt : undefined,
54
+ };
55
+ }
56
+ catch {
57
+ // A corrupt/unreadable entry must not take the site down - treat it as a
58
+ // miss and refetch.
59
+ return null;
30
60
  }
31
- const windows = await fetcher();
32
- const cacheResponse = new Response(JSON.stringify(windows), {
61
+ }
62
+ async function writeCacheRecord(cacheKeyUrl, record) {
63
+ // A record with no usable windows is only a "don't retry yet" marker, so it
64
+ // must expire quickly; a real list should outlive its freshness window so it
65
+ // can still be served stale.
66
+ const maxAge = record.windows ? CACHE_MAX_STALE_SECONDS : CACHE_FAILURE_TTL_SECONDS;
67
+ await caches.default.put(new Request(cacheKeyUrl), new Response(JSON.stringify(record), {
33
68
  headers: {
34
69
  'content-type': 'application/json',
35
- 'cache-control': `max-age=${CACHE_TTL_SECONDS}`,
70
+ 'cache-control': `max-age=${maxAge}`,
36
71
  },
72
+ }));
73
+ }
74
+ /** Runs (or joins) the single in-flight fetch for this key and records the
75
+ * outcome - success or failure - in the cache. Rejects on failure; callers
76
+ * decide whether that's fatal (cold start -> fail open) or ignorable
77
+ * (background refresh -> keep serving stale). */
78
+ function refreshWindows(cacheKeyUrl, fetcher, existing) {
79
+ const pending = inFlightFetches.get(cacheKeyUrl);
80
+ if (pending) {
81
+ return pending;
82
+ }
83
+ const attempt = (async () => {
84
+ let windows;
85
+ try {
86
+ windows = await fetcher();
87
+ }
88
+ catch (error) {
89
+ // Remember the failure so the next visitors don't pile onto a struggling
90
+ // upstream - but never discard windows we already have. Recording it is
91
+ // best-effort; if even that fails, the original error is what matters.
92
+ await writeCacheRecord(cacheKeyUrl, {
93
+ windows: existing?.windows ?? null,
94
+ fetchedAt: existing?.fetchedAt ?? 0,
95
+ failedAt: Date.now(),
96
+ }).catch(() => undefined);
97
+ throw error;
98
+ }
99
+ // Caching is an optimization, not a precondition: a cache write that fails
100
+ // must not throw away windows we successfully fetched.
101
+ await writeCacheRecord(cacheKeyUrl, { windows, fetchedAt: Date.now() }).catch((error) => {
102
+ console.error('shabbat-gate: could not cache windows', error);
103
+ });
104
+ return windows;
105
+ })();
106
+ inFlightFetches.set(cacheKeyUrl, attempt);
107
+ return attempt.finally(() => {
108
+ inFlightFetches.delete(cacheKeyUrl);
37
109
  });
38
- await cache.put(cacheRequest, cacheResponse);
39
- return windows;
110
+ }
111
+ function failedRecently(record, now) {
112
+ return record?.failedAt != null && now - record.failedAt < CACHE_FAILURE_TTL_SECONDS * 1000;
113
+ }
114
+ /**
115
+ * Fetch a window list through the Workers Cache API under a fixed key, with
116
+ * stale-while-revalidate: a visitor only ever waits on Hebcal when there is
117
+ * nothing usable cached at all. Once warm, an expired list is served
118
+ * immediately and refreshed in the background via `waitUntil`, so a slow
119
+ * upstream can never sit on a visitor's request.
120
+ */
121
+ async function getCachedWindows(cacheKeyUrl, fetcher, waitUntil) {
122
+ const record = await readCacheRecord(cacheKeyUrl);
123
+ const now = Date.now();
124
+ if (record?.windows) {
125
+ if (now - record.fetchedAt < CACHE_TTL_SECONDS * 1000) {
126
+ return record.windows;
127
+ }
128
+ // Stale but usable: hand it back now, refresh out of band. Everything here
129
+ // is best-effort - a refresh that can't even be scheduled must not cost
130
+ // this visitor the perfectly good windows we're already holding.
131
+ if (!failedRecently(record, now)) {
132
+ const refreshing = refreshWindows(cacheKeyUrl, fetcher, record).catch((error) => {
133
+ console.error('shabbat-gate: background window refresh failed', error);
134
+ });
135
+ try {
136
+ waitUntil?.(refreshing);
137
+ }
138
+ catch (error) {
139
+ console.error('shabbat-gate: could not schedule background refresh', error);
140
+ }
141
+ }
142
+ return record.windows;
143
+ }
144
+ // Nothing usable cached. If an attempt just failed, fail open right away
145
+ // rather than making this visitor wait on an upstream we know is unhealthy.
146
+ if (failedRecently(record, now)) {
147
+ throw new Error('shabbat-gate: skipping hebcal retry, a recent fetch failed');
148
+ }
149
+ return refreshWindows(cacheKeyUrl, fetcher, record);
40
150
  }
41
151
  /** Reads the visitor's geolocation from Cloudflare's `request.cf`. Returns
42
152
  * `null` when any needed field is missing/unparseable (local dev, an IP CF
@@ -60,17 +170,17 @@ function readVisitorLocation(request) {
60
170
  * site's own times come from Hebcal's official city times (and the cache key is
61
171
  * namespaced by it so switching location doesn't serve stale coordinate-based
62
172
  * windows for up to 24h). */
63
- function getIsraelWindows(latitude, longitude, geonameid) {
173
+ function getIsraelWindows(latitude, longitude, geonameid, timeoutMs, waitUntil) {
64
174
  const cacheKey = geonameid != null ? `${INTERNAL_CACHE_KEY_URL}?geonameid=${geonameid}` : INTERNAL_CACHE_KEY_URL;
65
- return getCachedWindows(cacheKey, () => fetchWindows(latitude, longitude, geonameid != null ? { geonameid } : {}));
175
+ return getCachedWindows(cacheKey, () => fetchWindows(latitude, longitude, { ...(geonameid != null ? { geonameid } : {}), timeoutMs }), waitUntil);
66
176
  }
67
177
  /** Windows for a specific visitor location, cached per rounded cell. */
68
- function getVisitorWindows(loc) {
178
+ function getVisitorWindows(loc, timeoutMs, waitUntil) {
69
179
  const rlat = Math.round(loc.latitude);
70
180
  const rlon = Math.round(loc.longitude);
71
181
  const iParam = loc.israelMode ? 'on' : 'off';
72
182
  const cacheKey = `${VISITOR_CACHE_KEY_PREFIX}?lat=${rlat}&lon=${rlon}&tz=${encodeURIComponent(loc.tzid)}&i=${iParam}`;
73
- return getCachedWindows(cacheKey, () => fetchWindows(loc.latitude, loc.longitude, { israelMode: loc.israelMode, tzid: loc.tzid }));
183
+ return getCachedWindows(cacheKey, () => fetchWindows(loc.latitude, loc.longitude, { israelMode: loc.israelMode, tzid: loc.tzid, timeoutMs }), waitUntil);
74
184
  }
75
185
  function formatTime(epochMs, tzid) {
76
186
  return new Intl.DateTimeFormat('he-IL', {
@@ -95,7 +205,7 @@ function applyBuffer(windows, bufferMinutes) {
95
205
  * around this, so neither can drift out of sync on caching/fail-open/bypass
96
206
  * behavior.
97
207
  */
98
- async function evaluateGate(config, request) {
208
+ async function evaluateGate(config, request, waitUntil) {
99
209
  const userAgent = request.headers.get('user-agent') ?? '';
100
210
  if (isBot(userAgent)) {
101
211
  return { type: 'pass' };
@@ -115,9 +225,9 @@ async function evaluateGate(config, request) {
115
225
  // local-time display shown to a visitor outside Israel.
116
226
  const visitor = readVisitorLocation(request);
117
227
  const isAbroad = visitor !== null && !visitor.israelMode;
118
- let windows = applyBuffer(await getIsraelWindows(latitude, longitude, config.geonameid), bufferMinutes);
228
+ let windows = applyBuffer(await getIsraelWindows(latitude, longitude, config.geonameid, config.hebcalTimeoutMs, waitUntil), bufferMinutes);
119
229
  if (config.enforceVisitorLocation && visitor) {
120
- const visitorWindows = applyBuffer(await getVisitorWindows(visitor), bufferMinutes);
230
+ const visitorWindows = applyBuffer(await getVisitorWindows(visitor, config.hebcalTimeoutMs, waitUntil), bufferMinutes);
121
231
  // Union of both calendars: block if it's Shabbat/Yom Tov in Israel OR
122
232
  // where the visitor is. Merge coalesces the overlap into one continuous
123
233
  // block so the shown reopen time is the true end of both.
@@ -162,7 +272,7 @@ async function evaluateGate(config, request) {
162
272
  */
163
273
  export function createShabbatGate(config) {
164
274
  return async (context) => {
165
- const decision = await evaluateGate(config, context.request);
275
+ const decision = await evaluateGate(config, context.request, (promise) => context.waitUntil(promise));
166
276
  if (decision.type === 'pass') {
167
277
  return context.next();
168
278
  }
@@ -182,18 +292,22 @@ export function createShabbatGate(config) {
182
292
  * const gate = createShabbatGateForWorker({ siteName: 'My Site' });
183
293
  * export default {
184
294
  * async fetch(request, env, ctx) {
185
- * const blocked = await gate(request);
295
+ * const blocked = await gate(request, ctx);
186
296
  * return blocked ?? env.ASSETS.fetch(request);
187
297
  * },
188
298
  * };
189
299
  *
300
+ * Passing `ctx` is optional but recommended: it's what lets a stale window
301
+ * list refresh in the background after the response is sent, instead of the
302
+ * refresh being cancelled when the invocation ends.
303
+ *
190
304
  * Note: a Worker with an `assets` binding skips the `fetch` handler entirely
191
305
  * for requests matching a static asset unless `assets.run_worker_first: true`
192
306
  * is set in `wrangler.jsonc` - without it, this gate never runs.
193
307
  */
194
308
  export function createShabbatGateForWorker(config) {
195
- return async (request) => {
196
- const decision = await evaluateGate(config, request);
309
+ return async (request, ctx) => {
310
+ const decision = await evaluateGate(config, request, ctx ? (p) => ctx.waitUntil(p) : undefined);
197
311
  if (decision.type === 'pass') {
198
312
  return null;
199
313
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shabbat-gate",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Cloudflare Pages/Workers middleware that closes a site to human visitors during Shabbat and major Jewish holidays (Israel-observance rules), while always letting search engines and AI crawlers through.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",