@webjsdev/server 0.8.24 → 0.8.26

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.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Streaming RPC results (#489), server side.
3
+ *
4
+ * When a `'use server'` action returns a `ReadableStream`, an async iterable, an
5
+ * async generator, or a Node `Readable` (all carry `Symbol.asyncIterator`, and a
6
+ * web `ReadableStream` is normalized to one), the framework streams the chunks
7
+ * over the single RPC response instead of buffering the whole thing. Each chunk
8
+ * is rich-serialized and length-prefixed via the shared wire protocol in
9
+ * `@webjsdev/core` (`encodeFrame`), so a `Date` / `Map` / `BigInt` inside a chunk
10
+ * round-trips exactly like a normal RPC value. The generated client stub
11
+ * (`actions.js`) decodes the frames and yields the chunks from an async iterable.
12
+ *
13
+ * Composes with the rest of the RPC stack:
14
+ * - #492 abort: the response is driven from the request's `AbortSignal`; a
15
+ * client disconnect (or a superseded async render) aborts the fetch, which
16
+ * cancels the source iterator, so a server generator stops producing.
17
+ * - #472 seeding: a streamed result is NOT seeded (a stream is not a
18
+ * serializer-safe value); `action-seed.js` skips a streamable result.
19
+ * - #488 verbs: streaming is orthogonal to the verb; detection is purely on the
20
+ * RETURN value, so a GET or a POST action can both stream.
21
+ */
22
+ import { encodeFrame, FRAME_CHUNK, FRAME_END, FRAME_ERROR, STREAM_CONTENT_TYPE } from '@webjsdev/core';
23
+ import { getSerializer } from './serializer.js';
24
+
25
+ /**
26
+ * Whether an action's return value should stream over the RPC wire rather than
27
+ * buffer. True for a web `ReadableStream`, any async iterable (an async
28
+ * generator, a Node `Readable`), but NOT for a plain object / array / string /
29
+ * Promise / ActionResult envelope (none of which carry `Symbol.asyncIterator`).
30
+ * @param {unknown} v
31
+ * @returns {boolean}
32
+ */
33
+ export function isStreamable(v) {
34
+ if (!v || typeof v !== 'object') return false;
35
+ if (typeof ReadableStream !== 'undefined' && v instanceof ReadableStream) return true;
36
+ return typeof (/** @type any */ (v)[Symbol.asyncIterator]) === 'function';
37
+ }
38
+
39
+ /**
40
+ * Normalize a streamable source to an async iterator with `next()` / `return()`.
41
+ * A source with `Symbol.asyncIterator` (async generator, Node Readable, and a
42
+ * web ReadableStream in modern Node) uses it directly; a ReadableStream lacking
43
+ * async iteration falls back to a reader.
44
+ * @param {any} source
45
+ * @returns {{ next(): Promise<{ value: any, done?: boolean }>, return?(v?: any): Promise<any> }}
46
+ */
47
+ function toAsyncIterator(source) {
48
+ if (source && typeof source[Symbol.asyncIterator] === 'function') {
49
+ return source[Symbol.asyncIterator]();
50
+ }
51
+ // A ReadableStream without async-iterator support: drive it from a reader.
52
+ const reader = source.getReader();
53
+ return {
54
+ next: () => reader.read(),
55
+ return: (v) => {
56
+ try { reader.cancel(v); } catch {}
57
+ try { reader.releaseLock(); } catch {}
58
+ return Promise.resolve({ value: undefined, done: true });
59
+ },
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Build the streaming RPC `Response` for a streamable action result. The body is
65
+ * a back-pressured `ReadableStream` of frames: one CHUNK frame per serialized
66
+ * source value, a terminal END frame on clean completion, or an ERROR frame
67
+ * carrying the (author-controlled) message if the source throws mid-flight (the
68
+ * HTTP status is already 200 by then, so the error rides the frame channel).
69
+ *
70
+ * Back-pressure: chunks are pulled (and serialized) one at a time as the consumer
71
+ * reads, so a slow client throttles a fast producer. Cancellation: an aborted
72
+ * request signal (client disconnect / superseded render, #492) returns the source
73
+ * iterator, stopping a server generator.
74
+ *
75
+ * @param {any} source the streamable action return value
76
+ * @param {{ signal?: AbortSignal, onError?: (e: unknown) => void, headers?: Record<string,string> }} [opts]
77
+ * @returns {Response}
78
+ */
79
+ export function streamActionResponse(source, opts = {}) {
80
+ const { signal, onError, headers } = opts;
81
+ const s = getSerializer();
82
+ const enc = new TextEncoder();
83
+ const iter = toAsyncIterator(source);
84
+ let closed = false;
85
+ /** @type {(() => void) | null} */
86
+ let onAbort = null;
87
+
88
+ const releaseSource = (reason) => {
89
+ if (typeof iter.return === 'function') {
90
+ try { return Promise.resolve(iter.return(reason)).catch(() => {}); } catch {}
91
+ }
92
+ return Promise.resolve();
93
+ };
94
+ const detach = () => { if (onAbort && signal) { try { signal.removeEventListener('abort', onAbort); } catch {} onAbort = null; } };
95
+
96
+ const body = new ReadableStream({
97
+ async pull(controller) {
98
+ if (closed) return;
99
+ try {
100
+ if (signal && signal.aborted) {
101
+ closed = true;
102
+ // Emit an ERROR frame so a still-reading client sees an explicit abort
103
+ // rather than a silent truncation (a clean close with no terminal
104
+ // frame is indistinguishable from success). Best-effort: the
105
+ // connection may already be gone, in which case enqueue throws.
106
+ try { controller.enqueue(encodeFrame(FRAME_ERROR, enc.encode('stream aborted'))); } catch {}
107
+ await releaseSource();
108
+ detach();
109
+ controller.close();
110
+ return;
111
+ }
112
+ const { value, done } = await iter.next();
113
+ if (done) {
114
+ closed = true;
115
+ controller.enqueue(encodeFrame(FRAME_END));
116
+ detach();
117
+ controller.close();
118
+ return;
119
+ }
120
+ const bytes = enc.encode(await s.serialize(value));
121
+ controller.enqueue(encodeFrame(FRAME_CHUNK, bytes));
122
+ } catch (e) {
123
+ closed = true;
124
+ // Surface the original error to an APM sink + the server log, exactly
125
+ // like the buffered error path, then ship the author-controlled message
126
+ // on the frame channel (the status is already 200, mid-stream).
127
+ if (typeof onError === 'function') { try { onError(e); } catch {} }
128
+ console.error('[webjs] streaming action threw:', e);
129
+ const msg = e instanceof Error && e.message ? e.message : 'Internal server error';
130
+ try { controller.enqueue(encodeFrame(FRAME_ERROR, enc.encode(msg))); } catch {}
131
+ await releaseSource(e);
132
+ detach();
133
+ try { controller.close(); } catch {}
134
+ }
135
+ },
136
+ async cancel(reason) {
137
+ closed = true;
138
+ detach();
139
+ await releaseSource(reason);
140
+ },
141
+ });
142
+
143
+ // A client disconnect / superseded render aborts the request signal: cancel the
144
+ // source promptly even if the consumer is not actively pulling. The listener is
145
+ // detached on completion / cancel so it cannot outlive the stream.
146
+ if (signal) {
147
+ onAbort = () => { releaseSource(); };
148
+ signal.addEventListener('abort', onAbort, { once: true });
149
+ }
150
+
151
+ // A streamed result is never cacheable; mark it streamed so the conditional-GET
152
+ // funnel never tries to buffer it into memory for an ETag.
153
+ const h = new Headers(headers || {});
154
+ h.set('content-type', STREAM_CONTENT_TYPE);
155
+ h.set('cache-control', 'no-store');
156
+ h.set('x-webjs-stream-rpc', '1');
157
+ return new Response(body, { status: 200, headers: h });
158
+ }