@effectstream/sync 0.200.5 → 0.200.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ import { NtpTimeSync } from "ntp-time-sync";
2
+
3
+ const DEFAULT_TIMEOUT_MS = 15_000;
4
+ // setTimeout silently clamps larger delays to 1ms, so reject them up front.
5
+ const MAX_TIMEOUT_MS = 2_147_483_647;
6
+
7
+ export type NtpTipErrorCode =
8
+ | "INVALID_OPTIONS"
9
+ | "ABORTED"
10
+ | "TIMEOUT"
11
+ | "NETWORK"
12
+ | "INVALID_TIME";
13
+
14
+ export type GetNtpTipOptions = {
15
+ startTime: number;
16
+ blockTimeMS: number;
17
+ servers?: readonly string[];
18
+ requestTimeoutMs?: number;
19
+ signal?: AbortSignal;
20
+ };
21
+
22
+ export type NtpTip = { height: number };
23
+
24
+ export class NtpTipError extends Error {
25
+ readonly code: NtpTipErrorCode;
26
+ readonly timeoutMs?: number;
27
+ override readonly cause?: unknown;
28
+
29
+ constructor(
30
+ code: NtpTipErrorCode,
31
+ message: string,
32
+ details: { cause?: unknown; timeoutMs?: number } = {},
33
+ ) {
34
+ super(message);
35
+ this.name = "NtpTipError";
36
+ this.code = code;
37
+ this.cause = details.cause;
38
+ this.timeoutMs = details.timeoutMs;
39
+ }
40
+ }
41
+
42
+ function invalidOptions(message: string): NtpTipError {
43
+ return new NtpTipError("INVALID_OPTIONS", message);
44
+ }
45
+
46
+ function abortedError(reason: unknown): NtpTipError {
47
+ return new NtpTipError("ABORTED", "NTP tip request was aborted", {
48
+ cause: reason,
49
+ });
50
+ }
51
+
52
+ function validateOptions(options: GetNtpTipOptions): {
53
+ startTime: number;
54
+ blockTimeMS: number;
55
+ servers: readonly string[];
56
+ requestTimeoutMs: number;
57
+ signal?: AbortSignal;
58
+ } {
59
+ if (!options || typeof options !== "object") {
60
+ throw invalidOptions("NTP tip options are required");
61
+ }
62
+ if (!Number.isSafeInteger(options.startTime)) {
63
+ throw invalidOptions("NTP startTime must be a finite safe integer");
64
+ }
65
+ if (!Number.isSafeInteger(options.blockTimeMS) || options.blockTimeMS <= 0) {
66
+ throw invalidOptions("NTP blockTimeMS must be a positive safe integer");
67
+ }
68
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS;
69
+ if (
70
+ !Number.isSafeInteger(requestTimeoutMs) ||
71
+ requestTimeoutMs <= 0 ||
72
+ requestTimeoutMs > MAX_TIMEOUT_MS
73
+ ) {
74
+ throw invalidOptions(
75
+ `NTP requestTimeoutMs must be a positive safe integer at most ${MAX_TIMEOUT_MS}`,
76
+ );
77
+ }
78
+ if (
79
+ options.servers !== undefined &&
80
+ (!Array.isArray(options.servers) ||
81
+ options.servers.some(
82
+ (server) =>
83
+ typeof server !== "string" ||
84
+ server.length === 0 ||
85
+ server !== server.trim(),
86
+ ))
87
+ ) {
88
+ throw invalidOptions(
89
+ "NTP servers must be non-empty strings without surrounding whitespace",
90
+ );
91
+ }
92
+ if (
93
+ options.signal !== undefined &&
94
+ (typeof options.signal !== "object" ||
95
+ options.signal === null ||
96
+ typeof options.signal.addEventListener !== "function" ||
97
+ typeof options.signal.removeEventListener !== "function")
98
+ ) {
99
+ throw invalidOptions("NTP signal must be an AbortSignal");
100
+ }
101
+ return {
102
+ startTime: options.startTime,
103
+ blockTimeMS: options.blockTimeMS,
104
+ servers: options.servers ?? [],
105
+ requestTimeoutMs,
106
+ signal: options.signal,
107
+ };
108
+ }
109
+
110
+ /** Resolve one inclusive page from a bounded, one-shot NTP network sample. */
111
+ export async function getNtpTip(options: GetNtpTipOptions): Promise<NtpTip> {
112
+ const { startTime, blockTimeMS, servers, requestTimeoutMs, signal } =
113
+ validateOptions(options);
114
+ if (signal?.aborted) throw abortedError(signal.reason);
115
+
116
+ // One client per call keeps the server list and offset cache instance-owned:
117
+ // concurrent tips can never observe each other's configuration. sampleCount 1
118
+ // queries every selected server concurrently and settles on the first round
119
+ // with a validated response; replyTimeout bounds the client's own socket wait
120
+ // to the same deadline as the operation.
121
+ const client = new NtpTimeSync({
122
+ sampleCount: 1,
123
+ replyTimeout: requestTimeoutMs,
124
+ ...(servers.length ? { servers: [...servers] } : {}),
125
+ });
126
+
127
+ let timer: ReturnType<typeof setTimeout> | undefined;
128
+ let onAbort: (() => void) | undefined;
129
+ const cancelled = new Promise<never>((_, reject) => {
130
+ timer = setTimeout(
131
+ () =>
132
+ reject(
133
+ new NtpTipError(
134
+ "TIMEOUT",
135
+ `NTP tip request timed out after ${requestTimeoutMs}ms`,
136
+ { timeoutMs: requestTimeoutMs },
137
+ ),
138
+ ),
139
+ requestTimeoutMs,
140
+ );
141
+ onAbort = () => reject(abortedError(signal?.reason));
142
+ signal?.addEventListener("abort", onAbort, { once: true });
143
+ });
144
+
145
+ let sampled: { now: Date };
146
+ try {
147
+ sampled = await Promise.race([
148
+ client.getTime(true).catch((cause) => {
149
+ throw new NtpTipError("NETWORK", "NTP sampling failed", { cause });
150
+ }),
151
+ cancelled,
152
+ ]);
153
+ } finally {
154
+ clearTimeout(timer);
155
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
156
+ }
157
+
158
+ const timestamp = sampled.now.getTime();
159
+ if (!Number.isFinite(timestamp)) {
160
+ throw new NtpTipError("INVALID_TIME", "NTP returned an invalid timestamp");
161
+ }
162
+ const height = Math.floor((timestamp - startTime) / blockTimeMS);
163
+ if (!Number.isSafeInteger(height) || height < 0) {
164
+ throw new NtpTipError(
165
+ "INVALID_TIME",
166
+ "NTP timestamp is before startTime or produces an unsafe height",
167
+ );
168
+ }
169
+ return { height };
170
+ }
@@ -15,4 +15,8 @@ test("README: per-chain fetchers and states are exported", () => {
15
15
  expect("AvailFetcher" in sync).toBe(true);
16
16
  expect("UtxoRpcFetcher" in sync).toBe(true);
17
17
  expect("CelestiaFetcher" in sync).toBe(true);
18
+ expect(typeof sync.getMidnightTip).toBe("function");
19
+ expect(typeof sync.MidnightTipError).toBe("function");
20
+ expect(typeof sync.getNtpTip).toBe("function");
21
+ expect(typeof sync.NtpTipError).toBe("function");
18
22
  });