@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.
- package/dist/esm/createStartHandler.js +268 -257
- package/dist/esm/createStartHandler.js.map +1 -1
- package/dist/esm/server-functions-handler.d.ts +2 -1
- package/dist/esm/server-functions-handler.js +124 -122
- package/dist/esm/server-functions-handler.js.map +1 -1
- package/package.json +4 -4
- package/src/createStartHandler.ts +386 -343
- package/src/server-functions-handler.ts +144 -168
|
@@ -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
|
-
|
|
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
|
|
33
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
63
|
+
let res = await (async () => {
|
|
69
64
|
// FormData
|
|
70
65
|
if (
|
|
71
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
}
|
|
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 (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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 (
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
if (result instanceof Response) {
|
|
159
|
-
return result
|
|
160
|
-
}
|
|
142
|
+
if (isNotFound(res)) {
|
|
143
|
+
res = isNotFoundResponse(res)
|
|
161
144
|
}
|
|
162
145
|
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|