@chaos-overlords/worker 0.9.8 → 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 +6 -3
- package/package.json +12 -12
- package/src/RateLimitCounter.ts +163 -0
- package/src/env.ts +7 -1
- package/src/index.ts +51 -19
- package/src/kernel.ts +14 -2
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
|
|
17
|
-
|
|
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.
|
|
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,22 +47,22 @@
|
|
|
47
47
|
"provenance": true
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@chaos-overlords/bug-reports": "0.9.
|
|
51
|
-
"@chaos-overlords/contracts": "0.9.
|
|
52
|
-
"@chaos-overlords/kernel": "0.9.
|
|
53
|
-
"@chaos-overlords/server": "0.9.
|
|
54
|
-
"@chaos-overlords/storage": "0.9.
|
|
55
|
-
"drizzle-orm": "^0.45.
|
|
56
|
-
"hono": "^4.13.
|
|
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
|
+
"drizzle-orm": "^0.45.3",
|
|
56
|
+
"hono": "^4.13.9"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@chaos-overlords/client": "0.9.
|
|
60
|
-
"@chaos-overlords/conformance": "0.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
|
-
"@cloudflare/workers-types": "^5.
|
|
62
|
+
"@cloudflare/workers-types": "^5.20260927.1",
|
|
63
63
|
"typescript": "^7.0.2",
|
|
64
64
|
"vitest": "^4.1.11",
|
|
65
|
-
"wrangler": "^4.
|
|
65
|
+
"wrangler": "^4.141.0"
|
|
66
66
|
},
|
|
67
67
|
"scripts": {
|
|
68
68
|
"build": "tsc -p tsconfig.json --noEmit",
|
|
@@ -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,
|
|
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 {
|
|
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
|
|
33
|
-
*
|
|
34
|
-
* are per-isolate state for the same reason a
|
|
35
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
)
|