@sdxc/ip 2026.10.6 → 2026.10.13

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.
Files changed (2) hide show
  1. package/README.md +115 -60
  2. package/package.json +3 -3
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  IPv4 and IPv6 addresses and ranges as value objects that classify against the IANA special-purpose registries.
4
4
 
5
- An `IP` is one parsed, valid address. A function that takes one cannot be handed `"unknown"`, `"10.0.0"` or a hostname, and the address can say whether it is on the public internet, which network it sits in, and how it is spelled canonically. Nothing performs I/O, so it runs wherever an address arrives as text: a URL host, a DNS answer, a request header.
5
+ An `IP` is one parsed, valid address: it knows whether it is on the public internet, which network it sits in, and how it is spelled canonically. Everything runs without I/O, wherever an address arrives as text: a URL host, a DNS answer, a request header.
6
6
 
7
7
  ## Installation
8
8
 
9
- ```bash
9
+ ```sh
10
10
  npm add @sdxc/ip
11
11
  ```
12
12
 
@@ -14,22 +14,22 @@ Parsing answers a `Result` from [`@sdxc/result`](https://www.npmjs.com/package/@
14
14
 
15
15
  ## Usage
16
16
 
17
- ### Parsing An Address
17
+ ### Parse an address
18
18
 
19
19
  ```typescript
20
20
  import { IP } from "@sdxc/ip";
21
21
  import { isFailure } from "@sdxc/result";
22
22
 
23
23
  let parsed = IP.parse("2001:DB8::0:1");
24
- if (isFailure(parsed)) return parsed; // IP.Error { code: "invalid-address" }
24
+ if (isFailure(parsed)) return parsed.error.code; // "invalid-address"
25
25
 
26
26
  let ip = parsed.data;
27
27
  ip.version; // 6
28
28
  ip.toString(); // "2001:db8::1"
29
- ip.isPublic; // false: documentation range
29
+ ip.equals(ip); // compare two addresses with equals, never ===
30
30
  ```
31
31
 
32
- ### Refusing A URL That Points Inside The Network
32
+ ### Classify it
33
33
 
34
34
  ```typescript
35
35
  import { IP } from "@sdxc/ip";
@@ -40,115 +40,170 @@ function isPublicHost(url: URL): boolean {
40
40
  return isSuccess(ip) && ip.data.isPublic;
41
41
  }
42
42
 
43
- isPublicHost(new URL("http://169.254.169.254/")); // false
44
- isPublicHost(new URL("http://[::ffff:10.0.0.1]/")); // false
43
+ isPublicHost(new URL("http://169.254.169.254/")); // false: link-local
44
+ isPublicHost(new URL("http://[::ffff:10.0.0.1]/")); // false: judged by the IPv4 inside
45
45
  isPublicHost(new URL("http://[::ffff:8.8.8.8]/")); // true
46
46
  ```
47
47
 
48
- ### Testing A Range
48
+ `classification` names what the address is for (`"private"`, `"loopback"`, `"documentation"`, …), and `isPublic` is `classification === "public"`.
49
49
 
50
- ```typescript
51
- let office = IP.Range.parse("203.0.113.0/24");
52
- if (isSuccess(office)) office.data.contains(ip); // false across versions
53
-
54
- IP.Range.parse("10.0.0.1/8"); // failure: IP.Error { code: "host-bits-set" }
55
- ```
50
+ ### Test a range
56
51
 
57
- ### Keying A Rate Limit On The Client's Network
52
+ ```typescript
53
+ import { IP } from "@sdxc/ip";
54
+ import { isSuccess } from "@sdxc/result";
58
55
 
59
- An IPv6 client is normally assigned a whole `/64`, so a key on the full address counts each address it rotates through separately.
56
+ let range = IP.Range.parse("203.0.113.0/24");
57
+ let ip = IP.parse("203.0.113.7");
58
+ if (isSuccess(range) && isSuccess(ip)) range.data.contains(ip.data); // true
60
59
 
61
- ```typescript
62
- ip.network({ v4: 32, v6: 64 }).toString(); // "2001:db8:1:2::/64" or "203.0.113.7/32"
60
+ IP.Range.parse("10.0.0.1/8"); // failure: IP.Error { code: "host-bits-set" }
63
61
  ```
64
62
 
65
63
  ## API
66
64
 
67
- ### `IP.parse(text): Result<IP, IP.Error>`
65
+ ### `IP.parse(text: string): Result<IP, IP.Error>`
68
66
 
69
- Parses an IPv4 or IPv6 address. Brackets around IPv6 are accepted (`[::1]`), the way a URL host spells it. IPv4 must be canonical dotted decimal: `URL` already normalizes octal, hex and bare-integer hosts, and refusing them here keeps any other input from naming a different address than it appears to. Zone identifiers (`fe80::1%eth0`) are refused.
67
+ Parses an IPv4 or IPv6 address, accepting brackets around IPv6 (`[::1]`) the way a URL host spells it. IPv4 must be canonical dotted decimal, and zone identifiers (`fe80::1%eth0`) fail, so every accepted text names exactly the address it appears to.
70
68
 
71
69
  ### `IP`
72
70
 
73
- Immutable, and only obtainable from `IP.parse`.
71
+ An immutable address, obtainable only from `IP.parse`.
74
72
 
75
- - `version` — `4` or `6`.
76
- - `classification` — what the IANA special-purpose registries say the address is for: `"public"`, `"unspecified"`, `"loopback"`, `"private"`, `"shared"`, `"link-local"`, `"documentation"`, `"benchmarking"`, `"multicast"`, `"reserved"`, `"unique-local"`, `"teredo"` or `"discard"`. IPv6 outside `2000::/3` and not otherwise assigned is `"reserved"`.
77
- - `isPublic` — `classification === "public"`.
78
- - `embeddedIPv4` — the IPv4 address inside an IPv4-mapped (`::ffff:0:0/96`), NAT64 (`64:ff9b::/96`) or 6to4 (`2002::/16`) address, or `null`. `classification` and `isPublic` judge those addresses by the IPv4 inside, so `::ffff:10.0.0.1` is `"private"`.
79
- - `network({ v4, v6 })` — the `IP.Range` the address sits in at the prefix length for its version. A length outside the version's width is clamped into it.
80
- - `equals(other)` — same version and same address. `===` compares object identity, so compare two `IP`s with this.
81
- - `toString()` — canonical text: dotted decimal, or [RFC 5952](https://www.rfc-editor.org/rfc/rfc5952) IPv6 (lowercase, longest zero run compressed, IPv4-mapped ending in a dotted quad), without brackets. Every spelling of one address prints one string, so it is the key for a `Map`, a rate limit, a log field or a database column.
82
- - `toJSON()` — the same string, so `JSON.stringify({ ip })` writes `{"ip":"2001:db8::1"}`.
73
+ - `version`: `4` or `6`.
74
+ - `classification`: what the IANA special-purpose registries say the address is for, one of `"public"`, `"unspecified"`, `"loopback"`, `"private"`, `"shared"`, `"link-local"`, `"documentation"`, `"benchmarking"`, `"multicast"`, `"reserved"`, `"unique-local"`, `"teredo"` or `"discard"`. IPv6 outside `2000::/3` and not otherwise assigned is `"reserved"`.
75
+ - `isPublic`: `classification === "public"`.
76
+ - `embeddedIPv4`: the IPv4 address inside an IPv4-mapped (`::ffff:0:0/96`), NAT64 (`64:ff9b::/96`) or 6to4 (`2002::/16`) address, or `null`. `classification` judges those addresses by the IPv4 inside.
77
+ - `network({ v4, v6 })`: the `IP.Range` the address sits in at the prefix length for its version, clamped to the version's width.
78
+ - `equals(other)`: same version and same address.
79
+ - `toString()`: canonical text, dotted decimal or [RFC 5952](https://www.rfc-editor.org/rfc/rfc5952) IPv6 without brackets. Every spelling of one address prints one string.
80
+ - `toJSON()`: the same string, so `JSON.stringify({ ip })` writes `{"ip":"2001:db8::1"}`.
83
81
 
84
- A structured clone (Durable Object storage, `postMessage`) drops the methods. Store `toString()` and parse it back on read.
82
+ A structured clone (`postMessage`, Durable Object storage) drops the methods, so store `toString()` and parse it back.
85
83
 
86
- ### `IP.Range.parse(text): Result<IP.Range, IP.Error>`
84
+ ### `IP.Range.parse(text: string): Result<IP.Range, IP.Error>`
87
85
 
88
- Parses `network/prefix` text. A network address with bits set past the prefix fails with `host-bits-set`; anything else malformed fails with `invalid-range`.
86
+ Parses `network/prefix` text. An address with bits set past the prefix fails with `host-bits-set`; other malformed text fails with `invalid-range`.
89
87
 
90
88
  ### `IP.Range`
91
89
 
92
- Immutable, and only obtainable from `IP.Range.parse` or `ip.network`.
90
+ An immutable network, obtainable only from `IP.Range.parse` or `ip.network()`.
93
91
 
94
- - `network` — the first address, as an `IP`.
95
- - `prefix` — the prefix length.
96
- - `version` — `4` or `6`.
97
- - `contains(ip)` — whether the address shares the range's leading bits. An address of the other version is never a member.
98
- - `equals(other)`, `toString()`, `toJSON()` — as on `IP`; the text is `network/prefix`, such as `2001:db8::/32`.
92
+ - `network`: the first address, as an `IP`.
93
+ - `prefix`: the prefix length.
94
+ - `version`: `4` or `6`.
95
+ - `contains(ip)`: whether the address shares the range's leading bits; an address of the other version is never a member.
96
+ - `equals(other)`, `toString()`, `toJSON()`: as on `IP`, with the text written `network/prefix`, such as `2001:db8::/32`.
99
97
 
100
98
  ### `IP.Error`
101
99
 
102
- The failure both parsers answer, never thrown. `code` is `"invalid-address"`, `"invalid-range"` or `"host-bits-set"`, and `input` is the rejected text. It is a class, so `error instanceof IP.Error` narrows.
100
+ The failure both parsers answer inside a `Result`. `code` is `"invalid-address"`, `"invalid-range"` or `"host-bits-set"`, `input` is the rejected text, and `error instanceof IP.Error` narrows it.
103
101
 
104
102
  ### Types
105
103
 
106
104
  `IP.Range`, `IP.Error`, `IP.ErrorCode`, `IP.Classification` and `IP.Prefixes` (`{ v4: number; v6: number }`) are types under the same `IP` name.
107
105
 
106
+ ## Pattern: Refusing A Name That Resolves Inside The Network
107
+
108
+ Checking the URL host stops `http://127.0.0.1`; classifying every DNS answer before fetching also stops a public name whose record points at an internal address.
109
+
110
+ ```typescript
111
+ import { IP } from "@sdxc/ip";
112
+ import { isSuccess } from "@sdxc/result";
113
+
114
+ function allPublic(answers: string[]): boolean {
115
+ return (
116
+ answers.length > 0 &&
117
+ answers.every((answer) => {
118
+ let ip = IP.parse(answer);
119
+ return isSuccess(ip) && ip.data.isPublic;
120
+ })
121
+ );
122
+ }
123
+
124
+ allPublic(["93.184.215.14"]); // true
125
+ allPublic(["93.184.215.14", "10.0.0.5"]); // false
126
+ ```
127
+
108
128
  ## Pattern: An Allowlist Of Networks
109
129
 
130
+ Parse the ranges once and test each request's address against them.
131
+
110
132
  ```typescript
111
133
  import { IP } from "@sdxc/ip";
112
134
  import { isSuccess } from "@sdxc/result";
113
135
 
114
- const OFFICE_RANGES = ["203.0.113.0/24", "2001:db8:42::/48"];
136
+ let allowed = ["203.0.113.0/24", "2001:db8:42::/48"].flatMap((text) => {
137
+ let range = IP.Range.parse(text);
138
+ return isSuccess(range) ? [range.data] : [];
139
+ });
115
140
 
116
- function isOffice(ip: IP): boolean {
117
- return OFFICE_RANGES.some((text) => {
118
- let range = IP.Range.parse(text);
119
- return isSuccess(range) && range.data.contains(ip);
120
- });
141
+ function isAllowed(text: string): boolean {
142
+ let ip = IP.parse(text);
143
+ return isSuccess(ip) && allowed.some((range) => range.contains(ip.data));
121
144
  }
145
+
146
+ isAllowed("203.0.113.9"); // true
147
+ isAllowed("2001:db8:42:7::1"); // true
148
+ isAllowed("198.51.100.1"); // false
122
149
  ```
123
150
 
124
- ## Pattern: Checking Every Address A Name Resolves To
151
+ ## Pattern: Keying A Rate Limit On The Client's Network
125
152
 
126
- A literal check only stops `http://127.0.0.1`. A public name whose `A` record points at an internal address is caught by classifying every answer before fetching:
153
+ An IPv6 client is usually assigned a whole `/64`, so keying on the full address counts each address it rotates through separately. Key on the network instead, and the canonical text keeps every spelling of one client on one key.
127
154
 
128
155
  ```typescript
129
156
  import { IP } from "@sdxc/ip";
130
- import { isSuccess } from "@sdxc/result";
157
+ import { isFailure } from "@sdxc/result";
131
158
 
132
- function allPublic(answers: string[]): boolean {
133
- return (
134
- answers.length > 0 &&
135
- answers.every((answer) => {
136
- let ip = IP.parse(answer);
137
- return isSuccess(ip) && ip.data.isPublic;
138
- })
139
- );
159
+ function rateLimitKey(text: string): string | null {
160
+ let ip = IP.parse(text);
161
+ if (isFailure(ip)) return null;
162
+ return ip.data.network({ v4: 32, v6: 64 }).toString();
140
163
  }
164
+
165
+ rateLimitKey("2001:DB8:1:2::abcd"); // "2001:db8:1:2::/64"
166
+ rateLimitKey("2001:db8:1:2:ffff::1"); // "2001:db8:1:2::/64"
167
+ rateLimitKey("203.0.113.7"); // "203.0.113.7/32"
141
168
  ```
142
169
 
143
- ## Pattern: A Function That Only Accepts A Checked Address
170
+ ## Pattern: Reporting Why Input Was Refused
171
+
172
+ `IP.Error` carries a code to map to your own wording and the text that arrived.
144
173
 
145
174
  ```typescript
146
- import type { IP } from "@sdxc/ip";
175
+ import { IP } from "@sdxc/ip";
176
+ import { isFailure } from "@sdxc/result";
177
+
178
+ let messages: Record<IP.ErrorCode, string> = {
179
+ "invalid-address": "Enter an IPv4 or IPv6 address",
180
+ "invalid-range": "Enter a range such as 10.0.0.0/8",
181
+ "host-bits-set": "Use the network's first address, such as 10.0.0.0/8",
182
+ };
183
+
184
+ function validateRange(text: string): string | null {
185
+ let range = IP.Range.parse(text.trim());
186
+ return isFailure(range) ? messages[range.error.code] : null;
187
+ }
188
+
189
+ validateRange("10.0.0.1/8"); // "Use the network's first address, such as 10.0.0.0/8"
190
+ ```
191
+
192
+ ## Pattern: Functions That Only Accept A Checked Address
193
+
194
+ Taking `IP` instead of `string` moves validation to the edge, and `toString()` stores the canonical form.
195
+
196
+ ```typescript
197
+ import { IP } from "@sdxc/ip";
198
+ import { isSuccess } from "@sdxc/result";
147
199
 
148
200
  function recordProbe(target: IP, at: number) {
149
- return { target: target.toString(), at };
201
+ return { target: target.toString(), version: target.version, at };
150
202
  }
151
203
 
204
+ let target = IP.parse("[2001:db8::0:1]");
205
+ if (isSuccess(target)) recordProbe(target.data, Date.now()); // { target: "2001:db8::1", ... }
206
+
152
207
  recordProbe("10.0.0.1", Date.now()); // type error: a string is not an IP
153
208
  ```
154
209
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/ip",
3
- "version": "2026.10.6",
3
+ "version": "2026.10.13",
4
4
  "description": "IPv4 and IPv6 addresses and ranges as value objects that classify against the IANA special-purpose registries",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,9 +8,9 @@
8
8
  ".": "./dist/index.js"
9
9
  },
10
10
  "dependencies": {
11
- "@sdxc/result": "2026.10.6"
11
+ "@sdxc/result": "2026.10.13"
12
12
  },
13
- "gitHead": "b1067eb20fc47ec51ff8c4de47d07dc3f9c00466",
13
+ "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
14
14
  "publishConfig": {
15
15
  "access": "public"
16
16
  },