@sdxc/ip 2026.10.6 → 2026.11.3
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 +127 -56
- package/dist/cli-classify.d.ts +18 -0
- package/dist/cli-classify.js +43 -0
- package/dist/cli-contains.d.ts +19 -0
- package/dist/cli-contains.js +38 -0
- package/dist/cli-contract.d.ts +23 -0
- package/dist/cli-contract.js +56 -0
- package/dist/cli-input.d.ts +23 -0
- package/dist/cli-input.js +30 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +18 -0
- package/package.json +8 -4
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,186 @@ 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
|
+
|
|
50
|
+
### Test a range
|
|
49
51
|
|
|
50
52
|
```typescript
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
import { IP } from "@sdxc/ip";
|
|
54
|
+
import { isSuccess } from "@sdxc/result";
|
|
55
|
+
|
|
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
|
|
53
59
|
|
|
54
60
|
IP.Range.parse("10.0.0.1/8"); // failure: IP.Error { code: "host-bits-set" }
|
|
55
61
|
```
|
|
56
62
|
|
|
57
|
-
|
|
63
|
+
## Command line
|
|
58
64
|
|
|
59
|
-
|
|
65
|
+
```sh
|
|
66
|
+
bunx @sdxc/ip classify 192.168.1.10
|
|
67
|
+
```
|
|
60
68
|
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
With [`@sdxc/kit`](https://www.npmjs.com/package/@sdxc/kit) installed, `sdxc pkg ip …` runs the same command line, using the version your project installs.
|
|
70
|
+
|
|
71
|
+
`classify` prints the address's version, canonical form, classification, whether it is public, and the IPv4 an IPv4-mapped, NAT64 or 6to4 address carries. With no address, or `-`, it reads stdin; `--json` prints the same as an object.
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
bunx @sdxc/ip contains 10.0.0.0/8 10.1.2.3
|
|
63
75
|
```
|
|
64
76
|
|
|
77
|
+
`contains` exits 0 when the range holds the address and 1 when it does not. Both commands exit 2 for text that is not an address or a range. `bunx @sdxc/ip --help` lists every flag.
|
|
78
|
+
|
|
65
79
|
## API
|
|
66
80
|
|
|
67
|
-
### `IP.parse(text): Result<IP, IP.Error>`
|
|
81
|
+
### `IP.parse(text: string): Result<IP, IP.Error>`
|
|
68
82
|
|
|
69
|
-
Parses an IPv4 or IPv6 address
|
|
83
|
+
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
84
|
|
|
71
85
|
### `IP`
|
|
72
86
|
|
|
73
|
-
|
|
87
|
+
An immutable address, obtainable only from `IP.parse`.
|
|
74
88
|
|
|
75
|
-
- `version
|
|
76
|
-
- `classification
|
|
77
|
-
- `isPublic
|
|
78
|
-
- `embeddedIPv4
|
|
79
|
-
- `network({ v4, v6 })
|
|
80
|
-
- `equals(other)
|
|
81
|
-
- `toString()
|
|
82
|
-
- `toJSON()
|
|
89
|
+
- `version`: `4` or `6`.
|
|
90
|
+
- `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"`.
|
|
91
|
+
- `isPublic`: `classification === "public"`.
|
|
92
|
+
- `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.
|
|
93
|
+
- `network({ v4, v6 })`: the `IP.Range` the address sits in at the prefix length for its version, clamped to the version's width.
|
|
94
|
+
- `equals(other)`: same version and same address.
|
|
95
|
+
- `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.
|
|
96
|
+
- `toJSON()`: the same string, so `JSON.stringify({ ip })` writes `{"ip":"2001:db8::1"}`.
|
|
83
97
|
|
|
84
|
-
A structured clone (Durable Object storage
|
|
98
|
+
A structured clone (`postMessage`, Durable Object storage) drops the methods, so store `toString()` and parse it back.
|
|
85
99
|
|
|
86
|
-
### `IP.Range.parse(text): Result<IP.Range, IP.Error>`
|
|
100
|
+
### `IP.Range.parse(text: string): Result<IP.Range, IP.Error>`
|
|
87
101
|
|
|
88
|
-
Parses `network/prefix` text.
|
|
102
|
+
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
103
|
|
|
90
104
|
### `IP.Range`
|
|
91
105
|
|
|
92
|
-
|
|
106
|
+
An immutable network, obtainable only from `IP.Range.parse` or `ip.network()`.
|
|
93
107
|
|
|
94
|
-
- `network
|
|
95
|
-
- `prefix
|
|
96
|
-
- `version
|
|
97
|
-
- `contains(ip)
|
|
98
|
-
- `equals(other)`, `toString()`, `toJSON()
|
|
108
|
+
- `network`: the first address, as an `IP`.
|
|
109
|
+
- `prefix`: the prefix length.
|
|
110
|
+
- `version`: `4` or `6`.
|
|
111
|
+
- `contains(ip)`: whether the address shares the range's leading bits; an address of the other version is never a member.
|
|
112
|
+
- `equals(other)`, `toString()`, `toJSON()`: as on `IP`, with the text written `network/prefix`, such as `2001:db8::/32`.
|
|
99
113
|
|
|
100
114
|
### `IP.Error`
|
|
101
115
|
|
|
102
|
-
The failure both parsers answer
|
|
116
|
+
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
117
|
|
|
104
118
|
### Types
|
|
105
119
|
|
|
106
120
|
`IP.Range`, `IP.Error`, `IP.ErrorCode`, `IP.Classification` and `IP.Prefixes` (`{ v4: number; v6: number }`) are types under the same `IP` name.
|
|
107
121
|
|
|
122
|
+
## Pattern: Refusing A Name That Resolves Inside The Network
|
|
123
|
+
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
import { IP } from "@sdxc/ip";
|
|
128
|
+
import { isSuccess } from "@sdxc/result";
|
|
129
|
+
|
|
130
|
+
function allPublic(answers: string[]): boolean {
|
|
131
|
+
return (
|
|
132
|
+
answers.length > 0 &&
|
|
133
|
+
answers.every((answer) => {
|
|
134
|
+
let ip = IP.parse(answer);
|
|
135
|
+
return isSuccess(ip) && ip.data.isPublic;
|
|
136
|
+
})
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
allPublic(["93.184.215.14"]); // true
|
|
141
|
+
allPublic(["93.184.215.14", "10.0.0.5"]); // false
|
|
142
|
+
```
|
|
143
|
+
|
|
108
144
|
## Pattern: An Allowlist Of Networks
|
|
109
145
|
|
|
146
|
+
Parse the ranges once and test each request's address against them.
|
|
147
|
+
|
|
110
148
|
```typescript
|
|
111
149
|
import { IP } from "@sdxc/ip";
|
|
112
150
|
import { isSuccess } from "@sdxc/result";
|
|
113
151
|
|
|
114
|
-
|
|
152
|
+
let allowed = ["203.0.113.0/24", "2001:db8:42::/48"].flatMap((text) => {
|
|
153
|
+
let range = IP.Range.parse(text);
|
|
154
|
+
return isSuccess(range) ? [range.data] : [];
|
|
155
|
+
});
|
|
115
156
|
|
|
116
|
-
function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return isSuccess(range) && range.data.contains(ip);
|
|
120
|
-
});
|
|
157
|
+
function isAllowed(text: string): boolean {
|
|
158
|
+
let ip = IP.parse(text);
|
|
159
|
+
return isSuccess(ip) && allowed.some((range) => range.contains(ip.data));
|
|
121
160
|
}
|
|
161
|
+
|
|
162
|
+
isAllowed("203.0.113.9"); // true
|
|
163
|
+
isAllowed("2001:db8:42:7::1"); // true
|
|
164
|
+
isAllowed("198.51.100.1"); // false
|
|
122
165
|
```
|
|
123
166
|
|
|
124
|
-
## Pattern:
|
|
167
|
+
## Pattern: Keying A Rate Limit On The Client's Network
|
|
125
168
|
|
|
126
|
-
|
|
169
|
+
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
170
|
|
|
128
171
|
```typescript
|
|
129
172
|
import { IP } from "@sdxc/ip";
|
|
130
|
-
import {
|
|
173
|
+
import { isFailure } from "@sdxc/result";
|
|
131
174
|
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
let ip = IP.parse(answer);
|
|
137
|
-
return isSuccess(ip) && ip.data.isPublic;
|
|
138
|
-
})
|
|
139
|
-
);
|
|
175
|
+
function rateLimitKey(text: string): string | null {
|
|
176
|
+
let ip = IP.parse(text);
|
|
177
|
+
if (isFailure(ip)) return null;
|
|
178
|
+
return ip.data.network({ v4: 32, v6: 64 }).toString();
|
|
140
179
|
}
|
|
180
|
+
|
|
181
|
+
rateLimitKey("2001:DB8:1:2::abcd"); // "2001:db8:1:2::/64"
|
|
182
|
+
rateLimitKey("2001:db8:1:2:ffff::1"); // "2001:db8:1:2::/64"
|
|
183
|
+
rateLimitKey("203.0.113.7"); // "203.0.113.7/32"
|
|
141
184
|
```
|
|
142
185
|
|
|
143
|
-
## Pattern:
|
|
186
|
+
## Pattern: Reporting Why Input Was Refused
|
|
187
|
+
|
|
188
|
+
`IP.Error` carries a code to map to your own wording and the text that arrived.
|
|
144
189
|
|
|
145
190
|
```typescript
|
|
146
|
-
import
|
|
191
|
+
import { IP } from "@sdxc/ip";
|
|
192
|
+
import { isFailure } from "@sdxc/result";
|
|
193
|
+
|
|
194
|
+
let messages: Record<IP.ErrorCode, string> = {
|
|
195
|
+
"invalid-address": "Enter an IPv4 or IPv6 address",
|
|
196
|
+
"invalid-range": "Enter a range such as 10.0.0.0/8",
|
|
197
|
+
"host-bits-set": "Use the network's first address, such as 10.0.0.0/8",
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function validateRange(text: string): string | null {
|
|
201
|
+
let range = IP.Range.parse(text.trim());
|
|
202
|
+
return isFailure(range) ? messages[range.error.code] : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
validateRange("10.0.0.1/8"); // "Use the network's first address, such as 10.0.0.0/8"
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Pattern: Functions That Only Accept A Checked Address
|
|
209
|
+
|
|
210
|
+
Taking `IP` instead of `string` moves validation to the edge, and `toString()` stores the canonical form.
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
import { IP } from "@sdxc/ip";
|
|
214
|
+
import { isSuccess } from "@sdxc/result";
|
|
147
215
|
|
|
148
216
|
function recordProbe(target: IP, at: number) {
|
|
149
|
-
return { target: target.toString(), at };
|
|
217
|
+
return { target: target.toString(), version: target.version, at };
|
|
150
218
|
}
|
|
151
219
|
|
|
220
|
+
let target = IP.parse("[2001:db8::0:1]");
|
|
221
|
+
if (isSuccess(target)) recordProbe(target.data, Date.now()); // { target: "2001:db8::1", ... }
|
|
222
|
+
|
|
152
223
|
recordProbe("10.0.0.1", Date.now()); // type error: a string is not an IP
|
|
153
224
|
```
|
|
154
225
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sdxc-ip classify` handler: parses the address and prints its version, canonical text,
|
|
3
|
+
* IANA special-purpose classification and any IPv4 it carries, as aligned lines or as JSON.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Classifies the address; `--json` prints `{ address, version, classification, public,
|
|
10
|
+
* embeddedIPv4 }`, with `embeddedIPv4` null outside the IPv4-mapped, NAT64 and 6to4 prefixes.
|
|
11
|
+
* Text that is not an address exits 2.
|
|
12
|
+
*/
|
|
13
|
+
declare const _default: import("@sdxc/cli").CommandAction<import("@sdxc/cli").Command<{
|
|
14
|
+
readonly address: import("@sdxc/cli").Argument<string>;
|
|
15
|
+
}, {
|
|
16
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
17
|
+
}>, []>;
|
|
18
|
+
export default _default;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sdxc-ip classify` handler: parses the address and prints its version, canonical text,
|
|
3
|
+
* IANA special-purpose classification and any IPv4 it carries, as aligned lines or as JSON.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
import { createCommand, success } from "@sdxc/cli";
|
|
9
|
+
import { isFailure } from "@sdxc/result";
|
|
10
|
+
import { IP_COMMANDS } from "./cli-contract.js";
|
|
11
|
+
import { readAddress } from "./cli-input.js";
|
|
12
|
+
/**
|
|
13
|
+
* Classifies the address; `--json` prints `{ address, version, classification, public,
|
|
14
|
+
* embeddedIPv4 }`, with `embeddedIPv4` null outside the IPv4-mapped, NAT64 and 6to4 prefixes.
|
|
15
|
+
* Text that is not an address exits 2.
|
|
16
|
+
*/
|
|
17
|
+
export default createCommand(IP_COMMANDS.classify, async (ctx) => {
|
|
18
|
+
let parsed = await readAddress(ctx.arguments.address, ctx.stdin);
|
|
19
|
+
if (isFailure(parsed))
|
|
20
|
+
return parsed;
|
|
21
|
+
let ip = parsed.data;
|
|
22
|
+
let report = {
|
|
23
|
+
address: ip.toString(),
|
|
24
|
+
version: ip.version,
|
|
25
|
+
classification: ip.classification,
|
|
26
|
+
public: ip.isPublic,
|
|
27
|
+
embeddedIPv4: ip.embeddedIPv4?.toString() ?? null,
|
|
28
|
+
};
|
|
29
|
+
if (ctx.flags.json) {
|
|
30
|
+
ctx.stdout(`${JSON.stringify(report, null, 2)}\n`);
|
|
31
|
+
return success();
|
|
32
|
+
}
|
|
33
|
+
let lines = [
|
|
34
|
+
`address ${report.address}`,
|
|
35
|
+
`version ${report.version}`,
|
|
36
|
+
`classification ${report.classification}`,
|
|
37
|
+
`public ${report.public ? "yes" : "no"}`,
|
|
38
|
+
];
|
|
39
|
+
if (report.embeddedIPv4 !== null)
|
|
40
|
+
lines.push(`embedded IPv4 ${report.embeddedIPv4}`);
|
|
41
|
+
ctx.stdout(`${lines.join("\n")}\n`);
|
|
42
|
+
return success();
|
|
43
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sdxc-ip contains` handler: parses a range and an address and answers membership in the
|
|
3
|
+
* exit code, so a shell script can branch on it; stdout says the same for a person or as JSON.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Exits 0 when the range holds the address and 1 when it does not; an address of the other
|
|
10
|
+
* version is always outside. A malformed range, one with host bits set, or text that is not an
|
|
11
|
+
* address exits 2. `--json` prints `{ range, address, contains }` with both in canonical text.
|
|
12
|
+
*/
|
|
13
|
+
declare const _default: import("@sdxc/cli").CommandAction<import("@sdxc/cli").Command<{
|
|
14
|
+
readonly range: import("@sdxc/cli").Argument<string>;
|
|
15
|
+
readonly address: import("@sdxc/cli").Argument<string>;
|
|
16
|
+
}, {
|
|
17
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
18
|
+
}>, []>;
|
|
19
|
+
export default _default;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sdxc-ip contains` handler: parses a range and an address and answers membership in the
|
|
3
|
+
* exit code, so a shell script can branch on it; stdout says the same for a person or as JSON.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
import { createCommand, exit, success } from "@sdxc/cli";
|
|
9
|
+
import { isFailure } from "@sdxc/result";
|
|
10
|
+
import { IP_COMMANDS } from "./cli-contract.js";
|
|
11
|
+
import { readAddress } from "./cli-input.js";
|
|
12
|
+
import { IP } from "./ip.js";
|
|
13
|
+
/**
|
|
14
|
+
* Exits 0 when the range holds the address and 1 when it does not; an address of the other
|
|
15
|
+
* version is always outside. A malformed range, one with host bits set, or text that is not an
|
|
16
|
+
* address exits 2. `--json` prints `{ range, address, contains }` with both in canonical text.
|
|
17
|
+
*/
|
|
18
|
+
export default createCommand(IP_COMMANDS.contains, async (ctx) => {
|
|
19
|
+
let range = IP.Range.parse(ctx.arguments.range);
|
|
20
|
+
if (isFailure(range)) {
|
|
21
|
+
let reason = range.error.code === "host-bits-set"
|
|
22
|
+
? `${JSON.stringify(ctx.arguments.range)} has bits set past its prefix; write the network's first address`
|
|
23
|
+
: `Not an IP range: ${JSON.stringify(ctx.arguments.range)}`;
|
|
24
|
+
return exit(2, reason);
|
|
25
|
+
}
|
|
26
|
+
let address = await readAddress(ctx.arguments.address, ctx.stdin);
|
|
27
|
+
if (isFailure(address))
|
|
28
|
+
return address;
|
|
29
|
+
let contains = range.data.contains(address.data);
|
|
30
|
+
if (ctx.flags.json) {
|
|
31
|
+
let report = { range: range.data.toString(), address: address.data.toString(), contains };
|
|
32
|
+
ctx.stdout(`${JSON.stringify(report, null, 2)}\n`);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
ctx.stdout(`${address.data.toString()} is ${contains ? "inside" : "outside"} ${range.data.toString()}\n`);
|
|
36
|
+
}
|
|
37
|
+
return contains ? success() : exit(1);
|
|
38
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sdxc-ip` command line as a contract: `classify` names what an address is for, and
|
|
3
|
+
* `contains` tests whether a range holds an address. Help, usage errors and each handler's
|
|
4
|
+
* typed `ctx.arguments` and `ctx.flags` all come from this one declaration.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** Every command `sdxc-ip` takes. */
|
|
10
|
+
export declare const IP_COMMANDS: import("@sdxc/cli").Group<Omit<{
|
|
11
|
+
readonly description: "sdxc-ip — IPv4 and IPv6 addresses against the IANA special-purpose registries";
|
|
12
|
+
readonly classify: import("@sdxc/cli").Command<{
|
|
13
|
+
readonly address: import("@sdxc/cli").Argument<string>;
|
|
14
|
+
}, {
|
|
15
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
16
|
+
}>;
|
|
17
|
+
readonly contains: import("@sdxc/cli").Command<{
|
|
18
|
+
readonly range: import("@sdxc/cli").Argument<string>;
|
|
19
|
+
readonly address: import("@sdxc/cli").Argument<string>;
|
|
20
|
+
}, {
|
|
21
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
22
|
+
}>;
|
|
23
|
+
}, "description" | "hidden" | "arguments" | "flags" | "tasks">, Record<never, never>, Record<never, never>>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sdxc-ip` command line as a contract: `classify` names what an address is for, and
|
|
3
|
+
* `contains` tests whether a range holds an address. Help, usage errors and each handler's
|
|
4
|
+
* typed `ctx.arguments` and `ctx.flags` all come from this one declaration.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { argument, command, commands, flag } from "@sdxc/cli";
|
|
10
|
+
/**
|
|
11
|
+
* Accepts any text, so the package needs no schema library; each handler parses the text
|
|
12
|
+
* itself and answers its own exit 2 naming the rejected value.
|
|
13
|
+
*/
|
|
14
|
+
const TEXT = {
|
|
15
|
+
"~standard": {
|
|
16
|
+
version: 1,
|
|
17
|
+
vendor: "@sdxc/ip",
|
|
18
|
+
types: { input: undefined, output: "" },
|
|
19
|
+
validate(value) {
|
|
20
|
+
if (typeof value === "string")
|
|
21
|
+
return { value };
|
|
22
|
+
return { issues: [{ message: `expects text; got ${JSON.stringify(value)}` }] };
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
/** The `--json` flag every report-printing command takes. */
|
|
27
|
+
function json() {
|
|
28
|
+
return flag.boolean("json", { description: "Print the result as JSON" });
|
|
29
|
+
}
|
|
30
|
+
/** Every command `sdxc-ip` takes. */
|
|
31
|
+
export const IP_COMMANDS = commands({
|
|
32
|
+
description: "sdxc-ip — IPv4 and IPv6 addresses against the IANA special-purpose registries",
|
|
33
|
+
classify: command({
|
|
34
|
+
description: "Print an address's version, canonical form and IANA special-purpose classification",
|
|
35
|
+
arguments: {
|
|
36
|
+
address: argument(TEXT, {
|
|
37
|
+
default: "-",
|
|
38
|
+
description: "The address, or - to read it from stdin",
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
flags: { json: json() },
|
|
42
|
+
examples: ["classify 192.168.1.10", "classify ::ffff:10.0.0.1 --json", "classify - < ip.txt"],
|
|
43
|
+
}),
|
|
44
|
+
contains: command({
|
|
45
|
+
description: "Exit 0 when the range holds the address and 1 when it does not",
|
|
46
|
+
arguments: {
|
|
47
|
+
range: argument(TEXT, { description: "The range as network/prefix" }),
|
|
48
|
+
address: argument(TEXT, {
|
|
49
|
+
default: "-",
|
|
50
|
+
description: "The address, or - to read it from stdin",
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
flags: { json: json() },
|
|
54
|
+
examples: ["contains 10.0.0.0/8 10.1.2.3", "contains 2001:db8::/32 2001:db8::1 --json"],
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every `sdxc-ip` command shares: reading the address from its argument or stdin and
|
|
3
|
+
* parsing it, so both commands refuse the same input with the same exit 2 and message.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
import type { ExitError, HandlerContext } from "@sdxc/cli";
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { IP_COMMANDS } from "./cli-contract.js";
|
|
11
|
+
import { IP } from "./ip.js";
|
|
12
|
+
/** The stdin every command reads a `-` address from. */
|
|
13
|
+
type Stdin = HandlerContext<typeof IP_COMMANDS.classify>["stdin"];
|
|
14
|
+
/**
|
|
15
|
+
* The address an argument names: the text as typed, or stdin's text trimmed for `-`. A
|
|
16
|
+
* terminal on stdin exits 2, so a command never waits on a person typing.
|
|
17
|
+
*
|
|
18
|
+
* @param value - The argument as typed
|
|
19
|
+
* @param stdin - The run's stdin, read when `value` is `-`
|
|
20
|
+
* @returns The parsed address, or an exit 2 naming the rejected text
|
|
21
|
+
*/
|
|
22
|
+
export declare function readAddress(value: string, stdin: Stdin): Promise<Result<IP, ExitError>>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every `sdxc-ip` command shares: reading the address from its argument or stdin and
|
|
3
|
+
* parsing it, so both commands refuse the same input with the same exit 2 and message.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
import { exit } from "@sdxc/cli";
|
|
9
|
+
import { isFailure, success } from "@sdxc/result";
|
|
10
|
+
import { IP } from "./ip.js";
|
|
11
|
+
/**
|
|
12
|
+
* The address an argument names: the text as typed, or stdin's text trimmed for `-`. A
|
|
13
|
+
* terminal on stdin exits 2, so a command never waits on a person typing.
|
|
14
|
+
*
|
|
15
|
+
* @param value - The argument as typed
|
|
16
|
+
* @param stdin - The run's stdin, read when `value` is `-`
|
|
17
|
+
* @returns The parsed address, or an exit 2 naming the rejected text
|
|
18
|
+
*/
|
|
19
|
+
export async function readAddress(value, stdin) {
|
|
20
|
+
let text = value;
|
|
21
|
+
if (text === "-") {
|
|
22
|
+
if (stdin.isTTY)
|
|
23
|
+
return exit(2, "No address: pass one, or pipe it on stdin");
|
|
24
|
+
text = (await stdin.text()).trim();
|
|
25
|
+
}
|
|
26
|
+
let parsed = IP.parse(text);
|
|
27
|
+
if (isFailure(parsed))
|
|
28
|
+
return exit(2, `Not an IP address: ${JSON.stringify(text)}`);
|
|
29
|
+
return success(parsed.data);
|
|
30
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `sdxc-ip` command-line program: the contract in `cli-contract.ts` with each command mapped
|
|
4
|
+
* onto its handler, which loads only when that command runs. Exit codes: 0 done, 1 the address
|
|
5
|
+
* is outside the range, 2 usage or text that is not an address or a range.
|
|
6
|
+
*
|
|
7
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
|
+
* @copyright Sergio Xalambrí 2026
|
|
9
|
+
*/
|
|
10
|
+
/** The `sdxc-ip` program, run by the bin entry below and by tests through `invoke()`. */
|
|
11
|
+
export declare const cli: import("@sdxc/cli").CLI<import("@sdxc/cli").Group<Omit<{
|
|
12
|
+
readonly description: "sdxc-ip — IPv4 and IPv6 addresses against the IANA special-purpose registries";
|
|
13
|
+
readonly classify: import("@sdxc/cli").Command<{
|
|
14
|
+
readonly address: import("@sdxc/cli").Argument<string>;
|
|
15
|
+
}, {
|
|
16
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
17
|
+
}>;
|
|
18
|
+
readonly contains: import("@sdxc/cli").Command<{
|
|
19
|
+
readonly range: import("@sdxc/cli").Argument<string>;
|
|
20
|
+
readonly address: import("@sdxc/cli").Argument<string>;
|
|
21
|
+
}, {
|
|
22
|
+
readonly json: import("@sdxc/cli").Flag<boolean>;
|
|
23
|
+
}>;
|
|
24
|
+
}, "description" | "hidden" | "arguments" | "flags" | "tasks">, Record<never, never>, Record<never, never>>, []>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `sdxc-ip` command-line program: the contract in `cli-contract.ts` with each command mapped
|
|
4
|
+
* onto its handler, which loads only when that command runs. Exit codes: 0 done, 1 the address
|
|
5
|
+
* is outside the range, 2 usage or text that is not an address or a range.
|
|
6
|
+
*
|
|
7
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
|
+
* @copyright Sergio Xalambrí 2026
|
|
9
|
+
*/
|
|
10
|
+
import { createCLI, lazy } from "@sdxc/cli";
|
|
11
|
+
import { IP_COMMANDS } from "./cli-contract.js";
|
|
12
|
+
/** The `sdxc-ip` program, run by the bin entry below and by tests through `invoke()`. */
|
|
13
|
+
export const cli = createCLI({ name: "sdxc-ip", commands: IP_COMMANDS });
|
|
14
|
+
cli.map(IP_COMMANDS.classify, lazy(() => import("./cli-classify.js")));
|
|
15
|
+
cli.map(IP_COMMANDS.contains, lazy(() => import("./cli-contains.js")));
|
|
16
|
+
if (import.meta.main) {
|
|
17
|
+
process.exit(await cli.run(process.argv.slice(2)));
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/ip",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.11.3",
|
|
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
|
+
"bin": {
|
|
7
|
+
"sdxc-ip": "dist/cli.js"
|
|
8
|
+
},
|
|
6
9
|
"type": "module",
|
|
7
10
|
"exports": {
|
|
8
11
|
".": "./dist/index.js"
|
|
9
12
|
},
|
|
10
13
|
"dependencies": {
|
|
11
|
-
"@sdxc/
|
|
14
|
+
"@sdxc/cli": "2026.11.3",
|
|
15
|
+
"@sdxc/result": "2026.10.13"
|
|
12
16
|
},
|
|
13
|
-
"gitHead": "
|
|
17
|
+
"gitHead": "d1dd4030f8df68b8a170940a3f790ea8654d3676",
|
|
14
18
|
"publishConfig": {
|
|
15
19
|
"access": "public"
|
|
16
20
|
},
|
|
@@ -19,7 +23,7 @@
|
|
|
19
23
|
"url": "git+https://github.com/sergiodxa/monorepo.git",
|
|
20
24
|
"directory": "packages/ip"
|
|
21
25
|
},
|
|
22
|
-
"homepage": "https://sdxc.
|
|
26
|
+
"homepage": "https://sdxc.dev/api/ip",
|
|
23
27
|
"author": {
|
|
24
28
|
"name": "Sergio Xalambrí",
|
|
25
29
|
"url": "https://sergiodxa.com"
|