@tanstack/start-client-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/client-rpc/frame-decoder.d.ts +23 -0
- package/dist/esm/client-rpc/frame-decoder.js +243 -0
- package/dist/esm/client-rpc/frame-decoder.js.map +1 -0
- package/dist/esm/client-rpc/serverFnFetcher.js +61 -15
- package/dist/esm/client-rpc/serverFnFetcher.js.map +1 -1
- package/dist/esm/constants.d.ts +29 -0
- package/dist/esm/constants.js +40 -1
- package/dist/esm/constants.js.map +1 -1
- package/dist/esm/createServerFn.d.ts +1 -2
- package/dist/esm/createServerFn.js +104 -70
- package/dist/esm/createServerFn.js.map +1 -1
- package/dist/esm/getDefaultSerovalPlugins.d.ts +1 -1
- package/dist/esm/index.d.ts +6 -2
- package/dist/esm/index.js +15 -4
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/safeObjectMerge.d.ts +10 -0
- package/dist/esm/safeObjectMerge.js +30 -0
- package/dist/esm/safeObjectMerge.js.map +1 -0
- package/package.json +4 -4
- package/src/client-rpc/frame-decoder.ts +389 -0
- package/src/client-rpc/serverFnFetcher.ts +116 -16
- package/src/constants.ts +60 -0
- package/src/createServerFn.ts +145 -99
- package/src/index.tsx +12 -1
- package/src/safeObjectMerge.ts +38 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side frame decoder for multiplexed responses.
|
|
3
|
+
*
|
|
4
|
+
* Decodes binary frame protocol and reconstructs:
|
|
5
|
+
* - JSON stream (NDJSON lines for seroval)
|
|
6
|
+
* - Raw streams (binary data as ReadableStream<Uint8Array>)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { FRAME_HEADER_SIZE, FrameType } from '../constants'
|
|
10
|
+
|
|
11
|
+
/** Cached TextDecoder for frame decoding */
|
|
12
|
+
const textDecoder = new TextDecoder()
|
|
13
|
+
|
|
14
|
+
/** Shared empty buffer for empty buffer case - avoids allocation */
|
|
15
|
+
const EMPTY_BUFFER = new Uint8Array(0)
|
|
16
|
+
|
|
17
|
+
/** Hardening limits to prevent memory/CPU DoS */
|
|
18
|
+
const MAX_FRAME_PAYLOAD_SIZE = 16 * 1024 * 1024 // 16MiB
|
|
19
|
+
const MAX_BUFFERED_BYTES = 32 * 1024 * 1024 // 32MiB
|
|
20
|
+
const MAX_STREAMS = 1024
|
|
21
|
+
const MAX_FRAMES = 100_000 // Limit total frames to prevent CPU DoS
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Result of frame decoding.
|
|
25
|
+
*/
|
|
26
|
+
export interface FrameDecoderResult {
|
|
27
|
+
/** Gets or creates a raw stream by ID (for use by deserialize plugin) */
|
|
28
|
+
getOrCreateStream: (id: number) => ReadableStream<Uint8Array>
|
|
29
|
+
/** Stream of JSON strings (NDJSON lines) */
|
|
30
|
+
jsonChunks: ReadableStream<string>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a frame decoder that processes a multiplexed response stream.
|
|
35
|
+
*
|
|
36
|
+
* @param input The raw response body stream
|
|
37
|
+
* @returns Decoded JSON stream and stream getter function
|
|
38
|
+
*/
|
|
39
|
+
export function createFrameDecoder(
|
|
40
|
+
input: ReadableStream<Uint8Array>,
|
|
41
|
+
): FrameDecoderResult {
|
|
42
|
+
const streamControllers = new Map<
|
|
43
|
+
number,
|
|
44
|
+
ReadableStreamDefaultController<Uint8Array>
|
|
45
|
+
>()
|
|
46
|
+
const streams = new Map<number, ReadableStream<Uint8Array>>()
|
|
47
|
+
const cancelledStreamIds = new Set<number>()
|
|
48
|
+
|
|
49
|
+
let cancelled = false as boolean
|
|
50
|
+
let inputReader: ReadableStreamReader<Uint8Array> | null = null
|
|
51
|
+
let frameCount = 0
|
|
52
|
+
|
|
53
|
+
let jsonController!: ReadableStreamDefaultController<string>
|
|
54
|
+
const jsonChunks = new ReadableStream<string>({
|
|
55
|
+
start(controller) {
|
|
56
|
+
jsonController = controller
|
|
57
|
+
},
|
|
58
|
+
cancel() {
|
|
59
|
+
cancelled = true
|
|
60
|
+
try {
|
|
61
|
+
inputReader?.cancel()
|
|
62
|
+
} catch {
|
|
63
|
+
// Ignore
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
streamControllers.forEach((ctrl) => {
|
|
67
|
+
try {
|
|
68
|
+
ctrl.error(new Error('Framed response cancelled'))
|
|
69
|
+
} catch {
|
|
70
|
+
// Ignore
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
streamControllers.clear()
|
|
74
|
+
streams.clear()
|
|
75
|
+
cancelledStreamIds.clear()
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Gets or creates a stream for a given stream ID.
|
|
81
|
+
* Called by deserialize plugin when it encounters a RawStream reference.
|
|
82
|
+
*/
|
|
83
|
+
function getOrCreateStream(id: number): ReadableStream<Uint8Array> {
|
|
84
|
+
const existing = streams.get(id)
|
|
85
|
+
if (existing) {
|
|
86
|
+
return existing
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// If we already received an END/ERROR for this streamId, returning a fresh stream
|
|
90
|
+
// would hang consumers. Return an already-closed stream instead.
|
|
91
|
+
if (cancelledStreamIds.has(id)) {
|
|
92
|
+
return new ReadableStream<Uint8Array>({
|
|
93
|
+
start(controller) {
|
|
94
|
+
controller.close()
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (streams.size >= MAX_STREAMS) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Too many raw streams in framed response (max ${MAX_STREAMS})`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
106
|
+
start(ctrl) {
|
|
107
|
+
streamControllers.set(id, ctrl)
|
|
108
|
+
},
|
|
109
|
+
cancel() {
|
|
110
|
+
cancelledStreamIds.add(id)
|
|
111
|
+
streamControllers.delete(id)
|
|
112
|
+
streams.delete(id)
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
streams.set(id, stream)
|
|
116
|
+
return stream
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Ensures stream exists and returns its controller for enqueuing data.
|
|
121
|
+
* Used for CHUNK frames where we need to ensure stream is created.
|
|
122
|
+
*/
|
|
123
|
+
function ensureController(
|
|
124
|
+
id: number,
|
|
125
|
+
): ReadableStreamDefaultController<Uint8Array> | undefined {
|
|
126
|
+
getOrCreateStream(id)
|
|
127
|
+
return streamControllers.get(id)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Process frames asynchronously
|
|
131
|
+
;(async () => {
|
|
132
|
+
const reader = input.getReader()
|
|
133
|
+
inputReader = reader
|
|
134
|
+
|
|
135
|
+
const bufferList: Array<Uint8Array> = []
|
|
136
|
+
let totalLength = 0
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Reads header bytes from buffer chunks without flattening.
|
|
140
|
+
* Returns header data or null if not enough bytes available.
|
|
141
|
+
*/
|
|
142
|
+
function readHeader(): {
|
|
143
|
+
type: number
|
|
144
|
+
streamId: number
|
|
145
|
+
length: number
|
|
146
|
+
} | null {
|
|
147
|
+
if (totalLength < FRAME_HEADER_SIZE) return null
|
|
148
|
+
|
|
149
|
+
const first = bufferList[0]!
|
|
150
|
+
|
|
151
|
+
// Fast path: header fits entirely in first chunk (common case)
|
|
152
|
+
if (first.length >= FRAME_HEADER_SIZE) {
|
|
153
|
+
const type = first[0]!
|
|
154
|
+
const streamId =
|
|
155
|
+
((first[1]! << 24) |
|
|
156
|
+
(first[2]! << 16) |
|
|
157
|
+
(first[3]! << 8) |
|
|
158
|
+
first[4]!) >>>
|
|
159
|
+
0
|
|
160
|
+
const length =
|
|
161
|
+
((first[5]! << 24) |
|
|
162
|
+
(first[6]! << 16) |
|
|
163
|
+
(first[7]! << 8) |
|
|
164
|
+
first[8]!) >>>
|
|
165
|
+
0
|
|
166
|
+
return { type, streamId, length }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Slow path: header spans multiple chunks - flatten header bytes only
|
|
170
|
+
const headerBytes = new Uint8Array(FRAME_HEADER_SIZE)
|
|
171
|
+
let offset = 0
|
|
172
|
+
let remaining = FRAME_HEADER_SIZE
|
|
173
|
+
for (let i = 0; i < bufferList.length && remaining > 0; i++) {
|
|
174
|
+
const chunk = bufferList[i]!
|
|
175
|
+
const toCopy = Math.min(chunk.length, remaining)
|
|
176
|
+
headerBytes.set(chunk.subarray(0, toCopy), offset)
|
|
177
|
+
offset += toCopy
|
|
178
|
+
remaining -= toCopy
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const type = headerBytes[0]!
|
|
182
|
+
const streamId =
|
|
183
|
+
((headerBytes[1]! << 24) |
|
|
184
|
+
(headerBytes[2]! << 16) |
|
|
185
|
+
(headerBytes[3]! << 8) |
|
|
186
|
+
headerBytes[4]!) >>>
|
|
187
|
+
0
|
|
188
|
+
const length =
|
|
189
|
+
((headerBytes[5]! << 24) |
|
|
190
|
+
(headerBytes[6]! << 16) |
|
|
191
|
+
(headerBytes[7]! << 8) |
|
|
192
|
+
headerBytes[8]!) >>>
|
|
193
|
+
0
|
|
194
|
+
|
|
195
|
+
return { type, streamId, length }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Flattens buffer list into single Uint8Array and removes from list.
|
|
200
|
+
*/
|
|
201
|
+
function extractFlattened(count: number): Uint8Array {
|
|
202
|
+
if (count === 0) return EMPTY_BUFFER
|
|
203
|
+
|
|
204
|
+
const result = new Uint8Array(count)
|
|
205
|
+
let offset = 0
|
|
206
|
+
let remaining = count
|
|
207
|
+
|
|
208
|
+
while (remaining > 0 && bufferList.length > 0) {
|
|
209
|
+
const chunk = bufferList[0]
|
|
210
|
+
if (!chunk) break
|
|
211
|
+
const toCopy = Math.min(chunk.length, remaining)
|
|
212
|
+
result.set(chunk.subarray(0, toCopy), offset)
|
|
213
|
+
|
|
214
|
+
offset += toCopy
|
|
215
|
+
remaining -= toCopy
|
|
216
|
+
|
|
217
|
+
if (toCopy === chunk.length) {
|
|
218
|
+
bufferList.shift()
|
|
219
|
+
} else {
|
|
220
|
+
bufferList[0] = chunk.subarray(toCopy)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
totalLength -= count
|
|
225
|
+
return result
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
230
|
+
while (true) {
|
|
231
|
+
const { done, value } = await reader.read()
|
|
232
|
+
if (cancelled) break
|
|
233
|
+
if (done) break
|
|
234
|
+
|
|
235
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
236
|
+
if (!value) continue
|
|
237
|
+
|
|
238
|
+
// Append incoming chunk to buffer list
|
|
239
|
+
if (totalLength + value.length > MAX_BUFFERED_BYTES) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`Framed response buffer exceeded ${MAX_BUFFERED_BYTES} bytes`,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
bufferList.push(value)
|
|
245
|
+
totalLength += value.length
|
|
246
|
+
|
|
247
|
+
// Parse complete frames from buffer
|
|
248
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
249
|
+
while (true) {
|
|
250
|
+
const header = readHeader()
|
|
251
|
+
if (!header) break // Not enough bytes for header
|
|
252
|
+
|
|
253
|
+
const { type, streamId, length } = header
|
|
254
|
+
|
|
255
|
+
if (
|
|
256
|
+
type !== FrameType.JSON &&
|
|
257
|
+
type !== FrameType.CHUNK &&
|
|
258
|
+
type !== FrameType.END &&
|
|
259
|
+
type !== FrameType.ERROR
|
|
260
|
+
) {
|
|
261
|
+
throw new Error(`Unknown frame type: ${type}`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Enforce stream id conventions: JSON uses streamId 0, raw streams use non-zero ids
|
|
265
|
+
if (type === FrameType.JSON) {
|
|
266
|
+
if (streamId !== 0) {
|
|
267
|
+
throw new Error('Invalid JSON frame streamId (expected 0)')
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
if (streamId === 0) {
|
|
271
|
+
throw new Error('Invalid raw frame streamId (expected non-zero)')
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (length > MAX_FRAME_PAYLOAD_SIZE) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`Frame payload too large: ${length} bytes (max ${MAX_FRAME_PAYLOAD_SIZE})`,
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const frameSize = FRAME_HEADER_SIZE + length
|
|
282
|
+
if (totalLength < frameSize) break // Wait for more data
|
|
283
|
+
|
|
284
|
+
if (++frameCount > MAX_FRAMES) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Too many frames in framed response (max ${MAX_FRAMES})`,
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Extract and consume header bytes
|
|
291
|
+
extractFlattened(FRAME_HEADER_SIZE)
|
|
292
|
+
|
|
293
|
+
// Extract payload
|
|
294
|
+
const payload = extractFlattened(length)
|
|
295
|
+
|
|
296
|
+
// Process frame by type
|
|
297
|
+
switch (type) {
|
|
298
|
+
case FrameType.JSON: {
|
|
299
|
+
try {
|
|
300
|
+
jsonController.enqueue(textDecoder.decode(payload))
|
|
301
|
+
} catch {
|
|
302
|
+
// JSON stream may be cancelled/closed
|
|
303
|
+
}
|
|
304
|
+
break
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
case FrameType.CHUNK: {
|
|
308
|
+
const ctrl = ensureController(streamId)
|
|
309
|
+
if (ctrl) {
|
|
310
|
+
ctrl.enqueue(payload)
|
|
311
|
+
}
|
|
312
|
+
break
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
case FrameType.END: {
|
|
316
|
+
const ctrl = ensureController(streamId)
|
|
317
|
+
cancelledStreamIds.add(streamId)
|
|
318
|
+
if (ctrl) {
|
|
319
|
+
try {
|
|
320
|
+
ctrl.close()
|
|
321
|
+
} catch {
|
|
322
|
+
// Already closed
|
|
323
|
+
}
|
|
324
|
+
streamControllers.delete(streamId)
|
|
325
|
+
}
|
|
326
|
+
break
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
case FrameType.ERROR: {
|
|
330
|
+
const ctrl = ensureController(streamId)
|
|
331
|
+
cancelledStreamIds.add(streamId)
|
|
332
|
+
if (ctrl) {
|
|
333
|
+
const message = textDecoder.decode(payload)
|
|
334
|
+
ctrl.error(new Error(message))
|
|
335
|
+
streamControllers.delete(streamId)
|
|
336
|
+
}
|
|
337
|
+
break
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (totalLength !== 0) {
|
|
344
|
+
throw new Error('Incomplete frame at end of framed response')
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Close JSON stream when done
|
|
348
|
+
try {
|
|
349
|
+
jsonController.close()
|
|
350
|
+
} catch {
|
|
351
|
+
// JSON stream may be cancelled/closed
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Close any remaining streams (shouldn't happen in normal operation)
|
|
355
|
+
streamControllers.forEach((ctrl) => {
|
|
356
|
+
try {
|
|
357
|
+
ctrl.close()
|
|
358
|
+
} catch {
|
|
359
|
+
// Already closed
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
streamControllers.clear()
|
|
363
|
+
} catch (error) {
|
|
364
|
+
// Error reading - propagate to all streams
|
|
365
|
+
try {
|
|
366
|
+
jsonController.error(error)
|
|
367
|
+
} catch {
|
|
368
|
+
// Already errored/closed
|
|
369
|
+
}
|
|
370
|
+
streamControllers.forEach((ctrl) => {
|
|
371
|
+
try {
|
|
372
|
+
ctrl.error(error)
|
|
373
|
+
} catch {
|
|
374
|
+
// Already errored/closed
|
|
375
|
+
}
|
|
376
|
+
})
|
|
377
|
+
streamControllers.clear()
|
|
378
|
+
} finally {
|
|
379
|
+
try {
|
|
380
|
+
reader.releaseLock()
|
|
381
|
+
} catch {
|
|
382
|
+
// Ignore
|
|
383
|
+
}
|
|
384
|
+
inputReader = null
|
|
385
|
+
}
|
|
386
|
+
})()
|
|
387
|
+
|
|
388
|
+
return { getOrCreateStream, jsonChunks }
|
|
389
|
+
}
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createRawStreamDeserializePlugin,
|
|
3
|
+
encode,
|
|
4
|
+
isNotFound,
|
|
5
|
+
parseRedirect,
|
|
6
|
+
} from '@tanstack/router-core'
|
|
2
7
|
import { fromCrossJSON, toJSONAsync } from 'seroval'
|
|
3
8
|
import invariant from 'tiny-invariant'
|
|
4
9
|
import { getDefaultSerovalPlugins } from '../getDefaultSerovalPlugins'
|
|
5
10
|
import {
|
|
11
|
+
TSS_CONTENT_TYPE_FRAMED,
|
|
6
12
|
TSS_FORMDATA_CONTEXT,
|
|
7
13
|
X_TSS_RAW_RESPONSE,
|
|
8
14
|
X_TSS_SERIALIZED,
|
|
15
|
+
validateFramedProtocolVersion,
|
|
9
16
|
} from '../constants'
|
|
17
|
+
import { createFrameDecoder } from './frame-decoder'
|
|
10
18
|
import type { FunctionMiddlewareClientFnOptions } from '../createMiddleware'
|
|
11
19
|
import type { Plugin as SerovalPlugin } from 'seroval'
|
|
12
20
|
|
|
@@ -25,6 +33,15 @@ function hasOwnProperties(obj: object): boolean {
|
|
|
25
33
|
}
|
|
26
34
|
return false
|
|
27
35
|
}
|
|
36
|
+
// caller =>
|
|
37
|
+
// serverFnFetcher =>
|
|
38
|
+
// client =>
|
|
39
|
+
// server =>
|
|
40
|
+
// fn =>
|
|
41
|
+
// seroval =>
|
|
42
|
+
// client middleware =>
|
|
43
|
+
// serverFnFetcher =>
|
|
44
|
+
// caller
|
|
28
45
|
|
|
29
46
|
export async function serverFnFetcher(
|
|
30
47
|
url: string,
|
|
@@ -43,10 +60,13 @@ export async function serverFnFetcher(
|
|
|
43
60
|
|
|
44
61
|
// Arrange the headers
|
|
45
62
|
const headers = first.headers ? new Headers(first.headers) : new Headers()
|
|
46
|
-
headers.set('x-tsr-
|
|
63
|
+
headers.set('x-tsr-serverFn', 'true')
|
|
47
64
|
|
|
48
65
|
if (type === 'payload') {
|
|
49
|
-
headers.set(
|
|
66
|
+
headers.set(
|
|
67
|
+
'accept',
|
|
68
|
+
`${TSS_CONTENT_TYPE_FRAMED}, application/x-ndjson, application/json`,
|
|
69
|
+
)
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
// If the method is GET, we need to move the payload to the query string
|
|
@@ -146,7 +166,7 @@ async function getFetchBody(
|
|
|
146
166
|
async function getResponse(fn: () => Promise<Response>) {
|
|
147
167
|
let response: Response
|
|
148
168
|
try {
|
|
149
|
-
response = await fn()
|
|
169
|
+
response = await fn() // client => server => fn => server => client
|
|
150
170
|
} catch (error) {
|
|
151
171
|
if (error instanceof Response) {
|
|
152
172
|
response = error
|
|
@@ -159,23 +179,45 @@ async function getResponse(fn: () => Promise<Response>) {
|
|
|
159
179
|
if (response.headers.get(X_TSS_RAW_RESPONSE) === 'true') {
|
|
160
180
|
return response
|
|
161
181
|
}
|
|
182
|
+
|
|
162
183
|
const contentType = response.headers.get('content-type')
|
|
163
184
|
invariant(contentType, 'expected content-type header to be set')
|
|
164
185
|
const serializedByStart = !!response.headers.get(X_TSS_SERIALIZED)
|
|
165
|
-
// If the response is not ok, throw an error
|
|
166
|
-
if (!response.ok) {
|
|
167
|
-
if (serializedByStart && contentType.includes('application/json')) {
|
|
168
|
-
const jsonPayload = await response.json()
|
|
169
|
-
const result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })
|
|
170
|
-
throw result
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
throw new Error(await response.text())
|
|
174
|
-
}
|
|
175
186
|
|
|
187
|
+
// If the response is serialized by the start server, we need to process it
|
|
188
|
+
// differently than a normal response.
|
|
176
189
|
if (serializedByStart) {
|
|
177
190
|
let result
|
|
178
|
-
|
|
191
|
+
|
|
192
|
+
// If it's a framed response (contains RawStream), use frame decoder
|
|
193
|
+
if (contentType.includes(TSS_CONTENT_TYPE_FRAMED)) {
|
|
194
|
+
// Validate protocol version compatibility
|
|
195
|
+
validateFramedProtocolVersion(contentType)
|
|
196
|
+
|
|
197
|
+
if (!response.body) {
|
|
198
|
+
throw new Error('No response body for framed response')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const { getOrCreateStream, jsonChunks } = createFrameDecoder(
|
|
202
|
+
response.body,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
// Create deserialize plugin that wires up the raw streams
|
|
206
|
+
const rawStreamPlugin =
|
|
207
|
+
createRawStreamDeserializePlugin(getOrCreateStream)
|
|
208
|
+
const plugins = [rawStreamPlugin, ...(serovalPlugins || [])]
|
|
209
|
+
|
|
210
|
+
const refs = new Map()
|
|
211
|
+
result = await processFramedResponse({
|
|
212
|
+
jsonStream: jsonChunks,
|
|
213
|
+
onMessage: (msg: any) => fromCrossJSON(msg, { refs, plugins }),
|
|
214
|
+
onError(msg, error) {
|
|
215
|
+
console.error(msg, error)
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
// If it's a stream from the start serializer, process it as such
|
|
220
|
+
else if (contentType.includes('application/x-ndjson')) {
|
|
179
221
|
const refs = new Map()
|
|
180
222
|
result = await processServerFnResponse({
|
|
181
223
|
response,
|
|
@@ -187,17 +229,22 @@ async function getResponse(fn: () => Promise<Response>) {
|
|
|
187
229
|
},
|
|
188
230
|
})
|
|
189
231
|
}
|
|
190
|
-
|
|
232
|
+
// If it's a JSON response, it can be simpler
|
|
233
|
+
else if (contentType.includes('application/json')) {
|
|
191
234
|
const jsonPayload = await response.json()
|
|
192
235
|
result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })
|
|
193
236
|
}
|
|
237
|
+
|
|
194
238
|
invariant(result, 'expected result to be resolved')
|
|
195
239
|
if (result instanceof Error) {
|
|
196
240
|
throw result
|
|
197
241
|
}
|
|
242
|
+
|
|
198
243
|
return result
|
|
199
244
|
}
|
|
200
245
|
|
|
246
|
+
// If it wasn't processed by the start serializer, check
|
|
247
|
+
// if it's JSON
|
|
201
248
|
if (contentType.includes('application/json')) {
|
|
202
249
|
const jsonPayload = await response.json()
|
|
203
250
|
const redirect = parseRedirect(jsonPayload)
|
|
@@ -210,6 +257,12 @@ async function getResponse(fn: () => Promise<Response>) {
|
|
|
210
257
|
return jsonPayload
|
|
211
258
|
}
|
|
212
259
|
|
|
260
|
+
// Otherwise, if it's not OK, throw the content
|
|
261
|
+
if (!response.ok) {
|
|
262
|
+
throw new Error(await response.text())
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Or return the response itself
|
|
213
266
|
return response
|
|
214
267
|
}
|
|
215
268
|
|
|
@@ -296,3 +349,50 @@ async function processServerFnResponse({
|
|
|
296
349
|
|
|
297
350
|
return onMessage(firstObject)
|
|
298
351
|
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Processes a framed response where each JSON chunk is a complete JSON string
|
|
355
|
+
* (already decoded by frame decoder).
|
|
356
|
+
*/
|
|
357
|
+
async function processFramedResponse({
|
|
358
|
+
jsonStream,
|
|
359
|
+
onMessage,
|
|
360
|
+
onError,
|
|
361
|
+
}: {
|
|
362
|
+
jsonStream: ReadableStream<string>
|
|
363
|
+
onMessage: (msg: any) => any
|
|
364
|
+
onError?: (msg: string, error?: any) => void
|
|
365
|
+
}) {
|
|
366
|
+
const reader = jsonStream.getReader()
|
|
367
|
+
|
|
368
|
+
// Read first JSON frame - this is the main result
|
|
369
|
+
const { value: firstValue, done: firstDone } = await reader.read()
|
|
370
|
+
if (firstDone || !firstValue) {
|
|
371
|
+
throw new Error('Stream ended before first object')
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Each frame is a complete JSON string
|
|
375
|
+
const firstObject = JSON.parse(firstValue)
|
|
376
|
+
|
|
377
|
+
// Process remaining frames asynchronously (for streaming refs like RawStream)
|
|
378
|
+
;(async () => {
|
|
379
|
+
try {
|
|
380
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
381
|
+
while (true) {
|
|
382
|
+
const { value, done } = await reader.read()
|
|
383
|
+
if (done) break
|
|
384
|
+
if (value) {
|
|
385
|
+
try {
|
|
386
|
+
onMessage(JSON.parse(value))
|
|
387
|
+
} catch (e) {
|
|
388
|
+
onError?.(`Invalid JSON: ${value}`, e)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
} catch (err) {
|
|
393
|
+
onError?.('Stream processing error:', err)
|
|
394
|
+
}
|
|
395
|
+
})()
|
|
396
|
+
|
|
397
|
+
return onMessage(firstObject)
|
|
398
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -6,4 +6,64 @@ export const TSS_SERVER_FUNCTION_FACTORY = Symbol.for(
|
|
|
6
6
|
|
|
7
7
|
export const X_TSS_SERIALIZED = 'x-tss-serialized'
|
|
8
8
|
export const X_TSS_RAW_RESPONSE = 'x-tss-raw'
|
|
9
|
+
export const X_TSS_CONTEXT = 'x-tss-context'
|
|
10
|
+
|
|
11
|
+
/** Content-Type for multiplexed framed responses (RawStream support) */
|
|
12
|
+
export const TSS_CONTENT_TYPE_FRAMED = 'application/x-tss-framed'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Frame types for binary multiplexing protocol.
|
|
16
|
+
*/
|
|
17
|
+
export const FrameType = {
|
|
18
|
+
/** Seroval JSON chunk (NDJSON line) */
|
|
19
|
+
JSON: 0,
|
|
20
|
+
/** Raw stream data chunk */
|
|
21
|
+
CHUNK: 1,
|
|
22
|
+
/** Raw stream end (EOF) */
|
|
23
|
+
END: 2,
|
|
24
|
+
/** Raw stream error */
|
|
25
|
+
ERROR: 3,
|
|
26
|
+
} as const
|
|
27
|
+
|
|
28
|
+
export type FrameType = (typeof FrameType)[keyof typeof FrameType]
|
|
29
|
+
|
|
30
|
+
/** Header size in bytes: type(1) + streamId(4) + length(4) */
|
|
31
|
+
export const FRAME_HEADER_SIZE = 9
|
|
32
|
+
|
|
33
|
+
/** Current protocol version for framed responses */
|
|
34
|
+
export const TSS_FRAMED_PROTOCOL_VERSION = 1
|
|
35
|
+
|
|
36
|
+
/** Full Content-Type header value with version parameter */
|
|
37
|
+
export const TSS_CONTENT_TYPE_FRAMED_VERSIONED = `${TSS_CONTENT_TYPE_FRAMED}; v=${TSS_FRAMED_PROTOCOL_VERSION}`
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parses the version parameter from a framed Content-Type header.
|
|
41
|
+
* Returns undefined if no version parameter is present.
|
|
42
|
+
*/
|
|
43
|
+
const FRAMED_VERSION_REGEX = /;\s*v=(\d+)/
|
|
44
|
+
export function parseFramedProtocolVersion(
|
|
45
|
+
contentType: string,
|
|
46
|
+
): number | undefined {
|
|
47
|
+
// Match "v=<number>" in the content-type parameters
|
|
48
|
+
const match = contentType.match(FRAMED_VERSION_REGEX)
|
|
49
|
+
return match ? parseInt(match[1]!, 10) : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validates that the server's protocol version is compatible with this client.
|
|
54
|
+
* Throws an error if versions are incompatible.
|
|
55
|
+
*/
|
|
56
|
+
export function validateFramedProtocolVersion(contentType: string): void {
|
|
57
|
+
const serverVersion = parseFramedProtocolVersion(contentType)
|
|
58
|
+
if (serverVersion === undefined) {
|
|
59
|
+
// No version specified - assume compatible (backwards compat)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if (serverVersion !== TSS_FRAMED_PROTOCOL_VERSION) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Incompatible framed protocol version: server=${serverVersion}, client=${TSS_FRAMED_PROTOCOL_VERSION}. ` +
|
|
65
|
+
`Please ensure client and server are using compatible versions.`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
9
69
|
export {}
|