@chaos-overlords/worker 0.9.0 → 0.9.2
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 +9 -5
- package/package.json +8 -8
- package/src/MatchHub.ts +19 -2
- package/src/env.ts +19 -2
- package/src/index.ts +27 -10
- package/src/kernel.ts +41 -14
package/README.md
CHANGED
|
@@ -18,17 +18,21 @@ Object namespace, whose migration lineage starts at tag `v1` with `new_sqlite_cl
|
|
|
18
18
|
The D1 migration lineages ship in `@chaos-overlords/storage` and `@chaos-overlords/bug-reports`; point
|
|
19
19
|
`wrangler d1 migrations apply` at them rather than copying them.
|
|
20
20
|
|
|
21
|
-
`PUBLIC_LISTING`, `RATE_LIMIT_PER_MINUTE`, `MEMBER_RATE_LIMIT_PER_MINUTE`,
|
|
22
|
-
`UPLOAD_RATE_LIMIT_PER_MINUTE`, `BUG_REPORT_RATE_LIMIT_PER_MINUTE`,
|
|
23
|
-
`
|
|
21
|
+
`PUBLIC_LISTING`, `CORS_ORIGINS`, `RATE_LIMIT_PER_MINUTE`, `MEMBER_RATE_LIMIT_PER_MINUTE`,
|
|
22
|
+
`UPLOAD_RATE_LIMIT_PER_MINUTE`, `BUG_REPORT_RATE_LIMIT_PER_MINUTE`,
|
|
23
|
+
`MATCH_CREATION_RATE_LIMIT_PER_MINUTE`, `RETENTION_DAYS`,
|
|
24
|
+
`LOBBY_RETENTION_DAYS`, `ABANDONED_RETENTION_DAYS`, `SILENT_RETENTION_DAYS`, `RETENTION_BATCH_SIZE`,
|
|
25
|
+
`BUG_REPORT_RETENTION_DAYS` and `BUG_REPORT_DAILY_STATE_MB` are vars; unset, each retention window
|
|
26
|
+
takes the kernel's default for a shared public server (see the multiplayer README). The
|
|
24
27
|
`scheduled` handler expects a cron trigger; five minutes is the interval its sweeper is written for.
|
|
25
28
|
|
|
26
29
|
**A deployment without a cron trigger has no safety net.** The sweeper is what seals a turn whose
|
|
27
30
|
Durable Object alarm never fired, finishes a seal whose isolate died halfway through, re-runs the
|
|
28
31
|
verdict of a match left paused by an interrupted one, and collects retention. Nothing else does any
|
|
29
32
|
of it, and nothing fails loudly when it is missing: matches simply stop advancing for the players in
|
|
30
|
-
them. `wrangler.dev.toml`
|
|
31
|
-
deployment — so the first thing to add to a
|
|
33
|
+
them. `wrangler.dev.toml` carries the trigger so `wrangler dev --test-scheduled` exercises the same
|
|
34
|
+
path, but it is not a deployment and triggers are not inherited — so the first thing to add to a
|
|
35
|
+
real one is:
|
|
32
36
|
|
|
33
37
|
```toml
|
|
34
38
|
[triggers]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chaos-overlords/worker",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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.
|
|
51
|
-
"@chaos-overlords/contracts": "0.9.
|
|
52
|
-
"@chaos-overlords/kernel": "0.9.
|
|
53
|
-
"@chaos-overlords/server": "0.9.
|
|
54
|
-
"@chaos-overlords/storage": "0.9.
|
|
50
|
+
"@chaos-overlords/bug-reports": "0.9.2",
|
|
51
|
+
"@chaos-overlords/contracts": "0.9.2",
|
|
52
|
+
"@chaos-overlords/kernel": "0.9.2",
|
|
53
|
+
"@chaos-overlords/server": "0.9.2",
|
|
54
|
+
"@chaos-overlords/storage": "0.9.2",
|
|
55
55
|
"drizzle-orm": "^0.45.2",
|
|
56
56
|
"hono": "^4.13.8"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@chaos-overlords/client": "0.9.
|
|
60
|
-
"@chaos-overlords/conformance": "0.9.
|
|
59
|
+
"@chaos-overlords/client": "0.9.2",
|
|
60
|
+
"@chaos-overlords/conformance": "0.9.2",
|
|
61
61
|
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
|
62
62
|
"@cloudflare/workers-types": "^5.20260920.1",
|
|
63
63
|
"typescript": "^7.0.2",
|
package/src/MatchHub.ts
CHANGED
|
@@ -3,7 +3,12 @@ import {
|
|
|
3
3
|
type MultiplayerStorage,
|
|
4
4
|
type PersistedEvent,
|
|
5
5
|
} from '@chaos-overlords/kernel'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_EVENT_HUB_LIMITS,
|
|
8
|
+
DEFAULT_SERVER_CONFIG,
|
|
9
|
+
isActiveMember,
|
|
10
|
+
LocalEventHub,
|
|
11
|
+
} from '@chaos-overlords/server'
|
|
7
12
|
import { createSqliteStorage, sqliteSchema } from '@chaos-overlords/storage/sqlite'
|
|
8
13
|
import type { DurableObjectState } from '@cloudflare/workers-types'
|
|
9
14
|
import { drizzle } from 'drizzle-orm/d1'
|
|
@@ -31,7 +36,19 @@ export class MatchHub {
|
|
|
31
36
|
private readonly env: Env,
|
|
32
37
|
) {
|
|
33
38
|
this.repositories = createSqliteStorage(drizzle(env.DB, { schema: sqliteSchema }))
|
|
34
|
-
this.hub = new LocalEventHub(
|
|
39
|
+
this.hub = new LocalEventHub(
|
|
40
|
+
this.repositories.events,
|
|
41
|
+
DEFAULT_SERVER_CONFIG.sseHeartbeatMs,
|
|
42
|
+
DEFAULT_EVENT_HUB_LIMITS,
|
|
43
|
+
{
|
|
44
|
+
// The request signal here is the Worker's fetch into this object, not the client's own,
|
|
45
|
+
// so a run of these is where a signal that only looks aborted would show up.
|
|
46
|
+
abandoned: (matchId, playerId) =>
|
|
47
|
+
workerLogger.info('answered an already abandoned event stream', { matchId, playerId }),
|
|
48
|
+
revalidate: (matchId, playerId) =>
|
|
49
|
+
isActiveMember(this.repositories.players, matchId, playerId),
|
|
50
|
+
},
|
|
51
|
+
)
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
async fetch(request: Request): Promise<Response> {
|
package/src/env.ts
CHANGED
|
@@ -21,14 +21,23 @@ export interface Env {
|
|
|
21
21
|
* `BUG_REPORT_LIMITS.inlineStateBytes` are kept.
|
|
22
22
|
*/
|
|
23
23
|
BUG_BLOBS?: R2Bucket
|
|
24
|
-
/**
|
|
24
|
+
/** Shared Node/Cloudflare boolean parsing; unset serves the public lobby list. */
|
|
25
25
|
PUBLIC_LISTING?: string
|
|
26
|
+
/** Comma-separated browser origins allowed to call the API. */
|
|
27
|
+
CORS_ORIGINS?: string
|
|
26
28
|
RATE_LIMIT_PER_MINUTE?: string
|
|
27
29
|
MEMBER_RATE_LIMIT_PER_MINUTE?: string
|
|
28
30
|
UPLOAD_RATE_LIMIT_PER_MINUTE?: string
|
|
29
31
|
BUG_REPORT_RATE_LIMIT_PER_MINUTE?: string
|
|
30
|
-
/**
|
|
32
|
+
/** Matches created per minute across every caller, per isolate. */
|
|
33
|
+
MATCH_CREATION_RATE_LIMIT_PER_MINUTE?: string
|
|
34
|
+
/** Days before a finished or abandoned match is deleted. 0 keeps them forever. */
|
|
31
35
|
RETENTION_DAYS?: string
|
|
36
|
+
/**
|
|
37
|
+
* Days before a lobby that was never started is deleted. 0 keeps them forever. Unset (or not a
|
|
38
|
+
* whole number) is three days, or 0 when `RETENTION_DAYS` is 0.
|
|
39
|
+
*/
|
|
40
|
+
LOBBY_RETENTION_DAYS?: string
|
|
32
41
|
/**
|
|
33
42
|
* Days before a RUNNING match with nobody active in it is deleted. 0 keeps them forever.
|
|
34
43
|
*
|
|
@@ -36,6 +45,14 @@ export interface Env {
|
|
|
36
45
|
* nothing else ever collects one of those.
|
|
37
46
|
*/
|
|
38
47
|
ABANDONED_RETENTION_DAYS?: string
|
|
48
|
+
/**
|
|
49
|
+
* Days before a running match is deleted whatever its roster says, because nothing has happened
|
|
50
|
+
* in it for that long. Unset (or not a whole number) follows `ABANDONED_RETENTION_DAYS` (three
|
|
51
|
+
* times as long).
|
|
52
|
+
*/
|
|
53
|
+
SILENT_RETENTION_DAYS?: string
|
|
54
|
+
/** Matches each retention window deletes per cron run. */
|
|
55
|
+
RETENTION_BATCH_SIZE?: string
|
|
39
56
|
/** Days a bug report and its R2 object are kept. 0 keeps them forever. */
|
|
40
57
|
BUG_REPORT_RETENTION_DAYS?: string
|
|
41
58
|
/** Attached journal megabytes accepted per rolling day across every reporter. 0 lifts the cap. */
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { RateLimitedError, RateLimiter } from '@chaos-overlords/kernel'
|
|
2
2
|
import {
|
|
3
3
|
type AppEnv,
|
|
4
|
+
configFlag,
|
|
5
|
+
configInteger,
|
|
6
|
+
configList,
|
|
4
7
|
createApp,
|
|
5
8
|
DEFAULT_RATE_LIMITS,
|
|
6
9
|
DEFAULT_SERVER_CONFIG,
|
|
@@ -16,6 +19,13 @@ export { MatchHub } from './MatchHub'
|
|
|
16
19
|
|
|
17
20
|
type Built = { container: ServerContainer; app: Hono<AppEnv> }
|
|
18
21
|
|
|
22
|
+
type RateLimitVar =
|
|
23
|
+
| 'RATE_LIMIT_PER_MINUTE'
|
|
24
|
+
| 'MEMBER_RATE_LIMIT_PER_MINUTE'
|
|
25
|
+
| 'UPLOAD_RATE_LIMIT_PER_MINUTE'
|
|
26
|
+
| 'BUG_REPORT_RATE_LIMIT_PER_MINUTE'
|
|
27
|
+
| 'MATCH_CREATION_RATE_LIMIT_PER_MINUTE'
|
|
28
|
+
|
|
19
29
|
/**
|
|
20
30
|
* One container per isolate, held in module scope.
|
|
21
31
|
*
|
|
@@ -48,11 +58,10 @@ export function resetContainerForTests(): void {
|
|
|
48
58
|
|
|
49
59
|
export function buildContainer(env: Env): ServerContainer {
|
|
50
60
|
const clock = { now: () => new Date() }
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
61
|
+
// A misconfigured var refuses every request and every cron run until it is fixed, so the error
|
|
62
|
+
// names the variable: a Worker has no startup log for it to go unnoticed in, only request errors.
|
|
63
|
+
const perMinute = (name: RateLimitVar, fallback: number) =>
|
|
64
|
+
new RateLimiter(clock, { limit: configInteger(env[name], fallback, 1, name), windowMs: 60_000 })
|
|
56
65
|
const bugReports = buildBugReports(env)
|
|
57
66
|
return {
|
|
58
67
|
kernel: buildKernel(env),
|
|
@@ -80,21 +89,29 @@ export function buildContainer(env: Env): ServerContainer {
|
|
|
80
89
|
},
|
|
81
90
|
},
|
|
82
91
|
rateLimiters: {
|
|
83
|
-
anonymous: perMinute(
|
|
84
|
-
member: perMinute(
|
|
85
|
-
upload: perMinute(
|
|
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),
|
|
86
95
|
bugReport: perMinute(
|
|
87
|
-
|
|
96
|
+
'BUG_REPORT_RATE_LIMIT_PER_MINUTE',
|
|
88
97
|
DEFAULT_RATE_LIMITS.bugReportPerMinute,
|
|
89
98
|
),
|
|
90
99
|
bugReportState: new RateLimiter(clock, {
|
|
91
100
|
limit: DEFAULT_RATE_LIMITS.bugReportStatePerDay,
|
|
92
101
|
windowMs: 24 * 60 * 60 * 1000,
|
|
93
102
|
}),
|
|
103
|
+
matchCreation: perMinute(
|
|
104
|
+
'MATCH_CREATION_RATE_LIMIT_PER_MINUTE',
|
|
105
|
+
DEFAULT_RATE_LIMITS.matchCreationPerMinute,
|
|
106
|
+
),
|
|
94
107
|
},
|
|
95
108
|
// Listing is on unless a deployment turns it off: an unset var means the Browse screen works,
|
|
96
109
|
// rather than every client being told the server lists nothing.
|
|
97
|
-
config: {
|
|
110
|
+
config: {
|
|
111
|
+
...DEFAULT_SERVER_CONFIG,
|
|
112
|
+
publicListing: configFlag(env.PUBLIC_LISTING, true, 'PUBLIC_LISTING'),
|
|
113
|
+
corsOrigins: configList(env.CORS_ORIGINS),
|
|
114
|
+
},
|
|
98
115
|
// `CF-Connecting-IP` is authoritative here and only here: Cloudflare sets it on every request
|
|
99
116
|
// that reaches a Worker and a client cannot forge it through the edge. Off Cloudflare it is a
|
|
100
117
|
// header anyone can write, which is why the default resolver ignores it unless told otherwise.
|
package/src/kernel.ts
CHANGED
|
@@ -8,10 +8,14 @@ import {
|
|
|
8
8
|
} from '@chaos-overlords/bug-reports'
|
|
9
9
|
import {
|
|
10
10
|
createKernel,
|
|
11
|
+
DEFAULT_RETENTION_BATCH_SIZE,
|
|
12
|
+
DEFAULT_RETENTION_DAYS,
|
|
11
13
|
type DeadlineScheduler,
|
|
12
14
|
type EventNotifier,
|
|
13
15
|
type Kernel,
|
|
14
16
|
type Logger,
|
|
17
|
+
type RetentionPolicy,
|
|
18
|
+
retentionPolicyFromDays,
|
|
15
19
|
type StreamCloser,
|
|
16
20
|
} from '@chaos-overlords/kernel'
|
|
17
21
|
import { createSqliteStorage, sqliteSchema } from '@chaos-overlords/storage/sqlite'
|
|
@@ -27,8 +31,6 @@ export const workerLogger: Logger = {
|
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
30
|
-
const DEFAULT_RETENTION_DAYS = 30
|
|
31
|
-
const DEFAULT_ABANDONED_RETENTION_DAYS = 90
|
|
32
34
|
|
|
33
35
|
/** A non-negative whole number from an environment variable; anything else reads as `fallback`. */
|
|
34
36
|
function wholeNumber(raw: string | undefined, fallback: number): number {
|
|
@@ -36,6 +38,42 @@ function wholeNumber(raw: string | undefined, fallback: number): number {
|
|
|
36
38
|
return Number.isInteger(value) && value >= 0 ? value : fallback
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
/** A whole number when the variable is set, `undefined` when it is unset or not a whole number. */
|
|
42
|
+
function optionalWholeNumber(raw: string | undefined): number | undefined {
|
|
43
|
+
if (raw === undefined || raw === '') return undefined
|
|
44
|
+
const value = Number(raw)
|
|
45
|
+
return Number.isInteger(value) && value >= 0 ? value : undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The retention windows from the vars, with the kernel's defaults for any that are unset.
|
|
50
|
+
*
|
|
51
|
+
* The lobby and silent windows are only passed when they are set to a whole number, so the kernel
|
|
52
|
+
* derives them the same way it does on Node: the silent window from the abandoned one, the lobby
|
|
53
|
+
* window from `RETENTION_DAYS`'s off switch. A mistyped value reads as unset rather than as a
|
|
54
|
+
* fixed default, because a fixed silent window could end up shorter than the abandoned window and
|
|
55
|
+
* collect matches whose players are still seated. Node refuses to start on the same input; a
|
|
56
|
+
* Worker has no start to refuse, so the derived window is the safe reading.
|
|
57
|
+
*/
|
|
58
|
+
export function retentionPolicyFor(env: Env): RetentionPolicy {
|
|
59
|
+
const lobby = optionalWholeNumber(env.LOBBY_RETENTION_DAYS)
|
|
60
|
+
const silentLive = optionalWholeNumber(env.SILENT_RETENTION_DAYS)
|
|
61
|
+
const batchSize = wholeNumber(env.RETENTION_BATCH_SIZE, DEFAULT_RETENTION_BATCH_SIZE)
|
|
62
|
+
return retentionPolicyFromDays(
|
|
63
|
+
{
|
|
64
|
+
finished: wholeNumber(env.RETENTION_DAYS, DEFAULT_RETENTION_DAYS.finished),
|
|
65
|
+
abandonedLive: wholeNumber(
|
|
66
|
+
env.ABANDONED_RETENTION_DAYS,
|
|
67
|
+
DEFAULT_RETENTION_DAYS.abandonedLive,
|
|
68
|
+
),
|
|
69
|
+
...(lobby === undefined ? {} : { lobby }),
|
|
70
|
+
...(silentLive === undefined ? {} : { silentLive }),
|
|
71
|
+
},
|
|
72
|
+
// A batch of 0 would delete nothing while looking switched on; the windows are the off switch.
|
|
73
|
+
batchSize > 0 ? batchSize : DEFAULT_RETENTION_BATCH_SIZE,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
39
77
|
export const HUB_PATHS = {
|
|
40
78
|
notify: '/notify',
|
|
41
79
|
schedule: '/schedule',
|
|
@@ -125,18 +163,7 @@ export function buildKernel(
|
|
|
125
163
|
clock: { now: () => new Date() },
|
|
126
164
|
logger: workerLogger,
|
|
127
165
|
},
|
|
128
|
-
{
|
|
129
|
-
retention: {
|
|
130
|
-
maxAgeMs: wholeNumber(env.RETENTION_DAYS, DEFAULT_RETENTION_DAYS) * DAY_MS,
|
|
131
|
-
abandonedLiveMaxAgeMs:
|
|
132
|
-
wholeNumber(env.ABANDONED_RETENTION_DAYS, DEFAULT_ABANDONED_RETENTION_DAYS) * DAY_MS,
|
|
133
|
-
// Twice the abandoned window, and without its roster test, which never collects an untimed
|
|
134
|
-
// match whose players' clients died without a `leave`.
|
|
135
|
-
silentLiveMaxAgeMs:
|
|
136
|
-
wholeNumber(env.ABANDONED_RETENTION_DAYS, DEFAULT_ABANDONED_RETENTION_DAYS) * 2 * DAY_MS,
|
|
137
|
-
batchSize: 50,
|
|
138
|
-
},
|
|
139
|
-
},
|
|
166
|
+
{ retention: retentionPolicyFor(env) },
|
|
140
167
|
)
|
|
141
168
|
}
|
|
142
169
|
|