@chaos-overlords/worker 0.1.0 → 0.5.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/README.md CHANGED
@@ -19,7 +19,8 @@ The D1 migration lineages ship in `@chaos-overlords/storage` and `@chaos-overlor
19
19
  `wrangler d1 migrations apply` at them rather than copying them.
20
20
 
21
21
  `PUBLIC_LISTING`, `RATE_LIMIT_PER_MINUTE`, `MEMBER_RATE_LIMIT_PER_MINUTE`,
22
- `UPLOAD_RATE_LIMIT_PER_MINUTE`, `BUG_REPORT_RATE_LIMIT_PER_MINUTE` and `RETENTION_DAYS` are vars. The
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
23
24
  `scheduled` handler expects a cron trigger; five minutes is the interval its sweeper is written for.
24
25
 
25
26
  ## Install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaos-overlords/worker",
3
- "version": "0.1.0",
3
+ "version": "0.5.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.1.0",
51
- "@chaos-overlords/contracts": "0.1.0",
52
- "@chaos-overlords/kernel": "0.1.0",
53
- "@chaos-overlords/server": "0.1.0",
54
- "@chaos-overlords/storage": "0.1.0",
50
+ "@chaos-overlords/bug-reports": "0.5.0",
51
+ "@chaos-overlords/contracts": "0.5.0",
52
+ "@chaos-overlords/kernel": "0.5.0",
53
+ "@chaos-overlords/server": "0.5.0",
54
+ "@chaos-overlords/storage": "0.5.0",
55
55
  "drizzle-orm": "^0.45.2",
56
56
  "hono": "^4.13.7"
57
57
  },
58
58
  "devDependencies": {
59
- "@chaos-overlords/client": "0.1.0",
60
- "@chaos-overlords/conformance": "0.1.0",
59
+ "@chaos-overlords/client": "0.5.0",
60
+ "@chaos-overlords/conformance": "0.5.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
@@ -1,3 +1,4 @@
1
+ import { isDomainError } from '@chaos-overlords/kernel'
1
2
  import { DEFAULT_SERVER_CONFIG, LocalEventHub } from '@chaos-overlords/server'
2
3
  import { createSqliteStorage, sqliteSchema } from '@chaos-overlords/storage/sqlite'
3
4
  import type { DurableObjectState } from '@cloudflare/workers-types'
@@ -47,8 +48,22 @@ export class MatchHub {
47
48
  }
48
49
  case HUB_PATHS.subscribe: {
49
50
  const matchId = url.searchParams.get('matchId') ?? ''
51
+ const playerId = url.searchParams.get('playerId') ?? ''
50
52
  const afterSeq = Number(url.searchParams.get('after') ?? '0')
51
- return this.hub.open({ matchId, afterSeq, signal: request.signal })
53
+ try {
54
+ return await this.hub.open({ matchId, playerId, afterSeq, signal: request.signal })
55
+ } catch (error) {
56
+ // The stream caps refuse here, inside the object, where the app's error handler cannot
57
+ // reach. A bare status crosses back instead and the Worker rethrows it as the domain
58
+ // error, so the envelope still has exactly one producer.
59
+ if (!isDomainError(error)) throw error
60
+ return new Response(null, { status: 429, headers: { 'X-Stream-Refusal': error.code } })
61
+ }
62
+ }
63
+ case HUB_PATHS.disconnect: {
64
+ const body = (await request.json()) as { matchId: string; playerId: string }
65
+ await this.hub.close(body)
66
+ return new Response(null, { status: 204 })
52
67
  }
53
68
  default:
54
69
  return new Response('not found', { status: 404 })
@@ -61,6 +76,7 @@ export class MatchHub {
61
76
  const kernel = buildKernel(this.env, {
62
77
  // Already inside the hub: wake local streams directly instead of calling ourselves.
63
78
  notifier: { notify: async (event) => this.hub.wake(event.matchId) },
79
+ streams: this.hub,
64
80
  scheduler: {
65
81
  schedule: async (input) => {
66
82
  await this.state.storage.put<PendingDeadline>(DEADLINE_KEY, {
package/src/env.ts CHANGED
@@ -28,4 +28,15 @@ export interface Env {
28
28
  BUG_REPORT_RATE_LIMIT_PER_MINUTE?: string
29
29
  /** Days before a finished, abandoned or never-started match is deleted. 0 keeps everything. */
30
30
  RETENTION_DAYS?: string
31
+ /**
32
+ * Days before a RUNNING match with nobody active in it is deleted. 0 keeps them forever.
33
+ *
34
+ * The ordinary end of a public match is everyone walking away from one that is still running, and
35
+ * nothing else ever collects one of those.
36
+ */
37
+ ABANDONED_RETENTION_DAYS?: string
38
+ /** Days a bug report and its R2 object are kept. 0 keeps them forever. */
39
+ BUG_REPORT_RETENTION_DAYS?: string
40
+ /** Attached journal megabytes accepted per rolling day across every reporter. 0 lifts the cap. */
41
+ BUG_REPORT_DAILY_STATE_MB?: string
31
42
  }
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { RateLimiter } from '@chaos-overlords/kernel'
1
+ import { RateLimitedError, RateLimiter } from '@chaos-overlords/kernel'
2
2
  import {
3
3
  type AppEnv,
4
4
  createApp,
5
5
  DEFAULT_RATE_LIMITS,
6
6
  DEFAULT_SERVER_CONFIG,
7
+ defaultClientAddress,
7
8
  type ServerContainer,
8
9
  } from '@chaos-overlords/server'
9
10
  import type { ExecutionContext, ScheduledController } from '@cloudflare/workers-types'
@@ -57,9 +58,17 @@ export function buildContainer(env: Env): ServerContainer {
57
58
  kernel: buildKernel(env),
58
59
  ...(bugReports ? { bugReports } : {}),
59
60
  eventStream: {
60
- open: async ({ matchId, afterSeq, signal }) => {
61
- const url = `https://hub${HUB_PATHS.subscribe}?matchId=${encodeURIComponent(matchId)}&after=${afterSeq}`
61
+ open: async ({ matchId, playerId, afterSeq, signal }) => {
62
+ const query = new URLSearchParams({ matchId, playerId, after: String(afterSeq) })
63
+ const url = `https://hub${HUB_PATHS.subscribe}?${query}`
62
64
  const response = await hubFor(env, matchId).fetch(url, { signal })
65
+ // The object refuses an over-cap stream with a bare 429; turning it back into the domain
66
+ // error here is what gets the caller the same envelope every other refusal has.
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
+ })
71
+ }
63
72
  return new Response(response.body as ReadableStream<Uint8Array> | null, {
64
73
  status: response.status,
65
74
  headers: response.headers as unknown as HeadersInit,
@@ -76,6 +85,10 @@ export function buildContainer(env: Env): ServerContainer {
76
85
  ),
77
86
  },
78
87
  config: { ...DEFAULT_SERVER_CONFIG, publicListing: env.PUBLIC_LISTING === 'true' },
88
+ // `CF-Connecting-IP` is authoritative here and only here: Cloudflare sets it on every request
89
+ // that reaches a Worker and a client cannot forge it through the edge. Off Cloudflare it is a
90
+ // header anyone can write, which is why the default resolver ignores it unless told otherwise.
91
+ clientAddress: (c) => defaultClientAddress(c, { cloudflare: true }),
79
92
  }
80
93
  }
81
94
 
@@ -98,6 +111,9 @@ export default {
98
111
  workerLogger.info('cron advanced turns', { sealed, repaired })
99
112
  }
100
113
  await kernel.retention.collect()
114
+ // A separate database with a separate window; nothing about a match's retention decides
115
+ // when a bug report and its R2 object go.
116
+ await container.bugReports?.collect()
101
117
  })().catch((error: unknown) => {
102
118
  workerLogger.error('cron sweep failed', { error: String(error) })
103
119
  }),
package/src/kernel.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  createBugReportRepository,
5
5
  createBugReportService,
6
6
  createR2BlobStore,
7
+ DEFAULT_BUG_REPORT_RETENTION,
7
8
  } from '@chaos-overlords/bug-reports'
8
9
  import {
9
10
  createKernel,
@@ -11,6 +12,7 @@ import {
11
12
  type EventNotifier,
12
13
  type Kernel,
13
14
  type Logger,
15
+ type StreamCloser,
14
16
  } from '@chaos-overlords/kernel'
15
17
  import { createSqliteStorage, sqliteSchema } from '@chaos-overlords/storage/sqlite'
16
18
  import { drizzle } from 'drizzle-orm/d1'
@@ -26,11 +28,14 @@ export const workerLogger: Logger = {
26
28
 
27
29
  const DAY_MS = 24 * 60 * 60 * 1000
28
30
  const DEFAULT_RETENTION_DAYS = 30
31
+ const DEFAULT_ABANDONED_RETENTION_DAYS = 90
29
32
 
30
33
  export const HUB_PATHS = {
31
34
  notify: '/notify',
32
35
  schedule: '/schedule',
33
36
  subscribe: '/subscribe',
37
+ /** Hangs up the streams of a membership that has just been revoked. */
38
+ disconnect: '/disconnect',
34
39
  } as const
35
40
 
36
41
  /** The per-match Durable Object, addressed by match id. */
@@ -44,7 +49,11 @@ export function hubFor(env: Env, matchId: string) {
44
49
  */
45
50
  export function buildKernel(
46
51
  env: Env,
47
- overrides: Partial<{ notifier: EventNotifier; scheduler: DeadlineScheduler }> = {},
52
+ overrides: Partial<{
53
+ notifier: EventNotifier
54
+ scheduler: DeadlineScheduler
55
+ streams: StreamCloser
56
+ }> = {},
48
57
  ): Kernel {
49
58
  const storage = createSqliteStorage(drizzle(env.DB, { schema: sqliteSchema }))
50
59
  const notifier: EventNotifier = overrides.notifier ?? {
@@ -55,6 +64,14 @@ export function buildKernel(
55
64
  })
56
65
  },
57
66
  }
67
+ const streams: StreamCloser = overrides.streams ?? {
68
+ close: async (input) => {
69
+ await hubFor(env, input.matchId).fetch(`https://hub${HUB_PATHS.disconnect}`, {
70
+ method: 'POST',
71
+ body: JSON.stringify(input),
72
+ })
73
+ },
74
+ }
58
75
  const scheduler: DeadlineScheduler = overrides.scheduler ?? {
59
76
  schedule: async (input) => {
60
77
  await hubFor(env, input.matchId).fetch(`https://hub${HUB_PATHS.schedule}`, {
@@ -63,21 +80,24 @@ export function buildKernel(
63
80
  })
64
81
  },
65
82
  }
66
- const retentionDays = Number(env.RETENTION_DAYS ?? DEFAULT_RETENTION_DAYS)
83
+ const days = (raw: string | undefined, fallback: number): number => {
84
+ const value = Number(raw ?? fallback)
85
+ return Number.isInteger(value) && value >= 0 ? value : fallback
86
+ }
67
87
  return createKernel(
68
88
  {
69
89
  storage,
70
90
  notifier,
71
91
  scheduler,
92
+ streams,
72
93
  clock: { now: () => new Date() },
73
94
  logger: workerLogger,
74
95
  },
75
96
  {
76
97
  retention: {
77
- maxAgeMs:
78
- (Number.isInteger(retentionDays) && retentionDays >= 0
79
- ? retentionDays
80
- : DEFAULT_RETENTION_DAYS) * DAY_MS,
98
+ maxAgeMs: days(env.RETENTION_DAYS, DEFAULT_RETENTION_DAYS) * DAY_MS,
99
+ abandonedLiveMaxAgeMs:
100
+ days(env.ABANDONED_RETENTION_DAYS, DEFAULT_ABANDONED_RETENTION_DAYS) * DAY_MS,
81
101
  batchSize: 50,
82
102
  },
83
103
  },
@@ -99,10 +119,27 @@ export function buildBugReports(env: Env): BugReportService | undefined {
99
119
  if (!env.BUG_DB) return undefined
100
120
  const repository = createBugReportRepository(drizzle(env.BUG_DB, { schema: bugReportSchema }))
101
121
  const blobs = env.BUG_BLOBS ? createR2BlobStore(env.BUG_BLOBS) : undefined
122
+ const number = (raw: string | undefined, fallback: number): number => {
123
+ const value = Number(raw ?? fallback)
124
+ return Number.isInteger(value) && value >= 0 ? value : fallback
125
+ }
102
126
  return createBugReportService({
103
127
  repository,
104
128
  clock: { now: () => new Date() },
105
129
  logger: workerLogger,
130
+ retention: {
131
+ ...DEFAULT_BUG_REPORT_RETENTION,
132
+ dailyStateBytes:
133
+ number(
134
+ env.BUG_REPORT_DAILY_STATE_MB,
135
+ DEFAULT_BUG_REPORT_RETENTION.dailyStateBytes / (1024 * 1024),
136
+ ) *
137
+ 1024 *
138
+ 1024,
139
+ maxAgeMs:
140
+ number(env.BUG_REPORT_RETENTION_DAYS, DEFAULT_BUG_REPORT_RETENTION.maxAgeMs / DAY_MS) *
141
+ DAY_MS,
142
+ },
106
143
  ...(blobs ? { blobs } : {}),
107
144
  })
108
145
  }