@tanstack/router-core 1.143.6 → 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.
@@ -0,0 +1,464 @@
1
+ import { createPlugin, createStream } from 'seroval'
2
+ import type { Plugin } from 'seroval'
3
+
4
+ /**
5
+ * Hint for RawStream encoding strategy during SSR serialization.
6
+ * - 'binary': Always use base64 encoding (best for binary data like files, images)
7
+ * - 'text': Try UTF-8 first, fallback to base64 (best for text-heavy data like RSC payloads)
8
+ */
9
+ export type RawStreamHint = 'binary' | 'text'
10
+
11
+ /**
12
+ * Options for RawStream configuration.
13
+ */
14
+ export interface RawStreamOptions {
15
+ /**
16
+ * Encoding hint for SSR serialization.
17
+ * - 'binary' (default): Always use base64 encoding
18
+ * - 'text': Try UTF-8 first, fallback to base64 for invalid UTF-8 chunks
19
+ */
20
+ hint?: RawStreamHint
21
+ }
22
+
23
+ /**
24
+ * Marker class for ReadableStream<Uint8Array> that should be serialized
25
+ * with base64 encoding (SSR) or binary framing (server functions).
26
+ *
27
+ * Wrap your binary streams with this to get efficient serialization:
28
+ * ```ts
29
+ * // For binary data (files, images, etc.)
30
+ * return { data: new RawStream(file.stream()) }
31
+ *
32
+ * // For text-heavy data (RSC payloads, etc.)
33
+ * return { data: new RawStream(rscStream, { hint: 'text' }) }
34
+ * ```
35
+ */
36
+ export class RawStream {
37
+ public readonly hint: RawStreamHint
38
+
39
+ constructor(
40
+ public readonly stream: ReadableStream<Uint8Array>,
41
+ options?: RawStreamOptions,
42
+ ) {
43
+ this.hint = options?.hint ?? 'binary'
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Callback type for RPC plugin to register raw streams with multiplexer
49
+ */
50
+ export type OnRawStreamCallback = (
51
+ streamId: number,
52
+ stream: ReadableStream<Uint8Array>,
53
+ ) => void
54
+
55
+ // Base64 helpers used in both Node and browser.
56
+ // In Node-like runtimes, prefer Buffer for speed and compatibility.
57
+ const BufferCtor: any = (globalThis as any).Buffer
58
+ const hasNodeBuffer = !!BufferCtor && typeof BufferCtor.from === 'function'
59
+
60
+ function uint8ArrayToBase64(bytes: Uint8Array): string {
61
+ if (bytes.length === 0) return ''
62
+
63
+ if (hasNodeBuffer) {
64
+ return BufferCtor.from(bytes).toString('base64')
65
+ }
66
+
67
+ // Browser fallback: chunked String.fromCharCode + btoa
68
+ const CHUNK_SIZE = 0x8000 // 32KB chunks to avoid stack overflow
69
+ const chunks: Array<string> = []
70
+ for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
71
+ const chunk = bytes.subarray(i, i + CHUNK_SIZE)
72
+ chunks.push(String.fromCharCode.apply(null, chunk as any))
73
+ }
74
+ return btoa(chunks.join(''))
75
+ }
76
+
77
+ function base64ToUint8Array(base64: string): Uint8Array {
78
+ if (base64.length === 0) return new Uint8Array(0)
79
+
80
+ if (hasNodeBuffer) {
81
+ const buf = BufferCtor.from(base64, 'base64')
82
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
83
+ }
84
+
85
+ const binary = atob(base64)
86
+ const bytes = new Uint8Array(binary.length)
87
+ for (let i = 0; i < binary.length; i++) {
88
+ bytes[i] = binary.charCodeAt(i)
89
+ }
90
+ return bytes
91
+ }
92
+
93
+ // Factory sentinels - use null-proto objects to avoid prototype surprises
94
+ const RAW_STREAM_FACTORY_BINARY: Record<string, never> = Object.create(null)
95
+ const RAW_STREAM_FACTORY_TEXT: Record<string, never> = Object.create(null)
96
+
97
+ // Factory constructor for binary mode - converts seroval stream to ReadableStream<Uint8Array>
98
+ // All chunks are base64 encoded strings
99
+ const RAW_STREAM_FACTORY_CONSTRUCTOR_BINARY = (
100
+ stream: ReturnType<typeof createStream>,
101
+ ) =>
102
+ new ReadableStream<Uint8Array>({
103
+ start(controller) {
104
+ stream.on({
105
+ next(base64: string) {
106
+ try {
107
+ controller.enqueue(base64ToUint8Array(base64))
108
+ } catch {
109
+ // Stream may be closed
110
+ }
111
+ },
112
+ throw(error: unknown) {
113
+ controller.error(error)
114
+ },
115
+ return() {
116
+ try {
117
+ controller.close()
118
+ } catch {
119
+ // Stream may already be closed
120
+ }
121
+ },
122
+ })
123
+ },
124
+ })
125
+
126
+ // Factory constructor for text mode - converts seroval stream to ReadableStream<Uint8Array>
127
+ // Chunks are either strings (UTF-8) or { $b64: string } (base64 fallback)
128
+ // Use module-level TextEncoder to avoid per-factory allocation
129
+ const textEncoderForFactory = new TextEncoder()
130
+ const RAW_STREAM_FACTORY_CONSTRUCTOR_TEXT = (
131
+ stream: ReturnType<typeof createStream>,
132
+ ) => {
133
+ return new ReadableStream<Uint8Array>({
134
+ start(controller) {
135
+ stream.on({
136
+ next(value: string | { $b64: string }) {
137
+ try {
138
+ if (typeof value === 'string') {
139
+ controller.enqueue(textEncoderForFactory.encode(value))
140
+ } else {
141
+ controller.enqueue(base64ToUint8Array(value.$b64))
142
+ }
143
+ } catch {
144
+ // Stream may be closed
145
+ }
146
+ },
147
+ throw(error: unknown) {
148
+ controller.error(error)
149
+ },
150
+ return() {
151
+ try {
152
+ controller.close()
153
+ } catch {
154
+ // Stream may already be closed
155
+ }
156
+ },
157
+ })
158
+ },
159
+ })
160
+ }
161
+
162
+ // Minified factory function for binary mode - all chunks are base64 strings
163
+ // This must be self-contained since it's injected into the HTML
164
+ const FACTORY_BINARY = `(s=>new ReadableStream({start(c){s.on({next(b){try{const d=atob(b),a=new Uint8Array(d.length);for(let i=0;i<d.length;i++)a[i]=d.charCodeAt(i);c.enqueue(a)}catch(_){}},throw(e){c.error(e)},return(){try{c.close()}catch(_){}}})}}))`
165
+
166
+ // Minified factory function for text mode - chunks are string or {$b64: string}
167
+ // Uses cached TextEncoder for performance
168
+ const FACTORY_TEXT = `(s=>{const e=new TextEncoder();return new ReadableStream({start(c){s.on({next(v){try{if(typeof v==='string'){c.enqueue(e.encode(v))}else{const d=atob(v.$b64),a=new Uint8Array(d.length);for(let i=0;i<d.length;i++)a[i]=d.charCodeAt(i);c.enqueue(a)}}catch(_){}},throw(x){c.error(x)},return(){try{c.close()}catch(_){}}})}})})`
169
+
170
+ // Convert ReadableStream<Uint8Array> to seroval stream with base64-encoded chunks (binary mode)
171
+ function toBinaryStream(readable: ReadableStream<Uint8Array>) {
172
+ const stream = createStream()
173
+ const reader = readable.getReader()
174
+
175
+ // Use iterative loop instead of recursive async to avoid stack accumulation
176
+ ;(async () => {
177
+ try {
178
+ while (true) {
179
+ const { done, value } = await reader.read()
180
+ if (done) {
181
+ stream.return(undefined)
182
+ break
183
+ }
184
+ stream.next(uint8ArrayToBase64(value))
185
+ }
186
+ } catch (error) {
187
+ stream.throw(error)
188
+ } finally {
189
+ reader.releaseLock()
190
+ }
191
+ })()
192
+
193
+ return stream
194
+ }
195
+
196
+ // Convert ReadableStream<Uint8Array> to seroval stream with UTF-8 first, base64 fallback (text mode)
197
+ function toTextStream(readable: ReadableStream<Uint8Array>) {
198
+ const stream = createStream()
199
+ const reader = readable.getReader()
200
+ const decoder = new TextDecoder('utf-8', { fatal: true })
201
+
202
+ // Use iterative loop instead of recursive async to avoid stack accumulation
203
+ ;(async () => {
204
+ try {
205
+ while (true) {
206
+ const { done, value } = await reader.read()
207
+ if (done) {
208
+ // Flush any remaining bytes in the decoder
209
+ try {
210
+ const remaining = decoder.decode()
211
+ if (remaining.length > 0) {
212
+ stream.next(remaining)
213
+ }
214
+ } catch {
215
+ // Ignore decode errors on flush
216
+ }
217
+ stream.return(undefined)
218
+ break
219
+ }
220
+
221
+ try {
222
+ // Try UTF-8 decode first
223
+ const text = decoder.decode(value, { stream: true })
224
+ if (text.length > 0) {
225
+ stream.next(text)
226
+ }
227
+ } catch {
228
+ // UTF-8 decode failed, fallback to base64
229
+ stream.next({ $b64: uint8ArrayToBase64(value) })
230
+ }
231
+ }
232
+ } catch (error) {
233
+ stream.throw(error)
234
+ } finally {
235
+ reader.releaseLock()
236
+ }
237
+ })()
238
+
239
+ return stream
240
+ }
241
+
242
+ // Factory plugin for binary mode
243
+ const RawStreamFactoryBinaryPlugin = createPlugin<
244
+ Record<string, never>,
245
+ undefined
246
+ >({
247
+ tag: 'tss/RawStreamFactory',
248
+ test(value) {
249
+ return value === RAW_STREAM_FACTORY_BINARY
250
+ },
251
+ parse: {
252
+ sync() {
253
+ return undefined
254
+ },
255
+ async() {
256
+ return Promise.resolve(undefined)
257
+ },
258
+ stream() {
259
+ return undefined
260
+ },
261
+ },
262
+ serialize() {
263
+ return FACTORY_BINARY
264
+ },
265
+ deserialize() {
266
+ return RAW_STREAM_FACTORY_BINARY
267
+ },
268
+ })
269
+
270
+ // Factory plugin for text mode
271
+ const RawStreamFactoryTextPlugin = createPlugin<
272
+ Record<string, never>,
273
+ undefined
274
+ >({
275
+ tag: 'tss/RawStreamFactoryText',
276
+ test(value) {
277
+ return value === RAW_STREAM_FACTORY_TEXT
278
+ },
279
+ parse: {
280
+ sync() {
281
+ return undefined
282
+ },
283
+ async() {
284
+ return Promise.resolve(undefined)
285
+ },
286
+ stream() {
287
+ return undefined
288
+ },
289
+ },
290
+ serialize() {
291
+ return FACTORY_TEXT
292
+ },
293
+ deserialize() {
294
+ return RAW_STREAM_FACTORY_TEXT
295
+ },
296
+ })
297
+
298
+ /**
299
+ * SSR Plugin - uses base64 or UTF-8+base64 encoding for chunks, delegates to seroval's stream mechanism.
300
+ * Used during SSR when serializing to JavaScript code for HTML injection.
301
+ *
302
+ * Supports two modes based on RawStream hint:
303
+ * - 'binary': Always base64 encode (default)
304
+ * - 'text': Try UTF-8 first, fallback to base64 for invalid UTF-8
305
+ */
306
+ export const RawStreamSSRPlugin: Plugin<any, any> = createPlugin({
307
+ tag: 'tss/RawStream',
308
+ extends: [RawStreamFactoryBinaryPlugin, RawStreamFactoryTextPlugin],
309
+
310
+ test(value: unknown) {
311
+ return value instanceof RawStream
312
+ },
313
+
314
+ parse: {
315
+ sync(value: RawStream, ctx) {
316
+ // Sync parse not really supported for streams, return empty stream
317
+ const factory =
318
+ value.hint === 'text'
319
+ ? RAW_STREAM_FACTORY_TEXT
320
+ : RAW_STREAM_FACTORY_BINARY
321
+ return {
322
+ hint: value.hint,
323
+ factory: ctx.parse(factory),
324
+ stream: ctx.parse(createStream()),
325
+ }
326
+ },
327
+ async async(value: RawStream, ctx) {
328
+ const factory =
329
+ value.hint === 'text'
330
+ ? RAW_STREAM_FACTORY_TEXT
331
+ : RAW_STREAM_FACTORY_BINARY
332
+ const encodedStream =
333
+ value.hint === 'text'
334
+ ? toTextStream(value.stream)
335
+ : toBinaryStream(value.stream)
336
+ return {
337
+ hint: value.hint,
338
+ factory: await ctx.parse(factory),
339
+ stream: await ctx.parse(encodedStream),
340
+ }
341
+ },
342
+ stream(value: RawStream, ctx) {
343
+ const factory =
344
+ value.hint === 'text'
345
+ ? RAW_STREAM_FACTORY_TEXT
346
+ : RAW_STREAM_FACTORY_BINARY
347
+ const encodedStream =
348
+ value.hint === 'text'
349
+ ? toTextStream(value.stream)
350
+ : toBinaryStream(value.stream)
351
+ return {
352
+ hint: value.hint,
353
+ factory: ctx.parse(factory),
354
+ stream: ctx.parse(encodedStream),
355
+ }
356
+ },
357
+ },
358
+
359
+ serialize(node: { hint: RawStreamHint; factory: any; stream: any }, ctx) {
360
+ return (
361
+ '(' +
362
+ ctx.serialize(node.factory) +
363
+ ')(' +
364
+ ctx.serialize(node.stream) +
365
+ ')'
366
+ )
367
+ },
368
+
369
+ deserialize(
370
+ node: { hint: RawStreamHint; factory: any; stream: any },
371
+ ctx,
372
+ ): any {
373
+ const stream: ReturnType<typeof createStream> = ctx.deserialize(node.stream)
374
+ return node.hint === 'text'
375
+ ? RAW_STREAM_FACTORY_CONSTRUCTOR_TEXT(stream)
376
+ : RAW_STREAM_FACTORY_CONSTRUCTOR_BINARY(stream)
377
+ },
378
+ }) as Plugin<any, any>
379
+
380
+ /**
381
+ * Node type for RPC plugin serialization
382
+ */
383
+ interface RawStreamRPCNode {
384
+ streamId: number
385
+ }
386
+
387
+ /**
388
+ * Creates an RPC plugin instance that registers raw streams with a multiplexer.
389
+ * Used for server function responses where we want binary framing.
390
+ * Note: RPC always uses binary framing regardless of hint.
391
+ *
392
+ * @param onRawStream Callback invoked when a RawStream is encountered during serialization
393
+ */
394
+ export function createRawStreamRPCPlugin(
395
+ onRawStream: OnRawStreamCallback,
396
+ ): Plugin<any, any> {
397
+ // Own stream counter - sequential IDs starting at 1, independent of seroval internals
398
+ let nextStreamId = 1
399
+
400
+ return createPlugin({
401
+ tag: 'tss/RawStream',
402
+
403
+ test(value: unknown) {
404
+ return value instanceof RawStream
405
+ },
406
+
407
+ parse: {
408
+ async(value: RawStream) {
409
+ const streamId = nextStreamId++
410
+ onRawStream(streamId, value.stream)
411
+ return Promise.resolve({ streamId })
412
+ },
413
+ stream(value: RawStream) {
414
+ const streamId = nextStreamId++
415
+ onRawStream(streamId, value.stream)
416
+ return { streamId }
417
+ },
418
+ },
419
+
420
+ serialize(): never {
421
+ // RPC uses toCrossJSONStream which produces JSON nodes, not JS code.
422
+ // This method is only called by crossSerialize* which we don't use.
423
+ throw new Error(
424
+ 'RawStreamRPCPlugin.serialize should not be called. RPC uses JSON serialization, not JS code generation.',
425
+ )
426
+ },
427
+
428
+ deserialize(): never {
429
+ // Client uses createRawStreamDeserializePlugin instead
430
+ throw new Error(
431
+ 'RawStreamRPCPlugin.deserialize should not be called. Use createRawStreamDeserializePlugin on client.',
432
+ )
433
+ },
434
+ }) as Plugin<any, any>
435
+ }
436
+
437
+ /**
438
+ * Creates a deserialize-only plugin for client-side stream reconstruction.
439
+ * Used in serverFnFetcher to wire up streams from frame decoder.
440
+ *
441
+ * @param getOrCreateStream Function to get/create a stream by ID from frame decoder
442
+ */
443
+ export function createRawStreamDeserializePlugin(
444
+ getOrCreateStream: (id: number) => ReadableStream<Uint8Array>,
445
+ ): Plugin<any, any> {
446
+ return createPlugin({
447
+ tag: 'tss/RawStream',
448
+
449
+ test: () => false, // Client never serializes RawStream
450
+
451
+ parse: {}, // Client only deserializes, never parses
452
+
453
+ serialize(): never {
454
+ // Client never serializes RawStream back to server
455
+ throw new Error(
456
+ 'RawStreamDeserializePlugin.serialize should not be called. Client only deserializes.',
457
+ )
458
+ },
459
+
460
+ deserialize(node: RawStreamRPCNode) {
461
+ return getOrCreateStream(node.streamId)
462
+ },
463
+ }) as Plugin<any, any>
464
+ }
@@ -1,9 +1,12 @@
1
1
  import { ReadableStreamPlugin } from 'seroval-plugins/web'
2
2
  import { ShallowErrorPlugin } from './ShallowErrorPlugin'
3
+ import { RawStreamSSRPlugin } from './RawStream'
3
4
  import type { Plugin } from 'seroval'
4
5
 
5
6
  export const defaultSerovalPlugins = [
6
7
  ShallowErrorPlugin as Plugin<Error, any>,
8
+ // RawStreamSSRPlugin must come before ReadableStreamPlugin to match first
9
+ RawStreamSSRPlugin,
7
10
  // ReadableStreamNode is not exported by seroval
8
11
  ReadableStreamPlugin as Plugin<ReadableStream, any>,
9
12
  ]
@@ -8,6 +8,7 @@ import type {
8
8
  } from '../../router'
9
9
  import type { LooseReturnType } from '../../utils'
10
10
  import type { AnyRoute, ResolveAllSSR } from '../../route'
11
+ import type { RawStream } from './RawStream'
11
12
 
12
13
  declare const TSR_SERIALIZABLE: unique symbol
13
14
  export type TSR_SERIALIZABLE = typeof TSR_SERIALIZABLE
@@ -21,6 +22,8 @@ export interface DefaultSerializable {
21
22
  undefined: undefined
22
23
  bigint: bigint
23
24
  Date: Date
25
+ Uint8Array: Uint8Array
26
+ RawStream: RawStream
24
27
  TsrSerializable: TsrSerializable
25
28
  }
26
29