@tanstack/start-server-core 1.167.9 → 1.167.11

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.
@@ -82,135 +82,177 @@ export function encodeErrorFrame(streamId: number, error: unknown): Uint8Array {
82
82
  return encodeFrame(FrameType.ERROR, streamId, textEncoder.encode(message))
83
83
  }
84
84
 
85
+ /**
86
+ * Late stream registration for RawStreams discovered after serialization starts.
87
+ * Used when Promise<RawStream> resolves after the initial synchronous pass.
88
+ */
89
+ export interface LateStreamRegistration {
90
+ id: number
91
+ stream: ReadableStream<Uint8Array>
92
+ }
93
+
85
94
  /**
86
95
  * Creates a multiplexed ReadableStream from JSON stream and raw streams.
87
96
  *
88
97
  * The JSON stream emits NDJSON lines (from seroval's toCrossJSONStream).
89
98
  * Raw streams are pumped concurrently, interleaved with JSON frames.
90
99
  *
100
+ * Supports late stream registration for RawStreams discovered after initial
101
+ * serialization (e.g., from resolved Promises).
102
+ *
91
103
  * @param jsonStream Stream of JSON strings (each string is one NDJSON line)
92
- * @param rawStreams Map of stream IDs to raw binary streams
104
+ * @param rawStreams Map of stream IDs to raw binary streams (known at start)
105
+ * @param lateStreamSource Optional stream of late registrations for streams discovered later
93
106
  */
94
107
  export function createMultiplexedStream(
95
108
  jsonStream: ReadableStream<string>,
96
109
  rawStreams: Map<number, ReadableStream<Uint8Array>>,
110
+ lateStreamSource?: ReadableStream<LateStreamRegistration>,
97
111
  ): 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
112
+ // Shared state for the multiplexed stream
113
+ let controller: ReadableStreamDefaultController<Uint8Array>
114
+ let cancelled = false
115
+ const readers: Array<ReadableStreamDefaultReader<any>> = []
116
+
117
+ // Helper to enqueue a frame, ignoring errors if stream is closed/cancelled
118
+ const enqueue = (frame: Uint8Array): boolean => {
119
+ if (cancelled) return false
106
120
  try {
107
- controllerRef.enqueue(chunk)
121
+ controller.enqueue(frame)
122
+ return true
108
123
  } catch {
109
- // Ignore enqueue after close/cancel
124
+ return false
110
125
  }
111
126
  }
112
127
 
113
- const safeError = (err: unknown) => {
114
- if (cancelled || !controllerRef) return
128
+ // Helper to error the output stream (for fatal errors like JSON stream failure)
129
+ const errorOutput = (error: unknown): void => {
130
+ if (cancelled) return
131
+ cancelled = true
115
132
  try {
116
- controllerRef.error(err)
133
+ controller.error(error)
117
134
  } catch {
118
- // Ignore
135
+ // Already errored
136
+ }
137
+ // Cancel all readers to stop other pumps
138
+ for (const reader of readers) {
139
+ reader.cancel().catch(() => {})
119
140
  }
120
141
  }
121
142
 
122
- const safeClose = () => {
123
- if (cancelled || !controllerRef) return
143
+ // Pumps a raw stream, sending CHUNK frames and END/ERROR on completion
144
+ async function pumpRawStream(
145
+ streamId: number,
146
+ stream: ReadableStream<Uint8Array>,
147
+ ): Promise<void> {
148
+ const reader = stream.getReader()
149
+ readers.push(reader)
124
150
  try {
125
- controllerRef.close()
126
- } catch {
127
- // Ignore
151
+ while (!cancelled) {
152
+ const { done, value } = await reader.read()
153
+ if (done) {
154
+ enqueue(encodeEndFrame(streamId))
155
+ return
156
+ }
157
+ if (!enqueue(encodeChunkFrame(streamId, value))) return
158
+ }
159
+ } catch (error) {
160
+ // Raw stream error - send ERROR frame, don't fail entire response
161
+ enqueue(encodeErrorFrame(streamId, error))
162
+ } finally {
163
+ reader.releaseLock()
128
164
  }
129
165
  }
130
166
 
131
- const checkComplete = () => {
132
- activePumps--
133
- if (activePumps === 0) {
134
- safeClose()
167
+ // Pumps the JSON stream, sending JSON frames
168
+ // JSON stream errors are fatal - they error the entire output
169
+ async function pumpJSON(): Promise<void> {
170
+ const reader = jsonStream.getReader()
171
+ readers.push(reader)
172
+ try {
173
+ while (!cancelled) {
174
+ const { done, value } = await reader.read()
175
+ if (done) return
176
+ if (!enqueue(encodeJSONFrame(value))) return
177
+ }
178
+ } catch (error) {
179
+ // JSON stream error is fatal - error the entire output
180
+ errorOutput(error)
181
+ throw error // Re-throw to signal failure to Promise.all
182
+ } finally {
183
+ reader.releaseLock()
135
184
  }
136
185
  }
137
186
 
187
+ // Pumps late stream registrations, spawning raw stream pumps as they arrive
188
+ async function pumpLateStreams(): Promise<Array<Promise<void>>> {
189
+ if (!lateStreamSource) return []
190
+
191
+ const lateStreamPumps: Array<Promise<void>> = []
192
+ const reader = lateStreamSource.getReader()
193
+ readers.push(reader)
194
+ try {
195
+ while (!cancelled) {
196
+ const { done, value } = await reader.read()
197
+ if (done) break
198
+ // Start pumping this late stream and track it
199
+ lateStreamPumps.push(pumpRawStream(value.id, value.stream))
200
+ }
201
+ } finally {
202
+ reader.releaseLock()
203
+ }
204
+ return lateStreamPumps
205
+ }
206
+
138
207
  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
- }
208
+ async start(ctrl) {
209
+ controller = ctrl
210
+
211
+ // Collect all pump promises
212
+ const pumps: Array<Promise<void | Array<Promise<void>>>> = [pumpJSON()]
213
+
214
+ for (const [streamId, stream] of rawStreams) {
215
+ pumps.push(pumpRawStream(streamId, stream))
166
216
  }
167
217
 
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
- }
218
+ // Add late stream pump (returns array of spawned pump promises)
219
+ if (lateStreamSource) {
220
+ pumps.push(pumpLateStreams())
197
221
  }
198
222
 
199
- // Start all pumps concurrently
200
- pumpJSON()
201
- for (const [streamId, stream] of rawStreams) {
202
- pumpRawStream(streamId, stream)
223
+ try {
224
+ // Wait for initial pumps to complete
225
+ const results = await Promise.all(pumps)
226
+
227
+ // Wait for any late stream pumps that were spawned
228
+ const latePumps = results.find(Array.isArray) as
229
+ | Array<Promise<void>>
230
+ | undefined
231
+ if (latePumps && latePumps.length > 0) {
232
+ await Promise.all(latePumps)
233
+ }
234
+
235
+ // All pumps done - close the output stream
236
+ if (!cancelled) {
237
+ try {
238
+ controller.close()
239
+ } catch {
240
+ // Already closed
241
+ }
242
+ }
243
+ } catch {
244
+ // Error already handled by errorOutput in pumpJSON
245
+ // or was a raw stream error (non-fatal, already sent ERROR frame)
203
246
  }
204
247
  },
205
248
 
206
249
  cancel() {
207
250
  cancelled = true
208
- controllerRef = null
209
- // Proactively cancel all underlying readers to stop work quickly.
210
- for (const cancelReader of cancelReaders) {
211
- cancelReader()
251
+ // Cancel all readers to stop pumps quickly
252
+ for (const reader of readers) {
253
+ reader.cancel().catch(() => {})
212
254
  }
213
- cancelReaders.length = 0
255
+ readers.length = 0
214
256
  },
215
257
  })
216
258
  }
@@ -17,7 +17,7 @@ export const ServerFunctionSerializationAdapter = createSerializationAdapter({
17
17
  // When a function ID is received through serialization (e.g., as a parameter
18
18
  // to another server function), it originates from the client and must be
19
19
  // validated the same way as direct HTTP calls to server functions.
20
- const serverFn = await getServerFnById(functionId, { fromClient: true })
20
+ const serverFn = await getServerFnById(functionId, { origin: 'client' })
21
21
  const result = await serverFn(opts ?? {}, signal)
22
22
  return result.result
23
23
  }
@@ -18,14 +18,12 @@ import {
18
18
  TSS_CONTENT_TYPE_FRAMED_VERSIONED,
19
19
  createMultiplexedStream,
20
20
  } from './frame-protocol'
21
+ import type { LateStreamRegistration } from './frame-protocol'
21
22
  import type { Plugin as SerovalPlugin } from 'seroval'
22
23
 
23
24
  // Cache serovalPlugins at module level to avoid repeated calls
24
25
  let serovalPlugins: Array<SerovalPlugin<any, any>> | undefined = undefined
25
26
 
26
- // Cache TextEncoder for NDJSON serialization
27
- const textEncoder = new TextEncoder()
28
-
29
27
  // Known FormData 'Content-Type' header values - module-level constant
30
28
  const FORM_DATA_CONTENT_TYPES = [
31
29
  'multipart/form-data',
@@ -48,7 +46,7 @@ export const handleServerAction = async ({
48
46
  const methodUpper = method.toUpperCase()
49
47
  const url = new URL(request.url)
50
48
 
51
- const action = await getServerFnById(serverFnId, { fromClient: true })
49
+ const action = await getServerFnById(serverFnId, { origin: 'client' })
52
50
 
53
51
  // Early method check: reject mismatched HTTP methods before parsing
54
52
  // the request payload (FormData, JSON, query string, etc.)
@@ -117,8 +115,8 @@ export const handleServerAction = async ({
117
115
  deserializedContext
118
116
  ) {
119
117
  params.context = safeObjectMerge(
120
- context,
121
118
  deserializedContext as Record<string, unknown>,
119
+ context,
122
120
  )
123
121
  }
124
122
  } catch (e) {
@@ -144,7 +142,7 @@ export const handleServerAction = async ({
144
142
  const payload: any = payloadParam
145
143
  ? parsePayload(JSON.parse(payloadParam))
146
144
  : {}
147
- payload.context = safeObjectMerge(context, payload.context)
145
+ payload.context = safeObjectMerge(payload.context, context)
148
146
  payload.method = methodUpper
149
147
  // Send it through!
150
148
  return await action(payload)
@@ -186,11 +184,40 @@ export const handleServerAction = async ({
186
184
 
187
185
  const alsResponse = getResponse()
188
186
  if (res !== undefined) {
189
- // Collect raw streams encountered during serialization
187
+ // Collect raw streams encountered during initial synchronous serialization
190
188
  const rawStreams = new Map<number, ReadableStream<Uint8Array>>()
189
+
190
+ // Track whether we're still in the initial synchronous phase
191
+ // After initial phase, new RawStreams go to lateStreamWriter
192
+ let initialPhase = true
193
+
194
+ // Late stream registration for RawStreams discovered after initial pass
195
+ // (e.g., from resolved Promises)
196
+ let lateStreamWriter:
197
+ | WritableStreamDefaultWriter<LateStreamRegistration>
198
+ | undefined
199
+ let lateStreamReadable:
200
+ | ReadableStream<LateStreamRegistration>
201
+ | undefined = undefined
202
+ const pendingLateStreams: Array<LateStreamRegistration> = []
203
+
191
204
  const rawStreamPlugin = createRawStreamRPCPlugin(
192
205
  (id: number, stream: ReadableStream<Uint8Array>) => {
193
- rawStreams.set(id, stream)
206
+ if (initialPhase) {
207
+ rawStreams.set(id, stream)
208
+ return
209
+ }
210
+
211
+ if (lateStreamWriter) {
212
+ // Late stream - write to the late stream channel
213
+ lateStreamWriter.write({ id, stream }).catch(() => {
214
+ // Ignore write errors - stream may be closed
215
+ })
216
+ return
217
+ }
218
+
219
+ // Discovered after initial phase but before writer exists.
220
+ pendingLateStreams.push({ id, stream })
194
221
  },
195
222
  )
196
223
 
@@ -228,6 +255,13 @@ export const handleServerAction = async ({
228
255
  },
229
256
  })
230
257
 
258
+ // End of initial synchronous phase - any new RawStreams are "late"
259
+ initialPhase = false
260
+
261
+ // If any RawStreams are discovered after this point but before the
262
+ // late-stream writer exists, we buffer them and flush once the writer
263
+ // is ready. This avoids an occasional missed-stream race.
264
+
231
265
  // If no raw streams and done synchronously, return simple JSON
232
266
  if (done && rawStreams.size === 0) {
233
267
  return new Response(
@@ -243,71 +277,87 @@ export const handleServerAction = async ({
243
277
  )
244
278
  }
245
279
 
246
- // If we have raw streams, use framed protocol
247
- if (rawStreams.size > 0) {
248
- // Create a stream of JSON chunks (NDJSON style)
249
- const jsonStream = new ReadableStream<string>({
250
- start(controller) {
251
- callbacks.onParse = (value) => {
252
- controller.enqueue(JSON.stringify(value) + '\n')
253
- }
254
- callbacks.onDone = () => {
255
- try {
256
- controller.close()
257
- } catch {
258
- // Already closed
259
- }
260
- }
261
- callbacks.onError = (error) => controller.error(error)
262
- // Emit initial body if we have one
263
- if (nonStreamingBody !== undefined) {
264
- callbacks.onParse(nonStreamingBody)
265
- }
266
- },
267
- })
268
-
269
- // Create multiplexed stream with JSON and raw streams
270
- const multiplexedStream = createMultiplexedStream(
271
- jsonStream,
272
- rawStreams,
273
- )
274
-
275
- return new Response(multiplexedStream, {
276
- status: alsResponse.status,
277
- statusText: alsResponse.statusText,
278
- headers: {
279
- 'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED,
280
- [X_TSS_SERIALIZED]: 'true',
281
- },
280
+ // Not done synchronously or has raw streams - use framed protocol
281
+ // This supports late RawStreams from resolved Promises
282
+ const { readable, writable } =
283
+ new TransformStream<LateStreamRegistration>()
284
+ lateStreamReadable = readable
285
+ lateStreamWriter = writable.getWriter()
286
+
287
+ // Flush any late streams that were discovered in the small window
288
+ // between end of initial serialization and writer setup.
289
+ for (const registration of pendingLateStreams) {
290
+ lateStreamWriter.write(registration).catch(() => {
291
+ // Ignore write errors - stream may be closed
282
292
  })
283
293
  }
294
+ pendingLateStreams.length = 0
284
295
 
285
- // No raw streams but not done yet - use standard NDJSON streaming
286
- const stream = new ReadableStream({
296
+ // Create a stream of JSON chunks
297
+ const jsonStream = new ReadableStream<string>({
287
298
  start(controller) {
288
- callbacks.onParse = (value) =>
289
- controller.enqueue(
290
- textEncoder.encode(JSON.stringify(value) + '\n'),
291
- )
299
+ callbacks.onParse = (value) => {
300
+ controller.enqueue(JSON.stringify(value) + '\n')
301
+ }
292
302
  callbacks.onDone = () => {
293
303
  try {
294
304
  controller.close()
295
- } catch (error) {
296
- controller.error(error)
305
+ } catch {
306
+ // Already closed
297
307
  }
308
+ // Close late stream writer when JSON serialization is done
309
+ // Any RawStreams not yet discovered won't be sent
310
+ lateStreamWriter
311
+ ?.close()
312
+ .catch(() => {
313
+ // Ignore close errors
314
+ })
315
+ .finally(() => {
316
+ lateStreamWriter = undefined
317
+ })
298
318
  }
299
- callbacks.onError = (error) => controller.error(error)
300
- // stream initial body
319
+
320
+ callbacks.onError = (error) => {
321
+ controller.error(error)
322
+ lateStreamWriter
323
+ ?.abort(error)
324
+ .catch(() => {
325
+ // Ignore abort errors
326
+ })
327
+ .finally(() => {
328
+ lateStreamWriter = undefined
329
+ })
330
+ }
331
+
332
+ // Emit initial body if we have one
301
333
  if (nonStreamingBody !== undefined) {
302
334
  callbacks.onParse(nonStreamingBody)
303
335
  }
336
+ // If serialization already completed synchronously, close now
337
+ // This handles the case where onDone was called during toCrossJSONStream
338
+ // before we overwrote callbacks.onDone
339
+ if (done) {
340
+ callbacks.onDone()
341
+ }
342
+ },
343
+ cancel() {
344
+ lateStreamWriter?.abort().catch(() => {})
345
+ lateStreamWriter = undefined
304
346
  },
305
347
  })
306
- return new Response(stream, {
348
+
349
+ // Create multiplexed stream with JSON, initial raw streams, and late streams
350
+ const multiplexedStream = createMultiplexedStream(
351
+ jsonStream,
352
+ rawStreams,
353
+ lateStreamReadable,
354
+ )
355
+
356
+ return new Response(multiplexedStream, {
307
357
  status: alsResponse.status,
308
358
  statusText: alsResponse.statusText,
309
359
  headers: {
310
- 'Content-Type': 'application/x-ndjson',
360
+ 'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED,
311
361
  [X_TSS_SERIALIZED]: 'true',
312
362
  },
313
363
  })
@@ -11,12 +11,14 @@ declare module 'tanstack-start-route-tree:v' {
11
11
  }
12
12
 
13
13
  declare module '#tanstack-start-server-fn-resolver' {
14
+ export type ServerFnLookupAccess = { origin: 'client' } | { origin: 'server' }
15
+
14
16
  export type ServerFn = ((...args: Array<any>) => Promise<any>) & {
15
17
  method?: 'GET' | 'POST'
16
18
  }
17
19
  export function getServerFnById(
18
20
  id: string,
19
- opts?: { fromClient?: boolean },
21
+ access: ServerFnLookupAccess,
20
22
  ): Promise<ServerFn>
21
23
  }
22
24
 
@@ -445,16 +445,15 @@ export async function transformManifestAssets(
445
445
  }),
446
446
  )
447
447
 
448
- const rootRoute = manifest.routes[rootRouteId]
449
- if (rootRoute) {
450
- rootRoute.assets = rootRoute.assets || []
451
- rootRoute.assets.push(
452
- buildClientEntryScriptTag(
453
- transformedClientEntry.href,
454
- source.injectedHeadScripts,
455
- ),
456
- )
457
- }
448
+ const rootRoute = (manifest.routes[rootRouteId] =
449
+ manifest.routes[rootRouteId] || {})
450
+ rootRoute.assets = rootRoute.assets || []
451
+ rootRoute.assets.push(
452
+ buildClientEntryScriptTag(
453
+ transformedClientEntry.href,
454
+ source.injectedHeadScripts,
455
+ ),
456
+ )
458
457
 
459
458
  return manifest
460
459
  }
@@ -476,14 +475,10 @@ export function buildManifestWithClientEntry(
476
475
  const baseRootRoute = source.manifest.routes[rootRouteId]
477
476
  const routes = {
478
477
  ...source.manifest.routes,
479
- ...(baseRootRoute
480
- ? {
481
- [rootRouteId]: {
482
- ...baseRootRoute,
483
- assets: [...(baseRootRoute.assets || []), scriptTag],
484
- },
485
- }
486
- : {}),
478
+ [rootRouteId]: {
479
+ ...baseRootRoute,
480
+ assets: [...(baseRootRoute?.assets || []), scriptTag],
481
+ },
487
482
  }
488
483
 
489
484
  return { routes }
@@ -2,4 +2,5 @@ export const VIRTUAL_MODULES = {
2
2
  startManifest: 'tanstack-start-manifest:v',
3
3
  injectedHeadScripts: 'tanstack-start-injected-head-scripts:v',
4
4
  serverFnResolver: '#tanstack-start-server-fn-resolver',
5
+ pluginAdapters: '#tanstack-start-plugin-adapters',
5
6
  } as const
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2021-present Tanner Linsley
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.