@xano/cli 1.0.6-beta.7 → 1.0.6-beta.8

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,188 @@
1
+ /* eslint-disable n/no-unsupported-features/node-builtins -- Web Streams (ReadableStream, TextDecoder) are what fetch bodies are; available since Node 18 */
2
+ /* eslint-disable no-await-in-loop -- reading a stream and backing off between reconnects is sequential by definition */
3
+ /** Consecutive fruitless reconnects tolerated before giving up. */
4
+ export const DEFAULT_MAX_ATTEMPTS = 5;
5
+ /** 500ms, doubling, capped at 8s: 500, 1000, 2000, 4000, 8000. */
6
+ export function defaultBackoffMs(attempt) {
7
+ return Math.min(500 * 2 ** (attempt - 1), 8000);
8
+ }
9
+ const defaultSleep = (ms) => new Promise((resolve) => {
10
+ setTimeout(resolve, ms);
11
+ });
12
+ function errorMessage(error) {
13
+ return error instanceof Error ? error.message : String(error);
14
+ }
15
+ /**
16
+ * Accumulates the fields of one event until a blank line dispatches it.
17
+ *
18
+ * Kept separate from the byte plumbing so the framing rules — comments, field
19
+ * splitting, multi-line data — read as a unit and are testable through the
20
+ * generator with plain strings.
21
+ */
22
+ class FrameAccumulator {
23
+ dataLines = [];
24
+ eventId;
25
+ eventName;
26
+ sawField = false;
27
+ /** Feeds one line; returns an event when that line completed one. */
28
+ push(line) {
29
+ // A blank line dispatches. A leading colon marks a comment — servers use
30
+ // those to hold an idle connection open (`: keep-alive`), and treating them
31
+ // as events would emit garbage on a quiet stream.
32
+ if (line === '')
33
+ return this.dispatch();
34
+ if (line.startsWith(':'))
35
+ return undefined;
36
+ const colon = line.indexOf(':');
37
+ const field = colon === -1 ? line : line.slice(0, colon);
38
+ let value = colon === -1 ? '' : line.slice(colon + 1);
39
+ if (value.startsWith(' '))
40
+ value = value.slice(1);
41
+ switch (field) {
42
+ case 'data': {
43
+ this.dataLines.push(value);
44
+ this.sawField = true;
45
+ break;
46
+ }
47
+ case 'event': {
48
+ this.eventName = value;
49
+ this.sawField = true;
50
+ break;
51
+ }
52
+ case 'id': {
53
+ if (/^\d+$/.test(value))
54
+ this.eventId = Number(value);
55
+ this.sawField = true;
56
+ break;
57
+ }
58
+ // `retry:` and anything unrecognized are not this reader's business.
59
+ default:
60
+ }
61
+ return undefined;
62
+ }
63
+ dispatch() {
64
+ if (!this.sawField)
65
+ return undefined;
66
+ const event = { data: this.dataLines.join('\n'), event: this.eventName, id: this.eventId };
67
+ this.dataLines = [];
68
+ this.eventName = undefined;
69
+ this.eventId = undefined;
70
+ this.sawField = false;
71
+ return event;
72
+ }
73
+ }
74
+ /**
75
+ * Parses a `text/event-stream` body into events.
76
+ *
77
+ * Decodes UTF-8 incrementally, so a multi-byte character split across a chunk
78
+ * boundary survives. Handles `\n` and `\r\n`. A trailing partial event at
79
+ * end-of-stream is discarded — an event is only dispatched on a blank line.
80
+ *
81
+ * @yields each parsed event, in arrival order.
82
+ */
83
+ export async function* readSseStream(stream) {
84
+ const reader = stream.getReader();
85
+ const decoder = new TextDecoder('utf8');
86
+ const frames = new FrameAccumulator();
87
+ let buffer = '';
88
+ try {
89
+ for (;;) {
90
+ let chunk;
91
+ try {
92
+ chunk = await reader.read();
93
+ }
94
+ catch (error) {
95
+ throw new Error(`failed to read event stream: ${errorMessage(error)}`);
96
+ }
97
+ if (chunk.done)
98
+ break;
99
+ buffer += decoder.decode(chunk.value, { stream: true });
100
+ let newline = buffer.indexOf('\n');
101
+ while (newline !== -1) {
102
+ const line = buffer.slice(0, newline).replace(/\r$/, '');
103
+ buffer = buffer.slice(newline + 1);
104
+ newline = buffer.indexOf('\n');
105
+ const event = frames.push(line);
106
+ if (event)
107
+ yield event;
108
+ }
109
+ }
110
+ }
111
+ finally {
112
+ // Cancel rather than release: an early `break` by the consumer (on a
113
+ // terminal event, say) must close the underlying connection, not leak it.
114
+ await reader.cancel().catch(() => { });
115
+ }
116
+ }
117
+ /** An already-closed stream, used to route a failed reopen through the same retry accounting. */
118
+ function closedStream() {
119
+ return new ReadableStream({
120
+ start(controller) {
121
+ controller.close();
122
+ },
123
+ });
124
+ }
125
+ /**
126
+ * Reads a stream to its terminal event, reopening across unexpected closes.
127
+ *
128
+ * Tracks the highest id seen and hands it to `open()` so the caller can resume
129
+ * with `Last-Event-ID`. A close that follows a terminal event completes
130
+ * normally; any other close is a drop, reported through `onReconnecting` and
131
+ * retried after a backoff.
132
+ *
133
+ * The attempt budget counts *consecutive fruitless* reopens: a reopen that
134
+ * delivers at least one event resets it, so a long-running stream survives many
135
+ * drops while a dead endpoint still fails fast. Callers that need an absolute
136
+ * ceiling should impose their own wall-clock cap on top of this.
137
+ *
138
+ * @yields each parsed event, up to and including the terminal one.
139
+ */
140
+ export async function* readSseWithReconnect(options) {
141
+ const { backoffMs = defaultBackoffMs, isTerminal, maxAttempts = DEFAULT_MAX_ATTEMPTS, onReconnecting, open, sleep = defaultSleep, } = options;
142
+ let stream;
143
+ try {
144
+ stream = await open();
145
+ }
146
+ catch (error) {
147
+ throw new Error(`failed to open event stream: ${errorMessage(error)}`);
148
+ }
149
+ let lastEventId;
150
+ let attempt = 0;
151
+ let reopenError;
152
+ for (;;) {
153
+ let progressed = false;
154
+ let terminated = false;
155
+ for await (const event of readSseStream(stream)) {
156
+ if (event.id !== undefined && (lastEventId === undefined || event.id > lastEventId)) {
157
+ lastEventId = event.id;
158
+ }
159
+ progressed = true;
160
+ yield event;
161
+ if (isTerminal(event)) {
162
+ terminated = true;
163
+ break;
164
+ }
165
+ }
166
+ if (terminated)
167
+ return;
168
+ if (progressed)
169
+ attempt = 0;
170
+ attempt += 1;
171
+ if (attempt > maxAttempts) {
172
+ const cause = reopenError ? `: ${errorMessage(reopenError)}` : '';
173
+ throw new Error(`event stream closed before a terminal event and did not recover after ${maxAttempts} reconnect attempts${cause}`);
174
+ }
175
+ onReconnecting?.(attempt);
176
+ await sleep(backoffMs(attempt));
177
+ try {
178
+ stream = await open(lastEventId);
179
+ reopenError = undefined;
180
+ }
181
+ catch (error) {
182
+ // Reopening failed. Substitute an empty stream so the next pass through
183
+ // the loop charges this against the same attempt budget as a close does.
184
+ reopenError = error;
185
+ stream = closedStream();
186
+ }
187
+ }
188
+ }