Build Reactive Signals for Bluesky's AT Protocol Firehose in Laravel
at dev 7.6 kB view raw
1<?php 2 3declare(strict_types=1); 4 5namespace SocialDept\AtpSignals\CBOR; 6 7use RuntimeException; 8use SocialDept\AtpSignals\Binary\Reader; 9use SocialDept\AtpSignals\Core\CID; 10 11/** 12 * CBOR (Concise Binary Object Representation) decoder. 13 * 14 * Implements RFC 8949 CBOR with DAG-CBOR extensions for IPLD. 15 * Supports tag 42 for CID links. 16 */ 17class Decoder 18{ 19 private const MAJOR_TYPE_UNSIGNED = 0; 20 21 private const MAJOR_TYPE_NEGATIVE = 1; 22 23 private const MAJOR_TYPE_BYTES = 2; 24 25 private const MAJOR_TYPE_TEXT = 3; 26 27 private const MAJOR_TYPE_ARRAY = 4; 28 29 private const MAJOR_TYPE_MAP = 5; 30 31 private const MAJOR_TYPE_TAG = 6; 32 33 private const MAJOR_TYPE_SPECIAL = 7; 34 35 private const TAG_CID = 42; 36 37 private Reader $reader; 38 39 public function __construct(string $data) 40 { 41 $this->reader = new Reader($data); 42 } 43 44 /** 45 * Decode the next CBOR item. 46 * 47 * @return mixed Decoded value 48 * 49 * @throws RuntimeException If data is malformed 50 */ 51 public function decode(): mixed 52 { 53 if (! $this->reader->hasMore()) { 54 throw new RuntimeException('Unexpected end of CBOR data'); 55 } 56 57 $initialByte = $this->reader->readByte(); 58 $majorType = $initialByte >> 5; 59 $additionalInfo = $initialByte & 0x1F; 60 61 return match ($majorType) { 62 self::MAJOR_TYPE_UNSIGNED => $this->decodeUnsigned($additionalInfo), 63 self::MAJOR_TYPE_NEGATIVE => $this->decodeNegative($additionalInfo), 64 self::MAJOR_TYPE_BYTES => $this->decodeBytes($additionalInfo), 65 self::MAJOR_TYPE_TEXT => $this->decodeText($additionalInfo), 66 self::MAJOR_TYPE_ARRAY => $this->decodeArray($additionalInfo), 67 self::MAJOR_TYPE_MAP => $this->decodeMap($additionalInfo), 68 self::MAJOR_TYPE_TAG => $this->decodeTag($additionalInfo), 69 self::MAJOR_TYPE_SPECIAL => $this->decodeSpecial($additionalInfo), 70 default => throw new RuntimeException("Unknown major type: {$majorType}"), 71 }; 72 } 73 74 /** 75 * Check if there's more data to decode. 76 */ 77 public function hasMore(): bool 78 { 79 return $this->reader->hasMore(); 80 } 81 82 /** 83 * Get current position. 84 */ 85 public function getPosition(): int 86 { 87 return $this->reader->getPosition(); 88 } 89 90 /** 91 * Decode unsigned integer. 92 */ 93 private function decodeUnsigned(int $additionalInfo): int 94 { 95 return $this->decodeLength($additionalInfo); 96 } 97 98 /** 99 * Decode negative integer. 100 */ 101 private function decodeNegative(int $additionalInfo): int 102 { 103 $value = $this->decodeLength($additionalInfo); 104 105 return -1 - $value; 106 } 107 108 /** 109 * Decode byte string. 110 */ 111 private function decodeBytes(int $additionalInfo): string 112 { 113 $length = $this->decodeLength($additionalInfo); 114 115 return $this->reader->readBytes($length); 116 } 117 118 /** 119 * Decode text string. 120 */ 121 private function decodeText(int $additionalInfo): string 122 { 123 $length = $this->decodeLength($additionalInfo); 124 125 return $this->reader->readBytes($length); 126 } 127 128 /** 129 * Decode array. 130 */ 131 private function decodeArray(int $additionalInfo): array 132 { 133 $length = $this->decodeLength($additionalInfo); 134 $array = []; 135 136 for ($i = 0; $i < $length; $i++) { 137 $array[] = $this->decode(); 138 } 139 140 return $array; 141 } 142 143 /** 144 * Decode map (object). 145 */ 146 private function decodeMap(int $additionalInfo): array 147 { 148 $length = $this->decodeLength($additionalInfo); 149 $map = []; 150 151 for ($i = 0; $i < $length; $i++) { 152 $key = $this->decode(); 153 $value = $this->decode(); 154 155 if (! is_string($key) && ! is_int($key)) { 156 throw new RuntimeException('Map keys must be strings or integers'); 157 } 158 159 $map[$key] = $value; 160 } 161 162 return $map; 163 } 164 165 /** 166 * Decode tagged value. 167 */ 168 private function decodeTag(int $additionalInfo): mixed 169 { 170 $tag = $this->decodeLength($additionalInfo); 171 172 if ($tag === self::TAG_CID) { 173 // Tag 42 = CID link (DAG-CBOR) 174 // Next item should be byte string containing CID 175 $cidBytes = $this->decode(); 176 177 if (! is_string($cidBytes)) { 178 throw new RuntimeException('CID tag must be followed by byte string'); 179 } 180 181 // First byte should be 0x00 for CID 182 if (ord($cidBytes[0]) !== 0x00) { 183 throw new RuntimeException('Invalid CID byte string prefix'); 184 } 185 186 return CID::fromBinary(substr($cidBytes, 1)); 187 } 188 189 // For other tags, just return the tagged value 190 return $this->decode(); 191 } 192 193 /** 194 * Decode special values (bool, null, floats). 195 */ 196 private function decodeSpecial(int $additionalInfo): mixed 197 { 198 return match ($additionalInfo) { 199 20 => false, 200 21 => true, 201 22 => null, 202 23 => throw new RuntimeException('Undefined special value'), 203 25 => $this->decodeFloat16(), // IEEE 754 Half-Precision (16-bit) 204 26 => $this->decodeFloat32(), // IEEE 754 Single-Precision (32-bit) 205 27 => $this->decodeFloat64(), // IEEE 754 Double-Precision (64-bit) 206 default => throw new RuntimeException("Unsupported special value: {$additionalInfo}"), 207 }; 208 } 209 210 /** 211 * Decode IEEE 754 half-precision float (16-bit). 212 */ 213 private function decodeFloat16(): float 214 { 215 $bytes = $this->reader->readBytes(2); 216 $bits = unpack('n', $bytes)[1]; 217 218 // Extract sign, exponent, and mantissa 219 $sign = ($bits >> 15) & 1; 220 $exponent = ($bits >> 10) & 0x1F; 221 $mantissa = $bits & 0x3FF; 222 223 // Handle special cases 224 if ($exponent === 0) { 225 // Subnormal or zero 226 $value = $mantissa / 1024.0 * (2 ** -14); 227 } elseif ($exponent === 31) { 228 // Infinity or NaN 229 return $mantissa === 0 ? ($sign ? -INF : INF) : NAN; 230 } else { 231 // Normalized value 232 $value = (1 + $mantissa / 1024.0) * (2 ** ($exponent - 15)); 233 } 234 235 return $sign ? -$value : $value; 236 } 237 238 /** 239 * Decode IEEE 754 single-precision float (32-bit). 240 */ 241 private function decodeFloat32(): float 242 { 243 $bytes = $this->reader->readBytes(4); 244 245 return unpack('G', $bytes)[1]; // Big-endian float 246 } 247 248 /** 249 * Decode IEEE 754 double-precision float (64-bit). 250 */ 251 private function decodeFloat64(): float 252 { 253 $bytes = $this->reader->readBytes(8); 254 255 return unpack('E', $bytes)[1]; // Big-endian double 256 } 257 258 /** 259 * Decode length/value from additional info. 260 */ 261 private function decodeLength(int $additionalInfo): int 262 { 263 if ($additionalInfo < 24) { 264 return $additionalInfo; 265 } 266 267 return match ($additionalInfo) { 268 24 => $this->reader->readByte(), 269 25 => unpack('n', $this->reader->readBytes(2))[1], 270 26 => unpack('N', $this->reader->readBytes(4))[1], 271 27 => $this->readUint64(), 272 default => throw new RuntimeException("Invalid additional info: {$additionalInfo}"), 273 }; 274 } 275 276 /** 277 * Read 64-bit unsigned integer. 278 */ 279 private function readUint64(): int 280 { 281 $bytes = $this->reader->readBytes(8); 282 $unpacked = unpack('J', $bytes)[1]; 283 284 return $unpacked; 285 } 286}