@freqhole/api-client 0.1.30

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 (45) hide show
  1. package/auth-test.html +651 -0
  2. package/package.json +42 -0
  3. package/src/AdminClient.ts +154 -0
  4. package/src/CharnelLocalTransport.ts +598 -0
  5. package/src/CharnelTransport.ts +616 -0
  6. package/src/FreqholeClient.ts +261 -0
  7. package/src/WasmTransport.ts +1077 -0
  8. package/src/automerge/IrohNetworkAdapter.ts +727 -0
  9. package/src/automerge/index.ts +14 -0
  10. package/src/codegen/admin_commands.ts +61 -0
  11. package/src/codegen/routes.ts +213 -0
  12. package/src/codegen/schema.ts +6721 -0
  13. package/src/domains/admin.ts +97 -0
  14. package/src/domains/app.ts +98 -0
  15. package/src/domains/auth.ts +191 -0
  16. package/src/domains/favorites.types.ts +52 -0
  17. package/src/domains/jobs.ts +48 -0
  18. package/src/domains/music.ts +1935 -0
  19. package/src/domains/types.ts +19 -0
  20. package/src/domains/upload.ts +214 -0
  21. package/src/index.ts +48 -0
  22. package/src/permissions.ts +468 -0
  23. package/src/playlistz/convert.ts +267 -0
  24. package/src/playlistz/index.ts +6 -0
  25. package/src/playlistz/mutations.ts +181 -0
  26. package/src/playlistz/protocol.ts +222 -0
  27. package/src/playlistz/schema.ts +138 -0
  28. package/src/playlistz/shareLink.ts +76 -0
  29. package/src/storage/blobs.ts +272 -0
  30. package/src/storage/identity.ts +190 -0
  31. package/src/storage/index.ts +5 -0
  32. package/src/storage/webLocks.ts +92 -0
  33. package/src/test/automerge.ts +796 -0
  34. package/src/test/coverage.ts +105 -0
  35. package/src/test/fixtures.ts +460 -0
  36. package/src/test/integration.ts +320 -0
  37. package/src/test/playlistz.ts +631 -0
  38. package/src/test/stateful.ts +561 -0
  39. package/src/test/storage.ts +443 -0
  40. package/src/test.ts +75 -0
  41. package/src/transport.ts +391 -0
  42. package/src/utils.ts +79 -0
  43. package/src/webauthn.ts +228 -0
  44. package/tsconfig.json +22 -0
  45. package/vite.config.js +9 -0
@@ -0,0 +1,267 @@
1
+ // converters between external record shapes and PlaylistDoc.
2
+ // all converters are total: missing optional source fields become schema defaults.
3
+ // never throw on extra fields.
4
+ import type { Playlist as FreqholePlaylist, Song as FreqholeSong } from "../codegen/schema.js";
5
+ import {
6
+ emptyPlaylistDoc,
7
+ type ImageRef,
8
+ type PlaylistDoc,
9
+ type SongEntry,
10
+ type EntityUrl,
11
+ } from "./schema.js";
12
+
13
+ // ---- shared helpers ----
14
+
15
+ function tsToIso(ts: number): string {
16
+ return new Date(ts).toISOString();
17
+ }
18
+
19
+ function mapEntityUrls(
20
+ urls?: Array<{ id?: string | null; name?: string | null; url: string }> | null,
21
+ ): EntityUrl[] {
22
+ if (!urls) return [];
23
+ return urls.map((u) => ({
24
+ url: u.url,
25
+ ...(u.id != null ? { id: u.id } : {}),
26
+ ...(u.name != null ? { name: u.name } : {}),
27
+ }));
28
+ }
29
+
30
+ // ---- legacy playlistz types ----
31
+ // (removed: playlistz has no deployed users, so there is no legacy idb data
32
+ // to convert - new playlists are created directly as docs)
33
+
34
+ // ---- spume idb types (inline - do not import from spume source) ----
35
+ // field shapes from tomb/client/spume/src/music/services/storage/types.ts
36
+
37
+ interface SpumeImageMetadata {
38
+ local_blob_id?: string;
39
+ remote_blob_id?: string;
40
+ is_primary: boolean;
41
+ blob_type: "thumbnail" | "waveform" | "original" | "preview";
42
+ }
43
+
44
+ interface SpumePlaylist {
45
+ playlist_id: string;
46
+ title: string;
47
+ description?: string | null;
48
+ is_public?: boolean;
49
+ images?: SpumeImageMetadata[];
50
+ urls?: Array<{ id?: string; name?: string; url: string }>;
51
+ created_at?: number;
52
+ updated_at?: number;
53
+ }
54
+
55
+ interface SpumeSong {
56
+ id: string;
57
+ sha256: string;
58
+ title: string;
59
+ artist_name?: string;
60
+ album_title?: string;
61
+ duration_seconds?: number;
62
+ mime_type?: string | null;
63
+ file_size?: number | null;
64
+ blake3?: string | null;
65
+ lyrics?: string | null;
66
+ images?: SpumeImageMetadata[];
67
+ urls?: Array<{ id?: string; name?: string; url: string }>;
68
+ }
69
+
70
+ // map SpumeImageMetadata -> ImageRef, skipping entries with no blob id
71
+ function mapSpumeImages(images?: SpumeImageMetadata[]): ImageRef[] {
72
+ if (!images) return [];
73
+ const result: ImageRef[] = [];
74
+ for (const img of images) {
75
+ const blobId = img.local_blob_id ?? img.remote_blob_id;
76
+ if (!blobId) continue;
77
+ result.push({
78
+ blobId,
79
+ isPrimary: img.is_primary,
80
+ blobType: img.blob_type,
81
+ });
82
+ }
83
+ return result;
84
+ }
85
+
86
+ // map freqhole wire image (is_primary as 0/1 number) -> ImageRef
87
+ function mapFreqholeImages(
88
+ images?: Array<{
89
+ blob_id: string;
90
+ is_primary: number;
91
+ blob_type: "original" | "thumbnail" | "waveform" | "preview";
92
+ }> | null,
93
+ ): ImageRef[] {
94
+ if (!images) return [];
95
+ return images.map((img) => ({
96
+ blobId: img.blob_id,
97
+ isPrimary: img.is_primary !== 0,
98
+ blobType: img.blob_type,
99
+ }));
100
+ }
101
+
102
+ // ---- converters ----
103
+
104
+ // convert a freqhole wire Playlist + Song[] to a PlaylistDoc.
105
+ // maps snake_case fields and is_primary number (0/1) -> boolean.
106
+ // note: the freqhole Song wire type does not carry album_title; that field defaults to "".
107
+ // media_blob_id is used as sha256 (it is the server's short blob id, not a true content hash).
108
+ export function freqholePlaylistToDoc(
109
+ playlist: FreqholePlaylist,
110
+ songs: FreqholeSong[],
111
+ ): PlaylistDoc {
112
+ const createdAt = tsToIso(playlist.created_at);
113
+ const lastModified = tsToIso(playlist.updated_at);
114
+
115
+ const songEntries: Record<string, SongEntry> = {};
116
+ const order: string[] = [];
117
+
118
+ for (const song of songs) {
119
+ const entry: SongEntry = {
120
+ id: song.id,
121
+ title: song.title ?? "",
122
+ artist: song.track_artist ?? "",
123
+ album: "",
124
+ duration: song.duration ?? 0,
125
+ mimeType: "",
126
+ fileSize: 0,
127
+ sha256: song.media_blob_id,
128
+ images: mapFreqholeImages(song.images),
129
+ urls: mapEntityUrls(song.urls),
130
+ ...(song.lyrics != null ? { lyrics: song.lyrics } : {}),
131
+ };
132
+ songEntries[song.id] = entry;
133
+ order.push(song.id);
134
+ }
135
+
136
+ return emptyPlaylistDoc({
137
+ title: playlist.title ?? "",
138
+ description: playlist.description ?? "",
139
+ createdAt,
140
+ lastModified,
141
+ images: mapFreqholeImages(playlist.images),
142
+ urls: mapEntityUrls(playlist.urls),
143
+ songs: songEntries,
144
+ order,
145
+ });
146
+ }
147
+
148
+ // convert a spume idb Playlist + Song[] to a PlaylistDoc.
149
+ // maps spume's ImageMetadata (local_blob_id/remote_blob_id) to ImageRef.
150
+ // spume Song fields: sha256, artist_name, album_title, duration_seconds, mime_type, file_size, blake3.
151
+ export function spumePlaylistToDoc(
152
+ playlist: SpumePlaylist,
153
+ songs: SpumeSong[],
154
+ ): PlaylistDoc {
155
+ const now = new Date().toISOString();
156
+ const createdAt = playlist.created_at ? tsToIso(playlist.created_at) : now;
157
+ const lastModified = playlist.updated_at ? tsToIso(playlist.updated_at) : now;
158
+
159
+ const songEntries: Record<string, SongEntry> = {};
160
+ const order: string[] = [];
161
+
162
+ for (const song of songs) {
163
+ const entry: SongEntry = {
164
+ id: song.id,
165
+ title: song.title ?? "",
166
+ artist: song.artist_name ?? "",
167
+ album: song.album_title ?? "",
168
+ duration: song.duration_seconds ?? 0,
169
+ mimeType: song.mime_type ?? "",
170
+ fileSize: song.file_size ?? 0,
171
+ sha256: song.sha256,
172
+ images: mapSpumeImages(song.images),
173
+ urls: mapEntityUrls(song.urls),
174
+ ...(song.blake3 != null ? { blake3: song.blake3 } : {}),
175
+ ...(song.lyrics != null ? { lyrics: song.lyrics } : {}),
176
+ };
177
+ songEntries[song.id] = entry;
178
+ order.push(song.id);
179
+ }
180
+
181
+ return emptyPlaylistDoc({
182
+ title: playlist.title ?? "",
183
+ description: playlist.description ?? "",
184
+ createdAt,
185
+ lastModified,
186
+ images: mapSpumeImages(playlist.images),
187
+ urls: mapEntityUrls(playlist.urls),
188
+ songs: songEntries,
189
+ order,
190
+ });
191
+ }
192
+
193
+ // convert a PlaylistDoc back to the freqhole wire format where lossless.
194
+ // id is not stored in the doc - defaults to "". caller must supply the real
195
+ // playlist id if needed (e.g. from the docIndex entry).
196
+ // note: album_title, mime_type, file_size are not in the freqhole Song wire type
197
+ // and are dropped. sha256 is mapped back to media_blob_id.
198
+ export function docToFreqholePlaylist(
199
+ doc: PlaylistDoc,
200
+ ): { playlist: FreqholePlaylist; songs: FreqholeSong[] } {
201
+ const createdAt = doc.createdAt ? new Date(doc.createdAt).getTime() : 0;
202
+ const updatedAt = doc.lastModified ? new Date(doc.lastModified).getTime() : 0;
203
+
204
+ const playlist: FreqholePlaylist = {
205
+ id: "",
206
+ title: doc.title,
207
+ description: doc.description || null,
208
+ is_public: 0,
209
+ images: doc.images.map((img) => ({
210
+ blob_id: img.blobId,
211
+ is_primary: img.isPrimary ? 1 : 0,
212
+ blob_type: img.blobType,
213
+ })),
214
+ urls: doc.urls.map((u) => ({
215
+ url: u.url,
216
+ id: u.id ?? null,
217
+ name: u.name ?? null,
218
+ })),
219
+ created_by_id: null,
220
+ created_at: createdAt,
221
+ updated_at: updatedAt,
222
+ deleted_at: null,
223
+ deleted_by: null,
224
+ created_by: null,
225
+ updated_by: null,
226
+ song_count: doc.order.length,
227
+ };
228
+
229
+ const songs: FreqholeSong[] = doc.order
230
+ .map((id) => doc.songs[id])
231
+ .filter((s): s is SongEntry => s != null)
232
+ .map((s) => {
233
+ return {
234
+ id: s.id,
235
+ media_blob_id: s.sha256,
236
+ title: s.title,
237
+ track_number: 1,
238
+ disc_number: 1,
239
+ duration: s.duration,
240
+ track_artist: s.artist || null,
241
+ images: s.images.map((img) => ({
242
+ blob_id: img.blobId,
243
+ is_primary: img.isPrimary ? 1 : 0,
244
+ blob_type: img.blobType,
245
+ })),
246
+ urls: s.urls.map((u) => ({
247
+ url: u.url,
248
+ id: u.id ?? null,
249
+ name: u.name ?? null,
250
+ })),
251
+ metadata: null,
252
+ lyrics: s.lyrics ?? null,
253
+ created_at: createdAt,
254
+ updated_at: updatedAt,
255
+ deleted_at: null,
256
+ deleted_by: null,
257
+ created_by: null,
258
+ updated_by: null,
259
+ created_by_username: null,
260
+ updated_by_username: null,
261
+ bpm: null,
262
+ play_count: null,
263
+ };
264
+ });
265
+
266
+ return { playlist, songs };
267
+ }
@@ -0,0 +1,6 @@
1
+ // public exports for the playlistz domain package
2
+ export * from "./schema.js";
3
+ export * from "./mutations.js";
4
+ export * from "./convert.js";
5
+ export * from "./shareLink.js";
6
+ export * from "./protocol.js";
@@ -0,0 +1,181 @@
1
+ // pure mutation helpers for PlaylistDoc.
2
+ // each function mutates the doc draft in place - safe to call inside handle.change().
3
+ // no automerge imports: these are plain object mutations over plain objects.
4
+ import type { PlaylistDoc, SongEntry, ImageRef } from "./schema.js";
5
+
6
+ function nowIso(): string {
7
+ return new Date().toISOString();
8
+ }
9
+
10
+ // bump lastModified to now, optionally setting lastModifiedBy
11
+ function bumpModified(doc: PlaylistDoc, by?: string): void {
12
+ doc.lastModified = nowIso();
13
+ if (by !== undefined) {
14
+ doc.lastModifiedBy = by;
15
+ }
16
+ }
17
+
18
+ // insert or replace a song in the doc, appending its id to order if not already present
19
+ export function upsertSong(doc: PlaylistDoc, song: SongEntry, by?: string): void {
20
+ doc.songs[song.id] = song;
21
+ if (!doc.order.includes(song.id)) {
22
+ doc.order.push(song.id);
23
+ }
24
+ bumpModified(doc, by);
25
+ }
26
+
27
+ // remove a song from songs and order
28
+ export function removeSong(doc: PlaylistDoc, songId: string, by?: string): void {
29
+ delete doc.songs[songId];
30
+ const idx = doc.order.indexOf(songId);
31
+ if (idx !== -1) {
32
+ doc.order.splice(idx, 1);
33
+ }
34
+ bumpModified(doc, by);
35
+ }
36
+
37
+ // move a single song to a target index within order.
38
+ // single-item splice keeps automerge merge semantics sane.
39
+ export function reorderSongs(
40
+ doc: PlaylistDoc,
41
+ songId: string,
42
+ toIndex: number,
43
+ by?: string,
44
+ ): void {
45
+ const fromIdx = doc.order.indexOf(songId);
46
+ if (fromIdx === -1) return;
47
+ doc.order.splice(fromIdx, 1);
48
+ const clamped = Math.max(0, Math.min(toIndex, doc.order.length));
49
+ doc.order.splice(clamped, 0, songId);
50
+ bumpModified(doc, by);
51
+ }
52
+
53
+ // playlist metadata fields settable via setMetadata
54
+ type MetadataFields = Partial<
55
+ Pick<
56
+ PlaylistDoc,
57
+ | "title"
58
+ | "description"
59
+ | "bgFilterEnabled"
60
+ | "bgFilterBlur"
61
+ | "bgFilterContrast"
62
+ | "bgFilterBrightness"
63
+ | "coverFilterEnabled"
64
+ | "coverFilterBlur"
65
+ | "bgSize"
66
+ | "bgPosition"
67
+ | "bgRepeat"
68
+ >
69
+ >;
70
+
71
+ // update playlist-level metadata fields
72
+ export function setMetadata(
73
+ doc: PlaylistDoc,
74
+ fields: MetadataFields,
75
+ by?: string,
76
+ ): void {
77
+ if (fields.title !== undefined) doc.title = fields.title;
78
+ if (fields.description !== undefined) doc.description = fields.description;
79
+ for (const key of [
80
+ "bgFilterEnabled",
81
+ "bgFilterBlur",
82
+ "bgFilterContrast",
83
+ "bgFilterBrightness",
84
+ "coverFilterEnabled",
85
+ "coverFilterBlur",
86
+ "bgSize",
87
+ "bgPosition",
88
+ "bgRepeat",
89
+ ] as const) {
90
+ if (fields[key] !== undefined) {
91
+ // assignment is type-safe per key; typescript can't narrow the loop var
92
+ (doc as Record<string, unknown>)[key] = fields[key];
93
+ }
94
+ }
95
+ bumpModified(doc, by);
96
+ }
97
+
98
+ // add an image ref to the playlist or to a specific song.
99
+ // if ref.isPrimary is true, clears isPrimary on all sibling images first.
100
+ export function addImage(
101
+ doc: PlaylistDoc,
102
+ ref: ImageRef,
103
+ target?: { songId?: string },
104
+ by?: string,
105
+ ): void {
106
+ const images =
107
+ target?.songId != null
108
+ ? doc.songs[target.songId]?.images
109
+ : doc.images;
110
+
111
+ if (!images) return;
112
+
113
+ if (ref.isPrimary) {
114
+ for (const img of images) {
115
+ img.isPrimary = false;
116
+ }
117
+ }
118
+ images.push(ref);
119
+ bumpModified(doc, by);
120
+ }
121
+
122
+ // set the primary image by blobId, clearing isPrimary on all others.
123
+ // operates on playlist images or a specific song's images.
124
+ export function setPrimaryImage(
125
+ doc: PlaylistDoc,
126
+ blobId: string,
127
+ target?: { songId?: string },
128
+ by?: string,
129
+ ): void {
130
+ const images =
131
+ target?.songId != null
132
+ ? doc.songs[target.songId]?.images
133
+ : doc.images;
134
+
135
+ if (!images) return;
136
+
137
+ for (const img of images) {
138
+ img.isPrimary = img.blobId === blobId;
139
+ }
140
+ bumpModified(doc, by);
141
+ }
142
+
143
+ // record a peer joining this doc. idempotent - no-op if peer is already present.
144
+ // does not bump lastModified (peer bookkeeping is not a content edit).
145
+ export function addPeer(doc: PlaylistDoc, nodeId: string): void {
146
+ if (doc.peers[nodeId]) return;
147
+ doc.peers[nodeId] = {
148
+ nodeId,
149
+ joinedAt: nowIso(),
150
+ };
151
+ }
152
+
153
+ // update lastSeenAt for an existing peer entry.
154
+ // does not bump lastModified (ephemeral presence, not a content edit).
155
+ export function stampLastSeen(doc: PlaylistDoc, nodeId: string): void {
156
+ const peer = doc.peers[nodeId];
157
+ if (peer) {
158
+ peer.lastSeenAt = nowIso();
159
+ }
160
+ }
161
+
162
+ // set or update the access role for a node in the acl
163
+ export function setAclRole(
164
+ doc: PlaylistDoc,
165
+ nodeId: string,
166
+ role: "owner" | "editor" | "viewer",
167
+ by?: string,
168
+ ): void {
169
+ if (!doc.acl) {
170
+ doc.acl = {};
171
+ }
172
+ doc.acl[nodeId] = { role };
173
+ bumpModified(doc, by);
174
+ }
175
+
176
+ // mark this playlist as deleted (tombstone).
177
+ // the doc remains in the repo; consumers check deleted before rendering.
178
+ export function tombstone(doc: PlaylistDoc, by?: string): void {
179
+ doc.deleted = true;
180
+ bumpModified(doc, by);
181
+ }
@@ -0,0 +1,222 @@
1
+ // freqhole-playlistz/1 discovery and knock protocol.
2
+ // zod-validated message envelopes + BiStream encode/decode helpers.
3
+ // no midden or automerge imports.
4
+ import * as z from "zod";
5
+
6
+ // ALPN strings used when registering iroh endpoints
7
+ export const PLAYLISTZ_ALPN = "freqhole-playlistz/1";
8
+ export const AUTOMERGE_ALPN = "iroh/automerge-repo/1";
9
+
10
+ // ---- message schemas ----
11
+
12
+ const HelloSchema = z.object({
13
+ v: z.literal(1),
14
+ type: z.literal("hello"),
15
+ nodeId: z.string(),
16
+ name: z.string().optional(),
17
+ avatarBlobId: z.string().optional(),
18
+ });
19
+
20
+ const HelloOkSchema = z.object({
21
+ v: z.literal(1),
22
+ type: z.literal("hello_ok"),
23
+ nodeId: z.string(),
24
+ name: z.string().optional(),
25
+ avatarDataUrl: z.string().optional(),
26
+ public: z.boolean(),
27
+ });
28
+
29
+ const ListPlaylistsSchema = z.object({
30
+ v: z.literal(1),
31
+ type: z.literal("list_playlists"),
32
+ });
33
+
34
+ const PlaylistItemSchema = z.object({
35
+ docId: z.string(),
36
+ title: z.string(),
37
+ songCount: z.number(),
38
+ });
39
+
40
+ const PlaylistsSchema = z.object({
41
+ v: z.literal(1),
42
+ type: z.literal("playlists"),
43
+ items: z.array(PlaylistItemSchema),
44
+ });
45
+
46
+ const KnockSchema = z.object({
47
+ v: z.literal(1),
48
+ type: z.literal("knock"),
49
+ nodeId: z.string(),
50
+ name: z.string().optional(),
51
+ message: z.string().optional(),
52
+ knockType: z.enum(["browse", "doc_access"]).optional(), // default: "browse"
53
+ docId: z.string().optional(), // present when knockType is "doc_access"
54
+ });
55
+
56
+ const KnockStatusSchema = z.object({
57
+ v: z.literal(1),
58
+ type: z.literal("knock_status"),
59
+ status: z.union([
60
+ z.literal("pending"),
61
+ z.literal("accepted"),
62
+ z.literal("denied"),
63
+ ]),
64
+ grantedDocIds: z.array(z.string()).optional(),
65
+ });
66
+
67
+ // ask a peer to make a blob available for iroh-blobs verified download.
68
+ // sha256 is the blob store key carried in playlist docs.
69
+ const BlobRequestSchema = z.object({
70
+ v: z.literal(1),
71
+ type: z.literal("blob_request"),
72
+ sha256: z.string(),
73
+ });
74
+
75
+ // reply: the peer imported the blob into its iroh-blobs store.
76
+ // blake3 + size are what download_verified_streaming needs.
77
+ const BlobReadySchema = z.object({
78
+ v: z.literal(1),
79
+ type: z.literal("blob_ready"),
80
+ sha256: z.string(),
81
+ blake3: z.string(),
82
+ size: z.number(),
83
+ });
84
+
85
+ const ErrorSchema = z.object({
86
+ v: z.literal(1),
87
+ type: z.literal("error"),
88
+ code: z.string(),
89
+ message: z.string(),
90
+ });
91
+
92
+ // proactive acceptance notification: owner opens a stream to the peer and
93
+ // sends this after accepting their knock, so the peer doesn't have to poll.
94
+ const KnockNotifySchema = z.object({
95
+ v: z.literal(1),
96
+ type: z.literal("knock_notify"),
97
+ status: z.literal("accepted"),
98
+ docIds: z.array(z.string()),
99
+ ownerNodeId: z.string(),
100
+ });
101
+
102
+ // proactive identity update: a peer broadcasts their current name/avatar
103
+ // to all peers they have established connections with.
104
+ const IdentityUpdateSchema = z.object({
105
+ v: z.literal(1),
106
+ type: z.literal("identity_update"),
107
+ name: z.string().optional(),
108
+ avatarDataUrl: z.string().optional(),
109
+ });
110
+
111
+ // discriminated union of all protocol messages
112
+ export const MessageSchema = z.discriminatedUnion("type", [
113
+ HelloSchema,
114
+ HelloOkSchema,
115
+ ListPlaylistsSchema,
116
+ PlaylistsSchema,
117
+ KnockSchema,
118
+ KnockStatusSchema,
119
+ BlobRequestSchema,
120
+ BlobReadySchema,
121
+ ErrorSchema,
122
+ KnockNotifySchema,
123
+ IdentityUpdateSchema,
124
+ ]);
125
+ export type Message = z.infer<typeof MessageSchema>;
126
+
127
+ // convenience inferred types per message kind
128
+ export type HelloMessage = z.infer<typeof HelloSchema>;
129
+ export type HelloOkMessage = z.infer<typeof HelloOkSchema>;
130
+ export type ListPlaylistsMessage = z.infer<typeof ListPlaylistsSchema>;
131
+ export type PlaylistsMessage = z.infer<typeof PlaylistsSchema>;
132
+ export type KnockMessage = z.infer<typeof KnockSchema>;
133
+ export type KnockStatusMessage = z.infer<typeof KnockStatusSchema>;
134
+ export type BlobRequestMessage = z.infer<typeof BlobRequestSchema>;
135
+ export type BlobReadyMessage = z.infer<typeof BlobReadySchema>;
136
+ export type ErrorMessage = z.infer<typeof ErrorSchema>;
137
+ export type KnockNotifyMessage = z.infer<typeof KnockNotifySchema>;
138
+
139
+ // ---- BiStream structural interface ----
140
+ // matches the shape provided by midden without importing it
141
+
142
+ export interface BiStreamLike {
143
+ write_message(data: Uint8Array): Promise<void>;
144
+ read_message(): Promise<Uint8Array | null>;
145
+ close(): void;
146
+ peer_node_id(): string;
147
+ alpn(): string;
148
+ }
149
+
150
+ // ---- codec ----
151
+
152
+ // error thrown when decoding an invalid or unrecognized message
153
+ export class ProtocolError extends Error {
154
+ readonly code: string;
155
+
156
+ constructor(code: string, message: string) {
157
+ super(message);
158
+ this.name = "ProtocolError";
159
+ this.code = code;
160
+ }
161
+ }
162
+
163
+ const encoder = new TextEncoder();
164
+ const decoder = new TextDecoder();
165
+
166
+ // serialize a message to utf-8 JSON bytes
167
+ export function encodeMessage(msg: Message): Uint8Array {
168
+ return encoder.encode(JSON.stringify(msg));
169
+ }
170
+
171
+ // parse utf-8 JSON bytes back to a Message.
172
+ // throws ProtocolError with a descriptive code on invalid input.
173
+ export function decodeMessage(bytes: Uint8Array): Message {
174
+ let json: string;
175
+ try {
176
+ json = decoder.decode(bytes);
177
+ } catch (err) {
178
+ throw new ProtocolError(
179
+ "decode_error",
180
+ `failed to decode utf-8: ${err instanceof Error ? err.message : String(err)}`,
181
+ );
182
+ }
183
+
184
+ let raw: unknown;
185
+ try {
186
+ raw = JSON.parse(json);
187
+ } catch (err) {
188
+ throw new ProtocolError(
189
+ "parse_error",
190
+ `failed to parse json: ${err instanceof Error ? err.message : String(err)}`,
191
+ );
192
+ }
193
+
194
+ const result = MessageSchema.safeParse(raw);
195
+ if (!result.success) {
196
+ const firstIssue = result.error.issues[0];
197
+ throw new ProtocolError(
198
+ "schema_error",
199
+ `invalid message: ${firstIssue?.message ?? "unknown"}`,
200
+ );
201
+ }
202
+ return result.data;
203
+ }
204
+
205
+ // write a message to a BiStream
206
+ export async function sendMessage(
207
+ stream: BiStreamLike,
208
+ msg: Message,
209
+ ): Promise<void> {
210
+ await stream.write_message(encodeMessage(msg));
211
+ }
212
+
213
+ // read the next message from a BiStream.
214
+ // returns null on clean EOF (read_message returned null).
215
+ // throws ProtocolError on invalid data.
216
+ export async function readMessage(
217
+ stream: BiStreamLike,
218
+ ): Promise<Message | null> {
219
+ const bytes = await stream.read_message();
220
+ if (bytes === null) return null;
221
+ return decodeMessage(bytes);
222
+ }