@k03mad/ip2geo 1.0.0

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/.editorconfig ADDED
@@ -0,0 +1,12 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ indent_size = 4
7
+ indent_style = space
8
+ insert_final_newline = true
9
+ trim_trailing_whitespace = true
10
+
11
+ [*.json]
12
+ indent_size = 2
@@ -0,0 +1,10 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "npm"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "monthly"
7
+ - package-ecosystem: "github-actions"
8
+ directory: "/"
9
+ schedule:
10
+ interval: "daily"
@@ -0,0 +1,32 @@
1
+ name: Lint
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ pull_request:
8
+ branches:
9
+ - master
10
+ schedule:
11
+ - cron: '00 15 * * 6'
12
+
13
+ jobs:
14
+ lint:
15
+ name: Lint
16
+ permissions:
17
+ contents: read
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - name: Checkout
21
+ uses: actions/checkout@v4
22
+
23
+ - name: Install NodeJS
24
+ uses: actions/setup-node@v4
25
+ with:
26
+ node-version-file: '.nvmrc'
27
+
28
+ - name: Run setup
29
+ run: npm run setup
30
+
31
+ - name: Run lint
32
+ run: npm run lint
@@ -0,0 +1,32 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ pull_request:
8
+ branches:
9
+ - master
10
+ schedule:
11
+ - cron: '00 15 * * 6'
12
+
13
+ jobs:
14
+ test:
15
+ name: Test
16
+ permissions:
17
+ contents: read
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - name: Checkout
21
+ uses: actions/checkout@v4
22
+
23
+ - name: Install NodeJS
24
+ uses: actions/setup-node@v4
25
+ with:
26
+ node-version-file: '.nvmrc'
27
+
28
+ - name: Run setup
29
+ run: npm run setup
30
+
31
+ - name: Run test
32
+ run: npm run test
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env sh
2
+ . "$(dirname -- "$0")/_/husky.sh"
3
+
4
+ npm run lint && npm run test
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 20
@@ -0,0 +1,3 @@
1
+ {
2
+ "eslint.experimental.useFlatConfig": true
3
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Kirill Molchanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # GeoIP lookup
2
+
3
+ — Using [ipwhois.io](https://ipwhois.io/documentation) \
4
+ — Runtime cache \
5
+ — Filesystem infinity cache
6
+
7
+ ## API
8
+
9
+ ```bash
10
+ npm i @k03mad/ip2geo --save-exact
11
+ echo "/geoip" >> .gitignore
12
+ ```
13
+
14
+ ```js
15
+ import {ip2geo} from '@k03mad/ip2geo';
16
+
17
+ const {
18
+ ip,
19
+ emoji,
20
+ country,
21
+ countryA2,
22
+ city,
23
+ isp,
24
+ } = await ip2geo('1.1.1.1', {
25
+ // defaults
26
+ cacheDir: 'geoip',
27
+ cacheFileName: 'ip.log',
28
+ cacheFileSeparator: ';;',
29
+ cacheFileNewline: '\n',
30
+ });
31
+
32
+ // ip: "1.1.1.1"
33
+ // emoji: "🇺🇸"
34
+ // country: "United States"
35
+ // countryA2: "US"
36
+ // city: "Washington"
37
+ // isp: "Cloudflare, Inc."
38
+ ```
package/app/index.js ADDED
@@ -0,0 +1 @@
1
+ export {default as ip2geo} from './lib/ip2geo.js';
@@ -0,0 +1,164 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import {request} from '@k03mad/request';
5
+ import _debug from 'debug';
6
+
7
+ const debug = _debug('mad:geoip');
8
+
9
+ /**
10
+ * @typedef {object} GeoIpOutput
11
+ * @property {string} [ip]
12
+ * @property {string} [emoji]
13
+ * @property {string} [country]
14
+ * @property {string} [countryA2]
15
+ * @property {string} [city]
16
+ * @property {string} [isp]
17
+ */
18
+
19
+ const API = 'https://ipwho.is/';
20
+
21
+ const CACHE_FILE_DIR = 'geoip';
22
+ const CACHE_FILE_NAME = 'ip.log';
23
+ const CACHE_FILE_SEPARATOR = ';;';
24
+ const CACHE_FILE_NEWLINE = '\n';
25
+
26
+ const cacheMap = new Map();
27
+
28
+ const outputKeys = [
29
+ 'ip',
30
+ 'emoji',
31
+ 'country',
32
+ 'countryA2',
33
+ 'city',
34
+ 'isp',
35
+ ];
36
+
37
+ /**
38
+ * @param {Array<string>} dataArr
39
+ * @returns {GeoIpOutput}
40
+ */
41
+ const collectOutputData = dataArr => {
42
+ const outputData = {};
43
+
44
+ outputKeys.forEach((key, i) => {
45
+ outputData[key] = dataArr[i];
46
+ });
47
+
48
+ return outputData;
49
+ };
50
+
51
+ /**
52
+ * @param {string} ip
53
+ * @param {string} cacheDir
54
+ * @param {string} cacheFileName
55
+ * @returns {string}
56
+ */
57
+ const getCacheFileFullPath = (ip, cacheDir, cacheFileName) => {
58
+ const [firstOctet] = ip.split('.');
59
+ return path.join(cacheDir, `${firstOctet}_${cacheFileName}`);
60
+ };
61
+
62
+ /**
63
+ * @param {string} ip
64
+ * @param {string} cacheDir
65
+ * @param {string} cacheFileName
66
+ * @returns {Promise<string>}
67
+ */
68
+ const readFromFsCache = async (ip, cacheDir, cacheFileName) => {
69
+ try {
70
+ await fs.mkdir(cacheDir, {recursive: true});
71
+ return await fs.readFile(getCacheFileFullPath(ip, cacheDir, cacheFileName), {encoding: 'utf8'});
72
+ } catch (err) {
73
+ if (err.code === 'ENOENT') {
74
+ await fs.appendFile(getCacheFileFullPath(ip, cacheDir, cacheFileName), '');
75
+ } else {
76
+ throw err;
77
+ }
78
+ }
79
+ };
80
+
81
+ /**
82
+ * @param {string} ip
83
+ * @param {Array[string]} data
84
+ * @param {string} cacheDir
85
+ * @param {string} cacheFileName
86
+ * @param {string} cacheFileSeparator
87
+ * @param {string} cacheFileNewline
88
+ * @returns {Promise<void>}
89
+ */
90
+ const writeToFsCache = async (ip, data, cacheDir, cacheFileName, cacheFileSeparator, cacheFileNewline) => {
91
+ await fs.mkdir(cacheDir, {recursive: true});
92
+
93
+ await fs.appendFile(
94
+ getCacheFileFullPath(ip, cacheDir, cacheFileName),
95
+ data.join(cacheFileSeparator) + cacheFileNewline,
96
+ );
97
+ };
98
+
99
+ /**
100
+ * @param {string} [ip]
101
+ * @param {object} [opts]
102
+ * @param {string} [opts.cacheDir]
103
+ * @param {string} [opts.cacheFileName]
104
+ * @param {string} [opts.cacheFileSeparator]
105
+ * @param {string} [opts.cacheFileNewline]
106
+ * @returns {Promise<GeoIpOutput>}
107
+ */
108
+ export default async (ip = '', {
109
+ cacheDir = CACHE_FILE_DIR,
110
+ cacheFileName = CACHE_FILE_NAME,
111
+ cacheFileSeparator = CACHE_FILE_SEPARATOR,
112
+ cacheFileNewline = CACHE_FILE_NEWLINE,
113
+ } = {}) => {
114
+ if (ip) {
115
+ const ipData = cacheMap.get(ip);
116
+
117
+ if (ipData) {
118
+ debug('get from map cache: %o', ipData);
119
+ return ipData;
120
+ }
121
+
122
+ const fsCache = await readFromFsCache(ip, cacheDir, cacheFileName);
123
+
124
+ if (fsCache) {
125
+ const data = fsCache.split(cacheFileNewline);
126
+
127
+ for (const elem of data) {
128
+ const fileData = elem.split(cacheFileSeparator);
129
+
130
+ if (ip === fileData[0]) {
131
+ const outputData = collectOutputData(fileData);
132
+ debug('get from fs cache: %o', outputData);
133
+
134
+ cacheMap.set(ip, outputData);
135
+ return outputData;
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ const {body} = await request(API + ip);
142
+
143
+ const usedData = [
144
+ body.ip,
145
+ body?.flag?.emoji,
146
+ body?.country,
147
+ body?.country_code,
148
+ body?.city,
149
+ body?.connection?.isp,
150
+ ];
151
+
152
+ const outputData = collectOutputData(usedData);
153
+
154
+ cacheMap.set(body.ip, outputData);
155
+ debug('set to map cache: %o', outputData);
156
+
157
+ await writeToFsCache(
158
+ body.ip, usedData,
159
+ cacheDir, cacheFileName, cacheFileSeparator, cacheFileNewline,
160
+ );
161
+
162
+ debug('set to fs cache: %o', outputData);
163
+ return outputData;
164
+ };
@@ -0,0 +1 @@
1
+ export {default} from '@k03mad/eslint-config';
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@k03mad/ip2geo",
3
+ "version": "1.0.0",
4
+ "description": "GeoIP library",
5
+ "maintainers": [
6
+ "Kirill Molchanov <k03.mad@gmail.com"
7
+ ],
8
+ "main": "app/index.js",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/k03mad/ip2geo.git"
12
+ },
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "dependencies": {
19
+ "@k03mad/request": "5.4.1",
20
+ "debug": "4.3.4"
21
+ },
22
+ "devDependencies": {
23
+ "@k03mad/eslint-config": "19.2.0",
24
+ "eslint": "8.56.0",
25
+ "husky": "8.0.3"
26
+ },
27
+ "scripts": {
28
+ "lint": "npm run lint:eslint",
29
+ "lint:eslint": "eslint ./ --cache",
30
+ "test": "node --test tests/*",
31
+ "clean": "npm run clean:modules && npm run clean:eslint:cache",
32
+ "clean:modules": "rm -rf ./node_modules || true",
33
+ "clean:eslint:cache": "rm -rf .eslintcache || true",
34
+ "setup": "npm run clean && npm i",
35
+ "prepare": "husky install || true"
36
+ }
37
+ }
@@ -0,0 +1,44 @@
1
+ import assert from 'node:assert/strict';
2
+ import {describe, it} from 'node:test';
3
+
4
+ import {ip2geo} from '../app/index.js';
5
+
6
+ import {checkCacheFile, removeCacheFolder} from './shared/fs.js';
7
+
8
+ describe('opts-assigned', () => {
9
+ const CACHE_FILE_DIR = 'geoip-subfolder/hello_there';
10
+ const CACHE_FILE_NAME = 'ip.log';
11
+ const CACHE_FILE_SEPARATOR = ';;';
12
+ const CACHE_FILE_NEWLINE = '\n';
13
+
14
+ const REQUEST_IP = '9.9.9.9';
15
+
16
+ const cacheFile = `${REQUEST_IP.split('.')[0]}_${CACHE_FILE_NAME}`;
17
+
18
+ const response = {
19
+ ip: REQUEST_IP,
20
+ emoji: '🇨🇭',
21
+ country: 'Switzerland',
22
+ countryA2: 'CH',
23
+ city: 'Zürich',
24
+ isp: 'Quad9',
25
+ };
26
+
27
+ removeCacheFolder(CACHE_FILE_DIR);
28
+
29
+ it(`should return correct response for IP: "${REQUEST_IP}"`, async () => {
30
+ const data = await ip2geo(REQUEST_IP, {
31
+ cacheDir: CACHE_FILE_DIR,
32
+ });
33
+
34
+ assert.deepEqual(data, response);
35
+ });
36
+
37
+ checkCacheFile(
38
+ CACHE_FILE_DIR,
39
+ cacheFile,
40
+ CACHE_FILE_SEPARATOR,
41
+ CACHE_FILE_NEWLINE,
42
+ response,
43
+ );
44
+ });
@@ -0,0 +1,47 @@
1
+ import assert from 'node:assert/strict';
2
+ import {describe, it} from 'node:test';
3
+
4
+ import {ip2geo} from '../app/index.js';
5
+
6
+ import {checkCacheFile, removeCacheFolder} from './shared/fs.js';
7
+
8
+ describe('opts-assigned', () => {
9
+ const CACHE_FILE_DIR = 'geoip-cache-opts';
10
+ const CACHE_FILE_NAME = 'ips.md';
11
+ const CACHE_FILE_SEPARATOR = '-_-';
12
+ const CACHE_FILE_NEWLINE = '%%%';
13
+
14
+ const REQUEST_IP = '8.8.8.8';
15
+
16
+ const cacheFile = `${REQUEST_IP.split('.')[0]}_${CACHE_FILE_NAME}`;
17
+
18
+ const response = {
19
+ ip: REQUEST_IP,
20
+ emoji: '🇺🇸',
21
+ country: 'United States',
22
+ countryA2: 'US',
23
+ city: 'Mountain View',
24
+ isp: 'Google LLC',
25
+ };
26
+
27
+ removeCacheFolder(CACHE_FILE_DIR);
28
+
29
+ it(`should return correct response for IP: "${REQUEST_IP}"`, async () => {
30
+ const data = await ip2geo(REQUEST_IP, {
31
+ cacheDir: CACHE_FILE_DIR,
32
+ cacheFileName: CACHE_FILE_NAME,
33
+ cacheFileSeparator: CACHE_FILE_SEPARATOR,
34
+ cacheFileNewline: CACHE_FILE_NEWLINE,
35
+ });
36
+
37
+ assert.deepEqual(data, response);
38
+ });
39
+
40
+ checkCacheFile(
41
+ CACHE_FILE_DIR,
42
+ cacheFile,
43
+ CACHE_FILE_SEPARATOR,
44
+ CACHE_FILE_NEWLINE,
45
+ response,
46
+ );
47
+ });
@@ -0,0 +1,67 @@
1
+ import assert from 'node:assert/strict';
2
+ import {describe, it} from 'node:test';
3
+
4
+ import {ip2geo} from '../app/index.js';
5
+
6
+ import {checkCacheFile, removeCacheFolder} from './shared/fs.js';
7
+
8
+ describe('opts-default', () => {
9
+ const CACHE_FILE_DIR = 'geoip';
10
+ const CACHE_FILE_NAME = 'ip.log';
11
+ const CACHE_FILE_SEPARATOR = ';;';
12
+ const CACHE_FILE_NEWLINE = '\n';
13
+
14
+ const REQUEST_IP = '1.1.1.1';
15
+
16
+ const cacheFile = `${REQUEST_IP.split('.')[0]}_${CACHE_FILE_NAME}`;
17
+
18
+ const response = {
19
+ ip: REQUEST_IP,
20
+ emoji: '🇺🇸',
21
+ country: 'United States',
22
+ countryA2: 'US',
23
+ city: 'Washington',
24
+ isp: 'Cloudflare, Inc.',
25
+ };
26
+
27
+ const outputKeys = [
28
+ 'ip',
29
+ 'emoji',
30
+ 'country',
31
+ 'countryA2',
32
+ 'city',
33
+ 'isp',
34
+ ];
35
+
36
+ removeCacheFolder(CACHE_FILE_DIR);
37
+
38
+ describe('with ip arg', () => {
39
+ it(`should return correct response for IP: "${REQUEST_IP}"`, async () => {
40
+ const data = await ip2geo(REQUEST_IP);
41
+
42
+ assert.deepEqual(data, response);
43
+ });
44
+
45
+ checkCacheFile(
46
+ CACHE_FILE_DIR,
47
+ cacheFile,
48
+ CACHE_FILE_SEPARATOR,
49
+ CACHE_FILE_NEWLINE,
50
+ response,
51
+ );
52
+ });
53
+
54
+ describe('without ip arg', () => {
55
+ let data;
56
+
57
+ it('should request geoip without ip arg', async () => {
58
+ data = await ip2geo();
59
+ });
60
+
61
+ outputKeys.forEach(key => {
62
+ it(`should have "${key}" in request response`, () => {
63
+ assert.ok(data[key]);
64
+ });
65
+ });
66
+ });
67
+ });
@@ -0,0 +1,34 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {it} from 'node:test';
5
+
6
+ /**
7
+ * @param {string} cacheDir
8
+ */
9
+ export const removeCacheFolder = cacheDir => {
10
+ it('should remove fs cache dir if exist', async () => {
11
+ try {
12
+ await fs.rm(cacheDir, {recursive: true, force: true});
13
+ } catch (err) {
14
+ if (err.code !== 'ENOENT') {
15
+ throw err;
16
+ }
17
+ }
18
+ });
19
+ };
20
+
21
+ /**
22
+ * @param {string} cacheDir
23
+ * @param {string} cacheFileName
24
+ * @param {string} cacheFileSeparator
25
+ * @param {string} cacheFileNewline
26
+ * @param {object} response
27
+ */
28
+ export const checkCacheFile = (cacheDir, cacheFileName, cacheFileSeparator, cacheFileNewline, response) => {
29
+ it('should have cache file', async () => {
30
+ const data = await fs.readFile(path.join(cacheDir, cacheFileName), {encoding: 'utf8'});
31
+
32
+ assert.equal(data, Object.values(response).join(cacheFileSeparator) + cacheFileNewline);
33
+ });
34
+ };