@sdxc/ip 0.0.0-pre.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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergio Xalambrí
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,179 @@
1
+ # @sdxc/ip
2
+
3
+ IPv4 and IPv6 addresses and ranges as value objects that classify against the IANA special-purpose registries.
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.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm add @sdxc/ip
11
+ ```
12
+
13
+ Parsing answers a `Result` from [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result), installed with this package.
14
+
15
+ ## Usage
16
+
17
+ ### Parsing An Address
18
+
19
+ ```typescript
20
+ import { IP } from "@sdxc/ip";
21
+ import { isFailure } from "@sdxc/result";
22
+
23
+ let parsed = IP.parse("2001:DB8::0:1");
24
+ if (isFailure(parsed)) return parsed; // IP.Error { code: "invalid-address" }
25
+
26
+ let ip = parsed.data;
27
+ ip.version; // 6
28
+ ip.toString(); // "2001:db8::1"
29
+ ip.isPublic; // false: documentation range
30
+ ```
31
+
32
+ ### Refusing A URL That Points Inside The Network
33
+
34
+ ```typescript
35
+ import { IP } from "@sdxc/ip";
36
+ import { isSuccess } from "@sdxc/result";
37
+
38
+ function isPublicHost(url: URL): boolean {
39
+ let ip = IP.parse(url.hostname);
40
+ return isSuccess(ip) && ip.data.isPublic;
41
+ }
42
+
43
+ isPublicHost(new URL("http://169.254.169.254/")); // false
44
+ isPublicHost(new URL("http://[::ffff:10.0.0.1]/")); // false
45
+ isPublicHost(new URL("http://[::ffff:8.8.8.8]/")); // true
46
+ ```
47
+
48
+ ### Testing A Range
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
+ ```
56
+
57
+ ### Keying A Rate Limit On The Client's Network
58
+
59
+ An IPv6 client is normally assigned a whole `/64`, so a key on the full address counts each address it rotates through separately.
60
+
61
+ ```typescript
62
+ ip.network({ v4: 32, v6: 64 }).toString(); // "2001:db8:1:2::/64" or "203.0.113.7/32"
63
+ ```
64
+
65
+ ## API
66
+
67
+ ### `IP.parse(text): Result<IP, IP.Error>`
68
+
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.
70
+
71
+ ### `IP`
72
+
73
+ Immutable, and only obtainable from `IP.parse`.
74
+
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"}`.
83
+
84
+ A structured clone (Durable Object storage, `postMessage`) drops the methods. Store `toString()` and parse it back on read.
85
+
86
+ ### `IP.Range.parse(text): Result<IP.Range, IP.Error>`
87
+
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`.
89
+
90
+ ### `IP.Range`
91
+
92
+ Immutable, and only obtainable from `IP.Range.parse` or `ip.network`.
93
+
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`.
99
+
100
+ ### `IP.Error`
101
+
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.
103
+
104
+ ### Types
105
+
106
+ `IP.Range`, `IP.Error`, `IP.ErrorCode`, `IP.Classification` and `IP.Prefixes` (`{ v4: number; v6: number }`) are types under the same `IP` name.
107
+
108
+ ## Pattern: An Allowlist Of Networks
109
+
110
+ ```typescript
111
+ import { IP } from "@sdxc/ip";
112
+ import { isSuccess } from "@sdxc/result";
113
+
114
+ const OFFICE_RANGES = ["203.0.113.0/24", "2001:db8:42::/48"];
115
+
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
+ });
121
+ }
122
+ ```
123
+
124
+ ## Pattern: Checking Every Address A Name Resolves To
125
+
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:
127
+
128
+ ```typescript
129
+ import { IP } from "@sdxc/ip";
130
+ import { isSuccess } from "@sdxc/result";
131
+
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
+ );
140
+ }
141
+ ```
142
+
143
+ ## Pattern: A Function That Only Accepts A Checked Address
144
+
145
+ ```typescript
146
+ import type { IP } from "@sdxc/ip";
147
+
148
+ function recordProbe(target: IP, at: number) {
149
+ return { target: target.toString(), at };
150
+ }
151
+
152
+ recordProbe("10.0.0.1", Date.now()); // type error: a string is not an IP
153
+ ```
154
+
155
+ ## Versioning
156
+
157
+ Releases are dated rather than semantic. A version is the UTC date it was published, written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one release goes out per day.
158
+
159
+ Those numbers say when, not what: a later date means a later release and carries no compatibility promise. Any release may change or remove an export.
160
+
161
+ Depend on one exact date, and move it when you are ready to take the change:
162
+
163
+ ```json
164
+ {
165
+ "dependencies": {
166
+ "@sdxc/ip": "2026.9.4"
167
+ }
168
+ }
169
+ ```
170
+
171
+ A caret or tilde range reads the date as major, minor and patch, so it accepts every later release in the same year. An exact version keeps the upgrade yours to schedule.
172
+
173
+ ## License
174
+
175
+ MIT
176
+
177
+ ## Author
178
+
179
+ [Sergio Xalambrí](https://sergiodxa.com)
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The numeric form every address and range in the package is built on: a version
3
+ * and the address as one unsigned integer. Parsing text into it and printing it as
4
+ * RFC 5952 canonical text live here, so both classes agree on a single spelling.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /**
10
+ * An address as a number: 32 bits for IPv4, 128 for IPv6. A `bigint` for both
11
+ * versions, so masking and comparison are the same operations whatever the width.
12
+ */
13
+ export interface Address {
14
+ version: 4 | 6;
15
+ value: bigint;
16
+ }
17
+ /** How many bits an address of each version holds. */
18
+ export declare const ADDRESS_BITS: {
19
+ readonly 4: 32;
20
+ readonly 6: 128;
21
+ };
22
+ /**
23
+ * Reads the text a URL host, a header or a DNS answer carries into an address.
24
+ * Brackets are accepted around IPv6 only, the way a URL host spells it.
25
+ *
26
+ * @param text - The address as written.
27
+ * @returns The address, or `null` when the text is not one.
28
+ */
29
+ export declare function parseAddress(text: string): Address | null;
30
+ /**
31
+ * Prints an address as its one canonical spelling, so two spellings of the same
32
+ * address always print the same string.
33
+ *
34
+ * @param address - The address to print.
35
+ * @returns Dotted decimal for IPv4, RFC 5952 text for IPv6, without brackets.
36
+ */
37
+ export declare function formatAddress(address: Address): string;
38
+ /**
39
+ * Clears every bit past the first `prefix`, giving the first address of the network
40
+ * an address sits in.
41
+ *
42
+ * @param address - The address to mask.
43
+ * @param prefix - How many leading bits to keep, already within the version's width.
44
+ * @returns The network address.
45
+ */
46
+ export declare function maskAddress(address: Address, prefix: number): Address;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The numeric form every address and range in the package is built on: a version
3
+ * and the address as one unsigned integer. Parsing text into it and printing it as
4
+ * RFC 5952 canonical text live here, so both classes agree on a single spelling.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** How many bits an address of each version holds. */
10
+ export const ADDRESS_BITS = { 4: 32, 6: 128 };
11
+ /**
12
+ * A decimal octet as `URL` prints one: no leading zero, so `010` is refused rather
13
+ * than read as decimal ten by one parser and octal eight by another.
14
+ */
15
+ const IPV4_OCTET = /^(?:0|[1-9]\d{0,2})$/;
16
+ /** One IPv6 group: up to four hex digits, either case. */
17
+ const IPV6_GROUP = /^[\da-f]{1,4}$/i;
18
+ /** The first 80 bits of every IPv4-mapped address are zero, then sixteen ones. */
19
+ const IPV4_MAPPED_PREFIX = 0xffffn << 32n;
20
+ /**
21
+ * Reads a dotted quad into its 32-bit value.
22
+ *
23
+ * @param text - Four decimal octets.
24
+ * @returns The value, or `null` for any other spelling, octal, hex and bare integers included.
25
+ */
26
+ function parseIPv4(text) {
27
+ let parts = text.split(".");
28
+ if (parts.length !== 4)
29
+ return null;
30
+ let value = 0n;
31
+ for (let part of parts) {
32
+ if (!IPV4_OCTET.test(part))
33
+ return null;
34
+ let octet = Number(part);
35
+ if (octet > 255)
36
+ return null;
37
+ value = (value << 8n) | BigInt(octet);
38
+ }
39
+ return value;
40
+ }
41
+ /**
42
+ * Converts colon-separated groups into 16-bit numbers, expanding a trailing dotted
43
+ * quad into the two groups it occupies.
44
+ *
45
+ * @param parts - The groups on one side of a `::`, or all of them when there is none.
46
+ * @param last - Whether these groups end the address, the only place a dotted quad may sit.
47
+ * @returns The groups, or `null` when any of them is malformed.
48
+ */
49
+ function parseGroups(parts, last) {
50
+ let groups = [];
51
+ for (let [index, part] of parts.entries()) {
52
+ if (part.includes(".")) {
53
+ if (!last || index !== parts.length - 1)
54
+ return null;
55
+ let embedded = parseIPv4(part);
56
+ if (embedded === null)
57
+ return null;
58
+ groups.push(Number(embedded >> 16n), Number(embedded & 0xffffn));
59
+ continue;
60
+ }
61
+ if (!IPV6_GROUP.test(part))
62
+ return null;
63
+ groups.push(Number.parseInt(part, 16));
64
+ }
65
+ return groups;
66
+ }
67
+ /**
68
+ * Reads IPv6 text into its 128-bit value. A `::` must stand for at least one group,
69
+ * and a zone identifier (`%eth0`) fails, since no URL a Worker fetches carries one.
70
+ *
71
+ * @param text - An IPv6 address without brackets.
72
+ * @returns The value, or `null` when the text is not an address.
73
+ */
74
+ function parseIPv6(text) {
75
+ let halves = text.split("::");
76
+ if (halves.length > 2)
77
+ return null;
78
+ let [rawHead = "", rawTail] = halves;
79
+ let head = parseGroups(rawHead === "" ? [] : rawHead.split(":"), rawTail === undefined);
80
+ let tail = parseGroups(rawTail === undefined || rawTail === "" ? [] : rawTail.split(":"), true);
81
+ if (head === null || tail === null)
82
+ return null;
83
+ let groups = head;
84
+ if (rawTail !== undefined) {
85
+ let missing = 8 - head.length - tail.length;
86
+ if (missing < 1)
87
+ return null;
88
+ groups = [...head, ...Array.from({ length: missing }, () => 0), ...tail];
89
+ }
90
+ if (groups.length !== 8)
91
+ return null;
92
+ return groups.reduce((value, group) => (value << 16n) | BigInt(group), 0n);
93
+ }
94
+ /**
95
+ * Reads the text a URL host, a header or a DNS answer carries into an address.
96
+ * Brackets are accepted around IPv6 only, the way a URL host spells it.
97
+ *
98
+ * @param text - The address as written.
99
+ * @returns The address, or `null` when the text is not one.
100
+ */
101
+ export function parseAddress(text) {
102
+ if (text.startsWith("[") && text.endsWith("]")) {
103
+ let value = parseIPv6(text.slice(1, -1));
104
+ return value === null ? null : { version: 6, value };
105
+ }
106
+ if (text.includes(":")) {
107
+ let value = parseIPv6(text);
108
+ return value === null ? null : { version: 6, value };
109
+ }
110
+ let value = parseIPv4(text);
111
+ return value === null ? null : { version: 4, value };
112
+ }
113
+ /**
114
+ * Prints a 32-bit value as a dotted quad.
115
+ *
116
+ * @param value - The IPv4 address.
117
+ * @returns Four decimal octets.
118
+ */
119
+ function formatIPv4(value) {
120
+ return [24n, 16n, 8n, 0n].map((shift) => String((value >> shift) & 0xffn)).join(".");
121
+ }
122
+ /**
123
+ * Prints a 128-bit value as RFC 5952 text: lowercase, no leading zeros, and the
124
+ * longest run of two or more zero groups compressed, the first one on a tie. An
125
+ * IPv4-mapped address ends in a dotted quad, as section 5 of the RFC recommends.
126
+ *
127
+ * @param value - The IPv6 address.
128
+ * @returns The canonical text.
129
+ */
130
+ function formatIPv6(value) {
131
+ if (value >> 32n === IPV4_MAPPED_PREFIX >> 32n) {
132
+ return `::ffff:${formatIPv4(value & 0xffffffffn)}`;
133
+ }
134
+ let groups = Array.from({ length: 8 }, (_, index) => Number((value >> BigInt(112 - index * 16)) & 0xffffn));
135
+ let bestStart = -1;
136
+ let bestLength = 1;
137
+ let runStart = -1;
138
+ for (let [index, group] of [...groups, 1].entries()) {
139
+ if (group === 0) {
140
+ if (runStart === -1)
141
+ runStart = index;
142
+ continue;
143
+ }
144
+ if (runStart !== -1 && index - runStart > bestLength) {
145
+ bestStart = runStart;
146
+ bestLength = index - runStart;
147
+ }
148
+ runStart = -1;
149
+ }
150
+ let hex = groups.map((group) => group.toString(16));
151
+ if (bestStart === -1)
152
+ return hex.join(":");
153
+ let head = hex.slice(0, bestStart).join(":");
154
+ let tail = hex.slice(bestStart + bestLength).join(":");
155
+ return `${head}::${tail}`;
156
+ }
157
+ /**
158
+ * Prints an address as its one canonical spelling, so two spellings of the same
159
+ * address always print the same string.
160
+ *
161
+ * @param address - The address to print.
162
+ * @returns Dotted decimal for IPv4, RFC 5952 text for IPv6, without brackets.
163
+ */
164
+ export function formatAddress(address) {
165
+ return address.version === 4 ? formatIPv4(address.value) : formatIPv6(address.value);
166
+ }
167
+ /**
168
+ * Clears every bit past the first `prefix`, giving the first address of the network
169
+ * an address sits in.
170
+ *
171
+ * @param address - The address to mask.
172
+ * @param prefix - How many leading bits to keep, already within the version's width.
173
+ * @returns The network address.
174
+ */
175
+ export function maskAddress(address, prefix) {
176
+ let hostBits = BigInt(ADDRESS_BITS[address.version] - prefix);
177
+ return { version: address.version, value: (address.value >> hostBits) << hostBits };
178
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The failure parsing returns for text that is not an address or a range. It keeps
3
+ * a machine-readable code and the rejected text, so a caller can answer with its
4
+ * own wording and a log can show what arrived.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /**
10
+ * Why text was refused: not an address, not a `network/prefix` range, or a range
11
+ * whose address has bits set past its prefix (`10.0.0.1/8`), which names a host
12
+ * where a network was meant.
13
+ */
14
+ export type IPErrorCode = "invalid-address" | "invalid-range" | "host-bits-set";
15
+ /** Returned inside a `Failure` by `IP.parse` and `IP.Range.parse`, never thrown. */
16
+ export declare class IPError extends Error {
17
+ /** Machine-readable cause, for a caller that maps it to its own message. */
18
+ readonly code: IPErrorCode;
19
+ /** The rejected text, verbatim. */
20
+ readonly input: string;
21
+ /**
22
+ * @param code - Why the text was refused.
23
+ * @param input - The text as it was given.
24
+ */
25
+ constructor(code: IPErrorCode, input: string);
26
+ }
package/dist/error.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The failure parsing returns for text that is not an address or a range. It keeps
3
+ * a machine-readable code and the rejected text, so a caller can answer with its
4
+ * own wording and a log can show what arrived.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** Returned inside a `Failure` by `IP.parse` and `IP.Range.parse`, never thrown. */
10
+ export class IPError extends Error {
11
+ /** Machine-readable cause, for a caller that maps it to its own message. */
12
+ code;
13
+ /** The rejected text, verbatim. */
14
+ input;
15
+ /**
16
+ * @param code - Why the text was refused.
17
+ * @param input - The text as it was given.
18
+ */
19
+ constructor(code, input) {
20
+ super(`${code}: ${JSON.stringify(input)}`);
21
+ this.name = "IPError";
22
+ this.code = code;
23
+ this.input = input;
24
+ }
25
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The package entrypoint: one `IP` export carrying the address value object, the
3
+ * `IP.Range` network class, the `IP.Error` failure, and the types merged under the
4
+ * same name. It performs no I/O, so it runs wherever an address arrives as text.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export { IP } from "./ip.js";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The package entrypoint: one `IP` export carrying the address value object, the
3
+ * `IP.Range` network class, the `IP.Error` failure, and the types merged under the
4
+ * same name. It performs no I/O, so it runs wherever an address arrives as text.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export { IP } from "./ip.js";
package/dist/ip.d.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The `IP` value object and the `IP.Range` it carries: parsed, valid, immutable
3
+ * addresses and networks that classify themselves, test membership and print as
4
+ * canonical text. Parsing returns a `Result`, so nothing here throws.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { IPErrorCode } from "./error.js";
11
+ import type { Classification as IPClassification } from "./special-purpose.js";
12
+ import { IPError } from "./error.js";
13
+ /**
14
+ * A network: the first address and how many leading bits every member shares.
15
+ * Its address never has bits set past the prefix, so one network has one spelling.
16
+ *
17
+ * @example
18
+ * let range = IP.Range.parse("10.0.0.0/8");
19
+ * if (isSuccess(range)) range.data.contains(ip);
20
+ */
21
+ declare class IPRange {
22
+ /**
23
+ * Parses `network/prefix` text, IPv4 or IPv6.
24
+ *
25
+ * @param text - The range as written, such as `10.0.0.0/8` or `2001:db8::/32`.
26
+ * @returns The range, or an `IP.Error` coded `invalid-range` for malformed text and
27
+ * `host-bits-set` when the address names a host inside the network.
28
+ *
29
+ * @example
30
+ * IP.Range.parse("10.0.0.0/8"); // success
31
+ * @example
32
+ * IP.Range.parse("10.0.0.1/8"); // failure: host-bits-set
33
+ */
34
+ static parse(text: string): Result<IPRange, IPError>;
35
+ /** The first address of the network. */
36
+ readonly network: IP;
37
+ /** How many leading bits every member shares. */
38
+ readonly prefix: number;
39
+ /**
40
+ * @param network - The network's first address, host bits already cleared.
41
+ * @param prefix - The prefix length, within the address's width.
42
+ */
43
+ private constructor();
44
+ /** `4` or `6`, the version of every address in the range. */
45
+ get version(): 4 | 6;
46
+ /**
47
+ * Whether an address is a member. An address of the other version is never one,
48
+ * so an IPv4-mapped IPv6 address is outside every IPv4 range.
49
+ *
50
+ * @param ip - The address to test.
51
+ * @returns `true` when the address shares the range's leading bits.
52
+ */
53
+ contains(ip: IP): boolean;
54
+ /**
55
+ * Compares by value, since two ranges parsed from the same text are two objects.
56
+ *
57
+ * @param other - The range to compare with.
58
+ * @returns `true` for the same network and prefix.
59
+ */
60
+ equals(other: IPRange): boolean;
61
+ /**
62
+ * The canonical `network/prefix` text, the same for every spelling of one range,
63
+ * which makes it the key for a map, a rate limit or a database column.
64
+ *
65
+ * @returns Such as `10.0.0.0/8` or `2001:db8::/32`.
66
+ */
67
+ toString(): string;
68
+ /**
69
+ * Serializes as the canonical text, so a range goes into a log or a response as is.
70
+ *
71
+ * @returns The same string as {@link IPRange.toString}.
72
+ */
73
+ toJSON(): string;
74
+ }
75
+ /**
76
+ * One parsed, valid IPv4 or IPv6 address. A function that takes an `IP` holds an
77
+ * address that was checked, never `"unknown"`, `"10.0.0"` or a hostname. Compare two
78
+ * with {@link IP.equals}, and key a map or a column on {@link IP.toString}.
79
+ *
80
+ * @example
81
+ * let parsed = IP.parse("2001:DB8::0:1");
82
+ * if (isFailure(parsed)) return parsed;
83
+ * parsed.data.toString(); // "2001:db8::1"
84
+ */
85
+ export declare class IP {
86
+ #private;
87
+ /**
88
+ * The network class: `IP.Range.parse("10.0.0.0/8")`, and `IP.Range` as the type.
89
+ */
90
+ static readonly Range: typeof IPRange;
91
+ /**
92
+ * The failure parsing returns, so `error instanceof IP.Error` narrows it.
93
+ */
94
+ static readonly Error: typeof IPError;
95
+ /**
96
+ * Parses the text a URL host, a header or a DNS answer carries. Brackets around
97
+ * IPv6 are accepted (`[::1]`). IPv4 must be canonical dotted decimal, so octal,
98
+ * hex and bare integers fail rather than naming a different address than they
99
+ * appear to; zone identifiers (`fe80::1%eth0`) fail too.
100
+ *
101
+ * @param text - The address as written.
102
+ * @returns The address, or an `IP.Error` coded `invalid-address`.
103
+ *
104
+ * @example
105
+ * IP.parse("[::1]"); // success
106
+ * @example
107
+ * IP.parse("010.0.0.1"); // failure: invalid-address
108
+ */
109
+ static parse(text: string): Result<IP, IPError>;
110
+ /** `4` or `6`. */
111
+ readonly version: 4 | 6;
112
+ /**
113
+ * @param address - An address the parser produced.
114
+ */
115
+ private constructor();
116
+ /**
117
+ * What the IANA special-purpose registries say the address is for, judging an
118
+ * IPv4-mapped, NAT64 or 6to4 address by the IPv4 inside it.
119
+ */
120
+ get classification(): IPClassification;
121
+ /**
122
+ * Whether the address is on the public internet: `classification === "public"`,
123
+ * so `::ffff:10.0.0.1` is not and `::ffff:8.8.8.8` is.
124
+ */
125
+ get isPublic(): boolean;
126
+ /**
127
+ * The IPv4 address an IPv4-mapped (`::ffff:0:0/96`), NAT64 (`64:ff9b::/96`) or
128
+ * 6to4 (`2002::/16`) address carries, or `null` for any other address.
129
+ */
130
+ get embeddedIPv4(): IP | null;
131
+ /**
132
+ * The network this address sits in, at the prefix length given for its version.
133
+ * A length outside the version's width is clamped into it.
134
+ *
135
+ * @param prefixes - The prefix length to use for each version.
136
+ * @returns The range, whose text keys a rate limit on a client's network.
137
+ *
138
+ * @example
139
+ * ip.network({ v4: 32, v6: 64 }).toString(); // "2001:db8:1:2::/64"
140
+ */
141
+ network(prefixes: IP.Prefixes): IPRange;
142
+ /**
143
+ * Compares by value, since two `IP`s parsed from the same text are two objects.
144
+ *
145
+ * @param other - The address to compare with.
146
+ * @returns `true` for the same version and the same address.
147
+ */
148
+ equals(other: IP): boolean;
149
+ /**
150
+ * The canonical text: dotted decimal, or RFC 5952 IPv6 without brackets. Every
151
+ * spelling of one address prints the same string.
152
+ *
153
+ * @returns Such as `203.0.113.7` or `2001:db8::1`.
154
+ */
155
+ toString(): string;
156
+ /**
157
+ * Serializes as the canonical text, so an address goes into a log or a response
158
+ * as is. A structured clone drops the methods; store the text and parse it back.
159
+ *
160
+ * @returns The same string as {@link IP.toString}.
161
+ */
162
+ toJSON(): string;
163
+ }
164
+ export declare namespace IP {
165
+ /** A network, as `IP.Range.parse` and `ip.network` answer it. */
166
+ type Range = IPRange;
167
+ /** The failure `IP.parse` and `IP.Range.parse` return. */
168
+ type Error = IPError;
169
+ /** Why parsing failed. */
170
+ type ErrorCode = IPErrorCode;
171
+ /** What an address is for, as the special-purpose registries name it. */
172
+ type Classification = IPClassification;
173
+ /** A prefix length per version, since the same length means different things for each. */
174
+ interface Prefixes {
175
+ v4: number;
176
+ v6: number;
177
+ }
178
+ }
179
+ export {};
package/dist/ip.js ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The `IP` value object and the `IP.Range` it carries: parsed, valid, immutable
3
+ * addresses and networks that classify themselves, test membership and print as
4
+ * canonical text. Parsing returns a `Result`, so nothing here throws.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { failure, success } from "@sdxc/result";
10
+ import { ADDRESS_BITS, formatAddress, maskAddress, parseAddress } from "./address.js";
11
+ import { IPError } from "./error.js";
12
+ import { classify, embeddedIPv4 } from "./special-purpose.js";
13
+ /** A prefix length: `0`, or a decimal integer without a leading zero. */
14
+ const PREFIX_LENGTH = /^(?:0|[1-9]\d{0,2})$/;
15
+ /**
16
+ * Builds an `IP` from a parsed address. Assigned by the class itself, so the
17
+ * constructor stays private and every instance still comes from parsed text.
18
+ */
19
+ let createIP;
20
+ /** Reads the address an `IP` holds, assigned by the class for the same reason. */
21
+ let addressOf;
22
+ /** Builds an `IP.Range`, assigned by the range class so its constructor stays private. */
23
+ let createRange;
24
+ /**
25
+ * A network: the first address and how many leading bits every member shares.
26
+ * Its address never has bits set past the prefix, so one network has one spelling.
27
+ *
28
+ * @example
29
+ * let range = IP.Range.parse("10.0.0.0/8");
30
+ * if (isSuccess(range)) range.data.contains(ip);
31
+ */
32
+ class IPRange {
33
+ /**
34
+ * Parses `network/prefix` text, IPv4 or IPv6.
35
+ *
36
+ * @param text - The range as written, such as `10.0.0.0/8` or `2001:db8::/32`.
37
+ * @returns The range, or an `IP.Error` coded `invalid-range` for malformed text and
38
+ * `host-bits-set` when the address names a host inside the network.
39
+ *
40
+ * @example
41
+ * IP.Range.parse("10.0.0.0/8"); // success
42
+ * @example
43
+ * IP.Range.parse("10.0.0.1/8"); // failure: host-bits-set
44
+ */
45
+ static parse(text) {
46
+ let slash = text.lastIndexOf("/");
47
+ let address = slash === -1 ? null : parseAddress(text.slice(0, slash));
48
+ let length = text.slice(slash + 1);
49
+ if (address === null || !PREFIX_LENGTH.test(length)) {
50
+ return failure(new IPError("invalid-range", text));
51
+ }
52
+ let prefix = Number(length);
53
+ if (prefix > ADDRESS_BITS[address.version])
54
+ return failure(new IPError("invalid-range", text));
55
+ if (maskAddress(address, prefix).value !== address.value) {
56
+ return failure(new IPError("host-bits-set", text));
57
+ }
58
+ return success(new IPRange(createIP(address), prefix));
59
+ }
60
+ /** The first address of the network. */
61
+ network;
62
+ /** How many leading bits every member shares. */
63
+ prefix;
64
+ /**
65
+ * @param network - The network's first address, host bits already cleared.
66
+ * @param prefix - The prefix length, within the address's width.
67
+ */
68
+ constructor(network, prefix) {
69
+ this.network = network;
70
+ this.prefix = prefix;
71
+ Object.freeze(this);
72
+ }
73
+ static {
74
+ createRange = (network, prefix) => new IPRange(network, prefix);
75
+ }
76
+ /** `4` or `6`, the version of every address in the range. */
77
+ get version() {
78
+ return this.network.version;
79
+ }
80
+ /**
81
+ * Whether an address is a member. An address of the other version is never one,
82
+ * so an IPv4-mapped IPv6 address is outside every IPv4 range.
83
+ *
84
+ * @param ip - The address to test.
85
+ * @returns `true` when the address shares the range's leading bits.
86
+ */
87
+ contains(ip) {
88
+ let address = addressOf(ip);
89
+ if (address.version !== this.version)
90
+ return false;
91
+ return maskAddress(address, this.prefix).value === addressOf(this.network).value;
92
+ }
93
+ /**
94
+ * Compares by value, since two ranges parsed from the same text are two objects.
95
+ *
96
+ * @param other - The range to compare with.
97
+ * @returns `true` for the same network and prefix.
98
+ */
99
+ equals(other) {
100
+ return this.prefix === other.prefix && this.network.equals(other.network);
101
+ }
102
+ /**
103
+ * The canonical `network/prefix` text, the same for every spelling of one range,
104
+ * which makes it the key for a map, a rate limit or a database column.
105
+ *
106
+ * @returns Such as `10.0.0.0/8` or `2001:db8::/32`.
107
+ */
108
+ toString() {
109
+ return `${this.network.toString()}/${this.prefix}`;
110
+ }
111
+ /**
112
+ * Serializes as the canonical text, so a range goes into a log or a response as is.
113
+ *
114
+ * @returns The same string as {@link IPRange.toString}.
115
+ */
116
+ toJSON() {
117
+ return this.toString();
118
+ }
119
+ }
120
+ /**
121
+ * One parsed, valid IPv4 or IPv6 address. A function that takes an `IP` holds an
122
+ * address that was checked, never `"unknown"`, `"10.0.0"` or a hostname. Compare two
123
+ * with {@link IP.equals}, and key a map or a column on {@link IP.toString}.
124
+ *
125
+ * @example
126
+ * let parsed = IP.parse("2001:DB8::0:1");
127
+ * if (isFailure(parsed)) return parsed;
128
+ * parsed.data.toString(); // "2001:db8::1"
129
+ */
130
+ export class IP {
131
+ /**
132
+ * The network class: `IP.Range.parse("10.0.0.0/8")`, and `IP.Range` as the type.
133
+ */
134
+ static Range = IPRange;
135
+ /**
136
+ * The failure parsing returns, so `error instanceof IP.Error` narrows it.
137
+ */
138
+ static Error = IPError;
139
+ /**
140
+ * Parses the text a URL host, a header or a DNS answer carries. Brackets around
141
+ * IPv6 are accepted (`[::1]`). IPv4 must be canonical dotted decimal, so octal,
142
+ * hex and bare integers fail rather than naming a different address than they
143
+ * appear to; zone identifiers (`fe80::1%eth0`) fail too.
144
+ *
145
+ * @param text - The address as written.
146
+ * @returns The address, or an `IP.Error` coded `invalid-address`.
147
+ *
148
+ * @example
149
+ * IP.parse("[::1]"); // success
150
+ * @example
151
+ * IP.parse("010.0.0.1"); // failure: invalid-address
152
+ */
153
+ static parse(text) {
154
+ let address = parseAddress(text);
155
+ if (address === null)
156
+ return failure(new IPError("invalid-address", text));
157
+ return success(new IP(address));
158
+ }
159
+ /** The parsed address, the whole state of an `IP`. */
160
+ #address;
161
+ /** `4` or `6`. */
162
+ version;
163
+ /**
164
+ * @param address - An address the parser produced.
165
+ */
166
+ constructor(address) {
167
+ this.#address = address;
168
+ this.version = address.version;
169
+ Object.freeze(this);
170
+ }
171
+ static {
172
+ createIP = (address) => new IP(address);
173
+ addressOf = (ip) => ip.#address;
174
+ }
175
+ /**
176
+ * What the IANA special-purpose registries say the address is for, judging an
177
+ * IPv4-mapped, NAT64 or 6to4 address by the IPv4 inside it.
178
+ */
179
+ get classification() {
180
+ return classify(this.#address);
181
+ }
182
+ /**
183
+ * Whether the address is on the public internet: `classification === "public"`,
184
+ * so `::ffff:10.0.0.1` is not and `::ffff:8.8.8.8` is.
185
+ */
186
+ get isPublic() {
187
+ return this.classification === "public";
188
+ }
189
+ /**
190
+ * The IPv4 address an IPv4-mapped (`::ffff:0:0/96`), NAT64 (`64:ff9b::/96`) or
191
+ * 6to4 (`2002::/16`) address carries, or `null` for any other address.
192
+ */
193
+ get embeddedIPv4() {
194
+ let inner = embeddedIPv4(this.#address);
195
+ return inner === null ? null : new IP(inner);
196
+ }
197
+ /**
198
+ * The network this address sits in, at the prefix length given for its version.
199
+ * A length outside the version's width is clamped into it.
200
+ *
201
+ * @param prefixes - The prefix length to use for each version.
202
+ * @returns The range, whose text keys a rate limit on a client's network.
203
+ *
204
+ * @example
205
+ * ip.network({ v4: 32, v6: 64 }).toString(); // "2001:db8:1:2::/64"
206
+ */
207
+ network(prefixes) {
208
+ let width = ADDRESS_BITS[this.version];
209
+ let requested = this.version === 4 ? prefixes.v4 : prefixes.v6;
210
+ let prefix = Math.min(width, Math.max(0, Math.trunc(requested) || 0));
211
+ return createRange(new IP(maskAddress(this.#address, prefix)), prefix);
212
+ }
213
+ /**
214
+ * Compares by value, since two `IP`s parsed from the same text are two objects.
215
+ *
216
+ * @param other - The address to compare with.
217
+ * @returns `true` for the same version and the same address.
218
+ */
219
+ equals(other) {
220
+ let address = addressOf(other);
221
+ return this.#address.version === address.version && this.#address.value === address.value;
222
+ }
223
+ /**
224
+ * The canonical text: dotted decimal, or RFC 5952 IPv6 without brackets. Every
225
+ * spelling of one address prints the same string.
226
+ *
227
+ * @returns Such as `203.0.113.7` or `2001:db8::1`.
228
+ */
229
+ toString() {
230
+ return formatAddress(this.#address);
231
+ }
232
+ /**
233
+ * Serializes as the canonical text, so an address goes into a log or a response
234
+ * as is. A structured clone drops the methods; store the text and parse it back.
235
+ *
236
+ * @returns The same string as {@link IP.toString}.
237
+ */
238
+ toJSON() {
239
+ return this.toString();
240
+ }
241
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The IANA IPv4 and IPv6 Special-Purpose Address Registries as one table, each row
3
+ * citing the RFC it comes from, plus the three IPv6 forms that carry an IPv4 address
4
+ * inside. Classifying an address is a longest-prefix match against this table.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Address } from "./address.js";
10
+ /**
11
+ * What a special-purpose range is for. `public` is every address no row claims, and
12
+ * the few rows inside a reserved block that the registry marks globally reachable.
13
+ */
14
+ export type Classification = "public" | "unspecified" | "loopback" | "private" | "shared" | "link-local" | "documentation" | "benchmarking" | "multicast" | "reserved" | "unique-local" | "teredo" | "discard";
15
+ /** One registry row, as written in the RFC that defines it. */
16
+ interface Row {
17
+ range: string;
18
+ classification: Classification;
19
+ rfc: string;
20
+ }
21
+ /**
22
+ * Every special-purpose range, both versions in one list. Where rows nest, the
23
+ * longest prefix decides, so `::1/128` is loopback inside the reserved `::/0`, and
24
+ * the globally reachable anycast addresses inside `192.0.0.0/24` stay public.
25
+ * Multicast and the IPv6 space outside `2000::/3` come from the address
26
+ * architecture rather than the special-purpose registries, and are listed here so one
27
+ * table answers for every address.
28
+ */
29
+ export declare const SPECIAL_PURPOSE_RANGES: readonly Row[];
30
+ /**
31
+ * The IPv6 prefixes that carry an IPv4 address, and the bit offset it starts at:
32
+ * IPv4-mapped (RFC 4291), NAT64 (RFC 6052) and 6to4 (RFC 3056).
33
+ */
34
+ export declare const EMBEDDING_RANGES: readonly {
35
+ range: string;
36
+ offset: number;
37
+ }[];
38
+ /**
39
+ * The IPv4 address an IPv6 address carries inside it, so an address like
40
+ * `::ffff:169.254.169.254` is judged by the IPv4 it reaches.
41
+ *
42
+ * @param address - Any address.
43
+ * @returns The embedded IPv4, or `null` for IPv4 and for IPv6 outside the three prefixes.
44
+ */
45
+ export declare function embeddedIPv4(address: Address): Address | null;
46
+ /**
47
+ * Classifies an address by its most specific row, after unwrapping an embedded
48
+ * IPv4, so `::ffff:10.0.0.1` is `private` and `::ffff:8.8.8.8` is `public`.
49
+ *
50
+ * @param address - Any address.
51
+ * @returns The classification of the most specific row, or `public` when none matches.
52
+ */
53
+ export declare function classify(address: Address): Classification;
54
+ export {};
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The IANA IPv4 and IPv6 Special-Purpose Address Registries as one table, each row
3
+ * citing the RFC it comes from, plus the three IPv6 forms that carry an IPv4 address
4
+ * inside. Classifying an address is a longest-prefix match against this table.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { ADDRESS_BITS, maskAddress, parseAddress } from "./address.js";
10
+ /**
11
+ * Every special-purpose range, both versions in one list. Where rows nest, the
12
+ * longest prefix decides, so `::1/128` is loopback inside the reserved `::/0`, and
13
+ * the globally reachable anycast addresses inside `192.0.0.0/24` stay public.
14
+ * Multicast and the IPv6 space outside `2000::/3` come from the address
15
+ * architecture rather than the special-purpose registries, and are listed here so one
16
+ * table answers for every address.
17
+ */
18
+ export const SPECIAL_PURPOSE_RANGES = [
19
+ { range: "0.0.0.0/8", classification: "reserved", rfc: "RFC 791" },
20
+ { range: "0.0.0.0/32", classification: "unspecified", rfc: "RFC 1122" },
21
+ { range: "10.0.0.0/8", classification: "private", rfc: "RFC 1918" },
22
+ { range: "100.64.0.0/10", classification: "shared", rfc: "RFC 6598" },
23
+ { range: "127.0.0.0/8", classification: "loopback", rfc: "RFC 1122" },
24
+ { range: "169.254.0.0/16", classification: "link-local", rfc: "RFC 3927" },
25
+ { range: "172.16.0.0/12", classification: "private", rfc: "RFC 1918" },
26
+ { range: "192.0.0.0/24", classification: "reserved", rfc: "RFC 6890" },
27
+ { range: "192.0.0.9/32", classification: "public", rfc: "RFC 7723" },
28
+ { range: "192.0.0.10/32", classification: "public", rfc: "RFC 8155" },
29
+ { range: "192.0.2.0/24", classification: "documentation", rfc: "RFC 5737" },
30
+ { range: "192.88.99.0/24", classification: "reserved", rfc: "RFC 7526" },
31
+ { range: "192.168.0.0/16", classification: "private", rfc: "RFC 1918" },
32
+ { range: "198.18.0.0/15", classification: "benchmarking", rfc: "RFC 2544" },
33
+ { range: "198.51.100.0/24", classification: "documentation", rfc: "RFC 5737" },
34
+ { range: "203.0.113.0/24", classification: "documentation", rfc: "RFC 5737" },
35
+ { range: "224.0.0.0/4", classification: "multicast", rfc: "RFC 5771" },
36
+ { range: "240.0.0.0/4", classification: "reserved", rfc: "RFC 1112" },
37
+ { range: "255.255.255.255/32", classification: "reserved", rfc: "RFC 919" },
38
+ { range: "::/0", classification: "reserved", rfc: "RFC 4291" },
39
+ { range: "::/128", classification: "unspecified", rfc: "RFC 4291" },
40
+ { range: "::1/128", classification: "loopback", rfc: "RFC 4291" },
41
+ { range: "64:ff9b:1::/48", classification: "reserved", rfc: "RFC 8215" },
42
+ { range: "100::/64", classification: "discard", rfc: "RFC 6666" },
43
+ { range: "100:0:0:1::/64", classification: "reserved", rfc: "RFC 9780" },
44
+ { range: "2000::/3", classification: "public", rfc: "RFC 4291" },
45
+ { range: "2001::/23", classification: "reserved", rfc: "RFC 2928" },
46
+ { range: "2001::/32", classification: "teredo", rfc: "RFC 4380" },
47
+ { range: "2001:1::1/128", classification: "public", rfc: "RFC 7723" },
48
+ { range: "2001:1::2/128", classification: "public", rfc: "RFC 8155" },
49
+ { range: "2001:1::3/128", classification: "public", rfc: "RFC 9665" },
50
+ { range: "2001:2::/48", classification: "benchmarking", rfc: "RFC 5180" },
51
+ { range: "2001:3::/32", classification: "public", rfc: "RFC 7450" },
52
+ { range: "2001:4:112::/48", classification: "public", rfc: "RFC 7535" },
53
+ { range: "2001:20::/28", classification: "public", rfc: "RFC 7343" },
54
+ { range: "2001:30::/28", classification: "public", rfc: "RFC 9374" },
55
+ { range: "2001:db8::/32", classification: "documentation", rfc: "RFC 3849" },
56
+ { range: "3fff::/20", classification: "documentation", rfc: "RFC 9637" },
57
+ { range: "5f00::/16", classification: "reserved", rfc: "RFC 9602" },
58
+ { range: "fc00::/7", classification: "unique-local", rfc: "RFC 4193" },
59
+ { range: "fe80::/10", classification: "link-local", rfc: "RFC 4291" },
60
+ { range: "ff00::/8", classification: "multicast", rfc: "RFC 4291" },
61
+ ];
62
+ /**
63
+ * The IPv6 prefixes that carry an IPv4 address, and the bit offset it starts at:
64
+ * IPv4-mapped (RFC 4291), NAT64 (RFC 6052) and 6to4 (RFC 3056).
65
+ */
66
+ export const EMBEDDING_RANGES = [
67
+ { range: "::ffff:0:0/96", offset: 96 },
68
+ { range: "64:ff9b::/96", offset: 96 },
69
+ { range: "2002::/16", offset: 16 },
70
+ ];
71
+ /**
72
+ * The parsed table, longest prefix first so the first match is the most specific.
73
+ * Filled on first use, keeping module evaluation free of work.
74
+ */
75
+ const PARSED_ROWS = [];
76
+ /** The parsed embedding prefixes, filled on first use. */
77
+ const PARSED_EMBEDDINGS = [];
78
+ /**
79
+ * Splits a table row's `network/prefix` text. The tables are written by hand and
80
+ * their tests classify the first and last address of every row, which fails for a
81
+ * row this drops.
82
+ *
83
+ * @param range - A range from one of the tables.
84
+ * @returns The network and prefix length, or an empty list for a malformed row.
85
+ */
86
+ function parseRow(range) {
87
+ let [text = "", prefix = ""] = range.split("/");
88
+ let network = parseAddress(text);
89
+ return network === null ? [] : [{ network, prefix: Number(prefix) }];
90
+ }
91
+ /**
92
+ * Whether an address sits inside a network.
93
+ *
94
+ * @param address - The address to test.
95
+ * @param network - The network's first address.
96
+ * @param prefix - The network's prefix length.
97
+ * @returns `true` for the same version and the same leading `prefix` bits.
98
+ */
99
+ function inNetwork(address, network, prefix) {
100
+ return (address.version === network.version && maskAddress(address, prefix).value === network.value);
101
+ }
102
+ /**
103
+ * The IPv4 address an IPv6 address carries inside it, so an address like
104
+ * `::ffff:169.254.169.254` is judged by the IPv4 it reaches.
105
+ *
106
+ * @param address - Any address.
107
+ * @returns The embedded IPv4, or `null` for IPv4 and for IPv6 outside the three prefixes.
108
+ */
109
+ export function embeddedIPv4(address) {
110
+ if (address.version === 4)
111
+ return null;
112
+ if (PARSED_EMBEDDINGS.length === 0) {
113
+ for (let row of EMBEDDING_RANGES) {
114
+ PARSED_EMBEDDINGS.push(...parseRow(row.range).map((parsed) => ({ ...parsed, offset: row.offset })));
115
+ }
116
+ }
117
+ for (let { network, prefix, offset } of PARSED_EMBEDDINGS) {
118
+ if (!inNetwork(address, network, prefix))
119
+ continue;
120
+ let shift = BigInt(ADDRESS_BITS[6] - offset - ADDRESS_BITS[4]);
121
+ return { version: 4, value: (address.value >> shift) & 0xffffffffn };
122
+ }
123
+ return null;
124
+ }
125
+ /**
126
+ * Classifies an address by its most specific row, after unwrapping an embedded
127
+ * IPv4, so `::ffff:10.0.0.1` is `private` and `::ffff:8.8.8.8` is `public`.
128
+ *
129
+ * @param address - Any address.
130
+ * @returns The classification of the most specific row, or `public` when none matches.
131
+ */
132
+ export function classify(address) {
133
+ let inner = embeddedIPv4(address);
134
+ if (inner !== null)
135
+ return classify(inner);
136
+ if (PARSED_ROWS.length === 0) {
137
+ let rows = SPECIAL_PURPOSE_RANGES.flatMap((row) => parseRow(row.range).map((parsed) => ({ ...parsed, classification: row.classification })));
138
+ PARSED_ROWS.push(...rows.sort((a, b) => b.prefix - a.prefix));
139
+ }
140
+ let row = PARSED_ROWS.find(({ network, prefix }) => inNetwork(address, network, prefix));
141
+ return row?.classification ?? "public";
142
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@sdxc/ip",
3
+ "version": "0.0.0-pre.1",
4
+ "description": "IPv4 and IPv6 addresses and ranges as value objects that classify against the IANA special-purpose registries",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "dependencies": {
11
+ "@sdxc/result": "2026.10.2"
12
+ },
13
+ "gitHead": "780589b65576d3aa4014c6bcd448b33b4931705a",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/sergiodxa/monorepo.git",
20
+ "directory": "packages/ip"
21
+ }
22
+ }