@pylonsync/react 0.2.4
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 +24 -0
- package/src/db.ts +218 -0
- package/src/hooks.ts +922 -0
- package/src/index.ts +610 -0
- package/src/typed.ts +149 -0
- package/src/useRoom.ts +231 -0
- package/src/useSession.ts +71 -0
- package/src/useShard.ts +299 -0
- package/tsconfig.json +7 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
export { defineRoute } from "@pylonsync/sdk";
|
|
2
|
+
export type { RouteMode, AppManifest } from "@pylonsync/sdk";
|
|
3
|
+
|
|
4
|
+
import { defaultStorage, type Storage as PylonStorage } from "@pylonsync/sync";
|
|
5
|
+
|
|
6
|
+
// React hooks — high-level ergonomic shape
|
|
7
|
+
export {
|
|
8
|
+
useQuery,
|
|
9
|
+
useQueryOne,
|
|
10
|
+
useMutation,
|
|
11
|
+
useInfiniteQuery,
|
|
12
|
+
usePaginatedQuery,
|
|
13
|
+
useEntityMutation,
|
|
14
|
+
useAction,
|
|
15
|
+
useQueryRaw,
|
|
16
|
+
useQueryOneRaw,
|
|
17
|
+
useLiveList,
|
|
18
|
+
useLiveRow,
|
|
19
|
+
useInsert,
|
|
20
|
+
useUpdate,
|
|
21
|
+
useDelete,
|
|
22
|
+
useFn,
|
|
23
|
+
useAggregate,
|
|
24
|
+
useSearch,
|
|
25
|
+
} from "./hooks";
|
|
26
|
+
export type {
|
|
27
|
+
QueryOptions,
|
|
28
|
+
QueryFilter,
|
|
29
|
+
IncludeSpec,
|
|
30
|
+
UseQueryReturn,
|
|
31
|
+
UseQueryOneReturn,
|
|
32
|
+
UseMutationReturn,
|
|
33
|
+
UseInfiniteQueryReturn,
|
|
34
|
+
UsePaginatedQueryReturn,
|
|
35
|
+
PaginatedQueryStatus,
|
|
36
|
+
UseFnReturn,
|
|
37
|
+
AggregateSpec,
|
|
38
|
+
UseAggregateReturn,
|
|
39
|
+
SearchSpec,
|
|
40
|
+
UseSearchReturn,
|
|
41
|
+
} from "./hooks";
|
|
42
|
+
|
|
43
|
+
// Room hook
|
|
44
|
+
export { useRoom } from "./useRoom";
|
|
45
|
+
export type {
|
|
46
|
+
RoomPeer,
|
|
47
|
+
RoomSnapshot,
|
|
48
|
+
UseRoomOptions,
|
|
49
|
+
UseRoomReturn,
|
|
50
|
+
} from "./useRoom";
|
|
51
|
+
|
|
52
|
+
// Shard hook for real-time sims (games, MMO, live docs, etc.)
|
|
53
|
+
export { useShard, connectShard } from "./useShard";
|
|
54
|
+
export type {
|
|
55
|
+
UseShardOptions,
|
|
56
|
+
UseShardReturn,
|
|
57
|
+
ShardClient,
|
|
58
|
+
} from "./useShard";
|
|
59
|
+
|
|
60
|
+
// Session hook — server-resolved user + tenant identity
|
|
61
|
+
export { useSession } from "./useSession";
|
|
62
|
+
export type { UseSessionReturn, ResolvedSession } from "./useSession";
|
|
63
|
+
|
|
64
|
+
// One-liner API
|
|
65
|
+
export { db, init } from "./db";
|
|
66
|
+
|
|
67
|
+
// Typed client (consumes generated AppSchema)
|
|
68
|
+
export { createTypedDb } from "./typed";
|
|
69
|
+
export type { TypedDb, AgentDBSchema } from "./typed";
|
|
70
|
+
|
|
71
|
+
// Re-export sync engine for direct use.
|
|
72
|
+
export {
|
|
73
|
+
SyncEngine,
|
|
74
|
+
createSyncEngine,
|
|
75
|
+
getServerData,
|
|
76
|
+
LocalStore,
|
|
77
|
+
MutationQueue,
|
|
78
|
+
} from "@pylonsync/sync";
|
|
79
|
+
export type {
|
|
80
|
+
ChangeEvent,
|
|
81
|
+
SyncCursor,
|
|
82
|
+
PullResponse,
|
|
83
|
+
HydrationData,
|
|
84
|
+
Row,
|
|
85
|
+
} from "@pylonsync/sync";
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Client context
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
export interface AgentDBClientConfig {
|
|
92
|
+
baseUrl?: string;
|
|
93
|
+
/**
|
|
94
|
+
* App identifier used to namespace all client-side storage keys —
|
|
95
|
+
* localStorage (token, cached user, feature-flag toggles) and
|
|
96
|
+
* IndexedDB (sync replica). Two apps served from the same browser
|
|
97
|
+
* origin (different ports in dev, or the same domain in prod) must
|
|
98
|
+
* pick different names or they'll see each other's sessions and
|
|
99
|
+
* local replicas. Defaults to "default" for a single-app setup.
|
|
100
|
+
*/
|
|
101
|
+
appName?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let _baseUrl = "http://localhost:4321";
|
|
105
|
+
let _baseUrlConfigured = false;
|
|
106
|
+
let _appName = "default";
|
|
107
|
+
|
|
108
|
+
/** Current effective base URL. Used by hooks (useRoom, useShard) that share
|
|
109
|
+
* the client config but don't have access to the module-private state. */
|
|
110
|
+
export function getBaseUrl(): string {
|
|
111
|
+
return _baseUrl;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Current app name. Used by sync engine + storage helpers to namespace keys. */
|
|
115
|
+
export function getAppName(): string {
|
|
116
|
+
return _appName;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the localStorage key for a conceptual slot (e.g. "token",
|
|
121
|
+
* "user") into its actual storage key. When `appName` is "default" we
|
|
122
|
+
* fall back to the legacy unprefixed key so older single-app setups
|
|
123
|
+
* keep working without migration.
|
|
124
|
+
*/
|
|
125
|
+
export function storageKey(slot: string): string {
|
|
126
|
+
if (_appName === "default") return `pylon_${slot}`;
|
|
127
|
+
return `pylon:${_appName}:${slot}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function configureClient(config: AgentDBClientConfig): void {
|
|
131
|
+
if (config.baseUrl) {
|
|
132
|
+
_baseUrl = config.baseUrl;
|
|
133
|
+
_baseUrlConfigured = true;
|
|
134
|
+
maybeWarnDowngrade(config.baseUrl);
|
|
135
|
+
}
|
|
136
|
+
if (config.appName) {
|
|
137
|
+
_appName = config.appName;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Shout loudly if the configured baseUrl is http:// while the current page
|
|
143
|
+
* is served over https://. That combination means auth/session traffic
|
|
144
|
+
* ships in plaintext to a (possibly different) origin — either a misconfig
|
|
145
|
+
* or a downgrade attack via stale config. Browsers typically also block
|
|
146
|
+
* mixed-content requests silently, so the warning helps debugging.
|
|
147
|
+
*/
|
|
148
|
+
function maybeWarnDowngrade(baseUrl: string): void {
|
|
149
|
+
try {
|
|
150
|
+
if (typeof window === "undefined") return;
|
|
151
|
+
const page = window.location?.protocol;
|
|
152
|
+
if (page === "https:" && baseUrl.startsWith("http://")) {
|
|
153
|
+
console.warn(
|
|
154
|
+
`[pylon] configured baseUrl is http:// but page origin is https:// — auth traffic will be blocked or sent in plaintext: ${baseUrl}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
/* ignore */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* In non-localhost production builds, refuse to use the built-in
|
|
164
|
+
* http://localhost:4321 default — that default existing silently was how
|
|
165
|
+
* a forgotten `configureClient` call could ship user tokens in the clear
|
|
166
|
+
* to a broken dev URL. Throws instead of downgrading; a `configureClient`
|
|
167
|
+
* call with an explicit origin fixes it.
|
|
168
|
+
*
|
|
169
|
+
* Local development still gets the convenience default.
|
|
170
|
+
*/
|
|
171
|
+
function assertBaseUrlSafeForEnv(): void {
|
|
172
|
+
if (_baseUrlConfigured) return;
|
|
173
|
+
if (typeof window === "undefined") return;
|
|
174
|
+
const host = window.location?.hostname ?? "";
|
|
175
|
+
const isLocal =
|
|
176
|
+
host === "" ||
|
|
177
|
+
host === "localhost" ||
|
|
178
|
+
host === "127.0.0.1" ||
|
|
179
|
+
host.endsWith(".localhost");
|
|
180
|
+
if (!isLocal) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
"[pylon] configureClient({ baseUrl }) must be called before any " +
|
|
183
|
+
"request when the app is not running on localhost. Using the " +
|
|
184
|
+
"built-in http://localhost:4321 default in production would ship " +
|
|
185
|
+
"user credentials to the wrong origin.",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function apiRequest(
|
|
191
|
+
method: string,
|
|
192
|
+
path: string,
|
|
193
|
+
body?: unknown
|
|
194
|
+
): Promise<unknown> {
|
|
195
|
+
assertBaseUrlSafeForEnv();
|
|
196
|
+
// Auto-attach the session token so `db.insert`, `fetchList`, etc. behave
|
|
197
|
+
// as the signed-in user without every call site threading the header.
|
|
198
|
+
// Safe: `currentAuthToken` is a no-op server-side.
|
|
199
|
+
const headers: Record<string, string> = {};
|
|
200
|
+
if (body) headers["Content-Type"] = "application/json";
|
|
201
|
+
const token = currentAuthToken();
|
|
202
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
203
|
+
const res = await fetch(`${_baseUrl}${path}`, {
|
|
204
|
+
method,
|
|
205
|
+
headers,
|
|
206
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
207
|
+
});
|
|
208
|
+
if (!res.ok) {
|
|
209
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
210
|
+
const errorObj = err?.error as Record<string, unknown> | undefined;
|
|
211
|
+
throw new Error((errorObj?.message as string) ?? `HTTP ${res.status}`);
|
|
212
|
+
}
|
|
213
|
+
return res.json();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Direct data access (non-synced, for server components / one-shot reads)
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
export async function fetchList(entity: string): Promise<Record<string, unknown>[]> {
|
|
221
|
+
return apiRequest("GET", `/api/entities/${entity}`) as Promise<Record<string, unknown>[]>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function fetchById(
|
|
225
|
+
entity: string,
|
|
226
|
+
id: string
|
|
227
|
+
): Promise<Record<string, unknown> | null> {
|
|
228
|
+
try {
|
|
229
|
+
return (await apiRequest("GET", `/api/entities/${entity}/${id}`)) as Record<string, unknown>;
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function insert(
|
|
236
|
+
entity: string,
|
|
237
|
+
data: Record<string, unknown>
|
|
238
|
+
): Promise<{ id: string }> {
|
|
239
|
+
return apiRequest("POST", `/api/entities/${entity}`, data) as Promise<{ id: string }>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function update(
|
|
243
|
+
entity: string,
|
|
244
|
+
id: string,
|
|
245
|
+
data: Record<string, unknown>
|
|
246
|
+
): Promise<{ updated: boolean }> {
|
|
247
|
+
return apiRequest("PATCH", `/api/entities/${entity}/${id}`, data) as Promise<{
|
|
248
|
+
updated: boolean;
|
|
249
|
+
}>;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function remove(
|
|
253
|
+
entity: string,
|
|
254
|
+
id: string
|
|
255
|
+
): Promise<{ deleted: boolean }> {
|
|
256
|
+
return apiRequest("DELETE", `/api/entities/${entity}/${id}`) as Promise<{
|
|
257
|
+
deleted: boolean;
|
|
258
|
+
}>;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Auth helpers
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
export async function createSession(
|
|
266
|
+
userId: string
|
|
267
|
+
): Promise<{ token: string; user_id: string }> {
|
|
268
|
+
return apiRequest("POST", "/api/auth/session", {
|
|
269
|
+
user_id: userId,
|
|
270
|
+
}) as Promise<{ token: string; user_id: string }>;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function getAuthContext(
|
|
274
|
+
token?: string
|
|
275
|
+
): Promise<{ user_id: string | null }> {
|
|
276
|
+
const headers: Record<string, string> = {};
|
|
277
|
+
if (token) {
|
|
278
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
279
|
+
}
|
|
280
|
+
const res = await fetch(`${_baseUrl}/api/auth/me`, { headers });
|
|
281
|
+
return res.json() as Promise<{ user_id: string | null }>;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Exchange a current session token for a new one with a fresh 30-day expiry.
|
|
286
|
+
* The old token is revoked server-side. Call this before expiry to keep
|
|
287
|
+
* long-lived sessions alive without forcing a re-login.
|
|
288
|
+
*
|
|
289
|
+
* Returns `null` if the old token is already expired or invalid — the
|
|
290
|
+
* caller should treat that as "log back in."
|
|
291
|
+
*/
|
|
292
|
+
export async function refreshSession(
|
|
293
|
+
token: string
|
|
294
|
+
): Promise<{ token: string; user_id: string; expires_at: number } | null> {
|
|
295
|
+
const res = await fetch(`${_baseUrl}/api/auth/refresh`, {
|
|
296
|
+
method: "POST",
|
|
297
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
298
|
+
});
|
|
299
|
+
if (!res.ok) return null;
|
|
300
|
+
return res.json() as Promise<{
|
|
301
|
+
token: string;
|
|
302
|
+
user_id: string;
|
|
303
|
+
expires_at: number;
|
|
304
|
+
}>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Keep a session alive by automatically refreshing ~1 hour before expiry.
|
|
309
|
+
*
|
|
310
|
+
* ```ts
|
|
311
|
+
* const session = await createSession("alice");
|
|
312
|
+
* const stop = startSessionAutoRefresh(session, {
|
|
313
|
+
* onRefresh: (next) => localStorage.setItem("token", next.token),
|
|
314
|
+
* onExpired: () => redirect("/login"),
|
|
315
|
+
* });
|
|
316
|
+
* // later:
|
|
317
|
+
* stop();
|
|
318
|
+
* ```
|
|
319
|
+
*
|
|
320
|
+
* Returns a cleanup function that cancels the scheduled refresh. Call it
|
|
321
|
+
* on logout or unmount — otherwise the timer leaks.
|
|
322
|
+
*
|
|
323
|
+
* Default refresh margin is 1 hour. Pass `{ marginSecs }` to tune.
|
|
324
|
+
*/
|
|
325
|
+
export function startSessionAutoRefresh(
|
|
326
|
+
session: { token: string; expires_at: number },
|
|
327
|
+
opts: {
|
|
328
|
+
onRefresh: (next: { token: string; user_id: string; expires_at: number }) => void;
|
|
329
|
+
onExpired?: () => void;
|
|
330
|
+
marginSecs?: number;
|
|
331
|
+
}
|
|
332
|
+
): () => void {
|
|
333
|
+
const margin = opts.marginSecs ?? 3600;
|
|
334
|
+
const now = Math.floor(Date.now() / 1000);
|
|
335
|
+
const when = Math.max(0, session.expires_at - now - margin);
|
|
336
|
+
// Cap JS setTimeout at 2^31-1 ms (~24.8d). For tokens with a longer
|
|
337
|
+
// remaining life, schedule at the cap and let the next tick reschedule.
|
|
338
|
+
const delay = Math.min(when * 1000, 2_147_483_000);
|
|
339
|
+
let cancelled = false;
|
|
340
|
+
const timer = setTimeout(async () => {
|
|
341
|
+
if (cancelled) return;
|
|
342
|
+
const next = await refreshSession(session.token);
|
|
343
|
+
if (cancelled) return;
|
|
344
|
+
if (next) {
|
|
345
|
+
opts.onRefresh(next);
|
|
346
|
+
// Chain: schedule the next refresh for the new token's expiry.
|
|
347
|
+
startSessionAutoRefresh(next, opts);
|
|
348
|
+
} else {
|
|
349
|
+
opts.onExpired?.();
|
|
350
|
+
}
|
|
351
|
+
}, delay);
|
|
352
|
+
return () => {
|
|
353
|
+
cancelled = true;
|
|
354
|
+
clearTimeout(timer);
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// TypeScript function calls (queries, mutations, actions)
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Call a server-side function defined in the `functions/` directory.
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* ```ts
|
|
367
|
+
* const result = await callFn("placeBid", { lotId: "lot_1", amount: 150 });
|
|
368
|
+
* ```
|
|
369
|
+
*/
|
|
370
|
+
/**
|
|
371
|
+
* Read the auth token from the configured storage adapter (default:
|
|
372
|
+
* localStorage on the web). React Native and other non-browser hosts
|
|
373
|
+
* inject their own adapter via `setReactStorage` so `callFn` and the
|
|
374
|
+
* other free helpers send the right token without each call site
|
|
375
|
+
* threading it explicitly.
|
|
376
|
+
*/
|
|
377
|
+
function currentAuthToken(): string | undefined {
|
|
378
|
+
return _storage.get(storageKey("token")) ?? undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let _storage: PylonStorage = defaultStorage();
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Swap the storage adapter used by the React free helpers (`callFn`,
|
|
385
|
+
* `useSession`, `getAuthToken`, etc). React Native's `init()` calls this
|
|
386
|
+
* with an AsyncStorage-backed adapter so token reads/writes go through
|
|
387
|
+
* the same backend as the sync engine.
|
|
388
|
+
*/
|
|
389
|
+
export function setReactStorage(storage: PylonStorage): void {
|
|
390
|
+
_storage = storage;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Current storage adapter used by the React layer. Exposed for adapters. */
|
|
394
|
+
export function getReactStorage(): PylonStorage {
|
|
395
|
+
return _storage;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function callFn<T = unknown>(
|
|
399
|
+
name: string,
|
|
400
|
+
args: Record<string, unknown> = {},
|
|
401
|
+
options: { token?: string } = {}
|
|
402
|
+
): Promise<T> {
|
|
403
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
404
|
+
const token = options.token ?? currentAuthToken();
|
|
405
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
406
|
+
const res = await fetch(`${_baseUrl}/api/fn/${name}`, {
|
|
407
|
+
method: "POST",
|
|
408
|
+
headers,
|
|
409
|
+
body: JSON.stringify(args),
|
|
410
|
+
});
|
|
411
|
+
const json = (await res.json()) as unknown;
|
|
412
|
+
if (!res.ok) {
|
|
413
|
+
const err = (json as { error?: { code: string; message: string } }).error;
|
|
414
|
+
throw new Error(err?.message || `HTTP ${res.status}`);
|
|
415
|
+
}
|
|
416
|
+
return json as T;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Stream a server-side function's output as Server-Sent Events.
|
|
421
|
+
*
|
|
422
|
+
* @example
|
|
423
|
+
* ```ts
|
|
424
|
+
* for await (const chunk of streamFn("chat", { message: "hello" })) {
|
|
425
|
+
* console.log(chunk);
|
|
426
|
+
* }
|
|
427
|
+
* ```
|
|
428
|
+
*/
|
|
429
|
+
export async function* streamFn(
|
|
430
|
+
name: string,
|
|
431
|
+
args: Record<string, unknown> = {},
|
|
432
|
+
options: { token?: string } = {}
|
|
433
|
+
): AsyncGenerator<string, unknown, unknown> {
|
|
434
|
+
const headers: Record<string, string> = {
|
|
435
|
+
"Content-Type": "application/json",
|
|
436
|
+
Accept: "text/event-stream",
|
|
437
|
+
};
|
|
438
|
+
if (options.token) headers["Authorization"] = `Bearer ${options.token}`;
|
|
439
|
+
|
|
440
|
+
const res = await fetch(`${_baseUrl}/api/fn/${name}`, {
|
|
441
|
+
method: "POST",
|
|
442
|
+
headers,
|
|
443
|
+
body: JSON.stringify(args),
|
|
444
|
+
});
|
|
445
|
+
if (!res.ok || !res.body) {
|
|
446
|
+
throw new Error(`Stream failed: HTTP ${res.status}`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const reader = res.body.getReader();
|
|
450
|
+
const decoder = new TextDecoder();
|
|
451
|
+
let buffer = "";
|
|
452
|
+
let finalResult: unknown = undefined;
|
|
453
|
+
|
|
454
|
+
while (true) {
|
|
455
|
+
const { done, value } = await reader.read();
|
|
456
|
+
if (done) break;
|
|
457
|
+
buffer += decoder.decode(value, { stream: true });
|
|
458
|
+
const events = buffer.split("\n\n");
|
|
459
|
+
buffer = events.pop() || "";
|
|
460
|
+
|
|
461
|
+
for (const evt of events) {
|
|
462
|
+
if (!evt.trim()) continue;
|
|
463
|
+
let eventType = "message";
|
|
464
|
+
let data = "";
|
|
465
|
+
for (const line of evt.split("\n")) {
|
|
466
|
+
if (line.startsWith("event: ")) eventType = line.slice(7);
|
|
467
|
+
else if (line.startsWith("data: ")) data += line.slice(6);
|
|
468
|
+
}
|
|
469
|
+
if (eventType === "result") {
|
|
470
|
+
try {
|
|
471
|
+
finalResult = JSON.parse(data);
|
|
472
|
+
} catch {
|
|
473
|
+
finalResult = data;
|
|
474
|
+
}
|
|
475
|
+
} else if (eventType === "error") {
|
|
476
|
+
try {
|
|
477
|
+
const err = JSON.parse(data) as { message?: string };
|
|
478
|
+
throw new Error(err.message || "Function error");
|
|
479
|
+
} catch (e) {
|
|
480
|
+
throw e instanceof Error ? e : new Error(String(e));
|
|
481
|
+
}
|
|
482
|
+
} else {
|
|
483
|
+
yield data;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return finalResult;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* List all server-side functions available.
|
|
493
|
+
*/
|
|
494
|
+
export async function listFns(): Promise<
|
|
495
|
+
{ name: string; fn_type: "query" | "mutation" | "action" }[]
|
|
496
|
+
> {
|
|
497
|
+
return apiRequest("GET", "/api/fn") as Promise<
|
|
498
|
+
{ name: string; fn_type: "query" | "mutation" | "action" }[]
|
|
499
|
+
>;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
// File upload
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
export interface UploadedFile {
|
|
507
|
+
id: string;
|
|
508
|
+
url: string;
|
|
509
|
+
size: number;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Upload a file (File/Blob or raw bytes) to /api/files/upload.
|
|
514
|
+
*
|
|
515
|
+
* For File / Blob inputs this sends a single raw binary request with the
|
|
516
|
+
* filename and content-type as headers (the server short-circuits on this
|
|
517
|
+
* shape so uploads avoid being coerced through string-based handling).
|
|
518
|
+
*
|
|
519
|
+
* @example
|
|
520
|
+
* ```ts
|
|
521
|
+
* const uploaded = await uploadFile(fileFromInput);
|
|
522
|
+
* console.log(uploaded.url, uploaded.id, uploaded.size);
|
|
523
|
+
* ```
|
|
524
|
+
*/
|
|
525
|
+
export async function uploadFile(
|
|
526
|
+
input: File | Blob | ArrayBuffer | Uint8Array,
|
|
527
|
+
options: {
|
|
528
|
+
filename?: string;
|
|
529
|
+
contentType?: string;
|
|
530
|
+
token?: string;
|
|
531
|
+
} = {}
|
|
532
|
+
): Promise<UploadedFile> {
|
|
533
|
+
let body: BodyInit;
|
|
534
|
+
let filename = options.filename;
|
|
535
|
+
let contentType = options.contentType;
|
|
536
|
+
|
|
537
|
+
if (typeof File !== "undefined" && input instanceof File) {
|
|
538
|
+
body = input;
|
|
539
|
+
filename ??= input.name;
|
|
540
|
+
contentType ??= input.type || "application/octet-stream";
|
|
541
|
+
} else if (typeof Blob !== "undefined" && input instanceof Blob) {
|
|
542
|
+
body = input;
|
|
543
|
+
contentType ??= input.type || "application/octet-stream";
|
|
544
|
+
} else if (input instanceof ArrayBuffer) {
|
|
545
|
+
body = input;
|
|
546
|
+
} else {
|
|
547
|
+
// Newer TS lib types refuse `Uint8Array<ArrayBufferLike>` as BodyInit
|
|
548
|
+
// directly even though every runtime accepts it. Hand fetch the
|
|
549
|
+
// underlying ArrayBuffer slice to sidestep the type narrowing.
|
|
550
|
+
const u8 = input as Uint8Array;
|
|
551
|
+
body = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
filename ??= "upload";
|
|
555
|
+
contentType ??= "application/octet-stream";
|
|
556
|
+
|
|
557
|
+
const headers: Record<string, string> = {
|
|
558
|
+
"Content-Type": contentType,
|
|
559
|
+
"X-Filename": filename,
|
|
560
|
+
};
|
|
561
|
+
if (options.token) headers["Authorization"] = `Bearer ${options.token}`;
|
|
562
|
+
|
|
563
|
+
const res = await fetch(`${_baseUrl}/api/files/upload`, {
|
|
564
|
+
method: "POST",
|
|
565
|
+
headers,
|
|
566
|
+
body,
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
if (!res.ok) {
|
|
570
|
+
const err = (await res.json().catch(() => ({}))) as {
|
|
571
|
+
error?: { code: string; message: string };
|
|
572
|
+
};
|
|
573
|
+
throw new Error(err.error?.message || `Upload failed: HTTP ${res.status}`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return (await res.json()) as UploadedFile;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Upload via multipart/form-data. Useful when the app needs to pass extra
|
|
581
|
+
* fields alongside the file (captions, categories, etc.), though only the
|
|
582
|
+
* first file part is stored today.
|
|
583
|
+
*/
|
|
584
|
+
export async function uploadFileMultipart(
|
|
585
|
+
file: File | Blob,
|
|
586
|
+
fields: Record<string, string> = {},
|
|
587
|
+
options: { token?: string } = {}
|
|
588
|
+
): Promise<UploadedFile> {
|
|
589
|
+
const form = new FormData();
|
|
590
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
591
|
+
form.append(k, v);
|
|
592
|
+
}
|
|
593
|
+
form.append("file", file);
|
|
594
|
+
|
|
595
|
+
const headers: Record<string, string> = {};
|
|
596
|
+
if (options.token) headers["Authorization"] = `Bearer ${options.token}`;
|
|
597
|
+
|
|
598
|
+
const res = await fetch(`${_baseUrl}/api/files/upload`, {
|
|
599
|
+
method: "POST",
|
|
600
|
+
headers,
|
|
601
|
+
body: form,
|
|
602
|
+
});
|
|
603
|
+
if (!res.ok) {
|
|
604
|
+
const err = (await res.json().catch(() => ({}))) as {
|
|
605
|
+
error?: { code: string; message: string };
|
|
606
|
+
};
|
|
607
|
+
throw new Error(err.error?.message || `Upload failed: HTTP ${res.status}`);
|
|
608
|
+
}
|
|
609
|
+
return (await res.json()) as UploadedFile;
|
|
610
|
+
}
|