@forgezero/providers 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,77 +1,262 @@
1
+ <!--
2
+ GENERATED FILE — do not edit.
3
+
4
+ Change scripts/generate-guides.ts or its typed sources, run `bun run guides`,
5
+ and commit the generator and rendered files together.
6
+ -->
7
+
1
8
  # @forgezero/providers
2
9
 
3
- **One service, several vendors, automatic failover and a registry that knows
4
- which failures are worth retrying.**
10
+ Send through whichever provider is up. Priority and failover, reordered live.
11
+
12
+ ## Global package root and supported runtimes
13
+
14
+ Anyone calling somebody else's API who needs the failure classified rather than guessed. Provider capabilities stay independent from application-owned services, which attach named methods by priority with health tracking. Supported runtimes: bun, node, workers, deno. The global base/root import is @forgezero/providers. Every public import or command is listed below; the documentation inventory is checked in both directions against package.json exports.
15
+
16
+ ```text
17
+ import * as root from '@forgezero/providers';
18
+ ```
5
19
 
6
- You declare a service (`email`, `chain`, `storage`) and the providers that can
7
- serve it, in priority order. Calls try them in turn. Where the credentials come
8
- from is yours: environment variables in development, a vault in production,
9
- nothing changes in your code.
20
+ ## Commands
10
21
 
11
- Zero runtime dependencies. Bun, Node 18+, Deno, Cloudflare Workers anywhere
12
- `fetch` and Web Crypto exist.
22
+ bun add @forgezero/providers @forgezero/vault Install provider/service routing and the optional ForgeZero Vault credential adapter.
23
+ bun test — Run the consumer project tests containing provider failure and fallback cases.
13
24
 
14
- ```bash
15
- bun add @forgezero/providers
25
+ ```text
26
+ bun add @forgezero/providers @forgezero/vault
27
+ bun test
16
28
  ```
17
29
 
18
- ```ts
19
- import { createRegistry, envCredentials, staticConfig } from '@forgezero/providers';
20
- import { jetemail, smtp } from '@forgezero/providers/email';
30
+ ## @forgezero/providers
31
+
32
+ The registry: priority, health, and a terminal-versus-retryable verdict per failure.
33
+
34
+ ```text
35
+ import * as api from '@forgezero/providers';
36
+ ```
37
+
38
+ ## @forgezero/providers/email
39
+
40
+ JetEmail (`send`, `sendBatch`), SMTP (`send` only), and the typed email service contract; SMTP transport is injected.
41
+
42
+ ```text
43
+ import * as api from '@forgezero/providers/email';
44
+ ```
45
+
46
+ ## @forgezero/providers/database
47
+
48
+ ArangoDB in the registry for credential rotation and health — with failover off by default.
49
+
50
+ ```text
51
+ import * as api from '@forgezero/providers/database';
52
+ ```
53
+
54
+ ## @forgezero/providers/http
55
+
56
+ Outbound HTTP with a per-host weight budget reserved before the call and settled from the response.
57
+
58
+ ```text
59
+ import * as api from '@forgezero/providers/http';
60
+ ```
61
+
62
+ ## @forgezero/providers/pool
63
+
64
+ Which outbound address a request leaves by, sticky per key, over the same budget as everything else.
65
+
66
+ ```text
67
+ import * as api from '@forgezero/providers/pool';
68
+ ```
69
+
70
+ ## @forgezero/providers/chain
71
+
72
+ An EVM node behind the same failover as any other service: read logs, broadcast, and never stop scanning because one endpoint rate-limited.
73
+
74
+ ```text
75
+ import * as api from '@forgezero/providers/chain';
76
+ ```
77
+
78
+ ## @forgezero/providers/binance
79
+
80
+ Binance behind the venue adapter: three hosts, one weight budget, signed over the exact string that is sent.
81
+
82
+ ```text
83
+ import * as api from '@forgezero/providers/binance';
84
+ ```
85
+
86
+ ## @forgezero/providers/storage
87
+
88
+ S3-compatible object storage, SigV4 signed with Web Crypto and no vendor SDK.
89
+
90
+ ```text
91
+ import * as api from '@forgezero/providers/storage';
92
+ ```
93
+
94
+ ## @forgezero/providers/translation
95
+
96
+ Google AI Studio translation with strict batch alignment and classified quota failures.
97
+
98
+ ```text
99
+ import * as api from '@forgezero/providers/translation';
100
+ ```
21
101
 
22
- const services = createRegistry({
23
- credentials: envCredentials(process.env), // or vaultCredentials(fz)
24
- config: staticConfig({
25
- email: [
26
- { providerId: 'jetemail', priority: 1, enabled: true, secretRef: 'jetemail', config: {} },
27
- { providerId: 'smtp', priority: 2, enabled: true, secretRef: 'smtp', config: {} }
28
- ]
29
- }),
30
- providers: [jetemail, smtp]
102
+ ## @forgezero/providers/realtime
103
+
104
+ Cloudflare KV node-directory and Durable Object fan-out clients for project-defined realtime services.
105
+
106
+ ```text
107
+ import * as api from '@forgezero/providers/realtime';
108
+ ```
109
+
110
+ ## Email with ordered provider fallback
111
+
112
+ The service owns send/sendBatch. Attach provider methods directly in priority arrays; SMTP is never presented as batch-capable.
113
+
114
+ ```text
115
+ import { createService } from '@forgezero/providers';
116
+ import { emailService, jetemail, smtp } from '@forgezero/providers/email';
117
+ import { createVault } from '@forgezero/vault';
118
+ import { vaultCredentials } from '@forgezero/vault/providers';
119
+
120
+ const vault = createVault({ project: 'my-project', environment: 'production' });
121
+ const email = createService(emailService, {
122
+ send: [
123
+ { provider: jetemail, method: 'send', credentials: vaultCredentials(vault, 'jetemail'), config: { from: 'ops@example.com' }, priority: 1 },
124
+ { provider: smtp, method: 'send', credentials: vaultCredentials(vault, 'smtp'), config: { host: 'smtp.example.com', port: 587, from: 'ops@example.com', transport }, priority: 2 }
125
+ ],
126
+ sendBatch: [
127
+ { provider: jetemail, method: 'sendBatch', credentials: vaultCredentials(vault, 'jetemail'), config: { from: 'ops@example.com' }, priority: 1 }
128
+ ]
129
+ });
130
+
131
+ const outcome = await email.call('send', message);
132
+ ```
133
+
134
+ ## Extend with a custom provider and service
135
+
136
+ Public consumers define provider capabilities and application services independently, then attach them with the same ordered-array API.
137
+
138
+ ```text
139
+ const postmark = defineProvider({
140
+ id: 'postmark', label: 'Postmark',
141
+ methods: { deliver: defineProviderMethod({ invoke, classify }) }
142
+ });
143
+ const notifications = defineService({
144
+ key: 'notifications', methods: { deliver: serviceMethod<Notice, Receipt>() }
145
+ });
146
+ const service = createService(notifications, {
147
+ deliver: [{ provider: postmark, method: 'deliver', credentials: vaultCredentials(vault, 'postmark'), priority: 1 }]
31
148
  });
149
+ ```
150
+
151
+ ## Vault first, systemd credential fallback
152
+
153
+ Bootstrap tries the scoped Vault entry first and the identically named systemd credential second. If neither exists, the provider call fails.
154
+
155
+ ```text
156
+ const credentials = chainScopedCredentials(
157
+ vaultCredentials(vault, 'jetemail'),
158
+ scopeCredentials(systemdCredentials({
159
+ nameFor: (reference, field) => `${reference}-${field}`
160
+ }), 'jetemail')
161
+ );
162
+ ```
32
163
 
33
- const controller = new AbortController();
34
- await services.call('email', { to, subject, html }, { signal: controller.signal });
35
- // JetEmail first, then SMTP. Abort stops the chain and reaches provider I/O.
164
+ ## Built-in provider capabilities
165
+
166
+ A provider owns capabilities; it does not own a service. A service method attaches a provider instance and one of its methods. This keeps custom providers and custom services independent.
167
+
168
+ ```text
169
+ evm-rpc request
170
+ jetemail send, sendBatch
171
+ smtp send
172
+ arangodb query
173
+ http request
174
+ s3 request
175
+ google-ai-studio translate
176
+ ```
177
+
178
+ ## EVM JSON-RPC node (evm-rpc)
179
+
180
+ Import: @forgezero/providers/chain. Methods: request. Credential fields: url, bearer. Nonsecret config: chainId, maxLogRange, timeoutMs. Several endpoints per chain is the normal case, not a luxury: public RPC is rate-limited and paid gateways have outages, and a scanner that stops when one is down stops crediting deposits while the money sits on the chain. "Already known" and "nonce too low" are TERMINAL — the transaction is already in flight, so rebroadcasting through another node is how a sweep gets sent twice.
181
+
182
+ ```text
183
+ importPath: '@forgezero/providers/chain'
184
+ providerId: 'evm-rpc'
185
+ methods: request
186
+ credentials: url, bearer
187
+ config: chainId, maxLogRange, timeoutMs
188
+ ```
189
+
190
+ ## JetEmail (jetemail)
191
+
192
+ Import: @forgezero/providers/email. Methods: send, sendBatch. Credential fields: apiKey. Nonsecret config: eu, from. A 401 is RETRYABLE at the loop level — that key is bad, the next provider's may not be. Treating any 4xx as fatal is what loses the fallback.
193
+
194
+ ```text
195
+ importPath: '@forgezero/providers/email'
196
+ providerId: 'jetemail'
197
+ methods: send, sendBatch
198
+ credentials: apiKey
199
+ config: eu, from
36
200
  ```
37
201
 
38
- ## Why a 400 stops the loop and a 401 does not
202
+ ## SMTP (smtp)
39
203
 
40
- This is the whole reason the package exists. Every failure is classified before
41
- the loop decides what to do:
204
+ Import: @forgezero/providers/email. Methods: send. Credential fields: user, password. Nonsecret config: host, port, secure, from, transport. Any number of named relays, each with its own priority slot. Also the bootstrap path: the vault opens after a ceremony, and the ceremony needs mail.
42
205
 
43
- | verdict | meaning | what happens |
44
- |---|---|---|
45
- | `terminal` | the request itself is wrong — a 400, a malformed address | **stop.** Every other provider will reject it too |
46
- | `retryable` | this vendor failed — a 401, a 500, a dropped socket | try the next one. Their key being bad says nothing about the next key |
47
- | `backoff` | rate limited — a 429 with `Retry-After` | wait, then continue |
206
+ ```text
207
+ importPath: '@forgezero/providers/email'
208
+ providerId: 'smtp'
209
+ methods: send
210
+ credentials: user, password
211
+ config: host, port, secure, from, transport
212
+ ```
213
+
214
+ ## ArangoDB (arangodb)
215
+
216
+ Import: @forgezero/providers/database. Methods: query. Credential fields: password. Nonsecret config: url, urls, readPreferredUrls, readPreferredFallback, clusterId, database, username. Failover is OFF by default. Writing to a different database because the first was slow is data loss with extra steps; this is here for credential rotation and health.
217
+
218
+ ```text
219
+ importPath: '@forgezero/providers/database'
220
+ providerId: 'arangodb'
221
+ methods: query
222
+ credentials: password
223
+ config: url, urls, readPreferredUrls, readPreferredFallback, clusterId, database, username
224
+ ```
48
225
 
49
- A registry that retries a 400 across five vendors sends five identical rejections
50
- and reports "all providers down". A registry that gives up on a 401 takes your
51
- whole service offline because one API key expired.
226
+ ## HTTP (http)
52
227
 
53
- ## Health that reflects reality
228
+ Import: @forgezero/providers/http. Methods: request. Credential fields: apiKey, apiSecret. Nonsecret config: baseUrl, limit, windowMs, headroom. 418 and 429 back off rather than falling through. 418 is a venue saying "you ignored a 429 and are now banned", and hammering makes the ban longer.
54
229
 
55
- Missing is healthy. One or two failures is **degraded** — still attempted, amber
56
- in a dashboard. Three is **offline** — skipped entirely until a success clears
57
- it. A provider disabled by configuration never counts against health, because a
58
- vendor you turned off is not a vendor that is failing.
230
+ ```text
231
+ importPath: '@forgezero/providers/http'
232
+ providerId: 'http'
233
+ methods: request
234
+ credentials: apiKey, apiSecret
235
+ config: baseUrl, limit, windowMs, headroom
236
+ ```
237
+
238
+ ## S3-compatible storage (s3)
59
239
 
60
- ## Subpaths
240
+ Import: @forgezero/providers/storage. Methods: request. Credential fields: accessKeyId, secretAccessKey, sessionToken. Nonsecret config: endpoint, region, bucket, addressing. A 403 is retryable because on S3 it usually means this key lacks a permission, not that the key is invalid. Works against AWS, R2, MinIO, Garage and Backblaze — path-style addressing by default, because that is what everything except AWS expects.
61
241
 
62
- | import | what it is |
63
- |---|---|
64
- | `@forgezero/providers` | the registry, `defineProvider`, `classify`, health |
65
- | `/email` | JetEmail and SMTP behind one `email` service |
66
- | `/chain` | an EVM JSON-RPC node behind the same failover — logs, balance, broadcast |
67
- | `/database` | ArangoDB, for credential rotation and health |
68
- | `/http` | outbound HTTP with a per-host weight budget reserved before the call |
69
- | `/pool` | which outbound address a request leaves by, sticky per key |
70
- | `/storage` | S3-compatible object storage, SigV4 signed with Web Crypto, no vendor SDK |
242
+ ```text
243
+ importPath: '@forgezero/providers/storage'
244
+ providerId: 's3'
245
+ methods: request
246
+ credentials: accessKeyId, secretAccessKey, sessionToken
247
+ config: endpoint, region, bucket, addressing
248
+ ```
71
249
 
72
- Full documentation: **https://www.forgezero.net/docs/providers**
250
+ ## Google AI Studio (google-ai-studio)
73
251
 
74
- ## Licence
252
+ Import: @forgezero/providers/translation. Methods: translate. Credential fields: apiKey. Nonsecret config: model. Machine translation for a tenant that wants it — ForgeZero translates nothing at runtime. Chosen as the first adapter because the free tier needs no billing account, so the feature can be tried without a procurement conversation. A 429 is a BACKOFF rather than a failure: on the free tier it is expected traffic, and treating it as terminal abandons a catalogue most of the way through. A short answer is terminal, because one translation missing from a batch shifts every later string onto the wrong source and nothing about the result looks broken afterwards.
253
+
254
+ ```text
255
+ importPath: '@forgezero/providers/translation'
256
+ providerId: 'google-ai-studio'
257
+ methods: translate
258
+ credentials: apiKey
259
+ config: model
260
+ ```
75
261
 
76
- MIT. Part of [ForgeZero](https://www.forgezero.net) — secrets, attested compute and
77
- deploys — and usable entirely on its own, with no ForgeZero account.
262
+ Full rendered documentation: https://www.forgezero.net/docs/providers
package/dist/binance.js CHANGED
@@ -38,25 +38,71 @@ function chainCredentials(...sources) {
38
38
  }
39
39
  };
40
40
  }
41
- function staticConfig(services) {
41
+ function scopeCredentials(source, reference) {
42
+ return {
43
+ name: `${source.name}:${reference}`,
44
+ get: (field) => source.get(reference, field)
45
+ };
46
+ }
47
+ function chainScopedCredentials(...sources) {
48
+ return {
49
+ name: sources.map((source) => source.name).join("+"),
50
+ async get(field) {
51
+ let last;
52
+ for (const source of sources) {
53
+ try {
54
+ return await source.get(field);
55
+ } catch (error) {
56
+ last = error;
57
+ }
58
+ }
59
+ throw last instanceof Error ? last : new ProviderError("CREDENTIAL_MISSING", `No scoped source held field "${field}".`);
60
+ }
61
+ };
62
+ }
63
+ function staticConfig(config) {
64
+ const providers = new Map(config.providers.map((provider) => [provider.instanceKey, provider]));
65
+ if (providers.size !== config.providers.length) {
66
+ throw new ProviderError("CONFIG_DUPLICATE_INSTANCE", "Provider instance keys must be unique.");
67
+ }
42
68
  const health = new Map;
43
69
  return {
44
70
  name: "static",
45
- async list(serviceKey) {
46
- return (services[serviceKey] ?? []).map((provider) => ({
47
- ...provider,
48
- health: health.get(`${serviceKey}:${provider.providerId}`) ?? provider.health
71
+ async provider(instanceKey) {
72
+ const provider = providers.get(instanceKey);
73
+ return provider ? { ...provider, config: { ...provider.config } } : undefined;
74
+ },
75
+ async list(serviceKey, methodKey) {
76
+ return (config.services[serviceKey]?.[methodKey] ?? []).map((attachment) => ({
77
+ ...attachment,
78
+ health: health.get(`${serviceKey}:${methodKey}:${attachment.instanceKey}:${attachment.providerMethod}`) ?? attachment.health
49
79
  }));
50
80
  },
51
- async recordHealth(serviceKey, providerId, next) {
52
- health.set(`${serviceKey}:${providerId}`, next);
81
+ async recordHealth(serviceKey, methodKey, instanceKey, providerMethod, next) {
82
+ health.set(`${serviceKey}:${methodKey}:${instanceKey}:${providerMethod}`, next);
53
83
  }
54
84
  };
55
85
  }
86
+ function defineProviderMethod(method) {
87
+ return method;
88
+ }
56
89
  function defineProvider(spec) {
57
90
  return spec;
58
91
  }
92
+ function defineSingleMethodProvider(spec) {
93
+ const { method, invoke, classify, ...identity } = spec;
94
+ return defineProvider({
95
+ ...identity,
96
+ methods: { [method]: defineProviderMethod({ invoke, classify }) }
97
+ });
98
+ }
59
99
  var STRIKES_TO_OFFLINE = 3;
100
+ function serviceMethod() {
101
+ return Object.freeze({});
102
+ }
103
+ function defineService(definition) {
104
+ return definition;
105
+ }
60
106
  function nextHealth(current, kind) {
61
107
  if (kind === "success")
62
108
  return { strikes: 0, status: "ok" };
@@ -71,80 +117,188 @@ function nextHealth(current, kind) {
71
117
  }
72
118
  function createRegistry(options) {
73
119
  const byId = new Map(options.providers.map((provider) => [provider.id, provider]));
74
- async function call(serviceKey, args, callOptions = {}) {
75
- const configured = [...await options.config.list(serviceKey)].filter((provider) => provider.enabled).sort((a, b) => a.priority - b.priority);
120
+ async function call(serviceKey, methodKey, args, callOptions = {}) {
121
+ const configured = [...await options.config.list(serviceKey, methodKey)].filter((attachment) => attachment.enabled).sort((a, b) => a.priority - b.priority);
76
122
  const attempts = [];
77
- for (const entry of configured) {
123
+ for (const attachment of configured) {
78
124
  if (callOptions.signal?.aborted) {
79
125
  const cancelled = {
80
126
  ok: false,
127
+ fallbackUsed: attempts.length > 0,
81
128
  attempts,
82
- error: new ProviderError("CALL_ABORTED", `The "${serviceKey}" call was cancelled.`)
129
+ error: new ProviderError("CALL_ABORTED", `The "${serviceKey}.${methodKey}" call was cancelled.`)
83
130
  };
84
- options.after?.(cancelled);
131
+ await options.after?.(cancelled);
85
132
  return cancelled;
86
133
  }
87
- const spec = byId.get(entry.providerId);
88
- if (!spec) {
89
- attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "not registered" });
134
+ const instance = await options.config.provider(attachment.instanceKey);
135
+ if (!instance) {
136
+ attempts.push({ providerId: "unknown", instanceKey: attachment.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "instance not registered" });
90
137
  continue;
91
138
  }
92
- if (entry.health?.status === "offline") {
93
- attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "offline" });
139
+ const spec = byId.get(instance.providerId);
140
+ const method = spec?.methods[attachment.providerMethod];
141
+ if (!spec || !method) {
142
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: !spec ? "provider not registered" : "method not supported" });
94
143
  continue;
95
144
  }
96
- options.before?.({ service: serviceKey, provider: entry.providerId });
145
+ if (!instance.enabled) {
146
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "instance disabled" });
147
+ continue;
148
+ }
149
+ if (attachment.health?.status === "offline") {
150
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "offline" });
151
+ continue;
152
+ }
153
+ await options.before?.({ service: serviceKey, method: methodKey, provider: instance.providerId, instance: instance.instanceKey });
154
+ const startedAt = performance.now();
97
155
  try {
98
- const result = await spec.invoke({
99
- config: entry.config,
100
- secret: (field) => options.credentials.get(entry.secretRef, field),
156
+ const result = await method.invoke({
157
+ config: instance.config,
158
+ secret: (field) => options.credentials.get(instance.secretRef, field),
101
159
  signal: callOptions.signal
102
160
  }, args);
103
- attempts.push({ providerId: entry.providerId, outcome: "sent" });
104
- await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, "success"));
105
- const sent = { ok: true, result, provider: entry.providerId, attempts };
106
- options.after?.(sent);
161
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "sent", durationMs: performance.now() - startedAt });
162
+ await options.config.recordHealth(serviceKey, methodKey, instance.instanceKey, attachment.providerMethod, nextHealth(attachment.health, "success"));
163
+ const sent = {
164
+ ok: true,
165
+ result,
166
+ provider: instance.providerId,
167
+ instance: instance.instanceKey,
168
+ method: attachment.providerMethod,
169
+ selected: { providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod },
170
+ fallbackUsed: attempts.length > 1,
171
+ attempts
172
+ };
173
+ await options.after?.(sent);
107
174
  return sent;
108
175
  } catch (error) {
109
176
  if (callOptions.signal?.aborted) {
110
177
  const cancelled = {
111
178
  ok: false,
179
+ fallbackUsed: attempts.length > 0,
112
180
  attempts,
113
- error: new ProviderError("CALL_ABORTED", `The "${serviceKey}" call was cancelled.`)
181
+ error: new ProviderError("CALL_ABORTED", `The "${serviceKey}.${methodKey}" call was cancelled.`)
114
182
  };
115
- options.after?.(cancelled);
183
+ await options.after?.(cancelled);
116
184
  return cancelled;
117
185
  }
118
- const kind = spec.classify(error);
186
+ const kind = method.classify(error);
187
+ const message = error instanceof Error ? error.message : String(error);
188
+ const code = error instanceof ProviderError ? error.code : typeof error?.code === "string" ? error.code : undefined;
119
189
  attempts.push({
120
- providerId: entry.providerId,
190
+ providerId: instance.providerId,
191
+ instanceKey: instance.instanceKey,
192
+ providerMethod: attachment.providerMethod,
121
193
  outcome: "failed",
122
194
  kind,
123
- error: error instanceof Error ? error.message : String(error)
195
+ durationMs: performance.now() - startedAt,
196
+ failure: { kind, ...code ? { code } : {}, message },
197
+ error: message
124
198
  });
125
- await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, kind));
199
+ await options.config.recordHealth(serviceKey, methodKey, instance.instanceKey, attachment.providerMethod, nextHealth(attachment.health, kind));
126
200
  if (kind === "terminal") {
127
201
  const refused = {
128
202
  ok: false,
203
+ fallbackUsed: attempts.length > 1,
129
204
  attempts,
130
205
  error: new ProviderError("PAYLOAD_REJECTED", "The request was refused as malformed; no provider will accept it.")
131
206
  };
132
- options.after?.(refused);
207
+ await options.after?.(refused);
133
208
  return refused;
134
209
  }
135
210
  }
136
211
  }
137
212
  const failed = {
138
213
  ok: false,
214
+ fallbackUsed: attempts.length > 1,
139
215
  attempts,
140
- 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.`)
216
+ error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider method is configured for "${serviceKey}.${methodKey}".` : `Every provider method for "${serviceKey}.${methodKey}" failed or was skipped.`)
141
217
  };
142
- options.after?.(failed);
218
+ await options.after?.(failed);
143
219
  return failed;
144
220
  }
145
- return { call };
221
+ function service(definition) {
222
+ return {
223
+ call(method, args, callOptions) {
224
+ return call(definition.key, method, args, callOptions);
225
+ }
226
+ };
227
+ }
228
+ return { call, service };
229
+ }
230
+ function createService(definition, methods, options = {}) {
231
+ const providers = new Map;
232
+ const credentials = new Map;
233
+ const instances = [];
234
+ const serviceMethods = {};
235
+ for (const methodKey of Object.keys(definition.methods)) {
236
+ const attachments = methods[methodKey];
237
+ if (!Array.isArray(attachments)) {
238
+ throw new ProviderError("CONFIG_METHOD_MISSING", `Service method "${definition.key}.${methodKey}" needs an attachment array.`);
239
+ }
240
+ const priorities = new Set;
241
+ serviceMethods[methodKey] = attachments.map((attachment, index) => {
242
+ if (!Number.isInteger(attachment.priority) || attachment.priority < 1 || attachment.priority > 1000) {
243
+ throw new ProviderError("CONFIG_PRIORITY", `Priority for "${definition.key}.${methodKey}" must be an integer from 1 to 1000.`);
244
+ }
245
+ if (priorities.has(attachment.priority)) {
246
+ throw new ProviderError("CONFIG_PRIORITY_DUPLICATE", `Priorities for "${definition.key}.${methodKey}" must be unique.`);
247
+ }
248
+ priorities.add(attachment.priority);
249
+ if (!attachment.provider.methods[attachment.method]) {
250
+ throw new ProviderError("CONFIG_METHOD_UNSUPPORTED", `Provider "${attachment.provider.id}" does not support "${attachment.method}".`);
251
+ }
252
+ const existing = providers.get(attachment.provider.id);
253
+ if (existing && existing !== attachment.provider) {
254
+ throw new ProviderError("CONFIG_PROVIDER_DUPLICATE", `Provider id "${attachment.provider.id}" has more than one definition.`);
255
+ }
256
+ providers.set(attachment.provider.id, attachment.provider);
257
+ const internalKey = `${definition.key}:${methodKey}:${index}`;
258
+ credentials.set(internalKey, attachment.credentials);
259
+ instances.push({
260
+ instanceKey: internalKey,
261
+ providerId: attachment.provider.id,
262
+ enabled: attachment.enabled ?? true,
263
+ config: { ...attachment.config ?? {} },
264
+ secretRef: internalKey
265
+ });
266
+ return {
267
+ instanceKey: internalKey,
268
+ providerMethod: attachment.method,
269
+ priority: attachment.priority,
270
+ enabled: attachment.enabled ?? true
271
+ };
272
+ });
273
+ }
274
+ const registry = createRegistry({
275
+ providers: [...providers.values()],
276
+ config: staticConfig({ providers: instances, services: { [definition.key]: serviceMethods } }),
277
+ credentials: {
278
+ name: [...new Set([...credentials.values()].map((source) => source.name))].join("+"),
279
+ async get(reference, field) {
280
+ const source = credentials.get(reference);
281
+ if (!source)
282
+ throw new ProviderError("CREDENTIAL_SOURCE_MISSING", "The provider credential source is unavailable.");
283
+ return source.get(field);
284
+ }
285
+ },
286
+ ...options
287
+ });
288
+ const dynamic = registry.service(definition);
289
+ return {
290
+ async call(method, args, callOptions) {
291
+ const result = await dynamic.call(method, args, callOptions);
292
+ const { instance: _instance, selected, attempts, ...rest } = result;
293
+ return {
294
+ ...rest,
295
+ ...selected ? { selected: { providerId: selected.providerId, providerMethod: selected.providerMethod } } : {},
296
+ attempts: attempts.map(({ instanceKey: _instanceKey, ...attempt }) => attempt)
297
+ };
298
+ }
299
+ };
146
300
  }
147
- var VERSION = "0.1.9";
301
+ var VERSION = "0.1.11";
148
302
 
149
303
  // src/http.ts
150
304
  class BudgetExhausted extends ProviderError {
@@ -227,9 +381,9 @@ function createHttpClient(config) {
227
381
  budget: () => budgetState(config.budget.host)
228
382
  };
229
383
  }
230
- var http = defineProvider({
384
+ var http = defineSingleMethodProvider({
231
385
  id: "http",
232
- service: "http",
386
+ method: "request",
233
387
  label: "HTTP",
234
388
  multiInstance: true,
235
389
  credentials: {
package/dist/chain.d.ts CHANGED
@@ -96,4 +96,4 @@ export type ChainResult = {
96
96
  op: 'receipt';
97
97
  receipt: ChainReceipt | null;
98
98
  };
99
- export declare const evmRpc: import("./index").ProviderSpec<ChainCall, ChainResult>;
99
+ export declare const evmRpc: import("./index").ProviderDefinition<Record<"request", import("./index").ProviderMethodSpec<ChainCall, ChainResult>>>;