@chaos-overlords/worker 0.8.2 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaos-overlords/worker",
3
- "version": "0.8.2",
3
+ "version": "0.9.1",
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.8.2",
51
- "@chaos-overlords/contracts": "0.8.2",
52
- "@chaos-overlords/kernel": "0.8.2",
53
- "@chaos-overlords/server": "0.8.2",
54
- "@chaos-overlords/storage": "0.8.2",
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",
55
55
  "drizzle-orm": "^0.45.2",
56
56
  "hono": "^4.13.8"
57
57
  },
58
58
  "devDependencies": {
59
- "@chaos-overlords/client": "0.8.2",
60
- "@chaos-overlords/conformance": "0.8.2",
59
+ "@chaos-overlords/client": "0.9.1",
60
+ "@chaos-overlords/conformance": "0.9.1",
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,5 +1,13 @@
1
- import { isDomainError, type PersistedEvent } from '@chaos-overlords/kernel'
2
- import { DEFAULT_SERVER_CONFIG, LocalEventHub } from '@chaos-overlords/server'
1
+ import {
2
+ isDomainError,
3
+ type MultiplayerStorage,
4
+ type PersistedEvent,
5
+ } from '@chaos-overlords/kernel'
6
+ import {
7
+ DEFAULT_EVENT_HUB_LIMITS,
8
+ DEFAULT_SERVER_CONFIG,
9
+ LocalEventHub,
10
+ } from '@chaos-overlords/server'
3
11
  import { createSqliteStorage, sqliteSchema } from '@chaos-overlords/storage/sqlite'
4
12
  import type { DurableObjectState } from '@cloudflare/workers-types'
5
13
  import { drizzle } from 'drizzle-orm/d1'
@@ -20,13 +28,24 @@ const DEADLINE_KEY = 'deadline'
20
28
  */
21
29
  export class MatchHub {
22
30
  private readonly hub: LocalEventHub
31
+ private readonly repositories: MultiplayerStorage
23
32
 
24
33
  constructor(
25
34
  private readonly state: DurableObjectState,
26
35
  private readonly env: Env,
27
36
  ) {
28
- const storage = createSqliteStorage(drizzle(env.DB, { schema: sqliteSchema }))
29
- this.hub = new LocalEventHub(storage.events, DEFAULT_SERVER_CONFIG.sseHeartbeatMs)
37
+ this.repositories = createSqliteStorage(drizzle(env.DB, { schema: sqliteSchema }))
38
+ this.hub = new LocalEventHub(
39
+ this.repositories.events,
40
+ DEFAULT_SERVER_CONFIG.sseHeartbeatMs,
41
+ DEFAULT_EVENT_HUB_LIMITS,
42
+ {
43
+ // The request signal here is the Worker's fetch into this object, not the client's own,
44
+ // so a run of these is where a signal that only looks aborted would show up.
45
+ abandoned: (matchId, playerId) =>
46
+ workerLogger.info('answered an already abandoned event stream', { matchId, playerId }),
47
+ },
48
+ )
30
49
  }
31
50
 
32
51
  async fetch(request: Request): Promise<Response> {
@@ -42,11 +61,7 @@ export class MatchHub {
42
61
  }
43
62
  case HUB_PATHS.schedule: {
44
63
  const body = (await request.json()) as PendingDeadline & { dueAt: string }
45
- await this.state.storage.put<PendingDeadline>(DEADLINE_KEY, {
46
- matchId: body.matchId,
47
- turn: body.turn,
48
- })
49
- await this.state.storage.setAlarm(new Date(body.dueAt).getTime())
64
+ await this.arm(body.matchId, body.turn, new Date(body.dueAt).getTime())
50
65
  return new Response(null, { status: 204 })
51
66
  }
52
67
  case HUB_PATHS.subscribe: {
@@ -84,13 +99,7 @@ export class MatchHub {
84
99
  notifier: { notify: async (event) => this.hub.notify(event) },
85
100
  streams: this.hub,
86
101
  scheduler: {
87
- schedule: async (input) => {
88
- await this.state.storage.put<PendingDeadline>(DEADLINE_KEY, {
89
- matchId: input.matchId,
90
- turn: input.turn,
91
- })
92
- await this.state.storage.setAlarm(input.dueAt.getTime())
93
- },
102
+ schedule: async (input) => this.arm(input.matchId, input.turn, input.dueAt.getTime()),
94
103
  },
95
104
  })
96
105
  const sealed = await kernel.turns.trySeal(pending.matchId, pending.turn, 'deadline')
@@ -98,6 +107,12 @@ export class MatchHub {
98
107
  await this.forgetSpentDeadline(pending)
99
108
  }
100
109
 
110
+ /** Record which turn the alarm is for, then set the alarm. One deadline is pending at a time. */
111
+ private async arm(matchId: string, turn: number, dueAtMs: number): Promise<void> {
112
+ await this.state.storage.put<PendingDeadline>(DEADLINE_KEY, { matchId, turn })
113
+ await this.state.storage.setAlarm(dueAtMs)
114
+ }
115
+
101
116
  /**
102
117
  * Drop the pending deadline once nothing is left to fire for. A seal on readiness, a finished
103
118
  * match and a match retention deleted all leave the alarm armed for a turn that is no longer
@@ -108,10 +123,9 @@ export class MatchHub {
108
123
  private async forgetSpentDeadline(fired: PendingDeadline): Promise<void> {
109
124
  const current = await this.state.storage.get<PendingDeadline>(DEADLINE_KEY)
110
125
  if (!current || current.matchId !== fired.matchId || current.turn !== fired.turn) return
111
- const storage = createSqliteStorage(drizzle(this.env.DB, { schema: sqliteSchema }))
112
126
  const [match, turn] = await Promise.all([
113
- storage.matches.get(fired.matchId),
114
- storage.turns.get(fired.matchId, fired.turn),
127
+ this.repositories.matches.get(fired.matchId),
128
+ this.repositories.turns.get(fired.matchId, fired.turn),
115
129
  ])
116
130
  const stillDue =
117
131
  match?.status === 'running' && turn?.status === 'open' && turn.deadlineAt !== null
package/src/kernel.ts CHANGED
@@ -30,6 +30,12 @@ const DAY_MS = 24 * 60 * 60 * 1000
30
30
  const DEFAULT_RETENTION_DAYS = 30
31
31
  const DEFAULT_ABANDONED_RETENTION_DAYS = 90
32
32
 
33
+ /** A non-negative whole number from an environment variable; anything else reads as `fallback`. */
34
+ function wholeNumber(raw: string | undefined, fallback: number): number {
35
+ const value = Number(raw ?? fallback)
36
+ return Number.isInteger(value) && value >= 0 ? value : fallback
37
+ }
38
+
33
39
  export const HUB_PATHS = {
34
40
  notify: '/notify',
35
41
  schedule: '/schedule',
@@ -110,10 +116,6 @@ export function buildKernel(
110
116
  body: { ...input, dueAt: input.dueAt.toISOString() },
111
117
  }),
112
118
  }
113
- const days = (raw: string | undefined, fallback: number): number => {
114
- const value = Number(raw ?? fallback)
115
- return Number.isInteger(value) && value >= 0 ? value : fallback
116
- }
117
119
  return createKernel(
118
120
  {
119
121
  storage,
@@ -125,13 +127,13 @@ export function buildKernel(
125
127
  },
126
128
  {
127
129
  retention: {
128
- maxAgeMs: days(env.RETENTION_DAYS, DEFAULT_RETENTION_DAYS) * DAY_MS,
130
+ maxAgeMs: wholeNumber(env.RETENTION_DAYS, DEFAULT_RETENTION_DAYS) * DAY_MS,
129
131
  abandonedLiveMaxAgeMs:
130
- days(env.ABANDONED_RETENTION_DAYS, DEFAULT_ABANDONED_RETENTION_DAYS) * DAY_MS,
132
+ wholeNumber(env.ABANDONED_RETENTION_DAYS, DEFAULT_ABANDONED_RETENTION_DAYS) * DAY_MS,
131
133
  // Twice the abandoned window, and without its roster test, which never collects an untimed
132
134
  // match whose players' clients died without a `leave`.
133
135
  silentLiveMaxAgeMs:
134
- days(env.ABANDONED_RETENTION_DAYS, DEFAULT_ABANDONED_RETENTION_DAYS) * 2 * DAY_MS,
136
+ wholeNumber(env.ABANDONED_RETENTION_DAYS, DEFAULT_ABANDONED_RETENTION_DAYS) * 2 * DAY_MS,
135
137
  batchSize: 50,
136
138
  },
137
139
  },
@@ -153,10 +155,6 @@ export function buildBugReports(env: Env): BugReportService | undefined {
153
155
  if (!env.BUG_DB) return undefined
154
156
  const repository = createBugReportRepository(drizzle(env.BUG_DB, { schema: bugReportSchema }))
155
157
  const blobs = env.BUG_BLOBS ? createR2BlobStore(env.BUG_BLOBS) : undefined
156
- const number = (raw: string | undefined, fallback: number): number => {
157
- const value = Number(raw ?? fallback)
158
- return Number.isInteger(value) && value >= 0 ? value : fallback
159
- }
160
158
  return createBugReportService({
161
159
  repository,
162
160
  clock: { now: () => new Date() },
@@ -164,14 +162,14 @@ export function buildBugReports(env: Env): BugReportService | undefined {
164
162
  retention: {
165
163
  ...DEFAULT_BUG_REPORT_RETENTION,
166
164
  dailyStateBytes:
167
- number(
165
+ wholeNumber(
168
166
  env.BUG_REPORT_DAILY_STATE_MB,
169
167
  DEFAULT_BUG_REPORT_RETENTION.dailyStateBytes / (1024 * 1024),
170
168
  ) *
171
169
  1024 *
172
170
  1024,
173
171
  maxAgeMs:
174
- number(env.BUG_REPORT_RETENTION_DAYS, DEFAULT_BUG_REPORT_RETENTION.maxAgeMs / DAY_MS) *
172
+ wholeNumber(env.BUG_REPORT_RETENTION_DAYS, DEFAULT_BUG_REPORT_RETENTION.maxAgeMs / DAY_MS) *
175
173
  DAY_MS,
176
174
  },
177
175
  ...(blobs ? { blobs } : {}),