@tanstack/start-client-core 1.167.10 → 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.
Files changed (31) hide show
  1. package/dist/esm/client/hydrateStart.js +3 -1
  2. package/dist/esm/client/hydrateStart.js.map +1 -1
  3. package/dist/esm/client-rpc/serverFnFetcher.d.ts +21 -0
  4. package/dist/esm/client-rpc/serverFnFetcher.js +94 -71
  5. package/dist/esm/client-rpc/serverFnFetcher.js.map +1 -1
  6. package/dist/esm/fake-entries/plugin-adapters.d.ts +3 -0
  7. package/dist/esm/fake-entries/plugin-adapters.js +7 -0
  8. package/dist/esm/fake-entries/plugin-adapters.js.map +1 -0
  9. package/dist/esm/fake-entries/router.d.ts +1 -0
  10. package/dist/esm/fake-entries/router.js +6 -0
  11. package/dist/esm/fake-entries/router.js.map +1 -0
  12. package/dist/esm/{fake-start-entry.d.ts → fake-entries/start.d.ts} +0 -1
  13. package/dist/esm/fake-entries/start.js +6 -0
  14. package/dist/esm/fake-entries/start.js.map +1 -0
  15. package/dist/esm/getDefaultSerovalPlugins.d.ts +2 -1
  16. package/dist/esm/getDefaultSerovalPlugins.js.map +1 -1
  17. package/dist/esm/index.d.ts +1 -0
  18. package/dist/esm/index.js +2 -1
  19. package/package.json +7 -4
  20. package/src/client/hydrateStart.ts +10 -4
  21. package/src/client-rpc/serverFnFetcher.ts +132 -103
  22. package/src/fake-entries/plugin-adapters.ts +4 -0
  23. package/src/fake-entries/router.ts +1 -0
  24. package/src/{fake-start-entry.ts → fake-entries/start.ts} +0 -1
  25. package/src/getDefaultSerovalPlugins.ts +2 -1
  26. package/src/index.tsx +1 -0
  27. package/src/start-entry.d.ts +7 -0
  28. package/src/tests/createServerFn.test-d.ts +8 -2
  29. package/src/tests/createServerMiddleware.test-d.ts +9 -3
  30. package/dist/esm/fake-start-entry.js +0 -7
  31. package/dist/esm/fake-start-entry.js.map +0 -1
@@ -1,7 +1,8 @@
1
1
  import { ServerFunctionSerializationAdapter } from "./ServerFunctionSerializationAdapter.js";
2
2
  import { hydrate } from "@tanstack/router-core/ssr/client";
3
- import { getRouter } from "#tanstack-router-entry";
4
3
  import { startInstance } from "#tanstack-start-entry";
4
+ import { hasPluginAdapters, pluginSerializationAdapters } from "#tanstack-start-plugin-adapters";
5
+ import { getRouter } from "#tanstack-router-entry";
5
6
  //#region src/client/hydrateStart.ts
6
7
  async function hydrateStart() {
7
8
  const router = await getRouter();
@@ -16,6 +17,7 @@ async function hydrateStart() {
16
17
  serializationAdapters = [];
17
18
  window.__TSS_START_OPTIONS__ = { serializationAdapters };
18
19
  }
20
+ if (hasPluginAdapters) serializationAdapters.push(...pluginSerializationAdapters);
19
21
  serializationAdapters.push(ServerFunctionSerializationAdapter);
20
22
  if (router.options.serializationAdapters) serializationAdapters.push(...router.options.serializationAdapters);
21
23
  router.update({
@@ -1 +1 @@
1
- {"version":3,"file":"hydrateStart.js","names":[],"sources":["../../../src/client/hydrateStart.ts"],"sourcesContent":["import { hydrate } from '@tanstack/router-core/ssr/client'\n\nimport { ServerFunctionSerializationAdapter } from './ServerFunctionSerializationAdapter'\nimport type { AnyStartInstanceOptions } from '../createStart'\nimport type { AnyRouter, AnySerializationAdapter } from '@tanstack/router-core'\n// eslint-disable-next-line import/no-duplicates,import/order\nimport { getRouter } from '#tanstack-router-entry'\n// eslint-disable-next-line import/no-duplicates,import/order\nimport { startInstance } from '#tanstack-start-entry'\n\nexport async function hydrateStart(): Promise<AnyRouter> {\n const router = await getRouter()\n\n let serializationAdapters: Array<AnySerializationAdapter>\n if (startInstance) {\n const startOptions = await startInstance.getOptions()\n startOptions.serializationAdapters =\n startOptions.serializationAdapters ?? []\n window.__TSS_START_OPTIONS__ = startOptions as AnyStartInstanceOptions\n serializationAdapters = startOptions.serializationAdapters\n router.options.defaultSsr = startOptions.defaultSsr\n } else {\n serializationAdapters = []\n window.__TSS_START_OPTIONS__ = {\n serializationAdapters,\n } as AnyStartInstanceOptions\n }\n\n serializationAdapters.push(ServerFunctionSerializationAdapter)\n if (router.options.serializationAdapters) {\n serializationAdapters.push(...router.options.serializationAdapters)\n }\n\n router.update({\n basepath: process.env.TSS_ROUTER_BASEPATH,\n ...{ serializationAdapters },\n })\n if (!router.stores.matchesId.state.length) {\n await hydrate(router)\n }\n\n return router\n}\n"],"mappings":";;;;;AAUA,eAAsB,eAAmC;CACvD,MAAM,SAAS,MAAM,WAAW;CAEhC,IAAI;AACJ,KAAI,eAAe;EACjB,MAAM,eAAe,MAAM,cAAc,YAAY;AACrD,eAAa,wBACX,aAAa,yBAAyB,EAAE;AAC1C,SAAO,wBAAwB;AAC/B,0BAAwB,aAAa;AACrC,SAAO,QAAQ,aAAa,aAAa;QACpC;AACL,0BAAwB,EAAE;AAC1B,SAAO,wBAAwB,EAC7B,uBACD;;AAGH,uBAAsB,KAAK,mCAAmC;AAC9D,KAAI,OAAO,QAAQ,sBACjB,uBAAsB,KAAK,GAAG,OAAO,QAAQ,sBAAsB;AAGrE,QAAO,OAAO;EACZ,UAAU,QAAQ,IAAI;EACjB;EACN,CAAC;AACF,KAAI,CAAC,OAAO,OAAO,UAAU,MAAM,OACjC,OAAM,QAAQ,OAAO;AAGvB,QAAO"}
1
+ {"version":3,"file":"hydrateStart.js","names":[],"sources":["../../../src/client/hydrateStart.ts"],"sourcesContent":["import { hydrate } from '@tanstack/router-core/ssr/client'\n\nimport { startInstance } from '#tanstack-start-entry'\nimport {\n hasPluginAdapters,\n pluginSerializationAdapters,\n} from '#tanstack-start-plugin-adapters'\nimport { getRouter } from '#tanstack-router-entry'\nimport { ServerFunctionSerializationAdapter } from './ServerFunctionSerializationAdapter'\nimport type { AnyStartInstanceOptions } from '../createStart'\nimport type { AnyRouter, AnySerializationAdapter } from '@tanstack/router-core'\n\nexport async function hydrateStart(): Promise<AnyRouter> {\n const router = await getRouter()\n\n let serializationAdapters: Array<AnySerializationAdapter>\n if (startInstance) {\n const startOptions = await startInstance.getOptions()\n startOptions.serializationAdapters =\n startOptions.serializationAdapters ?? []\n window.__TSS_START_OPTIONS__ = startOptions as AnyStartInstanceOptions\n serializationAdapters = startOptions.serializationAdapters\n router.options.defaultSsr = startOptions.defaultSsr\n } else {\n serializationAdapters = []\n window.__TSS_START_OPTIONS__ = {\n serializationAdapters,\n } as AnyStartInstanceOptions\n }\n\n // Only spread plugin adapters if any are configured (this will tree-shake away otherwise)\n if (hasPluginAdapters) {\n serializationAdapters.push(...pluginSerializationAdapters)\n }\n serializationAdapters.push(ServerFunctionSerializationAdapter)\n if (router.options.serializationAdapters) {\n serializationAdapters.push(...router.options.serializationAdapters)\n }\n\n router.update({\n basepath: process.env.TSS_ROUTER_BASEPATH,\n ...{ serializationAdapters },\n })\n if (!router.stores.matchesId.state.length) {\n await hydrate(router)\n }\n\n return router\n}\n"],"mappings":";;;;;;AAYA,eAAsB,eAAmC;CACvD,MAAM,SAAS,MAAM,WAAW;CAEhC,IAAI;AACJ,KAAI,eAAe;EACjB,MAAM,eAAe,MAAM,cAAc,YAAY;AACrD,eAAa,wBACX,aAAa,yBAAyB,EAAE;AAC1C,SAAO,wBAAwB;AAC/B,0BAAwB,aAAa;AACrC,SAAO,QAAQ,aAAa,aAAa;QACpC;AACL,0BAAwB,EAAE;AAC1B,SAAO,wBAAwB,EAC7B,uBACD;;AAIH,KAAI,kBACF,uBAAsB,KAAK,GAAG,4BAA4B;AAE5D,uBAAsB,KAAK,mCAAmC;AAC9D,KAAI,OAAO,QAAQ,sBACjB,uBAAsB,KAAK,GAAG,OAAO,QAAQ,sBAAsB;AAGrE,QAAO,OAAO;EACZ,UAAU,QAAQ,IAAI;EACjB;EACN,CAAC;AACF,KAAI,CAAC,OAAO,OAAO,UAAU,MAAM,OACjC,OAAM,QAAQ,OAAO;AAGvB,QAAO"}
@@ -1 +1,22 @@
1
+ /**
2
+ * Set the current post-processing context for async deserialization work.
3
+ * Called before deserialization starts.
4
+ *
5
+ * @param ctx - Array to collect async work promises, or null to clear
6
+ */
7
+ export declare function setPostProcessContext(ctx: Array<Promise<unknown>> | null): void;
8
+ /**
9
+ * Get the current post-processing context.
10
+ * Returns null if no deserialization is in progress.
11
+ */
12
+ export declare function getPostProcessContext(): Array<Promise<unknown>> | null;
13
+ /**
14
+ * Track an async post-processing promise in the current deserialization context.
15
+ * Called by deserializers that need to perform async work after sync deserialization.
16
+ *
17
+ * If no context is active (e.g., on server), this is a no-op.
18
+ *
19
+ * @param promise - The async work promise to track
20
+ */
21
+ export declare function trackPostProcessPromise(promise: Promise<unknown>): void;
1
22
  export declare function serverFnFetcher(url: string, args: Array<any>, handler: (url: string, requestInit: RequestInit) => Promise<Response>): Promise<any>;
@@ -6,6 +6,50 @@ import { fromCrossJSON, toJSONAsync } from "seroval";
6
6
  //#region src/client-rpc/serverFnFetcher.ts
7
7
  var serovalPlugins = null;
8
8
  /**
9
+ * Current async post-processing context for deserialization.
10
+ *
11
+ * Some deserializers need to perform async work after synchronous deserialization
12
+ * (e.g., decoding RSC payloads, fetching remote data). This context allows them
13
+ * to register promises that must complete before the deserialized value is used.
14
+ *
15
+ * This uses a synchronous execution context pattern:
16
+ * - Each call to `fromCrossJSON` is synchronous
17
+ * - Within that synchronous execution, all `fromSerializable` calls happen
18
+ * - We set the context before `fromCrossJSON`, clear it after
19
+ * - For streaming chunks, we set/clear context around each `onMessage` call
20
+ *
21
+ * Even with concurrent server function calls, each individual deserialization
22
+ * is atomic (synchronous), so promises are correctly scoped to their call.
23
+ */
24
+ var currentPostProcessContext = null;
25
+ /**
26
+ * Set the current post-processing context for async deserialization work.
27
+ * Called before deserialization starts.
28
+ *
29
+ * @param ctx - Array to collect async work promises, or null to clear
30
+ */
31
+ function setPostProcessContext(ctx) {
32
+ currentPostProcessContext = ctx;
33
+ }
34
+ /**
35
+ * Track an async post-processing promise in the current deserialization context.
36
+ * Called by deserializers that need to perform async work after sync deserialization.
37
+ *
38
+ * If no context is active (e.g., on server), this is a no-op.
39
+ *
40
+ * @param promise - The async work promise to track
41
+ */
42
+ function trackPostProcessPromise(promise) {
43
+ if (currentPostProcessContext) currentPostProcessContext.push(promise);
44
+ }
45
+ /**
46
+ * Helper to await all post-processing promises.
47
+ * Uses Promise.allSettled to ensure all promises complete even if some reject.
48
+ */
49
+ async function awaitPostProcessPromises(promises) {
50
+ if (promises.length > 0) await Promise.allSettled(promises);
51
+ }
52
+ /**
9
53
  * Checks if an object has at least one own enumerable property.
10
54
  * More efficient than Object.keys(obj).length > 0 as it short-circuits on first property.
11
55
  */
@@ -116,19 +160,17 @@ async function getResponse(fn) {
116
160
  console.error(msg, error);
117
161
  }
118
162
  });
119
- } else if (contentType.includes("application/x-ndjson")) {
120
- const refs = /* @__PURE__ */ new Map();
121
- result = await processServerFnResponse({
122
- response,
123
- onMessage: (msg) => fromCrossJSON(msg, {
124
- refs,
125
- plugins: serovalPlugins
126
- }),
127
- onError(msg, error) {
128
- console.error(msg, error);
129
- }
130
- });
131
- } else if (contentType.includes("application/json")) result = fromCrossJSON(await response.json(), { plugins: serovalPlugins });
163
+ } else if (contentType.includes("application/json")) {
164
+ const jsonPayload = await response.json();
165
+ const postProcessPromises = [];
166
+ setPostProcessContext(postProcessPromises);
167
+ try {
168
+ result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins });
169
+ } finally {
170
+ setPostProcessContext(null);
171
+ }
172
+ await awaitPostProcessPromises(postProcessPromises);
173
+ }
132
174
  if (!result) {
133
175
  if (process.env.NODE_ENV !== "production") throw new Error("Invariant failed: expected result to be resolved");
134
176
  invariant();
@@ -146,86 +188,67 @@ async function getResponse(fn) {
146
188
  if (!response.ok) throw new Error(await response.text());
147
189
  return response;
148
190
  }
149
- async function processServerFnResponse({ response, onMessage, onError }) {
150
- if (!response.body) throw new Error("No response body");
151
- const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
152
- let buffer = "";
153
- let firstRead = false;
154
- let firstObject;
155
- while (!firstRead) {
156
- const { value, done } = await reader.read();
157
- if (value) buffer += value;
158
- if (buffer.length === 0 && done) throw new Error("Stream ended before first object");
159
- if (buffer.endsWith("\n")) {
160
- const lines = buffer.split("\n").filter(Boolean);
161
- const firstLine = lines[0];
162
- if (!firstLine) throw new Error("No JSON line in the first chunk");
163
- firstObject = JSON.parse(firstLine);
164
- firstRead = true;
165
- buffer = lines.slice(1).join("\n");
166
- } else {
167
- const newlineIndex = buffer.indexOf("\n");
168
- if (newlineIndex >= 0) {
169
- const line = buffer.slice(0, newlineIndex).trim();
170
- buffer = buffer.slice(newlineIndex + 1);
171
- if (line.length > 0) {
172
- firstObject = JSON.parse(line);
173
- firstRead = true;
174
- }
175
- }
176
- }
177
- }
178
- (async () => {
179
- try {
180
- while (true) {
181
- const { value, done } = await reader.read();
182
- if (value) buffer += value;
183
- const lastNewline = buffer.lastIndexOf("\n");
184
- if (lastNewline >= 0) {
185
- const chunk = buffer.slice(0, lastNewline);
186
- buffer = buffer.slice(lastNewline + 1);
187
- const lines = chunk.split("\n").filter(Boolean);
188
- for (const line of lines) try {
189
- onMessage(JSON.parse(line));
190
- } catch (e) {
191
- onError?.(`Invalid JSON line: ${line}`, e);
192
- }
193
- }
194
- if (done) break;
195
- }
196
- } catch (err) {
197
- onError?.("Stream processing error:", err);
198
- }
199
- })();
200
- return onMessage(firstObject);
201
- }
202
191
  /**
203
192
  * Processes a framed response where each JSON chunk is a complete JSON string
204
193
  * (already decoded by frame decoder).
194
+ *
195
+ * Uses per-chunk post-processing context to ensure async deserialization work
196
+ * completes before the next chunk is processed. This prevents issues when
197
+ * streaming values require async post-processing (e.g., RSC decoding).
205
198
  */
206
199
  async function processFramedResponse({ jsonStream, onMessage, onError }) {
207
200
  const reader = jsonStream.getReader();
208
201
  const { value: firstValue, done: firstDone } = await reader.read();
209
202
  if (firstDone || !firstValue) throw new Error("Stream ended before first object");
210
203
  const firstObject = JSON.parse(firstValue);
211
- (async () => {
204
+ let drainCancelled = false;
205
+ const drain = (async () => {
212
206
  try {
213
207
  while (true) {
214
208
  const { value, done } = await reader.read();
215
209
  if (done) break;
216
210
  if (value) try {
217
- onMessage(JSON.parse(value));
211
+ const chunkPostProcessPromises = [];
212
+ setPostProcessContext(chunkPostProcessPromises);
213
+ try {
214
+ onMessage(JSON.parse(value));
215
+ } finally {
216
+ setPostProcessContext(null);
217
+ }
218
+ await awaitPostProcessPromises(chunkPostProcessPromises);
218
219
  } catch (e) {
219
220
  onError?.(`Invalid JSON: ${value}`, e);
220
221
  }
221
222
  }
222
223
  } catch (err) {
223
- onError?.("Stream processing error:", err);
224
+ if (!drainCancelled) onError?.("Stream processing error:", err);
224
225
  }
225
226
  })();
226
- return onMessage(firstObject);
227
+ let result;
228
+ const initialPostProcessPromises = [];
229
+ setPostProcessContext(initialPostProcessPromises);
230
+ try {
231
+ result = onMessage(firstObject);
232
+ } catch (err) {
233
+ setPostProcessContext(null);
234
+ drainCancelled = true;
235
+ reader.cancel().catch(() => {});
236
+ throw err;
237
+ }
238
+ setPostProcessContext(null);
239
+ await awaitPostProcessPromises(initialPostProcessPromises);
240
+ Promise.resolve(result).catch(() => {
241
+ drainCancelled = true;
242
+ reader.cancel().catch(() => {});
243
+ });
244
+ drain.finally(() => {
245
+ try {
246
+ reader.releaseLock();
247
+ } catch {}
248
+ });
249
+ return result;
227
250
  }
228
251
  //#endregion
229
- export { serverFnFetcher };
252
+ export { serverFnFetcher, trackPostProcessPromise };
230
253
 
231
254
  //# sourceMappingURL=serverFnFetcher.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"serverFnFetcher.js","names":[],"sources":["../../../src/client-rpc/serverFnFetcher.ts"],"sourcesContent":["import {\n createRawStreamDeserializePlugin,\n encode,\n invariant,\n isNotFound,\n parseRedirect,\n} from '@tanstack/router-core'\nimport { fromCrossJSON, toJSONAsync } from 'seroval'\nimport { getDefaultSerovalPlugins } from '../getDefaultSerovalPlugins'\nimport {\n TSS_CONTENT_TYPE_FRAMED,\n TSS_FORMDATA_CONTEXT,\n X_TSS_RAW_RESPONSE,\n X_TSS_SERIALIZED,\n validateFramedProtocolVersion,\n} from '../constants'\nimport { createFrameDecoder } from './frame-decoder'\nimport type { FunctionMiddlewareClientFnOptions } from '../createMiddleware'\nimport type { Plugin as SerovalPlugin } from 'seroval'\n\nlet serovalPlugins: Array<SerovalPlugin<any, any>> | null = null\n\n/**\n * Checks if an object has at least one own enumerable property.\n * More efficient than Object.keys(obj).length > 0 as it short-circuits on first property.\n */\nconst hop = Object.prototype.hasOwnProperty\nfunction hasOwnProperties(obj: object): boolean {\n for (const _ in obj) {\n if (hop.call(obj, _)) {\n return true\n }\n }\n return false\n}\n// caller =>\n// serverFnFetcher =>\n// client =>\n// server =>\n// fn =>\n// seroval =>\n// client middleware =>\n// serverFnFetcher =>\n// caller\n\nexport async function serverFnFetcher(\n url: string,\n args: Array<any>,\n handler: (url: string, requestInit: RequestInit) => Promise<Response>,\n) {\n if (!serovalPlugins) {\n serovalPlugins = getDefaultSerovalPlugins()\n }\n const _first = args[0]\n\n const first = _first as FunctionMiddlewareClientFnOptions<any, any, any> & {\n headers?: HeadersInit\n }\n\n // Use custom fetch if provided, otherwise fall back to the passed handler (global fetch)\n const fetchImpl = first.fetch ?? handler\n\n const type = first.data instanceof FormData ? 'formData' : 'payload'\n\n // Arrange the headers\n const headers = first.headers ? new Headers(first.headers) : new Headers()\n headers.set('x-tsr-serverFn', 'true')\n\n if (type === 'payload') {\n headers.set(\n 'accept',\n `${TSS_CONTENT_TYPE_FRAMED}, application/x-ndjson, application/json`,\n )\n }\n\n // If the method is GET, we need to move the payload to the query string\n if (first.method === 'GET') {\n if (type === 'formData') {\n throw new Error('FormData is not supported with GET requests')\n }\n const serializedPayload = await serializePayload(first)\n if (serializedPayload !== undefined) {\n const encodedPayload = encode({\n payload: serializedPayload,\n })\n if (url.includes('?')) {\n url += `&${encodedPayload}`\n } else {\n url += `?${encodedPayload}`\n }\n }\n }\n\n let body = undefined\n if (first.method === 'POST') {\n const fetchBody = await getFetchBody(first)\n if (fetchBody?.contentType) {\n headers.set('content-type', fetchBody.contentType)\n }\n body = fetchBody?.body\n }\n\n return await getResponse(async () =>\n fetchImpl(url, {\n method: first.method,\n headers,\n signal: first.signal,\n body,\n }),\n )\n}\n\nasync function serializePayload(\n opts: FunctionMiddlewareClientFnOptions<any, any, any>,\n): Promise<string | undefined> {\n let payloadAvailable = false\n const payloadToSerialize: any = {}\n if (opts.data !== undefined) {\n payloadAvailable = true\n payloadToSerialize['data'] = opts.data\n }\n\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (opts.context && hasOwnProperties(opts.context)) {\n payloadAvailable = true\n payloadToSerialize['context'] = opts.context\n }\n\n if (payloadAvailable) {\n return serialize(payloadToSerialize)\n }\n return undefined\n}\n\nasync function serialize(data: any) {\n return JSON.stringify(\n await Promise.resolve(toJSONAsync(data, { plugins: serovalPlugins! })),\n )\n}\n\nasync function getFetchBody(\n opts: FunctionMiddlewareClientFnOptions<any, any, any>,\n): Promise<{ body: FormData | string; contentType?: string } | undefined> {\n if (opts.data instanceof FormData) {\n let serializedContext = undefined\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (opts.context && hasOwnProperties(opts.context)) {\n serializedContext = await serialize(opts.context)\n }\n if (serializedContext !== undefined) {\n opts.data.set(TSS_FORMDATA_CONTEXT, serializedContext)\n }\n return { body: opts.data }\n }\n const serializedBody = await serializePayload(opts)\n if (serializedBody) {\n return { body: serializedBody, contentType: 'application/json' }\n }\n return undefined\n}\n\n/**\n * Retrieves a response from a given function and manages potential errors\n * and special response types including redirects and not found errors.\n *\n * @param fn - The function to execute for obtaining the response.\n * @returns The processed response from the function.\n * @throws If the response is invalid or an error occurs during processing.\n */\nasync function getResponse(fn: () => Promise<Response>) {\n let response: Response\n try {\n response = await fn() // client => server => fn => server => client\n } catch (error) {\n if (error instanceof Response) {\n response = error\n } else {\n console.log(error)\n throw error\n }\n }\n\n if (response.headers.get(X_TSS_RAW_RESPONSE) === 'true') {\n return response\n }\n\n const contentType = response.headers.get('content-type')\n if (!contentType) {\n if (process.env.NODE_ENV !== 'production') {\n throw new Error(\n 'Invariant failed: expected content-type header to be set',\n )\n }\n\n invariant()\n }\n const serializedByStart = !!response.headers.get(X_TSS_SERIALIZED)\n\n // If the response is serialized by the start server, we need to process it\n // differently than a normal response.\n if (serializedByStart) {\n let result\n\n // If it's a framed response (contains RawStream), use frame decoder\n if (contentType.includes(TSS_CONTENT_TYPE_FRAMED)) {\n // Validate protocol version compatibility\n validateFramedProtocolVersion(contentType)\n\n if (!response.body) {\n throw new Error('No response body for framed response')\n }\n\n const { getOrCreateStream, jsonChunks } = createFrameDecoder(\n response.body,\n )\n\n // Create deserialize plugin that wires up the raw streams\n const rawStreamPlugin =\n createRawStreamDeserializePlugin(getOrCreateStream)\n const plugins = [rawStreamPlugin, ...(serovalPlugins || [])]\n\n const refs = new Map()\n result = await processFramedResponse({\n jsonStream: jsonChunks,\n onMessage: (msg: any) => fromCrossJSON(msg, { refs, plugins }),\n onError(msg, error) {\n console.error(msg, error)\n },\n })\n }\n // If it's a stream from the start serializer, process it as such\n else if (contentType.includes('application/x-ndjson')) {\n const refs = new Map()\n result = await processServerFnResponse({\n response,\n onMessage: (msg) =>\n fromCrossJSON(msg, { refs, plugins: serovalPlugins! }),\n onError(msg, error) {\n // TODO how could we notify consumer that an error occurred?\n console.error(msg, error)\n },\n })\n }\n // If it's a JSON response, it can be simpler\n else if (contentType.includes('application/json')) {\n const jsonPayload = await response.json()\n result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })\n }\n\n if (!result) {\n if (process.env.NODE_ENV !== 'production') {\n throw new Error('Invariant failed: expected result to be resolved')\n }\n\n invariant()\n }\n if (result instanceof Error) {\n throw result\n }\n\n return result\n }\n\n // If it wasn't processed by the start serializer, check\n // if it's JSON\n if (contentType.includes('application/json')) {\n const jsonPayload = await response.json()\n const redirect = parseRedirect(jsonPayload)\n if (redirect) {\n throw redirect\n }\n if (isNotFound(jsonPayload)) {\n throw jsonPayload\n }\n return jsonPayload\n }\n\n // Otherwise, if it's not OK, throw the content\n if (!response.ok) {\n throw new Error(await response.text())\n }\n\n // Or return the response itself\n return response\n}\n\nasync function processServerFnResponse({\n response,\n onMessage,\n onError,\n}: {\n response: Response\n onMessage: (msg: any) => any\n onError?: (msg: string, error?: any) => void\n}) {\n if (!response.body) {\n throw new Error('No response body')\n }\n\n const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()\n\n let buffer = ''\n let firstRead = false\n let firstObject\n\n while (!firstRead) {\n const { value, done } = await reader.read()\n if (value) buffer += value\n\n if (buffer.length === 0 && done) {\n throw new Error('Stream ended before first object')\n }\n\n // common case: buffer ends with newline\n if (buffer.endsWith('\\n')) {\n const lines = buffer.split('\\n').filter(Boolean)\n const firstLine = lines[0]\n if (!firstLine) throw new Error('No JSON line in the first chunk')\n firstObject = JSON.parse(firstLine)\n firstRead = true\n buffer = lines.slice(1).join('\\n')\n } else {\n // fallback: wait for a newline to parse first object safely\n const newlineIndex = buffer.indexOf('\\n')\n if (newlineIndex >= 0) {\n const line = buffer.slice(0, newlineIndex).trim()\n buffer = buffer.slice(newlineIndex + 1)\n if (line.length > 0) {\n firstObject = JSON.parse(line)\n firstRead = true\n }\n }\n }\n }\n\n // process rest of the stream asynchronously\n ;(async () => {\n try {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n while (true) {\n const { value, done } = await reader.read()\n if (value) buffer += value\n\n const lastNewline = buffer.lastIndexOf('\\n')\n if (lastNewline >= 0) {\n const chunk = buffer.slice(0, lastNewline)\n buffer = buffer.slice(lastNewline + 1)\n const lines = chunk.split('\\n').filter(Boolean)\n\n for (const line of lines) {\n try {\n onMessage(JSON.parse(line))\n } catch (e) {\n onError?.(`Invalid JSON line: ${line}`, e)\n }\n }\n }\n\n if (done) {\n break\n }\n }\n } catch (err) {\n onError?.('Stream processing error:', err)\n }\n })()\n\n return onMessage(firstObject)\n}\n\n/**\n * Processes a framed response where each JSON chunk is a complete JSON string\n * (already decoded by frame decoder).\n */\nasync function processFramedResponse({\n jsonStream,\n onMessage,\n onError,\n}: {\n jsonStream: ReadableStream<string>\n onMessage: (msg: any) => any\n onError?: (msg: string, error?: any) => void\n}) {\n const reader = jsonStream.getReader()\n\n // Read first JSON frame - this is the main result\n const { value: firstValue, done: firstDone } = await reader.read()\n if (firstDone || !firstValue) {\n throw new Error('Stream ended before first object')\n }\n\n // Each frame is a complete JSON string\n const firstObject = JSON.parse(firstValue)\n\n // Process remaining frames asynchronously (for streaming refs like RawStream)\n ;(async () => {\n try {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n while (true) {\n const { value, done } = await reader.read()\n if (done) break\n if (value) {\n try {\n onMessage(JSON.parse(value))\n } catch (e) {\n onError?.(`Invalid JSON: ${value}`, e)\n }\n }\n }\n } catch (err) {\n onError?.('Stream processing error:', err)\n }\n })()\n\n return onMessage(firstObject)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"serverFnFetcher.js","names":[],"sources":["../../../src/client-rpc/serverFnFetcher.ts"],"sourcesContent":["import {\n createRawStreamDeserializePlugin,\n encode,\n invariant,\n isNotFound,\n parseRedirect,\n} from '@tanstack/router-core'\nimport { fromCrossJSON, toJSONAsync } from 'seroval'\nimport { getDefaultSerovalPlugins } from '../getDefaultSerovalPlugins'\nimport {\n TSS_CONTENT_TYPE_FRAMED,\n TSS_FORMDATA_CONTEXT,\n X_TSS_RAW_RESPONSE,\n X_TSS_SERIALIZED,\n validateFramedProtocolVersion,\n} from '../constants'\nimport { createFrameDecoder } from './frame-decoder'\nimport type { FunctionMiddlewareClientFnOptions } from '../createMiddleware'\nimport type { Plugin as SerovalPlugin } from 'seroval'\n\nlet serovalPlugins: Array<SerovalPlugin<any, any>> | null = null\n\n/**\n * Current async post-processing context for deserialization.\n *\n * Some deserializers need to perform async work after synchronous deserialization\n * (e.g., decoding RSC payloads, fetching remote data). This context allows them\n * to register promises that must complete before the deserialized value is used.\n *\n * This uses a synchronous execution context pattern:\n * - Each call to `fromCrossJSON` is synchronous\n * - Within that synchronous execution, all `fromSerializable` calls happen\n * - We set the context before `fromCrossJSON`, clear it after\n * - For streaming chunks, we set/clear context around each `onMessage` call\n *\n * Even with concurrent server function calls, each individual deserialization\n * is atomic (synchronous), so promises are correctly scoped to their call.\n */\nlet currentPostProcessContext: Array<Promise<unknown>> | null = null\n\n/**\n * Set the current post-processing context for async deserialization work.\n * Called before deserialization starts.\n *\n * @param ctx - Array to collect async work promises, or null to clear\n */\nexport function setPostProcessContext(\n ctx: Array<Promise<unknown>> | null,\n): void {\n currentPostProcessContext = ctx\n}\n\n/**\n * Get the current post-processing context.\n * Returns null if no deserialization is in progress.\n */\nexport function getPostProcessContext(): Array<Promise<unknown>> | null {\n return currentPostProcessContext\n}\n\n/**\n * Track an async post-processing promise in the current deserialization context.\n * Called by deserializers that need to perform async work after sync deserialization.\n *\n * If no context is active (e.g., on server), this is a no-op.\n *\n * @param promise - The async work promise to track\n */\nexport function trackPostProcessPromise(promise: Promise<unknown>): void {\n if (currentPostProcessContext) {\n currentPostProcessContext.push(promise)\n }\n}\n\n/**\n * Helper to await all post-processing promises.\n * Uses Promise.allSettled to ensure all promises complete even if some reject.\n */\nasync function awaitPostProcessPromises(\n promises: Array<Promise<unknown>>,\n): Promise<void> {\n if (promises.length > 0) {\n await Promise.allSettled(promises)\n }\n}\n\n/**\n * Checks if an object has at least one own enumerable property.\n * More efficient than Object.keys(obj).length > 0 as it short-circuits on first property.\n */\nconst hop = Object.prototype.hasOwnProperty\nfunction hasOwnProperties(obj: object): boolean {\n for (const _ in obj) {\n if (hop.call(obj, _)) {\n return true\n }\n }\n return false\n}\n// caller =>\n// serverFnFetcher =>\n// client =>\n// server =>\n// fn =>\n// seroval =>\n// client middleware =>\n// serverFnFetcher =>\n// caller\n\nexport async function serverFnFetcher(\n url: string,\n args: Array<any>,\n handler: (url: string, requestInit: RequestInit) => Promise<Response>,\n) {\n if (!serovalPlugins) {\n serovalPlugins = getDefaultSerovalPlugins()\n }\n const _first = args[0]\n\n const first = _first as FunctionMiddlewareClientFnOptions<any, any, any> & {\n headers?: HeadersInit\n }\n\n // Use custom fetch if provided, otherwise fall back to the passed handler (global fetch)\n const fetchImpl = first.fetch ?? handler\n\n const type = first.data instanceof FormData ? 'formData' : 'payload'\n\n // Arrange the headers\n const headers = first.headers ? new Headers(first.headers) : new Headers()\n headers.set('x-tsr-serverFn', 'true')\n\n if (type === 'payload') {\n headers.set(\n 'accept',\n `${TSS_CONTENT_TYPE_FRAMED}, application/x-ndjson, application/json`,\n )\n }\n\n // If the method is GET, we need to move the payload to the query string\n if (first.method === 'GET') {\n if (type === 'formData') {\n throw new Error('FormData is not supported with GET requests')\n }\n const serializedPayload = await serializePayload(first)\n if (serializedPayload !== undefined) {\n const encodedPayload = encode({\n payload: serializedPayload,\n })\n if (url.includes('?')) {\n url += `&${encodedPayload}`\n } else {\n url += `?${encodedPayload}`\n }\n }\n }\n\n let body = undefined\n if (first.method === 'POST') {\n const fetchBody = await getFetchBody(first)\n if (fetchBody?.contentType) {\n headers.set('content-type', fetchBody.contentType)\n }\n body = fetchBody?.body\n }\n\n return await getResponse(async () =>\n fetchImpl(url, {\n method: first.method,\n headers,\n signal: first.signal,\n body,\n }),\n )\n}\n\nasync function serializePayload(\n opts: FunctionMiddlewareClientFnOptions<any, any, any>,\n): Promise<string | undefined> {\n let payloadAvailable = false\n const payloadToSerialize: any = {}\n if (opts.data !== undefined) {\n payloadAvailable = true\n payloadToSerialize['data'] = opts.data\n }\n\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (opts.context && hasOwnProperties(opts.context)) {\n payloadAvailable = true\n payloadToSerialize['context'] = opts.context\n }\n\n if (payloadAvailable) {\n return serialize(payloadToSerialize)\n }\n return undefined\n}\n\nasync function serialize(data: any) {\n return JSON.stringify(\n await Promise.resolve(toJSONAsync(data, { plugins: serovalPlugins! })),\n )\n}\n\nasync function getFetchBody(\n opts: FunctionMiddlewareClientFnOptions<any, any, any>,\n): Promise<{ body: FormData | string; contentType?: string } | undefined> {\n if (opts.data instanceof FormData) {\n let serializedContext = undefined\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (opts.context && hasOwnProperties(opts.context)) {\n serializedContext = await serialize(opts.context)\n }\n if (serializedContext !== undefined) {\n opts.data.set(TSS_FORMDATA_CONTEXT, serializedContext)\n }\n return { body: opts.data }\n }\n const serializedBody = await serializePayload(opts)\n if (serializedBody) {\n return { body: serializedBody, contentType: 'application/json' }\n }\n return undefined\n}\n\n/**\n * Retrieves a response from a given function and manages potential errors\n * and special response types including redirects and not found errors.\n *\n * @param fn - The function to execute for obtaining the response.\n * @returns The processed response from the function.\n * @throws If the response is invalid or an error occurs during processing.\n */\nasync function getResponse(fn: () => Promise<Response>) {\n let response: Response\n try {\n response = await fn() // client => server => fn => server => client\n } catch (error) {\n if (error instanceof Response) {\n response = error\n } else {\n console.log(error)\n throw error\n }\n }\n\n if (response.headers.get(X_TSS_RAW_RESPONSE) === 'true') {\n return response\n }\n\n const contentType = response.headers.get('content-type')\n if (!contentType) {\n if (process.env.NODE_ENV !== 'production') {\n throw new Error(\n 'Invariant failed: expected content-type header to be set',\n )\n }\n\n invariant()\n }\n const serializedByStart = !!response.headers.get(X_TSS_SERIALIZED)\n\n // If the response is serialized by the start server, we need to process it\n // differently than a normal response.\n if (serializedByStart) {\n let result\n\n // If it's a framed response (contains RawStream), use frame decoder\n if (contentType.includes(TSS_CONTENT_TYPE_FRAMED)) {\n // Validate protocol version compatibility\n validateFramedProtocolVersion(contentType)\n\n if (!response.body) {\n throw new Error('No response body for framed response')\n }\n\n const { getOrCreateStream, jsonChunks } = createFrameDecoder(\n response.body,\n )\n\n // Create deserialize plugin that wires up the raw streams\n const rawStreamPlugin =\n createRawStreamDeserializePlugin(getOrCreateStream)\n const plugins = [rawStreamPlugin, ...(serovalPlugins || [])]\n\n const refs = new Map()\n result = await processFramedResponse({\n jsonStream: jsonChunks,\n onMessage: (msg: any) => fromCrossJSON(msg, { refs, plugins }),\n onError(msg, error) {\n console.error(msg, error)\n },\n })\n }\n // If it's a JSON response, it can be simpler\n else if (contentType.includes('application/json')) {\n const jsonPayload = await response.json()\n // Track async post-processing work for this deserialization\n const postProcessPromises: Array<Promise<unknown>> = []\n setPostProcessContext(postProcessPromises)\n try {\n result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })\n } finally {\n setPostProcessContext(null)\n }\n // Await any async post-processing before returning\n await awaitPostProcessPromises(postProcessPromises)\n }\n\n if (!result) {\n if (process.env.NODE_ENV !== 'production') {\n throw new Error('Invariant failed: expected result to be resolved')\n }\n\n invariant()\n }\n if (result instanceof Error) {\n throw result\n }\n\n return result\n }\n\n // If it wasn't processed by the start serializer, check\n // if it's JSON\n if (contentType.includes('application/json')) {\n const jsonPayload = await response.json()\n const redirect = parseRedirect(jsonPayload)\n if (redirect) {\n throw redirect\n }\n if (isNotFound(jsonPayload)) {\n throw jsonPayload\n }\n return jsonPayload\n }\n\n // Otherwise, if it's not OK, throw the content\n if (!response.ok) {\n throw new Error(await response.text())\n }\n\n // Or return the response itself\n return response\n}\n\n/**\n * Processes a framed response where each JSON chunk is a complete JSON string\n * (already decoded by frame decoder).\n *\n * Uses per-chunk post-processing context to ensure async deserialization work\n * completes before the next chunk is processed. This prevents issues when\n * streaming values require async post-processing (e.g., RSC decoding).\n */\nasync function processFramedResponse({\n jsonStream,\n onMessage,\n onError,\n}: {\n jsonStream: ReadableStream<string>\n onMessage: (msg: any) => any\n onError?: (msg: string, error?: any) => void\n}) {\n const reader = jsonStream.getReader()\n\n // Read first JSON frame - this is the main result\n const { value: firstValue, done: firstDone } = await reader.read()\n if (firstDone || !firstValue) {\n throw new Error('Stream ended before first object')\n }\n\n // Each frame is a complete JSON string\n const firstObject = JSON.parse(firstValue)\n\n // Process remaining frames for streaming refs like RawStream.\n // Keep draining until the server closes the stream.\n // Each chunk gets its own post-processing context to properly scope async work.\n let drainCancelled = false as boolean\n const drain = (async () => {\n try {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n while (true) {\n const { value, done } = await reader.read()\n if (done) break\n if (value) {\n try {\n // Set up post-processing context for this chunk\n const chunkPostProcessPromises: Array<Promise<unknown>> = []\n setPostProcessContext(chunkPostProcessPromises)\n try {\n onMessage(JSON.parse(value))\n } finally {\n setPostProcessContext(null)\n }\n // Await any async post-processing from this chunk before processing next.\n // This ensures values requiring async work are ready before their\n // containing Promise/Stream resolves/emits to consumers.\n await awaitPostProcessPromises(chunkPostProcessPromises)\n } catch (e) {\n onError?.(`Invalid JSON: ${value}`, e)\n }\n }\n }\n } catch (err) {\n if (!drainCancelled) {\n onError?.('Stream processing error:', err)\n }\n }\n })()\n\n // Process first object with its own post-processing context\n let result: any\n const initialPostProcessPromises: Array<Promise<unknown>> = []\n setPostProcessContext(initialPostProcessPromises)\n try {\n result = onMessage(firstObject)\n } catch (err) {\n setPostProcessContext(null)\n drainCancelled = true\n reader.cancel().catch(() => {})\n throw err\n }\n setPostProcessContext(null)\n\n // Await initial post-processing promises before returning result\n await awaitPostProcessPromises(initialPostProcessPromises)\n\n // If the initial decode fails async, stop draining to avoid holding\n // onto the response body and raw stream buffers unnecessarily.\n Promise.resolve(result).catch(() => {\n drainCancelled = true\n reader.cancel().catch(() => {})\n })\n\n // Detach reader once draining completes.\n drain.finally(() => {\n try {\n reader.releaseLock()\n } catch {\n // Ignore\n }\n })\n\n return result\n}\n"],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import { AnySerializationAdapter } from '@tanstack/router-core';
2
+ export declare const pluginSerializationAdapters: Array<AnySerializationAdapter>;
3
+ export declare const hasPluginAdapters = false;
@@ -0,0 +1,7 @@
1
+ //#region src/fake-entries/plugin-adapters.ts
2
+ var pluginSerializationAdapters = [];
3
+ var hasPluginAdapters = false;
4
+ //#endregion
5
+ export { hasPluginAdapters, pluginSerializationAdapters };
6
+
7
+ //# sourceMappingURL=plugin-adapters.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-adapters.js","names":[],"sources":["../../../src/fake-entries/plugin-adapters.ts"],"sourcesContent":["import type { AnySerializationAdapter } from '@tanstack/router-core'\n\nexport const pluginSerializationAdapters: Array<AnySerializationAdapter> = []\nexport const hasPluginAdapters = false\n"],"mappings":";AAEA,IAAa,8BAA8D,EAAE;AAC7E,IAAa,oBAAoB"}
@@ -0,0 +1 @@
1
+ export declare function getRouter(): void;
@@ -0,0 +1,6 @@
1
+ //#region src/fake-entries/router.ts
2
+ function getRouter() {}
3
+ //#endregion
4
+ export { getRouter };
5
+
6
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.js","names":[],"sources":["../../../src/fake-entries/router.ts"],"sourcesContent":["export function getRouter() {}\n"],"mappings":";AAAA,SAAgB,YAAY"}
@@ -1,2 +1 @@
1
1
  export declare const startInstance: undefined;
2
- export declare const getRouter: () => void;
@@ -0,0 +1,6 @@
1
+ //#region src/fake-entries/start.ts
2
+ var startInstance = void 0;
3
+ //#endregion
4
+ export { startInstance };
5
+
6
+ //# sourceMappingURL=start.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"start.js","names":[],"sources":["../../../src/fake-entries/start.ts"],"sourcesContent":["export const startInstance = undefined\n"],"mappings":";AAAA,IAAa,gBAAgB,KAAA"}
@@ -1 +1,2 @@
1
- export declare function getDefaultSerovalPlugins(): (import('seroval').Plugin<any, any> | import('seroval').Plugin<Error, any> | import('seroval').Plugin<ReadableStream<any>, any>)[];
1
+ import { Plugin } from 'seroval';
2
+ export declare function getDefaultSerovalPlugins(): Array<Plugin<any, any>>;
@@ -1 +1 @@
1
- {"version":3,"file":"getDefaultSerovalPlugins.js","names":[],"sources":["../../src/getDefaultSerovalPlugins.ts"],"sourcesContent":["import {\n makeSerovalPlugin,\n defaultSerovalPlugins as routerDefaultSerovalPlugins,\n} from '@tanstack/router-core'\nimport { getStartOptions } from './getStartOptions'\nimport type { AnySerializationAdapter } from '@tanstack/router-core'\n\nexport function getDefaultSerovalPlugins() {\n const start = getStartOptions()\n const adapters = start?.serializationAdapters as\n | Array<AnySerializationAdapter>\n | undefined\n return [\n ...(adapters?.map(makeSerovalPlugin) ?? []),\n ...routerDefaultSerovalPlugins,\n ]\n}\n"],"mappings":";;;AAOA,SAAgB,2BAA2B;AAKzC,QAAO,CACL,IALY,iBAAiB,EACP,wBAIR,IAAI,kBAAkB,IAAI,EAAE,EAC1C,GAAG,sBACJ"}
1
+ {"version":3,"file":"getDefaultSerovalPlugins.js","names":[],"sources":["../../src/getDefaultSerovalPlugins.ts"],"sourcesContent":["import {\n makeSerovalPlugin,\n defaultSerovalPlugins as routerDefaultSerovalPlugins,\n} from '@tanstack/router-core'\nimport { getStartOptions } from './getStartOptions'\nimport type { AnySerializationAdapter } from '@tanstack/router-core'\nimport type { Plugin } from 'seroval'\n\nexport function getDefaultSerovalPlugins(): Array<Plugin<any, any>> {\n const start = getStartOptions()\n const adapters = start?.serializationAdapters as\n | Array<AnySerializationAdapter>\n | undefined\n return [\n ...(adapters?.map(makeSerovalPlugin) ?? []),\n ...routerDefaultSerovalPlugins,\n ]\n}\n"],"mappings":";;;AAQA,SAAgB,2BAAoD;AAKlE,QAAO,CACL,IALY,iBAAiB,EACP,wBAIR,IAAI,kBAAkB,IAAI,EAAE,EAC1C,GAAG,sBACJ"}
@@ -18,3 +18,4 @@ export { getRouterInstance } from './getRouterInstance.js';
18
18
  export { getDefaultSerovalPlugins } from './getDefaultSerovalPlugins.js';
19
19
  export { getGlobalStartContext } from './getGlobalStartContext.js';
20
20
  export { safeObjectMerge, createNullProtoObject } from './safeObjectMerge.js';
21
+ export { trackPostProcessPromise } from './client-rpc/serverFnFetcher.js';
package/dist/esm/index.js CHANGED
@@ -6,7 +6,8 @@ import { createStart } from "./createStart.js";
6
6
  import { getRouterInstance } from "./getRouterInstance.js";
7
7
  import { getDefaultSerovalPlugins } from "./getDefaultSerovalPlugins.js";
8
8
  import { getGlobalStartContext } from "./getGlobalStartContext.js";
9
+ import { trackPostProcessPromise } from "./client-rpc/serverFnFetcher.js";
9
10
  import { hydrate, json, mergeHeaders } from "@tanstack/router-core/ssr/client";
10
11
  import { RawStream } from "@tanstack/router-core";
11
12
  import { createClientOnlyFn, createIsomorphicFn, createServerOnlyFn } from "@tanstack/start-fn-stubs";
12
- export { FRAME_HEADER_SIZE, FrameType, RawStream, TSS_CONTENT_TYPE_FRAMED, TSS_CONTENT_TYPE_FRAMED_VERSIONED, TSS_FORMDATA_CONTEXT, TSS_FRAMED_PROTOCOL_VERSION, TSS_SERVER_FUNCTION, X_TSS_CONTEXT, X_TSS_RAW_RESPONSE, X_TSS_SERIALIZED, createClientOnlyFn, createIsomorphicFn, createMiddleware, createNullProtoObject, createServerFn, createServerOnlyFn, createStart, execValidator, executeMiddleware, flattenMiddlewares, getDefaultSerovalPlugins, getGlobalStartContext, getRouterInstance, hydrate, json, mergeHeaders, safeObjectMerge, validateFramedProtocolVersion };
13
+ export { FRAME_HEADER_SIZE, FrameType, RawStream, TSS_CONTENT_TYPE_FRAMED, TSS_CONTENT_TYPE_FRAMED_VERSIONED, TSS_FORMDATA_CONTEXT, TSS_FRAMED_PROTOCOL_VERSION, TSS_SERVER_FUNCTION, X_TSS_CONTEXT, X_TSS_RAW_RESPONSE, X_TSS_SERIALIZED, createClientOnlyFn, createIsomorphicFn, createMiddleware, createNullProtoObject, createServerFn, createServerOnlyFn, createStart, execValidator, executeMiddleware, flattenMiddlewares, getDefaultSerovalPlugins, getGlobalStartContext, getRouterInstance, hydrate, json, mergeHeaders, safeObjectMerge, trackPostProcessPromise, validateFramedProtocolVersion };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/start-client-core",
3
- "version": "1.167.10",
3
+ "version": "1.167.11",
4
4
  "description": "Modern and scalable routing for React applications",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -48,10 +48,13 @@
48
48
  },
49
49
  "imports": {
50
50
  "#tanstack-start-entry": {
51
- "default": "./dist/esm/fake-start-entry.js"
51
+ "default": "./dist/esm/fake-entries/start.js"
52
52
  },
53
53
  "#tanstack-router-entry": {
54
- "default": "./dist/esm/fake-start-entry.js"
54
+ "default": "./dist/esm/fake-entries/router.js"
55
+ },
56
+ "#tanstack-start-plugin-adapters": {
57
+ "default": "./dist/esm/fake-entries/plugin-adapters.js"
55
58
  }
56
59
  },
57
60
  "sideEffects": false,
@@ -66,7 +69,7 @@
66
69
  "node": ">=22.12.0"
67
70
  },
68
71
  "dependencies": {
69
- "seroval": "^1.4.2",
72
+ "seroval": "^1.5.0",
70
73
  "@tanstack/router-core": "1.168.9",
71
74
  "@tanstack/start-fn-stubs": "1.161.6",
72
75
  "@tanstack/start-storage-context": "1.166.23"
@@ -1,12 +1,14 @@
1
1
  import { hydrate } from '@tanstack/router-core/ssr/client'
2
2
 
3
+ import { startInstance } from '#tanstack-start-entry'
4
+ import {
5
+ hasPluginAdapters,
6
+ pluginSerializationAdapters,
7
+ } from '#tanstack-start-plugin-adapters'
8
+ import { getRouter } from '#tanstack-router-entry'
3
9
  import { ServerFunctionSerializationAdapter } from './ServerFunctionSerializationAdapter'
4
10
  import type { AnyStartInstanceOptions } from '../createStart'
5
11
  import type { AnyRouter, AnySerializationAdapter } from '@tanstack/router-core'
6
- // eslint-disable-next-line import/no-duplicates,import/order
7
- import { getRouter } from '#tanstack-router-entry'
8
- // eslint-disable-next-line import/no-duplicates,import/order
9
- import { startInstance } from '#tanstack-start-entry'
10
12
 
11
13
  export async function hydrateStart(): Promise<AnyRouter> {
12
14
  const router = await getRouter()
@@ -26,6 +28,10 @@ export async function hydrateStart(): Promise<AnyRouter> {
26
28
  } as AnyStartInstanceOptions
27
29
  }
28
30
 
31
+ // Only spread plugin adapters if any are configured (this will tree-shake away otherwise)
32
+ if (hasPluginAdapters) {
33
+ serializationAdapters.push(...pluginSerializationAdapters)
34
+ }
29
35
  serializationAdapters.push(ServerFunctionSerializationAdapter)
30
36
  if (router.options.serializationAdapters) {
31
37
  serializationAdapters.push(...router.options.serializationAdapters)
@@ -20,6 +20,70 @@ import type { Plugin as SerovalPlugin } from 'seroval'
20
20
 
21
21
  let serovalPlugins: Array<SerovalPlugin<any, any>> | null = null
22
22
 
23
+ /**
24
+ * Current async post-processing context for deserialization.
25
+ *
26
+ * Some deserializers need to perform async work after synchronous deserialization
27
+ * (e.g., decoding RSC payloads, fetching remote data). This context allows them
28
+ * to register promises that must complete before the deserialized value is used.
29
+ *
30
+ * This uses a synchronous execution context pattern:
31
+ * - Each call to `fromCrossJSON` is synchronous
32
+ * - Within that synchronous execution, all `fromSerializable` calls happen
33
+ * - We set the context before `fromCrossJSON`, clear it after
34
+ * - For streaming chunks, we set/clear context around each `onMessage` call
35
+ *
36
+ * Even with concurrent server function calls, each individual deserialization
37
+ * is atomic (synchronous), so promises are correctly scoped to their call.
38
+ */
39
+ let currentPostProcessContext: Array<Promise<unknown>> | null = null
40
+
41
+ /**
42
+ * Set the current post-processing context for async deserialization work.
43
+ * Called before deserialization starts.
44
+ *
45
+ * @param ctx - Array to collect async work promises, or null to clear
46
+ */
47
+ export function setPostProcessContext(
48
+ ctx: Array<Promise<unknown>> | null,
49
+ ): void {
50
+ currentPostProcessContext = ctx
51
+ }
52
+
53
+ /**
54
+ * Get the current post-processing context.
55
+ * Returns null if no deserialization is in progress.
56
+ */
57
+ export function getPostProcessContext(): Array<Promise<unknown>> | null {
58
+ return currentPostProcessContext
59
+ }
60
+
61
+ /**
62
+ * Track an async post-processing promise in the current deserialization context.
63
+ * Called by deserializers that need to perform async work after sync deserialization.
64
+ *
65
+ * If no context is active (e.g., on server), this is a no-op.
66
+ *
67
+ * @param promise - The async work promise to track
68
+ */
69
+ export function trackPostProcessPromise(promise: Promise<unknown>): void {
70
+ if (currentPostProcessContext) {
71
+ currentPostProcessContext.push(promise)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Helper to await all post-processing promises.
77
+ * Uses Promise.allSettled to ensure all promises complete even if some reject.
78
+ */
79
+ async function awaitPostProcessPromises(
80
+ promises: Array<Promise<unknown>>,
81
+ ): Promise<void> {
82
+ if (promises.length > 0) {
83
+ await Promise.allSettled(promises)
84
+ }
85
+ }
86
+
23
87
  /**
24
88
  * Checks if an object has at least one own enumerable property.
25
89
  * More efficient than Object.keys(obj).length > 0 as it short-circuits on first property.
@@ -228,23 +292,19 @@ async function getResponse(fn: () => Promise<Response>) {
228
292
  },
229
293
  })
230
294
  }
231
- // If it's a stream from the start serializer, process it as such
232
- else if (contentType.includes('application/x-ndjson')) {
233
- const refs = new Map()
234
- result = await processServerFnResponse({
235
- response,
236
- onMessage: (msg) =>
237
- fromCrossJSON(msg, { refs, plugins: serovalPlugins! }),
238
- onError(msg, error) {
239
- // TODO how could we notify consumer that an error occurred?
240
- console.error(msg, error)
241
- },
242
- })
243
- }
244
295
  // If it's a JSON response, it can be simpler
245
296
  else if (contentType.includes('application/json')) {
246
297
  const jsonPayload = await response.json()
247
- result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })
298
+ // Track async post-processing work for this deserialization
299
+ const postProcessPromises: Array<Promise<unknown>> = []
300
+ setPostProcessContext(postProcessPromises)
301
+ try {
302
+ result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })
303
+ } finally {
304
+ setPostProcessContext(null)
305
+ }
306
+ // Await any async post-processing before returning
307
+ await awaitPostProcessPromises(postProcessPromises)
248
308
  }
249
309
 
250
310
  if (!result) {
@@ -284,93 +344,13 @@ async function getResponse(fn: () => Promise<Response>) {
284
344
  return response
285
345
  }
286
346
 
287
- async function processServerFnResponse({
288
- response,
289
- onMessage,
290
- onError,
291
- }: {
292
- response: Response
293
- onMessage: (msg: any) => any
294
- onError?: (msg: string, error?: any) => void
295
- }) {
296
- if (!response.body) {
297
- throw new Error('No response body')
298
- }
299
-
300
- const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
301
-
302
- let buffer = ''
303
- let firstRead = false
304
- let firstObject
305
-
306
- while (!firstRead) {
307
- const { value, done } = await reader.read()
308
- if (value) buffer += value
309
-
310
- if (buffer.length === 0 && done) {
311
- throw new Error('Stream ended before first object')
312
- }
313
-
314
- // common case: buffer ends with newline
315
- if (buffer.endsWith('\n')) {
316
- const lines = buffer.split('\n').filter(Boolean)
317
- const firstLine = lines[0]
318
- if (!firstLine) throw new Error('No JSON line in the first chunk')
319
- firstObject = JSON.parse(firstLine)
320
- firstRead = true
321
- buffer = lines.slice(1).join('\n')
322
- } else {
323
- // fallback: wait for a newline to parse first object safely
324
- const newlineIndex = buffer.indexOf('\n')
325
- if (newlineIndex >= 0) {
326
- const line = buffer.slice(0, newlineIndex).trim()
327
- buffer = buffer.slice(newlineIndex + 1)
328
- if (line.length > 0) {
329
- firstObject = JSON.parse(line)
330
- firstRead = true
331
- }
332
- }
333
- }
334
- }
335
-
336
- // process rest of the stream asynchronously
337
- ;(async () => {
338
- try {
339
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
340
- while (true) {
341
- const { value, done } = await reader.read()
342
- if (value) buffer += value
343
-
344
- const lastNewline = buffer.lastIndexOf('\n')
345
- if (lastNewline >= 0) {
346
- const chunk = buffer.slice(0, lastNewline)
347
- buffer = buffer.slice(lastNewline + 1)
348
- const lines = chunk.split('\n').filter(Boolean)
349
-
350
- for (const line of lines) {
351
- try {
352
- onMessage(JSON.parse(line))
353
- } catch (e) {
354
- onError?.(`Invalid JSON line: ${line}`, e)
355
- }
356
- }
357
- }
358
-
359
- if (done) {
360
- break
361
- }
362
- }
363
- } catch (err) {
364
- onError?.('Stream processing error:', err)
365
- }
366
- })()
367
-
368
- return onMessage(firstObject)
369
- }
370
-
371
347
  /**
372
348
  * Processes a framed response where each JSON chunk is a complete JSON string
373
349
  * (already decoded by frame decoder).
350
+ *
351
+ * Uses per-chunk post-processing context to ensure async deserialization work
352
+ * completes before the next chunk is processed. This prevents issues when
353
+ * streaming values require async post-processing (e.g., RSC decoding).
374
354
  */
375
355
  async function processFramedResponse({
376
356
  jsonStream,
@@ -392,8 +372,11 @@ async function processFramedResponse({
392
372
  // Each frame is a complete JSON string
393
373
  const firstObject = JSON.parse(firstValue)
394
374
 
395
- // Process remaining frames asynchronously (for streaming refs like RawStream)
396
- ;(async () => {
375
+ // Process remaining frames for streaming refs like RawStream.
376
+ // Keep draining until the server closes the stream.
377
+ // Each chunk gets its own post-processing context to properly scope async work.
378
+ let drainCancelled = false as boolean
379
+ const drain = (async () => {
397
380
  try {
398
381
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
399
382
  while (true) {
@@ -401,16 +384,62 @@ async function processFramedResponse({
401
384
  if (done) break
402
385
  if (value) {
403
386
  try {
404
- onMessage(JSON.parse(value))
387
+ // Set up post-processing context for this chunk
388
+ const chunkPostProcessPromises: Array<Promise<unknown>> = []
389
+ setPostProcessContext(chunkPostProcessPromises)
390
+ try {
391
+ onMessage(JSON.parse(value))
392
+ } finally {
393
+ setPostProcessContext(null)
394
+ }
395
+ // Await any async post-processing from this chunk before processing next.
396
+ // This ensures values requiring async work are ready before their
397
+ // containing Promise/Stream resolves/emits to consumers.
398
+ await awaitPostProcessPromises(chunkPostProcessPromises)
405
399
  } catch (e) {
406
400
  onError?.(`Invalid JSON: ${value}`, e)
407
401
  }
408
402
  }
409
403
  }
410
404
  } catch (err) {
411
- onError?.('Stream processing error:', err)
405
+ if (!drainCancelled) {
406
+ onError?.('Stream processing error:', err)
407
+ }
412
408
  }
413
409
  })()
414
410
 
415
- return onMessage(firstObject)
411
+ // Process first object with its own post-processing context
412
+ let result: any
413
+ const initialPostProcessPromises: Array<Promise<unknown>> = []
414
+ setPostProcessContext(initialPostProcessPromises)
415
+ try {
416
+ result = onMessage(firstObject)
417
+ } catch (err) {
418
+ setPostProcessContext(null)
419
+ drainCancelled = true
420
+ reader.cancel().catch(() => {})
421
+ throw err
422
+ }
423
+ setPostProcessContext(null)
424
+
425
+ // Await initial post-processing promises before returning result
426
+ await awaitPostProcessPromises(initialPostProcessPromises)
427
+
428
+ // If the initial decode fails async, stop draining to avoid holding
429
+ // onto the response body and raw stream buffers unnecessarily.
430
+ Promise.resolve(result).catch(() => {
431
+ drainCancelled = true
432
+ reader.cancel().catch(() => {})
433
+ })
434
+
435
+ // Detach reader once draining completes.
436
+ drain.finally(() => {
437
+ try {
438
+ reader.releaseLock()
439
+ } catch {
440
+ // Ignore
441
+ }
442
+ })
443
+
444
+ return result
416
445
  }
@@ -0,0 +1,4 @@
1
+ import type { AnySerializationAdapter } from '@tanstack/router-core'
2
+
3
+ export const pluginSerializationAdapters: Array<AnySerializationAdapter> = []
4
+ export const hasPluginAdapters = false
@@ -0,0 +1 @@
1
+ export function getRouter() {}
@@ -1,2 +1 @@
1
1
  export const startInstance = undefined
2
- export const getRouter = () => {}
@@ -4,8 +4,9 @@ import {
4
4
  } from '@tanstack/router-core'
5
5
  import { getStartOptions } from './getStartOptions'
6
6
  import type { AnySerializationAdapter } from '@tanstack/router-core'
7
+ import type { Plugin } from 'seroval'
7
8
 
8
- export function getDefaultSerovalPlugins() {
9
+ export function getDefaultSerovalPlugins(): Array<Plugin<any, any>> {
9
10
  const start = getStartOptions()
10
11
  const adapters = start?.serializationAdapters as
11
12
  | Array<AnySerializationAdapter>
package/src/index.tsx CHANGED
@@ -117,3 +117,4 @@ export { getRouterInstance } from './getRouterInstance'
117
117
  export { getDefaultSerovalPlugins } from './getDefaultSerovalPlugins'
118
118
  export { getGlobalStartContext } from './getGlobalStartContext'
119
119
  export { safeObjectMerge, createNullProtoObject } from './safeObjectMerge'
120
+ export { trackPostProcessPromise } from './client-rpc/serverFnFetcher'
@@ -9,3 +9,10 @@ declare module '#tanstack-router-entry' {
9
9
 
10
10
  export const getRouter: RouterEntry['getRouter']
11
11
  }
12
+
13
+ declare module '#tanstack-start-plugin-adapters' {
14
+ import type { AnySerializationAdapter } from '@tanstack/router-core'
15
+
16
+ export const pluginSerializationAdapters: Array<AnySerializationAdapter>
17
+ export const hasPluginAdapters: boolean
18
+ }
@@ -6,6 +6,7 @@ import type { ServerFnMeta } from '../constants'
6
6
  import type {
7
7
  Constrain,
8
8
  Register,
9
+ SerializationError,
9
10
  TsrSerializable,
10
11
  ValidateSerializableInput,
11
12
  Validator,
@@ -512,7 +513,9 @@ test('createServerFn returns undefined', () => {
512
513
  test('createServerFn cannot return function', () => {
513
514
  expectTypeOf(createServerFn().handler<{ func: () => 'func' }>)
514
515
  .parameter(0)
515
- .returns.toEqualTypeOf<{ func: 'Function is not serializable' }>()
516
+ .returns.toEqualTypeOf<{
517
+ func: SerializationError<'Function may not be serializable'>
518
+ }>()
516
519
  })
517
520
 
518
521
  test('createServerFn cannot validate function', () => {
@@ -525,7 +528,10 @@ test('createServerFn cannot validate function', () => {
525
528
  .toEqualTypeOf<
526
529
  Constrain<
527
530
  (input: { func: () => 'string' }) => { output: 'string' },
528
- Validator<{ func: 'Function is not serializable' }, any>
531
+ Validator<
532
+ { func: SerializationError<'Function may not be serializable'> },
533
+ any
534
+ >
529
535
  >
530
536
  >()
531
537
  })
@@ -2,7 +2,7 @@ import { expectTypeOf, test } from 'vitest'
2
2
  import { createMiddleware } from '../createMiddleware'
3
3
  import type { RequestServerNextFn } from '../createMiddleware'
4
4
  import type { ConstrainValidator, CustomFetch } from '../createServerFn'
5
- import type { Register } from '@tanstack/router-core'
5
+ import type { Register, SerializationError } from '@tanstack/router-core'
6
6
  import type { ServerFnMeta } from '../constants'
7
7
 
8
8
  test('createServeMiddleware removes middleware after middleware,', () => {
@@ -573,7 +573,10 @@ test('createMiddleware sendContext cannot send a function', () => {
573
573
  .parameter(0)
574
574
  .exclude<undefined>()
575
575
  .toHaveProperty('sendContext')
576
- .toEqualTypeOf<{ func: 'Function is not serializable' } | undefined>()
576
+ .toEqualTypeOf<
577
+ | { func: SerializationError<'Function may not be serializable'> }
578
+ | undefined
579
+ >()
577
580
 
578
581
  return next()
579
582
  })
@@ -582,7 +585,10 @@ test('createMiddleware sendContext cannot send a function', () => {
582
585
  .parameter(0)
583
586
  .exclude<undefined>()
584
587
  .toHaveProperty('sendContext')
585
- .toEqualTypeOf<{ func: 'Function is not serializable' } | undefined>()
588
+ .toEqualTypeOf<
589
+ | { func: SerializationError<'Function may not be serializable'> }
590
+ | undefined
591
+ >()
586
592
 
587
593
  return next()
588
594
  })
@@ -1,7 +0,0 @@
1
- //#region src/fake-start-entry.ts
2
- var startInstance = void 0;
3
- var getRouter = () => {};
4
- //#endregion
5
- export { getRouter, startInstance };
6
-
7
- //# sourceMappingURL=fake-start-entry.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"fake-start-entry.js","names":[],"sources":["../../src/fake-start-entry.ts"],"sourcesContent":["export const startInstance = undefined\nexport const getRouter = () => {}\n"],"mappings":";AAAA,IAAa,gBAAgB,KAAA;AAC7B,IAAa,kBAAkB"}