@hadoku/service-kernel 0.0.0-stage → 0.1.4
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/LICENSE +21 -0
- package/dist/auth/index.d.ts +125 -0
- package/dist/auth/index.js +189 -0
- package/dist/capacity/capacity.d.ts +33 -0
- package/dist/capacity/capacity.js +119 -0
- package/dist/capacity/index.d.ts +1 -0
- package/dist/capacity/index.js +1 -0
- package/dist/erase/anonymise.d.ts +18 -0
- package/dist/erase/anonymise.js +77 -0
- package/dist/erase/erase.d.ts +59 -0
- package/dist/erase/erase.js +72 -0
- package/dist/erase/index.d.ts +2 -0
- package/dist/erase/index.js +2 -0
- package/dist/health/health.d.ts +42 -0
- package/dist/health/health.js +52 -0
- package/dist/health/index.d.ts +1 -0
- package/dist/health/index.js +1 -0
- package/dist/http/index.d.ts +123 -0
- package/dist/http/index.js +192 -0
- package/dist/lifecycle/announce.d.ts +74 -0
- package/dist/lifecycle/announce.js +232 -0
- package/dist/lifecycle/bootClock.d.ts +7 -0
- package/dist/lifecycle/bootClock.js +14 -0
- package/dist/lifecycle/busy.d.ts +57 -0
- package/dist/lifecycle/busy.js +47 -0
- package/dist/lifecycle/flush-sockets.d.ts +41 -0
- package/dist/lifecycle/flush-sockets.js +61 -0
- package/dist/lifecycle/index.d.ts +4 -0
- package/dist/lifecycle/index.js +4 -0
- package/dist/limits/index.d.ts +1 -0
- package/dist/limits/index.js +1 -0
- package/dist/limits/limits.d.ts +76 -0
- package/dist/limits/limits.js +103 -0
- package/dist/log/index.d.ts +1 -0
- package/dist/log/index.js +1 -0
- package/dist/log/log.d.ts +7 -0
- package/dist/log/log.js +8 -0
- package/dist/relays/client-events.d.ts +51 -0
- package/dist/relays/client-events.js +53 -0
- package/dist/relays/index.d.ts +2 -0
- package/dist/relays/index.js +2 -0
- package/dist/relays/usage.d.ts +53 -0
- package/dist/relays/usage.js +75 -0
- package/dist/server/bus.d.ts +27 -0
- package/dist/server/bus.js +21 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +2 -0
- package/dist/server/server.d.ts +157 -0
- package/dist/server/server.js +244 -0
- package/dist/state/index.d.ts +1 -0
- package/dist/state/index.js +1 -0
- package/dist/state/state-dir.d.ts +39 -0
- package/dist/state/state-dir.js +63 -0
- package/dist/telemetry/index.d.ts +1 -0
- package/dist/telemetry/index.js +1 -0
- package/dist/telemetry/telemetry.d.ts +105 -0
- package/dist/telemetry/telemetry.js +221 -0
- package/package.json +71 -4
- package/README.md +0 -3
|
@@ -0,0 +1,244 @@
|
|
|
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 { createServer } from 'node:http';
|
|
44
|
+
import express from 'express';
|
|
45
|
+
import { Server as SocketIOServer } from 'socket.io';
|
|
46
|
+
import { callerIdOf, tierOf, userIdOf } from "../auth/index.js";
|
|
47
|
+
import { capacitySampler } from "../capacity/index.js";
|
|
48
|
+
import { createAccountErase, ERASE_PATH } from "../erase/index.js";
|
|
49
|
+
import { healthResponse, readCommit, serviceIdOf } from "../health/index.js";
|
|
50
|
+
import { checkBundle, isEmbedEntry, stampOrigin } from "../http/index.js";
|
|
51
|
+
import { bootClock, busyHandler, closeConnections, sendThenFlush } from "../lifecycle/index.js";
|
|
52
|
+
import { firstSeen, installTelemetry } from "../telemetry/index.js";
|
|
53
|
+
import { createBus } from "./bus.js";
|
|
54
|
+
export const PHASES = ['config', 'bundle', 'stores', 'routes', 'sockets', 'listen', 'warm'];
|
|
55
|
+
const DEFAULT_SHUTDOWN = { deadlineMs: 1_300, requestGraceMs: 300, restartFlushMs: 500 };
|
|
56
|
+
export async function createGameServer({ id, config, state, plugins }) {
|
|
57
|
+
const { log } = config;
|
|
58
|
+
const stamp = bootClock(log);
|
|
59
|
+
const stampPhases = config.stampPhases ?? true;
|
|
60
|
+
const timing = { ...DEFAULT_SHUTDOWN, ...config.shutdown };
|
|
61
|
+
const exit = config.exit ?? ((code) => process.exit(code));
|
|
62
|
+
const bus = createBus();
|
|
63
|
+
// The kernel's own two events, emitted through the same bus the game's plugins share.
|
|
64
|
+
const kernelBus = bus;
|
|
65
|
+
const app = express();
|
|
66
|
+
app.set('trust proxy', true);
|
|
67
|
+
app.disable('x-powered-by');
|
|
68
|
+
const httpServer = createServer(app);
|
|
69
|
+
// The default path: the edge strips the base prefix before the socket's request reaches us.
|
|
70
|
+
const io = new SocketIOServer(httpServer, { serveClient: false });
|
|
71
|
+
const gauges = { sockets: () => io.engine.clientsCount };
|
|
72
|
+
const countReaders = [];
|
|
73
|
+
const capacity = capacitySampler({
|
|
74
|
+
gauges,
|
|
75
|
+
counts: () => Object.assign({}, ...countReaders.map((read) => read())),
|
|
76
|
+
...(config.uncounted === undefined ? {} : { uncountedPath: config.uncounted.path, uncountedField: config.uncounted.field }),
|
|
77
|
+
});
|
|
78
|
+
const quiet = config.uncounted?.path ?? (() => false);
|
|
79
|
+
let verdict;
|
|
80
|
+
const service = serviceIdOf(config.basePrefix, id);
|
|
81
|
+
const commit = readCommit(config.repoRoot);
|
|
82
|
+
const busyReaders = [];
|
|
83
|
+
const accountErase = createAccountErase({ log, edgeSealed: config.edgeAuth.edgeSealed, deriveParticipantId: config.erase.deriveParticipantId });
|
|
84
|
+
let seen;
|
|
85
|
+
app.use(config.edgeAuth.edgeProvenance);
|
|
86
|
+
app.use(capacity.requests);
|
|
87
|
+
app.use((req, res, next) => {
|
|
88
|
+
if (quiet(req.path)) {
|
|
89
|
+
next();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const start = process.hrtime.bigint();
|
|
93
|
+
res.on('finish', () => {
|
|
94
|
+
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
|
95
|
+
// The identity the handlers saw, post-sanitize: a spoofed request logs as public.
|
|
96
|
+
console.log(JSON.stringify({ t: new Date().toISOString(), svc: id, method: req.method, path: req.path, status: res.statusCode, ms: Math.round(ms), user: userIdOf(req), tier: tierOf(req) }));
|
|
97
|
+
});
|
|
98
|
+
next();
|
|
99
|
+
});
|
|
100
|
+
// Public: the monitoring probe has no key. A server up but serving a bundle nobody can load says so.
|
|
101
|
+
app.get('/health', (_req, res) => {
|
|
102
|
+
const { code, body } = healthResponse({ service, commit, bundle: verdict ?? { ok: false, reason: 'missing' } });
|
|
103
|
+
res.status(code).json(body);
|
|
104
|
+
});
|
|
105
|
+
// Local only, and a bare 404 to anyone else: the deploy asks it before stopping us.
|
|
106
|
+
app.get('/health/busy', busyHandler(() => {
|
|
107
|
+
const runs = busyReaders.flatMap((read) => read());
|
|
108
|
+
return { busy: runs.length > 0, count: runs.length, runs };
|
|
109
|
+
}));
|
|
110
|
+
// The edge's seal only, never a tier, and above first-seen so an erase does not record the person again.
|
|
111
|
+
app.post(ERASE_PATH, (req, res) => void accountErase.handle(req, res));
|
|
112
|
+
const sealed = express.Router();
|
|
113
|
+
app.use((req, res, next) => (config.edgeAuth.edgeSealed(req) ? sealed(req, res, next) : next()));
|
|
114
|
+
// ── The tier floor: everything below is gated ──
|
|
115
|
+
app.use(config.gate.requireMinTier);
|
|
116
|
+
app.use((req, res, next) => (seen === undefined ? next() : seen(req, res, next)));
|
|
117
|
+
const api = express.Router();
|
|
118
|
+
api.use(express.json({ limit: '1mb' }));
|
|
119
|
+
app.use('/api', api);
|
|
120
|
+
// Hashed names are immutable; index.html is never cached; the embed entries keep their names, so they revalidate.
|
|
121
|
+
const statics = express.static(config.bundle.webDir, {
|
|
122
|
+
index: false,
|
|
123
|
+
maxAge: '1y',
|
|
124
|
+
immutable: true,
|
|
125
|
+
setHeaders: (res, filePath) => {
|
|
126
|
+
if (filePath.endsWith('index.html'))
|
|
127
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
128
|
+
else if (isEmbedEntry(filePath, config.bundle.embedEntries))
|
|
129
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
app.use((req, res, next) => (verdict?.ok === true ? statics(req, res, next) : next()));
|
|
133
|
+
const ctx = {
|
|
134
|
+
id,
|
|
135
|
+
app,
|
|
136
|
+
api,
|
|
137
|
+
sealed,
|
|
138
|
+
httpServer,
|
|
139
|
+
io,
|
|
140
|
+
bus,
|
|
141
|
+
state,
|
|
142
|
+
get bundle() {
|
|
143
|
+
if (verdict === undefined)
|
|
144
|
+
throw new Error('the bundle is checked in the bundle phase; read it from then on');
|
|
145
|
+
return verdict;
|
|
146
|
+
},
|
|
147
|
+
bootPhase: stamp,
|
|
148
|
+
gauge: (name, read) => void (gauges[name] = read),
|
|
149
|
+
counts: (read) => void countReaders.push(read),
|
|
150
|
+
busy: (read) => void busyReaders.push(read),
|
|
151
|
+
};
|
|
152
|
+
const stoppers = [];
|
|
153
|
+
const listeners = [];
|
|
154
|
+
const runPhase = async (phase) => {
|
|
155
|
+
for (const plugin of plugins) {
|
|
156
|
+
if (plugin.phase !== phase)
|
|
157
|
+
continue;
|
|
158
|
+
const hooks = (await plugin.setup(ctx)) ?? {};
|
|
159
|
+
if (hooks.shutdown !== undefined)
|
|
160
|
+
stoppers.push({ name: plugin.name, stop: hooks.shutdown });
|
|
161
|
+
for (const step of hooks.erase ?? [])
|
|
162
|
+
accountErase.register(step);
|
|
163
|
+
if (hooks.onListen !== undefined)
|
|
164
|
+
listeners.push(hooks.onListen);
|
|
165
|
+
}
|
|
166
|
+
if (stampPhases)
|
|
167
|
+
stamp(phase);
|
|
168
|
+
};
|
|
169
|
+
await runPhase('config');
|
|
170
|
+
seen = firstSeen({ file: config.firstSeen.file, userIdOf: callerIdOf, tierOf, log: config.firstSeen.log });
|
|
171
|
+
const callersSeen = seen;
|
|
172
|
+
accountErase.register({ what: 'callersSeen', erase: (p) => callersSeen.forget(p.userId) });
|
|
173
|
+
verdict = await checkBundle(config.bundle.indexHtml, config.basePrefix);
|
|
174
|
+
await runPhase('bundle');
|
|
175
|
+
if (config.telemetry !== undefined)
|
|
176
|
+
installTelemetry(config.telemetry());
|
|
177
|
+
await runPhase('stores');
|
|
178
|
+
await runPhase('routes');
|
|
179
|
+
await runPhase('sockets');
|
|
180
|
+
let shuttingDown;
|
|
181
|
+
const shutdown = (signal) => {
|
|
182
|
+
// A second signal is the process manager being impatient, not a second shutdown.
|
|
183
|
+
if (shuttingDown !== undefined)
|
|
184
|
+
return shuttingDown;
|
|
185
|
+
shuttingDown = new Promise((done) => {
|
|
186
|
+
const startedAt = Date.now();
|
|
187
|
+
log({ evt: 'shutdown-begin', signal });
|
|
188
|
+
kernelBus.emit('shutdown', { signal, deadlineMs: timing.deadlineMs });
|
|
189
|
+
const fields = {};
|
|
190
|
+
for (const { name, stop } of [...stoppers].reverse()) {
|
|
191
|
+
try {
|
|
192
|
+
Object.assign(fields, stop() ?? {});
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
// One plugin that will not let go must not stop the rest of the shutdown.
|
|
196
|
+
log({ evt: 'shutdown-plugin-failed', plugin: name, error: err.message });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
let finished = false;
|
|
200
|
+
const finish = (why) => {
|
|
201
|
+
if (finished)
|
|
202
|
+
return;
|
|
203
|
+
finished = true;
|
|
204
|
+
log({ evt: 'shutdown-done', signal, why, ms: Date.now() - startedAt, ...fields });
|
|
205
|
+
done(why);
|
|
206
|
+
exit(0);
|
|
207
|
+
};
|
|
208
|
+
// Referenced on purpose: it holds the process up for the closes below, and ends it if one never returns.
|
|
209
|
+
const deadline = setTimeout(() => finish('deadline'), timing.deadlineMs);
|
|
210
|
+
// `io.close` drops whatever a connection has not written, so the notice is let out first.
|
|
211
|
+
void sendThenFlush([...io.sockets.sockets.values()].map((socket) => socket.conn), () => io.emit(config.restartEvent), timing.restartFlushMs).then(() => {
|
|
212
|
+
// Disconnects every socket and closes the HTTP server it was given.
|
|
213
|
+
io.close(() => {
|
|
214
|
+
clearTimeout(deadline);
|
|
215
|
+
finish('closed');
|
|
216
|
+
});
|
|
217
|
+
closeConnections(httpServer, timing.requestGraceMs);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
return shuttingDown;
|
|
221
|
+
};
|
|
222
|
+
if (config.signals ?? true) {
|
|
223
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
224
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
225
|
+
}
|
|
226
|
+
// After every socket.io attach: engine.io moves itself to the front of the request listeners.
|
|
227
|
+
stampOrigin(httpServer, config.origin ?? id);
|
|
228
|
+
await new Promise((listening, failed) => {
|
|
229
|
+
httpServer.once('error', failed);
|
|
230
|
+
httpServer.listen(config.port, config.host, () => {
|
|
231
|
+
httpServer.off('error', failed);
|
|
232
|
+
listening();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
capacity.start();
|
|
236
|
+
const { port } = httpServer.address();
|
|
237
|
+
console.log(`[${id}] listening on ${config.host}:${port}`);
|
|
238
|
+
kernelBus.emit('listening', { host: config.host, port });
|
|
239
|
+
for (const onListen of listeners)
|
|
240
|
+
onListen();
|
|
241
|
+
await runPhase('listen');
|
|
242
|
+
await runPhase('warm');
|
|
243
|
+
return { app, httpServer, io, bus, state, shutdown };
|
|
244
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { moveLegacyDir, moveLegacyState, type StateMove } from './state-dir.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { moveLegacyDir, moveLegacyState } from "./state-dir.js";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The boot move from an in-clone layout to a state directory the app sets up
|
|
3
|
+
* outside its deploy clone.
|
|
4
|
+
*
|
|
5
|
+
* Until a deploy builds its new clone aside and swaps it in, everything a
|
|
6
|
+
* server writes lives in the clone (e.g. `apps/server/data`, `apps/server/logs`).
|
|
7
|
+
* A swapped clone has none of it, so the state has to live outside before that
|
|
8
|
+
* deploy is turned on. The first boot that has a state dir moves each entry of
|
|
9
|
+
* the old directory into the new one; an entry already there is never
|
|
10
|
+
* overwritten, and is left where it was and reported. Runs before anything opens
|
|
11
|
+
* a store.
|
|
12
|
+
*/
|
|
13
|
+
export interface StateMove {
|
|
14
|
+
from: string;
|
|
15
|
+
to: string;
|
|
16
|
+
/** Entries moved into `to`. */
|
|
17
|
+
moved: string[];
|
|
18
|
+
/** Entries left in `from` because `to` already holds one of that name. */
|
|
19
|
+
kept: string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Move every entry of `from` into `to` that `to` lacks, then remove `from` if
|
|
23
|
+
* that emptied it. Null when there was nothing to move: `from` missing, or the
|
|
24
|
+
* same directory as `to`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function moveLegacyDir(from: string, to: string): StateMove | null;
|
|
27
|
+
/**
|
|
28
|
+
* The boot move for a server: its in-clone `data/` and `logs/` into the state
|
|
29
|
+
* dir, when one is set. Logs one `state-moved` line per directory that held
|
|
30
|
+
* anything, so the cutover is visible in pm2's log.
|
|
31
|
+
*/
|
|
32
|
+
export declare function moveLegacyState(config: {
|
|
33
|
+
stateDir: string;
|
|
34
|
+
dataDir: string;
|
|
35
|
+
logDir: string;
|
|
36
|
+
}, legacy: {
|
|
37
|
+
dataDir: string;
|
|
38
|
+
logDir: string;
|
|
39
|
+
}, log: (entry: Record<string, unknown>) => void): StateMove[];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The boot move from an in-clone layout to a state directory the app sets up
|
|
3
|
+
* outside its deploy clone.
|
|
4
|
+
*
|
|
5
|
+
* Until a deploy builds its new clone aside and swaps it in, everything a
|
|
6
|
+
* server writes lives in the clone (e.g. `apps/server/data`, `apps/server/logs`).
|
|
7
|
+
* A swapped clone has none of it, so the state has to live outside before that
|
|
8
|
+
* deploy is turned on. The first boot that has a state dir moves each entry of
|
|
9
|
+
* the old directory into the new one; an entry already there is never
|
|
10
|
+
* overwritten, and is left where it was and reported. Runs before anything opens
|
|
11
|
+
* a store.
|
|
12
|
+
*/
|
|
13
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, rmSync } from 'node:fs';
|
|
14
|
+
import { join, resolve } from 'node:path';
|
|
15
|
+
/** Rename, or copy then delete when `to` is on another filesystem. */
|
|
16
|
+
function moveEntry(from, to) {
|
|
17
|
+
try {
|
|
18
|
+
renameSync(from, to);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (err.code !== 'EXDEV')
|
|
22
|
+
throw err;
|
|
23
|
+
cpSync(from, to, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
|
|
24
|
+
rmSync(from, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Move every entry of `from` into `to` that `to` lacks, then remove `from` if
|
|
29
|
+
* that emptied it. Null when there was nothing to move: `from` missing, or the
|
|
30
|
+
* same directory as `to`.
|
|
31
|
+
*/
|
|
32
|
+
export function moveLegacyDir(from, to) {
|
|
33
|
+
const src = resolve(from);
|
|
34
|
+
const dst = resolve(to);
|
|
35
|
+
if (src === dst || !existsSync(src))
|
|
36
|
+
return null;
|
|
37
|
+
mkdirSync(dst, { recursive: true });
|
|
38
|
+
const move = { from: src, to: dst, moved: [], kept: [] };
|
|
39
|
+
for (const name of readdirSync(src).sort()) {
|
|
40
|
+
if (existsSync(join(dst, name))) {
|
|
41
|
+
move.kept.push(name);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
moveEntry(join(src, name), join(dst, name));
|
|
45
|
+
move.moved.push(name);
|
|
46
|
+
}
|
|
47
|
+
if (move.kept.length === 0)
|
|
48
|
+
rmdirSync(src);
|
|
49
|
+
return move;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The boot move for a server: its in-clone `data/` and `logs/` into the state
|
|
53
|
+
* dir, when one is set. Logs one `state-moved` line per directory that held
|
|
54
|
+
* anything, so the cutover is visible in pm2's log.
|
|
55
|
+
*/
|
|
56
|
+
export function moveLegacyState(config, legacy, log) {
|
|
57
|
+
if (!config.stateDir)
|
|
58
|
+
return [];
|
|
59
|
+
const moves = [moveLegacyDir(legacy.dataDir, config.dataDir), moveLegacyDir(legacy.logDir, config.logDir)].filter((m) => m !== null);
|
|
60
|
+
for (const m of moves)
|
|
61
|
+
log({ event: m.kept.length ? 'state-moved-partly' : 'state-moved', ...m });
|
|
62
|
+
return moves;
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { callerContext, firstSeen, installTelemetry, monitoringSink, report, reportingAlerts, reportSealRefused, siteOf, userAgentOf, type FirstSeen, type FirstSeenOptions, type JsonLog, type Level, type Report, type SinkOptions, type TelemetryContext, type TelemetryValue, } from './telemetry.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { callerContext, firstSeen, installTelemetry, monitoringSink, report, reportingAlerts, reportSealRefused, siteOf, userAgentOf, } from "./telemetry.js";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security telemetry: who got in, who was turned away, and who is spending the costly acts — the
|
|
3
|
+
* platform half. Knows no game: the service id, the deployment site and the refusal surface are
|
|
4
|
+
* all parameters, so an app that posts under `service: 'cardtable'` and one that posts under a
|
|
5
|
+
* second game's id read apart in the same monitoring store. See the app's own adapter (this
|
|
6
|
+
* wave's `apps/server/src/telemetry.ts`) for the one place those values get bound.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is fire-and-forget. A post that fails is logged on stdout (pm2 keeps it), never
|
|
9
|
+
* retried, and never delays the request that caused it. One event per (name, caller, incident
|
|
10
|
+
* key) per window goes out; the rest are counted and ride on the next one as `suppressed`, so a
|
|
11
|
+
* scanner hammering the seal costs monitoring one row a minute, not one per request.
|
|
12
|
+
*/
|
|
13
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
14
|
+
/**
|
|
15
|
+
* A log line: one object a reader can grep by its own fields. This module never imports a
|
|
16
|
+
* concrete logger (another lane owns that), so it takes whatever shape fits.
|
|
17
|
+
*/
|
|
18
|
+
export type JsonLog = (entry: Record<string, unknown>) => void;
|
|
19
|
+
export type TelemetryValue = string | number | boolean | null | TelemetryValue[] | {
|
|
20
|
+
[key: string]: TelemetryValue;
|
|
21
|
+
};
|
|
22
|
+
export type TelemetryContext = Record<string, TelemetryValue>;
|
|
23
|
+
/**
|
|
24
|
+
* How much an event needs a person. monitoring-api stores `debug | info | warn | error` and
|
|
25
|
+
* answers 400 to anything else, so `critical` goes out as `error` with `severity: 'critical'`
|
|
26
|
+
* in its context; a reader takes `severity` over the level column.
|
|
27
|
+
*/
|
|
28
|
+
export type Level = 'info' | 'warn' | 'error' | 'critical';
|
|
29
|
+
/** `level` overrides the name rule (`levelOf`). */
|
|
30
|
+
export type Report = (event: string, context: TelemetryContext, level?: Level) => void;
|
|
31
|
+
/** The process's reporter. The app's boot installs the monitoring sink once. */
|
|
32
|
+
export declare function installTelemetry(sink: Report): void;
|
|
33
|
+
/** Report one event. A no-op until a sink is installed. */
|
|
34
|
+
export declare function report(event: string, context: TelemetryContext, level?: Level): void;
|
|
35
|
+
/**
|
|
36
|
+
* Which deployment a base path is: `/<service>` is prod, `/<service>-<site>` that site (a PPE
|
|
37
|
+
* wrapper's prefix, e.g. `/<service>-ppe`). Any other prefix names itself.
|
|
38
|
+
*/
|
|
39
|
+
export declare function siteOf(basePrefix: string, service: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* The hosting alerts (`play/placement/alerts.ts`) as service events, so the daily report lists
|
|
42
|
+
* each one beside the seal refusals. Wraps the log the alerts are given: every entry still goes
|
|
43
|
+
* to it, and each `alert` entry — one per message the alerts post to Discord — is also reported
|
|
44
|
+
* as `alert` with its key and text, at `alertLevel`. Every key is reported, so an alert kind
|
|
45
|
+
* added later needs nothing here.
|
|
46
|
+
*/
|
|
47
|
+
export declare function reportingAlerts(log: JsonLog, sink?: Report): JsonLog;
|
|
48
|
+
export interface SinkOptions {
|
|
49
|
+
/** `https://hadoku.me` — the telemetry route is `/health/api/telemetry` under it. */
|
|
50
|
+
baseUrl: string;
|
|
51
|
+
serviceKey: string;
|
|
52
|
+
/** The env var named in the disabled-reason log line, when `serviceKey` is empty (e.g. `CARDTABLE_SERVICE_KEY`). */
|
|
53
|
+
serviceKeyEnvVar: string;
|
|
54
|
+
log: JsonLog;
|
|
55
|
+
/** Stamped on every event's `context.service`, and in the posting User-Agent. */
|
|
56
|
+
service: string;
|
|
57
|
+
/** This deployment (`siteOf`), on every event. */
|
|
58
|
+
site: string;
|
|
59
|
+
/** One post per (event, caller, incident key) per this many ms. */
|
|
60
|
+
windowMs?: number;
|
|
61
|
+
now?: () => number;
|
|
62
|
+
fetch?: typeof fetch;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A reporter that posts to monitoring-api. Without a service key it posts
|
|
66
|
+
* nothing and says so once, the way the stats reporter does.
|
|
67
|
+
*/
|
|
68
|
+
export declare function monitoringSink(opts: SinkOptions): Report;
|
|
69
|
+
type Headers = Request['headers'];
|
|
70
|
+
/** The caller's user agent, cut short: enough to tell a browser from a script. */
|
|
71
|
+
export declare function userAgentOf(headers: Headers): string | null;
|
|
72
|
+
/**
|
|
73
|
+
* Who a request or handshake says it is, from the edge's headers AFTER
|
|
74
|
+
* `sanitizeHeaders` — so a spoofed caller reports as public with a null userId,
|
|
75
|
+
* exactly what the handlers saw.
|
|
76
|
+
*/
|
|
77
|
+
export declare function callerContext(headers: Headers): TelemetryContext;
|
|
78
|
+
/**
|
|
79
|
+
* A seal below the edge refused a caller. With the edge in front this should
|
|
80
|
+
* never fire: one means a direct hit on the tunnel host, or an edge rule wider
|
|
81
|
+
* than this server's floor — both worth a look. `Surface` is an open string:
|
|
82
|
+
* this module knows no game's surface names, so the app's own adapter narrows
|
|
83
|
+
* it to a closed set.
|
|
84
|
+
*/
|
|
85
|
+
export declare function reportSealRefused<Surface extends string>(surface: Surface, headers: Headers, path: string | null): void;
|
|
86
|
+
export interface FirstSeenOptions {
|
|
87
|
+
/** The JSON list of every user id this server has admitted, kept across restarts. */
|
|
88
|
+
file: string;
|
|
89
|
+
userIdOf: (req: Request) => string | null;
|
|
90
|
+
tierOf: (req: Request) => string;
|
|
91
|
+
log: JsonLog;
|
|
92
|
+
}
|
|
93
|
+
export interface FirstSeen {
|
|
94
|
+
(req: Request, res: Response, next: NextFunction): void;
|
|
95
|
+
/** Drop one account from the list (account erase); 1 when it was there, else 0. */
|
|
96
|
+
forget(userId: string): number;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Middleware, mounted below the tier floor: the first request this server ever
|
|
100
|
+
* admits from a user id reports `caller-first-seen` with its tier and user
|
|
101
|
+
* agent — the "who is getting in" signal, once per account, not per visit.
|
|
102
|
+
* Callers the edge named nobody (a null userId) are not accounts and are skipped.
|
|
103
|
+
*/
|
|
104
|
+
export declare function firstSeen(opts: FirstSeenOptions): FirstSeen;
|
|
105
|
+
export {};
|