Current section

26 Versions

Jump to

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…