@erenthedeveloper0/zen-middleware 0.1.0-alpha.1

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/src/store.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The counter store — rfcs/0001 §3.5, the `Store` seam.
3
+ *
4
+ * §3.5's inventory lists one interface shared by the cache, the rate limiter
5
+ * and the session store, with an in-memory default and Redis / SQLite / D1 /
6
+ * Durable Object as the alternates. This module is the rate limiter's half of
7
+ * it: the narrowest contract that a fixed-window counter needs, chosen so that
8
+ * a Redis implementation is two commands rather than a transaction.
9
+ *
10
+ * INCR zen:rl:{window}:{key}
11
+ * PEXPIRE zen:rl:{window}:{key} {windowMs} // only on the first hit
12
+ *
13
+ * That is the whole implementation, and it is the reason `hit` returns the
14
+ * count *including* the current request rather than taking a "should I allow
15
+ * this" decision. A store that decided would need the limit, and a store that
16
+ * needs the limit cannot be shared with the cache.
17
+ */
18
+
19
+ /** One key's standing in the window it is currently inside. */
20
+ export interface Tally {
21
+ /** Hits in this window, **including** the one that produced this tally. */
22
+ readonly count: number
23
+ /** Epoch milliseconds at which this window ends and the count resets. */
24
+ readonly resetAt: number
25
+ }
26
+
27
+ /**
28
+ * A counter keyed by string, bucketed into fixed windows.
29
+ *
30
+ * `hit` may be async — a Redis store is a round trip — and the rate limiter
31
+ * awaits it only when it actually returns a promise, so the in-memory default
32
+ * keeps the whole limiter on §8.4's synchronous fast path.
33
+ */
34
+ export interface Store {
35
+ hit(key: string, now: number): Tally | Promise<Tally>
36
+ /** Drop everything. Tests use it; production has no reason to. */
37
+ reset?(): void
38
+ /** Release connections. Called from the limiter's `onClose` (§4.5). */
39
+ close?(): void | Promise<void>
40
+ /** Live keys, when the store can say. Read by the memory-bound gate. */
41
+ readonly size?: number
42
+ }
43
+
44
+ export interface MemoryStoreOptions {
45
+ readonly windowMs: number
46
+ }
47
+
48
+ /**
49
+ * The default store: a fixed-window counter that forgets a window when it ends.
50
+ *
51
+ * **Fixed windows, stated plainly.** A key's count resets at a wall-clock
52
+ * boundary, so a client can spend its whole budget in the last millisecond of
53
+ * one window and its whole budget again in the first millisecond of the next —
54
+ * up to `2 × limit` inside one window's worth of time straddling the boundary.
55
+ * That is the known cost of fixed windows and it belongs here rather than in a
56
+ * footnote, because the alternatives are not free: a sliding log keeps one
57
+ * timestamp per request, which is unbounded memory per key and the key is the
58
+ * thing an attacker chooses; and a sliding-window *counter* cannot be expressed
59
+ * as `INCR` + `PEXPIRE`, so it would push every alternate store into a Lua
60
+ * script or a transaction. §28.8 records it as a gap.
61
+ *
62
+ * **Eviction is a dropped reference, not a sweep.** The failure mode of every
63
+ * in-memory limiter is that its map is keyed by something the client chooses —
64
+ * an IP, an API key, a header — so whoever is attacking decides how much memory
65
+ * the process holds. The usual answer is a timer that walks the map, and
66
+ * walking a map with ten million keys is a pause that happens under exactly the
67
+ * load that created the keys.
68
+ *
69
+ * A fixed window needs neither. When the clock crosses into a new window every
70
+ * count in the old one is dead by definition, so the map is replaced whole: one
71
+ * assignment, no scan, no pause, and the collector reclaims a generation at
72
+ * once. Memory is bounded by the distinct keys seen inside a *single* window,
73
+ * and there is no timer to leak — which also means no `unref`, a Node API this
74
+ * package cannot use if it is to run everywhere core does.
75
+ *
76
+ * The saving is that the eviction rule falls out of the counting rule instead
77
+ * of being a second mechanism bolted beside it. Everything a sweep could get
78
+ * wrong — evicting a live key, retaining a dead one, pausing — is unreachable,
79
+ * because there is no sweep. What *is* reachable is losing or double-counting a
80
+ * key across the boundary, and that is what the differential suite fuzzes.
81
+ */
82
+ export class MemoryStore implements Store {
83
+ readonly #windowMs: number
84
+ #counts = new Map<string, number>()
85
+ /** The window index (`floor(now / windowMs)`) `#counts` belongs to. */
86
+ #window = -1
87
+
88
+ constructor(options: MemoryStoreOptions) {
89
+ this.#windowMs = options.windowMs
90
+ }
91
+
92
+ hit(key: string, now: number): Tally {
93
+ const window = Math.floor(now / this.#windowMs)
94
+ if (window !== this.#window) {
95
+ // Not `.clear()`: replacing the reference lets the collector take the
96
+ // whole map, while `clear` walks it. At the sizes this exists to survive,
97
+ // that is the difference the design is for.
98
+ this.#counts = new Map()
99
+ this.#window = window
100
+ }
101
+
102
+ const count = (this.#counts.get(key) ?? 0) + 1
103
+ this.#counts.set(key, count)
104
+ return { count, resetAt: (window + 1) * this.#windowMs }
105
+ }
106
+
107
+ reset(): void {
108
+ this.#counts = new Map()
109
+ this.#window = -1
110
+ }
111
+
112
+ get size(): number {
113
+ return this.#counts.size
114
+ }
115
+ }
116
+
117
+ /**
118
+ * The reference twin — rfcs/0001 §14.5, §20.5, I6.
119
+ *
120
+ * Same contract, no eviction: one map keyed by window *and* key, which grows
121
+ * forever. It is obviously correct and completely unusable, which is exactly
122
+ * what a reference implementation is for. `MemoryStore` must agree with it on
123
+ * every verdict over a non-decreasing clock; where they differ, the difference
124
+ * is a count the evicting store lost or repeated at a window boundary, and that
125
+ * is the only bug class this component has.
126
+ *
127
+ * They *do* diverge, in one case, and it is a documented property rather than a
128
+ * defect: on a clock that steps **backwards** across a boundary the reference
129
+ * still remembers the window it left and `MemoryStore` does not. Fuzzing a
130
+ * non-decreasing clock and pinning the backwards case in one named test is the
131
+ * honest split — a fuzzer that quietly avoided the disagreement would be
132
+ * hiding it, and one that asserted equality there would be asserting something
133
+ * neither implementation promises.
134
+ *
135
+ * Exported rather than kept in the test directory for the same reason
136
+ * `PlainContext` and `walkSerializer` are: a twin only the framework's own
137
+ * tests can reach is one nobody verifying an alternate store can use, and
138
+ * §14.5 says every optimised subsystem ships its reference implementation.
139
+ */
140
+ export class ReferenceStore implements Store {
141
+ readonly #windowMs: number
142
+ readonly #counts = new Map<string, number>()
143
+
144
+ constructor(options: MemoryStoreOptions) {
145
+ this.#windowMs = options.windowMs
146
+ }
147
+
148
+ hit(key: string, now: number): Tally {
149
+ const window = Math.floor(now / this.#windowMs)
150
+ // The window is always base-10 digits, so the text up to the first colon is
151
+ // unambiguous however exotic the key is — no separator a key could forge.
152
+ const composite = `${window}:${key}`
153
+ const count = (this.#counts.get(composite) ?? 0) + 1
154
+ this.#counts.set(composite, count)
155
+ return { count, resetAt: (window + 1) * this.#windowMs }
156
+ }
157
+
158
+ reset(): void {
159
+ this.#counts.clear()
160
+ }
161
+
162
+ get size(): number {
163
+ return this.#counts.size
164
+ }
165
+ }