@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.
- package/.turbo/turbo-build.log +50 -42
- package/CHANGELOG.md +51 -0
- package/dist/api/api.d.ts +19 -1
- package/dist/api/api.d.ts.map +1 -1
- package/dist/api/api.js.map +1 -1
- package/dist/api/fragment-definition-builder.d.ts +17 -7
- package/dist/api/fragment-definition-builder.d.ts.map +1 -1
- package/dist/api/fragment-definition-builder.js +3 -2
- package/dist/api/fragment-definition-builder.js.map +1 -1
- package/dist/api/fragment-instantiator.d.ts +23 -16
- package/dist/api/fragment-instantiator.d.ts.map +1 -1
- package/dist/api/fragment-instantiator.js +163 -19
- package/dist/api/fragment-instantiator.js.map +1 -1
- package/dist/api/request-input-context.d.ts +57 -1
- package/dist/api/request-input-context.d.ts.map +1 -1
- package/dist/api/request-input-context.js +67 -0
- package/dist/api/request-input-context.js.map +1 -1
- package/dist/api/request-middleware.d.ts +1 -1
- package/dist/api/request-middleware.d.ts.map +1 -1
- package/dist/api/request-middleware.js.map +1 -1
- package/dist/api/route.d.ts +7 -7
- package/dist/api/route.d.ts.map +1 -1
- package/dist/api/route.js.map +1 -1
- package/dist/client/client.d.ts +4 -3
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +103 -7
- package/dist/client/client.js.map +1 -1
- package/dist/client/vue.d.ts +7 -3
- package/dist/client/vue.d.ts.map +1 -1
- package/dist/client/vue.js +16 -1
- package/dist/client/vue.js.map +1 -1
- package/dist/internal/trace-context.d.ts +23 -0
- package/dist/internal/trace-context.d.ts.map +1 -0
- package/dist/internal/trace-context.js +14 -0
- package/dist/internal/trace-context.js.map +1 -0
- package/dist/mod-client.d.ts +3 -17
- package/dist/mod-client.d.ts.map +1 -1
- package/dist/mod-client.js +20 -10
- package/dist/mod-client.js.map +1 -1
- package/dist/mod.d.ts +3 -2
- package/dist/mod.js +2 -1
- package/dist/runtime.d.ts +15 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +33 -0
- package/dist/runtime.js.map +1 -0
- package/dist/test/test.d.ts +2 -2
- package/dist/test/test.d.ts.map +1 -1
- package/dist/test/test.js.map +1 -1
- package/package.json +23 -17
- package/src/api/api.ts +22 -0
- package/src/api/fragment-definition-builder.ts +36 -17
- package/src/api/fragment-instantiator.test.ts +286 -0
- package/src/api/fragment-instantiator.ts +338 -31
- package/src/api/internal/path-runtime.test.ts +7 -0
- package/src/api/request-input-context.test.ts +152 -0
- package/src/api/request-input-context.ts +85 -0
- package/src/api/request-middleware.test.ts +47 -1
- package/src/api/request-middleware.ts +1 -1
- package/src/api/route.ts +7 -2
- package/src/client/client.test.ts +195 -0
- package/src/client/client.ts +185 -10
- package/src/client/vue.test.ts +253 -3
- package/src/client/vue.ts +44 -1
- package/src/internal/trace-context.ts +35 -0
- package/src/mod-client.ts +51 -7
- package/src/mod.ts +6 -1
- package/src/runtime.ts +48 -0
- package/src/test/test.ts +13 -4
- package/tsdown.config.ts +1 -0
package/src/client/client.ts
CHANGED
|
@@ -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(
|
|
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<
|
|
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(
|
|
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(
|
|
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
|
|
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:
|
|
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 =
|
|
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,
|
package/src/client/vue.test.ts
CHANGED
|
@@ -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
|
+
};
|