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

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.
@@ -0,0 +1,297 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+ const DEFAULT_SERVER_URL = process.env.CONSENSUS_SERVER_URL || "https://consensus.canister.software";
3
+ class ProxyClientError extends Error {
4
+ status;
5
+ data;
6
+ }
7
+ const proxyFetchContext = new AsyncLocalStorage();
8
+ let interceptorInstalled = false;
9
+ let passthroughFetch = typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : null;
10
+ function trimTrailingSlash(value) {
11
+ return String(value || "").replace(/\/+$/, "");
12
+ }
13
+ function normalizePath(value) {
14
+ const path = String(value || "/").split("?")[0] || "/";
15
+ if (path === "/")
16
+ return "/";
17
+ const normalized = path.replace(/\/+$/, "");
18
+ return normalized || "/";
19
+ }
20
+ function normalizeHeaders(headers) {
21
+ if (!headers)
22
+ return {};
23
+ if (typeof Headers !== "undefined" && headers instanceof Headers) {
24
+ return Object.fromEntries(headers.entries());
25
+ }
26
+ if (Array.isArray(headers)) {
27
+ return Object.fromEntries(headers.map(([key, value]) => [String(key), String(value)]));
28
+ }
29
+ const result = {};
30
+ for (const [key, value] of Object.entries(headers)) {
31
+ if (typeof value === "undefined" || value === null)
32
+ continue;
33
+ result[key] = String(value);
34
+ }
35
+ return result;
36
+ }
37
+ function pathMatches(pathname, route, matchSubroutes = false) {
38
+ const requestPath = normalizePath(pathname);
39
+ const configuredRoute = normalizePath(route);
40
+ if (requestPath === configuredRoute)
41
+ return true;
42
+ if (!matchSubroutes)
43
+ return false;
44
+ if (configuredRoute === "/")
45
+ return true;
46
+ return requestPath.startsWith(`${configuredRoute}/`);
47
+ }
48
+ function shouldProxyPath(pathname, options) {
49
+ const mode = options.mode === "exclusive" ? "exclusive" : "inclusive";
50
+ const routes = Array.isArray(options.routes) ? options.routes : [];
51
+ const matchSubroutes = Boolean(options.matchSubroutes);
52
+ const matched = routes.some((route) => pathMatches(pathname, route, matchSubroutes));
53
+ return mode === "exclusive" ? matched : !matched;
54
+ }
55
+ function controlHeadersFromOptions(options) {
56
+ const headers = {};
57
+ if (typeof options.cache_ttl !== "undefined" && options.cache_ttl !== null) {
58
+ headers["x-cache-ttl"] = String(options.cache_ttl);
59
+ }
60
+ if (options.verbose === true) {
61
+ headers["x-verbose"] = "true";
62
+ }
63
+ if (typeof options.node_region === "string" && options.node_region.trim()) {
64
+ headers["x-node-region"] = options.node_region.trim();
65
+ }
66
+ if (typeof options.node_domain === "string" && options.node_domain.trim()) {
67
+ headers["x-node-domain"] = options.node_domain.trim();
68
+ }
69
+ if (typeof options.node_exclude === "string" && options.node_exclude.trim()) {
70
+ headers["x-node-exclude"] = options.node_exclude.trim();
71
+ }
72
+ return headers;
73
+ }
74
+ function parseMaybeJson(text) {
75
+ if (!text)
76
+ return null;
77
+ try {
78
+ return JSON.parse(text);
79
+ }
80
+ catch {
81
+ return text;
82
+ }
83
+ }
84
+ function normalizeBody(body, headers) {
85
+ if (typeof body === "undefined" || body === null)
86
+ return undefined;
87
+ if (typeof body === "string")
88
+ return body;
89
+ if (typeof body === "number" || typeof body === "boolean")
90
+ return body;
91
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
92
+ if (!headers["content-type"] && !headers["Content-Type"]) {
93
+ headers["content-type"] = "application/x-www-form-urlencoded;charset=UTF-8";
94
+ }
95
+ return body.toString();
96
+ }
97
+ if (typeof Buffer !== "undefined" && Buffer.isBuffer(body)) {
98
+ return body.toString("utf8");
99
+ }
100
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
101
+ const bytes = body instanceof ArrayBuffer
102
+ ? new Uint8Array(body)
103
+ : new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
104
+ if (typeof Buffer !== "undefined")
105
+ return Buffer.from(bytes).toString("utf8");
106
+ return new TextDecoder().decode(bytes);
107
+ }
108
+ if (typeof FormData !== "undefined" && body instanceof FormData) {
109
+ throw new Error("FormData request bodies are not supported by ProxyClient");
110
+ }
111
+ if (typeof body === "object") {
112
+ if (!headers["content-type"] && !headers["Content-Type"]) {
113
+ headers["content-type"] = "application/json";
114
+ }
115
+ return body;
116
+ }
117
+ throw new Error(`Unsupported request body type: ${typeof body}`);
118
+ }
119
+ async function buildProxyPayload(input, init = {}, controlHeaders) {
120
+ let targetUrl;
121
+ let method = "GET";
122
+ let headers = {};
123
+ let body;
124
+ if (typeof Request !== "undefined" && input instanceof Request) {
125
+ targetUrl = input.url;
126
+ method = input.method || method;
127
+ headers = normalizeHeaders(input.headers);
128
+ if (!("body" in init) && method !== "GET" && method !== "HEAD") {
129
+ const raw = await input.clone().text();
130
+ if (raw.length > 0)
131
+ body = raw;
132
+ }
133
+ }
134
+ else if (typeof input === "string" || input instanceof URL) {
135
+ targetUrl = String(input);
136
+ }
137
+ else {
138
+ throw new Error("ProxyClient fetch input must be URL string, URL, or Request");
139
+ }
140
+ method = String(init.method || method || "GET").toUpperCase();
141
+ headers = {
142
+ ...controlHeaders,
143
+ ...headers,
144
+ ...normalizeHeaders(init.headers),
145
+ };
146
+ if ("body" in init) {
147
+ body = init.body;
148
+ }
149
+ const normalizedBody = normalizeBody(body, headers);
150
+ return {
151
+ target_url: targetUrl,
152
+ method,
153
+ headers,
154
+ ...(typeof normalizedBody !== "undefined" ? { body: normalizedBody } : {}),
155
+ };
156
+ }
157
+ function toProxyResult(response, data) {
158
+ if (data && typeof data === "object" && "status" in data && "data" in data) {
159
+ const maybe = data;
160
+ return {
161
+ status: Number(maybe.status) || response.status || 200,
162
+ statusText: maybe.statusText || response.statusText || "",
163
+ headers: maybe.headers || {},
164
+ data: maybe.data,
165
+ meta: maybe.meta ?? null,
166
+ };
167
+ }
168
+ return {
169
+ status: response.status || 500,
170
+ statusText: response.statusText || "",
171
+ headers: {},
172
+ data,
173
+ meta: null,
174
+ };
175
+ }
176
+ function toFetchResponse(proxyResult, requestUrl) {
177
+ const headers = new Headers(proxyResult.headers || {});
178
+ const payload = proxyResult.data;
179
+ const body = payload === null || typeof payload === "undefined"
180
+ ? null
181
+ : typeof payload === "string"
182
+ ? payload
183
+ : JSON.stringify(payload);
184
+ if (payload !== null &&
185
+ typeof payload === "object" &&
186
+ !headers.has("content-type")) {
187
+ headers.set("content-type", "application/json");
188
+ }
189
+ const response = new Response(body, {
190
+ status: proxyResult.status,
191
+ statusText: proxyResult.statusText || "",
192
+ headers,
193
+ });
194
+ Object.defineProperty(response, "consensus", {
195
+ value: {
196
+ request_url: requestUrl,
197
+ meta: proxyResult.meta || null,
198
+ },
199
+ enumerable: false,
200
+ configurable: false,
201
+ writable: false,
202
+ });
203
+ return response;
204
+ }
205
+ function ensureInterceptorInstalled() {
206
+ if (interceptorInstalled)
207
+ return;
208
+ if (typeof globalThis.fetch === "function") {
209
+ passthroughFetch = globalThis.fetch.bind(globalThis);
210
+ }
211
+ if (!passthroughFetch) {
212
+ throw new Error("Global fetch is unavailable; use strategy: 'manual' or polyfill fetch.");
213
+ }
214
+ globalThis.fetch = ((input, init) => {
215
+ const state = proxyFetchContext.getStore();
216
+ if (state?.proxyFetch)
217
+ return state.proxyFetch(input, init);
218
+ return passthroughFetch(input, init);
219
+ });
220
+ interceptorInstalled = true;
221
+ }
222
+ function currentPassthroughFetch() {
223
+ if (passthroughFetch)
224
+ return passthroughFetch;
225
+ if (typeof globalThis.fetch === "function") {
226
+ passthroughFetch = globalThis.fetch.bind(globalThis);
227
+ }
228
+ return passthroughFetch;
229
+ }
230
+ export function ProxyClient(fetchWithPayment, options = {}) {
231
+ if (typeof fetchWithPayment !== "function") {
232
+ throw new TypeError("ProxyClient requires fetchWithPayment as the first argument");
233
+ }
234
+ const strategy = options.strategy === "manual" ? "manual" : "auto";
235
+ const serverUrl = trimTrailingSlash(DEFAULT_SERVER_URL);
236
+ const proxyEndpoint = `${serverUrl}/proxy`;
237
+ const baseControlHeaders = controlHeadersFromOptions(options);
238
+ async function requestProxy(payload) {
239
+ const response = await fetchWithPayment(proxyEndpoint, {
240
+ method: "POST",
241
+ headers: { "content-type": "application/json" },
242
+ body: JSON.stringify(payload),
243
+ });
244
+ const raw = await response.text();
245
+ const parsed = parseMaybeJson(raw);
246
+ if (!response.ok && !(parsed && typeof parsed === "object" && "status" in parsed)) {
247
+ const message = parsed?.message ||
248
+ parsed?.error ||
249
+ `Proxy request failed (${response.status})`;
250
+ const error = new ProxyClientError(message);
251
+ error.status = response.status;
252
+ error.data = parsed;
253
+ throw error;
254
+ }
255
+ return toProxyResult(response, parsed);
256
+ }
257
+ async function proxiedFetch(input, init = {}, perRequestOptions = {}) {
258
+ const controlHeaders = {
259
+ ...baseControlHeaders,
260
+ ...controlHeadersFromOptions(perRequestOptions),
261
+ };
262
+ const payload = await buildProxyPayload(input, init, controlHeaders);
263
+ const proxyResult = await requestProxy(payload);
264
+ const requestUrl = typeof Request !== "undefined" && input instanceof Request ? input.url : String(input);
265
+ return toFetchResponse(proxyResult, requestUrl);
266
+ }
267
+ async function proxiedRequest(payload = {}, perRequestOptions = {}) {
268
+ const controlHeaders = {
269
+ ...baseControlHeaders,
270
+ ...controlHeadersFromOptions(perRequestOptions),
271
+ ...normalizeHeaders(payload.headers),
272
+ };
273
+ return requestProxy({
274
+ target_url: String(payload.target_url || ""),
275
+ method: String(payload.method || "GET").toUpperCase(),
276
+ headers: controlHeaders,
277
+ ...(typeof payload.body !== "undefined" ? { body: payload.body } : {}),
278
+ });
279
+ }
280
+ return (req, _res, next) => {
281
+ const routePath = req?.path || req?.url || "/";
282
+ const shouldProxy = shouldProxyPath(routePath, options);
283
+ req.consensus = {
284
+ strategy,
285
+ shouldProxy,
286
+ fetch: proxiedFetch,
287
+ request: proxiedRequest,
288
+ passthroughFetch: currentPassthroughFetch(),
289
+ };
290
+ if (strategy !== "auto") {
291
+ next();
292
+ return;
293
+ }
294
+ ensureInterceptorInstalled();
295
+ proxyFetchContext.run({ proxyFetch: shouldProxy ? proxiedFetch : null }, () => next());
296
+ };
297
+ }
@@ -0,0 +1,317 @@
1
+ const DEFAULT_SERVER_URL = process.env.CONSENSUS_SERVER_URL || "https://consensus.canister.software";
2
+ class SocketClientError extends Error {
3
+ status;
4
+ data;
5
+ }
6
+ function trimTrailingSlash(value) {
7
+ return String(value || "").replace(/\/+$/, "");
8
+ }
9
+ function parseMaybeJson(text) {
10
+ if (!text)
11
+ return null;
12
+ try {
13
+ return JSON.parse(text);
14
+ }
15
+ catch {
16
+ return text;
17
+ }
18
+ }
19
+ function normalizeTokenParams(defaults, params) {
20
+ const merged = {
21
+ model: params?.model ?? defaults?.model ?? "hybrid",
22
+ minutes: params?.minutes ?? defaults?.minutes ?? 5,
23
+ megabytes: params?.megabytes ?? defaults?.megabytes ?? 50,
24
+ nodeRegion: params?.nodeRegion ?? defaults?.nodeRegion,
25
+ nodeDomain: params?.nodeDomain ?? defaults?.nodeDomain,
26
+ nodeExclude: params?.nodeExclude ?? defaults?.nodeExclude,
27
+ };
28
+ if (!["hybrid", "time", "data"].includes(merged.model)) {
29
+ throw new SocketClientError(`Invalid model '${String(merged.model)}'`);
30
+ }
31
+ if (!Number.isInteger(merged.minutes) || merged.minutes < 0) {
32
+ throw new SocketClientError("minutes must be a non-negative integer");
33
+ }
34
+ if (!Number.isInteger(merged.megabytes) || merged.megabytes < 0) {
35
+ throw new SocketClientError("megabytes must be a non-negative integer");
36
+ }
37
+ return merged;
38
+ }
39
+ function toTokenHeaders(params) {
40
+ const headers = {};
41
+ if (params?.nodeRegion)
42
+ headers["x-node-region"] = params.nodeRegion;
43
+ if (params?.nodeDomain)
44
+ headers["x-node-domain"] = params.nodeDomain;
45
+ if (params?.nodeExclude)
46
+ headers["x-node-exclude"] = params.nodeExclude;
47
+ return headers;
48
+ }
49
+ async function resolveWebSocketFactory(factory) {
50
+ if (factory)
51
+ return factory;
52
+ if (typeof WebSocket !== "undefined")
53
+ return WebSocket;
54
+ const wsModule = await import("ws");
55
+ const maybeCtor = wsModule.default ||
56
+ wsModule.WebSocket;
57
+ if (typeof maybeCtor !== "function") {
58
+ throw new SocketClientError("Unable to resolve a WebSocket constructor");
59
+ }
60
+ return maybeCtor;
61
+ }
62
+ function addListener(socket, event, handler) {
63
+ if (typeof socket.addEventListener === "function") {
64
+ socket.addEventListener(event, handler);
65
+ return;
66
+ }
67
+ if (typeof socket.on === "function") {
68
+ socket.on(event, handler);
69
+ }
70
+ }
71
+ function removeListener(socket, event, handler) {
72
+ if (typeof socket.removeEventListener === "function") {
73
+ socket.removeEventListener(event, handler);
74
+ return;
75
+ }
76
+ if (typeof socket.off === "function") {
77
+ socket.off(event, handler);
78
+ return;
79
+ }
80
+ if (typeof socket.removeListener === "function") {
81
+ socket.removeListener(event, handler);
82
+ }
83
+ }
84
+ function getOpenStateValue(socket) {
85
+ const maybeCtor = socket;
86
+ const open = maybeCtor.constructor?.OPEN;
87
+ return typeof open === "number" ? open : 1;
88
+ }
89
+ function getMessagePayload(value) {
90
+ if (value && typeof value === "object" && "data" in value) {
91
+ return value.data;
92
+ }
93
+ return value;
94
+ }
95
+ function toSafeResult(promise) {
96
+ return promise
97
+ .then((data) => ({ ok: true, data }))
98
+ .catch((error) => ({ ok: false, error }));
99
+ }
100
+ export function SocketClient(fetchWithPayment, options = {}) {
101
+ if (typeof fetchWithPayment !== "function") {
102
+ throw new TypeError("SocketClient requires fetchWithPayment as the first argument");
103
+ }
104
+ const baseUrl = trimTrailingSlash(DEFAULT_SERVER_URL);
105
+ const openTimeoutMs = options.openTimeoutMs ?? 12_000;
106
+ const reconnectIntervalMs = options.reconnectIntervalMs ?? 2_000;
107
+ let lastTokenParams;
108
+ async function requestTokenInternal(params) {
109
+ const normalized = normalizeTokenParams(options.defaults, params);
110
+ lastTokenParams = normalized;
111
+ const query = new URLSearchParams({
112
+ model: normalized.model,
113
+ minutes: String(normalized.minutes),
114
+ megabytes: String(normalized.megabytes),
115
+ });
116
+ const response = await fetchWithPayment(`${baseUrl}/ws?${query.toString()}`, {
117
+ method: "GET",
118
+ headers: toTokenHeaders(normalized),
119
+ });
120
+ const raw = await response.text();
121
+ const parsed = parseMaybeJson(raw);
122
+ if (!response.ok) {
123
+ const message = parsed?.message ||
124
+ parsed?.error ||
125
+ `WebSocket token request failed (${response.status})`;
126
+ const error = new SocketClientError(message);
127
+ error.status = response.status;
128
+ error.data = parsed;
129
+ throw error;
130
+ }
131
+ const auth = parsed;
132
+ if (!auth?.connect_url || !auth?.token) {
133
+ throw new SocketClientError("Invalid token response: missing token/connect_url");
134
+ }
135
+ return {
136
+ token: String(auth.token),
137
+ connect_url: String(auth.connect_url),
138
+ expires_in: Number(auth.expires_in ?? 0),
139
+ };
140
+ }
141
+ async function requestToken(params, safeOptions) {
142
+ const task = requestTokenInternal(params);
143
+ if (safeOptions?.safe)
144
+ return toSafeResult(task);
145
+ return task;
146
+ }
147
+ async function connectInternal(connectUrlOrAuth, callbacks) {
148
+ const initialConnectUrl = typeof connectUrlOrAuth === "string"
149
+ ? connectUrlOrAuth
150
+ : connectUrlOrAuth?.connect_url;
151
+ if (!initialConnectUrl) {
152
+ throw new SocketClientError("connect requires connect_url");
153
+ }
154
+ const listeners = {
155
+ open: new Set(),
156
+ message: new Set(),
157
+ close: new Set(),
158
+ error: new Set(),
159
+ };
160
+ const state = {
161
+ connected: false,
162
+ reconnecting: false,
163
+ closedByCaller: false,
164
+ };
165
+ let currentConnectUrl = initialConnectUrl;
166
+ let reconnectTimer = null;
167
+ let activeSocket = null;
168
+ const socketFactory = await resolveWebSocketFactory(options.webSocketFactory);
169
+ const connectHeaders = toTokenHeaders(lastTokenParams ?? options.defaults);
170
+ const emit = (event, ...args) => {
171
+ for (const handler of listeners[event]) {
172
+ handler(...args);
173
+ }
174
+ };
175
+ const clearReconnectTimer = () => {
176
+ if (!reconnectTimer)
177
+ return;
178
+ clearTimeout(reconnectTimer);
179
+ reconnectTimer = null;
180
+ };
181
+ const waitForOpen = (socket) => new Promise((resolve, reject) => {
182
+ let timeout = null;
183
+ const cleanup = () => {
184
+ if (timeout)
185
+ clearTimeout(timeout);
186
+ removeListener(socket, "open", onOpen);
187
+ removeListener(socket, "error", onError);
188
+ removeListener(socket, "close", onClose);
189
+ };
190
+ const onOpen = () => {
191
+ cleanup();
192
+ resolve();
193
+ };
194
+ const onError = (error) => {
195
+ cleanup();
196
+ reject(error);
197
+ };
198
+ const onClose = () => {
199
+ cleanup();
200
+ reject(new SocketClientError("Socket closed before opening"));
201
+ };
202
+ addListener(socket, "open", onOpen);
203
+ addListener(socket, "error", onError);
204
+ addListener(socket, "close", onClose);
205
+ timeout = setTimeout(() => {
206
+ cleanup();
207
+ reject(new SocketClientError("Socket open timeout"));
208
+ }, openTimeoutMs);
209
+ });
210
+ const openSocket = async () => {
211
+ let socketInstance;
212
+ try {
213
+ socketInstance = new socketFactory(currentConnectUrl, { headers: connectHeaders });
214
+ }
215
+ catch {
216
+ socketInstance = new socketFactory(currentConnectUrl);
217
+ }
218
+ addListener(socketInstance, "open", () => {
219
+ state.connected = true;
220
+ state.reconnecting = false;
221
+ callbacks?.onOpen?.();
222
+ emit("open");
223
+ });
224
+ addListener(socketInstance, "message", (event) => {
225
+ const payload = getMessagePayload(event);
226
+ callbacks?.onMessage?.(payload);
227
+ emit("message", payload);
228
+ });
229
+ addListener(socketInstance, "error", (error) => {
230
+ callbacks?.onError?.(error);
231
+ emit("error", error);
232
+ });
233
+ addListener(socketInstance, "close", (event) => {
234
+ state.connected = false;
235
+ callbacks?.onClose?.(event);
236
+ emit("close", event);
237
+ if (state.closedByCaller)
238
+ return;
239
+ clearReconnectTimer();
240
+ reconnectTimer = setTimeout(async () => {
241
+ if (state.closedByCaller)
242
+ return;
243
+ state.reconnecting = true;
244
+ try {
245
+ if (lastTokenParams) {
246
+ const auth = await requestTokenInternal(lastTokenParams);
247
+ currentConnectUrl = auth.connect_url;
248
+ }
249
+ await openSocket();
250
+ }
251
+ catch (error) {
252
+ callbacks?.onError?.(error);
253
+ emit("error", error);
254
+ if (!state.closedByCaller) {
255
+ reconnectTimer = setTimeout(async () => {
256
+ if (!state.closedByCaller) {
257
+ state.reconnecting = true;
258
+ try {
259
+ if (lastTokenParams) {
260
+ const auth = await requestTokenInternal(lastTokenParams);
261
+ currentConnectUrl = auth.connect_url;
262
+ }
263
+ await openSocket();
264
+ }
265
+ catch (retryError) {
266
+ callbacks?.onError?.(retryError);
267
+ emit("error", retryError);
268
+ }
269
+ }
270
+ }, reconnectIntervalMs);
271
+ }
272
+ }
273
+ }, reconnectIntervalMs);
274
+ });
275
+ activeSocket = socketInstance;
276
+ await waitForOpen(socketInstance);
277
+ };
278
+ await openSocket();
279
+ return {
280
+ send(data) {
281
+ if (!activeSocket) {
282
+ throw new SocketClientError("No active socket session");
283
+ }
284
+ const openState = getOpenStateValue(activeSocket);
285
+ if (activeSocket.readyState !== openState) {
286
+ throw new SocketClientError("Socket is not open");
287
+ }
288
+ activeSocket.send(data);
289
+ },
290
+ close(code, reason) {
291
+ state.closedByCaller = true;
292
+ state.reconnecting = false;
293
+ clearReconnectTimer();
294
+ activeSocket?.close(code, reason);
295
+ },
296
+ on(event, handler) {
297
+ listeners[event].add(handler);
298
+ },
299
+ off(event, handler) {
300
+ listeners[event].delete(handler);
301
+ },
302
+ getState() {
303
+ return { ...state };
304
+ },
305
+ };
306
+ }
307
+ async function connect(connectUrlOrAuth, callbacks, safeOptions) {
308
+ const task = connectInternal(connectUrlOrAuth, callbacks);
309
+ if (safeOptions?.safe)
310
+ return toSafeResult(task);
311
+ return task;
312
+ }
313
+ return {
314
+ requestToken,
315
+ connect,
316
+ };
317
+ }