@freqhole/haruspex 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/dist/flows/add-peer/machine.d.ts +36 -0
  2. package/dist/flows/add-peer/machine.d.ts.map +1 -0
  3. package/dist/flows/add-peer/machine.js +500 -0
  4. package/dist/flows/add-peer/machine.test.d.ts +2 -0
  5. package/dist/flows/add-peer/machine.test.d.ts.map +1 -0
  6. package/dist/flows/add-peer/machine.test.js +421 -0
  7. package/dist/flows/add-peer/runner.d.ts +16 -0
  8. package/dist/flows/add-peer/runner.d.ts.map +1 -0
  9. package/dist/flows/add-peer/runner.js +349 -0
  10. package/dist/flows/add-peer/runner.test.d.ts +2 -0
  11. package/dist/flows/add-peer/runner.test.d.ts.map +1 -0
  12. package/dist/flows/add-peer/runner.test.js +205 -0
  13. package/dist/flows/add-peer/types.d.ts +271 -0
  14. package/dist/flows/add-peer/types.d.ts.map +1 -0
  15. package/dist/flows/add-peer/types.js +10 -0
  16. package/dist/flows/index.d.ts +6 -0
  17. package/dist/flows/index.d.ts.map +1 -0
  18. package/dist/flows/index.js +6 -0
  19. package/dist/identity/idbStore.d.ts +51 -0
  20. package/dist/identity/idbStore.d.ts.map +1 -0
  21. package/dist/identity/idbStore.js +183 -0
  22. package/dist/identity/idbStore.test.d.ts +2 -0
  23. package/dist/identity/idbStore.test.d.ts.map +1 -0
  24. package/dist/identity/idbStore.test.js +103 -0
  25. package/dist/identity/index.d.ts +8 -0
  26. package/dist/identity/index.d.ts.map +1 -0
  27. package/dist/identity/index.js +3 -0
  28. package/dist/identity/resolve.d.ts +31 -0
  29. package/dist/identity/resolve.d.ts.map +1 -0
  30. package/dist/identity/resolve.js +37 -0
  31. package/dist/identity/resolve.test.d.ts +2 -0
  32. package/dist/identity/resolve.test.d.ts.map +1 -0
  33. package/dist/identity/resolve.test.js +105 -0
  34. package/dist/identity/types.d.ts +21 -0
  35. package/dist/identity/types.d.ts.map +1 -0
  36. package/dist/identity/types.js +1 -0
  37. package/dist/identity/webLocks.d.ts +22 -0
  38. package/dist/identity/webLocks.d.ts.map +1 -0
  39. package/dist/identity/webLocks.js +67 -0
  40. package/dist/identity/webLocks.test.d.ts +2 -0
  41. package/dist/identity/webLocks.test.d.ts.map +1 -0
  42. package/dist/identity/webLocks.test.js +125 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +1 -0
  46. package/dist/index.test.d.ts +2 -0
  47. package/dist/index.test.d.ts.map +1 -0
  48. package/dist/index.test.js +7 -0
  49. package/dist/knock/index.d.ts +10 -0
  50. package/dist/knock/index.d.ts.map +1 -0
  51. package/dist/knock/index.js +5 -0
  52. package/dist/knock/pending-checker.d.ts +19 -0
  53. package/dist/knock/pending-checker.d.ts.map +1 -0
  54. package/dist/knock/pending-checker.js +27 -0
  55. package/dist/knock/pending-checker.test.d.ts +2 -0
  56. package/dist/knock/pending-checker.test.d.ts.map +1 -0
  57. package/dist/knock/pending-checker.test.js +93 -0
  58. package/dist/knock/requester.d.ts +26 -0
  59. package/dist/knock/requester.d.ts.map +1 -0
  60. package/dist/knock/requester.js +72 -0
  61. package/dist/knock/requester.test.d.ts +2 -0
  62. package/dist/knock/requester.test.d.ts.map +1 -0
  63. package/dist/knock/requester.test.js +108 -0
  64. package/dist/knock/responder.d.ts +13 -0
  65. package/dist/knock/responder.d.ts.map +1 -0
  66. package/dist/knock/responder.js +40 -0
  67. package/dist/knock/responder.test.d.ts +2 -0
  68. package/dist/knock/responder.test.d.ts.map +1 -0
  69. package/dist/knock/responder.test.js +89 -0
  70. package/dist/knock/store.d.ts +11 -0
  71. package/dist/knock/store.d.ts.map +1 -0
  72. package/dist/knock/store.js +211 -0
  73. package/dist/knock/store.test.d.ts +2 -0
  74. package/dist/knock/store.test.d.ts.map +1 -0
  75. package/dist/knock/store.test.js +195 -0
  76. package/dist/knock/types.d.ts +144 -0
  77. package/dist/knock/types.d.ts.map +1 -0
  78. package/dist/knock/types.js +26 -0
  79. package/dist/permissions/access.d.ts +54 -0
  80. package/dist/permissions/access.d.ts.map +1 -0
  81. package/dist/permissions/access.js +56 -0
  82. package/dist/permissions/access.test.d.ts +2 -0
  83. package/dist/permissions/access.test.d.ts.map +1 -0
  84. package/dist/permissions/access.test.js +72 -0
  85. package/dist/permissions/index.d.ts +5 -0
  86. package/dist/permissions/index.d.ts.map +1 -0
  87. package/dist/permissions/index.js +2 -0
  88. package/dist/permissions/role-table.d.ts +11 -0
  89. package/dist/permissions/role-table.d.ts.map +1 -0
  90. package/dist/permissions/role-table.js +16 -0
  91. package/dist/protocol/client.d.ts +67 -0
  92. package/dist/protocol/client.d.ts.map +1 -0
  93. package/dist/protocol/client.js +195 -0
  94. package/dist/protocol/client.test.d.ts +2 -0
  95. package/dist/protocol/client.test.d.ts.map +1 -0
  96. package/dist/protocol/client.test.js +282 -0
  97. package/dist/protocol/codec.d.ts +45 -0
  98. package/dist/protocol/codec.d.ts.map +1 -0
  99. package/dist/protocol/codec.js +111 -0
  100. package/dist/protocol/codec.test.d.ts +2 -0
  101. package/dist/protocol/codec.test.d.ts.map +1 -0
  102. package/dist/protocol/codec.test.js +93 -0
  103. package/dist/protocol/extensions.d.ts +33 -0
  104. package/dist/protocol/extensions.d.ts.map +1 -0
  105. package/dist/protocol/extensions.js +36 -0
  106. package/dist/protocol/extensions.test.d.ts +2 -0
  107. package/dist/protocol/extensions.test.d.ts.map +1 -0
  108. package/dist/protocol/extensions.test.js +65 -0
  109. package/dist/protocol/fixtures.test.d.ts +2 -0
  110. package/dist/protocol/fixtures.test.d.ts.map +1 -0
  111. package/dist/protocol/fixtures.test.js +51 -0
  112. package/dist/protocol/index.d.ts +9 -0
  113. package/dist/protocol/index.d.ts.map +1 -0
  114. package/dist/protocol/index.js +4 -0
  115. package/dist/protocol/messages.d.ts +480 -0
  116. package/dist/protocol/messages.d.ts.map +1 -0
  117. package/dist/protocol/messages.js +255 -0
  118. package/dist/protocol/messages.test.d.ts +2 -0
  119. package/dist/protocol/messages.test.d.ts.map +1 -0
  120. package/dist/protocol/messages.test.js +75 -0
  121. package/dist/share/codec.d.ts +58 -0
  122. package/dist/share/codec.d.ts.map +1 -0
  123. package/dist/share/codec.js +222 -0
  124. package/dist/share/codec.test.d.ts +2 -0
  125. package/dist/share/codec.test.d.ts.map +1 -0
  126. package/dist/share/codec.test.js +150 -0
  127. package/dist/share/detect.d.ts +24 -0
  128. package/dist/share/detect.d.ts.map +1 -0
  129. package/dist/share/detect.js +25 -0
  130. package/dist/share/detect.test.d.ts +2 -0
  131. package/dist/share/detect.test.d.ts.map +1 -0
  132. package/dist/share/detect.test.js +31 -0
  133. package/dist/share/index.d.ts +7 -0
  134. package/dist/share/index.d.ts.map +1 -0
  135. package/dist/share/index.js +3 -0
  136. package/dist/share/peer-addr.d.ts +32 -0
  137. package/dist/share/peer-addr.d.ts.map +1 -0
  138. package/dist/share/peer-addr.js +72 -0
  139. package/dist/share/peer-addr.test.d.ts +2 -0
  140. package/dist/share/peer-addr.test.d.ts.map +1 -0
  141. package/dist/share/peer-addr.test.js +58 -0
  142. package/dist/solid/auth-form.d.ts +43 -0
  143. package/dist/solid/auth-form.d.ts.map +1 -0
  144. package/dist/solid/auth-form.js +55 -0
  145. package/dist/solid/auth-form.test.d.ts +2 -0
  146. package/dist/solid/auth-form.test.d.ts.map +1 -0
  147. package/dist/solid/auth-form.test.js +131 -0
  148. package/dist/solid/auth-status.d.ts +21 -0
  149. package/dist/solid/auth-status.d.ts.map +1 -0
  150. package/dist/solid/auth-status.js +58 -0
  151. package/dist/solid/auth-status.test.d.ts +2 -0
  152. package/dist/solid/auth-status.test.d.ts.map +1 -0
  153. package/dist/solid/auth-status.test.js +60 -0
  154. package/dist/solid/index.d.ts +5 -0
  155. package/dist/solid/index.d.ts.map +1 -0
  156. package/dist/solid/index.js +10 -0
  157. package/dist/solid/knock-inbox.d.ts +61 -0
  158. package/dist/solid/knock-inbox.d.ts.map +1 -0
  159. package/dist/solid/knock-inbox.js +123 -0
  160. package/dist/solid/knock-inbox.test.d.ts +2 -0
  161. package/dist/solid/knock-inbox.test.d.ts.map +1 -0
  162. package/dist/solid/knock-inbox.test.js +153 -0
  163. package/dist/solid/reauth-flow.d.ts +26 -0
  164. package/dist/solid/reauth-flow.d.ts.map +1 -0
  165. package/dist/solid/reauth-flow.js +34 -0
  166. package/dist/solid/reauth-flow.test.d.ts +2 -0
  167. package/dist/solid/reauth-flow.test.d.ts.map +1 -0
  168. package/dist/solid/reauth-flow.test.js +91 -0
  169. package/dist/state/endpoint-control.d.ts +22 -0
  170. package/dist/state/endpoint-control.d.ts.map +1 -0
  171. package/dist/state/endpoint-control.js +34 -0
  172. package/dist/state/endpoint-control.test.d.ts +2 -0
  173. package/dist/state/endpoint-control.test.d.ts.map +1 -0
  174. package/dist/state/endpoint-control.test.js +63 -0
  175. package/dist/state/friend-directory.d.ts +37 -0
  176. package/dist/state/friend-directory.d.ts.map +1 -0
  177. package/dist/state/friend-directory.js +44 -0
  178. package/dist/state/friend-directory.test.d.ts +2 -0
  179. package/dist/state/friend-directory.test.d.ts.map +1 -0
  180. package/dist/state/friend-directory.test.js +63 -0
  181. package/dist/state/index.d.ts +6 -0
  182. package/dist/state/index.d.ts.map +1 -0
  183. package/dist/state/index.js +3 -0
  184. package/dist/state/peer-names.d.ts +7 -0
  185. package/dist/state/peer-names.d.ts.map +1 -0
  186. package/dist/state/peer-names.js +21 -0
  187. package/dist/state/peer-names.test.d.ts +2 -0
  188. package/dist/state/peer-names.test.d.ts.map +1 -0
  189. package/dist/state/peer-names.test.js +29 -0
  190. package/dist/testing/idb-harness.d.ts +9 -0
  191. package/dist/testing/idb-harness.d.ts.map +1 -0
  192. package/dist/testing/idb-harness.js +19 -0
  193. package/dist/testing/idb-harness.test.d.ts +2 -0
  194. package/dist/testing/idb-harness.test.d.ts.map +1 -0
  195. package/dist/testing/idb-harness.test.js +18 -0
  196. package/dist/testing/identity-fixtures.d.ts +10 -0
  197. package/dist/testing/identity-fixtures.d.ts.map +1 -0
  198. package/dist/testing/identity-fixtures.js +21 -0
  199. package/dist/testing/identity-fixtures.test.d.ts +2 -0
  200. package/dist/testing/identity-fixtures.test.d.ts.map +1 -0
  201. package/dist/testing/identity-fixtures.test.js +28 -0
  202. package/dist/testing/index.d.ts +7 -0
  203. package/dist/testing/index.d.ts.map +1 -0
  204. package/dist/testing/index.js +11 -0
  205. package/dist/testing/knock-fixtures.d.ts +13 -0
  206. package/dist/testing/knock-fixtures.d.ts.map +1 -0
  207. package/dist/testing/knock-fixtures.js +45 -0
  208. package/dist/testing/knock-fixtures.test.d.ts +2 -0
  209. package/dist/testing/knock-fixtures.test.d.ts.map +1 -0
  210. package/dist/testing/knock-fixtures.test.js +65 -0
  211. package/dist/testing/protocol-fixtures.d.ts +7 -0
  212. package/dist/testing/protocol-fixtures.d.ts.map +1 -0
  213. package/dist/testing/protocol-fixtures.js +28 -0
  214. package/dist/testing/protocol-fixtures.test.d.ts +2 -0
  215. package/dist/testing/protocol-fixtures.test.d.ts.map +1 -0
  216. package/dist/testing/protocol-fixtures.test.js +19 -0
  217. package/dist/testing/state-fixtures.d.ts +33 -0
  218. package/dist/testing/state-fixtures.d.ts.map +1 -0
  219. package/dist/testing/state-fixtures.js +67 -0
  220. package/dist/testing/state-fixtures.test.d.ts +2 -0
  221. package/dist/testing/state-fixtures.test.d.ts.map +1 -0
  222. package/dist/testing/state-fixtures.test.js +68 -0
  223. package/dist/testing/webauthn-mocks.d.ts +27 -0
  224. package/dist/testing/webauthn-mocks.d.ts.map +1 -0
  225. package/dist/testing/webauthn-mocks.js +78 -0
  226. package/dist/testing/webauthn-mocks.test.d.ts +2 -0
  227. package/dist/testing/webauthn-mocks.test.d.ts.map +1 -0
  228. package/dist/testing/webauthn-mocks.test.js +40 -0
  229. package/dist/webauthn/ceremony.d.ts +76 -0
  230. package/dist/webauthn/ceremony.d.ts.map +1 -0
  231. package/dist/webauthn/ceremony.js +136 -0
  232. package/dist/webauthn/ceremony.test.d.ts +2 -0
  233. package/dist/webauthn/ceremony.test.d.ts.map +1 -0
  234. package/dist/webauthn/ceremony.test.js +245 -0
  235. package/dist/webauthn/codec.d.ts +39 -0
  236. package/dist/webauthn/codec.d.ts.map +1 -0
  237. package/dist/webauthn/codec.js +118 -0
  238. package/dist/webauthn/codec.test.d.ts +2 -0
  239. package/dist/webauthn/codec.test.d.ts.map +1 -0
  240. package/dist/webauthn/codec.test.js +126 -0
  241. package/dist/webauthn/index.d.ts +7 -0
  242. package/dist/webauthn/index.d.ts.map +1 -0
  243. package/dist/webauthn/index.js +3 -0
  244. package/dist/webauthn/transport.d.ts +58 -0
  245. package/dist/webauthn/transport.d.ts.map +1 -0
  246. package/dist/webauthn/transport.js +6 -0
  247. package/dist/webauthn/types.d.ts +78 -0
  248. package/dist/webauthn/types.d.ts.map +1 -0
  249. package/dist/webauthn/types.js +29 -0
  250. package/package.json +83 -0
@@ -0,0 +1,282 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createFriendzClient, } from "./client.js";
3
+ const ALPN = "freqhole-friendz/1";
4
+ /** a minimal async queue backing an in-memory duplex BiStreamLike pair. */
5
+ function createQueue() {
6
+ const items = [];
7
+ const waiters = [];
8
+ let closed = false;
9
+ return {
10
+ push(item) {
11
+ if (closed)
12
+ return;
13
+ const waiter = waiters.shift();
14
+ if (waiter)
15
+ waiter(item);
16
+ else
17
+ items.push(item);
18
+ },
19
+ pop() {
20
+ if (items.length > 0)
21
+ return Promise.resolve(items.shift());
22
+ if (closed)
23
+ return Promise.resolve(null);
24
+ return new Promise((resolve) => waiters.push(resolve));
25
+ },
26
+ close() {
27
+ closed = true;
28
+ while (waiters.length > 0)
29
+ waiters.shift()(null);
30
+ },
31
+ };
32
+ }
33
+ /** two BiStreamLike ends wired to each other - no real transport involved. */
34
+ function createDuplexPair(nodeIdA, nodeIdB) {
35
+ const aToB = createQueue();
36
+ const bToA = createQueue();
37
+ const streamA = {
38
+ peer_node_id: () => nodeIdB,
39
+ alpn: () => ALPN,
40
+ write_message: async (data) => {
41
+ aToB.push(data);
42
+ },
43
+ read_message: () => bToA.pop(),
44
+ close: () => {
45
+ aToB.close();
46
+ },
47
+ };
48
+ const streamB = {
49
+ peer_node_id: () => nodeIdA,
50
+ alpn: () => ALPN,
51
+ write_message: async (data) => {
52
+ bToA.push(data);
53
+ },
54
+ read_message: () => aToB.pop(),
55
+ close: () => {
56
+ bToA.close();
57
+ },
58
+ };
59
+ return { streamA, streamB };
60
+ }
61
+ function flush() {
62
+ return new Promise((resolve) => setTimeout(resolve, 0));
63
+ }
64
+ let clients = [];
65
+ function track(client) {
66
+ clients.push(client);
67
+ return client;
68
+ }
69
+ afterEach(() => {
70
+ for (const client of clients)
71
+ client.destroy();
72
+ clients = [];
73
+ vi.useRealTimers();
74
+ });
75
+ describe("createFriendzClient", () => {
76
+ it("delivers a message sent from an opened stream to the accepting side", async () => {
77
+ const { streamA, streamB } = createDuplexPair("node-a", "node-b");
78
+ const received = [];
79
+ const nodeB = track(createFriendzClient({
80
+ getNode: async () => {
81
+ throw new Error("node B never opens outbound in this test");
82
+ },
83
+ localNodeId: "node-b",
84
+ localUsername: "bob",
85
+ onMessage: (message, fromNodeId) => received.push({ message, fromNodeId }),
86
+ }));
87
+ nodeB.handleIncomingStream(streamB);
88
+ const nodeA = track(createFriendzClient({
89
+ getNode: async () => ({
90
+ node_id: () => "node-a",
91
+ open_bi: async () => streamA,
92
+ }),
93
+ localNodeId: "node-a",
94
+ localUsername: "alice",
95
+ }));
96
+ await nodeA.sendMessage("node-b", {
97
+ kind: "core",
98
+ message: {
99
+ type: "knock-request",
100
+ v: 1,
101
+ knockId: "k1",
102
+ nodeId: "node-a",
103
+ message: "hi",
104
+ scope: { kind: "browse" },
105
+ },
106
+ });
107
+ await flush();
108
+ expect(received).toHaveLength(1);
109
+ expect(received[0].fromNodeId).toBe("node-a");
110
+ expect(received[0].message).toMatchObject({
111
+ kind: "core",
112
+ message: { type: "knock-request", knockId: "k1" },
113
+ });
114
+ });
115
+ it("reuses an already-open stream instead of opening a new one", async () => {
116
+ const { streamA, streamB } = createDuplexPair("node-a", "node-b");
117
+ const nodeB = track(createFriendzClient({
118
+ getNode: async () => {
119
+ throw new Error("unused");
120
+ },
121
+ localNodeId: "node-b",
122
+ localUsername: "bob",
123
+ }));
124
+ nodeB.handleIncomingStream(streamB);
125
+ let opens = 0;
126
+ const nodeA = track(createFriendzClient({
127
+ getNode: async () => ({
128
+ node_id: () => "node-a",
129
+ open_bi: async () => {
130
+ opens += 1;
131
+ return streamA;
132
+ },
133
+ }),
134
+ localNodeId: "node-a",
135
+ localUsername: "alice",
136
+ }));
137
+ const msg = {
138
+ kind: "core",
139
+ message: { type: "offline-announcement", v: 1, nodeId: "node-a" },
140
+ };
141
+ await nodeA.sendMessage("node-b", msg);
142
+ await nodeA.sendMessage("node-b", msg);
143
+ expect(opens).toBe(1);
144
+ });
145
+ it("marks a peer online on receiving a heartbeat and fires onPeerBecameOnline once", async () => {
146
+ const { streamA, streamB } = createDuplexPair("node-a", "node-b");
147
+ const becameOnline = [];
148
+ const nodeB = track(createFriendzClient({
149
+ getNode: async () => {
150
+ throw new Error("unused");
151
+ },
152
+ localNodeId: "node-b",
153
+ localUsername: "bob",
154
+ onPeerBecameOnline: (nodeId) => becameOnline.push(nodeId),
155
+ }));
156
+ nodeB.handleIncomingStream(streamB);
157
+ const nodeA = track(createFriendzClient({
158
+ getNode: async () => ({
159
+ node_id: () => "node-a",
160
+ open_bi: async () => streamA,
161
+ }),
162
+ localNodeId: "node-a",
163
+ localUsername: "alice",
164
+ }));
165
+ expect(nodeB.isOnline("node-a")).toBe(false);
166
+ await nodeA.sendMessage("node-a", {
167
+ kind: "core",
168
+ message: { type: "heartbeat", v: 1, nodeId: "node-a", username: "alice" },
169
+ });
170
+ await flush();
171
+ expect(nodeB.isOnline("node-a")).toBe(true);
172
+ expect(nodeB.getOnlinePeers()).toEqual(["node-a"]);
173
+ expect(becameOnline).toEqual(["node-a"]);
174
+ });
175
+ it("marks a peer offline on an explicit offline-announcement", async () => {
176
+ const { streamA, streamB } = createDuplexPair("node-a", "node-b");
177
+ const wentOffline = [];
178
+ const nodeB = track(createFriendzClient({
179
+ getNode: async () => {
180
+ throw new Error("unused");
181
+ },
182
+ localNodeId: "node-b",
183
+ localUsername: "bob",
184
+ onPeerWentOffline: (nodeId) => wentOffline.push(nodeId),
185
+ }));
186
+ nodeB.handleIncomingStream(streamB);
187
+ const nodeA = track(createFriendzClient({
188
+ getNode: async () => ({
189
+ node_id: () => "node-a",
190
+ open_bi: async () => streamA,
191
+ }),
192
+ localNodeId: "node-a",
193
+ localUsername: "alice",
194
+ }));
195
+ await nodeA.sendMessage("node-a", {
196
+ kind: "core",
197
+ message: { type: "heartbeat", v: 1, nodeId: "node-a", username: "alice" },
198
+ });
199
+ await flush();
200
+ expect(nodeB.isOnline("node-a")).toBe(true);
201
+ await nodeA.sendMessage("node-a", {
202
+ kind: "core",
203
+ message: { type: "offline-announcement", v: 1, nodeId: "node-a" },
204
+ });
205
+ await flush();
206
+ expect(nodeB.isOnline("node-a")).toBe(false);
207
+ expect(wentOffline).toEqual(["node-a"]);
208
+ });
209
+ it("calls onDecodeError instead of throwing when a peer sends garbage bytes", async () => {
210
+ const { streamA, streamB } = createDuplexPair("node-a", "node-b");
211
+ const errors = [];
212
+ const nodeB = track(createFriendzClient({
213
+ getNode: async () => {
214
+ throw new Error("unused");
215
+ },
216
+ localNodeId: "node-b",
217
+ localUsername: "bob",
218
+ onDecodeError: (error) => errors.push(error),
219
+ }));
220
+ nodeB.handleIncomingStream(streamB);
221
+ await streamA.write_message(new TextEncoder().encode("not json"));
222
+ await flush();
223
+ expect(errors).toHaveLength(1);
224
+ });
225
+ it("startHeartbeat sends an immediate heartbeat and stopHeartbeat halts further ticks", async () => {
226
+ vi.useFakeTimers();
227
+ const { streamA, streamB } = createDuplexPair("node-a", "node-b");
228
+ const received = [];
229
+ const nodeB = track(createFriendzClient({
230
+ getNode: async () => {
231
+ throw new Error("unused");
232
+ },
233
+ localNodeId: "node-b",
234
+ localUsername: "bob",
235
+ onMessage: (message) => received.push(message),
236
+ }));
237
+ nodeB.handleIncomingStream(streamB);
238
+ const nodeA = track(createFriendzClient({
239
+ getNode: async () => ({
240
+ node_id: () => "node-a",
241
+ open_bi: async () => streamA,
242
+ }),
243
+ localNodeId: "node-a",
244
+ localUsername: "alice",
245
+ heartbeatIntervalMs: 1000,
246
+ }));
247
+ nodeA.startHeartbeat(() => ["node-b"]);
248
+ await vi.advanceTimersByTimeAsync(0);
249
+ expect(received).toHaveLength(1);
250
+ nodeA.stopHeartbeat();
251
+ await vi.advanceTimersByTimeAsync(5000);
252
+ expect(received).toHaveLength(1);
253
+ });
254
+ it("destroy() closes streams and clears online state without throwing", async () => {
255
+ const { streamA, streamB } = createDuplexPair("node-a", "node-b");
256
+ const nodeB = createFriendzClient({
257
+ getNode: async () => {
258
+ throw new Error("unused");
259
+ },
260
+ localNodeId: "node-b",
261
+ localUsername: "bob",
262
+ });
263
+ nodeB.handleIncomingStream(streamB);
264
+ const nodeA = createFriendzClient({
265
+ getNode: async () => ({
266
+ node_id: () => "node-a",
267
+ open_bi: async () => streamA,
268
+ }),
269
+ localNodeId: "node-a",
270
+ localUsername: "alice",
271
+ });
272
+ await nodeA.sendMessage("node-a", {
273
+ kind: "core",
274
+ message: { type: "heartbeat", v: 1, nodeId: "node-a", username: "alice" },
275
+ });
276
+ await flush();
277
+ expect(nodeB.isOnline("node-a")).toBe(true);
278
+ expect(() => nodeA.destroy()).not.toThrow();
279
+ expect(() => nodeB.destroy()).not.toThrow();
280
+ expect(nodeB.getOnlinePeers()).toEqual([]);
281
+ });
282
+ });
@@ -0,0 +1,45 @@
1
+ import { type CoreMessage } from "./messages.js";
2
+ import { type AppExtensionMessage } from "./extensions.js";
3
+ /**
4
+ * the full friendz protocol message set, decoded: every core (unified)
5
+ * message, tagged as such, or a namespaced app-extension passthrough this
6
+ * package never inspects the shape of.
7
+ */
8
+ export type FriendzMessage = {
9
+ kind: "core";
10
+ message: CoreMessage;
11
+ } | ({
12
+ kind: "app-extension";
13
+ } & AppExtensionMessage);
14
+ /** the wire `type` discriminant of a decoded message, for logging/dispatch. */
15
+ export declare function friendzMessageType(msg: FriendzMessage): string;
16
+ /**
17
+ * validate + decode a raw json value (already parsed, not yet bytes) into
18
+ * a FriendzMessage. a `type` containing `:` is routed to the app-extension
19
+ * passthrough without inspecting its payload shape further; anything else
20
+ * is validated against the core message union and is a real protocol
21
+ * error (thrown) if it matches no core variant.
22
+ */
23
+ export declare function decodeFriendzMessage(raw: unknown): FriendzMessage;
24
+ /** flatten a FriendzMessage back to the plain json object the wire sends. */
25
+ export declare function encodeFriendzMessageToJson(msg: FriendzMessage): Record<string, unknown>;
26
+ /** encode a message to its utf-8 json wire bytes, unframed. */
27
+ export declare function encodeMessage(msg: FriendzMessage): Uint8Array;
28
+ /** parse utf-8 json wire bytes into a validated, decoded FriendzMessage. */
29
+ export declare function decodeMessage(bytes: Uint8Array): FriendzMessage;
30
+ /** encode a message as one length-delimited frame: a 4-byte be length + payload. */
31
+ export declare function frameMessage(msg: FriendzMessage): Uint8Array;
32
+ /**
33
+ * accumulates bytes fed via push() and yields fully-framed messages as
34
+ * they complete - handles a message arriving split across multiple reads
35
+ * (typical for a raw byte stream) or several messages arriving in one
36
+ * chunk.
37
+ */
38
+ export declare class FrameReader {
39
+ private buffer;
40
+ /** feed newly received bytes in; returns every message that completed. */
41
+ push(chunk: Uint8Array): FriendzMessage[];
42
+ /** bytes buffered so far that don't yet form a complete frame. */
43
+ pendingByteLength(): number;
44
+ }
45
+ //# sourceMappingURL=codec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codec.d.ts","sourceRoot":"","sources":["../../src/protocol/codec.ts"],"names":[],"mappings":"AAUA,OAAO,EAAwC,KAAK,WAAW,EAAE,MAAM,eAAe,CAAC;AACvF,OAAO,EAAsB,KAAK,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAE/E;;;;GAIG;AACH,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,WAAW,CAAA;CAAE,GACtC,CAAC;IAAE,IAAI,EAAE,eAAe,CAAA;CAAE,GAAG,mBAAmB,CAAC,CAAC;AAEtD,+EAA+E;AAC/E,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,cAAc,GAAG,MAAM,CAE9D;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,cAAc,CAoBjE;AAED,6EAA6E;AAC7E,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEvF;AAKD,+DAA+D;AAC/D,wBAAgB,aAAa,CAAC,GAAG,EAAE,cAAc,GAAG,UAAU,CAE7D;AAED,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,cAAc,CAS/D;AAYD,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,GAAG,EAAE,cAAc,GAAG,UAAU,CAM5D;AAED;;;;;GAKG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,MAAM,CAAqB;IAEnC,0EAA0E;IAC1E,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,cAAc,EAAE;IAkBzC,kEAAkE;IAClE,iBAAiB,IAAI,MAAM;CAG5B"}
@@ -0,0 +1,111 @@
1
+ // friendz message codec: json wire encode/decode plus a length-delimited
2
+ // framing layer for transports that give raw bytes with no message
3
+ // boundaries of their own.
4
+ //
5
+ // the 20 rust fixture files (haruspex/rust/fixtures/protocol/) are the
6
+ // pre-framing json payload shape - not length-delimited bytes - so
7
+ // decodeMessage/encodeMessage below operate on that same json shape, and
8
+ // framing is a separate, optional layer on top for callers whose
9
+ // transport needs it.
10
+ import { CoreMessageSchema, isCoreMessageType } from "./messages.js";
11
+ import { isAppExtensionType } from "./extensions.js";
12
+ /** the wire `type` discriminant of a decoded message, for logging/dispatch. */
13
+ export function friendzMessageType(msg) {
14
+ return msg.kind === "core" ? msg.message.type : msg.messageType;
15
+ }
16
+ /**
17
+ * validate + decode a raw json value (already parsed, not yet bytes) into
18
+ * a FriendzMessage. a `type` containing `:` is routed to the app-extension
19
+ * passthrough without inspecting its payload shape further; anything else
20
+ * is validated against the core message union and is a real protocol
21
+ * error (thrown) if it matches no core variant.
22
+ */
23
+ export function decodeFriendzMessage(raw) {
24
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
25
+ throw new Error("friendz message must be a json object");
26
+ }
27
+ const obj = raw;
28
+ const type = obj["type"];
29
+ if (typeof type !== "string" || type.length === 0) {
30
+ throw new Error("friendz message is missing a string 'type' field");
31
+ }
32
+ if (isAppExtensionType(type)) {
33
+ return { kind: "app-extension", messageType: type, payload: obj };
34
+ }
35
+ if (!isCoreMessageType(type)) {
36
+ throw new Error(`unknown friendz message type "${type}"`);
37
+ }
38
+ const message = CoreMessageSchema.parse(obj);
39
+ return { kind: "core", message };
40
+ }
41
+ /** flatten a FriendzMessage back to the plain json object the wire sends. */
42
+ export function encodeFriendzMessageToJson(msg) {
43
+ return msg.kind === "core" ? msg.message : msg.payload;
44
+ }
45
+ const encoder = new TextEncoder();
46
+ const decoder = new TextDecoder();
47
+ /** encode a message to its utf-8 json wire bytes, unframed. */
48
+ export function encodeMessage(msg) {
49
+ return encoder.encode(JSON.stringify(encodeFriendzMessageToJson(msg)));
50
+ }
51
+ /** parse utf-8 json wire bytes into a validated, decoded FriendzMessage. */
52
+ export function decodeMessage(bytes) {
53
+ const text = decoder.decode(bytes);
54
+ let raw;
55
+ try {
56
+ raw = JSON.parse(text);
57
+ }
58
+ catch {
59
+ throw new Error("friendz message bytes are not valid json");
60
+ }
61
+ return decodeFriendzMessage(raw);
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ // length-delimited framing
65
+ //
66
+ // for a transport that hands over raw bytes with no message-boundary
67
+ // framing of its own (unlike a midden BiStream, whose write_message/
68
+ // read_message already frame each call - see client.ts). a 4-byte
69
+ // big-endian u32 length prefix precedes each encoded message, matching
70
+ // this workspace's existing length-delimited wire convention.
71
+ // ---------------------------------------------------------------------------
72
+ /** encode a message as one length-delimited frame: a 4-byte be length + payload. */
73
+ export function frameMessage(msg) {
74
+ const payload = encodeMessage(msg);
75
+ const frame = new Uint8Array(4 + payload.byteLength);
76
+ new DataView(frame.buffer).setUint32(0, payload.byteLength, false);
77
+ frame.set(payload, 4);
78
+ return frame;
79
+ }
80
+ /**
81
+ * accumulates bytes fed via push() and yields fully-framed messages as
82
+ * they complete - handles a message arriving split across multiple reads
83
+ * (typical for a raw byte stream) or several messages arriving in one
84
+ * chunk.
85
+ */
86
+ export class FrameReader {
87
+ buffer = new Uint8Array(0);
88
+ /** feed newly received bytes in; returns every message that completed. */
89
+ push(chunk) {
90
+ const merged = new Uint8Array(this.buffer.byteLength + chunk.byteLength);
91
+ merged.set(this.buffer, 0);
92
+ merged.set(chunk, this.buffer.byteLength);
93
+ this.buffer = merged;
94
+ const messages = [];
95
+ for (;;) {
96
+ if (this.buffer.byteLength < 4)
97
+ break;
98
+ const length = new DataView(this.buffer.buffer, this.buffer.byteOffset).getUint32(0, false);
99
+ if (this.buffer.byteLength < 4 + length)
100
+ break;
101
+ const payload = this.buffer.subarray(4, 4 + length);
102
+ messages.push(decodeMessage(payload));
103
+ this.buffer = this.buffer.subarray(4 + length);
104
+ }
105
+ return messages;
106
+ }
107
+ /** bytes buffered so far that don't yet form a complete frame. */
108
+ pendingByteLength() {
109
+ return this.buffer.byteLength;
110
+ }
111
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=codec.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codec.test.d.ts","sourceRoot":"","sources":["../../src/protocol/codec.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { FrameReader, decodeFriendzMessage, decodeMessage, encodeFriendzMessageToJson, encodeMessage, frameMessage, friendzMessageType, } from "./codec.js";
3
+ const heartbeat = {
4
+ kind: "core",
5
+ message: { type: "heartbeat", v: 1, nodeId: "node-abc", username: "alice" },
6
+ };
7
+ const extension = {
8
+ kind: "app-extension",
9
+ messageType: "skein:canvas-invite",
10
+ payload: { v: 1, type: "skein:canvas-invite", inviteId: "inv-1" },
11
+ };
12
+ describe("decodeFriendzMessage", () => {
13
+ it("throws on a non-object value", () => {
14
+ expect(() => decodeFriendzMessage("nope")).toThrow(/json object/);
15
+ expect(() => decodeFriendzMessage(null)).toThrow();
16
+ expect(() => decodeFriendzMessage([1, 2])).toThrow();
17
+ });
18
+ it("throws when 'type' is missing or not a string", () => {
19
+ expect(() => decodeFriendzMessage({})).toThrow(/type/);
20
+ expect(() => decodeFriendzMessage({ type: 5 })).toThrow();
21
+ });
22
+ it("throws on an unrecognized non-namespaced type", () => {
23
+ expect(() => decodeFriendzMessage({ type: "not-a-real-type" })).toThrow(/unknown/);
24
+ });
25
+ it("routes a namespaced type to the app-extension passthrough", () => {
26
+ const message = decodeFriendzMessage({ type: "skein:canvas-invite", foo: "bar" });
27
+ expect(message).toEqual({
28
+ kind: "app-extension",
29
+ messageType: "skein:canvas-invite",
30
+ payload: { type: "skein:canvas-invite", foo: "bar" },
31
+ });
32
+ });
33
+ });
34
+ describe("friendzMessageType", () => {
35
+ it("returns the core message's type", () => {
36
+ expect(friendzMessageType(heartbeat)).toBe("heartbeat");
37
+ });
38
+ it("returns the app-extension's messageType", () => {
39
+ expect(friendzMessageType(extension)).toBe("skein:canvas-invite");
40
+ });
41
+ });
42
+ describe("encodeFriendzMessageToJson", () => {
43
+ it("flattens a core message back to its plain wire shape", () => {
44
+ expect(encodeFriendzMessageToJson(heartbeat)).toEqual(heartbeat.message);
45
+ });
46
+ it("flattens an app-extension message back to its raw payload", () => {
47
+ expect(encodeFriendzMessageToJson(extension)).toEqual(extension.payload);
48
+ });
49
+ });
50
+ describe("encodeMessage / decodeMessage", () => {
51
+ it("round-trips a core message through utf-8 json bytes", () => {
52
+ const bytes = encodeMessage(heartbeat);
53
+ expect(decodeMessage(bytes)).toEqual(heartbeat);
54
+ });
55
+ it("round-trips an app-extension message through utf-8 json bytes", () => {
56
+ const bytes = encodeMessage(extension);
57
+ expect(decodeMessage(bytes)).toEqual(extension);
58
+ });
59
+ it("throws decodeMessage on non-json bytes", () => {
60
+ const bytes = new TextEncoder().encode("not json at all {");
61
+ expect(() => decodeMessage(bytes)).toThrow(/not valid json/);
62
+ });
63
+ });
64
+ describe("frameMessage / FrameReader", () => {
65
+ it("decodes a single complete frame delivered whole", () => {
66
+ const reader = new FrameReader();
67
+ const frame = frameMessage(heartbeat);
68
+ expect(reader.push(frame)).toEqual([heartbeat]);
69
+ expect(reader.pendingByteLength()).toBe(0);
70
+ });
71
+ it("decodes a frame split across multiple chunks", () => {
72
+ const reader = new FrameReader();
73
+ const frame = frameMessage(heartbeat);
74
+ const mid = Math.floor(frame.byteLength / 2);
75
+ expect(reader.push(frame.subarray(0, mid))).toEqual([]);
76
+ expect(reader.pendingByteLength()).toBe(mid);
77
+ expect(reader.push(frame.subarray(mid))).toEqual([heartbeat]);
78
+ });
79
+ it("decodes multiple frames delivered in one chunk", () => {
80
+ const reader = new FrameReader();
81
+ const a = frameMessage(heartbeat);
82
+ const b = frameMessage(extension);
83
+ const combined = new Uint8Array(a.byteLength + b.byteLength);
84
+ combined.set(a, 0);
85
+ combined.set(b, a.byteLength);
86
+ expect(reader.push(combined)).toEqual([heartbeat, extension]);
87
+ });
88
+ it("waits for the length prefix itself to arrive before decoding anything", () => {
89
+ const reader = new FrameReader();
90
+ expect(reader.push(new Uint8Array([0, 0]))).toEqual([]);
91
+ expect(reader.pendingByteLength()).toBe(2);
92
+ });
93
+ });
@@ -0,0 +1,33 @@
1
+ import type { z } from "zod";
2
+ /** a namespaced app-extension message: the raw payload, unparsed. */
3
+ export interface AppExtensionMessage {
4
+ /** the wire `type` string, e.g. "skein:canvas-invite". */
5
+ messageType: string;
6
+ /** the full received json object, including `type` and `v`. */
7
+ payload: Record<string, unknown>;
8
+ }
9
+ /** does `type` look like an app-extension message (has a `:` in it)? */
10
+ export declare function isAppExtensionType(type: string): boolean;
11
+ /**
12
+ * a registry of zod schemas for app-extension message types, keyed by the
13
+ * exact wire `type` string. lets a consuming app parse its own extension
14
+ * payloads with real types instead of a raw `Record<string, unknown>`.
15
+ */
16
+ export interface AppExtensionRegistry {
17
+ /** register a schema for one exact message type (e.g. "skein:canvas-invite"). */
18
+ register<T>(messageType: string, schema: z.ZodType<T>): void;
19
+ /** remove a previously registered schema. */
20
+ unregister(messageType: string): void;
21
+ /** is a schema registered for this message type? */
22
+ isRegistered(messageType: string): boolean;
23
+ /**
24
+ * parse an app-extension message's payload against its registered
25
+ * schema. throws if no schema is registered, or if the payload doesn't
26
+ * match - use `isRegistered` first to fall back to the raw payload
27
+ * instead of throwing.
28
+ */
29
+ parse<T = unknown>(message: AppExtensionMessage): T;
30
+ }
31
+ /** create a fresh, empty app-extension registry. */
32
+ export declare function createAppExtensionRegistry(): AppExtensionRegistry;
33
+ //# sourceMappingURL=extensions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extensions.d.ts","sourceRoot":"","sources":["../../src/protocol/extensions.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B,qEAAqE;AACrE,MAAM,WAAW,mBAAmB;IAClC,0DAA0D;IAC1D,WAAW,EAAE,MAAM,CAAC;IACpB,+DAA+D;IAC/D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAExD;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACnC,iFAAiF;IACjF,QAAQ,CAAC,CAAC,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IAC7D,6CAA6C;IAC7C,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,oDAAoD;IACpD,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC;IAC3C;;;;;OAKG;IACH,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,OAAO,EAAE,mBAAmB,GAAG,CAAC,CAAC;CACrD;AAED,oDAAoD;AACpD,wBAAgB,0BAA0B,IAAI,oBAAoB,CAqBjE"}
@@ -0,0 +1,36 @@
1
+ // the app-extension mechanism: an unknown `type` string containing a `:`
2
+ // (e.g. "skein:canvas-invite", "playlistz:list-playlists") is a namespaced
3
+ // message this package never inspects the shape of - only the framing and
4
+ // the `:`-prefixed routing convention belong to it. a `type` with no `:`
5
+ // that doesn't match any core message is a real protocol error instead
6
+ // (see codec.ts's decodeFriendzMessage).
7
+ //
8
+ // an app registers a zod schema for its own extension message types here,
9
+ // so it can parse a received app-extension payload with its own shape
10
+ // without forking this package.
11
+ /** does `type` look like an app-extension message (has a `:` in it)? */
12
+ export function isAppExtensionType(type) {
13
+ return type.includes(":");
14
+ }
15
+ /** create a fresh, empty app-extension registry. */
16
+ export function createAppExtensionRegistry() {
17
+ const schemas = new Map();
18
+ return {
19
+ register(messageType, schema) {
20
+ schemas.set(messageType, schema);
21
+ },
22
+ unregister(messageType) {
23
+ schemas.delete(messageType);
24
+ },
25
+ isRegistered(messageType) {
26
+ return schemas.has(messageType);
27
+ },
28
+ parse(message) {
29
+ const schema = schemas.get(message.messageType);
30
+ if (!schema) {
31
+ throw new Error(`no schema registered for app-extension type "${message.messageType}"`);
32
+ }
33
+ return schema.parse(message.payload);
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=extensions.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extensions.test.d.ts","sourceRoot":"","sources":["../../src/protocol/extensions.test.ts"],"names":[],"mappings":""}