@freqhole/api-client 0.1.30 → 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.
@@ -1,222 +0,0 @@
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
- }
@@ -1,138 +0,0 @@
1
- // zod schemas for the playlistz automerge document model.
2
- // plain objects + zod only - no automerge imports.
3
- import * as z from "zod";
4
-
5
- // the set of blob types a stored binary can represent
6
- export const BlobKindSchema = z.union([
7
- z.literal("original"),
8
- z.literal("thumbnail"),
9
- z.literal("waveform"),
10
- z.literal("preview"),
11
- ]);
12
- export type BlobKind = z.infer<typeof BlobKindSchema>;
13
-
14
- // a reference to a binary stored in the blob store, keyed by sha256
15
- export const ImageRefSchema = z.object({
16
- blobId: z.string(),
17
- isPrimary: z.boolean(),
18
- blobType: BlobKindSchema,
19
- });
20
- export type ImageRef = z.infer<typeof ImageRefSchema>;
21
-
22
- // a named url associated with a playlist or song
23
- export const EntityUrlSchema = z.object({
24
- id: z.string().optional(),
25
- name: z.string().optional(),
26
- url: z.string(),
27
- });
28
- export type EntityUrl = z.infer<typeof EntityUrlSchema>;
29
-
30
- // a single song as stored in the playlist doc
31
- export const SongEntrySchema = z.object({
32
- id: z.string(),
33
- title: z.string(),
34
- artist: z.string(),
35
- album: z.string(),
36
- duration: z.number(),
37
- mimeType: z.string(),
38
- fileSize: z.number(),
39
- sha256: z.string(),
40
- blake3: z.string().optional(),
41
- images: z.array(ImageRefSchema).default([]),
42
- urls: z.array(EntityUrlSchema).default([]),
43
- lyrics: z.string().optional(),
44
- });
45
- export type SongEntry = z.infer<typeof SongEntrySchema>;
46
-
47
- // the top-level automerge document for a playlist.
48
- // all fields have defaults so schema.parse({}) succeeds for graceful degradation.
49
- export const PlaylistDocSchema = z.object({
50
- version: z.literal(1).default(1),
51
- title: z.string().default(""),
52
- description: z.string().default(""),
53
- createdAt: z.string().default(() => new Date().toISOString()),
54
- lastModified: z.string().default(() => new Date().toISOString()),
55
- lastModifiedBy: z.string().default(""),
56
- images: z.array(ImageRefSchema).default([]),
57
- urls: z.array(EntityUrlSchema).default([]),
58
- songs: z.record(z.string(), SongEntrySchema).default({}),
59
- order: z.array(z.string()).default([]),
60
- peers: z
61
- .record(
62
- z.string(),
63
- z.object({
64
- nodeId: z.string(),
65
- joinedAt: z.string(),
66
- lastSeenAt: z.string().optional(),
67
- }),
68
- )
69
- .default({}),
70
- acl: z
71
- .record(
72
- z.string(),
73
- z.object({
74
- role: z.union([
75
- z.literal("owner"),
76
- z.literal("editor"),
77
- z.literal("viewer"),
78
- ]),
79
- }),
80
- )
81
- .optional(),
82
- deleted: z.boolean().optional(),
83
- // per-playlist sharing mode. unset = private (not shared).
84
- sharingMode: z.union([z.literal("public"), z.literal("knock")]).optional(),
85
- // display filter settings for the playlist background and cover
86
- bgFilterEnabled: z.boolean().optional(),
87
- bgFilterBlur: z.number().optional(),
88
- bgFilterContrast: z.number().optional(),
89
- bgFilterBrightness: z.number().optional(),
90
- coverFilterEnabled: z.boolean().optional(),
91
- coverFilterBlur: z.number().optional(),
92
- // background image layout
93
- bgSize: z.string().optional(), // css background-size, default: "cover"
94
- bgPosition: z.string().optional(), // css background-position, default: "top"
95
- bgRepeat: z.string().optional(), // css background-repeat, default: "no-repeat"
96
- });
97
- export type PlaylistDoc = z.infer<typeof PlaylistDocSchema>;
98
-
99
- // recursively copy an object keeping only string-keyed enumerable props.
100
- // automerge doc proxies carry symbol keys (_am_objectId, _am_datatype_) that
101
- // zod records reject, so raw docs are plainified before parsing.
102
- function plainify(value: unknown): unknown {
103
- if (Array.isArray(value)) return value.map(plainify);
104
- if (value && typeof value === "object") {
105
- const out: Record<string, unknown> = {};
106
- for (const key of Object.keys(value)) {
107
- out[key] = plainify((value as Record<string, unknown>)[key]);
108
- }
109
- return out;
110
- }
111
- return value;
112
- }
113
-
114
- // parse raw unknown data into a PlaylistDoc.
115
- // on failure, logs a warning and returns schema defaults (graceful degradation).
116
- export function parsePlaylistDoc(raw: unknown): PlaylistDoc {
117
- try {
118
- return PlaylistDocSchema.parse(plainify(raw ?? {}));
119
- } catch (err) {
120
- console.warn(
121
- "[playlistz:schema] parse failed - falling back to defaults. error:",
122
- err instanceof z.ZodError ? err.issues : err,
123
- "raw keys:",
124
- raw && typeof raw === "object" ? Object.keys(raw) : "null",
125
- );
126
- return PlaylistDocSchema.parse({});
127
- }
128
- }
129
-
130
- // create an empty PlaylistDoc, optionally merged with caller-supplied fields.
131
- export function emptyPlaylistDoc(init?: Partial<PlaylistDoc>): PlaylistDoc {
132
- const now = new Date().toISOString();
133
- return PlaylistDocSchema.parse({
134
- createdAt: now,
135
- lastModified: now,
136
- ...init,
137
- });
138
- }
@@ -1,76 +0,0 @@
1
- // share token encoding/decoding for playlistz p2p sharing.
2
- // versioned base64url payload, no padding.
3
- // format: base64url({ v: 1, n: nodeId, d: docId, t?: titleHint })
4
- // url fragment: #share/<token>
5
- import * as z from "zod";
6
-
7
- // v1 share payload: node id, doc id, optional title hint, optional mode hint
8
- const SharePayloadV1Schema = z.object({
9
- v: z.literal(1),
10
- n: z.string().min(1),
11
- d: z.string().min(1),
12
- t: z.string().optional(),
13
- m: z.enum(["public", "knock"]).optional(), // sharing mode at link-creation time
14
- });
15
- export type SharePayloadV1 = z.infer<typeof SharePayloadV1Schema>;
16
-
17
- // convert standard base64 to base64url (no padding)
18
- function toBase64Url(b64: string): string {
19
- return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
20
- }
21
-
22
- // normalize base64url back to standard base64 for decoding
23
- function fromBase64Url(b64url: string): string {
24
- let s = b64url.replace(/-/g, "+").replace(/_/g, "/");
25
- // re-add padding
26
- while (s.length % 4 !== 0) {
27
- s += "=";
28
- }
29
- return s;
30
- }
31
-
32
- // encode a share payload to a base64url token (no padding)
33
- export function encodeShareToken(payload: SharePayloadV1): string {
34
- const json = JSON.stringify(payload);
35
- const b64 = btoa(json);
36
- return toBase64Url(b64);
37
- }
38
-
39
- // decode a share token back to a SharePayloadV1.
40
- // forgiving: strips whitespace, full urls, "#share/" and "share/" prefixes.
41
- // accepts both base64 and base64url.
42
- // returns null on anything invalid.
43
- export function decodeShareToken(input: string): SharePayloadV1 | null {
44
- try {
45
- let raw = input.trim();
46
-
47
- // strip full url up to and including the #share/ fragment
48
- const hashIdx = raw.indexOf("#share/");
49
- if (hashIdx !== -1) {
50
- raw = raw.slice(hashIdx + 7);
51
- } else if (raw.startsWith("share/")) {
52
- raw = raw.slice(6);
53
- }
54
-
55
- // strip any trailing fragment or query that may have been appended
56
- const ampIdx = raw.indexOf("&");
57
- if (ampIdx !== -1) raw = raw.slice(0, ampIdx);
58
-
59
- if (!raw) return null;
60
-
61
- const b64 = fromBase64Url(raw);
62
- const json = atob(b64);
63
- const parsed: unknown = JSON.parse(json);
64
-
65
- const result = SharePayloadV1Schema.safeParse(parsed);
66
- if (!result.success) return null;
67
- return result.data;
68
- } catch {
69
- return null;
70
- }
71
- }
72
-
73
- // build a url fragment for embedding in window.location.hash
74
- export function shareFragment(payload: SharePayloadV1): string {
75
- return `#share/${encodeShareToken(payload)}`;
76
- }