@hadoku/service-kernel 0.0.0-stage → 0.1.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth/index.d.ts +118 -0
  3. package/dist/auth/index.js +171 -0
  4. package/dist/capacity/capacity.d.ts +33 -0
  5. package/dist/capacity/capacity.js +119 -0
  6. package/dist/capacity/index.d.ts +1 -0
  7. package/dist/capacity/index.js +1 -0
  8. package/dist/erase/anonymise.d.ts +18 -0
  9. package/dist/erase/anonymise.js +77 -0
  10. package/dist/erase/erase.d.ts +59 -0
  11. package/dist/erase/erase.js +72 -0
  12. package/dist/erase/index.d.ts +2 -0
  13. package/dist/erase/index.js +2 -0
  14. package/dist/health/health.d.ts +42 -0
  15. package/dist/health/health.js +52 -0
  16. package/dist/health/index.d.ts +1 -0
  17. package/dist/health/index.js +1 -0
  18. package/dist/http/index.d.ts +123 -0
  19. package/dist/http/index.js +192 -0
  20. package/dist/lifecycle/announce.d.ts +74 -0
  21. package/dist/lifecycle/announce.js +232 -0
  22. package/dist/lifecycle/bootClock.d.ts +7 -0
  23. package/dist/lifecycle/bootClock.js +14 -0
  24. package/dist/lifecycle/busy.d.ts +57 -0
  25. package/dist/lifecycle/busy.js +47 -0
  26. package/dist/lifecycle/flush-sockets.d.ts +41 -0
  27. package/dist/lifecycle/flush-sockets.js +61 -0
  28. package/dist/lifecycle/index.d.ts +4 -0
  29. package/dist/lifecycle/index.js +4 -0
  30. package/dist/limits/index.d.ts +1 -0
  31. package/dist/limits/index.js +1 -0
  32. package/dist/limits/limits.d.ts +76 -0
  33. package/dist/limits/limits.js +103 -0
  34. package/dist/log/index.d.ts +1 -0
  35. package/dist/log/index.js +1 -0
  36. package/dist/log/log.d.ts +7 -0
  37. package/dist/log/log.js +8 -0
  38. package/dist/relays/client-events.d.ts +51 -0
  39. package/dist/relays/client-events.js +53 -0
  40. package/dist/relays/index.d.ts +2 -0
  41. package/dist/relays/index.js +2 -0
  42. package/dist/relays/usage.d.ts +53 -0
  43. package/dist/relays/usage.js +75 -0
  44. package/dist/server/bus.d.ts +27 -0
  45. package/dist/server/bus.js +21 -0
  46. package/dist/server/index.d.ts +2 -0
  47. package/dist/server/index.js +2 -0
  48. package/dist/server/server.d.ts +157 -0
  49. package/dist/server/server.js +244 -0
  50. package/dist/state/index.d.ts +1 -0
  51. package/dist/state/index.js +1 -0
  52. package/dist/state/state-dir.d.ts +39 -0
  53. package/dist/state/state-dir.js +63 -0
  54. package/dist/telemetry/index.d.ts +1 -0
  55. package/dist/telemetry/index.js +1 -0
  56. package/dist/telemetry/telemetry.d.ts +105 -0
  57. package/dist/telemetry/telemetry.js +221 -0
  58. package/package.json +71 -4
  59. package/README.md +0 -3
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A service's log line: one JSON object on stdout with a timestamp, the
3
+ * service that wrote it, then the entry's own fields. pm2 keeps stdout, and
4
+ * every reader of these logs greps for `svc`.
5
+ */
6
+ export type JsonLog = (entry: Record<string, unknown>) => void;
7
+ export declare function jsonLogger(svc: string): JsonLog;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * A service's log line: one JSON object on stdout with a timestamp, the
3
+ * service that wrote it, then the entry's own fields. pm2 keeps stdout, and
4
+ * every reader of these logs greps for `svc`.
5
+ */
6
+ export function jsonLogger(svc) {
7
+ return (entry) => console.log(JSON.stringify({ t: new Date().toISOString(), svc, ...entry }));
8
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A browser relay for page telemetry from a screen with no match: a flat `{ page, kind, data }`
3
+ * event, posted here and logged as a `client` line keyed by page and user. Nothing is stored and
4
+ * nothing is answered but 204. The schema, the mount path, the per-second cap and the caller's id
5
+ * are all parameters — this module knows no game's event shape.
6
+ */
7
+ import type { Request } from 'express';
8
+ import { Router } from 'express';
9
+ /**
10
+ * What a `client-malformed` line says about telemetry the schema turned away: the event's `kind` and
11
+ * `data.what`, when they are strings, and each failing field by its path. Never a value. "Invalid input"
12
+ * alone could not say which report or which field.
13
+ */
14
+ export declare function malformedFacts(raw: unknown, issues: readonly {
15
+ path: readonly PropertyKey[];
16
+ message: string;
17
+ }[]): Record<string, unknown>;
18
+ export interface PageEventSchema<T> {
19
+ safeParse(body: unknown): {
20
+ success: true;
21
+ data: T;
22
+ } | {
23
+ success: false;
24
+ error: {
25
+ issues: readonly {
26
+ path: readonly PropertyKey[];
27
+ message: string;
28
+ }[];
29
+ };
30
+ };
31
+ }
32
+ export interface ClientEventsOptions<T extends {
33
+ page: unknown;
34
+ kind: unknown;
35
+ data: Record<string, unknown>;
36
+ }> {
37
+ /** The event's schema. */
38
+ schema: PageEventSchema<T>;
39
+ /** The mount path under `api`, e.g. `/api/<path>`. */
40
+ path: string;
41
+ /** Per-user events-per-second cap; the rest of the second is dropped silently (204). */
42
+ maxPerSecond: number;
43
+ userIdOf: (req: Request) => string;
44
+ log?: (entry: Record<string, unknown>) => void;
45
+ now?: () => number;
46
+ }
47
+ export declare function attachClientEvents<T extends {
48
+ page: unknown;
49
+ kind: unknown;
50
+ data: Record<string, unknown>;
51
+ }>(api: Router, opts: ClientEventsOptions<T>): void;
@@ -0,0 +1,53 @@
1
+ import { Router } from 'express';
2
+ import { bodyErrors } from "../limits/limits.js";
3
+ /** How much of a client-chosen name (a kind, a `what`, a field's key) a `client-malformed` line keeps. */
4
+ const NAME_MAX = 32;
5
+ function shortName(value) {
6
+ return typeof value === 'string' ? value.slice(0, NAME_MAX) : undefined;
7
+ }
8
+ /**
9
+ * What a `client-malformed` line says about telemetry the schema turned away: the event's `kind` and
10
+ * `data.what`, when they are strings, and each failing field by its path. Never a value. "Invalid input"
11
+ * alone could not say which report or which field.
12
+ */
13
+ export function malformedFacts(raw, issues) {
14
+ const event = typeof raw === 'object' && raw !== null ? raw : {};
15
+ const data = typeof event.data === 'object' && event.data !== null ? event.data : {};
16
+ return {
17
+ kind: shortName(event.kind),
18
+ what: shortName(data.what),
19
+ errors: issues.slice(0, 3).map((i) => `${i.path.map((k) => String(k).slice(0, NAME_MAX)).join('.') || '(event)'}: ${i.message}`),
20
+ };
21
+ }
22
+ export function attachClientEvents(api, opts) {
23
+ const log = opts.log ?? (() => { });
24
+ const now = opts.now ?? Date.now;
25
+ /** Per user, the second being counted and how many arrived in it. */
26
+ const sent = new Map();
27
+ const events = Router();
28
+ events.post('/', (req, res) => {
29
+ const parsed = opts.schema.safeParse(req.body);
30
+ const userId = opts.userIdOf(req);
31
+ if (!parsed.success) {
32
+ log({ evt: 'client-malformed', userId, ...malformedFacts(req.body, parsed.error.issues) });
33
+ return res.status(400).json({ error: 'not a page event' });
34
+ }
35
+ const second = Math.floor(now() / 1000);
36
+ const window = sent.get(userId);
37
+ if (window === undefined || window.second !== second) {
38
+ for (const [id, w] of sent)
39
+ if (w.second !== second)
40
+ sent.delete(id);
41
+ sent.set(userId, { second, count: 1 });
42
+ }
43
+ else {
44
+ window.count += 1;
45
+ if (window.count > opts.maxPerSecond)
46
+ return res.status(204).end();
47
+ }
48
+ log({ evt: 'client', page: parsed.data.page, userId, kind: parsed.data.kind, ...parsed.data.data });
49
+ return res.status(204).end();
50
+ });
51
+ api.use(`/${opts.path}`, events);
52
+ api.use(`/${opts.path}`, bodyErrors((name) => log({ evt: 'client-error', name })));
53
+ }
@@ -0,0 +1,2 @@
1
+ export { attachClientEvents, malformedFacts, type ClientEventsOptions, type PageEventSchema } from './client-events.js';
2
+ export { attachUsage, MAX_BATCHES_PER_MINUTE, USAGE_TOP, USAGE_WINDOW_MS, type UsageCounter, type UsageEntry, type UsageOptions, type UsageSchema, } from './usage.js';
@@ -0,0 +1,2 @@
1
+ export { attachClientEvents, malformedFacts } from "./client-events.js";
2
+ export { attachUsage, MAX_BATCHES_PER_MINUTE, USAGE_TOP, USAGE_WINDOW_MS, } from "./usage.js";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A browser relay for usage batches: which screens were shown and which controls pressed, counted
3
+ * here and posted to telemetry as one `usage` event per window — how often, by how many people,
4
+ * by tier. Nothing per click is kept, and nothing is answered but 204. The schema, the mount path
5
+ * and the caller's tier/id are all parameters — this module knows no game's usage shape.
6
+ *
7
+ * A caller sending more than `maxBatchesPerMinute` is a client bug or a script; the rest of that
8
+ * minute is dropped and counted as `dropped`, so the event says so.
9
+ */
10
+ import type { Request } from 'express';
11
+ import { Router } from 'express';
12
+ import { type Report } from '../telemetry/telemetry.js';
13
+ export declare const USAGE_WINDOW_MS: number;
14
+ export declare const MAX_BATCHES_PER_MINUTE = 10;
15
+ /** How many (kind, screen, target) rows one event lists, busiest first. */
16
+ export declare const USAGE_TOP = 60;
17
+ export interface UsageEntry {
18
+ kind: string;
19
+ screen: string;
20
+ target?: string;
21
+ }
22
+ export interface UsageSchema<T extends {
23
+ entries: readonly UsageEntry[];
24
+ }> {
25
+ safeParse(body: unknown): {
26
+ success: true;
27
+ data: T;
28
+ } | {
29
+ success: false;
30
+ };
31
+ }
32
+ export interface UsageOptions<T extends {
33
+ entries: readonly UsageEntry[];
34
+ }> {
35
+ schema: UsageSchema<T>;
36
+ /** The mount path under `api`, e.g. `/api/<path>`. */
37
+ path: string;
38
+ tierOf: (req: Request) => string;
39
+ userIdOf: (req: Request) => string;
40
+ report?: Report;
41
+ now?: () => number;
42
+ windowMs?: number;
43
+ maxBatchesPerMinute?: number;
44
+ top?: number;
45
+ }
46
+ export interface UsageCounter {
47
+ /** Post the window counted so far, if anything arrived, and start a new one. */
48
+ flush(): void;
49
+ stop(): void;
50
+ }
51
+ export declare function attachUsage<T extends {
52
+ entries: readonly UsageEntry[];
53
+ }>(api: Router, opts: UsageOptions<T>): UsageCounter;
@@ -0,0 +1,75 @@
1
+ import { Router } from 'express';
2
+ import { bodyErrors } from "../limits/limits.js";
3
+ import { report as defaultReport } from "../telemetry/telemetry.js";
4
+ export const USAGE_WINDOW_MS = 5 * 60_000;
5
+ export const MAX_BATCHES_PER_MINUTE = 10;
6
+ /** How many (kind, screen, target) rows one event lists, busiest first. */
7
+ export const USAGE_TOP = 60;
8
+ export function attachUsage(api, opts) {
9
+ const report = opts.report ?? defaultReport;
10
+ const now = opts.now ?? Date.now;
11
+ const maxBatchesPerMinute = opts.maxBatchesPerMinute ?? MAX_BATCHES_PER_MINUTE;
12
+ const top = opts.top ?? USAGE_TOP;
13
+ let counts = new Map();
14
+ let users = new Map();
15
+ let batches = 0;
16
+ let dropped = 0;
17
+ let windowStart = now();
18
+ const perMinute = new Map();
19
+ const router = Router();
20
+ router.post('/', (req, res) => {
21
+ const parsed = opts.schema.safeParse(req.body);
22
+ if (!parsed.success)
23
+ return res.status(400).json({ error: 'not a usage batch' });
24
+ const userId = opts.userIdOf(req);
25
+ const minute = Math.floor(now() / 60_000);
26
+ const seen = perMinute.get(userId);
27
+ if (seen === undefined || seen.minute !== minute) {
28
+ for (const [id, w] of perMinute)
29
+ if (w.minute !== minute)
30
+ perMinute.delete(id);
31
+ perMinute.set(userId, { minute, n: 1 });
32
+ }
33
+ else if (++seen.n > maxBatchesPerMinute) {
34
+ dropped += 1;
35
+ return res.status(204).end();
36
+ }
37
+ batches += 1;
38
+ users.set(userId, opts.tierOf(req));
39
+ for (const e of parsed.data.entries) {
40
+ const key = `${e.kind} ${e.screen}${e.target === undefined ? '' : ` ${e.target}`}`;
41
+ counts.set(key, (counts.get(key) ?? 0) + 1);
42
+ }
43
+ return res.status(204).end();
44
+ });
45
+ api.use(`/${opts.path}`, router);
46
+ api.use(`/${opts.path}`, bodyErrors());
47
+ const flush = () => {
48
+ const start = windowStart;
49
+ windowStart = now();
50
+ if (batches === 0 && dropped === 0)
51
+ return;
52
+ const tiers = {};
53
+ for (const tier of users.values())
54
+ tiers[tier] = (tiers[tier] ?? 0) + 1;
55
+ const rows = [...counts].sort((a, b) => b[1] - a[1]);
56
+ report('usage', {
57
+ from: new Date(start).toISOString(),
58
+ minutes: Math.round((windowStart - start) / 60_000),
59
+ users: users.size,
60
+ tiers,
61
+ batches,
62
+ dropped,
63
+ views: rows.filter(([k]) => k.startsWith('view ')).reduce((n, [, c]) => n + c, 0),
64
+ clicks: rows.filter(([k]) => k.startsWith('click ')).reduce((n, [, c]) => n + c, 0),
65
+ top: Object.fromEntries(rows.slice(0, top)),
66
+ });
67
+ counts = new Map();
68
+ users = new Map();
69
+ batches = 0;
70
+ dropped = 0;
71
+ };
72
+ const timer = setInterval(flush, opts.windowMs ?? USAGE_WINDOW_MS);
73
+ timer.unref();
74
+ return { flush, stop: () => clearInterval(timer) };
75
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The event bus a game's plugins share (`ctx.bus`): a typed emitter, synchronous, so a
3
+ * listener runs inside `emit` and a plugin set up later hears what an earlier one emits from
4
+ * then on. The kernel emits `listening` and `shutdown`; each game declares its own events in
5
+ * the type parameter and emits them itself.
6
+ */
7
+ /** The events the kernel itself emits. A type, not an interface, so it is an `EventMap`. */
8
+ export type KernelEvents = {
9
+ /** The HTTP server is listening, before any `onListen` hook or `warm` plugin runs. */
10
+ listening: {
11
+ host: string;
12
+ port: number;
13
+ };
14
+ /** A signal began the shutdown, before any plugin's own `shutdown` runs. */
15
+ shutdown: {
16
+ signal: string;
17
+ deadlineMs: number;
18
+ };
19
+ };
20
+ export type EventMap = Record<string, unknown>;
21
+ export interface Bus<E extends EventMap> {
22
+ /** Listen for `event`; the returned function stops listening. */
23
+ on<K extends keyof E & string>(event: K, listener: (payload: E[K]) => void): () => void;
24
+ /** Call every listener of `event`, in the order they were added. */
25
+ emit<K extends keyof E & string>(event: K, payload: E[K]): void;
26
+ }
27
+ export declare function createBus<E extends EventMap>(): Bus<E>;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The event bus a game's plugins share (`ctx.bus`): a typed emitter, synchronous, so a
3
+ * listener runs inside `emit` and a plugin set up later hears what an earlier one emits from
4
+ * then on. The kernel emits `listening` and `shutdown`; each game declares its own events in
5
+ * the type parameter and emits them itself.
6
+ */
7
+ export function createBus() {
8
+ const listeners = new Map();
9
+ return {
10
+ on(event, listener) {
11
+ const set = listeners.get(event) ?? new Set();
12
+ listeners.set(event, set);
13
+ set.add(listener);
14
+ return () => void set.delete(listener);
15
+ },
16
+ emit(event, payload) {
17
+ for (const listener of [...(listeners.get(event) ?? [])])
18
+ listener(payload);
19
+ },
20
+ };
21
+ }
@@ -0,0 +1,2 @@
1
+ export { createBus, type Bus, type EventMap, type KernelEvents } from './bus.js';
2
+ export { createGameServer, PHASES, type GameContext, type GamePlugin, type GameServer, type GameServerConfig, type GameServerOptions, type Phase, type PluginHooks, } from './server.js';
@@ -0,0 +1,2 @@
1
+ export { createBus } from "./bus.js";
2
+ export { createGameServer, PHASES, } from "./server.js";
@@ -0,0 +1,157 @@
1
+ /**
2
+ * `createGameServer`: the one boot sequence every game's server runs, so a game is its config
3
+ * and a list of plugins.
4
+ *
5
+ * ## What the kernel owns
6
+ *
7
+ * The express app and its `/api` router, the HTTP server and the socket.io server; edge
8
+ * provenance and the tier floor (the app's `EdgeAuth` and `Gate`, `../auth` and `../http`);
9
+ * `/health` and the local-only busy probe; the telemetry sink and the capacity sampler; the
10
+ * account-erase route and its registry; the origin stamp; `listen`; and SIGTERM/SIGINT with
11
+ * the restart notice, the connection grace and the shutdown deadline.
12
+ *
13
+ * ## The order a request meets
14
+ *
15
+ * provenance → capacity → access log → /health, /health/busy, the erase route, the sealed routes
16
+ * → the tier floor → first-seen → /api (JSON body, then the plugins' routes)
17
+ * → the bundle's static files → the plugins' own app routes, in registration order
18
+ *
19
+ * Everything above the floor is public on purpose; a plugin registers nothing above it except on
20
+ * `sealed`, which answers only a request carrying the edge's seal and passes every other one on to
21
+ * the floor. The edge seals each request it proxies whatever the caller's tier, so a route there is
22
+ * reachable through hadoku.me by anyone the edge itself lets through (a link-preview crawler, say),
23
+ * and never by a direct hit on the tunnel.
24
+ *
25
+ * ## Boot phases
26
+ *
27
+ * `config → bundle → stores → routes → sockets → listen → warm`, each plugin's `setup` in
28
+ * array order within its phase. The kernel checks the bundle at the start of `bundle` and
29
+ * installs telemetry at its end, builds first-seen at the end of `config` (so a state-dir move in `config`
30
+ * happens before it reads its file), and starts listening between `sockets` and `listen`:
31
+ * `listen` and `warm` run once the server answers, `warm` last. The boot clock stamps each phase as it ends, unless the
32
+ * config turns that off to keep a game's own names (`stampPhases: false`, `ctx.bootPhase`).
33
+ *
34
+ * ## Going down
35
+ *
36
+ * The first SIGTERM or SIGINT logs `shutdown-begin`, emits `shutdown` on the bus, runs every
37
+ * plugin's `shutdown` in REVERSE setup order (the last thing built goes first), then tells
38
+ * every socket the game's restart event, closes socket.io and the HTTP server, and lets
39
+ * in-flight requests finish within `requestGraceMs`. It exits on the close or at
40
+ * `deadlineMs`, whichever comes first, logging `shutdown-done` with the fields the plugins'
41
+ * shutdowns returned.
42
+ */
43
+ import { type Server } from 'node:http';
44
+ import { type Express, type Router } from 'express';
45
+ import { Server as SocketIOServer } from 'socket.io';
46
+ import { type EdgeAuth } from '../auth/index.js';
47
+ import { type EraseStep } from '../erase/index.js';
48
+ import { type BasePathVerdict, type Gate } from '../http/index.js';
49
+ import { type BusyRun } from '../lifecycle/index.js';
50
+ import type { JsonLog } from '../log/index.js';
51
+ import { type Report } from '../telemetry/index.js';
52
+ import { type Bus, type EventMap, type KernelEvents } from './bus.js';
53
+ export declare const PHASES: readonly ["config", "bundle", "stores", "routes", "sockets", "listen", "warm"];
54
+ export type Phase = (typeof PHASES)[number];
55
+ export interface GameServerConfig {
56
+ host: string;
57
+ port: number;
58
+ /** The service's own lines: the boot clock, `shutdown-*`, and the erase route's. */
59
+ log: JsonLog;
60
+ /** The app's edge-auth binding: provenance on every request, the seal on the erase route. */
61
+ edgeAuth: EdgeAuth;
62
+ /** The app's gate: its `requireMinTier` is the HTTP floor. */
63
+ gate: Gate;
64
+ /** The prefix the edge serves this app under (`/<game>`); names `/health`'s service. */
65
+ basePrefix: string;
66
+ /** The built bundle: its index page, the directory served beside it, and the entry files that keep their names. */
67
+ bundle: {
68
+ indexHtml: string;
69
+ webDir: string;
70
+ embedEntries: readonly string[];
71
+ };
72
+ /** This instance's checkout, for `/health`'s commit. */
73
+ repoRoot: string;
74
+ /** The socket event that tells every client the server is restarting. */
75
+ restartEvent: string;
76
+ /** Built and installed after the `bundle` phase, before any plugin's route. Absent: events go nowhere. */
77
+ telemetry?: () => Report;
78
+ /** The accounts this server has admitted, kept across restarts (`caller-first-seen`). */
79
+ firstSeen: {
80
+ file: string;
81
+ log: JsonLog;
82
+ };
83
+ /** The app's own derived id for an erased account (`personOf`). */
84
+ erase: {
85
+ deriveParticipantId: (userId: string) => string;
86
+ };
87
+ /** A request path counted on its own, never timed nor logged a line each, and its capacity field. */
88
+ uncounted?: {
89
+ path: (path: string) => boolean;
90
+ field: string;
91
+ };
92
+ /** `X-Hadoku-Origin`; defaults to the game's id. */
93
+ origin?: string;
94
+ /** Stamp each kernel phase on the boot clock (default true). */
95
+ stampPhases?: boolean;
96
+ shutdown?: {
97
+ deadlineMs?: number;
98
+ requestGraceMs?: number;
99
+ restartFlushMs?: number;
100
+ };
101
+ /** Install SIGTERM/SIGINT handlers (default true). */
102
+ signals?: boolean;
103
+ /** Ends the process once shut down; `process.exit` unless a test passes its own. */
104
+ exit?: (code: number) => void;
105
+ }
106
+ export interface GameContext<E extends EventMap, S> {
107
+ readonly id: string;
108
+ readonly app: Express;
109
+ /** Mounted at `/api`, below the floor, after the JSON body parser. */
110
+ readonly api: Router;
111
+ /** Above the floor, behind the edge's seal and no tier: what the edge itself serves publicly. */
112
+ readonly sealed: Router;
113
+ readonly httpServer: Server;
114
+ readonly io: SocketIOServer;
115
+ readonly bus: Bus<KernelEvents & E>;
116
+ /** The bundle check's verdict, from the `bundle` phase on. */
117
+ readonly bundle: BasePathVerdict;
118
+ /** The game's own state, shared between its plugins. */
119
+ readonly state: S;
120
+ /** Stamp a named step on the boot clock. */
121
+ bootPhase(name: string): void;
122
+ /** Add a gauge to the capacity sample, read each sample. */
123
+ gauge(name: string, read: () => number): void;
124
+ /** Add counts to each capacity post, read once as it goes out. */
125
+ counts(read: () => Record<string, unknown>): void;
126
+ /** Add runs to the busy probe's answer. */
127
+ busy(read: () => BusyRun[]): void;
128
+ }
129
+ export interface PluginHooks {
130
+ /** Runs at shutdown, in reverse setup order; what it returns is added to `shutdown-done`. */
131
+ shutdown?: () => Record<string, unknown> | void;
132
+ /** Registered on the erase route in setup order. */
133
+ erase?: EraseStep[];
134
+ /** Runs once the server listens, in setup order, before the `listen` phase. */
135
+ onListen?: () => void;
136
+ }
137
+ export interface GamePlugin<E extends EventMap, S> {
138
+ name: string;
139
+ phase: Phase;
140
+ setup(ctx: GameContext<E, S>): PluginHooks | void | Promise<PluginHooks | void>;
141
+ }
142
+ export interface GameServer<E extends EventMap, S> {
143
+ readonly app: Express;
144
+ readonly httpServer: Server;
145
+ readonly io: SocketIOServer;
146
+ readonly bus: Bus<KernelEvents & E>;
147
+ readonly state: S;
148
+ /** Shut down as a signal would; resolves with why it finished (`closed` or `deadline`). */
149
+ shutdown(signal: string): Promise<string>;
150
+ }
151
+ export interface GameServerOptions<E extends EventMap, S> {
152
+ id: string;
153
+ config: GameServerConfig;
154
+ state: S;
155
+ plugins: readonly GamePlugin<E, S>[];
156
+ }
157
+ export declare function createGameServer<E extends EventMap, S>({ id, config, state, plugins }: GameServerOptions<E, S>): Promise<GameServer<E, S>>;