@benzenejs/self-host 0.0.0-stage → 0.1.0-beta.2
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/dist/BenzeneHost.d.ts +100 -0
- package/dist/BenzeneHost.js +136 -0
- package/dist/BenzeneWorkerBuilder.d.ts +21 -0
- package/dist/BenzeneWorkerBuilder.js +34 -0
- package/dist/BoundedChannel.d.ts +40 -0
- package/dist/BoundedChannel.js +105 -0
- package/dist/BoundedConcurrentDispatcher.d.ts +72 -0
- package/dist/BoundedConcurrentDispatcher.js +144 -0
- package/dist/CompositeBenzeneWorker.d.ts +11 -0
- package/dist/CompositeBenzeneWorker.js +24 -0
- package/dist/IBenzeneWorkerStartup.d.ts +16 -0
- package/dist/IBenzeneWorkerStartup.js +2 -0
- package/dist/InlineSelfHostedStartUp.d.ts +24 -0
- package/dist/InlineSelfHostedStartUp.js +39 -0
- package/dist/WorkerApplicationBuilder.d.ts +22 -0
- package/dist/WorkerApplicationBuilder.js +37 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +35 -0
- package/package.json +17 -4
- package/README.md +0 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { IBenzeneServiceContainer } from '@benzenejs/abstractions';
|
|
2
|
+
import { BenzeneStartUp, IBenzeneWorker } from '@benzenejs/abstractions-middleware';
|
|
3
|
+
/** Options for {@link BenzeneHost.build}. */
|
|
4
|
+
export interface BenzeneHostOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Applied to the container **before** the startup's own `configureServices`, so a caller can
|
|
7
|
+
* substitute one of Benzene's baseline services (they are registered `tryAdd`-style: first
|
|
8
|
+
* registration wins). It is deliberately not a way to overrule the startup — the startup is the
|
|
9
|
+
* caller's own code and runs last. Same ordering rule, for the same reason, as .NET's
|
|
10
|
+
* `BenzeneHost.Build(args, configureHost)`.
|
|
11
|
+
*/
|
|
12
|
+
configureServices?: (services: IBenzeneServiceContainer) => void;
|
|
13
|
+
}
|
|
14
|
+
/** Options for {@link BenzeneHost.runAsync} and {@link BenzeneHost.runWorkerAsync}. */
|
|
15
|
+
export interface BenzeneRunOptions extends BenzeneHostOptions {
|
|
16
|
+
/** An extra shutdown trigger, in addition to the process signals. Aborting it stops the workers. */
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
/**
|
|
19
|
+
* The process signals that trigger shutdown. Defaults to `['SIGINT', 'SIGTERM']` — Ctrl-C and the
|
|
20
|
+
* signal Kubernetes/ECS send before the grace period. Pass `[]` to install no handlers at all and
|
|
21
|
+
* drive shutdown entirely through {@link signal}.
|
|
22
|
+
*/
|
|
23
|
+
shutdownSignals?: readonly NodeJS.Signals[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Runs a {@link BenzeneStartUp} as a long-running self-hosted worker process, so a service's entry
|
|
27
|
+
* point is one line:
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* // main.ts, entire
|
|
31
|
+
* await BenzeneHost.runAsync(OrdersStartUp);
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* The point is not the line count. It is that **the startup becomes the only place hosting is
|
|
35
|
+
* described** — transports are declared in `configure` via `useWorker(app, workers => …)`
|
|
36
|
+
* (`useSqs`, `useKafka`, `useRabbitMq`, `useServiceBus`, …), so adding a second one later never
|
|
37
|
+
* touches the entry point. An entry point that names a specific transport is one that has to be
|
|
38
|
+
* rewritten when the service grows another.
|
|
39
|
+
*
|
|
40
|
+
* **The explicit form this composes**, one level down, all public API — write it yourself whenever
|
|
41
|
+
* you need to do something in the middle of it:
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* const startUp = new OrdersStartUp();
|
|
45
|
+
* const configuration = startUp.getConfiguration?.() ?? environmentConfiguration();
|
|
46
|
+
* const container = new DefaultBenzeneServiceContainer(); // @benzenejs/dependencies
|
|
47
|
+
* startUp.configureServices(container, configuration);
|
|
48
|
+
* const builder = new WorkerApplicationBuilder(container); // @benzenejs/self-host
|
|
49
|
+
* startUp.configure(builder, configuration);
|
|
50
|
+
* const worker = builder.createWorker(
|
|
51
|
+
* withStartUpChecks(container.createServiceResolverFactory()), // @benzenejs/core-message-handlers
|
|
52
|
+
* );
|
|
53
|
+
* await worker.startAsync(signal);
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* That is exactly what {@link build} does, and {@link build} hands the {@link IBenzeneWorker} back
|
|
57
|
+
* so you can start and stop it on your own terms — the rung between this and the block above.
|
|
58
|
+
*
|
|
59
|
+
* **When NOT to use it.** This owns the process's lifecycle. If Benzene is one part of a larger
|
|
60
|
+
* process — an Express app you already own, a gRPC `Server`, an AWS Lambda handler — do not run a
|
|
61
|
+
* host at all: mount Benzene into it with `benzene(...)` (`@benzenejs/express`), `useGrpc(...)`
|
|
62
|
+
* (`@benzenejs/grpc`) or `new AwsLambdaHost(StartUp)` (`@benzenejs/aws-lambda-core`).
|
|
63
|
+
*
|
|
64
|
+
* Port of the .NET `Benzene.HostedService.BenzeneHost`. .NET builds over
|
|
65
|
+
* `Host.CreateDefaultBuilder(args)` and registers an `IHostedService`; Node has no generic host, so
|
|
66
|
+
* this owns the signal handling and shutdown ordering directly and takes no `args`.
|
|
67
|
+
*/
|
|
68
|
+
export declare class BenzeneHost {
|
|
69
|
+
/**
|
|
70
|
+
* Builds the worker for `startUpFactory` without starting it — the escape hatch, and the seam a
|
|
71
|
+
* test uses. Runs the boot-time wiring checks (`withStartUpChecks`) before returning, so a
|
|
72
|
+
* registration mistake is a start-up failure rather than every message failing to route once the
|
|
73
|
+
* loop is live.
|
|
74
|
+
*
|
|
75
|
+
* @param startUpFactory A no-argument constructor for the startup — the port of C#'s
|
|
76
|
+
* `where TStartUp : BenzeneStartUp, new()`.
|
|
77
|
+
*/
|
|
78
|
+
static build<TStartUp extends BenzeneStartUp>(startUpFactory: new () => TStartUp, options?: BenzeneHostOptions): IBenzeneWorker;
|
|
79
|
+
/**
|
|
80
|
+
* Builds the worker for `startUpFactory` and runs it until a shutdown signal arrives, then stops it.
|
|
81
|
+
* Composed from {@link build} + {@link runWorkerAsync}; drop to either when you need to do something
|
|
82
|
+
* between the two.
|
|
83
|
+
*/
|
|
84
|
+
static runAsync<TStartUp extends BenzeneStartUp>(startUpFactory: new () => TStartUp, options?: BenzeneRunOptions): Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Runs an already-built {@link IBenzeneWorker} until shutdown: installs the shutdown-signal
|
|
87
|
+
* handlers, starts the worker, waits for a shutdown signal, then stops it and removes the handlers.
|
|
88
|
+
* The wait is for the *signal*, not for the workers - `startAsync` resolving means "started", not
|
|
89
|
+
* "finished". A worker that fails to start triggers shutdown itself, so the process never hangs on a
|
|
90
|
+
* signal that will not come.
|
|
91
|
+
*
|
|
92
|
+
* This is the rung for a worker you built some other way — {@link build}, an
|
|
93
|
+
* {@link InlineSelfHostedStartUp}, or a hand-written {@link IBenzeneWorker}. The explicit form it
|
|
94
|
+
* composes is `worker.startAsync(signal)` / `worker.stopAsync()` with your own
|
|
95
|
+
* `process.on('SIGTERM', …)`.
|
|
96
|
+
*
|
|
97
|
+
* A start failure is rethrown once the workers have been stopped.
|
|
98
|
+
*/
|
|
99
|
+
static runWorkerAsync(worker: IBenzeneWorker, options?: BenzeneRunOptions): Promise<void>;
|
|
100
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BenzeneHost = void 0;
|
|
4
|
+
const abstractions_middleware_1 = require("@benzenejs/abstractions-middleware");
|
|
5
|
+
const core_message_handlers_1 = require("@benzenejs/core-message-handlers");
|
|
6
|
+
const dependencies_1 = require("@benzenejs/dependencies");
|
|
7
|
+
const WorkerApplicationBuilder_1 = require("./WorkerApplicationBuilder");
|
|
8
|
+
/**
|
|
9
|
+
* Runs a {@link BenzeneStartUp} as a long-running self-hosted worker process, so a service's entry
|
|
10
|
+
* point is one line:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* // main.ts, entire
|
|
14
|
+
* await BenzeneHost.runAsync(OrdersStartUp);
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* The point is not the line count. It is that **the startup becomes the only place hosting is
|
|
18
|
+
* described** — transports are declared in `configure` via `useWorker(app, workers => …)`
|
|
19
|
+
* (`useSqs`, `useKafka`, `useRabbitMq`, `useServiceBus`, …), so adding a second one later never
|
|
20
|
+
* touches the entry point. An entry point that names a specific transport is one that has to be
|
|
21
|
+
* rewritten when the service grows another.
|
|
22
|
+
*
|
|
23
|
+
* **The explicit form this composes**, one level down, all public API — write it yourself whenever
|
|
24
|
+
* you need to do something in the middle of it:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* const startUp = new OrdersStartUp();
|
|
28
|
+
* const configuration = startUp.getConfiguration?.() ?? environmentConfiguration();
|
|
29
|
+
* const container = new DefaultBenzeneServiceContainer(); // @benzenejs/dependencies
|
|
30
|
+
* startUp.configureServices(container, configuration);
|
|
31
|
+
* const builder = new WorkerApplicationBuilder(container); // @benzenejs/self-host
|
|
32
|
+
* startUp.configure(builder, configuration);
|
|
33
|
+
* const worker = builder.createWorker(
|
|
34
|
+
* withStartUpChecks(container.createServiceResolverFactory()), // @benzenejs/core-message-handlers
|
|
35
|
+
* );
|
|
36
|
+
* await worker.startAsync(signal);
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* That is exactly what {@link build} does, and {@link build} hands the {@link IBenzeneWorker} back
|
|
40
|
+
* so you can start and stop it on your own terms — the rung between this and the block above.
|
|
41
|
+
*
|
|
42
|
+
* **When NOT to use it.** This owns the process's lifecycle. If Benzene is one part of a larger
|
|
43
|
+
* process — an Express app you already own, a gRPC `Server`, an AWS Lambda handler — do not run a
|
|
44
|
+
* host at all: mount Benzene into it with `benzene(...)` (`@benzenejs/express`), `useGrpc(...)`
|
|
45
|
+
* (`@benzenejs/grpc`) or `new AwsLambdaHost(StartUp)` (`@benzenejs/aws-lambda-core`).
|
|
46
|
+
*
|
|
47
|
+
* Port of the .NET `Benzene.HostedService.BenzeneHost`. .NET builds over
|
|
48
|
+
* `Host.CreateDefaultBuilder(args)` and registers an `IHostedService`; Node has no generic host, so
|
|
49
|
+
* this owns the signal handling and shutdown ordering directly and takes no `args`.
|
|
50
|
+
*/
|
|
51
|
+
class BenzeneHost {
|
|
52
|
+
/**
|
|
53
|
+
* Builds the worker for `startUpFactory` without starting it — the escape hatch, and the seam a
|
|
54
|
+
* test uses. Runs the boot-time wiring checks (`withStartUpChecks`) before returning, so a
|
|
55
|
+
* registration mistake is a start-up failure rather than every message failing to route once the
|
|
56
|
+
* loop is live.
|
|
57
|
+
*
|
|
58
|
+
* @param startUpFactory A no-argument constructor for the startup — the port of C#'s
|
|
59
|
+
* `where TStartUp : BenzeneStartUp, new()`.
|
|
60
|
+
*/
|
|
61
|
+
static build(startUpFactory, options = {}) {
|
|
62
|
+
const startUp = new startUpFactory();
|
|
63
|
+
const configuration = startUp.getConfiguration?.() ?? (0, abstractions_middleware_1.environmentConfiguration)();
|
|
64
|
+
const container = new dependencies_1.DefaultBenzeneServiceContainer();
|
|
65
|
+
// Before the startup's own registrations: see BenzeneHostOptions.configureServices.
|
|
66
|
+
options.configureServices?.(container);
|
|
67
|
+
startUp.configureServices(container, configuration);
|
|
68
|
+
const builder = new WorkerApplicationBuilder_1.WorkerApplicationBuilder(container);
|
|
69
|
+
startUp.configure(builder, configuration);
|
|
70
|
+
return builder.createWorker((0, core_message_handlers_1.withStartUpChecks)(container.createServiceResolverFactory()));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Builds the worker for `startUpFactory` and runs it until a shutdown signal arrives, then stops it.
|
|
74
|
+
* Composed from {@link build} + {@link runWorkerAsync}; drop to either when you need to do something
|
|
75
|
+
* between the two.
|
|
76
|
+
*/
|
|
77
|
+
static async runAsync(startUpFactory, options = {}) {
|
|
78
|
+
await BenzeneHost.runWorkerAsync(BenzeneHost.build(startUpFactory, options), options);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Runs an already-built {@link IBenzeneWorker} until shutdown: installs the shutdown-signal
|
|
82
|
+
* handlers, starts the worker, waits for a shutdown signal, then stops it and removes the handlers.
|
|
83
|
+
* The wait is for the *signal*, not for the workers - `startAsync` resolving means "started", not
|
|
84
|
+
* "finished". A worker that fails to start triggers shutdown itself, so the process never hangs on a
|
|
85
|
+
* signal that will not come.
|
|
86
|
+
*
|
|
87
|
+
* This is the rung for a worker you built some other way — {@link build}, an
|
|
88
|
+
* {@link InlineSelfHostedStartUp}, or a hand-written {@link IBenzeneWorker}. The explicit form it
|
|
89
|
+
* composes is `worker.startAsync(signal)` / `worker.stopAsync()` with your own
|
|
90
|
+
* `process.on('SIGTERM', …)`.
|
|
91
|
+
*
|
|
92
|
+
* A start failure is rethrown once the workers have been stopped.
|
|
93
|
+
*/
|
|
94
|
+
static async runWorkerAsync(worker, options = {}) {
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const abort = () => {
|
|
97
|
+
controller.abort();
|
|
98
|
+
};
|
|
99
|
+
const signals = options.shutdownSignals ?? ['SIGINT', 'SIGTERM'];
|
|
100
|
+
for (const name of signals) {
|
|
101
|
+
process.on(name, abort);
|
|
102
|
+
}
|
|
103
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
104
|
+
if (options.signal?.aborted === true) {
|
|
105
|
+
controller.abort();
|
|
106
|
+
}
|
|
107
|
+
const shutdown = new Promise((resolve) => {
|
|
108
|
+
if (controller.signal.aborted) {
|
|
109
|
+
resolve();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
controller.signal.addEventListener('abort', () => resolve(), { once: true });
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
const running = worker.startAsync(controller.signal);
|
|
116
|
+
// The host runs until it is told to stop, NOT until the workers finish: `startAsync` resolves
|
|
117
|
+
// promptly for a push-based worker (a Kafka consumer, an HTTP listener) and only on shutdown for
|
|
118
|
+
// a polling one, so "all workers resolved" is not a reason to exit. A worker that fails to start
|
|
119
|
+
// is, though - it triggers the same shutdown rather than leaving the process waiting on a signal
|
|
120
|
+
// that will never come.
|
|
121
|
+
running.catch(() => controller.abort());
|
|
122
|
+
await shutdown;
|
|
123
|
+
await worker.stopAsync();
|
|
124
|
+
// Now that stop has run the loops unwind; this rethrows a start failure (already handled above,
|
|
125
|
+
// so it never surfaces as an unhandled rejection) or anything they threw on the way out.
|
|
126
|
+
await running;
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
for (const name of signals) {
|
|
130
|
+
process.off(name, abort);
|
|
131
|
+
}
|
|
132
|
+
options.signal?.removeEventListener('abort', abort);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
exports.BenzeneHost = BenzeneHost;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { IBenzeneServiceContainer, IServiceResolverFactory } from '@benzenejs/abstractions';
|
|
2
|
+
import { IBenzeneWorker, IMiddlewarePipelineBuilder } from '@benzenejs/abstractions-middleware';
|
|
3
|
+
import { IBenzeneWorkerStartup } from './IBenzeneWorkerStartup';
|
|
4
|
+
/**
|
|
5
|
+
* Default {@link IBenzeneWorkerStartup}: collects worker factories and, at host start, materializes them
|
|
6
|
+
* into one {@link CompositeBenzeneWorker} bound to the invocation's resolver factory.
|
|
7
|
+
* Port of Benzene.SelfHost.BenzeneWorkerBuilder.
|
|
8
|
+
*
|
|
9
|
+
* C#'s `IBenzeneWorkerStartup.Create(IServiceResolverFactory)` (the worker-materialization overload of
|
|
10
|
+
* `Create`) is named `createWorker` here to disambiguate from the generic pipeline-builder `create`,
|
|
11
|
+
* since TypeScript can't overload a generic no-arg method against a one-arg method as cleanly as C#.
|
|
12
|
+
*/
|
|
13
|
+
export declare class BenzeneWorkerBuilder implements IBenzeneWorkerStartup {
|
|
14
|
+
private readonly benzeneServiceContainer;
|
|
15
|
+
private readonly apps;
|
|
16
|
+
constructor(benzeneServiceContainer: IBenzeneServiceContainer);
|
|
17
|
+
add(func: (serviceResolverFactory: IServiceResolverFactory) => IBenzeneWorker): void;
|
|
18
|
+
register(action: (container: IBenzeneServiceContainer) => void): void;
|
|
19
|
+
create<TNewContext>(): IMiddlewarePipelineBuilder<TNewContext>;
|
|
20
|
+
createWorker(serviceResolverFactory: IServiceResolverFactory): IBenzeneWorker;
|
|
21
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BenzeneWorkerBuilder = void 0;
|
|
4
|
+
const core_middleware_1 = require("@benzenejs/core-middleware");
|
|
5
|
+
const CompositeBenzeneWorker_1 = require("./CompositeBenzeneWorker");
|
|
6
|
+
/**
|
|
7
|
+
* Default {@link IBenzeneWorkerStartup}: collects worker factories and, at host start, materializes them
|
|
8
|
+
* into one {@link CompositeBenzeneWorker} bound to the invocation's resolver factory.
|
|
9
|
+
* Port of Benzene.SelfHost.BenzeneWorkerBuilder.
|
|
10
|
+
*
|
|
11
|
+
* C#'s `IBenzeneWorkerStartup.Create(IServiceResolverFactory)` (the worker-materialization overload of
|
|
12
|
+
* `Create`) is named `createWorker` here to disambiguate from the generic pipeline-builder `create`,
|
|
13
|
+
* since TypeScript can't overload a generic no-arg method against a one-arg method as cleanly as C#.
|
|
14
|
+
*/
|
|
15
|
+
class BenzeneWorkerBuilder {
|
|
16
|
+
benzeneServiceContainer;
|
|
17
|
+
apps = [];
|
|
18
|
+
constructor(benzeneServiceContainer) {
|
|
19
|
+
this.benzeneServiceContainer = benzeneServiceContainer;
|
|
20
|
+
}
|
|
21
|
+
add(func) {
|
|
22
|
+
this.apps.push(func);
|
|
23
|
+
}
|
|
24
|
+
register(action) {
|
|
25
|
+
action(this.benzeneServiceContainer);
|
|
26
|
+
}
|
|
27
|
+
create() {
|
|
28
|
+
return new core_middleware_1.MiddlewarePipelineBuilder(this.benzeneServiceContainer);
|
|
29
|
+
}
|
|
30
|
+
createWorker(serviceResolverFactory) {
|
|
31
|
+
return new CompositeBenzeneWorker_1.CompositeBenzeneWorker(this.apps.map((x) => x(serviceResolverFactory)));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
exports.BenzeneWorkerBuilder = BenzeneWorkerBuilder;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bounded, capacity-1, single-reader / multi-writer async channel - the subset of
|
|
3
|
+
* `System.Threading.Channels.Channel<T>` (created via `Channel.CreateBounded<T>(new
|
|
4
|
+
* BoundedChannelOptions(1) { SingleReader = true, SingleWriter = false, FullMode = Wait })`) that
|
|
5
|
+
* {@link BoundedConcurrentDispatcher} relies on. .NET ships Channels in the BCL; Node has no
|
|
6
|
+
* equivalent, so the used behaviour is re-created here (same pattern as `@benzenejs/rate-limiting`
|
|
7
|
+
* re-creating the `System.Threading.RateLimiting` subset).
|
|
8
|
+
*
|
|
9
|
+
* `write` resolves once the single slot is free (giving the producer backpressure); `complete` closes
|
|
10
|
+
* the channel so a pending/next `write` rejects and the `readAll` iterator finishes once drained. Node
|
|
11
|
+
* is single-threaded, so no locking is needed - the fields are only ever touched between `await`
|
|
12
|
+
* points on one event loop.
|
|
13
|
+
*/
|
|
14
|
+
/** Thrown by {@link BoundedChannel.write} when the channel has been completed. Port of `ChannelClosedException`. */
|
|
15
|
+
export declare class ChannelCompletedError extends Error {
|
|
16
|
+
constructor();
|
|
17
|
+
}
|
|
18
|
+
export declare class BoundedChannel<T> {
|
|
19
|
+
private slot;
|
|
20
|
+
private hasItem;
|
|
21
|
+
private completed;
|
|
22
|
+
private readonly readWaiters;
|
|
23
|
+
private readonly writeWaiters;
|
|
24
|
+
/**
|
|
25
|
+
* Writes an item, waiting while the single slot is occupied (capacity-1 backpressure). Rejects with
|
|
26
|
+
* {@link ChannelCompletedError} if the channel is (or becomes) completed, or with the signal's reason
|
|
27
|
+
* if `signal` aborts while waiting.
|
|
28
|
+
*/
|
|
29
|
+
write(item: T, signal?: AbortSignal): Promise<void>;
|
|
30
|
+
/** Closes the channel: no further items are accepted, and `readAll` ends once the current item (if any) is read. */
|
|
31
|
+
complete(): void;
|
|
32
|
+
/** The single consumer's loop: yields each written item in order, returning when completed and drained. */
|
|
33
|
+
readAll(): AsyncIterableIterator<T>;
|
|
34
|
+
private wait;
|
|
35
|
+
/**
|
|
36
|
+
* Wakes every waiter; each re-checks its own condition on resume (a freed slot only lets one writer
|
|
37
|
+
* proceed, the rest re-wait), so spurious wake-ups are harmless.
|
|
38
|
+
*/
|
|
39
|
+
private wake;
|
|
40
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* A bounded, capacity-1, single-reader / multi-writer async channel - the subset of
|
|
4
|
+
* `System.Threading.Channels.Channel<T>` (created via `Channel.CreateBounded<T>(new
|
|
5
|
+
* BoundedChannelOptions(1) { SingleReader = true, SingleWriter = false, FullMode = Wait })`) that
|
|
6
|
+
* {@link BoundedConcurrentDispatcher} relies on. .NET ships Channels in the BCL; Node has no
|
|
7
|
+
* equivalent, so the used behaviour is re-created here (same pattern as `@benzenejs/rate-limiting`
|
|
8
|
+
* re-creating the `System.Threading.RateLimiting` subset).
|
|
9
|
+
*
|
|
10
|
+
* `write` resolves once the single slot is free (giving the producer backpressure); `complete` closes
|
|
11
|
+
* the channel so a pending/next `write` rejects and the `readAll` iterator finishes once drained. Node
|
|
12
|
+
* is single-threaded, so no locking is needed - the fields are only ever touched between `await`
|
|
13
|
+
* points on one event loop.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.BoundedChannel = exports.ChannelCompletedError = void 0;
|
|
17
|
+
/** Thrown by {@link BoundedChannel.write} when the channel has been completed. Port of `ChannelClosedException`. */
|
|
18
|
+
class ChannelCompletedError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super('The channel has been completed and cannot accept further writes.');
|
|
21
|
+
this.name = 'ChannelCompletedError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
exports.ChannelCompletedError = ChannelCompletedError;
|
|
25
|
+
class BoundedChannel {
|
|
26
|
+
slot;
|
|
27
|
+
hasItem = false;
|
|
28
|
+
completed = false;
|
|
29
|
+
readWaiters = [];
|
|
30
|
+
writeWaiters = [];
|
|
31
|
+
/**
|
|
32
|
+
* Writes an item, waiting while the single slot is occupied (capacity-1 backpressure). Rejects with
|
|
33
|
+
* {@link ChannelCompletedError} if the channel is (or becomes) completed, or with the signal's reason
|
|
34
|
+
* if `signal` aborts while waiting.
|
|
35
|
+
*/
|
|
36
|
+
async write(item, signal) {
|
|
37
|
+
while (this.hasItem && !this.completed) {
|
|
38
|
+
await this.wait(this.writeWaiters, signal);
|
|
39
|
+
}
|
|
40
|
+
if (this.completed) {
|
|
41
|
+
throw new ChannelCompletedError();
|
|
42
|
+
}
|
|
43
|
+
this.slot = item;
|
|
44
|
+
this.hasItem = true;
|
|
45
|
+
this.wake(this.readWaiters);
|
|
46
|
+
}
|
|
47
|
+
/** Closes the channel: no further items are accepted, and `readAll` ends once the current item (if any) is read. */
|
|
48
|
+
complete() {
|
|
49
|
+
this.completed = true;
|
|
50
|
+
this.wake(this.readWaiters);
|
|
51
|
+
this.wake(this.writeWaiters);
|
|
52
|
+
}
|
|
53
|
+
/** The single consumer's loop: yields each written item in order, returning when completed and drained. */
|
|
54
|
+
async *readAll() {
|
|
55
|
+
for (;;) {
|
|
56
|
+
while (!this.hasItem && !this.completed) {
|
|
57
|
+
await this.wait(this.readWaiters);
|
|
58
|
+
}
|
|
59
|
+
if (this.hasItem) {
|
|
60
|
+
const item = this.slot;
|
|
61
|
+
this.slot = undefined;
|
|
62
|
+
this.hasItem = false;
|
|
63
|
+
this.wake(this.writeWaiters);
|
|
64
|
+
yield item;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
return; // completed and empty
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
wait(waiters, signal) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
if (signal?.aborted) {
|
|
74
|
+
reject(signal.reason);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const onResolve = () => {
|
|
78
|
+
cleanup();
|
|
79
|
+
resolve();
|
|
80
|
+
};
|
|
81
|
+
const onAbort = () => {
|
|
82
|
+
const index = waiters.indexOf(onResolve);
|
|
83
|
+
if (index >= 0) {
|
|
84
|
+
waiters.splice(index, 1);
|
|
85
|
+
}
|
|
86
|
+
cleanup();
|
|
87
|
+
reject(signal?.reason);
|
|
88
|
+
};
|
|
89
|
+
const cleanup = () => signal?.removeEventListener('abort', onAbort);
|
|
90
|
+
waiters.push(onResolve);
|
|
91
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Wakes every waiter; each re-checks its own condition on resume (a freed slot only lets one writer
|
|
96
|
+
* proceed, the rest re-wait), so spurious wake-ups are harmless.
|
|
97
|
+
*/
|
|
98
|
+
wake(waiters) {
|
|
99
|
+
const pending = waiters.splice(0);
|
|
100
|
+
for (const resolve of pending) {
|
|
101
|
+
resolve();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
exports.BoundedChannel = BoundedChannel;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { ILogger } from '@benzenejs/abstractions';
|
|
2
|
+
/** The async handler each dispatched item is passed to. Port of C# `Func<T, CancellationToken, Task>`. */
|
|
3
|
+
export type DispatchHandler<T> = (item: T, cancellationToken?: AbortSignal) => Promise<void>;
|
|
4
|
+
/** Optional configuration for {@link BoundedConcurrentDispatcher}. Ports the C# constructor's optional parameters. */
|
|
5
|
+
export interface BoundedConcurrentDispatcherOptions<T> {
|
|
6
|
+
/**
|
|
7
|
+
* When provided, routes items sharing the same key to the same lane, preserving per-key order. When
|
|
8
|
+
* omitted (the default), items round-robin across lanes with no ordering guarantee.
|
|
9
|
+
*/
|
|
10
|
+
keySelector?: (item: T) => number;
|
|
11
|
+
/**
|
|
12
|
+
* When `true` (the default), a fault from `handle` is logged and swallowed - that lane keeps
|
|
13
|
+
* consuming. When `false`, the fault is logged, passed to `onFault` if supplied, and rethrown -
|
|
14
|
+
* ending that lane's consume loop.
|
|
15
|
+
*/
|
|
16
|
+
catchExceptions?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Invoked with the fault when `catchExceptions` is `false` and `handle` throws, before the fault is
|
|
19
|
+
* rethrown. Lets a caller react to a lane dying (e.g. stop the whole worker).
|
|
20
|
+
*/
|
|
21
|
+
onFault?: (error: unknown) => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Dispatches items pulled from a self-hosted worker's poll loop to an async handler, bounding how many
|
|
25
|
+
* handlers run at once. Built on a capacity-1 {@link BoundedChannel} per lane - the port's stand-in
|
|
26
|
+
* for `System.Threading.Channels`.
|
|
27
|
+
* Port of Benzene.SelfHost.BoundedConcurrentDispatcher<T>.
|
|
28
|
+
*
|
|
29
|
+
* Runs `laneCount` independent lanes, each a single-consumer channel with one dedicated consumer. With
|
|
30
|
+
* a `keySelector`, items sharing a key always route to the same lane, so that lane's strictly-FIFO
|
|
31
|
+
* consumer preserves order for that key while different keys run concurrently, up to `laneCount` at
|
|
32
|
+
* once. Without one, items round-robin with no ordering promise. Each lane's channel has capacity 1, so
|
|
33
|
+
* `enqueueAsync` blocks once a lane already has one item queued behind the one in flight - the poll
|
|
34
|
+
* loop's backpressure.
|
|
35
|
+
*
|
|
36
|
+
* Node is single-threaded, so C#'s `Interlocked`/`Volatile` accesses on the outstanding-count array and
|
|
37
|
+
* round-robin counter become plain reads/writes (only ever touched between `await` points on one event
|
|
38
|
+
* loop); C#'s `ILogger` maps to `@benzenejs/abstractions`' `ILogger`.
|
|
39
|
+
*/
|
|
40
|
+
export declare class BoundedConcurrentDispatcher<T> {
|
|
41
|
+
private readonly logger;
|
|
42
|
+
private readonly lanes;
|
|
43
|
+
private readonly consumers;
|
|
44
|
+
private readonly keySelector?;
|
|
45
|
+
private readonly laneOutstanding;
|
|
46
|
+
private roundRobinCounter;
|
|
47
|
+
constructor(laneCount: number, handle: DispatchHandler<T>, logger: ILogger, options?: BoundedConcurrentDispatcherOptions<T>);
|
|
48
|
+
/** The number of lanes items are dispatched across. */
|
|
49
|
+
get laneCount(): number;
|
|
50
|
+
/** Maps a key to the lane index it routes to (the same mapping {@link enqueueAsync} uses). */
|
|
51
|
+
laneForKey(key: number): number;
|
|
52
|
+
/**
|
|
53
|
+
* Routes `item` to a lane (by key, if a key selector was supplied, otherwise round-robin) and
|
|
54
|
+
* enqueues it for that lane's consumer to dispatch.
|
|
55
|
+
*/
|
|
56
|
+
enqueueAsync(item: T, cancellationToken?: AbortSignal): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Waits for the lanes the given keys route to (via {@link laneForKey}) to have no items queued or in
|
|
59
|
+
* flight, up to `timeoutMs` - without completing them, so those lanes keep consuming afterwards.
|
|
60
|
+
* Because lanes are shared (`key % laneCount`), this may also wait on unrelated keys that share a
|
|
61
|
+
* lane - safe, just conservative.
|
|
62
|
+
*/
|
|
63
|
+
drainLanesAsync(laneKeys: Iterable<number>, timeoutMs: number): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Stops accepting new items on every lane and waits for all in-flight handler calls to finish, up to
|
|
66
|
+
* `drainTimeoutMs`. Lanes that haven't finished by the timeout are abandoned, not cancelled - `handle`
|
|
67
|
+
* is responsible for honoring its own cancellation, if any.
|
|
68
|
+
*/
|
|
69
|
+
drainAsync(drainTimeoutMs: number): Promise<void>;
|
|
70
|
+
private consumeLoopAsync;
|
|
71
|
+
private consumeLaneAsync;
|
|
72
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BoundedConcurrentDispatcher = void 0;
|
|
4
|
+
const BoundedChannel_1 = require("./BoundedChannel");
|
|
5
|
+
/**
|
|
6
|
+
* Dispatches items pulled from a self-hosted worker's poll loop to an async handler, bounding how many
|
|
7
|
+
* handlers run at once. Built on a capacity-1 {@link BoundedChannel} per lane - the port's stand-in
|
|
8
|
+
* for `System.Threading.Channels`.
|
|
9
|
+
* Port of Benzene.SelfHost.BoundedConcurrentDispatcher<T>.
|
|
10
|
+
*
|
|
11
|
+
* Runs `laneCount` independent lanes, each a single-consumer channel with one dedicated consumer. With
|
|
12
|
+
* a `keySelector`, items sharing a key always route to the same lane, so that lane's strictly-FIFO
|
|
13
|
+
* consumer preserves order for that key while different keys run concurrently, up to `laneCount` at
|
|
14
|
+
* once. Without one, items round-robin with no ordering promise. Each lane's channel has capacity 1, so
|
|
15
|
+
* `enqueueAsync` blocks once a lane already has one item queued behind the one in flight - the poll
|
|
16
|
+
* loop's backpressure.
|
|
17
|
+
*
|
|
18
|
+
* Node is single-threaded, so C#'s `Interlocked`/`Volatile` accesses on the outstanding-count array and
|
|
19
|
+
* round-robin counter become plain reads/writes (only ever touched between `await` points on one event
|
|
20
|
+
* loop); C#'s `ILogger` maps to `@benzenejs/abstractions`' `ILogger`.
|
|
21
|
+
*/
|
|
22
|
+
class BoundedConcurrentDispatcher {
|
|
23
|
+
logger;
|
|
24
|
+
lanes;
|
|
25
|
+
consumers;
|
|
26
|
+
keySelector;
|
|
27
|
+
laneOutstanding;
|
|
28
|
+
roundRobinCounter = -1;
|
|
29
|
+
constructor(laneCount, handle, logger, options = {}) {
|
|
30
|
+
this.logger = logger;
|
|
31
|
+
if (laneCount < 1) {
|
|
32
|
+
throw new RangeError(`laneCount must be at least 1 (was ${laneCount}).`);
|
|
33
|
+
}
|
|
34
|
+
this.keySelector = options.keySelector;
|
|
35
|
+
const catchExceptions = options.catchExceptions ?? true;
|
|
36
|
+
this.lanes = Array.from({ length: laneCount }, () => new BoundedChannel_1.BoundedChannel());
|
|
37
|
+
this.laneOutstanding = new Array(laneCount).fill(0);
|
|
38
|
+
this.consumers = this.lanes.map((_, i) => {
|
|
39
|
+
const consumer = this.consumeLoopAsync(i, handle, catchExceptions, options.onFault);
|
|
40
|
+
// A lane consumer rejects when catchExceptions=false and its handler faults. Observe it here so
|
|
41
|
+
// the rejection is never "unhandled" before drainAsync awaits it (drainAsync uses allSettled, so
|
|
42
|
+
// it never surfaces as a throw either - matching C#'s `Task.WhenAny`, which ignores a faulted task).
|
|
43
|
+
void consumer.catch(() => { });
|
|
44
|
+
return consumer;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** The number of lanes items are dispatched across. */
|
|
48
|
+
get laneCount() {
|
|
49
|
+
return this.lanes.length;
|
|
50
|
+
}
|
|
51
|
+
/** Maps a key to the lane index it routes to (the same mapping {@link enqueueAsync} uses). */
|
|
52
|
+
laneForKey(key) {
|
|
53
|
+
// `>>> 0` reinterprets as uint32, matching C#'s `(uint)key % (uint)laneCount` for negative keys.
|
|
54
|
+
return (key >>> 0) % this.lanes.length;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Routes `item` to a lane (by key, if a key selector was supplied, otherwise round-robin) and
|
|
58
|
+
* enqueues it for that lane's consumer to dispatch.
|
|
59
|
+
*/
|
|
60
|
+
async enqueueAsync(item, cancellationToken) {
|
|
61
|
+
const laneIndex = this.keySelector !== undefined
|
|
62
|
+
? (this.keySelector(item) >>> 0) % this.lanes.length
|
|
63
|
+
: (++this.roundRobinCounter >>> 0) % this.lanes.length;
|
|
64
|
+
// Count the item as outstanding for its lane from the moment it's accepted (queued or in flight)
|
|
65
|
+
// until its handler finishes - this is what drainLanesAsync waits on. If the write is
|
|
66
|
+
// cancelled/faulted it never entered the lane, so undo the increment.
|
|
67
|
+
this.laneOutstanding[laneIndex]++;
|
|
68
|
+
try {
|
|
69
|
+
await this.lanes[laneIndex].write(item, cancellationToken);
|
|
70
|
+
}
|
|
71
|
+
catch (ex) {
|
|
72
|
+
this.laneOutstanding[laneIndex]--;
|
|
73
|
+
throw ex;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Waits for the lanes the given keys route to (via {@link laneForKey}) to have no items queued or in
|
|
78
|
+
* flight, up to `timeoutMs` - without completing them, so those lanes keep consuming afterwards.
|
|
79
|
+
* Because lanes are shared (`key % laneCount`), this may also wait on unrelated keys that share a
|
|
80
|
+
* lane - safe, just conservative.
|
|
81
|
+
*/
|
|
82
|
+
async drainLanesAsync(laneKeys, timeoutMs) {
|
|
83
|
+
const targetLanes = [...new Set([...laneKeys].map((k) => this.laneForKey(k)))];
|
|
84
|
+
if (targetLanes.length === 0) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const start = Date.now();
|
|
88
|
+
while (targetLanes.some((i) => this.laneOutstanding[i] > 0)) {
|
|
89
|
+
if (Date.now() - start >= timeoutMs) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await delay(10);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Stops accepting new items on every lane and waits for all in-flight handler calls to finish, up to
|
|
97
|
+
* `drainTimeoutMs`. Lanes that haven't finished by the timeout are abandoned, not cancelled - `handle`
|
|
98
|
+
* is responsible for honoring its own cancellation, if any.
|
|
99
|
+
*/
|
|
100
|
+
async drainAsync(drainTimeoutMs) {
|
|
101
|
+
for (const lane of this.lanes) {
|
|
102
|
+
lane.complete();
|
|
103
|
+
}
|
|
104
|
+
// allSettled (not all) so a faulted lane consumer doesn't make drain reject - C#'s `Task.WhenAny`
|
|
105
|
+
// returns the faulted task without observing it; the equivalent here is to wait on settlement.
|
|
106
|
+
await Promise.race([Promise.allSettled(this.consumers), delay(drainTimeoutMs)]);
|
|
107
|
+
}
|
|
108
|
+
async consumeLoopAsync(laneIndex, handle, catchExceptions, onFault) {
|
|
109
|
+
try {
|
|
110
|
+
await this.consumeLaneAsync(laneIndex, handle, catchExceptions, onFault);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
// The lane consumer is exiting - either normal completion (the count is already 0) or a rethrown
|
|
114
|
+
// fault with catchExceptions=false. On the fault path any item still queued in this lane's
|
|
115
|
+
// channel was counted at enqueue and will never be read now, so clear the phantom count -
|
|
116
|
+
// otherwise a later drainLanesAsync on this lane would burn its full timeout every time.
|
|
117
|
+
this.laneOutstanding[laneIndex] = 0;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async consumeLaneAsync(laneIndex, handle, catchExceptions, onFault) {
|
|
121
|
+
for await (const item of this.lanes[laneIndex].readAll()) {
|
|
122
|
+
try {
|
|
123
|
+
await handle(item);
|
|
124
|
+
}
|
|
125
|
+
catch (ex) {
|
|
126
|
+
this.logger.logError(ex, 'Unhandled exception processing item in Benzene worker loop');
|
|
127
|
+
if (!catchExceptions) {
|
|
128
|
+
onFault?.(ex);
|
|
129
|
+
throw ex;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
// Runs on every path - success, swallowed exception, and the rethrow above - so each item is
|
|
134
|
+
// un-counted exactly once and a quiescing drainLanesAsync never hangs on a dying lane.
|
|
135
|
+
this.laneOutstanding[laneIndex]--;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
exports.BoundedConcurrentDispatcher = BoundedConcurrentDispatcher;
|
|
141
|
+
/** A cancellable delay. `Task.Delay(TimeSpan)` -> a `setTimeout`-backed promise. */
|
|
142
|
+
function delay(ms) {
|
|
143
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
144
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { IBenzeneWorker } from '@benzenejs/abstractions-middleware';
|
|
2
|
+
/**
|
|
3
|
+
* Aggregates several {@link IBenzeneWorker}s as one, starting and stopping them together.
|
|
4
|
+
* Port of Benzene.SelfHost.CompositeBenzeneWorker.
|
|
5
|
+
*/
|
|
6
|
+
export declare class CompositeBenzeneWorker implements IBenzeneWorker {
|
|
7
|
+
private readonly workers;
|
|
8
|
+
constructor(workers: Iterable<IBenzeneWorker>);
|
|
9
|
+
startAsync(cancellationToken?: AbortSignal): Promise<void>;
|
|
10
|
+
stopAsync(cancellationToken?: AbortSignal): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CompositeBenzeneWorker = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Aggregates several {@link IBenzeneWorker}s as one, starting and stopping them together.
|
|
6
|
+
* Port of Benzene.SelfHost.CompositeBenzeneWorker.
|
|
7
|
+
*/
|
|
8
|
+
class CompositeBenzeneWorker {
|
|
9
|
+
workers;
|
|
10
|
+
constructor(workers) {
|
|
11
|
+
// Materialize once. Callers pass a deferred sequence (BenzeneWorkerBuilder.create hands us
|
|
12
|
+
// `apps.map(factory => factory(resolver))`, and every factory news up a fresh worker), so
|
|
13
|
+
// re-enumerating in stopAsync would build a SECOND, never-started worker set and stop those
|
|
14
|
+
// instead - silently skipping every worker's drain/close/commit. `[...workers]` snapshots it.
|
|
15
|
+
this.workers = [...workers];
|
|
16
|
+
}
|
|
17
|
+
async startAsync(cancellationToken) {
|
|
18
|
+
await Promise.all(this.workers.map((x) => x.startAsync(cancellationToken)));
|
|
19
|
+
}
|
|
20
|
+
async stopAsync(cancellationToken) {
|
|
21
|
+
await Promise.all(this.workers.map((x) => x.stopAsync(cancellationToken)));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
exports.CompositeBenzeneWorker = CompositeBenzeneWorker;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { IRegisterDependency, IServiceResolverFactory } from '@benzenejs/abstractions';
|
|
2
|
+
import { IBenzeneWorker, IMiddlewarePipelineBuilder } from '@benzenejs/abstractions-middleware';
|
|
3
|
+
/**
|
|
4
|
+
* The worker-registration surface a self-hosted `BenzeneStartUp.Configure` builds against: register
|
|
5
|
+
* worker factories, create per-context middleware pipelines, and (at host start) materialize the
|
|
6
|
+
* composite worker for a given resolver factory.
|
|
7
|
+
* Port of Benzene.SelfHost.IBenzeneWorkerStartup.
|
|
8
|
+
*/
|
|
9
|
+
export interface IBenzeneWorkerStartup extends IRegisterDependency {
|
|
10
|
+
/** Registers a factory that builds one worker from the invocation's resolver factory. */
|
|
11
|
+
add(func: (serviceResolverFactory: IServiceResolverFactory) => IBenzeneWorker): void;
|
|
12
|
+
/** Creates a middleware pipeline builder for a new context type. */
|
|
13
|
+
create<TNewContext>(): IMiddlewarePipelineBuilder<TNewContext>;
|
|
14
|
+
/** Materializes all registered workers as a single composite worker over the given resolver factory. */
|
|
15
|
+
createWorker(serviceResolverFactory: IServiceResolverFactory): IBenzeneWorker;
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { IBenzeneServiceContainer } from '@benzenejs/abstractions';
|
|
2
|
+
import { IBenzeneWorker } from '@benzenejs/abstractions-middleware';
|
|
3
|
+
import { IBenzeneWorkerStartup } from './IBenzeneWorkerStartup';
|
|
4
|
+
/** Builds a ready-to-run {@link IBenzeneWorker}. Port of Benzene.SelfHost.IBenzeneWorkerBuilder. */
|
|
5
|
+
export interface IBenzeneWorkerBuilder {
|
|
6
|
+
build(): IBenzeneWorker;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A minimal, fluent self-hosted startup: register services and configure workers inline, then `build`
|
|
10
|
+
* a composite worker - the no-ceremony alternative to a full `BenzeneStartUp` subclass.
|
|
11
|
+
* Port of Benzene.SelfHost.InlineSelfHostedStartUp.
|
|
12
|
+
*
|
|
13
|
+
* C# builds over `new ServiceCollection()` + `MicrosoftBenzeneServiceContainer`/
|
|
14
|
+
* `MicrosoftServiceResolverFactory`; the port uses `DefaultBenzeneServiceContainer` from
|
|
15
|
+
* `@benzenejs/dependencies`, which is both the registration surface and the resolver-factory source - so
|
|
16
|
+
* C#'s separate `Action<IServiceCollection>` becomes an `Action<IBenzeneServiceContainer>`.
|
|
17
|
+
*/
|
|
18
|
+
export declare class InlineSelfHostedStartUp implements IBenzeneWorkerBuilder {
|
|
19
|
+
private servicesAction;
|
|
20
|
+
private appAction;
|
|
21
|
+
configureServices(action: (container: IBenzeneServiceContainer) => void): InlineSelfHostedStartUp;
|
|
22
|
+
configure(action: (app: IBenzeneWorkerStartup) => void): InlineSelfHostedStartUp;
|
|
23
|
+
build(): IBenzeneWorker;
|
|
24
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InlineSelfHostedStartUp = void 0;
|
|
4
|
+
const core_message_handlers_1 = require("@benzenejs/core-message-handlers");
|
|
5
|
+
const dependencies_1 = require("@benzenejs/dependencies");
|
|
6
|
+
const BenzeneWorkerBuilder_1 = require("./BenzeneWorkerBuilder");
|
|
7
|
+
/**
|
|
8
|
+
* A minimal, fluent self-hosted startup: register services and configure workers inline, then `build`
|
|
9
|
+
* a composite worker - the no-ceremony alternative to a full `BenzeneStartUp` subclass.
|
|
10
|
+
* Port of Benzene.SelfHost.InlineSelfHostedStartUp.
|
|
11
|
+
*
|
|
12
|
+
* C# builds over `new ServiceCollection()` + `MicrosoftBenzeneServiceContainer`/
|
|
13
|
+
* `MicrosoftServiceResolverFactory`; the port uses `DefaultBenzeneServiceContainer` from
|
|
14
|
+
* `@benzenejs/dependencies`, which is both the registration surface and the resolver-factory source - so
|
|
15
|
+
* C#'s separate `Action<IServiceCollection>` becomes an `Action<IBenzeneServiceContainer>`.
|
|
16
|
+
*/
|
|
17
|
+
class InlineSelfHostedStartUp {
|
|
18
|
+
servicesAction = () => { };
|
|
19
|
+
appAction = () => { };
|
|
20
|
+
configureServices(action) {
|
|
21
|
+
this.servicesAction = action;
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
configure(action) {
|
|
25
|
+
this.appAction = action;
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
build() {
|
|
29
|
+
const container = new dependencies_1.DefaultBenzeneServiceContainer();
|
|
30
|
+
const app = new BenzeneWorkerBuilder_1.BenzeneWorkerBuilder(container);
|
|
31
|
+
this.appAction(app);
|
|
32
|
+
this.servicesAction(container);
|
|
33
|
+
// Run the boot-time wiring checks before the worker goes live, exactly as every other host does
|
|
34
|
+
// (AwsLambdaStartUpRunner, AzureFunctionStartUpRunner, GoogleCloudFunctionHost, BenzeneHost) - a
|
|
35
|
+
// registration mistake fails the build here rather than on the first polled message.
|
|
36
|
+
return app.createWorker((0, core_message_handlers_1.withStartUpChecks)(container.createServiceResolverFactory()));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
exports.InlineSelfHostedStartUp = InlineSelfHostedStartUp;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { IBenzeneServiceContainer, IServiceResolverFactory } from '@benzenejs/abstractions';
|
|
2
|
+
import { IBenzeneApplicationBuilder, IBenzeneWorker } from '@benzenejs/abstractions-middleware';
|
|
3
|
+
import { BenzeneApplicationBuilder } from '@benzenejs/core-middleware';
|
|
4
|
+
import { IBenzeneWorkerStartup } from './IBenzeneWorkerStartup';
|
|
5
|
+
/**
|
|
6
|
+
* The `IBenzeneApplicationBuilder` for the self-hosted worker platform ("Worker"): adds a worker
|
|
7
|
+
* registry (`workers`) alongside the base pipeline/registration plumbing, and materializes the
|
|
8
|
+
* composite worker at host start.
|
|
9
|
+
* Port of Benzene.SelfHost.WorkerApplicationBuilder.
|
|
10
|
+
*/
|
|
11
|
+
export declare class WorkerApplicationBuilder extends BenzeneApplicationBuilder {
|
|
12
|
+
static readonly platformName = "Worker";
|
|
13
|
+
private readonly workerStartup;
|
|
14
|
+
constructor(benzeneServiceContainer: IBenzeneServiceContainer);
|
|
15
|
+
get workers(): IBenzeneWorkerStartup;
|
|
16
|
+
createWorker(serviceResolverFactory: IServiceResolverFactory): IBenzeneWorker;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Applies worker-host-specific configuration; a no-op on other platforms. Port of the C# extension
|
|
20
|
+
* method `UseWorker` -> a free function taking the builder first.
|
|
21
|
+
*/
|
|
22
|
+
export declare function useWorker(app: IBenzeneApplicationBuilder, configure: (workers: IBenzeneWorkerStartup) => void): IBenzeneApplicationBuilder;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WorkerApplicationBuilder = void 0;
|
|
4
|
+
exports.useWorker = useWorker;
|
|
5
|
+
const core_middleware_1 = require("@benzenejs/core-middleware");
|
|
6
|
+
const BenzeneWorkerBuilder_1 = require("./BenzeneWorkerBuilder");
|
|
7
|
+
/**
|
|
8
|
+
* The `IBenzeneApplicationBuilder` for the self-hosted worker platform ("Worker"): adds a worker
|
|
9
|
+
* registry (`workers`) alongside the base pipeline/registration plumbing, and materializes the
|
|
10
|
+
* composite worker at host start.
|
|
11
|
+
* Port of Benzene.SelfHost.WorkerApplicationBuilder.
|
|
12
|
+
*/
|
|
13
|
+
class WorkerApplicationBuilder extends core_middleware_1.BenzeneApplicationBuilder {
|
|
14
|
+
static platformName = 'Worker';
|
|
15
|
+
workerStartup;
|
|
16
|
+
constructor(benzeneServiceContainer) {
|
|
17
|
+
super(WorkerApplicationBuilder.platformName, benzeneServiceContainer);
|
|
18
|
+
this.workerStartup = new BenzeneWorkerBuilder_1.BenzeneWorkerBuilder(benzeneServiceContainer);
|
|
19
|
+
}
|
|
20
|
+
get workers() {
|
|
21
|
+
return this.workerStartup;
|
|
22
|
+
}
|
|
23
|
+
createWorker(serviceResolverFactory) {
|
|
24
|
+
return this.workerStartup.createWorker(serviceResolverFactory);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.WorkerApplicationBuilder = WorkerApplicationBuilder;
|
|
28
|
+
/**
|
|
29
|
+
* Applies worker-host-specific configuration; a no-op on other platforms. Port of the C# extension
|
|
30
|
+
* method `UseWorker` -> a free function taking the builder first.
|
|
31
|
+
*/
|
|
32
|
+
function useWorker(app, configure) {
|
|
33
|
+
if (app instanceof WorkerApplicationBuilder) {
|
|
34
|
+
configure(app.workers);
|
|
35
|
+
}
|
|
36
|
+
return app;
|
|
37
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port of Benzene.SelfHost - the platform-neutral self-hosted worker model: register long-running
|
|
3
|
+
* workers against a `WorkerApplicationBuilder`, compose them, and dispatch polled work concurrently
|
|
4
|
+
* with per-key ordering via `BoundedConcurrentDispatcher`.
|
|
5
|
+
*
|
|
6
|
+
* The dispatcher's `System.Threading.Channels` usage is re-created in-package as `BoundedChannel` (Node
|
|
7
|
+
* has no BCL Channels). `Benzene.HostedService` (the .NET generic-host `IHostedService` adapter) has no
|
|
8
|
+
* direct JS counterpart - Node has no generic host - but its user-facing shorthand does: `BenzeneHost`
|
|
9
|
+
* here owns a worker process's start/stop lifecycle, so an entry point is
|
|
10
|
+
* `await BenzeneHost.runAsync(StartUp)`.
|
|
11
|
+
*/
|
|
12
|
+
export * from './BoundedChannel';
|
|
13
|
+
export * from './BoundedConcurrentDispatcher';
|
|
14
|
+
export * from './CompositeBenzeneWorker';
|
|
15
|
+
export * from './IBenzeneWorkerStartup';
|
|
16
|
+
export * from './BenzeneWorkerBuilder';
|
|
17
|
+
export * from './WorkerApplicationBuilder';
|
|
18
|
+
export * from './InlineSelfHostedStartUp';
|
|
19
|
+
export * from './BenzeneHost';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
/**
|
|
18
|
+
* Port of Benzene.SelfHost - the platform-neutral self-hosted worker model: register long-running
|
|
19
|
+
* workers against a `WorkerApplicationBuilder`, compose them, and dispatch polled work concurrently
|
|
20
|
+
* with per-key ordering via `BoundedConcurrentDispatcher`.
|
|
21
|
+
*
|
|
22
|
+
* The dispatcher's `System.Threading.Channels` usage is re-created in-package as `BoundedChannel` (Node
|
|
23
|
+
* has no BCL Channels). `Benzene.HostedService` (the .NET generic-host `IHostedService` adapter) has no
|
|
24
|
+
* direct JS counterpart - Node has no generic host - but its user-facing shorthand does: `BenzeneHost`
|
|
25
|
+
* here owns a worker process's start/stop lifecycle, so an entry point is
|
|
26
|
+
* `await BenzeneHost.runAsync(StartUp)`.
|
|
27
|
+
*/
|
|
28
|
+
__exportStar(require("./BoundedChannel"), exports);
|
|
29
|
+
__exportStar(require("./BoundedConcurrentDispatcher"), exports);
|
|
30
|
+
__exportStar(require("./CompositeBenzeneWorker"), exports);
|
|
31
|
+
__exportStar(require("./IBenzeneWorkerStartup"), exports);
|
|
32
|
+
__exportStar(require("./BenzeneWorkerBuilder"), exports);
|
|
33
|
+
__exportStar(require("./WorkerApplicationBuilder"), exports);
|
|
34
|
+
__exportStar(require("./InlineSelfHostedStartUp"), exports);
|
|
35
|
+
__exportStar(require("./BenzeneHost"), exports);
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@benzenejs/self-host",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.2",
|
|
4
|
+
"description": "TypeScript port of Benzene.SelfHost",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@benzenejs/abstractions": "0.1.0-beta.2",
|
|
11
|
+
"@benzenejs/abstractions-middleware": "0.1.0-beta.2",
|
|
12
|
+
"@benzenejs/core-message-handlers": "0.1.0-beta.2",
|
|
13
|
+
"@benzenejs/core-middleware": "0.1.0-beta.2",
|
|
14
|
+
"@benzenejs/dependencies": "0.1.0-beta.2"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
]
|
|
19
|
+
}
|
package/README.md
DELETED