Build Reactive Signals for Bluesky's AT Protocol Firehose in Laravel
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}