@mfxhub/sdk 0.2.1 → 0.2.3
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/CHANGELOG.md +19 -0
- package/dist/client.d.ts +16 -7
- package/dist/client.js +52 -9
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/stream.d.ts +4 -1
- package/dist/stream.js +21 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ Every release of `@mfxhub/sdk`, newest first. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## 0.2.3
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Every HTTP attempt is timed. `new MfxClient(..., { onCall })` is told each one as a `CallTiming`
|
|
12
|
+
(`what`, `status`, `ms`; `status` is `null` when no answer came), and when the environment
|
|
13
|
+
variable `MFX_CALL_LOG` names a file, one line `{"status": 200, "ms": 12.5}` is appended to it
|
|
14
|
+
for each. A read retried after a 429 is two attempts. Nothing else is written: no path, id,
|
|
15
|
+
header or key. A listener that throws, or a file that cannot be written, never fails a call.
|
|
16
|
+
The platform's checks use it to count and time a sample's calls, as they do for the Python SDK.
|
|
17
|
+
|
|
18
|
+
## 0.2.2
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `stream.close()` ends the stream for good. A `next()` or a `for await` loop waiting for a message
|
|
23
|
+
ends with nothing, and a reconnect under way is given up. Before, closing while a read waited
|
|
24
|
+
made the stream reconnect and keep delivering, so a script that closed its stream did not end.
|
|
25
|
+
|
|
7
26
|
## 0.2.1
|
|
8
27
|
|
|
9
28
|
### Added
|
package/dist/client.d.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `MfxClient`: one signed connection to the MFX Hub API.
|
|
3
|
-
*
|
|
4
|
-
* Every request is signed with the tenant's Ed25519 key. Reads that are rate limited are retried
|
|
5
|
-
* after the platform's own `Retry-After`; writes are never retried, because a retried write is a
|
|
6
|
-
* second order. Failures become typed errors from `errors.ts`.
|
|
7
|
-
*/
|
|
8
1
|
import { MfxApiError } from "./errors.js";
|
|
9
2
|
import { RequestSigner, type SigningKey } from "./signing.js";
|
|
10
3
|
import { Accounts } from "./accounts.js";
|
|
@@ -16,6 +9,18 @@ import { Terminals } from "./terminals.js";
|
|
|
16
9
|
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
17
10
|
/** How many times a rate-limited read is tried again. */
|
|
18
11
|
export declare const DEFAULT_READ_RETRIES = 3;
|
|
12
|
+
/** The environment variable naming a file to append one JSON line to for every HTTP attempt. */
|
|
13
|
+
export declare const CALL_LOG_ENV = "MFX_CALL_LOG";
|
|
14
|
+
/**
|
|
15
|
+
* One HTTP attempt the client made: what was asked, the status, and how long it took.
|
|
16
|
+
* Each attempt is a call: a read retried after a 429 is two. `status` is null when no answer came
|
|
17
|
+
* (a connection that failed, a timeout). `ms` is measured on the client's own clock.
|
|
18
|
+
*/
|
|
19
|
+
export interface CallTiming {
|
|
20
|
+
readonly what: string;
|
|
21
|
+
readonly status: number | null;
|
|
22
|
+
readonly ms: number;
|
|
23
|
+
}
|
|
19
24
|
/** What the key had left when MFX Hub last answered. */
|
|
20
25
|
export interface Budget {
|
|
21
26
|
readonly remaining: number;
|
|
@@ -35,6 +40,10 @@ export interface ClientOptions {
|
|
|
35
40
|
readonly clock?: () => number;
|
|
36
41
|
/** A number in [0, 1) for the backoff's jitter; replaceable in tests. */
|
|
37
42
|
readonly random?: () => number;
|
|
43
|
+
/** Told each call as it ends ({@link CallTiming}); `MFX_CALL_LOG` is the same, as a file. */
|
|
44
|
+
readonly onCall?: (timing: CallTiming) => void;
|
|
45
|
+
/** A monotonic clock in milliseconds; replaceable in tests. */
|
|
46
|
+
readonly now?: () => number;
|
|
38
47
|
}
|
|
39
48
|
/** One request, as the resource modules describe it. */
|
|
40
49
|
export interface RequestSpec {
|
package/dist/client.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* after the platform's own `Retry-After`; writes are never retried, because a retried write is a
|
|
6
6
|
* second order. Failures become typed errors from `errors.ts`.
|
|
7
7
|
*/
|
|
8
|
+
import { appendFileSync } from "node:fs";
|
|
8
9
|
import { MfxApiError, MfxTimeout, MfxTransportError, errorFor, retryAfterSeconds, } from "./errors.js";
|
|
9
10
|
import { RequestSigner } from "./signing.js";
|
|
10
11
|
import { Accounts } from "./accounts.js";
|
|
@@ -28,6 +29,8 @@ const NOT_SENT = new Set([
|
|
|
28
29
|
"EHOSTUNREACH",
|
|
29
30
|
"UND_ERR_CONNECT_TIMEOUT",
|
|
30
31
|
]);
|
|
32
|
+
/** The environment variable naming a file to append one JSON line to for every HTTP attempt. */
|
|
33
|
+
export const CALL_LOG_ENV = "MFX_CALL_LOG";
|
|
31
34
|
const wait = (ms) => new Promise((resolve) => {
|
|
32
35
|
setTimeout(resolve, ms);
|
|
33
36
|
});
|
|
@@ -40,6 +43,9 @@ export class MfxClient {
|
|
|
40
43
|
#sleep;
|
|
41
44
|
#fetch;
|
|
42
45
|
#random;
|
|
46
|
+
#onCall;
|
|
47
|
+
#now;
|
|
48
|
+
#callLog;
|
|
43
49
|
#budget = null;
|
|
44
50
|
/** The terminals this key's tenant owns. */
|
|
45
51
|
terminals;
|
|
@@ -63,6 +69,9 @@ export class MfxClient {
|
|
|
63
69
|
this.#sleep = options.sleep ?? wait;
|
|
64
70
|
this.#fetch = options.fetch ?? fetch;
|
|
65
71
|
this.#random = options.random ?? Math.random;
|
|
72
|
+
this.#onCall = options.onCall;
|
|
73
|
+
this.#now = options.now ?? (() => performance.now());
|
|
74
|
+
this.#callLog = process.env[CALL_LOG_ENV] || undefined;
|
|
66
75
|
this.terminals = new Terminals(this);
|
|
67
76
|
this.execution = new Execution(this);
|
|
68
77
|
this.catalog = new Catalog(this);
|
|
@@ -146,18 +155,29 @@ export class MfxClient {
|
|
|
146
155
|
headers["Content-Type"] = "application/json";
|
|
147
156
|
if (spec.idempotencyKey !== undefined)
|
|
148
157
|
headers["Idempotency-Key"] = spec.idempotencyKey;
|
|
149
|
-
|
|
158
|
+
const started = this.#now();
|
|
159
|
+
let status = null;
|
|
150
160
|
try {
|
|
151
|
-
response
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
161
|
+
let response;
|
|
162
|
+
try {
|
|
163
|
+
response = await this.#fetch(url, {
|
|
164
|
+
method: spec.method,
|
|
165
|
+
headers,
|
|
166
|
+
...(spec.body === undefined ? {} : { body }),
|
|
167
|
+
signal: AbortSignal.timeout(this.#timeoutMs),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
throw transportError(error, spec);
|
|
172
|
+
}
|
|
173
|
+
status = response.status;
|
|
174
|
+
return await this.#answer(response);
|
|
157
175
|
}
|
|
158
|
-
|
|
159
|
-
|
|
176
|
+
finally {
|
|
177
|
+
this.#timed(`${spec.method} ${spec.path}`, status, started);
|
|
160
178
|
}
|
|
179
|
+
}
|
|
180
|
+
async #answer(response) {
|
|
161
181
|
const text = await response.text();
|
|
162
182
|
this.#noteBudget(response.headers);
|
|
163
183
|
let parsed = null;
|
|
@@ -171,6 +191,29 @@ export class MfxClient {
|
|
|
171
191
|
}
|
|
172
192
|
return { status: response.status, headers: response.headers, body: parsed };
|
|
173
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Hand one call's timing on. Never throws: timing is an observation, not a reason for a call
|
|
196
|
+
* that succeeded to fail. Nothing but the status and the time is written: no path, id, header
|
|
197
|
+
* or key.
|
|
198
|
+
*/
|
|
199
|
+
#timed(what, status, started) {
|
|
200
|
+
const timing = { what, status, ms: this.#now() - started };
|
|
201
|
+
try {
|
|
202
|
+
this.#onCall?.(timing);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// A listener's bug must not fail the call.
|
|
206
|
+
}
|
|
207
|
+
if (this.#callLog !== undefined) {
|
|
208
|
+
try {
|
|
209
|
+
const ms = Math.round(timing.ms * 1000) / 1000;
|
|
210
|
+
appendFileSync(this.#callLog, JSON.stringify({ status, ms }) + "\n", "utf8");
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// An unwritable log is no reason to fail a call.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
174
217
|
#noteBudget(headers) {
|
|
175
218
|
const remaining = headers.get("X-MFX-Budget-Remaining");
|
|
176
219
|
if (remaining !== null && Number.isFinite(Number(remaining))) {
|
package/dist/index.d.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* const client = new MfxClient("https://api.dev.mfxhub.com/v1", keyId, privateKey);
|
|
7
7
|
* ```
|
|
8
8
|
*/
|
|
9
|
-
export { MfxClient, DEFAULT_READ_RETRIES, DEFAULT_TIMEOUT_MS } from "./client.js";
|
|
10
|
-
export type { Answer, Budget, ClientOptions, RequestSpec } from "./client.js";
|
|
9
|
+
export { MfxClient, CALL_LOG_ENV, DEFAULT_READ_RETRIES, DEFAULT_TIMEOUT_MS } from "./client.js";
|
|
10
|
+
export type { Answer, Budget, CallTiming, ClientOptions, RequestSpec } from "./client.js";
|
|
11
11
|
export * from "./errors.js";
|
|
12
12
|
export { Page } from "./pages.js";
|
|
13
13
|
export { Catalog } from "./catalog.js";
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* const client = new MfxClient("https://api.dev.mfxhub.com/v1", keyId, privateKey);
|
|
7
7
|
* ```
|
|
8
8
|
*/
|
|
9
|
-
export { MfxClient, DEFAULT_READ_RETRIES, DEFAULT_TIMEOUT_MS } from "./client.js";
|
|
9
|
+
export { MfxClient, CALL_LOG_ENV, DEFAULT_READ_RETRIES, DEFAULT_TIMEOUT_MS } from "./client.js";
|
|
10
10
|
export * from "./errors.js";
|
|
11
11
|
export { Page } from "./pages.js";
|
|
12
12
|
export { Catalog } from "./catalog.js";
|
package/dist/stream.d.ts
CHANGED
|
@@ -127,7 +127,10 @@ export declare class MfxStream implements AsyncIterable<StreamEvent> {
|
|
|
127
127
|
resume(channel: string): Promise<void>;
|
|
128
128
|
/** Stop watching a channel, and stop subscribing to it again after a reconnect. */
|
|
129
129
|
unsubscribe(channel: string): Promise<void>;
|
|
130
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* Close the stream for good. A read waiting on it ends with null, a reconnect under way is
|
|
132
|
+
* given up, and the stream is never opened again.
|
|
133
|
+
*/
|
|
131
134
|
close(): Promise<void>;
|
|
132
135
|
[Symbol.asyncIterator](): AsyncGenerator<StreamEvent, void, undefined>;
|
|
133
136
|
/**
|
package/dist/stream.js
CHANGED
|
@@ -208,6 +208,8 @@ export class MfxStream {
|
|
|
208
208
|
refused: 0,
|
|
209
209
|
};
|
|
210
210
|
#connection = null;
|
|
211
|
+
/** The caller closed the stream: nothing reconnects it, and every read is null from here on. */
|
|
212
|
+
#closed = false;
|
|
211
213
|
/** Use {@link MfxClient.stream}. */
|
|
212
214
|
constructor(client, options = {}) {
|
|
213
215
|
this.#client = client;
|
|
@@ -258,8 +260,12 @@ export class MfxStream {
|
|
|
258
260
|
this.#channels.delete(channel);
|
|
259
261
|
await this.#send({ op: "unsubscribe", ch: channel });
|
|
260
262
|
}
|
|
261
|
-
/**
|
|
263
|
+
/**
|
|
264
|
+
* Close the stream for good. A read waiting on it ends with null, a reconnect under way is
|
|
265
|
+
* given up, and the stream is never opened again.
|
|
266
|
+
*/
|
|
262
267
|
async close() {
|
|
268
|
+
this.#closed = true;
|
|
263
269
|
const connection = this.#connection;
|
|
264
270
|
this.#connection = null;
|
|
265
271
|
if (connection !== null)
|
|
@@ -287,6 +293,9 @@ export class MfxStream {
|
|
|
287
293
|
raw = await connection.recv();
|
|
288
294
|
}
|
|
289
295
|
catch (error) {
|
|
296
|
+
// The caller's own close ends the read; it is not a drop to come back from.
|
|
297
|
+
if (this.#closed)
|
|
298
|
+
return null;
|
|
290
299
|
if (!(error instanceof StreamClosed))
|
|
291
300
|
throw error;
|
|
292
301
|
if (!(await this.#reopen(error)))
|
|
@@ -362,7 +371,14 @@ export class MfxStream {
|
|
|
362
371
|
this.#bump("reconnects");
|
|
363
372
|
// The client's own backoff, so the stream and the reads lean on this tenant's quota alike.
|
|
364
373
|
await this.#client.pause(this.#client.backoffMs(this.#stats.reconnects));
|
|
374
|
+
if (this.#closedNow())
|
|
375
|
+
return false; // closed while waiting out the backoff
|
|
365
376
|
await this.open();
|
|
377
|
+
if (this.#closedNow()) {
|
|
378
|
+
// closed while the connection was being opened: that one is closed too
|
|
379
|
+
await this.close();
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
366
382
|
for (const [channel, last] of [...this.#channels]) {
|
|
367
383
|
if (replayable(channel) && last !== null) {
|
|
368
384
|
await this.#send({ op: "resume", ch: channel, after_seq: last });
|
|
@@ -376,6 +392,10 @@ export class MfxStream {
|
|
|
376
392
|
}
|
|
377
393
|
return true;
|
|
378
394
|
}
|
|
395
|
+
/** Read afresh after an await: close() may have run while this was waiting. */
|
|
396
|
+
#closedNow() {
|
|
397
|
+
return this.#closed;
|
|
398
|
+
}
|
|
379
399
|
async #send(message) {
|
|
380
400
|
const connection = this.#connection;
|
|
381
401
|
if (connection === null)
|
package/dist/version.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** This package's version. */
|
|
2
|
-
export declare const VERSION = "0.2.
|
|
2
|
+
export declare const VERSION = "0.2.3";
|
|
3
3
|
/** What every request says it is, as a product token: the SDK's language and its version. */
|
|
4
|
-
export declare const USER_AGENT = "mfx-sdk-typescript/0.2.
|
|
4
|
+
export declare const USER_AGENT = "mfx-sdk-typescript/0.2.3";
|
package/dist/version.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// This package's version, in one place: exported by the package, and named by every request.
|
|
2
2
|
/** This package's version. */
|
|
3
|
-
export const VERSION = "0.2.
|
|
3
|
+
export const VERSION = "0.2.3";
|
|
4
4
|
/** What every request says it is, as a product token: the SDK's language and its version. */
|
|
5
5
|
export const USER_AGENT = `mfx-sdk-typescript/${VERSION}`;
|
package/package.json
CHANGED