@shiprev/server-sdk-node 0.0.0-stage → 0.1.0-beta.1
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 +202 -0
- package/README.md +276 -2
- package/dist/auth/token-verifier.d.ts +56 -0
- package/dist/auth/token-verifier.js +257 -0
- package/dist/client/connection-supervisor.d.ts +148 -0
- package/dist/client/connection-supervisor.js +650 -0
- package/dist/client/shiprev-client.d.ts +139 -0
- package/dist/client/shiprev-client.js +400 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +47 -0
- package/dist/metering/backlog/host-adapter-backlog.d.ts +10 -0
- package/dist/metering/backlog/host-adapter-backlog.js +27 -0
- package/dist/metering/backlog/journal-backlog.d.ts +14 -0
- package/dist/metering/backlog/journal-backlog.js +135 -0
- package/dist/metering/backlog/memory-backlog.d.ts +9 -0
- package/dist/metering/backlog/memory-backlog.js +34 -0
- package/dist/metering/usage-buffer.d.ts +59 -0
- package/dist/metering/usage-buffer.js +175 -0
- package/dist/mirror/degradation.d.ts +22 -0
- package/dist/mirror/degradation.js +67 -0
- package/dist/mirror/entitlement-mirror.d.ts +61 -0
- package/dist/mirror/entitlement-mirror.js +143 -0
- package/dist/nest/decorators.d.ts +15 -0
- package/dist/nest/decorators.js +21 -0
- package/dist/nest/index.d.ts +4 -0
- package/dist/nest/index.js +21 -0
- package/dist/nest/shiprev-auth.guard.d.ts +7 -0
- package/dist/nest/shiprev-auth.guard.js +52 -0
- package/dist/nest/shiprev-capability.guard.d.ts +9 -0
- package/dist/nest/shiprev-capability.guard.js +65 -0
- package/dist/nest/shiprev.module.d.ts +17 -0
- package/dist/nest/shiprev.module.js +57 -0
- package/dist/ruling/adjudicator.d.ts +15 -0
- package/dist/ruling/adjudicator.js +123 -0
- package/dist/transport/event-stream.d.ts +27 -0
- package/dist/transport/event-stream.js +92 -0
- package/dist/transport/http.transport.d.ts +69 -0
- package/dist/transport/http.transport.js +238 -0
- package/dist/transport/wire.d.ts +79 -0
- package/dist/transport/wire.js +8 -0
- package/dist/types.d.ts +252 -0
- package/dist/types.js +37 -0
- package/dist/util/decimal.d.ts +14 -0
- package/dist/util/decimal.js +57 -0
- package/package.json +96 -3
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ConnectionSupervisor = void 0;
|
|
4
|
+
const token_verifier_1 = require("../auth/token-verifier");
|
|
5
|
+
const degradation_1 = require("../mirror/degradation");
|
|
6
|
+
const entitlement_mirror_1 = require("../mirror/entitlement-mirror");
|
|
7
|
+
const event_stream_1 = require("../transport/event-stream");
|
|
8
|
+
const http_transport_1 = require("../transport/http.transport");
|
|
9
|
+
/** Retry interval while the instance refuses the API key (401 or 403 on the token exchange). */
|
|
10
|
+
const REFUSED_RETRY_MS = 30_000;
|
|
11
|
+
const BACKOFF_STEPS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
|
12
|
+
const RENEW_AT = 0.8;
|
|
13
|
+
const HEARTBEAT_TIMEOUT_FACTOR = 3;
|
|
14
|
+
/** Shortest time between two renewals of the same entry. */
|
|
15
|
+
const MIN_RENEWAL_SPACING_MS = 10_000;
|
|
16
|
+
/** Wait before renewing an entry again after a renewal pull failed. */
|
|
17
|
+
const RENEWAL_RETRY_MS = 30_000;
|
|
18
|
+
function backoffDelay(step) {
|
|
19
|
+
const base = BACKOFF_STEPS_MS[Math.min(step, BACKOFF_STEPS_MS.length - 1)];
|
|
20
|
+
return Math.round(base * (0.8 + Math.random() * 0.4));
|
|
21
|
+
}
|
|
22
|
+
function unrefTimer(callback, ms) {
|
|
23
|
+
const timer = setTimeout(callback, Math.max(0, ms));
|
|
24
|
+
timer.unref();
|
|
25
|
+
return timer;
|
|
26
|
+
}
|
|
27
|
+
class ConnectionSupervisor {
|
|
28
|
+
host;
|
|
29
|
+
token = null;
|
|
30
|
+
tokenExpiresAt = 0;
|
|
31
|
+
projectId = null;
|
|
32
|
+
credentialRefused = false;
|
|
33
|
+
exchangeInFlight = null;
|
|
34
|
+
keysLoaded = false;
|
|
35
|
+
stream = null;
|
|
36
|
+
streams = new Set();
|
|
37
|
+
live = false;
|
|
38
|
+
running = false;
|
|
39
|
+
paused = false;
|
|
40
|
+
/** Bumped by stop, pause and start; work begun under an older generation drops its result. */
|
|
41
|
+
generation = 0;
|
|
42
|
+
connecting = null;
|
|
43
|
+
rotating = false;
|
|
44
|
+
backoffStep = 0;
|
|
45
|
+
renewalBackoffStep = 0;
|
|
46
|
+
/** When the next renewal is due (local clock), kept across a pause. */
|
|
47
|
+
renewDueAt = null;
|
|
48
|
+
renewTimer = null;
|
|
49
|
+
reconnectTimer = null;
|
|
50
|
+
watchdogTimer = null;
|
|
51
|
+
renewalTimer = null;
|
|
52
|
+
refillTargets = new Map();
|
|
53
|
+
refillsInFlight = new Set();
|
|
54
|
+
/** Per-organization pulls in flight, shared by refills, renewals and `refresh()`. */
|
|
55
|
+
organizationPulls = new Map();
|
|
56
|
+
/** Organizations whose last renewal pull failed, with the time a renewal may be tried again. */
|
|
57
|
+
renewalRetryAt = new Map();
|
|
58
|
+
constructor(host) {
|
|
59
|
+
this.host = host;
|
|
60
|
+
}
|
|
61
|
+
/** The machine token in use, or `null`. */
|
|
62
|
+
currentToken() {
|
|
63
|
+
return this.token;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Keys, token, stream (waiting for `ready`), then a full pull. Never throws: whatever fails is
|
|
67
|
+
* retried in the background.
|
|
68
|
+
*/
|
|
69
|
+
async start() {
|
|
70
|
+
this.halt();
|
|
71
|
+
this.running = true;
|
|
72
|
+
this.paused = false;
|
|
73
|
+
this.keysLoaded = false;
|
|
74
|
+
const attempt = this.attempt(true);
|
|
75
|
+
this.connecting = attempt;
|
|
76
|
+
try {
|
|
77
|
+
await attempt;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
if (this.connecting === attempt)
|
|
81
|
+
this.connecting = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Closes the stream and stops every timer. */
|
|
85
|
+
stop() {
|
|
86
|
+
this.running = false;
|
|
87
|
+
this.paused = false;
|
|
88
|
+
this.halt();
|
|
89
|
+
}
|
|
90
|
+
/** `setOnline(false)`: closes the stream and suspends supervision until `resume()`. */
|
|
91
|
+
pause() {
|
|
92
|
+
if (!this.running)
|
|
93
|
+
return;
|
|
94
|
+
this.paused = true;
|
|
95
|
+
this.halt();
|
|
96
|
+
}
|
|
97
|
+
/** `setOnline(true)`: resumes supervision; the next attempt corrects the state. */
|
|
98
|
+
resume() {
|
|
99
|
+
if (!this.running)
|
|
100
|
+
return;
|
|
101
|
+
if (this.paused) {
|
|
102
|
+
this.paused = false;
|
|
103
|
+
if (this.renewDueAt !== null) {
|
|
104
|
+
this.scheduleRenewal(this.renewDueAt - Date.now());
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!this.live) {
|
|
108
|
+
void this.reconnect();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** A token set by hand (`setM2MToken`): used as is, never renewed on a timer. */
|
|
112
|
+
injectToken(token) {
|
|
113
|
+
this.token = token;
|
|
114
|
+
this.tokenExpiresAt = (0, token_verifier_1.expiryOfToken)(token) ?? Number.POSITIVE_INFINITY;
|
|
115
|
+
const expectations = (0, token_verifier_1.expectationsFromMachineToken)(token);
|
|
116
|
+
if (expectations)
|
|
117
|
+
this.projectId = expectations.projectId;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Runs an authenticated request. A 401 triggers one renewal, shared by every request that failed
|
|
121
|
+
* with the same token, and one retry with the new token; a second failure is the request's own.
|
|
122
|
+
*/
|
|
123
|
+
async authorized(call) {
|
|
124
|
+
const token = this.token;
|
|
125
|
+
if (!token) {
|
|
126
|
+
throw new Error('Client is not authenticated: M2M token missing');
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
return await call(token);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (!(0, http_transport_1.isHttpStatus)(error, 401))
|
|
133
|
+
throw error;
|
|
134
|
+
let fresh;
|
|
135
|
+
try {
|
|
136
|
+
fresh = await this.renewAfterUnauthorized(token);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
return call(fresh);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// ─── Connection attempts ──────────────────────────────────────────────────
|
|
145
|
+
/**
|
|
146
|
+
* One attempt to get online. `initial` (start) pulls even when the stream cannot be opened, so the
|
|
147
|
+
* mirror is loaded; later attempts pull only once a stream is ready, except while the mirror is
|
|
148
|
+
* still empty.
|
|
149
|
+
*/
|
|
150
|
+
async attempt(initial) {
|
|
151
|
+
const generation = this.generation;
|
|
152
|
+
const current = () => generation === this.generation;
|
|
153
|
+
let opened = null;
|
|
154
|
+
try {
|
|
155
|
+
if (initial || !this.keysLoaded) {
|
|
156
|
+
await this.host.loadKeys();
|
|
157
|
+
this.keysLoaded = true;
|
|
158
|
+
}
|
|
159
|
+
if (!current())
|
|
160
|
+
return;
|
|
161
|
+
const token = await this.validToken();
|
|
162
|
+
if (!current())
|
|
163
|
+
return;
|
|
164
|
+
let streamError;
|
|
165
|
+
try {
|
|
166
|
+
opened = await this.openStream(token);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
streamError = error;
|
|
170
|
+
if ((0, http_transport_1.isHttpStatus)(error, 401) && this.token === token) {
|
|
171
|
+
// The instance no longer accepts this token: exchange a new one on the next attempt.
|
|
172
|
+
this.tokenExpiresAt = 0;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!current())
|
|
176
|
+
return;
|
|
177
|
+
if (streamError !== undefined && !initial && !this.host.mirror.isEmpty())
|
|
178
|
+
throw streamError;
|
|
179
|
+
await this.pull();
|
|
180
|
+
if (!current())
|
|
181
|
+
return;
|
|
182
|
+
if (streamError !== undefined)
|
|
183
|
+
throw streamError;
|
|
184
|
+
if (opened.closed)
|
|
185
|
+
throw new Error('The event stream ended');
|
|
186
|
+
this.adopt(opened);
|
|
187
|
+
opened = null;
|
|
188
|
+
this.goLive(!initial);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
if (!current())
|
|
192
|
+
return;
|
|
193
|
+
this.goOffline();
|
|
194
|
+
this.scheduleReconnect();
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
if (opened)
|
|
198
|
+
this.retire(opened);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
reconnect() {
|
|
202
|
+
if (!this.running || this.paused)
|
|
203
|
+
return Promise.resolve();
|
|
204
|
+
if (this.connecting)
|
|
205
|
+
return this.connecting;
|
|
206
|
+
this.clearTimer('reconnectTimer');
|
|
207
|
+
const attempt = this.attempt(false);
|
|
208
|
+
this.connecting = attempt;
|
|
209
|
+
void attempt.finally(() => {
|
|
210
|
+
if (this.connecting === attempt)
|
|
211
|
+
this.connecting = null;
|
|
212
|
+
});
|
|
213
|
+
return attempt;
|
|
214
|
+
}
|
|
215
|
+
scheduleReconnect() {
|
|
216
|
+
if (!this.running || this.paused)
|
|
217
|
+
return;
|
|
218
|
+
// While the API key is refused the 30-second renewal drives recovery (§5.2 rule 4).
|
|
219
|
+
if (this.credentialRefused && !(this.token && this.tokenExpiresAt > Date.now()))
|
|
220
|
+
return;
|
|
221
|
+
this.clearTimer('reconnectTimer');
|
|
222
|
+
this.reconnectTimer = unrefTimer(() => {
|
|
223
|
+
this.reconnectTimer = null;
|
|
224
|
+
void this.reconnect();
|
|
225
|
+
}, backoffDelay(this.backoffStep++));
|
|
226
|
+
}
|
|
227
|
+
goLive(flush) {
|
|
228
|
+
this.live = true;
|
|
229
|
+
this.backoffStep = 0;
|
|
230
|
+
this.host.setOnline(true);
|
|
231
|
+
this.armWatchdog();
|
|
232
|
+
this.scheduleRenewals();
|
|
233
|
+
if (flush) {
|
|
234
|
+
void this.host.flush().catch(() => undefined);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
goOffline() {
|
|
238
|
+
this.live = false;
|
|
239
|
+
if (this.stream) {
|
|
240
|
+
this.retire(this.stream);
|
|
241
|
+
this.stream = null;
|
|
242
|
+
}
|
|
243
|
+
this.clearTimer('watchdogTimer');
|
|
244
|
+
this.clearTimer('renewalTimer');
|
|
245
|
+
this.host.setOnline(false);
|
|
246
|
+
}
|
|
247
|
+
/** An unplanned break of the active stream: one immediate reconnect, still LIVE meanwhile. */
|
|
248
|
+
onBreak(stream) {
|
|
249
|
+
if (stream !== this.stream || !this.running || this.paused)
|
|
250
|
+
return;
|
|
251
|
+
this.retire(stream);
|
|
252
|
+
this.stream = null;
|
|
253
|
+
this.clearTimer('watchdogTimer');
|
|
254
|
+
void this.reconnect();
|
|
255
|
+
}
|
|
256
|
+
// ─── Token ────────────────────────────────────────────────────────────────
|
|
257
|
+
async validToken() {
|
|
258
|
+
if (this.token && this.tokenExpiresAt > Date.now()) {
|
|
259
|
+
return this.token;
|
|
260
|
+
}
|
|
261
|
+
if (this.credentialRefused) {
|
|
262
|
+
throw new Error('The API key was refused; waiting for the next renewal attempt');
|
|
263
|
+
}
|
|
264
|
+
return this.exchange();
|
|
265
|
+
}
|
|
266
|
+
exchange() {
|
|
267
|
+
if (!this.exchangeInFlight) {
|
|
268
|
+
const exchange = (async () => {
|
|
269
|
+
try {
|
|
270
|
+
const issuedAt = Date.now();
|
|
271
|
+
const auth = await this.host.transport.exchangeM2MToken(this.host.apiKey);
|
|
272
|
+
const expectations = (0, token_verifier_1.expectationsFromMachineToken)(auth.access_token);
|
|
273
|
+
if (!expectations) {
|
|
274
|
+
throw new Error('The service token names no application (pid)');
|
|
275
|
+
}
|
|
276
|
+
this.credentialRefused = false;
|
|
277
|
+
this.token = auth.access_token;
|
|
278
|
+
this.projectId = expectations.projectId;
|
|
279
|
+
const lifetimeMs = auth.expires_in * 1000;
|
|
280
|
+
this.tokenExpiresAt = Math.min(issuedAt + lifetimeMs, (0, token_verifier_1.expiryOfToken)(auth.access_token) ?? Infinity);
|
|
281
|
+
this.host.onToken(auth.access_token);
|
|
282
|
+
this.scheduleRenewal(lifetimeMs * RENEW_AT);
|
|
283
|
+
return auth.access_token;
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if ((0, http_transport_1.isHttpStatus)(error, 401, 403)) {
|
|
287
|
+
this.credentialRefused = true;
|
|
288
|
+
this.scheduleRenewal(REFUSED_RETRY_MS);
|
|
289
|
+
}
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
})();
|
|
293
|
+
this.exchangeInFlight = exchange;
|
|
294
|
+
void exchange.then(() => this.clearExchange(exchange), () => this.clearExchange(exchange));
|
|
295
|
+
}
|
|
296
|
+
return this.exchangeInFlight;
|
|
297
|
+
}
|
|
298
|
+
clearExchange(exchange) {
|
|
299
|
+
if (this.exchangeInFlight === exchange)
|
|
300
|
+
this.exchangeInFlight = null;
|
|
301
|
+
}
|
|
302
|
+
async renewAfterUnauthorized(staleToken) {
|
|
303
|
+
if (this.token && this.token !== staleToken) {
|
|
304
|
+
return this.token;
|
|
305
|
+
}
|
|
306
|
+
if (this.credentialRefused && !this.exchangeInFlight) {
|
|
307
|
+
throw new Error('The API key was refused');
|
|
308
|
+
}
|
|
309
|
+
const token = await this.exchange();
|
|
310
|
+
this.afterRenewal(token);
|
|
311
|
+
return token;
|
|
312
|
+
}
|
|
313
|
+
scheduleRenewal(delayMs) {
|
|
314
|
+
this.renewDueAt = Date.now() + delayMs;
|
|
315
|
+
if (!this.running || this.paused)
|
|
316
|
+
return;
|
|
317
|
+
this.clearTimer('renewTimer');
|
|
318
|
+
this.renewTimer = unrefTimer(() => {
|
|
319
|
+
this.renewTimer = null;
|
|
320
|
+
void this.renew();
|
|
321
|
+
}, delayMs);
|
|
322
|
+
}
|
|
323
|
+
async renew() {
|
|
324
|
+
if (!this.running || this.paused)
|
|
325
|
+
return;
|
|
326
|
+
try {
|
|
327
|
+
const token = await this.exchange();
|
|
328
|
+
this.renewalBackoffStep = 0;
|
|
329
|
+
this.afterRenewal(token);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
// A refusal already scheduled the 30-second retry.
|
|
333
|
+
if ((0, http_transport_1.isHttpStatus)(error, 401, 403))
|
|
334
|
+
return;
|
|
335
|
+
const delay = backoffDelay(this.renewalBackoffStep++);
|
|
336
|
+
// Past the old token's expiry the reconnection exchanges a new one itself.
|
|
337
|
+
if (Date.now() + delay < this.tokenExpiresAt) {
|
|
338
|
+
this.scheduleRenewal(delay);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** A new token: rotate a live stream onto it, or reconnect if the client is not live. */
|
|
343
|
+
afterRenewal(token) {
|
|
344
|
+
if (!this.running || this.paused)
|
|
345
|
+
return;
|
|
346
|
+
if (this.live && this.stream) {
|
|
347
|
+
void this.rotate(token);
|
|
348
|
+
}
|
|
349
|
+
else if (!this.live) {
|
|
350
|
+
void this.reconnect();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Planned rotation: the new stream must be ready before the old one is closed; no pull. */
|
|
354
|
+
async rotate(token) {
|
|
355
|
+
if (this.rotating)
|
|
356
|
+
return;
|
|
357
|
+
this.rotating = true;
|
|
358
|
+
const generation = this.generation;
|
|
359
|
+
try {
|
|
360
|
+
const fresh = await this.openStream(token);
|
|
361
|
+
if (generation !== this.generation || !this.live || this.token !== token || fresh.closed) {
|
|
362
|
+
this.retire(fresh);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const old = this.stream;
|
|
366
|
+
this.adopt(fresh);
|
|
367
|
+
if (old)
|
|
368
|
+
this.retire(old);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// Keep the old stream; the instance ends it at the old token's expiry, which reconnects.
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
this.rotating = false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// ─── Stream ───────────────────────────────────────────────────────────────
|
|
378
|
+
/** Opens a stream and resolves once its `ready` frame arrived (bounded by the connect timeout). */
|
|
379
|
+
openStream(token) {
|
|
380
|
+
const stream = {
|
|
381
|
+
controller: new AbortController(),
|
|
382
|
+
heartbeatTimeoutMs: 0,
|
|
383
|
+
lastFrameAt: 0,
|
|
384
|
+
ready: false,
|
|
385
|
+
retired: false,
|
|
386
|
+
closed: false,
|
|
387
|
+
};
|
|
388
|
+
this.streams.add(stream);
|
|
389
|
+
return new Promise((resolve, reject) => {
|
|
390
|
+
let settled = false;
|
|
391
|
+
const fail = (error) => {
|
|
392
|
+
if (settled)
|
|
393
|
+
return;
|
|
394
|
+
settled = true;
|
|
395
|
+
clearTimeout(timer);
|
|
396
|
+
this.retire(stream);
|
|
397
|
+
reject(error);
|
|
398
|
+
};
|
|
399
|
+
const timer = unrefTimer(() => fail(new Error(`No ready frame within ${this.host.connectTimeoutMs}ms`)), this.host.connectTimeoutMs);
|
|
400
|
+
const onFrame = (event, data) => {
|
|
401
|
+
if (stream.retired)
|
|
402
|
+
return;
|
|
403
|
+
stream.lastFrameAt = Date.now();
|
|
404
|
+
if (stream.ready) {
|
|
405
|
+
this.onFrame(event, data);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (event !== 'ready')
|
|
409
|
+
return;
|
|
410
|
+
const ready = (data ?? {});
|
|
411
|
+
if (typeof ready.project_id !== 'string' || ready.project_id !== this.projectId) {
|
|
412
|
+
fail(new Error('The event stream belongs to another application'));
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const interval = typeof ready.heartbeat_interval_s === 'number' && ready.heartbeat_interval_s > 0
|
|
416
|
+
? ready.heartbeat_interval_s
|
|
417
|
+
: 15;
|
|
418
|
+
stream.heartbeatTimeoutMs = interval * 1000 * HEARTBEAT_TIMEOUT_FACTOR;
|
|
419
|
+
stream.ready = true;
|
|
420
|
+
settled = true;
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
resolve(stream);
|
|
423
|
+
};
|
|
424
|
+
const ended = (error) => {
|
|
425
|
+
stream.closed = true;
|
|
426
|
+
if (!settled) {
|
|
427
|
+
fail(error instanceof Error ? error : new Error('The event stream ended before ready'));
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
this.streams.delete(stream);
|
|
431
|
+
this.onBreak(stream);
|
|
432
|
+
};
|
|
433
|
+
this.host.transport
|
|
434
|
+
.openEventStream(token, stream.controller.signal)
|
|
435
|
+
.then((response) => (0, event_stream_1.readEventStream)(response.body, (frame) => {
|
|
436
|
+
let data;
|
|
437
|
+
try {
|
|
438
|
+
data = JSON.parse(frame.data);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
// Not JSON: only an unknown event could carry it, and unknown events are ignored.
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
onFrame(frame.event, data);
|
|
445
|
+
}))
|
|
446
|
+
.then(() => ended(), (error) => ended(error));
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
adopt(stream) {
|
|
450
|
+
this.stream = stream;
|
|
451
|
+
this.armWatchdog();
|
|
452
|
+
}
|
|
453
|
+
retire(stream) {
|
|
454
|
+
stream.retired = true;
|
|
455
|
+
this.streams.delete(stream);
|
|
456
|
+
stream.controller.abort();
|
|
457
|
+
}
|
|
458
|
+
armWatchdog() {
|
|
459
|
+
this.clearTimer('watchdogTimer');
|
|
460
|
+
const stream = this.stream;
|
|
461
|
+
if (!stream || !stream.ready)
|
|
462
|
+
return;
|
|
463
|
+
const due = stream.lastFrameAt + stream.heartbeatTimeoutMs - Date.now();
|
|
464
|
+
this.watchdogTimer = unrefTimer(() => {
|
|
465
|
+
this.watchdogTimer = null;
|
|
466
|
+
if (stream !== this.stream)
|
|
467
|
+
return;
|
|
468
|
+
if (Date.now() - stream.lastFrameAt >= stream.heartbeatTimeoutMs) {
|
|
469
|
+
this.onBreak(stream);
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
this.armWatchdog();
|
|
473
|
+
}
|
|
474
|
+
}, due);
|
|
475
|
+
}
|
|
476
|
+
// ─── Push consumption ─────────────────────────────────────────────────────
|
|
477
|
+
onFrame(event, data) {
|
|
478
|
+
if (event === 'snapshot.changed') {
|
|
479
|
+
const changed = (data ?? {});
|
|
480
|
+
if (typeof changed.organization_id === 'string' && typeof changed.revision === 'number') {
|
|
481
|
+
this.onChanged(changed.organization_id, changed.revision);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
else if (event === 'snapshot.resync') {
|
|
485
|
+
void this.pull().catch(() => undefined);
|
|
486
|
+
}
|
|
487
|
+
// `heartbeat` only refreshes lastFrameAt; unknown events are ignored.
|
|
488
|
+
}
|
|
489
|
+
onChanged(organizationId, revision) {
|
|
490
|
+
const known = this.host.mirror.getOrganizationRevision(organizationId) ?? 0;
|
|
491
|
+
if (known >= revision)
|
|
492
|
+
return;
|
|
493
|
+
this.refillTargets.set(organizationId, Math.max(this.refillTargets.get(organizationId) ?? 0, revision));
|
|
494
|
+
if (this.refillsInFlight.has(organizationId))
|
|
495
|
+
return;
|
|
496
|
+
void this.refill(organizationId);
|
|
497
|
+
}
|
|
498
|
+
/** Refills one organization until the mirror reaches the highest announced revision. */
|
|
499
|
+
async refill(organizationId) {
|
|
500
|
+
this.refillsInFlight.add(organizationId);
|
|
501
|
+
try {
|
|
502
|
+
for (;;) {
|
|
503
|
+
const { envelope, outcome } = await this.pullOrganization(organizationId);
|
|
504
|
+
if (!this.running)
|
|
505
|
+
return;
|
|
506
|
+
if (!envelope.organizations || !(organizationId in envelope.organizations)) {
|
|
507
|
+
this.refillTargets.delete(organizationId);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
// An answer the mirror did not take (stale or unsupported) is not asked again at once; the target
|
|
511
|
+
// stays for the next notification or full pull.
|
|
512
|
+
if (outcome === 'ignored')
|
|
513
|
+
return;
|
|
514
|
+
const target = this.refillTargets.get(organizationId) ?? 0;
|
|
515
|
+
if ((this.host.mirror.getOrganizationRevision(organizationId) ?? 0) >= target) {
|
|
516
|
+
this.refillTargets.delete(organizationId);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
// The state does not change; the target stays for the next notification or full pull.
|
|
523
|
+
}
|
|
524
|
+
finally {
|
|
525
|
+
this.refillsInFlight.delete(organizationId);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* One pull of one organization, shared by every concurrent caller, merged into the mirror (or removing the
|
|
530
|
+
* organization when the answer no longer holds it). A pull that finishes after `stop()` or `pause()` leaves
|
|
531
|
+
* the mirror as it is.
|
|
532
|
+
*/
|
|
533
|
+
pullOrganization(organizationId) {
|
|
534
|
+
const inFlight = this.organizationPulls.get(organizationId);
|
|
535
|
+
if (inFlight)
|
|
536
|
+
return inFlight;
|
|
537
|
+
const generation = this.generation;
|
|
538
|
+
const pull = (async () => {
|
|
539
|
+
const heldBefore = this.host.mirror.getOrganization(organizationId);
|
|
540
|
+
const envelope = await this.authorized((token) => this.host.transport.fetchSnapshot(token, organizationId));
|
|
541
|
+
const usable = generation === this.generation &&
|
|
542
|
+
typeof envelope?.schema_version === 'number' &&
|
|
543
|
+
envelope.schema_version <= entitlement_mirror_1.MAX_SUPPORTED_SCHEMA_VERSION;
|
|
544
|
+
if (!usable)
|
|
545
|
+
return { envelope, outcome: 'ignored' };
|
|
546
|
+
const answered = envelope.organizations?.[organizationId];
|
|
547
|
+
if (answered) {
|
|
548
|
+
this.host.mirror.mergeSnapshot(envelope);
|
|
549
|
+
const held = this.host.mirror.getOrganization(organizationId);
|
|
550
|
+
return held === answered ? { envelope, outcome: 'applied', entry: held } : { envelope, outcome: 'ignored' };
|
|
551
|
+
}
|
|
552
|
+
// Drop the organization only if the mirror still holds what it held when the pull started: a newer entry
|
|
553
|
+
// that arrived meanwhile is not removed by an older answer.
|
|
554
|
+
if (this.host.mirror.getOrganization(organizationId) !== heldBefore)
|
|
555
|
+
return { envelope, outcome: 'ignored' };
|
|
556
|
+
this.host.mirror.removeOrganization(organizationId);
|
|
557
|
+
return { envelope, outcome: 'removed' };
|
|
558
|
+
})();
|
|
559
|
+
this.organizationPulls.set(organizationId, pull);
|
|
560
|
+
const clear = () => {
|
|
561
|
+
if (this.organizationPulls.get(organizationId) === pull)
|
|
562
|
+
this.organizationPulls.delete(organizationId);
|
|
563
|
+
};
|
|
564
|
+
void pull.then(clear, clear);
|
|
565
|
+
return pull;
|
|
566
|
+
}
|
|
567
|
+
// ─── Renewal of entries with a validity ───────────────────────────────────
|
|
568
|
+
/** Re-arms the renewal timer for the earliest entry due; nothing while offline. Called on every mirror change. */
|
|
569
|
+
scheduleRenewals() {
|
|
570
|
+
this.clearTimer('renewalTimer');
|
|
571
|
+
if (!this.running || this.paused || !this.live)
|
|
572
|
+
return;
|
|
573
|
+
let next = Number.POSITIVE_INFINITY;
|
|
574
|
+
for (const organizationId of this.host.mirror.organizationIds()) {
|
|
575
|
+
const due = this.renewalDue(organizationId);
|
|
576
|
+
if (due !== undefined)
|
|
577
|
+
next = Math.min(next, due);
|
|
578
|
+
}
|
|
579
|
+
if (next === Number.POSITIVE_INFINITY)
|
|
580
|
+
return;
|
|
581
|
+
this.renewalTimer = unrefTimer(() => {
|
|
582
|
+
this.renewalTimer = null;
|
|
583
|
+
this.renewDueEntries();
|
|
584
|
+
}, next - Date.now());
|
|
585
|
+
}
|
|
586
|
+
/** Halfway between receiving an entry and its `valid_until`; undefined for an entry without a usable one. */
|
|
587
|
+
renewalDue(organizationId) {
|
|
588
|
+
const entry = this.host.mirror.getOrganization(organizationId);
|
|
589
|
+
const receivedAt = this.host.mirror.getReceivedAt(organizationId);
|
|
590
|
+
if (!entry || receivedAt === undefined)
|
|
591
|
+
return undefined;
|
|
592
|
+
const validUntil = (0, degradation_1.validUntilOf)(entry);
|
|
593
|
+
if (validUntil === undefined || validUntil <= receivedAt)
|
|
594
|
+
return undefined;
|
|
595
|
+
const due = Math.max(receivedAt + (validUntil - receivedAt) / 2, receivedAt + MIN_RENEWAL_SPACING_MS);
|
|
596
|
+
return Math.max(due, this.renewalRetryAt.get(organizationId) ?? 0);
|
|
597
|
+
}
|
|
598
|
+
renewDueEntries() {
|
|
599
|
+
if (!this.running || this.paused || !this.live)
|
|
600
|
+
return;
|
|
601
|
+
const now = Date.now();
|
|
602
|
+
for (const organizationId of this.host.mirror.organizationIds()) {
|
|
603
|
+
const due = this.renewalDue(organizationId);
|
|
604
|
+
if (due === undefined || due > now)
|
|
605
|
+
continue;
|
|
606
|
+
this.renewalRetryAt.set(organizationId, now + RENEWAL_RETRY_MS);
|
|
607
|
+
// The retry time set above stays unless the renewal was taken: a stale or unsupported answer does not
|
|
608
|
+
// move the entry's receipt time, and renewing it again at once would pull without end.
|
|
609
|
+
void this.pullOrganization(organizationId).then(({ outcome }) => {
|
|
610
|
+
if (outcome !== 'ignored')
|
|
611
|
+
this.renewalRetryAt.delete(organizationId);
|
|
612
|
+
this.scheduleRenewals();
|
|
613
|
+
}, () => this.scheduleRenewals());
|
|
614
|
+
}
|
|
615
|
+
this.scheduleRenewals();
|
|
616
|
+
}
|
|
617
|
+
/** Full pull: replaces the whole mirror. */
|
|
618
|
+
async pull() {
|
|
619
|
+
const snapshot = await this.authorized((token) => this.host.transport.fetchSnapshot(token));
|
|
620
|
+
this.host.mirror.loadSnapshot(snapshot);
|
|
621
|
+
for (const [organizationId, target] of this.refillTargets) {
|
|
622
|
+
if ((this.host.mirror.getOrganizationRevision(organizationId) ?? 0) >= target) {
|
|
623
|
+
this.refillTargets.delete(organizationId);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// ─── Housekeeping ─────────────────────────────────────────────────────────
|
|
628
|
+
/** Drops the stream, every timer and any attempt in flight. */
|
|
629
|
+
halt() {
|
|
630
|
+
this.generation++;
|
|
631
|
+
this.live = false;
|
|
632
|
+
this.connecting = null;
|
|
633
|
+
this.stream = null;
|
|
634
|
+
for (const stream of [...this.streams]) {
|
|
635
|
+
this.retire(stream);
|
|
636
|
+
}
|
|
637
|
+
this.clearTimer('renewTimer');
|
|
638
|
+
this.clearTimer('reconnectTimer');
|
|
639
|
+
this.clearTimer('watchdogTimer');
|
|
640
|
+
this.clearTimer('renewalTimer');
|
|
641
|
+
}
|
|
642
|
+
clearTimer(name) {
|
|
643
|
+
const timer = this[name];
|
|
644
|
+
if (timer)
|
|
645
|
+
clearTimeout(timer);
|
|
646
|
+
this[name] = null;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
exports.ConnectionSupervisor = ConnectionSupervisor;
|
|
650
|
+
//# sourceMappingURL=connection-supervisor.js.map
|