@lizard-build/cli 0.3.42 → 0.3.44
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/dist/commands/events.d.ts +2 -0
- package/dist/commands/events.js +70 -0
- package/dist/commands/events.js.map +1 -0
- package/dist/commands/logs.js +103 -32
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/metrics.d.ts +2 -0
- package/dist/commands/metrics.js +280 -0
- package/dist/commands/metrics.js.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/api.d.ts +13 -2
- package/dist/lib/api.js +143 -62
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/updater.d.ts +1 -1
- package/dist/lib/updater.js +1 -1
- package/package.json +1 -1
- package/src/commands/events.ts +106 -0
- package/src/commands/logs.ts +110 -34
- package/src/commands/metrics.ts +390 -0
- package/src/index.ts +4 -0
- package/src/lib/api.ts +158 -70
- package/src/lib/updater.ts +1 -1
- package/test/unit/sse.test.ts +173 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as http from "node:http";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import { streamSSE, setBaseURL, getBaseURL, APIError } from "../../src/lib/api.js";
|
|
5
|
+
|
|
6
|
+
type SSEHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
|
7
|
+
|
|
8
|
+
let server: http.Server;
|
|
9
|
+
let serveSSE: SSEHandler;
|
|
10
|
+
const originalBaseURL = getBaseURL();
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
server = http.createServer((req, res) => serveSSE(req, res));
|
|
14
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
15
|
+
const { port } = server.address() as AddressInfo;
|
|
16
|
+
setBaseURL(`http://127.0.0.1:${port}`);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
setBaseURL(originalBaseURL);
|
|
21
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function sseHead(res: http.ServerResponse) {
|
|
25
|
+
res.writeHead(200, { "Content-Type": "text/event-stream" });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("streamSSE parsing", () => {
|
|
29
|
+
test("dispatches events and stops when handler returns false", async () => {
|
|
30
|
+
serveSSE = (_req, res) => {
|
|
31
|
+
sseHead(res);
|
|
32
|
+
res.write("data: one\n\n");
|
|
33
|
+
res.write("data: two\n\n");
|
|
34
|
+
res.write("event: done\ndata: bye\n\n");
|
|
35
|
+
// Keep the connection open — the handler's `false` must end the stream.
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const seen: Array<[string, string]> = [];
|
|
39
|
+
await streamSSE("/sse", (event, data) => {
|
|
40
|
+
seen.push([event, data]);
|
|
41
|
+
return event !== "done";
|
|
42
|
+
});
|
|
43
|
+
expect(seen).toEqual([["", "one"], ["", "two"], ["done", "bye"]]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("concatenates multiple data: lines with newlines (SSE spec)", async () => {
|
|
47
|
+
serveSSE = (_req, res) => {
|
|
48
|
+
sseHead(res);
|
|
49
|
+
res.write("data: line1\ndata: line2\n\n");
|
|
50
|
+
res.end();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const seen: string[] = [];
|
|
54
|
+
await streamSSE("/sse", (_event, data) => {
|
|
55
|
+
seen.push(data);
|
|
56
|
+
return true;
|
|
57
|
+
});
|
|
58
|
+
expect(seen).toEqual(["line1\nline2"]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("ignores heartbeat comments and empty data", async () => {
|
|
62
|
+
serveSSE = (_req, res) => {
|
|
63
|
+
sseHead(res);
|
|
64
|
+
res.write(": keepalive\n\n");
|
|
65
|
+
res.write("data: real\n\n");
|
|
66
|
+
res.end();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const seen: string[] = [];
|
|
70
|
+
await streamSSE("/sse", (_event, data) => {
|
|
71
|
+
seen.push(data);
|
|
72
|
+
return true;
|
|
73
|
+
});
|
|
74
|
+
expect(seen).toEqual(["real"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("rejects with APIError on HTTP >= 400", async () => {
|
|
78
|
+
serveSSE = (_req, res) => {
|
|
79
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
80
|
+
res.end('{"error":"App not found"}');
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
await expect(streamSSE("/sse", () => true)).rejects.toSatisfy(
|
|
84
|
+
(err: unknown) => err instanceof APIError && err.status === 404,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("idleTimeoutMs resolves when no events arrive", async () => {
|
|
89
|
+
serveSSE = (_req, res) => {
|
|
90
|
+
sseHead(res);
|
|
91
|
+
res.write("data: snapshot\n\n");
|
|
92
|
+
// Then go quiet without ending — idle timeout must fire.
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const seen: string[] = [];
|
|
96
|
+
await streamSSE(
|
|
97
|
+
"/sse",
|
|
98
|
+
(_event, data) => {
|
|
99
|
+
seen.push(data);
|
|
100
|
+
return true;
|
|
101
|
+
},
|
|
102
|
+
{ idleTimeoutMs: 200 },
|
|
103
|
+
);
|
|
104
|
+
expect(seen).toEqual(["snapshot"]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("streamSSE reconnect", () => {
|
|
109
|
+
test("without reconnect, server end resolves silently", async () => {
|
|
110
|
+
serveSSE = (_req, res) => {
|
|
111
|
+
sseHead(res);
|
|
112
|
+
res.write("data: only\n\n");
|
|
113
|
+
res.end();
|
|
114
|
+
};
|
|
115
|
+
const seen: string[] = [];
|
|
116
|
+
await streamSSE("/sse", (_e, d) => (seen.push(d), true));
|
|
117
|
+
expect(seen).toEqual(["only"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("reconnects after drop, sends Last-Event-ID, suppresses replayed ids", async () => {
|
|
121
|
+
let conn = 0;
|
|
122
|
+
let lastEventIdHeader: string | undefined;
|
|
123
|
+
serveSSE = (req, res) => {
|
|
124
|
+
conn++;
|
|
125
|
+
sseHead(res);
|
|
126
|
+
if (conn === 1) {
|
|
127
|
+
res.write("id: 100-0\ndata: a\n\n");
|
|
128
|
+
res.write("id: 200-0\ndata: b\n\n");
|
|
129
|
+
res.end(); // unexpected drop
|
|
130
|
+
} else {
|
|
131
|
+
lastEventIdHeader = req.headers["last-event-id"] as string | undefined;
|
|
132
|
+
// Server replays history (like /api/apps/:id/logs does) + one new event
|
|
133
|
+
res.write("id: 100-0\ndata: a\n\n");
|
|
134
|
+
res.write("id: 200-0\ndata: b\n\n");
|
|
135
|
+
res.write("id: 300-0\ndata: c\n\n");
|
|
136
|
+
res.write("event: done\ndata: end\n\n");
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const seen: string[] = [];
|
|
141
|
+
const retries: number[] = [];
|
|
142
|
+
await streamSSE(
|
|
143
|
+
"/sse",
|
|
144
|
+
(event, data) => {
|
|
145
|
+
if (event === "done") return false;
|
|
146
|
+
seen.push(data);
|
|
147
|
+
return true;
|
|
148
|
+
},
|
|
149
|
+
{ reconnect: true, reconnectBaseDelayMs: 10, onReconnect: (n) => retries.push(n) },
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(retries).toEqual([1]);
|
|
153
|
+
expect(lastEventIdHeader).toBe("200-0");
|
|
154
|
+
expect(seen).toEqual(["a", "b", "c"]); // no duplicate a/b after reconnect
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("rejects after exhausting reconnect attempts", async () => {
|
|
158
|
+
serveSSE = (_req, res) => {
|
|
159
|
+
sseHead(res);
|
|
160
|
+
res.end(); // drop immediately, every time
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const retries: number[] = [];
|
|
164
|
+
await expect(
|
|
165
|
+
streamSSE("/sse", () => true, {
|
|
166
|
+
reconnect: true,
|
|
167
|
+
reconnectBaseDelayMs: 10,
|
|
168
|
+
onReconnect: (n) => retries.push(n),
|
|
169
|
+
}),
|
|
170
|
+
).rejects.toThrow(/disconnected/);
|
|
171
|
+
expect(retries).toEqual([1, 2, 3, 4, 5]);
|
|
172
|
+
});
|
|
173
|
+
});
|