@tanstack/start-server-core 1.143.8 → 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,62 +1,57 @@
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'
13
+ import type { Plugin as SerovalPlugin } from 'seroval'
12
14
 
13
- let regex: RegExp | undefined = undefined
15
+ // Cache serovalPlugins at module level to avoid repeated calls
16
+ let serovalPlugins: Array<SerovalPlugin<any, any>> | undefined = undefined
17
+
18
+ // Known FormData 'Content-Type' header values - module-level constant
19
+ const FORM_DATA_CONTENT_TYPES = [
20
+ 'multipart/form-data',
21
+ 'application/x-www-form-urlencoded',
22
+ ]
23
+
24
+ // Maximum payload size for GET requests (1MB)
25
+ const MAX_PAYLOAD_SIZE = 1_000_000
14
26
 
15
27
  export const handleServerAction = async ({
16
28
  request,
17
29
  context,
30
+ serverFnId,
18
31
  }: {
19
32
  request: Request
20
33
  context: any
34
+ serverFnId: string
21
35
  }) => {
22
36
  const controller = new AbortController()
23
37
  const signal = controller.signal
24
38
  const abort = () => controller.abort()
25
39
  request.signal.addEventListener('abort', abort)
26
40
 
27
- if (regex === undefined) {
28
- regex = new RegExp(`${process.env.TSS_SERVER_FN_BASE}([^/?#]+)`)
29
- }
30
-
31
41
  const method = request.method
32
- const url = new URL(request.url, 'http://localhost:3000')
33
- // extract the serverFnId from the url as host/_serverFn/:serverFnId
34
- // Define a regex to match the path and extract the :thing part
35
-
36
- // Execute the regex
37
- const match = url.pathname.match(regex)
38
- const serverFnId = match ? match[1] : null
39
- const search = Object.fromEntries(url.searchParams.entries()) as {
40
- payload?: any
41
- createServerFn?: boolean
42
- }
43
-
44
- const isCreateServerFn = 'createServerFn' in search
45
-
46
- if (typeof serverFnId !== 'string') {
47
- throw new Error('Invalid server action param for serverFnId: ' + serverFnId)
48
- }
42
+ const methodLower = method.toLowerCase()
43
+ const url = new URL(request.url)
49
44
 
50
45
  const action = await getServerFnById(serverFnId, { fromClient: true })
51
46
 
52
- // Known FormData 'Content-Type' header values
53
- const formDataContentTypes = [
54
- 'multipart/form-data',
55
- 'application/x-www-form-urlencoded',
56
- ]
47
+ const isServerFn = request.headers.get('x-tsr-serverFn') === 'true'
48
+
49
+ // Initialize serovalPlugins lazily (cached at module level)
50
+ if (!serovalPlugins) {
51
+ serovalPlugins = getDefaultSerovalPlugins()
52
+ }
57
53
 
58
54
  const contentType = request.headers.get('Content-Type')
59
- const serovalPlugins = getDefaultSerovalPlugins()
60
55
 
61
56
  function parsePayload(payload: any) {
62
57
  const parsedPayload = fromJSON(payload, { plugins: serovalPlugins })
@@ -65,16 +60,16 @@ export const handleServerAction = async ({
65
60
 
66
61
  const response = await (async () => {
67
62
  try {
68
- let result = await (async () => {
63
+ let res = await (async () => {
69
64
  // FormData
70
65
  if (
71
- formDataContentTypes.some(
66
+ FORM_DATA_CONTENT_TYPES.some(
72
67
  (type) => contentType && contentType.includes(type),
73
68
  )
74
69
  ) {
75
70
  // We don't support GET requests with FormData payloads... that seems impossible
76
71
  invariant(
77
- method.toLowerCase() !== 'get',
72
+ methodLower !== 'get',
78
73
  'GET requests with FormData payloads are not supported',
79
74
  )
80
75
  const formData = await request.formData()
@@ -95,30 +90,40 @@ export const handleServerAction = async ({
95
90
  typeof deserializedContext === 'object' &&
96
91
  deserializedContext
97
92
  ) {
98
- params.context = { ...context, ...deserializedContext }
93
+ params.context = safeObjectMerge(
94
+ context,
95
+ deserializedContext as Record<string, unknown>,
96
+ )
97
+ }
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)
99
102
  }
100
- } catch {}
103
+ }
101
104
  }
102
105
 
103
106
  return await action(params, signal)
104
107
  }
105
108
 
106
109
  // Get requests use the query string
107
- if (method.toLowerCase() === 'get') {
108
- invariant(
109
- isCreateServerFn,
110
- 'expected GET request to originate from createServerFn',
111
- )
112
- // By default the payload is the search params
113
- let payload: any = search.payload
110
+ if (methodLower === 'get') {
111
+ // Get payload directly from searchParams
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
+ }
114
117
  // If there's a payload, we should try to parse it
115
- payload = payload ? parsePayload(JSON.parse(payload)) : {}
116
- payload.context = { ...context, ...payload.context }
118
+ const payload: any = payloadParam
119
+ ? parsePayload(JSON.parse(payloadParam))
120
+ : {}
121
+ payload.context = safeObjectMerge(context, payload.context)
117
122
  // Send it through!
118
123
  return await action(payload, signal)
119
124
  }
120
125
 
121
- if (method.toLowerCase() !== 'post') {
126
+ if (methodLower !== 'post') {
122
127
  throw new Error('expected POST method')
123
128
  }
124
129
 
@@ -127,144 +132,115 @@ export const handleServerAction = async ({
127
132
  jsonPayload = await request.json()
128
133
  }
129
134
 
130
- // If this POST request was created by createServerFn,
131
- // its payload will be the only argument
132
- if (isCreateServerFn) {
133
- const payload = jsonPayload ? parsePayload(jsonPayload) : {}
134
- payload.context = { ...payload.context, ...context }
135
- return await action(payload, signal)
136
- }
137
-
138
- // Otherwise, we'll spread the payload. Need to
139
- // support `use server` functions that take multiple
140
- // arguments.
141
- return await action(...jsonPayload)
135
+ const payload = jsonPayload ? parsePayload(jsonPayload) : {}
136
+ payload.context = safeObjectMerge(payload.context, context)
137
+ return await action(payload, signal)
142
138
  })()
143
139
 
144
- // Any time we get a Response back, we should just
145
- // return it immediately.
146
- if (result.result instanceof Response) {
147
- result.result.headers.set(X_TSS_RAW_RESPONSE, 'true')
148
- return result.result
149
- }
150
-
151
- // If this is a non createServerFn request, we need to
152
- // pull out the result from the result object
153
- if (!isCreateServerFn) {
154
- result = result.result
140
+ const unwrapped = res.result || res.error
155
141
 
156
- // The result might again be a response,
157
- // and if it is, return it.
158
- if (result instanceof Response) {
159
- return result
160
- }
142
+ if (isNotFound(res)) {
143
+ res = isNotFoundResponse(res)
161
144
  }
162
145
 
163
- // TODO: RSCs Where are we getting this package?
164
- // if (isValidElement(result)) {
165
- // const { renderToPipeableStream } = await import(
166
- // // @ts-expect-error
167
- // 'react-server-dom/server'
168
- // )
169
-
170
- // const pipeableStream = renderToPipeableStream(result)
171
-
172
- // setHeaders(event, {
173
- // 'Content-Type': 'text/x-component',
174
- // } as any)
175
-
176
- // sendStream(event, response)
177
- // event._handled = true
178
-
179
- // return new Response(null, { status: 200 })
180
- // }
181
-
182
- if (isNotFound(result)) {
183
- return isNotFoundResponse(result)
146
+ if (!isServerFn) {
147
+ return unwrapped
184
148
  }
185
149
 
186
- const response = getResponse()
187
- let nonStreamingBody: any = undefined
188
-
189
- if (result !== undefined) {
190
- // first run without the stream in case `result` does not need streaming
191
- let done = false as boolean
192
- const callbacks: {
193
- onParse: (value: any) => void
194
- onDone: () => void
195
- onError: (error: any) => void
196
- } = {
197
- onParse: (value) => {
198
- nonStreamingBody = value
199
- },
200
- onDone: () => {
201
- done = true
202
- },
203
- onError: (error) => {
204
- throw error
205
- },
150
+ if (unwrapped instanceof Response) {
151
+ if (isRedirect(unwrapped)) {
152
+ return unwrapped
206
153
  }
207
- toCrossJSONStream(result, {
208
- refs: new Map(),
209
- plugins: serovalPlugins,
210
- onParse(value) {
211
- callbacks.onParse(value)
212
- },
213
- onDone() {
214
- callbacks.onDone()
215
- },
216
- onError: (error) => {
217
- callbacks.onError(error)
218
- },
219
- })
220
- if (done) {
221
- return new Response(
222
- nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined,
223
- {
224
- status: response.status,
225
- statusText: response.statusText,
226
- headers: {
227
- 'Content-Type': 'application/json',
228
- [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
+ },
229
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
+ }
230
227
  },
231
- )
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
+ })
232
237
  }
233
238
 
234
- // not done yet, we need to stream
235
- const encoder = new TextEncoder()
236
- const stream = new ReadableStream({
237
- start(controller) {
238
- callbacks.onParse = (value) =>
239
- controller.enqueue(encoder.encode(JSON.stringify(value) + '\n'))
240
- callbacks.onDone = () => {
241
- try {
242
- controller.close()
243
- } catch (error) {
244
- controller.error(error)
245
- }
246
- }
247
- callbacks.onError = (error) => controller.error(error)
248
- // stream the initial body
249
- if (nonStreamingBody !== undefined) {
250
- callbacks.onParse(nonStreamingBody)
251
- }
252
- },
253
- })
254
- return new Response(stream, {
255
- status: response.status,
256
- statusText: response.statusText,
257
- headers: {
258
- 'Content-Type': 'application/x-ndjson',
259
- [X_TSS_SERIALIZED]: 'true',
260
- },
239
+ return new Response(undefined, {
240
+ status: alsResponse.status,
241
+ statusText: alsResponse.statusText,
261
242
  })
262
243
  }
263
-
264
- return new Response(undefined, {
265
- status: response.status,
266
- statusText: response.statusText,
267
- })
268
244
  } catch (error: any) {
269
245
  if (error instanceof Response) {
270
246
  return error