@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.
@@ -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 { isNotFound } from '@tanstack/router-core'
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, 'http://localhost:3000')
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
- const result = await (async () => {
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 = { ...context, ...deserializedContext }
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
- } catch {}
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 = { ...context, ...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 = { ...payload.context, ...context }
147
+ payload.context = safeObjectMerge(payload.context, context)
133
148
  return await action(payload, signal)
134
149
  })()
135
150
 
136
- // Any time we get a Response back, we should just
137
- // return it immediately.
138
- if (result.result instanceof Response) {
139
- result.result.headers.set(X_TSS_RAW_RESPONSE, 'true')
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 (isNotFound(result)) {
144
- return isNotFoundResponse(result)
157
+ if (!isServerFn) {
158
+ return unwrapped
145
159
  }
146
160
 
147
- const response = getResponse()
148
- let nonStreamingBody: any = undefined
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
- toCrossJSONStream(result, {
169
- refs: new Map(),
170
- plugins: serovalPlugins,
171
- onParse(value) {
172
- callbacks.onParse(value)
173
- },
174
- onDone() {
175
- callbacks.onDone()
176
- },
177
- onError: (error) => {
178
- callbacks.onError(error)
179
- },
180
- })
181
- if (done) {
182
- return new Response(
183
- nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined,
184
- {
185
- status: response.status,
186
- statusText: response.statusText,
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': 'application/json',
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
- // not done yet, we need to stream
196
- const encoder = new TextEncoder()
197
- const stream = new ReadableStream({
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