@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 +29 -1
- package/dist/proxy-client.js +176 -7
- package/dist/socket-client.js +123 -0
- package/index.d.ts +146 -9
- package/package.json +1 -1
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
|
|
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:
|
package/dist/proxy-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/socket-client.js
CHANGED
|
@@ -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
|
|
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(
|