@sincpro/client-framework 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +53 -12
- package/MANIFESTO.md +56 -13
- package/README.md +329 -33
- package/dist/circuit_breaker/domain/circuit.d.ts +29 -0
- package/dist/circuit_breaker/domain/circuit.js +37 -0
- package/dist/circuit_breaker/entrypoint/circuit-breaker.d.ts +56 -0
- package/dist/circuit_breaker/entrypoint/circuit-breaker.js +94 -0
- package/dist/circuit_breaker/index.d.ts +9 -0
- package/dist/circuit_breaker/index.js +9 -0
- package/dist/client/adapters/json-rpc.d.ts +40 -0
- package/dist/client/adapters/json-rpc.js +97 -0
- package/dist/client/adapters/rest-operations.d.ts +19 -0
- package/dist/client/adapters/rest-operations.js +66 -0
- package/dist/client/domain/operation.d.ts +73 -0
- package/dist/client/domain/operation.js +19 -0
- package/dist/client/index.d.ts +5 -1
- package/dist/client/index.js +5 -1
- package/dist/client/services/guarded.d.ts +17 -0
- package/dist/client/services/guarded.js +19 -0
- package/dist/client/services/propagation.d.ts +20 -7
- package/dist/client/services/propagation.js +44 -15
- package/dist/codegen/entrypoint/cli.js +7 -7
- package/dist/codegen/services/client.d.ts +7 -8
- package/dist/codegen/services/client.js +50 -148
- package/dist/codegen/services/scaffold.d.ts +6 -3
- package/dist/codegen/services/scaffold.js +14 -13
- package/dist/common/failures.d.ts +83 -0
- package/dist/common/failures.js +212 -0
- package/dist/common/index.d.ts +1 -0
- package/dist/common/index.js +1 -0
- package/dist/context/domain/execution.d.ts +12 -0
- package/dist/core/domain/bus.d.ts +16 -7
- package/dist/core/domain/bus.js +14 -6
- package/dist/core/domain/dto.d.ts +32 -2
- package/dist/core/domain/dto.js +23 -0
- package/dist/core/domain/pipeline.d.ts +89 -0
- package/dist/core/domain/pipeline.js +1 -0
- package/dist/core/domain/services.d.ts +14 -4
- package/dist/core/domain/services.js +4 -1
- package/dist/core/entrypoint/bounded-context.d.ts +79 -10
- package/dist/core/entrypoint/bounded-context.js +207 -33
- package/dist/core/index.d.ts +4 -2
- package/dist/core/index.js +3 -2
- package/dist/core/infrastructure/listeners.d.ts +28 -0
- package/dist/core/infrastructure/listeners.js +34 -0
- package/dist/core/services/binding.d.ts +2 -2
- package/dist/core/services/binding.js +6 -3
- package/dist/core/services/execution.d.ts +37 -6
- package/dist/core/services/execution.js +86 -7
- package/dist/core/services/interceptors.d.ts +15 -0
- package/dist/core/services/interceptors.js +25 -0
- package/dist/core/services/listening.d.ts +17 -0
- package/dist/core/services/listening.js +25 -0
- package/dist/event_driven/adapters/memory-event-store.d.ts +10 -0
- package/dist/event_driven/adapters/memory-event-store.js +23 -0
- package/dist/event_driven/adapters/stored-queue.d.ts +67 -0
- package/dist/event_driven/adapters/stored-queue.js +192 -0
- package/dist/event_driven/adapters/sync-queue.d.ts +11 -0
- package/dist/event_driven/adapters/sync-queue.js +19 -0
- package/dist/event_driven/domain/envelope.d.ts +28 -0
- package/dist/event_driven/domain/envelope.js +19 -0
- package/dist/event_driven/domain/queue.d.ts +40 -0
- package/dist/event_driven/domain/queue.js +1 -0
- package/dist/event_driven/domain/store.d.ts +56 -0
- package/dist/event_driven/domain/store.js +1 -0
- package/dist/event_driven/entrypoint/publisher.d.ts +26 -0
- package/dist/event_driven/entrypoint/publisher.js +35 -0
- package/dist/event_driven/entrypoint/subscriber.d.ts +34 -0
- package/dist/event_driven/entrypoint/subscriber.js +51 -0
- package/dist/event_driven/index.d.ts +19 -0
- package/dist/event_driven/index.js +17 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +7 -2
- package/dist/observability/domain/logging.d.ts +2 -0
- package/dist/observability/services/logging.js +1 -0
- package/dist/observability/services/outcome.d.ts +6 -6
- package/dist/observability/services/outcome.js +10 -13
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FailureKind } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* Where a circuit stands — for a health screen or a test; a call never needs it.
|
|
4
|
+
*
|
|
5
|
+
* - `closed`: calls go through.
|
|
6
|
+
* - `open`: calls fail at once (`CircuitOpen`) until its time passes.
|
|
7
|
+
* - `half_open`: its time passed; one call goes through to try, the others still fail at once.
|
|
8
|
+
*/
|
|
9
|
+
export type CircuitState = "closed" | "open" | "half_open";
|
|
10
|
+
/**
|
|
11
|
+
* The kinds a circuit counts by default — the backend's `COUNTED`: the dependency did not answer,
|
|
12
|
+
* may not have, asked to be left alone, or failed in a way nobody declared. Every other kind is
|
|
13
|
+
* an answer: a refusal is the dependency working.
|
|
14
|
+
*/
|
|
15
|
+
export declare const COUNTED_KINDS: ReadonlySet<FailureKind>;
|
|
16
|
+
/** Whether a failure says the dependency is not answering — not what it answered. */
|
|
17
|
+
export declare function countedByDefault(error: unknown): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* The dependency a circuit guards failed too often: the call was not made. It declares itself
|
|
20
|
+
* `unavailable` with its wait, so a retry, a stored queue and a screen already know what to do.
|
|
21
|
+
*/
|
|
22
|
+
export declare class CircuitOpen extends Error {
|
|
23
|
+
readonly circuit: string;
|
|
24
|
+
static readonly failureKind = "unavailable";
|
|
25
|
+
readonly reason = "CIRCUIT_OPEN";
|
|
26
|
+
/** Seconds to wait before trying again — one at least. */
|
|
27
|
+
readonly retryAfter: number;
|
|
28
|
+
constructor(circuit: string, retryAfterSeconds: number);
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { failureOf } from "../../common/failures.js";
|
|
2
|
+
/**
|
|
3
|
+
* The kinds a circuit counts by default — the backend's `COUNTED`: the dependency did not answer,
|
|
4
|
+
* may not have, asked to be left alone, or failed in a way nobody declared. Every other kind is
|
|
5
|
+
* an answer: a refusal is the dependency working.
|
|
6
|
+
*/
|
|
7
|
+
export const COUNTED_KINDS = new Set([
|
|
8
|
+
"unavailable",
|
|
9
|
+
"unknown_outcome",
|
|
10
|
+
"exhausted",
|
|
11
|
+
"internal",
|
|
12
|
+
]);
|
|
13
|
+
/** Whether a failure says the dependency is not answering — not what it answered. */
|
|
14
|
+
export function countedByDefault(error) {
|
|
15
|
+
const failure = failureOf(error);
|
|
16
|
+
return !failure.aborted && COUNTED_KINDS.has(failure.kind);
|
|
17
|
+
}
|
|
18
|
+
/** The least wait a refused call is told: a clock that rounds to nothing never says «now». */
|
|
19
|
+
const LEAST_RETRY_AFTER = 1;
|
|
20
|
+
/**
|
|
21
|
+
* The dependency a circuit guards failed too often: the call was not made. It declares itself
|
|
22
|
+
* `unavailable` with its wait, so a retry, a stored queue and a screen already know what to do.
|
|
23
|
+
*/
|
|
24
|
+
export class CircuitOpen extends Error {
|
|
25
|
+
circuit;
|
|
26
|
+
static failureKind = "unavailable";
|
|
27
|
+
reason = "CIRCUIT_OPEN";
|
|
28
|
+
/** Seconds to wait before trying again — one at least. */
|
|
29
|
+
retryAfter;
|
|
30
|
+
constructor(circuit, retryAfterSeconds) {
|
|
31
|
+
const retryAfter = Math.max(LEAST_RETRY_AFTER, Math.ceil(retryAfterSeconds));
|
|
32
|
+
super(`circuit ${circuit} is open — the call was not made; try again in ${retryAfter}s`);
|
|
33
|
+
this.circuit = circuit;
|
|
34
|
+
this.name = "CircuitOpen";
|
|
35
|
+
this.retryAfter = retryAfter;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type CircuitState } from "../domain/circuit.js";
|
|
2
|
+
export type CircuitBreakerOptions = {
|
|
3
|
+
/** Consecutive counted failures that open it (5). */
|
|
4
|
+
readonly failures?: number;
|
|
5
|
+
/** How long it stays open before it lets one call try, in ms (30 s). */
|
|
6
|
+
readonly openFor?: number;
|
|
7
|
+
/** What counts as the dependency not answering (`countedByDefault`). */
|
|
8
|
+
readonly counts?: (error: unknown) => boolean;
|
|
9
|
+
/** Milliseconds since the epoch — what a test moves by hand (`Date.now`). */
|
|
10
|
+
readonly clock?: () => number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Stops calling a dependency that fails repeatedly, and tries it again once it may be back — the
|
|
14
|
+
* backend's `CircuitBreaker`, in this process's memory.
|
|
15
|
+
*
|
|
16
|
+
* const odoo = new CircuitBreaker("odoo", { failures: 3, openFor: 60_000 });
|
|
17
|
+
* await odoo.call(() => client.callModel("sale.order", "create", values));
|
|
18
|
+
* const send = odoo.wrap(sendInvoice); // the same, as a function
|
|
19
|
+
* const transport = guardedTransport(restTransport({ base }), odoo);
|
|
20
|
+
*
|
|
21
|
+
* Context: a phone without coverage or a backend that is down answers every call slowly with the
|
|
22
|
+
* same failure; trying it again at once only spends battery and blocks what waits. After
|
|
23
|
+
* `failures` counted failures in a row it opens: calls fail at once with `CircuitOpen` —
|
|
24
|
+
* `unavailable`, with the time left as its `retryAfter` —, which `failureOf`, a stored queue and a
|
|
25
|
+
* screen already handle. Once `openFor` passed, one call tries (half-open): an answer closes it,
|
|
26
|
+
* a counted failure opens it again. A refusal (`domain`, `not_found`…) is an answer and never
|
|
27
|
+
* counts. Each process keeps its own circuit: a tab, the app.
|
|
28
|
+
*/
|
|
29
|
+
export declare class CircuitBreaker {
|
|
30
|
+
readonly name: string;
|
|
31
|
+
private readonly failures;
|
|
32
|
+
private readonly openFor;
|
|
33
|
+
private readonly counts;
|
|
34
|
+
private readonly clock;
|
|
35
|
+
private streak;
|
|
36
|
+
private openedAt;
|
|
37
|
+
private probing;
|
|
38
|
+
constructor(name: string, options?: CircuitBreakerOptions);
|
|
39
|
+
/** Where it stands now. */
|
|
40
|
+
state(): CircuitState;
|
|
41
|
+
/** Closed again, as if it never failed — what an operator or a test does. */
|
|
42
|
+
reset(): void;
|
|
43
|
+
/**
|
|
44
|
+
* `work` run through the circuit.
|
|
45
|
+
*
|
|
46
|
+
* 1. Open: refused at once (`CircuitOpen`, the time left as its wait); half-open with a call
|
|
47
|
+
* already trying: refused, a second's wait.
|
|
48
|
+
* 2. Run. An answer — or a failure that does not count — closes it.
|
|
49
|
+
* 3. A counted failure: half-open, it opens again; closed, one more in the streak, and at
|
|
50
|
+
* `failures` it opens.
|
|
51
|
+
* Final: what `work` answered, or what it failed with.
|
|
52
|
+
*/
|
|
53
|
+
call<T>(work: () => T | Promise<T>): Promise<T>;
|
|
54
|
+
/** `fn`, every call of it through the circuit. */
|
|
55
|
+
wrap<A extends unknown[], T>(fn: (...args: A) => T | Promise<T>): (...args: A) => Promise<T>;
|
|
56
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { CircuitOpen, countedByDefault, } from "../domain/circuit.js";
|
|
2
|
+
/**
|
|
3
|
+
* Stops calling a dependency that fails repeatedly, and tries it again once it may be back — the
|
|
4
|
+
* backend's `CircuitBreaker`, in this process's memory.
|
|
5
|
+
*
|
|
6
|
+
* const odoo = new CircuitBreaker("odoo", { failures: 3, openFor: 60_000 });
|
|
7
|
+
* await odoo.call(() => client.callModel("sale.order", "create", values));
|
|
8
|
+
* const send = odoo.wrap(sendInvoice); // the same, as a function
|
|
9
|
+
* const transport = guardedTransport(restTransport({ base }), odoo);
|
|
10
|
+
*
|
|
11
|
+
* Context: a phone without coverage or a backend that is down answers every call slowly with the
|
|
12
|
+
* same failure; trying it again at once only spends battery and blocks what waits. After
|
|
13
|
+
* `failures` counted failures in a row it opens: calls fail at once with `CircuitOpen` —
|
|
14
|
+
* `unavailable`, with the time left as its `retryAfter` —, which `failureOf`, a stored queue and a
|
|
15
|
+
* screen already handle. Once `openFor` passed, one call tries (half-open): an answer closes it,
|
|
16
|
+
* a counted failure opens it again. A refusal (`domain`, `not_found`…) is an answer and never
|
|
17
|
+
* counts. Each process keeps its own circuit: a tab, the app.
|
|
18
|
+
*/
|
|
19
|
+
export class CircuitBreaker {
|
|
20
|
+
name;
|
|
21
|
+
failures;
|
|
22
|
+
openFor;
|
|
23
|
+
counts;
|
|
24
|
+
clock;
|
|
25
|
+
streak = 0;
|
|
26
|
+
openedAt;
|
|
27
|
+
probing = false;
|
|
28
|
+
constructor(name, options = {}) {
|
|
29
|
+
this.name = name;
|
|
30
|
+
this.failures = options.failures ?? 5;
|
|
31
|
+
this.openFor = options.openFor ?? 30_000;
|
|
32
|
+
this.counts = options.counts ?? countedByDefault;
|
|
33
|
+
this.clock = options.clock ?? Date.now;
|
|
34
|
+
if (!Number.isInteger(this.failures) || this.failures < 1) {
|
|
35
|
+
throw new TypeError(`circuit ${name}: failures must be 1 or more`);
|
|
36
|
+
}
|
|
37
|
+
if (!(this.openFor > 0))
|
|
38
|
+
throw new TypeError(`circuit ${name}: openFor must be some time`);
|
|
39
|
+
}
|
|
40
|
+
/** Where it stands now. */
|
|
41
|
+
state() {
|
|
42
|
+
if (this.openedAt === undefined)
|
|
43
|
+
return "closed";
|
|
44
|
+
return this.clock() < this.openedAt + this.openFor ? "open" : "half_open";
|
|
45
|
+
}
|
|
46
|
+
/** Closed again, as if it never failed — what an operator or a test does. */
|
|
47
|
+
reset() {
|
|
48
|
+
this.streak = 0;
|
|
49
|
+
this.openedAt = undefined;
|
|
50
|
+
this.probing = false;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `work` run through the circuit.
|
|
54
|
+
*
|
|
55
|
+
* 1. Open: refused at once (`CircuitOpen`, the time left as its wait); half-open with a call
|
|
56
|
+
* already trying: refused, a second's wait.
|
|
57
|
+
* 2. Run. An answer — or a failure that does not count — closes it.
|
|
58
|
+
* 3. A counted failure: half-open, it opens again; closed, one more in the streak, and at
|
|
59
|
+
* `failures` it opens.
|
|
60
|
+
* Final: what `work` answered, or what it failed with.
|
|
61
|
+
*/
|
|
62
|
+
async call(work) {
|
|
63
|
+
const state = this.state();
|
|
64
|
+
if (state === "open") {
|
|
65
|
+
throw new CircuitOpen(this.name, ((this.openedAt ?? 0) + this.openFor - this.clock()) / 1000);
|
|
66
|
+
}
|
|
67
|
+
const probe = state === "half_open";
|
|
68
|
+
if (probe && this.probing)
|
|
69
|
+
throw new CircuitOpen(this.name, 1);
|
|
70
|
+
if (probe)
|
|
71
|
+
this.probing = true;
|
|
72
|
+
try {
|
|
73
|
+
const answer = await work();
|
|
74
|
+
this.reset();
|
|
75
|
+
return answer;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (!this.counts(error)) {
|
|
79
|
+
if (probe)
|
|
80
|
+
this.reset();
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
this.probing = false;
|
|
84
|
+
this.streak += 1;
|
|
85
|
+
if (probe || this.streak >= this.failures)
|
|
86
|
+
this.openedAt = this.clock();
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** `fn`, every call of it through the circuit. */
|
|
91
|
+
wrap(fn) {
|
|
92
|
+
return (...args) => this.call(() => fn(...args));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit breaker: stop calling a dependency that fails repeatedly, and try it again once it may
|
|
3
|
+
* be back — as sincpro_framework's `circuit_breaker`.
|
|
4
|
+
*
|
|
5
|
+
* domain/ CircuitState, CircuitOpen, what counts as the dependency not answering
|
|
6
|
+
* entrypoint/ CircuitBreaker
|
|
7
|
+
*/
|
|
8
|
+
export { CircuitOpen, type CircuitState, COUNTED_KINDS, countedByDefault, } from "./domain/circuit.js";
|
|
9
|
+
export { CircuitBreaker, type CircuitBreakerOptions, } from "./entrypoint/circuit-breaker.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit breaker: stop calling a dependency that fails repeatedly, and try it again once it may
|
|
3
|
+
* be back — as sincpro_framework's `circuit_breaker`.
|
|
4
|
+
*
|
|
5
|
+
* domain/ CircuitState, CircuitOpen, what counts as the dependency not answering
|
|
6
|
+
* entrypoint/ CircuitBreaker
|
|
7
|
+
*/
|
|
8
|
+
export { CircuitOpen, COUNTED_KINDS, countedByDefault, } from "./domain/circuit.js";
|
|
9
|
+
export { CircuitBreaker, } from "./entrypoint/circuit-breaker.js";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type OperationTransport } from "../domain/operation.js";
|
|
2
|
+
import { type FailureKind, type Problem } from "@sincpro/criteria";
|
|
3
|
+
export type JsonRpcOptions = {
|
|
4
|
+
/** Where the gateway answers: `"/rpc"`, `"https://forge.sincpro.com.bo/rpc"`. */
|
|
5
|
+
endpoint: string;
|
|
6
|
+
/** Sent with every request. A function is called fresh each time — a token that rotates. */
|
|
7
|
+
headers?: Record<string, string> | (() => Record<string, string>);
|
|
8
|
+
/** Defaults to the global `fetch`. Inject one for tests, or a platform's. */
|
|
9
|
+
fetch?: typeof fetch;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A JSON-RPC call that failed: the error the gateway answered — its `code` and, when it sent
|
|
13
|
+
* one, its `Problem` — or a request nobody answered (`code: null`). `failureOf` reads its
|
|
14
|
+
* `Problem`; without one, the kind its code stands for (`kindOfJsonRpcCode`).
|
|
15
|
+
*/
|
|
16
|
+
export declare class JsonRpcError extends Error {
|
|
17
|
+
readonly method: string;
|
|
18
|
+
readonly code: number | null;
|
|
19
|
+
readonly problem: Problem | null;
|
|
20
|
+
/** Declared only when the gateway sent no `Problem` — the `Problem` says more when it did. */
|
|
21
|
+
readonly failureKind: FailureKind | undefined;
|
|
22
|
+
constructor(method: string, code: number | null, problem: Problem | null, message?: string);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Operations over JSON-RPC 2.0, as sincpro_framework's RPC gateway publishes them: one `POST` to
|
|
26
|
+
* its endpoint, the operation's method (`operation.rpc.method`) with its input as named params.
|
|
27
|
+
*
|
|
28
|
+
* const operations = jsonRpcOperations({ endpoint: "/rpc", headers: () => session() });
|
|
29
|
+
* const forge = clientOf(operations);
|
|
30
|
+
*
|
|
31
|
+
* 1. The operation's JSON-RPC binding; none: `OperationNotPublished`.
|
|
32
|
+
* 2. The request — `{jsonrpc: "2.0", id, method, params}` — with the call's headers (the run's
|
|
33
|
+
* trace and context) and the gesture's `Idempotency-Key`, cancelled with its signal.
|
|
34
|
+
* 3. An answer with `error`: a `JsonRpcError` with its code and the `Problem` its `data` carries
|
|
35
|
+
* (its `message` the detail when the problem has none).
|
|
36
|
+
* 4. No answer (the network, an `AbortError` aside): a `JsonRpcError` nobody answered — it may
|
|
37
|
+
* have run.
|
|
38
|
+
* Final: the `result`, the operation's answer as the contract describes it.
|
|
39
|
+
*/
|
|
40
|
+
export declare function jsonRpcOperations(options: JsonRpcOptions): OperationTransport;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { OperationNotPublished, } from "../domain/operation.js";
|
|
2
|
+
import { kindOfJsonRpcCode } from "../../common/failures.js";
|
|
3
|
+
import { readProblem } from "@sincpro/criteria";
|
|
4
|
+
/**
|
|
5
|
+
* A JSON-RPC call that failed: the error the gateway answered — its `code` and, when it sent
|
|
6
|
+
* one, its `Problem` — or a request nobody answered (`code: null`). `failureOf` reads its
|
|
7
|
+
* `Problem`; without one, the kind its code stands for (`kindOfJsonRpcCode`).
|
|
8
|
+
*/
|
|
9
|
+
export class JsonRpcError extends Error {
|
|
10
|
+
method;
|
|
11
|
+
code;
|
|
12
|
+
problem;
|
|
13
|
+
/** Declared only when the gateway sent no `Problem` — the `Problem` says more when it did. */
|
|
14
|
+
failureKind;
|
|
15
|
+
constructor(method, code, problem, message) {
|
|
16
|
+
super(message ??
|
|
17
|
+
problem?.detail ??
|
|
18
|
+
problem?.title ??
|
|
19
|
+
`${method} failed (${code ?? "no answer"})`);
|
|
20
|
+
this.method = method;
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.problem = problem;
|
|
23
|
+
this.name = "JsonRpcError";
|
|
24
|
+
this.failureKind =
|
|
25
|
+
problem !== null
|
|
26
|
+
? undefined
|
|
27
|
+
: code === null
|
|
28
|
+
? "unknown_outcome"
|
|
29
|
+
: kindOfJsonRpcCode(code);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
let next = 0;
|
|
33
|
+
/**
|
|
34
|
+
* Operations over JSON-RPC 2.0, as sincpro_framework's RPC gateway publishes them: one `POST` to
|
|
35
|
+
* its endpoint, the operation's method (`operation.rpc.method`) with its input as named params.
|
|
36
|
+
*
|
|
37
|
+
* const operations = jsonRpcOperations({ endpoint: "/rpc", headers: () => session() });
|
|
38
|
+
* const forge = clientOf(operations);
|
|
39
|
+
*
|
|
40
|
+
* 1. The operation's JSON-RPC binding; none: `OperationNotPublished`.
|
|
41
|
+
* 2. The request — `{jsonrpc: "2.0", id, method, params}` — with the call's headers (the run's
|
|
42
|
+
* trace and context) and the gesture's `Idempotency-Key`, cancelled with its signal.
|
|
43
|
+
* 3. An answer with `error`: a `JsonRpcError` with its code and the `Problem` its `data` carries
|
|
44
|
+
* (its `message` the detail when the problem has none).
|
|
45
|
+
* 4. No answer (the network, an `AbortError` aside): a `JsonRpcError` nobody answered — it may
|
|
46
|
+
* have run.
|
|
47
|
+
* Final: the `result`, the operation's answer as the contract describes it.
|
|
48
|
+
*/
|
|
49
|
+
export function jsonRpcOperations(options) {
|
|
50
|
+
const send = options.fetch ?? ((...args) => fetch(...args));
|
|
51
|
+
const own = () => typeof options.headers === "function" ? options.headers() : options.headers;
|
|
52
|
+
return {
|
|
53
|
+
async call(operation, input, call = {}) {
|
|
54
|
+
const method = operation.rpc?.method;
|
|
55
|
+
if (method === undefined)
|
|
56
|
+
throw new OperationNotPublished(operation.identity, "JSON-RPC");
|
|
57
|
+
const id = ++next;
|
|
58
|
+
let response;
|
|
59
|
+
try {
|
|
60
|
+
response = await send(options.endpoint, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: {
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
Accept: "application/json",
|
|
65
|
+
...own(),
|
|
66
|
+
...call.headers,
|
|
67
|
+
...(call.idempotencyKey ? { "Idempotency-Key": call.idempotencyKey } : {}),
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({ jsonrpc: "2.0", id, method, params: input ?? {} }),
|
|
70
|
+
...(call.signal ? { signal: call.signal } : {}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error?.name === "AbortError")
|
|
75
|
+
throw error;
|
|
76
|
+
throw new JsonRpcError(method, null, null, `${method} got no answer`);
|
|
77
|
+
}
|
|
78
|
+
const text = await response.text();
|
|
79
|
+
let answer;
|
|
80
|
+
try {
|
|
81
|
+
answer = JSON.parse(text);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new JsonRpcError(method, null, readProblem(text), `${method} answered no JSON-RPC`);
|
|
85
|
+
}
|
|
86
|
+
if (answer.error !== undefined) {
|
|
87
|
+
const { code, message, data } = answer.error;
|
|
88
|
+
const said = typeof message === "string" ? message : undefined;
|
|
89
|
+
const problem = readProblem(data);
|
|
90
|
+
throw new JsonRpcError(method, typeof code === "number" ? code : null, problem && problem.detail === undefined && said
|
|
91
|
+
? { ...problem, detail: said }
|
|
92
|
+
: problem, said);
|
|
93
|
+
}
|
|
94
|
+
return answer.result;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type OperationTransport } from "../domain/operation.js";
|
|
2
|
+
import { type Transport } from "../domain/transport.js";
|
|
3
|
+
/**
|
|
4
|
+
* Operations over REST, as sincpro_framework's REST gateway publishes them — on a `Transport`
|
|
5
|
+
* (`restTransport`, `memoryTransport`, a platform's):
|
|
6
|
+
*
|
|
7
|
+
* const operations = restOperations(restTransport({ base: "/v1", headers: session }));
|
|
8
|
+
* const forge = clientOf(operations);
|
|
9
|
+
*
|
|
10
|
+
* 1. The operation's REST binding (`operation.rest`); none: `OperationNotPublished`.
|
|
11
|
+
* 2. Each `{field}` of its path taken from the input.
|
|
12
|
+
* 3. A `GET` — a Query — sends every other field as its query (text as it is, `criteria` and any
|
|
13
|
+
* other value as JSON) and reads the answer as it came; one that answers bytes (`streamed`,
|
|
14
|
+
* an export) is downloaded.
|
|
15
|
+
* 4. Anything else — a Command — sends the input as its body, with the gesture's
|
|
16
|
+
* `Idempotency-Key`.
|
|
17
|
+
* Final: the operation's answer, as the contract describes it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function restOperations(transport: Transport): OperationTransport;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { OperationNotPublished, } from "../domain/operation.js";
|
|
2
|
+
import { downloadRoute, readRoute, } from "../domain/transport.js";
|
|
3
|
+
const PATH_KEY = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
4
|
+
/** What a value is in a query string: text as it is, anything else as JSON (`criteria`). */
|
|
5
|
+
function queryValue(value) {
|
|
6
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
7
|
+
}
|
|
8
|
+
/** The path with each `{field}` taken from the input, and the input's fields it did not take. */
|
|
9
|
+
function placed(operation, path, input) {
|
|
10
|
+
const rest = { ...input };
|
|
11
|
+
const route = path.replace(PATH_KEY, (_whole, name) => {
|
|
12
|
+
const value = input[name];
|
|
13
|
+
if (value === undefined || value === null || value === "")
|
|
14
|
+
throw new TypeError(`${operation.identity} needs "${name}" for ${path}`);
|
|
15
|
+
delete rest[name];
|
|
16
|
+
return encodeURIComponent(String(value));
|
|
17
|
+
});
|
|
18
|
+
return { route, rest };
|
|
19
|
+
}
|
|
20
|
+
/** The route with every other field of a GET as its query. */
|
|
21
|
+
function withQuery(route, fields) {
|
|
22
|
+
const query = new URLSearchParams(Object.entries(fields)
|
|
23
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== "")
|
|
24
|
+
.map(([name, value]) => [name, queryValue(value)])).toString();
|
|
25
|
+
return query === "" ? route : `${route}${route.includes("?") ? "&" : "?"}${query}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Operations over REST, as sincpro_framework's REST gateway publishes them — on a `Transport`
|
|
29
|
+
* (`restTransport`, `memoryTransport`, a platform's):
|
|
30
|
+
*
|
|
31
|
+
* const operations = restOperations(restTransport({ base: "/v1", headers: session }));
|
|
32
|
+
* const forge = clientOf(operations);
|
|
33
|
+
*
|
|
34
|
+
* 1. The operation's REST binding (`operation.rest`); none: `OperationNotPublished`.
|
|
35
|
+
* 2. Each `{field}` of its path taken from the input.
|
|
36
|
+
* 3. A `GET` — a Query — sends every other field as its query (text as it is, `criteria` and any
|
|
37
|
+
* other value as JSON) and reads the answer as it came; one that answers bytes (`streamed`,
|
|
38
|
+
* an export) is downloaded.
|
|
39
|
+
* 4. Anything else — a Command — sends the input as its body, with the gesture's
|
|
40
|
+
* `Idempotency-Key`.
|
|
41
|
+
* Final: the operation's answer, as the contract describes it.
|
|
42
|
+
*/
|
|
43
|
+
export function restOperations(transport) {
|
|
44
|
+
return {
|
|
45
|
+
call(operation, input, options = {}) {
|
|
46
|
+
const rest = operation.rest;
|
|
47
|
+
if (rest === undefined)
|
|
48
|
+
return Promise.reject(new OperationNotPublished(operation.identity, "REST"));
|
|
49
|
+
const { signal, ...call } = options;
|
|
50
|
+
const fields = (input ?? {});
|
|
51
|
+
try {
|
|
52
|
+
const { route, rest: others } = placed(operation, rest.path, fields);
|
|
53
|
+
if (rest.method === "GET") {
|
|
54
|
+
const asked = withQuery(route, others);
|
|
55
|
+
return operation.streamed
|
|
56
|
+
? downloadRoute(transport, asked, signal, call)
|
|
57
|
+
: readRoute(transport, asked, signal, call);
|
|
58
|
+
}
|
|
59
|
+
return transport.send(rest.method, route, fields, signal, call);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return Promise.reject(error);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { SendOptions } from "./transport.js";
|
|
2
|
+
/**
|
|
3
|
+
* One operation of a remote service, as its client contract names it: who it is, what it does
|
|
4
|
+
* for its entities, and how each wire reaches it. What a generated client's `operations` holds,
|
|
5
|
+
* one entry per Command or Query the backend publishes.
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* identity: "sincpro-forge-project.CommandSendIssue",
|
|
9
|
+
* role: "command", query: false, entities: ["Issue"], targets: { Issue: "issue_id" },
|
|
10
|
+
* rest: { method: "POST", path: "/issues/{issue_id}/messages" },
|
|
11
|
+
* rpc: { method: "project.send_issue" },
|
|
12
|
+
* }
|
|
13
|
+
*/
|
|
14
|
+
export type RemoteOperation = {
|
|
15
|
+
/** `<bounded context>.<Command>`: the same on every wire. */
|
|
16
|
+
readonly identity: string;
|
|
17
|
+
/** What it is for its entity: `search`, `get`, `edit`, `export`, `command`… */
|
|
18
|
+
readonly role: string;
|
|
19
|
+
/** It writes nothing: retrying or caching it is safe. */
|
|
20
|
+
readonly query: boolean;
|
|
21
|
+
readonly entities: readonly string[];
|
|
22
|
+
readonly targets: Readonly<Record<string, string>>;
|
|
23
|
+
/** It answers bytes as they are produced (an export), not JSON. */
|
|
24
|
+
readonly streamed?: boolean;
|
|
25
|
+
readonly rest?: {
|
|
26
|
+
readonly method: string;
|
|
27
|
+
readonly path: string;
|
|
28
|
+
};
|
|
29
|
+
readonly rpc?: {
|
|
30
|
+
readonly method: string;
|
|
31
|
+
};
|
|
32
|
+
readonly grpc?: {
|
|
33
|
+
readonly method: string;
|
|
34
|
+
};
|
|
35
|
+
readonly mcp?: {
|
|
36
|
+
readonly tool: string;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* What one call carries besides its input: its headers (the run's trace and context), what
|
|
41
|
+
* cancels it, and — for a write — the gesture's idempotency key.
|
|
42
|
+
*/
|
|
43
|
+
export type OperationOptions = SendOptions & {
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Calls an operation of a remote service by who it is — the port every wire implements, and
|
|
48
|
+
* what a generated client is made over:
|
|
49
|
+
*
|
|
50
|
+
* clientOf(restOperations(restTransport({ base: "/v1" }))); // REST
|
|
51
|
+
* clientOf(jsonRpcOperations({ endpoint: "/rpc" })); // JSON-RPC
|
|
52
|
+
* clientOf(grpcOperations(channel)); // gRPC — a platform's adapter
|
|
53
|
+
*
|
|
54
|
+
* Context: the contract publishes each operation once, with how every wire reaches it; the
|
|
55
|
+
* generated client names only the operation and its input, never a route, a verb or a method.
|
|
56
|
+
* Which wire it travels on is the adapter an application injects — the backend's, another
|
|
57
|
+
* backend's, a platform's —, so the same client and the same Features run over any of them. An
|
|
58
|
+
* adapter answers the operation's answer as the contract describes it, and fails with errors
|
|
59
|
+
* `failureOf` reads (a `Problem`, or a declared `failureKind`).
|
|
60
|
+
*/
|
|
61
|
+
export interface OperationTransport {
|
|
62
|
+
call<T>(operation: RemoteOperation, input: unknown, options?: OperationOptions): Promise<T>;
|
|
63
|
+
}
|
|
64
|
+
/** An operation the wire an adapter speaks does not publish: a client asked for what is not there. */
|
|
65
|
+
export declare class OperationNotPublished extends Error {
|
|
66
|
+
readonly operation: string;
|
|
67
|
+
readonly wire: string;
|
|
68
|
+
static readonly failureKind = "not_found";
|
|
69
|
+
readonly reason = "OPERATION_NOT_PUBLISHED";
|
|
70
|
+
constructor(operation: string, wire: string);
|
|
71
|
+
}
|
|
72
|
+
/** Whether a value is an `OperationTransport`: what a run binds to itself even without `BIND`. */
|
|
73
|
+
export declare function isOperationTransport(value: unknown): value is OperationTransport;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** An operation the wire an adapter speaks does not publish: a client asked for what is not there. */
|
|
2
|
+
export class OperationNotPublished extends Error {
|
|
3
|
+
operation;
|
|
4
|
+
wire;
|
|
5
|
+
static failureKind = "not_found";
|
|
6
|
+
reason = "OPERATION_NOT_PUBLISHED";
|
|
7
|
+
constructor(operation, wire) {
|
|
8
|
+
super(`${operation} is not published over ${wire}: its contract has no ${wire} binding`);
|
|
9
|
+
this.operation = operation;
|
|
10
|
+
this.wire = wire;
|
|
11
|
+
this.name = "OperationNotPublished";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Whether a value is an `OperationTransport`: what a run binds to itself even without `BIND`. */
|
|
15
|
+
export function isOperationTransport(value) {
|
|
16
|
+
return (value !== null &&
|
|
17
|
+
typeof value === "object" &&
|
|
18
|
+
typeof value.call === "function");
|
|
19
|
+
}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* adapters/ REST, memory, and Parquet (optional entry `./parquet`)
|
|
8
8
|
* services/ discovery, retry keys, propagation (`traceparent`, `sincpro-context`)
|
|
9
9
|
*/
|
|
10
|
+
export { JsonRpcError, jsonRpcOperations, type JsonRpcOptions, } from "./adapters/json-rpc.js";
|
|
10
11
|
export { type MemoryOptions, type MemoryTable, memoryTransport, } from "./adapters/memory.js";
|
|
11
12
|
export { type RestOptions, restTransport, urlOf, type VersionDrift, } from "./adapters/rest.js";
|
|
13
|
+
export { restOperations } from "./adapters/rest-operations.js";
|
|
12
14
|
export { type ClientContract, entityKeyOf, kindOf, metasOf, versionsOf, } from "./domain/contract.js";
|
|
13
15
|
export { bodyFor, type CommandAnswers, type CommandBodies, type CommandRoute, type Entity, entity, type EntityRef, type EntityRoutes, type ExportFormat, exportRoute, listRoute, NEW_PARAM, pathFor, PICK_PARAM, QUERY_PARAM, SCOPE_PARAM, scopeOfRef, startingValuesOf, type SummaryFields, type SummaryPart, TRAIL_PARAM, WITH_PARAM, type WriteRoute, writeRoute, } from "./domain/entity.js";
|
|
16
|
+
export { OperationNotPublished, type OperationOptions, type OperationTransport, type RemoteOperation, } from "./domain/operation.js";
|
|
14
17
|
export { type CallOptions, type Decoder, type Download, downloadRoute, type Draft, type Method, type One, readRoute, type SendOptions, type Transport, TransportError, } from "./domain/transport.js";
|
|
15
|
-
export {
|
|
18
|
+
export { guardedTransport } from "./services/guarded.js";
|
|
19
|
+
export { bindable, bindOperations, bindTransport, headersOf, } from "./services/propagation.js";
|
|
16
20
|
export { type RetryKeys, retryKeys, unanswered, } from "./services/retry.js";
|
package/dist/client/index.js
CHANGED
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* adapters/ REST, memory, and Parquet (optional entry `./parquet`)
|
|
8
8
|
* services/ discovery, retry keys, propagation (`traceparent`, `sincpro-context`)
|
|
9
9
|
*/
|
|
10
|
+
export { JsonRpcError, jsonRpcOperations, } from "./adapters/json-rpc.js";
|
|
10
11
|
export { memoryTransport, } from "./adapters/memory.js";
|
|
11
12
|
export { restTransport, urlOf, } from "./adapters/rest.js";
|
|
13
|
+
export { restOperations } from "./adapters/rest-operations.js";
|
|
12
14
|
export { entityKeyOf, kindOf, metasOf, versionsOf, } from "./domain/contract.js";
|
|
13
15
|
export { bodyFor, entity, exportRoute, listRoute, NEW_PARAM, pathFor, PICK_PARAM, QUERY_PARAM, SCOPE_PARAM, scopeOfRef, startingValuesOf, TRAIL_PARAM, WITH_PARAM, writeRoute, } from "./domain/entity.js";
|
|
16
|
+
export { OperationNotPublished, } from "./domain/operation.js";
|
|
14
17
|
export { downloadRoute, readRoute, TransportError, } from "./domain/transport.js";
|
|
15
|
-
export {
|
|
18
|
+
export { guardedTransport } from "./services/guarded.js";
|
|
19
|
+
export { bindable, bindOperations, bindTransport, headersOf, } from "./services/propagation.js";
|
|
16
20
|
export { retryKeys, unanswered, } from "./services/retry.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CircuitBreaker } from "../../circuit_breaker/entrypoint/circuit-breaker.js";
|
|
2
|
+
import { type OperationTransport } from "../domain/operation.js";
|
|
3
|
+
import type { Transport } from "../domain/transport.js";
|
|
4
|
+
/**
|
|
5
|
+
* `transport`, every call through `breaker` — an `OperationTransport` of any wire, or REST's
|
|
6
|
+
* `Transport`:
|
|
7
|
+
*
|
|
8
|
+
* const operations = guardedTransport(jsonRpcOperations({ endpoint }), new CircuitBreaker("forge"));
|
|
9
|
+
* project.addDependency("client", clientOf(operations));
|
|
10
|
+
*
|
|
11
|
+
* Context: the circuit belongs where the dependency is — the transport —, not in each Feature. A
|
|
12
|
+
* run binds it as it binds any transport, so the trace and the context still travel; an open
|
|
13
|
+
* circuit fails at once with `CircuitOpen` (`unavailable`, with its wait), which the outcome, a
|
|
14
|
+
* stored queue and a screen already read.
|
|
15
|
+
*/
|
|
16
|
+
export declare function guardedTransport(transport: OperationTransport, breaker: CircuitBreaker): OperationTransport;
|
|
17
|
+
export declare function guardedTransport(transport: Transport, breaker: CircuitBreaker): Transport;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isOperationTransport, } from "../domain/operation.js";
|
|
2
|
+
export function guardedTransport(transport, breaker) {
|
|
3
|
+
if (isOperationTransport(transport))
|
|
4
|
+
return {
|
|
5
|
+
call: (operation, input, options) => breaker.call(() => transport.call(operation, input, options)),
|
|
6
|
+
};
|
|
7
|
+
const guarded = {
|
|
8
|
+
list: (route, criteria, signal, options) => breaker.call(() => transport.list(route, criteria, signal, options)),
|
|
9
|
+
one: (route, signal, options) => breaker.call(() => transport.one(route, signal, options)),
|
|
10
|
+
send: (method, route, body, signal, options) => breaker.call(() => transport.send(method, route, body, signal, options)),
|
|
11
|
+
};
|
|
12
|
+
const read = transport.read;
|
|
13
|
+
if (read !== undefined)
|
|
14
|
+
guarded.read = (route, signal, options) => breaker.call(() => read.call(transport, route, signal, options));
|
|
15
|
+
const download = transport.download;
|
|
16
|
+
if (download !== undefined)
|
|
17
|
+
guarded.download = (route, signal, options) => breaker.call(() => download.call(transport, route, signal, options));
|
|
18
|
+
return guarded;
|
|
19
|
+
}
|