@forwardslashns/fws-geo-location-api 1.0.0 → 1.0.1

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.
@@ -9,6 +9,10 @@ export declare class GeoLocationClient {
9
9
  private readonly restrictions;
10
10
  /** In-memory cache: ISO2 countryCode → Country. Populated on first full country fetch. */
11
11
  private readonly countryCache;
12
+ /** True once the full country list has been fetched and cached — avoids re-fetching on every getCountries() call. */
13
+ private countriesFullyCached;
14
+ /** In-memory cache: ISO2 countryCode → full sorted PostalCode[]. Populated by getAllPostalCodes(). */
15
+ private readonly postalCodeCache;
12
16
  constructor(options: GeoLocationClientOptions);
13
17
  /**
14
18
  * Returns a list of countries.
@@ -61,10 +65,10 @@ export declare class GeoLocationClient {
61
65
  *
62
66
  * Because GeoNames requires at least one search term, a bare country-only
63
67
  * query always returns status 15. This method works around that constraint
64
- * transparently: it fires one request per character prefix (up to 36), each
65
- * with `postalcode_startsWith=<char>`, and pages through results in chunks
66
- * of 1 000 until the bucket is exhausted. Prefixes that yield no results
67
- * (status 15 or empty page) are skipped automatically.
68
+ * transparently: it fires all 36 prefix bucket requests in **parallel**,
69
+ * each with `postalcode_startsWith=<char>`. Within each bucket, pages are
70
+ * fetched sequentially until exhausted. This cuts total wall-clock time from
71
+ * `sum(bucket times)` down to `max(bucket time)`.
68
72
  *
69
73
  * The results are deduplicated and returned as a single flat array sorted by
70
74
  * postal code.
@@ -72,11 +76,16 @@ export declare class GeoLocationClient {
72
76
  * **API credit cost**: roughly 1 request per 1 000 postal codes in the
73
77
  * country, spread across up to 36 prefix buckets. For small countries
74
78
  * (< 5 000 codes) expect 10–20 requests; for large ones (US ~43 000) expect
75
- * ~80 requests.
79
+ * ~80 requests — all fired concurrently.
76
80
  *
77
81
  * @param countryCode - ISO 3166-1 alpha-2 country code, e.g. `"US"`.
78
82
  */
79
83
  getAllPostalCodes(countryCode: string, options?: GetAllPostalCodesOptions): Promise<PostalCode[]>;
84
+ /**
85
+ * Fetches all postal codes for a single alphanumeric prefix bucket,
86
+ * paginating until exhausted.
87
+ */
88
+ private fetchAllForPrefix;
80
89
  /**
81
90
  * Returns one page of postal codes for a country, working around the GeoNames
82
91
  * per-country query limit that prevents retrieving all codes for large
@@ -113,7 +122,6 @@ export declare class GeoLocationClient {
113
122
  */
114
123
  findPostalCode(postalCode: string, countryCode: string): Promise<PostalCode | null>;
115
124
  private fetchAllCountries;
116
- private fetchCountriesByContinent;
117
125
  private cacheCountries;
118
126
  private resolveCountryName;
119
127
  private assertNonEmptyCountryCode;
@@ -1 +1 @@
1
- {"version":3,"file":"geo-location.client.d.ts","sourceRoot":"","sources":["../../src/client/geo-location.client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,wBAAwB,EAExB,mBAAmB,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,qBAAqB,EACrB,wBAAwB,EACxB,wBAAwB,EACxB,cAAc,EACf,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAC;AACvD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAC;AAuBhE,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAc;IAC1C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA0B;IACvD,0FAA0F;IAC1F,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAmC;gBAE7C,OAAO,EAAE,wBAAwB;IASpD;;;;;OAKG;IACU,YAAY,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;IAyB5E;;;;;;OAMG;IACU,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;IAsC3G;;;;;;OAMG;IACU,SAAS,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;IA+C/G;;;;;;;;;;;;;;;;;;;;OAoBG;IACU,cAAc,CACzB,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC;IAuDvC;;;;;;;;;;;;;;;;;;;;OAoBG;IACU,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,wBAAwB,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;IAoD9G;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACU,kBAAkB,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,wBAAwB,GAAG,OAAO,CAAC,cAAc,CAAC;IAmEjH;;;;;;OAMG;IACU,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;YA8BlF,iBAAiB;YAUjB,yBAAyB;IAUvC,OAAO,CAAC,cAAc;YAMR,kBAAkB;IAUhC,OAAO,CAAC,yBAAyB;CAKlC"}
1
+ {"version":3,"file":"geo-location.client.d.ts","sourceRoot":"","sources":["../../src/client/geo-location.client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,wBAAwB,EAExB,mBAAmB,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,qBAAqB,EACrB,wBAAwB,EACxB,wBAAwB,EACxB,cAAc,EACf,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAC;AACvD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAC;AAsBhE,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAc;IAC1C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA0B;IACvD,0FAA0F;IAC1F,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAmC;IAChE,qHAAqH;IACrH,OAAO,CAAC,oBAAoB,CAAS;IACrC,sGAAsG;IACtG,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAwC;gBAErD,OAAO,EAAE,wBAAwB;IASpD;;;;;OAKG;IACU,YAAY,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;IAyB5E;;;;;;OAMG;IACU,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;IAsC3G;;;;;;OAMG;IACU,SAAS,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;IA+C/G;;;;;;;;;;;;;;;;;;;;OAoBG;IACU,cAAc,CACzB,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC;IAuDvC;;;;;;;;;;;;;;;;;;;;OAoBG;IACU,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,wBAAwB,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;IAwE9G;;;OAGG;YACW,iBAAiB;IA6B/B;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACU,kBAAkB,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,wBAAwB,GAAG,OAAO,CAAC,cAAc,CAAC;IAmEjH;;;;;;OAMG;IACU,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;YA8BlF,iBAAiB;IAc/B,OAAO,CAAC,cAAc;YAMR,kBAAkB;IAUhC,OAAO,CAAC,yBAAyB;CAKlC"}
@@ -14,6 +14,10 @@ class GeoLocationClient {
14
14
  restrictions;
15
15
  /** In-memory cache: ISO2 countryCode → Country. Populated on first full country fetch. */
16
16
  countryCache = new Map();
17
+ /** True once the full country list has been fetched and cached — avoids re-fetching on every getCountries() call. */
18
+ countriesFullyCached = false;
19
+ /** In-memory cache: ISO2 countryCode → full sorted PostalCode[]. Populated by getAllPostalCodes(). */
20
+ postalCodeCache = new Map();
17
21
  constructor(options) {
18
22
  this.httpService = new http_service_js_1.HttpService(options.usernames);
19
23
  this.restrictions = options.restrictions ?? {};
@@ -30,13 +34,12 @@ class GeoLocationClient {
30
34
  async getCountries(options) {
31
35
  const continents = options?.continents ?? this.restrictions.continents;
32
36
  const countryCodes = options?.countryCodes ?? this.restrictions.countries;
33
- let countries;
37
+ // Always fetch the full list (single request, result is cached).
38
+ // Client-side continent filtering is more reliable than depending on GeoNames
39
+ // honouring the `continent` query parameter for countryInfoJSON.
40
+ let countries = await this.fetchAllCountries();
34
41
  if (continents && continents.length > 0) {
35
- const results = await Promise.all(continents.map((continent) => this.fetchCountriesByContinent(continent)));
36
- countries = results.flat();
37
- }
38
- else {
39
- countries = await this.fetchAllCountries();
42
+ countries = countries.filter((c) => continents.includes(c.continent));
40
43
  }
41
44
  if (countryCodes && countryCodes.length > 0) {
42
45
  const upper = countryCodes.map((c) => c.toUpperCase());
@@ -193,10 +196,10 @@ class GeoLocationClient {
193
196
  *
194
197
  * Because GeoNames requires at least one search term, a bare country-only
195
198
  * query always returns status 15. This method works around that constraint
196
- * transparently: it fires one request per character prefix (up to 36), each
197
- * with `postalcode_startsWith=<char>`, and pages through results in chunks
198
- * of 1 000 until the bucket is exhausted. Prefixes that yield no results
199
- * (status 15 or empty page) are skipped automatically.
199
+ * transparently: it fires all 36 prefix bucket requests in **parallel**,
200
+ * each with `postalcode_startsWith=<char>`. Within each bucket, pages are
201
+ * fetched sequentially until exhausted. This cuts total wall-clock time from
202
+ * `sum(bucket times)` down to `max(bucket time)`.
200
203
  *
201
204
  * The results are deduplicated and returned as a single flat array sorted by
202
205
  * postal code.
@@ -204,51 +207,97 @@ class GeoLocationClient {
204
207
  * **API credit cost**: roughly 1 request per 1 000 postal codes in the
205
208
  * country, spread across up to 36 prefix buckets. For small countries
206
209
  * (< 5 000 codes) expect 10–20 requests; for large ones (US ~43 000) expect
207
- * ~80 requests.
210
+ * ~80 requests — all fired concurrently.
208
211
  *
209
212
  * @param countryCode - ISO 3166-1 alpha-2 country code, e.g. `"US"`.
210
213
  */
211
214
  async getAllPostalCodes(countryCode, options) {
212
215
  this.assertNonEmptyCountryCode(countryCode);
213
216
  const upper = countryCode.toUpperCase();
217
+ // Return cached result immediately — zero API requests on repeat calls.
218
+ if (this.postalCodeCache.has(upper)) {
219
+ const cached = this.postalCodeCache.get(upper);
220
+ if (options?.onProgress)
221
+ options.onProgress(cached.slice());
222
+ return cached.slice();
223
+ }
214
224
  const countryName = await this.resolveCountryName(upper);
225
+ // ── Fast path: try a single unfiltered request first ──────────────────────
226
+ // GeoNames accepts bare country queries for countries with small datasets
227
+ // (roughly < 1 000 postal codes). When the response is smaller than
228
+ // MAX_ROWS_LIMIT we have the complete set in one request instead of 36.
229
+ // For large countries GeoNames returns status 15 — we fall through to the
230
+ // parallel prefix-bucket strategy in that case.
231
+ try {
232
+ const raw = await this.httpService.get(`${api_constants_js_1.GEONAMES_BASE_URL}${api_constants_js_1.GEONAMES_ENDPOINTS.POSTAL_CODE_SEARCH}`, { country: upper, maxRows: feature_constants_js_1.MAX_ROWS_LIMIT });
233
+ if (raw.postalCodes.length < feature_constants_js_1.MAX_ROWS_LIMIT) {
234
+ // Got the complete set in a single request.
235
+ const result = raw.postalCodes.map((pc) => (0, postal_code_mapper_js_1.mapPostalCode)(pc, countryName));
236
+ result.sort((a, b) => a.postalCode.localeCompare(b.postalCode, undefined, { numeric: true }));
237
+ this.postalCodeCache.set(upper, result);
238
+ if (options?.onProgress)
239
+ options.onProgress(result.slice());
240
+ return result.slice();
241
+ }
242
+ // Exactly MAX_ROWS_LIMIT results — dataset is larger than one page.
243
+ // Fall through to prefix iteration.
244
+ }
245
+ catch (err) {
246
+ if (!(err instanceof geo_location_error_js_1.GeoLocationError && err.code === api_constants_js_1.GEONAMES_ERROR_CODES.NO_RESULT_FOUND)) {
247
+ throw err; // unexpected error
248
+ }
249
+ // status 15: country dataset is too large for bare query — use prefix iteration.
250
+ }
251
+ // ── Slow path: parallel prefix-bucket iteration ───────────────────────────
215
252
  const PREFIXES = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
216
253
  const seen = new Set();
217
254
  const all = [];
218
- for (const prefix of PREFIXES) {
219
- let startRow = 0;
220
- while (true) {
221
- let page;
222
- try {
223
- const raw = await this.httpService.get(`${api_constants_js_1.GEONAMES_BASE_URL}${api_constants_js_1.GEONAMES_ENDPOINTS.POSTAL_CODE_SEARCH}`, { country: upper, postalcode_startsWith: prefix, maxRows: feature_constants_js_1.MAX_ROWS_LIMIT, startRow });
224
- page = raw.postalCodes.map((pc) => (0, postal_code_mapper_js_1.mapPostalCode)(pc, countryName));
255
+ // Fire all prefix buckets in parallel. Each bucket handles its own pagination.
256
+ // onProgress is called (in completion order) as each bucket resolves.
257
+ const bucketPromises = PREFIXES.map((prefix) => this.fetchAllForPrefix(upper, prefix, countryName).then((bucket) => {
258
+ for (const pc of bucket) {
259
+ const key = `${pc.postalCode}|${pc.placeName}|${pc.adminName1}`;
260
+ if (!seen.has(key)) {
261
+ seen.add(key);
262
+ all.push(pc);
225
263
  }
226
- catch (err) {
227
- if (err instanceof geo_location_error_js_1.GeoLocationError && err.code === api_constants_js_1.GEONAMES_ERROR_CODES.NO_RESULT_FOUND) {
228
- break; // this prefix has no results — move on
229
- }
230
- throw err;
231
- }
232
- for (const pc of page) {
233
- const key = `${pc.postalCode}|${pc.placeName}|${pc.adminName1}`;
234
- if (!seen.has(key)) {
235
- seen.add(key);
236
- all.push(pc);
237
- }
238
- }
239
- if (page.length < feature_constants_js_1.MAX_ROWS_LIMIT)
240
- break; // last page for this prefix
241
- startRow += feature_constants_js_1.MAX_ROWS_LIMIT;
242
264
  }
243
- // Notify caller of progress after each prefix bucket
244
265
  if (options?.onProgress && all.length > 0) {
245
266
  all.sort((a, b) => a.postalCode.localeCompare(b.postalCode, undefined, { numeric: true }));
246
267
  options.onProgress(all.slice());
247
268
  }
248
- }
269
+ }));
270
+ await Promise.all(bucketPromises);
249
271
  all.sort((a, b) => a.postalCode.localeCompare(b.postalCode, undefined, { numeric: true }));
272
+ this.postalCodeCache.set(upper, all);
250
273
  return all;
251
274
  }
275
+ /**
276
+ * Fetches all postal codes for a single alphanumeric prefix bucket,
277
+ * paginating until exhausted.
278
+ */
279
+ async fetchAllForPrefix(countryCode, prefix, countryName) {
280
+ const bucket = [];
281
+ let startRow = 0;
282
+ while (true) {
283
+ let page;
284
+ try {
285
+ const raw = await this.httpService.get(`${api_constants_js_1.GEONAMES_BASE_URL}${api_constants_js_1.GEONAMES_ENDPOINTS.POSTAL_CODE_SEARCH}`, { country: countryCode, postalcode_startsWith: prefix, maxRows: feature_constants_js_1.MAX_ROWS_LIMIT, startRow });
286
+ page = raw.postalCodes.map((pc) => (0, postal_code_mapper_js_1.mapPostalCode)(pc, countryName));
287
+ }
288
+ catch (err) {
289
+ if (err instanceof geo_location_error_js_1.GeoLocationError && err.code === api_constants_js_1.GEONAMES_ERROR_CODES.NO_RESULT_FOUND) {
290
+ break; // this prefix has no results — done
291
+ }
292
+ throw err;
293
+ }
294
+ bucket.push(...page);
295
+ if (page.length < feature_constants_js_1.MAX_ROWS_LIMIT)
296
+ break; // last page for this prefix
297
+ startRow += feature_constants_js_1.MAX_ROWS_LIMIT;
298
+ }
299
+ return bucket;
300
+ }
252
301
  /**
253
302
  * Returns one page of postal codes for a country, working around the GeoNames
254
303
  * per-country query limit that prevents retrieving all codes for large
@@ -356,15 +405,13 @@ class GeoLocationClient {
356
405
  // Private helpers
357
406
  // ---------------------------------------------------------------------------
358
407
  async fetchAllCountries() {
408
+ if (this.countriesFullyCached) {
409
+ return Array.from(this.countryCache.values());
410
+ }
359
411
  const raw = await this.httpService.get(`${api_constants_js_1.GEONAMES_BASE_URL}${api_constants_js_1.GEONAMES_ENDPOINTS.COUNTRY_INFO}`, {});
360
412
  const countries = raw.geonames.map(country_mapper_js_1.mapCountry);
361
413
  this.cacheCountries(countries);
362
- return countries;
363
- }
364
- async fetchCountriesByContinent(continent) {
365
- const raw = await this.httpService.get(`${api_constants_js_1.GEONAMES_BASE_URL}${api_constants_js_1.GEONAMES_ENDPOINTS.COUNTRY_INFO}`, { continent });
366
- const countries = raw.geonames.map(country_mapper_js_1.mapCountry);
367
- this.cacheCountries(countries);
414
+ this.countriesFullyCached = true;
368
415
  return countries;
369
416
  }
370
417
  cacheCountries(countries) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardslashns/fws-geo-location-api",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "FWS company dedicated TypeScript wrapper around the GeoNames geolocation API. Provides countries, regions, cities, and postal codes with username rotation and restriction support.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",