@tanstack/start-client-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.
@@ -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 { encode, isNotFound, parseRedirect } from '@tanstack/router-core'
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
 
@@ -55,7 +63,10 @@ export async function serverFnFetcher(
55
63
  headers.set('x-tsr-serverFn', 'true')
56
64
 
57
65
  if (type === 'payload') {
58
- headers.set('accept', 'application/x-ndjson, application/json')
66
+ headers.set(
67
+ 'accept',
68
+ `${TSS_CONTENT_TYPE_FRAMED}, application/x-ndjson, application/json`,
69
+ )
59
70
  }
60
71
 
61
72
  // If the method is GET, we need to move the payload to the query string
@@ -177,8 +188,36 @@ async function getResponse(fn: () => Promise<Response>) {
177
188
  // differently than a normal response.
178
189
  if (serializedByStart) {
179
190
  let result
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
+ }
180
219
  // If it's a stream from the start serializer, process it as such
181
- if (contentType.includes('application/x-ndjson')) {
220
+ else if (contentType.includes('application/x-ndjson')) {
182
221
  const refs = new Map()
183
222
  result = await processServerFnResponse({
184
223
  response,
@@ -191,7 +230,7 @@ async function getResponse(fn: () => Promise<Response>) {
191
230
  })
192
231
  }
193
232
  // If it's a JSON response, it can be simpler
194
- if (contentType.includes('application/json')) {
233
+ else if (contentType.includes('application/json')) {
195
234
  const jsonPayload = await response.json()
196
235
  result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })
197
236
  }
@@ -310,3 +349,50 @@ async function processServerFnResponse({
310
349
 
311
350
  return onMessage(firstObject)
312
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
@@ -7,4 +7,63 @@ export const TSS_SERVER_FUNCTION_FACTORY = Symbol.for(
7
7
  export const X_TSS_SERIALIZED = 'x-tss-serialized'
8
8
  export const X_TSS_RAW_RESPONSE = 'x-tss-raw'
9
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
+ }
10
69
  export {}
@@ -227,7 +227,7 @@ export type IntersectAllValidatorOutputs<TMiddlewares, TInputValidator> =
227
227
  ? IntersectAllMiddleware<TMiddlewares, 'allOutput'>
228
228
  : IntersectAssign<
229
229
  IntersectAllMiddleware<TMiddlewares, 'allOutput'>,
230
- ResolveValidatorOutput<TInputValidator>
230
+ Awaited<ResolveValidatorOutput<TInputValidator>>
231
231
  >
232
232
 
233
233
  /**
@@ -703,17 +703,14 @@ export type MiddlewareFn = (
703
703
  },
704
704
  ) => Promise<ServerFnMiddlewareResult>
705
705
 
706
- export function execValidator(
706
+ export async function execValidator(
707
707
  validator: AnyValidator,
708
708
  input: unknown,
709
- ): unknown {
709
+ ): Promise<unknown> {
710
710
  if (validator == null) return {}
711
711
 
712
712
  if ('~standard' in validator) {
713
- const result = validator['~standard'].validate(input)
714
-
715
- if (result instanceof Promise)
716
- throw new Error('Async validation not supported')
713
+ const result = await validator['~standard'].validate(input)
717
714
 
718
715
  if (result.issues)
719
716
  throw new Error(JSON.stringify(result.issues, undefined, 2))
package/src/index.tsx CHANGED
@@ -2,6 +2,9 @@ export type { JsonResponse } from '@tanstack/router-core/ssr/client'
2
2
 
3
3
  export { hydrate, json, mergeHeaders } from '@tanstack/router-core/ssr/client'
4
4
 
5
+ export { RawStream } from '@tanstack/router-core'
6
+ export type { OnRawStreamCallback } from '@tanstack/router-core'
7
+
5
8
  export {
6
9
  createIsomorphicFn,
7
10
  createServerOnlyFn,
@@ -80,10 +83,17 @@ export {
80
83
  export {
81
84
  TSS_FORMDATA_CONTEXT,
82
85
  TSS_SERVER_FUNCTION,
86
+ TSS_CONTENT_TYPE_FRAMED,
87
+ TSS_CONTENT_TYPE_FRAMED_VERSIONED,
88
+ TSS_FRAMED_PROTOCOL_VERSION,
89
+ FrameType,
90
+ FRAME_HEADER_SIZE,
83
91
  X_TSS_SERIALIZED,
84
92
  X_TSS_RAW_RESPONSE,
85
93
  X_TSS_CONTEXT,
94
+ validateFramedProtocolVersion,
86
95
  } from './constants'
96
+ export type { FrameType as FrameTypeValue } from './constants'
87
97
 
88
98
  export type * from './serverRoute'
89
99