@pylonsync/react 0.4.20 → 0.4.22

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
@@ -142,9 +142,38 @@ export declare function getReactStorage(): PylonStorage;
142
142
  export declare function callFn<T = unknown>(name: string, args?: Record<string, unknown>, options?: {
143
143
  token?: string;
144
144
  }): Promise<T>;
145
+ /** Options for {@link streamFn}. */
146
+ export interface StreamFnOptions {
147
+ token?: string;
148
+ /**
149
+ * Auto-resume on abrupt network failure (default true). The server
150
+ * buffers every fn stream server-side; when the connection drops
151
+ * mid-stream, the generator silently reconnects to
152
+ * `GET /api/fn-streams/<id>` from its last seen cursor and keeps
153
+ * yielding — no chunk is duplicated or lost, and the final result
154
+ * still arrives even if the reconnect lands after the handler
155
+ * finished. Set false to surface disconnects as errors instead.
156
+ */
157
+ resume?: boolean;
158
+ /** Reconnect attempts per silent gap (default 3). The budget resets
159
+ * whenever a resumed connection makes progress. */
160
+ maxResumes?: number;
161
+ /**
162
+ * Called with the server-assigned stream id as soon as the response
163
+ * upgrades to SSE. Persist it (e.g. in your row for the agent run)
164
+ * to resume from a different tab or a full page reload via
165
+ * {@link resumeStream}.
166
+ */
167
+ onStreamId?: (streamId: string) => void;
168
+ }
145
169
  /**
146
170
  * Stream a server-side function's output as Server-Sent Events.
147
171
  *
172
+ * Streams are resumable: the server buffers every frame under a stream
173
+ * id (see `X-Pylon-Stream-Id`), so a dropped connection reconnects and
174
+ * catches up transparently — including receiving the final result
175
+ * after the handler already finished. Disable with `resume: false`.
176
+ *
148
177
  * @example
149
178
  * ```ts
150
179
  * for await (const chunk of streamFn("chat", { message: "hello" })) {
@@ -152,8 +181,20 @@ export declare function callFn<T = unknown>(name: string, args?: Record<string,
152
181
  * }
153
182
  * ```
154
183
  */
155
- export declare function streamFn(name: string, args?: Record<string, unknown>, options?: {
184
+ export declare function streamFn(name: string, args?: Record<string, unknown>, options?: StreamFnOptions): AsyncGenerator<string, unknown, unknown>;
185
+ /**
186
+ * Attach to a buffered fn stream by id — from another tab, a page
187
+ * reload, or any time after the original `streamFn` call went away.
188
+ * Replays from `since` (default 0 = everything, including the final
189
+ * result if the handler already finished), then live-tails.
190
+ *
191
+ * Get the id from `streamFn`'s `onStreamId` callback and persist it
192
+ * wherever the run's state lives. Completed streams stay resumable for
193
+ * an hour (PYLON_STREAM_RETAIN_SECS).
194
+ */
195
+ export declare function resumeStream(streamId: string, options?: {
156
196
  token?: string;
197
+ since?: number;
157
198
  }): AsyncGenerator<string, unknown, unknown>;
158
199
  /**
159
200
  * List all server-side functions available.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.4.20",
6
+ "version": "0.4.22",
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.20",
18
- "@pylonsync/sync": "0.4.20"
17
+ "@pylonsync/sdk": "0.4.22",
18
+ "@pylonsync/sync": "0.4.22"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "react": ">=19.0.0"
package/src/index.ts CHANGED
@@ -517,9 +517,113 @@ export async function callFn<T = unknown>(
517
517
  );
518
518
  }
519
519
 
520
+ /** Options for {@link streamFn}. */
521
+ export interface StreamFnOptions {
522
+ token?: string;
523
+ /**
524
+ * Auto-resume on abrupt network failure (default true). The server
525
+ * buffers every fn stream server-side; when the connection drops
526
+ * mid-stream, the generator silently reconnects to
527
+ * `GET /api/fn-streams/<id>` from its last seen cursor and keeps
528
+ * yielding — no chunk is duplicated or lost, and the final result
529
+ * still arrives even if the reconnect lands after the handler
530
+ * finished. Set false to surface disconnects as errors instead.
531
+ */
532
+ resume?: boolean;
533
+ /** Reconnect attempts per silent gap (default 3). The budget resets
534
+ * whenever a resumed connection makes progress. */
535
+ maxResumes?: number;
536
+ /**
537
+ * Called with the server-assigned stream id as soon as the response
538
+ * upgrades to SSE. Persist it (e.g. in your row for the agent run)
539
+ * to resume from a different tab or a full page reload via
540
+ * {@link resumeStream}.
541
+ */
542
+ onStreamId?: (streamId: string) => void;
543
+ }
544
+
545
+ interface SseCursor {
546
+ lastSeq: number;
547
+ /** Set once the terminal result/error frame was seen. */
548
+ terminal: boolean;
549
+ finalResult: unknown;
550
+ }
551
+
552
+ /** Parse one SSE response body, yielding data frames and tracking the
553
+ * cursor. Multi-line payloads rejoin `data:` lines with '\n' per the
554
+ * SSE spec (the server splits them the same way). */
555
+ async function* consumeSseBody(
556
+ res: Response,
557
+ cursor: SseCursor,
558
+ ): AsyncGenerator<string, void, unknown> {
559
+ if (!res.body) throw new Error(`Stream failed: HTTP ${res.status}`);
560
+ const reader = res.body.getReader();
561
+ const decoder = new TextDecoder();
562
+ let buffer = "";
563
+ try {
564
+ while (true) {
565
+ const { done, value } = await reader.read();
566
+ if (done) break;
567
+ buffer += decoder.decode(value, { stream: true });
568
+ const events = buffer.split("\n\n");
569
+ buffer = events.pop() || "";
570
+
571
+ for (const evt of events) {
572
+ if (!evt.trim() || evt.startsWith(":")) continue; // heartbeat
573
+ let eventType = "message";
574
+ const dataLines: string[] = [];
575
+ for (const line of evt.split("\n")) {
576
+ if (line.startsWith("event: ")) eventType = line.slice(7);
577
+ else if (line.startsWith("data: ")) dataLines.push(line.slice(6));
578
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5));
579
+ else if (line.startsWith("id: ")) {
580
+ const seq = Number.parseInt(line.slice(4), 10);
581
+ if (Number.isFinite(seq)) cursor.lastSeq = seq;
582
+ }
583
+ }
584
+ // Frames without any data line (the `retry:` prelude) carry
585
+ // nothing to deliver.
586
+ if (dataLines.length === 0) continue;
587
+ const data = dataLines.join("\n");
588
+ if (eventType === "result") {
589
+ cursor.terminal = true;
590
+ try {
591
+ cursor.finalResult = JSON.parse(data);
592
+ } catch {
593
+ cursor.finalResult = data;
594
+ }
595
+ } else if (eventType === "error") {
596
+ cursor.terminal = true;
597
+ let message = "Function error";
598
+ try {
599
+ const err = JSON.parse(data) as { message?: string };
600
+ if (err.message) message = err.message;
601
+ } catch {
602
+ // non-JSON error payload — keep the generic message
603
+ }
604
+ throw new Error(message);
605
+ } else {
606
+ yield data;
607
+ }
608
+ }
609
+ }
610
+ } finally {
611
+ // Runs on normal completion AND when the consumer breaks out of
612
+ // its for-await (generator return). Without the cancel, the fetch
613
+ // body stays locked and the connection pins a browser socket (and
614
+ // a server stream slot) until page unload.
615
+ reader.cancel().catch(() => {});
616
+ }
617
+ }
618
+
520
619
  /**
521
620
  * Stream a server-side function's output as Server-Sent Events.
522
621
  *
622
+ * Streams are resumable: the server buffers every frame under a stream
623
+ * id (see `X-Pylon-Stream-Id`), so a dropped connection reconnects and
624
+ * catches up transparently — including receiving the final result
625
+ * after the handler already finished. Disable with `resume: false`.
626
+ *
523
627
  * @example
524
628
  * ```ts
525
629
  * for await (const chunk of streamFn("chat", { message: "hello" })) {
@@ -530,23 +634,20 @@ export async function callFn<T = unknown>(
530
634
  export async function* streamFn(
531
635
  name: string,
532
636
  args: Record<string, unknown> = {},
533
- options: { token?: string } = {}
637
+ options: StreamFnOptions = {}
534
638
  ): AsyncGenerator<string, unknown, unknown> {
639
+ const transport = {
640
+ baseUrl: getBaseUrl(),
641
+ getToken: () => options.token ?? currentAuthToken() ?? undefined,
642
+ };
535
643
  // Streaming response — use pylonFetchRaw so we can read .body
536
644
  // ourselves. URL + auth + credentials are centralized in the
537
645
  // 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
- );
646
+ const res = await pylonFetchRaw(transport, `/api/fn/${name}`, {
647
+ method: "POST",
648
+ json: args,
649
+ accept: "text/event-stream",
650
+ });
550
651
  // The server only upgrades to SSE once the handler actually calls
551
652
  // ctx.stream. A function that completes without streaming answers
552
653
  // with plain JSON (Content-Type: application/json) — the raw return
@@ -571,50 +672,123 @@ export async function* streamFn(
571
672
  return text;
572
673
  }
573
674
  }
574
- if (!res.ok || !res.body) {
675
+ if (!res.ok) {
575
676
  throw new Error(`Stream failed: HTTP ${res.status}`);
576
677
  }
577
678
 
578
- const reader = res.body.getReader();
579
- const decoder = new TextDecoder();
580
- let buffer = "";
581
- let finalResult: unknown = undefined;
679
+ const streamId = res.headers.get("x-pylon-stream-id");
680
+ if (streamId && options.onStreamId) options.onStreamId(streamId);
582
681
 
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() || "";
682
+ const cursor: SseCursor = { lastSeq: 0, terminal: false, finalResult: undefined };
683
+ const resume = options.resume !== false && Boolean(streamId);
684
+ let attemptsLeft = options.maxResumes ?? 3;
589
685
 
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;
686
+ // First pass over the live connection, then (on silent disconnect)
687
+ // resume passes over /api/fn-streams until the terminal frame.
688
+ let body: AsyncGenerator<string, void, unknown> = consumeSseBody(res, cursor);
689
+ for (;;) {
690
+ let progressed = false;
691
+ try {
692
+ for await (const chunk of body) {
693
+ progressed = true;
694
+ yield chunk;
613
695
  }
696
+ } catch (e) {
697
+ if (cursor.terminal) throw e; // server-sent error frame — real
698
+ if (!resume || attemptsLeft <= 0) throw e; // network error, no budget
699
+ }
700
+ if (cursor.terminal) return cursor.finalResult;
701
+ // Connection ended without a terminal frame — silent disconnect.
702
+ if (!resume || attemptsLeft <= 0) {
703
+ throw new Error("Stream disconnected before completing");
614
704
  }
705
+ if (progressed) attemptsLeft = options.maxResumes ?? 3;
706
+ attemptsLeft -= 1;
707
+ const resumed = await pylonFetchRaw(
708
+ transport,
709
+ `/api/fn-streams/${streamId}?since=${cursor.lastSeq}`,
710
+ { method: "GET", accept: "text/event-stream" },
711
+ );
712
+ if (resumed.status === 503 && attemptsLeft > 0) {
713
+ // STREAM_OVERLOADED is transient by definition (Retry-After: 1) —
714
+ // failing here would kill exactly the stream resume exists to save.
715
+ await resumed.text().catch(() => {});
716
+ await new Promise((r) => setTimeout(r, 1000));
717
+ continue;
718
+ }
719
+ if (!resumed.ok) {
720
+ const text = await resumed.text().catch(() => "");
721
+ throw new Error(
722
+ `Stream resume failed: HTTP ${resumed.status}${text ? ` — ${text}` : ""}`,
723
+ );
724
+ }
725
+ body = consumeSseBody(resumed, cursor);
615
726
  }
727
+ }
616
728
 
617
- return finalResult;
729
+ /**
730
+ * Attach to a buffered fn stream by id — from another tab, a page
731
+ * reload, or any time after the original `streamFn` call went away.
732
+ * Replays from `since` (default 0 = everything, including the final
733
+ * result if the handler already finished), then live-tails.
734
+ *
735
+ * Get the id from `streamFn`'s `onStreamId` callback and persist it
736
+ * wherever the run's state lives. Completed streams stay resumable for
737
+ * an hour (PYLON_STREAM_RETAIN_SECS).
738
+ */
739
+ export async function* resumeStream(
740
+ streamId: string,
741
+ options: { token?: string; since?: number } = {}
742
+ ): AsyncGenerator<string, unknown, unknown> {
743
+ const transport = {
744
+ baseUrl: getBaseUrl(),
745
+ getToken: () => options.token ?? currentAuthToken() ?? undefined,
746
+ };
747
+ const cursor: SseCursor = {
748
+ lastSeq: options.since ?? 0,
749
+ terminal: false,
750
+ finalResult: undefined,
751
+ };
752
+ let attemptsLeft = 3;
753
+ for (;;) {
754
+ const res = await pylonFetchRaw(
755
+ transport,
756
+ `/api/fn-streams/${streamId}?since=${cursor.lastSeq}`,
757
+ { method: "GET", accept: "text/event-stream" },
758
+ );
759
+ if (res.status === 503 && attemptsLeft > 0) {
760
+ attemptsLeft -= 1;
761
+ await res.text().catch(() => {});
762
+ await new Promise((r) => setTimeout(r, 1000));
763
+ continue;
764
+ }
765
+ if (!res.ok) {
766
+ const text = await res.text().catch(() => "");
767
+ let message = `Stream resume failed: HTTP ${res.status}`;
768
+ try {
769
+ const err = JSON.parse(text) as { error?: { message?: string } };
770
+ if (err.error?.message) message = err.error.message;
771
+ } catch {
772
+ // keep the HTTP message
773
+ }
774
+ throw new Error(message);
775
+ }
776
+ let progressed = false;
777
+ try {
778
+ for await (const chunk of consumeSseBody(res, cursor)) {
779
+ progressed = true;
780
+ yield chunk;
781
+ }
782
+ } catch (e) {
783
+ if (cursor.terminal || attemptsLeft <= 0) throw e;
784
+ }
785
+ if (cursor.terminal) return cursor.finalResult;
786
+ if (progressed) attemptsLeft = 3;
787
+ attemptsLeft -= 1;
788
+ if (attemptsLeft < 0) {
789
+ throw new Error("Stream disconnected before completing");
790
+ }
791
+ }
618
792
  }
619
793
 
620
794
  /**