Current section
26 Versions
Jump to
Current section
26 Versions
Compare versions
49
files changed
+8623
additions
-315
deletions
| @@ -40,6 +40,7 @@ Website: https://hologram.page | |
| 40 40 | * Oban, [@oban-bg](https://github.com/oban-bg) |
| 41 41 | * Lucas Sifoni, [@Lucassifoni](https://github.com/Lucassifoni) |
| 42 42 | * Robert Urbańczyk, [@robertu](https://github.com/robertu) |
| 43 | + * Moss Piglet, [@moss-piglet](https://github.com/moss-piglet) |
| 43 44 | |
| 44 45 | ### Early Adopter Tier |
| @@ -0,0 +1,22 @@ | |
| 1 | + "use strict"; |
| 2 | + |
| 3 | + import SubscriptionReceiptRegistry from "./subscription_receipt_registry.mjs"; |
| 4 | + |
| 5 | + export default class App { |
| 6 | + // Stable per JS context (across page navigation). |
| 7 | + // Populated from globalThis during Hologram boot. |
| 8 | + static instanceId = null; |
| 9 | + |
| 10 | + static subscriptionReceiptRegistry = SubscriptionReceiptRegistry; |
| 11 | + |
| 12 | + // Idempotent: returns early when `App.instanceId` is already set so a |
| 13 | + // snapshot-restored value survives the boot sequence even though |
| 14 | + // `#restorePageSnapshot` runs before `App.maybeLoadInstanceId()`. |
| 15 | + static maybeLoadInstanceId() { |
| 16 | + if (App.instanceId !== null) { |
| 17 | + return; |
| 18 | + } |
| 19 | + |
| 20 | + App.instanceId = globalThis.Hologram.instanceId; |
| 21 | + } |
| 22 | + } |
| @@ -1,5 +1,6 @@ | |
| 1 1 | "use strict"; |
| 2 2 | |
| 3 | + import App from "./app.mjs"; |
| 3 4 | import Bitstring from "./bitstring.mjs"; |
| 4 5 | import ComponentRegistry from "./component_registry.mjs"; |
| 5 6 | import Config from "./config.mjs"; |
| @@ -24,6 +25,7 @@ export default class Client { | |
| 24 25 | const module = ComponentRegistry.getComponentModule(target); |
| 25 26 | |
| 26 27 | return Type.map([ |
| 28 | + [Type.atom("instance_id"), Type.bitstring(App.instanceId)], |
| 27 29 | [Type.atom("module"), module], |
| 28 30 | [Type.atom("name"), Erlang_Maps["get/2"](Type.atom("name"), command)], |
| 29 31 | [Type.atom("params"), Erlang_Maps["get/2"](Type.atom("params"), command)], |
| @@ -76,6 +78,17 @@ export default class Client { | |
| 76 78 | return queryParts.length > 0 ? `?${queryParts.join("&")}` : ""; |
| 77 79 | } |
| 78 80 | |
| 81 | + static buildPageRequestPayload() { |
| 82 | + const clientClaimedSubKeys = Array.from( |
| 83 | + App.subscriptionReceiptRegistry.entries.values(), |
| 84 | + ).map((triple) => Type.tuple([triple.data[0], triple.data[1]])); |
| 85 | + |
| 86 | + return Type.map([ |
| 87 | + [Type.atom("client_claimed_sub_keys"), Type.list(clientClaimedSubKeys)], |
| 88 | + [Type.atom("instance_id"), Type.bitstring(App.instanceId)], |
| 89 | + ]); |
| 90 | + } |
| 91 | + |
| 79 92 | static connect(sendImmediatePing) { |
| 80 93 | Connection.connect(); |
| 81 94 | HttpTransport.restartPing(sendImmediatePing); |
| @@ -95,7 +108,12 @@ export default class Client { | |
| 95 108 | try { |
| 96 109 | const pageModuleName = Interpreter.moduleExName(pageModule); |
| 97 110 | const url = `/hologram/page/${pageModuleName}${queryString}`; |
| 98 | - const response = await fetch(url); |
| 111 | + |
| 112 | + const response = await fetch(url, { |
| 113 | + method: "POST", |
| 114 | + headers: {"Content-Type": "application/json"}, |
| 115 | + body: Serializer.serialize($.buildPageRequestPayload(), "server"), |
| 116 | + }); |
| 99 117 | |
| 100 118 | if (!response.ok) { |
| 101 119 | $.#handleFetchPageError(response.status); |
| @@ -146,17 +164,40 @@ export default class Client { | |
| 146 164 | $.#failCommand(response.status); |
| 147 165 | } |
| 148 166 | |
| 149 | - const [status, result] = await response.json(); |
| 167 | + const { |
| 168 | + action, |
| 169 | + selfEchoes: encodedSelfEchoes, |
| 170 | + status, |
| 171 | + subReceiptAdds: encodedSubReceiptAdds, |
| 172 | + subReceiptDrops: encodedSubReceiptDrops, |
| 173 | + } = await response.json(); |
| 150 174 | |
| 151 175 | if (status === 0) { |
| 152 | - $.#failCommand(result); |
| 176 | + $.#failCommand(action); |
| 153 177 | } |
| 154 178 | |
| 155 | - const nextAction = Interpreter.evaluateJavaScriptExpression(result); |
| 179 | + const subReceiptAdds = Interpreter.evaluateJavaScriptExpression( |
| 180 | + encodedSubReceiptAdds, |
| 181 | + ); |
| 182 | + |
| 183 | + const subReceiptDrops = Interpreter.evaluateJavaScriptExpression( |
| 184 | + encodedSubReceiptDrops, |
| 185 | + ); |
| 186 | + |
| 187 | + App.subscriptionReceiptRegistry.merge(subReceiptAdds, subReceiptDrops); |
| 188 | + |
| 189 | + const nextAction = Interpreter.evaluateJavaScriptExpression(action); |
| 156 190 | |
| 157 191 | if (!Type.isNil(nextAction)) { |
| 158 192 | Hologram.scheduleAction(nextAction); |
| 159 193 | } |
| 194 | + |
| 195 | + const selfEchoes = |
| 196 | + Interpreter.evaluateJavaScriptExpression(encodedSelfEchoes); |
| 197 | + |
| 198 | + for (const action of selfEchoes.data) { |
| 199 | + Hologram.scheduleAction(action); |
| 200 | + } |
| 160 201 | } catch (error) { |
| 161 202 | if (error instanceof HologramRuntimeError) { |
| 162 203 | throw error; |
| @@ -115,7 +115,7 @@ export default class Connection { | |
| 115 115 | console.warn("Hologram: disconnected from server", event); |
| 116 116 | |
| 117 117 | $.status = "disconnected"; |
| 118 | - GlobalRegistry.set("connected?", false); |
| 118 | + GlobalRegistry.set("wsConnected?", false); |
| 119 119 | |
| 120 120 | $.clearConnectionTimer(); |
| 121 121 | $.clearPingTimer(); |
| @@ -137,7 +137,7 @@ export default class Connection { | |
| 137 137 | console.error("Hologram: server connection error", event); |
| 138 138 | |
| 139 139 | $.status = "error"; |
| 140 | - GlobalRegistry.set("connected?", false); |
| 140 | + GlobalRegistry.set("wsConnected?", false); |
| 141 141 | |
| 142 142 | $.clearConnectionTimer(); |
| 143 143 | |
| @@ -185,7 +185,7 @@ export default class Connection { | |
| 185 185 | console.log("Hologram: connected to server"); |
| 186 186 | |
| 187 187 | $.status = "connected"; |
| 188 | - GlobalRegistry.set("connected?", true); |
| 188 | + GlobalRegistry.set("wsConnected?", true); |
| 189 189 | |
| 190 190 | $.reconnectAttempts = 0; |
| 191 191 | $.clearConnectionTimer(); |
| @@ -614,6 +614,816 @@ const Erlang = { | |
| 614 614 | // End binary_to_list/1 |
| 615 615 | // Deps: [] |
| 616 616 | |
| 617 | + // Start binary_to_term/1 |
| 618 | + "binary_to_term/1": async (binary) => { |
| 619 | + if (!Type.isBinary(binary)) { |
| 620 | + Interpreter.raiseArgumentError( |
| 621 | + Interpreter.buildArgumentErrorMsg(1, "not a binary"), |
| 622 | + ); |
| 623 | + } |
| 624 | + |
| 625 | + // ETF version byte (precedes every top-level term). |
| 626 | + const VERSION_MAGIC = 131; |
| 627 | + |
| 628 | + // ETF tag constants |
| 629 | + const NEW_FLOAT_EXT = 70; |
| 630 | + const BIT_BINARY_EXT = 77; |
| 631 | + const COMPRESSED = 80; |
| 632 | + const NEW_PID_EXT = 88; |
| 633 | + const NEW_PORT_EXT = 89; |
| 634 | + const NEWER_REFERENCE_EXT = 90; |
| 635 | + const SMALL_INTEGER_EXT = 97; |
| 636 | + const INTEGER_EXT = 98; |
| 637 | + const FLOAT_EXT = 99; |
| 638 | + const ATOM_EXT = 100; |
| 639 | + const REFERENCE_EXT = 101; |
| 640 | + const PORT_EXT = 102; |
| 641 | + const PID_EXT = 103; |
| 642 | + const SMALL_TUPLE_EXT = 104; |
| 643 | + const LARGE_TUPLE_EXT = 105; |
| 644 | + const NIL_EXT = 106; |
| 645 | + const STRING_EXT = 107; |
| 646 | + const LIST_EXT = 108; |
| 647 | + const BINARY_EXT = 109; |
| 648 | + const SMALL_BIG_EXT = 110; |
| 649 | + const LARGE_BIG_EXT = 111; |
| 650 | + const EXPORT_EXT = 113; |
| 651 | + const NEW_REFERENCE_EXT = 114; |
| 652 | + const SMALL_ATOM_EXT = 115; |
| 653 | + const MAP_EXT = 116; |
| 654 | + const ATOM_UTF8_EXT = 118; |
| 655 | + const SMALL_ATOM_UTF8_EXT = 119; |
| 656 | + const V4_PORT_EXT = 120; |
| 657 | + |
| 658 | + const raiseInvalid = () => |
| 659 | + Interpreter.raiseArgumentError( |
| 660 | + Interpreter.buildArgumentErrorMsg( |
| 661 | + 1, |
| 662 | + "invalid external representation of a term", |
| 663 | + ), |
| 664 | + ); |
| 665 | + |
| 666 | + // Decompresses zlib-compressed data using native DecompressionStream API. |
| 667 | + // Returns a Promise that resolves to a Uint8Array; throws on failure. |
| 668 | + const zlibInflate = async (compressedData) => { |
| 669 | + const stream = new ReadableStream({ |
| 670 | + start(controller) { |
| 671 | + controller.enqueue(compressedData); |
| 672 | + controller.close(); |
| 673 | + }, |
| 674 | + }); |
| 675 | + |
| 676 | + const decompressedStream = stream.pipeThrough( |
| 677 | + new DecompressionStream("deflate"), |
| 678 | + ); |
| 679 | + |
| 680 | + const reader = decompressedStream.getReader(); |
| 681 | + const chunks = []; |
| 682 | + |
| 683 | + while (true) { |
| 684 | + const {done, value} = await reader.read(); |
| 685 | + if (done) break; |
| 686 | + chunks.push(value); |
| 687 | + } |
| 688 | + |
| 689 | + // TODO: binary_to_term/2 with :used must report the exact number of |
| 690 | + // bytes consumed by the zlib stream, which DecompressionStream does |
| 691 | + // not expose. binary_to_term/1 does not care - it accepts trailing |
| 692 | + // bytes and decodes only the first term. Options when we get there: |
| 693 | + // 1. Parse the zlib header/trailer ourselves to find the stream end. |
| 694 | + // 2. Switch to a sync zlib (e.g. pako) that returns bytes consumed. |
| 695 | + // 3. Drop COMPRESSED support and reject tag 80. |
| 696 | + return Utils.concatUint8Arrays(chunks); |
| 697 | + }; |
| 698 | + |
| 699 | + // Sync recursive dispatcher. The COMPRESSED tag is intentionally NOT |
| 700 | + // handled here - OTP only allows it at the top level (immediately after |
| 701 | + // the version byte), so a nested tag 80 falls to the default case and |
| 702 | + // raises. Keeping this function sync avoids a Promise allocation per |
| 703 | + // recursive call, which matters for deeply nested / large terms. |
| 704 | + // |
| 705 | + // TODO: NEW_FUN_EXT (tag 112, anonymous fun encoding) is not implemented |
| 706 | + // and currently falls to the default case, raising. OTP accepts it; we |
| 707 | + // diverge. Filling the gap requires a JS shape for "remote anon fun |
| 708 | + // reference" plus a decision on how Hologram represents funs encoded by |
| 709 | + // a foreign BEAM (the decoded fun cannot actually be invoked without |
| 710 | + // the matching module + Uniq). FUN_EXT (tag 117) is intentionally |
| 711 | + // rejected forever - OTP itself stopped decoding it in OTP 23. |
| 712 | + const decodeTerm = (dataView, bytes, offset) => { |
| 713 | + if (offset >= bytes.length) raiseInvalid(); |
| 714 | + |
| 715 | + const tag = dataView.getUint8(offset); |
| 716 | + |
| 717 | + switch (tag) { |
| 718 | + case SMALL_INTEGER_EXT: |
| 719 | + return decodeSmallInteger(dataView, offset + 1); |
| 720 | + |
| 721 | + case INTEGER_EXT: |
| 722 | + return decodeInteger(dataView, offset + 1); |
| 723 | + |
| 724 | + case SMALL_BIG_EXT: |
| 725 | + return decodeSmallBig(dataView, bytes, offset + 1); |
| 726 | + |
| 727 | + case LARGE_BIG_EXT: |
| 728 | + return decodeLargeBig(dataView, bytes, offset + 1); |
| 729 | + |
| 730 | + case ATOM_EXT: |
| 731 | + return decodeAtom(dataView, bytes, offset + 1, false); |
| 732 | + |
| 733 | + case SMALL_ATOM_EXT: |
| 734 | + return decodeSmallAtom(dataView, bytes, offset + 1, false); |
| 735 | + |
| 736 | + case ATOM_UTF8_EXT: |
| 737 | + return decodeAtom(dataView, bytes, offset + 1, true); |
| 738 | + |
| 739 | + case SMALL_ATOM_UTF8_EXT: |
| 740 | + return decodeSmallAtom(dataView, bytes, offset + 1, true); |
| 741 | + |
| 742 | + case BINARY_EXT: |
| 743 | + return decodeBinary(dataView, bytes, offset + 1); |
| 744 | + |
| 745 | + case SMALL_TUPLE_EXT: |
| 746 | + return decodeSmallTuple(dataView, bytes, offset + 1); |
| 747 | + |
| 748 | + case LARGE_TUPLE_EXT: |
| 749 | + return decodeLargeTuple(dataView, bytes, offset + 1); |
| 750 | + |
| 751 | + case NIL_EXT: |
| 752 | + return {term: Type.list(), newOffset: offset + 1}; |
| 753 | + |
| 754 | + case STRING_EXT: |
| 755 | + return decodeString(dataView, bytes, offset + 1); |
| 756 | + |
| 757 | + case LIST_EXT: |
| 758 | + return decodeList(dataView, bytes, offset + 1); |
| 759 | + |
| 760 | + case MAP_EXT: |
| 761 | + return decodeMap(dataView, bytes, offset + 1); |
| 762 | + |
| 763 | + case NEW_FLOAT_EXT: |
| 764 | + return decodeNewFloat(dataView, offset + 1); |
| 765 | + |
| 766 | + case FLOAT_EXT: |
| 767 | + return decodeFloatExt(dataView, bytes, offset + 1); |
| 768 | + |
| 769 | + case BIT_BINARY_EXT: |
| 770 | + return decodeBitBinary(dataView, bytes, offset + 1); |
| 771 | + |
| 772 | + case REFERENCE_EXT: |
| 773 | + return decodeReference(dataView, bytes, offset + 1); |
| 774 | + |
| 775 | + case NEW_REFERENCE_EXT: |
| 776 | + return decodeNewReference(dataView, bytes, offset + 1); |
| 777 | + |
| 778 | + case NEWER_REFERENCE_EXT: |
| 779 | + return decodeNewerReference(dataView, bytes, offset + 1); |
| 780 | + |
| 781 | + case PID_EXT: |
| 782 | + return decodePid(dataView, bytes, offset + 1); |
| 783 | + |
| 784 | + case NEW_PID_EXT: |
| 785 | + return decodeNewPid(dataView, bytes, offset + 1); |
| 786 | + |
| 787 | + case PORT_EXT: |
| 788 | + return decodePort(dataView, bytes, offset + 1); |
| 789 | + |
| 790 | + case NEW_PORT_EXT: |
| 791 | + return decodeNewPort(dataView, bytes, offset + 1); |
| 792 | + |
| 793 | + case V4_PORT_EXT: |
| 794 | + return decodeV4Port(dataView, bytes, offset + 1); |
| 795 | + |
| 796 | + case EXPORT_EXT: |
| 797 | + return decodeExport(dataView, bytes, offset + 1); |
| 798 | + |
| 799 | + default: |
| 800 | + raiseInvalid(); |
| 801 | + } |
| 802 | + }; |
| 803 | + |
| 804 | + const decodeSmallInteger = (dataView, offset) => { |
| 805 | + const value = dataView.getUint8(offset); |
| 806 | + |
| 807 | + return { |
| 808 | + term: Type.integer(value), |
| 809 | + newOffset: offset + 1, |
| 810 | + }; |
| 811 | + }; |
| 812 | + |
| 813 | + const decodeInteger = (dataView, offset) => { |
| 814 | + const value = dataView.getInt32(offset); |
| 815 | + |
| 816 | + return { |
| 817 | + term: Type.integer(value), |
| 818 | + newOffset: offset + 4, |
| 819 | + }; |
| 820 | + }; |
| 821 | + |
| 822 | + // OTP treats any non-zero Sign byte as negative (not just 1) - so e.g. |
| 823 | + // sign=2, sign=255 all decode to a negative value. The spec says only 0 |
| 824 | + // and 1 are emitted, but the decoder is lenient. |
| 825 | + const decodeSmallBig = (dataView, bytes, offset) => { |
| 826 | + const n = dataView.getUint8(offset); |
| 827 | + const sign = dataView.getUint8(offset + 1); |
| 828 | + |
| 829 | + if (offset + 2 + n > bytes.length) raiseInvalid(); |
| 830 | + |
| 831 | + // Wire format is little-endian; walk high-to-low so each iteration is |
| 832 | + // a single (value << 8n) | byte instead of allocating BigInt(i * 8). |
| 833 | + let value = 0n; |
| 834 | + for (let i = n - 1; i >= 0; i--) { |
| 835 | + value = (value << 8n) | BigInt(bytes[offset + 2 + i]); |
| 836 | + } |
| 837 | + |
| 838 | + if (sign !== 0) { |
| 839 | + value = -value; |
| 840 | + } |
| 841 | + |
| 842 | + return { |
| 843 | + term: Type.integer(value), |
| 844 | + newOffset: offset + 2 + n, |
| 845 | + }; |
| 846 | + }; |
| 847 | + |
| 848 | + const decodeLargeBig = (dataView, bytes, offset) => { |
| 849 | + const n = dataView.getUint32(offset); |
| 850 | + const sign = dataView.getUint8(offset + 4); |
| 851 | + |
| 852 | + if (offset + 5 + n > bytes.length) raiseInvalid(); |
| 853 | + |
| 854 | + let value = 0n; |
| 855 | + for (let i = n - 1; i >= 0; i--) { |
| 856 | + value = (value << 8n) | BigInt(bytes[offset + 5 + i]); |
| 857 | + } |
| 858 | + |
| 859 | + if (sign !== 0) { |
| 860 | + value = -value; |
| 861 | + } |
| 862 | + |
| 863 | + return { |
| 864 | + term: Type.integer(value), |
| 865 | + newOffset: offset + 5 + n, |
| 866 | + }; |
| 867 | + }; |
| 868 | + |
| 869 | + // Decodes atom name bytes. Latin-1 (the deprecated ATOM_EXT/SMALL_ATOM_EXT |
| 870 | + // encoding) maps each byte 0-255 to the same Unicode code point, which is |
| 871 | + // why we use String.fromCharCode in a loop rather than TextDecoder("latin1"): |
| 872 | + // per the WHATWG Encoding Standard, "latin1" is a label for windows-1252, |
| 873 | + // and would mis-decode 0x80-0x9F (e.g. 0x80 -> U+20AC). The loop also |
| 874 | + // avoids String.fromCharCode(...atomBytes), whose argument count is engine- |
| 875 | + // limited (~65k); Erlang atoms are capped at 255 bytes today, but ATOM_EXT's |
| 876 | + // wire-format length field is uint16, so we stay defensive. |
| 877 | + // For UTF-8, invalid bytes throw TypeError from TextDecoder({fatal:true}); |
| 878 | + // we convert that to ArgumentError here so the outer wrapper does not have |
| 879 | + // to catch TypeError generically. |
| 880 | + const decodeAtomBytes = (atomBytes, isUtf8) => { |
| 881 | + if (!isUtf8) { |
| 882 | + let result = ""; |
| 883 | + for (let i = 0; i < atomBytes.length; i++) { |
| 884 | + result += String.fromCharCode(atomBytes[i]); |
| 885 | + } |
| 886 | + return result; |
| 887 | + } |
| 888 | + try { |
| 889 | + return ERTS.utf8Decoder.decode(atomBytes); |
| 890 | + } catch { |
| 891 | + raiseInvalid(); |
| 892 | + } |
| 893 | + }; |
| 894 | + |
| 895 | + const decodeAtom = (dataView, bytes, offset, isUtf8) => { |
| 896 | + const length = dataView.getUint16(offset); |
| 897 | + |
| 898 | + // OTP caps atom names at 255 characters. For Latin-1 (ATOM_EXT) one |
| 899 | + // byte == one char, so we can early-reject without slicing. The |
| 900 | + // SMALL_* variants use a uint8 length field and are naturally bounded. |
| 901 | + if (!isUtf8 && length > 255) raiseInvalid(); |
| 902 | + |
| 903 | + if (offset + 2 + length > bytes.length) raiseInvalid(); |
| 904 | + |
| 905 | + const atomBytes = bytes.slice(offset + 2, offset + 2 + length); |
| 906 | + const atomString = decodeAtomBytes(atomBytes, isUtf8); |
| 907 | + |
| 908 | + // For UTF-8 (ATOM_UTF8_EXT) the byte length can be up to 1020 (255 * 4), |
| 909 | + // but the code-point count must be <= 255. for...of iterates by code |
| 910 | + // points (not UTF-16 units), so emoji etc. count as one. Short-circuit |
| 911 | + // at 256 to avoid walking the whole string when we already know it |
| 912 | + // overflows the cap. |
| 913 | + if (isUtf8) { |
| 914 | + let codepoints = 0; |
| 915 | + for (const _ of atomString) { |
| 916 | + if (++codepoints > 255) raiseInvalid(); |
| 917 | + } |
| 918 | + } |
| 919 | + |
| 920 | + return { |
| 921 | + term: Type.atom(atomString), |
| 922 | + newOffset: offset + 2 + length, |
| 923 | + }; |
| 924 | + }; |
| 925 | + |
| 926 | + const decodeSmallAtom = (dataView, bytes, offset, isUtf8) => { |
| 927 | + const length = dataView.getUint8(offset); |
| 928 | + if (offset + 1 + length > bytes.length) raiseInvalid(); |
| 929 | + |
| 930 | + const atomBytes = bytes.slice(offset + 1, offset + 1 + length); |
| 931 | + const atomString = decodeAtomBytes(atomBytes, isUtf8); |
| 932 | + |
| 933 | + return { |
| 934 | + term: Type.atom(atomString), |
| 935 | + newOffset: offset + 1 + length, |
| 936 | + }; |
| 937 | + }; |
| 938 | + |
| 939 | + const decodeBinary = (dataView, bytes, offset) => { |
| 940 | + const length = dataView.getUint32(offset); |
| 941 | + |
| 942 | + if (offset + 4 + length > bytes.length) raiseInvalid(); |
| 943 | + |
| 944 | + // Uint8Array.slice already returns a fresh Uint8Array, so passing it |
| 945 | + // straight into Bitstring.fromBytes avoids the extra copy that |
| 946 | + // `new Uint8Array(binaryBytes)` would do. |
| 947 | + const binaryBytes = bytes.slice(offset + 4, offset + 4 + length); |
| 948 | + |
| 949 | + return { |
| 950 | + term: Bitstring.fromBytes(binaryBytes), |
| 951 | + newOffset: offset + 4 + length, |
| 952 | + }; |
| 953 | + }; |
| 954 | + |
| 955 | + const decodeSmallTuple = (dataView, bytes, offset) => { |
| 956 | + const arity = dataView.getUint8(offset); |
| 957 | + const elements = []; |
| 958 | + let currentOffset = offset + 1; |
| 959 | + |
| 960 | + for (let i = 0; i < arity; i++) { |
| 961 | + const result = decodeTerm(dataView, bytes, currentOffset); |
| 962 | + elements.push(result.term); |
| 963 | + currentOffset = result.newOffset; |
| 964 | + } |
| 965 | + |
| 966 | + return { |
| 967 | + term: Type.tuple(elements), |
| 968 | + newOffset: currentOffset, |
| 969 | + }; |
| 970 | + }; |
| 971 | + |
| 972 | + const decodeLargeTuple = (dataView, bytes, offset) => { |
| 973 | + const arity = dataView.getUint32(offset); |
| 974 | + const elements = []; |
| 975 | + let currentOffset = offset + 4; |
| 976 | + |
| 977 | + for (let i = 0; i < arity; i++) { |
| 978 | + const result = decodeTerm(dataView, bytes, currentOffset); |
| 979 | + elements.push(result.term); |
| 980 | + currentOffset = result.newOffset; |
| 981 | + } |
| 982 | + |
| 983 | + return { |
| 984 | + term: Type.tuple(elements), |
| 985 | + newOffset: currentOffset, |
| 986 | + }; |
| 987 | + }; |
| 988 | + |
| 989 | + const decodeString = (dataView, bytes, offset) => { |
| 990 | + const length = dataView.getUint16(offset); |
| 991 | + if (offset + 2 + length > bytes.length) raiseInvalid(); |
| 992 | + const elements = []; |
| 993 | + |
| 994 | + for (let i = 0; i < length; i++) { |
| 995 | + const byte = bytes[offset + 2 + i]; |
| 996 | + elements.push(Type.integer(byte)); |
| 997 | + } |
| 998 | + |
| 999 | + return { |
| 1000 | + term: Type.list(elements), |
| 1001 | + newOffset: offset + 2 + length, |
| 1002 | + }; |
| 1003 | + }; |
| 1004 | + |
| 1005 | + const decodeList = (dataView, bytes, offset) => { |
| 1006 | + const length = dataView.getUint32(offset); |
| 1007 | + const elements = []; |
| 1008 | + let currentOffset = offset + 4; |
| 1009 | + |
| 1010 | + for (let i = 0; i < length; i++) { |
| 1011 | + const result = decodeTerm(dataView, bytes, currentOffset); |
| 1012 | + elements.push(result.term); |
| 1013 | + currentOffset = result.newOffset; |
| 1014 | + } |
| 1015 | + |
| 1016 | + const tailResult = decodeTerm(dataView, bytes, currentOffset); |
| 1017 | + currentOffset = tailResult.newOffset; |
| 1018 | + |
| 1019 | + // LIST_EXT with length=0 collapses to its tail. OTP accepts this and |
| 1020 | + // returns the tail term as-is (e.g. <<131,108,0,0,0,0,97,1>> decodes |
| 1021 | + // to the integer 1). Without this short-circuit, a non-list tail would |
| 1022 | + // hit Type.improperList([tail]) below and crash with |
| 1023 | + // HologramInterpreterError ("improper list must have at least 2 items"). |
| 1024 | + if (length === 0) { |
| 1025 | + return {term: tailResult.term, newOffset: currentOffset}; |
| 1026 | + } |
| 1027 | + |
| 1028 | + // If tail is a list, merge it to preserve proper list semantics |
| 1029 | + if (Type.isList(tailResult.term)) { |
| 1030 | + const merged = elements.concat(tailResult.term.data); |
| 1031 | + |
| 1032 | + return Type.isProperList(tailResult.term) |
| 1033 | + ? {term: Type.list(merged), newOffset: currentOffset} |
| 1034 | + : {term: Type.improperList(merged), newOffset: currentOffset}; |
| 1035 | + } |
| 1036 | + |
| 1037 | + elements.push(tailResult.term); |
| 1038 | + |
| 1039 | + return { |
| 1040 | + term: Type.improperList(elements), |
| 1041 | + newOffset: currentOffset, |
| 1042 | + }; |
| 1043 | + }; |
| 1044 | + |
| 1045 | + const decodeMap = (dataView, bytes, offset) => { |
| 1046 | + const arity = dataView.getUint32(offset); |
| 1047 | + const entries = []; |
| 1048 | + const seenKeys = new Set(); |
| 1049 | + let currentOffset = offset + 4; |
| 1050 | + |
| 1051 | + for (let i = 0; i < arity; i++) { |
| 1052 | + const keyResult = decodeTerm(dataView, bytes, currentOffset); |
| 1053 | + const valueResult = decodeTerm(dataView, bytes, keyResult.newOffset); |
| 1054 | + |
| 1055 | + // Spec: MAP_EXT keys must be unique. OTP raises badarg on duplicates; |
| 1056 | + // without this check we'd silently apply Type.map's last-write-wins |
| 1057 | + // semantics and diverge from OTP. |
| 1058 | + const encodedKey = Type.encodeMapKey(keyResult.term); |
| 1059 | + if (seenKeys.has(encodedKey)) raiseInvalid(); |
| 1060 | + seenKeys.add(encodedKey); |
| 1061 | + |
| 1062 | + entries.push([keyResult.term, valueResult.term]); |
| 1063 | + currentOffset = valueResult.newOffset; |
| 1064 | + } |
| 1065 | + |
| 1066 | + return { |
| 1067 | + term: Type.map(entries), |
| 1068 | + newOffset: currentOffset, |
| 1069 | + }; |
| 1070 | + }; |
| 1071 | + |
| 1072 | + const decodeNewFloat = (dataView, offset) => { |
| 1073 | + const value = dataView.getFloat64(offset); |
| 1074 | + |
| 1075 | + // OTP rejects non-finite floats (NaN, +Inf, -Inf): Erlang floats must |
| 1076 | + // be finite. Without this check, hand-crafted IEEE 754 bit patterns |
| 1077 | + // would produce a Type.float(NaN) / Type.float(Infinity) and diverge. |
| 1078 | + if (!Number.isFinite(value)) raiseInvalid(); |
| 1079 | + |
| 1080 | + return { |
| 1081 | + term: Type.float(value), |
| 1082 | + newOffset: offset + 8, |
| 1083 | + }; |
| 1084 | + }; |
| 1085 | + |
| 1086 | + const decodeFloatExt = (dataView, bytes, offset) => { |
| 1087 | + // FLOAT_EXT: 31-byte null-terminated string (deprecated format) |
| 1088 | + if (offset + 31 > bytes.length) raiseInvalid(); |
| 1089 | + |
| 1090 | + const floatBytes = bytes.slice(offset, offset + 31); |
| 1091 | + |
| 1092 | + const floatString = String.fromCharCode(...floatBytes).replace( |
| 1093 | + /\0.*/, |
| 1094 | + "", |
| 1095 | + ); |
| 1096 | + |
| 1097 | + const value = parseFloat(floatString); |
| 1098 | + |
| 1099 | + // !isFinite covers NaN and +/-Infinity. parseFloat returns NaN for |
| 1100 | + // unparseable strings ("nan", "inf") and Infinity for "Infinity"; |
| 1101 | + // OTP rejects all of them. |
| 1102 | + if (!Number.isFinite(value)) raiseInvalid(); |
| 1103 | + |
| 1104 | + return { |
| 1105 | + term: Type.float(value), |
| 1106 | + newOffset: offset + 31, |
| 1107 | + }; |
| 1108 | + }; |
| 1109 | + |
| 1110 | + const decodeBitBinary = (dataView, bytes, offset) => { |
| 1111 | + const length = dataView.getUint32(offset); |
| 1112 | + const bits = dataView.getUint8(offset + 4); |
| 1113 | + |
| 1114 | + // Reject BIT_BINARY_EXT with Length=0 (no data byte for trailing bits |
| 1115 | + // to attach to) or with Bits outside 1..8. |
| 1116 | + // Note: OTP also rejects all of these except the specific Length=0, |
| 1117 | + // Bits=0 pattern, which crashes the VM with a giant binary alloc |
| 1118 | + // (real OTP bug). We deliberately reject that case cleanly here. |
| 1119 | + if (length === 0 || bits < 1 || bits > 8) raiseInvalid(); |
| 1120 | + |
| 1121 | + if (offset + 5 + length > bytes.length) raiseInvalid(); |
| 1122 | + |
| 1123 | + // bytes.slice already returns a fresh Uint8Array; no need to copy |
| 1124 | + // again via `new Uint8Array(...)`. |
| 1125 | + const binaryBytes = bytes.slice(offset + 5, offset + 5 + length); |
| 1126 | + const bitstring = Bitstring.fromBytes(binaryBytes); |
| 1127 | + |
| 1128 | + if (bits < 8) { |
| 1129 | + bitstring.leftoverBitCount = bits; |
| 1130 | + } |
| 1131 | + |
| 1132 | + return { |
| 1133 | + term: bitstring, |
| 1134 | + newOffset: offset + 5 + length, |
| 1135 | + }; |
| 1136 | + }; |
| 1137 | + |
| 1138 | + const decodeReferenceWithOptions = (dataView, bytes, offset, options) => { |
| 1139 | + let currentOffset = offset; |
| 1140 | + let idWordCount = 1; // Default for REFERENCE_EXT |
| 1141 | + |
| 1142 | + if (options.hasLengthPrefix) { |
| 1143 | + idWordCount = dataView.getUint16(currentOffset); |
| 1144 | + currentOffset += 2; |
| 1145 | + |
| 1146 | + // OTP caps both NEW_REFERENCE_EXT and NEWER_REFERENCE_EXT at 5 ID |
| 1147 | + // words regardless of what the wire format permits (uint16). Without |
| 1148 | + // this check a Len of e.g. 65535 would attempt a 256KB read - caught |
| 1149 | + // by the outer wrapper as RangeError, but we lose the early-exit and |
| 1150 | + // diverge from OTP for moderately oversized values like Len=6. |
| 1151 | + if (idWordCount > 5) raiseInvalid(); |
| 1152 | + } |
| 1153 | + |
| 1154 | + const nodeResult = decodeTerm(dataView, bytes, currentOffset); |
| 1155 | + currentOffset = nodeResult.newOffset; |
| 1156 | + |
| 1157 | + // For REFERENCE_EXT: read ID words first, then creation |
| 1158 | + // For NEW/NEWER_REFERENCE_EXT: read creation first, then ID words |
| 1159 | + let creation, idWords; |
| 1160 | + |
| 1161 | + if (options.hasLengthPrefix) { |
| 1162 | + creation = |
| 1163 | + options.creationSize === 4 |
| 1164 | + ? dataView.getUint32(currentOffset) |
| 1165 | + : dataView.getUint8(currentOffset); |
| 1166 | + |
| 1167 | + currentOffset += options.creationSize; |
| 1168 | + |
| 1169 | + idWords = []; |
| 1170 | + for (let i = 0; i < idWordCount; i++) { |
| 1171 | + idWords.push(dataView.getUint32(currentOffset)); |
| 1172 | + currentOffset += 4; |
| 1173 | + } |
| 1174 | + } else { |
| 1175 | + idWords = []; |
| 1176 | + for (let i = 0; i < idWordCount; i++) { |
| 1177 | + idWords.push(dataView.getUint32(currentOffset)); |
| 1178 | + currentOffset += 4; |
| 1179 | + } |
| 1180 | + |
| 1181 | + creation = dataView.getUint8(currentOffset); |
| 1182 | + currentOffset += 1; |
| 1183 | + } |
| 1184 | + |
| 1185 | + return { |
| 1186 | + term: Type.reference(nodeResult.term, creation, idWords), |
| 1187 | + newOffset: currentOffset, |
| 1188 | + }; |
| 1189 | + }; |
| 1190 | + |
| 1191 | + const decodeReference = (dataView, bytes, offset) => { |
| 1192 | + return decodeReferenceWithOptions(dataView, bytes, offset, { |
| 1193 | + hasLengthPrefix: false, |
| 1194 | + creationSize: 1, |
| 1195 | + }); |
| 1196 | + }; |
| 1197 | + |
| 1198 | + const decodeNewReference = (dataView, bytes, offset) => { |
| 1199 | + return decodeReferenceWithOptions(dataView, bytes, offset, { |
| 1200 | + hasLengthPrefix: true, |
| 1201 | + creationSize: 1, |
| 1202 | + }); |
| 1203 | + }; |
| 1204 | + |
| 1205 | + const decodeNewerReference = (dataView, bytes, offset) => { |
| 1206 | + return decodeReferenceWithOptions(dataView, bytes, offset, { |
| 1207 | + hasLengthPrefix: true, |
| 1208 | + creationSize: 4, |
| 1209 | + }); |
| 1210 | + }; |
| 1211 | + |
| 1212 | + // The ETF spec describes "only N bits significant" for several fields |
| 1213 | + // (PID_EXT id 15 bits, serial 13 bits; PORT_EXT / NEW_PORT_EXT id 28 bits; |
| 1214 | + // PID_EXT / PORT_EXT creation 2 bits; NEW_PORT_EXT creation == 0 is |
| 1215 | + // "reserved"). Empirically OTP does not validate any of these - it accepts |
| 1216 | + // the full 32-bit / 64-bit values as written and accepts creation == 0 - |
| 1217 | + // so we deliberately do not validate either. Adding such checks would |
| 1218 | + // diverge from OTP rather than match it. |
| 1219 | + |
| 1220 | + const decodePid = (dataView, bytes, offset) => { |
| 1221 | + let currentOffset = offset; |
| 1222 | + |
| 1223 | + const nodeResult = decodeTerm(dataView, bytes, currentOffset); |
| 1224 | + currentOffset = nodeResult.newOffset; |
| 1225 | + |
| 1226 | + // Read ID (4 bytes), Serial (4 bytes), Creation (1 byte) |
| 1227 | + const id = dataView.getUint32(currentOffset); |
| 1228 | + currentOffset += 4; |
| 1229 | + |
| 1230 | + const serial = dataView.getUint32(currentOffset); |
| 1231 | + currentOffset += 4; |
| 1232 | + |
| 1233 | + const creation = dataView.getUint8(currentOffset); |
| 1234 | + currentOffset += 1; |
| 1235 | + |
| 1236 | + return { |
| 1237 | + term: Type.pid(nodeResult.term, [id, serial, creation]), |
| 1238 | + newOffset: currentOffset, |
| 1239 | + }; |
| 1240 | + }; |
| 1241 | + |
| 1242 | + const decodeNewPid = (dataView, bytes, offset) => { |
| 1243 | + let currentOffset = offset; |
| 1244 | + |
| 1245 | + const nodeResult = decodeTerm(dataView, bytes, currentOffset); |
| 1246 | + currentOffset = nodeResult.newOffset; |
| 1247 | + |
| 1248 | + // Read ID (4 bytes), Serial (4 bytes), Creation (4 bytes) |
| 1249 | + const id = dataView.getUint32(currentOffset); |
| 1250 | + currentOffset += 4; |
| 1251 | + |
| 1252 | + const serial = dataView.getUint32(currentOffset); |
| 1253 | + currentOffset += 4; |
| 1254 | + |
| 1255 | + const creation = dataView.getUint32(currentOffset); |
| 1256 | + currentOffset += 4; |
| 1257 | + |
| 1258 | + return { |
| 1259 | + term: Type.pid(nodeResult.term, [id, serial, creation]), |
| 1260 | + newOffset: currentOffset, |
| 1261 | + }; |
| 1262 | + }; |
| 1263 | + |
| 1264 | + const decodePort = (dataView, bytes, offset) => { |
| 1265 | + let currentOffset = offset; |
| 1266 | + |
| 1267 | + const nodeResult = decodeTerm(dataView, bytes, currentOffset); |
| 1268 | + currentOffset = nodeResult.newOffset; |
| 1269 | + |
| 1270 | + // Read ID (4 bytes), Creation (1 byte) |
| 1271 | + const id = dataView.getUint32(currentOffset); |
| 1272 | + currentOffset += 4; |
| 1273 | + |
| 1274 | + const creation = dataView.getUint8(currentOffset); |
| 1275 | + currentOffset += 1; |
| 1276 | + |
| 1277 | + return { |
| 1278 | + term: Type.port(nodeResult.term, [id, creation]), |
| 1279 | + newOffset: currentOffset, |
| 1280 | + }; |
| 1281 | + }; |
| 1282 | + |
| 1283 | + const decodeNewPort = (dataView, bytes, offset) => { |
| 1284 | + let currentOffset = offset; |
| 1285 | + |
| 1286 | + const nodeResult = decodeTerm(dataView, bytes, currentOffset); |
| 1287 | + currentOffset = nodeResult.newOffset; |
| 1288 | + |
| 1289 | + // Read ID (4 bytes), Creation (4 bytes) |
| 1290 | + const id = dataView.getUint32(currentOffset); |
| 1291 | + currentOffset += 4; |
| 1292 | + |
| 1293 | + const creation = dataView.getUint32(currentOffset); |
| 1294 | + currentOffset += 4; |
| 1295 | + |
| 1296 | + return { |
| 1297 | + term: Type.port(nodeResult.term, [id, creation]), |
| 1298 | + newOffset: currentOffset, |
| 1299 | + }; |
| 1300 | + }; |
| 1301 | + |
| 1302 | + const decodeV4Port = (dataView, bytes, offset) => { |
| 1303 | + let currentOffset = offset; |
| 1304 | + |
| 1305 | + const nodeResult = decodeTerm(dataView, bytes, currentOffset); |
| 1306 | + currentOffset = nodeResult.newOffset; |
| 1307 | + |
| 1308 | + // Read ID (8 bytes as BigUint64), Creation (4 bytes) |
| 1309 | + const id = dataView.getBigUint64(currentOffset); |
| 1310 | + currentOffset += 8; |
| 1311 | + |
| 1312 | + const creation = dataView.getUint32(currentOffset); |
| 1313 | + currentOffset += 4; |
| 1314 | + |
| 1315 | + return { |
| 1316 | + term: Type.port(nodeResult.term, [id, creation]), |
| 1317 | + newOffset: currentOffset, |
| 1318 | + }; |
| 1319 | + }; |
| 1320 | + |
| 1321 | + const decodeExport = (dataView, bytes, offset) => { |
| 1322 | + let currentOffset = offset; |
| 1323 | + |
| 1324 | + const moduleResult = decodeTerm(dataView, bytes, currentOffset); |
| 1325 | + currentOffset = moduleResult.newOffset; |
| 1326 | + |
| 1327 | + const functionResult = decodeTerm(dataView, bytes, currentOffset); |
| 1328 | + currentOffset = functionResult.newOffset; |
| 1329 | + |
| 1330 | + const arityResult = decodeTerm(dataView, bytes, currentOffset); |
| 1331 | + currentOffset = arityResult.newOffset; |
| 1332 | + |
| 1333 | + // Spec: Module and Function must be atoms, Arity must be an integer. |
| 1334 | + // OTP raises badarg when any component has the wrong shape; without |
| 1335 | + // this check we'd reach .value on the wrong term and either throw |
| 1336 | + // (relabelled by the outer wrapper) or build a malformed function. |
| 1337 | + if ( |
| 1338 | + !Type.isAtom(moduleResult.term) || |
| 1339 | + !Type.isAtom(functionResult.term) || |
| 1340 | + !Type.isInteger(arityResult.term) |
| 1341 | + ) { |
| 1342 | + raiseInvalid(); |
| 1343 | + } |
| 1344 | + |
| 1345 | + const context = Interpreter.buildContext(); |
| 1346 | + |
| 1347 | + const arity = Number(arityResult.term.value); |
| 1348 | + |
| 1349 | + return { |
| 1350 | + term: Type.functionCapture( |
| 1351 | + moduleResult.term.value, |
| 1352 | + functionResult.term.value, |
| 1353 | + arity, |
| 1354 | + [], |
| 1355 | + context, |
| 1356 | + ), |
| 1357 | + newOffset: currentOffset, |
| 1358 | + }; |
| 1359 | + }; |
| 1360 | + |
| 1361 | + try { |
| 1362 | + Bitstring.maybeSetBytesFromText(binary); |
| 1363 | + |
| 1364 | + const bytes = binary.bytes; |
| 1365 | + |
| 1366 | + const dataView = new DataView( |
| 1367 | + bytes.buffer, |
| 1368 | + bytes.byteOffset, |
| 1369 | + bytes.byteLength, |
| 1370 | + ); |
| 1371 | + |
| 1372 | + if (dataView.getUint8(0) !== VERSION_MAGIC) raiseInvalid(); |
| 1373 | + |
| 1374 | + // COMPRESSED is only valid immediately after the version byte. OTP |
| 1375 | + // rejects nested COMPRESSED tags, so handling it here (rather than as |
| 1376 | + // a case in decodeTerm's switch) keeps the recursive decoder sync. |
| 1377 | + // Format: 80 | UncompressedSize (uint32 BE) | zlib-compressed payload. |
| 1378 | + if (dataView.getUint8(1) === COMPRESSED) { |
| 1379 | + if (bytes.length < 6) raiseInvalid(); |
| 1380 | + |
| 1381 | + const uncompressedSize = dataView.getUint32(2, false); |
| 1382 | + const compressedData = bytes.slice(6); |
| 1383 | + |
| 1384 | + let decompressed; |
| 1385 | + try { |
| 1386 | + decompressed = await zlibInflate(compressedData); |
| 1387 | + } catch { |
| 1388 | + raiseInvalid(); |
| 1389 | + } |
| 1390 | + |
| 1391 | + if (decompressed.length !== uncompressedSize) raiseInvalid(); |
| 1392 | + |
| 1393 | + const decompressedView = new DataView( |
| 1394 | + decompressed.buffer, |
| 1395 | + decompressed.byteOffset, |
| 1396 | + decompressed.byteLength, |
| 1397 | + ); |
| 1398 | + |
| 1399 | + const innerResult = decodeTerm(decompressedView, decompressed, 0); |
| 1400 | + |
| 1401 | + // Trailing bytes inside the decompressed payload are rejected by |
| 1402 | + // OTP (the outer binary may have trailing bytes; the decompressed |
| 1403 | + // term cannot). |
| 1404 | + if (innerResult.newOffset !== decompressed.length) raiseInvalid(); |
| 1405 | + |
| 1406 | + return innerResult.term; |
| 1407 | + } |
| 1408 | + |
| 1409 | + // OTP's binary_to_term/1 silently ignores any bytes past the first |
| 1410 | + // term: term_to_binary(42) <> "garbage" decodes to 42. |
| 1411 | + return decodeTerm(dataView, bytes, 1).term; |
| 1412 | + } catch (err) { |
| 1413 | + // RangeError is the only generic signal for malformed input we accept |
| 1414 | + // here: DataView.getXxx throws it when reading past the end of the |
| 1415 | + // buffer, which any decoder can hit on truncated input. Everything |
| 1416 | + // else - TypeError, HologramInterpreterError, etc. - is treated as a |
| 1417 | + // real bug and bubbles up. Decoder helpers that have other legitimate |
| 1418 | + // failure modes (e.g. invalid UTF-8 in atom names) raise ArgumentError |
| 1419 | + // at the call site instead of relying on this wrapper. |
| 1420 | + if (err instanceof RangeError) raiseInvalid(); |
| 1421 | + throw err; |
| 1422 | + } |
| 1423 | + }, |
| 1424 | + // End binary_to_term/1 |
| 1425 | + // Deps: [] |
| 1426 | + |
| 617 1427 | // Start bit_size/1 |
| 618 1428 | "bit_size/1": (term) => { |
| 619 1429 | if (!Type.isBitstring(term)) { |
Loading more files…