@astronomer-app/signals 0.0.0

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 ADDED
@@ -0,0 +1,55 @@
1
+ # @astronomer-app/signals
2
+
3
+ TypeScript client for Astronomer Signal Stream.
4
+
5
+ Signal Stream delivers live signal events while a listener is running. The SDK
6
+ polls a short-lived JSON endpoint with a cursor, so it works reliably on
7
+ serverless hosting while still exposing a simple `listen()` API. A new process
8
+ starts from the latest signal cursor and does not replay historical events.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @astronomer-app/signals
14
+ ```
15
+
16
+ ## Listen For Signals
17
+
18
+ ```ts
19
+ import { AstronomerSignals } from '@astronomer-app/signals';
20
+
21
+ const client = new AstronomerSignals({
22
+ apiKey: process.env.ASTRONOMER_SIGNAL_STREAM_KEY!,
23
+ });
24
+
25
+ const listener = client.signals.listen({
26
+ onSignal(signal) {
27
+ console.log('New signal:', signal);
28
+ },
29
+ onError(error) {
30
+ console.error(error);
31
+ },
32
+ });
33
+
34
+ // later
35
+ await listener.close();
36
+ ```
37
+
38
+ Signal Stream is active during the live signal window from 9:30 AM to 3:00 PM
39
+ Eastern Time, Monday through Friday.
40
+
41
+ ## Local Example
42
+
43
+ After building the package, run the listener example with a Signal Stream key:
44
+
45
+ ```bash
46
+ ASTRONOMER_SIGNAL_STREAM_KEY=ast_live_... npm --workspace @astronomer-app/signals run example:listen
47
+ ```
48
+
49
+ For local app testing, pass a custom base URL:
50
+
51
+ ```bash
52
+ ASTRONOMER_SIGNAL_STREAM_KEY=ast_live_... \
53
+ ASTRONOMER_SIGNAL_STREAM_BASE_URL=http://localhost:4200 \
54
+ npm --workspace @astronomer-app/signals run example:listen
55
+ ```
@@ -0,0 +1,12 @@
1
+ import { AstronomerSignalsOptions, ListenOptions, SignalListener } from './types.js';
2
+ export declare class AstronomerSignals {
3
+ private readonly apiKey;
4
+ private readonly baseUrl;
5
+ private readonly fetchImpl;
6
+ constructor(options: AstronomerSignalsOptions);
7
+ signals: {
8
+ listen: (options: ListenOptions) => SignalListener;
9
+ };
10
+ private listenToSignals;
11
+ }
12
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,wBAAwB,EACxB,aAAa,EAEb,cAAc,EACf,MAAM,YAAY,CAAC;AA4BpB,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;gBAE7B,OAAO,EAAE,wBAAwB;IAc7C,OAAO;0BACa,aAAa,KAAG,cAAc;MAEhD;IAEF,OAAO,CAAC,eAAe;CAkCxB"}
package/dist/client.js ADDED
@@ -0,0 +1,217 @@
1
+ const DEFAULT_BASE_URL = 'https://www.astronomerapp.com';
2
+ const DEFAULT_POLL_INTERVAL_MS = 15000;
3
+ const MAX_RECONNECT_DELAY_MS = 30000;
4
+ const MIN_RECONNECT_DELAY_MS = 1000;
5
+ const STREAM_PATH = '/api/v1/signals/stream';
6
+ class SignalStreamRequestError extends Error {
7
+ constructor(message, retryAfterMs) {
8
+ super(message);
9
+ this.name = 'SignalStreamRequestError';
10
+ this.retryAfterMs = retryAfterMs;
11
+ }
12
+ }
13
+ export class AstronomerSignals {
14
+ constructor(options) {
15
+ this.signals = {
16
+ listen: (options) => this.listenToSignals(options),
17
+ };
18
+ if (!options.apiKey) {
19
+ throw new Error('AstronomerSignals requires an apiKey');
20
+ }
21
+ this.apiKey = options.apiKey;
22
+ this.baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
23
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
24
+ if (!this.fetchImpl) {
25
+ throw new Error('AstronomerSignals requires a fetch implementation');
26
+ }
27
+ }
28
+ listenToSignals(options) {
29
+ const controller = new AbortController();
30
+ const abortSignals = [controller.signal, options.signal].filter(Boolean);
31
+ let closed = false;
32
+ let closePromise = null;
33
+ const close = async () => {
34
+ if (closed)
35
+ return;
36
+ closed = true;
37
+ controller.abort();
38
+ };
39
+ closePromise = runSignalPoller({
40
+ apiKey: this.apiKey,
41
+ baseUrl: this.baseUrl,
42
+ fetchImpl: this.fetchImpl,
43
+ isClosed: () => closed,
44
+ options,
45
+ signal: mergeAbortSignals(abortSignals),
46
+ }).finally(() => {
47
+ closed = true;
48
+ options.onClose?.();
49
+ });
50
+ return {
51
+ async close() {
52
+ await close();
53
+ await closePromise;
54
+ },
55
+ };
56
+ }
57
+ }
58
+ async function runSignalPoller({ apiKey, baseUrl, fetchImpl, isClosed, options, signal, }) {
59
+ // The first request asks the API for the latest queue cursor without emitting
60
+ // historical rows. After that, each request asks for events after the cursor.
61
+ let cursor = 'latest';
62
+ let hasOpened = false;
63
+ let reconnectAttempt = 0;
64
+ while (!isClosed() && !signal.aborted) {
65
+ try {
66
+ const response = await pollOnce({
67
+ apiKey,
68
+ baseUrl,
69
+ cursor,
70
+ fetchImpl,
71
+ signal,
72
+ });
73
+ if (!hasOpened) {
74
+ hasOpened = true;
75
+ options.onOpen?.();
76
+ }
77
+ for (const event of response.events) {
78
+ options.onSignal(event);
79
+ }
80
+ cursor = response.nextCursor ?? cursor;
81
+ reconnectAttempt = 0;
82
+ await delay(getNextPollDelay(response, options), signal);
83
+ }
84
+ catch (error) {
85
+ if (isClosed() || signal.aborted) {
86
+ return;
87
+ }
88
+ options.onError?.(error);
89
+ await delay(getErrorRetryDelay(error, reconnectAttempt), signal);
90
+ reconnectAttempt += 1;
91
+ }
92
+ }
93
+ }
94
+ async function pollOnce({ apiKey, baseUrl, cursor, fetchImpl, signal, }) {
95
+ const url = new URL(STREAM_PATH, baseUrl);
96
+ if (cursor) {
97
+ url.searchParams.set('cursor', cursor);
98
+ }
99
+ const response = await fetchImpl(url, {
100
+ headers: {
101
+ Accept: 'application/json',
102
+ Authorization: `Bearer ${apiKey}`,
103
+ },
104
+ signal,
105
+ });
106
+ if (!response.ok) {
107
+ throw new SignalStreamRequestError(`Signal Stream request failed with status ${response.status}`, getRetryAfterMs(response));
108
+ }
109
+ return parsePollResponse(await response.json());
110
+ }
111
+ function parsePollResponse(data) {
112
+ if (!isRecord(data) || !Array.isArray(data.events)) {
113
+ throw new Error('Received malformed Signal Stream response');
114
+ }
115
+ const events = data.events.map(parseSignalEvent);
116
+ const nextCursor = typeof data.nextCursor === 'string' || data.nextCursor === null
117
+ ? data.nextCursor
118
+ : null;
119
+ const retryAfterMs = typeof data.retryAfterMs === 'number' && data.retryAfterMs > 0
120
+ ? data.retryAfterMs
121
+ : undefined;
122
+ const window = isRecord(data.window)
123
+ ? {
124
+ open: data.window.open === true,
125
+ message: typeof data.window.message === 'string'
126
+ ? data.window.message
127
+ : undefined,
128
+ }
129
+ : undefined;
130
+ return {
131
+ events,
132
+ nextCursor,
133
+ retryAfterMs,
134
+ window,
135
+ };
136
+ }
137
+ function getNextPollDelay(response, options) {
138
+ if (response.window?.open === false && response.retryAfterMs) {
139
+ return response.retryAfterMs;
140
+ }
141
+ return (options.pollIntervalMs ?? response.retryAfterMs ?? DEFAULT_POLL_INTERVAL_MS);
142
+ }
143
+ function getErrorRetryDelay(error, attempt) {
144
+ if (error instanceof SignalStreamRequestError &&
145
+ typeof error.retryAfterMs === 'number') {
146
+ return error.retryAfterMs;
147
+ }
148
+ return getReconnectDelay(attempt);
149
+ }
150
+ function getRetryAfterMs(response) {
151
+ const retryAfter = response.headers.get('retry-after');
152
+ if (!retryAfter) {
153
+ return undefined;
154
+ }
155
+ const retryAfterSeconds = Number.parseInt(retryAfter, 10);
156
+ return Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
157
+ ? retryAfterSeconds * 1000
158
+ : undefined;
159
+ }
160
+ function parseSignalEvent(data) {
161
+ if (!isRecord(data)) {
162
+ throw new Error('Received malformed Signal Stream event');
163
+ }
164
+ const signal = data.signal;
165
+ if (data.type !== 'signal.created' ||
166
+ typeof data.id !== 'string' ||
167
+ typeof data.createdAt !== 'string' ||
168
+ !isRecord(signal) ||
169
+ typeof signal.id !== 'string' ||
170
+ typeof signal.symbol !== 'string' ||
171
+ (signal.optionType !== 'C' && signal.optionType !== 'P') ||
172
+ typeof signal.strikePrice !== 'number') {
173
+ throw new Error('Received malformed Signal Stream event');
174
+ }
175
+ return data;
176
+ }
177
+ function isRecord(value) {
178
+ return typeof value === 'object' && value !== null;
179
+ }
180
+ function getReconnectDelay(attempt) {
181
+ return Math.min(MAX_RECONNECT_DELAY_MS, MIN_RECONNECT_DELAY_MS * 2 ** attempt);
182
+ }
183
+ function delay(durationMs, signal) {
184
+ return new Promise((resolve, reject) => {
185
+ if (signal.aborted) {
186
+ reject(signal.reason);
187
+ return;
188
+ }
189
+ const timeout = setTimeout(resolve, durationMs);
190
+ signal.addEventListener('abort', () => {
191
+ clearTimeout(timeout);
192
+ reject(signal.reason);
193
+ }, { once: true });
194
+ });
195
+ }
196
+ function mergeAbortSignals(signals) {
197
+ if (signals.length === 1) {
198
+ return signals[0];
199
+ }
200
+ const controller = new AbortController();
201
+ const abort = (signal) => {
202
+ if (!controller.signal.aborted) {
203
+ controller.abort(signal.reason);
204
+ }
205
+ };
206
+ for (const signal of signals) {
207
+ if (signal.aborted) {
208
+ abort(signal);
209
+ break;
210
+ }
211
+ signal.addEventListener('abort', () => abort(signal), { once: true });
212
+ }
213
+ return controller.signal;
214
+ }
215
+ function normalizeBaseUrl(baseUrl) {
216
+ return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
217
+ }
@@ -0,0 +1,3 @@
1
+ export { AstronomerSignals } from './client.js';
2
+ export type { AstronomerSignalsOptions, ListenOptions, SignalEvent, SignalEventType, SignalListener, } from './types.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,YAAY,EACV,wBAAwB,EACxB,aAAa,EACb,WAAW,EACX,eAAe,EACf,cAAc,GACf,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { AstronomerSignals } from './client.js';
@@ -0,0 +1,31 @@
1
+ export type SignalEventType = 'signal.created';
2
+ export interface SignalEvent {
3
+ id: string;
4
+ type: SignalEventType;
5
+ createdAt: string;
6
+ signal: {
7
+ id: string;
8
+ symbol: string;
9
+ optionType: 'C' | 'P';
10
+ strikePrice: number;
11
+ openedAt?: string;
12
+ priceAtOpen?: number;
13
+ };
14
+ }
15
+ export interface AstronomerSignalsOptions {
16
+ apiKey: string;
17
+ baseUrl?: string;
18
+ fetch?: typeof fetch;
19
+ }
20
+ export interface ListenOptions {
21
+ pollIntervalMs?: number;
22
+ signal?: AbortSignal;
23
+ onSignal(event: SignalEvent): void;
24
+ onError?(error: unknown): void;
25
+ onOpen?(): void;
26
+ onClose?(): void;
27
+ }
28
+ export interface SignalListener {
29
+ close(): Promise<void>;
30
+ }
31
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GAAG,gBAAgB,CAAC;AAE/C,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,eAAe,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE;QACN,EAAE,EAAE,MAAM,CAAC;QACX,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,GAAG,GAAG,GAAG,CAAC;QACtB,WAAW,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;CACH;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;IACnC,OAAO,CAAC,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAC/B,MAAM,CAAC,IAAI,IAAI,CAAC;IAChB,OAAO,CAAC,IAAI,IAAI,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@astronomer-app/signals",
3
+ "version": "0.0.0",
4
+ "description": "TypeScript client for Astronomer Signal Stream.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "example:listen": "npm run build && node examples/listen.mjs",
21
+ "typecheck": "tsc -p tsconfig.json --noEmit"
22
+ },
23
+ "keywords": [
24
+ "astronomer",
25
+ "signals",
26
+ "polling",
27
+ "trading"
28
+ ],
29
+ "license": "UNLICENSED",
30
+ "sideEffects": false,
31
+ "engines": {
32
+ "node": ">=18"
33
+ }
34
+ }