@forgezero/runtime 0.1.2 → 0.1.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.
- package/README.md +22 -1
- package/dist/audit.js +35 -2
- package/dist/identity.d.ts +25 -0
- package/dist/identity.js +99 -3
- package/dist/jobs.d.ts +29 -1
- package/dist/jobs.js +136 -12
- package/dist/queue.d.ts +26 -0
- package/dist/queue.js +36 -2
- package/package.json +4 -3
- package/dist/finance/binance.d.ts +0 -27
- package/dist/finance/binance.js +0 -452
- package/dist/serial.d.ts +0 -54
- package/dist/serial.js +0 -40
package/README.md
CHANGED
|
@@ -43,7 +43,9 @@ ArangoDB unique claim, while another caller may use any database or no database.
|
|
|
43
43
|
```ts
|
|
44
44
|
import { createQueue } from '@forgezero/runtime/queue';
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// Default admission is 60% of reported logical CPUs (with one reserved).
|
|
47
|
+
// Use `width` for an exact ceiling, or an explicit dynamic resource policy:
|
|
48
|
+
const queue = createQueue({ resources: { percent: 60, reserve: 1, max: 32 } });
|
|
47
49
|
const task = queue.run('tenant-a:wallet-7', transfer, amount, destination);
|
|
48
50
|
const receipt = await task.result;
|
|
49
51
|
|
|
@@ -55,6 +57,25 @@ queue.cancel(task.id); // pending task only
|
|
|
55
57
|
await queue.stop(30_000); // close intake and drain all work
|
|
56
58
|
```
|
|
57
59
|
|
|
60
|
+
Different async keys overlap immediately. CPU-heavy JavaScript does not become
|
|
61
|
+
multi-core merely by entering a queue: put that handler in Bun/standard Workers
|
|
62
|
+
and await the Worker result from the queue.
|
|
63
|
+
|
|
64
|
+
Jobs accept intervals down to seconds or a local wall-clock schedule with an
|
|
65
|
+
IANA timezone and weekday filter. `overlap: 'wait'` (the default) schedules the
|
|
66
|
+
next run after completion; `overlap: 'skip'` keeps clock cadence and drops a tick
|
|
67
|
+
when the same key is still busy. Same-key overlap is never allowed.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
defineJob({
|
|
71
|
+
key: 'tenant:acme:invoice',
|
|
72
|
+
label: 'Monthly invoice preparation',
|
|
73
|
+
schedule: { timezone: 'Asia/Kolkata', time: '00:00:15', weekdays: [1] },
|
|
74
|
+
overlap: 'skip',
|
|
75
|
+
run: async ({ signal }) => generateInvoices({ signal })
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
58
79
|
## Three things worth knowing before you use it
|
|
59
80
|
|
|
60
81
|
**Money is never a number.** An amount is minor units as a `bigint` with its
|
package/dist/audit.js
CHANGED
|
@@ -33,10 +33,41 @@ var DEFAULT_RETRY = {
|
|
|
33
33
|
attempts: 1,
|
|
34
34
|
backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
|
|
35
35
|
};
|
|
36
|
+
var reportedParallelism = () => {
|
|
37
|
+
const reported = globalThis.navigator?.hardwareConcurrency;
|
|
38
|
+
return Number.isSafeInteger(reported) && reported > 0 ? reported : 1;
|
|
39
|
+
};
|
|
40
|
+
function queueWidthFor(policy = {}) {
|
|
41
|
+
const percent = policy.percent ?? 60;
|
|
42
|
+
const reserve = policy.reserve ?? 1;
|
|
43
|
+
const min = policy.min ?? 1;
|
|
44
|
+
const max = policy.max ?? Number.MAX_SAFE_INTEGER;
|
|
45
|
+
const available = (policy.available ?? reportedParallelism)();
|
|
46
|
+
if (!Number.isFinite(percent) || percent <= 0 || percent > 100) {
|
|
47
|
+
throw new RangeError("queue: resource percent must be greater than 0 and at most 100");
|
|
48
|
+
}
|
|
49
|
+
for (const [name, value] of [["reserve", reserve], ["min", min], ["max", max]]) {
|
|
50
|
+
if (!Number.isSafeInteger(value) || value < (name === "reserve" ? 0 : 1)) {
|
|
51
|
+
throw new RangeError(`queue: resource ${name} must be ${name === "reserve" ? "a non-negative" : "a positive"} integer`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!Number.isSafeInteger(available) || available < 1) {
|
|
55
|
+
throw new RangeError("queue: available parallelism must be a positive integer");
|
|
56
|
+
}
|
|
57
|
+
if (min > max)
|
|
58
|
+
throw new RangeError("queue: resource min cannot exceed max");
|
|
59
|
+
const usable = Math.max(1, available - reserve);
|
|
60
|
+
return Math.min(max, Math.max(min, Math.floor(usable * percent / 100)));
|
|
61
|
+
}
|
|
36
62
|
function createQueue(options = {}) {
|
|
37
|
-
|
|
63
|
+
if (options.width !== undefined && options.resources !== undefined) {
|
|
64
|
+
throw new Error("queue: choose either width or resources, not both");
|
|
65
|
+
}
|
|
66
|
+
const configuredWidth = options.width;
|
|
67
|
+
const widthNow = () => configuredWidth ?? queueWidthFor(options.resources);
|
|
68
|
+
const initialWidth = widthNow();
|
|
38
69
|
const retry = { ...DEFAULT_RETRY, ...options.retry };
|
|
39
|
-
if (!Number.isSafeInteger(
|
|
70
|
+
if (!Number.isSafeInteger(initialWidth) || initialWidth < 1) {
|
|
40
71
|
throw new RangeError("queue: width must be a positive integer");
|
|
41
72
|
}
|
|
42
73
|
if (!Number.isSafeInteger(retry.attempts) || retry.attempts < 1) {
|
|
@@ -68,6 +99,7 @@ function createQueue(options = {}) {
|
|
|
68
99
|
announceIdle();
|
|
69
100
|
return;
|
|
70
101
|
}
|
|
102
|
+
const width = widthNow();
|
|
71
103
|
for (const [key, lane] of lanes) {
|
|
72
104
|
if (running.size >= width)
|
|
73
105
|
break;
|
|
@@ -215,6 +247,7 @@ function createQueue(options = {}) {
|
|
|
215
247
|
for (const lane of lanes.values())
|
|
216
248
|
queued += lane.length;
|
|
217
249
|
return {
|
|
250
|
+
width: widthNow(),
|
|
218
251
|
running: running.size,
|
|
219
252
|
queued,
|
|
220
253
|
keys: lanes.size,
|
package/dist/identity.d.ts
CHANGED
|
@@ -64,6 +64,28 @@ export interface SignedEnvelope {
|
|
|
64
64
|
edSignature: string;
|
|
65
65
|
mlDsaSignature: string;
|
|
66
66
|
}
|
|
67
|
+
export interface ResponseRecipient {
|
|
68
|
+
publicKey: string;
|
|
69
|
+
secretKey: string;
|
|
70
|
+
}
|
|
71
|
+
export interface SealedResponse {
|
|
72
|
+
version: 1;
|
|
73
|
+
kemCiphertext: string;
|
|
74
|
+
nonce: string;
|
|
75
|
+
ciphertext: string;
|
|
76
|
+
}
|
|
77
|
+
export declare const RESPONSE_KEY_HEADER = "x-fz-response-key";
|
|
78
|
+
export declare function validResponsePublicKey(value: string): boolean;
|
|
79
|
+
/** One ephemeral hybrid ML-KEM-768 + X25519 recipient per request. */
|
|
80
|
+
export declare function generateResponseRecipient(): ResponseRecipient;
|
|
81
|
+
/** Seal one JSON response so TLS termination never sees the secret payload. */
|
|
82
|
+
export declare function sealResponse<T>(recipientPublicKey: string, requestBinding: string, payload: T): Promise<SealedResponse>;
|
|
83
|
+
/** Open a response only with the request's ephemeral private half and exact signature binding. */
|
|
84
|
+
export declare function openResponse<T>(recipientSecretKey: string, requestBinding: string, envelope: SealedResponse): Promise<T>;
|
|
85
|
+
/** One wire encoding for Agent and external API-key hybrid signatures. */
|
|
86
|
+
export declare function encodeSignatureHeader(envelope: SignedEnvelope): string;
|
|
87
|
+
/** Parse the common wire encoding and bind its public key id from the companion header. */
|
|
88
|
+
export declare function decodeSignatureHeader(raw: string, nodeKey: string): SignedEnvelope | null;
|
|
67
89
|
/**
|
|
68
90
|
* The bytes that get signed.
|
|
69
91
|
*
|
|
@@ -79,12 +101,14 @@ export declare function canonicalString(args: {
|
|
|
79
101
|
timestamp: number;
|
|
80
102
|
nonce: string;
|
|
81
103
|
body: string | Uint8Array;
|
|
104
|
+
responseKey?: string;
|
|
82
105
|
}): string;
|
|
83
106
|
export declare function signRequest(keys: NodeKeyPair, nodeKey: string, args: {
|
|
84
107
|
method: string;
|
|
85
108
|
path: string;
|
|
86
109
|
query?: string;
|
|
87
110
|
body: string | Uint8Array;
|
|
111
|
+
responseKey?: string;
|
|
88
112
|
}): SignedEnvelope;
|
|
89
113
|
export type VerifyFailure = 'timestamp_out_of_window' | 'ed25519_invalid' | 'ml_dsa_invalid' | 'malformed';
|
|
90
114
|
/** Requests older or newer than this are refused before any signature work. */
|
|
@@ -106,6 +130,7 @@ export declare function verifyRequest(args: {
|
|
|
106
130
|
path: string;
|
|
107
131
|
query?: string;
|
|
108
132
|
body: string | Uint8Array;
|
|
133
|
+
responseKey?: string;
|
|
109
134
|
nowSeconds?: number;
|
|
110
135
|
}): {
|
|
111
136
|
verified: true;
|
package/dist/identity.js
CHANGED
|
@@ -10,6 +10,7 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
10
10
|
import { toBase64Url, fromBase64Url } from "@forgezero/access/security";
|
|
11
11
|
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
12
12
|
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
|
|
13
|
+
import { ml_kem768_x25519 } from "@noble/post-quantum/hybrid.js";
|
|
13
14
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
14
15
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
15
16
|
var ENCODER = new TextEncoder;
|
|
@@ -41,17 +42,104 @@ function generateNodeKeys() {
|
|
|
41
42
|
mlDsa: { publicKey: b64(mlKeys.publicKey), secretKey: b64(mlKeys.secretKey) }
|
|
42
43
|
};
|
|
43
44
|
}
|
|
45
|
+
var RESPONSE_KEY_HEADER = "x-fz-response-key";
|
|
46
|
+
function validResponsePublicKey(value) {
|
|
47
|
+
try {
|
|
48
|
+
return un64(value).length === ml_kem768_x25519.lengths.publicKey;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function generateResponseRecipient() {
|
|
54
|
+
const pair = ml_kem768_x25519.keygen();
|
|
55
|
+
return { publicKey: b64(pair.publicKey), secretKey: b64(pair.secretKey) };
|
|
56
|
+
}
|
|
57
|
+
var responseKey = (sharedSecret) => hkdf(sha256, sharedSecret, undefined, ENCODER.encode("forgezero/response/ml-kem-768+x25519/v1"), 32);
|
|
58
|
+
async function sealResponse(recipientPublicKey, requestBinding, payload) {
|
|
59
|
+
const { cipherText, sharedSecret } = ml_kem768_x25519.encapsulate(un64(recipientPublicKey));
|
|
60
|
+
const rawKey = responseKey(sharedSecret);
|
|
61
|
+
sharedSecret.fill(0);
|
|
62
|
+
const key = await crypto.subtle.importKey("raw", new Uint8Array(rawKey), "AES-GCM", false, ["encrypt"]);
|
|
63
|
+
rawKey.fill(0);
|
|
64
|
+
const nonce = randomBytes(12);
|
|
65
|
+
const serialized = JSON.stringify(payload);
|
|
66
|
+
if (serialized === undefined)
|
|
67
|
+
throw new Error("response: payload is not JSON serializable");
|
|
68
|
+
const plaintext = ENCODER.encode(serialized);
|
|
69
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
70
|
+
name: "AES-GCM",
|
|
71
|
+
iv: new Uint8Array(nonce),
|
|
72
|
+
additionalData: new Uint8Array(ENCODER.encode(requestBinding)),
|
|
73
|
+
tagLength: 128
|
|
74
|
+
}, key, new Uint8Array(plaintext));
|
|
75
|
+
plaintext.fill(0);
|
|
76
|
+
return {
|
|
77
|
+
version: 1,
|
|
78
|
+
kemCiphertext: b64(cipherText),
|
|
79
|
+
nonce: b64(nonce),
|
|
80
|
+
ciphertext: b64(new Uint8Array(ciphertext))
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async function openResponse(recipientSecretKey, requestBinding, envelope) {
|
|
84
|
+
if (envelope?.version !== 1)
|
|
85
|
+
throw new Error("response: unsupported sealed response");
|
|
86
|
+
const sharedSecret = ml_kem768_x25519.decapsulate(un64(envelope.kemCiphertext), un64(recipientSecretKey));
|
|
87
|
+
const rawKey = responseKey(sharedSecret);
|
|
88
|
+
sharedSecret.fill(0);
|
|
89
|
+
const key = await crypto.subtle.importKey("raw", new Uint8Array(rawKey), "AES-GCM", false, ["decrypt"]);
|
|
90
|
+
rawKey.fill(0);
|
|
91
|
+
const decrypted = new Uint8Array(await crypto.subtle.decrypt({
|
|
92
|
+
name: "AES-GCM",
|
|
93
|
+
iv: new Uint8Array(un64(envelope.nonce)),
|
|
94
|
+
additionalData: new Uint8Array(ENCODER.encode(requestBinding)),
|
|
95
|
+
tagLength: 128
|
|
96
|
+
}, key, new Uint8Array(un64(envelope.ciphertext))));
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
99
|
+
} finally {
|
|
100
|
+
decrypted.fill(0);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
var SIGNATURE_FIELDS = (envelope) => ({
|
|
104
|
+
timestamp: envelope.timestamp,
|
|
105
|
+
nonce: envelope.nonce,
|
|
106
|
+
edSignature: envelope.edSignature,
|
|
107
|
+
mlDsaSignature: envelope.mlDsaSignature
|
|
108
|
+
});
|
|
109
|
+
function encodeSignatureHeader(envelope) {
|
|
110
|
+
return b64(ENCODER.encode(JSON.stringify(SIGNATURE_FIELDS(envelope))));
|
|
111
|
+
}
|
|
112
|
+
function decodeSignatureHeader(raw, nodeKey) {
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(new TextDecoder().decode(un64(raw)));
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (typeof parsed.timestamp !== "number" || !Number.isSafeInteger(parsed.timestamp) || typeof parsed.nonce !== "string" || typeof parsed.edSignature !== "string" || typeof parsed.mlDsaSignature !== "string")
|
|
120
|
+
return null;
|
|
121
|
+
return {
|
|
122
|
+
nodeKey,
|
|
123
|
+
timestamp: parsed.timestamp,
|
|
124
|
+
nonce: parsed.nonce,
|
|
125
|
+
edSignature: parsed.edSignature,
|
|
126
|
+
mlDsaSignature: parsed.mlDsaSignature
|
|
127
|
+
};
|
|
128
|
+
}
|
|
44
129
|
function canonicalString(args) {
|
|
45
130
|
const body = typeof args.body === "string" ? ENCODER.encode(args.body) : args.body;
|
|
46
131
|
const digest = Array.from(sha256(body), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
47
|
-
|
|
132
|
+
const fields = [
|
|
48
133
|
args.method.toUpperCase(),
|
|
49
134
|
args.path,
|
|
50
135
|
args.query ?? "",
|
|
51
136
|
String(args.timestamp),
|
|
52
137
|
args.nonce,
|
|
53
138
|
digest
|
|
54
|
-
]
|
|
139
|
+
];
|
|
140
|
+
if (args.responseKey)
|
|
141
|
+
fields.push(args.responseKey);
|
|
142
|
+
return fields.join(`
|
|
55
143
|
`);
|
|
56
144
|
}
|
|
57
145
|
function signRequest(keys, nodeKey, args) {
|
|
@@ -80,7 +168,8 @@ function verifyRequest(args) {
|
|
|
80
168
|
query: args.query ?? "",
|
|
81
169
|
timestamp: args.envelope.timestamp,
|
|
82
170
|
nonce: args.envelope.nonce,
|
|
83
|
-
body: args.body
|
|
171
|
+
body: args.body,
|
|
172
|
+
responseKey: args.responseKey
|
|
84
173
|
}));
|
|
85
174
|
} catch {
|
|
86
175
|
return { verified: false, reason: "malformed" };
|
|
@@ -103,9 +192,16 @@ function verifyRequest(args) {
|
|
|
103
192
|
}
|
|
104
193
|
export {
|
|
105
194
|
verifyRequest,
|
|
195
|
+
validResponsePublicKey,
|
|
106
196
|
signRequest,
|
|
197
|
+
sealResponse,
|
|
198
|
+
openResponse,
|
|
199
|
+
generateResponseRecipient,
|
|
107
200
|
generateNodeKeys,
|
|
201
|
+
encodeSignatureHeader,
|
|
108
202
|
deriveKeysFromSeed,
|
|
203
|
+
decodeSignatureHeader,
|
|
109
204
|
canonicalString,
|
|
205
|
+
RESPONSE_KEY_HEADER,
|
|
110
206
|
CLOCK_SKEW_SECONDS
|
|
111
207
|
};
|
package/dist/jobs.d.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* Zero dependencies. The lock and the cursor store are interfaces, so this runs
|
|
21
21
|
* against a database, Redis, or nothing at all in a test.
|
|
22
22
|
*/
|
|
23
|
-
import { type DrainReport } from './queue';
|
|
23
|
+
import { type DrainReport, type QueueResourcePolicy } from './queue';
|
|
24
24
|
/** Injected so a test does not sleep and a resumed run is reproducible. */
|
|
25
25
|
export interface Clock {
|
|
26
26
|
now(): number;
|
|
@@ -29,6 +29,20 @@ export interface Clock {
|
|
|
29
29
|
export declare const systemClock: Clock;
|
|
30
30
|
/** `30s` → 30000. Throws rather than guessing — a wrong interval is silent. */
|
|
31
31
|
export declare function everyMs(interval: string | number): number;
|
|
32
|
+
export interface WallClockSchedule {
|
|
33
|
+
/** IANA timezone such as `Asia/Kolkata` or `UTC`. */
|
|
34
|
+
timezone: string;
|
|
35
|
+
/** Local wall time, including optional seconds: `HH:MM` or `HH:MM:SS`. */
|
|
36
|
+
time: string;
|
|
37
|
+
/** Optional local weekdays, Sunday=0 through Saturday=6. */
|
|
38
|
+
weekdays?: readonly number[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Find the next occurrence strictly after `afterMs` in the requested timezone.
|
|
42
|
+
* Intl owns daylight-saving and historical offset rules; the bounded minute
|
|
43
|
+
* scan avoids implementing a second, inevitably-wrong timezone database here.
|
|
44
|
+
*/
|
|
45
|
+
export declare function nextWallClockAt(schedule: WallClockSchedule, afterMs: number): number;
|
|
32
46
|
/**
|
|
33
47
|
* A lease, not a mutex.
|
|
34
48
|
*
|
|
@@ -80,6 +94,14 @@ export interface JobSpec {
|
|
|
80
94
|
label: string;
|
|
81
95
|
/** `30s`, `5m`, or milliseconds. Omit for a job only ever run by hand. */
|
|
82
96
|
every?: string | number;
|
|
97
|
+
/** Local daily/weekly schedule. Mutually exclusive with `every`. */
|
|
98
|
+
schedule?: WallClockSchedule;
|
|
99
|
+
/**
|
|
100
|
+
* `wait` schedules the next occurrence after this run settles. `skip` keeps
|
|
101
|
+
* clock time and drops a tick when the previous run is still queued/running.
|
|
102
|
+
* Neither mode overlaps the same key.
|
|
103
|
+
*/
|
|
104
|
+
overlap?: 'wait' | 'skip';
|
|
83
105
|
run(context: JobContext): Promise<JobResult | void>;
|
|
84
106
|
/**
|
|
85
107
|
* Lease length. Defaults to four intervals, so a slow run is not evicted the
|
|
@@ -107,6 +129,8 @@ export interface JobReport {
|
|
|
107
129
|
consecutiveFailures: number;
|
|
108
130
|
runs: number;
|
|
109
131
|
skippedLocked: number;
|
|
132
|
+
skippedOverlap: number;
|
|
133
|
+
nextRunAtMs?: number;
|
|
110
134
|
}
|
|
111
135
|
export interface SchedulerOptions {
|
|
112
136
|
jobs: readonly JobSpec[];
|
|
@@ -115,6 +139,10 @@ export interface SchedulerOptions {
|
|
|
115
139
|
/** Called on every failure. Wire to telemetry; must not throw. */
|
|
116
140
|
onError?: (key: string, error: unknown) => void;
|
|
117
141
|
onLog?: (key: string, message: string, detail?: Record<string, unknown>) => void;
|
|
142
|
+
/** Exact lane ceiling. Mutually exclusive with `resources`. */
|
|
143
|
+
width?: number;
|
|
144
|
+
/** Dynamic resource admission for different job keys. */
|
|
145
|
+
resources?: QueueResourcePolicy;
|
|
118
146
|
}
|
|
119
147
|
export declare function createScheduler(options: SchedulerOptions): {
|
|
120
148
|
start(): void;
|
package/dist/jobs.js
CHANGED
|
@@ -33,10 +33,41 @@ var DEFAULT_RETRY = {
|
|
|
33
33
|
attempts: 1,
|
|
34
34
|
backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
|
|
35
35
|
};
|
|
36
|
+
var reportedParallelism = () => {
|
|
37
|
+
const reported = globalThis.navigator?.hardwareConcurrency;
|
|
38
|
+
return Number.isSafeInteger(reported) && reported > 0 ? reported : 1;
|
|
39
|
+
};
|
|
40
|
+
function queueWidthFor(policy = {}) {
|
|
41
|
+
const percent = policy.percent ?? 60;
|
|
42
|
+
const reserve = policy.reserve ?? 1;
|
|
43
|
+
const min = policy.min ?? 1;
|
|
44
|
+
const max = policy.max ?? Number.MAX_SAFE_INTEGER;
|
|
45
|
+
const available = (policy.available ?? reportedParallelism)();
|
|
46
|
+
if (!Number.isFinite(percent) || percent <= 0 || percent > 100) {
|
|
47
|
+
throw new RangeError("queue: resource percent must be greater than 0 and at most 100");
|
|
48
|
+
}
|
|
49
|
+
for (const [name, value] of [["reserve", reserve], ["min", min], ["max", max]]) {
|
|
50
|
+
if (!Number.isSafeInteger(value) || value < (name === "reserve" ? 0 : 1)) {
|
|
51
|
+
throw new RangeError(`queue: resource ${name} must be ${name === "reserve" ? "a non-negative" : "a positive"} integer`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!Number.isSafeInteger(available) || available < 1) {
|
|
55
|
+
throw new RangeError("queue: available parallelism must be a positive integer");
|
|
56
|
+
}
|
|
57
|
+
if (min > max)
|
|
58
|
+
throw new RangeError("queue: resource min cannot exceed max");
|
|
59
|
+
const usable = Math.max(1, available - reserve);
|
|
60
|
+
return Math.min(max, Math.max(min, Math.floor(usable * percent / 100)));
|
|
61
|
+
}
|
|
36
62
|
function createQueue(options = {}) {
|
|
37
|
-
|
|
63
|
+
if (options.width !== undefined && options.resources !== undefined) {
|
|
64
|
+
throw new Error("queue: choose either width or resources, not both");
|
|
65
|
+
}
|
|
66
|
+
const configuredWidth = options.width;
|
|
67
|
+
const widthNow = () => configuredWidth ?? queueWidthFor(options.resources);
|
|
68
|
+
const initialWidth = widthNow();
|
|
38
69
|
const retry = { ...DEFAULT_RETRY, ...options.retry };
|
|
39
|
-
if (!Number.isSafeInteger(
|
|
70
|
+
if (!Number.isSafeInteger(initialWidth) || initialWidth < 1) {
|
|
40
71
|
throw new RangeError("queue: width must be a positive integer");
|
|
41
72
|
}
|
|
42
73
|
if (!Number.isSafeInteger(retry.attempts) || retry.attempts < 1) {
|
|
@@ -68,6 +99,7 @@ function createQueue(options = {}) {
|
|
|
68
99
|
announceIdle();
|
|
69
100
|
return;
|
|
70
101
|
}
|
|
102
|
+
const width = widthNow();
|
|
71
103
|
for (const [key, lane] of lanes) {
|
|
72
104
|
if (running.size >= width)
|
|
73
105
|
break;
|
|
@@ -215,6 +247,7 @@ function createQueue(options = {}) {
|
|
|
215
247
|
for (const lane of lanes.values())
|
|
216
248
|
queued += lane.length;
|
|
217
249
|
return {
|
|
250
|
+
width: widthNow(),
|
|
218
251
|
running: running.size,
|
|
219
252
|
queued,
|
|
220
253
|
keys: lanes.size,
|
|
@@ -297,6 +330,53 @@ function everyMs(interval) {
|
|
|
297
330
|
throw new Error(`"${interval}" is not an interval like 30s, 5m, 1h, 1d.`);
|
|
298
331
|
return Number(match[1]) * UNITS[match[2]];
|
|
299
332
|
}
|
|
333
|
+
var WEEKDAY = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
334
|
+
function wallTime(schedule) {
|
|
335
|
+
const match = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(schedule.time);
|
|
336
|
+
if (!match)
|
|
337
|
+
throw new Error(`"${schedule.time}" is not a wall time like 09:30 or 09:30:15.`);
|
|
338
|
+
const hour = Number(match[1]);
|
|
339
|
+
const minute = Number(match[2]);
|
|
340
|
+
const second = Number(match[3] ?? 0);
|
|
341
|
+
if (hour > 23 || minute > 59 || second > 59)
|
|
342
|
+
throw new Error(`"${schedule.time}" is not a valid wall time.`);
|
|
343
|
+
return { hour, minute, second };
|
|
344
|
+
}
|
|
345
|
+
function nextWallClockAt(schedule, afterMs) {
|
|
346
|
+
if (!Number.isFinite(afterMs))
|
|
347
|
+
throw new RangeError("job: schedule start must be finite");
|
|
348
|
+
const target = wallTime(schedule);
|
|
349
|
+
const weekdays = schedule.weekdays ? new Set(schedule.weekdays) : undefined;
|
|
350
|
+
if (weekdays?.size === 0 || [...weekdays ?? []].some((day) => !Number.isSafeInteger(day) || day < 0 || day > 6)) {
|
|
351
|
+
throw new Error("job: weekdays must contain Sunday=0 through Saturday=6");
|
|
352
|
+
}
|
|
353
|
+
let formatter;
|
|
354
|
+
try {
|
|
355
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
356
|
+
timeZone: schedule.timezone,
|
|
357
|
+
hour: "2-digit",
|
|
358
|
+
minute: "2-digit",
|
|
359
|
+
second: "2-digit",
|
|
360
|
+
weekday: "short",
|
|
361
|
+
hourCycle: "h23"
|
|
362
|
+
});
|
|
363
|
+
} catch {
|
|
364
|
+
throw new Error(`job: unknown IANA timezone "${schedule.timezone}"`);
|
|
365
|
+
}
|
|
366
|
+
const minuteFloor = Math.floor(afterMs / 60000) * 60000;
|
|
367
|
+
let candidate = minuteFloor + target.second * 1000;
|
|
368
|
+
if (candidate <= afterMs)
|
|
369
|
+
candidate += 60000;
|
|
370
|
+
for (let checked = 0;checked < 8 * 24 * 60; checked += 1, candidate += 60000) {
|
|
371
|
+
const parts = Object.fromEntries(formatter.formatToParts(candidate).map((part) => [part.type, part.value]));
|
|
372
|
+
if (Number(parts.hour) !== target.hour || Number(parts.minute) !== target.minute || Number(parts.second) !== target.second)
|
|
373
|
+
continue;
|
|
374
|
+
const weekday = WEEKDAY[parts.weekday];
|
|
375
|
+
if (!weekdays || weekdays.has(weekday))
|
|
376
|
+
return candidate;
|
|
377
|
+
}
|
|
378
|
+
throw new Error("job: no matching wall-clock occurrence was found in the next eight days");
|
|
379
|
+
}
|
|
300
380
|
function memoryLock(clock = systemClock) {
|
|
301
381
|
const held = new Map;
|
|
302
382
|
let fences = 0;
|
|
@@ -335,6 +415,10 @@ function defineJob(spec) {
|
|
|
335
415
|
throw new Error("A job needs a key — it is the lock key and the report key.");
|
|
336
416
|
if (spec.every !== undefined)
|
|
337
417
|
everyMs(spec.every);
|
|
418
|
+
if (spec.every !== undefined && spec.schedule)
|
|
419
|
+
throw new Error("A job must choose either every or schedule.");
|
|
420
|
+
if (spec.schedule)
|
|
421
|
+
nextWallClockAt(spec.schedule, Date.now());
|
|
338
422
|
return spec;
|
|
339
423
|
}
|
|
340
424
|
function spreadOf(key, ceiling) {
|
|
@@ -349,16 +433,29 @@ function createScheduler(options) {
|
|
|
349
433
|
const jobs = new Map(options.jobs.map((job) => [job.key, job]));
|
|
350
434
|
const reports = new Map(options.jobs.map((job) => [
|
|
351
435
|
job.key,
|
|
352
|
-
{ key: job.key, label: job.label, state: "stopped", consecutiveFailures: 0, runs: 0, skippedLocked: 0 }
|
|
436
|
+
{ key: job.key, label: job.label, state: "stopped", consecutiveFailures: 0, runs: 0, skippedLocked: 0, skippedOverlap: 0 }
|
|
353
437
|
]));
|
|
354
438
|
const timers = new Map;
|
|
355
|
-
|
|
439
|
+
const queueOptions = options.width !== undefined ? { width: options.width } : options.resources !== undefined ? { resources: options.resources } : { width: Math.max(1, jobs.size) };
|
|
440
|
+
let work = createQueue(queueOptions);
|
|
356
441
|
let workStopped = false;
|
|
357
442
|
let restartBlocked = false;
|
|
358
443
|
let controller = new AbortController;
|
|
359
444
|
let paused = false;
|
|
360
445
|
let running = false;
|
|
361
|
-
const
|
|
446
|
+
const outstanding = new Map;
|
|
447
|
+
const submit = async (job) => {
|
|
448
|
+
outstanding.set(job.key, (outstanding.get(job.key) ?? 0) + 1);
|
|
449
|
+
try {
|
|
450
|
+
await work.run(job.key, execute, job).result;
|
|
451
|
+
} finally {
|
|
452
|
+
const left = (outstanding.get(job.key) ?? 1) - 1;
|
|
453
|
+
if (left === 0)
|
|
454
|
+
outstanding.delete(job.key);
|
|
455
|
+
else
|
|
456
|
+
outstanding.set(job.key, left);
|
|
457
|
+
}
|
|
458
|
+
};
|
|
362
459
|
async function execute(job) {
|
|
363
460
|
const report = reports.get(job.key);
|
|
364
461
|
const leaseMs = job.leaseMs ?? (job.every ? everyMs(job.every) * 4 : 60000);
|
|
@@ -397,17 +494,37 @@ function createScheduler(options) {
|
|
|
397
494
|
});
|
|
398
495
|
}
|
|
399
496
|
}
|
|
497
|
+
function nextDelay(job) {
|
|
498
|
+
if (job.every !== undefined)
|
|
499
|
+
return everyMs(job.every);
|
|
500
|
+
if (job.schedule)
|
|
501
|
+
return Math.max(0, nextWallClockAt(job.schedule, clock.now()) - clock.now());
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
400
504
|
function schedule(job, delayMs) {
|
|
401
|
-
|
|
505
|
+
const next = delayMs ?? nextDelay(job);
|
|
506
|
+
if (!running || next === undefined)
|
|
402
507
|
return;
|
|
508
|
+
reports.get(job.key).nextRunAtMs = clock.now() + next;
|
|
403
509
|
timers.set(job.key, setTimeout(() => {
|
|
404
510
|
timers.delete(job.key);
|
|
405
511
|
if (!running || paused)
|
|
406
512
|
return;
|
|
407
|
-
|
|
513
|
+
if (job.overlap === "skip") {
|
|
514
|
+
schedule(job);
|
|
515
|
+
if ((outstanding.get(job.key) ?? 0) > 0) {
|
|
516
|
+
reports.get(job.key).skippedOverlap += 1;
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
submit(job).catch(() => {
|
|
520
|
+
return;
|
|
521
|
+
});
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
submit(job).then(() => schedule(job), () => {
|
|
408
525
|
return;
|
|
409
526
|
});
|
|
410
|
-
},
|
|
527
|
+
}, next));
|
|
411
528
|
}
|
|
412
529
|
return {
|
|
413
530
|
start() {
|
|
@@ -417,7 +534,7 @@ function createScheduler(options) {
|
|
|
417
534
|
throw new Error("scheduler: cannot restart after an incomplete drain while abandoned work may still run");
|
|
418
535
|
}
|
|
419
536
|
if (workStopped) {
|
|
420
|
-
work = createQueue(
|
|
537
|
+
work = createQueue(queueOptions);
|
|
421
538
|
workStopped = false;
|
|
422
539
|
}
|
|
423
540
|
running = true;
|
|
@@ -425,8 +542,9 @@ function createScheduler(options) {
|
|
|
425
542
|
controller = new AbortController;
|
|
426
543
|
for (const job of jobs.values()) {
|
|
427
544
|
reports.get(job.key).state = "idle";
|
|
428
|
-
const interval = job.every === undefined ?
|
|
429
|
-
|
|
545
|
+
const interval = job.every === undefined ? undefined : everyMs(job.every);
|
|
546
|
+
const initial = job.startDelayMs ?? (interval === undefined ? undefined : spreadOf(job.key, Math.min(interval, 30000)));
|
|
547
|
+
schedule(job, initial);
|
|
430
548
|
}
|
|
431
549
|
},
|
|
432
550
|
async stop(deadlineMs = 30000) {
|
|
@@ -440,6 +558,8 @@ function createScheduler(options) {
|
|
|
440
558
|
restartBlocked = drained.timedOut;
|
|
441
559
|
for (const report of reports.values())
|
|
442
560
|
report.state = "stopped";
|
|
561
|
+
for (const report of reports.values())
|
|
562
|
+
report.nextRunAtMs = undefined;
|
|
443
563
|
return drained;
|
|
444
564
|
},
|
|
445
565
|
pause() {
|
|
@@ -450,6 +570,7 @@ function createScheduler(options) {
|
|
|
450
570
|
for (const report of reports.values()) {
|
|
451
571
|
if (report.state !== "running")
|
|
452
572
|
report.state = "paused";
|
|
573
|
+
report.nextRunAtMs = undefined;
|
|
453
574
|
}
|
|
454
575
|
},
|
|
455
576
|
resume() {
|
|
@@ -458,7 +579,7 @@ function createScheduler(options) {
|
|
|
458
579
|
paused = false;
|
|
459
580
|
for (const job of jobs.values()) {
|
|
460
581
|
reports.get(job.key).state = "idle";
|
|
461
|
-
schedule(job, 0);
|
|
582
|
+
schedule(job, job.every !== undefined ? 0 : undefined);
|
|
462
583
|
}
|
|
463
584
|
},
|
|
464
585
|
async runNow(key) {
|
|
@@ -483,6 +604,8 @@ function cursorJob(spec) {
|
|
|
483
604
|
key: spec.key,
|
|
484
605
|
label: spec.label,
|
|
485
606
|
every: spec.every,
|
|
607
|
+
schedule: spec.schedule,
|
|
608
|
+
overlap: spec.overlap,
|
|
486
609
|
leaseMs: spec.leaseMs,
|
|
487
610
|
unlocked: spec.unlocked,
|
|
488
611
|
startDelayMs: spec.startDelayMs,
|
|
@@ -516,6 +639,7 @@ var VERSION = "0.1.0";
|
|
|
516
639
|
export {
|
|
517
640
|
systemClock,
|
|
518
641
|
storeLock,
|
|
642
|
+
nextWallClockAt,
|
|
519
643
|
memoryLock,
|
|
520
644
|
everyMs,
|
|
521
645
|
defineJob,
|
package/dist/queue.d.ts
CHANGED
|
@@ -43,10 +43,33 @@ export interface RetryPolicy {
|
|
|
43
43
|
export interface QueueOptions {
|
|
44
44
|
/** How many keys may run at once. Ordering within a key is unaffected. */
|
|
45
45
|
width?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Resource-aware admission. Mutually exclusive with `width`.
|
|
48
|
+
*
|
|
49
|
+
* This limits concurrent KEY lanes; it does not claim that an arbitrary
|
|
50
|
+
* JavaScript closure becomes CPU-parallel. Async I/O overlaps naturally.
|
|
51
|
+
* CPU-bound handlers should use Bun/standard Workers and await them here.
|
|
52
|
+
*/
|
|
53
|
+
resources?: QueueResourcePolicy;
|
|
46
54
|
retry?: Partial<RetryPolicy>;
|
|
47
55
|
/** Retry delay injection. Shutdown deadlines use a cancellable native timer. */
|
|
48
56
|
sleep?: (ms: number) => Promise<void>;
|
|
49
57
|
}
|
|
58
|
+
export interface QueueResourcePolicy {
|
|
59
|
+
/** Percentage of the currently reported logical processors. Default 60. */
|
|
60
|
+
percent?: number;
|
|
61
|
+
/** Logical processors kept outside this queue. Default 1 when possible. */
|
|
62
|
+
reserve?: number;
|
|
63
|
+
/** Never admit fewer lanes than this. Default 1. */
|
|
64
|
+
min?: number;
|
|
65
|
+
/** Optional hard ceiling after percentage and reserve are applied. */
|
|
66
|
+
max?: number;
|
|
67
|
+
/**
|
|
68
|
+
* Re-read on every pump, so a container/runtime can expose a changing quota.
|
|
69
|
+
* The default uses `navigator.hardwareConcurrency` and safely falls back to 1.
|
|
70
|
+
*/
|
|
71
|
+
available?: () => number;
|
|
72
|
+
}
|
|
50
73
|
export interface DrainReport {
|
|
51
74
|
completed: number;
|
|
52
75
|
failed: number;
|
|
@@ -64,6 +87,8 @@ export declare class QueueKeyStoppedError extends Error {
|
|
|
64
87
|
export declare class TaskCancelledError extends Error {
|
|
65
88
|
constructor();
|
|
66
89
|
}
|
|
90
|
+
/** Resolve a resource policy to a safe positive queue width. */
|
|
91
|
+
export declare function queueWidthFor(policy?: QueueResourcePolicy): number;
|
|
67
92
|
export declare function createQueue(options?: QueueOptions): {
|
|
68
93
|
/**
|
|
69
94
|
* Submit work and await its value.
|
|
@@ -93,6 +118,7 @@ export declare function createQueue(options?: QueueOptions): {
|
|
|
93
118
|
resume(): void;
|
|
94
119
|
/** How much is outstanding, for a health endpoint or a drain decision. */
|
|
95
120
|
snapshot(): {
|
|
121
|
+
width: number;
|
|
96
122
|
running: number;
|
|
97
123
|
queued: number;
|
|
98
124
|
keys: number;
|
package/dist/queue.js
CHANGED
|
@@ -33,10 +33,41 @@ var DEFAULT_RETRY = {
|
|
|
33
33
|
attempts: 1,
|
|
34
34
|
backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
|
|
35
35
|
};
|
|
36
|
+
var reportedParallelism = () => {
|
|
37
|
+
const reported = globalThis.navigator?.hardwareConcurrency;
|
|
38
|
+
return Number.isSafeInteger(reported) && reported > 0 ? reported : 1;
|
|
39
|
+
};
|
|
40
|
+
function queueWidthFor(policy = {}) {
|
|
41
|
+
const percent = policy.percent ?? 60;
|
|
42
|
+
const reserve = policy.reserve ?? 1;
|
|
43
|
+
const min = policy.min ?? 1;
|
|
44
|
+
const max = policy.max ?? Number.MAX_SAFE_INTEGER;
|
|
45
|
+
const available = (policy.available ?? reportedParallelism)();
|
|
46
|
+
if (!Number.isFinite(percent) || percent <= 0 || percent > 100) {
|
|
47
|
+
throw new RangeError("queue: resource percent must be greater than 0 and at most 100");
|
|
48
|
+
}
|
|
49
|
+
for (const [name, value] of [["reserve", reserve], ["min", min], ["max", max]]) {
|
|
50
|
+
if (!Number.isSafeInteger(value) || value < (name === "reserve" ? 0 : 1)) {
|
|
51
|
+
throw new RangeError(`queue: resource ${name} must be ${name === "reserve" ? "a non-negative" : "a positive"} integer`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!Number.isSafeInteger(available) || available < 1) {
|
|
55
|
+
throw new RangeError("queue: available parallelism must be a positive integer");
|
|
56
|
+
}
|
|
57
|
+
if (min > max)
|
|
58
|
+
throw new RangeError("queue: resource min cannot exceed max");
|
|
59
|
+
const usable = Math.max(1, available - reserve);
|
|
60
|
+
return Math.min(max, Math.max(min, Math.floor(usable * percent / 100)));
|
|
61
|
+
}
|
|
36
62
|
function createQueue(options = {}) {
|
|
37
|
-
|
|
63
|
+
if (options.width !== undefined && options.resources !== undefined) {
|
|
64
|
+
throw new Error("queue: choose either width or resources, not both");
|
|
65
|
+
}
|
|
66
|
+
const configuredWidth = options.width;
|
|
67
|
+
const widthNow = () => configuredWidth ?? queueWidthFor(options.resources);
|
|
68
|
+
const initialWidth = widthNow();
|
|
38
69
|
const retry = { ...DEFAULT_RETRY, ...options.retry };
|
|
39
|
-
if (!Number.isSafeInteger(
|
|
70
|
+
if (!Number.isSafeInteger(initialWidth) || initialWidth < 1) {
|
|
40
71
|
throw new RangeError("queue: width must be a positive integer");
|
|
41
72
|
}
|
|
42
73
|
if (!Number.isSafeInteger(retry.attempts) || retry.attempts < 1) {
|
|
@@ -68,6 +99,7 @@ function createQueue(options = {}) {
|
|
|
68
99
|
announceIdle();
|
|
69
100
|
return;
|
|
70
101
|
}
|
|
102
|
+
const width = widthNow();
|
|
71
103
|
for (const [key, lane] of lanes) {
|
|
72
104
|
if (running.size >= width)
|
|
73
105
|
break;
|
|
@@ -215,6 +247,7 @@ function createQueue(options = {}) {
|
|
|
215
247
|
for (const lane of lanes.values())
|
|
216
248
|
queued += lane.length;
|
|
217
249
|
return {
|
|
250
|
+
width: widthNow(),
|
|
218
251
|
running: running.size,
|
|
219
252
|
queued,
|
|
220
253
|
keys: lanes.size,
|
|
@@ -275,6 +308,7 @@ function createQueue(options = {}) {
|
|
|
275
308
|
};
|
|
276
309
|
}
|
|
277
310
|
export {
|
|
311
|
+
queueWidthFor,
|
|
278
312
|
createQueue,
|
|
279
313
|
TaskCancelledError,
|
|
280
314
|
QueueStoppedError,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
|
|
3
3
|
"name": "@forgezero/runtime",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -178,6 +178,7 @@
|
|
|
178
178
|
},
|
|
179
179
|
"scripts": {
|
|
180
180
|
"check": "tsc --noEmit",
|
|
181
|
+
"prebuild": "rm -rf dist",
|
|
181
182
|
"build": "bun build src/jobs.ts src/queue.ts src/outbox.ts src/audit.ts src/backup.ts src/notify.ts src/notify-templates.ts src/calendar.ts src/compliance.ts src/pipeline.ts src/totp.ts src/otpauth.ts src/identity.ts src/slip10.ts src/openssh.ts src/ssh-cert.ts src/importers.ts src/snp.ts src/passkey.ts src/custody-crypto.ts src/custody-share.ts src/phrase.ts src/ssh-agent.ts src/schema.ts src/schema-typebox.ts src/finance/discounts.ts src/finance/money.ts src/finance/storage.ts src/finance/custody.ts src/finance/tax.ts src/finance/derive.ts src/finance/venues.ts src/finance/ledger.ts src/finance/rates.ts src/finance/transfers.ts src/finance/chain.ts src/finance/chain-addresses.ts src/finance/chain-deposits.ts src/finance/chain-withdrawals.ts src/finance/chain-reconcile.ts src/finance/market.ts src/finance/commission.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
|
|
182
183
|
"prepublishOnly": "bun run check && bun run build"
|
|
183
184
|
},
|
|
@@ -240,10 +241,10 @@
|
|
|
240
241
|
"homepage": "https://www.forgezero.net/docs/runtime",
|
|
241
242
|
"repository": {
|
|
242
243
|
"type": "git",
|
|
243
|
-
"url": "git+https://github.com/
|
|
244
|
+
"url": "git+https://github.com/forgezero-net/packages.git",
|
|
244
245
|
"directory": "packages/runtime"
|
|
245
246
|
},
|
|
246
|
-
"bugs": "https://github.com/
|
|
247
|
+
"bugs": "https://github.com/forgezero-net/packages/issues",
|
|
247
248
|
"sideEffects": false,
|
|
248
249
|
"files": [
|
|
249
250
|
"dist",
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { VenueError, type VenueAdapter, type MarketType, type OrderStatus } from './venues';
|
|
2
|
-
export interface BinanceCredentials {
|
|
3
|
-
apiKey: string;
|
|
4
|
-
apiSecret: string;
|
|
5
|
-
}
|
|
6
|
-
export interface BinanceOptions {
|
|
7
|
-
credentials: BinanceCredentials;
|
|
8
|
-
/** Override for testnet, or for a test. */
|
|
9
|
-
hosts?: Partial<Record<MarketType, string>>;
|
|
10
|
-
fetch?: typeof globalThis.fetch;
|
|
11
|
-
/**
|
|
12
|
-
* How far a request may be delayed before Binance refuses it.
|
|
13
|
-
*
|
|
14
|
-
* 5s rather than the 60s maximum. A signed order that arrives a minute late
|
|
15
|
-
* is an order placed into a market that has moved, and accepting it is worse
|
|
16
|
-
* than being told to retry.
|
|
17
|
-
*/
|
|
18
|
-
recvWindowMs?: number;
|
|
19
|
-
now?: () => number;
|
|
20
|
-
}
|
|
21
|
-
/** Binance spells `BTC/USDT` as `BTCUSDT`. Denormalised here and nowhere else. */
|
|
22
|
-
export declare const binanceSymbol: (symbol: string) => string;
|
|
23
|
-
/** Binance statuses → ours. An unknown one is `rejected`, never silently `accepted`. */
|
|
24
|
-
export declare function toOrderStatus(status: string): OrderStatus;
|
|
25
|
-
export declare function createBinanceAdapter(options: BinanceOptions): VenueAdapter;
|
|
26
|
-
/** Turn a Binance error into something that names the actual cause. */
|
|
27
|
-
export declare function readBinanceError(error: unknown): VenueError | undefined;
|
package/dist/finance/binance.js
DELETED
|
@@ -1,452 +0,0 @@
|
|
|
1
|
-
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
-
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
-
}) : x)(function(x) {
|
|
4
|
-
if (typeof require !== "undefined")
|
|
5
|
-
return require.apply(this, arguments);
|
|
6
|
-
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
// src/finance/money.ts
|
|
10
|
-
class MoneyError extends Error {
|
|
11
|
-
code;
|
|
12
|
-
constructor(code, message) {
|
|
13
|
-
super(message);
|
|
14
|
-
this.code = code;
|
|
15
|
-
this.name = "MoneyError";
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
var ASSETS = [
|
|
19
|
-
{ code: "USDT", decimals: 6 },
|
|
20
|
-
{ code: "USDC", decimals: 6 },
|
|
21
|
-
{ code: "BTC", decimals: 8 },
|
|
22
|
-
{ code: "ETH", decimals: 18 },
|
|
23
|
-
{ code: "BNB", decimals: 18 },
|
|
24
|
-
{ code: "EUR", decimals: 2 },
|
|
25
|
-
{ code: "USD", decimals: 2 }
|
|
26
|
-
];
|
|
27
|
-
var REGISTRY = new Map(ASSETS.map((asset) => [asset.code, asset]));
|
|
28
|
-
function defineAsset(spec) {
|
|
29
|
-
if (spec.decimals < 0 || spec.decimals > 30 || !Number.isInteger(spec.decimals)) {
|
|
30
|
-
throw new MoneyError("UNKNOWN_ASSET", `${spec.code}: decimals must be an integer 0–30.`);
|
|
31
|
-
}
|
|
32
|
-
REGISTRY.set(spec.code, spec);
|
|
33
|
-
}
|
|
34
|
-
function assetSpec(code) {
|
|
35
|
-
const spec = REGISTRY.get(code);
|
|
36
|
-
if (!spec)
|
|
37
|
-
throw new MoneyError("UNKNOWN_ASSET", `Unknown asset "${code}". Call defineAsset first.`);
|
|
38
|
-
return spec;
|
|
39
|
-
}
|
|
40
|
-
var money = (units, asset) => {
|
|
41
|
-
assetSpec(asset);
|
|
42
|
-
return { units, asset };
|
|
43
|
-
};
|
|
44
|
-
var zero = (asset) => money(0n, asset);
|
|
45
|
-
function parseAmount(value, asset) {
|
|
46
|
-
const spec = assetSpec(asset);
|
|
47
|
-
const text = value.trim();
|
|
48
|
-
if (!/^-?\d+(\.\d+)?$/.test(text)) {
|
|
49
|
-
throw new MoneyError("NOT_FINITE", `"${value}" is not a plain decimal amount.`);
|
|
50
|
-
}
|
|
51
|
-
const negative = text.startsWith("-");
|
|
52
|
-
const [whole, fraction = ""] = text.replace("-", "").split(".");
|
|
53
|
-
if (fraction.length > spec.decimals) {
|
|
54
|
-
throw new MoneyError("PRECISION_LOSS", `${asset} has ${spec.decimals} decimals; "${value}" has ${fraction.length}.`);
|
|
55
|
-
}
|
|
56
|
-
const padded = fraction.padEnd(spec.decimals, "0");
|
|
57
|
-
const units = BigInt(whole + padded);
|
|
58
|
-
return { units: negative ? -units : units, asset };
|
|
59
|
-
}
|
|
60
|
-
function formatAmount(amount, options = {}) {
|
|
61
|
-
const spec = assetSpec(amount.asset);
|
|
62
|
-
const negative = amount.units < 0n;
|
|
63
|
-
const digits = (negative ? -amount.units : amount.units).toString().padStart(spec.decimals + 1, "0");
|
|
64
|
-
const whole = digits.slice(0, digits.length - spec.decimals);
|
|
65
|
-
let fraction = spec.decimals === 0 ? "" : digits.slice(digits.length - spec.decimals);
|
|
66
|
-
if (options.trim && fraction)
|
|
67
|
-
fraction = fraction.replace(/0+$/, "");
|
|
68
|
-
return `${negative ? "-" : ""}${whole}${fraction ? `.${fraction}` : ""}`;
|
|
69
|
-
}
|
|
70
|
-
function sameAsset(a, b) {
|
|
71
|
-
if (a.asset !== b.asset) {
|
|
72
|
-
throw new MoneyError("ASSET_MISMATCH", `Cannot combine ${a.asset} and ${b.asset}.`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
function add(a, b) {
|
|
76
|
-
sameAsset(a, b);
|
|
77
|
-
return { units: a.units + b.units, asset: a.asset };
|
|
78
|
-
}
|
|
79
|
-
function subtract(a, b) {
|
|
80
|
-
sameAsset(a, b);
|
|
81
|
-
return { units: a.units - b.units, asset: a.asset };
|
|
82
|
-
}
|
|
83
|
-
var negate = (amount) => ({ units: -amount.units, asset: amount.asset });
|
|
84
|
-
var abs = (amount) => ({
|
|
85
|
-
units: amount.units < 0n ? -amount.units : amount.units,
|
|
86
|
-
asset: amount.asset
|
|
87
|
-
});
|
|
88
|
-
var isZero = (amount) => amount.units === 0n;
|
|
89
|
-
var isNegative = (amount) => amount.units < 0n;
|
|
90
|
-
function compare(a, b) {
|
|
91
|
-
sameAsset(a, b);
|
|
92
|
-
return a.units < b.units ? -1 : a.units > b.units ? 1 : 0;
|
|
93
|
-
}
|
|
94
|
-
var equals = (a, b) => a.asset === b.asset && a.units === b.units;
|
|
95
|
-
var ROUNDING = ["down", "up", "half-up"];
|
|
96
|
-
function divideRounded(numerator, denominator, mode) {
|
|
97
|
-
if (denominator === 0n)
|
|
98
|
-
throw new MoneyError("DIVIDE_BY_ZERO", "Division by zero.");
|
|
99
|
-
const negative = numerator < 0n !== denominator < 0n;
|
|
100
|
-
const a = numerator < 0n ? -numerator : numerator;
|
|
101
|
-
const b = denominator < 0n ? -denominator : denominator;
|
|
102
|
-
const quotient = a / b;
|
|
103
|
-
const remainder = a % b;
|
|
104
|
-
if (remainder === 0n)
|
|
105
|
-
return negative ? -quotient : quotient;
|
|
106
|
-
let result = quotient;
|
|
107
|
-
if (mode === "up")
|
|
108
|
-
result += 1n;
|
|
109
|
-
else if (mode === "half-up" && remainder * 2n >= b)
|
|
110
|
-
result += 1n;
|
|
111
|
-
return negative ? -result : result;
|
|
112
|
-
}
|
|
113
|
-
function mulRate(amount, rate, mode = "down") {
|
|
114
|
-
if (!/^-?\d+(\.\d+)?$/.test(rate.trim())) {
|
|
115
|
-
throw new MoneyError("NOT_FINITE", `"${rate}" is not a plain decimal rate.`);
|
|
116
|
-
}
|
|
117
|
-
const [whole, fraction = ""] = rate.trim().replace("-", "").split(".");
|
|
118
|
-
const scale = 10n ** BigInt(fraction.length);
|
|
119
|
-
const scaled = BigInt(whole + fraction) * (rate.trim().startsWith("-") ? -1n : 1n);
|
|
120
|
-
return { units: divideRounded(amount.units * scaled, scale, mode), asset: amount.asset };
|
|
121
|
-
}
|
|
122
|
-
function convert(amount, to, rate, mode = "down") {
|
|
123
|
-
const from = assetSpec(amount.asset);
|
|
124
|
-
const target = assetSpec(to);
|
|
125
|
-
const asTarget = mulRate({ units: amount.units, asset: to }, rate, mode);
|
|
126
|
-
const shift = target.decimals - from.decimals;
|
|
127
|
-
if (shift === 0)
|
|
128
|
-
return asTarget;
|
|
129
|
-
if (shift > 0)
|
|
130
|
-
return { units: asTarget.units * 10n ** BigInt(shift), asset: to };
|
|
131
|
-
return { units: divideRounded(asTarget.units, 10n ** BigInt(-shift), mode), asset: to };
|
|
132
|
-
}
|
|
133
|
-
function allocate(amount, parts) {
|
|
134
|
-
if (parts < 1)
|
|
135
|
-
throw new MoneyError("NOT_FINITE", "Cannot allocate into fewer than one part.");
|
|
136
|
-
const each = divideRounded(amount.units, BigInt(parts), "down");
|
|
137
|
-
const allocated = Array.from({ length: parts }, () => each);
|
|
138
|
-
let remainder = amount.units - each * BigInt(parts);
|
|
139
|
-
const step = remainder < 0n ? -1n : 1n;
|
|
140
|
-
for (let index = 0;remainder !== 0n; index = (index + 1) % parts) {
|
|
141
|
-
allocated[index] += step;
|
|
142
|
-
remainder -= step;
|
|
143
|
-
}
|
|
144
|
-
return allocated.map((units) => ({ units, asset: amount.asset }));
|
|
145
|
-
}
|
|
146
|
-
function toStep(amount, step, mode = "down") {
|
|
147
|
-
const stepUnits = parseAmount(step, amount.asset).units;
|
|
148
|
-
if (stepUnits <= 0n)
|
|
149
|
-
throw new MoneyError("NOT_FINITE", "A step must be positive.");
|
|
150
|
-
return { units: divideRounded(amount.units, stepUnits, mode) * stepUnits, asset: amount.asset };
|
|
151
|
-
}
|
|
152
|
-
var VERSION = "0.1.0";
|
|
153
|
-
|
|
154
|
-
// src/finance/venues.ts
|
|
155
|
-
var MARKET_TYPES = ["spot", "margin", "futures"];
|
|
156
|
-
var ORDER_SIDES = ["buy", "sell"];
|
|
157
|
-
var ORDER_TYPES = ["market", "limit", "stop-limit"];
|
|
158
|
-
var TIME_IN_FORCE = ["gtc", "ioc", "fok"];
|
|
159
|
-
var VENUES = [
|
|
160
|
-
{
|
|
161
|
-
key: "binance",
|
|
162
|
-
label: "Binance",
|
|
163
|
-
markets: ["spot", "margin", "futures"],
|
|
164
|
-
rateLimit: { perMinute: 6000, counts: "weight" },
|
|
165
|
-
clientOrderIds: true,
|
|
166
|
-
note: "Publishes no AAAA records, so every endpoint is IPv4-only and the budget is per IP."
|
|
167
|
-
}
|
|
168
|
-
];
|
|
169
|
-
var venue = (key) => VENUES.find((entry) => entry.key === key);
|
|
170
|
-
var venuesFor = (type) => VENUES.filter((entry) => entry.markets.includes(type));
|
|
171
|
-
var symbolOf = (base, quote) => `${base.toUpperCase()}/${quote.toUpperCase()}`;
|
|
172
|
-
function parseSymbol(symbol) {
|
|
173
|
-
const [base, quote] = symbol.toUpperCase().split("/");
|
|
174
|
-
if (!base || !quote)
|
|
175
|
-
throw new VenueError("BAD_SYMBOL", `"${symbol}" is not BASE/QUOTE.`);
|
|
176
|
-
return { base, quote };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
class VenueError extends Error {
|
|
180
|
-
code;
|
|
181
|
-
constructor(code, message) {
|
|
182
|
-
super(message);
|
|
183
|
-
this.code = code;
|
|
184
|
-
this.name = "VenueError";
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
function notionalOf(request, market) {
|
|
188
|
-
const price = request.price;
|
|
189
|
-
if (!price)
|
|
190
|
-
return parseAmount("0", market.quote);
|
|
191
|
-
const quantity = Number(formatAmount(request.quantity));
|
|
192
|
-
const priceText = formatAmount(price);
|
|
193
|
-
return parseAmount((quantity * Number(priceText)).toFixed(0) === "NaN" ? "0" : String(quantity * Number(priceText)), market.quote);
|
|
194
|
-
}
|
|
195
|
-
function validateOrder(request, market) {
|
|
196
|
-
const spec = venue(request.venue);
|
|
197
|
-
if (!spec)
|
|
198
|
-
throw new VenueError("UNKNOWN_VENUE", `Unknown venue "${request.venue}".`);
|
|
199
|
-
if (!spec.markets.includes(request.type)) {
|
|
200
|
-
throw new VenueError("MARKET_UNSUPPORTED", `${spec.label} does not offer ${request.type}.`);
|
|
201
|
-
}
|
|
202
|
-
if (market.venue !== request.venue || market.type !== request.type || market.symbol !== request.symbol) {
|
|
203
|
-
throw new VenueError("UNKNOWN_MARKET", "The market does not describe this order.");
|
|
204
|
-
}
|
|
205
|
-
if (request.orderType !== "market" && !request.price) {
|
|
206
|
-
throw new VenueError("PRICE_REQUIRED", `A ${request.orderType} order needs a price.`);
|
|
207
|
-
}
|
|
208
|
-
const snapped = toStep(request.quantity, market.lotStep);
|
|
209
|
-
if (compare(snapped, request.quantity) !== 0) {
|
|
210
|
-
throw new VenueError("LOT_STEP", `Quantity ${formatAmount(request.quantity, { trim: true })} is not a multiple of ${market.lotStep}.`);
|
|
211
|
-
}
|
|
212
|
-
if (request.price) {
|
|
213
|
-
const onTick = toStep(request.price, market.tickStep);
|
|
214
|
-
if (compare(onTick, request.price) !== 0) {
|
|
215
|
-
throw new VenueError("TICK_STEP", `Price ${formatAmount(request.price, { trim: true })} is not a multiple of ${market.tickStep}.`);
|
|
216
|
-
}
|
|
217
|
-
const notional = notionalOf(request, market);
|
|
218
|
-
if (compare(notional, parseAmount(market.minNotional, market.quote)) < 0) {
|
|
219
|
-
throw new VenueError("MIN_NOTIONAL", `Order is worth ${formatAmount(notional, { trim: true })} ${market.quote}; the minimum is ${market.minNotional}.`);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
if (request.leverage !== undefined) {
|
|
223
|
-
if (request.type === "spot" || market.maxLeverage === undefined) {
|
|
224
|
-
throw new VenueError("LEVERAGE_UNSUPPORTED", `${request.type} markets have no leverage.`);
|
|
225
|
-
}
|
|
226
|
-
if (request.leverage < 1 || request.leverage > market.maxLeverage) {
|
|
227
|
-
throw new VenueError("LEVERAGE_TOO_HIGH", `Leverage ${request.leverage}× exceeds the ${market.maxLeverage}× maximum on this market.`);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
async function submitOrder(adapter, request, market) {
|
|
232
|
-
validateOrder(request, market);
|
|
233
|
-
return adapter.placeOrder(request, market);
|
|
234
|
-
}
|
|
235
|
-
var VERSION2 = "0.1.0";
|
|
236
|
-
|
|
237
|
-
// src/finance/binance.ts
|
|
238
|
-
import { createHttpClient, binanceWeight } from "@forgezero/providers/http";
|
|
239
|
-
var BUDGET_HOST = "binance";
|
|
240
|
-
var DEFAULT_HOSTS = {
|
|
241
|
-
spot: "https://api.binance.com",
|
|
242
|
-
margin: "https://api.binance.com",
|
|
243
|
-
futures: "https://fapi.binance.com"
|
|
244
|
-
};
|
|
245
|
-
var binanceSymbol = (symbol) => {
|
|
246
|
-
const { base, quote } = parseSymbol(symbol);
|
|
247
|
-
return `${base}${quote}`;
|
|
248
|
-
};
|
|
249
|
-
var PATHS = {
|
|
250
|
-
spot: {
|
|
251
|
-
order: "/api/v3/order",
|
|
252
|
-
openOrders: "/api/v3/openOrders",
|
|
253
|
-
account: "/api/v3/account",
|
|
254
|
-
exchangeInfo: "/api/v3/exchangeInfo"
|
|
255
|
-
},
|
|
256
|
-
margin: {
|
|
257
|
-
order: "/sapi/v1/margin/order",
|
|
258
|
-
openOrders: "/sapi/v1/margin/openOrders",
|
|
259
|
-
account: "/sapi/v1/margin/account",
|
|
260
|
-
exchangeInfo: "/api/v3/exchangeInfo"
|
|
261
|
-
},
|
|
262
|
-
futures: {
|
|
263
|
-
order: "/fapi/v1/order",
|
|
264
|
-
openOrders: "/fapi/v1/openOrders",
|
|
265
|
-
account: "/fapi/v2/account",
|
|
266
|
-
exchangeInfo: "/fapi/v1/exchangeInfo"
|
|
267
|
-
}
|
|
268
|
-
};
|
|
269
|
-
function toOrderStatus(status) {
|
|
270
|
-
switch (status) {
|
|
271
|
-
case "NEW":
|
|
272
|
-
case "PENDING_NEW":
|
|
273
|
-
return "accepted";
|
|
274
|
-
case "PARTIALLY_FILLED":
|
|
275
|
-
return "partial";
|
|
276
|
-
case "FILLED":
|
|
277
|
-
return "filled";
|
|
278
|
-
case "CANCELED":
|
|
279
|
-
case "PENDING_CANCEL":
|
|
280
|
-
case "EXPIRED":
|
|
281
|
-
case "EXPIRED_IN_MATCH":
|
|
282
|
-
return "cancelled";
|
|
283
|
-
default:
|
|
284
|
-
return "rejected";
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
var SIDE = { buy: "BUY", sell: "SELL" };
|
|
288
|
-
var TYPE = { market: "MARKET", limit: "LIMIT", "stop-limit": "STOP_LOSS_LIMIT" };
|
|
289
|
-
async function sign(secret, query) {
|
|
290
|
-
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
291
|
-
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(query));
|
|
292
|
-
return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
293
|
-
}
|
|
294
|
-
function createBinanceAdapter(options) {
|
|
295
|
-
const hosts = { ...DEFAULT_HOSTS, ...options.hosts };
|
|
296
|
-
const recvWindow = options.recvWindowMs ?? 5000;
|
|
297
|
-
const now = options.now ?? Date.now;
|
|
298
|
-
const clients = new Map;
|
|
299
|
-
const clientFor = (type) => {
|
|
300
|
-
const base = hosts[type];
|
|
301
|
-
const existing = clients.get(base);
|
|
302
|
-
if (existing)
|
|
303
|
-
return existing;
|
|
304
|
-
const client = createHttpClient({
|
|
305
|
-
baseUrl: base,
|
|
306
|
-
fetch: options.fetch,
|
|
307
|
-
costOf: binanceWeight,
|
|
308
|
-
budget: { host: BUDGET_HOST, limit: 6000, windowMs: 60000, defaultCost: 1, headroom: 0.9 }
|
|
309
|
-
});
|
|
310
|
-
clients.set(base, client);
|
|
311
|
-
return client;
|
|
312
|
-
};
|
|
313
|
-
async function signed(args) {
|
|
314
|
-
const entries = Object.entries(args.params).filter(([, value]) => value !== undefined);
|
|
315
|
-
const base = new URLSearchParams(entries.map(([name, value]) => [name, String(value)])).toString();
|
|
316
|
-
const withTiming = `${base}${base ? "&" : ""}recvWindow=${recvWindow}×tamp=${now()}`;
|
|
317
|
-
const signature = await sign(options.credentials.apiSecret, withTiming);
|
|
318
|
-
const response = await clientFor(args.type).call({
|
|
319
|
-
path: `${args.path}?${withTiming}&signature=${signature}`,
|
|
320
|
-
method: args.method,
|
|
321
|
-
weight: args.weight,
|
|
322
|
-
headers: { "X-MBX-APIKEY": options.credentials.apiKey }
|
|
323
|
-
});
|
|
324
|
-
return response.body;
|
|
325
|
-
}
|
|
326
|
-
return {
|
|
327
|
-
venue: "binance",
|
|
328
|
-
symbolFor: (symbol) => binanceSymbol(symbol),
|
|
329
|
-
async markets(type) {
|
|
330
|
-
const info = await clientFor(type).call({ path: PATHS[type].exchangeInfo, weight: 20 });
|
|
331
|
-
return (info.body.symbols ?? []).filter((entry) => entry.status === "TRADING").map((entry) => {
|
|
332
|
-
const filter = (name) => entry.filters.find((candidate) => candidate.filterType === name);
|
|
333
|
-
return {
|
|
334
|
-
venue: "binance",
|
|
335
|
-
type,
|
|
336
|
-
symbol: `${entry.baseAsset}/${entry.quoteAsset}`,
|
|
337
|
-
base: entry.baseAsset,
|
|
338
|
-
quote: entry.quoteAsset,
|
|
339
|
-
lotStep: filter("LOT_SIZE")?.stepSize ?? "0.00000001",
|
|
340
|
-
tickStep: filter("PRICE_FILTER")?.tickSize ?? "0.00000001",
|
|
341
|
-
minNotional: filter("NOTIONAL")?.minNotional ?? filter("MIN_NOTIONAL")?.minNotional ?? "0",
|
|
342
|
-
...type === "spot" ? {} : { maxLeverage: 125 }
|
|
343
|
-
};
|
|
344
|
-
});
|
|
345
|
-
},
|
|
346
|
-
async placeOrder(request, market) {
|
|
347
|
-
if (request.type === "futures" && request.leverage) {
|
|
348
|
-
await signed({
|
|
349
|
-
type: "futures",
|
|
350
|
-
path: "/fapi/v1/leverage",
|
|
351
|
-
method: "POST",
|
|
352
|
-
params: { symbol: binanceSymbol(request.symbol), leverage: request.leverage }
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
const raw = await signed({
|
|
356
|
-
type: request.type,
|
|
357
|
-
path: PATHS[request.type].order,
|
|
358
|
-
method: "POST",
|
|
359
|
-
weight: 1,
|
|
360
|
-
params: {
|
|
361
|
-
symbol: binanceSymbol(request.symbol),
|
|
362
|
-
side: SIDE[request.side],
|
|
363
|
-
type: TYPE[request.orderType],
|
|
364
|
-
quantity: formatAmount(request.quantity, { trim: true }),
|
|
365
|
-
price: request.price ? formatAmount(request.price, { trim: true }) : undefined,
|
|
366
|
-
stopPrice: request.stopPrice ? formatAmount(request.stopPrice, { trim: true }) : undefined,
|
|
367
|
-
timeInForce: request.orderType === "market" ? undefined : (request.timeInForce ?? "gtc").toUpperCase(),
|
|
368
|
-
newClientOrderId: request.clientOrderId,
|
|
369
|
-
...request.type === "margin" ? { sideEffectType: "NO_SIDE_EFFECT" } : {},
|
|
370
|
-
...request.dryRun ? { test: "true" } : {}
|
|
371
|
-
}
|
|
372
|
-
});
|
|
373
|
-
return readOrder(raw, market);
|
|
374
|
-
},
|
|
375
|
-
async cancelOrder(args) {
|
|
376
|
-
await signed({
|
|
377
|
-
type: args.type,
|
|
378
|
-
path: PATHS[args.type].order,
|
|
379
|
-
method: "DELETE",
|
|
380
|
-
params: { symbol: binanceSymbol(args.symbol), orderId: args.venueOrderId }
|
|
381
|
-
});
|
|
382
|
-
},
|
|
383
|
-
async openOrders(args) {
|
|
384
|
-
const raw = await signed({
|
|
385
|
-
type: args.type,
|
|
386
|
-
path: PATHS[args.type].openOrders,
|
|
387
|
-
method: "GET",
|
|
388
|
-
weight: args.symbol ? 3 : 40,
|
|
389
|
-
params: { symbol: args.symbol ? binanceSymbol(args.symbol) : undefined }
|
|
390
|
-
});
|
|
391
|
-
return (raw ?? []).map((entry) => readOrder(entry, undefined));
|
|
392
|
-
},
|
|
393
|
-
async balances(type) {
|
|
394
|
-
const raw = await signed({ type, path: PATHS[type].account, method: "GET", weight: 10, params: {} });
|
|
395
|
-
const rows = raw.balances ?? raw.userAssets ?? (raw.assets ?? []).map((entry) => ({ asset: entry.asset, free: entry.availableBalance }));
|
|
396
|
-
return rows.filter((entry) => Number(entry.free) > 0).map((entry) => {
|
|
397
|
-
try {
|
|
398
|
-
return parseAmount(entry.free, entry.asset);
|
|
399
|
-
} catch {
|
|
400
|
-
return null;
|
|
401
|
-
}
|
|
402
|
-
}).filter((amount) => amount !== null);
|
|
403
|
-
}
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
function readOrder(raw, market) {
|
|
407
|
-
const asset = market?.base ?? "BTC";
|
|
408
|
-
const executed = String(raw.executedQty ?? raw.origQty ?? "0");
|
|
409
|
-
let filledQuantity;
|
|
410
|
-
try {
|
|
411
|
-
filledQuantity = parseAmount(executed, asset);
|
|
412
|
-
} catch {
|
|
413
|
-
filledQuantity = zero(asset);
|
|
414
|
-
}
|
|
415
|
-
const quoteFilled = Number(raw.cummulativeQuoteQty ?? 0);
|
|
416
|
-
const filled = Number(executed);
|
|
417
|
-
return {
|
|
418
|
-
venueOrderId: String(raw.orderId ?? ""),
|
|
419
|
-
clientOrderId: raw.clientOrderId ? String(raw.clientOrderId) : undefined,
|
|
420
|
-
status: toOrderStatus(String(raw.status ?? "NEW")),
|
|
421
|
-
filledQuantity,
|
|
422
|
-
...market && filled > 0 && quoteFilled > 0 ? {
|
|
423
|
-
averagePrice: (() => {
|
|
424
|
-
try {
|
|
425
|
-
return parseAmount((quoteFilled / filled).toFixed(assetSpec(market.quote).decimals), market.quote);
|
|
426
|
-
} catch {
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
})()
|
|
430
|
-
} : {},
|
|
431
|
-
raw
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
function readBinanceError(error) {
|
|
435
|
-
const body = error.body;
|
|
436
|
-
if (!body?.code)
|
|
437
|
-
return;
|
|
438
|
-
const known = {
|
|
439
|
-
[-1013]: ["MIN_NOTIONAL", "The order is below the venue minimum, or off its lot or tick step."],
|
|
440
|
-
[-2010]: ["MIN_NOTIONAL", "Rejected: insufficient balance, or below the minimum."],
|
|
441
|
-
[-1111]: ["LOT_STEP", "More decimal places than this market accepts."],
|
|
442
|
-
[-1121]: ["UNKNOWN_MARKET", "That symbol is not traded on this venue."]
|
|
443
|
-
};
|
|
444
|
-
const match = known[body.code];
|
|
445
|
-
return match ? new VenueError(match[0], `${match[1]} (${body.msg})`) : undefined;
|
|
446
|
-
}
|
|
447
|
-
export {
|
|
448
|
-
toOrderStatus,
|
|
449
|
-
readBinanceError,
|
|
450
|
-
createBinanceAdapter,
|
|
451
|
-
binanceSymbol
|
|
452
|
-
};
|
package/dist/serial.d.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Run work for one key strictly in order, and give the caller the result back.
|
|
3
|
-
*
|
|
4
|
-
* ## Why this is not `@forgezero/runtime/queue`
|
|
5
|
-
*
|
|
6
|
-
* The queue is for work that happens LATER: it persists a message, hands it to
|
|
7
|
-
* a handler, retries, dead-letters. It is the right answer when the caller does
|
|
8
|
-
* not need the outcome — a deposit credit, a webhook, a scan.
|
|
9
|
-
*
|
|
10
|
-
* This is for work that happens NOW and whose result the caller returns. A
|
|
11
|
-
* request writing a ledger posting has to answer with the posting; enqueuing it
|
|
12
|
-
* would mean replying "accepted" to something the caller needs to have
|
|
13
|
-
* happened. Two different problems, and using the queue for this one would mean
|
|
14
|
-
* inventing a way to wait for a message — which is a worse version of this file.
|
|
15
|
-
*
|
|
16
|
-
* It was written twice before it was written once: the audit chain needed it to
|
|
17
|
-
* keep sequence numbers gapless, and accounts needed it to stop two credits
|
|
18
|
-
* interleaving. Two copies of a concurrency primitive is two chances to get it
|
|
19
|
-
* wrong, so it lives here.
|
|
20
|
-
*
|
|
21
|
-
* ## What it does NOT give you
|
|
22
|
-
*
|
|
23
|
-
* Ordering within ONE process. Two API nodes each hold their own chain, so a
|
|
24
|
-
* durable guarantee still needs a unique index on whatever must not happen
|
|
25
|
-
* twice. This makes the common case cheap and correct; the index makes every
|
|
26
|
-
* case correct. Anything relying on this alone across a cluster is relying on
|
|
27
|
-
* there being one node, which stops being true without warning.
|
|
28
|
-
*/
|
|
29
|
-
export interface SerialOptions {
|
|
30
|
-
/**
|
|
31
|
-
* Keys to keep chains for.
|
|
32
|
-
*
|
|
33
|
-
* A chain per key is a promise per key, and a process serving a million
|
|
34
|
-
* accounts would hold a million of them forever. Idle chains are dropped once
|
|
35
|
-
* settled, so the map holds only keys with work in flight.
|
|
36
|
-
*/
|
|
37
|
-
maxKeys?: number;
|
|
38
|
-
}
|
|
39
|
-
export declare function createKeyedSerial(options?: SerialOptions): {
|
|
40
|
-
/**
|
|
41
|
-
* Run `work` after everything already queued for this key.
|
|
42
|
-
*
|
|
43
|
-
* A rejection does NOT poison the chain: the tail is always replaced with a
|
|
44
|
-
* settled promise, so one transient failure cannot take every subsequent
|
|
45
|
-
* call for that key with it — which is how a single database blip becomes a
|
|
46
|
-
* permanently stuck account.
|
|
47
|
-
*/
|
|
48
|
-
run<T>(key: string, work: () => Promise<T>): Promise<T>;
|
|
49
|
-
/** How many keys have work in flight. For a health endpoint. */
|
|
50
|
-
size: () => number;
|
|
51
|
-
/** Wait for everything currently queued. Tests, and a graceful shutdown. */
|
|
52
|
-
drain: () => Promise<void>;
|
|
53
|
-
};
|
|
54
|
-
export type KeyedSerial = ReturnType<typeof createKeyedSerial>;
|
package/dist/serial.js
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
-
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
-
}) : x)(function(x) {
|
|
4
|
-
if (typeof require !== "undefined")
|
|
5
|
-
return require.apply(this, arguments);
|
|
6
|
-
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
// src/serial.ts
|
|
10
|
-
function createKeyedSerial(options = {}) {
|
|
11
|
-
const maxKeys = options.maxKeys ?? 1e4;
|
|
12
|
-
const chains = new Map;
|
|
13
|
-
return {
|
|
14
|
-
run(key, work) {
|
|
15
|
-
const previous = chains.get(key) ?? Promise.resolve();
|
|
16
|
-
const next = previous.then(work, work);
|
|
17
|
-
const settled = next.then(() => {
|
|
18
|
-
return;
|
|
19
|
-
}, () => {
|
|
20
|
-
return;
|
|
21
|
-
});
|
|
22
|
-
chains.set(key, settled);
|
|
23
|
-
settled.then(() => {
|
|
24
|
-
if (chains.get(key) === settled)
|
|
25
|
-
chains.delete(key);
|
|
26
|
-
});
|
|
27
|
-
if (chains.size > maxKeys) {
|
|
28
|
-
console.warn(`[serial] ${chains.size} keys in flight, above the ${maxKeys} guideline.`);
|
|
29
|
-
}
|
|
30
|
-
return next;
|
|
31
|
-
},
|
|
32
|
-
size: () => chains.size,
|
|
33
|
-
drain: async () => {
|
|
34
|
-
await Promise.allSettled([...chains.values()]);
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
export {
|
|
39
|
-
createKeyedSerial
|
|
40
|
-
};
|