@fragno-dev/core 0.1.11 → 0.2.0

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 (69) hide show
  1. package/.turbo/turbo-build.log +50 -42
  2. package/CHANGELOG.md +51 -0
  3. package/dist/api/api.d.ts +19 -1
  4. package/dist/api/api.d.ts.map +1 -1
  5. package/dist/api/api.js.map +1 -1
  6. package/dist/api/fragment-definition-builder.d.ts +17 -7
  7. package/dist/api/fragment-definition-builder.d.ts.map +1 -1
  8. package/dist/api/fragment-definition-builder.js +3 -2
  9. package/dist/api/fragment-definition-builder.js.map +1 -1
  10. package/dist/api/fragment-instantiator.d.ts +23 -16
  11. package/dist/api/fragment-instantiator.d.ts.map +1 -1
  12. package/dist/api/fragment-instantiator.js +163 -19
  13. package/dist/api/fragment-instantiator.js.map +1 -1
  14. package/dist/api/request-input-context.d.ts +57 -1
  15. package/dist/api/request-input-context.d.ts.map +1 -1
  16. package/dist/api/request-input-context.js +67 -0
  17. package/dist/api/request-input-context.js.map +1 -1
  18. package/dist/api/request-middleware.d.ts +1 -1
  19. package/dist/api/request-middleware.d.ts.map +1 -1
  20. package/dist/api/request-middleware.js.map +1 -1
  21. package/dist/api/route.d.ts +7 -7
  22. package/dist/api/route.d.ts.map +1 -1
  23. package/dist/api/route.js.map +1 -1
  24. package/dist/client/client.d.ts +4 -3
  25. package/dist/client/client.d.ts.map +1 -1
  26. package/dist/client/client.js +103 -7
  27. package/dist/client/client.js.map +1 -1
  28. package/dist/client/vue.d.ts +7 -3
  29. package/dist/client/vue.d.ts.map +1 -1
  30. package/dist/client/vue.js +16 -1
  31. package/dist/client/vue.js.map +1 -1
  32. package/dist/internal/trace-context.d.ts +23 -0
  33. package/dist/internal/trace-context.d.ts.map +1 -0
  34. package/dist/internal/trace-context.js +14 -0
  35. package/dist/internal/trace-context.js.map +1 -0
  36. package/dist/mod-client.d.ts +3 -17
  37. package/dist/mod-client.d.ts.map +1 -1
  38. package/dist/mod-client.js +20 -10
  39. package/dist/mod-client.js.map +1 -1
  40. package/dist/mod.d.ts +3 -2
  41. package/dist/mod.js +2 -1
  42. package/dist/runtime.d.ts +15 -0
  43. package/dist/runtime.d.ts.map +1 -0
  44. package/dist/runtime.js +33 -0
  45. package/dist/runtime.js.map +1 -0
  46. package/dist/test/test.d.ts +2 -2
  47. package/dist/test/test.d.ts.map +1 -1
  48. package/dist/test/test.js.map +1 -1
  49. package/package.json +23 -17
  50. package/src/api/api.ts +22 -0
  51. package/src/api/fragment-definition-builder.ts +36 -17
  52. package/src/api/fragment-instantiator.test.ts +286 -0
  53. package/src/api/fragment-instantiator.ts +338 -31
  54. package/src/api/internal/path-runtime.test.ts +7 -0
  55. package/src/api/request-input-context.test.ts +152 -0
  56. package/src/api/request-input-context.ts +85 -0
  57. package/src/api/request-middleware.test.ts +47 -1
  58. package/src/api/request-middleware.ts +1 -1
  59. package/src/api/route.ts +7 -2
  60. package/src/client/client.test.ts +195 -0
  61. package/src/client/client.ts +185 -10
  62. package/src/client/vue.test.ts +253 -3
  63. package/src/client/vue.ts +44 -1
  64. package/src/internal/trace-context.ts +35 -0
  65. package/src/mod-client.ts +51 -7
  66. package/src/mod.ts +6 -1
  67. package/src/runtime.ts +48 -0
  68. package/src/test/test.ts +13 -4
  69. package/tsdown.config.ts +1 -0
@@ -6,6 +6,7 @@ import type {
6
6
  HTTPMethod,
7
7
  NonGetHTTPMethod,
8
8
  RequestThisContext,
9
+ RouteContentType,
9
10
  } from "../api/api";
10
11
  import {
11
12
  buildPath,
@@ -33,6 +34,7 @@ import {
33
34
  import { addStore, getInitialData, SSR_ENABLED } from "../util/ssr";
34
35
  import { unwrapObject } from "../util/nanostores";
35
36
  import type { FragmentDefinition } from "../api/fragment-definition-builder";
37
+ import type { AnyFragnoInstantiatedFragment } from "../api/fragment-instantiator";
36
38
  import {
37
39
  type AnyRouteOrFactory,
38
40
  type FlattenRouteFactories,
@@ -47,6 +49,147 @@ const GET_HOOK_SYMBOL = Symbol("fragno-get-hook");
47
49
  const MUTATOR_HOOK_SYMBOL = Symbol("fragno-mutator-hook");
48
50
  const STORE_SYMBOL = Symbol("fragno-store");
49
51
 
52
+ /**
53
+ * Check if a value contains files that should be sent as FormData.
54
+ * @internal
55
+ */
56
+ function containsFiles(value: unknown): boolean {
57
+ if (value instanceof File || value instanceof Blob) {
58
+ return true;
59
+ }
60
+
61
+ if (value instanceof FormData) {
62
+ return true;
63
+ }
64
+
65
+ if (typeof value === "object" && value !== null) {
66
+ return Object.values(value).some(
67
+ (v) => v instanceof File || v instanceof Blob || v instanceof FormData,
68
+ );
69
+ }
70
+
71
+ return false;
72
+ }
73
+
74
+ /**
75
+ * Convert an object containing files to FormData.
76
+ * Handles nested File/Blob values by appending them directly.
77
+ * Other values are JSON-stringified.
78
+ * @internal
79
+ */
80
+ function toFormData(value: object): FormData {
81
+ const formData = new FormData();
82
+
83
+ for (const [key, val] of Object.entries(value)) {
84
+ if (val instanceof File) {
85
+ formData.append(key, val, val.name);
86
+ } else if (val instanceof Blob) {
87
+ formData.append(key, val);
88
+ } else if (val !== undefined && val !== null) {
89
+ // For non-file values, stringify if needed
90
+ formData.append(key, typeof val === "string" ? val : JSON.stringify(val));
91
+ }
92
+ }
93
+
94
+ return formData;
95
+ }
96
+
97
+ /**
98
+ * Prepare request body and headers for sending.
99
+ * Handles FormData (file uploads) vs JSON data.
100
+ * @internal
101
+ */
102
+ function prepareRequestBody(
103
+ body: unknown,
104
+ contentType?: RouteContentType,
105
+ ): { body: BodyInit | undefined; headers?: HeadersInit } {
106
+ if (body === undefined) {
107
+ return { body: undefined };
108
+ }
109
+
110
+ if (contentType === "application/octet-stream") {
111
+ if (
112
+ body instanceof ReadableStream ||
113
+ body instanceof Blob ||
114
+ body instanceof File ||
115
+ body instanceof ArrayBuffer ||
116
+ body instanceof Uint8Array
117
+ ) {
118
+ return { body: body as BodyInit, headers: { "Content-Type": "application/octet-stream" } };
119
+ }
120
+
121
+ throw new Error(
122
+ "Octet-stream routes only accept Blob, File, ArrayBuffer, Uint8Array, or ReadableStream bodies.",
123
+ );
124
+ }
125
+
126
+ // If already FormData, send as-is (browser sets Content-Type with boundary)
127
+ if (body instanceof FormData) {
128
+ return { body };
129
+ }
130
+
131
+ // If body is directly a File or Blob, wrap it in FormData
132
+ if (body instanceof File) {
133
+ const formData = new FormData();
134
+ formData.append("file", body, body.name);
135
+ return { body: formData };
136
+ }
137
+
138
+ if (body instanceof Blob) {
139
+ const formData = new FormData();
140
+ formData.append("file", body);
141
+ return { body: formData };
142
+ }
143
+
144
+ // If object contains files, convert to FormData
145
+ if (typeof body === "object" && body !== null && containsFiles(body)) {
146
+ return { body: toFormData(body) };
147
+ }
148
+
149
+ // Otherwise, JSON-stringify
150
+ return {
151
+ body: JSON.stringify(body),
152
+ headers: { "Content-Type": "application/json" },
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Merge request headers from multiple sources.
158
+ * Returns undefined if there are no headers to merge.
159
+ * @internal
160
+ */
161
+ function mergeRequestHeaders(
162
+ ...headerSources: (HeadersInit | undefined)[]
163
+ ): Record<string, string> | undefined {
164
+ const result: Record<string, string> = {};
165
+ let hasHeaders = false;
166
+
167
+ for (const source of headerSources) {
168
+ if (!source) {
169
+ continue;
170
+ }
171
+
172
+ if (source instanceof Headers) {
173
+ for (const [key, value] of source.entries()) {
174
+ result[key] = value;
175
+ hasHeaders = true;
176
+ }
177
+ } else if (Array.isArray(source)) {
178
+ for (const [key, value] of source) {
179
+ result[key] = value;
180
+ hasHeaders = true;
181
+ }
182
+ } else {
183
+ for (const [key, value] of Object.entries(source)) {
184
+ result[key] = value;
185
+ hasHeaders = true;
186
+ }
187
+ }
188
+ }
189
+
190
+ return hasHeaders ? result : undefined;
191
+ }
192
+
50
193
  /**
51
194
  * Extract only GET routes from a library config's routes array
52
195
  * @internal
@@ -585,7 +728,10 @@ export class ClientBuilder<
585
728
  },
586
729
  ): string {
587
730
  const baseUrl = this.#publicConfig.baseUrl ?? "";
588
- const mountRoute = getMountRoute(this.#fragmentConfig);
731
+ const mountRoute = getMountRoute({
732
+ name: this.#fragmentConfig.name,
733
+ mountRoute: this.#publicConfig.mountRoute,
734
+ });
589
735
 
590
736
  return buildUrl(
591
737
  { baseUrl, mountRoute, path },
@@ -627,9 +773,11 @@ export class ClientBuilder<
627
773
  ): FragnoClientHookData<
628
774
  "GET",
629
775
  TPath,
630
- NonNullable<ExtractRouteByPath<TFragmentConfig["routes"], TPath>["outputSchema"]>,
631
- NonNullable<ExtractRouteByPath<TFragmentConfig["routes"], TPath>["errorCodes"]>[number],
632
- NonNullable<ExtractRouteByPath<TFragmentConfig["routes"], TPath>["queryParameters"]>[number]
776
+ NonNullable<ExtractRouteByPath<TFragmentConfig["routes"], TPath, "GET">["outputSchema"]>,
777
+ NonNullable<ExtractRouteByPath<TFragmentConfig["routes"], TPath, "GET">["errorCodes"]>[number],
778
+ NonNullable<
779
+ ExtractRouteByPath<TFragmentConfig["routes"], TPath, "GET">["queryParameters"]
780
+ >[number]
633
781
  > {
634
782
  const route = this.#fragmentConfig.routes.find(
635
783
  (
@@ -717,7 +865,10 @@ export class ClientBuilder<
717
865
  }
718
866
 
719
867
  const baseUrl = this.#publicConfig.baseUrl ?? "";
720
- const mountRoute = getMountRoute(this.#fragmentConfig);
868
+ const mountRoute = getMountRoute({
869
+ name: this.#fragmentConfig.name,
870
+ mountRoute: this.#publicConfig.mountRoute,
871
+ });
721
872
  const fetcher = this.#getFetcher();
722
873
  const fetcherOptions = this.#getFetcherOptions();
723
874
 
@@ -908,7 +1059,10 @@ export class ClientBuilder<
908
1059
  const method = route.method;
909
1060
 
910
1061
  const baseUrl = this.#publicConfig.baseUrl ?? "";
911
- const mountRoute = getMountRoute(this.#fragmentConfig);
1062
+ const mountRoute = getMountRoute({
1063
+ name: this.#fragmentConfig.name,
1064
+ mountRoute: this.#publicConfig.mountRoute,
1065
+ });
912
1066
  const fetcher = this.#getFetcher();
913
1067
  const fetcherOptions = this.#getFetcherOptions();
914
1068
 
@@ -944,11 +1098,27 @@ export class ClientBuilder<
944
1098
 
945
1099
  let response: Response;
946
1100
  try {
947
- const requestOptions: RequestInit = {
1101
+ const { body: preparedBody, headers: bodyHeaders } = prepareRequestBody(
1102
+ body,
1103
+ route.contentType,
1104
+ );
1105
+
1106
+ // Merge headers: fetcherOptions headers + body-specific headers (e.g., Content-Type for JSON)
1107
+ // For FormData, bodyHeaders is undefined and browser sets Content-Type with boundary automatically
1108
+ const mergedHeaders = mergeRequestHeaders(
1109
+ fetcherOptions?.headers as HeadersInit | undefined,
1110
+ bodyHeaders,
1111
+ );
1112
+
1113
+ const requestOptions: RequestInit & { duplex?: "half" } = {
948
1114
  ...fetcherOptions,
949
1115
  method,
950
- body: body !== undefined ? JSON.stringify(body) : undefined,
1116
+ body: preparedBody,
1117
+ ...(mergedHeaders ? { headers: mergedHeaders } : {}),
951
1118
  };
1119
+ if (preparedBody instanceof ReadableStream) {
1120
+ requestOptions.duplex = "half";
1121
+ }
952
1122
  response = await fetcher(url, requestOptions);
953
1123
  } catch (error) {
954
1124
  throw FragnoClientFetchError.fromUnknownFetchError(error);
@@ -1130,6 +1300,7 @@ export function createClientBuilder<
1130
1300
  THandlerThisContext extends RequestThisContext,
1131
1301
  TRequestStorage,
1132
1302
  const TRoutesOrFactories extends readonly AnyRouteOrFactory[],
1303
+ TLinkedFragments extends Record<string, AnyFragnoInstantiatedFragment> = {},
1133
1304
  >(
1134
1305
  definition: FragmentDefinition<
1135
1306
  TConfig,
@@ -1141,7 +1312,8 @@ export function createClientBuilder<
1141
1312
  TPrivateServices,
1142
1313
  TServiceThisContext,
1143
1314
  THandlerThisContext,
1144
- TRequestStorage
1315
+ TRequestStorage,
1316
+ TLinkedFragments
1145
1317
  >,
1146
1318
  publicConfig: FragnoPublicClientConfig,
1147
1319
  routesOrFactories: TRoutesOrFactories,
@@ -1166,7 +1338,10 @@ export function createClientBuilder<
1166
1338
  routes,
1167
1339
  };
1168
1340
 
1169
- const mountRoute = publicConfig.mountRoute ?? `/${definition.name}`;
1341
+ const mountRoute = getMountRoute({
1342
+ name: definition.name,
1343
+ mountRoute: publicConfig.mountRoute,
1344
+ });
1170
1345
  const mergedFetcherConfig = mergeFetcherConfigs(authorFetcherConfig, publicConfig.fetcherConfig);
1171
1346
  const fullPublicConfig = {
1172
1347
  ...publicConfig,
@@ -1,4 +1,4 @@
1
- import { test, expect, describe, vi, beforeEach, afterEach, assert } from "vitest";
1
+ import { test, expect, describe, vi, beforeEach, afterEach, assert, expectTypeOf } from "vitest";
2
2
  import { type FragnoPublicClientConfig } from "./client";
3
3
  import { createClientBuilder } from "./client";
4
4
  import { defineRoute } from "../api/route";
@@ -6,9 +6,9 @@ import { defineFragment } from "../api/fragment-definition-builder";
6
6
  import { z } from "zod";
7
7
  import { refToAtom, useFragno } from "./vue";
8
8
  import { waitFor } from "@testing-library/vue";
9
- import { nextTick, ref, watch } from "vue";
9
+ import { nextTick, ref, watch, effectScope } from "vue";
10
10
  import { FragnoClientUnknownApiError } from "./client-error";
11
- import { atom } from "nanostores";
11
+ import { atom, computed, type ReadableAtom } from "nanostores";
12
12
 
13
13
  global.fetch = vi.fn();
14
14
 
@@ -752,3 +752,253 @@ describe("useFragno", () => {
752
752
  expect(typeof result.usePostAction).toBe("function");
753
753
  });
754
754
  });
755
+
756
+ describe("useFragno - createStore", () => {
757
+ const clientConfig: FragnoPublicClientConfig = {
758
+ baseUrl: "http://localhost:3000",
759
+ };
760
+
761
+ beforeEach(() => {
762
+ vi.clearAllMocks();
763
+ (global.fetch as ReturnType<typeof vi.fn>).mockReset();
764
+ });
765
+
766
+ afterEach(() => {
767
+ vi.restoreAllMocks();
768
+ });
769
+
770
+ test("FragnoVueStore type test - ReadableAtom fields", () => {
771
+ // Test that ReadableAtom fields are properly unwrapped to their value types
772
+ const stringAtom: ReadableAtom<string> = atom("hello");
773
+ const numberAtom: ReadableAtom<number> = atom(42);
774
+ const booleanAtom: ReadableAtom<boolean> = atom(true);
775
+ const objectAtom: ReadableAtom<{ count: number }> = atom({ count: 0 });
776
+ const arrayAtom: ReadableAtom<string[]> = atom(["a", "b", "c"]);
777
+
778
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
779
+ const client = {
780
+ useStore: cb.createStore({
781
+ message: stringAtom,
782
+ count: numberAtom,
783
+ isActive: booleanAtom,
784
+ data: objectAtom,
785
+ items: arrayAtom,
786
+ }),
787
+ };
788
+
789
+ const { useStore } = useFragno(client);
790
+
791
+ // Type assertions to ensure the types are correctly inferred
792
+ expectTypeOf(useStore).toExtend<
793
+ () => {
794
+ message: string;
795
+ count: number;
796
+ isActive: boolean;
797
+ data: { count: number };
798
+ items: string[];
799
+ }
800
+ >();
801
+
802
+ // Runtime test - need to run in effect scope for Vue reactivity
803
+ const scope = effectScope();
804
+ scope.run(() => {
805
+ const result = useStore();
806
+ expect(result.message).toBe("hello");
807
+ expect(result.count).toBe(42);
808
+ expect(result.isActive).toBe(true);
809
+ expect(result.data).toEqual({ count: 0 });
810
+ expect(result.items).toEqual(["a", "b", "c"]);
811
+ });
812
+ scope.stop();
813
+ });
814
+
815
+ test("FragnoVueStore type test - computed stores", () => {
816
+ // Test that computed stores (which are also ReadableAtom) are properly unwrapped
817
+ const baseNumber = atom(10);
818
+ const doubled = computed(baseNumber, (n) => n * 2);
819
+ const tripled = computed(baseNumber, (n) => n * 3);
820
+ const combined = computed([doubled, tripled], (d, t) => ({ doubled: d, tripled: t }));
821
+
822
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
823
+ const client = {
824
+ useComputedValues: cb.createStore({
825
+ base: baseNumber,
826
+ doubled: doubled,
827
+ tripled: tripled,
828
+ combined: combined,
829
+ }),
830
+ };
831
+
832
+ const { useComputedValues } = useFragno(client);
833
+
834
+ // Type assertions
835
+ expectTypeOf(useComputedValues).toExtend<
836
+ () => {
837
+ base: number;
838
+ doubled: number;
839
+ tripled: number;
840
+ combined: { doubled: number; tripled: number };
841
+ }
842
+ >();
843
+
844
+ // Runtime test
845
+ const scope = effectScope();
846
+ scope.run(() => {
847
+ const result = useComputedValues();
848
+ expect(result.base).toBe(10);
849
+ expect(result.doubled).toBe(20);
850
+ expect(result.tripled).toBe(30);
851
+ expect(result.combined).toEqual({ doubled: 20, tripled: 30 });
852
+ });
853
+ scope.stop();
854
+ });
855
+
856
+ test("FragnoVueStore type test - mixed store and non-store fields", () => {
857
+ // Test that non-store fields are passed through unchanged
858
+ const messageAtom: ReadableAtom<string> = atom("test");
859
+ const regularFunction = (x: number) => x * 2;
860
+ const regularObject = { foo: "bar", baz: 123 };
861
+
862
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
863
+ const client = {
864
+ useMixed: cb.createStore({
865
+ message: messageAtom,
866
+ multiply: regularFunction,
867
+ config: regularObject,
868
+ constant: 42,
869
+ }),
870
+ };
871
+
872
+ const { useMixed } = useFragno(client);
873
+
874
+ // Type assertions
875
+ expectTypeOf(useMixed).toExtend<
876
+ () => {
877
+ message: string;
878
+ multiply: (x: number) => number;
879
+ config: { foo: string; baz: number };
880
+ constant: number;
881
+ }
882
+ >();
883
+
884
+ // Runtime test
885
+ const scope = effectScope();
886
+ scope.run(() => {
887
+ const result = useMixed();
888
+ expect(result.message).toBe("test");
889
+ expect(result.multiply(5)).toBe(10);
890
+ expect(result.config).toEqual({ foo: "bar", baz: 123 });
891
+ expect(result.constant).toBe(42);
892
+ });
893
+ scope.stop();
894
+ });
895
+
896
+ test("FragnoVueStore type test - single store vs object with stores", () => {
897
+ // Test that a single store is unwrapped directly
898
+ const singleAtom: ReadableAtom<string> = atom("single");
899
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
900
+
901
+ // Single store case
902
+ const clientSingle = {
903
+ useSingle: cb.createStore(singleAtom),
904
+ };
905
+ const { useSingle } = useFragno(clientSingle);
906
+ expectTypeOf(useSingle).toExtend<() => string>();
907
+
908
+ // Object with stores case
909
+ const clientObject = {
910
+ useObject: cb.createStore({
911
+ value: singleAtom,
912
+ }),
913
+ };
914
+ const { useObject } = useFragno(clientObject);
915
+ expectTypeOf(useObject).toExtend<() => { value: string }>();
916
+
917
+ // Runtime test
918
+ const scope = effectScope();
919
+ scope.run(() => {
920
+ const singleResult = useSingle();
921
+ expect(singleResult).toBe("single");
922
+
923
+ const objectResult = useObject();
924
+ expect(objectResult).toEqual({ value: "single" });
925
+ });
926
+ scope.stop();
927
+ });
928
+
929
+ test("FragnoVueStore type test - complex nested atoms", () => {
930
+ // Test complex nested structures with atoms
931
+ type User = { id: number; name: string; email: string };
932
+ type Settings = { theme: "light" | "dark"; notifications: boolean };
933
+
934
+ const userAtom: ReadableAtom<User> = atom({ id: 1, name: "John", email: "john@example.com" });
935
+ const settingsAtom: ReadableAtom<Settings> = atom({ theme: "light", notifications: true });
936
+ const loadingAtom: ReadableAtom<boolean> = atom(false);
937
+ const errorAtom: ReadableAtom<string | null> = atom(null);
938
+
939
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
940
+ const client = {
941
+ useAppState: cb.createStore({
942
+ user: userAtom,
943
+ settings: settingsAtom,
944
+ loading: loadingAtom,
945
+ error: errorAtom,
946
+ }),
947
+ };
948
+
949
+ const { useAppState } = useFragno(client);
950
+
951
+ // Type assertions for complex nested structure
952
+ expectTypeOf(useAppState).toExtend<
953
+ () => {
954
+ user: User;
955
+ settings: Settings;
956
+ loading: boolean;
957
+ error: string | null;
958
+ }
959
+ >();
960
+
961
+ // Runtime test
962
+ const scope = effectScope();
963
+ scope.run(() => {
964
+ const result = useAppState();
965
+ expect(result.user).toEqual({ id: 1, name: "John", email: "john@example.com" });
966
+ expect(result.settings).toEqual({ theme: "light", notifications: true });
967
+ expect(result.loading).toBe(false);
968
+ expect(result.error).toBeNull();
969
+ });
970
+ scope.stop();
971
+ });
972
+
973
+ test("FragnoVueStore - reactivity with atom updates", async () => {
974
+ // Test that stores are reactive and update when atoms change
975
+ const countAtom = atom(0);
976
+
977
+ const cb = createClientBuilder(defineFragment("test-fragment"), clientConfig, []);
978
+ const client = {
979
+ useCounter: cb.createStore({
980
+ count: countAtom,
981
+ }),
982
+ };
983
+
984
+ const { useCounter } = useFragno(client);
985
+
986
+ const scope = effectScope();
987
+ let result: { count: number } | undefined;
988
+ scope.run(() => {
989
+ result = useCounter();
990
+ expect(result.count).toBe(0);
991
+ });
992
+
993
+ // Update the atom
994
+ countAtom.set(5);
995
+
996
+ // Re-run in scope to get updated value
997
+ scope.run(() => {
998
+ result = useCounter();
999
+ expect(result.count).toBe(5);
1000
+ });
1001
+
1002
+ scope.stop();
1003
+ });
1004
+ });
package/src/client/vue.ts CHANGED
@@ -6,9 +6,12 @@ import type { NonGetHTTPMethod } from "../api/api";
6
6
  import {
7
7
  isGetHook,
8
8
  isMutatorHook,
9
+ isStore,
9
10
  type FragnoClientMutatorData,
10
11
  type FragnoClientHookData,
12
+ type FragnoStoreData,
11
13
  } from "./client";
14
+ import { isReadableAtom } from "../util/nanostores";
12
15
  import type { FragnoClientError } from "./client-error";
13
16
  import type { MaybeExtractPathParamsOrWiden, QueryParamsHint } from "../api/internal/path";
14
17
  import type { InferOr } from "../util/types-util";
@@ -46,6 +49,15 @@ export type FragnoVueMutator<
46
49
  data: Ref<InferOr<TOutputSchema, undefined>>;
47
50
  };
48
51
 
52
+ /**
53
+ * Type helper that unwraps any Store fields of the object into StoreValues
54
+ */
55
+ export type FragnoVueStore<T extends object> = () => T extends Store<infer TStore>
56
+ ? StoreValue<TStore>
57
+ : {
58
+ [K in keyof T]: T[K] extends Store ? StoreValue<T[K]> : T[K];
59
+ };
60
+
49
61
  /**
50
62
  * Converts a Vue Ref to a NanoStore Atom.
51
63
  *
@@ -181,6 +193,33 @@ function createVueMutator<
181
193
  };
182
194
  }
183
195
 
196
+ // Helper function to create a Vue composable from a store
197
+ function createVueStore<const T extends object>(hook: FragnoStoreData<T>): FragnoVueStore<T> {
198
+ if (isReadableAtom(hook.obj)) {
199
+ return (() => useStore(hook.obj as Store).value) as FragnoVueStore<T>;
200
+ }
201
+
202
+ return (() => {
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ const result: any = {};
205
+
206
+ for (const key in hook.obj) {
207
+ if (!Object.prototype.hasOwnProperty.call(hook.obj, key)) {
208
+ continue;
209
+ }
210
+
211
+ const value = hook.obj[key];
212
+ if (isReadableAtom(value)) {
213
+ result[key] = useStore(value).value;
214
+ } else {
215
+ result[key] = value;
216
+ }
217
+ }
218
+
219
+ return result;
220
+ }) as FragnoVueStore<T>;
221
+ }
222
+
184
223
  export function useFragno<T extends Record<string, unknown>>(
185
224
  clientObj: T,
186
225
  ): {
@@ -201,7 +240,9 @@ export function useFragno<T extends Record<string, unknown>>(
201
240
  infer TQueryParameters
202
241
  >
203
242
  ? FragnoVueMutator<M, TPath, TInputSchema, TOutputSchema, TErrorCode, TQueryParameters>
204
- : T[K];
243
+ : T[K] extends FragnoStoreData<infer TStoreObj>
244
+ ? FragnoVueStore<TStoreObj>
245
+ : T[K];
205
246
  } {
206
247
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
207
248
  const result = {} as any;
@@ -216,6 +257,8 @@ export function useFragno<T extends Record<string, unknown>>(
216
257
  result[key] = createVueHook(hook);
217
258
  } else if (isMutatorHook(hook)) {
218
259
  result[key] = createVueMutator(hook);
260
+ } else if (isStore(hook)) {
261
+ result[key] = createVueStore(hook);
219
262
  } else {
220
263
  // Pass through non-hook values unchanged
221
264
  result[key] = hook;
@@ -0,0 +1,35 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ export type FragnoCoreTraceEvent =
4
+ | {
5
+ type: "route-input";
6
+ method: string;
7
+ path: string;
8
+ pathParams: Record<string, string>;
9
+ queryParams: [string, string][];
10
+ headers: [string, string][];
11
+ body: unknown;
12
+ }
13
+ | {
14
+ type: "middleware-decision";
15
+ method: string;
16
+ path: string;
17
+ outcome: "allow" | "deny";
18
+ status?: number;
19
+ };
20
+
21
+ export type FragnoTraceRecorder = (event: FragnoCoreTraceEvent) => void;
22
+
23
+ const traceStorage = new AsyncLocalStorage<FragnoTraceRecorder>();
24
+
25
+ export const runWithTraceRecorder = <T>(recorder: FragnoTraceRecorder, callback: () => T): T =>
26
+ traceStorage.run(recorder, callback);
27
+
28
+ export const getTraceRecorder = (): FragnoTraceRecorder | undefined => traceStorage.getStore();
29
+
30
+ export const recordTraceEvent = (event: FragnoCoreTraceEvent): void => {
31
+ const recorder = traceStorage.getStore();
32
+ if (recorder) {
33
+ recorder(event);
34
+ }
35
+ };