@xano/cli 1.0.6-beta.6 → 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.
@@ -114,17 +114,22 @@ export async function collectAllPages(fetchPage, options = {}) {
114
114
  /**
115
115
  * Build the `--output json` payload for a list command.
116
116
  *
117
+ * Field names mirror the Metadata API envelope exactly — `curPage`, `nextPage`,
118
+ * `prevPage`, `itemsTotal`, `items` — so a script can move between the CLI and
119
+ * the raw API without a translation layer. `perPage` is the one field the CLI
120
+ * injects: the API never echoes it back, and it is otherwise invisible when the
121
+ * user leaves the flag at its default.
122
+ *
117
123
  * Emits only fields the response actually proves, mirroring the footer's
118
- * honesty rule: `total` appears only when the server sent `itemsTotal`,
119
- * `next_page` only when the server computed it. `count` is always the number of
120
- * items in this payload and is the one figure that is unconditionally true.
124
+ * honesty rule: `itemsTotal` appears only when the server sent it, `nextPage`
125
+ * only when the server computed it.
121
126
  *
122
- * Without this, a script driving `--output json` sees a bare array and its only
123
- * available stop condition is `length < per_page` — precisely the page-fullness
124
- * inference this module refuses to make in the footer.
127
+ * Without this envelope, a script driving `--output json` sees a bare array and
128
+ * its only available stop condition is `length < perPage` — precisely the
129
+ * page-fullness inference this module refuses to make in the footer.
125
130
  */
126
131
  export function buildPagingJson(list, options) {
127
- const payload = { count: list.items.length, items: list.items };
132
+ const payload = { items: list.items };
128
133
  if (options.tier === 'none') {
129
134
  return payload;
130
135
  }
@@ -136,17 +141,17 @@ export function buildPagingJson(list, options) {
136
141
  if (!hasPositionEvidence) {
137
142
  return payload;
138
143
  }
139
- const page = list.curPage ?? options.page;
140
- if (page !== undefined)
141
- payload.page = page;
144
+ const curPage = list.curPage ?? options.page;
145
+ if (curPage !== undefined)
146
+ payload.curPage = curPage;
142
147
  if (options.tier === 'envelope' && options.perPage !== undefined)
143
- payload.per_page = options.perPage;
148
+ payload.perPage = options.perPage;
144
149
  if (list.nextPage !== undefined)
145
- payload.next_page = list.nextPage;
150
+ payload.nextPage = list.nextPage;
146
151
  if (list.prevPage !== undefined)
147
- payload.prev_page = list.prevPage;
152
+ payload.prevPage = list.prevPage;
148
153
  if (list.itemsTotal !== undefined)
149
- payload.total = list.itemsTotal;
154
+ payload.itemsTotal = list.itemsTotal;
150
155
  return payload;
151
156
  }
152
157
  const SEPARATOR = ' · ';
@@ -0,0 +1,78 @@
1
+ /**
2
+ * A minimal `text/event-stream` reader.
3
+ *
4
+ * Bytes in, `{id, event, data}` out. It knows nothing about oclif, HTTP, or any
5
+ * particular server's payloads: `readSseStream` is a pure transform over a
6
+ * `ReadableStream<Uint8Array>`, and `readSseWithReconnect` drives reconnection
7
+ * through a caller-supplied `open()` rather than doing its own requests. That
8
+ * keeps every test a string fixture.
9
+ */
10
+ /** One dispatched server-sent event. */
11
+ export interface SseEvent {
12
+ /** The joined `data:` lines, newline-separated. Empty when the event carried none. */
13
+ data: string;
14
+ /** The `event:` field, or undefined for the default (unnamed) event type. */
15
+ event?: string;
16
+ /**
17
+ * The `id:` field, parsed as a non-negative integer. Non-numeric ids are
18
+ * ignored: this reader's only use for an id is resuming from `Last-Event-ID`,
19
+ * and a caller that cannot order ids cannot resume from them either.
20
+ */
21
+ id?: number;
22
+ }
23
+ /** Consecutive fruitless reconnects tolerated before giving up. */
24
+ export declare const DEFAULT_MAX_ATTEMPTS = 5;
25
+ /** 500ms, doubling, capped at 8s: 500, 1000, 2000, 4000, 8000. */
26
+ export declare function defaultBackoffMs(attempt: number): number;
27
+ export interface ReconnectOptions {
28
+ /** Backoff before reconnect attempt `n` (1-based). Defaults to {@link defaultBackoffMs}. */
29
+ backoffMs?: (attempt: number) => number;
30
+ /**
31
+ * Whether this event ends the stream. Supplied by the caller so this module
32
+ * stays free of any particular protocol's terminal shape. A close that
33
+ * follows a terminal event is normal completion, not a drop.
34
+ */
35
+ isTerminal: (event: SseEvent) => boolean;
36
+ /** Consecutive fruitless reconnects tolerated. Defaults to {@link DEFAULT_MAX_ATTEMPTS}. */
37
+ maxAttempts?: number;
38
+ /**
39
+ * Called once per reconnect attempt, before the backoff. Required for any UI
40
+ * that would otherwise look hung: a drop is invisible while the connection is
41
+ * down, so this is the only signal the caller can render.
42
+ */
43
+ onReconnecting?: (attempt: number) => void;
44
+ /**
45
+ * Opens (or reopens) the stream. `lastEventId` is the highest id seen so far
46
+ * and must be sent as the `Last-Event-ID` **header** — servers commonly ignore
47
+ * a query parameter, which silently replays the stream from the beginning.
48
+ */
49
+ open: (lastEventId?: number) => Promise<ReadableStream<Uint8Array>>;
50
+ /** Injectable so tests do not actually sleep. */
51
+ sleep?: (ms: number) => Promise<void>;
52
+ }
53
+ /**
54
+ * Parses a `text/event-stream` body into events.
55
+ *
56
+ * Decodes UTF-8 incrementally, so a multi-byte character split across a chunk
57
+ * boundary survives. Handles `\n` and `\r\n`. A trailing partial event at
58
+ * end-of-stream is discarded — an event is only dispatched on a blank line.
59
+ *
60
+ * @yields each parsed event, in arrival order.
61
+ */
62
+ export declare function readSseStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent>;
63
+ /**
64
+ * Reads a stream to its terminal event, reopening across unexpected closes.
65
+ *
66
+ * Tracks the highest id seen and hands it to `open()` so the caller can resume
67
+ * with `Last-Event-ID`. A close that follows a terminal event completes
68
+ * normally; any other close is a drop, reported through `onReconnecting` and
69
+ * retried after a backoff.
70
+ *
71
+ * The attempt budget counts *consecutive fruitless* reopens: a reopen that
72
+ * delivers at least one event resets it, so a long-running stream survives many
73
+ * drops while a dead endpoint still fails fast. Callers that need an absolute
74
+ * ceiling should impose their own wall-clock cap on top of this.
75
+ *
76
+ * @yields each parsed event, up to and including the terminal one.
77
+ */
78
+ export declare function readSseWithReconnect(options: ReconnectOptions): AsyncGenerator<SseEvent>;
@@ -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
+ }