@johannc-dev/zyrk 0.0.0-stage → 0.2.1
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 +79 -2
- package/dist/build.d.ts +48 -0
- package/dist/build.js +338 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +295 -0
- package/dist/client.d.ts +212 -0
- package/dist/client.js +909 -0
- package/dist/contracts.d.ts +312 -0
- package/dist/contracts.js +154 -0
- package/dist/errors.d.ts +99 -0
- package/dist/errors.js +63 -0
- package/dist/export.d.ts +96 -0
- package/dist/export.js +422 -0
- package/dist/globals.d.ts +59 -0
- package/dist/hourfile.d.ts +59 -0
- package/dist/hourfile.js +345 -0
- package/dist/limits.d.ts +91 -0
- package/dist/limits.js +54 -0
- package/dist/local-data.d.ts +115 -0
- package/dist/local-data.js +373 -0
- package/dist/local-replay.d.ts +40 -0
- package/dist/local-replay.js +343 -0
- package/dist/local-storage.d.ts +76 -0
- package/dist/local-storage.js +499 -0
- package/dist/local.d.ts +235 -0
- package/dist/local.js +1147 -0
- package/dist/sandbox-bootstrap.js +681 -0
- package/dist/sdk.d.ts +1152 -0
- package/dist/sdk.js +210 -0
- package/dist/stream.d.ts +84 -0
- package/dist/stream.js +167 -0
- package/dist/webhook.d.ts +43 -0
- package/dist/webhook.js +54 -0
- package/package.json +56 -4
package/dist/sdk.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
export const Side = { buy: 0, sell: 1, event: 2 };
|
|
2
|
+
export const Protocol = { pumpfun: 0, pumpswap: 1 };
|
|
3
|
+
export const OrderFlag = {
|
|
4
|
+
known: 1,
|
|
5
|
+
bySignature: 2,
|
|
6
|
+
ambiguous: 4,
|
|
7
|
+
amountDiffers: 8,
|
|
8
|
+
unknownNoMatch: 16,
|
|
9
|
+
unknownNotCovered: 32,
|
|
10
|
+
unknownNoKey: 64,
|
|
11
|
+
notFinalized: 128,
|
|
12
|
+
};
|
|
13
|
+
export const NO_TX_INDEX = 0xffff_ffff;
|
|
14
|
+
export const NO_EVENT_INDEX = 0xffff;
|
|
15
|
+
export const NO_TX_ID = 0xffff_ffff;
|
|
16
|
+
export const TxFlag = {
|
|
17
|
+
bySignature: 1,
|
|
18
|
+
byChain: 2,
|
|
19
|
+
chainIndex: 4,
|
|
20
|
+
providerIndex: 8,
|
|
21
|
+
unknown: 16,
|
|
22
|
+
};
|
|
23
|
+
export const NO_ADDRESS = -1;
|
|
24
|
+
export const HOUR_MS = 3_600_000;
|
|
25
|
+
export const SOL_MINT = "So11111111111111111111111111111111111111112";
|
|
26
|
+
export const MarketEvent = {
|
|
27
|
+
none: 0,
|
|
28
|
+
migrate: 1,
|
|
29
|
+
createPool: 2,
|
|
30
|
+
add: 3,
|
|
31
|
+
remove: 4,
|
|
32
|
+
create: 5,
|
|
33
|
+
feeHolders: 6,
|
|
34
|
+
feeShareholders: 7,
|
|
35
|
+
feeCreator: 8,
|
|
36
|
+
feeSocial: 9,
|
|
37
|
+
feeOther: 10,
|
|
38
|
+
};
|
|
39
|
+
export const MarketFlag = {
|
|
40
|
+
reserves: 1,
|
|
41
|
+
virtualReserves: 2,
|
|
42
|
+
multiOpRow: 4,
|
|
43
|
+
chainBreak: 8,
|
|
44
|
+
chainUnchecked: 16,
|
|
45
|
+
creatorInitialBuy: 32,
|
|
46
|
+
cashback: 64,
|
|
47
|
+
mayhem: 128,
|
|
48
|
+
feeWithoutToken: 256,
|
|
49
|
+
invertedPool: 512,
|
|
50
|
+
virtualTokenReserve: 1024,
|
|
51
|
+
virtualQuoteReserve: 2048,
|
|
52
|
+
};
|
|
53
|
+
export function isFinal(result) {
|
|
54
|
+
return result.status !== "queued" && result.status !== "running";
|
|
55
|
+
}
|
|
56
|
+
export function txFlagNames(flags) {
|
|
57
|
+
return Object.keys(TxFlag).filter((k) => (flags & TxFlag[k]) !== 0);
|
|
58
|
+
}
|
|
59
|
+
export function sameTransaction(a, i, b, j) {
|
|
60
|
+
if (!a.tx || !b.tx)
|
|
61
|
+
throw new RangeError("sameTransaction needs batches read withTx: true");
|
|
62
|
+
const x = a.tx.id[i] ?? NO_TX_ID;
|
|
63
|
+
return a.hour === b.hour && x !== NO_TX_ID && x === b.tx.id[j];
|
|
64
|
+
}
|
|
65
|
+
export function deliveryNow(delivery) {
|
|
66
|
+
return delivery.replay?.clockMs ?? Date.now();
|
|
67
|
+
}
|
|
68
|
+
export function isDelivery(args) {
|
|
69
|
+
return isRecord(args) && args.kind === "events" && Array.isArray(args.events);
|
|
70
|
+
}
|
|
71
|
+
export function isCompletion(args) {
|
|
72
|
+
return (isRecord(args) &&
|
|
73
|
+
args.kind === "completion" &&
|
|
74
|
+
typeof args.executionId === "string");
|
|
75
|
+
}
|
|
76
|
+
export const SDK_MARKER = "__zyrkSdk";
|
|
77
|
+
export const SDK_VERSION = "0.2";
|
|
78
|
+
export function defineProgram(program) {
|
|
79
|
+
Object.defineProperty(program, SDK_MARKER, {
|
|
80
|
+
value: SDK_VERSION,
|
|
81
|
+
enumerable: false,
|
|
82
|
+
});
|
|
83
|
+
return program;
|
|
84
|
+
}
|
|
85
|
+
export function tradeId(batch, i) {
|
|
86
|
+
return `${batch.hour}:${batch.line[i]}:${batch.op[i]}`;
|
|
87
|
+
}
|
|
88
|
+
export function parseTradeId(id) {
|
|
89
|
+
const match = /^(\d+):(\d+):(\d+)$/.exec(id);
|
|
90
|
+
if (!match)
|
|
91
|
+
throw new RangeError(`Invalid trade id: ${id}`);
|
|
92
|
+
return {
|
|
93
|
+
hour: Number(match[1]),
|
|
94
|
+
line: Number(match[2]),
|
|
95
|
+
op: Number(match[3]),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function compareTradeIds(a, b) {
|
|
99
|
+
const x = parseTradeId(a), y = parseTradeId(b);
|
|
100
|
+
return x.hour - y.hour || x.line - y.line || x.op - y.op;
|
|
101
|
+
}
|
|
102
|
+
export function operationTradeId(operation) {
|
|
103
|
+
const id = operation.id;
|
|
104
|
+
if (typeof id === "string" && /^\d+:\d+:\d+$/.test(id))
|
|
105
|
+
return id;
|
|
106
|
+
return operation.order?.ord ?? null;
|
|
107
|
+
}
|
|
108
|
+
export function priceFactor(percent) {
|
|
109
|
+
const ratio = percent >= 0 ? 1 + percent / 100 : 1 / (1 + percent / 100);
|
|
110
|
+
const factor = Math.floor(ratio * 10_000 + 1e-9) / 10_000;
|
|
111
|
+
if (!(factor >= 1.01 && factor <= 11)) {
|
|
112
|
+
throw new RangeError(`priceFactor: ${percent} % is outside 1.01..11`);
|
|
113
|
+
}
|
|
114
|
+
return factor;
|
|
115
|
+
}
|
|
116
|
+
export function addressAt(batch, index) {
|
|
117
|
+
return index < 0 ? null : (batch.addresses[index] ?? null);
|
|
118
|
+
}
|
|
119
|
+
export function addressIndex(batch, address) {
|
|
120
|
+
return batch.addresses.indexOf(address);
|
|
121
|
+
}
|
|
122
|
+
export function mintOf(batch, i) {
|
|
123
|
+
return addressAt(batch, batch.mint[i] ?? NO_ADDRESS) ?? "";
|
|
124
|
+
}
|
|
125
|
+
export function traderOf(batch, i) {
|
|
126
|
+
return addressAt(batch, batch.trader[i] ?? NO_ADDRESS);
|
|
127
|
+
}
|
|
128
|
+
export function poolOf(batch, i) {
|
|
129
|
+
return addressAt(batch, batch.pool[i] ?? NO_ADDRESS);
|
|
130
|
+
}
|
|
131
|
+
export function quoteMintOf(batch, i) {
|
|
132
|
+
return addressAt(batch, batch.quoteMint[i] ?? NO_ADDRESS) ?? "";
|
|
133
|
+
}
|
|
134
|
+
export function sideOf(batch, i) {
|
|
135
|
+
if (batch.side[i] === Side.event)
|
|
136
|
+
throw new RangeError(`Row ${i} is a pool event (withEvents), not a trade`);
|
|
137
|
+
return batch.side[i] === Side.sell ? "sell" : "buy";
|
|
138
|
+
}
|
|
139
|
+
export function protocolOf(batch, i) {
|
|
140
|
+
return batch.protocol[i] === Protocol.pumpswap ? "pumpswap" : "pumpfun";
|
|
141
|
+
}
|
|
142
|
+
export function compareChainOrder(a, i, b, j) {
|
|
143
|
+
const x = a.order, y = b.order;
|
|
144
|
+
if (!x || !y)
|
|
145
|
+
throw new RangeError("compareChainOrder needs batches read withOrder: true");
|
|
146
|
+
return ((a.slot[i] ?? 0) - (b.slot[j] ?? 0) ||
|
|
147
|
+
(x.txIndex[i] ?? NO_TX_INDEX) - (y.txIndex[j] ?? NO_TX_INDEX) ||
|
|
148
|
+
(x.eventIndex[i] ?? NO_EVENT_INDEX) - (y.eventIndex[j] ?? NO_EVENT_INDEX) ||
|
|
149
|
+
a.hour - b.hour ||
|
|
150
|
+
(a.line[i] ?? 0) - (b.line[j] ?? 0) ||
|
|
151
|
+
(a.op[i] ?? 0) - (b.op[j] ?? 0));
|
|
152
|
+
}
|
|
153
|
+
export function hourStartMs(hour) {
|
|
154
|
+
return hour * HOUR_MS;
|
|
155
|
+
}
|
|
156
|
+
const orNull = (value) => value === undefined || Number.isNaN(value) ? null : value;
|
|
157
|
+
export function tradeAt(batch, i) {
|
|
158
|
+
if (!Number.isInteger(i) || i < 0 || i >= batch.length)
|
|
159
|
+
throw new RangeError(`Row ${i} out of range (length ${batch.length})`);
|
|
160
|
+
return {
|
|
161
|
+
id: tradeId(batch, i),
|
|
162
|
+
hour: batch.hour,
|
|
163
|
+
line: batch.line[i],
|
|
164
|
+
op: batch.op[i],
|
|
165
|
+
slot: batch.slot[i],
|
|
166
|
+
timeMs: batch.timeMs[i],
|
|
167
|
+
receivedMs: orNull(batch.receivedMs[i]),
|
|
168
|
+
side: sideOf(batch, i),
|
|
169
|
+
protocol: protocolOf(batch, i),
|
|
170
|
+
mint: mintOf(batch, i),
|
|
171
|
+
pool: poolOf(batch, i),
|
|
172
|
+
trader: traderOf(batch, i),
|
|
173
|
+
quoteMint: quoteMintOf(batch, i),
|
|
174
|
+
tokenRaw: batch.tokenRaw[i],
|
|
175
|
+
tokenDecimals: batch.tokenDecimals[i],
|
|
176
|
+
quoteRaw: batch.quoteRaw[i],
|
|
177
|
+
quoteDecimals: batch.quoteDecimals[i],
|
|
178
|
+
tokenAmount: batch.tokenAmount[i],
|
|
179
|
+
quoteAmount: batch.quoteAmount[i],
|
|
180
|
+
price: orNull(batch.price[i]),
|
|
181
|
+
priceSol: orNull(batch.priceSol[i]),
|
|
182
|
+
priceUsd: orNull(batch.priceUsd[i]),
|
|
183
|
+
quotePriceUsd: orNull(batch.quotePriceUsd[i]),
|
|
184
|
+
usdValue: orNull(batch.usdValue[i]),
|
|
185
|
+
signature: batch.signatures?.[i] ?? null,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export const STORAGE_STATUS_BY_CODE = {
|
|
189
|
+
blocked_type: 415,
|
|
190
|
+
forbidden: 403,
|
|
191
|
+
invalid_request: 400,
|
|
192
|
+
not_found: 404,
|
|
193
|
+
quota_exceeded: 413,
|
|
194
|
+
too_large: 413,
|
|
195
|
+
unavailable: 503,
|
|
196
|
+
already_exists: 409,
|
|
197
|
+
value_changed: 409,
|
|
198
|
+
io_limit: 429,
|
|
199
|
+
upload_limit: 429,
|
|
200
|
+
precondition_failed: 412,
|
|
201
|
+
};
|
|
202
|
+
export function isSdkError(error, code) {
|
|
203
|
+
if (!(error instanceof Error))
|
|
204
|
+
return false;
|
|
205
|
+
const actual = error.code;
|
|
206
|
+
return typeof actual === "string" && (code === undefined || actual === code);
|
|
207
|
+
}
|
|
208
|
+
function isRecord(value) {
|
|
209
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
210
|
+
}
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Stream } from "effect";
|
|
2
|
+
import { ExecutionEvent, Gap } from "./contracts.js";
|
|
3
|
+
import { DecodeError, NetworkError } from "./errors.js";
|
|
4
|
+
export { ExecutionEvent, Gap } from "./contracts.js";
|
|
5
|
+
export type FunctionsMessage = {
|
|
6
|
+
readonly _tag: "Execution";
|
|
7
|
+
readonly event: ExecutionEvent;
|
|
8
|
+
} | {
|
|
9
|
+
readonly _tag: "Gap";
|
|
10
|
+
readonly gap: Gap;
|
|
11
|
+
};
|
|
12
|
+
export type StreamError = DecodeError | NetworkError;
|
|
13
|
+
export interface StreamOptions {
|
|
14
|
+
readonly url: string;
|
|
15
|
+
readonly apiKey: string;
|
|
16
|
+
/** Resume after this sequence number. */
|
|
17
|
+
readonly afterSeq?: number;
|
|
18
|
+
/**
|
|
19
|
+
* How long the server must keep refusing the key, with no successful join
|
|
20
|
+
* in between, before the stream ends with `invalid_key` (or `unauthorized`).
|
|
21
|
+
* Default 360 000 ms (6 min). See {@link executionEvents}: below the
|
|
22
|
+
* server's longest key sync delay, a key created just before the stream
|
|
23
|
+
* started could end it.
|
|
24
|
+
*/
|
|
25
|
+
readonly keyRefusalWindowMs?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Live execution events of the account. Gaps are emitted as values.
|
|
29
|
+
*
|
|
30
|
+
* The stream reconnects by itself after a dropped or closed connection (no
|
|
31
|
+
* network, a server restart or deploy, a connection the server closed) and
|
|
32
|
+
* resumes after the last event. It fails, once, with:
|
|
33
|
+
* - a {@link DecodeError} on an undecodable payload;
|
|
34
|
+
* - a {@link NetworkError} when the server refuses the join, with the
|
|
35
|
+
* server's `reason` (`functions_disabled`, `functions_unavailable`);
|
|
36
|
+
* - a {@link NetworkError} whose `reason` is `functions_disabled` (Functions
|
|
37
|
+
* access removed) or `channel_closed` (the server closed the channel:
|
|
38
|
+
* subscribe again with `afterSeq`);
|
|
39
|
+
* - a {@link NetworkError} whose `reason` is `invalid_key` (the key was
|
|
40
|
+
* revoked, replaced or is unknown, or its account is not active) or
|
|
41
|
+
* `unauthorized` (the same, seen at the join): the server kept refusing
|
|
42
|
+
* the key for {@link StreamOptions.keyRefusalWindowMs} (default 6 min)
|
|
43
|
+
* with no successful join in between. A single refusal never ends the
|
|
44
|
+
* stream: a key created or changed less than a key sync ago, or a server
|
|
45
|
+
* whose key data is briefly behind, is refused too; the stream retries
|
|
46
|
+
* (every 5 s at most) and goes on as soon as a join succeeds. A refusal
|
|
47
|
+
* that the server does not name (a server starting, a network failure,
|
|
48
|
+
* the connection limit) is retried with no time limit;
|
|
49
|
+
* - a {@link NetworkError} whose `reason` is `account_changed`: a
|
|
50
|
+
* reconnection joined another account than the first join (the key was
|
|
51
|
+
* moved to another account). No event of the other account is delivered.
|
|
52
|
+
* This check needs a server that names the account in its join reply: a
|
|
53
|
+
* join answered without `account` (an older server, e.g. during a rollout
|
|
54
|
+
* or rollback) is accepted unchecked.
|
|
55
|
+
*
|
|
56
|
+
* A transient `status` `{available: false}` does not end it. An older
|
|
57
|
+
* server that does not say why it refused a connection gives no
|
|
58
|
+
* `invalid_key`: the stream then keeps reconnecting, as before. The socket
|
|
59
|
+
* is closed and no reconnection happens once the stream is interrupted or
|
|
60
|
+
* has failed.
|
|
61
|
+
*/
|
|
62
|
+
export declare const executionEvents: (options: StreamOptions) => Stream.Stream<FunctionsMessage, StreamError>;
|
|
63
|
+
/**
|
|
64
|
+
* Callback API kept for existing users; errors are tagged `StreamError`s or
|
|
65
|
+
* raw socket errors (transient: it reconnects by itself). While the server
|
|
66
|
+
* refuses the key (see {@link executionEvents}) nothing is reported: it
|
|
67
|
+
* retries silently, and reports once if the refusal lasts the whole window.
|
|
68
|
+
* After a `NetworkError` (`functions_disabled`, `functions_unavailable`,
|
|
69
|
+
* `channel_closed`, `invalid_key`, `unauthorized`, `account_changed`) the
|
|
70
|
+
* socket is closed and nothing more arrives.
|
|
71
|
+
*/
|
|
72
|
+
export declare function subscribeFunctions(options: {
|
|
73
|
+
url: string;
|
|
74
|
+
apiKey: string;
|
|
75
|
+
afterSeq?: number;
|
|
76
|
+
/** See {@link StreamOptions.keyRefusalWindowMs}. */
|
|
77
|
+
keyRefusalWindowMs?: number;
|
|
78
|
+
onEvent: (event: ExecutionEvent) => void;
|
|
79
|
+
onGap?: (gap: Gap) => void;
|
|
80
|
+
onError?: (error: unknown) => void;
|
|
81
|
+
}): {
|
|
82
|
+
close(): void;
|
|
83
|
+
readonly cursor: number | undefined;
|
|
84
|
+
};
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Socket } from "phoenix";
|
|
2
|
+
import { Cause, Effect, Queue, Result, Stream } from "effect";
|
|
3
|
+
import { ExecutionEvent, Gap, decodeWith, } from "./contracts.js";
|
|
4
|
+
import { DecodeError, NetworkError } from "./errors.js";
|
|
5
|
+
export { ExecutionEvent, Gap } from "./contracts.js";
|
|
6
|
+
const decoder = (schema, what) => decodeWith(schema, undefined, (message) => new DecodeError({ what, message }));
|
|
7
|
+
const decodeEvent = decoder(ExecutionEvent, "execution event");
|
|
8
|
+
const decodeGap = decoder(Gap, "stream gap");
|
|
9
|
+
const refusedKey = 4401;
|
|
10
|
+
const defaultKeyRefusalWindowMs = 360_000;
|
|
11
|
+
const reconnectDelay = (closes) => Math.min(5_000, 500 * 2 ** closes) * (0.5 + Math.random() / 2);
|
|
12
|
+
function connect(options, handlers) {
|
|
13
|
+
let cursor = options.afterSeq;
|
|
14
|
+
const window = options.keyRefusalWindowMs ?? defaultKeyRefusalWindowMs;
|
|
15
|
+
const endpoint = new URL("/v1/stream", options.url);
|
|
16
|
+
endpoint.protocol =
|
|
17
|
+
endpoint.protocol === "https:" || endpoint.protocol === "wss:"
|
|
18
|
+
? "wss:"
|
|
19
|
+
: "ws:";
|
|
20
|
+
const socket = new Socket(endpoint.toString(), {
|
|
21
|
+
params: { api_key: options.apiKey, on_refusal: "close" },
|
|
22
|
+
transport: WebSocket,
|
|
23
|
+
});
|
|
24
|
+
const channel = socket.channel("functions", () => cursor === undefined ? {} : { after_seq: cursor });
|
|
25
|
+
let account;
|
|
26
|
+
let checked = false;
|
|
27
|
+
let held = [];
|
|
28
|
+
const gated = (handle) => (raw) => {
|
|
29
|
+
if (closed)
|
|
30
|
+
return;
|
|
31
|
+
if (!checked)
|
|
32
|
+
return void held.push(() => handle(raw));
|
|
33
|
+
handle(raw);
|
|
34
|
+
};
|
|
35
|
+
channel.on("execution", gated((raw) => {
|
|
36
|
+
const result = decodeEvent(raw);
|
|
37
|
+
if (Result.isFailure(result))
|
|
38
|
+
return handlers.fail(result.failure);
|
|
39
|
+
const event = result.success;
|
|
40
|
+
if (cursor !== undefined && event.seq <= cursor)
|
|
41
|
+
return;
|
|
42
|
+
cursor = event.seq;
|
|
43
|
+
handlers.message({ _tag: "Execution", event });
|
|
44
|
+
}));
|
|
45
|
+
channel.on("gap", gated((raw) => {
|
|
46
|
+
const result = decodeGap(raw);
|
|
47
|
+
if (Result.isFailure(result))
|
|
48
|
+
return handlers.fail(result.failure);
|
|
49
|
+
cursor = result.success.resumeAfter;
|
|
50
|
+
handlers.message({ _tag: "Gap", gap: result.success });
|
|
51
|
+
}));
|
|
52
|
+
let closed = false;
|
|
53
|
+
let timer;
|
|
54
|
+
const stop = () => {
|
|
55
|
+
closed = true;
|
|
56
|
+
held = [];
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
channel.leave();
|
|
59
|
+
socket.disconnect();
|
|
60
|
+
};
|
|
61
|
+
const fail = (message, cause, reason) => {
|
|
62
|
+
if (closed)
|
|
63
|
+
return;
|
|
64
|
+
stop();
|
|
65
|
+
handlers.fail(new NetworkError({
|
|
66
|
+
url: endpoint.toString(),
|
|
67
|
+
message,
|
|
68
|
+
cause,
|
|
69
|
+
...(reason === undefined ? {} : { reason }),
|
|
70
|
+
}));
|
|
71
|
+
};
|
|
72
|
+
channel.on("status", gated((raw) => {
|
|
73
|
+
const status = raw;
|
|
74
|
+
if (status?.available === false && status.reason === "functions_disabled")
|
|
75
|
+
fail("Functions access was removed from this account: the server closed the stream", raw, "functions_disabled");
|
|
76
|
+
}));
|
|
77
|
+
channel.onClose((cause) => fail(`The server closed the stream: subscribe again with afterSeq ${cursor ?? "(unset)"}`, cause, "channel_closed"));
|
|
78
|
+
let closes = 0;
|
|
79
|
+
let pending = false;
|
|
80
|
+
const reconnect = () => {
|
|
81
|
+
if (pending)
|
|
82
|
+
return;
|
|
83
|
+
pending = true;
|
|
84
|
+
socket.disconnect();
|
|
85
|
+
timer = setTimeout(() => {
|
|
86
|
+
pending = false;
|
|
87
|
+
socket.connect();
|
|
88
|
+
}, reconnectDelay(closes++));
|
|
89
|
+
};
|
|
90
|
+
let refusedSince;
|
|
91
|
+
const refused = (reason, message, cause) => {
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
refusedSince ??= now;
|
|
94
|
+
if (now - refusedSince >= window)
|
|
95
|
+
return fail(message, cause, reason);
|
|
96
|
+
reconnect();
|
|
97
|
+
};
|
|
98
|
+
socket.onClose((event) => {
|
|
99
|
+
if (closed || pending)
|
|
100
|
+
return;
|
|
101
|
+
if (event?.code === refusedKey)
|
|
102
|
+
return refused("invalid_key", `The API key is no longer valid (revoked, unknown, or its account is not active): the server refused it for ${Math.round(window / 1000)} s`, event);
|
|
103
|
+
if (event?.code === 1000)
|
|
104
|
+
reconnect();
|
|
105
|
+
});
|
|
106
|
+
const transient = (error) => queueMicrotask(() => {
|
|
107
|
+
if (!closed && refusedSince === undefined)
|
|
108
|
+
handlers.transient(error);
|
|
109
|
+
});
|
|
110
|
+
socket.onError(transient);
|
|
111
|
+
channel.onError((error) => {
|
|
112
|
+
checked = false;
|
|
113
|
+
transient(error);
|
|
114
|
+
});
|
|
115
|
+
socket.connect();
|
|
116
|
+
channel
|
|
117
|
+
.join()
|
|
118
|
+
.receive("ok", (reply) => {
|
|
119
|
+
if (closed)
|
|
120
|
+
return;
|
|
121
|
+
const named = reply?.account;
|
|
122
|
+
if (typeof named === "string") {
|
|
123
|
+
account ??= named;
|
|
124
|
+
if (named !== account)
|
|
125
|
+
return fail("The API key now belongs to another account: the stream of the previous account ended (its events are not followed on the new one)", reply, "account_changed");
|
|
126
|
+
}
|
|
127
|
+
checked = true;
|
|
128
|
+
closes = 0;
|
|
129
|
+
refusedSince = undefined;
|
|
130
|
+
const waiting = held;
|
|
131
|
+
held = [];
|
|
132
|
+
for (const handle of waiting)
|
|
133
|
+
if (!closed)
|
|
134
|
+
handle();
|
|
135
|
+
})
|
|
136
|
+
.receive("error", (reason) => {
|
|
137
|
+
if (closed)
|
|
138
|
+
return;
|
|
139
|
+
const code = reason?.reason;
|
|
140
|
+
const message = `Stream join refused: ${JSON.stringify(reason)}`;
|
|
141
|
+
if (code === "unauthorized")
|
|
142
|
+
return refused(code, message, reason);
|
|
143
|
+
fail(message, reason, typeof code === "string" ? code : undefined);
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
close() {
|
|
147
|
+
stop();
|
|
148
|
+
},
|
|
149
|
+
get cursor() {
|
|
150
|
+
return cursor;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
export const executionEvents = (options) => Stream.callback((queue) => Effect.acquireRelease(Effect.sync(() => connect(options, {
|
|
155
|
+
message: (message) => void Queue.offerUnsafe(queue, message),
|
|
156
|
+
fail: (error) => void Queue.failCauseUnsafe(queue, Cause.fail(error)),
|
|
157
|
+
transient: () => { },
|
|
158
|
+
})), (connection) => Effect.sync(() => connection.close())));
|
|
159
|
+
export function subscribeFunctions(options) {
|
|
160
|
+
return connect(options, {
|
|
161
|
+
message: (message) => message._tag === "Execution"
|
|
162
|
+
? options.onEvent(message.event)
|
|
163
|
+
: options.onGap?.(message.gap),
|
|
164
|
+
fail: (error) => options.onError?.(error),
|
|
165
|
+
transient: (error) => options.onError?.(error),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** What a webhook delivers (the JSON body). */
|
|
2
|
+
export interface WebhookMessage {
|
|
3
|
+
/** Unique per message, the same on every delivery attempt: dedup on it. */
|
|
4
|
+
id: string;
|
|
5
|
+
/** Per account, 1, 2, 3… without holes; a hole is a message that failed for good. */
|
|
6
|
+
seq: number;
|
|
7
|
+
/** `send` (`ctx.webhook.send`), `execution.failed`, `execution.limit` or `test`. */
|
|
8
|
+
type: "send" | "execution.failed" | "execution.limit" | "test";
|
|
9
|
+
createdAt: number;
|
|
10
|
+
/** The payload of `ctx.webhook.send`, or `{ error }` for a failure. */
|
|
11
|
+
data: unknown;
|
|
12
|
+
/** Execution that produced it (absent for `test`). */
|
|
13
|
+
executionId?: string;
|
|
14
|
+
/** Attempt of that execution: a retried execution sends again with a higher one. */
|
|
15
|
+
attempt?: number;
|
|
16
|
+
/** Rank of the `send` call in that attempt (0, 1, …). */
|
|
17
|
+
index?: number;
|
|
18
|
+
program?: string;
|
|
19
|
+
function?: string;
|
|
20
|
+
workflow?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface VerifyOptions {
|
|
23
|
+
/** Maximum age (and clock skew) of the signature, in seconds (default 300). */
|
|
24
|
+
toleranceSec?: number;
|
|
25
|
+
/** Current time in ms (tests). */
|
|
26
|
+
now?: number;
|
|
27
|
+
}
|
|
28
|
+
export declare class WebhookVerificationError extends Error {
|
|
29
|
+
readonly code: "missing_signature" | "malformed_signature" | "timestamp_out_of_tolerance" | "invalid_signature" | "invalid_body";
|
|
30
|
+
constructor(code: WebhookVerificationError["code"], message: string);
|
|
31
|
+
}
|
|
32
|
+
type HeaderBag = Headers | Record<string, string | string[] | undefined> | Map<string, string>;
|
|
33
|
+
/**
|
|
34
|
+
* Checks `x-zyrk-signature` (`t=<unix>,v1=<hex>[,v1=<hex>]`, HMAC-SHA256 of
|
|
35
|
+
* `t + "." + body` with your secret) and returns the parsed message. Pass
|
|
36
|
+
* the **raw** body, exactly as received. During a secret rotation the
|
|
37
|
+
* header carries one `v1` per valid secret: either of your secrets passes.
|
|
38
|
+
* Pass an array to accept several secrets on your side too.
|
|
39
|
+
*
|
|
40
|
+
* Throws {@link WebhookVerificationError}.
|
|
41
|
+
*/
|
|
42
|
+
export declare const verifyWebhook: (secret: string | readonly string[], headers: HeaderBag, body: string | Uint8Array, options?: VerifyOptions) => WebhookMessage;
|
|
43
|
+
export {};
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export class WebhookVerificationError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "WebhookVerificationError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const header = (headers, name) => {
|
|
11
|
+
if (typeof headers.get === "function") {
|
|
12
|
+
const v = headers.get(name);
|
|
13
|
+
return v ?? undefined;
|
|
14
|
+
}
|
|
15
|
+
const record = headers;
|
|
16
|
+
const key = Object.keys(record).find((k) => k.toLowerCase() === name);
|
|
17
|
+
const v = key === undefined ? undefined : record[key];
|
|
18
|
+
return Array.isArray(v) ? v[0] : v;
|
|
19
|
+
};
|
|
20
|
+
export const verifyWebhook = (secret, headers, body, options = {}) => {
|
|
21
|
+
const text = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
22
|
+
const raw = header(headers, "x-zyrk-signature");
|
|
23
|
+
if (!raw)
|
|
24
|
+
throw new WebhookVerificationError("missing_signature", "No x-zyrk-signature header");
|
|
25
|
+
let timestamp;
|
|
26
|
+
const signatures = [];
|
|
27
|
+
for (const part of raw.split(",")) {
|
|
28
|
+
const [k, v] = part.trim().split("=", 2);
|
|
29
|
+
if (k === "t" && v !== undefined && /^\d+$/.test(v))
|
|
30
|
+
timestamp = Number(v);
|
|
31
|
+
else if (k === "v1" && v !== undefined && /^[0-9a-f]{64}$/.test(v))
|
|
32
|
+
signatures.push(v);
|
|
33
|
+
}
|
|
34
|
+
if (timestamp === undefined || signatures.length === 0)
|
|
35
|
+
throw new WebhookVerificationError("malformed_signature", "Expected t=<unix>,v1=<hex>");
|
|
36
|
+
const tolerance = options.toleranceSec ?? 300;
|
|
37
|
+
const now = Math.floor((options.now ?? Date.now()) / 1000);
|
|
38
|
+
if (Math.abs(now - timestamp) > tolerance)
|
|
39
|
+
throw new WebhookVerificationError("timestamp_out_of_tolerance", `Signature timestamp is more than ${tolerance} s away from now`);
|
|
40
|
+
const secrets = typeof secret === "string" ? [secret] : secret;
|
|
41
|
+
const signed = `${timestamp}.${text}`;
|
|
42
|
+
const ok = secrets.some((s) => {
|
|
43
|
+
const expected = createHmac("sha256", s).update(signed).digest();
|
|
44
|
+
return signatures.some((candidate) => timingSafeEqual(expected, Buffer.from(candidate, "hex")));
|
|
45
|
+
});
|
|
46
|
+
if (!ok)
|
|
47
|
+
throw new WebhookVerificationError("invalid_signature", "No signature matches the secret");
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(text);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new WebhookVerificationError("invalid_body", "Body is not JSON");
|
|
53
|
+
}
|
|
54
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johannc-dev/zyrk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "SDK and CLI for Zyrk hosted functions",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public",
|
|
8
|
+
"provenance": false
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"types": "./dist/sdk.d.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/*.js",
|
|
14
|
+
"dist/*.d.ts",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./dist/sdk.js",
|
|
19
|
+
"./client": "./dist/client.js",
|
|
20
|
+
"./stream": "./dist/stream.js",
|
|
21
|
+
"./contracts": "./dist/contracts.js",
|
|
22
|
+
"./errors": "./dist/errors.js",
|
|
23
|
+
"./build": "./dist/build.js",
|
|
24
|
+
"./limits": "./dist/limits.js",
|
|
25
|
+
"./local": "./dist/local.js",
|
|
26
|
+
"./export": "./dist/export.js",
|
|
27
|
+
"./hourfile": "./dist/hourfile.js",
|
|
28
|
+
"./webhook": "./dist/webhook.js",
|
|
29
|
+
"./globals": {
|
|
30
|
+
"types": "./dist/globals.d.ts"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"bin": {
|
|
34
|
+
"zyrk": "./dist/cli.js"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=24"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "node scripts/build.mjs",
|
|
41
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
|
42
|
+
"test": "npm run build && npm run typecheck && node --test --test-timeout=60000 --test-force-exit test/*.test.ts",
|
|
43
|
+
"format": "prettier --write src types scripts test examples package.json tsconfig.json tsconfig.test.json README.md",
|
|
44
|
+
"format:check": "prettier --check src types scripts test examples package.json tsconfig.json tsconfig.test.json README.md",
|
|
45
|
+
"prepack": "npm run build"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"effect": "4.1.2",
|
|
49
|
+
"esbuild": "0.28.2",
|
|
50
|
+
"phoenix": "1.7.24",
|
|
51
|
+
"typescript": "5.9.2"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "24.10.0",
|
|
55
|
+
"@types/phoenix": "1.6.8",
|
|
56
|
+
"prettier": "3.6.2"
|
|
57
|
+
}
|
|
58
|
+
}
|