@forgezero/providers 0.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/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/binance.d.ts +27 -0
- package/dist/binance.js +493 -0
- package/dist/chain.d.ts +99 -0
- package/dist/chain.js +279 -0
- package/dist/database.d.ts +49 -0
- package/dist/database.js +209 -0
- package/dist/email.d.ts +68 -0
- package/dist/email.js +278 -0
- package/dist/http.d.ts +100 -0
- package/dist/http.js +283 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +139 -0
- package/dist/pool.d.ts +112 -0
- package/dist/pool.js +376 -0
- package/dist/storage.d.ts +137 -0
- package/dist/storage.js +441 -0
- package/package.json +82 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @forgezero/providers — service registry with priority, health and fallback.
|
|
3
|
+
*
|
|
4
|
+
* Zero runtime dependencies. It declares two interfaces and someone injects
|
|
5
|
+
* them; it never imports `@forgezero/vault`. That constraint is what makes it
|
|
6
|
+
* usable without ForgeZero at all — if it reached for the vault directly it
|
|
7
|
+
* would be a ForgeZero package with a public name.
|
|
8
|
+
*
|
|
9
|
+
* priority providers are tried in the order the DATABASE says, not the code
|
|
10
|
+
* health three strikes marks a provider offline; any success clears them
|
|
11
|
+
* fallback the next provider is tried, unless the error says not to
|
|
12
|
+
*
|
|
13
|
+
* That last part is what most registries get wrong. See `FailureKind`.
|
|
14
|
+
*/
|
|
15
|
+
export declare class ProviderError extends Error {
|
|
16
|
+
readonly code: string;
|
|
17
|
+
/**
|
|
18
|
+
* Structured detail for `classify`, optional and additive.
|
|
19
|
+
*
|
|
20
|
+
* Without it a provider has to decide `terminal` vs `backoff` by matching
|
|
21
|
+
* substrings of its own error message, which breaks the first time a
|
|
22
|
+
* vendor rewords one. An HTTP status or a JSON-RPC code is the fact the
|
|
23
|
+
* decision actually rests on, so it travels with the error.
|
|
24
|
+
*/
|
|
25
|
+
readonly details?: Readonly<Record<string, unknown>> | undefined;
|
|
26
|
+
constructor(code: string, message: string,
|
|
27
|
+
/**
|
|
28
|
+
* Structured detail for `classify`, optional and additive.
|
|
29
|
+
*
|
|
30
|
+
* Without it a provider has to decide `terminal` vs `backoff` by matching
|
|
31
|
+
* substrings of its own error message, which breaks the first time a
|
|
32
|
+
* vendor rewords one. An HTTP status or a JSON-RPC code is the fact the
|
|
33
|
+
* decision actually rests on, so it travels with the error.
|
|
34
|
+
*/
|
|
35
|
+
details?: Readonly<Record<string, unknown>> | undefined);
|
|
36
|
+
}
|
|
37
|
+
/** Where credentials come from. The interface IS the contract. */
|
|
38
|
+
export interface CredentialSource {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
get(reference: string, field: string): Promise<string>;
|
|
41
|
+
}
|
|
42
|
+
/** Environment variables. `smtp` + `password` → `SMTP_PASSWORD`. */
|
|
43
|
+
export declare function envCredentials(env: Record<string, string | undefined>): CredentialSource;
|
|
44
|
+
/**
|
|
45
|
+
* Try each source in order, swallowing failures until one answers.
|
|
46
|
+
*
|
|
47
|
+
* This is what makes bootstrap work. A locked vault THROWS, and if that
|
|
48
|
+
* propagated it would take down the environment fallback that exists to recover
|
|
49
|
+
* it. The swallow is load-bearing, not defensive coding — and it is why there is
|
|
50
|
+
* a test that locks the vault and asserts mail still sends.
|
|
51
|
+
*/
|
|
52
|
+
export declare function chainCredentials(...sources: readonly CredentialSource[]): CredentialSource;
|
|
53
|
+
export interface ProviderHealth {
|
|
54
|
+
strikes: number;
|
|
55
|
+
status: 'ok' | 'degraded' | 'offline';
|
|
56
|
+
lastFailureAtTs?: number;
|
|
57
|
+
}
|
|
58
|
+
export interface ProviderConfig {
|
|
59
|
+
providerId: string;
|
|
60
|
+
/** Named relay, for providers configurable more than once. */
|
|
61
|
+
instanceKey?: string;
|
|
62
|
+
priority: number;
|
|
63
|
+
enabled: boolean;
|
|
64
|
+
config: Record<string, unknown>;
|
|
65
|
+
/** Names a vault entry. NEVER the secret itself — a dump yields metadata. */
|
|
66
|
+
secretRef: string;
|
|
67
|
+
health?: ProviderHealth;
|
|
68
|
+
}
|
|
69
|
+
/** Where the ordered list lives: a database, a file, constants. */
|
|
70
|
+
export interface ConfigSource {
|
|
71
|
+
readonly name: string;
|
|
72
|
+
list(serviceKey: string): Promise<readonly ProviderConfig[]>;
|
|
73
|
+
/** Persisted, because strikes that reset on restart retry a dead provider forever. */
|
|
74
|
+
recordHealth(serviceKey: string, providerId: string, health: ProviderHealth): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
export declare function staticConfig(services: Record<string, readonly ProviderConfig[]>): ConfigSource;
|
|
77
|
+
/**
|
|
78
|
+
* How a failure should be treated.
|
|
79
|
+
*
|
|
80
|
+
* terminal OUR payload is malformed. Every provider will reject it, so
|
|
81
|
+
* trying the next buries the real error under identical ones.
|
|
82
|
+
* retryable THIS provider or key is bad. The next may be fine.
|
|
83
|
+
* backoff rate limited. Skip without a strike — it is working, we are
|
|
84
|
+
* asking too fast, and taking it offline punishes it for that.
|
|
85
|
+
*
|
|
86
|
+
* Collapsing these into "it failed" gives a system that either gives up too
|
|
87
|
+
* early or hammers every provider with a request none can accept.
|
|
88
|
+
*/
|
|
89
|
+
export type FailureKind = 'terminal' | 'retryable' | 'backoff';
|
|
90
|
+
export interface InvokeContext {
|
|
91
|
+
config: Record<string, unknown>;
|
|
92
|
+
secret(field: string): Promise<string>;
|
|
93
|
+
signal?: AbortSignal;
|
|
94
|
+
}
|
|
95
|
+
export interface ProviderSpec<Args = never, Result = never> {
|
|
96
|
+
id: string;
|
|
97
|
+
service: string;
|
|
98
|
+
label: string;
|
|
99
|
+
multiInstance?: boolean;
|
|
100
|
+
/** JSON Schema. `writeOnly` fields route to secret storage. */
|
|
101
|
+
credentials?: Record<string, unknown>;
|
|
102
|
+
config?: Record<string, unknown>;
|
|
103
|
+
invoke(context: InvokeContext, args: Args): Promise<Result>;
|
|
104
|
+
classify(error: unknown): FailureKind;
|
|
105
|
+
}
|
|
106
|
+
export declare function defineProvider<Args, Result>(spec: ProviderSpec<Args, Result>): ProviderSpec<Args, Result>;
|
|
107
|
+
export declare const STRIKES_TO_OFFLINE = 3;
|
|
108
|
+
export interface Attempt {
|
|
109
|
+
providerId: string;
|
|
110
|
+
outcome: 'sent' | 'skipped' | 'failed';
|
|
111
|
+
kind?: FailureKind;
|
|
112
|
+
error?: string;
|
|
113
|
+
}
|
|
114
|
+
export interface CallResult<Result> {
|
|
115
|
+
ok: boolean;
|
|
116
|
+
result?: Result;
|
|
117
|
+
provider?: string;
|
|
118
|
+
attempts: readonly Attempt[];
|
|
119
|
+
error?: ProviderError;
|
|
120
|
+
}
|
|
121
|
+
export interface RegistryOptions {
|
|
122
|
+
credentials: CredentialSource;
|
|
123
|
+
config: ConfigSource;
|
|
124
|
+
/**
|
|
125
|
+
* Deliberately `any` in both parameters.
|
|
126
|
+
*
|
|
127
|
+
* A registry is heterogeneous by definition — an email provider and a storage
|
|
128
|
+
* provider have nothing in common but the interface — so this list cannot be
|
|
129
|
+
* given one useful pair of type arguments. It was `ProviderSpec<never, never>`,
|
|
130
|
+
* which is unsatisfiable in the `Result` position: every real provider had to
|
|
131
|
+
* be cast with `as never` at the call site. The type parameters earn their
|
|
132
|
+
* keep at the DEFINITION site, where `defineProvider` still checks that
|
|
133
|
+
* `invoke` matches what the provider claims to take and return.
|
|
134
|
+
*/
|
|
135
|
+
providers: readonly ProviderSpec<any, any>[];
|
|
136
|
+
before?: (context: {
|
|
137
|
+
service: string;
|
|
138
|
+
provider: string;
|
|
139
|
+
}) => void;
|
|
140
|
+
after?: (result: CallResult<unknown>) => void;
|
|
141
|
+
}
|
|
142
|
+
export declare function nextHealth(current: ProviderHealth | undefined, kind: FailureKind | 'success'): ProviderHealth;
|
|
143
|
+
export declare function createRegistry(options: RegistryOptions): {
|
|
144
|
+
call: <Result>(serviceKey: string, args: unknown) => Promise<CallResult<Result>>;
|
|
145
|
+
};
|
|
146
|
+
/** Passed to `registry.call('email', message)`. */
|
|
147
|
+
export interface EmailMessage {
|
|
148
|
+
to: string | readonly string[];
|
|
149
|
+
subject: string;
|
|
150
|
+
html?: string;
|
|
151
|
+
text?: string;
|
|
152
|
+
from?: string;
|
|
153
|
+
replyTo?: string;
|
|
154
|
+
}
|
|
155
|
+
export declare const VERSION = "0.1.0";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
class ProviderError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
details;
|
|
5
|
+
constructor(code, message, details) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.details = details;
|
|
9
|
+
this.name = "ProviderError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function envCredentials(env) {
|
|
13
|
+
return {
|
|
14
|
+
name: "env",
|
|
15
|
+
async get(reference, field) {
|
|
16
|
+
const key = `${reference}_${field}`.replace(/[.-]/g, "_").toUpperCase();
|
|
17
|
+
const value = env[key];
|
|
18
|
+
if (value === undefined) {
|
|
19
|
+
throw new ProviderError("CREDENTIAL_MISSING", `Set ${key} in the environment.`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function chainCredentials(...sources) {
|
|
26
|
+
return {
|
|
27
|
+
name: sources.map((source) => source.name).join("+"),
|
|
28
|
+
async get(reference, field) {
|
|
29
|
+
let last;
|
|
30
|
+
for (const source of sources) {
|
|
31
|
+
try {
|
|
32
|
+
return await source.get(reference, field);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
last = error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw last instanceof Error ? last : new ProviderError("CREDENTIAL_MISSING", `No source held ${reference}.${field}.`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function staticConfig(services) {
|
|
42
|
+
const health = new Map;
|
|
43
|
+
return {
|
|
44
|
+
name: "static",
|
|
45
|
+
async list(serviceKey) {
|
|
46
|
+
return (services[serviceKey] ?? []).map((provider) => ({
|
|
47
|
+
...provider,
|
|
48
|
+
health: health.get(`${serviceKey}:${provider.providerId}`) ?? provider.health
|
|
49
|
+
}));
|
|
50
|
+
},
|
|
51
|
+
async recordHealth(serviceKey, providerId, next) {
|
|
52
|
+
health.set(`${serviceKey}:${providerId}`, next);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function defineProvider(spec) {
|
|
57
|
+
return spec;
|
|
58
|
+
}
|
|
59
|
+
var STRIKES_TO_OFFLINE = 3;
|
|
60
|
+
function nextHealth(current, kind) {
|
|
61
|
+
if (kind === "success")
|
|
62
|
+
return { strikes: 0, status: "ok" };
|
|
63
|
+
if (kind === "backoff")
|
|
64
|
+
return current ?? { strikes: 0, status: "ok" };
|
|
65
|
+
const strikes = (current?.strikes ?? 0) + 1;
|
|
66
|
+
return {
|
|
67
|
+
strikes,
|
|
68
|
+
status: strikes >= STRIKES_TO_OFFLINE ? "offline" : "degraded",
|
|
69
|
+
lastFailureAtTs: Date.now()
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function createRegistry(options) {
|
|
73
|
+
const byId = new Map(options.providers.map((provider) => [provider.id, provider]));
|
|
74
|
+
async function call(serviceKey, args) {
|
|
75
|
+
const configured = [...await options.config.list(serviceKey)].filter((provider) => provider.enabled).sort((a, b) => a.priority - b.priority);
|
|
76
|
+
const attempts = [];
|
|
77
|
+
for (const entry of configured) {
|
|
78
|
+
const spec = byId.get(entry.providerId);
|
|
79
|
+
if (!spec) {
|
|
80
|
+
attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "not registered" });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (entry.health?.status === "offline") {
|
|
84
|
+
attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "offline" });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
options.before?.({ service: serviceKey, provider: entry.providerId });
|
|
88
|
+
try {
|
|
89
|
+
const result = await spec.invoke({
|
|
90
|
+
config: entry.config,
|
|
91
|
+
secret: (field) => options.credentials.get(entry.secretRef, field)
|
|
92
|
+
}, args);
|
|
93
|
+
attempts.push({ providerId: entry.providerId, outcome: "sent" });
|
|
94
|
+
await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, "success"));
|
|
95
|
+
const sent = { ok: true, result, provider: entry.providerId, attempts };
|
|
96
|
+
options.after?.(sent);
|
|
97
|
+
return sent;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
const kind = spec.classify(error);
|
|
100
|
+
attempts.push({
|
|
101
|
+
providerId: entry.providerId,
|
|
102
|
+
outcome: "failed",
|
|
103
|
+
kind,
|
|
104
|
+
error: error instanceof Error ? error.message : String(error)
|
|
105
|
+
});
|
|
106
|
+
await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, kind));
|
|
107
|
+
if (kind === "terminal") {
|
|
108
|
+
const refused = {
|
|
109
|
+
ok: false,
|
|
110
|
+
attempts,
|
|
111
|
+
error: new ProviderError("PAYLOAD_REJECTED", "The request was refused as malformed; no provider will accept it.")
|
|
112
|
+
};
|
|
113
|
+
options.after?.(refused);
|
|
114
|
+
return refused;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const failed = {
|
|
119
|
+
ok: false,
|
|
120
|
+
attempts,
|
|
121
|
+
error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider is configured for "${serviceKey}".` : `Every provider for "${serviceKey}" failed or was skipped.`)
|
|
122
|
+
};
|
|
123
|
+
options.after?.(failed);
|
|
124
|
+
return failed;
|
|
125
|
+
}
|
|
126
|
+
return { call };
|
|
127
|
+
}
|
|
128
|
+
var VERSION = "0.1.0";
|
|
129
|
+
export {
|
|
130
|
+
staticConfig,
|
|
131
|
+
nextHealth,
|
|
132
|
+
envCredentials,
|
|
133
|
+
defineProvider,
|
|
134
|
+
createRegistry,
|
|
135
|
+
chainCredentials,
|
|
136
|
+
VERSION,
|
|
137
|
+
STRIKES_TO_OFFLINE,
|
|
138
|
+
ProviderError
|
|
139
|
+
};
|
package/dist/pool.d.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which address an outbound request leaves by, and how many it may carry.
|
|
3
|
+
*
|
|
4
|
+
* Venues rate-limit per IP. One address gets a fixed allowance, so placing more
|
|
5
|
+
* orders than that means more addresses — and choosing between them has to be
|
|
6
|
+
* accounted centrally or every caller spends the same allowance independently.
|
|
7
|
+
*
|
|
8
|
+
* ## This adds no counting
|
|
9
|
+
*
|
|
10
|
+
* `@forgezero/providers/http` already reserves before a call and settles from
|
|
11
|
+
* the response, and its budget is keyed by an arbitrary string rather than a
|
|
12
|
+
* real hostname. So the per-address cap IS that budget with the address as the
|
|
13
|
+
* key. What this module adds is the CHOICE of address and the health of each
|
|
14
|
+
* one, which genuinely did not exist.
|
|
15
|
+
*
|
|
16
|
+
* Writing a second counter here would mean two answers to "how much is left",
|
|
17
|
+
* and they would disagree the first time a response settled differently from
|
|
18
|
+
* its reservation.
|
|
19
|
+
*
|
|
20
|
+
* ## Sticky by key, not round-robin
|
|
21
|
+
*
|
|
22
|
+
* Orders for one account should leave by one address where possible. A venue
|
|
23
|
+
* sees a coherent client rather than an account whose requests arrive from four
|
|
24
|
+
* places, and — more practically — a per-address weight limit is easier to
|
|
25
|
+
* reason about when one account's burst lands in one bucket rather than
|
|
26
|
+
* smeared across all of them.
|
|
27
|
+
*
|
|
28
|
+
* Stickiness is a preference, never a guarantee. When the preferred address is
|
|
29
|
+
* exhausted or unhealthy the request moves, because refusing an order to
|
|
30
|
+
* preserve affinity is the wrong trade.
|
|
31
|
+
*/
|
|
32
|
+
export declare class PoolExhausted extends Error {
|
|
33
|
+
readonly addresses: number;
|
|
34
|
+
readonly retryAfterMs: number;
|
|
35
|
+
constructor(addresses: number, retryAfterMs: number);
|
|
36
|
+
}
|
|
37
|
+
export declare class PoolEmpty extends Error {
|
|
38
|
+
constructor();
|
|
39
|
+
}
|
|
40
|
+
export interface PoolAddress {
|
|
41
|
+
/** The outbound address, or a label for one. Also the budget key. */
|
|
42
|
+
id: string;
|
|
43
|
+
/** Local address to bind to, when the host can. Absent means the default route. */
|
|
44
|
+
bind?: string;
|
|
45
|
+
enabled: boolean;
|
|
46
|
+
/** Set when a venue has banned this address. Cleared by hand or by expiry. */
|
|
47
|
+
blockedUntilMs?: number;
|
|
48
|
+
}
|
|
49
|
+
export interface PoolOptions {
|
|
50
|
+
addresses: readonly PoolAddress[];
|
|
51
|
+
/** Requests per address per window. The owner's figure is 50. */
|
|
52
|
+
perAddress: number;
|
|
53
|
+
windowMs: number;
|
|
54
|
+
/** Stop at a fraction of the limit. The venue's window boundary is not ours. */
|
|
55
|
+
headroom?: number;
|
|
56
|
+
now?: () => number;
|
|
57
|
+
/**
|
|
58
|
+
* Off means every request takes the default route and no accounting happens.
|
|
59
|
+
*
|
|
60
|
+
* Correct for a single-node install, and being able to turn it off is what
|
|
61
|
+
* makes a routing problem debuggable: an operator can remove the routing.
|
|
62
|
+
*/
|
|
63
|
+
enabled?: boolean;
|
|
64
|
+
}
|
|
65
|
+
export interface Lease {
|
|
66
|
+
/** The address chosen, or null when the pool is off. */
|
|
67
|
+
address: PoolAddress | null;
|
|
68
|
+
/** Call with the true cost once the response is in. */
|
|
69
|
+
settle: (actualCost: number) => void;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A pool over the shared budget.
|
|
73
|
+
*
|
|
74
|
+
* Deliberately not a class: the state that matters lives in the budget ledger
|
|
75
|
+
* in `http.ts`, which is module-level and shared by every client in the
|
|
76
|
+
* process. A pool holding its own counters would be a per-instance view of a
|
|
77
|
+
* per-process limit, which is the bug this exists to avoid.
|
|
78
|
+
*/
|
|
79
|
+
export declare function createAddressPool(options: PoolOptions): {
|
|
80
|
+
/**
|
|
81
|
+
* Take an address for one request.
|
|
82
|
+
*
|
|
83
|
+
* Reserves BEFORE the request, like the underlying budget: counting on the
|
|
84
|
+
* way back lets a burst of concurrent orders all pass the check and
|
|
85
|
+
* collectively exceed the limit — which is precisely what happens when a
|
|
86
|
+
* strategy wakes up.
|
|
87
|
+
*/
|
|
88
|
+
take(args?: {
|
|
89
|
+
key?: string;
|
|
90
|
+
cost?: number;
|
|
91
|
+
}): Lease;
|
|
92
|
+
/**
|
|
93
|
+
* Mark an address banned by the venue.
|
|
94
|
+
*
|
|
95
|
+
* A 418 from Binance means this address is banned and hammering makes it
|
|
96
|
+
* longer, so it leaves the rotation entirely rather than merely running out
|
|
97
|
+
* of budget — a budget resets on its own and a ban does not.
|
|
98
|
+
*/
|
|
99
|
+
block(id: string, forMs: number): void;
|
|
100
|
+
/** What each address has spent. For an admin screen and for a decision to scale. */
|
|
101
|
+
state(): {
|
|
102
|
+
id: string;
|
|
103
|
+
spent: number;
|
|
104
|
+
limit: number;
|
|
105
|
+
blocked: boolean;
|
|
106
|
+
enabled: boolean;
|
|
107
|
+
}[];
|
|
108
|
+
/** Total capacity per window, so a screen can say "you can place N". */
|
|
109
|
+
capacity: () => number;
|
|
110
|
+
enabled: boolean;
|
|
111
|
+
};
|
|
112
|
+
export type AddressPool = ReturnType<typeof createAddressPool>;
|