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 +67 -1
- package/README.he.md +62 -6
- package/README.md +68 -6
- package/dist/hebcal.d.ts +19 -0
- package/dist/hebcal.js +37 -6
- package/dist/index.d.ts +16 -4
- package/dist/index.js +140 -26
- package/package.json +1 -1
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.
|
|
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-
|
|
188
|
-
|
|
189
|
-
buffer משלכם), כדאי להשתמש במפתח אחר - שימוש חוזר
|
|
190
|
-
|
|
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-
|
|
191
|
-
|
|
192
|
-
your own buffer applied) via `caches.default`, use
|
|
193
|
-
silently serve stale, unprocessed data
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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=${
|
|
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
|
-
|
|
39
|
-
|
|
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
|
+
"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",
|