@chaos-overlords/worker 0.1.0 → 0.4.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 +2 -1
- package/package.json +8 -8
- package/src/MatchHub.ts +17 -1
- package/src/env.ts +11 -0
- package/src/index.ts +19 -3
- package/src/kernel.ts +43 -6
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
|
|
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.
|
|
3
|
+
"version": "0.4.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.
|
|
51
|
-
"@chaos-overlords/contracts": "0.
|
|
52
|
-
"@chaos-overlords/kernel": "0.
|
|
53
|
-
"@chaos-overlords/server": "0.
|
|
54
|
-
"@chaos-overlords/storage": "0.
|
|
50
|
+
"@chaos-overlords/bug-reports": "0.4.0",
|
|
51
|
+
"@chaos-overlords/contracts": "0.4.0",
|
|
52
|
+
"@chaos-overlords/kernel": "0.4.0",
|
|
53
|
+
"@chaos-overlords/server": "0.4.0",
|
|
54
|
+
"@chaos-overlords/storage": "0.4.0",
|
|
55
55
|
"drizzle-orm": "^0.45.2",
|
|
56
56
|
"hono": "^4.13.7"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@chaos-overlords/client": "0.
|
|
60
|
-
"@chaos-overlords/conformance": "0.
|
|
59
|
+
"@chaos-overlords/client": "0.4.0",
|
|
60
|
+
"@chaos-overlords/conformance": "0.4.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
|
-
|
|
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
|
|
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<{
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
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
|
}
|