@mionjs/client 0.8.7 → 0.8.10

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 (76) hide show
  1. package/.dist/cjs/index.d.ts +1 -0
  2. package/.dist/cjs/index.d.ts.map +1 -0
  3. package/.dist/cjs/src/client.d.ts +1 -0
  4. package/.dist/cjs/src/client.d.ts.map +1 -0
  5. package/.dist/cjs/src/constants.d.ts +1 -0
  6. package/.dist/cjs/src/constants.d.ts.map +1 -0
  7. package/.dist/cjs/src/lib/clientMethodsMetadata.d.ts +1 -0
  8. package/.dist/cjs/src/lib/clientMethodsMetadata.d.ts.map +1 -0
  9. package/.dist/cjs/src/lib/fetchRemoteMethodsMetadata.d.ts +1 -0
  10. package/.dist/cjs/src/lib/fetchRemoteMethodsMetadata.d.ts.map +1 -0
  11. package/.dist/cjs/src/lib/handlersRegistry.d.ts +1 -0
  12. package/.dist/cjs/src/lib/handlersRegistry.d.ts.map +1 -0
  13. package/.dist/cjs/src/lib/serializer.d.ts +1 -0
  14. package/.dist/cjs/src/lib/serializer.d.ts.map +1 -0
  15. package/.dist/cjs/src/lib/storage.d.ts +1 -0
  16. package/.dist/cjs/src/lib/storage.d.ts.map +1 -0
  17. package/.dist/cjs/src/lib/testUtils.d.ts +1 -0
  18. package/.dist/cjs/src/lib/testUtils.d.ts.map +1 -0
  19. package/.dist/cjs/src/lib/typedEvent.d.ts +1 -0
  20. package/.dist/cjs/src/lib/typedEvent.d.ts.map +1 -0
  21. package/.dist/cjs/src/lib/validation.d.ts +1 -0
  22. package/.dist/cjs/src/lib/validation.d.ts.map +1 -0
  23. package/.dist/cjs/src/request.d.ts +1 -0
  24. package/.dist/cjs/src/request.d.ts.map +1 -0
  25. package/.dist/cjs/src/routesFlow.d.ts +1 -0
  26. package/.dist/cjs/src/routesFlow.d.ts.map +1 -0
  27. package/.dist/cjs/src/subRequest.d.ts +1 -0
  28. package/.dist/cjs/src/subRequest.d.ts.map +1 -0
  29. package/.dist/cjs/src/types.d.ts +1 -0
  30. package/.dist/cjs/src/types.d.ts.map +1 -0
  31. package/.dist/esm/index.d.ts +1 -0
  32. package/.dist/esm/index.d.ts.map +1 -0
  33. package/.dist/esm/src/client.d.ts +1 -0
  34. package/.dist/esm/src/client.d.ts.map +1 -0
  35. package/.dist/esm/src/constants.d.ts +1 -0
  36. package/.dist/esm/src/constants.d.ts.map +1 -0
  37. package/.dist/esm/src/lib/clientMethodsMetadata.d.ts +1 -0
  38. package/.dist/esm/src/lib/clientMethodsMetadata.d.ts.map +1 -0
  39. package/.dist/esm/src/lib/fetchRemoteMethodsMetadata.d.ts +1 -0
  40. package/.dist/esm/src/lib/fetchRemoteMethodsMetadata.d.ts.map +1 -0
  41. package/.dist/esm/src/lib/handlersRegistry.d.ts +1 -0
  42. package/.dist/esm/src/lib/handlersRegistry.d.ts.map +1 -0
  43. package/.dist/esm/src/lib/serializer.d.ts +1 -0
  44. package/.dist/esm/src/lib/serializer.d.ts.map +1 -0
  45. package/.dist/esm/src/lib/storage.d.ts +1 -0
  46. package/.dist/esm/src/lib/storage.d.ts.map +1 -0
  47. package/.dist/esm/src/lib/testUtils.d.ts +1 -0
  48. package/.dist/esm/src/lib/testUtils.d.ts.map +1 -0
  49. package/.dist/esm/src/lib/typedEvent.d.ts +1 -0
  50. package/.dist/esm/src/lib/typedEvent.d.ts.map +1 -0
  51. package/.dist/esm/src/lib/validation.d.ts +1 -0
  52. package/.dist/esm/src/lib/validation.d.ts.map +1 -0
  53. package/.dist/esm/src/request.d.ts +1 -0
  54. package/.dist/esm/src/request.d.ts.map +1 -0
  55. package/.dist/esm/src/routesFlow.d.ts +1 -0
  56. package/.dist/esm/src/routesFlow.d.ts.map +1 -0
  57. package/.dist/esm/src/subRequest.d.ts +1 -0
  58. package/.dist/esm/src/subRequest.d.ts.map +1 -0
  59. package/.dist/esm/src/types.d.ts +1 -0
  60. package/.dist/esm/src/types.d.ts.map +1 -0
  61. package/index.ts +19 -0
  62. package/package.json +12 -8
  63. package/src/client.ts +290 -0
  64. package/src/constants.ts +37 -0
  65. package/src/lib/clientMethodsMetadata.ts +187 -0
  66. package/src/lib/fetchRemoteMethodsMetadata.ts +44 -0
  67. package/src/lib/handlersRegistry.ts +101 -0
  68. package/src/lib/serializer.ts +245 -0
  69. package/src/lib/storage.ts +57 -0
  70. package/src/lib/testUtils.ts +16 -0
  71. package/src/lib/typedEvent.ts +57 -0
  72. package/src/lib/validation.ts +72 -0
  73. package/src/request.ts +563 -0
  74. package/src/routesFlow.ts +101 -0
  75. package/src/subRequest.ts +111 -0
  76. package/src/types.ts +237 -0
package/src/request.ts ADDED
@@ -0,0 +1,563 @@
1
+ /* ########
2
+ * 2023 mion
3
+ * Author: Ma-jerez
4
+ * License: MIT
5
+ * The software is provided "as is", without warranty of any kind.
6
+ * ######## */
7
+
8
+ import type {ResponseBody} from '@mionjs/router';
9
+ import {
10
+ ClientOptions,
11
+ MiddlewareSubRequest,
12
+ SubRequest,
13
+ RouteSubRequest,
14
+ RequestErrors,
15
+ PrefilledMiddleFnsCache,
16
+ } from './types.ts';
17
+ import type {RunTypeError, RoutesFlowQuery, RoutesFlowMapping} from '@mionjs/core';
18
+ import {RpcError, isRpcError, routesCache, MION_ROUTES, HandlerType, HeadersSubset, toBase64Url} from '@mionjs/core';
19
+ import type {SerializerMode} from '@mionjs/core';
20
+ import {getRoutePath} from '@mionjs/core';
21
+ import {fetchRemoteMethodsMetadata} from './lib/fetchRemoteMethodsMetadata.ts';
22
+ import {createMetadataSubRequest} from './lib/clientMethodsMetadata.ts';
23
+ import {validateSubRequests} from './lib/validation.ts';
24
+ import {serializeRequestBody, deserializeResponseBody} from './lib/serializer.ts';
25
+ import {ROUTES_FLOW_KEY, MAX_GET_URL_LENGTH} from './constants.ts';
26
+
27
+ export class MionClientRequest<RR extends RouteSubRequest<any>, MiddleFnRequestsList extends MiddlewareSubRequest<any>[]> {
28
+ readonly path: string;
29
+ readonly requestId: string;
30
+ readonly subRequestList: {[key: string]: SubRequest<any>} = {};
31
+ response: Response | undefined;
32
+
33
+ constructor(
34
+ public readonly options: ClientOptions,
35
+ private readonly prefilledMiddleFnsCache: PrefilledMiddleFnsCache,
36
+ public readonly route?: RR,
37
+ public readonly middleFns?: MiddleFnRequestsList,
38
+ /** Array of routesFlow subrequests when executing a routesFlow */
39
+ public readonly workflowSubRequests?: RouteSubRequest<any>[],
40
+ /** Composed abort signal for this request */
41
+ public readonly signal?: AbortSignal
42
+ ) {
43
+ if (workflowSubRequests && workflowSubRequests.length > 0) {
44
+ const routePaths = workflowSubRequests.map((sr) => getRoutePath(sr.pointer, this.options));
45
+ const query = buildRoutesFlowQuery(routePaths, workflowSubRequests);
46
+ const flowPath = getRoutePath([ROUTES_FLOW_KEY], this.options);
47
+ this.path = `${flowPath}?data=${toBase64Url(JSON.stringify(query))}`;
48
+ this.requestId = 'mion-routes-flow';
49
+ workflowSubRequests.forEach((sr) => this.addSubRequest(sr));
50
+ } else {
51
+ this.path = route ? getRoutePath(route.pointer, this.options) : 'no-route';
52
+ this.requestId = route ? route.id : 'no-route';
53
+ if (route) this.addSubRequest(route);
54
+ }
55
+ if (middleFns) middleFns.forEach((middleFn) => this.addSubRequest(middleFn));
56
+ }
57
+
58
+ /** Calls a remote route */
59
+ async call(): Promise<ResponseBody> {
60
+ if (this.signal?.aborted) {
61
+ const errors: RequestErrors = new Map();
62
+ this.onError(
63
+ this.signal.reason ?? new DOMException('This operation was aborted', 'AbortError'),
64
+ 'Request aborted',
65
+ errors
66
+ );
67
+ return Promise.reject(errors);
68
+ }
69
+ return this.makeCall(this.options.serializer);
70
+ }
71
+
72
+ /** Call flow that auto-detects missing metadata and uses optimistic serialization when needed */
73
+ private async makeCall(originalSerializer: SerializerMode, skipOptimistic?: boolean): Promise<ResponseBody> {
74
+ const errors: RequestErrors = new Map();
75
+ const subRequestIds = Object.keys(this.subRequestList);
76
+ const allCached = subRequestIds.every((id) => routesCache.hasMetadata(id));
77
+ const isOptimistic = !allCached && !skipOptimistic;
78
+
79
+ try {
80
+ if (isOptimistic) {
81
+ (this.options as any).serializer = 'optimistic';
82
+ this.restorePrefilledMiddleFns();
83
+ // Add metadata subrequest (after prefilled restore so we include all IDs)
84
+ const allSubRequestIds = Object.keys(this.subRequestList);
85
+ this.addSubRequest(createMetadataSubRequest(allSubRequestIds));
86
+ } else {
87
+ (this.options as any).serializer = originalSerializer;
88
+ await fetchRemoteMethodsMetadata(subRequestIds, this.options, this.signal);
89
+ this.restorePrefilledMiddleFns(errors);
90
+ if (errors.size) return Promise.reject(errors);
91
+ validateSubRequests(subRequestIds, this, errors);
92
+ if (errors.size) return Promise.reject(errors);
93
+ }
94
+ } catch (error: any) {
95
+ this.onError(error, 'Error preparing request', errors);
96
+ return Promise.reject(errors);
97
+ }
98
+
99
+ try {
100
+ let serialized: ReturnType<typeof serializeRequestBody>;
101
+ try {
102
+ serialized = serializeRequestBody(this);
103
+ } catch (serializeError) {
104
+ if (isOptimistic) {
105
+ // JSON.stringify failed → fall back to standard, will fetch metadata
106
+ delete this.subRequestList[MION_ROUTES.methodsMetadata];
107
+ return this.makeCall(originalSerializer, true);
108
+ }
109
+ throw serializeError;
110
+ }
111
+
112
+ const headersFromParams = extractRequestHeaders(this);
113
+ const url = new URL(this.path, this.options.baseURL);
114
+ const fetchOptions = buildFetchOptions(
115
+ url,
116
+ serialized,
117
+ headersFromParams,
118
+ this.options,
119
+ isOptimistic,
120
+ this.isQueryRoute(),
121
+ this.signal
122
+ );
123
+ this.response = await fetch(url, fetchOptions);
124
+ } catch (error: any) {
125
+ this.onError(error, 'Error executing request', errors);
126
+ return Promise.reject(errors);
127
+ }
128
+
129
+ try {
130
+ // If the signal already aborted while fetch was in flight, don't try to read a possibly
131
+ // truncated response body — surface the abort directly.
132
+ if (this.signal?.aborted) {
133
+ this.onError(this.signal.reason, 'Request aborted', errors);
134
+ return Promise.reject(errors);
135
+ }
136
+ const deserialized = await deserializeResponseBody(this.response, this.options);
137
+ if (this.handlePlatformError(deserialized, errors)) return Promise.reject(errors);
138
+
139
+ // Never retry an aborted request — the user explicitly canceled it.
140
+ if (isOptimistic && !this.signal?.aborted && this.shouldRetryWithProperSerialization(deserialized)) {
141
+ return this.retryWithProperSerialization(originalSerializer);
142
+ }
143
+
144
+ this.resolveSubRequests(deserialized, errors, isOptimistic ? MION_ROUTES.methodsMetadata : undefined);
145
+ if (errors.size) return Promise.reject(errors);
146
+ return deserialized;
147
+ } catch (error) {
148
+ this.onError(error, 'Error parsing response', errors);
149
+ return Promise.reject(errors);
150
+ }
151
+ }
152
+
153
+ /** Checks if the response contains errors that require retry with proper JIT serialization */
154
+ private shouldRetryWithProperSerialization(deserialized: ResponseBody): boolean {
155
+ return Object.values(deserialized).some(
156
+ (value) =>
157
+ isRpcError(value) &&
158
+ (value.type === 'serialization-error' ||
159
+ value.type === 'validation-error' ||
160
+ value.type === 'parsing-json-request-error')
161
+ );
162
+ }
163
+
164
+ /** Retries the request with proper JIT serialization after metadata has been cached */
165
+ private async retryWithProperSerialization(originalSerializer: SerializerMode): Promise<ResponseBody> {
166
+ delete this.subRequestList[MION_ROUTES.methodsMetadata];
167
+ Object.values(this.subRequestList).forEach((sr) => {
168
+ sr.isResolved = false;
169
+ sr.resolvedValue = undefined;
170
+ sr.error = undefined;
171
+ });
172
+ return this.makeCall(originalSerializer);
173
+ }
174
+
175
+ /** Validate params */
176
+ async validateParams(subReqList?: SubRequest<any>[]): Promise<RunTypeError[]> {
177
+ if (subReqList) subReqList.forEach((subRequest) => this.addSubRequest(subRequest));
178
+ const errors: RequestErrors = new Map();
179
+ try {
180
+ const subRequestIds = Object.keys(this.subRequestList);
181
+ await fetchRemoteMethodsMetadata(subRequestIds, this.options);
182
+ validateSubRequests(subRequestIds, this, errors, false);
183
+ return Object.values(this.subRequestList)
184
+ .map((subRequest) => subRequest.error?.errorData || [])
185
+ .flat();
186
+ } catch (error: any) {
187
+ this.onError(error, 'Error preparing request', errors);
188
+ return Promise.reject(errors);
189
+ }
190
+ }
191
+
192
+ /** Prefills and stores SubRequest */
193
+ async prefill(subReqList?: SubRequest<any>[]): Promise<void> {
194
+ if (subReqList) subReqList.forEach((subRequest) => this.addSubRequest(subRequest));
195
+ const errors: RequestErrors = new Map();
196
+ try {
197
+ const subRequestIds = Object.keys(this.subRequestList);
198
+ await fetchRemoteMethodsMetadata(subRequestIds, this.options);
199
+
200
+ validateSubRequests(subRequestIds, this, errors, false);
201
+ if (errors.size) return Promise.reject(errors);
202
+
203
+ serializeRequestBody(this);
204
+
205
+ this.storePrefilledMiddleFns(errors);
206
+ if (errors.size) return Promise.reject(errors);
207
+
208
+ return;
209
+ } catch (error: any) {
210
+ this.onError(error, 'Error preparing request', errors);
211
+ return Promise.reject(errors);
212
+ }
213
+ }
214
+
215
+ /** Removes Prefills and stores SubRequest */
216
+ async removePrefill(subRequests?: SubRequest<any>[]): Promise<void> {
217
+ if (subRequests) subRequests.forEach((subRequest) => this.addSubRequest(subRequest));
218
+ this.removePrefilledMiddleFns();
219
+ }
220
+
221
+ addSubRequest(subRequest: SubRequest<any>) {
222
+ if (subRequest.isResolved) throw new Error(`SubRequest ${subRequest.id} is already resolved`);
223
+ this.subRequestList[subRequest.id] = subRequest;
224
+ }
225
+
226
+ /** Checks for platform-level errors and propagates them to all subrequests. Returns true if a platform error was found */
227
+ private handlePlatformError(deserialized: ResponseBody, errors: RequestErrors): boolean {
228
+ if (!(MION_ROUTES.platformError in deserialized)) return false;
229
+ const platformError = deserialized[MION_ROUTES.platformError];
230
+ Object.entries(this.subRequestList).forEach(([id, methodMeta]) => {
231
+ methodMeta.isResolved = true;
232
+ methodMeta.error = platformError as RpcError<string>;
233
+ errors.set(id, platformError as RpcError<string>);
234
+ });
235
+ return true;
236
+ }
237
+
238
+ /** Resolves sub request values from the deserialized response body and collects errors */
239
+ private resolveSubRequests(deserialized: ResponseBody, errors: RequestErrors, skipId?: string): void {
240
+ Object.entries(this.subRequestList).forEach(([id, methodMeta]) => {
241
+ if (id === skipId) return;
242
+ const resp = this.getResponseValueFromBodyOrHeader(id, deserialized, (this.response as Response).headers);
243
+ methodMeta.isResolved = true;
244
+ if (isRpcError(resp)) {
245
+ methodMeta.error = resp;
246
+ errors.set(id, resp);
247
+ } else {
248
+ methodMeta.resolvedValue = resp;
249
+ }
250
+ });
251
+
252
+ Object.entries(deserialized).forEach(([id, value]) => {
253
+ if (!(id in this.subRequestList) && isRpcError(value)) {
254
+ errors.set(id, value);
255
+ }
256
+ });
257
+ }
258
+
259
+ private onError(error: any, stageMessage: string, errors: RequestErrors): void {
260
+ // Detect abort/timeout via signal.reason FIRST. We must do this before the isRpcError early return
261
+ // because errors that surface from later stages (deserialization, retry, etc.) may already be
262
+ // wrapped as RpcError but the user-facing reason is still abort/timeout.
263
+ const reason = this.signal?.aborted ? this.signal.reason : undefined;
264
+ if (reason instanceof DOMException) {
265
+ if (reason.name === 'TimeoutError') {
266
+ errors.set(
267
+ this.requestId,
268
+ new RpcError({
269
+ type: 'request-timeout',
270
+ publicMessage: 'Request timed out',
271
+ originalError: error instanceof Error ? error : undefined,
272
+ })
273
+ );
274
+ return;
275
+ }
276
+ if (reason.name === 'AbortError') {
277
+ errors.set(
278
+ this.requestId,
279
+ new RpcError({
280
+ type: 'request-aborted',
281
+ publicMessage: 'Request was aborted',
282
+ originalError: error instanceof Error ? error : undefined,
283
+ })
284
+ );
285
+ return;
286
+ }
287
+ }
288
+ if (isRpcError(error)) {
289
+ errors.set(this.requestId, error);
290
+ return;
291
+ }
292
+ const message = error?.message ? `${stageMessage}: ${error.message}` : `${stageMessage}: Unknown Error`;
293
+ errors.set(
294
+ this.requestId,
295
+ new RpcError({
296
+ type: error?.name || 'unknown-error',
297
+ publicMessage: message,
298
+ originalError: error instanceof Error ? error : undefined,
299
+ })
300
+ );
301
+ }
302
+
303
+ private getResponseValueFromBodyOrHeader(id: string, respBody: ResponseBody, headers: Headers): any {
304
+ const headersSubset = reconstructHeadersSubsetFromResponse(id, headers);
305
+ if (headersSubset) return headersSubset;
306
+ return respBody[id];
307
+ }
308
+
309
+ /** When errors is omitted, silently skips routes without cached metadata (used by optimistic flow) */
310
+ private restorePrefilledMiddleFns(errors?: RequestErrors): void {
311
+ if (this.workflowSubRequests && this.workflowSubRequests.length > 0) {
312
+ this.restorePrefilledMiddleFnsForWorkflow(errors);
313
+ return;
314
+ }
315
+
316
+ const methodMeta = routesCache.getMetadata(this.requestId);
317
+ if (!methodMeta) {
318
+ if (errors) {
319
+ errors.set(
320
+ this.requestId,
321
+ new RpcError({
322
+ type: 'route-metadata-not-found',
323
+ publicMessage: `Metadata for Route '${this.requestId} not found.'.`,
324
+ })
325
+ );
326
+ }
327
+ return;
328
+ }
329
+ const missingIds = methodMeta.middleFnIds?.filter((id) => !!id && this.requestId !== id) || [];
330
+ missingIds.forEach((id) => {
331
+ const subRequest = this.subRequestList[id];
332
+ if (subRequest) return;
333
+ const cacheKey = this.getPrefilledMiddleFnCacheKey(id);
334
+ const cachedSubRequest = this.prefilledMiddleFnsCache.get(cacheKey);
335
+ if (cachedSubRequest) {
336
+ const clonedSubRequest: SubRequest<any> = {
337
+ ...cachedSubRequest,
338
+ isResolved: false,
339
+ resolvedValue: undefined,
340
+ error: undefined,
341
+ };
342
+ this.addSubRequest(clonedSubRequest);
343
+ }
344
+ });
345
+ }
346
+
347
+ /** Restore prefilled middleFns for all routes in a routesFlow, deduplicating by ID */
348
+ private restorePrefilledMiddleFnsForWorkflow(errors?: RequestErrors): void {
349
+ const workflowRouteIds = new Set(this.workflowSubRequests!.map((sr) => sr.id));
350
+
351
+ for (const routeSubRequest of this.workflowSubRequests!) {
352
+ const methodMeta = routesCache.getMetadata(routeSubRequest.id);
353
+ if (!methodMeta) {
354
+ if (errors) {
355
+ errors.set(
356
+ routeSubRequest.id,
357
+ new RpcError({
358
+ type: 'route-metadata-not-found',
359
+ publicMessage: `Metadata for Route '${routeSubRequest.id}' not found.`,
360
+ })
361
+ );
362
+ }
363
+ continue;
364
+ }
365
+ const missingIds = methodMeta.middleFnIds?.filter((id) => !!id && !workflowRouteIds.has(id)) || [];
366
+ missingIds.forEach((id) => {
367
+ const subRequest = this.subRequestList[id];
368
+ if (subRequest) return;
369
+ const cacheKey = this.getPrefilledMiddleFnCacheKey(id);
370
+ const cachedSubRequest = this.prefilledMiddleFnsCache.get(cacheKey);
371
+ if (cachedSubRequest) {
372
+ const clonedSubRequest: SubRequest<any> = {
373
+ ...cachedSubRequest,
374
+ isResolved: false,
375
+ resolvedValue: undefined,
376
+ error: undefined,
377
+ };
378
+ this.addSubRequest(clonedSubRequest);
379
+ }
380
+ });
381
+ }
382
+ }
383
+
384
+ private storePrefilledMiddleFns(errors: RequestErrors): void {
385
+ Object.keys(this.subRequestList).forEach((id) => {
386
+ const subRequest = this.subRequestList[id];
387
+ const methodMeta = routesCache.getMetadata(id);
388
+ if (!methodMeta) throw new Error(`Remote method ${id} not found.`);
389
+ if (methodMeta.type === HandlerType.route) {
390
+ errors.set(
391
+ id,
392
+ new RpcError({
393
+ type: 'routes-cant-be-prefilled',
394
+ publicMessage: `Remote method ${id} is a route and can't be prefilled.`,
395
+ })
396
+ );
397
+ return;
398
+ }
399
+ const cacheKey = this.getPrefilledMiddleFnCacheKey(id);
400
+ this.prefilledMiddleFnsCache.set(cacheKey, subRequest);
401
+ });
402
+ }
403
+
404
+ private removePrefilledMiddleFns(): void {
405
+ Object.keys(this.subRequestList).forEach((id) => {
406
+ const cacheKey = this.getPrefilledMiddleFnCacheKey(id);
407
+ this.prefilledMiddleFnsCache.delete(cacheKey);
408
+ });
409
+ }
410
+
411
+ /** Returns true if the route is a query (isMutation === false) and not a routesFlow */
412
+ private isQueryRoute(): boolean {
413
+ if (this.workflowSubRequests) return false;
414
+ const meta = routesCache.getMetadata(this.requestId);
415
+ // strict false value required for queries
416
+ return meta?.options?.isMutation === false;
417
+ }
418
+
419
+ private getPrefilledMiddleFnCacheKey(id: string): string {
420
+ return `${this.options.baseURL}:${id}`;
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Builds the RequestInit options for the fetch call, choosing between GET and POST:
426
+ * - GET (query): Used for non-mutation routes (isMutation === false) with JSON serialization,
427
+ * where the base64url-encoded body fits within the URL length limit. The serialized data is
428
+ * sent as a `?data=` query parameter, with no request body.
429
+ * - POST (mutation/route call): Used for all other cases: mutations, optimistic requests,
430
+ * routesFlow calls, non-JSON serializers, or when the GET URL would exceed MAX_GET_URL_LENGTH.
431
+ * The serialized data is sent in the request body with the appropriate Content-Type header.
432
+ */
433
+ function buildFetchOptions(
434
+ url: URL,
435
+ serialized: ReturnType<typeof serializeRequestBody>,
436
+ headersFromParams: Record<string, string>,
437
+ options: ClientOptions,
438
+ isOptimistic: boolean,
439
+ isQuery: boolean,
440
+ signal?: AbortSignal
441
+ ): RequestInit {
442
+ if (!isOptimistic && isQuery && serialized.contentType.includes('json')) {
443
+ const encoded = toBase64Url(serialized.body as string);
444
+ const testUrl = new URL(url.pathname + url.search, url.origin);
445
+ testUrl.searchParams.set('data', encoded);
446
+
447
+ if (testUrl.toString().length <= MAX_GET_URL_LENGTH) {
448
+ url.searchParams.set('data', encoded);
449
+ return {
450
+ ...options.fetchOptions,
451
+ method: 'GET',
452
+ headers: {...options.fetchOptions.headers, ...headersFromParams},
453
+ body: undefined,
454
+ signal,
455
+ };
456
+ }
457
+ }
458
+
459
+ return {
460
+ ...options.fetchOptions,
461
+ method: 'POST',
462
+ headers: {...options.fetchOptions.headers, ...headersFromParams, 'Content-Type': serialized.contentType},
463
+ body: serialized.body as BodyInit,
464
+ signal,
465
+ };
466
+ }
467
+
468
+ /** Extracts headers from HeadersSubset params in headersFn methods */
469
+ function extractRequestHeaders(req: MionClientRequest<any, any>): Record<string, string> {
470
+ const headers: Record<string, string> = {};
471
+ const subRequestIds = Object.keys(req.subRequestList);
472
+
473
+ for (let i = 0; i < subRequestIds.length; i++) {
474
+ const id = subRequestIds[i];
475
+ const subRequest = req.subRequestList[id];
476
+ if (!subRequest) continue;
477
+
478
+ const method = routesCache.getMetadata(id);
479
+ if (!method || method.type !== HandlerType.headersMiddleFn || !method.headersParam) continue;
480
+
481
+ const params = subRequest.params;
482
+ const extracted = extractHeadersFromParams(params);
483
+ Object.assign(headers, extracted);
484
+ }
485
+
486
+ return headers;
487
+ }
488
+
489
+ /** Extracts headers from a HeadersSubset parameter */
490
+ function extractHeadersFromParams(params: any[]): Record<string, string> {
491
+ if (!params || params.length === 0) {
492
+ throw new RpcError({
493
+ type: 'missing-headers-param',
494
+ publicMessage: 'HeadersFn requires a HeadersSubset parameter.',
495
+ });
496
+ }
497
+
498
+ const firstParam = params[0];
499
+
500
+ if (firstParam instanceof HeadersSubset) {
501
+ return firstParam.headers as Record<string, string>;
502
+ }
503
+
504
+ if (firstParam && typeof firstParam === 'object' && 'headers' in firstParam && typeof firstParam.headers === 'object') {
505
+ return firstParam.headers as Record<string, string>;
506
+ }
507
+
508
+ throw new RpcError({
509
+ type: 'invalid-headers-param',
510
+ publicMessage: 'HeadersFn first parameter must be a HeadersSubset instance or object with headers property.',
511
+ });
512
+ }
513
+
514
+ /** Reconstructs a HeadersSubset from HTTP response headers for methods that return HeadersSubset */
515
+ function reconstructHeadersSubsetFromResponse(
516
+ methodId: string,
517
+ responseHeaders: Headers
518
+ ): HeadersSubset<string, string> | undefined {
519
+ const method = routesCache.getMetadata(methodId);
520
+
521
+ if (!method?.headersReturn?.headerNames || method.headersReturn.headerNames.length === 0) {
522
+ return undefined;
523
+ }
524
+
525
+ const headerNames = method.headersReturn.headerNames;
526
+ const headersMap: Record<string, string> = {};
527
+
528
+ for (const name of headerNames) {
529
+ const value = responseHeaders.get(name);
530
+ if (value !== undefined && value !== null) {
531
+ headersMap[name] = value;
532
+ }
533
+ }
534
+
535
+ if (Object.keys(headersMap).length > 0) {
536
+ return new HeadersSubset(headersMap);
537
+ }
538
+
539
+ return undefined;
540
+ }
541
+
542
+ /** Builds a RoutesFlowQuery from route paths and subrequests, collecting any mapFrom mappings */
543
+ function buildRoutesFlowQuery(routePaths: string[], workflowSubRequests: RouteSubRequest<any>[]): RoutesFlowQuery {
544
+ const allMappings: RoutesFlowMapping[] = [];
545
+ for (const sr of workflowSubRequests) {
546
+ // Duck-type check for mappings array (avoids circular import of MionSubRequest)
547
+ const mappings = (sr as any).mappings;
548
+ if (Array.isArray(mappings) && mappings.length > 0) {
549
+ for (const ref of mappings) {
550
+ allMappings.push({
551
+ fromId: ref.fromRequestId,
552
+ toId: ref.toRequestId,
553
+ bodyHash: ref.bodyHash,
554
+ paramIndex: ref.paramIndex,
555
+ });
556
+ }
557
+ }
558
+ }
559
+ return {
560
+ routes: routePaths,
561
+ mappings: allMappings.length > 0 ? allMappings : undefined,
562
+ };
563
+ }
@@ -0,0 +1,101 @@
1
+ /* ########
2
+ * 2025 mion
3
+ * Author: Ma-jerez
4
+ * License: MIT
5
+ * The software is provided "as is", without warranty of any kind.
6
+ * ######## */
7
+
8
+ import {PURE_SERVER_FN_NAMESPACE, RpcError} from '@mionjs/core';
9
+ import type {MiddlewareSubRequest, RouteSubRequest, RoutesFlowBuilder, SubRequest} from './types.ts';
10
+ import type {MionSubRequest} from './subRequest.ts';
11
+ import {MapFromServerFnRef} from '@mionjs/core';
12
+
13
+ /** Creates a RoutesFlowBuilder for deferred execution of multiple routes in a single HTTP request */
14
+ export function routesFlow<Routes extends RouteSubRequest<any>[]>(routeSubRequests: [...Routes]): RoutesFlowBuilder<Routes> {
15
+ if (!routeSubRequests || routeSubRequests.length === 0) {
16
+ throw new RpcError({
17
+ type: 'routesFlow-empty-routes',
18
+ publicMessage: 'RoutesFlow requires at least one route subrequest.',
19
+ });
20
+ }
21
+
22
+ const firstSubRequest = routeSubRequests[0] as MionSubRequest;
23
+ if (!firstSubRequest.client) {
24
+ throw new RpcError({
25
+ type: 'routesFlow-missing-client',
26
+ publicMessage: 'Could not extract MionClient from subrequest. Ensure subrequests are created via routes proxy.',
27
+ });
28
+ }
29
+
30
+ const client = firstSubRequest.client;
31
+
32
+ // Validate all subrequests use the same client instance
33
+ for (let i = 1; i < routeSubRequests.length; i++) {
34
+ const subRequest = routeSubRequests[i] as MionSubRequest;
35
+ if (subRequest.client !== client) {
36
+ throw new RpcError({
37
+ type: 'routesFlow-client-mismatch',
38
+ publicMessage: `All subrequests in a routesFlow must use the same client instance. Subrequest at index ${i} has a different client.`,
39
+ });
40
+ }
41
+ }
42
+
43
+ return {
44
+ async call(setup?: {middleFns?: Record<string, MiddlewareSubRequest<any>>; signal?: AbortSignal; timeout?: number}) {
45
+ const middleFns = setup?.middleFns ?? {};
46
+ const [results, errors, middleFnResults, middleFnErrors] = await client.execute(
47
+ undefined,
48
+ routeSubRequests as any,
49
+ middleFns as any,
50
+ setup?.signal,
51
+ setup?.timeout
52
+ );
53
+ const emptyResults = routeSubRequests.map(() => undefined);
54
+ const emptyErrors = routeSubRequests.map(() => undefined);
55
+ return [results ?? emptyResults, errors ?? emptyErrors, middleFnResults, middleFnErrors] as any;
56
+ },
57
+ };
58
+ }
59
+
60
+ export const mapFromSymbol = Symbol('MapFromServerFnRef');
61
+
62
+ // ╔══════════════════════════════════════════════════════════════════════════════╗
63
+ // ║ WARNING: This function's call signature is parsed by the mion vite plugin ║
64
+ // ║ at build time (see devtools/src/vite-plugin/extractPureFn.ts). ║
65
+ // ║ Do NOT rename, change the parameter order, or modify the function ║
66
+ // ║ signature without updating the corresponding AST extraction and ║
67
+ // ║ transformer logic in @mionjs/devtools. ║
68
+ // ╚══════════════════════════════════════════════════════════════════════════════╝
69
+ /**
70
+ * Maps the output of one route SubRequest to the input of another within a routesFlow.
71
+ * The mapper function must be pure (same rules as pureServerFn).
72
+ * The bodyHash is injected at build time by the mion vite plugin.
73
+ */
74
+
75
+ export function mapFrom<FromSR extends SubRequest<any>, MappedInput>(
76
+ source: FromSR,
77
+ mapper: (value: FromSR['resolvedValue']) => MappedInput,
78
+ bodyHash?: string // injected by mion vite plugin
79
+ ): MapFromServerFnRef<(value: FromSR['resolvedValue']) => MappedInput> {
80
+ // Important: bodyHash is injected at build time by mion vite plugin
81
+ if (!bodyHash) throw new Error('mapFrom() requires mion vite plugin transform to inject bodyHash');
82
+ const ref: MapFromServerFnRef<(value: FromSR['resolvedValue']) => MappedInput> = {
83
+ mapFromSymbol,
84
+ namespace: PURE_SERVER_FN_NAMESPACE,
85
+ fnName: bodyHash,
86
+ bodyHash,
87
+ pureFn: mapper,
88
+ isFactory: false,
89
+ fromRequestId: source.id,
90
+ toRequestId: '',
91
+ paramIndex: -1, // set by MionSubRequest constructor when passed as a parameter
92
+ type() {
93
+ return ref as unknown as MappedInput;
94
+ },
95
+ };
96
+ return ref;
97
+ }
98
+
99
+ export function isMapFromRef(ref: any): ref is MapFromServerFnRef<any> {
100
+ return ref && ref.mapFromSymbol === mapFromSymbol;
101
+ }