@fgv/ts-extras 5.1.0-46 → 5.1.0-48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +90 -0
  3. package/dist/index.browser.js +3 -1
  4. package/dist/index.browser.js.map +1 -1
  5. package/dist/index.js +2 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/packlets/safer-fetch/addressClassification.js +438 -0
  8. package/dist/packlets/safer-fetch/addressClassification.js.map +1 -0
  9. package/dist/packlets/safer-fetch/addressPolicy.js +119 -0
  10. package/dist/packlets/safer-fetch/addressPolicy.js.map +1 -0
  11. package/dist/packlets/safer-fetch/contentType.js +120 -0
  12. package/dist/packlets/safer-fetch/contentType.js.map +1 -0
  13. package/dist/packlets/safer-fetch/deadline.js +254 -0
  14. package/dist/packlets/safer-fetch/deadline.js.map +1 -0
  15. package/dist/packlets/safer-fetch/defaults.js +131 -0
  16. package/dist/packlets/safer-fetch/defaults.js.map +1 -0
  17. package/dist/packlets/safer-fetch/failureReason.js +21 -0
  18. package/dist/packlets/safer-fetch/failureReason.js.map +1 -0
  19. package/dist/packlets/safer-fetch/guards.js +148 -0
  20. package/dist/packlets/safer-fetch/guards.js.map +1 -0
  21. package/dist/packlets/safer-fetch/index.browser.js +58 -0
  22. package/dist/packlets/safer-fetch/index.browser.js.map +1 -0
  23. package/dist/packlets/safer-fetch/index.js +52 -0
  24. package/dist/packlets/safer-fetch/index.js.map +1 -0
  25. package/dist/packlets/safer-fetch/model.js +21 -0
  26. package/dist/packlets/safer-fetch/model.js.map +1 -0
  27. package/dist/packlets/safer-fetch/nodeAddressGuard.js +191 -0
  28. package/dist/packlets/safer-fetch/nodeAddressGuard.js.map +1 -0
  29. package/dist/packlets/safer-fetch/redirect.js +93 -0
  30. package/dist/packlets/safer-fetch/redirect.js.map +1 -0
  31. package/dist/packlets/safer-fetch/retry.js +188 -0
  32. package/dist/packlets/safer-fetch/retry.js.map +1 -0
  33. package/dist/packlets/safer-fetch/saferFetch.js +755 -0
  34. package/dist/packlets/safer-fetch/saferFetch.js.map +1 -0
  35. package/dist/packlets/safer-fetch/transport.js +50 -0
  36. package/dist/packlets/safer-fetch/transport.js.map +1 -0
  37. package/dist/ts-extras.d.ts +1337 -0
  38. package/lib/index.browser.d.ts +2 -1
  39. package/lib/index.browser.d.ts.map +1 -1
  40. package/lib/index.browser.js +4 -1
  41. package/lib/index.browser.js.map +1 -1
  42. package/lib/index.d.ts +2 -1
  43. package/lib/index.d.ts.map +1 -1
  44. package/lib/index.js +3 -1
  45. package/lib/index.js.map +1 -1
  46. package/lib/packlets/safer-fetch/addressClassification.d.ts +144 -0
  47. package/lib/packlets/safer-fetch/addressClassification.d.ts.map +1 -0
  48. package/lib/packlets/safer-fetch/addressClassification.js +441 -0
  49. package/lib/packlets/safer-fetch/addressClassification.js.map +1 -0
  50. package/lib/packlets/safer-fetch/addressPolicy.d.ts +113 -0
  51. package/lib/packlets/safer-fetch/addressPolicy.d.ts.map +1 -0
  52. package/lib/packlets/safer-fetch/addressPolicy.js +123 -0
  53. package/lib/packlets/safer-fetch/addressPolicy.js.map +1 -0
  54. package/lib/packlets/safer-fetch/contentType.d.ts +51 -0
  55. package/lib/packlets/safer-fetch/contentType.d.ts.map +1 -0
  56. package/lib/packlets/safer-fetch/contentType.js +127 -0
  57. package/lib/packlets/safer-fetch/contentType.js.map +1 -0
  58. package/lib/packlets/safer-fetch/deadline.d.ts +138 -0
  59. package/lib/packlets/safer-fetch/deadline.d.ts.map +1 -0
  60. package/lib/packlets/safer-fetch/deadline.js +258 -0
  61. package/lib/packlets/safer-fetch/deadline.js.map +1 -0
  62. package/lib/packlets/safer-fetch/defaults.d.ts +108 -0
  63. package/lib/packlets/safer-fetch/defaults.d.ts.map +1 -0
  64. package/lib/packlets/safer-fetch/defaults.js +134 -0
  65. package/lib/packlets/safer-fetch/defaults.js.map +1 -0
  66. package/lib/packlets/safer-fetch/failureReason.d.ts +148 -0
  67. package/lib/packlets/safer-fetch/failureReason.d.ts.map +1 -0
  68. package/lib/packlets/safer-fetch/failureReason.js +22 -0
  69. package/lib/packlets/safer-fetch/failureReason.js.map +1 -0
  70. package/lib/packlets/safer-fetch/guards.d.ts +62 -0
  71. package/lib/packlets/safer-fetch/guards.d.ts.map +1 -0
  72. package/lib/packlets/safer-fetch/guards.js +153 -0
  73. package/lib/packlets/safer-fetch/guards.js.map +1 -0
  74. package/lib/packlets/safer-fetch/index.browser.d.ts +33 -0
  75. package/lib/packlets/safer-fetch/index.browser.d.ts.map +1 -0
  76. package/lib/packlets/safer-fetch/index.browser.js +82 -0
  77. package/lib/packlets/safer-fetch/index.browser.js.map +1 -0
  78. package/lib/packlets/safer-fetch/index.d.ts +27 -0
  79. package/lib/packlets/safer-fetch/index.d.ts.map +1 -0
  80. package/lib/packlets/safer-fetch/index.js +78 -0
  81. package/lib/packlets/safer-fetch/index.js.map +1 -0
  82. package/lib/packlets/safer-fetch/model.d.ts +373 -0
  83. package/lib/packlets/safer-fetch/model.d.ts.map +1 -0
  84. package/lib/packlets/safer-fetch/model.js +22 -0
  85. package/lib/packlets/safer-fetch/model.js.map +1 -0
  86. package/lib/packlets/safer-fetch/nodeAddressGuard.d.ts +129 -0
  87. package/lib/packlets/safer-fetch/nodeAddressGuard.d.ts.map +1 -0
  88. package/lib/packlets/safer-fetch/nodeAddressGuard.js +196 -0
  89. package/lib/packlets/safer-fetch/nodeAddressGuard.js.map +1 -0
  90. package/lib/packlets/safer-fetch/redirect.d.ts +62 -0
  91. package/lib/packlets/safer-fetch/redirect.d.ts.map +1 -0
  92. package/lib/packlets/safer-fetch/redirect.js +98 -0
  93. package/lib/packlets/safer-fetch/redirect.js.map +1 -0
  94. package/lib/packlets/safer-fetch/retry.d.ts +162 -0
  95. package/lib/packlets/safer-fetch/retry.d.ts.map +1 -0
  96. package/lib/packlets/safer-fetch/retry.js +197 -0
  97. package/lib/packlets/safer-fetch/retry.js.map +1 -0
  98. package/lib/packlets/safer-fetch/saferFetch.d.ts +108 -0
  99. package/lib/packlets/safer-fetch/saferFetch.d.ts.map +1 -0
  100. package/lib/packlets/safer-fetch/saferFetch.js +760 -0
  101. package/lib/packlets/safer-fetch/saferFetch.js.map +1 -0
  102. package/lib/packlets/safer-fetch/transport.d.ts +18 -0
  103. package/lib/packlets/safer-fetch/transport.d.ts.map +1 -0
  104. package/lib/packlets/safer-fetch/transport.js +53 -0
  105. package/lib/packlets/safer-fetch/transport.js.map +1 -0
  106. package/package.json +17 -7
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2020 Erik Fortune
3
+ Copyright (c) 2026 Erik Fortune
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -86,6 +86,96 @@ const rawContents = file.value.getRawContents(); // Raw string
86
86
 
87
87
  **Note**: This implementation uses Node.js-specific dependencies (AdmZip, Buffer). For browser environments, see the browser-specific implementations in individual projects.
88
88
 
89
+ ### Safer Fetch
90
+
91
+ An HTTP fetch primitive with an explicit threat model, exported from `@fgv/ts-extras/safer-fetch`.
92
+ It is deliberately **not** a thin boundary over an upstream library — `fetch` is a platform global
93
+ and there is nothing to wrap. The opinion is the product: the deadlines, the scheme refusal, the
94
+ streaming size cap, the redirect posture, and the required address guard are the deliverable.
95
+
96
+ ```typescript
97
+ import {
98
+ saferFetchJson,
99
+ blockPrivateNetworks,
100
+ allowContentTypes
101
+ } from '@fgv/ts-extras/safer-fetch';
102
+
103
+ const result = await saferFetchJson('https://api.example.com/thing', {
104
+ // Required, with no default: omitting it is a compile error, so no call site can inherit a
105
+ // guarantee it was never given. Every call site's posture is greppable in one search.
106
+ addressGuard: blockPrivateNetworks(),
107
+ responseHeadersGuard: allowContentTypes(['application/json']).orThrow(),
108
+ timeoutMs: 10_000,
109
+ maxResponseBytes: 1024 * 1024,
110
+ retry: { attempts: 2 } // off unless asked for
111
+ });
112
+ ```
113
+
114
+ `addressGuard` has no default. `allowAnyAddress()` is the named, deliberately uncomfortable
115
+ opt-out, and it is the only honest choice in a browser.
116
+
117
+ #### The guarantee table
118
+
119
+ This table is the artifact that keeps the primitive honest. It is reproduced verbatim in
120
+ `@fgv/ts-web-extras`'s README.
121
+
122
+ | Property | Node (`@fgv/ts-extras/safer-fetch`) | Browser (`@fgv/ts-web-extras`) |
123
+ |---|---|---|
124
+ | Overall + headers deadline | ✅ | ✅ |
125
+ | Streaming size cap, enforced during read | ✅ | ✅ |
126
+ | Content-type gate before body read | ✅ | ✅ |
127
+ | Structured failure taxonomy | ✅ | ✅ |
128
+ | Retry with idempotency + budget rules | ✅ | ✅ |
129
+ | Scheme allowlist on URL₀ | ✅ | ✅ |
130
+ | Host / port allowlist on URL₀ | ✅ (`blockPrivateNetworks({ allowHosts, allowPorts })`) | ✅ (caller-supplied guard) |
131
+ | **Resolved-address (private-IP) guard** | ✅ | ❌ **impossible** — no DNS API |
132
+ | **Per-hop revalidation of redirects** | ✅ | ❌ **impossible** — opaque redirect |
133
+ | **Credential stripping on cross-origin hop** | ✅ | n/a — platform does it |
134
+ | Reject-all-redirects mode | ✅ | ✅ (enforced; surfaces as `'redirect-opaque'`) |
135
+ | DNS-rebinding resistance | ❌ **documented limit** | ❌ |
136
+
137
+ Two rows differ from the design document they came from, and the difference is deliberate:
138
+
139
+ - **Host / port allowlist.** `blockPrivateNetworks` takes `allowHosts` / `allowPorts` /
140
+ `allowInsecureHttp`. A host allowlist is the *recommended* posture, because it shrinks the
141
+ DNS-rebinding exposure below to "an allowlisted host's own resolver is hostile". `https:` is
142
+ required unless `allowInsecureHttp` is set; there is no default port restriction, since a
143
+ default of `{443}` would reject the very common `:8443` public endpoint with a failure that
144
+ reads as an SSRF block.
145
+ - **Reject-all-redirects.** Both runtimes use `redirect: 'manual'` and reject the redirect
146
+ themselves. The *guarantee* is identical; the failure **reason** is not — Node reports
147
+ `'redirect-rejected'` with the status, a browser reports `'redirect-opaque'` because an opaque
148
+ response has no status to report. Callers branching on the reason under `'reject'` must handle
149
+ both.
150
+
151
+ #### Stated limits
152
+
153
+ Each of these is a hole in a guarantee this package makes, and each is documented rather than
154
+ implied.
155
+
156
+ - **DNS rebinding** — the guard validates a resolved address and the transport then re-resolves,
157
+ so hostile DNS can answer the two lookups differently. Closing it needs a pinning transport;
158
+ the seam (`IGuardVerdict.pinnedAddress` + `IFetchTransport`) exists, and
159
+ `platformFetchTransport` **fails** rather than silently ignoring a pin it cannot honor. A
160
+ strict `allowHosts` list is the recommended mitigation.
161
+ - **The browser has no SSRF guard at all** — not a partial guarantee, an absent one. See the
162
+ table.
163
+ - **Time-of-check/time-of-use beyond DNS** — the guard authorizes a *destination*, never the
164
+ *content* that comes back.
165
+ - **The failure taxonomy is an information-disclosure surface** — a `'blocked-by-guard'` detail
166
+ names the URL, the hop and the guard, which is an internal-network scanning oracle if echoed
167
+ to an untrusted caller. Log it; return a coarse code.
168
+ - **No egress accounting** — no per-host rate limiting or quota. A retry policy is not a rate
169
+ limiter.
170
+ - **Loopback is blocked by default**, including for this repo's own local-development path. A
171
+ caller who wants it says so, and every deviation is independently greppable:
172
+ `blockPrivateNetworks({ allowLoopback: true, allowInsecureHttp: true, allowHosts: ['localhost'], allowPorts: [11434] })`.
173
+ - **HTTP semantics not implemented** — no cookie jar, no cache, no conditional requests, no
174
+ proxy configuration beyond what the platform picks up from the environment.
175
+
176
+ If your deployment has an egress proxy or firewall, that control is strictly stronger than this
177
+ one; this is defense in depth for deployments that do not.
178
+
89
179
  ### Converters
90
180
 
91
181
  Type-safe data conversion utilities for transforming between different data formats while maintaining type safety.
@@ -31,11 +31,13 @@ import * as Hash from './packlets/hash/index.browser';
31
31
  import * as Mustache from './packlets/mustache';
32
32
  // eslint-disable-next-line @rushstack/packlets/mechanics
33
33
  import * as RecordJar from './packlets/record-jar/index.browser';
34
+ // eslint-disable-next-line @rushstack/packlets/mechanics
35
+ import * as SaferFetch from './packlets/safer-fetch/index.browser';
34
36
  import * as Yaml from './packlets/yaml';
35
37
  import * as ZipFileTree from './packlets/zip-file-tree';
36
38
  import { Converters } from './packlets/conversion';
37
39
  // Browser-safe exports - Node.js crypto-based providers excluded
38
40
  // Use BrowserCryptoProvider from @fgv/ts-web-extras for browser crypto
39
- export { AiAssist, Converters, CryptoUtils, CryptoUtils as Crypto, Csv, Experimental, Hash, Mustache, RecordJar, Yaml, ZipFileTree };
41
+ export { AiAssist, Converters, CryptoUtils, CryptoUtils as Crypto, Csv, Experimental, Hash, Mustache, RecordJar, SaferFetch, Yaml, ZipFileTree };
40
42
  /* c8 ignore stop */
41
43
  //# sourceMappingURL=index.browser.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.browser.js","sourceRoot":"","sources":["../src/index.browser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,kFAAkF;AAClF,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAC;AACjD,yDAAyD;AACzD,OAAO,KAAK,WAAW,MAAM,uCAAuC,CAAC;AACrE,yDAAyD;AACzD,OAAO,KAAK,GAAG,MAAM,8BAA8B,CAAC;AACpD,OAAO,KAAK,YAAY,MAAM,yBAAyB,CAAC;AACxD,yDAAyD;AACzD,OAAO,KAAK,IAAI,MAAM,+BAA+B,CAAC;AACtD,OAAO,KAAK,QAAQ,MAAM,qBAAqB,CAAC;AAChD,yDAAyD;AACzD,OAAO,KAAK,SAAS,MAAM,qCAAqC,CAAC;AACjE,OAAO,KAAK,IAAI,MAAM,iBAAiB,CAAC;AACxC,OAAO,KAAK,WAAW,MAAM,0BAA0B,CAAC;AAExD,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAEnD,iEAAiE;AACjE,uEAAuE;AACvE,OAAO,EACL,QAAQ,EACR,UAAU,EACV,WAAW,EACX,WAAW,IAAI,MAAM,EACrB,GAAG,EACH,YAAY,EACZ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,IAAI,EACJ,WAAW,EACZ,CAAC;AACF,oBAAoB","sourcesContent":["/*\n * Copyright (c) 2020 Erik Fortune\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\n/* c8 ignore start - Browser-specific export used conditionally in package.json */\nimport * as AiAssist from './packlets/ai-assist';\n// eslint-disable-next-line @rushstack/packlets/mechanics\nimport * as CryptoUtils from './packlets/crypto-utils/index.browser';\n// eslint-disable-next-line @rushstack/packlets/mechanics\nimport * as Csv from './packlets/csv/index.browser';\nimport * as Experimental from './packlets/experimental';\n// eslint-disable-next-line @rushstack/packlets/mechanics\nimport * as Hash from './packlets/hash/index.browser';\nimport * as Mustache from './packlets/mustache';\n// eslint-disable-next-line @rushstack/packlets/mechanics\nimport * as RecordJar from './packlets/record-jar/index.browser';\nimport * as Yaml from './packlets/yaml';\nimport * as ZipFileTree from './packlets/zip-file-tree';\n\nimport { Converters } from './packlets/conversion';\n\n// Browser-safe exports - Node.js crypto-based providers excluded\n// Use BrowserCryptoProvider from @fgv/ts-web-extras for browser crypto\nexport {\n AiAssist,\n Converters,\n CryptoUtils,\n CryptoUtils as Crypto,\n Csv,\n Experimental,\n Hash,\n Mustache,\n RecordJar,\n Yaml,\n ZipFileTree\n};\n/* c8 ignore stop */\n"]}
1
+ {"version":3,"file":"index.browser.js","sourceRoot":"","sources":["../src/index.browser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,kFAAkF;AAClF,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAC;AACjD,yDAAyD;AACzD,OAAO,KAAK,WAAW,MAAM,uCAAuC,CAAC;AACrE,yDAAyD;AACzD,OAAO,KAAK,GAAG,MAAM,8BAA8B,CAAC;AACpD,OAAO,KAAK,YAAY,MAAM,yBAAyB,CAAC;AACxD,yDAAyD;AACzD,OAAO,KAAK,IAAI,MAAM,+BAA+B,CAAC;AACtD,OAAO,KAAK,QAAQ,MAAM,qBAAqB,CAAC;AAChD,yDAAyD;AACzD,OAAO,KAAK,SAAS,MAAM,qCAAqC,CAAC;AACjE,yDAAyD;AACzD,OAAO,KAAK,UAAU,MAAM,sCAAsC,CAAC;AACnE,OAAO,KAAK,IAAI,MAAM,iBAAiB,CAAC;AACxC,OAAO,KAAK,WAAW,MAAM,0BAA0B,CAAC;AAExD,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAEnD,iEAAiE;AACjE,uEAAuE;AACvE,OAAO,EACL,QAAQ,EACR,UAAU,EACV,WAAW,EACX,WAAW,IAAI,MAAM,EACrB,GAAG,EACH,YAAY,EACZ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,UAAU,EACV,IAAI,EACJ,WAAW,EACZ,CAAC;AACF,oBAAoB","sourcesContent":["/*\n * Copyright (c) 2020 Erik Fortune\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\n/* c8 ignore start - Browser-specific export used conditionally in package.json */\nimport * as AiAssist from './packlets/ai-assist';\n// eslint-disable-next-line @rushstack/packlets/mechanics\nimport * as CryptoUtils from './packlets/crypto-utils/index.browser';\n// eslint-disable-next-line @rushstack/packlets/mechanics\nimport * as Csv from './packlets/csv/index.browser';\nimport * as Experimental from './packlets/experimental';\n// eslint-disable-next-line @rushstack/packlets/mechanics\nimport * as Hash from './packlets/hash/index.browser';\nimport * as Mustache from './packlets/mustache';\n// eslint-disable-next-line @rushstack/packlets/mechanics\nimport * as RecordJar from './packlets/record-jar/index.browser';\n// eslint-disable-next-line @rushstack/packlets/mechanics\nimport * as SaferFetch from './packlets/safer-fetch/index.browser';\nimport * as Yaml from './packlets/yaml';\nimport * as ZipFileTree from './packlets/zip-file-tree';\n\nimport { Converters } from './packlets/conversion';\n\n// Browser-safe exports - Node.js crypto-based providers excluded\n// Use BrowserCryptoProvider from @fgv/ts-web-extras for browser crypto\nexport {\n AiAssist,\n Converters,\n CryptoUtils,\n CryptoUtils as Crypto,\n Csv,\n Experimental,\n Hash,\n Mustache,\n RecordJar,\n SaferFetch,\n Yaml,\n ZipFileTree\n};\n/* c8 ignore stop */\n"]}
package/dist/index.js CHANGED
@@ -26,8 +26,9 @@ import * as Experimental from './packlets/experimental';
26
26
  import * as Hash from './packlets/hash';
27
27
  import * as Mustache from './packlets/mustache';
28
28
  import * as RecordJar from './packlets/record-jar';
29
+ import * as SaferFetch from './packlets/safer-fetch';
29
30
  import * as Yaml from './packlets/yaml';
30
31
  import * as ZipFileTree from './packlets/zip-file-tree';
31
32
  import { Converters } from './packlets/conversion';
32
- export { AiAssist, Converters, CryptoUtils, Csv, Experimental, Hash, Mustache, RecordJar, Yaml, ZipFileTree };
33
+ export { AiAssist, Converters, CryptoUtils, Csv, Experimental, Hash, Mustache, RecordJar, SaferFetch, Yaml, ZipFileTree };
33
34
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAC;AACjD,OAAO,KAAK,WAAW,MAAM,yBAAyB,CAAC;AACvD,OAAO,KAAK,GAAG,MAAM,gBAAgB,CAAC;AACtC,OAAO,KAAK,YAAY,MAAM,yBAAyB,CAAC;AACxD,OAAO,KAAK,IAAI,MAAM,iBAAiB,CAAC;AACxC,OAAO,KAAK,QAAQ,MAAM,qBAAqB,CAAC;AAChD,OAAO,KAAK,SAAS,MAAM,uBAAuB,CAAC;AACnD,OAAO,KAAK,IAAI,MAAM,iBAAiB,CAAC;AACxC,OAAO,KAAK,WAAW,MAAM,0BAA0B,CAAC;AAExD,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAEnD,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,WAAW,EAAE,GAAG,EAAE,YAAY,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC","sourcesContent":["/*\n * Copyright (c) 2020 Erik Fortune\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport * as AiAssist from './packlets/ai-assist';\nimport * as CryptoUtils from './packlets/crypto-utils';\nimport * as Csv from './packlets/csv';\nimport * as Experimental from './packlets/experimental';\nimport * as Hash from './packlets/hash';\nimport * as Mustache from './packlets/mustache';\nimport * as RecordJar from './packlets/record-jar';\nimport * as Yaml from './packlets/yaml';\nimport * as ZipFileTree from './packlets/zip-file-tree';\n\nimport { Converters } from './packlets/conversion';\n\nexport { AiAssist, Converters, CryptoUtils, Csv, Experimental, Hash, Mustache, RecordJar, Yaml, ZipFileTree };\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,QAAQ,MAAM,sBAAsB,CAAC;AACjD,OAAO,KAAK,WAAW,MAAM,yBAAyB,CAAC;AACvD,OAAO,KAAK,GAAG,MAAM,gBAAgB,CAAC;AACtC,OAAO,KAAK,YAAY,MAAM,yBAAyB,CAAC;AACxD,OAAO,KAAK,IAAI,MAAM,iBAAiB,CAAC;AACxC,OAAO,KAAK,QAAQ,MAAM,qBAAqB,CAAC;AAChD,OAAO,KAAK,SAAS,MAAM,uBAAuB,CAAC;AACnD,OAAO,KAAK,UAAU,MAAM,wBAAwB,CAAC;AACrD,OAAO,KAAK,IAAI,MAAM,iBAAiB,CAAC;AACxC,OAAO,KAAK,WAAW,MAAM,0BAA0B,CAAC;AAExD,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAEnD,OAAO,EACL,QAAQ,EACR,UAAU,EACV,WAAW,EACX,GAAG,EACH,YAAY,EACZ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,UAAU,EACV,IAAI,EACJ,WAAW,EACZ,CAAC","sourcesContent":["/*\n * Copyright (c) 2020 Erik Fortune\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport * as AiAssist from './packlets/ai-assist';\nimport * as CryptoUtils from './packlets/crypto-utils';\nimport * as Csv from './packlets/csv';\nimport * as Experimental from './packlets/experimental';\nimport * as Hash from './packlets/hash';\nimport * as Mustache from './packlets/mustache';\nimport * as RecordJar from './packlets/record-jar';\nimport * as SaferFetch from './packlets/safer-fetch';\nimport * as Yaml from './packlets/yaml';\nimport * as ZipFileTree from './packlets/zip-file-tree';\n\nimport { Converters } from './packlets/conversion';\n\nexport {\n AiAssist,\n Converters,\n CryptoUtils,\n Csv,\n Experimental,\n Hash,\n Mustache,\n RecordJar,\n SaferFetch,\n Yaml,\n ZipFileTree\n};\n"]}
@@ -0,0 +1,438 @@
1
+ // Copyright (c) 2026 Erik Fortune
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+ // Address classification is prefix arithmetic: every range test is a mask and a
21
+ // compare, and every IPv6 group/byte conversion is a shift. Spelling those as
22
+ // arithmetic instead would be strictly harder to audit against the RFCs, which
23
+ // is the opposite of what this file needs. The repo already takes per-site
24
+ // disables for the same reason in `crypto-utils/keystore` and `ts-random`.
25
+ /* eslint-disable no-bitwise */
26
+ import { fail, succeed } from '@fgv/ts-utils';
27
+ /**
28
+ * IPv4 special-purpose ranges, most specific first. The broadcast address is
29
+ * listed ahead of `240.0.0.0/4` because it falls inside it.
30
+ */
31
+ const IPV4_RANGES = [
32
+ { network: 0xffffffff, prefixLength: 32, classification: 'broadcast' },
33
+ { network: 0x00000000, prefixLength: 8, classification: 'unspecified' },
34
+ { network: 0x0a000000, prefixLength: 8, classification: 'private' },
35
+ { network: 0x64400000, prefixLength: 10, classification: 'carrier-grade-nat' },
36
+ { network: 0x7f000000, prefixLength: 8, classification: 'loopback' },
37
+ { network: 0xa9fe0000, prefixLength: 16, classification: 'link-local' },
38
+ { network: 0xac100000, prefixLength: 12, classification: 'private' },
39
+ { network: 0xc0000000, prefixLength: 24, classification: 'protocol-assignment' },
40
+ { network: 0xc0000200, prefixLength: 24, classification: 'documentation' },
41
+ { network: 0xc0586300, prefixLength: 24, classification: 'reserved' },
42
+ { network: 0xc0a80000, prefixLength: 16, classification: 'private' },
43
+ { network: 0xc6120000, prefixLength: 15, classification: 'benchmarking' },
44
+ { network: 0xc6336400, prefixLength: 24, classification: 'documentation' },
45
+ { network: 0xcb007100, prefixLength: 24, classification: 'documentation' },
46
+ { network: 0xe0000000, prefixLength: 4, classification: 'multicast' },
47
+ { network: 0xf0000000, prefixLength: 4, classification: 'reserved' }
48
+ ];
49
+ /**
50
+ * IPv6 special-purpose ranges, most specific first. Ranges whose addresses
51
+ * embed an IPv4 address are handled separately, before this table is consulted,
52
+ * and anything outside global unicast (`2000::/3`) that matches nothing here is
53
+ * classified `'reserved'` — so this table only needs to name the ranges that
54
+ * deserve a more specific classification than that fallback gives them.
55
+ */
56
+ const IPV6_RANGES = [
57
+ { prefix: [0x20, 0x01, 0x0d, 0xb8], prefixLength: 32, classification: 'documentation' },
58
+ { prefix: [0x20, 0x01, 0x00], prefixLength: 23, classification: 'protocol-assignment' },
59
+ { prefix: [0x3f, 0xff, 0x00], prefixLength: 20, classification: 'documentation' },
60
+ { prefix: [0xfc], prefixLength: 7, classification: 'unique-local' },
61
+ { prefix: [0xfe, 0x80], prefixLength: 10, classification: 'link-local' },
62
+ { prefix: [0xff], prefixLength: 8, classification: 'multicast' }
63
+ ];
64
+ /** The first three bits of a global unicast IPv6 address (`2000::/3`). */
65
+ const IPV6_GLOBAL_UNICAST_MASK = 0xe0;
66
+ const IPV6_GLOBAL_UNICAST_VALUE = 0x20;
67
+ /**
68
+ * Parses one component of an IPv4 literal using `inet_aton` conventions: a
69
+ * `0x`/`0X` prefix selects hexadecimal, an otherwise-leading `0` selects octal,
70
+ * and anything else is decimal.
71
+ *
72
+ * This is deliberately permissive about *radix* because the WHATWG URL parser
73
+ * is: `http://0177.0.0.1/` and `http://2130706433/` are both `127.0.0.1`, and a
74
+ * classifier that only understands dotted decimal is bypassed by either.
75
+ */
76
+ function parseIpv4Component(text) {
77
+ if (text.length === 0) {
78
+ return undefined;
79
+ }
80
+ let radix = 10;
81
+ let digits = text;
82
+ if (text.length > 1 && (text[1] === 'x' || text[1] === 'X') && text[0] === '0') {
83
+ radix = 16;
84
+ digits = text.slice(2);
85
+ }
86
+ else if (text.length > 1 && text[0] === '0') {
87
+ radix = 8;
88
+ digits = text.slice(1);
89
+ }
90
+ // The WHATWG IPv4 number parser returns 0 when stripping the radix prefix
91
+ // leaves nothing behind, so a bare `0x` is zero: `127.0x.1` is `127.0.0.1`.
92
+ // Rejecting it here would leave a live encoding bypass.
93
+ if (digits.length === 0) {
94
+ return 0;
95
+ }
96
+ const allowed = radix === 16 ? /^[0-9a-fA-F]+$/ : radix === 8 ? /^[0-7]+$/ : /^[0-9]+$/;
97
+ if (!allowed.test(digits)) {
98
+ return undefined;
99
+ }
100
+ const value = Number.parseInt(digits, radix);
101
+ return Number.isSafeInteger(value) ? value : undefined;
102
+ }
103
+ /**
104
+ * Parses an IPv4 literal in any of the forms the platform URL parser accepts
105
+ * (`a.b.c.d`, `a.b.c`, `a.b`, `a`), returning the address as an unsigned 32-bit
106
+ * value. The final component absorbs all remaining bytes, so `127.1` is
107
+ * `127.0.0.1` and `2130706433` is also `127.0.0.1`.
108
+ */
109
+ function parseIpv4(text) {
110
+ const parts = text.split('.');
111
+ if (parts.length > 4) {
112
+ return undefined;
113
+ }
114
+ const values = [];
115
+ for (const part of parts) {
116
+ const value = parseIpv4Component(part);
117
+ if (value === undefined) {
118
+ return undefined;
119
+ }
120
+ values.push(value);
121
+ }
122
+ const leading = values.length - 1;
123
+ for (let i = 0; i < leading; i++) {
124
+ if (values[i] > 0xff) {
125
+ return undefined;
126
+ }
127
+ }
128
+ if (values[leading] > Math.pow(256, 4 - leading) - 1) {
129
+ return undefined;
130
+ }
131
+ let address = values[leading];
132
+ for (let i = 0; i < leading; i++) {
133
+ address += values[i] * Math.pow(256, 3 - i);
134
+ }
135
+ return address >>> 0;
136
+ }
137
+ /**
138
+ * Parses a strict dotted-quad IPv4 literal — exactly four decimal components,
139
+ * each `0..255`, with no octal or hexadecimal forms. This is the grammar RFC
140
+ * 4291 permits for the IPv4 tail of an IPv6 literal, and it is deliberately
141
+ * stricter than {@link parseIpv4}: an unparseable literal fails closed.
142
+ */
143
+ function parseStrictDottedQuad(text) {
144
+ const parts = text.split('.');
145
+ if (parts.length !== 4) {
146
+ return undefined;
147
+ }
148
+ let address = 0;
149
+ for (const part of parts) {
150
+ if (!/^(0|[1-9][0-9]{0,2})$/.test(part)) {
151
+ return undefined;
152
+ }
153
+ const value = Number.parseInt(part, 10);
154
+ if (value > 0xff) {
155
+ return undefined;
156
+ }
157
+ address = address * 256 + value;
158
+ }
159
+ return address >>> 0;
160
+ }
161
+ /**
162
+ * Parses a colon-separated run of IPv6 groups, allowing a trailing embedded
163
+ * dotted-quad IPv4 literal which expands to the final two groups.
164
+ *
165
+ * @param allowEmbeddedIpv4 - whether this run may end in a dotted-quad IPv4
166
+ * literal. Only the run that ends the whole address may, so the run *before* a
167
+ * `::` never does: `1.2.3.4::` is malformed, not `102:304::`.
168
+ */
169
+ function parseIpv6Groups(text, allowEmbeddedIpv4) {
170
+ if (text.length === 0) {
171
+ return [];
172
+ }
173
+ const parts = text.split(':');
174
+ const groups = [];
175
+ for (let i = 0; i < parts.length; i++) {
176
+ const part = parts[i];
177
+ if (part.indexOf('.') >= 0) {
178
+ if (!allowEmbeddedIpv4 || i !== parts.length - 1) {
179
+ return undefined;
180
+ }
181
+ const embedded = parseStrictDottedQuad(part);
182
+ if (embedded === undefined) {
183
+ return undefined;
184
+ }
185
+ groups.push((embedded >>> 16) & 0xffff, embedded & 0xffff);
186
+ continue;
187
+ }
188
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part)) {
189
+ return undefined;
190
+ }
191
+ groups.push(Number.parseInt(part, 16));
192
+ }
193
+ return groups;
194
+ }
195
+ /**
196
+ * Parses an IPv6 literal into its 16 constituent bytes.
197
+ */
198
+ function parseIpv6(text) {
199
+ const doubleColon = text.indexOf('::');
200
+ let groups;
201
+ if (doubleColon >= 0) {
202
+ if (text.indexOf('::', doubleColon + 1) >= 0) {
203
+ return undefined;
204
+ }
205
+ const head = parseIpv6Groups(text.slice(0, doubleColon), false);
206
+ const tail = parseIpv6Groups(text.slice(doubleColon + 2), true);
207
+ if (head === undefined || tail === undefined) {
208
+ return undefined;
209
+ }
210
+ const elided = 8 - head.length - tail.length;
211
+ if (elided < 1) {
212
+ return undefined;
213
+ }
214
+ groups = head.concat(new Array(elided).fill(0), tail);
215
+ }
216
+ else {
217
+ groups = parseIpv6Groups(text, true);
218
+ if (groups === undefined || groups.length !== 8) {
219
+ return undefined;
220
+ }
221
+ }
222
+ const bytes = new Uint8Array(16);
223
+ for (let i = 0; i < 8; i++) {
224
+ bytes[i * 2] = (groups[i] >>> 8) & 0xff;
225
+ bytes[i * 2 + 1] = groups[i] & 0xff;
226
+ }
227
+ return bytes;
228
+ }
229
+ function ipv4ToString(address) {
230
+ return [(address >>> 24) & 0xff, (address >>> 16) & 0xff, (address >>> 8) & 0xff, address & 0xff].join('.');
231
+ }
232
+ /**
233
+ * Renders the RFC 5952 canonical text form of an IPv6 address: lowercase, no
234
+ * leading zeros in a group, and the leftmost longest run of two or more
235
+ * all-zero groups replaced by `::`.
236
+ */
237
+ function ipv6ToString(bytes) {
238
+ const groups = [];
239
+ for (let i = 0; i < 8; i++) {
240
+ groups.push((bytes[i * 2] << 8) | bytes[i * 2 + 1]);
241
+ }
242
+ let bestStart = -1;
243
+ let bestLength = 0;
244
+ let runStart = -1;
245
+ let runLength = 0;
246
+ for (let i = 0; i < 8; i++) {
247
+ if (groups[i] === 0) {
248
+ if (runStart < 0) {
249
+ runStart = i;
250
+ runLength = 0;
251
+ }
252
+ runLength++;
253
+ if (runLength > bestLength) {
254
+ bestStart = runStart;
255
+ bestLength = runLength;
256
+ }
257
+ }
258
+ else {
259
+ runStart = -1;
260
+ runLength = 0;
261
+ }
262
+ }
263
+ const rendered = groups.map((g) => g.toString(16));
264
+ if (bestLength < 2) {
265
+ return rendered.join(':');
266
+ }
267
+ const head = rendered.slice(0, bestStart).join(':');
268
+ const tail = rendered.slice(bestStart + bestLength).join(':');
269
+ return `${head}::${tail}`;
270
+ }
271
+ function classifyIpv4Value(address) {
272
+ for (const range of IPV4_RANGES) {
273
+ const mask = (0xffffffff << (32 - range.prefixLength)) >>> 0;
274
+ if ((address & mask) >>> 0 === range.network) {
275
+ return range.classification;
276
+ }
277
+ }
278
+ return 'public';
279
+ }
280
+ function matchesIpv6Range(bytes, range) {
281
+ const fullBytes = range.prefixLength >>> 3;
282
+ for (let i = 0; i < fullBytes; i++) {
283
+ if (bytes[i] !== range.prefix[i]) {
284
+ return false;
285
+ }
286
+ }
287
+ const remainingBits = range.prefixLength & 7;
288
+ if (remainingBits === 0) {
289
+ return true;
290
+ }
291
+ const mask = (0xff << (8 - remainingBits)) & 0xff;
292
+ return (bytes[fullBytes] & mask) === (range.prefix[fullBytes] & mask);
293
+ }
294
+ function allZero(bytes, start, end) {
295
+ for (let i = start; i < end; i++) {
296
+ if (bytes[i] !== 0) {
297
+ return false;
298
+ }
299
+ }
300
+ return true;
301
+ }
302
+ function readIpv4At(bytes, offset) {
303
+ return (((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0);
304
+ }
305
+ /**
306
+ * Detects the IPv4 address embedded in an IPv6 address, if any. `::` and `::1`
307
+ * are excluded before the IPv4-compatible check so that the unspecified and
308
+ * loopback addresses keep their own classifications.
309
+ */
310
+ function detectIpv6Embedding(bytes) {
311
+ if (allZero(bytes, 0, 10) && bytes[10] === 0xff && bytes[11] === 0xff) {
312
+ return { kind: 'ipv4-mapped', value: readIpv4At(bytes, 12) };
313
+ }
314
+ if (bytes[0] === 0x00 &&
315
+ bytes[1] === 0x64 &&
316
+ bytes[2] === 0xff &&
317
+ bytes[3] === 0x9b &&
318
+ allZero(bytes, 4, 12)) {
319
+ return { kind: 'nat64', value: readIpv4At(bytes, 12) };
320
+ }
321
+ if (bytes[0] === 0x20 && bytes[1] === 0x02) {
322
+ return { kind: '6to4', value: readIpv4At(bytes, 2) };
323
+ }
324
+ if (allZero(bytes, 0, 12)) {
325
+ return { kind: 'ipv4-compatible', value: readIpv4At(bytes, 12) };
326
+ }
327
+ return undefined;
328
+ }
329
+ function classifyIpv6Bytes(bytes) {
330
+ const canonical = ipv6ToString(bytes);
331
+ if (allZero(bytes, 0, 16)) {
332
+ return { address: canonical, canonical, family: 'ipv6', classification: 'unspecified' };
333
+ }
334
+ if (allZero(bytes, 0, 15) && bytes[15] === 0x01) {
335
+ return { address: canonical, canonical, family: 'ipv6', classification: 'loopback' };
336
+ }
337
+ const embedding = detectIpv6Embedding(bytes);
338
+ if (embedding !== undefined) {
339
+ return {
340
+ address: canonical,
341
+ canonical,
342
+ family: 'ipv6',
343
+ classification: classifyIpv4Value(embedding.value),
344
+ embeddedIpv4: { kind: embedding.kind, address: ipv4ToString(embedding.value) }
345
+ };
346
+ }
347
+ for (const range of IPV6_RANGES) {
348
+ if (matchesIpv6Range(bytes, range)) {
349
+ return { address: canonical, canonical, family: 'ipv6', classification: range.classification };
350
+ }
351
+ }
352
+ // Only `2000::/3` is assigned as global unicast. Everything else that reached
353
+ // this point is unassigned or reserved for future use, so it is never public.
354
+ const classification = (bytes[0] & IPV6_GLOBAL_UNICAST_MASK) === IPV6_GLOBAL_UNICAST_VALUE ? 'public' : 'reserved';
355
+ return { address: canonical, canonical, family: 'ipv6', classification };
356
+ }
357
+ /**
358
+ * Normalizes the textual forms an address can arrive in before parsing:
359
+ * surrounding whitespace and the square brackets a URL `hostname` carries for
360
+ * an IPv6 literal. A zone identifier is *not* stripped here — it is stripped on
361
+ * the IPv6 path only, because `8.8.8.8%something` is not an address with a zone
362
+ * and must fail rather than be read as `8.8.8.8`.
363
+ */
364
+ function normalizeAddressText(address) {
365
+ const text = address.trim();
366
+ if (text.length > 1 && text[0] === '[' && text[text.length - 1] === ']') {
367
+ return text.slice(1, -1);
368
+ }
369
+ return text;
370
+ }
371
+ /** Strips an IPv6 zone identifier (`fe80::1%eth0`). */
372
+ function stripIpv6Zone(text) {
373
+ const zone = text.indexOf('%');
374
+ return zone >= 0 ? text.slice(0, zone) : text;
375
+ }
376
+ /**
377
+ * Classifies a single IP address literal.
378
+ *
379
+ * The function is pure, synchronous, and deterministic — it performs no name
380
+ * resolution and no I/O. Input that is not an IP address literal (a DNS
381
+ * hostname, for instance) fails; callers that accept hostnames should treat a
382
+ * failure here as "not a literal" and resolve the name, then classify each
383
+ * resolved address.
384
+ *
385
+ * Accepted forms:
386
+ *
387
+ * - dotted-quad IPv4 (`169.254.169.254`)
388
+ * - the shortened and non-decimal IPv4 forms the WHATWG URL parser accepts
389
+ * (`127.1`, `2130706433`, `0177.0.0.1`, `0x7f.1`)
390
+ * - IPv6, with or without `::` compression, optionally bracketed as a URL
391
+ * `hostname` is (`[::1]`) and optionally carrying a zone id (`fe80::1%eth0`)
392
+ * - IPv6 forms that embed an IPv4 address — IPv4-mapped, IPv4-compatible,
393
+ * NAT64 and 6to4 — which are classified by their embedded IPv4 address
394
+ *
395
+ * A trailing dot is accepted on an IPv4 literal (`127.0.0.1.`), matching URL
396
+ * hostname normalization.
397
+ *
398
+ * **Classify a URL's `hostname`, never the raw URL text.** This function does
399
+ * not apply IDNA/Unicode normalization, and that step is not cosmetic: the
400
+ * platform's URL parser reads `http://127.0.0.1/`, `http://127。0。0。1/` and
401
+ * even `http://⑫7.0.0.1/` as the host `127.0.0.1`, because IDNA normalizes
402
+ * fullwidth digits, the ideographic full stop and circled numbers to their
403
+ * ASCII equivalents. Handed one of those strings directly this function fails —
404
+ * which is fail-closed, but only because the caller is then expected to treat
405
+ * "not a literal" as "resolve it as a name". Reading `new URL(...).hostname`
406
+ * gets the normalization for free and is the only supported use.
407
+ *
408
+ * @param address - the address literal to classify.
409
+ * @returns `Success` with the {@link IClassifiedAddress | classification},
410
+ * or `Failure` if the supplied text is not a well-formed IP address literal.
411
+ * @public
412
+ */
413
+ export function classifyAddress(address) {
414
+ const text = normalizeAddressText(address);
415
+ if (text.length === 0) {
416
+ return fail(`"${address}": not a valid IP address literal (empty)`);
417
+ }
418
+ if (text.indexOf(':') >= 0) {
419
+ const bytes = parseIpv6(stripIpv6Zone(text));
420
+ if (bytes === undefined) {
421
+ return fail(`"${address}": not a valid IPv6 address literal`);
422
+ }
423
+ return succeed(Object.assign(Object.assign({}, classifyIpv6Bytes(bytes)), { address }));
424
+ }
425
+ const v4Text = text.length > 1 && text[text.length - 1] === '.' ? text.slice(0, -1) : text;
426
+ const value = parseIpv4(v4Text);
427
+ if (value === undefined) {
428
+ return fail(`"${address}": not a valid IP address literal`);
429
+ }
430
+ const canonical = ipv4ToString(value);
431
+ return succeed({
432
+ address,
433
+ canonical,
434
+ family: 'ipv4',
435
+ classification: classifyIpv4Value(value)
436
+ });
437
+ }
438
+ //# sourceMappingURL=addressClassification.js.map