@forgezero/providers 0.1.8 → 0.1.10

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,185 @@
1
1
  # @forgezero/providers
2
2
 
3
- **One service, several vendors, automatic failover — and a registry that knows
4
- which failures are worth retrying.**
3
+ Provider identities, named capabilities, and service-owned priority/fallback.
5
4
 
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.
10
-
11
- Zero runtime dependencies. Bun, Node 18+, Deno, Cloudflare Workers — anywhere
12
- `fetch` and Web Crypto exist.
5
+ A provider declares what a vendor can do. A service declares what an
6
+ application needs. Configuration attaches one provider method to one service
7
+ method, so adding a provider never changes the service contract and adding a
8
+ service never forks a provider.
13
9
 
14
10
  ```bash
15
11
  bun add @forgezero/providers
16
12
  ```
17
13
 
18
- ```ts
19
- import { createRegistry, envCredentials, staticConfig } from '@forgezero/providers';
20
- import { jetemail, smtp } from '@forgezero/providers/email';
14
+ ## Built-in providers
15
+
16
+ | provider | methods | import |
17
+ |---|---|---|
18
+ | JetEmail | `send`, `sendBatch` | `@forgezero/providers/email` |
19
+ | SMTP | `send` | `@forgezero/providers/email` |
20
+ | ArangoDB | `query` | `@forgezero/providers/database` |
21
+ | EVM JSON-RPC | `request` | `@forgezero/providers/chain` |
22
+ | HTTP | `request` | `@forgezero/providers/http` |
23
+ | S3-compatible storage | `request` | `@forgezero/providers/storage` |
24
+ | Google AI Studio | `translate` | `@forgezero/providers/translation` |
25
+
26
+ `pool`, `binance`, and `realtime` are specialised provider utilities/clients.
27
+ Every public subpath is documented at
28
+ [forgezero.net/docs/providers](https://www.forgezero.net/docs/providers).
29
+
30
+ ## Email: exact capability routing
31
+
32
+ JetEmail provides native single and batch delivery. SMTP provides single
33
+ delivery only. ForgeZero does not pretend SMTP has a batch capability by
34
+ silently looping.
21
35
 
22
- const services = createRegistry({
23
- credentials: envCredentials(process.env), // or vaultCredentials(fz)
36
+ ```ts
37
+ import {
38
+ createRegistry,
39
+ envCredentials,
40
+ staticConfig
41
+ } from '@forgezero/providers';
42
+ import {
43
+ emailProviders,
44
+ emailService
45
+ } from '@forgezero/providers/email';
46
+
47
+ const registry = createRegistry({
48
+ providers: emailProviders,
49
+ credentials: envCredentials(process.env),
24
50
  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]
51
+ providers: {
52
+ jetPrimary: {
53
+ providerId: 'jetemail',
54
+ enabled: true,
55
+ config: { from: 'ops@example.com' },
56
+ secretRef: 'jetemail'
57
+ },
58
+ smtpBackup: {
59
+ providerId: 'smtp',
60
+ enabled: true,
61
+ config: {
62
+ host: 'smtp.example.com',
63
+ port: 587,
64
+ from: 'ops@example.com',
65
+ transport
66
+ },
67
+ secretRef: 'smtp'
68
+ }
69
+ },
70
+ services: {
71
+ email: {
72
+ send: [
73
+ { instanceKey: 'jetPrimary', providerMethod: 'send', priority: 1, enabled: true },
74
+ { instanceKey: 'smtpBackup', providerMethod: 'send', priority: 2, enabled: true }
75
+ ],
76
+ sendBatch: [
77
+ { instanceKey: 'jetPrimary', providerMethod: 'sendBatch', priority: 1, enabled: true }
78
+ ]
79
+ }
80
+ }
81
+ })
82
+ });
83
+
84
+ const email = registry.service(emailService);
85
+ const outcome = await email.call('send', {
86
+ to: 'ada@example.com',
87
+ subject: 'Your invite',
88
+ html: '<p>Welcome</p>'
31
89
  });
32
90
 
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.
91
+ if (!outcome.ok) throw outcome.error;
92
+ outcome.result; // provider-neutral result
93
+ outcome.selected; // exact provider instance and method
94
+ outcome.fallbackUsed; // true when a lower priority succeeded
95
+ outcome.attempts; // every failed, skipped and successful attempt
36
96
  ```
37
97
 
38
- ## Why a 400 stops the loop and a 401 does not
98
+ Hooks and health writes are awaited. A successful fallback therefore still
99
+ returns the complete failure trail for auditing or operator notification.
39
100
 
40
- This is the whole reason the package exists. Every failure is classified before
41
- the loop decides what to do:
101
+ ## Define a custom provider
42
102
 
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 |
103
+ A provider does not name a service:
48
104
 
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.
105
+ ```ts
106
+ import {
107
+ defineProvider,
108
+ defineProviderMethod
109
+ } from '@forgezero/providers';
110
+
111
+ export const postmark = defineProvider({
112
+ id: 'postmark',
113
+ label: 'Postmark',
114
+ credentials: {
115
+ type: 'object',
116
+ additionalProperties: false,
117
+ properties: { token: { type: 'string', writeOnly: true } }
118
+ },
119
+ methods: {
120
+ deliver: defineProviderMethod<Notice, DeliveryReceipt>({
121
+ invoke: async (context, notice) =>
122
+ post(context.config, await context.secret('token'), notice),
123
+ classify: (error) => status(error) === 400 ? 'terminal' : 'retryable'
124
+ })
125
+ }
126
+ });
127
+ ```
52
128
 
53
- ## Health that reflects reality
129
+ ## Define a custom service
54
130
 
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.
131
+ The application owns the service vocabulary and attaches provider methods in
132
+ configuration:
59
133
 
60
- ## Subpaths
134
+ ```ts
135
+ import { defineService, serviceMethod } from '@forgezero/providers';
136
+
137
+ export const notifications = defineService({
138
+ key: 'notifications',
139
+ methods: {
140
+ deliver: serviceMethod<Notice, DeliveryReceipt>()
141
+ }
142
+ });
143
+
144
+ // services.notifications.deliver attaches postmark.deliver by priority.
145
+ const result = await registry.service(notifications).call('deliver', notice);
146
+ ```
147
+
148
+ An SSOT route handler calls `notifications.deliver`; it never imports Postmark,
149
+ JetEmail, or SMTP. A tenant may add both its own provider and its own service
150
+ without changing ForgeZero source.
151
+
152
+ ## Vault and external credential sources
61
153
 
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 |
154
+ `@forgezero/providers` never imports `@forgezero/vault`. It consumes a tiny
155
+ structural interface. ForgeZero provides adapters in `@forgezero/vault/providers`;
156
+ other consumers can inject any secret manager.
157
+
158
+ ```ts
159
+ import { chainCredentials, envCredentials } from '@forgezero/providers';
160
+ import { vaultCredentials } from '@forgezero/vault/providers';
161
+
162
+ const credentials = chainCredentials(
163
+ vaultCredentials(vault),
164
+ {
165
+ name: 'customer-secret-service',
166
+ get: (reference, field) => external.read(reference, field)
167
+ },
168
+ envCredentials(process.env) // bootstrap/systemd-projected fallback
169
+ );
170
+ ```
171
+
172
+ ## Failure classification
173
+
174
+ | kind | meaning | registry action |
175
+ |---|---|---|
176
+ | `terminal` | the request is invalid for every provider | stop |
177
+ | `retryable` | this provider/key failed | try the next attachment |
178
+ | `backoff` | the provider is healthy but rate-limiting | preserve health and continue according to policy |
71
179
 
72
- Full documentation: **https://www.forgezero.net/docs/providers**
180
+ Three fault strikes mark an attachment offline; a success clears them. Backoff
181
+ does not count as a fault.
73
182
 
74
183
  ## Licence
75
184
 
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.
185
+ MIT. Usable independently of a ForgeZero account.
package/dist/binance.js CHANGED
@@ -38,25 +38,45 @@ function chainCredentials(...sources) {
38
38
  }
39
39
  };
40
40
  }
41
- function staticConfig(services) {
41
+ function staticConfig(config) {
42
42
  const health = new Map;
43
43
  return {
44
44
  name: "static",
45
- async list(serviceKey) {
46
- return (services[serviceKey] ?? []).map((provider) => ({
47
- ...provider,
48
- health: health.get(`${serviceKey}:${provider.providerId}`) ?? provider.health
45
+ async provider(instanceKey) {
46
+ const provider = config.providers[instanceKey];
47
+ return provider ? { instanceKey, ...provider, config: { ...provider.config } } : undefined;
48
+ },
49
+ async list(serviceKey, methodKey) {
50
+ return (config.services[serviceKey]?.[methodKey] ?? []).map((attachment) => ({
51
+ ...attachment,
52
+ health: health.get(`${serviceKey}:${methodKey}:${attachment.instanceKey}:${attachment.providerMethod}`) ?? attachment.health
49
53
  }));
50
54
  },
51
- async recordHealth(serviceKey, providerId, next) {
52
- health.set(`${serviceKey}:${providerId}`, next);
55
+ async recordHealth(serviceKey, methodKey, instanceKey, providerMethod, next) {
56
+ health.set(`${serviceKey}:${methodKey}:${instanceKey}:${providerMethod}`, next);
53
57
  }
54
58
  };
55
59
  }
60
+ function defineProviderMethod(method) {
61
+ return method;
62
+ }
56
63
  function defineProvider(spec) {
57
64
  return spec;
58
65
  }
66
+ function defineSingleMethodProvider(spec) {
67
+ const { method, invoke, classify, ...identity } = spec;
68
+ return defineProvider({
69
+ ...identity,
70
+ methods: { [method]: defineProviderMethod({ invoke, classify }) }
71
+ });
72
+ }
59
73
  var STRIKES_TO_OFFLINE = 3;
74
+ function serviceMethod() {
75
+ return Object.freeze({});
76
+ }
77
+ function defineService(definition) {
78
+ return definition;
79
+ }
60
80
  function nextHealth(current, kind) {
61
81
  if (kind === "success")
62
82
  return { strikes: 0, status: "ok" };
@@ -71,80 +91,117 @@ function nextHealth(current, kind) {
71
91
  }
72
92
  function createRegistry(options) {
73
93
  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);
94
+ async function call(serviceKey, methodKey, args, callOptions = {}) {
95
+ const configured = [...await options.config.list(serviceKey, methodKey)].filter((attachment) => attachment.enabled).sort((a, b) => a.priority - b.priority);
76
96
  const attempts = [];
77
- for (const entry of configured) {
97
+ for (const attachment of configured) {
78
98
  if (callOptions.signal?.aborted) {
79
99
  const cancelled = {
80
100
  ok: false,
101
+ fallbackUsed: attempts.length > 0,
81
102
  attempts,
82
- error: new ProviderError("CALL_ABORTED", `The "${serviceKey}" call was cancelled.`)
103
+ error: new ProviderError("CALL_ABORTED", `The "${serviceKey}.${methodKey}" call was cancelled.`)
83
104
  };
84
- options.after?.(cancelled);
105
+ await options.after?.(cancelled);
85
106
  return cancelled;
86
107
  }
87
- const spec = byId.get(entry.providerId);
88
- if (!spec) {
89
- attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "not registered" });
108
+ const instance = await options.config.provider(attachment.instanceKey);
109
+ if (!instance) {
110
+ attempts.push({ providerId: "unknown", instanceKey: attachment.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "instance not registered" });
111
+ continue;
112
+ }
113
+ const spec = byId.get(instance.providerId);
114
+ const method = spec?.methods[attachment.providerMethod];
115
+ if (!spec || !method) {
116
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: !spec ? "provider not registered" : "method not supported" });
90
117
  continue;
91
118
  }
92
- if (entry.health?.status === "offline") {
93
- attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "offline" });
119
+ if (!instance.enabled) {
120
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "instance disabled" });
94
121
  continue;
95
122
  }
96
- options.before?.({ service: serviceKey, provider: entry.providerId });
123
+ if (attachment.health?.status === "offline") {
124
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "offline" });
125
+ continue;
126
+ }
127
+ await options.before?.({ service: serviceKey, method: methodKey, provider: instance.providerId, instance: instance.instanceKey });
128
+ const startedAt = performance.now();
97
129
  try {
98
- const result = await spec.invoke({
99
- config: entry.config,
100
- secret: (field) => options.credentials.get(entry.secretRef, field),
130
+ const result = await method.invoke({
131
+ config: instance.config,
132
+ secret: (field) => options.credentials.get(instance.secretRef, field),
101
133
  signal: callOptions.signal
102
134
  }, 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);
135
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "sent", durationMs: performance.now() - startedAt });
136
+ await options.config.recordHealth(serviceKey, methodKey, instance.instanceKey, attachment.providerMethod, nextHealth(attachment.health, "success"));
137
+ const sent = {
138
+ ok: true,
139
+ result,
140
+ provider: instance.providerId,
141
+ instance: instance.instanceKey,
142
+ method: attachment.providerMethod,
143
+ selected: { providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod },
144
+ fallbackUsed: attempts.length > 1,
145
+ attempts
146
+ };
147
+ await options.after?.(sent);
107
148
  return sent;
108
149
  } catch (error) {
109
150
  if (callOptions.signal?.aborted) {
110
151
  const cancelled = {
111
152
  ok: false,
153
+ fallbackUsed: attempts.length > 0,
112
154
  attempts,
113
- error: new ProviderError("CALL_ABORTED", `The "${serviceKey}" call was cancelled.`)
155
+ error: new ProviderError("CALL_ABORTED", `The "${serviceKey}.${methodKey}" call was cancelled.`)
114
156
  };
115
- options.after?.(cancelled);
157
+ await options.after?.(cancelled);
116
158
  return cancelled;
117
159
  }
118
- const kind = spec.classify(error);
160
+ const kind = method.classify(error);
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ const code = error instanceof ProviderError ? error.code : typeof error?.code === "string" ? error.code : undefined;
119
163
  attempts.push({
120
- providerId: entry.providerId,
164
+ providerId: instance.providerId,
165
+ instanceKey: instance.instanceKey,
166
+ providerMethod: attachment.providerMethod,
121
167
  outcome: "failed",
122
168
  kind,
123
- error: error instanceof Error ? error.message : String(error)
169
+ durationMs: performance.now() - startedAt,
170
+ failure: { kind, ...code ? { code } : {}, message },
171
+ error: message
124
172
  });
125
- await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, kind));
173
+ await options.config.recordHealth(serviceKey, methodKey, instance.instanceKey, attachment.providerMethod, nextHealth(attachment.health, kind));
126
174
  if (kind === "terminal") {
127
175
  const refused = {
128
176
  ok: false,
177
+ fallbackUsed: attempts.length > 1,
129
178
  attempts,
130
179
  error: new ProviderError("PAYLOAD_REJECTED", "The request was refused as malformed; no provider will accept it.")
131
180
  };
132
- options.after?.(refused);
181
+ await options.after?.(refused);
133
182
  return refused;
134
183
  }
135
184
  }
136
185
  }
137
186
  const failed = {
138
187
  ok: false,
188
+ fallbackUsed: attempts.length > 1,
139
189
  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.`)
190
+ 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
191
  };
142
- options.after?.(failed);
192
+ await options.after?.(failed);
143
193
  return failed;
144
194
  }
145
- return { call };
195
+ function service(definition) {
196
+ return {
197
+ call(method, args, callOptions) {
198
+ return call(definition.key, method, args, callOptions);
199
+ }
200
+ };
201
+ }
202
+ return { call, service };
146
203
  }
147
- var VERSION = "0.1.8";
204
+ var VERSION = "0.1.10";
148
205
 
149
206
  // src/http.ts
150
207
  class BudgetExhausted extends ProviderError {
@@ -227,9 +284,9 @@ function createHttpClient(config) {
227
284
  budget: () => budgetState(config.budget.host)
228
285
  };
229
286
  }
230
- var http = defineProvider({
287
+ var http = defineSingleMethodProvider({
231
288
  id: "http",
232
- service: "http",
289
+ method: "request",
233
290
  label: "HTTP",
234
291
  multiInstance: true,
235
292
  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>>>;