@pylonsync/react 0.4.21 → 0.4.23

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/dist/index.d.ts CHANGED
@@ -16,6 +16,8 @@ export type { PylonRouter } from "./useRouter";
16
16
  import { type Storage as PylonStorage } from "@pylonsync/sync";
17
17
  export { useQuery, useQueryOne, useReactiveQuery, useMutation, useInfiniteQuery, usePaginatedQuery, useEntityMutation, useAction, useQueryRaw, useQueryOneRaw, useLiveList, useLiveRow, useInsert, useUpdate, useDelete, useFn, useAggregate, useSearch, } from "./hooks";
18
18
  export type { QueryOptions, QueryFilter, IncludeSpec, UseQueryReturn, UseQueryOneReturn, UseReactiveQueryReturn, UseMutationReturn, UseInfiniteQueryReturn, UsePaginatedQueryReturn, PaginatedQueryStatus, UseFnReturn, AggregateSpec, UseAggregateReturn, SearchSpec, UseSearchReturn, } from "./hooks";
19
+ export { useAgentRun } from "./useAgentRun";
20
+ export type { AgentRunRow, AgentMessageRow, UseAgentRunReturn, } from "./useAgentRun";
19
21
  export { useRoom, useRoomMessages } from "./useRoom";
20
22
  export type { RoomPeer, RoomSnapshot, UseRoomOptions, UseRoomReturn, } from "./useRoom";
21
23
  export type { RoomMessage } from "@pylonsync/sync";
@@ -142,9 +144,38 @@ export declare function getReactStorage(): PylonStorage;
142
144
  export declare function callFn<T = unknown>(name: string, args?: Record<string, unknown>, options?: {
143
145
  token?: string;
144
146
  }): Promise<T>;
147
+ /** Options for {@link streamFn}. */
148
+ export interface StreamFnOptions {
149
+ token?: string;
150
+ /**
151
+ * Auto-resume on abrupt network failure (default true). The server
152
+ * buffers every fn stream server-side; when the connection drops
153
+ * mid-stream, the generator silently reconnects to
154
+ * `GET /api/fn-streams/<id>` from its last seen cursor and keeps
155
+ * yielding — no chunk is duplicated or lost, and the final result
156
+ * still arrives even if the reconnect lands after the handler
157
+ * finished. Set false to surface disconnects as errors instead.
158
+ */
159
+ resume?: boolean;
160
+ /** Reconnect attempts per silent gap (default 3). The budget resets
161
+ * whenever a resumed connection makes progress. */
162
+ maxResumes?: number;
163
+ /**
164
+ * Called with the server-assigned stream id as soon as the response
165
+ * upgrades to SSE. Persist it (e.g. in your row for the agent run)
166
+ * to resume from a different tab or a full page reload via
167
+ * {@link resumeStream}.
168
+ */
169
+ onStreamId?: (streamId: string) => void;
170
+ }
145
171
  /**
146
172
  * Stream a server-side function's output as Server-Sent Events.
147
173
  *
174
+ * Streams are resumable: the server buffers every frame under a stream
175
+ * id (see `X-Pylon-Stream-Id`), so a dropped connection reconnects and
176
+ * catches up transparently — including receiving the final result
177
+ * after the handler already finished. Disable with `resume: false`.
178
+ *
148
179
  * @example
149
180
  * ```ts
150
181
  * for await (const chunk of streamFn("chat", { message: "hello" })) {
@@ -152,8 +183,20 @@ export declare function callFn<T = unknown>(name: string, args?: Record<string,
152
183
  * }
153
184
  * ```
154
185
  */
155
- export declare function streamFn(name: string, args?: Record<string, unknown>, options?: {
186
+ export declare function streamFn(name: string, args?: Record<string, unknown>, options?: StreamFnOptions): AsyncGenerator<string, unknown, unknown>;
187
+ /**
188
+ * Attach to a buffered fn stream by id — from another tab, a page
189
+ * reload, or any time after the original `streamFn` call went away.
190
+ * Replays from `since` (default 0 = everything, including the final
191
+ * result if the handler already finished), then live-tails.
192
+ *
193
+ * Get the id from `streamFn`'s `onStreamId` callback and persist it
194
+ * wherever the run's state lives. Completed streams stay resumable for
195
+ * an hour (PYLON_STREAM_RETAIN_SECS).
196
+ */
197
+ export declare function resumeStream(streamId: string, options?: {
156
198
  token?: string;
199
+ since?: number;
157
200
  }): AsyncGenerator<string, unknown, unknown>;
158
201
  /**
159
202
  * List all server-side functions available.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Live view of an `agent()` run — the AgentRun row plus its ordered
3
+ * transcript — straight from the synced replica. Because AgentRun and
4
+ * AgentMessage are ordinary synced entities (owner-scoped by policy),
5
+ * this updates in real time on every one of the user's devices as the
6
+ * loop persists turns: user input, assistant text, tool calls, tool
7
+ * results.
8
+ *
9
+ * ```tsx
10
+ * const { run, messages } = useAgentRun(runId);
11
+ * // messages[i]: { role: "user" | "assistant", content: string | LlmContentBlock[] , seq, ... }
12
+ * // run.status: "idle" | "running" | "completed" | "failed"
13
+ * // run.streamId: attach to live tokens with resumeStream(run.streamId)
14
+ * ```
15
+ *
16
+ * Start or continue a run with `streamFn("<agentName>", { input, runId? })`
17
+ * — tokens stream on that connection; the persisted transcript arrives
18
+ * here.
19
+ */
20
+ export interface AgentRunRow {
21
+ id: string;
22
+ agent: string;
23
+ status: "idle" | "running" | "completed" | "failed" | string;
24
+ userId?: string | null;
25
+ title?: string | null;
26
+ /** Resumable-stream id of the current/last generation. */
27
+ streamId?: string | null;
28
+ error?: string | null;
29
+ createdAt: string;
30
+ updatedAt: string;
31
+ }
32
+ export interface AgentMessageRow {
33
+ id: string;
34
+ runId: string;
35
+ seq: number;
36
+ role: "user" | "assistant" | string;
37
+ /** A string for plain turns, or LlmContentBlock[] for assistant
38
+ * turns with tool_use and tool_result batches. */
39
+ content: unknown;
40
+ createdAt: string;
41
+ }
42
+ export interface UseAgentRunReturn {
43
+ run: AgentRunRow | null;
44
+ messages: AgentMessageRow[];
45
+ loading: boolean;
46
+ }
47
+ export declare function useAgentRun(runId: string | null | undefined): UseAgentRunReturn;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.4.21",
6
+ "version": "0.4.23",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -14,8 +14,8 @@
14
14
  "prepack": "bun run build"
15
15
  },
16
16
  "dependencies": {
17
- "@pylonsync/sdk": "0.4.21",
18
- "@pylonsync/sync": "0.4.21"
17
+ "@pylonsync/sdk": "0.4.23",
18
+ "@pylonsync/sync": "0.4.23"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "react": ">=19.0.0"
package/src/index.ts CHANGED
@@ -107,6 +107,14 @@ export type {
107
107
  UseSearchReturn,
108
108
  } from "./hooks";
109
109
 
110
+ // Agent run hook — live transcript + status for agent() runs
111
+ export { useAgentRun } from "./useAgentRun";
112
+ export type {
113
+ AgentRunRow,
114
+ AgentMessageRow,
115
+ UseAgentRunReturn,
116
+ } from "./useAgentRun";
117
+
110
118
  // Room hook
111
119
  export { useRoom, useRoomMessages } from "./useRoom";
112
120
  export type {
@@ -517,9 +525,113 @@ export async function callFn<T = unknown>(
517
525
  );
518
526
  }
519
527
 
528
+ /** Options for {@link streamFn}. */
529
+ export interface StreamFnOptions {
530
+ token?: string;
531
+ /**
532
+ * Auto-resume on abrupt network failure (default true). The server
533
+ * buffers every fn stream server-side; when the connection drops
534
+ * mid-stream, the generator silently reconnects to
535
+ * `GET /api/fn-streams/<id>` from its last seen cursor and keeps
536
+ * yielding — no chunk is duplicated or lost, and the final result
537
+ * still arrives even if the reconnect lands after the handler
538
+ * finished. Set false to surface disconnects as errors instead.
539
+ */
540
+ resume?: boolean;
541
+ /** Reconnect attempts per silent gap (default 3). The budget resets
542
+ * whenever a resumed connection makes progress. */
543
+ maxResumes?: number;
544
+ /**
545
+ * Called with the server-assigned stream id as soon as the response
546
+ * upgrades to SSE. Persist it (e.g. in your row for the agent run)
547
+ * to resume from a different tab or a full page reload via
548
+ * {@link resumeStream}.
549
+ */
550
+ onStreamId?: (streamId: string) => void;
551
+ }
552
+
553
+ interface SseCursor {
554
+ lastSeq: number;
555
+ /** Set once the terminal result/error frame was seen. */
556
+ terminal: boolean;
557
+ finalResult: unknown;
558
+ }
559
+
560
+ /** Parse one SSE response body, yielding data frames and tracking the
561
+ * cursor. Multi-line payloads rejoin `data:` lines with '\n' per the
562
+ * SSE spec (the server splits them the same way). */
563
+ async function* consumeSseBody(
564
+ res: Response,
565
+ cursor: SseCursor,
566
+ ): AsyncGenerator<string, void, unknown> {
567
+ if (!res.body) throw new Error(`Stream failed: HTTP ${res.status}`);
568
+ const reader = res.body.getReader();
569
+ const decoder = new TextDecoder();
570
+ let buffer = "";
571
+ try {
572
+ while (true) {
573
+ const { done, value } = await reader.read();
574
+ if (done) break;
575
+ buffer += decoder.decode(value, { stream: true });
576
+ const events = buffer.split("\n\n");
577
+ buffer = events.pop() || "";
578
+
579
+ for (const evt of events) {
580
+ if (!evt.trim() || evt.startsWith(":")) continue; // heartbeat
581
+ let eventType = "message";
582
+ const dataLines: string[] = [];
583
+ for (const line of evt.split("\n")) {
584
+ if (line.startsWith("event: ")) eventType = line.slice(7);
585
+ else if (line.startsWith("data: ")) dataLines.push(line.slice(6));
586
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5));
587
+ else if (line.startsWith("id: ")) {
588
+ const seq = Number.parseInt(line.slice(4), 10);
589
+ if (Number.isFinite(seq)) cursor.lastSeq = seq;
590
+ }
591
+ }
592
+ // Frames without any data line (the `retry:` prelude) carry
593
+ // nothing to deliver.
594
+ if (dataLines.length === 0) continue;
595
+ const data = dataLines.join("\n");
596
+ if (eventType === "result") {
597
+ cursor.terminal = true;
598
+ try {
599
+ cursor.finalResult = JSON.parse(data);
600
+ } catch {
601
+ cursor.finalResult = data;
602
+ }
603
+ } else if (eventType === "error") {
604
+ cursor.terminal = true;
605
+ let message = "Function error";
606
+ try {
607
+ const err = JSON.parse(data) as { message?: string };
608
+ if (err.message) message = err.message;
609
+ } catch {
610
+ // non-JSON error payload — keep the generic message
611
+ }
612
+ throw new Error(message);
613
+ } else {
614
+ yield data;
615
+ }
616
+ }
617
+ }
618
+ } finally {
619
+ // Runs on normal completion AND when the consumer breaks out of
620
+ // its for-await (generator return). Without the cancel, the fetch
621
+ // body stays locked and the connection pins a browser socket (and
622
+ // a server stream slot) until page unload.
623
+ reader.cancel().catch(() => {});
624
+ }
625
+ }
626
+
520
627
  /**
521
628
  * Stream a server-side function's output as Server-Sent Events.
522
629
  *
630
+ * Streams are resumable: the server buffers every frame under a stream
631
+ * id (see `X-Pylon-Stream-Id`), so a dropped connection reconnects and
632
+ * catches up transparently — including receiving the final result
633
+ * after the handler already finished. Disable with `resume: false`.
634
+ *
523
635
  * @example
524
636
  * ```ts
525
637
  * for await (const chunk of streamFn("chat", { message: "hello" })) {
@@ -530,23 +642,20 @@ export async function callFn<T = unknown>(
530
642
  export async function* streamFn(
531
643
  name: string,
532
644
  args: Record<string, unknown> = {},
533
- options: { token?: string } = {}
645
+ options: StreamFnOptions = {}
534
646
  ): AsyncGenerator<string, unknown, unknown> {
647
+ const transport = {
648
+ baseUrl: getBaseUrl(),
649
+ getToken: () => options.token ?? currentAuthToken() ?? undefined,
650
+ };
535
651
  // Streaming response — use pylonFetchRaw so we can read .body
536
652
  // ourselves. URL + auth + credentials are centralized in the
537
653
  // transport.
538
- const res = await pylonFetchRaw(
539
- {
540
- baseUrl: getBaseUrl(),
541
- getToken: () => options.token ?? currentAuthToken() ?? undefined,
542
- },
543
- `/api/fn/${name}`,
544
- {
545
- method: "POST",
546
- json: args,
547
- accept: "text/event-stream",
548
- },
549
- );
654
+ const res = await pylonFetchRaw(transport, `/api/fn/${name}`, {
655
+ method: "POST",
656
+ json: args,
657
+ accept: "text/event-stream",
658
+ });
550
659
  // The server only upgrades to SSE once the handler actually calls
551
660
  // ctx.stream. A function that completes without streaming answers
552
661
  // with plain JSON (Content-Type: application/json) — the raw return
@@ -571,50 +680,123 @@ export async function* streamFn(
571
680
  return text;
572
681
  }
573
682
  }
574
- if (!res.ok || !res.body) {
683
+ if (!res.ok) {
575
684
  throw new Error(`Stream failed: HTTP ${res.status}`);
576
685
  }
577
686
 
578
- const reader = res.body.getReader();
579
- const decoder = new TextDecoder();
580
- let buffer = "";
581
- let finalResult: unknown = undefined;
687
+ const streamId = res.headers.get("x-pylon-stream-id");
688
+ if (streamId && options.onStreamId) options.onStreamId(streamId);
582
689
 
583
- while (true) {
584
- const { done, value } = await reader.read();
585
- if (done) break;
586
- buffer += decoder.decode(value, { stream: true });
587
- const events = buffer.split("\n\n");
588
- buffer = events.pop() || "";
690
+ const cursor: SseCursor = { lastSeq: 0, terminal: false, finalResult: undefined };
691
+ const resume = options.resume !== false && Boolean(streamId);
692
+ let attemptsLeft = options.maxResumes ?? 3;
589
693
 
590
- for (const evt of events) {
591
- if (!evt.trim()) continue;
592
- let eventType = "message";
593
- let data = "";
594
- for (const line of evt.split("\n")) {
595
- if (line.startsWith("event: ")) eventType = line.slice(7);
596
- else if (line.startsWith("data: ")) data += line.slice(6);
597
- }
598
- if (eventType === "result") {
599
- try {
600
- finalResult = JSON.parse(data);
601
- } catch {
602
- finalResult = data;
603
- }
604
- } else if (eventType === "error") {
605
- try {
606
- const err = JSON.parse(data) as { message?: string };
607
- throw new Error(err.message || "Function error");
608
- } catch (e) {
609
- throw e instanceof Error ? e : new Error(String(e));
610
- }
611
- } else {
612
- yield data;
694
+ // First pass over the live connection, then (on silent disconnect)
695
+ // resume passes over /api/fn-streams until the terminal frame.
696
+ let body: AsyncGenerator<string, void, unknown> = consumeSseBody(res, cursor);
697
+ for (;;) {
698
+ let progressed = false;
699
+ try {
700
+ for await (const chunk of body) {
701
+ progressed = true;
702
+ yield chunk;
613
703
  }
704
+ } catch (e) {
705
+ if (cursor.terminal) throw e; // server-sent error frame — real
706
+ if (!resume || attemptsLeft <= 0) throw e; // network error, no budget
707
+ }
708
+ if (cursor.terminal) return cursor.finalResult;
709
+ // Connection ended without a terminal frame — silent disconnect.
710
+ if (!resume || attemptsLeft <= 0) {
711
+ throw new Error("Stream disconnected before completing");
614
712
  }
713
+ if (progressed) attemptsLeft = options.maxResumes ?? 3;
714
+ attemptsLeft -= 1;
715
+ const resumed = await pylonFetchRaw(
716
+ transport,
717
+ `/api/fn-streams/${streamId}?since=${cursor.lastSeq}`,
718
+ { method: "GET", accept: "text/event-stream" },
719
+ );
720
+ if (resumed.status === 503 && attemptsLeft > 0) {
721
+ // STREAM_OVERLOADED is transient by definition (Retry-After: 1) —
722
+ // failing here would kill exactly the stream resume exists to save.
723
+ await resumed.text().catch(() => {});
724
+ await new Promise((r) => setTimeout(r, 1000));
725
+ continue;
726
+ }
727
+ if (!resumed.ok) {
728
+ const text = await resumed.text().catch(() => "");
729
+ throw new Error(
730
+ `Stream resume failed: HTTP ${resumed.status}${text ? ` — ${text}` : ""}`,
731
+ );
732
+ }
733
+ body = consumeSseBody(resumed, cursor);
615
734
  }
735
+ }
616
736
 
617
- return finalResult;
737
+ /**
738
+ * Attach to a buffered fn stream by id — from another tab, a page
739
+ * reload, or any time after the original `streamFn` call went away.
740
+ * Replays from `since` (default 0 = everything, including the final
741
+ * result if the handler already finished), then live-tails.
742
+ *
743
+ * Get the id from `streamFn`'s `onStreamId` callback and persist it
744
+ * wherever the run's state lives. Completed streams stay resumable for
745
+ * an hour (PYLON_STREAM_RETAIN_SECS).
746
+ */
747
+ export async function* resumeStream(
748
+ streamId: string,
749
+ options: { token?: string; since?: number } = {}
750
+ ): AsyncGenerator<string, unknown, unknown> {
751
+ const transport = {
752
+ baseUrl: getBaseUrl(),
753
+ getToken: () => options.token ?? currentAuthToken() ?? undefined,
754
+ };
755
+ const cursor: SseCursor = {
756
+ lastSeq: options.since ?? 0,
757
+ terminal: false,
758
+ finalResult: undefined,
759
+ };
760
+ let attemptsLeft = 3;
761
+ for (;;) {
762
+ const res = await pylonFetchRaw(
763
+ transport,
764
+ `/api/fn-streams/${streamId}?since=${cursor.lastSeq}`,
765
+ { method: "GET", accept: "text/event-stream" },
766
+ );
767
+ if (res.status === 503 && attemptsLeft > 0) {
768
+ attemptsLeft -= 1;
769
+ await res.text().catch(() => {});
770
+ await new Promise((r) => setTimeout(r, 1000));
771
+ continue;
772
+ }
773
+ if (!res.ok) {
774
+ const text = await res.text().catch(() => "");
775
+ let message = `Stream resume failed: HTTP ${res.status}`;
776
+ try {
777
+ const err = JSON.parse(text) as { error?: { message?: string } };
778
+ if (err.error?.message) message = err.error.message;
779
+ } catch {
780
+ // keep the HTTP message
781
+ }
782
+ throw new Error(message);
783
+ }
784
+ let progressed = false;
785
+ try {
786
+ for await (const chunk of consumeSseBody(res, cursor)) {
787
+ progressed = true;
788
+ yield chunk;
789
+ }
790
+ } catch (e) {
791
+ if (cursor.terminal || attemptsLeft <= 0) throw e;
792
+ }
793
+ if (cursor.terminal) return cursor.finalResult;
794
+ if (progressed) attemptsLeft = 3;
795
+ attemptsLeft -= 1;
796
+ if (attemptsLeft < 0) {
797
+ throw new Error("Stream disconnected before completing");
798
+ }
799
+ }
618
800
  }
619
801
 
620
802
  /**
Binary file