plevinjs 0.1.11 β†’ 0.2.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.
package/README.md CHANGED
@@ -7,124 +7,113 @@
7
7
  </a>
8
8
 
9
9
  **Location, network and abuse information for any IP address in one offline file.**<br>
10
- No API, no rate limit, no lookup leaving the machine, or the browser tab.
10
+ No API, no rate limit, no lookup leaving the machine or the browser tab.
11
11
 
12
12
  [![npm](https://img.shields.io/npm/v/plevinjs?color=1868f2)](https://www.npmjs.com/package/plevinjs)
13
13
  [![Types](https://img.shields.io/badge/types-included-1868f2)](https://www.npmjs.com/package/plevinjs?activeTab=code)
14
14
  [![License](https://img.shields.io/badge/license-Apache--2.0-1868f2)](https://github.com/tn3w/plevin/blob/master/LICENSE)
15
- [![Fields](https://img.shields.io/badge/fields-101-6f42c1)](#every-field)
16
- [![Boundaries](https://img.shields.io/badge/boundaries-3.0M-6f42c1)](https://github.com/tn3w/plevin/blob/master/README.md#data)
17
- [![Warm](https://img.shields.io/badge/warm%20lookups-4M%2Fs-2ea043)](#speed)
15
+ [![Fields](https://img.shields.io/badge/fields-101-6f42c1)](#fields)
16
+ [![Warm](https://img.shields.io/badge/warm%20lookups-5M%2Fs-2ea043)](#speed)
18
17
 
19
18
  </div>
20
19
 
20
+ ## Quick start
21
+
21
22
  ```bash
22
23
  npm install plevinjs
23
24
  ```
24
25
 
25
26
  ```js
26
- import { Plevin, open } from "plevinjs";
27
+ import { open } from "plevinjs";
27
28
 
28
29
  const db = await open("https://plevin.tn3w.dev/db/plevin.plv");
29
- const found = db.lookup("1.1.1.1"); // string, number, bigint or packed bytes
30
- ```
31
-
32
- Always a `Result`, never `undefined`; it throws for anything that is not an address, and
33
- a number reads as v6 only above `0xffffffff`, so `lookup(1)` is `0.0.0.1`.
30
+ const found = db.lookup("1.1.1.1"); // string, number, bigint or bytes
34
31
 
35
- ```js
36
32
  found.place.city.name; // 'Brisbane'
37
- found.place.city.region.name; // 'Queensland'
38
- found.place.country.name; // 'Australia'
39
33
  found.place.time.local; // '2026-08-13T23:27:58+10:00'
40
-
41
- found.network.asn; // 13335
42
34
  found.network.operator.brand; // 'Cloudflare'
43
35
  found.network.cidr; // '1.1.1.0/24'
44
-
45
- const exit = db.lookup("185.220.101.1");
46
- exit.abuse.service; // 'tor_exit_node'
47
- exit.abuse.risk; // 0.98
48
- exit.abuse.is_tor_exit_node; // true
36
+ db.lookup("185.220.101.1").abuse.service; // 'tor_exit_node'
49
37
  ```
50
38
 
51
- Pure ESM with no dependencies, so it runs wherever fetch does: Node, Deno, Bun,
52
- Cloudflare Workers, and any browser off a CDN.
39
+ - `lookup` always answers a `Result` and throws for non-addresses.
40
+ - Numbers up to `0xffffffff` are v4, so `lookup(1)` is `0.0.0.1`.
41
+ - Pure ESM, zero dependencies: Node, Deno, Bun, Cloudflare Workers, browsers.
53
42
 
54
- ```html
55
- <script type="module">
56
- import { open } from "https://cdn.jsdelivr.net/npm/plevinjs";
57
-
58
- const db = await open("https://plevin.tn3w.dev/db/plevin.place-country-code.plv");
59
- const { flag, name } = db.lookup("8.8.8.8").place.country;
60
- document.body.textContent = `${flag} ${name}`; // 'πŸ‡ΊπŸ‡Έ United States'
61
- </script>
62
- ```
43
+ ## Opening a database
63
44
 
64
- That build is 390 KB, the country code and nothing else, and the name and flag are
65
- derived in the reader. `https://esm.sh/plevinjs` serves the same thing. Every database is rehosted with open
66
- CORS at [plevin.tn3w.dev/db](https://plevin.tn3w.dev/db/), because GitHub release
67
- downloads send no CORS header.
45
+ | where | how |
46
+ | --------------- | ---------------------------------------------------------- |
47
+ | browser, worker | `await open(url)` or `await open(response)` |
48
+ | Node, Deno, Bun | `openFile(path)` from `plevinjs/node`; `PLEVIN_DB` if no path |
49
+ | bytes in hand | `new Plevin(uint8Array)` |
68
50
 
69
- ## Where the file comes from
51
+ Open once, reuse for every lookup. Nothing is downloaded or cached for you.
70
52
 
71
- | where it runs | how to open it |
72
- | --- | --- |
73
- | browser, worker | `await open(url)`, or `await open(response)` |
74
- | Node, Deno, Bun | `import { openFile } from "plevinjs/node"`, then `await openFile(path)` |
75
- | bytes you hold | `new Plevin(bytes)`, taking a `Uint8Array` |
53
+ | file | size | carries |
54
+ | ----------------------------------------------------- | ------- | ---------------------------------------- |
55
+ | `plevin.plv` | 17.3 MB | every field |
56
+ | `plevin.metro-place.plv` | 5.4 MB | city, region, postal, coordinates, metro |
57
+ | `plevin.network.plv` | 7.0 MB | ASN, operator, routing |
58
+ | `plevin.abuse-level-abuse-provider-abuse-service.plv` | 4.1 MB | abuse level, service and provider |
59
+ | `plevin.place-country-code.plv` | 378 KB | country code |
76
60
 
77
- `openFile()` reads `PLEVIN_DB` where no path is given. Nothing is downloaded for you
78
- and nothing is cached for you: hand the same `Plevin` to every lookup and the file is
79
- read once.
61
+ All are served with open CORS from [plevin.tn3w.dev/db](https://plevin.tn3w.dev/db/);
62
+ GitHub release downloads send no CORS header.
80
63
 
81
- | file | size | carries |
82
- | --- | --- | --- |
83
- | `plevin.plv` | 17.3 MB | every field |
84
- | `plevin.metro-place.plv` | 5.7 MB | city, region, postal, coordinates, metro |
85
- | `plevin.network.plv` | 7.3 MB | ASN, operator, routing |
86
- | `plevin.abuse-level-abuse-provider-abuse-service.plv` | 3.5 MB | abuse level, service and provider |
87
- | `plevin.place-country-code.plv` | 390 KB | the country code |
64
+ ## In a browser
88
65
 
89
- ## The same answers over HTTP
66
+ No build step:
90
67
 
91
- Where a file is one dependency too many, the reader runs on a Cloudflare Worker at
92
- [plevin.tn3w.dev/api](https://plevin.tn3w.dev/api/1.1.1.1) and returns the same JSON,
93
- field for field, with no key and CORS open to every origin.
68
+ ```html
69
+ <script type="module">
70
+ import { open } from "https://cdn.jsdelivr.net/npm/plevinjs";
94
71
 
95
- ```bash
96
- curl https://plevin.tn3w.dev/api/1.1.1.1 # any address
97
- curl https://plevin.tn3w.dev/api/me # the caller's own
98
- curl https://plevin.tn3w.dev/api/about # the build and its fields
72
+ const db = await open("https://plevin.tn3w.dev/db/plevin.place-country-code.plv");
73
+ const { flag, name } = db.lookup("8.8.8.8").place.country;
74
+ document.body.textContent = `${flag} ${name}`; // 'πŸ‡ΊπŸ‡Έ United States'
75
+ </script>
99
76
  ```
100
77
 
101
- [`worker/`](https://github.com/tn3w/plevin/blob/master/worker) is that worker, ready to
102
- run on an account of your own.
78
+ | URL | serves |
79
+ | ---------------------------------------------- | --------------------------------- |
80
+ | `https://cdn.jsdelivr.net/npm/plevinjs` | `plevin.min.js`, one 52 kB file |
81
+ | `https://unpkg.com/plevinjs` | the same |
82
+ | `https://esm.sh/plevinjs` | the modules, imports rewritten |
83
+ | `https://plevin.tn3w.dev/plevin/plevin.min.js` | the bundle beside the databases |
84
+
85
+ - **Pin a version for production:** `cdn.jsdelivr.net/npm/plevinjs@0.2.1`.
86
+ - **Pick the smallest build:** the country build is 378 KB against 17.3 MB.
87
+ - **Cache the file** so it downloads once per visitor:
88
+
89
+ ```js
90
+ const store = await caches.open("plevin");
91
+ const url = "https://plevin.tn3w.dev/db/plevin.plv";
92
+ if (!(await store.match(url))) await store.add(url);
93
+ const db = new Plevin(new Uint8Array(await (await store.match(url)).arrayBuffer()));
94
+ ```
103
95
 
104
- ## Every field
96
+ ## Fields
105
97
 
106
98
  <picture>
107
99
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/tn3w/plevin/master/.github/fields-dark.png">
108
100
  <img src="https://raw.githubusercontent.com/tn3w/plevin/master/.github/fields-light.png" width="840" alt="address to place, network and abuse">
109
101
  </picture>
110
102
 
111
- `found.place` is where the address is, `found.network` who announces it, `found.abuse`
112
- what has been seen from it. Any of the three is `null` where the build carries none of
113
- it, and every leaf is `null` rather than `""` or `0` where a source says nothing. The
114
- shapes and the field names are the ones the
115
- [Python package](https://github.com/tn3w/plevin/blob/master/python/README.md#every-field)
116
- answers with, to the letter.
103
+ Identical to the
104
+ [Python package](https://github.com/tn3w/plevin/blob/master/python/README.md#fields),
105
+ field for field; see there for what each one means. A missing value is `null`, never
106
+ `""` or `0`.
117
107
 
118
108
  ```js
109
+ found.place;
119
110
  {
120
- lat: -27.4675, lon: 153.0281, accuracy: 200, confidence: 36,
121
- granularity: 'city',
111
+ lat: -27.4675, lon: 153.0281, accuracy: 200, confidence: 36, granularity: 'city',
122
112
  city: {
123
113
  id: 2174003, name: 'Brisbane', ascii: 'Brisbane', country: 'AU',
124
114
  population: 2780063, elevation: 27, postal: '4000', postal_partial: null,
125
115
  timezone: 'Australia/Brisbane', type: 'regional capital', capital: 'region',
126
- region: { id: 2152274, code: '04', iso: 'AU-QLD', name: 'Queensland',
127
- type: 'State' },
116
+ region: { id: 2152274, code: '04', iso: 'AU-QLD', name: 'Queensland', type: 'State' },
128
117
  district: { id: 7839562, code: '31000', name: 'Brisbane' },
129
118
  metro: null,
130
119
  },
@@ -138,14 +127,8 @@ answers with, to the letter.
138
127
  dst_start: null, dst_end: null,
139
128
  },
140
129
  }
141
- ```
142
-
143
- `country` and `time` are derived, not stored: `country` out of an ISO 3166 table in the
144
- package, `time` out of the runtime's own `Intl` zone data, so neither needs an install
145
- and neither is a network call. Where the host's zone data is older or newer than the
146
- one the Python package reads, a daylight boundary may move by a transition.
147
130
 
148
- ```js
131
+ found.network;
149
132
  {
150
133
  asn: 13335, handle: 'CLOUDFLARENET', prefix: 24, cidr: '1.1.1.0/24',
151
134
  start: '1.1.1.0', end: '1.1.1.255', rir: 'apnic', rpki: 'valid', roas: 1,
@@ -159,278 +142,151 @@ one the Python package reads, a daylight boundary may move by a transition.
159
142
  carrier: { user_type: 'hosting', user_count: 19, mcc: null, mnc: null,
160
143
  is_mobile: false },
161
144
  }
162
- ```
163
-
164
- `cidr` is the announcement the address falls in, masked out of the address itself.
165
- `rir` is the registry that holds the address rather than the one that registered the
166
- ASN. `rpki` is `valid`, `invalid` or `unknown` and `roas` how many ROAs agree. `brand`
167
- drops the legal form and the words every network carries, so `GOOGLE` and `Google LLC`
168
- both read `Google`.
169
145
 
170
- Where nothing is announced, the registries still answer: `asn`, `rpki` and `roas` fall
171
- silent, `cidr` becomes the block a registry gave out, and `handle` and `operator` name
172
- whoever holds it.
173
-
174
- ```js
175
- db.lookup("36.50.238.1").network;
176
- { asn: null, handle: 'GMTECH-BD', cidr: '36.50.238.0/23', rir: 'apnic',
177
- operator: { company: 'GM Tech', ... }, ... }
178
- ```
179
-
180
- ```js
181
146
  db.lookup("185.220.101.1").abuse;
182
147
  {
183
148
  name: 'Tor', provider: 'Tor', service: 'tor_exit_node', evidence: 'measured',
184
- level: 'high', risk: 0.98, network_risk: 0.82, last_seen_days: 1,
185
- is_malicious: true, is_anycast: false,
186
- is_satellite: false, is_hosting_provider: true, is_proxy: false,
187
- is_public_proxy: false, is_residential_proxy: false, is_anonymous_vpn: false,
188
- is_tor_exit_node: true, is_private_relay: false, is_anonymous: true,
149
+ threat: 'spam', level: 'high', risk: 0.99, network_risk: 0.86, last_seen_days: 1,
150
+ is_malicious: true, is_anycast: false, is_satellite: false,
151
+ is_hosting_provider: true, is_proxy: false, is_public_proxy: false,
152
+ is_residential_proxy: false, is_anonymous_vpn: false, is_tor_exit_node: true,
153
+ is_private_relay: false, is_anonymous: true,
189
154
  }
190
155
  ```
191
156
 
192
- `level` is `low`, `medium` or `high`, the same reading in three steps: `low` from 0.40,
193
- `medium` from 0.60, `high` from 0.80, and `null` below that, where the reports are too
194
- thin to call. `is_malicious` is true wherever a level stands.
157
+ JavaScript specifics:
195
158
 
196
- `risk` is 0 to 1 for the address, `network_risk` the same for the whole ASN, `null`
197
- where nothing has ever been seen, so which is not a risk of zero. It is a total of what
198
- the service the address runs is worth on its own and what every feed that named it
199
- scored it, combined so each agreeing source raises the total and none of them replaces
200
- the rest, capped at 0.99. `provider` is who runs the service: the feed's `name` where
201
- one names it, else the brand of the network the address sits in, and `null` where the
202
- address runs no service at all.
159
+ - `country` comes from an ISO 3166 table in the package and `time` from the runtime's
160
+ `Intl` data, so neither needs an install or a network call. A different zone
161
+ database can move a daylight-saving boundary.
162
+ - `number` is a `bigint` for v6, so `JSON.stringify` needs a replacer.
203
163
 
204
- ## What an address says on its own
164
+ Address fields (`compressed`, `expanded`, `arpa`, `is_*`, `tunnel`, `embedded_ipv4`,
165
+ `decimal_ipv4`, `as_*`) work without any database.
205
166
 
206
- Answered without the database, so they hold for every address:
167
+ ## DNS
207
168
 
208
- ```js
209
- const found = db.lookup("2606:4700::1111");
210
- found.number; // 50543257672059871404715951523469725969n
211
- found.compressed // '2606:4700::1111'
212
- found.expanded; // '2606:4700:0000:0000:0000:0000:0000:1111'
213
- found.arpa; // '1.1.1.1.0.0.….0.7.4.6.0.6.2.ip6.arpa'
214
- ```
215
-
216
- `number` is a `bigint` for v6 and a `number` for v4, so `JSON.stringify` needs a
217
- replacer where you hand a v6 result on. Then `is_global` and `is_bogon`, `is_private`,
218
- `is_loopback`, `is_multicast`, `is_reserved`, `is_link_local`, `is_unique_local`,
219
- `is_documentation`, `is_shared` (100.64/10) and `is_benchmark` (198.18/15), bisected
220
- out of the IANA special-purpose registries.
169
+ `lookup` stays synchronous and offline. `resolve` adds DNS only when asked:
221
170
 
222
171
  ```js
223
- db.lookup("::ffff:8.8.8.8").tunnel; // 'ipv4-mapped'
224
- db.lookup("::ffff:8.8.8.8").embedded_ipv4; // '8.8.8.8'
225
- db.lookup("2002:808:808::1").is_6to4; // true
172
+ (await db.resolve("8.8.8.8", { dns: true })).dns;
173
+ {
174
+ asked: '8.8.8.8', hostname: 'dns.google', hostnames: ['dns.google'],
175
+ ipv4: '8.8.4.4', ipv6: '2001:4860:4860::8888',
176
+ ipv4_addresses: ['8.8.4.4', '8.8.8.8'],
177
+ ipv6_addresses: ['2001:4860:4860::8888', '2001:4860:4860::8844'],
178
+ alias: null, zone: '8.8.8.in-addr.arpa', zone_primary: 'ns1.google.com',
179
+ zone_contact: 'dns-admin@google.com', is_confirmed: true, is_signed: true,
180
+ }
226
181
  ```
227
182
 
228
- `tunnel` is `ipv4-mapped`, `6to4`, `teredo`, `nat64` or `null`.
183
+ - **Node, Deno, Bun:** raw DNS over UDP, raced across the system resolver plus
184
+ 1.1.1.1, 8.8.8.8 and 9.9.9.9, with TCP on truncation.
185
+ - **Browsers, workers:** DNS-over-HTTPS to Cloudflare and Google.
186
+ - **Cache:** answers are kept for one hour.
229
187
 
230
- ```js
231
- db.lookup("2001:67c:e60:c0c:192:42:116:55").decimal_ipv4; // '192.42.116.55'
232
- ```
233
-
234
- `decimal_ipv4` is a guess and never a tunnel: the last four hextets where an operator
235
- wrote a v4 address into them as decimal. It is `null` wherever a real tunnel answers.
188
+ ## Own address
236
189
 
237
190
  ```js
238
- db.lookup("8.8.8.8").as_ipv4_mapped; // '::ffff:8.8.8.8'
239
- db.lookup("8.8.8.8").as_6to4; // '2002:808:808::'
240
- db.lookup("8.8.8.8").as_nat64; // '64:ff9b::808:808'
241
- ```
191
+ import { publicAddress } from "plevinjs";
242
192
 
243
- `as_ipv4_mapped`, `as_6to4` and `as_nat64` write a v4 address the other way about, as
244
- the v6 addresses that carry it; all three are `null` for a v6 address, where
245
- `embedded_ipv4` already says what it carries.
246
-
247
- ## What DNS says, where you ask for it
248
-
249
- `lookup` never leaves the machine and stays synchronous. `resolve` is the same lookup
250
- with DNS behind a flag, and does nothing more than `lookup` unless the flag is set:
251
-
252
- ```js
253
- (await db.resolve("8.8.8.8", { dns: true })).dns;
254
- {
255
- asked: '8.8.8.8',
256
- hostname: 'dns.google',
257
- hostnames: [ 'dns.google' ],
258
- ipv4: '8.8.4.4',
259
- ipv6: '2001:4860:4860::8888',
260
- ipv4_addresses: [ '8.8.4.4', '8.8.8.8' ],
261
- ipv6_addresses: [ '2001:4860:4860::8888', '2001:4860:4860::8844' ],
262
- alias: null,
263
- zone: '8.8.8.in-addr.arpa',
264
- zone_primary: 'ns1.google.com',
265
- zone_contact: 'dns-admin@google.com',
266
- is_confirmed: true,
267
- is_signed: true,
268
- }
193
+ const own = await publicAddress(); // '203.0.113.42', or null
194
+ own && db.lookup(own);
269
195
  ```
270
196
 
271
- `hostname` is the first PTR name and `hostnames` all of them, `ipv4` and `ipv6` that
272
- name resolved forward with `ipv4_addresses` and `ipv6_addresses` all of those, so each
273
- address names its other half; `is_confirmed` says the name leads back to the address,
274
- which is forward-confirmed reverse DNS; `zone`, `zone_primary` and `zone_contact` come
275
- from the reverse zone's SOA, naming who runs the range; `is_signed` is the DNSSEC
276
- verdict and `alias` a CNAME in the way; `asked` is the address actually asked about,
277
- which for a tunnel is the v4 it carries. Four questions go out in two rounds, PTR and
278
- SOA on the reverse name together and then A and AAAA of the hostname: Node, Deno and
279
- Bun write those onto the wire themselves and send them to every server at once, so the
280
- machine's own from `node:dns` and 1.1.1.1, 8.8.8.8 and 9.9.9.9, so first real answer
281
- winning, TCP where one comes back truncated, while a browser or a worker, having no
282
- datagram, sends the same queries to Cloudflare and Google over DNS-over-HTTPS. Answers
283
- are kept for an hour, and nothing is asked where the flag is off, which keeps a bundled
284
- reader as offline as it was.
285
-
286
- ## One ASN, and the networks a name belongs to
197
+ - **How:** one STUN binding request, answered in tens of milliseconds.
198
+ - **Browsers:** go through `RTCPeerConnection` instead.
199
+ - **Fallback:** after 2 s, asks `api.ipify.org`, then `icanhazip.com`.
200
+
201
+ ## ASNs
287
202
 
288
203
  ```js
289
204
  const found = db.system("AS13335"); // 'AS13335', 'as13335' or 13335
290
- found.handle; // 'CLOUDFLARENET'
291
205
  found.network.operator.brand; // 'Cloudflare'
292
206
  found.abuse.network_risk; // 0.14
293
207
 
294
208
  db.search("hetzner").map((one) => one.asn);
295
209
  // [24940, 212317, 213230, 215859]
296
- ```
297
-
298
- `system()` answers a `System`, so the `asn`, the `handle`, the same `network` with its
299
- operator and carrier, and the ASN's own `abuse` record, without anything only an address
300
- fixes: no prefix, no CIDR, no RPKI, no place. `found` is `false` where the file carries
301
- no such ASN. It bisects the network table, so no spine is read and nothing about the
302
- address lookup changes.
303
210
 
304
- `search(text, limit = 20)` matches the text against every handle and every company in
305
- the file and answers the same `System`, widest network first: a match at the head of a
306
- word beats one inside it, and then the network that touches most of the internet wins,
307
- which is its exchanges and its users. `search("13335")` is the ASN itself. The index is
308
- one lowercase text built on the first search, 0.32 s, and kept from then on.
309
-
310
- ## What an ASN announces
311
-
312
- ```js
313
- const routes = db.routes("AS13335"); // written however system() takes it
314
- routes.ipv4.length; // 1506
315
- routes.ipv4[0].cidr; // '152.114.0.0/17', the widest first
316
- routes.ipv4[0].addresses; // 32768
317
- routes.ipv4_addresses; // 616704, the space of all of them
318
- routes.ipv6_addresses >> 64n; // 75037868032n, as /64 networks
211
+ const routes = db.routes("AS13335");
212
+ routes.ipv4.length; // 1411
213
+ routes.ipv4[0].cidr; // '152.114.0.0/17', widest first
214
+ routes.ipv6_addresses >> 64n; // space as /64 networks, a bigint
319
215
  ```
320
216
 
321
- `routes()` answers a `Routes`: every prefix the ASN is announced as, split into `ipv4`
322
- and `ipv6` and widest first, each one a `Span` with its `cidr`, `start`, `end`,
323
- `version`, `prefix` and `addresses`. `ipv4_addresses` is a number and `ipv6_addresses` a
324
- bigint, and both count the space once where a more specific sits inside its own cover.
325
- `found` is `false` where the file carries no such ASN.
217
+ | call | answers |
218
+ | ------------------------- | -------------------------------------------------------- |
219
+ | `system(asn)` | `System`: handle, network, operator, carrier, ASN abuse; `found` false if unknown |
220
+ | `search(text, limit=20)` | `System`s whose handle or company matches, best first |
221
+ | `routes(asn)` | `Routes`: every announced prefix as a `Span`, widest first |
326
222
 
327
- It reads the spine's network column whole, a block at a time, rather than a row at a
328
- time: 159 ms for the first ASN asked about and 2 to 9 ms for every one after it, each
329
- answer kept. Nothing an address lookup reads is touched.
330
-
331
- ## In a browser
223
+ ## HTTP
332
224
 
333
- No build step and no install: the package is plain ESM with no dependencies, so any npm
334
- CDN serves it as it is.
225
+ The same JSON without a file, CORS open, no key:
335
226
 
336
- ```html
337
- <script type="module">
338
- import { open } from "https://cdn.jsdelivr.net/npm/plevinjs";
339
-
340
- const db = await open(
341
- "https://plevin.tn3w.dev/db/plevin.place-country-code.plv"
342
- );
343
- const { flag, name } = db.lookup("1.1.1.1").place.country;
344
- document.body.textContent = `${flag} ${name}`; // 'πŸ‡ΊπŸ‡Έ United States'
345
- </script>
346
- ```
347
-
348
- Open the smallest build a page actually needs: `plevin.place-country-code.plv` is
349
- 390 KB against the 17.3 MB of `plevin.plv`, so the first lookup lands in a moment
350
- rather than a download.
351
-
352
- | | |
353
- | --- | --- |
354
- | `https://cdn.jsdelivr.net/npm/plevinjs` | `dist/plevin.min.js`, the whole reader in one file |
355
- | `https://unpkg.com/plevinjs` | the same as jsDelivr |
356
- | `https://esm.sh/plevinjs` | the modules as published, imports rewritten |
357
- | `https://plevin.tn3w.dev/plevin/plevin.min.js` | the reader beside the databases |
358
-
359
- jsDelivr and unpkg serve the bundle named by the `jsdelivr`/`unpkg` fields, 44 kB of
360
- JavaScript with no further requests. The bare `dist/index.js` is not usable from those
361
- URLs: it imports `./reader.js` and neighbours, which resolve against `/npm/` there and
362
- 404. Pin a version for anything that ships: `cdn.jsdelivr.net/npm/plevinjs@0.1.2`.
363
- `plevinjs/node` is the only entry that touches Node, so a CDN import never reaches for
364
- `node:fs`.
365
-
366
- 18 MB crosses the wire once, so keep it out of the critical path and out of the next
367
- visit's way:
368
-
369
- ```js
370
- const store = await caches.open("plevin");
371
- const url = "https://plevin.tn3w.dev/db/plevin.plv";
372
- if (!(await store.match(url))) await store.add(url);
373
-
374
- const held = await store.match(url);
375
- const db = new Plevin(new Uint8Array(await held.arrayBuffer()));
227
+ ```bash
228
+ curl https://plevin.tn3w.dev/api/1.1.1.1 # any address
229
+ curl https://plevin.tn3w.dev/api/me # the caller
230
+ curl https://plevin.tn3w.dev/api/about # build and fields
376
231
  ```
377
232
 
378
- `plevin.place-country-code.plv` is 390 KB where the country code is all a page needs.
379
- [The lookup page](https://plevin.tn3w.dev/) is the whole idea in one file of
380
- plain JavaScript: it reads the database in the tab and calls out only for the visitor's
381
- own address and for hostnames.
233
+ Self-host with [`worker/`](https://github.com/tn3w/plevin/blob/master/worker).
382
234
 
383
235
  ## Speed
384
236
 
385
- Measured on the full file, Node 26, one core:
237
+ Full file, Node 26, one core:
386
238
 
387
- | | |
388
- | --- | --- |
389
- | open | 14 ms, the header only |
390
- | first answer | 44 ms, the blocks it lands in |
391
- | repeats | 4,600,000/s |
392
- | uniformly random v4 | 13,000/s cold, 200,000/s over the same log again |
393
- | one ASN | 350,000/s, a bisect of the network table |
394
- | one ASN's prefixes | 159 ms for the first, 2 to 9 ms after, then kept |
395
- | search | 1 ms, after 0.32 s building the index |
239
+ | operation | speed |
240
+ | ------------ | -------------------------------------- |
241
+ | open | 9 ms, header only |
242
+ | first lookup | 70 ms |
243
+ | repeats | 5,000,000/s |
244
+ | random v4 | 8,000/s cold, 72,000/s warm |
245
+ | `system` | 1,000,000/s |
246
+ | `routes` | 100–160 ms, then cached |
247
+ | `search` | 1 ms, after 0.07 s building the index |
396
248
 
397
- Blocks decode on reach and stay decoded, so a real log lands between the two. Memory is
398
- the file plus whatever it decoded, around 120 MB of heap with the whole world touched.
249
+ Blocks decode lazily, only as far as a lookup reaches, and stay decoded: about 160 MB
250
+ of heap with the whole world touched.
399
251
 
400
- ## Zstandard
252
+ ## LZMA
401
253
 
402
- The file is Zstandard with trained dictionaries, which no runtime decompresses on its
403
- own, so `DecompressionStream` has no zstd and Node's `zlib` takes no dictionary. So the
404
- package carries one, condensed from [fzstd](https://github.com/101arrowz/fzstd) (MIT)
405
- with the dictionary support it leaves out, and verified block for block against
406
- libzstd over the whole database.
254
+ Blocks are raw LZMA1, which neither `DecompressionStream` nor `zlib` reads, so the
255
+ package ships its own decoder, checked against liblzma on every block.
407
256
 
408
257
  ```js
409
- import { decompress, loadDictionary } from "plevin/zstd";
258
+ import { decompress } from "plevinjs/lzma";
410
259
 
411
- decompress(frame); // one frame
412
- decompress(frame, loadDictionary(trained)); // with a trained dictionary
260
+ decompress(block, [3, 0, 0]); // tuning: literal context, literal position, match position bits
413
261
  ```
414
262
 
415
263
  ## Development
416
264
 
265
+ | module | job |
266
+ | ------------ | --------------------------------------------------- |
267
+ | `index.ts` | `Plevin`, `open`, result shaping |
268
+ | `reader.ts` | file format: sections, blocks, groups, bisection |
269
+ | `lzma.ts` | resumable LZMA1 decoder |
270
+ | `address.ts` | parsing, spelling, special ranges |
271
+ | `naming.ts` | DNS and own address |
272
+ | `extra.ts` | country and clock, from `countries.ts`/`zones.ts` |
273
+ | `derive.ts` | brand, domain, capital |
274
+ | `models.ts` | published types |
275
+ | `node.ts` | `openFile`, the only `node:fs` import |
276
+
417
277
  ```bash
418
- cd js
419
278
  npm ci
420
- npm test # node --test, no database needed for most of it
421
- npm run lint # biome
422
- npm run typecheck # tsc, strict
423
- npm run build # dist/, ESM and .d.ts, plus the CDN bundle
424
- npm run bundle # dist/plevin.min.js only (esbuild)
279
+ npm test && npm run lint && npm run typecheck
280
+ npm run build # dist/ and the CDN bundle
425
281
 
426
282
  node test/compare.ts ../plevin.plv sample.json # field for field against Python
427
- node test/blocks.ts ../plevin.plv 100000 # every block against libzstd
283
+ node test/blocks.ts ../plevin.plv 100000 # every block against liblzma
428
284
  ```
429
285
 
430
286
  ## License
431
287
 
432
288
  Apache 2.0, see [LICENSE](https://github.com/tn3w/plevin/blob/master/LICENSE). The
433
- database carries the licenses of the sources it was built from, listed in
434
- [`builder/README.md`](https://github.com/tn3w/plevin/blob/master/builder/README.md#sources).
289
+ database carries the licenses of its
290
+ [sources](https://github.com/tn3w/plevin/blob/master/builder/README.md#sources).
435
291
 
436
292
  <!-- brand: Noto Sans 800, wordmark bar #1868f2 place, #6f42c1 network, #2ea043 abuse; #7d8894 address, ink #0b1220 light, #f0f6fc dark -->
@@ -1 +1 @@
1
- {"version":3,"file":"address.d.ts","sourceRoot":"","sources":["../src/address.ts"],"names":[],"mappings":"AAAA,wDAAwD;AAExD,MAAM,MAAM,KAAK,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,UAAU,CAAC;AAE1D,eAAO,MAAM,OAAO,IAAI,CAAC;AACzB,eAAO,MAAM,QAAQ,IAAI,CAAC;AAC1B,eAAO,MAAM,SAAS,IAAI,CAAC;AAC3B,eAAO,MAAM,QAAQ,IAAI,CAAC;AAC1B,eAAO,MAAM,UAAU,KAAK,CAAC;AAC7B,eAAO,MAAM,YAAY,KAAK,CAAC;AAC/B,eAAO,MAAM,aAAa,KAAK,CAAC;AAChC,eAAO,MAAM,MAAM,MAAM,CAAC;AAC1B,eAAO,MAAM,SAAS,MAAM,CAAC;AAE7B,eAAO,MAAM,MAAM,gBAAgB,CAAC;AACpC,eAAO,MAAM,SAAS,SAAS,CAAC;AAChC,eAAO,MAAM,MAAM,WAAW,CAAC;AAC/B,eAAO,MAAM,KAAK,UAAU,CAAC;AAuF7B,sFAAsF;AACtF,eAAO,MAAM,KAAK,UAAW,KAAK,KAAG,CAAC,MAAM,GAAG,MAAM,EAAE,OAAO,CA6B7D,CAAC;AA2BF,gFAAgF;AAChF,eAAO,MAAM,OAAO,UACX,MAAM,GAAG,MAAM,QAChB,OAAO,KACZ,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CA4BzB,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,OAAO,UAAW,MAAM,GAAG,MAAM,QAAQ,OAAO,KAAG,MACvC,CAAC;AAc1B,kFAAkF;AAClF,eAAO,MAAM,OAAO,UAAW,MAAM,GAAG,MAAM,QAAQ,OAAO,KAAG,MAa/D,CAAC;AAEF,0EAA0E;AAC1E,eAAO,MAAM,MAAM,UACV,MAAM,GAAG,MAAM,QAChB,OAAO,KACZ,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,GAAG,IAAI,CAW/B,CAAC;AAEF,8EAA8E;AAC9E,eAAO,MAAM,OAAO,UAAW,MAAM,GAAG,MAAM,QAAQ,OAAO,KAAG,MAAM,GAAG,IAUxE,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,OAAO,UACX,MAAM,GAAG,MAAM,QAChB,OAAO,KACZ,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,GAAG,IAAI,CAQ9C,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,IAAI,UACR,MAAM,GAAG,MAAM,QAChB,OAAO,UACL,MAAM,KACb,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAOzB,CAAC"}
1
+ {"version":3,"file":"address.d.ts","sourceRoot":"","sources":["../src/address.ts"],"names":[],"mappings":"AAAA,wDAAwD;AAExD,MAAM,MAAM,KAAK,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,UAAU,CAAC;AAE1D,eAAO,MAAM,OAAO,IAAI,CAAC;AACzB,eAAO,MAAM,QAAQ,IAAI,CAAC;AAC1B,eAAO,MAAM,SAAS,IAAI,CAAC;AAC3B,eAAO,MAAM,QAAQ,IAAI,CAAC;AAC1B,eAAO,MAAM,UAAU,KAAK,CAAC;AAC7B,eAAO,MAAM,YAAY,KAAK,CAAC;AAC/B,eAAO,MAAM,aAAa,KAAK,CAAC;AAChC,eAAO,MAAM,MAAM,MAAM,CAAC;AAC1B,eAAO,MAAM,SAAS,MAAM,CAAC;AAE7B,eAAO,MAAM,MAAM,gBAAgB,CAAC;AACpC,eAAO,MAAM,SAAS,SAAS,CAAC;AAChC,eAAO,MAAM,MAAM,WAAW,CAAC;AAC/B,eAAO,MAAM,KAAK,UAAU,CAAC;AAuF7B,sFAAsF;AACtF,eAAO,MAAM,KAAK,UAAW,KAAK,KAAG,CAAC,MAAM,GAAG,MAAM,EAAE,OAAO,CA6B7D,CAAC;AA2BF,gFAAgF;AAChF,eAAO,MAAM,OAAO,UACX,MAAM,GAAG,MAAM,QAChB,OAAO,KACZ,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAezB,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,OAAO,UAAW,MAAM,GAAG,MAAM,QAAQ,OAAO,KAAG,MACvC,CAAC;AAc1B,kFAAkF;AAClF,eAAO,MAAM,OAAO,UAAW,MAAM,GAAG,MAAM,QAAQ,OAAO,KAAG,MAa/D,CAAC;AAEF,0EAA0E;AAC1E,eAAO,MAAM,MAAM,UACV,MAAM,GAAG,MAAM,QAChB,OAAO,KACZ,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,GAAG,IAAI,CAW/B,CAAC;AAEF,8EAA8E;AAC9E,eAAO,MAAM,OAAO,UAAW,MAAM,GAAG,MAAM,QAAQ,OAAO,KAAG,MAAM,GAAG,IAUxE,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,OAAO,UACX,MAAM,GAAG,MAAM,QAChB,OAAO,KACZ,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,GAAG,IAAI,CAQ9C,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,IAAI,UACR,MAAM,GAAG,MAAM,QAChB,OAAO,UACL,MAAM,KACb,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAOzB,CAAC"}
package/dist/address.js CHANGED
@@ -168,21 +168,11 @@ export const spelled = (value, wide) => {
168
168
  const nibbles = value.toString(16).padStart(32, "0");
169
169
  const arpa = `${[...nibbles].reverse().join(".")}.ip6.arpa`;
170
170
  const mapped = held.slice(0, 5).every((group) => group === 0) && held[5] === 0xffff;
171
- if (mapped) {
172
- const quad = dotted(Number(value & 0xffffffffn));
173
- const parts = held.slice(0, 6).map((group) => group.toString(16));
174
- const full = held.slice(0, 6).map((group) => group.toString(16).padStart(4, "0"));
175
- return [
176
- `${shortest(parts, held.slice(0, 6))}:${quad}`,
177
- `${full.join(":")}:${quad}`,
178
- arpa,
179
- ];
180
- }
181
- return [
182
- shortest(held.map((group) => group.toString(16)), held),
183
- held.map((group) => group.toString(16).padStart(4, "0")).join(":"),
184
- arpa,
185
- ];
171
+ const groups = mapped ? held.slice(0, 6) : held;
172
+ const quad = mapped ? `:${dotted(Number(value & 0xffffffffn))}` : "";
173
+ const hex = groups.map((group) => group.toString(16));
174
+ const full = groups.map((group) => group.toString(16).padStart(4, "0"));
175
+ return [shortest(hex, groups) + quad, full.join(":") + quad, arpa];
186
176
  };
187
177
  /** An address as text: v4 from its octets, v6 through the shortening rules. */
188
178
  export const written = (value, wide) => spelled(value, wide)[0];