@fragno-dev/core 0.1.5 → 0.1.7

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 (71) hide show
  1. package/.turbo/turbo-build.log +49 -45
  2. package/CHANGELOG.md +52 -0
  3. package/dist/api/api.d.ts +2 -2
  4. package/dist/api/fragment-builder.d.ts +3 -2
  5. package/dist/api/fragment-instantiation.d.ts +4 -3
  6. package/dist/api/fragment-instantiation.js +3 -3
  7. package/dist/api/route.d.ts +3 -0
  8. package/dist/api/route.js +3 -0
  9. package/dist/{api-B1-h7jPC.d.ts → api-BWN97TOr.d.ts} +17 -3
  10. package/dist/api-BWN97TOr.d.ts.map +1 -0
  11. package/dist/api-DngJDcmO.js.map +1 -1
  12. package/dist/client/client.d.ts +4 -3
  13. package/dist/client/client.js +3 -3
  14. package/dist/client/client.svelte.d.ts +3 -3
  15. package/dist/client/client.svelte.d.ts.map +1 -1
  16. package/dist/client/client.svelte.js +3 -3
  17. package/dist/client/react.d.ts +3 -3
  18. package/dist/client/react.d.ts.map +1 -1
  19. package/dist/client/react.js +3 -3
  20. package/dist/client/solid.d.ts +3 -3
  21. package/dist/client/solid.d.ts.map +1 -1
  22. package/dist/client/solid.js +3 -3
  23. package/dist/client/vanilla.d.ts +3 -3
  24. package/dist/client/vanilla.d.ts.map +1 -1
  25. package/dist/client/vanilla.js +3 -3
  26. package/dist/client/vue.d.ts +3 -3
  27. package/dist/client/vue.d.ts.map +1 -1
  28. package/dist/client/vue.js +7 -7
  29. package/dist/client/vue.js.map +1 -1
  30. package/dist/{client-YUZaNg5U.js → client-C5LsYHEI.js} +92 -11
  31. package/dist/client-C5LsYHEI.js.map +1 -0
  32. package/dist/{fragment-builder-DsqUOfJ5.d.ts → fragment-builder-MGr68GNb.d.ts} +80 -44
  33. package/dist/fragment-builder-MGr68GNb.d.ts.map +1 -0
  34. package/dist/{fragment-instantiation-Cp0K8zdS.js → fragment-instantiation-C4wvwl6V.js} +108 -3
  35. package/dist/fragment-instantiation-C4wvwl6V.js.map +1 -0
  36. package/dist/mod.d.ts +3 -2
  37. package/dist/mod.js +3 -3
  38. package/dist/{route-Dk1GyqHs.js → request-output-context-CdIjwmEN.js} +13 -24
  39. package/dist/request-output-context-CdIjwmEN.js.map +1 -0
  40. package/dist/route-Bl9Zr1Yv.d.ts +26 -0
  41. package/dist/route-Bl9Zr1Yv.d.ts.map +1 -0
  42. package/dist/route-C5Uryylh.js +21 -0
  43. package/dist/route-C5Uryylh.js.map +1 -0
  44. package/dist/test/test.d.ts +24 -70
  45. package/dist/test/test.d.ts.map +1 -1
  46. package/dist/test/test.js +27 -115
  47. package/dist/test/test.js.map +1 -1
  48. package/package.json +6 -1
  49. package/src/api/api.ts +1 -0
  50. package/src/api/fragment-instantiation.test.ts +460 -0
  51. package/src/api/fragment-instantiation.ts +121 -0
  52. package/src/api/fragno-response.ts +132 -0
  53. package/src/api/internal/path-type.test.ts +7 -7
  54. package/src/api/internal/path.ts +1 -1
  55. package/src/api/request-output-context.test.ts +10 -10
  56. package/src/api/request-output-context.ts +3 -3
  57. package/src/api/route-handler-input-options.ts +15 -0
  58. package/src/client/client-types.test.ts +4 -4
  59. package/src/client/client.test.ts +341 -0
  60. package/src/client/client.ts +96 -15
  61. package/src/client/internal/fetcher-merge.ts +59 -0
  62. package/src/test/test.test.ts +110 -165
  63. package/src/test/test.ts +56 -266
  64. package/tsdown.config.ts +1 -0
  65. package/dist/api-B1-h7jPC.d.ts.map +0 -1
  66. package/dist/client-YUZaNg5U.js.map +0 -1
  67. package/dist/fragment-builder-DsqUOfJ5.d.ts.map +0 -1
  68. package/dist/fragment-instantiation-Cp0K8zdS.js.map +0 -1
  69. package/dist/route-CTxjMtGZ.js +0 -10
  70. package/dist/route-CTxjMtGZ.js.map +0 -1
  71. package/dist/route-Dk1GyqHs.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { nanoquery, type FetcherStore, type MutatorStore } from "@nanostores/query";
2
2
  import type { StandardSchemaV1 } from "@standard-schema/spec";
3
- import { task, type ReadableAtom, type Store } from "nanostores";
3
+ import { computed, task, type ReadableAtom, type Store } from "nanostores";
4
4
  import type { FragnoRouteConfig, HTTPMethod, NonGetHTTPMethod } from "../api/api";
5
5
  import {
6
6
  buildPath,
@@ -13,6 +13,7 @@ import { getMountRoute } from "../api/internal/route";
13
13
  import { RequestInputContext } from "../api/request-input-context";
14
14
  import { RequestOutputContext } from "../api/request-output-context";
15
15
  import type {
16
+ FetcherConfig,
16
17
  FragnoFragmentSharedConfig,
17
18
  FragnoPublicClientConfig,
18
19
  } from "../api/fragment-instantiation";
@@ -31,6 +32,7 @@ import {
31
32
  type FlattenRouteFactories,
32
33
  resolveRouteFactories,
33
34
  } from "../api/route";
35
+ import { mergeFetcherConfigs } from "./internal/fetcher-merge";
34
36
 
35
37
  /**
36
38
  * Symbols used to identify hook types
@@ -250,11 +252,11 @@ export type FragnoClientHookData<
250
252
  >;
251
253
  query(args?: {
252
254
  path?: MaybeExtractPathParamsOrWiden<TPath, string>;
253
- query?: Record<TQueryParameters, string>;
255
+ query?: Record<TQueryParameters, string | undefined>;
254
256
  }): Promise<StandardSchemaV1.InferOutput<TOutputSchema>>;
255
257
  store(args?: {
256
258
  path?: MaybeExtractPathParamsOrWiden<TPath, string | ReadableAtom<string>>;
257
- query?: Record<TQueryParameters, string | ReadableAtom<string>>;
259
+ query?: Record<TQueryParameters, string | undefined | ReadableAtom<string | undefined>>;
258
260
  }): FetcherStore<StandardSchemaV1.InferOutput<TOutputSchema>, FragnoClientError<TErrorCode>>;
259
261
  [GET_HOOK_SYMBOL]: true;
260
262
  } & {
@@ -285,14 +287,14 @@ export type FragnoClientMutatorData<
285
287
  mutateQuery(args?: {
286
288
  body?: InferOr<TInputSchema, undefined>;
287
289
  path?: MaybeExtractPathParamsOrWiden<TPath, string>;
288
- query?: Record<TQueryParameters, string>;
290
+ query?: Record<TQueryParameters, string | undefined>;
289
291
  }): Promise<InferOr<TOutputSchema, undefined>>;
290
292
 
291
293
  mutatorStore: MutatorStore<
292
294
  {
293
295
  body?: InferOr<TInputSchema, undefined>;
294
296
  path?: MaybeExtractPathParamsOrWiden<TPath, string | ReadableAtom<string>>;
295
- query?: Record<TQueryParameters, string | ReadableAtom<string>>;
297
+ query?: Record<TQueryParameters, string | undefined | ReadableAtom<string | undefined>>;
296
298
  },
297
299
  InferOr<TOutputSchema, undefined>,
298
300
  FragnoClientError<TErrorCode>
@@ -311,7 +313,7 @@ export function buildUrl<TPath extends string>(
311
313
  },
312
314
  params: {
313
315
  pathParams?: Record<string, string | ReadableAtom<string>>;
314
- queryParams?: Record<string, string | ReadableAtom<string>>;
316
+ queryParams?: Record<string, string | undefined | ReadableAtom<string | undefined>>;
315
317
  },
316
318
  ): string {
317
319
  const { baseUrl = "", mountRoute, path } = config;
@@ -320,7 +322,12 @@ export function buildUrl<TPath extends string>(
320
322
  const normalizedPathParams = unwrapObject(pathParams) as ExtractPathParams<TPath, string>;
321
323
  const normalizedQueryParams = unwrapObject(queryParams) ?? {};
322
324
 
323
- const searchParams = new URLSearchParams(normalizedQueryParams);
325
+ // Filter out undefined values to prevent URLSearchParams from converting them to string "undefined"
326
+ const filteredQueryParams = Object.fromEntries(
327
+ Object.entries(normalizedQueryParams).filter(([_, value]) => value !== undefined),
328
+ ) as Record<string, string>;
329
+
330
+ const searchParams = new URLSearchParams(filteredQueryParams);
324
331
  const builtPath = buildPath(path, normalizedPathParams ?? {});
325
332
  const search = searchParams.toString() ? `?${searchParams.toString()}` : "";
326
333
  return `${baseUrl}${mountRoute}${builtPath}${search}`;
@@ -331,6 +338,7 @@ export function buildUrl<TPath extends string>(
331
338
  *
332
339
  * The returned array is always: path, pathParams (In order they appear in the path), queryParams (In alphabetical order)
333
340
  * Missing pathParams are replaced with "<missing>".
341
+ * Atoms with undefined values are wrapped in computed atoms that map undefined to "" to avoid nanoquery treating the key as incomplete.
334
342
  * @param path
335
343
  * @param params
336
344
  * @returns
@@ -340,7 +348,7 @@ export function getCacheKey<TMethod extends HTTPMethod, TPath extends string>(
340
348
  path: TPath,
341
349
  params?: {
342
350
  pathParams?: Record<string, string | ReadableAtom<string>>;
343
- queryParams?: Record<string, string | ReadableAtom<string>>;
351
+ queryParams?: Record<string, string | undefined | ReadableAtom<string | undefined>>;
344
352
  },
345
353
  ): (string | ReadableAtom<string>)[] {
346
354
  if (!params) {
@@ -355,7 +363,15 @@ export function getCacheKey<TMethod extends HTTPMethod, TPath extends string>(
355
363
  const queryParamValues = queryParams
356
364
  ? Object.keys(queryParams)
357
365
  .sort()
358
- .map((key) => queryParams[key])
366
+ .map((key) => {
367
+ const value = queryParams[key];
368
+ // If it's an atom, wrap it to convert undefined to ""
369
+ if (value && typeof value === "object" && "get" in value) {
370
+ return computed(value as ReadableAtom<string | undefined>, (v) => v ?? "");
371
+ }
372
+ // Plain string value (or undefined)
373
+ return value ?? "";
374
+ })
359
375
  : [];
360
376
 
361
377
  return [method, path, ...pathParamValues, ...queryParamValues];
@@ -487,6 +503,7 @@ export class ClientBuilder<
487
503
  > {
488
504
  #publicConfig: FragnoPublicClientConfig;
489
505
  #fragmentConfig: TFragmentConfig;
506
+ #fetcherConfig?: FetcherConfig;
490
507
 
491
508
  #cache = new Map<string, CacheLine>();
492
509
 
@@ -497,6 +514,7 @@ export class ClientBuilder<
497
514
  constructor(publicConfig: FragnoPublicClientConfig, fragmentConfig: TFragmentConfig) {
498
515
  this.#publicConfig = publicConfig;
499
516
  this.#fragmentConfig = fragmentConfig;
517
+ this.#fetcherConfig = publicConfig.fetcherConfig;
500
518
 
501
519
  const [createFetcherStore, createMutatorStore, { invalidateKeys }] = nanoquery({
502
520
  cache: this.#cache,
@@ -514,6 +532,54 @@ export class ClientBuilder<
514
532
  return { obj: obj, [STORE_SYMBOL]: true };
515
533
  }
516
534
 
535
+ /**
536
+ * Build a URL for a custom backend call using the configured baseUrl and mountRoute.
537
+ * Useful for fragment authors who need to make custom fetch calls.
538
+ */
539
+ buildUrl<TPath extends string>(
540
+ path: TPath,
541
+ params?: {
542
+ path?: MaybeExtractPathParamsOrWiden<TPath, string>;
543
+ query?: Record<string, string>;
544
+ },
545
+ ): string {
546
+ const baseUrl = this.#publicConfig.baseUrl ?? "";
547
+ const mountRoute = getMountRoute(this.#fragmentConfig);
548
+
549
+ return buildUrl(
550
+ { baseUrl, mountRoute, path },
551
+ { pathParams: params?.path, queryParams: params?.query },
552
+ );
553
+ }
554
+
555
+ /**
556
+ * Get the configured fetcher function for custom backend calls.
557
+ * Returns fetch with merged options applied.
558
+ */
559
+ getFetcher(): {
560
+ fetcher: typeof fetch;
561
+ defaultOptions: RequestInit | undefined;
562
+ } {
563
+ return {
564
+ fetcher: this.#getFetcher(),
565
+ defaultOptions: this.#getFetcherOptions(),
566
+ };
567
+ }
568
+
569
+ #getFetcher(): typeof fetch {
570
+ if (this.#fetcherConfig?.type === "function") {
571
+ return this.#fetcherConfig.fetcher;
572
+ }
573
+ return fetch;
574
+ }
575
+
576
+ #getFetcherOptions(): RequestInit | undefined {
577
+ if (this.#fetcherConfig?.type === "options") {
578
+ return this.#fetcherConfig.options;
579
+ }
580
+ return undefined;
581
+ }
582
+
517
583
  createHook<TPath extends ExtractGetRoutePaths<TFragmentConfig["routes"]>>(
518
584
  path: ValidateGetRoutePath<TFragmentConfig["routes"], TPath>,
519
585
  options?: CreateHookOptions,
@@ -611,17 +677,24 @@ export class ClientBuilder<
611
677
 
612
678
  const baseUrl = this.#publicConfig.baseUrl ?? "";
613
679
  const mountRoute = getMountRoute(this.#fragmentConfig);
680
+ const fetcher = this.#getFetcher();
681
+ const fetcherOptions = this.#getFetcherOptions();
614
682
 
615
683
  async function callServerSideHandler(params: {
616
684
  pathParams?: Record<string, string | ReadableAtom<string>>;
617
- queryParams?: Record<string, string | ReadableAtom<string>>;
685
+ queryParams?: Record<string, string | undefined | ReadableAtom<string | undefined>>;
618
686
  }): Promise<Response> {
619
687
  const { pathParams, queryParams } = params ?? {};
620
688
 
621
689
  const normalizedPathParams = unwrapObject(pathParams) as ExtractPathParams<TPath, string>;
622
690
  const normalizedQueryParams = unwrapObject(queryParams) ?? {};
623
691
 
624
- const searchParams = new URLSearchParams(normalizedQueryParams);
692
+ // Filter out undefined values to prevent URLSearchParams from converting them to string "undefined"
693
+ const filteredQueryParams = Object.fromEntries(
694
+ Object.entries(normalizedQueryParams).filter(([_, value]) => value !== undefined),
695
+ ) as Record<string, string>;
696
+
697
+ const searchParams = new URLSearchParams(filteredQueryParams);
625
698
 
626
699
  const result = await route.handler(
627
700
  RequestInputContext.fromSSRContext({
@@ -638,7 +711,7 @@ export class ClientBuilder<
638
711
 
639
712
  async function executeQuery(params?: {
640
713
  pathParams?: Record<string, string | ReadableAtom<string>>;
641
- queryParams?: Record<string, string | ReadableAtom<string>>;
714
+ queryParams?: Record<string, string | undefined | ReadableAtom<string | undefined>>;
642
715
  }): Promise<Response> {
643
716
  const { pathParams, queryParams } = params ?? {};
644
717
 
@@ -650,7 +723,7 @@ export class ClientBuilder<
650
723
 
651
724
  let response: Response;
652
725
  try {
653
- response = await fetch(url);
726
+ response = fetcherOptions ? await fetcher(url, fetcherOptions) : await fetcher(url);
654
727
  } catch (error) {
655
728
  throw FragnoClientFetchError.fromUnknownFetchError(error);
656
729
  }
@@ -795,6 +868,8 @@ export class ClientBuilder<
795
868
 
796
869
  const baseUrl = this.#publicConfig.baseUrl ?? "";
797
870
  const mountRoute = getMountRoute(this.#fragmentConfig);
871
+ const fetcher = this.#getFetcher();
872
+ const fetcherOptions = this.#getFetcherOptions();
798
873
 
799
874
  async function executeMutateQuery({
800
875
  body,
@@ -828,10 +903,12 @@ export class ClientBuilder<
828
903
 
829
904
  let response: Response;
830
905
  try {
831
- response = await fetch(url, {
906
+ const requestOptions: RequestInit = {
907
+ ...fetcherOptions,
832
908
  method,
833
909
  body: body !== undefined ? JSON.stringify(body) : undefined,
834
- });
910
+ };
911
+ response = await fetcher(url, requestOptions);
835
912
  } catch (error) {
836
913
  throw FragnoClientFetchError.fromUnknownFetchError(error);
837
914
  }
@@ -1008,6 +1085,7 @@ export function createClientBuilder<
1008
1085
  },
1009
1086
  publicConfig: FragnoPublicClientConfig,
1010
1087
  routesOrFactories: TRoutesOrFactories,
1088
+ authorFetcherConfig?: FetcherConfig,
1011
1089
  ): ClientBuilder<
1012
1090
  FlattenRouteFactories<TRoutesOrFactories>,
1013
1091
  FragnoFragmentSharedConfig<FlattenRouteFactories<TRoutesOrFactories>>
@@ -1030,12 +1108,15 @@ export function createClientBuilder<
1030
1108
  };
1031
1109
 
1032
1110
  const mountRoute = publicConfig.mountRoute ?? `/${definition.name}`;
1111
+ const mergedFetcherConfig = mergeFetcherConfigs(authorFetcherConfig, publicConfig.fetcherConfig);
1033
1112
  const fullPublicConfig = {
1034
1113
  ...publicConfig,
1035
1114
  mountRoute,
1115
+ fetcherConfig: mergedFetcherConfig,
1036
1116
  };
1037
1117
 
1038
1118
  return new ClientBuilder(fullPublicConfig, fragmentConfig);
1039
1119
  }
1040
1120
 
1041
1121
  export * from "./client-error";
1122
+ export type { FetcherConfig };
@@ -0,0 +1,59 @@
1
+ import type { FetcherConfig } from "../../api/fragment-instantiation";
2
+
3
+ /**
4
+ * Merge two fetcher configurations, with user config taking precedence.
5
+ * If user provides a custom function, it takes full precedence.
6
+ * Otherwise, deep merge RequestInit options.
7
+ */
8
+ export function mergeFetcherConfigs(
9
+ authorConfig?: FetcherConfig,
10
+ userConfig?: FetcherConfig,
11
+ ): FetcherConfig | undefined {
12
+ // If user provides custom function, it takes full precedence
13
+ if (userConfig?.type === "function") {
14
+ return userConfig;
15
+ }
16
+
17
+ if (!userConfig && authorConfig?.type === "function") {
18
+ return authorConfig;
19
+ }
20
+
21
+ // Deep merge RequestInit options
22
+ const authorOpts = authorConfig?.type === "options" ? authorConfig.options : {};
23
+ const userOpts = userConfig?.type === "options" ? userConfig.options : {};
24
+
25
+ // If both are empty, return undefined
26
+ if (Object.keys(authorOpts).length === 0 && Object.keys(userOpts).length === 0) {
27
+ return undefined;
28
+ }
29
+
30
+ return {
31
+ type: "options",
32
+ options: {
33
+ ...authorOpts,
34
+ ...userOpts,
35
+ headers: mergeHeaders(authorOpts.headers, userOpts.headers),
36
+ },
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Merge headers from author and user configs.
42
+ * User headers override author headers.
43
+ */
44
+ function mergeHeaders(author?: HeadersInit, user?: HeadersInit): HeadersInit | undefined {
45
+ if (!author && !user) {
46
+ return undefined;
47
+ }
48
+
49
+ // Convert to Headers objects and merge
50
+ const merged = new Headers(author);
51
+ new Headers(user).forEach((value, key) => merged.set(key, value));
52
+
53
+ // If no headers after merge, return undefined
54
+ if (merged.keys().next().done) {
55
+ return undefined;
56
+ }
57
+
58
+ return merged;
59
+ }