@tanstack/start-server-core 1.143.9 → 1.143.12

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.
@@ -1,18 +1,17 @@
1
- import { isNotFound } from '@tanstack/router-core'
1
+ import { isNotFound, isRedirect } from '@tanstack/router-core'
2
2
  import invariant from 'tiny-invariant'
3
3
  import {
4
4
  TSS_FORMDATA_CONTEXT,
5
5
  X_TSS_RAW_RESPONSE,
6
6
  X_TSS_SERIALIZED,
7
7
  getDefaultSerovalPlugins,
8
+ safeObjectMerge,
8
9
  } from '@tanstack/start-client-core'
9
10
  import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval'
10
11
  import { getResponse } from './request-response'
11
12
  import { getServerFnById } from './getServerFnById'
12
13
  import type { Plugin as SerovalPlugin } from 'seroval'
13
14
 
14
- let regex: RegExp | undefined = undefined
15
-
16
15
  // Cache serovalPlugins at module level to avoid repeated calls
17
16
  let serovalPlugins: Array<SerovalPlugin<any, any>> | undefined = undefined
18
17
 
@@ -22,38 +21,31 @@ const FORM_DATA_CONTENT_TYPES = [
22
21
  'application/x-www-form-urlencoded',
23
22
  ]
24
23
 
24
+ // Maximum payload size for GET requests (1MB)
25
+ const MAX_PAYLOAD_SIZE = 1_000_000
26
+
25
27
  export const handleServerAction = async ({
26
28
  request,
27
29
  context,
30
+ serverFnId,
28
31
  }: {
29
32
  request: Request
30
33
  context: any
34
+ serverFnId: string
31
35
  }) => {
32
36
  const controller = new AbortController()
33
37
  const signal = controller.signal
34
38
  const abort = () => controller.abort()
35
39
  request.signal.addEventListener('abort', abort)
36
40
 
37
- if (regex === undefined) {
38
- regex = new RegExp(`${process.env.TSS_SERVER_FN_BASE}([^/?#]+)`)
39
- }
40
-
41
41
  const method = request.method
42
42
  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
- }
43
+ const url = new URL(request.url)
54
44
 
55
45
  const action = await getServerFnById(serverFnId, { fromClient: true })
56
46
 
47
+ const isServerFn = request.headers.get('x-tsr-serverFn') === 'true'
48
+
57
49
  // Initialize serovalPlugins lazily (cached at module level)
58
50
  if (!serovalPlugins) {
59
51
  serovalPlugins = getDefaultSerovalPlugins()
@@ -68,7 +60,7 @@ export const handleServerAction = async ({
68
60
 
69
61
  const response = await (async () => {
70
62
  try {
71
- const result = await (async () => {
63
+ let res = await (async () => {
72
64
  // FormData
73
65
  if (
74
66
  FORM_DATA_CONTENT_TYPES.some(
@@ -98,9 +90,17 @@ export const handleServerAction = async ({
98
90
  typeof deserializedContext === 'object' &&
99
91
  deserializedContext
100
92
  ) {
101
- params.context = { ...context, ...deserializedContext }
93
+ params.context = safeObjectMerge(
94
+ context,
95
+ deserializedContext as Record<string, unknown>,
96
+ )
102
97
  }
103
- } catch {}
98
+ } catch (e) {
99
+ // Log warning for debugging but don't expose to client
100
+ if (process.env.NODE_ENV === 'development') {
101
+ console.warn('Failed to parse FormData context:', e)
102
+ }
103
+ }
104
104
  }
105
105
 
106
106
  return await action(params, signal)
@@ -110,11 +110,15 @@ export const handleServerAction = async ({
110
110
  if (methodLower === 'get') {
111
111
  // Get payload directly from searchParams
112
112
  const payloadParam = url.searchParams.get('payload')
113
+ // Reject oversized payloads to prevent DoS
114
+ if (payloadParam && payloadParam.length > MAX_PAYLOAD_SIZE) {
115
+ throw new Error('Payload too large')
116
+ }
113
117
  // If there's a payload, we should try to parse it
114
118
  const payload: any = payloadParam
115
119
  ? parsePayload(JSON.parse(payloadParam))
116
120
  : {}
117
- payload.context = { ...context, ...payload.context }
121
+ payload.context = safeObjectMerge(context, payload.context)
118
122
  // Send it through!
119
123
  return await action(payload, signal)
120
124
  }
@@ -129,103 +133,114 @@ export const handleServerAction = async ({
129
133
  }
130
134
 
131
135
  const payload = jsonPayload ? parsePayload(jsonPayload) : {}
132
- payload.context = { ...payload.context, ...context }
136
+ payload.context = safeObjectMerge(payload.context, context)
133
137
  return await action(payload, signal)
134
138
  })()
135
139
 
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
140
+ const unwrapped = res.result || res.error
141
+
142
+ if (isNotFound(res)) {
143
+ res = isNotFoundResponse(res)
141
144
  }
142
145
 
143
- if (isNotFound(result)) {
144
- return isNotFoundResponse(result)
146
+ if (!isServerFn) {
147
+ return unwrapped
145
148
  }
146
149
 
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
- },
150
+ if (unwrapped instanceof Response) {
151
+ if (isRedirect(unwrapped)) {
152
+ return unwrapped
167
153
  }
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,
187
- headers: {
188
- 'Content-Type': 'application/json',
189
- [X_TSS_SERIALIZED]: 'true',
154
+ unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true')
155
+ return unwrapped
156
+ }
157
+
158
+ return serializeResult(res)
159
+
160
+ function serializeResult(res: unknown): Response {
161
+ let nonStreamingBody: any = undefined
162
+
163
+ const alsResponse = getResponse()
164
+ if (res !== undefined) {
165
+ // first run without the stream in case `result` does not need streaming
166
+ let done = false as boolean
167
+ const callbacks: {
168
+ onParse: (value: any) => void
169
+ onDone: () => void
170
+ onError: (error: any) => void
171
+ } = {
172
+ onParse: (value) => {
173
+ nonStreamingBody = value
174
+ },
175
+ onDone: () => {
176
+ done = true
177
+ },
178
+ onError: (error) => {
179
+ throw error
180
+ },
181
+ }
182
+ toCrossJSONStream(res, {
183
+ refs: new Map(),
184
+ plugins: serovalPlugins,
185
+ onParse(value) {
186
+ callbacks.onParse(value)
187
+ },
188
+ onDone() {
189
+ callbacks.onDone()
190
+ },
191
+ onError: (error) => {
192
+ callbacks.onError(error)
193
+ },
194
+ })
195
+ if (done) {
196
+ return new Response(
197
+ nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined,
198
+ {
199
+ status: alsResponse.status,
200
+ statusText: alsResponse.statusText,
201
+ headers: {
202
+ 'Content-Type': 'application/json',
203
+ [X_TSS_SERIALIZED]: 'true',
204
+ },
190
205
  },
206
+ )
207
+ }
208
+
209
+ // not done yet, we need to stream
210
+ const encoder = new TextEncoder()
211
+ const stream = new ReadableStream({
212
+ start(controller) {
213
+ callbacks.onParse = (value) =>
214
+ controller.enqueue(encoder.encode(JSON.stringify(value) + '\n'))
215
+ callbacks.onDone = () => {
216
+ try {
217
+ controller.close()
218
+ } catch (error) {
219
+ controller.error(error)
220
+ }
221
+ }
222
+ callbacks.onError = (error) => controller.error(error)
223
+ // stream the initial body
224
+ if (nonStreamingBody !== undefined) {
225
+ callbacks.onParse(nonStreamingBody)
226
+ }
191
227
  },
192
- )
228
+ })
229
+ return new Response(stream, {
230
+ status: alsResponse.status,
231
+ statusText: alsResponse.statusText,
232
+ headers: {
233
+ 'Content-Type': 'application/x-ndjson',
234
+ [X_TSS_SERIALIZED]: 'true',
235
+ },
236
+ })
193
237
  }
194
238
 
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
- },
239
+ return new Response(undefined, {
240
+ status: alsResponse.status,
241
+ statusText: alsResponse.statusText,
222
242
  })
223
243
  }
224
-
225
- return new Response(undefined, {
226
- status: response.status,
227
- statusText: response.statusText,
228
- })
229
244
  } catch (error: any) {
230
245
  if (error instanceof Response) {
231
246
  return error