@freqhole/api-client 0.1.31 → 0.1.32

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freqhole/api-client",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,7 +13,6 @@
13
13
  "exports": {
14
14
  ".": "./src/index.ts",
15
15
  "./storage": "./src/storage/index.ts",
16
- "./playlistz": "./src/playlistz/index.ts",
17
16
  "./automerge": "./src/automerge/index.ts"
18
17
  },
19
18
  "scripts": {
@@ -77,6 +77,8 @@ export interface BiStreamLike {
77
77
  */
78
78
  export interface MiddenStreamNode {
79
79
  node_id(): string;
80
+ /** full serialized endpoint addr (node id + relay url) for deterministic dialing. */
81
+ node_addr?(): string;
80
82
  open_bi(peer_addr: string, alpn: string): Promise<BiStreamLike>;
81
83
  accept(): Promise<BiStreamLike | null>;
82
84
  }
@@ -153,6 +155,14 @@ export class IrohNetworkAdapter extends NetworkAdapter {
153
155
  { attempt: number; timer: ReturnType<typeof setTimeout> | null }
154
156
  >();
155
157
 
158
+ /**
159
+ * optional per-peer dial address hint keyed by node id. when present the
160
+ * adapter dials this full endpoint addr instead of the bare node id, which
161
+ * skips discovery (pkarr/dns) lookup. empty in normal operation; populated
162
+ * by callers that already know a peer's reachable addr.
163
+ */
164
+ private peerDialAddr = new Map<string, string>();
165
+
156
166
  /** listeners for connection state changes. */
157
167
  private connectionStateListeners: Array<() => void> = [];
158
168
 
@@ -248,6 +258,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
248
258
  this.readLoops.clear();
249
259
  this.intendedPeers.clear();
250
260
  this.failedPeers.clear();
261
+ this.peerDialAddr.clear();
251
262
  this.alpnHandlers.clear();
252
263
 
253
264
  // cancel all pending reconnection timers
@@ -278,7 +289,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
278
289
  * ALPN, starts a read loop, and emits a peer-candidate event so
279
290
  * automerge-repo begins syncing with this peer.
280
291
  */
281
- async addPeer(nodeId: string): Promise<void> {
292
+ async addPeer(nodeId: string, dialAddr?: string): Promise<void> {
282
293
  if (this._disconnected) {
283
294
  throw new Error("adapter is disconnected");
284
295
  }
@@ -286,6 +297,11 @@ export class IrohNetworkAdapter extends NetworkAdapter {
286
297
  // track this peer as one we intend to stay connected to
287
298
  this.intendedPeers.add(nodeId);
288
299
  this.failedPeers.delete(nodeId);
300
+ // remember an explicit dial addr hint so dials (and reconnects) can skip
301
+ // discovery. keyed by node id; everything else stays keyed by node id too.
302
+ if (dialAddr) {
303
+ this.peerDialAddr.set(nodeId, dialAddr);
304
+ }
289
305
  this.emitConnectionStateChange();
290
306
 
291
307
  // clear any pending reconnection state - this is a fresh attempt
@@ -298,9 +314,22 @@ export class IrohNetworkAdapter extends NetworkAdapter {
298
314
  }
299
315
 
300
316
  const midden = await this.ensureMidden();
301
- const stream = await midden.open_bi(nodeId, this.syncAlpn);
302
-
303
- this.registerStream(nodeId, stream);
317
+ const target = this.peerDialAddr.get(nodeId) ?? nodeId;
318
+ try {
319
+ const stream = await midden.open_bi(target, this.syncAlpn);
320
+ this.registerStream(nodeId, stream);
321
+ } catch (err) {
322
+ // the initial dial can fail when the peer is not yet discoverable. hand
323
+ // off to the background reconnect loop with backoff instead of forcing
324
+ // every caller to implement its own retry.
325
+ console.warn(
326
+ TAG,
327
+ "initial dial failed, scheduling reconnect:",
328
+ nodeId.slice(0, 16) + "...",
329
+ err
330
+ );
331
+ this.scheduleReconnect(nodeId);
332
+ }
304
333
  }
305
334
 
306
335
  /**
@@ -313,6 +342,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
313
342
  // remove from intended set first so removePeer() won't schedule a reconnect
314
343
  this.intendedPeers.delete(nodeId);
315
344
  this.failedPeers.delete(nodeId);
345
+ this.peerDialAddr.delete(nodeId);
316
346
  this.clearReconnectState(nodeId);
317
347
  // delegate stream cleanup and peer-disconnected emission to removePeer
318
348
  this.removePeer(nodeId);
@@ -643,6 +673,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
643
673
  );
644
674
  this.intendedPeers.delete(peerId);
645
675
  this.failedPeers.add(peerId);
676
+ this.peerDialAddr.delete(peerId);
646
677
  this.clearReconnectState(peerId);
647
678
  this.emitConnectionStateChange();
648
679
  return;
@@ -696,7 +727,8 @@ export class IrohNetworkAdapter extends NetworkAdapter {
696
727
 
697
728
  try {
698
729
  const midden = await this.ensureMidden();
699
- const stream = await midden.open_bi(peerId, this.syncAlpn);
730
+ const target = this.peerDialAddr.get(peerId) ?? peerId;
731
+ const stream = await midden.open_bi(target, this.syncAlpn);
700
732
  console.log(TAG, "reconnected to peer:", peerId.slice(0, 16) + "...");
701
733
  this.registerStream(peerId, stream);
702
734
  // registerStream calls clearReconnectState, so no need to do it here
@@ -93,5 +93,28 @@ export function createAdminMethods(call: CallFn) {
93
93
  routes.admin.get_knock_status_public.path,
94
94
  );
95
95
  },
96
+
97
+ // internal service callbacks
98
+ deviceLinkedCallback: (params: s.DeviceLinkedCallbackRequest) => {
99
+ return call(
100
+ "admin", "device_linked_callback",
101
+ routes.admin.device_linked_callback.resp,
102
+ routes.admin.device_linked_callback.req,
103
+ routes.admin.device_linked_callback.method,
104
+ routes.admin.device_linked_callback.path,
105
+ params,
106
+ );
107
+ },
108
+
109
+ knockAcceptedCallback: (params: s.KnockAcceptedCallbackRequest) => {
110
+ return call(
111
+ "admin", "knock_accepted_callback",
112
+ routes.admin.knock_accepted_callback.resp,
113
+ routes.admin.knock_accepted_callback.req,
114
+ routes.admin.knock_accepted_callback.method,
115
+ routes.admin.knock_accepted_callback.path,
116
+ params,
117
+ );
118
+ },
96
119
  };
97
120
  }
package/src/test.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  import { validateWrapperCoverage } from "./test/coverage.js";
3
3
  import { runIntegrationTests } from "./test/integration.js";
4
4
  import { runStatefulTests } from "./test/stateful.js";
5
- import { runPlaylistzTests } from "./test/playlistz.js";
6
5
  import { runAutomergeTests } from "./test/automerge.js";
7
6
  import { runStorageTests } from "./test/storage.js";
8
7
 
@@ -22,14 +21,6 @@ async function main() {
22
21
  console.log("=".repeat(50));
23
22
  console.log("");
24
23
 
25
- // always run playlistz domain tests (pure - no server needed)
26
- const playlistzResults = await runPlaylistzTests();
27
- totalPassed += playlistzResults.passed;
28
- totalFailed += playlistzResults.failed;
29
-
30
- console.log("=".repeat(50));
31
- console.log("");
32
-
33
24
  // automerge adapter unit tests (no server required)
34
25
  const automergeResults = await runAutomergeTests();
35
26
  totalPassed += automergeResults.passed;
@@ -1,631 +0,0 @@
1
- // unit tests for the playlistz domain package.
2
- // pure - no server, no automerge, no browser APIs required.
3
- import {
4
- parsePlaylistDoc,
5
- emptyPlaylistDoc,
6
- type SongEntry,
7
- type ImageRef,
8
- } from "../playlistz/schema.js";
9
- import {
10
- upsertSong,
11
- removeSong,
12
- reorderSongs,
13
- setMetadata,
14
- addImage,
15
- setPrimaryImage,
16
- addPeer,
17
- stampLastSeen,
18
- setAclRole,
19
- tombstone,
20
- } from "../playlistz/mutations.js";
21
- import {
22
- freqholePlaylistToDoc,
23
- spumePlaylistToDoc,
24
- docToFreqholePlaylist,
25
- } from "../playlistz/convert.js";
26
- import {
27
- encodeShareToken,
28
- decodeShareToken,
29
- shareFragment,
30
- } from "../playlistz/shareLink.js";
31
- import {
32
- encodeMessage,
33
- decodeMessage,
34
- ProtocolError,
35
- PLAYLISTZ_ALPN,
36
- AUTOMERGE_ALPN,
37
- type Message,
38
- } from "../playlistz/protocol.js";
39
-
40
- // ---- minimal assertion helpers ----
41
-
42
- function assert(condition: boolean, message: string): void {
43
- if (!condition) throw new Error(message);
44
- }
45
-
46
- function assertEqual<T>(a: T, b: T, label: string): void {
47
- if (a !== b)
48
- throw new Error(`${label}: expected ${JSON.stringify(a)} got ${JSON.stringify(b)}`);
49
- }
50
-
51
- function assertDeepEqual(a: unknown, b: unknown, label: string): void {
52
- const as = JSON.stringify(a);
53
- const bs = JSON.stringify(b);
54
- if (as !== bs)
55
- throw new Error(`${label}: expected ${bs} got ${as}`);
56
- }
57
-
58
- // ---- test runner ----
59
-
60
- export async function runPlaylistzTests(): Promise<{
61
- passed: number;
62
- failed: number;
63
- }> {
64
- let passed = 0;
65
- let failed = 0;
66
-
67
- async function test(name: string, fn: () => void | Promise<void>) {
68
- try {
69
- await fn();
70
- console.log(`✓ ${name}`);
71
- passed++;
72
- } catch (err) {
73
- console.log(
74
- `✗ ${name}: ${err instanceof Error ? err.message : String(err)}`,
75
- );
76
- failed++;
77
- }
78
- }
79
-
80
- console.log("playlistz domain tests\n");
81
-
82
- // ---- schema defaults ----
83
-
84
- await test("emptyPlaylistDoc returns valid doc with defaults", () => {
85
- const doc = emptyPlaylistDoc();
86
- assertEqual(doc.version, 1, "version");
87
- assertEqual(doc.title, "", "title");
88
- assertEqual(doc.description, "", "description");
89
- assert(doc.createdAt.length > 0, "createdAt non-empty");
90
- assertDeepEqual(doc.songs, {}, "songs");
91
- assertDeepEqual(doc.order, [], "order");
92
- assertDeepEqual(doc.peers, {}, "peers");
93
- });
94
-
95
- await test("emptyPlaylistDoc respects init overrides", () => {
96
- const doc = emptyPlaylistDoc({ title: "my list", description: "desc" });
97
- assertEqual(doc.title, "my list", "title");
98
- assertEqual(doc.description, "desc", "description");
99
- });
100
-
101
- await test("parsePlaylistDoc passes through valid data", () => {
102
- const now = new Date().toISOString();
103
- const raw = {
104
- version: 1 as const,
105
- title: "test",
106
- description: "",
107
- createdAt: now,
108
- lastModified: now,
109
- lastModifiedBy: "",
110
- images: [],
111
- urls: [],
112
- songs: {},
113
- order: [],
114
- peers: {},
115
- };
116
- const doc = parsePlaylistDoc(raw);
117
- assertEqual(doc.title, "test", "title");
118
- });
119
-
120
- await test("parsePlaylistDoc degrades gracefully on invalid data", () => {
121
- // invalid data: version wrong type, should fall back to defaults
122
- const doc = parsePlaylistDoc({ version: "bad", title: 999 });
123
- assertEqual(doc.version, 1, "version defaults to 1");
124
- assertEqual(doc.title, "", "title defaults to empty string");
125
- });
126
-
127
- await test("parsePlaylistDoc degrades on null", () => {
128
- const doc = parsePlaylistDoc(null);
129
- assertEqual(doc.version, 1, "version");
130
- });
131
-
132
- // ---- mutations ----
133
-
134
- function makeSong(id: string): SongEntry {
135
- return {
136
- id,
137
- title: `song ${id}`,
138
- artist: "artist",
139
- album: "album",
140
- duration: 180,
141
- mimeType: "audio/mpeg",
142
- fileSize: 1024,
143
- sha256: `sha256-${id}`,
144
- images: [],
145
- urls: [],
146
- };
147
- }
148
-
149
- await test("upsertSong adds song and appends to order", () => {
150
- const doc = emptyPlaylistDoc();
151
- upsertSong(doc, makeSong("a"));
152
- assert("a" in doc.songs, "song a in songs");
153
- assertEqual(doc.order.length, 1, "order length");
154
- assertEqual(doc.order[0], "a", "order[0]");
155
- });
156
-
157
- await test("upsertSong does not duplicate order entry", () => {
158
- const doc = emptyPlaylistDoc();
159
- upsertSong(doc, makeSong("a"));
160
- upsertSong(doc, makeSong("a"));
161
- assertEqual(doc.order.length, 1, "order length stays 1");
162
- });
163
-
164
- await test("upsertSong bumps lastModified", () => {
165
- const doc = emptyPlaylistDoc();
166
- // set a known old timestamp so any call to nowIso() will be strictly after it
167
- doc.lastModified = "2000-01-01T00:00:00.000Z";
168
- upsertSong(doc, makeSong("a"), "node1");
169
- assert(doc.lastModified > "2000-01-01T00:00:00.000Z", "lastModified bumped past sentinel");
170
- assertEqual(doc.lastModifiedBy, "node1", "lastModifiedBy set");
171
- });
172
-
173
- await test("removeSong deletes from songs and order", () => {
174
- const doc = emptyPlaylistDoc();
175
- upsertSong(doc, makeSong("a"));
176
- upsertSong(doc, makeSong("b"));
177
- removeSong(doc, "a");
178
- assert(!("a" in doc.songs), "a removed from songs");
179
- assert(!doc.order.includes("a"), "a removed from order");
180
- assertEqual(doc.order.length, 1, "order length");
181
- assertEqual(doc.order[0], "b", "b remains");
182
- });
183
-
184
- await test("removeSong on missing id is a no-op", () => {
185
- const doc = emptyPlaylistDoc();
186
- removeSong(doc, "nonexistent"); // should not throw
187
- });
188
-
189
- await test("reorderSongs moves song to target index", () => {
190
- const doc = emptyPlaylistDoc();
191
- upsertSong(doc, makeSong("a"));
192
- upsertSong(doc, makeSong("b"));
193
- upsertSong(doc, makeSong("c"));
194
- // move "c" (index 2) to index 0
195
- reorderSongs(doc, "c", 0);
196
- assertEqual(doc.order[0], "c", "c is first");
197
- assertEqual(doc.order[1], "a", "a is second");
198
- assertEqual(doc.order[2], "b", "b is third");
199
- });
200
-
201
- await test("reorderSongs clamps to valid range", () => {
202
- const doc = emptyPlaylistDoc();
203
- upsertSong(doc, makeSong("a"));
204
- upsertSong(doc, makeSong("b"));
205
- // move "a" to index 99 (should clamp to end)
206
- reorderSongs(doc, "a", 99);
207
- assertEqual(doc.order[doc.order.length - 1], "a", "a is last");
208
- });
209
-
210
- await test("reorderSongs ignores missing songId", () => {
211
- const doc = emptyPlaylistDoc();
212
- reorderSongs(doc, "ghost", 0); // should not throw
213
- });
214
-
215
- await test("setMetadata updates title and description", () => {
216
- const doc = emptyPlaylistDoc();
217
- setMetadata(doc, { title: "new title", description: "new desc" });
218
- assertEqual(doc.title, "new title", "title");
219
- assertEqual(doc.description, "new desc", "description");
220
- });
221
-
222
- await test("setMetadata partial update only changes supplied fields", () => {
223
- const doc = emptyPlaylistDoc({ title: "original" });
224
- setMetadata(doc, { description: "only desc" });
225
- assertEqual(doc.title, "original", "title unchanged");
226
- assertEqual(doc.description, "only desc", "description updated");
227
- });
228
-
229
- await test("addImage appends to playlist images", () => {
230
- const doc = emptyPlaylistDoc();
231
- const ref: ImageRef = { blobId: "blob1", isPrimary: false, blobType: "thumbnail" };
232
- addImage(doc, ref);
233
- assertEqual(doc.images.length, 1, "images length");
234
- assertEqual(doc.images[0].blobId, "blob1", "blobId");
235
- });
236
-
237
- await test("addImage with isPrimary clears siblings", () => {
238
- const doc = emptyPlaylistDoc();
239
- addImage(doc, { blobId: "blob1", isPrimary: true, blobType: "original" });
240
- addImage(doc, { blobId: "blob2", isPrimary: true, blobType: "thumbnail" });
241
- // blob1 should no longer be primary
242
- assert(!doc.images[0].isPrimary, "blob1 isPrimary cleared");
243
- assert(doc.images[1].isPrimary, "blob2 is primary");
244
- });
245
-
246
- await test("addImage to song images", () => {
247
- const doc = emptyPlaylistDoc();
248
- upsertSong(doc, makeSong("s1"));
249
- const ref: ImageRef = { blobId: "songblob", isPrimary: false, blobType: "thumbnail" };
250
- addImage(doc, ref, { songId: "s1" });
251
- assertEqual(doc.songs["s1"].images.length, 1, "song images length");
252
- });
253
-
254
- await test("addImage to missing song is a no-op", () => {
255
- const doc = emptyPlaylistDoc();
256
- addImage(doc, { blobId: "x", isPrimary: false, blobType: "original" }, { songId: "ghost" });
257
- // no throw
258
- });
259
-
260
- await test("setPrimaryImage sets exactly one primary", () => {
261
- const doc = emptyPlaylistDoc();
262
- addImage(doc, { blobId: "a", isPrimary: true, blobType: "original" });
263
- addImage(doc, { blobId: "b", isPrimary: false, blobType: "thumbnail" });
264
- setPrimaryImage(doc, "b");
265
- assert(!doc.images[0].isPrimary, "a no longer primary");
266
- assert(doc.images[1].isPrimary, "b is primary");
267
- });
268
-
269
- await test("addPeer is idempotent", () => {
270
- const doc = emptyPlaylistDoc();
271
- addPeer(doc, "node1");
272
- const firstJoinedAt = doc.peers["node1"].joinedAt;
273
- addPeer(doc, "node1"); // second call should not overwrite
274
- assertEqual(doc.peers["node1"].joinedAt, firstJoinedAt, "joinedAt unchanged");
275
- });
276
-
277
- await test("addPeer does not bump lastModified", () => {
278
- const doc = emptyPlaylistDoc();
279
- const before = doc.lastModified;
280
- addPeer(doc, "node1");
281
- assertEqual(doc.lastModified, before, "lastModified not bumped");
282
- });
283
-
284
- await test("stampLastSeen updates peer lastSeenAt", () => {
285
- const doc = emptyPlaylistDoc();
286
- addPeer(doc, "node1");
287
- stampLastSeen(doc, "node1");
288
- assert(doc.peers["node1"].lastSeenAt != null, "lastSeenAt set");
289
- });
290
-
291
- await test("stampLastSeen on unknown peer is a no-op", () => {
292
- const doc = emptyPlaylistDoc();
293
- stampLastSeen(doc, "ghost"); // should not throw
294
- });
295
-
296
- await test("setAclRole creates acl and sets role", () => {
297
- const doc = emptyPlaylistDoc();
298
- setAclRole(doc, "node1", "editor");
299
- assert(doc.acl != null, "acl created");
300
- assertEqual(doc.acl!["node1"].role, "editor", "role");
301
- });
302
-
303
- await test("tombstone sets deleted flag", () => {
304
- const doc = emptyPlaylistDoc();
305
- tombstone(doc);
306
- assert(doc.deleted === true, "deleted is true");
307
- });
308
-
309
- // ---- converters ----
310
-
311
- await test("freqholePlaylistToDoc maps snake_case + is_primary number", () => {
312
- const doc = freqholePlaylistToDoc(
313
- {
314
- id: "fpl1",
315
- title: "FH Playlist",
316
- description: "desc",
317
- is_public: 1,
318
- images: [
319
- { blob_id: "img1", is_primary: 1, blob_type: "original" },
320
- { blob_id: "img2", is_primary: 0, blob_type: "thumbnail" },
321
- ],
322
- urls: [{ id: "u1", name: "site", url: "https://example.com" }],
323
- created_by_id: null,
324
- created_at: 1000,
325
- updated_at: 2000,
326
- deleted_at: null,
327
- deleted_by: null,
328
- created_by: null,
329
- updated_by: null,
330
- song_count: 1,
331
- },
332
- [
333
- {
334
- id: "fs1",
335
- media_blob_id: "mediablob1",
336
- title: "FH Song",
337
- track_number: 1,
338
- disc_number: 1,
339
- duration: 120,
340
- images: [],
341
- urls: [],
342
- bpm: null,
343
- track_artist: "The Artist",
344
- metadata: null,
345
- lyrics: "la la la",
346
- created_at: 1000,
347
- updated_at: 2000,
348
- deleted_at: null,
349
- deleted_by: null,
350
- created_by: null,
351
- updated_by: null,
352
- created_by_username: null,
353
- updated_by_username: null,
354
- play_count: null,
355
- },
356
- ],
357
- );
358
- assertEqual(doc.title, "FH Playlist", "title");
359
- assert(doc.images[0].isPrimary, "first image is primary");
360
- assert(!doc.images[1].isPrimary, "second image not primary");
361
- assertEqual(doc.songs["fs1"].sha256, "mediablob1", "sha256 from media_blob_id");
362
- assertEqual(doc.songs["fs1"].artist, "The Artist", "artist from track_artist");
363
- assertEqual(doc.songs["fs1"].lyrics, "la la la", "lyrics");
364
- });
365
-
366
- await test("spumePlaylistToDoc maps spume field names", () => {
367
- const doc = spumePlaylistToDoc(
368
- {
369
- playlist_id: "spl1",
370
- title: "Spume Playlist",
371
- description: "spume desc",
372
- created_at: 1000,
373
- updated_at: 2000,
374
- images: [
375
- { local_blob_id: "lb1", is_primary: true, blob_type: "original" },
376
- { remote_blob_id: "rb1", is_primary: false, blob_type: "thumbnail" },
377
- ],
378
- },
379
- [
380
- {
381
- id: "ss1",
382
- sha256: "spumehash1",
383
- title: "Spume Song",
384
- artist_name: "Spume Artist",
385
- album_title: "Spume Album",
386
- duration_seconds: 240,
387
- mime_type: "audio/ogg",
388
- file_size: 4096,
389
- blake3: "blake3hash",
390
- lyrics: "words",
391
- },
392
- ],
393
- );
394
- assertEqual(doc.title, "Spume Playlist", "title");
395
- assertEqual(doc.images[0].blobId, "lb1", "local_blob_id mapped");
396
- assertEqual(doc.images[1].blobId, "rb1", "remote_blob_id mapped");
397
- assertEqual(doc.songs["ss1"].sha256, "spumehash1", "sha256");
398
- assertEqual(doc.songs["ss1"].artist, "Spume Artist", "artist_name mapped");
399
- assertEqual(doc.songs["ss1"].album, "Spume Album", "album_title mapped");
400
- assertEqual(doc.songs["ss1"].duration, 240, "duration_seconds mapped");
401
- assertEqual(doc.songs["ss1"].blake3, "blake3hash", "blake3");
402
- });
403
-
404
- await test("spumePlaylistToDoc skips images with no blob id", () => {
405
- const doc = spumePlaylistToDoc(
406
- {
407
- playlist_id: "pl",
408
- title: "T",
409
- images: [{ is_primary: false, blob_type: "original" }],
410
- },
411
- [],
412
- );
413
- assertEqual(doc.images.length, 0, "no images (no blob id)");
414
- });
415
-
416
- await test("docToFreqholePlaylist round-trip preserves core fields", () => {
417
- const original = freqholePlaylistToDoc(
418
- {
419
- id: "x",
420
- title: "RT Playlist",
421
- description: "rt desc",
422
- is_public: 0,
423
- images: [],
424
- urls: [],
425
- created_by_id: null,
426
- created_at: 1000,
427
- updated_at: 2000,
428
- deleted_at: null,
429
- deleted_by: null,
430
- created_by: null,
431
- updated_by: null,
432
- song_count: 0,
433
- },
434
- [],
435
- );
436
- const { playlist } = docToFreqholePlaylist(original);
437
- assertEqual(playlist.title, "RT Playlist", "title round-trips");
438
- assertEqual(playlist.description, "rt desc", "description round-trips");
439
- });
440
-
441
- await test("docToFreqholePlaylist songs preserve title and artist", () => {
442
- const doc = emptyPlaylistDoc({ title: "pl" });
443
- upsertSong(doc, {
444
- id: "s1",
445
- title: "Song Title",
446
- artist: "Artist",
447
- album: "Album",
448
- duration: 100,
449
- mimeType: "audio/mpeg",
450
- fileSize: 512,
451
- sha256: "hash1",
452
- images: [],
453
- urls: [],
454
- });
455
- const { songs } = docToFreqholePlaylist(doc);
456
- assertEqual(songs.length, 1, "one song");
457
- assertEqual(songs[0].title, "Song Title", "title");
458
- assertEqual(songs[0].track_artist, "Artist", "track_artist from artist");
459
- });
460
-
461
- // ---- share token ----
462
-
463
- await test("encodeShareToken + decodeShareToken round-trip", () => {
464
- const payload = { v: 1 as const, n: "a".repeat(64), d: "docid123" };
465
- const token = encodeShareToken(payload);
466
- const decoded = decodeShareToken(token);
467
- assert(decoded !== null, "decoded is not null");
468
- assertEqual(decoded!.n, payload.n, "nodeId");
469
- assertEqual(decoded!.d, payload.d, "docId");
470
- assertEqual(decoded!.v, 1, "version");
471
- });
472
-
473
- await test("shareFragment builds correct fragment", () => {
474
- const payload = { v: 1 as const, n: "n", d: "d", t: "title" };
475
- const frag = shareFragment(payload);
476
- assert(frag.startsWith("#share/"), "starts with #share/");
477
- const decoded = decodeShareToken(frag);
478
- assert(decoded !== null, "fragment decodes");
479
- assertEqual(decoded!.t, "title", "title hint preserved");
480
- });
481
-
482
- await test("decodeShareToken strips #share/ prefix", () => {
483
- const payload = { v: 1 as const, n: "nn", d: "dd" };
484
- const frag = shareFragment(payload);
485
- const decoded = decodeShareToken(frag);
486
- assert(decoded !== null, "decoded");
487
- assertEqual(decoded!.d, "dd", "docId");
488
- });
489
-
490
- await test("decodeShareToken strips share/ prefix", () => {
491
- const payload = { v: 1 as const, n: "nn", d: "dd" };
492
- const token = encodeShareToken(payload);
493
- const decoded = decodeShareToken("share/" + token);
494
- assert(decoded !== null, "decoded");
495
- });
496
-
497
- await test("decodeShareToken strips full url with #share/", () => {
498
- const payload = { v: 1 as const, n: "nn", d: "dd" };
499
- const token = encodeShareToken(payload);
500
- const url = "https://example.com/app#share/" + token;
501
- const decoded = decodeShareToken(url);
502
- assert(decoded !== null, "decoded from full url");
503
- assertEqual(decoded!.n, "nn", "nodeId");
504
- });
505
-
506
- await test("decodeShareToken accepts base64url encoding", () => {
507
- // base64url uses - and _ instead of + and /; also no padding
508
- const payload = { v: 1 as const, n: "nn", d: "dd" };
509
- const token = encodeShareToken(payload);
510
- // tokens from encodeShareToken are already base64url - decoding should work
511
- assert(!token.includes("+"), "no + in token");
512
- assert(!token.includes("/"), "no / in token");
513
- assert(!token.includes("="), "no padding");
514
- const decoded = decodeShareToken(token);
515
- assert(decoded !== null, "decoded");
516
- });
517
-
518
- await test("decodeShareToken trims whitespace", () => {
519
- const payload = { v: 1 as const, n: "n1", d: "d1" };
520
- const token = encodeShareToken(payload);
521
- const decoded = decodeShareToken(" " + token + "\n");
522
- assert(decoded !== null, "decoded with whitespace");
523
- });
524
-
525
- await test("decodeShareToken returns null on garbage", () => {
526
- const result = decodeShareToken("not-a-valid-token!!!");
527
- assert(result === null, "null on garbage");
528
- });
529
-
530
- await test("decodeShareToken returns null on empty string", () => {
531
- assert(decodeShareToken("") === null, "null on empty");
532
- });
533
-
534
- await test("decodeShareToken returns null on missing required fields", () => {
535
- // payload missing 'd'
536
- const partial = btoa(JSON.stringify({ v: 1, n: "nodeonly" }));
537
- assert(decodeShareToken(partial) === null, "null on missing d");
538
- });
539
-
540
- // ---- protocol ----
541
-
542
- await test("ALPN constants are correct", () => {
543
- assertEqual(PLAYLISTZ_ALPN, "freqhole-playlistz/1", "playlistz ALPN");
544
- assertEqual(AUTOMERGE_ALPN, "iroh/automerge-repo/1", "automerge ALPN");
545
- });
546
-
547
- await test("encodeMessage + decodeMessage round-trip for hello", () => {
548
- const msg: Message = { v: 1, type: "hello", nodeId: "abc123" };
549
- const bytes = encodeMessage(msg);
550
- const decoded = decodeMessage(bytes);
551
- assertDeepEqual(decoded, msg, "hello round-trip");
552
- });
553
-
554
- await test("encodeMessage + decodeMessage for playlists response", () => {
555
- const msg: Message = {
556
- v: 1,
557
- type: "playlists",
558
- items: [{ docId: "doc1", title: "My List", songCount: 5 }],
559
- };
560
- const decoded = decodeMessage(encodeMessage(msg));
561
- assertDeepEqual(decoded, msg, "playlists round-trip");
562
- });
563
-
564
- await test("decodeMessage throws ProtocolError on non-JSON bytes", () => {
565
- const bytes = new TextEncoder().encode("not json {{{{");
566
- try {
567
- decodeMessage(bytes);
568
- throw new Error("should have thrown");
569
- } catch (err) {
570
- assert(err instanceof ProtocolError, "ProtocolError thrown");
571
- assertEqual((err as ProtocolError).code, "parse_error", "code");
572
- }
573
- });
574
-
575
- await test("decodeMessage throws ProtocolError on unknown type", () => {
576
- const bytes = new TextEncoder().encode(
577
- JSON.stringify({ v: 1, type: "unknown_type_xyz" }),
578
- );
579
- try {
580
- decodeMessage(bytes);
581
- throw new Error("should have thrown");
582
- } catch (err) {
583
- assert(err instanceof ProtocolError, "ProtocolError thrown");
584
- assertEqual((err as ProtocolError).code, "schema_error", "code");
585
- }
586
- });
587
-
588
- await test("decodeMessage throws ProtocolError on missing required fields", () => {
589
- const bytes = new TextEncoder().encode(JSON.stringify({ v: 1, type: "hello" }));
590
- // hello requires nodeId
591
- try {
592
- decodeMessage(bytes);
593
- throw new Error("should have thrown");
594
- } catch (err) {
595
- assert(err instanceof ProtocolError, "ProtocolError thrown");
596
- }
597
- });
598
-
599
- await test("all message types encode/decode", () => {
600
- const messages: Message[] = [
601
- { v: 1, type: "hello", nodeId: "n1", name: "Alice" },
602
- { v: 1, type: "hello_ok", nodeId: "n2", public: true },
603
- { v: 1, type: "list_playlists" },
604
- { v: 1, type: "playlists", items: [] },
605
- { v: 1, type: "knock", nodeId: "n3", message: "hi" },
606
- {
607
- v: 1,
608
- type: "knock_status",
609
- status: "accepted",
610
- grantedDocIds: ["doc1"],
611
- },
612
- { v: 1, type: "blob_request", sha256: "a".repeat(64) },
613
- {
614
- v: 1,
615
- type: "blob_ready",
616
- sha256: "a".repeat(64),
617
- blake3: "b".repeat(64),
618
- size: 12345,
619
- },
620
- { v: 1, type: "error", code: "not_found", message: "not found" },
621
- ];
622
- for (const msg of messages) {
623
- const decoded = decodeMessage(encodeMessage(msg));
624
- assertDeepEqual(decoded, msg, `round-trip for type=${msg.type}`);
625
- }
626
- });
627
-
628
- // summary
629
- console.log(`\nplaylistz: ${passed} passed, ${failed} failed\n`);
630
- return { passed, failed };
631
- }