| 1 | /** |
| 2 | * Hexadecimal encoding, decoding, and random value helpers. |
| 3 | * |
| 4 | * @since 4.0.0 |
| 5 | */ |
| 6 | import * as Result from "../Result.ts" |
| 7 | import { EncodingError } from "./EncodingError.ts" |
| 8 | |
| 9 | /** |
| 10 | * Encodes the given value into a hex `string`. |
| 11 | * |
| 12 | * **When to use** |
| 13 | * |
| 14 | * Use to encode text or bytes as lowercase hexadecimal text. |
| 15 | * |
| 16 | * **Example** (Encoding hex strings and bytes) |
| 17 | * |
| 18 | * ```ts import.meta.vitest |
| 19 | * import { Hex } from "effect/encoding" |
| 20 | * |
| 21 | * // Encode a string to hex |
| 22 | * Hex.encode("hello") // => "68656c6c6f" |
| 23 | * |
| 24 | * // Encode binary data to hex |
| 25 | * const bytes = new Uint8Array([72, 101, 108, 108, 111]) |
| 26 | * Hex.encode(bytes) // => "48656c6c6f" |
| 27 | * ``` |
| 28 | * |
| 29 | * @category encoding |
| 30 | * @since 4.0.0 |
| 31 | */ |
| 32 | export const encode: (input: Uint8Array | string) => string = (input) => |
| 33 | typeof input === "string" ? hexEncodeUint8Array(encoder.encode(input)) : hexEncodeUint8Array(input) |
| 34 | |
| 35 | /** |
| 36 | * Generates a random lowercase hexadecimal string, optimized for lengths that |
| 37 | * are multiples of 8. |
| 38 | * |
| 39 | * **Details** |
| 40 | * |
| 41 | * `length` is not validated. The function generates `length >>> 3` random |
| 42 | * 8-character words, so non-negative lengths below `2 ** 32` are rounded down |
| 43 | * to a multiple of 8 and other values follow JavaScript's unsigned 32-bit |
| 44 | * coercion rules. |
| 45 | * |
| 46 | * This function uses `Math.random()` and is not cryptographically secure. For |
| 47 | * security-sensitive values, use the `Crypto.Crypto` service's `randomBytes` |
| 48 | * method and encode the result with {@link encode}. |
| 49 | * |
| 50 | * @category encoding |
| 51 | * @since 4.0.0 |
| 52 | */ |
| 53 | export const random = (length: number): string => { |
| 54 | switch (length) { |
| 55 | case 16: |
| 56 | return random16() |
| 57 | case 32: |
| 58 | return random32() |
| 59 | default: { |
| 60 | let result = "" |
| 61 | for (let i = length >>> 3; i > 0; i--) { |
| 62 | result += random8() |
| 63 | } |
| 64 | return result |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | const hexCharCodes = Uint8Array.from("0123456789abcdef", (c) => c.charCodeAt(0)) |
| 70 | |
| 71 | const randomWord = (): number => (Math.random() * 0x100000000) >>> 0 |
| 72 | |
| 73 | // Trace and span identifiers are the common lengths. A single |
| 74 | // String.fromCharCode call produces a flat string, which avoids rope |
| 75 | // flattening when the identifier is later serialized. |
| 76 | const random8 = (): string => { |
| 77 | const a = randomWord() |
| 78 | return String.fromCharCode( |
| 79 | hexCharCodes[a >>> 28], |
| 80 | hexCharCodes[(a >>> 24) & 15], |
| 81 | hexCharCodes[(a >>> 20) & 15], |
| 82 | hexCharCodes[(a >>> 16) & 15], |
| 83 | hexCharCodes[(a >>> 12) & 15], |
| 84 | hexCharCodes[(a >>> 8) & 15], |
| 85 | hexCharCodes[(a >>> 4) & 15], |
| 86 | hexCharCodes[a & 15] |
| 87 | ) |
| 88 | } |
| 89 | |
| 90 | const random16 = (): string => { |
| 91 | const a = randomWord() |
| 92 | const b = randomWord() |
| 93 | return String.fromCharCode( |
| 94 | hexCharCodes[a >>> 28], |
| 95 | hexCharCodes[(a >>> 24) & 15], |
| 96 | hexCharCodes[(a >>> 20) & 15], |
| 97 | hexCharCodes[(a >>> 16) & 15], |
| 98 | hexCharCodes[(a >>> 12) & 15], |
| 99 | hexCharCodes[(a >>> 8) & 15], |
| 100 | hexCharCodes[(a >>> 4) & 15], |
| 101 | hexCharCodes[a & 15], |
| 102 | hexCharCodes[b >>> 28], |
| 103 | hexCharCodes[(b >>> 24) & 15], |
| 104 | hexCharCodes[(b >>> 20) & 15], |
| 105 | hexCharCodes[(b >>> 16) & 15], |
| 106 | hexCharCodes[(b >>> 12) & 15], |
| 107 | hexCharCodes[(b >>> 8) & 15], |
| 108 | hexCharCodes[(b >>> 4) & 15], |
| 109 | hexCharCodes[b & 15] |
| 110 | ) |
| 111 | } |
| 112 | |
| 113 | const random32 = (): string => { |
| 114 | const a = randomWord() |
| 115 | const b = randomWord() |
| 116 | const c = randomWord() |
| 117 | const d = randomWord() |
| 118 | return String.fromCharCode( |
| 119 | hexCharCodes[a >>> 28], |
| 120 | hexCharCodes[(a >>> 24) & 15], |
| 121 | hexCharCodes[(a >>> 20) & 15], |
| 122 | hexCharCodes[(a >>> 16) & 15], |
| 123 | hexCharCodes[(a >>> 12) & 15], |
| 124 | hexCharCodes[(a >>> 8) & 15], |
| 125 | hexCharCodes[(a >>> 4) & 15], |
| 126 | hexCharCodes[a & 15], |
| 127 | hexCharCodes[b >>> 28], |
| 128 | hexCharCodes[(b >>> 24) & 15], |
| 129 | hexCharCodes[(b >>> 20) & 15], |
| 130 | hexCharCodes[(b >>> 16) & 15], |
| 131 | hexCharCodes[(b >>> 12) & 15], |
| 132 | hexCharCodes[(b >>> 8) & 15], |
| 133 | hexCharCodes[(b >>> 4) & 15], |
| 134 | hexCharCodes[b & 15], |
| 135 | hexCharCodes[c >>> 28], |
| 136 | hexCharCodes[(c >>> 24) & 15], |
| 137 | hexCharCodes[(c >>> 20) & 15], |
| 138 | hexCharCodes[(c >>> 16) & 15], |
| 139 | hexCharCodes[(c >>> 12) & 15], |
| 140 | hexCharCodes[(c >>> 8) & 15], |
| 141 | hexCharCodes[(c >>> 4) & 15], |
| 142 | hexCharCodes[c & 15], |
| 143 | hexCharCodes[d >>> 28], |
| 144 | hexCharCodes[(d >>> 24) & 15], |
| 145 | hexCharCodes[(d >>> 20) & 15], |
| 146 | hexCharCodes[(d >>> 16) & 15], |
| 147 | hexCharCodes[(d >>> 12) & 15], |
| 148 | hexCharCodes[(d >>> 8) & 15], |
| 149 | hexCharCodes[(d >>> 4) & 15], |
| 150 | hexCharCodes[d & 15] |
| 151 | ) |
| 152 | } |
| 153 | |
| 154 | /** |
| 155 | * Decodes a hexadecimal string into bytes safely. |
| 156 | * |
| 157 | * **When to use** |
| 158 | * |
| 159 | * Use to decode hexadecimal text into bytes without throwing on invalid input. |
| 160 | * |
| 161 | * **Details** |
| 162 | * |
| 163 | * Returns `Result.succeed` with a `Uint8Array` when decoding succeeds, or |
| 164 | * `Result.fail` with an `EncodingError` when the input has an odd length or |
| 165 | * contains invalid hex characters. |
| 166 | * |
| 167 | * **Example** (Decoding hex bytes) |
| 168 | * |
| 169 | * ```ts import.meta.vitest |
| 170 | * import { Result } from "effect" |
| 171 | * import { Hex } from "effect/encoding" |
| 172 | * |
| 173 | * Hex.decode("48656c6c6f") // => Result.succeed(new Uint8Array([72, 101, 108, 108, 111])) |
| 174 | * ``` |
| 175 | * |
| 176 | * @category decoding |
| 177 | * @since 4.0.0 |
| 178 | */ |
| 179 | export const decode = (str: string): Result.Result<Uint8Array, EncodingError> => { |
| 180 | const bytes = new TextEncoder().encode(str) |
| 181 | if (bytes.length % 2 !== 0) { |
| 182 | return Result.fail( |
| 183 | new EncodingError({ |
| 184 | module: "Hex", |
| 185 | kind: "Decode", |
| 186 | input: str, |
| 187 | message: `Length must be a multiple of 2, but is ${bytes.length}` |
| 188 | }) |
| 189 | ) |
| 190 | } |
| 191 | |
| 192 | try { |
| 193 | const length = bytes.length / 2 |
| 194 | const result = new Uint8Array(length) |
| 195 | for (let i = 0; i < length; i++) { |
| 196 | const a = fromHexChar(bytes[i * 2]) |
| 197 | const b = fromHexChar(bytes[i * 2 + 1]) |
| 198 | result[i] = (a << 4) | b |
| 199 | } |
| 200 | |
| 201 | return Result.succeed(result) |
| 202 | } catch (e) { |
| 203 | return Result.fail( |
| 204 | new EncodingError({ |
| 205 | module: "Hex", |
| 206 | kind: "Decode", |
| 207 | input: str, |
| 208 | message: e instanceof Error ? e.message : "Invalid input" |
| 209 | }) |
| 210 | ) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | /** |
| 215 | * Decodes a hexadecimal string into a UTF-8 string safely. |
| 216 | * |
| 217 | * **When to use** |
| 218 | * |
| 219 | * Use to decode hexadecimal text into UTF-8 text without throwing on invalid |
| 220 | * input. |
| 221 | * |
| 222 | * **Details** |
| 223 | * |
| 224 | * Returns `Result.succeed` with the decoded text when decoding succeeds, or |
| 225 | * `Result.fail` with an `EncodingError` when the input is not valid hex. |
| 226 | * |
| 227 | * **Example** (Decoding hex strings) |
| 228 | * |
| 229 | * ```ts import.meta.vitest |
| 230 | * import { Result } from "effect" |
| 231 | * import { Hex } from "effect/encoding" |
| 232 | * |
| 233 | * Hex.decodeString("68656c6c6f") // => Result.succeed("hello") |
| 234 | * ``` |
| 235 | * |
| 236 | * @category decoding |
| 237 | * @since 4.0.0 |
| 238 | */ |
| 239 | export const decodeString = (str: string) => Result.map(decode(str), (_) => decoder.decode(_)) |
| 240 | |
| 241 | const encoder = new TextEncoder() |
| 242 | const decoder = new TextDecoder() |
| 243 | |
| 244 | const byteToHex: Array<string> = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0")) |
| 245 | |
| 246 | const hexEncodeUint8Array = (bytes: Uint8Array): string => { |
| 247 | let result = "" |
| 248 | for (let i = 0; i < bytes.length; i++) { |
| 249 | result += byteToHex[bytes[i]] |
| 250 | } |
| 251 | return result |
| 252 | } |
| 253 | |
| 254 | const fromHexChar = (byte: number) => { |
| 255 | if (48 <= byte && byte <= 57) { |
| 256 | return byte - 48 |
| 257 | } |
| 258 | |
| 259 | if (97 <= byte && byte <= 102) { |
| 260 | return byte - 97 + 10 |
| 261 | } |
| 262 | |
| 263 | if (65 <= byte && byte <= 70) { |
| 264 | return byte - 65 + 10 |
| 265 | } |
| 266 | |
| 267 | throw new TypeError("Invalid input") |
| 268 | } |