@canister-software/consensus-cli 0.1.0-beta.4 → 0.1.0-beta.5

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
@@ -69,7 +69,7 @@ CONSENSUS_SERVER_URL=https://your-custom-node.example.com
69
69
 
70
70
  ## ProxyClient
71
71
 
72
- `ProxyClient(fetchWithPayment, options)` returns Express-compatible middleware that routes your outbound HTTP requests through Consensus proxy nodes, handling x402 payments automatically.
72
+ `ProxyClient(fetchWithPayment, options)` returns a framework-agnostic proxy controller with Express-compatible middleware behavior. It routes outbound HTTP requests through Consensus proxy nodes and supports automatic spend-limit stand-down.
73
73
 
74
74
  ### ProxyClient Options
75
75
 
@@ -84,6 +84,10 @@ CONSENSUS_SERVER_URL=https://your-custom-node.example.com
84
84
  | `node_region` | `string` | — | Prefer proxy nodes in a specific geographic region. |
85
85
  | `node_domain` | `string` | — | Route through a specific node domain. |
86
86
  | `node_exclude` | `string` | — | Exclude a specific node domain from selection. |
87
+ | `limit_usd` | `number` | — | Max proxy spend in USD (up to 6 decimals). When reached, proxying stands down to direct `fetch`. |
88
+ | `on_limit_reached` | `(budget) => void` | — | Callback fired once when stand-down is activated. |
89
+
90
+ Proxy spend tracking uses the fixed server price of `$0.0001` per paid `/proxy` request (cached hits are not charged).
87
91
 
88
92
  ### Auto Strategy (Default)
89
93
 
@@ -147,6 +151,26 @@ const response = await req.consensus.fetch(
147
151
  );
148
152
  ```
149
153
 
154
+ ### Framework-Agnostic Usage
155
+
156
+ Use `runWithPath()` to scope interception in any server framework and `createFetch()` for explicit route-scoped fetch:
157
+
158
+ ```ts
159
+ const proxy = ProxyClient(fetchWithPayment, {
160
+ mode: "exclusive",
161
+ routes: ["/api"],
162
+ limit_usd: 1.25,
163
+ });
164
+
165
+ await proxy.runWithPath("/api", async () => {
166
+ const response = await fetch("https://api.example.com/data");
167
+ console.log(await response.json());
168
+ });
169
+
170
+ const apiFetch = proxy.createFetch("/api");
171
+ const directFetch = proxy.createFetch("/health");
172
+ ```
173
+
150
174
  ---
151
175
 
152
176
  ## SocketClient
@@ -160,8 +184,12 @@ const response = await req.consensus.fetch(
160
184
  | `openTimeoutMs` | `number` | `12000` | Milliseconds to wait for the WebSocket connection to open before timing out. |
161
185
  | `reconnectIntervalMs` | `number` | `2000` | Milliseconds between automatic reconnection attempts. |
162
186
  | `defaults` | `ConsensusSocketTokenParams` | — | Default token parameters applied to every `requestToken()` call unless overridden. |
187
+ | `limit_usd` | `number` | — | Max WebSocket spend in USD (up to 6 decimals). If next token quote exceeds remaining budget, token request is blocked. |
188
+ | `on_limit_reached` | `(budget) => void` | — | Callback fired once when the WebSocket spend limit is reached. |
163
189
  | `webSocketFactory` | `constructor` | auto-detected | Custom WebSocket constructor (browser `WebSocket` or `ws` for Node.js). Auto-detected if not provided. |
164
190
 
191
+ WebSocket spend checks use a local quote from the known pricing model (`model`, `minutes`, `megabytes`) before token purchase, so there is no additional price-check round trip.
192
+
165
193
  ### Billing Models
166
194
 
167
195
  Token requests accept a `model` parameter to control how your session is billed:
@@ -1,7 +1,11 @@
1
1
  import { AsyncLocalStorage } from "async_hooks";
2
2
  const DEFAULT_SERVER_URL = process.env.CONSENSUS_SERVER_URL || "https://consensus.canister.software";
3
+ const USD_SCALE = 1_000_000;
4
+ const PROXY_PAID_REQUEST_COST_USD = 0.0001;
3
5
  class ProxyClientError extends Error {
6
+ /** HTTP status from proxy response when available. */
4
7
  status;
8
+ /** Parsed proxy error payload when available. */
5
9
  data;
6
10
  }
7
11
  const proxyFetchContext = new AsyncLocalStorage();
@@ -81,6 +85,22 @@ function parseMaybeJson(text) {
81
85
  return text;
82
86
  }
83
87
  }
88
+ function parseUsdToMicros(value, fieldName) {
89
+ if (typeof value === "undefined" || value === null)
90
+ return null;
91
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
92
+ throw new TypeError(`${fieldName} must be a non-negative number`);
93
+ }
94
+ const micros = Math.round(value * USD_SCALE);
95
+ const normalized = micros / USD_SCALE;
96
+ if (Math.abs(normalized - value) > 1e-9) {
97
+ throw new TypeError(`${fieldName} supports at most 6 decimal places`);
98
+ }
99
+ return micros;
100
+ }
101
+ function microsToUsd(micros) {
102
+ return Number((micros / USD_SCALE).toFixed(6));
103
+ }
84
104
  function normalizeBody(body, headers) {
85
105
  if (typeof body === "undefined" || body === null)
86
106
  return undefined;
@@ -116,6 +136,20 @@ function normalizeBody(body, headers) {
116
136
  }
117
137
  throw new Error(`Unsupported request body type: ${typeof body}`);
118
138
  }
139
+ function bodyToInit(body, headers) {
140
+ const normalized = normalizeBody(body, headers);
141
+ if (typeof normalized === "undefined")
142
+ return undefined;
143
+ if (typeof normalized === "string")
144
+ return normalized;
145
+ if (typeof normalized === "object" &&
146
+ normalized !== null &&
147
+ !(normalized instanceof ArrayBuffer) &&
148
+ !ArrayBuffer.isView(normalized)) {
149
+ return JSON.stringify(normalized);
150
+ }
151
+ return normalized;
152
+ }
119
153
  async function buildProxyPayload(input, init = {}, controlHeaders) {
120
154
  let targetUrl;
121
155
  let method = "GET";
@@ -181,9 +215,7 @@ function toFetchResponse(proxyResult, requestUrl) {
181
215
  : typeof payload === "string"
182
216
  ? payload
183
217
  : JSON.stringify(payload);
184
- if (payload !== null &&
185
- typeof payload === "object" &&
186
- !headers.has("content-type")) {
218
+ if (payload !== null && typeof payload === "object" && !headers.has("content-type")) {
187
219
  headers.set("content-type", "application/json");
188
220
  }
189
221
  const response = new Response(body, {
@@ -202,6 +234,20 @@ function toFetchResponse(proxyResult, requestUrl) {
202
234
  });
203
235
  return response;
204
236
  }
237
+ function responseHeadersToRecord(headers) {
238
+ const record = {};
239
+ headers.forEach((value, key) => {
240
+ record[key] = value;
241
+ });
242
+ return record;
243
+ }
244
+ function isLikelyPaidProxyResponse(proxyResult) {
245
+ const meta = proxyResult.meta;
246
+ if (meta && typeof meta === "object" && "cached" in meta) {
247
+ return meta.cached !== true;
248
+ }
249
+ return true;
250
+ }
205
251
  function ensureInterceptorInstalled() {
206
252
  if (interceptorInstalled)
207
253
  return;
@@ -235,6 +281,60 @@ export function ProxyClient(fetchWithPayment, options = {}) {
235
281
  const serverUrl = trimTrailingSlash(DEFAULT_SERVER_URL);
236
282
  const proxyEndpoint = `${serverUrl}/proxy`;
237
283
  const baseControlHeaders = controlHeadersFromOptions(options);
284
+ const limitMicros = parseUsdToMicros(options.limit_usd, "limit_usd");
285
+ const requestCostMicros = parseUsdToMicros(PROXY_PAID_REQUEST_COST_USD, "proxy_request_cost_usd") ?? 0;
286
+ let spentMicros = 0;
287
+ let limitCallbackFired = false;
288
+ function computeStandDownState() {
289
+ if (limitMicros === null)
290
+ return false;
291
+ if (spentMicros >= limitMicros)
292
+ return true;
293
+ if (requestCostMicros <= 0)
294
+ return false;
295
+ return spentMicros + requestCostMicros > limitMicros;
296
+ }
297
+ function getBudget() {
298
+ const remainingMicros = limitMicros === null ? null : Math.max(0, limitMicros - spentMicros);
299
+ return {
300
+ limit_usd: limitMicros === null ? null : microsToUsd(limitMicros),
301
+ request_cost_usd: microsToUsd(requestCostMicros),
302
+ spent_usd: microsToUsd(spentMicros),
303
+ remaining_usd: remainingMicros === null ? null : microsToUsd(remainingMicros),
304
+ exhausted: computeStandDownState(),
305
+ };
306
+ }
307
+ function isStandDown() {
308
+ const exhausted = computeStandDownState();
309
+ if (exhausted &&
310
+ !limitCallbackFired &&
311
+ typeof options.on_limit_reached === "function") {
312
+ limitCallbackFired = true;
313
+ options.on_limit_reached(getBudget());
314
+ }
315
+ return exhausted;
316
+ }
317
+ function incrementSpend(proxyResult) {
318
+ if (requestCostMicros <= 0)
319
+ return;
320
+ if (!isLikelyPaidProxyResponse(proxyResult))
321
+ return;
322
+ spentMicros += requestCostMicros;
323
+ if (limitMicros !== null && spentMicros > limitMicros)
324
+ spentMicros = limitMicros;
325
+ isStandDown();
326
+ }
327
+ function resetBudget() {
328
+ spentMicros = 0;
329
+ limitCallbackFired = false;
330
+ }
331
+ async function passthroughFetchOrThrow(input, init) {
332
+ const directFetch = currentPassthroughFetch();
333
+ if (!directFetch) {
334
+ throw new ProxyClientError("Global fetch is unavailable; cannot bypass proxy while in stand-down mode.");
335
+ }
336
+ return directFetch(input, init);
337
+ }
238
338
  async function requestProxy(payload) {
239
339
  const response = await fetchWithPayment(proxyEndpoint, {
240
340
  method: "POST",
@@ -254,38 +354,99 @@ export function ProxyClient(fetchWithPayment, options = {}) {
254
354
  }
255
355
  return toProxyResult(response, parsed);
256
356
  }
357
+ async function requestDirectFromPayload(payload, reason) {
358
+ const targetUrl = String(payload.target_url || "").trim();
359
+ if (!targetUrl) {
360
+ throw new ProxyClientError("target_url is required when proxy is in stand-down mode");
361
+ }
362
+ const method = String(payload.method || "GET").toUpperCase();
363
+ const headers = normalizeHeaders(payload.headers);
364
+ const init = {
365
+ method,
366
+ headers,
367
+ };
368
+ if (!["GET", "HEAD"].includes(method) && typeof payload.body !== "undefined") {
369
+ const convertedBody = bodyToInit(payload.body, headers);
370
+ if (typeof convertedBody !== "undefined")
371
+ init.body = convertedBody;
372
+ }
373
+ const response = await passthroughFetchOrThrow(targetUrl, init);
374
+ const raw = await response.text();
375
+ const parsed = parseMaybeJson(raw);
376
+ return {
377
+ status: response.status,
378
+ statusText: response.statusText || "",
379
+ headers: responseHeadersToRecord(response.headers),
380
+ data: parsed,
381
+ meta: { bypassed: true, reason },
382
+ };
383
+ }
257
384
  async function proxiedFetch(input, init = {}, perRequestOptions = {}) {
385
+ if (isStandDown()) {
386
+ return passthroughFetchOrThrow(input, init);
387
+ }
258
388
  const controlHeaders = {
259
389
  ...baseControlHeaders,
260
390
  ...controlHeadersFromOptions(perRequestOptions),
261
391
  };
262
392
  const payload = await buildProxyPayload(input, init, controlHeaders);
263
393
  const proxyResult = await requestProxy(payload);
394
+ incrementSpend(proxyResult);
264
395
  const requestUrl = typeof Request !== "undefined" && input instanceof Request ? input.url : String(input);
265
396
  return toFetchResponse(proxyResult, requestUrl);
266
397
  }
267
398
  async function proxiedRequest(payload = {}, perRequestOptions = {}) {
399
+ if (isStandDown()) {
400
+ return requestDirectFromPayload(payload, "limit_reached");
401
+ }
268
402
  const controlHeaders = {
269
403
  ...baseControlHeaders,
270
404
  ...controlHeadersFromOptions(perRequestOptions),
271
405
  ...normalizeHeaders(payload.headers),
272
406
  };
273
- return requestProxy({
407
+ const proxyResult = await requestProxy({
274
408
  target_url: String(payload.target_url || ""),
275
409
  method: String(payload.method || "GET").toUpperCase(),
276
410
  headers: controlHeaders,
277
411
  ...(typeof payload.body !== "undefined" ? { body: payload.body } : {}),
278
412
  });
413
+ incrementSpend(proxyResult);
414
+ return proxyResult;
415
+ }
416
+ function createFetch(pathname = "/") {
417
+ return (input, init) => {
418
+ if (!shouldProxyPath(pathname, options)) {
419
+ return passthroughFetchOrThrow(input, init);
420
+ }
421
+ return proxiedFetch(input, init);
422
+ };
423
+ }
424
+ async function runWithPath(pathname, run) {
425
+ if (typeof run !== "function") {
426
+ throw new TypeError("runWithPath requires a callback function");
427
+ }
428
+ ensureInterceptorInstalled();
429
+ const shouldProxy = shouldProxyPath(pathname, options);
430
+ return new Promise((resolve, reject) => {
431
+ proxyFetchContext.run({ proxyFetch: shouldProxy ? proxiedFetch : null }, () => {
432
+ Promise.resolve()
433
+ .then(run)
434
+ .then(resolve, reject);
435
+ });
436
+ });
279
437
  }
280
- return (req, _res, next) => {
438
+ const middleware = ((req, _res, next) => {
281
439
  const routePath = req?.path || req?.url || "/";
282
- const shouldProxy = shouldProxyPath(routePath, options);
440
+ const shouldProxy = shouldProxyPath(routePath, options) && !isStandDown();
283
441
  req.consensus = {
284
442
  strategy,
285
443
  shouldProxy,
286
444
  fetch: proxiedFetch,
287
445
  request: proxiedRequest,
288
446
  passthroughFetch: currentPassthroughFetch(),
447
+ createFetch,
448
+ getBudget,
449
+ isStandDown,
289
450
  };
290
451
  if (strategy !== "auto") {
291
452
  next();
@@ -293,5 +454,13 @@ export function ProxyClient(fetchWithPayment, options = {}) {
293
454
  }
294
455
  ensureInterceptorInstalled();
295
456
  proxyFetchContext.run({ proxyFetch: shouldProxy ? proxiedFetch : null }, () => next());
296
- };
457
+ });
458
+ middleware.fetch = proxiedFetch;
459
+ middleware.request = proxiedRequest;
460
+ middleware.runWithPath = runWithPath;
461
+ middleware.createFetch = createFetch;
462
+ middleware.getBudget = getBudget;
463
+ middleware.resetBudget = resetBudget;
464
+ middleware.isStandDown = isStandDown;
465
+ return middleware;
297
466
  }
@@ -1,8 +1,31 @@
1
1
  const DEFAULT_SERVER_URL = process.env.CONSENSUS_SERVER_URL || "https://consensus.canister.software";
2
+ const USD_SCALE = 1_000_000;
3
+ const PRICING_PRESETS = {
4
+ TIME: {
5
+ model: "time",
6
+ pricePerMinute: 0.001,
7
+ pricePerMB: 0,
8
+ },
9
+ DATA: {
10
+ model: "data",
11
+ pricePerMinute: 0,
12
+ pricePerMB: 0.00012,
13
+ },
14
+ HYBRID: {
15
+ model: "hybrid",
16
+ pricePerMinute: 0.0005,
17
+ pricePerMB: 0.0001,
18
+ },
19
+ };
2
20
  class SocketClientError extends Error {
21
+ /** HTTP status from token endpoint when available. */
3
22
  status;
23
+ /** Parsed server error payload when available. */
4
24
  data;
5
25
  }
26
+ /** Thrown when requested token cost exceeds remaining websocket budget. */
27
+ class SocketBudgetLimitError extends SocketClientError {
28
+ }
6
29
  function trimTrailingSlash(value) {
7
30
  return String(value || "").replace(/\/+$/, "");
8
31
  }
@@ -16,6 +39,32 @@ function parseMaybeJson(text) {
16
39
  return text;
17
40
  }
18
41
  }
42
+ function parseUsdToMicros(value, fieldName) {
43
+ if (typeof value === "undefined" || value === null)
44
+ return null;
45
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
46
+ throw new TypeError(`${fieldName} must be a non-negative number`);
47
+ }
48
+ const micros = Math.round(value * USD_SCALE);
49
+ const normalized = micros / USD_SCALE;
50
+ if (Math.abs(normalized - value) > 1e-9) {
51
+ throw new TypeError(`${fieldName} supports at most 6 decimal places`);
52
+ }
53
+ return micros;
54
+ }
55
+ function microsToUsd(micros) {
56
+ return Number((micros / USD_SCALE).toFixed(6));
57
+ }
58
+ function calculateSessionCost(pricing, minutes, megabytes) {
59
+ let cost = 0;
60
+ if (pricing.model === "time" || pricing.model === "hybrid") {
61
+ cost += (minutes || 0) * pricing.pricePerMinute;
62
+ }
63
+ if (pricing.model === "data" || pricing.model === "hybrid") {
64
+ cost += (megabytes || 0) * pricing.pricePerMB;
65
+ }
66
+ return cost;
67
+ }
19
68
  function normalizeTokenParams(defaults, params) {
20
69
  const merged = {
21
70
  model: params?.model ?? defaults?.model ?? "hybrid",
@@ -104,10 +153,73 @@ export function SocketClient(fetchWithPayment, options = {}) {
104
153
  const baseUrl = trimTrailingSlash(DEFAULT_SERVER_URL);
105
154
  const openTimeoutMs = options.openTimeoutMs ?? 12_000;
106
155
  const reconnectIntervalMs = options.reconnectIntervalMs ?? 2_000;
156
+ const limitMicros = parseUsdToMicros(options.limit_usd, "limit_usd");
107
157
  let lastTokenParams;
158
+ let spentMicros = 0;
159
+ let limitCallbackFired = false;
160
+ let lastQuoteMicros = 0;
161
+ function computeStandDownState(nextCostMicros = 0) {
162
+ if (limitMicros === null)
163
+ return false;
164
+ if (spentMicros >= limitMicros)
165
+ return true;
166
+ return spentMicros + nextCostMicros > limitMicros;
167
+ }
168
+ function getBudget() {
169
+ const remainingMicros = limitMicros === null ? null : Math.max(0, limitMicros - spentMicros);
170
+ return {
171
+ limit_usd: limitMicros === null ? null : microsToUsd(limitMicros),
172
+ spent_usd: microsToUsd(spentMicros),
173
+ remaining_usd: remainingMicros === null ? null : microsToUsd(remainingMicros),
174
+ exhausted: computeStandDownState(),
175
+ last_quote_usd: microsToUsd(lastQuoteMicros),
176
+ };
177
+ }
178
+ function isStandDown() {
179
+ const exhausted = computeStandDownState();
180
+ if (exhausted &&
181
+ !limitCallbackFired &&
182
+ typeof options.on_limit_reached === "function") {
183
+ limitCallbackFired = true;
184
+ options.on_limit_reached(getBudget());
185
+ }
186
+ return exhausted;
187
+ }
188
+ function ensureBudgetFor(quotedCostMicros) {
189
+ if (!computeStandDownState(quotedCostMicros))
190
+ return;
191
+ isStandDown();
192
+ throw new SocketBudgetLimitError("WebSocket budget limit reached; token request blocked");
193
+ }
194
+ function incrementSpend(quotedCostMicros) {
195
+ if (quotedCostMicros <= 0)
196
+ return;
197
+ spentMicros += quotedCostMicros;
198
+ if (limitMicros !== null && spentMicros > limitMicros)
199
+ spentMicros = limitMicros;
200
+ isStandDown();
201
+ }
202
+ function resetBudget() {
203
+ spentMicros = 0;
204
+ limitCallbackFired = false;
205
+ lastQuoteMicros = 0;
206
+ }
207
+ function quoteTokenCostMicros(params) {
208
+ const pricingKey = params.model === "time" ? "TIME" : params.model === "data" ? "DATA" : "HYBRID";
209
+ const pricing = PRICING_PRESETS[pricingKey];
210
+ const usd = calculateSessionCost(pricing, params.minutes, params.megabytes);
211
+ return parseUsdToMicros(usd, "session_cost_usd") ?? 0;
212
+ }
108
213
  async function requestTokenInternal(params) {
109
214
  const normalized = normalizeTokenParams(options.defaults, params);
110
215
  lastTokenParams = normalized;
216
+ const quotedCostMicros = quoteTokenCostMicros({
217
+ model: normalized.model,
218
+ minutes: normalized.minutes,
219
+ megabytes: normalized.megabytes,
220
+ });
221
+ lastQuoteMicros = quotedCostMicros;
222
+ ensureBudgetFor(quotedCostMicros);
111
223
  const query = new URLSearchParams({
112
224
  model: normalized.model,
113
225
  minutes: String(normalized.minutes),
@@ -132,6 +244,7 @@ export function SocketClient(fetchWithPayment, options = {}) {
132
244
  if (!auth?.connect_url || !auth?.token) {
133
245
  throw new SocketClientError("Invalid token response: missing token/connect_url");
134
246
  }
247
+ incrementSpend(quotedCostMicros);
135
248
  return {
136
249
  token: String(auth.token),
137
250
  connect_url: String(auth.connect_url),
@@ -251,6 +364,10 @@ export function SocketClient(fetchWithPayment, options = {}) {
251
364
  catch (error) {
252
365
  callbacks?.onError?.(error);
253
366
  emit("error", error);
367
+ if (error instanceof SocketBudgetLimitError) {
368
+ state.reconnecting = false;
369
+ return;
370
+ }
254
371
  if (!state.closedByCaller) {
255
372
  reconnectTimer = setTimeout(async () => {
256
373
  if (!state.closedByCaller) {
@@ -265,6 +382,9 @@ export function SocketClient(fetchWithPayment, options = {}) {
265
382
  catch (retryError) {
266
383
  callbacks?.onError?.(retryError);
267
384
  emit("error", retryError);
385
+ if (retryError instanceof SocketBudgetLimitError) {
386
+ state.reconnecting = false;
387
+ }
268
388
  }
269
389
  }
270
390
  }, reconnectIntervalMs);
@@ -313,5 +433,8 @@ export function SocketClient(fetchWithPayment, options = {}) {
313
433
  return {
314
434
  requestToken,
315
435
  connect,
436
+ getBudget,
437
+ resetBudget,
438
+ isStandDown,
316
439
  };
317
440
  }
package/index.d.ts CHANGED
@@ -1,13 +1,100 @@
1
1
  export interface ProxyClientOptions {
2
+ /**
3
+ * Route filtering behavior for inbound server paths.
4
+ * - "inclusive": proxy everything except `routes`
5
+ * - "exclusive": proxy only `routes`
6
+ */
2
7
  mode?: "inclusive" | "exclusive";
8
+ /**
9
+ * Path rules used with `mode`, for example `["/health", "/metrics"]`.
10
+ * Query params are ignored; matching is based on path only.
11
+ */
3
12
  routes?: string[];
13
+ /**
14
+ * Path matcher behavior for `routes`.
15
+ * - false (default): exact path only (`/route` does not match `/route/subroute`)
16
+ * - true: include subroutes (`/route` matches `/route/*`)
17
+ */
4
18
  matchSubroutes?: boolean;
19
+ /**
20
+ * Interception strategy.
21
+ * - "auto": globally intercepts `fetch` for route-matched request scope
22
+ * - "manual": does not intercept global `fetch`; use `req.consensus.fetch` / `request`
23
+ */
5
24
  strategy?: "auto" | "manual";
25
+ /**
26
+ * Cache time-to-live in seconds for proxy responses.
27
+ * Sent as `x-cache-ttl`; controls how long deduped responses can be reused.
28
+ */
6
29
  cache_ttl?: number;
30
+ /**
31
+ * Enables verbose proxy response payload.
32
+ * When true, proxy responses include `meta` with fields like:
33
+ * `cached`, `dedupe_key`, `processing_ms`, and `timestamp`.
34
+ */
7
35
  verbose?: boolean;
36
+ /**
37
+ * Preferred proxy region, for example `"us-east"`.
38
+ * Sent as `x-node-region`.
39
+ */
8
40
  node_region?: string;
41
+ /**
42
+ * Force routing through a specific node domain, for example:
43
+ * `"nodexyz.consensus.canister.software"`.
44
+ * Sent as `x-node-domain`.
45
+ */
9
46
  node_domain?: string;
47
+ /**
48
+ * Exclude a specific node/domain from routing.
49
+ * Sent as `x-node-exclude`.
50
+ */
10
51
  node_exclude?: string;
52
+ /**
53
+ * Max proxy spend in USD (up to 6 decimals).
54
+ * Once exhausted, ProxyClient stands down and uses direct fetch.
55
+ */
56
+ limit_usd?: number;
57
+ /** Called once when proxy budget is exhausted and stand-down activates. */
58
+ on_limit_reached?: (budget: ProxyBudgetSnapshot) => void;
59
+ }
60
+
61
+ export interface ProxyBudgetSnapshot {
62
+ /** Configured max spend in USD, or null when no limit is configured. */
63
+ limit_usd: number | null;
64
+ /** Fixed server proxy charge per paid request. */
65
+ request_cost_usd: number;
66
+ /** Total spent so far in USD. */
67
+ spent_usd: number;
68
+ /** Remaining budget in USD, or null when unlimited. */
69
+ remaining_usd: number | null;
70
+ /** True when budget guard has disabled further proxying. */
71
+ exhausted: boolean;
72
+ }
73
+
74
+ export interface ProxyResponseShape {
75
+ /** HTTP status code returned by proxy response. */
76
+ status: number;
77
+ /** HTTP reason phrase from proxy response. */
78
+ statusText: string;
79
+ /** Response headers from the target/proxy response. */
80
+ headers: Record<string, string>;
81
+ /** Parsed payload body (JSON/object/string) returned by proxy. */
82
+ data: unknown;
83
+ /**
84
+ * Verbose metadata returned when `verbose` is enabled.
85
+ * Common fields:
86
+ * - `cached`: whether response came from proxy cache
87
+ * - `dedupe_key`: deduplication key used by proxy
88
+ * - `processing_ms`: end-to-end proxy processing duration
89
+ * - `timestamp`: ISO timestamp generated by proxy
90
+ */
91
+ meta: {
92
+ cached?: boolean;
93
+ dedupe_key?: string;
94
+ processing_ms?: number;
95
+ timestamp?: string;
96
+ [key: string]: unknown;
97
+ } | null;
11
98
  }
12
99
 
13
100
  export interface ProxyClientRequestContext {
@@ -27,23 +114,43 @@ export interface ProxyClientRequestContext {
27
114
  body?: unknown;
28
115
  },
29
116
  perRequestOptions?: Partial<ProxyClientOptions>
30
- ) => Promise<{
31
- status: number;
32
- statusText: string;
33
- headers: Record<string, string>;
34
- data: unknown;
35
- meta: unknown;
36
- }>;
117
+ ) => Promise<ProxyResponseShape>;
37
118
  passthroughFetch?: ((input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) | null;
119
+ createFetch?: (pathname?: string) => (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
120
+ getBudget?: () => ProxyBudgetSnapshot;
121
+ isStandDown?: () => boolean;
38
122
  };
39
123
  [key: string]: unknown;
40
124
  }
41
125
 
42
- export type ProxyClientMiddleware = (
126
+ export interface ProxyClientRuntime {
127
+ fetch(
128
+ input: RequestInfo | URL,
129
+ init?: RequestInit,
130
+ perRequestOptions?: Partial<ProxyClientOptions>
131
+ ): Promise<Response>;
132
+ request(
133
+ payload: {
134
+ target_url?: string;
135
+ method?: string;
136
+ headers?: Record<string, string>;
137
+ body?: unknown;
138
+ },
139
+ perRequestOptions?: Partial<ProxyClientOptions>
140
+ ): Promise<ProxyResponseShape>;
141
+ runWithPath<T>(pathname: string, run: () => T | Promise<T>): Promise<T>;
142
+ createFetch(pathname?: string): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
143
+ getBudget(): ProxyBudgetSnapshot;
144
+ resetBudget(): void;
145
+ isStandDown(): boolean;
146
+ }
147
+
148
+ export type ProxyClientMiddleware = ((
43
149
  req: ProxyClientRequestContext,
44
150
  res: unknown,
45
151
  next: (err?: unknown) => void
46
- ) => void;
152
+ ) => void) &
153
+ ProxyClientRuntime;
47
154
 
48
155
  export declare function ProxyClient(
49
156
  fetchWithPayment: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
@@ -53,11 +160,17 @@ export declare function ProxyClient(
53
160
  export type ConsensusSocketModel = "hybrid" | "time" | "data";
54
161
 
55
162
  export interface ConsensusSocketTokenParams {
163
+ /** Billing model for session pricing. */
56
164
  model?: ConsensusSocketModel;
165
+ /** Session duration to purchase (integer minutes, >= 0). */
57
166
  minutes?: number;
167
+ /** Session data amount to purchase (integer MB, >= 0). */
58
168
  megabytes?: number;
169
+ /** Optional preferred node region for token/session routing (e.g. `"us-east"`). */
59
170
  nodeRegion?: string;
171
+ /** Optional hard route to a specific node domain (e.g. `"nodexyz.consensus.canister.software"`). */
60
172
  nodeDomain?: string;
173
+ /** Optional node/domain to exclude from routing. */
61
174
  nodeExclude?: string;
62
175
  }
63
176
 
@@ -101,10 +214,31 @@ export interface ConsensusSocketSession {
101
214
  }
102
215
 
103
216
  export interface ConsensusSocketClientOptions {
217
+ /** Custom WebSocket constructor; auto-detected when omitted. */
104
218
  webSocketFactory?: new (...args: unknown[]) => unknown;
219
+ /** Max time to wait for websocket open before failing (ms). */
105
220
  openTimeoutMs?: number;
221
+ /** Fixed reconnect delay (ms). */
106
222
  reconnectIntervalMs?: number;
223
+ /** Default token params merged into each requestToken call. */
107
224
  defaults?: ConsensusSocketTokenParams;
225
+ /** Max websocket spend in USD (up to 6 decimals) before token requests are blocked. */
226
+ limit_usd?: number;
227
+ /** Called once when websocket budget is exhausted. */
228
+ on_limit_reached?: (budget: ConsensusSocketBudgetSnapshot) => void;
229
+ }
230
+
231
+ export interface ConsensusSocketBudgetSnapshot {
232
+ /** Configured max spend in USD, or null when no limit is configured. */
233
+ limit_usd: number | null;
234
+ /** Total spent so far in USD. */
235
+ spent_usd: number;
236
+ /** Remaining budget in USD, or null when unlimited. */
237
+ remaining_usd: number | null;
238
+ /** True when budget guard blocks further token purchases. */
239
+ exhausted: boolean;
240
+ /** Last locally quoted token/session price in USD. */
241
+ last_quote_usd: number;
108
242
  }
109
243
 
110
244
  export interface ConsensusSocketClient {
@@ -126,6 +260,9 @@ export interface ConsensusSocketClient {
126
260
  callbacks: ConsensusSocketCallbacks | undefined,
127
261
  options: { safe: true }
128
262
  ): Promise<ConsensusSocketSafeResult<ConsensusSocketSession>>;
263
+ getBudget(): ConsensusSocketBudgetSnapshot;
264
+ resetBudget(): void;
265
+ isStandDown(): boolean;
129
266
  }
130
267
 
131
268
  export declare function SocketClient(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canister-software/consensus-cli",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.5",
4
4
  "description": "Consensus SDK for interacting with the Consensus protocol",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",