@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/chain.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A blockchain node, behind the same failover every other service gets.
|
|
3
|
+
*
|
|
4
|
+
* Reading a chain is a PROVIDER problem, not a runtime one, and the distinction
|
|
5
|
+
* is not bookkeeping. A node needs a credential, it rate-limits, it goes down,
|
|
6
|
+
* and the answer when it does is to ask a different one — which is precisely
|
|
7
|
+
* what the registry already does for email and storage. Deriving an address
|
|
8
|
+
* needs none of that and lives in `runtime/finance/custody`.
|
|
9
|
+
*
|
|
10
|
+
* ## Why several nodes is the normal case, not a luxury
|
|
11
|
+
*
|
|
12
|
+
* Public RPC endpoints are rate-limited and unreliable, and paid ones have
|
|
13
|
+
* outages like anything else. A deposit scanner that stops when one endpoint is
|
|
14
|
+
* down stops CREDITING DEPOSITS, and the user's money is on the chain the whole
|
|
15
|
+
* time. Priority and health belong here so a scanner never has to know which
|
|
16
|
+
* node answered.
|
|
17
|
+
*
|
|
18
|
+
* ## The classification is the load-bearing part
|
|
19
|
+
*
|
|
20
|
+
* terminal our request is malformed, or the chain rejected the transaction
|
|
21
|
+
* on its merits. Every node will say the same thing, and asking
|
|
22
|
+
* three of them buries the real reason under two duplicates.
|
|
23
|
+
* backoff rate limited. The node is healthy and we are asking too fast;
|
|
24
|
+
* striking it would punish the one that is working.
|
|
25
|
+
* retryable this node is behind, broken, or its key is bad. The next may
|
|
26
|
+
* be fine.
|
|
27
|
+
*
|
|
28
|
+
* "Already known" and "nonce too low" are the interesting cases. Both mean the
|
|
29
|
+
* transaction is ALREADY IN FLIGHT, so they are terminal rather than retryable:
|
|
30
|
+
* rebroadcasting through another node cannot help, and treating them as failure
|
|
31
|
+
* is how a sweep gets sent twice.
|
|
32
|
+
*/
|
|
33
|
+
export type ChainCall = {
|
|
34
|
+
op: 'head';
|
|
35
|
+
} | {
|
|
36
|
+
op: 'balance';
|
|
37
|
+
address: string;
|
|
38
|
+
block?: string;
|
|
39
|
+
} | {
|
|
40
|
+
op: 'call';
|
|
41
|
+
to: string;
|
|
42
|
+
data: string;
|
|
43
|
+
block?: string;
|
|
44
|
+
} | {
|
|
45
|
+
op: 'logs';
|
|
46
|
+
fromBlock: number;
|
|
47
|
+
toBlock: number;
|
|
48
|
+
address?: string;
|
|
49
|
+
topics?: (string | string[] | null)[];
|
|
50
|
+
} | {
|
|
51
|
+
op: 'nonce';
|
|
52
|
+
address: string;
|
|
53
|
+
block?: 'pending' | 'latest';
|
|
54
|
+
} | {
|
|
55
|
+
op: 'send';
|
|
56
|
+
raw: string;
|
|
57
|
+
} | {
|
|
58
|
+
op: 'receipt';
|
|
59
|
+
hash: string;
|
|
60
|
+
};
|
|
61
|
+
export interface ChainLog {
|
|
62
|
+
transactionHash: string;
|
|
63
|
+
logIndex: number;
|
|
64
|
+
blockNumber: number;
|
|
65
|
+
address: string;
|
|
66
|
+
topics: string[];
|
|
67
|
+
data: string;
|
|
68
|
+
}
|
|
69
|
+
export interface ChainReceipt {
|
|
70
|
+
hash: string;
|
|
71
|
+
blockNumber: number;
|
|
72
|
+
/** True only when the chain executed it successfully. */
|
|
73
|
+
success: boolean;
|
|
74
|
+
}
|
|
75
|
+
export type ChainResult = {
|
|
76
|
+
op: 'head';
|
|
77
|
+
block: number;
|
|
78
|
+
} | {
|
|
79
|
+
op: 'balance';
|
|
80
|
+
wei: bigint;
|
|
81
|
+
} | {
|
|
82
|
+
op: 'call';
|
|
83
|
+
data: string;
|
|
84
|
+
} | {
|
|
85
|
+
op: 'logs';
|
|
86
|
+
logs: ChainLog[];
|
|
87
|
+
} | {
|
|
88
|
+
op: 'nonce';
|
|
89
|
+
nonce: bigint;
|
|
90
|
+
} | {
|
|
91
|
+
op: 'send';
|
|
92
|
+
hash: string;
|
|
93
|
+
}
|
|
94
|
+
/** `null` when the transaction has not mined yet — not an error. */
|
|
95
|
+
| {
|
|
96
|
+
op: 'receipt';
|
|
97
|
+
receipt: ChainReceipt | null;
|
|
98
|
+
};
|
|
99
|
+
export declare const evmRpc: import("./index").ProviderSpec<ChainCall, ChainResult>;
|
package/dist/chain.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
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
|
+
|
|
130
|
+
// src/chain.ts
|
|
131
|
+
var hexToNumber = (value) => Number(BigInt(value));
|
|
132
|
+
var TERMINAL_PATTERNS = [
|
|
133
|
+
"already known",
|
|
134
|
+
"nonce too low",
|
|
135
|
+
"already imported",
|
|
136
|
+
"replacement transaction underpriced",
|
|
137
|
+
"intrinsic gas too low",
|
|
138
|
+
"insufficient funds",
|
|
139
|
+
"exceeds block gas limit",
|
|
140
|
+
"invalid sender",
|
|
141
|
+
"execution reverted"
|
|
142
|
+
];
|
|
143
|
+
var evmRpc = defineProvider({
|
|
144
|
+
id: "evm-rpc",
|
|
145
|
+
service: "chain",
|
|
146
|
+
label: "EVM JSON-RPC node",
|
|
147
|
+
multiInstance: true,
|
|
148
|
+
credentials: {
|
|
149
|
+
type: "object",
|
|
150
|
+
properties: {
|
|
151
|
+
url: { type: "string", writeOnly: true },
|
|
152
|
+
bearer: { type: "string", writeOnly: true }
|
|
153
|
+
},
|
|
154
|
+
required: ["url"],
|
|
155
|
+
additionalProperties: false
|
|
156
|
+
},
|
|
157
|
+
config: {
|
|
158
|
+
type: "object",
|
|
159
|
+
properties: {
|
|
160
|
+
chainId: { type: "integer" },
|
|
161
|
+
maxLogRange: { type: "integer" },
|
|
162
|
+
timeoutMs: { type: "integer" }
|
|
163
|
+
},
|
|
164
|
+
required: ["chainId"],
|
|
165
|
+
additionalProperties: false
|
|
166
|
+
},
|
|
167
|
+
async invoke(context, call) {
|
|
168
|
+
const config = context.config;
|
|
169
|
+
const url = await context.secret("url");
|
|
170
|
+
const bearer = await context.secret("bearer").catch(() => {
|
|
171
|
+
return;
|
|
172
|
+
});
|
|
173
|
+
const rpc = async (method, params) => {
|
|
174
|
+
const response = await fetch(url, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: {
|
|
177
|
+
"content-type": "application/json",
|
|
178
|
+
...bearer ? { authorization: `Bearer ${bearer}` } : {}
|
|
179
|
+
},
|
|
180
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
181
|
+
signal: context.signal ?? AbortSignal.timeout(config.timeoutMs ?? 15000)
|
|
182
|
+
});
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
throw new ProviderError("RPC_HTTP", `${method}: HTTP ${response.status}`, {
|
|
185
|
+
status: response.status
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
const body = await response.json();
|
|
189
|
+
if (body.error) {
|
|
190
|
+
throw new ProviderError("RPC_ERROR", `${method}: ${body.error.message ?? "error"}`, {
|
|
191
|
+
code: body.error.code
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (body.result === undefined) {
|
|
195
|
+
throw new ProviderError("RPC_EMPTY", `${method}: no result`);
|
|
196
|
+
}
|
|
197
|
+
return body.result;
|
|
198
|
+
};
|
|
199
|
+
switch (call.op) {
|
|
200
|
+
case "head":
|
|
201
|
+
return { op: "head", block: hexToNumber(await rpc("eth_blockNumber", [])) };
|
|
202
|
+
case "balance":
|
|
203
|
+
return {
|
|
204
|
+
op: "balance",
|
|
205
|
+
wei: BigInt(await rpc("eth_getBalance", [call.address, call.block ?? "latest"]))
|
|
206
|
+
};
|
|
207
|
+
case "call":
|
|
208
|
+
return {
|
|
209
|
+
op: "call",
|
|
210
|
+
data: await rpc("eth_call", [
|
|
211
|
+
{ to: call.to, data: call.data },
|
|
212
|
+
call.block ?? "latest"
|
|
213
|
+
])
|
|
214
|
+
};
|
|
215
|
+
case "logs": {
|
|
216
|
+
const span = call.toBlock - call.fromBlock + 1;
|
|
217
|
+
const cap = config.maxLogRange ?? 2000;
|
|
218
|
+
if (span > cap) {
|
|
219
|
+
throw new ProviderError("RPC_RANGE_TOO_WIDE", `Asked for ${span} blocks; this node allows ${cap}. Split the range.`);
|
|
220
|
+
}
|
|
221
|
+
const logs = await rpc("eth_getLogs", [
|
|
222
|
+
{
|
|
223
|
+
fromBlock: `0x${call.fromBlock.toString(16)}`,
|
|
224
|
+
toBlock: `0x${call.toBlock.toString(16)}`,
|
|
225
|
+
...call.address ? { address: call.address } : {},
|
|
226
|
+
...call.topics ? { topics: call.topics } : {}
|
|
227
|
+
}
|
|
228
|
+
]);
|
|
229
|
+
return {
|
|
230
|
+
op: "logs",
|
|
231
|
+
logs: logs.map((log) => ({
|
|
232
|
+
transactionHash: log.transactionHash,
|
|
233
|
+
logIndex: hexToNumber(log.logIndex),
|
|
234
|
+
blockNumber: hexToNumber(log.blockNumber),
|
|
235
|
+
address: log.address,
|
|
236
|
+
topics: log.topics,
|
|
237
|
+
data: log.data
|
|
238
|
+
}))
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
case "nonce":
|
|
242
|
+
return {
|
|
243
|
+
op: "nonce",
|
|
244
|
+
nonce: BigInt(await rpc("eth_getTransactionCount", [call.address, call.block ?? "pending"]))
|
|
245
|
+
};
|
|
246
|
+
case "send":
|
|
247
|
+
return { op: "send", hash: await rpc("eth_sendRawTransaction", [call.raw]) };
|
|
248
|
+
case "receipt": {
|
|
249
|
+
const receipt = await rpc("eth_getTransactionReceipt", [call.hash]);
|
|
250
|
+
if (!receipt)
|
|
251
|
+
return { op: "receipt", receipt: null };
|
|
252
|
+
return {
|
|
253
|
+
op: "receipt",
|
|
254
|
+
receipt: {
|
|
255
|
+
hash: call.hash,
|
|
256
|
+
blockNumber: hexToNumber(receipt.blockNumber ?? "0x0"),
|
|
257
|
+
success: receipt.status === "0x1"
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
classify(error) {
|
|
264
|
+
const message = String(error?.message ?? "").toLowerCase();
|
|
265
|
+
const details = error.details;
|
|
266
|
+
if (TERMINAL_PATTERNS.some((pattern) => message.includes(pattern)))
|
|
267
|
+
return "terminal";
|
|
268
|
+
if (message.includes("split the range"))
|
|
269
|
+
return "terminal";
|
|
270
|
+
if (details?.code === -32602)
|
|
271
|
+
return "terminal";
|
|
272
|
+
if (details?.status === 429 || message.includes("rate limit") || message.includes("too many"))
|
|
273
|
+
return "backoff";
|
|
274
|
+
return "retryable";
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
export {
|
|
278
|
+
evmRpc
|
|
279
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database providers — ArangoDB, with the same priority and health machinery
|
|
3
|
+
* every other service gets.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a database belongs in the registry at all
|
|
6
|
+
*
|
|
7
|
+
* Not for failover between databases — that is what a cluster is for, and
|
|
8
|
+
* silently writing to a different database because the first was slow is data
|
|
9
|
+
* loss with extra steps. It belongs here for two other reasons:
|
|
10
|
+
*
|
|
11
|
+
* CREDENTIALS the password lives in the same place as every other secret,
|
|
12
|
+
* so rotating it is a vault write rather than a redeploy
|
|
13
|
+
* HEALTH three failed connections mark it degraded, and the same admin
|
|
14
|
+
* screen that shows email relays shows the database
|
|
15
|
+
*
|
|
16
|
+
* `failover: false` is therefore the default and a deliberate one. Set it true
|
|
17
|
+
* only for read replicas, where answering from a stale replica beats not
|
|
18
|
+
* answering.
|
|
19
|
+
*/
|
|
20
|
+
/** Just enough of an ArangoDB client that this package imports nothing. */
|
|
21
|
+
export interface ArangoLike {
|
|
22
|
+
query<T = unknown>(query: unknown, bindVars?: Record<string, unknown>): Promise<{
|
|
23
|
+
all(): Promise<T[]>;
|
|
24
|
+
}>;
|
|
25
|
+
version?(): Promise<{
|
|
26
|
+
version: string;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
export interface ArangoConfig {
|
|
30
|
+
url: string;
|
|
31
|
+
database: string;
|
|
32
|
+
username: string;
|
|
33
|
+
/** Built by the host, because a connection pool must outlive one call. */
|
|
34
|
+
connect?: (options: {
|
|
35
|
+
url: string;
|
|
36
|
+
database: string;
|
|
37
|
+
username: string;
|
|
38
|
+
password: string;
|
|
39
|
+
}) => ArangoLike;
|
|
40
|
+
failover?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface Query {
|
|
43
|
+
query: unknown;
|
|
44
|
+
bindVars?: Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
export declare const arangodb: import("./index").ProviderSpec<Query, unknown[]>;
|
|
47
|
+
/** Drop memoised connections. Tests, and after a credential rotation. */
|
|
48
|
+
export declare function resetPools(): void;
|
|
49
|
+
export declare const databaseProviders: readonly [import("./index").ProviderSpec<Query, unknown[]>];
|
package/dist/database.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
|
|
130
|
+
// src/database.ts
|
|
131
|
+
var pools = new Map;
|
|
132
|
+
var arangodb = defineProvider({
|
|
133
|
+
id: "arangodb",
|
|
134
|
+
service: "database",
|
|
135
|
+
label: "ArangoDB",
|
|
136
|
+
multiInstance: true,
|
|
137
|
+
credentials: {
|
|
138
|
+
type: "object",
|
|
139
|
+
additionalProperties: false,
|
|
140
|
+
required: ["password"],
|
|
141
|
+
properties: {
|
|
142
|
+
password: { type: "string", title: "Password", writeOnly: true }
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
config: {
|
|
146
|
+
type: "object",
|
|
147
|
+
additionalProperties: false,
|
|
148
|
+
required: ["url", "database", "username"],
|
|
149
|
+
properties: {
|
|
150
|
+
url: {
|
|
151
|
+
type: "string",
|
|
152
|
+
title: "URL",
|
|
153
|
+
description: "Bind to loopback. A database reachable from the internet is a database that will be."
|
|
154
|
+
},
|
|
155
|
+
database: { type: "string", title: "Database" },
|
|
156
|
+
username: { type: "string", title: "Username" },
|
|
157
|
+
failover: {
|
|
158
|
+
type: "boolean",
|
|
159
|
+
default: false,
|
|
160
|
+
title: "Allow failover",
|
|
161
|
+
description: "Off by default. Writing to a different database because the first was slow is data loss with extra steps. Enable only for read replicas."
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
async invoke(context, request) {
|
|
166
|
+
const config = context.config;
|
|
167
|
+
if (!config.connect) {
|
|
168
|
+
throw new ProviderError("ARANGO_NO_CONNECT", "Provide a connect function in this provider's config — arangojs on a server; a database is not reachable from an edge runtime.");
|
|
169
|
+
}
|
|
170
|
+
const key = `${config.url}/${config.database}/${config.username}`;
|
|
171
|
+
let client = pools.get(key);
|
|
172
|
+
if (!client) {
|
|
173
|
+
client = config.connect({
|
|
174
|
+
url: config.url,
|
|
175
|
+
database: config.database,
|
|
176
|
+
username: config.username,
|
|
177
|
+
password: await context.secret("password")
|
|
178
|
+
});
|
|
179
|
+
pools.set(key, client);
|
|
180
|
+
}
|
|
181
|
+
const cursor = await client.query(request.query, request.bindVars);
|
|
182
|
+
return cursor.all();
|
|
183
|
+
},
|
|
184
|
+
classify(error) {
|
|
185
|
+
const arango = error;
|
|
186
|
+
if (arango.errorNum === 1210)
|
|
187
|
+
return "terminal";
|
|
188
|
+
if (arango.errorNum === 1202)
|
|
189
|
+
return "terminal";
|
|
190
|
+
if (arango.errorNum === 1501)
|
|
191
|
+
return "terminal";
|
|
192
|
+
if (arango.code === 400 || arango.code === 404 || arango.code === 409)
|
|
193
|
+
return "terminal";
|
|
194
|
+
if (arango.code === 401 || arango.code === 403)
|
|
195
|
+
return "retryable";
|
|
196
|
+
if (arango.code === 503)
|
|
197
|
+
return "backoff";
|
|
198
|
+
return "retryable";
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
function resetPools() {
|
|
202
|
+
pools.clear();
|
|
203
|
+
}
|
|
204
|
+
var databaseProviders = [arangodb];
|
|
205
|
+
export {
|
|
206
|
+
resetPools,
|
|
207
|
+
databaseProviders,
|
|
208
|
+
arangodb
|
|
209
|
+
};
|
package/dist/email.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type EmailMessage } from './index';
|
|
2
|
+
export interface JetEmailConfig {
|
|
3
|
+
/** EU residency endpoint. */
|
|
4
|
+
eu?: boolean;
|
|
5
|
+
endpoint?: string;
|
|
6
|
+
fetch?: typeof globalThis.fetch;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Status handling, from the published API:
|
|
10
|
+
*
|
|
11
|
+
* 201 sent 202 scheduled
|
|
12
|
+
* 400 bad input → TERMINAL: our payload is malformed and no relay will
|
|
13
|
+
* accept it, so trying the next one wastes a round trip
|
|
14
|
+
* and buries the real error
|
|
15
|
+
* 401 bad key → RETRYABLE: THIS key is wrong; the next provider's may
|
|
16
|
+
* be fine. This is the case people get wrong by treating
|
|
17
|
+
* any 4xx as fatal
|
|
18
|
+
* 409 idempotency → TERMINAL: the message already exists. Re-sending via
|
|
19
|
+
* another provider would deliver it twice
|
|
20
|
+
* 429 rate limited → BACKOFF: it is working, we are asking too fast
|
|
21
|
+
*/
|
|
22
|
+
export declare const jetemail: import("./index").ProviderSpec<EmailMessage, {
|
|
23
|
+
id: string;
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* A transport, so this package holds no socket code and no nodemailer.
|
|
27
|
+
*
|
|
28
|
+
* Supplied by the host: on Bun or Node that is a nodemailer transport, in a
|
|
29
|
+
* Worker it is an HTTP relay. The provider is the same either way, which is
|
|
30
|
+
* what lets one config work in both places.
|
|
31
|
+
*/
|
|
32
|
+
export interface SmtpTransport {
|
|
33
|
+
send(envelope: {
|
|
34
|
+
host: string;
|
|
35
|
+
port: number;
|
|
36
|
+
secure: boolean;
|
|
37
|
+
user?: string;
|
|
38
|
+
password?: string;
|
|
39
|
+
from: string;
|
|
40
|
+
to: readonly string[];
|
|
41
|
+
subject: string;
|
|
42
|
+
html?: string;
|
|
43
|
+
text?: string;
|
|
44
|
+
replyTo?: string;
|
|
45
|
+
}): Promise<{
|
|
46
|
+
messageId: string;
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* SMTP is `multiInstance`: a deployment may hold several named relays, each its
|
|
51
|
+
* own priority slot — a primary, a backup with a different vendor, and a
|
|
52
|
+
* loopback for development.
|
|
53
|
+
*
|
|
54
|
+
* SMTP reply codes carry the same distinction as HTTP status:
|
|
55
|
+
*
|
|
56
|
+
* 5xx permanent → TERMINAL for 550/553 (bad recipient — every relay agrees)
|
|
57
|
+
* RETRYABLE for 535 (auth failed — THIS relay's credentials)
|
|
58
|
+
* 4xx transient → RETRYABLE, and 421/450/451 specifically mean try later
|
|
59
|
+
*/
|
|
60
|
+
export declare const smtp: import("./index").ProviderSpec<EmailMessage, {
|
|
61
|
+
id: string;
|
|
62
|
+
}>;
|
|
63
|
+
/** Ready to register. Order here is irrelevant; the config decides priority. */
|
|
64
|
+
export declare const emailProviders: readonly [import("./index").ProviderSpec<EmailMessage, {
|
|
65
|
+
id: string;
|
|
66
|
+
}>, import("./index").ProviderSpec<EmailMessage, {
|
|
67
|
+
id: string;
|
|
68
|
+
}>];
|