@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.
- package/dist/esm/client/hydrateStart.js +3 -1
- package/dist/esm/client/hydrateStart.js.map +1 -1
- package/dist/esm/client-rpc/serverFnFetcher.d.ts +21 -0
- package/dist/esm/client-rpc/serverFnFetcher.js +94 -71
- package/dist/esm/client-rpc/serverFnFetcher.js.map +1 -1
- package/dist/esm/fake-entries/plugin-adapters.d.ts +3 -0
- package/dist/esm/fake-entries/plugin-adapters.js +7 -0
- package/dist/esm/fake-entries/plugin-adapters.js.map +1 -0
- package/dist/esm/fake-entries/router.d.ts +1 -0
- package/dist/esm/fake-entries/router.js +6 -0
- package/dist/esm/fake-entries/router.js.map +1 -0
- package/dist/esm/{fake-start-entry.d.ts → fake-entries/start.d.ts} +0 -1
- package/dist/esm/fake-entries/start.js +6 -0
- package/dist/esm/fake-entries/start.js.map +1 -0
- package/dist/esm/getDefaultSerovalPlugins.d.ts +2 -1
- package/dist/esm/getDefaultSerovalPlugins.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +2 -1
- package/package.json +7 -4
- package/src/client/hydrateStart.ts +10 -4
- package/src/client-rpc/serverFnFetcher.ts +132 -103
- package/src/fake-entries/plugin-adapters.ts +4 -0
- package/src/fake-entries/router.ts +1 -0
- package/src/{fake-start-entry.ts → fake-entries/start.ts} +0 -1
- package/src/getDefaultSerovalPlugins.ts +2 -1
- package/src/index.tsx +1 -0
- package/src/start-entry.d.ts +7 -0
- package/src/tests/createServerFn.test-d.ts +8 -2
- package/src/tests/createServerMiddleware.test-d.ts +9 -3
- package/dist/esm/fake-start-entry.js +0 -7
- 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 {
|
|
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/
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 @@
|
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"router.js","names":[],"sources":["../../../src/fake-entries/router.ts"],"sourcesContent":["export function getRouter() {}\n"],"mappings":";AAAA,SAAgB,YAAY"}
|
|
@@ -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
|
-
|
|
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":";;;
|
|
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"}
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|
51
|
+
"default": "./dist/esm/fake-entries/start.js"
|
|
52
52
|
},
|
|
53
53
|
"#tanstack-router-entry": {
|
|
54
|
-
"default": "./dist/esm/fake-
|
|
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.
|
|
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
|
-
|
|
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
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
+
if (!drainCancelled) {
|
|
406
|
+
onError?.('Stream processing error:', err)
|
|
407
|
+
}
|
|
412
408
|
}
|
|
413
409
|
})()
|
|
414
410
|
|
|
415
|
-
|
|
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 @@
|
|
|
1
|
+
export function 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'
|
package/src/start-entry.d.ts
CHANGED
|
@@ -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<{
|
|
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<
|
|
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<
|
|
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<
|
|
588
|
+
.toEqualTypeOf<
|
|
589
|
+
| { func: SerializationError<'Function may not be serializable'> }
|
|
590
|
+
| undefined
|
|
591
|
+
>()
|
|
586
592
|
|
|
587
593
|
return next()
|
|
588
594
|
})
|
|
@@ -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"}
|