@tanstack/start-server-core 1.143.9 → 1.144.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/createStartHandler.js +268 -257
- package/dist/esm/createStartHandler.js.map +1 -1
- 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.d.ts +2 -1
- package/dist/esm/server-functions-handler.js +147 -93
- package/dist/esm/server-functions-handler.js.map +1 -1
- package/package.json +4 -4
- package/src/createStartHandler.ts +386 -343
- package/src/frame-protocol.ts +216 -0
- package/src/server-functions-handler.ts +182 -103
|
@@ -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,59 +1,62 @@
|
|
|
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,
|
|
5
9
|
X_TSS_RAW_RESPONSE,
|
|
6
10
|
X_TSS_SERIALIZED,
|
|
7
11
|
getDefaultSerovalPlugins,
|
|
12
|
+
safeObjectMerge,
|
|
8
13
|
} from '@tanstack/start-client-core'
|
|
9
14
|
import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval'
|
|
10
15
|
import { getResponse } from './request-response'
|
|
11
16
|
import { getServerFnById } from './getServerFnById'
|
|
17
|
+
import {
|
|
18
|
+
TSS_CONTENT_TYPE_FRAMED_VERSIONED,
|
|
19
|
+
createMultiplexedStream,
|
|
20
|
+
} from './frame-protocol'
|
|
12
21
|
import type { Plugin as SerovalPlugin } from 'seroval'
|
|
13
22
|
|
|
14
|
-
let regex: RegExp | undefined = undefined
|
|
15
|
-
|
|
16
23
|
// Cache serovalPlugins at module level to avoid repeated calls
|
|
17
24
|
let serovalPlugins: Array<SerovalPlugin<any, any>> | undefined = undefined
|
|
18
25
|
|
|
26
|
+
// Cache TextEncoder for NDJSON serialization
|
|
27
|
+
const textEncoder = new TextEncoder()
|
|
28
|
+
|
|
19
29
|
// Known FormData 'Content-Type' header values - module-level constant
|
|
20
30
|
const FORM_DATA_CONTENT_TYPES = [
|
|
21
31
|
'multipart/form-data',
|
|
22
32
|
'application/x-www-form-urlencoded',
|
|
23
33
|
]
|
|
24
34
|
|
|
35
|
+
// Maximum payload size for GET requests (1MB)
|
|
36
|
+
const MAX_PAYLOAD_SIZE = 1_000_000
|
|
37
|
+
|
|
25
38
|
export const handleServerAction = async ({
|
|
26
39
|
request,
|
|
27
40
|
context,
|
|
41
|
+
serverFnId,
|
|
28
42
|
}: {
|
|
29
43
|
request: Request
|
|
30
44
|
context: any
|
|
45
|
+
serverFnId: string
|
|
31
46
|
}) => {
|
|
32
47
|
const controller = new AbortController()
|
|
33
48
|
const signal = controller.signal
|
|
34
49
|
const abort = () => controller.abort()
|
|
35
50
|
request.signal.addEventListener('abort', abort)
|
|
36
51
|
|
|
37
|
-
if (regex === undefined) {
|
|
38
|
-
regex = new RegExp(`${process.env.TSS_SERVER_FN_BASE}([^/?#]+)`)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
52
|
const method = request.method
|
|
42
53
|
const methodLower = method.toLowerCase()
|
|
43
|
-
const url = new URL(request.url
|
|
44
|
-
// extract the serverFnId from the url as host/_serverFn/:serverFnId
|
|
45
|
-
// Define a regex to match the path and extract the :thing part
|
|
46
|
-
|
|
47
|
-
// Execute the regex
|
|
48
|
-
const match = url.pathname.match(regex)
|
|
49
|
-
const serverFnId = match ? match[1] : null
|
|
50
|
-
|
|
51
|
-
if (typeof serverFnId !== 'string') {
|
|
52
|
-
throw new Error('Invalid server action param for serverFnId: ' + serverFnId)
|
|
53
|
-
}
|
|
54
|
+
const url = new URL(request.url)
|
|
54
55
|
|
|
55
56
|
const action = await getServerFnById(serverFnId, { fromClient: true })
|
|
56
57
|
|
|
58
|
+
const isServerFn = request.headers.get('x-tsr-serverFn') === 'true'
|
|
59
|
+
|
|
57
60
|
// Initialize serovalPlugins lazily (cached at module level)
|
|
58
61
|
if (!serovalPlugins) {
|
|
59
62
|
serovalPlugins = getDefaultSerovalPlugins()
|
|
@@ -68,7 +71,7 @@ export const handleServerAction = async ({
|
|
|
68
71
|
|
|
69
72
|
const response = await (async () => {
|
|
70
73
|
try {
|
|
71
|
-
|
|
74
|
+
let res = await (async () => {
|
|
72
75
|
// FormData
|
|
73
76
|
if (
|
|
74
77
|
FORM_DATA_CONTENT_TYPES.some(
|
|
@@ -98,9 +101,17 @@ export const handleServerAction = async ({
|
|
|
98
101
|
typeof deserializedContext === 'object' &&
|
|
99
102
|
deserializedContext
|
|
100
103
|
) {
|
|
101
|
-
params.context =
|
|
104
|
+
params.context = safeObjectMerge(
|
|
105
|
+
context,
|
|
106
|
+
deserializedContext as Record<string, unknown>,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
} catch (e) {
|
|
110
|
+
// Log warning for debugging but don't expose to client
|
|
111
|
+
if (process.env.NODE_ENV === 'development') {
|
|
112
|
+
console.warn('Failed to parse FormData context:', e)
|
|
102
113
|
}
|
|
103
|
-
}
|
|
114
|
+
}
|
|
104
115
|
}
|
|
105
116
|
|
|
106
117
|
return await action(params, signal)
|
|
@@ -110,11 +121,15 @@ export const handleServerAction = async ({
|
|
|
110
121
|
if (methodLower === 'get') {
|
|
111
122
|
// Get payload directly from searchParams
|
|
112
123
|
const payloadParam = url.searchParams.get('payload')
|
|
124
|
+
// Reject oversized payloads to prevent DoS
|
|
125
|
+
if (payloadParam && payloadParam.length > MAX_PAYLOAD_SIZE) {
|
|
126
|
+
throw new Error('Payload too large')
|
|
127
|
+
}
|
|
113
128
|
// If there's a payload, we should try to parse it
|
|
114
129
|
const payload: any = payloadParam
|
|
115
130
|
? parsePayload(JSON.parse(payloadParam))
|
|
116
131
|
: {}
|
|
117
|
-
payload.context =
|
|
132
|
+
payload.context = safeObjectMerge(context, payload.context)
|
|
118
133
|
// Send it through!
|
|
119
134
|
return await action(payload, signal)
|
|
120
135
|
}
|
|
@@ -129,103 +144,167 @@ export const handleServerAction = async ({
|
|
|
129
144
|
}
|
|
130
145
|
|
|
131
146
|
const payload = jsonPayload ? parsePayload(jsonPayload) : {}
|
|
132
|
-
payload.context =
|
|
147
|
+
payload.context = safeObjectMerge(payload.context, context)
|
|
133
148
|
return await action(payload, signal)
|
|
134
149
|
})()
|
|
135
150
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
return result.result
|
|
151
|
+
const unwrapped = res.result || res.error
|
|
152
|
+
|
|
153
|
+
if (isNotFound(res)) {
|
|
154
|
+
res = isNotFoundResponse(res)
|
|
141
155
|
}
|
|
142
156
|
|
|
143
|
-
if (
|
|
144
|
-
return
|
|
157
|
+
if (!isServerFn) {
|
|
158
|
+
return unwrapped
|
|
145
159
|
}
|
|
146
160
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (result !== undefined) {
|
|
151
|
-
// first run without the stream in case `result` does not need streaming
|
|
152
|
-
let done = false as boolean
|
|
153
|
-
const callbacks: {
|
|
154
|
-
onParse: (value: any) => void
|
|
155
|
-
onDone: () => void
|
|
156
|
-
onError: (error: any) => void
|
|
157
|
-
} = {
|
|
158
|
-
onParse: (value) => {
|
|
159
|
-
nonStreamingBody = value
|
|
160
|
-
},
|
|
161
|
-
onDone: () => {
|
|
162
|
-
done = true
|
|
163
|
-
},
|
|
164
|
-
onError: (error) => {
|
|
165
|
-
throw error
|
|
166
|
-
},
|
|
161
|
+
if (unwrapped instanceof Response) {
|
|
162
|
+
if (isRedirect(unwrapped)) {
|
|
163
|
+
return unwrapped
|
|
167
164
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
165
|
+
unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true')
|
|
166
|
+
return unwrapped
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return serializeResult(res)
|
|
170
|
+
|
|
171
|
+
function serializeResult(res: unknown): Response {
|
|
172
|
+
let nonStreamingBody: any = undefined
|
|
173
|
+
|
|
174
|
+
const alsResponse = getResponse()
|
|
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
|
+
|
|
187
|
+
// first run without the stream in case `result` does not need streaming
|
|
188
|
+
let done = false as boolean
|
|
189
|
+
const callbacks: {
|
|
190
|
+
onParse: (value: any) => void
|
|
191
|
+
onDone: () => void
|
|
192
|
+
onError: (error: any) => void
|
|
193
|
+
} = {
|
|
194
|
+
onParse: (value) => {
|
|
195
|
+
nonStreamingBody = value
|
|
196
|
+
},
|
|
197
|
+
onDone: () => {
|
|
198
|
+
done = true
|
|
199
|
+
},
|
|
200
|
+
onError: (error) => {
|
|
201
|
+
throw error
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
toCrossJSONStream(res, {
|
|
205
|
+
refs: new Map(),
|
|
206
|
+
plugins,
|
|
207
|
+
onParse(value) {
|
|
208
|
+
callbacks.onParse(value)
|
|
209
|
+
},
|
|
210
|
+
onDone() {
|
|
211
|
+
callbacks.onDone()
|
|
212
|
+
},
|
|
213
|
+
onError: (error) => {
|
|
214
|
+
callbacks.onError(error)
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// If no raw streams and done synchronously, return simple JSON
|
|
219
|
+
if (done && rawStreams.size === 0) {
|
|
220
|
+
return new Response(
|
|
221
|
+
nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined,
|
|
222
|
+
{
|
|
223
|
+
status: alsResponse.status,
|
|
224
|
+
statusText: alsResponse.statusText,
|
|
225
|
+
headers: {
|
|
226
|
+
'Content-Type': 'application/json',
|
|
227
|
+
[X_TSS_SERIALIZED]: 'true',
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
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,
|
|
187
265
|
headers: {
|
|
188
|
-
'Content-Type':
|
|
266
|
+
'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED,
|
|
189
267
|
[X_TSS_SERIALIZED]: 'true',
|
|
190
268
|
},
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// No raw streams but not done yet - use standard NDJSON streaming
|
|
273
|
+
const stream = new ReadableStream({
|
|
274
|
+
start(controller) {
|
|
275
|
+
callbacks.onParse = (value) =>
|
|
276
|
+
controller.enqueue(
|
|
277
|
+
textEncoder.encode(JSON.stringify(value) + '\n'),
|
|
278
|
+
)
|
|
279
|
+
callbacks.onDone = () => {
|
|
280
|
+
try {
|
|
281
|
+
controller.close()
|
|
282
|
+
} catch (error) {
|
|
283
|
+
controller.error(error)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
callbacks.onError = (error) => controller.error(error)
|
|
287
|
+
// stream initial body
|
|
288
|
+
if (nonStreamingBody !== undefined) {
|
|
289
|
+
callbacks.onParse(nonStreamingBody)
|
|
290
|
+
}
|
|
191
291
|
},
|
|
192
|
-
)
|
|
292
|
+
})
|
|
293
|
+
return new Response(stream, {
|
|
294
|
+
status: alsResponse.status,
|
|
295
|
+
statusText: alsResponse.statusText,
|
|
296
|
+
headers: {
|
|
297
|
+
'Content-Type': 'application/x-ndjson',
|
|
298
|
+
[X_TSS_SERIALIZED]: 'true',
|
|
299
|
+
},
|
|
300
|
+
})
|
|
193
301
|
}
|
|
194
302
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
start(controller) {
|
|
199
|
-
callbacks.onParse = (value) =>
|
|
200
|
-
controller.enqueue(encoder.encode(JSON.stringify(value) + '\n'))
|
|
201
|
-
callbacks.onDone = () => {
|
|
202
|
-
try {
|
|
203
|
-
controller.close()
|
|
204
|
-
} catch (error) {
|
|
205
|
-
controller.error(error)
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
callbacks.onError = (error) => controller.error(error)
|
|
209
|
-
// stream the initial body
|
|
210
|
-
if (nonStreamingBody !== undefined) {
|
|
211
|
-
callbacks.onParse(nonStreamingBody)
|
|
212
|
-
}
|
|
213
|
-
},
|
|
214
|
-
})
|
|
215
|
-
return new Response(stream, {
|
|
216
|
-
status: response.status,
|
|
217
|
-
statusText: response.statusText,
|
|
218
|
-
headers: {
|
|
219
|
-
'Content-Type': 'application/x-ndjson',
|
|
220
|
-
[X_TSS_SERIALIZED]: 'true',
|
|
221
|
-
},
|
|
303
|
+
return new Response(undefined, {
|
|
304
|
+
status: alsResponse.status,
|
|
305
|
+
statusText: alsResponse.statusText,
|
|
222
306
|
})
|
|
223
307
|
}
|
|
224
|
-
|
|
225
|
-
return new Response(undefined, {
|
|
226
|
-
status: response.status,
|
|
227
|
-
statusText: response.statusText,
|
|
228
|
-
})
|
|
229
308
|
} catch (error: any) {
|
|
230
309
|
if (error instanceof Response) {
|
|
231
310
|
return error
|