@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.
- package/auth-test.html +651 -0
- package/package.json +42 -0
- package/src/AdminClient.ts +154 -0
- package/src/CharnelLocalTransport.ts +598 -0
- package/src/CharnelTransport.ts +616 -0
- package/src/FreqholeClient.ts +261 -0
- package/src/WasmTransport.ts +1077 -0
- package/src/automerge/IrohNetworkAdapter.ts +727 -0
- package/src/automerge/index.ts +14 -0
- package/src/codegen/admin_commands.ts +61 -0
- package/src/codegen/routes.ts +213 -0
- package/src/codegen/schema.ts +6721 -0
- package/src/domains/admin.ts +97 -0
- package/src/domains/app.ts +98 -0
- package/src/domains/auth.ts +191 -0
- package/src/domains/favorites.types.ts +52 -0
- package/src/domains/jobs.ts +48 -0
- package/src/domains/music.ts +1935 -0
- package/src/domains/types.ts +19 -0
- package/src/domains/upload.ts +214 -0
- package/src/index.ts +48 -0
- package/src/permissions.ts +468 -0
- package/src/playlistz/convert.ts +267 -0
- package/src/playlistz/index.ts +6 -0
- package/src/playlistz/mutations.ts +181 -0
- package/src/playlistz/protocol.ts +222 -0
- package/src/playlistz/schema.ts +138 -0
- package/src/playlistz/shareLink.ts +76 -0
- package/src/storage/blobs.ts +272 -0
- package/src/storage/identity.ts +190 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/webLocks.ts +92 -0
- package/src/test/automerge.ts +796 -0
- package/src/test/coverage.ts +105 -0
- package/src/test/fixtures.ts +460 -0
- package/src/test/integration.ts +320 -0
- package/src/test/playlistz.ts +631 -0
- package/src/test/stateful.ts +561 -0
- package/src/test/storage.ts +443 -0
- package/src/test.ts +75 -0
- package/src/transport.ts +391 -0
- package/src/utils.ts +79 -0
- package/src/webauthn.ts +228 -0
- package/tsconfig.json +22 -0
- 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
|
+
}
|