@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 +42 -1
- package/package.json +3 -3
- package/src/index.ts +222 -48
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.
|
|
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.
|
|
18
|
-
"@pylonsync/sync": "0.4.
|
|
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:
|
|
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
|
-
|
|
541
|
-
|
|
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
|
|
675
|
+
if (!res.ok) {
|
|
575
676
|
throw new Error(`Stream failed: HTTP ${res.status}`);
|
|
576
677
|
}
|
|
577
678
|
|
|
578
|
-
const
|
|
579
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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
|
/**
|