@tanstack/start-server-core 1.143.12 → 1.145.0
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/esm/frame-protocol.d.ts +32 -0
- package/dist/esm/frame-protocol.js +139 -0
- package/dist/esm/frame-protocol.js.map +1 -0
- package/dist/esm/server-functions-handler.js +47 -6
- package/dist/esm/server-functions-handler.js.map +1 -1
- package/package.json +4 -4
- package/src/frame-protocol.ts +216 -0
- package/src/server-functions-handler.ts +71 -7
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { FrameType } from '@tanstack/start-client-core';
|
|
2
|
+
export { FRAME_HEADER_SIZE, FrameType, TSS_CONTENT_TYPE_FRAMED, TSS_CONTENT_TYPE_FRAMED_VERSIONED, TSS_FRAMED_PROTOCOL_VERSION, } from '@tanstack/start-client-core';
|
|
3
|
+
/**
|
|
4
|
+
* Encodes a single frame with header and payload.
|
|
5
|
+
*/
|
|
6
|
+
export declare function encodeFrame(type: FrameType, streamId: number, payload: Uint8Array): Uint8Array;
|
|
7
|
+
/**
|
|
8
|
+
* Encodes a JSON frame (type 0, streamId 0).
|
|
9
|
+
*/
|
|
10
|
+
export declare function encodeJSONFrame(json: string): Uint8Array;
|
|
11
|
+
/**
|
|
12
|
+
* Encodes a raw stream chunk frame.
|
|
13
|
+
*/
|
|
14
|
+
export declare function encodeChunkFrame(streamId: number, chunk: Uint8Array): Uint8Array;
|
|
15
|
+
/**
|
|
16
|
+
* Encodes a raw stream end frame.
|
|
17
|
+
*/
|
|
18
|
+
export declare function encodeEndFrame(streamId: number): Uint8Array;
|
|
19
|
+
/**
|
|
20
|
+
* Encodes a raw stream error frame.
|
|
21
|
+
*/
|
|
22
|
+
export declare function encodeErrorFrame(streamId: number, error: unknown): Uint8Array;
|
|
23
|
+
/**
|
|
24
|
+
* Creates a multiplexed ReadableStream from JSON stream and raw streams.
|
|
25
|
+
*
|
|
26
|
+
* The JSON stream emits NDJSON lines (from seroval's toCrossJSONStream).
|
|
27
|
+
* Raw streams are pumped concurrently, interleaved with JSON frames.
|
|
28
|
+
*
|
|
29
|
+
* @param jsonStream Stream of JSON strings (each string is one NDJSON line)
|
|
30
|
+
* @param rawStreams Map of stream IDs to raw binary streams
|
|
31
|
+
*/
|
|
32
|
+
export declare function createMultiplexedStream(jsonStream: ReadableStream<string>, rawStreams: Map<number, ReadableStream<Uint8Array>>): ReadableStream<Uint8Array>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { FrameType, FRAME_HEADER_SIZE } from "@tanstack/start-client-core";
|
|
2
|
+
import { FRAME_HEADER_SIZE as FRAME_HEADER_SIZE2, FrameType as FrameType2, TSS_CONTENT_TYPE_FRAMED, TSS_CONTENT_TYPE_FRAMED_VERSIONED, TSS_FRAMED_PROTOCOL_VERSION } from "@tanstack/start-client-core";
|
|
3
|
+
const textEncoder = new TextEncoder();
|
|
4
|
+
const EMPTY_PAYLOAD = new Uint8Array(0);
|
|
5
|
+
function encodeFrame(type, streamId, payload) {
|
|
6
|
+
const frame = new Uint8Array(FRAME_HEADER_SIZE + payload.length);
|
|
7
|
+
frame[0] = type;
|
|
8
|
+
frame[1] = streamId >>> 24 & 255;
|
|
9
|
+
frame[2] = streamId >>> 16 & 255;
|
|
10
|
+
frame[3] = streamId >>> 8 & 255;
|
|
11
|
+
frame[4] = streamId & 255;
|
|
12
|
+
frame[5] = payload.length >>> 24 & 255;
|
|
13
|
+
frame[6] = payload.length >>> 16 & 255;
|
|
14
|
+
frame[7] = payload.length >>> 8 & 255;
|
|
15
|
+
frame[8] = payload.length & 255;
|
|
16
|
+
frame.set(payload, FRAME_HEADER_SIZE);
|
|
17
|
+
return frame;
|
|
18
|
+
}
|
|
19
|
+
function encodeJSONFrame(json) {
|
|
20
|
+
return encodeFrame(FrameType.JSON, 0, textEncoder.encode(json));
|
|
21
|
+
}
|
|
22
|
+
function encodeChunkFrame(streamId, chunk) {
|
|
23
|
+
return encodeFrame(FrameType.CHUNK, streamId, chunk);
|
|
24
|
+
}
|
|
25
|
+
function encodeEndFrame(streamId) {
|
|
26
|
+
return encodeFrame(FrameType.END, streamId, EMPTY_PAYLOAD);
|
|
27
|
+
}
|
|
28
|
+
function encodeErrorFrame(streamId, error) {
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error ?? "Unknown error");
|
|
30
|
+
return encodeFrame(FrameType.ERROR, streamId, textEncoder.encode(message));
|
|
31
|
+
}
|
|
32
|
+
function createMultiplexedStream(jsonStream, rawStreams) {
|
|
33
|
+
let activePumps = 1 + rawStreams.size;
|
|
34
|
+
let controllerRef = null;
|
|
35
|
+
let cancelled = false;
|
|
36
|
+
const cancelReaders = [];
|
|
37
|
+
const safeEnqueue = (chunk) => {
|
|
38
|
+
if (cancelled || !controllerRef) return;
|
|
39
|
+
try {
|
|
40
|
+
controllerRef.enqueue(chunk);
|
|
41
|
+
} catch {
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const safeError = (err) => {
|
|
45
|
+
if (cancelled || !controllerRef) return;
|
|
46
|
+
try {
|
|
47
|
+
controllerRef.error(err);
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const safeClose = () => {
|
|
52
|
+
if (cancelled || !controllerRef) return;
|
|
53
|
+
try {
|
|
54
|
+
controllerRef.close();
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const checkComplete = () => {
|
|
59
|
+
activePumps--;
|
|
60
|
+
if (activePumps === 0) {
|
|
61
|
+
safeClose();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
return new ReadableStream({
|
|
65
|
+
start(controller) {
|
|
66
|
+
controllerRef = controller;
|
|
67
|
+
cancelReaders.length = 0;
|
|
68
|
+
const pumpJSON = async () => {
|
|
69
|
+
const reader = jsonStream.getReader();
|
|
70
|
+
cancelReaders.push(() => {
|
|
71
|
+
reader.cancel().catch(() => {
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
while (true) {
|
|
76
|
+
const { done, value } = await reader.read();
|
|
77
|
+
if (cancelled) break;
|
|
78
|
+
if (done) break;
|
|
79
|
+
safeEnqueue(encodeJSONFrame(value));
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
safeError(error);
|
|
83
|
+
} finally {
|
|
84
|
+
reader.releaseLock();
|
|
85
|
+
checkComplete();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const pumpRawStream = async (streamId, stream) => {
|
|
89
|
+
const reader = stream.getReader();
|
|
90
|
+
cancelReaders.push(() => {
|
|
91
|
+
reader.cancel().catch(() => {
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
while (true) {
|
|
96
|
+
const { done, value } = await reader.read();
|
|
97
|
+
if (cancelled) break;
|
|
98
|
+
if (done) {
|
|
99
|
+
safeEnqueue(encodeEndFrame(streamId));
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
safeEnqueue(encodeChunkFrame(streamId, value));
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
safeEnqueue(encodeErrorFrame(streamId, error));
|
|
106
|
+
} finally {
|
|
107
|
+
reader.releaseLock();
|
|
108
|
+
checkComplete();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
pumpJSON();
|
|
112
|
+
for (const [streamId, stream] of rawStreams) {
|
|
113
|
+
pumpRawStream(streamId, stream);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
cancel() {
|
|
117
|
+
cancelled = true;
|
|
118
|
+
controllerRef = null;
|
|
119
|
+
for (const cancelReader of cancelReaders) {
|
|
120
|
+
cancelReader();
|
|
121
|
+
}
|
|
122
|
+
cancelReaders.length = 0;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
export {
|
|
127
|
+
FRAME_HEADER_SIZE2 as FRAME_HEADER_SIZE,
|
|
128
|
+
FrameType2 as FrameType,
|
|
129
|
+
TSS_CONTENT_TYPE_FRAMED,
|
|
130
|
+
TSS_CONTENT_TYPE_FRAMED_VERSIONED,
|
|
131
|
+
TSS_FRAMED_PROTOCOL_VERSION,
|
|
132
|
+
createMultiplexedStream,
|
|
133
|
+
encodeChunkFrame,
|
|
134
|
+
encodeEndFrame,
|
|
135
|
+
encodeErrorFrame,
|
|
136
|
+
encodeFrame,
|
|
137
|
+
encodeJSONFrame
|
|
138
|
+
};
|
|
139
|
+
//# sourceMappingURL=frame-protocol.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frame-protocol.js","sources":["../../src/frame-protocol.ts"],"sourcesContent":["/**\n * Binary frame protocol for multiplexing JSON and raw streams over HTTP.\n *\n * Frame format: [type:1][streamId:4][length:4][payload:length]\n * - type: 1 byte - frame type (JSON, CHUNK, END, ERROR)\n * - streamId: 4 bytes big-endian uint32 - stream identifier\n * - length: 4 bytes big-endian uint32 - payload length\n * - payload: variable length bytes\n */\n\n// Re-export constants from shared location\nimport { FRAME_HEADER_SIZE, FrameType } from '@tanstack/start-client-core'\n\nexport {\n FRAME_HEADER_SIZE,\n FrameType,\n TSS_CONTENT_TYPE_FRAMED,\n TSS_CONTENT_TYPE_FRAMED_VERSIONED,\n TSS_FRAMED_PROTOCOL_VERSION,\n} from '@tanstack/start-client-core'\n\n/** Cached TextEncoder for frame encoding */\nconst textEncoder = new TextEncoder()\n\n/** Shared empty payload for END frames - avoids allocation per call */\nconst EMPTY_PAYLOAD = new Uint8Array(0)\n\n/**\n * Encodes a single frame with header and payload.\n */\nexport function encodeFrame(\n type: FrameType,\n streamId: number,\n payload: Uint8Array,\n): Uint8Array {\n const frame = new Uint8Array(FRAME_HEADER_SIZE + payload.length)\n // Write header bytes directly to avoid DataView allocation per frame\n // Frame format: [type:1][streamId:4 BE][length:4 BE]\n frame[0] = type\n frame[1] = (streamId >>> 24) & 0xff\n frame[2] = (streamId >>> 16) & 0xff\n frame[3] = (streamId >>> 8) & 0xff\n frame[4] = streamId & 0xff\n frame[5] = (payload.length >>> 24) & 0xff\n frame[6] = (payload.length >>> 16) & 0xff\n frame[7] = (payload.length >>> 8) & 0xff\n frame[8] = payload.length & 0xff\n frame.set(payload, FRAME_HEADER_SIZE)\n return frame\n}\n\n/**\n * Encodes a JSON frame (type 0, streamId 0).\n */\nexport function encodeJSONFrame(json: string): Uint8Array {\n return encodeFrame(FrameType.JSON, 0, textEncoder.encode(json))\n}\n\n/**\n * Encodes a raw stream chunk frame.\n */\nexport function encodeChunkFrame(\n streamId: number,\n chunk: Uint8Array,\n): Uint8Array {\n return encodeFrame(FrameType.CHUNK, streamId, chunk)\n}\n\n/**\n * Encodes a raw stream end frame.\n */\nexport function encodeEndFrame(streamId: number): Uint8Array {\n return encodeFrame(FrameType.END, streamId, EMPTY_PAYLOAD)\n}\n\n/**\n * Encodes a raw stream error frame.\n */\nexport function encodeErrorFrame(streamId: number, error: unknown): Uint8Array {\n const message =\n error instanceof Error ? error.message : String(error ?? 'Unknown error')\n return encodeFrame(FrameType.ERROR, streamId, textEncoder.encode(message))\n}\n\n/**\n * Creates a multiplexed ReadableStream from JSON stream and raw streams.\n *\n * The JSON stream emits NDJSON lines (from seroval's toCrossJSONStream).\n * Raw streams are pumped concurrently, interleaved with JSON frames.\n *\n * @param jsonStream Stream of JSON strings (each string is one NDJSON line)\n * @param rawStreams Map of stream IDs to raw binary streams\n */\nexport function createMultiplexedStream(\n jsonStream: ReadableStream<string>,\n rawStreams: Map<number, ReadableStream<Uint8Array>>,\n): ReadableStream<Uint8Array> {\n // Track active pumps for completion\n let activePumps = 1 + rawStreams.size // 1 for JSON + raw streams\n let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null\n let cancelled = false as boolean\n const cancelReaders: Array<() => void> = []\n\n const safeEnqueue = (chunk: Uint8Array) => {\n if (cancelled || !controllerRef) return\n try {\n controllerRef.enqueue(chunk)\n } catch {\n // Ignore enqueue after close/cancel\n }\n }\n\n const safeError = (err: unknown) => {\n if (cancelled || !controllerRef) return\n try {\n controllerRef.error(err)\n } catch {\n // Ignore\n }\n }\n\n const safeClose = () => {\n if (cancelled || !controllerRef) return\n try {\n controllerRef.close()\n } catch {\n // Ignore\n }\n }\n\n const checkComplete = () => {\n activePumps--\n if (activePumps === 0) {\n safeClose()\n }\n }\n\n return new ReadableStream<Uint8Array>({\n start(controller) {\n controllerRef = controller\n cancelReaders.length = 0\n\n // Pump JSON stream (streamId 0)\n const pumpJSON = async () => {\n const reader = jsonStream.getReader()\n cancelReaders.push(() => {\n // Catch async rejection - reader may already be released\n reader.cancel().catch(() => {})\n })\n try {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n while (true) {\n const { done, value } = await reader.read()\n // Check cancelled after await - flag may have changed while waiting\n if (cancelled) break\n if (done) break\n safeEnqueue(encodeJSONFrame(value))\n }\n } catch (error) {\n // JSON stream error - fatal, error the whole response\n safeError(error)\n } finally {\n reader.releaseLock()\n checkComplete()\n }\n }\n\n // Pump a single raw stream with its streamId\n const pumpRawStream = async (\n streamId: number,\n stream: ReadableStream<Uint8Array>,\n ) => {\n const reader = stream.getReader()\n cancelReaders.push(() => {\n // Catch async rejection - reader may already be released\n reader.cancel().catch(() => {})\n })\n try {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n while (true) {\n const { done, value } = await reader.read()\n // Check cancelled after await - flag may have changed while waiting\n if (cancelled) break\n if (done) {\n safeEnqueue(encodeEndFrame(streamId))\n break\n }\n safeEnqueue(encodeChunkFrame(streamId, value))\n }\n } catch (error) {\n // Stream error - send ERROR frame (non-fatal, other streams continue)\n safeEnqueue(encodeErrorFrame(streamId, error))\n } finally {\n reader.releaseLock()\n checkComplete()\n }\n }\n\n // Start all pumps concurrently\n pumpJSON()\n for (const [streamId, stream] of rawStreams) {\n pumpRawStream(streamId, stream)\n }\n },\n\n cancel() {\n cancelled = true\n controllerRef = null\n // Proactively cancel all underlying readers to stop work quickly.\n for (const cancelReader of cancelReaders) {\n cancelReader()\n }\n cancelReaders.length = 0\n },\n })\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { isNotFound, isRedirect } from "@tanstack/router-core";
|
|
1
|
+
import { createRawStreamRPCPlugin, isNotFound, isRedirect } from "@tanstack/router-core";
|
|
2
2
|
import invariant from "tiny-invariant";
|
|
3
|
-
import { getDefaultSerovalPlugins, X_TSS_SERIALIZED, TSS_FORMDATA_CONTEXT, safeObjectMerge, X_TSS_RAW_RESPONSE } from "@tanstack/start-client-core";
|
|
3
|
+
import { getDefaultSerovalPlugins, X_TSS_SERIALIZED, TSS_CONTENT_TYPE_FRAMED_VERSIONED, TSS_FORMDATA_CONTEXT, safeObjectMerge, X_TSS_RAW_RESPONSE } from "@tanstack/start-client-core";
|
|
4
4
|
import { toCrossJSONStream, fromJSON, toCrossJSONAsync } from "seroval";
|
|
5
5
|
import { getResponse } from "./request-response.js";
|
|
6
|
+
import { createMultiplexedStream } from "./frame-protocol.js";
|
|
6
7
|
import { getServerFnById } from "#tanstack-start-server-fn-resolver";
|
|
7
8
|
let serovalPlugins = void 0;
|
|
9
|
+
const textEncoder = new TextEncoder();
|
|
8
10
|
const FORM_DATA_CONTENT_TYPES = [
|
|
9
11
|
"multipart/form-data",
|
|
10
12
|
"application/x-www-form-urlencoded"
|
|
@@ -38,6 +40,13 @@ const handleServerAction = async ({
|
|
|
38
40
|
let nonStreamingBody = void 0;
|
|
39
41
|
const alsResponse = getResponse();
|
|
40
42
|
if (res2 !== void 0) {
|
|
43
|
+
const rawStreams = /* @__PURE__ */ new Map();
|
|
44
|
+
const rawStreamPlugin = createRawStreamRPCPlugin(
|
|
45
|
+
(id, stream2) => {
|
|
46
|
+
rawStreams.set(id, stream2);
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
const plugins = [rawStreamPlugin, ...serovalPlugins || []];
|
|
41
50
|
let done = false;
|
|
42
51
|
const callbacks = {
|
|
43
52
|
onParse: (value) => {
|
|
@@ -52,7 +61,7 @@ const handleServerAction = async ({
|
|
|
52
61
|
};
|
|
53
62
|
toCrossJSONStream(res2, {
|
|
54
63
|
refs: /* @__PURE__ */ new Map(),
|
|
55
|
-
plugins
|
|
64
|
+
plugins,
|
|
56
65
|
onParse(value) {
|
|
57
66
|
callbacks.onParse(value);
|
|
58
67
|
},
|
|
@@ -63,7 +72,7 @@ const handleServerAction = async ({
|
|
|
63
72
|
callbacks.onError(error);
|
|
64
73
|
}
|
|
65
74
|
});
|
|
66
|
-
if (done) {
|
|
75
|
+
if (done && rawStreams.size === 0) {
|
|
67
76
|
return new Response(
|
|
68
77
|
nonStreamingBody ? JSON.stringify(nonStreamingBody) : void 0,
|
|
69
78
|
{
|
|
@@ -76,10 +85,42 @@ const handleServerAction = async ({
|
|
|
76
85
|
}
|
|
77
86
|
);
|
|
78
87
|
}
|
|
79
|
-
|
|
88
|
+
if (rawStreams.size > 0) {
|
|
89
|
+
const jsonStream = new ReadableStream({
|
|
90
|
+
start(controller2) {
|
|
91
|
+
callbacks.onParse = (value) => {
|
|
92
|
+
controller2.enqueue(JSON.stringify(value) + "\n");
|
|
93
|
+
};
|
|
94
|
+
callbacks.onDone = () => {
|
|
95
|
+
try {
|
|
96
|
+
controller2.close();
|
|
97
|
+
} catch {
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
callbacks.onError = (error) => controller2.error(error);
|
|
101
|
+
if (nonStreamingBody !== void 0) {
|
|
102
|
+
callbacks.onParse(nonStreamingBody);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
const multiplexedStream = createMultiplexedStream(
|
|
107
|
+
jsonStream,
|
|
108
|
+
rawStreams
|
|
109
|
+
);
|
|
110
|
+
return new Response(multiplexedStream, {
|
|
111
|
+
status: alsResponse.status,
|
|
112
|
+
statusText: alsResponse.statusText,
|
|
113
|
+
headers: {
|
|
114
|
+
"Content-Type": TSS_CONTENT_TYPE_FRAMED_VERSIONED,
|
|
115
|
+
[X_TSS_SERIALIZED]: "true"
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
80
119
|
const stream = new ReadableStream({
|
|
81
120
|
start(controller2) {
|
|
82
|
-
callbacks.onParse = (value) => controller2.enqueue(
|
|
121
|
+
callbacks.onParse = (value) => controller2.enqueue(
|
|
122
|
+
textEncoder.encode(JSON.stringify(value) + "\n")
|
|
123
|
+
);
|
|
83
124
|
callbacks.onDone = () => {
|
|
84
125
|
try {
|
|
85
126
|
controller2.close();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-functions-handler.js","sources":["../../src/server-functions-handler.ts"],"sourcesContent":["import { isNotFound, isRedirect } from '@tanstack/router-core'\nimport invariant from 'tiny-invariant'\nimport {\n TSS_FORMDATA_CONTEXT,\n X_TSS_RAW_RESPONSE,\n X_TSS_SERIALIZED,\n getDefaultSerovalPlugins,\n safeObjectMerge,\n} from '@tanstack/start-client-core'\nimport { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval'\nimport { getResponse } from './request-response'\nimport { getServerFnById } from './getServerFnById'\nimport type { Plugin as SerovalPlugin } from 'seroval'\n\n// Cache serovalPlugins at module level to avoid repeated calls\nlet serovalPlugins: Array<SerovalPlugin<any, any>> | undefined = undefined\n\n// Known FormData 'Content-Type' header values - module-level constant\nconst FORM_DATA_CONTENT_TYPES = [\n 'multipart/form-data',\n 'application/x-www-form-urlencoded',\n]\n\n// Maximum payload size for GET requests (1MB)\nconst MAX_PAYLOAD_SIZE = 1_000_000\n\nexport const handleServerAction = async ({\n request,\n context,\n serverFnId,\n}: {\n request: Request\n context: any\n serverFnId: string\n}) => {\n const controller = new AbortController()\n const signal = controller.signal\n const abort = () => controller.abort()\n request.signal.addEventListener('abort', abort)\n\n const method = request.method\n const methodLower = method.toLowerCase()\n const url = new URL(request.url)\n\n const action = await getServerFnById(serverFnId, { fromClient: true })\n\n const isServerFn = request.headers.get('x-tsr-serverFn') === 'true'\n\n // Initialize serovalPlugins lazily (cached at module level)\n if (!serovalPlugins) {\n serovalPlugins = getDefaultSerovalPlugins()\n }\n\n const contentType = request.headers.get('Content-Type')\n\n function parsePayload(payload: any) {\n const parsedPayload = fromJSON(payload, { plugins: serovalPlugins })\n return parsedPayload as any\n }\n\n const response = await (async () => {\n try {\n let res = await (async () => {\n // FormData\n if (\n FORM_DATA_CONTENT_TYPES.some(\n (type) => contentType && contentType.includes(type),\n )\n ) {\n // We don't support GET requests with FormData payloads... that seems impossible\n invariant(\n methodLower !== 'get',\n 'GET requests with FormData payloads are not supported',\n )\n const formData = await request.formData()\n const serializedContext = formData.get(TSS_FORMDATA_CONTEXT)\n formData.delete(TSS_FORMDATA_CONTEXT)\n\n const params = {\n context,\n data: formData,\n }\n if (typeof serializedContext === 'string') {\n try {\n const parsedContext = JSON.parse(serializedContext)\n const deserializedContext = fromJSON(parsedContext, {\n plugins: serovalPlugins,\n })\n if (\n typeof deserializedContext === 'object' &&\n deserializedContext\n ) {\n params.context = safeObjectMerge(\n context,\n deserializedContext as Record<string, unknown>,\n )\n }\n } catch (e) {\n // Log warning for debugging but don't expose to client\n if (process.env.NODE_ENV === 'development') {\n console.warn('Failed to parse FormData context:', e)\n }\n }\n }\n\n return await action(params, signal)\n }\n\n // Get requests use the query string\n if (methodLower === 'get') {\n // Get payload directly from searchParams\n const payloadParam = url.searchParams.get('payload')\n // Reject oversized payloads to prevent DoS\n if (payloadParam && payloadParam.length > MAX_PAYLOAD_SIZE) {\n throw new Error('Payload too large')\n }\n // If there's a payload, we should try to parse it\n const payload: any = payloadParam\n ? parsePayload(JSON.parse(payloadParam))\n : {}\n payload.context = safeObjectMerge(context, payload.context)\n // Send it through!\n return await action(payload, signal)\n }\n\n if (methodLower !== 'post') {\n throw new Error('expected POST method')\n }\n\n let jsonPayload\n if (contentType?.includes('application/json')) {\n jsonPayload = await request.json()\n }\n\n const payload = jsonPayload ? parsePayload(jsonPayload) : {}\n payload.context = safeObjectMerge(payload.context, context)\n return await action(payload, signal)\n })()\n\n const unwrapped = res.result || res.error\n\n if (isNotFound(res)) {\n res = isNotFoundResponse(res)\n }\n\n if (!isServerFn) {\n return unwrapped\n }\n\n if (unwrapped instanceof Response) {\n if (isRedirect(unwrapped)) {\n return unwrapped\n }\n unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true')\n return unwrapped\n }\n\n return serializeResult(res)\n\n function serializeResult(res: unknown): Response {\n let nonStreamingBody: any = undefined\n\n const alsResponse = getResponse()\n if (res !== undefined) {\n // first run without the stream in case `result` does not need streaming\n let done = false as boolean\n const callbacks: {\n onParse: (value: any) => void\n onDone: () => void\n onError: (error: any) => void\n } = {\n onParse: (value) => {\n nonStreamingBody = value\n },\n onDone: () => {\n done = true\n },\n onError: (error) => {\n throw error\n },\n }\n toCrossJSONStream(res, {\n refs: new Map(),\n plugins: serovalPlugins,\n onParse(value) {\n callbacks.onParse(value)\n },\n onDone() {\n callbacks.onDone()\n },\n onError: (error) => {\n callbacks.onError(error)\n },\n })\n if (done) {\n return new Response(\n nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined,\n {\n status: alsResponse.status,\n statusText: alsResponse.statusText,\n headers: {\n 'Content-Type': 'application/json',\n [X_TSS_SERIALIZED]: 'true',\n },\n },\n )\n }\n\n // not done yet, we need to stream\n const encoder = new TextEncoder()\n const stream = new ReadableStream({\n start(controller) {\n callbacks.onParse = (value) =>\n controller.enqueue(encoder.encode(JSON.stringify(value) + '\\n'))\n callbacks.onDone = () => {\n try {\n controller.close()\n } catch (error) {\n controller.error(error)\n }\n }\n callbacks.onError = (error) => controller.error(error)\n // stream the initial body\n if (nonStreamingBody !== undefined) {\n callbacks.onParse(nonStreamingBody)\n }\n },\n })\n return new Response(stream, {\n status: alsResponse.status,\n statusText: alsResponse.statusText,\n headers: {\n 'Content-Type': 'application/x-ndjson',\n [X_TSS_SERIALIZED]: 'true',\n },\n })\n }\n\n return new Response(undefined, {\n status: alsResponse.status,\n statusText: alsResponse.statusText,\n })\n }\n } catch (error: any) {\n if (error instanceof Response) {\n return error\n }\n // else if (\n // isPlainObject(error) &&\n // 'result' in error &&\n // error.result instanceof Response\n // ) {\n // return error.result\n // }\n\n // Currently this server-side context has no idea how to\n // build final URLs, so we need to defer that to the client.\n // The client will check for __redirect and __notFound keys,\n // and if they exist, it will handle them appropriately.\n\n if (isNotFound(error)) {\n return isNotFoundResponse(error)\n }\n\n console.info()\n console.info('Server Fn Error!')\n console.info()\n console.error(error)\n console.info()\n\n const serializedError = JSON.stringify(\n await Promise.resolve(\n toCrossJSONAsync(error, {\n refs: new Map(),\n plugins: serovalPlugins,\n }),\n ),\n )\n const response = getResponse()\n return new Response(serializedError, {\n status: response.status ?? 500,\n statusText: response.statusText,\n headers: {\n 'Content-Type': 'application/json',\n [X_TSS_SERIALIZED]: 'true',\n },\n })\n }\n })()\n\n request.signal.removeEventListener('abort', abort)\n\n return response\n}\n\nfunction isNotFoundResponse(error: any) {\n const { headers, ...rest } = error\n\n return new Response(JSON.stringify(rest), {\n status: 404,\n headers: {\n 'Content-Type': 'application/json',\n ...(headers || {}),\n },\n })\n}\n"],"names":["res","controller","payload","response"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"server-functions-handler.js","sources":["../../src/server-functions-handler.ts"],"sourcesContent":["import {\n createRawStreamRPCPlugin,\n isNotFound,\n isRedirect,\n} from '@tanstack/router-core'\nimport invariant from 'tiny-invariant'\nimport {\n TSS_FORMDATA_CONTEXT,\n X_TSS_RAW_RESPONSE,\n X_TSS_SERIALIZED,\n getDefaultSerovalPlugins,\n safeObjectMerge,\n} from '@tanstack/start-client-core'\nimport { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval'\nimport { getResponse } from './request-response'\nimport { getServerFnById } from './getServerFnById'\nimport {\n TSS_CONTENT_TYPE_FRAMED_VERSIONED,\n createMultiplexedStream,\n} from './frame-protocol'\nimport type { Plugin as SerovalPlugin } from 'seroval'\n\n// Cache serovalPlugins at module level to avoid repeated calls\nlet serovalPlugins: Array<SerovalPlugin<any, any>> | undefined = undefined\n\n// Cache TextEncoder for NDJSON serialization\nconst textEncoder = new TextEncoder()\n\n// Known FormData 'Content-Type' header values - module-level constant\nconst FORM_DATA_CONTENT_TYPES = [\n 'multipart/form-data',\n 'application/x-www-form-urlencoded',\n]\n\n// Maximum payload size for GET requests (1MB)\nconst MAX_PAYLOAD_SIZE = 1_000_000\n\nexport const handleServerAction = async ({\n request,\n context,\n serverFnId,\n}: {\n request: Request\n context: any\n serverFnId: string\n}) => {\n const controller = new AbortController()\n const signal = controller.signal\n const abort = () => controller.abort()\n request.signal.addEventListener('abort', abort)\n\n const method = request.method\n const methodLower = method.toLowerCase()\n const url = new URL(request.url)\n\n const action = await getServerFnById(serverFnId, { fromClient: true })\n\n const isServerFn = request.headers.get('x-tsr-serverFn') === 'true'\n\n // Initialize serovalPlugins lazily (cached at module level)\n if (!serovalPlugins) {\n serovalPlugins = getDefaultSerovalPlugins()\n }\n\n const contentType = request.headers.get('Content-Type')\n\n function parsePayload(payload: any) {\n const parsedPayload = fromJSON(payload, { plugins: serovalPlugins })\n return parsedPayload as any\n }\n\n const response = await (async () => {\n try {\n let res = await (async () => {\n // FormData\n if (\n FORM_DATA_CONTENT_TYPES.some(\n (type) => contentType && contentType.includes(type),\n )\n ) {\n // We don't support GET requests with FormData payloads... that seems impossible\n invariant(\n methodLower !== 'get',\n 'GET requests with FormData payloads are not supported',\n )\n const formData = await request.formData()\n const serializedContext = formData.get(TSS_FORMDATA_CONTEXT)\n formData.delete(TSS_FORMDATA_CONTEXT)\n\n const params = {\n context,\n data: formData,\n }\n if (typeof serializedContext === 'string') {\n try {\n const parsedContext = JSON.parse(serializedContext)\n const deserializedContext = fromJSON(parsedContext, {\n plugins: serovalPlugins,\n })\n if (\n typeof deserializedContext === 'object' &&\n deserializedContext\n ) {\n params.context = safeObjectMerge(\n context,\n deserializedContext as Record<string, unknown>,\n )\n }\n } catch (e) {\n // Log warning for debugging but don't expose to client\n if (process.env.NODE_ENV === 'development') {\n console.warn('Failed to parse FormData context:', e)\n }\n }\n }\n\n return await action(params, signal)\n }\n\n // Get requests use the query string\n if (methodLower === 'get') {\n // Get payload directly from searchParams\n const payloadParam = url.searchParams.get('payload')\n // Reject oversized payloads to prevent DoS\n if (payloadParam && payloadParam.length > MAX_PAYLOAD_SIZE) {\n throw new Error('Payload too large')\n }\n // If there's a payload, we should try to parse it\n const payload: any = payloadParam\n ? parsePayload(JSON.parse(payloadParam))\n : {}\n payload.context = safeObjectMerge(context, payload.context)\n // Send it through!\n return await action(payload, signal)\n }\n\n if (methodLower !== 'post') {\n throw new Error('expected POST method')\n }\n\n let jsonPayload\n if (contentType?.includes('application/json')) {\n jsonPayload = await request.json()\n }\n\n const payload = jsonPayload ? parsePayload(jsonPayload) : {}\n payload.context = safeObjectMerge(payload.context, context)\n return await action(payload, signal)\n })()\n\n const unwrapped = res.result || res.error\n\n if (isNotFound(res)) {\n res = isNotFoundResponse(res)\n }\n\n if (!isServerFn) {\n return unwrapped\n }\n\n if (unwrapped instanceof Response) {\n if (isRedirect(unwrapped)) {\n return unwrapped\n }\n unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true')\n return unwrapped\n }\n\n return serializeResult(res)\n\n function serializeResult(res: unknown): Response {\n let nonStreamingBody: any = undefined\n\n const alsResponse = getResponse()\n if (res !== undefined) {\n // Collect raw streams encountered during serialization\n const rawStreams = new Map<number, ReadableStream<Uint8Array>>()\n const rawStreamPlugin = createRawStreamRPCPlugin(\n (id: number, stream: ReadableStream<Uint8Array>) => {\n rawStreams.set(id, stream)\n },\n )\n\n // Build plugins with RawStreamRPCPlugin first (before default SSR plugin)\n const plugins = [rawStreamPlugin, ...(serovalPlugins || [])]\n\n // first run without the stream in case `result` does not need streaming\n let done = false as boolean\n const callbacks: {\n onParse: (value: any) => void\n onDone: () => void\n onError: (error: any) => void\n } = {\n onParse: (value) => {\n nonStreamingBody = value\n },\n onDone: () => {\n done = true\n },\n onError: (error) => {\n throw error\n },\n }\n toCrossJSONStream(res, {\n refs: new Map(),\n plugins,\n onParse(value) {\n callbacks.onParse(value)\n },\n onDone() {\n callbacks.onDone()\n },\n onError: (error) => {\n callbacks.onError(error)\n },\n })\n\n // If no raw streams and done synchronously, return simple JSON\n if (done && rawStreams.size === 0) {\n return new Response(\n nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined,\n {\n status: alsResponse.status,\n statusText: alsResponse.statusText,\n headers: {\n 'Content-Type': 'application/json',\n [X_TSS_SERIALIZED]: 'true',\n },\n },\n )\n }\n\n // If we have raw streams, use framed protocol\n if (rawStreams.size > 0) {\n // Create a stream of JSON chunks (NDJSON style)\n const jsonStream = new ReadableStream<string>({\n start(controller) {\n callbacks.onParse = (value) => {\n controller.enqueue(JSON.stringify(value) + '\\n')\n }\n callbacks.onDone = () => {\n try {\n controller.close()\n } catch {\n // Already closed\n }\n }\n callbacks.onError = (error) => controller.error(error)\n // Emit initial body if we have one\n if (nonStreamingBody !== undefined) {\n callbacks.onParse(nonStreamingBody)\n }\n },\n })\n\n // Create multiplexed stream with JSON and raw streams\n const multiplexedStream = createMultiplexedStream(\n jsonStream,\n rawStreams,\n )\n\n return new Response(multiplexedStream, {\n status: alsResponse.status,\n statusText: alsResponse.statusText,\n headers: {\n 'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED,\n [X_TSS_SERIALIZED]: 'true',\n },\n })\n }\n\n // No raw streams but not done yet - use standard NDJSON streaming\n const stream = new ReadableStream({\n start(controller) {\n callbacks.onParse = (value) =>\n controller.enqueue(\n textEncoder.encode(JSON.stringify(value) + '\\n'),\n )\n callbacks.onDone = () => {\n try {\n controller.close()\n } catch (error) {\n controller.error(error)\n }\n }\n callbacks.onError = (error) => controller.error(error)\n // stream initial body\n if (nonStreamingBody !== undefined) {\n callbacks.onParse(nonStreamingBody)\n }\n },\n })\n return new Response(stream, {\n status: alsResponse.status,\n statusText: alsResponse.statusText,\n headers: {\n 'Content-Type': 'application/x-ndjson',\n [X_TSS_SERIALIZED]: 'true',\n },\n })\n }\n\n return new Response(undefined, {\n status: alsResponse.status,\n statusText: alsResponse.statusText,\n })\n }\n } catch (error: any) {\n if (error instanceof Response) {\n return error\n }\n // else if (\n // isPlainObject(error) &&\n // 'result' in error &&\n // error.result instanceof Response\n // ) {\n // return error.result\n // }\n\n // Currently this server-side context has no idea how to\n // build final URLs, so we need to defer that to the client.\n // The client will check for __redirect and __notFound keys,\n // and if they exist, it will handle them appropriately.\n\n if (isNotFound(error)) {\n return isNotFoundResponse(error)\n }\n\n console.info()\n console.info('Server Fn Error!')\n console.info()\n console.error(error)\n console.info()\n\n const serializedError = JSON.stringify(\n await Promise.resolve(\n toCrossJSONAsync(error, {\n refs: new Map(),\n plugins: serovalPlugins,\n }),\n ),\n )\n const response = getResponse()\n return new Response(serializedError, {\n status: response.status ?? 500,\n statusText: response.statusText,\n headers: {\n 'Content-Type': 'application/json',\n [X_TSS_SERIALIZED]: 'true',\n },\n })\n }\n })()\n\n request.signal.removeEventListener('abort', abort)\n\n return response\n}\n\nfunction isNotFoundResponse(error: any) {\n const { headers, ...rest } = error\n\n return new Response(JSON.stringify(rest), {\n status: 404,\n headers: {\n 'Content-Type': 'application/json',\n ...(headers || {}),\n },\n })\n}\n"],"names":["res","stream","controller","payload","response"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/start-server-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.145.0",
|
|
4
4
|
"description": "Modern and scalable routing for React applications",
|
|
5
5
|
"author": "Tanner Linsley",
|
|
6
6
|
"license": "MIT",
|
|
@@ -64,9 +64,9 @@
|
|
|
64
64
|
"seroval": "^1.4.1",
|
|
65
65
|
"tiny-invariant": "^1.3.3",
|
|
66
66
|
"@tanstack/history": "1.141.0",
|
|
67
|
-
"@tanstack/
|
|
68
|
-
"@tanstack/start-
|
|
69
|
-
"@tanstack/
|
|
67
|
+
"@tanstack/router-core": "1.144.0",
|
|
68
|
+
"@tanstack/start-client-core": "1.145.0",
|
|
69
|
+
"@tanstack/start-storage-context": "1.144.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@standard-schema/spec": "^1.0.0",
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary frame protocol for multiplexing JSON and raw streams over HTTP.
|
|
3
|
+
*
|
|
4
|
+
* Frame format: [type:1][streamId:4][length:4][payload:length]
|
|
5
|
+
* - type: 1 byte - frame type (JSON, CHUNK, END, ERROR)
|
|
6
|
+
* - streamId: 4 bytes big-endian uint32 - stream identifier
|
|
7
|
+
* - length: 4 bytes big-endian uint32 - payload length
|
|
8
|
+
* - payload: variable length bytes
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Re-export constants from shared location
|
|
12
|
+
import { FRAME_HEADER_SIZE, FrameType } from '@tanstack/start-client-core'
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
FRAME_HEADER_SIZE,
|
|
16
|
+
FrameType,
|
|
17
|
+
TSS_CONTENT_TYPE_FRAMED,
|
|
18
|
+
TSS_CONTENT_TYPE_FRAMED_VERSIONED,
|
|
19
|
+
TSS_FRAMED_PROTOCOL_VERSION,
|
|
20
|
+
} from '@tanstack/start-client-core'
|
|
21
|
+
|
|
22
|
+
/** Cached TextEncoder for frame encoding */
|
|
23
|
+
const textEncoder = new TextEncoder()
|
|
24
|
+
|
|
25
|
+
/** Shared empty payload for END frames - avoids allocation per call */
|
|
26
|
+
const EMPTY_PAYLOAD = new Uint8Array(0)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Encodes a single frame with header and payload.
|
|
30
|
+
*/
|
|
31
|
+
export function encodeFrame(
|
|
32
|
+
type: FrameType,
|
|
33
|
+
streamId: number,
|
|
34
|
+
payload: Uint8Array,
|
|
35
|
+
): Uint8Array {
|
|
36
|
+
const frame = new Uint8Array(FRAME_HEADER_SIZE + payload.length)
|
|
37
|
+
// Write header bytes directly to avoid DataView allocation per frame
|
|
38
|
+
// Frame format: [type:1][streamId:4 BE][length:4 BE]
|
|
39
|
+
frame[0] = type
|
|
40
|
+
frame[1] = (streamId >>> 24) & 0xff
|
|
41
|
+
frame[2] = (streamId >>> 16) & 0xff
|
|
42
|
+
frame[3] = (streamId >>> 8) & 0xff
|
|
43
|
+
frame[4] = streamId & 0xff
|
|
44
|
+
frame[5] = (payload.length >>> 24) & 0xff
|
|
45
|
+
frame[6] = (payload.length >>> 16) & 0xff
|
|
46
|
+
frame[7] = (payload.length >>> 8) & 0xff
|
|
47
|
+
frame[8] = payload.length & 0xff
|
|
48
|
+
frame.set(payload, FRAME_HEADER_SIZE)
|
|
49
|
+
return frame
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Encodes a JSON frame (type 0, streamId 0).
|
|
54
|
+
*/
|
|
55
|
+
export function encodeJSONFrame(json: string): Uint8Array {
|
|
56
|
+
return encodeFrame(FrameType.JSON, 0, textEncoder.encode(json))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Encodes a raw stream chunk frame.
|
|
61
|
+
*/
|
|
62
|
+
export function encodeChunkFrame(
|
|
63
|
+
streamId: number,
|
|
64
|
+
chunk: Uint8Array,
|
|
65
|
+
): Uint8Array {
|
|
66
|
+
return encodeFrame(FrameType.CHUNK, streamId, chunk)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Encodes a raw stream end frame.
|
|
71
|
+
*/
|
|
72
|
+
export function encodeEndFrame(streamId: number): Uint8Array {
|
|
73
|
+
return encodeFrame(FrameType.END, streamId, EMPTY_PAYLOAD)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Encodes a raw stream error frame.
|
|
78
|
+
*/
|
|
79
|
+
export function encodeErrorFrame(streamId: number, error: unknown): Uint8Array {
|
|
80
|
+
const message =
|
|
81
|
+
error instanceof Error ? error.message : String(error ?? 'Unknown error')
|
|
82
|
+
return encodeFrame(FrameType.ERROR, streamId, textEncoder.encode(message))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Creates a multiplexed ReadableStream from JSON stream and raw streams.
|
|
87
|
+
*
|
|
88
|
+
* The JSON stream emits NDJSON lines (from seroval's toCrossJSONStream).
|
|
89
|
+
* Raw streams are pumped concurrently, interleaved with JSON frames.
|
|
90
|
+
*
|
|
91
|
+
* @param jsonStream Stream of JSON strings (each string is one NDJSON line)
|
|
92
|
+
* @param rawStreams Map of stream IDs to raw binary streams
|
|
93
|
+
*/
|
|
94
|
+
export function createMultiplexedStream(
|
|
95
|
+
jsonStream: ReadableStream<string>,
|
|
96
|
+
rawStreams: Map<number, ReadableStream<Uint8Array>>,
|
|
97
|
+
): ReadableStream<Uint8Array> {
|
|
98
|
+
// Track active pumps for completion
|
|
99
|
+
let activePumps = 1 + rawStreams.size // 1 for JSON + raw streams
|
|
100
|
+
let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
101
|
+
let cancelled = false as boolean
|
|
102
|
+
const cancelReaders: Array<() => void> = []
|
|
103
|
+
|
|
104
|
+
const safeEnqueue = (chunk: Uint8Array) => {
|
|
105
|
+
if (cancelled || !controllerRef) return
|
|
106
|
+
try {
|
|
107
|
+
controllerRef.enqueue(chunk)
|
|
108
|
+
} catch {
|
|
109
|
+
// Ignore enqueue after close/cancel
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const safeError = (err: unknown) => {
|
|
114
|
+
if (cancelled || !controllerRef) return
|
|
115
|
+
try {
|
|
116
|
+
controllerRef.error(err)
|
|
117
|
+
} catch {
|
|
118
|
+
// Ignore
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const safeClose = () => {
|
|
123
|
+
if (cancelled || !controllerRef) return
|
|
124
|
+
try {
|
|
125
|
+
controllerRef.close()
|
|
126
|
+
} catch {
|
|
127
|
+
// Ignore
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const checkComplete = () => {
|
|
132
|
+
activePumps--
|
|
133
|
+
if (activePumps === 0) {
|
|
134
|
+
safeClose()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return new ReadableStream<Uint8Array>({
|
|
139
|
+
start(controller) {
|
|
140
|
+
controllerRef = controller
|
|
141
|
+
cancelReaders.length = 0
|
|
142
|
+
|
|
143
|
+
// Pump JSON stream (streamId 0)
|
|
144
|
+
const pumpJSON = async () => {
|
|
145
|
+
const reader = jsonStream.getReader()
|
|
146
|
+
cancelReaders.push(() => {
|
|
147
|
+
// Catch async rejection - reader may already be released
|
|
148
|
+
reader.cancel().catch(() => {})
|
|
149
|
+
})
|
|
150
|
+
try {
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
152
|
+
while (true) {
|
|
153
|
+
const { done, value } = await reader.read()
|
|
154
|
+
// Check cancelled after await - flag may have changed while waiting
|
|
155
|
+
if (cancelled) break
|
|
156
|
+
if (done) break
|
|
157
|
+
safeEnqueue(encodeJSONFrame(value))
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// JSON stream error - fatal, error the whole response
|
|
161
|
+
safeError(error)
|
|
162
|
+
} finally {
|
|
163
|
+
reader.releaseLock()
|
|
164
|
+
checkComplete()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Pump a single raw stream with its streamId
|
|
169
|
+
const pumpRawStream = async (
|
|
170
|
+
streamId: number,
|
|
171
|
+
stream: ReadableStream<Uint8Array>,
|
|
172
|
+
) => {
|
|
173
|
+
const reader = stream.getReader()
|
|
174
|
+
cancelReaders.push(() => {
|
|
175
|
+
// Catch async rejection - reader may already be released
|
|
176
|
+
reader.cancel().catch(() => {})
|
|
177
|
+
})
|
|
178
|
+
try {
|
|
179
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
180
|
+
while (true) {
|
|
181
|
+
const { done, value } = await reader.read()
|
|
182
|
+
// Check cancelled after await - flag may have changed while waiting
|
|
183
|
+
if (cancelled) break
|
|
184
|
+
if (done) {
|
|
185
|
+
safeEnqueue(encodeEndFrame(streamId))
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
safeEnqueue(encodeChunkFrame(streamId, value))
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// Stream error - send ERROR frame (non-fatal, other streams continue)
|
|
192
|
+
safeEnqueue(encodeErrorFrame(streamId, error))
|
|
193
|
+
} finally {
|
|
194
|
+
reader.releaseLock()
|
|
195
|
+
checkComplete()
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Start all pumps concurrently
|
|
200
|
+
pumpJSON()
|
|
201
|
+
for (const [streamId, stream] of rawStreams) {
|
|
202
|
+
pumpRawStream(streamId, stream)
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
cancel() {
|
|
207
|
+
cancelled = true
|
|
208
|
+
controllerRef = null
|
|
209
|
+
// Proactively cancel all underlying readers to stop work quickly.
|
|
210
|
+
for (const cancelReader of cancelReaders) {
|
|
211
|
+
cancelReader()
|
|
212
|
+
}
|
|
213
|
+
cancelReaders.length = 0
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createRawStreamRPCPlugin,
|
|
3
|
+
isNotFound,
|
|
4
|
+
isRedirect,
|
|
5
|
+
} from '@tanstack/router-core'
|
|
2
6
|
import invariant from 'tiny-invariant'
|
|
3
7
|
import {
|
|
4
8
|
TSS_FORMDATA_CONTEXT,
|
|
@@ -10,11 +14,18 @@ import {
|
|
|
10
14
|
import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval'
|
|
11
15
|
import { getResponse } from './request-response'
|
|
12
16
|
import { getServerFnById } from './getServerFnById'
|
|
17
|
+
import {
|
|
18
|
+
TSS_CONTENT_TYPE_FRAMED_VERSIONED,
|
|
19
|
+
createMultiplexedStream,
|
|
20
|
+
} from './frame-protocol'
|
|
13
21
|
import type { Plugin as SerovalPlugin } from 'seroval'
|
|
14
22
|
|
|
15
23
|
// Cache serovalPlugins at module level to avoid repeated calls
|
|
16
24
|
let serovalPlugins: Array<SerovalPlugin<any, any>> | undefined = undefined
|
|
17
25
|
|
|
26
|
+
// Cache TextEncoder for NDJSON serialization
|
|
27
|
+
const textEncoder = new TextEncoder()
|
|
28
|
+
|
|
18
29
|
// Known FormData 'Content-Type' header values - module-level constant
|
|
19
30
|
const FORM_DATA_CONTENT_TYPES = [
|
|
20
31
|
'multipart/form-data',
|
|
@@ -162,6 +173,17 @@ export const handleServerAction = async ({
|
|
|
162
173
|
|
|
163
174
|
const alsResponse = getResponse()
|
|
164
175
|
if (res !== undefined) {
|
|
176
|
+
// Collect raw streams encountered during serialization
|
|
177
|
+
const rawStreams = new Map<number, ReadableStream<Uint8Array>>()
|
|
178
|
+
const rawStreamPlugin = createRawStreamRPCPlugin(
|
|
179
|
+
(id: number, stream: ReadableStream<Uint8Array>) => {
|
|
180
|
+
rawStreams.set(id, stream)
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
// Build plugins with RawStreamRPCPlugin first (before default SSR plugin)
|
|
185
|
+
const plugins = [rawStreamPlugin, ...(serovalPlugins || [])]
|
|
186
|
+
|
|
165
187
|
// first run without the stream in case `result` does not need streaming
|
|
166
188
|
let done = false as boolean
|
|
167
189
|
const callbacks: {
|
|
@@ -181,7 +203,7 @@ export const handleServerAction = async ({
|
|
|
181
203
|
}
|
|
182
204
|
toCrossJSONStream(res, {
|
|
183
205
|
refs: new Map(),
|
|
184
|
-
plugins
|
|
206
|
+
plugins,
|
|
185
207
|
onParse(value) {
|
|
186
208
|
callbacks.onParse(value)
|
|
187
209
|
},
|
|
@@ -192,7 +214,9 @@ export const handleServerAction = async ({
|
|
|
192
214
|
callbacks.onError(error)
|
|
193
215
|
},
|
|
194
216
|
})
|
|
195
|
-
|
|
217
|
+
|
|
218
|
+
// If no raw streams and done synchronously, return simple JSON
|
|
219
|
+
if (done && rawStreams.size === 0) {
|
|
196
220
|
return new Response(
|
|
197
221
|
nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined,
|
|
198
222
|
{
|
|
@@ -206,12 +230,52 @@ export const handleServerAction = async ({
|
|
|
206
230
|
)
|
|
207
231
|
}
|
|
208
232
|
|
|
209
|
-
//
|
|
210
|
-
|
|
233
|
+
// If we have raw streams, use framed protocol
|
|
234
|
+
if (rawStreams.size > 0) {
|
|
235
|
+
// Create a stream of JSON chunks (NDJSON style)
|
|
236
|
+
const jsonStream = new ReadableStream<string>({
|
|
237
|
+
start(controller) {
|
|
238
|
+
callbacks.onParse = (value) => {
|
|
239
|
+
controller.enqueue(JSON.stringify(value) + '\n')
|
|
240
|
+
}
|
|
241
|
+
callbacks.onDone = () => {
|
|
242
|
+
try {
|
|
243
|
+
controller.close()
|
|
244
|
+
} catch {
|
|
245
|
+
// Already closed
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
callbacks.onError = (error) => controller.error(error)
|
|
249
|
+
// Emit initial body if we have one
|
|
250
|
+
if (nonStreamingBody !== undefined) {
|
|
251
|
+
callbacks.onParse(nonStreamingBody)
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// Create multiplexed stream with JSON and raw streams
|
|
257
|
+
const multiplexedStream = createMultiplexedStream(
|
|
258
|
+
jsonStream,
|
|
259
|
+
rawStreams,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return new Response(multiplexedStream, {
|
|
263
|
+
status: alsResponse.status,
|
|
264
|
+
statusText: alsResponse.statusText,
|
|
265
|
+
headers: {
|
|
266
|
+
'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED,
|
|
267
|
+
[X_TSS_SERIALIZED]: 'true',
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// No raw streams but not done yet - use standard NDJSON streaming
|
|
211
273
|
const stream = new ReadableStream({
|
|
212
274
|
start(controller) {
|
|
213
275
|
callbacks.onParse = (value) =>
|
|
214
|
-
controller.enqueue(
|
|
276
|
+
controller.enqueue(
|
|
277
|
+
textEncoder.encode(JSON.stringify(value) + '\n'),
|
|
278
|
+
)
|
|
215
279
|
callbacks.onDone = () => {
|
|
216
280
|
try {
|
|
217
281
|
controller.close()
|
|
@@ -220,7 +284,7 @@ export const handleServerAction = async ({
|
|
|
220
284
|
}
|
|
221
285
|
}
|
|
222
286
|
callbacks.onError = (error) => controller.error(error)
|
|
223
|
-
// stream
|
|
287
|
+
// stream initial body
|
|
224
288
|
if (nonStreamingBody !== undefined) {
|
|
225
289
|
callbacks.onParse(nonStreamingBody)
|
|
226
290
|
}
|