Current section

26 Versions

Jump to

Compare versions

76 files changed
+5122 additions
-5566 deletions
  @@ -3,167 +3,359 @@
3 3 import HologramInterpreterError from "./errors/interpreter_error.mjs";
4 4 import Interpreter from "./interpreter.mjs";
5 5 import Type from "./type.mjs";
6 - import Utils from "./utils.mjs";
7 6
8 7 export default class Bitstring {
9 - static buildSignedBigIntFromBitArray(bitArray) {
10 - if (bitArray.length === 0) {
11 - return 0n;
8 + static #decoder = new TextDecoder("utf-8", {fatal: true});
9 + static #encoder = new TextEncoder("utf-8");
10 +
11 + static calculateBitCount(bitstring) {
12 + if (bitstring.bytes !== null) {
13 + const completeByteCount =
14 + bitstring.leftoverBitCount === 0
15 + ? bitstring.bytes.length
16 + : bitstring.bytes.length - 1;
17 +
18 + return 8 * completeByteCount + bitstring.leftoverBitCount;
12 19 }
13 20
14 - const signBit = bitArray[0];
15 -
16 - const value = bitArray.slice(1).reduce((acc, bit, index) => {
17 - return acc | BigInt(bit << (bitArray.length - index - 2));
18 - }, 0n);
19 -
20 - return signBit === 1 ? -BigInt(2 ** (bitArray.length - 1)) + value : value;
21 + return 8 * $.calculateTextByteCount(bitstring.text);
21 22 }
22 23
23 - static buildUnsignedBigIntFromBitArray(bitArray) {
24 - return bitArray.reduce((acc, bit, index) => {
25 - return acc | BigInt(bit << (bitArray.length - 1 - index));
26 - }, 0n);
24 + static calculateSegmentBitCount(segment) {
25 + const size = $.resolveSegmentSize(segment);
26 + const unit = $.resolveSegmentUnit(segment);
27 +
28 + return size * unit;
27 29 }
28 30
29 - // TODO: test
30 - static buildValueFromBitstringChunk(segment, bitArray, offset) {
31 - switch (segment.type) {
32 - case "float":
33 - return Bitstring.#buildFloatFromBitstringChunk(
34 - segment,
35 - bitArray,
36 - offset,
37 - );
38 -
39 - case "integer":
40 - return Bitstring.#buildIntegerFromBitstringChunk(
41 - segment,
42 - bitArray,
43 - offset,
44 - );
45 -
46 - case "utf8":
47 - return Bitstring.fetchNextCodePointFromUtf8BitstringChunk(
48 - bitArray,
49 - offset,
50 - );
51 -
52 - default:
53 - throw new HologramInterpreterError(
54 - `building ${segment.type} value from a bitstring segment is not yet implemented in Hologram`,
55 - );
56 - }
31 + static calculateTextByteCount(text) {
32 + return $.#encoder.encode(text).length;
57 33 }
58 34
59 - // See: https://en.wikipedia.org/wiki/UTF-8#Encoding
60 - static fetchNextCodePointFromUtf8BitstringChunk(bitArray, offset) {
61 - const numRemainingBits = bitArray.length - offset;
62 -
63 - let numBytes;
64 -
65 - // 0xxxxxxx
66 - if (numRemainingBits >= 8 && bitArray[offset] === 0) {
67 - numBytes = 1;
68 - } else if (
69 - // 110xxxxx, 10xxxxxx
70 - numRemainingBits >= 16 &&
71 - bitArray[offset] === 1 &&
72 - bitArray[offset + 1] === 1 &&
73 - bitArray[offset + 2] === 0 &&
74 - bitArray[offset + 8] == 1 &&
75 - bitArray[offset + 9] == 0
76 - ) {
77 - numBytes = 2;
78 - } else if (
79 - //1110xxxx, 10xxxxxx, 10xxxxxx
80 - numRemainingBits >= 24 &&
81 - bitArray[offset] === 1 &&
82 - bitArray[offset + 1] === 1 &&
83 - bitArray[offset + 2] === 1 &&
84 - bitArray[offset + 3] === 0 &&
85 - bitArray[offset + 8] == 1 &&
86 - bitArray[offset + 9] == 0 &&
87 - bitArray[offset + 16] == 1 &&
88 - bitArray[offset + 17] == 0
89 - ) {
90 - numBytes = 3;
91 - } else if (
92 - // 11110xxx, 10xxxxxx, 10xxxxxx, 10xxxxxx
93 - numRemainingBits >= 32 &&
94 - bitArray[offset] === 1 &&
95 - bitArray[offset + 1] === 1 &&
96 - bitArray[offset + 2] === 1 &&
97 - bitArray[offset + 3] === 1 &&
98 - bitArray[offset + 4] === 0 &&
99 - bitArray[offset + 8] == 1 &&
100 - bitArray[offset + 9] == 0 &&
101 - bitArray[offset + 16] == 1 &&
102 - bitArray[offset + 17] == 0 &&
103 - bitArray[offset + 24] == 1 &&
104 - bitArray[offset + 25] == 0
105 - ) {
106 - numBytes = 4;
107 - } else {
108 - return false;
35 + static concat(bitstrings) {
36 + // Fast path: if a single bitstring is given return it as is
37 + if (bitstrings.length === 1) {
38 + return bitstrings[0];
109 39 }
110 40
111 - if (numBytes > 1 && offset + numBytes * 8 > bitArray.length) {
112 - return false;
41 + // Fast path: if all bitstrings are text-based join them
42 + // Notice: this also covers the case when an empty array is given (an empty bitstring is returned)
43 + if (bitstrings.every((bs) => bs.text !== null)) {
44 + const text = bitstrings.map((bs) => bs.text).join("");
45 + return $.fromText(text);
113 46 }
114 47
115 - const chunks = [];
48 + bitstrings.forEach((bs) => $.maybeSetBytesFromText(bs));
116 49
117 - switch (numBytes) {
118 - case 1:
119 - chunks[0] = bitArray.slice(offset + 1, offset + 8);
120 - break;
121 -
122 - case 2:
123 - chunks[0] = bitArray.slice(offset + 3, offset + 8);
124 - chunks[1] = bitArray.slice(offset + 8 + 2, offset + 2 * 8);
125 - break;
126 -
127 - case 3:
128 - chunks[0] = bitArray.slice(offset + 4, offset + 8);
129 - chunks[1] = bitArray.slice(offset + 8 + 2, offset + 2 * 8);
130 - chunks[2] = bitArray.slice(offset + 2 * 8 + 2, offset + 3 * 8);
131 - break;
132 -
133 - case 4:
134 - chunks[0] = bitArray.slice(offset + 5, offset + 8);
135 - chunks[1] = bitArray.slice(offset + 8 + 2, offset + 8 + 8);
136 - chunks[2] = bitArray.slice(offset + 2 * 8 + 2, offset + 3 * 8);
137 - chunks[3] = bitArray.slice(offset + 3 * 8 + 2, offset + 4 * 8);
138 - break;
50 + // Fast path: no bitstrings with leftover bits
51 + if (bitstrings.every((bs) => bs.leftoverBitCount === 0)) {
52 + return $.#concatBitstringsWithoutLeftoverBits(bitstrings);
139 53 }
140 54
141 - const codePointBitCount = chunks.reduce(
142 - (acc, chunk) => acc + chunk.length,
55 + // Complex case: handle leftover bits
56 +
57 + const totalBitCount = bitstrings.reduce(
58 + (acc, bs) => acc + $.calculateBitCount(bs),
143 59 0,
144 60 );
145 61
146 - const codePointBitArray = new Uint8Array(codePointBitCount);
147 - let codePointOffset = 0;
62 + const resultByteCount = Math.ceil(totalBitCount / 8);
63 + const resultLeftoverBitCount = totalBitCount % 8;
64 + const resultBytes = new Uint8Array(resultByteCount);
148 65
149 - for (const chunk of chunks) {
150 - codePointBitArray.set(chunk, codePointOffset);
151 - codePointOffset += chunk.length;
66 + let bitOffset = 0;
67 +
68 + for (let i = 0; i < bitstrings.length; i++) {
69 + const bs = bitstrings[i];
70 + const bsBitCount = $.calculateBitCount(bs);
71 +
72 + if (bsBitCount === 0) continue;
73 +
74 + const byteOffset = bitOffset >>> 3; // Integer division by 8
75 +
76 + if (bitOffset % 8 === 0) {
77 + // If we're at a byte boundary, we can use a fast path
78 + $.#appendBitstringAtByteBoundary(resultBytes, byteOffset, bs);
79 + } else {
80 + // We're not at a byte boundary - need to shift bits
81 + $.#appendBitstringNotAtByteBoundary(
82 + resultBytes,
83 + byteOffset,
84 + bitOffset,
85 + bs,
86 + );
87 + }
88 +
89 + bitOffset += bsBitCount;
152 90 }
153 91
154 - const codePoint =
155 - Bitstring.buildUnsignedBigIntFromBitArray(codePointBitArray);
156 -
157 - return [Type.integer(codePoint), numBytes * 8];
92 + return {
93 + type: "bitstring",
94 + text: null,
95 + bytes: resultBytes,
96 + leftoverBitCount: resultLeftoverBitCount,
97 + hex: null,
98 + };
158 99 }
159 100
160 - static from(segments) {
161 - const bitArrays = segments.map((segment, index) => {
162 - Bitstring.validateSegment(segment, index + 1);
163 - return Bitstring.#buildBitArray(segment, index + 1);
101 + // TODO: support utf8, utf16, utf32 modifiers
102 + static decodeSegmentChunk(segment, chunk) {
103 + let endianness, signedness;
104 +
105 + switch (segment.type) {
106 + case "binary":
107 + case "bitstring":
108 + return chunk;
109 +
110 + case "float":
111 + endianness = segment.endianness || "big";
112 + return $.toFloat(chunk, endianness);
113 +
114 + case "integer":
115 + signedness = segment.signedness || "unsigned";
116 + endianness = segment.endianness || "big";
117 + return $.toInteger(chunk, signedness, endianness);
118 +
119 + default:
120 + throw new HologramInterpreterError(
121 + `${segment.type} segment type modifier is not yet implemented in Hologram`,
122 + );
123 + }
124 + }
125 +
126 + static fromBits(bits) {
127 + const bitCount = bits.length;
128 + const byteCount = Math.ceil(bitCount / 8);
129 + const leftoverBitCount = bitCount % 8;
130 + const bytes = new Uint8Array(byteCount);
131 +
132 + // Process 8 bytes at a time when possible
133 + let byteIndex = 0;
134 + let bitIndex = 0;
135 +
136 + // Fast path for byte-aligned chunks
137 + while (bitIndex + 8 <= bitCount) {
138 + bytes[byteIndex++] =
139 + (bits[bitIndex] << 7) |
140 + (bits[bitIndex + 1] << 6) |
141 + (bits[bitIndex + 2] << 5) |
142 + (bits[bitIndex + 3] << 4) |
143 + (bits[bitIndex + 4] << 3) |
144 + (bits[bitIndex + 5] << 2) |
145 + (bits[bitIndex + 6] << 1) |
146 + bits[bitIndex + 7];
147 + bitIndex += 8;
148 + }
149 +
150 + // Handle remaining bits if any
151 + if (bitIndex < bitCount) {
152 + let lastByte = 0;
153 +
154 + for (let j = 0; j < leftoverBitCount; j++) {
155 + if (bits[bitIndex + j]) {
156 + lastByte |= 1 << (7 - j);
157 + }
158 + }
159 + bytes[byteIndex] = lastByte;
160 + }
161 +
162 + return {
163 + type: "bitstring",
164 + text: null,
165 + bytes,
166 + leftoverBitCount,
167 + hex: null,
168 + };
169 + }
170 +
171 + static fromBytes(bytes) {
172 + const uint8Bytes =
173 + bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
174 +
175 + return {
176 + type: "bitstring",
177 + text: null,
178 + bytes: uint8Bytes,
179 + leftoverBitCount: 0,
180 + hex: null,
181 + };
182 + }
183 +
184 + static fromSegments(segments) {
185 + const bitstrings = segments.map((segment) => {
186 + switch (segment.value.type) {
187 + case "bitstring":
188 + return $.fromSegmentWithBitstringValue(segment);
189 +
190 + case "float":
191 + return $.fromSegmentWithFloatValue(segment);
192 +
193 + case "integer":
194 + return $.fromSegmentWithIntegerValue(segment);
195 +
196 + case "string":
197 + return $.fromSegmentWithStringValue(segment);
198 + }
164 199 });
165 200
166 - return {type: "bitstring", bits: Utils.concatUint8Arrays(bitArrays)};
201 + return $.concat(bitstrings);
202 + }
203 +
204 + static fromSegmentWithBitstringValue(segment) {
205 + // Fast path: if no size specified, use the entire bitstring
206 + if (segment.size === null) {
207 + return segment.value;
208 + }
209 +
210 + // For bitstrings "unit" is always 1, so we can use just "size"
211 + return $.takeChunk(segment.value, 0, Number(segment.size.value));
212 + }
213 +
214 + static fromSegmentWithFloatValue(segment) {
215 + let value;
216 +
217 + if (segment.value.type === "float") {
218 + value = segment.value.value;
219 + } else {
220 + // integer
221 + value = Number(segment.value.value);
222 + }
223 +
224 + const isLittleEndian = $.#isLittleEndian(segment);
225 +
226 + const bitCount = $.calculateSegmentBitCount(segment);
227 + const byteCount = bitCount / 8;
228 + const buffer = new ArrayBuffer(byteCount);
229 + const dataView = new DataView(buffer);
230 +
231 + if (bitCount === 64) {
232 + dataView.setFloat64(0, value, isLittleEndian);
233 + } else if (bitCount === 32) {
234 + dataView.setFloat32(0, value, isLittleEndian);
235 + } else {
236 + // DataView.setFloat16() has limited availability in browsers
237 + $.#setFloat16(dataView, value, isLittleEndian);
238 + }
239 +
240 + return {
241 + type: "bitstring",
242 + text: null,
243 + bytes: new Uint8Array(buffer),
244 + leftoverBitCount: 0,
245 + hex: null,
246 + };
247 + }
248 +
249 + // TODO: support utf16 and utf32 type modifiers
250 + static fromSegmentWithIntegerValue(segment) {
251 + const value = segment.value.value;
252 +
253 + if (segment.type === "utf8") {
254 + return {
255 + type: "bitstring",
256 + text: String.fromCodePoint(Number(value)),
257 + bytes: null,
258 + leftoverBitCount: 0,
259 + hex: null,
260 + };
261 + }
262 +
263 + if (
264 + value >= BigInt(Number.MIN_SAFE_INTEGER) &&
265 + value <= BigInt(Number.MAX_SAFE_INTEGER)
266 + ) {
267 + return $.#fromSegmentWithIntegerWithinNumberRangeValue(segment);
268 + }
269 +
270 + return $.#fromSegmentWithIntegerOutsideNumberRangeValue(segment);
271 + }
272 +
273 + static fromSegmentWithStringValue(segment) {
274 + const valueStr = segment.value.value;
275 +
276 + // TODO: this won't work correctly for utf16 and utf32
277 + // Fast path: if no size specified, use the entire string
278 + if (segment.size === null) {
279 + return {
280 + type: "bitstring",
281 + text: valueStr,
282 + bytes: null,
283 + leftoverBitCount: 0,
284 + hex: null,
285 + };
286 + }
287 +
288 + // Calculate the bit count from size and unit (do this before encoding for potential early returns)
289 + const bitCount = $.calculateSegmentBitCount(segment);
290 + const completeBytes = Math.floor(bitCount / 8);
291 + const leftoverBits = bitCount % 8;
292 +
293 + const byteLength = $.calculateTextByteCount(valueStr);
294 +
295 + // If we know we need the complete string and no leftover bits, avoid encoding
296 + if (completeBytes === byteLength && leftoverBits === 0) {
297 + return {
298 + type: "bitstring",
299 + text: valueStr,
300 + bytes: null,
301 + leftoverBitCount: 0,
302 + hex: null,
303 + };
304 + }
305 +
306 + const sourceBytes = $.#encoder.encode(valueStr);
307 +
308 + // Fast path: if we need all complete bytes but no leftover bits
309 + if (leftoverBits === 0) {
310 + // We can use a subarray view of the original bytes to avoid copying
311 + return {
312 + type: "bitstring",
313 + text: null,
314 + bytes: sourceBytes.subarray(0, completeBytes),
315 + leftoverBitCount: 0,
316 + hex: null,
317 + };
318 + }
319 +
320 + const totalBytes = completeBytes + 1;
321 + const bytes = new Uint8Array(totalBytes);
322 +
323 + // Micro-optimization: If we only need a few bytes, avoid the set() operation overhead
324 + if (completeBytes <= 4) {
325 + // Manual copy for small arrays - can be faster than set() due to function call overhead
326 + for (let i = 0; i < completeBytes; i++) {
327 + bytes[i] = sourceBytes[i];
328 + }
329 + } else {
330 + // Use set() for larger arrays - more efficient for bulk operations
331 + bytes.set(sourceBytes.subarray(0, completeBytes));
332 + }
333 +
334 + // We take the leftmost (most significant bits) as the leftover bits
335 + const mask = 0xff << (8 - leftoverBits);
336 + bytes[completeBytes] = sourceBytes[completeBytes] & mask;
337 +
338 + return {
339 + type: "bitstring",
340 + text: null,
341 + bytes,
342 + leftoverBitCount: leftoverBits,
343 + hex: null,
344 + };
345 + }
346 +
347 + static fromText(text) {
348 + return {
349 + type: "bitstring",
350 + text,
351 + bytes: null,
352 + leftoverBitCount: 0,
353 + hex: null,
354 + };
355 + }
356 +
357 + static isEmpty(bitstring) {
358 + return bitstring.text === "" || bitstring.bytes?.length === 0;
167 359 }
168 360
169 361 // See: String.printable?/2
  @@ -183,7 +375,17 @@ export default class Bitstring {
183 375 // ?\e = 27
184 376 // ?\d = 127
185 377 // ?\a = 7
186 - if ([10, 13, 9, 11, 8, 12, 27, 127, 7].includes(codePoint)) {
378 + if (
379 + codePoint === 10 ||
380 + codePoint === 13 ||
381 + codePoint === 9 ||
382 + codePoint === 11 ||
383 + codePoint === 8 ||
384 + codePoint === 12 ||
385 + codePoint === 27 ||
386 + codePoint === 127 ||
387 + codePoint === 7
388 + ) {
187 389 return true;
188 390 }
189 391
  @@ -206,31 +408,20 @@ export default class Bitstring {
206 408 }
207 409
208 410 static isPrintableText(bitstring) {
209 - if (!Type.isBinary(bitstring)) {
411 + if (bitstring.leftoverBitCount !== 0) {
210 412 return false;
211 413 }
212 414
213 - let offset = 0;
415 + $.maybeSetTextFromBytes(bitstring);
214 416
215 - while (offset < bitstring.bits.length) {
216 - const codePointInfo = Bitstring.fetchNextCodePointFromUtf8BitstringChunk(
217 - bitstring.bits,
218 - offset,
219 - );
417 + if (bitstring.text === false) {
418 + return false;
419 + }
220 420
221 - if (!codePointInfo) {
421 + for (const char of bitstring.text) {
422 + if (!$.isPrintableCodePoint(char.codePointAt(0))) {
222 423 return false;
223 424 }
224 -
225 - if (!Bitstring.validateCodePoint(codePointInfo[0].value)) {
226 - return false;
227 - }
228 -
229 - if (!Bitstring.isPrintableCodePoint(codePointInfo[0].value)) {
230 - return false;
231 - }
232 -
233 - offset += codePointInfo[1];
234 425 }
235 426
236 427 return true;
  @@ -241,94 +432,252 @@ export default class Bitstring {
241 432 return false;
242 433 }
243 434
244 - let offset = 0;
245 -
246 - while (offset < bitstring.bits.length) {
247 - const codePointInfo = Bitstring.fetchNextCodePointFromUtf8BitstringChunk(
248 - bitstring.bits,
249 - offset,
250 - );
251 -
252 - if (!codePointInfo) {
253 - return false;
254 - }
255 -
256 - if (!Bitstring.validateCodePoint(codePointInfo[0].value)) {
257 - return false;
258 - }
259 -
260 - offset += codePointInfo[1];
261 - }
262 -
263 - return true;
435 + $.maybeSetTextFromBytes(bitstring);
436 + return bitstring.text !== false;
264 437 }
265 438
266 - // Using set() is much more performant than using spread operator,
267 - // see: https://jsben.ch/jze3P
268 - static merge(bitstrings) {
269 - const length = bitstrings.reduce(
270 - (acc, bitstring) => acc + bitstring.bits.length,
271 - 0,
272 - );
439 + static maybeResolveHex(bitstring) {
440 + if (bitstring.hex === null) {
441 + $.maybeSetBytesFromText(bitstring);
273 442
274 - const bits = new Uint8Array(length);
275 - let offset = 0;
443 + let hex = "";
444 + const bytes = bitstring.bytes;
276 445
277 - for (const bitstring of bitstrings) {
278 - bits.set(bitstring.bits, offset);
279 - offset += bitstring.bits.length;
446 + for (let i = 0; i < bytes.length; i++) {
447 + hex += bytes[i].toString(16).padStart(2, "0");
448 + }
449 +
450 + bitstring.hex = hex;
280 451 }
281 -
282 - return Type.bitstring(bits);
283 452 }
284 453
454 + static maybeSetBytesFromText(bitstring) {
455 + if (bitstring.bytes === null) {
456 + bitstring.bytes = $.#encoder.encode(bitstring.text);
457 + }
458 + }
459 +
460 + static maybeSetTextFromBytes(bitstring) {
461 + if (bitstring.text === null) {
462 + try {
463 + bitstring.text = $.#decoder.decode(bitstring.bytes);
464 + } catch {
465 + bitstring.text = false;
466 + }
467 + }
468 + }
469 +
470 + // TODO: support utf8, utf16 and utf32 type modifiers
285 471 static resolveSegmentSize(segment) {
286 - if (["float", "integer"].includes(segment.type) && segment.size !== null) {
287 - return segment.size.value;
288 - }
289 -
290 - switch (segment.type) {
291 - case "float":
292 - return 64n;
293 -
294 - case "integer":
295 - return 8n;
296 -
297 - default:
298 - throw new HologramInterpreterError(
299 - `resolving ${segment.type} segment size is not yet implemented in Hologram`,
300 - );
301 - }
302 - }
303 -
304 - static resolveSegmentUnit(segment) {
305 - if (
306 - ["binary", "float", "integer"].includes(segment.type) &&
307 - segment.unit !== null
308 - ) {
309 - return segment.unit;
472 + if (segment.size !== null) {
473 + return Number(segment.size.value);
310 474 }
311 475
312 476 switch (segment.type) {
313 477 case "binary":
314 - return 8n;
478 + if (segment.value.type === "string") {
479 + return $.calculateTextByteCount(segment.value.value);
480 + }
481 +
482 + // bitstring
483 + if (segment.value.text !== null) {
484 + return $.calculateTextByteCount(segment.value.text);
485 + }
486 +
487 + // bitstring
488 + return segment.value.bytes.length;
315 489
316 490 case "float":
491 + return 64;
492 +
317 493 case "integer":
318 - return 1n;
494 + return 8;
319 495
320 496 default:
321 - throw new HologramInterpreterError(
322 - `resolving ${segment.type} segment unit is not yet implemented in Hologram`,
323 - );
497 + return null;
324 498 }
325 499 }
326 500
327 - static toText(bitstring) {
328 - const byteArray = Bitstring.#convertBitArrayToByteArray(bitstring.bits);
329 - const decoder = new TextDecoder("utf-8");
501 + static resolveSegmentUnit(segment) {
502 + if (segment.unit !== null && segment.size !== null) {
503 + return Number(segment.unit);
504 + }
330 505
331 - return decoder.decode(byteArray);
506 + switch (segment.type) {
507 + case "binary":
508 + return 8;
509 +
510 + case "float":
511 + case "integer":
512 + return 1;
513 +
514 + default:
515 + return null;
516 + }
517 + }
518 +
519 + static serialize(bitstring) {
520 + if ($.isEmpty(bitstring)) {
521 + return "b";
522 + }
523 +
524 + $.maybeResolveHex(bitstring);
525 +
526 + return `b${bitstring.leftoverBitCount}${bitstring.hex}`;
527 + }
528 +
529 + static takeChunk(bitstring, chunkOffset, chunkSize) {
530 + const bitstringBitCount = $.calculateBitCount(bitstring);
531 +
532 + // Early return for taking the entire bitstring
533 + if (chunkOffset === 0 && chunkSize === bitstringBitCount) {
534 + return bitstring;
535 + }
536 +
537 + $.maybeSetBytesFromText(bitstring);
538 +
539 + const startByteIndex = Math.floor(chunkOffset / 8);
540 + const startBitOffset = chunkOffset % 8;
541 + const resultByteCount = Math.ceil(chunkSize / 8);
542 + const resultLeftoverBits = chunkSize % 8;
543 +
544 + // Fast path: if byte-aligned and no leftover bits
545 + if (startBitOffset === 0 && resultLeftoverBits === 0) {
546 + return {
547 + type: "bitstring",
548 + text: null,
549 + bytes: bitstring.bytes.subarray(
550 + startByteIndex,
551 + startByteIndex + resultByteCount,
552 + ),
553 + leftoverBitCount: 0,
554 + hex: null,
555 + };
556 + }
557 +
558 + const resultBytes = new Uint8Array(resultByteCount);
559 +
560 + if (startBitOffset === 0) {
561 + // Byte-aligned with leftover bits
562 +
563 + resultBytes.set(
564 + bitstring.bytes.subarray(
565 + startByteIndex,
566 + startByteIndex + resultByteCount,
567 + ),
568 + );
569 +
570 + resultBytes[resultByteCount - 1] &= 0xff << (8 - resultLeftoverBits);
571 + } else {
572 + // Non-byte-aligned
573 +
574 + const rightShift = 8 - startBitOffset;
575 + const leftShift = startBitOffset;
576 + const bytes = bitstring.bytes; // Local reference to array performance optimization
577 +
578 + for (let i = 0; i < resultByteCount; i++) {
579 + const firstByte = bytes[startByteIndex + i];
580 + const secondByte = bytes[startByteIndex + i + 1];
581 +
582 + resultBytes[i] =
583 + ((firstByte << leftShift) | (secondByte >>> rightShift)) & 0xff;
584 + }
585 +
586 + // Mask out extra bits in the last byte if we have leftover bits
587 + if (resultLeftoverBits > 0) {
588 + resultBytes[resultByteCount - 1] &= 0xff << (8 - resultLeftoverBits);
589 + }
590 + }
591 +
592 + return {
593 + type: "bitstring",
594 + text: null,
595 + bytes: resultBytes,
596 + leftoverBitCount: resultLeftoverBits,
597 + hex: null,
598 + };
599 + }
600 +
601 + static toCodepoints(bitstring) {
602 + $.maybeSetTextFromBytes(bitstring);
603 +
604 + return Type.list(
605 + Array.from(bitstring.text, (char) => Type.integer(char.codePointAt(0))),
606 + );
607 + }
608 +
609 + static toFloat(bitstring, endianness) {
610 + $.maybeSetBytesFromText(bitstring);
611 +
612 + const bytes = bitstring.bytes;
613 + const byteCount = bytes.length;
614 + const isLittleEndian = endianness === "little";
615 +
616 + let result;
617 +
618 + if (byteCount === 8) {
619 + // 64-bit float
620 + const buffer = new ArrayBuffer(8);
621 + const view = new Uint8Array(buffer);
622 + view.set(bytes);
623 + result = new DataView(buffer).getFloat64(0, isLittleEndian);
624 + } else if (byteCount === 4) {
625 + // 32-bit float
626 + const buffer = new ArrayBuffer(4);
627 + const view = new Uint8Array(buffer);
628 + view.set(bytes);
629 + result = new DataView(buffer).getFloat32(0, isLittleEndian);
630 + } else {
631 + // 16-bit float - needs manual conversion as JavaScript doesn't natively support Float16
632 + result = $.#decodeFloat16(bytes, isLittleEndian);
633 + }
634 +
635 + return Type.float(result);
636 + }
637 +
638 + static toInteger(bitstring, signedness, endianness) {
639 + $.maybeSetBytesFromText(bitstring);
640 +
641 + const bytes = bitstring.bytes;
642 + const byteCount = bytes.length;
643 +
644 + if (byteCount === 0) {
645 + return Type.integer(0n);
646 + }
647 +
648 + const leftoverBitCount = bitstring.leftoverBitCount;
649 + const isSigned = signedness === "signed";
650 +
651 + // Fast path for single byte with no leftover bits
652 + if (byteCount === 1 && leftoverBitCount === 0) {
653 + const value = bytes[0];
654 +
655 + return Type.integer(
656 + isSigned && value & 0x80 ? BigInt(value - 256) : BigInt(value),
657 + );
658 + }
659 +
660 + const isLittleEndian = endianness === "little";
661 +
662 + if (leftoverBitCount === 0) {
663 + return $.#toIntegerFromBitstringWithoutLeftoverBits(
664 + bitstring,
665 + isSigned,
666 + isLittleEndian,
667 + );
668 + }
669 +
670 + return $.#toIntegerFromBitstringWithLeftoverBits(
671 + bitstring,
672 + isSigned,
673 + isLittleEndian,
674 + );
675 + }
676 +
677 + static toText(bitstring) {
678 + $.maybeSetTextFromBytes(bitstring);
679 +
680 + return bitstring.text;
332 681 }
333 682
334 683 static validateCodePoint(codePoint) {
  @@ -351,254 +700,417 @@ export default class Bitstring {
351 700 static validateSegment(segment, index) {
352 701 switch (segment.type) {
353 702 case "binary":
354 - return Bitstring.#validateBinarySegment(segment, index);
703 + return $.#validateSegmentWithBinaryType(segment, index);
355 704
356 705 case "bitstring":
357 - return Bitstring.#validateBitstringSegment(segment, index);
706 + return $.#validateSegmentWithBitstringType(segment, index);
358 707
359 708 case "float":
360 - return Bitstring.#validateFloatSegment(segment, index);
709 + return $.#validateSegmentWithFloatType(segment, index);
361 710
362 711 case "integer":
363 - return Bitstring.#validateIntegerSegment(segment, index);
712 + return $.#validateSegmentWithIntegerType(segment, index);
364 713
365 714 case "utf8":
366 715 case "utf16":
367 716 case "utf32":
368 - return Bitstring.#validateUtfSegment(segment, index);
717 + return $.#validateSegmentWithUtfType(segment, index);
369 718 }
370 719 }
371 720
372 - static #buildBitArray(segment, index) {
373 - switch (segment.value.type) {
374 - case "bitstring":
375 - return Bitstring.#buildBitArrayFromBitstring(segment);
721 + static #appendBitstringAtByteBoundary(resultBytes, byteOffset, bitstring) {
722 + const bytes = bitstring.bytes;
723 + const leftoverBitCount = bitstring.leftoverBitCount;
376 724
377 - case "float":
378 - return Bitstring.#buildBitArrayFromFloat(segment);
725 + if (leftoverBitCount === 0) {
726 + // If no leftover bits in this bitstring, copy directly
727 + resultBytes.set(bytes, byteOffset);
728 + } else {
729 + const totalByteCount = bytes.length;
730 + const completeByteCount = totalByteCount - 1;
379 731
380 - case "integer":
381 - return Bitstring.#buildBitArrayFromInteger(segment, index);
732 + if (completeByteCount > 0) {
733 + resultBytes.set(bytes.subarray(0, completeByteCount), byteOffset);
734 + }
382 735
383 - case "string":
384 - return Bitstring.#buildBitArrayFromString(segment);
736 + // Apply a mask to only include the leftover bits in the last byte
737 + const lastByte = bytes[totalByteCount - 1];
738 + const lastByteOffset = byteOffset + completeByteCount;
739 + const leftoverBitsMask = 0xff << (8 - leftoverBitCount);
740 + const maskedLastByte = lastByte & leftoverBitsMask;
741 +
742 + // Place leftover bits in the correct position
743 + resultBytes[lastByteOffset] = maskedLastByte;
385 744 }
386 745 }
387 746
388 - static #buildBitArrayFromBitstring(segment) {
389 - return new Uint8Array(segment.value.bits);
390 - }
747 + static #appendBitstringNotAtByteBoundary(
748 + resultBytes,
749 + byteOffset,
750 + bitOffset,
751 + bitstring,
752 + ) {
753 + const bytes = bitstring.bytes;
754 + const leftoverBitCount = bitstring.leftoverBitCount;
755 + const bitPositionInByte = bitOffset & 7; // Modulo 8 (position within byte)
756 + const bitsToShiftRight = bitPositionInByte; // How many bits to shift right when adding to current byte
757 + const bitsToShiftLeft = 8 - bitsToShiftRight; // How many bits to shift left when adding to next byte
391 758
392 - static #buildBitArrayFromFloat(segment) {
393 - const value = segment.value.value;
394 - const size = Bitstring.resolveSegmentSize(segment);
759 + // Calculate how many complete bytes we have in the source bitstring
760 + const completeByteCount =
761 + leftoverBitCount === 0 ? bytes.length : bytes.length - 1;
395 762
396 - const bitArrays = Array.from(Bitstring.#getBytesFromFloat(value, size)).map(
397 - (byte) => Bitstring.#convertDataToBitArray(BigInt(byte), 8n, 1n),
398 - );
763 + // Process all complete bytes in the source bitstring
764 + for (let i = 0; i < completeByteCount; i++) {
765 + const currentByte = bytes[i];
399 766
400 - return Utils.concatUint8Arrays(bitArrays);
401 - }
767 + // Add high bits to current byte (may already have bits)
768 + resultBytes[byteOffset + i] |= currentByte >>> bitsToShiftRight;
402 769
403 - static #buildBitArrayFromInteger(segment, index) {
404 - if (segment.type === "float") {
405 - const segmentWithValueCastedToFloat = {
406 - ...segment,
407 - value: Type.float(Number(segment.value.value)),
408 - };
409 - return Bitstring.#buildBitArrayFromFloat(segmentWithValueCastedToFloat);
410 - }
411 -
412 - // Max Unicode code point value is 1,114,112
413 - if (["utf8", "utf16", "utf32"].includes(segment.type)) {
414 - try {
415 - const str = String.fromCodePoint(Number(segment.value.value));
416 -
417 - const segmentWithValueCastedToString = {
418 - ...segment,
419 - value: Type.string(str),
420 - };
421 -
422 - return Bitstring.#buildBitArrayFromString(
423 - segmentWithValueCastedToString,
424 - );
425 - } catch {
426 - Bitstring.#raiseInvalidUnicodeCodePointError(segment, index);
770 + // Add low bits to next byte (if we're not byte-aligned)
771 + if (bitsToShiftRight > 0) {
772 + resultBytes[byteOffset + i + 1] =
773 + (currentByte << bitsToShiftLeft) & 0xff;
427 774 }
428 775 }
429 776
430 - const value = segment.value.value;
431 - const size = Bitstring.resolveSegmentSize(segment);
432 - const unit = Bitstring.resolveSegmentUnit(segment);
777 + // Handle last byte with leftover bits if any
778 + if (leftoverBitCount > 0) {
779 + const lastByte = bytes[completeByteCount];
433 780
434 - return Bitstring.#convertDataToBitArray(value, size, unit);
781 + // Create a mask to extract only the valid leftover bits from the most significant bits
782 + const leftoverBitsMask = 0xff << (8 - leftoverBitCount);
783 + const maskedLastByte = lastByte & leftoverBitsMask;
784 +
785 + // Add high bits of the last byte to current position
786 + resultBytes[byteOffset + completeByteCount] |=
787 + maskedLastByte >>> bitsToShiftRight;
788 +
789 + // Add low bits of the last byte to next position if we're not byte-aligned
790 + if (bitsToShiftRight > 0) {
791 + resultBytes[byteOffset + completeByteCount + 1] |=
792 + (maskedLastByte << bitsToShiftLeft) & 0xff;
793 + }
794 + }
435 795 }
436 796
437 - static #buildBitArrayFromString(segment) {
438 - const value = segment.value.value;
797 + static #concatBitstringsWithoutLeftoverBits(bitstrings) {
798 + const totalByteCount = bitstrings.reduce((acc, bs) => {
799 + return acc + bs.bytes.length;
800 + }, 0);
439 801
440 - const bitArrays = Array.from(
441 - Bitstring.#getBytesFromString(value, segment.type),
442 - ).map((byte) => Bitstring.#convertDataToBitArray(BigInt(byte), 8n, 1n));
802 + const resultBytes = new Uint8Array(totalByteCount);
803 + let offset = 0;
443 804
444 - if (segment.size !== null) {
445 - const unit = Bitstring.resolveSegmentUnit({...segment, type: "binary"});
446 - const numBits = segment.size.value * unit;
805 + for (let i = 0; i < bitstrings.length; i++) {
806 + const bs = bitstrings[i];
807 + resultBytes.set(bs.bytes, offset);
808 + offset += bs.bytes.length;
809 + }
447 810
448 - return Utils.concatUint8Arrays(bitArrays).subarray(0, Number(numBits));
811 + return {
812 + type: "bitstring",
813 + text: null,
814 + bytes: resultBytes,
815 + leftoverBitCount: 0,
816 + hex: null,
817 + };
818 + }
819 +
820 + static #decodeFloat16(bytes, isLittleEndian) {
821 + const byte1 = isLittleEndian ? bytes[1] : bytes[0];
822 + const byte2 = isLittleEndian ? bytes[0] : bytes[1];
823 +
824 + const sign = byte1 & 0x80 ? -1 : 1;
825 + const exponent = (byte1 & 0x7c) >> 2;
826 + const fraction = ((byte1 & 0x03) << 8) | byte2;
827 +
828 + // Handle special cases
829 + if (exponent === 0) {
830 + if (fraction === 0) {
831 + return sign * 0; // Signed zero
832 + }
833 +
834 + // Denormalized number
835 + return sign * Math.pow(2, -14) * (fraction / 1024);
836 + }
837 +
838 + // Normalized number
839 + return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024);
840 + }
841 +
842 + static #fromSegmentWithIntegerWithinNumberRangeValue(segment) {
843 + const numberValue = Number(segment.value.value);
844 + const isLittleEndian = $.#isLittleEndian(segment);
845 +
846 + const bitCount = $.calculateSegmentBitCount(segment);
847 + const completeBytes = Math.floor(bitCount / 8);
848 + const leftoverBits = bitCount % 8;
849 + const totalBytes = completeBytes + (leftoverBits > 0 ? 1 : 0);
850 +
851 + const buffer = new ArrayBuffer(totalBytes);
852 + const bytesArray = new Uint8Array(buffer);
853 + const dataView = new DataView(buffer);
854 +
855 + // Fast path for standard bit counts
856 + if (bitCount === 8) {
857 + dataView.setUint8(0, numberValue & 0xff);
858 +
859 + return {
860 + type: "bitstring",
861 + text: null,
862 + bytes: bytesArray,
863 + leftoverBitCount: leftoverBits,
864 + hex: null,
865 + };
866 + } else if (bitCount === 16 && completeBytes === 2) {
867 + dataView.setUint16(0, numberValue & 0xffff, isLittleEndian);
868 +
869 + return {
870 + type: "bitstring",
871 + text: null,
872 + bytes: bytesArray,
873 + leftoverBitCount: leftoverBits,
874 + hex: null,
875 + };
876 + } else if (bitCount === 32 && completeBytes === 4) {
877 + dataView.setUint32(0, numberValue & 0xffffffff, isLittleEndian);
878 +
879 + return {
880 + type: "bitstring",
881 + text: null,
882 + bytes: bytesArray,
883 + leftoverBitCount: leftoverBits,
884 + hex: null,
885 + };
886 + }
887 +
888 + // Hybrid approach: bitwise operations for small integers, division for larger ones
889 + const usesBitwiseOps = Math.abs(numberValue) < 0x100000000; // 2^32
890 +
891 + if (isLittleEndian) {
892 + // Little endian: LSB first
893 +
894 + let remainingValue = numberValue;
895 +
896 + for (let i = 0; i < completeBytes; i++) {
897 + if (usesBitwiseOps) {
898 + bytesArray[i] = remainingValue & 0xff;
899 + remainingValue = remainingValue >>> 8;
900 + } else {
901 + bytesArray[i] = remainingValue % 256;
902 + remainingValue = Math.floor(remainingValue / 256);
903 + }
904 + }
905 +
906 + // Handle leftover bits
907 + if (leftoverBits > 0) {
908 + const shiftAmount = 8 - leftoverBits;
909 +
910 + bytesArray[completeBytes] =
911 + (remainingValue & ((1 << leftoverBits) - 1)) << shiftAmount;
912 + }
449 913 } else {
450 - return Utils.concatUint8Arrays(bitArrays);
451 - }
452 - }
914 + // Big endian: MSB first
453 915
454 - static #buildFloatFromBitstringChunk(segment, bitArray, offset) {
455 - let size = Bitstring.resolveSegmentSize(segment);
916 + if (usesBitwiseOps) {
917 + // Fast bit shifting approach for small integers
456 918
457 - if (![16n, 32n, 64n].includes(size)) {
458 - size = 64n;
459 - }
919 + const totalBitsInInteger = 32;
460 920
461 - if (size === 16n) {
462 - throw new HologramInterpreterError(
463 - "16-bit float bitstring segments are not yet implemented in Hologram",
464 - );
465 - }
921 + // Calculate shift to align with MSB
466 922
467 - const unit = Bitstring.resolveSegmentUnit(segment);
468 - const segmentLen = Number(size * unit);
923 + const initialShift = totalBitsInInteger - bitCount;
469 924
470 - if (offset + segmentLen > bitArray.length) {
471 - return false;
472 - }
925 + let shiftedValue =
926 + initialShift > 0 ? numberValue << initialShift : numberValue;
473 927
474 - const chunk = bitArray.slice(offset, offset + segmentLen);
475 - const bytesArray = Bitstring.#convertBitArrayToByteArray(chunk);
476 - const dataView = new DataView(bytesArray.buffer);
928 + // For complete bytes
929 + for (let i = 0; i < completeBytes; i++) {
930 + bytesArray[i] = (shiftedValue >>> (totalBitsInInteger - 8)) & 0xff;
931 + shiftedValue = shiftedValue << 8;
932 + }
477 933
478 - const value =
479 - size === 64n
480 - ? dataView.getFloat64(0, false)
481 - : dataView.getFloat32(0, false);
934 + // Handle leftover bits
935 + if (leftoverBits > 0) {
936 + bytesArray[completeBytes] =
937 + (shiftedValue >>> (totalBitsInInteger - 8)) & 0xff;
938 + }
939 + } else {
940 + // Division approach for larger integers
482 941
483 - return [Type.float(value), segmentLen];
484 - }
942 + let remainingValue = numberValue;
485 943
486 - static #buildIntegerFromBitstringChunk(segment, bitArray, offset) {
487 - const size = Bitstring.resolveSegmentSize(segment);
488 - const unit = Bitstring.resolveSegmentUnit(segment);
489 - const segmentLen = Number(size * unit);
944 + // For big-endian, we need to start with the most significant bits
945 + // Calculate bytes from most significant to least significant
946 + for (let i = 0; i < completeBytes; i++) {
947 + // Calculate how many bits we still need to shift
948 + const bitsToShift = (completeBytes - i - 1) * 8 + leftoverBits;
490 949
491 - if (offset + segmentLen > bitArray.length) {
492 - return false;
493 - }
950 + // Create a divisor based on that shift
951 + const divisor = Math.pow(2, bitsToShift);
494 952
495 - const bitArrayChunk = bitArray.slice(offset, offset + segmentLen);
496 - let value;
953 + // Extract the current byte
954 + const byteValue = Math.floor(remainingValue / divisor);
955 + bytesArray[i] = byteValue & 0xff;
497 956
498 - if (Bitstring.#resolveSegmentSignedness(segment) === "signed") {
499 - value = Bitstring.buildSignedBigIntFromBitArray(bitArrayChunk);
500 - } else {
501 - value = Bitstring.buildUnsignedBigIntFromBitArray(bitArrayChunk);
502 - }
957 + // Remove the processed bits
958 + remainingValue = remainingValue % divisor;
959 + }
503 960
504 - return [Type.integer(value), segmentLen];
505 - }
506 -
507 - static #convertBitArrayToByteArray(bitArray) {
508 - if (bitArray.length % 8 !== 0) {
509 - throw new HologramInterpreterError(
510 - `number of bits must be divisible by 8, got ${bitArray.length} bits`,
511 - );
512 - }
513 -
514 - const numBytes = bitArray.length / 8;
515 - const byteArray = new Uint8Array(numBytes);
516 -
517 - for (let i = 0; i < numBytes; ++i) {
518 - for (let j = 0; j < 8; ++j) {
519 - if (bitArray[i * 8 + j] === 1) {
520 - byteArray[i] = Bitstring.#putNumberBit(byteArray[i], 7 - j);
961 + // Handle leftover bits
962 + if (leftoverBits > 0) {
963 + // For leftover bits, we shift them to the most significant bits of the last byte
964 + bytesArray[completeBytes] =
965 + (remainingValue << (8 - leftoverBits)) & 0xff;
521 966 }
522 967 }
523 968 }
524 969
525 - return byteArray;
970 + return {
971 + type: "bitstring",
972 + text: null,
973 + bytes: bytesArray,
974 + leftoverBitCount: leftoverBits,
975 + hex: null,
976 + };
526 977 }
527 978
528 - static #convertDataToBitArray(data, size, unit) {
529 - // clamp to size number of bits
530 - const numBits = size * unit;
531 - const bitmask = 2n ** numBits - 1n;
532 - const clampedData = data & bitmask;
979 + static #fromSegmentWithIntegerOutsideNumberRangeValue(segment) {
980 + const value = segment.value.value;
981 + const isLittleEndian = $.#isLittleEndian(segment);
533 982
534 - const bitArr = [];
983 + const bitCount = $.calculateSegmentBitCount(segment);
984 + const completeBytes = Math.floor(bitCount / 8);
985 + const leftoverBits = bitCount % 8;
986 + const totalBytes = completeBytes + (leftoverBits > 0 ? 1 : 0);
535 987
536 - for (let i = numBits; i >= 1n; --i) {
537 - bitArr[numBits - i] = Bitstring.#getBit(clampedData, i - 1n);
988 + const buffer = new ArrayBuffer(totalBytes);
989 + const bytesArray = new Uint8Array(buffer);
990 +
991 + // Special fast path for 64-bit BigInts (common case)
992 + if (bitCount === 64 && completeBytes === 8 && leftoverBits === 0) {
993 + const byteMask = 0xffn;
994 +
995 + if (isLittleEndian) {
996 + // Little endian: LSB first
997 + for (let i = 0; i < 8; i++) {
998 + bytesArray[i] = Number((value >> BigInt(i * 8)) & byteMask);
999 + }
1000 + } else {
1001 + // Big endian: MSB first
1002 + for (let i = 0; i < 8; i++) {
1003 + bytesArray[i] = Number((value >> BigInt(56 - i * 8)) & byteMask);
1004 + }
1005 + }
1006 +
1007 + return {
1008 + type: "bitstring",
1009 + text: null,
1010 + bytes: bytesArray,
1011 + leftoverBitCount: leftoverBits,
1012 + hex: null,
1013 + };
538 1014 }
539 1015
540 - return new Uint8Array(bitArr);
541 - }
1016 + // Fast path for byte-aligned BigInts (no leftover bits)
1017 + if (leftoverBits === 0) {
1018 + const byteMask = 0xffn;
542 1019
543 - static #encodeUtf16(str, endianness) {
544 - const byteArray = new Uint8Array(str.length * 2);
545 - const view = new DataView(byteArray.buffer);
1020 + if (isLittleEndian) {
1021 + // Little endian: LSB first
546 1022
547 - str
548 - .split("")
549 - .forEach((char, index) =>
550 - view.setUint16(index * 2, char.charCodeAt(0), endianness === "little"),
551 - );
1023 + let remainingValue = value;
552 1024
553 - return byteArray;
554 - }
1025 + for (let i = 0; i < completeBytes; i++) {
1026 + bytesArray[i] = Number(remainingValue & byteMask);
1027 + remainingValue = remainingValue >> 8n;
1028 + }
1029 + } else {
1030 + // Big endian: MSB first
555 1031
556 - static #getBit(value, position) {
557 - return (value & (1n << position)) === 0n ? 0 : 1;
558 - }
1032 + const totalBits = BigInt(completeBytes * 8);
559 1033
560 - static #getBytesFromFloat(float, size) {
561 - let floatArr;
1034 + for (let i = 0; i < completeBytes; i++) {
1035 + const shift = totalBits - BigInt((i + 1) * 8);
1036 + bytesArray[i] = Number((value >> shift) & byteMask);
1037 + }
1038 + }
562 1039
563 - switch (size) {
564 - case 64n:
565 - floatArr = new Float64Array([float]);
566 - break;
567 -
568 - case 32n:
569 - floatArr = new Float32Array([float]);
570 - break;
571 -
572 - case 16n:
573 - // This case is not possible at the moment, since an error would be raised earlier.
1040 + return {
1041 + type: "bitstring",
1042 + text: null,
1043 + bytes: bytesArray,
1044 + leftoverBitCount: leftoverBits,
1045 + hex: null,
1046 + };
574 1047 }
575 1048
576 - return new Uint8Array(floatArr.buffer).reverse();
577 - }
1049 + // Handle cases with leftover bits (not byte-aligned)
1050 + const byteMask = 0xffn;
578 1051
579 - static #getBytesFromString(str, encoding) {
580 - switch (encoding) {
581 - case "binary":
582 - case "bitstring":
583 - case "utf8":
584 - return new TextEncoder().encode(str);
1052 + if (isLittleEndian) {
1053 + // Little endian: LSB first
585 1054
586 - case "utf16":
587 - return Bitstring.#encodeUtf16(str, "big");
1055 + let remainingValue = value;
1056 +
1057 + // Process complete bytes
1058 + for (let i = 0; i < completeBytes; i++) {
1059 + bytesArray[i] = Number(remainingValue & byteMask);
1060 + remainingValue = remainingValue >> 8n;
1061 + }
1062 +
1063 + // Handle leftover bits - shift to the most significant bits of the byte
1064 + if (leftoverBits > 0) {
1065 + const leftoverMask = (1n << BigInt(leftoverBits)) - 1n;
1066 + const shiftAmount = BigInt(8 - leftoverBits);
1067 +
1068 + bytesArray[completeBytes] = Number(
1069 + (remainingValue & leftoverMask) << shiftAmount,
1070 + );
1071 + }
1072 + } else {
1073 + // Big endian: MSB first
1074 +
1075 + // Calculate total bits needed
1076 + const totalBits = BigInt(completeBytes * 8 + leftoverBits);
1077 +
1078 + let remainingValue = value;
1079 +
1080 + // Process complete bytes
1081 + for (let i = 0; i < completeBytes; i++) {
1082 + // Calculate how many bits we still need to shift right to get the current byte
1083 + const shift = totalBits - BigInt((i + 1) * 8);
1084 +
1085 + bytesArray[i] = Number((remainingValue >> shift) & byteMask);
1086 + }
1087 +
1088 + // Handle leftover bits
1089 + if (leftoverBits > 0) {
1090 + // For leftover bits, we need to:
1091 + // 1. Get the remaining value (last bits)
1092 + // 2. Shift it left to align with MSB of the last byte
1093 +
1094 + const remainingBits =
1095 + remainingValue & ((1n << BigInt(leftoverBits)) - 1n);
1096 +
1097 + bytesArray[completeBytes] = Number(
1098 + remainingBits << BigInt(8 - leftoverBits),
1099 + );
1100 + }
588 1101 }
1102 +
1103 + return {
1104 + type: "bitstring",
1105 + text: null,
1106 + bytes: bytesArray,
1107 + leftoverBitCount: leftoverBits,
1108 + hex: null,
1109 + };
589 1110 }
590 1111
591 - static #putNumberBit(value, position) {
592 - return value | (1 << position);
593 - }
594 -
595 - static #raiseInvalidUnicodeCodePointError(segment, index) {
596 - Bitstring.#raiseTypeMismatchError(
597 - index,
598 - segment.type,
599 - "a non-negative integer encodable as " + segment.type,
600 - segment.value,
601 - );
1112 + static #isLittleEndian(segment) {
1113 + return segment.endianness === "little";
602 1114 }
603 1115
604 1116 static #raiseTypeMismatchError(
  @@ -613,19 +1125,183 @@ export default class Bitstring {
613 1125 Interpreter.raiseArgumentError(message);
614 1126 }
615 1127
616 - static #resolveSegmentSignedness(segment) {
617 - if (segment.signedness !== null) {
618 - return segment.signedness;
1128 + static #setFloat16(dataView, value, isLittleEndian) {
1129 + // Handle zeros
1130 + if (value === 0) {
1131 + const highByte = Object.is(value, -0) ? 0x80 : 0;
1132 + const lowByte = 0;
1133 +
1134 + if (isLittleEndian) {
1135 + dataView.setUint8(0, lowByte);
1136 + dataView.setUint8(1, highByte);
1137 + } else {
1138 + dataView.setUint8(0, highByte);
1139 + dataView.setUint8(1, lowByte);
1140 + }
1141 +
1142 + return;
619 1143 }
620 1144
621 - return "unsigned";
1145 + // Extract sign and absolute value
1146 + const absValue = Math.abs(value);
1147 + const signByte = value < 0 ? 0x80 : 0;
1148 +
1149 + // Calculate exponent
1150 + const exp = Math.floor(Math.log2(absValue));
1151 + const biasedExp = exp + 15;
1152 +
1153 + // Calculate normalized fraction (remove hidden bit and scale to 10 bits)
1154 + // Use precise multiplication to avoid rounding errors
1155 + const significand = absValue * Math.pow(2, -exp);
1156 + const fraction = Math.round((significand - 1) * 0x400);
1157 +
1158 + // Combine high byte: sign + 5 bits of exponent + top 2 bits of fraction
1159 + const highByte =
1160 + signByte | ((biasedExp & 0x1f) << 2) | ((fraction >> 8) & 0x03);
1161 +
1162 + // Low byte: bottom 8 bits of fraction
1163 + const lowByte = fraction & 0xff;
1164 +
1165 + // Set bytes in proper order
1166 + if (isLittleEndian) {
1167 + dataView.setUint8(0, lowByte);
1168 + dataView.setUint8(1, highByte);
1169 + } else {
1170 + dataView.setUint8(0, highByte);
1171 + dataView.setUint8(1, lowByte);
1172 + }
622 1173 }
623 1174
624 - static #validateBinarySegment(segment, index) {
625 - if (
626 - segment.value.type === "bitstring" &&
627 - segment.value.bits.length % 8 !== 0
628 - ) {
1175 + static #toIntegerFromBitstringWithLeftoverBits(
1176 + bitstring,
1177 + isSigned,
1178 + isLittleEndian,
1179 + ) {
1180 + const bytes = bitstring.bytes;
1181 + const byteCount = bytes.length;
1182 + const leftoverBitCount = bitstring.leftoverBitCount;
1183 +
1184 + let result = 0n;
1185 +
1186 + // Little endian: LSB first
1187 + // Big endian: MSB first
1188 +
1189 + // Process complete bytes first
1190 + if (isLittleEndian) {
1191 + for (let i = 0; i < byteCount - 1; i++) {
1192 + result |= BigInt(bytes[i]) << BigInt(i * 8);
1193 + }
1194 + } else {
1195 + for (let i = 0; i < byteCount - 1; i++) {
1196 + result = (result << 8n) | BigInt(bytes[i]);
1197 + }
1198 + }
1199 +
1200 + // Handle the last byte with leftover bits
1201 + const lastByte = bytes[byteCount - 1];
1202 + const mask = 0xff << (8 - leftoverBitCount);
1203 + const leftoverValue = lastByte & mask;
1204 +
1205 + // Right-shift the leftover bits to align them properly
1206 + const shiftedValue = leftoverValue >>> (8 - leftoverBitCount);
1207 +
1208 + // Place leftover bits in the correct position
1209 + if (isLittleEndian) {
1210 + result |= BigInt(shiftedValue) << BigInt((byteCount - 1) * 8);
1211 + } else {
1212 + result = (result << BigInt(leftoverBitCount)) | BigInt(shiftedValue);
1213 + }
1214 +
1215 + if (isSigned) {
1216 + const bitCount = $.calculateBitCount(bitstring);
1217 + const signBit = 1n << BigInt(bitCount - 1);
1218 +
1219 + if ((result & signBit) !== 0n) {
1220 + result = result - (1n << BigInt(bitCount));
1221 + }
1222 + }
1223 +
1224 + return Type.integer(result);
1225 + }
1226 +
1227 + static #toIntegerFromBitstringWithoutLeftoverBits(
1228 + bitstring,
1229 + isSigned,
1230 + isLittleEndian,
1231 + ) {
1232 + const bytes = bitstring.bytes;
1233 + const byteCount = bytes.length;
1234 +
1235 + // Use DataView for standard sizes (1, 2, 4 bytes)
1236 + let buffer, dataView, result;
1237 +
1238 + switch (byteCount) {
1239 + case 1:
1240 + result = BigInt(bytes[0]);
1241 + break;
1242 +
1243 + case 2:
1244 + buffer = new ArrayBuffer(2);
1245 + dataView = new DataView(buffer);
1246 +
1247 + dataView.setUint8(0, bytes[0]);
1248 + dataView.setUint8(1, bytes[1]);
1249 +
1250 + result = isSigned
1251 + ? BigInt(dataView.getInt16(0, isLittleEndian))
1252 + : BigInt(dataView.getUint16(0, isLittleEndian));
1253 +
1254 + break;
1255 +
1256 + case 4:
1257 + buffer = new ArrayBuffer(4);
1258 + dataView = new DataView(buffer);
1259 +
1260 + dataView.setUint8(0, bytes[0]);
1261 + dataView.setUint8(1, bytes[1]);
1262 + dataView.setUint8(2, bytes[2]);
1263 + dataView.setUint8(3, bytes[3]);
1264 +
1265 + result = isSigned
1266 + ? BigInt(dataView.getInt32(0, isLittleEndian))
1267 + : BigInt(dataView.getUint32(0, isLittleEndian));
1268 +
1269 + break;
1270 +
1271 + default:
1272 + result = 0n;
1273 +
1274 + if (isLittleEndian) {
1275 + for (let i = 0; i < byteCount; i++) {
1276 + result |= BigInt(bytes[i]) << BigInt(i * 8);
1277 + }
1278 + } else {
1279 + for (let i = 0; i < byteCount; i++) {
1280 + result = (result << 8n) | BigInt(bytes[i]);
1281 + }
1282 + }
1283 +
1284 + if (isSigned) {
1285 + const bitCount = $.calculateBitCount(bitstring);
1286 + const signBit = 1n << BigInt(bitCount - 1);
1287 +
1288 + if ((result & signBit) !== 0n) {
1289 + result = result - (1n << BigInt(bitCount));
1290 + }
1291 + }
1292 + }
1293 +
1294 + return Type.integer(result);
1295 + }
1296 +
1297 + static #validateSegmentWithBinaryType(segment, index) {
1298 + const valueType = segment.value.type;
1299 +
1300 + if (valueType !== "bitstring" && valueType !== "string") {
1301 + $.#raiseTypeMismatchError(index, "binary", "a binary", segment.value);
1302 + }
1303 +
1304 + if (valueType === "bitstring" && segment.value.leftoverBitCount !== 0) {
629 1305 const inspectedValue = Interpreter.inspect(segment.value);
630 1306
631 1307 Interpreter.raiseArgumentError(
  @@ -633,45 +1309,32 @@ export default class Bitstring {
633 1309 );
634 1310 }
635 1311
636 - if (["float", "integer"].includes(segment.value.type)) {
637 - Bitstring.#raiseTypeMismatchError(
638 - index,
639 - "binary",
640 - "a binary",
641 - segment.value,
642 - );
1312 + return true;
1313 + }
1314 +
1315 + static #validateSegmentWithBitstringType(segment, index) {
1316 + const valueType = segment.value.type;
1317 +
1318 + if (valueType === "float" || valueType === "integer") {
1319 + $.#raiseTypeMismatchError(index, "binary", "a binary", segment.value);
1320 + }
1321 +
1322 + if (segment.size !== null || segment.signedness !== null) {
1323 + $.#raiseTypeMismatchError(index, "integer", "an integer", segment.value);
643 1324 }
644 1325
645 1326 return true;
646 1327 }
647 1328
648 - static #validateBitstringSegment(segment, index) {
649 - if (["float", "integer"].includes(segment.value.type)) {
650 - Bitstring.#raiseTypeMismatchError(
651 - index,
652 - "binary",
653 - "a binary",
654 - segment.value,
655 - );
656 - }
1329 + static #validateSegmentWithFloatType(segment, index) {
1330 + const valueType = segment.value.type;
657 1331
658 - if (segment.signedness !== null || segment.size !== null) {
659 - Bitstring.#raiseTypeMismatchError(
660 - index,
661 - "integer",
662 - "an integer",
663 - segment.value,
664 - );
665 - }
666 -
667 - return true;
668 - }
669 -
670 - static #validateFloatSegment(segment, index) {
671 1332 if (
672 - !["float", "integer", "variable_pattern"].includes(segment.value.type)
1333 + valueType !== "float" &&
1334 + valueType !== "integer" &&
1335 + valueType !== "variable_pattern"
673 1336 ) {
674 - Bitstring.#raiseTypeMismatchError(
1337 + $.#raiseTypeMismatchError(
675 1338 index,
676 1339 "float",
677 1340 "a float or an integer",
  @@ -679,50 +1342,36 @@ export default class Bitstring {
679 1342 );
680 1343 }
681 1344
682 - if (segment.size === null && segment.unit !== null) {
1345 + if (!(segment.size !== null) && segment.unit !== null) {
683 1346 Interpreter.raiseCompileError(
684 1347 "integer and float types require a size specifier if the unit specifier is given",
685 1348 );
686 1349 }
687 1350
688 - const size = Bitstring.resolveSegmentSize(segment);
689 - const unit = Bitstring.resolveSegmentUnit(segment);
690 - const numBits = size * unit;
1351 + const bitCount = $.calculateSegmentBitCount(segment);
691 1352
692 - if (![16n, 32n, 64n].includes(numBits)) {
693 - Bitstring.#raiseTypeMismatchError(
694 - index,
695 - "integer",
696 - "an integer",
697 - segment.value,
698 - );
699 - }
700 -
701 - if (numBits !== 64n && numBits !== 32n) {
702 - throw new HologramInterpreterError(
703 - `${numBits}-bit float bitstring segments are not yet implemented in Hologram`,
704 - );
1353 + if (bitCount !== 16 && bitCount !== 32 && bitCount !== 64) {
1354 + $.#raiseTypeMismatchError(index, "integer", "an integer", segment.value);
705 1355 }
706 1356
707 1357 return true;
708 1358 }
709 1359
710 - static #validateIntegerSegment(segment, index) {
711 - if (!["integer", "variable_pattern"].includes(segment.value.type)) {
712 - Bitstring.#raiseTypeMismatchError(
713 - index,
714 - "integer",
715 - "an integer",
716 - segment.value,
717 - );
1360 + static #validateSegmentWithIntegerType(segment, index) {
1361 + const valueType = segment.value.type;
1362 +
1363 + if (valueType !== "integer" && valueType !== "variable_pattern") {
1364 + $.#raiseTypeMismatchError(index, "integer", "an integer", segment.value);
718 1365 }
719 1366
720 1367 return true;
721 1368 }
722 1369
723 - static #validateUtfSegment(segment, index) {
724 - if (["bitstring", "float"].includes(segment.value.type)) {
725 - Bitstring.#raiseTypeMismatchError(
1370 + static #validateSegmentWithUtfType(segment, index) {
1371 + const valueType = segment.value.type;
1372 +
1373 + if (valueType === "bitstring" || valueType === "float") {
1374 + $.#raiseTypeMismatchError(
726 1375 index,
727 1376 segment.type,
728 1377 "a non-negative integer encodable as " + segment.type,
  @@ -731,18 +1380,15 @@ export default class Bitstring {
731 1380 }
732 1381
733 1382 if (
734 - segment.signedness !== null ||
735 1383 segment.size !== null ||
736 - segment.unit !== null
1384 + segment.unit !== null ||
1385 + segment.signedness !== null
737 1386 ) {
738 - Bitstring.#raiseTypeMismatchError(
739 - index,
740 - "integer",
741 - "an integer",
742 - segment.value,
743 - );
1387 + $.#raiseTypeMismatchError(index, "integer", "an integer", segment.value);
744 1388 }
745 1389
746 1390 return true;
747 1391 }
748 1392 }
1393 +
1394 + const $ = Bitstring;
  @@ -1,100 +1,171 @@
1 1 "use strict";
2 2
3 + import Bitstring from "./bitstring.mjs";
4 + import ComponentRegistry from "./component_registry.mjs";
3 5 import Config from "./config.mjs";
4 - import GlobalRegistry from "./global_registry.mjs";
6 + import Connection from "./connection.mjs";
7 + import Hologram from "./hologram.mjs";
5 8 import HologramRuntimeError from "./errors/runtime_error.mjs";
9 + import HttpTransport from "./http_transport.mjs";
10 + import Interpreter from "./interpreter.mjs";
6 11 import Serializer from "./serializer.mjs";
7 - import Utils from "./utils.mjs";
12 + import Type from "./type.mjs";
8 13
9 - import {Socket} from "phoenix";
10 -
11 - // TODO: test
12 14 export default class Client {
13 - static #channel = null;
15 + // Deps: [:maps.get/2]
16 + static buildCommandPayload(command) {
17 + const target = Erlang_Maps["get/2"](Type.atom("target"), command);
14 18
15 - // Made public to make tests easier
16 - static socket = null;
17 -
18 - static connect() {
19 - Utils.runAsyncTask(() => {
20 - Client.socket = new Socket("/hologram", {
21 - encode: Client.encoder,
22 - longPollFallbackMs: window.location.host.startsWith("localhost")
23 - ? undefined
24 - : 3000,
25 - });
26 -
27 - Client.socket.connect();
28 -
29 - Client.#channel = Client.socket.channel("hologram");
30 -
31 - Client.#channel
32 - .join()
33 - .receive("ok", (_resp) => {
34 - console.debug("Hologram: connected to a server");
35 - GlobalRegistry.set("connected?", true);
36 - })
37 - .receive("error", (_resp) => {
38 - GlobalRegistry.set("connected?", false);
39 - throw new HologramRuntimeError("unable to connect to a server");
40 - })
41 - .receive("timeout", (_resp) => {
42 - GlobalRegistry.set("connected?", false);
43 - throw new HologramRuntimeError("unable to connect to a server");
44 - });
45 -
46 - Client.#channel.on("reload", (_payload) => document.location.reload());
47 - });
48 - }
49 -
50 - static encoder(msg, callback) {
51 - let encoded;
52 -
53 - if (msg.topic === "hologram") {
54 - const serializedPayload = Serializer.serialize(msg.payload, false, true);
55 - encoded = `["${msg.join_ref}","${msg.ref}","${msg.topic}","${msg.event}",${serializedPayload}]`;
56 - } else {
57 - encoded = JSON.stringify([
58 - msg.join_ref,
59 - msg.ref,
60 - msg.topic,
61 - msg.event,
62 - msg.payload,
63 - ]);
19 + if (!ComponentRegistry.isCidRegistered(target)) {
20 + const message = `invalid command target, there is no component with CID: ${Interpreter.inspect(target)}`;
21 + throw new HologramRuntimeError(message);
64 22 }
65 23
66 - return callback(encoded);
24 + const module = ComponentRegistry.getComponentModule(target);
25 +
26 + return Type.map([
27 + [Type.atom("module"), module],
28 + [Type.atom("name"), Erlang_Maps["get/2"](Type.atom("name"), command)],
29 + [Type.atom("params"), Erlang_Maps["get/2"](Type.atom("params"), command)],
30 + [Type.atom("target"), target],
31 + ]);
67 32 }
68 33
69 - static fetchPage(toParam, successCallback, failureCallback) {
70 - return Utils.runAsyncTask(() => {
71 - Client.#channel
72 - .push("page", toParam, Config.fetchPageTimeoutMs)
73 - .receive("ok", successCallback)
74 - .receive("error", failureCallback)
75 - .receive("timeout", failureCallback);
34 + static buildPageQueryString(params) {
35 + if (Type.isList(params)) {
36 + params = Type.map(
37 + params.data.map((param) => [param.data[0], param.data[1]]),
38 + );
39 + }
40 +
41 + let queryParts = [];
42 +
43 + Object.values(params.data).forEach((param) => {
44 + const key = param[0];
45 +
46 + if (key.type !== "atom") {
47 + throw new HologramRuntimeError(
48 + `invalid param key type (only atom type is allowed), got: ${Interpreter.inspect(key)}`,
49 + );
50 + }
51 +
52 + const value = param[1];
53 +
54 + if (
55 + value.type !== "atom" &&
56 + value.type !== "float" &&
57 + value.type !== "integer" &&
58 + !Type.isBinary(value)
59 + ) {
60 + throw new HologramRuntimeError(
61 + `invalid param value type (only atom, float, integer and string types are allowed), got: ${Interpreter.inspect(value)}`,
62 + );
63 + }
64 +
65 + queryParts.push(
66 + `${key.value}=${Type.isBitstring(value) ? Bitstring.toText(value) : value.value.toString()}`,
67 + );
76 68 });
69 +
70 + return queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
77 71 }
78 72
79 - static fetchPageBundlePath(pageModule, successCallback, failureCallback) {
80 - return Utils.runAsyncTask(() => {
81 - Client.#channel
82 - .push("page_bundle_path", pageModule, Config.clientFetchTimeoutMs)
83 - .receive("ok", successCallback)
84 - .receive("error", failureCallback)
85 - .receive("timeout", failureCallback);
86 - });
73 + static connect(sendImmediatePing) {
74 + Connection.connect();
75 + HttpTransport.restartPing(sendImmediatePing);
87 76 }
88 77
78 + static async fetchPage(toParam, onSuccess) {
79 + let pageModule, queryString;
80 +
81 + if (Type.isAlias(toParam)) {
82 + pageModule = toParam;
83 + queryString = "";
84 + } else {
85 + pageModule = toParam.data[0];
86 + queryString = $.buildPageQueryString(toParam.data[1]);
87 + }
88 +
89 + try {
90 + const pageModuleName = Interpreter.moduleExName(pageModule);
91 + const url = `/hologram/page/${pageModuleName}${queryString}`;
92 + const response = await fetch(url);
93 +
94 + if (!response.ok) {
95 + $.#handleFetchPageError(response.status);
96 + }
97 +
98 + const html = await response.text();
99 + onSuccess(html);
100 + } catch (error) {
101 + if (error instanceof HologramRuntimeError) {
102 + throw error;
103 + }
104 +
105 + $.#handleFetchPageError(error);
106 + }
107 + }
108 +
109 + // Covered in feature tests
110 + static fetchPageBundlePath(pageModule, onSuccess, onFail) {
111 + const opts = {
112 + onSuccess,
113 + onError: onFail,
114 + onTimeout: onFail,
115 + timeout: Config.clientFetchTimeoutMs,
116 + };
117 +
118 + return Connection.sendRequest("page_bundle_path", pageModule, opts);
119 + }
120 +
121 + // Covered in feature tests
89 122 static isConnected() {
90 - return Client.socket === null ? false : Client.socket.isConnected();
123 + return Connection.isConnected();
91 124 }
92 125
93 - static sendCommand(payload, successCallback, failureCallback) {
94 - Client.#channel
95 - .push("command", payload)
96 - .receive("ok", successCallback)
97 - .receive("error", failureCallback)
98 - .receive("timeout", failureCallback);
126 + static async sendCommand(command) {
127 + const opts = {
128 + method: "POST",
129 + headers: {
130 + "Content-Type": "application/json",
131 + },
132 + body: Serializer.serialize($.buildCommandPayload(command), "server"),
133 + };
134 +
135 + try {
136 + const response = await fetch("/hologram/command", opts);
137 +
138 + if (!response.ok) {
139 + $.#failCommand(response.status);
140 + }
141 +
142 + const [status, result] = await response.json();
143 +
144 + if (status === 0) {
145 + $.#failCommand(result);
146 + }
147 +
148 + const nextAction = Interpreter.evaluateJavaScriptExpression(result);
149 +
150 + if (!Type.isNil(nextAction)) {
151 + Hologram.executeAction(nextAction);
152 + }
153 + } catch (error) {
154 + if (error instanceof HologramRuntimeError) {
155 + throw error;
156 + }
157 +
158 + $.#failCommand(error);
159 + }
160 + }
161 +
162 + static #failCommand(message) {
163 + throw new HologramRuntimeError(`command failed: ${message}`);
164 + }
165 +
166 + static #handleFetchPageError(message) {
167 + throw new HologramRuntimeError(`page fetch failed: ${message}`);
99 168 }
100 169 }
170 +
171 + const $ = Client;
  @@ -19,6 +19,11 @@ export default class CommandQueue {
19 19 ++CommandQueue.items[id].failCount;
20 20 }
21 21
22 + static failAndThrowError(id, message) {
23 + CommandQueue.fail(id);
24 + throw new HologramRuntimeError(`command failed: ${message}`);
25 + }
26 +
22 27 // Made public to make tests easier
23 28 static getNextPending() {
24 29 // The traversal order for string keys is ascending chronological (so we get FIFO behaviour)
  @@ -41,21 +46,27 @@ export default class CommandQueue {
41 46 item.status = "sending";
42 47
43 48 const successCallback = ((currentItem) => {
44 - return (resp) => {
45 - CommandQueue.remove(currentItem.id);
49 + return (responsePayload) => {
50 + const [status, result] = responsePayload;
46 51
47 - const nextAction = Interpreter.evaluateJavaScriptExpression(resp);
52 + if (status === 1) {
53 + CommandQueue.remove(currentItem.id);
48 54
49 - if (!Type.isNil(nextAction)) {
50 - Hologram.executeAction(nextAction);
55 + const nextAction =
56 + Interpreter.evaluateJavaScriptExpression(result);
57 +
58 + if (!Type.isNil(nextAction)) {
59 + Hologram.executeAction(nextAction);
60 + }
61 + } else {
62 + $.failAndThrowError(currentItem.id, result);
51 63 }
52 64 };
53 65 })(item);
54 66
55 67 const failureCallback = ((currentItem) => {
56 - return (resp) => {
57 - CommandQueue.fail(currentItem.id);
58 - throw new HologramRuntimeError(`command failed: ${resp}`);
68 + return (responsePayload) => {
69 + $.failAndThrowError(currentItem.id, responsePayload);
59 70 };
60 71 })(item);
61 72
  @@ -108,3 +119,5 @@ export default class CommandQueue {
108 119 ]);
109 120 }
110 121 }
122 +
123 + const $ = CommandQueue;
  @@ -0,0 +1,299 @@
1 + "use strict";
2 +
3 + import GlobalRegistry from "./global_registry.mjs";
4 + import LiveReload from "./live_reload.mjs";
5 + import Serializer from "./serializer.mjs";
6 +
7 + export default class Connection {
8 + // 1 second
9 + static BASE_RECONNECT_DELAY = 1_000;
10 +
11 + // 10 seconds
12 + static CONNECTION_TIMEOUT = 10_000;
13 +
14 + // 32 seconds
15 + static MAX_RECONNECT_DELAY = 32_000;
16 +
17 + // 30 seconds
18 + static PING_INTERVAL = 30_000;
19 +
20 + // 5 seconds
21 + static PONG_TIMEOUT = 5_000;
22 +
23 + // 60 seconds
24 + static REQUEST_TIMEOUT = 60_000;
25 +
26 + static WEBSOCKET_PATH = "/hologram/websocket";
27 +
28 + static connectionTimer = null;
29 + static pendingRequests = new Map();
30 + static pingTimer = null;
31 + static pongTimer = null;
32 + static reconnectAttempts = 0;
33 + static reconnectTimer = null;
34 + static websocket = null;
35 +
36 + // disconnected, connecting, connected, error
37 + static status = "disconnected";
38 +
39 + static clearConnectionTimer() {
40 + if ($.connectionTimer) {
41 + clearTimeout($.connectionTimer);
42 + $.connectionTimer = null;
43 + }
44 + }
45 +
46 + static clearPendingRequests(triggerErrorCallbacks) {
47 + for (const [_correlationId, request] of $.pendingRequests.entries()) {
48 + clearTimeout(request.timerId);
49 +
50 + if (triggerErrorCallbacks) {
51 + request.onError();
52 + }
53 + }
54 +
55 + $.pendingRequests.clear();
56 + }
57 +
58 + static clearPingTimer() {
59 + if ($.pingTimer) {
60 + clearInterval($.pingTimer);
61 + $.pingTimer = null;
62 + }
63 + }
64 +
65 + static clearPongTimer() {
66 + if ($.pongTimer) {
67 + clearTimeout($.pongTimer);
68 + $.pongTimer = null;
69 + }
70 + }
71 +
72 + static clearReconnectTimer() {
73 + if ($.reconnectTimer) {
74 + clearTimeout($.reconnectTimer);
75 + $.reconnectTimer = null;
76 + }
77 + }
78 +
79 + static connect() {
80 + if ($.status === "connected" || $.status === "connecting") return;
81 +
82 + $.status = "connecting";
83 + $.clearReconnectTimer();
84 +
85 + try {
86 + $.websocket = new WebSocket($.WEBSOCKET_PATH);
87 +
88 + $.websocket.onopen = $.handleOpen;
89 + $.websocket.onclose = $.handleClose;
90 + $.websocket.onerror = $.handleError;
91 + $.websocket.onmessage = $.handleMessage;
92 +
93 + $.connectionTimer = setTimeout(() => {
94 + if ($.status === "connecting") {
95 + $.websocket.close();
96 + $.handleConnectionTimeout();
97 + }
98 + }, $.CONNECTION_TIMEOUT);
99 + } catch (error) {
100 + $.handleError(error);
101 + }
102 + }
103 +
104 + static encodeMessage(type, payload, correlationId) {
105 + if (payload === null && correlationId === null) return `"${type}"`;
106 +
107 + if (correlationId) {
108 + return `["${type}",${Serializer.serialize(payload, "server")},"${correlationId}"]`;
109 + }
110 +
111 + return `["${type}",${Serializer.serialize(payload, "server")}]`;
112 + }
113 +
114 + static handleClose(event) {
115 + console.warn("Hologram: disconnected from server", event);
116 +
117 + $.status = "disconnected";
118 + GlobalRegistry.set("connected?", false);
119 +
120 + $.clearConnectionTimer();
121 + $.clearPingTimer();
122 + $.clearPongTimer();
123 + $.clearPendingRequests(true);
124 +
125 + $.reconnect();
126 + }
127 +
128 + static handleConnectionTimeout() {
129 + console.error("Hologram: server connection timeout");
130 +
131 + $.status = "error";
132 +
133 + $.reconnect();
134 + }
135 +
136 + static handleError(event) {
137 + console.error("Hologram: server connection error", event);
138 +
139 + $.status = "error";
140 + GlobalRegistry.set("connected?", false);
141 +
142 + $.clearConnectionTimer();
143 +
144 + $.reconnect();
145 + }
146 +
147 + static handleMessage(event) {
148 + const encodedMessage = event.data;
149 +
150 + if (encodedMessage === '"pong"') {
151 + $.clearPongTimer();
152 + return;
153 + }
154 +
155 + if (encodedMessage === '"reload"') {
156 + document.location.reload();
157 + return;
158 + }
159 +
160 + const decodedMessage = JSON.parse(encodedMessage);
161 +
162 + if (decodedMessage.length === 3) {
163 + // Currently, the only supported message type that has a correlation ID is "reply"
164 + const [_type, payload, correlationId] = decodedMessage;
165 +
166 + if ($.pendingRequests.has(correlationId)) {
167 + const request = $.pendingRequests.get(correlationId);
168 +
169 + clearTimeout(request.timerId);
170 + $.pendingRequests.delete(correlationId);
171 +
172 + request.onSuccess(payload);
173 + }
174 +
175 + return;
176 + }
177 +
178 + // Currently, the only supported message type that has a payload,
179 + // but doesn't have a correlation ID is "compilation_error"
180 + const [_type, payload] = decodedMessage;
181 + LiveReload.showErrorOverlay(payload);
182 + }
183 +
184 + static handleOpen(_event) {
185 + console.log("Hologram: connected to server");
186 +
187 + $.status = "connected";
188 + GlobalRegistry.set("connected?", true);
189 +
190 + $.reconnectAttempts = 0;
191 + $.clearConnectionTimer();
192 +
193 + $.startPing();
194 + }
195 +
196 + static isConnected() {
197 + return $.status === "connected";
198 + }
199 +
200 + static reconnect() {
201 + $.reconnectAttempts++;
202 +
203 + const delay = Math.min(
204 + $.BASE_RECONNECT_DELAY * Math.pow(2, $.reconnectAttempts - 1),
205 + $.MAX_RECONNECT_DELAY,
206 + );
207 +
208 + console.log(
209 + `Hologram: reconnecting in ${delay} ms (attempt ${$.reconnectAttempts})`,
210 + );
211 +
212 + $.reconnectTimer = setTimeout(() => {
213 + $.connect();
214 + }, delay);
215 + }
216 +
217 + static sendMessage(type, payload = null, correlationId = null) {
218 + if ($.status === "connected") {
219 + const encodedMessage = $.encodeMessage(type, payload, correlationId);
220 +
221 + try {
222 + $.websocket.send(encodedMessage);
223 + return true;
224 + // eslint-disable-next-line no-empty
225 + } catch {}
226 + }
227 +
228 + console.error(
229 + "Hologram: failed to send message to server",
230 + type,
231 + payload,
232 + correlationId,
233 + );
234 +
235 + return false;
236 + }
237 +
238 + static sendRequest(
239 + type,
240 + payload = null,
241 + {onSuccess, onError, onTimeout, timeout = $.REQUEST_TIMEOUT} = {},
242 + ) {
243 + return new Promise((resolve, reject) => {
244 + const correlationId = crypto.randomUUID();
245 +
246 + const timerId = setTimeout(() => {
247 + $.pendingRequests.delete(correlationId);
248 + if (onTimeout) onTimeout();
249 + reject(new Error("Request timeout"));
250 + }, timeout);
251 +
252 + $.pendingRequests.set(correlationId, {
253 + onSuccess: (responsePayload) => {
254 + if (onSuccess) onSuccess(responsePayload);
255 + resolve(responsePayload);
256 + },
257 + onError: () => {
258 + if (onError) onError();
259 + reject(new Error("Request failed"));
260 + },
261 + onTimeout: () => {
262 + if (onTimeout) onTimeout();
263 + reject(new Error("Request timeout"));
264 + },
265 + timerId,
266 + });
267 +
268 + if (!$.sendMessage(type, payload, correlationId)) {
269 + $.pendingRequests.delete(correlationId);
270 + clearTimeout(timerId);
271 + if (onError) onError();
272 + reject(new Error("Failed to send message"));
273 + }
274 + });
275 + }
276 +
277 + static sendPing() {
278 + if ($.status === "connected") {
279 + $.sendMessage("ping");
280 +
281 + $.pongTimer = setTimeout(() => {
282 + console.warn("Hologram: pong timeout");
283 + $.websocket.close();
284 + }, $.PONG_TIMEOUT);
285 + }
286 + }
287 +
288 + static startPing() {
289 + $.clearPingTimer();
290 +
291 + $.pingTimer = setInterval(() => {
292 + if ($.status === "connected") {
293 + $.sendPing();
294 + }
295 + }, $.PING_INTERVAL);
296 + }
297 + }
298 +
299 + const $ = Connection;
  @@ -1,48 +1,114 @@
1 1 "use strict";
2 2
3 + import Bitstring from "./bitstring.mjs";
3 4 import Interpreter from "./interpreter.mjs";
5 + import Serializer from "./serializer.mjs";
4 6 import Type from "./type.mjs";
5 7
6 8 export default class Deserializer {
7 - static deserialize(serialized, isVersioned = true) {
8 - const deserialized = JSON.parse(serialized, (_key, value) => {
9 - if (typeof value === "string") {
10 - if (value.startsWith("__atom__:")) {
11 - return Type.atom(value.slice(9));
12 - }
9 + static deserialize(serialized) {
10 + return JSON.parse(serialized, (_key, value) => {
11 + return typeof value === "string"
12 + ? $.#maybeDeserializeFromString(value)
13 + : $.#maybeDeserializeFromObject(value);
14 + })[1];
15 + }
13 16
14 - if (value.startsWith("__bigint__:")) {
15 - return BigInt(value.slice(11));
16 - }
17 + static #deserializeBoxedBitstring(serialized) {
18 + if (serialized === "b") {
19 + return Type.bitstring("");
20 + }
17 21
18 - if (value.startsWith("__binary__:")) {
19 - return Type.bitstring(value.slice(11));
20 - }
22 + const hex = serialized.slice(2);
23 + const hexLength = hex.length;
24 + const bytes = new Uint8Array(hexLength >> 1);
21 25
22 - if (value.startsWith("__float__:")) {
23 - return Type.float(Number(value.slice(10)));
24 - }
26 + // Use separate j index variable to avoid division in each iteration
27 + for (let i = 0, j = 0; i < hexLength; i += 2, j++) {
28 + bytes[j] = parseInt(hex.slice(i, i + 2), 16);
29 + }
25 30
26 - if (value.startsWith("__function__:")) {
27 - return Interpreter.evaluateJavaScriptExpression(value.slice(13));
28 - }
31 + const bitstring = Bitstring.fromBytes(bytes);
32 + bitstring.leftoverBitCount = parseInt(serialized[1]);
29 33
30 - if (value.startsWith("__integer__:")) {
31 - return Type.integer(BigInt(value.slice(12)));
32 - }
33 - }
34 + return bitstring;
35 + }
34 36
35 - if (value?.type === "bitstring") {
36 - return Type.bitstring(value.bits);
37 - }
37 + static #deserializeBoxedFunctionCapture(serialized) {
38 + const parts = serialized.split(Serializer.DELIMITER);
39 + const context = Interpreter.buildContext();
38 40
39 - if (value?.type === "map") {
40 - return Type.map(value.data);
41 - }
41 + return Type.functionCapture(
42 + parts[0],
43 + parts[1],
44 + parseInt(parts[2]),
45 + [],
46 + context,
47 + );
48 + }
42 49
43 - return value;
44 - });
50 + static #deserializeBoxedIdentifier(identifierType, serialized) {
51 + const parts = serialized.split(Serializer.DELIMITER);
45 52
46 - return isVersioned ? deserialized[1] : deserialized;
53 + return Type[identifierType](
54 + parts[0],
55 + parts[1].split(",").map((segment) => parseInt(segment)),
56 + parts[2],
57 + );
58 + }
59 +
60 + static #maybeDeserializeFromObject(obj) {
61 + switch (obj?.t) {
62 + case "l":
63 + return Type.list(obj.d);
64 +
65 + case "m":
66 + return Type.map(obj.d);
67 +
68 + case "t":
69 + return Type.tuple(obj.d);
70 + }
71 +
72 + return obj;
73 + }
74 +
75 + static #maybeDeserializeFromString(serialized) {
76 + const data = serialized.slice(1);
77 +
78 + switch (serialized[0]) {
79 + case "a":
80 + return Type.atom(data);
81 +
82 + case "b":
83 + return $.#deserializeBoxedBitstring(serialized);
84 +
85 + case "c":
86 + return $.#deserializeBoxedFunctionCapture(data);
87 +
88 + case "f":
89 + return Type.float(Number(data));
90 +
91 + case "i":
92 + return Type.integer(BigInt(data));
93 +
94 + case "o":
95 + return $.#deserializeBoxedIdentifier("port", data);
96 +
97 + case "p":
98 + return $.#deserializeBoxedIdentifier("pid", data);
99 +
100 + case "r":
101 + return $.#deserializeBoxedIdentifier("reference", data);
102 +
103 + case "s":
104 + return data;
105 +
106 + case "u":
107 + return Interpreter.evaluateJavaScriptExpression(data);
108 + }
109 +
110 + return serialized;
47 111 }
48 112 }
113 +
114 + const $ = Deserializer;
Loading more files…