@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,261 @@
1
+ // instance-based freqhole client
2
+ //
3
+ // provides typed methods for all API routes, using a Transport for the
4
+ // underlying request/response handling. Zod validation happens here.
5
+
6
+ import { z } from "zod";
7
+ import type { Transport, BlobData } from "./transport.js";
8
+ import { HttpTransport } from "./transport.js";
9
+ import { createAdminMethods } from "./domains/admin.js";
10
+ import { createAppMethods } from "./domains/app.js";
11
+ import { createAuthMethods } from "./domains/auth.js";
12
+ import { createJobsMethods } from "./domains/jobs.js";
13
+ import { createMusicMethods } from "./domains/music.js";
14
+ import { createUploadMethods } from "./domains/upload.js";
15
+ import type { CallFn, SafeParseResult } from "./domains/types.js";
16
+
17
+ // re-export types for consumers
18
+ export type { SafeParseResult } from "./domains/types.js";
19
+
20
+ // sentinel for 401 detection
21
+ const AUTH_ERROR_PATH = "__auth_expired__";
22
+
23
+ // ============================================================================
24
+ // FreqholeClient
25
+ // ============================================================================
26
+
27
+ export class FreqholeClient {
28
+ public readonly admin: ReturnType<typeof createAdminMethods>;
29
+ public readonly app: ReturnType<typeof createAppMethods>;
30
+ public readonly auth: ReturnType<typeof createAuthMethods>;
31
+ public readonly jobs: ReturnType<typeof createJobsMethods>;
32
+ public readonly music: ReturnType<typeof createMusicMethods>;
33
+ public readonly upload: ReturnType<typeof createUploadMethods>;
34
+
35
+ constructor(public readonly transport: Transport) {
36
+ const call = this.createCallFn();
37
+ this.admin = createAdminMethods(call);
38
+ this.app = createAppMethods(call);
39
+ this.auth = createAuthMethods(call);
40
+ this.jobs = createJobsMethods(transport);
41
+ this.music = createMusicMethods(call);
42
+ this.upload = createUploadMethods(transport);
43
+ }
44
+
45
+ // -------------------------------------------------------------------------
46
+ // internal call helper factory
47
+ // -------------------------------------------------------------------------
48
+
49
+ private createCallFn(): CallFn {
50
+ return async <Resp>(
51
+ domain: string,
52
+ routeName: string,
53
+ respSchema: z.ZodType<Resp> | null,
54
+ reqSchema: z.ZodTypeAny | null,
55
+ method: string,
56
+ path: string,
57
+ params?: any,
58
+ ): Promise<SafeParseResult<Resp>> => {
59
+ // validate request body for POST/PUT/PATCH
60
+ const isFormData = params instanceof FormData;
61
+ if (method !== "GET" && method !== "DELETE" && method !== "HEAD" && reqSchema && params && !isFormData) {
62
+ const validated = reqSchema.safeParse(params);
63
+ if (!validated.success) {
64
+ return { success: false, error: validated.error };
65
+ }
66
+ params = validated.data;
67
+ }
68
+
69
+ // interpolate path params
70
+ let finalPath = path;
71
+ if (params && finalPath.includes("{")) {
72
+ finalPath = finalPath.replace(/\{(\w+)\}/g, (_, key) => {
73
+ return params[key] !== undefined ? encodeURIComponent(params[key]) : `{${key}}`;
74
+ });
75
+ }
76
+
77
+ // prepare body
78
+ const body = method !== "GET" && method !== "DELETE" && method !== "HEAD" && params && !isFormData
79
+ ? JSON.stringify(params)
80
+ : undefined;
81
+
82
+ try {
83
+ const response = await this.transport.request(method, finalPath, body);
84
+
85
+ // handle errors: status >= 400 OR status 0 (IPC/network failure)
86
+ if (response.status >= 400 || response.status === 0) {
87
+ // try to extract error details
88
+ let errorMessage = response.status === 0 ? "connection error" : `HTTP ${response.status}`;
89
+ let errorCode: string | undefined;
90
+ try {
91
+ const errorBody = JSON.parse(response.body);
92
+ if (errorBody?.error) {
93
+ errorMessage = response.status === 0
94
+ ? errorBody.error
95
+ : `HTTP ${response.status}: ${errorBody.error}`;
96
+ }
97
+ if (errorBody?.message) {
98
+ errorMessage = response.status === 0
99
+ ? errorBody.message
100
+ : `HTTP ${response.status}: ${errorBody.message}`;
101
+ }
102
+ if (errorBody?.code) {
103
+ errorCode = errorBody.code;
104
+ }
105
+ } catch {
106
+ // body wasn't JSON
107
+ }
108
+
109
+ const issuePath: (string | number)[] = [];
110
+ if (response.status === 401) {
111
+ issuePath.push(AUTH_ERROR_PATH);
112
+ }
113
+ if (errorCode) {
114
+ issuePath.push(errorCode);
115
+ }
116
+
117
+ return {
118
+ success: false,
119
+ error: new z.ZodError([
120
+ { code: "custom", path: issuePath, message: errorMessage },
121
+ ]),
122
+ };
123
+ }
124
+
125
+ // no response schema (e.g., blob streaming)
126
+ if (!respSchema) {
127
+ return { success: true, data: null as any };
128
+ }
129
+
130
+ // parse and validate response
131
+ const json = JSON.parse(response.body);
132
+
133
+ // check for GrimoireResponse failure (success: false with errors)
134
+ if (json.success === false) {
135
+ const errorMessage = json.message || json.errors?.[0]?.detail || "request failed";
136
+ const errorCode = json.errors?.[0]?.error_type;
137
+ const issuePath: (string | number)[] = [];
138
+ if (errorCode === "unauthorized") {
139
+ issuePath.push(AUTH_ERROR_PATH);
140
+ }
141
+ if (errorCode) {
142
+ issuePath.push(errorCode);
143
+ }
144
+ return {
145
+ success: false,
146
+ error: new z.ZodError([
147
+ { code: "custom", path: issuePath, message: errorMessage },
148
+ ]),
149
+ };
150
+ }
151
+
152
+ const data = json.data ?? json;
153
+
154
+ const result = respSchema.safeParse(data);
155
+ if (result.success) {
156
+ return { success: true, data: result.data };
157
+ } else {
158
+ console.warn(`[API] Zod validation failed for ${domain}.${routeName}:`, result.error);
159
+ return { success: false, error: result.error };
160
+ }
161
+ } catch (err) {
162
+ // tauri invoke rejects with whatever the Rust command returned in
163
+ // Err(...). that's often a plain string (not an Error instance), so
164
+ // `err.message` is undefined. coerce string/object rejections to
165
+ // their string form before falling back to "network error", otherwise
166
+ // we'd swallow useful messages like
167
+ // "federation api error: failed to connect to peer ...: No addressing information available"
168
+ let message = "network error";
169
+ if (err instanceof Error) {
170
+ message = err.message;
171
+ } else if (typeof err === "string" && err.length > 0) {
172
+ message = err;
173
+ } else if (err != null) {
174
+ const s = String(err);
175
+ if (s && s !== "[object Object]") message = s;
176
+ }
177
+ return {
178
+ success: false,
179
+ error: new z.ZodError([
180
+ { code: "custom", path: [], message },
181
+ ]),
182
+ };
183
+ }
184
+ };
185
+ }
186
+
187
+ // -------------------------------------------------------------------------
188
+ // blob helpers
189
+ // -------------------------------------------------------------------------
190
+
191
+ /**
192
+ * get a URL for a blob (audio/image)
193
+ * for HTTP, returns direct URL. for P2P, may involve caching.
194
+ */
195
+ getBlobUrl(blobId: string): string | Promise<string> {
196
+ return this.transport.getBlobUrl(blobId);
197
+ }
198
+
199
+ /**
200
+ * fetch blob data directly
201
+ */
202
+ fetchBlob(blobId: string): Promise<BlobData> {
203
+ return this.transport.fetchBlob(blobId);
204
+ }
205
+ }
206
+
207
+ // ============================================================================
208
+ // factory functions
209
+ // ============================================================================
210
+
211
+ /**
212
+ * create a FreqholeClient with the given transport
213
+ */
214
+ export function createClient(transport: Transport): FreqholeClient {
215
+ return new FreqholeClient(transport);
216
+ }
217
+
218
+ /**
219
+ * create a FreqholeClient for HTTP
220
+ * convenience function - equivalent to createClient(new HttpTransport(baseUrl, apiKey))
221
+ */
222
+ export function createHttpClient(baseUrl: string, apiKey?: string): FreqholeClient {
223
+ return new FreqholeClient(new HttpTransport(baseUrl, apiKey));
224
+ }
225
+
226
+ // ============================================================================
227
+ // error helpers
228
+ // ============================================================================
229
+
230
+ export function isAuthError<T>(result: SafeParseResult<T>): boolean {
231
+ if (result.success) return false;
232
+ return result.error.issues.some(
233
+ (issue) => issue.code === "custom" && issue.path.includes(AUTH_ERROR_PATH),
234
+ );
235
+ }
236
+
237
+ export function isNetworkError<T>(result: SafeParseResult<T>): boolean {
238
+ if (result.success) return false;
239
+ return result.error.issues.some(
240
+ (issue) => {
241
+ if (issue.code !== "custom") return false;
242
+ const msg = issue.message.toLowerCase();
243
+
244
+ // HTTP fetch errors
245
+ if (msg === "failed to fetch" || msg === "network error") return true;
246
+
247
+ // P2P/iroh connection errors - be generous with matching
248
+ if (msg.includes("connection")) return true; // connection failed, closed, refused, etc
249
+ if (msg.includes("connect to peer")) return true; // "failed to connect to peer ..."
250
+ if (msg.includes("federation api error")) return true; // wrapper from p2p_client
251
+ if (msg.includes("no addressing information")) return true; // iroh: peer has no relay/direct addrs
252
+ if (msg.includes("timeout") || msg.includes("unreachable")) return true;
253
+ if (msg.includes("closed")) return true; // ClosedPath, stream closed, etc
254
+ if (msg.includes("no route") || msg.includes("endpoint")) return true;
255
+ if (msg.includes("stream") && (msg.includes("error") || msg.includes("failed"))) return true;
256
+ if (msg.includes("read") && msg.includes("error")) return true;
257
+
258
+ return false;
259
+ }
260
+ );
261
+ }