@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.
- package/README.md +115 -60
- 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
|
|
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
|
-
```
|
|
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
|
-
###
|
|
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; //
|
|
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.
|
|
29
|
+
ip.equals(ip); // compare two addresses with equals, never ===
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
###
|
|
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
|
-
|
|
48
|
+
`classification` names what the address is for (`"private"`, `"loopback"`, `"documentation"`, …), and `isPublic` is `classification === "public"`.
|
|
49
49
|
|
|
50
|
-
|
|
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
|
-
|
|
52
|
+
```typescript
|
|
53
|
+
import { IP } from "@sdxc/ip";
|
|
54
|
+
import { isSuccess } from "@sdxc/result";
|
|
58
55
|
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
71
|
+
An immutable address, obtainable only from `IP.parse`.
|
|
74
72
|
|
|
75
|
-
- `version
|
|
76
|
-
- `classification
|
|
77
|
-
- `isPublic
|
|
78
|
-
- `embeddedIPv4
|
|
79
|
-
- `network({ v4, v6 })
|
|
80
|
-
- `equals(other)
|
|
81
|
-
- `toString()
|
|
82
|
-
- `toJSON()
|
|
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
|
|
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.
|
|
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
|
-
|
|
90
|
+
An immutable network, obtainable only from `IP.Range.parse` or `ip.network()`.
|
|
93
91
|
|
|
94
|
-
- `network
|
|
95
|
-
- `prefix
|
|
96
|
-
- `version
|
|
97
|
-
- `contains(ip)
|
|
98
|
-
- `equals(other)`, `toString()`, `toJSON()
|
|
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
|
|
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
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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:
|
|
151
|
+
## Pattern: Keying A Rate Limit On The Client's Network
|
|
125
152
|
|
|
126
|
-
|
|
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 {
|
|
157
|
+
import { isFailure } from "@sdxc/result";
|
|
131
158
|
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
11
|
+
"@sdxc/result": "2026.10.13"
|
|
12
12
|
},
|
|
13
|
-
"gitHead": "
|
|
13
|
+
"gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
|
|
14
14
|
"publishConfig": {
|
|
15
15
|
"access": "public"
|
|
16
16
|
},
|