@chaos-overlords/worker 0.9.1 → 0.9.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/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`, `RETENTION_DAYS`,
23
- `ABANDONED_RETENTION_DAYS`, `BUG_REPORT_RETENTION_DAYS` and `BUG_REPORT_DAILY_STATE_MB` are vars. The
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` deliberately carries no `[triggers]` block, because it is not a
31
- deployment — so the first thing to add to a real one is:
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.1",
3
+ "version": "0.9.3",
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.1",
51
- "@chaos-overlords/contracts": "0.9.1",
52
- "@chaos-overlords/kernel": "0.9.1",
53
- "@chaos-overlords/server": "0.9.1",
54
- "@chaos-overlords/storage": "0.9.1",
50
+ "@chaos-overlords/bug-reports": "0.9.3",
51
+ "@chaos-overlords/contracts": "0.9.3",
52
+ "@chaos-overlords/kernel": "0.9.3",
53
+ "@chaos-overlords/server": "0.9.3",
54
+ "@chaos-overlords/storage": "0.9.3",
55
55
  "drizzle-orm": "^0.45.2",
56
56
  "hono": "^4.13.8"
57
57
  },
58
58
  "devDependencies": {
59
- "@chaos-overlords/client": "0.9.1",
60
- "@chaos-overlords/conformance": "0.9.1",
59
+ "@chaos-overlords/client": "0.9.3",
60
+ "@chaos-overlords/conformance": "0.9.3",
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
@@ -1,4 +1,5 @@
1
1
  import {
2
+ EARLY_DEADLINE_RETRY_MS,
2
3
  isDomainError,
3
4
  type MultiplayerStorage,
4
5
  type PersistedEvent,
@@ -6,7 +7,9 @@ import {
6
7
  import {
7
8
  DEFAULT_EVENT_HUB_LIMITS,
8
9
  DEFAULT_SERVER_CONFIG,
10
+ isActiveMember,
9
11
  LocalEventHub,
12
+ logStreamClosed,
10
13
  } from '@chaos-overlords/server'
11
14
  import { createSqliteStorage, sqliteSchema } from '@chaos-overlords/storage/sqlite'
12
15
  import type { DurableObjectState } from '@cloudflare/workers-types'
@@ -17,10 +20,33 @@ import { buildKernel, HUB_PATHS, workerLogger } from './kernel'
17
20
  interface PendingDeadline {
18
21
  matchId: string
19
22
  turn: number
23
+ /** When the alarm was set for. Absent on keys written before it was recorded. */
24
+ dueAtMs?: number
20
25
  }
21
26
 
22
27
  const DEADLINE_KEY = 'deadline'
23
28
 
29
+ /**
30
+ * Where a deadline the kernel re-arms from inside an alarm may be set for.
31
+ *
32
+ * An alarm that the kernel finds early is re-armed for the same turn, floored against the object's
33
+ * `Date.now()`. That floor means nothing to the alarm scheduler when the object's clock lags it:
34
+ * the scheduler has already passed the time the alarm fired at, so a deadline at or before that
35
+ * time fires again at once, and again, building a kernel and reading D1 each time until the
36
+ * object's clock catches up. Having fired proves the scheduler's clock reached `fired.dueAtMs`, so
37
+ * the retry goes at least the kernel's retry interval past that, which bounds the rate on the
38
+ * scheduler's own clock. Any other turn's deadline (the successor a seal opened) is set as asked.
39
+ */
40
+ function retryFloor(
41
+ fired: PendingDeadline,
42
+ next: { matchId: string; turn: number },
43
+ dueAtMs: number,
44
+ ): number {
45
+ const sameTurn = next.matchId === fired.matchId && next.turn === fired.turn
46
+ if (!sameTurn || fired.dueAtMs === undefined) return dueAtMs
47
+ return Math.max(dueAtMs, fired.dueAtMs + EARLY_DEADLINE_RETRY_MS)
48
+ }
49
+
24
50
  /**
25
51
  * One instance per match. It holds the open event streams of that match (so a notification from
26
52
  * any Worker isolate reaches every subscriber) and the alarm for the open turn's deadline. The
@@ -44,6 +70,9 @@ export class MatchHub {
44
70
  // so a run of these is where a signal that only looks aborted would show up.
45
71
  abandoned: (matchId, playerId) =>
46
72
  workerLogger.info('answered an already abandoned event stream', { matchId, playerId }),
73
+ revalidate: (matchId, playerId) =>
74
+ isActiveMember(this.repositories.players, matchId, playerId),
75
+ closed: logStreamClosed(workerLogger),
47
76
  },
48
77
  )
49
78
  }
@@ -99,7 +128,8 @@ export class MatchHub {
99
128
  notifier: { notify: async (event) => this.hub.notify(event) },
100
129
  streams: this.hub,
101
130
  scheduler: {
102
- schedule: async (input) => this.arm(input.matchId, input.turn, input.dueAt.getTime()),
131
+ schedule: async (input) =>
132
+ this.arm(input.matchId, input.turn, retryFloor(pending, input, input.dueAt.getTime())),
103
133
  },
104
134
  })
105
135
  const sealed = await kernel.turns.trySeal(pending.matchId, pending.turn, 'deadline')
@@ -109,7 +139,7 @@ export class MatchHub {
109
139
 
110
140
  /** Record which turn the alarm is for, then set the alarm. One deadline is pending at a time. */
111
141
  private async arm(matchId: string, turn: number, dueAtMs: number): Promise<void> {
112
- await this.state.storage.put<PendingDeadline>(DEADLINE_KEY, { matchId, turn })
142
+ await this.state.storage.put<PendingDeadline>(DEADLINE_KEY, { matchId, turn, dueAtMs })
113
143
  await this.state.storage.setAlarm(dueAtMs)
114
144
  }
115
145
 
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
- /** `"false"` stops serving `GET /api/v1/matches`; anything else (unset included) serves it. */
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
- /** Days before a finished, abandoned or never-started match is deleted. 0 keeps everything. */
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
- const perMinute = (raw: string | undefined, fallback: number) => {
52
- const limit = Number(raw ?? fallback)
53
- const effective = Number.isInteger(limit) && limit > 0 ? limit : fallback
54
- return new RateLimiter(clock, { limit: effective, windowMs: 60_000 })
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(env.RATE_LIMIT_PER_MINUTE, DEFAULT_RATE_LIMITS.anonymousPerMinute),
84
- member: perMinute(env.MEMBER_RATE_LIMIT_PER_MINUTE, DEFAULT_RATE_LIMITS.memberPerMinute),
85
- upload: perMinute(env.UPLOAD_RATE_LIMIT_PER_MINUTE, DEFAULT_RATE_LIMITS.uploadPerMinute),
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
- env.BUG_REPORT_RATE_LIMIT_PER_MINUTE,
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: { ...DEFAULT_SERVER_CONFIG, publicListing: env.PUBLIC_LISTING !== 'false' },
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