treat IPv4 and IPv6 address space as sets. union, intersect, subtract, and complement them, and always get back the minimal list of CIDR blocks that covers the result.
this is the math you reach for when you want "this big block, except these few": punching holes in a firewall rule, reconciling two blocklists, working out exactly which subnets are left after you carve some out. doing it by hand is error prone and doing it with strings is worse, so this does it with interval arithmetic and hands you clean CIDRs.
npm install ipspace
subtract a CIDR from a block and see what is left:
import { IpSet } from "ipspace";
IpSet.from("10.0.0.0/8").difference(IpSet.from("10.10.0.0/16")).toCIDRs();
// [
// "10.0.0.0/13", "10.8.0.0/15", "10.11.0.0/16", "10.12.0.0/14",
// "10.16.0.0/12", "10.32.0.0/11", "10.64.0.0/10", "10.128.0.0/9"
// ]intersect two lists, mixing CIDRs and explicit ranges:
IpSet.from(["198.51.100.0/24", "203.0.113.0/24"])
.intersection(IpSet.from("198.51.100.128-203.0.113.127"))
.toCIDRs();
// ["198.51.100.128/25", "203.0.113.0/25"]complement a set over the whole v4 space (here, everything that is not RFC 1918 private space):
IpSet.from(["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]).complement(4).toCIDRs();
// ["0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", ...]an arbitrary range becomes the fewest CIDRs that exactly cover it:
IpSet.from("192.168.1.5-192.168.1.50").toCIDRs();
// [
// "192.168.1.5/32", "192.168.1.6/31", "192.168.1.8/29", "192.168.1.16/28",
// "192.168.1.32/28", "192.168.1.48/31", "192.168.1.50/32"
// ]it understands :: compression and embedded v4, and v6 counts are returned as bigint because they blow past Number:
IpSet.from("2001:db8::/32").size();
// 79228162514264337593543950336nan address is just a number, but a v6 address is a 128-bit one, so every address, range bound, and set size is a bigint. no octet arrays, no string parsing in the parts that do the work.
an IpSet is always in canonical form: its ranges are sorted, pairwise disjoint, and adjacent ranges are merged (if one ends right where the next begins, they become one). every operation restores this form before returning, and v4 and v6 ranges never interact. because the representation is canonical, equals is a straight structural comparison, toCIDRs is always minimal, and contains is a binary search. you never have to normalize anything yourself.
construct
IpSet.from(input)where input is a string, an array of strings, or anotherIpSet. accepts a single address, a CIDR (host bits are masked off, not rejected), or an explicitstart-endrange.IpSet.empty()
algebra, each returns a new set and never mutates
union(other)intersection(other)difference(other)symmetricDifference(other)complement(version?), over the full space of the given version, or every version present if omitted. an empty set needs the version argument since there is nothing to infer.
query
contains(addr), membership for one address via binary searchcontainsAll(other),overlaps(other),equals(other),isEmpty()size(), total address count as a bigint
output
toCIDRs(), the minimal list of CIDR stringstoRanges(),start-endstringsaddresses(), a lazy generator over every address, never materialized- the set is iterable over its ranges
MIT