@fragno-dev/core 0.1.1 → 0.1.3

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 +41 -32
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE.md +16 -0
  4. package/dist/api/api.d.ts +1 -1
  5. package/dist/api/fragment-builder.d.ts +2 -2
  6. package/dist/api/fragment-instantiation.d.ts +2 -2
  7. package/dist/api/fragment-instantiation.js +3 -2
  8. package/dist/{api-Dcr4_-3g.d.ts → api-BX90b4-D.d.ts} +92 -6
  9. package/dist/api-BX90b4-D.d.ts.map +1 -0
  10. package/dist/client/client.d.ts +2 -2
  11. package/dist/client/client.js +4 -3
  12. package/dist/client/client.svelte.d.ts +2 -2
  13. package/dist/client/client.svelte.js +4 -3
  14. package/dist/client/client.svelte.js.map +1 -1
  15. package/dist/client/react.d.ts +2 -2
  16. package/dist/client/react.d.ts.map +1 -1
  17. package/dist/client/react.js +4 -3
  18. package/dist/client/react.js.map +1 -1
  19. package/dist/client/solid.d.ts +2 -2
  20. package/dist/client/solid.js +4 -3
  21. package/dist/client/solid.js.map +1 -1
  22. package/dist/client/vanilla.d.ts +2 -2
  23. package/dist/client/vanilla.js +4 -3
  24. package/dist/client/vanilla.js.map +1 -1
  25. package/dist/client/vue.d.ts +2 -2
  26. package/dist/client/vue.js +4 -3
  27. package/dist/client/vue.js.map +1 -1
  28. package/dist/{client-D5ORmjBP.js → client-C6LChM0Y.js} +4 -3
  29. package/dist/client-C6LChM0Y.js.map +1 -0
  30. package/dist/{fragment-builder-D6-oLYnH.d.ts → fragment-builder-BZr2JkuW.d.ts} +51 -38
  31. package/dist/fragment-builder-BZr2JkuW.d.ts.map +1 -0
  32. package/dist/fragment-builder-DOnCVBqc.js.map +1 -1
  33. package/dist/{fragment-instantiation-f4AhwQss.js → fragment-instantiation-DMw8OKMC.js} +137 -11
  34. package/dist/fragment-instantiation-DMw8OKMC.js.map +1 -0
  35. package/dist/integrations/react-ssr.js +1 -1
  36. package/dist/mod.d.ts +2 -2
  37. package/dist/mod.js +3 -2
  38. package/dist/route-CTxjMtGZ.js +10 -0
  39. package/dist/route-CTxjMtGZ.js.map +1 -0
  40. package/dist/{route-B4RbOWjd.js → route-D1MZR6JL.js} +22 -22
  41. package/dist/route-D1MZR6JL.js.map +1 -0
  42. package/dist/{ssr-CamRrMc0.js → ssr-BByDVfFD.js} +1 -1
  43. package/dist/{ssr-CamRrMc0.js.map → ssr-BByDVfFD.js.map} +1 -1
  44. package/dist/test/test.d.ts +112 -0
  45. package/dist/test/test.d.ts.map +1 -0
  46. package/dist/test/test.js +155 -0
  47. package/dist/test/test.js.map +1 -0
  48. package/package.json +18 -24
  49. package/src/api/fragment-builder.ts +0 -1
  50. package/src/api/fragment-instantiation.ts +16 -3
  51. package/src/api/mutable-request-state.ts +107 -0
  52. package/src/api/request-input-context.test.ts +51 -0
  53. package/src/api/request-input-context.ts +20 -13
  54. package/src/api/request-middleware.test.ts +88 -2
  55. package/src/api/request-middleware.ts +28 -6
  56. package/src/api/request-output-context.test.ts +6 -2
  57. package/src/api/request-output-context.ts +15 -9
  58. package/src/client/component.test.svelte +2 -0
  59. package/src/client/internal/ndjson-streaming.ts +6 -2
  60. package/src/client/react.ts +3 -1
  61. package/src/test/test.test.ts +449 -0
  62. package/src/test/test.ts +379 -0
  63. package/src/util/async.test.ts +6 -2
  64. package/tsdown.config.ts +1 -0
  65. package/.turbo/turbo-test.log +0 -297
  66. package/.turbo/turbo-types$colon$check.log +0 -1
  67. package/dist/api-Dcr4_-3g.d.ts.map +0 -1
  68. package/dist/client-D5ORmjBP.js.map +0 -1
  69. package/dist/fragment-builder-D6-oLYnH.d.ts.map +0 -1
  70. package/dist/fragment-instantiation-f4AhwQss.js.map +0 -1
  71. package/dist/route-B4RbOWjd.js.map +0 -1
@@ -0,0 +1,379 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import type { FragmentDefinition } from "../api/fragment-builder";
3
+ import type { FragnoRouteConfig, HTTPMethod } from "../api/api";
4
+ import type { ExtractPathParams } from "../api/internal/path";
5
+ import { RequestInputContext } from "../api/request-input-context";
6
+ import { RequestOutputContext } from "../api/request-output-context";
7
+ import type { AnyRouteOrFactory, FlattenRouteFactories } from "../api/route";
8
+ import { resolveRouteFactories } from "../api/route";
9
+ import type { FragnoPublicConfig } from "../api/fragment-instantiation";
10
+
11
+ /**
12
+ * Discriminated union representing all possible test response types
13
+ */
14
+ export type TestResponse<T> =
15
+ | {
16
+ type: "empty";
17
+ status: number;
18
+ headers: Headers;
19
+ }
20
+ | {
21
+ type: "error";
22
+ status: number;
23
+ headers: Headers;
24
+ error: { message: string; code: string };
25
+ }
26
+ | {
27
+ type: "json";
28
+ status: number;
29
+ headers: Headers;
30
+ data: T;
31
+ }
32
+ | {
33
+ type: "jsonStream";
34
+ status: number;
35
+ headers: Headers;
36
+ stream: AsyncGenerator<T>;
37
+ };
38
+
39
+ /**
40
+ * Parse a Response object into a TestResponse discriminated union
41
+ */
42
+ async function parseResponse<T>(response: Response): Promise<TestResponse<T>> {
43
+ const status = response.status;
44
+ const headers = response.headers;
45
+ const contentType = headers.get("content-type") || "";
46
+
47
+ // Check for streaming response
48
+ if (contentType.includes("application/x-ndjson")) {
49
+ return {
50
+ type: "jsonStream",
51
+ status,
52
+ headers,
53
+ stream: parseNDJSONStream<T>(response),
54
+ };
55
+ }
56
+
57
+ // Parse JSON body
58
+ const text = await response.text();
59
+
60
+ // Empty response
61
+ if (!text || text === "null") {
62
+ return {
63
+ type: "empty",
64
+ status,
65
+ headers,
66
+ };
67
+ }
68
+
69
+ const data = JSON.parse(text);
70
+
71
+ // Error response (has message and code)
72
+ if (data && typeof data === "object" && "message" in data && "code" in data) {
73
+ return {
74
+ type: "error",
75
+ status,
76
+ headers,
77
+ error: { message: data.message, code: data.code },
78
+ };
79
+ }
80
+
81
+ // JSON response
82
+ return {
83
+ type: "json",
84
+ status,
85
+ headers,
86
+ data: data as T,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Parse an NDJSON stream into an async generator
92
+ */
93
+ async function* parseNDJSONStream<T>(response: Response): AsyncGenerator<T> {
94
+ if (!response.body) {
95
+ return;
96
+ }
97
+
98
+ const reader = response.body.getReader();
99
+ const decoder = new TextDecoder();
100
+ let buffer = "";
101
+
102
+ try {
103
+ while (true) {
104
+ const { done, value } = await reader.read();
105
+
106
+ if (done) {
107
+ break;
108
+ }
109
+
110
+ buffer += decoder.decode(value, { stream: true });
111
+ const lines = buffer.split("\n");
112
+
113
+ // Keep the last incomplete line in the buffer
114
+ buffer = lines.pop() || "";
115
+
116
+ for (const line of lines) {
117
+ if (line.trim()) {
118
+ yield JSON.parse(line) as T;
119
+ }
120
+ }
121
+ }
122
+
123
+ // Process any remaining data in the buffer
124
+ if (buffer.trim()) {
125
+ yield JSON.parse(buffer) as T;
126
+ }
127
+ } finally {
128
+ reader.releaseLock();
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Options for creating a test fragment
134
+ */
135
+ export interface CreateFragmentForTestOptions<
136
+ TConfig,
137
+ TDeps,
138
+ TServices,
139
+ TAdditionalContext extends Record<string, unknown>,
140
+ TOptions extends FragnoPublicConfig,
141
+ > {
142
+ config: TConfig;
143
+ options?: Partial<TOptions>;
144
+ deps?: Partial<TDeps>;
145
+ services?: Partial<TServices>;
146
+ additionalContext?: Partial<TAdditionalContext>;
147
+ }
148
+
149
+ /**
150
+ * Options for calling a route handler
151
+ */
152
+ export interface RouteHandlerInputOptions<
153
+ TPath extends string,
154
+ TInputSchema extends StandardSchemaV1 | undefined,
155
+ > {
156
+ pathParams?: ExtractPathParams<TPath>;
157
+ body?: TInputSchema extends StandardSchemaV1 ? StandardSchemaV1.InferInput<TInputSchema> : never;
158
+ query?: URLSearchParams | Record<string, string>;
159
+ headers?: Headers | Record<string, string>;
160
+ }
161
+
162
+ /**
163
+ * Options for overriding config/deps/services when initializing routes
164
+ */
165
+ export interface InitRoutesOverrides<TConfig, TDeps, TServices> {
166
+ config?: Partial<TConfig>;
167
+ deps?: Partial<TDeps>;
168
+ services?: Partial<TServices>;
169
+ }
170
+
171
+ /**
172
+ * Fragment test instance with type-safe handler method
173
+ */
174
+ export interface FragmentForTest<
175
+ TConfig,
176
+ TDeps,
177
+ TServices,
178
+ TAdditionalContext extends Record<string, unknown>,
179
+ TOptions extends FragnoPublicConfig,
180
+ > {
181
+ config: TConfig;
182
+ deps: TDeps;
183
+ services: TServices;
184
+ additionalContext: TAdditionalContext & TOptions;
185
+ handler: <
186
+ TMethod extends HTTPMethod,
187
+ TPath extends string,
188
+ TInputSchema extends StandardSchemaV1 | undefined,
189
+ TOutputSchema extends StandardSchemaV1 | undefined,
190
+ TErrorCode extends string,
191
+ TQueryParameters extends string,
192
+ >(
193
+ route: FragnoRouteConfig<
194
+ TMethod,
195
+ TPath,
196
+ TInputSchema,
197
+ TOutputSchema,
198
+ TErrorCode,
199
+ TQueryParameters
200
+ >,
201
+ inputOptions?: RouteHandlerInputOptions<TPath, TInputSchema>,
202
+ ) => Promise<
203
+ TestResponse<
204
+ TOutputSchema extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<TOutputSchema> : unknown
205
+ >
206
+ >;
207
+ initRoutes: <const TRoutesOrFactories extends readonly AnyRouteOrFactory[]>(
208
+ routesOrFactories: TRoutesOrFactories,
209
+ overrides?: InitRoutesOverrides<TConfig, TDeps, TServices>,
210
+ ) => FlattenRouteFactories<TRoutesOrFactories>;
211
+ }
212
+
213
+ /**
214
+ * Create a fragment instance for testing with optional dependency and service overrides
215
+ *
216
+ * @param fragmentBuilder - The fragment builder with definition and required options
217
+ * @param options - Configuration and optional overrides for deps/services
218
+ * @returns A fragment test instance with a type-safe handler method
219
+ *
220
+ * @example
221
+ * ```typescript
222
+ * const fragment = createFragmentForTest(chatnoDefinition, {
223
+ * config: { openaiApiKey: "test-key" },
224
+ * options: { mountRoute: "/api/chatno" },
225
+ * services: {
226
+ * generateStreamMessages: mockGenerator
227
+ * }
228
+ * });
229
+ *
230
+ * // Initialize routes with fragment's config/deps/services
231
+ * const [route] = fragment.initRoutes(routes);
232
+ *
233
+ * // Or override specific config/deps/services for certain routes
234
+ * const [route] = fragment.initRoutes(routes, {
235
+ * services: { mockService: mockImplementation }
236
+ * });
237
+ *
238
+ * const response = await fragment.handler(route, {
239
+ * pathParams: { id: "123" },
240
+ * body: { message: "Hello" }
241
+ * });
242
+ *
243
+ * if (response.type === 'json') {
244
+ * expect(response.data).toEqual({...});
245
+ * }
246
+ * ```
247
+ */
248
+ export function createFragmentForTest<
249
+ TConfig,
250
+ TDeps,
251
+ TServices extends Record<string, unknown>,
252
+ TAdditionalContext extends Record<string, unknown>,
253
+ TOptions extends FragnoPublicConfig,
254
+ >(
255
+ fragmentBuilder: {
256
+ definition: FragmentDefinition<TConfig, TDeps, TServices, TAdditionalContext>;
257
+ $requiredOptions: TOptions;
258
+ },
259
+ options: CreateFragmentForTestOptions<TConfig, TDeps, TServices, TAdditionalContext, TOptions>,
260
+ ): FragmentForTest<TConfig, TDeps, TServices, TAdditionalContext, TOptions> {
261
+ const {
262
+ config,
263
+ options: fragmentOptions = {} as TOptions,
264
+ deps: depsOverride,
265
+ services: servicesOverride,
266
+ additionalContext: additionalContextOverride,
267
+ } = options;
268
+
269
+ // Create deps from definition or use empty object
270
+ const definition = fragmentBuilder.definition;
271
+ const baseDeps = definition.dependencies
272
+ ? definition.dependencies(config, fragmentOptions)
273
+ : ({} as TDeps);
274
+
275
+ // Merge deps with overrides
276
+ const deps = { ...baseDeps, ...depsOverride } as TDeps;
277
+
278
+ // Create services from definition or use empty object
279
+ const baseServices = definition.services
280
+ ? definition.services(config, fragmentOptions, deps)
281
+ : ({} as TServices);
282
+
283
+ // Merge services with overrides
284
+ const services = { ...baseServices, ...servicesOverride } as TServices;
285
+
286
+ // Merge additional context with options
287
+ const additionalContext = {
288
+ ...definition.additionalContext,
289
+ ...fragmentOptions,
290
+ ...additionalContextOverride,
291
+ } as TAdditionalContext & TOptions;
292
+
293
+ return {
294
+ config,
295
+ deps,
296
+ services,
297
+ additionalContext,
298
+ initRoutes: <const TRoutesOrFactories extends readonly AnyRouteOrFactory[]>(
299
+ routesOrFactories: TRoutesOrFactories,
300
+ overrides?: InitRoutesOverrides<TConfig, TDeps, TServices>,
301
+ ): FlattenRouteFactories<TRoutesOrFactories> => {
302
+ // Merge overrides with base config/deps/services
303
+ const routeContext = {
304
+ config: { ...config, ...overrides?.config } as TConfig,
305
+ deps: { ...deps, ...overrides?.deps } as TDeps,
306
+ services: { ...services, ...overrides?.services } as TServices,
307
+ };
308
+ return resolveRouteFactories(routeContext, routesOrFactories);
309
+ },
310
+ handler: async <
311
+ TMethod extends HTTPMethod,
312
+ TPath extends string,
313
+ TInputSchema extends StandardSchemaV1 | undefined,
314
+ TOutputSchema extends StandardSchemaV1 | undefined,
315
+ TErrorCode extends string,
316
+ TQueryParameters extends string,
317
+ >(
318
+ route: FragnoRouteConfig<
319
+ TMethod,
320
+ TPath,
321
+ TInputSchema,
322
+ TOutputSchema,
323
+ TErrorCode,
324
+ TQueryParameters
325
+ >,
326
+ inputOptions?: RouteHandlerInputOptions<TPath, TInputSchema>,
327
+ ): Promise<
328
+ TestResponse<
329
+ TOutputSchema extends StandardSchemaV1
330
+ ? StandardSchemaV1.InferOutput<TOutputSchema>
331
+ : unknown
332
+ >
333
+ > => {
334
+ const {
335
+ pathParams = {} as ExtractPathParams<TPath>,
336
+ body,
337
+ query,
338
+ headers,
339
+ } = inputOptions || {};
340
+
341
+ // Convert query to URLSearchParams if needed
342
+ const searchParams =
343
+ query instanceof URLSearchParams
344
+ ? query
345
+ : query
346
+ ? new URLSearchParams(query)
347
+ : new URLSearchParams();
348
+
349
+ // Convert headers to Headers if needed
350
+ const requestHeaders =
351
+ headers instanceof Headers ? headers : headers ? new Headers(headers) : new Headers();
352
+
353
+ // Construct RequestInputContext
354
+ const inputContext = new RequestInputContext<TPath, TInputSchema>({
355
+ path: route.path,
356
+ method: route.method,
357
+ pathParams,
358
+ searchParams,
359
+ headers: requestHeaders,
360
+ body,
361
+ inputSchema: route.inputSchema,
362
+ shouldValidateInput: false, // Skip validation in tests
363
+ });
364
+
365
+ // Construct RequestOutputContext
366
+ const outputContext = new RequestOutputContext(route.outputSchema);
367
+
368
+ // Call the route handler
369
+ const response = await route.handler(inputContext, outputContext);
370
+
371
+ // Parse and return the response
372
+ return parseResponse<
373
+ TOutputSchema extends StandardSchemaV1
374
+ ? StandardSchemaV1.InferOutput<TOutputSchema>
375
+ : unknown
376
+ >(response);
377
+ },
378
+ };
379
+ }
@@ -20,7 +20,9 @@ describe("createAsyncIteratorFromCallback", () => {
20
20
  const values: string[] = [];
21
21
  for await (const value of iterator) {
22
22
  values.push(value);
23
- if (values.length === 3) break;
23
+ if (values.length === 3) {
24
+ break;
25
+ }
24
26
  }
25
27
  return values;
26
28
  })();
@@ -53,7 +55,9 @@ describe("createAsyncIteratorFromCallback", () => {
53
55
  const values: string[] = [];
54
56
  for await (const value of iterator) {
55
57
  values.push(value);
56
- if (values.length === 2) break; // Break after 2 values
58
+ if (values.length === 2) {
59
+ break;
60
+ } // Break after 2 values
57
61
  }
58
62
  return values;
59
63
  })();
package/tsdown.config.ts CHANGED
@@ -16,6 +16,7 @@ export default defineConfig({
16
16
  "./src/integrations/next-js.ts",
17
17
  "./src/integrations/react-ssr.ts",
18
18
  "./src/integrations/svelte-kit.ts",
19
+ "./src/test/test.ts",
19
20
  ],
20
21
  dts: true,
21
22
  // TODO: This should be true, but we need some additional type exports in chatno/src/index.ts