about-system 0.10.9 → 0.10.11

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.
@@ -3,6 +3,7 @@
3
3
  * @module network
4
4
  */
5
5
 
6
+ import http from "http";
6
7
  import https from "https";
7
8
  import {
8
9
  DEFAULT_IPINFO_TOKEN,
@@ -25,34 +26,152 @@ export interface IPInfo {
25
26
  }
26
27
 
27
28
  /**
28
- * Fetches IP geolocation information from ipinfo.io API
29
+ * IP information from ip-api.com (fallback)
30
+ * @interface IPAPIInfo
31
+ */
32
+ interface IPAPIInfo {
33
+ status?: string;
34
+ query?: string;
35
+ city?: string;
36
+ isp?: string;
37
+ org?: string;
38
+ as?: string;
39
+ }
40
+
41
+ /**
42
+ * GETs a URL and parses the body as JSON. Never rejects: resolves `null` on
43
+ * any error, on abort, or once `timeout` ms have passed in total — a hard
44
+ * deadline for the whole request, not a socket-idle timeout.
45
+ */
46
+ function getJSON(
47
+ url: string,
48
+ timeout: number,
49
+ signal?: AbortSignal
50
+ ): Promise<any> {
51
+ return new Promise((resolve) => {
52
+ if (signal?.aborted) return resolve(null);
53
+
54
+ let settled = false;
55
+ let req: http.ClientRequest | undefined;
56
+ const finish = (value: any) => {
57
+ if (settled) return;
58
+ settled = true;
59
+ clearTimeout(timer);
60
+ signal?.removeEventListener("abort", onAbort);
61
+ resolve(value);
62
+ };
63
+ const onAbort = () => {
64
+ req?.destroy();
65
+ finish(null);
66
+ };
67
+ const timer = setTimeout(onAbort, timeout);
68
+ signal?.addEventListener("abort", onAbort);
69
+
70
+ try {
71
+ const get = url.startsWith("https:") ? https.get : http.get;
72
+ req = get(url, (res) => {
73
+ let data = "";
74
+ res.on("data", (chunk) => (data += chunk));
75
+ res.on("end", () => {
76
+ try {
77
+ finish(JSON.parse(data));
78
+ } catch {
79
+ finish(null);
80
+ }
81
+ });
82
+ res.on("error", () => finish(null));
83
+ });
84
+ req.on("error", () => finish(null));
85
+ } catch {
86
+ finish(null);
87
+ }
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Fetches IP information for this machine from the ipinfo.io lite API
93
+ * @param {string} token - IPInfo.io API token
94
+ * @param {number} timeout - Total request deadline in milliseconds
95
+ * @param {AbortSignal} signal - Abandons the request when aborted
96
+ * @returns {Promise<IPInfo>} IP information object or empty object on error
97
+ */
98
+ export async function fetchFromIPInfo(
99
+ token: string = DEFAULT_IPINFO_TOKEN,
100
+ timeout: number = DEFAULT_NETWORK_TIMEOUT,
101
+ signal?: AbortSignal
102
+ ): Promise<IPInfo> {
103
+ const parsed = await getJSON(
104
+ `https://api.ipinfo.io/lite/me?token=${token}`,
105
+ timeout,
106
+ signal
107
+ );
108
+ if (!parsed || typeof parsed !== "object") return {};
109
+ // ipinfo lite returns { ip, asn, as_name, country, ... } — no city.
110
+ const org =
111
+ parsed.org ?? (parsed.asn && parsed.as_name ? `${parsed.asn} ${parsed.as_name}` : undefined);
112
+ return {
113
+ ip: parsed.ip,
114
+ city: parsed.city,
115
+ hostname: parsed.hostname,
116
+ org,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Fetches IP geolocation information from ip-api.com (fallback). The free
122
+ * tier is plain HTTP only.
123
+ * @param {number} timeout - Total request deadline in milliseconds
124
+ * @param {AbortSignal} signal - Abandons the request when aborted
125
+ * @returns {Promise<IPInfo>} IP information object or empty object on error
126
+ */
127
+ export async function fetchFromIPAPI(
128
+ timeout: number = DEFAULT_NETWORK_TIMEOUT,
129
+ signal?: AbortSignal
130
+ ): Promise<IPInfo> {
131
+ const parsed: IPAPIInfo | null = await getJSON(
132
+ "http://ip-api.com/json/?fields=status,message,query,city,isp,org,as",
133
+ timeout,
134
+ signal
135
+ );
136
+ if (!parsed || parsed.status !== "success") return {};
137
+ return {
138
+ ip: parsed.query,
139
+ city: parsed.city,
140
+ hostname: undefined,
141
+ org: parsed.org || parsed.isp || parsed.as,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Fetches IP information from ipinfo.io and ip-api.com in parallel and merges
147
+ * them: ipinfo.io wins per field, ip-api.com fills the gaps (ipinfo lite has
148
+ * no city). Both requests share the same deadline, so this never takes longer
149
+ * than `timeout`.
29
150
  * @param {string} token - IPInfo.io API token
30
- * @param {number} timeout - Request timeout in milliseconds
151
+ * @param {number} timeout - Total deadline in milliseconds
152
+ * @param {AbortSignal} signal - Abandons both requests when aborted
31
153
  * @returns {Promise<IPInfo>} IP information object or empty object on error
32
154
  */
33
155
  export async function fetchIPInfo(
34
156
  token: string = DEFAULT_IPINFO_TOKEN,
35
- timeout: number = DEFAULT_NETWORK_TIMEOUT
157
+ timeout: number = DEFAULT_NETWORK_TIMEOUT,
158
+ signal?: AbortSignal
36
159
  ): Promise<IPInfo> {
37
- return new Promise((resolve) => {
38
- const url = `https://ipinfo.io/json${token ? `?token=${token}` : ""}`;
39
-
40
- const req = https.get(url, (res) => {
41
- let data = "";
42
- res.on("data", (chunk) => (data += chunk));
43
- res.on("end", () => {
44
- try {
45
- resolve(JSON.parse(data));
46
- } catch {
47
- resolve({});
48
- }
49
- });
50
- });
160
+ const [ipinfo, ipapi] = await Promise.all([
161
+ fetchFromIPInfo(token, timeout, signal),
162
+ fetchFromIPAPI(timeout, signal),
163
+ ]);
51
164
 
52
- req.on("error", () => resolve({}));
53
- req.setTimeout(timeout, () => {
54
- req.destroy();
55
- resolve({});
56
- });
57
- });
165
+ const merged: IPInfo = {};
166
+ for (const source of [ipapi, ipinfo]) {
167
+ for (const [key, value] of Object.entries(source)) {
168
+ if (value) merged[key as keyof IPInfo] = value;
169
+ }
170
+ }
171
+ // ipinfo answers over IPv6 when the machine has it; the short IPv4 address
172
+ // reads better in a one-line greeting, so keep ip-api's when it has one.
173
+ if (merged.ip?.includes(":") && ipapi.ip && !ipapi.ip.includes(":")) {
174
+ merged.ip = ipapi.ip;
175
+ }
176
+ return merged;
58
177
  }
@@ -22,16 +22,23 @@ vi.mock("https", () => ({
22
22
  get: httpsGet,
23
23
  default: { get: httpsGet },
24
24
  }));
25
+ const httpGet = vi.hoisted(() => vi.fn());
26
+ vi.mock("http", () => ({
27
+ get: httpGet,
28
+ default: { get: httpGet },
29
+ }));
25
30
 
26
31
  import { commandExists, execCommand } from "./command";
27
32
  import { IS_LINUX, IS_MAC, IS_WINDOWS } from "./platform";
28
- import { fetchIPInfo } from "./network";
29
- import { DEFAULT_IPINFO_TOKEN } from "../cache/cache-config";
33
+ import { fetchIPInfo, fetchFromIPInfo, fetchFromIPAPI } from "./network";
34
+ import { DEFAULT_IPINFO_TOKEN, DEFAULT_NETWORK_TIMEOUT } from "../cache/cache-config";
30
35
 
31
36
  afterEach(() => {
32
37
  execSync.mockReset();
33
38
  httpsGet.mockReset();
39
+ httpGet.mockReset();
34
40
  vi.restoreAllMocks();
41
+ vi.useRealTimers();
35
42
  });
36
43
 
37
44
  describe("platform flags", () => {
@@ -118,101 +125,170 @@ describe("commandExists", () => {
118
125
  });
119
126
  });
120
127
 
121
- /** A fake `https.get` whose response body and failure mode a test controls. */
122
- function stubHttpsGet(behavior: (req: EventEmitter & { setTimeout: (ms: number, fn: () => void) => void; destroy: () => void }, onResponse: (res: EventEmitter) => void) => void) {
123
- httpsGet.mockImplementation(
124
- (_url: string, callback: (res: EventEmitter) => void) => {
125
- const req = Object.assign(new EventEmitter(), {
126
- setTimeout: vi.fn(),
127
- destroy: vi.fn(),
128
- });
129
- queueMicrotask(() => behavior(req, callback));
130
- return req;
131
- },
132
- );
133
- return httpsGet;
128
+ type FakeReq = EventEmitter & { destroy: () => void };
129
+
130
+ /**
131
+ * A fake `get` (https or http) whose response a test controls. `behavior`
132
+ * runs on the next microtask; leave it empty to simulate a hung request.
133
+ */
134
+ function stubGet(
135
+ get: typeof httpsGet,
136
+ behavior: (req: FakeReq, onResponse: (res: EventEmitter) => void) => void = () => {},
137
+ ) {
138
+ get.mockImplementation((_url: string, callback: (res: EventEmitter) => void) => {
139
+ const req: FakeReq = Object.assign(new EventEmitter(), { destroy: vi.fn() });
140
+ queueMicrotask(() => behavior(req, callback));
141
+ return req;
142
+ });
143
+ return get;
134
144
  }
135
145
 
136
146
  /** Emits a complete response body on the next tick. */
137
- const respondWith = (body: string) =>
138
- stubHttpsGet((_req, onResponse) => {
147
+ const respondWith = (get: typeof httpsGet, body: string) =>
148
+ stubGet(get, (_req, onResponse) => {
139
149
  const res = new EventEmitter();
140
150
  onResponse(res);
141
151
  res.emit("data", body);
142
152
  res.emit("end");
143
153
  });
144
154
 
145
- describe("fetchIPInfo", () => {
146
- it("parses the ipinfo.io payload", async () => {
147
- respondWith(JSON.stringify({ ip: "1.2.3.4", city: "San Francisco" }));
155
+ describe("fetchFromIPInfo", () => {
156
+ it("parses the ipinfo.io lite payload", async () => {
157
+ respondWith(httpsGet, JSON.stringify({ ip: "1.2.3.4", asn: "AS123", as_name: "Test ISP", country: "US" }));
148
158
 
149
- await expect(fetchIPInfo()).resolves.toEqual({
159
+ await expect(fetchFromIPInfo()).resolves.toEqual({
150
160
  ip: "1.2.3.4",
151
- city: "San Francisco",
161
+ org: "AS123 Test ISP",
152
162
  });
153
163
  });
154
164
 
155
- it("sends the default token when none is given", async () => {
156
- const get = respondWith("{}");
157
- await fetchIPInfo();
165
+ it("asks about this machine, not a fixed address", async () => {
166
+ const get = respondWith(httpsGet, "{}");
167
+ await fetchFromIPInfo();
158
168
 
159
169
  expect(get.mock.calls[0][0]).toBe(
160
- `https://ipinfo.io/json?token=${DEFAULT_IPINFO_TOKEN}`,
170
+ `https://api.ipinfo.io/lite/me?token=${DEFAULT_IPINFO_TOKEN}`,
161
171
  );
162
172
  });
163
173
 
164
- it("sends a caller-supplied token", async () => {
165
- const get = respondWith("{}");
166
- await fetchIPInfo("my-token");
174
+ it("resolves empty rather than rejecting on a malformed body", async () => {
175
+ respondWith(httpsGet, "<html>gateway error</html>");
176
+ await expect(fetchFromIPInfo()).resolves.toEqual({});
177
+ });
167
178
 
168
- expect(get.mock.calls[0][0]).toContain("token=my-token");
179
+ it("resolves empty rather than rejecting when the request errors", async () => {
180
+ stubGet(httpsGet, (req) => req.emit("error", new Error("ENOTFOUND")));
181
+ await expect(fetchFromIPInfo()).resolves.toEqual({});
169
182
  });
170
183
 
171
- it("omits the query string entirely for an empty token", async () => {
172
- const get = respondWith("{}");
173
- await fetchIPInfo("");
184
+ it("gives up after a hard 3 second deadline by default", async () => {
185
+ vi.useFakeTimers();
186
+ stubGet(httpsGet);
187
+ let done = false;
188
+ const pending = fetchFromIPInfo().then((r) => ((done = true), r));
174
189
 
175
- expect(get.mock.calls[0][0]).toBe("https://ipinfo.io/json");
190
+ await vi.advanceTimersByTimeAsync(DEFAULT_NETWORK_TIMEOUT - 1);
191
+ expect(done).toBe(false);
192
+ await vi.advanceTimersByTimeAsync(1);
193
+ await expect(pending).resolves.toEqual({});
194
+ expect(DEFAULT_NETWORK_TIMEOUT).toBe(3000);
195
+ expect(httpsGet.mock.results[0].value.destroy).toHaveBeenCalled();
176
196
  });
177
197
 
178
- it("reassembles a body that arrives in chunks", async () => {
179
- stubHttpsGet((_req, onResponse) => {
180
- const res = new EventEmitter();
181
- onResponse(res);
182
- res.emit("data", '{"ip":"1.2');
183
- res.emit("data", '.3.4"}');
184
- res.emit("end");
198
+ it("abandons the request when the signal aborts", async () => {
199
+ stubGet(httpsGet);
200
+ const controller = new AbortController();
201
+ const pending = fetchFromIPInfo(DEFAULT_IPINFO_TOKEN, 60_000, controller.signal);
202
+
203
+ controller.abort();
204
+ await expect(pending).resolves.toEqual({});
205
+ expect(httpsGet.mock.results[0].value.destroy).toHaveBeenCalled();
206
+ });
207
+ });
208
+
209
+ describe("fetchFromIPAPI", () => {
210
+ it("parses the ip-api.com payload", async () => {
211
+ respondWith(httpGet, JSON.stringify({
212
+ status: "success",
213
+ query: "1.2.3.4",
214
+ city: "San Francisco",
215
+ isp: "Test ISP",
216
+ org: "AS123 Test Organization",
217
+ as: "AS123 Test AS"
218
+ }));
219
+
220
+ await expect(fetchFromIPAPI()).resolves.toEqual({
221
+ ip: "1.2.3.4",
222
+ city: "San Francisco",
223
+ org: "AS123 Test Organization",
185
224
  });
225
+ });
226
+
227
+ it("uses plain http, which is all ip-api.com's free tier serves", async () => {
228
+ const get = respondWith(httpGet, '{"status":"success"}');
229
+ await fetchFromIPAPI();
186
230
 
187
- await expect(fetchIPInfo()).resolves.toEqual({ ip: "1.2.3.4" });
231
+ expect(get.mock.calls[0][0]).toBe(
232
+ "http://ip-api.com/json/?fields=status,message,query,city,isp,org,as"
233
+ );
234
+ expect(httpsGet).not.toHaveBeenCalled();
235
+ });
236
+
237
+ it("returns empty object on failed status", async () => {
238
+ respondWith(httpGet, JSON.stringify({ status: "fail", message: "private range" }));
239
+ await expect(fetchFromIPAPI()).resolves.toEqual({});
188
240
  });
189
241
 
190
242
  it("resolves empty rather than rejecting on a malformed body", async () => {
191
- respondWith("<html>gateway error</html>");
192
- await expect(fetchIPInfo()).resolves.toEqual({});
243
+ respondWith(httpGet, "<html>gateway error</html>");
244
+ await expect(fetchFromIPAPI()).resolves.toEqual({});
193
245
  });
246
+ });
194
247
 
195
- it("resolves empty rather than rejecting when the request errors", async () => {
196
- stubHttpsGet((req) => req.emit("error", new Error("ENOTFOUND")));
197
- await expect(fetchIPInfo()).resolves.toEqual({});
248
+ describe("fetchIPInfo", () => {
249
+ it("prefers ipinfo per field and fills gaps from ip-api", async () => {
250
+ respondWith(httpsGet, JSON.stringify({ ip: "1.2.3.4", asn: "AS123", as_name: "Primary ISP" }));
251
+ respondWith(httpGet, JSON.stringify({ status: "success", query: "5.6.7.8", city: "New York", org: "AS456 Other" }));
252
+
253
+ await expect(fetchIPInfo()).resolves.toEqual({
254
+ ip: "1.2.3.4",
255
+ city: "New York",
256
+ org: "AS123 Primary ISP",
257
+ });
258
+ });
259
+
260
+ it("prefers an IPv4 address over ipinfo's IPv6 one", async () => {
261
+ respondWith(httpsGet, JSON.stringify({ ip: "2600:1010::1", asn: "AS123", as_name: "Primary ISP" }));
262
+ respondWith(httpGet, JSON.stringify({ status: "success", query: "5.6.7.8", city: "New York" }));
263
+
264
+ await expect(fetchIPInfo()).resolves.toMatchObject({ ip: "5.6.7.8" });
198
265
  });
199
266
 
200
- it("arms a timeout that abandons the request", async () => {
201
- let armed: { ms: number; fire: () => void } | undefined;
202
- httpsGet.mockImplementation(() => {
203
- const req = Object.assign(new EventEmitter(), {
204
- setTimeout: (ms: number, fire: () => void) => {
205
- armed = { ms, fire };
206
- },
207
- destroy: vi.fn(),
208
- });
209
- return req;
267
+ it("falls back to ip-api when ipinfo fails", async () => {
268
+ stubGet(httpsGet, (req) => req.emit("error", new Error("ECONNRESET")));
269
+ respondWith(httpGet, JSON.stringify({ status: "success", query: "5.6.7.8", city: "New York", org: "AS456 Fallback ISP" }));
270
+
271
+ await expect(fetchIPInfo()).resolves.toEqual({
272
+ ip: "5.6.7.8",
273
+ city: "New York",
274
+ org: "AS456 Fallback ISP",
210
275
  });
276
+ });
277
+
278
+ it("returns empty when both fail", async () => {
279
+ stubGet(httpsGet, (req) => req.emit("error", new Error("ENOTFOUND")));
280
+ stubGet(httpGet, (req) => req.emit("error", new Error("ENOTFOUND")));
281
+
282
+ await expect(fetchIPInfo()).resolves.toEqual({});
283
+ });
211
284
 
212
- const pending = fetchIPInfo("t", 1234);
213
- expect(armed?.ms).toBe(1234);
285
+ it("never waits past the shared deadline when both hang", async () => {
286
+ vi.useFakeTimers();
287
+ stubGet(httpsGet);
288
+ stubGet(httpGet);
289
+ const pending = fetchIPInfo();
214
290
 
215
- armed?.fire();
291
+ await vi.advanceTimersByTimeAsync(DEFAULT_NETWORK_TIMEOUT);
216
292
  await expect(pending).resolves.toEqual({});
217
293
  });
218
294
  });