@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.
- package/index.d.ts +26 -2
- package/index.js +3 -0
- package/package.json +1 -1
- package/src/action-config.js +162 -0
- package/src/action-middleware.d.ts +21 -0
- package/src/action-middleware.js +68 -0
- package/src/action-route.js +147 -0
- package/src/action-seed.js +8 -3
- package/src/action-signal.js +39 -0
- package/src/action-stream.js +158 -0
- package/src/actions.js +271 -316
- package/src/check.js +43 -0
- package/src/cors.js +5 -8
- package/src/csrf.js +5 -4
- package/src/dev.js +6 -34
- package/src/importmap.js +3 -3
|
@@ -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
|
+
}
|