@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
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ // Copyright (c) 2026 Erik Fortune
3
+ //
4
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ // of this software and associated documentation files (the "Software"), to deal
6
+ // in the Software without restriction, including without limitation the rights
7
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ // copies of the Software, and to permit persons to whom the Software is
9
+ // furnished to do so, subject to the following conditions:
10
+ //
11
+ // The above copyright notice and this permission notice shall be included in all
12
+ // copies or substantial portions of the Software.
13
+ //
14
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ // SOFTWARE.
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.blockPrivateNetworksPolicy = blockPrivateNetworksPolicy;
23
+ exports.allowAnyAddressPolicy = allowAnyAddressPolicy;
24
+ const ts_utils_1 = require("@fgv/ts-utils");
25
+ const addressClassification_1 = require("./addressClassification");
26
+ const PUBLIC_ONLY = new Set(['public']);
27
+ const PUBLIC_OR_LOOPBACK = new Set([
28
+ 'public',
29
+ 'loopback'
30
+ ]);
31
+ function describeAddress(classified) {
32
+ const embedded = classified.embeddedIpv4 !== undefined
33
+ ? ` via ${classified.embeddedIpv4.kind} ${classified.embeddedIpv4.address}`
34
+ : '';
35
+ return `${classified.canonical} is ${classified.classification}${embedded}`;
36
+ }
37
+ function checkOneAddress(policyName, allowed, address) {
38
+ return (0, addressClassification_1.classifyAddress)(address)
39
+ .withErrorFormat((message) => `${policyName}: ${message}`)
40
+ .onSuccess((classified) => allowed.has(classified.classification)
41
+ ? (0, ts_utils_1.succeed)(classified)
42
+ : (0, ts_utils_1.fail)(`${policyName}: ${describeAddress(classified)} and is not allowed`));
43
+ }
44
+ function checkAllAddresses(policyName, allowed, addresses) {
45
+ if (addresses.length === 0) {
46
+ return (0, ts_utils_1.fail)(`${policyName}: no addresses to check - the destination cannot be verified`);
47
+ }
48
+ return (0, ts_utils_1.mapResults)(addresses.map((address) => checkOneAddress(policyName, allowed, address))).onSuccess((classified) => (0, ts_utils_1.succeed)({ policy: policyName, addresses: classified }));
49
+ }
50
+ /**
51
+ * Creates the recommended address policy: every address must be a globally
52
+ * routable public unicast address.
53
+ *
54
+ * Rejected, in both their plain and their IPv6-embedded encodings: loopback,
55
+ * the link-local range that carries the cloud instance-metadata endpoint
56
+ * (`169.254.169.254`), RFC 1918 private ranges, IPv6 unique-local, carrier-grade
57
+ * NAT (`100.64.0.0/10`), the unspecified address, multicast, broadcast,
58
+ * benchmarking, documentation and every other reserved range. The encoding
59
+ * bypasses covered are IPv4-mapped IPv6 (`::ffff:169.254.169.254`),
60
+ * IPv4-compatible IPv6, NAT64 (`64:ff9b::a9fe:a9fe`), 6to4 (`2002:a9fe:a9fe::`)
61
+ * and the shortened / octal / hexadecimal / decimal IPv4 literal forms
62
+ * (`127.1`, `0177.0.0.1`, `0x7f.1`, `2130706433`).
63
+ *
64
+ * **What this does not protect against.** The policy classifies addresses a
65
+ * caller has already resolved; it cannot see the address the connection
66
+ * ultimately uses. A hostile DNS server that answers the resolution with a
67
+ * public address and the connect with a private one is not stopped by this
68
+ * policy — closing that requires connecting to a pinned address. The policy
69
+ * also says nothing about scheme, host, port, redirects or response content.
70
+ *
71
+ * This is the **policy-layer** factory. It is not what a safer-fetch call's
72
+ * `addressGuard` option takes: an entry point takes an `IAddressGuard`, which is
73
+ * the asynchronous, hop-chain-aware, name-resolving half. `blockPrivateNetworks`
74
+ * (Node only) is the guard that resolves a hostname and delegates every resolved
75
+ * address to this policy; reach for that unless you are classifying an address
76
+ * list you already hold.
77
+ *
78
+ * @param options - optional {@link IBlockPrivateNetworksOptions | relaxations}
79
+ * of the default posture.
80
+ * @returns the policy. Construction cannot fail.
81
+ * @public
82
+ */
83
+ function blockPrivateNetworksPolicy(options) {
84
+ const allowLoopback = (options === null || options === void 0 ? void 0 : options.allowLoopback) === true;
85
+ const name = allowLoopback
86
+ ? 'blockPrivateNetworksPolicy(allowLoopback)'
87
+ : 'blockPrivateNetworksPolicy';
88
+ const allowed = allowLoopback ? PUBLIC_OR_LOOPBACK : PUBLIC_ONLY;
89
+ return {
90
+ name,
91
+ checkAddresses: (addresses) => checkAllAddresses(name, allowed, addresses)
92
+ };
93
+ }
94
+ /**
95
+ * Creates a policy that permits every address, including loopback, link-local
96
+ * and private ones.
97
+ *
98
+ * **This policy provides no protection whatsoever.** It classifies nothing,
99
+ * rejects nothing, and never fails — not even for an empty address list or an
100
+ * address that is not a well-formed literal. It exists so that choosing to go
101
+ * without an address guarantee is a deliberate, named, greppable act at the
102
+ * call site rather than something reachable by omission, and so that tests and
103
+ * genuinely trusted-input paths do not hand-roll something worse.
104
+ *
105
+ * It is the only correct choice in a browser, where neither name resolution nor
106
+ * redirect interposition is available and no address guarantee is possible.
107
+ *
108
+ * @returns the policy.
109
+ * @public
110
+ */
111
+ function allowAnyAddressPolicy() {
112
+ const name = 'allowAnyAddressPolicy';
113
+ return {
114
+ name,
115
+ // The parameter is deliberately not declared. This policy does not read the
116
+ // addresses, and an unused `_addresses` would both restate that less
117
+ // clearly and cut against the repo's "don't rename unused variables to
118
+ // `_var`" rule. Parameter bivariance makes the zero-arg form a complete
119
+ // implementation of `IAddressPolicy`.
120
+ checkAddresses: () => (0, ts_utils_1.succeed)({ policy: name, addresses: [] })
121
+ };
122
+ }
123
+ //# sourceMappingURL=addressPolicy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"addressPolicy.js","sourceRoot":"","sources":["../../../src/packlets/safer-fetch/addressPolicy.ts"],"names":[],"mappings":";AAAA,kCAAkC;AAClC,EAAE;AACF,+EAA+E;AAC/E,gFAAgF;AAChF,+EAA+E;AAC/E,4EAA4E;AAC5E,wEAAwE;AACxE,2DAA2D;AAC3D,EAAE;AACF,iFAAiF;AACjF,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,8EAA8E;AAC9E,yEAAyE;AACzE,gFAAgF;AAChF,gFAAgF;AAChF,YAAY;;AAiJZ,gEAWC;AAmBD,sDAWC;AAxLD,4CAAuE;AACvE,mEAIiC;AAgEjC,MAAM,WAAW,GAAuC,IAAI,GAAG,CAAwB,CAAC,QAAQ,CAAC,CAAC,CAAC;AACnG,MAAM,kBAAkB,GAAuC,IAAI,GAAG,CAAwB;IAC5F,QAAQ;IACR,UAAU;CACX,CAAC,CAAC;AAEH,SAAS,eAAe,CAAC,UAA8B;IACrD,MAAM,QAAQ,GACZ,UAAU,CAAC,YAAY,KAAK,SAAS;QACnC,CAAC,CAAC,QAAQ,UAAU,CAAC,YAAY,CAAC,IAAI,IAAI,UAAU,CAAC,YAAY,CAAC,OAAO,EAAE;QAC3E,CAAC,CAAC,EAAE,CAAC;IACT,OAAO,GAAG,UAAU,CAAC,SAAS,OAAO,UAAU,CAAC,cAAc,GAAG,QAAQ,EAAE,CAAC;AAC9E,CAAC;AAED,SAAS,eAAe,CACtB,UAAkB,EAClB,OAA2C,EAC3C,OAAe;IAEf,OAAO,IAAA,uCAAe,EAAC,OAAO,CAAC;SAC5B,eAAe,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,GAAG,UAAU,KAAK,OAAO,EAAE,CAAC;SACzD,SAAS,CAAC,CAAC,UAAU,EAAE,EAAE,CACxB,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,cAAc,CAAC;QACpC,CAAC,CAAC,IAAA,kBAAO,EAAC,UAAU,CAAC;QACrB,CAAC,CAAC,IAAA,eAAI,EAAC,GAAG,UAAU,KAAK,eAAe,CAAC,UAAU,CAAC,qBAAqB,CAAC,CAC7E,CAAC;AACN,CAAC;AAED,SAAS,iBAAiB,CACxB,UAAkB,EAClB,OAA2C,EAC3C,SAAgC;IAEhC,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC3B,OAAO,IAAA,eAAI,EAAC,GAAG,UAAU,8DAA8D,CAAC,CAAC;IAC3F,CAAC;IACD,OAAO,IAAA,qBAAU,EAAC,SAAS,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,eAAe,CAAC,UAAU,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,SAAS,CACpG,CAAC,UAAU,EAAE,EAAE,CAAC,IAAA,kBAAO,EAAC,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CACvE,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,SAAgB,0BAA0B,CAAC,OAAsC;IAC/E,MAAM,aAAa,GAAY,CAAA,OAAO,aAAP,OAAO,uBAAP,OAAO,CAAE,aAAa,MAAK,IAAI,CAAC;IAC/D,MAAM,IAAI,GAAW,aAAa;QAChC,CAAC,CAAC,2CAA2C;QAC7C,CAAC,CAAC,4BAA4B,CAAC;IACjC,MAAM,OAAO,GAAuC,aAAa,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,WAAW,CAAC;IACrG,OAAO;QACL,IAAI;QACJ,cAAc,EAAE,CAAC,SAAgC,EAAgC,EAAE,CACjF,iBAAiB,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC;KAC9C,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,SAAgB,qBAAqB;IACnC,MAAM,IAAI,GAAW,uBAAuB,CAAC;IAC7C,OAAO;QACL,IAAI;QACJ,4EAA4E;QAC5E,qEAAqE;QACrE,uEAAuE;QACvE,wEAAwE;QACxE,sCAAsC;QACtC,cAAc,EAAE,GAAiC,EAAE,CAAC,IAAA,kBAAO,EAAC,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,CAAC;KAC7F,CAAC;AACJ,CAAC","sourcesContent":["// Copyright (c) 2026 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\nimport { type Result, fail, mapResults, succeed } from '@fgv/ts-utils';\nimport {\n type AddressClassification,\n type IClassifiedAddress,\n classifyAddress\n} from './addressClassification';\n\n/**\n * The evidence behind an allowed address-policy decision.\n * @public\n */\nexport interface IAddressCheckVerdict {\n /** The {@link IAddressPolicy.name | name} of the policy that produced the verdict. */\n readonly policy: string;\n /**\n * The classification of every address the policy examined, in the order\n * supplied. Empty for {@link allowAnyAddressPolicy}, which classifies nothing.\n */\n readonly addresses: ReadonlyArray<IClassifiedAddress>;\n}\n\n/**\n * A pure, synchronous decision over a set of IP addresses.\n *\n * A policy performs no name resolution and no I/O: a caller resolves a\n * hostname to its addresses and hands the whole list to\n * {@link IAddressPolicy.checkAddresses}. The list contract is\n * **reject-if-any** — a hostname that resolves to one public and one private\n * address is disallowed, because without connect-time address pinning there is\n * no guarantee which address the connection will use.\n *\n * @public\n */\nexport interface IAddressPolicy {\n /**\n * A stable, greppable identifier for the posture this policy implements.\n * Surfaced in the failure message when an address is disallowed.\n */\n readonly name: string;\n\n /**\n * Decides whether a connection may be made to a destination that resolved to\n * `addresses`.\n *\n * @param addresses - every address the destination resolved to, or the\n * single literal address when the destination was an IP literal.\n * @returns `Success` with the {@link IAddressCheckVerdict | verdict} when\n * *every* address is permitted, `Failure` naming every address that is not.\n * An empty list is a failure for any policy that offers a guarantee: nothing\n * was verified, so nothing may be reached.\n */\n checkAddresses(addresses: ReadonlyArray<string>): Result<IAddressCheckVerdict>;\n}\n\n/**\n * Options shared by {@link blockPrivateNetworksPolicy} and the address guard built over it.\n * @public\n */\nexport interface IBlockPrivateNetworksOptions {\n /**\n * Permits `127.0.0.0/8` and `::1` (and their IPv4-mapped forms). Off by\n * default: the polarity that ships a guard permitting `http://127.0.0.1:6379/`\n * so a local-development convenience keeps working is the wrong one. A\n * caller talking to a local sidecar opts in here, and the opt-in is\n * independently greppable at the call site.\n */\n readonly allowLoopback?: boolean;\n}\n\nconst PUBLIC_ONLY: ReadonlySet<AddressClassification> = new Set<AddressClassification>(['public']);\nconst PUBLIC_OR_LOOPBACK: ReadonlySet<AddressClassification> = new Set<AddressClassification>([\n 'public',\n 'loopback'\n]);\n\nfunction describeAddress(classified: IClassifiedAddress): string {\n const embedded: string =\n classified.embeddedIpv4 !== undefined\n ? ` via ${classified.embeddedIpv4.kind} ${classified.embeddedIpv4.address}`\n : '';\n return `${classified.canonical} is ${classified.classification}${embedded}`;\n}\n\nfunction checkOneAddress(\n policyName: string,\n allowed: ReadonlySet<AddressClassification>,\n address: string\n): Result<IClassifiedAddress> {\n return classifyAddress(address)\n .withErrorFormat((message) => `${policyName}: ${message}`)\n .onSuccess((classified) =>\n allowed.has(classified.classification)\n ? succeed(classified)\n : fail(`${policyName}: ${describeAddress(classified)} and is not allowed`)\n );\n}\n\nfunction checkAllAddresses(\n policyName: string,\n allowed: ReadonlySet<AddressClassification>,\n addresses: ReadonlyArray<string>\n): Result<IAddressCheckVerdict> {\n if (addresses.length === 0) {\n return fail(`${policyName}: no addresses to check - the destination cannot be verified`);\n }\n return mapResults(addresses.map((address) => checkOneAddress(policyName, allowed, address))).onSuccess(\n (classified) => succeed({ policy: policyName, addresses: classified })\n );\n}\n\n/**\n * Creates the recommended address policy: every address must be a globally\n * routable public unicast address.\n *\n * Rejected, in both their plain and their IPv6-embedded encodings: loopback,\n * the link-local range that carries the cloud instance-metadata endpoint\n * (`169.254.169.254`), RFC 1918 private ranges, IPv6 unique-local, carrier-grade\n * NAT (`100.64.0.0/10`), the unspecified address, multicast, broadcast,\n * benchmarking, documentation and every other reserved range. The encoding\n * bypasses covered are IPv4-mapped IPv6 (`::ffff:169.254.169.254`),\n * IPv4-compatible IPv6, NAT64 (`64:ff9b::a9fe:a9fe`), 6to4 (`2002:a9fe:a9fe::`)\n * and the shortened / octal / hexadecimal / decimal IPv4 literal forms\n * (`127.1`, `0177.0.0.1`, `0x7f.1`, `2130706433`).\n *\n * **What this does not protect against.** The policy classifies addresses a\n * caller has already resolved; it cannot see the address the connection\n * ultimately uses. A hostile DNS server that answers the resolution with a\n * public address and the connect with a private one is not stopped by this\n * policy — closing that requires connecting to a pinned address. The policy\n * also says nothing about scheme, host, port, redirects or response content.\n *\n * This is the **policy-layer** factory. It is not what a safer-fetch call's\n * `addressGuard` option takes: an entry point takes an `IAddressGuard`, which is\n * the asynchronous, hop-chain-aware, name-resolving half. `blockPrivateNetworks`\n * (Node only) is the guard that resolves a hostname and delegates every resolved\n * address to this policy; reach for that unless you are classifying an address\n * list you already hold.\n *\n * @param options - optional {@link IBlockPrivateNetworksOptions | relaxations}\n * of the default posture.\n * @returns the policy. Construction cannot fail.\n * @public\n */\nexport function blockPrivateNetworksPolicy(options?: IBlockPrivateNetworksOptions): IAddressPolicy {\n const allowLoopback: boolean = options?.allowLoopback === true;\n const name: string = allowLoopback\n ? 'blockPrivateNetworksPolicy(allowLoopback)'\n : 'blockPrivateNetworksPolicy';\n const allowed: ReadonlySet<AddressClassification> = allowLoopback ? PUBLIC_OR_LOOPBACK : PUBLIC_ONLY;\n return {\n name,\n checkAddresses: (addresses: ReadonlyArray<string>): Result<IAddressCheckVerdict> =>\n checkAllAddresses(name, allowed, addresses)\n };\n}\n\n/**\n * Creates a policy that permits every address, including loopback, link-local\n * and private ones.\n *\n * **This policy provides no protection whatsoever.** It classifies nothing,\n * rejects nothing, and never fails — not even for an empty address list or an\n * address that is not a well-formed literal. It exists so that choosing to go\n * without an address guarantee is a deliberate, named, greppable act at the\n * call site rather than something reachable by omission, and so that tests and\n * genuinely trusted-input paths do not hand-roll something worse.\n *\n * It is the only correct choice in a browser, where neither name resolution nor\n * redirect interposition is available and no address guarantee is possible.\n *\n * @returns the policy.\n * @public\n */\nexport function allowAnyAddressPolicy(): IAddressPolicy {\n const name: string = 'allowAnyAddressPolicy';\n return {\n name,\n // The parameter is deliberately not declared. This policy does not read the\n // addresses, and an unused `_addresses` would both restate that less\n // clearly and cut against the repo's \"don't rename unused variables to\n // `_var`\" rule. Parameter bivariance makes the zero-arg form a complete\n // implementation of `IAddressPolicy`.\n checkAddresses: (): Result<IAddressCheckVerdict> => succeed({ policy: name, addresses: [] })\n };\n}\n"]}
@@ -0,0 +1,51 @@
1
+ import { Result } from '@fgv/ts-utils';
2
+ /**
3
+ * A media type split into its type and subtype, lowercased, with parameters removed.
4
+ * @internal
5
+ */
6
+ export interface IMediaType {
7
+ readonly type: string;
8
+ readonly subtype: string;
9
+ }
10
+ /**
11
+ * Parses `type/subtype`, ignoring any parameters and surrounding whitespace, and lowercasing
12
+ * both halves.
13
+ *
14
+ * The parse is not as trivial as it looks, which is the argument for owning it once:
15
+ * `text/html; charset=utf-8` must match `text/html`, matching is case-insensitive on both
16
+ * halves, and parameters must be *stripped* rather than string-matched around. Every
17
+ * hand-rolled version gets a different subset of that right, and the failure is silent.
18
+ * @internal
19
+ */
20
+ export declare function parseMediaType(value: string): Result<IMediaType>;
21
+ /**
22
+ * Parses an allowlist entry, which may use `*` as a wildcard for the subtype (`text/*`) or for
23
+ * both halves (a wildcard type with a wildcard subtype, which accepts anything). A wildcard
24
+ * type paired with a *concrete* subtype is rejected: it reads as if it means something and
25
+ * does not.
26
+ * @internal
27
+ */
28
+ export declare function parseMediaTypePattern(value: string): Result<IMediaType>;
29
+ /**
30
+ * Reports whether a parsed media type matches a parsed allowlist pattern.
31
+ * @internal
32
+ */
33
+ export declare function mediaTypeMatches(actual: IMediaType, pattern: IMediaType): boolean;
34
+ /**
35
+ * Extracts the `charset` parameter from a `Content-Type` header value, lowercased.
36
+ *
37
+ * Returns `undefined` when the header is absent or carries no `charset`, which the caller
38
+ * reads as "use the default encoding" — not as "any encoding will do".
39
+ * @internal
40
+ */
41
+ export declare function parseCharset(contentType: string | undefined): string | undefined;
42
+ /**
43
+ * Parses a `Content-Length` header value.
44
+ *
45
+ * Returns `undefined` for an absent, non-numeric, negative, or non-integer value rather than
46
+ * guessing: a malformed length is not evidence of a size, and the streaming cap does not need
47
+ * it. It is a fast-reject path only.
48
+ * @internal
49
+ */
50
+ export declare function parseContentLength(value: string | undefined): number | undefined;
51
+ //# sourceMappingURL=contentType.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contentType.d.ts","sourceRoot":"","sources":["../../../src/packlets/safer-fetch/contentType.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAQ,MAAM,EAAW,MAAM,eAAe,CAAC;AAEtD;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAOD;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC,CAehE;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC,CAOvE;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,UAAU,GAAG,OAAO,CAQjF;AAMD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAUhF;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAUhF"}
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ // Copyright (c) 2026 Erik Fortune
3
+ //
4
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ // of this software and associated documentation files (the "Software"), to deal
6
+ // in the Software without restriction, including without limitation the rights
7
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ // copies of the Software, and to permit persons to whom the Software is
9
+ // furnished to do so, subject to the following conditions:
10
+ //
11
+ // The above copyright notice and this permission notice shall be included in all
12
+ // copies or substantial portions of the Software.
13
+ //
14
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ // SOFTWARE.
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.parseMediaType = parseMediaType;
23
+ exports.parseMediaTypePattern = parseMediaTypePattern;
24
+ exports.mediaTypeMatches = mediaTypeMatches;
25
+ exports.parseCharset = parseCharset;
26
+ exports.parseContentLength = parseContentLength;
27
+ const ts_utils_1 = require("@fgv/ts-utils");
28
+ // RFC 7230 token. Deliberately not a permissive `[^/;]+`: an allowlist entry that is not a
29
+ // well-formed media type should be reported to its author, not quietly compiled into a
30
+ // pattern that never matches anything.
31
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
32
+ /**
33
+ * Parses `type/subtype`, ignoring any parameters and surrounding whitespace, and lowercasing
34
+ * both halves.
35
+ *
36
+ * The parse is not as trivial as it looks, which is the argument for owning it once:
37
+ * `text/html; charset=utf-8` must match `text/html`, matching is case-insensitive on both
38
+ * halves, and parameters must be *stripped* rather than string-matched around. Every
39
+ * hand-rolled version gets a different subset of that right, and the failure is silent.
40
+ * @internal
41
+ */
42
+ function parseMediaType(value) {
43
+ const essence = value.split(';')[0].trim().toLowerCase();
44
+ const slash = essence.indexOf('/');
45
+ if (slash < 0) {
46
+ return (0, ts_utils_1.fail)(`"${value}": not a media type — expected "type/subtype".`);
47
+ }
48
+ const type = essence.slice(0, slash);
49
+ const subtype = essence.slice(slash + 1);
50
+ if (!TOKEN.test(type) && type !== '*') {
51
+ return (0, ts_utils_1.fail)(`"${value}": "${type}" is not a valid media type.`);
52
+ }
53
+ if (!TOKEN.test(subtype) && subtype !== '*') {
54
+ return (0, ts_utils_1.fail)(`"${value}": "${subtype}" is not a valid media subtype.`);
55
+ }
56
+ return (0, ts_utils_1.succeed)({ type, subtype });
57
+ }
58
+ /**
59
+ * Parses an allowlist entry, which may use `*` as a wildcard for the subtype (`text/*`) or for
60
+ * both halves (a wildcard type with a wildcard subtype, which accepts anything). A wildcard
61
+ * type paired with a *concrete* subtype is rejected: it reads as if it means something and
62
+ * does not.
63
+ * @internal
64
+ */
65
+ function parseMediaTypePattern(value) {
66
+ return parseMediaType(value).onSuccess((parsed) => {
67
+ if (parsed.type === '*' && parsed.subtype !== '*') {
68
+ return (0, ts_utils_1.fail)(`"${value}": a wildcard type requires a wildcard subtype.`);
69
+ }
70
+ return (0, ts_utils_1.succeed)(parsed);
71
+ });
72
+ }
73
+ /**
74
+ * Reports whether a parsed media type matches a parsed allowlist pattern.
75
+ * @internal
76
+ */
77
+ function mediaTypeMatches(actual, pattern) {
78
+ if (pattern.type === '*') {
79
+ return true;
80
+ }
81
+ if (pattern.type !== actual.type) {
82
+ return false;
83
+ }
84
+ return pattern.subtype === '*' || pattern.subtype === actual.subtype;
85
+ }
86
+ // A parameter value is either a token or a quoted-string; the charset names that matter here
87
+ // (`utf-8`, `iso-8859-1`, …) are tokens either way once the quotes come off.
88
+ const CHARSET_PARAM = /;\s*charset\s*=\s*(?:"([^"]*)"|([^;\s]*))/i;
89
+ /**
90
+ * Extracts the `charset` parameter from a `Content-Type` header value, lowercased.
91
+ *
92
+ * Returns `undefined` when the header is absent or carries no `charset`, which the caller
93
+ * reads as "use the default encoding" — not as "any encoding will do".
94
+ * @internal
95
+ */
96
+ function parseCharset(contentType) {
97
+ var _a;
98
+ if (contentType === undefined) {
99
+ return undefined;
100
+ }
101
+ const match = CHARSET_PARAM.exec(contentType);
102
+ if (match === null) {
103
+ return undefined;
104
+ }
105
+ const value = ((_a = match[1]) !== null && _a !== void 0 ? _a : match[2]).trim().toLowerCase();
106
+ return value.length > 0 ? value : undefined;
107
+ }
108
+ /**
109
+ * Parses a `Content-Length` header value.
110
+ *
111
+ * Returns `undefined` for an absent, non-numeric, negative, or non-integer value rather than
112
+ * guessing: a malformed length is not evidence of a size, and the streaming cap does not need
113
+ * it. It is a fast-reject path only.
114
+ * @internal
115
+ */
116
+ function parseContentLength(value) {
117
+ if (value === undefined) {
118
+ return undefined;
119
+ }
120
+ const trimmed = value.trim();
121
+ if (!/^\d+$/.test(trimmed)) {
122
+ return undefined;
123
+ }
124
+ const parsed = Number(trimmed);
125
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
126
+ }
127
+ //# sourceMappingURL=contentType.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contentType.js","sourceRoot":"","sources":["../../../src/packlets/safer-fetch/contentType.ts"],"names":[],"mappings":"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","sourcesContent":["// Copyright (c) 2026 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\nimport { fail, Result, succeed } from '@fgv/ts-utils';\n\n/**\n * A media type split into its type and subtype, lowercased, with parameters removed.\n * @internal\n */\nexport interface IMediaType {\n readonly type: string;\n readonly subtype: string;\n}\n\n// RFC 7230 token. Deliberately not a permissive `[^/;]+`: an allowlist entry that is not a\n// well-formed media type should be reported to its author, not quietly compiled into a\n// pattern that never matches anything.\nconst TOKEN: RegExp = /^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$/;\n\n/**\n * Parses `type/subtype`, ignoring any parameters and surrounding whitespace, and lowercasing\n * both halves.\n *\n * The parse is not as trivial as it looks, which is the argument for owning it once:\n * `text/html; charset=utf-8` must match `text/html`, matching is case-insensitive on both\n * halves, and parameters must be *stripped* rather than string-matched around. Every\n * hand-rolled version gets a different subset of that right, and the failure is silent.\n * @internal\n */\nexport function parseMediaType(value: string): Result<IMediaType> {\n const essence = value.split(';')[0].trim().toLowerCase();\n const slash = essence.indexOf('/');\n if (slash < 0) {\n return fail(`\"${value}\": not a media type — expected \"type/subtype\".`);\n }\n const type = essence.slice(0, slash);\n const subtype = essence.slice(slash + 1);\n if (!TOKEN.test(type) && type !== '*') {\n return fail(`\"${value}\": \"${type}\" is not a valid media type.`);\n }\n if (!TOKEN.test(subtype) && subtype !== '*') {\n return fail(`\"${value}\": \"${subtype}\" is not a valid media subtype.`);\n }\n return succeed({ type, subtype });\n}\n\n/**\n * Parses an allowlist entry, which may use `*` as a wildcard for the subtype (`text/*`) or for\n * both halves (a wildcard type with a wildcard subtype, which accepts anything). A wildcard\n * type paired with a *concrete* subtype is rejected: it reads as if it means something and\n * does not.\n * @internal\n */\nexport function parseMediaTypePattern(value: string): Result<IMediaType> {\n return parseMediaType(value).onSuccess((parsed) => {\n if (parsed.type === '*' && parsed.subtype !== '*') {\n return fail(`\"${value}\": a wildcard type requires a wildcard subtype.`);\n }\n return succeed(parsed);\n });\n}\n\n/**\n * Reports whether a parsed media type matches a parsed allowlist pattern.\n * @internal\n */\nexport function mediaTypeMatches(actual: IMediaType, pattern: IMediaType): boolean {\n if (pattern.type === '*') {\n return true;\n }\n if (pattern.type !== actual.type) {\n return false;\n }\n return pattern.subtype === '*' || pattern.subtype === actual.subtype;\n}\n\n// A parameter value is either a token or a quoted-string; the charset names that matter here\n// (`utf-8`, `iso-8859-1`, …) are tokens either way once the quotes come off.\nconst CHARSET_PARAM: RegExp = /;\\s*charset\\s*=\\s*(?:\"([^\"]*)\"|([^;\\s]*))/i;\n\n/**\n * Extracts the `charset` parameter from a `Content-Type` header value, lowercased.\n *\n * Returns `undefined` when the header is absent or carries no `charset`, which the caller\n * reads as \"use the default encoding\" — not as \"any encoding will do\".\n * @internal\n */\nexport function parseCharset(contentType: string | undefined): string | undefined {\n if (contentType === undefined) {\n return undefined;\n }\n const match = CHARSET_PARAM.exec(contentType);\n if (match === null) {\n return undefined;\n }\n const value = (match[1] ?? match[2]).trim().toLowerCase();\n return value.length > 0 ? value : undefined;\n}\n\n/**\n * Parses a `Content-Length` header value.\n *\n * Returns `undefined` for an absent, non-numeric, negative, or non-integer value rather than\n * guessing: a malformed length is not evidence of a size, and the streaming cap does not need\n * it. It is a fast-reject path only.\n * @internal\n */\nexport function parseContentLength(value: string | undefined): number | undefined {\n if (value === undefined) {\n return undefined;\n }\n const trimmed = value.trim();\n if (!/^\\d+$/.test(trimmed)) {\n return undefined;\n }\n const parsed = Number(trimmed);\n return Number.isSafeInteger(parsed) ? parsed : undefined;\n}\n"]}
@@ -0,0 +1,138 @@
1
+ import type { FetchFailureReason, FetchTimeoutPhase } from './failureReason';
2
+ /**
3
+ * Why a {@link DeadlineWatch} stopped waiting.
4
+ * @internal
5
+ */
6
+ export type DeadlineStopCause = 'caller-aborted' | FetchTimeoutPhase;
7
+ /**
8
+ * The outcome of racing a promise against the deadlines.
9
+ * @internal
10
+ */
11
+ export type DeadlineRace<T> = {
12
+ readonly stopped: false;
13
+ readonly value: T;
14
+ } | {
15
+ readonly stopped: true;
16
+ readonly cause: DeadlineStopCause;
17
+ };
18
+ /**
19
+ * Composes the caller's cancellation signal with an overall deadline and a headers deadline
20
+ * into one `AbortSignal`, and remembers which of the three stopped the call.
21
+ *
22
+ * @remarks
23
+ * The composition is done with a plain `AbortController` and listeners rather than
24
+ * `AbortSignal.any`, so there is no runtime-version floor to state and no feature-detection
25
+ * branch that only one of the two runtimes would ever execute.
26
+ *
27
+ * The overall deadline covers the connect, the headers, and the body read, and it is what
28
+ * stops a response that dribbles one byte every 25 seconds — such a response passes every
29
+ * per-read check and never trips a connect timeout.
30
+ * @internal
31
+ */
32
+ export declare class DeadlineWatch {
33
+ private _controller;
34
+ private readonly _startedAt;
35
+ private readonly _timeoutMs;
36
+ private readonly _headersTimeoutMs;
37
+ private readonly _callerSignal;
38
+ private readonly _onCallerAbort;
39
+ private _overallTimer;
40
+ private _headersTimer;
41
+ private _cause;
42
+ /**
43
+ * Whether the current stop ends the whole call rather than just this attempt. The overall
44
+ * deadline and the caller's signal are terminal; the per-attempt headers deadline is not.
45
+ */
46
+ private _terminal;
47
+ private _inBodyPhase;
48
+ private readonly _waiters;
49
+ constructor(timeoutMs: number, headersTimeoutMs: number, callerSignal?: AbortSignal);
50
+ /** The composed signal to hand to the transport. */
51
+ get signal(): AbortSignal;
52
+ /** Why the call was stopped, or `undefined` while it is still running. */
53
+ get cause(): DeadlineStopCause | undefined;
54
+ /**
55
+ * Records that a new attempt is starting: the headers deadline is (re)armed from now.
56
+ *
57
+ * @remarks
58
+ * The headers deadline is **per attempt**, and a redirect walk makes more than one. Without a
59
+ * re-arm, {@link DeadlineWatch.headersReceived} on the first hop would retire the headers
60
+ * deadline for the whole call, leaving every later hop bounded only by the overall deadline —
61
+ * so a chain whose second host simply never answers would hang for the overall budget instead
62
+ * of failing as `timeout.phase === 'headers'`.
63
+ *
64
+ * Called before the address guard rather than before the connect, because guard evaluation
65
+ * (a DNS resolution, in the shipped guard) is part of the time a caller waits for a usable
66
+ * response and is deliberately inside this budget.
67
+ *
68
+ * The overall deadline is untouched: it spans the whole call, redirects included.
69
+ */
70
+ attemptStarted(): void;
71
+ /**
72
+ * Records that response headers have arrived: the headers deadline no longer applies to this
73
+ * attempt, and a subsequent overall-deadline expiry is a body-phase timeout rather than an
74
+ * overall one.
75
+ */
76
+ headersReceived(): void;
77
+ /**
78
+ * Races a promise against the deadlines. A stopped race reports the cause; the underlying
79
+ * promise is abandoned, not cancelled — the caller is responsible for releasing whatever
80
+ * resource it represents (a response body reader, in practice).
81
+ *
82
+ * @remarks
83
+ * The waiter is removed once the race settles. A body read calls this once per chunk, so a
84
+ * waiter set that only grew would be a leak proportional to the number of chunks — in the
85
+ * exact code path whose job is to bound what a hostile response can cost the process.
86
+ */
87
+ race<T>(promise: Promise<T>): Promise<DeadlineRace<T>>;
88
+ /**
89
+ * Records that an attempt has ended and another may follow: the headers deadline is disarmed,
90
+ * and a stop that ended only *that attempt* is cleared.
91
+ *
92
+ * @remarks
93
+ * **The headers deadline is attempt-scoped and the overall deadline is not.** A retry attempt
94
+ * that timed out waiting for headers must not leave the whole call stopped — without this,
95
+ * retry could never answer a `timeout.phase === 'headers'` failure, which is precisely the
96
+ * failure retry exists for. The composed signal is replaced rather than reset, because an
97
+ * aborted `AbortSignal` cannot be un-aborted.
98
+ *
99
+ * The overall deadline and the caller's signal are **terminal** by contrast: neither is about
100
+ * one attempt, and clearing either would let a retry loop outlive the budget the caller set or
101
+ * ignore the cancellation the caller requested. A terminal stop survives this call, so the
102
+ * backoff that follows is answered immediately and the call ends.
103
+ *
104
+ * Called between attempts rather than at the start of one, so the interval spent in a backoff
105
+ * is bounded by the overall deadline alone — arming a headers deadline over a sleep during
106
+ * which no request is outstanding would stop the call for a response nobody is waiting for.
107
+ */
108
+ attemptEnded(): void;
109
+ /**
110
+ * How much of the overall deadline is left, in milliseconds, never below zero.
111
+ *
112
+ * @remarks
113
+ * Read by the retry scheduler, which must not sleep past a deadline it is already inside:
114
+ * "the overall budget is the ceiling" is only enforceable if the remaining budget is
115
+ * observable.
116
+ */
117
+ get remainingMs(): number;
118
+ /**
119
+ * Waits for the given number of milliseconds, or until the call is stopped — whichever comes
120
+ * first.
121
+ *
122
+ * @remarks
123
+ * Backing the retry delay with the same watch the request races against is what keeps a
124
+ * caller's `abort()` responsive *between* attempts. A bare `setTimeout` would leave a caller
125
+ * who cancelled during a five-second backoff waiting out the full delay before being told the
126
+ * call was aborted.
127
+ *
128
+ * The timer is cleared however the wait ends, so a stopped delay leaves nothing pending —
129
+ * which matters in a test runner, where a stray timer keeps the process alive.
130
+ */
131
+ delay(ms: number): Promise<DeadlineRace<true>>;
132
+ /** Builds the failure reason corresponding to why the call was stopped. */
133
+ toFailureReason(cause: DeadlineStopCause): FetchFailureReason;
134
+ /** Clears timers and listeners. Safe to call more than once. */
135
+ dispose(): void;
136
+ private _stop;
137
+ }
138
+ //# sourceMappingURL=deadline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deadline.d.ts","sourceRoot":"","sources":["../../../src/packlets/safer-fetch/deadline.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAE7E;;;GAGG;AACH,MAAM,MAAM,iBAAiB,GAAG,gBAAgB,GAAG,iBAAiB,CAAC;AAErE;;;GAGG;AACH,MAAM,MAAM,YAAY,CAAC,CAAC,IACtB;IAAE,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAC9C;IAAE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,iBAAiB,CAAA;CAAE,CAAC;AAElE;;;;;;;;;;;;;GAaG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,WAAW,CAAkB;IACrC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA0B;IACxD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAa;IAE5C,OAAO,CAAC,aAAa,CAA4C;IACjE,OAAO,CAAC,aAAa,CAA4C;IACjE,OAAO,CAAC,MAAM,CAAgC;IAC9C;;;OAGG;IACH,OAAO,CAAC,SAAS,CAAU;IAC3B,OAAO,CAAC,YAAY,CAAU;IAC9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA0C;gBAEhD,SAAS,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,WAAW;IAuB1F,oDAAoD;IACpD,IAAW,MAAM,IAAI,WAAW,CAE/B;IAED,0EAA0E;IAC1E,IAAW,KAAK,IAAI,iBAAiB,GAAG,SAAS,CAEhD;IAED;;;;;;;;;;;;;;;OAeG;IACI,cAAc,IAAI,IAAI;IAW7B;;;;OAIG;IACI,eAAe,IAAI,IAAI;IAQ9B;;;;;;;;;OASG;IACU,IAAI,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IAoBnE;;;;;;;;;;;;;;;;;;;OAmBG;IACI,YAAY,IAAI,IAAI;IAoB3B;;;;;;;OAOG;IACH,IAAW,WAAW,IAAI,MAAM,CAE/B;IAED;;;;;;;;;;;;OAYG;IACU,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;IAe3D,2EAA2E;IACpE,eAAe,CAAC,KAAK,EAAE,iBAAiB,GAAG,kBAAkB;IAYpE,gEAAgE;IACzD,OAAO,IAAI,IAAI;IAetB,OAAO,CAAC,KAAK;CAkBd"}