@chaos-overlords/worker 0.7.1 → 0.8.0

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.7.1",
3
+ "version": "0.8.0",
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.7.1",
51
- "@chaos-overlords/contracts": "0.7.1",
52
- "@chaos-overlords/kernel": "0.7.1",
53
- "@chaos-overlords/server": "0.7.1",
54
- "@chaos-overlords/storage": "0.7.1",
50
+ "@chaos-overlords/bug-reports": "0.8.0",
51
+ "@chaos-overlords/contracts": "0.8.0",
52
+ "@chaos-overlords/kernel": "0.8.0",
53
+ "@chaos-overlords/server": "0.8.0",
54
+ "@chaos-overlords/storage": "0.8.0",
55
55
  "drizzle-orm": "^0.45.2",
56
56
  "hono": "^4.13.7"
57
57
  },
58
58
  "devDependencies": {
59
- "@chaos-overlords/client": "0.7.1",
60
- "@chaos-overlords/conformance": "0.7.1",
59
+ "@chaos-overlords/client": "0.8.0",
60
+ "@chaos-overlords/conformance": "0.8.0",
61
61
  "@cloudflare/vitest-pool-workers": "^0.22.0",
62
62
  "@cloudflare/workers-types": "^5.20260910.1",
63
63
  "typescript": "^5.9.3",
package/src/MatchHub.ts CHANGED
@@ -57,7 +57,10 @@ export class MatchHub {
57
57
  // reach. A bare status crosses back instead and the Worker rethrows it as the domain
58
58
  // error, so the envelope still has exactly one producer.
59
59
  if (!isDomainError(error)) throw error
60
- return new Response(null, { status: 429, headers: { 'X-Stream-Refusal': error.code } })
60
+ return new Response(null, {
61
+ status: 429,
62
+ headers: { 'X-Stream-Refusal': String(error.details?.scope ?? 'match') },
63
+ })
61
64
  }
62
65
  }
63
66
  case HUB_PATHS.disconnect: {
@@ -89,5 +92,28 @@ export class MatchHub {
89
92
  })
90
93
  const sealed = await kernel.turns.trySeal(pending.matchId, pending.turn, 'deadline')
91
94
  workerLogger.info('deadline alarm handled', { ...pending, sealed })
95
+ await this.forgetSpentDeadline(pending)
96
+ }
97
+
98
+ /**
99
+ * Drop the pending deadline once nothing is left to fire for. A seal on readiness, a finished
100
+ * match and a match retention deleted all leave the alarm armed for a turn that is no longer
101
+ * open; each such alarm builds a kernel and reads the match for nothing, and the key kept every
102
+ * timed match's object in storage forever. A seal that just ran has already replaced the key
103
+ * with the next turn's deadline, which is why the key is re-read rather than deleted outright.
104
+ */
105
+ private async forgetSpentDeadline(fired: PendingDeadline): Promise<void> {
106
+ const current = await this.state.storage.get<PendingDeadline>(DEADLINE_KEY)
107
+ if (!current || current.matchId !== fired.matchId || current.turn !== fired.turn) return
108
+ const storage = createSqliteStorage(drizzle(this.env.DB, { schema: sqliteSchema }))
109
+ const [match, turn] = await Promise.all([
110
+ storage.matches.get(fired.matchId),
111
+ storage.turns.get(fired.matchId, fired.turn),
112
+ ])
113
+ const stillDue =
114
+ match?.status === 'running' && turn?.status === 'open' && turn.deadlineAt !== null
115
+ if (stillDue) return
116
+ await this.state.storage.delete(DEADLINE_KEY)
117
+ await this.state.storage.deleteAlarm()
92
118
  }
93
119
  }
package/src/env.ts CHANGED
@@ -21,6 +21,7 @@ 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
25
  PUBLIC_LISTING?: string
25
26
  RATE_LIMIT_PER_MINUTE?: string
26
27
  MEMBER_RATE_LIMIT_PER_MINUTE?: string
package/src/index.ts CHANGED
@@ -65,9 +65,13 @@ export function buildContainer(env: Env): ServerContainer {
65
65
  // The object refuses an over-cap stream with a bare 429; turning it back into the domain
66
66
  // error here is what gets the caller the same envelope every other refusal has.
67
67
  if (response.status === 429) {
68
- throw new RateLimitedError('This match is holding as many event streams as it can', {
69
- reason: 'too_many_streams',
70
- })
68
+ const scope = response.headers.get('X-Stream-Refusal') === 'process' ? 'process' : 'match'
69
+ throw new RateLimitedError(
70
+ scope === 'process'
71
+ ? 'This server is holding as many event streams as it can'
72
+ : 'This match is holding as many event streams as it can',
73
+ { reason: 'too_many_streams', scope },
74
+ )
71
75
  }
72
76
  return new Response(response.body as ReadableStream<Uint8Array> | null, {
73
77
  status: response.status,
@@ -84,7 +88,9 @@ export function buildContainer(env: Env): ServerContainer {
84
88
  DEFAULT_RATE_LIMITS.bugReportPerMinute,
85
89
  ),
86
90
  },
87
- config: { ...DEFAULT_SERVER_CONFIG, publicListing: env.PUBLIC_LISTING === 'true' },
91
+ // Listing is on unless a deployment turns it off: an unset var means the Browse screen works,
92
+ // rather than every client being told the server lists nothing.
93
+ config: { ...DEFAULT_SERVER_CONFIG, publicListing: env.PUBLIC_LISTING !== 'false' },
88
94
  // `CF-Connecting-IP` is authoritative here and only here: Cloudflare sets it on every request
89
95
  // that reaches a Worker and a client cannot forge it through the edge. Off Cloudflare it is a
90
96
  // header anyone can write, which is why the default resolver ignores it unless told otherwise.