shabbat-gate 0.1.1 → 0.1.3
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 +81 -0
- package/README.he.md +155 -90
- package/README.md +156 -91
- package/dist/hebcal.d.ts +40 -1
- package/dist/hebcal.js +57 -19
- package/dist/holdingPage.d.ts +1 -0
- package/dist/holdingPage.js +1 -1
- package/dist/index.d.ts +32 -0
- package/dist/index.js +91 -34
- package/package.json +36 -35
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. Format loosely follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/).
|
|
5
|
+
|
|
6
|
+
## [0.1.3] - 2026-07-12
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **Windows were computed for the wrong location past the nearest Shabbat.** `fetchWindows`
|
|
11
|
+
used to call two separate Hebcal endpoints: `/shabbat` (accurate for the given
|
|
12
|
+
`latitude`/`longitude`, but - confirmed by testing live against the API - it silently
|
|
13
|
+
ignores the requested `start`/`end` range and always returns only the single nearest
|
|
14
|
+
Shabbat) and `/hebcal` for holidays (which took a hardcoded `geonameid` for Jerusalem,
|
|
15
|
+
never the coordinates passed in). Net effect: only the very next Shabbat was ever
|
|
16
|
+
correctly localized; every window after that was computed for Jerusalem regardless of
|
|
17
|
+
the site's configured location.
|
|
18
|
+
- **Duplicate/inconsistent windows around the nearest Shabbat**, a direct consequence of
|
|
19
|
+
the bug above - the two endpoints could both emit a window for the same week, a few
|
|
20
|
+
minutes apart, with nothing merging or de-duplicating them.
|
|
21
|
+
- **A holiday's Hebrew label could leak into the following, unrelated Shabbat window.**
|
|
22
|
+
`pairWindows` tracked "the most recently seen holiday item" as a running pointer. Some
|
|
23
|
+
`holiday`-category items - fast days such as Tish'a B'Av, which are `maj=on` but have no
|
|
24
|
+
candle-lighting/havdalah of their own - never got consumed into a window, so their label
|
|
25
|
+
stayed "current" and was wrongly attached to the next real window (e.g. an ordinary
|
|
26
|
+
Shabbat mislabeled with the fast day's name). Fixed by matching each window's opening
|
|
27
|
+
`candles` event to a holiday via Hebcal's own `memo` field instead of positional
|
|
28
|
+
tracking.
|
|
29
|
+
|
|
30
|
+
**Root fix for both window bugs**: `fetchWindows` now makes a single call to the
|
|
31
|
+
`/hebcal` endpoint with `ss=on` (weekly Shabbat) added alongside `maj=on` (major
|
|
32
|
+
holidays), passing `latitude`/`longitude` directly instead of a `geonameid`. This
|
|
33
|
+
returns every window in the requested range, correctly localized, already merged and
|
|
34
|
+
chronologically ordered - eliminating the two-source reconciliation that caused both bugs.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `ShabbatGateConfig.bufferMinutes` - closes the site a configurable number of minutes
|
|
39
|
+
*before* candle-lighting and reopens it the same number of minutes *after* havdalah, on
|
|
40
|
+
top of the raw Hebcal window. Applied at decision time (not baked into the 24h cache), so
|
|
41
|
+
changing it takes effect immediately.
|
|
42
|
+
- `createShabbatGateForWorker(config)` - an adapter for plain Cloudflare Workers with a
|
|
43
|
+
static `assets` binding (as opposed to Pages Functions). Returns
|
|
44
|
+
`(request: Request) => Promise<Response | null>`: `null` means "let the real site
|
|
45
|
+
through", a `Response` means "serve the holding page". Shares all fail-open/caching/
|
|
46
|
+
bypass logic with `createShabbatGate` internally, so the two can't drift apart.
|
|
47
|
+
- `INTERNAL_CACHE_KEY_URL` export - the fixed internal Workers-Cache-API key this package
|
|
48
|
+
uses to cache the merged window list for ~24h. Exported so consumers doing their own
|
|
49
|
+
caching of derived data (e.g. windows with a hand-rolled buffer) can pick a different key
|
|
50
|
+
and avoid silently serving stale data for up to a day.
|
|
51
|
+
|
|
52
|
+
## [0.1.2] - 2026-07-10
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- Holding page showed the generic literal "הדלקת נרות"/"הבדלה" as the closure reason for
|
|
57
|
+
every window, Shabbat or holiday alike (Hebcal's `candles`/`havdalah` items always carry
|
|
58
|
+
that generic text in their `hebrew` field, never the actual occasion name). Plain Shabbat
|
|
59
|
+
weeks now default to "שבת קודש" (opening) / "השבת" (closing); holiday windows pick up the
|
|
60
|
+
holiday's own Hebrew name (e.g. "ערב ראש השנה").
|
|
61
|
+
|
|
62
|
+
### Documentation
|
|
63
|
+
|
|
64
|
+
- Documented the Cloudflare Worker + `assets` binding `run_worker_first: true` gotcha:
|
|
65
|
+
without it, requests matching a static asset are served directly and the gate's `fetch`
|
|
66
|
+
handler never runs, so the site never actually closes despite looking correctly wired up.
|
|
67
|
+
Found live in production on the package's first real-world Worker (non-Pages) deployment.
|
|
68
|
+
|
|
69
|
+
## [0.1.1] - 2026-07-10
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
- Hebrew README (`README.he.md`), for the package's primary intended audience.
|
|
74
|
+
|
|
75
|
+
## [0.1.0] - 2026-07-10
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
|
|
79
|
+
- Initial release: bot/crawler allowlist, Hebcal Shabbat + major-holiday window fetching
|
|
80
|
+
(Israel single-day Yom Tov mode), `createShabbatGate` Pages Functions middleware,
|
|
81
|
+
bypass query param, ~24h caching via the Workers Cache API, and fail-open error handling.
|
package/README.he.md
CHANGED
|
@@ -1,90 +1,155 @@
|
|
|
1
|
-
# shabbat-gate
|
|
2
|
-
|
|
3
|
-
Middleware ל-Cloudflare Pages / Workers שסוגר אתר לגולשים אנושיים בזמן שבת וחגי ישראל
|
|
4
|
-
המרכזיים (לפי כללי שמירת שבת בישראל) - תוך מתן גישה תמידית למנועי חיפוש ולסורקי AI, כך
|
|
5
|
-
שה-SEO לא נפגע.
|
|
6
|
-
|
|
7
|
-
[English README](README.md)
|
|
8
|
-
|
|
9
|
-
## למה זה עובד ככה
|
|
10
|
-
|
|
11
|
-
- **יום טוב חד-יומי לפי ישראל, לא ספירת חו"ל.** לוח החגים נשלף מה-API החינמי של Hebcal
|
|
12
|
-
עם הפרמטר `i=on`, שקריטי - בלעדיו מתקבלת ספירת חו"ל (יום חסימה מיותר) במקום יום טוב
|
|
13
|
-
חד-יומי, כפי שנהוג בישראל.
|
|
14
|
-
- **בוטים תמיד עוברים.** רשימת אלוול רחבה, לא תלוית רישיות, של user-agent (Googlebot,
|
|
15
|
-
Bingbot, GPTBot, ClaudeBot ורבים נוספים) נבדקת ראשונה, לפני כל שאר הלוגיקה. השער משפיע
|
|
16
|
-
רק על גולשים אנושיים - סורקים ומנועי אינדוקס רואים את האתר האמיתי 24/7, כך שדירוג
|
|
17
|
-
וניראות ב-AI-search לא נפגעים מכך שהאתר "סגור".
|
|
18
|
-
- **נכשל פתוח (Fail open).** כל שגיאה (תקלת רשת, תשובת API לא תקינה, מה שלא יהיה) גורמת
|
|
19
|
-
למעבר לאתר האמיתי במקום הצגת דף שגיאה. חסימה בטעות ביום שלישי רגיל היא באג אמיתי וגלוי;
|
|
20
|
-
אי-חסימה נדירה בזמן שגיאה היא תקלה קטנה ובלתי נראית.
|
|
21
|
-
|
|
22
|
-
## התקנה
|
|
23
|
-
|
|
24
|
-
```sh
|
|
25
|
-
npm install shabbat-gate
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
## שימוש
|
|
29
|
-
|
|
30
|
-
בפרויקט Cloudflare Pages, מוסיפים קובץ `functions/_middleware.ts`:
|
|
31
|
-
|
|
32
|
-
```ts
|
|
33
|
-
import { createShabbatGate } from 'shabbat-gate';
|
|
34
|
-
|
|
35
|
-
const gate = createShabbatGate({ siteName: 'שם האתר שלי' });
|
|
36
|
-
|
|
37
|
-
export const onRequest: PagesFunction = (context) => gate(context);
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
1
|
+
# shabbat-gate
|
|
2
|
+
|
|
3
|
+
Middleware ל-Cloudflare Pages / Workers שסוגר אתר לגולשים אנושיים בזמן שבת וחגי ישראל
|
|
4
|
+
המרכזיים (לפי כללי שמירת שבת בישראל) - תוך מתן גישה תמידית למנועי חיפוש ולסורקי AI, כך
|
|
5
|
+
שה-SEO לא נפגע.
|
|
6
|
+
|
|
7
|
+
[English README](README.md)
|
|
8
|
+
|
|
9
|
+
## למה זה עובד ככה
|
|
10
|
+
|
|
11
|
+
- **יום טוב חד-יומי לפי ישראל, לא ספירת חו"ל.** לוח החגים נשלף מה-API החינמי של Hebcal
|
|
12
|
+
עם הפרמטר `i=on`, שקריטי - בלעדיו מתקבלת ספירת חו"ל (יום חסימה מיותר) במקום יום טוב
|
|
13
|
+
חד-יומי, כפי שנהוג בישראל.
|
|
14
|
+
- **בוטים תמיד עוברים.** רשימת אלוול רחבה, לא תלוית רישיות, של user-agent (Googlebot,
|
|
15
|
+
Bingbot, GPTBot, ClaudeBot ורבים נוספים) נבדקת ראשונה, לפני כל שאר הלוגיקה. השער משפיע
|
|
16
|
+
רק על גולשים אנושיים - סורקים ומנועי אינדוקס רואים את האתר האמיתי 24/7, כך שדירוג
|
|
17
|
+
וניראות ב-AI-search לא נפגעים מכך שהאתר "סגור".
|
|
18
|
+
- **נכשל פתוח (Fail open).** כל שגיאה (תקלת רשת, תשובת API לא תקינה, מה שלא יהיה) גורמת
|
|
19
|
+
למעבר לאתר האמיתי במקום הצגת דף שגיאה. חסימה בטעות ביום שלישי רגיל היא באג אמיתי וגלוי;
|
|
20
|
+
אי-חסימה נדירה בזמן שגיאה היא תקלה קטנה ובלתי נראית.
|
|
21
|
+
|
|
22
|
+
## התקנה
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install shabbat-gate
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## שימוש
|
|
29
|
+
|
|
30
|
+
בפרויקט Cloudflare Pages, מוסיפים קובץ `functions/_middleware.ts`:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { createShabbatGate } from 'shabbat-gate';
|
|
34
|
+
|
|
35
|
+
const gate = createShabbatGate({ siteName: 'שם האתר שלי' });
|
|
36
|
+
|
|
37
|
+
export const onRequest: PagesFunction = (context) => gate(context);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### שימוש עם Worker רגיל + assets binding (לא Pages)
|
|
41
|
+
|
|
42
|
+
`createShabbatGate` מחזירה handler בצורה של Pages Functions (`(context) => Response`), וזה
|
|
43
|
+
לא מתאים ל-signature של `fetch(request, env)` של Worker רגיל (אין `next()`). במקום זאת יש
|
|
44
|
+
להשתמש ב-`createShabbatGateForWorker` - היא מחזירה `null` כשצריך לתת לאתר האמיתי לעבור, ו-
|
|
45
|
+
`Response` כשצריך להציג את דף ה"סגור":
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { createShabbatGateForWorker } from 'shabbat-gate';
|
|
49
|
+
|
|
50
|
+
const gate = createShabbatGateForWorker({ siteName: 'שם האתר שלי' });
|
|
51
|
+
|
|
52
|
+
export default {
|
|
53
|
+
async fetch(request: Request, env: { ASSETS: Fetcher }) {
|
|
54
|
+
const blocked = await gate(request);
|
|
55
|
+
return blocked ?? env.ASSETS.fetch(request);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**מוקש שמבטל את כל השער בשקט:** Cloudflare Worker עם `assets` binding מגיש כל בקשה שתואמת
|
|
61
|
+
קובץ בתיקיית ה-assets **ישירות**, בלי להריץ בכלל את ה-`fetch` handler של ה-Worker - אלא אם
|
|
62
|
+
מגדירים `run_worker_first: true`. בלי זה, קוד השער רץ ונראה מחובר נכון, הבדיקות עוברות, אבל
|
|
63
|
+
בקשות אמיתיות לעמודים (שכמעט תמיד תואמות קובץ סטטי) אף פעם לא מגיעות אליו, אז האתר בפועל
|
|
64
|
+
אף פעם לא נסגר. ב-`wrangler.jsonc`:
|
|
65
|
+
|
|
66
|
+
```jsonc
|
|
67
|
+
{
|
|
68
|
+
"assets": {
|
|
69
|
+
"directory": "./dist",
|
|
70
|
+
"binding": "ASSETS",
|
|
71
|
+
"run_worker_first": true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## קונפיגורציה
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
export interface ShabbatGateConfig {
|
|
80
|
+
siteName: string;
|
|
81
|
+
|
|
82
|
+
/** קווי אורך/רוחב עשרוניים לחישוב זמנים. ברירת מחדל לשניהם היא ירושלים (31.7683,
|
|
83
|
+
* 35.2137) אם לא סופקו - נקודת ייחוס טובה לכל ישראל ברזולוציה הזו. */
|
|
84
|
+
latitude?: number;
|
|
85
|
+
longitude?: number;
|
|
86
|
+
|
|
87
|
+
/** שם פרמטר + ערך נדרש ב-query string שעוקפים את השער לגמרי, כדי שבעל האתר יוכל
|
|
88
|
+
* לבדוק/לתצוגה מקדימה בכל יום. חשוב לבחור ערך שלא ניתן לניחוש - זו נוחות לבדיקות,
|
|
89
|
+
* לא הרשאה אמיתית. */
|
|
90
|
+
bypassParam?: string;
|
|
91
|
+
bypassValue?: string;
|
|
92
|
+
|
|
93
|
+
/** פונקציית רינדור מותאמת אישית לדף ה"סגור", אופציונלית. ברירת המחדל היא דף בעברית,
|
|
94
|
+
* רספונסיבי למובייל, שמציג את שם האתר ומתי הוא ייפתח מחדש. `reasonLabel` ו-`closingLabel`
|
|
95
|
+
* שונים דקדוקית עבור שבת רגילה ("שבת קודש" בפתיחה מול "השבת" בסגירה) - יש להשתמש
|
|
96
|
+
* ב-`closingLabel` עבור "ניפגש שוב אחרי ___", לא לחזור על `reasonLabel`. */
|
|
97
|
+
renderHoldingPage?: (ctx: {
|
|
98
|
+
siteName: string;
|
|
99
|
+
reasonLabel: string;
|
|
100
|
+
closingLabel: string;
|
|
101
|
+
untilLabel: string;
|
|
102
|
+
}) => string;
|
|
103
|
+
|
|
104
|
+
/** דקות לסגור את האתר *לפני* הדלקת נרות ולפתוח אותו *אחרי* הבדלה, מעל החלון הגולמי
|
|
105
|
+
* מ-Hebcal. ברירת מחדל: 0. שימושי כרפידת בטחון מול סחיפת שעון / גלישה של הרגע
|
|
106
|
+
* האחרון בדיוק בגבול החלון. */
|
|
107
|
+
bufferMinutes?: number;
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
דוגמה מלאה:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { createShabbatGate } from 'shabbat-gate';
|
|
115
|
+
|
|
116
|
+
const gate = createShabbatGate({
|
|
117
|
+
siteName: 'tehila·games',
|
|
118
|
+
latitude: 31.7683,
|
|
119
|
+
longitude: 35.2137,
|
|
120
|
+
bypassParam: 'preview',
|
|
121
|
+
bypassValue: 'letmein-9f3a7c',
|
|
122
|
+
bufferMinutes: 10,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const onRequest: PagesFunction = (context) => gate(context);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## איך זה עובד
|
|
129
|
+
|
|
130
|
+
1. בדיקת בוט (regex אלוול על header ה-`user-agent`) - התאמה עוברת ישירות.
|
|
131
|
+
2. בדיקת bypass - אם פרמטר ה-query וערכו תואמים, עוברים ישירות.
|
|
132
|
+
3. שליפה (עם caching לכ-24 שעות דרך Workers Cache API) של רשימת חלונות שבת וחגים
|
|
133
|
+
מאוחדת מ-Hebcal, כ-45 יום קדימה - קריאה אחת ל-endpoint `/hebcal` (`ss=on` לשבתות
|
|
134
|
+
שבועיות + `maj=on` לחגים מרכזיים), עם `latitude`/`longitude` מועברים ישירות, כך שכל
|
|
135
|
+
חלון מחושב נכון למיקום שהוגדר, לא רק החלון הקרוב ביותר.
|
|
136
|
+
4. אם הוגדר `bufferMinutes`, הוא מוחל מעל החלונות שנשלפו לפני בדיקת הזמן.
|
|
137
|
+
5. אם הזמן הנוכחי נופל בתוך חלון, מוצג דף ה"סגור" (HTTP 200). אחרת, האתר האמיתי עובר.
|
|
138
|
+
6. כל שגיאה בדרך גורמת למעבר לאתר האמיתי.
|
|
139
|
+
|
|
140
|
+
## מפתח קאש פנימי
|
|
141
|
+
|
|
142
|
+
רשימת החלונות המאוחדת נשמרת בקאש תחת מפתח פנימי קבוע
|
|
143
|
+
(`https://internal.cache/shabbat-gate-windows-v1`, מיוצא בשם `INTERNAL_CACHE_KEY_URL`) לכ-24
|
|
144
|
+
שעות דרך Workers Cache API. אם הקוד שלכם עושה caching משלו לנתונים נגזרים (למשל חלונות עם
|
|
145
|
+
buffer משלכם), כדאי להשתמש במפתח אחר - שימוש חוזר במפתח הזה יגרום בשקט להחזרת נתונים ישנים
|
|
146
|
+
ולא-מעובדים למשך עד 24 שעות.
|
|
147
|
+
|
|
148
|
+
## היסטוריית שינויים
|
|
149
|
+
|
|
150
|
+
ב-[CHANGELOG.md](CHANGELOG.md) (באנגלית) מתועד מה השתנה בכל גרסה, כולל הסבר לשורש כל
|
|
151
|
+
באג שתוקן.
|
|
152
|
+
|
|
153
|
+
## רישיון
|
|
154
|
+
|
|
155
|
+
MIT
|
package/README.md
CHANGED
|
@@ -1,91 +1,156 @@
|
|
|
1
|
-
# shabbat-gate
|
|
2
|
-
|
|
3
|
-
[קריאה בעברית](README.he.md)
|
|
4
|
-
|
|
5
|
-
Cloudflare Pages / Workers middleware that automatically closes a site to human visitors
|
|
6
|
-
during Shabbat and major Jewish holidays (Israel-observance rules) - while always letting
|
|
7
|
-
search engines and AI crawlers through, so SEO stays unaffected.
|
|
8
|
-
|
|
9
|
-
## Why
|
|
10
|
-
|
|
11
|
-
- **Israel single-day Yom Tov, not diaspora 2-day.** The holiday calendar is fetched from
|
|
12
|
-
Hebcal's free public API with `i=on`, which is critical - without it you'd get the diaspora
|
|
13
|
-
reckoning (an extra blocked day) instead of the correct single-day Yom Tov used in Israel.
|
|
14
|
-
- **Bots always get through.** A broad, case-insensitive user-agent allowlist (Googlebot,
|
|
15
|
-
Bingbot, GPTBot, ClaudeBot, and many others) is checked first, before any other logic runs.
|
|
16
|
-
The gate only ever affects human visitors - crawlers and indexers see the real site 24/7, so
|
|
17
|
-
ranking and AI-search visibility are never impacted by the site being "closed."
|
|
18
|
-
- **Fails open.** Any error (network failure, bad API response, whatever) falls through to the
|
|
19
|
-
real site rather than showing an error page. An accidental block on a regular Tuesday would be
|
|
20
|
-
a real, visible bug; an occasional missed block during a rare error is a minor, invisible one.
|
|
21
|
-
|
|
22
|
-
## Install
|
|
23
|
-
|
|
24
|
-
```sh
|
|
25
|
-
npm install shabbat-gate
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
## Usage
|
|
29
|
-
|
|
30
|
-
In a Cloudflare Pages project, add `functions/_middleware.ts`:
|
|
31
|
-
|
|
32
|
-
```ts
|
|
33
|
-
import { createShabbatGate } from 'shabbat-gate';
|
|
34
|
-
|
|
35
|
-
const gate = createShabbatGate({ siteName: 'My Site' });
|
|
36
|
-
|
|
37
|
-
export const onRequest: PagesFunction = (context) => gate(context);
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
1
|
+
# shabbat-gate
|
|
2
|
+
|
|
3
|
+
[קריאה בעברית](README.he.md)
|
|
4
|
+
|
|
5
|
+
Cloudflare Pages / Workers middleware that automatically closes a site to human visitors
|
|
6
|
+
during Shabbat and major Jewish holidays (Israel-observance rules) - while always letting
|
|
7
|
+
search engines and AI crawlers through, so SEO stays unaffected.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
- **Israel single-day Yom Tov, not diaspora 2-day.** The holiday calendar is fetched from
|
|
12
|
+
Hebcal's free public API with `i=on`, which is critical - without it you'd get the diaspora
|
|
13
|
+
reckoning (an extra blocked day) instead of the correct single-day Yom Tov used in Israel.
|
|
14
|
+
- **Bots always get through.** A broad, case-insensitive user-agent allowlist (Googlebot,
|
|
15
|
+
Bingbot, GPTBot, ClaudeBot, and many others) is checked first, before any other logic runs.
|
|
16
|
+
The gate only ever affects human visitors - crawlers and indexers see the real site 24/7, so
|
|
17
|
+
ranking and AI-search visibility are never impacted by the site being "closed."
|
|
18
|
+
- **Fails open.** Any error (network failure, bad API response, whatever) falls through to the
|
|
19
|
+
real site rather than showing an error page. An accidental block on a regular Tuesday would be
|
|
20
|
+
a real, visible bug; an occasional missed block during a rare error is a minor, invisible one.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install shabbat-gate
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
In a Cloudflare Pages project, add `functions/_middleware.ts`:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { createShabbatGate } from 'shabbat-gate';
|
|
34
|
+
|
|
35
|
+
const gate = createShabbatGate({ siteName: 'My Site' });
|
|
36
|
+
|
|
37
|
+
export const onRequest: PagesFunction = (context) => gate(context);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Using with a plain Worker + Assets binding (not Pages)
|
|
41
|
+
|
|
42
|
+
`createShabbatGate` returns a Pages-Functions-shaped handler (`(context) => Response`), which
|
|
43
|
+
doesn't fit a plain Worker's `fetch(request, env)` signature (there's no `next()`). Use
|
|
44
|
+
`createShabbatGateForWorker` instead - it returns `null` for "let the real site through" and a
|
|
45
|
+
`Response` for "serve the holding page":
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { createShabbatGateForWorker } from 'shabbat-gate';
|
|
49
|
+
|
|
50
|
+
const gate = createShabbatGateForWorker({ siteName: 'My Site' });
|
|
51
|
+
|
|
52
|
+
export default {
|
|
53
|
+
async fetch(request: Request, env: { ASSETS: Fetcher }) {
|
|
54
|
+
const blocked = await gate(request);
|
|
55
|
+
return blocked ?? env.ASSETS.fetch(request);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Gotcha that silently defeats the whole gate:** a Cloudflare Worker with an `assets` binding
|
|
61
|
+
serves any request matching a file in the assets directory *directly*, without invoking the
|
|
62
|
+
Worker's `fetch` handler at all - unless `run_worker_first: true` is set. Without it, the gate
|
|
63
|
+
code runs and looks correctly wired up, tests pass, but real page requests (which almost always
|
|
64
|
+
match a static asset) never reach it, so the site never actually closes. In `wrangler.jsonc`:
|
|
65
|
+
|
|
66
|
+
```jsonc
|
|
67
|
+
{
|
|
68
|
+
"assets": {
|
|
69
|
+
"directory": "./dist",
|
|
70
|
+
"binding": "ASSETS",
|
|
71
|
+
"run_worker_first": true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Config
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
export interface ShabbatGateConfig {
|
|
80
|
+
siteName: string;
|
|
81
|
+
|
|
82
|
+
/** Decimal lat/long for zmanim. Both default to Jerusalem (31.7683, 35.2137) if
|
|
83
|
+
* omitted - a fine single reference point for all of Israel at this granularity. */
|
|
84
|
+
latitude?: number;
|
|
85
|
+
longitude?: number;
|
|
86
|
+
|
|
87
|
+
/** Query param name + required value that bypasses the gate entirely, so the site
|
|
88
|
+
* owner can preview/test on any day. Keep the value non-guessable - this is a
|
|
89
|
+
* testing convenience, not real auth. */
|
|
90
|
+
bypassParam?: string;
|
|
91
|
+
bypassValue?: string;
|
|
92
|
+
|
|
93
|
+
/** Optional custom holding-page renderer. Defaults to a Hebrew, mobile-responsive
|
|
94
|
+
* page showing siteName and when the site reopens. `reasonLabel` and `closingLabel`
|
|
95
|
+
* differ grammatically for plain Shabbat ("שבת קודש" opening vs. "השבת" closing) -
|
|
96
|
+
* use `closingLabel` for "back after ___", not `reasonLabel` again. */
|
|
97
|
+
renderHoldingPage?: (ctx: {
|
|
98
|
+
siteName: string;
|
|
99
|
+
reasonLabel: string;
|
|
100
|
+
closingLabel: string;
|
|
101
|
+
untilLabel: string;
|
|
102
|
+
}) => string;
|
|
103
|
+
|
|
104
|
+
/** Minutes to close the site *before* candle-lighting and reopen *after*
|
|
105
|
+
* havdalah, on top of the raw Hebcal window. Defaults to 0. Useful padding
|
|
106
|
+
* against clock drift / last-minute browsing right at the boundary. */
|
|
107
|
+
bufferMinutes?: number;
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Full example:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { createShabbatGate } from 'shabbat-gate';
|
|
115
|
+
|
|
116
|
+
const gate = createShabbatGate({
|
|
117
|
+
siteName: 'tehila·games',
|
|
118
|
+
latitude: 31.7683,
|
|
119
|
+
longitude: 35.2137,
|
|
120
|
+
bypassParam: 'preview',
|
|
121
|
+
bypassValue: 'letmein-9f3a7c',
|
|
122
|
+
bufferMinutes: 10,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const onRequest: PagesFunction = (context) => gate(context);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## How it works
|
|
129
|
+
|
|
130
|
+
1. Bot check (allowlist regex on the `user-agent` header) - matches pass straight through.
|
|
131
|
+
2. Bypass check - if the bypass query param + value match, pass straight through.
|
|
132
|
+
3. Fetch (with ~24h caching via the Workers Cache API) the merged list of Shabbat and major
|
|
133
|
+
holiday windows from Hebcal, ~45 days into the future - one call to Hebcal's `/hebcal`
|
|
134
|
+
endpoint (`ss=on` for weekly Shabbat + `maj=on` for major holidays), passing `latitude`/
|
|
135
|
+
`longitude` directly so every window is correctly localized, not just the nearest one.
|
|
136
|
+
4. `bufferMinutes` (if set) is applied on top of the fetched windows before the time check.
|
|
137
|
+
5. If the current time falls inside a window, serve the holding page (HTTP 200). Otherwise let
|
|
138
|
+
the real site through.
|
|
139
|
+
6. Any error along the way falls through to the real site.
|
|
140
|
+
|
|
141
|
+
## Internal cache key
|
|
142
|
+
|
|
143
|
+
The merged window list is cached under a fixed internal key
|
|
144
|
+
(`https://internal.cache/shabbat-gate-windows-v1`, exported as `INTERNAL_CACHE_KEY_URL`) for
|
|
145
|
+
~24h via the Workers Cache API. If your own code also caches derived data (e.g. windows with
|
|
146
|
+
your own buffer applied) via `caches.default`, use a different key - reusing this one will
|
|
147
|
+
silently serve stale, unprocessed data for up to 24h.
|
|
148
|
+
|
|
149
|
+
## Changelog
|
|
150
|
+
|
|
151
|
+
See [CHANGELOG.md](CHANGELOG.md) for what changed in each release, including root-cause
|
|
152
|
+
explanations for fixed bugs.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
package/dist/hebcal.d.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
export interface Window {
|
|
2
2
|
start: number;
|
|
3
3
|
end: number;
|
|
4
|
+
/** Hebrew label for "the site is closed for ___". */
|
|
4
5
|
label: string;
|
|
6
|
+
/** Hebrew label for "back after ___ [ends]" - grammatically distinct from
|
|
7
|
+
* `label` for Shabbat ("שבת קודש" opening vs. "השבת" closing). */
|
|
8
|
+
closingLabel: string;
|
|
5
9
|
}
|
|
6
10
|
interface HebcalItem {
|
|
7
11
|
title: string;
|
|
8
12
|
hebrew?: string;
|
|
9
13
|
date: string;
|
|
10
14
|
category: string;
|
|
15
|
+
/** Present on `candles`/`havdalah` items: the title of the holiday/parasha
|
|
16
|
+
* it belongs to (e.g. "Erev Rosh Hashana", or "Parashat Devarim" for a
|
|
17
|
+
* plain Shabbat week). Used to cross-reference against `holiday` items'
|
|
18
|
+
* own `title` to find the right Hebrew label for *this specific* window,
|
|
19
|
+
* instead of trusting whichever `holiday` item happened to appear most
|
|
20
|
+
* recently in the feed (which leaks into unrelated windows - see below). */
|
|
21
|
+
memo?: string;
|
|
11
22
|
}
|
|
12
23
|
/**
|
|
13
24
|
* Pairs candles/havdalah events into continuous windows. Multi-day holidays
|
|
@@ -15,11 +26,39 @@ interface HebcalItem {
|
|
|
15
26
|
* very end, so candles cannot simply be paired 1:1 with the next havdalah.
|
|
16
27
|
* Instead: open a window on the first candles seen while none is open, ignore
|
|
17
28
|
* further candles while one is open, and close on the next havdalah.
|
|
29
|
+
*
|
|
30
|
+
* `defaults` overrides the label for windows that have no matching `holiday`
|
|
31
|
+
* item (i.e. plain Shabbat weeks) - pass it when calling this with a merged
|
|
32
|
+
* feed that mixes weekly Shabbat and holiday items together.
|
|
33
|
+
*
|
|
34
|
+
* Holiday windows pick up the matching `holiday` item's own Hebrew name (e.g.
|
|
35
|
+
* "ערב ראש השנה") by matching the opening `candles` item's `memo` field
|
|
36
|
+
* against a `holiday` item's `title` - not just "the most recent holiday item
|
|
37
|
+
* seen so far". Some `holiday`-category items (e.g. fast days like תשעה באב,
|
|
38
|
+
* which are `maj=on` but have no candle-lighting of their own) never get
|
|
39
|
+
* consumed by a window; naively tracking "last holiday label seen" would leak
|
|
40
|
+
* their label into the next, unrelated Shabbat window instead of falling back
|
|
41
|
+
* to `defaults`.
|
|
18
42
|
*/
|
|
19
|
-
export declare function pairWindows(items: HebcalItem[]
|
|
43
|
+
export declare function pairWindows(items: HebcalItem[], defaults?: {
|
|
44
|
+
label: string;
|
|
45
|
+
closingLabel: string;
|
|
46
|
+
}): Window[];
|
|
20
47
|
/**
|
|
21
48
|
* Fetches and merges Shabbat + major-holiday (Israel single-day Yom Tov mode)
|
|
22
49
|
* windows for the next ~45 days from Hebcal's free public JSON API.
|
|
50
|
+
*
|
|
51
|
+
* Uses a *single* call to the `/hebcal` endpoint (not the separate `/shabbat`
|
|
52
|
+
* endpoint) with `ss=on` added, passing `latitude`/`longitude` directly
|
|
53
|
+
* instead of a `geonameid`. Two real bugs motivated this over the previous
|
|
54
|
+
* two-call approach: (1) `/shabbat?start=...&end=...` silently ignores the
|
|
55
|
+
* requested range and only ever returns the single nearest Shabbat,
|
|
56
|
+
* regardless of how far out `end` is; (2) `geonameid` always resolves to a
|
|
57
|
+
* fixed city (Jerusalem), so every week after the nearest one was computed
|
|
58
|
+
* for the wrong location instead of the coordinates passed in. Querying
|
|
59
|
+
* `/hebcal` with `ss=on` + lat/long returns every Shabbat and holiday in the
|
|
60
|
+
* range, correctly localized, in one chronologically-ordered, already-merged
|
|
61
|
+
* list - which also means there's nothing left to de-duplicate.
|
|
23
62
|
*/
|
|
24
63
|
export declare function fetchWindows(latitude: number, longitude: number): Promise<Window[]>;
|
|
25
64
|
/** Pure function: is `now` inside any of the given windows? */
|
package/dist/hebcal.js
CHANGED
|
@@ -1,26 +1,58 @@
|
|
|
1
|
-
|
|
1
|
+
/** Hebcal's own "candles"/"havdalah" items always carry the generic literal
|
|
2
|
+
* "הדלקת נרות"/"הבדלה" in their `hebrew` field, never the occasion name - so
|
|
3
|
+
* it's unusable as a display label on its own. For a plain Shabbat week
|
|
4
|
+
* (no accompanying `holiday` item), fall back to this fixed pair instead. */
|
|
5
|
+
const SHABBAT_LABEL = 'שבת קודש';
|
|
6
|
+
const SHABBAT_CLOSING_LABEL = 'השבת';
|
|
2
7
|
/**
|
|
3
8
|
* Pairs candles/havdalah events into continuous windows. Multi-day holidays
|
|
4
9
|
* (e.g. Rosh Hashana) emit two "candles" events but only one "havdalah" at the
|
|
5
10
|
* very end, so candles cannot simply be paired 1:1 with the next havdalah.
|
|
6
11
|
* Instead: open a window on the first candles seen while none is open, ignore
|
|
7
12
|
* further candles while one is open, and close on the next havdalah.
|
|
13
|
+
*
|
|
14
|
+
* `defaults` overrides the label for windows that have no matching `holiday`
|
|
15
|
+
* item (i.e. plain Shabbat weeks) - pass it when calling this with a merged
|
|
16
|
+
* feed that mixes weekly Shabbat and holiday items together.
|
|
17
|
+
*
|
|
18
|
+
* Holiday windows pick up the matching `holiday` item's own Hebrew name (e.g.
|
|
19
|
+
* "ערב ראש השנה") by matching the opening `candles` item's `memo` field
|
|
20
|
+
* against a `holiday` item's `title` - not just "the most recent holiday item
|
|
21
|
+
* seen so far". Some `holiday`-category items (e.g. fast days like תשעה באב,
|
|
22
|
+
* which are `maj=on` but have no candle-lighting of their own) never get
|
|
23
|
+
* consumed by a window; naively tracking "last holiday label seen" would leak
|
|
24
|
+
* their label into the next, unrelated Shabbat window instead of falling back
|
|
25
|
+
* to `defaults`.
|
|
8
26
|
*/
|
|
9
|
-
export function pairWindows(items) {
|
|
27
|
+
export function pairWindows(items, defaults) {
|
|
10
28
|
const windows = [];
|
|
29
|
+
const holidayTitles = new Map();
|
|
11
30
|
let openStart = null;
|
|
12
31
|
let openLabel = '';
|
|
32
|
+
let openClosingLabel = '';
|
|
13
33
|
for (const item of items) {
|
|
34
|
+
if (item.category === 'holiday') {
|
|
35
|
+
holidayTitles.set(item.title, item.hebrew ?? item.title);
|
|
36
|
+
}
|
|
14
37
|
if (item.category === 'candles') {
|
|
15
38
|
if (openStart === null) {
|
|
16
39
|
openStart = new Date(item.date).getTime();
|
|
17
|
-
|
|
40
|
+
const matchedHoliday = item.memo ? holidayTitles.get(item.memo) : undefined;
|
|
41
|
+
const label = matchedHoliday ?? defaults?.label ?? item.hebrew ?? item.title;
|
|
42
|
+
openLabel = label;
|
|
43
|
+
openClosingLabel = matchedHoliday ? label : (defaults?.closingLabel ?? label);
|
|
18
44
|
}
|
|
19
45
|
}
|
|
20
46
|
else if (item.category === 'havdalah' && openStart !== null) {
|
|
21
|
-
windows.push({
|
|
47
|
+
windows.push({
|
|
48
|
+
start: openStart,
|
|
49
|
+
end: new Date(item.date).getTime(),
|
|
50
|
+
label: openLabel,
|
|
51
|
+
closingLabel: openClosingLabel,
|
|
52
|
+
});
|
|
22
53
|
openStart = null;
|
|
23
54
|
openLabel = '';
|
|
55
|
+
openClosingLabel = '';
|
|
24
56
|
}
|
|
25
57
|
}
|
|
26
58
|
return windows;
|
|
@@ -31,31 +63,37 @@ function toISODate(date) {
|
|
|
31
63
|
/**
|
|
32
64
|
* Fetches and merges Shabbat + major-holiday (Israel single-day Yom Tov mode)
|
|
33
65
|
* windows for the next ~45 days from Hebcal's free public JSON API.
|
|
66
|
+
*
|
|
67
|
+
* Uses a *single* call to the `/hebcal` endpoint (not the separate `/shabbat`
|
|
68
|
+
* endpoint) with `ss=on` added, passing `latitude`/`longitude` directly
|
|
69
|
+
* instead of a `geonameid`. Two real bugs motivated this over the previous
|
|
70
|
+
* two-call approach: (1) `/shabbat?start=...&end=...` silently ignores the
|
|
71
|
+
* requested range and only ever returns the single nearest Shabbat,
|
|
72
|
+
* regardless of how far out `end` is; (2) `geonameid` always resolves to a
|
|
73
|
+
* fixed city (Jerusalem), so every week after the nearest one was computed
|
|
74
|
+
* for the wrong location instead of the coordinates passed in. Querying
|
|
75
|
+
* `/hebcal` with `ss=on` + lat/long returns every Shabbat and holiday in the
|
|
76
|
+
* range, correctly localized, in one chronologically-ordered, already-merged
|
|
77
|
+
* list - which also means there's nothing left to de-duplicate.
|
|
34
78
|
*/
|
|
35
79
|
export async function fetchWindows(latitude, longitude) {
|
|
36
80
|
const start = new Date();
|
|
37
81
|
const end = new Date(start.getTime() + 45 * 24 * 60 * 60 * 1000);
|
|
38
82
|
const startParam = toISODate(start);
|
|
39
83
|
const endParam = toISODate(end);
|
|
40
|
-
const shabbatUrl = `https://www.hebcal.com/shabbat?cfg=json&latitude=${latitude}&longitude=${longitude}` +
|
|
41
|
-
`&tzid=Asia/Jerusalem&M=on&start=${startParam}&end=${endParam}`;
|
|
42
84
|
// i=on = Israel single-day Yom Tov reckoning (not diaspora 2-day).
|
|
43
85
|
// c=on = attach candles/havdalah entries to holidays, not just bare dates.
|
|
86
|
+
// ss=on = weekly Shabbat candle-lighting/havdalah, localized to lat/long.
|
|
44
87
|
// maj=on + everything else off = only real work-restricted Yom Tov days.
|
|
45
|
-
const
|
|
46
|
-
`&c=on&i=on&
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
88
|
+
const url = `https://www.hebcal.com/hebcal?cfg=json&v=1&maj=on&min=off&mod=off&nx=off&mf=off&ss=on` +
|
|
89
|
+
`&c=on&i=on&latitude=${latitude}&longitude=${longitude}&tzid=Asia/Jerusalem` +
|
|
90
|
+
`&start=${startParam}&end=${endParam}`;
|
|
91
|
+
const res = await fetch(url);
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
throw new Error(`hebcal fetch failed: ${res.status}`);
|
|
50
94
|
}
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
holidayRes.json(),
|
|
54
|
-
]));
|
|
55
|
-
const windows = [
|
|
56
|
-
...pairWindows(shabbatData.items ?? []),
|
|
57
|
-
...pairWindows(holidayData.items ?? []),
|
|
58
|
-
];
|
|
95
|
+
const data = (await res.json());
|
|
96
|
+
const windows = pairWindows(data.items ?? [], { label: SHABBAT_LABEL, closingLabel: SHABBAT_CLOSING_LABEL });
|
|
59
97
|
return windows.sort((a, b) => a.start - b.start);
|
|
60
98
|
}
|
|
61
99
|
/** Pure function: is `now` inside any of the given windows? */
|
package/dist/holdingPage.d.ts
CHANGED
package/dist/holdingPage.js
CHANGED
|
@@ -30,7 +30,7 @@ export function defaultRenderHoldingPage(ctx) {
|
|
|
30
30
|
<body>
|
|
31
31
|
<div class="card">
|
|
32
32
|
<h1>${ctx.siteName}</h1>
|
|
33
|
-
<p>האתר סגור לכבוד ${ctx.reasonLabel}, ניפגש שוב אחרי
|
|
33
|
+
<p>האתר סגור לכבוד ${ctx.reasonLabel}, ניפגש שוב אחרי צאת ${ctx.closingLabel}.</p>
|
|
34
34
|
<p class="until">שעת פתיחה משוערת: ${ctx.untilLabel}</p>
|
|
35
35
|
</div>
|
|
36
36
|
</body>
|
package/dist/index.d.ts
CHANGED
|
@@ -18,7 +18,19 @@ export interface ShabbatGateConfig {
|
|
|
18
18
|
/** Optional custom holding-page renderer. Defaults to a Hebrew, mobile-
|
|
19
19
|
* responsive page showing siteName + when the site reopens. */
|
|
20
20
|
renderHoldingPage?: (ctx: HoldingPageContext) => string;
|
|
21
|
+
/** Minutes to close the site *before* candle-lighting and reopen *after*
|
|
22
|
+
* havdalah, on top of the raw Hebcal window. Defaults to 0 (no buffer).
|
|
23
|
+
* Useful padding against clock drift / last-minute browsing right at the
|
|
24
|
+
* boundary - applied at decision time, not baked into the cached windows,
|
|
25
|
+
* so changing it takes effect immediately without waiting on the cache. */
|
|
26
|
+
bufferMinutes?: number;
|
|
21
27
|
}
|
|
28
|
+
/** Internal cache key for the merged window list (~24h TTL via the Workers
|
|
29
|
+
* Cache API). Exported so consumers that do their own caching of
|
|
30
|
+
* derived/post-processed window data (e.g. after applying their own buffer)
|
|
31
|
+
* can pick a different key and avoid accidentally colliding with this one -
|
|
32
|
+
* which would silently serve stale, unprocessed windows for up to 24h. */
|
|
33
|
+
export declare const INTERNAL_CACHE_KEY_URL = "https://internal.cache/shabbat-gate-windows-v1";
|
|
22
34
|
/**
|
|
23
35
|
* Returns a Cloudflare Pages Functions-compatible handler that closes the
|
|
24
36
|
* site to human visitors during Shabbat and major Jewish holidays, while
|
|
@@ -27,3 +39,23 @@ export interface ShabbatGateConfig {
|
|
|
27
39
|
* occasional missed block during an error is a minor, invisible one.
|
|
28
40
|
*/
|
|
29
41
|
export declare function createShabbatGate(config: ShabbatGateConfig): PagesFunction;
|
|
42
|
+
/**
|
|
43
|
+
* Same gate logic as `createShabbatGate`, adapted for a plain Cloudflare
|
|
44
|
+
* Worker (with a static `assets` binding) instead of Pages Functions - there
|
|
45
|
+
* is no `next()` to call in that shape, so this returns `null` for "let the
|
|
46
|
+
* real site through" and a `Response` for "serve the holding page". Wire it
|
|
47
|
+
* up in the Worker's own `fetch` handler:
|
|
48
|
+
*
|
|
49
|
+
* const gate = createShabbatGateForWorker({ siteName: 'My Site' });
|
|
50
|
+
* export default {
|
|
51
|
+
* async fetch(request, env, ctx) {
|
|
52
|
+
* const blocked = await gate(request);
|
|
53
|
+
* return blocked ?? env.ASSETS.fetch(request);
|
|
54
|
+
* },
|
|
55
|
+
* };
|
|
56
|
+
*
|
|
57
|
+
* Note: a Worker with an `assets` binding skips the `fetch` handler entirely
|
|
58
|
+
* for requests matching a static asset unless `assets.run_worker_first: true`
|
|
59
|
+
* is set in `wrangler.jsonc` - without it, this gate never runs.
|
|
60
|
+
*/
|
|
61
|
+
export declare function createShabbatGateForWorker(config: ShabbatGateConfig): (request: Request) => Promise<Response | null>;
|
package/dist/index.js
CHANGED
|
@@ -6,11 +6,16 @@ export { isBot, BOT_PATTERN } from './botPattern.js';
|
|
|
6
6
|
export { defaultRenderHoldingPage } from './holdingPage.js';
|
|
7
7
|
const JERUSALEM_LATITUDE = 31.7683;
|
|
8
8
|
const JERUSALEM_LONGITUDE = 35.2137;
|
|
9
|
-
|
|
9
|
+
/** Internal cache key for the merged window list (~24h TTL via the Workers
|
|
10
|
+
* Cache API). Exported so consumers that do their own caching of
|
|
11
|
+
* derived/post-processed window data (e.g. after applying their own buffer)
|
|
12
|
+
* can pick a different key and avoid accidentally colliding with this one -
|
|
13
|
+
* which would silently serve stale, unprocessed windows for up to 24h. */
|
|
14
|
+
export const INTERNAL_CACHE_KEY_URL = 'https://internal.cache/shabbat-gate-windows-v1';
|
|
10
15
|
const CACHE_TTL_SECONDS = 24 * 60 * 60;
|
|
11
16
|
async function getWindows(latitude, longitude) {
|
|
12
17
|
const cache = caches.default;
|
|
13
|
-
const cacheRequest = new Request(
|
|
18
|
+
const cacheRequest = new Request(INTERNAL_CACHE_KEY_URL);
|
|
14
19
|
const cached = await cache.match(cacheRequest);
|
|
15
20
|
if (cached) {
|
|
16
21
|
return (await cached.json());
|
|
@@ -34,6 +39,53 @@ function formatJerusalemTime(epochMs) {
|
|
|
34
39
|
minute: '2-digit',
|
|
35
40
|
}).format(new Date(epochMs));
|
|
36
41
|
}
|
|
42
|
+
function applyBuffer(windows, bufferMinutes) {
|
|
43
|
+
if (!bufferMinutes) {
|
|
44
|
+
return windows;
|
|
45
|
+
}
|
|
46
|
+
const bufferMs = bufferMinutes * 60_000;
|
|
47
|
+
return windows.map((w) => ({ ...w, start: w.start - bufferMs, end: w.end + bufferMs }));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Shared core: bot allowlist, bypass check, window fetch (cached) + buffer,
|
|
51
|
+
* and the fail-open try/catch. Both `createShabbatGate` (Pages Functions) and
|
|
52
|
+
* `createShabbatGateForWorker` (plain Workers + Assets) are thin wrappers
|
|
53
|
+
* around this, so neither can drift out of sync on caching/fail-open/bypass
|
|
54
|
+
* behavior.
|
|
55
|
+
*/
|
|
56
|
+
async function evaluateGate(config, request) {
|
|
57
|
+
const userAgent = request.headers.get('user-agent') ?? '';
|
|
58
|
+
if (isBot(userAgent)) {
|
|
59
|
+
return { type: 'pass' };
|
|
60
|
+
}
|
|
61
|
+
if (config.bypassParam && config.bypassValue) {
|
|
62
|
+
const url = new URL(request.url);
|
|
63
|
+
if (url.searchParams.get(config.bypassParam) === config.bypassValue) {
|
|
64
|
+
return { type: 'pass' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const latitude = config.latitude ?? JERUSALEM_LATITUDE;
|
|
69
|
+
const longitude = config.longitude ?? JERUSALEM_LONGITUDE;
|
|
70
|
+
const windows = applyBuffer(await getWindows(latitude, longitude), config.bufferMinutes ?? 0);
|
|
71
|
+
const active = findActiveWindow(windows, Date.now());
|
|
72
|
+
if (!active) {
|
|
73
|
+
return { type: 'pass' };
|
|
74
|
+
}
|
|
75
|
+
const render = config.renderHoldingPage ?? defaultRenderHoldingPage;
|
|
76
|
+
const html = render({
|
|
77
|
+
siteName: config.siteName,
|
|
78
|
+
reasonLabel: active.label,
|
|
79
|
+
closingLabel: active.closingLabel,
|
|
80
|
+
untilLabel: formatJerusalemTime(active.end),
|
|
81
|
+
});
|
|
82
|
+
return { type: 'block', html };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
console.error('shabbat-gate: failing open due to error', error);
|
|
86
|
+
return { type: 'pass' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
37
89
|
/**
|
|
38
90
|
* Returns a Cloudflare Pages Functions-compatible handler that closes the
|
|
39
91
|
* site to human visitors during Shabbat and major Jewish holidays, while
|
|
@@ -43,39 +95,44 @@ function formatJerusalemTime(epochMs) {
|
|
|
43
95
|
*/
|
|
44
96
|
export function createShabbatGate(config) {
|
|
45
97
|
return async (context) => {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return next();
|
|
50
|
-
}
|
|
51
|
-
if (config.bypassParam && config.bypassValue) {
|
|
52
|
-
const url = new URL(request.url);
|
|
53
|
-
if (url.searchParams.get(config.bypassParam) === config.bypassValue) {
|
|
54
|
-
return next();
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
try {
|
|
58
|
-
const latitude = config.latitude ?? JERUSALEM_LATITUDE;
|
|
59
|
-
const longitude = config.longitude ?? JERUSALEM_LONGITUDE;
|
|
60
|
-
const windows = await getWindows(latitude, longitude);
|
|
61
|
-
const active = findActiveWindow(windows, Date.now());
|
|
62
|
-
if (!active) {
|
|
63
|
-
return next();
|
|
64
|
-
}
|
|
65
|
-
const render = config.renderHoldingPage ?? defaultRenderHoldingPage;
|
|
66
|
-
const html = render({
|
|
67
|
-
siteName: config.siteName,
|
|
68
|
-
reasonLabel: active.label,
|
|
69
|
-
untilLabel: formatJerusalemTime(active.end),
|
|
70
|
-
});
|
|
71
|
-
return new Response(html, {
|
|
72
|
-
status: 200,
|
|
73
|
-
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
74
|
-
});
|
|
98
|
+
const decision = await evaluateGate(config, context.request);
|
|
99
|
+
if (decision.type === 'pass') {
|
|
100
|
+
return context.next();
|
|
75
101
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
102
|
+
return new Response(decision.html, {
|
|
103
|
+
status: 200,
|
|
104
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Same gate logic as `createShabbatGate`, adapted for a plain Cloudflare
|
|
110
|
+
* Worker (with a static `assets` binding) instead of Pages Functions - there
|
|
111
|
+
* is no `next()` to call in that shape, so this returns `null` for "let the
|
|
112
|
+
* real site through" and a `Response` for "serve the holding page". Wire it
|
|
113
|
+
* up in the Worker's own `fetch` handler:
|
|
114
|
+
*
|
|
115
|
+
* const gate = createShabbatGateForWorker({ siteName: 'My Site' });
|
|
116
|
+
* export default {
|
|
117
|
+
* async fetch(request, env, ctx) {
|
|
118
|
+
* const blocked = await gate(request);
|
|
119
|
+
* return blocked ?? env.ASSETS.fetch(request);
|
|
120
|
+
* },
|
|
121
|
+
* };
|
|
122
|
+
*
|
|
123
|
+
* Note: a Worker with an `assets` binding skips the `fetch` handler entirely
|
|
124
|
+
* for requests matching a static asset unless `assets.run_worker_first: true`
|
|
125
|
+
* is set in `wrangler.jsonc` - without it, this gate never runs.
|
|
126
|
+
*/
|
|
127
|
+
export function createShabbatGateForWorker(config) {
|
|
128
|
+
return async (request) => {
|
|
129
|
+
const decision = await evaluateGate(config, request);
|
|
130
|
+
if (decision.type === 'pass') {
|
|
131
|
+
return null;
|
|
79
132
|
}
|
|
133
|
+
return new Response(decision.html, {
|
|
134
|
+
status: 200,
|
|
135
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
136
|
+
});
|
|
80
137
|
};
|
|
81
138
|
}
|
package/package.json
CHANGED
|
@@ -1,35 +1,36 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "shabbat-gate",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"type": "module",
|
|
6
|
-
"main": "./dist/index.js",
|
|
7
|
-
"types": "./dist/index.d.ts",
|
|
8
|
-
"files": [
|
|
9
|
-
"dist",
|
|
10
|
-
"README.md",
|
|
11
|
-
"README.he.md",
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"cloudflare-
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "shabbat-gate",
|
|
3
|
+
"version": "0.1.3",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md",
|
|
11
|
+
"README.he.md",
|
|
12
|
+
"CHANGELOG.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"cloudflare-pages",
|
|
21
|
+
"cloudflare-workers",
|
|
22
|
+
"shabbat",
|
|
23
|
+
"jewish-holidays",
|
|
24
|
+
"hebcal",
|
|
25
|
+
"israel"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@cloudflare/workers-types": "^4.20250204.0",
|
|
33
|
+
"typescript": "^5.7.3",
|
|
34
|
+
"vitest": "^3.0.5"
|
|
35
|
+
}
|
|
36
|
+
}
|