@chaos-overlords/worker 0.9.9 → 0.9.10

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/README.md CHANGED
@@ -9,12 +9,15 @@ This package ships TypeScript sources rather than a bundle, because wrangler bun
9
9
  itself. A deployment re-exports both the handler and the Durable Object class:
10
10
 
11
11
  ```ts
12
- export { MatchHub } from '@chaos-overlords/worker'
12
+ export { MatchHub, RateLimitCounter } from '@chaos-overlords/worker'
13
13
  export { default } from '@chaos-overlords/worker'
14
14
  ```
15
15
 
16
- It needs two D1 bindings (`DB`, `BUG_DB`), an R2 bucket (`BUG_BLOBS`) and the `MATCH_HUB` Durable
17
- Object namespace, whose migration lineage starts at tag `v1` with `new_sqlite_classes = ["MatchHub"]`.
16
+ It needs two D1 bindings (`DB`, `BUG_DB`), an R2 bucket (`BUG_BLOBS`) and two Durable Object
17
+ namespaces: `MATCH_HUB`, whose migration lineage starts at tag `v1` with
18
+ `new_sqlite_classes = ["MatchHub"]`, and `RATE_LIMITS`, the `RateLimitCounter` class added at tag
19
+ `v2` with `new_sqlite_classes = ["RateLimitCounter"]`. `RATE_LIMITS` is what makes the rate limits
20
+ global; without it every isolate counts on its own, and the Worker logs `RATE_LIMITS is not bound`.
18
21
  The D1 migration lineages ship in `@chaos-overlords/storage` and `@chaos-overlords/bug-reports`; point
19
22
  `wrangler d1 migrations apply` at them rather than copying them.
20
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaos-overlords/worker",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
4
4
  "description": "Cloudflare Workers runtime facade: the shared Hono app over D1, with one Durable Object per match for SSE fan-out and turn-deadline alarms.",
5
5
  "keywords": [
6
6
  "chaos-overlords",
@@ -47,17 +47,17 @@
47
47
  "provenance": true
48
48
  },
49
49
  "dependencies": {
50
- "@chaos-overlords/bug-reports": "0.9.9",
51
- "@chaos-overlords/contracts": "0.9.9",
52
- "@chaos-overlords/kernel": "0.9.9",
53
- "@chaos-overlords/server": "0.9.9",
54
- "@chaos-overlords/storage": "0.9.9",
50
+ "@chaos-overlords/bug-reports": "0.9.10",
51
+ "@chaos-overlords/contracts": "0.9.10",
52
+ "@chaos-overlords/kernel": "0.9.10",
53
+ "@chaos-overlords/server": "0.9.10",
54
+ "@chaos-overlords/storage": "0.9.10",
55
55
  "drizzle-orm": "^0.45.3",
56
56
  "hono": "^4.13.9"
57
57
  },
58
58
  "devDependencies": {
59
- "@chaos-overlords/client": "0.9.9",
60
- "@chaos-overlords/conformance": "0.9.9",
59
+ "@chaos-overlords/client": "0.9.10",
60
+ "@chaos-overlords/conformance": "0.9.10",
61
61
  "@cloudflare/vitest-pool-workers": "^0.22.0",
62
62
  "@cloudflare/workers-types": "^5.20260927.1",
63
63
  "typescript": "^7.0.2",
@@ -0,0 +1,163 @@
1
+ import {
2
+ consumeWindow,
3
+ type RateLimitPolicy,
4
+ type RateLimitStore,
5
+ type RateLimitWindow,
6
+ } from '@chaos-overlords/kernel'
7
+ import type { DurableObjectNamespace, DurableObjectState } from '@cloudflare/workers-types'
8
+
9
+ interface StoredWindow {
10
+ windowStart: number
11
+ resetAt: number
12
+ count: number
13
+ }
14
+
15
+ const WINDOW_KEY = 'window'
16
+
17
+ /**
18
+ * The longest window kept in memory alone.
19
+ *
20
+ * Cloudflare evicts an idle object after 70 to 140 seconds without a request, so a minute-long
21
+ * window has rolled before its object can be evicted for idleness, and keeping it in memory loses
22
+ * nothing but the rare window a deployment or a runtime restart interrupts, which forgives that
23
+ * caller the rest of one minute. A longer window (the day-long journal budget) would be forgiven
24
+ * by simply waiting two minutes, so it is written to the object's storage and deleted by an alarm
25
+ * when it rolls.
26
+ */
27
+ const MEMORY_ONLY_WINDOW_MS = 60_000
28
+
29
+ const PATHS = { consume: '/consume', inspect: '/inspect', refund: '/refund' } as const
30
+
31
+ /**
32
+ * One rate limit window, for one budget and one caller, wherever in the world the caller's
33
+ * requests land.
34
+ *
35
+ * Each key gets an object of its own (`idFromName(key)`), so every isolate that counts a caller
36
+ * reaches the same counter and a budget holds across the whole deployment; the object is created
37
+ * near the caller's first request, so the round trip stays short. Cloudflare's rate limiting
38
+ * binding was not usable for this: it counts per Cloudflare location, only over ten or sixty
39
+ * seconds, and answers only yes or no, with no time to wait, no inspection and no refund, which the
40
+ * day-long journal budget, `Retry-After`, the match-creation peek and the journal reservation need.
41
+ *
42
+ * An object processes one event at a time and its storage calls hold its input gate, so the read,
43
+ * decision and write of a `consume` cannot interleave with another caller's.
44
+ */
45
+ export class RateLimitCounter {
46
+ private current: StoredWindow | undefined
47
+ /** Whether `current` is also in storage, so a refund knows to write it back. */
48
+ private persisted = false
49
+ /**
50
+ * The one read of storage, shared by every call that arrives before it settles, so a call that
51
+ * reads late never overwrites a window another call has already counted in.
52
+ */
53
+ private loading: Promise<void> | undefined
54
+
55
+ constructor(
56
+ private readonly state: DurableObjectState,
57
+ _env: unknown,
58
+ ) {}
59
+
60
+ async fetch(request: Request): Promise<Response> {
61
+ const path = new URL(request.url).pathname
62
+ const body = (await request.json()) as {
63
+ policy?: RateLimitPolicy
64
+ now?: number
65
+ windowStart?: number
66
+ }
67
+ await this.load()
68
+ if (path === PATHS.consume && body.policy && typeof body.now === 'number') {
69
+ const window = consumeWindow(this.current, body.policy, body.now)
70
+ // A refused call leaves the window as it was, so there is nothing to write.
71
+ if (window.allowed) {
72
+ await this.save(
73
+ { windowStart: window.windowStart, resetAt: window.resetAt, count: window.count },
74
+ body.policy.windowMs > MEMORY_ONLY_WINDOW_MS,
75
+ )
76
+ }
77
+ return Response.json(window)
78
+ }
79
+ if (path === PATHS.inspect && typeof body.now === 'number') {
80
+ const live = this.current && this.current.resetAt > body.now ? this.current : undefined
81
+ return Response.json(live ? { count: live.count, resetAt: live.resetAt } : null)
82
+ }
83
+ if (path === PATHS.refund && typeof body.windowStart === 'number') {
84
+ const window = this.current
85
+ if (window && window.windowStart === body.windowStart && window.count > 0) {
86
+ await this.save({ ...window, count: window.count - 1 }, this.persisted)
87
+ }
88
+ return Response.json(null)
89
+ }
90
+ return new Response('not found', { status: 404 })
91
+ }
92
+
93
+ /** Forgets a persisted window once it has rolled; a fresh one is opened by the next call. */
94
+ async alarm(): Promise<void> {
95
+ await this.load()
96
+ if (!this.persisted) return
97
+ if (this.current && this.current.resetAt > Date.now()) {
98
+ await this.state.storage.setAlarm(this.current.resetAt)
99
+ return
100
+ }
101
+ this.current = undefined
102
+ this.persisted = false
103
+ await this.state.storage.deleteAll()
104
+ }
105
+
106
+ private load(): Promise<void> {
107
+ this.loading ??= this.state.storage.get<StoredWindow>(WINDOW_KEY).then(
108
+ (stored) => {
109
+ this.current = stored ?? undefined
110
+ this.persisted = this.current !== undefined
111
+ },
112
+ (error: unknown) => {
113
+ // A failed read is retried by the next call rather than remembered.
114
+ this.loading = undefined
115
+ throw error
116
+ },
117
+ )
118
+ return this.loading
119
+ }
120
+
121
+ private async save(window: StoredWindow, persist: boolean): Promise<void> {
122
+ const opened = this.current?.windowStart !== window.windowStart
123
+ this.current = window
124
+ if (!persist) {
125
+ // A minute window that replaced a persisted day window: drop the stored one and its alarm.
126
+ if (this.persisted) await this.state.storage.deleteAll()
127
+ this.persisted = false
128
+ return
129
+ }
130
+ await this.state.storage.put(WINDOW_KEY, window)
131
+ if (opened || !this.persisted) await this.state.storage.setAlarm(window.resetAt)
132
+ this.persisted = true
133
+ }
134
+ }
135
+
136
+ /**
137
+ * How long a Worker waits on a counter before letting the call through. A counter that does not
138
+ * answer must not hold every request behind it; see `sharedRateLimiters` for why a failure lets
139
+ * the call through.
140
+ */
141
+ const COUNTER_TIMEOUT_MS = 2_000
142
+
143
+ /** The {@link RateLimitStore} the Worker counts in: one {@link RateLimitCounter} per key. */
144
+ export function durableObjectRateLimitStore(namespace: DurableObjectNamespace): RateLimitStore {
145
+ const call = async <T>(key: string, path: string, body: unknown): Promise<T> => {
146
+ const stub = namespace.get(namespace.idFromName(key))
147
+ const response = await stub.fetch(`https://counter${path}`, {
148
+ method: 'POST',
149
+ body: JSON.stringify(body),
150
+ signal: AbortSignal.timeout(COUNTER_TIMEOUT_MS),
151
+ })
152
+ if (!response.ok) throw new Error(`rate limit counter answered ${response.status}`)
153
+ return (await response.json()) as T
154
+ }
155
+ return {
156
+ consume: (key, policy, now) => call<RateLimitWindow>(key, PATHS.consume, { policy, now }),
157
+ inspect: (key, now) =>
158
+ call<{ count: number; resetAt: number } | null>(key, PATHS.inspect, { now }),
159
+ refund: async (key, windowStart) => {
160
+ await call<null>(key, PATHS.refund, { windowStart })
161
+ },
162
+ }
163
+ }
package/src/env.ts CHANGED
@@ -3,6 +3,12 @@ import type { D1Database, DurableObjectNamespace, R2Bucket } from '@cloudflare/w
3
3
  export interface Env {
4
4
  DB: D1Database
5
5
  MATCH_HUB: DurableObjectNamespace
6
+ /**
7
+ * The rate limit counters, one `RateLimitCounter` object per budget and caller, so every isolate
8
+ * spends the same budgets. Unbound, each isolate counts on its own and the Worker logs a warning:
9
+ * a budget is then multiplied by however many isolates a caller's requests reach.
10
+ */
11
+ RATE_LIMITS?: DurableObjectNamespace
6
12
  /**
7
13
  * Bug reports, in a D1 instance of their own.
8
14
  *
@@ -29,7 +35,7 @@ export interface Env {
29
35
  MEMBER_RATE_LIMIT_PER_MINUTE?: string
30
36
  UPLOAD_RATE_LIMIT_PER_MINUTE?: string
31
37
  BUG_REPORT_RATE_LIMIT_PER_MINUTE?: string
32
- /** Matches created per minute across every caller, per isolate. */
38
+ /** Matches created per minute across every caller, counted for the whole deployment when `RATE_LIMITS` is bound. */
33
39
  MATCH_CREATION_RATE_LIMIT_PER_MINUTE?: string
34
40
  /** Days before a finished or abandoned match is deleted. 0 keeps them forever. */
35
41
  RETENTION_DAYS?: string
package/src/index.ts CHANGED
@@ -1,10 +1,16 @@
1
- import { RateLimitedError, RateLimiter } from '@chaos-overlords/kernel'
1
+ import {
2
+ memoryRateLimiters,
3
+ RateLimitedError,
4
+ type RateLimiterFactory,
5
+ sharedRateLimiters,
6
+ } from '@chaos-overlords/kernel'
2
7
  import {
3
8
  type AppEnv,
4
9
  configFlag,
5
10
  configInteger,
6
11
  configList,
7
12
  createApp,
13
+ createRateLimiters,
8
14
  DEFAULT_RATE_LIMITS,
9
15
  DEFAULT_SERVER_CONFIG,
10
16
  defaultClientAddress,
@@ -14,8 +20,10 @@ import type { ExecutionContext, ScheduledController } from '@cloudflare/workers-
14
20
  import type { Hono } from 'hono'
15
21
  import type { Env } from './env'
16
22
  import { buildBugReports, buildKernel, HUB_PATHS, hubFor, workerLogger } from './kernel'
23
+ import { durableObjectRateLimitStore } from './RateLimitCounter'
17
24
 
18
25
  export { MatchHub } from './MatchHub'
26
+ export { RateLimitCounter } from './RateLimitCounter'
19
27
 
20
28
  type Built = { container: ServerContainer; app: Hono<AppEnv> }
21
29
 
@@ -29,11 +37,10 @@ type RateLimitVar =
29
37
  /**
30
38
  * One container per isolate, held in module scope.
31
39
  *
32
- * It has to be cached: a rate limiter counts requests within a window, so building a fresh one per
33
- * request would reset the window every time and limit nothing. The router and the D1-backed kernel
34
- * are per-isolate state for the same reason a server builds them once at startup — there is nothing
35
- * request-specific in either. Cloudflare's own rate limiting rules still belong in front of a public
36
- * deployment, because an isolate is not the whole world.
40
+ * It has to be cached: without the `RATE_LIMITS` binding a rate limiter counts requests within a
41
+ * window in the isolate, so building a fresh one per request would reset the window every time and
42
+ * limit nothing. The router and the D1-backed kernel are per-isolate state for the same reason a
43
+ * server builds them once at startup: there is nothing request-specific in either.
37
44
  *
38
45
  * A module-scoped singleton rather than a `WeakMap` keyed on `env`: the bindings object being the
39
46
  * same identity on every request is not a documented guarantee, and if it ever stopped being one the
@@ -61,10 +68,11 @@ export function buildContainer(env: Env): ServerContainer {
61
68
  // A misconfigured var refuses every request and every cron run until it is fixed, so the error
62
69
  // names the variable: a Worker has no startup log for it to go unnoticed in, only request errors.
63
70
  const perMinute = (name: RateLimitVar, fallback: number) =>
64
- new RateLimiter(clock, { limit: configInteger(env[name], fallback, 1, name), windowMs: 60_000 })
71
+ configInteger(env[name], fallback, 1, name)
72
+ const rateLimits = rateLimitersFor(env, clock)
65
73
  const bugReports = buildBugReports(env)
66
74
  return {
67
- kernel: buildKernel(env),
75
+ kernel: buildKernel(env, { rateLimits }),
68
76
  ...(bugReports ? { bugReports } : {}),
69
77
  eventStream: {
70
78
  open: async ({ matchId, playerId, afterSeq, signal }) => {
@@ -88,23 +96,29 @@ export function buildContainer(env: Env): ServerContainer {
88
96
  })
89
97
  },
90
98
  },
91
- rateLimiters: {
92
- anonymous: perMinute('RATE_LIMIT_PER_MINUTE', DEFAULT_RATE_LIMITS.anonymousPerMinute),
93
- member: perMinute('MEMBER_RATE_LIMIT_PER_MINUTE', DEFAULT_RATE_LIMITS.memberPerMinute),
94
- upload: perMinute('UPLOAD_RATE_LIMIT_PER_MINUTE', DEFAULT_RATE_LIMITS.uploadPerMinute),
95
- bugReport: perMinute(
99
+ rateLimiters: createRateLimiters(rateLimits, {
100
+ anonymousPerMinute: perMinute(
101
+ 'RATE_LIMIT_PER_MINUTE',
102
+ DEFAULT_RATE_LIMITS.anonymousPerMinute,
103
+ ),
104
+ memberPerMinute: perMinute(
105
+ 'MEMBER_RATE_LIMIT_PER_MINUTE',
106
+ DEFAULT_RATE_LIMITS.memberPerMinute,
107
+ ),
108
+ uploadPerMinute: perMinute(
109
+ 'UPLOAD_RATE_LIMIT_PER_MINUTE',
110
+ DEFAULT_RATE_LIMITS.uploadPerMinute,
111
+ ),
112
+ bugReportPerMinute: perMinute(
96
113
  'BUG_REPORT_RATE_LIMIT_PER_MINUTE',
97
114
  DEFAULT_RATE_LIMITS.bugReportPerMinute,
98
115
  ),
99
- bugReportState: new RateLimiter(clock, {
100
- limit: DEFAULT_RATE_LIMITS.bugReportStatePerDay,
101
- windowMs: 24 * 60 * 60 * 1000,
102
- }),
103
- matchCreation: perMinute(
116
+ bugReportStatePerDay: DEFAULT_RATE_LIMITS.bugReportStatePerDay,
117
+ matchCreationPerMinute: perMinute(
104
118
  'MATCH_CREATION_RATE_LIMIT_PER_MINUTE',
105
119
  DEFAULT_RATE_LIMITS.matchCreationPerMinute,
106
120
  ),
107
- },
121
+ }),
108
122
  // Listing is on unless a deployment turns it off: an unset var means the Browse screen works,
109
123
  // rather than every client being told the server lists nothing.
110
124
  config: {
@@ -119,6 +133,24 @@ export function buildContainer(env: Env): ServerContainer {
119
133
  }
120
134
  }
121
135
 
136
+ /**
137
+ * Where this Worker's budgets are counted: in a `RateLimitCounter` per key when `RATE_LIMITS` is
138
+ * bound, so they hold across every isolate and location, and in the isolate otherwise.
139
+ *
140
+ * The fallback keeps a deployment that has not added the binding yet serving, and says so once per
141
+ * isolate, at the point the container is built: a Worker has no startup to fail, and refusing every
142
+ * request over a missing binding would take the game down for a limit that is only weaker.
143
+ */
144
+ function rateLimitersFor(env: Env, clock: { now(): Date }): RateLimiterFactory {
145
+ if (env.RATE_LIMITS) {
146
+ return sharedRateLimiters(durableObjectRateLimitStore(env.RATE_LIMITS), clock, workerLogger)
147
+ }
148
+ workerLogger.warn('RATE_LIMITS is not bound: rate limits count per isolate', {
149
+ hint: 'bind the RateLimitCounter Durable Object as RATE_LIMITS in wrangler.toml; see multiplayer/README.md',
150
+ })
151
+ return memoryRateLimiters(clock)
152
+ }
153
+
122
154
  /**
123
155
  * Whether this isolate has ever seen the cron fire, and when it started.
124
156
  *
package/src/kernel.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  type EventNotifier,
15
15
  type Kernel,
16
16
  type Logger,
17
+ type RateLimiterFactory,
17
18
  type RetentionPolicy,
18
19
  retentionPolicyFromDays,
19
20
  type StreamCloser,
@@ -103,18 +104,26 @@ const HUB_CALL_TIMEOUT_MS = 5_000
103
104
  *
104
105
  * The step it belongs to has already committed whatever was durable about it, so the only thing a
105
106
  * failure here can still cost is latency somebody else's retry or the sweep already covers.
107
+ *
108
+ * An answer with an error status is a failure too: the object was reached but did not do what it
109
+ * was asked, for example a path missing from its routes answers 404. It gets its own warning, so a
110
+ * hub that refuses every call shows up in the log instead of passing as a delivered one.
106
111
  */
107
- async function tellHub(
112
+ export async function tellHub(
108
113
  env: Env,
109
114
  call: { matchId: string; path: string; body: unknown },
110
115
  ): Promise<void> {
111
116
  const { matchId, path, body } = call
112
117
  try {
113
- await hubFor(env, matchId).fetch(`https://hub${path}`, {
118
+ const response = await hubFor(env, matchId).fetch(`https://hub${path}`, {
114
119
  method: 'POST',
115
120
  body: JSON.stringify(body),
116
121
  signal: AbortSignal.timeout(HUB_CALL_TIMEOUT_MS),
117
122
  })
123
+ if (!response.ok) {
124
+ workerLogger.warn('the match hub refused a call', { matchId, path, status: response.status })
125
+ }
126
+ await response.body?.cancel()
118
127
  } catch (error) {
119
128
  workerLogger.warn('could not reach the match hub', { matchId, path, error: String(error) })
120
129
  }
@@ -130,6 +139,8 @@ export function buildKernel(
130
139
  notifier: EventNotifier
131
140
  scheduler: DeadlineScheduler
132
141
  streams: StreamCloser
142
+ /** Where the kernel's password budgets count; the isolate when absent. */
143
+ rateLimits: RateLimiterFactory
133
144
  }> = {},
134
145
  ): Kernel {
135
146
  const storage = createSqliteStorage(drizzle(env.DB, { schema: sqliteSchema }))
@@ -162,6 +173,7 @@ export function buildKernel(
162
173
  streams,
163
174
  clock: { now: () => new Date() },
164
175
  logger: workerLogger,
176
+ ...(overrides.rateLimits ? { rateLimits: overrides.rateLimits } : {}),
165
177
  },
166
178
  { retention: retentionPolicyFor(env) },
167
179
  )