@spine-event-engine/server 2.0.0-snapshot.16 → 2.0.0-snapshot.20
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 +17 -3
- package/REFERENCE.md +66 -12
- package/dist/context/bounded-context.d.ts +72 -14
- package/dist/context/bounded-context.d.ts.map +1 -1
- package/dist/context/bounded-context.js +130 -49
- package/dist/context/bounded-context.js.map +1 -1
- package/dist/context/effective-tenant.d.ts +9 -0
- package/dist/context/effective-tenant.d.ts.map +1 -0
- package/dist/context/effective-tenant.js +58 -0
- package/dist/context/effective-tenant.js.map +1 -0
- package/dist/context/tenant-index.d.ts.map +1 -1
- package/dist/context/tenant-index.js +70 -1
- package/dist/context/tenant-index.js.map +1 -1
- package/dist/delivery/delivery.d.ts.map +1 -1
- package/dist/delivery/delivery.js +270 -26
- package/dist/delivery/delivery.js.map +1 -1
- package/dist/delivery/inbox-storage.d.ts +7 -0
- package/dist/delivery/inbox-storage.d.ts.map +1 -1
- package/dist/delivery/inbox-storage.js +110 -0
- package/dist/delivery/inbox-storage.js.map +1 -1
- package/dist/entity/entity-metadata.d.ts +2 -2
- package/dist/entity/entity-metadata.js +2 -2
- package/dist/entity/entity-metadata.js.map +1 -1
- package/dist/entity/entity-storage-descriptor.d.ts +24 -4
- package/dist/entity/entity-storage-descriptor.d.ts.map +1 -1
- package/dist/entity/entity-storage-descriptor.js +69 -23
- package/dist/entity/entity-storage-descriptor.js.map +1 -1
- package/dist/entity/entity.d.ts +19 -18
- package/dist/entity/entity.d.ts.map +1 -1
- package/dist/entity/entity.js +61 -24
- package/dist/entity/entity.js.map +1 -1
- package/dist/entity/spec-scanner.d.ts +13 -1
- package/dist/entity/spec-scanner.d.ts.map +1 -1
- package/dist/entity/spec-scanner.js +20 -3
- package/dist/entity/spec-scanner.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/repository/command-routing.d.ts +63 -10
- package/dist/repository/command-routing.d.ts.map +1 -1
- package/dist/repository/command-routing.js +41 -8
- package/dist/repository/command-routing.js.map +1 -1
- package/dist/repository/event-routing.d.ts +64 -9
- package/dist/repository/event-routing.d.ts.map +1 -1
- package/dist/repository/event-routing.js +42 -7
- package/dist/repository/event-routing.js.map +1 -1
- package/dist/repository/repository.d.ts +96 -10
- package/dist/repository/repository.d.ts.map +1 -1
- package/dist/repository/repository.js +437 -133
- package/dist/repository/repository.js.map +1 -1
- package/dist/repository/state-update-routing.d.ts +67 -8
- package/dist/repository/state-update-routing.d.ts.map +1 -1
- package/dist/repository/state-update-routing.js +45 -6
- package/dist/repository/state-update-routing.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +407 -109
- package/dist/server/server.js.map +1 -1
- package/dist/services/registered-targets.d.ts +73 -0
- package/dist/services/registered-targets.d.ts.map +1 -0
- package/dist/services/registered-targets.js +127 -0
- package/dist/services/registered-targets.js.map +1 -0
- package/dist/services/spine-services.d.ts +26 -12
- package/dist/services/spine-services.d.ts.map +1 -1
- package/dist/services/spine-services.js +1044 -962
- package/dist/services/spine-services.js.map +1 -1
- package/dist/stand/stand.d.ts +73 -1
- package/dist/stand/stand.d.ts.map +1 -1
- package/dist/stand/stand.js +220 -48
- package/dist/stand/stand.js.map +1 -1
- package/dist/stand/subscription-observer.d.ts.map +1 -1
- package/dist/stand/subscription-observer.js +4 -11
- package/dist/stand/subscription-observer.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +12 -12
package/dist/server/server.js
CHANGED
|
@@ -15,6 +15,7 @@ import * as http2 from "node:http2";
|
|
|
15
15
|
import { connectNodeAdapter } from "@connectrpc/connect-node";
|
|
16
16
|
import { BoundedContext, BoundedContextBuilder, boundedContextAccess, } from "../context/bounded-context.js";
|
|
17
17
|
import { SpineServices, spineServicesAccess, } from "../services/spine-services.js";
|
|
18
|
+
import { RegisteredTargets } from "../services/registered-targets.js";
|
|
18
19
|
import { ProcessServerCoordinator } from "./process-server-coordinator.js";
|
|
19
20
|
import { CloseErrors, RetryableCloseGroup } from "./retryable-close.js";
|
|
20
21
|
import { ServerEnvironment, serverEnvironmentAccess } from "./server-environment.js";
|
|
@@ -25,6 +26,7 @@ const maximumMessageMaxBytes = 0xffff_ffff;
|
|
|
25
26
|
const gracefulSessionDrainMs = 100;
|
|
26
27
|
const runningContexts = new WeakMap();
|
|
27
28
|
const serverHosts = new WeakMap();
|
|
29
|
+
const assemblingStands = new WeakMap();
|
|
28
30
|
/**
|
|
29
31
|
* Configures and starts local Spine Connect/gRPC-compatible services.
|
|
30
32
|
*
|
|
@@ -191,75 +193,236 @@ export class Server {
|
|
|
191
193
|
});
|
|
192
194
|
return running;
|
|
193
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Builds contexts, attaches delivery, and opens the listener for one start attempt.
|
|
198
|
+
*
|
|
199
|
+
* @param ownership Whether the environment is managed by this Server.
|
|
200
|
+
* @returns The running listener after lifecycle startup.
|
|
201
|
+
*/
|
|
194
202
|
async #startOnce(ownership) {
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
203
|
+
const admission = { token: {}, contexts: [] };
|
|
204
|
+
const contexts = await this.#prepareContexts(admission);
|
|
205
|
+
const { attachment, routes } = await this.#attachContexts(contexts, ownership, admission);
|
|
206
|
+
const running = await this.#openListener(contexts, attachment, routes);
|
|
207
|
+
await this.#startLifecycles(running);
|
|
208
|
+
return running;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Opens the service listener with retryable cleanup for every preparation phase.
|
|
212
|
+
*
|
|
213
|
+
* @param contexts Contexts exposed by the listener.
|
|
214
|
+
* @param attachment Active environment delivery attachment.
|
|
215
|
+
* @param routes Routes installed for these contexts.
|
|
216
|
+
* @returns The prepared running listener.
|
|
217
|
+
*/
|
|
218
|
+
async #openListener(contexts, attachment, routes) {
|
|
219
|
+
const closeables = [...contexts, ...this.#resources];
|
|
220
|
+
const cleanup = {
|
|
221
|
+
closeGroup: new RetryableCloseGroup(closeables, "Server start cleanup failed while closing owned contexts/resources."),
|
|
222
|
+
attachment,
|
|
223
|
+
routes,
|
|
224
|
+
failedStartRollback: undefined,
|
|
225
|
+
};
|
|
202
226
|
try {
|
|
203
|
-
|
|
204
|
-
ownership,
|
|
205
|
-
descriptors: contexts.map((context) => boundedContextAccess.delivery(context)),
|
|
206
|
-
});
|
|
227
|
+
return await this.#prepareListener(contexts, attachment, routes, closeables, cleanup);
|
|
207
228
|
}
|
|
208
229
|
catch (error) {
|
|
209
|
-
const closeGroup = new RetryableCloseGroup([...contexts, ...this.#resources], "Server start cleanup failed while closing owned contexts/resources.");
|
|
210
|
-
if (serverEnvironmentAccess.failedStartRetryPending(this.#environment, error)) {
|
|
211
|
-
this.#failedStartCleanup = {
|
|
212
|
-
closeGroup,
|
|
213
|
-
failedStartRollback: { rejection: error },
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
else {
|
|
217
|
-
try {
|
|
218
|
-
await closeGroup.close();
|
|
219
|
-
}
|
|
220
|
-
catch (cleanupError) {
|
|
221
|
-
this.#failedStartCleanup = {
|
|
222
|
-
closeGroup,
|
|
223
|
-
failedStartRollback: undefined,
|
|
224
|
-
};
|
|
225
|
-
throw ServerValues.attachmentCleanupError(error, cleanupError);
|
|
226
|
-
}
|
|
227
|
-
this.#failedStartConsumed = true;
|
|
228
|
-
}
|
|
229
|
-
throw error;
|
|
230
|
-
}
|
|
231
|
-
const closeables = [...contexts, ...this.#resources];
|
|
232
|
-
const services = new SpineServices({
|
|
233
|
-
contexts,
|
|
234
|
-
...this.#services,
|
|
235
|
-
});
|
|
236
|
-
spineServicesAccess.installLogger(services, logger);
|
|
237
|
-
const sessions = new Set();
|
|
238
|
-
const httpServer = ServerValues.createHttpServer(services, sessions, this.#readMaxBytes, this.#writeMaxBytes);
|
|
239
|
-
const listener = { host: this.#host, port: this.#port };
|
|
240
|
-
const address = await ServerValues.listen(httpServer, listener.host, listener.port).catch(async (error) => {
|
|
241
|
-
const cleanup = {
|
|
242
|
-
closeGroup: new RetryableCloseGroup(closeables, "Server start cleanup failed while closing owned contexts/resources."),
|
|
243
|
-
network: { server: httpServer, sessions },
|
|
244
|
-
attachment,
|
|
245
|
-
failedStartRollback: undefined,
|
|
246
|
-
};
|
|
247
230
|
this.#failedStartCleanup = cleanup;
|
|
248
231
|
return this.#cleanupFailedListenerStart(cleanup, error);
|
|
249
|
-
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Constructs services and the HTTP adapter, then binds the listener.
|
|
236
|
+
*
|
|
237
|
+
* @param contexts Contexts exposed by this Server.
|
|
238
|
+
* @param attachment Active delivery attachment.
|
|
239
|
+
* @param routes Installed state routes.
|
|
240
|
+
* @param closeables Contexts and resources for eventual shutdown.
|
|
241
|
+
* @param cleanup Retained cleanup state if preparation fails.
|
|
242
|
+
* @returns The prepared running listener.
|
|
243
|
+
*/
|
|
244
|
+
async #prepareListener(contexts, attachment, routes, closeables, cleanup) {
|
|
245
|
+
const services = new SpineServices({ contexts, ...this.#services });
|
|
246
|
+
cleanup.services = services;
|
|
247
|
+
spineServicesAccess.installLogger(services, serverEnvironmentAccess.loggerFor(this.#environment));
|
|
248
|
+
const { httpServer, sessions } = this.#createHttpListener(services);
|
|
249
|
+
cleanup.network = { server: httpServer, sessions };
|
|
250
|
+
const address = await ServerValues.listen(httpServer, this.#host, this.#port);
|
|
250
251
|
const host = typeof address.address === "string" ? address.address : this.#host;
|
|
251
|
-
|
|
252
|
+
return this.#createRunningServer({
|
|
252
253
|
server: httpServer,
|
|
253
254
|
sessions,
|
|
254
255
|
environment: this.#environment,
|
|
255
256
|
attachment,
|
|
257
|
+
routes,
|
|
256
258
|
services,
|
|
257
259
|
host,
|
|
258
260
|
port: address.port,
|
|
261
|
+
contexts,
|
|
259
262
|
closeables,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Prepares an HTTP listener and its tracked sessions for startup.
|
|
267
|
+
*
|
|
268
|
+
* @param services Service routes exposed by the listener.
|
|
269
|
+
* @returns The unbound listener and its session set.
|
|
270
|
+
*/
|
|
271
|
+
#createHttpListener(services) {
|
|
272
|
+
const sessions = new Set();
|
|
273
|
+
const httpServer = ServerValues.createHttpServer(services, sessions, this.#readMaxBytes, this.#writeMaxBytes);
|
|
274
|
+
return { httpServer, sessions };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Creates the listener handle and associates its context set.
|
|
278
|
+
*
|
|
279
|
+
* @param options Listener resources prepared during startup.
|
|
280
|
+
* @returns The not-yet-started listener handle.
|
|
281
|
+
*/
|
|
282
|
+
#createRunningServer(options) {
|
|
283
|
+
const running = new RunningHttp2Server({
|
|
284
|
+
...options,
|
|
260
285
|
listenerLifecycles: this.#listenerLifecycles,
|
|
261
286
|
});
|
|
262
|
-
runningContexts.set(running, contexts);
|
|
287
|
+
runningContexts.set(running, options.contexts);
|
|
288
|
+
return running;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Reserves prebuilt contexts before awaiting builders, then assembles each builder.
|
|
292
|
+
*
|
|
293
|
+
* @param admission Attempt-local reservations retained through cleanup.
|
|
294
|
+
* @returns Prepared contexts for this Server.
|
|
295
|
+
*/
|
|
296
|
+
async #prepareContexts(admission) {
|
|
297
|
+
const contexts = [];
|
|
298
|
+
let conflict;
|
|
299
|
+
let conflicted = false;
|
|
300
|
+
for (const entry of this.#contexts) {
|
|
301
|
+
if (boundedContextAccess.isBuilder(entry))
|
|
302
|
+
continue;
|
|
303
|
+
try {
|
|
304
|
+
this.#admitContext(entry, admission);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (!conflicted)
|
|
308
|
+
conflict = error;
|
|
309
|
+
conflicted = true;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
if (conflicted)
|
|
314
|
+
throw conflict;
|
|
315
|
+
for (const entry of this.#contexts) {
|
|
316
|
+
const context = boundedContextAccess.isBuilder(entry)
|
|
317
|
+
? await boundedContextAccess.build(entry, this.#environment.storageFactory)
|
|
318
|
+
: entry;
|
|
319
|
+
if (boundedContextAccess.isBuilder(entry))
|
|
320
|
+
this.#admitContext(context, admission);
|
|
321
|
+
contexts.push(context);
|
|
322
|
+
}
|
|
323
|
+
return contexts;
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
await this.#cleanupFailedAttachment(admission.contexts, undefined, error, admission);
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Reserves one context Stand while this Server assembles its routes.
|
|
332
|
+
*
|
|
333
|
+
* @param context Context to reserve.
|
|
334
|
+
* @param admission Attempt-local reservations and token.
|
|
335
|
+
*/
|
|
336
|
+
#admitContext(context, admission) {
|
|
337
|
+
const stand = context.stand();
|
|
338
|
+
const token = assemblingStands.get(stand);
|
|
339
|
+
if (token !== undefined || RegisteredTargets.forStand(stand) !== undefined) {
|
|
340
|
+
throw new Error("Stand is already associated with another Server or assembly.");
|
|
341
|
+
}
|
|
342
|
+
assemblingStands.set(stand, admission.token);
|
|
343
|
+
admission.contexts.push(context);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Clears only reservations made by the given assembly attempt.
|
|
347
|
+
*
|
|
348
|
+
* @param admission Attempt whose reserved Stands may be released.
|
|
349
|
+
*/
|
|
350
|
+
#releaseAdmission(admission) {
|
|
351
|
+
for (const context of admission.contexts) {
|
|
352
|
+
const stand = context.stand();
|
|
353
|
+
if (assemblingStands.get(stand) === admission.token)
|
|
354
|
+
assemblingStands.delete(stand);
|
|
355
|
+
}
|
|
356
|
+
admission.contexts.length = 0;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Validates state routes before delivery recovery can invoke handlers.
|
|
360
|
+
*
|
|
361
|
+
* @param contexts Built contexts for this Server.
|
|
362
|
+
* @param ownership Environment attachment mode.
|
|
363
|
+
* @param admission Reservations transferred to installed routes before attachment.
|
|
364
|
+
* @returns The environment attachment and its installed routes.
|
|
365
|
+
*/
|
|
366
|
+
async #attachContexts(contexts, ownership, admission) {
|
|
367
|
+
const freshContexts = contexts.filter((context) => RegisteredTargets.forStand(context.stand()) === undefined);
|
|
368
|
+
let routes;
|
|
369
|
+
try {
|
|
370
|
+
routes = new RegisteredTargets(contexts);
|
|
371
|
+
routes.install(contexts);
|
|
372
|
+
this.#releaseAdmission(admission);
|
|
373
|
+
const logger = serverEnvironmentAccess.loggerFor(this.#environment);
|
|
374
|
+
for (const context of contexts) {
|
|
375
|
+
boundedContextAccess.installLogger(context, logger);
|
|
376
|
+
serverEnvironmentAccess.warnVolatileRegistry(this.#environment, context);
|
|
377
|
+
}
|
|
378
|
+
const attachment = await serverEnvironmentAccess.attach(this.#environment, {
|
|
379
|
+
ownership,
|
|
380
|
+
descriptors: contexts.map((context) => boundedContextAccess.delivery(context)),
|
|
381
|
+
});
|
|
382
|
+
return { attachment, routes };
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
await this.#cleanupFailedAttachment(freshContexts, routes, error, admission);
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Retains retryable cleanup when route validation or attachment fails.
|
|
391
|
+
*
|
|
392
|
+
* @param contexts Fresh contexts to close after failure.
|
|
393
|
+
* @param routes Routes installed by this attempt, if any.
|
|
394
|
+
* @param error Original route or attachment failure.
|
|
395
|
+
* @param admission Reservations retained until context cleanup succeeds.
|
|
396
|
+
*/
|
|
397
|
+
async #cleanupFailedAttachment(contexts, routes, error, admission) {
|
|
398
|
+
const closeGroup = new RetryableCloseGroup([...contexts, ...this.#resources], "Server start cleanup failed while closing owned contexts/resources.");
|
|
399
|
+
if (serverEnvironmentAccess.failedStartRetryPending(this.#environment, error)) {
|
|
400
|
+
this.#failedStartCleanup = {
|
|
401
|
+
closeGroup,
|
|
402
|
+
failedStartRollback: { rejection: error },
|
|
403
|
+
routes,
|
|
404
|
+
admission,
|
|
405
|
+
};
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
await closeGroup.close();
|
|
410
|
+
}
|
|
411
|
+
catch (cleanupError) {
|
|
412
|
+
this.#failedStartCleanup = { closeGroup, failedStartRollback: undefined, routes, admission };
|
|
413
|
+
throw ServerValues.attachmentCleanupError(error, cleanupError);
|
|
414
|
+
}
|
|
415
|
+
routes?.release();
|
|
416
|
+
if (admission !== undefined)
|
|
417
|
+
this.#releaseAdmission(admission);
|
|
418
|
+
this.#failedStartConsumed = true;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Starts listener lifecycles and preserves failed close retries.
|
|
422
|
+
*
|
|
423
|
+
* @param running Listener whose lifecycles begin.
|
|
424
|
+
*/
|
|
425
|
+
async #startLifecycles(running) {
|
|
263
426
|
try {
|
|
264
427
|
await running.startLifecycles();
|
|
265
428
|
}
|
|
@@ -270,7 +433,6 @@ export class Server {
|
|
|
270
433
|
this.#failedStartConsumed = true;
|
|
271
434
|
throw error;
|
|
272
435
|
}
|
|
273
|
-
return running;
|
|
274
436
|
}
|
|
275
437
|
async #retryFailedStartCleanup(cleanup) {
|
|
276
438
|
const errors = [];
|
|
@@ -301,6 +463,32 @@ export class Server {
|
|
|
301
463
|
if (!(await this.#advanceFailedStartAttachment(cleanup, errors))) {
|
|
302
464
|
return;
|
|
303
465
|
}
|
|
466
|
+
await this.#retryFailedStartRollback(cleanup, errors);
|
|
467
|
+
let closeFailed = false;
|
|
468
|
+
try {
|
|
469
|
+
await cleanup.closeGroup.close();
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
closeFailed = true;
|
|
473
|
+
CloseErrors.collect(error, errors);
|
|
474
|
+
}
|
|
475
|
+
if (!closeFailed && cleanup.attachment === undefined && this.#failedStartCleanup === cleanup) {
|
|
476
|
+
cleanup.routes?.release();
|
|
477
|
+
if (cleanup.admission !== undefined)
|
|
478
|
+
this.#releaseAdmission(cleanup.admission);
|
|
479
|
+
if (cleanup.services !== undefined)
|
|
480
|
+
spineServicesAccess.clearLogger(cleanup.services);
|
|
481
|
+
this.#failedStartCleanup = undefined;
|
|
482
|
+
this.#failedStartConsumed = true;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Retries environment rollback before closing contexts from a failed start.
|
|
487
|
+
*
|
|
488
|
+
* @param cleanup Retained failed-start state.
|
|
489
|
+
* @param errors Errors collected across cleanup phases.
|
|
490
|
+
*/
|
|
491
|
+
async #retryFailedStartRollback(cleanup, errors) {
|
|
304
492
|
const failedStartRollback = cleanup.failedStartRollback;
|
|
305
493
|
if (failedStartRollback !== undefined &&
|
|
306
494
|
serverEnvironmentAccess.failedStartRetryPending(this.#environment, failedStartRollback.rejection)) {
|
|
@@ -314,18 +502,6 @@ export class Server {
|
|
|
314
502
|
CloseErrors.collect(error, errors);
|
|
315
503
|
}
|
|
316
504
|
}
|
|
317
|
-
let closeFailed = false;
|
|
318
|
-
try {
|
|
319
|
-
await cleanup.closeGroup.close();
|
|
320
|
-
}
|
|
321
|
-
catch (error) {
|
|
322
|
-
closeFailed = true;
|
|
323
|
-
CloseErrors.collect(error, errors);
|
|
324
|
-
}
|
|
325
|
-
if (!closeFailed && cleanup.attachment === undefined && this.#failedStartCleanup === cleanup) {
|
|
326
|
-
this.#failedStartCleanup = undefined;
|
|
327
|
-
this.#failedStartConsumed = true;
|
|
328
|
-
}
|
|
329
505
|
}
|
|
330
506
|
async #closeFailedStartNetwork(cleanup, errors) {
|
|
331
507
|
const network = cleanup.network;
|
|
@@ -409,14 +585,19 @@ export const runningServerAccess = Object.freeze({
|
|
|
409
585
|
return server instanceof RunningHttp2Server ? server.drainDelivery() : Promise.resolve();
|
|
410
586
|
},
|
|
411
587
|
});
|
|
588
|
+
/**
|
|
589
|
+
* Holds listener resources and closes them in recovery-safe phases.
|
|
590
|
+
*/
|
|
412
591
|
class RunningHttp2Server {
|
|
413
592
|
#server;
|
|
414
593
|
#sessions;
|
|
415
594
|
#closeables;
|
|
595
|
+
#contexts;
|
|
416
596
|
#listenerLifecycles;
|
|
417
597
|
#startedLifecycles = [];
|
|
418
598
|
#environment;
|
|
419
599
|
#attachment;
|
|
600
|
+
#routes;
|
|
420
601
|
#services;
|
|
421
602
|
host;
|
|
422
603
|
port;
|
|
@@ -425,19 +606,31 @@ class RunningHttp2Server {
|
|
|
425
606
|
#networkClosed = false;
|
|
426
607
|
#attachmentDetached = false;
|
|
427
608
|
#closeGroup;
|
|
609
|
+
/**
|
|
610
|
+
* Captures the running listener's network, environment, and cleanup resources.
|
|
611
|
+
*
|
|
612
|
+
* @param options Resources prepared by Server startup.
|
|
613
|
+
*/
|
|
428
614
|
constructor(options) {
|
|
429
615
|
this.#server = options.server;
|
|
430
616
|
this.#sessions = options.sessions;
|
|
431
617
|
this.#closeables = options.closeables;
|
|
618
|
+
this.#contexts = options.contexts;
|
|
432
619
|
this.#listenerLifecycles = options.listenerLifecycles;
|
|
433
620
|
this.#environment = options.environment;
|
|
434
621
|
this.#attachment = options.attachment;
|
|
622
|
+
this.#routes = options.routes;
|
|
435
623
|
this.#services = options.services;
|
|
436
624
|
this.host = options.host;
|
|
437
625
|
this.port = options.port;
|
|
438
626
|
this.baseUrl = `http://${ServerValues.formatHostForUrl(options.host)}:${options.port.toString()}`;
|
|
439
627
|
this.#closeGroup = new RetryableCloseGroup(this.#closeables, "Server close failed while closing owned contexts/resources.");
|
|
440
628
|
}
|
|
629
|
+
/**
|
|
630
|
+
* Closes this listener once, preserving a failed close for retry.
|
|
631
|
+
*
|
|
632
|
+
* @returns Completion after resources close.
|
|
633
|
+
*/
|
|
441
634
|
close() {
|
|
442
635
|
this.#closed ??= this.#closeOnce().catch((error) => {
|
|
443
636
|
this.#closed = undefined;
|
|
@@ -467,11 +660,16 @@ class RunningHttp2Server {
|
|
|
467
660
|
throw error;
|
|
468
661
|
}
|
|
469
662
|
}
|
|
663
|
+
/**
|
|
664
|
+
* Checks whether a failed lifecycle start still needs close retry.
|
|
665
|
+
*
|
|
666
|
+
* @returns `true` when no close completion is retained.
|
|
667
|
+
*/
|
|
470
668
|
hasPendingClose() {
|
|
471
669
|
return this.#closed === undefined;
|
|
472
670
|
}
|
|
473
671
|
/**
|
|
474
|
-
*
|
|
672
|
+
* Completes the server's Delivery detachment while network sessions remain available.
|
|
475
673
|
*
|
|
476
674
|
* @returns Completion after the attachment drains.
|
|
477
675
|
*/
|
|
@@ -486,7 +684,35 @@ class RunningHttp2Server {
|
|
|
486
684
|
}
|
|
487
685
|
this.#attachmentDetached = true;
|
|
488
686
|
}
|
|
687
|
+
/**
|
|
688
|
+
* Closes network, delivery, accepted context work, and registered resources.
|
|
689
|
+
*
|
|
690
|
+
* @returns Completion after all resources close.
|
|
691
|
+
*/
|
|
489
692
|
async #closeOnce() {
|
|
693
|
+
await this.#closeListenerAndNetwork();
|
|
694
|
+
const { detachErrors, detachRejected } = await this.#detachDelivery();
|
|
695
|
+
await this.#drainContexts();
|
|
696
|
+
try {
|
|
697
|
+
await this.#closeGroup.close();
|
|
698
|
+
this.#routes.release();
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
if (!detachRejected)
|
|
702
|
+
throw error;
|
|
703
|
+
CloseErrors.collect(error, detachErrors);
|
|
704
|
+
throw new AggregateError(detachErrors, "Server close failed while detaching delivery and closing owned contexts/resources.");
|
|
705
|
+
}
|
|
706
|
+
if (detachRejected)
|
|
707
|
+
ServerValues.throwRunningDetachErrors(detachErrors);
|
|
708
|
+
spineServicesAccess.clearLogger(this.#services);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Closes lifecycle attachments and network intake before delivery detachment.
|
|
712
|
+
*
|
|
713
|
+
* @returns Completion after the listener and sessions close.
|
|
714
|
+
*/
|
|
715
|
+
async #closeListenerAndNetwork() {
|
|
490
716
|
while (this.#startedLifecycles.length > 0) {
|
|
491
717
|
const lifecycle = this.#startedLifecycles.at(-1);
|
|
492
718
|
if (lifecycle === undefined)
|
|
@@ -498,6 +724,13 @@ class RunningHttp2Server {
|
|
|
498
724
|
await ServerValues.closeNetwork(this.#server, this.#sessions);
|
|
499
725
|
this.#networkClosed = true;
|
|
500
726
|
}
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Completes Delivery detachment while retaining errors for safe endpoint cleanup.
|
|
730
|
+
*
|
|
731
|
+
* @returns Detachment errors and whether detachment itself was rejected.
|
|
732
|
+
*/
|
|
733
|
+
async #detachDelivery() {
|
|
501
734
|
const detachErrors = [];
|
|
502
735
|
let detachRejected = false;
|
|
503
736
|
if (!this.#attachmentDetached) {
|
|
@@ -527,42 +760,38 @@ class RunningHttp2Server {
|
|
|
527
760
|
}
|
|
528
761
|
}
|
|
529
762
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
763
|
+
return { detachErrors, detachRejected };
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Waits for every context's accepted work before any Stand closes.
|
|
767
|
+
*
|
|
768
|
+
* @returns Completion after all context work drains.
|
|
769
|
+
*/
|
|
770
|
+
async #drainContexts() {
|
|
771
|
+
for (const context of this.#contexts)
|
|
772
|
+
boundedContextAccess.beginClose(context);
|
|
773
|
+
const drained = await Promise.allSettled(this.#contexts.map((context) => boundedContextAccess.drainWork(context)));
|
|
774
|
+
const drainErrors = drained.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
775
|
+
if (drainErrors.length > 0) {
|
|
776
|
+
throw new AggregateError(drainErrors, "Server close failed while draining contexts.");
|
|
542
777
|
}
|
|
543
|
-
spineServicesAccess.clearLogger(this.#services);
|
|
544
778
|
}
|
|
545
779
|
}
|
|
546
780
|
/**
|
|
781
|
+
* Groups private server assembly, network, and shutdown operations.
|
|
547
782
|
*
|
|
548
|
-
* @internal
|
|
783
|
+
* @internal
|
|
549
784
|
*/
|
|
550
785
|
const ServerValues = Object.freeze({
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}
|
|
561
|
-
catch (error) {
|
|
562
|
-
await ServerValues.cleanupBuiltContexts(contexts, error);
|
|
563
|
-
throw error;
|
|
564
|
-
}
|
|
565
|
-
},
|
|
786
|
+
/**
|
|
787
|
+
* Creates the HTTP/2 listener with Connect routes and session tracking.
|
|
788
|
+
*
|
|
789
|
+
* @param services Public service adapters.
|
|
790
|
+
* @param sessions Set collecting active HTTP/2 sessions.
|
|
791
|
+
* @param readMaxBytes Maximum request size.
|
|
792
|
+
* @param writeMaxBytes Maximum response size.
|
|
793
|
+
* @returns An unbound HTTP/2 server.
|
|
794
|
+
*/
|
|
566
795
|
createHttpServer(services, sessions, readMaxBytes, writeMaxBytes) {
|
|
567
796
|
const server = http2.createServer(connectNodeAdapter({
|
|
568
797
|
routes: (router) => {
|
|
@@ -577,6 +806,14 @@ const ServerValues = Object.freeze({
|
|
|
577
806
|
});
|
|
578
807
|
return server;
|
|
579
808
|
},
|
|
809
|
+
/**
|
|
810
|
+
* Binds a listener and resolves its actual address.
|
|
811
|
+
*
|
|
812
|
+
* @param server HTTP/2 server to bind.
|
|
813
|
+
* @param host Network host.
|
|
814
|
+
* @param port Requested port, including zero for an ephemeral port.
|
|
815
|
+
* @returns Bound listener address.
|
|
816
|
+
*/
|
|
580
817
|
listen(server, host, port) {
|
|
581
818
|
return new Promise((resolve, reject) => {
|
|
582
819
|
const cleanup = () => {
|
|
@@ -596,32 +833,38 @@ const ServerValues = Object.freeze({
|
|
|
596
833
|
server.listen(port, host);
|
|
597
834
|
});
|
|
598
835
|
},
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
},
|
|
607
|
-
toCleanupErrors(error) {
|
|
608
|
-
if (error instanceof AggregateError) {
|
|
609
|
-
return error.errors;
|
|
610
|
-
}
|
|
611
|
-
return [error];
|
|
612
|
-
},
|
|
836
|
+
/**
|
|
837
|
+
* Collects attachment and immediate cleanup failures for startup reporting.
|
|
838
|
+
*
|
|
839
|
+
* @param startError Attachment or duplicate registration failure.
|
|
840
|
+
* @param cleanupError Failure while closing prepared resources.
|
|
841
|
+
* @returns Both failures in one AggregateError.
|
|
842
|
+
*/
|
|
613
843
|
attachmentCleanupError(startError, cleanupError) {
|
|
614
844
|
const errors = [];
|
|
615
845
|
CloseErrors.collect(startError, errors);
|
|
616
846
|
CloseErrors.collect(cleanupError, errors);
|
|
617
847
|
return new AggregateError(errors, "Server attachment failed and immediate dependency cleanup also failed.");
|
|
618
848
|
},
|
|
849
|
+
/**
|
|
850
|
+
* Throws one deferred cleanup failure or groups several.
|
|
851
|
+
*
|
|
852
|
+
* @param errors Deferred cleanup failures.
|
|
853
|
+
* @returns Never; this method throws.
|
|
854
|
+
*/
|
|
619
855
|
throwCleanupErrors(errors) {
|
|
620
856
|
if (errors.length === 1) {
|
|
621
857
|
throw errors[0];
|
|
622
858
|
}
|
|
623
859
|
throw new AggregateError(errors, "Server deferred failed-start cleanup failed.");
|
|
624
860
|
},
|
|
861
|
+
/**
|
|
862
|
+
* Throws a listener-start failure with any rollback failures.
|
|
863
|
+
*
|
|
864
|
+
* @param startError Original listener failure.
|
|
865
|
+
* @param cleanupErrors Failures while closing startup resources.
|
|
866
|
+
* @returns Never; this method throws.
|
|
867
|
+
*/
|
|
625
868
|
throwListenerStartError(startError, cleanupErrors) {
|
|
626
869
|
if (cleanupErrors.length === 0) {
|
|
627
870
|
throw startError;
|
|
@@ -633,18 +876,37 @@ const ServerValues = Object.freeze({
|
|
|
633
876
|
}
|
|
634
877
|
throw new AggregateError(errors, "Server start failed while opening listener and cleanup also failed.");
|
|
635
878
|
},
|
|
879
|
+
/**
|
|
880
|
+
* Throws one delivery-detachment error or groups several.
|
|
881
|
+
*
|
|
882
|
+
* @param errors Delivery-detachment failures.
|
|
883
|
+
* @returns Never; this method throws.
|
|
884
|
+
*/
|
|
636
885
|
throwRunningDetachErrors(errors) {
|
|
637
886
|
if (errors.length === 1) {
|
|
638
887
|
throw errors[0];
|
|
639
888
|
}
|
|
640
889
|
throw new AggregateError(errors, "Server close failed while detaching delivery.");
|
|
641
890
|
},
|
|
891
|
+
/**
|
|
892
|
+
* Closes the HTTP/2 server and all tracked sessions.
|
|
893
|
+
*
|
|
894
|
+
* @param server Listener being closed.
|
|
895
|
+
* @param sessions Sessions admitted by the listener.
|
|
896
|
+
* @returns Completion after the network releases its resources.
|
|
897
|
+
*/
|
|
642
898
|
async closeNetwork(server, sessions) {
|
|
643
899
|
const closed = ServerValues.closeHttpServer(server);
|
|
644
900
|
await ServerValues.closeSessions(sessions);
|
|
645
901
|
await closed;
|
|
646
902
|
await ServerValues.nextTurn();
|
|
647
903
|
},
|
|
904
|
+
/**
|
|
905
|
+
* Closes the listener, including a listener that never started.
|
|
906
|
+
*
|
|
907
|
+
* @param server Listener being closed.
|
|
908
|
+
* @returns Completion after the listener closes.
|
|
909
|
+
*/
|
|
648
910
|
closeHttpServer(server) {
|
|
649
911
|
return new Promise((resolve, reject) => {
|
|
650
912
|
if (!server.listening) {
|
|
@@ -661,9 +923,21 @@ const ServerValues = Object.freeze({
|
|
|
661
923
|
});
|
|
662
924
|
});
|
|
663
925
|
},
|
|
926
|
+
/**
|
|
927
|
+
* Closes all active HTTP/2 sessions.
|
|
928
|
+
*
|
|
929
|
+
* @param sessions Active sessions to close.
|
|
930
|
+
* @returns Completion after every session closes.
|
|
931
|
+
*/
|
|
664
932
|
async closeSessions(sessions) {
|
|
665
933
|
await Promise.all([...sessions].map((session) => ServerValues.closeSession(session)));
|
|
666
934
|
},
|
|
935
|
+
/**
|
|
936
|
+
* Closes one session gracefully or destroys it after a bounded wait.
|
|
937
|
+
*
|
|
938
|
+
* @param session Active HTTP/2 session.
|
|
939
|
+
* @returns Completion after close or forced destruction.
|
|
940
|
+
*/
|
|
667
941
|
closeSession(session) {
|
|
668
942
|
return new Promise((resolve) => {
|
|
669
943
|
const timer = setTimeout(() => {
|
|
@@ -679,14 +953,31 @@ const ServerValues = Object.freeze({
|
|
|
679
953
|
session.close();
|
|
680
954
|
});
|
|
681
955
|
},
|
|
956
|
+
/**
|
|
957
|
+
* Waits one event-loop turn for pending close callbacks.
|
|
958
|
+
*
|
|
959
|
+
* @returns Completion on the next turn.
|
|
960
|
+
*/
|
|
682
961
|
nextTurn() {
|
|
683
962
|
return new Promise((resolve) => {
|
|
684
963
|
setImmediate(resolve);
|
|
685
964
|
});
|
|
686
965
|
},
|
|
966
|
+
/**
|
|
967
|
+
* Wraps an IPv6 host for URL authority syntax.
|
|
968
|
+
*
|
|
969
|
+
* @param host Bound network host.
|
|
970
|
+
* @returns Host formatted for the base URL.
|
|
971
|
+
*/
|
|
687
972
|
formatHostForUrl(host) {
|
|
688
973
|
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
689
974
|
},
|
|
975
|
+
/**
|
|
976
|
+
* Normalizes a non-blank host for listener binding.
|
|
977
|
+
*
|
|
978
|
+
* @param host Configured host, if supplied.
|
|
979
|
+
* @returns Normalized host or the loopback default.
|
|
980
|
+
*/
|
|
690
981
|
normalizeHost(host) {
|
|
691
982
|
const normalized = host?.trim() ?? defaultHost;
|
|
692
983
|
if (normalized.length === 0) {
|
|
@@ -694,6 +985,13 @@ const ServerValues = Object.freeze({
|
|
|
694
985
|
}
|
|
695
986
|
return normalized;
|
|
696
987
|
},
|
|
988
|
+
/**
|
|
989
|
+
* Validates a configured HTTP message-size limit.
|
|
990
|
+
*
|
|
991
|
+
* @param value Requested byte limit.
|
|
992
|
+
* @param name Option name used in diagnostics.
|
|
993
|
+
* @returns Accepted byte limit.
|
|
994
|
+
*/
|
|
697
995
|
normalizeMessageMaxBytes(value, name) {
|
|
698
996
|
if (!Number.isInteger(value) || value < 1 || value > maximumMessageMaxBytes) {
|
|
699
997
|
throw new Error(`Server ${name} must be an integer from 1 through 4294967295.`);
|